diff --git a/cmd/stepsecurity-dev-machine-guard/main.go b/cmd/stepsecurity-dev-machine-guard/main.go index f705b73..93d4f86 100644 --- a/cmd/stepsecurity-dev-machine-guard/main.go +++ b/cmd/stepsecurity-dev-machine-guard/main.go @@ -262,8 +262,13 @@ func main() { os.Exit(1) } armExecutionWatchdog(telemetry.ExecutionDeadline(config.MaxExecutionDuration), log) - if err := telemetry.Run(exec, log, cfg); err != nil { - log.Error("%v", err) + telemetryErr := telemetry.Run(exec, log, cfg) + // Package-config enforcement runs on every cycle, even one where telemetry + // failed, so an emergency unassignment/offboarding directive is never + // blocked by a telemetry outage — hence before the error-exit below. + runPackageConfigEnforce(exec, log) + if telemetryErr != nil { + log.Error("%v", telemetryErr) os.Exit(1) } runHookStateReconcile(exec, log) @@ -351,6 +356,16 @@ func main() { } } + // Package-config enforcement runs even if the initial telemetry failed + // (before the error-exit below). Skipped on Windows at install time: an + // elevated installer resolves the ADMINISTRATOR's home, not the developer's, + // so let the first scheduled /ru INTERACTIVE firing do the first enforcement + // (macOS root installs resolve the console user; Linux installs are user + // mode, and the Windows-SYSTEM / macOS paths already returned above). + if runtime.GOOS != model.PlatformWindows { + runPackageConfigEnforce(exec, log) + } + if telemetryErr != nil { if cfg.IgnoreTelemetryError { // Opt-in tolerance for MSI/SCCM/Intune deployments: the @@ -475,8 +490,12 @@ func main() { case config.IsEnterpriseMode(): log.Debug("dispatch: enterprise telemetry (auto-detected)") armExecutionWatchdog(telemetry.ExecutionDeadline(config.MaxExecutionDuration), log) - if err := telemetry.Run(exec, log, cfg); err != nil { - log.Error("%v", err) + telemetryErr := telemetry.Run(exec, log, cfg) + // Package-config enforcement runs on every enterprise cycle — including a + // manually invoked one, and even when telemetry failed. + runPackageConfigEnforce(exec, log) + if telemetryErr != nil { + log.Error("%v", telemetryErr) os.Exit(1) } default: @@ -746,3 +765,103 @@ func runIDEExtensionEnforce(exec executor.Executor, log *progress.Logger) { aiagentscli.AppendError("devicepolicy", "enforce_failed", err.Error(), "") } } + +// runPackageConfigEnforce fetches the device's effective package-config policy +// (the npm secure-registry directive) and converges the managed block in the +// console user's ~/.npmrc to match, then reports compliance — on the same +// scheduled cycle and agent auth channel as the IDE-extension enforcement above. +// It runs on every telemetry cycle, INCLUDING cycles where telemetry itself +// failed, so an emergency unassignment/offboarding directive is never blocked by +// a telemetry outage. A device whose npm config is already governed by the MDM +// remediation script is detected by the writer's content-aware probe and reported +// mdm_managed instead. A silent no-op when enterprise config is missing. Failures +// are logged but never crash main. +func runPackageConfigEnforce(exec executor.Executor, log *progress.Logger) { + cfg, ok := ingest.Snapshot() + if !ok { + log.Debug("package-config enforce: skipped (no enterprise config)") + return + } + fetcher, ok := devicepolicy.NewHTTPFetcher(cfg, nil) + if !ok { + log.Debug("package-config enforce: skipped (fetcher init refused config)") + return + } + reporter, ok := devicepolicy.NewHTTPReporter(cfg, nil) + if !ok { + log.Debug("package-config enforce: skipped (reporter init refused config)") + return + } + + ctx, cancel := context.WithTimeout(context.Background(), devicePolicyEnforceTimeout) + defer cancel() + + dev := device.Gather(ctx, exec) + if dev.SerialNumber == "" || dev.SerialNumber == "unknown" { + log.Warn("package-config enforce: device serial unresolved; skipping") + return + } + serial := dev.SerialNumber + + r := &devicepolicy.Reconciler{ + Fetcher: fetcher, + Reporter: reporter, + CustomerID: cfg.CustomerID, + DeviceID: serial, + Platform: dev.Platform, + Category: devicepolicy.CategoryPackageConfig, + Target: devicepolicy.TargetNPM, + // Render derives the two managed ~/.npmrc content lines from the policy and + // this device's serial. It fully validates the policy and is pure, so it is + // wired even when the writer below could not be constructed. + Render: func(policy json.RawMessage) (string, error) { + return devicepolicy.RenderNPMRCBlock(policy, serial) + }, + OwnsByMarker: true, + // The managed block is one atomic unit, so the lane owns exactly one + // WrittenSettings entry under this key. + OwnershipKey: devicepolicy.NPMOwnedKey, + Logf: func(format string, args ...any) { log.Debug(format, args...) }, + } + + // The writer resolves the console user and opens a directory fd over their + // home. When it cannot (no enforceable target user, or an infrastructure + // failure) leave the writer seams nil and hand the reconciler the init error: + // it classifies AFTER the fetch (absent → silent, clear → retain all state, + // enforce → policy_not_applied for no-target else write_failed). Binding + // w.Converged / w.ProbeExpected before this nil check would capture method + // values on a nil receiver, and the deferred Close would panic. + w, werr := devicepolicy.NewNPMRCWriter(exec) + if werr != nil { + r.WriterInitErr = werr + } else { + defer w.Close() + w.SetLogf(func(format string, args ...any) { log.Debug(format, args...) }) + + // Concurrent convergence of this ~/.npmrc is not serialized across + // processes. Every write is an atomic temp+rename, so an overlapping + // cycle never sees a torn file. While the policy is stable both cycles + // render identical bytes; only a policy transition (a key rotation, or an + // enforce racing a clear) that interleaves with a concurrent cycle can + // briefly leave the superseded value, reconverged next cycle — eventual + // consistency, the same model the VS Code settings.json lane relies on. + // The telemetry singleton lock already serializes the preceding scan phase. + // Ownership state is the exception: it shares one file with every other + // category, so its read-modify-write does take a cross-process lock. + r.Writer = w + r.Converged = w.Converged + r.ProbeExpected = w.ProbeExpected + r.RestoreSnapshot = w.RestoreSnapshot + // Verify-only channel (enforcement=mdm): read the effective ~/.npmrc and + // report the observed bag instead of writing. Bound here because it needs the + // writer's identity-checked read path; with no writer the reconciler's + // category-aware fallback reports verification_failed rather than probing VS + // Code policy for an npm category. + r.ProbeContent = w.ProbeContentNPM + } + + if err := r.Reconcile(ctx); err != nil { + log.Warn("package-config enforce: %v", err) + aiagentscli.AppendError("devicepolicy", "enforce_failed", err.Error(), "") + } +} diff --git a/internal/devicepolicy/api.go b/internal/devicepolicy/api.go index 54c1945..71a046b 100644 --- a/internal/devicepolicy/api.go +++ b/internal/devicepolicy/api.go @@ -39,22 +39,24 @@ const maxRunConfigBytes = 4 << 20 // EffectivePolicy is the parsed device-policy directive, lifted from the // `policy` sub-object of the run-config response (it mirrors agent-api's -// EffectivePolicyResponse). Policy is the settings map: VS Code setting id -// (e.g. "extensions.allowed", "extensions.gallery.serviceUrl") → that setting's -// compiled value as canonical JSON — the exact bytes the backend hashed. The -// agent writes each value verbatim and never re-serializes -// (re-serialization could reorder keys and break the backend's byte-exact -// applied==desired check). extensions.allowed is always present; other keys -// appear only when the policy sets them. Policy identity is (Category, Target); -// Target defaults to vscode for ide_extension. Enforcement selects the channel: -// "mdm" is verify-only (probe and report, no settings write), "dmg" or "" is the -// write-and-verify path. A zero EffectivePolicy (present()==false) means -// run-config carried no directive for this category/target → reconciler no-op. +// EffectivePolicyResponse). Policy carries the compiled policy object as +// canonical JSON — the exact bytes the backend hashed — kept RAW because its +// shape is per-category: for ide_extension it is the settings map (VS Code +// setting id, e.g. "extensions.allowed" / "extensions.gallery.serviceUrl", → that +// setting's compiled value), for package_config it is the npm registry object. +// Each category decodes what it needs; the agent writes the compiled values +// verbatim and never re-serializes (re-serialization could reorder keys and break +// the backend's byte-exact applied==desired check). Policy identity is +// (Category, Target); Target defaults to vscode for ide_extension. Enforcement +// selects the channel: "mdm" is verify-only (probe and report, never a write), +// "dmg" or "" is the write-and-verify path. A zero EffectivePolicy +// (present()==false) means run-config carried no directive for this +// category/target → reconciler no-op. type EffectivePolicy struct { Category string Target string Clear bool - Policy map[string]json.RawMessage + Policy json.RawMessage Hash string GeneratedAt string Enforcement string @@ -62,23 +64,23 @@ type EffectivePolicy struct { // present reports whether the backend expressed a policy directive for this // category — a value to enforce, or an explicit clear. The fetcher guarantees -// clear=false ⇒ a non-empty settings map, so the only successful-fetch state +// clear=false ⇒ a non-empty policy object, so the only successful-fetch state // with neither is "no policy in run-config" (absent), which the reconciler // treats as a no-op (NEVER a clear). func (ep EffectivePolicy) present() bool { return ep.Clear || len(ep.Policy) > 0 } // policyEnvelope is the wire shape of the run-config `policy` sub-object (must -// match agent-api EffectivePolicyResponse). `policy` is the settings map -// (setting id → compiled value). Unknown fields are ignored, so a backend -// emitting extra fields the agent does not model stays compatible. +// match agent-api EffectivePolicyResponse). `policy` stays raw for the same +// per-category reason as EffectivePolicy.Policy. Unknown fields are ignored, so a +// backend emitting extra fields the agent does not model stays compatible. type policyEnvelope struct { - Category string `json:"category"` - Target string `json:"target"` - Clear bool `json:"clear"` - Policy map[string]json.RawMessage `json:"policy,omitempty"` - Hash string `json:"hash,omitempty"` - GeneratedAt string `json:"generated_at"` - Enforcement string `json:"enforcement,omitempty"` // "dmg" | "mdm" | "" + Category string `json:"category"` + Target string `json:"target"` + Clear bool `json:"clear"` + Policy json.RawMessage `json:"policy,omitempty"` + Hash string `json:"hash,omitempty"` + GeneratedAt string `json:"generated_at"` + Enforcement string `json:"enforcement,omitempty"` // "dmg" | "mdm" | "" } // Fetcher returns the effective policy for one device + category + target. @@ -123,9 +125,11 @@ func NewHTTPFetcher(cfg ingest.Config, h *http.Client) (*HTTPFetcher, bool) { // - body that is not valid JSON → error; // - a non-clear result missing policy or hash → error (a malformed policy // must not be written, and must not be mistaken for a clear); -// - a non-clear result whose `policy` is not a JSON object → error: it must -// decode as a setting id → value map, so a string/array/scalar in its place -// fails the decode above and no-ops the reconciler. +// - a non-clear policy that is not itself a JSON object → error (a string or +// array written verbatim could even read back "compliant"); +// - a non-clear ide_extension policy whose settings map does not carry an +// extensions.allowed object → error (validateCategoryPolicy). The check is +// category-gated: a package_config policy legitimately has no such key. // // An omitted/null `policy` is NOT an error: it means run-config carried no // directive for this category/target (a degraded/rules-only response, an older @@ -206,6 +210,16 @@ func (c *HTTPFetcher) Fetch(ctx context.Context, customerID, deviceID, category, GeneratedAt: p.GeneratedAt, Enforcement: strings.TrimSpace(p.Enforcement), } + // Reject a response scoped to a different category/target than requested + // (backend bug, proxy/cache mixup). Acting on it could enforce — or worse, + // clear — the wrong pair. An empty field is not a mismatch; it defaults to + // the requested value just below. + if ep.Category != "" && ep.Category != category { + return EffectivePolicy{}, fmt.Errorf("devicepolicy: response category %q does not match requested %q", ep.Category, category) + } + if ep.Target != "" && ep.Target != target { + return EffectivePolicy{}, fmt.Errorf("devicepolicy: response target %q does not match requested %q", ep.Target, target) + } if ep.Category == "" { ep.Category = category } @@ -213,29 +227,54 @@ func (c *HTTPFetcher) Fetch(ctx context.Context, customerID, deviceID, category, ep.Target = target } if !ep.Clear { - // A non-clear directive must carry a settings map and a hash. The map's - // object shape is already guaranteed: a non-object `policy` fails to - // decode into the map above and returns a decode error, so a - // string/array/scalar never reaches here. + // A non-clear directive must carry a policy object and a hash. if len(ep.Policy) == 0 || ep.Hash == "" { return EffectivePolicy{}, errors.New("devicepolicy: malformed policy: clear=false but policy or hash missing") } - // extensions.allowed is the mandatory core of every ide_extension policy: - // it MUST be present and a JSON object. This rejects an allowlist-missing - // settings map (e.g. a gallery-only response) before it can be written and - // read back "compliant". Other keys stay backend-owned — their values are - // heterogeneous (the allowlist an object, the gallery URL a string). - allow, ok := ep.Policy[allowedExtensionsSettingKey] - if !ok { - return EffectivePolicy{}, errors.New("devicepolicy: malformed policy: settings missing " + allowedExtensionsSettingKey) + // The compiled policy is always a JSON object, in EVERY category. Shape is + // checked here so a malformed payload no-ops at the reconciler; a + // string/array/scalar written verbatim could even read back "compliant". + if !isJSONObject(ep.Policy) { + return EffectivePolicy{}, errors.New("devicepolicy: malformed policy: policy is not a JSON object") } - if !isJSONObject(allow) { - return EffectivePolicy{}, errors.New("devicepolicy: malformed policy: " + allowedExtensionsSettingKey + " is not a JSON object") + if err := validateCategoryPolicy(ep.Category, ep.Policy); err != nil { + return EffectivePolicy{}, err } } return ep, nil } +// validateCategoryPolicy applies the per-category structural checks that top-level +// object shape does not cover. It is category-GATED on purpose: the raw policy is +// shaped differently per category, so an ide_extension key requirement must never +// be imposed on a package_config object. +// +// - ide_extension: extensions.allowed is the mandatory core of the policy — it +// MUST be present and a JSON object. This rejects an allowlist-missing settings +// map (e.g. a gallery-only response) before it can be written and read back +// "compliant". Other keys stay backend-owned: their values are heterogeneous +// (the allowlist an object, the gallery URL a string). +// - package_config: object shape is sufficient here. The npm structure +// (ecosystem / registry_url / auth) is validated downstream by +// RenderNPMRCBlock, which rejects a malformed policy before any write. +func validateCategoryPolicy(category string, policy json.RawMessage) error { + if category != CategoryIDEExtension { + return nil + } + var settings map[string]json.RawMessage + if err := json.Unmarshal(policy, &settings); err != nil { + return fmt.Errorf("devicepolicy: malformed policy: settings map: %w", err) + } + allow, ok := settings[allowedExtensionsSettingKey] + if !ok { + return errors.New("devicepolicy: malformed policy: settings missing " + allowedExtensionsSettingKey) + } + if !isJSONObject(allow) { + return errors.New("devicepolicy: malformed policy: " + allowedExtensionsSettingKey + " is not a JSON object") + } + return nil +} + // isJSONObject reports whether raw's first JSON token opens an object. Callers // pass syntactically valid JSON (decoded by json.Unmarshal, or json.Valid-checked // first), so only the shape needs checking. diff --git a/internal/devicepolicy/api_identity_test.go b/internal/devicepolicy/api_identity_test.go new file mode 100644 index 0000000..7ed7eaf --- /dev/null +++ b/internal/devicepolicy/api_identity_test.go @@ -0,0 +1,162 @@ +package devicepolicy + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/step-security/dev-machine-guard/internal/aiagents/ingest" +) + +// newPolicyFetchServer is a fetch server that asserts the request carries the +// EXPECTED category/target query and returns a fixed body. Unlike newFetchServer +// (pinned to ide_extension/vscode) it lets a test drive any requested pair — +// needed by the identity checks below, which turn on the (category, target) the +// RESPONSE claims versus the one the agent asked for. +func newPolicyFetchServer(t *testing.T, wantCategory, wantTarget, body string) *HTTPFetcher { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.URL.Query().Get("category"); got != wantCategory { + t.Errorf("request category = %q, want %q", got, wantCategory) + } + if got := r.URL.Query().Get("target"); got != wantTarget { + t.Errorf("request target = %q, want %q", got, wantTarget) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + _, _ = w.Write([]byte(body)) + })) + t.Cleanup(srv.Close) + + f, ok := NewHTTPFetcher(ingest.Config{APIEndpoint: srv.URL, APIKey: "test-key"}, srv.Client()) + if !ok { + t.Fatal("NewHTTPFetcher returned ok=false on valid config") + } + return f +} + +func TestFetchRejectsMismatchedResponseCategory(t *testing.T) { + // The agent asked for ide_extension/vscode; the response claims a DIFFERENT + // category (backend bug, proxy/cache mixup). Enforcing it would apply the + // wrong pair — Fetch must reject it before the reconciler ever sees it. + body := `{"policy":{"category":"package_config","target":"vscode","clear":false,` + + `"policy":{"x":true},"hash":"sha256:h","generated_at":"x"}}` + f := newPolicyFetchServer(t, CategoryIDEExtension, TargetVSCode, body) + _, err := f.Fetch(context.Background(), "cust", "dev-1", CategoryIDEExtension, TargetVSCode) + if err == nil || !strings.Contains(err.Error(), "category") { + t.Fatalf("mismatched response category must error, got %v", err) + } +} + +func TestFetchRejectsMismatchedResponseTarget(t *testing.T) { + // Category matches but the response targets a different IDE family — still the + // wrong pair to act on. + body := `{"policy":{"category":"ide_extension","target":"jetbrains","clear":false,` + + `"policy":{"x":true},"hash":"sha256:h","generated_at":"x"}}` + f := newPolicyFetchServer(t, CategoryIDEExtension, TargetVSCode, body) + _, err := f.Fetch(context.Background(), "cust", "dev-1", CategoryIDEExtension, TargetVSCode) + if err == nil || !strings.Contains(err.Error(), "target") { + t.Fatalf("mismatched response target must error, got %v", err) + } +} + +func TestFetchRejectsMismatchedClearDirective(t *testing.T) { + // The most dangerous mixup: a clear:true scoped to the WRONG pair. If it + // slipped through, the reconciler would remove an unrelated category's value. + // The identity check fires before clear is ever surfaced as a directive. + body := `{"policy":{"category":"package_config","target":"npm","clear":true,"generated_at":"x"}}` + f := newPolicyFetchServer(t, CategoryIDEExtension, TargetVSCode, body) + _, err := f.Fetch(context.Background(), "cust", "dev-1", CategoryIDEExtension, TargetVSCode) + if err == nil { + t.Fatal("a clear scoped to a different category/target must be rejected, not surfaced as a clear") + } +} + +func TestFetchPackageConfigTargetRoundTrips(t *testing.T) { + // The generic fetcher carries the package_config/npm pair end to end: the + // request query is scoped to it and a matching response parses back cleanly. + body := `{"policy":{"category":"package_config","target":"npm","clear":false,` + + `"policy":{"registry":"https://npm.pkg.example/"},"hash":"sha256:npm","generated_at":"x"}}` + f := newPolicyFetchServer(t, CategoryPackageConfig, TargetNPM, body) + ep, err := f.Fetch(context.Background(), "cust", "dev-1", CategoryPackageConfig, TargetNPM) + if err != nil { + t.Fatalf("Fetch: %v", err) + } + if ep.Category != CategoryPackageConfig || ep.Target != TargetNPM { + t.Fatalf("round-trip identity = %q/%q, want %q/%q", + ep.Category, ep.Target, CategoryPackageConfig, TargetNPM) + } + if ep.Hash != "sha256:npm" || !ep.present() { + t.Fatalf("ep = %+v, want present with hash sha256:npm", ep) + } +} + +// --------------------------------------------------------------------------- +// Category-gated policy validation +// --------------------------------------------------------------------------- + +func TestFetchAcceptsNPMPolicyWithoutIDEKeys(t *testing.T) { + // `policy` travels raw because its shape is per-category. The ide_extension + // structural check (extensions.allowed present and an object) must therefore be + // GATED on the category: a legitimate package_config object has no such key and + // must not be rejected by it. The npm structure is validated downstream by + // RenderNPMRCBlock. + body := `{"policy":{"category":"package_config","target":"npm","clear":false,` + + `"policy":{"ecosystem":"npm","registry_url":"https://t.registry.stepsecurity.io/javascript",` + + `"auth":{"scheme":"stepsecurity_device_token","api_key":"ssabc123"}},` + + `"hash":"sha256:n","enforcement":"mdm","generated_at":"2026-07-24T00:00:00Z"}}` + f := newPolicyFetchServer(t, CategoryPackageConfig, TargetNPM, body) + ep, err := f.Fetch(context.Background(), "cust", "dev-1", CategoryPackageConfig, TargetNPM) + if err != nil { + t.Fatalf("a valid npm policy must be accepted, got %v", err) + } + if !ep.present() || ep.Clear { + t.Fatalf("npm policy must be present and non-clear, got %+v", ep) + } + if ep.Enforcement != "mdm" { + t.Fatalf("enforcement = %q, want mdm", ep.Enforcement) + } + // The raw bytes reach the renderer untouched — never re-serialized. + if !strings.Contains(string(ep.Policy), `"ecosystem":"npm"`) { + t.Fatalf("policy bytes = %s, want the npm object verbatim", ep.Policy) + } +} + +func TestFetchRejectsNonObjectPolicyForEveryCategory(t *testing.T) { + // The top-level object-shape check is category-AGNOSTIC: a string/array/scalar + // `policy` is malformed in every lane, and a non-object written verbatim could + // even read back "compliant". + for _, tc := range []struct{ category, target string }{ + {CategoryIDEExtension, TargetVSCode}, + {CategoryPackageConfig, TargetNPM}, + } { + for _, raw := range []string{`"bad"`, `[]`, `42`, `true`} { + body := `{"policy":{"category":"` + tc.category + `","target":"` + tc.target + `","clear":false,` + + `"policy":` + raw + `,"hash":"sha256:x","generated_at":"x"}}` + f := newPolicyFetchServer(t, tc.category, tc.target, body) + if _, err := f.Fetch(context.Background(), "cust", "dev-1", tc.category, tc.target); err == nil { + t.Fatalf("%s/%s: policy %s must be an error", tc.category, tc.target, raw) + } + } + } +} + +func TestFetchIDEStructuralCheckStillApplies(t *testing.T) { + // The mirror of the npm case: gating the check on the category must not weaken + // it for ide_extension. An allowlist-missing or non-object allowlist bag still + // errors, even though the same raw shape would be fine for npm. + for _, settings := range []string{ + `{"extensions.gallery.serviceUrl":"https://mkt.example/api/v1"}`, + `{"extensions.allowed":"nope"}`, + `{"extensions.allowed":[]}`, + } { + body := `{"policy":{"category":"ide_extension","target":"vscode","clear":false,` + + `"policy":` + settings + `,"hash":"sha256:x","generated_at":"x"}}` + f := newPolicyFetchServer(t, CategoryIDEExtension, TargetVSCode, body) + if _, err := f.Fetch(context.Background(), "cust", "dev-1", CategoryIDEExtension, TargetVSCode); err == nil { + t.Fatalf("ide settings %s must be an error", settings) + } + } +} diff --git a/internal/devicepolicy/api_test.go b/internal/devicepolicy/api_test.go index d819faa..88da5ac 100644 --- a/internal/devicepolicy/api_test.go +++ b/internal/devicepolicy/api_test.go @@ -64,7 +64,7 @@ func TestFetchPolicy(t *testing.T) { t.Fatalf("hash = %q", ep.Hash) } // The allowlist value in the settings map must round-trip as the bytes the backend sent. - al, ok := ep.Policy[allowedExtensionsSettingKey] + al, ok := policySettings(t, ep)[allowedExtensionsSettingKey] if !ok { t.Fatal("settings map missing extensions.allowed") } @@ -95,7 +95,7 @@ func TestFetchLiftsGalleryServiceURL(t *testing.T) { if err != nil { t.Fatalf("Fetch: %v", err) } - gal, ok := ep.Policy[galleryServiceURLSettingKey] + gal, ok := policySettings(t, ep)[galleryServiceURLSettingKey] if !ok || string(gal) != `"https://mkt.example/api/v1"` { t.Fatalf("gallery value = %q ok=%v, want the URL as a JSON string", string(gal), ok) } @@ -111,8 +111,8 @@ func TestFetchAbsentGalleryServiceURLIsEmpty(t *testing.T) { if err != nil { t.Fatalf("Fetch: %v", err) } - if _, ok := ep.Policy[galleryServiceURLSettingKey]; ok { - t.Fatalf("gallery key should be absent from the settings map, got %q", string(ep.Policy[galleryServiceURLSettingKey])) + if got, ok := policySettings(t, ep)[galleryServiceURLSettingKey]; ok { + t.Fatalf("gallery key should be absent from the settings map, got %q", string(got)) } } @@ -160,7 +160,7 @@ func TestFetchIgnoresDetectionRules(t *testing.T) { if ep.Hash != "sha256:xyz" { t.Fatalf("hash = %q, want sha256:xyz", ep.Hash) } - if got := string(ep.Policy[allowedExtensionsSettingKey]); !strings.Contains(got, `"ms-python.python":true`) { + if got := string(policySettings(t, ep)[allowedExtensionsSettingKey]); !strings.Contains(got, `"ms-python.python":true`) { t.Fatalf("allowlist = %s", got) } // The policy object omits `target`; Fetch defaults it to the requested target. diff --git a/internal/devicepolicy/cache.go b/internal/devicepolicy/cache.go index 5d1487a..9f3e598 100644 --- a/internal/devicepolicy/cache.go +++ b/internal/devicepolicy/cache.go @@ -2,15 +2,18 @@ package devicepolicy import ( "encoding/json" + "errors" + "fmt" "os" "path/filepath" "sync" "time" ) -// CacheFilename is the basename of the enforcement state file. It lives under -// ~/.stepsecurity/ alongside config.json and hooks-state.json, and is distinct -// from the AI-agent hook cache (this is a separate subsystem — no shared state). +// CacheFilename is the basename of the enforcement state file — the ONE file +// holding every category's ownership record, keyed by category then target. It +// lives under ~/.stepsecurity/ alongside config.json and hooks-state.json, and is +// distinct from the AI-agent hook cache (a separate subsystem — no shared state). const CacheFilename = "device-policy-state.json" // CacheSchemaVersion is the on-disk version of the state file. Bump only on a @@ -23,9 +26,10 @@ const ( ) // AppliedStateFile is the on-disk shape: a schema-versioned wrapper keyed by -// category and then by target, so multiple categories and IDE targets share one -// file without forcing a future migration. Exactly one pair -// (ide_extension/vscode) is populated today. +// category and then by target, so every category and target shares ONE file +// without forcing a future migration. This is the only state file the subsystem +// keeps — a category does not get its own, and writing or clearing one +// (category, target) preserves every other one. // // { // "schema_version": 1, @@ -34,6 +38,11 @@ const ( // "targets": { // "vscode": { "applied_hash": …, "written_settings": …, "fetched_at": … } // } +// }, +// "package_config": { +// "targets": { +// "npm": { "applied_hash": …, "written_settings": …, "fetched_at": … } +// } // } // } // } @@ -58,28 +67,34 @@ type AppliedCategoryState struct { // // - AppliedHash is the backend's content hash, stored VERBATIM (never // recomputed). Compared against the freshly-fetched hash for idempotency. -// - WrittenSettings holds ownership for the managed multi-key path: setting id -// → the exact compacted value the agent wrote, for every managed key (the VS -// Code allowlist and the gallery service URL). A key absent from the map is -// one the agent does not own. -// - WrittenValue holds ownership for the single-key path (the npm writer, which -// owns one opaque value). The managed multi-key path does not use it. +// - WrittenSettings is the ONLY ownership field, for every lane: the managed +// multi-key path records setting id → the exact compacted value the agent +// wrote for each managed key (the VS Code allowlist and the gallery service +// URL), and a single-value path records exactly one entry under its own +// ownership key (npmOwnedKey for the ~/.npmrc block, the allowlist setting id +// for a degraded VS Code writer). A key absent from the map is one the agent +// does not own. // // A zero-value entry means "the agent owns nothing on disk" for that // category/target. +// +// AppliedHash and WrittenSettings are persisted as one struct in one write, so a +// record whose AppliedHash equals the freshly-fetched hash always carries the +// complete ownership map for that policy. Convergence checks rely on that: a +// cycle that finds the target converged with an unchanged hash short-circuits +// without persisting, which is only safe because a matching hash cannot coexist +// with partial ownership. A record hand-edited to break that pairing is outside +// the supported inputs — the value-based clear would leave those keys in place. type AppliedTargetState struct { AppliedHash string `json:"applied_hash"` - WrittenValue string `json:"written_value,omitempty"` WrittenSettings map[string]string `json:"written_settings,omitempty"` FetchedAt time.Time `json:"fetched_at"` } -// cacheMu serializes the read-modify-write of the shared state file so two -// in-process category writers cannot lose each other's update. It does NOT make -// the file safe across separate agent PROCESSES — that still relies on -// atomic-rename last-writer-wins, and a cross-process lock (flock/LockFileEx) -// would be needed before categories are reconciled concurrently or multiple -// agents run against more than one category. +// cacheMu serializes the read-modify-write of the state file so two in-process +// category writers cannot lose each other's update. It sees only this process; +// withStateLock covers separate agent processes, and every read-modify-write +// takes both — cacheMu first, then the file lock, always in that order. // // The lock is NOT reentrant: helpers that already hold it use the unlocked // readStateFile / persistStateFile, never the public ReadAppliedState / @@ -117,9 +132,19 @@ type readStatus int const ( // stateReadable: the file parsed and its schema is this build's or older. stateReadable readStatus = iota - // stateAbsentOrCorrupt: missing, unreadable, or not a JSON object. Safe to - // recreate from scratch. + // stateAbsentOrCorrupt: missing, or read but not a JSON object. Safe to + // recreate from scratch — nothing interpretable is there to lose. The two stay + // one status because both callers treat them identically: a write recreates, + // and a clear's removeStateFile already takes an absent file as success. stateAbsentOrCorrupt + // stateUnreadable: the file is THERE but its bytes could not be obtained (no + // read permission, an I/O error, a directory at the path, an unresolvable + // home). Distinct from corrupt precisely because the content is unknown, not + // known-worthless: it may hold live ownership records for other categories, so + // a write must not replace it and a clear must not remove it — on POSIX an + // unreadable file is still unlinkable through a writable parent. Both surface + // the read error instead. + stateUnreadable // stateFuture: a cleanly-parsed file from a NEWER agent (schema_version // beyond this build). Must NOT be overwritten — its category metadata can't // be interpreted, and clobbering it would strand a newer agent's ownership. @@ -144,31 +169,37 @@ func peekSchemaVersion(b []byte) (version int, ok bool) { // readStateFile loads and classifies the state file. UNLOCKED: callers that // also write hold cacheMu and call this (never the public ReadAppliedState), // because cacheMu is not reentrant. On stateReadable, Categories is non-nil. -func readStateFile() (AppliedStateFile, readStatus) { +// +// The returned error is non-nil only for stateUnreadable, and is what a mutating +// caller returns instead of touching a file whose content it could not see. +func readStateFile() (AppliedStateFile, readStatus, error) { path := CachePath() if path == "" { - return AppliedStateFile{}, stateAbsentOrCorrupt + return AppliedStateFile{}, stateUnreadable, errNoHomeDir } // #nosec G304 -- path is CachePath(): a test override or os.UserHomeDir() // joined with the package constant CacheFilename. Never external input. b, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return AppliedStateFile{}, stateAbsentOrCorrupt, nil + } if err != nil { - return AppliedStateFile{}, stateAbsentOrCorrupt + return AppliedStateFile{}, stateUnreadable, fmt.Errorf("devicepolicy: read %s: %w", path, err) } ver, ok := peekSchemaVersion(b) if !ok { // Not a JSON object — corrupt. Safe to recreate. - return AppliedStateFile{}, stateAbsentOrCorrupt + return AppliedStateFile{}, stateAbsentOrCorrupt, nil } // Refuse a file from a newer agent. A schema beyond what this build knows // may reuse fields with changed meaning; the reader falls back to "owns // nothing" and the writer refuses to clobber it. if ver > CacheSchemaVersion { - return AppliedStateFile{}, stateFuture + return AppliedStateFile{}, stateFuture, nil } var f AppliedStateFile if err := json.Unmarshal(b, &f); err != nil { - return AppliedStateFile{}, stateAbsentOrCorrupt + return AppliedStateFile{}, stateAbsentOrCorrupt, nil } // A 0 version predates the field (or was hand-written); persistStateFile // always stamps it, so a genuine file from this agent is never 0. Two older @@ -182,16 +213,22 @@ func readStateFile() (AppliedStateFile, readStatus) { if f.Categories == nil { f.Categories = map[string]AppliedCategoryState{} } - return f, stateReadable + return f, stateReadable, nil } // ReadAppliedState returns the agent's recorded ownership for one // (category, target): (state, true) when a record exists, else (zero, false). // An empty target defaults to vscode. It never surfaces an error — a -// missing/corrupt file, or one written by a newer agent (schema_version beyond -// this build's CacheSchemaVersion), simply means "no recorded ownership". The -// reconciler treats that as owning nothing: safe, because it then refuses to -// clear a value it has no record of writing and re-applies the policy. +// missing/corrupt/unreadable file, or one written by a newer agent +// (schema_version beyond this build's CacheSchemaVersion), simply means "no +// recorded ownership". The reconciler treats that as owning nothing: safe, +// because it then refuses to clear a value it has no record of writing and +// re-applies the policy. Only the MUTATING accessors distinguish unreadable from +// absent, because only they can destroy what they could not read. +// +// It takes no file lock: a lone read modifies nothing, and every write lands by +// atomic rename, so a reader sees one complete generation of the file or another — +// never a half-written one. func ReadAppliedState(category, target string) (AppliedTargetState, bool) { cacheMu.Lock() defer cacheMu.Unlock() @@ -199,7 +236,7 @@ func ReadAppliedState(category, target string) (AppliedTargetState, bool) { if target == "" { target = TargetVSCode } - f, status := readStateFile() + f, status, _ := readStateFile() if status != stateReadable { return AppliedTargetState{}, false } @@ -216,7 +253,16 @@ func ReadAppliedState(category, target string) (AppliedTargetState, bool) { // (read-modify-write), then atomically replaces the file (temp + sync + rename). // An empty target defaults to vscode. It REFUSES to overwrite a file written by // a newer agent (errFutureSchema) rather than clobber metadata it cannot -// interpret. A missing or corrupt file is recreated. +// interpret, and likewise refuses (returning the read error) when the file is +// present but unreadable — recreating it there would silently drop live sibling +// records whose content was never seen. A missing or corrupt file IS recreated: +// neither holds anything a read could have recovered. +// +// The whole read-modify-write runs under cacheMu and the cross-process file lock, +// so a concurrent write for a different category — an npm reconcile in one process +// against an IDE reconcile in another — cannot drop this one's record, or have its +// own dropped. That holds unconditionally: a lock that cannot be taken fails the +// write (errStateLockBusy and friends) instead of proceeding unlocked. func WriteAppliedState(category, target string, s AppliedTargetState) error { cacheMu.Lock() defer cacheMu.Unlock() @@ -224,31 +270,49 @@ func WriteAppliedState(category, target string, s AppliedTargetState) error { if target == "" { target = TargetVSCode } - f, status := readStateFile() - switch status { - case stateFuture: - return errFutureSchema - case stateAbsentOrCorrupt: - f = AppliedStateFile{Categories: map[string]AppliedCategoryState{}} - } - if f.Categories == nil { - f.Categories = map[string]AppliedCategoryState{} - } - cat := f.Categories[category] - if cat.Targets == nil { - cat.Targets = map[string]AppliedTargetState{} - } - cat.Targets[target] = s - f.Categories[category] = cat - return persistStateFile(f) + return withStateLock(func() error { + f, status, rerr := readStateFile() + switch status { + case stateUnreadable: + return rerr + case stateFuture: + return errFutureSchema + case stateAbsentOrCorrupt: + f = AppliedStateFile{Categories: map[string]AppliedCategoryState{}} + } + if f.Categories == nil { + f.Categories = map[string]AppliedCategoryState{} + } + cat := f.Categories[category] + if cat.Targets == nil { + cat.Targets = map[string]AppliedTargetState{} + } + cat.Targets[target] = s + f.Categories[category] = cat + return persistStateFile(f) + }) } // ClearAppliedState drops one (category, target) ownership record, PRESERVING // every other category AND every sibling target, then atomically rewrites the // file. An empty target defaults to vscode. When the cleared target was the // category's last, the now-empty category is dropped too. Same future-schema -// refusal as WriteAppliedState. A missing or corrupt file — or an already-absent -// category/target — is a no-op (nothing recorded to drop). +// refusal and same cacheMu + cross-process locking as WriteAppliedState. An +// already-absent category/target is a no-op (nothing recorded to drop). +// +// A CORRUPT file is removed rather than left in place. Its bytes can still hold a +// token-bearing written_settings entry (the ~/.npmrc block records the device's +// auth token), and a clear is an unassignment or offboarding — reporting it done +// while those bytes survive on disk is what removal prevents. Nothing +// interpretable is lost: an unparseable file already reads as "owns nothing" for +// every category, so no sibling record could have been recovered from it. An +// absent file is left alone (there is nothing to remove). +// +// An UNREADABLE file is neither removed nor rewritten — the read error is +// returned. On POSIX a file with no read permission is still unlinkable through a +// writable parent, so removing it here would destroy sibling categories' live +// ownership records sight unseen; a surfaced error keeps the operation honest and +// leaves the file for the next cycle (or an admin) to deal with. func ClearAppliedState(category, target string) error { cacheMu.Lock() defer cacheMu.Unlock() @@ -256,27 +320,47 @@ func ClearAppliedState(category, target string) error { if target == "" { target = TargetVSCode } - f, status := readStateFile() - switch status { - case stateFuture: - return errFutureSchema - case stateAbsentOrCorrupt: - return nil - } - cat, ok := f.Categories[category] - if !ok { - return nil - } - if _, ok := cat.Targets[target]; !ok { + return withStateLock(func() error { + f, status, rerr := readStateFile() + switch status { + case stateUnreadable: + return rerr + case stateFuture: + return errFutureSchema + case stateAbsentOrCorrupt: + return removeStateFile() + } + cat, ok := f.Categories[category] + if !ok { + return nil + } + if _, ok := cat.Targets[target]; !ok { + return nil + } + delete(cat.Targets, target) + if len(cat.Targets) == 0 { + delete(f.Categories, category) + } else { + f.Categories[category] = cat + } + return persistStateFile(f) + }) +} + +// removeStateFile unlinks the state file, treating an already-absent one as +// success — which is what makes it safe for the shared absent-or-corrupt status: +// absent stays a no-op, corrupt gets cleaned up. UNLOCKED: callers hold cacheMu +// and the file lock. A symlink at the path is unlinked itself, not followed, so it +// cannot redirect the delete. +func removeStateFile() error { + path := CachePath() + if path == "" { return nil } - delete(cat.Targets, target) - if len(cat.Targets) == 0 { - delete(f.Categories, category) - } else { - f.Categories[category] = cat + if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { + return err } - return persistStateFile(f) + return nil } // persistStateFile stamps the current schema version and atomically writes the @@ -337,4 +421,8 @@ func (e cacheError) Error() string { return string(e) } const ( errNoHomeDir = cacheError("devicepolicy: cannot resolve home directory") errFutureSchema = cacheError("devicepolicy: refusing to overwrite a newer-schema state file") + // errStateLockBusy: a peer agent process held the state lock for the whole wait + // budget. The read-modify-write is abandoned rather than run unlocked — see + // withStateLock for why that trade is deliberate. + errStateLockBusy = cacheError("devicepolicy: another process holds the enforcement-state lock") ) diff --git a/internal/devicepolicy/cache_test.go b/internal/devicepolicy/cache_test.go index 9aa1653..f8bbb90 100644 --- a/internal/devicepolicy/cache_test.go +++ b/internal/devicepolicy/cache_test.go @@ -5,20 +5,34 @@ import ( "errors" "os" "path/filepath" + "runtime" "strings" + "sync" "testing" "time" ) +// ownRec / npmOwnRec build the one-entry ownership record a single-value lane +// persists: WrittenSettings is the only ownership field, so a lane that owns one +// opaque value records it under its own ownership key — the allowlist setting id +// for a plain VS Code Writer, NPMOwnedKey for the ~/.npmrc block. +func ownRec(value string) map[string]string { + return map[string]string{allowedExtensionsSettingKey: value} +} + +func npmOwnRec(value string) map[string]string { + return map[string]string{NPMOwnedKey: value} +} + func TestAppliedTargetRoundTrip(t *testing.T) { dir := t.TempDir() restore := SetCachePathForTest(filepath.Join(dir, CacheFilename)) defer restore() want := AppliedTargetState{ - AppliedHash: "sha256:abc", - WrittenValue: samplePolicy, - FetchedAt: time.Date(2026, 6, 8, 0, 0, 0, 0, time.UTC), + AppliedHash: "sha256:abc", + WrittenSettings: ownRec(samplePolicy), + FetchedAt: time.Date(2026, 6, 8, 0, 0, 0, 0, time.UTC), } if err := WriteAppliedState(CategoryIDEExtension, TargetVSCode, want); err != nil { t.Fatalf("WriteAppliedState: %v", err) @@ -27,7 +41,7 @@ func TestAppliedTargetRoundTrip(t *testing.T) { if !ok { t.Fatal("ReadAppliedState ok=false after write") } - if got.AppliedHash != want.AppliedHash || got.WrittenValue != want.WrittenValue { + if got.AppliedHash != want.AppliedHash || got.WrittenSettings[allowedExtensionsSettingKey] != want.WrittenSettings[allowedExtensionsSettingKey] { t.Fatalf("got %+v, want %+v", got, want) } // On disk it is the schema-versioned wrapper keyed by category then target. @@ -110,7 +124,7 @@ func TestReadAbsentCategoryOwnsNothing(t *testing.T) { restore := SetCachePathForTest(filepath.Join(t.TempDir(), CacheFilename)) defer restore() // The file exists and holds one category; a DIFFERENT category owns nothing. - if err := WriteAppliedState("other_category", TargetVSCode, AppliedTargetState{WrittenValue: "x"}); err != nil { + if err := WriteAppliedState("other_category", TargetVSCode, AppliedTargetState{WrittenSettings: ownRec("x")}); err != nil { t.Fatal(err) } if _, ok := ReadAppliedState(CategoryIDEExtension, TargetVSCode); ok { @@ -122,7 +136,7 @@ func TestReadAbsentTargetOwnsNothing(t *testing.T) { restore := SetCachePathForTest(filepath.Join(t.TempDir(), CacheFilename)) defer restore() // The category exists with a vscode target; a DIFFERENT target owns nothing. - if err := WriteAppliedState(CategoryIDEExtension, TargetVSCode, AppliedTargetState{WrittenValue: samplePolicy}); err != nil { + if err := WriteAppliedState(CategoryIDEExtension, TargetVSCode, AppliedTargetState{WrittenSettings: ownRec(samplePolicy)}); err != nil { t.Fatal(err) } if _, ok := ReadAppliedState(CategoryIDEExtension, "jetbrains"); ok { @@ -138,16 +152,16 @@ func TestWritePreservesOtherCategories(t *testing.T) { restore := SetCachePathForTest(filepath.Join(t.TempDir(), CacheFilename)) defer restore() - other := AppliedTargetState{AppliedHash: "sha256:OTHER", WrittenValue: "other-value"} + other := AppliedTargetState{AppliedHash: "sha256:OTHER", WrittenSettings: ownRec("other-value")} if err := WriteAppliedState("other_category", TargetVSCode, other); err != nil { t.Fatal(err) } - if err := WriteAppliedState(CategoryIDEExtension, TargetVSCode, AppliedTargetState{AppliedHash: "sha256:H", WrittenValue: samplePolicy}); err != nil { + if err := WriteAppliedState(CategoryIDEExtension, TargetVSCode, AppliedTargetState{AppliedHash: "sha256:H", WrittenSettings: ownRec(samplePolicy)}); err != nil { t.Fatal(err) } // Writing ide_extension must not disturb other_category. got, ok := ReadAppliedState("other_category", TargetVSCode) - if !ok || got.AppliedHash != other.AppliedHash || got.WrittenValue != other.WrittenValue { + if !ok || got.AppliedHash != other.AppliedHash || got.WrittenSettings[allowedExtensionsSettingKey] != other.WrittenSettings[allowedExtensionsSettingKey] { t.Fatalf("other category not preserved across a sibling write: got %+v ok=%v", got, ok) } } @@ -157,19 +171,19 @@ func TestWritePreservesOtherTargets(t *testing.T) { defer restore() // Two targets under the SAME category. Rewriting one must not disturb the other. - jb := AppliedTargetState{AppliedHash: "sha256:JB", WrittenValue: "jetbrains-value"} + jb := AppliedTargetState{AppliedHash: "sha256:JB", WrittenSettings: ownRec("jetbrains-value")} if err := WriteAppliedState(CategoryIDEExtension, "jetbrains", jb); err != nil { t.Fatal(err) } - if err := WriteAppliedState(CategoryIDEExtension, TargetVSCode, AppliedTargetState{AppliedHash: "sha256:VS", WrittenValue: samplePolicy}); err != nil { + if err := WriteAppliedState(CategoryIDEExtension, TargetVSCode, AppliedTargetState{AppliedHash: "sha256:VS", WrittenSettings: ownRec(samplePolicy)}); err != nil { t.Fatal(err) } // Rewrite vscode again — the sibling jetbrains target must still stand. - if err := WriteAppliedState(CategoryIDEExtension, TargetVSCode, AppliedTargetState{AppliedHash: "sha256:VS2", WrittenValue: samplePolicy}); err != nil { + if err := WriteAppliedState(CategoryIDEExtension, TargetVSCode, AppliedTargetState{AppliedHash: "sha256:VS2", WrittenSettings: ownRec(samplePolicy)}); err != nil { t.Fatal(err) } got, ok := ReadAppliedState(CategoryIDEExtension, "jetbrains") - if !ok || got.AppliedHash != jb.AppliedHash || got.WrittenValue != jb.WrittenValue { + if !ok || got.AppliedHash != jb.AppliedHash || got.WrittenSettings[allowedExtensionsSettingKey] != jb.WrittenSettings[allowedExtensionsSettingKey] { t.Fatalf("sibling target not preserved across a same-category write: got %+v ok=%v", got, ok) } if vs, ok := ReadAppliedState(CategoryIDEExtension, TargetVSCode); !ok || vs.AppliedHash != "sha256:VS2" { @@ -186,7 +200,7 @@ func TestWriteRefusesFutureSchemaFile(t *testing.T) { restore := SetCachePathForTest(path) defer restore() - err := WriteAppliedState(CategoryIDEExtension, TargetVSCode, AppliedTargetState{WrittenValue: samplePolicy}) + err := WriteAppliedState(CategoryIDEExtension, TargetVSCode, AppliedTargetState{WrittenSettings: ownRec(samplePolicy)}) if !errors.Is(err, errFutureSchema) { t.Fatalf("write over a future-schema file must refuse with errFutureSchema, got %v", err) } @@ -203,10 +217,10 @@ func TestClearRemovesTargetAndPreservesSiblingCategory(t *testing.T) { restore := SetCachePathForTest(filepath.Join(t.TempDir(), CacheFilename)) defer restore() - if err := WriteAppliedState("keep_me", TargetVSCode, AppliedTargetState{WrittenValue: "keep"}); err != nil { + if err := WriteAppliedState("keep_me", TargetVSCode, AppliedTargetState{WrittenSettings: ownRec("keep")}); err != nil { t.Fatal(err) } - if err := WriteAppliedState(CategoryIDEExtension, TargetVSCode, AppliedTargetState{WrittenValue: samplePolicy}); err != nil { + if err := WriteAppliedState(CategoryIDEExtension, TargetVSCode, AppliedTargetState{WrittenSettings: ownRec(samplePolicy)}); err != nil { t.Fatal(err) } if err := ClearAppliedState(CategoryIDEExtension, TargetVSCode); err != nil { @@ -215,7 +229,7 @@ func TestClearRemovesTargetAndPreservesSiblingCategory(t *testing.T) { if _, ok := ReadAppliedState(CategoryIDEExtension, TargetVSCode); ok { t.Fatal("cleared target should be gone") } - if got, ok := ReadAppliedState("keep_me", TargetVSCode); !ok || got.WrittenValue != "keep" { + if got, ok := ReadAppliedState("keep_me", TargetVSCode); !ok || got.WrittenSettings[allowedExtensionsSettingKey] != "keep" { t.Fatalf("untouched category must survive a sibling clear: got %+v ok=%v", got, ok) } } @@ -226,10 +240,10 @@ func TestClearRemovesOnlyTargetWithinCategory(t *testing.T) { // Two targets under one category; clearing one must leave the other — and the // category itself — intact. Clearing the last target then drops the category. - if err := WriteAppliedState(CategoryIDEExtension, TargetVSCode, AppliedTargetState{WrittenValue: samplePolicy}); err != nil { + if err := WriteAppliedState(CategoryIDEExtension, TargetVSCode, AppliedTargetState{WrittenSettings: ownRec(samplePolicy)}); err != nil { t.Fatal(err) } - if err := WriteAppliedState(CategoryIDEExtension, "jetbrains", AppliedTargetState{WrittenValue: "jb"}); err != nil { + if err := WriteAppliedState(CategoryIDEExtension, "jetbrains", AppliedTargetState{WrittenSettings: ownRec("jb")}); err != nil { t.Fatal(err) } if err := ClearAppliedState(CategoryIDEExtension, TargetVSCode); err != nil { @@ -238,7 +252,7 @@ func TestClearRemovesOnlyTargetWithinCategory(t *testing.T) { if _, ok := ReadAppliedState(CategoryIDEExtension, TargetVSCode); ok { t.Fatal("cleared vscode target should be gone") } - if got, ok := ReadAppliedState(CategoryIDEExtension, "jetbrains"); !ok || got.WrittenValue != "jb" { + if got, ok := ReadAppliedState(CategoryIDEExtension, "jetbrains"); !ok || got.WrittenSettings[allowedExtensionsSettingKey] != "jb" { t.Fatalf("sibling jetbrains target must survive a vscode clear: got %+v ok=%v", got, ok) } // On disk the category must still exist (it still has the jetbrains target). @@ -279,7 +293,7 @@ func TestClearReclaimsEmptyTargetRecord(t *testing.T) { if err := WriteAppliedState(CategoryIDEExtension, TargetVSCode, AppliedTargetState{FetchedAt: time.Unix(0, 0).UTC()}); err != nil { t.Fatal(err) } - if err := WriteAppliedState("keep_me", TargetVSCode, AppliedTargetState{WrittenValue: "keep"}); err != nil { + if err := WriteAppliedState("keep_me", TargetVSCode, AppliedTargetState{WrittenSettings: ownRec("keep")}); err != nil { t.Fatal(err) } // The empty entry is still a present key (ok=true) — the reconciler's @@ -293,7 +307,7 @@ func TestClearReclaimsEmptyTargetRecord(t *testing.T) { if _, ok := ReadAppliedState(CategoryIDEExtension, TargetVSCode); ok { t.Fatal("empty target record should be reclaimed by clear") } - if got, ok := ReadAppliedState("keep_me", TargetVSCode); !ok || got.WrittenValue != "keep" { + if got, ok := ReadAppliedState("keep_me", TargetVSCode); !ok || got.WrittenSettings[allowedExtensionsSettingKey] != "keep" { t.Fatalf("sibling category must survive: got %+v ok=%v", got, ok) } } @@ -365,9 +379,9 @@ func TestAppliedTargetWrittenSettingsRoundTrip(t *testing.T) { defer restore() want := AppliedTargetState{ - AppliedHash: "sha256:abc", - WrittenValue: samplePolicy, + AppliedHash: "sha256:abc", WrittenSettings: map[string]string{ + allowedExtensionsSettingKey: samplePolicy, galleryServiceURLSettingKey: `"https://mkt.example/api/v1"`, }, FetchedAt: time.Date(2026, 7, 23, 0, 0, 0, 0, time.UTC), @@ -379,33 +393,652 @@ func TestAppliedTargetWrittenSettingsRoundTrip(t *testing.T) { if !ok { t.Fatal("ok=false after write") } - if got.WrittenSettings[galleryServiceURLSettingKey] != want.WrittenSettings[galleryServiceURLSettingKey] { - t.Fatalf("WrittenSettings not round-tripped: got %+v", got.WrittenSettings) + for key, wantVal := range want.WrittenSettings { + if got.WrittenSettings[key] != wantVal { + t.Fatalf("WrittenSettings[%s] not round-tripped: got %+v", key, got.WrittenSettings) + } } } -func TestAppliedTargetNoWrittenSettingsOmitsField(t *testing.T) { +func TestAppliedTargetEmptyOwnershipOmitsField(t *testing.T) { path := filepath.Join(t.TempDir(), CacheFilename) restore := SetCachePathForTest(path) defer restore() + // A record that owns nothing (the preflight writability probe writes exactly + // this) must omit written_settings on disk and read back as a nil map. This is + // the only shape that omits it now that WrittenSettings is the sole ownership + // field: a single-value lane records one entry, so its record always carries it. if err := WriteAppliedState(CategoryIDEExtension, TargetVSCode, AppliedTargetState{ - AppliedHash: "sha256:H", WrittenValue: samplePolicy, + AppliedHash: "sha256:H", }); err != nil { t.Fatal(err) } - // A single-key writer's record (npm: one opaque WrittenValue, no managed - // multi-key map) must omit written_settings on disk. raw, err := os.ReadFile(path) if err != nil { t.Fatal(err) } if strings.Contains(string(raw), "written_settings") { - t.Fatalf("single-key record must omit written_settings:\n%s", raw) + t.Fatalf("a record owning nothing must omit written_settings:\n%s", raw) } - // And it reads back as a nil map (owns no managed multi-key state). got, ok := ReadAppliedState(CategoryIDEExtension, TargetVSCode) if !ok || got.WrittenSettings != nil { - t.Fatalf("WrittenSettings must be nil for a single-key record, got %+v ok=%v", got.WrittenSettings, ok) + t.Fatalf("WrittenSettings must be nil when nothing is owned, got %+v ok=%v", got.WrittenSettings, ok) + } +} + +func TestAppliedTargetSingleValueRecordsOneEntry(t *testing.T) { + path := filepath.Join(t.TempDir(), CacheFilename) + restore := SetCachePathForTest(path) + defer restore() + + // The single-value lanes (the ~/.npmrc block writer, and the degraded VS Code + // writer) record ownership as exactly ONE written_settings entry keyed by their + // own ownership key. This is the shape the retired written_value field used to + // hold, so the collapse must not change what round-trips. + if err := WriteAppliedState(CategoryPackageConfig, TargetNPM, AppliedTargetState{ + AppliedHash: "sha256:N", WrittenSettings: npmOwnRec("registry=https://x.example/javascript"), + }); err != nil { + t.Fatal(err) + } + got, ok := ReadAppliedState(CategoryPackageConfig, TargetNPM) + if !ok { + t.Fatal("ok=false after write") + } + if len(got.WrittenSettings) != 1 || got.WrittenSettings[NPMOwnedKey] != "registry=https://x.example/javascript" { + t.Fatalf("single-value record = %+v, want exactly one %s entry", got.WrittenSettings, NPMOwnedKey) + } +} + +// TestAppliedTargetLegacyWrittenValueReadsAsUnowned pins the no-migrator +// decision: a state file written before the collapse carries only the retired +// written_value key, which decodes into no WrittenSettings entry — so the target +// reads as "owns nothing" and the next enforce re-converges and re-records it. No +// production devices exist, so nothing is owed beyond not crashing. +func TestAppliedTargetLegacyWrittenValueReadsAsUnowned(t *testing.T) { + path := filepath.Join(t.TempDir(), CacheFilename) + restore := SetCachePathForTest(path) + defer restore() + + legacy := `{"schema_version":1,"categories":{"ide_extension":{"targets":{"vscode":` + + `{"applied_hash":"sha256:OLD","written_value":"{\"*\":false}","fetched_at":"2026-07-01T00:00:00Z"}}}}}` + if err := os.WriteFile(path, []byte(legacy), 0o600); err != nil { + t.Fatal(err) + } + got, ok := ReadAppliedState(CategoryIDEExtension, TargetVSCode) + if !ok { + t.Fatal("a legacy record must still decode (ok=true), just own nothing") + } + if got.AppliedHash != "sha256:OLD" { + t.Fatalf("applied_hash = %q, want the legacy hash preserved", got.AppliedHash) + } + if len(got.WrittenSettings) != 0 { + t.Fatalf("legacy written_value must not decode into ownership, got %+v", got.WrittenSettings) + } + if len(ownedKeys(got, ok)) != 0 { + t.Fatal("ownedKeys over a legacy record must be empty (owns nothing)") + } +} + +// --------------------------------------------------------------------------- +// One state file for every category +// --------------------------------------------------------------------------- + +// npmRec / ideRec are the two categories' records as they coexist in the file. +func npmRec(hash, value string) AppliedTargetState { + return AppliedTargetState{AppliedHash: hash, WrittenSettings: npmOwnRec(value), FetchedAt: time.Date(2026, 7, 25, 0, 0, 0, 0, time.UTC)} +} + +func ideRec(hash, value string) AppliedTargetState { + return AppliedTargetState{AppliedHash: hash, WrittenSettings: ownRec(value), FetchedAt: time.Date(2026, 7, 25, 0, 0, 0, 0, time.UTC)} +} + +func TestNPMAndIDEShareOneStateFile(t *testing.T) { + // package_config/npm and ide_extension/vscode are two entries in ONE file — + // there is no per-category state file. Each is written through the same + // accessors, each preserves the other, and the file keeps the schema-versioned + // category→target shape and 0600 mode for both. + dir := t.TempDir() + path := filepath.Join(dir, CacheFilename) + restore := SetCachePathForTest(path) + defer restore() + + if err := WriteAppliedState(CategoryPackageConfig, TargetNPM, npmRec("sha256:N", "npm-block")); err != nil { + t.Fatalf("write npm: %v", err) + } + if err := WriteAppliedState(CategoryIDEExtension, TargetVSCode, ideRec("sha256:V", "vscode-value")); err != nil { + t.Fatalf("write ide: %v", err) + } + + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var f AppliedStateFile + if err := json.Unmarshal(raw, &f); err != nil { + t.Fatalf("on-disk file is not an AppliedStateFile: %v (%s)", err, raw) + } + if f.SchemaVersion != CacheSchemaVersion { + t.Fatalf("schema_version = %d, want %d", f.SchemaVersion, CacheSchemaVersion) + } + if got, ok := f.Categories[CategoryPackageConfig].Targets[TargetNPM]; !ok || got.WrittenSettings[NPMOwnedKey] != "npm-block" { + t.Fatalf("categories.%s.targets.%s = %+v ok=%v", CategoryPackageConfig, TargetNPM, got, ok) + } + if got, ok := f.Categories[CategoryIDEExtension].Targets[TargetVSCode]; !ok || got.WrittenSettings[allowedExtensionsSettingKey] != "vscode-value" { + t.Fatalf("categories.%s.targets.%s = %+v ok=%v", CategoryIDEExtension, TargetVSCode, got, ok) + } + // Exactly one JSON state file in the directory — no sibling per-category file. + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + var jsonFiles []string + for _, e := range entries { + if strings.HasSuffix(e.Name(), ".json") { + jsonFiles = append(jsonFiles, e.Name()) + } + } + if len(jsonFiles) != 1 || jsonFiles[0] != CacheFilename { + t.Fatalf("state directory holds %v, want only %s", jsonFiles, CacheFilename) + } + // The file carries a device auth token in the npm record, so it must stay + // owner-only. Windows has no permission bits to assert. + if runtime.GOOS != "windows" { + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != cacheFileMode { + t.Fatalf("state file mode = %o, want %o", info.Mode().Perm(), cacheFileMode) + } + } +} + +func TestWritingOneCategoryPreservesTheOther(t *testing.T) { + // Symmetric preservation: whichever category writes second (and third, on a + // rewrite) must leave the other's record byte-for-byte intact. + cases := []struct { + name string + first, second string + firstTgt, secTgt string + firstRec, secRec AppliedTargetState + firstKey, secKey string + firstVal, secVal string + }{ + { + name: "npm write preserves ide", first: CategoryIDEExtension, firstTgt: TargetVSCode, + second: CategoryPackageConfig, secTgt: TargetNPM, + firstRec: ideRec("sha256:V", "vscode-value"), secRec: npmRec("sha256:N", "npm-block"), + firstKey: allowedExtensionsSettingKey, firstVal: "vscode-value", + secKey: NPMOwnedKey, secVal: "npm-block", + }, + { + name: "ide write preserves npm", first: CategoryPackageConfig, firstTgt: TargetNPM, + second: CategoryIDEExtension, secTgt: TargetVSCode, + firstRec: npmRec("sha256:N", "npm-block"), secRec: ideRec("sha256:V", "vscode-value"), + firstKey: NPMOwnedKey, firstVal: "npm-block", + secKey: allowedExtensionsSettingKey, secVal: "vscode-value", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + restore := SetCachePathForTest(filepath.Join(t.TempDir(), CacheFilename)) + defer restore() + + if err := WriteAppliedState(tc.first, tc.firstTgt, tc.firstRec); err != nil { + t.Fatalf("write %s: %v", tc.first, err) + } + if err := WriteAppliedState(tc.second, tc.secTgt, tc.secRec); err != nil { + t.Fatalf("write %s: %v", tc.second, err) + } + // A rewrite of the second category is the case that would clobber a + // read-modify-write that dropped what it did not know about. + if err := WriteAppliedState(tc.second, tc.secTgt, tc.secRec); err != nil { + t.Fatalf("rewrite %s: %v", tc.second, err) + } + got, ok := ReadAppliedState(tc.first, tc.firstTgt) + if !ok || got.AppliedHash != tc.firstRec.AppliedHash || got.WrittenSettings[tc.firstKey] != tc.firstVal { + t.Fatalf("%s/%s not preserved: got %+v ok=%v", tc.first, tc.firstTgt, got, ok) + } + if got, ok := ReadAppliedState(tc.second, tc.secTgt); !ok || got.WrittenSettings[tc.secKey] != tc.secVal { + t.Fatalf("%s/%s = %+v ok=%v, want its own record", tc.second, tc.secTgt, got, ok) + } + }) + } +} + +func TestClearingOneCategoryLeavesTheOther(t *testing.T) { + // Unassignment of one category is scoped to its own (category, target) — an npm + // offboarding must not revoke the agent's record of the VS Code policy it still + // enforces, and the reverse. + cases := []struct { + name string + clearCat, clrTgt string + keepCat, keepTgt string + keepKey, keepVal string + }{ + {"clear npm", CategoryPackageConfig, TargetNPM, CategoryIDEExtension, TargetVSCode, allowedExtensionsSettingKey, "vscode-value"}, + {"clear ide", CategoryIDEExtension, TargetVSCode, CategoryPackageConfig, TargetNPM, NPMOwnedKey, "npm-block"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), CacheFilename) + restore := SetCachePathForTest(path) + defer restore() + + if err := WriteAppliedState(CategoryPackageConfig, TargetNPM, npmRec("sha256:N", "npm-block")); err != nil { + t.Fatal(err) + } + if err := WriteAppliedState(CategoryIDEExtension, TargetVSCode, ideRec("sha256:V", "vscode-value")); err != nil { + t.Fatal(err) + } + if err := ClearAppliedState(tc.clearCat, tc.clrTgt); err != nil { + t.Fatalf("clear %s: %v", tc.clearCat, err) + } + if _, ok := ReadAppliedState(tc.clearCat, tc.clrTgt); ok { + t.Fatalf("%s/%s must be gone after its clear", tc.clearCat, tc.clrTgt) + } + got, ok := ReadAppliedState(tc.keepCat, tc.keepTgt) + if !ok || got.WrittenSettings[tc.keepKey] != tc.keepVal { + t.Fatalf("%s/%s must survive: got %+v ok=%v", tc.keepCat, tc.keepTgt, got, ok) + } + // The cleared category is dropped from the file (it had one target); the + // surviving one is still there, so the file itself stays. + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var f AppliedStateFile + if err := json.Unmarshal(raw, &f); err != nil { + t.Fatal(err) + } + if _, ok := f.Categories[tc.clearCat]; ok { + t.Fatalf("category %s should be dropped once its last target is cleared: %s", tc.clearCat, raw) + } + if _, ok := f.Categories[tc.keepCat]; !ok { + t.Fatalf("category %s must remain: %s", tc.keepCat, raw) + } + }) + } +} + +func TestClearRemovesCorruptFile(t *testing.T) { + // A corrupt state file can still hold a token-bearing written_settings entry — + // the ~/.npmrc record carries the device auth token. A clear is an unassignment + // or offboarding, so it must not report success while those bytes survive. + // Nothing interpretable is lost: an unparseable file already reads as "owns + // nothing" for every category. + path := filepath.Join(t.TempDir(), CacheFilename) + corrupt := `{ broken "written_settings": "ssabc123::dev:SERIAL-1` // invalid JSON, token-shaped bytes + if err := os.WriteFile(path, []byte(corrupt), 0o600); err != nil { + t.Fatal(err) + } + restore := SetCachePathForTest(path) + defer restore() + + if err := ClearAppliedState(CategoryPackageConfig, TargetNPM); err != nil { + t.Fatalf("clearing a corrupt file must succeed, got %v", err) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("a corrupt state file must be removed by a clear, stat err = %v", err) + } +} + +// --- Unreadable, as opposed to absent or corrupt ----------------------------- +// +// "Could not read it" is not "there is nothing in it". A present-but-unreadable +// state file may hold live ownership records for OTHER categories, and on POSIX +// no read permission still leaves it rewritable and unlinkable through a writable +// parent — so a mutation that treats it as recreatable destroys those records +// sight unseen, and (before the split) reported success while doing it. The two +// realistic sources are a mode/ownership mismatch between a root or +// SYSTEM-context run and a user-context one over the same home, and plain I/O +// failure. + +// seedUnreadableState writes a state file holding an ide_extension record, then +// makes it unreadable. Returns the path and its exact bytes for an +// unchanged-after check. Skips when the platform or the caller's privileges make +// "unreadable" unachievable: Windows os.Chmod only toggles the read-only +// attribute, and root bypasses the permission bits entirely. +func seedUnreadableState(t *testing.T) (path string, before []byte) { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("os.Chmod on Windows cannot revoke read access") + } + path = filepath.Join(t.TempDir(), CacheFilename) + restore := SetCachePathForTest(path) + t.Cleanup(restore) + + if err := WriteAppliedState(CategoryIDEExtension, TargetVSCode, ideRec("sha256:V", "vscode-value")); err != nil { + t.Fatalf("seeding the ide record: %v", err) + } + before, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if err := os.Chmod(path, 0o000); err != nil { + t.Fatalf("chmod 000: %v", err) + } + // Restore a readable mode before TempDir cleanup, and let the assertions read. + t.Cleanup(func() { _ = os.Chmod(path, 0o600) }) + if _, err := os.ReadFile(path); err == nil { + t.Skip("the file is still readable at mode 000 (running as root)") + } + return path, before +} + +// assertStateFileUnchanged re-reads the file (restoring a readable mode first) +// and fails unless its bytes are exactly what was seeded. +func assertStateFileUnchanged(t *testing.T, path string, before []byte) { + t.Helper() + if err := os.Chmod(path, 0o600); err != nil { + t.Fatalf("chmod back: %v", err) + } + after, err := os.ReadFile(path) + if err != nil { + t.Fatalf("the state file is gone or still unreadable: %v", err) + } + if string(after) != string(before) { + t.Fatalf("the state file was rewritten.\nbefore:\n%s\nafter:\n%s", before, after) + } + if got, ok := ReadAppliedState(CategoryIDEExtension, TargetVSCode); !ok || got.WrittenSettings[allowedExtensionsSettingKey] != "vscode-value" { + t.Fatalf("the ide ownership record did not survive: %+v ok=%v", got, ok) + } +} + +func TestWriteRefusesUnreadableFile(t *testing.T) { + // A write for one category must not recreate a file it could not read: doing so + // replaces every sibling category's record with nothing. It must fail instead — + // the reconciler then classifies write_failed rather than reporting a success + // that quietly cost the IDE lane its ownership. + path, before := seedUnreadableState(t) + + err := WriteAppliedState(CategoryPackageConfig, TargetNPM, npmRec("sha256:N", "npm-block")) + if err == nil { + t.Fatal("a write against an unreadable state file must return an error, got nil") + } + if !strings.Contains(err.Error(), CacheFilename) { + t.Errorf("the error should name the file it could not read, got %v", err) + } + assertStateFileUnchanged(t, path, before) +} + +func TestClearRefusesUnreadableFile(t *testing.T) { + // The clear path is the dangerous one: removeStateFile unlinks through the + // parent directory, which succeeds on a file the process cannot read. An + // unassignment of package_config would take the IDE lane's ownership with it and + // still report done. + path, before := seedUnreadableState(t) + + err := ClearAppliedState(CategoryPackageConfig, TargetNPM) + if err == nil { + t.Fatal("a clear against an unreadable state file must return an error, got nil") + } + if _, statErr := os.Stat(path); statErr != nil { + t.Fatalf("the state file must still exist, stat err = %v", statErr) + } + assertStateFileUnchanged(t, path, before) +} + +func TestUnreadableFileStillReadsAsOwnsNothing(t *testing.T) { + // Only the mutating accessors changed. A plain read keeps its no-error contract + // and reports owning nothing, which is the safe direction: the reconciler then + // re-applies the policy and refuses to clear a value it has no record of writing. + seedUnreadableState(t) + + if got, ok := ReadAppliedState(CategoryIDEExtension, TargetVSCode); ok { + t.Fatalf("an unreadable file must read as owning nothing, got %+v", got) + } +} + +func TestConcurrentCategoryWritesLoseNeither(t *testing.T) { + // Two lanes converging at once — an npm cycle and an IDE cycle — must each end + // up recorded. This is the lost-update the read-modify-write locking exists to + // prevent: without it, one lane reads the file, the other writes, and the first + // one's rename drops the second's category. + restore := SetCachePathForTest(filepath.Join(t.TempDir(), CacheFilename)) + defer restore() + + const rounds = 40 + writes := []struct { + cat, tgt string + rec AppliedTargetState + }{ + {CategoryPackageConfig, TargetNPM, npmRec("sha256:N", "npm-block")}, + {CategoryIDEExtension, TargetVSCode, ideRec("sha256:V", "vscode-value")}, + } + errs := make(chan error, len(writes)*rounds) + var wg sync.WaitGroup + for _, w := range writes { + wg.Add(1) + go func(cat, tgt string, rec AppliedTargetState) { + defer wg.Done() + for i := 0; i < rounds; i++ { + if err := WriteAppliedState(cat, tgt, rec); err != nil { + errs <- err + return + } + } + }(w.cat, w.tgt, w.rec) + } + wg.Wait() + close(errs) + for err := range errs { + t.Fatalf("concurrent write failed: %v", err) + } + + if got, ok := ReadAppliedState(CategoryPackageConfig, TargetNPM); !ok || got.WrittenSettings[NPMOwnedKey] != "npm-block" { + t.Fatalf("npm record lost to a concurrent IDE write: %+v ok=%v", got, ok) + } + if got, ok := ReadAppliedState(CategoryIDEExtension, TargetVSCode); !ok || got.WrittenSettings[allowedExtensionsSettingKey] != "vscode-value" { + t.Fatalf("ide record lost to a concurrent npm write: %+v ok=%v", got, ok) + } +} + +func TestStateLockExcludesASecondHolder(t *testing.T) { + // What makes the read-modify-write safe between separate agent PROCESSES. A + // second open file description on the lock path is exactly what another process + // would hold, and it must not be able to take the lock while this one has it. + if !stateLockSupported { + t.Skip("no advisory locking on this platform") + } + restore := SetCachePathForTest(filepath.Join(t.TempDir(), CacheFilename)) + defer restore() + + held := make(chan struct{}) + proceed := make(chan struct{}) + released := make(chan struct{}) + go func() { + defer close(released) + _ = withStateLock(func() error { + close(held) + <-proceed + return nil + }) + }() + <-held + + f, err := os.OpenFile(stateLockPath(), os.O_CREATE|os.O_RDWR, cacheFileMode) + if err != nil { + t.Fatalf("opening the lock file: %v", err) + } + defer func() { _ = f.Close() }() + + ok, err := tryLockHandle(f) + if err != nil { + t.Fatalf("tryLockHandle: %v", err) + } + if ok { + unlockHandle(f) + t.Fatal("a second holder took the lock while it was held") + } + + close(proceed) + <-released + + // Released — the same handle can take it now, so the lock is not leaked. + ok, err = tryLockHandle(f) + if err != nil { + t.Fatalf("tryLockHandle after release: %v", err) + } + if !ok { + t.Fatal("the lock was not released") + } + unlockHandle(f) +} + +// holdStateLockUntilCleanup takes the state lock in a background goroutine and +// keeps it until the test ends, with the wait budget shrunk so the contending +// caller reaches its timeout fast. Returns once the lock is genuinely held. +func holdStateLockUntilCleanup(t *testing.T) { + t.Helper() + if !stateLockSupported { + t.Skip("no advisory locking on this platform") + } + prevWait, prevDelay := stateLockWait, stateLockRetryDelay + stateLockWait, stateLockRetryDelay = 20*time.Millisecond, 5*time.Millisecond + t.Cleanup(func() { stateLockWait, stateLockRetryDelay = prevWait, prevDelay }) + + held := make(chan struct{}) + proceed := make(chan struct{}) + released := make(chan struct{}) + go func() { + defer close(released) + if err := withStateLock(func() error { + close(held) + <-proceed + return nil + }); err != nil { + t.Errorf("the holder itself failed to take the lock: %v", err) + close(held) + } + }() + <-held + t.Cleanup(func() { close(proceed); <-released }) +} + +func TestStateWriteFailsWhenAPeerHoldsTheLock(t *testing.T) { + // FAIL CLOSED. A peer still holding the lock after the whole wait budget is a + // concurrent writer PROVEN to exist, which is the one situation in which + // proceeding can drop another category's ownership record — the invariant the + // single shared file rests on. The write is abandoned instead; the caller + // classifies write_failed, rolls its own file change back and retries next cycle. + // A reported, recoverable failure beats silently losing a sibling's record. + restore := SetCachePathForTest(filepath.Join(t.TempDir(), CacheFilename)) + defer restore() + holdStateLockUntilCleanup(t) + + err := WriteAppliedState(CategoryPackageConfig, TargetNPM, npmRec("sha256:N", "npm-block")) + if err == nil { + t.Fatal("a write must fail while a peer holds the lock, got nil") + } + if !errors.Is(err, errStateLockBusy) { + t.Errorf("error should identify lock contention, got %v", err) + } + // Nothing was written: the read-modify-write never ran. + if got, ok := ReadAppliedState(CategoryPackageConfig, TargetNPM); ok { + t.Fatalf("the refused write still landed: %+v", got) + } +} + +func TestStateClearFailsWhenAPeerHoldsTheLock(t *testing.T) { + // The clear path fails closed for the same reason, and it is the one with teeth: + // a clear that proceeded unlocked could rewrite the file from a snapshot taken + // before a peer's write, resurrecting a record the peer had just dropped or + // dropping one it had just added. + path := filepath.Join(t.TempDir(), CacheFilename) + restore := SetCachePathForTest(path) + defer restore() + if err := WriteAppliedState(CategoryIDEExtension, TargetVSCode, ideRec("sha256:V", "vscode-value")); err != nil { + t.Fatalf("seeding the ide record: %v", err) + } + before, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + holdStateLockUntilCleanup(t) + + if err := ClearAppliedState(CategoryIDEExtension, TargetVSCode); err == nil { + t.Fatal("a clear must fail while a peer holds the lock, got nil") + } else if !errors.Is(err, errStateLockBusy) { + t.Errorf("error should identify lock contention, got %v", err) + } + after, err := os.ReadFile(path) + if err != nil { + t.Fatalf("the state file must survive a refused clear: %v", err) + } + if string(after) != string(before) { + t.Fatalf("a refused clear rewrote the file.\nbefore:\n%s\nafter:\n%s", before, after) + } +} + +func TestStateLockWaitAbsorbsSlowRenames(t *testing.T) { + // The budget is not a latency knob: the critical section is microseconds, so + // anything short enough to be tripped by an antivirus-serialized rename or a slow + // network home would convert routine slowness into a reported enforcement + // failure now that acquisition fails closed. Pinned so a future trim is a + // deliberate act. + if stateLockWait < 10*time.Second { + t.Fatalf("stateLockWait = %s, want at least 10s", stateLockWait) + } +} + +func TestNoCategoryScopedStateFileAnywhereInTheTree(t *testing.T) { + // The dedicated package_config store is gone for good: nothing in the module may + // name or create a per-category state file again. Scanning the source is what + // catches a re-introduction that no behavioral test would see until it shipped. + root := moduleRoot(t) + // Assembled from fragments so this scanner does not match its own source. + needles := []string{"package-config" + "-state", "packageConfigState" + "Basename", "NewStateStore" + "For"} + var hits []string + err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + if name := info.Name(); name == ".git" || name == "vendor" || name == "specs" { + return filepath.SkipDir + } + return nil + } + if !strings.HasSuffix(path, ".go") { + return nil + } + b, rerr := os.ReadFile(path) + if rerr != nil { + return rerr + } + for _, needle := range needles { + if strings.Contains(string(b), needle) { + rel, _ := filepath.Rel(root, path) + hits = append(hits, rel+" contains "+needle) + } + } + return nil + }) + if err != nil { + t.Fatalf("walking %s: %v", root, err) + } + if len(hits) != 0 { + t.Fatalf("category-scoped state must not come back:\n%s", strings.Join(hits, "\n")) + } +} + +// moduleRoot walks up from the package directory to the directory holding go.mod. +func moduleRoot(t *testing.T) string { + t.Helper() + dir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + for { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + return dir + } + parent := filepath.Dir(dir) + if parent == dir { + t.Fatal("no go.mod found above the package directory") + } + dir = parent } } diff --git a/internal/devicepolicy/npmrc.go b/internal/devicepolicy/npmrc.go new file mode 100644 index 0000000..244e50c --- /dev/null +++ b/internal/devicepolicy/npmrc.go @@ -0,0 +1,2190 @@ +package devicepolicy + +import ( + "bytes" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "net/url" + "os" + "os/user" + "path/filepath" + "sort" + "strconv" + "strings" + + "github.com/step-security/dev-machine-guard/internal/executor" +) + +// This file backs the package_config#npm policy category: it converges a +// managed block inside the console user's ~/.npmrc so npm (and the pnpm / yarn +// v1 / bun tools that read the same file) resolves packages through the +// tenant's StepSecurity secure registry. It parallels the VS Code +// settings.json writer (settings_writer.go) but the target is a file the agent +// may run as root against a user-owned tree, so every file operation goes +// through os.Root rather than atomicfile — see the security notes on +// NPMRCWriter below. + +// Ownership markers for the managed block. The BEGIN/END pair delimits exactly +// the bytes the agent owns; nothing outside it is ever rewritten. BEGIN carries +// the "-- managed by dmg" suffix so it is distinguishable from the MDM script's +// own header (which ends "-- managed by mdm") — the two lanes must never claim +// each other's block. +const ( + npmrcBeginMarker = "# BEGIN StepSecurity Secure Registry -- managed by dmg" + npmrcEndMarker = "# END StepSecurity Secure Registry" + // npmrcMDMMarker is the header the published MDM remediation script writes. + // The probe treats its presence (outside our block) as the first signal + // that the MDM lane is managing this file. + npmrcMDMMarker = "# StepSecurity Secure Registry -- managed by mdm" +) + +// NPMOwnedKey is the WrittenSettings key the npm lane records ownership under. +// The managed block is ONE atomic unit (its BEGIN/END markers bound it), so the +// lane owns exactly one entry — value = the rendered block body — where the VS +// Code lane owns one entry per setting id. Wired as Reconciler.OwnershipKey so +// drift, adoption, persistence, and the value-based clear all read the same key. +const NPMOwnedKey = "npmrc" + +// The observed-bag keys and auth verdicts of the MDM verify-only report. They are +// WIRE-PERMANENT: the backend validates exactly these three keys and rejects any +// other (a secret-ingest guard), and maps auth_token_status to a redacted auth +// change. auth_token_status is the ONLY axis decided on-device, because deciding +// it backend-side would mean transmitting a token. +const ( + observedKeyEcosystem = "ecosystem" + observedKeyRegistryURL = "registry_url" + // #nosec G101 -- a JSON field NAME, not a credential; the value it carries is + // one of the three verdicts below and never token material. + observedKeyAuthTokenStatus = "auth_token_status" + + authTokenMatch = "match" + authTokenMismatch = "mismatch" + authTokenAbsent = "absent" +) + +// npmrcMaxRegistryURLBytes caps the observed registry_url before transmission. +// The value is read off a user-writable file and the backend rejects anything +// longer, so an oversize value is refused on-device rather than sent to fail +// there. +const npmrcMaxRegistryURLBytes = 2048 + +// npmrcDMGPrefix is prepended to a user's active bare `registry=` line when the +// managed block is applied, so the original survives (commented) and can be +// restored on clear. It is deliberately distinct from the MDM script's +// `# [stepsecurity] ` prefix: each lane only ever un-comments its own prefix, +// so they cannot resurrect each other's work. +const ( + npmrcDMGPrefix = "# [stepsecurity-dmg] " + npmrcMDMPrefix = "# [stepsecurity] " +) + +const ( + // npmrcMaxBytes caps the file the writer will read, snapshot, back up, or + // transform. A pathological multi-megabyte .npmrc must not balloon memory + // or the backup set; exceeding it is a structural refusal, not a transform. + npmrcMaxBytes = 1 << 20 + // npmrcMaxRenderedBytes caps the two rendered content lines. Anything past + // this is a malformed policy, not a block to write. + npmrcMaxRenderedBytes = 4 << 10 + // npmrcMaxKeyBytes / npmrcMaxSerialBytes bound the two variable-length + // fields the renderer accepts. + npmrcMaxKeyBytes = 256 + npmrcMaxSerialBytes = 128 + // npmrcMaxHostBytes is the RFC 1123 hostname length ceiling. + npmrcMaxHostBytes = 253 + // npmrcMaxSymlinkDepth bounds the .npmrc symlink chain the resolver will + // follow before declaring a loop. + npmrcMaxSymlinkDepth = 8 + // npmrcMaxBackups is the retained backup count beside the resolved leaf. + npmrcMaxBackups = 3 + // npmrcFileMode is the mode every file the writer creates or rewrites lands + // with. A token-bearing file is never group/other-readable. + npmrcFileMode os.FileMode = 0o600 +) + +// Structural errors. Every "this target cannot be enforced" condition wraps +// ErrTargetUnusable so the reconciler can classify the whole class as +// write_failed regardless of whether a read or a write surfaced it, while a +// plain permission-denied / transient I/O error (which does not wrap it) stays +// verification_failed. ErrNoTargetUser is separate: it means there is no +// enforceable user on this machine state (LocalSystem, a non-interactive +// Windows session, or root with no resolvable GUI user), which the reconciler +// reports as policy_not_applied, not write_failed. +var ( + ErrTargetUnusable = errors.New("npmrc: target unusable") + ErrNoTargetUser = errors.New("npmrc: no enforceable target user") + ErrAbsoluteSymlink = fmt.Errorf("npmrc: .npmrc is an absolute symlink: %w", ErrTargetUnusable) + ErrSymlinkLoop = fmt.Errorf("npmrc: .npmrc symlink chain too deep: %w", ErrTargetUnusable) + ErrDanglingSymlink = fmt.Errorf("npmrc: .npmrc symlink target does not exist: %w", ErrTargetUnusable) +) + +// ErrWriteUnverified means a mutating op landed new bytes it could then neither +// verify NOR roll back — the post-rename identity re-check failed and the restore +// to the pre-state also failed. On-disk state is therefore indeterminate (not the +// clean "write failed, disk untouched" case), which the reconciler classifies as +// verification_failed rather than write_failed. It deliberately does NOT wrap +// ErrTargetUnusable, so the write-path classifier routes it to verification_failed. +var ErrWriteUnverified = errors.New("npmrc: write could not be verified or rolled back") + +// NPMRCWriter converges the managed block in one user's ~/.npmrc. It satisfies +// the Writer interface (Read/Write/Clear/Location) and adds the concrete-type +// methods the reconciler seams need — Converged, ProbeExpected, RestoreSnapshot +// — none of which fit the settings.json-shaped interface. +// +// Threat model: the agent can run as root (macOS LaunchDaemon) against a home +// directory the target user controls. A user who can plant symlinks or swap +// directory entries mid-operation must not be able to steer a root-owned write, +// a root read, or a token-bearing backup outside their own regular .npmrc. The +// writer therefore anchors every operation to a directory file descriptor via +// os.Root, resolves the .npmrc symlink chain explicitly, pins the resolved +// parent with a second os.Root, re-verifies file identity after every open, and +// performs all metadata changes on open handles (never by path). It never uses +// atomicfile, whose predictable, symlink-following backup names are exactly the +// attack this design closes. +type NPMRCWriter struct { + exec executor.Executor + targetUser *user.User + home string + uid, gid int // parsed from targetUser; only meaningful where enforcePOSIXMetadata + + root *os.Root // directory fd over the target home, held for the writer's lifetime + owners ownerReader + logf func(format string, args ...any) + + // pending is the memory-only snapshot captured at the start of the last + // mutating op (Write/Clear) and retained on success so a later + // RestoreSnapshot can undo it. It is never persisted. + pending *pendingSnapshot +} + +// pendingSnapshot is the pre-mutation state one Write or Clear can roll back to. +type pendingSnapshot struct { + existed bool + data []byte + mode os.FileMode + // leaf is the resolved leaf path relative to the home root at capture time. + // RestoreSnapshot re-resolves and refuses to write if the chain now points + // somewhere else. + leaf string + // committed is the identity (post-rename FileInfo) of the file this writer + // last left at the leaf. RestoreSnapshot requires the on-disk leaf to still be + // SameFile as this before reverting: a relative path can be unchanged while the + // parent directory or the leaf inode was swapped underneath it, and reverting + // into that would write stale bytes into someone else's file. + committed os.FileInfo +} + +// ownerReader reads the uid/gid owning an open file. enforcePOSIXMetadata +// platforms return the real owner; elsewhere (Windows, ACL model) enforced is +// false and the caller skips every ownership decision. Tests inject a fake to +// exercise wrong-owner branches without root. +type ownerReader interface { + ownerUIDGID(f *os.File) (uid, gid uint32, enforced bool, err error) +} + +// NewNPMRCWriter resolves the console user and opens a directory fd over their +// home. It returns ErrNoTargetUser when this machine state has no enforceable +// user (Windows LocalSystem or a non-interactive session, root with no GUI +// user); any other error is an infrastructure failure (home unresolvable or +// unopenable). The caller defers Close to release the directory fd. +func NewNPMRCWriter(exec executor.Executor) (*NPMRCWriter, error) { + // On Windows, a write from any identity that is not the interactive user of + // an active session would silently land in the wrong profile + // (service/RMM account, session 0, runas alternate credentials). Fail + // closed to no-target rather than enforce against the wrong .npmrc. + if !interactiveSessionOK() { + return nil, ErrNoTargetUser + } + + u, err := exec.LoggedInUser() + if err != nil { + // The only error LoggedInUser returns is the darwin-root "no GUI console + // user" case; treat the absence of a resolvable user as no-target. + return nil, fmt.Errorf("%w: %v", ErrNoTargetUser, err) + } + if u == nil || u.HomeDir == "" { + return nil, fmt.Errorf("npmrc: resolved user has no home directory") + } + + root, err := os.OpenRoot(u.HomeDir) + if err != nil { + return nil, fmt.Errorf("npmrc: open home root %q: %w", u.HomeDir, err) + } + + w := &NPMRCWriter{ + exec: exec, + targetUser: u, + home: u.HomeDir, + root: root, + owners: newOwnerReader(), + } + if enforcePOSIXMetadata { + // Uid/Gid are numeric on POSIX. A parse failure means we cannot chown to + // the target user, which defeats the whole point of resolving them. + uid, uerr := strconv.Atoi(u.Uid) + gid, gerr := strconv.Atoi(u.Gid) + if uerr != nil || gerr != nil { + _ = root.Close() + return nil, fmt.Errorf("npmrc: target user %q has non-numeric uid/gid", u.Username) + } + w.uid, w.gid = uid, gid + } + return w, nil +} + +// Close releases the home directory fd. Safe to call more than once. +func (w *NPMRCWriter) Close() error { + if w == nil || w.root == nil { + return nil + } + err := w.root.Close() + w.root = nil + w.pending = nil + return err +} + +// Location is a human-readable target description for logs. It never includes +// file contents or key material. +func (w *NPMRCWriter) Location() string { + return filepath.Join(w.home, ".npmrc") + " [npm secure registry]" +} + +// SetLogf installs an optional diagnostic sink for non-fatal events (a missing +// END marker stripped to EOF, a backup-rotation prune failure, a snapshot +// restore that aborted). It is never handed file contents or key material. +func (w *NPMRCWriter) SetLogf(logf func(format string, args ...any)) { w.logf = logf } + +func (w *NPMRCWriter) log(format string, args ...any) { + if w.logf != nil { + w.logf(format, args...) + } +} + +// --------------------------------------------------------------------------- +// Symlink-chain resolution +// --------------------------------------------------------------------------- + +// resolvedTarget is the outcome of walking the ~/.npmrc symlink chain: a child +// os.Root pinned at the resolved leaf's real parent directory plus the leaf's +// basename within it. Every subsequent operation uses (child, base) so a swap +// of an ancestor directory after resolution cannot redirect the open or rename +// — a directory fd references the original directory even if it is later moved. +type resolvedTarget struct { + child *os.Root // caller closes + base string // leaf basename within child + rel string // leaf path relative to the home root (parentDir/base) + viaSymlink bool // the leaf was reached by following at least one link + existed bool // the leaf exists (Lstat succeeded) +} + +func (rt *resolvedTarget) close() { + if rt != nil && rt.child != nil { + _ = rt.child.Close() + } +} + +// resolveLeaf walks the .npmrc chain relative to the home root and pins the +// resolved parent. It rejects, before any file open: +// - an absolute symlink target (ErrAbsoluteSymlink) — even one pointing back +// inside the home; enforcing through an absolute link is out of scope; +// - a raw target ending in a path separator or "/." (ErrTargetUnusable), +// checked BEFORE cleaning: `.npmrc -> "file/"` fails kernel resolution with +// ENOTDIR when `file` is not a directory, so npm never reads it; cleaning +// would erase that evidence and let a bogus write report success; +// - a chain deeper than npmrcMaxSymlinkDepth (ErrSymlinkLoop); +// - a dangling target (ErrDanglingSymlink); +// - a chain escaping the home (os.Root refuses; surfaces as ErrTargetUnusable). +func (w *NPMRCWriter) resolveLeaf() (*resolvedTarget, error) { + cur := ".npmrc" + viaSymlink := false + + for depth := 0; ; depth++ { + if depth > npmrcMaxSymlinkDepth { + return nil, ErrSymlinkLoop + } + fi, err := w.root.Lstat(cur) + if errors.Is(err, os.ErrNotExist) { + if viaSymlink { + // A link resolved to a name that does not exist. + return nil, ErrDanglingSymlink + } + // The plain .npmrc simply does not exist yet. + return w.pin(cur, false, false) + } + if err != nil { + return nil, fmt.Errorf("npmrc: lstat %q: %w", cur, err) + } + if fi.Mode()&fs.ModeSymlink == 0 { + // Regular (or other) leaf reached. + return w.pin(cur, viaSymlink, true) + } + + target, err := w.root.Readlink(cur) + if err != nil { + return nil, fmt.Errorf("npmrc: readlink %q: %w", cur, err) + } + if isAbsSymlinkTarget(target) { + return nil, ErrAbsoluteSymlink + } + if endsInSeparatorOrDot(target) { + return nil, fmt.Errorf("npmrc: symlink target %q is directory-shaped: %w", target, ErrTargetUnusable) + } + next := filepath.Clean(filepath.Join(filepath.Dir(cur), target)) + if next == ".." || strings.HasPrefix(next, ".."+string(filepath.Separator)) { + return nil, fmt.Errorf("npmrc: symlink escapes home: %w", ErrTargetUnusable) + } + cur = next + viaSymlink = true + } +} + +// pin opens a child os.Root at the resolved leaf's parent directory so every +// later op is anchored to that directory fd. +func (w *NPMRCWriter) pin(rel string, viaSymlink, existed bool) (*resolvedTarget, error) { + parent := filepath.Dir(rel) + base := filepath.Base(rel) + if base == "." || base == ".." || strings.ContainsRune(base, filepath.Separator) { + return nil, fmt.Errorf("npmrc: resolved leaf %q is not a basename: %w", rel, ErrTargetUnusable) + } + child, err := w.root.OpenRoot(parent) + if err != nil { + if errors.Is(err, os.ErrPermission) { + // The parent exists but is unreadable (permissions / transient). That is + // not a structural refusal: surface it as a plain error so the reconciler + // classifies it verification_failed and retries, rather than the + // write_failed reserved for a target that can never be enforced. + return nil, fmt.Errorf("npmrc: pin parent %q: %w", parent, err) + } + // A parent that is itself a symlink escaping the home, or a non-directory + // component, lands here: structurally unenforceable. + return nil, fmt.Errorf("npmrc: pin parent %q: %w", parent, ErrTargetUnusable) + } + return &resolvedTarget{child: child, base: base, rel: rel, viaSymlink: viaSymlink, existed: existed}, nil +} + +// isAbsSymlinkTarget reports whether a raw link target is absolute. filepath.IsAbs +// covers POSIX "/..." and Windows drive/UNC forms; a leading separator is caught +// explicitly so a POSIX target evaluated on any host is still rejected. +func isAbsSymlinkTarget(target string) bool { + if target == "" { + return false + } + if target[0] == '/' || target[0] == filepath.Separator { + return true + } + return filepath.IsAbs(target) +} + +// endsInSeparatorOrDot reports whether a raw (uncleaned) symlink target is +// directory-shaped — ending in a separator or in "/." — the GO-2026-4970 +// trigger the resolver refuses before filepath.Clean can erase the evidence. +func endsInSeparatorOrDot(target string) bool { + if target == "" { + return false + } + last := target[len(target)-1] + if last == '/' || last == filepath.Separator { + return true + } + if target == "." || strings.HasSuffix(target, "/.") { + return true + } + if filepath.Separator != '/' && strings.HasSuffix(target, string(filepath.Separator)+".") { + return true + } + return false +} + +// --------------------------------------------------------------------------- +// Bounded, identity-checked reads +// --------------------------------------------------------------------------- + +// readCurrent opens the resolved leaf and returns its bytes, existence, and +// mode. It enforces the full open-identity discipline: a Lstat pre-screen that +// fast-fails an obvious FIFO/device, an O_NONBLOCK open so a FIFO cannot block +// the daemon, a post-open regular-file check, a re-Lstat + SameFile identity +// check to close the resolve→open swap race, an ownership rule (the resolved +// leaf must be owned by the target user — root-owned included is refused, since +// this writer always chowns its own output to that user and so never leaves a +// root-owned leaf behind), and a size cap. An absent file returns +// (nil, false, 0, nil). +func (w *NPMRCWriter) readCurrent(rt *resolvedTarget) ([]byte, bool, os.FileMode, error) { + li, err := rt.child.Lstat(rt.base) + if errors.Is(err, os.ErrNotExist) { + return nil, false, 0, nil + } + if err != nil { + return nil, false, 0, fmt.Errorf("npmrc: lstat leaf %q: %w", rt.base, err) + } + if li.Mode()&fs.ModeSymlink != 0 { + // The chain was resolved to a regular leaf; a symlink here means the + // entry was swapped after resolution. + return nil, false, 0, fmt.Errorf("npmrc: leaf %q became a symlink: %w", rt.base, ErrTargetUnusable) + } + if !li.Mode().IsRegular() { + return nil, false, 0, fmt.Errorf("npmrc: leaf %q is not a regular file: %w", rt.base, ErrTargetUnusable) + } + + f, err := rt.child.OpenFile(rt.base, os.O_RDONLY|nonblockOpenFlag(), 0) + if err != nil { + return nil, false, 0, fmt.Errorf("npmrc: open leaf %q: %w", rt.base, err) + } + defer f.Close() + + hi, err := f.Stat() + if err != nil { + return nil, false, 0, fmt.Errorf("npmrc: stat leaf handle: %w", err) + } + if !hi.Mode().IsRegular() { + return nil, false, 0, fmt.Errorf("npmrc: opened leaf %q is not a regular file: %w", rt.base, ErrTargetUnusable) + } + + // Re-Lstat through the pinned child and require the same inode: an in-root + // symlink swapped in between the pre-screen and the open would have been + // followed by os.Root to another file, which this rejects. + li2, err := rt.child.Lstat(rt.base) + if err != nil { + return nil, false, 0, fmt.Errorf("npmrc: re-lstat leaf %q: %w", rt.base, err) + } + if li2.Mode()&fs.ModeSymlink != 0 || !li2.Mode().IsRegular() || !os.SameFile(li2, hi) { + return nil, false, 0, fmt.Errorf("npmrc: leaf %q changed during open: %w", rt.base, ErrTargetUnusable) + } + + if err := w.checkOwner(f, rt); err != nil { + return nil, false, 0, err + } + + data, err := io.ReadAll(io.LimitReader(f, npmrcMaxBytes+1)) + if err != nil { + return nil, false, 0, fmt.Errorf("npmrc: read leaf %q: %w", rt.base, err) + } + if len(data) > npmrcMaxBytes { + return nil, false, 0, fmt.Errorf("npmrc: leaf %q exceeds %d bytes: %w", rt.base, npmrcMaxBytes, ErrTargetUnusable) + } + return data, true, hi.Mode().Perm(), nil +} + +// checkOwner enforces the ownership rule for an existing target: on POSIX the +// resolved leaf must be owned by the target user, full stop. A leaf owned by +// anyone else — root included — is refused. A user could otherwise point .npmrc +// at a root-owned file in their home and have the daemon read it, copy its bytes +// into a user-readable backup, and rewrite it user-owned, disclosing and mutating +// a file they could not otherwise touch. And since this writer always chowns its +// own output to the target user (applyMetadata), a root-owned leaf is never one +// it left behind, so there is nothing legitimate to tolerate. On Windows +// (enforced=false) ownership is governed by ACLs and this check is skipped. +func (w *NPMRCWriter) checkOwner(f *os.File, rt *resolvedTarget) error { + uid, _, enforced, err := w.owners.ownerUIDGID(f) + if err != nil { + return fmt.Errorf("npmrc: read owner: %w", err) + } + if !enforced { + return nil + } + if uid == uint32(w.uid) { // #nosec G115 -- w.uid is strconv.Atoi of a POSIX uid (os/user), always non-negative and within uint32 + return nil + } + return fmt.Errorf("npmrc: leaf %q owned by uid %d, not target user: %w", rt.base, uid, ErrTargetUnusable) +} + +// Read returns the managed block body (canonicalized) and whether it is +// present. It satisfies the Writer interface; the reconciler uses Converged +// (not this) for the real idempotency decision. +func (w *NPMRCWriter) Read() (string, bool, error) { + rt, err := w.resolveLeaf() + if err != nil { + return "", false, err + } + defer rt.close() + + data, existed, _, err := w.readCurrent(rt) + if err != nil { + return "", false, err + } + if !existed { + return "", false, nil + } + body, present := extractManagedBody(string(data)) + return body, present, nil +} + +// --------------------------------------------------------------------------- +// Write / Clear (the §3 rewrite and clear algorithms) +// --------------------------------------------------------------------------- + +// Write applies the rewrite transform for the given rendered block body and +// returns the block body read back from disk. The op is transactional: it +// snapshots the pre-state first, and any failure after the rename self-restores +// before returning. On success the snapshot is retained for a later +// RestoreSnapshot. +func (w *NPMRCWriter) Write(value string) (string, error) { + rt, err := w.resolveLeaf() + if err != nil { + return "", err + } + defer rt.close() + + cur, existed, mode, err := w.readCurrent(rt) + if err != nil { + return "", err + } + if !existed { + mode = npmrcFileMode + } + + next, err := w.rewriteContent(cur, value) + if err != nil { + return "", err + } + + snap := &pendingSnapshot{existed: existed, data: cur, mode: mode, leaf: rt.rel} + if existed { + // Preserve the pre-rewrite file before overwriting it. Best-effort: + // backup failure must not block enforcement. + if err := w.backup(rt, cur); err != nil { + w.log("npmrc: backup of %q failed: %v", rt.base, err) + } + } + out, err := w.commit(rt, next, npmrcFileMode) + if err != nil { + if out.renamed { + // New bytes landed but their identity could not be confirmed. Revert to + // the pre-state so an unverified write is never left behind; if that + // revert also fails, disk is indeterminate (ErrWriteUnverified). + return "", w.afterFailedRollback(rt, snap, err, "commit verification") + } + return "", err + } + snap.committed = out.committed + + body, err := w.readbackBody(rt) + if err != nil { + // The write landed but readback failed — undo it so disk is not left in an + // unverified state (and flag an indeterminate disk if the undo fails too). + return "", w.afterFailedRollback(rt, snap, err, "readback") + } + w.pending = snap + return body, nil +} + +// Clear removes the managed block and restores the user's commented-out +// `registry=` lines. It carries the same transactional and metadata guarantees +// as Write and never deletes the file. The returned bool reports whether the +// file actually changed; the two no-op paths below return false. +func (w *NPMRCWriter) Clear() (bool, error) { + rt, err := w.resolveLeaf() + if err != nil { + return false, err + } + defer rt.close() + + cur, existed, mode, err := w.readCurrent(rt) + if err != nil { + return false, err + } + if !existed { + // Nothing to clear; leave the (absent) file alone. A backup from an earlier + // cycle can still hold a managed block, and with the live file gone this is + // the only path that ever reaches it — so retry the purge here. + w.purgeBackups(rt) + return false, nil + } + + next, err := w.clearContent(cur) + if err != nil { + return false, err + } + if bytes.Equal(next, cur) { + // No managed block and no prefixed lines — a no-op that performs no write at + // all. Nothing of ours is live, so a backup beside the leaf is residue: either + // an earlier purge that could not unlink it, or a block someone removed by + // hand. Retry the purge; the clear itself stays a no-op. + w.purgeBackups(rt) + return false, nil + } + + snap := &pendingSnapshot{existed: true, data: cur, mode: mode, leaf: rt.rel} + if err := w.backup(rt, cur); err != nil { + w.log("npmrc: backup of %q failed: %v", rt.base, err) + } + out, err := w.commit(rt, next, npmrcFileMode) + if err != nil { + if out.renamed { + // The cleared bytes landed but unverified — revert to the pre-clear state + // rather than leave an unverified file; a failed revert leaves disk + // indeterminate (ErrWriteUnverified). The backup stays as a recovery aid. + return false, w.afterFailedRollback(rt, snap, err, "clear commit verification") + } + return false, err + } + snap.committed = out.committed + w.pending = snap + // The clear succeeded, so every backup beside the leaf is now stale — and the + // one taken moments ago holds the very token this clear exists to revoke. + // Removing the managed block while leaving a readable copy of it in a sibling + // would defeat offboarding, so drop our own backups here. Rollback is + // unaffected: RestoreSnapshot reverts from snap's in-memory bytes. + w.purgeBackups(rt) + return true, nil +} + +// RestoreSnapshot reverts the last successful Write/Clear. It re-resolves the +// chain and refuses to write if the leaf now differs from the snapshot's — either +// by relative path (the user re-pointed .npmrc) or by identity (the parent or +// leaf inode was swapped under an unchanged path) — surfacing that as an error the +// reconciler maps to verification_failed. The snapshot is CONSUMED: a restore is +// attempted at most once, so a second call cannot re-run against a stale leaf. +// Calling it with no pending snapshot is an error. +func (w *NPMRCWriter) RestoreSnapshot() error { + if w.pending == nil { + return errors.New("npmrc: no snapshot to restore") + } + snap := w.pending + w.pending = nil + + rt, err := w.resolveLeaf() + if err != nil { + return err + } + defer rt.close() + if rt.rel != snap.leaf { + return fmt.Errorf("npmrc: chain moved from %q to %q; refusing to restore: %w", snap.leaf, rt.rel, ErrTargetUnusable) + } + if err := w.verifyCommitted(rt, snap); err != nil { + return err + } + return w.restoreFrom(rt, snap) +} + +// verifyCommitted confirms the leaf on disk is still the exact file this writer +// committed (SameFile against the snapshot's recorded identity) before a restore +// touches it. A relative path can be unchanged while the parent directory or the +// leaf has been swapped underneath it; reverting into that would write stale bytes +// into someone else's file. An identity mismatch wraps ErrTargetUnusable. +func (w *NPMRCWriter) verifyCommitted(rt *resolvedTarget, snap *pendingSnapshot) error { + if snap.committed == nil { + return nil + } + li, err := rt.child.Lstat(rt.base) + if errors.Is(err, os.ErrNotExist) { + if !snap.existed { + // We had created the file and it is already gone — the pre-state is + // "absent", so there is nothing to revert. + return nil + } + return fmt.Errorf("npmrc: committed leaf %q vanished before restore: %w", rt.base, ErrTargetUnusable) + } + if err != nil { + return fmt.Errorf("npmrc: lstat leaf before restore: %w", err) + } + if li.Mode()&fs.ModeSymlink != 0 || !li.Mode().IsRegular() || !os.SameFile(li, snap.committed) { + return fmt.Errorf("npmrc: leaf %q changed since it was written; refusing to restore: %w", rt.base, ErrTargetUnusable) + } + return nil +} + +// restoreFrom writes a snapshot's bytes/existence/mode at an already-resolved +// target. Ownership is not snapshotted: on restore, as on write, the file is +// chowned to the target user (the file should be target-user-owned, and an +// arbitrary prior owner cannot be expressed portably anyway). +func (w *NPMRCWriter) restoreFrom(rt *resolvedTarget, snap *pendingSnapshot) error { + if !snap.existed { + // The pre-state was "no file": remove what we created. + if err := rt.child.Remove(rt.base); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("npmrc: restore-remove %q: %w", rt.base, err) + } + return nil + } + _, err := w.commit(rt, snap.data, snap.mode) + return err +} + +// afterFailedRollback runs the pre-state restore after a post-rename failure and +// returns the error to surface. If the restore itself fails, on-disk state is +// indeterminate — new bytes landed and could not be reverted — so the returned +// error wraps ErrWriteUnverified (the reconciler reports verification_failed). If +// the restore succeeds, disk is back to the pre-state and the original cause is +// surfaced unchanged (a clean write_failed). +func (w *NPMRCWriter) afterFailedRollback(rt *resolvedTarget, snap *pendingSnapshot, cause error, stage string) error { + if rerr := w.restoreFrom(rt, snap); rerr != nil { + w.log("npmrc: restore after %s aborted: %v", stage, rerr) + return fmt.Errorf("npmrc: %s failed and rollback could not be verified (%v): %w", stage, cause, ErrWriteUnverified) + } + return cause +} + +// commitOutcome reports what a commit did to disk so a caller can react to a +// partial failure. renamed is true once the temp has been renamed into place — +// even if the post-rename identity re-check then failed, meaning new bytes are on +// disk but unverified. committed is the verified leaf identity on full success +// (nil on any error). +type commitOutcome struct { + committed os.FileInfo + renamed bool +} + +// commit writes data to a fresh O_CREATE|O_EXCL temp beside the leaf, sets mode +// and owner on the handle before the rename, renames it into place, then +// re-verifies identity through the pinned child. On Windows the rename is +// best-effort replace semantics rather than a POSIX atomic swap. The returned +// commitOutcome lets Write/Clear tell "failed before the rename, disk untouched" +// apart from "renamed then failed to verify, disk changed" so they can restore. +func (w *NPMRCWriter) commit(rt *resolvedTarget, data []byte, mode os.FileMode) (commitOutcome, error) { + tmp, tmpName, err := w.createExclusive(rt, rt.base+".dmg-tmp-", "") + if err != nil { + return commitOutcome{}, err + } + cleanupTmp := true + defer func() { + if cleanupTmp { + _ = rt.child.Remove(tmpName) + } + }() + + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return commitOutcome{}, fmt.Errorf("npmrc: write temp: %w", err) + } + if err := w.applyMetadata(tmp, mode); err != nil { + _ = tmp.Close() + return commitOutcome{}, err + } + if err := tmp.Sync(); err != nil { + _ = tmp.Close() + return commitOutcome{}, fmt.Errorf("npmrc: fsync temp: %w", err) + } + tmpInfo, err := tmp.Stat() + if err != nil { + _ = tmp.Close() + return commitOutcome{}, fmt.Errorf("npmrc: stat temp: %w", err) + } + if err := tmp.Close(); err != nil { + return commitOutcome{}, fmt.Errorf("npmrc: close temp: %w", err) + } + + if err := rt.child.Rename(tmpName, rt.base); err != nil { + return commitOutcome{}, fmt.Errorf("npmrc: rename into place: %w", err) + } + cleanupTmp = false // the temp name no longer exists after a successful rename + + // Re-verify the just-written leaf is the file we renamed. From here the rename + // has landed, so a failure reports renamed=true: the caller must restore, not + // assume disk is untouched. + li, err := rt.child.Lstat(rt.base) + if err != nil { + return commitOutcome{renamed: true}, fmt.Errorf("npmrc: re-lstat after rename: %w", err) + } + if li.Mode()&fs.ModeSymlink != 0 || !li.Mode().IsRegular() || !os.SameFile(li, tmpInfo) { + return commitOutcome{renamed: true}, fmt.Errorf("npmrc: leaf identity changed across rename: %w", ErrTargetUnusable) + } + w.syncDir(rt) + return commitOutcome{committed: li, renamed: true}, nil +} + +// createExclusive opens a uniquely named file beside the leaf with +// O_CREATE|O_EXCL — the one open mode os.Root never resolves through a symlink, +// which is what makes a pre-planted file at the name harmless. The random middle +// keeps the name unpredictable; prefix and suffix let callers distinguish temp +// files (`.dmg-tmp-`) from committed backups (`.dmg-.bak`). +func (w *NPMRCWriter) createExclusive(rt *resolvedTarget, prefix, suffix string) (*os.File, string, error) { + for attempt := 0; attempt < 8; attempt++ { + mid, err := randomSuffix() + if err != nil { + return nil, "", fmt.Errorf("npmrc: random suffix: %w", err) + } + name := prefix + mid + suffix + f, err := rt.child.OpenFile(name, os.O_CREATE|os.O_EXCL|os.O_WRONLY, npmrcFileMode) + if errors.Is(err, os.ErrExist) { + continue + } + if err != nil { + return nil, "", fmt.Errorf("npmrc: create %q: %w", name, err) + } + return f, name, nil + } + return nil, "", errors.New("npmrc: could not create a unique temp file") +} + +// applyMetadata sets mode and owner on an open handle. Both are POSIX-only: +// Windows inherits ACLs and asserts no POSIX mode, so this is a no-op there. +func (w *NPMRCWriter) applyMetadata(f *os.File, mode os.FileMode) error { + if !enforcePOSIXMetadata { + return nil + } + if err := f.Chmod(mode); err != nil { + return fmt.Errorf("npmrc: fchmod: %w", err) + } + if err := chownHandle(f, w.uid, w.gid); err != nil { + return fmt.Errorf("npmrc: fchown: %w", err) + } + return nil +} + +// syncDir best-effort fsyncs the resolved parent directory so the rename is +// durable. A failure here never fails the write. +func (w *NPMRCWriter) syncDir(rt *resolvedTarget) { + d, err := rt.child.Open(".") + if err != nil { + return + } + _ = d.Sync() + _ = d.Close() +} + +// readbackBody re-reads the leaf and extracts the managed block body. +func (w *NPMRCWriter) readbackBody(rt *resolvedTarget) (string, error) { + data, existed, _, err := w.readCurrent(rt) + if err != nil { + return "", err + } + if !existed { + return "", nil + } + body, _ := extractManagedBody(string(data)) + return body, nil +} + +// --------------------------------------------------------------------------- +// Backups +// --------------------------------------------------------------------------- + +// backup copies the pre-rewrite bytes into a uniquely named, 0600, +// target-user-owned sibling and prunes the set to the newest npmrcMaxBackups. +// The first backup is the pre-policy file (no token); every later one is +// token-bearing, so it carries the same 0600/ownership as the live file. The +// prune is best-effort — a transient extra backup is the same exposure class as +// the file itself and is not worth failing enforcement over. +func (w *NPMRCWriter) backup(rt *resolvedTarget, data []byte) error { + f, name, err := w.createExclusive(rt, rt.base+".dmg-", ".bak") + if err != nil { + return err + } + if _, err := f.Write(data); err != nil { + _ = f.Close() + _ = rt.child.Remove(name) + return fmt.Errorf("npmrc: write backup: %w", err) + } + if err := w.applyMetadata(f, npmrcFileMode); err != nil { + _ = f.Close() + _ = rt.child.Remove(name) + return err + } + if err := f.Close(); err != nil { + return fmt.Errorf("npmrc: close backup: %w", err) + } + w.rotateBackups(rt) + return nil +} + +// rotateBackups prunes backups beside the leaf down to the newest npmrcMaxBackups, +// matching basenames only. Committed backups are ".dmg-.bak"; in-flight +// temp files (".dmg-tmp-") carry no ".bak" suffix and are excluded. +// +// The directory is read in bounded batches and pruning happens as candidates are +// seen, so a home stuffed with millions of pattern-matching entries cannot force +// the whole listing into memory: at most one batch plus npmrcMaxBackups names are +// ever held. Only already-returned entries are removed mid-iteration (the current +// candidate or one kept from an earlier batch), which is safe for directory +// enumeration. +func (w *NPMRCWriter) rotateBackups(rt *resolvedTarget) { + d, err := rt.child.Open(".") + if err != nil { + w.log("npmrc: backup rotation open dir failed: %v", err) + return + } + defer d.Close() + + prefix := rt.base + ".dmg-" + tmpPrefix := rt.base + ".dmg-tmp-" + + type backupFile struct { + name string + mtime int64 + } + kept := make([]backupFile, 0, npmrcMaxBackups) // ascending mtime; kept[0] is oldest + remove := func(name string) { + if err := rt.child.Remove(name); err != nil { + w.log("npmrc: prune backup %q failed: %v", name, err) + } + } + insert := func(bf backupFile) { + i := sort.Search(len(kept), func(i int) bool { return kept[i].mtime > bf.mtime }) + kept = append(kept, backupFile{}) + copy(kept[i+1:], kept[i:]) + kept[i] = bf + } + + for { + entries, rerr := d.ReadDir(256) + for _, e := range entries { + name := e.Name() + if name == "" || strings.ContainsRune(name, filepath.Separator) { + continue + } + if !strings.HasPrefix(name, prefix) || !strings.HasSuffix(name, ".bak") { + continue + } + if strings.HasPrefix(name, tmpPrefix) { + continue + } + li, lerr := rt.child.Lstat(name) + if lerr != nil || !li.Mode().IsRegular() { + continue + } + bf := backupFile{name: name, mtime: li.ModTime().UnixNano()} + if len(kept) < npmrcMaxBackups { + insert(bf) + continue + } + if bf.mtime <= kept[0].mtime { + remove(bf.name) // older than everything kept + continue + } + remove(kept[0].name) // evict the oldest kept, then keep this newer one + copy(kept, kept[1:]) + kept = kept[:len(kept)-1] + insert(bf) + } + if rerr == io.EOF { + break + } + if rerr != nil { + w.log("npmrc: backup rotation readdir failed: %v", rerr) + break + } + } +} + +// purgeBackups removes every committed backup beside the leaf. It runs on every +// Clear that leaves nothing of ours live: the managed block is gone from the live +// file, so a sibling still holding a copy of it would keep the revoked token +// readable and defeat offboarding. Only files WE created are touched — the +// ".dmg-.bak" shape rotateBackups maintains — and in-flight temp files +// are left to their own owners. +// +// Failures are logged, never returned, for two reasons. By the time we get here the +// revocation itself has already happened, so reporting a failed unlink as a failed +// Clear would call a completed revocation failed; and because the reconciler then +// re-clears a file that has no block left, every later pass would take the no-op +// path and fail again — a permanent synthetic failure over a sibling file. The no-op +// paths call this instead, so an unlink that lost a race with a reader (a mapped or +// open .bak on Windows) is retried on the next unassignment cycle. +func (w *NPMRCWriter) purgeBackups(rt *resolvedTarget) { + d, err := rt.child.Open(".") + if err != nil { + w.log("npmrc: backup purge open dir failed: %v", err) + return + } + defer d.Close() + + prefix := rt.base + ".dmg-" + tmpPrefix := rt.base + ".dmg-tmp-" + for { + entries, rerr := d.ReadDir(256) + for _, e := range entries { + name := e.Name() + if name == "" || strings.ContainsRune(name, filepath.Separator) { + continue + } + if !strings.HasPrefix(name, prefix) || !strings.HasSuffix(name, ".bak") { + continue + } + if strings.HasPrefix(name, tmpPrefix) { + continue + } + li, lerr := rt.child.Lstat(name) + if lerr != nil || !li.Mode().IsRegular() { + continue + } + if err := rt.child.Remove(name); err != nil { + w.log("npmrc: purge backup %q failed: %v", name, err) + } + } + if rerr != nil { + if !errors.Is(rerr, io.EOF) { + w.log("npmrc: backup purge readdir failed: %v", rerr) + } + break + } + } +} + +func randomSuffix() (string, error) { + var b [8]byte + if _, err := rand.Read(b[:]); err != nil { + return "", err + } + return hex.EncodeToString(b[:]), nil +} + +// --------------------------------------------------------------------------- +// Content transforms (rewrite / clear) and the INI classifier +// --------------------------------------------------------------------------- + +// rewriteContent produces the new file bytes from the current bytes and the +// rendered block body: strip any existing managed block, fail closed on an INI +// section header, comment out active bare `registry=` lines, and append a fresh +// block at the very bottom on its own line. Preserves all other bytes exactly. +func (w *NPMRCWriter) rewriteContent(current []byte, body string) ([]byte, error) { + rest, bom := stripBOM(current) + if hasLoneCR(string(rest)) { + return nil, fmt.Errorf("npmrc: file contains a bare CR npm would treat as a line break; cannot safely transform: %w", ErrTargetUnusable) + } + lines := strings.Split(string(rest), "\n") + + lines, strippedToEOF := stripManagedBlock(lines) + if strippedToEOF { + w.log("npmrc: managed block had no END marker; stripped to EOF and rewriting") + } + if containsSection(lines) { + // An INI section header scopes every following key to section.key, which + // npm ignores — our appended block would be inert while a line-based + // check reported it applied. There is no way to close a section, so the + // only safe outcome is to refuse. + return nil, fmt.Errorf("npmrc: file contains an INI [section] header; cannot safely append: %w", ErrTargetUnusable) + } + if hasCoercibleQuotedKey(lines) { + return nil, fmt.Errorf("npmrc: file has a quoted key npm would coerce from non-string JSON; cannot safely transform: %w", ErrTargetUnusable) + } + if _, tokKey, _, _ := parseExpected(body); hasArrayAppendOverride(lines, tokKey) { + // npm folds `registry[]=` and our block's `registry=` into one array, so the + // block would be present and last-wins yet npm would not resolve to the + // tenant registry alone. Commenting the array line out is not enough (npm + // arrays are order-independent), so refuse the transform. + return nil, fmt.Errorf("npmrc: file uses npm array-append syntax on a managed key; cannot safely transform: %w", ErrTargetUnusable) + } + lines = commentBareRegistry(lines) + + base := strings.Join(lines, "\n") + var buf bytes.Buffer + buf.Write(bom) + buf.WriteString(base) + if len(base) > 0 && !strings.HasSuffix(base, "\n") { + // Start the block on a fresh line, but never squash a pre-existing + // trailing newline (blank lines are content and are preserved). + buf.WriteByte('\n') + } + buf.WriteString(npmrcBeginMarker) + buf.WriteByte('\n') + buf.WriteString(body) + buf.WriteByte('\n') + buf.WriteString(npmrcEndMarker) + buf.WriteByte('\n') + return buf.Bytes(), nil +} + +// clearContent removes the managed block and un-comments only our own +// `# [stepsecurity-dmg] ` lines. It never touches the MDM script's +// `# [stepsecurity] ` lines and never deletes the file. The one permitted byte +// deviation from "restore the world" is that a missing original final newline +// is not restored — the remainder keeps the newline enforce added before the +// block. +// +// It fails closed on a bare CR for a reason specific to clearing: a CR-delimited +// file collapses to a single line under the '\n' split, so no marker line matches +// and the block is not FOUND — the transform would return the input unchanged, +// Clear would report the nothing-to-do success, and the reconciler would drop +// ownership state while the token stayed on disk. Refusing keeps the failure +// visible and the ownership record intact, so a later run can retry. +func (w *NPMRCWriter) clearContent(current []byte) ([]byte, error) { + rest, bom := stripBOM(current) + if hasLoneCR(string(rest)) { + return nil, fmt.Errorf("npmrc: file contains a bare CR npm treats as a line break; the managed block cannot be located to remove it: %w", ErrTargetUnusable) + } + lines := strings.Split(string(rest), "\n") + lines, _ = stripManagedBlock(lines) + lines = unprefixDMG(lines) + var buf bytes.Buffer + buf.Write(bom) + buf.WriteString(strings.Join(lines, "\n")) + return buf.Bytes(), nil +} + +// stripBOM splits a leading UTF-8 BOM off the content. The BOM is removed for +// parsing (an INI key on the first line is matched correctly, a JSON document +// starts on a value) and re-prepended on rewrite so the bytes are preserved. +// Used by both the ~/.npmrc block writer and the settings.json writer. +func stripBOM(b []byte) (rest, bom []byte) { + const bomSeq = "\ufeff" + if bytes.HasPrefix(b, []byte(bomSeq)) { + return b[len(bomSeq):], []byte(bomSeq) + } + return b, nil +} + +// stripManagedBlock removes EVERY managed block (each BEGIN marker through its +// matching END, inclusive), not just the first. A BEGIN with no matching END +// anywhere after it is a truncated block and is stripped to EOF, reported via the +// returned flag. Removing all blocks is what makes offboarding revoke every +// token: a duplicated block — a user copy, or a partial prior write — must never +// survive a clear still carrying a live token, and must never make a rewrite +// oscillate forever between one block and two. +func stripManagedBlock(lines []string) ([]string, bool) { + out := make([]string, 0, len(lines)) + strippedToEOF := false + for i := 0; i < len(lines); { + if !isMarkerLine(lines[i], npmrcBeginMarker) { + out = append(out, lines[i]) + i++ + continue + } + end := -1 + for j := i + 1; j < len(lines); j++ { + if isMarkerLine(lines[j], npmrcEndMarker) { + end = j + break + } + } + if end < 0 { + // Truncated block: no END exists past this BEGIN. Drop it to EOF so no + // partial token lingers; bytes past a genuine truncation are not + // recoverable structure. + strippedToEOF = true + break + } + i = end + 1 + } + return out, strippedToEOF +} + +// isMarkerLine matches a marker tolerantly of surrounding whitespace and a +// trailing CR, so a marker survives being read back from a CRLF file. +func isMarkerLine(line, marker string) bool { + return strings.TrimSpace(line) == marker +} + +// containsSection reports whether any line is an INI section header. +func containsSection(lines []string) bool { + for _, l := range lines { + if isSectionLine(l) { + return true + } + } + return false +} + +// hasLoneCR reports whether s contains a bare carriage return — a '\r' not +// immediately followed by '\n'. npm's INI parser splits logical lines on '\r\n', +// '\n', AND a lone '\r', so a bare CR begins a new line for npm that our +// '\n'-only split does not see. That split mismatch is exploitable: `[global]\r` +// is a section header to npm (scoping, and thus nullifying, our appended block) +// but one opaque line to us, and `k=v\rregistry=evil` hides an overriding +// registry from us while npm honors it. We cannot safely transform such a file, +// so the enforce/convergence/probe paths treat a bare CR the way they treat an +// INI section: fail closed. A CRLF ('\r\n') file is NOT a lone CR and still +// round-trips through the '\n' split with its trailing '\r' preserved. +func hasLoneCR(s string) bool { + for i := 0; i < len(s); i++ { + if s[i] == '\r' && (i+1 >= len(s) || s[i+1] != '\n') { + return true + } + } + return false +} + +// hasCoercibleQuotedKey reports whether any active line's key is a single-quoted +// token whose inner text npm's unsafe() JSON-parses to a NON-string value. npm +// strips the single quotes, JSON-parses the bare inner, and then coerces the +// result to a string when it is used as a config key — a single-element array +// like `'["registry"]'` becomes the JS array ["registry"], which coerces to the +// key `registry`, forging an override. Replicating JS's String() coercion for +// every shape (arrays join, objects → "[object Object]", numbers, bools) is +// fragile, and our own keys are never quoted, so we fail closed on any such line +// the same way we do on an INI section or a bare CR. (A double-quoted token is +// itself a JSON string literal, so it always decodes to a string — jsonDecodeString +// already handles it — and is not coercible-non-string.) +func hasCoercibleQuotedKey(lines []string) bool { + for _, l := range lines { + if isCommentLine(l) || isSectionLine(l) { + continue + } + i := strings.IndexByte(l, '=') + if i < 0 { + continue + } + if quotedNonStringInner(strings.TrimSpace(l[:i])) { + return true + } + } + return false +} + +// hasArrayAppendOverride reports whether any active line uses npm's `key[]=` +// array-append form on a key this writer's precedence model treats as a scalar. +// npm's ini reader turns `registry[]=…` into an ARRAY and then folds the plain +// `registry=` assignment into that same array, so BOTH orders leave the effective +// registry a comma-joined list containing the injected value while a last-wins +// scalar scan still sees the block's own `registry=` as the winner — i.e. we would +// report converged (or observe a clean registry_url) on a file where npm no longer +// resolves to the tenant registry alone. Verified against npm 10.9.7: +// +// registry= + registry[]= → "," +// registry[]= + registry= → "," +// +// Only the keys we manage are judged, so an unrelated array config (`omit[]=dev`) +// is left alone. tokenKey is the single `//host/path/:_authToken` this writer +// manages: npm consults exactly that key for the tenant registry's credential, so +// an array-append on any OTHER registry's token cannot perturb what we render or +// read, and refusing the file over it would be a false unenforceable. When the +// desired pair does not parse, which key is ours is unknown, so every token key is +// judged rather than none. +// +// The `[]` suffix is tested AFTER npmUnsafe, matching npm's own order (it unquotes +// before checking for `[]`), which is what catches the quoted `"registry[]"=…` form; +// `registry [] = …` is NOT flagged because npm stores that under the distinct key +// "registry " and it overrides nothing. +func hasArrayAppendOverride(lines []string, tokenKey string) bool { + for _, l := range lines { + key, _, ok := activeKV(l) + if !ok { + continue + } + base, isAppend := strings.CutSuffix(key, "[]") + if !isAppend { + continue + } + if base == "registry" { + return true + } + if tokenKey != "" { + if base == tokenKey { + return true + } + } else if strings.HasSuffix(base, ":_authToken") { + return true + } + } + return false +} + +// quotedNonStringInner reports whether s is a single-quoted token whose bare inner +// is valid JSON that is NOT a string (array, object, number, bool, null) — the +// shape npm coerces to a string key. A parse failure (e.g. the bare word in +// `'registry'`) or a JSON string is not coercible-non-string and is left to the +// normal npmUnsafe path. +func quotedNonStringInner(s string) bool { + if len(s) < 2 || s[0] != '\'' || s[len(s)-1] != '\'' { + return false + } + var v any + if err := json.Unmarshal([]byte(s[1:len(s)-1]), &v); err != nil { + return false + } + _, isStr := v.(string) + return !isStr +} + +// commentBareRegistry prefixes every active bare `registry=` line with the DMG +// prefix, preserving the original (including any trailing CR) after the prefix. +// Scoped `@scope:registry=` lines, token lines, cooldown keys, env-ref lines, +// and every comment are left untouched. +func commentBareRegistry(lines []string) []string { + out := make([]string, len(lines)) + for i, l := range lines { + if key, _, ok := activeKV(l); ok && key == "registry" { + out[i] = npmrcDMGPrefix + l + continue + } + out[i] = l + } + return out +} + +// unprefixDMG restores lines the writer previously commented out, removing only +// an exact leading DMG prefix. +func unprefixDMG(lines []string) []string { + out := make([]string, len(lines)) + for i, l := range lines { + if strings.HasPrefix(l, npmrcDMGPrefix) { + out[i] = l[len(npmrcDMGPrefix):] + continue + } + out[i] = l + } + return out +} + +// isCommentLine reports whether a line is an npm INI comment (first non-space +// rune is '#' or ';') or blank. +func isCommentLine(line string) bool { + t := strings.TrimLeft(line, " \t") + if t == "" { + return true + } + return t[0] == '#' || t[0] == ';' +} + +// isSectionLine reports whether a line is an INI section header `[...]`. +func isSectionLine(line string) bool { + t := strings.TrimSpace(line) + return len(t) >= 2 && t[0] == '[' && t[len(t)-1] == ']' +} + +// activeKV parses an active (uncommented, non-section) key=value line the way +// npm's INI parser does: split on the FIRST '=', then run BOTH sides through +// npmUnsafe — npm's own key/value normalization (trim, unquote a fully quoted +// token, or strip an unescaped inline ';'/'#' comment). ok is false for comments, +// sections, and lines with no '=' or an empty key. This one classifier backs +// every key-matching path (comment-out, clear, probe precedence, convergence); +// parsing keys exactly as npm does is what keeps a disguised override like +// `registry#x=evil` or `"registry"=evil` — both of which npm reads as the key +// `registry` — from slipping past the precedence checks as an unrecognized key, +// and a spaced form like `registry = https://evil/` from being mistaken for inert. +func activeKV(line string) (key, value string, ok bool) { + if isCommentLine(line) || isSectionLine(line) { + return "", "", false + } + i := strings.IndexByte(line, '=') + if i < 0 { + return "", "", false + } + key = npmUnsafe(line[:i]) + if key == "" { + return "", "", false + } + value = npmUnsafe(line[i+1:]) + return key, value, true +} + +// npmUnsafe mirrors the npm `ini` package's unsafe(): the normalization npm +// applies to BOTH the key and the value of every line before storing it. A fully +// quoted token is unquoted (one layer); otherwise everything from the first +// UNESCAPED ';' or '#' is dropped as an inline comment and '\;', '\#', '\\' +// escapes are resolved (any other '\x' is kept verbatim). Our classifier must +// match npm here or it fails to recognize a disguised override: npm reads +// `registry#x=evil` as key `registry` and `"registry"=evil` as key `registry`, so +// a naive first-'=' split keeping `registry#x` / `"registry"` would let a later +// poisoned line defeat last-wins while Converged/ProbeExpected still reported +// compliant. Every key and value this writer itself renders is drawn from a +// comment-, quote-, and backslash-free alphabet, so this is the identity function +// on our own content. +func npmUnsafe(s string) string { + s = strings.TrimSpace(s) + if inner, ok := unquoteININToken(s); ok { + return inner + } + var b strings.Builder + b.Grow(len(s)) + esc := false + for i := 0; i < len(s); i++ { + c := s[i] + switch { + case esc: + if c == '\\' || c == ';' || c == '#' { + b.WriteByte(c) + } else { + b.WriteByte('\\') + b.WriteByte(c) + } + esc = false + case c == ';' || c == '#': + return strings.TrimSpace(b.String()) + case c == '\\': + esc = true + default: + b.WriteByte(c) + } + } + if esc { + b.WriteByte('\\') + } + return strings.TrimSpace(b.String()) +} + +// unquoteININToken mirrors npm ini unsafe()'s quoted branch, which JSON-parses a +// quoted token rather than merely stripping the quotes. A double-quoted token is +// JSON-decoded whole, so string escapes resolve — `"registry"` decodes to +// `registry`, an active override we must recognize — and falls back to the +// ORIGINAL quoted string when it is not a valid JSON string (npm keeps the quoted +// form on a JSON.parse failure). A single-quoted token has its quotes stripped +// and the inside JSON-decoded only if that inside is itself a valid JSON string, +// else kept verbatim. ok is false when s is not fully quoted, so the caller falls +// through to inline-comment handling. Merely trimming the quotes (the previous +// behavior) would read `"registry"` as the key `registry` and miss the +// override npm honors as `registry`. +func unquoteININToken(s string) (string, bool) { + if len(s) < 2 { + return "", false + } + if s[0] == '"' && s[len(s)-1] == '"' { + if v, ok := jsonDecodeString(s); ok { + return v, true + } + return s, true // npm keeps the quoted form when JSON.parse fails + } + if s[0] == '\'' && s[len(s)-1] == '\'' { + inner := s[1 : len(s)-1] + if v, ok := jsonDecodeString(inner); ok { + return v, true + } + return inner, true + } + return "", false +} + +// jsonDecodeString reports whether s is a valid JSON string literal and, if so, +// its decoded value — the string half of npm's JSON.parse(). A non-string JSON +// value (number, object, bool) or a parse error yields ok=false. +func jsonDecodeString(s string) (string, bool) { + var v string + if err := json.Unmarshal([]byte(s), &v); err != nil { + return "", false + } + return v, true +} + +// extractManagedBody returns the canonical body between our markers (the two +// content lines, '\n'-joined, no markers) and whether a well-formed block is +// present. A BEGIN with no END yields present=false. +func extractManagedBody(content string) (string, bool) { + rest, _ := stripBOM([]byte(content)) + lines := strings.Split(string(rest), "\n") + begin := -1 + for i, l := range lines { + if isMarkerLine(l, npmrcBeginMarker) { + begin = i + break + } + } + if begin < 0 { + return "", false + } + end := -1 + for i := begin + 1; i < len(lines); i++ { + if isMarkerLine(lines[i], npmrcEndMarker) { + end = i + break + } + } + if end < 0 { + return "", false + } + body := make([]string, 0, end-begin-1) + for _, l := range lines[begin+1 : end] { + body = append(body, strings.TrimRight(l, "\r")) + } + return strings.Join(body, "\n"), true +} + +// --------------------------------------------------------------------------- +// Convergence +// --------------------------------------------------------------------------- + +// Converged reports whether the file already reflects the desired block with no +// further work needed. It is stronger than block-body equality: the block must +// be present with body == expected, effective (nothing active overrides its +// registry/token after it, END marker intact, no displaced duplicate), and +// carry sane metadata (0600, target-user-owned on POSIX). A `registry=` line +// appended below an unchanged block (e.g. `aws codeartifact login`) leaves the +// body equal but defeats precedence — so body equality alone would report +// converged forever without ever re-running the transform. +func (w *NPMRCWriter) Converged(expected string) (bool, error) { + rt, err := w.resolveLeaf() + if err != nil { + return false, err + } + defer rt.close() + + data, existed, mode, err := w.readCurrent(rt) + if err != nil { + return false, err + } + if !existed { + return false, nil + } + + rest, _ := stripBOM(data) + if hasLoneCR(string(rest)) { + // A bare CR is a line break to npm but not to our '\n' split, so a section or + // overriding line could hide behind it — the block would look present and + // effective to us yet be scoped-out or overridden for npm. Fail closed, the + // same refusal the rewrite path makes. + return false, fmt.Errorf("npmrc: file contains a bare CR npm treats as a line break; managed block cannot be verified: %w", ErrTargetUnusable) + } + lines := strings.Split(string(rest), "\n") + if containsSection(lines) { + // An INI [section] header scopes our registry/token keys to section.key, + // which npm ignores for the global registry: the block would be present and + // body-equal yet inert, so reporting it converged would loop on a false + // 'compliant'. Fail closed — the same refusal the rewrite path makes — so + // enforce classifies it write_failed rather than silently accepting it. + return false, fmt.Errorf("npmrc: file contains an INI [section] header; managed block cannot be effective: %w", ErrTargetUnusable) + } + if hasCoercibleQuotedKey(lines) { + // A single-quoted key npm coerces from non-string JSON (e.g. '["registry"]') + // could override our registry/token invisibly to a line-based check. Fail + // closed, the same refusal the rewrite path makes. + return false, fmt.Errorf("npmrc: file has a quoted key npm would coerce from non-string JSON; managed block cannot be verified: %w", ErrTargetUnusable) + } + if _, tokKey, _, _ := parseExpected(expected); hasArrayAppendOverride(lines, tokKey) { + // npm folds an array-append line into the same key as the block's scalar + // assignment, so last-wins would report converged while npm resolves to a + // list containing someone else's registry. Fail closed, as the rewrite path + // does. + return false, fmt.Errorf("npmrc: file uses npm array-append syntax on a managed key; managed block cannot be verified: %w", ErrTargetUnusable) + } + + body, present := extractManagedBody(string(data)) + if !present || body != expected { + return false, nil + } + + if countMarker(lines, npmrcBeginMarker) != 1 || countMarker(lines, npmrcEndMarker) != 1 { + // A duplicate or displaced block: converge by rewriting. + return false, nil + } + if !blockIsLastEffective(lines, expected) { + return false, nil + } + + if enforcePOSIXMetadata && mode.Perm() != npmrcFileMode { + return false, nil + } + // Ownership is not re-checked here: readCurrent already required the resolved + // leaf to be owned by the target user, from the same identity-verified handle + // it read the content through. A second open to re-read the owner would race + // the very content check it is meant to corroborate. + return true, nil +} + +// blockIsLastEffective reports whether, after our block, no active line +// overrides the block's registry or token — i.e. the block's own keys are the +// last-wins values for the file. +func blockIsLastEffective(lines []string, expected string) bool { + expReg, expTokKey, expTokVal, ok := parseExpected(expected) + if !ok { + return false + } + endIdx := -1 + for i, l := range lines { + if isMarkerLine(l, npmrcEndMarker) { + endIdx = i + } + } + if endIdx < 0 { + return false + } + for _, l := range lines[endIdx+1:] { + key, val, ok := activeKV(l) + if !ok { + continue + } + if key == "registry" && val != expReg { + return false + } + if key == expTokKey && val != expTokVal { + return false + } + } + return true +} + +func countMarker(lines []string, marker string) int { + n := 0 + for _, l := range lines { + if isMarkerLine(l, marker) { + n++ + } + } + return n +} + +// --------------------------------------------------------------------------- +// MDM probe +// --------------------------------------------------------------------------- + +// ProbeExpected reports whether the MDM lane has actually achieved the current +// desired state for this device — not merely that an MDM marker exists. Because +// ~/.npmrc is user-writable (unlike the privileged VS Code policy locations), +// trusting a marker alone would let a user pin permanent mdm_managed while +// pointing npm anywhere. Managed requires all of: the MDM marker outside our +// block, the MDM block's own registry/token lines equal to the expected +// rendered content, those keys effective (last-wins) with nothing overriding +// them, and sane metadata (0600, target-user-owned on POSIX). +func (w *NPMRCWriter) ProbeExpected(expected string) (bool, string) { + rt, err := w.resolveLeaf() + if err != nil { + return false, "" + } + defer rt.close() + + data, existed, mode, err := w.readCurrent(rt) + if err != nil || !existed { + return false, "" + } + + managed, detail := probeNPMRCContent(string(data), expected) + if !managed { + return false, "" + } + if enforcePOSIXMetadata && mode.Perm() != npmrcFileMode { + return false, "" + } + // Ownership is already enforced by readCurrent's checkOwner on the same + // identity-verified handle; re-opening to re-read the owner would race the + // content probe above. + return true, detail +} + +// probeNPMRCContent is the pure content logic behind ProbeExpected. It takes the +// whole file and the expected rendered body and reports whether the MDM lane +// owns an effective, current block. +func probeNPMRCContent(content, expected string) (bool, string) { + expReg, expTokKey, expTokVal, ok := parseExpected(expected) + if !ok { + return false, "" + } + rest, _ := stripBOM([]byte(content)) + if hasLoneCR(string(rest)) { + // A bare CR hides a section/override from our '\n' split; a marker plus + // matching lines is then not proof the MDM lane governs npm. Fail closed. + return false, "" + } + lines := strings.Split(string(rest), "\n") + + if containsSection(lines) { + // A section scopes every following key to section.key; npm then ignores the + // MDM block's registry/token for the global registry, so a marker plus + // matching lines under a section is NOT proof the MDM lane governs npm. Fail + // closed (not managed) — enforce then refuses the sectioned file too. + return false, "" + } + if hasCoercibleQuotedKey(lines) { + // A single-quoted key npm coerces from non-string JSON could override the + // registry/token below the MDM block invisibly to the precedence loop. A + // marker plus matching lines is then not proof; fail closed (not managed). + return false, "" + } + if hasArrayAppendOverride(lines, expTokKey) { + // npm folds an array-append line into the MDM block's own key, so a marker + // plus matching lines is not proof the MDM lane governs npm. Fail closed. + return false, "" + } + + // Our own block boundaries, so the MDM marker search can exclude it (a user + // planting the marker inside our block must not count). + ourBegin, ourEnd := managedBlockBounds(lines) + + mdmIdx := -1 + for i, l := range lines { + if i >= ourBegin && i <= ourEnd { + continue + } + if isMarkerLine(l, npmrcMDMMarker) { + mdmIdx = i + break + } + } + if mdmIdx < 0 { + return false, "" + } + + // The MDM block's own lines (contiguous config after its header, stopping at + // a blank line, our block, or a section) must carry the expected content. + mdmReg, mdmTok := false, false + for i := mdmIdx + 1; i < len(lines); i++ { + if i >= ourBegin && i <= ourEnd { + break + } + l := lines[i] + if strings.TrimSpace(l) == "" || isSectionLine(l) { + break + } + key, val, ok := activeKV(l) + if !ok { + continue + } + if key == "registry" && val == expReg { + mdmReg = true + } + if key == expTokKey && val == expTokVal { + mdmTok = true + } + } + if !mdmReg || !mdmTok { + return false, "" + } + + // Effective precedence: the last active registry and token in the whole + // file must be the expected ones. A later override (poisoned token, bare + // registry) defeats this and we enforce instead. + lastReg, lastRegOK := "", false + lastTok, lastTokOK := "", false + for _, l := range lines { + key, val, ok := activeKV(l) + if !ok { + continue + } + if key == "registry" { + lastReg, lastRegOK = val, true + } + if key == expTokKey { + lastTok, lastTokOK = val, true + } + } + if !lastRegOK || lastReg != expReg || !lastTokOK || lastTok != expTokVal { + return false, "" + } + return true, "mdm-managed npmrc block present and effective" +} + +// managedBlockBounds returns the [begin, end] line indices of our block, or +// (len, -1) when absent (so the "i >= begin && i <= end" exclusion is empty). +func managedBlockBounds(lines []string) (int, int) { + begin := -1 + for i, l := range lines { + if isMarkerLine(l, npmrcBeginMarker) { + begin = i + break + } + } + if begin < 0 { + return len(lines), -1 + } + for i := begin + 1; i < len(lines); i++ { + if isMarkerLine(lines[i], npmrcEndMarker) { + return begin, i + } + } + return begin, len(lines) - 1 +} + +// parseExpected splits the rendered body (two content lines) into the registry +// value, the token key, and the token value used by the precedence checks. +func parseExpected(expected string) (registry, tokenKey, tokenVal string, ok bool) { + lines := strings.Split(expected, "\n") + if len(lines) != 2 { + return "", "", "", false + } + rk, rv, rok := activeKV(lines[0]) + tk, tv, tok := activeKV(lines[1]) + if !rok || rk != "registry" || !tok { + return "", "", "", false + } + return rv, tk, tv, true +} + +// ProbeContentNPM is the MDM verify-only reader. It reports whether a +// StepSecurity MDM-managed block is present in ~/.npmrc and, if so, the effective +// (last-wins) configuration as the observed bag {ecosystem, registry_url, +// auth_token_status}. It NEVER writes, patches, or clears — in MDM mode the agent +// owns nothing on this file — and it never touches the ownership state store. +// +// expected is the rendered desired block. Only its tenant key (the api_key before +// `::dev:`) is used, to decide auth_token_status here on the device; no +// token, hash, or fingerprint is ever returned or logged. +// +// The three outcomes are deliberately distinct: +// +// - genuinely absent file, or present with no MDM marker → (false, nil, nil) → +// policy_not_applied. Nothing is managing this file. +// - unreadable file — permission failure, a leaf that became a symlink or +// changed across the open, a non-regular leaf, an ownership mismatch, the size +// cap — or a construct we cannot reason about → error → verification_failed. +// We could not establish the effective config, so we must not report the clean +// policy_not_applied. +// - MDM marker present and the file parses → (true, bag, nil) → mdm_managed. +// +// Unlike the DMG-mode ProbeExpected this does NOT require 0600: perms are outside +// the locked observed contract, so a correctly-deployed-but-lax file must still +// report its real registry and auth status rather than be hidden behind a +// synthetic failure. Ownership IS still enforced — readCurrent refuses a leaf the +// target user does not own, because another user's file is not this user's +// effective npm config. +func (w *NPMRCWriter) ProbeContentNPM(expected string) (bool, map[string]json.RawMessage, error) { + rt, err := w.resolveLeaf() + if err != nil { + return false, nil, err + } + defer rt.close() + + data, existed, mode, err := w.readCurrent(rt) + if err != nil { + return false, nil, err + } + if !existed { + return false, nil, nil + } + if enforcePOSIXMetadata && mode.Perm() != npmrcFileMode { + // Not a verification failure (see the doc comment), and not reportable — the + // observed bag has no perms field. Log it so support can spot a token file + // other local users can read; the mode only, never the content. + w.log("npmrc: mdm-managed file mode is %#o, not %#o (token may be readable by other local users)", mode.Perm(), npmrcFileMode) + } + return probeNPMRCObserved(string(data), expected) +} + +// probeNPMRCObserved is the pure content logic behind ProbeContentNPM. It shares +// the parse guards and the last-wins precedence scan with probeNPMRCContent, but +// returns the observed VALUES instead of a yield/no-yield verdict: the backend +// structurally compares registry_url and ecosystem against desired, so the agent +// reports them raw and judges only the secret axis. +func probeNPMRCObserved(content, expected string) (bool, map[string]json.RawMessage, error) { + // The desired registry is deliberately unused: the backend compares + // registry_url structurally. Only the token key (which _authToken line belongs + // to the tenant registry) and its value (the tenant key) are needed here. + _, expTokKey, expTokVal, ok := parseExpected(expected) + if !ok { + return false, nil, errors.New("npmrc: expected value is not a rendered registry/token pair") + } + + rest, _ := stripBOM([]byte(content)) + // Fail closed on the same constructs the DMG probe rejects. Each one hides a + // section or an override from the '\n'-split precedence scan below, so the + // values we would report are not provably the effective ones. An honest + // verification_failed beats a confident wrong observation. + if hasLoneCR(string(rest)) { + return false, nil, fmt.Errorf("npmrc: file contains a bare CR line break: %w", ErrTargetUnusable) + } + lines := strings.Split(string(rest), "\n") + if containsSection(lines) { + return false, nil, fmt.Errorf("npmrc: file contains an INI section header: %w", ErrTargetUnusable) + } + if hasCoercibleQuotedKey(lines) { + return false, nil, fmt.Errorf("npmrc: file contains a coercible quoted key: %w", ErrTargetUnusable) + } + if hasArrayAppendOverride(lines, expTokKey) { + // The registry we would report is not the one npm resolves: it folds the + // array-append line into the same key. Reporting the scalar last-wins value + // would be a confident wrong observation. + return false, nil, fmt.Errorf("npmrc: file uses npm array-append syntax on a managed key: %w", ErrTargetUnusable) + } + + // Presence = an MDM marker OUTSIDE every DMG-owned block, so a marker planted + // inside one of our own blocks cannot pass as MDM management. + inDMGBlock, err := dmgBlockLines(lines) + if err != nil { + return false, nil, err + } + present := false + for i, l := range lines { + if inDMGBlock[i] { + continue + } + if isMarkerLine(l, npmrcMDMMarker) { + present = true + break + } + } + if !present { + return false, nil, nil + } + + // Effective precedence over the WHOLE file: npm takes the LAST active + // assignment, so a line below the MDM block wins. Report what npm would + // actually use — an override surfaces as drift at the backend, which is the + // correct outcome, not something to hide. + lastReg, lastRegOK := "", false + lastTok, lastTokOK := "", false + for _, l := range lines { + key, val, ok := activeKV(l) + if !ok { + continue + } + switch key { + case "registry": + lastReg, lastRegOK = val, true + case expTokKey: + // The tenant registry's _authToken key. A block pointing at a DIFFERENT + // registry carries a different token key, so its token does not count as + // this policy's credential — it reports absent, alongside the registry drift. + lastTok, lastTokOK = val, true + } + } + if !lastRegOK { + // A managed marker with no effective registry line is not a credible read, + // and the backend requires registry_url to compare anything at all. + return false, nil, errors.New("npmrc: mdm marker present but no effective registry line") + } + if err := transmittableRegistryURL(lastReg); err != nil { + return false, nil, err + } + + status := authTokenAbsent + if lastTokOK { + // Tenant-key PREFIX comparison on both sides: an admin-pushed shared token + // carrying no ::dev: suffix is still the tenant's key, so it reads + // match. The serial is device-specific and deliberately not part of the + // verdict. + status = authTokenMismatch + if tenantKeyPrefix(lastTok) == tenantKeyPrefix(expTokVal) { + status = authTokenMatch + } + } + return npmObservedBag(lastReg, status) +} + +// dmgBlockLines marks every line that falls inside a DMG-owned block, so the MDM +// marker search can exclude all of them. managedBlockBounds only locates the +// FIRST block, so a second BEGIN/END pair would leave its interior unexcluded and +// a marker planted there would read as MDM presence — refuse that file instead. +// The writer never produces two blocks, so this is a tampered/hand-edited file. +func dmgBlockLines(lines []string) ([]bool, error) { + if countMarker(lines, npmrcBeginMarker) > 1 || countMarker(lines, npmrcEndMarker) > 1 { + return nil, fmt.Errorf("npmrc: more than one dmg-managed block: %w", ErrTargetUnusable) + } + in := make([]bool, len(lines)) + begin, end := managedBlockBounds(lines) + for i := begin; i <= end && i < len(lines); i++ { + in[i] = true + } + return in, nil +} + +// transmittableRegistryURL rejects an effective registry_url that must not leave +// the device. It is deliberately a SUBSET of validateRegistryURL's rules: the +// value is read off a user-writable file, so credential-bearing or +// parser-ambiguous forms (userinfo, a query, a fragment, control bytes) and +// oversize values are refused before transmission — a token smuggled into the URL +// must never reach the backend. The shape rules validateRegistryURL also enforces +// (host grammar, no port, an exact /javascript path) are NOT applied: a merely +// wrong registry is the drift the backend exists to detect, and erroring on it +// would destroy that signal. +// +// The SCHEME is likewise not judged beyond http/https: a device resolving to a +// plaintext http mirror is the most security-relevant drift an admin can have, so +// it must travel as evidence and surface as a registry_url diff rather than be +// discarded as malformed. validateRegistryURL stays https-only for the POLICY +// side, where we compose the URL and a non-https value is our own bug; here the +// URL is someone else's input, so it is data to report. What still fails is a +// value that is not a credible registry read at all — another scheme, or no host. +func transmittableRegistryURL(raw string) error { + if len(raw) > npmrcMaxRegistryURLBytes { + return fmt.Errorf("npmrc: effective registry_url exceeds %d bytes", npmrcMaxRegistryURLBytes) + } + if hasControlBytes(raw) { + return errors.New("npmrc: effective registry_url contains control characters") + } + if strings.ContainsAny(raw, "#?") { + return errors.New("npmrc: effective registry_url contains '#' or '?'") + } + u, err := url.Parse(raw) + if err != nil { + return errors.New("npmrc: effective registry_url is not a valid URL") + } + if u.Scheme != "https" && u.Scheme != "http" { + return errors.New("npmrc: effective registry_url is not an http(s) URL") + } + if u.Host == "" { + return errors.New("npmrc: effective registry_url has no host") + } + if u.User != nil { + return errors.New("npmrc: effective registry_url contains userinfo") + } + if u.RawQuery != "" || u.ForceQuery { + return errors.New("npmrc: effective registry_url contains a query") + } + if u.Fragment != "" { + return errors.New("npmrc: effective registry_url contains a fragment") + } + return nil +} + +// tenantKeyPrefix returns the tenant api_key portion of a device token — +// everything before the "::dev:" suffix. A token with no suffix is +// already the bare tenant key and returns unchanged. +func tenantKeyPrefix(token string) string { + return strings.SplitN(token, "::dev:", 2)[0] +} + +// npmObservedBag builds the observed bag. Exactly three keys, JSON strings — the +// backend rejects any unknown key, and nothing derived from the token beyond the +// verdict is included. +func npmObservedBag(registryURL, authStatus string) (bool, map[string]json.RawMessage, error) { + reg, err := json.Marshal(registryURL) + if err != nil { + return false, nil, fmt.Errorf("npmrc: encode observed registry_url: %w", err) + } + eco, err := json.Marshal("npm") + if err != nil { + return false, nil, fmt.Errorf("npmrc: encode observed ecosystem: %w", err) + } + status, err := json.Marshal(authStatus) + if err != nil { + return false, nil, fmt.Errorf("npmrc: encode observed auth_token_status: %w", err) + } + return true, map[string]json.RawMessage{ + observedKeyEcosystem: eco, + observedKeyRegistryURL: reg, + observedKeyAuthTokenStatus: status, + }, nil +} + +// --------------------------------------------------------------------------- +// Rendering +// --------------------------------------------------------------------------- + +// npmPolicy is the run-config policy payload for the npm ecosystem. +type npmPolicy struct { + Ecosystem string `json:"ecosystem"` + RegistryURL string `json:"registry_url"` + Auth struct { + Scheme string `json:"scheme"` + APIKey string `json:"api_key"` + } `json:"auth"` +} + +// RenderNPMRCBlock validates a policy and returns the two content lines the +// writer wraps in its markers: the `registry=` line and the `//host/path/:_authToken=` +// line, '\n'-joined with no markers and no trailing newline. It fully validates +// the policy (the HTTP layer only checks "is a JSON object"): the token line's +// host and path derive from registry_url, and the composed device token is +// `::dev:`. Any validation failure returns an error the +// reconciler reports as policy_not_applied; error messages never echo the key +// or the policy. +func RenderNPMRCBlock(policy json.RawMessage, serial string) (string, error) { + var p npmPolicy + if err := json.Unmarshal(policy, &p); err != nil { + return "", errors.New("npmrc: policy is not a well-formed npm policy object") + } + if p.Ecosystem != "npm" { + return "", errors.New("npmrc: policy ecosystem is not npm") + } + if p.Auth.Scheme != "stepsecurity_device_token" { + return "", errors.New("npmrc: unsupported auth scheme") + } + + key := p.Auth.APIKey + if key == "" { + return "", errors.New("npmrc: policy api_key is empty") + } + if len(key) > npmrcMaxKeyBytes { + return "", errors.New("npmrc: policy api_key too long") + } + if !isNPMSafe(key) { + return "", errors.New("npmrc: policy api_key contains unsupported characters") + } + if serial == "" { + return "", errors.New("npmrc: device serial is empty") + } + if len(serial) > npmrcMaxSerialBytes { + return "", errors.New("npmrc: device serial too long") + } + if !isNPMSafe(serial) { + return "", errors.New("npmrc: device serial contains unsupported characters") + } + + host, path, err := validateRegistryURL(p.RegistryURL) + if err != nil { + return "", err + } + + token := key + "::dev:" + serial + // npm's _authToken key is `//host/path/:_authToken` with a trailing slash + // before the colon. + tokenKey := "//" + host + path + "/:_authToken" + body := "registry=" + p.RegistryURL + "\n" + tokenKey + "=" + token + if len(body) > npmrcMaxRenderedBytes { + return "", errors.New("npmrc: rendered block exceeds size limit") + } + return body, nil +} + +// validateRegistryURL requires an HTTPS URL with no userinfo, query, fragment, +// or port; a valid lowercase RFC 1123 host; and an exact `/javascript` path. It +// returns the host and path used to compose the token key. +func validateRegistryURL(raw string) (host, path string, err error) { + if raw == "" { + return "", "", errors.New("npmrc: policy registry_url is empty") + } + if hasControlBytes(raw) { + return "", "", errors.New("npmrc: policy registry_url contains control characters") + } + // Reject '#' and '?' in the raw string. url.Parse turns a trailing bare '#' + // into an empty Fragment (there is no ForceFragment to catch it the way + // ForceQuery catches a bare '?'), so `.../javascript#` would otherwise slip + // through the Fragment check below and land verbatim in the rendered + // `registry=` line — where an npm INI parser could read '#' as a mid-value + // comment and silently fall back to the default registry. + if strings.ContainsAny(raw, "#?") { + return "", "", errors.New("npmrc: policy registry_url must not contain '#' or '?'") + } + u, perr := url.Parse(raw) + if perr != nil { + return "", "", errors.New("npmrc: policy registry_url is not a valid URL") + } + if u.Scheme != "https" { + return "", "", errors.New("npmrc: policy registry_url must be https") + } + if u.User != nil { + return "", "", errors.New("npmrc: policy registry_url must not contain userinfo") + } + if u.RawQuery != "" || u.ForceQuery { + return "", "", errors.New("npmrc: policy registry_url must not contain a query") + } + if u.Fragment != "" { + return "", "", errors.New("npmrc: policy registry_url must not contain a fragment") + } + if u.Port() != "" { + return "", "", errors.New("npmrc: policy registry_url must not contain a port") + } + host = u.Hostname() + if !isValidHost(host) { + return "", "", errors.New("npmrc: policy registry_url host is not a valid hostname") + } + if u.EscapedPath() != "/javascript" { + return "", "", errors.New("npmrc: policy registry_url path must be /javascript") + } + return host, "/javascript", nil +} + +// isNPMSafe reports whether every byte is in the unquoted npm-INI-safe alphabet +// [A-Za-z0-9._:@/-]. Anything outside it — spaces, quotes, '#', ';', '=', +// '$', control bytes — is rejected rather than escaped; v1 defines no escaping. +func isNPMSafe(s string) bool { + for i := 0; i < len(s); i++ { + c := s[i] + switch { + case c >= 'A' && c <= 'Z': + case c >= 'a' && c <= 'z': + case c >= '0' && c <= '9': + case c == '.' || c == '_' || c == ':' || c == '@' || c == '/' || c == '-': + default: + return false + } + } + return true +} + +func hasControlBytes(s string) bool { + for i := 0; i < len(s); i++ { + if s[i] < 0x20 || s[i] == 0x7f { + return true + } + } + return false +} + +// isValidHost validates a lowercase RFC 1123 hostname. The grammar is checked, +// not an allowlist — dedicated instances use custom domains, so no base domain +// can be pinned agent-side. +func isValidHost(host string) bool { + if host == "" || len(host) > npmrcMaxHostBytes { + return false + } + labels := strings.Split(host, ".") + for _, label := range labels { + if label == "" || len(label) > 63 { + return false + } + for i := 0; i < len(label); i++ { + c := label[i] + switch { + case c >= 'a' && c <= 'z': + case c >= '0' && c <= '9': + case c == '-': + if i == 0 || i == len(label)-1 { + return false + } + default: + return false + } + } + } + return true +} diff --git a/internal/devicepolicy/npmrc_observed_test.go b/internal/devicepolicy/npmrc_observed_test.go new file mode 100644 index 0000000..f1109b9 --- /dev/null +++ b/internal/devicepolicy/npmrc_observed_test.go @@ -0,0 +1,381 @@ +package devicepolicy + +import ( + "encoding/json" + "strings" + "testing" +) + +// --------------------------------------------------------------------------- +// probeNPMRCObserved — MDM verify-only observed bag (pure) +// --------------------------------------------------------------------------- + +// observedStrings decodes an observed bag into plain strings so a test can assert +// values without repeating the JSON quoting at every call site. +func observedStrings(t *testing.T, observed map[string]json.RawMessage) map[string]string { + t.Helper() + out := make(map[string]string, len(observed)) + for k, raw := range observed { + var s string + if err := json.Unmarshal(raw, &s); err != nil { + t.Fatalf("observed[%s] = %s is not a JSON string: %v", k, raw, err) + } + out[k] = s + } + return out +} + +func TestProbeContentNPM_ObservedBag(t *testing.T) { + // A shared tenant token carries no ::dev: suffix — the comparison is on + // the tenant-key prefix, so it still reads match. + const sharedToken = "ssabc123" + const foreignToken = "ssdifferent999::dev:SERIAL123" + const otherRegistry = "https://evil.example/javascript" + + cases := []struct { + name string + content string + present bool + reg string + auth string + }{ + { + name: "marker with matching registry and device token", + content: mdmBlock(), + present: true, reg: stdRegistry, auth: authTokenMatch, + }, + { + name: "shared tenant token without a device serial still matches", + content: npmrcMDMMarker + "\nregistry=" + stdRegistry + "\n" + stdTokenKey + "=" + sharedToken + "\n", + present: true, reg: stdRegistry, auth: authTokenMatch, + }, + { + name: "foreign tenant key mismatches", + content: npmrcMDMMarker + "\nregistry=" + stdRegistry + "\n" + stdTokenKey + "=" + foreignToken + "\n", + present: true, reg: stdRegistry, auth: authTokenMismatch, + }, + { + name: "no token line for the tenant registry is absent", + content: npmrcMDMMarker + "\nregistry=" + stdRegistry + "\n", + present: true, reg: stdRegistry, auth: authTokenAbsent, + }, + { + // The reported registry is what npm would USE, not what the MDM block + // claims — a later line wins, and the backend derives drift from it. + name: "a later registry line is reported as the effective one", + content: mdmBlock() + "registry=" + otherRegistry + "\n", + present: true, reg: otherRegistry, auth: authTokenMatch, + }, + { + // A block pointing elsewhere carries a different _authToken key, so the + // tenant registry has no effective token: absent, plus the registry drift. + name: "a drifted registry reports its own url and an absent token", + content: npmrcMDMMarker + "\nregistry=" + otherRegistry + "\n//evil.example/javascript/:_authToken=" + stdTokenVal + "\n", + present: true, reg: otherRegistry, auth: authTokenAbsent, + }, + { + name: "no mdm marker is not applied", + content: "registry=" + stdRegistry + "\n" + stdTokenKey + "=" + stdTokenVal + "\n", + present: false, + }, + { + name: "empty file is not applied", + content: "", + present: false, + }, + { + // Our own DMG block is not MDM management: the marker search excludes it. + name: "only a dmg-owned block is not mdm applied", + content: block(stdBody), + present: false, + }, + { + name: "an mdm marker planted inside our dmg block does not count", + content: npmrcBeginMarker + "\n" + npmrcMDMMarker + "\nregistry=" + stdRegistry + "\n" + stdTokenKey + "=" + stdTokenVal + "\n" + npmrcEndMarker + "\n", + present: false, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + present, observed, err := probeNPMRCObserved(tc.content, stdBody) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if present != tc.present { + t.Fatalf("present = %v, want %v\ncontent:\n%s", present, tc.present, tc.content) + } + if !tc.present { + if observed != nil { + t.Fatalf("a not-applied read must carry no observed bag, got %v", observed) + } + return + } + got := observedStrings(t, observed) + if len(got) != 3 { + t.Fatalf("observed must carry exactly 3 keys, got %v", got) + } + if got[observedKeyEcosystem] != "npm" { + t.Fatalf("ecosystem = %q, want npm", got[observedKeyEcosystem]) + } + if got[observedKeyRegistryURL] != tc.reg { + t.Fatalf("registry_url = %q, want %q", got[observedKeyRegistryURL], tc.reg) + } + if got[observedKeyAuthTokenStatus] != tc.auth { + t.Fatalf("auth_token_status = %q, want %q", got[observedKeyAuthTokenStatus], tc.auth) + } + }) + } +} + +func TestProbeContentNPM_FailsClosedNotUnapplied(t *testing.T) { + // Constructs we cannot reason about must return an ERROR (→ verification_failed), + // never the clean present=false (→ policy_not_applied). Reporting "nothing is + // managing this file" off a file whose effective config we could not establish + // would be a confident wrong answer. + cases := []struct { + name string + content string + }{ + {"bare CR hides a section from the line split", "[team]\r" + mdmBlock()}, + {"INI section scopes the keys below it", "[team]\n" + mdmBlock()}, + {"single-quoted array key coerces to a registry override", mdmBlock() + `'["registry"]'=https://evil/` + "\n"}, + {"marker present but no effective registry line", npmrcMDMMarker + "\n" + stdTokenKey + "=" + stdTokenVal + "\n"}, + {"two dmg blocks leave a marker range unexcluded", mdmBlock() + block(stdBody) + block(stdBody)}, + // npm folds an array-append line into the same key, so the registry we would + // report is not the one npm resolves. + {"array-append registry poisons the effective value", mdmBlock() + "registry[]=https://evil.example/\n"}, + {"array-append registry above the block poisons it too", "registry[]=https://evil.example/\n" + mdmBlock()}, + {"a quoted array-append key is the same override", mdmBlock() + `"registry[]"=https://evil.example/` + "\n"}, + {"array-append on the token key", mdmBlock() + stdTokenKey + "[]=ssevil\n"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + present, observed, err := probeNPMRCObserved(tc.content, stdBody) + if err == nil { + t.Fatalf("want an error (verification_failed), got present=%v observed=%v", present, observed) + } + if present || observed != nil { + t.Fatalf("a failed read must report nothing, got present=%v observed=%v", present, observed) + } + }) + } +} + +func TestProbeContentNPM_RejectsUnrenderableExpected(t *testing.T) { + // Without a parseable desired block there is no tenant key to compare against, + // so the auth verdict cannot be computed. Fail rather than guess. + if _, _, err := probeNPMRCObserved(mdmBlock(), "registry=only-one-line"); err == nil { + t.Fatal("a non-rendered expected value must error") + } +} + +func TestProbeContentNPM_RefusesCredentialBearingRegistryURL(t *testing.T) { + // The effective registry_url is read off a user-writable file and is about to be + // transmitted. A credential smuggled into the URL, a form an INI/URL parser + // reads differently, or a value that is not a credible registry read at all + // (another scheme, no host, oversize) must never leave the device — even though + // the backend rejects it too. Note http is NOT here: see the transmit test. + for _, url := range []string{ + "https://user:pass@registry-int.stepsecurity.io/javascript", + "ftp://registry-int.stepsecurity.io/javascript", + "file:///etc/passwd", + "https:opaque-no-host", + "https:///javascript", + "https://" + strings.Repeat("a", 2048) + ".example/javascript", + } { + content := npmrcMDMMarker + "\nregistry=" + url + "\n" + stdTokenKey + "=" + stdTokenVal + "\n" + present, observed, err := probeNPMRCObserved(content, stdBody) + if err == nil { + t.Fatalf("registry_url %q must be refused, got present=%v observed=%v", url, present, observed) + } + if strings.Contains(err.Error(), "pass") { + t.Fatalf("the error must not echo the url, got %v", err) + } + } +} + +func TestProbeContentNPM_TransmitsAPlaintextHTTPRegistry(t *testing.T) { + // A device resolving to a plaintext mirror is the most security-relevant drift + // an admin can have, so it must travel as evidence and surface as a registry_url + // diff. Discarding it as malformed would hide exactly the case the verify-only + // channel exists to catch. (The renderer's validateRegistryURL stays https-only + // for the POLICY side, which we compose ourselves.) + const plaintext = "http://registry-int.stepsecurity.io/javascript" + content := npmrcMDMMarker + "\nregistry=" + plaintext + "\n" + stdTokenKey + "=" + stdTokenVal + "\n" + present, observed, err := probeNPMRCObserved(content, stdBody) + if err != nil { + t.Fatalf("a plaintext registry must be transmitted, not refused: %v", err) + } + if !present { + t.Fatal("present = false, want true (drift evidence)") + } + if got := observedStrings(t, observed)[observedKeyRegistryURL]; got != plaintext { + t.Fatalf("registry_url = %q, want the observed %q", got, plaintext) + } +} + +func TestProbeContentNPM_ForeignRegistryTokenArrayStillObserved(t *testing.T) { + // npm consults one token key per registry, so an array-append on some OTHER + // registry's credential cannot perturb the tenant registry or its token. The + // guard is scoped to the key we manage: judging every `:_authToken[]` line would + // turn a device with an ordinary second registry into a verification failure. + content := mdmBlock() + "//npm.pkg.github.com/:_authToken[]=ghtoken\n" + present, observed, err := probeNPMRCObserved(content, stdBody) + if err != nil { + t.Fatalf("another registry's token array must not fail the read: %v", err) + } + if !present { + t.Fatal("present = false, want true") + } + if got := observedStrings(t, observed)[observedKeyRegistryURL]; got == "" { + t.Fatal("registry_url must still be observed") + } +} + +func TestProbeContentNPM_ReportsAWrongButCleanRegistry(t *testing.T) { + // The shape rules the RENDERER enforces (host grammar, no port, an exact + // /javascript path) are deliberately NOT applied to an observed value: a merely + // wrong registry is exactly the drift the backend exists to derive, so it must + // be transmitted, not turned into a verification failure. + const wrong = "https://registry.npmjs.org:8443/some/other/path" + content := npmrcMDMMarker + "\nregistry=" + wrong + "\n" + stdTokenKey + "=" + stdTokenVal + "\n" + present, observed, err := probeNPMRCObserved(content, stdBody) + if err != nil || !present { + t.Fatalf("a wrong-but-clean registry must still be observed, got present=%v err=%v", present, err) + } + if got := observedStrings(t, observed)[observedKeyRegistryURL]; got != wrong { + t.Fatalf("registry_url = %q, want the observed %q", got, wrong) + } +} + +func TestProbeContentNPM_NeverLeaksTheToken(t *testing.T) { + // The observed bag is the only thing that leaves the device, and it must carry + // no token material — not the on-disk token, not the desired tenant key, not a + // hash or prefix of either. Only the verdict. + const onDiskToken = "ssabc123::dev:OTHER-SERIAL" + content := npmrcMDMMarker + "\nregistry=" + stdRegistry + "\n" + stdTokenKey + "=" + onDiskToken + "\n" + present, observed, err := probeNPMRCObserved(content, stdBody) + if err != nil || !present { + t.Fatalf("probe failed: present=%v err=%v", present, err) + } + raw, err := json.Marshal(observed) + if err != nil { + t.Fatal(err) + } + for _, secret := range []string{"ssabc123", onDiskToken, stdTokenVal, "OTHER-SERIAL", "SERIAL123"} { + if strings.Contains(string(raw), secret) { + t.Fatalf("observed leaks %q: %s", secret, raw) + } + } + // The token key names the registry, not the credential, so its presence would be + // a contract violation rather than a leak — the bag is exactly three keys. + if len(observed) != 3 { + t.Fatalf("observed must carry exactly 3 keys, got %s", raw) + } +} + +func TestTenantKeyPrefix(t *testing.T) { + cases := map[string]string{ + "ssabc123::dev:SERIAL123": "ssabc123", + "ssabc123": "ssabc123", + "ssabc123::dev:": "ssabc123", + "": "", + // Only the FIRST separator splits, so a serial that itself contains the + // separator cannot shorten the compared prefix. + "ssabc123::dev:A::dev:B": "ssabc123", + } + for token, want := range cases { + if got := tenantKeyPrefix(token); got != want { + t.Fatalf("tenantKeyPrefix(%q) = %q, want %q", token, got, want) + } + } +} + +func TestHasArrayAppendOverride(t *testing.T) { + // npm's ini reader strips a trailing "[]" AFTER its unsafe() normalization and + // appends into an array under the remaining key, so `registry[]=` and our + // block's `registry=` land in ONE array — both orders leave npm resolving to a + // comma-joined list while a scalar last-wins scan still picks our line. Verified + // against npm 10.9.7. Only keys we manage are judged: an unrelated array config + // must not make the file unusable. + flagged := []string{ + "registry[]=https://evil.example/", + `"registry[]"=https://evil.example/`, + stdTokenKey + "[]=ssevil", + "registry[]=", + } + for _, l := range flagged { + if !hasArrayAppendOverride([]string{l}, stdTokenKey) { + t.Errorf("hasArrayAppendOverride(%q) = false, want true", l) + } + } + clean := []string{ + "registry=https://good.example/javascript", + // npm stores this under the distinct key "registry " — it overrides nothing. + "registry [] = https://evil.example/", + // Array configs we do not manage are none of our business. + "omit[]=dev", + "//other.example/:_authToken=sstoken", + // npm consults only the tenant registry's token key, so an array on some + // other registry's credential cannot perturb ours — refusing over it would + // make a file we can enforce report unenforceable. + "//other.example/:_authToken[]=sstoken", + "# registry[]=https://evil.example/", + "[]=x", + "", + } + for _, l := range clean { + if hasArrayAppendOverride([]string{l}, stdTokenKey) { + t.Errorf("hasArrayAppendOverride(%q) = true, want false", l) + } + } + + // With no parseable desired pair we cannot tell our token key from anyone + // else's, so every token key is judged rather than none. + for _, l := range []string{stdTokenKey + "[]=ssevil", "//other.example/:_authToken[]=x"} { + if !hasArrayAppendOverride([]string{l}, "") { + t.Errorf("hasArrayAppendOverride(%q, \"\") = false, want true", l) + } + } + if hasArrayAppendOverride([]string{"omit[]=dev"}, "") { + t.Error("an unrelated array config must stay clean even with no token key") + } +} + +func TestDMGBlockLines(t *testing.T) { + // One block → its whole range is marked; no block → nothing is marked; more + // than one → refused (managedBlockBounds only finds the first, so a marker + // planted in the second would otherwise read as MDM presence). + lines := strings.Split(strings.TrimRight("cache=x\n"+block(stdBody), "\n"), "\n") + in, err := dmgBlockLines(lines) + if err != nil { + t.Fatal(err) + } + if in[0] { + t.Fatal("a line before the block must not be marked") + } + marked := 0 + for _, m := range in { + if m { + marked++ + } + } + if marked != len(lines)-1 { + t.Fatalf("marked %d of %d lines, want every block line", marked, len(lines)) + } + + none, err := dmgBlockLines([]string{"cache=x", "registry=y"}) + if err != nil { + t.Fatal(err) + } + for i, m := range none { + if m { + t.Fatalf("line %d marked with no block present", i) + } + } + + two := strings.Split(strings.TrimRight(block(stdBody)+block(stdBody), "\n"), "\n") + if _, err := dmgBlockLines(two); !isTargetUnusable(err) { + t.Fatalf("two dmg blocks must fail closed with ErrTargetUnusable, got %v", err) + } +} diff --git a/internal/devicepolicy/npmrc_test.go b/internal/devicepolicy/npmrc_test.go new file mode 100644 index 0000000..b8deb2d --- /dev/null +++ b/internal/devicepolicy/npmrc_test.go @@ -0,0 +1,676 @@ +package devicepolicy + +import ( + "encoding/json" + "errors" + "strings" + "testing" +) + +// Standard fixture policy + serial shared across the pure-layer tests. +const ( + stdSerial = "SERIAL123" + stdPolicyJSON = `{"ecosystem":"npm","registry_url":"https://registry-int.stepsecurity.io/javascript","auth":{"scheme":"stepsecurity_device_token","api_key":"ssabc123"}}` + stdBody = "registry=https://registry-int.stepsecurity.io/javascript\n//registry-int.stepsecurity.io/javascript/:_authToken=ssabc123::dev:SERIAL123" + stdRegistry = "https://registry-int.stepsecurity.io/javascript" + stdTokenKey = "//registry-int.stepsecurity.io/javascript/:_authToken" + stdTokenVal = "ssabc123::dev:SERIAL123" +) + +// block wraps a rendered body in the managed markers exactly as the writer does. +func block(body string) string { + return npmrcBeginMarker + "\n" + body + "\n" + npmrcEndMarker + "\n" +} + +// --------------------------------------------------------------------------- +// RenderNPMRCBlock — validation table +// --------------------------------------------------------------------------- + +func TestRenderNPMRCBlock_Valid(t *testing.T) { + got, err := RenderNPMRCBlock(json.RawMessage(stdPolicyJSON), stdSerial) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != stdBody { + t.Fatalf("rendered body mismatch:\n got: %q\nwant: %q", got, stdBody) + } + // The rendered body is exactly two content lines, no markers, no trailing + // newline. + if strings.Contains(got, npmrcBeginMarker) || strings.Contains(got, npmrcEndMarker) { + t.Fatalf("rendered body must not contain markers: %q", got) + } + if strings.HasSuffix(got, "\n") { + t.Fatalf("rendered body must not end in a newline: %q", got) + } + if lines := strings.Split(got, "\n"); len(lines) != 2 { + t.Fatalf("rendered body must be two lines, got %d", len(lines)) + } +} + +func TestRenderNPMRCBlock_Rejections(t *testing.T) { + base := func(mut func(m map[string]any)) json.RawMessage { + m := map[string]any{ + "ecosystem": "npm", + "registry_url": stdRegistry, + "auth": map[string]any{ + "scheme": "stepsecurity_device_token", + "api_key": "ssabc123", + }, + } + if mut != nil { + mut(m) + } + b, _ := json.Marshal(m) + return b + } + setAuth := func(m map[string]any, k string, v any) { + m["auth"].(map[string]any)[k] = v + } + + cases := []struct { + name string + policy json.RawMessage + serial string + }{ + {"not-an-object", json.RawMessage(`["nope"]`), stdSerial}, + {"wrong-ecosystem", base(func(m map[string]any) { m["ecosystem"] = "pip" }), stdSerial}, + {"wrong-scheme", base(func(m map[string]any) { setAuth(m, "scheme", "basic") }), stdSerial}, + {"empty-key", base(func(m map[string]any) { setAuth(m, "api_key", "") }), stdSerial}, + {"oversize-key", base(func(m map[string]any) { setAuth(m, "api_key", strings.Repeat("a", 257)) }), stdSerial}, + {"unsafe-key-space", base(func(m map[string]any) { setAuth(m, "api_key", "ab cd") }), stdSerial}, + {"unsafe-key-hash", base(func(m map[string]any) { setAuth(m, "api_key", "ab#cd") }), stdSerial}, + {"unsafe-key-dollar", base(func(m map[string]any) { setAuth(m, "api_key", "${X}") }), stdSerial}, + {"unsafe-key-newline", base(func(m map[string]any) { setAuth(m, "api_key", "ab\ncd") }), stdSerial}, + {"empty-serial", base(nil), ""}, + {"oversize-serial", base(nil), strings.Repeat("s", 129)}, + {"unsafe-serial", base(nil), "ser ial"}, + {"empty-url", base(func(m map[string]any) { m["registry_url"] = "" }), stdSerial}, + {"http-url", base(func(m map[string]any) { m["registry_url"] = "http://registry-int.stepsecurity.io/javascript" }), stdSerial}, + {"url-with-userinfo", base(func(m map[string]any) { m["registry_url"] = "https://user:pw@registry-int.stepsecurity.io/javascript" }), stdSerial}, + {"url-with-query", base(func(m map[string]any) { m["registry_url"] = "https://registry-int.stepsecurity.io/javascript?x=1" }), stdSerial}, + {"url-with-fragment", base(func(m map[string]any) { m["registry_url"] = "https://registry-int.stepsecurity.io/javascript#f" }), stdSerial}, + {"url-bare-fragment", base(func(m map[string]any) { m["registry_url"] = "https://registry-int.stepsecurity.io/javascript#" }), stdSerial}, + {"url-bare-query", base(func(m map[string]any) { m["registry_url"] = "https://registry-int.stepsecurity.io/javascript?" }), stdSerial}, + {"url-control-byte", base(func(m map[string]any) { m["registry_url"] = "https://registry-int.stepsecurity.io/java\x00script" }), stdSerial}, + {"url-with-port", base(func(m map[string]any) { m["registry_url"] = "https://registry-int.stepsecurity.io:8443/javascript" }), stdSerial}, + {"url-wrong-path", base(func(m map[string]any) { m["registry_url"] = "https://registry-int.stepsecurity.io/py" }), stdSerial}, + {"url-trailing-slash-path", base(func(m map[string]any) { m["registry_url"] = "https://registry-int.stepsecurity.io/javascript/" }), stdSerial}, + {"url-uppercase-host", base(func(m map[string]any) { m["registry_url"] = "https://Registry-Int.StepSecurity.io/javascript" }), stdSerial}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := RenderNPMRCBlock(tc.policy, tc.serial) + if err == nil { + t.Fatalf("expected rejection, got nil error") + } + // Error messages never echo the key material. + if strings.Contains(err.Error(), "ssabc123") || strings.Contains(err.Error(), "${X}") { + t.Fatalf("error message leaked key material: %v", err) + } + }) + } +} + +// --------------------------------------------------------------------------- +// rewriteContent — the §3 rewrite algorithm (pure []byte -> []byte) +// --------------------------------------------------------------------------- + +func rewrite(t *testing.T, current string) string { + t.Helper() + w := &NPMRCWriter{} + out, err := w.rewriteContent([]byte(current), stdBody) + if err != nil { + t.Fatalf("rewriteContent(%q): %v", current, err) + } + return string(out) +} + +func TestRewrite_Table(t *testing.T) { + cases := []struct { + name string + current string + want string + }{ + { + name: "empty file creates block only", // edge 1 (content) + current: "", + want: block(stdBody), + }, + { + name: "no registry lines appends block", // edge 2 + current: "cache=/tmp/x\n", + want: "cache=/tmp/x\n" + block(stdBody), + }, + { + name: "bare registry commented out", // edge 3 + current: "registry=https://registry.npmjs.org/\n", + want: "# [stepsecurity-dmg] registry=https://registry.npmjs.org/\n" + block(stdBody), + }, + { + name: "scoped registry / token / cooldown preserved", // edge 4 + current: "@acme:registry=https://acme.jfrog.io/\n//acme.jfrog.io/:_authToken=xyz\nmin-release-age=7\n", + want: "@acme:registry=https://acme.jfrog.io/\n//acme.jfrog.io/:_authToken=xyz\nmin-release-age=7\n" + block(stdBody), + }, + { + name: "already prefixed line not double prefixed", // edge 5 + current: "# [stepsecurity-dmg] registry=https://registry.npmjs.org/\n", + want: "# [stepsecurity-dmg] registry=https://registry.npmjs.org/\n" + block(stdBody), + }, + { + name: "registry appended below block is re-commented, block stays last", // edge 6 + current: block(stdBody) + "registry=https://evil/\n", + want: "# [stepsecurity-dmg] registry=https://evil/\n" + block(stdBody), + }, + { + name: "missing END stripped to EOF", // edge 10 + current: "foo\n" + npmrcBeginMarker + "\nregistry=stale\n", + want: "foo\n" + block(stdBody), + }, + { + name: "env-ref token line preserved", // edge 13 + current: "//host/:_authToken=${NPM_TOKEN}\n", + want: "//host/:_authToken=${NPM_TOKEN}\n" + block(stdBody), + }, + { + name: "no trailing newline gets one before block", // edge 34 + current: "foo", + want: "foo\n" + block(stdBody), + }, + { + name: "pre-existing blank lines preserved", // edge 34 + current: "foo\n\n\n", + want: "foo\n\n\n" + block(stdBody), + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := rewrite(t, tc.current); got != tc.want { + t.Fatalf("rewrite mismatch:\n current: %q\n got: %q\n want: %q", tc.current, got, tc.want) + } + }) + } +} + +func TestRewrite_CRLFPreserved(t *testing.T) { // edge 11 + current := "cache=x\r\nregistry=y\r\n" + got := rewrite(t, current) + want := "cache=x\r\n# [stepsecurity-dmg] registry=y\r\n" + block(stdBody) + if got != want { + t.Fatalf("CRLF rewrite mismatch:\n got: %q\nwant: %q", got, want) + } +} + +func TestRewrite_BOMPreserved(t *testing.T) { // edge 38 + current := "\ufeff" + "registry=x\n" + got := rewrite(t, current) + if !strings.HasPrefix(got, "\ufeff") { + t.Fatalf("BOM not preserved at start: %q", got) + } + if !strings.Contains(got, "# [stepsecurity-dmg] registry=x\n") { + t.Fatalf("BOM file registry not commented (BOM must not glue to key): %q", got) + } + if strings.Count(got, "\ufeff") != 1 { + t.Fatalf("BOM should appear exactly once: %q", got) + } +} + +func TestRewrite_SectionFailsClosed(t *testing.T) { // edge 37 + w := &NPMRCWriter{} + _, err := w.rewriteContent([]byte("[global]\nregistry=x\n"), stdBody) + if err == nil { + t.Fatal("expected a section header to fail closed") + } + if !isTargetUnusable(err) { + t.Fatalf("section rewrite error should be ErrTargetUnusable, got %v", err) + } +} + +func TestStripManagedBlock_RemovesAllDuplicates(t *testing.T) { + // Two complete managed blocks (a user copy, or a partial prior write) must both + // be stripped — the pure guarantee behind offboarding revoking EVERY token and + // a rewrite never oscillating between one block and two. + two := block(stdBody) + block(stdBody) + lines := strings.Split(strings.TrimRight(two, "\n"), "\n") + out, toEOF := stripManagedBlock(lines) + if toEOF { + t.Fatal("two well-formed blocks must not report a truncated (EOF) strip") + } + for _, l := range out { + if isMarkerLine(l, npmrcBeginMarker) || isMarkerLine(l, npmrcEndMarker) { + t.Fatalf("a managed marker survived stripping all blocks: %q", out) + } + } +} + +func TestRewrite_CollapsesDuplicateBlocks(t *testing.T) { + // A file carrying two managed blocks rewrites to exactly one clean block — + // otherwise Converged's single-block requirement would loop forever. + got := rewrite(t, block(stdBody)+block(stdBody)) + if n := strings.Count(got, npmrcBeginMarker); n != 1 { + t.Fatalf("expected exactly one block after rewrite, got %d:\n%s", n, got) + } + if got != block(stdBody) { + t.Fatalf("rewrite of duplicate blocks = %q, want a single clean block %q", got, block(stdBody)) + } +} + +func TestRewrite_Idempotent(t *testing.T) { // edge 15 (content) + fixtures := []string{ + "", + "registry=https://registry.npmjs.org/\n", + "@acme:registry=https://acme.jfrog.io/\nmin-release-age=7\n", + "foo", + "foo\n\n\n", + "\ufeffregistry=x\n", + "cache=x\r\nregistry=y\r\n", + } + for _, f := range fixtures { + first := rewrite(t, f) + second := rewrite(t, first) + if first != second { + t.Fatalf("not idempotent for %q:\nfirst: %q\nsecond: %q", f, first, second) + } + } +} + +// --------------------------------------------------------------------------- +// clearContent — the clear transform (pure bytes in, bytes out) +// --------------------------------------------------------------------------- + +func clearOf(t *testing.T, current string) string { + t.Helper() + w := &NPMRCWriter{} + out, err := w.clearContent([]byte(current)) + if err != nil { + t.Fatalf("clearContent: %v", err) + } + return string(out) +} + +func TestClear_RestoresAndPreserves(t *testing.T) { // edge 9 + // A file the writer previously converged: original registry commented, MDM + // line present, our block at the bottom. + current := "# [stepsecurity-dmg] registry=https://registry.npmjs.org/\n" + + "# [stepsecurity] registry=https://mdm/\n" + + block(stdBody) + got := clearOf(t, current) + want := "registry=https://registry.npmjs.org/\n# [stepsecurity] registry=https://mdm/\n" + if got != want { + t.Fatalf("clear mismatch:\n got: %q\nwant: %q", got, want) + } +} + +func TestClear_ShellOnlyBlockRemoved(t *testing.T) { // edge 24 + current := npmrcBeginMarker + "\n" + npmrcEndMarker + "\n" + if got := clearOf(t, current); got != "" { + t.Fatalf("shell-only block should clear to empty, got %q", got) + } +} + +func TestClear_NeverUnprefixesMDM(t *testing.T) { + current := "# [stepsecurity] registry=https://mdm/\n" + block(stdBody) + got := clearOf(t, current) + if !strings.Contains(got, "# [stepsecurity] registry=https://mdm/\n") { + t.Fatalf("clear must not un-comment the MDM lane's prefix: %q", got) + } +} + +func TestClear_MissingFinalNewlineNotRestored(t *testing.T) { // edge 34 (clear) + // Enforce turned "foo" (no trailing newline) into "foo\n". Clearing + // keeps the "\n" enforce added — the one permitted byte deviation. + enforced := rewrite(t, "foo") + got := clearOf(t, enforced) + if got != "foo\n" { + t.Fatalf("clear should leave %q, got %q", "foo\n", got) + } +} + +// --------------------------------------------------------------------------- +// INI classifier + shared-consumer behavior +// --------------------------------------------------------------------------- + +func TestActiveKV(t *testing.T) { + cases := []struct { + line string + key string + val string + ok bool + }{ + {"registry=https://x/", "registry", "https://x/", true}, + {"registry = https://x/", "registry", "https://x/", true}, // spaced + {"registry\t=\tx", "registry", "x", true}, // tabbed + {"@acme:registry=https://y/", "@acme:registry", "https://y/", true}, + {`always-auth="true"`, "always-auth", "true", true}, // quoted value + // npm parity: npm's unsafe() strips an unescaped inline ';'/'#' comment and + // unquotes a fully quoted token on BOTH key and value, so each of these is an + // active `registry` assignment to npm — and must be to us, or a disguised + // override slips past last-wins. + {"registry#ignored=https://evil/", "registry", "https://evil/", true}, + {`"registry"=https://evil/`, "registry", "https://evil/", true}, + {`'registry'=https://evil/`, "registry", "https://evil/", true}, + {`"a\qb"=v`, `"a\qb"`, "v", true}, // invalid JSON escape → npm keeps the quoted form + {"registry ; note=https://evil/", "registry", "https://evil/", true}, // comment in key portion + {"registry=https://evil/ # trailing", "registry", "https://evil/", true}, + {`registry\#x=v`, "registry#x", "v", true}, // escaped '#' is literal, not a comment + {"# registry=commented", "", "", false}, + {"; registry=commented", "", "", false}, + {" # indented comment", "", "", false}, + {"[section]", "", "", false}, + {"", "", "", false}, + {"noequalsline", "", "", false}, + {"=noKey", "", "", false}, + } + for _, tc := range cases { + key, val, ok := activeKV(tc.line) + if ok != tc.ok || key != tc.key || val != tc.val { + t.Fatalf("activeKV(%q) = (%q,%q,%v), want (%q,%q,%v)", tc.line, key, val, ok, tc.key, tc.val, tc.ok) + } + } +} + +func TestActiveKV_DoubleQuotedJSONEscapeDecodes(t *testing.T) { + // npm's ini unsafe() runs JSON.parse on a double-quoted token, so a \uXXXX + // escape resolves: the on-disk key `"registry"` (i = 'i') is the key + // `registry` to npm. Our classifier must JSON-decode it too, or the override is + // missed and Converged/probe report a false compliant/managed. The escape is + // assembled from a literal backslash so the on-disk bytes are unambiguous. + bs := "\\" // one backslash + line := `"reg` + bs + `u0069stry"=https://evil/` + if !strings.Contains(line, "u0069") { // guard: prove it is the ESCAPED form, not plain "registry" + t.Fatalf("test did not build the escaped form: %q", line) + } + key, val, ok := activeKV(line) + if !ok || key != "registry" || val != "https://evil/" { + t.Fatalf("activeKV(%q) = (%q,%q,%v), want (registry, https://evil/, true)", line, key, val, ok) + } + // The effectiveness + precedence consumers must treat it as a real override. + blk := strings.Split(strings.TrimRight(block(stdBody)+line+"\n", "\n"), "\n") + if blockIsLastEffective(blk, stdBody) { + t.Fatal("a \\u-escaped registry override must defeat blockIsLastEffective") + } + if managed, _ := probeNPMRCContent(mdmBlock()+line+"\n", stdBody); managed { + t.Fatal("a \\u-escaped registry override must prevent a managed probe") + } +} + +// TestSharedClassifier_SpacedForms proves one INI classifier backs the +// consumers that must see npm's whitespace-tolerant key matching: comment-out +// (rewrite), precedence (probe), and round-trip restore (clear). +func TestSharedClassifier_SpacedForms(t *testing.T) { + for _, spaced := range []string{"registry = https://evil/", "registry\t=\thttps://evil/"} { + // rewrite comments out the spaced active registry line. + out := rewrite(t, spaced+"\n") + if !strings.Contains(out, npmrcDMGPrefix+spaced+"\n") { + t.Fatalf("spaced registry %q was not commented out:\n%s", spaced, out) + } + // clear restores it exactly (literal prefix strip, spacing intact). + if got := clearOf(t, out); got != spaced+"\n" { + t.Fatalf("clear did not restore spaced form: got %q want %q", got, spaced+"\n") + } + // probe precedence: the same spaced line below an MDM block defeats + // effectiveness (a later bare registry overrides). + content := npmrcMDMMarker + "\nregistry=" + stdRegistry + "\n" + stdTokenKey + "=" + stdTokenVal + "\n" + spaced + "\n" + if managed, _ := probeNPMRCContent(content, stdBody); managed { + t.Fatalf("probe must not report managed when a spaced registry override follows: %q", spaced) + } + // Converged's effectiveness check (the 4th consumer) sees the spaced form + // too: a spaced registry override after our block defeats last-wins. + blk := strings.Split(strings.TrimRight(block(stdBody)+spaced+"\n", "\n"), "\n") + if blockIsLastEffective(blk, stdBody) { + t.Fatalf("blockIsLastEffective must be false when a spaced registry override follows: %q", spaced) + } + } +} + +// TestClassifier_NpmDisguisedOverrideCaught proves the npm-faithful key parsing +// catches an override npm honors but a naive first-'=' split would miss: an +// inline comment or quotes on the key both resolve to `registry` for npm. Both +// the convergence effectiveness check and the MDM precedence probe must treat +// each as a real override and refuse to report the block effective/managed. +func TestClassifier_NpmDisguisedOverrideCaught(t *testing.T) { + for _, override := range []string{ + "registry#ignored=https://evil/", + `"registry"=https://evil/`, + `'registry'=https://evil/`, + "registry = https://evil/ # trailing", + } { + blk := strings.Split(strings.TrimRight(block(stdBody)+override+"\n", "\n"), "\n") + if blockIsLastEffective(blk, stdBody) { + t.Fatalf("blockIsLastEffective must be false when an npm-parsed override follows: %q", override) + } + if managed, _ := probeNPMRCContent(mdmBlock()+override+"\n", stdBody); managed { + t.Fatalf("probe must not report managed when an npm-parsed override follows: %q", override) + } + } +} + +func TestRewrite_LoneCRFailsClosed(t *testing.T) { + // A bare CR (old-Mac line break, or an injected one) is a line separator to npm + // but not to our '\n' split; a section or override hidden behind it must fail + // closed, never be silently mis-parsed. + w := &NPMRCWriter{} + for _, in := range []string{ + "[global]\rregistry=x\n", // section hidden behind a bare CR + "cache=x\rregistry=https://evil/\n", // override hidden behind a bare CR + "foo\r", // trailing bare CR + } { + if _, err := w.rewriteContent([]byte(in), stdBody); !isTargetUnusable(err) { + t.Fatalf("rewriteContent(%q) must fail closed with ErrTargetUnusable, got %v", in, err) + } + } + // CRLF is NOT a lone CR and must still round-trip. + if _, err := w.rewriteContent([]byte("cache=x\r\nregistry=y\r\n"), stdBody); err != nil { + t.Fatalf("CRLF must not be rejected as a bare CR: %v", err) + } +} + +func TestRewrite_CoercibleQuotedKeyFailsClosed(t *testing.T) { + // npm strips single quotes and JSON-parses the inner, coercing a non-string + // (an array) to a string key: `'["registry"]'` becomes the key `registry`. We + // can't cheaply mirror that coercion, so any single-quoted non-string JSON key + // fails closed rather than being silently mis-parsed and missed. + w := &NPMRCWriter{} + for _, in := range []string{ + `'["registry"]'=https://evil/` + "\n", + `'["//registry-int.stepsecurity.io/javascript/:_authToken"]'=evil::dev:X` + "\n", + `'[["registry"]]'=https://evil/` + "\n", + } { + if _, err := w.rewriteContent([]byte(in), stdBody); !isTargetUnusable(err) { + t.Fatalf("rewriteContent(%q) must fail closed with ErrTargetUnusable, got %v", in, err) + } + } + // A single-quoted STRING key is NOT coercible-non-string: npm reads it as the + // plain key, and so do we — it is recognized and commented out, not refused. + for _, in := range []string{`'registry'=https://evil/` + "\n", `'"registry"'=https://evil/` + "\n"} { + out, err := w.rewriteContent([]byte(in), stdBody) + if err != nil { + t.Fatalf("rewriteContent(%q) must not fail closed, got %v", in, err) + } + if !strings.Contains(string(out), npmrcDMGPrefix) { + t.Fatalf("a single-quoted registry key should be commented out, got %q", string(out)) + } + } +} + +func TestRewrite_ArrayAppendOverrideFailsClosed(t *testing.T) { + // npm's ini reader folds `registry[]=` and our block's own `registry=` into ONE + // array, so the effective registry becomes a comma-joined list containing the + // injected value while a last-wins scalar scan still sees our line as the + // winner. Verified against npm 10.9.7: `registry=` + `registry[]=` + // yields ",", and the reverse order yields ",". + // Commenting the line out is not enough (npm arrays are order-independent), so + // the transform refuses the file. + w := &NPMRCWriter{} + for _, in := range []string{ + "registry[]=https://evil.example/\n", + `"registry[]"=https://evil.example/` + "\n", + "//registry-int.stepsecurity.io/javascript/:_authToken[]=ssevil\n", + } { + if _, err := w.rewriteContent([]byte(in), stdBody); !isTargetUnusable(err) { + t.Fatalf("rewriteContent(%q) must fail closed with ErrTargetUnusable, got %v", in, err) + } + } + // Array syntax on a key we do NOT manage is npm-legal config and must not make + // the file unusable — including another registry's token, which npm never + // consults for ours. Nor must the spaced form, which npm stores under the + // distinct key "registry " and which overrides nothing. + for _, in := range []string{ + "omit[]=dev\n", + "//npm.pkg.github.com/:_authToken[]=ghtoken\n", + "registry [] = https://evil.example/\n", + } { + if _, err := w.rewriteContent([]byte(in), stdBody); err != nil { + t.Fatalf("rewriteContent(%q) must not fail closed, got %v", in, err) + } + } +} + +func TestProbeContent_ArrayAppendOverrideNotManaged(t *testing.T) { + // The MDM probe shares the guard: a marker plus matching lines is not proof the + // MDM lane governs npm when an array-append line folds into the same key. + for _, in := range []string{ + mdmBlock() + "registry[]=https://evil.example/\n", + "registry[]=https://evil.example/\n" + mdmBlock(), + } { + if managed, _ := probeNPMRCContent(in, stdBody); managed { + t.Fatalf("probe must fail closed on array-append syntax, got managed for %q", in) + } + } +} + +func TestClear_LoneCRFailsClosed(t *testing.T) { + // A CR-delimited file collapses to one line under the '\n' split, so no marker + // matches and the block is never FOUND. Without this guard clearContent returned + // its input unchanged, Clear reported the nothing-to-do success, and the + // reconciler dropped ownership state while the token stayed on disk — an + // offboarding that silently revoked nothing. + w := &NPMRCWriter{} + crFile := strings.ReplaceAll("cache=x\n"+block(stdBody), "\n", "\r") + out, err := w.clearContent([]byte(crFile)) + if !isTargetUnusable(err) { + t.Fatalf("clearContent on a CR-delimited block must fail closed with ErrTargetUnusable, got err=%v out=%q", err, string(out)) + } + if out != nil { + t.Fatalf("a refused clear must return no bytes, got %q", string(out)) + } + // CRLF is not a lone CR: a real block still clears. + crlf := strings.ReplaceAll(block(stdBody), "\n", "\r\n") + got, err := w.clearContent([]byte(crlf)) + if err != nil { + t.Fatalf("CRLF must not be refused as a bare CR: %v", err) + } + if strings.Contains(string(got), stdTokenVal) { + t.Fatalf("the token must be gone after a CRLF clear, got %q", string(got)) + } +} + +func TestProbeContent_LoneCRNotManaged(t *testing.T) { + // A bare CR that hides a section from our split must not let a probe report + // managed off a marker plus matching lines npm would actually scope out. + if managed, _ := probeNPMRCContent("[team]\r"+mdmBlock(), stdBody); managed { + t.Fatal("probe must fail closed on a bare CR") + } +} + +func TestExtractManagedBody(t *testing.T) { + body, present := extractManagedBody(block(stdBody)) + if !present || body != stdBody { + t.Fatalf("extractManagedBody = (%q,%v), want (%q,true)", body, present, stdBody) + } + // A BEGIN with no END is not a well-formed block. + if _, present := extractManagedBody(npmrcBeginMarker + "\nregistry=x\n"); present { + t.Fatal("a block with no END marker must report not present") + } + if _, present := extractManagedBody("registry=x\n"); present { + t.Fatal("no markers means not present") + } +} + +// --------------------------------------------------------------------------- +// probeNPMRCContent — MDM ownership logic (pure) +// --------------------------------------------------------------------------- + +func mdmBlock() string { + return npmrcMDMMarker + "\nregistry=" + stdRegistry + "\n" + stdTokenKey + "=" + stdTokenVal + "\n" +} + +func TestProbeContent(t *testing.T) { + cases := []struct { + name string + content string + managed bool + }{ + {"managed and effective", mdmBlock(), true}, // edge 8 + {"mdm absorbed our lines, our empty shell present", mdmBlock() + block(""), true}, // edge 16 + {"our shell only, mdm removed", block(""), false}, // edge 17 + {"no mdm marker", "registry=" + stdRegistry + "\n" + stdTokenKey + "=" + stdTokenVal + "\n", false}, + {"planted marker without valid content", npmrcMDMMarker + "\nregistry=https://wrong/\n", false}, // edge 20 + {"stale token under marker", npmrcMDMMarker + "\nregistry=" + stdRegistry + "\n" + stdTokenKey + "=stale::dev:X\n", false}, // edge 21 + {"later bare registry override defeats precedence", mdmBlock() + "registry=https://evil/\n", false}, + {"later token override defeats precedence", mdmBlock() + stdTokenKey + "=evil::dev:X\n", false}, + {"section scopes keys → not managed", "[team]\n" + mdmBlock(), false}, + {"single-quoted array key coerces to a registry override", mdmBlock() + `'["registry"]'=https://evil/` + "\n", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + managed, _ := probeNPMRCContent(tc.content, stdBody) + if managed != tc.managed { + t.Fatalf("probeNPMRCContent managed=%v, want %v\ncontent:\n%s", managed, tc.managed, tc.content) + } + }) + } +} + +func TestProbeContent_MarkerInsideOurBlockIgnored(t *testing.T) { + // A user cannot force mdm_managed by planting the MDM marker inside our own + // block — condition 1 searches only outside it. + content := npmrcBeginMarker + "\n" + npmrcMDMMarker + "\nregistry=" + stdRegistry + "\n" + stdTokenKey + "=" + stdTokenVal + "\n" + npmrcEndMarker + "\n" + if managed, _ := probeNPMRCContent(content, stdBody); managed { + t.Fatal("MDM marker inside our block must not count as MDM-managed") + } +} + +func TestParseExpected(t *testing.T) { + reg, tokKey, tokVal, ok := parseExpected(stdBody) + if !ok || reg != stdRegistry || tokKey != stdTokenKey || tokVal != stdTokenVal { + t.Fatalf("parseExpected = (%q,%q,%q,%v)", reg, tokKey, tokVal, ok) + } + if _, _, _, ok := parseExpected("registry=only-one-line"); ok { + t.Fatal("a single-line body must not parse") + } +} + +// --------------------------------------------------------------------------- +// resolver predicates (pure) +// --------------------------------------------------------------------------- + +func TestSymlinkTargetPredicates(t *testing.T) { + absCases := []string{"/etc/passwd", "/home/u/.npmrc"} + for _, c := range absCases { + if !isAbsSymlinkTarget(c) { + t.Fatalf("isAbsSymlinkTarget(%q) = false, want true", c) + } + } + for _, c := range []string{"dotfiles/npmrc", "../up", "npmrc"} { + if isAbsSymlinkTarget(c) { + t.Fatalf("isAbsSymlinkTarget(%q) = true, want false", c) + } + } + // The GO-2026-4970 trigger: a directory-shaped raw target. + for _, c := range []string{"file/", "dir/.", "."} { + if !endsInSeparatorOrDot(c) { + t.Fatalf("endsInSeparatorOrDot(%q) = false, want true", c) + } + } + for _, c := range []string{"file", "dotfiles/npmrc"} { + if endsInSeparatorOrDot(c) { + t.Fatalf("endsInSeparatorOrDot(%q) = true, want false", c) + } + } +} + +// isTargetUnusable mirrors the reconciler's future structural-error +// classification. +func isTargetUnusable(err error) bool { + return errors.Is(err, ErrTargetUnusable) +} diff --git a/internal/devicepolicy/npmrc_unix.go b/internal/devicepolicy/npmrc_unix.go new file mode 100644 index 0000000..8f1b9f9 --- /dev/null +++ b/internal/devicepolicy/npmrc_unix.go @@ -0,0 +1,46 @@ +//go:build unix + +package devicepolicy + +import ( + "errors" + "os" + "syscall" +) + +// enforcePOSIXMetadata gates every mode/owner decision in the writer. On Unix +// the writer asserts mode 0600 and chowns the file to the target user; the +// Windows counterpart leaves both to the platform's ACL model. +const enforcePOSIXMetadata = true + +// nonblockOpenFlag adds O_NONBLOCK to the leaf open so that if the entry was +// swapped for a FIFO between the pre-screen Lstat and the open, the open returns +// immediately instead of blocking the daemon before the regular-file check can +// run. It is harmless on a regular file. +func nonblockOpenFlag() int { return syscall.O_NONBLOCK } + +// chownHandle sets ownership on an already-open handle (fchown), never by path, +// so the operation cannot be redirected through a swapped symlink. +func chownHandle(f *os.File, uid, gid int) error { return f.Chown(uid, gid) } + +// interactiveSessionOK is the Windows-only session guard. On Unix the console +// user was already resolved by the executor, so there is nothing further to +// gate here. +func interactiveSessionOK() bool { return true } + +func newOwnerReader() ownerReader { return unixOwnerReader{} } + +// unixOwnerReader reads the owning uid/gid from an open handle's stat. +type unixOwnerReader struct{} + +func (unixOwnerReader) ownerUIDGID(f *os.File) (uid, gid uint32, enforced bool, err error) { + fi, serr := f.Stat() + if serr != nil { + return 0, 0, true, serr + } + st, ok := fi.Sys().(*syscall.Stat_t) + if !ok { + return 0, 0, true, errors.New("npmrc: file handle has no unix owner metadata") + } + return st.Uid, st.Gid, true, nil +} diff --git a/internal/devicepolicy/npmrc_unix_test.go b/internal/devicepolicy/npmrc_unix_test.go new file mode 100644 index 0000000..7d74b81 --- /dev/null +++ b/internal/devicepolicy/npmrc_unix_test.go @@ -0,0 +1,799 @@ +//go:build unix + +package devicepolicy + +import ( + "os" + "path/filepath" + "strings" + "syscall" + "testing" +) + +// fakeOwner is an injectable ownerReader for exercising ownership branches +// without root: it reports whatever uid/gid the test sets, while the file's +// real mode is still read from disk. +type fakeOwner struct { + uid, gid uint32 + enforced bool + err error +} + +func (f fakeOwner) ownerUIDGID(_ *os.File) (uint32, uint32, bool, error) { + return f.uid, f.gid, f.enforced, f.err +} + +// newDiskWriter builds a writer anchored at a real tempdir home. Files created +// there are owned by the test process, so the real ownership reader is used by +// default; tests needing a foreign owner swap w.owners. +func newDiskWriter(t *testing.T, home string) *NPMRCWriter { + t.Helper() + root, err := os.OpenRoot(home) + if err != nil { + t.Fatalf("OpenRoot(%q): %v", home, err) + } + t.Cleanup(func() { _ = root.Close() }) + return &NPMRCWriter{ + home: home, + root: root, + owners: newOwnerReader(), + uid: os.Getuid(), + gid: os.Getgid(), + } +} + +func npmrcPath(home string) string { return filepath.Join(home, ".npmrc") } + +func readFile(t *testing.T, path string) string { + t.Helper() + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %q: %v", path, err) + } + return string(b) +} + +// TestWrite_CreatesFile covers edge 1 and doubles as the os.Root.OpenRoot(".") +// canary for the common direct-.npmrc case. +func TestWrite_CreatesFile(t *testing.T) { + home := t.TempDir() + w := newDiskWriter(t, home) + + body, err := w.Write(stdBody) + if err != nil { + t.Fatalf("Write: %v", err) + } + if body != stdBody { + t.Fatalf("readback body = %q, want %q", body, stdBody) + } + got := readFile(t, npmrcPath(home)) + if got != block(stdBody) { + t.Fatalf("file content = %q, want %q", got, block(stdBody)) + } + fi, err := os.Stat(npmrcPath(home)) + if err != nil { + t.Fatalf("stat: %v", err) + } + if fi.Mode().Perm() != 0o600 { + t.Fatalf("mode = %v, want 0600", fi.Mode().Perm()) + } +} + +func TestWrite_ThenConvergedTrue(t *testing.T) { // edge 15 on disk + home := t.TempDir() + w := newDiskWriter(t, home) + if _, err := w.Write(stdBody); err != nil { + t.Fatalf("Write: %v", err) + } + first := readFile(t, npmrcPath(home)) + + conv, err := w.Converged(stdBody) + if err != nil { + t.Fatalf("Converged: %v", err) + } + if !conv { + t.Fatal("expected Converged=true after a fresh write") + } + // A second write is byte-identical. + if _, err := w.Write(stdBody); err != nil { + t.Fatalf("second Write: %v", err) + } + if second := readFile(t, npmrcPath(home)); second != first { + t.Fatalf("second write not idempotent:\n%q\n%q", first, second) + } +} + +func TestConverged_FalseOnLooseMode(t *testing.T) { // edge 18 + home := t.TempDir() + w := newDiskWriter(t, home) + if _, err := w.Write(stdBody); err != nil { + t.Fatalf("Write: %v", err) + } + if err := os.Chmod(npmrcPath(home), 0o644); err != nil { + t.Fatalf("chmod: %v", err) + } + conv, err := w.Converged(stdBody) + if err != nil { + t.Fatalf("Converged: %v", err) + } + if conv { + t.Fatal("expected Converged=false when mode is 0644") + } +} + +func TestConverged_RootOwnedRejected(t *testing.T) { // edge 19 (root-owned refused) + home := t.TempDir() + w := newDiskWriter(t, home) + if _, err := w.Write(stdBody); err != nil { + t.Fatalf("Write: %v", err) + } + // A root-owned direct .npmrc is never one this writer left behind — it always + // chowns its output to the target user. Reading it would disclose potentially + // root-only content into a user-owned backup, so the read fails closed + // (ErrTargetUnusable → write_failed) rather than quietly reporting "not + // converged". + w.owners = fakeOwner{uid: 0, enforced: true} + if _, err := w.Converged(stdBody); !isTargetUnusable(err) { + t.Fatalf("root-owned leaf: want ErrTargetUnusable, got %v", err) + } +} + +func TestConverged_FalseWhenActiveRegistryBelowBlock(t *testing.T) { // edge 27 + home := t.TempDir() + w := newDiskWriter(t, home) + if _, err := w.Write(stdBody); err != nil { + t.Fatalf("Write: %v", err) + } + // `aws codeartifact login`-style append below the block leaves the body + // equal but defeats precedence. + f, err := os.OpenFile(npmrcPath(home), os.O_APPEND|os.O_WRONLY, 0o600) + if err != nil { + t.Fatalf("open append: %v", err) + } + if _, err := f.WriteString("registry=https://evil/\n"); err != nil { + t.Fatalf("append: %v", err) + } + f.Close() + + conv, err := w.Converged(stdBody) + if err != nil { + t.Fatalf("Converged: %v", err) + } + if conv { + t.Fatal("expected Converged=false when an active registry follows the block") + } +} + +func TestForeignOwner_ReadRejected(t *testing.T) { // edge 36 + home := t.TempDir() + if err := os.WriteFile(npmrcPath(home), []byte("registry=x\n"), 0o600); err != nil { + t.Fatalf("seed: %v", err) + } + w := newDiskWriter(t, home) + w.owners = fakeOwner{uid: 99999, enforced: true} + + if _, _, err := w.Read(); !isTargetUnusable(err) { + t.Fatalf("Read of foreign-owned file: want ErrTargetUnusable, got %v", err) + } +} + +func TestClear_RemovesBlockKeepsFile(t *testing.T) { // edge 9 / 24 on disk + home := t.TempDir() + w := newDiskWriter(t, home) + if err := os.WriteFile(npmrcPath(home), []byte("registry=https://registry.npmjs.org/\n"), 0o600); err != nil { + t.Fatalf("seed: %v", err) + } + if _, err := w.Write(stdBody); err != nil { + t.Fatalf("Write: %v", err) + } + if _, err := w.Clear(); err != nil { + t.Fatalf("Clear: %v", err) + } + got := readFile(t, npmrcPath(home)) + if strings.Contains(got, npmrcBeginMarker) { + t.Fatalf("block not removed by Clear: %q", got) + } + if !strings.Contains(got, "registry=https://registry.npmjs.org/\n") { + t.Fatalf("Clear did not restore the commented registry line: %q", got) + } +} + +func TestClear_AbsentFileIsNoOp(t *testing.T) { + home := t.TempDir() + w := newDiskWriter(t, home) + if _, err := w.Clear(); err != nil { + t.Fatalf("Clear on absent file: %v", err) + } + if _, err := os.Stat(npmrcPath(home)); !os.IsNotExist(err) { + t.Fatal("Clear must not create the file") + } +} + +// Clear's bool is what lets a caller describe what happened without weakening the +// unconditional call: the clear must still run when no block is present (a lost +// ownership record must never strand a live one), so "did anything change" cannot +// be inferred from whether it was invoked. All three outcomes are pinned because +// only the true case may be reported as a removal. +func TestClear_ReportsWhetherAnythingChanged(t *testing.T) { + const seed = "registry=https://registry.npmjs.org/\n" + + t.Run("absent file changes nothing", func(t *testing.T) { + home := t.TempDir() + changed, err := newDiskWriter(t, home).Clear() + if err != nil { + t.Fatalf("Clear: %v", err) + } + if changed { + t.Fatal("Clear on an absent file must report changed=false") + } + }) + + t.Run("no managed block changes nothing", func(t *testing.T) { + home := t.TempDir() + if err := os.WriteFile(npmrcPath(home), []byte(seed), 0o600); err != nil { + t.Fatalf("seed: %v", err) + } + changed, err := newDiskWriter(t, home).Clear() + if err != nil { + t.Fatalf("Clear: %v", err) + } + if changed { + t.Fatal("Clear over a file with no managed block must report changed=false") + } + if got := readFile(t, npmrcPath(home)); got != seed { + t.Fatalf("a no-op clear must leave the file byte-identical, got %q", got) + } + }) + + t.Run("live block changes the file", func(t *testing.T) { + home := t.TempDir() + if err := os.WriteFile(npmrcPath(home), []byte(seed), 0o600); err != nil { + t.Fatalf("seed: %v", err) + } + w := newDiskWriter(t, home) + if _, err := w.Write(stdBody); err != nil { + t.Fatalf("Write: %v", err) + } + changed, err := w.Clear() + if err != nil { + t.Fatalf("Clear: %v", err) + } + if !changed { + t.Fatal("Clear that removed a live block must report changed=true") + } + if got := readFile(t, npmrcPath(home)); strings.Contains(got, npmrcBeginMarker) { + t.Fatalf("block not removed: %q", got) + } + }) +} + +func TestRestoreSnapshot(t *testing.T) { + home := t.TempDir() + w := newDiskWriter(t, home) + if err := os.WriteFile(npmrcPath(home), []byte("registry=original\n"), 0o600); err != nil { + t.Fatalf("seed: %v", err) + } + if _, err := w.Write(stdBody); err != nil { + t.Fatalf("Write: %v", err) + } + if err := w.RestoreSnapshot(); err != nil { + t.Fatalf("RestoreSnapshot: %v", err) + } + if got := readFile(t, npmrcPath(home)); got != "registry=original\n" { + t.Fatalf("restore did not revert file: %q", got) + } +} + +func TestRestoreSnapshot_RemovesCreatedFile(t *testing.T) { + home := t.TempDir() + w := newDiskWriter(t, home) + if _, err := w.Write(stdBody); err != nil { // file did not exist before + t.Fatalf("Write: %v", err) + } + if err := w.RestoreSnapshot(); err != nil { + t.Fatalf("RestoreSnapshot: %v", err) + } + if _, err := os.Stat(npmrcPath(home)); !os.IsNotExist(err) { + t.Fatal("restore of a created file should remove it") + } +} + +func TestRestoreSnapshot_NoPending(t *testing.T) { + home := t.TempDir() + w := newDiskWriter(t, home) + if err := w.RestoreSnapshot(); err == nil { + t.Fatal("RestoreSnapshot with no pending snapshot must error") + } +} + +func TestSymlink_RelativeInHomeResolved(t *testing.T) { // edge 22 + home := t.TempDir() + if err := os.Mkdir(filepath.Join(home, "dotfiles"), 0o700); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(filepath.Join(home, "dotfiles", "npmrc"), []byte("registry=orig\n"), 0o600); err != nil { + t.Fatalf("seed leaf: %v", err) + } + if err := os.Symlink(filepath.Join("dotfiles", "npmrc"), npmrcPath(home)); err != nil { + t.Fatalf("symlink: %v", err) + } + w := newDiskWriter(t, home) + if _, err := w.Write(stdBody); err != nil { + t.Fatalf("Write via symlink: %v", err) + } + // The chain is preserved: .npmrc is still a symlink. + li, err := os.Lstat(npmrcPath(home)) + if err != nil { + t.Fatalf("lstat: %v", err) + } + if li.Mode()&os.ModeSymlink == 0 { + t.Fatal("symlink was replaced by a regular file") + } + // The block landed at the resolved leaf, and a backup sits beside it. + leaf := readFile(t, filepath.Join(home, "dotfiles", "npmrc")) + if !strings.Contains(leaf, npmrcBeginMarker) { + t.Fatalf("resolved leaf missing block: %q", leaf) + } + entries, _ := os.ReadDir(filepath.Join(home, "dotfiles")) + backups := 0 + for _, e := range entries { + if strings.HasPrefix(e.Name(), "npmrc.dmg-") && strings.HasSuffix(e.Name(), ".bak") { + backups++ + } + } + if backups == 0 { + t.Fatal("expected a backup beside the resolved leaf") + } +} + +func TestSymlink_AbsoluteRejected(t *testing.T) { // edge 30 + home := t.TempDir() + if err := os.Symlink("/etc/hosts", npmrcPath(home)); err != nil { + t.Fatalf("symlink: %v", err) + } + w := newDiskWriter(t, home) + if _, err := w.Write(stdBody); !isTargetUnusable(err) { + t.Fatalf("absolute symlink: want ErrTargetUnusable, got %v", err) + } +} + +func TestSymlink_EscapesHomeRejected(t *testing.T) { // edge 25 + home := t.TempDir() + if err := os.Symlink(filepath.Join("..", "outside"), npmrcPath(home)); err != nil { + t.Fatalf("symlink: %v", err) + } + w := newDiskWriter(t, home) + if _, err := w.Write(stdBody); !isTargetUnusable(err) { + t.Fatalf("escaping symlink: want ErrTargetUnusable, got %v", err) + } +} + +func TestSymlink_SlashTerminatedRejected(t *testing.T) { // GO-2026-4970 regression + home := t.TempDir() + if err := os.WriteFile(filepath.Join(home, "file"), []byte("x"), 0o600); err != nil { + t.Fatalf("seed: %v", err) + } + if err := os.Symlink("file/", npmrcPath(home)); err != nil { + t.Fatalf("symlink: %v", err) + } + w := newDiskWriter(t, home) + if _, err := w.Write(stdBody); !isTargetUnusable(err) { + t.Fatalf("slash-terminated symlink: want ErrTargetUnusable, got %v", err) + } +} + +func TestSymlink_DanglingRejected(t *testing.T) { + home := t.TempDir() + if err := os.Symlink("nonexistent-target", npmrcPath(home)); err != nil { + t.Fatalf("symlink: %v", err) + } + w := newDiskWriter(t, home) + if _, err := w.Write(stdBody); !isTargetUnusable(err) { + t.Fatalf("dangling symlink: want ErrTargetUnusable, got %v", err) + } +} + +func TestNonRegular_FIFORejected(t *testing.T) { // edge 31 (FIFO) + home := t.TempDir() + if err := syscall.Mkfifo(npmrcPath(home), 0o600); err != nil { + t.Skipf("mkfifo unsupported: %v", err) + } + w := newDiskWriter(t, home) + if _, err := w.Write(stdBody); !isTargetUnusable(err) { + t.Fatalf("FIFO leaf: want ErrTargetUnusable, got %v", err) + } +} + +func TestOversizeRejected(t *testing.T) { // edge 31 (size) + home := t.TempDir() + big := make([]byte, npmrcMaxBytes+10) + for i := range big { + big[i] = 'a' + } + if err := os.WriteFile(npmrcPath(home), big, 0o600); err != nil { + t.Fatalf("seed: %v", err) + } + w := newDiskWriter(t, home) + if _, err := w.Write(stdBody); !isTargetUnusable(err) { + t.Fatalf("oversize file: want ErrTargetUnusable, got %v", err) + } +} + +func TestProbeExpected_OnDisk(t *testing.T) { // edge 8 + metadata gate + home := t.TempDir() + if err := os.WriteFile(npmrcPath(home), []byte(mdmBlock()), 0o600); err != nil { + t.Fatalf("seed: %v", err) + } + w := newDiskWriter(t, home) + if managed, _ := w.ProbeExpected(stdBody); !managed { + t.Fatal("expected ProbeExpected=true for an effective 0600 MDM block") + } + // Loose metadata must not freeze the file as managed. + if err := os.Chmod(npmrcPath(home), 0o644); err != nil { + t.Fatalf("chmod: %v", err) + } + if managed, _ := w.ProbeExpected(stdBody); managed { + t.Fatal("ProbeExpected must reject an MDM block with loose (0644) metadata") + } +} + +func TestBackup_ModeAndRotation(t *testing.T) { // edge 28 + rotation cap + home := t.TempDir() + if err := os.WriteFile(npmrcPath(home), []byte("registry=seed\n"), 0o600); err != nil { + t.Fatalf("seed: %v", err) + } + w := newDiskWriter(t, home) + for i := 0; i < 5; i++ { + if _, err := w.Write(stdBody); err != nil { + t.Fatalf("Write %d: %v", i, err) + } + } + entries, _ := os.ReadDir(home) + var backups []os.DirEntry + for _, e := range entries { + if strings.HasPrefix(e.Name(), ".npmrc.dmg-") && strings.HasSuffix(e.Name(), ".bak") { + backups = append(backups, e) + } + } + if len(backups) == 0 || len(backups) > npmrcMaxBackups { + t.Fatalf("expected 1..%d backups, got %d", npmrcMaxBackups, len(backups)) + } + for _, b := range backups { + fi, err := b.Info() + if err != nil { + t.Fatalf("info: %v", err) + } + if fi.Mode().Perm() != 0o600 { + t.Fatalf("backup %q mode = %v, want 0600", b.Name(), fi.Mode().Perm()) + } + } +} + +func TestConverged_SectionFailsClosed(t *testing.T) { + // A [section] header above the block scopes npm's registry key so the block is + // inert. Converged must fail closed (ErrTargetUnusable → write_failed) instead + // of reporting a false 'compliant' on a body-equal but ineffective block. + home := t.TempDir() + w := newDiskWriter(t, home) + if _, err := w.Write(stdBody); err != nil { + t.Fatalf("Write: %v", err) + } + orig := readFile(t, npmrcPath(home)) + if err := os.WriteFile(npmrcPath(home), []byte("[global]\n"+orig), 0o600); err != nil { + t.Fatalf("prepend section: %v", err) + } + if _, err := w.Converged(stdBody); !isTargetUnusable(err) { + t.Fatalf("sectioned file: want ErrTargetUnusable from Converged, got %v", err) + } +} + +func TestConverged_LoneCRFailsClosed(t *testing.T) { + // A bare CR is a line break to npm but not to our '\n' split, so a section or + // override could hide behind it. Converged must fail closed (ErrTargetUnusable + // → write_failed), never report a false 'compliant'. + home := t.TempDir() + w := newDiskWriter(t, home) + if _, err := w.Write(stdBody); err != nil { + t.Fatalf("Write: %v", err) + } + orig := readFile(t, npmrcPath(home)) + // Prepend a bare-CR line npm reads as `[global]` + `x=1` (a section) but our + // '\n' split sees as one opaque line. + if err := os.WriteFile(npmrcPath(home), []byte("[global]\rx=1\n"+orig), 0o600); err != nil { + t.Fatalf("prepend lone CR: %v", err) + } + if _, err := w.Converged(stdBody); !isTargetUnusable(err) { + t.Fatalf("lone-CR file: want ErrTargetUnusable from Converged, got %v", err) + } +} + +func TestConverged_CoercibleQuotedKeyFailsClosed(t *testing.T) { + // A single-quoted non-string JSON key npm coerces to `registry` (e.g. + // '["registry"]') appended below the block could override it invisibly to a + // line-based check. Converged must fail closed (ErrTargetUnusable), never report + // a false 'compliant'. + home := t.TempDir() + w := newDiskWriter(t, home) + if _, err := w.Write(stdBody); err != nil { + t.Fatalf("Write: %v", err) + } + f, err := os.OpenFile(npmrcPath(home), os.O_APPEND|os.O_WRONLY, 0o600) + if err != nil { + t.Fatalf("open append: %v", err) + } + if _, err := f.WriteString(`'["registry"]'=https://evil/` + "\n"); err != nil { + t.Fatalf("append: %v", err) + } + f.Close() + if _, err := w.Converged(stdBody); !isTargetUnusable(err) { + t.Fatalf("coercible quoted key: want ErrTargetUnusable from Converged, got %v", err) + } +} + +func TestClear_RemovesDuplicateBlocks(t *testing.T) { + // Offboarding must revoke EVERY token: a file carrying two managed blocks must + // clear to zero blocks and zero token bytes, not leave the second one live. + home := t.TempDir() + if err := os.WriteFile(npmrcPath(home), []byte(block(stdBody)+block(stdBody)), 0o600); err != nil { + t.Fatalf("seed: %v", err) + } + w := newDiskWriter(t, home) + if _, err := w.Clear(); err != nil { + t.Fatalf("Clear: %v", err) + } + got := readFile(t, npmrcPath(home)) + if strings.Contains(got, npmrcBeginMarker) || strings.Contains(got, stdTokenVal) { + t.Fatalf("clear left a duplicate block or live token behind: %q", got) + } +} + +func TestReadCurrent_LeafSwappedToSymlinkRejected(t *testing.T) { // edge 35 + // The leaf resolves as a regular file, then is swapped for a symlink before the + // bounded read. readCurrent's Lstat pre-screen must reject it as + // ErrTargetUnusable rather than follow the swap to another file. + home := t.TempDir() + if err := os.WriteFile(npmrcPath(home), []byte("registry=x\n"), 0o600); err != nil { + t.Fatalf("seed: %v", err) + } + if err := os.WriteFile(filepath.Join(home, "elsewhere"), []byte("registry=evil\n"), 0o600); err != nil { + t.Fatalf("seed elsewhere: %v", err) + } + w := newDiskWriter(t, home) + rt, err := w.resolveLeaf() + if err != nil { + t.Fatalf("resolveLeaf: %v", err) + } + defer rt.close() + if err := os.Remove(npmrcPath(home)); err != nil { + t.Fatalf("remove leaf: %v", err) + } + if err := os.Symlink("elsewhere", npmrcPath(home)); err != nil { + t.Fatalf("symlink swap: %v", err) + } + if _, _, _, err := w.readCurrent(rt); !isTargetUnusable(err) { + t.Fatalf("swapped-to-symlink leaf: want ErrTargetUnusable, got %v", err) + } +} + +func TestRestoreSnapshot_ConsumedAfterUse(t *testing.T) { + // A snapshot is restored at most once: after a successful restore it is + // consumed, so a second call has nothing to revert and errors. + home := t.TempDir() + w := newDiskWriter(t, home) + if err := os.WriteFile(npmrcPath(home), []byte("registry=original\n"), 0o600); err != nil { + t.Fatalf("seed: %v", err) + } + if _, err := w.Write(stdBody); err != nil { + t.Fatalf("Write: %v", err) + } + if err := w.RestoreSnapshot(); err != nil { + t.Fatalf("first RestoreSnapshot: %v", err) + } + if err := w.RestoreSnapshot(); err == nil { + t.Fatal("second RestoreSnapshot must error — the snapshot is consumed after one use") + } +} + +func TestProbeContentNPM_OnDisk(t *testing.T) { + home := t.TempDir() + w := newDiskWriter(t, home) + + // An absent file is the clean "nothing is managing this" signal, NOT an error: + // the reconciler must report policy_not_applied, not verification_failed. + present, observed, err := w.ProbeContentNPM(stdBody) + if err != nil || present || observed != nil { + t.Fatalf("absent ~/.npmrc = (%v, %v, %v), want (false, nil, nil)", present, observed, err) + } + + if err := os.WriteFile(npmrcPath(home), []byte(mdmBlock()), 0o600); err != nil { + t.Fatalf("seed: %v", err) + } + before, err := os.ReadFile(npmrcPath(home)) + if err != nil { + t.Fatal(err) + } + present, observed, err = w.ProbeContentNPM(stdBody) + if err != nil || !present { + t.Fatalf("an effective MDM block = (%v, %v), want present with no error", present, err) + } + if len(observed) != 3 { + t.Fatalf("observed = %v, want exactly 3 keys", observed) + } + // Verify-only means verify-only: the file is never opened for writing, so not + // one byte moves — no write, no clear, no rollback, no backup. + after, err := os.ReadFile(npmrcPath(home)) + if err != nil { + t.Fatal(err) + } + if string(after) != string(before) { + t.Fatalf("ProbeContentNPM mutated the file:\nbefore:\n%s\nafter:\n%s", before, after) + } + if entries, err := os.ReadDir(home); err != nil { + t.Fatal(err) + } else if len(entries) != 1 { + t.Fatalf("ProbeContentNPM left extra files behind: %v", entries) + } +} + +func TestProbeContentNPM_LooseModeStillObserved(t *testing.T) { + // Deliberate divergence from ProbeExpected, which rejects loose metadata so the + // DMG lane enforces instead. In verify-only mode there is no write to fall back + // to, and perms are not part of the observed contract — so a + // correctly-deployed-but-0644 file must still report its real registry and auth + // status rather than be hidden behind a synthetic failure. + home := t.TempDir() + if err := os.WriteFile(npmrcPath(home), []byte(mdmBlock()), 0o644); err != nil { + t.Fatalf("seed: %v", err) + } + w := newDiskWriter(t, home) + present, observed, err := w.ProbeContentNPM(stdBody) + if err != nil || !present || len(observed) != 3 { + t.Fatalf("a 0644 MDM block = (%v, %v, %v), want it observed", present, observed, err) + } +} + +func TestProbeContentNPM_UnreadableIsAnError(t *testing.T) { + // A file we cannot trust as the target user's own effective config must NOT read + // as the clean policy_not_applied: we could not establish what npm resolves, so + // the honest answer is verification_failed. + home := t.TempDir() + if err := os.WriteFile(npmrcPath(home), []byte(mdmBlock()), 0o600); err != nil { + t.Fatalf("seed: %v", err) + } + w := newDiskWriter(t, home) + w.owners = fakeOwner{uid: uint32(os.Getuid() + 1), enforced: true} + present, observed, err := w.ProbeContentNPM(stdBody) + if !isTargetUnusable(err) { + t.Fatalf("a foreign-owned leaf must error with ErrTargetUnusable, got %v", err) + } + if present || observed != nil { + t.Fatalf("a failed read must report nothing, got present=%v observed=%v", present, observed) + } +} + +func TestClear_PurgesTokenBearingBackups(t *testing.T) { + // Offboarding must leave no readable copy of the token. Every backup after the + // first holds a previous managed block, and the one Clear itself takes holds the + // very block being revoked — so removing the block from the live file while + // leaving those siblings on disk would revoke nothing recoverable. Clear purges + // its own backups once the new bytes are committed. + home := t.TempDir() + if err := os.WriteFile(npmrcPath(home), []byte("registry=https://registry.npmjs.org/\n"), 0o600); err != nil { + t.Fatalf("seed: %v", err) + } + w := newDiskWriter(t, home) + for i := 0; i < 3; i++ { + if _, err := w.Write(stdBody); err != nil { + t.Fatalf("Write %d: %v", i, err) + } + } + if backups := dmgBackups(t, home); len(backups) == 0 { + t.Fatal("expected the writes to leave backups to purge") + } + if _, err := w.Clear(); err != nil { + t.Fatalf("Clear: %v", err) + } + if backups := dmgBackups(t, home); len(backups) != 0 { + t.Fatalf("clear must purge our backups, got %v", backups) + } + // Nothing anywhere beside the leaf may still carry the token. + entries, err := os.ReadDir(home) + if err != nil { + t.Fatal(err) + } + for _, e := range entries { + if e.IsDir() { + continue + } + if strings.Contains(readFile(t, filepath.Join(home, e.Name())), stdTokenVal) { + t.Fatalf("%q still contains the token after clear", e.Name()) + } + } + // The user's own pre-policy line is restored in the live file — the purge + // removes our backups, not the user's content. + if got := readFile(t, npmrcPath(home)); !strings.Contains(got, "registry=https://registry.npmjs.org/") { + t.Fatalf("the user's original registry must be restored, got %q", got) + } +} + +func TestClear_NoOpClearRetriesTheBackupPurge(t *testing.T) { + // The purge is best-effort, so an unlink that failed once must be retried rather + // than stranded: nothing else ever revisits a token-bearing sibling. Both paths + // that clear nothing — a file with no block of ours, and no file at all — reach + // the same residue, so both retry. Unassignment calls Clear on every cycle, which + // is what makes the retry fire. + cases := []struct { + name string + seed func(home string) + }{ + {"a file we do not manage", func(home string) { + if err := os.WriteFile(npmrcPath(home), []byte("registry=https://registry.npmjs.org/\n"), 0o600); err != nil { + t.Fatalf("seed: %v", err) + } + }}, + {"no live file at all", func(string) {}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + home := t.TempDir() + tc.seed(home) + // A backup an earlier purge could not remove, still holding the block. + stale := filepath.Join(home, ".npmrc.dmg-deadbeef.bak") + if err := os.WriteFile(stale, []byte(block(stdBody)), 0o600); err != nil { + t.Fatal(err) + } + w := newDiskWriter(t, home) + if _, err := w.Clear(); err != nil { + t.Fatalf("Clear: %v", err) + } + if _, err := os.Stat(stale); !os.IsNotExist(err) { + t.Fatalf("a later clear must retry the purge, stat err = %v", err) + } + }) + } +} + +func TestClear_LoneCRIsAnErrorAndKeepsTheBlock(t *testing.T) { + // On disk: a CR-delimited managed block cannot be located, so Clear reports an + // error instead of a false success. The reconciler maps that to a failure and + // keeps the ownership record, so a later run retries rather than forgetting a + // token that is still on disk. + home := t.TempDir() + crFile := strings.ReplaceAll("cache=x\n"+block(stdBody), "\n", "\r") + if err := os.WriteFile(npmrcPath(home), []byte(crFile), 0o600); err != nil { + t.Fatalf("seed: %v", err) + } + // A refused clear leaves the block live, so the backup is still the recovery aid + // for it and must survive: only a clear that left nothing of ours purges. + stale := filepath.Join(home, ".npmrc.dmg-deadbeef.bak") + if err := os.WriteFile(stale, []byte(block(stdBody)), 0o600); err != nil { + t.Fatal(err) + } + w := newDiskWriter(t, home) + _, err := w.Clear() + if !isTargetUnusable(err) { + t.Fatalf("Clear must fail closed with ErrTargetUnusable, got %v", err) + } + if got := readFile(t, npmrcPath(home)); got != crFile { + t.Fatalf("a refused clear must leave the file byte-identical, got %q", got) + } + if _, err := os.Stat(stale); err != nil { + t.Fatalf("a refused clear must keep the backup as a recovery aid, stat err = %v", err) + } +} + +// dmgBackups lists the committed backups the writer maintains beside the leaf. +func dmgBackups(t *testing.T, home string) []string { + t.Helper() + entries, err := os.ReadDir(home) + if err != nil { + t.Fatal(err) + } + var out []string + for _, e := range entries { + if strings.HasPrefix(e.Name(), ".npmrc.dmg-") && strings.HasSuffix(e.Name(), ".bak") { + out = append(out, e.Name()) + } + } + return out +} diff --git a/internal/devicepolicy/npmrc_windows.go b/internal/devicepolicy/npmrc_windows.go new file mode 100644 index 0000000..e87b22c --- /dev/null +++ b/internal/devicepolicy/npmrc_windows.go @@ -0,0 +1,152 @@ +//go:build windows + +package devicepolicy + +import ( + "errors" + "os" + "unsafe" + + "golang.org/x/sys/windows" +) + +// enforcePOSIXMetadata is false on Windows: the file is governed by inherited +// NTFS ACLs, so the writer neither asserts a POSIX mode nor chowns. +const enforcePOSIXMetadata = false + +// nonblockOpenFlag is a no-op on Windows — there is no O_NONBLOCK and no +// npm-relevant FIFO to guard against. +func nonblockOpenFlag() int { return 0 } + +// chownHandle is a no-op on Windows (ACL ownership model). +func chownHandle(f *os.File, uid, gid int) error { return nil } + +func newOwnerReader() ownerReader { return windowsOwnerReader{} } + +// windowsOwnerReader reports enforced=false so the writer skips every ownership +// decision on this platform. +type windowsOwnerReader struct{} + +func (windowsOwnerReader) ownerUIDGID(f *os.File) (uid, gid uint32, enforced bool, err error) { + return 0, 0, false, nil +} + +const ( + wtsCurrentServerHandle = 0 + wtsInfoUserName = 5 // WTSUserName + wtsInfoDomainName = 7 // WTSDomainName + sidLocalSystem = "S-1-5-18" +) + +// WTSQuerySessionInformationW is not exposed by golang.org/x/sys/windows, so it +// is bound directly. wtsapi32.dll is always present on Windows. +var ( + wtsapi32 = windows.NewLazySystemDLL("wtsapi32.dll") + procWTSQuerySessionInformationW = wtsapi32.NewProc("WTSQuerySessionInformationW") +) + +// interactiveSessionOK reports whether this process is the interactive user of +// an active session — the only state in which writing ~/.npmrc lands in the +// developer's own profile. It rejects LocalSystem, session 0 (services), any +// non-active (disconnected) session, and alternate-credential (runas) processes +// whose token user differs from the session's logged-on user. A standard +// elevated admin inside their own active session passes both checks. A true +// cross-session resolver (WTSQueryUserToken impersonation) needs SeTcbPrivilege +// and is deliberately out of scope here. +func interactiveSessionOK() bool { + tokenSID, err := currentTokenUserSID() + if err != nil { + return false + } + if tokenSID.String() == sidLocalSystem { + return false + } + + var sessionID uint32 + if err := windows.ProcessIdToSessionId(windows.GetCurrentProcessId(), &sessionID); err != nil { + return false + } + if sessionID == 0 { + // Session 0 is the non-interactive services session; never a developer. + return false + } + + state, ok := sessionConnectState(sessionID) + if !ok || state != uint32(windows.WTSActive) { + return false + } + + sessionSID, err := sessionUserSID(sessionID) + if err != nil { + return false + } + // Session membership alone cannot catch runas: the alternate-credential + // process lives in the caller's session but carries a different user SID. + return tokenSID.String() == sessionSID.String() +} + +func currentTokenUserSID() (*windows.SID, error) { + tu, err := windows.GetCurrentProcessToken().GetTokenUser() + if err != nil { + return nil, err + } + return tu.User.Sid, nil +} + +// sessionConnectState returns the WTS connect state for a session id. +func sessionConnectState(sessionID uint32) (uint32, bool) { + var info *windows.WTS_SESSION_INFO + var count uint32 + if err := windows.WTSEnumerateSessions(0, 0, 1, &info, &count); err != nil { + return 0, false + } + defer windows.WTSFreeMemory(uintptr(unsafe.Pointer(info))) + for _, s := range unsafe.Slice(info, count) { + if s.SessionID == sessionID { + return s.State, true + } + } + return 0, false +} + +// sessionUserSID resolves the SID of the user logged on to a session via its +// WTS user/domain name. Unlike WTSQueryUserToken this needs no SeTcbPrivilege, +// so it works from a normal elevated admin process. +func sessionUserSID(sessionID uint32) (*windows.SID, error) { + name, err := wtsQueryString(sessionID, wtsInfoUserName) + if err != nil { + return nil, err + } + if name == "" { + return nil, errors.New("npmrc: session has no logged-on user") + } + domain, _ := wtsQueryString(sessionID, wtsInfoDomainName) + account := name + if domain != "" { + account = domain + `\` + name + } + sid, _, _, err := windows.LookupSID("", account) + if err != nil { + return nil, err + } + return sid, nil +} + +// wtsQueryString calls WTSQuerySessionInformationW for a string info class and +// returns the decoded value, freeing the buffer the API allocates. +func wtsQueryString(sessionID uint32, infoClass uint32) (string, error) { + var buf *uint16 + var bytesReturned uint32 + r1, _, callErr := procWTSQuerySessionInformationW.Call( + uintptr(wtsCurrentServerHandle), + uintptr(sessionID), + uintptr(infoClass), + uintptr(unsafe.Pointer(&buf)), + uintptr(unsafe.Pointer(&bytesReturned)), + ) + if r1 == 0 { + return "", callErr + } + defer windows.WTSFreeMemory(uintptr(unsafe.Pointer(buf))) + return windows.UTF16PtrToString(buf), nil +} diff --git a/internal/devicepolicy/probe_darwin.go b/internal/devicepolicy/probe_darwin.go index e802eeb..6963e8a 100644 --- a/internal/devicepolicy/probe_darwin.go +++ b/internal/devicepolicy/probe_darwin.go @@ -51,7 +51,13 @@ func probeDarwinManagedPrefs(root string) (bool, string) { // user's VS Code would resolve on the com.microsoft.VSCode domain: the machine- // wide managed-prefs plist overlaid by that user's per-user plist (per-user // wins). It parses the plists (binary or XML) and never globs other users. -func ProbeManagedContent() (bool, map[string]json.RawMessage, error) { +// +// expected (the reconciler's rendered desired value) is unused: the backend +// compares observed VS Code values against desired, so nothing about the desired +// policy is needed to READ the managed ones. It exists because the shared +// ProbeContent seam carries it for probes that must decide part of the verdict +// on-device (see ProbeContentNPM). +func ProbeManagedContent(expected string) (bool, map[string]json.RawMessage, error) { return probeDarwinManagedContent(darwinManagedPrefsDir, currentManagedPrefsUser()) } diff --git a/internal/devicepolicy/probe_linux.go b/internal/devicepolicy/probe_linux.go index 5d0dc71..4c575a1 100644 --- a/internal/devicepolicy/probe_linux.go +++ b/internal/devicepolicy/probe_linux.go @@ -32,8 +32,10 @@ func probeLinuxPolicyFile(path string) (bool, string) { } // ProbeManagedContent reads the values of the Linux VS Code managed-policy file -// for the verify-only path. -func ProbeManagedContent() (bool, map[string]json.RawMessage, error) { +// for the verify-only path. expected is unused — the backend compares observed +// against desired, so reading the managed values needs nothing from the policy +// (see ProbeContentNPM for a probe that does). +func ProbeManagedContent(expected string) (bool, map[string]json.RawMessage, error) { return probeLinuxPolicyContent(linuxPolicyFilePath) } diff --git a/internal/devicepolicy/probe_other.go b/internal/devicepolicy/probe_other.go index 8b8ca7b..05e3f6c 100644 --- a/internal/devicepolicy/probe_other.go +++ b/internal/devicepolicy/probe_other.go @@ -10,4 +10,6 @@ import "encoding/json" // the package compiling on every GOOS. func ProbeManagedPolicy() (bool, string) { return false, "" } -func ProbeManagedContent() (bool, map[string]json.RawMessage, error) { return false, nil, nil } +func ProbeManagedContent(expected string) (bool, map[string]json.RawMessage, error) { + return false, nil, nil +} diff --git a/internal/devicepolicy/probe_windows.go b/internal/devicepolicy/probe_windows.go index 423e224..c4ee2da 100644 --- a/internal/devicepolicy/probe_windows.go +++ b/internal/devicepolicy/probe_windows.go @@ -97,8 +97,10 @@ const ( // ProbeManagedContent reads the VS Code managed-policy values from the registry // for the verify-only path. Each value is resolved with its own HKLM-then-HKCU // fallback (values may live in different hives), reading only the string types -// VS Code's policy watcher honors. -func ProbeManagedContent() (bool, map[string]json.RawMessage, error) { +// VS Code's policy watcher honors. expected is unused — the backend compares +// observed against desired, so reading the managed values needs nothing from the +// policy (see ProbeContentNPM for a probe that does). +func ProbeManagedContent(expected string) (bool, map[string]json.RawMessage, error) { return probeRegistryContent([]registryProbe{ {registry.LOCAL_MACHINE, "HKLM", windowsPolicyKeyPath}, {registry.CURRENT_USER, "HKCU", windowsPolicyKeyPath}, diff --git a/internal/devicepolicy/reconcile.go b/internal/devicepolicy/reconcile.go index abd0cc1..327dd87 100644 --- a/internal/devicepolicy/reconcile.go +++ b/internal/devicepolicy/reconcile.go @@ -45,10 +45,72 @@ type Reconciler struct { // inject a stub so results never depend on the host machine. Probe func() (managed bool, detail string) - // ProbeContent reads the OS-managed VS Code policy values (keyed by setting - // id) for the verify-only path. nil → ProbeManagedContent (the per-OS - // implementation); tests inject a stub. The DMG path never calls it. - ProbeContent func() (present bool, observed map[string]json.RawMessage, err error) + // ProbeContent reads the effective externally-managed configuration for the + // verify-only path and returns it as the observed bag. expected is this + // cycle's rendered desired value: the VS Code probe ignores it (the backend + // compares OS-managed values), while the ~/.npmrc probe needs it to decide + // auth_token_status on-device — the desired tenant key must never leave the + // machine, so only the verdict is reported. nil → the per-OS + // ProbeManagedContent for ide_extension, and an error (→ verification_failed) + // for any other category: falling back to the VS Code probe would report + // another category's policy. The DMG path never calls it. + ProbeContent func(expected string) (present bool, observed map[string]json.RawMessage, err error) + + // OwnershipKey is the WrittenSettings key the single-value paths record + // ownership under (drift, adopt, persist, and the value-based clear all read + // the same one). The managed multi-key path keys by real setting id and + // ignores this. Empty → allowedExtensionsSettingKey, the only key a plain VS + // Code Writer manages; the npm lane sets NPMOwnedKey. + OwnershipKey string + + // The seams below adapt the ladder to a target whose ownership and + // convergence model differs from the single-JSON-key settings.json writer + // (concretely the ~/.npmrc block writer, npmrc.go). EVERY seam nil/false + // reproduces the settings.json behavior byte-for-byte — the IDE wiring sets + // none of them, so its path is unchanged. + + // Converged, when set, REPLACES the generic body-equality idempotency test + // (present && on-disk == desired) with a target-specific full-state check — + // e.g. the ~/.npmrc writer also verifies its block is effective (nothing + // overrides it below) and carries sane metadata (0600, owned by the target + // user). Body equality alone is a hole there: a `registry=` line appended + // below an unchanged block leaves the body equal but defeats precedence. + Converged func(expected string) (bool, error) + + // Render, when set, derives the value to write/compare from the raw policy — + // e.g. rendering the two ~/.npmrc content lines from the npm policy object + // and the device serial. nil → the value is the compacted policy JSON + // (settings.json). A render failure is a malformed backend payload and is + // reported as policy_not_applied. + Render func(policy json.RawMessage) (string, error) + + // ProbeExpected, when set, REPLACES Probe for this cycle and receives the + // rendered value so a content-aware probe can decide whether the MDM lane has + // achieved the SAME desired state (the ~/.npmrc file is user-writable, so a + // bare marker is not proof). nil → Probe. + ProbeExpected func(expected string) (managed bool, detail string) + + // RestoreSnapshot, when set, is the rollback used after a post-write ownership + // persist fails: the writer reverts its whole-file transformation from a + // snapshot and its RESULT is classified — restore succeeded → write_failed + // (the enforce write was undone), restore failed/aborted → verification_failed + // (on-disk state now unknown). nil → the generic best-effort re-write of the + // previous value (always write_failed), which suits a single settings key. + RestoreSnapshot func() error + + // OwnsByMarker switches handleClear from value-based ownership (clear only + // when on-disk still equals the recorded written value) to marker-based: + // always call Writer.Clear() and drop the state record unconditionally. It + // suits a writer whose Clear is intrinsically scoped to its own markers, so a + // lost/drifted/empty record must not strand a token-bearing block on disk. + OwnsByMarker bool + + // WriterInitErr carries a writer-construction failure (Writer is then nil). + // The reconciler classifies it AFTER the fetch: absent policy → silent no-op, + // clear → retain all state (no target to act against), enforce → + // policy_not_applied for ErrNoTargetUser else write_failed. nil with a nil + // Writer is the ordinary unsupported-platform silent no-op. + WriterInitErr error // Now and Logf are optional seams. Now defaults to time.Now().UTC; Logf to a // no-op. @@ -67,6 +129,16 @@ type Reconciler struct { enforcement string } +// readState / persistState / dropState are every category's access to the one +// state file (device-policy-state.json), keyed by (category, target). No category +// has a store of its own: the file's read-modify-write preserves every other +// category and target, and takes a cross-process lock so two agent processes +// reconciling different categories cannot drop each other's record. The +// writeState/clearState test seams inject persist failures. +func (r *Reconciler) readState(cat, tgt string) (AppliedTargetState, bool) { + return ReadAppliedState(cat, tgt) +} + func (r *Reconciler) persistState(cat, tgt string, s AppliedTargetState) error { if r.writeState != nil { return r.writeState(cat, tgt, s) @@ -81,6 +153,81 @@ func (r *Reconciler) dropState(cat, tgt string) error { return ClearAppliedState(cat, tgt) } +// renderValue produces the value to write/compare: the rendered block via the +// Render seam, or the compacted policy JSON for settings.json. +func (r *Reconciler) renderValue(policy json.RawMessage) (string, error) { + if r.Render != nil { + return r.Render(policy) + } + return compactJSON(policy) +} + +// converged answers "is the desired value already fully in place?". With the +// Converged seam it delegates to the writer's full-state check; otherwise it is +// the generic body-equality test over the already-read on-disk value. +func (r *Reconciler) converged(expected, onDisk string, present bool) (bool, error) { + if r.Converged != nil { + return r.Converged(expected) + } + return present && onDisk == expected, nil +} + +// probeExpected reports whether a managed policy already governs this target. +// The content-aware ProbeExpected seam (needs the rendered value) wins; else the +// legacy content-blind Probe. +func (r *Reconciler) probeExpected(expected string) (bool, string) { + if r.ProbeExpected != nil { + return r.ProbeExpected(expected) + } + return r.probe() +} + +// rollback undoes the just-committed write after the post-write ownership +// persist failed, and returns the compliance state the outcome warrants. +// +// With the RestoreSnapshot seam, the writer performs a whole-file transactional +// restore whose success is meaningful: restore succeeded → write_failed (the +// enforce write was cleanly undone); restore failed/aborted (e.g. the resolved +// path moved between write and rollback) → verification_failed (on-disk state is +// now unknown). Without the seam it falls back to the generic best-effort +// re-write of the previous value, which always yields write_failed — correct for +// a single settings key and byte-identical for the IDE path. +func (r *Reconciler) rollback(prevOnDisk string, prevPresent bool) (state string, err error) { + if r.RestoreSnapshot != nil { + if rerr := r.RestoreSnapshot(); rerr != nil { + return StateVerificationFailed, rerr + } + return StateWriteFailed, nil + } + r.rollbackWrite(prevOnDisk, prevPresent) + return StateWriteFailed, nil +} + +// classifyReadError maps a Writer read/convergence error to a compliance state. +// A structural refusal (the target cannot be enforced at all — wraps +// ErrTargetUnusable) is a write-class fact; everything else (permission denied, +// transient I/O) stays verification_failed. The IDE writer never wraps the +// sentinel, so this always returns verification_failed for it. +func classifyReadError(err error) string { + if errors.Is(err, ErrTargetUnusable) { + return StateWriteFailed + } + return StateVerificationFailed +} + +// classifyWriteError maps a Writer.Write / Writer.Clear failure to a compliance +// state. A write that errored is write_failed by default — the value did not take +// effect. The one exception is a writer that landed new bytes it could neither +// verify NOR roll back (ErrWriteUnverified): on-disk state is then indeterminate, +// which is verification_failed, not a clean write failure. The IDE writer never +// returns that sentinel, so this is always write_failed for it. +func classifyWriteError(err error) string { + if errors.Is(err, ErrWriteUnverified) { + return StateVerificationFailed + } + return StateWriteFailed +} + func (r *Reconciler) now() time.Time { if r.Now != nil { return r.Now() @@ -115,11 +262,31 @@ func (r *Reconciler) probe() (bool, string) { return ProbeManagedPolicy() } -func (r *Reconciler) probeContent() (bool, map[string]json.RawMessage, error) { +// probeContent reads the effective externally-managed configuration for the +// verify-only path. The fallback is CATEGORY-AWARE: only ide_extension may fall +// back to the OS policy reader. Any other category with no seam (e.g. an npm +// cycle whose writer could not be constructed, so ProbeContentNPM was never +// bound) errors → verification_failed, an honest "could not verify". Falling +// through to ProbeManagedContent there would report VS Code policy values as an +// npm observation. +func (r *Reconciler) probeContent(expected string) (bool, map[string]json.RawMessage, error) { if r.ProbeContent != nil { - return r.ProbeContent() + return r.ProbeContent(expected) + } + if r.category() == CategoryIDEExtension { + return ProbeManagedContent(expected) + } + return false, nil, fmt.Errorf("devicepolicy: no content probe for %s", r.category()) +} + +// ownershipKey is the WrittenSettings key the single-value paths record +// ownership under. Empty OwnershipKey → the allowlist setting id, the only key a +// plain VS Code Writer manages. +func (r *Reconciler) ownershipKey() string { + if r.OwnershipKey != "" { + return r.OwnershipKey } - return ProbeManagedContent() + return allowedExtensionsSettingKey } // Reconcile runs one enforcement cycle. It NEVER panics into the caller's hot @@ -127,17 +294,23 @@ func (r *Reconciler) probeContent() (bool, map[string]json.RawMessage, error) { // // - fetch error (transport / non-200 / malformed) → NO-OP, error returned. // Enforcement on disk is never wiped on a transient or malformed response. -// - MDM enforcement (ep.Enforcement=="mdm") → verify-only: probe the OS-managed -// policy and report what was observed; never writes or clears settings.json. -// Checked right after fetch, before the gates below, so it runs even with a -// nil Writer or a clear directive. -// - platform not enforceable (nil Writer) → silent no-op. +// - MDM enforcement (ep.Enforcement=="mdm") → verify-only: probe the +// externally-managed configuration and report what was observed; never +// writes, patches, or clears. Checked right after fetch, before the gates +// below, so it runs even with a nil Writer, a WriterInitErr, or a clear +// directive. +// - platform not enforceable (nil Writer, nil WriterInitErr) → silent no-op. +// - writer could not be constructed (nil Writer, WriterInitErr set) → +// classified AFTER the fetch by what run-config asked for: absent → silent; +// clear → no-op retaining ALL state (no resolved target to act against); +// enforce → policy_not_applied (ErrNoTargetUser) or write_failed (other). // - absent policy (run-config carried no `policy` directive for this // category/target) → silent no-op; the on-disk value and ownership record // stand. This is NOT a clear — removal happens only on an explicit clear. -// - clear result → remove ONLY the agent-owned settings key; a value the -// agent has no record of writing is left untouched. No compliance report -// (an unassigned device is backend-derived). +// - clear result → remove ONLY the agent-owned value; a value the agent has no +// record of writing is left untouched (value-based ownership) or the block +// is removed and the record dropped (marker-based ownership, OwnsByMarker). +// No compliance report (an unassigned device is backend-derived). // - policy result → probe → ownership/drift-checked write + readback + // verify + report (handleEnforce). func (r *Reconciler) Reconcile(ctx context.Context) error { @@ -170,8 +343,15 @@ func (r *Reconciler) Reconcile(ctx context.Context) error { } if r.Writer == nil { - r.logf("devicepolicy: no settings path on this platform; skipping (category=%s target=%s)", cat, tgt) - return nil + // No usable writer. Two shapes: an unsupported platform (no init error → + // the long-standing silent skip) or a construction failure (init error → + // classified against the fetched directive, since reporting before the + // fetch would fire even when run-config would have said "no policy"). + if r.WriterInitErr == nil { + r.logf("devicepolicy: no settings path on this platform; skipping (category=%s target=%s)", cat, tgt) + return nil + } + return r.handleNoWriter(ctx, cat, tgt, ep) } if !ep.present() { @@ -188,24 +368,36 @@ func (r *Reconciler) Reconcile(ctx context.Context) error { return r.handleEnforce(ctx, cat, tgt, ep) } -// verifyMDM is the verify-only path: it never opens settings.json (an external -// MDM owns the VS Code policy), probes the OS-managed policy, and reports what -// it saw. It does not compare observed against ep.Policy or decide drift — the -// backend does that on read — and applied_hash is always empty. +// verifyMDM is the verify-only path: it never opens the target file (an external +// MDM owns the configuration), probes what is effective, and reports what it saw. +// It does not compare observed against ep.Policy or decide drift — the backend +// does that on read — and applied_hash is always empty. +// +// The desired value is rendered first and handed to the probe. The VS Code probe +// ignores it; the ~/.npmrc probe needs it to decide auth_token_status on-device, +// because the tenant key must never leave the machine. A render failure means the +// backend payload is malformed, so nothing could be verified against it → +// verification_failed. func (r *Reconciler) verifyMDM(ctx context.Context, cat, tgt string, ep EffectivePolicy) error { if ep.Clear || !ep.present() { r.logf("devicepolicy: mdm enforcement with no active policy for %s/%s; nothing to verify", cat, tgt) return nil } - present, observed, err := r.probeContent() + expected, err := r.renderValue(ep.Policy) + if err != nil { + r.logf("devicepolicy: mdm render desired value failed: %v → verification_failed", err) + return r.sendReport(ctx, ComplianceReport{Category: cat, Target: tgt, State: StateVerificationFailed}) + } + + present, observed, err := r.probeContent(expected) if err != nil { r.logf("devicepolicy: mdm content probe failed: %v → verification_failed", err) return r.sendReport(ctx, ComplianceReport{Category: cat, Target: tgt, State: StateVerificationFailed}) } if !present { - r.logf("devicepolicy: mdm enforcement but no managed VS Code policy present → policy_not_applied") + r.logf("devicepolicy: mdm enforcement but no externally-managed policy present → policy_not_applied") return r.sendReport(ctx, ComplianceReport{Category: cat, Target: tgt, State: StatePolicyNotApplied}) } @@ -224,20 +416,64 @@ func (r *Reconciler) verifyMDM(ctx context.Context, cat, tgt string, ep Effectiv }) } -// handleClear removes the agent-owned settings on unassignment, then drops the -// ownership record. It dispatches on the Writer: a managed multi-key writer -// clears each owned key independently (clearManaged); any other Writer keeps -// the single-key path (clearSingle). Both are ownership-gated: a value the -// agent has no record of writing is left intact. +// handleNoWriter classifies a cycle whose writer could not be constructed +// (WriterInitErr set, Writer nil). It never touches disk or state — there is no +// resolved target user to act against — and decides purely from the fetched +// directive: +// +// - absent policy → silent no-op (nothing to enforce, nothing to clear); +// - clear → no-op that RETAINS every state record. With ErrNoTargetUser there +// is no uid to even select a per-user record, and dropping records blindly +// would erase the bookkeeping that other users still carry a token-bearing +// block pending cleanup. The backend re-sends clear each cycle, so cleanup +// happens when a writer is next constructible (a real user is present); +// - enforce → report why nothing was applied: policy_not_applied when this +// machine state simply has no enforceable target user (ErrNoTargetUser), +// write_failed for any other construction failure (home unresolvable/ +// unopenable — an infrastructure problem worth surfacing louder). +func (r *Reconciler) handleNoWriter(ctx context.Context, cat, tgt string, ep EffectivePolicy) error { + if !ep.present() { + r.logf("devicepolicy: no enforceable target user and run-config carried no policy for %s/%s; nothing to do", cat, tgt) + return nil + } + if ep.Clear { + r.logf("devicepolicy: clear requested for %s/%s but no enforceable target user; retaining all state (cleared when a user is present)", cat, tgt) + return nil + } + state := StateWriteFailed + if errors.Is(r.WriterInitErr, ErrNoTargetUser) { + state = StatePolicyNotApplied + } + _ = r.report(ctx, cat, tgt, state, "") + return fmt.Errorf("devicepolicy: enforce %s/%s: no usable writer: %w", cat, tgt, r.WriterInitErr) +} + +// handleClear removes the agent-owned value on unassignment. Two ownership +// models, selected by OwnsByMarker: +// +// - value-based (default, settings.json): clear the on-disk value ONLY when it +// still equals what the agent last wrote; a value the agent has no record of +// writing — the user's own extensions.allowed predates enforcement, or the +// record was lost — is left intact, and the state record is dropped only +// when one existed. +// - marker-based (OwnsByMarker, ~/.npmrc): handleClearByMarker. +// +// Within the value-based model it dispatches on the Writer: a managed multi-key +// writer clears each owned key independently (clearManaged); any other Writer +// keeps the single-key path (clearSingle). func (r *Reconciler) handleClear(cat, tgt string) error { - prev, hadPrev := ReadAppliedState(cat, tgt) + if r.OwnsByMarker { + return r.handleClearByMarker(cat, tgt) + } + + prev, hadPrev := r.readState(cat, tgt) if mw, ok := r.Writer.(managedSettingsWriter); ok { return r.clearManaged(cat, tgt, prev, hadPrev, mw) } return r.clearSingle(cat, tgt, prev, hadPrev) } -// clearSingle is the single-key unassignment path. It clears the on-disk value +// clearSingle is the single-value unassignment path. It clears the on-disk value // ONLY when it still equals what the agent last wrote (ownership); a value the // agent has no record of writing — the user's own extensions.allowed predates // enforcement, or the record was lost — is left intact. @@ -247,13 +483,17 @@ func (r *Reconciler) clearSingle(cat, tgt string, prev AppliedTargetState, hadPr return fmt.Errorf("devicepolicy: clear: read %s: %w", r.Writer.Location(), err) } - owns := present && prev.WrittenValue != "" && onDisk == prev.WrittenValue + prevWritten := prev.WrittenSettings[r.ownershipKey()] + owns := present && prevWritten != "" && onDisk == prevWritten switch { case owns: - if err := r.Writer.Clear(); err != nil { - return fmt.Errorf("devicepolicy: clear %s: %w", r.Writer.Location(), err) + changed, cerr := r.Writer.Clear() + if cerr != nil { + return fmt.Errorf("devicepolicy: clear %s: %w", r.Writer.Location(), cerr) + } + if changed { + r.logf("devicepolicy: cleared agent-owned policy at %s", r.Writer.Location()) } - r.logf("devicepolicy: cleared agent-owned policy at %s", r.Writer.Location()) case present: // A value the agent did not write — leave it to whoever set it. r.logf("devicepolicy: clear requested but %s holds a value the agent did not write; leaving it", r.Writer.Location()) @@ -307,11 +547,37 @@ func (r *Reconciler) dropClearedState(cat, tgt string, hadPrev bool) error { return nil } -// handleEnforce converges settings.json to the compiled policy and reports. It -// runs the shared head of the ladder (compact the allowlist, then the +// handleClearByMarker removes the managed block regardless of recorded state. +// Ownership is intrinsic to the writer's own markers — its Clear only ever +// removes content between OUR markers and un-prefixes OUR commented lines, never +// anything else — so a value-equality gate is both unnecessary and unsafe here: +// lost or corrupt state, a drifted block, or an empty marker shell would +// otherwise strand a token-bearing block on disk forever after unassignment. +// Clear is called unconditionally (a no-op when there is no block) and the state +// record is dropped UNCONDITIONALLY afterward — a store read that failed or lied +// (no record found) must not leave an orphan behind; Drop is idempotent. +func (r *Reconciler) handleClearByMarker(cat, tgt string) error { + changed, err := r.Writer.Clear() + if err != nil { + return fmt.Errorf("devicepolicy: clear %s: %w", r.Writer.Location(), err) + } + if changed { + r.logf("devicepolicy: cleared managed block at %s", r.Writer.Location()) + } else { + r.logf("devicepolicy: clear requested but %s holds no managed block; nothing to remove", r.Writer.Location()) + } + if err := r.dropState(cat, tgt); err != nil { + return fmt.Errorf("devicepolicy: clear: update state: %w", err) + } + return nil +} + +// handleEnforce converges the target to the compiled policy and reports. It runs +// the shared head of the ladder (render the desired value, then the // managed-policy probe), then dispatches on the Writer: a managed multi-key // writer converges the full SET of managed keys (enforceManaged); any other -// Writer keeps the single-key path (enforceSingle). +// Writer keeps the single-value path (enforceSingle — the VS Code degraded case +// and the ~/.npmrc block writer). The ladder, in order: // // probe (managed policy exists → mdm_managed, never write) // → read current value(s) @@ -322,74 +588,137 @@ func (r *Reconciler) dropClearedState(cat, tgt string, hadPrev bool) error { // → persist ownership on every successful write (rollback if that fails) // → Verify → report (drift upgrades a would-be compliant to drift_detected) func (r *Reconciler) handleEnforce(ctx context.Context, cat, tgt string, ep EffectivePolicy) error { - // Compact every value in the settings map to its canonical comparison form - // (setting id → compacted value): the form used for readback, idempotency, - // and ownership. (The backend's hash still travels verbatim; only the value - // bytes are normalized for comparison.) - desired, err := compactSettings(ep.Policy) + // The value the probe reasons about, and — on the single-value path — the value + // to write: the rendered block (Render seam) or the compacted policy JSON. + // Computed FIRST because the content-aware probe below needs it. (The + // backend's hash still travels verbatim; only the value bytes are normalized + // for comparison.) + newValue, err := r.renderValue(ep.Policy) if err != nil { - // Defensive: the fetcher already validated the settings map decodes, so a - // value that will not compact is a malformed-payload class failure → no-op, - // never write. + if r.Render != nil { + // A malformed backend payload the renderer rejected: nothing was + // applied and nothing will be. Make it visible rather than a silent + // no-op. (The default compactJSON path only fails on bytes the fetcher + // already rejected as a non-object, so it keeps its silent return.) + _ = r.report(ctx, cat, tgt, StatePolicyNotApplied, "") + return fmt.Errorf("devicepolicy: enforce: render policy: %w", err) + } return fmt.Errorf("devicepolicy: enforce: compact policy: %w", err) } - // Managed-policy probe. A policy at the OS policy location outranks user - // settings inside VS Code — writing would be ineffective at best and fight - // the MDM at worst. Yield and report. (Presence of EITHER managed policy - // key yields; see the probe.) - if managed, detail := r.probe(); managed { + // 1. Managed-policy probe. A real managed policy outranks the value the agent + // would write — writing would be ineffective at best and fight the MDM at + // worst. Yield and report. (For VS Code, presence of EITHER managed policy key + // yields; see the probe.) + if managed, detail := r.probeExpected(newValue); managed { r.logf("devicepolicy: managed policy present at %s → mdm_managed (yielding)", detail) return r.report(ctx, cat, tgt, StateMDMManaged, "") } if mw, ok := r.Writer.(managedSettingsWriter); ok { + desired, derr := compactPolicySettings(ep.Policy) + if derr != nil { + // Defensive: the fetcher already validated object shape, so a value that + // will not decode/compact is a malformed-payload class failure → no-op, + // never write. + return fmt.Errorf("devicepolicy: enforce: compact policy: %w", derr) + } return r.enforceManaged(ctx, cat, tgt, ep, desired, mw) } - // Single-key fallback: a plain Writer manages only extensions.allowed; other - // settings need the managed writer. The production settings writer is always - // managed, so this path is the fake/degraded case. - newValue, ok := desired[allowedExtensionsSettingKey] - if !ok { - _ = r.report(ctx, cat, tgt, StateVerificationFailed, "") - return fmt.Errorf("devicepolicy: enforce: settings missing %q for single-key writer", allowedExtensionsSettingKey) - } - if len(desired) > 1 { - // A multi-key policy on a single-key writer enforces only the allowlist; - // surface it so a partial-enforce is never invisible. - r.logf("devicepolicy: single-key writer at %s enforces only %s; %d other setting(s) dropped", - r.Writer.Location(), allowedExtensionsSettingKey, len(desired)-1) + + if r.Render == nil { + // Single-key fallback for a plain settings Writer: it manages only + // extensions.allowed, so the compacted whole-policy object newValue holds is + // not what to write — pick the allowlist value out of the settings map. The + // production settings writer is always managed, so this path is the + // fake/degraded case. A Writer WITH a Render seam (the ~/.npmrc block + // writer) writes its rendered value and never consults the map. + desired, derr := compactPolicySettings(ep.Policy) + if derr != nil { + return fmt.Errorf("devicepolicy: enforce: compact policy: %w", derr) + } + v, ok := desired[allowedExtensionsSettingKey] + if !ok { + _ = r.report(ctx, cat, tgt, StateVerificationFailed, "") + return fmt.Errorf("devicepolicy: enforce: settings missing %q for single-key writer", allowedExtensionsSettingKey) + } + if len(desired) > 1 { + // A multi-key policy on a single-key writer enforces only the allowlist; + // surface it so a partial-enforce is never invisible. + r.logf("devicepolicy: single-key writer at %s enforces only %s; %d other setting(s) dropped", + r.Writer.Location(), allowedExtensionsSettingKey, len(desired)-1) + } + newValue = v } return r.enforceSingle(ctx, cat, tgt, ep, newValue) } -// enforceSingle is the single-key convergence path (any Writer without the -// managed multi-key API). It is unchanged from the original enforce: it manages -// exactly the extensions.allowed key. +// enforceSingle is the single-VALUE convergence path (any Writer without the +// managed multi-key API): the ~/.npmrc block writer, whose whole managed block is +// one opaque value, and the degraded VS Code Writer, which manages exactly the +// extensions.allowed key. Ownership is recorded under one WrittenSettings entry +// keyed by r.ownershipKey(). func (r *Reconciler) enforceSingle(ctx context.Context, cat, tgt string, ep EffectivePolicy, newValue string) error { - // Read the current settings value. - prev, hadPrev := ReadAppliedState(cat, tgt) + ownKey := r.ownershipKey() + + // 2. Read the current value. + prev, hadPrev := r.readState(cat, tgt) onDisk, present, err := r.Writer.Read() if err != nil { - // Couldn't read to decide idempotency/drift → verification_failed. - // This includes an unsalvageable settings.json (not valid JSONC), which - // the writer refuses to touch. - _ = r.report(ctx, cat, tgt, StateVerificationFailed, "") + // Couldn't read to decide idempotency/drift. A structural refusal (the + // target cannot be enforced) is write_failed; a plain unreadable/unparseable + // file is verification_failed. classifyReadError always returns the latter + // for the IDE writer, which never wraps ErrTargetUnusable. + state := classifyReadError(err) + _ = r.report(ctx, cat, tgt, state, "") return fmt.Errorf("devicepolicy: enforce: read %s: %w", r.Writer.Location(), err) } - // Idempotency: the desired policy is already in place and unchanged. No - // write — but still report so the backend sees a fresh evaluation. - if present && onDisk == newValue && prev.AppliedHash == ep.Hash { + // 3. Idempotency: the desired value is already fully in place and the hash is + // unchanged. No write — but still report so the backend sees a fresh + // evaluation. The convergence test is the writer's when the Converged seam is + // set (it also checks effectiveness and metadata), else plain body equality. + converged, cerr := r.converged(newValue, onDisk, present) + if cerr != nil { + // Converged runs its own secure read; a structural refusal there is the + // same write-class fact as an initial read refusal. + state := classifyReadError(cerr) + _ = r.report(ctx, cat, tgt, state, "") + return fmt.Errorf("devicepolicy: enforce: convergence check %s: %w", r.Writer.Location(), cerr) + } + if converged && prev.AppliedHash == ep.Hash { r.logf("devicepolicy: policy already applied (hash unchanged) — no write") return r.report(ctx, cat, tgt, StateCompliant, ep.Hash) } - // Drift: the agent wrote a value before, and what is on disk now is not it - // (edited or removed — typically the user hand-editing settings.json). - // Enforcement means converging it back; the distinct state lets the backend - // surface that it happened. - drifted := hadPrev && prev.WrittenValue != "" && (!present || onDisk != prev.WrittenValue) + // The full-state convergence seam (npm) proves the exact desired block is on + // disk, effective, and correctly owned — a strictly stronger fact than body + // equality — yet the state file does not carry this hash. That happens when our + // record is stale or was removed by hand, or when the cycle that applied it + // resolved a different home for the state file (a root daemon and the user's own + // cycle do not always agree on one). Adopt the on-disk state rather than churn a + // redundant rewrite or misreport it as drift, and report compliant. Best-effort: + // the block is already applied, so a persist hiccup only defers the record one + // cycle. Gated on the Converged seam so the settings.json path (body equality) + // is byte-identical to before. + if converged && r.Converged != nil { + if perr := r.persistState(cat, tgt, AppliedTargetState{ + AppliedHash: ep.Hash, + WrittenSettings: map[string]string{ownKey: newValue}, + FetchedAt: r.now(), + }); perr != nil { + r.logf("devicepolicy: could not adopt already-converged state at %s: %v", r.Writer.Location(), perr) + } + r.logf("devicepolicy: %s already holds the desired block (adopted) — no write", r.Writer.Location()) + return r.report(ctx, cat, tgt, StateCompliant, ep.Hash) + } + + // 4. Drift: the agent wrote a value before, and what is on disk now is not + // it (edited or removed — typically the user hand-editing settings.json). + // Enforcement means converging it back; the distinct state lets the + // backend surface that it happened. + prevWritten := prev.WrittenSettings[ownKey] + drifted := hadPrev && prevWritten != "" && (!present || onDisk != prevWritten) if drifted { r.logf("devicepolicy: %s diverged from the recorded written value → re-applying (drift)", r.Writer.Location()) } @@ -410,7 +739,9 @@ func (r *Reconciler) enforceSingle(ctx context.Context, cat, tgt string, ep Effe // Merge-write + readback. rb, werr := r.Writer.Write(newValue) if werr != nil { - _ = r.report(ctx, cat, tgt, StateWriteFailed, "") + // write_failed by default; verification_failed only when the writer landed + // bytes it could neither verify nor roll back (on-disk state indeterminate). + _ = r.report(ctx, cat, tgt, classifyWriteError(werr), "") return fmt.Errorf("devicepolicy: enforce: write %s: %w", r.Writer.Location(), werr) } readbackMatch := rb == newValue @@ -421,14 +752,18 @@ func (r *Reconciler) enforceSingle(ctx context.Context, cat, tgt string, ep Effe // own value as drift forever. Value-based ownership self-corrects: the // record only takes effect when the on-disk value actually equals it. if err := r.persistState(cat, tgt, AppliedTargetState{ - AppliedHash: ep.Hash, - WrittenValue: newValue, - FetchedAt: r.now(), + AppliedHash: ep.Hash, + WrittenSettings: map[string]string{ownKey: newValue}, + FetchedAt: r.now(), }); err != nil { // The write happened but ownership couldn't be recorded — undo it so no - // unrecorded value is left behind, and report a failed write. - r.rollbackWrite(onDisk, present) - _ = r.report(ctx, cat, tgt, StateWriteFailed, "") + // unrecorded value is left behind. The rollback outcome decides the state: + // cleanly undone → write_failed; restore failed/aborted → verification_failed. + state, rbErr := r.rollback(onDisk, present) + if rbErr != nil { + r.logf("devicepolicy: rollback at %s failed: %v", r.Writer.Location(), rbErr) + } + _ = r.report(ctx, cat, tgt, state, "") return fmt.Errorf("devicepolicy: enforce: update state: %w", err) } r.logf("devicepolicy: wrote policy to %s (readback_match=%v)", r.Writer.Location(), readbackMatch) @@ -457,7 +792,7 @@ func (r *Reconciler) enforceSingle(ctx context.Context, cat, tgt string, ep Effe // (a foreign or absent value is never deleted). No setting id is special-cased, // so a new managed key rides through with no change here. func (r *Reconciler) enforceManaged(ctx context.Context, cat, tgt string, ep EffectivePolicy, desired map[string]string, mw managedSettingsWriter) error { - prev, hadPrev := ReadAppliedState(cat, tgt) + prev, hadPrev := r.readState(cat, tgt) owned := ownedKeys(prev, hadPrev) // 1. Read every key this cycle may touch: the union of the settings map's keys @@ -561,8 +896,7 @@ func (r *Reconciler) enforceManaged(ctx context.Context, cat, tgt string, ep Eff // 9. Persist ownership: every key the agent Set this cycle (i.e. the whole // settings map), keyed by setting id → the exact value written. A Remove or - // preserve key asserts no ownership this cycle (omitted). WrittenValue is the - // single-key path's field and is left untouched here. + // preserve key asserts no ownership this cycle (omitted). ownedAfter := make(map[string]string, len(desired)) for key, v := range desired { ownedAfter[key] = v @@ -601,12 +935,19 @@ func (r *Reconciler) enforceManaged(ctx context.Context, cat, tgt string, ep Eff return r.report(ctx, cat, tgt, state, appliedHash) } -// compactSettings compacts every value in the settings map to its canonical -// comparison form, returning setting id → compacted value. Compaction -// normalizes whitespace so on-disk readback and next-cycle ownership compare -// byte-exactly regardless of the backend's wire formatting; member order within -// each value is preserved (it is the backend's canonical, hashed order). -func compactSettings(settings map[string]json.RawMessage) (map[string]string, error) { +// compactPolicySettings decodes the raw policy object into the VS Code settings +// map (setting id → compiled value) and compacts every value to its canonical +// comparison form. Compaction normalizes whitespace so on-disk readback and +// next-cycle ownership compare byte-exactly regardless of the backend's wire +// formatting; member order within each value is preserved (it is the backend's +// canonical, hashed order). The raw policy travels category-agnostically (npm's +// object is not a settings map), so the decode lives here rather than in the +// fetcher. +func compactPolicySettings(policy json.RawMessage) (map[string]string, error) { + var settings map[string]json.RawMessage + if err := json.Unmarshal(policy, &settings); err != nil { + return nil, fmt.Errorf("devicepolicy: decode settings map: %w", err) + } out := make(map[string]string, len(settings)) for k, raw := range settings { c, err := compactJSON(raw) @@ -664,12 +1005,13 @@ func opConverged(op settingOp, m map[string]settingValue) bool { } // ownedKeys folds an ownership record into a flat map of setting id → the exact -// value the agent last wrote, skipping empty entries. Every managed key — the -// allowlist included — lives in WrittenSettings. Drift detection and -// ownership-gated removal act only on keys the agent actually wrote. The managed -// path reads WrittenSettings only; a legacy single-key WrittenValue is NOT -// migrated in (pre-GA — no shipped single-key VS Code state exists to carry over, -// so there is nothing to be backward-compatible with). +// value the agent last wrote, skipping empty entries. WrittenSettings is the only +// ownership field: every managed key — the allowlist included — and every +// single-value lane's one entry live in it. Drift detection and ownership-gated +// removal act only on keys the agent actually wrote. A pre-collapse state file +// carrying only the retired written_value key decodes to an empty map, i.e. +// "owns nothing", and the next enforce re-converges and re-records (pre-GA — no +// shipped state to migrate). func ownedKeys(prev AppliedTargetState, hadPrev bool) map[string]string { owned := map[string]string{} if !hadPrev { @@ -694,7 +1036,7 @@ func (r *Reconciler) rollbackWrite(prevOnDisk string, prevPresent bool) { if prevPresent { _, err = r.Writer.Write(prevOnDisk) } else { - err = r.Writer.Clear() + _, err = r.Writer.Clear() } if err != nil { r.logf("devicepolicy: rollback at %s failed: %v", r.Writer.Location(), err) diff --git a/internal/devicepolicy/reconcile_npm_test.go b/internal/devicepolicy/reconcile_npm_test.go new file mode 100644 index 0000000..038e947 --- /dev/null +++ b/internal/devicepolicy/reconcile_npm_test.go @@ -0,0 +1,1054 @@ +package devicepolicy + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// The tests here drive the reconciler with the seams the ~/.npmrc path sets +// (Render, Converged, ProbeExpected, RestoreSnapshot, OwnsByMarker). The +// existing reconcile_test.go covers the settings.json path with every seam at +// its zero value; together they show the ladder serves both targets from one +// body — the seams change behavior ONLY when set. + +// --- state fixture ---------------------------------------------------------- + +// npmStore is these tests' handle on the ONE state file (device-policy-state.json, +// redirected to a temp dir for the test). There is no npm-specific store to fake: +// the npm category records ownership under categories.package_config.targets.npm +// through the same ReadAppliedState / WriteAppliedState / ClearAppliedState the +// IDE lane uses, so assertions read the real file back. The counters prove the +// ladder went through the store, and writeErr / dropErr / failWriteFrom inject +// persist failures through the reconciler's writeState / clearState seams — +// failWriteFrom is how a test fails the POST-WRITE persist specifically. +type npmStore struct { + path string + writeErr error + dropErr error + failWriteFrom int // when >0, the Nth persist and every one after it fails + writes int + drops int +} + +func newNPMStore(t *testing.T) *npmStore { + t.Helper() + path := filepath.Join(t.TempDir(), CacheFilename) + t.Cleanup(SetCachePathForTest(path)) + return &npmStore{path: path} +} + +// seed writes a record straight to the file, bypassing the counters: it is the +// state the cycle STARTS from, not something the reconciler did. +func (s *npmStore) seed(t *testing.T, cat, tgt string, st AppliedTargetState) *npmStore { + t.Helper() + if err := WriteAppliedState(cat, tgt, st); err != nil { + t.Fatalf("seeding %s/%s: %v", cat, tgt, err) + } + return s +} + +func (s *npmStore) get(cat, tgt string) (AppliedTargetState, bool) { + return ReadAppliedState(cat, tgt) +} + +// exists reports whether the state file was created at all. +func (s *npmStore) exists() bool { + _, err := os.Stat(s.path) + return err == nil +} + +// --- npm fixtures ----------------------------------------------------------- + +// npmPolicyWire stands in for the fetched npm policy payload (passed verbatim to +// the Render seam). npmRendered is what the fake renderer turns it into — the +// value the reconciler writes and compares, standing in for the two managed +// content lines RenderNPMRCBlock produces. +const npmPolicyWire = `{"registry":"https://npm.pkg.example/","always_auth":true}` +const npmRendered = "registry=https://npm.pkg.example/\nalways-auth=true" + +func npmRenderOK(json.RawMessage) (string, error) { return npmRendered, nil } + +func npmPolicyEP(hash string) EffectivePolicy { + return EffectivePolicy{ + Category: CategoryPackageConfig, + Target: TargetNPM, + Clear: false, + Policy: json.RawMessage(npmPolicyWire), + Hash: hash, + } +} + +// newNPMRec builds a marker-owned reconciler wired like the ~/.npmrc path: +// OwnsByMarker, OwnershipKey, a Render seam that produces the managed block, a +// content-aware ProbeExpected, and a Converged seam. It mirrors +// runPackageConfigEnforce's wiring, so a seam the production path sets and this +// one does not would show up as a behavior difference. Defaults: Render → the +// fixed block, probe → not managed, Converged → false (proceed to write). Tests +// override a single seam to exercise one rung. Ownership lands in st's file — the +// same shared state file the IDE lane writes. +func newNPMRec(t *testing.T, ep EffectivePolicy, w *fakeWriter, st *npmStore) (*Reconciler, *fakeReporter) { + t.Helper() + rep := &fakeReporter{} + r := &Reconciler{ + Fetcher: &fakeFetcher{ep: ep}, + Reporter: rep, + Writer: w, + CustomerID: "cust", + DeviceID: "SERIAL-1", + Platform: "darwin", + Category: CategoryPackageConfig, + Target: TargetNPM, + OwnsByMarker: true, + OwnershipKey: NPMOwnedKey, + Render: npmRenderOK, + ProbeExpected: func(string) (bool, string) { return false, "" }, + Converged: func(string) (bool, error) { return false, nil }, + Now: func() time.Time { return time.Date(2026, 7, 19, 0, 0, 0, 0, time.UTC) }, + } + // The counters and fault injection sit on the real store calls, so a test can + // both fail a persist and assert what the file ended up holding. + r.writeState = func(cat, tgt string, s AppliedTargetState) error { + st.writes++ + if st.failWriteFrom > 0 && st.writes >= st.failWriteFrom { + return errors.New("state persist failed") + } + if st.writeErr != nil { + return st.writeErr + } + return WriteAppliedState(cat, tgt, s) + } + r.clearState = func(cat, tgt string) error { + st.drops++ + if st.dropErr != nil { + return st.dropErr + } + return ClearAppliedState(cat, tgt) + } + return r, rep +} + +// --- tests ------------------------------------------------------------------ + +func TestNPMEnforceRendersBlockAndWrites(t *testing.T) { + w := &fakeWriter{} + st := newNPMStore(t) + r, rep := newNPMRec(t, npmPolicyEP("sha256:N"), w, st) + if err := r.Reconcile(context.Background()); err != nil { + t.Fatalf("Reconcile: %v", err) + } + // The rendered block is written verbatim — the Render seam, not compactJSON. + if len(w.writes) != 1 || w.writes[0] != npmRendered { + t.Fatalf("expected the rendered block written once, got %v", w.writes) + } + got := lastReport(t, rep) + if got.State != StateCompliant || got.Category != CategoryPackageConfig || got.Target != TargetNPM { + t.Fatalf("report = %+v, want compliant package_config/npm", got) + } + if got.AppliedHash != "sha256:N" { + t.Fatalf("applied_hash = %q, want sha256:N", got.AppliedHash) + } + // Ownership recorded in the one shared state file, under this category/target. + if st.writes == 0 { + t.Fatal("ownership must be recorded in the state file") + } + rec, ok := st.get(CategoryPackageConfig, TargetNPM) + if !ok || rec.WrittenSettings[NPMOwnedKey] != npmRendered || rec.AppliedHash != "sha256:N" { + t.Fatalf("state record = %+v ok=%v, want the rendered block + hash", rec, ok) + } +} + +func TestNPMRenderFailureReportsPolicyNotApplied(t *testing.T) { + // A malformed npm policy the renderer rejects: nothing is applied and the + // cycle reports policy_not_applied (not a silent no-op). Render runs FIRST, so + // the writer is never read or written and the probe never runs. + w := &fakeWriter{} + st := newNPMStore(t) + r, rep := newNPMRec(t, npmPolicyEP("sha256:N"), w, st) + probed := false + r.ProbeExpected = func(string) (bool, string) { probed = true; return false, "" } + r.Render = func(json.RawMessage) (string, error) { return "", errors.New("policy missing registry") } + if err := r.Reconcile(context.Background()); err == nil { + t.Fatal("a render failure must surface an error") + } + if w.reads != 0 || len(w.writes) != 0 || w.clears != 0 || probed { + t.Fatalf("render failure must touch nothing: reads=%d writes=%v clears=%d probed=%v", + w.reads, w.writes, w.clears, probed) + } + if got := lastReport(t, rep); got.State != StatePolicyNotApplied { + t.Fatalf("state = %q, want policy_not_applied", got.State) + } +} + +func TestNPMProbeExpectedReceivesRenderedBlockAndYields(t *testing.T) { + // The content-aware probe receives the RENDERED block (not the raw policy) — + // the ~/.npmrc file is user-writable, so a bare marker is not proof; the probe + // compares the desired state. When it reports the MDM lane already governs the + // same state, the reconciler yields mdm_managed without touching the file. + w := &fakeWriter{value: "whatever", present: true} + st := newNPMStore(t) + r, rep := newNPMRec(t, npmPolicyEP("sha256:N"), w, st) + var gotArg string + r.ProbeExpected = func(expected string) (bool, string) { + gotArg = expected + return true, "managed npm config present" + } + if err := r.Reconcile(context.Background()); err != nil { + t.Fatalf("Reconcile: %v", err) + } + if gotArg != npmRendered { + t.Fatalf("ProbeExpected received %q, want the rendered block %q", gotArg, npmRendered) + } + if w.reads != 0 || len(w.writes) != 0 { + t.Fatalf("managed probe must short-circuit before any file I/O: reads=%d writes=%v", w.reads, w.writes) + } + if got := lastReport(t, rep); got.State != StateMDMManaged || got.AppliedHash != "" { + t.Fatalf("report = %+v, want mdm_managed with no applied_hash", got) + } +} + +func TestNPMConvergedSeamOverridesBodyEquality(t *testing.T) { + // Body equality alone is not convergence for ~/.npmrc: a `registry=` line + // appended BELOW the block leaves the block bytes identical yet overrides it. + // The Converged seam owns that decision. Here on-disk == the rendered block + // (body-equal) and the recorded hash matches, but Converged=false → the + // reconciler still rewrites, where plain body-equality would have skipped. + w := &fakeWriter{value: npmRendered, present: true} + st := newNPMStore(t).seed(t, CategoryPackageConfig, TargetNPM, + AppliedTargetState{AppliedHash: "sha256:N", WrittenSettings: npmOwnRec(npmRendered)}) + r, rep := newNPMRec(t, npmPolicyEP("sha256:N"), w, st) + r.Converged = func(string) (bool, error) { return false, nil } + if err := r.Reconcile(context.Background()); err != nil { + t.Fatalf("Reconcile: %v", err) + } + if len(w.writes) != 1 { + t.Fatalf("Converged=false must force a rewrite even when body-equal, writes=%v", w.writes) + } + if got := lastReport(t, rep); got.State != StateCompliant { + t.Fatalf("state = %q, want compliant", got.State) + } +} + +func TestNPMConvergedTrueIsIdempotent(t *testing.T) { + // Converged=true AND the recorded hash matches → the block is fully in place + // and effective. No write; still reports compliant so the backend sees a fresh + // evaluation. + w := &fakeWriter{value: npmRendered, present: true} + st := newNPMStore(t).seed(t, CategoryPackageConfig, TargetNPM, + AppliedTargetState{AppliedHash: "sha256:N", WrittenSettings: npmOwnRec(npmRendered)}) + r, rep := newNPMRec(t, npmPolicyEP("sha256:N"), w, st) + r.Converged = func(string) (bool, error) { return true, nil } + if err := r.Reconcile(context.Background()); err != nil { + t.Fatalf("Reconcile: %v", err) + } + if len(w.writes) != 0 { + t.Fatalf("converged + hash unchanged must not write, got %v", w.writes) + } + if got := lastReport(t, rep); got.State != StateCompliant || got.AppliedHash != "sha256:N" { + t.Fatalf("report = %+v, want compliant + echoed hash", got) + } +} + +func TestNPMAdoptsAlreadyConvergedState(t *testing.T) { + // The exact block is fully applied on disk (Converged=true) but the state file + // carries no matching hash — our record is stale or gone, or the cycle that + // applied it resolved a different home for the file. The reconciler must adopt + // the on-disk state (no rewrite, no false drift) and report compliant, recording + // the current hash for next cycle. + cases := []struct { + name string + seed *AppliedTargetState + }{ + {"no record at all", nil}, + {"stale hash recorded", &AppliedTargetState{AppliedHash: "sha256:OLD", WrittenSettings: npmOwnRec(npmRendered)}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + st := newNPMStore(t) + if tc.seed != nil { + st.seed(t, CategoryPackageConfig, TargetNPM, *tc.seed) + } + w := &fakeWriter{value: npmRendered, present: true} + r, rep := newNPMRec(t, npmPolicyEP("sha256:NEW"), w, st) + r.Converged = func(string) (bool, error) { return true, nil } + if err := r.Reconcile(context.Background()); err != nil { + t.Fatalf("Reconcile: %v", err) + } + if len(w.writes) != 0 { + t.Fatalf("already-converged state must not rewrite, writes=%v", w.writes) + } + if got := lastReport(t, rep); got.State != StateCompliant || got.AppliedHash != "sha256:NEW" { + t.Fatalf("report = %+v, want compliant + adopted hash", got) + } + rec, ok := st.get(CategoryPackageConfig, TargetNPM) + if !ok || rec.AppliedHash != "sha256:NEW" || rec.WrittenSettings[NPMOwnedKey] != npmRendered { + t.Fatalf("state not adopted: rec=%+v ok=%v", rec, ok) + } + }) + } +} + +func TestNPMReadErrorClassification(t *testing.T) { + // A structural refusal on the initial read (the target cannot be enforced at + // all — wraps ErrTargetUnusable) is a write-class fact → write_failed; a plain + // unreadable file stays verification_failed. + cases := []struct { + name string + err error + state string + }{ + {"structural refusal", fmt.Errorf("npmrc: %w", ErrTargetUnusable), StateWriteFailed}, + {"plain unreadable", errors.New("permission denied"), StateVerificationFailed}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + w := &fakeWriter{readErr: tc.err} + st := newNPMStore(t) + r, rep := newNPMRec(t, npmPolicyEP("sha256:N"), w, st) + if err := r.Reconcile(context.Background()); err == nil { + t.Fatal("a read error must surface") + } + if len(w.writes) != 0 { + t.Fatalf("nothing must be written on a read error, writes=%v", w.writes) + } + if got := lastReport(t, rep); got.State != tc.state { + t.Fatalf("state = %q, want %q", got.State, tc.state) + } + }) + } +} + +func TestNPMConvergedErrorClassification(t *testing.T) { + // The Converged seam runs its OWN secure read; a structural refusal there is + // the same write-class fact as a refusal on the initial read. + cases := []struct { + name string + err error + state string + }{ + {"structural refusal", fmt.Errorf("npmrc: %w", ErrTargetUnusable), StateWriteFailed}, + {"plain error", errors.New("stat failed"), StateVerificationFailed}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + w := &fakeWriter{value: "x", present: true} + st := newNPMStore(t) + r, rep := newNPMRec(t, npmPolicyEP("sha256:N"), w, st) + r.Converged = func(string) (bool, error) { return false, tc.err } + if err := r.Reconcile(context.Background()); err == nil { + t.Fatal("a convergence-check error must surface") + } + if len(w.writes) != 0 { + t.Fatalf("nothing must be written on a convergence error, writes=%v", w.writes) + } + if got := lastReport(t, rep); got.State != tc.state { + t.Fatalf("state = %q, want %q", got.State, tc.state) + } + }) + } +} + +func TestNPMClearByMarkerAlwaysClearsAndDrops(t *testing.T) { + // Marker-based ownership: on unassignment the block is removed UNCONDITIONALLY + // (Clear is scoped to our own markers) and the record dropped UNCONDITIONALLY — + // even with no record, and without reading the file — so a lost/empty/drifted + // record can never strand a token-bearing block. + cases := []struct { + name string + seed *AppliedTargetState + }{ + {"no record", nil}, + {"stale record", &AppliedTargetState{WrittenSettings: npmOwnRec("old-block")}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + st := newNPMStore(t) + if tc.seed != nil { + st.seed(t, CategoryPackageConfig, TargetNPM, *tc.seed) + } + w := &fakeWriter{value: "a-managed-block", present: true} + ep := EffectivePolicy{Category: CategoryPackageConfig, Target: TargetNPM, Clear: true} + r, rep := newNPMRec(t, ep, w, st) + if err := r.Reconcile(context.Background()); err != nil { + t.Fatalf("Reconcile: %v", err) + } + if w.clears != 1 { + t.Fatalf("marker clear must call Clear exactly once, clears=%d", w.clears) + } + if w.reads != 0 { + t.Fatalf("marker clear must not read the file, reads=%d", w.reads) + } + if st.drops != 1 { + t.Fatalf("marker clear must Drop the record unconditionally, drops=%d", st.drops) + } + if _, ok := st.get(CategoryPackageConfig, TargetNPM); ok { + t.Fatal("state record must be gone after a marker clear") + } + if len(rep.reports) != 0 { + t.Fatalf("clear reports no compliance state, got %+v", rep.reports) + } + }) + } +} + +func TestNPMRestoreSnapshotRollbackClassification(t *testing.T) { + // After the block is written, the post-write ownership persist fails. The npm + // writer reverts its whole-file change from a snapshot (RestoreSnapshot seam), + // and the OUTCOME is classified: a clean restore → write_failed (the write was + // cleanly undone); a failed/aborted restore → verification_failed (on-disk + // state now unknown). The generic re-write path is NOT used — Writer.Write ran + // once (the enforce) and Clear never ran. + cases := []struct { + name string + restoreErr error + wantState string + }{ + {"restore succeeds", nil, StateWriteFailed}, + {"restore fails", errors.New("path moved under us"), StateVerificationFailed}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + w := &fakeWriter{} + st := newNPMStore(t) + st.failWriteFrom = 2 // preflight ok, post-write persist fails + r, rep := newNPMRec(t, npmPolicyEP("sha256:N"), w, st) + restored := 0 + r.RestoreSnapshot = func() error { restored++; return tc.restoreErr } + if err := r.Reconcile(context.Background()); err == nil { + t.Fatal("a post-write persist failure must surface an error") + } + if restored != 1 { + t.Fatalf("RestoreSnapshot must run exactly once, ran %d", restored) + } + if len(w.writes) != 1 { + t.Fatalf("the generic re-write path must NOT run; Writer.Write should have run once, got %v", w.writes) + } + if w.clears != 0 { + t.Fatalf("RestoreSnapshot replaces the generic clear-based rollback, clears=%d", w.clears) + } + if got := lastReport(t, rep); got.State != tc.wantState { + t.Fatalf("state = %q, want %q", got.State, tc.wantState) + } + }) + } +} + +func TestNPMWriteErrorClassification(t *testing.T) { + // A Writer.Write failure is write_failed by default; the one exception is a + // writer that landed bytes it could neither verify nor roll back + // (ErrWriteUnverified) → verification_failed, since on-disk state is then + // indeterminate. The IDE writer never returns the sentinel, so its Write + // failures stay write_failed (proven in TestSeamFallbacksMatchIDEBehavior). + cases := []struct { + name string + err error + state string + }{ + {"plain write failure", errors.New("disk full"), StateWriteFailed}, + {"unverified rollback", fmt.Errorf("npmrc: commit: %w", ErrWriteUnverified), StateVerificationFailed}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + w := &fakeWriter{writeErr: tc.err} + st := newNPMStore(t) + r, rep := newNPMRec(t, npmPolicyEP("sha256:N"), w, st) + if err := r.Reconcile(context.Background()); err == nil { + t.Fatal("a write error must surface") + } + if len(w.writes) != 1 { + t.Fatalf("Write should have been attempted once, got %v", w.writes) + } + if got := lastReport(t, rep); got.State != tc.state { + t.Fatalf("state = %q, want %q", got.State, tc.state) + } + }) + } +} + +func TestNPMWriterInitErrClassification(t *testing.T) { + // Writer construction failed (Writer nil, WriterInitErr set). The reconciler + // classifies AFTER the fetch by what run-config asked for — it never touches + // disk or state, since there is no resolved target user to act against. + npmEnforce := npmPolicyEP("sha256:N") + npmClear := EffectivePolicy{Category: CategoryPackageConfig, Target: TargetNPM, Clear: true} + cases := []struct { + name string + ep EffectivePolicy + initErr error + wantErr bool + wantReports []string + }{ + {"no target user + enforce → policy_not_applied", npmEnforce, ErrNoTargetUser, true, []string{StatePolicyNotApplied}}, + {"other failure + enforce → write_failed", npmEnforce, errors.New("home unopenable"), true, []string{StateWriteFailed}}, + {"clear + no writer → retain, no report", npmClear, ErrNoTargetUser, false, nil}, + {"absent + no writer → silent", EffectivePolicy{}, ErrNoTargetUser, false, nil}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + withTempCache(t) + rep := &fakeReporter{} + r := &Reconciler{ + Fetcher: &fakeFetcher{ep: tc.ep}, + Reporter: rep, + Writer: nil, + WriterInitErr: tc.initErr, + CustomerID: "cust", + DeviceID: "SERIAL-1", + Platform: "darwin", + Category: CategoryPackageConfig, + Target: TargetNPM, + OwnsByMarker: true, + } + err := r.Reconcile(context.Background()) + if tc.wantErr != (err != nil) { + t.Fatalf("err = %v, wantErr = %v", err, tc.wantErr) + } + if len(rep.reports) != len(tc.wantReports) { + t.Fatalf("reports = %+v, want %v", rep.reports, tc.wantReports) + } + for i, want := range tc.wantReports { + if rep.reports[i].State != want { + t.Fatalf("report[%d] state = %q, want %q", i, rep.reports[i].State, want) + } + if rep.reports[i].Category != CategoryPackageConfig || rep.reports[i].Target != TargetNPM { + t.Fatalf("report[%d] identity = %q/%q, want package_config/npm", + i, rep.reports[i].Category, rep.reports[i].Target) + } + } + }) + } +} + +func TestNPMStateLivesInTheOneSharedFile(t *testing.T) { + // npm ownership goes in device-policy-state.json under + // categories.package_config.targets.npm — the same file, and the same + // category→target shape, as every other lane. The npm category gets NO file of + // its own: this asserts the record's location on disk, and that reconciling npm + // creates that one file and nothing else beside it (a lock artifact aside — it + // carries no state). + w := &fakeWriter{} + st := newNPMStore(t) + r, _ := newNPMRec(t, npmPolicyEP("sha256:N"), w, st) + if err := r.Reconcile(context.Background()); err != nil { + t.Fatalf("Reconcile: %v", err) + } + if _, ok := ReadAppliedState(CategoryPackageConfig, TargetNPM); !ok { + t.Fatal("the npm record must be readable through the shared accessors") + } + + raw, err := os.ReadFile(st.path) + if err != nil { + t.Fatalf("reading %s: %v", CacheFilename, err) + } + var f AppliedStateFile + if err := json.Unmarshal(raw, &f); err != nil { + t.Fatalf("on-disk file is not an AppliedStateFile: %v (%s)", err, raw) + } + if f.SchemaVersion != CacheSchemaVersion { + t.Fatalf("schema_version = %d, want %d", f.SchemaVersion, CacheSchemaVersion) + } + rec, ok := f.Categories[CategoryPackageConfig].Targets[TargetNPM] + if !ok { + t.Fatalf("categories.%s.targets.%s missing: %s", CategoryPackageConfig, TargetNPM, raw) + } + if rec.WrittenSettings[NPMOwnedKey] != npmRendered { + t.Fatalf("record = %+v, want the rendered block under %s", rec, NPMOwnedKey) + } + + // One JSON state file in the directory, whatever else the run leaves behind. + entries, err := os.ReadDir(filepath.Dir(st.path)) + if err != nil { + t.Fatal(err) + } + var jsonFiles []string + for _, e := range entries { + if strings.HasSuffix(e.Name(), ".json") { + jsonFiles = append(jsonFiles, e.Name()) + } + } + if len(jsonFiles) != 1 || jsonFiles[0] != CacheFilename { + t.Fatalf("state directory holds %v, want only %s", jsonFiles, CacheFilename) + } +} + +func TestSeamFallbacksMatchIDEBehavior(t *testing.T) { + // Every seam at its zero value must reproduce the settings.json behavior — the + // fallbacks the IDE wiring relies on (it sets none of the seams). This pins the + // nil-seam contract directly, next to the reconcile_test.go path that exercises + // it end to end. + r := &Reconciler{} + + // renderValue → compacted policy JSON, not a rendered block. + got, err := r.renderValue(json.RawMessage(samplePolicyWire)) + if err != nil || got != samplePolicy { + t.Fatalf("renderValue fallback = %q err=%v, want %q", got, err, samplePolicy) + } + + // converged → plain body equality over the already-read value. + if ok, _ := r.converged("v", "v", true); !ok { + t.Fatal("converged fallback must be true when present and body-equal") + } + if ok, _ := r.converged("v", "v", false); ok { + t.Fatal("converged fallback must be false when not present") + } + if ok, _ := r.converged("v", "other", true); ok { + t.Fatal("converged fallback must be false when the body differs") + } + + // classifyReadError → verification_failed for a plain error (the IDE writer + // never wraps ErrTargetUnusable); write_failed only for the structural sentinel. + if s := classifyReadError(errors.New("plain")); s != StateVerificationFailed { + t.Fatalf("classifyReadError(plain) = %q, want verification_failed", s) + } + if s := classifyReadError(fmt.Errorf("x: %w", ErrTargetUnusable)); s != StateWriteFailed { + t.Fatalf("classifyReadError(unusable) = %q, want write_failed", s) + } + + // classifyWriteError → write_failed by default (the IDE writer never returns the + // unverified-rollback sentinel); verification_failed only for ErrWriteUnverified. + if s := classifyWriteError(errors.New("plain")); s != StateWriteFailed { + t.Fatalf("classifyWriteError(plain) = %q, want write_failed", s) + } + if s := classifyWriteError(fmt.Errorf("x: %w", ErrWriteUnverified)); s != StateVerificationFailed { + t.Fatalf("classifyWriteError(unverified) = %q, want verification_failed", s) + } +} + +// --------------------------------------------------------------------------- +// enforcement channel fork (npm): dmg writes, mdm verifies only +// --------------------------------------------------------------------------- + +// npmObservedFake is the observed bag a wired ProbeContentNPM would return. Built +// here rather than imported from the probe so a change to the real probe's shape +// shows up as a wiring test failure, not silently. +func npmObservedFake() map[string]json.RawMessage { + return map[string]json.RawMessage{ + observedKeyEcosystem: json.RawMessage(`"npm"`), + observedKeyRegistryURL: json.RawMessage(`"https://npm.pkg.example/javascript"`), + observedKeyAuthTokenStatus: json.RawMessage(`"match"`), + } +} + +// npmMDMEP is an npm policy directive on the verify-only channel. +func npmMDMEP(hash string) EffectivePolicy { + ep := npmPolicyEP(hash) + ep.Enforcement = "mdm" + return ep +} + +func TestNPMDMGChannelStillWrites(t *testing.T) { + // The explicit "dmg" channel and an absent one are the same write-and-verify + // path: the fork must not change the DMG lane the rest of this file covers. + for _, channel := range []string{"", "dmg", "DMG", " dmg ", "wat"} { + t.Run("channel="+channel, func(t *testing.T) { + w := &fakeWriter{} + st := newNPMStore(t) + ep := npmPolicyEP("sha256:N") + ep.Enforcement = channel + r, rep := newNPMRec(t, ep, w, st) + if err := r.Reconcile(context.Background()); err != nil { + t.Fatalf("Reconcile: %v", err) + } + if len(w.writes) != 1 || w.writes[0] != npmRendered { + t.Fatalf("the dmg lane must write the rendered block once, got %v", w.writes) + } + // An unrecognized channel resolves to dmg and REPORTS dmg, so the + // backend's exact-match gate sees the channel that actually ran. + if got := lastReport(t, rep).EvaluatedEnforcement; got != enforcementDMG { + t.Fatalf("evaluated_enforcement = %q, want %q", got, enforcementDMG) + } + }) + } +} + +func TestNPMMDMChannelVerifiesAndNeverWrites(t *testing.T) { + w := &fakeWriter{} + st := newNPMStore(t) + r, rep := newNPMRec(t, npmMDMEP("sha256:N"), w, st) + r.ProbeContent = func(expected string) (bool, map[string]json.RawMessage, error) { + // verifyMDM renders the desired value FIRST and hands it over — the npm probe + // needs it to decide auth_token_status on-device. + if expected != npmRendered { + t.Fatalf("probe expected = %q, want the rendered block %q", expected, npmRendered) + } + return true, npmObservedFake(), nil + } + if err := r.Reconcile(context.Background()); err != nil { + t.Fatalf("Reconcile: %v", err) + } + + rec := lastReport(t, rep) + if rec.State != StateMDMManaged { + t.Fatalf("state = %q, want %q", rec.State, StateMDMManaged) + } + if rec.EvaluatedEnforcement != enforcementMDM { + t.Fatalf("evaluated_enforcement = %q, want %q", rec.EvaluatedEnforcement, enforcementMDM) + } + // The agent applied nothing, so it must claim nothing. + if rec.AppliedHash != "" { + t.Fatalf("applied_hash = %q, want empty in mdm mode", rec.AppliedHash) + } + var observed map[string]json.RawMessage + if err := json.Unmarshal(rec.Observed, &observed); err != nil { + t.Fatalf("observed is not a JSON object: %v (%s)", err, rec.Observed) + } + if len(observed) != 3 || string(observed[observedKeyAuthTokenStatus]) != `"match"` { + t.Fatalf("observed = %s, want the three-key bag", rec.Observed) + } + // MDM owns nothing on disk: no write, no clear, no read, and the ownership + // store is never touched. + if len(w.writes) != 0 || w.clears != 0 || w.reads != 0 { + t.Fatalf("mdm mode touched the writer: writes=%v clears=%d reads=%d", w.writes, w.clears, w.reads) + } + if st.writes != 0 || st.drops != 0 { + t.Fatalf("mdm mode touched the state store: writes=%d drops=%d", st.writes, st.drops) + } + // Nothing was recorded, so the state file was never even created. + if st.exists() { + t.Fatal("mdm mode must not create the state file — it owns nothing to record") + } +} + +func TestNPMMDMChannelStatesFromProbe(t *testing.T) { + cases := []struct { + name string + probe func(string) (bool, map[string]json.RawMessage, error) + state string + wantObs bool + }{ + { + name: "marker present → mdm_managed with observed", + probe: func(string) (bool, map[string]json.RawMessage, error) { return true, npmObservedFake(), nil }, + state: StateMDMManaged, + wantObs: true, + }, + { + name: "no marker → policy_not_applied, no observed", + probe: func(string) (bool, map[string]json.RawMessage, error) { return false, nil, nil }, + state: StatePolicyNotApplied, + }, + { + name: "unreadable/malformed → verification_failed, no observed", + probe: func(string) (bool, map[string]json.RawMessage, error) { + return false, nil, errors.New("npmrc: file contains an INI section header") + }, + state: StateVerificationFailed, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + w := &fakeWriter{} + r, rep := newNPMRec(t, npmMDMEP("sha256:N"), w, newNPMStore(t)) + r.ProbeContent = tc.probe + if err := r.Reconcile(context.Background()); err != nil { + t.Fatalf("Reconcile: %v", err) + } + rec := lastReport(t, rep) + if rec.State != tc.state { + t.Fatalf("state = %q, want %q", rec.State, tc.state) + } + if rec.EvaluatedEnforcement != enforcementMDM { + t.Fatalf("evaluated_enforcement = %q, want %q", rec.EvaluatedEnforcement, enforcementMDM) + } + if gotObs := len(rec.Observed) > 0; gotObs != tc.wantObs { + t.Fatalf("observed present = %v, want %v (%s)", gotObs, tc.wantObs, rec.Observed) + } + if len(w.writes) != 0 || w.clears != 0 { + t.Fatalf("mdm mode must not touch the writer, got writes=%v clears=%d", w.writes, w.clears) + } + }) + } +} + +func TestNPMMDMChannelRenderFailureIsVerificationFailed(t *testing.T) { + // In MDM mode a policy the renderer rejects means there is no desired value to + // verify against, so nothing could be checked — verification_failed, not the + // DMG lane's policy_not_applied (which asserts a failed APPLY). + w := &fakeWriter{} + r, rep := newNPMRec(t, npmMDMEP("sha256:N"), w, newNPMStore(t)) + r.Render = func(json.RawMessage) (string, error) { return "", errors.New("bad policy") } + probed := false + r.ProbeContent = func(string) (bool, map[string]json.RawMessage, error) { + probed = true + return true, npmObservedFake(), nil + } + if err := r.Reconcile(context.Background()); err != nil { + t.Fatalf("Reconcile: %v", err) + } + if got := lastReport(t, rep).State; got != StateVerificationFailed { + t.Fatalf("state = %q, want %q", got, StateVerificationFailed) + } + if probed { + t.Fatal("the probe must not run without a desired value to compare against") + } + if len(w.writes) != 0 { + t.Fatalf("mdm mode must never write, got %v", w.writes) + } +} + +func TestNPMMDMChannelClearIsANoOp(t *testing.T) { + // enforcement=mdm + clear: the MDM lane owns nothing the agent could remove, and + // there is nothing to verify either. No write, no clear, no state change, and no + // report (an unassigned device is backend-derived). + w := &fakeWriter{} + st := newNPMStore(t).seed(t, CategoryPackageConfig, TargetNPM, + AppliedTargetState{AppliedHash: "sha256:OLD", WrittenSettings: npmOwnRec(npmRendered)}) + ep := npmMDMEP("") + ep.Clear = true + ep.Policy = nil + r, rep := newNPMRec(t, ep, w, st) + r.ProbeContent = func(string) (bool, map[string]json.RawMessage, error) { + t.Fatal("a clear directive must not reach the probe") + return false, nil, nil + } + if err := r.Reconcile(context.Background()); err != nil { + t.Fatalf("Reconcile: %v", err) + } + if len(rep.reports) != 0 { + t.Fatalf("mdm clear must not report, got %+v", rep.reports) + } + if len(w.writes) != 0 || w.clears != 0 { + t.Fatalf("mdm clear must not touch the writer, got writes=%v clears=%d", w.writes, w.clears) + } + if _, ok := st.get(CategoryPackageConfig, TargetNPM); !ok { + t.Fatal("mdm clear must leave the ownership record alone — it owns nothing to drop") + } +} + +func TestNPMMDMChannelRunsWithNoWriter(t *testing.T) { + // The MDM fork sits ABOVE the writer gates, so a cycle with no constructible + // writer still verifies and reports — the DMG lane's handleNoWriter + // classification must not swallow it. + st := newNPMStore(t) + r, rep := newNPMRec(t, npmMDMEP("sha256:N"), nil, st) + r.Writer = nil + r.Converged = nil + r.RestoreSnapshot = nil + r.WriterInitErr = fmt.Errorf("resolve target user: %w", ErrNoTargetUser) + r.ProbeContent = func(string) (bool, map[string]json.RawMessage, error) { return true, npmObservedFake(), nil } + if err := r.Reconcile(context.Background()); err != nil { + t.Fatalf("Reconcile: %v", err) + } + rec := lastReport(t, rep) + if rec.State != StateMDMManaged || rec.EvaluatedEnforcement != enforcementMDM { + t.Fatalf("report = %+v, want mdm_managed on the mdm channel", rec) + } +} + +func TestNPMMDMChannelNoProbeSeamIsVerificationFailed(t *testing.T) { + // The category-aware fallback. ProbeContent is nil whenever the writer could not + // be constructed (it is bound off the writer), and the generic default probes VS + // CODE policy locations — which for an npm category would report another + // category's policy. A non-ide category with no seam must error instead. + // + // verification_failed is the discriminating outcome: had it fallen through to + // ProbeManagedContent, a machine with no VS Code policy would have answered + // present=false and reported the clean policy_not_applied. + st := newNPMStore(t) + r, rep := newNPMRec(t, npmMDMEP("sha256:N"), nil, st) + r.Writer = nil + r.Converged = nil + r.RestoreSnapshot = nil + r.WriterInitErr = fmt.Errorf("resolve target user: %w", ErrNoTargetUser) + r.ProbeContent = nil + if err := r.Reconcile(context.Background()); err != nil { + t.Fatalf("Reconcile: %v", err) + } + rec := lastReport(t, rep) + if rec.State != StateVerificationFailed { + t.Fatalf("state = %q, want %q (never a VS Code probe for an npm category)", rec.State, StateVerificationFailed) + } + if len(rec.Observed) != 0 { + t.Fatalf("a failed verification must carry no observed bag, got %s", rec.Observed) + } +} + +func TestIDEMDMChannelKeepsItsDefaultProbe(t *testing.T) { + // The mirror of the case above: for ide_extension the nil-seam fallback is still + // ProbeManagedContent, so making the fallback category-aware must not have + // changed the VS Code lane. probe_other/darwin/linux/windows all answer for a + // machine with no managed policy, so this asserts the clean not-applied outcome + // rather than the npm error. + withTempCache(t) + rep := &fakeReporter{} + r := &Reconciler{ + Fetcher: &fakeFetcher{ep: func() EffectivePolicy { ep := policyEP("sha256:H"); ep.Enforcement = "mdm"; return ep }()}, + Reporter: rep, + Writer: &fakeWriter{}, + CustomerID: "cust", + DeviceID: "dev-1", + Platform: "linux", + Now: func() time.Time { return time.Date(2026, 7, 24, 0, 0, 0, 0, time.UTC) }, + } + if err := r.Reconcile(context.Background()); err != nil { + t.Fatalf("Reconcile: %v", err) + } + if got := lastReport(t, rep).State; got != StatePolicyNotApplied && got != StateMDMManaged { + t.Fatalf("state = %q, want the OS probe's verdict (policy_not_applied or mdm_managed), not an error", got) + } +} + +// --------------------------------------------------------------------------- +// Cache collapse: one WrittenSettings entry drives the whole npm DMG lifecycle +// --------------------------------------------------------------------------- + +func TestNPMOwnershipLifecycleThroughWrittenSettings(t *testing.T) { + // The collapse of the retired single-value WrittenValue field into one + // WrittenSettings entry must leave the npm DMG lane behaving identically: + // enforce records ownership, a hand-edit is detected as drift and converged + // back, and clear removes the block by marker and drops the record — all of it + // against the one shared state file, under package_config/npm. + w := &fakeWriter{} + st := newNPMStore(t) + r, rep := newNPMRec(t, npmPolicyEP("sha256:N"), w, st) + // Converged mirrors the writer: the block is in place iff the last write stands. + r.Converged = func(expected string) (bool, error) { return w.present && w.value == expected, nil } + + // 1. Enforce → the block is written and ownership recorded under NPMOwnedKey. + if err := r.Reconcile(context.Background()); err != nil { + t.Fatalf("enforce: %v", err) + } + rec, ok := st.get(CategoryPackageConfig, TargetNPM) + if !ok || len(rec.WrittenSettings) != 1 || rec.WrittenSettings[NPMOwnedKey] != npmRendered { + t.Fatalf("ownership = %+v, want exactly one %s entry", rec.WrittenSettings, NPMOwnedKey) + } + if got := lastReport(t, rep).State; got != StateCompliant { + t.Fatalf("first enforce state = %q, want compliant", got) + } + + // 2. Hand-edit the file, then re-enforce with the SAME hash. The recorded entry + // no longer matches disk → drift, converged back in the same cycle. + w.value, w.present = "registry=https://hand-edited.example/", true + rep.reports = nil + if err := r.Reconcile(context.Background()); err != nil { + t.Fatalf("re-enforce after hand-edit: %v", err) + } + if got := lastReport(t, rep).State; got != StateDriftDetected { + t.Fatalf("state after hand-edit = %q, want drift_detected", got) + } + if len(w.writes) != 2 || w.writes[1] != npmRendered { + t.Fatalf("drift must re-apply the rendered block, got %v", w.writes) + } + if rec, _ := st.get(CategoryPackageConfig, TargetNPM); rec.WrittenSettings[NPMOwnedKey] != npmRendered { + t.Fatalf("ownership after drift = %+v, want the re-applied block", rec.WrittenSettings) + } + + // 3. A third cycle with the block intact is idempotent — no write, still + // compliant (not drift): the ownership entry now agrees with disk. + rep.reports = nil + if err := r.Reconcile(context.Background()); err != nil { + t.Fatalf("idempotent cycle: %v", err) + } + if len(w.writes) != 2 { + t.Fatalf("a converged cycle must not write, got %v", w.writes) + } + if got := lastReport(t, rep).State; got != StateCompliant { + t.Fatalf("idempotent state = %q, want compliant", got) + } + + // 4. Clear → marker-based, so the block goes and the record is dropped + // unconditionally (clear never consults the ownership entry). + clearEP := npmPolicyEP("") + clearEP.Clear = true + clearEP.Policy = nil + r.Fetcher = &fakeFetcher{ep: clearEP} + rep.reports = nil + if err := r.Reconcile(context.Background()); err != nil { + t.Fatalf("clear: %v", err) + } + if w.clears != 1 { + t.Fatalf("clear must remove the block, clears=%d", w.clears) + } + if _, ok := st.get(CategoryPackageConfig, TargetNPM); ok { + t.Fatal("clear must drop the ownership record") + } + if len(rep.reports) != 0 { + t.Fatalf("clear must not report (backend-derived), got %+v", rep.reports) + } +} + +func TestNPMEnforceIgnoresForeignOwnershipKeys(t *testing.T) { + // The npm lane reads and writes exactly its own WrittenSettings key. A record + // carrying only some other lane's key means "this lane owns nothing", so an + // unconverged file is a plain first enforce — NOT drift (drift asserts the agent's + // own value was changed) — and the post-write persist records the npm key alone, + // never inheriting the foreign one. + w := &fakeWriter{} + st := newNPMStore(t).seed(t, CategoryPackageConfig, TargetNPM, AppliedTargetState{ + AppliedHash: "sha256:N", + WrittenSettings: map[string]string{allowedExtensionsSettingKey: "not-ours"}, + }) + r, rep := newNPMRec(t, npmPolicyEP("sha256:N"), w, st) + if err := r.Reconcile(context.Background()); err != nil { + t.Fatalf("Reconcile: %v", err) + } + if got := lastReport(t, rep).State; got != StateCompliant { + t.Fatalf("state = %q, want compliant (a foreign key is not npm drift)", got) + } + rec, _ := st.get(CategoryPackageConfig, TargetNPM) + if len(rec.WrittenSettings) != 1 || rec.WrittenSettings[NPMOwnedKey] != npmRendered { + t.Fatalf("ownership = %+v, want only the npm entry recorded", rec.WrittenSettings) + } +} + +// --------------------------------------------------------------------------- +// Secret hygiene +// --------------------------------------------------------------------------- + +func TestNPMNeverLogsOrReportsTheToken(t *testing.T) { + // The rendered block carries the device token, and the reconciler handles it on + // every rung (render → probe → write → ownership → report). None of the api_key, + // the composed token, or the serial may reach a log line or any report field. + const apiKey = "ssSECRETKEY123" + const serial = "SERIAL-ABC" + const rendered = "registry=https://t.registry.stepsecurity.io/javascript\n" + + "//t.registry.stepsecurity.io/javascript/:_authToken=" + apiKey + "::dev:" + serial + + var logged strings.Builder + run := func(channel string, probe func(string) (bool, map[string]json.RawMessage, error)) { + w := &fakeWriter{} + ep := npmPolicyEP("sha256:N") + ep.Enforcement = channel + r, rep := newNPMRec(t, ep, w, newNPMStore(t)) + r.Render = func(json.RawMessage) (string, error) { return rendered, nil } + r.Converged = func(expected string) (bool, error) { return w.present && w.value == expected, nil } + r.ProbeContent = probe + r.Logf = func(format string, args ...any) { + _, _ = fmt.Fprintf(&logged, format+"\n", args...) + } + if err := r.Reconcile(context.Background()); err != nil { + t.Fatalf("channel %q: %v", channel, err) + } + for _, got := range rep.reports { + raw, err := json.Marshal(got) + if err != nil { + t.Fatal(err) + } + for _, secret := range []string{apiKey, apiKey + "::dev:" + serial, serial} { + if strings.Contains(string(raw), secret) { + t.Fatalf("channel %q report leaks %q: %s", channel, secret, raw) + } + } + } + } + + // The DMG write lane handles the token most (render, write, ownership record)... + run("dmg", nil) + // ...and the MDM lane renders it to hand the tenant key to the probe. + run("mdm", func(string) (bool, map[string]json.RawMessage, error) { return true, npmObservedFake(), nil }) + + for _, secret := range []string{apiKey, apiKey + "::dev:" + serial, serial} { + if strings.Contains(logged.String(), secret) { + t.Fatalf("logs leak %q:\n%s", secret, logged.String()) + } + } + if logged.Len() == 0 { + t.Fatal("the test proved nothing — no log lines were captured") + } +} diff --git a/internal/devicepolicy/reconcile_test.go b/internal/devicepolicy/reconcile_test.go index 1b93e30..806bf15 100644 --- a/internal/devicepolicy/reconcile_test.go +++ b/internal/devicepolicy/reconcile_test.go @@ -68,10 +68,11 @@ func (w *fakeWriter) Write(v string) (string, error) { return v, nil } -func (w *fakeWriter) Clear() error { +func (w *fakeWriter) Clear() (bool, error) { w.clears++ + changed := w.present w.value, w.present = "", false - return nil + return changed, nil } func (w *fakeWriter) Location() string { return "fake://settings.json" } @@ -108,11 +109,36 @@ func policyEP(hash string) EffectivePolicy { return EffectivePolicy{ Category: CategoryIDEExtension, Clear: false, - Policy: map[string]json.RawMessage{allowedExtensionsSettingKey: json.RawMessage(samplePolicyWire)}, + Policy: settingsPolicy(map[string]json.RawMessage{allowedExtensionsSettingKey: json.RawMessage(samplePolicyWire)}), Hash: hash, } } +// settingsPolicy encodes a VS Code settings map into the raw `policy` object the +// wire carries. EffectivePolicy.Policy is category-agnostic raw JSON, so the IDE +// lane's settings map is a decode away rather than the field's own type. +func settingsPolicy(settings map[string]json.RawMessage) json.RawMessage { + raw, err := json.Marshal(settings) + if err != nil { + panic("settingsPolicy: " + err.Error()) + } + return raw +} + +// policySettings decodes a raw policy object back into the settings map, for +// assertions on what the fetcher lifted. +func policySettings(t *testing.T, ep EffectivePolicy) map[string]json.RawMessage { + t.Helper() + m := map[string]json.RawMessage{} + if len(ep.Policy) == 0 { + return m + } + if err := json.Unmarshal(ep.Policy, &m); err != nil { + t.Fatalf("decode policy settings map: %v", err) + } + return m +} + func lastReport(t *testing.T, rep *fakeReporter) ComplianceReport { t.Helper() if len(rep.reports) != 1 { @@ -147,7 +173,7 @@ func TestEnforceWritesCompactedPolicyAndReportsCompliant(t *testing.T) { } // Ownership recorded. st, ok := ReadAppliedState(CategoryIDEExtension, TargetVSCode) - if !ok || st.WrittenValue != samplePolicy || st.AppliedHash != "sha256:H" { + if !ok || st.WrittenSettings[allowedExtensionsSettingKey] != samplePolicy || st.AppliedHash != "sha256:H" { t.Fatalf("cache = %+v ok=%v", st, ok) } } @@ -155,7 +181,7 @@ func TestEnforceWritesCompactedPolicyAndReportsCompliant(t *testing.T) { func TestEnforceIdempotentSecondRunWritesNothing(t *testing.T) { withTempCache(t) // Seed prior ownership + on-disk value matching the desired policy. - if err := WriteAppliedState(CategoryIDEExtension, TargetVSCode, AppliedTargetState{AppliedHash: "sha256:H", WrittenValue: samplePolicy}); err != nil { + if err := WriteAppliedState(CategoryIDEExtension, TargetVSCode, AppliedTargetState{AppliedHash: "sha256:H", WrittenSettings: ownRec(samplePolicy)}); err != nil { t.Fatal(err) } w := &fakeWriter{value: samplePolicy, present: true} @@ -179,7 +205,7 @@ func TestEnforceIdempotentSecondRunWritesNothing(t *testing.T) { func TestClearRemovesAgentOwnedPolicy(t *testing.T) { withTempCache(t) - if err := WriteAppliedState(CategoryIDEExtension, TargetVSCode, AppliedTargetState{AppliedHash: "sha256:H", WrittenValue: samplePolicy}); err != nil { + if err := WriteAppliedState(CategoryIDEExtension, TargetVSCode, AppliedTargetState{AppliedHash: "sha256:H", WrittenSettings: ownRec(samplePolicy)}); err != nil { t.Fatal(err) } w := &fakeWriter{value: samplePolicy, present: true} // on-disk == what we wrote → owned @@ -199,7 +225,7 @@ func TestClearRemovesAgentOwnedPolicy(t *testing.T) { if len(rep.reports) != 0 { t.Fatalf("clear must not report a compliance state, got %+v", rep.reports) } - if st, _ := ReadAppliedState(CategoryIDEExtension, TargetVSCode); st.WrittenValue != "" { + if st, _ := ReadAppliedState(CategoryIDEExtension, TargetVSCode); st.WrittenSettings[allowedExtensionsSettingKey] != "" { t.Fatalf("ownership record should be dropped, got %+v", st) } } @@ -208,7 +234,7 @@ func TestClearLeavesValueAgentDidNotWrite(t *testing.T) { withTempCache(t) // We recorded writing "mine", but on disk is "theirs" — the user (or some // other tool) changed it. Unassignment must not destroy their value. - if err := WriteAppliedState(CategoryIDEExtension, TargetVSCode, AppliedTargetState{WrittenValue: "mine"}); err != nil { + if err := WriteAppliedState(CategoryIDEExtension, TargetVSCode, AppliedTargetState{WrittenSettings: ownRec("mine")}); err != nil { t.Fatal(err) } w := &fakeWriter{value: "theirs", present: true} @@ -299,7 +325,7 @@ func TestEnforceDriftReappliesAndReportsDriftDetected(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { withTempCache(t) - if err := WriteAppliedState(CategoryIDEExtension, TargetVSCode, AppliedTargetState{AppliedHash: "sha256:H", WrittenValue: samplePolicy}); err != nil { + if err := WriteAppliedState(CategoryIDEExtension, TargetVSCode, AppliedTargetState{AppliedHash: "sha256:H", WrittenSettings: ownRec(samplePolicy)}); err != nil { t.Fatal(err) } w := &fakeWriter{value: tc.value, present: tc.present} @@ -365,7 +391,7 @@ func TestEnforceReadbackMismatchReportsPolicyNotApplied(t *testing.T) { // Ownership IS recorded even on a readback mismatch — it tracks what the // agent wrote, not what it verified; next-cycle recovery depends on it // (value-based ownership only takes effect if the value actually landed). - if st, ok := ReadAppliedState(CategoryIDEExtension, TargetVSCode); !ok || st.WrittenValue != samplePolicy { + if st, ok := ReadAppliedState(CategoryIDEExtension, TargetVSCode); !ok || st.WrittenSettings[allowedExtensionsSettingKey] != samplePolicy { t.Fatalf("cache must record the written value even on readback mismatch, got %+v ok=%v", st, ok) } } @@ -449,7 +475,7 @@ func TestReconcileNoOpsWhenPolicyAbsent(t *testing.T) { // This is NOT a clear: the on-disk value, ownership record, and reporter must // all be left untouched. A transient policy drop must never wipe enforcement. withTempCache(t) - if err := WriteAppliedState(CategoryIDEExtension, TargetVSCode, AppliedTargetState{AppliedHash: "sha256:H", WrittenValue: samplePolicy}); err != nil { + if err := WriteAppliedState(CategoryIDEExtension, TargetVSCode, AppliedTargetState{AppliedHash: "sha256:H", WrittenSettings: ownRec(samplePolicy)}); err != nil { t.Fatal(err) } w := &fakeWriter{value: samplePolicy, present: true} @@ -469,7 +495,7 @@ func TestReconcileNoOpsWhenPolicyAbsent(t *testing.T) { t.Fatalf("absent policy must not report, got %+v", rep.reports) } // Ownership record must stand for next cycle's idempotency check. - if st, ok := ReadAppliedState(CategoryIDEExtension, TargetVSCode); !ok || st.WrittenValue != samplePolicy { + if st, ok := ReadAppliedState(CategoryIDEExtension, TargetVSCode); !ok || st.WrittenSettings[allowedExtensionsSettingKey] != samplePolicy { t.Fatalf("ownership record must be untouched, got %+v ok=%v", st, ok) } } @@ -493,6 +519,29 @@ func TestEnforceStateUnwritablePreflightWritesNothing(t *testing.T) { } } +func TestEnforcePreflightCatchesLockContentionBeforeWriting(t *testing.T) { + // What keeps a fail-closed state lock from costing anything in the common case. + // The preflight persist goes through the same locked accessor as the real one, so + // a peer process holding the lock is discovered BEFORE the settings file is + // touched: write_failed is reported, nothing is written, and there is nothing to + // roll back. Only contention arriving in the window between the preflight and the + // post-write persist reaches the rollback path — accepted, because a rollback the + // next cycle retries beats losing another category's ownership record. + w := &fakeWriter{} + r, rep := newRec(t, policyEP("sha256:H"), nil, w) + holdStateLockUntilCleanup(t) // after newRec: its withTempCache redirects the lock too + + if err := r.Reconcile(context.Background()); err == nil { + t.Fatal("a held state lock should surface an error") + } + if len(w.writes) != 0 { + t.Fatalf("the settings file must not be touched when the lock is unavailable, writes=%v", w.writes) + } + if got := lastReport(t, rep); got.State != StateWriteFailed { + t.Fatalf("state = %q, want write_failed", got.State) + } +} + func TestEnforceStatePersistFailureRollsBackWrite(t *testing.T) { // Preflight succeeds but the post-write persist fails: the agent undoes the // just-written value (no prior value → remove the key) so it never leaves @@ -525,7 +574,7 @@ func TestEnforceStatePersistFailureRestoresPreviousOwnedValue(t *testing.T) { // Same as above but a previous owned value existed: rollback restores it, // keeping the (intact, atomic) old state file and the disk consistent. withTempCache(t) - if err := WriteAppliedState(CategoryIDEExtension, TargetVSCode, AppliedTargetState{AppliedHash: "sha256:OLD", WrittenValue: "old-value"}); err != nil { + if err := WriteAppliedState(CategoryIDEExtension, TargetVSCode, AppliedTargetState{AppliedHash: "sha256:OLD", WrittenSettings: ownRec("old-value")}); err != nil { t.Fatal(err) } w := &fakeWriter{value: "old-value", present: true} @@ -537,7 +586,7 @@ func TestEnforceStatePersistFailureRestoresPreviousOwnedValue(t *testing.T) { Now: func() time.Time { return time.Unix(0, 0).UTC() }, } r.writeState = func(_, _ string, s AppliedTargetState) error { - if s.WrittenValue == samplePolicy { + if s.WrittenSettings[allowedExtensionsSettingKey] == samplePolicy { return errors.New("disk full") // fail only the post-write persist } return nil // preflight probe succeeds @@ -561,7 +610,7 @@ func TestEnforcePolicyChangeRewrites(t *testing.T) { // We own "old-value" and it is still intact on disk; the backend now sends // a new policy with a new hash. This is a policy CHANGE, not drift — the // report is plain compliant. - if err := WriteAppliedState(CategoryIDEExtension, TargetVSCode, AppliedTargetState{AppliedHash: "sha256:OLD", WrittenValue: "old-value"}); err != nil { + if err := WriteAppliedState(CategoryIDEExtension, TargetVSCode, AppliedTargetState{AppliedHash: "sha256:OLD", WrittenSettings: ownRec("old-value")}); err != nil { t.Fatal(err) } w := &fakeWriter{value: "old-value", present: true} @@ -630,7 +679,7 @@ func TestReconcilePreservesSiblingTargetOwnership(t *testing.T) { // sibling must be seeded AFTER it (not before) to land in the same file. w := &fakeWriter{} r, rep := newRec(t, policyEP("sha256:H"), nil, w) // Target empty → defaults to vscode - jb := AppliedTargetState{AppliedHash: "sha256:JB", WrittenValue: "jetbrains-value"} + jb := AppliedTargetState{AppliedHash: "sha256:JB", WrittenSettings: ownRec("jetbrains-value")} if err := WriteAppliedState(CategoryIDEExtension, "jetbrains", jb); err != nil { t.Fatal(err) } @@ -641,11 +690,11 @@ func TestReconcilePreservesSiblingTargetOwnership(t *testing.T) { if got := lastReport(t, rep); got.Target != TargetVSCode || got.State != StateCompliant { t.Fatalf("report = %+v, want vscode + compliant", got) } - if vs, ok := ReadAppliedState(CategoryIDEExtension, TargetVSCode); !ok || vs.WrittenValue != samplePolicy { + if vs, ok := ReadAppliedState(CategoryIDEExtension, TargetVSCode); !ok || vs.WrittenSettings[allowedExtensionsSettingKey] != samplePolicy { t.Fatalf("vscode ownership not recorded: got %+v ok=%v", vs, ok) } // jetbrains sibling untouched. - if got, ok := ReadAppliedState(CategoryIDEExtension, "jetbrains"); !ok || got.AppliedHash != jb.AppliedHash || got.WrittenValue != jb.WrittenValue { + if got, ok := ReadAppliedState(CategoryIDEExtension, "jetbrains"); !ok || got.AppliedHash != jb.AppliedHash || got.WrittenSettings[allowedExtensionsSettingKey] != jb.WrittenSettings[allowedExtensionsSettingKey] { t.Fatalf("sibling jetbrains ownership must survive a vscode reconcile: got %+v ok=%v", got, ok) } } @@ -757,16 +806,20 @@ func (w *fakeManagedWriter) Write(v string) (string, error) { w.state[allowedExtensionsSettingKey] = settingValue{Present: true, Raw: v} return v, nil } -func (w *fakeManagedWriter) Clear() error { +func (w *fakeManagedWriter) Clear() (bool, error) { w.ensure() + _, changed := w.state[allowedExtensionsSettingKey] delete(w.state, allowedExtensionsSettingKey) - return nil + return changed, nil } func (w *fakeManagedWriter) Location() string { return "fake://managed-settings.json" } func policyEPGallery(hash, url string) EffectivePolicy { ep := policyEP(hash) - ep.Policy[galleryServiceURLSettingKey] = json.RawMessage(galleryRaw(url)) + ep.Policy = settingsPolicy(map[string]json.RawMessage{ + allowedExtensionsSettingKey: json.RawMessage(samplePolicyWire), + galleryServiceURLSettingKey: json.RawMessage(galleryRaw(url)), + }) return ep } @@ -1177,11 +1230,11 @@ func TestEnforceManagedRealWriterArbitraryThirdKey(t *testing.T) { ep := EffectivePolicy{ Category: CategoryIDEExtension, Hash: "sha256:H", - Policy: map[string]json.RawMessage{ + Policy: settingsPolicy(map[string]json.RawMessage{ allowedExtensionsSettingKey: json.RawMessage(samplePolicyWire), galleryServiceURLSettingKey: json.RawMessage(galleryRaw(galURLA)), thirdKey: json.RawMessage("false"), - }, + }), } rep := &fakeReporter{} r := &Reconciler{ @@ -1239,7 +1292,7 @@ func sampleObserved() map[string]json.RawMessage { func TestReconcileMDMReportsManagedWithObserved(t *testing.T) { w := &fakeWriter{} r, rep := newRec(t, mdmEP("sha256:H"), nil, w) - r.ProbeContent = func() (bool, map[string]json.RawMessage, error) { return true, sampleObserved(), nil } + r.ProbeContent = func(string) (bool, map[string]json.RawMessage, error) { return true, sampleObserved(), nil } if err := r.Reconcile(context.Background()); err != nil { t.Fatalf("Reconcile: %v", err) } @@ -1270,7 +1323,7 @@ func TestReconcileMDMReportsManagedWithObserved(t *testing.T) { func TestReconcileMDMPolicyNotApplied(t *testing.T) { w := &fakeWriter{} r, rep := newRec(t, mdmEP("sha256:H"), nil, w) - r.ProbeContent = func() (bool, map[string]json.RawMessage, error) { return false, nil, nil } + r.ProbeContent = func(string) (bool, map[string]json.RawMessage, error) { return false, nil, nil } if err := r.Reconcile(context.Background()); err != nil { t.Fatalf("Reconcile: %v", err) } @@ -1292,7 +1345,7 @@ func TestReconcileMDMPolicyNotApplied(t *testing.T) { func TestReconcileMDMProbeErrorVerificationFailed(t *testing.T) { w := &fakeWriter{} r, rep := newRec(t, mdmEP("sha256:H"), nil, w) - r.ProbeContent = func() (bool, map[string]json.RawMessage, error) { + r.ProbeContent = func(string) (bool, map[string]json.RawMessage, error) { return false, nil, errors.New("policy.json present but unreadable") } if err := r.Reconcile(context.Background()); err != nil { @@ -1322,7 +1375,7 @@ func TestReconcileMDMRunsWithNilWriter(t *testing.T) { CustomerID: "c", DeviceID: "d", Platform: "darwin", - ProbeContent: func() (bool, map[string]json.RawMessage, error) { return true, sampleObserved(), nil }, + ProbeContent: func(string) (bool, map[string]json.RawMessage, error) { return true, sampleObserved(), nil }, } if err := r.Reconcile(context.Background()); err != nil { t.Fatalf("Reconcile: %v", err) @@ -1339,7 +1392,7 @@ func TestReconcileMDMClearIsNoOp(t *testing.T) { ep := EffectivePolicy{Category: CategoryIDEExtension, Clear: true, Enforcement: enforcementMDM} r, rep := newRec(t, ep, nil, w) probed := false - r.ProbeContent = func() (bool, map[string]json.RawMessage, error) { probed = true; return false, nil, nil } + r.ProbeContent = func(string) (bool, map[string]json.RawMessage, error) { probed = true; return false, nil, nil } if err := r.Reconcile(context.Background()); err != nil { t.Fatalf("Reconcile: %v", err) } @@ -1385,7 +1438,7 @@ func TestReconcileEnforcementRoutingIsCaseInsensitive(t *testing.T) { ep := policyEP("sha256:H") ep.Enforcement = " MDM " r, rep := newRec(t, ep, nil, w) - r.ProbeContent = func() (bool, map[string]json.RawMessage, error) { return false, nil, nil } + r.ProbeContent = func(string) (bool, map[string]json.RawMessage, error) { return false, nil, nil } if err := r.Reconcile(context.Background()); err != nil { t.Fatalf("Reconcile: %v", err) } diff --git a/internal/devicepolicy/settings_writer.go b/internal/devicepolicy/settings_writer.go index 06dc773..97b1b50 100644 --- a/internal/devicepolicy/settings_writer.go +++ b/internal/devicepolicy/settings_writer.go @@ -47,7 +47,12 @@ type Writer interface { // Clear removes the extensions.allowed key, leaving the rest of the file // (and the file itself) intact. A missing file or absent key is a no-op. - Clear() error + // + // changed reports whether anything was actually removed. Callers must keep + // calling Clear unconditionally — that is what stops a lost ownership record + // from stranding a live block — and use changed only to describe what + // happened, so a clean device does not report a removal every cycle. + Clear() (changed bool, err error) // Location is a human-readable description of the target, for logs. Location() string @@ -198,33 +203,45 @@ func (w *settingsWriter) Location() string { // - the syntax tree (an empty object when the file is absent or blank, so // callers can patch a first key into a fresh file); // - existed=false when the file is absent; +// - bom, the leading UTF-8 byte-order mark if the file carried one, for the +// caller to hand back to store so the write preserves it; // - an error when the file exists but is unreadable, is not parseable JSONC, // or its root is not an object — the never-clobber contract. -func (w *settingsWriter) load() (v hujson.Value, existed bool, err error) { +// +// The BOM is split off BEFORE both the blank check and the parse. A BOM is not +// whitespace to bytes.TrimSpace and not a valid start of a JSON value, so +// without this a file whose only difference is those three bytes reads as +// unparseable — permanently, since the agent's only way to remove them is the +// write it then refuses to attempt. VS Code strips it on read; tooling that +// seeds settings.json emits it (PowerShell 5.1's Set-Content -Encoding UTF8, +// editors set to utf8bom). +func (w *settingsWriter) load() (v hujson.Value, existed bool, bom []byte, err error) { // #nosec G304 -- w.path is settingsPath() (env/home + fixed segments) or a // test override, never external input. b, err := os.ReadFile(w.path) if errors.Is(err, os.ErrNotExist) { v, _ := hujson.Parse([]byte("{}")) - return v, false, nil + return v, false, nil, nil } if err != nil { - return hujson.Value{}, false, fmt.Errorf("devicepolicy: read %s: %w", w.path, err) + return hujson.Value{}, false, nil, fmt.Errorf("devicepolicy: read %s: %w", w.path, err) } + b, bom = stripBOM(b) if len(bytes.TrimSpace(b)) == 0 { // An empty file is how VS Code-adjacent tooling often seeds settings; - // treat it as an empty object rather than a parse error. + // treat it as an empty object rather than a parse error. A BOM-only file + // lands here too, now that the mark is off the front. v, _ := hujson.Parse([]byte("{}")) - return v, true, nil + return v, true, bom, nil } v, perr := hujson.Parse(b) if perr != nil { - return hujson.Value{}, true, fmt.Errorf("devicepolicy: %s is not valid JSONC, refusing to touch it: %w", w.path, perr) + return hujson.Value{}, true, bom, fmt.Errorf("devicepolicy: %s is not valid JSONC, refusing to touch it: %w", w.path, perr) } if _, ok := v.Value.(*hujson.Object); !ok { - return hujson.Value{}, true, fmt.Errorf("devicepolicy: %s root is not a JSON object, refusing to touch it", w.path) + return hujson.Value{}, true, bom, fmt.Errorf("devicepolicy: %s root is not a JSON object, refusing to touch it", w.path) } - return v, true, nil + return v, true, bom, nil } // extractAllowedExtensions returns the compacted current value of the @@ -268,7 +285,7 @@ func compactJSON(raw []byte) (string, error) { } func (w *settingsWriter) Read() (string, bool, error) { - v, existed, err := w.load() + v, existed, _, err := w.load() if err != nil { return "", false, err } @@ -288,7 +305,7 @@ func (w *settingsWriter) Write(value string) (string, error) { // fetcher already enforces object shape). return "", fmt.Errorf("devicepolicy: refusing to write non-object policy value to %s", w.path) } - v, _, err := w.load() + v, _, bom, err := w.load() if err != nil { return "", err } @@ -298,7 +315,7 @@ func (w *settingsWriter) Write(value string) (string, error) { if err := v.Patch([]byte(patch)); err != nil { return "", fmt.Errorf("devicepolicy: patch %s: %w", w.path, err) } - if err := w.store(v); err != nil { + if err := w.store(v, bom); err != nil { return "", err } rb, _, err := w.Read() @@ -311,32 +328,44 @@ func (w *settingsWriter) Write(value string) (string, error) { // Clear removes the extensions.allowed key. The file is never deleted (it is // the user's settings.json); a file or key already absent is a no-op that // performs no write at all. -func (w *settingsWriter) Clear() error { - v, existed, err := w.load() +func (w *settingsWriter) Clear() (bool, error) { + v, existed, bom, err := w.load() if err != nil { - return err + return false, err } if !existed { - return nil + return false, nil } if _, present, err := extractAllowedExtensions(v); err != nil { - return err + return false, err } else if !present { - return nil + return false, nil } patch := `[{"op":"remove","path":"/` + allowedExtensionsSettingKey + `"}]` if err := v.Patch([]byte(patch)); err != nil { - return fmt.Errorf("devicepolicy: patch %s: %w", w.path, err) + return false, fmt.Errorf("devicepolicy: patch %s: %w", w.path, err) + } + if err := w.store(v, bom); err != nil { + return false, err } - return w.store(v) + return true, nil } // store atomically replaces the settings file with the packed tree, preserving // the existing file mode and keeping a capped sibling backup of the previous // content (atomicfile: temp in target dir → fsync → rename). -func (w *settingsWriter) store(v hujson.Value) error { +// +// bom is the mark load split off the file being replaced, re-prepended here so +// the write preserves the encoding the user's file already had. The agent owns +// specific keys, not the file's encoding — and dropping the mark would fight an +// editor configured to re-add it. nil (the BOM-free case) prepends nothing. +func (w *settingsWriter) store(v hujson.Value, bom []byte) error { + out := v.Pack() + if len(bom) > 0 { + out = append(append([]byte(nil), bom...), out...) + } mode := atomicfile.PickMode(w.path, settingsFileMode) - if _, err := atomicfile.WriteAtomic(w.path, v.Pack(), mode); err != nil { + if _, err := atomicfile.WriteAtomic(w.path, out, mode); err != nil { return fmt.Errorf("devicepolicy: write %s: %w", w.path, err) } return nil @@ -346,7 +375,7 @@ func (w *settingsWriter) store(v hujson.Value) error { // file read. An absent/blank file yields all-absent; an unparseable or // non-object file is an error (the never-clobber contract), same as Read. func (w *settingsWriter) ReadManaged(keys []string) (map[string]settingValue, error) { - v, existed, err := w.load() + v, existed, _, err := w.load() if err != nil { return nil, err } @@ -403,7 +432,7 @@ func jsonPointerPath(key string) (string, error) { // writes nothing, so a remove-absent or preserve-only call leaves the file // untouched. Returns a readback of every op's key. func (w *settingsWriter) ApplyManaged(ops []settingOp) (map[string]settingValue, error) { - v, _, err := w.load() + v, _, bom, err := w.load() if err != nil { return nil, err } @@ -448,7 +477,7 @@ func (w *settingsWriter) ApplyManaged(ops []settingOp) (map[string]settingValue, if err := v.Patch([]byte(patch)); err != nil { return nil, fmt.Errorf("devicepolicy: patch %s: %w", w.path, err) } - if err := w.store(v); err != nil { + if err := w.store(v, bom); err != nil { return nil, err } } diff --git a/internal/devicepolicy/settings_writer_test.go b/internal/devicepolicy/settings_writer_test.go index fa02041..db51001 100644 --- a/internal/devicepolicy/settings_writer_test.go +++ b/internal/devicepolicy/settings_writer_test.go @@ -1,6 +1,7 @@ package devicepolicy import ( + "context" "encoding/json" "os" "path/filepath" @@ -265,7 +266,7 @@ func TestSettingsClearRemovesOnlyTheKey(t *testing.T) { t.Fatalf("Write: %v", err) } - if err := w.Clear(); err != nil { + if _, err := w.Clear(); err != nil { t.Fatalf("Clear: %v", err) } after := readFileString(t, path) @@ -282,7 +283,7 @@ func TestSettingsClearAbsentIsNoOp(t *testing.T) { w, path := newTestSettingsWriter(t) // Missing file: Clear must not create it. - if err := w.Clear(); err != nil { + if _, err := w.Clear(); err != nil { t.Fatalf("Clear(missing file): %v", err) } if _, err := os.Stat(path); !os.IsNotExist(err) { @@ -291,7 +292,7 @@ func TestSettingsClearAbsentIsNoOp(t *testing.T) { // File without the key: Clear must not rewrite it. writeSettingsFixture(t, path, sampleSettings) - if err := w.Clear(); err != nil { + if _, err := w.Clear(); err != nil { t.Fatalf("Clear(no key): %v", err) } if got := readFileString(t, path); got != sampleSettings { @@ -311,7 +312,7 @@ func TestSettingsUnsalvageableFileIsNeverTouched(t *testing.T) { if _, err := w.Write(samplePolicyObject); err == nil { t.Fatal("Write on unparseable file: want error") } - if err := w.Clear(); err == nil { + if _, err := w.Clear(); err == nil { t.Fatal("Clear on unparseable file: want error") } if got := readFileString(t, path); got != broken { @@ -626,7 +627,7 @@ func TestApplyManagedEscapesUnusualKeys(t *testing.T) { // TestGalleryValueRoundTrips pins the ownership invariant on the value path the // reconciler now uses: the gallery URL arrives as a JSON string in the settings -// map, is compacted (compactSettings), written, and recorded as owned — and that +// map, is compacted (compactPolicySettings), written, and recorded as owned — and that // value must equal what a write→read round-trip returns, or ownership / // convergence would churn forever. Includes a URL with &, =, <, > — the // HTML-escaping edge (canonical JSON must not HTML-escape; json.Compact and the @@ -654,3 +655,264 @@ func TestGalleryValueRoundTrips(t *testing.T) { } } } + +// --- UTF-8 BOM --------------------------------------------------------------- +// +// A settings.json carrying a leading BOM must be fully enforceable. The mark is +// not whitespace and is not a valid start of a JSON value, so before it was +// split off in load() every entry point failed with "not valid JSONC" — and +// permanently, because removing the three bytes needs the write the writer then +// refused. VS Code strips it on read; tooling that seeds the file emits it +// (PowerShell 5.1's Set-Content -Encoding UTF8, editors set to utf8bom). + +// Escaped, not literal: the Go scanner rejects a raw BOM inside a source file. +const utf8BOM = "\ufeff" + +func TestSettingsBOMFileIsReadable(t *testing.T) { + w, path := newTestSettingsWriter(t) + writeSettingsFixture(t, path, utf8BOM+`{"extensions.allowed":`+samplePolicyObject+`}`) + + got, present, err := w.Read() + if err != nil { + t.Fatalf("Read on BOM file: %v", err) + } + if !present || got != samplePolicyObject { + t.Fatalf("Read = (%q, %v), want (%q, true)", got, present, samplePolicyObject) + } + + m, err := w.ReadManaged([]string{allowedExtensionsSettingKey, galleryServiceURLSettingKey}) + if err != nil { + t.Fatalf("ReadManaged on BOM file: %v", err) + } + if sv := m[allowedExtensionsSettingKey]; !sv.Present || sv.Raw != samplePolicyObject { + t.Fatalf("ReadManaged allowed = %+v, want present %q", sv, samplePolicyObject) + } + if sv := m[galleryServiceURLSettingKey]; sv.Present { + t.Fatalf("ReadManaged gallery = %+v, want absent", sv) + } +} + +// The BOM is the user's file encoding, not a value the agent owns: a write +// preserves it, exactly once, still at the very front. +func TestSettingsWritePreservesBOM(t *testing.T) { + w, path := newTestSettingsWriter(t) + writeSettingsFixture(t, path, utf8BOM+sampleSettings) + + rb, err := w.Write(samplePolicyObject) + if err != nil { + t.Fatalf("Write on BOM file: %v", err) + } + if rb != samplePolicyObject { + t.Fatalf("readback = %q, want %q", rb, samplePolicyObject) + } + + after := readFileString(t, path) + if !strings.HasPrefix(after, utf8BOM) { + t.Fatalf("BOM lost from the front of the file: %q", after) + } + if n := strings.Count(after, utf8BOM); n != 1 { + t.Fatalf("BOM appears %d times, want exactly 1: %q", n, after) + } + assertFragmentsPreserved(t, after) + + // A second write must not accumulate marks, and must stay byte-idempotent. + before := after + if _, err := w.Write(samplePolicyObject); err != nil { + t.Fatalf("second Write: %v", err) + } + if got := readFileString(t, path); got != before { + t.Fatalf("second write not byte-idempotent on a BOM file:\n%q\n--- vs ---\n%q", got, before) + } +} + +// Clear must work on a BOM file too — otherwise an unassignment can never +// remove the key the agent wrote, leaving policy applied after offboarding. +func TestSettingsClearPreservesBOM(t *testing.T) { + w, path := newTestSettingsWriter(t) + writeSettingsFixture(t, path, utf8BOM+sampleSettings) + if _, err := w.Write(samplePolicyObject); err != nil { + t.Fatalf("Write: %v", err) + } + + if _, err := w.Clear(); err != nil { + t.Fatalf("Clear on BOM file: %v", err) + } + if _, present, err := w.Read(); err != nil || present { + t.Fatalf("Read after Clear = (present=%v, err=%v), want absent", present, err) + } + + after := readFileString(t, path) + if !strings.HasPrefix(after, utf8BOM) || strings.Count(after, utf8BOM) != 1 { + t.Fatalf("BOM not preserved exactly once through Clear: %q", after) + } + assertFragmentsPreserved(t, after) +} + +// ApplyManaged / RestoreManaged share load+store with the single-key path, so +// the multi-key lane must round-trip a BOM file too. +func TestApplyAndRestoreManagedPreserveBOM(t *testing.T) { + w, path := newTestSettingsWriter(t) + writeSettingsFixture(t, path, utf8BOM+sampleSettings) + + gallery := `"https://mkt.example/api/v1"` + snapshot, err := w.ReadManaged([]string{allowedExtensionsSettingKey, galleryServiceURLSettingKey}) + if err != nil { + t.Fatalf("ReadManaged: %v", err) + } + if _, err := w.ApplyManaged([]settingOp{ + {Key: allowedExtensionsSettingKey, Set: true, Value: json.RawMessage(samplePolicyObject)}, + {Key: galleryServiceURLSettingKey, Set: true, Value: json.RawMessage(gallery)}, + }); err != nil { + t.Fatalf("ApplyManaged on BOM file: %v", err) + } + after := readFileString(t, path) + if !strings.HasPrefix(after, utf8BOM) || strings.Count(after, utf8BOM) != 1 { + t.Fatalf("BOM not preserved exactly once through ApplyManaged: %q", after) + } + + if err := w.RestoreManaged(snapshot); err != nil { + t.Fatalf("RestoreManaged: %v", err) + } + restored := readFileString(t, path) + if !strings.HasPrefix(restored, utf8BOM) || strings.Count(restored, utf8BOM) != 1 { + t.Fatalf("BOM not preserved exactly once through RestoreManaged: %q", restored) + } + if m, err := w.ReadManaged([]string{allowedExtensionsSettingKey, galleryServiceURLSettingKey}); err != nil { + t.Fatal(err) + } else if m[allowedExtensionsSettingKey].Present || m[galleryServiceURLSettingKey].Present { + t.Fatalf("rollback left a managed key behind: %+v", m) + } + assertFragmentsPreserved(t, restored) +} + +// A file holding nothing but a BOM is the blank-file case once the mark is off +// the front — an empty object to patch a first key into, not a parse error. +func TestSettingsBOMOnlyFileIsTreatedAsEmptyObject(t *testing.T) { + w, path := newTestSettingsWriter(t) + writeSettingsFixture(t, path, utf8BOM) + + if _, err := w.Write(samplePolicyObject); err != nil { + t.Fatalf("Write on BOM-only file: %v", err) + } + got, present, err := w.Read() + if err != nil || !present || got != samplePolicyObject { + t.Fatalf("Read = (%q, %v, %v), want (%q, true, nil)", got, present, err, samplePolicyObject) + } + if after := readFileString(t, path); !strings.HasPrefix(after, utf8BOM) || strings.Count(after, utf8BOM) != 1 { + t.Fatalf("BOM not preserved exactly once: %q", after) + } +} + +// The BOM must not become a way to smuggle a broken file past the +// never-clobber contract: with the mark off the front, the remainder is still +// parsed, and unparseable content is still refused without a write. +func TestSettingsBOMDoesNotBypassNeverClobber(t *testing.T) { + for _, tc := range []struct{ name, body string }{ + {"unparseable", `{"extensions.allowed": ,,,}`}, + {"root not object", `[1,2,3]`}, + {"second BOM inside", utf8BOM + `{}`}, + } { + t.Run(tc.name, func(t *testing.T) { + w, path := newTestSettingsWriter(t) + content := utf8BOM + tc.body + writeSettingsFixture(t, path, content) + + if _, err := w.Write(samplePolicyObject); err == nil { + t.Fatal("Write must refuse a BOM file whose remainder is not a JSON object") + } + if got := readFileString(t, path); got != content { + t.Fatalf("file was modified:\n%q\n--- want unchanged ---\n%q", got, content) + } + }) + } +} + +// End to end over the REAL writer (not a fake), because the symptom was a +// compliance state, not a parse error: a BOM'd settings.json reported +// verification_failed on every cycle forever, with the file untouched, and no +// signal to the developer beyond the agent log. Enforce must converge and report +// compliant instead — and the clear that follows must remove the key it wrote. +func TestReconcileEnforcesAndClearsThroughABOM(t *testing.T) { + path := filepath.Join(t.TempDir(), "User", "settings.json") + writeSettingsFixture(t, path, utf8BOM+sampleSettings) + + r, rep := newRec(t, policyEP("sha256:H"), nil, nil) + r.Writer = newSettingsWriterAt(path) + + if err := r.Reconcile(context.Background()); err != nil { + t.Fatalf("Reconcile: %v", err) + } + if got := lastReport(t, rep); got.State != StateCompliant { + t.Fatalf("state = %q, want %q", got.State, StateCompliant) + } + after := readFileString(t, path) + if !strings.HasPrefix(after, utf8BOM) || strings.Count(after, utf8BOM) != 1 { + t.Fatalf("BOM not preserved exactly once through enforce: %q", after) + } + assertFragmentsPreserved(t, after) + + // Unassignment: the key the agent just wrote must come back out. + rep.reports = nil + r.Fetcher = &fakeFetcher{ep: EffectivePolicy{Category: CategoryIDEExtension, Clear: true}} + if err := r.Reconcile(context.Background()); err != nil { + t.Fatalf("Reconcile(clear): %v", err) + } + if m, err := r.Writer.(managedSettingsWriter).ReadManaged([]string{allowedExtensionsSettingKey}); err != nil { + t.Fatal(err) + } else if m[allowedExtensionsSettingKey].Present { + t.Fatalf("clear left the agent-written key behind: %+v", m) + } + if final := readFileString(t, path); !strings.HasPrefix(final, utf8BOM) || strings.Count(final, utf8BOM) != 1 { + t.Fatalf("BOM not preserved exactly once through clear: %q", final) + } +} + +// The settings lane needs the same three-way answer from Clear as the npm lane: +// an absent file, a present file without the key, and a real removal must be +// distinguishable, since only the last is a removal a caller may report. +func TestSettingsClear_ReportsWhetherAnythingChanged(t *testing.T) { + t.Run("absent file changes nothing", func(t *testing.T) { + w, _ := newTestSettingsWriter(t) + changed, err := w.Clear() + if err != nil { + t.Fatalf("Clear: %v", err) + } + if changed { + t.Fatal("Clear on an absent file must report changed=false") + } + }) + + t.Run("key absent changes nothing", func(t *testing.T) { + w, path := newTestSettingsWriter(t) + const fixture = "{\n \"editor.fontSize\": 13\n}\n" + writeSettingsFixture(t, path, fixture) + changed, err := w.Clear() + if err != nil { + t.Fatalf("Clear: %v", err) + } + if changed { + t.Fatal("Clear over a file without the managed key must report changed=false") + } + if got := readFileString(t, path); got != fixture { + t.Fatalf("a no-op clear must leave the file byte-identical, got %q", got) + } + }) + + t.Run("key present changes the file", func(t *testing.T) { + w, path := newTestSettingsWriter(t) + writeSettingsFixture(t, path, "{\n \"editor.fontSize\": 13\n}\n") + if _, err := w.Write(`{"*":false}`); err != nil { + t.Fatalf("Write: %v", err) + } + changed, err := w.Clear() + if err != nil { + t.Fatalf("Clear: %v", err) + } + if !changed { + t.Fatal("Clear that removed the managed key must report changed=true") + } + if got := readFileString(t, path); strings.Contains(got, allowedExtensionsSettingKey) { + t.Fatalf("key not removed: %q", got) + } + }) +} diff --git a/internal/devicepolicy/statelock.go b/internal/devicepolicy/statelock.go new file mode 100644 index 0000000..26c4664 --- /dev/null +++ b/internal/devicepolicy/statelock.go @@ -0,0 +1,132 @@ +package devicepolicy + +import ( + "fmt" + "os" + "path/filepath" + "time" +) + +// stateLockSuffix names the lock artifact guarding the state file's +// read-modify-write. It sits beside the state file (device-policy-state.json -> +// device-policy-state.json.lock) and carries NO policy state whatsoever — it +// exists only to hold an OS advisory lock, so every category's ownership record +// still lives in exactly one JSON file. +const stateLockSuffix = ".lock" + +// stateLockWait bounds how long a read-modify-write waits for a concurrent agent +// process to finish its own before giving up and failing the operation. The +// critical section is a small read, an in-memory map edit and an atomic rename — +// microseconds — so the budget is deliberately orders of magnitude larger than +// honest contention needs: it has to absorb a rename serialized behind on-access +// antivirus, a slow network home directory, or a descheduled peer without ever +// mistaking one for a wedged process. Anything still holding it after this is not +// contention, and proceeding anyway would be the lost update the lock exists to +// prevent. +// +// stateLockRetryDelay is the poll interval. The lock is taken non-blocking and +// retried rather than waited on in the kernel so the budget is enforceable on +// both platforms with the same code. +var ( + stateLockWait = 10 * time.Second + stateLockRetryDelay = 5 * time.Millisecond +) + +// stateLockPath returns the lock artifact's path, or "" when the state file's own +// path is unresolvable (no home directory). Derived by suffixing CachePath() so a +// test override redirects the lock with the file it guards. +func stateLockPath() string { + p := CachePath() + if p == "" { + return "" + } + return p + stateLockSuffix +} + +// withStateLock runs fn holding an exclusive cross-process lock on the state +// file, then releases it. It is what makes a read-modify-write safe between +// separate agent PROCESSES: the in-process cacheMu cannot see a second agent, and +// without it an IDE reconcile in one process and an npm reconcile in another could +// each read the file, add their own category, and have the later atomic rename +// drop the earlier one's record — a silent loss of ownership that strands whatever +// that category wrote on disk, since the agent will not later remove a value it +// has no record of writing. +// +// Acquisition FAILS CLOSED: if the lock cannot be taken, fn does not run and the +// error is returned. The one file holding every category's ownership is an +// invariant, not a best effort, and "the lock was busy" is precisely the state in +// which a concurrent writer is proven to exist — proceeding there is the one case +// that can actually lose a record. The cost is accepted deliberately: the caller +// classifies the failure write_failed, which for npm rolls the ~/.npmrc block back +// and reports a failure the next cycle retries. A reported, recoverable failure is +// preferable to silently dropping another category's ownership. +// +// The single exception is a platform or filesystem with no advisory locking at all +// (see lockUnavailable): there fn runs unlocked, because no peer can hold a lock +// either — there is no race to lose, and failing closed would permanently break +// state persistence on that machine instead of protecting anything. +// +// Callers hold cacheMu first and this second, always in that order, so the two +// can never deadlock against each other. Neither lock is reentrant: fn must use +// the unlocked readStateFile / persistStateFile, never the public accessors. +func withStateLock(fn func() error) error { + release, err := acquireStateLock() + if err != nil { + return err + } + defer release() + return fn() +} + +// acquireStateLock takes the exclusive lock. On success the returned release is +// non-nil and safe to call (a no-op when nothing was locked because locking is +// unavailable). On error release is nil and the caller must not proceed. +func acquireStateLock() (release func(), err error) { + if !stateLockSupported { + return func() {}, nil + } + path := stateLockPath() + if path == "" { + return nil, errNoHomeDir + } + // The parent may not exist yet on a first write; persistStateFile creates it + // too, but the lock has to be opened before that runs. + if err := os.MkdirAll(filepath.Dir(path), cacheParentDirMode); err != nil { + return nil, fmt.Errorf("devicepolicy: state lock dir: %w", err) + } + // #nosec G304 -- path is CachePath() (a test override, or the resolved home + // joined with the package constant CacheFilename) plus a constant suffix. + f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, cacheFileMode) + if err != nil { + // Not openable is actionable, not benign: an existing lock file this process + // cannot open (one left owner-only by a root or SYSTEM-context run over the + // same home) is exactly the mixed-privilege setup where a peer CAN lock and + // this process would be the one silently clobbering it. + return nil, fmt.Errorf("devicepolicy: open state lock %s: %w", path, err) + } + // The lock file is a rendezvous point, never a record: it is created empty and + // left empty, and it is deliberately NOT unlinked on release. Unlinking would + // let a peer that already opened the old inode hold a lock nobody else can see. + deadline := time.Now().Add(stateLockWait) + for { + ok, lerr := tryLockHandle(f) + if ok { + return func() { + unlockHandle(f) + _ = f.Close() + }, nil + } + if lerr != nil { + _ = f.Close() + if lockUnavailable(lerr) { + return func() {}, nil + } + return nil, fmt.Errorf("devicepolicy: lock %s: %w", path, lerr) + } + if !time.Now().Before(deadline) { + _ = f.Close() + return nil, fmt.Errorf("%w after %s", errStateLockBusy, stateLockWait) + } + time.Sleep(stateLockRetryDelay) + } +} diff --git a/internal/devicepolicy/statelock_other.go b/internal/devicepolicy/statelock_other.go new file mode 100644 index 0000000..8f02ed9 --- /dev/null +++ b/internal/devicepolicy/statelock_other.go @@ -0,0 +1,18 @@ +//go:build !darwin && !linux && !windows + +package devicepolicy + +import "os" + +// stateLockSupported is false on platforms the agent does not ship to, so no lock +// file is created and no wait budget is spent, and a read-modify-write runs on the +// atomic temp+rename alone. This is the one place the cross-process guarantee is +// waived rather than enforced — nothing here can lock, so no peer holds a lock to +// race against, and failing closed would only make state unpersistable. +const stateLockSupported = false + +func tryLockHandle(*os.File) (bool, error) { return false, nil } + +func unlockHandle(*os.File) {} + +func lockUnavailable(error) bool { return true } diff --git a/internal/devicepolicy/statelock_unix.go b/internal/devicepolicy/statelock_unix.go new file mode 100644 index 0000000..3c444ba --- /dev/null +++ b/internal/devicepolicy/statelock_unix.go @@ -0,0 +1,66 @@ +//go:build darwin || linux + +package devicepolicy + +import ( + "errors" + "os" + "syscall" +) + +// stateLockSupported: POSIX advisory locking via flock(2). The lock is owned by +// the open file description, so the kernel drops it when the process exits or +// crashes — a dead agent can never wedge the state file. +const stateLockSupported = true + +// tryLockHandle takes an exclusive flock without blocking. ok=false with a nil +// error means another open description holds it (EWOULDBLOCK) and the caller +// should retry within its budget. A non-nil error fails the operation closed +// unless lockUnavailable classifies it as "this filesystem cannot lock at all". +func tryLockHandle(f *os.File) (bool, error) { + // #nosec G115 -- f.Fd() is a live descriptor from an *os.File: a small + // non-negative int widened to uintptr, so narrowing it back cannot overflow. The + // one edge value, the ^uintptr(0) a closed file returns, converts to -1, which + // flock rejects with EBADF — an error this function already fails closed on. + err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) + if err == nil { + return true, nil + } + if errors.Is(err, syscall.EWOULDBLOCK) { + return false, nil + } + return false, err +} + +// unlockHandle releases the flock. Closing the handle would release it anyway; +// doing it explicitly keeps the release path symmetric with Windows, where the +// unlock is not implied. +func unlockHandle(f *os.File) { + // #nosec G115 -- see tryLockHandle: narrowing a live *os.File descriptor back to + // int cannot overflow. + _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN) +} + +// lockUnavailable reports whether the error means this filesystem does not +// implement flock at all, as opposed to an actionable failure. It is the only +// error class that still runs the read-modify-write unlocked: where nothing can +// lock, no peer holds a lock either, so there is no lost update to prevent — +// while failing closed would leave the agent permanently unable to persist state +// on, say, a network or FUSE-mounted home directory. +// +// ENOTSUP/EOPNOTSUPP — "this filesystem does not implement flock" — is the whole +// list. Everything else fails the operation, including two errnos that look like +// they belong here and do not: ENOLCK means the kernel's lock records are +// exhausted, so peers may well already HOLD locks this call just cannot join, and +// EINVAL means the request itself was invalid, which says nothing about whether +// the filesystem can lock. Both are transient or local faults on a mount where +// locking works, exactly the case where running unlocked loses a record. +func lockUnavailable(err error) bool { + var errno syscall.Errno + if !errors.As(err, &errno) { + return false + } + // Not a switch: ENOTSUP and EOPNOTSUPP are the same value on Linux (distinct on + // Darwin), which a switch rejects as a duplicate case. + return errno == syscall.ENOTSUP || errno == syscall.EOPNOTSUPP +} diff --git a/internal/devicepolicy/statelock_unix_test.go b/internal/devicepolicy/statelock_unix_test.go new file mode 100644 index 0000000..f7cfed8 --- /dev/null +++ b/internal/devicepolicy/statelock_unix_test.go @@ -0,0 +1,43 @@ +//go:build darwin || linux + +package devicepolicy + +import ( + "errors" + "fmt" + "syscall" + "testing" +) + +func TestLockUnavailableIsTheOnlyUnlockedPath(t *testing.T) { + // The single waiver of the cross-process guarantee: a filesystem that cannot + // lock at all. Nothing there can hold a lock, so there is no race to lose, and + // failing closed would make state permanently unpersistable on such a mount — a + // network or FUSE home directory. Every other errno is a real fault and must + // fail the operation instead of quietly running the read-modify-write unlocked. + for _, tc := range []struct { + name string + err error + want bool + }{ + {"filesystem does not implement flock", syscall.ENOTSUP, true}, + {"wrapped, not bare", fmt.Errorf("flock: %w", syscall.ENOTSUP), true}, + // The two near misses. Neither means "cannot lock here": ENOLCK is exhausted + // kernel lock records on a mount where peers may already hold locks, and EINVAL + // is an invalid request. Treating either as unavailable would run the + // read-modify-write unlocked in exactly the case a peer exists. + {"exhausted lock records is a real fault", syscall.ENOLCK, false}, + {"an invalid lock request is a real fault", syscall.EINVAL, false}, + {"bad descriptor is a real fault", syscall.EBADF, false}, + {"io error is a real fault", syscall.EIO, false}, + {"permission denied is a real fault", syscall.EPERM, false}, + {"contention is not an error at all", syscall.EWOULDBLOCK, false}, + {"a non-errno error is a real fault", errors.New("something else"), false}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := lockUnavailable(tc.err); got != tc.want { + t.Fatalf("lockUnavailable(%v) = %v, want %v", tc.err, got, tc.want) + } + }) + } +} diff --git a/internal/devicepolicy/statelock_windows.go b/internal/devicepolicy/statelock_windows.go new file mode 100644 index 0000000..104edfe --- /dev/null +++ b/internal/devicepolicy/statelock_windows.go @@ -0,0 +1,70 @@ +//go:build windows + +package devicepolicy + +import ( + "errors" + "os" + + "golang.org/x/sys/windows" +) + +// stateLockSupported: byte-range locking via LockFileEx. Like flock the lock is +// tied to the handle, so it is released when the process exits or is killed. +const stateLockSupported = true + +// stateLockRegionLen is how many bytes of the (always empty) lock file the range +// covers. One byte is enough: every participant locks the same range, and a lock +// may extend past end-of-file. +const stateLockRegionLen = 1 + +// tryLockHandle takes an exclusive byte-range lock without blocking. +// LOCKFILE_FAIL_IMMEDIATELY turns contention into ERROR_LOCK_VIOLATION instead of +// a wait, which the caller polls against its own deadline. +func tryLockHandle(f *os.File) (bool, error) { + var ol windows.Overlapped + err := windows.LockFileEx( + windows.Handle(f.Fd()), + windows.LOCKFILE_EXCLUSIVE_LOCK|windows.LOCKFILE_FAIL_IMMEDIATELY, + 0, stateLockRegionLen, 0, &ol, + ) + if err == nil { + return true, nil + } + if errors.Is(err, windows.ERROR_LOCK_VIOLATION) { + return false, nil + } + return false, err +} + +// lockUnavailable reports whether the error means the volume does not implement +// byte-range locking at all, as opposed to an actionable failure. It is the only +// error class that still runs the read-modify-write unlocked: where nothing can +// lock, no peer holds a lock either, so there is no lost update to prevent — +// while failing closed would leave the agent permanently unable to persist state +// on such a volume. Local NTFS always locks; a network redirector or a +// third-party filesystem filter may not. +// +// ERROR_NOT_SUPPORTED and ERROR_INVALID_FUNCTION — the latter being what a driver +// returns for an operation it has no implementation for — are the whole list. +// Everything else fails the operation, including the errors that look like they +// belong here and do not: ERROR_NO_SYSTEM_RESOURCES and ERROR_NOT_ENOUGH_MEMORY +// mean the kernel could not allocate a lock record on a volume where peers may +// well already HOLD locks this call cannot join, and ERROR_INVALID_PARAMETER means +// the request itself was malformed, which says nothing about whether the volume +// can lock. All three are transient or local faults where locking works, exactly +// the case in which running unlocked loses a record. ERROR_LOCK_VIOLATION never +// reaches here at all — tryLockHandle turns contention into ok=false with no error +// — and must never be added: it is the one error that proves a peer holds the +// lock. +func lockUnavailable(err error) bool { + return errors.Is(err, windows.ERROR_NOT_SUPPORTED) || + errors.Is(err, windows.ERROR_INVALID_FUNCTION) +} + +// unlockHandle releases the byte-range lock. Unlike flock this is not implied by +// closing the handle in every case, so it is always issued explicitly. +func unlockHandle(f *os.File) { + var ol windows.Overlapped + _ = windows.UnlockFileEx(windows.Handle(f.Fd()), 0, stateLockRegionLen, 0, &ol) +} diff --git a/internal/devicepolicy/statelock_windows_test.go b/internal/devicepolicy/statelock_windows_test.go new file mode 100644 index 0000000..14735e3 --- /dev/null +++ b/internal/devicepolicy/statelock_windows_test.go @@ -0,0 +1,58 @@ +//go:build windows + +package devicepolicy + +import ( + "errors" + "fmt" + "testing" + + "golang.org/x/sys/windows" +) + +// The POSIX counterpart of this test lives in statelock_unix_test.go. Keep the two +// in step: the pair is what documents that the carve-out is a deliberate, +// identically-shaped exception on both platforms rather than whatever each one's +// locking API happened to return during development. This file compiles only on +// Windows, so a POSIX `go test` run never exercises it — the assertions come from +// the native windows job. `GOOS=windows go vet ./internal/devicepolicy/` +// typechecks it from any host. +func TestLockUnavailableIsTheOnlyUnlockedPath(t *testing.T) { + // The single waiver of the cross-process guarantee: a volume that cannot lock at + // all. Nothing there can hold a lock, so there is no race to lose, and failing + // closed would make state permanently unpersistable on such a volume — a network + // redirector or a third-party filesystem filter that declines locks. Every other + // error is a real fault and must fail the operation instead of quietly running + // the read-modify-write unlocked. + for _, tc := range []struct { + name string + err error + want bool + }{ + {"volume does not implement byte-range locking", windows.ERROR_NOT_SUPPORTED, true}, + {"a driver with no implementation for the operation", windows.ERROR_INVALID_FUNCTION, true}, + {"wrapped, not bare", fmt.Errorf("LockFileEx: %w", windows.ERROR_NOT_SUPPORTED), true}, + // The near misses, mirroring ENOLCK and EINVAL in the POSIX carve-out. Neither + // means "cannot lock here": the first two are a lock record the kernel could not + // allocate on a volume where peers may already hold locks, and the third is a + // malformed request. Waiving any of them would run the read-modify-write + // unlocked in exactly the case a peer exists. + {"exhausted lock resources is a real fault", windows.ERROR_NO_SYSTEM_RESOURCES, false}, + {"no memory for the lock record is a real fault", windows.ERROR_NOT_ENOUGH_MEMORY, false}, + {"an invalid lock request is a real fault", windows.ERROR_INVALID_PARAMETER, false}, + // Contention is filtered by tryLockHandle before it can reach here, and it is + // the one error that proves a peer holds the lock — the worst possible thing to + // waive. Asserted so the two classifications can never be merged. + {"contention is not unavailability", windows.ERROR_LOCK_VIOLATION, false}, + {"sharing violation is a real fault", windows.ERROR_SHARING_VIOLATION, false}, + {"access denied is a real fault", windows.ERROR_ACCESS_DENIED, false}, + {"bad handle is a real fault", windows.ERROR_INVALID_HANDLE, false}, + {"a non-errno error is a real fault", errors.New("something else"), false}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := lockUnavailable(tc.err); got != tc.want { + t.Fatalf("lockUnavailable(%v) = %v, want %v", tc.err, got, tc.want) + } + }) + } +} diff --git a/internal/devicepolicy/verify.go b/internal/devicepolicy/verify.go index 46378a5..d15414d 100644 --- a/internal/devicepolicy/verify.go +++ b/internal/devicepolicy/verify.go @@ -29,6 +29,17 @@ const CategoryIDEExtension = "ide_extension" // report. const TargetVSCode = "vscode" +// CategoryPackageConfig / TargetNPM identify the second policy category: the +// managed StepSecurity secure-registry block inside the console user's ~/.npmrc +// (see npmrc.go). They mirror agent-api's package_config / npm identifiers and, +// like the IDE pair, are passed as ?category=/?target= on the run-config fetch +// and echoed in the compliance report. The Verify() states are shared verbatim; +// this category adds no new state. +const ( + CategoryPackageConfig = "package_config" + TargetNPM = "npm" +) + // VerifyInput is the result set the verifier reasons over. It is intentionally // pure data: the writer performs the I/O (write + readback), so Verify itself // touches nothing.