diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c50dbec..ecaf1a6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,19 @@ a native `rc` release gate, and CI cross-compile coverage. Plan 9 release artifacts remain a separate promotion decision. +### Changed + +- Internal, behavior-preserving refactor of the discovery engine: fact + resolvers now reach the host only through the run-scoped Session seam, with + an automated check freezing that boundary. Category assembly reads platform + identity and environment through the Session so windows/plan9 paths are + exercisable with a fake host; the host-virtualization signals are gathered + once per discovery instead of up to three times; cloud metadata transport is + consolidated in one helper; and dead/test-only entrances (`detector.go`, the + `query.go` Select delegates, the `LoadExternalFacts` facade, `filehelper.go`, + the version fast path's crutch exports) are removed. No public API, CLI flag, + output, input-source precedence, diagnostic, or cache behavior changes. + ### Fixed - The DragonFly `disks`/`partitions` probe no longer reports empty memory-disk diff --git a/docs/adr/0010-core-facts-split-by-category.md b/docs/adr/0010-core-facts-split-by-category.md index 1d4e5681..f38851da 100644 --- a/docs/adr/0010-core-facts-split-by-category.md +++ b/docs/adr/0010-core-facts-split-by-category.md @@ -11,7 +11,7 @@ We adopt **per-fact-category resolver modules** as the standard organization for Two constraints that must not be re-crossed: - **Platform splits within a category must not use Go's reserved GOOS suffixes.** A file named `networking_windows.go`, `*_linux.go`, `*_darwin.go`, or `*_freebsd.go` is given an *implicit* build constraint by the Go toolchain and compiles only on that OS. The Windows networking *parsing* logic is deliberately tested on Linux/macOS CI through the `goos`-string parameter seam (AGENTS.md: "platform logic should be tested through fixtures or injected probes"); a GOOS-suffixed file would exclude that logic from other platforms' builds and tests, silently breaking the seam. When a category file grows unwieldy and a hybrid by-platform split is warranted, use a non-reserved name (e.g. `networking_msft.go`), never a GOOS suffix. Genuine syscall-bound code that *should* be GOOS-constrained already lives in its own tagged files (`statfs_linux.go`); this convention is about the cross-platform resolver/parse logic. -- **This split does not change function signatures.** Collapsing the `commandRunner`/`fileReader` parameters into `Session` methods is a separate, already-deferred follow-on (recorded in the 2026-06-17 deepen-engine-internals design). The category split moves functions and adds per-category assembly funcs; it does not rewrite their parameters, so the two changes stay independently reviewable. +- **This split does not change function signatures.** Collapsing the `commandRunner`/`fileReader` parameters into `Session` methods is a separate, already-deferred follow-on (recorded in the 2026-06-17 deepen-engine-internals design). The category split moves functions and adds per-category assembly funcs; it does not rewrite their parameters, so the two changes stay independently reviewable. **Done (deepen-engine-seams, 2026-07):** the resolver `commandRunner`/`fileReader` threading and the `FromRoot`/`WithHost`/`WithReader`/`ForPlatform` variant families are collapsed onto the Session host seam, which is now the only resolver host-I/O path — structurally enforced by `TestNoRawHostIOInResolvers`. Pure `parse*`/goos-string-parameter signatures are unchanged, as this ADR requires; the recorded accepted leaks (`exec.LookPath` in the Linux distro probe, identity's uid/gid syscalls, the uptime clock, `net.Interfaces`) remain injectable parameters. That change also resolved the archived 2026-06-17 open question that had marked the `LoadExternalFacts` facade for deletion. ## Considered Options diff --git a/internal/app/app.go b/internal/app/app.go index ade0b5fb..1d1fb2c8 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -297,13 +297,11 @@ func runQuery(stdout, stderr io.Writer, args []string) error { disabledFacts = map[string]bool{} } if !*noBlock { - // The fast path must honor every disable source so a disabled - // facterversion query falls through to normal resolution (and - // disable-beats-query), mirroring the engine's union. - fastPathEntries := append([]string(nil), configOptions.Disabled...) - fastPathEntries = append(fastPathEntries, disableEntries...) - fastPathEntries = append(fastPathEntries, engine.EnvironmentDisabledFacts(os.Environ())...) - disabledFactsForFastPath = engine.DisabledFactsForFiltering(fastPathEntries, configOptions.FactGroups) + // The fast path honors every disable source so a disabled facterversion + // query falls through to normal resolution (and disable-beats-query). It + // derives its set from the same engine union discovery planning uses, so + // the two can never disagree on whether facterversion is disabled. + disabledFactsForFastPath = engine.DisabledUnion(configOptions, disableEntries, os.Environ()) } mergeDottedFacts := configOptions.ForceDotResolution || *forceDotResolution logLevel := firstNonEmpty(flags.Lookup("log-level").Value.String(), flags.Lookup("l").Value.String(), configOptions.LogLevel) @@ -496,19 +494,14 @@ func canUseVersionQueryFastPath(queries, externalDirs []string, disabledFacts ma func writeVersionQuery(stdout io.Writer, jsonOutput, yamlOutput, hoconOutput bool) error { facts := []engine.ResolvedFact{{Name: "facterversion", Value: engine.Version, UserQuery: "facterversion"}} - var ( - out string - err error - ) - if jsonOutput { - out, err = engine.FormatJSON(facts) - } else if yamlOutput { - out = engine.FormatYAML(facts) - } else if hoconOutput { - out = engine.FormatHOCON(facts) - } else { - out = engine.FormatLegacy(facts) - } + // The fast path carries only the three format booleans: it deliberately + // ignores --color and --force-dot-resolution, so Colorize/IncludeTypedDotted + // stay false and formatter selection reuses the engine's own precedence. + out, err := engine.BuildFormatter(engine.FormatOptions{ + JSON: jsonOutput, + YAML: yamlOutput, + HOCON: hoconOutput, + }).Format(facts) if err != nil { return err } diff --git a/internal/app/app_test.go b/internal/app/app_test.go index faf07f0c..5b4d3888 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -58,6 +58,38 @@ func TestRun_shortVersion(t *testing.T) { } } +// Byte pins for the version fast path's formatter selection. These lock the +// exact stdout before writeVersionQuery routes through engine.BuildFormatter, +// so the reroute is proven byte-identical. +func TestRun_facterversionJSONFastPathBytes(t *testing.T) { + var stdout, stderr bytes.Buffer + + if err := Run(&stdout, &stderr, []string{"--json", "facterversion"}); err != nil { + t.Fatal(err) + } + want := "{\n \"facterversion\": \"" + engine.Version + "\"\n}\n" + if got := stdout.String(); got != want { + t.Fatalf("stdout = %q, want %q", got, want) + } + if stderr.Len() != 0 { + t.Fatalf("stderr = %q, want empty", stderr.String()) + } +} + +func TestRun_facterversionHOCONFastPathBytes(t *testing.T) { + var stdout, stderr bytes.Buffer + + if err := Run(&stdout, &stderr, []string{"--hocon", "facterversion"}); 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 TestRun_facterversionQueryAllowsExternalOverride(t *testing.T) { dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, "version.txt"), []byte("facterversion=external\n"), 0o600); err != nil { diff --git a/internal/app/disable_test.go b/internal/app/disable_test.go index ada6d5e6..7f926ce9 100644 --- a/internal/app/disable_test.go +++ b/internal/app/disable_test.go @@ -76,3 +76,37 @@ func TestRun_noBlockClearsDisableOption(t *testing.T) { t.Fatalf("stdout = %q, want --no-block to clear --disable and resolve alpha", stdout.String()) } } + +// The version fast path must fall through to full discovery when facterversion +// is disabled by an ambient source, so a disabled version query behaves like +// any other disabled fact. Pinned in the bare legacy format (the only format +// whose disabled-single-query stdout is literally empty) before the fast path +// consumes the engine disabled-union, so the reroute cannot alter fall-through. +func TestRun_facterversionDisabledByEnvFallsThrough(t *testing.T) { + t.Setenv("FACTS_DISABLE", "facterversion") + var stdout, stderr bytes.Buffer + + if err := Run(&stdout, &stderr, []string{"facterversion"}); err != nil { + t.Fatal(err) + } + if stdout.Len() != 0 { + t.Fatalf("stdout = %q, want empty (disabled facterversion falls through)", stdout.String()) + } + if got, want := stderr.String(), "WARN Facts - fact \"facterversion\" is disabled by FACTS_DISABLE\n"; got != want { + t.Fatalf("stderr = %q, want %q", got, want) + } +} + +func TestRun_facterversionDisabledByFlagFallsThrough(t *testing.T) { + var stdout, stderr bytes.Buffer + + if err := Run(&stdout, &stderr, []string{"--disable", "facterversion", "facterversion"}); err != nil { + t.Fatal(err) + } + if stdout.Len() != 0 { + t.Fatalf("stdout = %q, want empty (disabled facterversion falls through)", stdout.String()) + } + if stderr.Len() != 0 { + t.Fatalf("stderr = %q, want no diagnostic for --disable", stderr.String()) + } +} diff --git a/internal/engine/augeas.go b/internal/engine/augeas.go index d25ba09d..6d02be23 100644 --- a/internal/engine/augeas.go +++ b/internal/engine/augeas.go @@ -5,15 +5,15 @@ import "regexp" var augeasVersionPattern = regexp.MustCompile(`\b(\d+\.\d+(?:\.\d+)?)\b`) func probeAugeasVersion(s *Session) string { - return currentAugeasVersion(fileExists, s.commandOutput) + return currentAugeasVersion(s) } -func currentAugeasVersion(exists func(string) bool, run commandRunner) string { +func currentAugeasVersion(s *Session) string { augparse := "augparse" - if exists("/opt/puppetlabs/puppet/bin/augparse") { + if fileExists(s.host, "/opt/puppetlabs/puppet/bin/augparse") { augparse = "/opt/puppetlabs/puppet/bin/augparse" } - return parseAugeasVersion(run(augparse, "--version")) + return parseAugeasVersion(s.commandOutput(augparse, "--version")) } func parseAugeasVersion(out string) string { diff --git a/internal/engine/augeas_test.go b/internal/engine/augeas_test.go index 068c3fe0..3bfe8cec 100644 --- a/internal/engine/augeas_test.go +++ b/internal/engine/augeas_test.go @@ -1,6 +1,7 @@ package engine import ( + "os" "reflect" "testing" ) @@ -34,45 +35,45 @@ func TestAugeasVersionFacts_omittedWhenAugparseUnavailable(t *testing.T) { } func TestCurrentAugeasVersion_prefersPuppetAgentAugparse(t *testing.T) { - var gotName string - var gotArgs []string - - got := currentAugeasVersion( - func(path string) bool { return path == "/opt/puppetlabs/puppet/bin/augparse" }, - func(name string, args ...string) string { - gotName = name - gotArgs = args - return "augparse 1.12.0 " + host := &fakeHostOS{ + emptyRunDefault: true, + stats: map[string]os.FileInfo{"/opt/puppetlabs/puppet/bin/augparse": fakeFileInfo{name: "augparse"}}, + runOutputs: map[string]string{ + fakeRunKey("/opt/puppetlabs/puppet/bin/augparse", "--version"): "augparse 1.12.0 ", }, - ) + } + s := NewSession() + s.host = host + + got := currentAugeasVersion(s) if got != "1.12.0" { t.Fatalf("currentAugeasVersion() = %q, want 1.12.0", got) } - if gotName != "/opt/puppetlabs/puppet/bin/augparse" { - t.Fatalf("augparse command = %q, want puppet-agent augparse", gotName) - } - if !reflect.DeepEqual(gotArgs, []string{"--version"}) { - t.Fatalf("augparse args = %#v, want --version", gotArgs) + want := []fakeHostRunCall{{name: "/opt/puppetlabs/puppet/bin/augparse", args: []string{"--version"}}} + if !reflect.DeepEqual(host.runCalls, want) { + t.Fatalf("commands = %#v, want puppet-agent augparse --version", host.runCalls) } } func TestCurrentAugeasVersion_usesPathAugparseWhenPuppetAgentAugparseIsAbsent(t *testing.T) { - var gotName string - - got := currentAugeasVersion( - func(string) bool { return false }, - func(name string, args ...string) string { - gotName = name - return "augparse 1.14.1 " + host := &fakeHostOS{ + emptyRunDefault: true, + runOutputs: map[string]string{ + fakeRunKey("augparse", "--version"): "augparse 1.14.1 ", }, - ) + } + s := NewSession() + s.host = host + + got := currentAugeasVersion(s) if got != "1.14.1" { t.Fatalf("currentAugeasVersion() = %q, want 1.14.1", got) } - if gotName != "augparse" { - t.Fatalf("augparse command = %q, want path augparse", gotName) + want := []fakeHostRunCall{{name: "augparse", args: []string{"--version"}}} + if !reflect.DeepEqual(host.runCalls, want) { + t.Fatalf("commands = %#v, want path augparse --version", host.runCalls) } } diff --git a/internal/engine/az.go b/internal/engine/az.go index 789397af..1e9c1020 100644 --- a/internal/engine/az.go +++ b/internal/engine/az.go @@ -3,7 +3,6 @@ package engine import ( "context" "encoding/json" - "io" "net/http" "strings" "time" @@ -13,7 +12,6 @@ const ( azureMetadataBaseURL = "http://169.254.169.254" azureAPIVersion = "2020-09-01" azureRequestTimeout = 5 * time.Second - azureMaxBodyBytes = 1 << 20 ) type azureClient struct { @@ -23,12 +21,7 @@ type azureClient struct { func newAzureClient(baseURL string, httpClient *http.Client) *azureClient { if httpClient == nil { - httpClient = &http.Client{ - Timeout: azureRequestTimeout, - Transport: &http.Transport{ - Proxy: nil, - }, - } + httpClient = newMetadataHTTPClient(azureRequestTimeout) } return &azureClient{baseURL: strings.TrimRight(baseURL, "/"), httpClient: httpClient} } @@ -52,25 +45,14 @@ func azureHypervisor(name string) bool { } func (ac *azureClient) metadata(ctx context.Context) map[string]any { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, ac.baseURL+"/metadata/instance?api-version="+azureAPIVersion, nil) - if err != nil { - return map[string]any{} - } - req.Header.Set("Metadata", "true") - resp, err := ac.httpClient.Do(req) - if err != nil { - return map[string]any{} - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return map[string]any{} - } - data, err := io.ReadAll(io.LimitReader(resp.Body, azureMaxBodyBytes)) - if err != nil { + body, _, ok := fetchMetadata(ctx, ac.httpClient, http.MethodGet, ac.baseURL+"/metadata/instance?api-version="+azureAPIVersion, map[string]string{ + "Metadata": "true", + }) + if !ok { return map[string]any{} } metadata := map[string]any{} - if err := json.Unmarshal(data, &metadata); err != nil { + if err := json.Unmarshal([]byte(body), &metadata); err != nil { return map[string]any{} } return metadata diff --git a/internal/engine/config_test.go b/internal/engine/config_test.go index 2a0fe909..38d83c24 100644 --- a/internal/engine/config_test.go +++ b/internal/engine/config_test.go @@ -1009,7 +1009,7 @@ func TestGroupTTLSecondsRejectsMalformedTTLTokens(t *testing.T) { } func TestDisabledFactsForFiltering_retiredLegacyGroupBlocksNothing(t *testing.T) { - blocked := DisabledFactsForFiltering([]string{"legacy"}, nil) + blocked := DisabledFactsWithGroups([]string{"legacy"}, nil) facts := []ResolvedFact{ {Name: "os.name", Value: "Darwin"}, @@ -1327,3 +1327,37 @@ func TestFirstConfigValueReturnsFirstNonEmptyValue(t *testing.T) { t.Fatalf("firstConfigValue(all empty) = %q, want empty", got) } } + +func TestDisabledUnion_mergesConfigFlagAndEnvSources(t *testing.T) { + config := Config{Disabled: []string{"os"}} + got := DisabledUnion(config, []string{"processors"}, []string{"FACTS_DISABLE=networking"}) + for _, name := range []string{"os", "processors", "networking"} { + if !got[name] { + t.Fatalf("DisabledUnion() missing %q from the union; got %#v", name, got) + } + } +} + +func TestDisabledUnion_nilEnvironExcludesEnvSource(t *testing.T) { + config := Config{Disabled: []string{"os"}} + got := DisabledUnion(config, nil, nil) + if !got["os"] { + t.Fatalf("DisabledUnion() dropped config.Disabled; got %#v", got) + } + if got["networking"] { + t.Fatalf("DisabledUnion(nil environ) included an env source; got %#v", got) + } +} + +func TestDisabledUnion_expandsGroups(t *testing.T) { + config := Config{ + Disabled: []string{"web"}, + FactGroups: []FactGroup{{Name: "web", Facts: []string{"networking", "os.name"}}}, + } + got := DisabledUnion(config, nil, nil) + for _, name := range []string{"networking", "os.name"} { + if !got[name] { + t.Fatalf("DisabledUnion() did not expand group member %q; got %#v", name, got) + } + } +} diff --git a/internal/engine/core.go b/internal/engine/core.go index 58d2249e..fc0319c8 100644 --- a/internal/engine/core.go +++ b/internal/engine/core.go @@ -4,7 +4,6 @@ import ( "math" "os" "path/filepath" - "runtime" "sort" "strconv" "strings" @@ -58,13 +57,14 @@ func disabledFingerprint(disabled map[string]bool) string { // the composition order does not affect the resolved output; it mirrors the // historical assembly order for reviewability. func buildCoreFacts(s *Session, disabled map[string]bool) []ResolvedFact { + goos := s.goos() virtualization := detectVirtualization(s) virtualFact, isVirtualFact := virtualizationFactValues(virtualization) dmi := s.cachedDMI() facts := []ResolvedFact{ {Name: "facterversion", Value: Version}, {Name: "is_virtual", Value: isVirtualFact}, - {Name: "path", Value: currentPathEntries(runtime.GOOS, os.Getenv)}, + {Name: "path", Value: currentPathEntries(goos, s.getenv)}, {Name: "virtual", Value: virtualFact}, } // gate skips a single-output category whose only top-level fact name is @@ -100,13 +100,13 @@ func buildCoreFacts(s *Session, disabled map[string]bool) []ResolvedFact { facts = append(facts, currentWindowsHypervisorFacts(s)...) facts = append(facts, azureFacts(s.Context(), newAzureClient(azureMetadataBaseURL, nil), virtualization)...) facts = append(facts, ec2Facts(s, newEC2Client(ec2MetadataBaseURL, nil), virtualization)...) - facts = append(facts, platformGCEFacts(s.Context(), runtime.GOOS, virtualization, dmiBIOSVendor(dmi), newGCEClient(gceMetadataBaseURL, nil))...) + facts = append(facts, platformGCEFacts(s.Context(), goos, virtualization, dmiBIOSVendor(dmi), newGCEClient(gceMetadataBaseURL, nil))...) return facts } func currentPathEntries(goos string, getenv func(string) string) []string { key := "PATH" - separator := string(os.PathListSeparator) + separator := corePathListSeparator(goos) if goos == "plan9" { key = "path" separator = "\x00" @@ -155,11 +155,7 @@ func readOptionalText(path string, readFile fileReader) any { return strings.TrimSpace(string(data)) } -func readFileString(path string, readFiles ...fileReader) string { - readFile := osHost{}.readFile - if len(readFiles) > 0 && readFiles[0] != nil { - readFile = readFiles[0] - } +func readFileString(path string, readFile fileReader) string { data, err := readFile(path) if err != nil { return "" @@ -167,11 +163,7 @@ func readFileString(path string, readFiles ...fileReader) string { return string(data) } -func isSymlink(path string, lstats ...func(string) (os.FileInfo, error)) bool { - lstat := osHost{}.lstat - if len(lstats) > 0 && lstats[0] != nil { - lstat = lstats[0] - } +func isSymlink(path string, lstat func(string) (os.FileInfo, error)) bool { info, err := lstat(path) if err != nil { return false @@ -179,11 +171,7 @@ func isSymlink(path string, lstats ...func(string) (os.FileInfo, error)) bool { return info.Mode()&os.ModeSymlink != 0 } -func readSysfsString(root, device, name string, readFiles ...fileReader) string { - readFile := osHost{}.readFile - if len(readFiles) > 0 && readFiles[0] != nil { - readFile = readFiles[0] - } +func readSysfsString(root, device, name string, readFile fileReader) string { data, err := readFile(filepath.Join(root, device, name)) if err != nil { return "" @@ -191,11 +179,7 @@ func readSysfsString(root, device, name string, readFiles ...fileReader) string return strings.TrimSpace(string(data)) } -func readDMIString(root, name string, readFiles ...fileReader) string { - readFile := osHost{}.readFile - if len(readFiles) > 0 && readFiles[0] != nil { - readFile = readFiles[0] - } +func readDMIString(root, name string, readFile fileReader) string { data, err := readFile(filepath.Join(root, name)) if err != nil { return "" diff --git a/internal/engine/core_gating_test.go b/internal/engine/core_gating_test.go index 4e6440a8..384b5b1a 100644 --- a/internal/engine/core_gating_test.go +++ b/internal/engine/core_gating_test.go @@ -2,7 +2,6 @@ package engine import ( "context" - "runtime" "strings" "testing" ) @@ -113,8 +112,8 @@ func TestBuildCoreFacts_keptCategoriesUnaffectedByGate(t *testing.T) { // on s.goos() take a path reading a distinctive marker file; the readFile/run // spies then record whether each resolver actually ran. A Linux fake is used // regardless of the test host so the s.goos()-keyed categories -// (networking/processors/memory/xen) are observable on darwin, linux, and -// Windows CI alike. +// (networking/processors/memory/xen/fips_enabled) are observable on darwin, +// linux, and Windows CI alike. func newGatingProbeHost() *fakeHostOS { return &fakeHostOS{platform: "linux"} } @@ -151,10 +150,8 @@ func hostReadFileMatching(h *fakeHostOS, substr string) bool { // resolution-gating from output filtering (ADR-0015). func TestBuildCoreFacts_resolutionGatingSkipsProbeWork(t *testing.T) { markers := []struct { - category string - probed func(h *fakeHostOS) bool - skip func() bool - skipReason string + category string + probed func(h *fakeHostOS) bool }{ { category: "networking", @@ -179,23 +176,14 @@ func TestBuildCoreFacts_resolutionGatingSkipsProbeWork(t *testing.T) { probed: func(h *fakeHostOS) bool { return hostRanCommand(h, "augparse") }, }, { - // ssh reads ssh_host_*_key.pub via readFile on every non-Windows host; - // the path set keys off runtime.GOOS, which the fake cannot override, - // so the Windows path (programdata + a different seam) is skipped. - category: "ssh", - probed: func(h *fakeHostOS) bool { return hostReadFileMatching(h, "ssh_host_rsa_key.pub") }, - skip: func() bool { return runtime.GOOS == "windows" }, - skipReason: "ssh host-key paths key off runtime.GOOS; the Windows path uses a different seam", + // ssh reads ssh_host_*_key.pub via readFile; the path set keys off + // s.goos(), so the fake Linux host drives the unix path on any test host. + category: "ssh", + probed: func(h *fakeHostOS) bool { return hostReadFileMatching(h, "ssh_host_rsa_key.pub") }, }, { - // fips_enabled reads /proc/sys/crypto/fips_enabled only when - // runtime.GOOS is linux (Windows uses a reg query); elsewhere the - // resolver returns nil before any host call, so there is nothing to - // observe on this host. - category: "fips_enabled", - probed: func(h *fakeHostOS) bool { return hostReadFileMatching(h, "/proc/sys/crypto/fips_enabled") }, - skip: func() bool { return runtime.GOOS != "linux" }, - skipReason: "fips_enabled probes only on linux/windows; runtime.GOOS gates the probe away here", + category: "fips_enabled", + probed: func(h *fakeHostOS) bool { return hostReadFileMatching(h, "/proc/sys/crypto/fips_enabled") }, }, // timezone is intentionally omitted: on every non-Windows host it derives // the zone from Go's time.Now().Format("MST") with no host probe at all, @@ -205,10 +193,6 @@ func TestBuildCoreFacts_resolutionGatingSkipsProbeWork(t *testing.T) { for _, m := range markers { t.Run(m.category, func(t *testing.T) { - if m.skip != nil && m.skip() { - t.Skipf("%s probe unobservable on %s: %s", m.category, runtime.GOOS, m.skipReason) - } - enabled := newGatingProbeHost() buildCoreFacts(gatingProbeSession(enabled), nil) if !m.probed(enabled) { diff --git a/internal/engine/detector.go b/internal/engine/detector.go deleted file mode 100644 index ee6e234b..00000000 --- a/internal/engine/detector.go +++ /dev/null @@ -1,115 +0,0 @@ -package engine - -import ( - "errors" - "fmt" - "log/slog" - "strings" -) - -// ErrUnknownOS reports that a Ruby host_os value does not map to a Facter OS identifier. -var ErrUnknownOS = errors.New("unknown os") - -// DetectOSIdentifier maps Ruby's RbConfig host_os value to Facter's OS identifier. -func DetectOSIdentifier(hostOS, linuxDistroID string) (string, error) { - hostOS = strings.ToLower(strings.TrimSpace(hostOS)) - switch { - case strings.Contains(hostOS, "darwin"): - return "macosx", nil - case strings.Contains(hostOS, "mingw") || strings.Contains(hostOS, "mswin") || strings.Contains(hostOS, "windows"): - return "windows", nil - case strings.Contains(hostOS, "linux"): - linuxDistroID = strings.TrimSpace(linuxDistroID) - if linuxDistroID != "" { - return linuxDistroID, nil - } - return "linux", nil - case strings.Contains(hostOS, "freebsd"): - return "freebsd", nil - case strings.Contains(hostOS, "openbsd"): - return "openbsd", nil - case strings.Contains(hostOS, "netbsd"): - return "netbsd", nil - case strings.Contains(hostOS, "dragonfly"): - return "dragonfly", nil - case strings.Contains(hostOS, "illumos") || strings.Contains(hostOS, "sunos") || strings.Contains(hostOS, "solaris"): - return "illumos", nil - default: - return "", fmt.Errorf("%w: %q", ErrUnknownOS, hostOS) - } -} - -// ConstructOSHierarchy returns the Ruby-compatible OS inheritance path for searchedOS. -func ConstructOSHierarchy(hierarchy []any, searchedOS string) []string { - if searchedOS == "" { - return []string{} - } - searched := capitalizeOSName(searchedOS) - if hierarchy == nil { - return []string{searched} - } - if path, ok := searchOSHierarchy(hierarchy, searched, nil); ok { - return path - } - return []string{} -} - -// DetectOSHierarchy returns Ruby's detected OS hierarchy for an identifier. -// Fallback diagnostics are emitted to log; pass a discard logger to ignore them. -func DetectOSHierarchy(hierarchy []any, identifier, family string, log *slog.Logger) []string { - if log == nil { - log = slog.New(slog.DiscardHandler) - } - resolved := ConstructOSHierarchy(hierarchy, identifier) - if len(resolved) > 0 { - return resolved - } - - log.Debug("Could not detect hierarchy using os identifier: " + identifier + " , trying with family") - for candidate := range strings.FieldsSeq(family) { - resolved = ConstructOSHierarchy(hierarchy, candidate) - if len(resolved) > 0 { - return resolved - } - } - - log.Debug("Could not detect hierarchy using family " + family + ", falling back to Linux") - return ConstructOSHierarchy(hierarchy, "linux") -} - -func searchOSHierarchy(nodes []any, searched string, path []string) ([]string, bool) { - for _, node := range nodes { - switch n := node.(type) { - case string: - if n == searched { - return append(append([]string(nil), path...), n), true - } - case map[string]any: - for key, value := range n { - nextPath := append(append([]string(nil), path...), key) - if key == searched { - return nextPath, true - } - children, ok := value.([]any) - if !ok { - continue - } - if found, ok := searchOSHierarchy(children, searched, nextPath); ok { - return found, true - } - } - } - } - return nil, false -} - -func capitalizeOSName(name string) string { - name = strings.TrimSpace(name) - if name == "" { - return "" - } - if name[1:] != strings.ToLower(name[1:]) { - return strings.ToUpper(name[:1]) + name[1:] - } - return strings.ToUpper(name[:1]) + strings.ToLower(name[1:]) -} diff --git a/internal/engine/detector_test.go b/internal/engine/detector_test.go deleted file mode 100644 index 2f420494..00000000 --- a/internal/engine/detector_test.go +++ /dev/null @@ -1,148 +0,0 @@ -package engine - -import ( - "errors" - "reflect" - "testing" -) - -func TestConstructOSHierarchy_matchesSupportedFixture(t *testing.T) { - hierarchy := []any{ - map[string]any{"Linux": []any{ - map[string]any{"Debian": []any{"Elementary", "Ubuntu", "Raspbian"}}, - map[string]any{"El": []any{"Fedora", "Amzn", "Centos"}}, - map[string]any{"Sles": []any{"Opensuse"}}, - }}, - "Macosx", - "Windows", - } - - tests := []struct { - name string - searched string - want []string - }{ - {name: "ubuntu", searched: "ubuntu", want: []string{"Linux", "Debian", "Ubuntu"}}, - {name: "debian", searched: "debian", want: []string{"Linux", "Debian"}}, - {name: "linux", searched: "linux", want: []string{"Linux"}}, - {name: "unknown", searched: "my_os", want: []string{}}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := ConstructOSHierarchy(hierarchy, tt.searched) - if !reflect.DeepEqual(got, tt.want) { - t.Fatalf("ConstructOSHierarchy(%q) = %#v, want %#v", tt.searched, got, tt.want) - } - }) - } -} - -func TestConstructOSHierarchy_returnsEmptyForNilSearchedOS(t *testing.T) { - if got := ConstructOSHierarchy([]any{"Linux"}, ""); len(got) != 0 { - t.Fatalf("ConstructOSHierarchy(empty) = %#v, want empty", got) - } -} - -func TestConstructOSHierarchy_fallsBackWhenHierarchyMissing(t *testing.T) { - got := ConstructOSHierarchy(nil, "myos") - want := []string{"Myos"} - if !reflect.DeepEqual(got, want) { - t.Fatalf("ConstructOSHierarchy(nil, myos) = %#v, want %#v", got, want) - } -} - -func TestDetectOSHierarchyFallsBackToLinuxWhenDistroAndFamilyAreUnknownLikeRubyDetector(t *testing.T) { - hierarchy := []any{ - map[string]any{"Linux": []any{ - map[string]any{"Debian": []any{"Ubuntu"}}, - }}, - "Windows", - } - debugMessages := []string{} - logger := captureLogger(&debugMessages, nil, nil) - - got := DetectOSHierarchy(hierarchy, "my_linux_distro", "", logger) - want := []string{"Linux"} - if !reflect.DeepEqual(got, want) { - t.Fatalf("DetectOSHierarchy() = %#v, want %#v", got, want) - } - - wantMessages := []string{ - "Could not detect hierarchy using os identifier: my_linux_distro , trying with family", - "Could not detect hierarchy using family , falling back to Linux", - } - if !reflect.DeepEqual(debugMessages, wantMessages) { - t.Fatalf("debug messages = %#v, want %#v", debugMessages, wantMessages) - } -} - -func TestDetectOSHierarchyUsesFirstKnownFamilyLikeRubyDetector(t *testing.T) { - hierarchy := []any{ - map[string]any{"Linux": []any{ - map[string]any{"El": []any{"Centos", "Fedora"}}, - }}, - } - debugMessages := []string{} - logger := captureLogger(&debugMessages, nil, nil) - - got := DetectOSHierarchy(hierarchy, "my_linux_distro", "Rhel centos fedora", logger) - want := []string{"Linux", "El", "Centos"} - if !reflect.DeepEqual(got, want) { - t.Fatalf("DetectOSHierarchy() = %#v, want %#v", got, want) - } - - wantMessages := []string{ - "Could not detect hierarchy using os identifier: my_linux_distro , trying with family", - } - if !reflect.DeepEqual(debugMessages, wantMessages) { - t.Fatalf("debug messages = %#v, want %#v", debugMessages, wantMessages) - } -} - -func TestDetectOSHierarchyPreservesMixedCaseFamilyNames(t *testing.T) { - hierarchy := []any{"RedHat"} - got := DetectOSHierarchy(hierarchy, "my_linux_distro", "RedHat", discardLog()) - want := []string{"RedHat"} - if !reflect.DeepEqual(got, want) { - t.Fatalf("DetectOSHierarchy() = %#v, want %#v", got, want) - } -} - -func TestDetectOSIdentifier_matchesRubyHostOSMapping(t *testing.T) { - tests := []struct { - name string - hostOS string - distro string - want string - wantErr error - }{ - {name: "macos", hostOS: "darwin", want: "macosx"}, - {name: "windows mingw", hostOS: "mingw", want: "windows"}, - {name: "windows mswin", hostOS: "mswin", want: "windows"}, - {name: "linux distro", hostOS: "linux", distro: "redhat", want: "redhat"}, - {name: "linux fallback", hostOS: "linux", want: "linux"}, - {name: "freebsd", hostOS: "freebsd13", want: "freebsd"}, - {name: "openbsd", hostOS: "openbsd7.5", want: "openbsd"}, - {name: "netbsd", hostOS: "netbsd10", want: "netbsd"}, - {name: "dragonfly", hostOS: "dragonfly6.4", want: "dragonfly"}, - {name: "illumos", hostOS: "illumos", want: "illumos"}, - {name: "sunos illumos family", hostOS: "sunos5.11", want: "illumos"}, - {name: "unknown", hostOS: "my_custom_os", wantErr: ErrUnknownOS}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := DetectOSIdentifier(tt.hostOS, tt.distro) - if !errors.Is(err, tt.wantErr) { - t.Fatalf("DetectOSIdentifier() err = %v, want %v", err, tt.wantErr) - } - if tt.wantErr != nil && err.Error() != `unknown os: "my_custom_os"` { - t.Fatalf("DetectOSIdentifier() err = %q, want Ruby unknown OS message", err) - } - if got != tt.want { - t.Fatalf("DetectOSIdentifier(%q, %q) = %q, want %q", tt.hostOS, tt.distro, got, tt.want) - } - }) - } -} diff --git a/internal/engine/discovery_plan.go b/internal/engine/discovery_plan.go index 983b0a92..8e399b90 100644 --- a/internal/engine/discovery_plan.go +++ b/internal/engine/discovery_plan.go @@ -84,16 +84,22 @@ const ( // diagnostic. func (e *Engine) unionDisabledFacts(s *Session, config Config, includeEnv bool) (map[string]bool, map[string]string) { groups := config.FactGroups - disabled := map[string]bool{} - ambient := map[string]string{} + var environ []string + if includeEnv { + environ = s.host.environ() + } + // The disabled set is derived from the exported union so a full discovery + // and the version fast path can never disagree on whether a fact is disabled. + disabled := DisabledUnion(config, e.cfg.ExtraDisabled, environ) + // The ambient map names the env/config source of each disabled fact for the + // explicit-query diagnostic; it mirrors the union's sources but is engine-only. + ambient := map[string]string{} for name := range DisabledFactsWithGroups(config.Disabled, groups) { - disabled[name] = true ambient[name] = disabledSourceConfig } if includeEnv { for name := range DisabledFactsWithGroups(environmentDisabledFacts(s.host.environ()), groups) { - disabled[name] = true ambient[name] = disabledSourceEnv } } @@ -103,7 +109,6 @@ func (e *Engine) unionDisabledFacts(s *Session, config Config, includeEnv bool) // drop also covers descendants, mirroring pruneDisabledDescendants: a // --disable of `networking` silences an ambient `networking.ip` it subsumes. for name := range DisabledFactsWithGroups(e.cfg.ExtraDisabled, groups) { - disabled[name] = true delete(ambient, name) for k := range ambient { if strings.HasPrefix(k, name+".") { diff --git a/internal/engine/disks_test.go b/internal/engine/disks_test.go index 288367c5..e701579c 100644 --- a/internal/engine/disks_test.go +++ b/internal/engine/disks_test.go @@ -1507,52 +1507,60 @@ func TestParseLinuxFilesystems_sortsAndSkipsPseudoEntries(t *testing.T) { } func TestCurrentLinuxFilesystemsUnreadableProcMatchesRubyResolver(t *testing.T) { - readFile := func(path string) ([]byte, error) { - if path != "/proc/filesystems" { - t.Fatalf("path = %q, want /proc/filesystems", path) - } - return nil, os.ErrPermission + host := &fakeHostOS{ + platform: "linux", + fileErrs: map[string]error{"/proc/filesystems": os.ErrPermission}, } + s := NewSessionContext(t.Context()) + s.host = host - if got := currentFilesystems("linux", readFile, nil); got != nil { + if got := probeFilesystems(s); got != nil { t.Fatalf("currentFilesystems(linux) = %#v, want nil", got) } + if !reflect.DeepEqual(host.readFileCalls, []string{"/proc/filesystems"}) { + t.Fatalf("read file calls = %#v, want [/proc/filesystems]", host.readFileCalls) + } } 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" - }) + host := &fakeHostOS{ + platform: "darwin", + runOutputs: map[string]string{ + fakeRunKey("mount"): "/dev/disk3s1s1 on / (apfs, local)\nmap auto_home on /System/Volumes/Data/home (autofs, automounted)\n", + }, + } + s := NewSessionContext(t.Context()) + s.host = host + + got := probeFilesystems(s) 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) + wantCalls := []fakeHostRunCall{{name: "mount", args: nil}} + if !reflect.DeepEqual(host.runCalls, wantCalls) { + t.Fatalf("run calls = %#v, want %#v", host.runCalls, wantCalls) } } func TestCurrentFilesystemsHonorsTargetCapabilityPolicy(t *testing.T) { - called := false - readFile := func(path string) ([]byte, error) { - called = true - return []byte("ext4\n"), nil - } - run := func(name string, args ...string) string { - called = true - return "/dev/disk on / (apfs, local)\n" + host := &fakeHostOS{ + platform: "freebsd", + files: map[string][]byte{"/proc/filesystems": []byte("ext4\n")}, + runOutputs: map[string]string{ + fakeRunKey("mount"): "/dev/disk on / (apfs, local)\n", + }, } + s := NewSessionContext(t.Context()) + s.host = host - if got := currentFilesystems("freebsd", readFile, run); got != nil { + if got := probeFilesystems(s); got != nil { t.Fatalf("currentFilesystems(freebsd) = %#v, want nil", got) } - if called { - t.Fatal("currentFilesystems(freebsd) touched probes despite target policy") + if len(host.readFileCalls) != 0 || len(host.runCalls) != 0 { + t.Fatalf("currentFilesystems(freebsd) touched probes despite target policy: reads=%#v runs=%#v", host.readFileCalls, host.runCalls) } } diff --git a/internal/engine/dmi.go b/internal/engine/dmi.go index 4767ed27..df09d298 100644 --- a/internal/engine/dmi.go +++ b/internal/engine/dmi.go @@ -1,8 +1,6 @@ package engine import ( - "log/slog" - "runtime" "strconv" "strings" ) @@ -73,16 +71,12 @@ func dmiBIOSVendor(dmi map[string]any) string { } func currentFreeBSDDMIFacts(s *Session) []ResolvedFact { - return currentFreeBSDDMIFactsForPlatform(runtime.GOOS, s.commandOutput) -} - -func currentFreeBSDDMIFactsForPlatform(goos string, run commandRunner) []ResolvedFact { - if goos != "freebsd" { + if s.goos() != "freebsd" { return nil } values := make(map[string]string, len(freeBSDDMIKeys)) for _, key := range freeBSDDMIKeys { - values[key] = run("/bin/kenv", key) + values[key] = s.commandOutput("/bin/kenv", key) } return freeBSDDMIFacts(values) } @@ -91,69 +85,53 @@ func currentFreeBSDDMIFactsForPlatform(goos string, run commandRunner) []Resolve // FreeBSD builder. kenv is PATH-resolved here (DragonFly ships it outside // FreeBSD's /bin/kenv path). func currentDragonFlyDMIFacts(s *Session) []ResolvedFact { - return currentDragonFlyDMIFactsForPlatform(runtime.GOOS, s.commandOutput) -} - -func currentDragonFlyDMIFactsForPlatform(goos string, run commandRunner) []ResolvedFact { - if goos != "dragonfly" { + if s.goos() != "dragonfly" { return nil } values := make(map[string]string, len(freeBSDDMIKeys)) for _, key := range freeBSDDMIKeys { - values[key] = run("kenv", key) + values[key] = s.commandOutput("kenv", key) } if facts := freeBSDDMIFacts(values); len(facts) > 0 { return facts } return dragonFlyDMIDecodeFacts( - run("/usr/local/sbin/dmidecode", "-t", "bios"), - run("/usr/local/sbin/dmidecode", "-t", "system"), - run("/usr/local/sbin/dmidecode", "-t", "chassis"), + s.commandOutput("/usr/local/sbin/dmidecode", "-t", "bios"), + s.commandOutput("/usr/local/sbin/dmidecode", "-t", "system"), + s.commandOutput("/usr/local/sbin/dmidecode", "-t", "chassis"), ) } func currentOpenBSDDMIFacts(s *Session) []ResolvedFact { - return currentOpenBSDDMIFactsForPlatform(runtime.GOOS, s.commandOutput) -} - -func currentOpenBSDDMIFactsForPlatform(goos string, run commandRunner) []ResolvedFact { - if goos != "openbsd" { + if s.goos() != "openbsd" { return nil } values := make(map[string]string, len(openBSDDMIKeys)) for _, key := range openBSDDMIKeys { - values[key] = run("/sbin/sysctl", "-n", key) + values[key] = s.commandOutput("/sbin/sysctl", "-n", key) } return openBSDDMIFacts(values) } func currentNetBSDDMIFacts(s *Session) []ResolvedFact { - return currentNetBSDDMIFactsForPlatform(runtime.GOOS, s.commandOutput) -} - -func currentNetBSDDMIFactsForPlatform(goos string, run commandRunner) []ResolvedFact { - if goos != "netbsd" { + if s.goos() != "netbsd" { return nil } values := make(map[string]string, len(netBSDDMIKeys)) for _, key := range netBSDDMIKeys { - values[key] = run("/sbin/sysctl", "-n", key) + values[key] = s.commandOutput("/sbin/sysctl", "-n", key) } return netBSDDMIFacts(values) } func currentIllumosDMIFacts(s *Session) []ResolvedFact { - return currentIllumosDMIFactsForPlatform(runtime.GOOS, s.commandOutput) -} - -func currentIllumosDMIFactsForPlatform(goos string, run commandRunner) []ResolvedFact { - if goos != "illumos" { + if s.goos() != "illumos" { return nil } return illumosDMIFacts( - run("/usr/sbin/smbios", "-t", "SMB_TYPE_BIOS"), - run("/usr/sbin/smbios", "-t", "SMB_TYPE_SYSTEM"), - run("/usr/sbin/smbios", "-t", "SMB_TYPE_CHASSIS"), + 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"), ) } @@ -412,17 +390,17 @@ type windowsDMI struct { ProductUUID string } -func currentWindowsDMI(goos string, run commandRunner, log *slog.Logger) windowsDMI { - if goos != "windows" { +func currentWindowsDMI(s *Session) windowsDMI { + if s.goos() != "windows" { return windowsDMI{} } - bios := parseWindowsWMIValues(windowsWMIOutput(run, "bios", "Manufacturer,SerialNumber")) - product := parseWindowsWMIValues(windowsWMIOutput(run, "computersystemproduct", "Name,UUID")) + bios := parseWindowsWMIValues(windowsWMIOutput(s.commandOutput, "bios", "Manufacturer,SerialNumber")) + product := parseWindowsWMIValues(windowsWMIOutput(s.commandOutput, "computersystemproduct", "Name,UUID")) if len(bios) == 0 { - log.Debug("WMI query returned no results for Win32_BIOS with values Manufacturer and SerialNumber.") + s.logr().Debug("WMI query returned no results for Win32_BIOS with values Manufacturer and SerialNumber.") } if len(product) == 0 { - log.Debug("WMI query returned no results for Win32_ComputerSystemProduct with values Name and UUID.") + s.logr().Debug("WMI query returned no results for Win32_ComputerSystemProduct with values Name and UUID.") } return windowsDMI{ Manufacturer: bios["Manufacturer"], @@ -507,7 +485,7 @@ func dmiCoreFacts(s *Session) []ResolvedFact { dmi := s.cachedDMI() facts := dmiFacts(dmi) facts = append(facts, macOSDMIFacts(s.cachedMacOSModel())...) - facts = append(facts, windowsDMIFacts(currentWindowsDMI(runtime.GOOS, s.commandOutput, s.logr()))...) + facts = append(facts, windowsDMIFacts(currentWindowsDMI(s))...) facts = append(facts, currentFreeBSDDMIFacts(s)...) facts = append(facts, currentDragonFlyDMIFacts(s)...) facts = append(facts, currentOpenBSDDMIFacts(s)...) diff --git a/internal/engine/dmi_test.go b/internal/engine/dmi_test.go index 42087ee3..b5f73e81 100644 --- a/internal/engine/dmi_test.go +++ b/internal/engine/dmi_test.go @@ -1,6 +1,7 @@ package engine import ( + "context" "os" "path/filepath" "reflect" @@ -11,23 +12,17 @@ import ( func TestCurrentWindowsDMIMatchesRubyResolvers(t *testing.T) { t.Parallel() - run := func(name string, args ...string) string { - if name != "wmic" { - t.Fatalf("command = %q %v, want wmic", name, args) - } - query := strings.Join(args, " ") - switch query { - case "bios get Manufacturer,SerialNumber /value": - return "Manufacturer=VMware, Inc.\r\nSerialNumber=VMware-42 1a 38 c5 9d 35 5b f1-7a 62 4b 6e cb a0 79 de\r\n" - case "computersystemproduct get Name,UUID /value": - return "Name=VMware7,1\r\nUUID=C5381A42-359D-F15B-7A62-4B6ECBA079DE\r\n" - default: - t.Fatalf("wmic args = %q", query) - } - return "" + host := &fakeHostOS{ + platform: "windows", + runOutputs: map[string]string{ + fakeRunKey("wmic", "bios", "get", "Manufacturer,SerialNumber", "/value"): "Manufacturer=VMware, Inc.\r\nSerialNumber=VMware-42 1a 38 c5 9d 35 5b f1-7a 62 4b 6e cb a0 79 de\r\n", + fakeRunKey("wmic", "computersystemproduct", "get", "Name,UUID", "/value"): "Name=VMware7,1\r\nUUID=C5381A42-359D-F15B-7A62-4B6ECBA079DE\r\n", + }, } + s := NewSessionContext(context.Background()) + s.host = host - got := currentWindowsDMI("windows", run, discardLog()) + got := currentWindowsDMI(s) want := windowsDMI{ Manufacturer: "VMware, Inc.", SerialNumber: "VMware-42 1a 38 c5 9d 35 5b f1-7a 62 4b 6e cb a0 79 de", @@ -37,13 +32,23 @@ func TestCurrentWindowsDMIMatchesRubyResolvers(t *testing.T) { if got != want { t.Fatalf("currentWindowsDMI() = %#v, want %#v", got, want) } + wantCalls := []fakeHostRunCall{ + {name: "wmic", args: []string{"bios", "get", "Manufacturer,SerialNumber", "/value"}}, + {name: "wmic", args: []string{"computersystemproduct", "get", "Name,UUID", "/value"}}, + } + if !reflect.DeepEqual(host.runCalls, wantCalls) { + t.Fatalf("run calls = %#v, want %#v", host.runCalls, wantCalls) + } } func TestCurrentWindowsDMILogsNoResultDiagnosticsLikeRubyResolvers(t *testing.T) { debugMessages := []string{} - logger := captureLogger(&debugMessages, nil, nil) + host := &fakeHostOS{platform: "windows", emptyRunDefault: true} + s := NewSessionContext(context.Background()) + s.host = host + s.logger = captureLogger(&debugMessages, nil, nil) - got := currentWindowsDMI("windows", func(string, ...string) string { return "" }, logger) + got := currentWindowsDMI(s) if got != (windowsDMI{}) { t.Fatalf("currentWindowsDMI(empty WMI) = %#v, want empty DMI", got) } @@ -387,127 +392,137 @@ func TestCurrentBSDDMIFactsQueryPlatformSources(t *testing.T) { 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 "" - }) + host := &fakeHostOS{ + platform: "freebsd", + emptyRunDefault: true, + runOutputs: map[string]string{ + fakeRunKey("/bin/kenv", "smbios.system.maker"): "FreeBSD Maker\n", + }, + } + s := NewSessionContext(context.Background()) + s.host = host + + facts := currentFreeBSDDMIFacts(s) if got := Collection(facts)["dmi"].(map[string]any)["manufacturer"]; got != "FreeBSD Maker" { t.Fatalf("freebsd manufacturer = %#v, want FreeBSD Maker", got) } + wantCalls := make([]fakeHostRunCall, 0, len(freeBSDDMIKeys)) for _, key := range freeBSDDMIKeys { - if !calls[key] { - t.Fatalf("freebsd DMI did not query %s", key) - } + wantCalls = append(wantCalls, fakeHostRunCall{name: "/bin/kenv", args: []string{key}}) + } + if !reflect.DeepEqual(host.runCalls, wantCalls) { + t.Fatalf("freebsd DMI run calls = %#v, want %#v", host.runCalls, wantCalls) } }) 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 "" - } - }) + host := &fakeHostOS{ + platform: "dragonfly", + emptyRunDefault: true, + runOutputs: map[string]string{ + fakeRunKey("kenv", "smbios.system.maker"): "DragonFly Maker\n", + fakeRunKey("/usr/local/sbin/dmidecode", "-t", "system"): "Manufacturer: fallback\n", + }, + } + s := NewSessionContext(context.Background()) + s.host = host + + facts := currentDragonFlyDMIFacts(s) 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 _, call := range host.runCalls { + if call.name == "/usr/local/sbin/dmidecode" { + t.Fatal("dragonfly DMI queried dmidecode despite kenv SMBIOS data") + } } + wantCalls := make([]fakeHostRunCall, 0, len(freeBSDDMIKeys)) for _, key := range freeBSDDMIKeys { - if !calls[fakeRunKey("kenv", key)] { - t.Fatalf("dragonfly DMI did not query %s", key) - } + wantCalls = append(wantCalls, fakeHostRunCall{name: "kenv", args: []string{key}}) + } + if !reflect.DeepEqual(host.runCalls, wantCalls) { + t.Fatalf("dragonfly DMI run calls = %#v, want %#v", host.runCalls, wantCalls) } }) 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 "" - }) + host := &fakeHostOS{ + platform: "openbsd", + emptyRunDefault: true, + runOutputs: map[string]string{ + fakeRunKey("/sbin/sysctl", "-n", "hw.vendor"): "OpenBSD Vendor\n", + }, + } + s := NewSessionContext(context.Background()) + s.host = host + + facts := currentOpenBSDDMIFacts(s) if got := Collection(facts)["dmi"].(map[string]any)["manufacturer"]; got != "OpenBSD Vendor" { t.Fatalf("openbsd manufacturer = %#v, want OpenBSD Vendor", got) } + wantCalls := make([]fakeHostRunCall, 0, len(openBSDDMIKeys)) for _, key := range openBSDDMIKeys { - if !calls[key] { - t.Fatalf("openbsd DMI did not query %s", key) - } + wantCalls = append(wantCalls, fakeHostRunCall{name: "/sbin/sysctl", args: []string{"-n", key}}) + } + if !reflect.DeepEqual(host.runCalls, wantCalls) { + t.Fatalf("openbsd DMI run calls = %#v, want %#v", host.runCalls, wantCalls) } }) 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 "" - }) + host := &fakeHostOS{ + platform: "netbsd", + emptyRunDefault: true, + runOutputs: map[string]string{ + fakeRunKey("/sbin/sysctl", "-n", "machdep.dmi.system-vendor"): "NetBSD Vendor\n", + }, + } + s := NewSessionContext(context.Background()) + s.host = host + + facts := currentNetBSDDMIFacts(s) if got := Collection(facts)["dmi"].(map[string]any)["manufacturer"]; got != "NetBSD Vendor" { t.Fatalf("netbsd manufacturer = %#v, want NetBSD Vendor", got) } + wantCalls := make([]fakeHostRunCall, 0, len(netBSDDMIKeys)) for _, key := range netBSDDMIKeys { - if !calls[key] { - t.Fatalf("netbsd DMI did not query %s", key) - } + wantCalls = append(wantCalls, fakeHostRunCall{name: "/sbin/sysctl", args: []string{"-n", key}}) + } + if !reflect.DeepEqual(host.runCalls, wantCalls) { + t.Fatalf("netbsd DMI run calls = %#v, want %#v", host.runCalls, wantCalls) } }) 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 "" - }) + host := &fakeHostOS{ + platform: "illumos", + emptyRunDefault: true, + runOutputs: map[string]string{ + fakeRunKey("/usr/sbin/smbios", "-t", "SMB_TYPE_SYSTEM"): "Manufacturer: illumos Maker\n", + }, + } + s := NewSessionContext(context.Background()) + s.host = host + + facts := currentIllumosDMIFacts(s) 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) - } + wantCalls := []fakeHostRunCall{ + {name: "/usr/sbin/smbios", args: []string{"-t", "SMB_TYPE_BIOS"}}, + {name: "/usr/sbin/smbios", args: []string{"-t", "SMB_TYPE_SYSTEM"}}, + {name: "/usr/sbin/smbios", args: []string{"-t", "SMB_TYPE_CHASSIS"}}, + } + if !reflect.DeepEqual(host.runCalls, wantCalls) { + t.Fatalf("illumos DMI run calls = %#v, want %#v", host.runCalls, wantCalls) } }) } @@ -515,24 +530,27 @@ func TestCurrentBSDDMIFactsQueryPlatformSources(t *testing.T) { func TestCurrentPlatformDMIFactsSkipOtherPlatforms(t *testing.T) { t.Parallel() - run := func(string, ...string) string { - t.Fatal("DMI platform helper ran command for non-matching platform") - return "" + host := &fakeHostOS{platform: "linux", emptyRunDefault: true} + s := NewSessionContext(context.Background()) + s.host = host + + if got := currentFreeBSDDMIFacts(s); got != nil { + t.Fatalf("currentFreeBSDDMIFacts(linux) = %#v, want nil", got) } - if got := currentFreeBSDDMIFactsForPlatform("linux", run); got != nil { - t.Fatalf("currentFreeBSDDMIFactsForPlatform(linux) = %#v, want nil", got) + if got := currentDragonFlyDMIFacts(s); got != nil { + t.Fatalf("currentDragonFlyDMIFacts(linux) = %#v, want nil", got) } - if got := currentDragonFlyDMIFactsForPlatform("linux", run); got != nil { - t.Fatalf("currentDragonFlyDMIFactsForPlatform(linux) = %#v, want nil", got) + if got := currentOpenBSDDMIFacts(s); got != nil { + t.Fatalf("currentOpenBSDDMIFacts(linux) = %#v, want nil", got) } - if got := currentOpenBSDDMIFactsForPlatform("linux", run); got != nil { - t.Fatalf("currentOpenBSDDMIFactsForPlatform(linux) = %#v, want nil", got) + if got := currentNetBSDDMIFacts(s); got != nil { + t.Fatalf("currentNetBSDDMIFacts(linux) = %#v, want nil", got) } - if got := currentNetBSDDMIFactsForPlatform("linux", run); got != nil { - t.Fatalf("currentNetBSDDMIFactsForPlatform(linux) = %#v, want nil", got) + if got := currentIllumosDMIFacts(s); got != nil { + t.Fatalf("currentIllumosDMIFacts(linux) = %#v, want nil", got) } - if got := currentIllumosDMIFactsForPlatform("linux", run); got != nil { - t.Fatalf("currentIllumosDMIFactsForPlatform(linux) = %#v, want nil", got) + if len(host.runCalls) != 0 { + t.Fatalf("DMI platform helper ran command for non-matching platform: %#v", host.runCalls) } } @@ -679,19 +697,18 @@ func TestMacOSDMIFacts_skipsEmptyProductName(t *testing.T) { } func TestCurrentLinuxDistro_mapsAmazonAMISystemReleaseIDAndMissingCodename(t *testing.T) { - files := map[string]string{ - "/etc/os-release": "ID=amzn\nVERSION_ID=2017.03\n", - "/etc/system-release": "Amazon Linux AMI release 2017.03\n", - } - readFile := func(path string) ([]byte, error) { - value, ok := files[path] - if !ok { - return nil, os.ErrNotExist - } - return []byte(value), nil + host := &fakeHostOS{ + platform: "linux", + emptyRunDefault: true, + files: map[string][]byte{ + "/etc/os-release": []byte("ID=amzn\nVERSION_ID=2017.03\n"), + "/etc/system-release": []byte("Amazon Linux AMI release 2017.03\n"), + }, } + s := NewSessionContext(t.Context()) + s.host = host - got := currentLinuxDistro("linux", func(string) (string, error) { return "", os.ErrNotExist }, func(string, ...string) string { return "" }, readFile) + got := currentLinuxDistro(s, func(string) (string, error) { return "", os.ErrNotExist }) want := linuxDistro{ ID: "AmazonAMI", Description: "Amazon Linux AMI release 2017.03", diff --git a/internal/engine/ec2.go b/internal/engine/ec2.go index 992e06bf..ee523bb9 100644 --- a/internal/engine/ec2.go +++ b/internal/engine/ec2.go @@ -2,7 +2,6 @@ package engine import ( "context" - "io" "net/http" "os" "strconv" @@ -13,7 +12,6 @@ import ( const ( ec2MetadataBaseURL = "http://169.254.169.254/latest" ec2RequestTimeout = 100 * time.Millisecond - ec2MaxBodyBytes = 1 << 20 ec2MaxDepth = 16 ec2TokenTTL = 21600 * time.Second ) @@ -29,12 +27,7 @@ type ec2Client struct { func newEC2Client(baseURL string, httpClient *http.Client) *ec2Client { if httpClient == nil { - httpClient = &http.Client{ - Timeout: ec2RequestTimeout, - Transport: &http.Transport{ - Proxy: nil, - }, - } + httpClient = newMetadataHTTPClient(ec2RequestTimeout) } return &ec2Client{ baseURL: strings.TrimRight(baseURL, "/"), @@ -71,7 +64,8 @@ func ec2Facts(s *Session, client *ec2Client, virt virtualization) []ResolvedFact } func cloudProviderFact(s *Session, virt virtualization, ec2Metadata map[string]any) *ResolvedFact { - return cloudProviderFactForPlatform(s.goos(), virt, ec2Metadata, os.Geteuid(), fileExecutable, s.commandOutput) + executable := func(path string) bool { return fileExecutable(s.host, path) } + return cloudProviderFactForPlatform(s.goos(), virt, ec2Metadata, os.Geteuid(), executable, s.commandOutput) } func cloudProviderFactForPlatform(goos string, virt virtualization, ec2Metadata map[string]any, euid int, executable func(string) bool, run func(string, ...string) string) *ResolvedFact { @@ -102,8 +96,8 @@ func linuxAWSCloudProvider(name string, ec2Metadata map[string]any, euid int, ex return false } -func fileExecutable(path string) bool { - info, err := os.Stat(path) +func fileExecutable(host hostOS, path string) bool { + info, err := host.stat(path) return err == nil && info.Mode().IsRegular() && info.Mode()&0o111 != 0 } @@ -180,26 +174,12 @@ func (ec *ec2Client) get(ctx context.Context, path string) (string, bool) { } func (ec *ec2Client) getRaw(ctx context.Context, path string) (string, bool) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, ec.baseURL+"/"+path, nil) - if err != nil { - return "", false - } + var headers map[string]string if token := ec.v2Token(ctx); token != "" { - req.Header.Set("X-aws-ec2-metadata-token", token) - } - resp, err := ec.httpClient.Do(req) - if err != nil { - return "", false - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return "", false - } - data, err := io.ReadAll(io.LimitReader(resp.Body, ec2MaxBodyBytes)) - if err != nil { - return "", false + headers = map[string]string{"X-aws-ec2-metadata-token": token} } - return string(data), true + body, _, ok := fetchMetadata(ctx, ec.httpClient, http.MethodGet, ec.baseURL+"/"+path, headers) + return body, ok } func (ec *ec2Client) v2Token(ctx context.Context) string { @@ -214,24 +194,13 @@ func (ec *ec2Client) v2Token(ctx context.Context) string { if ec.token != "" && now().Before(ec.tokenUntil) { return ec.token } - req, err := http.NewRequestWithContext(ctx, http.MethodPut, ec.baseURL+"/api/token", nil) - if err != nil { - return "" - } - req.Header.Set("X-aws-ec2-metadata-token-ttl-seconds", strconv.FormatInt(int64(ttl/time.Second), 10)) - resp, err := ec.httpClient.Do(req) - if err != nil { - return "" - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return "" - } - data, err := io.ReadAll(io.LimitReader(resp.Body, ec2MaxBodyBytes)) - if err != nil { + body, _, ok := fetchMetadata(ctx, ec.httpClient, http.MethodPut, ec.baseURL+"/api/token", map[string]string{ + "X-aws-ec2-metadata-token-ttl-seconds": strconv.FormatInt(int64(ttl/time.Second), 10), + }) + if !ok { return "" } - ec.token = strings.TrimSpace(string(data)) + ec.token = strings.TrimSpace(body) ec.tokenUntil = now().Add(ttl) return ec.token } diff --git a/internal/engine/ec2_test.go b/internal/engine/ec2_test.go index 9793794f..095a886d 100644 --- a/internal/engine/ec2_test.go +++ b/internal/engine/ec2_test.go @@ -5,9 +5,7 @@ import ( "net/http" "net/http/httptest" "os" - "path/filepath" "reflect" - "runtime" "testing" "time" ) @@ -304,38 +302,29 @@ 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") + host := &fakeHostOS{ + stats: map[string]os.FileInfo{ + "/bin/virt-what": fakeFileInfo{name: "virt-what", mode: 0o700}, + "/bin/not-executable": fakeFileInfo{name: "not-executable", mode: 0o600}, + "/bin": fakeFileInfo{name: "bin", mode: os.ModeDir | 0o755, isDir: true}, + }, } - 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) { + if !fileExecutable(host, "/bin/virt-what") { 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) { + if fileExecutable(host, "/bin/not-executable") { t.Fatal("fileExecutable(non-executable) = true, want false") } - if fileExecutable(dir) { + if fileExecutable(host, "/bin") { t.Fatal("fileExecutable(directory) = true, want false") } - if fileExecutable(filepath.Join(dir, "missing")) { + if fileExecutable(host, "/bin/missing") { t.Fatal("fileExecutable(missing) = true, want false") } } -func TestGCEFacts_fetchesMetadataAndCloudProvider(t *testing.T) { +func TestLinuxGCEFacts_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" { t.Fatalf("Metadata-Flavor = %q, want Google", got) @@ -349,7 +338,7 @@ func TestGCEFacts_fetchesMetadataAndCloudProvider(t *testing.T) { })) defer server.Close() - got := gceFacts(context.Background(), newGCEClient(server.URL+"/computeMetadata/v1", server.Client())) + got := linuxGCEFacts(context.Background(), "linux", "Google", newGCEClient(server.URL+"/computeMetadata/v1", server.Client())) want := []ResolvedFact{ {Name: "gce", Value: map[string]any{ "instance": map[string]any{ @@ -364,6 +353,6 @@ func TestGCEFacts_fetchesMetadataAndCloudProvider(t *testing.T) { {Name: "cloud.provider", Value: "gce"}, } if !reflect.DeepEqual(got, want) { - t.Fatalf("gceFacts(context.Background(), ) = %#v, want %#v", got, want) + t.Fatalf("linuxGCEFacts(context.Background(), ) = %#v, want %#v", got, want) } } diff --git a/internal/engine/engine.go b/internal/engine/engine.go index 7e410483..c51fc4ba 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -197,27 +197,23 @@ func (e *Engine) Discover(ctx context.Context, queries ...string) (*Snapshot, er e.warnOnce("Recursion detected while resolving external facts; executable external facts will be skipped") } loader := externalFactLoader{ - s: s, - dirs: plan.externalDirs, - blocked: plan.disabledFacts, + s: s, + dirs: plan.externalDirs, + blocked: plan.disabledFacts, + mode: plan.loaderMode, + includeEnv: plan.includeEnv, } - if plan.loaderMode == externalFactLoaderCLI { - loader.mode = plan.loaderMode - loader.includeEnv = plan.includeEnv - loaded, err := loader.load() - if err != nil { - return newSnapshot(nil, s.logger), err - } - externalFacts = loaded - } else { - loader.mode = plan.loaderMode - loader.includeEnv = plan.includeEnv - loaded, err := loader.load() - if err != nil { - failures = append(failures, err) - } - externalFacts = loaded + loaded, err := loader.load() + if err != nil && plan.loaderMode == externalFactLoaderCLI { + // CLI mode aborts on the first loader error, discarding the facts + // loaded before it and any earlier planFailures, and returns the bare + // loader error (finish()'s ctx.Err() join is skipped). + return newSnapshot(nil, s.logger), err + } + if err != nil { + failures = append(failures, err) } + externalFacts = loaded } if ctx.Err() != nil { facts = externalFacts diff --git a/internal/engine/engine_test.go b/internal/engine/engine_test.go index f41cd7b1..6a550c69 100644 --- a/internal/engine/engine_test.go +++ b/internal/engine/engine_test.go @@ -385,3 +385,61 @@ func TestEngineDiscoverUsesCachedValueForConfiguredFacts(t *testing.T) { t.Fatalf("cached Value(cache_probe) = %#v, %v, want cache file value", got, err) } } + +// externalFactErrorFixtureDir writes a good static fact file alongside a +// null-byte fact file. The loader loads the good file, then errors on the null +// byte — the fixture that distinguishes the CLI (fail-fast) and library +// (accumulate) error policies these pins lock before the Discover arms collapse. +func externalFactErrorFixtureDir(t *testing.T) string { + t.Helper() + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "good.txt"), []byte("good_fact=ok\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "bad.txt"), []byte("bad_fact=va\x00lue\n"), 0o600); err != nil { + t.Fatal(err) + } + return dir +} + +// Library mode: a per-file loader failure is accumulated into the joined error +// while the successfully loaded facts are retained in a partial snapshot. +func TestEngineDiscoverLibraryModeAccumulatesLoaderErrorAndKeepsFacts(t *testing.T) { + dir := externalFactErrorFixtureDir(t) + eng, err := NewEngine(EngineConfig{ExternalDirs: []string{dir}}) + if err != nil { + t.Fatal(err) + } + + snap, err := eng.Discover(context.Background(), "good_fact") + if !errors.Is(err, ErrNullByte) { + t.Fatalf("Discover() err = %v, want joined null-byte error", err) + } + if snap == nil { + t.Fatal("Discover() snapshot = nil, want partial snapshot retaining loaded facts") + } + if got, err := snap.Value("good_fact"); err != nil || got != "ok" { + t.Fatalf("Value(good_fact) = %#v, %v, want ok (library mode retains loaded facts)", got, err) + } +} + +// CLI mode: the first loader failure aborts discovery, discarding the facts +// loaded before it and returning the bare loader error (no core facts resolve). +func TestEngineDiscoverCLIModeFailsFastAndDiscardsFacts(t *testing.T) { + dir := externalFactErrorFixtureDir(t) + eng, err := NewEngine(EngineConfig{ExternalDirs: []string{dir}, CLICompat: true}) + if err != nil { + t.Fatal(err) + } + + snap, err := eng.Discover(context.Background(), "good_fact") + if !errors.Is(err, ErrNullByte) { + t.Fatalf("Discover() err = %v, want bare null-byte loader error", err) + } + if snap == nil { + t.Fatal("Discover() snapshot = nil, want empty snapshot") + } + if _, err := snap.Value("good_fact"); !errors.Is(err, ErrFactNotFound) { + t.Fatalf("Value(good_fact) err = %v, want ErrFactNotFound (CLI fail-fast discards loaded facts)", err) + } +} diff --git a/internal/engine/external.go b/internal/engine/external.go index 33d0aaca..e7c225da 100644 --- a/internal/engine/external.go +++ b/internal/engine/external.go @@ -111,25 +111,6 @@ func ExternalFactResolutionRunning() bool { return os.Getenv(externalFactResolutionEnv) != "" } -// LoadExternalFacts loads static external facts from the provided directories. -func LoadExternalFacts(s *Session, dirs []string) ([]ResolvedFact, error) { - return LoadExternalFactsWithBlocklist(s, dirs, nil) -} - -// LoadExternalFactsWithBlocklist loads external facts from dirs plus the -// FACTS_*/FACTER_* environment variables — the CLI's system-following -// semantics — skipping files whose base name is blocklisted by the Facter -// config. -func LoadExternalFactsWithBlocklist(s *Session, dirs []string, blocked map[string]bool) ([]ResolvedFact, error) { - return externalFactLoader{ - s: s, - mode: externalFactLoaderCLI, - dirs: dirs, - blocked: blocked, - includeEnv: true, - }.load() -} - func (l externalFactLoader) load() ([]ResolvedFact, error) { l = l.withDefaults() facts, failures, err := l.loadDirFacts() @@ -307,14 +288,6 @@ func SplitDisableList(value string) []string { return splitDisableList(value) } -// EnvironmentDisabledFacts extracts the disabled-set entries from the reserved -// FACTS_DISABLE / FACTER_DISABLE control variables in env (native wins). It is -// the exported seam internal/app uses to honor ambient disables in the -// facterversion fast path. -func EnvironmentDisabledFacts(env []string) []string { - return environmentDisabledFacts(env) -} - // splitDisableList splits a comma-separated disable list into trimmed, // lowercased entries, dropping empties. It is shared by the FACTS_DISABLE // environment variable and the --disable CLI option. diff --git a/internal/engine/external_test.go b/internal/engine/external_test.go index e9a0412b..74fe792d 100644 --- a/internal/engine/external_test.go +++ b/internal/engine/external_test.go @@ -16,6 +16,23 @@ import ( "time" ) +// loadExternalFactsForTest mirrors the deleted LoadExternalFacts facade +// field-for-field (CLI mode, env included, default host) so these tests keep +// exercising the CLI loader path through a stable in-package entry point. +func loadExternalFactsForTest(s *Session, dirs []string) ([]ResolvedFact, error) { + return loadExternalFactsForTestWithBlocklist(s, dirs, nil) +} + +func loadExternalFactsForTestWithBlocklist(s *Session, dirs []string, blocked map[string]bool) ([]ResolvedFact, error) { + return externalFactLoader{ + s: s, + mode: externalFactLoaderCLI, + dirs: dirs, + blocked: blocked, + includeEnv: true, + }.load() +} + type fakeExternalFactLoaderHost struct { externalFactOSHost @@ -385,7 +402,7 @@ func TestLoadExternalFacts_txtFacts(t *testing.T) { t.Fatal(err) } - got, err := LoadExternalFacts(testSession, []string{dir}) + got, err := loadExternalFactsForTest(testSession, []string{dir}) if err != nil { t.Fatal(err) } @@ -394,11 +411,11 @@ func TestLoadExternalFacts_txtFacts(t *testing.T) { {Name: "three", Value: "four=five", Type: "external"}, } if len(got) != len(want) { - t.Fatalf("LoadExternalFacts(testSession) len = %d, want %d: %#v", len(got), len(want), got) + t.Fatalf("loadExternalFactsForTest(testSession) len = %d, want %d: %#v", len(got), len(want), got) } for i := range want { if got[i] != want[i] { - t.Fatalf("LoadExternalFacts(testSession)[%d] = %#v, want %#v", i, got[i], want[i]) + t.Fatalf("loadExternalFactsForTest(testSession)[%d] = %#v, want %#v", i, got[i], want[i]) } } } @@ -412,7 +429,7 @@ func TestLoadExternalFacts_processesDirectoryEntriesInReverseLexicographicOrderL t.Fatal(err) } - got, err := LoadExternalFacts(testSession, []string{dir}) + got, err := loadExternalFactsForTest(testSession, []string{dir}) if err != nil { t.Fatal(err) } @@ -421,7 +438,7 @@ func TestLoadExternalFacts_processesDirectoryEntriesInReverseLexicographicOrderL {Name: "first", Value: "a", Type: "external"}, } if !reflect.DeepEqual(got, want) { - t.Fatalf("LoadExternalFacts(testSession) = %#v, want %#v", got, want) + t.Fatalf("loadExternalFactsForTest(testSession) = %#v, want %#v", got, want) } } @@ -438,13 +455,13 @@ func TestLoadExternalFacts_reportsBlockedFilesLikeRubyDirectoryLoader(t *testing s := NewSession() s.logger = captureLogger(&debugMessages, nil, nil) - got, err := LoadExternalFactsWithBlocklist(s, []string{dir}, map[string]bool{"data.yaml": true}) + got, err := loadExternalFactsForTestWithBlocklist(s, []string{dir}, map[string]bool{"data.yaml": true}) if err != nil { t.Fatal(err) } wantFacts := []ResolvedFact{{Name: "f3", Value: "three", Type: "external"}} if !reflect.DeepEqual(got, wantFacts) { - t.Fatalf("LoadExternalFactsWithBlocklist(testSession) = %#v, want %#v", got, wantFacts) + t.Fatalf("loadExternalFactsForTestWithBlocklist(testSession) = %#v, want %#v", got, wantFacts) } wantDebug := []string{"External fact file data.yaml blocked."} if !reflect.DeepEqual(debugMessages, wantDebug) { @@ -464,12 +481,12 @@ func TestLoadExternalFacts_reportsIgnoredBackupFilesLikeRubyDirectoryLoader(t *t s := NewSession() s.logger = captureLogger(&debugMessages, nil, nil) - got, err := LoadExternalFacts(s, []string{dir}) + got, err := loadExternalFactsForTest(s, []string{dir}) if err != nil { t.Fatal(err) } if len(got) != 0 { - t.Fatalf("LoadExternalFacts(testSession) = %#v, want no facts", got) + t.Fatalf("loadExternalFactsForTest(testSession) = %#v, want no facts", got) } for _, ext := range []string{"orig", "bak"} { found := false @@ -488,25 +505,25 @@ func TestLoadExternalFacts_reportsIgnoredBackupFilesLikeRubyDirectoryLoader(t *t func TestLoadExternalFacts_ignoresMissingDirectories(t *testing.T) { dir := filepath.Join(t.TempDir(), "missing") - got, err := LoadExternalFacts(testSession, []string{dir}) + got, err := loadExternalFactsForTest(testSession, []string{dir}) if err != nil { - t.Fatalf("LoadExternalFacts(testSession) err = %v, want nil", err) + t.Fatalf("loadExternalFactsForTest(testSession) err = %v, want nil", err) } if len(got) != 0 { - t.Fatalf("LoadExternalFacts(testSession) = %#v, want no facts", got) + t.Fatalf("loadExternalFactsForTest(testSession) = %#v, want no facts", got) } } func TestLoadExternalFacts_loadsEnvironmentFactsWithoutUnderscore(t *testing.T) { t.Setenv("FACTERsite_location", "lab") - got, err := LoadExternalFacts(testSession, nil) + got, err := loadExternalFactsForTest(testSession, nil) if err != nil { t.Fatal(err) } want := []ResolvedFact{{Name: "site_location", Value: "lab", Type: "external"}} if !reflect.DeepEqual(got, want) { - t.Fatalf("LoadExternalFacts(testSession) = %#v, want %#v", got, want) + t.Fatalf("loadExternalFactsForTest(testSession) = %#v, want %#v", got, want) } } @@ -626,7 +643,7 @@ func TestLoadExternalFacts_jsonFacts(t *testing.T) { t.Fatal(err) } - got, err := LoadExternalFacts(testSession, []string{dir}) + got, err := loadExternalFactsForTest(testSession, []string{dir}) if err != nil { t.Fatal(err) } @@ -636,7 +653,7 @@ func TestLoadExternalFacts_jsonFacts(t *testing.T) { "site": "lab", } if len(got) != len(want) { - t.Fatalf("LoadExternalFacts(testSession) len = %d, want %d: %#v", len(got), len(want), got) + t.Fatalf("loadExternalFactsForTest(testSession) len = %d, want %d: %#v", len(got), len(want), got) } for _, fact := range got { if fact.Type != "external" { @@ -658,12 +675,12 @@ func TestLoadExternalFacts_ignoresJSONWithTrailingTokens(t *testing.T) { t.Fatal(err) } - got, err := LoadExternalFacts(testSession, []string{dir}) + got, err := loadExternalFactsForTest(testSession, []string{dir}) if err != nil { - t.Fatalf("LoadExternalFacts(testSession) err = %v, want nil for malformed structured file", err) + t.Fatalf("loadExternalFactsForTest(testSession) err = %v, want nil for malformed structured file", err) } if len(got) != 0 { - t.Fatalf("LoadExternalFacts(testSession) = %#v, want no facts", got) + t.Fatalf("loadExternalFactsForTest(testSession) = %#v, want no facts", got) } } @@ -673,13 +690,13 @@ func TestLoadExternalFacts_preservesLargeJSONIntegerAsInt64(t *testing.T) { t.Fatal(err) } - got, err := LoadExternalFacts(testSession, []string{dir}) + got, err := loadExternalFactsForTest(testSession, []string{dir}) if err != nil { t.Fatal(err) } want := []ResolvedFact{{Name: "big", Value: int64(2147483648), Type: "external"}} if !reflect.DeepEqual(got, want) { - t.Fatalf("LoadExternalFacts(testSession) = %#v, want %#v", got, want) + t.Fatalf("loadExternalFactsForTest(testSession) = %#v, want %#v", got, want) } } @@ -690,7 +707,7 @@ func TestLoadExternalFacts_yamlFacts(t *testing.T) { t.Fatal(err) } - got, err := LoadExternalFacts(testSession, []string{dir}) + got, err := loadExternalFactsForTest(testSession, []string{dir}) if err != nil { t.Fatal(err) } @@ -701,7 +718,7 @@ func TestLoadExternalFacts_yamlFacts(t *testing.T) { "site": "lab", } if len(got) != len(want) { - t.Fatalf("LoadExternalFacts(testSession) len = %d, want %d: %#v", len(got), len(want), got) + t.Fatalf("loadExternalFactsForTest(testSession) len = %d, want %d: %#v", len(got), len(want), got) } for _, fact := range got { if fact.Type != "external" { @@ -745,13 +762,13 @@ func TestLoadExternalFacts_acceptsLongKeyValueLineWithinLimit(t *testing.T) { t.Fatal(err) } - got, err := LoadExternalFacts(testSession, []string{dir}) + got, err := loadExternalFactsForTest(testSession, []string{dir}) if err != nil { - t.Fatalf("LoadExternalFacts(testSession) err = %v, want nil", err) + t.Fatalf("loadExternalFactsForTest(testSession) err = %v, want nil", err) } want := []ResolvedFact{{Name: "site", Value: value, Type: "external"}} if !reflect.DeepEqual(got, want) { - t.Fatalf("LoadExternalFacts(testSession) = %#v, want long site fact", got) + t.Fatalf("loadExternalFactsForTest(testSession) = %#v, want long site fact", got) } } @@ -762,7 +779,7 @@ func TestLoadExternalFacts_yamlTimestampValuesStayStrings(t *testing.T) { t.Fatal(err) } - got, err := LoadExternalFacts(testSession, []string{dir}) + got, err := loadExternalFactsForTest(testSession, []string{dir}) if err != nil { t.Fatal(err) } @@ -770,7 +787,7 @@ func TestLoadExternalFacts_yamlTimestampValuesStayStrings(t *testing.T) { {Name: "testsfact", Value: map[string]any{"time": "2020-04-28 01:44:08.148119000 +01:01"}, Type: "external"}, } if !reflect.DeepEqual(got, want) { - t.Fatalf("LoadExternalFacts(testSession) = %#v, want %#v", got, want) + t.Fatalf("loadExternalFactsForTest(testSession) = %#v, want %#v", got, want) } } @@ -781,7 +798,7 @@ func TestLoadExternalFacts_yamlTimestampWithoutZoneStaysString(t *testing.T) { t.Fatal(err) } - got, err := LoadExternalFacts(testSession, []string{dir}) + got, err := loadExternalFactsForTest(testSession, []string{dir}) if err != nil { t.Fatal(err) } @@ -789,7 +806,7 @@ func TestLoadExternalFacts_yamlTimestampWithoutZoneStaysString(t *testing.T) { {Name: "testsfact", Value: map[string]any{"time": "2020-04-28 01:44:08.148119000"}, Type: "external"}, } if !reflect.DeepEqual(got, want) { - t.Fatalf("LoadExternalFacts(testSession) = %#v, want %#v", got, want) + t.Fatalf("loadExternalFactsForTest(testSession) = %#v, want %#v", got, want) } } @@ -800,7 +817,7 @@ func TestLoadExternalFacts_yamlDateLoadsAsDateLikeRubyParser(t *testing.T) { t.Fatal(err) } - got, err := LoadExternalFacts(testSession, []string{dir}) + got, err := loadExternalFactsForTest(testSession, []string{dir}) if err != nil { t.Fatal(err) } @@ -808,7 +825,7 @@ func TestLoadExternalFacts_yamlDateLoadsAsDateLikeRubyParser(t *testing.T) { {Name: "testsfact", Value: map[string]any{"date": time.Date(2020, 4, 28, 0, 0, 0, 0, time.UTC)}, Type: "external"}, } if !reflect.DeepEqual(got, want) { - t.Fatalf("LoadExternalFacts(testSession) = %#v, want %#v", got, want) + t.Fatalf("loadExternalFactsForTest(testSession) = %#v, want %#v", got, want) } } @@ -819,7 +836,7 @@ func TestLoadExternalFacts_yamlAnchors(t *testing.T) { t.Fatal(err) } - got, err := LoadExternalFacts(testSession, []string{dir}) + got, err := loadExternalFactsForTest(testSession, []string{dir}) if err != nil { t.Fatal(err) } @@ -828,7 +845,7 @@ func TestLoadExternalFacts_yamlAnchors(t *testing.T) { {Name: "two", Value: map[string]any{"TEST": map[string]any{"a": []any{"foo"}}}, Type: "external"}, } if !reflect.DeepEqual(got, want) { - t.Fatalf("LoadExternalFacts(testSession) = %#v, want %#v", got, want) + t.Fatalf("loadExternalFactsForTest(testSession) = %#v, want %#v", got, want) } } @@ -845,12 +862,12 @@ func TestLoadExternalFacts_ignoresStructuredFilesWithoutKeyValueData(t *testing. } } - got, err := LoadExternalFacts(testSession, []string{dir}) + got, err := loadExternalFactsForTest(testSession, []string{dir}) if err != nil { - t.Fatalf("LoadExternalFacts(testSession) err = %v, want nil", err) + t.Fatalf("loadExternalFactsForTest(testSession) err = %v, want nil", err) } if len(got) != 0 { - t.Fatalf("LoadExternalFacts(testSession) = %#v, want no facts", got) + t.Fatalf("loadExternalFactsForTest(testSession) = %#v, want no facts", got) } } @@ -870,12 +887,12 @@ func TestLoadExternalFacts_reportsStructuredFilesWithoutKeyValueData(t *testing. s := NewSession() s.logger = captureLogger(nil, nil, &messages) - got, err := LoadExternalFacts(s, []string{dir}) + got, err := loadExternalFactsForTest(s, []string{dir}) if err != nil { - t.Fatalf("LoadExternalFacts(testSession) err = %v, want nil", err) + t.Fatalf("loadExternalFactsForTest(testSession) err = %v, want nil", err) } if len(got) != 0 { - t.Fatalf("LoadExternalFacts(testSession) = %#v, want no facts", got) + t.Fatalf("loadExternalFactsForTest(testSession) = %#v, want no facts", got) } want := []string{ fmt.Sprintf("Structured data fact file %s was parsed but no key=>value data was returned.", filepath.Join(dir, "scalar.yaml")), @@ -902,12 +919,12 @@ func TestLoadExternalFacts_reportsEmptyStructuredFilesLikeRubyDirectoryLoader(t s := NewSession() s.logger = captureLogger(&messages, nil, nil) - got, err := LoadExternalFacts(s, []string{dir}) + got, err := loadExternalFactsForTest(s, []string{dir}) if err != nil { - t.Fatalf("LoadExternalFacts(testSession) err = %v, want nil", err) + t.Fatalf("loadExternalFactsForTest(testSession) err = %v, want nil", err) } if len(got) != 0 { - t.Fatalf("LoadExternalFacts(testSession) = %#v, want no facts", got) + t.Fatalf("loadExternalFactsForTest(testSession) = %#v, want no facts", got) } want := []string{ fmt.Sprintf("Structured data fact file %s was parsed but was either empty or an invalid filetype (valid filetypes are .yaml, .json, and .txt).", filepath.Join(dir, "empty.yaml")), @@ -929,12 +946,12 @@ func TestLoadExternalFacts_reportsUnsupportedVisibleFilesLikeRubyDirectoryLoader s := NewSession() s.logger = captureLogger(&messages, nil, nil) - got, err := LoadExternalFacts(s, []string{dir}) + got, err := loadExternalFactsForTest(s, []string{dir}) if err != nil { - t.Fatalf("LoadExternalFacts(testSession) err = %v, want nil", err) + t.Fatalf("loadExternalFactsForTest(testSession) err = %v, want nil", err) } if len(got) != 0 { - t.Fatalf("LoadExternalFacts(testSession) = %#v, want no facts", got) + t.Fatalf("loadExternalFactsForTest(testSession) = %#v, want no facts", got) } want := []string{ fmt.Sprintf("Structured data fact file %s was parsed but was either empty or an invalid filetype (valid filetypes are .yaml, .json, and .txt).", path), @@ -955,12 +972,12 @@ func TestLoadExternalFacts_skipsRubyFactFileWithWarningNamingTheFile(t *testing. s := NewSession() s.logger = captureLogger(nil, &warnings, nil) - got, err := LoadExternalFacts(s, []string{dir}) + got, err := loadExternalFactsForTest(s, []string{dir}) if err != nil { - t.Fatalf("LoadExternalFacts(testSession) err = %v, want nil", err) + t.Fatalf("loadExternalFactsForTest(testSession) err = %v, want nil", err) } if len(got) != 0 { - t.Fatalf("LoadExternalFacts(testSession) = %#v, want Ruby fact file unread", got) + t.Fatalf("loadExternalFactsForTest(testSession) = %#v, want Ruby fact file unread", got) } want := []string{ fmt.Sprintf("Ruby fact files are not supported by the Go port; skipping %s. Rewrite it as an executable external fact (see docs/CUSTOM_FACT_MIGRATION.md).", path), @@ -1002,11 +1019,11 @@ func TestLoadExternalFacts_ignoresUnreadableStaticFactFiles(t *testing.T) { includeEnv: true, }.load() if err != nil { - t.Fatalf("LoadExternalFacts(testSession) err = %v, want nil", err) + t.Fatalf("loadExternalFactsForTest(testSession) err = %v, want nil", err) } want := []ResolvedFact{{Name: "site", Value: "lab", Type: "external"}} if !reflect.DeepEqual(got, want) { - t.Fatalf("LoadExternalFacts(testSession) = %#v, want %#v", got, want) + t.Fatalf("loadExternalFactsForTest(testSession) = %#v, want %#v", got, want) } } @@ -1023,13 +1040,13 @@ func TestLoadExternalFacts_ignoresMalformedStructuredFiles(t *testing.T) { } } - got, err := LoadExternalFacts(testSession, []string{dir}) + got, err := loadExternalFactsForTest(testSession, []string{dir}) if err != nil { - t.Fatalf("LoadExternalFacts(testSession) err = %v, want nil for malformed structured files", err) + t.Fatalf("loadExternalFactsForTest(testSession) err = %v, want nil for malformed structured files", err) } want := []ResolvedFact{{Name: "site", Value: "lab", Type: "external"}} if !reflect.DeepEqual(got, want) { - t.Fatalf("LoadExternalFacts(testSession) = %#v, want %#v", got, want) + t.Fatalf("loadExternalFactsForTest(testSession) = %#v, want %#v", got, want) } } @@ -1047,7 +1064,7 @@ func TestLoadExternalFacts_matchesExtensionsCaseInsensitively(t *testing.T) { } } - got, err := LoadExternalFacts(testSession, []string{dir}) + got, err := loadExternalFactsForTest(testSession, []string{dir}) if err != nil { t.Fatal(err) } @@ -1058,7 +1075,7 @@ func TestLoadExternalFacts_matchesExtensionsCaseInsensitively(t *testing.T) { "yml_fact": "loaded", } if len(got) != len(want) { - t.Fatalf("LoadExternalFacts(testSession) len = %d, want %d: %#v", len(got), len(want), got) + t.Fatalf("loadExternalFactsForTest(testSession) len = %d, want %d: %#v", len(got), len(want), got) } for _, fact := range got { if fact.Type != "external" { @@ -1089,13 +1106,13 @@ func TestLoadExternalFacts_ignoresHiddenAndBackupFiles(t *testing.T) { } } - got, err := LoadExternalFacts(testSession, []string{dir}) + got, err := loadExternalFactsForTest(testSession, []string{dir}) if err != nil { t.Fatal(err) } want := []ResolvedFact{{Name: "visible", Value: "true", Type: "external"}} if !reflect.DeepEqual(got, want) { - t.Fatalf("LoadExternalFacts(testSession) = %#v, want %#v", got, want) + t.Fatalf("loadExternalFactsForTest(testSession) = %#v, want %#v", got, want) } } @@ -1106,7 +1123,7 @@ func TestLoadExternalFacts_txtFactsNormalizeNamesAndPreserveValueWhitespace(t *t t.Fatal(err) } - got, err := LoadExternalFacts(testSession, []string{dir}) + got, err := loadExternalFactsForTest(testSession, []string{dir}) if err != nil { t.Fatal(err) } @@ -1115,7 +1132,7 @@ func TestLoadExternalFacts_txtFactsNormalizeNamesAndPreserveValueWhitespace(t *t {Name: "owner", Value: " platform team", Type: "external"}, } if !reflect.DeepEqual(got, want) { - t.Fatalf("LoadExternalFacts(testSession) = %#v, want %#v", got, want) + t.Fatalf("loadExternalFactsForTest(testSession) = %#v, want %#v", got, want) } } @@ -1126,13 +1143,13 @@ func TestLoadExternalFacts_txtFactsPreserveValueWhitespaceLikeRubyParser(t *test t.Fatal(err) } - got, err := LoadExternalFacts(testSession, []string{dir}) + got, err := loadExternalFactsForTest(testSession, []string{dir}) if err != nil { t.Fatal(err) } want := []ResolvedFact{{Name: "site", Value: " lab ", Type: "external"}} if !reflect.DeepEqual(got, want) { - t.Fatalf("LoadExternalFacts(testSession) = %#v, want %#v", got, want) + t.Fatalf("loadExternalFactsForTest(testSession) = %#v, want %#v", got, want) } } @@ -1143,7 +1160,7 @@ func TestLoadExternalFacts_txtFactsIgnoreUTF8BOM(t *testing.T) { t.Fatal(err) } - got, err := LoadExternalFacts(testSession, []string{dir}) + got, err := loadExternalFactsForTest(testSession, []string{dir}) if err != nil { t.Fatal(err) } @@ -1152,7 +1169,7 @@ func TestLoadExternalFacts_txtFactsIgnoreUTF8BOM(t *testing.T) { {Name: "owner", Value: "platform", Type: "external"}, } if !reflect.DeepEqual(got, want) { - t.Fatalf("LoadExternalFacts(testSession) = %#v, want %#v", got, want) + t.Fatalf("loadExternalFactsForTest(testSession) = %#v, want %#v", got, want) } } @@ -1166,7 +1183,7 @@ func TestLoadExternalFacts_executableKeyValueFacts(t *testing.T) { t.Fatal(err) } - got, err := LoadExternalFacts(testSession, []string{dir}) + got, err := loadExternalFactsForTest(testSession, []string{dir}) if err != nil { t.Fatal(err) } @@ -1175,7 +1192,7 @@ func TestLoadExternalFacts_executableKeyValueFacts(t *testing.T) { {Name: "script_three", Value: "four=five", Type: "external"}, } if !reflect.DeepEqual(got, want) { - t.Fatalf("LoadExternalFacts(testSession) = %#v, want %#v", got, want) + t.Fatalf("loadExternalFactsForTest(testSession) = %#v, want %#v", got, want) } } @@ -1212,7 +1229,7 @@ func TestLoadExternalFacts_executableScriptPathWithSpacesMatchesRubyParser(t *te } want := []ResolvedFact{{Name: "script_fact", Value: "loaded", Type: "external"}} if !reflect.DeepEqual(got, want) { - t.Fatalf("LoadExternalFacts(testSession) = %#v, want %#v", got, want) + t.Fatalf("loadExternalFactsForTest(testSession) = %#v, want %#v", got, want) } wantName := `"` + path + `"` if gotName := host.runCommandNames[0]; gotName != wantName { @@ -1233,12 +1250,12 @@ func TestLoadExternalFacts_skipsWindowsExecutableExtensionsOnNonWindows(t *testi } } - got, err := LoadExternalFacts(testSession, []string{dir}) + got, err := loadExternalFactsForTest(testSession, []string{dir}) if err != nil { t.Fatal(err) } if len(got) != 0 { - t.Fatalf("LoadExternalFacts(testSession) = %#v, want no facts from Windows executable extensions", got) + t.Fatalf("loadExternalFactsForTest(testSession) = %#v, want no facts from Windows executable extensions", got) } } @@ -1272,7 +1289,7 @@ func TestLoadExternalFacts_windowsScriptExtensionsDoNotRequireUnixExecutableBit( } want := []ResolvedFact{{Name: "win_fact", Value: "loaded", Type: "external"}} if !reflect.DeepEqual(got, want) { - t.Fatalf("LoadExternalFacts(testSession) = %#v, want %#v", got, want) + t.Fatalf("loadExternalFactsForTest(testSession) = %#v, want %#v", got, want) } if gotName := host.runCommandNames[0]; gotName != path { t.Fatalf("script command = %q, want %q", gotName, path) @@ -1307,7 +1324,7 @@ func TestLoadExternalFacts_windowsPowerShellFacts(t *testing.T) { } want := []ResolvedFact{{Name: "ps_fact", Value: "loaded", Type: "external"}} if !reflect.DeepEqual(got, want) { - t.Fatalf("LoadExternalFacts(testSession) = %#v, want %#v", got, want) + t.Fatalf("loadExternalFactsForTest(testSession) = %#v, want %#v", got, want) } if gotName := host.runCommandNames[0]; gotName != "powershell.exe" { t.Fatalf("PowerShell command = %q, want powershell.exe", gotName) @@ -1346,7 +1363,7 @@ func TestLoadExternalFacts_windowsPowerShellExtensionIsCaseInsensitiveLikeRubyPa } want := []ResolvedFact{{Name: "ps_fact", Value: "loaded", Type: "external"}} if !reflect.DeepEqual(got, want) { - t.Fatalf("LoadExternalFacts(testSession) = %#v, want %#v", got, want) + t.Fatalf("loadExternalFactsForTest(testSession) = %#v, want %#v", got, want) } wantArgs := []string{"-NoProfile", "-NonInteractive", "-NoLogo", "-ExecutionPolicy", "Bypass", "-File", path} if gotName := host.runCommandNames[0]; gotName != "powershell.exe" { @@ -1384,7 +1401,7 @@ func TestLoadExternalFacts_windowsPowerShellSkipsDirectories(t *testing.T) { t.Fatal(err) } if len(got) != 0 { - t.Fatalf("LoadExternalFacts(testSession) = %#v, want no facts from PowerShell directory", got) + t.Fatalf("loadExternalFactsForTest(testSession) = %#v, want no facts from PowerShell directory", got) } } @@ -1419,7 +1436,7 @@ func TestLoadExternalFacts_windowsPowerShellWarnsWithRubyCommand(t *testing.T) { } want := []ResolvedFact{{Name: "ps_fact", Value: "loaded", Type: "external"}} if !reflect.DeepEqual(got, want) { - t.Fatalf("LoadExternalFacts(testSession) = %#v, want %#v", got, want) + t.Fatalf("loadExternalFactsForTest(testSession) = %#v, want %#v", got, want) } wantWarning := "Command \"powershell.exe\" -NoProfile -NonInteractive -NoLogo -ExecutionPolicy Bypass -File \"" + path + "\" completed with the following stderr message: some error" if !reflect.DeepEqual(warnings, []string{wantWarning}) { @@ -1469,12 +1486,12 @@ func TestLoadExternalFacts_executableInvalidOrEmptyOutputReturnsNoFacts(t *testi t.Fatal(err) } - got, err := LoadExternalFacts(testSession, []string{dir}) + got, err := loadExternalFactsForTest(testSession, []string{dir}) if err != nil { t.Fatal(err) } if len(got) != 0 { - t.Fatalf("LoadExternalFacts(testSession) = %#v, want no facts", got) + t.Fatalf("loadExternalFactsForTest(testSession) = %#v, want no facts", got) } }) } @@ -1494,13 +1511,13 @@ func TestLoadExternalFacts_executableWarnsWhenCommandWritesStderr(t *testing.T) s := NewSession() s.logger = captureLogger(nil, &warnings, nil) - got, err := LoadExternalFacts(s, []string{dir}) + got, err := loadExternalFactsForTest(s, []string{dir}) if err != nil { t.Fatal(err) } want := []ResolvedFact{{Name: "script_one", Value: "two", Type: "external"}} if !reflect.DeepEqual(got, want) { - t.Fatalf("LoadExternalFacts(testSession) = %#v, want %#v", got, want) + t.Fatalf("loadExternalFactsForTest(testSession) = %#v, want %#v", got, want) } if len(warnings) != 1 { t.Fatalf("warnings = %#v, want one warning", warnings) @@ -1520,13 +1537,13 @@ func TestLoadExternalFacts_ignoresFailedExecutableFact(t *testing.T) { t.Fatal(err) } - got, err := LoadExternalFacts(testSession, []string{dir}) + got, err := loadExternalFactsForTest(testSession, []string{dir}) if err != nil { - t.Fatalf("LoadExternalFacts(testSession) err = %v, want nil for failed executable fact", err) + t.Fatalf("loadExternalFactsForTest(testSession) err = %v, want nil for failed executable fact", err) } want := []ResolvedFact{{Name: "site", Value: "lab", Type: "external"}} if !reflect.DeepEqual(got, want) { - t.Fatalf("LoadExternalFacts(testSession) = %#v, want %#v", got, want) + t.Fatalf("loadExternalFactsForTest(testSession) = %#v, want %#v", got, want) } } @@ -1544,13 +1561,13 @@ func TestLoadExternalFacts_timesOutHungExecutableFact(t *testing.T) { t.Fatal(err) } - got, err := LoadExternalFacts(testSession, []string{dir}) + got, err := loadExternalFactsForTest(testSession, []string{dir}) if err != nil { - t.Fatalf("LoadExternalFacts(testSession) err = %v, want nil for timed out executable fact", err) + t.Fatalf("loadExternalFactsForTest(testSession) err = %v, want nil for timed out executable fact", err) } want := []ResolvedFact{{Name: "site", Value: "lab", Type: "external"}} if !reflect.DeepEqual(got, want) { - t.Fatalf("LoadExternalFacts(testSession) = %#v, want %#v", got, want) + t.Fatalf("loadExternalFactsForTest(testSession) = %#v, want %#v", got, want) } } @@ -1720,7 +1737,7 @@ func TestLoadExternalFacts_executableYAMLFacts(t *testing.T) { t.Fatal(err) } - got, err := LoadExternalFacts(testSession, []string{dir}) + got, err := loadExternalFactsForTest(testSession, []string{dir}) if err != nil { t.Fatal(err) } @@ -1729,7 +1746,7 @@ func TestLoadExternalFacts_executableYAMLFacts(t *testing.T) { {Name: "script_three", Value: "four", Type: "external"}, } if !reflect.DeepEqual(got, want) { - t.Fatalf("LoadExternalFacts(testSession) = %#v, want %#v", got, want) + t.Fatalf("loadExternalFactsForTest(testSession) = %#v, want %#v", got, want) } } @@ -1743,7 +1760,7 @@ func TestLoadExternalFacts_executableYAMLSymbolFacts(t *testing.T) { t.Fatal(err) } - got, err := LoadExternalFacts(testSession, []string{dir}) + got, err := loadExternalFactsForTest(testSession, []string{dir}) if err != nil { t.Fatal(err) } @@ -1752,7 +1769,7 @@ func TestLoadExternalFacts_executableYAMLSymbolFacts(t *testing.T) { {Name: "script_three", Value: "four", Type: "external"}, } if !reflect.DeepEqual(got, want) { - t.Fatalf("LoadExternalFacts(testSession) = %#v, want %#v", got, want) + t.Fatalf("loadExternalFactsForTest(testSession) = %#v, want %#v", got, want) } } @@ -1766,13 +1783,13 @@ func TestLoadExternalFacts_executableYAMLTimestampNormalizesLikeRubyParser(t *te t.Fatal(err) } - got, err := LoadExternalFacts(testSession, []string{dir}) + got, err := loadExternalFactsForTest(testSession, []string{dir}) if err != nil { t.Fatal(err) } want := []ResolvedFact{{Name: "first", Value: "2020-07-15T05:38:12Z", Type: "external"}} if !reflect.DeepEqual(got, want) { - t.Fatalf("LoadExternalFacts(testSession) = %#v, want %#v", got, want) + t.Fatalf("loadExternalFactsForTest(testSession) = %#v, want %#v", got, want) } } @@ -1831,7 +1848,7 @@ func TestLoadExternalFacts_executableJSONFacts(t *testing.T) { t.Fatal(err) } - got, err := LoadExternalFacts(testSession, []string{dir}) + got, err := loadExternalFactsForTest(testSession, []string{dir}) if err != nil { t.Fatal(err) } @@ -1840,7 +1857,7 @@ func TestLoadExternalFacts_executableJSONFacts(t *testing.T) { {Name: "script_one", Value: "two", Type: "external"}, } if !reflect.DeepEqual(got, want) { - t.Fatalf("LoadExternalFacts(testSession) = %#v, want %#v", got, want) + t.Fatalf("loadExternalFactsForTest(testSession) = %#v, want %#v", got, want) } } @@ -1856,13 +1873,13 @@ func TestLoadExternalFacts_skipsExecutableFactsDuringRecursiveResolution(t *test t.Fatal(err) } - got, err := LoadExternalFacts(testSession, []string{dir}) + got, err := loadExternalFactsForTest(testSession, []string{dir}) if err != nil { t.Fatal(err) } want := []ResolvedFact{{Name: "static", Value: "true", Type: "external"}} if !reflect.DeepEqual(got, want) { - t.Fatalf("LoadExternalFacts(testSession) = %#v, want %#v", got, want) + t.Fatalf("loadExternalFactsForTest(testSession) = %#v, want %#v", got, want) } } @@ -1893,9 +1910,9 @@ func TestLoadExternalFacts_rejectsNullBytes(t *testing.T) { t.Fatal(err) } - _, err := LoadExternalFacts(testSession, []string{dir}) + _, err := loadExternalFactsForTest(testSession, []string{dir}) if !errors.Is(err, ErrNullByte) { - t.Fatalf("LoadExternalFacts(testSession) err = %v, want ErrNullByte", err) + t.Fatalf("loadExternalFactsForTest(testSession) err = %v, want ErrNullByte", err) } }) } @@ -1973,7 +1990,7 @@ func TestExternalFactGroupsSkipsMissingDirectories(t *testing.T) { } } -func BenchmarkLoadExternalFacts(b *testing.B) { +func BenchmarkLoadExternalFactsForTest(b *testing.B) { dir := b.TempDir() files := map[string]string{ "site.txt": "site=lab\nowner=platform=team\n", @@ -1989,12 +2006,12 @@ func BenchmarkLoadExternalFacts(b *testing.B) { b.ReportAllocs() for b.Loop() { - facts, err := LoadExternalFacts(testSession, []string{dir}) + facts, err := loadExternalFactsForTest(testSession, []string{dir}) if err != nil { b.Fatal(err) } if len(facts) != 7 { - b.Fatalf("LoadExternalFacts(testSession) len = %d, want 7", len(facts)) + b.Fatalf("loadExternalFactsForTest(testSession) len = %d, want 7", len(facts)) } } } diff --git a/internal/engine/filehelper.go b/internal/engine/filehelper.go deleted file mode 100644 index 33a40190..00000000 --- a/internal/engine/filehelper.go +++ /dev/null @@ -1,53 +0,0 @@ -package engine - -import ( - "log/slog" - "os" - "sort" - "strings" -) - -func safeRead(path string, defaultValue string, log *slog.Logger) (string, bool) { - data, err := os.ReadFile(path) - if err != nil { - debugFileNotAccessible(path, log) - return defaultValue, false - } - return string(data), true -} - -func safeReadLines(path string, defaultValue []string, log *slog.Logger) ([]string, bool) { - data, err := os.ReadFile(path) - if err != nil { - debugFileNotAccessible(path, log) - return defaultValue, false - } - if len(data) == 0 { - return []string{}, true - } - lines := strings.SplitAfter(string(data), "\n") - if lines[len(lines)-1] == "" { - lines = lines[:len(lines)-1] - } - return lines, true -} - -func debugFileNotAccessible(path string, log *slog.Logger) { - if log == nil { - log = slog.New(slog.DiscardHandler) - } - log.Debug("File at: " + path + " is not accessible.") -} - -func dirChildren(path string) ([]string, error) { - entries, err := os.ReadDir(path) - if err != nil { - return nil, err - } - names := make([]string, 0, len(entries)) - for _, entry := range entries { - names = append(names, entry.Name()) - } - sort.Strings(names) - return names, nil -} diff --git a/internal/engine/filehelper_test.go b/internal/engine/filehelper_test.go deleted file mode 100644 index 8833cd0d..00000000 --- a/internal/engine/filehelper_test.go +++ /dev/null @@ -1,149 +0,0 @@ -package engine - -import ( - "os" - "path/filepath" - "reflect" - "testing" -) - -func TestSafeRead_returnsFileContent(t *testing.T) { - path := filepath.Join(t.TempDir(), "fact.txt") - if err := os.WriteFile(path, []byte("file content"), 0o600); err != nil { - t.Fatal(err) - } - - got, ok := safeRead(path, "", discardLog()) - if !ok { - t.Fatal("safeRead() ok = false, want true") - } - if got != "file content" { - t.Fatalf("safeRead() = %q, want file content", got) - } -} - -func TestSafeRead_returnsDefaultForUnreadablePath(t *testing.T) { - path := filepath.Join(t.TempDir(), "missing.txt") - - got, ok := safeRead(path, "default", discardLog()) - if ok { - t.Fatal("safeRead() ok = true, want false") - } - if got != "default" { - t.Fatalf("safeRead() = %q, want default", got) - } -} - -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 - logger := captureLogger(&messages, nil, nil) - - got, ok := safeRead(path, "default", logger) - - if ok { - t.Fatal("safeRead() ok = true, want false") - } - if got != "default" { - t.Fatalf("safeRead() = %q, want default", got) - } - want := []string{"File at: " + path + " is not accessible."} - if !reflect.DeepEqual(messages, want) { - t.Fatalf("debug messages = %#v, want %#v", messages, want) - } -} - -func TestSafeReadLines_returnsFileLines(t *testing.T) { - path := filepath.Join(t.TempDir(), "fact.txt") - if err := os.WriteFile(path, []byte("line 1\nline 2\n"), 0o600); err != nil { - t.Fatal(err) - } - - got, ok := safeReadLines(path, nil, discardLog()) - if !ok { - t.Fatal("safeReadLines() ok = false, want true") - } - want := []string{"line 1\n", "line 2\n"} - if !reflect.DeepEqual(got, want) { - t.Fatalf("safeReadLines() = %#v, want %#v", got, want) - } -} - -func TestSafeReadLines_preservesFinalLineWithoutNewline(t *testing.T) { - path := filepath.Join(t.TempDir(), "fact.txt") - if err := os.WriteFile(path, []byte("line 1\nline 2"), 0o600); err != nil { - t.Fatal(err) - } - - got, ok := safeReadLines(path, nil, discardLog()) - if !ok { - t.Fatal("safeReadLines() ok = false, want true") - } - want := []string{"line 1\n", "line 2"} - if !reflect.DeepEqual(got, want) { - t.Fatalf("safeReadLines() = %#v, want %#v", got, want) - } -} - -func TestSafeReadLines_returnsDefaultForUnreadablePath(t *testing.T) { - path := filepath.Join(t.TempDir(), "missing.txt") - want := []string{"default"} - - got, ok := safeReadLines(path, want, discardLog()) - if ok { - t.Fatal("safeReadLines() ok = true, want false") - } - if !reflect.DeepEqual(got, want) { - t.Fatalf("safeReadLines() = %#v, want %#v", got, want) - } -} - -func TestSafeReadLines_logsDebugForUnreadablePathLikeRubyFileHelper(t *testing.T) { - path := filepath.Join(t.TempDir(), "missing.txt") - defaultLines := []string{"default"} - var messages []string - logger := captureLogger(&messages, nil, nil) - - got, ok := safeReadLines(path, defaultLines, logger) - - if ok { - t.Fatal("safeReadLines() ok = true, want false") - } - if !reflect.DeepEqual(got, defaultLines) { - t.Fatalf("safeReadLines() = %#v, want %#v", got, defaultLines) - } - want := []string{"File at: " + path + " is not accessible."} - if !reflect.DeepEqual(messages, want) { - t.Fatalf("debug messages = %#v, want %#v", messages, want) - } -} - -func TestDirChildren_returnsDirectoryEntries(t *testing.T) { - dir := t.TempDir() - for _, name := range []string{"file.txt", "a"} { - if err := os.WriteFile(filepath.Join(dir, name), nil, 0o600); err != nil { - t.Fatal(err) - } - } - - got, err := dirChildren(dir) - if err != nil { - t.Fatal(err) - } - want := []string{"a", "file.txt"} - if !reflect.DeepEqual(got, want) { - t.Fatalf("dirChildren() = %#v, want %#v", got, want) - } -} diff --git a/internal/engine/fips.go b/internal/engine/fips.go index 8f963af3..fd313def 100644 --- a/internal/engine/fips.go +++ b/internal/engine/fips.go @@ -1,7 +1,6 @@ package engine import ( - "runtime" "strconv" "strings" ) @@ -14,21 +13,22 @@ func fipsEnabled(path string, readFile fileReader) bool { return strings.TrimSpace(string(data)) == "1" } -func currentFIPSEnabled(goos, linuxPath string, run commandRunner, readFile fileReader) bool { - if goos == "windows" { - return parseWindowsFIPSEnabled(run("reg", "query", `HKLM\System\CurrentControlSet\Control\Lsa\FipsAlgorithmPolicy`, "/v", "Enabled")) +func currentFIPSEnabled(s *Session, linuxPath string) bool { + if s.goos() == "windows" { + return parseWindowsFIPSEnabled(s.commandOutput("reg", "query", `HKLM\System\CurrentControlSet\Control\Lsa\FipsAlgorithmPolicy`, "/v", "Enabled")) } - return fipsEnabled(linuxPath, readFile) + return fipsEnabled(linuxPath, s.readFile) } // fipsEnabledFacts resolves fips_enabled only on Linux and Windows, the // platforms where Ruby Facter emits the fact; elsewhere the fact is absent // instead of a placeholder false. -func fipsEnabledFacts(goos, linuxPath string, run commandRunner, readFile fileReader) []ResolvedFact { +func fipsEnabledFacts(s *Session, linuxPath string) []ResolvedFact { + goos := s.goos() if goos != "linux" && goos != "windows" { return nil } - return []ResolvedFact{{Name: "fips_enabled", Value: currentFIPSEnabled(goos, linuxPath, run, readFile)}} + return []ResolvedFact{{Name: "fips_enabled", Value: currentFIPSEnabled(s, linuxPath)}} } func parseWindowsFIPSEnabled(input string) bool { @@ -46,5 +46,5 @@ func parseWindowsFIPSEnabled(input string) bool { // fipsCoreFacts assembles the fips category fact (fips_enabled), emitted only on // Linux and Windows. func fipsCoreFacts(s *Session) []ResolvedFact { - return fipsEnabledFacts(runtime.GOOS, "/proc/sys/crypto/fips_enabled", s.commandOutput, s.readFile) + return fipsEnabledFacts(s, "/proc/sys/crypto/fips_enabled") } diff --git a/internal/engine/fips_test.go b/internal/engine/fips_test.go index 2f8aef24..618ce361 100644 --- a/internal/engine/fips_test.go +++ b/internal/engine/fips_test.go @@ -28,37 +28,44 @@ func TestCoreFacts_fipsEnabledOnlyOnLinuxAndWindows(t *testing.T) { func TestFIPSEnabledFacts_omittedOutsideLinuxAndWindows(t *testing.T) { t.Parallel() - run := func(name string, args ...string) string { - t.Fatalf("command = %s %#v, want no probe on a platform without the fact", name, args) - return "" - } for _, goos := range []string{"darwin", "freebsd", "openbsd", "netbsd", "solaris", "aix"} { - if got := fipsEnabledFacts(goos, "/proc/sys/crypto/fips_enabled", run, os.ReadFile); got != nil { + host := &fakeHostOS{platform: goos, emptyRunDefault: true} + s := NewSession() + s.host = host + if got := fipsEnabledFacts(s, "/proc/sys/crypto/fips_enabled"); got != nil { t.Fatalf("fipsEnabledFacts(%s) = %#v, want nil", goos, got) } + if len(host.runCalls) != 0 || len(host.readFileCalls) != 0 { + t.Fatalf("fipsEnabledFacts(%s) probed the host (runs=%v reads=%v), want no probe", goos, host.runCalls, host.readFileCalls) + } } } func TestFIPSEnabledFacts_resolveOnLinuxAndWindows(t *testing.T) { t.Parallel() - path := filepath.Join(t.TempDir(), "fips_enabled") - if err := os.WriteFile(path, []byte("1\n"), 0o600); err != nil { - t.Fatal(err) - } + const path = "/proc/sys/crypto/fips_enabled" + linuxHost := &fakeHostOS{platform: "linux", files: map[string][]byte{path: []byte("1\n")}} + linux := NewSession() + linux.host = linuxHost want := []ResolvedFact{{Name: "fips_enabled", Value: true}} - if got := fipsEnabledFacts("linux", path, nil, os.ReadFile); !reflect.DeepEqual(got, want) { + if got := fipsEnabledFacts(linux, path); !reflect.DeepEqual(got, want) { t.Fatalf("fipsEnabledFacts(linux) = %#v, want %#v", got, want) } - run := func(name string, args ...string) string { - return strings.Join([]string{ - `HKEY_LOCAL_MACHINE\System\CurrentControlSet\Control\Lsa\FipsAlgorithmPolicy`, - " Enabled REG_DWORD 0x0", - }, "\n") + windowsHost := &fakeHostOS{ + platform: "windows", + runOutputs: map[string]string{ + fakeRunKey("reg", "query", `HKLM\System\CurrentControlSet\Control\Lsa\FipsAlgorithmPolicy`, "/v", "Enabled"): strings.Join([]string{ + `HKEY_LOCAL_MACHINE\System\CurrentControlSet\Control\Lsa\FipsAlgorithmPolicy`, + " Enabled REG_DWORD 0x0", + }, "\n"), + }, } + windows := NewSession() + windows.host = windowsHost want = []ResolvedFact{{Name: "fips_enabled", Value: false}} - if got := fipsEnabledFacts("windows", "", run, os.ReadFile); !reflect.DeepEqual(got, want) { + if got := fipsEnabledFacts(windows, ""); !reflect.DeepEqual(got, want) { t.Fatalf("fipsEnabledFacts(windows) = %#v, want %#v", got, want) } } @@ -66,19 +73,26 @@ func TestFIPSEnabledFacts_resolveOnLinuxAndWindows(t *testing.T) { func TestCurrentFIPSEnabledReadsWindowsRegistry(t *testing.T) { t.Parallel() - run := func(name string, args ...string) string { - if name != "reg" || !reflect.DeepEqual(args, []string{"query", `HKLM\System\CurrentControlSet\Control\Lsa\FipsAlgorithmPolicy`, "/v", "Enabled"}) { - t.Fatalf("command = %s %#v", name, args) - } - return strings.Join([]string{ - `HKEY_LOCAL_MACHINE\System\CurrentControlSet\Control\Lsa\FipsAlgorithmPolicy`, - " Enabled REG_DWORD 0xff", - }, "\n") + host := &fakeHostOS{ + platform: "windows", + emptyRunDefault: true, + runOutputs: map[string]string{ + fakeRunKey("reg", "query", `HKLM\System\CurrentControlSet\Control\Lsa\FipsAlgorithmPolicy`, "/v", "Enabled"): strings.Join([]string{ + `HKEY_LOCAL_MACHINE\System\CurrentControlSet\Control\Lsa\FipsAlgorithmPolicy`, + " Enabled REG_DWORD 0xff", + }, "\n"), + }, } + s := NewSession() + s.host = host - if !currentFIPSEnabled("windows", "", run, os.ReadFile) { + if !currentFIPSEnabled(s, "") { t.Fatal("currentFIPSEnabled(windows) = false, want true") } + want := []fakeHostRunCall{{name: "reg", args: []string{"query", `HKLM\System\CurrentControlSet\Control\Lsa\FipsAlgorithmPolicy`, "/v", "Enabled"}}} + if !reflect.DeepEqual(host.runCalls, want) { + t.Fatalf("commands = %#v, want reg query", host.runCalls) + } } func TestParseWindowsFIPSEnabled(t *testing.T) { diff --git a/internal/engine/formatter.go b/internal/engine/formatter.go index f6730a7c..aeb43909 100644 --- a/internal/engine/formatter.go +++ b/internal/engine/formatter.go @@ -57,11 +57,6 @@ func BuildFormatter(opts FormatOptions) Formatter { } } -// FormatJSON renders facts using Facter's JSON presentation contract. -func FormatJSON(facts []ResolvedFact) (string, error) { - return FormatJSONWithDottedFacts(facts, false) -} - // FormatJSONWithDottedFacts renders JSON and optionally merges dotted custom and external facts. func FormatJSONWithDottedFacts(facts []ResolvedFact, includeTypedDotted bool) (string, error) { projection := NewProjection(facts, includeTypedDotted) @@ -77,11 +72,6 @@ func FormatJSONWithDottedFacts(facts []ResolvedFact, includeTypedDotted bool) (s return string(out), nil } -// FormatYAML renders facts using Facter's YAML presentation contract. -func FormatYAML(facts []ResolvedFact) string { - return FormatYAMLWithDottedFacts(facts, false) -} - // FormatYAMLWithDottedFacts renders YAML and optionally merges dotted custom and external facts. func FormatYAMLWithDottedFacts(facts []ResolvedFact, includeTypedDotted bool) string { projection := NewProjection(facts, includeTypedDotted) @@ -96,11 +86,6 @@ func FormatYAMLWithDottedFacts(facts []ResolvedFact, includeTypedDotted bool) st return out + "\n" } -// FormatHOCON renders facts using Facter's HOCON presentation contract. -func FormatHOCON(facts []ResolvedFact) string { - return FormatHOCONWithDottedFacts(facts, false) -} - // FormatHOCONWithDottedFacts renders HOCON and optionally merges dotted custom and external facts. func FormatHOCONWithDottedFacts(facts []ResolvedFact, includeTypedDotted bool) string { projection := NewProjection(facts, includeTypedDotted) @@ -121,11 +106,6 @@ func FormatHOCONWithDottedFacts(facts []ResolvedFact, includeTypedDotted bool) s } } -// FormatLegacy renders facts using the original key => value text format. -func FormatLegacy(facts []ResolvedFact) string { - return FormatLegacyColored(facts, false, false) -} - // FormatLegacyColored renders legacy text and, when colorize is set, wraps each // key in an ANSI color chosen by its nesting depth. The rendering replicates // Ruby Facter's LegacyFactFormatter byte for byte: pretty-printed JSON rewritten diff --git a/internal/engine/formatter_helpers_test.go b/internal/engine/formatter_helpers_test.go new file mode 100644 index 00000000..e5883ae4 --- /dev/null +++ b/internal/engine/formatter_helpers_test.go @@ -0,0 +1,26 @@ +package engine + +// These four zero-flag formatter helpers had one production caller — the +// version fast path — which now routes through BuildFormatter. They survive +// only as terse test conveniences over the *WithDottedFacts/*Colored variants, +// so the production build no longer exports them. + +// FormatJSON renders facts using Facter's JSON presentation contract. +func FormatJSON(facts []ResolvedFact) (string, error) { + return FormatJSONWithDottedFacts(facts, false) +} + +// FormatYAML renders facts using Facter's YAML presentation contract. +func FormatYAML(facts []ResolvedFact) string { + return FormatYAMLWithDottedFacts(facts, false) +} + +// FormatHOCON renders facts using Facter's HOCON presentation contract. +func FormatHOCON(facts []ResolvedFact) string { + return FormatHOCONWithDottedFacts(facts, false) +} + +// FormatLegacy renders facts using the original key => value text format. +func FormatLegacy(facts []ResolvedFact) string { + return FormatLegacyColored(facts, false, false) +} diff --git a/internal/engine/gce.go b/internal/engine/gce.go index d28d3912..faa434ca 100644 --- a/internal/engine/gce.go +++ b/internal/engine/gce.go @@ -3,7 +3,6 @@ package engine import ( "context" "encoding/json" - "io" "net/http" "strings" "time" @@ -12,7 +11,6 @@ import ( const ( gceMetadataBaseURL = "http://metadata.google.internal/computeMetadata/v1" gceRequestTimeout = 100 * time.Millisecond - gceMaxBodyBytes = 1 << 20 ) type gceClient struct { @@ -22,30 +20,11 @@ type gceClient struct { func newGCEClient(baseURL string, httpClient *http.Client) *gceClient { if httpClient == nil { - httpClient = &http.Client{ - Timeout: gceRequestTimeout, - Transport: &http.Transport{ - Proxy: nil, - }, - } + httpClient = newMetadataHTTPClient(gceRequestTimeout) } return &gceClient{baseURL: strings.TrimRight(baseURL, "/"), httpClient: httpClient} } -func gceFacts(ctx context.Context, client *gceClient) []ResolvedFact { - if client == nil { - return nil - } - metadata := client.metadata(ctx) - if len(metadata) == 0 { - return nil - } - return []ResolvedFact{ - {Name: "gce", Value: metadata}, - {Name: "cloud.provider", Value: "gce"}, - } -} - func linuxGCEFacts(ctx context.Context, goos, biosVendor string, client *gceClient) []ResolvedFact { if goos != "linux" { return nil @@ -159,23 +138,12 @@ func lastPathSegment(value string) string { } func (gc *gceClient) get(ctx context.Context, path string) (string, bool) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, gc.baseURL+"/"+path, nil) - if err != nil { - return "", false - } - req.Header.Set("Metadata-Flavor", "Google") - req.Header.Set("Accept", "application/json") - resp, err := gc.httpClient.Do(req) - if err != nil { - return "", false - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK || resp.Header.Get("Metadata-Flavor") != "Google" { - return "", false - } - data, err := io.ReadAll(io.LimitReader(resp.Body, gceMaxBodyBytes)) - if err != nil { + body, header, ok := fetchMetadata(ctx, gc.httpClient, http.MethodGet, gc.baseURL+"/"+path, map[string]string{ + "Metadata-Flavor": "Google", + "Accept": "application/json", + }) + if !ok || header.Get("Metadata-Flavor") != "Google" { return "", false } - return strings.TrimSpace(string(data)), true + return strings.TrimSpace(body), true } diff --git a/internal/engine/gce_test.go b/internal/engine/gce_test.go index 085c6519..16a316aa 100644 --- a/internal/engine/gce_test.go +++ b/internal/engine/gce_test.go @@ -17,7 +17,7 @@ func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) } -func TestGCEFactsFetchRecursiveMetadataAndNormalizeInstance(t *testing.T) { +func TestLinuxGCEFactsFetchRecursiveMetadataAndNormalizeInstance(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()) @@ -43,7 +43,7 @@ func TestGCEFactsFetchRecursiveMetadataAndNormalizeInstance(t *testing.T) { })) t.Cleanup(server.Close) - facts := gceFacts(context.Background(), newGCEClient(server.URL, server.Client())) + facts := linuxGCEFacts(context.Background(), "linux", "Google", newGCEClient(server.URL, server.Client())) got := factValues(facts) metadata, ok := got["gce"].(map[string]any) @@ -79,7 +79,7 @@ func TestGCEFactsFetchRecursiveMetadataAndNormalizeInstance(t *testing.T) { } } -func TestGCEFactsSendsAcceptJSONHeaderLikeRubyResolver(t *testing.T) { +func TestLinuxGCEFactsSendsAcceptJSONHeaderLikeRubyResolver(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if got := r.Header.Get("Accept"); got != "application/json" { t.Fatalf("Accept = %q, want application/json", got) @@ -89,39 +89,37 @@ func TestGCEFactsSendsAcceptJSONHeaderLikeRubyResolver(t *testing.T) { })) t.Cleanup(server.Close) - got := factValues(gceFacts(context.Background(), newGCEClient(server.URL, server.Client()))) + got := factValues(linuxGCEFacts(context.Background(), "linux", "Google", newGCEClient(server.URL, server.Client()))) if got["gce"] == nil { t.Fatalf("gce fact = %#v, want metadata", got["gce"]) } } -func TestGCEFactsSkipInvalidMetadata(t *testing.T) { +func TestLinuxGCEFactsSkipInvalidMetadata(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Metadata-Flavor", "Google") _, _ = w.Write([]byte(`not json`)) })) t.Cleanup(server.Close) - if got := gceFacts(context.Background(), newGCEClient(server.URL, server.Client())); len(got) != 0 { - t.Fatalf("gceFacts(context.Background(), ) = %#v, want no facts for invalid metadata", got) + got := linuxGCEFacts(context.Background(), "linux", "Google", newGCEClient(server.URL, server.Client())) + want := []ResolvedFact{{Name: "gce", Value: nil}} + if !reflect.DeepEqual(got, want) { + t.Fatalf("linuxGCEFacts(invalid metadata) = %#v, want %#v", got, want) } } -func TestGCEFactsRequireGoogleMetadataFlavor(t *testing.T) { +func TestLinuxGCEFactsRequireGoogleMetadataFlavor(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Metadata-Flavor", "NotGoogle") _, _ = w.Write([]byte(`{"some":"metadata"}`)) })) t.Cleanup(server.Close) - if got := gceFacts(context.Background(), newGCEClient(server.URL, server.Client())); len(got) != 0 { - t.Fatalf("gceFacts(context.Background(), ) = %#v, want no facts for spoofed metadata flavor", got) - } -} - -func TestGCEFactsSkipNilClient(t *testing.T) { - if got := gceFacts(context.Background(), nil); got != nil { - t.Fatalf("gceFacts(nil client) = %#v, want nil", got) + got := linuxGCEFacts(context.Background(), "linux", "Google", newGCEClient(server.URL, server.Client())) + want := []ResolvedFact{{Name: "gce", Value: nil}} + if !reflect.DeepEqual(got, want) { + t.Fatalf("linuxGCEFacts(spoofed metadata flavor) = %#v, want %#v", got, want) } } diff --git a/internal/engine/groups.go b/internal/engine/groups.go index 85d452e7..452b541d 100644 --- a/internal/engine/groups.go +++ b/internal/engine/groups.go @@ -203,9 +203,18 @@ func DisabledFactsWithGroups(entries []string, configured []FactGroup) map[strin return disabled } -// DisabledFactsForFiltering expands the disabled set for resolver filtering. -func DisabledFactsForFiltering(entries []string, configured []FactGroup) map[string]bool { - return DisabledFactsWithGroups(entries, configured) +// DisabledUnion is the disabled-fact set both the version fast path and +// discovery planning derive from: the config disable/blocklist list, the +// --disable extraDisabled entries, and the FACTS_DISABLE control from environ, +// each expanded through the config's fact groups. Deriving both callers from +// this one function is what guarantees the fast path takes effect exactly when a +// full discovery would omit the queried fact. Pass a nil environ to exclude the +// environment source (the library default when SystemDefaults is off). +func DisabledUnion(config Config, extraDisabled []string, environ []string) map[string]bool { + entries := append([]string(nil), config.Disabled...) + entries = append(entries, extraDisabled...) + entries = append(entries, environmentDisabledFacts(environ)...) + return DisabledFactsWithGroups(entries, config.FactGroups) } // FilterDisabledFacts removes facts whose root name is disabled. diff --git a/internal/engine/identity.go b/internal/engine/identity.go index c8f14bde..f35983d3 100644 --- a/internal/engine/identity.go +++ b/internal/engine/identity.go @@ -1,10 +1,8 @@ package engine import ( - "log/slog" "os" osuser "os/user" - "runtime" "strconv" "strings" ) @@ -18,10 +16,13 @@ type identityInfo struct { } func identityFact(s *Session) map[string]any { - if runtime.GOOS == "windows" { - return identityFactFromInfo(runtime.GOOS, currentWindowsIdentityInfo(s.commandOutput, s.logr())) + goos := s.goos() + if goos == "windows" { + return identityFactFromInfo(goos, currentWindowsIdentityInfo(s)) } + // The uid/gid/osuser syscalls stay outside the host seam: they have no + // meaningful fake and describe the resolving process, not the probed host. privileged := os.Geteuid() == 0 info := identityInfo{ UID: strconv.Itoa(os.Getuid()), @@ -30,7 +31,7 @@ func identityFact(s *Session) map[string]any { } current, err := osuser.Current() if err != nil { - return identityFactFromInfo(runtime.GOOS, info) + return identityFactFromInfo(goos, info) } info.UID = current.Uid info.GID = current.Gid @@ -38,20 +39,17 @@ func identityFact(s *Session) map[string]any { if group, err := osuser.LookupGroupId(current.Gid); err == nil { info.Group = group.Name } - return identityFactFromInfo(runtime.GOOS, info) + return identityFactFromInfo(goos, info) } -func currentWindowsIdentityInfo(run commandRunner, log *slog.Logger) identityInfo { +func currentWindowsIdentityInfo(s *Session) identityInfo { info := identityInfo{} - if run == nil { - return info - } - info.User = strings.TrimSpace(run("whoami")) + info.User = strings.TrimSpace(s.commandOutput("whoami")) if info.User == "" { - log.Debug("failure resolving identity facts: ") + s.logr().Debug("failure resolving identity facts: ") return info } - if privileged, ok := parseWindowsAdministratorGroups(run("whoami", "/groups")); ok { + if privileged, ok := parseWindowsAdministratorGroups(s.commandOutput("whoami", "/groups")); ok { info.Privileged = &privileged } return info diff --git a/internal/engine/identity_test.go b/internal/engine/identity_test.go index a35d1d2b..fa27f76b 100644 --- a/internal/engine/identity_test.go +++ b/internal/engine/identity_test.go @@ -31,23 +31,22 @@ func TestIdentityFactFromInfoWindowsOmitsPOSIXFields(t *testing.T) { func TestCurrentWindowsIdentityInfoUsesWhoamiCommands(t *testing.T) { t.Parallel() - run := func(name string, args ...string) string { - switch { - case name == "whoami" && len(args) == 0: - return `MG93C9IN9WKOITF\Administrator` - case name == "whoami" && reflect.DeepEqual(args, []string{"/groups"}): - return strings.Join([]string{ + host := &fakeHostOS{ + platform: "windows", + emptyRunDefault: true, + runOutputs: map[string]string{ + fakeRunKey("whoami"): `MG93C9IN9WKOITF\Administrator`, + fakeRunKey("whoami", "/groups"): strings.Join([]string{ `Group Name Type SID Attributes`, `========================================== ================ ============ ===============================================`, `BUILTIN\Administrators Alias S-1-5-32-544 Mandatory group, Enabled by default, Enabled group`, - }, "\n") - default: - t.Fatalf("run = %s %v, want whoami or whoami /groups", name, args) - return "" - } + }, "\n"), + }, } + s := NewSession() + s.host = host - got := currentWindowsIdentityInfo(run, discardLog()) + got := currentWindowsIdentityInfo(s) if got.User != `MG93C9IN9WKOITF\Administrator` { t.Fatalf("User = %q, want administrator", got.User) } @@ -60,16 +59,21 @@ func TestCurrentWindowsIdentityInfoLogsFailureWhenUserCannotResolveLikeRubyResol debugMessages := []string{} logger := captureLogger(&debugMessages, nil, nil) - got := currentWindowsIdentityInfo(func(name string, args ...string) string { - if name != "whoami" || len(args) != 0 { - t.Fatalf("run = %s %v, want only whoami", name, args) - } - return "" - }, logger) + host := &fakeHostOS{platform: "windows", emptyRunDefault: true} + s := NewSession() + s.host = host + s.logger = logger + + got := currentWindowsIdentityInfo(s) if got.User != "" { t.Fatalf("User = %q, want empty", got.User) } + // A missing user short-circuits before the group query. + wantCalls := []fakeHostRunCall{{name: "whoami", args: nil}} + if !reflect.DeepEqual(host.runCalls, wantCalls) { + t.Fatalf("commands = %#v, want only whoami", host.runCalls) + } if got.Privileged != nil { t.Fatalf("Privileged = %#v, want nil", got.Privileged) } diff --git a/internal/engine/memory.go b/internal/engine/memory.go index a8b6b4a5..42be365a 100644 --- a/internal/engine/memory.go +++ b/internal/engine/memory.go @@ -23,13 +23,6 @@ type windowsMemory struct { Capacity string } -func currentWindowsMemory(goos string, run commandRunner, log *slog.Logger) windowsMemory { - if goos != "windows" { - return windowsMemory{} - } - return parseWindowsMemory(windowsWMIOutput(run, "os", "FreePhysicalMemory,TotalVisibleMemorySize"), log) -} - func parseWindowsMemory(input string, log *slog.Logger) windowsMemory { if strings.TrimSpace(input) == "" { log.Debug("Resolving memory facts failed") @@ -146,7 +139,10 @@ func probeSwapEncrypted(s *Session) bool { } func probeWindowsMemory(s *Session) windowsMemory { - return currentWindowsMemory(s.goos(), s.commandOutput, s.logr()) + if s.goos() != "windows" { + return windowsMemory{} + } + return parseWindowsMemory(windowsWMIOutput(s.commandOutput, "os", "FreePhysicalMemory,TotalVisibleMemorySize"), s.logr()) } type darwinSwapUsage struct { @@ -465,14 +461,10 @@ func parseIllumosKToken(value string) int { } func probeDarwinSwapUsage(s *Session) darwinSwapUsage { - return currentDarwinSwapUsage(s.goos(), s.commandOutput) -} - -func currentDarwinSwapUsage(goos string, run commandRunner) darwinSwapUsage { - if goos != "darwin" { + if s.goos() != "darwin" { return darwinSwapUsage{} } - return parseDarwinSwapUsage(run("sysctl", "-n", "vm.swapusage")) + return parseDarwinSwapUsage(s.commandOutput("sysctl", "-n", "vm.swapusage")) } func probeLinuxMeminfo(s *Session) string { diff --git a/internal/engine/memory_test.go b/internal/engine/memory_test.go index b84fa20a..c3663eac 100644 --- a/internal/engine/memory_test.go +++ b/internal/engine/memory_test.go @@ -72,19 +72,19 @@ func TestParseWindowsMemoryLogsFailureDiagnosticLikeRubyResolver(t *testing.T) { } } -func TestCurrentWindowsMemoryRunsOnlyOnWindows(t *testing.T) { +func TestProbeWindowsMemoryRunsOnlyOnWindows(t *testing.T) { t.Parallel() - called := false - got := currentWindowsMemory("linux", func(name string, args ...string) string { - called = true - return "" - }, discardLog()) + host := &fakeHostOS{platform: "linux"} + s := NewSession() + s.host = host + + got := probeWindowsMemory(s) if got != (windowsMemory{}) { - t.Fatalf("currentWindowsMemory(non-windows) = %#v, want empty", got) + t.Fatalf("probeWindowsMemory(non-windows) = %#v, want empty", got) } - if called { - t.Fatal("currentWindowsMemory(non-windows) ran command") + if len(host.runCalls) != 0 { + t.Fatal("probeWindowsMemory(non-windows) ran command") } } @@ -299,16 +299,19 @@ func TestParseDarwinMemoryAmountBytes(t *testing.T) { } } -func TestCurrentDarwinSwapUsageMatchesRubyResolver(t *testing.T) { +func TestProbeDarwinSwapUsageMatchesRubyResolver(t *testing.T) { t.Parallel() - got := currentDarwinSwapUsage("darwin", func(name string, args ...string) string { - if name != "sysctl" || !reflect.DeepEqual(args, []string{"-n", "vm.swapusage"}) { - t.Fatalf("run = %s %v, want sysctl -n vm.swapusage", name, args) - } - return "total = 3072.00M used = 1422.75M free = 1649.25M (encrypted)" - }) + host := &fakeHostOS{ + platform: "darwin", + runOutputs: map[string]string{ + fakeRunKey("sysctl", "-n", "vm.swapusage"): "total = 3072.00M used = 1422.75M free = 1649.25M (encrypted)", + }, + } + s := NewSession() + s.host = host + got := probeDarwinSwapUsage(s) want := darwinSwapUsage{ TotalBytes: 3_221_225_472, UsedBytes: 1_491_861_504, @@ -316,23 +319,24 @@ func TestCurrentDarwinSwapUsageMatchesRubyResolver(t *testing.T) { Encrypted: true, } if got != want { - t.Fatalf("currentDarwinSwapUsage() = %#v, want %#v", got, want) + t.Fatalf("probeDarwinSwapUsage() = %#v, want %#v", got, want) + } + if want := []fakeHostRunCall{{name: "sysctl", args: []string{"-n", "vm.swapusage"}}}; !reflect.DeepEqual(host.runCalls, want) { + t.Fatalf("run calls = %#v, want %#v", host.runCalls, want) } } 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) + host := &fakeHostOS{platform: "linux"} + s := NewSession() + s.host = host + if got := probeDarwinSwapUsage(s); got != (darwinSwapUsage{}) { + t.Fatalf("probeDarwinSwapUsage(non-darwin) = %#v, want empty", got) } - if called { - t.Fatal("currentDarwinSwapUsage(non-darwin) ran command") + if len(host.runCalls) != 0 { + t.Fatal("probeDarwinSwapUsage(non-darwin) ran command") } if got := parseDarwinVMStatAvailableBytes("Pages free: 10.\n"); got != 0 { diff --git a/internal/engine/metadatahttp.go b/internal/engine/metadatahttp.go new file mode 100644 index 00000000..81c76229 --- /dev/null +++ b/internal/engine/metadatahttp.go @@ -0,0 +1,51 @@ +package engine + +import ( + "context" + "io" + "net/http" + "time" +) + +// metadataMaxBodyBytes caps every cloud metadata response read. The link-local +// metadata endpoints return small documents; the cap fails closed against a +// hostile or misrouted endpoint streaming an unbounded body. +const metadataMaxBodyBytes = 1 << 20 + +// newMetadataHTTPClient builds the proxy-less client every cloud provider uses +// for link-local metadata. Proxy is nil so a configured HTTP(S)_PROXY cannot +// redirect a 169.254.x.x metadata request off-host. +func newMetadataHTTPClient(timeout time.Duration) *http.Client { + return &http.Client{ + Timeout: timeout, + Transport: &http.Transport{Proxy: nil}, + } +} + +// fetchMetadata performs one metadata request and fails closed: any transport +// error, a non-200 status, or a body read error yields ok=false. The body is +// capped at metadataMaxBodyBytes and returned untrimmed (callers trim as their +// contract requires). respHeader is returned so callers can enforce a required +// response header (GCE's Metadata-Flavor echo); it is nil when ok is false. +func fetchMetadata(ctx context.Context, client *http.Client, method, url string, headers map[string]string) (string, http.Header, bool) { + req, err := http.NewRequestWithContext(ctx, method, url, nil) + if err != nil { + return "", nil, false + } + for name, value := range headers { + req.Header.Set(name, value) + } + resp, err := client.Do(req) + if err != nil { + return "", nil, false + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "", nil, false + } + data, err := io.ReadAll(io.LimitReader(resp.Body, metadataMaxBodyBytes)) + if err != nil { + return "", nil, false + } + return string(data), resp.Header, true +} diff --git a/internal/engine/metadatahttp_test.go b/internal/engine/metadatahttp_test.go new file mode 100644 index 00000000..e83255eb --- /dev/null +++ b/internal/engine/metadatahttp_test.go @@ -0,0 +1,95 @@ +package engine + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func TestNewMetadataHTTPClientIsProxyLess(t *testing.T) { + client := newMetadataHTTPClient(3 * time.Second) + if client.Timeout != 3*time.Second { + t.Fatalf("Timeout = %v, want 3s", client.Timeout) + } + transport, ok := client.Transport.(*http.Transport) + if !ok { + t.Fatalf("Transport = %T, want *http.Transport", client.Transport) + } + // A nil Proxy func means link-local metadata requests cannot be redirected + // off-host by a configured HTTP(S)_PROXY. + if transport.Proxy != nil { + t.Fatal("Transport.Proxy != nil, want proxy-less client") + } +} + +func TestFetchMetadataReturnsBodyAndHeadersOn200(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("X-Token"); got != "secret" { + t.Fatalf("X-Token = %q, want secret (headers not passed through)", got) + } + w.Header().Set("Metadata-Flavor", "Google") + _, _ = w.Write([]byte(" raw-body ")) + })) + t.Cleanup(server.Close) + + body, header, ok := fetchMetadata(context.Background(), server.Client(), http.MethodGet, server.URL, map[string]string{"X-Token": "secret"}) + if !ok { + t.Fatal("ok = false, want true for 200") + } + // The body is returned untrimmed; trimming is the caller's contract. + if body != " raw-body " { + t.Fatalf("body = %q, want untrimmed raw-body", body) + } + if header.Get("Metadata-Flavor") != "Google" { + t.Fatalf("response header Metadata-Flavor = %q, want Google", header.Get("Metadata-Flavor")) + } +} + +func TestFetchMetadataFailsClosedOnNon200(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "nope", http.StatusForbidden) + })) + t.Cleanup(server.Close) + + body, header, ok := fetchMetadata(context.Background(), server.Client(), http.MethodGet, server.URL, nil) + if ok || body != "" || header != nil { + t.Fatalf("fetchMetadata(403) = (%q, %v, %v), want fail-closed", body, header, ok) + } +} + +func TestFetchMetadataFailsClosedOnRequestBuildError(t *testing.T) { + // A control character in the URL makes http.NewRequestWithContext fail + // before any network call. + body, header, ok := fetchMetadata(context.Background(), http.DefaultClient, http.MethodGet, "http://\x7f/bad", nil) + if ok || body != "" || header != nil { + t.Fatalf("fetchMetadata(bad url) = (%q, %v, %v), want fail-closed", body, header, ok) + } +} + +func TestFetchMetadataFailsClosedOnTransportError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + url := server.URL + server.Close() // nothing is listening now + + if _, _, ok := fetchMetadata(context.Background(), server.Client(), http.MethodGet, url, nil); ok { + t.Fatal("ok = true, want fail-closed on transport error") + } +} + +func TestFetchMetadataCapsBodyAtOneMebibyte(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(strings.Repeat("a", metadataMaxBodyBytes+4096))) + })) + t.Cleanup(server.Close) + + body, _, ok := fetchMetadata(context.Background(), server.Client(), http.MethodGet, server.URL, nil) + if !ok { + t.Fatal("ok = false, want true") + } + if len(body) != metadataMaxBodyBytes { + t.Fatalf("body length = %d, want capped at %d", len(body), metadataMaxBodyBytes) + } +} diff --git a/internal/engine/networking.go b/internal/engine/networking.go index b7a9eca6..bd907166 100644 --- a/internal/engine/networking.go +++ b/internal/engine/networking.go @@ -184,8 +184,8 @@ func networkingInterfacesForPlatform(s *Session, goos string, snapshotProvider f addLinuxDHCPServersFromSnapshots(s, values, snapshots) addLinuxRouteSourceBindings(s, values) addLinuxIfInet6Flags(values, parseLinuxIfInet6Flags(readText("/proc/net/if_inet6", s.readFile))) - addLinuxBondingSlaveMACsFromRootWithReader("/", values, s.readFile) - addLinuxInterfaceMetadataFromRootWithHost("/", values, s.host) + addLinuxBondingSlaveMACs(values, s.host) + addLinuxInterfaceMetadata(values, s.host) } return values } @@ -1066,17 +1066,13 @@ func addLinuxIfInet6Flags(interfaces map[string]any, flags map[string]map[string } } -func addLinuxInterfaceMetadataFromRoot(root string, interfaces map[string]any) { - addLinuxInterfaceMetadataFromRootWithHost(root, interfaces, osHost{}) -} - -func addLinuxInterfaceMetadataFromRootWithHost(root string, interfaces map[string]any, host hostOS) { +func addLinuxInterfaceMetadata(interfaces map[string]any, host hostOS) { for name, raw := range interfaces { iface, ok := raw.(map[string]any) if !ok { continue } - ifaceRoot := rootedPath(root, filepath.Join("sys/class/net", name)) + ifaceRoot := filepath.Join("/sys/class/net", name) if state := strings.TrimSpace(readText(filepath.Join(ifaceRoot, "operstate"), host.readFile)); state != "" { iface["operational_state"] = state } @@ -1091,12 +1087,8 @@ func addLinuxInterfaceMetadataFromRootWithHost(root string, interfaces map[strin } } -func addLinuxBondingSlaveMACsFromRoot(root string, interfaces map[string]any) { - addLinuxBondingSlaveMACsFromRootWithReader(root, interfaces, osHost{}.readFile) -} - -func addLinuxBondingSlaveMACsFromRootWithReader(root string, interfaces map[string]any, readFile fileReader) { - entries, err := os.ReadDir(rootedPath(root, "proc/net/bonding")) +func addLinuxBondingSlaveMACs(interfaces map[string]any, host hostOS) { + entries, err := host.readDir("/proc/net/bonding") if err != nil { return } @@ -1104,7 +1096,7 @@ func addLinuxBondingSlaveMACsFromRootWithReader(root string, interfaces map[stri if entry.IsDir() { continue } - for slave, mac := range parseLinuxBondingSlaveMACs(readText(rootedPath(root, filepath.Join("proc/net/bonding", entry.Name())), readFile)) { + for slave, mac := range parseLinuxBondingSlaveMACs(readText(filepath.Join("/proc/net/bonding", entry.Name()), host.readFile)) { iface, ok := interfaces[slave].(map[string]any) if ok { iface["mac"] = mac @@ -1132,42 +1124,26 @@ func parseLinuxBondingSlaveMACs(content string) map[string]string { } func linuxDHCPServer(s *Session, interfaceName string, interfaceIndex int) string { - return linuxDHCPServerFromRoot(s, "/", interfaceName, interfaceIndex) -} - -func linuxDHCPServerFromRoot(s *Session, root, interfaceName string, interfaceIndex int) string { - return linuxDHCPServerFromRootWithHost(root, interfaceName, interfaceIndex, s.commandOutput, s.host) -} - -func linuxDHCPServerFromRootWithRunner(root, interfaceName string, interfaceIndex int, run commandRunner) string { - return linuxDHCPServerFromRootWithHost(root, interfaceName, interfaceIndex, run, osHost{}) -} - -func linuxDHCPServerFromRootWithHost(root, interfaceName string, interfaceIndex int, run commandRunner, host hostOS) string { if interfaceIndex > 0 { - leasePath := rootedPath(root, filepath.Join("run/systemd/netif/leases", strconv.Itoa(interfaceIndex))) - if server := linuxSystemdDHCPServer(readText(leasePath, host.readFile)); server != "" { + leasePath := filepath.Join("/run/systemd/netif/leases", strconv.Itoa(interfaceIndex)) + if server := linuxSystemdDHCPServer(readText(leasePath, s.readFile)); server != "" { return server } } - for _, dir := range []string{"var/lib/dhclient", "var/lib/dhcp", "var/lib/dhcp3", "var/lib/NetworkManager", "var/db"} { - server := linuxDHCPServerFromLeaseDirWithReader(rootedPath(root, dir), interfaceName, host.readFile) + for _, dir := range []string{"/var/lib/dhclient", "/var/lib/dhcp", "/var/lib/dhcp3", "/var/lib/NetworkManager", "/var/db"} { + server := linuxDHCPServerFromLeaseDir(dir, interfaceName, s.host) if server != "" { return server } } - if server := linuxDHCPCDDHCPServer(run("dhcpcd", "-U", interfaceName)); server != "" { + if server := linuxDHCPCDDHCPServer(s.commandOutput("dhcpcd", "-U", interfaceName)); server != "" { return server } return "" } -func linuxDHCPServerFromLeaseDir(dir, interfaceName string) string { - return linuxDHCPServerFromLeaseDirWithReader(dir, interfaceName, osHost{}.readFile) -} - -func linuxDHCPServerFromLeaseDirWithReader(dir, interfaceName string, readFile fileReader) string { - entries, err := os.ReadDir(dir) +func linuxDHCPServerFromLeaseDir(dir, interfaceName string, host hostOS) string { + entries, err := host.readDir(dir) if err != nil { return "" } @@ -1176,7 +1152,7 @@ func linuxDHCPServerFromLeaseDirWithReader(dir, interfaceName string, readFile f if entry.IsDir() || !strings.Contains(name, "lease") { continue } - content := readText(filepath.Join(dir, name), readFile) + content := readText(filepath.Join(dir, name), host.readFile) if server, matched, explicit := linuxDHClientDHCPServerForInterfaceState(content, interfaceName); explicit { if matched { return server diff --git a/internal/engine/networking_test.go b/internal/engine/networking_test.go index c70c6c7e..6f58d6fe 100644 --- a/internal/engine/networking_test.go +++ b/internal/engine/networking_test.go @@ -6,10 +6,33 @@ import ( "os" "path/filepath" "reflect" + "sort" "strings" "testing" ) +// newLeaseDirHost builds a fakeHostOS that exposes dir as a directory of plain +// lease files, sorted like os.ReadDir so lease-iteration-order assertions hold. +// It replaces the former t.TempDir lease fixtures with the Session host seam. +func newLeaseDirHost(dir string, files map[string]string) *fakeHostOS { + names := make([]string, 0, len(files)) + blobs := make(map[string][]byte, len(files)) + for name, content := range files { + names = append(names, name) + // Key by forward slash: production joins with filepath.Join (backslash on + // Windows) but fakeHostOS.readFile normalizes lookups through + // fakeHostPath (filepath.ToSlash), so fixtures must use slashes to match + // on Windows as well as unix. + blobs[dir+"/"+name] = []byte(content) + } + sort.Strings(names) + return &fakeHostOS{ + emptyRunDefault: true, + dirs: map[string][]os.DirEntry{dir: fakeFileEntries(names...)}, + files: blobs, + } +} + func TestNetworkingDHCPFactUsesPrimaryInterfaceDHCPValue(t *testing.T) { t.Parallel() @@ -802,20 +825,23 @@ func TestAddLinuxIfInet6FlagsIgnoresMalformedInterfacesAndBindings(t *testing.T) func TestAddLinuxInterfaceMetadata_matchesRubyNetworkingResolver(t *testing.T) { t.Parallel() - root := t.TempDir() - writeFile(t, filepath.Join(root, "sys/class/net/lo/operstate"), "unknown\n") - writeFile(t, filepath.Join(root, "sys/class/net/ens160/operstate"), "up\n") - writeFile(t, filepath.Join(root, "sys/class/net/ens160/speed"), "1000\n") - writeFile(t, filepath.Join(root, "sys/class/net/ens160/duplex"), "full\n") - if err := os.MkdirAll(filepath.Join(root, "sys/class/net/ens160/device"), 0o755); err != nil { - t.Fatal(err) + host := &fakeHostOS{ + files: map[string][]byte{ + "/sys/class/net/lo/operstate": []byte("unknown\n"), + "/sys/class/net/ens160/operstate": []byte("up\n"), + "/sys/class/net/ens160/speed": []byte("1000\n"), + "/sys/class/net/ens160/duplex": []byte("full\n"), + }, + stats: map[string]os.FileInfo{ + "/sys/class/net/ens160/device": fakeFileInfo{name: "device", isDir: true}, + }, } interfaces := map[string]any{ "lo": map[string]any{"mtu": 65536}, "ens160": map[string]any{"mtu": 1500}, } - addLinuxInterfaceMetadataFromRoot(root, interfaces) + addLinuxInterfaceMetadata(interfaces, host) lo := interfaces["lo"].(map[string]any) if got := lo["operational_state"]; got != "unknown" { @@ -1688,29 +1714,43 @@ func TestCurrentNetworkingDataIgnoresInvalidDarwinDHCPServer(t *testing.T) { func TestLinuxDHCPServerReadsSystemdNetifLease(t *testing.T) { t.Parallel() - root := t.TempDir() - writeFile(t, filepath.Join(root, "run/systemd/netif/leases/2"), "CLIENT_ID=01:23\nSERVER_ADDRESS=10.16.122.163\n") + host := &fakeHostOS{ + files: map[string][]byte{ + "/run/systemd/netif/leases/2": []byte("CLIENT_ID=01:23\nSERVER_ADDRESS=10.16.122.163\n"), + }, + } + s := NewSession() + s.host = host - if got, want := linuxDHCPServerFromRoot(testSession, root, "eth0", 2), "10.16.122.163"; got != want { - t.Fatalf("linuxDHCPServerFromRoot(testSession) = %q, want %q", got, want) + if got, want := linuxDHCPServer(s, "eth0", 2), "10.16.122.163"; got != want { + t.Fatalf("linuxDHCPServer() = %q, want %q", got, want) } } func TestLinuxDHCPServerReadsDHClientLeaseForInterface(t *testing.T) { t.Parallel() - root := t.TempDir() - writeFile(t, filepath.Join(root, "var/lib/dhcp/dhclient.eth0.lease"), `lease { + host := &fakeHostOS{ + emptyRunDefault: true, + dirs: map[string][]os.DirEntry{ + "/var/lib/dhcp": fakeFileEntries("dhclient.en1.lease", "dhclient.eth0.lease"), + }, + files: map[string][]byte{ + "/var/lib/dhcp/dhclient.eth0.lease": []byte(`lease { interface "eth0"; option dhcp-server-identifier 10.32.10.163; -}`) - writeFile(t, filepath.Join(root, "var/lib/dhcp/dhclient.en1.lease"), `lease { +}`), + "/var/lib/dhcp/dhclient.en1.lease": []byte(`lease { interface "en1"; option dhcp-server-identifier 10.99.99.99; -}`) +}`), + }, + } + s := NewSession() + s.host = host - if got, want := linuxDHCPServerFromRoot(testSession, root, "eth0", 0), "10.32.10.163"; got != want { - t.Fatalf("linuxDHCPServerFromRoot(testSession) = %q, want %q", got, want) + if got, want := linuxDHCPServer(s, "eth0", 0), "10.32.10.163"; got != want { + t.Fatalf("linuxDHCPServer() = %q, want %q", got, want) } } @@ -1733,12 +1773,13 @@ func TestLinuxDHCPCDDHCPServer(t *testing.T) { func TestLinuxDHCPServerFromLeaseDirReadsMatchingLease(t *testing.T) { t.Parallel() - dir := t.TempDir() - writeFile(t, filepath.Join(dir, "00-commented.lease"), `lease { + dir := "/var/lib/dhcp" + host := newLeaseDirHost(dir, map[string]string{ + "00-commented.lease": `lease { # interface "eth0"; option dhcp-server-identifier 10.66.66.66; -}`) - writeFile(t, filepath.Join(dir, "dhclient.leases"), `lease { +}`, + "dhclient.leases": `lease { interface "eth0-backup"; option dhcp-server-identifier 10.99.99.98; } @@ -1749,17 +1790,18 @@ lease { lease { interface "eth0.100"; option dhcp-server-identifier 10.99.99.97; -}`) - writeFile(t, filepath.Join(dir, "dhclient.en1.lease"), `lease { +}`, + "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`) +}`, + "not-a-lease.txt": `SERVER_ADDRESS=192.0.2.1`, + }) - if got, want := linuxDHCPServerFromLeaseDir(dir, "eth0"), "10.32.10.163"; got != want { + if got, want := linuxDHCPServerFromLeaseDir(dir, "eth0", host), "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 { + if got, want := linuxDHCPServerFromLeaseDir(dir, "eth0-backup", host), "10.99.99.98"; got != want { t.Fatalf("linuxDHCPServerFromLeaseDir(eth0-backup) = %q, want %q", got, want) } } @@ -1767,14 +1809,16 @@ lease { func TestLinuxDHCPServerFromLeaseDirUsesFilenameFallbackWhenInterfaceValueMalformed(t *testing.T) { t.Parallel() - dir := t.TempDir() - writeFile(t, filepath.Join(dir, "dhclient.eth0.lease"), `lease { + dir := "/var/lib/dhcp" + host := newLeaseDirHost(dir, map[string]string{ + "dhclient.eth0.lease": `lease { interface "broken option host-name "router"; option dhcp-server-identifier 10.32.10.163; -}`) +}`, + }) - if got, want := linuxDHCPServerFromLeaseDir(dir, "eth0"), "10.32.10.163"; got != want { + if got, want := linuxDHCPServerFromLeaseDir(dir, "eth0", host), "10.32.10.163"; got != want { t.Fatalf("linuxDHCPServerFromLeaseDir() = %q, want filename fallback server %q", got, want) } } @@ -1782,13 +1826,15 @@ func TestLinuxDHCPServerFromLeaseDirUsesFilenameFallbackWhenInterfaceValueMalfor func TestLinuxDHCPServerFromLeaseDirDoesNotUseFilenameFallbackForUnrelatedExplicitInterface(t *testing.T) { t.Parallel() - dir := t.TempDir() - writeFile(t, filepath.Join(dir, "dhclient.eth0.lease"), `lease { + dir := "/var/lib/dhcp" + host := newLeaseDirHost(dir, map[string]string{ + "dhclient.eth0.lease": `lease { interface "eth1"; option dhcp-server-identifier 10.99.99.99; -}`) +}`, + }) - if got := linuxDHCPServerFromLeaseDir(dir, "eth0"); got != "" { + if got := linuxDHCPServerFromLeaseDir(dir, "eth0", host); got != "" { t.Fatalf("linuxDHCPServerFromLeaseDir() = %q, want empty for explicit non-matching interface", got) } } @@ -1796,18 +1842,20 @@ func TestLinuxDHCPServerFromLeaseDirDoesNotUseFilenameFallbackForUnrelatedExplic func TestLinuxDHCPServerFromLeaseDirStopsAtMatchingLeaseWithoutServer(t *testing.T) { t.Parallel() - dir := t.TempDir() - writeFile(t, filepath.Join(dir, "dhclient.eth0.lease"), `lease { + dir := "/var/lib/dhcp" + host := newLeaseDirHost(dir, map[string]string{ + "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 { +}`, + "zz-dhclient.eth0.lease": `lease { interface "eth0"; option dhcp-server-identifier 10.32.10.163; -}`) +}`, + }) - if got := linuxDHCPServerFromLeaseDir(dir, "eth0"); got != "" { + if got := linuxDHCPServerFromLeaseDir(dir, "eth0", host); got != "" { t.Fatalf("linuxDHCPServerFromLeaseDir() = %q, want empty latest matching lease server", got) } } @@ -2078,16 +2126,19 @@ lease { 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. + host := newLeaseDirHost("/var/lib/NetworkManager", map[string]string{ + "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 -`) +`, + "internal-fdgh45-345356fg-dfg-dsfge5er4-sdfghgf45ty-eth0.lease": `SERVER_ADDRESS=10.99.99.99 +`, + }) + s := NewSession() + s.host = host - 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 := linuxDHCPServer(s, "lo", 1), "35.32.82.9"; got != want { + t.Fatalf("linuxDHCPServer() = %q, want %q", got, want) } } @@ -2124,20 +2175,27 @@ func TestAddLinuxDHCPServersFromSnapshotsAddsInterfaceDHCP(t *testing.T) { 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") + host := &fakeHostOS{ + emptyRunDefault: true, + runOutputs: map[string]string{ + fakeRunKey("dhcpcd", "-U", "ens160"): strings.Join([]string{ + "broadcast_address='10.16.127.255'", + "dhcp_server_identifier='10.32.22.9'", + "domain_name='delivery.puppetlabs.net'", + }, "\n"), + }, } + s := NewSession() + s.host = host - if got, want := linuxDHCPServerFromRootWithRunner(root, "ens160", 1, run), "10.32.22.9"; got != want { - t.Fatalf("linuxDHCPServerFromRootWithRunner() = %q, want %q", got, want) + if got, want := linuxDHCPServer(s, "ens160", 1), "10.32.22.9"; got != want { + t.Fatalf("linuxDHCPServer() = %q, want %q", got, want) + } + // The server is derivable only from the dhcpcd fallback, so it must have + // run with exactly the interface flag. + wantCall := fakeHostRunCall{name: "dhcpcd", args: []string{"-U", "ens160"}} + if len(host.runCalls) != 1 || !reflect.DeepEqual(host.runCalls[0], wantCall) { + t.Fatalf("run calls = %#v, want exactly %#v", host.runCalls, wantCall) } } diff --git a/internal/engine/os.go b/internal/engine/os.go index e742c55d..77e32034 100644 --- a/internal/engine/os.go +++ b/internal/engine/os.go @@ -2,7 +2,6 @@ package engine import ( "log/slog" - "os" "os/exec" "regexp" "runtime" @@ -187,14 +186,10 @@ func probeHardwareModel(s *Session) string { } func probeMacOSModel(s *Session) string { - return currentMacOSModel(s.goos(), s.commandOutput) -} - -func currentMacOSModel(goos string, run commandRunner) string { - if goos != "darwin" { + if s.goos() != "darwin" { return "" } - return strings.TrimSpace(run("sysctl", "-n", "hw.model")) + return strings.TrimSpace(s.commandOutput("sysctl", "-n", "hw.model")) } func probeOSRelease(s *Session) any { @@ -205,7 +200,7 @@ func probeOSRelease(s *Session) any { } return nil } - return currentOSRelease(s, goos, s.readFile, s.commandOutput) + return currentOSRelease(s) } func probeWindowsOSVersionInput(s *Session) string { @@ -215,19 +210,20 @@ func probeWindowsOSVersionInput(s *Session) string { return windowsWMIOutput(s.commandOutput, "os", "OtherTypeDescription,ProductType,Version") } -func currentOSRelease(s *Session, goos string, readFile fileReader, run commandRunner) any { +func currentOSRelease(s *Session) any { + goos := s.goos() profile, ok := targets.Lookup(goos) if !ok || !profile.Capabilities.OSRelease { return nil } switch goos { case "linux": - data, err := readFile("/etc/os-release") + data, err := s.readFile("/etc/os-release") if err != nil { return s.cachedKernelRelease() } id := linuxOSReleaseID(string(data)) - if release := specificLinuxOSRelease(id, readFile, run); len(release) > 0 { + if release := specificLinuxOSRelease(id, s.readFile, s.commandOutput); len(release) > 0 { return release } if release := parseLinuxOSRelease(string(data)); len(release) > 0 { @@ -235,26 +231,26 @@ func currentOSRelease(s *Session, goos string, readFile fileReader, run commandR } return nil case "freebsd": - versions := parseFreeBSDVersions(run("/bin/freebsd-version", "-k"), run("/bin/freebsd-version", "-ru")) + versions := parseFreeBSDVersions(s.commandOutput("/bin/freebsd-version", "-k"), s.commandOutput("/bin/freebsd-version", "-ru")) if versions.InstalledUserland != "" { return parseFreeBSDOSRelease(versions.InstalledUserland) } case "openbsd": - return parseOpenBSDOSRelease(run("uname", "-r")) + return parseOpenBSDOSRelease(s.commandOutput("uname", "-r")) case "netbsd": - return parseOpenBSDOSRelease(run("uname", "-r")) + return parseOpenBSDOSRelease(s.commandOutput("uname", "-r")) case "dragonfly": - return parseOpenBSDOSRelease(run("uname", "-r")) + return parseOpenBSDOSRelease(s.commandOutput("uname", "-r")) case "illumos": - if release := parseIllumosRelease(readFileString("/etc/release", readFile)).Release; len(release) > 0 { + if release := parseIllumosRelease(readFileString("/etc/release", s.readFile)).Release; len(release) > 0 { return release } case "plan9": return nil case "darwin": - return parseDarwinOSRelease(run("uname", "-r")) + return parseDarwinOSRelease(s.commandOutput("uname", "-r")) case "windows": - if release := currentWindowsOSRelease(windowsWMIOutput(run, "os", "OtherTypeDescription,ProductType,Version")); len(release) > 0 { + if release := currentWindowsOSRelease(windowsWMIOutput(s.commandOutput, "os", "OtherTypeDescription,ProductType,Version")); len(release) > 0 { return release } return nil @@ -751,14 +747,10 @@ type macOSInfo struct { } func probeMacOSInfo(s *Session) macOSInfo { - return currentMacOSInfo(s.goos(), s.commandOutput) -} - -func currentMacOSInfo(goos string, run commandRunner) macOSInfo { - if goos != "darwin" { + if s.goos() != "darwin" { return macOSInfo{} } - return parseSwVers(run("sw_vers")) + return parseSwVers(s.commandOutput("sw_vers")) } func parseSwVers(input string) macOSInfo { @@ -848,14 +840,10 @@ func probeMacOSSystemProfilerSoftware(s *Session) macOSSystemProfilerSoftware { } func probeMacOSSystemProfilerEthernet(s *Session) macOSSystemProfilerEthernet { - return currentMacOSSystemProfilerEthernet(s.goos(), s.commandOutput) -} - -func currentMacOSSystemProfilerEthernet(goos string, run commandRunner) macOSSystemProfilerEthernet { - if goos != "darwin" || run == nil { + if s.goos() != "darwin" { return macOSSystemProfilerEthernet{} } - return parseMacOSSystemProfilerEthernet(run("system_profiler", "SPEthernetDataType")) + return parseMacOSSystemProfilerEthernet(s.commandOutput("system_profiler", "SPEthernetDataType")) } func parseMacOSSystemProfilerHardware(input string) macOSSystemProfilerHardware { @@ -1081,10 +1069,6 @@ func currentWindowsSystem32(goos, systemRoot string, isWOW64 func() (bool, bool) return systemRoot + `\system32` } -func currentWindowsProcessWOW64() (bool, bool) { - return os.Getenv("PROCESSOR_ARCHITEW6432") != "", true -} - func windowsSystem32Facts(path string) []ResolvedFact { if path == "" { return nil @@ -1193,30 +1177,31 @@ type linuxDistro struct { } func probeLinuxDistro(s *Session) linuxDistro { - return currentLinuxDistro(runtime.GOOS, exec.LookPath, s.commandOutput, s.readFile) + return currentLinuxDistro(s, exec.LookPath) } -func currentLinuxDistro(goos string, lookPath func(string) (string, error), run commandRunner, readFile fileReader) linuxDistro { +func currentLinuxDistro(s *Session, lookPath func(string) (string, error)) linuxDistro { + goos := s.goos() if goos != "linux" { return linuxDistro{} } lsbDistro := linuxDistro{} if _, err := lookPath("lsb_release"); err == nil { - out := run("lsb_release", "-a") + out := s.commandOutput("lsb_release", "-a") if out != "" { lsbDistro = parseLSBRelease(out) } } - data, err := readFile("/etc/os-release") + data, err := s.readFile("/etc/os-release") if err != nil { if linuxDistroHasData(lsbDistro) { return lsbDistro } - return currentSuseRelease(readFile) + return currentSuseRelease(s.readFile) } distro := parseLinuxDistroOSRelease(string(data)) if usesRedHatReleaseDistro(distro.ID) { - if redHat := currentRedHatRelease(readFile); redHat.ID != "" || redHat.Description != "" || redHat.Codename != "" || len(redHat.Release) > 0 { + if redHat := currentRedHatRelease(s.readFile); redHat.ID != "" || redHat.Description != "" || redHat.Codename != "" || len(redHat.Release) > 0 { distro = mergeRedHatDistro(distro, redHat) } if linuxDistroHasData(lsbDistro) { @@ -1226,12 +1211,12 @@ func currentLinuxDistro(goos string, lookPath func(string) (string, error), run return lsbDistro } if strings.EqualFold(distro.ID, "amzn") && distro.Release["full"] == "2023" { - if version := amazonOSReleaseRPMVersion(run); version != "" { + if version := amazonOSReleaseRPMVersion(s.commandOutput); version != "" { distro.Release = releaseHashFromString(version, true) } } if strings.EqualFold(distro.ID, "amzn") { - if systemRelease := currentAmazonSystemRelease(readFile); systemRelease.Description != "" { + if systemRelease := currentAmazonSystemRelease(s.readFile); systemRelease.Description != "" { distro.ID = systemRelease.ID distro.Description = systemRelease.Description distro.Codename = systemRelease.Codename @@ -1669,7 +1654,7 @@ func linuxDistroFacts(distro linuxDistro) []ResolvedFact { } func probeFilesystems(s *Session) []string { - return currentFilesystems(runtime.GOOS, s.readFile, s.commandOutput) + return currentFilesystems(s) } // filesystemsFacts returns the filesystems fact as an array of filesystem @@ -1683,18 +1668,16 @@ func filesystemsFacts(value []string) []ResolvedFact { return []ResolvedFact{{Name: "filesystems", Value: value}} } -func currentFilesystems(goos string, readFile fileReader, run commandRunner) []string { +func currentFilesystems(s *Session) []string { + goos := s.goos() if profile, ok := targets.Lookup(goos); ok && !profile.Capabilities.Filesystems { return nil } switch goos { case "darwin": - if run == nil { - return nil - } - return parseDarwinFilesystems(run("mount")) + return parseDarwinFilesystems(s.commandOutput("mount")) case "linux": - data, err := readFile("/proc/filesystems") + data, err := s.readFile("/proc/filesystems") if err != nil { return nil } @@ -1817,16 +1800,17 @@ func kernelFacts(name, release, version string) []ResolvedFact { // os name/family/release/architecture/hardware, filesystems, the Linux distro // facts, and the macOS and Windows OS-description facts) for the current host. func osCoreFacts(s *Session) []ResolvedFact { + goos := s.goos() hardwareModel := s.cachedHardwareModel() architecture := s.cachedArchitectureName() linuxDistro := s.cachedLinuxDistro() - if runtime.GOOS == "illumos" { + if goos == "illumos" { linuxDistro.Name = parseIllumosRelease(readFileString("/etc/release", s.readFile)).Name } - osFamily := osFamily(runtime.GOOS, linuxDistro) - osName := osName(runtime.GOOS, linuxDistro) - kernelName := kernelName(runtime.GOOS) - if runtime.GOOS == "plan9" { + osFamily := osFamily(goos, linuxDistro) + osName := osName(goos, linuxDistro) + kernelName := kernelName(goos) + if goos == "plan9" { return []ResolvedFact{ {Name: "os.architecture", Value: architecture}, {Name: "os.family", Value: osFamily}, @@ -1837,8 +1821,8 @@ func osCoreFacts(s *Session) []ResolvedFact { } kernelRelease := s.cachedKernelRelease() osRelease := s.cachedOSRelease() - kernelVersion := kernelVersionFact(runtime.GOOS, kernelRelease, "") - if runtime.GOOS == "windows" { + kernelVersion := kernelVersionFact(goos, kernelRelease, "") + if goos == "windows" { if name, release, version, ok := currentWindowsKernel(s.cachedWindowsOSVersionInput(), s.logr()); ok { kernelName = name kernelRelease = release @@ -1862,7 +1846,9 @@ func osCoreFacts(s *Session) []ResolvedFact { facts = append(facts, macOSSystemProfilerFacts(s.cachedMacOSSystemProfilerHardware())...) facts = append(facts, macOSSystemProfilerSoftwareFacts(s.cachedMacOSSystemProfilerSoftware())...) facts = append(facts, macOSSystemProfilerEthernetFacts(s.cachedMacOSSystemProfilerEthernet())...) - facts = append(facts, windowsSystem32Facts(currentWindowsSystem32(runtime.GOOS, os.Getenv("SystemRoot"), currentWindowsProcessWOW64))...) - facts = append(facts, windowsProductReleaseFacts(currentWindowsProductRelease(runtime.GOOS, s.commandOutput))...) + facts = append(facts, windowsSystem32Facts(currentWindowsSystem32(goos, s.getenv("SystemRoot"), func() (bool, bool) { + return s.getenv("PROCESSOR_ARCHITEW6432") != "", true + }))...) + facts = append(facts, windowsProductReleaseFacts(currentWindowsProductRelease(goos, s.commandOutput))...) return facts } diff --git a/internal/engine/os_test.go b/internal/engine/os_test.go index aa77c43a..f8f452e3 100644 --- a/internal/engine/os_test.go +++ b/internal/engine/os_test.go @@ -1,9 +1,9 @@ package engine import ( + "context" "net" "os" - "path/filepath" "reflect" "runtime" "strings" @@ -138,34 +138,41 @@ func TestWindowsReleaseFinderMatchesRuby(t *testing.T) { func TestCurrentOSReleaseWindowsUsesKernelAndDescriptionData(t *testing.T) { t.Parallel() - run := func(name string, args ...string) string { - if name != "wmic" { - t.Fatalf("command = %q %v, want wmic", name, args) - } - return "OtherTypeDescription=\r\nProductType=1\r\nVersion=10.0.22631\r\n" + 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 := currentOSRelease(testSession, "windows", nil, run) + got := currentOSRelease(s) want := map[string]any{"full": "11", "major": "11"} if !reflect.DeepEqual(got, want) { - t.Fatalf("currentOSRelease(testSession, windows) = %#v, want %#v", got, want) + t.Fatalf("currentOSRelease(windows) = %#v, want %#v", got, want) + } + wantCalls := []fakeHostRunCall{{name: "wmic", args: []string{"os", "get", "OtherTypeDescription,ProductType,Version", "/value"}}} + if !reflect.DeepEqual(host.runCalls, wantCalls) { + t.Fatalf("run calls = %#v, want %#v", host.runCalls, wantCalls) } } 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 { + host := &fakeHostOS{platform: goos} + s := NewSessionContext(t.Context()) + s.host = host + + if got := currentOSRelease(s); got != nil { t.Fatalf("currentOSRelease(%s) = %#v, want nil", goos, got) } + if len(host.readFileCalls) != 0 { + t.Fatalf("currentOSRelease(%s) read files %#v, want none for unsupported platform", goos, host.readFileCalls) + } + if len(host.runCalls) != 0 { + t.Fatalf("currentOSRelease(%s) ran commands %#v, want none for unsupported platform", goos, host.runCalls) + } } } @@ -173,37 +180,36 @@ 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) { + // No files fixture: /etc/os-release is absent (linux) and + // freebsd-version returns "" via emptyRunDefault, so both + // platforms fall through to the memoized kernel release + // (uname -r). s := NewSessionContext(t.Context()) s.host = &fakeHostOS{ - platform: tt.platform, + platform: tt.platform, + emptyRunDefault: true, runOutputs: map[string]string{ fakeRunKey("uname", "-r"): tt.want + "\n", }, } - if got := currentOSRelease(s, tt.platform, tt.readFile, tt.run); got != tt.want { + if got := currentOSRelease(s); got != tt.want { t.Fatalf("currentOSRelease(%s) = %#v, want %q", tt.platform, got, tt.want) } }) @@ -213,20 +219,17 @@ func TestCurrentOSReleaseFallsBackToKernelRelease(t *testing.T) { 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" + host := &fakeHostOS{ + platform: "freebsd", + runOutputs: map[string]string{ + fakeRunKey("/bin/freebsd-version", "-k"): "13.0-CURRENT\n", + fakeRunKey("/bin/freebsd-version", "-ru"): "12.1-RELEASE-p3\n12.0-STABLE\n", + }, } + s := NewSessionContext(t.Context()) + s.host = host - got := currentOSRelease(testSession, "freebsd", nil, run) + got := currentOSRelease(s) want := map[string]any{ "full": "12.0-STABLE", "major": "12", @@ -236,18 +239,25 @@ func TestCurrentOSReleaseFreeBSDUsesInstalledUserlandVersion(t *testing.T) { if !reflect.DeepEqual(got, want) { t.Fatalf("currentOSRelease(freebsd) = %#v, want %#v", got, want) } + wantCalls := []fakeHostRunCall{ + {name: "/bin/freebsd-version", args: []string{"-k"}}, + {name: "/bin/freebsd-version", args: []string{"-ru"}}, + } + if !reflect.DeepEqual(host.runCalls, wantCalls) { + t.Fatalf("run calls = %#v, want %#v", host.runCalls, wantCalls) + } } func TestCurrentOSReleaseWindowsReturnsNilWithoutVersion(t *testing.T) { - calls := 0 - got := currentOSRelease(testSession, "windows", nil, func(name string, args ...string) string { - calls++ - return "" - }) + host := &fakeHostOS{platform: "windows", emptyRunDefault: true} + s := NewSessionContext(t.Context()) + s.host = host + + got := currentOSRelease(s) if got != nil { t.Fatalf("currentOSRelease(windows empty version) = %#v, want nil", got) } - if calls == 0 { + if len(host.runCalls) == 0 { t.Fatal("currentOSRelease(windows empty version) did not query version data") } } @@ -327,19 +337,21 @@ func TestMacOSProbesUseSessionPlatform(t *testing.T) { } func TestMacOSCurrentHelpersSkipNonDarwin(t *testing.T) { - run := func(string, ...string) string { - t.Fatal("macOS helper ran command outside Darwin") - return "" - } + host := &fakeHostOS{platform: "linux"} + s := NewSessionContext(t.Context()) + s.host = host - if got := currentMacOSModel("linux", run); got != "" { - t.Fatalf("currentMacOSModel(linux) = %q, want empty", got) + if got := probeMacOSModel(s); got != "" { + t.Fatalf("probeMacOSModel(linux) = %q, want empty", got) + } + if got := probeMacOSInfo(s); got != (macOSInfo{}) { + t.Fatalf("probeMacOSInfo(linux) = %#v, want empty", got) } - if got := currentMacOSInfo("linux", run); got != (macOSInfo{}) { - t.Fatalf("currentMacOSInfo(linux) = %#v, want empty", got) + if got := probeMacOSSystemProfilerEthernet(s); got != (macOSSystemProfilerEthernet{}) { + t.Fatalf("probeMacOSSystemProfilerEthernet(linux) = %#v, want empty", got) } - if got := currentMacOSSystemProfilerEthernet("linux", run); got != (macOSSystemProfilerEthernet{}) { - t.Fatalf("currentMacOSSystemProfilerEthernet(linux) = %#v, want empty", got) + if len(host.runCalls) != 0 { + t.Fatalf("macOS helpers ran commands %#v, want none outside Darwin", host.runCalls) } } @@ -548,21 +560,6 @@ 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() @@ -578,23 +575,29 @@ func TestWindowsSystem32FactsReturnStructuredFacts(t *testing.T) { func TestAddLinuxBondingSlaveMACsUsesPermanentHardwareAddress(t *testing.T) { t.Parallel() - root := t.TempDir() - writeFile(t, filepath.Join(root, "proc/net/bonding/bond0"), strings.Join([]string{ - "Ethernet Channel Bonding Driver: v3.7.1 (April 27, 2011)", - "", - "Slave Interface: eth2", - "Permanent HW addr: 08:00:27:29:dc:a5", - "", - "Slave Interface: eth3", - "Permanent HW addr: 08:00:27:d5:44:7e", - }, "\n")) + host := &fakeHostOS{ + // Bonding proc entries are plain files; a directory entry would be + // skipped by the production loop. + dirs: map[string][]os.DirEntry{"/proc/net/bonding": fakeFileEntries("bond0")}, + files: map[string][]byte{ + "/proc/net/bonding/bond0": []byte(strings.Join([]string{ + "Ethernet Channel Bonding Driver: v3.7.1 (April 27, 2011)", + "", + "Slave Interface: eth2", + "Permanent HW addr: 08:00:27:29:dc:a5", + "", + "Slave Interface: eth3", + "Permanent HW addr: 08:00:27:d5:44:7e", + }, "\n")), + }, + } interfaces := map[string]any{ "bond0": map[string]any{"mac": "08:00:27:29:dc:a5"}, "eth2": map[string]any{"mac": "08:00:27:29:dc:a5"}, "eth3": map[string]any{"mac": "08:00:27:29:dc:a5"}, } - addLinuxBondingSlaveMACsFromRoot(root, interfaces) + addLinuxBondingSlaveMACs(interfaces, host) eth3 := interfaces["eth3"].(map[string]any) if got, want := eth3["mac"], "08:00:27:d5:44:7e"; got != want { @@ -722,16 +725,21 @@ func TestWindowsOSNameFamilyHardwareAndArchitectureMatchRubyFacts(t *testing.T) } func TestCurrentOSReleaseOpenBSDUsesKernelReleaseMap(t *testing.T) { - got := currentOSRelease(testSession, "openbsd", nil, func(name string, args ...string) string { - if name != "uname" || !reflect.DeepEqual(args, []string{"-r"}) { - t.Fatalf("command = %s %#v, want uname -r", name, args) - } - return "7.2\n" - }) + host := &fakeHostOS{ + platform: "openbsd", + runOutputs: map[string]string{fakeRunKey("uname", "-r"): "7.2\n"}, + } + s := NewSessionContext(t.Context()) + s.host = host + got := currentOSRelease(s) want := map[string]any{"full": "7.2", "major": "7", "minor": "2"} if !reflect.DeepEqual(got, want) { - t.Fatalf("currentOSRelease(testSession, openbsd) = %#v, want %#v", got, want) + t.Fatalf("currentOSRelease(openbsd) = %#v, want %#v", got, want) + } + wantCalls := []fakeHostRunCall{{name: "uname", args: []string{"-r"}}} + if !reflect.DeepEqual(host.runCalls, wantCalls) { + t.Fatalf("run calls = %#v, want %#v", host.runCalls, wantCalls) } } @@ -785,69 +793,91 @@ func TestWindows6ReleaseMapsConsumerAndServerNames(t *testing.T) { } func TestCurrentOSReleaseNetBSDUsesKernelReleaseMap(t *testing.T) { - got := currentOSRelease(testSession, "netbsd", nil, func(name string, args ...string) string { - if name != "uname" || !reflect.DeepEqual(args, []string{"-r"}) { - t.Fatalf("command = %s %#v, want uname -r", name, args) - } - return "10.1\n" - }) + host := &fakeHostOS{ + platform: "netbsd", + runOutputs: map[string]string{fakeRunKey("uname", "-r"): "10.1\n"}, + } + s := NewSessionContext(t.Context()) + s.host = host + got := currentOSRelease(s) want := map[string]any{"full": "10.1", "major": "10", "minor": "1"} if !reflect.DeepEqual(got, want) { - t.Fatalf("currentOSRelease(testSession, netbsd) = %#v, want %#v", got, want) + t.Fatalf("currentOSRelease(netbsd) = %#v, want %#v", got, want) + } + wantCalls := []fakeHostRunCall{{name: "uname", args: []string{"-r"}}} + if !reflect.DeepEqual(host.runCalls, wantCalls) { + t.Fatalf("run calls = %#v, want %#v", host.runCalls, wantCalls) } } func TestCurrentOSReleaseDragonFlyUsesKernelReleaseMap(t *testing.T) { - got := currentOSRelease(testSession, "dragonfly", nil, func(name string, args ...string) string { - if name != "uname" || !reflect.DeepEqual(args, []string{"-r"}) { - t.Fatalf("command = %s %#v, want uname -r", name, args) - } - return "6.4-RELEASE\n" - }) + host := &fakeHostOS{ + platform: "dragonfly", + runOutputs: map[string]string{fakeRunKey("uname", "-r"): "6.4-RELEASE\n"}, + } + s := NewSessionContext(t.Context()) + s.host = host + got := currentOSRelease(s) want := map[string]any{"full": "6.4-RELEASE", "major": "6", "minor": "4-RELEASE"} if !reflect.DeepEqual(got, want) { - t.Fatalf("currentOSRelease(testSession, dragonfly) = %#v, want %#v", got, want) + t.Fatalf("currentOSRelease(dragonfly) = %#v, want %#v", got, want) + } + wantCalls := []fakeHostRunCall{{name: "uname", args: []string{"-r"}}} + if !reflect.DeepEqual(host.runCalls, wantCalls) { + t.Fatalf("run calls = %#v, want %#v", host.runCalls, wantCalls) } } func TestCurrentOSReleaseIllumosUsesEtcRelease(t *testing.T) { - got := currentOSRelease(testSession, "illumos", func(path string) ([]byte, error) { - if path != "/etc/release" { - t.Fatalf("path = %q, want /etc/release", path) - } - return []byte(" OmniOS v11 r151058\n"), nil - }, nil) + host := &fakeHostOS{ + platform: "illumos", + files: map[string][]byte{"/etc/release": []byte(" OmniOS v11 r151058\n")}, + } + s := NewSessionContext(t.Context()) + s.host = host + got := currentOSRelease(s) want := map[string]any{"full": "r151058", "major": "151058"} if !reflect.DeepEqual(got, want) { - t.Fatalf("currentOSRelease(testSession, illumos) = %#v, want %#v", got, want) + t.Fatalf("currentOSRelease(illumos) = %#v, want %#v", got, want) + } + if !reflect.DeepEqual(host.readFileCalls, []string{"/etc/release"}) { + t.Fatalf("read file calls = %#v, want [/etc/release]", host.readFileCalls) + } + if len(host.runCalls) != 0 { + t.Fatalf("run calls = %#v, want none", host.runCalls) } } func TestCurrentOSReleaseIllumosScansPastBannerLines(t *testing.T) { - got := currentOSRelease(testSession, "illumos", func(path string) ([]byte, error) { - return []byte("\n OpenIndiana Development\n OpenIndiana Hipster r202510\n"), nil - }, nil) + host := &fakeHostOS{ + platform: "illumos", + files: map[string][]byte{"/etc/release": []byte("\n OpenIndiana Development\n OpenIndiana Hipster r202510\n")}, + } + s := NewSessionContext(t.Context()) + s.host = host + got := currentOSRelease(s) want := map[string]any{"full": "r202510", "major": "202510"} if !reflect.DeepEqual(got, want) { - t.Fatalf("currentOSRelease(testSession, illumos) = %#v, want %#v", got, want) + t.Fatalf("currentOSRelease(illumos) = %#v, want %#v", got, want) } } func TestCurrentOSReleaseIllumosFallsBackToKernelRelease(t *testing.T) { - host := &fakeHostOS{runOutput: "5.11\n"} - session := NewSession() - session.host = host - - got := currentOSRelease(session, "illumos", func(path string) ([]byte, error) { - return []byte("OpenIndiana Hipster\n"), nil - }, session.commandOutput) + host := &fakeHostOS{ + platform: "illumos", + runOutput: "5.11\n", + files: map[string][]byte{"/etc/release": []byte("OpenIndiana Hipster\n")}, + } + s := NewSessionContext(t.Context()) + s.host = host + got := currentOSRelease(s) if got != "5.11" { - t.Fatalf("currentOSRelease(session, illumos) = %#v, want %q", got, "5.11") + t.Fatalf("currentOSRelease(illumos) = %#v, want %q", got, "5.11") } } @@ -1051,21 +1081,20 @@ func TestCurrentOSRelease_prefersDistroSpecificReleaseFiles(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - files := map[string]string{ - "/etc/os-release": tt.osRelease, - tt.specificPath: tt.specificBody, - } - readFile := func(path string) ([]byte, error) { - value, ok := files[path] - if !ok { - return nil, os.ErrNotExist - } - return []byte(value), nil + host := &fakeHostOS{ + platform: "linux", + emptyRunDefault: true, + files: map[string][]byte{ + "/etc/os-release": []byte(tt.osRelease), + tt.specificPath: []byte(tt.specificBody), + }, } + s := NewSessionContext(t.Context()) + s.host = host - got := currentOSRelease(testSession, "linux", readFile, func(string, ...string) string { return "" }) + got := currentOSRelease(s) if !reflect.DeepEqual(got, tt.want) { - t.Fatalf("currentOSRelease(testSession) = %#v, want %#v", got, tt.want) + t.Fatalf("currentOSRelease(linux) = %#v, want %#v", got, tt.want) } }) } @@ -1141,33 +1170,35 @@ func TestCurrentOSRelease_marinerAndAzureLinuxFallbackSplitOSReleaseVersion(t *t for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - readFile := func(path string) ([]byte, error) { - if path != "/etc/os-release" { - return nil, os.ErrNotExist - } - return []byte(tt.osRelease), nil + host := &fakeHostOS{ + platform: "linux", + emptyRunDefault: true, + files: map[string][]byte{"/etc/os-release": []byte(tt.osRelease)}, } + s := NewSessionContext(t.Context()) + s.host = host - got := currentOSRelease(testSession, "linux", readFile, func(string, ...string) string { return "" }) + got := currentOSRelease(s) if !reflect.DeepEqual(got, tt.want) { - t.Fatalf("currentOSRelease(testSession) = %#v, want %#v", got, tt.want) + t.Fatalf("currentOSRelease(linux) = %#v, want %#v", got, tt.want) } }) } } func TestCurrentOSRelease_linuxmintFallbackSplitsOSReleaseVersionLikeRubyFact(t *testing.T) { - readFile := func(path string) ([]byte, error) { - if path != "/etc/os-release" { - return nil, os.ErrNotExist - } - return []byte("ID=linuxmint\nVERSION_ID=19.4\n"), nil + host := &fakeHostOS{ + platform: "linux", + emptyRunDefault: true, + files: map[string][]byte{"/etc/os-release": []byte("ID=linuxmint\nVERSION_ID=19.4\n")}, } + s := NewSessionContext(t.Context()) + s.host = host - got := currentOSRelease(testSession, "linux", readFile, func(string, ...string) string { return "" }) + got := currentOSRelease(s) want := map[string]any{"full": "19.4", "major": "19", "minor": "4"} if !reflect.DeepEqual(got, want) { - t.Fatalf("currentOSRelease(testSession) = %#v, want %#v", got, want) + t.Fatalf("currentOSRelease(linux) = %#v, want %#v", got, want) } } @@ -1191,44 +1222,44 @@ func TestCurrentOSRelease_gentooAndMageiaFallbackSplitOSReleaseVersion(t *testin for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - readFile := func(path string) ([]byte, error) { - if path != "/etc/os-release" { - return nil, os.ErrNotExist - } - return []byte(tt.osRelease), nil + host := &fakeHostOS{ + platform: "linux", + emptyRunDefault: true, + files: map[string][]byte{"/etc/os-release": []byte(tt.osRelease)}, } + s := NewSessionContext(t.Context()) + s.host = host - got := currentOSRelease(testSession, "linux", readFile, func(string, ...string) string { return "" }) + got := currentOSRelease(s) if !reflect.DeepEqual(got, tt.want) { - t.Fatalf("currentOSRelease(testSession) = %#v, want %#v", got, tt.want) + t.Fatalf("currentOSRelease(linux) = %#v, want %#v", got, tt.want) } }) } } func TestCurrentOSRelease_usesAmazonLinux2023RPMVersion(t *testing.T) { - files := map[string]string{ - "/etc/os-release": "ID=amzn\nVERSION_ID=2023\n", - "/etc/system-release": "Amazon Linux 2023\n", - } - readFile := func(path string) ([]byte, error) { - value, ok := files[path] - if !ok { - return nil, os.ErrNotExist - } - return []byte(value), nil - } - run := func(name string, args ...string) string { - if name != "rpm" || !reflect.DeepEqual(args, []string{"-q", "--qf", "%{NAME}\n%{VERSION}\n%{RELEASE}\n%{VENDOR}", "-f", "/etc/os-release"}) { - t.Fatalf("run(%q, %#v), want rpm os-release package query", name, args) - } - return "system-release\n2023.1.20230912\n1.amzn2023\nAmazon Linux" + host := &fakeHostOS{ + platform: "linux", + files: map[string][]byte{ + "/etc/os-release": []byte("ID=amzn\nVERSION_ID=2023\n"), + "/etc/system-release": []byte("Amazon Linux 2023\n"), + }, + runOutputs: map[string]string{ + fakeRunKey("rpm", "-q", "--qf", "%{NAME}\n%{VERSION}\n%{RELEASE}\n%{VENDOR}", "-f", "/etc/os-release"): "system-release\n2023.1.20230912\n1.amzn2023\nAmazon Linux", + }, } + s := NewSessionContext(t.Context()) + s.host = host - got := currentOSRelease(testSession, "linux", readFile, run) + got := currentOSRelease(s) want := map[string]any{"full": "2023.1.20230912", "major": "2023", "minor": "1"} if !reflect.DeepEqual(got, want) { - t.Fatalf("currentOSRelease(testSession) = %#v, want %#v", got, want) + t.Fatalf("currentOSRelease(linux) = %#v, want %#v", got, want) + } + wantCalls := []fakeHostRunCall{{name: "rpm", args: []string{"-q", "--qf", "%{NAME}\n%{VERSION}\n%{RELEASE}\n%{VENDOR}", "-f", "/etc/os-release"}}} + if !reflect.DeepEqual(host.runCalls, wantCalls) { + t.Fatalf("run calls = %#v, want %#v", host.runCalls, wantCalls) } } @@ -1351,20 +1382,19 @@ func TestCurrentLinuxReleaseFilesReturnEmptyWhenMissing(t *testing.T) { func TestCurrentLinuxDistro_usesRedHatReleaseForRHELDistroFields(t *testing.T) { t.Parallel() - files := map[string]string{ - "/etc/os-release": "NAME=\"CentOS Linux\"\nID=centos\nVERSION_ID=7.2.1511\n", - "/etc/redhat-release": "CentOS Linux release 7.2.1511 (Core)\n", - } - readFile := func(path string) ([]byte, error) { - value, ok := files[path] - if !ok { - return nil, os.ErrNotExist - } - return []byte(value), nil + host := &fakeHostOS{ + platform: "linux", + emptyRunDefault: true, + files: map[string][]byte{ + "/etc/os-release": []byte("NAME=\"CentOS Linux\"\nID=centos\nVERSION_ID=7.2.1511\n"), + "/etc/redhat-release": []byte("CentOS Linux release 7.2.1511 (Core)\n"), + }, } + s := NewSessionContext(t.Context()) + s.host = host lookPath := func(string) (string, error) { return "", os.ErrNotExist } - got := currentLinuxDistro("linux", lookPath, func(string, ...string) string { return "" }, readFile) + got := currentLinuxDistro(s, lookPath) want := linuxDistro{ Name: "CentOS", ID: "CentOS", @@ -1381,31 +1411,30 @@ func TestCurrentLinuxDistro_usesRedHatReleaseForRHELDistroFields(t *testing.T) { func TestCurrentLinuxDistroRHELPrefersRedHatReleaseOverLSB(t *testing.T) { t.Parallel() - files := map[string]string{ - "/etc/os-release": "NAME=\"Red Hat Enterprise Linux\"\nID=rhel\nVERSION_ID=8.0\n", - "/etc/redhat-release": "Red Hat Enterprise Linux release 8.0 (Ootpa)\n", - } - readFile := func(path string) ([]byte, error) { - value, ok := files[path] - if !ok { - return nil, os.ErrNotExist - } - return []byte(value), nil + host := &fakeHostOS{ + platform: "linux", + files: map[string][]byte{ + "/etc/os-release": []byte("NAME=\"Red Hat Enterprise Linux\"\nID=rhel\nVERSION_ID=8.0\n"), + "/etc/redhat-release": []byte("Red Hat Enterprise Linux release 8.0 (Ootpa)\n"), + }, + runOutputs: map[string]string{ + fakeRunKey("lsb_release", "-a"): "Distributor ID:\trhel-lsb\nDescription:\tLSB supplied description\nRelease:\t8.0-lsb\nCodename:\tlsb-code\n", + }, } + s := NewSessionContext(t.Context()) + s.host = host lookPath := func(name string) (string, error) { if name != "lsb_release" { return "", os.ErrNotExist } return "/usr/bin/lsb_release", nil } - run := func(name string, args ...string) string { - if name != "lsb_release" || !reflect.DeepEqual(args, []string{"-a"}) { - t.Fatalf("run(%q, %#v), want lsb_release -a", name, args) - } - return "Distributor ID:\trhel-lsb\nDescription:\tLSB supplied description\nRelease:\t8.0-lsb\nCodename:\tlsb-code\n" - } - got := currentLinuxDistro("linux", lookPath, run, readFile) + got := currentLinuxDistro(s, lookPath) + wantCalls := []fakeHostRunCall{{name: "lsb_release", args: []string{"-a"}}} + if !reflect.DeepEqual(host.runCalls, wantCalls) { + t.Fatalf("run calls = %#v, want %#v", host.runCalls, wantCalls) + } core := linuxDistroFacts(got) coreCollection := Collection(core) @@ -1428,19 +1457,16 @@ func TestCurrentLinuxDistroRHELPrefersRedHatReleaseOverLSB(t *testing.T) { func TestCurrentLinuxDistro_usesSuseReleaseWhenOSReleaseIsMissing(t *testing.T) { t.Parallel() - files := map[string]string{ - "/etc/SuSE-release": "openSUSE 11.1 (i586)\nVERSION = 11.1\n", - } - readFile := func(path string) ([]byte, error) { - value, ok := files[path] - if !ok { - return nil, os.ErrNotExist - } - return []byte(value), nil + host := &fakeHostOS{ + platform: "linux", + emptyRunDefault: true, + files: map[string][]byte{"/etc/SuSE-release": []byte("openSUSE 11.1 (i586)\nVERSION = 11.1\n")}, } + s := NewSessionContext(t.Context()) + s.host = host lookPath := func(string) (string, error) { return "", os.ErrNotExist } - got := currentLinuxDistro("linux", lookPath, func(string, ...string) string { return "" }, readFile) + got := currentLinuxDistro(s, lookPath) want := linuxDistro{ Name: "openSUSE", ID: "opensuse", @@ -1672,16 +1698,17 @@ func TestParseLinuxDistroOSRelease_normalizesArchLinuxName(t *testing.T) { func TestCurrentOSRelease_omitsArchRollingBuildID(t *testing.T) { t.Parallel() - readFile := func(path string) ([]byte, error) { - if path != "/etc/os-release" { - return nil, os.ErrNotExist - } - return []byte("NAME=\"Arch Linux\"\nPRETTY_NAME=\"Arch Linux\"\nID=arch\nBUILD_ID=rolling\n"), nil + host := &fakeHostOS{ + platform: "linux", + emptyRunDefault: true, + files: map[string][]byte{"/etc/os-release": []byte("NAME=\"Arch Linux\"\nPRETTY_NAME=\"Arch Linux\"\nID=arch\nBUILD_ID=rolling\n")}, } + s := NewSessionContext(t.Context()) + s.host = host - got := currentOSRelease(testSession, "linux", readFile, func(string, ...string) string { return "" }) + got := currentOSRelease(s) if got != nil { - t.Fatalf("currentOSRelease(testSession, arch) = %#v, want nil because BUILD_ID is not a release", got) + t.Fatalf("currentOSRelease(arch) = %#v, want nil because BUILD_ID is not a release", got) } } @@ -1816,16 +1843,21 @@ func TestParseFreeBSDVersions_returnsKernelAndUserlandValues(t *testing.T) { } func TestCurrentOSRelease_mapsDarwinKernelReleaseLikeRubyFact(t *testing.T) { - got := currentOSRelease(testSession, "darwin", nil, func(name string, args ...string) string { - if name != "uname" || !reflect.DeepEqual(args, []string{"-r"}) { - t.Fatalf("run(%q, %#v), want uname -r", name, args) - } - return "10.9\n" - }) + host := &fakeHostOS{ + platform: "darwin", + runOutputs: map[string]string{fakeRunKey("uname", "-r"): "10.9\n"}, + } + s := NewSessionContext(t.Context()) + s.host = host + got := currentOSRelease(s) want := map[string]any{"full": "10.9", "major": "10", "minor": "9"} if !reflect.DeepEqual(got, want) { - t.Fatalf("currentOSRelease(testSession, darwin) = %#v, want %#v", got, want) + t.Fatalf("currentOSRelease(darwin) = %#v, want %#v", got, want) + } + wantCalls := []fakeHostRunCall{{name: "uname", args: []string{"-r"}}} + if !reflect.DeepEqual(host.runCalls, wantCalls) { + t.Fatalf("run calls = %#v, want %#v", host.runCalls, wantCalls) } } @@ -1961,15 +1993,19 @@ func TestMacOSStringFact_skipsEmptyValues(t *testing.T) { func TestCurrentMacOSModelUsesSysctlHWModel(t *testing.T) { t.Parallel() - run := func(name string, args ...string) string { - if name != "sysctl" || !reflect.DeepEqual(args, []string{"-n", "hw.model"}) { - t.Fatalf("command = %s %#v, want sysctl -n hw.model", name, args) - } - return "MacBookPro11,4\n" + host := &fakeHostOS{ + platform: "darwin", + runOutputs: map[string]string{fakeRunKey("sysctl", "-n", "hw.model"): "MacBookPro11,4\n"}, } + s := NewSessionContext(t.Context()) + s.host = host - if got := currentMacOSModel("darwin", run); got != "MacBookPro11,4" { - t.Fatalf("currentMacOSModel() = %q, want MacBookPro11,4", got) + if got := probeMacOSModel(s); got != "MacBookPro11,4" { + t.Fatalf("probeMacOSModel() = %q, want MacBookPro11,4", got) + } + wantCalls := []fakeHostRunCall{{name: "sysctl", args: []string{"-n", "hw.model"}}} + if !reflect.DeepEqual(host.runCalls, wantCalls) { + t.Fatalf("run calls = %#v, want %#v", host.runCalls, wantCalls) } } @@ -1985,17 +2021,21 @@ func TestParseSwVers(t *testing.T) { func TestCurrentMacOSInfoUsesSwVersCommand(t *testing.T) { t.Parallel() - run := func(name string, args ...string) string { - if name != "sw_vers" || len(args) != 0 { - t.Fatalf("command = %s %#v, want sw_vers", name, args) - } - return "ProductName:\tmacOS\nProductVersion:\t13.3.1\nProductVersionExtra:\t(a)\nBuildVersion:\t22E772610a\n" + host := &fakeHostOS{ + platform: "darwin", + runOutputs: map[string]string{fakeRunKey("sw_vers"): "ProductName:\tmacOS\nProductVersion:\t13.3.1\nProductVersionExtra:\t(a)\nBuildVersion:\t22E772610a\n"}, } + s := NewSessionContext(t.Context()) + s.host = host - got := currentMacOSInfo("darwin", run) + got := probeMacOSInfo(s) want := macOSInfo{ProductName: "macOS", ProductVersion: "13.3.1", ProductVersionExtra: "(a)", BuildVersion: "22E772610a"} if got != want { - t.Fatalf("currentMacOSInfo() = %#v, want %#v", got, want) + t.Fatalf("probeMacOSInfo() = %#v, want %#v", got, want) + } + wantCalls := []fakeHostRunCall{{name: "sw_vers", args: nil}} + if !reflect.DeepEqual(host.runCalls, wantCalls) { + t.Fatalf("run calls = %#v, want %#v", host.runCalls, wantCalls) } } @@ -2123,20 +2163,20 @@ func TestParseMacOSSystemProfilerEthernetIgnoresMalformedKeyValueLinesLikeRubyEx } func TestCurrentMacOSSystemProfilerEthernetUsesCommand(t *testing.T) { - var calledName string - var calledArgs []string - run := func(name string, args ...string) string { - calledName = name - calledArgs = append([]string(nil), args...) - return "Vendor ID: 0x8086\n" + host := &fakeHostOS{ + platform: "darwin", + runOutputs: map[string]string{fakeRunKey("system_profiler", "SPEthernetDataType"): "Vendor ID: 0x8086\n"}, } + s := NewSessionContext(t.Context()) + s.host = host - got := currentMacOSSystemProfilerEthernet("darwin", run) - if calledName != "system_profiler" || len(calledArgs) != 1 || calledArgs[0] != "SPEthernetDataType" { - t.Fatalf("command = %q %#v, want system_profiler SPEthernetDataType", calledName, calledArgs) + got := probeMacOSSystemProfilerEthernet(s) + wantCalls := []fakeHostRunCall{{name: "system_profiler", args: []string{"SPEthernetDataType"}}} + if !reflect.DeepEqual(host.runCalls, wantCalls) { + t.Fatalf("command = %#v, want system_profiler SPEthernetDataType", host.runCalls) } if got.VendorID != "0x8086" { - t.Fatalf("currentMacOSSystemProfilerEthernet().VendorID = %q, want 0x8086", got.VendorID) + t.Fatalf("probeMacOSSystemProfilerEthernet().VendorID = %q, want 0x8086", got.VendorID) } } @@ -2485,24 +2525,17 @@ func TestParseLinuxDistroOSRelease_unescapesQuotedValues(t *testing.T) { } func TestCurrentLinuxDistro_usesAmazonLinux2023RPMVersionWithPatch(t *testing.T) { - files := map[string]string{ - "/etc/os-release": "ID=amzn\nVERSION_ID=2023\n", - } - readFile := func(path string) ([]byte, error) { - value, ok := files[path] - if !ok { - return nil, os.ErrNotExist - } - return []byte(value), nil - } - run := func(name string, args ...string) string { - if name != "rpm" || !reflect.DeepEqual(args, []string{"-q", "--qf", "%{NAME}\n%{VERSION}\n%{RELEASE}\n%{VENDOR}", "-f", "/etc/os-release"}) { - t.Fatalf("run(%q, %#v), want rpm os-release package query", name, args) - } - return "system-release\n2023.1.20230912\n1.amzn2023\nAmazon Linux" + host := &fakeHostOS{ + platform: "linux", + files: map[string][]byte{"/etc/os-release": []byte("ID=amzn\nVERSION_ID=2023\n")}, + runOutputs: map[string]string{ + fakeRunKey("rpm", "-q", "--qf", "%{NAME}\n%{VERSION}\n%{RELEASE}\n%{VENDOR}", "-f", "/etc/os-release"): "system-release\n2023.1.20230912\n1.amzn2023\nAmazon Linux", + }, } + s := NewSessionContext(t.Context()) + s.host = host - got := currentLinuxDistro("linux", func(string) (string, error) { return "", os.ErrNotExist }, run, readFile) + got := currentLinuxDistro(s, func(string) (string, error) { return "", os.ErrNotExist }) want := linuxDistro{ ID: "amzn", Release: map[string]any{"full": "2023.1.20230912", "major": "2023", "minor": "1", "patch": "20230912"}, @@ -2511,22 +2544,25 @@ func TestCurrentLinuxDistro_usesAmazonLinux2023RPMVersionWithPatch(t *testing.T) if !reflect.DeepEqual(got, want) { t.Fatalf("currentLinuxDistro() = %#v, want %#v", got, want) } + wantCalls := []fakeHostRunCall{{name: "rpm", args: []string{"-q", "--qf", "%{NAME}\n%{VERSION}\n%{RELEASE}\n%{VENDOR}", "-f", "/etc/os-release"}}} + if !reflect.DeepEqual(host.runCalls, wantCalls) { + t.Fatalf("run calls = %#v, want %#v", host.runCalls, wantCalls) + } } func TestCurrentLinuxDistro_usesAmazonSystemReleaseForDistroFields(t *testing.T) { - files := map[string]string{ - "/etc/os-release": "ID=amzn\nVERSION_ID=2\n", - "/etc/system-release": "Amazon Linux release 2 (2017.12) LTS Release Candidate\n", - } - readFile := func(path string) ([]byte, error) { - value, ok := files[path] - if !ok { - return nil, os.ErrNotExist - } - return []byte(value), nil + host := &fakeHostOS{ + platform: "linux", + emptyRunDefault: true, + files: map[string][]byte{ + "/etc/os-release": []byte("ID=amzn\nVERSION_ID=2\n"), + "/etc/system-release": []byte("Amazon Linux release 2 (2017.12) LTS Release Candidate\n"), + }, } + s := NewSessionContext(t.Context()) + s.host = host - got := currentLinuxDistro("linux", func(string) (string, error) { return "", os.ErrNotExist }, func(string, ...string) string { return "" }, readFile) + got := currentLinuxDistro(s, func(string) (string, error) { return "", os.ErrNotExist }) want := linuxDistro{ ID: "Amazon", Description: "Amazon Linux release 2 (2017.12) LTS Release Candidate", @@ -2625,3 +2661,43 @@ func TestUbuntuReleaseMapHandlesEmptyAndKnownRelease(t *testing.T) { t.Fatalf("ubuntuReleaseMap() = %#v, want %#v", got, want) } } + +// A fake windows host drives the windows os-assembly path — SystemRoot/system32 +// derivation and the WOW64 sysnative switch via the Session env seam — from any +// development platform. +func TestOSCoreFactsFakeWindowsHostDerivesSystem32(t *testing.T) { + newWindowsSession := func(env ...string) (*Session, *fakeHostOS) { + host := &fakeHostOS{ + platform: "windows", + emptyRunDefault: true, + environEntries: append([]string{`SystemRoot=C:\Windows`}, env...), + } + s := NewSessionContext(context.Background()) + s.host = host + return s, host + } + + factValue := func(facts []ResolvedFact, name string) any { + for _, f := range facts { + if f.Name == name { + return f.Value + } + } + return nil + } + + s, _ := newWindowsSession() + facts := osCoreFacts(s) + if got := factValue(facts, "os.family"); got != "windows" { + t.Fatalf("os.family = %#v, want windows", got) + } + if got := factValue(facts, "os.windows.system32"); got != `C:\Windows\system32` { + t.Fatalf("os.windows.system32 = %#v, want C:\\Windows\\system32", got) + } + + wow64, _ := newWindowsSession("PROCESSOR_ARCHITEW6432=AMD64") + facts = osCoreFacts(wow64) + if got := factValue(facts, "os.windows.system32"); got != `C:\Windows\sysnative` { + t.Fatalf("WOW64 os.windows.system32 = %#v, want C:\\Windows\\sysnative", got) + } +} diff --git a/internal/engine/plan9_existing_test.go b/internal/engine/plan9_existing_test.go index 202d6b5f..b1508365 100644 --- a/internal/engine/plan9_existing_test.go +++ b/internal/engine/plan9_existing_test.go @@ -1,7 +1,6 @@ package engine import ( - "os" "reflect" "testing" ) @@ -24,15 +23,9 @@ func TestCurrentOSReleasePlan9OmitsOSVersionProtocol(t *testing.T) { t.Parallel() s := NewSession() - s.host = &fakeHostOS{runOutput: "2000\n"} - readFile := func(path string) ([]byte, error) { - if path != "/dev/osversion" { - return nil, os.ErrNotExist - } - return []byte("2000\n"), nil - } + s.host = &fakeHostOS{platform: "plan9"} - if got := currentOSRelease(s, "plan9", readFile, func(string, ...string) string { return "" }); got != nil { + if got := currentOSRelease(s); got != nil { t.Fatalf("currentOSRelease(plan9) = %#v, want nil", got) } } @@ -45,8 +38,8 @@ func TestCurrentOSReleaseUnsupportedTargetOmitsOSRelease(t *testing.T) { t.Parallel() s := NewSession() - s.host = &fakeHostOS{runOutput: "5.11\n"} - if got := currentOSRelease(s, goos, nil, func(string, ...string) string { return "5.11\n" }); got != nil { + s.host = &fakeHostOS{platform: goos, runOutput: "5.11\n"} + if got := currentOSRelease(s); got != nil { t.Fatalf("currentOSRelease(%s) = %#v, want nil", goos, got) } }) @@ -56,14 +49,25 @@ func TestCurrentOSReleaseUnsupportedTargetOmitsOSRelease(t *testing.T) { func TestCurrentLoadAveragesPlan9OmitsLoadAverages(t *testing.T) { t.Parallel() - got := currentLoadAverages("plan9", func(string) ([]byte, error) { - return []byte("0.00 0.01 0.02\n"), nil - }, func(string, ...string) string { - return "cirno up 0 days, 01:35:26" - }) + host := &fakeHostOS{ + platform: "plan9", + files: map[string][]byte{ + "/proc/loadavg": []byte("0.00 0.01 0.02\n"), + }, + runOutputs: map[string]string{ + fakeRunKey("uptime"): "cirno up 0 days, 01:35:26", + }, + } + s := NewSession() + s.host = host + + got := currentLoadAverages(s) if got != nil { t.Fatalf("currentLoadAverages(plan9) = %#v, want nil", got) } + if len(host.readFileCalls) != 0 || len(host.runCalls) != 0 { + t.Fatalf("currentLoadAverages(plan9) consulted host: reads=%#v runs=%#v", host.readFileCalls, host.runCalls) + } } func TestParseUptimeCommandSecondsPlan9Format(t *testing.T) { @@ -91,14 +95,17 @@ func TestParseUptimeCommandSecondsPlan9Format(t *testing.T) { func TestCurrentUptimeInfoPlan9UsesNativeUptimeFormat(t *testing.T) { t.Parallel() - got := currentUptimeInfo(testSession, "plan9", func(path string) ([]byte, error) { - return nil, os.ErrNotExist - }, func(name string, args ...string) string { - if name != "uptime" { - return "" - } - return "cirno up 0 days, 01:35:26" - }, nil) + host := &fakeHostOS{ + platform: "plan9", + emptyRunDefault: true, + runOutputs: map[string]string{ + fakeRunKey("uptime"): "cirno up 0 days, 01:35:26", + }, + } + s := NewSession() + s.host = host + + got := currentUptimeInfo(s, nil) want := uptimeInfo{Duration: 5726 * 1_000_000_000, Known: true} if !reflect.DeepEqual(got, want) { diff --git a/internal/engine/processors.go b/internal/engine/processors.go index 4b0c98e1..2e0aa8d6 100644 --- a/internal/engine/processors.go +++ b/internal/engine/processors.go @@ -2,7 +2,6 @@ package engine import ( "log/slog" - "os" "path/filepath" "runtime" "sort" @@ -20,7 +19,7 @@ type processorInfo struct { ThreadsPerCore int } -func currentProcessorISA(s *Session, goos, fallback string, run commandRunner) string { +func currentProcessorISA(s *Session, goos, fallback string) string { if goos == "windows" { if isa := s.cachedPlatformProcessorInfo().ISA; isa != "" { return isa @@ -30,7 +29,7 @@ func currentProcessorISA(s *Session, goos, fallback string, run commandRunner) s if goos == "plan9" { return plan9ProcessorISA(s.readFile, fallback) } - processor := strings.TrimSpace(run("uname", "-p")) + processor := strings.TrimSpace(s.commandOutput("uname", "-p")) if processor == "" || processor == "unknown" { return fallback } @@ -437,27 +436,7 @@ func parseLinuxProcessorTopology(input string) (int, int) { return 0, 0 } -func currentLinuxProcessorPhysicalCount(cpuinfoPath, sysCPUPath string, host hostOS) int { - if host == nil { - host = osHost{} - } - data, err := host.readFile(cpuinfoPath) - cpuinfo := "" - if err == nil { - cpuinfo = string(data) - } - return linuxProcessorPhysicalCountWithReaders(cpuinfo, sysCPUPath, host.readFile, host.readDir) -} - -func linuxProcessorPhysicalCount(cpuinfo, sysCPUPath string, readFiles ...fileReader) int { - readFile := osHost{}.readFile - if len(readFiles) > 0 && readFiles[0] != nil { - readFile = readFiles[0] - } - return linuxProcessorPhysicalCountWithReaders(cpuinfo, sysCPUPath, readFile, os.ReadDir) -} - -func linuxProcessorPhysicalCountWithReaders(cpuinfo, sysCPUPath string, readFile fileReader, readDir func(string) ([]os.DirEntry, error)) int { +func linuxProcessorPhysicalCount(cpuinfo string, host hostOS) int { physicalIDs := make(map[string]struct{}) for line := range strings.SplitSeq(cpuinfo, "\n") { key, value, ok := strings.Cut(line, ":") @@ -473,7 +452,8 @@ func linuxProcessorPhysicalCountWithReaders(cpuinfo, sysCPUPath string, readFile return len(physicalIDs) } - entries, err := readDir(sysCPUPath) + const sysCPUPath = "/sys/devices/system/cpu" + entries, err := host.readDir(sysCPUPath) if err != nil { return 0 } @@ -482,7 +462,7 @@ func linuxProcessorPhysicalCountWithReaders(cpuinfo, sysCPUPath string, readFile if !linuxCPUEntryName(name) { continue } - data, err := readFile(filepath.Join(sysCPUPath, name, "topology", "physical_package_id")) + data, err := host.readFile(filepath.Join(sysCPUPath, name, "topology", "physical_package_id")) if err != nil { continue } @@ -618,14 +598,18 @@ func processorsCoreFacts(s *Session) []ResolvedFact { goos := s.goos() architecture := architectureName(goos, s.cachedHardwareModel()) if goos == "plan9" { - return plan9ProcessorsCoreFacts(s.cachedPlatformProcessorInfo(), currentProcessorISA(s, goos, architecture, s.commandOutput)) + return plan9ProcessorsCoreFacts(s.cachedPlatformProcessorInfo(), currentProcessorISA(s, goos, architecture)) } platformProcessors := processorInfo{} if goos == "darwin" || goos == "freebsd" || goos == "netbsd" || goos == "openbsd" || goos == "dragonfly" || goos == "illumos" || goos == "windows" { platformProcessors = s.cachedPlatformProcessorInfo() } if goos == "linux" { - platformProcessors.PhysicalCount = currentLinuxProcessorPhysicalCount("/proc/cpuinfo", "/sys/devices/system/cpu", s.host) + cpuinfo := "" + if data, err := s.readFile("/proc/cpuinfo"); err == nil { + cpuinfo = string(data) + } + platformProcessors.PhysicalCount = linuxProcessorPhysicalCount(cpuinfo, s.host) } processorCount := runtime.NumCPU() if platformProcessors.LogicalCount > 0 { @@ -635,7 +619,7 @@ func processorsCoreFacts(s *Session) []ResolvedFact { if platformProcessors.PhysicalCount > 0 { physicalProcessorCount = platformProcessors.PhysicalCount } - processorISA := currentProcessorISA(s, goos, architecture, s.commandOutput) + processorISA := currentProcessorISA(s, goos, architecture) 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 9349781f..d24948af 100644 --- a/internal/engine/processors_test.go +++ b/internal/engine/processors_test.go @@ -3,7 +3,6 @@ package engine import ( "context" "os" - "path/filepath" "reflect" "runtime" "strings" @@ -558,15 +557,18 @@ func TestParseIllumosProcessorsInfersCountsFromClockedModels(t *testing.T) { } func TestCurrentProcessorISAUsesOpenBSDUnameProcessor(t *testing.T) { - got := currentProcessorISA(testSession, "openbsd", "amd64", func(name string, args ...string) string { - if name != "uname" || !reflect.DeepEqual(args, []string{"-p"}) { - t.Fatalf("command = %s %#v, want uname -p", name, args) - } - return "i386\n" - }) + s := NewSession() + host := &fakeHostOS{runOutputs: map[string]string{fakeRunKey("uname", "-p"): "i386\n"}} + s.host = host + + got := currentProcessorISA(s, "openbsd", "amd64") if got != "i386" { - t.Fatalf("currentProcessorISA(testSession, openbsd) = %q, want i386", got) + t.Fatalf("currentProcessorISA(openbsd) = %q, want i386", got) + } + want := []fakeHostRunCall{{name: "uname", args: []string{"-p"}}} + if !reflect.DeepEqual(host.runCalls, want) { + t.Fatalf("commands = %#v, want uname -p", host.runCalls) } } @@ -574,7 +576,9 @@ 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 }) + s := NewSession() + s.host = &fakeHostOS{runOutputs: map[string]string{fakeRunKey("uname", "-p"): output}} + got := currentProcessorISA(s, "linux", "x86_64") if got != "x86_64" { t.Fatalf("currentProcessorISA(%q) = %q, want fallback", output, got) } @@ -585,7 +589,7 @@ func TestCurrentProcessorISAWindowsFallsBackWhenWMIHasNoISA(t *testing.T) { s := NewSession() s.host = &fakeHostOS{platform: "windows", runOutput: ""} - if got := currentProcessorISA(s, "windows", "amd64", func(string, ...string) string { return "" }); got != "amd64" { + if got := currentProcessorISA(s, "windows", "amd64"); got != "amd64" { t.Fatalf("currentProcessorISA(windows) = %q, want amd64 fallback", got) } } @@ -788,27 +792,27 @@ func TestParseLinuxProcessorTopologyMatchesRubyLscpuResolver(t *testing.T) { } func TestLinuxProcessorPhysicalCountFallsBackToSysfsPackageIDsLikeRuby(t *testing.T) { - sysCPU := t.TempDir() - for cpu, packageID := range map[string]string{"cpu0": "0", "cpu1": "1"} { - topology := filepath.Join(sysCPU, cpu, "topology") - if err := os.MkdirAll(topology, 0o755); err != nil { - t.Fatalf("MkdirAll(%q): %v", topology, err) - } - if err := os.WriteFile(filepath.Join(topology, "physical_package_id"), []byte(packageID), 0o644); err != nil { - t.Fatalf("WriteFile package id: %v", err) - } - } - if err := os.Mkdir(filepath.Join(sysCPU, "cpuindex"), 0o755); err != nil { - t.Fatalf("Mkdir cpuindex: %v", err) + t.Parallel() + + host := &fakeHostOS{ + files: map[string][]byte{ + "/sys/devices/system/cpu/cpu0/topology/physical_package_id": []byte("0"), + "/sys/devices/system/cpu/cpu1/topology/physical_package_id": []byte("1"), + }, + dirs: map[string][]os.DirEntry{ + "/sys/devices/system/cpu": fakeDirEntries("cpu0", "cpu1", "cpuindex"), + }, } cpuinfo := "processor\t: 0\nmodel name\t: CPU\nprocessor\t: 1\nmodel name\t: CPU\n" - if got, want := linuxProcessorPhysicalCount(cpuinfo, sysCPU), 2; got != want { + if got, want := linuxProcessorPhysicalCount(cpuinfo, host), 2; got != want { t.Fatalf("linuxProcessorPhysicalCount() = %d, want %d", got, want) } } -func TestCurrentLinuxProcessorPhysicalCountUsesHostSysfsWhenCPUInfoMissing(t *testing.T) { +func TestLinuxProcessorPhysicalCountUsesHostSysfsWhenCPUInfoEmpty(t *testing.T) { + t.Parallel() + host := &fakeHostOS{ files: map[string][]byte{ "/sys/devices/system/cpu/cpu0/topology/physical_package_id": []byte("0\n"), @@ -819,28 +823,27 @@ func TestCurrentLinuxProcessorPhysicalCountUsesHostSysfsWhenCPUInfoMissing(t *te }, } - got := currentLinuxProcessorPhysicalCount("/proc/cpuinfo", "/sys/devices/system/cpu", host) + got := linuxProcessorPhysicalCount("", host) if got != 2 { - t.Fatalf("currentLinuxProcessorPhysicalCount() = %d, want sysfs fallback count 2", got) + t.Fatalf("linuxProcessorPhysicalCount() = %d, want sysfs fallback count 2", got) } if !reflect.DeepEqual(host.readDirCalls, []string{"/sys/devices/system/cpu"}) { t.Fatalf("readDir calls = %#v, want sysfs path", host.readDirCalls) } } -func TestCurrentLinuxProcessorPhysicalCountUsesHostCPUInfoFirst(t *testing.T) { +func TestLinuxProcessorPhysicalCountUsesCPUInfoFirst(t *testing.T) { + t.Parallel() + 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) + got := linuxProcessorPhysicalCount("processor: 0\nphysical id: 0\nprocessor: 1\nphysical id: 1\n", host) if got != 2 { - t.Fatalf("currentLinuxProcessorPhysicalCount() = %d, want cpuinfo physical count 2", got) + t.Fatalf("linuxProcessorPhysicalCount() = %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) @@ -850,52 +853,59 @@ func TestCurrentLinuxProcessorPhysicalCountUsesHostCPUInfoFirst(t *testing.T) { func TestLinuxProcessorPhysicalCountUsesCPUInfoPhysicalIDs(t *testing.T) { t.Parallel() + host := &fakeHostOS{} 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 - }) + got := linuxProcessorPhysicalCount(cpuinfo, host) if got != 2 { - t.Fatalf("linuxProcessorPhysicalCountWithReaders() = %d, want 2", got) + t.Fatalf("linuxProcessorPhysicalCount() = %d, want 2", got) + } + if len(host.readDirCalls) != 0 { + t.Fatalf("readDir calls = %#v, want none: read sysfs despite cpuinfo physical IDs", host.readDirCalls) } } 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) + denied := &fakeHostOS{ + dirErrs: map[string]error{"/sys/devices/system/cpu": os.ErrPermission}, + } + if got := linuxProcessorPhysicalCount("", denied); got != 0 { + t.Fatalf("linuxProcessorPhysicalCount(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) + unreadable := &fakeHostOS{ + dirs: map[string][]os.DirEntry{ + "/sys/devices/system/cpu": fakeDirEntries("cpu0"), + }, + fileErrs: map[string]error{ + "/sys/devices/system/cpu/cpu0/topology/physical_package_id": os.ErrPermission, + }, + } + if got := linuxProcessorPhysicalCount("", unreadable); got != 0 { + t.Fatalf("linuxProcessorPhysicalCount(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 +func TestLinuxProcessorPhysicalCountReadsPackageIDsThroughHost(t *testing.T) { + t.Parallel() + + host := &fakeHostOS{ + files: map[string][]byte{ + "/sys/devices/system/cpu/cpu0/topology/physical_package_id": []byte("7\n"), + }, + dirs: map[string][]os.DirEntry{ + "/sys/devices/system/cpu": fakeDirEntries("cpu0"), + }, } - if got := linuxProcessorPhysicalCount("", sysCPU, readFile); got != 1 { + if got := linuxProcessorPhysicalCount("", host); got != 1 { t.Fatalf("linuxProcessorPhysicalCount() = %d, want 1", got) } + want := []string{"/sys/devices/system/cpu/cpu0/topology/physical_package_id"} + if !reflect.DeepEqual(host.readFileCalls, want) { + t.Fatalf("readFile calls = %#v, want %#v", host.readFileCalls, want) + } } func TestLinuxCPUEntryNameAcceptsOnlyNumberedCPUEntries(t *testing.T) { diff --git a/internal/engine/projection.go b/internal/engine/projection.go index a01ea085..8c35376a 100644 --- a/internal/engine/projection.go +++ b/internal/engine/projection.go @@ -1,6 +1,9 @@ package engine -import "strings" +import ( + "regexp" + "strings" +) // Projection owns query selection and output projection over one set of // resolved facts. It centralizes the rules that Snapshot value lookup, the CLI @@ -157,3 +160,12 @@ func findFactIn(facts []ResolvedFact, collection map[string]any, query string) R } return ResolvedFact{Name: query, UserQuery: query, Type: "nil"} } + +func factMatchesQuery(factName, query string) bool { + if strings.Contains(factName, ".*") && !strings.Contains(query, ".") { + pattern := strings.ReplaceAll(regexp.QuoteMeta(factName), `\.\*`, `.*`) + matched, err := regexp.MatchString("^"+pattern+"$", query) + return err == nil && matched + } + return query == factName || strings.HasPrefix(query, factName+".") +} diff --git a/internal/engine/query.go b/internal/engine/query.go deleted file mode 100644 index 566e2b04..00000000 --- a/internal/engine/query.go +++ /dev/null @@ -1,26 +0,0 @@ -package engine - -import ( - "regexp" - "strings" -) - -// Select returns resolved facts for the user-provided queries. -func Select(facts []ResolvedFact, queries []string) []ResolvedFact { - return SelectWithDottedFacts(facts, queries, false) -} - -// SelectWithDottedFacts returns resolved facts and optionally merges dotted custom -// and external facts into structured facts for partial queries. -func SelectWithDottedFacts(facts []ResolvedFact, queries []string, includeTypedDotted bool) []ResolvedFact { - return NewProjection(facts, includeTypedDotted).Select(queries) -} - -func factMatchesQuery(factName, query string) bool { - if strings.Contains(factName, ".*") && !strings.Contains(query, ".") { - pattern := strings.ReplaceAll(regexp.QuoteMeta(factName), `\.\*`, `.*`) - matched, err := regexp.MatchString("^"+pattern+"$", query) - return err == nil && matched - } - return query == factName || strings.HasPrefix(query, factName+".") -} diff --git a/internal/engine/query_test.go b/internal/engine/query_test.go index 9de7b5fb..ed61592e 100644 --- a/internal/engine/query_test.go +++ b/internal/engine/query_test.go @@ -5,50 +5,26 @@ import ( "testing" ) -func TestSelectWithDottedFacts_digsPartialQueriesThroughStructuredDottedFacts(t *testing.T) { +func TestProjectionKeepsTypedDottedFactFlatByDefault(t *testing.T) { facts := []ResolvedFact{ {Name: "a.b.c", Value: "external", Type: "external"}, } - selected := SelectWithDottedFacts(facts, []string{"a.b", "a"}, true) - if len(selected) != 2 { - t.Fatalf("Select() returned %d facts, want 2", len(selected)) - } - - if got, want := ValueForQuery(selected[0]), (map[string]any{"c": "external"}); !reflect.DeepEqual(got, want) { - t.Fatalf("ValueForQuery(a.b) = %#v, want %#v", got, want) - } - if got, want := ValueForQuery(selected[1]), (map[string]any{"b": map[string]any{"c": "external"}}); !reflect.DeepEqual(got, want) { - t.Fatalf("ValueForQuery(a) = %#v, want %#v", got, want) - } -} - -func TestSelect_keepsTypedDottedFactFlatByDefault(t *testing.T) { - facts := []ResolvedFact{ - {Name: "a.b.c", Value: "external", Type: "external"}, - } - - selected := Select(facts, []string{"a.b.c", "a.b", "a"}) - if len(selected) != 3 { - t.Fatalf("Select() returned %d facts, want 3", len(selected)) + selected := NewProjection(facts, false).Select([]string{"a.b.c"}) + if len(selected) != 1 { + t.Fatalf("Select() returned %d facts, want 1", len(selected)) } if got := ValueForQuery(selected[0]); got != "external" { t.Fatalf("ValueForQuery(a.b.c) = %#v, want external", got) } - if got := ValueForQuery(selected[1]); got != nil { - t.Fatalf("ValueForQuery(a.b) = %#v, want nil", got) - } - if got := ValueForQuery(selected[2]); got != nil { - t.Fatalf("ValueForQuery(a) = %#v, want nil", got) - } } -func TestSelect_unmatchedQueryWithRegexMetacharacterReturnsNilFact(t *testing.T) { +func TestProjectionUnmatchedQueryWithRegexMetacharacterReturnsNilFact(t *testing.T) { facts := []ResolvedFact{ {Name: "a_loaded_fact", Type: "custom"}, } - selected := Select(facts, []string{"regex(string"}) + selected := NewProjection(facts, false).Select([]string{"regex(string"}) if len(selected) != 1 { t.Fatalf("Select() returned %d facts, want 1", len(selected)) } @@ -68,13 +44,13 @@ func TestSelect_unmatchedQueryWithRegexMetacharacterReturnsNilFact(t *testing.T) } } -func TestSelect_matchesWildcardFactNameLikeRubyQueryParser(t *testing.T) { +func TestProjectionMatchesWildcardFactNameLikeRubyQueryParser(t *testing.T) { facts := []ResolvedFact{ {Name: "ipaddress_.*", Value: "10.0.0.2", Type: "external"}, {Name: "os.family", Value: "Debian", Type: "core"}, } - selected := Select(facts, []string{"ipaddress_ens160"}) + selected := NewProjection(facts, false).Select([]string{"ipaddress_ens160"}) if len(selected) != 1 { t.Fatalf("Select() returned %d facts, want 1", len(selected)) } @@ -91,12 +67,12 @@ func TestSelect_matchesWildcardFactNameLikeRubyQueryParser(t *testing.T) { } } -func TestSelect_wildcardFactNameEscapesOtherRegexpCharacters(t *testing.T) { +func TestProjectionWildcardFactNameEscapesOtherRegexpCharacters(t *testing.T) { facts := []ResolvedFact{ {Name: "metric[prod].*", Value: "literal", Type: "external"}, } - selected := Select(facts, []string{"metric[prod]cpu"}) + selected := NewProjection(facts, false).Select([]string{"metric[prod]cpu"}) if len(selected) != 1 { t.Fatalf("Select() returned %d facts, want 1", len(selected)) } @@ -110,13 +86,13 @@ func TestSelect_wildcardFactNameEscapesOtherRegexpCharacters(t *testing.T) { } } -func TestSelect_doesNotMatchWildcardNameForDottedStructuredQuery(t *testing.T) { +func TestProjectionDoesNotMatchWildcardNameForDottedStructuredQuery(t *testing.T) { facts := []ResolvedFact{ {Name: "ssh.*key", Value: "wildcard", Type: "external"}, {Name: "ssh", Value: map[string]any{"rsa": map[string]any{"key": "structured"}}, Type: "core"}, } - selected := Select(facts, []string{"ssh.rsa.key"}) + selected := NewProjection(facts, false).Select([]string{"ssh.rsa.key"}) if len(selected) != 1 { t.Fatalf("Select() returned %d facts, want 1", len(selected)) } diff --git a/internal/engine/seam_gate_test.go b/internal/engine/seam_gate_test.go new file mode 100644 index 00000000..66ecc04b --- /dev/null +++ b/internal/engine/seam_gate_test.go @@ -0,0 +1,80 @@ +package engine + +import ( + "go/ast" + "go/parser" + "go/token" + "os" + "strings" + "testing" +) + +// TestNoRawHostIOInResolvers freezes the collapsed state: fact resolvers reach +// the host only through the Session seam (s.readFile/readDir/stat/lstat/glob/ +// commandOutput). A raw os.ReadDir/os.ReadFile/os.Stat/os.Lstat/filepath.Glob/ +// exec.Command in a resolver file bypasses the seam and makes the resolver +// untestable with a fake host, so it fails here. +// +// The exclusion list is the documented seam boundary: the seam implementation +// itself (session*.go), the external-fact loader (external.go), the persistent +// cache (cache.go), config parsing (config.go), and the syscall-tagged statfs +// files. Test files are not resolvers and are not scanned. +func TestNoRawHostIOInResolvers(t *testing.T) { + forbidden := map[string]map[string]bool{ + "os": {"ReadDir": true, "ReadFile": true, "Stat": true, "Lstat": true}, + "filepath": {"Glob": true}, + "exec": {"Command": true, "CommandContext": true}, + } + excludedExact := map[string]bool{ + "external.go": true, + "cache.go": true, + "config.go": true, + } + excludedPrefix := []string{"session", "statfs"} + + entries, err := os.ReadDir(".") + if err != nil { + t.Fatal(err) + } + fset := token.NewFileSet() + for _, entry := range entries { + name := entry.Name() + if !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { + continue + } + if excludedExact[name] { + continue + } + skip := false + for _, prefix := range excludedPrefix { + if strings.HasPrefix(name, prefix) { + skip = true + break + } + } + if skip { + continue + } + + file, err := parser.ParseFile(fset, name, nil, 0) + if err != nil { + t.Fatalf("parse %s: %v", name, err) + } + ast.Inspect(file, func(n ast.Node) bool { + sel, ok := n.(*ast.SelectorExpr) + if !ok { + return true + } + pkg, ok := sel.X.(*ast.Ident) + if !ok { + return true + } + if methods := forbidden[pkg.Name]; methods[sel.Sel.Name] { + pos := fset.Position(sel.Pos()) + t.Errorf("%s:%d: raw host I/O %s.%s bypasses the Session seam; route it through s.readFile/readDir/stat/lstat/glob/commandOutput", + name, pos.Line, pkg.Name, sel.Sel.Name) + } + return true + }) + } +} diff --git a/internal/engine/selinux.go b/internal/engine/selinux.go index 3eff6a6e..e743d505 100644 --- a/internal/engine/selinux.go +++ b/internal/engine/selinux.go @@ -2,7 +2,6 @@ package engine import ( "path/filepath" - "runtime" "strings" ) @@ -105,5 +104,5 @@ func readSELinuxEnforce(path string, readFile fileReader) (bool, bool) { // selinuxCoreFacts assembles the selinux category facts (os.selinux), emitted // only on Linux. func selinuxCoreFacts(s *Session) []ResolvedFact { - return selinuxFactsForPlatform(runtime.GOOS, "/proc/self/mounts", "/etc/selinux/config", s.readFile) + return selinuxFactsForPlatform(s.goos(), "/proc/self/mounts", "/etc/selinux/config", s.readFile) } diff --git a/internal/engine/session.go b/internal/engine/session.go index e0119bee..9be806ac 100644 --- a/internal/engine/session.go +++ b/internal/engine/session.go @@ -231,6 +231,8 @@ type Session struct { filesystems memo[[]string] identity memo[map[string]any] dmi memo[map[string]any] + linuxVirtualization memo[linuxVirtualizationInput] + windowsVirtualization memo[windowsVirtualizationInput] } // NewSession returns an empty Session; probes run on first use. @@ -280,6 +282,31 @@ func (s *Session) statMountpoint(path string) (mountStat, bool) { return s.host.statMountpoint(path) } +func (s *Session) getenv(name string) string { + return envValue(s.host.environ(), s.goos(), name) +} + +// envValue returns the value of name in env ("KEY=VALUE" entries, first match +// wins). Lookup is case-insensitive only on windows; every other platform — +// including plan9, whose conventional variables are lowercase — matches +// exactly. Empty names never match. +func envValue(env []string, goos, name string) string { + if name == "" { + return "" + } + windows := goos == "windows" + for _, entry := range env { + key, value, ok := strings.Cut(entry, "=") + if !ok { + continue + } + if key == name || (windows && strings.EqualFold(key, name)) { + return value + } + } + return "" +} + // logr returns the session logger, defaulting to a discard logger so a Session // built as a bare literal never panics. Sessions from NewSessionContext always // carry a non-nil logger (a DiscardHandler unless an Engine supplies one). @@ -427,3 +454,19 @@ func (s *Session) cachedIdentity() map[string]any { func (s *Session) cachedDMI() map[string]any { return s.dmi.get(func() map[string]any { return dmiFact("/sys/class/dmi/id", s.readFile) }) } + +// cachedLinuxVirtualizationInput memoizes the Linux virtualization signal +// gather (DMI reads plus the dmidecode/virt-what/vmware/lspci command set) so +// the virtual fact, the hypervisors tree, and the uptime container gate share +// one gather per discovery. Classification stays a pure derivation over the +// memoized input. +func (s *Session) cachedLinuxVirtualizationInput() linuxVirtualizationInput { + return s.linuxVirtualization.get(func() linuxVirtualizationInput { return currentLinuxVirtualizationInput(s) }) +} + +// cachedWindowsVirtualizationInput memoizes the Windows virtualization gather +// (wmic/CIM computersystem+bios plus the services registry query) shared by the +// virtual fact and the hypervisors tree. +func (s *Session) cachedWindowsVirtualizationInput() windowsVirtualizationInput { + return s.windowsVirtualization.get(func() windowsVirtualizationInput { return currentWindowsVirtualizationInput(s.goos(), s.commandOutput) }) +} diff --git a/internal/engine/session_test.go b/internal/engine/session_test.go index 86d2fae3..0b0ae88f 100644 --- a/internal/engine/session_test.go +++ b/internal/engine/session_test.go @@ -2,6 +2,7 @@ package engine import ( "context" + "errors" "os" "path/filepath" "reflect" @@ -18,17 +19,28 @@ import ( var testSession = NewSession() type fakeHostOS struct { - platform string - runCalls []fakeHostRunCall - runOutput string - runOutputs map[string]string - files map[string][]byte - dirs map[string][]os.DirEntry - stats map[string]os.FileInfo - lstats map[string]os.FileInfo - globs map[string][]string - mountStats map[string]mountStat - environEntries []string + platform string + runCalls []fakeHostRunCall + runOutput string + runOutputs map[string]string + files map[string][]byte + dirs map[string][]os.DirEntry + stats map[string]os.FileInfo + lstats map[string]os.FileInfo + globs map[string][]string + mountStats map[string]mountStat + environEntries []string + // emptyRunDefault makes unmatched run() calls return "" instead of the + // "host-output\n" sentinel, expressing "every command produces no output". + emptyRunDefault bool + // Per-path error maps, consulted before the fixture maps, for injecting + // errors other than the os.ErrNotExist default (e.g. os.ErrPermission). + fileErrs map[string]error + dirErrs map[string]error + statErrs map[string]error + lstatErrs map[string]error + globErrs map[string]error + readFileCalls []string readDirCalls []string globCalls []string @@ -50,6 +62,9 @@ func (h *fakeHostOS) run(_ context.Context, name string, args ...string) string if h.runOutput != "" { return h.runOutput } + if h.emptyRunDefault { + return "" + } return "host-output\n" } @@ -66,6 +81,9 @@ func (h *fakeHostOS) goos() string { func (h *fakeHostOS) readFile(path string) ([]byte, error) { h.readFileCalls = append(h.readFileCalls, fakeHostPath(path)) + if err, ok := h.fileErrs[fakeHostPath(path)]; ok { + return nil, err + } data, ok := h.files[fakeHostPath(path)] if !ok { return nil, os.ErrNotExist @@ -75,6 +93,9 @@ func (h *fakeHostOS) readFile(path string) ([]byte, error) { func (h *fakeHostOS) readDir(path string) ([]os.DirEntry, error) { h.readDirCalls = append(h.readDirCalls, path) + if err, ok := h.dirErrs[fakeHostPath(path)]; ok { + return nil, err + } entries, ok := h.dirs[fakeHostPath(path)] if !ok { return nil, os.ErrNotExist @@ -83,6 +104,9 @@ func (h *fakeHostOS) readDir(path string) ([]os.DirEntry, error) { } func (h *fakeHostOS) stat(path string) (os.FileInfo, error) { + if err, ok := h.statErrs[fakeHostPath(path)]; ok { + return nil, err + } info, ok := h.stats[fakeHostPath(path)] if !ok { return nil, os.ErrNotExist @@ -91,6 +115,9 @@ func (h *fakeHostOS) stat(path string) (os.FileInfo, error) { } func (h *fakeHostOS) lstat(path string) (os.FileInfo, error) { + if err, ok := h.lstatErrs[fakeHostPath(path)]; ok { + return nil, err + } info, ok := h.lstats[fakeHostPath(path)] if !ok { return nil, os.ErrNotExist @@ -100,6 +127,9 @@ func (h *fakeHostOS) lstat(path string) (os.FileInfo, error) { func (h *fakeHostOS) glob(pattern string) ([]string, error) { h.globCalls = append(h.globCalls, pattern) + if err, ok := h.globErrs[fakeHostPath(pattern)]; ok { + return nil, err + } matches, ok := h.globs[fakeHostPath(pattern)] if !ok { return nil, nil @@ -117,8 +147,14 @@ func (h *fakeHostOS) environ() []string { return h.environEntries } +// fakeHostPath normalizes a lookup path to forward slashes so path-keyed +// fixtures use one portable form. It replaces backslashes unconditionally +// rather than via filepath.ToSlash (which only converts on Windows): resolvers +// that force a backslash join for the windows branch — sshCoreFacts keys off +// s.goos()=="windows", not the runtime OS — produce backslash paths on unix +// dev hosts too, and those must still resolve against slash-keyed fixtures. func fakeHostPath(path string) string { - return filepath.ToSlash(path) + return strings.ReplaceAll(path, `\`, "/") } type fakeFileInfo struct { @@ -147,6 +183,9 @@ func (de fakeDirEntry) Info() (os.FileInfo, error) { return fakeFileInfo{name: de.name, mode: de.mode, isDir: de.isDir}, nil } +// fakeDirEntries returns directory entries. Fixtures must list names in +// lexical order: osHost.readDir is os.ReadDir, which sorts, and lease-order +// tests depend on that convention holding in the fake too. func fakeDirEntries(names ...string) []os.DirEntry { entries := make([]os.DirEntry, 0, len(names)) for _, name := range names { @@ -155,6 +194,18 @@ func fakeDirEntries(names ...string) []os.DirEntry { return entries } +// fakeFileEntries returns plain-file entries (IsDir false). Loops that skip +// directories (bonding proc files, DHCP lease dirs) need these — a want-empty +// test fed only fakeDirEntries passes vacuously. Same lexical-order convention +// as fakeDirEntries. +func fakeFileEntries(names ...string) []os.DirEntry { + entries := make([]os.DirEntry, 0, len(names)) + for _, name := range names { + entries = append(entries, fakeDirEntry{name: name}) + } + return entries +} + func TestNewSessionContextDefaultsNilContext(t *testing.T) { s := NewSessionContext(nil) if s.Context() == nil { @@ -513,3 +564,172 @@ func TestOSHostRunDoesNotSearchCallerPath(t *testing.T) { t.Fatalf("osHost.run() = %q, want no output from caller PATH command", got) } } + +func TestFakeHostOSErrorMapsWinOverFixtures(t *testing.T) { + host := &fakeHostOS{ + files: map[string][]byte{"/f": []byte("data")}, + dirs: map[string][]os.DirEntry{"/d": fakeDirEntries("sub")}, + stats: map[string]os.FileInfo{"/f": fakeFileInfo{name: "f"}}, + lstats: map[string]os.FileInfo{"/f": fakeFileInfo{name: "f"}}, + globs: map[string][]string{"/g/*": {"/g/one"}}, + fileErrs: map[string]error{"/f": os.ErrPermission}, + dirErrs: map[string]error{"/d": os.ErrPermission}, + statErrs: map[string]error{"/f": os.ErrPermission}, + lstatErrs: map[string]error{"/f": os.ErrPermission}, + globErrs: map[string]error{"/g/*": os.ErrPermission}, + } + + if _, err := host.readFile("/f"); !errors.Is(err, os.ErrPermission) { + t.Fatalf("readFile err = %v, want ErrPermission", err) + } + if _, err := host.readDir("/d"); !errors.Is(err, os.ErrPermission) { + t.Fatalf("readDir err = %v, want ErrPermission", err) + } + if _, err := host.stat("/f"); !errors.Is(err, os.ErrPermission) { + t.Fatalf("stat err = %v, want ErrPermission", err) + } + if _, err := host.lstat("/f"); !errors.Is(err, os.ErrPermission) { + t.Fatalf("lstat err = %v, want ErrPermission", err) + } + if _, err := host.glob("/g/*"); !errors.Is(err, os.ErrPermission) { + t.Fatalf("glob err = %v, want ErrPermission", err) + } +} + +func TestFakeHostOSEmptyRunDefault(t *testing.T) { + host := &fakeHostOS{ + emptyRunDefault: true, + runOutputs: map[string]string{fakeRunKey("known"): "value\n"}, + } + if got := host.run(context.Background(), "known"); got != "value\n" { + t.Fatalf("run(known) = %q, want value", got) + } + if got := host.run(context.Background(), "unmatched"); got != "" { + t.Fatalf("run(unmatched) = %q, want empty with emptyRunDefault", got) + } + + // Default behavior is unchanged: the sentinel still flags unmatched calls. + plain := &fakeHostOS{} + if got := plain.run(context.Background(), "unmatched"); got != "host-output\n" { + t.Fatalf("run(unmatched) = %q, want host-output sentinel", got) + } +} + +func TestFakeFileEntriesAreNotDirectories(t *testing.T) { + entries := fakeFileEntries("a.lease", "b.lease") + if len(entries) != 2 { + t.Fatalf("len = %d, want 2", len(entries)) + } + for _, entry := range entries { + if entry.IsDir() { + t.Fatalf("entry %q IsDir() = true, want plain file", entry.Name()) + } + } + if entries[0].Name() != "a.lease" || entries[1].Name() != "b.lease" { + t.Fatalf("names = %q,%q, want lexical a.lease,b.lease", entries[0].Name(), entries[1].Name()) + } +} + +func TestEnvValueCasingRegimes(t *testing.T) { + env := []string{"MALFORMED", "Path=C:\\Windows", "path=/plan9/bin\x00/rc/bin", "HOME=/home/u", ""} + + // windows: case-insensitive, first match wins. + if got := envValue(env, "windows", "PATH"); got != "C:\\Windows" { + t.Fatalf("windows PATH = %q, want C:\\Windows", got) + } + // unix: exact match only. + if got := envValue(env, "linux", "PATH"); got != "" { + t.Fatalf("linux PATH = %q, want empty (no exact match)", got) + } + if got := envValue(env, "linux", "HOME"); got != "/home/u" { + t.Fatalf("linux HOME = %q, want /home/u", got) + } + // plan9: lowercase path is a distinct, exactly-matched variable. + if got := envValue(env, "plan9", "path"); got != "/plan9/bin\x00/rc/bin" { + t.Fatalf("plan9 path = %q, want NUL-joined entries", got) + } + // Exact match: "PATH" matches nothing; the Path entry must not leak. + if got := envValue(env, "plan9", "PATH"); got != "" { + t.Fatalf("plan9 PATH = %q, want empty", got) + } + // Empty names never match, even against malformed entries. + if got := envValue(env, "linux", ""); got != "" { + t.Fatalf("empty name = %q, want empty", got) + } +} + +func TestSessionGetenvUsesHostEnviron(t *testing.T) { + s := &Session{host: &fakeHostOS{ + platform: "windows", + environEntries: []string{"ProgramData=C:\\ProgramData"}, + }} + if got := s.getenv("programdata"); got != "C:\\ProgramData" { + t.Fatalf("getenv(programdata) = %q, want C:\\ProgramData", got) + } +} + +// Two accessor calls must run each virtualization gather command exactly once. +// Commands are counted by exact name+args (fakeRunKey): ec2 runs a +// path-qualified virt-what and the BSD DMI probe a path-qualified dmidecode, +// so substring counting would miscount. +func TestSessionLinuxVirtualizationGatherRunsOnce(t *testing.T) { + host := &fakeHostOS{ + platform: "linux", + emptyRunDefault: true, + runOutputs: map[string]string{fakeRunKey("uname", "-r"): "6.1.0-generic\n"}, + } + s := NewSessionContext(context.Background()) + s.host = host + + first := s.cachedLinuxVirtualizationInput() + second := s.cachedLinuxVirtualizationInput() + if !reflect.DeepEqual(first, second) { + t.Fatalf("second gather = %#v, want memoized %#v", second, first) + } + + counts := map[string]int{} + for _, call := range host.runCalls { + counts[fakeRunKey(call.name, call.args...)]++ + } + for _, cmd := range []string{ + fakeRunKey("dmidecode"), + fakeRunKey("virt-what"), + fakeRunKey("vmware", "-v"), + fakeRunKey("lspci"), + fakeRunKey("uname", "-r"), // the gather reads cachedKernelRelease + } { + if counts[cmd] != 1 { + t.Fatalf("command %q ran %d times, want exactly 1 (calls=%v)", cmd, counts[cmd], host.runCalls) + } + } + if len(host.runCalls) != 5 { + t.Fatalf("gather ran %d commands, want 5: %v", len(host.runCalls), host.runCalls) + } +} + +func TestSessionWindowsVirtualizationGatherRunsOnce(t *testing.T) { + // The wmic outputs contain "=" so windowsWMIOutput never falls back to the + // powershell CIM script — the gather is 3 commands, not 5. + host := &fakeHostOS{ + platform: "windows", + emptyRunDefault: true, + runOutputs: map[string]string{ + fakeRunKey("wmic", "computersystem", "get", "Manufacturer,Model,OEMStringArray", "/value"): "Manufacturer=Fake Inc.\nModel=FakeStation\nOEMStringArray={}\n", + fakeRunKey("wmic", "bios", "get", "Manufacturer", "/value"): "Manufacturer=FakeBIOS\n", + }, + } + s := NewSessionContext(context.Background()) + s.host = host + + first := s.cachedWindowsVirtualizationInput() + second := s.cachedWindowsVirtualizationInput() + if !reflect.DeepEqual(first, second) { + t.Fatalf("second gather = %#v, want memoized %#v", second, first) + } + if len(host.runCalls) != 3 { + t.Fatalf("gather ran %d commands, want 3 (wmic x2 + reg query): %v", len(host.runCalls), host.runCalls) + } + if first.Manufacturer != "Fake Inc." || first.BIOSManufacturer != "FakeBIOS" { + t.Fatalf("gather input = %#v, want parsed wmic values", first) + } +} diff --git a/internal/engine/ssh.go b/internal/engine/ssh.go index 9b40d088..49a2ca9d 100644 --- a/internal/engine/ssh.go +++ b/internal/engine/ssh.go @@ -5,9 +5,7 @@ import ( "crypto/sha256" "encoding/base64" "fmt" - "os" "path/filepath" - "runtime" "strings" ) @@ -19,10 +17,6 @@ type sshHostKey struct { SHA256 string } -func discoverSSHHostKeys(readFile fileReader) []sshHostKey { - return discoverSSHHostKeysForPlatform(runtime.GOOS, os.Getenv("programdata"), readFile) -} - func discoverSSHHostKeysForPlatform(goos, programData string, readFile fileReader) []sshHostKey { const maxHostKeys = 20 paths := []string{"/etc/ssh", "/usr/local/etc/ssh", "/etc", "/usr/local/etc", "/etc/opt/ssh"} @@ -145,8 +139,9 @@ func identityPrivileged(identity map[string]any) bool { // sshCoreFacts assembles the ssh category fact (the discovered host-key // fingerprints) for the current host, honoring the Windows privilege gate. func sshCoreFacts(s *Session) []ResolvedFact { + goos := s.goos() identity := s.cachedIdentity() - return sshFactsForPlatformWithPrivilege(runtime.GOOS, identityPrivileged(identity), func() []sshHostKey { - return discoverSSHHostKeys(s.readFile) + return sshFactsForPlatformWithPrivilege(goos, identityPrivileged(identity), func() []sshHostKey { + return discoverSSHHostKeysForPlatform(goos, s.getenv("programdata"), s.readFile) }) } diff --git a/internal/engine/ssh_test.go b/internal/engine/ssh_test.go index 39be0cea..d21fa456 100644 --- a/internal/engine/ssh_test.go +++ b/internal/engine/ssh_test.go @@ -1,6 +1,7 @@ package engine import ( + "context" "os" "path/filepath" "strings" @@ -246,3 +247,61 @@ func TestParseSSHHostPublicKeyRejectsUnknownOrInvalidKeys(t *testing.T) { }) } } + +// A fake windows host drives the full windows assembly path — ProgramData env +// lookup (case-insensitive via the Session env seam), whoami privilege gate, +// and backslash-joined host-key paths — from any development platform. +func TestSSHCoreFactsFakeWindowsHostUsesProgramDataAndPrivilegeGate(t *testing.T) { + host := &fakeHostOS{ + platform: "windows", + environEntries: []string{`ProgramData=C:\ProgramData`}, + runOutputs: map[string]string{ + fakeRunKey("whoami"): "corp\\admin\n", + fakeRunKey("whoami", "/groups"): `BUILTIN\Administrators S-1-5-32-544 Enabled group` + "\n", + }, + files: map[string][]byte{ + // Forward-slash key: production joins the windows path with + // backslashes, but fakeHostOS.readFile looks up through fakeHostPath + // (filepath.ToSlash), so the fixture must use slashes to match on a + // real Windows runner (on unix ToSlash is a no-op). + "C:/ProgramData/ssh/ssh_host_rsa_key.pub": []byte("ssh-rsa AAAA host\n"), + }, + } + s := NewSessionContext(context.Background()) + s.host = host + + facts := sshCoreFacts(s) + if len(facts) != 1 || facts[0].Name != "ssh" { + t.Fatalf("sshCoreFacts() = %#v, want one ssh fact", facts) + } + structured, ok := facts[0].Value.(map[string]any) + if !ok || structured["rsa"] == nil { + t.Fatalf("ssh fact value = %#v, want structured rsa key", facts[0].Value) + } + + // The same host without administrator membership must not discover keys. + unprivileged := &fakeHostOS{ + platform: "windows", + environEntries: []string{`ProgramData=C:\ProgramData`}, + runOutputs: map[string]string{ + fakeRunKey("whoami"): "corp\\user\n", + fakeRunKey("whoami", "/groups"): "Everyone S-1-1-0 Mandatory group\n", + }, + files: map[string][]byte{ + // Forward-slash key: production joins the windows path with + // backslashes, but fakeHostOS.readFile looks up through fakeHostPath + // (filepath.ToSlash), so the fixture must use slashes to match on a + // real Windows runner (on unix ToSlash is a no-op). + "C:/ProgramData/ssh/ssh_host_rsa_key.pub": []byte("ssh-rsa AAAA host\n"), + }, + } + s2 := NewSessionContext(context.Background()) + s2.host = unprivileged + facts = sshCoreFacts(s2) + if len(facts) != 1 || facts[0].Value != nil { + t.Fatalf("unprivileged sshCoreFacts() = %#v, want nil ssh fact", facts) + } + if len(unprivileged.readFileCalls) != 0 { + t.Fatalf("unprivileged discovery read %v, want no host-key reads", unprivileged.readFileCalls) + } +} diff --git a/internal/engine/timezone.go b/internal/engine/timezone.go index bbb8647f..763e113e 100644 --- a/internal/engine/timezone.go +++ b/internal/engine/timezone.go @@ -3,7 +3,6 @@ package engine import ( "bytes" "io" - "runtime" "strconv" "strings" "time" @@ -109,6 +108,6 @@ func windowsCodepageDecoder(codepage string) encoding.Encoding { // timezoneCoreFacts assembles the timezone category fact for the current host. func timezoneCoreFacts(s *Session) []ResolvedFact { return []ResolvedFact{ - {Name: "timezone", Value: currentTimezone(s, runtime.GOOS)}, + {Name: "timezone", Value: currentTimezone(s, s.goos())}, } } diff --git a/internal/engine/uptime.go b/internal/engine/uptime.go index 1901117a..cf75e459 100644 --- a/internal/engine/uptime.go +++ b/internal/engine/uptime.go @@ -2,8 +2,6 @@ package engine import ( "fmt" - "log/slog" - "runtime" "strconv" "strings" "time" @@ -31,49 +29,50 @@ func uptimeString(uptime uptimeInfo) string { } func probeUptime(s *Session) uptimeInfo { - return currentUptimeInfo(s, s.goos(), s.readFile, s.commandOutput, time.Now) + return currentUptimeInfo(s, time.Now) } -func currentUptime(s *Session, goos string, readFile fileReader, run commandRunner, now func() time.Time) time.Duration { - return currentUptimeInfo(s, goos, readFile, run, now).Duration +func currentUptime(s *Session, now func() time.Time) time.Duration { + return currentUptimeInfo(s, now).Duration } -func currentUptimeInfo(s *Session, goos string, readFile fileReader, run commandRunner, now func() time.Time) uptimeInfo { +func currentUptimeInfo(s *Session, now func() time.Time) uptimeInfo { + goos := s.goos() if goos == "windows" { - return currentWindowsUptime(goos, run, s.logr()) + return currentWindowsUptime(s) } if goos == "plan9" { - return currentPlan9Uptime(run) + return currentPlan9Uptime(s.commandOutput) } if goos == "linux" { - virtual := detectLinuxVirtualization(currentLinuxVirtualizationInputWithCommands(s, run)) - return currentLinuxUptimeInfo(readFile, run, now, linuxContainerUptimeUsesPID1(virtual.Name)) + virtual := detectLinuxVirtualization(s.cachedLinuxVirtualizationInput()) + return currentLinuxUptimeInfo(s, now, linuxContainerUptimeUsesPID1(virtual.Name)) } - return currentPosixUptime(readFile, run, now) + return currentPosixUptime(s, now) } func linuxContainerUptimeUsesPID1(name string) bool { return name == "docker" || name == "kubernetes" } -func currentLinuxUptimeInfo(readFile fileReader, run commandRunner, now func() time.Time, docker bool) uptimeInfo { +func currentLinuxUptimeInfo(s *Session, now func() time.Time, docker bool) uptimeInfo { if docker { - seconds := parseDockerElapsedTimeSeconds(run("ps", "-o", "etime=", "-p", "1")) + seconds := parseDockerElapsedTimeSeconds(s.commandOutput("ps", "-o", "etime=", "-p", "1")) if seconds > 0 { return uptimeInfo{Duration: time.Duration(seconds) * time.Second, Known: true} } } - return currentPosixUptime(readFile, run, now) + return currentPosixUptime(s, now) } -func currentPosixUptime(readFile fileReader, run commandRunner, now func() time.Time) uptimeInfo { - if uptime := uptimeFromProc(readFile); uptime > 0 { +func currentPosixUptime(s *Session, now func() time.Time) uptimeInfo { + if uptime := uptimeFromProc(s.readFile); uptime > 0 { return uptimeInfo{Duration: uptime, Known: true} } - if uptime := uptimeFromKernelBoottime(run("sysctl", "-n", "kern.boottime"), now); uptime > 0 { + if uptime := uptimeFromKernelBoottime(s.commandOutput("sysctl", "-n", "kern.boottime"), now); uptime > 0 { return uptimeInfo{Duration: uptime, Known: true} } - if out := run("uptime"); out != "" { + if out := s.commandOutput("uptime"); out != "" { seconds := parseUptimeCommandSeconds(out) if seconds > 0 { return uptimeInfo{Duration: time.Duration(seconds) * time.Second, Known: true} @@ -82,11 +81,12 @@ func currentPosixUptime(readFile fileReader, run commandRunner, now func() time. return uptimeInfo{} } -func currentWindowsUptime(goos string, run commandRunner, log *slog.Logger) uptimeInfo { - if goos != "windows" { +func currentWindowsUptime(s *Session) uptimeInfo { + if s.goos() != "windows" { return uptimeInfo{} } - values := parseWindowsWMIValues(windowsWMIOutput(run, "os", "LocalDateTime,LastBootUpTime")) + log := s.logr() + values := parseWindowsWMIValues(windowsWMIOutput(s.commandOutput, "os", "LocalDateTime,LastBootUpTime")) if len(values) == 0 { log.Debug("WMI query returned no resultsfor Win32_OperatingSystem with values LocalDateTime and LastBootUpTime.") log.Debug("Unable to determine system uptime!") @@ -300,25 +300,25 @@ func parseUptimeHoursMinutes(input string) (int, int, bool) { } func probeLoadAverages(s *Session) map[string]any { - return currentLoadAverages(runtime.GOOS, s.readFile, s.commandOutput) + return currentLoadAverages(s) } -func currentLoadAverages(goos string, readFile fileReader, run commandRunner) map[string]any { - switch goos { +func currentLoadAverages(s *Session) map[string]any { + switch s.goos() { case "darwin", "freebsd", "netbsd", "openbsd", "dragonfly": - out := run("sysctl", "-n", "vm.loadavg") + out := s.commandOutput("sysctl", "-n", "vm.loadavg") if out == "" { return emptyLoadAverages() } return parseLoadAverages(out) case "illumos": - out := run("uptime") + out := s.commandOutput("uptime") if out == "" { return emptyLoadAverages() } return parseIllumosLoadAverages(out) case "linux": - data, err := readFile("/proc/loadavg") + data, err := s.readFile("/proc/loadavg") if err != nil { return emptyLoadAverages() } diff --git a/internal/engine/uptime_test.go b/internal/engine/uptime_test.go index de295d4b..47eed5c9 100644 --- a/internal/engine/uptime_test.go +++ b/internal/engine/uptime_test.go @@ -112,15 +112,12 @@ func TestCurrentUptimeInfoUsesPID1ElapsedTimeForKubernetes(t *testing.T) { files: map[string][]byte{ "/proc/1/cgroup": []byte("0::/kubepods.slice/pod123\n"), }, - } - run := func(name string, args ...string) string { - if name == "ps" && reflect.DeepEqual(args, []string{"-o", "etime=", "-p", "1"}) { - return "01:02" - } - return "" + runOutputs: map[string]string{ + fakeRunKey("ps", "-o", "etime=", "-p", "1"): "01:02", + }, } - got := currentUptimeInfo(s, "linux", s.readFile, run, time.Now) + got := currentUptimeInfo(s, time.Now) want := uptimeInfo{Duration: 62 * time.Second, Known: true} if got != want { t.Fatalf("currentUptimeInfo(kubernetes) = %#v, want %#v", got, want) @@ -186,116 +183,135 @@ func TestParseDockerElapsedTimeSecondsMatchesRubyResolver(t *testing.T) { func TestCurrentUptimeFallsBackToKernelBoottime(t *testing.T) { t.Parallel() - readFile := func(path string) ([]byte, error) { - if path != "/proc/uptime" { - t.Fatalf("readFile path = %q, want /proc/uptime", path) - } - return nil, os.ErrNotExist - } - run := func(name string, args ...string) string { - if name != "sysctl" || !reflect.DeepEqual(args, []string{"-n", "kern.boottime"}) { - t.Fatalf("run = %s %v, want sysctl -n kern.boottime", name, args) - } - return "{ sec = 60, usec = 0 } Tue Oct 10 10:59:00 2019" + host := &fakeHostOS{ + platform: "freebsd", + runOutputs: map[string]string{ + fakeRunKey("sysctl", "-n", "kern.boottime"): "{ sec = 60, usec = 0 } Tue Oct 10 10:59:00 2019", + }, } + s := NewSession() + s.host = host now := func() time.Time { return time.Unix(120, 0) } - got := currentUptime(testSession, "freebsd", readFile, run, now) + got := currentUptime(s, now) if got != time.Minute { - t.Fatalf("currentUptime(testSession) = %s, want 1m0s", got) + t.Fatalf("currentUptime() = %s, want 1m0s", got) + } + if want := []string{"/proc/uptime"}; !reflect.DeepEqual(host.readFileCalls, want) { + t.Fatalf("readFile calls = %#v, want %#v", host.readFileCalls, want) + } + if want := []fakeHostRunCall{{name: "sysctl", args: []string{"-n", "kern.boottime"}}}; !reflect.DeepEqual(host.runCalls, want) { + t.Fatalf("run calls = %#v, want %#v", host.runCalls, want) } } func TestCurrentLinuxUptimeUsesDockerPIDOneElapsedTime(t *testing.T) { t.Parallel() - got := currentLinuxUptimeInfo(func(path string) ([]byte, error) { - t.Fatalf("currentLinuxUptimeInfo() read %q, want Docker ps only", path) - return nil, os.ErrNotExist - }, func(name string, args ...string) string { - if name != "ps" || !reflect.DeepEqual(args, []string{"-o", "etime=", "-p", "1"}) { - t.Fatalf("run = %s %v, want ps -o etime= -p 1", name, args) - } - return "1-3:10:20" - }, time.Now, true) + host := &fakeHostOS{ + runOutputs: map[string]string{ + fakeRunKey("ps", "-o", "etime=", "-p", "1"): "1-3:10:20", + }, + } + s := NewSession() + s.host = host + got := currentLinuxUptimeInfo(s, time.Now, true) if !got.Known || got.Duration != 97_820*time.Second { t.Fatalf("currentLinuxUptimeInfo() = %#v, want known 97820s", got) } + if len(host.readFileCalls) != 0 { + t.Fatalf("currentLinuxUptimeInfo() read %#v, want Docker ps only", host.readFileCalls) + } + if want := []fakeHostRunCall{{name: "ps", args: []string{"-o", "etime=", "-p", "1"}}}; !reflect.DeepEqual(host.runCalls, want) { + t.Fatalf("run calls = %#v, want %#v", host.runCalls, want) + } } func TestCurrentLinuxUptimeFallsBackWhenDockerElapsedTimeInvalid(t *testing.T) { t.Parallel() - got := currentLinuxUptimeInfo(func(path string) ([]byte, error) { - if path != "/proc/uptime" { - t.Fatalf("readFile path = %q, want /proc/uptime", path) - } - return []byte("60.00 10.00"), nil - }, func(name string, args ...string) string { - if name != "ps" { - t.Fatalf("run = %s %v, want ps for Docker fallback", name, args) - } - return "invalid" - }, time.Now, true) + host := &fakeHostOS{ + files: map[string][]byte{ + "/proc/uptime": []byte("60.00 10.00"), + }, + runOutputs: map[string]string{ + fakeRunKey("ps", "-o", "etime=", "-p", "1"): "invalid", + }, + } + s := NewSession() + s.host = host + got := currentLinuxUptimeInfo(s, time.Now, true) if !got.Known || got.Duration != time.Minute { t.Fatalf("currentLinuxUptimeInfo() = %#v, want known 1m", got) } + if want := []string{"/proc/uptime"}; !reflect.DeepEqual(host.readFileCalls, want) { + t.Fatalf("readFile calls = %#v, want %#v", host.readFileCalls, want) + } + if want := []fakeHostRunCall{{name: "ps", args: []string{"-o", "etime=", "-p", "1"}}}; !reflect.DeepEqual(host.runCalls, want) { + t.Fatalf("run calls = %#v, want %#v", host.runCalls, want) + } } func TestCurrentUptimeInfoMarksMissingSourcesUnknown(t *testing.T) { t.Parallel() - readFile := func(path string) ([]byte, error) { - if path != "/proc/uptime" { - t.Fatalf("readFile path = %q, want /proc/uptime", path) - } - return nil, os.ErrNotExist - } - run := func(name string, args ...string) string { - switch name { - case "sysctl": - return "" - case "uptime": - return "running for a while" - default: - t.Fatalf("unexpected command %q %v", name, args) - return "" - } + host := &fakeHostOS{ + platform: "freebsd", + emptyRunDefault: true, + runOutputs: map[string]string{ + fakeRunKey("uptime"): "running for a while", + }, } + s := NewSession() + s.host = host - got := currentUptimeInfo(testSession, "freebsd", readFile, run, time.Now) + got := currentUptimeInfo(s, time.Now) if got.Known || got.Duration != 0 { - t.Fatalf("currentUptimeInfo(testSession) = %#v, want unknown zero duration", got) + t.Fatalf("currentUptimeInfo() = %#v, want unknown zero duration", got) + } + if want := []string{"/proc/uptime"}; !reflect.DeepEqual(host.readFileCalls, want) { + t.Fatalf("readFile calls = %#v, want %#v", host.readFileCalls, want) + } + wantRun := []fakeHostRunCall{ + {name: "sysctl", args: []string{"-n", "kern.boottime"}}, + {name: "uptime"}, + } + if !reflect.DeepEqual(host.runCalls, wantRun) { + t.Fatalf("run calls = %#v, want %#v", host.runCalls, wantRun) } } func TestCurrentUptimeInfoBSDsFallBackToUptimeCommand(t *testing.T) { t.Parallel() - readFile := func(string) ([]byte, error) { - return nil, os.ErrNotExist - } for _, goos := range []string{"openbsd", "netbsd"} { t.Run(goos, func(t *testing.T) { t.Parallel() - got := currentUptimeInfo(testSession, goos, readFile, func(name string, args ...string) string { - switch name { - case "sysctl": - return "" - case "uptime": - return "10:00AM up 1 day, 2:03, 1 user, load averages: 0.01, 0.02, 0.03" - default: - t.Fatalf("run = %s %#v, want sysctl or uptime", name, args) - return "" - } - }, time.Now) + host := &fakeHostOS{ + platform: goos, + emptyRunDefault: true, + runOutputs: map[string]string{ + fakeRunKey("uptime"): "10:00AM up 1 day, 2:03, 1 user, load averages: 0.01, 0.02, 0.03", + }, + } + s := NewSession() + s.host = host + + got := currentUptimeInfo(s, time.Now) want := uptimeInfo{Duration: 26*time.Hour + 3*time.Minute, Known: true} if got != want { t.Fatalf("currentUptimeInfo(%s) = %#v, want %#v", goos, got, want) } + wantRun := []fakeHostRunCall{ + {name: "sysctl", args: []string{"-n", "kern.boottime"}}, + {name: "uptime"}, + } + if !reflect.DeepEqual(host.runCalls, wantRun) { + t.Fatalf("run calls = %#v, want %#v", host.runCalls, wantRun) + } }) } } @@ -303,81 +319,98 @@ func TestCurrentUptimeInfoBSDsFallBackToUptimeCommand(t *testing.T) { func TestCurrentUptimeUsesWindowsWMITimes(t *testing.T) { t.Parallel() - got := currentUptime(testSession, "windows", func(path string) ([]byte, error) { - t.Fatalf("currentUptime(testSession, windows) read %q, want WMI only", path) - return nil, os.ErrNotExist - }, func(name string, args ...string) string { - if name != "wmic" { - t.Fatalf("command = %q %v, want wmic", name, args) - } - wantArgs := []string{"os", "get", "LocalDateTime,LastBootUpTime", "/value"} - if !reflect.DeepEqual(args, wantArgs) { - t.Fatalf("wmic args = %#v, want %#v", args, wantArgs) - } - return "LocalDateTime=20010203045006+0700\r\nLastBootUpTime=20010203030506+0700\r\n" - }, time.Now) + host := &fakeHostOS{ + platform: "windows", + runOutputs: map[string]string{ + fakeRunKey("wmic", "os", "get", "LocalDateTime,LastBootUpTime", "/value"): "LocalDateTime=20010203045006+0700\r\nLastBootUpTime=20010203030506+0700\r\n", + }, + } + s := NewSession() + s.host = host + got := currentUptime(s, time.Now) if got != 105*time.Minute { - t.Fatalf("currentUptime(testSession, windows) = %s, want 1h45m0s", got) + t.Fatalf("currentUptime(windows) = %s, want 1h45m0s", got) + } + if len(host.readFileCalls) != 0 { + t.Fatalf("currentUptime(windows) read %#v, want WMI only", host.readFileCalls) + } + if want := []fakeHostRunCall{{name: "wmic", args: []string{"os", "get", "LocalDateTime,LastBootUpTime", "/value"}}}; !reflect.DeepEqual(host.runCalls, want) { + t.Fatalf("run calls = %#v, want %#v", host.runCalls, want) } } func TestCurrentWindowsUptimeSkipsNonWindows(t *testing.T) { t.Parallel() - got := currentWindowsUptime("linux", func(string, ...string) string { - t.Fatal("currentWindowsUptime(non-windows) ran command") - return "" - }, discardLog()) + host := &fakeHostOS{platform: "linux"} + s := NewSession() + s.host = host + + got := currentWindowsUptime(s) if got != (uptimeInfo{}) { t.Fatalf("currentWindowsUptime(non-windows) = %#v, want empty", got) } + if len(host.runCalls) != 0 { + t.Fatal("currentWindowsUptime(non-windows) ran command") + } } func TestCurrentUptimeReturnsZeroForInvalidWindowsWMITimes(t *testing.T) { t.Parallel() - got := currentUptime(testSession, "windows", func(string) ([]byte, error) { - t.Fatal("currentUptime(testSession, windows) read file, want WMI only") - return nil, os.ErrNotExist - }, func(string, ...string) string { - return "LocalDateTime=20010201110506+0700\r\nLastBootUpTime=20010201120506+0700\r\n" - }, time.Now) + host := &fakeHostOS{ + platform: "windows", + runOutputs: map[string]string{ + fakeRunKey("wmic", "os", "get", "LocalDateTime,LastBootUpTime", "/value"): "LocalDateTime=20010201110506+0700\r\nLastBootUpTime=20010201120506+0700\r\n", + }, + } + s := NewSession() + s.host = host + got := currentUptime(s, time.Now) if got != 0 { - t.Fatalf("currentUptime(testSession, windows invalid times) = %s, want 0", got) + t.Fatalf("currentUptime(windows invalid times) = %s, want 0", got) + } + if len(host.readFileCalls) != 0 { + t.Fatalf("currentUptime(windows) read %#v, want WMI only", host.readFileCalls) } } func TestCurrentWindowsUptimeInfoMarksInvalidWMITimesUnknown(t *testing.T) { t.Parallel() - got := currentUptimeInfo(testSession, "windows", func(string) ([]byte, error) { - t.Fatal("currentUptimeInfo(testSession, windows) read file, want WMI only") - return nil, os.ErrNotExist - }, func(string, ...string) string { - return "LocalDateTime=20010201110506+0700\r\nLastBootUpTime=20010201120506+0700\r\n" - }, time.Now) + host := &fakeHostOS{ + platform: "windows", + runOutputs: map[string]string{ + fakeRunKey("wmic", "os", "get", "LocalDateTime,LastBootUpTime", "/value"): "LocalDateTime=20010201110506+0700\r\nLastBootUpTime=20010201120506+0700\r\n", + }, + } + s := NewSession() + s.host = host + got := currentUptimeInfo(s, time.Now) if got.Known || got.Duration != 0 { - t.Fatalf("currentUptimeInfo(testSession, windows invalid times) = %#v, want unknown zero duration", got) + t.Fatalf("currentUptimeInfo(windows invalid times) = %#v, want unknown zero duration", got) + } + if len(host.readFileCalls) != 0 { + t.Fatalf("currentUptimeInfo(windows) read %#v, want WMI only", host.readFileCalls) } } func TestCurrentWindowsUptimeInfoLogsNoResultDiagnosticsLikeRubyResolver(t *testing.T) { debugMessages := []string{} + host := &fakeHostOS{platform: "windows", emptyRunDefault: true} s := NewSession() + s.host = host s.logger = captureLogger(&debugMessages, nil, nil) - got := currentUptimeInfo(s, "windows", func(string) ([]byte, error) { - t.Fatal("currentUptimeInfo(testSession, windows) read file, want WMI only") - return nil, os.ErrNotExist - }, func(string, ...string) string { - return "" - }, time.Now) - + got := currentUptimeInfo(s, time.Now) if got.Known || got.Duration != 0 { - t.Fatalf("currentUptimeInfo(testSession, windows empty WMI) = %#v, want unknown zero duration", got) + t.Fatalf("currentUptimeInfo(windows empty WMI) = %#v, want unknown zero duration", got) + } + if len(host.readFileCalls) != 0 { + t.Fatalf("currentUptimeInfo(windows) read %#v, want WMI only", host.readFileCalls) } want := []string{ "WMI query returned no resultsfor Win32_OperatingSystem with values LocalDateTime and LastBootUpTime.", @@ -401,11 +434,15 @@ func TestCurrentWindowsUptimeInfoLogsUnparseableWMITimes(t *testing.T) { t.Run(tt.name, func(t *testing.T) { var debugMessages []string s := NewSession() + s.host = &fakeHostOS{ + platform: "windows", + runOutputs: map[string]string{ + fakeRunKey("wmic", "os", "get", "LocalDateTime,LastBootUpTime", "/value"): tt.output, + }, + } s.logger = captureLogger(&debugMessages, nil, nil) - got := currentWindowsUptime("windows", func(string, ...string) string { - return tt.output - }, s.logr()) + got := currentWindowsUptime(s) if got.Known || got.Duration != 0 { t.Fatalf("currentWindowsUptime() = %#v, want unknown zero duration", got) } @@ -419,18 +456,22 @@ func TestCurrentWindowsUptimeInfoLogsUnparseableWMITimes(t *testing.T) { func TestCurrentWindowsUptimeInfoLogsInvalidDurationLikeRubyResolver(t *testing.T) { debugMessages := []string{} + host := &fakeHostOS{ + platform: "windows", + runOutputs: map[string]string{ + fakeRunKey("wmic", "os", "get", "LocalDateTime,LastBootUpTime", "/value"): "LocalDateTime=20010201110506+0700\r\nLastBootUpTime=20010201120506+0700\r\n", + }, + } s := NewSession() + s.host = host s.logger = captureLogger(&debugMessages, nil, nil) - got := currentUptimeInfo(s, "windows", func(string) ([]byte, error) { - t.Fatal("currentUptimeInfo(testSession, windows) read file, want WMI only") - return nil, os.ErrNotExist - }, func(string, ...string) string { - return "LocalDateTime=20010201110506+0700\r\nLastBootUpTime=20010201120506+0700\r\n" - }, time.Now) - + got := currentUptimeInfo(s, time.Now) if got.Known || got.Duration != 0 { - t.Fatalf("currentUptimeInfo(testSession, windows invalid times) = %#v, want unknown zero duration", got) + t.Fatalf("currentUptimeInfo(windows invalid times) = %#v, want unknown zero duration", got) + } + if len(host.readFileCalls) != 0 { + t.Fatalf("currentUptimeInfo(windows) read %#v, want WMI only", host.readFileCalls) } want := []string{"Unable to determine system uptime!"} if !reflect.DeepEqual(debugMessages, want) { @@ -525,61 +566,83 @@ func TestParseMacOSLoadAverages(t *testing.T) { func TestCurrentLoadAverages_wiresBSDVMLoadavg(t *testing.T) { for _, goos := range []string{"freebsd", "openbsd", "netbsd", "dragonfly"} { t.Run(goos, func(t *testing.T) { - got := currentLoadAverages(goos, nil, func(path string, args ...string) string { - if path != "sysctl" || !reflect.DeepEqual(args, []string{"-n", "vm.loadavg"}) { - t.Fatalf("run(%q, %#v), want sysctl -n vm.loadavg", path, args) - } - return "{ 0.01 0.02 0.03 }" - }) - want := map[string]any{"1m": 0.01, "5m": 0.02, "15m": 0.03} + host := &fakeHostOS{ + platform: goos, + runOutputs: map[string]string{ + fakeRunKey("sysctl", "-n", "vm.loadavg"): "{ 0.01 0.02 0.03 }", + }, + } + s := NewSession() + s.host = host + got := currentLoadAverages(s) + want := map[string]any{"1m": 0.01, "5m": 0.02, "15m": 0.03} if !reflect.DeepEqual(got, want) { t.Fatalf("currentLoadAverages() = %#v, want %#v", got, want) } + if want := []fakeHostRunCall{{name: "sysctl", args: []string{"-n", "vm.loadavg"}}}; !reflect.DeepEqual(host.runCalls, want) { + t.Fatalf("run calls = %#v, want %#v", host.runCalls, want) + } }) } } func TestCurrentLoadAverages_wiresIllumosUptime(t *testing.T) { - got := currentLoadAverages("illumos", nil, func(path string, args ...string) string { - if path != "uptime" || len(args) != 0 { - t.Fatalf("run(%q, %#v), want uptime", path, args) - } - return "22:09:38 up 3:04, 0 users, load average: 0.00, 0.01, 0.02" - }) - want := map[string]any{"1m": 0.00, "5m": 0.01, "15m": 0.02} + host := &fakeHostOS{ + platform: "illumos", + runOutputs: map[string]string{ + fakeRunKey("uptime"): "22:09:38 up 3:04, 0 users, load average: 0.00, 0.01, 0.02", + }, + } + s := NewSession() + s.host = host + got := currentLoadAverages(s) + want := map[string]any{"1m": 0.00, "5m": 0.01, "15m": 0.02} if !reflect.DeepEqual(got, want) { t.Fatalf("currentLoadAverages(illumos) = %#v, want %#v", got, want) } + if want := []fakeHostRunCall{{name: "uptime"}}; !reflect.DeepEqual(host.runCalls, want) { + t.Fatalf("run calls = %#v, want %#v", host.runCalls, want) + } } func TestCurrentLoadAverages_wiresDarwinVMLoadavg(t *testing.T) { - got := currentLoadAverages("darwin", nil, func(path string, args ...string) string { - if path != "sysctl" || !reflect.DeepEqual(args, []string{"-n", "vm.loadavg"}) { - t.Fatalf("run(%q, %#v), want sysctl -n vm.loadavg", path, args) - } - return "{ 0.00 0.03 0.03 }" - }) - want := map[string]any{"1m": 0.00, "5m": 0.03, "15m": 0.03} + host := &fakeHostOS{ + platform: "darwin", + runOutputs: map[string]string{ + fakeRunKey("sysctl", "-n", "vm.loadavg"): "{ 0.00 0.03 0.03 }", + }, + } + s := NewSession() + s.host = host + got := currentLoadAverages(s) + want := map[string]any{"1m": 0.00, "5m": 0.03, "15m": 0.03} if !reflect.DeepEqual(got, want) { t.Fatalf("currentLoadAverages() = %#v, want %#v", got, want) } + if want := []fakeHostRunCall{{name: "sysctl", args: []string{"-n", "vm.loadavg"}}}; !reflect.DeepEqual(host.runCalls, want) { + t.Fatalf("run calls = %#v, want %#v", host.runCalls, want) + } } func TestCurrentLoadAverages_linuxUnreadableProcLoadavgMatchesRubyResolver(t *testing.T) { - got := currentLoadAverages("linux", func(path string) ([]byte, error) { - if path != "/proc/loadavg" { - t.Fatalf("readFile(%q), want /proc/loadavg", path) - } - return nil, os.ErrPermission - }, nil) - want := map[string]any{"1m": nil, "5m": nil, "15m": nil} + host := &fakeHostOS{ + platform: "linux", + fileErrs: map[string]error{"/proc/loadavg": os.ErrPermission}, + } + s := NewSession() + s.host = host + got := currentLoadAverages(s) + want := map[string]any{"1m": nil, "5m": nil, "15m": nil} if !reflect.DeepEqual(got, want) { t.Fatalf("currentLoadAverages() = %#v, want %#v", got, want) } + if want := []string{"/proc/loadavg"}; !reflect.DeepEqual(host.readFileCalls, want) { + t.Fatalf("readFile calls = %#v, want %#v", host.readFileCalls, want) + } } func TestParseLoadAveragesInvalidInput(t *testing.T) { @@ -620,14 +683,20 @@ func TestUptimeCommandParsersRejectMalformedDurations(t *testing.T) { func TestLoadAverageParsersUseEmptyFallbacks(t *testing.T) { t.Parallel() + sessionFor := func(platform string) *Session { + s := NewSession() + s.host = &fakeHostOS{platform: platform, emptyRunDefault: true} + return s + } + 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: "bsd empty sysctl", got: currentLoadAverages(sessionFor("freebsd"))}, + {name: "illumos empty uptime", got: currentLoadAverages(sessionFor("illumos"))}, + {name: "unknown goos", got: currentLoadAverages(sessionFor("aix"))}, {name: "bad float", got: parseLoadAverages("0.01 bad 0.03")}, {name: "illumos no marker", got: parseIllumosLoadAverages("22:09:38 up 3:04")}, } @@ -640,7 +709,7 @@ func TestLoadAverageParsersUseEmptyFallbacks(t *testing.T) { }) } - if got := currentLoadAverages("plan9", nil, nil); got != nil { + if got := currentLoadAverages(sessionFor("plan9")); got != nil { t.Fatalf("currentLoadAverages(plan9) = %#v, want nil", got) } } diff --git a/internal/engine/virtual.go b/internal/engine/virtual.go index 122fc4df..e05c9461 100644 --- a/internal/engine/virtual.go +++ b/internal/engine/virtual.go @@ -1,7 +1,6 @@ package engine import ( - "os" "regexp" "strconv" "strings" @@ -118,7 +117,7 @@ type windowsVirtualizationInput struct { func detectVirtualization(s *Session) virtualization { switch s.goos() { case "linux": - return detectLinuxVirtualization(currentLinuxVirtualizationInput(s)) + return detectLinuxVirtualization(s.cachedLinuxVirtualizationInput()) case "darwin": return detectMacOSVirtualization(s.cachedMacOSSystemProfilerHardware()) case "freebsd": @@ -132,7 +131,7 @@ func detectVirtualization(s *Session) virtualization { case "illumos": return detectDMIHostVirtualization(currentIllumosVirtualizationInput(s.commandOutput)) case "windows": - return detectWindowsVirtualization(currentWindowsVirtualizationInput(s.goos(), s.commandOutput)) + return detectWindowsVirtualization(s.cachedWindowsVirtualizationInput()) case "plan9": return virtualization{Unknown: true} default: @@ -335,7 +334,7 @@ func currentWindowsHypervisorFacts(s *Session) []ResolvedFact { if s.goos() != "windows" { return nil } - return windowsHypervisorFacts(currentWindowsVirtualizationInput(s.goos(), s.commandOutput)) + return windowsHypervisorFacts(s.cachedWindowsVirtualizationInput()) } func windowsHypervisorFacts(input windowsVirtualizationInput) []ResolvedFact { @@ -381,17 +380,14 @@ func windowsXenContext(model string) string { } func currentLinuxVirtualizationInput(s *Session) linuxVirtualizationInput { - return currentLinuxVirtualizationInputWithCommands(s, s.commandOutput) -} - -func currentLinuxVirtualizationInputWithCommands(s *Session, run commandRunner) linuxVirtualizationInput { + run := s.commandOutput return linuxVirtualizationInput{ CGroup: readLinuxCGroup(s.readFile), - DockerEnv: fileExistsWithHost(s.host, "/.dockerenv"), - ContainerEnv: fileExistsWithHost(s.host, "/run/.containerenv"), - ProcVZ: dirExistsWithHost(s.host, "/proc/vz"), - LVEList: fileExistsWithHost(s.host, "/proc/lve/list"), - ProcVZEntries: procVZEntryCount("/proc/vz"), + DockerEnv: fileExists(s.host, "/.dockerenv"), + ContainerEnv: fileExists(s.host, "/run/.containerenv"), + ProcVZ: dirExists(s.host, "/proc/vz"), + LVEList: fileExists(s.host, "/proc/lve/list"), + ProcVZEntries: procVZEntryCount(s.host, "/proc/vz"), ProcStatus: readText("/proc/self/status", s.readFile), ContainerRuntime: containerRuntimeFromEnviron( readText("/proc/1/environ", s.readFile), @@ -575,11 +571,7 @@ func openVZEnvID(input linuxVirtualizationInput) (int, bool) { return 0, false } -func readLinuxCGroup(readFiles ...fileReader) string { - readFile := osHost{}.readFile - if len(readFiles) > 0 && readFiles[0] != nil { - readFile = readFiles[0] - } +func readLinuxCGroup(readFile fileReader) string { data, err := readFile("/proc/1/cgroup") if err != nil { return "" @@ -587,22 +579,18 @@ func readLinuxCGroup(readFiles ...fileReader) string { return string(data) } -func fileExists(path string) bool { - return fileExistsWithHost(osHost{}, path) -} - -func fileExistsWithHost(host hostOS, path string) bool { +func fileExists(host hostOS, path string) bool { _, err := host.stat(path) return err == nil } -func dirExistsWithHost(host hostOS, path string) bool { +func dirExists(host hostOS, path string) bool { info, err := host.stat(path) return err == nil && info.IsDir() } -func procVZEntryCount(path string) int { - entries, err := os.ReadDir(path) +func procVZEntryCount(host hostOS, path string) int { + entries, err := host.readDir(path) if err != nil { return 0 } @@ -613,7 +601,7 @@ func currentLinuxHypervisorFacts(s *Session) []ResolvedFact { if s.goos() != "linux" { return nil } - return linuxHypervisorFacts(currentLinuxVirtualizationInput(s)) + return linuxHypervisorFacts(s.cachedLinuxVirtualizationInput()) } func linuxHypervisorFacts(input linuxVirtualizationInput) []ResolvedFact { diff --git a/internal/engine/virtual_test.go b/internal/engine/virtual_test.go index c657fc6e..a2429ef3 100644 --- a/internal/engine/virtual_test.go +++ b/internal/engine/virtual_test.go @@ -3,7 +3,6 @@ package engine import ( "context" "os" - "path/filepath" "reflect" "runtime" "testing" @@ -700,22 +699,20 @@ func TestCurrentLinuxVirtualizationInputReadsHostSignals(t *testing.T) { } 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) + host := &fakeHostOS{ + dirs: map[string][]os.DirEntry{ + "/proc/vz": fakeFileEntries("veinfo", "vestat"), + "/proc/vz-empty": {}, + }, } - if got := procVZEntryCount(dir); got != 4 { + if got := procVZEntryCount(host, "/proc/vz"); got != 4 { t.Fatalf("procVZEntryCount() = %d, want entries plus resolver offset 4", got) } - emptyDir := t.TempDir() - if got := procVZEntryCount(emptyDir); got != 2 { + if got := procVZEntryCount(host, "/proc/vz-empty"); got != 2 { t.Fatalf("procVZEntryCount(empty) = %d, want resolver offset 2", got) } - if got := procVZEntryCount(filepath.Join(dir, "missing")); got != 0 { + if got := procVZEntryCount(host, "/proc/vz/missing"); got != 0 { t.Fatalf("procVZEntryCount(missing) = %d, want 0", got) } } @@ -1485,10 +1482,11 @@ func TestLinuxHypervisorFactsReturnsNilContainerFactsWhenAbsent(t *testing.T) { } func TestCurrentLinuxVirtualizationInputReadsDMIDecodeMetadata(t *testing.T) { - got := currentLinuxVirtualizationInputWithCommands(testSession, func(name string, args ...string) string { - switch name { - case "dmidecode": - return `BIOS Information + host := &fakeHostOS{ + platform: "linux", + emptyRunDefault: true, + runOutputs: map[string]string{ + fakeRunKey("dmidecode"): `BIOS Information Vendor: innotek GmbH Version: VirtualBox Address: 0xE0000 @@ -1496,21 +1494,15 @@ func TestCurrentLinuxVirtualizationInputReadsDMIDecodeMetadata(t *testing.T) { OEM Strings String 1: vboxVer_6.1.4 String 2: vboxRev_136177 -` - case "lspci": - return "" - case "virt-what": - return "kvm" - case "vmware": - if len(args) != 1 || args[0] != "-v" { - t.Fatalf("vmware args = %#v, want [-v]", args) - } - return "" - default: - t.Fatalf("unexpected command = %q", name) - return "" - } - }) +`, + fakeRunKey("virt-what"): "kvm", + fakeRunKey("vmware", "-v"): "", + }, + } + s := NewSessionContext(context.Background()) + s.host = host + + got := currentLinuxVirtualizationInput(s) want := dmiDecodeHypervisorInfo{VirtualBoxVersion: "6.1.4", VirtualBoxRevision: "136177"} if got.DMIDecodeInfo != want { @@ -1632,3 +1624,61 @@ func mapsEqual(got, want map[string]any) bool { } return true } + +// One discovery must gather the virtualization signals exactly once, however +// many consumers read them: the virtual/is_virtual facts, the hypervisors +// tree, and the uptime container gate all share the Session memo. +func TestBuildCoreFactsGathersVirtualizationOncePerDiscovery(t *testing.T) { + t.Run("linux", func(t *testing.T) { + // Gather outputs pinned empty so classification is physical — the + // assertion is about probe count, not classification. + host := &fakeHostOS{ + platform: "linux", + emptyRunDefault: true, + runOutputs: map[string]string{fakeRunKey("uname", "-r"): "6.1.0-generic\n"}, + } + s := NewSessionContext(context.Background()) + s.host = host + + buildCoreFacts(s, nil) + + counts := map[string]int{} + for _, call := range host.runCalls { + counts[fakeRunKey(call.name, call.args...)]++ + } + for _, cmd := range []string{ + fakeRunKey("dmidecode"), + fakeRunKey("virt-what"), + fakeRunKey("vmware", "-v"), + fakeRunKey("lspci"), + } { + if counts[cmd] != 1 { + t.Fatalf("command %q ran %d times across virtual+hypervisors+uptime, want exactly 1", cmd, counts[cmd]) + } + } + }) + + t.Run("windows", func(t *testing.T) { + host := &fakeHostOS{ + platform: "windows", + emptyRunDefault: true, + runOutputs: map[string]string{ + fakeRunKey("wmic", "computersystem", "get", "Manufacturer,Model,OEMStringArray", "/value"): "Manufacturer=Fake Inc.\nModel=FakeStation\nOEMStringArray={}\n", + fakeRunKey("wmic", "bios", "get", "Manufacturer", "/value"): "Manufacturer=FakeBIOS\n", + }, + } + s := NewSessionContext(context.Background()) + s.host = host + + buildCoreFacts(s, nil) + + counts := map[string]int{} + for _, call := range host.runCalls { + counts[fakeRunKey(call.name, call.args...)]++ + } + gather := fakeRunKey("wmic", "computersystem", "get", "Manufacturer,Model,OEMStringArray", "/value") + if counts[gather] != 1 { + t.Fatalf("windows wmic gather ran %d times across virtual+hypervisors, want exactly 1", counts[gather]) + } + }) +} diff --git a/internal/engine/xen.go b/internal/engine/xen.go index 99374e30..ece67009 100644 --- a/internal/engine/xen.go +++ b/internal/engine/xen.go @@ -30,7 +30,7 @@ func detectXenVM(s *Session) string { if strings.Contains(readFileString("/proc/xen/capabilities", s.readFile), "control_d") { return "xen0" } - return detectXenVMFromSignals(fileExistsWithHost(s.host, "/dev/xen/evtchn"), dirExistsWithHost(s.host, "/proc/xen"), fileExistsWithHost(s.host, "/dev/xvda1"), isSymlink("/dev/xvda1", s.lstat)) + return detectXenVMFromSignals(fileExists(s.host, "/dev/xen/evtchn"), dirExists(s.host, "/proc/xen"), fileExists(s.host, "/dev/xvda1"), isSymlink("/dev/xvda1", s.lstat)) } func detectXenVMFromSignals(evtchn, procXen, xvda1, xvda1Symlink bool) string { @@ -44,17 +44,13 @@ func detectXenVMFromSignals(evtchn, procXen, xvda1, xvda1Symlink bool) string { } func detectXenDomains(s *Session) []string { - 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) + bin := selectXenCommand(func(path string) bool { + return fileExists(s.host, path) + }) if bin == "" { return nil } - out := run(bin, "list") + out := s.commandOutput(bin, "list") if out == "" { return nil } diff --git a/internal/engine/xen_test.go b/internal/engine/xen_test.go index 48747e9c..06026228 100644 --- a/internal/engine/xen_test.go +++ b/internal/engine/xen_test.go @@ -79,32 +79,44 @@ 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) +func TestDetectXenDomainsRunsSelectedToolstack(t *testing.T) { + xlHost := func(output string) *fakeHostOS { + return &fakeHostOS{ + platform: "linux", + emptyRunDefault: true, + stats: map[string]os.FileInfo{"/usr/sbin/xl": fakeFileInfo{name: "xl"}}, + runOutputs: map[string]string{fakeRunKey("/usr/sbin/xl", "list"): output}, } - 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" - }) + } + + host := xlHost("Name ID Mem VCPUs State Time(s)\nDomain-0 0 4096 4 r----- 100.0\nguest-web 1 2048 2 -b---- 10.0\n") + s := NewSession() + s.host = host want := []string{"guest-web"} - if !reflect.DeepEqual(got, want) { - t.Fatalf("detectXenDomainsWithCommand() = %#v, want %#v", got, want) + if got := detectXenDomains(s); !reflect.DeepEqual(got, want) { + t.Fatalf("detectXenDomains() = %#v, want %#v", got, want) + } + wantCall := []fakeHostRunCall{{name: "/usr/sbin/xl", args: []string{"list"}}} + if !reflect.DeepEqual(host.runCalls, wantCall) { + t.Fatalf("commands = %#v, want xl list", host.runCalls) } - 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) + // No toolstack present: no command runs. + noStack := &fakeHostOS{platform: "linux", emptyRunDefault: true} + sNo := NewSession() + sNo.host = noStack + if got := detectXenDomains(sNo); got != nil { + t.Fatalf("detectXenDomains(no command) = %#v, want nil", got) + } + if len(noStack.runCalls) != 0 { + t.Fatalf("ran %#v without a Xen toolstack, want no commands", noStack.runCalls) } - 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) + // Toolstack present but no output: nil domains. + sEmpty := NewSession() + sEmpty.host = xlHost("") + if got := detectXenDomains(sEmpty); got != nil { + t.Fatalf("detectXenDomains(no output) = %#v, want nil", got) } } diff --git a/openspec/changes/archive/2026-07-03-deepen-engine-seams/.openspec.yaml b/openspec/changes/archive/2026-07-03-deepen-engine-seams/.openspec.yaml new file mode 100644 index 00000000..8e26fbec --- /dev/null +++ b/openspec/changes/archive/2026-07-03-deepen-engine-seams/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-02 diff --git a/openspec/changes/archive/2026-07-03-deepen-engine-seams/design.md b/openspec/changes/archive/2026-07-03-deepen-engine-seams/design.md new file mode 100644 index 00000000..802c21db --- /dev/null +++ b/openspec/changes/archive/2026-07-03-deepen-engine-seams/design.md @@ -0,0 +1,114 @@ +## Context + +`internal/engine` already has one real host-I/O seam: `Session.host` (the unexported `hostOS` interface) with two adapters — `osHost` in production, `fakeHostOS` in tests. But most resolvers predate it, so a second seam made of function-typed `commandRunner`/`fileReader` parameters and `FromRoot`/`WithHost`/`WithReader`/`WithRunner`/`ForPlatform` variant families runs in parallel, `core.go`'s reader helpers silently fall back to a raw `osHost{}` when the optional reader is omitted, and a handful of call sites bypass both seams with raw `os.ReadDir`/`os.Stat`. The same pattern repeats one level up: category assemblies read `runtime.GOOS` and `os.Getenv` directly instead of `s.goos()`/the host environ, the expensive virtualization gather is the only repeated probe without a Session memo, and several modules keep test-only or dead entrances (`detector.go`, `query.go` delegates, `LoadExternalFacts`, `gceFacts`, `filehelper.go`, the fast-path crutch exports, the bare `Format*` functions). + +This change collapses each of those onto the seam that already owns the concern. It is the deferred follow-on recorded in ADR-0010 and the archived 2026-06-17 deepen-engine-internals design, extended by six adjacent deepenings found by the same deletion-test review. Every candidate here was deep-dived at implementation depth and adversarially red-teamed; the red-team corrections below are binding, not advisory. + +Hard constraints restated (decided, not re-litigated): + +- ADR-0002: canonical dynamic tree only. +- ADR-0003: hermetic-vs-system-following split — `config.go`/`cache.go` default-path GOOS reads stay outside the Session seam (they are the system-following CLI layer and have no Session in scope). +- ADR-0005: memos are Session-resident per discovery, never Engine-resident; `envValue` is a per-call scan, not a memo. +- ADR-0010: pure `parse*`/`detect*(input)`/goos-string-parameter signatures unchanged; no GOOS-suffixed filenames for cross-platform logic. +- Output contract and input contract are binding: zero user-visible change. Contract test files may not be modified. + +## Goals / Non-Goals + +**Goals:** + +- Make the Session host seam the only resolver host-I/O path, structurally enforced by a gate test. +- Make category platform dispatch and env lookup flow through Session accessors so windows/plan9 assembly paths are exercisable with a fake host, and the ssh/fips gating-test skips disappear. +- Run the host-virtualization gather once per discovery, with `virtual`/`is_virtual`, `hypervisors.*`, and the uptime container gate reading one memoized input. +- Delete every dead or test-only entrance found by the review: `detector.go`, the `query.go` delegates, `LoadExternalFacts`/`WithBlocklist`, `gceFacts`, `filehelper.go`, `EnvironmentDisabledFacts`, `DisabledFactsForFiltering`, and the production exports of the four bare `Format*` functions. +- Concentrate the cloud-metadata transport invariants (proxy-less client, 200-required, 1MB cap, fail-closed) in one helper with the first tests to ever assert them. +- Collapse Discover's duplicated external-loader arms and `internal/app`'s disabled-union mirror onto their owners. +- Converge resolver tests on `fakeHostOS` under a same-assertions-relocated-fixtures rule. + +**Non-Goals:** + +- No deletion of the `probe*` memo-filler layer — those functions are wired to `Session.cached*` and most contain real platform dispatch; only the parameter-taking `current*`/`ForPlatform`/`With*` siblings collapse. +- No conversion of single-seam parameterized functions whose readers/runners are already bound once from a Session-holding assembly (the packages per-source listers — freshly reviewed code, `windowsWMIOutput`, plan9 helpers, `snapshotProvider`). +- No new `hostOS` methods for `getenv`/`now`/`lookPath`/`net.Interfaces` — those parameters survive. Accepted, recorded leaks: `exec.LookPath` in the linux distro probe (fires only when goos=="linux", degrades gracefully; it also bypasses the hardened-PATH policy — a separate follow-up) and identity's uid/gid/`osuser.Current` syscalls. +- No loader-owns-policy redesign and no `(facts, fatal, soft)` dual-error return — the error truth table already lives in the loader; Discover keeps one documented mode-conditional branch. +- No exposing of engine plan external-dirs and no Engine plan-inputs method — the `internal/app` dir merge feeds CLI-owned `--no-external-facts`/`--external-dir` conflict validation and the list-groups path, so engine exposure removes nothing (and a session-less plan method would invite ADR-0005 scrutiny for zero gain). +- No change to the two classification ladders in `virtual.go` — the kubepods divergence is test-pinned, and reconciling them is behavior-affecting work for a separate change. +- No formatter rewrite, no `Projection`/`FactCache` changes, no fact schema or release-target change. + +## Decisions + +**1. Collapse onto the existing seam; delete the duplicating one.** + +The Session/`hostOS` seam wins because it already has two adapters (`osHost`, `fakeHostOS`) — a real seam by definition — while the parameter threading is a per-function hypothetical seam re-invented ~126 times. I/O-doing `current*`/`detect*`/`discover*` functions take the Session (or `hostOS`) and production paths are hardcoded (`rootedPath(root="/")` is identity, so path strings are unchanged). `core.go`'s `readFileString`/`isSymlink`/`readSysfsString`/`readDMIString` take a required reader — a signature-only edit; all 17 production call sites already pass one explicitly. + +Red-team corrections carried: networking is 10 variants→4 functions (metadata, bonding, `linuxDHCPServer`, `linuxDHCPServerFromLeaseDir`); dmi has FIVE `current*DMIFacts`/`ForPlatform` pairs (FreeBSD, DragonFly, OpenBSD, NetBSD, Illumos) plus `currentWindowsDMI` (dmi.go:415) which must be folded in or explicitly excluded with a recorded reason; xen's remaining closure seam is the `detectXenDomains` pair only (`detectXenVM` is already Session-based); uptime keeps its `now func() time.Time` parameter. + +Rejected alternative: also deleting the 30 `probe*` functions (the reviewed candidate's original shape). They are the memo-filler layer wired at session.go:326-429 and most contain real dispatch logic; deleting them is relocation churn with no seam removed. + +**2. A grep-gate test freezes the collapsed state.** + +`TestNoRawHostIOInResolvers` asserts no `os.ReadDir`/`os.ReadFile`/`os.Stat`/`os.Lstat`/`filepath.Glob`/`exec.Command` outside the canonical exclusion list: `session*.go`, `external.go`, `cache.go`, `config.go`, `statfs*`, and `*_test.go`. `filehelper.go` is deleted (dead code — zero callers outside its own test) rather than excluded. Without the gate, the leak class regrows; with it, the collapse is structural, not conventional. + +**3. Extend `fakeHostOS` additively only, before any migration.** + +Confirmed gaps: errors are hardwired to `os.ErrNotExist` (tests injecting `os.ErrPermission` lose coverage), unmatched `run()` falls through to the sentinel `"host-output\n"` ("every command returns empty" is inexpressible), and `fakeDirEntries` hardcodes every entry as a directory — while the bonding and DHCP lease-dir loops skip `IsDir` entries, so migrating those tests without a file-entry helper makes want-empty tests pass vacuously. Additions: per-path error maps, an explicit empty-run-default knob, and a file-entry helper. Defaults never change — existing fake-based tests keep their meaning. No per-call sequencing feature is needed (closure counters only assert was-called, which the existing `runCalls`/`readDirCalls` recorders cover). Test migration follows a same-assertions-relocated-fixtures rule: `t.Fatal`-on-call closures become `len(h.readDirCalls)==0` assertions, injected error types become error-map entries; assertions are never dropped. + +**4. One pure env helper, goos-conditional, fed by the host.** + +`envValue(env []string, goos, name string)` is case-insensitive ONLY when `goos=="windows"` (first match wins), exact elsewhere — plan9's lowercase `path` and unix case-sensitivity are preserved. (The existing `systemRootFromEnv`'s unconditional `EqualFold` is the wrong template.) `currentPathEntries` switches its separator from compile-time `os.PathListSeparator` to the existing `corePathListSeparator(goos)`. The orphaned `discoverSSHHostKeys` and `currentWindowsProcessWOW64` thin wrappers are deleted. The whole `runtime.GOOS`→`s.goos()` conversion class (~30 sites) is one intentional latent-drift fix: production-identical because `osHost.goos()` returns `runtime.GOOS` and the only production Session constructor uses `osHost{}`. + +**5. Memoize the virtualization gather's raw input, not its classification.** + +Two Session memos — `linuxVirtualization memo[linuxVirtualizationInput]`, `windowsVirtualization memo[windowsVirtualizationInput]` with `cached*Input()` accessors (cachedDMI pattern) — routed to the five gather call sites (detectVirtualization linux+windows branches, both hypervisor fact builders, the uptime container gate). Classification stays pure and derives on demand, so the untouched ladders keep their pinned outputs. Red-team correction: the `currentLinuxVirtualizationInputWithCommands` wrapper KEEPS existing until the session-io group folds it (it has a live test caller at virtual_test.go:1488, which migrates to `fakeHostOS` in that same fold). + +Rejected alternative: memoizing the classified result — it would fuse the two ladders' inputs and outputs and invite exactly the reconciliation this change excludes. + +**6. Cloud transport helper is stateless functions, not an interface.** + +`metadatahttp.go`: `newMetadataHTTPClient(timeout)` and `fetchMetadata(ctx, client, method, url, headers) (body, respHeader, ok)` plus one shared 1MB cap const. Four plumbing copies (ec2 `getRaw`, ec2 `v2Token` request leg, gce `get`, az `metadata`) and three nil-default ctors convert. Provider files keep everything provider-specific: EC2 token cache/TTL and conditional token injection, untrimmed userdata, GCE `Metadata-Flavor` response validation (via the returned header) and TrimSpace, Azure JSON/empty-map error shape, hypervisor/BIOS gates, per-provider timeouts (az 5s, ec2/gce 100ms). Ctor signatures `(baseURL, *http.Client)` — the httptest injection seam — are unchanged. Dead `gceFacts` is deleted first; six tests reference it (five in gce_test.go, one in ec2_test.go): retarget to production entry points with production empty-case expectations (`{gce: nil}`), deleting `TestGCEFactsSkipNilClient` as an exact duplicate of the existing nil-client test. + +Rejected alternative: a provider interface with method/header/flavor hooks — permanent interface surface for three call sites; functions suffice. + +**7. Discover keeps the abort decision; the loader keeps the policy it already owns.** + +The two near-identical loader arms in `Discover` (engine.go:199-221) collapse to one construction and one `load()` call with a single commented abort-vs-accumulate branch on `plan.loaderMode`. The CLI error path stays byte-identical: `return newSnapshot(nil, s.logger), err` — bare loader error, earlier planFailures discarded, `finish()`'s ctx.Err() join skipped; the library path appends to failures and keeps partial facts (`externalFacts` assigned unconditionally). `load()`/`loadDirFacts` internals and the verified error truth table (env null-byte: CLI hard/library soft; dir-read ErrNotExist: both silent; dir-read other and stat: CLI hard/library soft; exec-class incl. timeout and oversized output: CLI silent-skip) are untouched. The test-only `LoadExternalFacts`/`LoadExternalFactsWithBlocklist` facade is deleted; its ~40 call sites retarget to a field-for-field test-local helper (mode CLI, includeEnv true, default host). This resolves the archived 2026-06-17 open question that marked the facade for deletion. + +Rejected alternatives: the full loader-owns-policy redesign and the `(facts, fatal, soft)` return — the mode field already drives all five branch decisions inside the loader; only `load()`'s final line flattens the outcome, and its sole production caller sits three lines from where the mode is set. Both alternatives churn ~20 loader-construction test sites for zero behavior delta. + +**8. The fast path asks the engine's owners; the decision stays in the CLI.** + +Per the deepen-discovery-input-surface design (fast-path handling and formatter selection stay in `internal/app`): (a) refactor the engine's `unionDisabledFacts` into a pure core taking `environ []string` and export one pure `DisabledUnion(config, extraDisabled, environ)`; `internal/app` replaces its admitted 8-line mirror with one call and the two crutch exports (`EnvironmentDisabledFacts`, `DisabledFactsForFiltering`) are deleted. (b) `writeVersionQuery` routes through `engine.BuildFormatter` with FormatOptions carrying ONLY the three format booleans — Colorize/IncludeTypedDotted stay false because the fast path deliberately ignores `--color`/`--force-dot-resolution` today. The four bare `Format*` functions move verbatim into an in-package `_test.go` helper file: 102 formatter_test.go call sites plus 3 benchmarks stay untouched while the production build loses the exports. + +Rejected alternative (killed at deep-dive): exposing engine plan external-dirs — the app-side dir merge feeds CLI-owned conflict validation and the list-groups path, so engine exposure strictly adds surface. + +**9. One change, ordered task groups, each landing green.** + +Order: delete-dead → fakeHostOS upgrade → goos-seam → virt-memo → session-io bulk → cloud-fetch → loader-policy → fastpath → cross-cutting verification. Rationale: the resolver trio rewrites the same files, so goos-seam establishes the platform/env seam first, virt-memo defines the final gather call shape, and the session-io bulk collapses onto already-final seams — each line touched once. Intra-file sequencing (binding): session.go/session_test.go additive in order 2→3→4; os.go, dmi.go, fips.go, identity.go: goos-seam call-site edits first and kept minimal (session-io's merges subsume them); uptime.go strict order goos-seam→virt-memo→session-io; virtual.go: virt-memo routes memos, session-io folds `WithCommands`; networking tasks (bonding vs DHCP) are sequential, never parallel; ec2.go: session-io's `fileExecutable` hunk before cloud-fetch's client hunks; external.go: loader-policy's facade deletion before fastpath's crutch-export deletion. The probeLinuxDistro `runtime.GOOS` fix belongs to goos-seam alone (struck from session-io). + +Rejected alternative: seven separate openspec changes — cross-change sequencing bookkeeping for files each change would re-touch; the archived deepen-engine-internals precedent bundled five seams the same way. + +## Risks / Trade-offs + +- **[Test migration silently weakens coverage]** → Same-assertions-relocated-fixtures rule checked per task group in review; fake upgrades are additive knobs only (defaults never change meaning); the two want-empty DHCP lease tests get the file-entry fake helper so they cannot pass vacuously. +- **[DHCP lease iteration order]** → Production is unchanged (`osHost.readDir` == `os.ReadDir`, sorted); `fakeHostOS` documents and enforces the sorted-order convention in `fakeDirEntries`; the existing lease-order tests keep asserting the same outcomes. +- **[Fake-host-only output delta at the GCE site]** → After the goos-seam conversion, platform-set fake sessions emit `{gce: nil}` where they previously emitted nothing (no network call involved). No current test asserts against it; documented here so it is not mistaken for a production change. +- **[Windows env-var case-insensitivity]** → `envValue` is goos-conditional; nlab Windows guest smoke (env-casing, windows gather memo) gates archive. +- **[plan9 env divergence (lowercase `path`, NUL separators, case-sensitive)]** → plan9 guest smoke asserting the `path` fact is non-empty and NUL-split correctly — or a recorded accepted risk if the guest is unavailable at archive time. +- **[Virtualization memo count-tests are brittle]** → Count by exact command name (fakeRunKey-style): ec2 runs a path-qualified `/opt/puppetlabs/puppet/bin/virt-what` and the BSD DMI probe runs `/usr/local/sbin/dmidecode`, so substring counting miscounts; pin `uname -r` (the gather calls `cachedKernelRelease`); pin wmic outputs containing `=` or count the powershell CIM fallback keys — otherwise the windows gather is 5 commands, not 3. +- **[Reviewer fatigue on a test-dominated ~4:1 diff]** → Per-group commits each landing `go test ./...` green; three untouchable gates (contract tests, full test suite, Lima Facter 4.10.0 parity diffs bracketing the resolver trio and after fastpath). +- **[Loader pinning fixtures are platform-fragile]** → Library-mode soft-failure fixture is a second null-byte external FILE (permission-denied dirs break on Windows/root; NUL env vars are unconstructible via t.Setenv); CLI-mode fixture uses a malformed facter.conf for planFailures, or drops that vacuous assertion. +- **[In-flight change collisions]** → Lands after deepen-discovery-input-surface and fix-linux-dhcp-lease-interface-match archive; core.go/core_gating_test.go/fastpath work lands after add-fact-disable-controls archives or rebases against its deltas; the facts-library-api spec delta targets the diagnostics requirement by name, not line number, because that spec file gains a requirement when the in-flight change archives. + +## Migration Plan + +No deployment migration — internal refactor, binary behavior identical. Rollback is per task group: each lands independently green, so any group can be reverted without unwinding the others (within a file, later groups depend on earlier ones per the sequencing above). Verification brackets: Lima VM Facter 4.10.0 parity diff before the resolver trio, after it, and after fastpath; nlab Windows + plan9 guest smokes gate archive, not individual tasks. + +## Open Questions + +- ~~`currentWindowsDMI` (dmi.go:415): fold into the dmi pair-merge or exclude with a recorded reason.~~ **Resolved:** folded into the Session-taking dmi shape (it takes `s.logr()`, same pattern as the five `ForPlatform` merges). +- ~~plan9 guest availability at archive time determines smoke-vs-accepted-risk for the `path` fact check.~~ **Resolved (validated on real guests):** the nlab Windows Server 2025 and Plan 9 (9front) guests were reachable and both smokes ran — see the Verification Record. + +## Verification Record + +- **Lima real-Linux behavior-preservation (9.1):** on the `facts-dev` VM (Facter 4.10.0 host), `facts --json` from this branch was diffed against a `main`-built binary. The stable fact subset is byte-identical; the only differences are inherently-volatile live measurements (memory `used_bytes`, disk `available_bytes`/`capacity`) that drift between any two sequential runs. Behavior-preserving on real Linux confirmed. +- **nlab real-guest smokes (9.2):** main and branch binaries were cross-built on the nlab host and run on the Windows Server 2025 and Plan 9 (9front) guests. Both diffed stable-subset byte-identical to `main` (only volatile uptime/memory/disk facts differed). The env-casing paths validate on real Windows — `os.windows.system32` = `C:\WINDOWS\system32` and the `path` fact leads with it, proving `s.getenv("SystemRoot")` matches the old `os.Getenv`. On Plan 9 the `path` fact is `["/bin", "."]` — non-empty and NUL-split correctly. +- **Full sweep (9.4):** `go build ./...`, `go vet ./...`, `go test ./...` (2326 tests), and `go test -race ./internal/engine/ ./internal/app/` all green; contract test files unchanged vs `main`; `openspec validate deepen-engine-seams --strict` passes. diff --git a/openspec/changes/archive/2026-07-03-deepen-engine-seams/proposal.md b/openspec/changes/archive/2026-07-03-deepen-engine-seams/proposal.md new file mode 100644 index 00000000..8bf457aa --- /dev/null +++ b/openspec/changes/archive/2026-07-03-deepen-engine-seams/proposal.md @@ -0,0 +1,42 @@ +## Why + +A deletion-test review of `internal/engine` (seven deep dives, each adversarially red-teamed) found the engine reaching the same concern through two seams, or keeping entrances nothing uses: + +- **Host I/O is reachable two ways in resolvers.** ADR-0010 deferred collapsing the `commandRunner`/`fileReader` parameter threading onto the Session; today `FromRoot`/`WithHost`/`WithReader`/`WithRunner`/`ForPlatform` variant families duplicate the Session host seam (networking alone carries 10 variants), `core.go`'s reader helpers silently default to a raw `osHost{}`, and raw `os.ReadDir`/`os.Stat` calls in networking, processors, virtual, and ec2 bypass every seam. The drift this causes is already real: `probeLinuxDistro` ignores `s.goos()`. +- **Platform and environment dispatch bypass the Session.** Category assemblies read `runtime.GOOS` directly (~30 sites across os, dmi, identity, ssh, selinux, fips, timezone, uptime, and `buildCoreFacts`) and `os.Getenv` at four sites, so fake-host tests cannot drive the windows or plan9 assembly paths and `core_gating_test.go` must skip ssh and fips off-host. +- **The host-virtualization gather runs three times per Linux discovery.** `virtual`, `hypervisors.*`, and the uptime container gate each re-run dmidecode/virt-what/vmware/lspci (twice on Windows for the wmic/reg gather). The Session memo pattern (`cachedDMI`) exists and is not applied to the most expensive repeated probe. +- **Dead and test-only entrances linger.** `detector.go` (six symbols, zero production callers), `query.go`'s Select delegates, the `LoadExternalFacts` facade the archived 2026-06-17 design already marked for deletion, dead `gceFacts`, the `EnvironmentDisabledFacts`/`DisabledFactsForFiltering` crutch exports, dead `filehelper.go`, and four bare `Format*` functions whose only production caller re-derives formatter precedence by hand. +- **Cloud metadata transport is copied four times.** ec2, gce, and az each hand-roll the same proxy-less client, 200-required check, and 1MB-capped read; the shared transport invariants are asserted nowhere because the code exists in copies. +- **The CLI and Discover mirror policy that already has an owner.** `internal/app` re-implements the engine's disabled-set union for the version fast path, and `Engine.Discover` duplicates two near-identical external-loader arms driven by the loader's own mode field. + +All of this is locality and testability debt, paid down before more facts land on top of it. + +## What Changes + +- **Collapse the resolver double host-I/O seam onto the Session** (the ADR-0010 deferred follow-on, downscoped): delete the variant families (networking 10→4, processors triple→1, virtual/xen pairs, dmi's five `ForPlatform` pairs with `currentWindowsDMI` folded in or explicitly excluded), make `core.go`'s variadic-optional reader helpers take required readers, close the raw `os.*` leaks, merge mixed-seam `current*` functions and trivial `probe*`/`current*` repackaging pairs (keeping `probe*` as the Session memo-filler layer), extend `fakeHostOS` additively, migrate ~150–200 test closures and ~35 TempDir fixtures to the fake host, delete dead `filehelper.go`, and add a grep-gate test freezing "no raw host I/O in resolvers". +- **Route category assembly through the Session goos/env seam**: bind `goos := s.goos()` in every category assembly and impure probe call site; add a pure `envValue` helper (case-insensitive only on windows) fed by `hostOS.environ()` for the SystemRoot/programdata/PROCESSOR_ARCHITEW6432/PATH reads; remove the ssh and fips skips from `core_gating_test.go`. The whole `runtime.GOOS`→`s.goos()` class is one intentional latent-drift fix, production-identical on every real host. +- **Memoize the host-virtualization gather on the Session** (cachedDMI pattern, ADR-0005-compliant): linux and windows gather memos routed to the five gather call sites; classification ladders stay pure and untouched; Linux spawns the gather commands once instead of three times. +- **Delete the test-only entrances**: `detector.go` (+ tests) and `query.go`'s `Select`/`SelectWithDottedFacts` delegates, moving `factMatchesQuery` beside its sole caller in `projection.go` and retargeting the delegate tests at the production `NewProjection(...).Select` entrance. +- **Concentrate the cloud metadata fetch in one transport helper** (`metadatahttp.go`): shared client constructor and fetch (proxy-less, 200-required, 1MB cap, fail-closed) under provider adapters that keep their headers, token/flavor handling, parse, gates, and per-provider timeouts; delete dead `gceFacts` and retarget its six referencing tests; land the first-ever transport-invariant tests once. +- **Collapse Discover's duplicated external-loader arms** into one loader construction with a single mode-conditional error branch (CLI fail-fast byte-identical, library accumulate); add Discover-level pinning tests for both policies; delete the test-only `LoadExternalFacts`/`LoadExternalFactsWithBlocklist` facade. +- **Feed the version fast path from the engine's seams**: export one pure `DisabledUnion` replacing `internal/app`'s admitted mirror of the engine union; delete the `EnvironmentDisabledFacts`/`DisabledFactsForFiltering` crutch exports; route `writeVersionQuery` through `BuildFormatter`, demoting the four bare `Format*` functions to an in-package test helper. Fast-path decision and formatter selection stay CLI-owned per the deepen-discovery-input-surface design. +- **Preserve everything user-visible.** Public `facts` API, CLI flags, output contract, input contract, cache behavior, and diagnostics are unchanged. No breaking change is intended. + +## Capabilities + +### New Capabilities + +(none) + +### Modified Capabilities + +- `facts-library-api`: the diagnostics requirement's enumerated source list drops OS-hierarchy detection (the detector module is deleted; it had no production callers). +- `go-port-supported-platform-facts`: resolver host probes, platform dispatch, and env lookup must flow through the Session host seam as the only resolver host-I/O path (structurally enforced), and the host-virtualization gather must probe once per discovery with all consumers reading the same memoized input. +- `facts-cli-option-contract`: the version fast path's disabled-set union and formatter selection must feed the engine's exported seams (`DisabledUnion`, `BuildFormatter`) instead of an `internal/app` mirror, while the fast-path decision itself stays CLI-owned. + +## Impact + +- **Code**: `internal/engine` (session, core, networking, os, dmi, disks remnants, processors, memory, uptime, virtual, xen, ssh, selinux, fips, identity, augeas, ec2, gce, az, external, engine, query→projection, detector deleted, filehelper deleted, new metadatahttp) and `internal/app` (fast-path wiring); the test diff dominates ~4:1 (closure/fixture migration to `fakeHostOS`). +- **Behavior**: Behavior-preserving refactor. Any observed public API, CLI output/status, input source precedence, diagnostics, or cache difference is a bug unless explicitly captured in a follow-up change. Production host behavior is bit-identical by construction: `osHost` methods are the same `os.*` calls the leaks made directly, and `s.goos()` equals `runtime.GOOS` on every real host. +- **Sequencing**: lands after `deepen-discovery-input-surface` and `fix-linux-dhcp-lease-interface-match` archive; the `core.go`/`core_gating_test.go`/fast-path work lands after `add-fact-disable-controls` archives or rebases against its deltas. +- **Docs/schema**: no fact schema change; one consolidated CHANGELOG internal-refactor entry; ADR-0010's deferred-follow-on note updated to record the collapse as done. diff --git a/openspec/changes/archive/2026-07-03-deepen-engine-seams/specs/facts-cli-option-contract/spec.md b/openspec/changes/archive/2026-07-03-deepen-engine-seams/specs/facts-cli-option-contract/spec.md new file mode 100644 index 00000000..3b7cf157 --- /dev/null +++ b/openspec/changes/archive/2026-07-03-deepen-engine-seams/specs/facts-cli-option-contract/spec.md @@ -0,0 +1,20 @@ +## ADDED Requirements + +### Requirement: Version fast path reuses engine-owned seams + +The CLI's version-query fast path SHALL derive its disabled-fact set from the engine's exported pure disabled-union function — the same union semantics the engine's discovery planning applies — instead of re-implementing the union in `internal/app`, and SHALL render its output through the engine's formatter-selection seam (`BuildFormatter`) instead of a CLI-local re-derivation of format precedence. The fast-path decision itself and formatter selection remain owned by `internal/app` per the discovery-input-surface design. The engine SHALL NOT export helpers whose only purpose is to feed a CLI-side re-implementation of engine policy. + +#### Scenario: Fast-path disabled set matches discovery semantics + +- **WHEN** `facts facterversion` runs with any combination of `--disable`, the `FACTS_DISABLE` environment variable, and a config-file disable list +- **THEN** the fast path takes effect exactly when a full discovery would omit `facterversion` for the same inputs, because both derive the disabled set from the same engine union + +#### Scenario: Disabled facterversion falls through identically + +- **WHEN** `facterversion` is disabled by any disable source and queried in the default format +- **THEN** stdout, stderr diagnostics, and exit status are byte-identical to the behavior before the fast path consumed the engine union + +#### Scenario: Version output is format-stable + +- **WHEN** `facts facterversion` is rendered with `--json`, `--yaml`, `--hocon`, or the default format +- **THEN** the bytes written to stdout are identical to the previous hand-selected formatter output for each format diff --git a/openspec/changes/archive/2026-07-03-deepen-engine-seams/specs/facts-library-api/spec.md b/openspec/changes/archive/2026-07-03-deepen-engine-seams/specs/facts-library-api/spec.md new file mode 100644 index 00000000..072ab2ae --- /dev/null +++ b/openspec/changes/archive/2026-07-03-deepen-engine-seams/specs/facts-library-api/spec.md @@ -0,0 +1,35 @@ +## MODIFIED Requirements + +### Requirement: Diagnostics via structured logging + +Engine diagnostics SHALL flow through `log/slog` with the contract-pinned message text and mapped severities, SHALL be discarded by default, and SHALL preserve once-only emission semantics per Engine. There SHALL be no package-global diagnostic sink: every diagnostic raised during discovery — including those from config-file parsing, the persistent cache, fact-group TTL parsing, and canonical-tree collection collisions (the default collection the Snapshot exposes) — SHALL be routed to the Engine's logger, not to a process-global handler. Collisions that arise only from a format-time transform (the CLI's `--force-dot-resolution`), not from the canonical tree, are out of scope of this requirement. + +#### Scenario: Silent by default + +- **WHEN** an Engine constructed without `WithLogger` encounters warn-class conditions (e.g. an invalid external-fact file in an opted-in directory) +- **THEN** discovery proceeds, the fact is skipped per input-contract semantics, and nothing is written to any process-global logger or stderr + +#### Scenario: Diagnostics routed to the consumer's logger + +- **WHEN** an Engine is constructed with `WithLogger` and a once-only diagnostic condition occurs repeatedly within and across discoveries on that Engine +- **THEN** the diagnostic is emitted to the supplied logger with contract-equivalent message text and severity, exactly once per Engine + +#### Scenario: Config, cache, and group diagnostics reach the logger + +- **WHEN** an Engine constructed with `WithLogger`, `WithConfigFile`, and `WithCache` encounters a config read failure, a cache write failure, or an unparseable group TTL during discovery +- **THEN** each diagnostic is emitted to the supplied logger with its mapped severity, rather than discarded or sent to a process-global handler + +#### Scenario: Canonical-tree collision is reported once at discovery + +- **WHEN** a resolved fact value collides with a dotted child in the canonical tree (e.g. `os` resolves to a scalar while `os.name` also resolves) +- **THEN** the collision is emitted once to the Engine's logger at error severity during discovery, and is not re-emitted when the resulting Snapshot is formatted + +#### Scenario: Format-time-only collisions under force-dot resolution are out of scope + +- **WHEN** two typed (custom or external) facts collide only when `--force-dot-resolution` expands their dotted names (e.g. `myapp.version` and `myapp.version.major` with no plain `myapp`), so they do not collide in the canonical tree and both appear in the Snapshot as flat keys +- **THEN** no collision diagnostic is emitted at discovery, matching Facter 4.10.0, which under `global.force-dot-resolution` also silently drops the colliding fact with no diagnostic on stderr at any log level (the facts CLI drops error-class diagnostics regardless) + +#### Scenario: Error-class diagnostics reach the library logger + +- **WHEN** an Engine constructed with `WithLogger` raises an error-class diagnostic (a collection collision, an unsupported cache group for an external fact, or an unparseable TTL unit) +- **THEN** the diagnostic is emitted to the supplied logger at error severity, even though the facts CLI's stderr handler drops error-class lines diff --git a/openspec/changes/archive/2026-07-03-deepen-engine-seams/specs/go-port-supported-platform-facts/spec.md b/openspec/changes/archive/2026-07-03-deepen-engine-seams/specs/go-port-supported-platform-facts/spec.md new file mode 100644 index 00000000..7a51d6a4 --- /dev/null +++ b/openspec/changes/archive/2026-07-03-deepen-engine-seams/specs/go-port-supported-platform-facts/spec.md @@ -0,0 +1,51 @@ +## MODIFIED Requirements + +### Requirement: Host probes remain Session-injectable + +Facts SHALL keep host I/O used for platform fact discovery reachable through the run-scoped Session seam so category behavior can be tested with injected native source data. The Session host seam SHALL be the only resolver host-I/O path: core fact resolvers MUST NOT read the host through raw `os`/`filepath`/`exec` calls or through parameter-injected reader/runner alternatives that duplicate the Session seam, and this MUST be structurally enforced by an automated check with a fixed, documented exclusion list (the seam implementation itself, the external-fact loader, the persistent cache, config parsing, syscall-tagged files, and test files). Category assemblies SHALL obtain platform identity through the Session (`s.goos()`) and environment values through the Session's host environment with Windows-only case-insensitive lookup, so platform-conditional assembly paths are exercisable with a fake host on any development platform. Pure parse functions keep their string and goos parameters per the category-split contract; recorded exceptions (process clock, `exec.LookPath` in the Linux distro probe, identity's uid/gid syscalls, `net.Interfaces`) remain injectable parameters where tests need them. + +#### Scenario: Disk probes are injectable + +- **WHEN** disk, partition, or mountpoint facts need command output, file reads, stat data, directory reads, glob matches, or platform identity +- **THEN** tests MUST be able to provide those inputs without reading the developer host directly + +#### Scenario: Session command behavior is preserved + +- **WHEN** a fact resolver executes a platform command through the Session host seam +- **THEN** command timeout, context cancellation, logging, and sanitized environment behavior MUST remain consistent with current Session command execution + +#### Scenario: Resolver host I/O cannot bypass the seam + +- **WHEN** a core fact resolver outside the documented exclusion list reads a file, lists a directory, stats a path, expands a glob, or executes a command through raw `os`/`filepath`/`exec` calls instead of the Session host seam +- **THEN** the automated seam check fails, identifying the offending file and call + +#### Scenario: Category assembly is drivable onto another platform + +- **WHEN** a test constructs a Session whose fake host reports a platform identity different from the test host (e.g. windows assembly driven from a Linux CI host) +- **THEN** the category assembly functions resolve using the fake host's platform identity, environment values, file contents, and command outputs, without reaching the real host + +## ADDED Requirements + +### Requirement: Host virtualization is gathered once per discovery + +The host-virtualization signal gather (on Linux: DMI reads plus the dmidecode/virt-what/vmware/lspci command set; on Windows: the wmic/CIM and registry gather) SHALL run at most once per discovery, memoized on the run-scoped Session like other shared host probes, with the `virtual`/`is_virtual` facts, the `hypervisors` fact tree, and the uptime container gate all reading the same memoized gather input. Classification of the gathered input SHALL remain a pure derivation so memoizing the gather does not change any resolved fact value. + +#### Scenario: Linux gather commands run once + +- **WHEN** a discovery resolves `virtual`, `hypervisors`, and `system_uptime` on a Linux host +- **THEN** each virtualization gather command (dmidecode, virt-what, vmware, lspci) is executed at most once for that discovery, and all three consumers observe facts derived from the same gather + +#### Scenario: Windows gather runs once + +- **WHEN** a discovery resolves `virtual` and `hypervisors` on a Windows host +- **THEN** the wmic/CIM and registry virtualization gather executes at most once for that discovery + +#### Scenario: Memoization is discovery-scoped + +- **WHEN** the same Engine runs two discoveries +- **THEN** the second discovery re-gathers virtualization signals fresh (the memo lives on the per-discovery Session, not the Engine) + +#### Scenario: Resolved values are unchanged by memoization + +- **WHEN** the memoized gather input is classified for the `virtual` fact and for the `hypervisors` tree +- **THEN** each consumer's classification produces the same fact names and values as before memoization, including their documented divergences diff --git a/openspec/changes/archive/2026-07-03-deepen-engine-seams/tasks.md b/openspec/changes/archive/2026-07-03-deepen-engine-seams/tasks.md new file mode 100644 index 00000000..d83c2fb6 --- /dev/null +++ b/openspec/changes/archive/2026-07-03-deepen-engine-seams/tasks.md @@ -0,0 +1,73 @@ +## 1. Delete the test-only entrances (independent, lands first) + +- [x] 1.1 Move `factMatchesQuery` verbatim from `internal/engine/query.go` to `projection.go` beside its sole caller `findFactIn`; move the `regexp` import with it and drop query.go's now-unused `regexp`/`strings` imports. Verify: build + full tests green. +- [x] 1.2 Retarget the delegate-driven tests in `query_test.go` at the production entrance: `SelectWithDottedFacts(facts, qs, true)` → `NewProjection(facts, true).Select(qs)`, `Select(facts, qs)` → `NewProjection(facts, false).Select(qs)`; delete `TestSelectWithDottedFacts_digsPartialQueriesThroughStructuredDottedFacts` (exact duplicate of `TestProjectionDottedFactModeMergesPartialQuery`) and trim the flat-mode assertions already covered there; rename retargeted tests to Projection-prefixed names. Verify: tests green. +- [x] 1.3 Delete `internal/engine/query.go` (now only the two uncalled delegates). Verify: build + tests green; grep shows no package-level `Select(`/`SelectWithDottedFacts` callers. +- [x] 1.4 Delete `internal/engine/detector.go` and `detector_test.go`. Verify: build + tests + race green; grep for all six detector symbols returns zero hits. +- [x] 1.5 Confirm the `facts-library-api` delta in this change (diagnostics requirement restated without ", and OS-hierarchy detection", targeted by requirement name — line numbers will drift when `deepen-discovery-input-surface` archives). Verify: `openspec validate deepen-engine-seams --strict` green. + +## 2. fakeHostOS upgrade (test-only prerequisite for groups 3–5) + +- [x] 2.1 Extend `fakeHostOS` (session_test.go) additively: per-path error maps (file/stat/lstat/dir/glob, consulted before fixture maps), an explicit empty-run-default knob (unmatched `run()` currently returns the `"host-output\n"` sentinel), and a file-entry helper beside the dir-only `fakeDirEntries` (the bonding and DHCP lease loops skip IsDir entries — without file entries, want-empty tests pass vacuously). Defaults never change. Document the sorted-readDir convention. Verify: new fake unit tests + full engine tests green. + +## 3. Route category assembly through the Session goos/env seam + +- [x] 3.1 Add pure `envValue(env []string, goos, name string)` (case-insensitive ONLY when goos=="windows", exact elsewhere, first match, skips empty names — do not copy `systemRootFromEnv`'s unconditional EqualFold) and `Session.getenv` in session.go, with tests covering windows/unix/plan9 casing regimes. Verify: build + tests green. +- [x] 3.2 Convert single-line leaf sites to `s.goos()`: fips.go:49, selinux.go:108, timezone.go:112, uptime.go:303, `probeFilesystems` (os.go:1672), `probeLinuxDistro` (os.go:1196, keep the `exec.LookPath` param — recorded accepted leak); delete the fips_enabled skip block from core_gating_test.go and fix the `newGatingProbeHost` harness comment in the same commit. Verify: build + tests green. +- [x] 3.3 Convert ssh: `sshCoreFacts` binds `goos := s.goos()` and passes `s.getenv("programdata")`/`s.readFile`; delete the `discoverSSHHostKeys` thin wrapper; delete the ssh skip block from core_gating_test.go (harness comment fixed same commit); add a fake-windows sshCoreFacts test using real `"ProgramData"` casing plus whoami runOutputs for the privilege gate. Verify: build + tests green. +- [x] 3.4 Convert `identityFact` to one bound `goos := s.goos()` (4 reads); the uid/gid/`osuser.Current` syscalls stay outside the seam — recorded accepted leak. Verify: existing plan9/unix identity tests green. +- [x] 3.5 Convert dmi call sites only (the five `current*DMIFactsForPlatform` call sites and the `currentWindowsDMI` site in `dmiCoreFacts`) to `s.goos()` — keep this edit call-site-minimal; group 5 merges the pairs. Verify: build + tests green. +- [x] 3.6 Convert `osCoreFacts`: bind `goos := s.goos()` (replacing ~9 reads); `os.Getenv("SystemRoot")` → `s.getenv("SystemRoot")`; replace the `currentWindowsProcessWOW64` argument with a closure over `s.getenv("PROCESSOR_ARCHITEW6432")` (`currentWindowsSystem32` signature unchanged) and delete `currentWindowsProcessWOW64` + its t.Setenv test; add a fake-windows osCoreFacts assembly test. Verify: build + tests green. +- [x] 3.7 Convert the buildCoreFacts inline sites (core.go): `goos := s.goos()`; path fact via `currentPathEntries(goos, s.getenv)` with separator switched to `corePathListSeparator(goos)`; GCE site via the bound goos — note the expected fake-host-only delta: platform-set fake sessions now emit `{gce: nil}` where they emitted nothing (no test asserts against it; no production change). Verify: build + tests green. [after add-fact-disable-controls archives, or rebase] +- [x] 3.8 Sweep: grep confirms the only remaining `runtime.GOOS` reads in internal/engine non-test files are the classified genuinely-fine set (session.go `osHost.goos`, external.go:78, config.go, cache.go); cross-builds for windows/linux/plan9 green; `go vet ./...` green. + +## 4. Memoize the host-virtualization gather on the Session + +- [x] 4.1 Add `linuxVirtualization`/`windowsVirtualization` Session memos with `cachedLinuxVirtualizationInput()`/`cachedWindowsVirtualizationInput()` accessors (cachedDMI doc pattern); session tests prove two accessor calls run each gather command exactly once — count by exact command name (fakeRunKey style; ec2's path-qualified virt-what and `/usr/local/sbin/dmidecode` would break substring counts), pin `uname -r` (the gather calls `cachedKernelRelease`), and pin wmic outputs containing `=` (or count the powershell CIM fallback keys) so the windows gather is 3 commands, not 5. Verify: tests green. +- [x] 4.2 Route `detectVirtualization`'s linux (virtual.go:121) and windows (virtual.go:135) branches through the memos. Verify: full suite green; existing dispatch tests unchanged. +- [x] 4.3 Route `currentLinuxHypervisorFacts` (virtual.go:616) and `currentWindowsHypervisorFacts` (virtual.go:338) through the memos. Verify: full suite green; existing fresh-session tests unchanged (memo transparent). +- [x] 4.4 Route the uptime container gate (uptime.go:49) through `s.cachedLinuxVirtualizationInput()` (signature unchanged; KEEP the `currentLinuxVirtualizationInputWithCommands` wrapper — it has a live test caller at virtual_test.go:1488, folded in group 5); add the buildCoreFacts-level single-gather regression test in virtual_test.go (fake linux host: exactly one dmidecode/virt-what/vmware/lspci call across virtual+hypervisors+uptime; fake windows host: one wmic gather; pin gather outputs so classification is `physical`). Verify: tests green. + +## 5. Collapse the double host-I/O seam onto the Session (bulk) + +- [x] 5.1 core.go: `readFileString`/`isSymlink`/`readSysfsString`/`readDMIString` take required reader/lstat params (signature-only — all 17 production call sites already pass one; only core_test.go calls `isSymlink` among tests). Verify: build + tests green. +- [x] 5.2 networking metadata+bonding: collapse `addLinuxInterfaceMetadataFromRoot(+WithHost)` and `addLinuxBondingSlaveMACsFromRoot(+WithReader)` to single host-taking functions (production paths hardcoded; `rootedPath(root="/")` is identity); route the raw `os.ReadDir` at networking.go:1099 through `host.readDir`; migrate the TempDir fixtures (networking_test.go:818, os_test.go:597) to fakeHostOS with file-entries. Verify: tests green. [sequential with 5.3 — same files] +- [x] 5.3 networking DHCP: collapse the `linuxDHCPServer`/`FromRoot`/`FromRootWithRunner`/`FromRootWithHost`/`FromLeaseDir`/`FromLeaseDirWithReader` sextet to `linuxDHCPServer(s, ...)` + `linuxDHCPServerFromLeaseDir(s, ...)`, closing the raw `os.ReadDir` at networking.go:1170; migrate the ~8 lease tests preserving lease-ordering and interface-match assertions (sorted fakeDirEntries convention). Verify: tests green. +- [x] 5.4 processors: collapse the physical-count triple to one `(cpuinfo string, host hostOS)` function, closing `os.ReadDir` at processors.go:457; drop the run param from `currentProcessorISA`; migrate closures — the `os.ErrPermission` case via the fake error map, the never-reads-sysfs case via `len(h.readDirCalls)==0` (same assertions, relocated fixtures). Verify: tests green. +- [x] 5.5 virtual + xen: host-taking `fileExists`/`dirExists`; `procVZEntryCount` takes host (closes `os.ReadDir` at virtual.go:605); inline `readLinuxCGroup`'s variadic reader; fold `currentLinuxVirtualizationInputWithCommands` into `currentLinuxVirtualizationInput` and migrate its test caller (virtual_test.go:1488) to fakeHostOS; merge the `detectXenDomains`/`WithCommand` pair onto Session (`detectXenVM` is already Session-based — no change). Verify: tests green; grep shows zero WithCommands references. +- [x] 5.6 dmi: merge the FIVE `current*DMIFacts(s)`/`ForPlatform(goos, run)` pairs (FreeBSD, DragonFly, OpenBSD, NetBSD, Illumos) into Session-taking functions; fold `currentWindowsDMI(goos, run, log)` into the same shape (it needs `s.logr()`) or record the exclusion reason in design.md; migrate dmi_test to fakeHostOS with runOutputs; pure `parse*` fixtures untouched. Verify: tests green. +- [x] 5.7 os.go: `currentOSRelease(s, goos, readFile, run)` → `currentOSRelease(s)`; merge the trivial macOS probe/current pairs (`currentMacOSModel`/`Info`/`SystemProfiler*`); `currentFilesystems`/`currentLinuxDistro` take readers from s; migrate os_test.go (~50 closures — largest single migration). Verify: tests green. +- [x] 5.8 uptime + memory: drop readFile/run params from the `currentUptime*`/`currentLoadAverages` family KEEPING the `now func() time.Time` param (clock is not on hostOS); merge `currentWindowsMemory`/`currentDarwinSwapUsage` pairs; migrate uptime_test (28 closures) and memory_test. Verify: tests green. +- [x] 5.9 Small collapses: `currentFIPSEnabled(s, linuxPath)`, `currentWindowsIdentityInfo(s)`, ec2.go `fileExecutable` via `host.stat` (closes the raw `os.Stat` at ec2.go:106), `currentAugeasVersion` exists-closure via s; migrate fips/identity/ec2/augeas tests. Verify: tests green. +- [x] 5.10 Delete dead `filehelper.go` + `filehelper_test.go` (zero callers outside its own test). Verify: build + tests green. +- [x] 5.11 Enforcement: add `TestNoRawHostIOInResolvers` grep-gate (no `os.ReadDir`/`os.ReadFile`/`os.Stat`/`os.Lstat`/`filepath.Glob`/`exec.Command` outside `session*.go`, `external.go`, `cache.go`, `config.go`, `statfs*`, `*_test.go`); fix any stragglers it finds. Verify: gate + full `go test ./...` green. + +## 6. Concentrate the cloud metadata fetch + +- [x] 6.1 Delete dead `gceFacts` (gce.go:35) and retarget its SIX referencing tests (five in gce_test.go plus `TestGCEFacts_fetchesMetadataAndCloudProvider` in ec2_test.go) to `platformGCEFacts`/`linuxGCEFacts` with production empty-case expectations (`{gce: nil}`); delete `TestGCEFactsSkipNilClient` (exact duplicate of the existing nil-client test). Verify: build + tests green. +- [x] 6.2 Add `internal/engine/metadatahttp.go` — shared `metadataMaxBodyBytes` const, `newMetadataHTTPClient(timeout)` (proxy-less), `fetchMetadata(ctx, client, method, url, headers) (string, http.Header, bool)` (200-required, 1MB cap, fail-closed) — plus metadatahttp_test.go covering client config, non-200, request-build error, read error, body cap, header passthrough (the first transport-invariant tests; today none exist). Verify: build + tests green. +- [x] 6.3 Convert az.go (ctor + `metadata`, keeping JSON/empty-map error shape and the 5s timeout). Verify: Azure tests + full package green. +- [x] 6.4 Convert gce.go (ctor + `get`, keeping the response `Metadata-Flavor` validation via the returned header and TrimSpace). Verify: GCE tests + full package green. +- [x] 6.5 Convert ec2.go (ctor + `getRaw` with conditional token header and untrimmed body + the `v2Token` request leg with PUT and TTL header; token cache/TrimSpace stay in `v2Token`). Verify: EC2 tests + benchmark + full package green. +- [x] 6.6 Smoke the nil paths: on the facts-dev Lima VM (NOT darwin — `platformGCEFacts` returns nil for goos=darwin, so `{gce: nil}` is unobservable there), confirm `az_metadata`/`ec2_metadata`/`ec2_userdata`/`gce` outputs unchanged on a non-cloud host. Verify: byte-identical. + +## 7. Collapse Discover's duplicated external-loader arms + +- [x] 7.1 Add Discover-level pinning tests for both error policies (they exist only at loader level today): library mode — a second external-fact FILE containing a null byte alongside a good file → partial snapshot retaining loaded facts + joined error (permission-denied dirs break on Windows/root; NUL env vars are unconstructible via t.Setenv); CLI mode — malformed facter.conf → planFailures populated, assert the returned error contains only the bare loader error (or drop the planFailures assertion as vacuous). Verify: green against unmodified code. +- [x] 7.2 Collapse the two loader arms in `Engine.Discover` (engine.go:199-221) into one construction + one `load()` call with a single commented mode-conditional branch: CLI returns `newSnapshot(nil, s.logger), err` byte-identically (planFailures discarded, `finish()` skipped); library appends to failures; `externalFacts` assigned unconditionally. Verify: 7.1 pins + full suite green. +- [x] 7.3 Delete the `LoadExternalFacts`/`LoadExternalFactsWithBlocklist` facade (external.go:114-131); add unexported test-local helpers in external_test.go mirroring it field-for-field (mode CLI, includeEnv true, default host); retarget the ~40 call sites mechanically. Verify: no call expressions outside external_test.go (test function NAMES may keep the LoadExternalFacts prefix); full suite green. + +## 8. Feed the version fast path from engine seams + +- [x] 8.1 Tests first: exact-byte facterversion pins for `--json` and `--hocon` in app_test.go; fast-path fall-through pins for `FACTS_DISABLE=facterversion` (empty stdout + `is disabled by FACTS_DISABLE` WARN) and `--disable facterversion` (empty stdout, no diagnostic) in internal/app/disable_test.go using the bare default legacy format (only there is stdout literally empty). Verify: green against current code. [after add-fact-disable-controls archives] +- [x] 8.2 Refactor `unionDisabledFacts` into a pure core taking `environ []string`; export `DisabledUnion(config, extraDisabled, environ) map[string]bool` delegating to it (the Engine path passes `s.host.environ()`); add DisabledUnion contract tests. Verify: build + tests green. +- [x] 8.3 Replace app.go:299-307's mirror with one `engine.DisabledUnion(configOptions, disableEntries, os.Environ())` call (keeping the `--no-block` branch); delete `EnvironmentDisabledFacts` (external.go:310-316) and `DisabledFactsForFiltering` (groups.go:206-209); retarget config_test.go:1012 to `DisabledFactsWithGroups`. Verify: grep zero references; full suite green. +- [x] 8.4 Route `writeVersionQuery` through `engine.BuildFormatter` with FormatOptions carrying ONLY the three format booleans (Colorize/IncludeTypedDotted stay false — the fast path deliberately ignores `--color`/`--force-dot-resolution`). Verify: 8.1 byte pins prove identity. +- [x] 8.5 Move `FormatJSON`/`FormatYAML`/`FormatHOCON`/`FormatLegacy` verbatim into a new in-package `formatter_helpers_test.go` (102 formatter_test.go call sites + 3 benchmarks untouched; production build loses the exports). Verify: build + tests + vet green. + +## 9. Cross-cutting verification and docs + +- [x] 9.1 Lima VM parity gates: facts-dev Facter 4.10.0 full-JSON diffs — before group 3, after group 5, and after group 8 — must be byte-identical pre/post at each bracket. +- [x] 9.2 Archive gates via facts-lab: nlab Windows guest smoke (env-casing paths: ssh programdata, SystemRoot/system32, path fact; windows virtualization memo) and plan9 guest smoke (`path` fact non-empty and NUL-split) — or record plan9 as an accepted risk in design.md if the guest is unavailable. +- [x] 9.3 One consolidated CHANGELOG internal-refactor entry; update ADR-0010's deferred-follow-on note to record the collapse as done; note the archived 2026-06-17 `LoadExternalFacts` open question as resolved. +- [x] 9.4 Final sweep: `go build ./...`, full `go test ./...`, `go vet ./...`, race where concurrency-sensitive, contract tests untouched (git diff clean on contract test files), `openspec validate deepen-engine-seams --strict`. diff --git a/openspec/specs/facts-cli-option-contract/spec.md b/openspec/specs/facts-cli-option-contract/spec.md index 4b19dc56..4e81da17 100644 --- a/openspec/specs/facts-cli-option-contract/spec.md +++ b/openspec/specs/facts-cli-option-contract/spec.md @@ -36,3 +36,23 @@ The shared CLI option metadata SHALL describe canonical names, aliases, value ar - **WHEN** help text, man text, or the installed man page omits a non-hidden supported option - **THEN** the CLI option contract tests MUST fail + +### Requirement: Version fast path reuses engine-owned seams + +The CLI's version-query fast path SHALL derive its disabled-fact set from the engine's exported pure disabled-union function — the same union semantics the engine's discovery planning applies — instead of re-implementing the union in `internal/app`, and SHALL render its output through the engine's formatter-selection seam (`BuildFormatter`) instead of a CLI-local re-derivation of format precedence. The fast-path decision itself and formatter selection remain owned by `internal/app` per the discovery-input-surface design. The engine SHALL NOT export helpers whose only purpose is to feed a CLI-side re-implementation of engine policy. + +#### Scenario: Fast-path disabled set matches discovery semantics + +- **WHEN** `facts facterversion` runs with any combination of `--disable`, the `FACTS_DISABLE` environment variable, and a config-file disable list +- **THEN** the fast path takes effect exactly when a full discovery would omit `facterversion` for the same inputs, because both derive the disabled set from the same engine union + +#### Scenario: Disabled facterversion falls through identically + +- **WHEN** `facterversion` is disabled by any disable source and queried in the default format +- **THEN** stdout, stderr diagnostics, and exit status are byte-identical to the behavior before the fast path consumed the engine union + +#### Scenario: Version output is format-stable + +- **WHEN** `facts facterversion` is rendered with `--json`, `--yaml`, `--hocon`, or the default format +- **THEN** the bytes written to stdout are identical to the previous hand-selected formatter output for each format + diff --git a/openspec/specs/facts-library-api/spec.md b/openspec/specs/facts-library-api/spec.md index 239e75e3..8334def7 100644 --- a/openspec/specs/facts-library-api/spec.md +++ b/openspec/specs/facts-library-api/spec.md @@ -65,7 +65,7 @@ The library SHALL distinguish missing facts from nil-valued facts via an `ErrFac ### Requirement: Diagnostics via structured logging -Engine diagnostics SHALL flow through `log/slog` with the contract-pinned message text and mapped severities, SHALL be discarded by default, and SHALL preserve once-only emission semantics per Engine. There SHALL be no package-global diagnostic sink: every diagnostic raised during discovery — including those from config-file parsing, the persistent cache, fact-group TTL parsing, canonical-tree collection collisions (the default collection the Snapshot exposes), and OS-hierarchy detection — SHALL be routed to the Engine's logger, not to a process-global handler. Collisions that arise only from a format-time transform (the CLI's `--force-dot-resolution`), not from the canonical tree, are out of scope of this requirement. +Engine diagnostics SHALL flow through `log/slog` with the contract-pinned message text and mapped severities, SHALL be discarded by default, and SHALL preserve once-only emission semantics per Engine. There SHALL be no package-global diagnostic sink: every diagnostic raised during discovery — including those from config-file parsing, the persistent cache, fact-group TTL parsing, and canonical-tree collection collisions (the default collection the Snapshot exposes) — SHALL be routed to the Engine's logger, not to a process-global handler. Collisions that arise only from a format-time transform (the CLI's `--force-dot-resolution`), not from the canonical tree, are out of scope of this requirement. #### Scenario: Silent by default @@ -96,3 +96,4 @@ Engine diagnostics SHALL flow through `log/slog` with the contract-pinned messag - **WHEN** an Engine constructed with `WithLogger` raises an error-class diagnostic (a collection collision, an unsupported cache group for an external fact, or an unparseable TTL unit) - **THEN** the diagnostic is emitted to the supplied logger at error severity, even though the facts CLI's stderr handler drops error-class lines + diff --git a/openspec/specs/go-port-supported-platform-facts/spec.md b/openspec/specs/go-port-supported-platform-facts/spec.md index 7c4be7ae..84bb0853 100644 --- a/openspec/specs/go-port-supported-platform-facts/spec.md +++ b/openspec/specs/go-port-supported-platform-facts/spec.md @@ -337,7 +337,7 @@ Collection facts SHALL be emitted as arrays rather than delimiter-separated stri ### Requirement: Host probes remain Session-injectable -Facts SHALL keep host I/O used for platform fact discovery reachable through the run-scoped Session seam so category behavior can be tested with injected native source data. +Facts SHALL keep host I/O used for platform fact discovery reachable through the run-scoped Session seam so category behavior can be tested with injected native source data. The Session host seam SHALL be the only resolver host-I/O path: core fact resolvers MUST NOT read the host through raw `os`/`filepath`/`exec` calls or through parameter-injected reader/runner alternatives that duplicate the Session seam, and this MUST be structurally enforced by an automated check with a fixed, documented exclusion list (the seam implementation itself, the external-fact loader, the persistent cache, config parsing, syscall-tagged files, and test files). Category assemblies SHALL obtain platform identity through the Session (`s.goos()`) and environment values through the Session's host environment with Windows-only case-insensitive lookup, so platform-conditional assembly paths are exercisable with a fake host on any development platform. Pure parse functions keep their string and goos parameters per the category-split contract; recorded exceptions (process clock, `exec.LookPath` in the Linux distro probe, identity's uid/gid syscalls, `net.Interfaces`) remain injectable parameters where tests need them. #### Scenario: Disk probes are injectable @@ -349,6 +349,16 @@ Facts SHALL keep host I/O used for platform fact discovery reachable through the - **WHEN** a fact resolver executes a platform command through the Session host seam - **THEN** command timeout, context cancellation, logging, and sanitized environment behavior MUST remain consistent with current Session command execution +#### Scenario: Resolver host I/O cannot bypass the seam + +- **WHEN** a core fact resolver outside the documented exclusion list reads a file, lists a directory, stats a path, expands a glob, or executes a command through raw `os`/`filepath`/`exec` calls instead of the Session host seam +- **THEN** the automated seam check fails, identifying the offending file and call + +#### Scenario: Category assembly is drivable onto another platform + +- **WHEN** a test constructs a Session whose fake host reports a platform identity different from the test host (e.g. windows assembly driven from a Linux CI host) +- **THEN** the category assembly functions resolve using the fake host's platform identity, environment values, file contents, and command outputs, without reaching the real host + ### Requirement: Platform capability policy is explicit Facts SHALL keep coarse platform capability policy explicit while preserving category-oriented resolver modules. @@ -363,3 +373,27 @@ Facts SHALL keep coarse platform capability policy explicit while preserving cat - **WHEN** platform capability policy is added or changed - **THEN** parser and resolver bodies MUST remain in the relevant category modules rather than moving into a platform registry +### Requirement: Host virtualization is gathered once per discovery + +The host-virtualization signal gather (on Linux: DMI reads plus the dmidecode/virt-what/vmware/lspci command set; on Windows: the wmic/CIM and registry gather) SHALL run at most once per discovery, memoized on the run-scoped Session like other shared host probes, with the `virtual`/`is_virtual` facts, the `hypervisors` fact tree, and the uptime container gate all reading the same memoized gather input. Classification of the gathered input SHALL remain a pure derivation so memoizing the gather does not change any resolved fact value. + +#### Scenario: Linux gather commands run once + +- **WHEN** a discovery resolves `virtual`, `hypervisors`, and `system_uptime` on a Linux host +- **THEN** each virtualization gather command (dmidecode, virt-what, vmware, lspci) is executed at most once for that discovery, and all three consumers observe facts derived from the same gather + +#### Scenario: Windows gather runs once + +- **WHEN** a discovery resolves `virtual` and `hypervisors` on a Windows host +- **THEN** the wmic/CIM and registry virtualization gather executes at most once for that discovery + +#### Scenario: Memoization is discovery-scoped + +- **WHEN** the same Engine runs two discoveries +- **THEN** the second discovery re-gathers virtualization signals fresh (the memo lives on the per-discovery Session, not the Engine) + +#### Scenario: Resolved values are unchanged by memoization + +- **WHEN** the memoized gather input is classified for the `virtual` fact and for the `hypervisors` tree +- **THEN** each consumer's classification produces the same fact names and values as before memoization, including their documented divergences +