Skip to content
Open
16 changes: 16 additions & 0 deletions updater/update/export_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// 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
UserAgent = userAgent
)
55 changes: 55 additions & 0 deletions updater/update/osinfo.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// 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"
headerBinaryArchKey string = "X-Binary-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.
Comment on lines +28 to +29

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

// The update server compares versions segment-by-segment numerically, so
// only digits and dots may be sent.Collapse comment

I don't think this part needs to be written since it is not Silver's behavior.

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. 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The server behavior doesn't need to be described here since how it handles X headers depends on its implementation.

func setOSHeaders(req *http.Request) {
req.Header.Set(headerOSTypeKey, runtime.GOOS)
// 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)
} else {
fmt.Printf("Couldn't detect OS version: %v.\n", err)
}
}
85 changes: 85 additions & 0 deletions updater/update/osinfo_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
// 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 TestUserAgent(t *testing.T) {
got := update.UserAgent()

// "silver-updater" alone (no VCS stamp, e.g. test binaries), or
// "silver-updater/<version>" 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 /<version>", got)
}
}

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-Binary-Arch"); got != runtime.GOARCH {
t.Errorf("X-Binary-Arch = %q, want %q", got, runtime.GOARCH)
}

// 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).
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.
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
}
5 changes: 3 additions & 2 deletions 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 @@ -39,8 +39,9 @@ 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)

res, err := client.Do(req)
if err != nil {
Expand Down
24 changes: 24 additions & 0 deletions updater/update/useragent.go
Original file line number Diff line number Diff line change
@@ -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"
}