Skip to content
15 changes: 15 additions & 0 deletions updater/update/export_test.go
Original file line number Diff line number Diff line change
@@ -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
)
53 changes: 53 additions & 0 deletions updater/update/osinfo.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// 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 (
"fmt"
"net/http"
"runtime"
"strings"
)

const (
headerOSTypeKey string = "X-OS-Type"
headerOSVersionKey string = "X-OS-Version"
headerOSArchKey string = "X-OS-Arch"
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], ".")
}

// 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)
Comment thread
ranganath42 marked this conversation as resolved.
// 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 {
req.Header.Set(headerOSVersionKey, version)
} else {
fmt.Printf("Couldn't detect OS version: %v.\n", err)
}
}
75 changes: 75 additions & 0 deletions updater/update/osinfo_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
// 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

import (
"net/http"
"regexp"
"runtime"
"testing"

"github.com/papercutsoftware/silver/updater/update"
)

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 := update.TrimToNumericVersion(tt.input); got != tt.want {
t.Errorf("TrimToNumericVersion(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}

func TestSetOSHeaders(t *testing.T) {
req, err := http.NewRequest("GET", "https://example.com/check-update/test", nil)
if err != nil {
t.Fatal(err)
}

update.SetOSHeaders(req)

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)
}

// 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("X-OS-Native-Arch")
if nativeGot != runtime.GOARCH && nativeGot != "arm64" {
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("X-OS-Version")
if matched := regexp.MustCompile(`^\d+(\.\d+)+`).MatchString(got); !matched {
t.Errorf("X-OS-Version = %q, want a dot-separated numeric version", got)
}
}
}
25 changes: 25 additions & 0 deletions updater/update/osnativearch_darwin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// 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"
"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
}
20 changes: 20 additions & 0 deletions updater/update/osnativearch_other.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// 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.
//

//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
}
38 changes: 38 additions & 0 deletions updater/update/osnativearch_windows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// 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"

"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
}
26 changes: 26 additions & 0 deletions updater/update/osversion_darwin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// 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 (
"errors"
"syscall"
)

// osVersion returns the macOS product version (e.g. "14.5").
func osVersion() (string, error) {
version, err := syscall.Sysctl("kern.osproductversion")
if err != nil {
return "", err
}
if version == "" {
return "", errors.New("kern.osproductversion is empty")
}
return version, nil
}
30 changes: 30 additions & 0 deletions updater/update/osversion_linux.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// 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 (
"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").
func osVersion() (string, error) {
var uts unix.Utsname
if err := unix.Uname(&uts); err != nil {
return "", err
}
release := unix.ByteSliceToString(uts.Release[:])
version := trimToNumericVersion(release)
if version == "" {
return "", fmt.Errorf("unrecognized kernel release %q", release)
}
return version, nil
}
22 changes: 22 additions & 0 deletions updater/update/osversion_other.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// 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.
//

//go:build !windows && !darwin && !linux

package update

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 "", fmt.Errorf("no OS version detection support for %s", runtime.GOOS)
}
23 changes: 23 additions & 0 deletions updater/update/osversion_windows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// 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 (
"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
}
3 changes: 2 additions & 1 deletion updater/update/update.go
Original file line number Diff line number Diff line change
@@ -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.
//
Expand Down Expand Up @@ -41,6 +41,7 @@ func Check(updateURL string, currentVer string, publicKey string) (*UpgradeInfo,
}
req.Header.Set("User-Agent", "Update Check")
addIDProfileToRequestHeader(req)
setOSHeaders(req)

res, err := client.Do(req)
if err != nil {
Expand Down