From 9265d243982515f8723b39bc32855a5f5f63948c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Gonz=C3=A1lez=20Di=20Antonio?= Date: Sat, 14 Feb 2026 19:42:32 +0100 Subject: [PATCH 1/3] feat: add sentinel errors and typed errors for programmatic error handling Introduce structured error types (CommandError, ParseError, ComponentError) and new sentinel errors (ErrNotFound, ErrOEMPlaceholder, ErrAllMethodsFailed) so callers can use errors.Is() and errors.As() to programmatically inspect failure causes. DiagnosticInfo.Errors now wraps all entries in ComponentError. Co-Authored-By: Claude Opus 4.6 --- darwin.go | 17 ++-- doc.go | 26 ++++++ errors.go | 84 +++++++++++++++++++ errors_test.go | 164 +++++++++++++++++++++++++++++++++++++ executor.go | 3 +- linux.go | 3 +- machineid.go | 28 ++----- machineid_internal_test.go | 39 +++++++-- windows.go | 15 ++-- 9 files changed, 332 insertions(+), 47 deletions(-) create mode 100644 errors.go create mode 100644 errors_test.go diff --git a/darwin.go b/darwin.go index c129c4f..556ce60 100644 --- a/darwin.go +++ b/darwin.go @@ -5,7 +5,6 @@ package machineid import ( "context" "encoding/json" - "errors" "fmt" "log/slog" "regexp" @@ -122,7 +121,7 @@ func macOSHardwareUUIDViaIOReg(ctx context.Context, executor CommandExecutor, lo return match[1], nil } - return "", errors.New("hardware UUID not found in ioreg output") + return "", fmt.Errorf("hardware UUID not found in ioreg output: %w", ErrNotFound) } // macOSSerialNumber retrieves system serial number. @@ -157,7 +156,7 @@ func macOSSerialNumberViaIOReg(ctx context.Context, executor CommandExecutor, lo return match[1], nil } - return "", errors.New("serial number not found in ioreg output") + return "", fmt.Errorf("serial number not found in ioreg output: %w", ErrNotFound) } // macOSCPUInfo retrieves CPU information. @@ -201,7 +200,7 @@ func macOSCPUInfo(ctx context.Context, executor CommandExecutor, logger *slog.Lo } } - return "", errors.New("failed to get CPU info: all methods failed") + return "", fmt.Errorf("failed to get CPU info: %w", ErrAllMethodsFailed) } // macOSDiskInfo retrieves internal disk device names for stable machine identification. @@ -221,7 +220,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. @@ -248,7 +247,7 @@ func parseStorageJSON(jsonOutput string) ([]string, error) { } if len(diskNames) == 0 { - return nil, errors.New("no internal disk identifiers found") + return nil, fmt.Errorf("no internal disk identifiers found: %w", ErrNotFound) } return diskNames, nil @@ -258,16 +257,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 "", fmt.Errorf("no hardware data found in JSON output: %w", ErrNotFound) } value := fieldFn(hw.SPHardwareDataType[0]) if value == "" { - return "", errors.New("field is empty in hardware data") + return "", fmt.Errorf("field is empty in hardware data: %w", ErrEmptyValue) } return value, nil diff --git a/doc.go b/doc.go index 2c46d59..bc8fc3f 100644 --- a/doc.go +++ b/doc.go @@ -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. diff --git a/errors.go b/errors.go new file mode 100644 index 0000000..c8a98af --- /dev/null +++ b/errors.go @@ -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 +} diff --git a/errors_test.go b/errors_test.go new file mode 100644 index 0000000..c244f71 --- /dev/null +++ b/errors_test.go @@ -0,0 +1,164 @@ +package machineid + +import ( + "errors" + "fmt" + "testing" +) + +func TestCommandErrorMessage(t *testing.T) { + inner := fmt.Errorf("exit status 1") + err := &CommandError{Command: "sysctl", Err: inner} + + want := `command "sysctl" failed: exit status 1` + if err.Error() != want { + t.Errorf("CommandError.Error() = %q, want %q", err.Error(), want) + } +} + +func TestCommandErrorUnwrap(t *testing.T) { + inner := fmt.Errorf("exit status 1") + err := &CommandError{Command: "sysctl", Err: inner} + + if err.Unwrap() != inner { + t.Error("CommandError.Unwrap() did not return inner error") + } +} + +func TestCommandErrorAs(t *testing.T) { + inner := fmt.Errorf("exit status 1") + err := fmt.Errorf("collecting CPU: %w", &CommandError{Command: "sysctl", Err: inner}) + + var cmdErr *CommandError + if !errors.As(err, &cmdErr) { + t.Fatal("errors.As() should find CommandError in wrapped chain") + } + + if cmdErr.Command != "sysctl" { + t.Errorf("CommandError.Command = %q, want %q", cmdErr.Command, "sysctl") + } +} + +func TestParseErrorMessage(t *testing.T) { + inner := fmt.Errorf("unexpected end of JSON input") + err := &ParseError{Source: "system_profiler JSON", Err: inner} + + want := "failed to parse system_profiler JSON: unexpected end of JSON input" + if err.Error() != want { + t.Errorf("ParseError.Error() = %q, want %q", err.Error(), want) + } +} + +func TestParseErrorUnwrap(t *testing.T) { + inner := fmt.Errorf("unexpected end of JSON input") + err := &ParseError{Source: "system_profiler JSON", Err: inner} + + if err.Unwrap() != inner { + t.Error("ParseError.Unwrap() did not return inner error") + } +} + +func TestParseErrorAs(t *testing.T) { + inner := fmt.Errorf("invalid character") + err := fmt.Errorf("hardware UUID: %w", &ParseError{Source: "ioreg output", Err: inner}) + + var parseErr *ParseError + if !errors.As(err, &parseErr) { + t.Fatal("errors.As() should find ParseError in wrapped chain") + } + + if parseErr.Source != "ioreg output" { + t.Errorf("ParseError.Source = %q, want %q", parseErr.Source, "ioreg output") + } +} + +func TestComponentErrorMessage(t *testing.T) { + inner := ErrNotFound + err := &ComponentError{Component: "uuid", Err: inner} + + want := `component "uuid": value not found` + if err.Error() != want { + t.Errorf("ComponentError.Error() = %q, want %q", err.Error(), want) + } +} + +func TestComponentErrorUnwrap(t *testing.T) { + err := &ComponentError{Component: "cpu", Err: ErrAllMethodsFailed} + + if err.Unwrap() != ErrAllMethodsFailed { + t.Error("ComponentError.Unwrap() did not return inner error") + } +} + +func TestComponentErrorIs(t *testing.T) { + err := &ComponentError{Component: "cpu", Err: ErrAllMethodsFailed} + + if !errors.Is(err, ErrAllMethodsFailed) { + t.Error("errors.Is(ComponentError, ErrAllMethodsFailed) should be true") + } +} + +func TestComponentErrorAs(t *testing.T) { + err := fmt.Errorf("ID generation: %w", &ComponentError{Component: "disk", Err: ErrNotFound}) + + var compErr *ComponentError + if !errors.As(err, &compErr) { + t.Fatal("errors.As() should find ComponentError in wrapped chain") + } + + if compErr.Component != "disk" { + t.Errorf("ComponentError.Component = %q, want %q", compErr.Component, "disk") + } + + if !errors.Is(compErr, ErrNotFound) { + t.Error("errors.Is(ComponentError, ErrNotFound) should be true through Unwrap") + } +} + +func TestSentinelErrorsWrapping(t *testing.T) { + tests := []struct { + name string + err error + sentinel error + }{ + {"ErrNotFound wrapped", fmt.Errorf("UUID not found: %w", ErrNotFound), ErrNotFound}, + {"ErrOEMPlaceholder wrapped", fmt.Errorf("serial is placeholder: %w", ErrOEMPlaceholder), ErrOEMPlaceholder}, + {"ErrAllMethodsFailed wrapped", fmt.Errorf("CPU failed: %w", ErrAllMethodsFailed), ErrAllMethodsFailed}, + {"ErrEmptyValue wrapped", fmt.Errorf("empty: %w", ErrEmptyValue), ErrEmptyValue}, + {"ErrNoValues wrapped", fmt.Errorf("no values: %w", ErrNoValues), ErrNoValues}, + {"ErrNoIdentifiers direct", ErrNoIdentifiers, ErrNoIdentifiers}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if !errors.Is(tt.err, tt.sentinel) { + t.Errorf("errors.Is() should find %v in %v", tt.sentinel, tt.err) + } + }) + } +} + +func TestDeepWrappingChain(t *testing.T) { + // CommandError -> ComponentError -> wrapped error + cmdErr := &CommandError{Command: "ioreg", Err: fmt.Errorf("not found")} + compErr := &ComponentError{Component: "uuid", Err: cmdErr} + topErr := fmt.Errorf("ID generation failed: %w", compErr) + + var gotCmd *CommandError + if !errors.As(topErr, &gotCmd) { + t.Fatal("errors.As() should find CommandError through deep chain") + } + + if gotCmd.Command != "ioreg" { + t.Errorf("CommandError.Command = %q, want %q", gotCmd.Command, "ioreg") + } + + var gotComp *ComponentError + if !errors.As(topErr, &gotComp) { + t.Fatal("errors.As() should find ComponentError through deep chain") + } + + if gotComp.Component != "uuid" { + t.Errorf("ComponentError.Component = %q, want %q", gotComp.Component, "uuid") + } +} diff --git a/executor.go b/executor.go index 7cade7a..6d2510c 100644 --- a/executor.go +++ b/executor.go @@ -2,7 +2,6 @@ package machineid import ( "context" - "fmt" "log/slog" "os/exec" "strings" @@ -28,7 +27,7 @@ func (e *defaultCommandExecutor) Execute(ctx context.Context, name string, args cmd := exec.CommandContext(timeoutCtx, name, args...) output, err := cmd.Output() if err != nil { - return "", fmt.Errorf("command %q failed: %w", name, err) + return "", &CommandError{Command: name, Err: err} } return strings.TrimSpace(string(output)), nil diff --git a/linux.go b/linux.go index e3e9cab..cbaada0 100644 --- a/linux.go +++ b/linux.go @@ -4,7 +4,6 @@ package machineid import ( "context" - "errors" "fmt" "log/slog" "os" @@ -142,7 +141,7 @@ func readFirstValidFromLocations(locations []string, validator func(string) bool } } - return "", errors.New("valid value not found in any location") + return "", fmt.Errorf("valid value not found in any location: %w", ErrNotFound) } // isValidUUID checks if UUID is valid (not empty or null) diff --git a/machineid.go b/machineid.go index d0532e3..6751bb7 100644 --- a/machineid.go +++ b/machineid.go @@ -4,7 +4,6 @@ import ( "context" "crypto/sha256" "encoding/hex" - "errors" "fmt" "log/slog" "runtime" @@ -14,21 +13,6 @@ import ( "time" ) -// Sentinel errors returned by [Provider.ID]. -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") -) - // FormatMode defines the output format and length of the machine ID. type FormatMode int @@ -353,8 +337,9 @@ func (p *Provider) enabledComponents() []string { func appendIdentifierIfValid(identifiers []string, getValue func() (string, error), prefix string, diag *DiagnosticInfo, component string, logger *slog.Logger) []string { value, err := getValue() if err != nil { + compErr := &ComponentError{Component: component, Err: err} if diag != nil { - diag.Errors[component] = err + diag.Errors[component] = compErr } if logger != nil { logger.Warn("component failed", "component", component, "error", err) @@ -364,8 +349,9 @@ func appendIdentifierIfValid(identifiers []string, getValue func() (string, erro } if value == "" { + compErr := &ComponentError{Component: component, Err: ErrEmptyValue} if diag != nil { - diag.Errors[component] = ErrEmptyValue + diag.Errors[component] = compErr } if logger != nil { logger.Warn("component returned empty value", "component", component) @@ -390,8 +376,9 @@ func appendIdentifierIfValid(identifiers []string, getValue func() (string, erro func appendIdentifiersIfValid(identifiers []string, getValues func() ([]string, error), prefix string, diag *DiagnosticInfo, component string, logger *slog.Logger) []string { values, err := getValues() if err != nil { + compErr := &ComponentError{Component: component, Err: err} if diag != nil { - diag.Errors[component] = err + diag.Errors[component] = compErr } if logger != nil { logger.Warn("component failed", "component", component, "error", err) @@ -401,8 +388,9 @@ func appendIdentifiersIfValid(identifiers []string, getValues func() ([]string, } if len(values) == 0 { + compErr := &ComponentError{Component: component, Err: ErrNoValues} if diag != nil { - diag.Errors[component] = ErrNoValues + diag.Errors[component] = compErr } if logger != nil { logger.Warn("component returned no values", "component", component) diff --git a/machineid_internal_test.go b/machineid_internal_test.go index 952cfb1..ff07868 100644 --- a/machineid_internal_test.go +++ b/machineid_internal_test.go @@ -160,8 +160,19 @@ func TestAppendIdentifierIfValidEmpty(t *testing.T) { if len(result) != 1 { t.Errorf("Expected 1 identifier, got %d", len(result)) } - if _, ok := diag.Errors["test"]; !ok { - t.Error("Expected error recorded in diagnostics for empty value") + diagErr, ok := diag.Errors["test"] + if !ok { + t.Fatal("Expected error recorded in diagnostics for empty value") + } + if !errors.Is(diagErr, ErrEmptyValue) { + t.Errorf("Expected ErrEmptyValue in diagnostic, got %v", diagErr) + } + var compErr *ComponentError + if !errors.As(diagErr, &compErr) { + t.Fatal("Expected ComponentError in diagnostic") + } + if compErr.Component != "test" { + t.Errorf("ComponentError.Component = %q, want %q", compErr.Component, "test") } } @@ -176,8 +187,16 @@ func TestAppendIdentifierIfValidError(t *testing.T) { if len(result) != 1 { t.Errorf("Expected 1 identifier (original), got %d", len(result)) } - if _, ok := diag.Errors["test"]; !ok { - t.Error("Expected error recorded in diagnostics") + diagErr, ok := diag.Errors["test"] + if !ok { + t.Fatal("Expected error recorded in diagnostics") + } + var compErr *ComponentError + if !errors.As(diagErr, &compErr) { + t.Fatal("Expected ComponentError in diagnostic") + } + if compErr.Component != "test" { + t.Errorf("ComponentError.Component = %q, want %q", compErr.Component, "test") } } @@ -221,8 +240,16 @@ func TestAppendIdentifiersIfValidError(t *testing.T) { if len(result) != 1 { t.Errorf("Expected 1 identifier (original), got %d", len(result)) } - if _, ok := diag.Errors["test"]; !ok { - t.Error("Expected error recorded in diagnostics") + diagErr, ok := diag.Errors["test"] + if !ok { + t.Fatal("Expected error recorded in diagnostics") + } + var compErr *ComponentError + if !errors.As(diagErr, &compErr) { + t.Fatal("Expected ComponentError in diagnostic") + } + if compErr.Component != "test" { + t.Errorf("ComponentError.Component = %q, want %q", compErr.Component, "test") } } diff --git a/windows.go b/windows.go index e532855..d431c80 100644 --- a/windows.go +++ b/windows.go @@ -4,7 +4,6 @@ package machineid import ( "context" - "errors" "fmt" "log/slog" "strings" @@ -64,7 +63,7 @@ func parseWmicValue(output, prefix string) (string, error) { } } - return "", fmt.Errorf("value with prefix %s not found", prefix) + return "", fmt.Errorf("value with prefix %s: %w", prefix, ErrNotFound) } // parseWmicMultipleValues extracts all values from wmic output with given prefix. @@ -90,7 +89,7 @@ func parseWmicMultipleValues(output, prefix string) []string { func parsePowerShellValue(output string) (string, error) { value := strings.TrimSpace(output) if value == "" { - return "", errors.New("empty value from PowerShell") + return "", fmt.Errorf("empty value from PowerShell: %w", ErrEmptyValue) } return value, nil @@ -128,7 +127,7 @@ func windowsCPUID(ctx context.Context, executor CommandExecutor, logger *slog.Lo psOutput, psErr := executeCommand(ctx, executor, logger, "powershell", "-Command", "Get-CimInstance -ClassName Win32_Processor | Select-Object -ExpandProperty ProcessorId") if psErr != nil { - return "", fmt.Errorf("failed to get CPU ID: wmic: %w, powershell: %w", err, psErr) + return "", fmt.Errorf("failed to get CPU ID (wmic: %v, powershell: %v): %w", err, psErr, ErrAllMethodsFailed) } return parsePowerShellValue(psOutput) @@ -151,7 +150,7 @@ func windowsMotherboardSerial(ctx context.Context, executor CommandExecutor, log psOutput, psErr := executeCommand(ctx, executor, logger, "powershell", "-Command", "Get-CimInstance -ClassName Win32_BaseBoard | Select-Object -ExpandProperty SerialNumber") if psErr != nil { - return "", fmt.Errorf("failed to get motherboard serial: wmic: %w, powershell: %w", err, psErr) + return "", fmt.Errorf("failed to get motherboard serial (wmic: %v, powershell: %v): %w", err, psErr, ErrAllMethodsFailed) } value, parseErr := parsePowerShellValue(psOutput) @@ -160,7 +159,7 @@ func windowsMotherboardSerial(ctx context.Context, executor CommandExecutor, log } if value == biosFirmwareMessage { - return "", errors.New("motherboard serial is OEM placeholder") + return "", fmt.Errorf("motherboard serial is OEM placeholder: %w", ErrOEMPlaceholder) } return value, nil @@ -212,12 +211,12 @@ func windowsDiskSerials(ctx context.Context, executor CommandExecutor, logger *s psOutput, psErr := executeCommand(ctx, executor, logger, "powershell", "-Command", "Get-CimInstance -ClassName Win32_DiskDrive | Select-Object -ExpandProperty SerialNumber") if psErr != nil { - return nil, fmt.Errorf("failed to get disk serials: wmic: %w, powershell: %w", err, psErr) + return nil, fmt.Errorf("failed to get disk serials (wmic: %v, powershell: %v): %w", err, psErr, ErrAllMethodsFailed) } values := parsePowerShellMultipleValues(psOutput) if len(values) == 0 { - return nil, errors.New("no disk serials found via PowerShell") + return nil, fmt.Errorf("no disk serials found via PowerShell: %w", ErrNotFound) } return values, nil From a7559623ab5b1f18195dce1927c8744dee6266d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Gonz=C3=A1lez=20Di=20Antonio?= Date: Sat, 14 Feb 2026 20:01:52 +0100 Subject: [PATCH 2/3] feat: replace fmt.Errorf with typed errors, add logging coverage, and CLI logger flags Replace all remaining fmt.Errorf calls with typed errors (ParseError, CommandError) and sentinel errors for programmatic error handling via errors.Is/errors.As. Add missing logging to silent fallback paths and file reads across darwin, linux, and windows collectors. Expose the library logger in the CLI with -verbose and -debug flags. Co-Authored-By: Claude Opus 4.6 --- cmd/machineid/main.go | 19 +++++++++++++++++++ darwin.go | 44 ++++++++++++++++++++++++++++++++++--------- doc.go | 2 ++ linux.go | 42 ++++++++++++++++++++++++++++++++++------- machineid.go | 3 +-- windows.go | 39 +++++++++++++++++++++++++++++--------- 6 files changed, 122 insertions(+), 27 deletions(-) diff --git a/cmd/machineid/main.go b/cmd/machineid/main.go index 8fc1967..adc5bfc 100644 --- a/cmd/machineid/main.go +++ b/cmd/machineid/main.go @@ -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") @@ -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 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") } @@ -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) } diff --git a/darwin.go b/darwin.go index 556ce60..e5413c6 100644 --- a/darwin.go +++ b/darwin.go @@ -99,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 @@ -113,7 +117,7 @@ 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) @@ -121,7 +125,11 @@ func macOSHardwareUUIDViaIOReg(ctx context.Context, executor CommandExecutor, lo return match[1], nil } - return "", fmt.Errorf("hardware UUID not found in ioreg output: %w", ErrNotFound) + if logger != nil { + logger.Debug("hardware UUID not found in ioreg output") + } + + return "", &ParseError{Source: "ioreg output", Err: ErrNotFound} } // macOSSerialNumber retrieves system serial number. @@ -134,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 @@ -148,7 +160,7 @@ 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) @@ -156,7 +168,11 @@ func macOSSerialNumberViaIOReg(ctx context.Context, executor CommandExecutor, lo return match[1], nil } - return "", fmt.Errorf("serial number not found in ioreg output: %w", ErrNotFound) + if logger != nil { + logger.Debug("serial number not found in ioreg output") + } + + return "", &ParseError{Source: "ioreg output", Err: ErrNotFound} } // macOSCPUInfo retrieves CPU information. @@ -197,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 "", fmt.Errorf("failed to get CPU info: %w", ErrAllMethodsFailed) + if logger != nil { + logger.Warn("all CPU info methods failed") + } + + return "", ErrAllMethodsFailed } // macOSDiskInfo retrieves internal disk device names for stable machine identification. @@ -209,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) @@ -247,7 +273,7 @@ func parseStorageJSON(jsonOutput string) ([]string, error) { } if len(diskNames) == 0 { - return nil, fmt.Errorf("no internal disk identifiers found: %w", ErrNotFound) + return nil, &ParseError{Source: "system_profiler storage output", Err: ErrNotFound} } return diskNames, nil @@ -261,12 +287,12 @@ func extractHardwareField(jsonOutput string, fieldFn func(spHardwareEntry) strin } if len(hw.SPHardwareDataType) == 0 { - return "", fmt.Errorf("no hardware data found in JSON output: %w", ErrNotFound) + return "", &ParseError{Source: "system_profiler hardware JSON", Err: ErrNotFound} } value := fieldFn(hw.SPHardwareDataType[0]) if value == "" { - return "", fmt.Errorf("field is empty in hardware data: %w", ErrEmptyValue) + return "", &ParseError{Source: "system_profiler hardware JSON", Err: ErrEmptyValue} } return value, nil diff --git a/doc.go b/doc.go index bc8fc3f..56ed8ce 100644 --- a/doc.go +++ b/doc.go @@ -159,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 diff --git a/linux.go b/linux.go index cbaada0..3e7fa3c 100644 --- a/linux.go +++ b/linux.go @@ -17,7 +17,9 @@ func collectIdentifiers(ctx context.Context, p *Provider, diag *DiagnosticInfo) logger := p.logger if p.includeCPU { - identifiers = appendIdentifierIfValid(identifiers, linuxCPUID, "cpu:", diag, ComponentCPU, logger) + identifiers = appendIdentifierIfValid(identifiers, func() (string, error) { + return linuxCPUID(logger) + }, "cpu:", diag, ComponentCPU, logger) } if p.includeSystemUUID { @@ -51,12 +53,22 @@ func collectIdentifiers(ctx context.Context, p *Provider, diag *DiagnosticInfo) } // linuxCPUID retrieves CPU information from /proc/cpuinfo -func linuxCPUID() (string, error) { - data, err := os.ReadFile("/proc/cpuinfo") +func linuxCPUID(logger *slog.Logger) (string, error) { + const path = "/proc/cpuinfo" + + data, err := os.ReadFile(path) if err != nil { + if logger != nil { + logger.Debug("failed to read CPU info", "path", path, "error", err) + } + return "", err } + if logger != nil { + logger.Debug("read CPU info", "path", path) + } + return parseCPUInfo(string(data)), nil } @@ -141,7 +153,7 @@ func readFirstValidFromLocations(locations []string, validator func(string) bool } } - return "", fmt.Errorf("valid value not found in any location: %w", ErrNotFound) + return "", ErrNotFound } // isValidUUID checks if UUID is valid (not empty or null) @@ -174,16 +186,28 @@ func linuxDiskSerials(ctx context.Context, executor CommandExecutor, logger *slo serials = append(serials, s) } } + + if logger != nil { + logger.Debug("collected disk serials via lsblk", "count", len(lsblkSerials)) + } + } else if logger != nil { + logger.Debug("lsblk failed, trying /sys/block", "error", err) } // Try reading from /sys/block - if sysSerials, err := linuxDiskSerialsSys(); err == nil { + if sysSerials, err := linuxDiskSerialsSys(logger); err == nil { for _, s := range sysSerials { if _, exists := seen[s]; !exists { seen[s] = struct{}{} serials = append(serials, s) } } + + if logger != nil { + logger.Debug("collected disk serials via /sys/block", "count", len(sysSerials)) + } + } else if logger != nil { + logger.Debug("/sys/block read failed", "error", err) } return serials, nil @@ -193,7 +217,7 @@ func linuxDiskSerials(ctx context.Context, executor CommandExecutor, logger *slo func linuxDiskSerialsLSBLK(ctx context.Context, executor CommandExecutor, logger *slog.Logger) ([]string, error) { output, err := executeCommand(ctx, executor, logger, "lsblk", "-d", "-n", "-o", "SERIAL") if err != nil { - return nil, fmt.Errorf("failed to get disk serials: %w", err) + return nil, err } var serials []string @@ -209,7 +233,7 @@ func linuxDiskSerialsLSBLK(ctx context.Context, executor CommandExecutor, logger } // linuxDiskSerialsSys retrieves disk serials from /sys/block -func linuxDiskSerialsSys() ([]string, error) { +func linuxDiskSerialsSys(logger *slog.Logger) ([]string, error) { var serials []string blockDir := "/sys/block" @@ -225,6 +249,10 @@ func linuxDiskSerialsSys() ([]string, error) { serial := strings.TrimSpace(string(data)) if serial != "" { serials = append(serials, serial) + + if logger != nil { + logger.Debug("read disk serial from sysfs", "disk", entry.Name(), "path", serialFile) + } } } } diff --git a/machineid.go b/machineid.go index 6751bb7..d64f534 100644 --- a/machineid.go +++ b/machineid.go @@ -4,7 +4,6 @@ import ( "context" "crypto/sha256" "encoding/hex" - "fmt" "log/slog" "runtime" "sort" @@ -192,7 +191,7 @@ func (p *Provider) ID(ctx context.Context) (string, error) { identifiers, err := collectIdentifiers(ctx, p, diag) if err != nil { - return "", fmt.Errorf("failed to collect hardware identifiers: %w", err) + return "", err } if len(identifiers) == 0 { diff --git a/windows.go b/windows.go index d431c80..1341970 100644 --- a/windows.go +++ b/windows.go @@ -4,7 +4,6 @@ package machineid import ( "context" - "fmt" "log/slog" "strings" ) @@ -63,7 +62,7 @@ func parseWmicValue(output, prefix string) (string, error) { } } - return "", fmt.Errorf("value with prefix %s: %w", prefix, ErrNotFound) + return "", &ParseError{Source: "wmic output", Err: ErrNotFound} } // parseWmicMultipleValues extracts all values from wmic output with given prefix. @@ -89,7 +88,7 @@ func parseWmicMultipleValues(output, prefix string) []string { func parsePowerShellValue(output string) (string, error) { value := strings.TrimSpace(output) if value == "" { - return "", fmt.Errorf("empty value from PowerShell: %w", ErrEmptyValue) + return "", &ParseError{Source: "PowerShell output", Err: ErrEmptyValue} } return value, nil @@ -116,6 +115,8 @@ func windowsCPUID(ctx context.Context, executor CommandExecutor, logger *slog.Lo 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) } } @@ -127,7 +128,11 @@ func windowsCPUID(ctx context.Context, executor CommandExecutor, logger *slog.Lo psOutput, psErr := executeCommand(ctx, executor, logger, "powershell", "-Command", "Get-CimInstance -ClassName Win32_Processor | Select-Object -ExpandProperty ProcessorId") if psErr != nil { - return "", fmt.Errorf("failed to get CPU ID (wmic: %v, powershell: %v): %w", err, psErr, ErrAllMethodsFailed) + if logger != nil { + logger.Warn("all CPU ID methods failed") + } + + return "", ErrAllMethodsFailed } return parsePowerShellValue(psOutput) @@ -139,6 +144,8 @@ func windowsMotherboardSerial(ctx context.Context, executor CommandExecutor, log 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) } } @@ -150,7 +157,11 @@ func windowsMotherboardSerial(ctx context.Context, executor CommandExecutor, log psOutput, psErr := executeCommand(ctx, executor, logger, "powershell", "-Command", "Get-CimInstance -ClassName Win32_BaseBoard | Select-Object -ExpandProperty SerialNumber") if psErr != nil { - return "", fmt.Errorf("failed to get motherboard serial (wmic: %v, powershell: %v): %w", err, psErr, ErrAllMethodsFailed) + if logger != nil { + logger.Warn("all motherboard serial methods failed") + } + + return "", ErrAllMethodsFailed } value, parseErr := parsePowerShellValue(psOutput) @@ -159,7 +170,7 @@ func windowsMotherboardSerial(ctx context.Context, executor CommandExecutor, log } if value == biosFirmwareMessage { - return "", fmt.Errorf("motherboard serial is OEM placeholder: %w", ErrOEMPlaceholder) + return "", &ParseError{Source: "PowerShell output", Err: ErrOEMPlaceholder} } return value, nil @@ -172,6 +183,8 @@ func windowsSystemUUID(ctx context.Context, executor CommandExecutor, logger *sl 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) } } @@ -188,7 +201,7 @@ func windowsSystemUUIDViaPowerShell(ctx context.Context, executor CommandExecuto output, err := executeCommand(ctx, executor, logger, "powershell", "-Command", "Get-CimInstance -ClassName Win32_ComputerSystemProduct | Select-Object -ExpandProperty UUID") if err != nil { - return "", fmt.Errorf("failed to get UUID via PowerShell: %w", err) + return "", err } return parsePowerShellValue(output) @@ -201,6 +214,10 @@ func windowsDiskSerials(ctx context.Context, executor CommandExecutor, logger *s if values := parseWmicMultipleValues(output, "SerialNumber="); len(values) > 0 { return values, nil } + + if logger != nil { + logger.Debug("wmic returned no disk serials") + } } // Fallback to PowerShell Get-CimInstance @@ -211,12 +228,16 @@ func windowsDiskSerials(ctx context.Context, executor CommandExecutor, logger *s psOutput, psErr := executeCommand(ctx, executor, logger, "powershell", "-Command", "Get-CimInstance -ClassName Win32_DiskDrive | Select-Object -ExpandProperty SerialNumber") if psErr != nil { - return nil, fmt.Errorf("failed to get disk serials (wmic: %v, powershell: %v): %w", err, psErr, ErrAllMethodsFailed) + if logger != nil { + logger.Warn("all disk serial methods failed") + } + + return nil, ErrAllMethodsFailed } values := parsePowerShellMultipleValues(psOutput) if len(values) == 0 { - return nil, fmt.Errorf("no disk serials found via PowerShell: %w", ErrNotFound) + return nil, &ParseError{Source: "PowerShell output", Err: ErrNotFound} } return values, nil From 0aecbe87047992ff2a75c7c8eb50be4f5720a776 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Gonz=C3=A1lez=20Di=20Antonio?= Date: Sat, 14 Feb 2026 20:02:19 +0100 Subject: [PATCH 3/3] chore: add new workds --- .vscode/settings.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.vscode/settings.json b/.vscode/settings.json index 2b4c6de..e9da3ca 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,10 +1,14 @@ { "cSpell.words": [ "betteralign", + "CPUID", + "csproduct", + "diskdrive", "Errorf", "golangci", "govulncheck", "ioreg", + "machdep", "machineid", "OSCPU", "slashdevops",