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
+