diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 7bd7d12..643f1be 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -5,7 +5,7 @@ Follows these guidelines precisely to ensure consistency and maintainability of ## Stack -- Language: Go (Go 1.26+) +- Language: Go (Go 1.27+) - Framework: Go standard library - Testing: Go's built-in testing package - Dependency Management: Go modules diff --git a/.github/dependabot.yml b/.github/dependabot.yml index e0871f9..53a8859 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,11 +1,42 @@ -# To get started with Dependabot version updates, you'll need to specify which -# package ecosystems to update and where the package manifests are located. -# Please see the documentation for all configuration options: -# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates +# Dependabot configuration. +# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file +# +# The library has no third-party Go dependencies, so the gomod entry mostly +# guards against one being introduced; the github-actions entry keeps the +# CI/CD pins current. version: 2 updates: - - package-ecosystem: "gomod" # See documentation for possible values - directory: "/" # Location of package manifests + - package-ecosystem: "gomod" + directory: "/" schedule: interval: "weekly" + day: "monday" + time: "06:00" + timezone: "Etc/UTC" + labels: + - "dependencies" + - "go" + commit-message: + prefix: "chore(deps)" + groups: + go-modules: + patterns: + - "*" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "06:00" + timezone: "Etc/UTC" + labels: + - "dependencies" + - "github-actions" + commit-message: + prefix: "chore(ci)" + groups: + github-actions: + patterns: + - "*" diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index e145d42..fb6d358 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -34,7 +34,7 @@ jobs: steps: - name: Check out code - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Initialize CodeQL uses: github/codeql-action/init@v4 diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 2256a2b..d35925c 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -19,10 +19,10 @@ jobs: os: [ubuntu-latest, macos-latest, windows-latest] steps: - name: Check out code - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@v7 with: go-version-file: ./go.mod @@ -90,10 +90,10 @@ jobs: MAKE_DEBUG: true steps: - name: Check out code - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@v7 with: go-version-file: ./go.mod diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 327ddf5..d8d9023 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,10 +22,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@v7 with: go-version-file: ./go.mod @@ -38,10 +38,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@v7 with: go-version-file: ./go.mod @@ -50,7 +50,7 @@ jobs: GIT_VERSION=${{ github.ref_name }} GO_OS="linux" make build-dist - name: Install Cosign - uses: sigstore/cosign-installer@v3 + uses: sigstore/cosign-installer@v4 - name: Sign Linux binaries with Cosign (keyless) run: | @@ -64,7 +64,7 @@ jobs: done - name: Upload Linux distribution files - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: dist-linux path: ./dist/ @@ -75,10 +75,10 @@ jobs: # runs-on: windows-latest # steps: # - name: Check out code - # uses: actions/checkout@v6 + # uses: actions/checkout@v7 # # - name: Set up Go - # uses: actions/setup-go@v6 + # uses: actions/setup-go@v7 # with: # go-version-file: ./go.mod # @@ -159,7 +159,7 @@ jobs: # } # # - name: Upload signed Windows artifacts - # uses: actions/upload-artifact@v6 + # uses: actions/upload-artifact@v7 # with: # name: dist-windows-signed # path: ./dist/assets/ @@ -170,10 +170,10 @@ jobs: runs-on: macos-latest steps: - name: Check out code - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@v7 with: go-version-file: ./go.mod @@ -275,7 +275,7 @@ jobs: run: security delete-keychain "$RUNNER_TEMP/build.keychain" || true - name: Upload macOS pkg artifacts - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: dist-macos path: ./dist/assets/ @@ -287,27 +287,27 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@v7 with: go-version-file: ./go.mod - name: Download Linux distribution files - uses: actions/download-artifact@v7 + uses: actions/download-artifact@v8 with: name: dist-linux path: ./dist/ # - name: Download signed Windows artifacts - # uses: actions/download-artifact@v7 + # uses: actions/download-artifact@v8 # with: # name: dist-windows-signed # path: ./dist/assets/ - name: Download macOS pkg - uses: actions/download-artifact@v7 + uses: actions/download-artifact@v8 with: name: dist-macos path: ./dist/assets/ @@ -322,7 +322,7 @@ jobs: - name: Create GitHub Release id: create-github-release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v3 with: tag_name: ${{ github.ref_name }} name: ${{ github.ref_name }} diff --git a/.gitignore b/.gitignore index 84b6044..16ac82a 100644 --- a/.gitignore +++ b/.gitignore @@ -35,4 +35,6 @@ go.work.sum /machineid .DS_Store build/ -dist/ \ No newline at end of file +dist/ +# Local planning notes (not committed) +.plan/ diff --git a/.golangci.yaml b/.golangci.yaml index 06c4375..4053521 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -80,10 +80,7 @@ linters: - (io.Writer).Write - io.ReadAll - filepath.Match - - github.com/spf13/viper.BindPFlag - - (*github.com/spf13/cobra.Command).MarkFlagRequired - - (*github.com/spf13/pflag.FlagSet).GetString - - (*github.com/spf13/pflag.FlagSet).GetInt - - (*github.com/spf13/pflag.FlagSet).GetBool - encoding/json.Marshal - encoding/hex.DecodeString + # errors.AsType[T] returns (T, bool); errcheck misreads T as an error result when T implements error. + - errors.AsType diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 131fee7..81ad51e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,10 +6,12 @@ Thank you for your interest in contributing to machineid! This document provides ### Prerequisites -- Go 1.26 or higher +- Go 1.27 or higher - Git - Make +Runnable examples are listed with `go doc -ex github.com/slashdevops/machineid`. + ### Getting Started 1. Fork the repository on GitHub diff --git a/README.md b/README.md index 43ae5b4..c826d9b 100644 --- a/README.md +++ b/README.md @@ -1,101 +1,123 @@ -# machineid +# πŸ†” machineid -[![main branch](https://github.com/slashdevops/machineid/actions/workflows/main.yml/badge.svg)](https://github.com/slashdevops/machineid/actions/workflows/main.yml) -![GitHub go.mod Go version](https://img.shields.io/github/go-mod/go-version/slashdevops/machineid?style=plastic) +**Deterministic, hardware-derived machine identifiers for Go. Zero dependencies. One binary.** + +[![Pull Request](https://github.com/slashdevops/machineid/actions/workflows/pr.yml/badge.svg)](https://github.com/slashdevops/machineid/actions/workflows/pr.yml) +[![Release](https://github.com/slashdevops/machineid/actions/workflows/release.yml/badge.svg)](https://github.com/slashdevops/machineid/actions/workflows/release.yml) +[![CodeQL](https://github.com/slashdevops/machineid/actions/workflows/codeql.yml/badge.svg)](https://github.com/slashdevops/machineid/actions/workflows/codeql.yml) +[![Go version](https://img.shields.io/github/go-mod/go-version/slashdevops/machineid)](go.mod) [![Go Reference](https://pkg.go.dev/badge/github.com/slashdevops/machineid.svg)](https://pkg.go.dev/github.com/slashdevops/machineid) [![Go Report Card](https://goreportcard.com/badge/github.com/slashdevops/machineid)](https://goreportcard.com/report/github.com/slashdevops/machineid) -[![license](https://img.shields.io/github/license/slashdevops/machineid.svg)](https://github.com/slashdevops/machineid/blob/main/LICENSE) -[![Release](https://github.com/slashdevops/machineid/actions/workflows/release.yml/badge.svg)](https://github.com/slashdevops/machineid/actions/workflows/release.yml) +[![Latest release](https://img.shields.io/github/v/release/slashdevops/machineid?sort=semver)](https://github.com/slashdevops/machineid/releases/latest) +[![License](https://img.shields.io/github/license/slashdevops/machineid.svg)](LICENSE) -A **zero-dependency** Go library that generates unique, deterministic machine identifiers from hardware characteristics. IDs are stable across reboots, sensitive to hardware changes, and ideal for software licensing, device fingerprinting, and telemetry correlation. +`machineid` turns the hardware a program is running on into a stable, opaque identifier. The same machine always produces the same ID; a different machine never does. IDs survive reboots and OS reinstalls, change when the hardware changes, and reveal nothing about the hardware itself. -## Features +Use it for **software licensing and activation**, **device fingerprinting**, **per-host telemetry correlation**, **fleet inventory**, or anywhere you need "which machine is this?" answered without a database. -- **Zero Dependencies** β€” built entirely on the Go standard library -- **Cross-Platform** β€” macOS, Linux, and Windows -- **Configurable** β€” choose which hardware signals to include (CPU, Motherboard, System UUID, MAC, Disk) -- **Power-of-2 Output** β€” 32, 64, 128, or 256 hex characters -- **SHA-256 Hashing** β€” cryptographically secure, no collisions in practice -- **Salt Support** β€” application-specific IDs on the same machine -- **VM Friendly** β€” preset for virtual environments (CPU + UUID) -- **Thread-Safe** β€” safe for concurrent use after configuration -- **Diagnostic API** β€” inspect which components succeeded or failed -- **Optional Logging** β€” `*slog.Logger` support for observability with zero overhead when disabled -- **Structured Errors** β€” sentinel errors and typed errors for programmatic handling via `errors.Is` / `errors.As` -- **Testable** β€” dependency-injectable command executor +```bash +$ machineid +b5c42832542981af58c9dc3bc241219e780ff7d276cfad05fac222846edb84f7 +``` + +```go +id, err := machineid.New().WithCPU().WithSystemUUID().ID(ctx) +``` -## Installation +--- + +## πŸ“š Table of contents + +- [✨ Features](#-features) +- [πŸ“¦ Installation](#-installation) +- [πŸš€ Quick start](#-quick-start) +- [πŸ–₯️ CLI](#️-cli) +- [πŸ“– Library guide](#-library-guide) +- [βš™οΈ How it works](#️-how-it-works) +- [🧭 Choosing components](#-choosing-components) +- [πŸ”’ Security](#-security) +- [πŸ§ͺ Testing](#-testing) +- [πŸ› οΈ Troubleshooting](#️-troubleshooting) +- [🀝 Contributing](#-contributing) +- [πŸ“„ License](#-license) + +--- + +## ✨ Features + +| | | +|---|---| +| 🧩 **Zero dependencies** | Built entirely on the Go standard library. Nothing to audit, nothing to update. | +| 🌍 **Cross-platform** | macOS, Linux and Windows, each with native primary sources and fallbacks. | +| πŸŽ›οΈ **Configurable signals** | Pick any mix of CPU, motherboard serial, system UUID, MAC addresses and disk serials. | +| πŸ“ **Power-of-two output** | 32, 64, 128 or 256 hex characters, pure hex, no dashes. | +| πŸ” **SHA-256 based** | One-way hash. Hardware details cannot be recovered from an ID. | +| πŸ§‚ **Salt support** | Different IDs for different applications on the same machine. | +| ☁️ **VM friendly** | A preset that ignores the signals virtual machines and clouds change. | +| ⚑ **Concurrent collection** | Every hardware query runs in parallel on every platform. Latency is the slowest single query, not the sum. | +| πŸ” **Deterministic** | Results are folded in a fixed order. Same ID, same diagnostics, every run. | +| 🩺 **Diagnostics API** | See exactly which components were collected and why the others failed. | +| πŸͺ΅ **Optional `slog` logging** | Structured logs at info, warn and debug. Zero overhead when no logger is set. | +| 🚨 **Structured errors** | Sentinel errors for `errors.Is`, typed errors for `errors.AsType`, timeouts that match `context.DeadlineExceeded`. | +| πŸ§ͺ **Testable** | Inject a command executor and run the whole library against fixtures. | +| 🧡 **Thread-safe** | A configured provider can be shared freely across goroutines. | + +--- + +## πŸ“¦ Installation ### Library -Add the module to your Go project: - ```bash go get github.com/slashdevops/machineid ``` -Requires **Go 1.26+**. No external dependencies. +Requires **Go 1.27 or newer**. No external dependencies. -### CLI Tool +### CLI -#### Using `go install` +#### With `go install` ```bash go install github.com/slashdevops/machineid/cmd/machineid@latest ``` -Make sure `~/go/bin` is in your `PATH`: +Make sure `$(go env GOPATH)/bin` is on your `PATH`: ```bash -mkdir -p ~/go/bin - # bash -cat >> ~/.bash_profile <> ~/.bash_profile && source ~/.bash_profile # zsh -cat >> ~/.zshrc <> ~/.zshrc && source ~/.zshrc ``` -#### Installing a Precompiled Binary +#### Pre-built binaries -Signed and notarized binaries for macOS, Linux, and Windows are available on the [releases page](https://github.com/slashdevops/machineid/releases). +Signed binaries are published on the [releases page](https://github.com/slashdevops/machineid/releases). -**macOS** (signed & notarized universal `.pkg` installer β€” arm64 + amd64): +**🍎 macOS** (signed, notarized, universal `.pkg` for Apple Silicon and Intel; requires **macOS 13 Ventura or newer**): ```bash curl -L https://github.com/slashdevops/machineid/releases/latest/download/machineid-darwin-universal.pkg -o machineid.pkg sudo installer -pkg machineid.pkg -target / ``` -Or double-click the `.pkg` file in Finder to use the graphical installer. +Or double-click the `.pkg` in Finder. -**Linux**: +**🐧 Linux** (`amd64` and `arm64`, signed with Sigstore): ```bash -curl -L https://github.com/slashdevops/machineid/releases/latest/download/machineid-linux-amd64.zip -o machineid.zip -unzip machineid.zip && sudo mv machineid /usr/local/bin/ +ARCH=amd64 # or arm64 +curl -L "https://github.com/slashdevops/machineid/releases/latest/download/machineid-linux-${ARCH}.zip" -o machineid.zip +unzip machineid.zip && sudo install -m 0755 machineid /usr/local/bin/machineid ``` -**Windows** (via PowerShell): - -```powershell -Invoke-WebRequest -Uri https://github.com/slashdevops/machineid/releases/latest/download/machineid-windows-amd64.zip -OutFile machineid.zip -Expand-Archive machineid.zip -DestinationPath $env:USERPROFILE\bin -``` +**πŸͺŸ Windows**: use `go install` above or build from source. Pre-built Windows binaries are not published yet. -See [docs/macos-signing.md](docs/macos-signing.md) and [docs/linux-signing.md](docs/linux-signing.md) for details on binary verification. +How to verify a download: [macOS signing and notarization](docs/macos-signing.md) and [Linux Sigstore verification](docs/linux-signing.md). -#### Building from Source - -Clone the repository and build with version metadata via the provided Makefile: +#### From source ```bash git clone https://github.com/slashdevops/machineid.git @@ -104,7 +126,9 @@ make build ./build/machineid -version ``` -## Quick Start +--- + +## πŸš€ Quick start ```go package main @@ -119,6 +143,7 @@ import ( func main() { ctx := context.Background() + id, err := machineid.New(). WithCPU(). WithSystemUUID(). @@ -126,147 +151,180 @@ func main() { if err != nil { log.Fatal(err) } - fmt.Println(id) - // Output: 64-character hex string (e.g. b5c42832542981af…) + + fmt.Println(id) // 64 hex characters, e.g. b5c42832542981af… } ``` -## Usage +The context bounds every system command the library runs. Pass one with a deadline if you need a hard upper limit. -### Selecting Hardware Components +--- -Enable one or more hardware sources via the `With*` methods: +## πŸ–₯️ CLI -```go -ctx := context.Background() -provider := machineid.New(). - WithCPU(). // processor ID and feature flags - WithMotherboard(). // motherboard serial number - WithSystemUUID(). // BIOS/UEFI system UUID - WithMAC(). // physical network interface MAC addresses (default filter) +```bash +# Default: CPU + motherboard + UUID, 64 hex characters +machineid - WithDisk() // internal disk serial numbers +# Pick components +machineid -cpu -uuid -id, err := provider.ID(ctx) -``` +# Everything, compact 32-character output +machineid -all -format 32 -### MAC Address Filtering +# VM-friendly preset with an application salt +machineid -vm -salt "my-app" -Control which network interfaces are included in the machine ID using `MACFilter`: +# JSON with per-component diagnostics +machineid -all -json -diagnostics -```go -ctx := context.Background() +# Validate an ID you stored earlier (exit code 0 = match, 1 = mismatch) +machineid -cpu -uuid -validate "b5c42832542981af58c9dc3bc241219e780ff7d276cfad05fac222846edb84f7" -// Physical interfaces only (default, most stable for bare-metal) -id, _ := machineid.New().WithCPU().WithMAC().ID(ctx) +# Include virtual interfaces too +machineid -mac -mac-filter all -// All interfaces including virtual (VPN, Docker, bridges) -id, _ = machineid.New().WithCPU().WithMAC(machineid.MACFilterAll).ID(ctx) +# Logs to stderr: info level, or debug level with commands, raw values and timing +machineid -all -verbose +machineid -all -debug -// Only virtual interfaces (useful for container-specific fingerprinting) -id, _ = machineid.New().WithCPU().WithMAC(machineid.MACFilterVirtual).ID(ctx) +# Build information +machineid -version +machineid -version-long ``` -| Filter | Interfaces Included | Best For | -|---------------------|--------------------------------------------------------|--------------------------| -| `MACFilterPhysical` | `en0`, `eth0`, `wlan0` (default) | Bare-metal stability | -| `MACFilterAll` | Physical + virtual (`docker0`, `utun`, `bridge`, etc.) | Maximum uniqueness | -| `MACFilterVirtual` | `docker0`, `utun`, `bridge0`, `veth`, `vmnet`, etc. | Container fingerprinting | +Ctrl-C or `SIGTERM` cancels any hardware query that is still running. + +### Flags + +| Flag | Description | +|------|-------------| +| `-cpu` | Include the CPU identifier | +| `-motherboard` | Include the motherboard serial number | +| `-uuid` | Include the system UUID (BIOS/UEFI) | +| `-mac` | Include network interface MAC addresses | +| `-mac-filter F` | MAC filter: `physical` (default), `all` or `virtual` | +| `-disk` | Include disk serial numbers | +| `-all` | Include every component | +| `-vm` | VM-friendly preset: CPU + UUID only | +| `-format N` | Output length: `32`, `64` (default), `128` or `256` hex characters | +| `-salt STRING` | Application-specific salt | +| `-validate ID` | Compare a stored ID against this machine | +| `-diagnostics` | Show which components were collected or failed | +| `-json` | JSON output | +| `-verbose` | Info-level logs on stderr | +| `-debug` | Debug-level logs on stderr | +| `-version` | Print the version | +| `-version-long` | Print detailed build information | + +With no component flags the default is `-cpu -motherboard -uuid`. + +| Exit code | Meaning | +|-----------|---------| +| `0` | Success, or `-validate` matched | +| `1` | ID generation failed, or `-validate` did not match | +| `2` | Invalid arguments | + +--- -### Output Formats +## πŸ“– Library guide -All formats produce pure hexadecimal strings without dashes: +### Selecting hardware components ```go -ctx := context.Background() +provider := machineid.New(). + WithCPU(). // processor identifier and feature flags + WithMotherboard(). // motherboard / baseboard serial number + WithSystemUUID(). // BIOS / UEFI system UUID + WithMAC(). // physical network interface MAC addresses + WithDisk() // internal disk serial numbers -// 32 characters (2^5) β€” compact -id, _ := machineid.New().WithCPU().WithSystemUUID().WithFormat(machineid.Format32).ID(ctx) +id, err := provider.ID(ctx) +``` -// 64 characters (2^6) β€” default, full SHA-256 -id, _ = machineid.New().WithCPU().WithSystemUUID().WithFormat(machineid.Format64).ID(ctx) +### MAC address filtering -// 128 characters (2^7) β€” extended -id, _ = machineid.New().WithCPU().WithSystemUUID().WithFormat(machineid.Format128).ID(ctx) +```go +// Physical interfaces only (default, most stable on bare metal) +machineid.New().WithCPU().WithMAC() -// 256 characters (2^8) β€” maximum -id, _ = machineid.New().WithCPU().WithSystemUUID().WithFormat(machineid.Format256).ID(ctx) +// Physical and virtual (VPN, Docker, bridges) +machineid.New().WithCPU().WithMAC(machineid.MACFilterAll) + +// Virtual interfaces only (container-specific fingerprinting) +machineid.New().WithCPU().WithMAC(machineid.MACFilterVirtual) ``` -| Format | Length | Bits | Collision Probability (1 B IDs) | Use Case | -|-----------|--------|------|--------------------------------|----------------------| -| `Format32` | 32 | 128 | ~1.47 Γ— 10⁻²¹ | Compact identifiers | -| `Format64` | 64 | 256 | ~4.32 Γ— 10⁻⁢⁰ | Default, recommended | -| `Format128` | 128 | 512 | Virtually zero | Extended security | -| `Format256` | 256 | 1024 | Astronomically low | Maximum security | +| Filter | Interfaces included | Best for | +|--------|---------------------|----------| +| `MACFilterPhysical` | `en0`, `eth0`, `wlan0`, … (default) | Bare-metal stability | +| `MACFilterAll` | Physical + virtual (`docker0`, `utun`, `bridge`, …) | Maximum uniqueness | +| `MACFilterVirtual` | `docker0`, `utun`, `bridge0`, `veth`, `vmnet`, … | Container fingerprinting | -### Custom Salt +Loopback interfaces and interfaces that are down are always excluded. -A salt ensures the same machine produces different IDs for different applications: +### Output formats ```go -ctx := context.Background() -id, _ := machineid.New(). +machineid.New().WithCPU().WithSystemUUID().WithFormat(machineid.Format32) // 32 hex chars +machineid.New().WithCPU().WithSystemUUID().WithFormat(machineid.Format64) // 64, default +machineid.New().WithCPU().WithSystemUUID().WithFormat(machineid.Format128) // 128 +machineid.New().WithCPU().WithSystemUUID().WithFormat(machineid.Format256) // 256 +``` + +| Format | Length | Bits | Collision probability at 10⁹ IDs | Use case | +|--------|--------|------|----------------------------------|----------| +| `Format32` | 32 | 128 | ~1.5 Γ— 10⁻²¹ | Compact identifiers | +| `Format64` | 64 | 256 | ~4.3 Γ— 10⁻⁢⁰ | **Default, recommended** | +| `Format128` | 128 | 512 | Effectively zero | Extended margin | +| `Format256` | 256 | 1024 | Effectively zero | Maximum margin | + +### Salt + +A salt makes the same machine produce a different ID for each application: + +```go +id, err := machineid.New(). WithCPU(). WithSystemUUID(). WithSalt("my-app-v1"). ID(ctx) ``` -### VM-Friendly Mode +### VM-friendly preset -For virtual machines where disk serials and MACs may be unstable: +Virtual machines and cloud instances change disks, MACs and sometimes motherboards under you. The preset keeps only the CPU and the system UUID: ```go -ctx := context.Background() -id, _ := machineid.New(). - VMFriendly(). // CPU + System UUID only - WithSalt("my-app"). - ID(ctx) +id, err := machineid.New().VMFriendly().WithSalt("my-app").ID(ctx) ``` ### Validation -Check whether a stored ID still matches the current hardware: - ```go -ctx := context.Background() provider := machineid.New().WithCPU().WithSystemUUID() valid, err := provider.Validate(ctx, storedID) ``` ### Diagnostics -Inspect which hardware components were successfully collected: - ```go -ctx := context.Background() -provider := machineid.New(). - WithCPU(). - WithSystemUUID(). - WithDisk() - +provider := machineid.New().WithCPU().WithSystemUUID().WithDisk() id, _ := provider.ID(ctx) -diag := provider.Diagnostics() -fmt.Println("Collected:", diag.Collected) // e.g. [cpu uuid] -fmt.Println("Errors:", diag.Errors) // e.g. map[disk: no internal disk identifiers found] +diag := provider.Diagnostics() // a copy; modify freely +fmt.Println("Collected:", diag.Collected) // [cpu uuid] +fmt.Println("Errors:", diag.Errors) // map[disk:component "disk": no values found] ``` +`Collected` is always in the same order for the same configuration, regardless of which query finished first. + ### Logging -Enable optional logging with any `*slog.Logger` for observability. When no logger is set (the default), there is zero overhead: +Any `*slog.Logger` works, including `slog.Default()`. Without a logger there is no overhead at all. ```go -import ( - "log/slog" - "os" -) - -ctx := context.Background() -logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ - Level: slog.LevelDebug, -})) +logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug})) id, err := machineid.New(). WithCPU(). @@ -275,268 +333,184 @@ id, err := machineid.New(). ID(ctx) ``` -| Log Level | What's Logged | -|-----------|-----------------------------------------------------------------------| -| **Info** | Component collected, fallback triggered, ID generation lifecycle | -| **Warn** | Component failed or returned empty value | -| **Debug** | Command execution details (name, args, duration), raw hardware values | - -The logger is compatible with `slog.Default()` which bridges to the standard `log` package: - -```go -// Use the standard library default logger -provider.WithLogger(slog.Default()) -``` +| Level | What is logged | +|-------|----------------| +| **Info** | Component collected, fallback taken, ID generation lifecycle | +| **Warn** | Component failed or returned an empty value | +| **Debug** | Every command with arguments and duration, raw hardware values, reuse of cached command output | -### Error Handling +### Timeouts and cancellation -The package provides sentinel errors for `errors.Is` and typed errors for `errors.As`: +Every system command runs under the context you pass to `ID` plus its own five second timeout. A context that is already done is rejected before anything runs. When a command is killed by the deadline or by cancellation, the recorded error wraps the context error: ```go -id, err := provider.ID(ctx) -if errors.Is(err, machineid.ErrNoIdentifiers) { - // No hardware identifiers were collected +ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) +defer cancel() + +_, err := provider.ID(ctx) +if errors.Is(err, context.DeadlineExceeded) { + // a hardware query took too long } ``` -#### Sentinel Errors +### Error handling -| Error | Meaning | -|-----------------------|------------------------------------------------------------------| -| `ErrNoIdentifiers` | No hardware identifiers collected with current config | -| `ErrEmptyValue` | A component returned an empty value | -| `ErrNoValues` | A multi-value component (MAC, disk) returned no values | -| `ErrNotFound` | A value was not found in command output or system files | -| `ErrOEMPlaceholder` | A value matches a BIOS/UEFI placeholder ("To be filled...") | -| `ErrAllMethodsFailed` | All collection methods for a component were exhausted | +Sentinel errors for `errors.Is`: -#### Typed Errors +| Error | Meaning | +|-------|---------| +| `ErrNoIdentifiers` | No component produced a value with the current configuration | +| `ErrEmptyValue` | A component returned an empty value | +| `ErrNoValues` | A multi-value component (MAC, disk) returned nothing | +| `ErrNotFound` | The value was missing from command output or system files | +| `ErrOEMPlaceholder` | The value is a BIOS placeholder such as "To be filled by O.E.M." | +| `ErrAllMethodsFailed` | Every source for a component failed | -Use `errors.As` to extract structured context from errors: +Typed errors for `errors.AsType`: ```go -// Check if a system command failed -var cmdErr *machineid.CommandError -if errors.As(err, &cmdErr) { - fmt.Println("command:", cmdErr.Command) // e.g. "sysctl", "ioreg", "wmic" +// A system command failed +if cmdErr, ok := errors.AsType[*machineid.CommandError](err); ok { + fmt.Println("command:", cmdErr.Command) // "sysctl", "ioreg", "wmic", "powershell", … + fmt.Println("stderr:", cmdErr.Stderr) // first line of stderr, if any } -// Check if output parsing failed -var parseErr *machineid.ParseError -if errors.As(err, &parseErr) { - fmt.Println("source:", parseErr.Source) // e.g. "system_profiler JSON" +// Output could not be parsed +if parseErr, ok := errors.AsType[*machineid.ParseError](err); ok { + fmt.Println("source:", parseErr.Source) // "system_profiler hardware JSON", … } -// Inspect diagnostic errors per component -diag := provider.Diagnostics() -var compErr *machineid.ComponentError -if errors.As(diag.Errors["cpu"], &compErr) { +// Per-component cause from the diagnostics +if compErr, ok := errors.AsType[*machineid.ComponentError](diag.Errors["cpu"]); ok { fmt.Println("component:", compErr.Component) fmt.Println("cause:", compErr.Err) } ``` -## CLI Tool +--- -A ready-to-use command-line tool is included. +## βš™οΈ How it works -See the [Installation](#installation) section above for all ways to install the CLI. +1. **Collect** every enabled component, concurrently, one goroutine per component. +2. **Validate** each value. Malformed, nil and max UUIDs and OEM placeholder serials are discarded and the next source is tried. +3. **Sort** the collected `prefix:value` strings so the order of collection never matters. +4. **Hash** the joined string (with the salt, if any) with SHA-256. +5. **Format** to the requested power-of-two length. -### Examples +### Platform sources -```bash -# Default: CPU + motherboard + UUID (64 hex chars) -machineid +| Platform | CPU | System UUID | Motherboard | Disk | MAC | +|----------|-----|-------------|-------------|------|-----| +| 🍎 **macOS** | `sysctl`, `system_profiler` | `system_profiler`, `ioreg` | `system_profiler`, `ioreg` | `system_profiler` | `net.Interfaces` | +| 🐧 **Linux** | `/proc/cpuinfo` | `/sys/class/dmi/id`, `/etc/machine-id` | `/sys/class/dmi/id` | `lsblk`, `/sys/block` | `net.Interfaces` | +| πŸͺŸ **Windows** | `wmic`, PowerShell | `wmic`, PowerShell | `wmic`, PowerShell | `wmic`, PowerShell | `net.Interfaces` | -# Specific components -machineid -cpu -uuid +Every source has a fallback. Some platform specifics worth knowing: -# All hardware sources, compact 32-char format -machineid -all -format 32 +- **macOS**: `system_profiler SPHardwareDataType` is the slowest query and is shared by the UUID, serial and CPU collectors, so it runs once per ID. +- **Windows**: `wmic` was removed from Windows 11 24H2 and Windows Server 2025. The library checks for it once and goes straight to `Get-CimInstance` when it is absent. PowerShell always runs with `-NoProfile -NonInteractive`, so user profiles cannot alter the output. +- **Linux**: no processes are spawned except `lsblk` for disk serials; everything else is read from `/proc` and `/sys`. -# VM-friendly with custom salt -machineid -vm -salt "my-app" +### Performance -# JSON output with diagnostics -machineid -all -json -diagnostics +All queries overlap, so an ID costs roughly the slowest single query. On a MacBook Pro the CLI with `-all` completes in about 0.2 seconds. Windows is dominated by PowerShell start-up and typically finishes in one to three seconds. -# Validate a previously stored ID -machineid -cpu -uuid -validate "b5c42832542981af58c9dc3bc241219e780ff7d276cfad05fac222846edb84f7" +--- -# Include all MACs (physical + virtual) -machineid -mac -mac-filter all +## 🧭 Choosing components -# Info-level logging (fallbacks, lifecycle events) -machineid -all -verbose +The right mix depends on how stable you need the ID to be versus how unique. -# Debug-level logging (command details, raw values, timing) -machineid -all -debug +| Profile | Configuration | Notes | +|---------|---------------|-------| +| **VMs and containers** | `VMFriendly()` | CPU + UUID. Survives disk and NIC changes. | +| **Balanced (recommended)** | `WithCPU().WithSystemUUID().WithMotherboard()` | The CLI default. Stable across reboots and OS reinstalls. | +| **Maximum uniqueness** | `WithCPU().WithSystemUUID().WithMotherboard().WithMAC().WithDisk()` | Changes when a NIC or disk is swapped. | -# Version information -machineid -version -machineid -version-long -``` +### What changes an ID -### All Flags +Be deliberate about which of these your users are likely to do. -| Flag | Description | -|------------------|---------------------------------------------------------------------| -| `-cpu` | Include CPU identifier | -| `-motherboard` | Include motherboard serial number | -| `-uuid` | Include system UUID (BIOS/UEFI) | -| `-mac` | Include network interface MAC addresses | -| `-mac-filter F` | MAC filter: `physical` (default), `all`, or `virtual` | -| `-disk` | Include disk serial numbers | -| `-all` | Include all hardware identifiers (CPU, motherboard, UUID, MAC, disk)| -| `-vm` | VM-friendly mode: CPU + UUID only | -| `-format N` | Output length: `32`, `64` (default), `128`, or `256` hex chars | -| `-salt STRING` | Application-specific salt for unique IDs per app | -| `-validate ID` | Check a stored ID against the current machine | -| `-diagnostics` | Show which hardware components were collected or failed | -| `-json` | Format output as JSON | -| `-verbose` | Info-level logs to stderr (fallbacks, lifecycle) | -| `-debug` | Debug-level logs to stderr (commands, values, timing) | -| `-version` | Print version and exit | -| `-version-long` | Print detailed build information and exit | +| Event | `cpu` | `uuid` | `motherboard` | `mac` | `disk` | +|-------|:-----:|:------:|:-------------:|:-----:|:------:| +| Reboot, OS reinstall | – | – | – | – | – | +| Replace the motherboard | βœ… | βœ… | βœ… | – | – | +| Replace or add a NIC | – | – | – | βœ… | – | +| Replace, add or remove a disk | – | – | – | – | βœ… | +| Kernel or microcode update (Linux) | ⚠️ | – | – | – | – | +| VM migration or resize | ⚠️ | ⚠️ | ⚠️ | βœ… | βœ… | -When no component flags are specified, the default is `-cpu -motherboard -uuid`. +⚠️ On Linux the CPU identifier includes the kernel's CPU `flags` line, which can gain entries after a kernel or microcode update. On virtual machines the hypervisor decides how stable the UUID and motherboard serial are. If either matters to you, prefer `VMFriendly()` plus a salt, or drop `WithCPU()` on Linux. -## How It Works +--- -1. **Collect** β€” gather hardware identifiers based on the provider configuration -2. **Sort** β€” sort identifiers alphabetically for deterministic ordering -3. **Hash** β€” apply SHA-256 to the concatenated identifiers (with optional salt) -4. **Format** β€” truncate or extend the hash to the selected power-of-2 length +## πŸ”’ Security -### Platform Details +- πŸ” SHA-256 is a one-way hash. An ID cannot be reversed into serial numbers, MAC addresses or any other hardware detail. +- πŸ§‚ Salting prevents one application's IDs from being reused by another. +- πŸͺͺ The output contains no personally identifiable information. +- ⏱️ Every system command has a timeout and is killed, together with its output pipes, when the context ends. +- πŸ›‘οΈ Firmware sentinels (nil and max UUIDs, "To be filled by O.E.M.") are rejected so they can never make two different machines share an ID. +- πŸ” Release binaries are signed: Apple Developer ID plus notarization on macOS, Sigstore keyless signatures on Linux. -| Platform | CPU | UUID | Motherboard | Disk | MAC | -|----------|-----|------|-------------|------|-----| -| **macOS** | `sysctl`, `system_profiler` | `system_profiler`, `ioreg` | `system_profiler`, `ioreg` | `system_profiler` | `net.Interfaces` | -| **Linux** | `/proc/cpuinfo` | `/sys/class/dmi/id`, `/etc/machine-id` | `/sys/class/dmi/id` | `lsblk`, `/sys/block` | `net.Interfaces` | -| **Windows** | `wmic`, `PowerShell` | `wmic`, `PowerShell` | `wmic`, `PowerShell` | `wmic`, `PowerShell` | `net.Interfaces` | +Please report vulnerabilities as described in [SECURITY.md](SECURITY.md). -Each source has fallback methods for resilience across OS versions and configurations. +--- -> **Performance note**: On Windows, all hardware queries run **concurrently** using goroutines. This reduces total latency from the sum of all `wmic`/PowerShell calls (which are slow due to process startup overhead) to the maximum of any single call β€” typically cutting ID generation time from ~8-12s to ~2-3s. +## πŸ§ͺ Testing -## Testing - -The library supports dependency injection for deterministic testing without real system commands: +Inject a `CommandExecutor` to run the library against fixtures instead of real commands. Executors must be safe for concurrent use, because components are collected in parallel. ```go -type mockExecutor struct { +type fakeExecutor struct { mu sync.RWMutex outputs map[string]string } -func (m *mockExecutor) Execute(ctx context.Context, name string, args ...string) (string, error) { - m.mu.RLock() - defer m.mu.RUnlock() - if output, ok := m.outputs[name]; ok { - return output, nil +func (f *fakeExecutor) Execute(ctx context.Context, name string, args ...string) (string, error) { + f.mu.RLock() + defer f.mu.RUnlock() + if out, ok := f.outputs[name]; ok { + return out, nil } - return "", fmt.Errorf("command not found: %s", name) + return "", fmt.Errorf("command not configured: %s", name) } -ctx := context.Background() provider := machineid.New(). - WithExecutor(&mockExecutor{ - outputs: map[string]string{ - "sysctl": "Intel Core i9", - }, - }). + WithExecutor(&fakeExecutor{outputs: map[string]string{"sysctl": "Intel Core i9"}}). WithCPU() id, err := provider.ID(ctx) ``` -> **Note**: Custom executors must be safe for concurrent use since Windows collects hardware identifiers in parallel goroutines. - -Run the test suite: +Run the suite: ```bash -go test -v -race ./... -``` - -## Security Considerations - -- SHA-256 is a cryptographically secure one-way hash β€” hardware details cannot be recovered from an ID -- Sorting ensures consistent output regardless of collection order -- Salt support prevents cross-application ID reuse -- No personally identifiable information (PII) is exposed in the output - -## Best Practices - -### Choosing a Format - -| Format | Recommendation | -|--------|----------------| -| `Format32` | Embedded systems or storage-constrained environments | -| `Format64` | **Recommended for most use cases** (default) | -| `Format128` | Extra security margin or regulatory requirements | -| `Format256` | Maximum security for critical applications | - -### Hardware Identifier Selection - -```go -ctx := context.Background() - -// Minimal (VMs, containers) -id, _ := machineid.New().VMFriendly().ID(ctx) - -// Balanced (recommended) -id, _ = machineid.New(). - WithCPU(). - WithSystemUUID(). - WithMotherboard(). - ID(ctx) - -// Maximum (most unique, but sensitive to hardware changes) -id, _ = machineid.New(). - WithCPU(). - WithSystemUUID(). - WithMotherboard(). - WithMAC(). - WithDisk(). - ID(ctx) +go test -race ./... ``` -## Troubleshooting - -### Git Tag Push Error: "push declined due to repository rule violations" +Runnable examples are listed with `go doc -ex github.com/slashdevops/machineid`. -If you encounter this error when trying to push a tag: - -``` -! [remote rejected] v0.0.1 -> v0.0.1 (push declined due to repository rule violations) -error: failed to push some refs to 'github.com:slashdevops/machineid.git' -``` +--- -**Cause**: This happens when you try to create a tag with a version number that is older than existing tags. GitHub repository rules enforce semantic versioning order to prevent version rollback. +## πŸ› οΈ Troubleshooting -**Solution**: Create a tag with a version number higher than all existing tags. +**`ErrNoIdentifiers` on a VM or in a container.** The hypervisor or runtime is hiding the hardware. Run `machineid -all -diagnostics -debug` to see which sources fail, then use `VMFriendly()` or a subset that works in that environment. -1. Check existing tags: +**The ID changed after a Linux kernel update.** See [What changes an ID](#what-changes-an-id). The CPU flags line moved. Drop `WithCPU()` on Linux or switch to `VMFriendly()`. - ```bash - git tag -l - ``` +**Windows is slow.** PowerShell start-up dominates. Make sure you are on a build where `wmic` is either present or cleanly absent; the library handles both. Use `-debug` to see per-command timing. -2. Create the next appropriate version: +**Git tag rejected: "push declined due to repository rule violations".** The repository enforces ascending semantic versions. Check `git tag -l` and tag a version higher than every existing one. - ```bash - # If the latest tag is v0.0.2, use v0.0.3 or higher - git tag -a "v0.0.3" -m "Release v0.0.3" - git push origin v0.0.3 - ``` +--- -For more information about versioning and releases, see [CONTRIBUTING.md](CONTRIBUTING.md). +## 🀝 Contributing -## Contributing +Contributions are welcome. [CONTRIBUTING.md](CONTRIBUTING.md) covers the toolchain, code style, testing and the release process. Please open an issue before large changes so we can agree on the approach. -Contributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines on how to contribute, including information about versioning, testing, and code style. +--- -## License +## πŸ“„ License [Apache License 2.0](LICENSE) diff --git a/cmd/machineid/main.go b/cmd/machineid/main.go index 342c82b..881b6e7 100644 --- a/cmd/machineid/main.go +++ b/cmd/machineid/main.go @@ -1,3 +1,17 @@ +// Command machineid prints a deterministic, hardware-derived identifier for +// the current machine. +// +// With no component flags it combines the CPU, motherboard serial and system +// UUID. Any subset can be selected with -cpu, -motherboard, -uuid, -mac and +// -disk; -all selects everything and -vm selects the VM-safe subset +// (CPU + UUID). The output length is chosen with -format (32, 64, 128 or +// 256 hex characters) and -salt mixes an application-specific string into +// the hash. -validate compares a stored ID against the current machine, +// -json and -diagnostics control the output, and -verbose or -debug write +// logs to stderr. +// +// Exit codes: 0 success, 1 generation or validation failed, 2 invalid +// arguments. Run machineid -h for the full flag list. package main import ( @@ -7,9 +21,11 @@ import ( "fmt" "log/slog" "os" + "os/signal" "runtime" "runtime/debug" "strings" + "syscall" "github.com/slashdevops/machineid" "github.com/slashdevops/machineid/internal/version" @@ -21,7 +37,7 @@ func main() { // Hardware component flags cpu := flag.Bool("cpu", false, "Include CPU identifier") motherboard := flag.Bool("motherboard", false, "Include motherboard serial number") - uuid := flag.Bool("uuid", false, "Include system UUID (BIOS/UEFI)") + sysUUID := flag.Bool("uuid", false, "Include system UUID (BIOS/UEFI)") mac := flag.Bool("mac", false, "Include network interface MAC addresses") macFilterFlag := flag.String("mac-filter", "physical", "MAC address filter: physical, all, or virtual (requires -mac or -all)") disk := flag.Bool("disk", false, "Include disk serial numbers") @@ -99,7 +115,7 @@ func main() { case *all: provider.WithCPU().WithMotherboard().WithSystemUUID().WithMAC(mFilter).WithDisk() default: - if !*cpu && !*motherboard && !*uuid && !*mac && !*disk { + if !*cpu && !*motherboard && !*sysUUID && !*mac && !*disk { // Default: CPU + Motherboard + System UUID provider.WithCPU().WithMotherboard().WithSystemUUID() } else { @@ -109,7 +125,7 @@ func main() { if *motherboard { provider.WithMotherboard() } - if *uuid { + if *sysUUID { provider.WithSystemUUID() } if *mac { @@ -121,8 +137,10 @@ func main() { } } - // Generate machine ID - ctx := context.Background() + // Generate machine ID. Ctrl-C or SIGTERM cancels the context so any + // in-flight system command is killed instead of orphaned. + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() id, err := provider.ID(ctx) if err != nil { diff --git a/collect.go b/collect.go new file mode 100644 index 0000000..4de0f72 --- /dev/null +++ b/collect.go @@ -0,0 +1,99 @@ +package machineid + +import ( + "context" + "log/slog" + "runtime/pprof" + "sync" +) + +// componentTask describes how to collect one hardware component. +// Exactly one of single or multi must be set. +type componentTask struct { + single func(ctx context.Context) (string, error) + multi func(ctx context.Context) ([]string, error) + component string + prefix string +} + +// componentResult holds the outcome of a single component collection. +type componentResult struct { + err error + component string + prefix string + value string // for single-value components + values []string // for multi-value components (MAC, disk) + multi bool // true if this is a multi-value result +} + +// pprofComponentLabel is the runtime/pprof label attached to every collector +// goroutine. Since Go 1.27, goroutine labels appear in tracebacks, so a hung +// system command shows which component it belongs to. +const pprofComponentLabel = "machineid.component" + +// runComponentTasks runs every task concurrently and folds the results into +// identifiers in task order. Hardware queries are dominated by process +// start-up cost (system_profiler, wmic, PowerShell), so running them in +// parallel reduces total latency to that of the slowest single command. +// +// Results are written into a slice indexed by task position rather than sent +// over a channel, so identifiers and diag.Collected are deterministic no +// matter which command finishes first, and no goroutine can outlive the call. +func runComponentTasks(ctx context.Context, tasks []componentTask, diag *DiagnosticInfo, logger *slog.Logger) []string { + if len(tasks) == 0 { + return nil + } + + results := make([]componentResult, len(tasks)) + + var wg sync.WaitGroup + for i, task := range tasks { + wg.Go(func() { + pprof.Do(ctx, pprof.Labels(pprofComponentLabel, task.component), func(ctx context.Context) { + results[i] = runComponentTask(ctx, task) + }) + }) + } + wg.Wait() + + var identifiers []string + for _, r := range results { + if r.multi { + identifiers = appendMultiResult(identifiers, r, diag, logger) + } else { + identifiers = appendSingleResult(identifiers, r, diag, logger) + } + } + + return identifiers +} + +// runComponentTask executes one task and packages its outcome. +func runComponentTask(ctx context.Context, task componentTask) componentResult { + r := componentResult{component: task.component, prefix: task.prefix} + + if task.multi != nil { + r.multi = true + r.values, r.err = task.multi(ctx) + + return r + } + + r.value, r.err = task.single(ctx) + + return r +} + +// appendSingleResult processes a single-value component result into identifiers. +func appendSingleResult(identifiers []string, r componentResult, diag *DiagnosticInfo, logger *slog.Logger) []string { + return appendIdentifierIfValid(identifiers, func() (string, error) { + return r.value, r.err + }, r.prefix, diag, r.component, logger) +} + +// appendMultiResult processes a multi-value component result into identifiers. +func appendMultiResult(identifiers []string, r componentResult, diag *DiagnosticInfo, logger *slog.Logger) []string { + return appendIdentifiersIfValid(identifiers, func() ([]string, error) { + return r.values, r.err + }, r.prefix, diag, r.component, logger) +} diff --git a/collect_test.go b/collect_test.go new file mode 100644 index 0000000..df0e7a0 --- /dev/null +++ b/collect_test.go @@ -0,0 +1,174 @@ +package machineid + +import ( + "bytes" + "context" + "errors" + "fmt" + "runtime/pprof" + "strings" + "sync/atomic" + "testing" + "time" +) + +func TestRunComponentTasksEmpty(t *testing.T) { + diag := &DiagnosticInfo{Errors: make(map[string]error)} + if got := runComponentTasks(context.Background(), nil, diag, nil); got != nil { + t.Errorf("Expected nil identifiers for no tasks, got %v", got) + } +} + +// TestRunComponentTasksDeterministicOrder verifies that identifiers and +// diag.Collected follow task declaration order even when tasks finish in +// reverse order. +func TestRunComponentTasksDeterministicOrder(t *testing.T) { + tasks := []componentTask{ + {component: "a", prefix: "a:", single: func(context.Context) (string, error) { + time.Sleep(30 * time.Millisecond) + return "1", nil + }}, + {component: "b", prefix: "b:", multi: func(context.Context) ([]string, error) { + time.Sleep(10 * time.Millisecond) + return []string{"2", "3"}, nil + }}, + {component: "c", prefix: "c:", single: func(context.Context) (string, error) { + return "4", nil + }}, + } + + for range 5 { + diag := &DiagnosticInfo{Errors: make(map[string]error)} + got := runComponentTasks(context.Background(), tasks, diag, nil) + + want := []string{"a:1", "b:2", "b:3", "c:4"} + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("identifiers = %v, want %v", got, want) + } + if strings.Join(diag.Collected, ",") != "a,b,c" { + t.Fatalf("Collected = %v, want [a b c]", diag.Collected) + } + } +} + +func TestRunComponentTasksRecordsErrors(t *testing.T) { + boom := errors.New("boom") + tasks := []componentTask{ + {component: "ok", prefix: "ok:", single: func(context.Context) (string, error) { return "v", nil }}, + {component: "fail", prefix: "fail:", single: func(context.Context) (string, error) { return "", boom }}, + {component: "empty", prefix: "empty:", single: func(context.Context) (string, error) { return "", nil }}, + {component: "none", prefix: "none:", multi: func(context.Context) ([]string, error) { return nil, nil }}, + } + + diag := &DiagnosticInfo{Errors: make(map[string]error)} + got := runComponentTasks(context.Background(), tasks, diag, nil) + + if len(got) != 1 || got[0] != "ok:v" { + t.Errorf("identifiers = %v, want [ok:v]", got) + } + if !errors.Is(diag.Errors["fail"], boom) { + t.Errorf("Errors[fail] = %v, want wrapped boom", diag.Errors["fail"]) + } + if !errors.Is(diag.Errors["empty"], ErrEmptyValue) { + t.Errorf("Errors[empty] = %v, want ErrEmptyValue", diag.Errors["empty"]) + } + if !errors.Is(diag.Errors["none"], ErrNoValues) { + t.Errorf("Errors[none] = %v, want ErrNoValues", diag.Errors["none"]) + } + for _, c := range []string{"fail", "empty", "none"} { + if _, ok := errors.AsType[*ComponentError](diag.Errors[c]); !ok { + t.Errorf("Errors[%s] = %T, want *ComponentError", c, diag.Errors[c]) + } + } +} + +// TestRunComponentTasksRunsConcurrently verifies that tasks overlap in time. +func TestRunComponentTasksRunsConcurrently(t *testing.T) { + const n = 4 + var inFlight, peak atomic.Int32 + + var tasks []componentTask + for i := range n { + tasks = append(tasks, componentTask{component: fmt.Sprint(i), prefix: "p:", + single: func(context.Context) (string, error) { + cur := inFlight.Add(1) + for { + old := peak.Load() + if cur <= old || peak.CompareAndSwap(old, cur) { + break + } + } + time.Sleep(20 * time.Millisecond) + inFlight.Add(-1) + return "x", nil + }}) + } + + runComponentTasks(context.Background(), tasks, nil, nil) + + if peak.Load() < 2 { + t.Errorf("peak concurrency = %d, expected tasks to overlap", peak.Load()) + } +} + +// TestRunComponentTasksPropagatesContext verifies that the task context is +// derived from the caller's context and carries the pprof component label. +func TestRunComponentTasksPropagatesContext(t *testing.T) { + type key struct{} + ctx := context.WithValue(context.Background(), key{}, "marker") + + var sawValue, sawLabel bool + tasks := []componentTask{{component: "cpu", prefix: "cpu:", + single: func(ctx context.Context) (string, error) { + sawValue = ctx.Value(key{}) == "marker" + label, ok := pprof.Label(ctx, pprofComponentLabel) + sawLabel = ok && label == "cpu" + return "v", nil + }}} + + runComponentTasks(ctx, tasks, nil, nil) + + if !sawValue { + t.Error("task did not receive the caller's context") + } + if !sawLabel { + t.Error("task context is missing the pprof component label") + } +} + +// TestRunComponentTasksNoGoroutineLeak uses the Go 1.27 goroutineleak +// profile to assert that no collector goroutine outlives the call, even +// when tasks block until the context is cancelled. +func TestRunComponentTasksNoGoroutineLeak(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + + tasks := []componentTask{ + {component: "block", prefix: "b:", single: func(ctx context.Context) (string, error) { + <-ctx.Done() + return "", ctx.Err() + }}, + {component: "canceller", prefix: "c:", single: func(context.Context) (string, error) { + cancel() + return "v", nil + }}, + } + + diag := &DiagnosticInfo{Errors: make(map[string]error)} + runComponentTasks(ctx, tasks, diag, nil) + + if !errors.Is(diag.Errors["block"], context.Canceled) { + t.Errorf("Errors[block] = %v, want context.Canceled", diag.Errors["block"]) + } + + prof := pprof.Lookup("goroutineleak") + if prof == nil { + t.Fatal("goroutineleak profile not available") + } + var buf bytes.Buffer + if err := prof.WriteTo(&buf, 1); err != nil { + t.Fatalf("WriteTo: %v", err) + } + if strings.Contains(buf.String(), "runComponentTask") { + t.Errorf("leaked collector goroutine:\n%s", buf.String()) + } +} diff --git a/darwin.go b/darwin.go index 330d320..038e397 100644 --- a/darwin.go +++ b/darwin.go @@ -17,11 +17,6 @@ var ( ioregSerialRe = regexp.MustCompile(`"IOPlatformSerialNumber"\s*=\s*"([^"]+)"`) ) -// nullUUID is the all-zero UUID that some firmware implementations return -// when no real hardware UUID is programmed. It must be rejected so it -// cannot contribute to the machine ID. -const nullUUID = "00000000-0000-0000-0000-000000000000" - // spHardwareDataType represents the JSON output of `system_profiler SPHardwareDataType -json`. type spHardwareDataType struct { SPHardwareDataType []spHardwareEntry `json:"SPHardwareDataType"` @@ -56,46 +51,57 @@ type spPhysicalDrive struct { SmartStatus string `json:"smart_status"` } -// collectIdentifiers gathers macOS-specific hardware identifiers based on provider config. +// collectIdentifiers gathers macOS-specific hardware identifiers concurrently. +// The UUID, serial and CPU collectors all read +// `system_profiler SPHardwareDataType -json`, the slowest command involved, +// so the executor is wrapped in a per-call memo and that process runs once. func collectIdentifiers(ctx context.Context, p *Provider, diag *DiagnosticInfo) ([]string, error) { - var identifiers []string logger := p.logger + executor := newMemoExecutor(p.commandExecutor, logger) + + var tasks []componentTask if p.includeSystemUUID { - identifiers = appendIdentifierIfValid(identifiers, func() (string, error) { - return macOSHardwareUUID(ctx, p.commandExecutor, logger) - }, "uuid:", diag, ComponentSystemUUID, logger) + tasks = append(tasks, componentTask{component: ComponentSystemUUID, prefix: "uuid:", + single: func(ctx context.Context) (string, error) { + return macOSHardwareUUID(ctx, executor, logger) + }}) } if p.includeMotherboard { - identifiers = appendIdentifierIfValid(identifiers, func() (string, error) { - return macOSSerialNumber(ctx, p.commandExecutor, logger) - }, "serial:", diag, ComponentMotherboard, logger) + tasks = append(tasks, componentTask{component: ComponentMotherboard, prefix: "serial:", + single: func(ctx context.Context) (string, error) { + return macOSSerialNumber(ctx, executor, logger) + }}) } if p.includeCPU { - identifiers = appendIdentifierIfValid(identifiers, func() (string, error) { - return macOSCPUInfo(ctx, p.commandExecutor, logger) - }, "cpu:", diag, ComponentCPU, logger) + tasks = append(tasks, componentTask{component: ComponentCPU, prefix: "cpu:", + single: func(ctx context.Context) (string, error) { + return macOSCPUInfo(ctx, executor, logger) + }}) } if p.includeMAC { - identifiers = appendIdentifiersIfValid(identifiers, func() ([]string, error) { - return collectMACAddresses(p.macFilter, logger) - }, "mac:", diag, ComponentMAC, logger) + tasks = append(tasks, componentTask{component: ComponentMAC, prefix: "mac:", + multi: func(context.Context) ([]string, error) { + return collectMACAddresses(p.macFilter, logger) + }}) } if p.includeDisk { - identifiers = appendIdentifiersIfValid(identifiers, func() ([]string, error) { - return macOSDiskInfo(ctx, p.commandExecutor, logger) - }, "disk:", diag, ComponentDisk, logger) + tasks = append(tasks, componentTask{component: ComponentDisk, prefix: "disk:", + multi: func(ctx context.Context) ([]string, error) { + return macOSDiskInfo(ctx, executor, logger) + }}) } - return identifiers, nil + return runComponentTasks(ctx, tasks, diag, logger), nil } // macOSHardwareUUID retrieves hardware UUID using system_profiler with JSON parsing. -// Null UUIDs (all zeros) are rejected so the fallback path is triggered. +// Malformed, nil (all zeros) and max (all ones) UUIDs are rejected so the +// fallback path is triggered. func macOSHardwareUUID(ctx context.Context, executor CommandExecutor, logger *slog.Logger) (string, error) { output, err := executeCommand(ctx, executor, logger, "system_profiler", "SPHardwareDataType", "-json") if err == nil { @@ -103,13 +109,13 @@ func macOSHardwareUUID(ctx context.Context, executor CommandExecutor, logger *sl return e.PlatformUUID }) if parseErr == nil { - if uuid == nullUUID { - if logger != nil { - logger.Debug("system_profiler returned null UUID, falling back") - } - } else { + if isValidUUID(uuid) { return uuid, nil } + + if logger != nil { + logger.Debug("system_profiler returned invalid UUID, falling back", "uuid", uuid) + } } else if logger != nil { logger.Debug("system_profiler UUID parsing failed", "error", parseErr) } @@ -124,7 +130,7 @@ func macOSHardwareUUID(ctx context.Context, executor CommandExecutor, logger *sl } // macOSHardwareUUIDViaIOReg retrieves hardware UUID using ioreg as fallback. -// Null UUIDs (all zeros) are rejected with ErrNotFound. +// Malformed, nil and max UUIDs are rejected with ErrNotFound. func macOSHardwareUUIDViaIOReg(ctx context.Context, executor CommandExecutor, logger *slog.Logger) (string, error) { output, err := executeCommand(ctx, executor, logger, "ioreg", "-d2", "-c", "IOPlatformExpertDevice") if err != nil { @@ -133,9 +139,9 @@ func macOSHardwareUUIDViaIOReg(ctx context.Context, executor CommandExecutor, lo match := ioregUUIDRe.FindStringSubmatch(output) if len(match) > 1 { - if match[1] == nullUUID { + if !isValidUUID(match[1]) { if logger != nil { - logger.Debug("ioreg returned null UUID") + logger.Debug("ioreg returned invalid UUID", "uuid", match[1]) } return "", &ParseError{Source: "ioreg output", Err: ErrNotFound} diff --git a/darwin_test.go b/darwin_test.go index f3afd4e..7fd0793 100644 --- a/darwin_test.go +++ b/darwin_test.go @@ -16,7 +16,7 @@ import ( func TestExtractHardwareFieldValid(t *testing.T) { jsonOutput := `{ "SPHardwareDataType": [{ - "platform_UUID": "12345-67890", + "platform_UUID": "E1B2C3D4-0001-4A5B-8C6D-7E8F9A0B1C2D", "serial_number": "C02TEST123", "chip_type": "Apple M1 Pro", "machine_model": "MacBookPro18,3" @@ -28,8 +28,8 @@ func TestExtractHardwareFieldValid(t *testing.T) { if err != nil { t.Errorf("Unexpected error: %v", err) } - if result != "12345-67890" { - t.Errorf("Expected '12345-67890', got '%s'", result) + if result != "E1B2C3D4-0001-4A5B-8C6D-7E8F9A0B1C2D" { + t.Errorf("Expected 'E1B2C3D4-0001-4A5B-8C6D-7E8F9A0B1C2D', got '%s'", result) } } @@ -98,7 +98,7 @@ func TestMacOSHardwareUUIDViaIORegSuccess(t *testing.T) { ioregOutput := ` +-o IOPlatformExpertDevice | { - | "IOPlatformUUID" = "ABCD-1234-EFGH-5678" + | "IOPlatformUUID" = "E1B2C3D4-0002-4A5B-8C6D-7E8F9A0B1C2D" | } ` mock.setOutput("ioreg", ioregOutput) @@ -107,8 +107,8 @@ func TestMacOSHardwareUUIDViaIORegSuccess(t *testing.T) { if err != nil { t.Errorf("Unexpected error: %v", err) } - if result != "ABCD-1234-EFGH-5678" { - t.Errorf("Expected 'ABCD-1234-EFGH-5678', got '%s'", result) + if result != "E1B2C3D4-0002-4A5B-8C6D-7E8F9A0B1C2D" { + t.Errorf("Expected 'E1B2C3D4-0002-4A5B-8C6D-7E8F9A0B1C2D', got '%s'", result) } } @@ -354,14 +354,14 @@ func TestMacOSHardwareUUIDWithLogger(t *testing.T) { mock := newMockExecutor() mock.setOutput("system_profiler", "not json") // Will cause parse error - mock.setOutput("ioreg", `"IOPlatformUUID" = "FALLBACK-UUID-123"`) + mock.setOutput("ioreg", `"IOPlatformUUID" = "E1B2C3D4-0003-4A5B-8C6D-7E8F9A0B1C2D"`) result, err := macOSHardwareUUID(context.Background(), mock, logger) if err != nil { t.Fatalf("Unexpected error: %v", err) } - if result != "FALLBACK-UUID-123" { - t.Errorf("Expected 'FALLBACK-UUID-123', got %q", result) + if result != "E1B2C3D4-0003-4A5B-8C6D-7E8F9A0B1C2D" { + t.Errorf("Expected 'E1B2C3D4-0003-4A5B-8C6D-7E8F9A0B1C2D', got %q", result) } if !bytes.Contains(buf.Bytes(), []byte("system_profiler UUID parsing failed")) { t.Error("Expected 'system_profiler UUID parsing failed' in log output") @@ -377,14 +377,14 @@ func TestMacOSHardwareUUIDWithLogger(t *testing.T) { mock := newMockExecutor() mock.setError("system_profiler", fmt.Errorf("command failed")) - mock.setOutput("ioreg", `"IOPlatformUUID" = "FALLBACK-UUID-456"`) + mock.setOutput("ioreg", `"IOPlatformUUID" = "E1B2C3D4-0004-4A5B-8C6D-7E8F9A0B1C2D"`) result, err := macOSHardwareUUID(context.Background(), mock, logger) if err != nil { t.Fatalf("Unexpected error: %v", err) } - if result != "FALLBACK-UUID-456" { - t.Errorf("Expected 'FALLBACK-UUID-456', got %q", result) + if result != "E1B2C3D4-0004-4A5B-8C6D-7E8F9A0B1C2D" { + t.Errorf("Expected 'E1B2C3D4-0004-4A5B-8C6D-7E8F9A0B1C2D', got %q", result) } if !bytes.Contains(buf.Bytes(), []byte("falling back to ioreg for hardware UUID")) { t.Error("Expected fallback log message") @@ -658,8 +658,8 @@ func TestExtractHardwareFieldErrorTypes(t *testing.T) { _, err := extractHardwareField("not json", func(e spHardwareEntry) string { return e.PlatformUUID }) - var parseErr *ParseError - if !errors.As(err, &parseErr) { + parseErr, ok := errors.AsType[*ParseError](err) + if !ok { t.Fatalf("Expected ParseError, got %T: %v", err, err) } if parseErr.Source != "system_profiler hardware JSON" { @@ -671,8 +671,7 @@ func TestExtractHardwareFieldErrorTypes(t *testing.T) { _, err := extractHardwareField(`{"SPHardwareDataType": []}`, func(e spHardwareEntry) string { return e.PlatformUUID }) - var parseErr *ParseError - if !errors.As(err, &parseErr) { + if _, ok := errors.AsType[*ParseError](err); !ok { t.Fatalf("Expected ParseError, got %T: %v", err, err) } if !errors.Is(err, ErrNotFound) { @@ -684,8 +683,7 @@ func TestExtractHardwareFieldErrorTypes(t *testing.T) { _, err := extractHardwareField(`{"SPHardwareDataType": [{"platform_UUID": ""}]}`, func(e spHardwareEntry) string { return e.PlatformUUID }) - var parseErr *ParseError - if !errors.As(err, &parseErr) { + if _, ok := errors.AsType[*ParseError](err); !ok { t.Fatalf("Expected ParseError, got %T: %v", err, err) } if !errors.Is(err, ErrEmptyValue) { @@ -701,8 +699,7 @@ func TestMacOSHardwareUUIDViaIORegErrorType(t *testing.T) { _, err := macOSHardwareUUIDViaIOReg(context.Background(), mock, nil) - var parseErr *ParseError - if !errors.As(err, &parseErr) { + if _, ok := errors.AsType[*ParseError](err); !ok { t.Fatalf("Expected ParseError, got %T: %v", err, err) } if !errors.Is(err, ErrNotFound) { @@ -734,14 +731,14 @@ func TestMacOSHardwareUUIDRejectsNullFromSystemProfiler(t *testing.T) { "serial_number": "C02TEST" }] }`) - mock.setOutput("ioreg", `"IOPlatformUUID" = "REAL-UUID-FROM-IOREG"`) + mock.setOutput("ioreg", `"IOPlatformUUID" = "E1B2C3D4-0005-4A5B-8C6D-7E8F9A0B1C2D"`) result, err := macOSHardwareUUID(context.Background(), mock, nil) if err != nil { t.Fatalf("Unexpected error: %v", err) } - if result != "REAL-UUID-FROM-IOREG" { - t.Errorf("Expected 'REAL-UUID-FROM-IOREG', got %q", result) + if result != "E1B2C3D4-0005-4A5B-8C6D-7E8F9A0B1C2D" { + t.Errorf("Expected 'E1B2C3D4-0005-4A5B-8C6D-7E8F9A0B1C2D', got %q", result) } } @@ -755,13 +752,13 @@ func TestMacOSHardwareUUIDRejectsNullFromSystemProfilerWithLogger(t *testing.T) "platform_UUID": "00000000-0000-0000-0000-000000000000" }] }`) - mock.setOutput("ioreg", `"IOPlatformUUID" = "REAL-UUID"`) + mock.setOutput("ioreg", `"IOPlatformUUID" = "E1B2C3D4-0006-4A5B-8C6D-7E8F9A0B1C2D"`) if _, err := macOSHardwareUUID(context.Background(), mock, logger); err != nil { t.Fatalf("Unexpected error: %v", err) } - if !bytes.Contains(buf.Bytes(), []byte("system_profiler returned null UUID")) { - t.Error("Expected 'system_profiler returned null UUID' log") + if !bytes.Contains(buf.Bytes(), []byte("system_profiler returned invalid UUID")) { + t.Error("Expected 'system_profiler returned invalid UUID' log") } } @@ -775,8 +772,7 @@ func TestMacOSHardwareUUIDViaIORegRejectsNull(t *testing.T) { if err == nil { t.Fatal("Expected error for null UUID from ioreg") } - var parseErr *ParseError - if !errors.As(err, &parseErr) { + if _, ok := errors.AsType[*ParseError](err); !ok { t.Errorf("Expected ParseError, got %T", err) } if !errors.Is(err, ErrNotFound) { diff --git a/doc.go b/doc.go index 8e34a03..c984767 100644 --- a/doc.go +++ b/doc.go @@ -84,10 +84,20 @@ // // valid, err := provider.Validate(ctx, storedID) // +// # Context, Timeouts and Cancellation +// +// [Provider.ID] takes a [context.Context] that bounds every system command it +// runs. Each command additionally has its own five second timeout. A context +// that is already done is rejected before any collection starts. When a +// command is ended by the deadline or by cancellation, the [CommandError] +// recorded for that component wraps [context.DeadlineExceeded] or +// [context.Canceled], so callers can match it with [errors.Is]. +// // # Diagnostics // // After calling [Provider.ID], call [Provider.Diagnostics] to inspect which -// components were collected and which encountered errors: +// components were collected and which encountered errors. The returned value +// is a copy and can be modified freely: // // diag := provider.Diagnostics() // fmt.Println("Collected:", diag.Collected) @@ -110,7 +120,8 @@ // Log levels: // - Info: component collected, fallback triggered, ID generation lifecycle // - Warn: component failed or returned empty value -// - Debug: command execution details, raw hardware values, timing +// - Debug: command execution details, raw hardware values, timing, reuse of +// cached command output // // # Errors // @@ -123,33 +134,60 @@ // - [ErrOEMPlaceholder] β€” a value matches a BIOS/UEFI OEM placeholder // - [ErrAllMethodsFailed] β€” all collection methods for a component were exhausted // -// Typed errors provide structured context for [errors.As]: +// Typed errors provide structured context for [errors.AsType]: // -// - [CommandError] β€” a system command execution failed (includes the command name) +// - [CommandError] β€” a system command execution failed (includes the command +// name and the first line of its stderr) // - [ParseError] β€” output parsing failed (includes the data source) // - [ComponentError] β€” a hardware component failed (includes the component name) // // Errors in [DiagnosticInfo.Errors] are wrapped in [ComponentError], so callers // can inspect both the component name and the underlying cause: // -// var compErr *machineid.ComponentError -// if errors.As(diag.Errors["cpu"], &compErr) { +// if compErr, ok := errors.AsType[*machineid.ComponentError](diag.Errors["cpu"]); ok { // fmt.Println("component:", compErr.Component) // fmt.Println("cause:", compErr.Err) // } // +// # Input Validation +// +// Values that cannot identify a machine are rejected before hashing: +// +// - System UUIDs must parse as a UUID and must not be the nil UUID +// (all zeros) or the max UUID (all ones), which firmware reports when no +// UUID is programmed. The raw string is hashed exactly as the platform +// reported it, so a well-formed UUID always produces the same ID. +// - Serial numbers equal to the BIOS placeholder "To be filled by O.E.M." +// are discarded. +// +// A rejected value falls through to the next source for that component, and +// the reason is recorded in [DiagnosticInfo.Errors] if every source fails. +// // # Thread Safety // // A [Provider] is safe for concurrent use after configuration is complete. // The first successful call to [Provider.ID] freezes the configuration and // caches the result; subsequent calls return the cached value. // +// # Concurrency and Performance +// +// On every platform, the enabled components are collected concurrently, one +// goroutine per component, so the total latency is that of the slowest +// single query rather than the sum. Results are folded in a fixed order, so +// the ID and [DiagnosticInfo.Collected] are deterministic no matter which +// query finishes first. Each collector goroutine carries a runtime/pprof +// label (machineid.component=) that appears in tracebacks. +// +// On macOS, the UUID, serial and CPU collectors share one execution of +// `system_profiler SPHardwareDataType -json` per [Provider.ID] call instead +// of spawning it once each. +// // # Testing // // Inject a custom [CommandExecutor] via [Provider.WithExecutor] to replace // real system commands with deterministic test doubles. Custom executors -// must be safe for concurrent use, since Windows collects hardware -// identifiers in parallel goroutines. +// must be safe for concurrent use, since components are collected in +// parallel goroutines. Passing nil keeps the current executor. // // provider := machineid.New(). // WithExecutor(myMock). @@ -158,19 +196,20 @@ // # Platform Support // // Supported operating systems: macOS (darwin), Linux, and Windows. Each -// platform uses native tools to collect hardware data: +// platform uses native tools to collect hardware data, with fallbacks: // -// - macOS: system_profiler, ioreg, sysctl +// - macOS: system_profiler (primary), ioreg and sysctl (fallbacks) // - Linux: /proc/cpuinfo, /sys/class/dmi/id, /etc/machine-id, lsblk, /sys/block -// - Windows: wmic, PowerShell (Get-CimInstance) β€” collected concurrently +// - Windows: wmic when present, PowerShell Get-CimInstance otherwise // -// On Windows, all hardware queries run in parallel using goroutines to -// minimize latency from slow process startup (wmic and PowerShell). Each -// command uses wmic as the primary method with PowerShell as fallback. +// On Windows 11 24H2 and Windows Server 2025, where wmic has been removed, +// the library detects its absence and uses PowerShell directly. PowerShell +// is always started with -NoProfile -NonInteractive so user profiles cannot +// alter the output. // // # Installation // -// To use machineid as a library in your Go project: +// To use machineid as a library in your Go project (Go 1.27 or newer): // // go get github.com/slashdevops/machineid // @@ -178,8 +217,8 @@ // // go install github.com/slashdevops/machineid/cmd/machineid@latest // -// Precompiled binaries for macOS, Linux, and Windows are available on the -// [releases page]: https://github.com/slashdevops/machineid/releases +// Signed binaries for macOS (13 Ventura or newer) and Linux are available on +// the [releases page]: https://github.com/slashdevops/machineid/releases // // # CLI Tool // @@ -191,8 +230,12 @@ // machineid -all -format 32 -json # all hardware, compact JSON // machineid -vm -salt "my-app" # VM-friendly with salt // machineid -mac -mac-filter all # include all MAC addresses +// machineid -all -json -diagnostics # JSON with per-component diagnostics +// machineid -cpu -uuid -validate # validate a stored ID // machineid -all -verbose # info-level logs // machineid -all -debug # debug-level logs // machineid -version # version info // machineid -version-long # detailed build info +// +// Ctrl-C or SIGTERM cancels any in-flight hardware query. package machineid diff --git a/errors.go b/errors.go index 79add00..8b1ceee 100644 --- a/errors.go +++ b/errors.go @@ -33,14 +33,21 @@ var ( ) // CommandError records a failed system command execution. -// Use [errors.As] to extract the command name from wrapped errors. +// Use [errors.AsType] to extract the command name from wrapped errors. +// When the command was ended by a timeout or cancellation, Err wraps +// [context.DeadlineExceeded] or [context.Canceled]. type CommandError struct { Err error // underlying error from exec Command string // command name, e.g. "sysctl", "ioreg", "wmic" + Stderr string // first line of the command's stderr, if any } // Error returns a human-readable description of the command failure. func (e *CommandError) Error() string { + if e.Stderr != "" { + return fmt.Sprintf("command %q failed: %v (stderr: %s)", e.Command, e.Err, e.Stderr) + } + return fmt.Sprintf("command %q failed: %v", e.Command, e.Err) } @@ -50,7 +57,7 @@ func (e *CommandError) Unwrap() error { } // ParseError records a failure while parsing command or system output. -// Use [errors.As] to extract the source from wrapped errors. +// Use [errors.AsType] to extract the source from wrapped errors. type ParseError struct { Err error // underlying parse error Source string // data source, e.g. "system_profiler JSON", "wmic output" @@ -67,7 +74,7 @@ func (e *ParseError) Unwrap() error { } // ComponentError records a failure while collecting a specific hardware component. -// These errors appear in [DiagnosticInfo.Errors] and can be inspected with [errors.As]. +// These errors appear in [DiagnosticInfo.Errors] and can be inspected with [errors.AsType]. type ComponentError struct { Err error // underlying error Component string // component name, e.g. "cpu", "uuid", "disk" diff --git a/executor.go b/executor.go index 6d2510c..7eb8e84 100644 --- a/executor.go +++ b/executor.go @@ -2,12 +2,24 @@ package machineid import ( "context" + "errors" + "fmt" "log/slog" "os/exec" "strings" + "sync" "time" ) +// waitDelay bounds how long Execute waits for a command's output pipes to +// close after the command has been killed on context cancellation. Without +// it, a grandchild process that inherited stdout keeps Output blocked +// indefinitely. +const waitDelay = time.Second + +// maxStderr caps the stderr excerpt attached to a CommandError. +const maxStderr = 200 + // defaultCommandExecutor implements CommandExecutor using actual system command execution. type defaultCommandExecutor struct { Timeout time.Duration @@ -15,6 +27,9 @@ type defaultCommandExecutor struct { // Execute runs a system command with a timeout and returns the output. // It uses context.WithTimeout to prevent commands from hanging indefinitely. +// When the deadline or the parent context ends the command, the returned +// error wraps [context.DeadlineExceeded] or [context.Canceled] so callers can +// detect it with [errors.Is]. func (e *defaultCommandExecutor) Execute(ctx context.Context, name string, args ...string) (string, error) { timeout := e.Timeout if timeout <= 0 { @@ -25,14 +40,41 @@ func (e *defaultCommandExecutor) Execute(ctx context.Context, name string, args defer cancel() cmd := exec.CommandContext(timeoutCtx, name, args...) + cmd.WaitDelay = waitDelay + output, err := cmd.Output() if err != nil { - return "", &CommandError{Command: name, Err: err} + cmdErr := &CommandError{Command: name, Err: err} + if ctxErr := timeoutCtx.Err(); ctxErr != nil { + cmdErr.Err = fmt.Errorf("%w: %w", ctxErr, err) + } + if exitErr, ok := errors.AsType[*exec.ExitError](err); ok { + cmdErr.Stderr = stderrExcerpt(exitErr.Stderr) + } + + return "", cmdErr } return strings.TrimSpace(string(output)), nil } +// stderrExcerpt returns the first non-empty line of stderr, truncated to maxStderr bytes. +func stderrExcerpt(stderr []byte) string { + for line := range strings.SplitSeq(string(stderr), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + if len(line) > maxStderr { + return line[:maxStderr] + "..." + } + + return line + } + + return "" +} + // executeCommand is a convenience wrapper that calls Execute with the given context. // This function is used by platform-specific collectors that need the Provider's executor. func executeCommand(ctx context.Context, executor CommandExecutor, logger *slog.Logger, name string, args ...string) (string, error) { @@ -60,3 +102,60 @@ func executeCommand(ctx context.Context, executor CommandExecutor, logger *slog. return result, err } + +// memoExecutor caches the result of every distinct command invocation for the +// lifetime of one collection pass. Collectors that share a data source (the +// macOS UUID, serial and CPU collectors all read +// `system_profiler SPHardwareDataType -json`) then spawn that process once. +// Concurrent callers of the same command wait for the single in-flight run. +// Failures are cached too, so a broken command is not retried by every +// collector that falls back through it. +type memoExecutor struct { + inner CommandExecutor + logger *slog.Logger + entries map[string]*memoEntry + mu sync.Mutex +} + +type memoEntry struct { + err error + output string + once sync.Once +} + +// newMemoExecutor wraps inner with a per-invocation result cache. +// A nil inner executor falls back to the default executor. +func newMemoExecutor(inner CommandExecutor, logger *slog.Logger) *memoExecutor { + if inner == nil { + inner = &defaultCommandExecutor{Timeout: defaultTimeout} + } + + return &memoExecutor{ + inner: inner, + logger: logger, + entries: make(map[string]*memoEntry), + } +} + +// Execute implements CommandExecutor. +func (m *memoExecutor) Execute(ctx context.Context, name string, args ...string) (string, error) { + key := name + "\x00" + strings.Join(args, "\x00") + + m.mu.Lock() + entry, seen := m.entries[key] + if !seen { + entry = &memoEntry{} + m.entries[key] = entry + } + m.mu.Unlock() + + if seen && m.logger != nil { + m.logger.Debug("reusing command output", "command", name, "args", args) + } + + entry.once.Do(func() { + entry.output, entry.err = m.inner.Execute(ctx, name, args...) + }) + + return entry.output, entry.err +} diff --git a/executor_test.go b/executor_test.go index cc63b73..ce56299 100644 --- a/executor_test.go +++ b/executor_test.go @@ -2,7 +2,10 @@ package machineid import ( "context" + "errors" "fmt" + "log/slog" + "runtime" "strings" "sync" "testing" @@ -47,6 +50,13 @@ func newMockExecutor() *mockExecutor { } } +// calls returns how many times the named command was executed. +func (m *mockExecutor) calls(command string) int { + m.mu.RLock() + defer m.mu.RUnlock() + return m.callCount[command] +} + // argsKey builds the internal lookup key for an args-specific mock entry. // Using NUL as separator avoids collisions with args that contain spaces. func argsKey(name string, args []string) string { @@ -120,17 +130,172 @@ func (m *mockExecutor) setErrorForArgs(command string, args []string, err error) m.errorsByArgs[argsKey(command, args)] = err } -// TestExecuteTimeout tests that command execution respects timeout. -func TestExecuteTimeout(t *testing.T) { +// TestExecuteCancelledContext tests that an already-cancelled context is +// reported as a CommandError wrapping context.Canceled. +func TestExecuteCancelledContext(t *testing.T) { executor := &defaultCommandExecutor{} - ctx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond) - defer cancel() - - time.Sleep(2 * time.Millisecond) // Ensure timeout expires + ctx, cancel := context.WithCancel(context.Background()) + cancel() _, err := executor.Execute(ctx, "echo", "test") if err == nil { - t.Error("Expected timeout error but got none") + t.Fatal("Expected error for cancelled context but got none") + } + if !errors.Is(err, context.Canceled) { + t.Errorf("Expected context.Canceled in chain, got %v", err) + } + cmdErr, ok := errors.AsType[*CommandError](err) + if !ok { + t.Fatalf("Expected CommandError, got %T", err) + } + if cmdErr.Command != "echo" { + t.Errorf("Expected command 'echo', got %q", cmdErr.Command) + } +} + +// TestExecuteDeadlineExceeded tests that a slow command is killed at the +// timeout, that the error wraps context.DeadlineExceeded, and that WaitDelay +// keeps Output from blocking past the deadline. +func TestExecuteDeadlineExceeded(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("requires a POSIX sleep binary") + } + + executor := &defaultCommandExecutor{Timeout: 50 * time.Millisecond} + start := time.Now() + + _, err := executor.Execute(context.Background(), "sleep", "5") + elapsed := time.Since(start) + + if !errors.Is(err, context.DeadlineExceeded) { + t.Errorf("Expected context.DeadlineExceeded in chain, got %v", err) + } + if elapsed > 3*time.Second { + t.Errorf("Execute took %v; expected the timeout plus WaitDelay to bound it", elapsed) + } +} + +// TestExecuteStderrExcerpt tests that a failing command's stderr is attached +// to the CommandError. +func TestExecuteStderrExcerpt(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("requires a POSIX shell") + } + + executor := &defaultCommandExecutor{} + _, err := executor.Execute(context.Background(), "sh", "-c", "echo boom >&2; exit 3") + + cmdErr, ok := errors.AsType[*CommandError](err) + if !ok { + t.Fatalf("Expected CommandError, got %T: %v", err, err) + } + if cmdErr.Stderr != "boom" { + t.Errorf("Expected stderr excerpt 'boom', got %q", cmdErr.Stderr) + } + if !strings.Contains(cmdErr.Error(), "boom") { + t.Errorf("Expected Error() to include stderr, got %q", cmdErr.Error()) + } +} + +func TestStderrExcerpt(t *testing.T) { + long := strings.Repeat("x", maxStderr+10) + tests := []struct { + name string + in string + want string + }{ + {"empty", "", ""}, + {"whitespace only", " \n\n ", ""}, + {"first non-empty line", "\n first \nsecond", "first"}, + {"truncated", long, long[:maxStderr] + "..."}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := stderrExcerpt([]byte(tt.in)); got != tt.want { + t.Errorf("stderrExcerpt(%q) = %q, want %q", tt.in, got, tt.want) + } + }) + } +} + +// --- memoExecutor tests --- + +func TestMemoExecutorRunsEachInvocationOnce(t *testing.T) { + mock := newMockExecutor() + mock.setOutputForArgs("system_profiler", []string{"SPHardwareDataType", "-json"}, "{}") + mock.setOutputForArgs("system_profiler", []string{"SPStorageDataType", "-json"}, "[]") + mock.setError("ioreg", fmt.Errorf("boom")) + + memo := newMemoExecutor(mock, nil) + ctx := context.Background() + + for range 3 { + out, err := memo.Execute(ctx, "system_profiler", "SPHardwareDataType", "-json") + if err != nil || out != "{}" { + t.Fatalf("Execute = %q, %v", out, err) + } + } + if _, err := memo.Execute(ctx, "system_profiler", "SPStorageDataType", "-json"); err != nil { + t.Fatal(err) + } + // Failures are cached too. + for range 2 { + if _, err := memo.Execute(ctx, "ioreg"); err == nil { + t.Fatal("Expected cached error") + } + } + + if got := mock.calls("system_profiler"); got != 2 { + t.Errorf("Expected 2 distinct system_profiler runs, got %d", got) + } + if got := mock.calls("ioreg"); got != 1 { + t.Errorf("Expected 1 ioreg run, got %d", got) + } +} + +func TestMemoExecutorConcurrentCallersShareOneRun(t *testing.T) { + mock := newMockExecutor() + mock.setOutput("slow", "value") + memo := newMemoExecutor(mock, nil) + + var wg sync.WaitGroup + for range 8 { + wg.Go(func() { + if out, err := memo.Execute(context.Background(), "slow", "arg"); err != nil || out != "value" { + t.Errorf("Execute = %q, %v", out, err) + } + }) + } + wg.Wait() + + if got := mock.calls("slow"); got != 1 { + t.Errorf("Expected exactly 1 run for concurrent callers, got %d", got) + } +} + +func TestMemoExecutorNilInnerUsesDefault(t *testing.T) { + memo := newMemoExecutor(nil, nil) + if memo.inner == nil { + t.Fatal("Expected default executor for nil inner") + } +} + +func TestMemoExecutorLogsReuse(t *testing.T) { + var buf strings.Builder + logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})) + + mock := newMockExecutor() + mock.setOutput("cmd", "v") + memo := newMemoExecutor(mock, logger) + + for range 2 { + if _, err := memo.Execute(context.Background(), "cmd"); err != nil { + t.Fatal(err) + } + } + + if !strings.Contains(buf.String(), "reusing command output") { + t.Errorf("Expected reuse log, got %q", buf.String()) } } diff --git a/go.mod b/go.mod index 6eb8b4c..ad960fb 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ module github.com/slashdevops/machineid -go 1.26.1 +go 1.27.1 diff --git a/installer/macos/machineid/resources/welcome.html b/installer/macos/machineid/resources/welcome.html index 1cfe85d..0998f92 100644 --- a/installer/macos/machineid/resources/welcome.html +++ b/installer/macos/machineid/resources/welcome.html @@ -23,7 +23,7 @@

What gets installed

Requirements

    -
  • macOS 12 (Monterey) or later
  • +
  • macOS 13 (Ventura) or later
diff --git a/linux.go b/linux.go index 1198c00..bd63ca4 100644 --- a/linux.go +++ b/linux.go @@ -11,45 +11,57 @@ import ( "strings" ) -// collectIdentifiers gathers Linux-specific hardware identifiers based on provider config. +// collectIdentifiers gathers Linux-specific hardware identifiers concurrently. +// Most sources are sysfs and procfs reads; running them alongside the lsblk +// process keeps the disk lookup off the critical path. func collectIdentifiers(ctx context.Context, p *Provider, diag *DiagnosticInfo) ([]string, error) { - var identifiers []string logger := p.logger + executor := p.commandExecutor + + var tasks []componentTask if p.includeCPU { - identifiers = appendIdentifierIfValid(identifiers, func() (string, error) { - return linuxCPUID(logger) - }, "cpu:", diag, ComponentCPU, logger) + tasks = append(tasks, componentTask{component: ComponentCPU, prefix: "cpu:", + single: func(context.Context) (string, error) { + return linuxCPUID(logger) + }}) } if p.includeSystemUUID { - identifiers = appendIdentifierIfValid(identifiers, func() (string, error) { - return linuxSystemUUID(logger) - }, "uuid:", diag, ComponentSystemUUID, logger) - identifiers = appendIdentifierIfValid(identifiers, func() (string, error) { - return linuxMachineID(logger) - }, "machine:", diag, ComponentMachineID, logger) + tasks = append(tasks, + componentTask{component: ComponentSystemUUID, prefix: "uuid:", + single: func(context.Context) (string, error) { + return linuxSystemUUID(logger) + }}, + componentTask{component: ComponentMachineID, prefix: "machine:", + single: func(context.Context) (string, error) { + return linuxMachineID(logger) + }}, + ) } if p.includeMotherboard { - identifiers = appendIdentifierIfValid(identifiers, func() (string, error) { - return linuxMotherboardSerial(logger) - }, "mb:", diag, ComponentMotherboard, logger) + tasks = append(tasks, componentTask{component: ComponentMotherboard, prefix: "mb:", + single: func(context.Context) (string, error) { + return linuxMotherboardSerial(logger) + }}) } if p.includeMAC { - identifiers = appendIdentifiersIfValid(identifiers, func() ([]string, error) { - return collectMACAddresses(p.macFilter, logger) - }, "mac:", diag, ComponentMAC, logger) + tasks = append(tasks, componentTask{component: ComponentMAC, prefix: "mac:", + multi: func(context.Context) ([]string, error) { + return collectMACAddresses(p.macFilter, logger) + }}) } if p.includeDisk { - identifiers = appendIdentifiersIfValid(identifiers, func() ([]string, error) { - return linuxDiskSerials(ctx, p.commandExecutor, logger) - }, "disk:", diag, ComponentDisk, logger) + tasks = append(tasks, componentTask{component: ComponentDisk, prefix: "disk:", + multi: func(ctx context.Context) ([]string, error) { + return linuxDiskSerials(ctx, executor, logger) + }}) } - return identifiers, nil + return runComponentTasks(ctx, tasks, diag, logger), nil } // linuxCPUID retrieves CPU information from /proc/cpuinfo. @@ -82,20 +94,21 @@ func parseCPUInfo(content string) (string, error) { for _, line := range lines { line = strings.TrimSpace(line) - parts := strings.SplitN(line, ":", 2) - if len(parts) != 2 { + _, value, found := strings.Cut(line, ":") + if !found { continue } + value = strings.TrimSpace(value) switch { case strings.HasPrefix(line, "processor"): - processor = strings.TrimSpace(parts[1]) + processor = value case strings.HasPrefix(line, "vendor_id"): - vendorID = strings.TrimSpace(parts[1]) + vendorID = value case strings.HasPrefix(line, "model name"): - modelName = strings.TrimSpace(parts[1]) + modelName = value case strings.HasPrefix(line, "flags"): - flags = strings.TrimSpace(parts[1]) + flags = value } } @@ -162,11 +175,6 @@ func readFirstValidFromLocations(locations []string, validator func(string) bool return "", ErrNotFound } -// isValidUUID reports whether the UUID is valid (not empty or null). -func isValidUUID(uuid string) bool { - return uuid != "" && uuid != "00000000-0000-0000-0000-000000000000" -} - // isValidSerial reports whether the serial is valid (not empty or placeholder). func isValidSerial(serial string) bool { return serial != "" && serial != biosFirmwareMessage diff --git a/linux_test.go b/linux_test.go index 81593c3..c17a714 100644 --- a/linux_test.go +++ b/linux_test.go @@ -43,8 +43,7 @@ func TestParseCPUInfoEmpty(t *testing.T) { if !errors.Is(err, ErrNotFound) { t.Errorf("Expected ErrNotFound, got %v", err) } - var parseErr *ParseError - if !errors.As(err, &parseErr) { + if _, ok := errors.AsType[*ParseError](err); !ok { t.Errorf("Expected ParseError, got %T", err) } } @@ -110,29 +109,6 @@ flags : fpu vme avx } } -// --- isValidUUID tests --- - -func TestIsValidUUID(t *testing.T) { - tests := []struct { - name string - uuid string - valid bool - }{ - {"valid UUID", "4C4C4544-0058-5210-8048-B4C04F595031", true}, - {"empty", "", false}, - {"null UUID", "00000000-0000-0000-0000-000000000000", false}, - {"simple string", "abc123", true}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := isValidUUID(tt.uuid); got != tt.valid { - t.Errorf("isValidUUID(%q) = %v, want %v", tt.uuid, got, tt.valid) - } - }) - } -} - // --- isValidSerial tests --- func TestIsValidSerial(t *testing.T) { @@ -429,7 +405,7 @@ func TestReadFirstValidFromLocationsSkipsInvalid(t *testing.T) { if err := os.WriteFile(invalidPath, []byte("00000000-0000-0000-0000-000000000000\n"), 0o644); err != nil { t.Fatalf("write invalid: %v", err) } - if err := os.WriteFile(goodPath, []byte("real-uuid\n"), 0o644); err != nil { + if err := os.WriteFile(goodPath, []byte("E1B2C3D4-0008-4A5B-8C6D-7E8F9A0B1C2D\n"), 0o644); err != nil { t.Fatalf("write good: %v", err) } @@ -437,8 +413,8 @@ func TestReadFirstValidFromLocationsSkipsInvalid(t *testing.T) { if err != nil { t.Fatalf("Unexpected error: %v", err) } - if value != "real-uuid" { - t.Errorf("Expected %q, got %q", "real-uuid", value) + if value != "E1B2C3D4-0008-4A5B-8C6D-7E8F9A0B1C2D" { + t.Errorf("Expected %q, got %q", "E1B2C3D4-0008-4A5B-8C6D-7E8F9A0B1C2D", value) } } diff --git a/machineid b/machineid deleted file mode 100755 index 398b6df..0000000 Binary files a/machineid and /dev/null differ diff --git a/machineid.go b/machineid.go index 8882caa..10d41e5 100644 --- a/machineid.go +++ b/machineid.go @@ -5,8 +5,9 @@ import ( "crypto/sha256" "encoding/hex" "log/slog" + "maps" "runtime" - "sort" + "slices" "strings" "sync" "time" @@ -74,7 +75,8 @@ type DiagnosticInfo struct { // CommandExecutor is an interface for executing system commands, allowing for // dependency injection and testing. Implementations must be safe for concurrent -// use, since Windows collects hardware identifiers in parallel goroutines. +// use, since hardware identifiers are collected in parallel goroutines on +// every platform. type CommandExecutor interface { Execute(ctx context.Context, name string, args ...string) (string, error) } @@ -167,9 +169,12 @@ func (p *Provider) WithDisk() *Provider { } // WithExecutor sets a custom [CommandExecutor], enabling deterministic testing -// without real system commands. +// without real system commands. A nil executor is ignored and the current +// executor is kept. func (p *Provider) WithExecutor(executor CommandExecutor) *Provider { - p.commandExecutor = executor + if executor != nil { + p.commandExecutor = executor + } return p } @@ -202,7 +207,8 @@ func (p *Provider) VMFriendly() *Provider { // It caches the result, so subsequent calls return the same ID. // The configuration is frozen after the first successful call. // The provided context controls the timeout and cancellation of any -// system commands executed during hardware identifier collection. +// system commands executed during hardware identifier collection; a +// context that is already done returns its error without collecting. // This method is safe for concurrent use. func (p *Provider) ID(ctx context.Context) (string, error) { p.mu.Lock() @@ -214,6 +220,10 @@ func (p *Provider) ID(ctx context.Context) (string, error) { return p.cachedID, nil } + if err := ctx.Err(); err != nil { + return "", err + } + p.logInfo("generating machine ID", "platform", runtime.GOOS, "format", p.formatMode, @@ -251,12 +261,20 @@ func (p *Provider) ID(ctx context.Context) (string, error) { // Diagnostics returns information about which hardware components were // successfully collected and which ones failed during the last call to [ID]. -// Returns nil if [ID] has not been called yet. +// Returns nil if [ID] has not been called yet. The returned value is a copy; +// modifying it does not affect the provider. func (p *Provider) Diagnostics() *DiagnosticInfo { p.mu.Lock() defer p.mu.Unlock() - return p.diagnostics + if p.diagnostics == nil { + return nil + } + + return &DiagnosticInfo{ + Errors: maps.Clone(p.diagnostics.Errors), + Collected: slices.Clone(p.diagnostics.Collected), + } } // Validate reports whether the provided ID matches the current machine ID. @@ -272,9 +290,9 @@ func (p *Provider) Validate(ctx context.Context, id string) (bool, error) { // hashIdentifiers processes and hashes the hardware identifiers with optional salt. // Returns a hash formatted according to the specified [FormatMode]. +// The input slice is not modified. func hashIdentifiers(identifiers []string, salt string, mode FormatMode) string { - sort.Strings(identifiers) - combined := strings.Join(identifiers, "|") + combined := strings.Join(slices.Sorted(slices.Values(identifiers)), "|") if salt != "" { combined = salt + "|" + combined } diff --git a/machineid_internal_darwin_test.go b/machineid_internal_darwin_test.go index 655387c..c3db0ee 100644 --- a/machineid_internal_darwin_test.go +++ b/machineid_internal_darwin_test.go @@ -126,7 +126,7 @@ func TestDiagnosticsAvailableAfterID(t *testing.T) { "SPHardwareDataType": [{ "chip_type": "Apple M1", "machine_model": "Mac", - "platform_UUID": "UUID-123", + "platform_UUID": "E1B2C3D4-0007-4A5B-8C6D-7E8F9A0B1C2D", "serial_number": "SERIAL" }] }`) diff --git a/machineid_internal_test.go b/machineid_internal_test.go index 87f98d3..7706a6a 100644 --- a/machineid_internal_test.go +++ b/machineid_internal_test.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "log/slog" + "strings" "testing" ) @@ -493,3 +494,59 @@ func TestExecuteCommandWithLogger(t *testing.T) { } }) } + +// --- Provider hygiene tests --- + +func TestDiagnosticsReturnsCopy(t *testing.T) { + p := New() + p.diagnostics = &DiagnosticInfo{ + Errors: map[string]error{"cpu": ErrEmptyValue}, + Collected: []string{"uuid"}, + } + + got := p.Diagnostics() + got.Errors["disk"] = ErrNoValues + got.Collected[0] = "mutated" + + if _, leaked := p.diagnostics.Errors["disk"]; leaked { + t.Error("mutating Diagnostics().Errors changed provider state") + } + if p.diagnostics.Collected[0] != "uuid" { + t.Error("mutating Diagnostics().Collected changed provider state") + } +} + +func TestWithExecutorNilKeepsCurrent(t *testing.T) { + mock := newMockExecutor() + p := New().WithExecutor(mock).WithExecutor(nil) + if p.commandExecutor != mock { + t.Errorf("WithExecutor(nil) replaced the executor with %T", p.commandExecutor) + } +} + +func TestIDCancelledContextDoesNotCollect(t *testing.T) { + mock := newMockExecutor() + p := New().WithExecutor(mock).WithCPU().WithSystemUUID().WithMotherboard() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := p.ID(ctx) + if !errors.Is(err, context.Canceled) { + t.Fatalf("ID() error = %v, want context.Canceled", err) + } + if p.cachedID != "" { + t.Error("ID() cached a value for a cancelled context") + } + if p.Diagnostics() != nil { + t.Error("ID() recorded diagnostics without collecting") + } +} + +func TestHashIdentifiersDoesNotMutateInput(t *testing.T) { + ids := []string{"c", "a", "b"} + _ = hashIdentifiers(ids, "", Format64) + if strings.Join(ids, "") != "cab" { + t.Errorf("hashIdentifiers reordered its input: %v", ids) + } +} diff --git a/uuid.go b/uuid.go new file mode 100644 index 0000000..6d373b2 --- /dev/null +++ b/uuid.go @@ -0,0 +1,19 @@ +package machineid + +import "uuid" + +// isValidUUID reports whether s is a well-formed UUID that is neither the nil +// UUID (all zeros) nor the max UUID (all ones). Firmware with no UUID +// programmed commonly reports one of those two sentinels, and both must be +// rejected so they cannot contribute a machine-independent value to the ID. +// +// Only the check is strict; callers keep hashing the raw string exactly as +// the platform reported it, so accepted values produce the same ID as before. +func isValidUUID(s string) bool { + u, err := uuid.Parse(s) + if err != nil { + return false + } + + return u != uuid.Nil() && u != uuid.Max() +} diff --git a/uuid_test.go b/uuid_test.go new file mode 100644 index 0000000..fe59dd4 --- /dev/null +++ b/uuid_test.go @@ -0,0 +1,32 @@ +package machineid + +import "testing" + +func TestIsValidUUID(t *testing.T) { + tests := []struct { + name string + uuid string + valid bool + }{ + {"uppercase", "4C4C4544-0058-5210-8048-B4C04F595031", true}, + {"lowercase", "4c4c4544-0058-5210-8048-b4c04f595031", true}, + {"braces", "{4C4C4544-0058-5210-8048-B4C04F595031}", true}, + {"no dashes", "4c4c45440058521080484b4c04f595031"[:32], true}, + {"urn prefix", "urn:uuid:4c4c4544-0058-5210-8048-b4c04f595031", true}, + {"empty", "", false}, + {"nil UUID", "00000000-0000-0000-0000-000000000000", false}, + {"max UUID", "FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF", false}, + {"not a UUID", "abc123", false}, + {"OEM placeholder", "To be filled by O.E.M.", false}, + {"dmidecode not settable", "Not Settable", false}, + {"truncated", "4C4C4544-0058-5210-8048", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isValidUUID(tt.uuid); got != tt.valid { + t.Errorf("isValidUUID(%q) = %v, want %v", tt.uuid, got, tt.valid) + } + }) + } +} diff --git a/windows.go b/windows.go index 041280d..4f915a6 100644 --- a/windows.go +++ b/windows.go @@ -5,165 +5,86 @@ package machineid import ( "context" "log/slog" + "os/exec" "strings" - "sync" ) -// componentResult holds the result from a single concurrent component collection. -type componentResult struct { - component string - prefix string - value string // for single-value components - values []string // for multi-value components (MAC, disk) - err error - multi bool // true if this is a multi-value result -} +// lookPath resolves a command on PATH. It is a variable so tests can force +// the wmic probe to a known outcome regardless of the host. +var lookPath = exec.LookPath + +// powerShellArgs precede every PowerShell script. -NoProfile keeps user +// profiles from writing to stdout (which would corrupt parsed values) and +// from slowing start-up; -NonInteractive prevents prompts from blocking. +var powerShellArgs = []string{"-NoProfile", "-NonInteractive", "-Command"} // collectIdentifiers gathers Windows-specific hardware identifiers concurrently. // Windows commands (wmic, PowerShell) are slow due to process startup overhead, // so all components are collected in parallel to minimize total latency. func collectIdentifiers(ctx context.Context, p *Provider, diag *DiagnosticInfo) ([]string, error) { logger := p.logger + executor := p.commandExecutor - var wg sync.WaitGroup - resultsCh := make(chan componentResult, 5) + var tasks []componentTask if p.includeCPU { - wg.Add(1) - go func() { - defer wg.Done() - value, err := windowsCPUID(ctx, p.commandExecutor, logger) - resultsCh <- componentResult{component: ComponentCPU, prefix: "cpu:", value: value, err: err} - }() + tasks = append(tasks, componentTask{component: ComponentCPU, prefix: "cpu:", + single: func(ctx context.Context) (string, error) { + return windowsCPUID(ctx, executor, logger) + }}) } if p.includeMotherboard { - wg.Add(1) - go func() { - defer wg.Done() - value, err := windowsMotherboardSerial(ctx, p.commandExecutor, logger) - resultsCh <- componentResult{component: ComponentMotherboard, prefix: "mb:", value: value, err: err} - }() + tasks = append(tasks, componentTask{component: ComponentMotherboard, prefix: "mb:", + single: func(ctx context.Context) (string, error) { + return windowsMotherboardSerial(ctx, executor, logger) + }}) } if p.includeSystemUUID { - wg.Add(1) - go func() { - defer wg.Done() - value, err := windowsSystemUUID(ctx, p.commandExecutor, logger) - resultsCh <- componentResult{component: ComponentSystemUUID, prefix: "uuid:", value: value, err: err} - }() + tasks = append(tasks, componentTask{component: ComponentSystemUUID, prefix: "uuid:", + single: func(ctx context.Context) (string, error) { + return windowsSystemUUID(ctx, executor, logger) + }}) } if p.includeMAC { - wg.Add(1) - go func() { - defer wg.Done() - values, err := collectMACAddresses(p.macFilter, logger) - resultsCh <- componentResult{component: ComponentMAC, prefix: "mac:", values: values, err: err, multi: true} - }() + tasks = append(tasks, componentTask{component: ComponentMAC, prefix: "mac:", + multi: func(context.Context) ([]string, error) { + return collectMACAddresses(p.macFilter, logger) + }}) } if p.includeDisk { - wg.Add(1) - go func() { - defer wg.Done() - values, err := windowsDiskSerials(ctx, p.commandExecutor, logger) - resultsCh <- componentResult{component: ComponentDisk, prefix: "disk:", values: values, err: err, multi: true} - }() + tasks = append(tasks, componentTask{component: ComponentDisk, prefix: "disk:", + multi: func(ctx context.Context) ([]string, error) { + return windowsDiskSerials(ctx, executor, logger) + }}) } - // Close channel once all goroutines complete. - go func() { - wg.Wait() - close(resultsCh) - }() - - // Collect results and build identifiers. - var identifiers []string - for r := range resultsCh { - if r.multi { - identifiers = appendMultiResult(identifiers, r, diag, logger) - } else { - identifiers = appendSingleResult(identifiers, r, diag, logger) - } - } - - return identifiers, nil + return runComponentTasks(ctx, tasks, diag, logger), nil } -// appendSingleResult processes a single-value component result into identifiers. -func appendSingleResult(identifiers []string, r componentResult, diag *DiagnosticInfo, logger *slog.Logger) []string { - if r.err != nil { - compErr := &ComponentError{Component: r.component, Err: r.err} - if diag != nil { - diag.Errors[r.component] = compErr - } +// wmicAvailable reports whether wmic is on PATH. wmic was removed from +// Windows 11 24H2 and Windows Server 2025, so skipping it avoids paying for +// a failed process spawn before every PowerShell fallback. +func wmicAvailable(logger *slog.Logger) bool { + if _, err := lookPath("wmic"); err != nil { if logger != nil { - logger.Warn("component failed", "component", r.component, "error", r.err) + logger.Debug("wmic not found, using PowerShell", "error", err) } - return identifiers - } - if r.value == "" { - compErr := &ComponentError{Component: r.component, Err: ErrEmptyValue} - if diag != nil { - diag.Errors[r.component] = compErr - } - if logger != nil { - logger.Warn("component returned empty value", "component", r.component) - } - return identifiers + return false } - if diag != nil { - diag.Collected = append(diag.Collected, r.component) - } - if logger != nil { - logger.Info("component collected", "component", r.component) - logger.Debug("component value", "component", r.component, "value", r.value) - } - - return append(identifiers, r.prefix+r.value) + return true } -// appendMultiResult processes a multi-value component result into identifiers. -func appendMultiResult(identifiers []string, r componentResult, diag *DiagnosticInfo, logger *slog.Logger) []string { - if r.err != nil { - compErr := &ComponentError{Component: r.component, Err: r.err} - if diag != nil { - diag.Errors[r.component] = compErr - } - if logger != nil { - logger.Warn("component failed", "component", r.component, "error", r.err) - } - return identifiers - } - - if len(r.values) == 0 { - compErr := &ComponentError{Component: r.component, Err: ErrNoValues} - if diag != nil { - diag.Errors[r.component] = compErr - } - if logger != nil { - logger.Warn("component returned no values", "component", r.component) - } - return identifiers - } +// runPowerShell executes a PowerShell script with the standard non-interactive flags. +func runPowerShell(ctx context.Context, executor CommandExecutor, logger *slog.Logger, script string) (string, error) { + args := append(append([]string{}, powerShellArgs...), script) - if diag != nil { - diag.Collected = append(diag.Collected, r.component) - } - if logger != nil { - logger.Info("component collected", "component", r.component, "count", len(r.values)) - logger.Debug("component values", "component", r.component, "values", r.values) - } - - for _, value := range r.values { - identifiers = append(identifiers, r.prefix+value) - } - - return identifiers + return executeCommand(ctx, executor, logger, "powershell", args...) } // parseWmicValue extracts value from wmic output with given prefix. @@ -172,8 +93,8 @@ func parseWmicValue(output, prefix string) (string, error) { for line := range lines { line = strings.TrimSpace(line) - if strings.HasPrefix(line, prefix) { - value := strings.TrimSpace(strings.TrimPrefix(line, prefix)) + if rest, ok := strings.CutPrefix(line, prefix); ok { + value := strings.TrimSpace(rest) if value == "" || value == biosFirmwareMessage { continue } @@ -192,8 +113,8 @@ func parseWmicMultipleValues(output, prefix string) []string { for line := range lines { line = strings.TrimSpace(line) - if strings.HasPrefix(line, prefix) { - value := strings.TrimSpace(strings.TrimPrefix(line, prefix)) + if rest, ok := strings.CutPrefix(line, prefix); ok { + value := strings.TrimSpace(rest) if value == "" || value == biosFirmwareMessage { continue } @@ -237,12 +158,14 @@ func parsePowerShellMultipleValues(output string) []string { // windowsCPUID retrieves CPU processor ID using wmic, with PowerShell fallback. func windowsCPUID(ctx context.Context, executor CommandExecutor, logger *slog.Logger) (string, error) { - output, err := executeCommand(ctx, executor, logger, "wmic", "cpu", "get", "ProcessorId", "/value") - if err == nil { - if value, parseErr := parseWmicValue(output, "ProcessorId="); parseErr == nil { - return value, nil - } else if logger != nil { - logger.Debug("wmic CPU ID parsing failed", "error", parseErr) + if wmicAvailable(logger) { + output, err := executeCommand(ctx, executor, logger, "wmic", "cpu", "get", "ProcessorId", "/value") + if err == nil { + if value, parseErr := parseWmicValue(output, "ProcessorId="); parseErr == nil { + return value, nil + } else if logger != nil { + logger.Debug("wmic CPU ID parsing failed", "error", parseErr) + } } } @@ -251,7 +174,7 @@ func windowsCPUID(ctx context.Context, executor CommandExecutor, logger *slog.Lo logger.Info("falling back to PowerShell for CPU ID") } - psOutput, psErr := executeCommand(ctx, executor, logger, "powershell", "-Command", + psOutput, psErr := runPowerShell(ctx, executor, logger, "Get-CimInstance -ClassName Win32_Processor | Select-Object -ExpandProperty ProcessorId") if psErr != nil { if logger != nil { @@ -266,12 +189,14 @@ func windowsCPUID(ctx context.Context, executor CommandExecutor, logger *slog.Lo // windowsMotherboardSerial retrieves motherboard serial number using wmic, with PowerShell fallback. func windowsMotherboardSerial(ctx context.Context, executor CommandExecutor, logger *slog.Logger) (string, error) { - output, err := executeCommand(ctx, executor, logger, "wmic", "baseboard", "get", "SerialNumber", "/value") - if err == nil { - if value, parseErr := parseWmicValue(output, "SerialNumber="); parseErr == nil { - return value, nil - } else if logger != nil { - logger.Debug("wmic motherboard serial parsing failed", "error", parseErr) + if wmicAvailable(logger) { + output, err := executeCommand(ctx, executor, logger, "wmic", "baseboard", "get", "SerialNumber", "/value") + if err == nil { + if value, parseErr := parseWmicValue(output, "SerialNumber="); parseErr == nil { + return value, nil + } else if logger != nil { + logger.Debug("wmic motherboard serial parsing failed", "error", parseErr) + } } } @@ -280,7 +205,7 @@ func windowsMotherboardSerial(ctx context.Context, executor CommandExecutor, log logger.Info("falling back to PowerShell for motherboard serial") } - psOutput, psErr := executeCommand(ctx, executor, logger, "powershell", "-Command", + psOutput, psErr := runPowerShell(ctx, executor, logger, "Get-CimInstance -ClassName Win32_BaseBoard | Select-Object -ExpandProperty SerialNumber") if psErr != nil { if logger != nil { @@ -294,14 +219,25 @@ func windowsMotherboardSerial(ctx context.Context, executor CommandExecutor, log } // windowsSystemUUID retrieves system UUID using wmic or PowerShell. +// Malformed, nil (all zeros) and max (all ones) UUIDs are rejected so the +// fallback path is triggered. func windowsSystemUUID(ctx context.Context, executor CommandExecutor, logger *slog.Logger) (string, error) { - // Try wmic first - output, err := executeCommand(ctx, executor, logger, "wmic", "csproduct", "get", "UUID", "/value") - if err == nil { - if value, parseErr := parseWmicValue(output, "UUID="); parseErr == nil { - return value, nil - } else if logger != nil { - logger.Debug("wmic UUID parsing failed", "error", parseErr) + if wmicAvailable(logger) { + output, err := executeCommand(ctx, executor, logger, "wmic", "csproduct", "get", "UUID", "/value") + if err == nil { + value, parseErr := parseWmicValue(output, "UUID=") + switch { + case parseErr != nil: + if logger != nil { + logger.Debug("wmic UUID parsing failed", "error", parseErr) + } + case !isValidUUID(value): + if logger != nil { + logger.Debug("wmic returned invalid UUID, falling back", "uuid", value) + } + default: + return value, nil + } } } @@ -314,26 +250,42 @@ func windowsSystemUUID(ctx context.Context, executor CommandExecutor, logger *sl } // windowsSystemUUIDViaPowerShell retrieves system UUID using PowerShell. +// Malformed, nil and max UUIDs are rejected with ErrNotFound. func windowsSystemUUIDViaPowerShell(ctx context.Context, executor CommandExecutor, logger *slog.Logger) (string, error) { - output, err := executeCommand(ctx, executor, logger, "powershell", "-Command", + output, err := runPowerShell(ctx, executor, logger, "Get-CimInstance -ClassName Win32_ComputerSystemProduct | Select-Object -ExpandProperty UUID") if err != nil { return "", err } - return parsePowerShellValue(output) + value, err := parsePowerShellValue(output) + if err != nil { + return "", err + } + + if !isValidUUID(value) { + if logger != nil { + logger.Debug("PowerShell returned invalid UUID", "uuid", value) + } + + return "", &ParseError{Source: "PowerShell output", Err: ErrNotFound} + } + + return value, nil } // windowsDiskSerials retrieves disk serial numbers using wmic, with PowerShell fallback. func windowsDiskSerials(ctx context.Context, executor CommandExecutor, logger *slog.Logger) ([]string, error) { - output, err := executeCommand(ctx, executor, logger, "wmic", "diskdrive", "get", "SerialNumber", "/value") - if err == nil { - if values := parseWmicMultipleValues(output, "SerialNumber="); len(values) > 0 { - return values, nil - } + if wmicAvailable(logger) { + output, err := executeCommand(ctx, executor, logger, "wmic", "diskdrive", "get", "SerialNumber", "/value") + if err == nil { + if values := parseWmicMultipleValues(output, "SerialNumber="); len(values) > 0 { + return values, nil + } - if logger != nil { - logger.Debug("wmic returned no disk serials") + if logger != nil { + logger.Debug("wmic returned no disk serials") + } } } @@ -342,7 +294,7 @@ func windowsDiskSerials(ctx context.Context, executor CommandExecutor, logger *s logger.Info("falling back to PowerShell for disk serials") } - psOutput, psErr := executeCommand(ctx, executor, logger, "powershell", "-Command", + psOutput, psErr := runPowerShell(ctx, executor, logger, "Get-CimInstance -ClassName Win32_DiskDrive | Select-Object -ExpandProperty SerialNumber") if psErr != nil { if logger != nil { diff --git a/windows_test.go b/windows_test.go index 531cb1e..3aa1d35 100644 --- a/windows_test.go +++ b/windows_test.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "log/slog" + "os/exec" "testing" ) @@ -41,8 +42,7 @@ func TestParseWmicValueEmpty(t *testing.T) { if err == nil { t.Error("Expected error for empty value") } - var parseErr *ParseError - if !errors.As(err, &parseErr) { + if _, ok := errors.AsType[*ParseError](err); !ok { t.Errorf("Expected ParseError, got %T", err) } } @@ -130,8 +130,7 @@ func TestParsePowerShellValueEmpty(t *testing.T) { if err == nil { t.Error("Expected error for empty value") } - var parseErr *ParseError - if !errors.As(err, &parseErr) { + if _, ok := errors.AsType[*ParseError](err); !ok { t.Errorf("Expected ParseError, got %T", err) } if !errors.Is(err, ErrEmptyValue) { @@ -144,8 +143,7 @@ func TestParsePowerShellValueOEMPlaceholder(t *testing.T) { if err == nil { t.Fatal("Expected error for OEM placeholder value") } - var parseErr *ParseError - if !errors.As(err, &parseErr) { + if _, ok := errors.AsType[*ParseError](err); !ok { t.Errorf("Expected ParseError, got %T", err) } if !errors.Is(err, ErrOEMPlaceholder) { @@ -419,14 +417,14 @@ func TestWindowsSystemUUIDAllFail(t *testing.T) { func TestWindowsSystemUUIDWmicParseFailFallback(t *testing.T) { mock := newMockExecutor() mock.setOutput("wmic", "garbage") // parse fails - mock.setOutput("powershell", "UUID-FROM-PS") + mock.setOutput("powershell", "7D8E4B3A-1C2D-4E5F-8A9B-0C1D2E3F4A5B") result, err := windowsSystemUUID(context.Background(), mock, nil) if err != nil { t.Fatalf("Unexpected error: %v", err) } - if result != "UUID-FROM-PS" { - t.Errorf("Expected 'UUID-FROM-PS', got %q", result) + if result != "7D8E4B3A-1C2D-4E5F-8A9B-0C1D2E3F4A5B" { + t.Errorf("Expected '7D8E4B3A-1C2D-4E5F-8A9B-0C1D2E3F4A5B', got %q", result) } } @@ -436,7 +434,7 @@ func TestWindowsSystemUUIDWithLogger(t *testing.T) { mock := newMockExecutor() mock.setOutput("wmic", "garbage") - mock.setOutput("powershell", "UUID-LOGGED") + mock.setOutput("powershell", "7D8E4B3A-1C2D-4E5F-8A9B-0C1D2E3F4A5C") _, err := windowsSystemUUID(context.Background(), mock, logger) if err != nil { @@ -454,14 +452,14 @@ func TestWindowsSystemUUIDWithLogger(t *testing.T) { func TestWindowsSystemUUIDViaPowerShellSuccess(t *testing.T) { mock := newMockExecutor() - mock.setOutput("powershell", "UUID-PS-123") + mock.setOutput("powershell", "7D8E4B3A-1C2D-4E5F-8A9B-0C1D2E3F4A5D") result, err := windowsSystemUUIDViaPowerShell(context.Background(), mock, nil) if err != nil { t.Fatalf("Unexpected error: %v", err) } - if result != "UUID-PS-123" { - t.Errorf("Expected 'UUID-PS-123', got %q", result) + if result != "7D8E4B3A-1C2D-4E5F-8A9B-0C1D2E3F4A5D" { + t.Errorf("Expected '7D8E4B3A-1C2D-4E5F-8A9B-0C1D2E3F4A5D', got %q", result) } } @@ -628,6 +626,85 @@ func TestWindowsDiskSerialsWithLogger(t *testing.T) { } } +// --- wmic probe tests --- + +// init forces the wmic probe to succeed so the wmic code paths are exercised +// even on hosts (Windows 11 24H2+, Server 2025) where wmic is absent. +func init() { + lookPath = func(file string) (string, error) { return file, nil } +} + +// withLookPath swaps the wmic probe for the duration of a test. +func withLookPath(t *testing.T, fn func(string) (string, error)) { + t.Helper() + prev := lookPath + lookPath = fn + t.Cleanup(func() { lookPath = prev }) +} + +func TestWmicAbsentSkipsToPowerShell(t *testing.T) { + withLookPath(t, func(string) (string, error) { return "", exec.ErrNotFound }) + + var buf bytes.Buffer + logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})) + + mock := newMockExecutor() + mock.setOutput("wmic", "ProcessorId=SHOULD-NOT-BE-USED\r\n") + mock.setOutput("powershell", "BFEBFBFF000906EA") + + result, err := windowsCPUID(context.Background(), mock, logger) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if result != "BFEBFBFF000906EA" { + t.Errorf("Expected PowerShell value, got %q", result) + } + if mock.calls("wmic") != 0 { + t.Errorf("Expected wmic not to be invoked, got %d calls", mock.calls("wmic")) + } + if !bytes.Contains(buf.Bytes(), []byte("wmic not found")) { + t.Error("Expected 'wmic not found' in log") + } +} + +func TestRunPowerShellUsesNonInteractiveFlags(t *testing.T) { + mock := newMockExecutor() + mock.setOutputForArgs("powershell", + []string{"-NoProfile", "-NonInteractive", "-Command", "Write-Output hi"}, "hi") + + result, err := runPowerShell(context.Background(), mock, nil, "Write-Output hi") + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if result != "hi" { + t.Errorf("Expected 'hi', got %q", result) + } +} + +func TestWindowsSystemUUIDWmicInvalidUUIDFallsBack(t *testing.T) { + mock := newMockExecutor() + mock.setOutput("wmic", "UUID=00000000-0000-0000-0000-000000000000\r\n") + mock.setOutput("powershell", "7D8E4B3A-1C2D-4E5F-8A9B-0C1D2E3F4A5B") + + result, err := windowsSystemUUID(context.Background(), mock, nil) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if result != "7D8E4B3A-1C2D-4E5F-8A9B-0C1D2E3F4A5B" { + t.Errorf("Expected PowerShell UUID, got %q", result) + } +} + +func TestWindowsSystemUUIDViaPowerShellInvalid(t *testing.T) { + mock := newMockExecutor() + mock.setOutput("powershell", "FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF") + + _, err := windowsSystemUUIDViaPowerShell(context.Background(), mock, nil) + if !errors.Is(err, ErrNotFound) { + t.Errorf("Expected ErrNotFound for max UUID, got %v", err) + } +} + // --- appendSingleResult tests --- func TestAppendSingleResultSuccess(t *testing.T) { @@ -670,8 +747,7 @@ func TestAppendSingleResultEmpty(t *testing.T) { if _, exists := diag.Errors["cpu"]; !exists { t.Error("Expected error recorded for empty cpu") } - var compErr *ComponentError - if !errors.As(diag.Errors["cpu"], &compErr) { + if _, ok := errors.AsType[*ComponentError](diag.Errors["cpu"]); !ok { t.Error("Expected ComponentError") } } @@ -742,8 +818,7 @@ func TestAppendMultiResultEmpty(t *testing.T) { if _, exists := diag.Errors["disk"]; !exists { t.Error("Expected error recorded for empty disk") } - var compErr *ComponentError - if !errors.As(diag.Errors["disk"], &compErr) { + if _, ok := errors.AsType[*ComponentError](diag.Errors["disk"]); !ok { t.Error("Expected ComponentError") } if !errors.Is(diag.Errors["disk"], ErrNoValues) { @@ -780,7 +855,7 @@ func TestAppendMultiResultNilDiag(t *testing.T) { func TestCollectIdentifiersConcurrent(t *testing.T) { mock := newMockExecutor() mock.setOutput("wmic", "ProcessorId=CPUID123\r\n") - mock.setOutput("powershell", "UUID-FROM-PS") + mock.setOutput("powershell", "7D8E4B3A-1C2D-4E5F-8A9B-0C1D2E3F4A5B") p := New().WithExecutor(mock).WithCPU().WithSystemUUID() diag := &DiagnosticInfo{Errors: make(map[string]error)} @@ -799,7 +874,7 @@ func TestCollectIdentifiersConcurrent(t *testing.T) { func TestCollectIdentifiersAllComponents(t *testing.T) { mock := newMockExecutor() - mock.setOutput("wmic", "ProcessorId=CPUID\r\nSerialNumber=MBSERIAL\r\nUUID=UUID123\r\n") + mock.setOutput("wmic", "ProcessorId=CPUID\r\nSerialNumber=MBSERIAL\r\nUUID=7D8E4B3A-1C2D-4E5F-8A9B-0C1D2E3F4A5E\r\n") p := New().WithExecutor(mock). WithCPU().