From 2ebab681131bc849734b516c13a5ab73c16f32dc Mon Sep 17 00:00:00 2001 From: Ranganath Gunawardane Date: Mon, 6 Jul 2026 09:46:15 +1000 Subject: [PATCH 01/11] feat: send OS context headers on update check requests The updater now sends X-OS-Type, X-OS-Version and X-OS-Arch with every update check, giving the update server the context to select a build compatible with this host. X-OS-Type and X-OS-Arch carry runtime.GOOS and runtime.GOARCH verbatim, so no OS/arch mapping code ships in the client; the server owns the vocabulary. X-OS-Version is best effort and omitted if detection fails: - Windows: RtlGetVersion (major.minor.build, unaffected by compatibility shims) - macOS: sysctl kern.osproductversion - Linux: kernel release via uname --- updater/update/osinfo.go | 32 ++++++++++++++++++++++ updater/update/osinfo_test.go | 41 +++++++++++++++++++++++++++++ updater/update/osversion_darwin.go | 18 +++++++++++++ updater/update/osversion_linux.go | 22 ++++++++++++++++ updater/update/osversion_other.go | 17 ++++++++++++ updater/update/osversion_windows.go | 25 ++++++++++++++++++ updater/update/update.go | 1 + 7 files changed, 156 insertions(+) create mode 100644 updater/update/osinfo.go create mode 100644 updater/update/osinfo_test.go create mode 100644 updater/update/osversion_darwin.go create mode 100644 updater/update/osversion_linux.go create mode 100644 updater/update/osversion_other.go create mode 100644 updater/update/osversion_windows.go diff --git a/updater/update/osinfo.go b/updater/update/osinfo.go new file mode 100644 index 0000000..9e8addd --- /dev/null +++ b/updater/update/osinfo.go @@ -0,0 +1,32 @@ +// SILVER - Service Wrapper +// Auto Updater +// +// Copyright (c) 2014-2021 PaperCut Software http://www.papercut.com/ +// Use of this source code is governed by an MIT or GPL Version 2 license. +// See the project's LICENSE file for more information. +// + +package update + +import ( + "net/http" + "runtime" +) + +const ( + headerOSTypeKey string = "X-OS-Type" + headerOSVersionKey string = "X-OS-Version" + headerOSArchKey string = "X-OS-Arch" +) + +// addOSContextToRequestHeader adds OS identity headers used by the update +// server to select a build compatible with this host. GOOS and GOARCH are +// sent verbatim so no OS/arch mapping code ships in the client. +func addOSContextToRequestHeader(req *http.Request) { + req.Header.Set(headerOSTypeKey, runtime.GOOS) + req.Header.Set(headerOSArchKey, runtime.GOARCH) + // Best effort: an unknown OS version is better than a failed update check. + if version, err := osVersion(); err == nil && version != "" { + req.Header.Set(headerOSVersionKey, version) + } +} diff --git a/updater/update/osinfo_test.go b/updater/update/osinfo_test.go new file mode 100644 index 0000000..70ca0ed --- /dev/null +++ b/updater/update/osinfo_test.go @@ -0,0 +1,41 @@ +// SILVER - Service Wrapper +// Auto Updater +// +// Copyright (c) 2014-2021 PaperCut Software http://www.papercut.com/ +// Use of this source code is governed by an MIT or GPL Version 2 license. +// See the project's LICENSE file for more information. +// + +package update + +import ( + "net/http" + "regexp" + "runtime" + "testing" +) + +func TestAddOSContextToRequestHeader(t *testing.T) { + req, err := http.NewRequest("GET", "https://example.com/check-update/test", nil) + if err != nil { + t.Fatal(err) + } + + addOSContextToRequestHeader(req) + + if got := req.Header.Get(headerOSTypeKey); got != runtime.GOOS { + t.Errorf("%s = %q, want %q", headerOSTypeKey, got, runtime.GOOS) + } + if got := req.Header.Get(headerOSArchKey); got != runtime.GOARCH { + t.Errorf("%s = %q, want %q", headerOSArchKey, got, runtime.GOARCH) + } + + // windows, darwin and linux all report a dot-separated numeric version. + switch runtime.GOOS { + case "windows", "darwin", "linux": + got := req.Header.Get(headerOSVersionKey) + if matched := regexp.MustCompile(`^\d+(\.\d+)+`).MatchString(got); !matched { + t.Errorf("%s = %q, want a dot-separated numeric version", headerOSVersionKey, got) + } + } +} diff --git a/updater/update/osversion_darwin.go b/updater/update/osversion_darwin.go new file mode 100644 index 0000000..236ac11 --- /dev/null +++ b/updater/update/osversion_darwin.go @@ -0,0 +1,18 @@ +// SILVER - Service Wrapper +// Auto Updater +// +// Copyright (c) 2014-2021 PaperCut Software http://www.papercut.com/ +// Use of this source code is governed by an MIT or GPL Version 2 license. +// See the project's LICENSE file for more information. +// + +//go:build darwin + +package update + +import "syscall" + +// osVersion returns the macOS product version (e.g. "14.5"). +func osVersion() (string, error) { + return syscall.Sysctl("kern.osproductversion") +} diff --git a/updater/update/osversion_linux.go b/updater/update/osversion_linux.go new file mode 100644 index 0000000..e68a36c --- /dev/null +++ b/updater/update/osversion_linux.go @@ -0,0 +1,22 @@ +// SILVER - Service Wrapper +// Auto Updater +// +// Copyright (c) 2014-2021 PaperCut Software http://www.papercut.com/ +// Use of this source code is governed by an MIT or GPL Version 2 license. +// See the project's LICENSE file for more information. +// + +//go:build linux + +package update + +import "golang.org/x/sys/unix" + +// osVersion returns the Linux kernel release (e.g. "6.17.24") via uname. +func osVersion() (string, error) { + var uts unix.Utsname + if err := unix.Uname(&uts); err != nil { + return "", err + } + return unix.ByteSliceToString(uts.Release[:]), nil +} diff --git a/updater/update/osversion_other.go b/updater/update/osversion_other.go new file mode 100644 index 0000000..fb188a8 --- /dev/null +++ b/updater/update/osversion_other.go @@ -0,0 +1,17 @@ +// SILVER - Service Wrapper +// Auto Updater +// +// Copyright (c) 2014-2021 PaperCut Software http://www.papercut.com/ +// Use of this source code is governed by an MIT or GPL Version 2 license. +// See the project's LICENSE file for more information. +// + +//go:build !windows && !darwin && !linux + +package update + +// osVersion is unknown on unsupported platforms; the X-OS-Version header is +// simply omitted. +func osVersion() (string, error) { + return "", nil +} diff --git a/updater/update/osversion_windows.go b/updater/update/osversion_windows.go new file mode 100644 index 0000000..ad4d09e --- /dev/null +++ b/updater/update/osversion_windows.go @@ -0,0 +1,25 @@ +// SILVER - Service Wrapper +// Auto Updater +// +// Copyright (c) 2014-2021 PaperCut Software http://www.papercut.com/ +// Use of this source code is governed by an MIT or GPL Version 2 license. +// See the project's LICENSE file for more information. +// + +//go:build windows + +package update + +import ( + "fmt" + + "golang.org/x/sys/windows" +) + +// osVersion returns the Windows version as major.minor.build (e.g. +// "10.0.19041"). RtlGetVersion reports the true OS version, unaffected by +// the compatibility shims that skew GetVersionEx. +func osVersion() (string, error) { + info := windows.RtlGetVersion() + return fmt.Sprintf("%d.%d.%d", info.MajorVersion, info.MinorVersion, info.BuildNumber), nil +} diff --git a/updater/update/update.go b/updater/update/update.go index 46f7f33..d05bb79 100644 --- a/updater/update/update.go +++ b/updater/update/update.go @@ -41,6 +41,7 @@ func Check(updateURL string, currentVer string, publicKey string) (*UpgradeInfo, } req.Header.Set("User-Agent", "Update Check") addIDProfileToRequestHeader(req) + addOSContextToRequestHeader(req) res, err := client.Do(req) if err != nil { From 8347de38555a2699b306e3759ffbc05026b286be Mon Sep 17 00:00:00 2001 From: Ranganath Gunawardane Date: Mon, 6 Jul 2026 16:14:10 +1000 Subject: [PATCH 02/11] feat: send X-OS-Native-Arch header on update check requests X-OS-Arch reports runtime.GOARCH, the architecture of the running binary, which under emulation differs from the host: an x64 build under Rosetta 2 or Windows-on-ARM reports amd64 on an arm64 machine. X-OS-Native-Arch reports the host architecture so the update server can distinguish hosts that could run a native build from hosts that cannot (e.g. genuine Intel Macs vs M1 Macs still on the x64 build). Detection is best effort with runtime.GOARCH as the fallback: - macOS: sysctl.proc_translated (1 under Rosetta 2) - Windows: IsWow64Process2 native machine (Windows 10 1511+; older hosts cannot be ARM64 machines, so the fallback is correct there) - others: assumed equal to the binary architecture --- updater/update/osinfo.go | 10 +++++-- updater/update/osinfo_test.go | 7 +++++ updater/update/osnativearch_darwin.go | 27 +++++++++++++++++ updater/update/osnativearch_other.go | 20 +++++++++++++ updater/update/osnativearch_windows.go | 40 ++++++++++++++++++++++++++ 5 files changed, 101 insertions(+), 3 deletions(-) create mode 100644 updater/update/osnativearch_darwin.go create mode 100644 updater/update/osnativearch_other.go create mode 100644 updater/update/osnativearch_windows.go diff --git a/updater/update/osinfo.go b/updater/update/osinfo.go index 9e8addd..83db8db 100644 --- a/updater/update/osinfo.go +++ b/updater/update/osinfo.go @@ -14,9 +14,10 @@ import ( ) const ( - headerOSTypeKey string = "X-OS-Type" - headerOSVersionKey string = "X-OS-Version" - headerOSArchKey string = "X-OS-Arch" + headerOSTypeKey string = "X-OS-Type" + headerOSVersionKey string = "X-OS-Version" + headerOSArchKey string = "X-OS-Arch" + headerOSNativeArchKey string = "X-OS-Native-Arch" ) // addOSContextToRequestHeader adds OS identity headers used by the update @@ -25,6 +26,9 @@ const ( func addOSContextToRequestHeader(req *http.Request) { req.Header.Set(headerOSTypeKey, runtime.GOOS) req.Header.Set(headerOSArchKey, runtime.GOARCH) + // Host architecture; differs from X-OS-Arch when running under + // emulation (Rosetta 2, Windows-on-ARM). + req.Header.Set(headerOSNativeArchKey, nativeArch()) // Best effort: an unknown OS version is better than a failed update check. if version, err := osVersion(); err == nil && version != "" { req.Header.Set(headerOSVersionKey, version) diff --git a/updater/update/osinfo_test.go b/updater/update/osinfo_test.go index 70ca0ed..2b999ad 100644 --- a/updater/update/osinfo_test.go +++ b/updater/update/osinfo_test.go @@ -30,6 +30,13 @@ func TestAddOSContextToRequestHeader(t *testing.T) { t.Errorf("%s = %q, want %q", headerOSArchKey, got, runtime.GOARCH) } + // Native arch is the binary arch except under emulation, where it must + // be arm64 (Rosetta 2 and Windows-on-ARM both emulate on arm64 hosts). + nativeGot := req.Header.Get(headerOSNativeArchKey) + if nativeGot != runtime.GOARCH && nativeGot != "arm64" { + t.Errorf("%s = %q, want %q or \"arm64\"", headerOSNativeArchKey, nativeGot, runtime.GOARCH) + } + // windows, darwin and linux all report a dot-separated numeric version. switch runtime.GOOS { case "windows", "darwin", "linux": diff --git a/updater/update/osnativearch_darwin.go b/updater/update/osnativearch_darwin.go new file mode 100644 index 0000000..db42a15 --- /dev/null +++ b/updater/update/osnativearch_darwin.go @@ -0,0 +1,27 @@ +// SILVER - Service Wrapper +// Auto Updater +// +// Copyright (c) 2014-2021 PaperCut Software http://www.papercut.com/ +// Use of this source code is governed by an MIT or GPL Version 2 license. +// See the project's LICENSE file for more information. +// + +//go:build darwin + +package update + +import ( + "runtime" + "syscall" +) + +// nativeArch returns the host CPU architecture, which differs from +// runtime.GOARCH when an x64 binary runs under Rosetta 2 translation. +// sysctl.proc_translated is 1 for translated processes; the sysctl does +// not exist on Intel Macs (Sysctl returns an error). +func nativeArch() string { + if translated, err := syscall.SysctlUint32("sysctl.proc_translated"); err == nil && translated == 1 { + return "arm64" + } + return runtime.GOARCH +} diff --git a/updater/update/osnativearch_other.go b/updater/update/osnativearch_other.go new file mode 100644 index 0000000..ba37cd7 --- /dev/null +++ b/updater/update/osnativearch_other.go @@ -0,0 +1,20 @@ +// SILVER - Service Wrapper +// Auto Updater +// +// Copyright (c) 2014-2021 PaperCut Software http://www.papercut.com/ +// Use of this source code is governed by an MIT or GPL Version 2 license. +// See the project's LICENSE file for more information. +// + +//go:build !windows && !darwin + +package update + +import "runtime" + +// nativeArch assumes the host architecture matches the binary on platforms +// without a reliable emulation signal (user-mode emulation on Linux is rare +// and deliberately hides itself from uname). +func nativeArch() string { + return runtime.GOARCH +} diff --git a/updater/update/osnativearch_windows.go b/updater/update/osnativearch_windows.go new file mode 100644 index 0000000..ce2187e --- /dev/null +++ b/updater/update/osnativearch_windows.go @@ -0,0 +1,40 @@ +// SILVER - Service Wrapper +// Auto Updater +// +// Copyright (c) 2014-2021 PaperCut Software http://www.papercut.com/ +// Use of this source code is governed by an MIT or GPL Version 2 license. +// See the project's LICENSE file for more information. +// + +//go:build windows + +package update + +import ( + "runtime" + + "golang.org/x/sys/windows" +) + +// nativeArch returns the host CPU architecture, which differs from +// runtime.GOARCH when the binary runs under emulation (e.g. x64 on +// Windows-on-ARM). IsWow64Process2 reports the native machine regardless +// of emulation; it is unavailable before Windows 10 1511, but no such +// host is an ARM64 machine, so falling back to GOARCH is correct there. +func nativeArch() string { + var processMachine, nativeMachine uint16 + if err := windows.IsWow64Process2(windows.CurrentProcess(), &processMachine, &nativeMachine); err != nil { + return runtime.GOARCH + } + switch nativeMachine { + case 0xAA64: // IMAGE_FILE_MACHINE_ARM64 + return "arm64" + case 0x8664: // IMAGE_FILE_MACHINE_AMD64 + return "amd64" + case 0x014C: // IMAGE_FILE_MACHINE_I386 + return "386" + case 0x01C4: // IMAGE_FILE_MACHINE_ARMNT + return "arm" + } + return runtime.GOARCH +} From 787ac04a7004def7d63799fed0bc7d8013fa5cac Mon Sep 17 00:00:00 2001 From: Ranganath Gunawardane Date: Mon, 6 Jul 2026 16:38:33 +1000 Subject: [PATCH 03/11] chore: set copyright year to 2026 on new and updated files --- updater/update/osinfo.go | 2 +- updater/update/osinfo_test.go | 2 +- updater/update/osnativearch_darwin.go | 2 +- updater/update/osnativearch_other.go | 2 +- updater/update/osnativearch_windows.go | 2 +- updater/update/osversion_darwin.go | 2 +- updater/update/osversion_linux.go | 2 +- updater/update/osversion_other.go | 2 +- updater/update/osversion_windows.go | 4 +--- updater/update/update.go | 2 +- 10 files changed, 10 insertions(+), 12 deletions(-) diff --git a/updater/update/osinfo.go b/updater/update/osinfo.go index 83db8db..1f2b8bb 100644 --- a/updater/update/osinfo.go +++ b/updater/update/osinfo.go @@ -1,7 +1,7 @@ // SILVER - Service Wrapper // Auto Updater // -// Copyright (c) 2014-2021 PaperCut Software http://www.papercut.com/ +// Copyright (c) 2026 PaperCut Software http://www.papercut.com/ // Use of this source code is governed by an MIT or GPL Version 2 license. // See the project's LICENSE file for more information. // diff --git a/updater/update/osinfo_test.go b/updater/update/osinfo_test.go index 2b999ad..83dc392 100644 --- a/updater/update/osinfo_test.go +++ b/updater/update/osinfo_test.go @@ -1,7 +1,7 @@ // SILVER - Service Wrapper // Auto Updater // -// Copyright (c) 2014-2021 PaperCut Software http://www.papercut.com/ +// Copyright (c) 2026 PaperCut Software http://www.papercut.com/ // Use of this source code is governed by an MIT or GPL Version 2 license. // See the project's LICENSE file for more information. // diff --git a/updater/update/osnativearch_darwin.go b/updater/update/osnativearch_darwin.go index db42a15..67f0147 100644 --- a/updater/update/osnativearch_darwin.go +++ b/updater/update/osnativearch_darwin.go @@ -1,7 +1,7 @@ // SILVER - Service Wrapper // Auto Updater // -// Copyright (c) 2014-2021 PaperCut Software http://www.papercut.com/ +// Copyright (c) 2026 PaperCut Software http://www.papercut.com/ // Use of this source code is governed by an MIT or GPL Version 2 license. // See the project's LICENSE file for more information. // diff --git a/updater/update/osnativearch_other.go b/updater/update/osnativearch_other.go index ba37cd7..8a8de69 100644 --- a/updater/update/osnativearch_other.go +++ b/updater/update/osnativearch_other.go @@ -1,7 +1,7 @@ // SILVER - Service Wrapper // Auto Updater // -// Copyright (c) 2014-2021 PaperCut Software http://www.papercut.com/ +// Copyright (c) 2026 PaperCut Software http://www.papercut.com/ // Use of this source code is governed by an MIT or GPL Version 2 license. // See the project's LICENSE file for more information. // diff --git a/updater/update/osnativearch_windows.go b/updater/update/osnativearch_windows.go index ce2187e..9fb7d8a 100644 --- a/updater/update/osnativearch_windows.go +++ b/updater/update/osnativearch_windows.go @@ -1,7 +1,7 @@ // SILVER - Service Wrapper // Auto Updater // -// Copyright (c) 2014-2021 PaperCut Software http://www.papercut.com/ +// Copyright (c) 2026 PaperCut Software http://www.papercut.com/ // Use of this source code is governed by an MIT or GPL Version 2 license. // See the project's LICENSE file for more information. // diff --git a/updater/update/osversion_darwin.go b/updater/update/osversion_darwin.go index 236ac11..b6d3380 100644 --- a/updater/update/osversion_darwin.go +++ b/updater/update/osversion_darwin.go @@ -1,7 +1,7 @@ // SILVER - Service Wrapper // Auto Updater // -// Copyright (c) 2014-2021 PaperCut Software http://www.papercut.com/ +// Copyright (c) 2026 PaperCut Software http://www.papercut.com/ // Use of this source code is governed by an MIT or GPL Version 2 license. // See the project's LICENSE file for more information. // diff --git a/updater/update/osversion_linux.go b/updater/update/osversion_linux.go index e68a36c..2ba1d3f 100644 --- a/updater/update/osversion_linux.go +++ b/updater/update/osversion_linux.go @@ -1,7 +1,7 @@ // SILVER - Service Wrapper // Auto Updater // -// Copyright (c) 2014-2021 PaperCut Software http://www.papercut.com/ +// Copyright (c) 2026 PaperCut Software http://www.papercut.com/ // Use of this source code is governed by an MIT or GPL Version 2 license. // See the project's LICENSE file for more information. // diff --git a/updater/update/osversion_other.go b/updater/update/osversion_other.go index fb188a8..9703f73 100644 --- a/updater/update/osversion_other.go +++ b/updater/update/osversion_other.go @@ -1,7 +1,7 @@ // SILVER - Service Wrapper // Auto Updater // -// Copyright (c) 2014-2021 PaperCut Software http://www.papercut.com/ +// Copyright (c) 2026 PaperCut Software http://www.papercut.com/ // Use of this source code is governed by an MIT or GPL Version 2 license. // See the project's LICENSE file for more information. // diff --git a/updater/update/osversion_windows.go b/updater/update/osversion_windows.go index ad4d09e..55e1b6b 100644 --- a/updater/update/osversion_windows.go +++ b/updater/update/osversion_windows.go @@ -1,13 +1,11 @@ // SILVER - Service Wrapper // Auto Updater // -// Copyright (c) 2014-2021 PaperCut Software http://www.papercut.com/ +// Copyright (c) 2026 PaperCut Software http://www.papercut.com/ // Use of this source code is governed by an MIT or GPL Version 2 license. // See the project's LICENSE file for more information. // -//go:build windows - package update import ( diff --git a/updater/update/update.go b/updater/update/update.go index d05bb79..ec81417 100644 --- a/updater/update/update.go +++ b/updater/update/update.go @@ -1,7 +1,7 @@ // SILVER - Service Wrapper // Auto Updater // -// Copyright (c) 2014-2021 PaperCut Software http://www.papercut.com/ +// Copyright (c) 2014-2026 PaperCut Software http://www.papercut.com/ // Use of this source code is governed by an MIT or GPL Version 2 license. // See the project's LICENSE file for more information. // From a17cce8183f96bcacbee298783e9d3e2fefd0c87 Mon Sep 17 00:00:00 2001 From: Ranganath Gunawardane Date: Mon, 6 Jul 2026 16:41:46 +1000 Subject: [PATCH 04/11] chore: drop build tags made redundant by filename suffixes The _darwin/_windows/_linux filename suffixes already constrain these files; only the _other files need explicit tags. --- updater/update/osnativearch_darwin.go | 2 -- updater/update/osnativearch_windows.go | 2 -- updater/update/osversion_darwin.go | 2 -- updater/update/osversion_linux.go | 2 -- 4 files changed, 8 deletions(-) diff --git a/updater/update/osnativearch_darwin.go b/updater/update/osnativearch_darwin.go index 67f0147..02bc91f 100644 --- a/updater/update/osnativearch_darwin.go +++ b/updater/update/osnativearch_darwin.go @@ -6,8 +6,6 @@ // See the project's LICENSE file for more information. // -//go:build darwin - package update import ( diff --git a/updater/update/osnativearch_windows.go b/updater/update/osnativearch_windows.go index 9fb7d8a..8fba3f6 100644 --- a/updater/update/osnativearch_windows.go +++ b/updater/update/osnativearch_windows.go @@ -6,8 +6,6 @@ // See the project's LICENSE file for more information. // -//go:build windows - package update import ( diff --git a/updater/update/osversion_darwin.go b/updater/update/osversion_darwin.go index b6d3380..a50c6de 100644 --- a/updater/update/osversion_darwin.go +++ b/updater/update/osversion_darwin.go @@ -6,8 +6,6 @@ // See the project's LICENSE file for more information. // -//go:build darwin - package update import "syscall" diff --git a/updater/update/osversion_linux.go b/updater/update/osversion_linux.go index 2ba1d3f..0d1e264 100644 --- a/updater/update/osversion_linux.go +++ b/updater/update/osversion_linux.go @@ -6,8 +6,6 @@ // See the project's LICENSE file for more information. // -//go:build linux - package update import "golang.org/x/sys/unix" From d507a86fe95165883163a302adb64c7b412ff364 Mon Sep 17 00:00:00 2001 From: Ranganath Gunawardane Date: Mon, 6 Jul 2026 16:52:30 +1000 Subject: [PATCH 05/11] refactor: return explicit error from osVersion on unsupported platforms An empty string with a nil error was a silent sentinel; the caller already omits the X-OS-Version header on error, so behavior is unchanged. --- updater/update/osversion_other.go | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/updater/update/osversion_other.go b/updater/update/osversion_other.go index 9703f73..4682036 100644 --- a/updater/update/osversion_other.go +++ b/updater/update/osversion_other.go @@ -10,8 +10,13 @@ package update -// osVersion is unknown on unsupported platforms; the X-OS-Version header is -// simply omitted. +import ( + "fmt" + "runtime" +) + +// osVersion has no detection on this platform; the caller omits the +// X-OS-Version header on error. func osVersion() (string, error) { - return "", nil + return "", fmt.Errorf("no OS version detection support for %s", runtime.GOOS) } From d27afe5734535aca9c2d0cc96d01d2524063b25e Mon Sep 17 00:00:00 2001 From: Ranganath Gunawardane Date: Mon, 6 Jul 2026 17:05:15 +1000 Subject: [PATCH 06/11] fix: trim Linux kernel release to its numeric version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit uname's release string carries distro suffixes on most distributions (5.15.0-91-generic, 4.18.0-477.10.1.el8_8.x86_64) which would break the update server's segment-by-segment numeric version comparison. Trim to the leading dot-separated numeric part so X-OS-Version is always comparable (5.15.0-91-generic → 5.15.0). Windows and macOS are unaffected; their sources are numeric by construction. --- updater/update/osinfo.go | 14 ++++++++++++++ updater/update/osinfo_test.go | 25 +++++++++++++++++++++++++ updater/update/osversion_linux.go | 5 +++-- 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/updater/update/osinfo.go b/updater/update/osinfo.go index 1f2b8bb..bd1a12c 100644 --- a/updater/update/osinfo.go +++ b/updater/update/osinfo.go @@ -11,6 +11,7 @@ package update import ( "net/http" "runtime" + "strings" ) const ( @@ -20,6 +21,19 @@ const ( headerOSNativeArchKey string = "X-OS-Native-Arch" ) +// trimToNumericVersion trims a version string to its leading dot-separated +// numeric part, dropping suffixes like the distro patch, kernel flavor and +// architecture in a Linux kernel release ("5.15.0-91-generic" → "5.15.0"). +// The update server compares versions segment-by-segment numerically, so +// only digits and dots may be sent. +func trimToNumericVersion(version string) string { + end := 0 + for end < len(version) && (version[end] == '.' || ('0' <= version[end] && version[end] <= '9')) { + end++ + } + return strings.TrimRight(version[:end], ".") +} + // addOSContextToRequestHeader adds OS identity headers used by the update // server to select a build compatible with this host. GOOS and GOARCH are // sent verbatim so no OS/arch mapping code ships in the client. diff --git a/updater/update/osinfo_test.go b/updater/update/osinfo_test.go index 83dc392..491897e 100644 --- a/updater/update/osinfo_test.go +++ b/updater/update/osinfo_test.go @@ -15,6 +15,31 @@ import ( "testing" ) +func TestTrimToNumericVersion(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + {"vanilla kernel", "6.17.24", "6.17.24"}, + {"ubuntu", "5.15.0-91-generic", "5.15.0"}, + {"debian", "6.1.0-13-amd64", "6.1.0"}, + {"rhel", "4.18.0-477.10.1.el8_8.x86_64", "4.18.0"}, + {"amazon linux", "6.1.66-91.160.amzn2023.x86_64", "6.1.66"}, + {"no trailing dot kept", "5.15.", "5.15"}, + {"non-numeric prefix", "generic-5.15", ""}, + {"empty", "", ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := trimToNumericVersion(tt.input); got != tt.want { + t.Errorf("trimToNumericVersion(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + func TestAddOSContextToRequestHeader(t *testing.T) { req, err := http.NewRequest("GET", "https://example.com/check-update/test", nil) if err != nil { diff --git a/updater/update/osversion_linux.go b/updater/update/osversion_linux.go index 0d1e264..0edc76f 100644 --- a/updater/update/osversion_linux.go +++ b/updater/update/osversion_linux.go @@ -10,11 +10,12 @@ package update import "golang.org/x/sys/unix" -// osVersion returns the Linux kernel release (e.g. "6.17.24") via uname. +// osVersion returns the Linux kernel version (e.g. "5.15.0") via uname, +// trimming distro suffixes from the release string ("5.15.0-91-generic"). func osVersion() (string, error) { var uts unix.Utsname if err := unix.Uname(&uts); err != nil { return "", err } - return unix.ByteSliceToString(uts.Release[:]), nil + return trimToNumericVersion(unix.ByteSliceToString(uts.Release[:])), nil } From a9cc084efe23bf65c4d46976e32c33ed14e46535 Mon Sep 17 00:00:00 2001 From: Ranganath Gunawardane Date: Mon, 6 Jul 2026 17:06:30 +1000 Subject: [PATCH 07/11] refactor: enforce non-empty osVersion contract osVersion now guarantees a non-empty version on nil error: linux returns an error for a kernel release with no numeric prefix, darwin for an empty sysctl value, and windows is numeric by construction. The caller drops its empty-string check accordingly. --- updater/update/osinfo.go | 2 +- updater/update/osversion_darwin.go | 14 ++++++++++++-- updater/update/osversion_linux.go | 13 +++++++++++-- 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/updater/update/osinfo.go b/updater/update/osinfo.go index bd1a12c..0562c20 100644 --- a/updater/update/osinfo.go +++ b/updater/update/osinfo.go @@ -44,7 +44,7 @@ func addOSContextToRequestHeader(req *http.Request) { // emulation (Rosetta 2, Windows-on-ARM). req.Header.Set(headerOSNativeArchKey, nativeArch()) // Best effort: an unknown OS version is better than a failed update check. - if version, err := osVersion(); err == nil && version != "" { + if version, err := osVersion(); err == nil { req.Header.Set(headerOSVersionKey, version) } } diff --git a/updater/update/osversion_darwin.go b/updater/update/osversion_darwin.go index a50c6de..666381a 100644 --- a/updater/update/osversion_darwin.go +++ b/updater/update/osversion_darwin.go @@ -8,9 +8,19 @@ package update -import "syscall" +import ( + "errors" + "syscall" +) // osVersion returns the macOS product version (e.g. "14.5"). func osVersion() (string, error) { - return syscall.Sysctl("kern.osproductversion") + version, err := syscall.Sysctl("kern.osproductversion") + if err != nil { + return "", err + } + if version == "" { + return "", errors.New("kern.osproductversion is empty") + } + return version, nil } diff --git a/updater/update/osversion_linux.go b/updater/update/osversion_linux.go index 0edc76f..62204f4 100644 --- a/updater/update/osversion_linux.go +++ b/updater/update/osversion_linux.go @@ -8,7 +8,11 @@ package update -import "golang.org/x/sys/unix" +import ( + "fmt" + + "golang.org/x/sys/unix" +) // osVersion returns the Linux kernel version (e.g. "5.15.0") via uname, // trimming distro suffixes from the release string ("5.15.0-91-generic"). @@ -17,5 +21,10 @@ func osVersion() (string, error) { if err := unix.Uname(&uts); err != nil { return "", err } - return trimToNumericVersion(unix.ByteSliceToString(uts.Release[:])), nil + release := unix.ByteSliceToString(uts.Release[:]) + version := trimToNumericVersion(release) + if version == "" { + return "", fmt.Errorf("unrecognized kernel release %q", release) + } + return version, nil } From 18c70dcb0fe24e7ddf1d7ce7a6415cc3ea7d4fd1 Mon Sep 17 00:00:00 2001 From: Ranganath Gunawardane Date: Mon, 6 Jul 2026 17:08:36 +1000 Subject: [PATCH 08/11] refactor: rename addOSContextToRequestHeader to setOSHeaders Set describes Header.Set semantics; the parameter type already says it operates on a request. --- updater/update/osinfo.go | 8 ++++---- updater/update/osinfo_test.go | 4 ++-- updater/update/update.go | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/updater/update/osinfo.go b/updater/update/osinfo.go index 0562c20..c234df6 100644 --- a/updater/update/osinfo.go +++ b/updater/update/osinfo.go @@ -34,10 +34,10 @@ func trimToNumericVersion(version string) string { return strings.TrimRight(version[:end], ".") } -// addOSContextToRequestHeader adds OS identity headers used by the update -// server to select a build compatible with this host. GOOS and GOARCH are -// sent verbatim so no OS/arch mapping code ships in the client. -func addOSContextToRequestHeader(req *http.Request) { +// setOSHeaders sets the OS identity headers used by the update server to +// select a build compatible with this host. GOOS and GOARCH are sent +// verbatim so no OS/arch mapping code ships in the client. +func setOSHeaders(req *http.Request) { req.Header.Set(headerOSTypeKey, runtime.GOOS) req.Header.Set(headerOSArchKey, runtime.GOARCH) // Host architecture; differs from X-OS-Arch when running under diff --git a/updater/update/osinfo_test.go b/updater/update/osinfo_test.go index 491897e..ebf9877 100644 --- a/updater/update/osinfo_test.go +++ b/updater/update/osinfo_test.go @@ -40,13 +40,13 @@ func TestTrimToNumericVersion(t *testing.T) { } } -func TestAddOSContextToRequestHeader(t *testing.T) { +func TestSetOSHeaders(t *testing.T) { req, err := http.NewRequest("GET", "https://example.com/check-update/test", nil) if err != nil { t.Fatal(err) } - addOSContextToRequestHeader(req) + setOSHeaders(req) if got := req.Header.Get(headerOSTypeKey); got != runtime.GOOS { t.Errorf("%s = %q, want %q", headerOSTypeKey, got, runtime.GOOS) diff --git a/updater/update/update.go b/updater/update/update.go index ec81417..66d6bc6 100644 --- a/updater/update/update.go +++ b/updater/update/update.go @@ -41,7 +41,7 @@ func Check(updateURL string, currentVer string, publicKey string) (*UpgradeInfo, } req.Header.Set("User-Agent", "Update Check") addIDProfileToRequestHeader(req) - addOSContextToRequestHeader(req) + setOSHeaders(req) res, err := client.Do(req) if err != nil { From 79c6995702dd84a8b0d5d82e40e4b9f48a5738e1 Mon Sep 17 00:00:00 2001 From: Ranganath Gunawardane Date: Mon, 6 Jul 2026 17:09:55 +1000 Subject: [PATCH 09/11] test: move osinfo tests to external update_test package Black-box tests via an export_test.go bridge; header names are asserted as literal wire strings rather than the package constants. --- updater/update/export_test.go | 15 +++++++++++++++ updater/update/osinfo_test.go | 26 ++++++++++++++------------ 2 files changed, 29 insertions(+), 12 deletions(-) create mode 100644 updater/update/export_test.go diff --git a/updater/update/export_test.go b/updater/update/export_test.go new file mode 100644 index 0000000..832b3a2 --- /dev/null +++ b/updater/update/export_test.go @@ -0,0 +1,15 @@ +// SILVER - Service Wrapper +// Auto Updater +// +// Copyright (c) 2026 PaperCut Software http://www.papercut.com/ +// Use of this source code is governed by an MIT or GPL Version 2 license. +// See the project's LICENSE file for more information. +// + +package update + +// Test-only exports for black-box tests in package update_test. +var ( + SetOSHeaders = setOSHeaders + TrimToNumericVersion = trimToNumericVersion +) diff --git a/updater/update/osinfo_test.go b/updater/update/osinfo_test.go index ebf9877..63cd222 100644 --- a/updater/update/osinfo_test.go +++ b/updater/update/osinfo_test.go @@ -6,13 +6,15 @@ // See the project's LICENSE file for more information. // -package update +package update_test import ( "net/http" "regexp" "runtime" "testing" + + "github.com/papercutsoftware/silver/updater/update" ) func TestTrimToNumericVersion(t *testing.T) { @@ -33,8 +35,8 @@ func TestTrimToNumericVersion(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if got := trimToNumericVersion(tt.input); got != tt.want { - t.Errorf("trimToNumericVersion(%q) = %q, want %q", tt.input, got, tt.want) + if got := update.TrimToNumericVersion(tt.input); got != tt.want { + t.Errorf("TrimToNumericVersion(%q) = %q, want %q", tt.input, got, tt.want) } }) } @@ -46,28 +48,28 @@ func TestSetOSHeaders(t *testing.T) { t.Fatal(err) } - setOSHeaders(req) + update.SetOSHeaders(req) - if got := req.Header.Get(headerOSTypeKey); got != runtime.GOOS { - t.Errorf("%s = %q, want %q", headerOSTypeKey, got, runtime.GOOS) + if got := req.Header.Get("X-OS-Type"); got != runtime.GOOS { + t.Errorf("X-OS-Type = %q, want %q", got, runtime.GOOS) } - if got := req.Header.Get(headerOSArchKey); got != runtime.GOARCH { - t.Errorf("%s = %q, want %q", headerOSArchKey, got, runtime.GOARCH) + if got := req.Header.Get("X-OS-Arch"); got != runtime.GOARCH { + t.Errorf("X-OS-Arch = %q, want %q", got, runtime.GOARCH) } // Native arch is the binary arch except under emulation, where it must // be arm64 (Rosetta 2 and Windows-on-ARM both emulate on arm64 hosts). - nativeGot := req.Header.Get(headerOSNativeArchKey) + nativeGot := req.Header.Get("X-OS-Native-Arch") if nativeGot != runtime.GOARCH && nativeGot != "arm64" { - t.Errorf("%s = %q, want %q or \"arm64\"", headerOSNativeArchKey, nativeGot, runtime.GOARCH) + t.Errorf("X-OS-Native-Arch = %q, want %q or \"arm64\"", nativeGot, runtime.GOARCH) } // windows, darwin and linux all report a dot-separated numeric version. switch runtime.GOOS { case "windows", "darwin", "linux": - got := req.Header.Get(headerOSVersionKey) + got := req.Header.Get("X-OS-Version") if matched := regexp.MustCompile(`^\d+(\.\d+)+`).MatchString(got); !matched { - t.Errorf("%s = %q, want a dot-separated numeric version", headerOSVersionKey, got) + t.Errorf("X-OS-Version = %q, want a dot-separated numeric version", got) } } } From 39eb2bddb49ca66b4f9bd55c995371b58a6cbe30 Mon Sep 17 00:00:00 2001 From: Ranganath Gunawardane Date: Mon, 6 Jul 2026 17:12:04 +1000 Subject: [PATCH 10/11] feat: log OS version detection failure Matches the updater's existing best-effort convention (see addIDProfileToRequestHeader): print the error and continue, so an omitted X-OS-Version header is diagnosable in the field. --- updater/update/osinfo.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/updater/update/osinfo.go b/updater/update/osinfo.go index c234df6..0b3f6f9 100644 --- a/updater/update/osinfo.go +++ b/updater/update/osinfo.go @@ -9,6 +9,7 @@ package update import ( + "fmt" "net/http" "runtime" "strings" @@ -46,5 +47,7 @@ func setOSHeaders(req *http.Request) { // Best effort: an unknown OS version is better than a failed update check. if version, err := osVersion(); err == nil { req.Header.Set(headerOSVersionKey, version) + } else { + fmt.Printf("Couldn't detect OS version: %v.\n", err) } } From 880e343632d0616b699dbbde569f7c16bc0e5bc4 Mon Sep 17 00:00:00 2001 From: Ranganath Gunawardane Date: Thu, 9 Jul 2026 22:19:53 +1000 Subject: [PATCH 11/11] feat: identify Silver version in User-Agent Update checks now send silver-updater/ (for example, silver-updater/v1.6.1) instead of the hardcoded "Update Check". The version comes from from module's git tag. Builds without tag info send plain silver-updater. --- updater/update/export_test.go | 1 + updater/update/osinfo.go | 22 ++++++++++++---------- updater/update/osinfo_test.go | 22 ++++++++++++++++------ updater/update/update.go | 2 +- updater/update/useragent.go | 24 ++++++++++++++++++++++++ 5 files changed, 54 insertions(+), 17 deletions(-) create mode 100644 updater/update/useragent.go diff --git a/updater/update/export_test.go b/updater/update/export_test.go index 832b3a2..9ffd677 100644 --- a/updater/update/export_test.go +++ b/updater/update/export_test.go @@ -12,4 +12,5 @@ package update var ( SetOSHeaders = setOSHeaders TrimToNumericVersion = trimToNumericVersion + UserAgent = userAgent ) diff --git a/updater/update/osinfo.go b/updater/update/osinfo.go index 0b3f6f9..38cbf76 100644 --- a/updater/update/osinfo.go +++ b/updater/update/osinfo.go @@ -16,10 +16,10 @@ import ( ) const ( - headerOSTypeKey string = "X-OS-Type" - headerOSVersionKey string = "X-OS-Version" - headerOSArchKey string = "X-OS-Arch" - headerOSNativeArchKey string = "X-OS-Native-Arch" + headerOSTypeKey string = "X-OS-Type" + headerOSVersionKey string = "X-OS-Version" + headerOSArchKey string = "X-OS-Arch" + headerBinaryArchKey string = "X-Binary-Arch" ) // trimToNumericVersion trims a version string to its leading dot-separated @@ -36,14 +36,16 @@ func trimToNumericVersion(version string) string { } // setOSHeaders sets the OS identity headers used by the update server to -// select a build compatible with this host. GOOS and GOARCH are sent -// verbatim so no OS/arch mapping code ships in the client. +// select a build compatible with this host. The server keys build selection +// on X-OS-Type + X-OS-Arch; X-Binary-Arch reports what the running updater +// was compiled for, which differs from X-OS-Arch under emulation (Rosetta 2, +// Windows-on-ARM) so those installs migrate to native builds. func setOSHeaders(req *http.Request) { req.Header.Set(headerOSTypeKey, runtime.GOOS) - req.Header.Set(headerOSArchKey, runtime.GOARCH) - // Host architecture; differs from X-OS-Arch when running under - // emulation (Rosetta 2, Windows-on-ARM). - req.Header.Set(headerOSNativeArchKey, nativeArch()) + // Best-effort host architecture: detects Rosetta 2 and Windows-on-ARM + // emulation; on other platforms falls back to the binary's arch. + req.Header.Set(headerOSArchKey, nativeArch()) + req.Header.Set(headerBinaryArchKey, runtime.GOARCH) // Best effort: an unknown OS version is better than a failed update check. if version, err := osVersion(); err == nil { req.Header.Set(headerOSVersionKey, version) diff --git a/updater/update/osinfo_test.go b/updater/update/osinfo_test.go index 63cd222..320efd6 100644 --- a/updater/update/osinfo_test.go +++ b/updater/update/osinfo_test.go @@ -17,6 +17,16 @@ import ( "github.com/papercutsoftware/silver/updater/update" ) +func TestUserAgent(t *testing.T) { + got := update.UserAgent() + + // "silver-updater" alone (no VCS stamp, e.g. test binaries), or + // "silver-updater/" where version is a spaceless token. + if matched := regexp.MustCompile(`^silver-updater(/\S+)?$`).MatchString(got); !matched { + t.Errorf("userAgent() = %q, want \"silver-updater\" optionally followed by /", got) + } +} + func TestTrimToNumericVersion(t *testing.T) { tests := []struct { name string @@ -53,15 +63,15 @@ func TestSetOSHeaders(t *testing.T) { if got := req.Header.Get("X-OS-Type"); got != runtime.GOOS { t.Errorf("X-OS-Type = %q, want %q", got, runtime.GOOS) } - if got := req.Header.Get("X-OS-Arch"); got != runtime.GOARCH { - t.Errorf("X-OS-Arch = %q, want %q", got, runtime.GOARCH) + if got := req.Header.Get("X-Binary-Arch"); got != runtime.GOARCH { + t.Errorf("X-Binary-Arch = %q, want %q", got, runtime.GOARCH) } - // Native arch is the binary arch except under emulation, where it must + // OS arch is the binary arch except under emulation, where it must // be arm64 (Rosetta 2 and Windows-on-ARM both emulate on arm64 hosts). - nativeGot := req.Header.Get("X-OS-Native-Arch") - if nativeGot != runtime.GOARCH && nativeGot != "arm64" { - t.Errorf("X-OS-Native-Arch = %q, want %q or \"arm64\"", nativeGot, runtime.GOARCH) + osArchGot := req.Header.Get("X-OS-Arch") + if osArchGot != runtime.GOARCH && osArchGot != "arm64" { + t.Errorf("X-OS-Arch = %q, want %q or \"arm64\"", osArchGot, runtime.GOARCH) } // windows, darwin and linux all report a dot-separated numeric version. diff --git a/updater/update/update.go b/updater/update/update.go index 66d6bc6..c84c9fb 100644 --- a/updater/update/update.go +++ b/updater/update/update.go @@ -39,7 +39,7 @@ func Check(updateURL string, currentVer string, publicKey string) (*UpgradeInfo, if err != nil { return nil, err } - req.Header.Set("User-Agent", "Update Check") + req.Header.Set("User-Agent", userAgent()) addIDProfileToRequestHeader(req) setOSHeaders(req) diff --git a/updater/update/useragent.go b/updater/update/useragent.go new file mode 100644 index 0000000..287af02 --- /dev/null +++ b/updater/update/useragent.go @@ -0,0 +1,24 @@ +// SILVER - Service Wrapper +// Auto Updater +// +// Copyright (c) 2026 PaperCut Software http://www.papercut.com/ +// Use of this source code is governed by an MIT or GPL Version 2 license. +// See the project's LICENSE file for more information. +// + +package update + +import "runtime/debug" + +// userAgent returns the User-Agent for update check requests, e.g. +// "silver-updater/v1.6.1". The version is stamped automatically by the Go +// toolchain from the module's VCS tag; builds without VCS information +// (or from an untagged tree reporting "(devel)") omit it. +func userAgent() string { + if info, ok := debug.ReadBuildInfo(); ok { + if v := info.Main.Version; v != "" && v != "(devel)" { + return "silver-updater/" + v + } + } + return "silver-updater" +}