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
4 changes: 4 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
{
"cSpell.words": [
"betteralign",
"CPUID",
"csproduct",
"diskdrive",
"Errorf",
"golangci",
"govulncheck",
"ioreg",
"machdep",
"machineid",
"OSCPU",
"slashdevops",
Expand Down
19 changes: 19 additions & 0 deletions cmd/machineid/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ func main() {
diagnostics := flag.Bool("diagnostics", false, "Show diagnostic information about collected components")
jsonOutput := flag.Bool("json", false, "Output result as JSON")

// Logging flags
verbose := flag.Bool("verbose", false, "Enable info-level logging to stderr (fallbacks, lifecycle)")
debugFlag := flag.Bool("debug", false, "Enable debug-level logging to stderr (command details, raw values, timing)")

// Info flags
versionFlag := flag.Bool("version", false, "Show version information")
versionLongFlag := flag.Bool("version.long", false, "Show detailed version information")
Expand All @@ -50,6 +54,8 @@ func main() {
fmt.Fprintf(os.Stderr, " machineid -cpu -uuid -diagnostics Show collected components\n")
fmt.Fprintf(os.Stderr, " machineid -cpu -uuid -validate <id> Validate an existing ID\n")
fmt.Fprintf(os.Stderr, " machineid -cpu -uuid -json Output as JSON\n")
fmt.Fprintf(os.Stderr, " machineid -cpu -uuid -verbose Show info-level logs\n")
fmt.Fprintf(os.Stderr, " machineid -all -debug Show debug-level logs\n")
fmt.Fprintf(os.Stderr, " machineid -version Show version\n")
fmt.Fprintf(os.Stderr, " machineid -version.long Show detailed version\n")
}
Expand Down Expand Up @@ -108,9 +114,22 @@ func main() {
os.Exit(1)
}

// Configure logger
if *debugFlag {
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug}))
slog.SetDefault(logger)
} else if *verbose {
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))
slog.SetDefault(logger)
}

// Build provider
provider := machineid.New().WithFormat(formatMode)

if *verbose || *debugFlag {
provider.WithLogger(slog.Default())
}

if *salt != "" {
provider.WithSalt(*salt)
}
Expand Down
49 changes: 37 additions & 12 deletions darwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ package machineid
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"regexp"
Expand Down Expand Up @@ -100,6 +99,10 @@ func macOSHardwareUUID(ctx context.Context, executor CommandExecutor, logger *sl
if parseErr == nil {
return uuid, nil
}

if logger != nil {
logger.Debug("system_profiler UUID parsing failed", "error", parseErr)
}
}

// Fallback to ioreg
Expand All @@ -114,15 +117,19 @@ func macOSHardwareUUID(ctx context.Context, executor CommandExecutor, logger *sl
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 {
return "", fmt.Errorf("failed to get hardware UUID: %w", err)
return "", err
}

match := ioregUUIDRe.FindStringSubmatch(output)
if len(match) > 1 {
return match[1], nil
}

return "", errors.New("hardware UUID not found in ioreg output")
if logger != nil {
logger.Debug("hardware UUID not found in ioreg output")
}

return "", &ParseError{Source: "ioreg output", Err: ErrNotFound}
}

// macOSSerialNumber retrieves system serial number.
Expand All @@ -135,6 +142,10 @@ func macOSSerialNumber(ctx context.Context, executor CommandExecutor, logger *sl
if parseErr == nil {
return serial, nil
}

if logger != nil {
logger.Debug("system_profiler serial parsing failed", "error", parseErr)
}
}

// Fallback to ioreg
Expand All @@ -149,15 +160,19 @@ func macOSSerialNumber(ctx context.Context, executor CommandExecutor, logger *sl
func macOSSerialNumberViaIOReg(ctx context.Context, executor CommandExecutor, logger *slog.Logger) (string, error) {
output, err := executeCommand(ctx, executor, logger, "ioreg", "-d2", "-c", "IOPlatformExpertDevice")
if err != nil {
return "", fmt.Errorf("failed to get serial number: %w", err)
return "", err
}

match := ioregSerialRe.FindStringSubmatch(output)
if len(match) > 1 {
return match[1], nil
}

return "", errors.New("serial number not found in ioreg output")
if logger != nil {
logger.Debug("serial number not found in ioreg output")
}

return "", &ParseError{Source: "ioreg output", Err: ErrNotFound}
}

// macOSCPUInfo retrieves CPU information.
Expand Down Expand Up @@ -198,10 +213,20 @@ func macOSCPUInfo(ctx context.Context, executor CommandExecutor, logger *slog.Lo
if entry.ChipType != "" {
return entry.ChipType, nil
}

if logger != nil {
logger.Debug("system_profiler returned empty chip_type")
}
} else if logger != nil {
logger.Debug("system_profiler CPU JSON parsing failed", "error", jsonErr)
}
}

return "", errors.New("failed to get CPU info: all methods failed")
if logger != nil {
logger.Warn("all CPU info methods failed")
}

return "", ErrAllMethodsFailed
}

// macOSDiskInfo retrieves internal disk device names for stable machine identification.
Expand All @@ -210,7 +235,7 @@ func macOSCPUInfo(ctx context.Context, executor CommandExecutor, logger *slog.Lo
func macOSDiskInfo(ctx context.Context, executor CommandExecutor, logger *slog.Logger) ([]string, error) {
output, err := executeCommand(ctx, executor, logger, "system_profiler", "SPStorageDataType", "-json")
if err != nil {
return nil, fmt.Errorf("failed to get disk info: %w", err)
return nil, err
}

return parseStorageJSON(output)
Expand All @@ -221,7 +246,7 @@ func macOSDiskInfo(ctx context.Context, executor CommandExecutor, logger *slog.L
func parseStorageJSON(jsonOutput string) ([]string, error) {
var storage spStorageDataType
if err := json.Unmarshal([]byte(jsonOutput), &storage); err != nil {
return nil, fmt.Errorf("failed to parse storage JSON: %w", err)
return nil, &ParseError{Source: "system_profiler storage JSON", Err: err}
}

// Use a set to deduplicate — multiple volumes can share the same physical disk.
Expand All @@ -248,7 +273,7 @@ func parseStorageJSON(jsonOutput string) ([]string, error) {
}

if len(diskNames) == 0 {
return nil, errors.New("no internal disk identifiers found")
return nil, &ParseError{Source: "system_profiler storage output", Err: ErrNotFound}
}

return diskNames, nil
Expand All @@ -258,16 +283,16 @@ func parseStorageJSON(jsonOutput string) ([]string, error) {
func extractHardwareField(jsonOutput string, fieldFn func(spHardwareEntry) string) (string, error) {
var hw spHardwareDataType
if err := json.Unmarshal([]byte(jsonOutput), &hw); err != nil {
return "", fmt.Errorf("failed to parse hardware JSON: %w", err)
return "", &ParseError{Source: "system_profiler hardware JSON", Err: err}
}

if len(hw.SPHardwareDataType) == 0 {
return "", errors.New("no hardware data found in JSON output")
return "", &ParseError{Source: "system_profiler hardware JSON", Err: ErrNotFound}
}

value := fieldFn(hw.SPHardwareDataType[0])
if value == "" {
return "", errors.New("field is empty in hardware data")
return "", &ParseError{Source: "system_profiler hardware JSON", Err: ErrEmptyValue}
}

return value, nil
Expand Down
28 changes: 28 additions & 0 deletions doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,32 @@
// - Warn: component failed or returned empty value
// - Debug: command execution details, raw hardware values, timing
//
// # Errors
//
// The package provides sentinel errors for programmatic error handling:
//
// - [ErrNoIdentifiers] — no hardware identifiers collected
// - [ErrEmptyValue] — a component returned an empty value
// - [ErrNoValues] — a multi-value component returned no values
// - [ErrNotFound] — a value was not found in command output or system files
// - [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]:
//
// - [CommandError] — a system command execution failed (includes the command name)
// - [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) {
// fmt.Println("component:", compErr.Component)
// fmt.Println("cause:", compErr.Err)
// }
//
// # Thread Safety
//
// A [Provider] is safe for concurrent use after configuration is complete.
Expand Down Expand Up @@ -133,6 +159,8 @@
// machineid -cpu -uuid
// machineid -all -format 32 -json
// machineid -vm -salt "my-app" -diagnostics
// machineid -cpu -uuid -verbose
// machineid -all -debug
// machineid -version
// machineid -version.long
package machineid
84 changes: 84 additions & 0 deletions errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package machineid

import (
"errors"
"fmt"
)

// Sentinel errors returned by [Provider.ID] and recorded in [DiagnosticInfo.Errors].
var (
// ErrNoIdentifiers is returned when no hardware identifiers could be
// collected with the current configuration.
ErrNoIdentifiers = errors.New("no hardware identifiers found with current configuration")

// ErrEmptyValue is returned in [DiagnosticInfo.Errors] when a hardware
// component returned an empty value.
ErrEmptyValue = errors.New("empty value returned")

// ErrNoValues is returned in [DiagnosticInfo.Errors] when a hardware
// component returned no values.
ErrNoValues = errors.New("no values found")

// ErrNotFound is returned when a hardware value is not found in
// command output or system files.
ErrNotFound = errors.New("value not found")

// ErrOEMPlaceholder is returned when a hardware value matches a
// BIOS/UEFI OEM placeholder such as "To be filled by O.E.M.".
ErrOEMPlaceholder = errors.New("value is OEM placeholder")

// ErrAllMethodsFailed is returned when all collection methods for a
// hardware component have been exhausted without success.
ErrAllMethodsFailed = errors.New("all collection methods failed")
)

// CommandError records a failed system command execution.
// Use [errors.As] to extract the command name from wrapped errors.
type CommandError struct {
Command string // command name, e.g. "sysctl", "ioreg", "wmic"
Err error // underlying error from exec
}

// Error returns a human-readable description of the command failure.
func (e *CommandError) Error() string {
return fmt.Sprintf("command %q failed: %v", e.Command, e.Err)
}

// Unwrap returns the underlying error.
func (e *CommandError) Unwrap() error {
return e.Err
}

// ParseError records a failure while parsing command or system output.
// Use [errors.As] to extract the source from wrapped errors.
type ParseError struct {
Source string // data source, e.g. "system_profiler JSON", "wmic output"
Err error // underlying parse error
}

// Error returns a human-readable description of the parse failure.
func (e *ParseError) Error() string {
return fmt.Sprintf("failed to parse %s: %v", e.Source, e.Err)
}

// Unwrap returns the underlying error.
func (e *ParseError) Unwrap() error {
return e.Err
}

// ComponentError records a failure while collecting a specific hardware component.
// These errors appear in [DiagnosticInfo.Errors] and can be inspected with [errors.As].
type ComponentError struct {
Component string // component name, e.g. "cpu", "uuid", "disk"
Err error // underlying error
}

// Error returns a human-readable description of the component failure.
func (e *ComponentError) Error() string {
return fmt.Sprintf("component %q: %v", e.Component, e.Err)
}

// Unwrap returns the underlying error.
func (e *ComponentError) Unwrap() error {
return e.Err
}
Loading
Loading