Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ go.work.sum
# .idea/
# .vscode/

machineid
.DS_Store
build/
dist/
55 changes: 55 additions & 0 deletions .testcoverage.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# (mandatory)
# Path to coverage profile file (output of `go test -coverprofile` command).
#
# For cases where there are many coverage profiles, such as when running
# unit tests and integration tests separately, you can combine all those
# profiles into one. In this case, the profile should have a comma-separated list
# of profile files, e.g., 'cover_unit.out,cover_integration.out'.
profile: build/coverage.txt

# (optional; but recommended to set)
# When specified reported file paths will not contain local prefix in the output.
local-prefix: "github.com/slashdevops/machineid"

# Holds coverage thresholds percentages, values should be in range [0-100].
threshold:
# (optional; default 0)
# Minimum coverage percentage required for individual files.
file: 0

# (optional; default 0)
# Minimum coverage percentage required for each package.
package: 20

# (optional; default 0)
# Minimum overall project coverage percentage required.
total: 25

# Holds regexp rules which will override thresholds for matched files or packages
# using their paths.
#
# First rule from this list that matches file or package is going to apply
# new threshold to it. If project has multiple rules that match same path,
# override rules should be listed in order from specific to more general rules.
override:
# Increase coverage threshold to 100% for `foo` package
# (default is 80, as configured above in this example).
- path: ^pkg/lib/foo$
threshold: 100

# Holds regexp rules which will exclude matched files or packages
# from coverage statistics.
exclude:
# Exclude files or packages matching their paths
paths:
- ^cmd/ # exclude package main
- ^mocks/ # exclude package mocks

# File name of go-test-coverage breakdown file, which can be used to
# analyze coverage difference.
breakdown-file-name: ""

diff:
# File name of go-test-coverage breakdown file which will be used to
# report coverage difference.
base-breakdown-file-name: ""
89 changes: 73 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ A **zero-dependency** Go library that generates unique, deterministic machine id
- **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

## Installation
Expand Down Expand Up @@ -262,6 +263,54 @@ The logger is compatible with `slog.Default()` which bridges to the standard `lo
provider.WithLogger(slog.Default())
```

### Error Handling

The package provides sentinel errors for `errors.Is` and typed errors for `errors.As`:

```go
id, err := provider.ID(ctx)
if errors.Is(err, machineid.ErrNoIdentifiers) {
// No hardware identifiers were collected
}
```

#### Sentinel Errors

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

#### Typed Errors

Use `errors.As` to extract structured context from errors:

```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"
}

// Check if output parsing failed
var parseErr *machineid.ParseError
if errors.As(err, &parseErr) {
fmt.Println("source:", parseErr.Source) // e.g. "system_profiler JSON"
}

// Inspect diagnostic errors per component
diag := provider.Diagnostics()
var compErr *machineid.ComponentError
if errors.As(diag.Errors["cpu"], &compErr) {
fmt.Println("component:", compErr.Component)
fmt.Println("cause:", compErr.Err)
}
```

## CLI Tool

A ready-to-use command-line tool is included.
Expand All @@ -286,29 +335,37 @@ machineid -cpu -uuid -json -diagnostics
# Validate a previously stored ID
machineid -cpu -uuid -validate "b5c42832542981af58c9dc3bc241219e780ff7d276cfad05fac222846edb84f7"

# Info-level logging (fallbacks, lifecycle events)
machineid -cpu -uuid -verbose

# Debug-level logging (command details, raw values, timing)
machineid -all -debug

# Version information
machineid -version
machineid -version.long
```

### All Flags

| Flag | Description |
|-----------------|------------------------------------------------------|
| `-cpu` | Include CPU identifier |
| `-motherboard` | Include motherboard serial number |
| `-uuid` | Include system UUID |
| `-mac` | Include network MAC addresses |
| `-disk` | Include disk serial numbers |
| `-all` | Include all hardware identifiers |
| `-vm` | VM-friendly mode (CPU + UUID only) |
| `-format N` | Output length: `32`, `64` (default), `128`, or `256` |
| `-salt STRING` | Custom salt for application-specific IDs |
| `-validate ID` | Validate an ID against the current machine |
| `-diagnostics` | Show collected/failed components |
| `-json` | Output as JSON |
| `-version` | Show version information |
| `-version.long` | Show detailed version information |
| Flag | Description |
|-----------------|-----------------------------------------------------------------|
| `-cpu` | Include CPU identifier |
| `-motherboard` | Include motherboard serial number |
| `-uuid` | Include system UUID |
| `-mac` | Include network MAC addresses |
| `-disk` | Include disk serial numbers |
| `-all` | Include all hardware identifiers |
| `-vm` | VM-friendly mode (CPU + UUID only) |
| `-format N` | Output length: `32`, `64` (default), `128`, or `256` |
| `-salt STRING` | Custom salt for application-specific IDs |
| `-validate ID` | Validate an ID against the current machine |
| `-diagnostics` | Show collected/failed components |
| `-json` | Output as JSON |
| `-verbose` | Enable info-level logging to stderr (fallbacks, lifecycle) |
| `-debug` | Enable debug-level logging to stderr (commands, values, timing) |
| `-version` | Show version information |
| `-version.long` | Show detailed version information |

## How It Works

Expand Down
174 changes: 174 additions & 0 deletions cmd/machineid/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
package main

import (
"bytes"
"encoding/json"
"io"
"os"
"testing"

"github.com/slashdevops/machineid"
)

func TestParseFormatMode(t *testing.T) {
tests := []struct {
input int
want machineid.FormatMode
wantErr bool
}{
{32, machineid.Format32, false},
{64, machineid.Format64, false},
{128, machineid.Format128, false},
{256, machineid.Format256, false},
{0, 0, true},
{16, 0, true},
{512, 0, true},
{-1, 0, true},
}

for _, tt := range tests {
got, err := parseFormatMode(tt.input)
if (err != nil) != tt.wantErr {
t.Errorf("parseFormatMode(%d) error = %v, wantErr %v", tt.input, err, tt.wantErr)
continue
}
if got != tt.want {
t.Errorf("parseFormatMode(%d) = %v, want %v", tt.input, got, tt.want)
}
}
}

func TestFormatDiagnosticsNil(t *testing.T) {
provider := machineid.New()
// Before ID() call, Diagnostics() is nil
result := formatDiagnostics(provider)
if result != nil {
t.Error("Expected nil for provider without diagnostics")
}
}

func TestFormatDiagnosticsWithData(t *testing.T) {
provider := machineid.New().WithCPU().WithSystemUUID()
// Generate ID to populate diagnostics
_, err := provider.ID(t.Context())
if err != nil {
t.Fatalf("ID() error: %v", err)
}

result := formatDiagnostics(provider)
if result == nil {
t.Fatal("Expected non-nil diagnostics")
}

if _, ok := result["collected"]; !ok {
t.Error("Expected 'collected' key in diagnostics")
}
}

func TestPrintDiagnosticsNil(t *testing.T) {
provider := machineid.New()
// Should not panic
printDiagnostics(provider)
}

func TestPrintDiagnosticsWithData(t *testing.T) {
provider := machineid.New().WithCPU().WithSystemUUID()
_, err := provider.ID(t.Context())
if err != nil {
t.Fatalf("ID() error: %v", err)
}
// Should not panic
printDiagnostics(provider)
}

func TestFormatDiagnosticsWithErrors(t *testing.T) {
provider := machineid.New().WithCPU().WithDisk()
_, _ = provider.ID(t.Context())

result := formatDiagnostics(provider)
if result == nil {
t.Fatal("Expected non-nil diagnostics")
}

// At least collected should be present
if _, ok := result["collected"]; !ok {
t.Error("Expected 'collected' key in diagnostics")
}
}

func TestPrintJSON(t *testing.T) {
// Capture stdout
oldStdout := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w

printJSON(map[string]any{"key": "value"})

w.Close()
os.Stdout = oldStdout

var buf bytes.Buffer
io.Copy(&buf, r)

var result map[string]any
if err := json.Unmarshal(buf.Bytes(), &result); err != nil {
t.Fatalf("printJSON output is not valid JSON: %v", err)
}
if result["key"] != "value" {
t.Errorf("Expected key=value, got %v", result["key"])
}
}

func TestHandleValidateValid(t *testing.T) {
provider := machineid.New().WithCPU().WithSystemUUID()
id, err := provider.ID(t.Context())
if err != nil {
t.Fatalf("ID() error: %v", err)
}

// Capture stdout
oldStdout := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w

handleValidate(t.Context(), provider, id, false)

w.Close()
os.Stdout = oldStdout

var buf bytes.Buffer
io.Copy(&buf, r)

if !bytes.Contains(buf.Bytes(), []byte("valid: machine ID matches")) {
t.Errorf("Expected 'valid: machine ID matches', got %q", buf.String())
}
}

func TestHandleValidateValidJSON(t *testing.T) {
provider := machineid.New().WithCPU().WithSystemUUID()
id, err := provider.ID(t.Context())
if err != nil {
t.Fatalf("ID() error: %v", err)
}

// Capture stdout
oldStdout := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w

handleValidate(t.Context(), provider, id, true)

w.Close()
os.Stdout = oldStdout

var buf bytes.Buffer
io.Copy(&buf, r)

var result map[string]any
if err := json.Unmarshal(buf.Bytes(), &result); err != nil {
t.Fatalf("handleValidate JSON output is not valid JSON: %v", err)
}
if result["valid"] != true {
t.Errorf("Expected valid=true, got %v", result["valid"])
}
}
Loading
Loading