From f05f4e65bf1906b7d93e44e1e120e9b77c7df197 Mon Sep 17 00:00:00 2001 From: Juliano Martinez Date: Thu, 25 Jun 2026 12:37:07 +0200 Subject: [PATCH 1/2] raise meaningful coverage --- CHANGELOG.md | 20 + cmd/facts/main.go | 18 +- cmd/facts/main_test.go | 70 + engine_test.go | 43 + internal/app/helpers_test.go | 123 ++ internal/app/loghandler_test.go | 13 + internal/cli/options_test.go | 181 ++- internal/cli/validation_test.go | 29 + internal/engine/cache_test.go | 250 +++- internal/engine/config.go | 12 +- internal/engine/config_test.go | 242 ++++ internal/engine/core_test.go | 30 + internal/engine/disks_test.go | 794 ++++++++++- internal/engine/dmi.go | 50 +- internal/engine/dmi_test.go | 248 ++++ internal/engine/ec2.go | 9 +- internal/engine/ec2_test.go | 91 ++ internal/engine/engine_test.go | 387 ++++++ internal/engine/external_test.go | 313 +++++ internal/engine/fact_test.go | 24 + internal/engine/factsutil_test.go | 40 + internal/engine/filehelper_test.go | 12 + internal/engine/formatter.go | 18 +- internal/engine/formatter_test.go | 121 ++ internal/engine/gce_test.go | 100 +- internal/engine/identity.go | 2 +- internal/engine/identity_test.go | 61 + internal/engine/memory.go | 23 +- internal/engine/memory_test.go | 420 ++++++ internal/engine/networking.go | 327 ++++- internal/engine/networking_test.go | 1176 ++++++++++++++++- internal/engine/os.go | 26 +- internal/engine/os_test.go | 447 +++++++ internal/engine/plan9.go | 9 +- internal/engine/plan9_parser_test.go | 229 ++++ internal/engine/processors.go | 31 +- internal/engine/processors_test.go | 409 ++++++ internal/engine/session_test.go | 203 +++ internal/engine/snapshot.go | 175 ++- internal/engine/snapshot_test.go | 475 +++++++ internal/engine/statfs_math_test.go | 60 + internal/engine/timezone.go | 9 +- internal/engine/timezone_test.go | 192 +++ internal/engine/uptime.go | 4 +- internal/engine/uptime_test.go | 151 +++ internal/engine/virtual.go | 11 +- internal/engine/virtual_test.go | 463 +++++++ internal/engine/xen.go | 17 +- internal/engine/xen_test.go | 53 + internal/schema/schema_test.go | 157 +++ .../proposal.md | 19 + .../go-port-supported-platform-facts/spec.md | 41 + .../tasks.md | 15 + snapshot.go | 5 + tools/supportedfacts/main.go | 20 +- tools/supportedfacts/main_test.go | 100 ++ 56 files changed, 8355 insertions(+), 213 deletions(-) create mode 100644 internal/app/helpers_test.go create mode 100644 internal/engine/engine_test.go create mode 100644 internal/engine/fact_test.go create mode 100644 openspec/changes/fix-linux-dhcp-lease-interface-match/proposal.md create mode 100644 openspec/changes/fix-linux-dhcp-lease-interface-match/specs/go-port-supported-platform-facts/spec.md create mode 100644 openspec/changes/fix-linux-dhcp-lease-interface-match/tasks.md diff --git a/CHANGELOG.md b/CHANGELOG.md index c189af7c..1bef8fb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,26 @@ ### Fixed +- Linux DHCP lease discovery now matches explicit dhclient interface names + exactly, so a lease for a similarly named interface such as `eth0-backup` + is not attributed to `eth0`, and parses lease blocks without being confused + by braces inside comments or quoted strings. Malformed lease blocks, + unterminated quoted strings, and malformed interface values no longer stop + later valid blocks from being scanned or suppress lease filename fallback, + and file-level interface declarations with multiple unqualified historical + lease blocks now use the latest DHCP server identifier. Multiple exact + matching lease blocks now let the latest matching block control the server + value, preventing stale servers from older leases when the newest lease has + no `dhcp-server-identifier`, and commented DHCP server option text is no + longer treated as a real DHCP server. +- YAML output now renders map values inside sequences with flow-map braces, + preserving each key when a nested sequence item has more than one map key. +- Plan 9 `system_uptime.days`, `system_uptime.hours`, and + `system_uptime.seconds` now use the same 64-bit numeric value types as other + supported platforms. +- Snapshot lookups and defensive copies now clone mutable values, including + pointer targets, pointer-bearing map keys, and cyclic graph references, + instead of returning shared Snapshot state for the public fact graph. - QEMU/KVM lab guests on OpenBSD, NetBSD, DragonFly BSD, illumos, and Windows now report `virtual: "kvm"` and `is_virtual: true` when native DMI/SMBIOS, PCI, or WMI indicators expose the VM. diff --git a/cmd/facts/main.go b/cmd/facts/main.go index 42b2966f..e07d3511 100644 --- a/cmd/facts/main.go +++ b/cmd/facts/main.go @@ -2,6 +2,7 @@ package main import ( "fmt" + "io" "os" "github.com/ncode/facts/internal/app" @@ -9,15 +10,22 @@ import ( ) func main() { - if err := app.Run(os.Stdout, os.Stderr, os.Args[1:]); err != nil { + if code := runMain(os.Stdout, os.Stderr, os.Args[1:]); code != 0 { + os.Exit(code) + } +} + +func runMain(stdout, stderr io.Writer, args []string) int { + if err := app.Run(stdout, stderr, args); err != nil { if status, ok := err.(app.ExitStatus); ok { - os.Exit(status.Code()) + return status.Code() } if cli.IsOptionError(err) { - fmt.Fprintf(os.Stderr, "ERROR Facts::OptionsValidator - %v\n", err) + fmt.Fprintf(stderr, "ERROR Facts::OptionsValidator - %v\n", err) } else { - fmt.Fprintln(os.Stderr, err) + fmt.Fprintln(stderr, err) } - os.Exit(1) + return 1 } + return 0 } diff --git a/cmd/facts/main_test.go b/cmd/facts/main_test.go index 1fc35f17..d9584af6 100644 --- a/cmd/facts/main_test.go +++ b/cmd/facts/main_test.go @@ -45,6 +45,76 @@ func TestFactsCommand_version(t *testing.T) { } } +func TestMainFunctionVersion(t *testing.T) { + oldArgs, oldStdout, oldStderr := os.Args, os.Stdout, os.Stderr + stdoutR, stdoutW, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + stderrR, stderrW, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + os.Args, os.Stdout, os.Stderr = oldArgs, oldStdout, oldStderr + _ = stdoutR.Close() + _ = stderrR.Close() + }) + os.Args, os.Stdout, os.Stderr = []string{"facts", "--version"}, stdoutW, stderrW + + main() + _ = stdoutW.Close() + _ = stderrW.Close() + + stdout := new(bytes.Buffer) + stderr := new(bytes.Buffer) + if _, err := stdout.ReadFrom(stdoutR); err != nil { + t.Fatal(err) + } + if _, err := stderr.ReadFrom(stderrR); err != nil { + t.Fatal(err) + } + if got, want := stdout.String(), engine.Version+"\n"; got != want { + t.Fatalf("stdout = %q, want %q", got, want) + } + if stderr.Len() != 0 { + t.Fatalf("stderr = %q, want empty", stderr.String()) + } +} + +func TestRunMainReportsOptionErrors(t *testing.T) { + var stdout, stderr bytes.Buffer + + if code := runMain(&stdout, &stderr, []string{"-z"}); code != 1 { + t.Fatalf("runMain() code = %d, want 1", code) + } + if !strings.Contains(stdout.String(), "facts [options] [query]") { + t.Fatalf("stdout = %q, want usage text", stdout.String()) + } + if got, want := stderr.String(), "ERROR Facts::OptionsValidator - unrecognised option '-z'\n"; got != want { + t.Fatalf("stderr = %q, want %q", got, want) + } +} + +func TestRunMainReportsGenericErrors(t *testing.T) { + dir := t.TempDir() + externalDir := filepath.Join(dir, "not-a-dir") + if err := os.WriteFile(externalDir, []byte("site=lab\n"), 0o600); err != nil { + t.Fatal(err) + } + var stdout, stderr bytes.Buffer + + if code := runMain(&stdout, &stderr, []string{"--external-dir", externalDir, "--list-cache-groups"}); code != 1 { + t.Fatalf("runMain() code = %d, want 1", code) + } + if stdout.Len() != 0 { + t.Fatalf("stdout = %q, want empty", stdout.String()) + } + if got := stderr.String(); got == "" || strings.Contains(got, "Facts::OptionsValidator") { + t.Fatalf("stderr = %q, want generic config error", got) + } +} + func TestFactsCommand_noQueryPrintsStructuredFacts(t *testing.T) { bin := buildFactsCommand(t) diff --git a/engine_test.go b/engine_test.go index 406ec8a9..545086e1 100644 --- a/engine_test.go +++ b/engine_test.go @@ -12,6 +12,7 @@ import ( "log/slog" "os" "path/filepath" + "reflect" "runtime" "strings" "sync" @@ -62,6 +63,15 @@ func TestNew_defaultEngineIsHermetic(t *testing.T) { } } +func TestNewIgnoresNilOptionsAndReturnsOptionErrors(t *testing.T) { + if eng, err := New(nil); err != nil || eng == nil { + t.Fatalf("New(nil) = %#v, %v; want engine, nil", eng, err) + } + if eng, err := New(WithConfigFile("")); err == nil || eng != nil { + t.Fatalf("New(WithConfigFile(empty)) = %#v, %v; want nil engine and error", eng, err) + } +} + func TestEngineDiscover_uninitializedReceiverReturnsError(t *testing.T) { var nilEngine *Engine if snap, err := nilEngine.Discover(context.Background()); err == nil || snap != nil { @@ -625,6 +635,39 @@ func TestAs_rejectsMapAnyKeyStringCollisions(t *testing.T) { } } +func TestAs_normalizesNestedMapAnyValues(t *testing.T) { + eng, err := New(WithFact("nested", func(context.Context) (any, error) { + return map[any]any{ + "items": []any{ + map[any]any{"name": "web", "port": 443}, + }, + }, nil + })) + if err != nil { + t.Fatal(err) + } + snap, err := eng.Discover(context.Background()) + if err != nil { + t.Fatal(err) + } + + type item struct { + Name string `json:"name"` + Port int `json:"port"` + } + type nestedFact struct { + Items []item `json:"items"` + } + got, err := As[nestedFact](snap, "nested") + if err != nil { + t.Fatal(err) + } + want := nestedFact{Items: []item{{Name: "web", Port: 443}}} + if !reflect.DeepEqual(got, want) { + t.Fatalf("As[nestedFact](nested) = %#v, want %#v", got, want) + } +} + func TestAs_missingFactReturnsErrFactNotFound(t *testing.T) { snap := hermeticSnapshot() diff --git a/internal/app/helpers_test.go b/internal/app/helpers_test.go new file mode 100644 index 00000000..355dc22e --- /dev/null +++ b/internal/app/helpers_test.go @@ -0,0 +1,123 @@ +package app + +import ( + "bytes" + "errors" + "os" + "strings" + "testing" +) + +func TestExitStatusReportsCodeAndError(t *testing.T) { + status := ExitStatus(42) + + if got := status.Code(); got != 42 { + t.Fatalf("Code() = %d, want 42", got) + } + if got := status.Error(); got != "exit status 42" { + t.Fatalf("Error() = %q, want exit status 42", got) + } +} + +func TestOptionErrorWritesHelpAndReturnsOriginalError(t *testing.T) { + var stdout bytes.Buffer + err := errors.New("bad option") + + got := optionError(&stdout, err) + if got != err { + t.Fatalf("optionError() = %v, want original error", got) + } + if got, want := stdout.String(), helpText(); got != want { + t.Fatalf("stdout = %q, want help text %q", got, want) + } + for _, marker := range []string{"Usage", "--config", "--help"} { + if !strings.Contains(stdout.String(), marker) { + t.Fatalf("stdout missing help marker %q: %q", marker, stdout.String()) + } + } +} + +func TestOptionErrorReturnsHelpWriteError(t *testing.T) { + writeErr := errors.New("stdout closed") + originalErr := errors.New("bad option") + + got := optionError(errorWriter{err: writeErr}, originalErr) + if !errors.Is(got, writeErr) { + t.Fatalf("optionError() = %v, want help write error %v", got, writeErr) + } +} + +func TestResolveColorHonorsFlagsAndWriter(t *testing.T) { + if !resolveColor(true, false, &bytes.Buffer{}) { + t.Fatal("resolveColor(force=true) = false, want true") + } + if resolveColor(true, true, &bytes.Buffer{}) { + t.Fatal("resolveColor(disable=true) = true, want false") + } + if resolveColor(false, false, &bytes.Buffer{}) { + t.Fatal("resolveColor(non-file writer) = true, want false") + } + + file, err := os.CreateTemp(t.TempDir(), "stdout") + if err != nil { + t.Fatal(err) + } + defer file.Close() + if resolveColor(false, false, file) { + t.Fatal("resolveColor(regular file) = true, want false") + } +} + +func TestResolvedLogOptionsConflict(t *testing.T) { + tests := []struct { + name string + debug bool + verbose bool + logLevel string + want bool + }{ + {name: "debug and verbose conflict", debug: true, verbose: true, want: true}, + {name: "no log level has no conflict", debug: true}, + {name: "placeholder log_level has no conflict", debug: true, logLevel: "log_level"}, + {name: "debug with debug log level is redundant", debug: true, logLevel: "debug"}, + {name: "debug with trace log level is redundant", debug: true, logLevel: "trace"}, + {name: "verbose with info log level is redundant", verbose: true, logLevel: "info"}, + {name: "debug with info log level conflicts", debug: true, logLevel: "info", want: true}, + {name: "verbose with debug log level conflicts", verbose: true, logLevel: "debug", want: true}, + {name: "bare log level has no conflict", logLevel: "debug"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := resolvedLogOptionsConflict(tt.debug, tt.verbose, tt.logLevel) + if got != tt.want { + t.Fatalf("resolvedLogOptionsConflict(%v, %v, %q) = %v, want %v", tt.debug, tt.verbose, tt.logLevel, got, tt.want) + } + }) + } +} + +func TestWriteErrorColorContract(t *testing.T) { + var stderr bytes.Buffer + + writeError(&stderr, "boom", true) + + if got, want := stderr.String(), "\x1b[31mERROR Facts - boom\x1b[0m\n"; got != want { + t.Fatalf("stderr = %q, want %q", got, want) + } + + stderr.Reset() + writeError(&stderr, "boom", false) + + if got, want := stderr.String(), "ERROR Facts - boom\n"; got != want { + t.Fatalf("stderr = %q, want %q", got, want) + } +} + +type errorWriter struct { + err error +} + +func (w errorWriter) Write([]byte) (int, error) { + return 0, w.err +} diff --git a/internal/app/loghandler_test.go b/internal/app/loghandler_test.go index b8d05ade..72e7cd43 100644 --- a/internal/app/loghandler_test.go +++ b/internal/app/loghandler_test.go @@ -53,3 +53,16 @@ func TestStderrLogHandlerConcurrentHandle(t *testing.T) { } wg.Wait() } + +func TestStderrLogHandlerAttrsAndGroupsDoNotRender(t *testing.T) { + var stderr bytes.Buffer + handler := &stderrLogHandler{stderr: &stderr, verbose: true} + + grouped := handler.WithAttrs([]slog.Attr{slog.String("ignored", "value")}). + WithGroup("group") + + slog.New(grouped).Info("hello", "also", "ignored") + if got, want := stderr.String(), "INFO Facts - hello\n"; got != want { + t.Fatalf("stderr = %q, want %q", got, want) + } +} diff --git a/internal/cli/options_test.go b/internal/cli/options_test.go index 879eda3f..7e74a334 100644 --- a/internal/cli/options_test.go +++ b/internal/cli/options_test.go @@ -1,6 +1,170 @@ package cli -import "testing" +import ( + "slices" + "testing" +) + +func TestOptionsReturnDefensiveCopies(t *testing.T) { + options := Options() + if len(options) == 0 { + t.Fatal("Options() returned no options") + } + originals := make([]Option, len(options)) + for i := range options { + originals[i] = options[i] + originals[i].Aliases = append([]string(nil), options[i].Aliases...) + originals[i].Conflicts = append([]string(nil), options[i].Conflicts...) + } + t.Cleanup(func() { + current := Options() + for i := range originals { + if i >= len(current) { + continue + } + for j, alias := range originals[i].Aliases { + if j < len(current[i].Aliases) { + current[i].Aliases[j] = alias + } + } + for j, conflict := range originals[i].Conflicts { + if j < len(current[i].Conflicts) { + current[i].Conflicts[j] = conflict + } + } + options[i] = originals[i] + options[i].Aliases = append([]string(nil), originals[i].Aliases...) + options[i].Conflicts = append([]string(nil), originals[i].Conflicts...) + } + }) + for i := range options { + options[i].Canonical = "--mutated-canonical" + if len(options[i].Aliases) > 0 { + options[i].Aliases[0] = "--mutated-alias" + } + if len(options[i].Conflicts) > 0 { + options[i].Conflicts[0] = "--mutated-conflict" + } + } + + fresh := Options() + if len(fresh) != len(originals) { + t.Fatalf("len(Options()) = %d, want %d", len(fresh), len(originals)) + } + for i := range fresh { + if fresh[i].Canonical != originals[i].Canonical { + t.Fatalf("Options()[%d].Canonical = %q, want %q", i, fresh[i].Canonical, originals[i].Canonical) + } + if !slices.Equal(fresh[i].Aliases, originals[i].Aliases) { + t.Fatalf("Options()[%d].Aliases = %#v, want %#v", i, fresh[i].Aliases, originals[i].Aliases) + } + if !slices.Equal(fresh[i].Conflicts, originals[i].Conflicts) { + t.Fatalf("Options()[%d].Conflicts = %#v, want %#v", i, fresh[i].Conflicts, originals[i].Conflicts) + } + } +} + +func TestDocumentedOptionsReturnDefensiveCopies(t *testing.T) { + options := DocumentedOptions() + if len(options) == 0 { + t.Fatal("DocumentedOptions() returned no options") + } + originals := make([]Option, len(options)) + for i := range options { + originals[i] = options[i] + originals[i].Aliases = slices.Clone(options[i].Aliases) + originals[i].Conflicts = slices.Clone(options[i].Conflicts) + } + t.Cleanup(func() { + current := DocumentedOptions() + for i := range originals { + if i >= len(current) { + continue + } + for j, alias := range originals[i].Aliases { + if j < len(current[i].Aliases) { + current[i].Aliases[j] = alias + } + } + for j, conflict := range originals[i].Conflicts { + if j < len(current[i].Conflicts) { + current[i].Conflicts[j] = conflict + } + } + options[i] = originals[i] + options[i].Aliases = slices.Clone(originals[i].Aliases) + options[i].Conflicts = slices.Clone(originals[i].Conflicts) + } + }) + for i := range options { + options[i].Canonical = "--mutated-canonical" + if len(options[i].Aliases) > 0 { + options[i].Aliases[0] = "--mutated-alias" + } + if len(options[i].Conflicts) > 0 { + options[i].Conflicts[0] = "--mutated-conflict" + } + } + + fresh := DocumentedOptions() + if len(fresh) != len(originals) { + t.Fatalf("len(DocumentedOptions()) = %d, want %d", len(fresh), len(originals)) + } + for i := range fresh { + if fresh[i].Canonical != originals[i].Canonical { + t.Fatalf("DocumentedOptions()[%d].Canonical = %q, want %q", i, fresh[i].Canonical, originals[i].Canonical) + } + if !slices.Equal(fresh[i].Aliases, originals[i].Aliases) { + t.Fatalf("DocumentedOptions()[%d].Aliases = %#v, want %#v", i, fresh[i].Aliases, originals[i].Aliases) + } + if !slices.Equal(fresh[i].Conflicts, originals[i].Conflicts) { + t.Fatalf("DocumentedOptions()[%d].Conflicts = %#v, want %#v", i, fresh[i].Conflicts, originals[i].Conflicts) + } + } +} + +func TestOptionsExposeVisibleAndFixedHiddenContract(t *testing.T) { + expectedHidden := map[string]bool{ + "--no-hocon": true, + "--no-json": true, + "--no-yaml": true, + } + seenHidden := map[string]bool{} + + for _, option := range DocumentedOptions() { + if option.Hidden { + t.Fatalf("DocumentedOptions() included hidden option %#v", option) + } + } + documented := map[string]bool{} + for _, option := range DocumentedOptions() { + documented[option.Canonical] = true + } + + for _, option := range Options() { + if option.Hidden { + if !expectedHidden[option.Canonical] { + t.Fatalf("Options() marked unexpected option %q as hidden", option.Canonical) + } + seenHidden[option.Canonical] = true + if !KnownOption(option.Canonical) { + t.Fatalf("KnownOption(%q) = false, want hidden option still accepted", option.Canonical) + } + if documented[option.Canonical] { + t.Fatalf("DocumentedOptions() included hidden option %q", option.Canonical) + } + continue + } + if !documented[option.Canonical] { + t.Fatalf("DocumentedOptions() omitted visible option %q", option.Canonical) + } + } + for canonical := range expectedHidden { + if !seenHidden[canonical] { + t.Fatalf("Options() did not mark expected hidden option %q as hidden", canonical) + } + } +} func TestOptions_describeAcceptedOptionMetadata(t *testing.T) { tests := []struct { @@ -124,6 +288,21 @@ func TestLookupOptionRejectsInlineValueForNoValueOption(t *testing.T) { } } +func TestOptionLookupHelpersHandleUnknownAndRawNames(t *testing.T) { + if got := CanonicalOption("--missing"); got != "--missing" { + t.Fatalf("CanonicalOption(--missing) = %q, want original", got) + } + if KnownOption("--missing") { + t.Fatal("KnownOption(--missing) = true, want false") + } + if got := rawOptionName("--config=/tmp/facts.conf"); got != "--config" { + t.Fatalf("rawOptionName() = %q, want --config", got) + } + if got := rawOptionName("--json"); got != "--json" { + t.Fatalf("rawOptionName() = %q, want --json", got) + } +} + func hasOption(options []string, want string) bool { for _, option := range options { if option == want { diff --git a/internal/cli/validation_test.go b/internal/cli/validation_test.go index b6aaa0fd..142a6f84 100644 --- a/internal/cli/validation_test.go +++ b/internal/cli/validation_test.go @@ -2,9 +2,38 @@ package cli import ( "errors" + "fmt" "testing" ) +func TestOptionErrorAccessors(t *testing.T) { + var nilErr *OptionError + if got := nilErr.Error(); got != "" { + t.Fatalf("nil OptionError Error() = %q, want empty", got) + } + if got := nilErr.Unwrap(); got != nil { + t.Fatalf("nil OptionError Unwrap() = %v, want nil", got) + } + + cause := errors.New("bad option") + err := &OptionError{Err: cause} + if got := err.Error(); got != "bad option" { + t.Fatalf("OptionError.Error() = %q, want bad option", got) + } + if got := err.Unwrap(); got != cause { + t.Fatalf("OptionError.Unwrap() = %v, want cause", got) + } + if !IsOptionError(err) { + t.Fatal("IsOptionError(*OptionError) = false, want true") + } + if !IsOptionError(fmt.Errorf("wrapped: %w", err)) { + t.Fatal("IsOptionError(wrapped *OptionError) = false, want true") + } + if IsOptionError(cause) { + t.Fatal("IsOptionError(non-option error) = true, want false") + } +} + func TestValidateOptions_rejectsInvalidPairs(t *testing.T) { tests := []struct { name string diff --git a/internal/engine/cache_test.go b/internal/engine/cache_test.go index 84c0a5ef..6622fce7 100644 --- a/internal/engine/cache_test.go +++ b/internal/engine/cache_test.go @@ -33,6 +33,48 @@ func TestFactCache_resolvesFreshCachedFactAndSkipsSearch(t *testing.T) { } } +func TestFactCache_disabledCachesAreNoops(t *testing.T) { + searched := []ResolvedFact{{Name: "os", Type: "core"}} + + for _, cache := range []*FactCache{nil, {}} { + remaining, cached := cache.ResolveFacts(searched) + if !reflect.DeepEqual(remaining, searched) { + t.Fatalf("ResolveFacts() remaining = %#v, want %#v", remaining, searched) + } + if cached != nil { + t.Fatalf("ResolveFacts() cached = %#v, want nil", cached) + } + if err := cache.CacheFacts([]ResolvedFact{{Name: "os", Value: "Ubuntu", Type: "core"}}); err != nil { + t.Fatalf("CacheFacts() err = %v, want nil", err) + } + } +} + +func TestNewFactCacheDefaultsLoggerAndIgnoresInvalidTTLs(t *testing.T) { + cache := NewFactCache(t.TempDir(), []FactTTL{ + {Fact: "os", TTL: "not a ttl"}, + {Fact: "networking", TTL: "1 hour"}, + }, nil, nil) + + if cache.logger() == nil { + t.Fatal("logger() = nil, want discard logger") + } + if _, ok := cache.ttls["os"]; ok { + t.Fatalf("ttls = %#v, want invalid os TTL ignored", cache.ttls) + } + if got, ok := cache.ttls["networking"]; !ok || got != time.Hour { + t.Fatalf("ttls[networking] = %v, %v, want 1h true", got, ok) + } +} + +func TestFactCacheNilLoggerFallsBackToDiscardLogger(t *testing.T) { + var cache *FactCache + + if cache.logger() == nil { + t.Fatal("nil FactCache logger() = nil, want discard logger") + } +} + func TestPlatformDefaultCachePathForSupportedPlatforms(t *testing.T) { tests := []struct { name string @@ -85,6 +127,35 @@ func TestPlatformDefaultCachePathForSupportedPlatforms(t *testing.T) { } } +func TestPlatformDefaultCachePathReadsCurrentWindowsEnvironment(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("ProgramData and APPDATA are only used by the Windows cache path") + } + + t.Setenv("ProgramData", `C:\ProgramData`) + t.Setenv("APPDATA", `C:\Users\Alice\AppData\Roaming`) + + got := platformDefaultCachePath() + want := `C:\ProgramData/PuppetLabs/facts/cache/cached_facts` + if got != want { + t.Fatalf("platformDefaultCachePath() = %q, want %q", got, want) + } +} + +func TestPlatformDefaultCachePathUsesDefaultPathOutsideWindows(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("non-Windows default path assertion") + } + t.Setenv("ProgramData", `C:\ProgramData`) + t.Setenv("APPDATA", `C:\Users\Alice\AppData\Roaming`) + + got := platformDefaultCachePath() + want := "/opt/puppetlabs/facts/cache/cached_facts" + if got != want { + t.Fatalf("platformDefaultCachePath() = %q, want %q", got, want) + } +} + func TestFactCache_resolvesExternalFactFromFileBasenameCacheGroup(t *testing.T) { dir := t.TempDir() cachePath := filepath.Join(dir, "ext_file.txt") @@ -217,6 +288,19 @@ func TestFactCache_cacheFactsWritesConfiguredGroups(t *testing.T) { } } +func TestFactCache_cacheFactsReturnsNonPermissionMkdirError(t *testing.T) { + dir := filepath.Join(t.TempDir(), "cache-file") + if err := os.WriteFile(dir, []byte("not a directory"), 0o600); err != nil { + t.Fatal(err) + } + cache := NewFactCache(dir, []FactTTL{{Fact: "operating system", TTL: "1 hour"}}, nil, discardLog()) + + err := cache.CacheFacts([]ResolvedFact{{Name: "os", Value: "Ubuntu", Type: "core"}}) + if err == nil { + t.Fatal("CacheFacts() err = nil, want mkdir error for file cache path") + } +} + func TestFactCache_cacheFactsWritesExternalFileBasenameGroup(t *testing.T) { dir := t.TempDir() cache := NewFactCache(dir, []FactTTL{{Fact: "ext_file.txt", TTL: "1 hour"}}, nil, discardLog()) @@ -236,6 +320,59 @@ func TestFactCache_cacheFactsWritesExternalFileBasenameGroup(t *testing.T) { } } +func TestFactCache_cacheFactsSkipsFreshCacheThatAlreadyContainsFacts(t *testing.T) { + dir := t.TempDir() + cachePath := filepath.Join(dir, "operating system") + writeJSONFile(t, cachePath, map[string]any{ + "cache_format_version": float64(1), + "os": "Ubuntu", + }) + before, err := os.Stat(cachePath) + if err != nil { + t.Fatal(err) + } + + cache := NewFactCache(dir, []FactTTL{{Fact: "operating system", TTL: "1 hour"}}, nil, discardLog()) + if err := cache.CacheFacts([]ResolvedFact{{Name: "os", Value: "Fedora", Type: "core"}}); err != nil { + t.Fatal(err) + } + + data := readJSONFile(t, cachePath) + if data["os"] != "Ubuntu" { + t.Fatalf("cached os = %#v, want fresh cache left untouched", data["os"]) + } + after, err := os.Stat(cachePath) + if err != nil { + t.Fatal(err) + } + if !after.ModTime().Equal(before.ModTime()) { + t.Fatalf("cache mtime changed from %s to %s, want unchanged", before.ModTime(), after.ModTime()) + } +} + +func TestFactCache_cacheFactsRewritesStaleCache(t *testing.T) { + dir := t.TempDir() + cachePath := filepath.Join(dir, "operating system") + writeJSONFile(t, cachePath, map[string]any{ + "cache_format_version": float64(1), + "os": "Ubuntu", + }) + old := time.Now().Add(-2 * time.Hour) + if err := os.Chtimes(cachePath, old, old); err != nil { + t.Fatal(err) + } + + cache := NewFactCache(dir, []FactTTL{{Fact: "operating system", TTL: "1 hour"}}, nil, discardLog()) + if err := cache.CacheFacts([]ResolvedFact{{Name: "os", Value: "Fedora", Type: "core"}}); err != nil { + t.Fatal(err) + } + + data := readJSONFile(t, cachePath) + if data["os"] != "Fedora" { + t.Fatalf("cached os = %#v, want stale cache rewritten", data["os"]) + } +} + func TestWriteCacheFileWritesFinalFileAndRemovesTemp(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "facts.cache") @@ -266,6 +403,33 @@ func TestWriteCacheFileWritesFinalFileAndRemovesTemp(t *testing.T) { } } +func TestWriteCacheFileReturnsErrorWhenParentIsMissing(t *testing.T) { + path := filepath.Join(t.TempDir(), "missing", "facts.cache") + + if err := writeCacheFile(path, []byte("cached"), 0o600); err == nil { + t.Fatal("writeCacheFile() err = nil, want missing parent directory error") + } +} + +func TestWriteCacheFileCleansTempFileWhenRenameFails(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "facts.cache") + if err := os.Mkdir(path, 0o755); err != nil { + t.Fatal(err) + } + + if err := writeCacheFile(path, []byte("cached"), 0o600); err == nil { + t.Fatal("writeCacheFile() err = nil, want rename error for directory target") + } + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + if len(entries) != 1 || entries[0].Name() != "facts.cache" { + t.Fatalf("cache dir entries = %#v, want only original directory target", entries) + } +} + func TestFactCache_ignoresUnsafeCacheGroupNames(t *testing.T) { dir := t.TempDir() outside := filepath.Join(dir, "..", "outside-cache") @@ -296,6 +460,18 @@ func TestSafeCacheGroupNameRejectsWindowsSpecialNames(t *testing.T) { } } +func TestWarnCacheWriteFailureIgnoresNonPermissionErrors(t *testing.T) { + warnings := []string{} + logger := captureLogger(nil, &warnings, nil) + + if warnCacheWriteFailure(os.ErrNotExist, logger) { + t.Fatal("warnCacheWriteFailure(os.ErrNotExist) = true, want false") + } + if len(warnings) != 0 { + t.Fatalf("warnings = %#v, want none", warnings) + } +} + func TestFactCache_cacheFactsWarnsWhenCacheFileCannotBeWrittenLikeRubyCacheManager(t *testing.T) { dir := t.TempDir() originalWriteFile := cacheWriteFile @@ -385,6 +561,41 @@ func TestFactCache_cacheFactsLogsNoKeysForNonObjectFreshCacheLikeRubyCacheManage } } +func TestFactCache_cacheFactsLogsNoKeysForNilFreshCacheLikeRubyCacheManager(t *testing.T) { + dir := t.TempDir() + cachePath := filepath.Join(dir, "operating system") + if err := os.WriteFile(cachePath, []byte("null"), 0o600); err != nil { + t.Fatal(err) + } + debugMessages := []string{} + logger := captureLogger(&debugMessages, nil, nil) + cache := NewFactCache(dir, []FactTTL{{Fact: "operating system", TTL: "1 hour"}}, nil, logger) + + if err := cache.CacheFacts([]ResolvedFact{{Name: "os", Value: "Ubuntu", Type: "core"}}); err != nil { + t.Fatal(err) + } + + data := readJSONFile(t, cachePath) + if data["os"] != "Ubuntu" { + t.Fatalf("cached os = %#v, want Ubuntu", data["os"]) + } + if data["cache_format_version"] != float64(1) { + t.Fatalf("cache_format_version = %#v, want 1", data["cache_format_version"]) + } + wantDebugPrefix := "No keys found in " + cachePath + ". Detail:" + wantDebugDetail := "cached data is nil" + foundDebug := false + for _, message := range debugMessages { + if strings.Contains(message, wantDebugPrefix) && strings.Contains(message, wantDebugDetail) { + foundDebug = true + break + } + } + if !foundDebug { + t.Fatalf("debug messages = %#v, want one containing %q and %q", debugMessages, wantDebugPrefix, wantDebugDetail) + } +} + func TestFactCache_resolveFactsWarnsWhenCorruptCacheCannotBeDeletedLikeRubyCacheManager(t *testing.T) { dir := t.TempDir() cachePath := filepath.Join(dir, "ext_file.txt") @@ -418,12 +629,49 @@ func TestFactCache_resolveFactsWarnsWhenCorruptCacheCannotBeDeletedLikeRubyCache } } +func TestFactCache_readCacheRejectsWrongFormatVersion(t *testing.T) { + dir := t.TempDir() + cachePath := filepath.Join(dir, "operating system") + writeJSONFile(t, cachePath, map[string]any{ + "cache_format_version": float64(2), + "os": "Ubuntu", + }) + cache := NewFactCache(dir, []FactTTL{{Fact: "operating system", TTL: "1 hour"}}, nil, discardLog()) + + got, ok := cache.readCache("operating system") + if ok || got != nil { + t.Fatalf("readCache() = %#v, %v; want nil false", got, ok) + } + if _, err := os.Stat(cachePath); !os.IsNotExist(err) { + t.Fatalf("cache file stat err = %v, want removed", err) + } +} + +func TestCacheDataHasKeyMatchingFactFallsBackToContainsForInvalidPattern(t *testing.T) { + data := map[string]any{"site[role": "web"} + + if !cacheDataHasKeyMatchingFact(data, "[") { + t.Fatal("cacheDataHasKeyMatchingFact() = false, want invalid regexp to match by contains") + } + if cacheDataHasKeyMatchingFact(data, "missing[") { + t.Fatal("cacheDataHasKeyMatchingFact() = true, want false for missing literal") + } + regexpData := map[string]any{"site-role": "web"} + if !cacheDataHasKeyMatchingFact(regexpData, `site.*role`) { + t.Fatal("cacheDataHasKeyMatchingFact() = false, want valid regexp to match") + } +} + func TestParseTTLDuration_matchesRubyUnits(t *testing.T) { tests := []struct { input string want time.Duration }{ {input: "10000", want: 10 * time.Second}, + {input: "2000000 us", want: 2 * time.Second}, + {input: "1500 milliseconds", want: time.Second}, + {input: "2 seconds", want: 2 * time.Second}, + {input: "3 minutes", want: 3 * time.Minute}, {input: "30 h", want: 30 * time.Hour}, {input: "1 hour", want: time.Hour}, {input: "1 day", want: 24 * time.Hour}, @@ -444,7 +692,7 @@ func TestParseTTLDuration_matchesRubyUnits(t *testing.T) { func TestParseTTLDuration_rejectsNegativeAndOverflowingValues(t *testing.T) { tooManyHours := strconv.FormatInt(int64((time.Duration(1<<63-1)/time.Hour)+1), 10) + " h" - for _, input := range []string{"-1 seconds", tooManyHours} { + for _, input := range []string{"", "1 2 3", "abc seconds", "-1 seconds", "1 fortnight", tooManyHours} { t.Run(input, func(t *testing.T) { if got, ok := parseTTLDuration(input); ok { t.Fatalf("parseTTLDuration(%q) = %v, true; want false", input, got) diff --git a/internal/engine/config.go b/internal/engine/config.go index f0c69d64..cd804f60 100644 --- a/internal/engine/config.go +++ b/internal/engine/config.go @@ -80,11 +80,15 @@ func DefaultExternalFactDirs(windows, root bool, home, windowsDataDir string) [] // directories (facts-native first, facter-compatible after) for the current // process environment. func CurrentDefaultExternalFactDirs() []string { + return currentDefaultExternalFactDirs(runtime.GOOS, os.Geteuid(), os.Getenv) +} + +func currentDefaultExternalFactDirs(goos string, euid int, getenv func(string) string) []string { return DefaultExternalFactDirs( - runtime.GOOS == "windows", - runtime.GOOS != "windows" && os.Geteuid() == 0, - os.Getenv("HOME"), - os.Getenv("ProgramData"), + goos == "windows", + goos != "windows" && euid == 0, + getenv("HOME"), + getenv("ProgramData"), ) } diff --git a/internal/engine/config_test.go b/internal/engine/config_test.go index ebbdd053..ba24a676 100644 --- a/internal/engine/config_test.go +++ b/internal/engine/config_test.go @@ -4,6 +4,7 @@ import ( "os" "path/filepath" "reflect" + "runtime" "strings" "testing" ) @@ -56,6 +57,82 @@ fact-groups : { } } +func TestCurrentDefaultExternalFactDirsUsesCurrentEnvironment(t *testing.T) { + home := filepath.Join(t.TempDir(), "home") + programData := filepath.Join(t.TempDir(), "ProgramData") + + got := currentDefaultExternalFactDirs("linux", 501, testConfigEnv(map[string]string{ + "HOME": home, + "ProgramData": programData, + })) + want := []string{ + home + "/.facts/facts.d", + home + "/.facter/facts.d", + home + "/.puppetlabs/opt/facter/facts.d", + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("currentDefaultExternalFactDirs() = %#v, want %#v", got, want) + } +} + +func TestCurrentDefaultExternalFactDirsUsesRootDefaultsForRoot(t *testing.T) { + got := currentDefaultExternalFactDirs("linux", 0, testConfigEnv(map[string]string{ + "HOME": filepath.Join(t.TempDir(), "home"), + })) + want := []string{ + "/etc/facts/facts.d", + "/etc/puppetlabs/facter/facts.d", + "/etc/facter/facts.d/", + "/opt/puppetlabs/facter/facts.d", + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("currentDefaultExternalFactDirs(root) = %#v, want %#v", got, want) + } +} + +func TestCurrentDefaultExternalFactDirsUsesProgramDataOnWindows(t *testing.T) { + programData := filepath.Join(t.TempDir(), "ProgramData") + + got := currentDefaultExternalFactDirs("windows", -1, testConfigEnv(map[string]string{ + "HOME": filepath.Join(t.TempDir(), "home"), + "ProgramData": programData, + })) + want := []string{ + programData + "/facts/facts.d", + programData + "/PuppetLabs/facter/facts.d", + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("currentDefaultExternalFactDirs(windows) = %#v, want %#v", got, want) + } +} + +func TestCurrentDefaultExternalFactDirsMatchesRuntimeInputs(t *testing.T) { + home := filepath.Join(t.TempDir(), "home") + programData := filepath.Join(t.TempDir(), "ProgramData") + t.Setenv("HOME", home) + t.Setenv("ProgramData", programData) + + got := CurrentDefaultExternalFactDirs() + var want []string + switch { + case runtime.GOOS == "windows": + want = []string{programData + "/facts/facts.d", programData + "/PuppetLabs/facter/facts.d"} + case os.Geteuid() == 0: + want = []string{"/etc/facts/facts.d", "/etc/puppetlabs/facter/facts.d", "/etc/facter/facts.d/", "/opt/puppetlabs/facter/facts.d"} + default: + want = []string{home + "/.facts/facts.d", home + "/.facter/facts.d", home + "/.puppetlabs/opt/facter/facts.d"} + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("CurrentDefaultExternalFactDirs() = %#v, want %#v", got, want) + } +} + +func testConfigEnv(values map[string]string) func(string) string { + return func(key string) string { + return values[key] + } +} + func TestParseConfig_ignoresSectionNamesInsideStringsAndComments(t *testing.T) { path := filepath.Join(t.TempDir(), "facter.conf") content := `global : { @@ -713,6 +790,50 @@ func TestGroupTTLSeconds_convertsRubyCompatibleUnits(t *testing.T) { } } +func TestTTLUnitScaleSupportsRubyCompatibleAliases(t *testing.T) { + tests := []struct { + unit string + multiplier int64 + divisor int64 + }{ + {unit: "ns", multiplier: 1, divisor: 1_000_000_000}, + {unit: "nanosecond", multiplier: 1, divisor: 1_000_000_000}, + {unit: "nanoseconds", multiplier: 1, divisor: 1_000_000_000}, + {unit: "us", multiplier: 1, divisor: 1_000_000}, + {unit: "microsecond", multiplier: 1, divisor: 1_000_000}, + {unit: "microseconds", multiplier: 1, divisor: 1_000_000}, + {unit: "ms", multiplier: 1, divisor: 1_000}, + {unit: "millisecond", multiplier: 1, divisor: 1_000}, + {unit: "milliseconds", multiplier: 1, divisor: 1_000}, + {unit: "s", multiplier: 1, divisor: 1}, + {unit: "second", multiplier: 1, divisor: 1}, + {unit: "seconds", multiplier: 1, divisor: 1}, + {unit: "m", multiplier: 60, divisor: 1}, + {unit: "minute", multiplier: 60, divisor: 1}, + {unit: "h", multiplier: 3600, divisor: 1}, + {unit: "hours", multiplier: 3600, divisor: 1}, + {unit: "d", multiplier: 86400, divisor: 1}, + {unit: "days", multiplier: 86400, divisor: 1}, + } + for _, tt := range tests { + t.Run(tt.unit, func(t *testing.T) { + multiplier, divisor, ok := ttlUnitScale(tt.unit) + if !ok || multiplier != tt.multiplier || divisor != tt.divisor { + t.Fatalf("ttlUnitScale(%q) = %d, %d, %v; want %d, %d, true", tt.unit, multiplier, divisor, ok, tt.multiplier, tt.divisor) + } + }) + } + if _, _, ok := ttlUnitScale("fortnight"); ok { + t.Fatal("ttlUnitScale(fortnight) ok = true, want false") + } + if got := rubyTTLLogUnit("fortnight"); got != "fortnights" { + t.Fatalf("rubyTTLLogUnit(fortnight) = %q, want fortnights", got) + } + if got := rubyTTLLogUnit("ms"); got != "ms" { + t.Fatalf("rubyTTLLogUnit(ms) = %q, want ms", got) + } +} + func TestFactGroupName_returnsGroupContainingFact(t *testing.T) { groups := []FactGroup{{Name: "operating system", Facts: []string{"os", "os.name"}}} @@ -729,6 +850,30 @@ func TestFactGroupName_returnsGroupContainingFact(t *testing.T) { } } +func TestMergeFactGroupsReplacesDefaultsAndAppendsNewGroups(t *testing.T) { + defaults := []FactGroup{ + {Name: "operating system", Facts: []string{"os"}}, + {Name: "memory", Facts: []string{"memory"}}, + } + configured := []FactGroup{ + {Name: "operating system", Facts: []string{"kernel"}}, + {Name: "site", Facts: []string{"site_role"}}, + } + + got := MergeFactGroups(defaults, configured) + want := []FactGroup{ + {Name: "operating system", Facts: []string{"kernel"}}, + {Name: "memory", Facts: []string{"memory"}}, + {Name: "site", Facts: []string{"site_role"}}, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("MergeFactGroups() = %#v, want %#v", got, want) + } + if got := MergeFactGroups(defaults, nil); !reflect.DeepEqual(got, defaults) { + t.Fatalf("MergeFactGroups(defaults, nil) = %#v, want %#v", got, defaults) + } +} + func TestFactGroupName_returnsGroupContainingDescendantFact(t *testing.T) { groups := []FactGroup{{Name: "operating system", Facts: []string{"os", "os.name"}}} @@ -741,6 +886,19 @@ func TestFactGroupName_returnsGroupContainingDescendantFact(t *testing.T) { } } +func TestFormatFactGroupsRendersRubyCompatibleRows(t *testing.T) { + groups := []FactGroup{ + {Name: "hardware", Facts: []string{"dmi", "processors"}}, + {Name: "path"}, + } + + got := FormatFactGroups(groups) + want := "hardware\n- dmi\n- processors\npath" + if got != want { + t.Fatalf("FormatFactGroups() = %q, want %q", got, want) + } +} + func TestGroupTTLSeconds_returnsFalseForMissingOrInvalidTTL(t *testing.T) { tests := []struct { name string @@ -777,6 +935,40 @@ func TestGroupTTLSeconds_logsRubyCompatibleInvalidUnitError(t *testing.T) { } } +func TestGroupTTLSecondsAcceptsCaseInsensitiveUnits(t *testing.T) { + tests := []struct { + ttl string + want int64 + }{ + {ttl: "1000000000 NANOSECOND", want: 1}, + {ttl: "1000000 MICROSECOND", want: 1}, + {ttl: "1000 MILLISECOND", want: 1}, + {ttl: "2 SECOND", want: 2}, + {ttl: "2 MINUTE", want: 120}, + {ttl: "2 HOUR", want: 7200}, + {ttl: "2 DAY", want: 172800}, + } + + for _, tt := range tests { + t.Run(tt.ttl, func(t *testing.T) { + got, ok := GroupTTLSeconds([]FactTTL{{Fact: "os", TTL: tt.ttl}}, "os", nil) + if !ok || got != tt.want { + t.Fatalf("GroupTTLSeconds(%q) = %d, %v; want %d, true", tt.ttl, got, ok, tt.want) + } + }) + } +} + +func TestGroupTTLSecondsRejectsMalformedTTLTokens(t *testing.T) { + for _, ttl := range []string{"", "+", "-", "999999999999999999999999999999999999 seconds", "1 hour extra"} { + t.Run(ttl, func(t *testing.T) { + if got, ok := GroupTTLSeconds([]FactTTL{{Fact: "os", TTL: ttl}}, "os", discardLog()); ok { + t.Fatalf("GroupTTLSeconds(%q) = %d, true; want false", ttl, got) + } + }) + } +} + func TestBlocklistedFactsForFiltering_retiredLegacyGroupBlocksNothing(t *testing.T) { blocked := BlocklistedFactsForFiltering([]string{"legacy"}, nil) @@ -851,6 +1043,33 @@ func TestFilterBlockedFacts_prunesBlockedDescendantsFromStructuredParents(t *tes } } +func TestFilterBlockedFactsPrunesEmptyMapsAndKeepsOriginalValue(t *testing.T) { + original := map[string]any{ + "name": "Ubuntu", + "release": map[string]any{ + "full": "24.04", + "major": "24", + }, + } + facts := []ResolvedFact{{Name: "os", Value: original, Type: "core"}} + + got := FilterBlockedFacts(facts, map[string]bool{ + "os.release.full": true, + "os.release.major": true, + "os.missing.nothing": true, + }) + want := []ResolvedFact{{Name: "os", Value: map[string]any{"name": "Ubuntu"}, Type: "core"}} + if !reflect.DeepEqual(got, want) { + t.Fatalf("FilterBlockedFacts() = %#v, want %#v", got, want) + } + if _, ok := original["release"]; !ok { + t.Fatalf("original value = %#v, want unpruned source map", original) + } + if got := pruneDottedValue(map[string]any{"name": "Ubuntu"}, nil); !reflect.DeepEqual(got, map[string]any{"name": "Ubuntu"}) { + t.Fatalf("pruneDottedValue(empty path) = %#v, want unchanged value", got) + } +} + func TestParseConfig_returnsConfiguredFactGroups(t *testing.T) { path := filepath.Join(t.TempDir(), "facter.conf") content := `fact-groups : { @@ -1045,4 +1264,27 @@ global : { t.Fatal("NoExternalFacts = false, want true") } }) + + t.Run("comment at end of file is ignored", func(t *testing.T) { + path := writeConfig(t, `global : { + external-dir : [ "/kept" ], +} +# no newline`) + got, err := ParseConfig(path, discardLog()) + if err != nil { + t.Fatal(err) + } + if want := []string{"/kept"}; !reflect.DeepEqual(got.ExternalDirs, want) { + t.Fatalf("ExternalDirs = %#v, want %#v", got.ExternalDirs, want) + } + }) +} + +func TestFirstConfigValueReturnsFirstNonEmptyValue(t *testing.T) { + if got, want := firstConfigValue("", "", "kept", "ignored"), "kept"; got != want { + t.Fatalf("firstConfigValue() = %q, want %q", got, want) + } + if got := firstConfigValue("", ""); got != "" { + t.Fatalf("firstConfigValue(all empty) = %q, want empty", got) + } } diff --git a/internal/engine/core_test.go b/internal/engine/core_test.go index 2061a544..859649d3 100644 --- a/internal/engine/core_test.go +++ b/internal/engine/core_test.go @@ -64,6 +64,36 @@ func TestPathEntries_splitsAndDropsEmpty(t *testing.T) { } } +func TestRootedPathAndIsSymlinkHelpers(t *testing.T) { + if got, want := rootedPath("/", "var/cache"), "/var/cache"; got != want { + t.Fatalf("rootedPath(/) = %q, want %q", got, want) + } + root := t.TempDir() + if got, want := rootedPath(root, "var/cache"), filepath.Join(root, "var/cache"); got != want { + t.Fatalf("rootedPath(temp) = %q, want %q", got, want) + } + + lstat := func(path string) (os.FileInfo, error) { + switch path { + case "/link": + return fakeFileInfo{name: "link", mode: os.ModeSymlink | 0o777}, nil + case "/regular": + return fakeFileInfo{name: "regular"}, nil + default: + return nil, os.ErrNotExist + } + } + if !isSymlink("/link", lstat) { + t.Fatal("isSymlink(/link) = false, want true") + } + if isSymlink("/regular", lstat) { + t.Fatal("isSymlink(/regular) = true, want false") + } + if isSymlink("/missing", lstat) { + t.Fatal("isSymlink(/missing) = true, want false") + } +} + func TestCoreFacts_includeFacterVersion(t *testing.T) { collection := Collection(CoreFacts(testSession)) diff --git a/internal/engine/disks_test.go b/internal/engine/disks_test.go index b67f0d71..1917b5e7 100644 --- a/internal/engine/disks_test.go +++ b/internal/engine/disks_test.go @@ -90,6 +90,44 @@ func TestPartitionsFactMatchesFreeBSDGPTMountsByPartlabel(t *testing.T) { } } +func TestPartitionForMountDeviceMatchesNamesLabelsAndUUIDs(t *testing.T) { + t.Parallel() + + partitions := map[string]any{ + "ada0p2": map[string]any{"partuuid": "7f6ec6ec-2e4e-11ef-9a8a-0800276f7822"}, + "vtbd0p2": map[string]any{"partlabel": "rootfs"}, + "sda1": map[string]any{"filesystem": "ext4"}, + "ignored": "not a partition map", + } + + tests := []struct { + name string + device string + want string + }{ + {name: "direct key", device: "sda1", want: "sda1"}, + {name: "dev prefix", device: "/dev/sda1", want: "sda1"}, + {name: "gpt label", device: "/dev/gpt/rootfs", want: "vtbd0p2"}, + {name: "gpt uuid", device: "/dev/gptid/7f6ec6ec-2e4e-11ef-9a8a-0800276f7822", want: "ada0p2"}, + {name: "missing", device: "/dev/gpt/missing"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := partitionForMountDevice(partitions, tt.device) + if tt.want == "" { + if got != nil { + t.Fatalf("partitionForMountDevice(%q) = %#v, want nil", tt.device, got) + } + return + } + want := partitions[tt.want].(map[string]any) + if !reflect.DeepEqual(got, want) { + t.Fatalf("partitionForMountDevice(%q) = %#v, want partition %q", tt.device, got, tt.want) + } + }) + } +} + func TestPartitionsFactReturnsPartitionsWithoutMountpoints(t *testing.T) { partitions := map[string]any{ "/dev/sda1": map[string]any{"filesystem": "ext3"}, @@ -107,6 +145,47 @@ func TestPartitionsFactReturnsNilForEmptyPartitions(t *testing.T) { } } +func TestParseLinuxLSBLKPropertyLineHandlesQuotedAndUnquotedValues(t *testing.T) { + t.Parallel() + + line := `NAME="sda1" FSTYPE=ext4 LABEL="data \"disk\"" EMPTY="" BROKEN="unterminated` + got := parseLinuxLSBLKPropertyLine(line) + want := map[string]string{ + "NAME": "sda1", + "FSTYPE": "ext4", + "LABEL": `data "disk"`, + "BROKEN": "unterminated", + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("parseLinuxLSBLKPropertyLine(%q) = %#v, want %#v", line, got, want) + } +} + +func TestParseLinuxLSBLKPropertiesSkipsRowsWithoutValues(t *testing.T) { + t.Parallel() + + input := "NAME=\"sda1\" FSTYPE=\"ext4\"\nNAME=\"sda2\"\nMISSING=\"name\"\n" + got := parseLinuxLSBLKProperties(input) + want := map[string]map[string]string{ + "sda1": {"FSTYPE": "ext4"}, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("parseLinuxLSBLKProperties(%q) = %#v, want %#v", input, got, want) + } +} + +func TestLinuxLSBLKVersionParsesUtilLinuxVersion(t *testing.T) { + t.Parallel() + + major, minor, ok := linuxLSBLKVersion("lsblk from util-linux 2.39.3\n") + if !ok || major != 2 || minor != 39 { + t.Fatalf("linuxLSBLKVersion() = %d, %d, %v; want 2, 39, true", major, minor, ok) + } + if major, minor, ok := linuxLSBLKVersion("lsblk unknown\n"); ok || major != 0 || minor != 0 { + t.Fatalf("linuxLSBLKVersion(no version) = %d, %d, %v; want 0, 0, false", major, minor, ok) + } +} + func TestDiscoverPartitionsReadsSysfsPartitionEntries(t *testing.T) { root := t.TempDir() partitionDir := filepath.Join(root, "sda1") @@ -237,6 +316,27 @@ func TestParseFreeBSDGeomPartitions_returnsRubyCompatiblePartitionFacts(t *testi } } +func TestFreeBSDPartitionTypePrefersTypeThenRawType(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + config freeBSDGeomConfig + want string + }{ + {name: "type", config: freeBSDGeomConfig{Type: " freebsd-zfs ", RawType: "raw"}, want: "freebsd-zfs"}, + {name: "raw type fallback", config: freeBSDGeomConfig{RawType: " efi "}, want: "efi"}, + {name: "empty", config: freeBSDGeomConfig{Type: " ", RawType: "\t"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := freeBSDPartitionType(tt.config); got != tt.want { + t.Fatalf("freeBSDPartitionType(%#v) = %q, want %q", tt.config, got, tt.want) + } + }) + } +} + func TestParseFreeBSDGeomDisks_returnsRubyCompatibleDiskFacts(t *testing.T) { input, err := os.ReadFile(filepath.Join("testdata", "kern.geom.confxml")) if err != nil { @@ -427,14 +527,58 @@ func TestParseBSDDiskNamesSplitsOpenBSDCommaSeparatedNames(t *testing.T) { } } +func TestIsBSDDiskNameAllowsOnlyAlnumDeviceNames(t *testing.T) { + t.Parallel() + + tests := map[string]bool{ + "": false, + "sd0": true, + "nvme0": true, + "da0p1": true, + "sd0:": false, + "sd0-": false, + "sd0.eli": false, + } + for name, want := range tests { + if got := isBSDDiskName(name); got != want { + t.Fatalf("isBSDDiskName(%q) = %v, want %v", name, got, want) + } + } +} + +func TestCurrentBSDDisksOmitWhenNoRunnableDiskData(t *testing.T) { + t.Parallel() + + if got := currentBSDDisks("openbsd", nil); got != nil { + t.Fatalf("currentBSDDisks(nil run) = %#v, want nil", got) + } + if got := currentBSDDisks("openbsd", func(string, ...string) string { return "" }); got != nil { + t.Fatalf("currentBSDDisks(no devices) = %#v, want nil", got) + } + got := currentBSDDisks("openbsd", func(name string, args ...string) string { + switch fakeRunKey(name, args...) { + case fakeRunKey("sysctl", "-n", "hw.disknames"): + return "sd0:942d2f143e47054f\n" + case fakeRunKey("disklabel", "sd0"): + return "" + default: + t.Fatalf("unexpected command %q %#v", name, args) + return "" + } + }) + if got != nil { + t.Fatalf("currentBSDDisks(empty disklabel) = %#v, want nil", got) + } +} + func TestCurrentOpenBSDPartitionsReadsEveryDiskName(t *testing.T) { got := currentOpenBSDPartitions(func(name string, args ...string) string { - switch strings.Join(append([]string{name}, args...), " ") { - case "sysctl -n hw.disknames": + switch fakeRunKey(name, args...) { + case fakeRunKey("sysctl", "-n", "hw.disknames"): return "sd0:942d2f143e47054f,sd1:1111111111111111\n" - case "disklabel sd0": + case fakeRunKey("disklabel", "sd0"): return openBSDDisklabelSD0 - case "disklabel sd1": + case fakeRunKey("disklabel", "sd1"): return strings.ReplaceAll(openBSDDisklabelSD0, "sd0", "sd1") default: t.Fatalf("unexpected command %q %#v", name, args) @@ -448,6 +592,81 @@ func TestCurrentOpenBSDPartitionsReadsEveryDiskName(t *testing.T) { } } +func TestCurrentOpenBSDPartitionsOmitWhenNoRunnableDiskData(t *testing.T) { + t.Parallel() + + if got := currentOpenBSDPartitions(nil); got != nil { + t.Fatalf("currentOpenBSDPartitions(nil run) = %#v, want nil", got) + } + if got := currentOpenBSDPartitions(func(string, ...string) string { return "" }); got != nil { + t.Fatalf("currentOpenBSDPartitions(no devices) = %#v, want nil", got) + } +} + +func TestCurrentNetBSDPartitionsReadsDkctlWedges(t *testing.T) { + disklabelCalled := false + got := currentNetBSDPartitions(func(name string, args ...string) string { + switch fakeRunKey(name, args...) { + case fakeRunKey("sysctl", "-n", "hw.disknames"): + return "ld4\n" + case fakeRunKey("sh", "-c", "disklabel ld4 2>/dev/null || true"): + disklabelCalled = true + return strings.Replace(netBSDDisklabelLD4, "bytes/sector: 512", "bytes/sector: 4096", 1) + case fakeRunKey("dkctl", "ld4", "listwedges"): + if !disklabelCalled { + t.Fatal("currentNetBSDPartitions() read wedges before disklabel") + } + return netBSDWedgesLD4 + default: + t.Fatalf("unexpected command %q %#v", name, args) + return "" + } + }) + + want := map[string]any{ + "/dev/dk0": map[string]any{"filesystem": "msdos", "partlabel": "EFI", "size": "640.00 MiB", "size_bytes": 671_088_640}, + "/dev/dk1": map[string]any{"filesystem": "ffs", "partlabel": "netbsd-root", "size": "79.22 GiB", "size_bytes": 85_060_485_120}, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("currentNetBSDPartitions() = %#v, want %#v", got, want) + } + if !disklabelCalled { + t.Fatal("currentNetBSDPartitions() did not read disklabel before parsing wedges") + } +} + +func TestCurrentNetBSDPartitionsFallsBackToDisklabelWhenNoWedges(t *testing.T) { + t.Parallel() + + got := currentNetBSDPartitions(func(name string, args ...string) string { + switch fakeRunKey(name, args...) { + case fakeRunKey("sysctl", "-n", "hw.disknames"): + return "ld4\n" + case fakeRunKey("sh", "-c", "disklabel ld4 2>/dev/null || true"): + return netBSDDisklabelLD4 + case fakeRunKey("dkctl", "ld4", "listwedges"): + return "" + default: + t.Fatalf("unexpected command %q %#v", name, args) + return "" + } + }) + if _, ok := got["/dev/ld4e"]; !ok { + t.Fatalf("currentNetBSDPartitions() = %#v, want fallback disklabel partition /dev/ld4e", got) + } +} + +func TestCurrentNetBSDPartitionsOmitWhenNoRunnableDiskData(t *testing.T) { + t.Parallel() + + if got := currentNetBSDPartitions(nil); got != nil { + t.Fatalf("currentNetBSDPartitions(nil run) = %#v, want nil", got) + } + if got := currentNetBSDPartitions(func(string, ...string) string { return "" }); got != nil { + t.Fatalf("currentNetBSDPartitions(no devices) = %#v, want nil", got) + } +} + func TestParseNetBSDDkctlWedges_returnsDevicePartitions(t *testing.T) { got := parseNetBSDDkctlWedges(netBSDWedgesLD4, 512) want := map[string]any{ @@ -658,6 +877,52 @@ func TestCurrentIllumosPartitionsReadsVTOCSlices(t *testing.T) { } } +func TestCurrentIllumosPartitionsOmitWhenNoRunnableDiskData(t *testing.T) { + t.Parallel() + + run := func(string, ...string) string { + t.Fatal("currentIllumosPartitions() ran command without whole slices") + return "" + } + if got := currentIllumosPartitions(nil, func(string) ([]string, error) { return nil, nil }); got != nil { + t.Fatalf("currentIllumosPartitions(nil run) = %#v, want nil", got) + } + if got := currentIllumosPartitions(run, nil); got != nil { + t.Fatalf("currentIllumosPartitions(nil glob) = %#v, want nil", got) + } + if got := currentIllumosPartitions(run, func(string) ([]string, error) { return nil, os.ErrNotExist }); got != nil { + t.Fatalf("currentIllumosPartitions(glob error) = %#v, want nil", got) + } + if got := currentIllumosPartitions(run, func(string) ([]string, error) { return []string{"/dev/rdsk/not-a-whole-slice"}, nil }); got != nil { + t.Fatalf("currentIllumosPartitions(no whole slices) = %#v, want nil", got) + } +} + +func TestIllumosPartitionFilesystemSkipsUnknownAndDeviceErrors(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + want string + }{ + {name: "valid", input: "zfs\n", want: "zfs"}, + {name: "empty", input: "", want: ""}, + {name: "unknown", input: "unknown_fstyp\n", want: ""}, + {name: "device error", input: "/dev/rdsk/c9t0d0s0: I/O error\n", want: ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + if got := illumosPartitionFilesystem(tt.input); got != tt.want { + t.Fatalf("illumosPartitionFilesystem(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + func TestDisksFacts_omittedWhenNoDevicesEnumerate(t *testing.T) { t.Parallel() @@ -1062,6 +1327,104 @@ func TestCurrentPartitionsUsesSessionHostGlobForIllumos(t *testing.T) { } } +func TestCurrentPartitionsDispatchesBySessionPlatform(t *testing.T) { + freeBSDGeom, err := os.ReadFile(filepath.Join("testdata", "kern.geom.confxml")) + if err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + host *fakeHostOS + wantKey string + wantCalls []fakeHostRunCall + }{ + { + name: "freebsd", + host: &fakeHostOS{ + platform: "freebsd", + runOutputs: map[string]string{ + fakeRunKey("sysctl", "-n", "kern.geom.confxml"): string(freeBSDGeom), + }, + }, + wantKey: "ada0p1", + wantCalls: []fakeHostRunCall{ + {name: "sysctl", args: []string{"-n", "kern.geom.confxml"}}, + }, + }, + { + name: "openbsd", + host: &fakeHostOS{ + platform: "openbsd", + runOutputs: map[string]string{ + fakeRunKey("sysctl", "-n", "hw.disknames"): "sd0:942d2f143e47054f\n", + fakeRunKey("disklabel", "sd0"): openBSDDisklabelSD0, + }, + }, + wantKey: "/dev/sd0a", + wantCalls: []fakeHostRunCall{ + {name: "sysctl", args: []string{"-n", "hw.disknames"}}, + {name: "disklabel", args: []string{"sd0"}}, + }, + }, + { + name: "netbsd", + host: &fakeHostOS{ + platform: "netbsd", + runOutputs: map[string]string{ + fakeRunKey("sysctl", "-n", "hw.disknames"): "ld4\n", + fakeRunKey("sh", "-c", "disklabel ld4 2>/dev/null || true"): netBSDDisklabelLD4, + fakeRunKey("dkctl", "ld4", "listwedges"): netBSDWedgesLD4, + }, + }, + wantKey: "/dev/dk0", + wantCalls: []fakeHostRunCall{ + {name: "sysctl", args: []string{"-n", "hw.disknames"}}, + {name: "sh", args: []string{"-c", "disklabel ld4 2>/dev/null || true"}}, + {name: "dkctl", args: []string{"ld4", "listwedges"}}, + }, + }, + { + name: "dragonfly", + host: &fakeHostOS{ + platform: "dragonfly", + runOutputs: map[string]string{ + fakeRunKey("sysctl", "-n", "kern.disks"): "da0\n", + fakeRunKey("disklabel", "da0"): "disklabel: Operation not supported by device\n", + fakeRunKey("disklabel", "da0s1"): dragonFlyDisklabelDA0S1, + fakeRunKey("disklabel", "da0s2"): "disklabel: Operation not supported by device\n", + fakeRunKey("disklabel", "da0s3"): "disklabel: Operation not supported by device\n", + fakeRunKey("disklabel", "da0s4"): "disklabel: Operation not supported by device\n", + }, + }, + wantKey: "/dev/da0s1a", + wantCalls: []fakeHostRunCall{ + {name: "sysctl", args: []string{"-n", "kern.disks"}}, + {name: "disklabel", args: []string{"da0"}}, + {name: "disklabel", args: []string{"da0s1"}}, + {name: "disklabel", args: []string{"da0s2"}}, + {name: "disklabel", args: []string{"da0s3"}}, + {name: "disklabel", args: []string{"da0s4"}}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := NewSessionContext(t.Context()) + s.host = tt.host + + got := currentPartitions(s) + if _, ok := got[tt.wantKey]; !ok { + t.Fatalf("currentPartitions(%s) = %#v, want key %q", tt.name, got, tt.wantKey) + } + if !reflect.DeepEqual(tt.host.runCalls, tt.wantCalls) { + t.Fatalf("run calls = %#v, want %#v", tt.host.runCalls, tt.wantCalls) + } + }) + } +} + func TestParseLinuxFilesystems_sortsAndSkipsPseudoEntries(t *testing.T) { input := "nodev\tsysfs\nnodev\tproc\next4\nfuseblk\nxfs\n" @@ -1083,6 +1446,24 @@ func TestCurrentLinuxFilesystemsUnreadableProcMatchesRubyResolver(t *testing.T) } } +func TestCurrentDarwinFilesystemsReadsMountOutput(t *testing.T) { + t.Parallel() + + got := currentFilesystems("darwin", nil, func(name string, args ...string) string { + if name != "mount" || len(args) != 0 { + t.Fatalf("run(%q, %#v), want mount", name, args) + } + return "/dev/disk3s1s1 on / (apfs, local)\nmap auto_home on /System/Volumes/Data/home (autofs, automounted)\n" + }) + want := []string{"apfs", "autofs"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("currentFilesystems(darwin) = %#v, want %#v", got, want) + } + if got := currentFilesystems("darwin", nil, nil); got != nil { + t.Fatalf("currentFilesystems(darwin nil runner) = %#v, want nil", got) + } +} + func TestCurrentFilesystemsHonorsTargetCapabilityPolicy(t *testing.T) { called := false readFile := func(path string) ([]byte, error) { @@ -1115,6 +1496,41 @@ func TestCurrentZFSFactsHonorsTargetCapabilityPolicy(t *testing.T) { if called { t.Fatal("currentZFSFacts(openbsd) touched probes despite target policy") } + if got := currentZFSFacts("freebsd", nil); got != nil { + t.Fatalf("currentZFSFacts(freebsd, nil) = %#v, want nil", got) + } +} + +func TestCurrentZFSFactsRunsZFSAndZpoolUpgradeCommands(t *testing.T) { + calls := []string{} + run := func(name string, args ...string) string { + key := fakeRunKey(name, args...) + calls = append(calls, key) + switch key { + case fakeRunKey("zfs", "upgrade", "-v"): + return "1 initial version\n2 snapshot version\n" + case fakeRunKey("zpool", "upgrade", "-v"): + return "1 initial version\n2 mirror version\n" + default: + t.Fatalf("unexpected command %q %#v", name, args) + return "" + } + } + + got := factsByName(currentZFSFacts("freebsd", run)) + want := map[string]any{ + "zfs.feature_numbers": []string{"1", "2"}, + "zfs.version": "2", + "zpool.feature_numbers": []string{"1", "2"}, + "zpool.version": "2", + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("currentZFSFacts() = %#v, want %#v", got, want) + } + wantCalls := []string{fakeRunKey("zfs", "upgrade", "-v"), fakeRunKey("zpool", "upgrade", "-v")} + if !reflect.DeepEqual(calls, wantCalls) { + t.Fatalf("calls = %#v, want %#v", calls, wantCalls) + } } func TestParseDarwinFilesystems_sortsUniqueFilesystemTypes(t *testing.T) { @@ -1353,6 +1769,259 @@ func TestLinuxMountEntriesReplaceDevRootLikeRubyResolver(t *testing.T) { } } +func TestCurrentMountEntriesLinuxReadsProcMountsAndResolvesDevRoot(t *testing.T) { + host := &fakeHostOS{ + platform: "linux", + files: map[string][]byte{ + "/proc/self/mounts": []byte("/dev/root / ext4 rw,noatime 0 0\n"), + "/proc/cmdline": []byte("console=ttyAMA0 root=/dev/mmcblk0p2 rootfstype=ext4"), + }, + } + s := NewSession() + s.host = host + + got := currentMountEntries(s) + want := []mountEntry{{Device: "/dev/mmcblk0p2", Path: "/", Filesystem: "ext4", Options: []string{"rw", "noatime"}}} + if !reflect.DeepEqual(got, want) { + t.Fatalf("currentMountEntries() = %#v, want %#v", got, want) + } + if len(host.runCalls) != 0 { + t.Fatalf("run calls = %#v, want none", host.runCalls) + } +} + +func TestCurrentMountEntriesParsesPlatformMountOutput(t *testing.T) { + tests := []struct { + goos string + outputs map[string]string + want []mountEntry + wantCalls []fakeHostRunCall + }{ + { + goos: "darwin", + outputs: map[string]string{ + fakeRunKey("mount"): "/dev/disk3s1s1 on / (apfs, sealed, local, read-only, journaled)\n", + }, + want: []mountEntry{{Device: "/dev/disk3s1s1", Path: "/", Filesystem: "apfs", Options: []string{"sealed", "local", "readonly", "journaled"}}}, + wantCalls: []fakeHostRunCall{{name: "mount"}}, + }, + { + goos: "freebsd", + outputs: map[string]string{ + fakeRunKey("mount"): "/dev/ada0p2 on / (ufs, local, journaled soft-updates)\n", + }, + want: []mountEntry{{Device: "/dev/ada0p2", Path: "/", Filesystem: "ufs", Options: []string{"local", "journaled soft-updates"}}}, + wantCalls: []fakeHostRunCall{{name: "mount"}}, + }, + { + goos: "netbsd", + outputs: map[string]string{ + fakeRunKey("mount"): "/dev/dk1 on / type ffs (noatime, local)\n", + }, + want: []mountEntry{{Device: "/dev/dk1", Path: "/", Filesystem: "ffs", Options: []string{"noatime", "local"}}}, + wantCalls: []fakeHostRunCall{{name: "mount"}}, + }, + { + goos: "plan9", + want: []mountEntry{{Path: "/"}}, + }, + } + + for _, tt := range tests { + t.Run(tt.goos, func(t *testing.T) { + s := NewSession() + host := &fakeHostOS{platform: tt.goos, runOutputs: tt.outputs} + s.host = host + + got := currentMountEntries(s) + if !reflect.DeepEqual(got, tt.want) { + t.Fatalf("currentMountEntries(%s) = %#v, want %#v", tt.goos, got, tt.want) + } + if !reflect.DeepEqual(host.runCalls, tt.wantCalls) { + t.Fatalf("run calls = %#v, want %#v", host.runCalls, tt.wantCalls) + } + }) + } +} + +func TestCurrentMountEntriesOmitWhenPlatformSourceUnavailable(t *testing.T) { + t.Parallel() + + for _, goos := range []string{"darwin", "freebsd", "netbsd", "linux"} { + t.Run(goos, func(t *testing.T) { + t.Parallel() + + s := NewSession() + host := &fakeHostOS{platform: goos} + switch goos { + case "darwin", "freebsd", "netbsd": + host.runOutputs = map[string]string{fakeRunKey("mount"): ""} + } + s.host = host + if got := currentMountEntries(s); got != nil { + t.Fatalf("currentMountEntries(%s) = %#v, want nil", goos, got) + } + }) + } +} + +func TestRootMountpointUsesPlatformSpecificMountCommands(t *testing.T) { + tests := []struct { + goos string + outputs map[string]string + wantDevice string + wantFilesystem string + wantSizeBytes int + wantUsedBytes int + wantAvailBytes int + wantCapacity string + wantOptions []string + wantCalls []fakeHostRunCall + }{ + { + goos: "openbsd", + outputs: map[string]string{ + fakeRunKey("mount"): "/dev/sd0a on / type ffs (local)\n", + fakeRunKey("df", "-P"): "Filesystem 512-blocks Used Available Capacity Mounted on\n/dev/sd0a 2000 1000 1000 50% /\n", + }, + wantDevice: "/dev/sd0a", + wantFilesystem: "ffs", + wantSizeBytes: 1_024_000, + wantUsedBytes: 512_000, + wantAvailBytes: 512_000, + wantCapacity: "50.00%", + wantOptions: []string{"local"}, + wantCalls: []fakeHostRunCall{ + {name: "mount"}, + {name: "df", args: []string{"-P"}}, + }, + }, + { + goos: "netbsd", + outputs: map[string]string{ + fakeRunKey("mount"): "/dev/dk1 on / type ffs (noatime, local)\n", + fakeRunKey("df", "-P"): "Filesystem 512-blocks Used Avail Capacity Mounted on\n/dev/dk1 4000 1000 3000 25% /\n", + }, + wantDevice: "/dev/dk1", + wantFilesystem: "ffs", + wantSizeBytes: 2_048_000, + wantUsedBytes: 512_000, + wantAvailBytes: 1_536_000, + wantCapacity: "25.00%", + wantOptions: []string{"noatime", "local"}, + wantCalls: []fakeHostRunCall{ + {name: "mount"}, + {name: "df", args: []string{"-P"}}, + }, + }, + { + goos: "dragonfly", + outputs: map[string]string{ + fakeRunKey("mount"): "da0s1d on / (hammer2, local)\n", + fakeRunKey("df", "-P"): "Filesystem 512-blocks Used Avail Capacity Mounted on\nda0s1d 8000 2000 6000 25% /\n", + }, + wantDevice: "/dev/da0s1d", + wantFilesystem: "hammer2", + wantSizeBytes: 4_096_000, + wantUsedBytes: 1_024_000, + wantAvailBytes: 3_072_000, + wantCapacity: "25.00%", + wantOptions: []string{"local"}, + wantCalls: []fakeHostRunCall{ + {name: "mount"}, + {name: "df", args: []string{"-P"}}, + }, + }, + { + goos: "illumos", + outputs: map[string]string{ + fakeRunKey("mount", "-v"): "rpool/ROOT/test on / type zfs read/write/setuid/devices/dev=4310002 on Thu Jan 1 00:00:00 1970\n", + fakeRunKey("df", "-P"): "Filesystem 512-blocks Used Available Capacity Mounted on\nrpool/ROOT/test 16000 4000 12000 25% /\n", + }, + wantDevice: "rpool/ROOT/test", + wantFilesystem: "zfs", + wantSizeBytes: 8_192_000, + wantUsedBytes: 2_048_000, + wantAvailBytes: 6_144_000, + wantCapacity: "25.00%", + wantOptions: []string{"read", "write", "setuid", "devices", "dev=4310002"}, + wantCalls: []fakeHostRunCall{ + {name: "mount", args: []string{"-v"}}, + {name: "df", args: []string{"-P"}}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.goos, func(t *testing.T) { + host := &fakeHostOS{platform: tt.goos, runOutputs: tt.outputs} + s := NewSession() + s.host = host + + got := rootMountpoint(s) + root, ok := got["/"].(map[string]any) + if !ok { + t.Fatalf("rootMountpoint(%s) = %#v, want / map", tt.goos, got) + } + if root["device"] != tt.wantDevice || root["filesystem"] != tt.wantFilesystem || root["size_bytes"] != tt.wantSizeBytes { + t.Fatalf("root mountpoint = %#v, want device %q filesystem %q size_bytes %d", root, tt.wantDevice, tt.wantFilesystem, tt.wantSizeBytes) + } + if root["used_bytes"] != tt.wantUsedBytes || root["available_bytes"] != tt.wantAvailBytes || root["capacity"] != tt.wantCapacity { + t.Fatalf("root mountpoint df fields = %#v, want used_bytes %d available_bytes %d capacity %q", root, tt.wantUsedBytes, tt.wantAvailBytes, tt.wantCapacity) + } + if !reflect.DeepEqual(root["options"], tt.wantOptions) { + t.Fatalf("root mountpoint options = %#v, want %#v", root["options"], tt.wantOptions) + } + if !reflect.DeepEqual(host.runCalls, tt.wantCalls) { + t.Fatalf("run calls = %#v, want %#v", host.runCalls, tt.wantCalls) + } + }) + } +} + +func TestCurrentBSDAndIllumosMountpointsFallbackToRootStatWhenMountOutputMissing(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + run func(*Session) map[string]any + }{ + {name: "dragonfly", run: currentDragonFlyMountpoints}, + {name: "illumos", run: currentIllumosMountpoints}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + host := &fakeHostOS{ + platform: tt.name, + runOutputs: map[string]string{ + fakeRunKey("mount"): "", + fakeRunKey("mount", "-v"): "", + }, + mountStats: map[string]mountStat{ + "/": {SizeBytes: 2048, UsedBytes: 1024, AvailableBytes: 1024}, + }, + } + s := NewSession() + s.host = host + + got := tt.run(s) + root, ok := got["/"].(map[string]any) + if !ok { + t.Fatalf("%s mountpoints = %#v, want / mountpoint", tt.name, got) + } + if root["size_bytes"] != 2048 || root["used_bytes"] != 1024 || root["available_bytes"] != 1024 || root["capacity"] != "50.00%" { + t.Fatalf("%s root mountpoint = %#v, want stat-derived bytes and capacity", tt.name, root) + } + if want := []string{"/"}; !reflect.DeepEqual(host.statMountpointCalls, want) { + t.Fatalf("%s statMountpoint calls = %#v, want %#v", tt.name, host.statMountpointCalls, want) + } + }) + } +} + func TestDarwinMountpointsFactUsesZeroDefaultsWhenStatFails(t *testing.T) { entries := []mountEntry{{Device: "/dev/root", Path: "/", Filesystem: "ext4", Options: []string{"rw", "noatime"}}} stats := func(string) (mountStat, bool) { return mountStat{}, false } @@ -1524,6 +2193,24 @@ devfs 2 2 0 100% /dev } } +func TestCurrentDragonFlyMountpointsUsesMountAndDFOutput(t *testing.T) { + t.Parallel() + + s := NewSession() + s.host = &fakeHostOS{runOutputs: map[string]string{ + fakeRunKey("mount"): "da0s1d on / (hammer2, local)\n", + fakeRunKey("df", "-P"): `Filesystem 512-blocks Used Avail Capacity Mounted on +da0s1d 247916160 2892416 245023744 1% / +`, + }} + + got := currentDragonFlyMountpoints(s) + root := got["/"].(map[string]any) + if root["device"] != "/dev/da0s1d" || root["filesystem"] != "hammer2" || root["size_bytes"] != 126_933_073_920 { + t.Fatalf("currentDragonFlyMountpoints()[/] = %#v", root) + } +} + func TestDragonFlyMountDeviceOnlyNormalizesDiskPartitions(t *testing.T) { tests := []struct { in string @@ -1557,6 +2244,69 @@ swap 5046424 133048 4913376 3% /tmp` } } +func TestCurrentIllumosMountpointsUsesMountAndDFOutput(t *testing.T) { + t.Parallel() + + s := NewSession() + s.host = &fakeHostOS{runOutputs: map[string]string{ + fakeRunKey("mount", "-v"): "rpool/ROOT/omnios-r151058 on / type zfs read/write/setuid on Thu Jan 1 00:00:00 1970\n", + fakeRunKey("df", "-P"): `Filesystem 512-blocks Used Available Capacity Mounted on +rpool/ROOT/omnios-r151058 59932672 1902744 57629848 4% / +`, + }} + + got := currentIllumosMountpoints(s) + root := got["/"].(map[string]any) + if root["device"] != "rpool/ROOT/omnios-r151058" || root["filesystem"] != "zfs" || root["size_bytes"] != 30_685_528_064 { + t.Fatalf("currentIllumosMountpoints()[/] = %#v", root) + } +} + +func TestCurrentBSDStyleMountpointsFallbackToRootStat(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + outputs map[string]string + current func(*Session) map[string]any + }{ + { + name: "dragonfly", + outputs: map[string]string{fakeRunKey("mount"): ""}, + current: currentDragonFlyMountpoints, + }, + { + name: "illumos", + outputs: map[string]string{fakeRunKey("mount", "-v"): ""}, + current: currentIllumosMountpoints, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + host := &fakeHostOS{ + runOutputs: tt.outputs, + mountStats: map[string]mountStat{ + "/": {SizeBytes: 100, AvailableBytes: 25, UsedBytes: 75}, + }, + } + s := NewSession() + s.host = host + + got := tt.current(s) + root := got["/"].(map[string]any) + if root["size_bytes"] != 100 || root["available_bytes"] != 25 || root["used_bytes"] != 75 { + t.Fatalf("current mountpoints fallback root = %#v", root) + } + if want := []string{"/"}; !reflect.DeepEqual(host.statMountpointCalls, want) { + t.Fatalf("statMountpoint calls = %#v, want %#v", host.statMountpointCalls, want) + } + }) + } +} + func TestParseNetBSDMountEntries(t *testing.T) { input := `/dev/dk1 on / type ffs (noatime, local) /dev/dk0 on /boot type msdos (local) @@ -1574,6 +2324,42 @@ ptyfs on /dev/pts type ptyfs (local) } } +func TestParseBSDMountEntriesHandlesFreeBSDStyleFilesystemOptions(t *testing.T) { + t.Parallel() + + input := `/dev/gpt/rootfs on / (ufs, local, journaled soft-updates) +malformed without separator +/dev/gpt/bad on /bad +` + + got := parseBSDMountEntries(input) + want := []mountEntry{ + {Device: "/dev/gpt/rootfs", Path: "/", Filesystem: "ufs", Options: []string{"local", "journaled soft-updates"}}, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("parseBSDMountEntries() = %#v, want %#v", got, want) + } +} + +func TestParseIllumosMountEntriesHandlesLegacyAndTypedFormats(t *testing.T) { + t.Parallel() + + input := `rpool/ROOT/omnios-r151058 on / type zfs read/write/setuid/devices/dev=4310002 on Thu Jan 1 00:00:00 1970 +/ on rpool/ROOT/omnios-r151058 read/write/setuid +bad line +/missing on onlyonefield +` + + got := parseIllumosMountEntries(input) + want := []mountEntry{ + {Device: "rpool/ROOT/omnios-r151058", Path: "/", Filesystem: "zfs", Options: []string{"read", "write", "setuid", "devices", "dev=4310002"}}, + {Device: "rpool/ROOT/omnios-r151058", Path: "/", Options: []string{"read", "write", "setuid"}}, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("parseIllumosMountEntries() = %#v, want %#v", got, want) + } +} + func TestParseDarwinMountEntries(t *testing.T) { input := "/dev/disk3s1s1 on / (apfs, sealed, local, read-only, journaled)\nmap auto_home on /System/Volumes/Data/home (autofs, automounted, nobrowse)\nserver:/Shared\\040Data on /Volumes/Shared\\040Data (nfs, nodev, nosuid)\n" diff --git a/internal/engine/dmi.go b/internal/engine/dmi.go index 11a83271..4767ed27 100644 --- a/internal/engine/dmi.go +++ b/internal/engine/dmi.go @@ -73,12 +73,16 @@ func dmiBIOSVendor(dmi map[string]any) string { } func currentFreeBSDDMIFacts(s *Session) []ResolvedFact { - if runtime.GOOS != "freebsd" { + return currentFreeBSDDMIFactsForPlatform(runtime.GOOS, s.commandOutput) +} + +func currentFreeBSDDMIFactsForPlatform(goos string, run commandRunner) []ResolvedFact { + if goos != "freebsd" { return nil } values := make(map[string]string, len(freeBSDDMIKeys)) for _, key := range freeBSDDMIKeys { - values[key] = s.commandOutput("/bin/kenv", key) + values[key] = run("/bin/kenv", key) } return freeBSDDMIFacts(values) } @@ -87,53 +91,69 @@ func currentFreeBSDDMIFacts(s *Session) []ResolvedFact { // FreeBSD builder. kenv is PATH-resolved here (DragonFly ships it outside // FreeBSD's /bin/kenv path). func currentDragonFlyDMIFacts(s *Session) []ResolvedFact { - if runtime.GOOS != "dragonfly" { + return currentDragonFlyDMIFactsForPlatform(runtime.GOOS, s.commandOutput) +} + +func currentDragonFlyDMIFactsForPlatform(goos string, run commandRunner) []ResolvedFact { + if goos != "dragonfly" { return nil } values := make(map[string]string, len(freeBSDDMIKeys)) for _, key := range freeBSDDMIKeys { - values[key] = s.commandOutput("kenv", key) + values[key] = run("kenv", key) } if facts := freeBSDDMIFacts(values); len(facts) > 0 { return facts } return dragonFlyDMIDecodeFacts( - s.commandOutput("/usr/local/sbin/dmidecode", "-t", "bios"), - s.commandOutput("/usr/local/sbin/dmidecode", "-t", "system"), - s.commandOutput("/usr/local/sbin/dmidecode", "-t", "chassis"), + run("/usr/local/sbin/dmidecode", "-t", "bios"), + run("/usr/local/sbin/dmidecode", "-t", "system"), + run("/usr/local/sbin/dmidecode", "-t", "chassis"), ) } func currentOpenBSDDMIFacts(s *Session) []ResolvedFact { - if runtime.GOOS != "openbsd" { + return currentOpenBSDDMIFactsForPlatform(runtime.GOOS, s.commandOutput) +} + +func currentOpenBSDDMIFactsForPlatform(goos string, run commandRunner) []ResolvedFact { + if goos != "openbsd" { return nil } values := make(map[string]string, len(openBSDDMIKeys)) for _, key := range openBSDDMIKeys { - values[key] = s.commandOutput("/sbin/sysctl", "-n", key) + values[key] = run("/sbin/sysctl", "-n", key) } return openBSDDMIFacts(values) } func currentNetBSDDMIFacts(s *Session) []ResolvedFact { - if runtime.GOOS != "netbsd" { + return currentNetBSDDMIFactsForPlatform(runtime.GOOS, s.commandOutput) +} + +func currentNetBSDDMIFactsForPlatform(goos string, run commandRunner) []ResolvedFact { + if goos != "netbsd" { return nil } values := make(map[string]string, len(netBSDDMIKeys)) for _, key := range netBSDDMIKeys { - values[key] = s.commandOutput("/sbin/sysctl", "-n", key) + values[key] = run("/sbin/sysctl", "-n", key) } return netBSDDMIFacts(values) } func currentIllumosDMIFacts(s *Session) []ResolvedFact { - if runtime.GOOS != "illumos" { + return currentIllumosDMIFactsForPlatform(runtime.GOOS, s.commandOutput) +} + +func currentIllumosDMIFactsForPlatform(goos string, run commandRunner) []ResolvedFact { + if goos != "illumos" { return nil } return illumosDMIFacts( - s.commandOutput("/usr/sbin/smbios", "-t", "SMB_TYPE_BIOS"), - s.commandOutput("/usr/sbin/smbios", "-t", "SMB_TYPE_SYSTEM"), - s.commandOutput("/usr/sbin/smbios", "-t", "SMB_TYPE_CHASSIS"), + run("/usr/sbin/smbios", "-t", "SMB_TYPE_BIOS"), + run("/usr/sbin/smbios", "-t", "SMB_TYPE_SYSTEM"), + run("/usr/sbin/smbios", "-t", "SMB_TYPE_CHASSIS"), ) } diff --git a/internal/engine/dmi_test.go b/internal/engine/dmi_test.go index fb6f3760..42087ee3 100644 --- a/internal/engine/dmi_test.go +++ b/internal/engine/dmi_test.go @@ -97,6 +97,30 @@ func TestDMIFacts_omittedWhenNoDataResolves(t *testing.T) { } } +func TestDMIBIOSVendorReadsNestedVendorOnly(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + dmi map[string]any + want string + }{ + {name: "missing bios", dmi: map[string]any{"manufacturer": "Acme"}, want: ""}, + {name: "wrong bios shape", dmi: map[string]any{"bios": "Acme BIOS"}, want: ""}, + {name: "missing vendor", dmi: map[string]any{"bios": map[string]any{"version": "1.0"}}, want: ""}, + {name: "vendor", dmi: map[string]any{"bios": map[string]any{"vendor": "SeaBIOS"}}, want: "SeaBIOS"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + if got := dmiBIOSVendor(tt.dmi); got != tt.want { + t.Fatalf("dmiBIOSVendor() = %q, want %q", got, tt.want) + } + }) + } +} + func TestParseLinuxOSRelease_splitsDebianMajorAndMinorRelease(t *testing.T) { got := parseLinuxOSRelease("ID=debian\nVERSION_ID=10.02\n") @@ -293,6 +317,28 @@ func TestDragonFlyDMIFacts_fallsBackToDMIDecodeWhenKenvHasNoSMBIOS(t *testing.T) } } +func TestDragonFlyDMIFactsPrefersKenvSMBIOSValues(t *testing.T) { + values := map[string]string{ + "smbios.system.maker": "DragonFly Maker", + "smbios.system.product": "DragonFly Product", + } + + facts := dragonFlyDMIFacts(values, "Vendor: dmidecode BIOS", "Manufacturer: dmidecode", "Type: Other") + collection := Collection(facts) + + want := map[string]any{ + "dmi": map[string]any{ + "manufacturer": "DragonFly Maker", + "product": map[string]any{ + "name": "DragonFly Product", + }, + }, + } + if !reflect.DeepEqual(collection, want) { + t.Fatalf("dragonFlyDMIFacts() = %#v, want %#v", collection, want) + } +} + func TestOpenBSDDMIFacts_returnsStructuredFacts(t *testing.T) { values := map[string]string{ "hw.vendor": "Phoenix Technologies LTD", @@ -326,6 +372,170 @@ func TestOpenBSDDMIFacts_returnsStructuredFacts(t *testing.T) { } } +func TestBSDDMIFactsOmitEmptyValues(t *testing.T) { + if got := openBSDDMIFacts(nil); got != nil { + t.Fatalf("openBSDDMIFacts(nil) = %#v, want nil", got) + } + if got := netBSDDMIFacts(map[string]string{"machdep.dmi.system-vendor": " "}); got != nil { + t.Fatalf("netBSDDMIFacts(blank) = %#v, want nil", got) + } +} + +func TestCurrentBSDDMIFactsQueryPlatformSources(t *testing.T) { + t.Parallel() + + t.Run("freebsd", func(t *testing.T) { + t.Parallel() + + calls := map[string]bool{} + facts := currentFreeBSDDMIFactsForPlatform("freebsd", func(name string, args ...string) string { + if name != "/bin/kenv" || len(args) != 1 { + t.Fatalf("run(%q, %#v), want /bin/kenv ", name, args) + } + calls[args[0]] = true + if args[0] == "smbios.system.maker" { + return "FreeBSD Maker\n" + } + return "" + }) + if got := Collection(facts)["dmi"].(map[string]any)["manufacturer"]; got != "FreeBSD Maker" { + t.Fatalf("freebsd manufacturer = %#v, want FreeBSD Maker", got) + } + for _, key := range freeBSDDMIKeys { + if !calls[key] { + t.Fatalf("freebsd DMI did not query %s", key) + } + } + }) + + t.Run("dragonfly", func(t *testing.T) { + t.Parallel() + + calls := map[string]bool{} + facts := currentDragonFlyDMIFactsForPlatform("dragonfly", func(name string, args ...string) string { + key := fakeRunKey(name, args...) + calls[key] = true + switch key { + case fakeRunKey("kenv", "smbios.system.maker"): + return "DragonFly Maker\n" + case fakeRunKey("/usr/local/sbin/dmidecode", "-t", "system"): + return "Manufacturer: fallback\n" + default: + return "" + } + }) + if got := Collection(facts)["dmi"].(map[string]any)["manufacturer"]; got != "DragonFly Maker" { + t.Fatalf("dragonfly manufacturer = %#v, want DragonFly Maker", got) + } + if calls[fakeRunKey("/usr/local/sbin/dmidecode", "-t", "system")] { + t.Fatal("dragonfly DMI queried dmidecode despite kenv SMBIOS data") + } + for _, key := range freeBSDDMIKeys { + if !calls[fakeRunKey("kenv", key)] { + t.Fatalf("dragonfly DMI did not query %s", key) + } + } + }) + + t.Run("openbsd", func(t *testing.T) { + t.Parallel() + + calls := map[string]bool{} + facts := currentOpenBSDDMIFactsForPlatform("openbsd", func(name string, args ...string) string { + if name != "/sbin/sysctl" || len(args) != 2 || args[0] != "-n" { + t.Fatalf("run(%q, %#v), want /sbin/sysctl -n ", name, args) + } + calls[args[1]] = true + if args[1] == "hw.vendor" { + return "OpenBSD Vendor\n" + } + return "" + }) + if got := Collection(facts)["dmi"].(map[string]any)["manufacturer"]; got != "OpenBSD Vendor" { + t.Fatalf("openbsd manufacturer = %#v, want OpenBSD Vendor", got) + } + for _, key := range openBSDDMIKeys { + if !calls[key] { + t.Fatalf("openbsd DMI did not query %s", key) + } + } + }) + + t.Run("netbsd", func(t *testing.T) { + t.Parallel() + + calls := map[string]bool{} + facts := currentNetBSDDMIFactsForPlatform("netbsd", func(name string, args ...string) string { + if name != "/sbin/sysctl" || len(args) != 2 || args[0] != "-n" { + t.Fatalf("run(%q, %#v), want /sbin/sysctl -n ", name, args) + } + calls[args[1]] = true + if args[1] == "machdep.dmi.system-vendor" { + return "NetBSD Vendor\n" + } + return "" + }) + if got := Collection(facts)["dmi"].(map[string]any)["manufacturer"]; got != "NetBSD Vendor" { + t.Fatalf("netbsd manufacturer = %#v, want NetBSD Vendor", got) + } + for _, key := range netBSDDMIKeys { + if !calls[key] { + t.Fatalf("netbsd DMI did not query %s", key) + } + } + }) + + t.Run("illumos", func(t *testing.T) { + t.Parallel() + + calls := map[string]bool{} + facts := currentIllumosDMIFactsForPlatform("illumos", func(name string, args ...string) string { + key := fakeRunKey(name, args...) + calls[key] = true + if key == fakeRunKey("/usr/sbin/smbios", "-t", "SMB_TYPE_SYSTEM") { + return "Manufacturer: illumos Maker\n" + } + return "" + }) + if got := Collection(facts)["dmi"].(map[string]any)["manufacturer"]; got != "illumos Maker" { + t.Fatalf("illumos manufacturer = %#v, want illumos Maker", got) + } + for _, key := range []string{ + fakeRunKey("/usr/sbin/smbios", "-t", "SMB_TYPE_BIOS"), + fakeRunKey("/usr/sbin/smbios", "-t", "SMB_TYPE_SYSTEM"), + fakeRunKey("/usr/sbin/smbios", "-t", "SMB_TYPE_CHASSIS"), + } { + if !calls[key] { + t.Fatalf("illumos DMI did not query %q", key) + } + } + }) +} + +func TestCurrentPlatformDMIFactsSkipOtherPlatforms(t *testing.T) { + t.Parallel() + + run := func(string, ...string) string { + t.Fatal("DMI platform helper ran command for non-matching platform") + return "" + } + if got := currentFreeBSDDMIFactsForPlatform("linux", run); got != nil { + t.Fatalf("currentFreeBSDDMIFactsForPlatform(linux) = %#v, want nil", got) + } + if got := currentDragonFlyDMIFactsForPlatform("linux", run); got != nil { + t.Fatalf("currentDragonFlyDMIFactsForPlatform(linux) = %#v, want nil", got) + } + if got := currentOpenBSDDMIFactsForPlatform("linux", run); got != nil { + t.Fatalf("currentOpenBSDDMIFactsForPlatform(linux) = %#v, want nil", got) + } + if got := currentNetBSDDMIFactsForPlatform("linux", run); got != nil { + t.Fatalf("currentNetBSDDMIFactsForPlatform(linux) = %#v, want nil", got) + } + if got := currentIllumosDMIFactsForPlatform("linux", run); got != nil { + t.Fatalf("currentIllumosDMIFactsForPlatform(linux) = %#v, want nil", got) + } +} + func TestNetBSDDMIFacts_returnsStructuredFacts(t *testing.T) { values := map[string]string{ "machdep.dmi.system-vendor": "QEMU", @@ -407,12 +617,50 @@ func TestIllumosDMIFacts_returnsStructuredFactsFromSMBIOS(t *testing.T) { } } +func TestIllumosDMIFactsFallsBackToChassisTypeKey(t *testing.T) { + chassis := `ID SIZE TYPE +768 41 SMB_TYPE_CHASSIS (type 3) (system enclosure or chassis) + Type: rack +` + + facts := illumosDMIFacts("", "", chassis) + collection := Collection(facts) + + want := map[string]any{ + "dmi": map[string]any{ + "chassis": map[string]any{"type": "rack"}, + }, + } + if !reflect.DeepEqual(collection, want) { + t.Fatalf("illumosDMIFacts() = %#v, want %#v", collection, want) + } +} + func TestIllumosDMIFacts_omitsDMIWhenSMBIOSHasNoValues(t *testing.T) { if got := illumosDMIFacts("", "", ""); got != nil { t.Fatalf("illumosDMIFacts(empty) = %#v, want nil", got) } } +func TestParseWindowsWMIRecordsSkipsMalformedLinesAndSplitsRepeatedNames(t *testing.T) { + input := strings.Join([]string{ + "Name=CPU One", + "malformed", + "NumberOfCores=2", + "Name=CPU Two", + "NumberOfCores=4", + }, "\r\n") + + got := parseWindowsWMIRecords(input) + want := []map[string]string{ + {"Name": "CPU One", "NumberOfCores": "2"}, + {"Name": "CPU Two", "NumberOfCores": "4"}, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("parseWindowsWMIRecords() = %#v, want %#v", got, want) + } +} + func TestMacOSDMIFacts_returnsProductName(t *testing.T) { core := macOSDMIFacts("MacBookPro11,4") diff --git a/internal/engine/ec2.go b/internal/engine/ec2.go index d55e2dbd..992e06bf 100644 --- a/internal/engine/ec2.go +++ b/internal/engine/ec2.go @@ -5,7 +5,6 @@ import ( "io" "net/http" "os" - "runtime" "strconv" "strings" "time" @@ -72,8 +71,12 @@ func ec2Facts(s *Session, client *ec2Client, virt virtualization) []ResolvedFact } func cloudProviderFact(s *Session, virt virtualization, ec2Metadata map[string]any) *ResolvedFact { - if runtime.GOOS == "linux" { - if !linuxAWSCloudProvider(virt.Name, ec2Metadata, os.Geteuid(), fileExecutable, s.commandOutput) { + return cloudProviderFactForPlatform(s.goos(), virt, ec2Metadata, os.Geteuid(), fileExecutable, s.commandOutput) +} + +func cloudProviderFactForPlatform(goos string, virt virtualization, ec2Metadata map[string]any, euid int, executable func(string) bool, run func(string, ...string) string) *ResolvedFact { + if goos == "linux" { + if !linuxAWSCloudProvider(virt.Name, ec2Metadata, euid, executable, run) { return nil } return &ResolvedFact{Name: "cloud.provider", Value: "aws"} diff --git a/internal/engine/ec2_test.go b/internal/engine/ec2_test.go index 64c82547..9793794f 100644 --- a/internal/engine/ec2_test.go +++ b/internal/engine/ec2_test.go @@ -4,7 +4,10 @@ import ( "context" "net/http" "net/http/httptest" + "os" + "path/filepath" "reflect" + "runtime" "testing" "time" ) @@ -220,6 +223,62 @@ func TestCloudProviderFact_skipsEmptyEC2Metadata(t *testing.T) { } } +func TestCloudProviderFactForPlatformRequiresVirtWhatAWSOnLinuxRootKVM(t *testing.T) { + metadata := map[string]any{"instance_type": "c1.medium"} + executable := func(path string) bool { + if path != "/opt/puppetlabs/puppet/bin/virt-what" { + t.Fatalf("executable path = %q, want virt-what path", path) + } + return true + } + + got := cloudProviderFactForPlatform("linux", virtualization{Name: "kvm", IsVirtual: true}, metadata, 0, executable, func(string, ...string) string { + return "kvm\n" + }) + if got != nil { + t.Fatalf("cloudProviderFactForPlatform(linux root kvm) = %#v, want nil", got) + } + + got = cloudProviderFactForPlatform("linux", virtualization{Name: "kvm", IsVirtual: true}, metadata, 0, executable, func(string, ...string) string { + return "kvm\naws\n" + }) + if got == nil || got.Name != "cloud.provider" || got.Value != "aws" { + t.Fatalf("cloudProviderFactForPlatform(linux root aws) = %#v, want aws provider fact", got) + } +} + +func TestCloudProviderFactForPlatformUsesMetadataOnNonLinuxAWSHypervisor(t *testing.T) { + got := cloudProviderFactForPlatform("darwin", virtualization{Name: "xen", IsVirtual: true}, map[string]any{"instance_type": "c1.medium"}, 0, func(string) bool { + t.Fatal("non-linux provider detection must not inspect virt-what") + return false + }, func(string, ...string) string { + t.Fatal("non-linux provider detection must not run virt-what") + return "" + }) + if got == nil || got.Name != "cloud.provider" || got.Value != "aws" { + t.Fatalf("cloudProviderFactForPlatform(non-linux) = %#v, want aws provider fact", got) + } +} + +func TestLinuxAWSCloudProviderRequiresAWSHypervisorAndMetadata(t *testing.T) { + metadata := map[string]any{"instance_type": "c1.medium"} + executable := func(string) bool { + t.Fatal("linuxAWSCloudProvider() checked virt-what before basic guards") + return false + } + run := func(string, ...string) string { + t.Fatal("linuxAWSCloudProvider() ran virt-what before basic guards") + return "" + } + + if linuxAWSCloudProvider("docker", metadata, 0, executable, run) { + t.Fatal("linuxAWSCloudProvider(non-AWS hypervisor) = true, want false") + } + if linuxAWSCloudProvider("kvm", nil, 0, executable, run) { + t.Fatal("linuxAWSCloudProvider(empty metadata) = true, want false") + } +} + func TestLinuxAWSCloudProviderRequiresVirtWhatAWSForRootKVM(t *testing.T) { metadata := map[string]any{"instance_type": "c1.medium"} executable := func(string) bool { return true } @@ -244,6 +303,38 @@ func TestLinuxAWSCloudProviderRequiresVirtWhatAWSForRootKVM(t *testing.T) { } } +func TestFileExecutableRequiresRegularExecutableFile(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX executable mode bits are not portable on Windows") + } + + dir := t.TempDir() + executable := filepath.Join(dir, "virt-what") + if err := os.WriteFile(executable, []byte("#!/bin/sh\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Chmod(executable, 0o700); err != nil { + t.Fatal(err) + } + if !fileExecutable(executable) { + t.Fatal("fileExecutable(executable) = false, want true") + } + + notExecutable := filepath.Join(dir, "not-executable") + if err := os.WriteFile(notExecutable, []byte("data"), 0o600); err != nil { + t.Fatal(err) + } + if fileExecutable(notExecutable) { + t.Fatal("fileExecutable(non-executable) = true, want false") + } + if fileExecutable(dir) { + t.Fatal("fileExecutable(directory) = true, want false") + } + if fileExecutable(filepath.Join(dir, "missing")) { + t.Fatal("fileExecutable(missing) = true, want false") + } +} + func TestGCEFacts_fetchesMetadataAndCloudProvider(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if got := r.Header.Get("Metadata-Flavor"); got != "Google" { diff --git a/internal/engine/engine_test.go b/internal/engine/engine_test.go new file mode 100644 index 00000000..1d813f56 --- /dev/null +++ b/internal/engine/engine_test.go @@ -0,0 +1,387 @@ +package engine + +import ( + "context" + "errors" + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +func TestNewEngineValidatesProgrammaticFactNames(t *testing.T) { + tests := []struct { + name string + facts []ProgrammaticFact + wantErr string + }{ + { + name: "empty name", + facts: []ProgrammaticFact{{Name: ""}}, + wantErr: "fact 0: name is empty", + }, + { + name: "null byte", + facts: []ProgrammaticFact{{Name: "site\x00role"}}, + wantErr: `fact "site\x00role": name contains a null byte`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := NewEngine(EngineConfig{Facts: tt.facts}) + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("NewEngine() err = %v, want containing %q", err, tt.wantErr) + } + }) + } +} + +func TestNewEngineFreezesConfigAndNormalizesFactNames(t *testing.T) { + externalDirs := []string{"/explicit"} + blocked := map[string]bool{"networking": true} + defaultDirs := []string{"/default"} + config := Config{ + Blocklist: []string{"ssh"}, + ExternalDirs: []string{"/config"}, + TTLs: []FactTTL{{Fact: "site_role", TTL: "30 days"}}, + FactGroups: []FactGroup{{Name: "site", Facts: []string{"site_role"}}}, + } + facts := []ProgrammaticFact{{ + Name: "Site.Role", + Resolve: func(context.Context) (any, error) { + return "web", nil + }, + }} + + eng, err := NewEngine(EngineConfig{ + ExternalDirs: externalDirs, + BlockedFacts: blocked, + ConfigLoaded: true, + Config: config, + DefaultExternalDirsSet: true, + DefaultExternalDirs: defaultDirs, + Facts: facts, + }) + if err != nil { + t.Fatal(err) + } + + externalDirs[0] = "/mutated-explicit" + blocked["networking"] = false + defaultDirs[0] = "/mutated-default" + config.Blocklist[0] = "mutated-blocklist" + config.ExternalDirs[0] = "/mutated-config" + config.TTLs[0] = FactTTL{Fact: "mutated", TTL: "1 second"} + config.FactGroups[0].Facts[0] = "mutated_group_fact" + facts[0].Name = "Mutated" + + if got, want := eng.cfg.ExternalDirs, []string{"/explicit"}; !reflect.DeepEqual(got, want) { + t.Fatalf("ExternalDirs = %#v, want %#v", got, want) + } + if got := eng.cfg.BlockedFacts["networking"]; !got { + t.Fatalf("BlockedFacts[networking] = false, want frozen true") + } + if got, want := eng.cfg.DefaultExternalDirs, []string{"/default"}; !reflect.DeepEqual(got, want) { + t.Fatalf("DefaultExternalDirs = %#v, want %#v", got, want) + } + if got, want := eng.cfg.Config.Blocklist, []string{"ssh"}; !reflect.DeepEqual(got, want) { + t.Fatalf("Config.Blocklist = %#v, want %#v", got, want) + } + if got, want := eng.cfg.Config.ExternalDirs, []string{"/config"}; !reflect.DeepEqual(got, want) { + t.Fatalf("Config.ExternalDirs = %#v, want %#v", got, want) + } + if got, want := eng.cfg.Config.TTLs, []FactTTL{{Fact: "site_role", TTL: "30 days"}}; !reflect.DeepEqual(got, want) { + t.Fatalf("Config.TTLs = %#v, want %#v", got, want) + } + if got, want := eng.cfg.Config.FactGroups, []FactGroup{{Name: "site", Facts: []string{"site_role"}}}; !reflect.DeepEqual(got, want) { + t.Fatalf("Config.FactGroups = %#v, want %#v", got, want) + } + if got := eng.cfg.Facts[0].Name; got != "site.role" { + t.Fatalf("Facts[0].Name = %q, want lowercase site.role", got) + } + if got, err := eng.cfg.Facts[0].Resolve(context.Background()); err != nil || got != "web" { + t.Fatalf("Facts[0].Resolve() = %#v, %v, want web", got, err) + } +} + +func TestEngineWarnOnceDeduplicatesMessages(t *testing.T) { + var warnings []string + eng, err := NewEngine(EngineConfig{Logger: captureLogger(nil, &warnings, nil)}) + if err != nil { + t.Fatal(err) + } + + eng.warnOnce("same warning") + eng.warnOnce("same warning") + eng.warnOnce("different warning") + + want := []string{"same warning", "different warning"} + if !reflect.DeepEqual(warnings, want) { + t.Fatalf("warnings = %#v, want %#v", warnings, want) + } +} + +func TestPlanDiscoveryMergesLoadedConfig(t *testing.T) { + config := Config{ + ExternalDirs: []string{"/config"}, + NoExternalFacts: true, + Blocklist: []string{"site"}, + TTLs: []FactTTL{{Fact: "site_role", TTL: "1 day"}}, + FactGroups: []FactGroup{{Name: "site", Facts: []string{"site_role", "site_location"}}}, + ForceDotResolution: true, + } + eng, err := NewEngine(EngineConfig{ + ConfigLoaded: true, + Config: config, + UseCache: true, + CLICompat: true, + }) + if err != nil { + t.Fatal(err) + } + config.ExternalDirs[0] = "/mutated" + config.FactGroups[0].Facts[0] = "mutated" + + plan, failures := eng.planDiscovery(NewSession(), []string{"site_role"}) + if len(failures) != 0 { + t.Fatalf("failures = %#v, want none", failures) + } + if got, want := plan.externalDirs, []string{"/config"}; !reflect.DeepEqual(got, want) { + t.Fatalf("externalDirs = %#v, want %#v", got, want) + } + if !plan.noExternalFacts { + t.Fatal("noExternalFacts = false, want config to disable external facts") + } + if !plan.useCache { + t.Fatal("useCache = false, want EngineConfig.UseCache") + } + if got, want := plan.cacheTTLs, []FactTTL{{Fact: "site_role", TTL: "1 day"}}; !reflect.DeepEqual(got, want) { + t.Fatalf("cacheTTLs = %#v, want %#v", got, want) + } + if got, want := plan.cacheGroups, []FactGroup{{Name: "site", Facts: []string{"site_role", "site_location"}}}; !reflect.DeepEqual(got, want) { + t.Fatalf("cacheGroups = %#v, want %#v", got, want) + } + if !plan.blockedFacts["site_role"] || !plan.blockedFacts["site_location"] { + t.Fatalf("blockedFacts = %#v, want configured group expanded", plan.blockedFacts) + } + if plan.loaderMode != externalFactLoaderCLI { + t.Fatalf("loaderMode = %v, want CLI", plan.loaderMode) + } + if !plan.includeEnv { + t.Fatal("includeEnv = false, want CLICompat environment facts") + } + if got, want := plan.queries, []string{"site_role"}; !reflect.DeepEqual(got, want) { + t.Fatalf("queries = %#v, want %#v", got, want) + } + if !plan.includeTypedDotted { + t.Fatal("includeTypedDotted = false, want force-dot-resolution from CLI config") + } +} + +func TestPlanDiscoveryPreservesExplicitInputsAndUsesDefaultDirs(t *testing.T) { + eng, err := NewEngine(EngineConfig{ + ExternalDirs: []string{"/explicit"}, + BlockedFacts: map[string]bool{"explicit": true}, + ConfigLoaded: true, + Config: Config{ExternalDirs: []string{"/config"}, Blocklist: []string{"config"}}, + SystemDefaults: true, + DefaultExternalDirsSet: true, + DefaultExternalDirs: []string{"/default"}, + }) + if err != nil { + t.Fatal(err) + } + plan, failures := eng.planDiscovery(NewSession(), nil) + if len(failures) != 0 { + t.Fatalf("failures = %#v, want none", failures) + } + if got, want := plan.externalDirs, []string{"/explicit"}; !reflect.DeepEqual(got, want) { + t.Fatalf("externalDirs = %#v, want explicit dirs %#v", got, want) + } + if got, want := plan.blockedFacts, map[string]bool{"explicit": true}; !reflect.DeepEqual(got, want) { + t.Fatalf("blockedFacts = %#v, want explicit blocklist %#v", got, want) + } + + defaultEng, err := NewEngine(EngineConfig{ + SystemDefaults: true, + DefaultExternalDirsSet: true, + DefaultExternalDirs: []string{"/default"}, + }) + if err != nil { + t.Fatal(err) + } + defaultPlan, failures := defaultEng.planDiscovery(NewSession(), nil) + if len(failures) != 0 { + t.Fatalf("default failures = %#v, want none", failures) + } + if got, want := defaultPlan.externalDirs, []string{"/default"}; !reflect.DeepEqual(got, want) { + t.Fatalf("default externalDirs = %#v, want %#v", got, want) + } + defaultPlan.externalDirs[0] = "/mutated-plan" + nextPlan, _ := defaultEng.planDiscovery(NewSession(), nil) + if got, want := nextPlan.externalDirs, []string{"/default"}; !reflect.DeepEqual(got, want) { + t.Fatalf("next default externalDirs = %#v, want clone %#v", got, want) + } +} + +func TestDefaultExternalDirsWithoutOverrideUsesPlatformDefaults(t *testing.T) { + eng, err := NewEngine(EngineConfig{}) + if err != nil { + t.Fatal(err) + } + + if got, want := eng.defaultExternalDirs(), CurrentDefaultExternalFactDirs(); !reflect.DeepEqual(got, want) { + t.Fatalf("defaultExternalDirs() = %#v, want platform defaults %#v", got, want) + } +} + +func TestEngineDiscoverResolvesRegisteredAndExternalFacts(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "site.txt"), []byte("external_site=lab\n"), 0o600); err != nil { + t.Fatal(err) + } + resolverErr := errors.New("boom") + var warnings []string + eng, err := NewEngine(EngineConfig{ + Logger: captureLogger(nil, &warnings, nil), + ExternalDirs: []string{dir}, + Facts: []ProgrammaticFact{ + { + Name: "site_map", + Resolve: func(context.Context) (any, error) { + return map[string]string{"role": "web"}, nil + }, + }, + {Name: "blank"}, + { + Name: "bad_value", + Resolve: func(context.Context) (any, error) { + return "bad\x00value", nil + }, + }, + { + Name: "failing", + Resolve: func(context.Context) (any, error) { + return nil, resolverErr + }, + }, + }, + }) + if err != nil { + t.Fatal(err) + } + + snap, err := eng.Discover(nil, "site_map.role", "external_site", "blank", "bad_value", "failing") + if !errors.Is(err, resolverErr) || err.Error() != "fact failing: boom" { + t.Fatalf("Discover() err = %v, want only resolver error", err) + } + if snap == nil { + t.Fatal("Discover() snapshot = nil, want partial snapshot") + } + if got, err := snap.Value("site_map.role"); err != nil || got != "web" { + t.Fatalf("Value(site_map.role) = %#v, %v, want web", got, err) + } + if got, err := snap.Value("external_site"); err != nil || got != "lab" { + t.Fatalf("Value(external_site) = %#v, %v, want lab", got, err) + } + if got, err := snap.Value("blank"); err != nil || got != nil { + t.Fatalf("Value(blank) = %#v, %v, want resolved nil", got, err) + } + if got, err := snap.Value("bad_value"); err != nil || got != nil { + t.Fatalf("Value(bad_value) = %#v, %v, want rejected nil value", got, err) + } + if got, err := snap.Value("failing"); !errors.Is(err, ErrFactNotFound) || got != nil { + t.Fatalf("Value(failing) = %#v, %v, want fact not found", got, err) + } + if want := []string{"custom fact value contains a null byte reference"}; !reflect.DeepEqual(warnings, want) { + t.Fatalf("warnings = %#v, want %#v", warnings, want) + } +} + +func TestEngineDiscoverReturnsPartialSnapshotWhenContextCancelled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + ran := false + eng, err := NewEngine(EngineConfig{ + Facts: []ProgrammaticFact{{ + Name: "should_not_run", + Resolve: func(context.Context) (any, error) { + ran = true + return "ran", nil + }, + }}, + }) + if err != nil { + t.Fatal(err) + } + + snap, err := eng.Discover(ctx) + if !errors.Is(err, context.Canceled) { + t.Fatalf("Discover() err = %v, want context.Canceled", err) + } + if snap == nil { + t.Fatal("Discover() snapshot = nil, want partial snapshot") + } + if ran { + t.Fatal("registered resolver ran after context cancellation") + } +} + +func TestEngineDiscoverUsesCachedValueForConfiguredFacts(t *testing.T) { + cacheDir := t.TempDir() + oldDefaultCachePath := DefaultCachePath + DefaultCachePath = func() string { return cacheDir } + t.Cleanup(func() { DefaultCachePath = oldDefaultCachePath }) + + eng, err := NewEngine(EngineConfig{ + UseCache: true, + ConfigLoaded: true, + Config: Config{TTLs: []FactTTL{{Fact: "cache_probe", TTL: "30 days"}}}, + Facts: []ProgrammaticFact{{ + Name: "cache_probe", + Resolve: func(context.Context) (any, error) { + return "cached", nil + }, + }}, + }) + if err != nil { + t.Fatal(err) + } + + snap, err := eng.Discover(context.Background()) + if err != nil { + t.Fatalf("Discover() err = %v, want nil", err) + } + if got, err := snap.Value("cache_probe"); err != nil || got != "cached" { + t.Fatalf("Value(cache_probe) = %#v, %v, want cached", got, err) + } + data := readJSONFile(t, filepath.Join(cacheDir, "cache_probe")) + if got := data["cache_probe"]; got != "cached" { + t.Fatalf("cached cache_probe = %#v, want cached", got) + } + + // The current cache contract is value precedence: a fresh cache entry wins. + cachedEng, err := NewEngine(EngineConfig{ + UseCache: true, + ConfigLoaded: true, + Config: Config{TTLs: []FactTTL{{Fact: "cache_probe", TTL: "30 days"}}}, + Facts: []ProgrammaticFact{{ + Name: "cache_probe", + Resolve: func(context.Context) (any, error) { + return "fresh", nil + }, + }}, + }) + if err != nil { + t.Fatal(err) + } + cachedSnap, err := cachedEng.Discover(context.Background()) + if err != nil { + t.Fatalf("cached Discover() err = %v, want nil", err) + } + if got, err := cachedSnap.Value("cache_probe"); err != nil || got != "cached" { + t.Fatalf("cached Value(cache_probe) = %#v, %v, want cache file value", got, err) + } +} diff --git a/internal/engine/external_test.go b/internal/engine/external_test.go index 44a9c572..b6165cca 100644 --- a/internal/engine/external_test.go +++ b/internal/engine/external_test.go @@ -2,6 +2,7 @@ package engine import ( "context" + "encoding/json" "errors" "fmt" "io" @@ -77,6 +78,41 @@ func (h *fakeExternalFactLoaderHost) readDir(dir string) ([]os.DirEntry, error) return h.externalFactOSHost.readDir(dir) } +type errorInfoDirEntry struct { + name string + err error +} + +func (de errorInfoDirEntry) Name() string { return de.name } +func (de errorInfoDirEntry) IsDir() bool { return false } +func (de errorInfoDirEntry) Type() os.FileMode { return 0 } +func (de errorInfoDirEntry) Info() (os.FileInfo, error) { return nil, de.err } + +func TestExternalFactFileReadableRequiresRegularFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "site.txt") + if err := os.WriteFile(path, []byte("site=lab\n"), 0o600); err != nil { + t.Fatal(err) + } + + host := externalFactOSHost{} + if !host.fileReadable(path) { + t.Fatalf("externalFactOSHost.fileReadable(%q) = false, want true", path) + } + if !fileReadable(path) { + t.Fatalf("fileReadable(%q) = false, want true", path) + } + if host.fileReadable(dir) { + t.Fatalf("externalFactOSHost.fileReadable(%q) = true, want false for directory", dir) + } + if fileReadable(dir) { + t.Fatalf("fileReadable(%q) = true, want false for directory", dir) + } + if fileReadable(filepath.Join(dir, "missing.txt")) { + t.Fatal("fileReadable(missing) = true, want false") + } +} + func TestExternalFactLoader_cliModeIncludesEnvironmentAndSkipsExecutableFailures(t *testing.T) { dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, "site.txt"), []byte("site=lab\n"), 0o600); err != nil { @@ -162,6 +198,155 @@ func TestExternalFactLoader_libraryModeReturnsPartialFailuresAndControlsEnvironm } } +func TestExternalFactLoader_libraryModeCollectsDirectoryAndStatFailures(t *testing.T) { + host := &fakeExternalFactLoaderHost{ + readDirFunc: func(dir string) ([]os.DirEntry, error) { + switch dir { + case "missing": + return nil, os.ErrNotExist + case "bad": + return nil, os.ErrPermission + case "stat": + return []os.DirEntry{errorInfoDirEntry{name: "site.txt", err: os.ErrPermission}}, nil + default: + t.Fatalf("readDir(%q) called, want only configured directories", dir) + return nil, nil + } + }, + } + + got, err := externalFactLoader{ + s: testSession, + mode: externalFactLoaderLibrary, + dirs: []string{"missing", "bad", "stat"}, + host: host, + }.load() + if err == nil { + t.Fatal("externalFactLoader.load() err = nil, want joined library-mode failures") + } + if len(got) != 0 { + t.Fatalf("externalFactLoader.load() = %#v, want no facts", got) + } + for _, want := range []string{"read external dir bad", filepath.Join("stat", "site.txt")} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("externalFactLoader.load() err = %v, want message containing %q", err, want) + } + } + if !errors.Is(err, os.ErrPermission) { + t.Fatalf("externalFactLoader.load() err = %v, want os.ErrPermission", err) + } +} + +func TestExternalFactLoader_cliModeReturnsDirectoryReadFailure(t *testing.T) { + host := &fakeExternalFactLoaderHost{ + readDirFunc: func(dir string) ([]os.DirEntry, error) { + if dir != "bad" { + t.Fatalf("readDir(%q), want bad", dir) + } + return nil, os.ErrPermission + }, + } + + got, err := externalFactLoader{ + s: testSession, + mode: externalFactLoaderCLI, + dirs: []string{"bad"}, + host: host, + }.load() + if err == nil || !errors.Is(err, os.ErrPermission) { + t.Fatalf("externalFactLoader.load() err = %v, want os.ErrPermission", err) + } + if got != nil { + t.Fatalf("externalFactLoader.load() = %#v, want nil facts", got) + } +} + +func TestExternalFactLoader_handlesInvalidEnvironmentFactsByMode(t *testing.T) { + host := &fakeExternalFactLoaderHost{env: []string{"FACTS_bad=bad\x00value"}} + + got, err := externalFactLoader{ + s: testSession, + mode: externalFactLoaderCLI, + host: host, + includeEnv: true, + }.load() + if err == nil || !errors.Is(err, ErrNullByte) { + t.Fatalf("CLI load err = %v, want ErrNullByte", err) + } + if got != nil { + t.Fatalf("CLI load facts = %#v, want nil", got) + } + + got, err = externalFactLoader{ + s: testSession, + mode: externalFactLoaderLibrary, + host: host, + includeEnv: true, + }.load() + if err == nil || !errors.Is(err, ErrNullByte) { + t.Fatalf("library load err = %v, want ErrNullByte", err) + } + if len(got) != 0 { + t.Fatalf("library load facts = %#v, want none", got) + } +} + +func TestExternalFactLoader_skipsBlockedDirsHiddenAndBackupFilesBeforeOpen(t *testing.T) { + var debugMessages []string + s := NewSession() + s.logger = captureLogger(&debugMessages, nil, nil) + + var opened []string + sitePath := filepath.Join("facts.d", "site.txt") + host := &fakeExternalFactLoaderHost{ + readDirFunc: func(dir string) ([]os.DirEntry, error) { + if dir != "facts.d" { + t.Fatalf("readDir(%q) called, want facts.d", dir) + } + return []os.DirEntry{ + fakeDirEntry{name: "site.txt"}, + fakeDirEntry{name: "old.bak"}, + fakeDirEntry{name: ".hidden.txt"}, + fakeDirEntry{name: "subdir", mode: os.ModeDir, isDir: true}, + fakeDirEntry{name: "blocked.txt"}, + }, nil + }, + openFunc: func(path string) (io.ReadCloser, error) { + opened = append(opened, path) + if path != sitePath { + return nil, fmt.Errorf("unexpected open %s", path) + } + return io.NopCloser(strings.NewReader("site=lab\n")), nil + }, + } + + got, err := externalFactLoader{ + s: s, + mode: externalFactLoaderCLI, + dirs: []string{"facts.d"}, + blocked: map[string]bool{"blocked.txt": true}, + host: host, + }.load() + if err != nil { + t.Fatalf("externalFactLoader.load() err = %v, want nil", err) + } + wantFacts := []ResolvedFact{{Name: "site", Value: "lab", Type: "external"}} + if !reflect.DeepEqual(got, wantFacts) { + t.Fatalf("externalFactLoader.load() = %#v, want %#v", got, wantFacts) + } + if !reflect.DeepEqual(opened, []string{sitePath}) { + t.Fatalf("opened paths = %#v, want only %#v", opened, []string{sitePath}) + } + for _, want := range []string{ + "External fact file blocked.txt blocked.", + "External fact file old.bak ignored: .bak extension.", + } { + if !slices.Contains(debugMessages, want) { + t.Fatalf("debug messages = %#v, want %q", debugMessages, want) + } + } +} + func TestExternalFactLoader_cliModeSkipsCancelledExecutableFailures(t *testing.T) { dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, "cancelled_fact"), []byte("ignored=true\n"), 0o700); err != nil { @@ -1438,6 +1623,48 @@ func TestLimitedBuffer_marksOverflowAndRetainsPrefix(t *testing.T) { } } +func TestLimitedBuffer_enforcesLimitAndCancelsOnce(t *testing.T) { + cancelled := 0 + buf := limitedBuffer{ + limit: 5, + cancel: func() { cancelled++ }, + } + + n, err := buf.Write([]byte("hello!")) + if !errors.Is(err, ErrExternalFactTooLarge) { + t.Fatalf("Write() err = %v, want ErrExternalFactTooLarge", err) + } + if n != 5 { + t.Fatalf("Write() n = %d, want retained byte count", n) + } + if got := string(buf.Bytes()); got != "hello" { + t.Fatalf("limitedBuffer bytes = %q, want retained prefix", got) + } + if cancelled != 1 { + t.Fatalf("cancel count = %d, want 1", cancelled) + } + + n, err = buf.Write([]byte("again")) + if !errors.Is(err, ErrExternalFactTooLarge) { + t.Fatalf("second Write() err = %v, want ErrExternalFactTooLarge", err) + } + if n != 0 { + t.Fatalf("second Write() n = %d, want 0", n) + } + if cancelled != 1 { + t.Fatalf("cancel count after second Write = %d, want still 1", cancelled) + } + + var empty limitedBuffer + n, err = empty.Write([]byte("x")) + if !errors.Is(err, ErrExternalFactTooLarge) { + t.Fatalf("zero-limit Write() err = %v, want ErrExternalFactTooLarge", err) + } + if n != 0 { + t.Fatalf("zero-limit Write() n = %d, want 0", n) + } +} + func TestLoadExternalFacts_executableYAMLFacts(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("extensionless shell-script external facts are a POSIX mechanism; Windows executables use .bat/.cmd/.exe/.ps1") @@ -1504,6 +1731,51 @@ func TestLoadExternalFacts_executableYAMLTimestampNormalizesLikeRubyParser(t *te } } +func TestNormalizeExecutableYAMLValueNormalizesNestedTimestamps(t *testing.T) { + t.Parallel() + + input := map[string]any{ + "timestamps": []any{ + "2020-07-15 05:38:12.427678398 +00:00", + map[string]any{"plain": "not a timestamp"}, + }, + "count": 2, + } + got := normalizeExecutableYAMLValue(input) + want := map[string]any{ + "timestamps": []any{ + "2020-07-15T05:38:12Z", + map[string]any{"plain": "not a timestamp"}, + }, + "count": 2, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("normalizeExecutableYAMLValue() = %#v, want %#v", got, want) + } +} + +func TestNormalizeStructuredValueConvertsJSONNumbersRecursively(t *testing.T) { + t.Parallel() + + input := map[string]any{ + "small": json.Number("42"), + "big": json.Number("2147483648"), + "float": []any{json.Number("3.5"), json.Number("not-a-number")}, + } + got, err := normalizeStructuredValue(input) + if err != nil { + t.Fatalf("normalizeStructuredValue() err = %v, want nil", err) + } + want := map[string]any{ + "small": 42, + "big": int64(2147483648), + "float": []any{3.5, "not-a-number"}, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("normalizeStructuredValue() = %#v, want %#v", got, want) + } +} + func TestLoadExternalFacts_executableJSONFacts(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("extensionless shell-script external facts are a POSIX mechanism; Windows executables use .bat/.cmd/.exe/.ps1") @@ -1584,6 +1856,31 @@ func TestLoadExternalFacts_rejectsNullBytes(t *testing.T) { } } +func TestYAMLErrorIsNullByteRecognizesParserMessages(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + err error + want bool + }{ + {name: "nil", err: nil, want: false}, + {name: "yaml control character", err: errors.New("yaml: control characters are not allowed"), want: true}, + {name: "yaml null codepoint", err: errors.New("yaml: did not find expected key near #x0000"), want: true}, + {name: "ordinary parse error", err: errors.New("yaml: did not find expected key"), want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + if got := yamlErrorIsNullByte(tt.err); got != tt.want { + t.Fatalf("yamlErrorIsNullByte(%v) = %v, want %v", tt.err, got, tt.want) + } + }) + } +} + func TestExternalFactGroups_includesEveryExternalDirectoryEntry(t *testing.T) { dir := t.TempDir() entries := map[string]os.FileMode{ @@ -1615,6 +1912,22 @@ func TestExternalFactGroups_includesEveryExternalDirectoryEntry(t *testing.T) { } } +func TestExternalFactGroupsSkipsMissingDirectories(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "site.txt"), []byte("site=lab\n"), 0o600); err != nil { + t.Fatal(err) + } + + got, err := ExternalFactGroups([]string{filepath.Join(t.TempDir(), "missing"), dir}) + if err != nil { + t.Fatal(err) + } + want := []FactGroup{{Name: "site.txt"}} + if !reflect.DeepEqual(got, want) { + t.Fatalf("ExternalFactGroups() = %#v, want %#v", got, want) + } +} + func BenchmarkLoadExternalFacts(b *testing.B) { dir := b.TempDir() files := map[string]string{ diff --git a/internal/engine/fact_test.go b/internal/engine/fact_test.go new file mode 100644 index 00000000..7a3c7bc0 --- /dev/null +++ b/internal/engine/fact_test.go @@ -0,0 +1,24 @@ +package engine + +import "testing" + +func TestFactTypeLabelMatchesRubyResolverMessages(t *testing.T) { + tests := []struct { + factType string + want string + }{ + {factType: "", want: "Fact"}, + {factType: "external", want: "External"}, + {factType: "custom", want: "Custom"}, + } + + for _, tt := range tests { + t.Run(tt.want, func(t *testing.T) { + t.Parallel() + + if got := factTypeLabel(tt.factType); got != tt.want { + t.Fatalf("factTypeLabel(%q) = %q, want %q", tt.factType, got, tt.want) + } + }) + } +} diff --git a/internal/engine/factsutil_test.go b/internal/engine/factsutil_test.go index 83593c6e..16e4769f 100644 --- a/internal/engine/factsutil_test.go +++ b/internal/engine/factsutil_test.go @@ -89,6 +89,18 @@ func TestReleaseHashFromString_emptyVersionReturnsNil(t *testing.T) { } } +func TestFirstNonEmptyReturnsFirstValueInOrder(t *testing.T) { + if got := firstNonEmpty("first", "second"); got != "first" { + t.Fatalf("firstNonEmpty() = %q, want first", got) + } + if got := firstNonEmpty("", "first", "second"); got != "first" { + t.Fatalf("firstNonEmpty() = %q, want first", got) + } + if got := firstNonEmpty("", ""); got != "" { + t.Fatalf("firstNonEmpty(empty) = %q, want empty", got) + } +} + func TestReleaseHashFromMatchData_matchesRubyFactsUtils(t *testing.T) { releasePattern := regexp.MustCompile(`^RELEASE=(\d+.\d+.*)`) majorPattern := regexp.MustCompile(`^RELEASE=(\d+)`) @@ -188,3 +200,31 @@ func TestDeepStringifyKeys_matchesRubyUtils(t *testing.T) { t.Fatalf("deepStringifyKeys() = %#v, want %#v", got, want) } } + +func TestDeepStringifyKeysHandlesStringMapsSlicesAndScalars(t *testing.T) { + t.Parallel() + + input := map[string]any{ + "nested": map[any]any{ + 1: []any{ + map[string]any{"leaf": "value"}, + "scalar", + }, + }, + } + want := map[string]any{ + "nested": map[string]any{ + "1": []any{ + map[string]any{"leaf": "value"}, + "scalar", + }, + }, + } + + if got := deepStringifyKeys(input); !reflect.DeepEqual(got, want) { + t.Fatalf("deepStringifyKeys() = %#v, want %#v", got, want) + } + if got := deepStringifyKeys("scalar"); got != "scalar" { + t.Fatalf("deepStringifyKeys(scalar) = %#v, want scalar", got) + } +} diff --git a/internal/engine/filehelper_test.go b/internal/engine/filehelper_test.go index 887f4c81..8833cd0d 100644 --- a/internal/engine/filehelper_test.go +++ b/internal/engine/filehelper_test.go @@ -34,6 +34,18 @@ func TestSafeRead_returnsDefaultForUnreadablePath(t *testing.T) { } } +func TestSafeReadAcceptsNilLoggerForUnreadablePath(t *testing.T) { + path := filepath.Join(t.TempDir(), "missing.txt") + + got, ok := safeRead(path, "default", nil) + if ok { + t.Fatal("safeRead() ok = true, want false") + } + if got != "default" { + t.Fatalf("safeRead() = %q, want default", got) + } +} + func TestSafeRead_logsDebugForUnreadablePathLikeRubyFileHelper(t *testing.T) { path := filepath.Join(t.TempDir(), "missing.txt") var messages []string diff --git a/internal/engine/formatter.go b/internal/engine/formatter.go index 22c9edee..334d95f6 100644 --- a/internal/engine/formatter.go +++ b/internal/engine/formatter.go @@ -183,7 +183,7 @@ func yamlSequenceLines(values []any, depth int) []string { lines := make([]string, 0, len(values)) for _, value := range values { if childMap, ok := value.(map[string]any); ok { - lines = append(lines, indent+"- "+yamlInlineMap(childMap)) + lines = append(lines, indent+"- "+yamlSequenceMap(childMap)) continue } lines = append(lines, indent+"- "+yamlScalar(value)) @@ -259,10 +259,7 @@ func isPlainYAMLKey(value string) bool { } func quoteOutputString(value string) string { - encoded, err := json.Marshal(value) - if err != nil { - return strconv.Quote(value) - } + encoded, _ := json.Marshal(value) // json.Marshal cannot fail for a concrete string. return string(encoded) } @@ -382,6 +379,17 @@ func isPlainHOCONString(value string) bool { } func yamlInlineMap(value map[string]any) string { + return "{" + yamlInlineMapContents(value) + "}" +} + +func yamlSequenceMap(value map[string]any) string { + if len(value) == 1 { + return yamlInlineMapContents(value) + } + return yamlInlineMap(value) +} + +func yamlInlineMapContents(value map[string]any) string { parts := make([]string, 0, len(value)) for _, key := range sortedKeys(value) { parts = append(parts, yamlKey(key)+": "+yamlScalar(value[key])) diff --git a/internal/engine/formatter_test.go b/internal/engine/formatter_test.go index 99605648..cfffdded 100644 --- a/internal/engine/formatter_test.go +++ b/internal/engine/formatter_test.go @@ -129,6 +129,64 @@ func TestBuildFormatterMachineFormatsIgnoreColorize(t *testing.T) { } } +func TestYAMLScalarFormatsInlineValues(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + value any + want string + }{ + {name: "nil", value: nil, want: `""`}, + {name: "boolean string", value: "true", want: "'true'"}, + {name: "plain string", value: "host-example", want: "host-example"}, + {name: "quoted string", value: "needs space", want: `"needs space"`}, + {name: "int", value: 42, want: "42"}, + {name: "float", value: 3.5, want: "3.5"}, + {name: "bool", value: true, want: "true"}, + {name: "map", value: map[string]any{"b": 2, "a": "true"}, want: "{a: 'true', b: 2}"}, + {name: "any slice", value: []any{"web", 2}, want: "[web, 2]"}, + {name: "string slice", value: []string{"web", "db"}, want: "[web, db]"}, + {name: "int slice", value: []int{1, 2}, want: "[1, 2]"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + if got := yamlScalar(tt.value); got != tt.want { + t.Fatalf("yamlScalar(%#v) = %q, want %q", tt.value, got, tt.want) + } + }) + } +} + +func TestIsPlainYAMLStringAcceptsOnlyRubySafeScalars(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + value string + want bool + }{ + {name: "empty", value: "", want: false}, + {name: "word with dash underscore slash and space", value: "host_id-1 /rack", want: true}, + {name: "colon would become mapping syntax", value: "fe80::1", want: false}, + {name: "yaml true prefix", value: "TrueNAS", want: false}, + {name: "lowercase off", value: "off", want: false}, + {name: "contains unsupported punctuation", value: "hello.world", want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + if got := isPlainYAMLString(tt.value); got != tt.want { + t.Fatalf("isPlainYAMLString(%q) = %v, want %v", tt.value, got, tt.want) + } + }) + } +} + func TestFormatJSON_noUserQueryBuildsStructuredFacts(t *testing.T) { facts := []ResolvedFact{ {Name: "os.name", Value: "Darwin"}, @@ -300,6 +358,18 @@ func TestFormatYAML_formatsNestedArrayValuesAsYAML(t *testing.T) { } } +func TestFormatYAML_formatsMultiKeyMapsInSequencesAsValidYAML(t *testing.T) { + facts := []ResolvedFact{ + {Name: "nested", Value: []any{map[string]any{"b": 2, "a": "true"}}, UserQuery: "nested"}, + } + + got := FormatYAML(facts) + want := "nested:\n- {a: 'true', b: 2}\n" + if got != want { + t.Fatalf("FormatYAML() = %q, want %q", got, want) + } +} + func TestFormatYAML_quotesStringValuesThatYAMLWouldParseAsScalars(t *testing.T) { facts := []ResolvedFact{ {Name: "feature.enabled", Value: "true"}, @@ -435,6 +505,27 @@ func TestFormatHOCON_singleNilQueryReturnsEmptyScalar(t *testing.T) { } } +func TestFormatHOCON_singleQueriesRenderTypedSlicesAndGenericMaps(t *testing.T) { + tests := []struct { + name string + value any + want string + }{ + {name: "string slice", value: []string{"web", "db"}, want: "[\"web\",\"db\"]"}, + {name: "int slice", value: []int{2, 4}, want: "[2,4]"}, + {name: "map", value: map[string]any{"role": "web", "ports": []int{80, 443}}, want: "{\n ports=[80,443]\n role=web\n}"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + facts := []ResolvedFact{{Name: "query", UserQuery: "query", Value: tt.value}} + if got := FormatHOCON(facts); got != tt.want { + t.Fatalf("FormatHOCON() = %q, want %q", got, tt.want) + } + }) + } +} + func TestFormatLegacy_singleUserQueryDigsIntoArraysAndMaps(t *testing.T) { fact := ResolvedFact{ Name: "my.nested.fact", @@ -843,3 +934,33 @@ func TestValueForQueryRejectsInvalidArrayIndexes(t *testing.T) { }) } } + +func TestFormatterScalarRendering(t *testing.T) { + yamlCases := []struct { + name string + value any + want string + }{ + {name: "nil", value: nil, want: `""`}, + {name: "plain string", value: "hello", want: "hello"}, + {name: "boolean-looking string", value: "true", want: "'true'"}, + {name: "quoted string", value: "hello:world", want: `"hello:world"`}, + {name: "int", value: 7, want: "7"}, + {name: "float", value: 1.5, want: "1.5"}, + {name: "bool", value: false, want: "false"}, + } + for _, tt := range yamlCases { + t.Run("yaml/"+tt.name, func(t *testing.T) { + facts := []ResolvedFact{{Name: "value", UserQuery: "value", Value: tt.value}} + want := "value: " + tt.want + "\n" + if got := FormatYAML(facts); got != want { + t.Fatalf("FormatYAML(%#v) = %q, want %q", tt.value, got, want) + } + }) + } + + facts := []ResolvedFact{{Name: "values", UserQuery: "values", Value: []any{"hello", 7, true}}} + if got, want := FormatHOCON(facts), `["hello",7,true]`; got != want { + t.Fatalf("FormatHOCON() = %q, want %q", got, want) + } +} diff --git a/internal/engine/gce_test.go b/internal/engine/gce_test.go index 07fd3632..085c6519 100644 --- a/internal/engine/gce_test.go +++ b/internal/engine/gce_test.go @@ -2,20 +2,32 @@ package engine import ( "context" + "io" "net/http" "net/http/httptest" "reflect" + "strings" "sync/atomic" "testing" ) +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + func TestGCEFactsFetchRecursiveMetadataAndNormalizeInstance(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.String() != "/?recursive=true&alt=json" { - t.Fatalf("request URL = %q, want recursive JSON metadata endpoint", r.URL.String()) + t.Errorf("request URL = %q, want recursive JSON metadata endpoint", r.URL.String()) + http.Error(w, "unexpected metadata endpoint", http.StatusBadRequest) + return } if got := r.Header.Get("Metadata-Flavor"); got != "Google" { - t.Fatalf("Metadata-Flavor = %q, want Google", got) + t.Errorf("Metadata-Flavor = %q, want Google", got) + http.Error(w, "missing metadata header", http.StatusBadRequest) + return } w.Header().Set("Metadata-Flavor", "Google") _, _ = w.Write([]byte(`{ @@ -107,6 +119,12 @@ func TestGCEFactsRequireGoogleMetadataFlavor(t *testing.T) { } } +func TestGCEFactsSkipNilClient(t *testing.T) { + if got := gceFacts(context.Background(), nil); got != nil { + t.Fatalf("gceFacts(nil client) = %#v, want nil", got) + } +} + func TestLinuxGCEFactsSkipsMetadataWhenBIOSVendorIsNotGoogleLikeRuby(t *testing.T) { var requested atomic.Bool server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { @@ -124,6 +142,43 @@ func TestLinuxGCEFactsSkipsMetadataWhenBIOSVendorIsNotGoogleLikeRuby(t *testing. } } +func TestLinuxGCEFactsFetchesMetadataForGoogleBIOSVendor(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.String() != "/?recursive=true&alt=json" { + t.Errorf("request URL = %q, want recursive JSON metadata endpoint", r.URL.String()) + http.Error(w, "unexpected metadata endpoint", http.StatusBadRequest) + return + } + w.Header().Set("Metadata-Flavor", "Google") + _, _ = w.Write([]byte(`{"instance":{"machineType":"projects/123/machineTypes/e2-medium"}}`)) + })) + t.Cleanup(server.Close) + + got := factValues(linuxGCEFacts(context.Background(), "linux", "Google", newGCEClient(server.URL, server.Client()))) + gce, ok := got["gce"].(map[string]any) + if !ok { + t.Fatalf("gce fact = %#v, want metadata map", got["gce"]) + } + instance := gce["instance"].(map[string]any) + if instance["machineType"] != "e2-medium" || got["cloud.provider"] != "gce" { + t.Fatalf("linuxGCEFacts() = %#v, want normalized metadata and cloud provider", got) + } +} + +func TestLinuxGCEFactsEmitsNilWhenGoogleBIOSHasNoClient(t *testing.T) { + got := linuxGCEFacts(context.Background(), "linux", "Google", nil) + want := []ResolvedFact{{Name: "gce", Value: nil}} + if !reflect.DeepEqual(got, want) { + t.Fatalf("linuxGCEFacts(nil client) = %#v, want %#v", got, want) + } +} + +func TestLinuxGCEFactsSkipNonLinuxPlatform(t *testing.T) { + if got := linuxGCEFacts(context.Background(), "freebsd", "Google", nil); got != nil { + t.Fatalf("linuxGCEFacts(non-linux) = %#v, want nil", got) + } +} + func TestPlatformGCEFactsFetchesWindowsMetadataWhenVirtualizationIsGCELikeRuby(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.String() != "/?recursive=true&alt=json" { @@ -143,6 +198,47 @@ func TestPlatformGCEFactsFetchesWindowsMetadataWhenVirtualizationIsGCELikeRuby(t } } +func TestPlatformGCEFactsEmitsNilForWindowsWithEmptyMetadata(t *testing.T) { + client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + if req.URL.String() != "http://metadata.test/?recursive=true&alt=json" { + t.Fatalf("request URL = %q, want recursive JSON metadata endpoint", req.URL.String()) + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Metadata-Flavor": []string{"Google"}}, + Body: io.NopCloser(strings.NewReader(`{}`)), + }, nil + })} + + got := platformGCEFacts(context.Background(), "windows", virtualization{Name: "gce", IsVirtual: true}, "", newGCEClient("http://metadata.test", client)) + want := []ResolvedFact{{Name: "gce", Value: nil}} + if !reflect.DeepEqual(got, want) { + t.Fatalf("platformGCEFacts(windows empty metadata) = %#v, want %#v", got, want) + } +} + +func TestPlatformGCEFactsDispatchesLinux(t *testing.T) { + got := platformGCEFacts(context.Background(), "linux", virtualization{}, "not google", nil) + want := []ResolvedFact{{Name: "gce", Value: nil}} + if !reflect.DeepEqual(got, want) { + t.Fatalf("platformGCEFacts(linux non-GCE) = %#v, want %#v", got, want) + } +} + +func TestPlatformGCEFactsEmitsNilForWindowsWithoutGCESignal(t *testing.T) { + got := platformGCEFacts(context.Background(), "windows", virtualization{Name: "kvm", IsVirtual: true}, "", nil) + want := []ResolvedFact{{Name: "gce", Value: nil}} + if !reflect.DeepEqual(got, want) { + t.Fatalf("platformGCEFacts(windows non-gce) = %#v, want %#v", got, want) + } +} + +func TestPlatformGCEFactsSkipUnsupportedPlatform(t *testing.T) { + if got := platformGCEFacts(context.Background(), "freebsd", virtualization{Name: "gce", IsVirtual: true}, "Google", nil); got != nil { + t.Fatalf("platformGCEFacts(unsupported) = %#v, want nil", got) + } +} + func factValues(facts []ResolvedFact) map[string]any { values := make(map[string]any, len(facts)) for _, fact := range facts { diff --git a/internal/engine/identity.go b/internal/engine/identity.go index 2cdf8e89..c8f14bde 100644 --- a/internal/engine/identity.go +++ b/internal/engine/identity.go @@ -105,7 +105,7 @@ func numericIdentityValue(value string) any { // identityCoreFacts assembles the identity category fact (the current user, // group, and privilege state) for the current host. func identityCoreFacts(s *Session) []ResolvedFact { - if runtime.GOOS == "plan9" { + if s.goos() == "plan9" { return nil } return []ResolvedFact{ diff --git a/internal/engine/identity_test.go b/internal/engine/identity_test.go index b79ecf52..81b7270c 100644 --- a/internal/engine/identity_test.go +++ b/internal/engine/identity_test.go @@ -97,6 +97,58 @@ func TestParseWindowsAdministratorGroupsDetectsDenyOnlyAdmin(t *testing.T) { } } +func TestParseWindowsAdministratorGroups(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + output string + want bool + wantOK bool + }{ + { + name: "blank output has unknown privilege", + output: " \n\t", + }, + { + name: "enabled administrators group by name", + output: strings.Join([]string{ + `Group Name Type SID Attributes`, + `========================================== ================ ============ ===============================================`, + `BUILTIN\Administrators Alias S-1-5-32-544 Mandatory group, Enabled by default, Enabled group`, + }, "\n"), + want: true, + wantOK: true, + }, + { + name: "enabled administrators group by SID", + output: strings.Join([]string{ + `Everyone Well-known group S-1-1-0 Mandatory group, Enabled by default, Enabled group`, + `Local account and member of Administrators group Alias S-1-5-32-544 Mandatory group, Enabled group`, + }, "\n"), + want: true, + wantOK: true, + }, + { + name: "no administrators group means known unprivileged", + output: strings.Join([]string{ + `Everyone Well-known group S-1-1-0 Mandatory group, Enabled group`, + `BUILTIN\Users Alias S-1-5-32-545 Mandatory group, Enabled group`, + }, "\n"), + wantOK: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := parseWindowsAdministratorGroups(tt.output) + if got != tt.want || ok != tt.wantOK { + t.Fatalf("parseWindowsAdministratorGroups() = %v, %v; want %v, %v", got, ok, tt.want, tt.wantOK) + } + }) + } +} + func TestIdentityFactFromInfoPOSIXReturnsNumericUIDAndGID(t *testing.T) { t.Parallel() @@ -121,6 +173,15 @@ func TestIdentityFactFromInfoPOSIXReturnsNumericUIDAndGID(t *testing.T) { } } +func TestIdentityCoreFactsUsesSessionPlatform(t *testing.T) { + s := NewSessionContext(t.Context()) + s.host = &fakeHostOS{platform: "plan9"} + + if got := identityCoreFacts(s); got != nil { + t.Fatalf("identityCoreFacts(plan9 session) = %#v, want nil", got) + } +} + func TestCoreFacts_includeMacOSReleaseKernelHardwareAndIdentity(t *testing.T) { if runtime.GOOS != "darwin" { t.Skipf("macOS host fact integration runs only on darwin, not %s", runtime.GOOS) diff --git a/internal/engine/memory.go b/internal/engine/memory.go index 9f628cca..a8b6b4a5 100644 --- a/internal/engine/memory.go +++ b/internal/engine/memory.go @@ -2,7 +2,6 @@ package engine import ( "log/slog" - "runtime" "strconv" "strings" ) @@ -59,7 +58,7 @@ func parseWindowsMemory(input string, log *slog.Logger) windowsMemory { } func probeTotalPhysicalMemoryBytes(s *Session) int { - switch runtime.GOOS { + switch s.goos() { case "darwin": out := s.commandOutput("sysctl", "-n", "hw.memsize") if out == "" { @@ -85,7 +84,7 @@ func probeTotalPhysicalMemoryBytes(s *Session) int { } func probeAvailablePhysicalMemoryBytes(s *Session) int { - switch runtime.GOOS { + switch s.goos() { case "darwin": out := s.commandOutput("vm_stat") if out == "" { @@ -106,7 +105,7 @@ func probeAvailablePhysicalMemoryBytes(s *Session) int { } func probeTotalSwapMemoryBytes(s *Session) int { - switch runtime.GOOS { + switch s.goos() { case "darwin": return s.cachedDarwinSwapUsage().TotalBytes case "freebsd": @@ -121,7 +120,7 @@ func probeTotalSwapMemoryBytes(s *Session) int { } func probeAvailableSwapMemoryBytes(s *Session) int { - switch runtime.GOOS { + switch s.goos() { case "darwin": return s.cachedDarwinSwapUsage().AvailableBytes case "freebsd": @@ -136,10 +135,10 @@ func probeAvailableSwapMemoryBytes(s *Session) int { } func probeSwapEncrypted(s *Session) bool { - if runtime.GOOS == "darwin" { + if s.goos() == "darwin" { return s.cachedDarwinSwapUsage().Encrypted } - if runtime.GOOS == "freebsd" { + if s.goos() == "freebsd" { value, _ := s.cachedFreeBSDMemoryInfo().Swap["encrypted"].(bool) return value } @@ -147,7 +146,7 @@ func probeSwapEncrypted(s *Session) bool { } func probeWindowsMemory(s *Session) windowsMemory { - return currentWindowsMemory(runtime.GOOS, s.commandOutput, s.logr()) + return currentWindowsMemory(s.goos(), s.commandOutput, s.logr()) } type darwinSwapUsage struct { @@ -158,7 +157,7 @@ type darwinSwapUsage struct { } func probeFreeBSDMemoryInfo(s *Session) freeBSDMemoryInfo { - if runtime.GOOS != "freebsd" { + if s.goos() != "freebsd" { return freeBSDMemoryInfo{} } return parseFreeBSDMemory(map[string]int{ @@ -170,7 +169,7 @@ func probeFreeBSDMemoryInfo(s *Session) freeBSDMemoryInfo { } func probeBSDMemoryInfo(s *Session) bsdMemoryInfo { - switch runtime.GOOS { + switch s.goos() { case "netbsd", "openbsd": values := map[string]int{ "hw.physmem": bsdSysctlInt(s, "hw.physmem64", "hw.physmem"), @@ -466,7 +465,7 @@ func parseIllumosKToken(value string) int { } func probeDarwinSwapUsage(s *Session) darwinSwapUsage { - return currentDarwinSwapUsage(runtime.GOOS, s.commandOutput) + return currentDarwinSwapUsage(s.goos(), s.commandOutput) } func currentDarwinSwapUsage(goos string, run commandRunner) darwinSwapUsage { @@ -588,7 +587,7 @@ func parseDarwinMemoryAmountBytes(input string) int { // current host. func memoryCoreFacts(s *Session) []ResolvedFact { memoryTotalBytes := s.cachedTotalPhysicalMemoryBytes() - if runtime.GOOS == "plan9" { + if s.goos() == "plan9" { return plan9MemoryCoreFacts(memoryTotalBytes) } memoryAvailableBytes := s.cachedAvailablePhysicalMemoryBytes() diff --git a/internal/engine/memory_test.go b/internal/engine/memory_test.go index dfd48c65..840617f2 100644 --- a/internal/engine/memory_test.go +++ b/internal/engine/memory_test.go @@ -1,6 +1,7 @@ package engine import ( + "context" "reflect" "runtime" "strings" @@ -274,6 +275,30 @@ func TestParseDarwinSwapUsage(t *testing.T) { } } +func TestParseDarwinMemoryAmountBytes(t *testing.T) { + t.Parallel() + + tests := []struct { + input string + want int + }{ + {input: "", want: 0}, + {input: "bad", want: 0}, + {input: "512K", want: 524_288}, + {input: "1.5M", want: 1_572_864}, + {input: "1G", want: 1_073_741_824}, + {input: "4096", want: 4096}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + if got := parseDarwinMemoryAmountBytes(tt.input); got != tt.want { + t.Fatalf("parseDarwinMemoryAmountBytes(%q) = %d, want %d", tt.input, got, tt.want) + } + }) + } +} + func TestCurrentDarwinSwapUsageMatchesRubyResolver(t *testing.T) { t.Parallel() @@ -295,6 +320,32 @@ func TestCurrentDarwinSwapUsageMatchesRubyResolver(t *testing.T) { } } +func TestDarwinMemoryParsersHandleMalformedAndNonDarwinInputs(t *testing.T) { + t.Parallel() + + called := false + got := currentDarwinSwapUsage("linux", func(string, ...string) string { + called = true + return "total = 1G" + }) + if got != (darwinSwapUsage{}) { + t.Fatalf("currentDarwinSwapUsage(non-darwin) = %#v, want empty", got) + } + if called { + t.Fatal("currentDarwinSwapUsage(non-darwin) ran command") + } + + if got := parseDarwinVMStatAvailableBytes("Pages free: 10.\n"); got != 0 { + t.Fatalf("parseDarwinVMStatAvailableBytes(missing page size) = %d, want 0", got) + } + if got := parseDarwinSwapUsage("total : 1G used = 256M unknown = 3G free = 768M"); got != (darwinSwapUsage{UsedBytes: 268_435_456, AvailableBytes: 805_306_368}) { + t.Fatalf("parseDarwinSwapUsage(noisy input) = %#v, want used/free only", got) + } + if got := parseDarwinMemoryAmountBytes("12B"); got != 0 { + t.Fatalf("parseDarwinMemoryAmountBytes(12B) = %d, want 0", got) + } +} + func TestBytesToMB(t *testing.T) { tests := []struct { name string @@ -375,6 +426,333 @@ func TestParseFreeBSDMemory_returnsRubyCompatibleSystemAndSwapFacts(t *testing.T } } +func TestMemorySysctlHelpersParseCommandOutput(t *testing.T) { + s := NewSessionContext(context.Background()) + host := &fakeHostOS{runOutputs: map[string]string{ + fakeRunKey("sysctl", "-n", "vm.stats.vm.v_page_size"): "4096\n", + fakeRunKey("sysctl", "-n", "bad"): "not-a-number\n", + fakeRunKey("sysctl", "-n", "hw.physmem"): "1049231360\n", + fakeRunKey("sysctl", "-n", "hw.usermem"): "2048\n", + }} + s.host = host + + if got := freeBSDSysctlInt(s, "vm.stats.vm.v_page_size"); got != 4096 { + t.Fatalf("freeBSDSysctlInt() = %d, want 4096", got) + } + if got := freeBSDSysctlInt(s, "bad"); got != 0 { + t.Fatalf("freeBSDSysctlInt(bad) = %d, want 0", got) + } + host.runCalls = nil + if got := bsdSysctlInt(s, "bad", "hw.physmem"); got != 1_049_231_360 { + t.Fatalf("bsdSysctlInt() = %d, want first positive fallback", got) + } + wantFallbackCalls := []fakeHostRunCall{ + {name: "sysctl", args: []string{"-n", "bad"}}, + {name: "sysctl", args: []string{"-n", "hw.physmem"}}, + } + if !reflect.DeepEqual(host.runCalls, wantFallbackCalls) { + t.Fatalf("fallback run calls = %#v, want %#v", host.runCalls, wantFallbackCalls) + } + if got := bsdSysctlInt(s, "hw.usermem", "hw.physmem"); got != 2048 { + t.Fatalf("bsdSysctlInt() = %d, want first positive value in order", got) + } + if got := bsdSysctlInt(s, "bad"); got != 0 { + t.Fatalf("bsdSysctlInt(all bad) = %d, want 0", got) + } + + host.runCalls = nil + if got := bsdSysctlInt(s, "hw.usermem", "hw.physmem"); got != 2048 { + t.Fatalf("bsdSysctlInt() = %d, want first positive value in order", got) + } + wantCalls := []fakeHostRunCall{ + {name: "sysctl", args: []string{"-n", "hw.usermem"}}, + } + if !reflect.DeepEqual(host.runCalls, wantCalls) { + t.Fatalf("run calls = %#v, want %#v", host.runCalls, wantCalls) + } +} + +func TestMemoryProbesUseSessionPlatformForFreeBSD(t *testing.T) { + s := NewSessionContext(context.Background()) + s.host = &fakeHostOS{ + platform: "freebsd", + runOutputs: map[string]string{ + fakeRunKey("sysctl", "-n", "vm.stats.vm.v_page_size"): "4096\n", + fakeRunKey("sysctl", "-n", "vm.stats.vm.v_page_count"): "100\n", + fakeRunKey("sysctl", "-n", "vm.stats.vm.v_active_count"): "30\n", + fakeRunKey("sysctl", "-n", "vm.stats.vm.v_wire_count"): "20\n", + fakeRunKey("swapinfo", "-k"): strings.Join([]string{ + "Device 1K-blocks Used Avail Capacity", + "/dev/ada0p2.eli 200 50 150 25%", + }, "\n"), + }, + } + + if got := probeTotalPhysicalMemoryBytes(s); got != 409_600 { + t.Fatalf("probeTotalPhysicalMemoryBytes() = %d, want 409600", got) + } + if got := probeAvailablePhysicalMemoryBytes(s); got != 204_800 { + t.Fatalf("probeAvailablePhysicalMemoryBytes() = %d, want 204800", got) + } + if got := probeTotalSwapMemoryBytes(s); got != 204_800 { + t.Fatalf("probeTotalSwapMemoryBytes() = %d, want 204800", got) + } + if got := probeAvailableSwapMemoryBytes(s); got != 153_600 { + t.Fatalf("probeAvailableSwapMemoryBytes() = %d, want 153600", got) + } + if got := probeSwapEncrypted(s); !got { + t.Fatalf("probeSwapEncrypted() = false, want true") + } +} + +func TestMemoryProbesUseSessionPlatformForBSDAndIllumos(t *testing.T) { + tests := []struct { + name string + platform string + runOutputs map[string]string + }{ + { + name: "openbsd", + platform: "openbsd", + runOutputs: map[string]string{ + fakeRunKey("sysctl", "-n", "hw.physmem64"): "409600\n", + fakeRunKey("vmstat", "-s"): strings.Join([]string{ + "4096 bytes per page", + "30 pages active", + "20 pages wired", + }, "\n"), + fakeRunKey("swapctl", "-sk"): "total: 200 1K-blocks allocated, 50 used, 150 available", + }, + }, + { + name: "dragonfly", + platform: "dragonfly", + runOutputs: map[string]string{ + fakeRunKey("sysctl", "-n", "hw.physmem"): "409600\n", + fakeRunKey("vmstat", "-s"): strings.Join([]string{ + "4096 bytes per page", + "30 pages active", + "20 pages wired", + }, "\n"), + fakeRunKey("swapinfo", "-k"): strings.Join([]string{ + "Device 1K-blocks Used Avail Capacity", + "/dev/da0s1b 200 50 150 25%", + }, "\n"), + }, + }, + { + name: "illumos", + platform: "illumos", + runOutputs: map[string]string{ + fakeRunKey("kstat", "-p", "unix:0:system_pages:physmem", "unix:0:system_pages:freemem"): strings.Join([]string{ + "unix:0:system_pages:physmem\t100", + "unix:0:system_pages:freemem\t50", + }, "\n"), + fakeRunKey("pagesize"): "4096\n", + fakeRunKey("swap", "-s"): "total: 50k used, 150k available", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := NewSessionContext(context.Background()) + s.host = &fakeHostOS{platform: tt.platform, runOutputs: tt.runOutputs} + + if got := probeTotalPhysicalMemoryBytes(s); got != 409_600 { + t.Fatalf("probeTotalPhysicalMemoryBytes() = %d, want 409600", got) + } + if got := probeAvailablePhysicalMemoryBytes(s); got != 204_800 { + t.Fatalf("probeAvailablePhysicalMemoryBytes() = %d, want 204800", got) + } + if got := probeTotalSwapMemoryBytes(s); got != 204_800 { + t.Fatalf("probeTotalSwapMemoryBytes() = %d, want 204800", got) + } + if got := probeAvailableSwapMemoryBytes(s); got != 153_600 { + t.Fatalf("probeAvailableSwapMemoryBytes() = %d, want 153600", got) + } + }) + } +} + +func TestMemoryProbesUseSessionPlatformForLinux(t *testing.T) { + s := NewSessionContext(context.Background()) + s.host = &fakeHostOS{ + platform: "linux", + files: map[string][]byte{ + "/proc/meminfo": []byte("MemTotal: 400 kB\nMemAvailable: 200 kB\nSwapTotal: 100 kB\nSwapFree: 25 kB\n"), + }, + } + + if got := probeLinuxMeminfo(s); got != "MemTotal: 400 kB\nMemAvailable: 200 kB\nSwapTotal: 100 kB\nSwapFree: 25 kB\n" { + t.Fatalf("probeLinuxMeminfo() = %q", got) + } + if got := probeTotalPhysicalMemoryBytes(s); got != 409_600 { + t.Fatalf("probeTotalPhysicalMemoryBytes() = %d, want 409600", got) + } + if got := probeAvailablePhysicalMemoryBytes(s); got != 204_800 { + t.Fatalf("probeAvailablePhysicalMemoryBytes() = %d, want 204800", got) + } + if got := probeTotalSwapMemoryBytes(s); got != 102_400 { + t.Fatalf("probeTotalSwapMemoryBytes() = %d, want 102400", got) + } + if got := probeAvailableSwapMemoryBytes(s); got != 25_600 { + t.Fatalf("probeAvailableSwapMemoryBytes() = %d, want 25600", got) + } +} + +func TestProbeLinuxMeminfoReturnsEmptyWhenMissing(t *testing.T) { + s := NewSessionContext(context.Background()) + s.host = &fakeHostOS{platform: "linux"} + + if got := probeLinuxMeminfo(s); got != "" { + t.Fatalf("probeLinuxMeminfo(missing) = %q, want empty", got) + } +} + +func TestMemoryTotalProbeUsesSessionPlatformForPlan9(t *testing.T) { + s := NewSessionContext(context.Background()) + s.host = &fakeHostOS{ + platform: "plan9", + files: map[string][]byte{ + "/dev/swap": []byte("1067843584 memory\n4096 pagesize\n"), + }, + } + + if got := probeTotalPhysicalMemoryBytes(s); got != 1_067_843_584 { + t.Fatalf("probeTotalPhysicalMemoryBytes(plan9) = %d, want 1067843584", got) + } +} + +func TestMemoryProbesReturnZeroForUnsupportedSessionPlatform(t *testing.T) { + s := NewSessionContext(context.Background()) + s.host = &fakeHostOS{platform: "hurd"} + + if got := probeTotalPhysicalMemoryBytes(s); got != 0 { + t.Fatalf("probeTotalPhysicalMemoryBytes(unsupported) = %d, want 0", got) + } + if got := probeAvailablePhysicalMemoryBytes(s); got != 0 { + t.Fatalf("probeAvailablePhysicalMemoryBytes(unsupported) = %d, want 0", got) + } + if got := probeTotalSwapMemoryBytes(s); got != 0 { + t.Fatalf("probeTotalSwapMemoryBytes(unsupported) = %d, want 0", got) + } + if got := probeAvailableSwapMemoryBytes(s); got != 0 { + t.Fatalf("probeAvailableSwapMemoryBytes(unsupported) = %d, want 0", got) + } + if got := probeFreeBSDMemoryInfo(s); !reflect.DeepEqual(got, freeBSDMemoryInfo{}) { + t.Fatalf("probeFreeBSDMemoryInfo(unsupported) = %#v, want empty", got) + } +} + +func TestMemoryProbesUseSessionPlatformForDarwinAndWindows(t *testing.T) { + t.Run("darwin", func(t *testing.T) { + s := NewSessionContext(context.Background()) + s.host = &fakeHostOS{ + platform: "darwin", + runOutputs: map[string]string{ + fakeRunKey("sysctl", "-n", "hw.memsize"): "409600\n", + fakeRunKey("vm_stat"): "Mach Virtual Memory Statistics: (page size of 4096 bytes)\nPages free: 50.\n", + fakeRunKey("sysctl", "-n", "vm.swapusage"): "total = 200K used = 50K free = 150K (encrypted)", + }, + } + + if got := probeTotalPhysicalMemoryBytes(s); got != 409_600 { + t.Fatalf("probeTotalPhysicalMemoryBytes() = %d, want 409600", got) + } + if got := probeAvailablePhysicalMemoryBytes(s); got != 204_800 { + t.Fatalf("probeAvailablePhysicalMemoryBytes() = %d, want 204800", got) + } + if got := probeTotalSwapMemoryBytes(s); got != 204_800 { + t.Fatalf("probeTotalSwapMemoryBytes() = %d, want 204800", got) + } + if got := probeAvailableSwapMemoryBytes(s); got != 153_600 { + t.Fatalf("probeAvailableSwapMemoryBytes() = %d, want 153600", got) + } + if got := probeSwapEncrypted(s); !got { + t.Fatalf("probeSwapEncrypted() = false, want true") + } + }) + + t.Run("windows", func(t *testing.T) { + s := NewSessionContext(context.Background()) + s.host = &fakeHostOS{ + platform: "windows", + runOutputs: map[string]string{ + fakeRunKey("wmic", "os", "get", "FreePhysicalMemory,TotalVisibleMemorySize", "/value"): "FreePhysicalMemory=200\nTotalVisibleMemorySize=400\n", + }, + } + + if got := probeWindowsMemory(s); got != (windowsMemory{TotalBytes: 409_600, AvailableBytes: 204_800, UsedBytes: 204_800, Capacity: "50.00%"}) { + t.Fatalf("probeWindowsMemory() = %#v, want populated Windows memory", got) + } + if got := probeTotalPhysicalMemoryBytes(s); got != 409_600 { + t.Fatalf("probeTotalPhysicalMemoryBytes() = %d, want 409600", got) + } + if got := probeAvailablePhysicalMemoryBytes(s); got != 204_800 { + t.Fatalf("probeAvailablePhysicalMemoryBytes() = %d, want 204800", got) + } + }) +} + +func TestFreeBSDMemoryValueReturnsOnlyIntegerFields(t *testing.T) { + values := map[string]any{ + "total_bytes": "1024", + "used_bytes": 512, + } + + if got := freeBSDMemoryValue(values, "used_bytes"); got != 512 { + t.Fatalf("freeBSDMemoryValue(used_bytes) = %d, want 512", got) + } + if got := freeBSDMemoryValue(values, "total_bytes"); got != 0 { + t.Fatalf("freeBSDMemoryValue(non-int) = %d, want 0", got) + } + if got := freeBSDMemoryValue(values, "missing"); got != 0 { + t.Fatalf("freeBSDMemoryValue(missing) = %d, want 0", got) + } +} + +func TestFreeBSDMemoryParsersRejectInvalidInputs(t *testing.T) { + t.Parallel() + + if got := parseFreeBSDSystemMemory(map[string]int{"vm.stats.vm.v_page_size": 4096}); got != nil { + t.Fatalf("parseFreeBSDSystemMemory(missing page count) = %#v, want nil", got) + } + if got := parseFreeBSDSwapMemory(""); got != nil { + t.Fatalf("parseFreeBSDSwapMemory(empty) = %#v, want nil", got) + } + input := strings.Join([]string{ + "Device 1K-blocks Used Avail Capacity", + "bad row", + "/dev/ada0p2 bad 0 0 0%", + "/dev/ada1p2 10 bad 0 0%", + "/dev/ada2p2 10 0 bad 0%", + }, "\n") + if got := parseFreeBSDSwapMemory(input); got != nil { + t.Fatalf("parseFreeBSDSwapMemory(invalid rows) = %#v, want nil", got) + } +} + +func TestParseFreeBSDSwapMemoryAggregatesPlainAndEncryptedDevices(t *testing.T) { + t.Parallel() + + input := `Device 1K-blocks Used Avail Capacity +/dev/ada0p2.eli 1024 512 512 50% +/dev/ada1p2 2048 1024 1024 50%` + + got := parseFreeBSDSwapMemory(input) + want := map[string]any{ + "available_bytes": 1_572_864, + "capacity": "50.00%", + "encrypted": false, + "total_bytes": 3_145_728, + "used_bytes": 1_572_864, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("parseFreeBSDSwapMemory() = %#v, want %#v", got, want) + } +} + func TestParseBSDMemory_returnsSystemAndSwapFacts(t *testing.T) { sysctlValues := map[string]int{ "hw.physmem": 1_049_231_360, @@ -451,8 +829,25 @@ func TestParseIllumosMemoryOmitsSystemWithoutFreePages(t *testing.T) { } } +func TestParseIllumosMemoryParsersRejectMalformedValues(t *testing.T) { + t.Parallel() + + if got := parseIllumosMemory("unix:0:system_pages:physmem\tbad\n", "4096\n", ""); got.System != nil { + t.Fatalf("parseIllumosMemory(bad kstat).System = %#v, want nil", got.System) + } + if got := parseIllumosSwapMemory("total: bad used, also bad available"); got != nil { + t.Fatalf("parseIllumosSwapMemory(bad tokens) = %#v, want nil", got) + } + if got := parseIllumosKToken("badk"); got != 0 { + t.Fatalf("parseIllumosKToken(badk) = %d, want 0", got) + } +} + func TestParseBSDVMStatCounters(t *testing.T) { input := ` 4096 bytes per page +not-a-number pages active +garbage + 10 files open 241757 pages managed 50327 pages free 7715 pages active @@ -482,3 +877,28 @@ func TestParseBSDMemory_omitsSwapWhenNoneConfigured(t *testing.T) { t.Fatalf("parseBSDMemory().Swap = %#v, want nil", got.Swap) } } + +func TestBSDMemoryParsersRejectInvalidInputs(t *testing.T) { + t.Parallel() + + invalidSystems := []map[string]int{ + {"hw.physmem": 1024, "vmstat.pages_active": 1, "vmstat.pages_wired": 1}, + {"hw.physmem": 1024, "vmstat.bytes_per_page": 0, "vmstat.pages_active": 1, "vmstat.pages_wired": 1}, + {"hw.physmem": 1024, "vmstat.bytes_per_page": 1, "vmstat.pages_active": -1, "vmstat.pages_wired": 1}, + } + for _, values := range invalidSystems { + if got := parseBSDSystemMemory(values); got != nil { + t.Fatalf("parseBSDSystemMemory(%#v) = %#v, want nil", values, got) + } + } + + for _, input := range []string{ + "total: bad 1K-blocks allocated, 1 used, 1 available", + "total: 100 1K-blocks allocated, bad used, 1 available", + "total: 100 1K-blocks allocated, 1 used, bad available", + } { + if got := parseBSDSwapMemory(input); got != nil { + t.Fatalf("parseBSDSwapMemory(%q) = %#v, want nil", input, got) + } + } +} diff --git a/internal/engine/networking.go b/internal/engine/networking.go index cb7f4768..b7a9eca6 100644 --- a/internal/engine/networking.go +++ b/internal/engine/networking.go @@ -6,7 +6,6 @@ import ( "os" "path/filepath" "regexp" - "runtime" "slices" "strconv" "strings" @@ -14,8 +13,6 @@ import ( var linuxSystemdDHCPServerPattern = regexp.MustCompile(`(?m)^SERVER_ADDRESS=(\S+)`) -var linuxDHClientServerPattern = regexp.MustCompile(`dhcp-server-identifier\s+([0-9]+\.[0-9]+\.[0-9]+\.[0-9]+)`) - var linuxDHCPCDServerPattern = regexp.MustCompile(`(?m)^dhcp_server_identifier='([^']+)'`) var openBSDDHCPServerPattern = regexp.MustCompile(`\sdhcp server (\S+)`) @@ -164,12 +161,12 @@ func currentNetworkInterfaceSnapshots() ([]networkInterfaceSnapshot, error) { } func networkingInterfaces(s *Session) map[string]any { - return networkingInterfacesForPlatform(s, runtime.GOOS, currentNetworkInterfaceSnapshots) + return networkingInterfacesForPlatform(s, s.goos(), currentNetworkInterfaceSnapshots) } func networkingInterfacesForPlatform(s *Session, goos string, snapshotProvider func() ([]networkInterfaceSnapshot, error)) map[string]any { if goos == "plan9" { - return currentPlan9Interfaces(s.readFile, filepath.Glob) + return currentPlan9Interfaces(s.readFile, s.glob) } snapshots, err := snapshotProvider() if err != nil { @@ -1180,7 +1177,13 @@ func linuxDHCPServerFromLeaseDirWithReader(dir, interfaceName string, readFile f continue } content := readText(filepath.Join(dir, name), readFile) - if !leaseMatchesInterface(name, content, interfaceName) { + if server, matched, explicit := linuxDHClientDHCPServerForInterfaceState(content, interfaceName); explicit { + if matched { + return server + } + continue + } + if !leaseFilenameMatchesInterface(name, interfaceName) { continue } if server := linuxDHClientDHCPServer(content); server != "" { @@ -1193,10 +1196,259 @@ func linuxDHCPServerFromLeaseDirWithReader(dir, interfaceName string, readFile f return "" } -func leaseMatchesInterface(name, content, interfaceName string) bool { - if strings.Contains(content, "interface") && strings.Contains(content, interfaceName) { - return true +func linuxDHClientDHCPServerForInterface(content, interfaceName string) (string, bool) { + server, _, explicit := linuxDHClientDHCPServerForInterfaceState(content, interfaceName) + return server, explicit +} + +func linuxDHClientDHCPServerForInterfaceState(content, interfaceName string) (string, bool, bool) { + if !dhclientContentHasInterface(content) { + return "", false, false + } + server := "" + blocks := linuxDHClientLeaseBlocks(content) + if len(blocks) == 0 { + if dhclientContentMatchesInterface(content, interfaceName) { + return linuxDHClientDHCPServer(content), true, true + } + return "", false, true + } + matched := false + sawInterfaceBlock := false + for _, block := range blocks { + if !dhclientContentHasInterface(block) { + continue + } + sawInterfaceBlock = true + if !dhclientContentMatchesInterface(block, interfaceName) { + continue + } + matched = true + server = linuxDHClientDHCPServer(block) + } + if matched { + return server, true, true + } + if sawInterfaceBlock { + return server, dhclientContentMatchesInterface(content, interfaceName), true + } + if !sawInterfaceBlock && dhclientContentMatchesInterface(content, interfaceName) { + return linuxDHClientDHCPServer(content), true, true + } + return server, false, true +} + +func linuxDHClientLeaseBlocks(content string) []string { + var blocks []string + for i := 0; i < len(content); { + switch content[i] { + case '#': + i = skipDHClientComment(content, i) + continue + case '"': + i = skipDHClientQuotedString(content, i) + continue + } + if !dhclientKeywordAt(content, i, "lease") { + i++ + continue + } + open := skipDHClientSpaceAndComments(content, i+len("lease")) + if open == len(content) || content[open] != '{' { + i++ + continue + } + end, next := dhclientBlockEnd(content, open) + if end < 0 { + if next <= i { + i++ + } else { + i = next + } + continue + } + blocks = append(blocks, content[i:end]) + i = end + } + return blocks +} + +func dhclientBlockEnd(content string, open int) (int, int) { + depth := 0 + for i := open; i < len(content); { + switch content[i] { + case '#': + i = skipDHClientComment(content, i) + case '"': + i = skipDHClientQuotedString(content, i) + case '{': + depth++ + i++ + case '}': + depth-- + i++ + if depth == 0 { + return i, i + } + default: + if depth == 1 && i != open && dhclientLeaseBlockStart(content, i) { + return -1, i + } + i++ + } + } + return -1, len(content) +} + +func dhclientLeaseBlockStart(content string, i int) bool { + if !dhclientKeywordAt(content, i, "lease") { + return false + } + open := skipDHClientSpaceAndComments(content, i+len("lease")) + return open < len(content) && content[open] == '{' +} + +func skipDHClientComment(content string, i int) int { + for i < len(content) && content[i] != '\n' { + i++ + } + return i +} + +func skipDHClientSpaceAndComments(content string, i int) int { + for i < len(content) { + if isDHClientSpace(content[i]) { + i++ + continue + } + if content[i] == '#' { + i = skipDHClientComment(content, i) + continue + } + return i + } + return i +} + +func skipDHClientQuotedString(content string, i int) int { + i++ + for i < len(content) { + if content[i] == '\n' || content[i] == '\r' { + return i + } + if content[i] == '\\' { + if i+1 < len(content) && (content[i+1] == '\n' || content[i+1] == '\r') { + return i + 1 + } + i += 2 + continue + } + if content[i] == '"' { + return i + 1 + } + i++ + } + return i +} + +func dhclientKeywordAt(content string, i int, keyword string) bool { + if i > 0 && isDHClientWordByte(content[i-1]) { + return false + } + if !strings.HasPrefix(content[i:], keyword) { + return false + } + end := i + len(keyword) + return end == len(content) || !isDHClientWordByte(content[end]) +} + +func isDHClientSpace(b byte) bool { + return b == ' ' || b == '\t' || b == '\n' || b == '\r' +} + +func isDHClientWordByte(b byte) bool { + return b == '_' || b == '-' || b == '.' || (b >= '0' && b <= '9') || (b >= 'A' && b <= 'Z') || (b >= 'a' && b <= 'z') +} + +func dhclientContentHasInterface(content string) bool { + return len(dhclientInterfaceNames(content)) > 0 +} + +func dhclientContentMatchesInterface(content, interfaceName string) bool { + for _, name := range dhclientInterfaceNames(content) { + if name == interfaceName { + return true + } + } + return false +} + +func dhclientInterfaceNames(content string) []string { + var names []string + for i := 0; i < len(content); { + switch content[i] { + case '#': + i = skipDHClientComment(content, i) + continue + case '"': + i = skipDHClientQuotedString(content, i) + continue + } + if !dhclientKeywordAt(content, i, "interface") { + i++ + continue + } + valueStart := skipDHClientSpaceAndComments(content, i+len("interface")) + value, next, ok := dhclientQuotedStringValue(content, valueStart) + if ok { + names = append(names, value) + i = next + continue + } + i++ } + return names +} + +func dhclientQuotedStringValue(content string, i int) (string, int, bool) { + if i == len(content) || content[i] != '"' { + return "", i, false + } + start := i + 1 + i = start + var out strings.Builder + escaped := false + for i < len(content) { + switch content[i] { + case '\n', '\r': + return "", i, false + case '\\': + if i+1 < len(content) && (content[i+1] == '\n' || content[i+1] == '\r') { + return "", i + 1, false + } + if !escaped { + out.WriteString(content[start:i]) + escaped = true + } + if i+1 < len(content) { + out.WriteByte(content[i+1]) + } + i += 2 + start = i + case '"': + if escaped { + out.WriteString(content[start:i]) + return out.String(), i + 1, true + } + return content[start:i], i + 1, true + default: + i++ + } + } + return "", i, false +} + +func leaseFilenameMatchesInterface(name, interfaceName string) bool { return strings.HasSuffix(name, "-"+interfaceName+".lease") || strings.HasSuffix(name, "."+interfaceName+".lease") || strings.HasSuffix(name, "."+interfaceName+".leases") @@ -1211,11 +1463,37 @@ func linuxSystemdDHCPServer(content string) string { } func linuxDHClientDHCPServer(content string) string { - matches := linuxDHClientServerPattern.FindAllStringSubmatch(content, -1) - if len(matches) == 0 { - return "" + server := "" + for i := 0; i < len(content); { + switch content[i] { + case '#': + i = skipDHClientComment(content, i) + continue + case '"': + i = skipDHClientQuotedString(content, i) + continue + } + if !dhclientKeywordAt(content, i, "option") { + i++ + continue + } + valueStart := skipDHClientSpaceAndComments(content, i+len("option")) + if !dhclientKeywordAt(content, valueStart, "dhcp-server-identifier") { + i++ + continue + } + valueStart = skipDHClientSpaceAndComments(content, valueStart+len("dhcp-server-identifier")) + valueEnd := valueStart + for valueEnd < len(content) && !isDHClientSpace(content[valueEnd]) && content[valueEnd] != ';' { + valueEnd++ + } + value := content[valueStart:valueEnd] + if ip := net.ParseIP(value).To4(); ip != nil { + server = ip.String() + } + i = valueEnd } - return matches[len(matches)-1][1] + return server } func linuxDHCPCDDHCPServer(content string) string { @@ -1351,7 +1629,7 @@ func firstInterfaceBinding(iface map[string]any, key string) map[string]any { } func hostName(s *Session) (string, any) { - return hostNameForPlatform(runtime.GOOS, os.Hostname, func() string { + return hostNameForPlatform(s.goos(), os.Hostname, func() string { return readLinuxKernelHostname(s.readFile) }, s.logr()) } @@ -1404,10 +1682,14 @@ func hostNameFromLookup(lookup func() (string, error), log *slog.Logger) (string } func fqdn(hostname string) string { + return fqdnWithLookup(hostname, net.LookupAddr) +} + +func fqdnWithLookup(hostname string, lookup func(string) ([]string, error)) string { if hostname == "" || strings.Contains(hostname, ".") { return hostname } - addrs, err := net.LookupAddr(hostname) + addrs, err := lookup(hostname) if err != nil || len(addrs) == 0 { return hostname } @@ -1538,6 +1820,10 @@ func primaryIPv6() string { if err != nil { return "" } + return primaryIPv6FromAddrs(addrs) +} + +func primaryIPv6FromAddrs(addrs []net.Addr) string { best := "" bestRank := 0 for _, addr := range addrs { @@ -1640,12 +1926,13 @@ func ipFromAddr(addr net.Addr) (net.IP, bool) { // domain, interfaces, primary interface and address selection, DHCP, and the // IPv4/IPv6 binding facts) for the current host. func networkingCoreFacts(s *Session) []ResolvedFact { - if runtime.GOOS == "plan9" { + goos := s.goos() + if goos == "plan9" { return plan9NetworkingCoreFacts(s) } nodeName, nodeNameValue := hostName(s) resolvedFQDN := fqdn(nodeName) - hostname, fqdn, domain := currentHostnameFacts(runtime.GOOS, nodeName, resolvedFQDN, "/etc/resolv.conf", s.readFile) + hostname, fqdn, domain := currentHostnameFacts(goos, nodeName, resolvedFQDN, "/etc/resolv.conf", s.readFile) var hostnameValue any if nodeNameValue != nil { hostnameValue = hostname @@ -1653,8 +1940,8 @@ func networkingCoreFacts(s *Session) []ResolvedFact { fqdnValue, domainValue := hostnameFactValues(hostnameValue, fqdn, domain) ipv4 := primaryIPv4() interfaces := networkingInterfaces(s) - configuredPrimary, interfaces := currentNetworkingData(runtime.GOOS, interfaces, s.commandOutput, s.readFile) - if runtime.GOOS == "windows" { + configuredPrimary, interfaces := currentNetworkingData(goos, interfaces, s.commandOutput, s.readFile) + if goos == "windows" { domain = currentWindowsNetworkingDomain(interfaces, s.commandOutput) fqdn = windowsFQDN(hostname, domain) fqdnValue, domainValue = hostnameFactValues(hostnameValue, fqdn, domain) @@ -1679,7 +1966,7 @@ func networkingCoreFacts(s *Session) []ResolvedFact { primaryScope6 := primaryIPv6Scope(interfaces, ipv6) primaryMAC, _ := primaryInterfaceFact(interfaces, primaryInterfaceName, "mac").(string) primaryMTU := primaryInterfaceFact(interfaces, primaryInterfaceName, "mtu") - primaryDHCP := networkingDHCPValue(runtime.GOOS, interfaces, ipv4) + primaryDHCP := networkingDHCPValue(goos, interfaces, ipv4) return []ResolvedFact{ {Name: "networking.hostname", Value: hostnameValue}, {Name: "networking.fqdn", Value: fqdnValue}, diff --git a/internal/engine/networking_test.go b/internal/engine/networking_test.go index 6f77cd84..5ec549ab 100644 --- a/internal/engine/networking_test.go +++ b/internal/engine/networking_test.go @@ -178,6 +178,114 @@ func TestNetworkingInterfacesWindowsLogsFailureLikeRubyResolver(t *testing.T) { } } +func TestNetworkingInterfacesForPlatformPlan9UsesSessionGlob(t *testing.T) { + t.Parallel() + + s := NewSession() + s.host = &fakeHostOS{ + files: map[string][]byte{ + "/net/ipifc/0/status": []byte(plan9Fixture(t, "ipifc_status")), + "/net/ether0/addr": []byte(plan9Fixture(t, "ether0_addr")), + }, + globs: map[string][]string{ + "/net/ipifc/*/status": {"/net/ipifc/0/status"}, + }, + } + calledSnapshotProvider := false + + got := networkingInterfacesForPlatform(s, "plan9", func() ([]networkInterfaceSnapshot, error) { + calledSnapshotProvider = true + return nil, nil + }) + if calledSnapshotProvider { + t.Fatal("networkingInterfacesForPlatform(plan9) called snapshot provider") + } + if _, ok := got["ether0"]; !ok { + t.Fatalf("networkingInterfacesForPlatform(plan9) = %#v, want ether0", got) + } +} + +func TestNetworkingInterfacesForPlatformLinuxAddsSessionMetadata(t *testing.T) { + t.Parallel() + + _, ipv4, err := net.ParseCIDR("10.16.122.20/24") + if err != nil { + t.Fatal(err) + } + ipv4.IP = net.ParseIP("10.16.122.20") + _, ipv6, err := net.ParseCIDR("fe80::250:56ff:fe9a:8481/64") + if err != nil { + t.Fatal(err) + } + ipv6.IP = net.ParseIP("fe80::250:56ff:fe9a:8481") + + s := NewSession() + s.host = &fakeHostOS{ + platform: "linux", + files: map[string][]byte{ + "/run/systemd/netif/leases/7": []byte("SERVER_ADDRESS=10.16.122.163\n"), + "/proc/net/if_inet6": []byte("fe80000000000000025056fffe9a8481 02 40 20 80 eth0\n"), + "/sys/class/net/eth0/operstate": []byte("up\n"), + "/sys/class/net/eth0/speed": []byte("1000\n"), + "/sys/class/net/eth0/duplex": []byte("full\n"), + }, + stats: map[string]os.FileInfo{ + "/sys/class/net/eth0/device": fakeFileInfo{name: "device"}, + }, + runOutputs: map[string]string{ + fakeRunKey("ip", "route", "show"): "default via 10.16.122.1 dev eth0 src 10.16.122.21\n", + fakeRunKey("ip", "-6", "route", "show"): "default via fe80::1 dev eth0 src 2001:db8::10 metric 1024\n", + }, + } + + got := networkingInterfacesForPlatform(s, "linux", func() ([]networkInterfaceSnapshot, error) { + return []networkInterfaceSnapshot{{ + Interface: net.Interface{ + Name: "eth0", + Index: 7, + MTU: 1500, + Flags: net.FlagUp | net.FlagBroadcast | net.FlagMulticast, + HardwareAddr: net.HardwareAddr{0x00, 0x50, 0x56, 0x9a, 0xf8, 0x6b}, + }, + Addrs: []net.Addr{ipv4, ipv6}, + }}, nil + }) + + eth0, ok := got["eth0"].(map[string]any) + if !ok { + t.Fatalf("eth0 = %#v, want map", got["eth0"]) + } + for key, want := range map[string]any{ + "dhcp": "10.16.122.163", + "operational_state": "up", + "physical": true, + "speed": 1000, + "duplex": "full", + } { + if got := eth0[key]; got != want { + t.Fatalf("eth0.%s = %#v, want %#v", key, got, want) + } + } + bindings, _ := eth0["bindings"].([]any) + if !bindingsContainAddress(bindings, "10.16.122.21") { + t.Fatalf("eth0.bindings = %#v, want route source address", eth0["bindings"]) + } + bindings6, _ := eth0["bindings6"].([]any) + if !bindingsContainAddress(bindings6, "2001:db8::10") { + t.Fatalf("eth0.bindings6 = %#v, want IPv6 route source address", eth0["bindings6"]) + } + for _, raw := range bindings6 { + binding, _ := raw.(map[string]any) + if binding["address"] == "fe80::250:56ff:fe9a:8481" { + if got, want := binding["flags"], []string{"permanent"}; !reflect.DeepEqual(got, want) { + t.Fatalf("link-local IPv6 flags = %#v, want %#v", got, want) + } + return + } + } + t.Fatalf("eth0.bindings6 = %#v, want link-local binding with flags", eth0["bindings6"]) +} + func TestNetworkingInterfacesWindowsReplacesInvalidFriendlyNameLikeRubyResolver(t *testing.T) { t.Parallel() @@ -455,6 +563,144 @@ func TestAddRouteSourceBindingsAddsMissingInterfaceAddresses(t *testing.T) { } } +func TestAddLinuxRouteSourceBindingsUsesIPv4AndIPv6Routes(t *testing.T) { + t.Parallel() + + host := &fakeHostOS{runOutputs: map[string]string{ + fakeRunKey("ip", "route", "show"): "default via 10.16.112.1 dev ens192 src 10.16.125.217\n", + fakeRunKey("ip", "-6", "route", "show"): "2001:db8::/64 dev ens192 src 2001:db8::20\n", + }} + s := NewSession() + s.host = host + interfaces := map[string]any{ + "ens192": map[string]any{ + "bindings": []any{map[string]any{"address": "10.16.112.10"}}, + "bindings6": []any{map[string]any{"address": "2001:db8::10"}}, + }, + } + + addLinuxRouteSourceBindings(s, interfaces) + + if got := interfaces["ens192"].(map[string]any)["bindings"]; !reflect.DeepEqual(got, []any{ + map[string]any{"address": "10.16.112.10"}, + map[string]any{"address": "10.16.125.217"}, + }) { + t.Fatalf("bindings = %#v", got) + } + if got := interfaces["ens192"].(map[string]any)["bindings6"]; !reflect.DeepEqual(got, []any{ + map[string]any{"address": "2001:db8::10"}, + map[string]any{"address": "2001:db8::20"}, + }) { + t.Fatalf("bindings6 = %#v", got) + } +} + +func TestWindowsFQDNCombinesHostnameAndDomainLikeRubyResolver(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + hostname string + domain string + want string + }{ + {name: "empty hostname", domain: "example.test", want: ""}, + {name: "no domain", hostname: "host", want: "host"}, + {name: "already fqdn", hostname: "host.example.test", domain: "ignored.test", want: "host.example.test"}, + {name: "short hostname and domain", hostname: "host", domain: "example.test", want: "host.example.test"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := windowsFQDN(tt.hostname, tt.domain); got != tt.want { + t.Fatalf("windowsFQDN(%q, %q) = %q, want %q", tt.hostname, tt.domain, got, tt.want) + } + }) + } +} + +func TestWindowsIPConfigAdapterNameParsesOnlyAdapterHeaders(t *testing.T) { + t.Parallel() + + tests := []struct { + header string + want string + }{ + {header: "Ethernet adapter Ethernet0:", want: "Ethernet0"}, + {header: "Wireless LAN adapter Wi-Fi:", want: "Wi-Fi"}, + {header: "Tunnel adapter isatap.example.test:", want: "isatap.example.test"}, + {header: "Connection-specific DNS Suffix . : example.test", want: ""}, + {header: "Ethernet:", want: ""}, + } + + for _, tt := range tests { + t.Run(tt.header, func(t *testing.T) { + if got := windowsIPConfigAdapterName(tt.header); got != tt.want { + t.Fatalf("windowsIPConfigAdapterName(%q) = %q, want %q", tt.header, got, tt.want) + } + }) + } +} + +func TestParseWindowsRegistryStringValue(t *testing.T) { + t.Parallel() + + input := strings.Join([]string{ + `HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\Tcpip\Parameters`, + " Domain REG_SZ example.test", + " SearchList REG_SZ", + " Hostname REG_DWORD 0x1", + }, "\n") + + tests := []struct { + name string + key string + want string + wantOK bool + }{ + {name: "value present", key: "Domain", want: "example.test", wantOK: true}, + {name: "empty REG_SZ value is present", key: "SearchList", want: "", wantOK: true}, + {name: "wrong registry type", key: "Hostname", want: "", wantOK: false}, + {name: "missing key", key: "DhcpDomain", want: "", wantOK: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := parseWindowsRegistryStringValue(input, tt.key) + if got != tt.want || ok != tt.wantOK { + t.Fatalf("parseWindowsRegistryStringValue(%q) = %q, %v; want %q, %v", tt.key, got, ok, tt.want, tt.wantOK) + } + }) + } +} + +func TestIgnoredIPAddressFiltersNonRoutableAddresses(t *testing.T) { + t.Parallel() + + tests := []struct { + address string + want bool + }{ + {address: "", want: true}, + {address: "not-an-ip", want: true}, + {address: "127.0.0.1", want: true}, + {address: "::1", want: true}, + {address: "169.254.10.20", want: true}, + {address: "fe80::1", want: true}, + {address: "fc00::1", want: true}, + {address: "192.0.2.10", want: false}, + {address: "2001:db8::1", want: false}, + } + + for _, tt := range tests { + t.Run(tt.address, func(t *testing.T) { + if got := ignoredIPAddress(tt.address); got != tt.want { + t.Fatalf("ignoredIPAddress(%q) = %v, want %v", tt.address, got, tt.want) + } + }) + } +} + func TestParseLinuxIfInet6Flags_matchesRubyIfInet6(t *testing.T) { t.Parallel() @@ -489,6 +735,20 @@ func TestParseLinuxIfInet6Flags_matchesRubyIfInet6(t *testing.T) { } } +func TestParseLinuxIfInet6FlagsSkipsMalformedRows(t *testing.T) { + t.Parallel() + + input := strings.Join([]string{ + "too few fields", + "not-32-hex 02 40 20 80 eth0", + "20010db8000000000000000000000001 02 40 20 not-hex eth0", + "20010db8000000000000000000000002 02 40 20 00 eth0", + }, "\n") + if got := parseLinuxIfInet6Flags(input); len(got) != 0 { + t.Fatalf("parseLinuxIfInet6Flags(malformed) = %#v, want empty", got) + } +} + func TestAddLinuxIfInet6FlagsToBindings(t *testing.T) { t.Parallel() @@ -508,6 +768,37 @@ func TestAddLinuxIfInet6FlagsToBindings(t *testing.T) { } } +func TestAddLinuxIfInet6FlagsIgnoresMalformedInterfacesAndBindings(t *testing.T) { + t.Parallel() + + interfaces := map[string]any{ + "not-map": "ignored", + "no-bindings": map[string]any{"mac": "00:00:5e:00:53:01"}, + "bad-bindings": map[string]any{"bindings6": "ignored"}, + "mixed-binding": map[string]any{"bindings6": []any{"ignored", map[string]any{}, map[string]any{"address": "2001:db8::10"}}}, + } + flags := map[string]map[string][]string{ + "missing": {"2001:db8::10": {"permanent"}}, + "not-map": {"2001:db8::10": {"permanent"}}, + "no-bindings": {"2001:db8::10": {"permanent"}}, + "bad-bindings": {"2001:db8::10": {"permanent"}}, + "mixed-binding": {"2001:db8::20": {"temporary"}}, + } + + addLinuxIfInet6Flags(interfaces, flags) + + bindings := interfaces["mixed-binding"].(map[string]any)["bindings6"].([]any) + for _, raw := range bindings { + binding, ok := raw.(map[string]any) + if !ok { + continue + } + if _, ok := binding["flags"]; ok { + t.Fatalf("addLinuxIfInet6Flags() added flags to malformed or non-matching binding %#v", binding) + } + } +} + func TestAddLinuxInterfaceMetadata_matchesRubyNetworkingResolver(t *testing.T) { t.Parallel() @@ -1033,6 +1324,92 @@ lo0: flags=8049 metric 0 mtu 16384 groups: lo ` +func TestDragonFlyIPv4MaskParsesHexAndDottedMasks(t *testing.T) { + t.Parallel() + + tests := []struct { + value string + want string + }{ + {value: "", want: ""}, + {value: "0xffffff00", want: "255.255.255.0"}, + {value: "255.255.0.0", want: "255.255.0.0"}, + {value: "not-a-mask", want: ""}, + } + + for _, tt := range tests { + t.Run(tt.value, func(t *testing.T) { + got := netmaskString(dragonFlyIPv4Mask(tt.value)) + if got != tt.want { + t.Fatalf("dragonFlyIPv4Mask(%q) = %q, want %q", tt.value, got, tt.want) + } + }) + } +} + +func TestDragonFlyIPv4BindingBuildsNetworkFromOptionalMask(t *testing.T) { + t.Parallel() + + binding, ok := dragonFlyIPv4Binding([]string{"inet", "10.0.2.15", "netmask", "0xffffff00", "broadcast", "10.0.2.255"}) + if !ok { + t.Fatal("dragonFlyIPv4Binding(valid) ok = false, want true") + } + if binding["address"] != "10.0.2.15" || binding["netmask"] != "255.255.255.0" || binding["network"] != "10.0.2.0" { + t.Fatalf("dragonFlyIPv4Binding(valid) = %#v", binding) + } + + binding, ok = dragonFlyIPv4Binding([]string{"inet", "10.0.2.15"}) + if !ok { + t.Fatal("dragonFlyIPv4Binding(no mask) ok = false, want true") + } + if _, exists := binding["netmask"]; exists { + t.Fatalf("dragonFlyIPv4Binding(no mask) = %#v, want no netmask", binding) + } + if _, exists := binding["network"]; exists { + t.Fatalf("dragonFlyIPv4Binding(no mask) = %#v, want no network", binding) + } + if binding, ok := dragonFlyIPv4Binding([]string{"inet", "not-an-ip"}); ok || binding != nil { + t.Fatalf("dragonFlyIPv4Binding(invalid) = %#v, %v; want nil, false", binding, ok) + } +} + +func TestDragonFlyIPv6BindingBuildsNetworkFromOptionalPrefix(t *testing.T) { + t.Parallel() + + binding, ok := dragonFlyIPv6Binding([]string{"inet6", "fe80::1%vtnet0", "prefixlen", "64"}) + if !ok { + t.Fatal("dragonFlyIPv6Binding(valid) ok = false, want true") + } + for key, want := range map[string]any{ + "address": "fe80::1", + "netmask": "ffff:ffff:ffff:ffff::", + "network": "fe80::", + "scope6": "link", + } { + if binding[key] != want { + t.Fatalf("dragonFlyIPv6Binding(valid)[%s] = %#v, want %#v", key, binding[key], want) + } + } + + binding, ok = dragonFlyIPv6Binding([]string{"inet6", "2001:db8::1", "prefixlen", "invalid"}) + if !ok { + t.Fatal("dragonFlyIPv6Binding(invalid prefix) ok = false, want true") + } + if _, exists := binding["netmask"]; exists { + t.Fatalf("dragonFlyIPv6Binding(invalid prefix) = %#v, want no netmask", binding) + } + if _, exists := binding["network"]; exists { + t.Fatalf("dragonFlyIPv6Binding(invalid prefix) = %#v, want no network", binding) + } + if binding["scope6"] != "global" { + t.Fatalf("dragonFlyIPv6Binding(invalid prefix) scope6 = %#v, want global", binding["scope6"]) + } + + if binding, ok := dragonFlyIPv6Binding([]string{"inet6", "10.0.2.15"}); ok || binding != nil { + t.Fatalf("dragonFlyIPv6Binding(IPv4) = %#v, %v; want nil, false", binding, ok) + } +} + func TestNetworkingInterfacesForPlatformDragonFlyFallsBackToIfconfigWhenGoHasNoInterfaces(t *testing.T) { t.Parallel() @@ -1337,68 +1714,442 @@ func TestLinuxDHCPServerReadsDHClientLeaseForInterface(t *testing.T) { } } -func TestLinuxDHClientDHCPServerUsesLastLeaseServer(t *testing.T) { +func TestLinuxDHCPCDDHCPServer(t *testing.T) { t.Parallel() - content := `lease { + content := strings.Join([]string{ + "interface eth0", + "static ip_address=192.0.2.10/24", + "dhcp_server_identifier='10.32.10.163'", + }, "\n") + if got, want := linuxDHCPCDDHCPServer(content), "10.32.10.163"; got != want { + t.Fatalf("linuxDHCPCDDHCPServer() = %q, want %q", got, want) + } + if got := linuxDHCPCDDHCPServer("interface eth0\nstatic routers=10.32.10.1\n"); got != "" { + t.Fatalf("linuxDHCPCDDHCPServer(no server) = %q, want empty", got) + } +} + +func TestLinuxDHCPServerFromLeaseDirReadsMatchingLease(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "00-commented.lease"), `lease { + # interface "eth0"; + option dhcp-server-identifier 10.66.66.66; +}`) + writeFile(t, filepath.Join(dir, "dhclient.leases"), `lease { + interface "eth0-backup"; + option dhcp-server-identifier 10.99.99.98; +} +lease { interface "eth0"; option dhcp-server-identifier 10.32.10.163; } lease { - interface "eth0"; - option dhcp-server-identifier 10.32.10.254; -}` + interface "eth0.100"; + option dhcp-server-identifier 10.99.99.97; +}`) + writeFile(t, filepath.Join(dir, "dhclient.en1.lease"), `lease { + interface "en1"; + option dhcp-server-identifier 10.99.99.99; +}`) + writeFile(t, filepath.Join(dir, "not-a-lease.txt"), `SERVER_ADDRESS=192.0.2.1`) - if got, want := linuxDHClientDHCPServer(content), "10.32.10.254"; got != want { - t.Fatalf("linuxDHClientDHCPServer() = %q, want %q", got, want) + if got, want := linuxDHCPServerFromLeaseDir(dir, "eth0"), "10.32.10.163"; got != want { + t.Fatalf("linuxDHCPServerFromLeaseDir() = %q, want %q", got, want) + } + if got, want := linuxDHCPServerFromLeaseDir(dir, "eth0-backup"), "10.99.99.98"; got != want { + t.Fatalf("linuxDHCPServerFromLeaseDir(eth0-backup) = %q, want %q", got, want) } } -func TestLinuxDHCPServerReadsNetworkManagerInternalLeaseForInterface(t *testing.T) { +func TestLinuxDHCPServerFromLeaseDirUsesFilenameFallbackWhenInterfaceValueMalformed(t *testing.T) { t.Parallel() - root := t.TempDir() - writeFile(t, filepath.Join(root, "var/lib/NetworkManager/internal-fdgh45-345356fg-dfg-dsfge5er4-sdfghgf45ty-lo.lease"), `# This is private data. Do not parse. -ADDRESS=11.22.36.241 -SERVER_ADDRESS=35.32.82.9 -`) - writeFile(t, filepath.Join(root, "var/lib/NetworkManager/internal-fdgh45-345356fg-dfg-dsfge5er4-sdfghgf45ty-eth0.lease"), `SERVER_ADDRESS=10.99.99.99 -`) + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "dhclient.eth0.lease"), `lease { + interface "broken + option host-name "router"; + option dhcp-server-identifier 10.32.10.163; +}`) - if got, want := linuxDHCPServerFromRoot(testSession, root, "lo", 1), "35.32.82.9"; got != want { - t.Fatalf("linuxDHCPServerFromRoot(testSession) = %q, want %q", got, want) + if got, want := linuxDHCPServerFromLeaseDir(dir, "eth0"), "10.32.10.163"; got != want { + t.Fatalf("linuxDHCPServerFromLeaseDir() = %q, want filename fallback server %q", got, want) } } -func TestLinuxDHCPServerFallsBackToDHCPCDCommand(t *testing.T) { +func TestLinuxDHCPServerFromLeaseDirDoesNotUseFilenameFallbackForUnrelatedExplicitInterface(t *testing.T) { t.Parallel() - root := t.TempDir() - run := func(name string, args ...string) string { - if name != "dhcpcd" || !reflect.DeepEqual(args, []string{"-U", "ens160"}) { - t.Fatalf("run(%q, %#v), want dhcpcd -U ens160", name, args) - } - return strings.Join([]string{ - "broadcast_address='10.16.127.255'", - "dhcp_server_identifier='10.32.22.9'", - "domain_name='delivery.puppetlabs.net'", - }, "\n") - } + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "dhclient.eth0.lease"), `lease { + interface "eth1"; + option dhcp-server-identifier 10.99.99.99; +}`) - if got, want := linuxDHCPServerFromRootWithRunner(root, "ens160", 1, run), "10.32.22.9"; got != want { - t.Fatalf("linuxDHCPServerFromRootWithRunner() = %q, want %q", got, want) + if got := linuxDHCPServerFromLeaseDir(dir, "eth0"); got != "" { + t.Fatalf("linuxDHCPServerFromLeaseDir() = %q, want empty for explicit non-matching interface", got) } } -func TestCoreFacts_networkingIncludesIP6(t *testing.T) { - collection := Collection(CoreFacts(testSession)) - networking, ok := collection["networking"].(map[string]any) - if !ok { - t.Fatalf("networking fact = %#v, want map", collection["networking"]) - } +func TestLinuxDHCPServerFromLeaseDirStopsAtMatchingLeaseWithoutServer(t *testing.T) { + t.Parallel() - if value, ok := networking["ip6"]; ok && value == "" { - t.Fatalf("networking.ip6 = empty string in %#v, want omitted or populated", networking) + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "dhclient.eth0.lease"), `lease { + interface "eth0"; + option host-name "dhcp-server-identifier 10.88.88.88"; + # option dhcp-server-identifier 10.99.99.99; +}`) + writeFile(t, filepath.Join(dir, "zz-dhclient.eth0.lease"), `lease { + interface "eth0"; + option dhcp-server-identifier 10.32.10.163; +}`) + + if got := linuxDHCPServerFromLeaseDir(dir, "eth0"); got != "" { + t.Fatalf("linuxDHCPServerFromLeaseDir() = %q, want empty latest matching lease server", got) + } +} + +func TestLinuxDHClientDHCPServerForInterfaceFallsBackWhenInterfaceIsOutsideLeaseBlock(t *testing.T) { + t.Parallel() + + content := `interface "eth0"; +lease { + option dhcp-server-identifier 10.32.10.163; +}` + + got, ok := linuxDHClientDHCPServerForInterface(content, "eth0") + if !ok { + t.Fatal("linuxDHClientDHCPServerForInterface() ok = false, want true") + } + if got != "10.32.10.163" { + t.Fatalf("linuxDHClientDHCPServerForInterface() = %q, want 10.32.10.163", got) + } + + content = `interface "eth0"; +lease { + option dhcp-server-identifier 10.32.10.163; +} +lease { + interface "eth1"; + option dhcp-server-identifier 10.99.99.99; +}` + got, ok = linuxDHClientDHCPServerForInterface(content, "eth0") + if !ok { + t.Fatal("linuxDHClientDHCPServerForInterface(mixed) ok = false, want true") + } + if got != "" { + t.Fatalf("linuxDHClientDHCPServerForInterface(mixed) = %q, want empty ambiguous mixed lease", got) + } + + content = `interface "eth0"; +lease { + option dhcp-server-identifier 10.32.10.163; +} +lease { + option dhcp-server-identifier 10.99.99.99; +}` + got, ok = linuxDHClientDHCPServerForInterface(content, "eth0") + if !ok { + t.Fatal("linuxDHClientDHCPServerForInterface(multiple unqualified) ok = false, want true") + } + if got != "10.99.99.99" { + t.Fatalf("linuxDHClientDHCPServerForInterface(multiple unqualified) = %q, want latest matching lease", got) + } +} + +func TestLinuxDHClientDHCPServerForInterfaceIgnoresCommentAndQuotedBraces(t *testing.T) { + t.Parallel() + + content := `# lease { ignored } +lease { + # } ignored comment brace + option host-name "edge-}router"; + interface "eth0"; + option dhcp-server-identifier 10.32.10.163; +} +lease { + interface "eth1"; + option dhcp-server-identifier 10.99.99.99; +}` + + got, ok := linuxDHClientDHCPServerForInterface(content, "eth0") + if !ok { + t.Fatal("linuxDHClientDHCPServerForInterface() ok = false, want true") + } + if got != "10.32.10.163" { + t.Fatalf("linuxDHClientDHCPServerForInterface() = %q, want 10.32.10.163", got) + } +} + +func TestLinuxDHClientDHCPServerForInterfaceSkipsHeaderAndInterfaceComments(t *testing.T) { + t.Parallel() + + content := `lease # dhclient permits comments in whitespace +{ + interface # comment before value + "eth0"; + option dhcp-server-identifier 10.32.10.163; +} +lease # another header comment +{ + interface "eth1"; + option dhcp-server-identifier 10.99.99.99; +}` + + got, ok := linuxDHClientDHCPServerForInterface(content, "eth0") + if !ok { + t.Fatal("linuxDHClientDHCPServerForInterface() ok = false, want true") + } + if got != "10.32.10.163" { + t.Fatalf("linuxDHClientDHCPServerForInterface() = %q, want 10.32.10.163", got) + } + if blocks := linuxDHClientLeaseBlocks(content); len(blocks) != 2 { + t.Fatalf("linuxDHClientLeaseBlocks() found %d blocks, want 2", len(blocks)) + } +} + +func TestLinuxDHClientDHCPServerForInterfaceMatchesInlineInterfaceStatement(t *testing.T) { + t.Parallel() + + content := `lease { interface "eth1"; option dhcp-server-identifier 10.99.99.99; } +lease { interface "eth0"; option dhcp-server-identifier 10.32.10.163; }` + + got, ok := linuxDHClientDHCPServerForInterface(content, "eth0") + if !ok { + t.Fatal("linuxDHClientDHCPServerForInterface() ok = false, want true") + } + if got != "10.32.10.163" { + t.Fatalf("linuxDHClientDHCPServerForInterface() = %q, want 10.32.10.163", got) + } +} + +func TestLinuxDHClientDHCPServerForInterfaceLatestMatchingLeaseWithoutServerWins(t *testing.T) { + t.Parallel() + + content := `lease { + interface "eth0"; + option dhcp-server-identifier 10.32.10.163; +} +lease { + interface "eth0"; + option host-name "dhcp-server-identifier 10.88.88.88"; + # option dhcp-server-identifier 10.99.99.99; +}` + + got, ok := linuxDHClientDHCPServerForInterface(content, "eth0") + if !ok { + t.Fatal("linuxDHClientDHCPServerForInterface() ok = false, want true") + } + if got != "" { + t.Fatalf("linuxDHClientDHCPServerForInterface() = %q, want empty latest matching lease server", got) + } +} + +func TestLinuxDHClientDHCPServerForInterfaceResyncsAfterMalformedLeaseBlock(t *testing.T) { + t.Parallel() + + content := `lease { + interface "eth0"; + option dhcp-server-identifier 10.32.10.163; +lease { + interface "eth1"; + option dhcp-server-identifier 10.99.99.99; +}` + + got, ok := linuxDHClientDHCPServerForInterface(content, "eth0") + if !ok { + t.Fatal("linuxDHClientDHCPServerForInterface() ok = false, want true") + } + if got != "" { + t.Fatalf("linuxDHClientDHCPServerForInterface() = %q, want empty when only later valid block belongs to eth1", got) + } + + blocks := linuxDHClientLeaseBlocks(content) + if len(blocks) != 1 || !dhclientContentMatchesInterface(blocks[0], "eth1") { + t.Fatalf("linuxDHClientLeaseBlocks() = %#v, want resynced eth1 block", blocks) + } +} + +func TestLinuxDHClientLeaseBlocksResyncsAcrossRepeatedMalformedBlocks(t *testing.T) { + t.Parallel() + + var content strings.Builder + for i := 0; i < 128; i++ { + content.WriteString("lease {\n") + content.WriteString(" interface \"stale\";\n") + } + content.WriteString(`lease { + interface "eth0"; + option dhcp-server-identifier 10.32.10.163; +}`) + + blocks := linuxDHClientLeaseBlocks(content.String()) + if len(blocks) != 1 || !dhclientContentMatchesInterface(blocks[0], "eth0") { + t.Fatalf("linuxDHClientLeaseBlocks() = %#v, want final eth0 block", blocks) + } +} + +func TestLinuxDHClientDHCPServerForInterfaceResyncsAfterUnterminatedQuotedString(t *testing.T) { + t.Parallel() + + content := `lease { + interface "eth0"; + option host-name "unterminated +lease { + interface "eth1"; + option dhcp-server-identifier 10.99.99.99; +}` + + got, ok := linuxDHClientDHCPServerForInterface(content, "eth0") + if !ok { + t.Fatal("linuxDHClientDHCPServerForInterface() ok = false, want true") + } + if got != "" { + t.Fatalf("linuxDHClientDHCPServerForInterface() = %q, want empty when only later valid block belongs to eth1", got) + } + + blocks := linuxDHClientLeaseBlocks(content) + if len(blocks) != 1 || !dhclientContentMatchesInterface(blocks[0], "eth1") { + t.Fatalf("linuxDHClientLeaseBlocks() = %#v, want resynced eth1 block", blocks) + } +} + +func TestDHClientScannerIgnoresInterfaceTokensInCommentsAndStrings(t *testing.T) { + t.Parallel() + + content := `# interface "commented0"; +lease { + option host-name "interface \"quoted0\""; + interface "eth0"; +}` + + got := dhclientInterfaceNames(content) + want := []string{"eth0"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("dhclientInterfaceNames(%q) = %#v, want %#v", content, got, want) + } +} + +func TestDHClientScannerHandlesMalformedBlocksAndQuotedStrings(t *testing.T) { + t.Parallel() + + if got := linuxDHClientLeaseBlocks(`lease { interface "eth0";`); len(got) != 0 { + t.Fatalf("linuxDHClientLeaseBlocks(unclosed) = %#v, want empty", got) + } + if got := linuxDHClientLeaseBlocks(`release { interface "eth0"; }`); len(got) != 0 { + t.Fatalf("linuxDHClientLeaseBlocks(release keyword) = %#v, want empty", got) + } + if value, next, ok := dhclientQuotedStringValue(`not quoted`, 0); ok || value != "" || next != 0 { + t.Fatalf("dhclientQuotedStringValue(unquoted) = %q, %d, %v; want empty, 0, false", value, next, ok) + } + if value, _, ok := dhclientQuotedStringValue(`"eth\"0"`, 0); !ok || value != `eth"0` { + t.Fatalf("dhclientQuotedStringValue(escaped) = %q, %v; want eth\"0, true", value, ok) + } + if value, _, ok := dhclientQuotedStringValue(`"unterminated`, 0); ok || value != "" { + t.Fatalf("dhclientQuotedStringValue(unterminated) = %q, %v; want empty, false", value, ok) + } + for _, input := range []string{"\"eth\n0\"", "\"eth\\\n0\""} { + if value, _, ok := dhclientQuotedStringValue(input, 0); ok || value != "" { + t.Fatalf("dhclientQuotedStringValue(%q) = %q, %v; want empty, false", input, value, ok) + } + } +} + +func TestLinuxDHClientDHCPServerUsesLastLeaseServer(t *testing.T) { + t.Parallel() + + content := `lease { + interface "eth0"; + option dhcp-server-identifier 10.32.10.163; +} +lease { + interface "eth0"; + option dhcp-server-identifier 10.32.10.254; +}` + + if got, want := linuxDHClientDHCPServer(content), "10.32.10.254"; got != want { + t.Fatalf("linuxDHClientDHCPServer() = %q, want %q", got, want) + } +} + +func TestLinuxDHCPServerReadsNetworkManagerInternalLeaseForInterface(t *testing.T) { + t.Parallel() + + root := t.TempDir() + writeFile(t, filepath.Join(root, "var/lib/NetworkManager/internal-fdgh45-345356fg-dfg-dsfge5er4-sdfghgf45ty-lo.lease"), `# This is private data. Do not parse. +ADDRESS=11.22.36.241 +SERVER_ADDRESS=35.32.82.9 +`) + writeFile(t, filepath.Join(root, "var/lib/NetworkManager/internal-fdgh45-345356fg-dfg-dsfge5er4-sdfghgf45ty-eth0.lease"), `SERVER_ADDRESS=10.99.99.99 +`) + + if got, want := linuxDHCPServerFromRoot(testSession, root, "lo", 1), "35.32.82.9"; got != want { + t.Fatalf("linuxDHCPServerFromRoot(testSession) = %q, want %q", got, want) + } +} + +func TestAddLinuxDHCPServersFromSnapshotsAddsInterfaceDHCP(t *testing.T) { + t.Parallel() + + host := &fakeHostOS{ + files: map[string][]byte{ + "/run/systemd/netif/leases/7": []byte("SERVER_ADDRESS=10.16.122.163\n"), + "/run/systemd/netif/leases/8": []byte("SERVER_ADDRESS=10.16.123.163\n"), + }, + } + s := NewSession() + s.host = host + values := map[string]any{ + "eth0": map[string]any{"bindings": []any{map[string]any{"address": "10.16.122.20"}}}, + "eth1": map[string]any{"bindings": []any{map[string]any{"address": "10.16.123.20"}}}, + } + snapshots := []networkInterfaceSnapshot{ + {Interface: net.Interface{Name: "eth1", Index: 8}}, + {Interface: net.Interface{Name: "eth0", Index: 7}}, + } + + addLinuxDHCPServersFromSnapshots(s, values, snapshots) + + if got, want := values["eth0"].(map[string]any)["dhcp"], "10.16.122.163"; got != want { + t.Fatalf("eth0 dhcp = %#v, want %q", got, want) + } + if got, want := values["eth1"].(map[string]any)["dhcp"], "10.16.123.163"; got != want { + t.Fatalf("eth1 dhcp = %#v, want %q", got, want) + } +} + +func TestLinuxDHCPServerFallsBackToDHCPCDCommand(t *testing.T) { + t.Parallel() + + root := t.TempDir() + run := func(name string, args ...string) string { + if name != "dhcpcd" || !reflect.DeepEqual(args, []string{"-U", "ens160"}) { + t.Fatalf("run(%q, %#v), want dhcpcd -U ens160", name, args) + } + return strings.Join([]string{ + "broadcast_address='10.16.127.255'", + "dhcp_server_identifier='10.32.22.9'", + "domain_name='delivery.puppetlabs.net'", + }, "\n") + } + + if got, want := linuxDHCPServerFromRootWithRunner(root, "ens160", 1, run), "10.32.22.9"; got != want { + t.Fatalf("linuxDHCPServerFromRootWithRunner() = %q, want %q", got, want) + } +} + +func TestCoreFacts_networkingIncludesIP6(t *testing.T) { + collection := Collection(CoreFacts(testSession)) + networking, ok := collection["networking"].(map[string]any) + if !ok { + t.Fatalf("networking fact = %#v, want map", collection["networking"]) + } + + if value, ok := networking["ip6"]; ok && value == "" { + t.Fatalf("networking.ip6 = empty string in %#v, want omitted or populated", networking) } } @@ -1451,6 +2202,60 @@ func TestPrimaryIPv6BindingReturnsMatchingBinding(t *testing.T) { } } +func TestPrimaryIPv6BindingReturnsNilForEmptyMalformedOrMissingBinding(t *testing.T) { + t.Parallel() + + interfaces := map[string]any{ + "bad": "ignored", + "no-list": map[string]any{"bindings6": "ignored"}, + "en0": map[string]any{ + "bindings6": []any{"ignored", map[string]any{"address": "2001:db8::10"}}, + }, + } + for _, primary := range []string{"", "2001:db8::20"} { + if got := primaryIPv6Binding(interfaces, primary); got != nil { + t.Fatalf("primaryIPv6Binding(%q) = %#v, want nil", primary, got) + } + } +} + +func TestNetworkAddressReturnsMaskedAddress(t *testing.T) { + t.Parallel() + + ip := &net.IPNet{IP: net.ParseIP("192.0.2.42"), Mask: net.CIDRMask(24, 32)} + if got := networkAddress(ip); got != "192.0.2.0" { + t.Fatalf("networkAddress(IPv4) = %q, want 192.0.2.0", got) + } + ip = &net.IPNet{IP: net.ParseIP("2001:db8::42"), Mask: net.CIDRMask(64, 128)} + if got := networkAddress(ip); got != "2001:db8::" { + t.Fatalf("networkAddress(IPv6) = %q, want 2001:db8::", got) + } + if got := networkAddress(nil); got != "" { + t.Fatalf("networkAddress(nil) = %q, want empty", got) + } +} + +func TestParseInterfaceAddrAcceptsIPNetAndIPAddrOnly(t *testing.T) { + t.Parallel() + + ipNet := &net.IPNet{IP: net.ParseIP("192.0.2.10"), Mask: net.CIDRMask(24, 32)} + ip, parsedNet, ok := parseInterfaceAddr(ipNet) + if !ok || !ip.Equal(ipNet.IP) || parsedNet == nil || !parsedNet.IP.Equal(ipNet.IP) || !reflect.DeepEqual(parsedNet.Mask, ipNet.Mask) { + t.Fatalf("parseInterfaceAddr(IPNet) = %v, %#v, %v; want %v, equivalent net, true", ip, parsedNet, ok, ipNet.IP) + } + + ipAddr := &net.IPAddr{IP: net.ParseIP("2001:db8::1")} + ip, parsedNet, ok = parseInterfaceAddr(ipAddr) + if !ok || !ip.Equal(ipAddr.IP) || parsedNet != nil { + t.Fatalf("parseInterfaceAddr(IPAddr) = %v, %#v, %v; want %v, nil, true", ip, parsedNet, ok, ipAddr.IP) + } + + ip, parsedNet, ok = parseInterfaceAddr(fakeAddr("192.0.2.10")) + if ok || ip != nil || parsedNet != nil { + t.Fatalf("parseInterfaceAddr(fakeAddr) = %v, %#v, %v; want nil, nil, false", ip, parsedNet, ok) + } +} + func TestPrimaryIPv6ScopeReturnsBindingScope(t *testing.T) { t.Parallel() @@ -1467,6 +2272,33 @@ func TestPrimaryIPv6ScopeReturnsBindingScope(t *testing.T) { } } +func TestPrimaryIPv6ScopeDefaultsToGlobalForRoutableAddressWithoutScope(t *testing.T) { + t.Parallel() + + interfaces := map[string]any{ + "en0": map[string]any{ + "bindings6": []any{ + map[string]any{"address": "2001:db8::10"}, + }, + }, + } + + if binding := primaryIPv6Binding(interfaces, "2001:db8::10"); binding == nil { + t.Fatal("primaryIPv6Binding() = nil; test fixture must contain the primary address") + } + if got := primaryIPv6Scope(interfaces, "2001:db8::10"); got != "global" { + t.Fatalf("primaryIPv6Scope() = %q, want global", got) + } +} + +func TestPrimaryIPv6ScopeOmitsEmptyPrimaryAddress(t *testing.T) { + t.Parallel() + + if got := primaryIPv6Scope(map[string]any{"en0": map[string]any{}}, ""); got != "" { + t.Fatalf("primaryIPv6Scope(empty) = %q, want empty", got) + } +} + // Primary IPv6 selection is a deliberate, documented deviation from Ruby // Facter's first-bound-address rule: routable addresses win over link-locals // on the primary interface — global scope first, then unique-local, then @@ -1583,6 +2415,24 @@ func TestPrimaryIPv6AddressIgnoresMissingPrimaryInterface(t *testing.T) { } } +func TestPrimaryIPv6AddressIgnoresMalformedBindings(t *testing.T) { + t.Parallel() + + interfaces := map[string]any{ + "en0": map[string]any{ + "bindings6": []any{ + "not a binding", + map[string]any{"address": "not an ip"}, + map[string]any{"address": "2001:db8::10"}, + }, + }, + } + + if got := primaryIPv6Address(interfaces, "en0"); got != "2001:db8::10" { + t.Fatalf("primaryIPv6Address(malformed bindings) = %q, want 2001:db8::10", got) + } +} + func TestInterfaceBindingIncludesIPv6Scope(t *testing.T) { t.Parallel() @@ -1593,6 +2443,7 @@ func TestInterfaceBindingIncludesIPv6Scope(t *testing.T) { }{ {"global", "2001:db8::10", "global"}, {"link local", "fe80::1", "link"}, + {"site local", "fec0::1", "site"}, {"loopback", "::1", "host"}, {"IPv4 compatible", "::192.0.2.128", "compat,global"}, } @@ -1606,6 +2457,16 @@ func TestInterfaceBindingIncludesIPv6Scope(t *testing.T) { } } +func TestIsIPv6SiteLocalRejectsInvalidAndNonSiteLocalAddresses(t *testing.T) { + t.Parallel() + + for _, ip := range []net.IP{nil, net.ParseIP("192.0.2.10"), net.ParseIP("2001:db8::1"), net.ParseIP("fe80::1")} { + if isIPv6SiteLocal(ip) { + t.Fatalf("isIPv6SiteLocal(%v) = true, want false", ip) + } + } +} + func TestInterfaceBindingIPv6NetmaskMatchesRubyBuildBinding(t *testing.T) { t.Parallel() @@ -1740,6 +2601,23 @@ func TestPrimaryIPv4BindingFindsNetmaskAndNetwork(t *testing.T) { } } +func TestPrimaryIPv4BindingReturnsNilForEmptyMalformedOrMissingBinding(t *testing.T) { + t.Parallel() + + interfaces := map[string]any{ + "bad": "ignored", + "no-list": map[string]any{"bindings": "ignored"}, + "eth0": map[string]any{ + "bindings": []any{"ignored", map[string]any{"address": "192.0.2.10"}}, + }, + } + for _, primary := range []string{"", "192.0.2.20"} { + if got := primaryIPv4Binding(interfaces, primary); got != nil { + t.Fatalf("primaryIPv4Binding(%q) = %#v, want nil", primary, got) + } + } +} + func TestPrimaryInterfaceFactFindsMAC(t *testing.T) { interfaces := map[string]any{ "eth0": map[string]any{"mac": "00:50:56:9a:61:46"}, @@ -1752,6 +2630,18 @@ func TestPrimaryInterfaceFactFindsMAC(t *testing.T) { } } +func TestPrimaryInterfaceFactReturnsNilForMissingInterface(t *testing.T) { + interfaces := map[string]any{ + "eth0": "not-a-map", + } + + for _, name := range []string{"", "eth0", "missing"} { + if got := primaryInterfaceFact(interfaces, name, "mac"); got != nil { + t.Fatalf("primaryInterfaceFact(%q) = %#v, want nil", name, got) + } + } +} + func TestLinuxPrimaryInterfaceFromProcRouteMatchesRubyResolver(t *testing.T) { t.Parallel() @@ -2044,6 +2934,15 @@ func TestHostnameFactValuesExposeNilDomainForShortLinuxHostnameLikeRubyResolver( } } +func TestHostnameFactValuesOmitFQDNAndDomainWhenHostnameLookupFailed(t *testing.T) { + t.Parallel() + + fqdnValue, domainValue := hostnameFactValues(nil, "foo.example.test", "example.test") + if fqdnValue != nil || domainValue != nil { + t.Fatalf("hostnameFactValues(nil) = %#v, %#v; want nil, nil", fqdnValue, domainValue) + } +} + func TestHostNameFromLookupReturnsNilValueWhenLookupFailsLikeRubyResolver(t *testing.T) { debugMessages := []string{} logger := captureLogger(&debugMessages, nil, nil) @@ -2093,3 +2992,202 @@ func TestLinuxHostNameFromLookupsFallsBackWhenPrimaryLookupReturnsZeroAddressLik t.Fatalf("hostname fact value = %#v, want kernel-host", value) } } + +func TestLinuxHostNameFromLookupsFallsBackWhenPrimaryLookupFails(t *testing.T) { + hostname, value := linuxHostNameFromLookups( + func() (string, error) { return "", errors.New("hostname unavailable") }, + func() string { return "kernel-host" }, + discardLog(), + ) + + if hostname != "kernel-host" || value != "kernel-host" { + t.Fatalf("linuxHostNameFromLookups() = %q, %#v; want kernel-host", hostname, value) + } +} + +func TestLinuxHostNameFromLookupsReturnsNilWhenFallbackIsMissingOrUnusable(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + fallback func() string + }{ + {name: "missing fallback"}, + {name: "empty fallback", fallback: func() string { return " " }}, + {name: "zero address fallback", fallback: func() string { return "0.0.0.0" }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + hostname, value := linuxHostNameFromLookups( + func() (string, error) { return "", nil }, + tt.fallback, + discardLog(), + ) + if hostname != "" || value != nil { + t.Fatalf("linuxHostNameFromLookups() = %q, %#v; want empty nil", hostname, value) + } + }) + } +} + +func TestLinuxHostNameFromLookupsPrefersUsablePrimaryLookup(t *testing.T) { + fallbackCalled := false + hostname, value := linuxHostNameFromLookups( + func() (string, error) { return "socket-host", nil }, + func() string { + fallbackCalled = true + return "kernel-host" + }, + discardLog(), + ) + + if hostname != "socket-host" || value != "socket-host" { + t.Fatalf("linuxHostNameFromLookups() = %q, %#v; want socket-host", hostname, value) + } + if fallbackCalled { + t.Fatal("linuxHostNameFromLookups() called fallback for usable primary hostname") + } +} + +func TestFQDNReturnsEmptyAndAlreadyQualifiedNames(t *testing.T) { + tests := []struct { + name string + hostname string + want string + }{ + {name: "empty", hostname: "", want: ""}, + {name: "already qualified", hostname: "node.example.test", want: "node.example.test"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + if got := fqdn(tt.hostname); got != tt.want { + t.Fatalf("fqdn(%q) = %q, want %q", tt.hostname, got, tt.want) + } + }) + } +} + +func TestFQDNWithLookupUsesReverseLookupAndFallsBack(t *testing.T) { + t.Parallel() + + got := fqdnWithLookup("node", func(host string) ([]string, error) { + if host != "node" { + t.Fatalf("lookup host = %q, want node", host) + } + return []string{"node.example.test."}, nil + }) + if got != "node.example.test" { + t.Fatalf("fqdnWithLookup() = %q, want node.example.test", got) + } + + for _, tt := range []struct { + name string + addrs []string + err error + }{ + {name: "lookup error", err: os.ErrNotExist}, + {name: "empty lookup"}, + } { + t.Run(tt.name, func(t *testing.T) { + got := fqdnWithLookup("node", func(string) ([]string, error) { + return tt.addrs, tt.err + }) + if got != "node" { + t.Fatalf("fqdnWithLookup(%s) = %q, want node", tt.name, got) + } + }) + } +} + +func TestHostNameForPlatformUsesLinuxFallbackOnlyOnLinux(t *testing.T) { + hostname, value := hostNameForPlatform( + "linux", + func() (string, error) { return "", nil }, + func() string { return "kernel-host" }, + discardLog(), + ) + if hostname != "kernel-host" || value != "kernel-host" { + t.Fatalf("linux hostNameForPlatform() = %q, %#v, want kernel-host", hostname, value) + } + + hostname, value = hostNameForPlatform( + "darwin", + func() (string, error) { return "", nil }, + func() string { + t.Fatal("non-Linux platform used Linux hostname fallback") + return "unused" + }, + discardLog(), + ) + if hostname != "" || value != "" { + t.Fatalf("darwin hostNameForPlatform() = %q, %#v, want empty lookup result", hostname, value) + } +} + +func TestReadLinuxKernelHostnameUsesInjectedReader(t *testing.T) { + readFile := func(path string) ([]byte, error) { + if path != "/proc/sys/kernel/hostname" { + t.Fatalf("path = %q, want kernel hostname path", path) + } + return []byte("kernel-host\n"), nil + } + + if got := readLinuxKernelHostname(readFile); got != "kernel-host" { + t.Fatalf("readLinuxKernelHostname() = %q, want kernel-host", got) + } +} + +func TestIPFromAddrAcceptsIPNetAndIPAddrOnly(t *testing.T) { + ipNet := &net.IPNet{IP: net.ParseIP("192.0.2.10"), Mask: net.CIDRMask(24, 32)} + if got, ok := ipFromAddr(ipNet); !ok || !got.Equal(ipNet.IP) { + t.Fatalf("ipFromAddr(IPNet) = %v, %v, want %v, true", got, ok, ipNet.IP) + } + ipAddr := &net.IPAddr{IP: net.ParseIP("2001:db8::1")} + if got, ok := ipFromAddr(ipAddr); !ok || !got.Equal(ipAddr.IP) { + t.Fatalf("ipFromAddr(IPAddr) = %v, %v, want %v, true", got, ok, ipAddr.IP) + } + if got, ok := ipFromAddr(fakeAddr("192.0.2.10")); ok || got != nil { + t.Fatalf("ipFromAddr(fakeAddr) = %v, %v, want nil, false", got, ok) + } +} + +func TestIPv6SelectionRankRejectsNonCandidates(t *testing.T) { + for _, raw := range []string{"", "192.0.2.10", "::1", "::"} { + if rank, ok := ipv6SelectionRank(net.ParseIP(raw)); ok { + t.Fatalf("ipv6SelectionRank(%q) = %d, true; want false", raw, rank) + } + } +} + +func TestPrimaryIPv6FromAddrsPrefersGlobalAndSkipsLinkLocal(t *testing.T) { + addrs := []net.Addr{ + ipNetAddr("fe80::1", 64), + ipNetAddr("fd00::10", 64), + ipNetAddr("2001:db8::10", 64), + ipNetAddr("192.0.2.10", 24), + } + + if got := primaryIPv6FromAddrs(addrs); got != "2001:db8::10" { + t.Fatalf("primaryIPv6FromAddrs() = %q, want global address", got) + } + if got := primaryIPv6FromAddrs([]net.Addr{ipNetAddr("fe80::1", 64)}); got != "" { + t.Fatalf("primaryIPv6FromAddrs(link-local only) = %q, want empty", got) + } +} + +func ipNetAddr(raw string, bits int) net.Addr { + ip := net.ParseIP(raw) + maskBits := 128 + if ip.To4() != nil { + maskBits = 32 + } + return &net.IPNet{IP: ip, Mask: net.CIDRMask(bits, maskBits)} +} + +type fakeAddr string + +func (a fakeAddr) Network() string { return "fake" } +func (a fakeAddr) String() string { return string(a) } diff --git a/internal/engine/os.go b/internal/engine/os.go index 2a9bdeaa..e742c55d 100644 --- a/internal/engine/os.go +++ b/internal/engine/os.go @@ -157,7 +157,7 @@ func windowsArchitectureFromHardware(hardware string) string { var linuxI386ArchitectureRE = regexp.MustCompile(`^(?:i[3-6]86|pentium)$`) func probeArchitectureName(s *Session) string { - return currentArchitectureName(runtime.GOOS, s.cachedHardwareModel()) + return currentArchitectureName(s.goos(), s.cachedHardwareModel()) } func currentArchitectureName(goos, machine string) string { @@ -165,17 +165,18 @@ func currentArchitectureName(goos, machine string) string { } func probeKernelRelease(s *Session) string { - if runtime.GOOS == "plan9" { + if s.goos() == "plan9" { return "" } return strings.TrimSpace(s.commandOutput("uname", "-r")) } func probeHardwareModel(s *Session) string { - if runtime.GOOS == "windows" { + goos := s.goos() + if goos == "windows" { return windowsHardwareFromGoArch(runtime.GOARCH) } - if runtime.GOOS == "plan9" { + if goos == "plan9" { return plan9Architecture(s.readFile, runtime.GOARCH) } out := s.commandOutput("uname", "-m") @@ -186,7 +187,7 @@ func probeHardwareModel(s *Session) string { } func probeMacOSModel(s *Session) string { - return currentMacOSModel(runtime.GOOS, s.commandOutput) + return currentMacOSModel(s.goos(), s.commandOutput) } func currentMacOSModel(goos string, run commandRunner) string { @@ -197,17 +198,18 @@ func currentMacOSModel(goos string, run commandRunner) string { } func probeOSRelease(s *Session) any { - if runtime.GOOS == "windows" { + goos := s.goos() + if goos == "windows" { if release := currentWindowsOSRelease(s.cachedWindowsOSVersionInput()); len(release) > 0 { return release } return nil } - return currentOSRelease(s, runtime.GOOS, s.readFile, s.commandOutput) + return currentOSRelease(s, goos, s.readFile, s.commandOutput) } func probeWindowsOSVersionInput(s *Session) string { - if runtime.GOOS != "windows" { + if s.goos() != "windows" { return "" } return windowsWMIOutput(s.commandOutput, "os", "OtherTypeDescription,ProductType,Version") @@ -749,7 +751,7 @@ type macOSInfo struct { } func probeMacOSInfo(s *Session) macOSInfo { - return currentMacOSInfo(runtime.GOOS, s.commandOutput) + return currentMacOSInfo(s.goos(), s.commandOutput) } func currentMacOSInfo(goos string, run commandRunner) macOSInfo { @@ -824,7 +826,7 @@ type macOSSystemProfilerEthernet struct { } func probeMacOSSystemProfilerHardware(s *Session) macOSSystemProfilerHardware { - if runtime.GOOS != "darwin" { + if s.goos() != "darwin" { return macOSSystemProfilerHardware{} } out := s.commandOutput("system_profiler", "SPHardwareDataType") @@ -835,7 +837,7 @@ func probeMacOSSystemProfilerHardware(s *Session) macOSSystemProfilerHardware { } func probeMacOSSystemProfilerSoftware(s *Session) macOSSystemProfilerSoftware { - if runtime.GOOS != "darwin" { + if s.goos() != "darwin" { return macOSSystemProfilerSoftware{} } out := s.commandOutput("system_profiler", "SPSoftwareDataType") @@ -846,7 +848,7 @@ func probeMacOSSystemProfilerSoftware(s *Session) macOSSystemProfilerSoftware { } func probeMacOSSystemProfilerEthernet(s *Session) macOSSystemProfilerEthernet { - return currentMacOSSystemProfilerEthernet(runtime.GOOS, s.commandOutput) + return currentMacOSSystemProfilerEthernet(s.goos(), s.commandOutput) } func currentMacOSSystemProfilerEthernet(goos string, run commandRunner) macOSSystemProfilerEthernet { diff --git a/internal/engine/os_test.go b/internal/engine/os_test.go index 847b830c..5365fd93 100644 --- a/internal/engine/os_test.go +++ b/internal/engine/os_test.go @@ -152,6 +152,215 @@ func TestCurrentOSReleaseWindowsUsesKernelAndDescriptionData(t *testing.T) { } } +func TestCurrentOSReleaseSkipsUnsupportedAndPlan9Platforms(t *testing.T) { + readFile := func(string) ([]byte, error) { + t.Fatal("currentOSRelease read file for unsupported platform") + return nil, os.ErrNotExist + } + run := func(string, ...string) string { + t.Fatal("currentOSRelease ran command for unsupported platform") + return "" + } + + for _, goos := range []string{"hurd", "plan9"} { + if got := currentOSRelease(testSession, goos, readFile, run); got != nil { + t.Fatalf("currentOSRelease(%s) = %#v, want nil", goos, got) + } + } +} + +func TestCurrentOSReleaseFallsBackToKernelRelease(t *testing.T) { + tests := []struct { + name string + platform string + readFile fileReader + run commandRunner + want string + }{ + { + name: "linux missing os release", + platform: "linux", + readFile: func(string) ([]byte, error) { return nil, os.ErrNotExist }, + run: func(string, ...string) string { return "" }, + want: "6.1.0-test", + }, + { + name: "freebsd missing userland release", + platform: "freebsd", + readFile: func(string) ([]byte, error) { return nil, os.ErrNotExist }, + run: func(string, ...string) string { return "" }, + want: "14.0-RELEASE", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := NewSessionContext(t.Context()) + s.host = &fakeHostOS{ + platform: tt.platform, + runOutputs: map[string]string{ + fakeRunKey("uname", "-r"): tt.want + "\n", + }, + } + + if got := currentOSRelease(s, tt.platform, tt.readFile, tt.run); got != tt.want { + t.Fatalf("currentOSRelease(%s) = %#v, want %q", tt.platform, got, tt.want) + } + }) + } +} + +func TestCurrentOSReleaseFreeBSDUsesInstalledUserlandVersion(t *testing.T) { + t.Parallel() + + run := func(name string, args ...string) string { + if name != "/bin/freebsd-version" { + t.Fatalf("run(%q, %#v), want freebsd-version", name, args) + } + if !reflect.DeepEqual(args, []string{"-k"}) && !reflect.DeepEqual(args, []string{"-ru"}) { + t.Fatalf("run(%q, %#v), want -k or -ru", name, args) + } + if args[0] == "-k" { + return "13.0-CURRENT\n" + } + return "12.1-RELEASE-p3\n12.0-STABLE\n" + } + + got := currentOSRelease(testSession, "freebsd", nil, run) + want := map[string]any{ + "full": "12.0-STABLE", + "major": "12", + "minor": "0", + "branch": "STABLE", + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("currentOSRelease(freebsd) = %#v, want %#v", got, want) + } +} + +func TestCurrentOSReleaseWindowsReturnsNilWithoutVersion(t *testing.T) { + calls := 0 + got := currentOSRelease(testSession, "windows", nil, func(name string, args ...string) string { + calls++ + return "" + }) + if got != nil { + t.Fatalf("currentOSRelease(windows empty version) = %#v, want nil", got) + } + if calls == 0 { + t.Fatal("currentOSRelease(windows empty version) did not query version data") + } +} + +func TestProbeOSReleaseUsesSessionPlatform(t *testing.T) { + host := &fakeHostOS{ + platform: "windows", + runOutputs: map[string]string{ + fakeRunKey("wmic", "os", "get", "OtherTypeDescription,ProductType,Version", "/value"): "OtherTypeDescription=\r\nProductType=1\r\nVersion=10.0.22631\r\n", + }, + } + s := NewSessionContext(t.Context()) + s.host = host + + got := probeOSRelease(s) + want := map[string]any{"full": "11", "major": "11"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("probeOSRelease() = %#v, want %#v", got, want) + } +} + +func TestOSProbesUseSessionPlatformForPlan9(t *testing.T) { + s := NewSessionContext(t.Context()) + s.host = &fakeHostOS{ + platform: "plan9", + files: map[string][]byte{ + "/env/objtype": []byte("amd64\x00"), + }, + runOutputs: map[string]string{ + fakeRunKey("uname", "-r"): "host-kernel\n", + fakeRunKey("uname", "-m"): "host-machine\n", + }, + } + + if got := probeKernelRelease(s); got != "" { + t.Fatalf("probeKernelRelease() = %q, want empty on Plan 9", got) + } + if got := probeHardwareModel(s); got != "amd64" { + t.Fatalf("probeHardwareModel() = %q, want Plan 9 objtype", got) + } + if got := probeArchitectureName(s); got != "amd64" { + t.Fatalf("probeArchitectureName() = %q, want Plan 9 architecture", got) + } + if got := probeWindowsOSVersionInput(s); got != "" { + t.Fatalf("probeWindowsOSVersionInput() = %q, want empty on Plan 9", got) + } +} + +func TestMacOSProbesUseSessionPlatform(t *testing.T) { + s := NewSessionContext(t.Context()) + s.host = &fakeHostOS{ + platform: "darwin", + runOutputs: map[string]string{ + fakeRunKey("sysctl", "-n", "hw.model"): "MacBookPro11,4\n", + fakeRunKey("sw_vers"): "ProductName:\tmacOS\nProductVersion:\t14.5\nBuildVersion:\t23F79\n", + fakeRunKey("system_profiler", "SPHardwareDataType"): "Hardware:\n\n Hardware Overview:\n\n Model Name: MacBook Pro\n Model Identifier: MacBookPro11,4\n", + fakeRunKey("system_profiler", "SPSoftwareDataType"): "Software:\n\n System Software Overview:\n\n System Version: macOS 14.5 (23F79)\n", + fakeRunKey("system_profiler", "SPEthernetDataType"): "Ethernet Cards:\n\n Ethernet:\n\n BSD name: en0\n", + }, + } + + if got := probeMacOSModel(s); got != "MacBookPro11,4" { + t.Fatalf("probeMacOSModel() = %q, want MacBookPro11,4", got) + } + if got := probeMacOSInfo(s).ProductVersion; got != "14.5" { + t.Fatalf("probeMacOSInfo().ProductVersion = %q, want 14.5", got) + } + if got := probeMacOSSystemProfilerHardware(s).ModelIdentifier; got != "MacBookPro11,4" { + t.Fatalf("probeMacOSSystemProfilerHardware().ModelIdentifier = %q, want MacBookPro11,4", got) + } + if got := probeMacOSSystemProfilerSoftware(s).SystemVersion; got != "macOS 14.5 (23F79)" { + t.Fatalf("probeMacOSSystemProfilerSoftware().SystemVersion = %q, want macOS 14.5 (23F79)", got) + } + if got := probeMacOSSystemProfilerEthernet(s).BSDName; got != "en0" { + t.Fatalf("probeMacOSSystemProfilerEthernet().BSDName = %q, want en0", got) + } +} + +func TestMacOSCurrentHelpersSkipNonDarwin(t *testing.T) { + run := func(string, ...string) string { + t.Fatal("macOS helper ran command outside Darwin") + return "" + } + + if got := currentMacOSModel("linux", run); got != "" { + t.Fatalf("currentMacOSModel(linux) = %q, want empty", got) + } + if got := currentMacOSInfo("linux", run); got != (macOSInfo{}) { + t.Fatalf("currentMacOSInfo(linux) = %#v, want empty", got) + } + if got := currentMacOSSystemProfilerEthernet("linux", run); got != (macOSSystemProfilerEthernet{}) { + t.Fatalf("currentMacOSSystemProfilerEthernet(linux) = %#v, want empty", got) + } +} + +func TestMacOSSystemProfilerProbesOmitEmptyOutput(t *testing.T) { + s := NewSessionContext(t.Context()) + s.host = &fakeHostOS{ + platform: "darwin", + runOutputs: map[string]string{ + fakeRunKey("system_profiler", "SPHardwareDataType"): "", + fakeRunKey("system_profiler", "SPSoftwareDataType"): "", + }, + } + + if got := probeMacOSSystemProfilerHardware(s); got != (macOSSystemProfilerHardware{}) { + t.Fatalf("probeMacOSSystemProfilerHardware(empty) = %#v, want empty", got) + } + if got := probeMacOSSystemProfilerSoftware(s); got != (macOSSystemProfilerSoftware{}) { + t.Fatalf("probeMacOSSystemProfilerSoftware(empty) = %#v, want empty", got) + } +} + func TestCurrentWindowsOSDescriptionMatchesRubyResolver(t *testing.T) { t.Parallel() @@ -226,6 +435,14 @@ func TestCurrentWindowsKernelLogsFailureLikeRubyResolver(t *testing.T) { } } +func TestCurrentWindowsOSReleaseReturnsNilWithoutVersion(t *testing.T) { + t.Parallel() + + if got := currentWindowsOSRelease("ProductType=1\r\nOtherTypeDescription=\r\n"); got != nil { + t.Fatalf("currentWindowsOSRelease(no version) = %#v, want nil", got) + } +} + func TestParseWindowsProductReleaseMatchesRubyResolver(t *testing.T) { t.Parallel() @@ -263,6 +480,18 @@ func TestParseWindowsProductReleaseFallsBackToReleaseID(t *testing.T) { } } +func TestCurrentWindowsProductReleaseRunsRegistryQuery(t *testing.T) { + got := currentWindowsProductRelease("windows", func(name string, args ...string) string { + if name != "reg" || !reflect.DeepEqual(args, []string{"query", `HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion`}) { + t.Fatalf("run = %s %v, want Windows current version registry query", name, args) + } + return " ProductName REG_SZ Windows Server 2022 Standard\n" + }) + if got.ProductName != "Windows Server 2022 Standard" { + t.Fatalf("ProductName = %q, want Windows Server 2022 Standard", got.ProductName) + } +} + func TestWindowsProductReleaseFactsReturnStructuredFacts(t *testing.T) { t.Parallel() @@ -319,6 +548,21 @@ func TestCurrentWindowsSystem32MatchesRubyResolver(t *testing.T) { } } +func TestCurrentWindowsProcessWOW64ReadsEnvironment(t *testing.T) { + t.Setenv("PROCESSOR_ARCHITEW6432", "AMD64") + + wow64, ok := currentWindowsProcessWOW64() + if !wow64 || !ok { + t.Fatalf("currentWindowsProcessWOW64() = %v, %v; want true, true", wow64, ok) + } + + t.Setenv("PROCESSOR_ARCHITEW6432", "") + wow64, ok = currentWindowsProcessWOW64() + if wow64 || !ok { + t.Fatalf("currentWindowsProcessWOW64() after clear = %v, %v; want false, true", wow64, ok) + } +} + func TestWindowsSystem32FactsReturnStructuredFacts(t *testing.T) { t.Parallel() @@ -491,6 +735,55 @@ func TestCurrentOSReleaseOpenBSDUsesKernelReleaseMap(t *testing.T) { } } +func TestWindowsHardwareAndArchitectureMappings(t *testing.T) { + tests := []struct { + goarch string + wantHardware string + wantArch string + }{ + {goarch: "amd64", wantHardware: "x86_64", wantArch: "x64"}, + {goarch: "386", wantHardware: "i686", wantArch: "x86"}, + {goarch: "riscv64", wantHardware: "riscv64", wantArch: "riscv64"}, + } + for _, tt := range tests { + t.Run(tt.goarch, func(t *testing.T) { + hardware := windowsHardwareFromGoArch(tt.goarch) + if hardware != tt.wantHardware { + t.Fatalf("windowsHardwareFromGoArch(%q) = %q, want %q", tt.goarch, hardware, tt.wantHardware) + } + if arch := windowsArchitectureFromHardware(hardware); arch != tt.wantArch { + t.Fatalf("windowsArchitectureFromHardware(%q) = %q, want %q", hardware, arch, tt.wantArch) + } + }) + } +} + +func TestWindows6ReleaseMapsConsumerAndServerNames(t *testing.T) { + tests := []struct { + name string + version string + consumer bool + want string + }{ + {name: "windows 8.1", version: "6.3", consumer: true, want: "8.1"}, + {name: "windows 8", version: "6.2", consumer: true, want: "8"}, + {name: "windows 7", version: "6.1", consumer: true, want: "7"}, + {name: "vista", version: "6.0", consumer: true, want: "Vista"}, + {name: "server 2012 r2", version: "6.3", want: "2012 R2"}, + {name: "server 2012", version: "6.2", want: "2012"}, + {name: "server 2008 r2", version: "6.1", want: "2008 R2"}, + {name: "server 2008", version: "6.0", want: "2008"}, + {name: "unknown", version: "5.2", want: ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := windows6Release(tt.version, tt.consumer); got != tt.want { + t.Fatalf("windows6Release(%q, %v) = %q, want %q", tt.version, tt.consumer, got, tt.want) + } + }) + } +} + func TestCurrentOSReleaseNetBSDUsesKernelReleaseMap(t *testing.T) { got := currentOSRelease(testSession, "netbsd", nil, func(name string, args ...string) string { if name != "uname" || !reflect.DeepEqual(args, []string{"-r"}) { @@ -778,6 +1071,56 @@ func TestCurrentOSRelease_prefersDistroSpecificReleaseFiles(t *testing.T) { } } +func TestSpecificLinuxOSRelease_ignoresMissingAndMalformedDistroFiles(t *testing.T) { + tests := []struct { + id string + path string + malformed string + }{ + {id: "mageia", path: "/etc/mageia-release", malformed: "Mageia Cauldron\n"}, + {id: "openwrt", path: "/etc/openwrt_version", malformed: "snapshot\n"}, + {id: "gentoo", path: "/etc/gentoo-release", malformed: "Gentoo Base System\n"}, + {id: "slackware", path: "/etc/slackware-version", malformed: "Slackware current\n"}, + {id: "amzn", path: "/etc/system-release", malformed: "Amazon Linux\n"}, + {id: "amazon", path: "/etc/system-release", malformed: "Amazon Linux\n"}, + {id: "photon", path: "/etc/lsb-release", malformed: "DISTRIB_RELEASE=\"preview\"\n"}, + {id: "mariner", path: "/etc/mariner-release", malformed: "CBL-Mariner preview\n"}, + {id: "azurelinux", path: "/etc/azurelinux-release", malformed: "AZURELINUX_BUILD_NUMBER=preview\n"}, + {id: "linuxmint", path: "/etc/linuxmint/info", malformed: "CODENAME=ulyana\n"}, + {id: "ovs", path: "/etc/ovs-release", malformed: "Open vSwitch snapshot\n"}, + {id: "eos", path: "/etc/Eos-release", malformed: "Arista\n"}, + {id: "oel", path: "/etc/enterprise-release", malformed: "Oracle Linux Server\n"}, + {id: "ol", path: "/etc/oracle-release", malformed: "Oracle Linux Server\n"}, + } + + for _, tt := range tests { + t.Run(tt.id+"/missing", func(t *testing.T) { + readFile := func(path string) ([]byte, error) { + if path != tt.path { + t.Fatalf("readFile(%q), want %q", path, tt.path) + } + return nil, os.ErrNotExist + } + + if got := specificLinuxOSRelease(tt.id, readFile, func(string, ...string) string { return "" }); got != nil { + t.Fatalf("specificLinuxOSRelease(%q) = %#v, want nil for missing distro file", tt.id, got) + } + }) + t.Run(tt.id+"/malformed", func(t *testing.T) { + readFile := func(path string) ([]byte, error) { + if path != tt.path { + t.Fatalf("readFile(%q), want %q", path, tt.path) + } + return []byte(tt.malformed), nil + } + + if got := specificLinuxOSRelease(tt.id, readFile, func(string, ...string) string { return "" }); got != nil { + t.Fatalf("specificLinuxOSRelease(%q) = %#v, want nil for malformed distro file", tt.id, got) + } + }) + } +} + func TestCurrentOSRelease_marinerAndAzureLinuxFallbackSplitOSReleaseVersion(t *testing.T) { tests := []struct { name string @@ -970,6 +1313,41 @@ func TestParseRedHatRelease_matchesRubyResolver(t *testing.T) { } } +func TestCurrentRedHatReleaseReadsReleaseFile(t *testing.T) { + got := currentRedHatRelease(func(path string) ([]byte, error) { + if path != "/etc/redhat-release" { + t.Fatalf("read path = %q, want /etc/redhat-release", path) + } + return []byte("CentOS Linux release 7.2.1511 (Core)\n"), nil + }) + if got.ID != "CentOS" || got.Release["full"] != "7.2.1511" || got.Codename != "Core" { + t.Fatalf("currentRedHatRelease() = %#v, want CentOS 7.2.1511 Core", got) + } +} + +func TestCurrentSuseReleaseReadsReleaseFile(t *testing.T) { + got := currentSuseRelease(func(path string) ([]byte, error) { + if path != "/etc/SuSE-release" { + t.Fatalf("read path = %q, want /etc/SuSE-release", path) + } + return []byte("SUSE Linux Enterprise Server\nVERSION = 15\n"), nil + }) + if got.ID != "suse" || got.Name != "SUSE" || got.Release["full"] != "15" { + t.Fatalf("currentSuseRelease() = %#v, want SUSE 15", got) + } +} + +func TestCurrentLinuxReleaseFilesReturnEmptyWhenMissing(t *testing.T) { + missing := func(string) ([]byte, error) { return nil, os.ErrNotExist } + + if got := currentRedHatRelease(missing); !reflect.DeepEqual(got, linuxDistro{}) { + t.Fatalf("currentRedHatRelease(missing) = %#v, want empty", got) + } + if got := currentSuseRelease(missing); !reflect.DeepEqual(got, linuxDistro{}) { + t.Fatalf("currentSuseRelease(missing) = %#v, want empty", got) + } +} + func TestCurrentLinuxDistro_usesRedHatReleaseForRHELDistroFields(t *testing.T) { t.Parallel() @@ -2178,3 +2556,72 @@ func TestOSName_mapsLinuxMintIDLikeRubyFact(t *testing.T) { t.Fatalf("osName(linux) = %q, want Linuxmint", got) } } + +func TestOSIdentityFallbacks(t *testing.T) { + t.Parallel() + + if got := osFamily("linux", linuxDistro{}); got != "Linux" { + t.Fatalf("osFamily(linux, empty distro) = %q, want Linux", got) + } + if got := osFamily("unknownos", linuxDistro{}); got != "unknownos" { + t.Fatalf("osFamily(unknownos) = %q, want unknownos", got) + } + if got := osName("linux", linuxDistro{ID: "customlinux"}); got != "customlinux" { + t.Fatalf("osName(linux custom ID) = %q, want customlinux", got) + } + if got := osName("linux", linuxDistro{}); got != "Linux" { + t.Fatalf("osName(linux empty distro) = %q, want Linux", got) + } + if got := osName("unknownos", linuxDistro{}); got != "unknownos" { + t.Fatalf("osName(unknownos) = %q, want unknownos", got) + } + if got := kernelName("unknownos"); got != "unknownos" { + t.Fatalf("kernelName(unknownos) = %q, want unknownos", got) + } +} + +func TestLinuxDistroCodenameFromVersionExtractsCodename(t *testing.T) { + tests := []struct { + version string + want string + }{ + {version: "", want: ""}, + {version: "24.04.2 LTS (Noble Numbat)", want: "Noble Numbat"}, + {version: "12 (bookworm)", want: "bookworm"}, + {version: "rolling", want: ""}, + } + + for _, tt := range tests { + t.Run(tt.version, func(t *testing.T) { + t.Parallel() + + if got := linuxDistroCodenameFromVersion(tt.version); got != tt.want { + t.Fatalf("linuxDistroCodenameFromVersion(%q) = %q, want %q", tt.version, got, tt.want) + } + }) + } +} + +func TestPartOrDefaultReturnsFallbackForMissingOrEmptyVersionPart(t *testing.T) { + parts := []string{"10", "", "2"} + + if got := partOrDefault(parts, 0, "0"); got != "10" { + t.Fatalf("partOrDefault(major) = %q, want 10", got) + } + if got := partOrDefault(parts, 1, "0"); got != "0" { + t.Fatalf("partOrDefault(empty minor) = %q, want 0", got) + } + if got := partOrDefault(parts, 3, "0"); got != "0" { + t.Fatalf("partOrDefault(missing) = %q, want 0", got) + } +} + +func TestUbuntuReleaseMapHandlesEmptyAndKnownRelease(t *testing.T) { + if got := ubuntuReleaseMap(""); got != nil { + t.Fatalf("ubuntuReleaseMap(empty) = %#v, want nil", got) + } + want := map[string]any{"full": "24.04", "major": "24.04"} + if got := ubuntuReleaseMap("24.04"); !reflect.DeepEqual(got, want) { + t.Fatalf("ubuntuReleaseMap() = %#v, want %#v", got, want) + } +} diff --git a/internal/engine/plan9.go b/internal/engine/plan9.go index 64e31936..5a55660e 100644 --- a/internal/engine/plan9.go +++ b/internal/engine/plan9.go @@ -3,7 +3,6 @@ package engine import ( "net" "path" - "path/filepath" "strconv" "strings" "time" @@ -267,7 +266,7 @@ func plan9ProcessorsCoreFacts(info processorInfo, isa string) []ResolvedFact { } func plan9NetworkingCoreFacts(s *Session) []ResolvedFact { - return plan9NetworkingCoreFactsWithGlob(s, filepath.Glob) + return plan9NetworkingCoreFactsWithGlob(s, s.glob) } func plan9NetworkingCoreFactsWithGlob(s *Session, glob pathGlobber) []ResolvedFact { @@ -300,9 +299,9 @@ func plan9UptimeCoreFacts(uptime uptimeInfo) []ResolvedFact { return nil } return []ResolvedFact{ - {Name: "system_uptime.days", Value: int(uptime.Duration.Hours()) / 24}, - {Name: "system_uptime.hours", Value: int(uptime.Duration.Hours())}, - {Name: "system_uptime.seconds", Value: int(uptime.Duration.Seconds())}, + {Name: "system_uptime.days", Value: int64(uptime.Duration.Hours()) / 24}, + {Name: "system_uptime.hours", Value: int64(uptime.Duration.Hours())}, + {Name: "system_uptime.seconds", Value: int64(uptime.Duration.Seconds())}, {Name: "system_uptime.uptime", Value: uptimeString(uptime)}, } } diff --git a/internal/engine/plan9_parser_test.go b/internal/engine/plan9_parser_test.go index d0e46851..8cd07bf2 100644 --- a/internal/engine/plan9_parser_test.go +++ b/internal/engine/plan9_parser_test.go @@ -4,7 +4,9 @@ import ( "os" "path/filepath" "reflect" + "strings" "testing" + "time" ) func plan9Fixture(t *testing.T, name string) string { @@ -112,6 +114,48 @@ func TestParsePlan9ProcessorModels(t *testing.T) { if !reflect.DeepEqual(got, want) { t.Fatalf("parsePlan9ProcessorModels(archctl) = %#v, want %#v", got, want) } + + got = parsePlan9ProcessorModels("", "cpu\n", 1) + if got != nil { + t.Fatalf("parsePlan9ProcessorModels(empty archctl model) = %#v, want nil", got) + } + + got = parsePlan9ProcessorModels("386\n", "", 0) + want = []string{"386"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("parsePlan9ProcessorModels(default count) = %#v, want %#v", got, want) + } +} + +func TestCurrentPlan9ProcessorInfoReadsInjectedFiles(t *testing.T) { + t.Parallel() + + files := map[string][]byte{ + "/dev/sysstat": []byte("0 1 2\n\n1 2 3\n"), + "/dev/cputype": []byte("\n"), + "/dev/archctl": []byte(plan9Fixture(t, "archctl")), + } + seen := map[string]bool{} + got := currentPlan9ProcessorInfo(func(path string) ([]byte, error) { + seen[path] = true + data, ok := files[path] + if !ok { + return nil, os.ErrNotExist + } + return data, nil + }) + want := processorInfo{ + LogicalCount: 2, + Models: []string{"Core 2/Xeon 3600", "Core 2/Xeon 3600"}, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("currentPlan9ProcessorInfo() = %#v, want %#v", got, want) + } + for _, path := range []string{"/dev/sysstat", "/dev/cputype", "/dev/archctl"} { + if !seen[path] { + t.Fatalf("currentPlan9ProcessorInfo() did not read %s", path) + } + } } func TestParsePlan9IPIFCStatus(t *testing.T) { @@ -168,6 +212,40 @@ func TestParsePlan9PrimaryRouteIP(t *testing.T) { } } +func TestParsePlan9PrimaryRouteIPPrefersHostRouteThenCandidate(t *testing.T) { + t.Parallel() + + input := strings.Join([]string{ + "0.0.0.0 0.0.0.0 /0 4 3 ifc 192.168.122.44 /120", + "0.0.0.0 0.0.0.0 /0 4 3 ifc 192.168.122.55 /128", + }, "\n") + if got := parsePlan9PrimaryRouteIP(input); got != "192.168.122.55" { + t.Fatalf("parsePlan9PrimaryRouteIP(host route) = %q, want 192.168.122.55", got) + } + + input = strings.Join([]string{ + "0.0.0.0 0.0.0.0 /0 4 3 ifc 0.0.0.0 /128", + "0.0.0.0 0.0.0.0 /0 4 3 ifc 192.168.122.44 /120", + }, "\n") + if got := parsePlan9PrimaryRouteIP(input); got != "192.168.122.44" { + t.Fatalf("parsePlan9PrimaryRouteIP(candidate) = %q, want 192.168.122.44", got) + } +} + +func TestPlan9PrimaryInterfaceFallsBackWhenRouteDoesNotMatch(t *testing.T) { + t.Parallel() + + interfaces := map[string]any{ + "ether0": map[string]any{"bindings": []any{map[string]any{"address": "192.168.122.44"}}}, + "ether1": map[string]any{"bindings": []any{map[string]any{"address": "198.51.100.10"}}}, + } + route := "0.0.0.0 0.0.0.0 /0 4 3 ifc 203.0.113.10 /128\n" + + if got := plan9PrimaryInterface(route, interfaces); got != "ether0" { + t.Fatalf("plan9PrimaryInterface() = %q, want first non-ignored interface", got) + } +} + func TestCurrentPlan9InterfacesMergesStatusAndMAC(t *testing.T) { t.Parallel() @@ -203,6 +281,107 @@ func TestCurrentPlan9InterfacesMergesStatusAndMAC(t *testing.T) { } } +func TestPlan9NetworkingCoreFactsUseInjectedHostFiles(t *testing.T) { + t.Parallel() + + s := NewSession() + s.host = &fakeHostOS{ + files: map[string][]byte{ + "/dev/sysname": []byte(plan9Fixture(t, "sysname")), + "/net/ipifc/0/status": []byte(plan9Fixture(t, "ipifc_status")), + "/net/ether0/addr": []byte(plan9Fixture(t, "ether0_addr")), + "/net/iproute": []byte(plan9Fixture(t, "iproute")), + }, + } + facts := Collection(plan9NetworkingCoreFactsWithGlob(s, func(pattern string) ([]string, error) { + if pattern != "/net/ipifc/*/status" { + t.Fatalf("glob pattern = %q, want /net/ipifc/*/status", pattern) + } + return []string{"/net/ipifc/0/status"}, nil + })) + networking, ok := facts["networking"].(map[string]any) + if !ok { + t.Fatalf("networking = %#v, want map", facts["networking"]) + } + + for key, want := range map[string]any{ + "hostname": "cirno", + "primary": "ether0", + "ip": "192.168.122.163", + "mac": "52:54:00:76:cc:6d", + "netmask": "255.255.255.0", + "network": "192.168.122.0", + } { + if got := networking[key]; got != want { + t.Fatalf("networking.%s = %#v, want %#v", key, got, want) + } + } + interfaces, ok := networking["interfaces"].(map[string]any) + if !ok { + t.Fatalf("networking.interfaces = %#v, want map", networking["interfaces"]) + } + ether0, ok := interfaces["ether0"].(map[string]any) + if !ok { + t.Fatalf("networking.interfaces.ether0 = %#v, want map", interfaces["ether0"]) + } + if got := ether0["ip"]; got != "192.168.122.163" { + t.Fatalf("ether0.ip = %#v, want 192.168.122.163", got) + } +} + +func TestPlan9NetworkingCoreFactsUsesSessionGlob(t *testing.T) { + t.Parallel() + + s := NewSession() + s.host = &fakeHostOS{ + files: map[string][]byte{ + "/dev/sysname": []byte(plan9Fixture(t, "sysname")), + "/net/ipifc/0/status": []byte(plan9Fixture(t, "ipifc_status")), + "/net/ether0/addr": []byte(plan9Fixture(t, "ether0_addr")), + "/net/iproute": []byte(plan9Fixture(t, "iproute")), + }, + globs: map[string][]string{ + "/net/ipifc/*/status": {"/net/ipifc/0/status"}, + }, + } + + facts := Collection(plan9NetworkingCoreFacts(s)) + networking, ok := facts["networking"].(map[string]any) + if !ok { + t.Fatalf("networking = %#v, want map", facts["networking"]) + } + if got := networking["ip"]; got != "192.168.122.163" { + t.Fatalf("networking.ip = %#v, want 192.168.122.163", got) + } +} + +func TestNetworkingCoreFactsUsesSessionPlatformForPlan9(t *testing.T) { + t.Parallel() + + s := NewSession() + s.host = &fakeHostOS{ + platform: "plan9", + files: map[string][]byte{ + "/dev/sysname": []byte(plan9Fixture(t, "sysname")), + "/net/ipifc/0/status": []byte(plan9Fixture(t, "ipifc_status")), + "/net/ether0/addr": []byte(plan9Fixture(t, "ether0_addr")), + "/net/iproute": []byte(plan9Fixture(t, "iproute")), + }, + globs: map[string][]string{ + "/net/ipifc/*/status": {"/net/ipifc/0/status"}, + }, + } + + facts := Collection(networkingCoreFacts(s)) + networking, ok := facts["networking"].(map[string]any) + if !ok { + t.Fatalf("networking = %#v, want map", facts["networking"]) + } + if got := networking["ip"]; got != "192.168.122.163" { + t.Fatalf("networking.ip = %#v, want Plan 9 fixture IP", got) + } +} + func TestPlan9MemoryCoreFactsEmitOnlyTotal(t *testing.T) { t.Parallel() @@ -214,6 +393,9 @@ func TestPlan9MemoryCoreFactsEmitOnlyTotal(t *testing.T) { if !reflect.DeepEqual(got, want) { t.Fatalf("plan9MemoryCoreFacts() = %#v, want %#v", got, want) } + if got := plan9MemoryCoreFacts(0); got != nil { + t.Fatalf("plan9MemoryCoreFacts(0) = %#v, want nil", got) + } } func TestPlan9ProcessorsCoreFactsEmitOnlyFirstSlice(t *testing.T) { @@ -231,6 +413,53 @@ func TestPlan9ProcessorsCoreFactsEmitOnlyFirstSlice(t *testing.T) { } } +func TestPlan9UptimeCoreFactsEmitsRubyCompatibleShape(t *testing.T) { + t.Parallel() + + got := plan9UptimeCoreFacts(uptimeInfo{Duration: 49*time.Hour + 3*time.Minute + 2*time.Second, Known: true}) + want := []ResolvedFact{ + {Name: "system_uptime.days", Value: int64(2)}, + {Name: "system_uptime.hours", Value: int64(49)}, + {Name: "system_uptime.seconds", Value: int64(176582)}, + {Name: "system_uptime.uptime", Value: "2 days"}, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("plan9UptimeCoreFacts() = %#v, want %#v", got, want) + } + got = plan9UptimeCoreFacts(uptimeInfo{Duration: 100_000*time.Hour + 5*time.Second, Known: true}) + want = []ResolvedFact{ + {Name: "system_uptime.days", Value: int64(4166)}, + {Name: "system_uptime.hours", Value: int64(100000)}, + {Name: "system_uptime.seconds", Value: int64(360000005)}, + {Name: "system_uptime.uptime", Value: "4166 days"}, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("plan9UptimeCoreFacts(large duration) = %#v, want %#v", got, want) + } + if got := plan9UptimeCoreFacts(uptimeInfo{}); got != nil { + t.Fatalf("plan9UptimeCoreFacts(unknown) = %#v, want nil", got) + } +} + +func TestCurrentPlan9UptimeParsesCommandOutput(t *testing.T) { + t.Parallel() + + got := currentPlan9Uptime(func(name string, args ...string) string { + if name != "uptime" || len(args) != 0 { + t.Fatalf("run(%q, %#v), want uptime", name, args) + } + return "cirno up 1 day, 01:02:03\n" + }) + want := uptimeInfo{Duration: 90_123 * time.Second, Known: true} + if got != want { + t.Fatalf("currentPlan9Uptime() = %#v, want %#v", got, want) + } + got = currentPlan9Uptime(func(string, ...string) string { return "not uptime output\n" }) + if got != (uptimeInfo{}) { + t.Fatalf("currentPlan9Uptime(invalid) = %#v, want unknown", got) + } +} + func TestPlan9NetworkingCoreFactsEmitFirstSliceOnly(t *testing.T) { t.Parallel() diff --git a/internal/engine/processors.go b/internal/engine/processors.go index d758104f..4b0c98e1 100644 --- a/internal/engine/processors.go +++ b/internal/engine/processors.go @@ -118,7 +118,7 @@ func processorSpeedFacts(speed string) []ResolvedFact { } func probeProcessorSpeed(s *Session) string { - switch runtime.GOOS { + switch s.goos() { case "darwin": if speed := s.cachedPlatformProcessorInfo().SpeedHz; speed > 0 { return hertzToHumanReadable(int64(speed)) @@ -138,8 +138,9 @@ func probeProcessorSpeed(s *Session) string { } func probeProcessorModels(s *Session) []string { - architecture := architectureName(runtime.GOOS, s.cachedHardwareModel()) - switch runtime.GOOS { + goos := s.goos() + architecture := architectureName(goos, s.cachedHardwareModel()) + switch goos { case "darwin": models := s.cachedPlatformProcessorInfo().Models if len(models) > 0 { @@ -167,7 +168,7 @@ func probeProcessorTopology(s *Session) (int, int) { if logical <= 0 { logical = 1 } - switch runtime.GOOS { + switch s.goos() { case "darwin": processors := s.cachedPlatformProcessorInfo() if processors.CoresPerSocket > 0 && processors.ThreadsPerCore > 0 { @@ -191,10 +192,10 @@ func probeProcessorTopology(s *Session) (int, int) { } func probePlatformProcessorInfo(s *Session) processorInfo { - if runtime.GOOS == "plan9" { + if s.goos() == "plan9" { return currentPlan9ProcessorInfo(s.readFile) } - return currentProcessorInfo(runtime.GOOS, s.commandOutput, s.logr()) + return currentProcessorInfo(s.goos(), s.commandOutput, s.logr()) } func currentProcessorInfo(goos string, run func(string, ...string) string, log *slog.Logger) processorInfo { @@ -397,8 +398,9 @@ func illumosClockMHz(line string) int { } func probeProcessorExtensions(s *Session) []string { - architecture := architectureName(runtime.GOOS, s.cachedHardwareModel()) - if runtime.GOOS != "linux" { + goos := s.goos() + architecture := architectureName(goos, s.cachedHardwareModel()) + if goos != "linux" { return sortedProcessorExtensions(map[string]bool{architecture: true}) } data, err := s.readFile("/proc/cpuinfo") @@ -613,15 +615,16 @@ func hertzToHumanReadable(hz any) string { // physical counts, cores, threads, models, ISA, speed, and extensions) for the // current host. func processorsCoreFacts(s *Session) []ResolvedFact { - architecture := s.cachedArchitectureName() - if runtime.GOOS == "plan9" { - return plan9ProcessorsCoreFacts(s.cachedPlatformProcessorInfo(), currentProcessorISA(s, runtime.GOOS, architecture, s.commandOutput)) + goos := s.goos() + architecture := architectureName(goos, s.cachedHardwareModel()) + if goos == "plan9" { + return plan9ProcessorsCoreFacts(s.cachedPlatformProcessorInfo(), currentProcessorISA(s, goos, architecture, s.commandOutput)) } platformProcessors := processorInfo{} - if runtime.GOOS == "darwin" || runtime.GOOS == "freebsd" || runtime.GOOS == "netbsd" || runtime.GOOS == "openbsd" || runtime.GOOS == "dragonfly" || runtime.GOOS == "illumos" || runtime.GOOS == "windows" { + if goos == "darwin" || goos == "freebsd" || goos == "netbsd" || goos == "openbsd" || goos == "dragonfly" || goos == "illumos" || goos == "windows" { platformProcessors = s.cachedPlatformProcessorInfo() } - if runtime.GOOS == "linux" { + if goos == "linux" { platformProcessors.PhysicalCount = currentLinuxProcessorPhysicalCount("/proc/cpuinfo", "/sys/devices/system/cpu", s.host) } processorCount := runtime.NumCPU() @@ -632,7 +635,7 @@ func processorsCoreFacts(s *Session) []ResolvedFact { if platformProcessors.PhysicalCount > 0 { physicalProcessorCount = platformProcessors.PhysicalCount } - processorISA := currentProcessorISA(s, runtime.GOOS, architecture, s.commandOutput) + processorISA := currentProcessorISA(s, goos, architecture, s.commandOutput) processorModels := s.cachedProcessorModels() processorSpeed := s.cachedProcessorSpeed() processorCores, processorThreads := s.cachedProcessorTopology() diff --git a/internal/engine/processors_test.go b/internal/engine/processors_test.go index d9cde155..86d9e3a2 100644 --- a/internal/engine/processors_test.go +++ b/internal/engine/processors_test.go @@ -1,9 +1,11 @@ package engine import ( + "context" "os" "path/filepath" "reflect" + "runtime" "strings" "testing" ) @@ -95,6 +97,28 @@ func TestParseWindowsProcessorsFallsBackWhenLogicalCountIsZero(t *testing.T) { } } +func TestParseWindowsProcessorsHandlesMissingCoreCounts(t *testing.T) { + t.Parallel() + + input := strings.Join([]string{ + "Name=Pretty_Name", + "Architecture=0", + "NumberOfLogicalProcessors=2", + "NumberOfCores=bad", + }, "\r\n") + + got := parseWindowsProcessors(input, discardLog()) + want := processorInfo{ + ISA: "x86", + Models: []string{"Pretty_Name"}, + LogicalCount: 2, + PhysicalCount: 1, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("parseWindowsProcessors() = %#v, want %#v", got, want) + } +} + func TestParseWindowsProcessorsLogsUnknownArchitectureLikeRubyResolver(t *testing.T) { debugMessages := []string{} logger := captureLogger(&debugMessages, nil, nil) @@ -134,6 +158,33 @@ func TestCurrentWindowsProcessorsQueriesWMIC(t *testing.T) { } } +func TestCurrentProcessorInfoWiresWindowsWMICOutput(t *testing.T) { + t.Parallel() + + got := currentProcessorInfo("windows", func(name string, args ...string) string { + if name != "wmic" || !reflect.DeepEqual(args, []string{"cpu", "get", "Name,Architecture,NumberOfLogicalProcessors,NumberOfCores", "/value"}) { + t.Fatalf("run(%q, %#v), want wmic processor query", name, args) + } + return "Name=Pretty_Name\r\nArchitecture=9\r\nNumberOfLogicalProcessors=4\r\nNumberOfCores=2\r\n" + }, discardLog()) + + if got.LogicalCount != 4 || got.CoresPerSocket != 2 || got.ThreadsPerCore != 2 || got.ISA != "x64" { + t.Fatalf("currentProcessorInfo(windows) = %#v", got) + } +} + +func TestCurrentProcessorInfoSkipsUnsupportedPlatform(t *testing.T) { + t.Parallel() + + got := currentProcessorInfo("plan9", func(string, ...string) string { + t.Fatal("currentProcessorInfo(unsupported) ran command") + return "" + }, discardLog()) + if !reflect.DeepEqual(got, processorInfo{}) { + t.Fatalf("currentProcessorInfo(unsupported) = %#v, want empty", got) + } +} + func TestCurrentWindowsProcessorsLogsNoResultDiagnosticsLikeRubyResolver(t *testing.T) { debugMessages := []string{} logger := captureLogger(&debugMessages, nil, nil) @@ -206,6 +257,169 @@ func TestCurrentProcessorInfoWiresFreeBSDSysctlOutput(t *testing.T) { } } +func TestProcessorProbesUseSessionPlatformForFreeBSD(t *testing.T) { + s := NewSessionContext(context.Background()) + s.host = &fakeHostOS{ + platform: "freebsd", + runOutputs: map[string]string{ + fakeRunKey("sysctl", "-n", "hw.ncpu"): "4\n", + fakeRunKey("sysctl", "-n", "hw.model"): "Intel CPU\n", + fakeRunKey("sysctl", "-n", "hw.clockrate"): "2400\n", + }, + } + + info := probePlatformProcessorInfo(s) + if info.LogicalCount != 4 || info.SpeedHz != 2_400_000_000 || info.CoresPerSocket != 4 || info.ThreadsPerCore != 1 { + t.Fatalf("probePlatformProcessorInfo() = %#v", info) + } + + if got := probeProcessorSpeed(s); got != "2.40 GHz" { + t.Fatalf("probeProcessorSpeed() = %q, want 2.40 GHz", got) + } + wantModels := []string{"Intel CPU", "Intel CPU", "Intel CPU", "Intel CPU"} + if got := probeProcessorModels(s); !reflect.DeepEqual(got, wantModels) { + t.Fatalf("probeProcessorModels() = %#v, want %#v", got, wantModels) + } + if cores, threads := probeProcessorTopology(s); cores != 4 || threads != 1 { + t.Fatalf("probeProcessorTopology() = %d, %d; want 4, 1", cores, threads) + } +} + +func TestProcessorProbesUseSessionPlatformForDarwin(t *testing.T) { + s := NewSessionContext(context.Background()) + s.host = &fakeHostOS{ + platform: "darwin", + runOutputs: map[string]string{ + fakeRunKey("sysctl", + "hw.logicalcpu_max", + "hw.physicalcpu_max", + "machdep.cpu.brand_string", + "hw.cpufrequency_max", + "machdep.cpu.core_count", + "machdep.cpu.thread_count", + ): strings.Join([]string{ + "hw.logicalcpu_max: 8", + "hw.physicalcpu_max: 4", + "machdep.cpu.brand_string: Apple M2", + "hw.cpufrequency_max: 3200000000", + "machdep.cpu.core_count: 4", + "machdep.cpu.thread_count: 8", + }, "\n"), + }, + } + + if got := probeProcessorSpeed(s); got != "3.20 GHz" { + t.Fatalf("probeProcessorSpeed() = %q, want 3.20 GHz", got) + } + models := probeProcessorModels(s) + if len(models) != 8 || models[0] != "Apple M2" || models[7] != "Apple M2" { + t.Fatalf("probeProcessorModels() = %#v, want eight Apple M2 entries", models) + } + if cores, threads := probeProcessorTopology(s); cores != 4 || threads != 2 { + t.Fatalf("probeProcessorTopology() = %d, %d; want 4, 2", cores, threads) + } +} + +func TestProcessorProbesUseSessionPlatformForWindows(t *testing.T) { + s := NewSessionContext(context.Background()) + s.host = &fakeHostOS{ + platform: "windows", + runOutputs: map[string]string{ + fakeRunKey("wmic", "cpu", "get", "Name,Architecture,NumberOfLogicalProcessors,NumberOfCores", "/value"): "Name=Pretty CPU\r\nArchitecture=9\r\nNumberOfLogicalProcessors=4\r\nNumberOfCores=2\r\n", + }, + } + + info := probePlatformProcessorInfo(s) + if info.ISA != "x64" || info.LogicalCount != 4 || info.PhysicalCount != 1 || info.CoresPerSocket != 2 || info.ThreadsPerCore != 2 { + t.Fatalf("probePlatformProcessorInfo() = %#v", info) + } + if got := probeProcessorModels(s); !reflect.DeepEqual(got, []string{"Pretty CPU"}) { + t.Fatalf("probeProcessorModels() = %#v, want Pretty CPU", got) + } + if cores, threads := probeProcessorTopology(s); cores != 2 || threads != 2 { + t.Fatalf("probeProcessorTopology() = %d, %d; want 2, 2", cores, threads) + } +} + +func TestProcessorProbesUseSessionPlatformForLinux(t *testing.T) { + cpuinfo := strings.Join([]string{ + "processor\t: 0", + "model name\t: Pretty CPU", + "cpu MHz\t\t: 1800.000", + "siblings\t: 4", + "cpu cores\t: 2", + "flags\t\t: fpu cx8 cmov mmx fxsr sse2 syscall lm cx16 lahf_lm popcnt sse4_1 sse4_2 ssse3 abm avx avx2 bmi1 bmi2 f16c fma movbe xsave", + "processor\t: 1", + "model name\t: Pretty CPU", + }, "\n") + s := NewSessionContext(context.Background()) + s.host = &fakeHostOS{ + platform: "linux", + files: map[string][]byte{ + "/proc/cpuinfo": []byte(cpuinfo), + }, + runOutputs: map[string]string{ + fakeRunKey("uname", "-m"): "x86_64\n", + }, + } + + if got := probeProcessorSpeed(s); got != "1.80 GHz" { + t.Fatalf("probeProcessorSpeed() = %q, want 1.80 GHz", got) + } + if got := probeProcessorModels(s); !reflect.DeepEqual(got, []string{"Pretty CPU", "Pretty CPU"}) { + t.Fatalf("probeProcessorModels() = %#v, want two Pretty CPU entries", got) + } + if cores, threads := probeProcessorTopology(s); cores != 2 || threads != 2 { + t.Fatalf("probeProcessorTopology() = %d, %d; want 2, 2", cores, threads) + } + wantExtensions := []string{"x86_64", "x86_64-v1", "x86_64-v2", "x86_64-v3"} + if got := probeProcessorExtensions(s); !reflect.DeepEqual(got, wantExtensions) { + t.Fatalf("probeProcessorExtensions() = %#v, want %#v", got, wantExtensions) + } +} + +func TestProbePlatformProcessorInfoUsesSessionPlatformForPlan9(t *testing.T) { + s := NewSessionContext(context.Background()) + s.host = &fakeHostOS{ + platform: "plan9", + files: map[string][]byte{ + "/dev/sysstat": []byte("0 1 2\n\n1 2 3\n"), + "/dev/cputype": []byte("\n"), + "/dev/archctl": []byte(plan9Fixture(t, "archctl")), + }, + } + + got := probePlatformProcessorInfo(s) + want := processorInfo{ + LogicalCount: 2, + Models: []string{"Core 2/Xeon 3600", "Core 2/Xeon 3600"}, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("probePlatformProcessorInfo() = %#v, want %#v", got, want) + } +} + +func TestProcessorsCoreFactsUsesSessionPlatformForISAFallback(t *testing.T) { + s := NewSessionContext(context.Background()) + s.host = &fakeHostOS{ + platform: "windows", + runOutputs: map[string]string{ + fakeRunKey("uname", "-m"): "x86_64\n", + fakeRunKey("wmic", "cpu", "get", "Name,Architecture,NumberOfLogicalProcessors,NumberOfCores", "/value"): "Name=Pretty CPU\r\nArchitecture=10\r\nNumberOfLogicalProcessors=4\r\nNumberOfCores=2\r\n", + }, + } + + got := Collection(processorsCoreFacts(s)) + processors, ok := got["processors"].(map[string]any) + if !ok { + t.Fatalf("processors facts = %#v, want map", got["processors"]) + } + want := architectureName("windows", windowsHardwareFromGoArch(runtime.GOARCH)) + if processors["isa"] != want { + t.Fatalf("processors.isa = %#v, want Windows-normalized fallback %#v", processors["isa"], want) + } +} + func TestCurrentProcessorInfoWiresGenericBSDSysctlOutput(t *testing.T) { t.Parallel() @@ -275,6 +489,15 @@ func TestCurrentProcessorInfoWiresDragonFlySysctlOutput(t *testing.T) { } } +func TestParseFreeBSDProcessorsRejectsInvalidValues(t *testing.T) { + t.Parallel() + + got := parseFreeBSDProcessors("not-a-count\n", "Intel CPU\n", "not-a-speed\n") + if !reflect.DeepEqual(got, processorInfo{}) { + t.Fatalf("parseFreeBSDProcessors(invalid) = %#v, want empty processor info", got) + } +} + func TestCurrentProcessorInfoWiresIllumosPsrinfoOutput(t *testing.T) { t.Parallel() @@ -313,6 +536,27 @@ func TestCurrentProcessorInfoIllumosParsesCoreAndThreadCounts(t *testing.T) { } } +func TestParseIllumosProcessorsInfersCountsFromClockedModels(t *testing.T) { + t.Parallel() + + got := parseIllumosProcessors(` + x86 (GenuineIntel 906E9 family 6 model 158 step 9 clock 3600 MHz) + Intel(r) Core(tm) i7-7700 CPU @ 3.60GHz +`) + want := processorInfo{ + ISA: "x86", + SpeedHz: 3_600_000_000, + Models: []string{"Intel(r) Core(tm) i7-7700 CPU @ 3.60GHz"}, + LogicalCount: 1, + PhysicalCount: 1, + CoresPerSocket: 1, + ThreadsPerCore: 1, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("parseIllumosProcessors() = %#v, want %#v", got, want) + } +} + func TestCurrentProcessorISAUsesOpenBSDUnameProcessor(t *testing.T) { got := currentProcessorISA(testSession, "openbsd", "amd64", func(name string, args ...string) string { if name != "uname" || !reflect.DeepEqual(args, []string{"-p"}) { @@ -326,6 +570,17 @@ func TestCurrentProcessorISAUsesOpenBSDUnameProcessor(t *testing.T) { } } +func TestCurrentProcessorISAFallsBackWhenUnameProcessorIsUnknown(t *testing.T) { + t.Parallel() + + for _, output := range []string{"", "unknown\n"} { + got := currentProcessorISA(testSession, "linux", "x86_64", func(string, ...string) string { return output }) + if got != "x86_64" { + t.Fatalf("currentProcessorISA(%q) = %q, want fallback", output, got) + } + } +} + func TestCurrentProcessorISAWindowsFallsBackWhenWMIHasNoISA(t *testing.T) { s := NewSession() s.host = &fakeHostOS{platform: "windows", runOutput: ""} @@ -472,6 +727,20 @@ func TestParseLinuxProcessorSpeed(t *testing.T) { } } +func TestParseLinuxProcessorSpeedRejectsMissingInvalidAndZeroMHz(t *testing.T) { + t.Parallel() + + for _, input := range []string{ + "processor\t: 0\nmodel name\t: CPU\n", + "cpu MHz\t\t: not-a-number\n", + "cpu MHz\t\t: 0\n", + } { + if got := parseLinuxProcessorSpeed(input); got != "" { + t.Fatalf("parseLinuxProcessorSpeed(%q) = %q, want empty", input, got) + } + } +} + func TestParseLinuxProcessorModels(t *testing.T) { input := "processor\t: 0\nmodel name\t: Intel(R) Core(TM) i7-4980HQ CPU @ 2.80GHz\n" + "processor\t: 1\nmodel name\t: Intel(R) Core(TM) i7-4980HQ CPU @ 2.80GHz\n" @@ -559,6 +828,124 @@ func TestCurrentLinuxProcessorPhysicalCountUsesHostSysfsWhenCPUInfoMissing(t *te } } +func TestCurrentLinuxProcessorPhysicalCountUsesHostCPUInfoFirst(t *testing.T) { + host := &fakeHostOS{ + files: map[string][]byte{ + "/proc/cpuinfo": []byte("processor: 0\nphysical id: 0\nprocessor: 1\nphysical id: 1\n"), + }, + dirs: map[string][]os.DirEntry{ + "/sys/devices/system/cpu": fakeDirEntries("cpu0", "cpu1"), + }, + } + + got := currentLinuxProcessorPhysicalCount("/proc/cpuinfo", "/sys/devices/system/cpu", host) + if got != 2 { + t.Fatalf("currentLinuxProcessorPhysicalCount() = %d, want cpuinfo physical count 2", got) + } + if len(host.readDirCalls) != 0 { + t.Fatalf("readDir calls = %#v, want none when cpuinfo has physical IDs", host.readDirCalls) + } +} + +func TestLinuxProcessorPhysicalCountUsesCPUInfoPhysicalIDs(t *testing.T) { + t.Parallel() + + cpuinfo := "processor\t: 0\nphysical id\t: 0\nprocessor\t: 1\nphysical id\t: 1\n" + got := linuxProcessorPhysicalCountWithReaders(cpuinfo, "/unused", nil, func(string) ([]os.DirEntry, error) { + t.Fatal("linuxProcessorPhysicalCountWithReaders() read sysfs despite cpuinfo physical IDs") + return nil, nil + }) + if got != 2 { + t.Fatalf("linuxProcessorPhysicalCountWithReaders() = %d, want 2", got) + } +} + +func TestLinuxProcessorPhysicalCountHandlesSysfsReadFailures(t *testing.T) { + t.Parallel() + + if got := linuxProcessorPhysicalCountWithReaders("", "/sys/cpu", nil, func(string) ([]os.DirEntry, error) { + return nil, os.ErrNotExist + }); got != 0 { + t.Fatalf("linuxProcessorPhysicalCountWithReaders(readDir error) = %d, want 0", got) + } + + got := linuxProcessorPhysicalCountWithReaders( + "", + "/sys/cpu", + func(string) ([]byte, error) { return nil, os.ErrPermission }, + func(string) ([]os.DirEntry, error) { return fakeDirEntries("cpu0"), nil }, + ) + if got != 0 { + t.Fatalf("linuxProcessorPhysicalCountWithReaders(readFile error) = %d, want 0", got) + } +} + +func TestLinuxProcessorPhysicalCountUsesInjectedReader(t *testing.T) { + sysCPU := t.TempDir() + if err := os.Mkdir(filepath.Join(sysCPU, "cpu0"), 0o755); err != nil { + t.Fatal(err) + } + readFile := func(path string) ([]byte, error) { + want := filepath.Join(sysCPU, "cpu0", "topology", "physical_package_id") + if path != want { + t.Fatalf("readFile(%q), want %q", path, want) + } + return []byte("7\n"), nil + } + + if got := linuxProcessorPhysicalCount("", sysCPU, readFile); got != 1 { + t.Fatalf("linuxProcessorPhysicalCount() = %d, want 1", got) + } +} + +func TestLinuxCPUEntryNameAcceptsOnlyNumberedCPUEntries(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + want bool + }{ + {name: "cpu0", want: true}, + {name: "cpu12", want: true}, + {name: "cpu", want: false}, + {name: "cpuindex", want: false}, + {name: "node0", want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := linuxCPUEntryName(tt.name); got != tt.want { + t.Fatalf("linuxCPUEntryName(%q) = %v, want %v", tt.name, got, tt.want) + } + }) + } +} + +func TestIllumosProcessorHelpersParseCountsAndClock(t *testing.T) { + t.Parallel() + + line := "The physical processor has 2 cores and 4 virtual processors (0-3)" + if got := illumosVirtualProcessorCount(line); got != 4 { + t.Fatalf("illumosVirtualProcessorCount() = %d, want 4", got) + } + if got := illumosCoreCount(line); got != 2 { + t.Fatalf("illumosCoreCount() = %d, want 2", got) + } + if got := illumosClockMHz("x86 (GenuineIntel clock 3600 MHz)"); got != 3600 { + t.Fatalf("illumosClockMHz() = %d, want 3600", got) + } + for _, input := range []string{"not a processor line", "The physical processor has virtual processors", "x86 no clock"} { + if got := illumosVirtualProcessorCount(input); got != 0 { + t.Fatalf("illumosVirtualProcessorCount(%q) = %d, want 0", input, got) + } + if got := illumosCoreCount(input); got != 0 { + t.Fatalf("illumosCoreCount(%q) = %d, want 0", input, got) + } + if got := illumosClockMHz(input); got != 0 { + t.Fatalf("illumosClockMHz(%q) = %d, want 0", input, got) + } + } +} + func TestParseLinuxProcessorExtensions_derivesX86Levels(t *testing.T) { input := "flags : fpu cx8 cmov mmx fxsr sse2 syscall lm cx16 lahf_lm popcnt sse4_1 sse4_2 ssse3 abm avx avx2 bmi1 bmi2 f16c fma movbe xsave\n" @@ -568,3 +955,25 @@ func TestParseLinuxProcessorExtensions_derivesX86Levels(t *testing.T) { t.Fatalf("parseLinuxProcessorExtensions() = %#v, want %#v", got, want) } } + +func TestParseLinuxProcessorExtensionsHandlesFallbackAndV4(t *testing.T) { + t.Parallel() + + if got := parseLinuxProcessorExtensions("flags : ignored\n", "arm64"); !reflect.DeepEqual(got, []string{"arm64"}) { + t.Fatalf("parseLinuxProcessorExtensions(non-x86) = %#v, want architecture only", got) + } + + input := "vendor_id : GenuineIntel\nflags : fpu cx8 cmov mmx fxsr sse2 syscall lm cx16 lahf_lm popcnt sse4_1 sse4_2 ssse3 abm avx avx2 bmi1 bmi2 f16c fma movbe xsave avx512f avx512bw avx512cd avx512dq avx512vl\n" + got := parseLinuxProcessorExtensions(input, "x86_64") + want := []string{"x86_64", "x86_64-v1", "x86_64-v2", "x86_64-v3", "x86_64-v4"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("parseLinuxProcessorExtensions(v4) = %#v, want %#v", got, want) + } + + if containsAll(wordsSet("fpu mmx"), []string{"fpu", "sse2"}) { + t.Fatal("containsAll() = true, want false when a required flag is missing") + } + if got := sortedProcessorExtensions(map[string]bool{"": true, "x86_64": true}); !reflect.DeepEqual(got, []string{"x86_64"}) { + t.Fatalf("sortedProcessorExtensions() = %#v, want empty extension omitted", got) + } +} diff --git a/internal/engine/session_test.go b/internal/engine/session_test.go index e5673024..a2f0af53 100644 --- a/internal/engine/session_test.go +++ b/internal/engine/session_test.go @@ -148,11 +148,59 @@ func fakeDirEntries(names ...string) []os.DirEntry { return entries } +func TestNewSessionContextDefaultsNilContext(t *testing.T) { + s := NewSessionContext(nil) + if s.Context() == nil { + t.Fatal("Context() = nil, want background context") + } + select { + case <-s.Context().Done(): + t.Fatal("nil context default is already canceled") + default: + } +} + +func TestSessionLogrDefaultsNilLogger(t *testing.T) { + s := &Session{} + if s.logr() == nil { + t.Fatal("logr() = nil, want discard logger") + } + s.warn("ignored") + s.debug("ignored") +} + +func TestOSHostLstatAndGlobUseFilesystem(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "one.fact") + if err := os.WriteFile(path, []byte("fact=value\n"), 0o600); err != nil { + t.Fatal(err) + } + + host := osHost{} + info, err := host.lstat(path) + if err != nil { + t.Fatal(err) + } + if info.Name() != "one.fact" { + t.Fatalf("lstat().Name() = %q, want one.fact", info.Name()) + } + + matches, err := host.glob(filepath.Join(dir, "*.fact")) + if err != nil { + t.Fatal(err) + } + if want := []string{path}; !reflect.DeepEqual(matches, want) { + t.Fatalf("glob() = %#v, want %#v", matches, want) + } +} + func TestSessionRoutesHostIOThroughHost(t *testing.T) { host := &fakeHostOS{ files: map[string][]byte{"/proc/data": []byte("file-data")}, + dirs: map[string][]os.DirEntry{"/dir": fakeDirEntries("one", "two")}, stats: map[string]os.FileInfo{"/stat": fakeFileInfo{name: "stat"}}, lstats: map[string]os.FileInfo{"/lstat": fakeFileInfo{name: "lstat"}}, + globs: map[string][]string{"/tmp/*.fact": {"/tmp/one.fact", "/tmp/two.fact"}}, } s := NewSessionContext(context.Background()) s.host = host @@ -185,6 +233,30 @@ func TestSessionRoutesHostIOThroughHost(t *testing.T) { if info.Name() != "lstat" { t.Fatalf("lstat().Name() = %q, want lstat", info.Name()) } + entries, err := s.readDir("/dir") + if err != nil { + t.Fatal(err) + } + gotNames := make([]string, 0, len(entries)) + for _, entry := range entries { + gotNames = append(gotNames, entry.Name()) + } + if want := []string{"one", "two"}; !reflect.DeepEqual(gotNames, want) { + t.Fatalf("readDir() names = %#v, want %#v", gotNames, want) + } + matches, err := s.glob("/tmp/*.fact") + if err != nil { + t.Fatal(err) + } + if want := []string{"/tmp/one.fact", "/tmp/two.fact"}; !reflect.DeepEqual(matches, want) { + t.Fatalf("glob() = %#v, want %#v", matches, want) + } + if want := []string{"/dir"}; !reflect.DeepEqual(host.readDirCalls, want) { + t.Fatalf("readDir calls = %#v, want %#v", host.readDirCalls, want) + } + if want := []string{"/tmp/*.fact"}; !reflect.DeepEqual(host.globCalls, want) { + t.Fatalf("glob calls = %#v, want %#v", host.globCalls, want) + } } func TestSessionBackedProbesUseFakeHost(t *testing.T) { @@ -209,6 +281,73 @@ func TestSessionBackedProbesUseFakeHost(t *testing.T) { } } +func TestSessionCachedLinuxMeminfoMemoizesFirstRead(t *testing.T) { + host := &fakeHostOS{ + files: map[string][]byte{ + "/proc/meminfo": []byte("MemTotal: 1024 kB\n"), + }, + } + s := NewSessionContext(context.Background()) + s.host = host + + if got := s.cachedLinuxMeminfo(); got != "MemTotal: 1024 kB\n" { + t.Fatalf("cachedLinuxMeminfo() = %q, want first fake meminfo", got) + } + host.files["/proc/meminfo"] = []byte("MemTotal: 2048 kB\n") + if got := s.cachedLinuxMeminfo(); got != "MemTotal: 1024 kB\n" { + t.Fatalf("cachedLinuxMeminfo() after host mutation = %q, want cached first read", got) + } +} + +func TestSessionCachedWindowsOSVersionInputUsesSessionPlatform(t *testing.T) { + host := &fakeHostOS{ + platform: "windows", + runOutputs: map[string]string{ + fakeRunKey("wmic", "os", "get", "OtherTypeDescription,ProductType,Version", "/value"): "", + fakeRunKey("powershell", "-NoProfile", "-NonInteractive", "-Command", windowsCIMScript("Win32_OperatingSystem", "OtherTypeDescription,ProductType,Version")): "OtherTypeDescription=\r\nProductType=1\r\nVersion=10.0.22631\r\n", + }, + } + s := NewSessionContext(context.Background()) + s.host = host + + if got := s.cachedWindowsOSVersionInput(); !strings.Contains(got, "Version=10.0.22631") { + t.Fatalf("cachedWindowsOSVersionInput() = %q, want Windows version input", got) + } + host.runOutputs[fakeRunKey("powershell", "-NoProfile", "-NonInteractive", "-Command", windowsCIMScript("Win32_OperatingSystem", "OtherTypeDescription,ProductType,Version"))] = "Version=changed\r\n" + if got := s.cachedWindowsOSVersionInput(); !strings.Contains(got, "Version=10.0.22631") { + t.Fatalf("cachedWindowsOSVersionInput() after host mutation = %q, want cached first read", got) + } + if len(host.runCalls) != 2 { + t.Fatalf("run calls = %#v, want wmic and PowerShell fallback", host.runCalls) + } +} + +func TestSessionCachedSwapEncryptedMemoizesFreeBSDProbe(t *testing.T) { + host := &fakeHostOS{ + platform: "freebsd", + runOutputs: map[string]string{ + fakeRunKey("sysctl", "-n", "vm.stats.vm.v_page_size"): "4096\n", + fakeRunKey("sysctl", "-n", "vm.stats.vm.v_page_count"): "100\n", + fakeRunKey("sysctl", "-n", "vm.stats.vm.v_active_count"): "20\n", + fakeRunKey("sysctl", "-n", "vm.stats.vm.v_wire_count"): "10\n", + fakeRunKey("swapinfo", "-k"): strings.Join([]string{ + "Device 1K-blocks Used Avail Capacity", + "/dev/gpt/swap.eli 200 50 150 25%", + }, "\n"), + }, + } + s := NewSessionContext(context.Background()) + s.host = host + + if !s.cachedSwapEncrypted() { + t.Fatal("cachedSwapEncrypted() = false, want true") + } + host.runOutputs[fakeRunKey("swapinfo", "-k")] = "" + if !s.cachedSwapEncrypted() { + t.Fatal("cachedSwapEncrypted() after host mutation = false, want cached true") + } +} + func TestCoreCommandEnvSanitizesPath(t *testing.T) { env := coreCommandEnv([]string{"PATH=/tmp/attacker", "HOME=/home/alice", "LD_PRELOAD=/tmp/libhack.so"}, "linux") @@ -285,6 +424,70 @@ func TestCoreCommandSearchPathIgnoresCallerSystemRoot(t *testing.T) { } } +func TestCoreCommandPathHelpersArePlatformAware(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + cmd string + goos string + want bool + }{ + {name: "slash", cmd: "bin/tool", goos: "linux", want: true}, + {name: "backslash", cmd: `bin\tool`, goos: "linux", want: true}, + {name: "windows drive", cmd: `C:tool`, goos: "windows", want: true}, + {name: "posix colon", cmd: "C:tool", goos: "linux"}, + {name: "bare", cmd: "tool", goos: "windows"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := commandHasPathSeparator(tt.cmd, tt.goos); got != tt.want { + t.Fatalf("commandHasPathSeparator(%q, %q) = %v, want %v", tt.cmd, tt.goos, got, tt.want) + } + }) + } + + if got := coreCommandCandidates("tool", "windows"); !reflect.DeepEqual(got, []string{"tool.exe", "tool.com", "tool.bat", "tool.cmd"}) { + t.Fatalf("coreCommandCandidates(windows bare) = %#v", got) + } + if got := coreCommandCandidates("tool.exe", "windows"); !reflect.DeepEqual(got, []string{"tool.exe"}) { + t.Fatalf("coreCommandCandidates(windows extension) = %#v", got) + } + if got := coreCommandCandidates("tool", "linux"); !reflect.DeepEqual(got, []string{"tool"}) { + t.Fatalf("coreCommandCandidates(linux) = %#v", got) + } + if got, ok := coreCommandExecutable("bin/tool", "linux"); !ok || got != "bin/tool" { + t.Fatalf("coreCommandExecutable(path) = %q, %v; want bin/tool, true", got, ok) + } +} + +func TestCoreCommandFileExecutableChecksRegularExecutableFiles(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + executable := filepath.Join(dir, "tool") + plain := filepath.Join(dir, "plain") + if err := os.WriteFile(executable, []byte("#!/bin/sh\n"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(plain, []byte("not executable\n"), 0o600); err != nil { + t.Fatal(err) + } + + if !coreCommandFileExecutable(executable, "linux") { + t.Fatalf("coreCommandFileExecutable(%q, linux) = false, want true", executable) + } + if coreCommandFileExecutable(plain, "linux") { + t.Fatalf("coreCommandFileExecutable(%q, linux) = true, want false", plain) + } + if !coreCommandFileExecutable(plain, "windows") { + t.Fatalf("coreCommandFileExecutable(%q, windows) = false, want regular files accepted", plain) + } + if coreCommandFileExecutable(dir, "windows") { + t.Fatalf("coreCommandFileExecutable(%q, windows) = true, want directory rejected", dir) + } +} + func TestOSHostRunDoesNotSearchCallerPath(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("uses a POSIX shell script") diff --git a/internal/engine/snapshot.go b/internal/engine/snapshot.go index f3499673..b98723e2 100644 --- a/internal/engine/snapshot.go +++ b/internal/engine/snapshot.go @@ -18,6 +18,11 @@ var ErrFactNotFound = errors.New("fact not found") // Snapshot is the immutable result of one discovery run: the canonical tree // plus pure query operations over it. Safe for concurrent use. +// +// Returned values are defensive copies of the public fact graph: maps, slices, +// arrays, pointers, and exported struct fields are cloned. Unexported struct +// fields in custom fact values are preserved by shallow value copy and are +// outside the deep-clone guarantee. type Snapshot struct { facts []ResolvedFact tree map[string]any @@ -49,7 +54,7 @@ func newSnapshot(facts []ResolvedFact, log *slog.Logger) *Snapshot { // rebuild the tree per call. func (sn *Snapshot) Value(query string) (any, error) { if value, found := sn.projection.LookupValue(query); found { - return value, nil + return deepCopyValue(value), nil } return nil, fmt.Errorf("fact %q: %w", query, ErrFactNotFound) } @@ -93,92 +98,170 @@ func cloneFacts(facts []ResolvedFact) []ResolvedFact { } func deepCopyValue(value any) any { - switch v := value.(type) { - case map[string]any: - out := make(map[string]any, len(v)) - for key, item := range v { - out[key] = deepCopyValue(item) - } - return out - case map[string]string: - out := make(map[string]string, len(v)) - for key, item := range v { - out[key] = item - } - return out - case map[string][]string: - out := make(map[string][]string, len(v)) - for key, item := range v { - out[key] = slices.Clone(item) - } - return out - case map[any]any: - out := make(map[any]any, len(v)) - for key, item := range v { - out[key] = deepCopyValue(item) - } - return out - case []any: - out := make([]any, len(v)) - for i, item := range v { - out[i] = deepCopyValue(item) - } - return out - case []string: - return slices.Clone(v) - case []int: - return slices.Clone(v) - default: - return deepCopyReflect(value) - } + return deepCopyValueSeen(value, map[deepCopyVisit]reflect.Value{}) +} + +type deepCopyVisit struct { + typ reflect.Type + ptr uintptr + length int +} + +func deepCopyValueSeen(value any, seen map[deepCopyVisit]reflect.Value) any { + return deepCopyReflect(value, seen) } -func deepCopyReflect(value any) any { +func deepCopyReflect(value any, seen map[deepCopyVisit]reflect.Value) any { rv := reflect.ValueOf(value) if !rv.IsValid() { return value } switch rv.Kind() { + case reflect.Interface: + if rv.IsNil() { + return value + } + return deepCopyValueSeen(rv.Elem().Interface(), seen) case reflect.Slice: if rv.IsNil() { return value } + if copied, ok := deepCopySeenValue(rv, seen); ok { + return copied.Interface() + } out := reflect.MakeSlice(rv.Type(), rv.Len(), rv.Len()) + rememberDeepCopy(rv, out, seen) for i := range rv.Len() { - setReflectValue(out.Index(i), deepCopyValue(rv.Index(i).Interface())) + setReflectValue(out.Index(i), deepCopyValueSeen(rv.Index(i).Interface(), seen)) } return out.Interface() case reflect.Array: out := reflect.New(rv.Type()).Elem() for i := range rv.Len() { - setReflectValue(out.Index(i), deepCopyValue(rv.Index(i).Interface())) + setReflectValue(out.Index(i), deepCopyValueSeen(rv.Index(i).Interface(), seen)) } return out.Interface() case reflect.Map: if rv.IsNil() { return value } + if copied, ok := deepCopySeenValue(rv, seen); ok { + return copied.Interface() + } out := reflect.MakeMapWithSize(rv.Type(), rv.Len()) + rememberDeepCopy(rv, out, seen) for _, key := range rv.MapKeys() { - item := deepCopyValue(rv.MapIndex(key).Interface()) + copiedKey := deepCopyMapKey(key, rv.Type().Key(), seen) + item := deepCopyValueSeen(rv.MapIndex(key).Interface(), seen) itemValue := reflect.ValueOf(item) if item == nil { itemValue = reflect.Zero(rv.Type().Elem()) } if itemValue.IsValid() && itemValue.Type().AssignableTo(rv.Type().Elem()) { - out.SetMapIndex(key, itemValue) + out.SetMapIndex(copiedKey, itemValue) } else if itemValue.IsValid() && itemValue.Type().ConvertibleTo(rv.Type().Elem()) { - out.SetMapIndex(key, itemValue.Convert(rv.Type().Elem())) + out.SetMapIndex(copiedKey, itemValue.Convert(rv.Type().Elem())) } else { - out.SetMapIndex(key, rv.MapIndex(key)) + out.SetMapIndex(copiedKey, rv.MapIndex(key)) } } return out.Interface() + case reflect.Struct: + out := reflect.New(rv.Type()).Elem() + // Preserve unexported fields by value. Deep-copying private reference + // fields without unsafe is not possible; exported fields are copied below. + // Pointer-rooted struct cycles are memoized by the pointer case; a top-level + // value struct has no stable source pointer to register here. + out.Set(rv) + for i := range rv.NumField() { + dst := out.Field(i) + if !dst.CanSet() { + continue + } + src := rv.Field(i) + if src.CanInterface() { + setReflectValue(dst, deepCopyValueSeen(src.Interface(), seen)) + continue + } + dst.Set(src) + } + return out.Interface() + case reflect.Pointer: + if rv.IsNil() { + return value + } + if copied, ok := deepCopySeenValue(rv, seen); ok { + return copied.Interface() + } + out := reflect.New(rv.Type().Elem()) + rememberDeepCopy(rv, out, seen) + setReflectValue(out.Elem(), deepCopyValueSeen(rv.Elem().Interface(), seen)) + return out.Interface() default: return value } } +func deepCopyMapKey(key reflect.Value, keyType reflect.Type, seen map[deepCopyVisit]reflect.Value) reflect.Value { + item := deepCopyValueSeen(key.Interface(), seen) + if item == nil { + return reflect.Zero(keyType) + } + itemValue := reflect.ValueOf(item) + if !itemValue.IsValid() || !itemValue.Type().Comparable() { + return key + } + if itemValue.Type().AssignableTo(keyType) { + return itemValue + } + if itemValue.Type().ConvertibleTo(keyType) { + converted := itemValue.Convert(keyType) + if converted.Type().Comparable() { + return converted + } + } + return key +} + +func deepCopySeenValue(rv reflect.Value, seen map[deepCopyVisit]reflect.Value) (reflect.Value, bool) { + visit, ok := deepCopyVisitFor(rv) + if !ok { + return reflect.Value{}, false + } + copied, ok := seen[visit] + return copied, ok +} + +func rememberDeepCopy(source, copied reflect.Value, seen map[deepCopyVisit]reflect.Value) { + visit, ok := deepCopyVisitFor(source) + if ok { + seen[visit] = copied + } +} + +func deepCopyVisitFor(rv reflect.Value) (deepCopyVisit, bool) { + var ptr uintptr + switch rv.Kind() { + case reflect.Map, reflect.Pointer: + ptr = rv.Pointer() + case reflect.Slice: + if rv.Len() == 0 { + return deepCopyVisit{}, false + } + ptr = uintptr(rv.UnsafePointer()) + default: + return deepCopyVisit{}, false + } + if ptr == 0 { + return deepCopyVisit{}, false + } + visit := deepCopyVisit{typ: rv.Type(), ptr: ptr} + if rv.Kind() == reflect.Slice { + visit.length = rv.Len() + } + return visit, true +} + func setReflectValue(dst reflect.Value, value any) { if value == nil { dst.SetZero() diff --git a/internal/engine/snapshot_test.go b/internal/engine/snapshot_test.go index 8bc6f72e..f1d981da 100644 --- a/internal/engine/snapshot_test.go +++ b/internal/engine/snapshot_test.go @@ -1,6 +1,7 @@ package engine import ( + "errors" "reflect" "testing" "time" @@ -75,8 +76,13 @@ func TestCustomValueContainsNullByte(t *testing.T) { {"map value with NUL", map[string]any{"k": "v\x00"}, true}, {"map key with NUL", map[string]any{"k\x00": "v"}, true}, {"nested NUL deep in slice-of-map", []any{map[string]any{"k": []any{"x\x00"}}}, true}, + {"nil", nil, false}, + {"clean typed array", [2]string{"a", "b"}, false}, {"typed slice with NUL element", []string{"a", "b\x00"}, true}, {"typed map with slice NUL element", map[string][]string{"k": {"v\x00"}}, true}, + {"typed map key with NUL", map[string]string{"k\x00": "v"}, true}, + {"pointer to string with NUL", ptrTo("x\x00"), true}, + {"nil pointer", (*string)(nil), false}, {"non-string scalar is never a NUL", 42, false}, } @@ -89,6 +95,128 @@ func TestCustomValueContainsNullByte(t *testing.T) { } } +func TestSnapshotValueReportsMissingFactSentinel(t *testing.T) { + snap := newSnapshot([]ResolvedFact{ + {Name: "present", Type: "external", UserQuery: "present", Value: nil}, + }, discardLog()) + + got, err := snap.Value("present") + if err != nil || got != nil { + t.Fatalf("Value(present) = %#v, %v, want nil, nil", got, err) + } + + got, err = snap.Value("missing") + if got != nil || !errors.Is(err, ErrFactNotFound) { + t.Fatalf("Value(missing) = %#v, %v, want ErrFactNotFound", got, err) + } +} + +func TestSnapshotTreeReturnsDeepCopy(t *testing.T) { + snap := newSnapshot([]ResolvedFact{ + {Name: "root.child", Value: []any{"original"}}, + }, discardLog()) + + tree := snap.Tree() + tree["root"].(map[string]any)["child"].([]any)[0] = "mutated" + + fresh := snap.Tree() + if got := fresh["root"].(map[string]any)["child"].([]any)[0]; got != "original" { + t.Fatalf("fresh Tree() after Tree mutation = %#v, want original", got) + } + + got, err := snap.Value("root.child.0") + if err != nil || got != "original" { + t.Fatalf("Value(root.child.0) after Tree mutation = %#v, %v, want original", got, err) + } +} + +func TestSnapshotFactsReturnsDeepCopies(t *testing.T) { + snap := newSnapshot([]ResolvedFact{ + {Name: "root", Value: map[string]any{"child": []any{"original"}}}, + }, discardLog()) + + facts := snap.Facts() + facts[0].Value.(map[string]any)["child"].([]any)[0] = "mutated" + + got, err := snap.Value("root.child.0") + if err != nil || got != "original" { + t.Fatalf("Value(root.child.0) after Facts mutation = %#v, %v, want original", got, err) + } + fresh := snap.Facts() + if got := fresh[0].Value.(map[string]any)["child"].([]any)[0]; got != "original" { + t.Fatalf("fresh Facts()[0] = %#v, want original", got) + } +} + +func TestSnapshotCopiesPointerValues(t *testing.T) { + original := map[string]any{"child": "original"} + snap := newSnapshot([]ResolvedFact{{Name: "root", Value: &original}}, discardLog()) + + original["child"] = "outside" + got, err := snap.Value("root") + if err != nil { + t.Fatal(err) + } + if got := (*got.(*map[string]any))["child"]; got != "original" { + t.Fatalf("Value(root) after source mutation = %#v, want original", got) + } + (*got.(*map[string]any))["child"] = "mutated" + freshValue, err := snap.Value("root") + if err != nil { + t.Fatal(err) + } + if got := (*freshValue.(*map[string]any))["child"]; got != "original" { + t.Fatalf("fresh Value(root) after pointer mutation = %#v, want original", got) + } + + facts := snap.Facts() + (*facts[0].Value.(*map[string]any))["child"] = "mutated" + fresh := snap.Facts() + if got := (*fresh[0].Value.(*map[string]any))["child"]; got != "original" { + t.Fatalf("fresh Facts()[0] after pointer mutation = %#v, want original", got) + } + + tree := snap.Tree() + (*tree["root"].(*map[string]any))["child"] = "mutated" + freshTree := snap.Tree() + if got := (*freshTree["root"].(*map[string]any))["child"]; got != "original" { + t.Fatalf("fresh Tree()[root] after pointer mutation = %#v, want original", got) + } +} + +func TestSnapshotValuePreservesTypedNilCollections(t *testing.T) { + var items []any + var labels map[string]string + snap := newSnapshot([]ResolvedFact{ + {Name: "items", Value: items}, + {Name: "labels", Value: labels}, + }, discardLog()) + + gotItems, err := snap.Value("items") + if err != nil { + t.Fatal(err) + } + itemsValue, ok := gotItems.([]any) + if !ok { + t.Fatalf("Value(items) = %T, want []any", gotItems) + } + if itemsValue != nil { + t.Fatalf("Value(items) = %#v, want typed nil slice", itemsValue) + } + + gotLabels, err := snap.Value("labels") + if err != nil { + t.Fatal(err) + } + labelsValue, ok := gotLabels.(map[string]string) + if !ok { + t.Fatalf("Value(labels) = %T, want map[string]string", gotLabels) + } + if labelsValue != nil { + t.Fatalf("Value(labels) = %#v, want typed nil map", labelsValue) + } +} + // deepCopyValue backs Snapshot.Tree/All, which promise that mutating the // returned value cannot affect the snapshot. This proves the copy is deep: // mutating a nested map and slice in the copy leaves the original intact. @@ -135,6 +263,57 @@ func TestDeepCopyValueHandlesAnyKeyedMap(t *testing.T) { } } +func TestDeepCopyValueHandlesNilAnyMapKey(t *testing.T) { + originalValue := map[string]any{"name": "original"} + original := map[any]any{nil: originalValue} + + copied, ok := deepCopyValue(original).(map[any]any) + if !ok { + t.Fatalf("deepCopyValue returned %T, want map[any]any", deepCopyValue(original)) + } + copiedValue, ok := copied[nil].(map[string]any) + if !ok { + t.Fatalf("copied nil-key value = %T, want map[string]any", copied[nil]) + } + copiedValue["name"] = "mutated" + + if got := originalValue["name"]; got != "original" { + t.Fatalf("mutating copied nil-key map value changed original: %v", got) + } +} + +func TestDeepCopyValueHandlesNilSliceElements(t *testing.T) { + original := []any{nil, map[string]any{"name": "original"}} + + copied, ok := deepCopyValue(original).([]any) + if !ok { + t.Fatalf("deepCopyValue returned %T, want []any", deepCopyValue(original)) + } + if copied[0] != nil { + t.Fatalf("copied[0] = %#v, want nil", copied[0]) + } + copied[1].(map[string]any)["name"] = "mutated" + + if got := original[1].(map[string]any)["name"]; got != "original" { + t.Fatalf("mutating copied map after nil element changed original: %v", got) + } +} + +func TestDeepCopyValuePreservesEmptyNonNilSlice(t *testing.T) { + original := make([]any, 0) + + copied, ok := deepCopyValue(original).([]any) + if !ok { + t.Fatalf("deepCopyValue returned %T, want []any", deepCopyValue(original)) + } + if copied == nil { + t.Fatal("deepCopyValue returned nil for empty non-nil slice") + } + if len(copied) != 0 { + t.Fatalf("len(copied) = %d, want 0", len(copied)) + } +} + func TestDeepCopyValueHandlesTypedSlicesInMaps(t *testing.T) { original := map[string][]int{"numbers": {1, 2}} @@ -148,3 +327,299 @@ func TestDeepCopyValueHandlesTypedSlicesInMaps(t *testing.T) { t.Errorf("original typed slice mutated through copy: numbers[0] = %d, want 1", got) } } + +func TestDeepCopyValueHandlesTypedSliceReflectPath(t *testing.T) { + type typedMaps []map[string]any + original := typedMaps{{"name": "first"}} + + copied, ok := deepCopyValue(original).(typedMaps) + if !ok { + t.Fatalf("deepCopyValue returned %T, want typedMaps", deepCopyValue(original)) + } + copied[0]["name"] = "mutated" + + if got := original[0]["name"]; got != "first" { + t.Fatalf("original typed slice element mutated through copy: %q, want first", got) + } +} + +func TestDeepCopyValueHandlesReflectMapWithNilAndNestedValues(t *testing.T) { + original := map[int]any{ + 1: nil, + 2: map[string]any{"name": "second"}, + } + + copied, ok := deepCopyValue(original).(map[int]any) + if !ok { + t.Fatalf("deepCopyValue returned %T, want map[int]any", deepCopyValue(original)) + } + if copied[1] != nil { + t.Fatalf("copied nil value = %#v, want nil", copied[1]) + } + copied[2].(map[string]any)["name"] = "mutated" + + if got := original[2].(map[string]any)["name"]; got != "second" { + t.Fatalf("original reflected map value mutated through copy: %q, want second", got) + } +} + +func TestDeepCopyValueCopiesPointerMapKeys(t *testing.T) { + type payload struct { + Name string + } + originalKey := &payload{Name: "original"} + original := map[*payload]any{originalKey: originalKey} + + copied, ok := deepCopyValue(original).(map[*payload]any) + if !ok { + t.Fatalf("deepCopyValue returned %T, want map[*payload]any", deepCopyValue(original)) + } + if len(copied) != 1 { + t.Fatalf("copied map length = %d, want 1", len(copied)) + } + for copiedKey, copiedValue := range copied { + if copiedKey == originalKey { + t.Fatal("deepCopyValue reused original pointer map key") + } + if copiedValue.(*payload) != copiedKey { + t.Fatal("deepCopyValue did not preserve key/value aliasing inside copied graph") + } + copiedKey.Name = "mutated" + } + if originalKey.Name != "original" { + t.Fatalf("mutating copied map key changed original key: %q", originalKey.Name) + } +} + +func TestSnapshotAllIteratesSortedCopies(t *testing.T) { + snap := newSnapshot([]ResolvedFact{ + {Name: "zeta", Value: map[string]any{"nested": "z"}}, + {Name: "beta", Value: "b"}, + {Name: "alpha", Value: []any{"a"}}, + {Name: "delta", Value: "d"}, + {Name: "gamma", Value: "g"}, + {Name: "omega.child", Value: map[string]any{"nested": "o"}}, + }, discardLog()) + + var names []string + for name, value := range snap.All() { + names = append(names, name) + switch name { + case "alpha": + value.([]any)[0] = "mutated" + case "zeta": + value.(map[string]any)["nested"] = "mutated" + case "omega": + value.(map[string]any)["child"].(map[string]any)["nested"] = "mutated" + } + } + + if want := []string{"alpha", "beta", "delta", "gamma", "omega", "zeta"}; !reflect.DeepEqual(names, want) { + t.Fatalf("All() names = %#v, want %#v", names, want) + } + if got, err := snap.Value("alpha.0"); err != nil || got != "a" { + t.Fatalf("Value(alpha.0) after All mutation = %#v, %v, want a", got, err) + } + if got, err := snap.Value("zeta.nested"); err != nil || got != "z" { + t.Fatalf("Value(zeta.nested) after All mutation = %#v, %v, want z", got, err) + } + if got, err := snap.Value("omega.child.nested"); err != nil || got != "o" { + t.Fatalf("Value(omega.child.nested) after All mutation = %#v, %v, want o", got, err) + } +} + +func TestSnapshotAllStopsWhenYieldReturnsFalse(t *testing.T) { + snap := newSnapshot([]ResolvedFact{ + {Name: "alpha", Value: "a"}, + {Name: "beta", Value: "b"}, + {Name: "gamma", Value: "g"}, + }, discardLog()) + + var names []string + for name := range snap.All() { + names = append(names, name) + break + } + + if want := []string{"alpha"}; !reflect.DeepEqual(names, want) { + t.Fatalf("All() yielded %#v before stop, want %#v", names, want) + } +} + +func TestDeepCopyValueHandlesArrays(t *testing.T) { + original := [2]map[string]any{{"name": "first"}, {"name": "second"}} + + copied, ok := deepCopyValue(original).([2]map[string]any) + if !ok { + t.Fatalf("deepCopyValue returned %T, want [2]map[string]any", deepCopyValue(original)) + } + copied[0]["name"] = "mutated" + + if got := original[0]["name"]; got != "first" { + t.Fatalf("original array element mutated through copy: %q, want first", got) + } +} + +func TestDeepCopyValueHandlesPointers(t *testing.T) { + original := map[string]any{"name": "first"} + + copied, ok := deepCopyValue(&original).(*map[string]any) + if !ok { + t.Fatalf("deepCopyValue returned %T, want *map[string]any", deepCopyValue(&original)) + } + (*copied)["name"] = "mutated" + + if got := original["name"]; got != "first" { + t.Fatalf("original pointer target mutated through copy: %q, want first", got) + } + if copied == &original { + t.Fatal("deepCopyValue returned original pointer") + } +} + +func TestDeepCopyValueHandlesPointersToStructs(t *testing.T) { + type payload struct { + Data map[string]any + } + original := payload{Data: map[string]any{"name": "first"}} + + copied, ok := deepCopyValue(&original).(*payload) + if !ok { + t.Fatalf("deepCopyValue returned %T, want *payload", deepCopyValue(&original)) + } + copied.Data["name"] = "mutated" + + if got := original.Data["name"]; got != "first" { + t.Fatalf("original pointer target mutated through struct copy: %q, want first", got) + } + if copied == &original { + t.Fatal("deepCopyValue returned original pointer") + } +} + +func TestDeepCopyValuePreservesUnexportedStructFieldsByValueOnly(t *testing.T) { + type payload struct { + data map[string]any + } + original := payload{data: map[string]any{"name": "first"}} + + copied, ok := deepCopyValue(original).(payload) + if !ok { + t.Fatalf("deepCopyValue returned %T, want payload", deepCopyValue(original)) + } + copied.data["name"] = "mutated" + + if got := original.data["name"]; got != "mutated" { + t.Fatalf("unexported map field was deep-copied, got original %q; want shallow value copy", got) + } +} + +func TestDeepCopyValueHandlesPointerCycles(t *testing.T) { + type node struct { + Next *node + } + original := &node{} + original.Next = original + + copied, ok := deepCopyValue(original).(*node) + if !ok { + t.Fatalf("deepCopyValue returned %T, want *node", deepCopyValue(original)) + } + if copied == original { + t.Fatal("deepCopyValue returned original pointer") + } + if copied.Next == nil { + t.Fatal("deepCopyValue cycle copy has nil Next") + } + if copied.Next != copied { + t.Fatal("deepCopyValue cycle copy points outside the copied graph") + } + copied.Next.Next = nil + if original.Next != original { + t.Fatal("mutating copied cycle changed original pointer graph") + } +} + +func TestDeepCopyValueHandlesMapCycles(t *testing.T) { + original := map[string]any{"name": "original"} + original["self"] = original + + copied, ok := deepCopyValue(original).(map[string]any) + if !ok { + t.Fatalf("deepCopyValue returned %T, want map[string]any", deepCopyValue(original)) + } + self, ok := copied["self"].(map[string]any) + if !ok { + t.Fatalf("copied self = %T, want map[string]any", copied["self"]) + } + if reflect.ValueOf(self).Pointer() != reflect.ValueOf(copied).Pointer() { + t.Fatal("deepCopyValue map cycle points outside the copied graph") + } + self["name"] = "mutated" + if got := copied["name"]; got != "mutated" { + t.Fatalf("copied self mutation did not affect copied map: %v", got) + } + if got := original["name"]; got != "original" { + t.Fatalf("mutating copied map cycle changed original: %v", got) + } +} + +func TestDeepCopyValueHandlesSliceCycles(t *testing.T) { + original := []any{"placeholder"} + original[0] = original + + copied, ok := deepCopyValue(original).([]any) + if !ok { + t.Fatalf("deepCopyValue returned %T, want []any", deepCopyValue(original)) + } + self, ok := copied[0].([]any) + if !ok { + t.Fatalf("copied self = %T, want []any", copied[0]) + } + if reflect.ValueOf(self).Pointer() != reflect.ValueOf(copied).Pointer() { + t.Fatal("deepCopyValue slice cycle points outside the copied graph") + } + self[0] = "mutated" + if got := copied[0]; got != "mutated" { + t.Fatalf("copied self mutation did not affect copied slice: %v", got) + } + originalSelf, ok := original[0].([]any) + if !ok { + t.Fatalf("original self = %T, want []any", original[0]) + } + if reflect.ValueOf(originalSelf).Pointer() != reflect.ValueOf(original).Pointer() { + t.Fatal("mutating copied slice cycle changed original graph") + } +} + +func TestDeepCopyValueDoesNotConflateOverlappingSlices(t *testing.T) { + base := []any{"first", "second"} + original := []any{base[:1], base[:2]} + + copied, ok := deepCopyValue(original).([]any) + if !ok { + t.Fatalf("deepCopyValue returned %T, want []any", deepCopyValue(original)) + } + first, ok := copied[0].([]any) + if !ok { + t.Fatalf("copied[0] = %T, want []any", copied[0]) + } + second, ok := copied[1].([]any) + if !ok { + t.Fatalf("copied[1] = %T, want []any", copied[1]) + } + if len(first) != 1 { + t.Fatalf("len(copied[0]) = %d, want 1", len(first)) + } + if len(second) != 2 { + t.Fatalf("len(copied[1]) = %d, want 2", len(second)) + } + second[0] = "mutated" + if got := base[0]; got != "first" { + t.Fatalf("mutating copied overlapping slice changed original: %v", got) + } +} + +func ptrTo[T any](value T) *T { + return &value +} diff --git a/internal/engine/statfs_math_test.go b/internal/engine/statfs_math_test.go index f9a1a000..173d4b6b 100644 --- a/internal/engine/statfs_math_test.go +++ b/internal/engine/statfs_math_test.go @@ -2,6 +2,36 @@ package engine import "testing" +func TestStatfsBlockBytesRejectsZeroInputs(t *testing.T) { + t.Parallel() + + if got := statfsBlockBytes(0, 4096); got != 0 { + t.Fatalf("statfsBlockBytes(zero blocks) = %d, want 0", got) + } + if got := statfsBlockBytes(10, 0); got != 0 { + t.Fatalf("statfsBlockBytes(zero block size) = %d, want 0", got) + } +} + +func TestStatfsBlockBytesClampsOverflowOnAllPlatforms(t *testing.T) { + t.Parallel() + + if got := statfsBlockBytes(^uint64(0), 4096); got != maxInt { + t.Fatalf("statfsBlockBytes(overflow) = %d, want maxInt", got) + } +} + +func TestStatfsNativeBlockBytesRejectsNegativeBlocks(t *testing.T) { + t.Parallel() + + if got := statfsNativeBlockBytes(int64(-1), 4096); got != 0 { + t.Fatalf("statfsNativeBlockBytes(negative blocks) = %d, want 0", got) + } + if got := statfsNativeBlockBytes(int64(7), 512); got != 3584 { + t.Fatalf("statfsNativeBlockBytes() = %d, want 3584", got) + } +} + func TestStatfsUsedBlockBytesClampsAfterSubtractingFreeBlocks(t *testing.T) { got := statfsUsedBlockBytes(^uint64(0), ^uint64(0)-1, 4096) if got != 4096 { @@ -15,3 +45,33 @@ func TestStatfsUsedBlockBytesClampsOverflow(t *testing.T) { t.Fatalf("statfsUsedBlockBytes() = %d, want maxInt", got) } } + +func TestStatfsNativeUsedBytesRejectsInvalidInputs(t *testing.T) { + tests := []struct { + name string + blocks int64 + free int64 + blockSize uint64 + }{ + {name: "zero blocks", blocks: 0, free: 0, blockSize: 4096}, + {name: "negative blocks", blocks: -1, free: 0, blockSize: 4096}, + {name: "negative free blocks", blocks: 10, free: -1, blockSize: 4096}, + {name: "free blocks exceed total", blocks: 10, free: 12, blockSize: 4096}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + if got := statfsNativeUsedBytes(tt.blocks, tt.free, tt.blockSize); got != 0 { + t.Fatalf("statfsNativeUsedBytes(%d, %d, %d) = %d, want 0", tt.blocks, tt.free, tt.blockSize, got) + } + }) + } +} + +func TestStatfsNativeUsedBytesSubtractsFreeBlocks(t *testing.T) { + if got := statfsNativeUsedBytes(int64(10), int64(3), uint64(4096)); got != 28_672 { + t.Fatalf("statfsNativeUsedBytes() = %d, want 28672", got) + } +} diff --git a/internal/engine/timezone.go b/internal/engine/timezone.go index 7af11c6b..bbb8647f 100644 --- a/internal/engine/timezone.go +++ b/internal/engine/timezone.go @@ -15,7 +15,10 @@ import ( ) func currentTimezone(s *Session, goos string) string { - zone := time.Now().Format("MST") + return currentTimezoneForZone(s, goos, time.Now().Format("MST")) +} + +func currentTimezoneForZone(s *Session, goos, zone string) string { if goos != "windows" { return zone } @@ -44,14 +47,14 @@ func currentWindowsTimezone(goos, zone, apiCodepage string, registryCodepage fun } func currentWindowsAPICodepage(s *Session) string { - if runtime.GOOS != "windows" { + if s.goos() != "windows" { return "" } return firstNumber(s.commandOutput("cmd", "/c", "chcp")) } func currentWindowsRegistryCodepage(s *Session) string { - if runtime.GOOS != "windows" { + if s.goos() != "windows" { return "" } return parseWindowsACPRegistry(s.commandOutput("reg", "query", `HKLM\SYSTEM\CurrentControlSet\Control\Nls\CodePage`, "/v", "ACP")) diff --git a/internal/engine/timezone_test.go b/internal/engine/timezone_test.go index 4056a694..1260e3e7 100644 --- a/internal/engine/timezone_test.go +++ b/internal/engine/timezone_test.go @@ -1,6 +1,7 @@ package engine import ( + "runtime" "testing" "time" ) @@ -70,6 +71,180 @@ func TestCurrentWindowsTimezoneRunsOnlyOnWindows(t *testing.T) { } } +func TestCurrentWindowsTimezoneReturnsEmptyForEmptyZone(t *testing.T) { + t.Parallel() + + called := false + got := currentWindowsTimezone("windows", "", "850", func() string { + called = true + return "850" + }) + if got != "" { + t.Fatalf("currentWindowsTimezone(empty zone) = %q, want empty", got) + } + if called { + t.Fatal("currentWindowsTimezone(empty zone) read registry codepage") + } +} + +func TestCurrentTimezoneForZoneDecodesWindowsZoneWithSessionCodepage(t *testing.T) { + s := NewSession() + s.host = &fakeHostOS{ + platform: "windows", + runOutputs: map[string]string{ + fakeRunKey("cmd", "/c", "chcp"): "Active code page: 850.\n", + }, + } + + got := currentTimezoneForZone(s, "windows", "Hora est\xa0ndar") + if got != "Hora estándar" { + t.Fatalf("currentTimezoneForZone(windows) = %q, want Hora estándar", got) + } +} + +func TestCurrentTimezoneForZoneKeepsZoneOutsideWindows(t *testing.T) { + got := currentTimezoneForZone(testSession, "linux", "UTC") + if got != "UTC" { + t.Fatalf("currentTimezoneForZone(linux) = %q, want UTC", got) + } +} + +func TestCurrentTimezoneForZoneKeepsEmptyWindowsZone(t *testing.T) { + got := currentTimezoneForZone(testSession, "windows", "") + if got != "" { + t.Fatalf("currentTimezoneForZone(empty windows zone) = %q, want empty", got) + } +} + +func TestCurrentWindowsCodepageProbesReadWindowsCommands(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("Windows codepage probes only run on Windows") + } + + host := &fakeHostOS{runOutputs: map[string]string{ + fakeRunKey("cmd", "/c", "chcp"): "Active code page: 850.\n", + fakeRunKey("reg", "query", `HKLM\SYSTEM\CurrentControlSet\Control\Nls\CodePage`, "/v", "ACP"): `HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Nls\CodePage + ACP REG_SZ 936 +`, + }} + s := NewSession() + s.host = host + + api := currentWindowsAPICodepage(s) + registry := currentWindowsRegistryCodepage(s) + if api != "850" || registry != "936" { + t.Fatalf("codepages = %q, %q; want 850, 936", api, registry) + } + if len(host.runCalls) != 2 { + t.Fatalf("run calls = %#v, want API and registry probes", host.runCalls) + } +} + +func TestCurrentWindowsCodepageProbesDoNotRunOutsideWindows(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("non-Windows guard assertion") + } + + host := &fakeHostOS{runOutputs: map[string]string{ + fakeRunKey("cmd", "/c", "chcp"): "Active code page: 850.\n", + fakeRunKey("reg", "query", `HKLM\SYSTEM\CurrentControlSet\Control\Nls\CodePage`, "/v", "ACP"): `HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Nls\CodePage + ACP REG_SZ 936 +`, + }} + s := NewSession() + s.host = host + + api := currentWindowsAPICodepage(s) + registry := currentWindowsRegistryCodepage(s) + if api != "" || registry != "" { + t.Fatalf("non-Windows codepages = %q, %q; want empty", api, registry) + } + if len(host.runCalls) != 0 { + t.Fatalf("non-Windows run calls = %#v, want none", host.runCalls) + } +} + +func TestCurrentWindowsCodepageProbesUseSessionPlatform(t *testing.T) { + host := &fakeHostOS{ + platform: "windows", + runOutputs: map[string]string{ + fakeRunKey("cmd", "/c", "chcp"): "Active code page: 850.\n", + fakeRunKey("reg", "query", `HKLM\SYSTEM\CurrentControlSet\Control\Nls\CodePage`, "/v", "ACP"): `HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Nls\CodePage + ACP REG_SZ 936 +`, + }, + } + s := NewSession() + s.host = host + + api := currentWindowsAPICodepage(s) + registry := currentWindowsRegistryCodepage(s) + if api != "850" || registry != "936" { + t.Fatalf("codepages = %q, %q; want 850, 936", api, registry) + } +} + +func TestParseWindowsACPRegistry(t *testing.T) { + t.Parallel() + + input := `HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Nls\CodePage + ACP REG_SZ 936 + OEMCP REG_SZ 437 +` + if got := parseWindowsACPRegistry(input); got != "936" { + t.Fatalf("parseWindowsACPRegistry() = %q, want 936", got) + } + if got := parseWindowsACPRegistry("OEMCP REG_SZ 437"); got != "" { + t.Fatalf("parseWindowsACPRegistry(no ACP) = %q, want empty", got) + } +} + +func TestFirstNumberExtractsTrimmedCodepage(t *testing.T) { + t.Parallel() + + tests := []struct { + input string + want string + }{ + {input: "Active code page: 850.", want: "850"}, + {input: "Codepage CP1252", want: ""}, + {input: "no number here", want: ""}, + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + if got := firstNumber(tt.input); got != tt.want { + t.Fatalf("firstNumber(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestDecodeWindowsCodepageSupportsRubyCompatibleCodepages(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + value string + codepage string + want string + wantOK bool + }{ + {name: "cp437", value: "\x9b", codepage: "CP437", want: "¢", wantOK: true}, + {name: "cp850", value: "\x9b", codepage: "850", want: "ø", wantOK: true}, + {name: "windows 1252", value: "\xe9", codepage: "1252", want: "é", wantOK: true}, + {name: "unsupported", value: "\xe9", codepage: "not-a-codepage"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := decodeWindowsCodepage(tt.value, tt.codepage) + if got != tt.want || ok != tt.wantOK { + t.Fatalf("decodeWindowsCodepage(%q, %q) = (%q, %v), want (%q, %v)", tt.value, tt.codepage, got, ok, tt.want, tt.wantOK) + } + }) + } +} + func TestCurrentTimezoneLinuxMatchesRubyResolverFormat(t *testing.T) { t.Parallel() assertCurrentTimezonePOSIXMatchesRubyResolverFormat(t, "linux") @@ -95,6 +270,23 @@ func TestCurrentTimezoneNetBSDMatchesRubyResolverFormat(t *testing.T) { assertCurrentTimezonePOSIXMatchesRubyResolverFormat(t, "netbsd") } +func TestCurrentTimezoneWindowsKeepsValidLocalZone(t *testing.T) { + s := NewSession() + s.host = &fakeHostOS{ + platform: "windows", + runOutputs: map[string]string{ + fakeRunKey("cmd", "/c", "chcp"): "", + }, + } + + before := time.Now().Format("MST") + got := currentTimezone(s, "windows") + after := time.Now().Format("MST") + if got != before && got != after { + t.Fatalf("currentTimezone(windows) = %q, want local timezone abbreviation %q or %q", got, before, after) + } +} + func assertCurrentTimezonePOSIXMatchesRubyResolverFormat(t *testing.T, goos string) { t.Helper() diff --git a/internal/engine/uptime.go b/internal/engine/uptime.go index 8c13c7af..1901117a 100644 --- a/internal/engine/uptime.go +++ b/internal/engine/uptime.go @@ -31,7 +31,7 @@ func uptimeString(uptime uptimeInfo) string { } func probeUptime(s *Session) uptimeInfo { - return currentUptimeInfo(s, runtime.GOOS, s.readFile, s.commandOutput, time.Now) + return currentUptimeInfo(s, s.goos(), s.readFile, s.commandOutput, time.Now) } func currentUptime(s *Session, goos string, readFile fileReader, run commandRunner, now func() time.Time) time.Duration { @@ -362,7 +362,7 @@ func emptyLoadAverages() map[string]any { // uptimeCoreFacts assembles the uptime category facts (the system_uptime fields // and the load_averages fact) for the current host. func uptimeCoreFacts(s *Session) []ResolvedFact { - if runtime.GOOS == "plan9" { + if s.goos() == "plan9" { return plan9UptimeCoreFacts(s.cachedUptime()) } return uptimeFacts(s.cachedUptime(), s.cachedLoadAverages()) diff --git a/internal/engine/uptime_test.go b/internal/engine/uptime_test.go index 5bfb1fcf..ce0be304 100644 --- a/internal/engine/uptime_test.go +++ b/internal/engine/uptime_test.go @@ -83,6 +83,28 @@ func TestUptimeFactsUseInt64DurationFields(t *testing.T) { } } +func TestUptimeCoreFactsUsesSessionPlatform(t *testing.T) { + s := NewSessionContext(t.Context()) + s.host = &fakeHostOS{ + platform: "plan9", + runOutputs: map[string]string{ + fakeRunKey("uptime"): "10:00AM up 1 day, 2:03, 1 user\n", + }, + } + + got := Collection(uptimeCoreFacts(s)) + systemUptime, ok := got["system_uptime"].(map[string]any) + if !ok { + t.Fatalf("system_uptime = %#v, want map", got["system_uptime"]) + } + if got := systemUptime["seconds"]; got != int64(93_780) { + t.Fatalf("system_uptime.seconds = %#v, want 93780", got) + } + if _, ok := got["load_averages"]; ok { + t.Fatalf("load_averages present for Plan 9 uptime facts: %#v", got) + } +} + func TestCurrentUptimeInfoUsesPID1ElapsedTimeForKubernetes(t *testing.T) { s := NewSession() s.host = &fakeHostOS{ @@ -300,6 +322,18 @@ func TestCurrentUptimeUsesWindowsWMITimes(t *testing.T) { } } +func TestCurrentWindowsUptimeSkipsNonWindows(t *testing.T) { + t.Parallel() + + got := currentWindowsUptime("linux", func(string, ...string) string { + t.Fatal("currentWindowsUptime(non-windows) ran command") + return "" + }, discardLog()) + if got != (uptimeInfo{}) { + t.Fatalf("currentWindowsUptime(non-windows) = %#v, want empty", got) + } +} + func TestCurrentUptimeReturnsZeroForInvalidWindowsWMITimes(t *testing.T) { t.Parallel() @@ -354,6 +388,35 @@ func TestCurrentWindowsUptimeInfoLogsNoResultDiagnosticsLikeRubyResolver(t *test } } +func TestCurrentWindowsUptimeInfoLogsUnparseableWMITimes(t *testing.T) { + tests := []struct { + name string + output string + }{ + {name: "invalid local time", output: "LocalDateTime=bad\r\nLastBootUpTime=20010201120506+0700\r\n"}, + {name: "invalid boot time", output: "LocalDateTime=20010201130506+0700\r\nLastBootUpTime=bad\r\n"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var debugMessages []string + s := NewSession() + s.logger = captureLogger(&debugMessages, nil, nil) + + got := currentWindowsUptime("windows", func(string, ...string) string { + return tt.output + }, s.logr()) + if got.Known || got.Duration != 0 { + t.Fatalf("currentWindowsUptime() = %#v, want unknown zero duration", got) + } + want := []string{"Unable to determine system uptime!"} + if !reflect.DeepEqual(debugMessages, want) { + t.Fatalf("debug messages = %#v, want %#v", debugMessages, want) + } + }) + } +} + func TestCurrentWindowsUptimeInfoLogsInvalidDurationLikeRubyResolver(t *testing.T) { debugMessages := []string{} s := NewSession() @@ -388,6 +451,40 @@ func TestParseWindowsWMITimeAcceptsCIMDatetimeOffsetMinutes(t *testing.T) { } } +func TestParseWindowsWMITimeRejectsMalformedValues(t *testing.T) { + t.Parallel() + + for _, input := range []string{ + "200102030405", + "20010203040506.123456", + "20010203040506+", + "20010203040506+bad", + "bad-date-time!!+0700", + } { + if got, ok := parseWindowsWMITime(input); ok || !got.IsZero() { + t.Fatalf("parseWindowsWMITime(%q) = %s, %v, want zero false", input, got, ok) + } + } +} + +func TestUptimeSourceParsersRejectMalformedInputs(t *testing.T) { + t.Parallel() + + if got := uptimeFromProc(func(string) ([]byte, error) { return []byte(""), nil }); got != 0 { + t.Fatalf("uptimeFromProc(empty) = %s, want 0", got) + } + if got := uptimeFromProc(func(string) ([]byte, error) { return []byte("not-a-number"), nil }); got != 0 { + t.Fatalf("uptimeFromProc(invalid) = %s, want 0", got) + } + + now := func() time.Time { return time.Unix(120, 0) } + for _, input := range []string{"", "{ sec = 60 }", "{ sec = bad, usec = 0 }"} { + if got := uptimeFromKernelBoottime(input, now); got != 0 { + t.Fatalf("uptimeFromKernelBoottime(%q) = %s, want 0", input, got) + } + } +} + func TestCoreFacts_includeLoadAverages(t *testing.T) { if runtime.GOOS != "darwin" && runtime.GOOS != "linux" { t.Skipf("load averages resolution is not implemented on %s", runtime.GOOS) @@ -493,3 +590,57 @@ func TestParseLoadAveragesInvalidInput(t *testing.T) { t.Fatalf("parseLoadAverages() = %#v, want %#v", got, want) } } + +func TestUptimeCommandParsersRejectMalformedDurations(t *testing.T) { + t.Parallel() + + for _, input := range []string{ + "10:00AM up users", + "10:00AM up trailing", + "10:00AM up about days", + } { + if got := parseUptimeCommandSeconds(input); got != 0 { + t.Fatalf("parseUptimeCommandSeconds(%q) = %d, want 0", input, got) + } + } + + for _, input := range []string{"bad-01:02:03", "bad:02", "01:bad", "bad:02:03", "01:bad:03", "01:02:bad", "01:02:03:04"} { + if got := parseDockerElapsedTimeSeconds(input); got != 0 { + t.Fatalf("parseDockerElapsedTimeSeconds(%q) = %d, want 0", input, got) + } + } + + for _, input := range []string{"bad:02", "01:bad"} { + if hours, minutes, ok := parseUptimeHoursMinutes(input); ok || hours != 0 || minutes != 0 { + t.Fatalf("parseUptimeHoursMinutes(%q) = %d, %d, %v, want zero false", input, hours, minutes, ok) + } + } +} + +func TestLoadAverageParsersUseEmptyFallbacks(t *testing.T) { + t.Parallel() + + want := emptyLoadAverages() + cases := []struct { + name string + got map[string]any + }{ + {name: "bsd empty sysctl", got: currentLoadAverages("freebsd", nil, func(string, ...string) string { return "" })}, + {name: "illumos empty uptime", got: currentLoadAverages("illumos", nil, func(string, ...string) string { return "" })}, + {name: "unknown goos", got: currentLoadAverages("aix", nil, nil)}, + {name: "bad float", got: parseLoadAverages("0.01 bad 0.03")}, + {name: "illumos no marker", got: parseIllumosLoadAverages("22:09:38 up 3:04")}, + } + + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + if !reflect.DeepEqual(tt.got, want) { + t.Fatalf("%s = %#v, want %#v", tt.name, tt.got, want) + } + }) + } + + if got := currentLoadAverages("plan9", nil, nil); got != nil { + t.Fatalf("currentLoadAverages(plan9) = %#v, want nil", got) + } +} diff --git a/internal/engine/virtual.go b/internal/engine/virtual.go index 3d7af859..122fc4df 100644 --- a/internal/engine/virtual.go +++ b/internal/engine/virtual.go @@ -3,7 +3,6 @@ package engine import ( "os" "regexp" - "runtime" "strconv" "strings" ) @@ -117,7 +116,7 @@ type windowsVirtualizationInput struct { } func detectVirtualization(s *Session) virtualization { - switch runtime.GOOS { + switch s.goos() { case "linux": return detectLinuxVirtualization(currentLinuxVirtualizationInput(s)) case "darwin": @@ -133,7 +132,7 @@ func detectVirtualization(s *Session) virtualization { case "illumos": return detectDMIHostVirtualization(currentIllumosVirtualizationInput(s.commandOutput)) case "windows": - return detectWindowsVirtualization(currentWindowsVirtualizationInput(runtime.GOOS, s.commandOutput)) + return detectWindowsVirtualization(currentWindowsVirtualizationInput(s.goos(), s.commandOutput)) case "plan9": return virtualization{Unknown: true} default: @@ -333,10 +332,10 @@ func parseWindowsOEMStrings(records []map[string]string) []string { } func currentWindowsHypervisorFacts(s *Session) []ResolvedFact { - if runtime.GOOS != "windows" { + if s.goos() != "windows" { return nil } - return windowsHypervisorFacts(currentWindowsVirtualizationInput(runtime.GOOS, s.commandOutput)) + return windowsHypervisorFacts(currentWindowsVirtualizationInput(s.goos(), s.commandOutput)) } func windowsHypervisorFacts(input windowsVirtualizationInput) []ResolvedFact { @@ -611,7 +610,7 @@ func procVZEntryCount(path string) int { } func currentLinuxHypervisorFacts(s *Session) []ResolvedFact { - if runtime.GOOS != "linux" { + if s.goos() != "linux" { return nil } return linuxHypervisorFacts(currentLinuxVirtualizationInput(s)) diff --git a/internal/engine/virtual_test.go b/internal/engine/virtual_test.go index b2a62675..c657fc6e 100644 --- a/internal/engine/virtual_test.go +++ b/internal/engine/virtual_test.go @@ -1,7 +1,11 @@ package engine import ( + "context" + "os" + "path/filepath" "reflect" + "runtime" "testing" ) @@ -119,6 +123,84 @@ func TestDetectLinuxVirtualization_detectsOpenVZ(t *testing.T) { } } +func TestOpenVZEnvIDRequiresUsableStatusAndHostSignals(t *testing.T) { + tests := []struct { + name string + input linuxVirtualizationInput + want int + ok bool + }{ + { + name: "OpenVZ host envID", + input: linuxVirtualizationInput{ + ProcVZ: true, + ProcVZEntries: 3, + ProcStatus: "Name:\tcat\nenvID:\t0\n", + }, + ok: true, + }, + { + name: "OpenVZ container envID", + input: linuxVirtualizationInput{ + ProcVZ: true, + ProcVZEntries: 3, + ProcStatus: "envID: 101\n", + }, + want: 101, + ok: true, + }, + { + name: "missing proc vz", + input: linuxVirtualizationInput{ + ProcVZEntries: 3, + ProcStatus: "envID: 101\n", + }, + }, + { + name: "CloudLinux LVE marker", + input: linuxVirtualizationInput{ + ProcVZ: true, + LVEList: true, + ProcVZEntries: 3, + ProcStatus: "envID: 101\n", + }, + }, + { + name: "not enough proc vz entries", + input: linuxVirtualizationInput{ + ProcVZ: true, + ProcVZEntries: 2, + ProcStatus: "envID: 101\n", + }, + }, + { + name: "invalid envID", + input: linuxVirtualizationInput{ + ProcVZ: true, + ProcVZEntries: 3, + ProcStatus: "envID: not-a-number\n", + }, + }, + { + name: "missing envID", + input: linuxVirtualizationInput{ + ProcVZ: true, + ProcVZEntries: 3, + ProcStatus: "Name:\tcat\n", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := openVZEnvID(tt.input) + if got != tt.want || ok != tt.ok { + t.Fatalf("openVZEnvID() = %d, %v; want %d, %v", got, ok, tt.want, tt.ok) + } + }) + } +} + func TestDetectLinuxVirtualization_detectsKVMFromDMI(t *testing.T) { tests := []struct { name string @@ -314,6 +396,330 @@ func TestDetectFreeBSDVirtualization(t *testing.T) { } } +func TestDetectVirtualizationUsesSessionPlatform(t *testing.T) { + s := NewSessionContext(context.Background()) + s.host = &fakeHostOS{ + platform: "freebsd", + runOutputs: map[string]string{ + fakeRunKey("sysctl", "-n", "security.jail.jailed"): "1\n", + fakeRunKey("sysctl", "-n", "kern.vm_guest"): "xen\n", + fakeRunKey("uname", "-r"): "", + fakeRunKey("dmidecode"): "", + fakeRunKey("virt-what"): "", + fakeRunKey("vmware", "-v"): "", + fakeRunKey("lspci"): "", + }, + } + + got := detectVirtualization(s) + want := virtualization{Name: "jail", IsVirtual: true} + if got != want { + t.Fatalf("detectVirtualization() = %#v, want %#v", got, want) + } +} + +func TestDetectVirtualizationDispatchesSessionPlatform(t *testing.T) { + tests := []struct { + name string + host *fakeHostOS + want virtualization + }{ + { + name: "linux physical", + host: &fakeHostOS{platform: "linux", runOutputs: map[string]string{ + fakeRunKey("uname", "-r"): "", + fakeRunKey("dmidecode"): "", + fakeRunKey("virt-what"): "", + fakeRunKey("vmware", "-v"): "", + fakeRunKey("lspci"): "", + }}, + want: virtualization{Name: "physical"}, + }, + { + name: "freebsd jail", + host: &fakeHostOS{platform: "freebsd", runOutputs: map[string]string{ + fakeRunKey("sysctl", "-n", "security.jail.jailed"): "1\n", + fakeRunKey("sysctl", "-n", "kern.vm_guest"): "none\n", + }}, + want: virtualization{Name: "jail", IsVirtual: true}, + }, + { + name: "openbsd vmm", + host: &fakeHostOS{platform: "openbsd", runOutputs: map[string]string{ + fakeRunKey("sysctl", "-n", "hw.product"): "VMM\n", + fakeRunKey("sysctl", "-n", "hw.vendor"): "OpenBSD\n", + }}, + want: virtualization{Name: "vmm", IsVirtual: true}, + }, + { + name: "netbsd kvm", + host: &fakeHostOS{platform: "netbsd", runOutputs: map[string]string{ + fakeRunKey("/sbin/sysctl", "-n", "machdep.dmi.system-vendor"): "QEMU\n", + fakeRunKey("/sbin/sysctl", "-n", "machdep.dmi.system-product"): "Standard PC\n", + }}, + want: virtualization{Name: "kvm", IsVirtual: true}, + }, + { + name: "dragonfly kvm", + host: &fakeHostOS{platform: "dragonfly", runOutputs: map[string]string{ + fakeRunKey("/usr/local/sbin/dmidecode", "-t", "system"): "Manufacturer: QEMU\nProduct Name: Standard PC\n", + fakeRunKey("/usr/local/sbin/dmidecode", "-t", "bios"): "Vendor: SeaBIOS\n", + fakeRunKey("kenv", "smbios.system.maker"): "", + fakeRunKey("kenv", "smbios.system.product"): "", + fakeRunKey("kenv", "smbios.bios.vendor"): "", + fakeRunKey("pciconf", "-lv"): "", + }}, + want: virtualization{Name: "kvm", IsVirtual: true}, + }, + { + name: "illumos vmware", + host: &fakeHostOS{platform: "illumos", runOutputs: map[string]string{ + fakeRunKey("/usr/sbin/smbios", "-t", "SMB_TYPE_SYSTEM"): "Manufacturer: VMware, Inc.\nProduct: VMware Virtual Platform\n", + fakeRunKey("/usr/sbin/smbios", "-t", "SMB_TYPE_BIOS"): "", + fakeRunKey("/usr/sbin/prtconf", "-pv"): "", + }}, + want: virtualization{Name: "vmware", IsVirtual: true}, + }, + { + name: "windows unknown", + host: &fakeHostOS{platform: "windows", runOutputs: map[string]string{ + fakeRunKey("wmic", "computersystem", "get", "Manufacturer,Model,OEMStringArray", "/value"): "", + fakeRunKey("wmic", "bios", "get", "Manufacturer", "/value"): "", + fakeRunKey("reg", "query", `HKLM\SYSTEM\CurrentControlSet\Services`): "", + }}, + want: virtualization{Unknown: true}, + }, + { + name: "plan9 unknown", + host: &fakeHostOS{platform: "plan9"}, + want: virtualization{Unknown: true}, + }, + { + name: "unsupported physical", + host: &fakeHostOS{platform: "hurd"}, + want: virtualization{Name: "physical"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := NewSessionContext(context.Background()) + s.host = tt.host + if got := detectVirtualization(s); got != tt.want { + t.Fatalf("detectVirtualization() = %#v, want %#v", got, tt.want) + } + }) + } +} + +func TestCurrentBSDVirtualizationInputsQueryPlatformSources(t *testing.T) { + t.Parallel() + + t.Run("freebsd", func(t *testing.T) { + t.Parallel() + + got := currentFreeBSDVirtualizationInput(func(name string, args ...string) string { + switch fakeRunKey(name, args...) { + case fakeRunKey("sysctl", "-n", "security.jail.jailed"): + return "1\n" + case fakeRunKey("sysctl", "-n", "kern.vm_guest"): + return "bhyve\n" + default: + t.Fatalf("unexpected command %q %#v", name, args) + return "" + } + }) + want := freeBSDVirtualizationInput{Jailed: true, VMGuest: "bhyve"} + if got != want { + t.Fatalf("currentFreeBSDVirtualizationInput() = %#v, want %#v", got, want) + } + }) + + t.Run("openbsd", func(t *testing.T) { + t.Parallel() + + got := currentOpenBSDVirtualizationInput(func(name string, args ...string) string { + switch fakeRunKey(name, args...) { + case fakeRunKey("sysctl", "-n", "hw.product"): + return "VMM\n" + case fakeRunKey("sysctl", "-n", "hw.vendor"): + return "OpenBSD\n" + default: + t.Fatalf("unexpected command %q %#v", name, args) + return "" + } + }) + want := openBSDVirtualizationInput{ProductName: "VMM", Vendor: "OpenBSD"} + if got != want { + t.Fatalf("currentOpenBSDVirtualizationInput() = %#v, want %#v", got, want) + } + }) + + t.Run("netbsd", func(t *testing.T) { + t.Parallel() + + got := currentNetBSDVirtualizationInput(func(name string, args ...string) string { + switch fakeRunKey(name, args...) { + case fakeRunKey("/sbin/sysctl", "-n", "machdep.dmi.system-vendor"): + return "QEMU\n" + case fakeRunKey("/sbin/sysctl", "-n", "machdep.dmi.system-product"): + return "Standard PC\n" + default: + t.Fatalf("unexpected command %q %#v", name, args) + return "" + } + }) + want := dmiVirtualizationInput{Manufacturer: "QEMU", ProductName: "Standard PC"} + if got != want { + t.Fatalf("currentNetBSDVirtualizationInput() = %#v, want %#v", got, want) + } + }) +} + +func TestCurrentDMIPlatformVirtualizationInputsParseCommandOutput(t *testing.T) { + t.Parallel() + + t.Run("dragonfly", func(t *testing.T) { + t.Parallel() + + calls := map[string]bool{} + got := currentDragonFlyVirtualizationInput(func(name string, args ...string) string { + key := fakeRunKey(name, args...) + calls[key] = true + switch key { + case fakeRunKey("/usr/local/sbin/dmidecode", "-t", "system"): + return "Manufacturer: QEMU\nProduct Name: Standard PC\n" + case fakeRunKey("/usr/local/sbin/dmidecode", "-t", "bios"): + return "Vendor: SeaBIOS\n" + case fakeRunKey("kenv", "smbios.system.maker"), fakeRunKey("kenv", "smbios.system.product"), fakeRunKey("kenv", "smbios.bios.vendor"): + return "" + case fakeRunKey("pciconf", "-lv"): + return "virtio_pci0@pci0:0:4:0\n" + default: + t.Fatalf("unexpected command %q %#v", name, args) + return "" + } + }) + want := dmiVirtualizationInput{ + Manufacturer: "QEMU", + ProductName: "Standard PC", + BIOSVendor: "SeaBIOS", + PCIOutput: "virtio_pci0@pci0:0:4:0\n", + } + if got != want { + t.Fatalf("currentDragonFlyVirtualizationInput() = %#v, want %#v", got, want) + } + for _, key := range []string{ + fakeRunKey("kenv", "smbios.system.maker"), + fakeRunKey("kenv", "smbios.system.product"), + fakeRunKey("kenv", "smbios.bios.vendor"), + } { + if !calls[key] { + t.Fatalf("currentDragonFlyVirtualizationInput() did not call %q", key) + } + } + }) + + t.Run("illumos", func(t *testing.T) { + t.Parallel() + + got := currentIllumosVirtualizationInput(func(name string, args ...string) string { + switch fakeRunKey(name, args...) { + case fakeRunKey("/usr/sbin/smbios", "-t", "SMB_TYPE_SYSTEM"): + return "Manufacturer: QEMU\nProduct: Standard PC\n" + case fakeRunKey("/usr/sbin/smbios", "-t", "SMB_TYPE_BIOS"): + return "Vendor: SeaBIOS\n" + case fakeRunKey("/usr/sbin/prtconf", "-pv"): + return "pci15ad,1976\n" + default: + t.Fatalf("unexpected command %q %#v", name, args) + return "" + } + }) + want := dmiVirtualizationInput{ + Manufacturer: "QEMU", + ProductName: "Standard PC", + BIOSVendor: "SeaBIOS", + PCIOutput: "pci15ad,1976\n", + } + if got != want { + t.Fatalf("currentIllumosVirtualizationInput() = %#v, want %#v", got, want) + } + }) +} + +func TestCurrentLinuxVirtualizationInputReadsHostSignals(t *testing.T) { + host := &fakeHostOS{ + runOutputs: map[string]string{ + fakeRunKey("uname", "-r"): "6.10.0-test\n", + fakeRunKey("dmidecode"): "vboxVer_7.0.14\nvboxRev_161095\nAddress: 0xea580\n", + fakeRunKey("virt-what"): "kvm\n", + fakeRunKey("vmware", "-v"): "VMware Fusion\n", + fakeRunKey("lspci"): "00:03.0 Ethernet controller: Red Hat, Inc. Virtio network device\n", + }, + files: map[string][]byte{ + "/proc/1/cgroup": []byte("0::/docker/abcdef\n"), + "/proc/self/status": []byte("Name:\tcat\n"), + "/proc/1/environ": []byte("container=systemd-nspawn\x00PATH=/usr/bin"), + "/etc/machine-id": []byte("machine-id\n"), + "/sys/class/dmi/id/bios_vendor": []byte("SeaBIOS\n"), + "/sys/class/dmi/id/product_name": []byte("Standard PC\n"), + "/sys/class/dmi/id/sys_vendor": []byte("QEMU\n"), + }, + stats: map[string]os.FileInfo{ + "/.dockerenv": fakeFileInfo{name: ".dockerenv"}, + "/run/.containerenv": fakeFileInfo{name: ".containerenv"}, + "/proc/vz": fakeFileInfo{name: "vz", isDir: true}, + "/proc/lve/list": fakeFileInfo{name: "list"}, + }, + } + s := NewSessionContext(context.Background()) + s.host = host + + got := currentLinuxVirtualizationInput(s) + // ProcVZEntries is covered through procVZEntryCount below; this collector + // currently probes that fixed path directly rather than through host IO. + if got.CGroup != "0::/docker/abcdef\n" || !got.DockerEnv || !got.ContainerEnv || !got.ProcVZ || !got.LVEList { + t.Fatalf("currentLinuxVirtualizationInput() host markers = %#v", got) + } + if got.ProcStatus != "Name:\tcat\n" || got.ContainerRuntime != "systemd-nspawn" || got.MachineID != "machine-id" { + t.Fatalf("currentLinuxVirtualizationInput() process fields = %#v", got) + } + if runtime.GOOS != "plan9" && got.KernelVersion != "6.10.0-test" { + t.Fatalf("currentLinuxVirtualizationInput() kernel version = %q, want 6.10.0-test", got.KernelVersion) + } + if got.DMIBIOSVendor != "SeaBIOS" || got.DMIProductName != "Standard PC" || got.DMISysVendor != "QEMU" { + t.Fatalf("currentLinuxVirtualizationInput() DMI fields = %#v", got) + } + if got.DMIDecodeInfo.VirtualBoxVersion != "7.0.14" || got.DMIDecodeInfo.VirtualBoxRevision != "161095" || got.DMIDecodeInfo.VMwareVersion != "ESXi 6.5" { + t.Fatalf("currentLinuxVirtualizationInput() dmidecode info = %#v", got.DMIDecodeInfo) + } + if got.VirtWhatOutput != "kvm\n" || got.VMwareCommand != "VMware Fusion\n" || got.LspciOutput != "00:03.0 Ethernet controller: Red Hat, Inc. Virtio network device\n" { + t.Fatalf("currentLinuxVirtualizationInput() command fields = %#v", got) + } +} + +func TestProcVZEntryCountMatchesRubyResolverOffset(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "veinfo"), []byte(""), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "vestat"), []byte(""), 0o600); err != nil { + t.Fatal(err) + } + + if got := procVZEntryCount(dir); got != 4 { + t.Fatalf("procVZEntryCount() = %d, want entries plus resolver offset 4", got) + } + emptyDir := t.TempDir() + if got := procVZEntryCount(emptyDir); got != 2 { + t.Fatalf("procVZEntryCount(empty) = %d, want resolver offset 2", got) + } + if got := procVZEntryCount(filepath.Join(dir, "missing")); got != 0 { + t.Fatalf("procVZEntryCount(missing) = %d, want 0", got) + } +} + func TestDetectWindowsVirtualizationMatchesRubyResolver(t *testing.T) { tests := []struct { name string @@ -547,6 +953,15 @@ func TestWindowsHypervisorFactsMatchRubyFacts(t *testing.T) { }, }, }, + { + name: "Xen paravirtualized context by default", + input: windowsVirtualizationInput{Manufacturer: "Xen", Model: "PV domU"}, + want: map[string]any{ + "hypervisors": map[string]any{ + "xen": map[string]any{"context": "pv"}, + }, + }, + }, { name: "physical host", input: windowsVirtualizationInput{}, @@ -564,6 +979,26 @@ func TestWindowsHypervisorFactsMatchRubyFacts(t *testing.T) { } } +func TestCurrentWindowsHypervisorFactsUsesSessionHost(t *testing.T) { + host := &fakeHostOS{ + platform: "windows", + runOutputs: map[string]string{ + fakeRunKey("wmic", "computersystem", "get", "Manufacturer,Model,OEMStringArray", "/value"): "Manufacturer=Microsoft Corporation\r\nModel=Virtual Machine\r\n", + fakeRunKey("wmic", "bios", "get", "Manufacturer", "/value"): "", + fakeRunKey("powershell", "-NoProfile", "-NonInteractive", "-Command", windowsCIMScript("Win32_BIOS", "Manufacturer")): "", + fakeRunKey("reg", "query", `HKLM\SYSTEM\CurrentControlSet\Services`): "", + }, + } + s := NewSessionContext(context.Background()) + s.host = host + + got := Collection(currentWindowsHypervisorFacts(s)) + want := map[string]any{"hypervisors": map[string]any{"hyperv": map[string]any{}}} + if !mapsEqual(got, want) { + t.Fatalf("currentWindowsHypervisorFacts() = %#v, want %#v", got, want) + } +} + func TestDetectOpenBSDVirtualization(t *testing.T) { tests := []struct { name string @@ -948,6 +1383,34 @@ func TestLinuxHypervisorFactsReturnsNilOpenVZFactWhenAbsent(t *testing.T) { } } +func TestCurrentLinuxHypervisorFactsUsesSessionHost(t *testing.T) { + host := &fakeHostOS{ + platform: "linux", + stats: map[string]os.FileInfo{ + "/.dockerenv": fakeFileInfo{name: ".dockerenv"}, + }, + runOutputs: map[string]string{ + fakeRunKey("uname", "-r"): "", + fakeRunKey("dmidecode"): "", + fakeRunKey("virt-what"): "", + fakeRunKey("vmware", "-v"): "", + fakeRunKey("lspci"): "", + }, + } + s := NewSessionContext(context.Background()) + s.host = host + + got := currentLinuxHypervisorFacts(s) + want := []ResolvedFact{ + {Name: "hypervisors.docker", Value: map[string]any{}}, + {Name: "hypervisors.lxc", Value: nil}, + {Name: "hypervisors.systemd_nspawn", Value: nil}, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("currentLinuxHypervisorFacts() = %#v, want %#v", got, want) + } +} + func TestLinuxHypervisorFactsReturnsNilDockerFactWhenDockerAbsent(t *testing.T) { tests := []struct { name string diff --git a/internal/engine/xen.go b/internal/engine/xen.go index 26ce5fcc..99374e30 100644 --- a/internal/engine/xen.go +++ b/internal/engine/xen.go @@ -1,9 +1,6 @@ package engine -import ( - "runtime" - "strings" -) +import "strings" func currentXenFacts(s *Session) []ResolvedFact { vm := detectXenVM(s) @@ -27,7 +24,7 @@ func xenFacts(vm string, domains []string) []ResolvedFact { } func detectXenVM(s *Session) string { - if runtime.GOOS != "linux" { + if s.goos() != "linux" { return "" } if strings.Contains(readFileString("/proc/xen/capabilities", s.readFile), "control_d") { @@ -47,11 +44,17 @@ func detectXenVMFromSignals(evtchn, procXen, xvda1, xvda1Symlink bool) string { } func detectXenDomains(s *Session) []string { - bin := selectXenCommand(fileExists) + return detectXenDomainsWithCommand(func(path string) bool { + return fileExistsWithHost(s.host, path) + }, s.commandOutput) +} + +func detectXenDomainsWithCommand(exists func(string) bool, run commandRunner) []string { + bin := selectXenCommand(exists) if bin == "" { return nil } - out := s.commandOutput(bin, "list") + out := run(bin, "list") if out == "" { return nil } diff --git a/internal/engine/xen_test.go b/internal/engine/xen_test.go index ff0fb204..48747e9c 100644 --- a/internal/engine/xen_test.go +++ b/internal/engine/xen_test.go @@ -1,6 +1,8 @@ package engine import ( + "context" + "os" "reflect" "testing" ) @@ -76,3 +78,54 @@ func TestSelectXenCommandMatchesRubyResolver(t *testing.T) { }) } } + +func TestDetectXenDomainsWithCommandRunsSelectedToolstack(t *testing.T) { + exists := map[string]bool{ + "/usr/sbin/xl": true, + } + got := detectXenDomainsWithCommand(func(path string) bool { return exists[path] }, func(name string, args ...string) string { + if name != "/usr/sbin/xl" || !reflect.DeepEqual(args, []string{"list"}) { + t.Fatalf("run(%q, %#v), want xl list", name, args) + } + return "Name ID Mem VCPUs State Time(s)\nDomain-0 0 4096 4 r----- 100.0\nguest-web 1 2048 2 -b---- 10.0\n" + }) + want := []string{"guest-web"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("detectXenDomainsWithCommand() = %#v, want %#v", got, want) + } + + if got := detectXenDomainsWithCommand(func(string) bool { return false }, func(string, ...string) string { + t.Fatal("detectXenDomainsWithCommand ran command without Xen toolstack") + return "" + }); got != nil { + t.Fatalf("detectXenDomainsWithCommand(no command) = %#v, want nil", got) + } + + if got := detectXenDomainsWithCommand(func(path string) bool { return path == "/usr/sbin/xl" }, func(string, ...string) string { + return "" + }); got != nil { + t.Fatalf("detectXenDomainsWithCommand(no output) = %#v, want nil", got) + } +} + +func TestCurrentXenFactsUsesSessionPlatformAndHostToolstack(t *testing.T) { + s := NewSessionContext(context.Background()) + s.host = &fakeHostOS{ + platform: "linux", + files: map[string][]byte{ + "/proc/xen/capabilities": []byte("control_d\n"), + }, + stats: map[string]os.FileInfo{ + "/usr/sbin/xl": fakeFileInfo{name: "xl"}, + }, + runOutputs: map[string]string{ + fakeRunKey("/usr/sbin/xl", "list"): "Name ID Mem VCPUs State Time(s)\nDomain-0 0 4096 4 r----- 100.0\nguest-web 1 2048 2 -b---- 10.0\n", + }, + } + + got := Collection(currentXenFacts(s)) + want := map[string]any{"xen": map[string]any{"domains": []string{"guest-web"}}} + if !reflect.DeepEqual(got, want) { + t.Fatalf("currentXenFacts() = %#v, want %#v", got, want) + } +} diff --git a/internal/schema/schema_test.go b/internal/schema/schema_test.go index b2f653f1..7d717fbf 100644 --- a/internal/schema/schema_test.go +++ b/internal/schema/schema_test.go @@ -1,6 +1,9 @@ package schema import ( + "errors" + "os" + "path/filepath" "reflect" "strings" "testing" @@ -145,6 +148,32 @@ func TestFlattenTreeEscapesDottedKeys(t *testing.T) { } } +func TestSchemaPathHelpersPreserveEscapedSegments(t *testing.T) { + t.Parallel() + + if got, want := joinPath("", "eth0.100"), `eth0\.100`; got != want { + t.Fatalf("joinPath(empty) = %q, want %q", got, want) + } + if got, want := joinPath("networking.interfaces", `bond\0`), `networking.interfaces.bond\\0`; got != want { + t.Fatalf("joinPath(prefix) = %q, want %q", got, want) + } + if got := splitPath(`networking.interfaces.eth0\.100.mtu`); !reflect.DeepEqual(got, []string{"networking", "interfaces", "eth0.100", "mtu"}) { + t.Fatalf("splitPath(escaped dot) = %#v", got) + } +} + +func TestLastSegmentIndexFindsLastWildcard(t *testing.T) { + t.Parallel() + + segments := []string{"mountpoints", "*", "options", "*", "name"} + if got := lastSegmentIndex(segments, "*"); got != 3 { + t.Fatalf("lastSegmentIndex() = %d, want 3", got) + } + if got := lastSegmentIndex(segments, "missing"); got != -1 { + t.Fatalf("lastSegmentIndex(missing) = %d, want -1", got) + } +} + func TestSchemaUndocumentedPathsAcceptsOpenSubtree(t *testing.T) { s := Schema{ "system_profiler": { @@ -335,3 +364,131 @@ func TestPlatformsUseTargetProfileVocabulary(t *testing.T) { t.Fatalf("Platforms() IDs = %#v, want target profile schema IDs %#v", got, want) } } + +func TestLoadFileReadsAndValidatesSchema(t *testing.T) { + path := filepath.Join(t.TempDir(), "facts.yaml") + data := []byte(` +kernel.name: + type: string + description: Kernel name. + platforms: [linux] +`) + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatal(err) + } + + got, err := LoadFile(path) + if err != nil { + t.Fatalf("LoadFile() err = %v, want nil", err) + } + want := Schema{"kernel.name": { + Type: "string", + Description: "Kernel name.", + Platforms: []string{"linux"}, + }} + if !reflect.DeepEqual(got, want) { + t.Fatalf("LoadFile() = %#v, want %#v", got, want) + } +} + +func TestLoadFileWrapsReadAndParseErrors(t *testing.T) { + if _, err := LoadFile(filepath.Join(t.TempDir(), "missing.yaml")); err == nil || !errors.Is(err, os.ErrNotExist) || !strings.Contains(err.Error(), "read schema") { + t.Fatalf("LoadFile(missing) err = %v, want read schema wrapper around os.ErrNotExist", err) + } + + path := filepath.Join(t.TempDir(), "invalid.yaml") + if err := os.WriteFile(path, []byte("[]"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := LoadFile(path); err == nil || !strings.Contains(err.Error(), "parse schema") { + t.Fatalf("LoadFile(invalid) err = %v, want parse schema wrapper", err) + } + + semanticPath := filepath.Join(t.TempDir(), "semantic-invalid.yaml") + data := []byte(` +kernel.name: + type: scalar + description: Kernel name. + platforms: [linux] +`) + if err := os.WriteFile(semanticPath, data, 0o600); err != nil { + t.Fatal(err) + } + if _, err := LoadFile(semanticPath); err == nil || !strings.Contains(err.Error(), "parse schema") || !strings.Contains(err.Error(), "invalid type") { + t.Fatalf("LoadFile(semantic invalid) err = %v, want parse schema validation wrapper", err) + } +} + +func TestParseRejectsUnknownEntryFields(t *testing.T) { + data := []byte(` +kernel.name: + type: string + description: Kernel name. + platforms: [linux] + typo: true +`) + + _, err := Parse(data) + if err == nil || !strings.Contains(err.Error(), "typo") { + t.Fatalf("Parse() err = %v, want unknown field error mentioning typo", err) + } +} + +func TestValidateReportsEntryShapeErrors(t *testing.T) { + s := Schema{ + "": { + Type: "scalar", + Description: " ", + Platforms: []string{"linux", "linux"}, + }, + "missing.platforms": { + Type: "string", + Description: "Missing platforms.", + }, + } + + err := s.Validate() + if err == nil { + t.Fatal("Validate() err = nil, want shape errors") + } + for _, want := range []string{ + "entry has empty path", + `entry "" has invalid type "scalar"`, + `entry "" has no description`, + `entry "" lists platform "linux" twice`, + `entry "missing.platforms" lists no platforms`, + } { + if !strings.Contains(err.Error(), want) { + t.Fatalf("Validate() err = %v, want containing %q", err, want) + } + } +} + +func TestEntriesForPlatformReturnsSortedPlatformItems(t *testing.T) { + s := Schema{ + "z.fact": { + Type: "string", + Description: "Z fact.", + Platforms: []string{"linux"}, + }, + "a.fact": { + Type: "string", + Description: "A fact.", + Platforms: []string{"darwin", "linux"}, + }, + "darwin.only": { + Type: "string", + Description: "Darwin fact.", + Platforms: []string{"darwin"}, + }, + } + + got := s.EntriesForPlatform("linux") + want := []Item{ + {Path: "a.fact", Entry: s["a.fact"]}, + {Path: "z.fact", Entry: s["z.fact"]}, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("EntriesForPlatform(linux) = %#v, want %#v", got, want) + } +} diff --git a/openspec/changes/fix-linux-dhcp-lease-interface-match/proposal.md b/openspec/changes/fix-linux-dhcp-lease-interface-match/proposal.md new file mode 100644 index 00000000..eabae1ca --- /dev/null +++ b/openspec/changes/fix-linux-dhcp-lease-interface-match/proposal.md @@ -0,0 +1,19 @@ +## Why + +Linux DHCP lease discovery can assign a lease for a similarly named interface to the requested interface because `leaseMatchesInterface` uses a broad substring check against lease content. For example, a lease declaring `interface "eth0-backup"` can match `eth0`. + +## What Changes + +- Match explicit dhclient `interface "name"` declarations exactly when lease content contains interface declarations. +- Preserve the existing filename fallback for lease files that do not declare an interface in their content. +- Render YAML maps inside sequence items as flow maps so multi-key nested map values remain valid YAML. +- Emit Plan 9 uptime duration fields with the same 64-bit numeric types as other platforms. +- Clone mutable values, including pointer targets, when returning Snapshot values and defensive copies. +- Add deterministic unit coverage for the substring-collision case. + +## Impact + +- **Code**: `internal/engine/networking.go`, `internal/engine/networking_test.go`, `internal/engine/formatter.go`, `internal/engine/formatter_test.go`, `internal/engine/plan9.go`, `internal/engine/plan9_parser_test.go`, `internal/engine/snapshot.go`, `internal/engine/snapshot_test.go`. +- **Behavior**: Linux `networking.interfaces..dhcp` avoids using DHCP server data from a different interface whose name merely contains the requested interface name. +- **Behavior**: YAML output preserves nested multi-key sequence maps, Plan 9 uptime facts use the shared 64-bit duration value types, and Snapshot value/copy accessors do not share mutable state. +- **Docs/schema**: No schema update; `CHANGELOG.md` records the user-visible fixes. diff --git a/openspec/changes/fix-linux-dhcp-lease-interface-match/specs/go-port-supported-platform-facts/spec.md b/openspec/changes/fix-linux-dhcp-lease-interface-match/specs/go-port-supported-platform-facts/spec.md new file mode 100644 index 00000000..69ff02a7 --- /dev/null +++ b/openspec/changes/fix-linux-dhcp-lease-interface-match/specs/go-port-supported-platform-facts/spec.md @@ -0,0 +1,41 @@ +## MODIFIED Requirements + +### Requirement: Core fact parity + +The Go port SHALL expose Ruby-compatible structured facts for each supported platform where Ruby Facter has comparable behavior, including correct Linux DHCP lease attribution for interface-level networking facts. + +#### Scenario: Linux DHCP lease interface declarations match exactly +- **WHEN** Linux DHCP lease files are scanned for `networking.interfaces..dhcp` +- **AND** a lease file contains one or more explicit dhclient `interface "..."` declarations +- **THEN** Facts MUST use that lease only when one non-comment declaration exactly equals the requested interface name +- **AND** when a file contains multiple lease blocks, Facts MUST extract the DHCP server from the matching interface block rather than from a later block for another interface +- **AND** Facts MUST parse lease block boundaries without treating braces inside comments or quoted strings as lease terminators +- **AND** if a malformed lease block has no terminator or contains an unterminated quoted string, Facts MUST continue scanning later valid lease blocks rather than falling back to a whole-file DHCP server from another interface +- **AND** malformed interface quoted values MUST NOT count as explicit interface declarations or suppress lease filename fallback +- **AND** Facts MUST recognize explicit interface declarations even when dhclient writes multiple statements on one line +- **AND** when an exact interface declaration appears outside the lease block, Facts MUST still use the file-level DHCP server identifier for that interface +- **AND** when a file-level interface declaration matches and lease blocks omit per-block interface declarations, Facts MUST use the latest DHCP server identifier from those historical leases +- **AND** when multiple lease blocks exactly match the requested interface, the latest matching block MUST control the DHCP server value even if it omits `dhcp-server-identifier` +- **AND** commented or quoted `dhcp-server-identifier` text MUST NOT count as a DHCP server option +- **AND** explicit lease blocks for other interfaces MUST NOT override a file-level declaration for the requested interface +- **AND** Facts MUST NOT treat interface names that merely contain the requested name, such as `eth0-backup` for `eth0`, as a match +- **AND** lease filename fallback MAY still apply when a lease file has no explicit interface declaration + +#### Scenario: YAML sequence map values preserve all keys +- **WHEN** YAML output renders a sequence item whose value is a map with multiple keys +- **THEN** Facts MUST render that map as valid YAML that preserves every key/value pair in the sequence item +- **AND** the sequence item MUST NOT collapse the map into a scalar value for the first key + +#### Scenario: Plan 9 uptime duration fields use shared numeric types +- **WHEN** Plan 9 uptime facts are emitted +- **THEN** `system_uptime.days`, `system_uptime.hours`, and `system_uptime.seconds` MUST use 64-bit integer values +- **AND** those fields MUST match the numeric value types emitted by other supported platforms + +#### Scenario: Snapshot accessors clone public mutable values +- **WHEN** a Snapshot contains a mutable public fact value, including maps, slices, pointers, arrays, and exported struct fields +- **THEN** Snapshot construction, value lookup, and copy-returning accessors MUST clone mutable values and pointed-to values in that public graph +- **AND** mutating the source value or a returned value MUST NOT mutate the Snapshot +- **AND** maps with pointer-bearing keys MUST NOT expose original key pointers when a copied key remains valid for the map key type +- **AND** cyclic pointer, map, and slice values MUST preserve cycles inside the copied graph without linking back to the original graph +- **AND** distinct slices that share backing storage MUST remain distinct copies when their visible lengths differ +- **AND** unexported struct fields MAY be preserved by shallow value copy and are not part of the deep-clone guarantee diff --git a/openspec/changes/fix-linux-dhcp-lease-interface-match/tasks.md b/openspec/changes/fix-linux-dhcp-lease-interface-match/tasks.md new file mode 100644 index 00000000..19037a27 --- /dev/null +++ b/openspec/changes/fix-linux-dhcp-lease-interface-match/tasks.md @@ -0,0 +1,15 @@ +## 1. Implementation + +- [x] 1.1 Add exact matching for explicit dhclient interface declarations. +- [x] 1.2 Keep filename fallback for lease files without interface declarations. +- [x] 1.3 Cover the substring-collision regression with a focused unit test. +- [x] 1.4 Ensure the latest matching lease block controls the DHCP server value. +- [x] 1.5 Ignore commented or quoted DHCP server option text in dhclient leases. + +## 2. Verification + +- [x] 2.1 Run focused networking tests. +- [x] 2.2 Run `go test ./...`. +- [x] 2.3 Run `go vet ./...`. +- [x] 2.4 Run release gates. +- [x] 2.5 Run `openspec validate fix-linux-dhcp-lease-interface-match --strict`. diff --git a/snapshot.go b/snapshot.go index d3c2ef5a..835eb424 100644 --- a/snapshot.go +++ b/snapshot.go @@ -13,6 +13,11 @@ import ( // tree plus pure query and decode operations over it. Facts within a Snapshot // are mutually consistent; freshness is obtained by discovering again, never // by mutating. Safe for concurrent use. +// +// Returned values are defensive copies of the public fact graph: maps, slices, +// arrays, pointers, and exported struct fields are cloned. Unexported struct +// fields in custom fact values are preserved by shallow value copy and are +// outside the deep-clone guarantee. type Snapshot struct { inner *engine.Snapshot } diff --git a/tools/supportedfacts/main.go b/tools/supportedfacts/main.go index e6743629..5af2c5e4 100644 --- a/tools/supportedfacts/main.go +++ b/tools/supportedfacts/main.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/json" "fmt" + "io" "os" "path/filepath" "strings" @@ -14,21 +15,28 @@ import ( const schemaPath = factschema.DefaultPath func main() { + if code := runMain(os.Stderr); code != 0 { + os.Exit(code) + } +} + +func runMain(stderr io.Writer) int { docs, err := renderDocs(schemaPath) if err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) + fmt.Fprintln(stderr, err) + return 1 } for path, content := range docs { if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - fmt.Fprintf(os.Stderr, "create %s: %v\n", filepath.Dir(path), err) - os.Exit(1) + fmt.Fprintf(stderr, "create %s: %v\n", filepath.Dir(path), err) + return 1 } if err := os.WriteFile(path, []byte(content), 0o644); err != nil { - fmt.Fprintf(os.Stderr, "write %s: %v\n", path, err) - os.Exit(1) + fmt.Fprintf(stderr, "write %s: %v\n", path, err) + return 1 } } + return 0 } func renderDocs(schemaFile string) (map[string]string, error) { diff --git a/tools/supportedfacts/main_test.go b/tools/supportedfacts/main_test.go index 9253a254..874e4e7d 100644 --- a/tools/supportedfacts/main_test.go +++ b/tools/supportedfacts/main_test.go @@ -1,9 +1,11 @@ package main import ( + "bytes" "os" "path/filepath" "reflect" + "strings" "testing" factschema "github.com/ncode/facts/internal/schema" @@ -26,6 +28,97 @@ func TestGeneratedDocsAreCurrent(t *testing.T) { } } +func TestMainWritesGeneratedDocs(t *testing.T) { + root := repoRoot(t) + schema, err := os.ReadFile(filepath.Join(root, "docs", "schema", "facts.yaml")) + if err != nil { + t.Fatal(err) + } + t.Chdir(t.TempDir()) + if err := os.MkdirAll(filepath.Join("docs", "schema"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join("docs", "schema", "facts.yaml"), schema, 0o600); err != nil { + t.Fatal(err) + } + + main() + + if _, err := os.Stat(filepath.Join("docs", "supported-facts", "README.md")); err != nil { + t.Fatalf("generated README stat err = %v", err) + } +} + +func TestRunMainReportsRenderErrors(t *testing.T) { + t.Chdir(t.TempDir()) + var stderr bytes.Buffer + + if code := runMain(&stderr); code != 1 { + t.Fatalf("runMain() code = %d, want 1", code) + } + if stderr.Len() == 0 { + t.Fatal("stderr is empty, want schema load error") + } +} + +func TestRunMainReportsCreateErrors(t *testing.T) { + root := repoRoot(t) + schema, err := os.ReadFile(filepath.Join(root, "docs", "schema", "facts.yaml")) + if err != nil { + t.Fatal(err) + } + t.Chdir(t.TempDir()) + if err := os.MkdirAll(filepath.Join("docs", "schema"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join("docs", "schema", "facts.yaml"), schema, 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join("docs", "supported-facts"), []byte("not a directory"), 0o600); err != nil { + t.Fatal(err) + } + var stderr bytes.Buffer + + if code := runMain(&stderr); code != 1 { + t.Fatalf("runMain() code = %d, want 1", code) + } + if got := stderr.String(); !strings.Contains(got, "create docs/supported-facts:") { + t.Fatalf("stderr = %q, want create error", got) + } +} + +func TestRunMainReportsWriteErrors(t *testing.T) { + root := repoRoot(t) + schema, err := os.ReadFile(filepath.Join(root, "docs", "schema", "facts.yaml")) + if err != nil { + t.Fatal(err) + } + t.Chdir(t.TempDir()) + if err := os.MkdirAll(filepath.Join("docs", "schema"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join("docs", "schema", "facts.yaml"), schema, 0o600); err != nil { + t.Fatal(err) + } + docPaths := []string{filepath.Join("docs", "supported-facts", "README.md")} + for _, platform := range factschema.Platforms() { + docPaths = append(docPaths, filepath.Join("docs", "supported-facts", platform.ID+".md")) + } + for _, path := range docPaths { + if err := os.MkdirAll(path, 0o755); err != nil { + t.Fatal(err) + } + } + var stderr bytes.Buffer + + if code := runMain(&stderr); code != 1 { + t.Fatalf("runMain() code = %d, want 1", code) + } + if got := stderr.String(); !strings.Contains(got, "write docs/supported-facts/") { + t.Fatalf("stderr = %q, want write error", got) + } +} + func TestRenderedDocsUseSchemaPlatformVocabulary(t *testing.T) { root := repoRoot(t) docs, err := renderDocs(filepath.Join(root, "docs", "schema", "facts.yaml")) @@ -54,6 +147,13 @@ func TestExampleOutputReturnsErrorForMissingPlatform(t *testing.T) { } } +func TestRenderPlatformReturnsExampleError(t *testing.T) { + _, err := renderPlatform(factschema.Schema{}, factschema.Platform{ID: "missing-platform", Label: "Missing"}) + if err == nil { + t.Fatal("renderPlatform(missing-platform) err = nil, want missing example error") + } +} + func TestExampleOutputReturnsErrorForMalformedJSON(t *testing.T) { original := exampleJSON["linux"] exampleJSON["linux"] = "{" From 940028da778b9fe22ade588f352a08a36a17cb4e Mon Sep 17 00:00:00 2001 From: Juliano Martinez Date: Thu, 25 Jun 2026 13:00:19 +0200 Subject: [PATCH 2/2] fix Windows test portability --- cmd/facts/main_test.go | 26 +++++++++++++++----------- internal/engine/session_test.go | 12 +++++++----- tools/supportedfacts/main_test.go | 4 ++-- 3 files changed, 24 insertions(+), 18 deletions(-) diff --git a/cmd/facts/main_test.go b/cmd/facts/main_test.go index d9584af6..3c546b7e 100644 --- a/cmd/facts/main_test.go +++ b/cmd/facts/main_test.go @@ -97,21 +97,17 @@ func TestRunMainReportsOptionErrors(t *testing.T) { } func TestRunMainReportsGenericErrors(t *testing.T) { - dir := t.TempDir() - externalDir := filepath.Join(dir, "not-a-dir") - if err := os.WriteFile(externalDir, []byte("site=lab\n"), 0o600); err != nil { - t.Fatal(err) - } - var stdout, stderr bytes.Buffer + writeErr := errors.New("stdout closed") + var stderr bytes.Buffer - if code := runMain(&stdout, &stderr, []string{"--external-dir", externalDir, "--list-cache-groups"}); code != 1 { + if code := runMain(errorWriter{err: writeErr}, &stderr, []string{"--version"}); code != 1 { t.Fatalf("runMain() code = %d, want 1", code) } - if stdout.Len() != 0 { - t.Fatalf("stdout = %q, want empty", stdout.String()) - } if got := stderr.String(); got == "" || strings.Contains(got, "Facts::OptionsValidator") { - t.Fatalf("stderr = %q, want generic config error", got) + t.Fatalf("stderr = %q, want generic app error", got) + } + if got := stderr.String(); !strings.Contains(got, writeErr.Error()) { + t.Fatalf("stderr = %q, want %q", got, writeErr) } } @@ -244,6 +240,14 @@ func TestFactsCommand_invalidConcatenatedShortFlagReportsOptionsValidatorError(t } } +type errorWriter struct { + err error +} + +func (w errorWriter) Write([]byte) (int, error) { + return 0, w.err +} + func buildFactsCommand(t *testing.T) string { t.Helper() commandBuild.once.Do(func() { diff --git a/internal/engine/session_test.go b/internal/engine/session_test.go index a2f0af53..1d842464 100644 --- a/internal/engine/session_test.go +++ b/internal/engine/session_test.go @@ -474,11 +474,13 @@ func TestCoreCommandFileExecutableChecksRegularExecutableFiles(t *testing.T) { t.Fatal(err) } - if !coreCommandFileExecutable(executable, "linux") { - t.Fatalf("coreCommandFileExecutable(%q, linux) = false, want true", executable) - } - if coreCommandFileExecutable(plain, "linux") { - t.Fatalf("coreCommandFileExecutable(%q, linux) = true, want false", plain) + if runtime.GOOS != "windows" { + if !coreCommandFileExecutable(executable, "linux") { + t.Fatalf("coreCommandFileExecutable(%q, linux) = false, want true", executable) + } + if coreCommandFileExecutable(plain, "linux") { + t.Fatalf("coreCommandFileExecutable(%q, linux) = true, want false", plain) + } } if !coreCommandFileExecutable(plain, "windows") { t.Fatalf("coreCommandFileExecutable(%q, windows) = false, want regular files accepted", plain) diff --git a/tools/supportedfacts/main_test.go b/tools/supportedfacts/main_test.go index 874e4e7d..a0f03c92 100644 --- a/tools/supportedfacts/main_test.go +++ b/tools/supportedfacts/main_test.go @@ -82,7 +82,7 @@ func TestRunMainReportsCreateErrors(t *testing.T) { if code := runMain(&stderr); code != 1 { t.Fatalf("runMain() code = %d, want 1", code) } - if got := stderr.String(); !strings.Contains(got, "create docs/supported-facts:") { + if got := filepath.ToSlash(stderr.String()); !strings.Contains(got, "create docs/supported-facts:") { t.Fatalf("stderr = %q, want create error", got) } } @@ -114,7 +114,7 @@ func TestRunMainReportsWriteErrors(t *testing.T) { if code := runMain(&stderr); code != 1 { t.Fatalf("runMain() code = %d, want 1", code) } - if got := stderr.String(); !strings.Contains(got, "write docs/supported-facts/") { + if got := filepath.ToSlash(stderr.String()); !strings.Contains(got, "write docs/supported-facts/") { t.Fatalf("stderr = %q, want write error", got) } }