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", 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 c129c4f..e5413c6 100644 --- a/darwin.go +++ b/darwin.go @@ -5,7 +5,6 @@ package machineid import ( "context" "encoding/json" - "errors" "fmt" "log/slog" "regexp" @@ -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 @@ -114,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) @@ -122,7 +125,11 @@ func macOSHardwareUUIDViaIOReg(ctx context.Context, executor CommandExecutor, lo 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. @@ -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 @@ -149,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) @@ -157,7 +168,11 @@ func macOSSerialNumberViaIOReg(ctx context.Context, executor CommandExecutor, lo 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. @@ -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. @@ -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) @@ -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. @@ -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 @@ -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 diff --git a/doc.go b/doc.go index 2c46d59..56ed8ce 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. @@ -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 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..3e7fa3c 100644 --- a/linux.go +++ b/linux.go @@ -4,7 +4,6 @@ package machineid import ( "context" - "errors" "fmt" "log/slog" "os" @@ -18,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 { @@ -52,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 } @@ -142,7 +153,7 @@ func readFirstValidFromLocations(locations []string, validator func(string) bool } } - return "", errors.New("valid value not found in any location") + return "", ErrNotFound } // isValidUUID checks if UUID is valid (not empty or null) @@ -175,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 @@ -194,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 @@ -210,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" @@ -226,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 d0532e3..d64f534 100644 --- a/machineid.go +++ b/machineid.go @@ -4,8 +4,6 @@ import ( "context" "crypto/sha256" "encoding/hex" - "errors" - "fmt" "log/slog" "runtime" "sort" @@ -14,21 +12,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 @@ -208,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 { @@ -353,8 +336,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 +348,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 +375,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 +387,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..1341970 100644 --- a/windows.go +++ b/windows.go @@ -4,8 +4,6 @@ package machineid import ( "context" - "errors" - "fmt" "log/slog" "strings" ) @@ -64,7 +62,7 @@ func parseWmicValue(output, prefix string) (string, error) { } } - return "", fmt.Errorf("value with prefix %s not found", prefix) + return "", &ParseError{Source: "wmic output", Err: ErrNotFound} } // parseWmicMultipleValues extracts all values from wmic output with given prefix. @@ -90,7 +88,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 "", &ParseError{Source: "PowerShell output", Err: ErrEmptyValue} } return value, nil @@ -117,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) } } @@ -128,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: %w, powershell: %w", err, psErr) + if logger != nil { + logger.Warn("all CPU ID methods failed") + } + + return "", ErrAllMethodsFailed } return parsePowerShellValue(psOutput) @@ -140,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) } } @@ -151,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: %w, powershell: %w", err, psErr) + if logger != nil { + logger.Warn("all motherboard serial methods failed") + } + + return "", ErrAllMethodsFailed } value, parseErr := parsePowerShellValue(psOutput) @@ -160,7 +170,7 @@ func windowsMotherboardSerial(ctx context.Context, executor CommandExecutor, log } if value == biosFirmwareMessage { - return "", errors.New("motherboard serial is OEM placeholder") + return "", &ParseError{Source: "PowerShell output", Err: ErrOEMPlaceholder} } return value, nil @@ -173,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) } } @@ -189,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) @@ -202,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 @@ -212,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: %w, powershell: %w", err, psErr) + if logger != nil { + logger.Warn("all disk serial methods failed") + } + + return nil, ErrAllMethodsFailed } values := parsePowerShellMultipleValues(psOutput) if len(values) == 0 { - return nil, errors.New("no disk serials found via PowerShell") + return nil, &ParseError{Source: "PowerShell output", Err: ErrNotFound} } return values, nil