From bac67ff5c89f95bac8e4041df41cc036ce457ecc Mon Sep 17 00:00:00 2001 From: Subham Ray Date: Sun, 19 Jul 2026 15:35:21 +0530 Subject: [PATCH 01/10] feat(devicepolicy): enforce npm secure-registry policy via managed ~/.npmrc block Add the package_config#npm device-policy lane: converge a StepSecurity-owned block in the console user's ~/.npmrc so npm (and the pnpm / yarn v1 / bun tools that read the same file) resolve packages through the tenant's secure registry, alongside the existing VS Code ide_extension lane. The target is a file the agent may touch as root (macOS LaunchDaemon) against a user-controlled home, so every file operation goes through Go 1.26 os.Root with explicit symlink-chain resolution, post-open identity re-checks, and metadata changes on open handles (never by path) to defeat symlink/entry-swap races. Supporting pieces: - an INI classifier that mirrors npm's own key/value parsing (spaced forms, inline comments, single/double-quoted and JSON-escaped keys) and fails closed on INI sections, bare CRs, and coercible non-string quoted keys it cannot safely reason about; - a cross-process reconciliation lock (flock / LockFileEx) serializing scheduled and manual cycles per guarded file; - a per-uid ownership state store kept out of the shared unlocked cache; - reconciler seams (Converged / Render / ProbeExpected / RestoreSnapshot / OwnsByMarker / State / ...) that leave the settings.json path byte-identical when unset; - a content-aware MDM probe, transactional write/clear with snapshot rollback, and bounded, identity-checked backups. Gated behind the existing device-policy feature gate. --- cmd/stepsecurity-dev-machine-guard/main.go | 131 +- internal/devicepolicy/api.go | 10 + internal/devicepolicy/api_identity_test.go | 94 + internal/devicepolicy/npmrc.go | 1772 +++++++++++++++++++ internal/devicepolicy/npmrc_lock.go | 225 +++ internal/devicepolicy/npmrc_test.go | 599 +++++++ internal/devicepolicy/npmrc_unix.go | 61 + internal/devicepolicy/npmrc_unix_test.go | 640 +++++++ internal/devicepolicy/npmrc_windows.go | 173 ++ internal/devicepolicy/reconcile.go | 340 +++- internal/devicepolicy/reconcile_npm_test.go | 576 ++++++ internal/devicepolicy/statestore.go | 273 +++ internal/devicepolicy/statestore_test.go | 210 +++ internal/devicepolicy/verify.go | 11 + internal/featuregate/featuregate.go | 20 +- 15 files changed, 5088 insertions(+), 47 deletions(-) create mode 100644 internal/devicepolicy/api_identity_test.go create mode 100644 internal/devicepolicy/npmrc.go create mode 100644 internal/devicepolicy/npmrc_lock.go create mode 100644 internal/devicepolicy/npmrc_test.go create mode 100644 internal/devicepolicy/npmrc_unix.go create mode 100644 internal/devicepolicy/npmrc_unix_test.go create mode 100644 internal/devicepolicy/npmrc_windows.go create mode 100644 internal/devicepolicy/reconcile_npm_test.go create mode 100644 internal/devicepolicy/statestore.go create mode 100644 internal/devicepolicy/statestore_test.go diff --git a/cmd/stepsecurity-dev-machine-guard/main.go b/cmd/stepsecurity-dev-machine-guard/main.go index c621334..f3db42a 100644 --- a/cmd/stepsecurity-dev-machine-guard/main.go +++ b/cmd/stepsecurity-dev-machine-guard/main.go @@ -3,6 +3,7 @@ package main import ( "context" "encoding/json" + "errors" "fmt" "io" "os" @@ -262,8 +263,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 +357,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 +491,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: @@ -744,3 +764,106 @@ 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. Gated behind FeaturePackageConfigPolicy (independent of the +// IDE-extension gate); a silent no-op when enterprise config is missing. Failures +// are logged but never crash main. +func runPackageConfigEnforce(exec executor.Executor, log *progress.Logger) { + if !featuregate.IsEnabled(featuregate.FeaturePackageConfigPolicy) { + log.Debug("package-config enforce: skipped (feature gated)") + return + } + 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, + 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...) }) + + // Serialize concurrent convergence of this ~/.npmrc across processes and + // privilege modes. Held by another process → skip this cycle entirely, no + // report (the holder is converging and will report). A lock INFRASTRUCTURE + // failure is not contention: fall through with the writer seams unset and + // the error handed to the reconciler, so enforce surfaces write_failed while + // clear/absent stay silent — the unavailable-writer rules. + lock, lerr := w.AcquireReconcileLock() + switch { + case lerr == nil: + defer lock.Close() + r.Writer = w + r.Converged = w.Converged + r.ProbeExpected = w.ProbeExpected + r.RestoreSnapshot = w.RestoreSnapshot + r.State = devicepolicy.NewStateStoreFor(w.TargetUser()) + case errors.Is(lerr, devicepolicy.ErrLockHeld): + log.Debug("package-config enforce: another process holds the reconciliation lock; skipping this cycle") + return + default: + r.WriterInitErr = lerr + } + } + + 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 1d4c9e7..f03c214 100644 --- a/internal/devicepolicy/api.go +++ b/internal/devicepolicy/api.go @@ -199,6 +199,16 @@ func (c *HTTPFetcher) Fetch(ctx context.Context, customerID, deviceID, category, Hash: strings.TrimSpace(p.Hash), GeneratedAt: p.GeneratedAt, } + // 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 } diff --git a/internal/devicepolicy/api_identity_test.go b/internal/devicepolicy/api_identity_test.go new file mode 100644 index 0000000..a829c1f --- /dev/null +++ b/internal/devicepolicy/api_identity_test.go @@ -0,0 +1,94 @@ +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) + } +} diff --git a/internal/devicepolicy/npmrc.go b/internal/devicepolicy/npmrc.go new file mode 100644 index 0000000..19276c1 --- /dev/null +++ b/internal/devicepolicy/npmrc.go @@ -0,0 +1,1772 @@ +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" +) + +// 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 +} + +// TargetUser is the console user resolved once at construction and immutable +// for the writer's lifetime. The per-user state store must be built from this +// identity, never a second independent resolution — two resolutions can +// straddle a console-user switch and bind one user's file to another's record. +func (w *NPMRCWriter) TargetUser() *user.User { return w.targetUser } + +// 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) { + 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. +func (w *NPMRCWriter) Clear() 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 { + // Nothing to clear; leave the (absent) file alone. + return nil + } + + next := w.clearContent(cur) + if bytes.Equal(next, cur) { + // No managed block and no prefixed lines — a no-op that performs no + // write at all. + return 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). + return w.afterFailedRollback(rt, snap, err, "clear commit verification") + } + return err + } + snap.committed = out.committed + w.pending = snap + return 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 + } + } +} + +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) + } + 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. +func (w *NPMRCWriter) clearContent(current []byte) []byte { + rest, bom := stripBOM(current) + 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() +} + +// stripBOM splits a leading UTF-8 BOM off the content. The BOM is removed for +// parsing (so a first-line key is matched correctly) and re-prepended on +// rewrite so the byte is preserved. +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 +} + +// 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) + } + + 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, "" + } + + // 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 +} + +// --------------------------------------------------------------------------- +// 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_lock.go b/internal/devicepolicy/npmrc_lock.go new file mode 100644 index 0000000..de71e6b --- /dev/null +++ b/internal/devicepolicy/npmrc_lock.go @@ -0,0 +1,225 @@ +package devicepolicy + +import ( + "errors" + "fmt" + "os" +) + +// This file adds the cross-process reconciliation lock for the package_config +// #npm category on top of NPMRCWriter. Package-config enforcement runs OUTSIDE +// the telemetry singleton lock (which lives inside telemetry.Run and is released +// when it returns), so a scheduled cycle and a manual run — or a root-daemon +// cycle and a user-mode manual run — could otherwise converge the same ~/.npmrc +// concurrently. This lock serializes them on ONE identity per guarded file. + +// Lock placement, relative to the target user's home. The lock guards exactly +// one file (~/.npmrc), so its identity is a function of that FILE, not of the +// process's privilege: a root-daemon cycle and a user-mode manual run converge +// the same file and therefore must contend on the same lock. The lock lives in +// the target user's own tree in BOTH modes (root can write there; a per-mode +// location would let the two modes interleave freely). +const ( + npmrcStateDirRel = ".stepsecurity" + npmrcLockDirRel = ".stepsecurity/locks" + npmrcLockPathRel = ".stepsecurity/locks/package_config-npm.lock" +) + +// npmrcLockDirMode is the lock directory's mode on POSIX. It holds a coordination +// primitive, not shared data, so it is not group/other-accessible. +const npmrcLockDirMode os.FileMode = 0o700 + +// ErrLockHeld signals another agent process already holds the package-config +// reconciliation lock for this file. The current cycle must skip silently and +// retry next tick — it never reports compliance, because the holder is already +// converging and will report. +var ErrLockHeld = errors.New("npmrc: reconciliation lock held by another process") + +// npmrcLock is a held cross-process lock. Release closes the fd (which drops the +// OS advisory lock); the lock FILE is deliberately never unlinked — unlink then +// recreate would let two processes lock different inodes under one path and both +// "win". +type npmrcLock struct { + f *os.File +} + +// Close releases the lock by closing its fd. The lock file is intentionally left +// on disk for the next cycle to re-lock. +func (l *npmrcLock) Close() error { + if l == nil || l.f == nil { + return nil + } + err := l.f.Close() + l.f = nil + return err +} + +// AcquireReconcileLock takes the cross-process reconciliation lock that +// serializes package-config convergence of THIS user's ~/.npmrc across every +// agent process and privilege mode: +// +// - The lock file lives at /.stepsecurity/locks/package_config-npm.lock, +// created through the SAME home os.Root the writer uses, so no path component +// is ever resolved through a symlink out of the home — the primitive the +// writer already defends against for .npmrc itself. +// - On POSIX the lock directory and file are chowned to the target user via +// open handles, so a root-daemon cycle creates a lock the later user-mode +// agent can still open — without it the two modes could never share one inode. +// - The lock is advisory and non-blocking. Held by another process → ErrLockHeld +// (the caller skips this cycle). It is held on a persistent fd and is NEVER +// unlinked on release. +// +// Any error other than ErrLockHeld is an infrastructure failure (cannot create +// the directory/file, EPERM); the caller classifies it like an unavailable +// writer — surfaced on enforce, silent on clear/absent. +func (w *NPMRCWriter) AcquireReconcileLock() (*npmrcLock, error) { + if w == nil || w.root == nil { + return nil, errors.New("npmrc: writer is closed") + } + // Create the lock directory chain if absent. Only a directory THIS call freshly + // creates is chowned to the target user; a pre-existing one (the user's own + // ~/.stepsecurity, possibly an in-home symlink to their dotfiles) is left as + // found. os.Root confines every component to the home tree. + for _, dir := range []string{npmrcStateDirRel, npmrcLockDirRel} { + if err := w.ensureOwnedDir(dir); err != nil { + return nil, err + } + } + + f, created, err := w.openLockFile() + if err != nil { + return nil, err + } + // Chown ONLY a lock file this call just created (O_EXCL guarantees it is a + // fresh regular file). An already-present lock is never chowned: it could be an + // in-home symlink or a file the user planted, and transferring ownership of a + // file we did not create is exactly the root-to-user escalation this guards + // against. Chown-on-create is still enough for the two modes to share one inode + // (a root cycle creates it user-owned; a later user cycle opens it). + if created && enforcePOSIXMetadata { + if err := chownHandle(f, w.uid, w.gid); err != nil { + f.Close() + return nil, fmt.Errorf("npmrc: chown lock file: %w", err) + } + } + + acquired, err := tryExclusiveLock(f) + if err != nil { + f.Close() + return nil, fmt.Errorf("npmrc: lock: %w", err) + } + if !acquired { + f.Close() + return nil, ErrLockHeld + } + return &npmrcLock{f: f}, nil +} + +// openLockFile creates the lock file if absent, or opens the existing one — in +// both cases without ever following a symlink at the final component, the same +// discipline readCurrent applies to .npmrc. It returns created=true only when this +// call made a fresh regular file via O_CREATE|O_EXCL (the one open mode os.Root +// never resolves through a final symlink). If the file already exists it is opened +// O_RDWR after an Lstat pre-screen (reject a symlink or non-regular file) plus a +// post-open SameFile re-check, so an in-home symlink swapped in at the path cannot +// redirect the open. Only a freshly created file is safe for the caller to chown. +func (w *NPMRCWriter) openLockFile() (f *os.File, created bool, err error) { + f, err = w.root.OpenFile(npmrcLockPathRel, os.O_CREATE|os.O_EXCL|os.O_RDWR, npmrcFileMode) + if err == nil { + return f, true, nil + } + if !errors.Is(err, os.ErrExist) { + return nil, false, fmt.Errorf("npmrc: create lock file: %w", err) + } + + li, err := w.root.Lstat(npmrcLockPathRel) + if err != nil { + return nil, false, fmt.Errorf("npmrc: lstat lock file: %w", err) + } + if li.Mode()&os.ModeSymlink != 0 || !li.Mode().IsRegular() { + return nil, false, fmt.Errorf("npmrc: lock path is not a regular file: %w", ErrTargetUnusable) + } + f, err = w.root.OpenFile(npmrcLockPathRel, os.O_RDWR|nonblockOpenFlag(), 0) + if err != nil { + return nil, false, fmt.Errorf("npmrc: open lock file: %w", err) + } + hi, err := f.Stat() + if err != nil { + f.Close() + return nil, false, fmt.Errorf("npmrc: stat lock handle: %w", err) + } + li2, err := w.root.Lstat(npmrcLockPathRel) + if err != nil { + f.Close() + return nil, false, fmt.Errorf("npmrc: re-lstat lock file: %w", err) + } + if li2.Mode()&os.ModeSymlink != 0 || !hi.Mode().IsRegular() || !os.SameFile(li2, hi) { + f.Close() + return nil, false, fmt.Errorf("npmrc: lock file changed during open: %w", ErrTargetUnusable) + } + return f, false, nil +} + +// ensureOwnedDir creates one directory relative to the home root if it does not +// exist and, on POSIX, chowns a freshly created one to the target user. An +// already-present directory is left exactly as found (the config layer owns +// ~/.stepsecurity, which may legitimately be an in-home symlink). It goes through +// the home os.Root so no path component is ever resolved out of the home. Before +// the chown it BINDS the created inode two ways so a create→open swap cannot +// redirect ownership onto another file: the opened handle's identity is +// re-verified (Lstat not-a-symlink + SameFile), AND the handle must be owned by +// our own effective uid — proof it is the directory we just made, since the user +// who controls this tree cannot fabricate a root-owned directory to swap in. The +// path re-Lstat alone cannot provide that anchor: it would happily bind an +// attacker's stable swap. Only after both checks is ownership transferred. +func (w *NPMRCWriter) ensureOwnedDir(rel string) error { + err := w.root.Mkdir(rel, npmrcLockDirMode) + if errors.Is(err, os.ErrExist) { + return nil + } + if err != nil { + return fmt.Errorf("npmrc: mkdir %q: %w", rel, err) + } + if !enforcePOSIXMetadata { + return nil + } + li, lerr := w.root.Lstat(rel) + if lerr != nil { + return fmt.Errorf("npmrc: lstat dir %q: %w", rel, lerr) + } + if li.Mode()&os.ModeSymlink != 0 || !li.IsDir() { + return fmt.Errorf("npmrc: lock dir %q is not a directory: %w", rel, ErrTargetUnusable) + } + d, oerr := w.root.Open(rel) + if oerr != nil { + return fmt.Errorf("npmrc: open dir %q for chown: %w", rel, oerr) + } + defer d.Close() + di, serr := d.Stat() + if serr != nil { + return fmt.Errorf("npmrc: stat dir handle %q: %w", rel, serr) + } + li2, lerr := w.root.Lstat(rel) + if lerr != nil { + return fmt.Errorf("npmrc: re-lstat dir %q: %w", rel, lerr) + } + if li2.Mode()&os.ModeSymlink != 0 || !di.IsDir() || !os.SameFile(li2, di) { + return fmt.Errorf("npmrc: lock dir %q changed during creation: %w", rel, ErrTargetUnusable) + } + // Creator-identity anchor: a directory we just created is owned by our own + // effective uid (root under the daemon). The target user could rmdir+swap rel + // between the Mkdir and this open, but cannot fabricate a root-owned directory, + // so an owner==euid handle proves this is the inode we created and not a + // substitution — the guarantee SameFile above cannot make on its own. + duid, _, downed, oerr := w.owners.ownerUIDGID(d) + if oerr != nil { + return fmt.Errorf("npmrc: read new dir owner %q: %w", rel, oerr) + } + if downed && duid != uint32(os.Geteuid()) { + return fmt.Errorf("npmrc: lock dir %q not owned by its creator; refusing chown: %w", rel, ErrTargetUnusable) + } + if cerr := chownHandle(d, w.uid, w.gid); cerr != nil { + return fmt.Errorf("npmrc: chown dir %q: %w", rel, cerr) + } + return nil +} diff --git a/internal/devicepolicy/npmrc_test.go b/internal/devicepolicy/npmrc_test.go new file mode 100644 index 0000000..e1531bb --- /dev/null +++ b/internal/devicepolicy/npmrc_test.go @@ -0,0 +1,599 @@ +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 §3 clear algorithm (pure []byte -> []byte) +// --------------------------------------------------------------------------- + +func clearOf(current string) string { + w := &NPMRCWriter{} + return string(w.clearContent([]byte(current))) +} + +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(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(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(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(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(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 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..1a330a3 --- /dev/null +++ b/internal/devicepolicy/npmrc_unix.go @@ -0,0 +1,61 @@ +//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) } + +// tryExclusiveLock attempts a non-blocking exclusive advisory lock (flock) on an +// open file. acquired=true when this fd now holds it; acquired=false with a nil +// error means another process holds it (contention); a non-nil error is an +// infrastructure failure. The lock releases when the fd is closed. +func tryExclusiveLock(f *os.File) (acquired bool, err error) { + e := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) + if e == nil { + return true, nil + } + if errors.Is(e, syscall.EWOULDBLOCK) { + return false, nil + } + return false, e +} + +// 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..7202cb5 --- /dev/null +++ b/internal/devicepolicy/npmrc_unix_test.go @@ -0,0 +1,640 @@ +//go:build unix + +package devicepolicy + +import ( + "errors" + "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") + } +} + +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 TestAcquireReconcileLock_LifecycleAndContention(t *testing.T) { + home := t.TempDir() + w1 := newDiskWriter(t, home) + + lock, err := w1.AcquireReconcileLock() + if err != nil { + t.Fatalf("first acquire: %v", err) + } + + // The lock file and its directory chain now exist, 0600 file under a 0700 dir. + lockDir := filepath.Join(home, ".stepsecurity", "locks") + lockPath := filepath.Join(lockDir, "package_config-npm.lock") + if fi, err := os.Stat(lockPath); err != nil { + t.Fatalf("lock file not created: %v", err) + } else if fi.Mode().Perm() != 0o600 { + t.Fatalf("lock file mode = %v, want 0600", fi.Mode().Perm()) + } + if di, err := os.Stat(lockDir); err != nil { + t.Fatalf("lock dir not created: %v", err) + } else if di.Mode().Perm() != 0o700 { + t.Fatalf("lock dir mode = %v, want 0700", di.Mode().Perm()) + } + + // A second, independent open of the same file observes contention (flock + // treats the two file descriptions independently even within one process). + w2 := newDiskWriter(t, home) + if _, err := w2.AcquireReconcileLock(); !errors.Is(err, ErrLockHeld) { + t.Fatalf("second acquire while held: want ErrLockHeld, got %v", err) + } + + // Releasing closes the fd but must NEVER unlink the file — a recreate would + // let two processes lock different inodes under one path and both proceed. + if err := lock.Close(); err != nil { + t.Fatalf("release: %v", err) + } + if _, err := os.Stat(lockPath); err != nil { + t.Fatalf("lock file must survive release (never unlinked): %v", err) + } + + // After release the lock is re-acquirable on the same inode. + lock2, err := w2.AcquireReconcileLock() + if err != nil { + t.Fatalf("re-acquire after release: %v", err) + } + if err := lock2.Close(); err != nil { + t.Fatalf("release 2: %v", err) + } +} + +func TestAcquireReconcileLock_PreexistingStepsecurityDir(t *testing.T) { + // The common case: ~/.stepsecurity already exists (config.json lives there). + // Mkdir returns ErrExist and the lock still acquires; the pre-existing dir is + // left untouched. + home := t.TempDir() + if err := os.Mkdir(filepath.Join(home, ".stepsecurity"), 0o700); err != nil { + t.Fatalf("seed .stepsecurity: %v", err) + } + w := newDiskWriter(t, home) + lock, err := w.AcquireReconcileLock() + if err != nil { + t.Fatalf("acquire with pre-existing .stepsecurity: %v", err) + } + if err := lock.Close(); err != nil { + t.Fatalf("release: %v", err) + } +} + +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 TestAcquireReconcileLock_SymlinkedLockPathRejected(t *testing.T) { + // A user who pre-plants the lock path as an in-home symlink must not get the + // (possibly root) daemon to open-and-chown the pointed-at file. openLockFile + // Lstat-rejects a symlink at the lock path rather than following it. + home := t.TempDir() + locks := filepath.Join(home, ".stepsecurity", "locks") + if err := os.MkdirAll(locks, 0o700); err != nil { + t.Fatalf("mkdir locks: %v", err) + } + if err := os.WriteFile(filepath.Join(home, "target"), []byte("secret\n"), 0o600); err != nil { + t.Fatalf("seed target: %v", err) + } + // Relative, in-home target so os.Root would otherwise follow it. + if err := os.Symlink(filepath.Join("..", "..", "target"), filepath.Join(locks, "package_config-npm.lock")); err != nil { + t.Fatalf("plant symlink: %v", err) + } + w := newDiskWriter(t, home) + if _, err := w.AcquireReconcileLock(); !isTargetUnusable(err) { + t.Fatalf("symlinked lock path: want ErrTargetUnusable, got %v", err) + } +} + +func TestAcquireReconcileLock_FreshDirNotCreatorOwnedRejected(t *testing.T) { + // The dir-chown path binds the created inode by requiring it be owned by our own + // euid — proof the daemon created it, and that an attacker did not rmdir+swap in + // their own directory after the Mkdir (they cannot forge a root-owned one). A + // handle reporting a foreign owner is refused rather than chowned to the user. + home := t.TempDir() + w := newDiskWriter(t, home) + w.owners = fakeOwner{uid: uint32(os.Geteuid()) + 1, enforced: true} // never our euid + if _, err := w.AcquireReconcileLock(); !isTargetUnusable(err) { + t.Fatalf("fresh dir with a foreign owner: want ErrTargetUnusable, got %v", err) + } +} diff --git a/internal/devicepolicy/npmrc_windows.go b/internal/devicepolicy/npmrc_windows.go new file mode 100644 index 0000000..d4ec2b3 --- /dev/null +++ b/internal/devicepolicy/npmrc_windows.go @@ -0,0 +1,173 @@ +//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 } + +// tryExclusiveLock attempts a non-blocking exclusive lock (LockFileEx with +// LOCKFILE_FAIL_IMMEDIATELY) on an open file — there is no flock on Windows. +// acquired=true when this handle now holds it; acquired=false with a nil error +// means another process holds it; a non-nil error is an infrastructure failure. +// The lock releases when the handle is closed. +func tryExclusiveLock(f *os.File) (acquired bool, err error) { + ol := new(windows.Overlapped) + e := windows.LockFileEx( + windows.Handle(f.Fd()), + windows.LOCKFILE_EXCLUSIVE_LOCK|windows.LOCKFILE_FAIL_IMMEDIATELY, + 0, 1, 0, ol, + ) + if e == nil { + return true, nil + } + if errors.Is(e, windows.ERROR_LOCK_VIOLATION) { + return false, nil + } + return false, e +} + +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/reconcile.go b/internal/devicepolicy/reconcile.go index 04a179f..214cdb3 100644 --- a/internal/devicepolicy/reconcile.go +++ b/internal/devicepolicy/reconcile.go @@ -2,6 +2,7 @@ package devicepolicy import ( "context" + "encoding/json" "errors" "fmt" "time" @@ -33,6 +34,62 @@ type Reconciler struct { // inject a stub so results never depend on the host machine. Probe func() (managed bool, detail 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 + + // State, when set, is the ownership store this cycle reads and writes through, + // replacing the package-level shared-file functions. The npm category uses + // its own per-mode/per-user store so its record can never share the IDE + // file's unlocked read-modify-write. nil → the shared package-level store + // (settings.json), byte-identical to before. + State StateStore + // Now and Logf are optional seams. Now defaults to time.Now().UTC; Logf to a // no-op. Now func() time.Time @@ -40,11 +97,26 @@ type Reconciler struct { // writeState and clearState are test seams over the ownership store // (WriteAppliedState / ClearAppliedState). nil → the real implementation. + // They apply only to the shared store (State == nil). writeState func(category, target string, s AppliedTargetState) error clearState func(category, target string) error } +// readState / persistState / dropState route through the injected StateStore +// when one is set, and otherwise fall back to the shared package-level store +// (with the writeState/clearState test seams) exactly as before — so the IDE +// wiring, which sets no State, is unchanged. +func (r *Reconciler) readState(cat, tgt string) (AppliedTargetState, bool) { + if r.State != nil { + return r.State.Read(cat, tgt) + } + return ReadAppliedState(cat, tgt) +} + func (r *Reconciler) persistState(cat, tgt string, s AppliedTargetState) error { + if r.State != nil { + return r.State.Write(cat, tgt, s) + } if r.writeState != nil { return r.writeState(cat, tgt, s) } @@ -52,12 +124,90 @@ func (r *Reconciler) persistState(cat, tgt string, s AppliedTargetState) error { } func (r *Reconciler) dropState(cat, tgt string) error { + if r.State != nil { + return r.State.Drop(cat, tgt) + } if r.clearState != nil { return r.clearState(cat, tgt) } 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() @@ -97,13 +247,18 @@ func (r *Reconciler) probe() (bool, string) { // // - fetch error (transport / non-200 / malformed) → NO-OP, error returned. // Enforcement on disk is never wiped on a transient or malformed response. -// - platform not enforceable (nil Writer) → silent no-op. +// - 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 { @@ -120,8 +275,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() { @@ -138,13 +300,53 @@ func (r *Reconciler) Reconcile(ctx context.Context) error { return r.handleEnforce(ctx, cat, tgt, ep) } -// handleClear removes the agent-owned settings key on unassignment. 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. +// 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. 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) onDisk, present, err := r.Writer.Read() if err != nil { return fmt.Errorf("devicepolicy: clear: read %s: %w", r.Writer.Location(), err) @@ -174,6 +376,26 @@ func (r *Reconciler) handleClear(cat, tgt string) error { return nil } +// 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 { + if err := r.Writer.Clear(); err != nil { + return fmt.Errorf("devicepolicy: clear %s: %w", r.Writer.Location(), err) + } + r.logf("devicepolicy: cleared managed block at %s", r.Writer.Location()) + if err := r.dropState(cat, tgt); err != nil { + return fmt.Errorf("devicepolicy: clear: update state: %w", err) + } + return nil +} + // handleEnforce converges settings.json to the compiled policy and reports. // The ladder, in order: // @@ -186,42 +408,82 @@ func (r *Reconciler) handleClear(cat, tgt string) 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 { - // The compiled policy compacted: the canonical comparison form for - // readback, idempotency, and ownership. (The backend's hash still travels - // verbatim; only the value bytes are normalized for comparison.) - newValue, err := compactJSON(ep.Policy) + // The value to enforce: 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 object shape, so this 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) } - // 1. 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. - 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. + 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, "") } - // 2. Read the current settings value. - prev, hadPrev := ReadAppliedState(cat, tgt) + // 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) } - // 3. 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) } + // 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 THIS cycle's store does not carry this hash. That happens when + // the other privilege mode applied it and recorded in its own per-mode store, + // or our record is stale. Adopt the on-disk state into this store rather than + // churn a redundant rewrite or misreport it as drift, and report compliant. + // Best-effort: the block is already applied, so a store hiccup only defers the + // record one cycle. Gated on the Converged seam so the settings.json path + // (body equality, shared store) is byte-identical to before. + if converged && r.Converged != nil { + if perr := r.persistState(cat, tgt, AppliedTargetState{ + AppliedHash: ep.Hash, + WrittenValue: 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 @@ -247,7 +509,9 @@ func (r *Reconciler) handleEnforce(ctx context.Context, cat, tgt string, ep Effe // 6. 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 @@ -263,10 +527,14 @@ func (r *Reconciler) handleEnforce(ctx context.Context, cat, tgt string, ep Effe WrittenValue: 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, "") + // The write happened but ownership couldn't be recorded — undo it so no + // 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) diff --git a/internal/devicepolicy/reconcile_npm_test.go b/internal/devicepolicy/reconcile_npm_test.go new file mode 100644 index 0000000..3858ff7 --- /dev/null +++ b/internal/devicepolicy/reconcile_npm_test.go @@ -0,0 +1,576 @@ +package devicepolicy + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "testing" + "time" +) + +// The tests here drive the reconciler with the seams the ~/.npmrc path sets +// (Render, Converged, ProbeExpected, RestoreSnapshot, OwnsByMarker, State). 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. + +// --- in-memory StateStore fake --------------------------------------------- + +// memStateStore is an in-memory StateStore for the npm reconcile tests. The npm +// category always drives ownership through the State seam (never the shared +// package-level cache), so these tests inject this instead of using +// withTempCache. It counts calls so a test can assert the ladder routed through +// the store, and failWriteFrom forces the post-write persist to fail. +type memStateStore struct { + m map[string]AppliedTargetState + writeErr error + dropErr error + failWriteFrom int // when >0, Write errors on the Nth call and after + writes int + drops int + reads int +} + +func msKey(cat, tgt string) string { return cat + "\x00" + tgt } + +func (s *memStateStore) Read(cat, tgt string) (AppliedTargetState, bool) { + s.reads++ + st, ok := s.m[msKey(cat, tgt)] + return st, ok +} + +func (s *memStateStore) Write(cat, tgt string, st AppliedTargetState) error { + s.writes++ + if s.failWriteFrom > 0 && s.writes >= s.failWriteFrom { + return errors.New("state store write failed") + } + if s.writeErr != nil { + return s.writeErr + } + if s.m == nil { + s.m = map[string]AppliedTargetState{} + } + s.m[msKey(cat, tgt)] = st + return nil +} + +func (s *memStateStore) Drop(cat, tgt string) error { + s.drops++ + if s.dropErr != nil { + return s.dropErr + } + delete(s.m, msKey(cat, tgt)) + return nil +} + +func (s *memStateStore) get(cat, tgt string) (AppliedTargetState, bool) { + st, ok := s.m[msKey(cat, tgt)] + return st, ok +} + +// --- 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, State-backed reconciler wired like the +// ~/.npmrc path: OwnsByMarker, a Render seam that produces the managed block, a +// content-aware ProbeExpected, and a Converged seam. Defaults: Render → the +// fixed block, probe → not managed, Converged → false (proceed to write). Tests +// override a single seam to exercise one rung. No withTempCache — State routes +// every ownership access away from the shared file. +func newNPMRec(t *testing.T, ep EffectivePolicy, w *fakeWriter, st *memStateStore) (*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, + State: st, + 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) }, + } + return r, rep +} + +// --- tests ------------------------------------------------------------------ + +func TestNPMEnforceRendersBlockAndWrites(t *testing.T) { + w := &fakeWriter{} + st := &memStateStore{} + 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 through the State seam (never the shared file). + if st.writes == 0 { + t.Fatal("ownership must be recorded through the State seam") + } + rec, ok := st.get(CategoryPackageConfig, TargetNPM) + if !ok || rec.WrittenValue != 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 := &memStateStore{} + 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 := &memStateStore{} + 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 := &memStateStore{m: map[string]AppliedTargetState{ + msKey(CategoryPackageConfig, TargetNPM): {AppliedHash: "sha256:N", WrittenValue: 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 := &memStateStore{m: map[string]AppliedTargetState{ + msKey(CategoryPackageConfig, TargetNPM): {AppliedHash: "sha256:N", WrittenValue: 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) { + // Cross-mode store split: the exact block is fully applied on disk + // (Converged=true) but THIS store carries no matching hash — the other + // privilege mode applied and recorded it in its own per-mode store, or our + // record is stale. 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 + st *memStateStore + }{ + {"empty store (other mode applied)", &memStateStore{}}, + {"stale hash in store", &memStateStore{m: map[string]AppliedTargetState{ + msKey(CategoryPackageConfig, TargetNPM): {AppliedHash: "sha256:OLD", WrittenValue: npmRendered}, + }}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + w := &fakeWriter{value: npmRendered, present: true} + r, rep := newNPMRec(t, npmPolicyEP("sha256:NEW"), w, tc.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 := tc.st.get(CategoryPackageConfig, TargetNPM) + if !ok || rec.AppliedHash != "sha256:NEW" || rec.WrittenValue != 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 := &memStateStore{} + 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 := &memStateStore{} + 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 + st *memStateStore + }{ + {"no record", &memStateStore{}}, + {"stale record", &memStateStore{m: map[string]AppliedTargetState{ + msKey(CategoryPackageConfig, TargetNPM): {WrittenValue: "old-block"}, + }}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + w := &fakeWriter{value: "a-managed-block", present: true} + ep := EffectivePolicy{Category: CategoryPackageConfig, Target: TargetNPM, Clear: true} + r, rep := newNPMRec(t, ep, w, tc.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 tc.st.drops != 1 { + t.Fatalf("marker clear must Drop the record unconditionally, drops=%d", tc.st.drops) + } + if _, ok := tc.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 := &memStateStore{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 := &memStateStore{} + 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) { + 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 TestNPMStateRoutingBypassesSharedFile(t *testing.T) { + // With the State seam set, every ownership access routes to the injected store; + // the shared device-policy-state.json is never created or read. This keeps the + // npm record out of the shared file's unlocked read-modify-write. + path := filepath.Join(t.TempDir(), CacheFilename) + restore := SetCachePathForTest(path) + defer restore() + + w := &fakeWriter{} + st := &memStateStore{} + r, _ := newNPMRec(t, npmPolicyEP("sha256:N"), w, st) + if err := r.Reconcile(context.Background()); err != nil { + t.Fatalf("Reconcile: %v", err) + } + if st.writes == 0 { + t.Fatal("ownership must have routed through the State seam") + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("shared state file must never be created when State is set; stat err = %v", err) + } + if _, ok := ReadAppliedState(CategoryPackageConfig, TargetNPM); ok { + t.Fatal("shared store must hold no npm record") + } +} + +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) + } +} diff --git a/internal/devicepolicy/statestore.go b/internal/devicepolicy/statestore.go new file mode 100644 index 0000000..095fa6f --- /dev/null +++ b/internal/devicepolicy/statestore.go @@ -0,0 +1,273 @@ +package devicepolicy + +import ( + "encoding/json" + "errors" + "os" + "os/user" + "path/filepath" + "runtime" + "strconv" + "sync" +) + +// StateStore is the ownership store the reconciler reads and writes through. It +// abstracts WHERE a category's applied-state records live so a category can keep +// its records in its own file instead of the shared device-policy-state.json. +// The three methods mirror the package-level ReadAppliedState / +// WriteAppliedState / ClearAppliedState the shared IDE path still calls directly. +// +// The npm category always uses one of these; the IDE category leaves it nil and +// keeps the shared store, byte-for-byte unchanged. +type StateStore interface { + Read(category, target string) (AppliedTargetState, bool) + Write(category, target string, s AppliedTargetState) error + Drop(category, target string) error +} + +// packageConfigStateBasename is the state-file basename for the package_config +// category. It is DELIBERATELY separate from CacheFilename (the shared IDE +// state): the shared file's read-modify-write is guarded only by an in-process +// mutex — its own comment says a cross-process lock "would be needed before +// categories are reconciled concurrently" — and only package reconciliation +// takes that lock. Sharing one file could let an IDE reconcile in another +// process silently drop the package record (or vice versa); separate files make +// the lost update structurally impossible. +const packageConfigStateBasename = "package-config-state" + +// NewStateStoreFor builds the package_config ownership store for a resolved +// target user. It is bound to the identity handed in — the writer's +// TargetUser(), resolved exactly once — never a second independent lookup, so a +// console-user switch between two resolutions cannot file one user's state under +// another's record. +// +// Placement: +// - user mode (the process IS the target user; the common case, and every +// Windows case) → /.stepsecurity/package-config-state.json (0600). +// - root mode (a POSIX daemon running as uid 0) → a machine-owned directory +// (/Library/Application Support/StepSecurity on macOS, /var/lib/stepsecurity +// on Linux; 0700 root) with ONE record file per target uid +// (package-config-state-.json). The shared store's ~/.stepsecurity is +// unsafe under root here: the LaunchDaemon bakes the install-time console +// user's HOME into its plist, so os.UserHomeDir() resolves into a +// user-controlled tree and stays pinned to that user even after someone else +// logs in. Per-uid files also keep one console user's ~/.npmrc state from +// conflating with another's across cycles (false drift / wrong ownership). +func NewStateStoreFor(u *user.User) StateStore { + if dir, uid, ok := rootMachineStateDir(u); ok { + return &fileStateStore{ + dir: dir, + path: filepath.Join(dir, packageConfigStateBasename+"-"+uid+".json"), + } + } + home := "" + if u != nil { + home = u.HomeDir + } + dir := filepath.Join(home, ".stepsecurity") + return &fileStateStore{ + dir: dir, + path: filepath.Join(dir, packageConfigStateBasename+".json"), + } +} + +// rootMachineStateDir returns the machine-owned state directory and the target +// user's uid string when the process is a POSIX root daemon with a usable +// per-user identity. ok=false — meaning user-mode placement — when the process +// is not root (Geteuid is -1 on Windows, so it never is there), the OS has no +// defined machine path, or the uid is unresolvable. +func rootMachineStateDir(u *user.User) (dir, uid string, ok bool) { + if os.Geteuid() != 0 || u == nil { + return "", "", false + } + if _, err := strconv.Atoi(u.Uid); err != nil { + return "", "", false + } + switch runtime.GOOS { + case "darwin": + return "/Library/Application Support/StepSecurity", u.Uid, true + case "linux": + return "/var/lib/stepsecurity", u.Uid, true + default: + return "", "", false + } +} + +// fileStateStore is a StateStore backed by one JSON file at a fixed path. It +// carries the same schema-versioned category→target→record shape and the same +// atomic-replace (temp + fsync + rename) + future-schema-refusal discipline as +// the shared cache.go store — only the path differs, and the file belongs to +// this category alone. A process-local mutex serializes its read-modify-write; +// the cross-process lock (main.go) covers separate processes. +type fileStateStore struct { + dir string + path string + mu sync.Mutex +} + +func (s *fileStateStore) Read(category, target string) (AppliedTargetState, bool) { + s.mu.Lock() + defer s.mu.Unlock() + + f, status := s.load() + if status != stateReadable { + return AppliedTargetState{}, false + } + cat, ok := f.Categories[category] + if !ok { + return AppliedTargetState{}, false + } + st, ok := cat.Targets[target] + return st, ok +} + +func (s *fileStateStore) Write(category, target string, st AppliedTargetState) error { + s.mu.Lock() + defer s.mu.Unlock() + + f, status := s.load() + 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] = st + f.Categories[category] = cat + return s.persist(f) +} + +func (s *fileStateStore) Drop(category, target string) error { + s.mu.Lock() + defer s.mu.Unlock() + + f, status := s.load() + switch status { + case stateFuture: + return errFutureSchema + case stateAbsentOrCorrupt: + // Absent → nothing to drop. Corrupt (present but unparseable) → the bytes on + // disk can still carry a token-bearing WrittenValue; a Drop that left the file + // in place would strand that credential after offboarding. Remove it so no + // stale record — readable or not — survives the clear. + return s.removeIfPresent() + } + 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 s.persist(f) +} + +// removeIfPresent deletes this store's file, treating an already-absent file as +// success. It underpins Drop's corrupt-file cleanup: a corrupt state file is +// removed rather than left to strand a token-bearing record on disk. A symlink at +// the path is unlinked (not its target), so it cannot redirect the delete. +func (s *fileStateStore) removeIfPresent() error { + if s.path == "" { + return nil + } + if err := os.Remove(s.path); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + return nil +} + +// load reads and classifies this store's file. UNLOCKED: callers hold s.mu. It +// is the path-parameterized twin of cache.go's readStateFile and reuses the same +// schema-version classification (corrupt → recreate, newer → refuse). +func (s *fileStateStore) load() (AppliedStateFile, readStatus) { + if s.path == "" { + return AppliedStateFile{}, stateAbsentOrCorrupt + } + // #nosec G304 -- s.path is built from the resolved target user's home or a + // fixed machine directory plus package constants, never external input. + b, err := os.ReadFile(s.path) + if err != nil { + return AppliedStateFile{}, stateAbsentOrCorrupt + } + ver, ok := peekSchemaVersion(b) + if !ok { + return AppliedStateFile{}, stateAbsentOrCorrupt + } + if ver > CacheSchemaVersion { + return AppliedStateFile{}, stateFuture + } + var f AppliedStateFile + if err := json.Unmarshal(b, &f); err != nil { + return AppliedStateFile{}, stateAbsentOrCorrupt + } + if f.SchemaVersion == 0 { + f.SchemaVersion = CacheSchemaVersion + } + if f.Categories == nil { + f.Categories = map[string]AppliedCategoryState{} + } + return f, stateReadable +} + +// persist stamps the schema version and atomically replaces the file, creating +// the parent dir 0700 and the file 0600. UNLOCKED: callers hold s.mu. The +// path-parameterized twin of cache.go's persistStateFile. On POSIX root the dir +// and file land root-owned (machine state); in user mode they are the target +// user's own — either way not group/other-accessible. +func (s *fileStateStore) persist(f AppliedStateFile) error { + f.SchemaVersion = CacheSchemaVersion + if f.Categories == nil { + f.Categories = map[string]AppliedCategoryState{} + } + if s.path == "" || s.dir == "" { + return errNoHomeDir + } + data, err := json.MarshalIndent(f, "", " ") + if err != nil { + return err + } + data = append(data, '\n') + + if err := os.MkdirAll(s.dir, cacheParentDirMode); err != nil { + return err + } + tmp, err := os.CreateTemp(s.dir, "."+packageConfigStateBasename+".tmp-*") + if err != nil { + return err + } + tmpPath := tmp.Name() + defer func() { + if _, statErr := os.Stat(tmpPath); statErr == nil { + _ = os.Remove(tmpPath) + } + }() + + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Sync(); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + if err := os.Chmod(tmpPath, cacheFileMode); err != nil { + return err + } + return os.Rename(tmpPath, s.path) +} diff --git a/internal/devicepolicy/statestore_test.go b/internal/devicepolicy/statestore_test.go new file mode 100644 index 0000000..11d3dfb --- /dev/null +++ b/internal/devicepolicy/statestore_test.go @@ -0,0 +1,210 @@ +package devicepolicy + +import ( + "os" + "os/user" + "path/filepath" + "strings" + "testing" + "time" +) + +func newFileStore(t *testing.T) *fileStateStore { + t.Helper() + dir := t.TempDir() + return &fileStateStore{ + dir: dir, + path: filepath.Join(dir, packageConfigStateBasename+".json"), + } +} + +func TestFileStateStoreRoundTrip(t *testing.T) { + s := newFileStore(t) + if _, ok := s.Read(CategoryPackageConfig, TargetNPM); ok { + t.Fatal("an empty store must read absent") + } + want := AppliedTargetState{ + AppliedHash: "sha256:N", + WrittenValue: "managed-block", + FetchedAt: time.Date(2026, 7, 19, 0, 0, 0, 0, time.UTC), + } + if err := s.Write(CategoryPackageConfig, TargetNPM, want); err != nil { + t.Fatalf("Write: %v", err) + } + got, ok := s.Read(CategoryPackageConfig, TargetNPM) + if !ok || got.AppliedHash != want.AppliedHash || got.WrittenValue != want.WrittenValue || !got.FetchedAt.Equal(want.FetchedAt) { + t.Fatalf("Read = %+v ok=%v, want %+v", got, ok, want) + } + // The file is created 0600 (not group/other-accessible). + info, err := os.Stat(s.path) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != cacheFileMode { + t.Fatalf("file mode = %o, want %o", info.Mode().Perm(), cacheFileMode) + } + if err := s.Drop(CategoryPackageConfig, TargetNPM); err != nil { + t.Fatalf("Drop: %v", err) + } + if _, ok := s.Read(CategoryPackageConfig, TargetNPM); ok { + t.Fatal("a dropped record must read absent") + } +} + +func TestFileStateStorePreservesSiblings(t *testing.T) { + // The read-modify-write must preserve every other category/target — the same + // guarantee the shared cache makes, applied to this category's own file. + s := newFileStore(t) + npm := AppliedTargetState{AppliedHash: "sha256:N", WrittenValue: "npm-block"} + ide := AppliedTargetState{AppliedHash: "sha256:V", WrittenValue: "vscode-value"} + if err := s.Write(CategoryPackageConfig, TargetNPM, npm); err != nil { + t.Fatal(err) + } + if err := s.Write(CategoryIDEExtension, TargetVSCode, ide); err != nil { + t.Fatal(err) + } + if err := s.Drop(CategoryPackageConfig, TargetNPM); err != nil { + t.Fatal(err) + } + if _, ok := s.Read(CategoryPackageConfig, TargetNPM); ok { + t.Fatal("the dropped npm record must be gone") + } + got, ok := s.Read(CategoryIDEExtension, TargetVSCode) + if !ok || got.WrittenValue != "vscode-value" { + t.Fatalf("the sibling record must survive, got %+v ok=%v", got, ok) + } +} + +func TestFileStateStoreRefusesFutureSchema(t *testing.T) { + // An older agent meeting a NEWER agent's state file must not overwrite it: + // Read yields "owns nothing", and Write/Drop refuse rather than clobber + // metadata they can't interpret. The file stays byte-identical. + dir := t.TempDir() + path := filepath.Join(dir, packageConfigStateBasename+".json") + future := `{"schema_version":999,"categories":{"package_config":{"targets":` + + `{"npm":{"applied_hash":"sha256:z","written_value":"blk","fetched_at":"2026-07-01T00:00:00Z"}}}}}` + "\n" + if err := os.WriteFile(path, []byte(future), 0o600); err != nil { + t.Fatal(err) + } + s := &fileStateStore{dir: dir, path: path} + + if _, ok := s.Read(CategoryPackageConfig, TargetNPM); ok { + t.Fatal("a future-schema file must read as absent") + } + if err := s.Write(CategoryPackageConfig, TargetNPM, AppliedTargetState{WrittenValue: "x"}); err != errFutureSchema { + t.Fatalf("Write err = %v, want errFutureSchema", err) + } + if err := s.Drop(CategoryPackageConfig, TargetNPM); err != errFutureSchema { + t.Fatalf("Drop err = %v, want errFutureSchema", err) + } + after, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(after) != future { + t.Fatalf("a future-schema file must be left byte-identical, got %q", string(after)) + } +} + +func TestFileStateStoreRecreatesCorruptFile(t *testing.T) { + // A corrupt (non-JSON-object) file reads as absent and is recreated cleanly on + // the next write — never surfaced as an error. + dir := t.TempDir() + path := filepath.Join(dir, packageConfigStateBasename+".json") + if err := os.WriteFile(path, []byte("not json"), 0o600); err != nil { + t.Fatal(err) + } + s := &fileStateStore{dir: dir, path: path} + if _, ok := s.Read(CategoryPackageConfig, TargetNPM); ok { + t.Fatal("a corrupt file must read as absent") + } + if err := s.Write(CategoryPackageConfig, TargetNPM, AppliedTargetState{WrittenValue: "blk"}); err != nil { + t.Fatalf("Write over a corrupt file must recreate it, got %v", err) + } + got, ok := s.Read(CategoryPackageConfig, TargetNPM) + if !ok || got.WrittenValue != "blk" { + t.Fatalf("record after recreate = %+v ok=%v", got, ok) + } +} + +func TestFileStateStoreDropAbsentIsNoOp(t *testing.T) { + s := newFileStore(t) + // No file yet. + if err := s.Drop(CategoryPackageConfig, TargetNPM); err != nil { + t.Fatalf("dropping from an absent store must be a no-op, got %v", err) + } + // A file that exists but holds no such record. + if err := s.Write(CategoryPackageConfig, TargetNPM, AppliedTargetState{WrittenValue: "x"}); err != nil { + t.Fatal(err) + } + if err := s.Drop(CategoryIDEExtension, TargetVSCode); err != nil { + t.Fatalf("dropping an absent record must be a no-op, got %v", err) + } + if _, ok := s.Read(CategoryPackageConfig, TargetNPM); !ok { + t.Fatal("the existing record must be untouched") + } +} + +func TestFileStateStoreDropRemovesCorruptFile(t *testing.T) { + // A corrupt state file can still carry token-bearing WrittenValue bytes. Drop + // (offboarding) must not report success while leaving those bytes on disk — it + // removes the file so no stale credential survives the clear. (Contrast an + // absent file, which stays a no-op — nothing to remove.) + dir := t.TempDir() + path := filepath.Join(dir, packageConfigStateBasename+".json") + corrupt := `{ broken "written_value": "ssabc123::dev:S1` // invalid JSON, token-shaped bytes + if err := os.WriteFile(path, []byte(corrupt), 0o600); err != nil { + t.Fatal(err) + } + s := &fileStateStore{dir: dir, path: path} + if err := s.Drop(CategoryPackageConfig, TargetNPM); err != nil { + t.Fatalf("Drop over a corrupt file must succeed, got %v", err) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("Drop must remove a corrupt state file, stat err = %v", err) + } +} + +func TestNewStateStoreForPlacement(t *testing.T) { + // Placement depends on whether the process is a POSIX root daemon. Assert the + // branch that matches this test process's euid so the test is host-portable. + u := &user.User{Uid: "501", HomeDir: filepath.Join(t.TempDir(), "home")} + s, ok := NewStateStoreFor(u).(*fileStateStore) + if !ok { + t.Fatal("NewStateStoreFor must return a *fileStateStore") + } + base := filepath.Base(s.path) + if os.Geteuid() == 0 { + // Root daemon → machine-owned dir + ONE record file per target uid. + if !strings.HasPrefix(s.path, "/Library/Application Support/StepSecurity") && + !strings.HasPrefix(s.path, "/var/lib/stepsecurity") { + t.Fatalf("root-mode path must be machine-owned, got %q", s.path) + } + if base != packageConfigStateBasename+"-501.json" { + t.Fatalf("root-mode file must be per-uid, got %q", base) + } + } else { + // User mode → under the target user's ~/.stepsecurity. + wantDir := filepath.Join(u.HomeDir, ".stepsecurity") + if s.dir != wantDir { + t.Fatalf("user-mode dir = %q, want %q", s.dir, wantDir) + } + if base != packageConfigStateBasename+".json" { + t.Fatalf("user-mode file = %q, want %q", base, packageConfigStateBasename+".json") + } + } +} + +func TestNewStateStoreForNonNumericUIDIsUserMode(t *testing.T) { + // A non-numeric uid (a Windows SID, or an unresolved identity) cannot select a + // per-uid machine file, so placement falls back to user mode — proven by the + // user-mode basename regardless of euid. + u := &user.User{Uid: "S-1-5-21-abc", HomeDir: filepath.Join(t.TempDir(), "home")} + s, ok := NewStateStoreFor(u).(*fileStateStore) + if !ok { + t.Fatal("NewStateStoreFor must return a *fileStateStore") + } + if base := filepath.Base(s.path); base != packageConfigStateBasename+".json" { + t.Fatalf("a non-numeric uid must yield the user-mode file, got %q", base) + } +} 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. diff --git a/internal/featuregate/featuregate.go b/internal/featuregate/featuregate.go index 26e58cb..ea6d90a 100644 --- a/internal/featuregate/featuregate.go +++ b/internal/featuregate/featuregate.go @@ -25,19 +25,25 @@ const ( FeatureYarnConfigAudit Feature = "yarn-config-audit" FeatureDevicePolicy Feature = "device-policy" FeatureAgentSkillsScan Feature = "agent-skills-scan" + // FeaturePackageConfigPolicy gates the package_config#npm enforcement lane + // (the managed ~/.npmrc secure-registry block). It is separate from + // FeatureDevicePolicy so the npm lane can be toggled independently of the + // IDE-extension lane even though both ride the device-policy machinery. + FeaturePackageConfigPolicy Feature = "package-config-policy" ) // enabled lists features safe to ship today. Uncomment a line once its // backend support has merged. var enabled = map[Feature]bool{ // FeatureAIAgentHooks: true, - FeatureNPMRCAudit: true, - FeaturePipConfigAudit: true, - FeaturePnpmConfigAudit: true, - FeatureBunConfigAudit: true, - FeatureYarnConfigAudit: true, - FeatureDevicePolicy: true, - FeatureAgentSkillsScan: true, + FeatureNPMRCAudit: true, + FeaturePipConfigAudit: true, + FeaturePnpmConfigAudit: true, + FeatureBunConfigAudit: true, + FeatureYarnConfigAudit: true, + FeatureDevicePolicy: true, + FeatureAgentSkillsScan: true, + FeaturePackageConfigPolicy: true, } var override bool From 62d9e78c8b0fd5cb54e3337e3f9c03fefe22a563 Mon Sep 17 00:00:00 2001 From: Subham Ray Date: Sun, 19 Jul 2026 18:44:38 +0530 Subject: [PATCH 02/10] refactor(devicepolicy): drop npm reconciliation lock for atomic-write convergence Remove the bespoke cross-process reconciliation lock (npmrc_lock.go, AcquireReconcileLock via flock/LockFileEx) from the package_config#npm enforce path. The process-wide singleton lock (internal/lock, acquired inside telemetry.Run) already serializes the scan phase across every invocation and privilege mode; only the enforce step runs outside it, and that overlap is atomic-write-safe: each ~/.npmrc write is a temp+rename of a deterministically rendered block, so concurrent cycles never observe a torn file. While a 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 on the next cycle (eventual consistency). This matches the VS Code ide_extension lane, which enforces the same way with no lock. - delete npmrc_lock.go and the flock / LockFileEx helpers in the platform files; - set the reconciler seams directly in runPackageConfigEnforce; - correct the state-store comments that credited the removed lock; - drop the four lock lifecycle / contention tests. Also clear the residual gosec findings in npmrc.go the way the file already handles cleanup errors: _ = on error-path Close() calls, and a #nosec G115 justification on the POSIX uid conversion. --- cmd/stepsecurity-dev-machine-guard/main.go | 35 ++-- internal/devicepolicy/npmrc.go | 18 +- internal/devicepolicy/npmrc_lock.go | 225 --------------------- internal/devicepolicy/npmrc_unix.go | 15 -- internal/devicepolicy/npmrc_unix_test.go | 103 ---------- internal/devicepolicy/npmrc_windows.go | 21 -- internal/devicepolicy/statestore.go | 17 +- 7 files changed, 31 insertions(+), 403 deletions(-) delete mode 100644 internal/devicepolicy/npmrc_lock.go diff --git a/cmd/stepsecurity-dev-machine-guard/main.go b/cmd/stepsecurity-dev-machine-guard/main.go index f3db42a..2569e49 100644 --- a/cmd/stepsecurity-dev-machine-guard/main.go +++ b/cmd/stepsecurity-dev-machine-guard/main.go @@ -3,7 +3,6 @@ package main import ( "context" "encoding/json" - "errors" "fmt" "io" "os" @@ -839,27 +838,19 @@ func runPackageConfigEnforce(exec executor.Executor, log *progress.Logger) { defer w.Close() w.SetLogf(func(format string, args ...any) { log.Debug(format, args...) }) - // Serialize concurrent convergence of this ~/.npmrc across processes and - // privilege modes. Held by another process → skip this cycle entirely, no - // report (the holder is converging and will report). A lock INFRASTRUCTURE - // failure is not contention: fall through with the writer seams unset and - // the error handed to the reconciler, so enforce surfaces write_failed while - // clear/absent stay silent — the unavailable-writer rules. - lock, lerr := w.AcquireReconcileLock() - switch { - case lerr == nil: - defer lock.Close() - r.Writer = w - r.Converged = w.Converged - r.ProbeExpected = w.ProbeExpected - r.RestoreSnapshot = w.RestoreSnapshot - r.State = devicepolicy.NewStateStoreFor(w.TargetUser()) - case errors.Is(lerr, devicepolicy.ErrLockHeld): - log.Debug("package-config enforce: another process holds the reconciliation lock; skipping this cycle") - return - default: - r.WriterInitErr = lerr - } + // 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. + r.Writer = w + r.Converged = w.Converged + r.ProbeExpected = w.ProbeExpected + r.RestoreSnapshot = w.RestoreSnapshot + r.State = devicepolicy.NewStateStoreFor(w.TargetUser()) } if err := r.Reconcile(ctx); err != nil { diff --git a/internal/devicepolicy/npmrc.go b/internal/devicepolicy/npmrc.go index 19276c1..eceef80 100644 --- a/internal/devicepolicy/npmrc.go +++ b/internal/devicepolicy/npmrc.go @@ -199,7 +199,7 @@ func NewNPMRCWriter(exec executor.Executor) (*NPMRCWriter, error) { uid, uerr := strconv.Atoi(u.Uid) gid, gerr := strconv.Atoi(u.Gid) if uerr != nil || gerr != nil { - root.Close() + _ = root.Close() return nil, fmt.Errorf("npmrc: target user %q has non-numeric uid/gid", u.Username) } w.uid, w.gid = uid, gid @@ -260,7 +260,7 @@ type resolvedTarget struct { func (rt *resolvedTarget) close() { if rt != nil && rt.child != nil { - rt.child.Close() + _ = rt.child.Close() } } @@ -462,7 +462,7 @@ func (w *NPMRCWriter) checkOwner(f *os.File, rt *resolvedTarget) error { if !enforced { return nil } - if uid == uint32(w.uid) { + 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) @@ -707,20 +707,20 @@ func (w *NPMRCWriter) commit(rt *resolvedTarget, data []byte, mode os.FileMode) }() if _, err := tmp.Write(data); err != nil { - tmp.Close() + _ = tmp.Close() return commitOutcome{}, fmt.Errorf("npmrc: write temp: %w", err) } if err := w.applyMetadata(tmp, mode); err != nil { - tmp.Close() + _ = tmp.Close() return commitOutcome{}, err } if err := tmp.Sync(); err != nil { - tmp.Close() + _ = tmp.Close() return commitOutcome{}, fmt.Errorf("npmrc: fsync temp: %w", err) } tmpInfo, err := tmp.Stat() if err != nil { - tmp.Close() + _ = tmp.Close() return commitOutcome{}, fmt.Errorf("npmrc: stat temp: %w", err) } if err := tmp.Close(); err != nil { @@ -825,12 +825,12 @@ func (w *NPMRCWriter) backup(rt *resolvedTarget, data []byte) error { return err } if _, err := f.Write(data); err != nil { - f.Close() + _ = f.Close() _ = rt.child.Remove(name) return fmt.Errorf("npmrc: write backup: %w", err) } if err := w.applyMetadata(f, npmrcFileMode); err != nil { - f.Close() + _ = f.Close() _ = rt.child.Remove(name) return err } diff --git a/internal/devicepolicy/npmrc_lock.go b/internal/devicepolicy/npmrc_lock.go deleted file mode 100644 index de71e6b..0000000 --- a/internal/devicepolicy/npmrc_lock.go +++ /dev/null @@ -1,225 +0,0 @@ -package devicepolicy - -import ( - "errors" - "fmt" - "os" -) - -// This file adds the cross-process reconciliation lock for the package_config -// #npm category on top of NPMRCWriter. Package-config enforcement runs OUTSIDE -// the telemetry singleton lock (which lives inside telemetry.Run and is released -// when it returns), so a scheduled cycle and a manual run — or a root-daemon -// cycle and a user-mode manual run — could otherwise converge the same ~/.npmrc -// concurrently. This lock serializes them on ONE identity per guarded file. - -// Lock placement, relative to the target user's home. The lock guards exactly -// one file (~/.npmrc), so its identity is a function of that FILE, not of the -// process's privilege: a root-daemon cycle and a user-mode manual run converge -// the same file and therefore must contend on the same lock. The lock lives in -// the target user's own tree in BOTH modes (root can write there; a per-mode -// location would let the two modes interleave freely). -const ( - npmrcStateDirRel = ".stepsecurity" - npmrcLockDirRel = ".stepsecurity/locks" - npmrcLockPathRel = ".stepsecurity/locks/package_config-npm.lock" -) - -// npmrcLockDirMode is the lock directory's mode on POSIX. It holds a coordination -// primitive, not shared data, so it is not group/other-accessible. -const npmrcLockDirMode os.FileMode = 0o700 - -// ErrLockHeld signals another agent process already holds the package-config -// reconciliation lock for this file. The current cycle must skip silently and -// retry next tick — it never reports compliance, because the holder is already -// converging and will report. -var ErrLockHeld = errors.New("npmrc: reconciliation lock held by another process") - -// npmrcLock is a held cross-process lock. Release closes the fd (which drops the -// OS advisory lock); the lock FILE is deliberately never unlinked — unlink then -// recreate would let two processes lock different inodes under one path and both -// "win". -type npmrcLock struct { - f *os.File -} - -// Close releases the lock by closing its fd. The lock file is intentionally left -// on disk for the next cycle to re-lock. -func (l *npmrcLock) Close() error { - if l == nil || l.f == nil { - return nil - } - err := l.f.Close() - l.f = nil - return err -} - -// AcquireReconcileLock takes the cross-process reconciliation lock that -// serializes package-config convergence of THIS user's ~/.npmrc across every -// agent process and privilege mode: -// -// - The lock file lives at /.stepsecurity/locks/package_config-npm.lock, -// created through the SAME home os.Root the writer uses, so no path component -// is ever resolved through a symlink out of the home — the primitive the -// writer already defends against for .npmrc itself. -// - On POSIX the lock directory and file are chowned to the target user via -// open handles, so a root-daemon cycle creates a lock the later user-mode -// agent can still open — without it the two modes could never share one inode. -// - The lock is advisory and non-blocking. Held by another process → ErrLockHeld -// (the caller skips this cycle). It is held on a persistent fd and is NEVER -// unlinked on release. -// -// Any error other than ErrLockHeld is an infrastructure failure (cannot create -// the directory/file, EPERM); the caller classifies it like an unavailable -// writer — surfaced on enforce, silent on clear/absent. -func (w *NPMRCWriter) AcquireReconcileLock() (*npmrcLock, error) { - if w == nil || w.root == nil { - return nil, errors.New("npmrc: writer is closed") - } - // Create the lock directory chain if absent. Only a directory THIS call freshly - // creates is chowned to the target user; a pre-existing one (the user's own - // ~/.stepsecurity, possibly an in-home symlink to their dotfiles) is left as - // found. os.Root confines every component to the home tree. - for _, dir := range []string{npmrcStateDirRel, npmrcLockDirRel} { - if err := w.ensureOwnedDir(dir); err != nil { - return nil, err - } - } - - f, created, err := w.openLockFile() - if err != nil { - return nil, err - } - // Chown ONLY a lock file this call just created (O_EXCL guarantees it is a - // fresh regular file). An already-present lock is never chowned: it could be an - // in-home symlink or a file the user planted, and transferring ownership of a - // file we did not create is exactly the root-to-user escalation this guards - // against. Chown-on-create is still enough for the two modes to share one inode - // (a root cycle creates it user-owned; a later user cycle opens it). - if created && enforcePOSIXMetadata { - if err := chownHandle(f, w.uid, w.gid); err != nil { - f.Close() - return nil, fmt.Errorf("npmrc: chown lock file: %w", err) - } - } - - acquired, err := tryExclusiveLock(f) - if err != nil { - f.Close() - return nil, fmt.Errorf("npmrc: lock: %w", err) - } - if !acquired { - f.Close() - return nil, ErrLockHeld - } - return &npmrcLock{f: f}, nil -} - -// openLockFile creates the lock file if absent, or opens the existing one — in -// both cases without ever following a symlink at the final component, the same -// discipline readCurrent applies to .npmrc. It returns created=true only when this -// call made a fresh regular file via O_CREATE|O_EXCL (the one open mode os.Root -// never resolves through a final symlink). If the file already exists it is opened -// O_RDWR after an Lstat pre-screen (reject a symlink or non-regular file) plus a -// post-open SameFile re-check, so an in-home symlink swapped in at the path cannot -// redirect the open. Only a freshly created file is safe for the caller to chown. -func (w *NPMRCWriter) openLockFile() (f *os.File, created bool, err error) { - f, err = w.root.OpenFile(npmrcLockPathRel, os.O_CREATE|os.O_EXCL|os.O_RDWR, npmrcFileMode) - if err == nil { - return f, true, nil - } - if !errors.Is(err, os.ErrExist) { - return nil, false, fmt.Errorf("npmrc: create lock file: %w", err) - } - - li, err := w.root.Lstat(npmrcLockPathRel) - if err != nil { - return nil, false, fmt.Errorf("npmrc: lstat lock file: %w", err) - } - if li.Mode()&os.ModeSymlink != 0 || !li.Mode().IsRegular() { - return nil, false, fmt.Errorf("npmrc: lock path is not a regular file: %w", ErrTargetUnusable) - } - f, err = w.root.OpenFile(npmrcLockPathRel, os.O_RDWR|nonblockOpenFlag(), 0) - if err != nil { - return nil, false, fmt.Errorf("npmrc: open lock file: %w", err) - } - hi, err := f.Stat() - if err != nil { - f.Close() - return nil, false, fmt.Errorf("npmrc: stat lock handle: %w", err) - } - li2, err := w.root.Lstat(npmrcLockPathRel) - if err != nil { - f.Close() - return nil, false, fmt.Errorf("npmrc: re-lstat lock file: %w", err) - } - if li2.Mode()&os.ModeSymlink != 0 || !hi.Mode().IsRegular() || !os.SameFile(li2, hi) { - f.Close() - return nil, false, fmt.Errorf("npmrc: lock file changed during open: %w", ErrTargetUnusable) - } - return f, false, nil -} - -// ensureOwnedDir creates one directory relative to the home root if it does not -// exist and, on POSIX, chowns a freshly created one to the target user. An -// already-present directory is left exactly as found (the config layer owns -// ~/.stepsecurity, which may legitimately be an in-home symlink). It goes through -// the home os.Root so no path component is ever resolved out of the home. Before -// the chown it BINDS the created inode two ways so a create→open swap cannot -// redirect ownership onto another file: the opened handle's identity is -// re-verified (Lstat not-a-symlink + SameFile), AND the handle must be owned by -// our own effective uid — proof it is the directory we just made, since the user -// who controls this tree cannot fabricate a root-owned directory to swap in. The -// path re-Lstat alone cannot provide that anchor: it would happily bind an -// attacker's stable swap. Only after both checks is ownership transferred. -func (w *NPMRCWriter) ensureOwnedDir(rel string) error { - err := w.root.Mkdir(rel, npmrcLockDirMode) - if errors.Is(err, os.ErrExist) { - return nil - } - if err != nil { - return fmt.Errorf("npmrc: mkdir %q: %w", rel, err) - } - if !enforcePOSIXMetadata { - return nil - } - li, lerr := w.root.Lstat(rel) - if lerr != nil { - return fmt.Errorf("npmrc: lstat dir %q: %w", rel, lerr) - } - if li.Mode()&os.ModeSymlink != 0 || !li.IsDir() { - return fmt.Errorf("npmrc: lock dir %q is not a directory: %w", rel, ErrTargetUnusable) - } - d, oerr := w.root.Open(rel) - if oerr != nil { - return fmt.Errorf("npmrc: open dir %q for chown: %w", rel, oerr) - } - defer d.Close() - di, serr := d.Stat() - if serr != nil { - return fmt.Errorf("npmrc: stat dir handle %q: %w", rel, serr) - } - li2, lerr := w.root.Lstat(rel) - if lerr != nil { - return fmt.Errorf("npmrc: re-lstat dir %q: %w", rel, lerr) - } - if li2.Mode()&os.ModeSymlink != 0 || !di.IsDir() || !os.SameFile(li2, di) { - return fmt.Errorf("npmrc: lock dir %q changed during creation: %w", rel, ErrTargetUnusable) - } - // Creator-identity anchor: a directory we just created is owned by our own - // effective uid (root under the daemon). The target user could rmdir+swap rel - // between the Mkdir and this open, but cannot fabricate a root-owned directory, - // so an owner==euid handle proves this is the inode we created and not a - // substitution — the guarantee SameFile above cannot make on its own. - duid, _, downed, oerr := w.owners.ownerUIDGID(d) - if oerr != nil { - return fmt.Errorf("npmrc: read new dir owner %q: %w", rel, oerr) - } - if downed && duid != uint32(os.Geteuid()) { - return fmt.Errorf("npmrc: lock dir %q not owned by its creator; refusing chown: %w", rel, ErrTargetUnusable) - } - if cerr := chownHandle(d, w.uid, w.gid); cerr != nil { - return fmt.Errorf("npmrc: chown dir %q: %w", rel, cerr) - } - return nil -} diff --git a/internal/devicepolicy/npmrc_unix.go b/internal/devicepolicy/npmrc_unix.go index 1a330a3..8f1b9f9 100644 --- a/internal/devicepolicy/npmrc_unix.go +++ b/internal/devicepolicy/npmrc_unix.go @@ -23,21 +23,6 @@ func nonblockOpenFlag() int { return syscall.O_NONBLOCK } // so the operation cannot be redirected through a swapped symlink. func chownHandle(f *os.File, uid, gid int) error { return f.Chown(uid, gid) } -// tryExclusiveLock attempts a non-blocking exclusive advisory lock (flock) on an -// open file. acquired=true when this fd now holds it; acquired=false with a nil -// error means another process holds it (contention); a non-nil error is an -// infrastructure failure. The lock releases when the fd is closed. -func tryExclusiveLock(f *os.File) (acquired bool, err error) { - e := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) - if e == nil { - return true, nil - } - if errors.Is(e, syscall.EWOULDBLOCK) { - return false, nil - } - return false, e -} - // 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. diff --git a/internal/devicepolicy/npmrc_unix_test.go b/internal/devicepolicy/npmrc_unix_test.go index 7202cb5..c9abd1a 100644 --- a/internal/devicepolicy/npmrc_unix_test.go +++ b/internal/devicepolicy/npmrc_unix_test.go @@ -3,7 +3,6 @@ package devicepolicy import ( - "errors" "os" "path/filepath" "strings" @@ -380,73 +379,6 @@ func TestProbeExpected_OnDisk(t *testing.T) { // edge 8 + metadata gate } } -func TestAcquireReconcileLock_LifecycleAndContention(t *testing.T) { - home := t.TempDir() - w1 := newDiskWriter(t, home) - - lock, err := w1.AcquireReconcileLock() - if err != nil { - t.Fatalf("first acquire: %v", err) - } - - // The lock file and its directory chain now exist, 0600 file under a 0700 dir. - lockDir := filepath.Join(home, ".stepsecurity", "locks") - lockPath := filepath.Join(lockDir, "package_config-npm.lock") - if fi, err := os.Stat(lockPath); err != nil { - t.Fatalf("lock file not created: %v", err) - } else if fi.Mode().Perm() != 0o600 { - t.Fatalf("lock file mode = %v, want 0600", fi.Mode().Perm()) - } - if di, err := os.Stat(lockDir); err != nil { - t.Fatalf("lock dir not created: %v", err) - } else if di.Mode().Perm() != 0o700 { - t.Fatalf("lock dir mode = %v, want 0700", di.Mode().Perm()) - } - - // A second, independent open of the same file observes contention (flock - // treats the two file descriptions independently even within one process). - w2 := newDiskWriter(t, home) - if _, err := w2.AcquireReconcileLock(); !errors.Is(err, ErrLockHeld) { - t.Fatalf("second acquire while held: want ErrLockHeld, got %v", err) - } - - // Releasing closes the fd but must NEVER unlink the file — a recreate would - // let two processes lock different inodes under one path and both proceed. - if err := lock.Close(); err != nil { - t.Fatalf("release: %v", err) - } - if _, err := os.Stat(lockPath); err != nil { - t.Fatalf("lock file must survive release (never unlinked): %v", err) - } - - // After release the lock is re-acquirable on the same inode. - lock2, err := w2.AcquireReconcileLock() - if err != nil { - t.Fatalf("re-acquire after release: %v", err) - } - if err := lock2.Close(); err != nil { - t.Fatalf("release 2: %v", err) - } -} - -func TestAcquireReconcileLock_PreexistingStepsecurityDir(t *testing.T) { - // The common case: ~/.stepsecurity already exists (config.json lives there). - // Mkdir returns ErrExist and the lock still acquires; the pre-existing dir is - // left untouched. - home := t.TempDir() - if err := os.Mkdir(filepath.Join(home, ".stepsecurity"), 0o700); err != nil { - t.Fatalf("seed .stepsecurity: %v", err) - } - w := newDiskWriter(t, home) - lock, err := w.AcquireReconcileLock() - if err != nil { - t.Fatalf("acquire with pre-existing .stepsecurity: %v", err) - } - if err := lock.Close(); err != nil { - t.Fatalf("release: %v", err) - } -} - 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 { @@ -603,38 +535,3 @@ func TestRestoreSnapshot_ConsumedAfterUse(t *testing.T) { t.Fatal("second RestoreSnapshot must error — the snapshot is consumed after one use") } } - -func TestAcquireReconcileLock_SymlinkedLockPathRejected(t *testing.T) { - // A user who pre-plants the lock path as an in-home symlink must not get the - // (possibly root) daemon to open-and-chown the pointed-at file. openLockFile - // Lstat-rejects a symlink at the lock path rather than following it. - home := t.TempDir() - locks := filepath.Join(home, ".stepsecurity", "locks") - if err := os.MkdirAll(locks, 0o700); err != nil { - t.Fatalf("mkdir locks: %v", err) - } - if err := os.WriteFile(filepath.Join(home, "target"), []byte("secret\n"), 0o600); err != nil { - t.Fatalf("seed target: %v", err) - } - // Relative, in-home target so os.Root would otherwise follow it. - if err := os.Symlink(filepath.Join("..", "..", "target"), filepath.Join(locks, "package_config-npm.lock")); err != nil { - t.Fatalf("plant symlink: %v", err) - } - w := newDiskWriter(t, home) - if _, err := w.AcquireReconcileLock(); !isTargetUnusable(err) { - t.Fatalf("symlinked lock path: want ErrTargetUnusable, got %v", err) - } -} - -func TestAcquireReconcileLock_FreshDirNotCreatorOwnedRejected(t *testing.T) { - // The dir-chown path binds the created inode by requiring it be owned by our own - // euid — proof the daemon created it, and that an attacker did not rmdir+swap in - // their own directory after the Mkdir (they cannot forge a root-owned one). A - // handle reporting a foreign owner is refused rather than chowned to the user. - home := t.TempDir() - w := newDiskWriter(t, home) - w.owners = fakeOwner{uid: uint32(os.Geteuid()) + 1, enforced: true} // never our euid - if _, err := w.AcquireReconcileLock(); !isTargetUnusable(err) { - t.Fatalf("fresh dir with a foreign owner: want ErrTargetUnusable, got %v", err) - } -} diff --git a/internal/devicepolicy/npmrc_windows.go b/internal/devicepolicy/npmrc_windows.go index d4ec2b3..e87b22c 100644 --- a/internal/devicepolicy/npmrc_windows.go +++ b/internal/devicepolicy/npmrc_windows.go @@ -21,27 +21,6 @@ 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 } -// tryExclusiveLock attempts a non-blocking exclusive lock (LockFileEx with -// LOCKFILE_FAIL_IMMEDIATELY) on an open file — there is no flock on Windows. -// acquired=true when this handle now holds it; acquired=false with a nil error -// means another process holds it; a non-nil error is an infrastructure failure. -// The lock releases when the handle is closed. -func tryExclusiveLock(f *os.File) (acquired bool, err error) { - ol := new(windows.Overlapped) - e := windows.LockFileEx( - windows.Handle(f.Fd()), - windows.LOCKFILE_EXCLUSIVE_LOCK|windows.LOCKFILE_FAIL_IMMEDIATELY, - 0, 1, 0, ol, - ) - if e == nil { - return true, nil - } - if errors.Is(e, windows.ERROR_LOCK_VIOLATION) { - return false, nil - } - return false, e -} - func newOwnerReader() ownerReader { return windowsOwnerReader{} } // windowsOwnerReader reports enforced=false so the writer skips every ownership diff --git a/internal/devicepolicy/statestore.go b/internal/devicepolicy/statestore.go index 095fa6f..6340a46 100644 --- a/internal/devicepolicy/statestore.go +++ b/internal/devicepolicy/statestore.go @@ -27,12 +27,10 @@ type StateStore interface { // packageConfigStateBasename is the state-file basename for the package_config // category. It is DELIBERATELY separate from CacheFilename (the shared IDE -// state): the shared file's read-modify-write is guarded only by an in-process -// mutex — its own comment says a cross-process lock "would be needed before -// categories are reconciled concurrently" — and only package reconciliation -// takes that lock. Sharing one file could let an IDE reconcile in another -// process silently drop the package record (or vice versa); separate files make -// the lost update structurally impossible. +// state): neither store takes a cross-process lock, so the only thing that keeps +// a package reconcile in one process from silently dropping a concurrent IDE +// reconcile's record in another (or vice versa) is that they never share a file. +// Separate files make that lost update structurally impossible. const packageConfigStateBasename = "package-config-state" // NewStateStoreFor builds the package_config ownership store for a resolved @@ -97,8 +95,11 @@ func rootMachineStateDir(u *user.User) (dir, uid string, ok bool) { // carries the same schema-versioned category→target→record shape and the same // atomic-replace (temp + fsync + rename) + future-schema-refusal discipline as // the shared cache.go store — only the path differs, and the file belongs to -// this category alone. A process-local mutex serializes its read-modify-write; -// the cross-process lock (main.go) covers separate processes. +// this category alone. A process-local mutex serializes its read-modify-write +// within a process; across processes there is no lock — the atomic temp+rename +// keeps the file from tearing, and a concurrent overlap is eventually consistent +// (identical records while the policy is stable; a transient stale record only +// during a policy transition, reconverged on the next cycle). type fileStateStore struct { dir string path string From 0c00ddbf97a7a30e2b0f5337218893036e9bdd0a Mon Sep 17 00:00:00 2001 From: Subham Ray Date: Sun, 19 Jul 2026 19:24:42 +0530 Subject: [PATCH 03/10] test(devicepolicy): skip POSIX perm assertion on Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TestFileStateStoreRoundTrip asserted the state file's mode is exactly 0600, but Windows has no POSIX permission bits: os.Chmod only toggles the write bit and Stat reports 0666 for any writable file, so the check could never hold on Windows (the store's own logic is unaffected — the state file round-trips fine). Guard the assertion with runtime.GOOS != "windows", matching the existing settings_writer_test.go idiom; the rest of the round-trip still runs on every platform. --- internal/devicepolicy/statestore_test.go | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/internal/devicepolicy/statestore_test.go b/internal/devicepolicy/statestore_test.go index 11d3dfb..a5a12b8 100644 --- a/internal/devicepolicy/statestore_test.go +++ b/internal/devicepolicy/statestore_test.go @@ -4,6 +4,7 @@ import ( "os" "os/user" "path/filepath" + "runtime" "strings" "testing" "time" @@ -35,13 +36,17 @@ func TestFileStateStoreRoundTrip(t *testing.T) { if !ok || got.AppliedHash != want.AppliedHash || got.WrittenValue != want.WrittenValue || !got.FetchedAt.Equal(want.FetchedAt) { t.Fatalf("Read = %+v ok=%v, want %+v", got, ok, want) } - // The file is created 0600 (not group/other-accessible). - info, err := os.Stat(s.path) - if err != nil { - t.Fatal(err) - } - if info.Mode().Perm() != cacheFileMode { - t.Fatalf("file mode = %o, want %o", info.Mode().Perm(), cacheFileMode) + // The file is created 0600 (not group/other-accessible). POSIX-only: Windows + // has no permission bits, so Chmod can't set 0600 and Stat reports 0666 for any + // writable file. + if runtime.GOOS != "windows" { + info, err := os.Stat(s.path) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != cacheFileMode { + t.Fatalf("file mode = %o, want %o", info.Mode().Perm(), cacheFileMode) + } } if err := s.Drop(CategoryPackageConfig, TargetNPM); err != nil { t.Fatalf("Drop: %v", err) From 56bdbeb8181314d113527864bc9bcbae7aeec9e4 Mon Sep 17 00:00:00 2001 From: Subham Ray Date: Sun, 19 Jul 2026 19:34:48 +0530 Subject: [PATCH 04/10] refactor(featuregate): drop FeaturePackageConfigPolicy gate The package_config#npm enforcement lane is ready to ship, so remove its dedicated feature gate and run it unconditionally. The gate was default-on already, so runtime behavior is unchanged; the lane stays dormant in production until the backend returns a package_config policy (an absent policy is a no-op, never a wipe). The IDE-extension lane keeps its own FeatureDevicePolicy gate. Removes the FeaturePackageConfigPolicy const, its enabled-map entry, and the gate check in runPackageConfigEnforce. --- cmd/stepsecurity-dev-machine-guard/main.go | 7 +------ internal/featuregate/featuregate.go | 20 +++++++------------- 2 files changed, 8 insertions(+), 19 deletions(-) diff --git a/cmd/stepsecurity-dev-machine-guard/main.go b/cmd/stepsecurity-dev-machine-guard/main.go index 2569e49..6f11fec 100644 --- a/cmd/stepsecurity-dev-machine-guard/main.go +++ b/cmd/stepsecurity-dev-machine-guard/main.go @@ -772,14 +772,9 @@ func runIDEExtensionEnforce(exec executor.Executor, log *progress.Logger) { // 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. Gated behind FeaturePackageConfigPolicy (independent of the -// IDE-extension gate); a silent no-op when enterprise config is missing. Failures +// 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) { - if !featuregate.IsEnabled(featuregate.FeaturePackageConfigPolicy) { - log.Debug("package-config enforce: skipped (feature gated)") - return - } cfg, ok := ingest.Snapshot() if !ok { log.Debug("package-config enforce: skipped (no enterprise config)") diff --git a/internal/featuregate/featuregate.go b/internal/featuregate/featuregate.go index ea6d90a..26e58cb 100644 --- a/internal/featuregate/featuregate.go +++ b/internal/featuregate/featuregate.go @@ -25,25 +25,19 @@ const ( FeatureYarnConfigAudit Feature = "yarn-config-audit" FeatureDevicePolicy Feature = "device-policy" FeatureAgentSkillsScan Feature = "agent-skills-scan" - // FeaturePackageConfigPolicy gates the package_config#npm enforcement lane - // (the managed ~/.npmrc secure-registry block). It is separate from - // FeatureDevicePolicy so the npm lane can be toggled independently of the - // IDE-extension lane even though both ride the device-policy machinery. - FeaturePackageConfigPolicy Feature = "package-config-policy" ) // enabled lists features safe to ship today. Uncomment a line once its // backend support has merged. var enabled = map[Feature]bool{ // FeatureAIAgentHooks: true, - FeatureNPMRCAudit: true, - FeaturePipConfigAudit: true, - FeaturePnpmConfigAudit: true, - FeatureBunConfigAudit: true, - FeatureYarnConfigAudit: true, - FeatureDevicePolicy: true, - FeatureAgentSkillsScan: true, - FeaturePackageConfigPolicy: true, + FeatureNPMRCAudit: true, + FeaturePipConfigAudit: true, + FeaturePnpmConfigAudit: true, + FeatureBunConfigAudit: true, + FeatureYarnConfigAudit: true, + FeatureDevicePolicy: true, + FeatureAgentSkillsScan: true, } var override bool From 253388175b3fb410c99ea4ce34e871ac47478576 Mon Sep 17 00:00:00 2001 From: Subham Ray Date: Sun, 26 Jul 2026 00:24:24 +0530 Subject: [PATCH 05/10] feat(devicepolicy): add MDM verify-only enforcement channel for the npm lane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enforcement already selects a channel per cycle for IDE-extension policy. This extends the same model to package_config#npm: "mdm" is verify-only — the agent reads the effective ~/.npmrc and reports what it observed, and never writes, patches, or clears the file (an external MDM owns the block). - ProbeContentNPM is the verify-only reader, bound in main.go because it needs the writer's identity-checked read path. Presence is an MDM marker outside every agent-owned block, so a marker planted inside one of ours cannot pass as MDM management. It reports the observed bag {ecosystem, registry_url, auth_token_status} computed over the whole file's last-wins precedence, so an override below the MDM block surfaces as drift instead of being hidden. No token, hash, or fingerprint is ever returned or logged. With no writer the reconciler's category-aware fallback reports verification_failed rather than probing VS Code policy for an npm category. - EffectivePolicy.Policy is kept RAW (json.RawMessage) instead of a settings map: the compiled shape is per-category — a settings map for ide_extension, a registry object for package_config — and each category decodes what it needs. Values are still written verbatim, never re-serialized, so the backend's byte-exact applied==desired check holds. - Ownership collapses onto WrittenSettings for every lane; the single-value WrittenValue field is retired. A single-value path records exactly one entry under its own OwnershipKey (the ~/.npmrc block, or the allowlist setting id for a degraded VS Code writer). A state file carrying the retired key decodes as "owns nothing" and the next enforce re-converges it. - ProbeManagedContent takes the expected value so a content probe can decide presence against what the policy actually compiled to. Hardening on the npm lane: - The verify-only reader transmits an observed plaintext http:// registry as drift evidence instead of refusing it — discarding it would hide the most security-relevant drift the channel exists to catch. The observed value is bounded instead: an absolute http(s) URL with a host, 2048 bytes, and no userinfo, query, fragment, or control bytes. - Fail closed on npm's `key[]=` array-append form on a managed key. npm's ini reader folds it into the same key as the block's scalar assignment, so both orders leave npm resolving to a comma-joined list containing the injected value while a last-wins scalar scan still reported converged. Scoped to `registry` and the tenant token key, so unrelated array config (`omit[]=dev`, another registry's token) is still enforceable. - Fail closed when clearing a file whose only line breaks are bare CRs. The managed block could not be located, so the clear reported the nothing-to-do success and ownership state was dropped while the token stayed on disk. - Purge our own backups once a clear leaves nothing managed. Removing the block from the live file while leaving a readable sibling copy of it revokes nothing. Best-effort with a retry on later no-op clears: a sibling we cannot unlink must not report a revocation that already happened as failed. - Log a loose file mode in the verify-only reader rather than failing it. Perms are outside the observed contract and that lane cannot remediate by writing, so a hard failure would be permanent and would hide the real registry and auth status. Ownership is still enforced on every read. --- cmd/stepsecurity-dev-machine-guard/main.go | 9 + internal/devicepolicy/api.go | 109 +++-- internal/devicepolicy/api_identity_test.go | 68 +++ internal/devicepolicy/api_test.go | 10 +- internal/devicepolicy/cache.go | 19 +- internal/devicepolicy/cache_test.go | 134 ++++-- internal/devicepolicy/npmrc.go | 436 ++++++++++++++++- internal/devicepolicy/npmrc_observed_test.go | 381 +++++++++++++++ internal/devicepolicy/npmrc_test.go | 93 +++- internal/devicepolicy/npmrc_unix_test.go | 204 ++++++++ internal/devicepolicy/probe_darwin.go | 8 +- internal/devicepolicy/probe_linux.go | 6 +- internal/devicepolicy/probe_other.go | 4 +- internal/devicepolicy/probe_windows.go | 6 +- internal/devicepolicy/reconcile_npm_test.go | 453 +++++++++++++++++- internal/devicepolicy/reconcile_test.go | 78 ++- internal/devicepolicy/settings_writer_test.go | 2 +- internal/devicepolicy/statestore.go | 6 +- internal/devicepolicy/statestore_test.go | 26 +- 19 files changed, 1895 insertions(+), 157 deletions(-) create mode 100644 internal/devicepolicy/npmrc_observed_test.go diff --git a/cmd/stepsecurity-dev-machine-guard/main.go b/cmd/stepsecurity-dev-machine-guard/main.go index 9a2aa7b..9eb5ed5 100644 --- a/cmd/stepsecurity-dev-machine-guard/main.go +++ b/cmd/stepsecurity-dev-machine-guard/main.go @@ -818,6 +818,9 @@ func runPackageConfigEnforce(exec executor.Executor, log *progress.Logger) { 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...) }, } @@ -848,6 +851,12 @@ func runPackageConfigEnforce(exec executor.Executor, log *progress.Logger) { r.ProbeExpected = w.ProbeExpected r.RestoreSnapshot = w.RestoreSnapshot r.State = devicepolicy.NewStateStoreFor(w.TargetUser()) + // 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 { diff --git a/internal/devicepolicy/api.go b/internal/devicepolicy/api.go index 5e60e7f..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 @@ -223,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 index a829c1f..7ed7eaf 100644 --- a/internal/devicepolicy/api_identity_test.go +++ b/internal/devicepolicy/api_identity_test.go @@ -92,3 +92,71 @@ func TestFetchPackageConfigTargetRoundTrips(t *testing.T) { 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..4a2df11 100644 --- a/internal/devicepolicy/cache.go +++ b/internal/devicepolicy/cache.go @@ -58,18 +58,21 @@ 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. +// category/target. A pre-collapse file carrying the retired written_value key +// decodes with an empty WrittenSettings — i.e. "owns nothing" — and the next +// enforce re-converges and re-records it; there are no production devices, so no +// migrator is owed. 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"` } diff --git a/internal/devicepolicy/cache_test.go b/internal/devicepolicy/cache_test.go index 9aa1653..71eb4e4 100644 --- a/internal/devicepolicy/cache_test.go +++ b/internal/devicepolicy/cache_test.go @@ -10,15 +10,27 @@ import ( "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 +39,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 +122,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 +134,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 +150,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 +169,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 +198,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 +215,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 +227,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 +238,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 +250,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 +291,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 +305,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 +377,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 +391,89 @@ 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)") } } diff --git a/internal/devicepolicy/npmrc.go b/internal/devicepolicy/npmrc.go index eceef80..fa9ebc8 100644 --- a/internal/devicepolicy/npmrc.go +++ b/internal/devicepolicy/npmrc.go @@ -43,6 +43,36 @@ const ( 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 @@ -563,14 +593,23 @@ func (w *NPMRCWriter) Clear() error { return err } if !existed { - // Nothing to clear; leave the (absent) file alone. + // 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 nil } - next := w.clearContent(cur) + next, err := w.clearContent(cur) + if err != nil { + return err + } if bytes.Equal(next, cur) { - // No managed block and no prefixed lines — a no-op that performs no - // write at all. + // 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 nil } @@ -583,13 +622,19 @@ func (w *NPMRCWriter) Clear() error { 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). + // indeterminate (ErrWriteUnverified). The backup stays as a recovery aid. return w.afterFailedRollback(rt, snap, err, "clear commit verification") } return 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 nil } @@ -920,6 +965,60 @@ func (w *NPMRCWriter) rotateBackups(rt *resolvedTarget) { } } +// 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 { @@ -957,6 +1056,13 @@ func (w *NPMRCWriter) rewriteContent(current []byte, body string) ([]byte, error 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") @@ -983,15 +1089,25 @@ func (w *NPMRCWriter) rewriteContent(current []byte, body string) ([]byte, error // 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. -func (w *NPMRCWriter) clearContent(current []byte) []byte { +// +// 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() + return buf.Bytes(), nil } // stripBOM splits a leading UTF-8 BOM off the content. The BOM is removed for @@ -1102,6 +1218,54 @@ func hasCoercibleQuotedKey(lines []string) bool { 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 @@ -1361,6 +1525,13 @@ func (w *NPMRCWriter) Converged(expected string) (bool, error) { // 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 { @@ -1493,6 +1664,11 @@ func probeNPMRCContent(content, expected string) (bool, string) { // 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). @@ -1597,6 +1773,252 @@ func parseExpected(expected string) (registry, tokenKey, tokenVal string, ok boo 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 // --------------------------------------------------------------------------- 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 index e1531bb..b8deb2d 100644 --- a/internal/devicepolicy/npmrc_test.go +++ b/internal/devicepolicy/npmrc_test.go @@ -274,12 +274,17 @@ func TestRewrite_Idempotent(t *testing.T) { // edge 15 (content) } // --------------------------------------------------------------------------- -// clearContent — the §3 clear algorithm (pure []byte -> []byte) +// clearContent — the clear transform (pure bytes in, bytes out) // --------------------------------------------------------------------------- -func clearOf(current string) string { +func clearOf(t *testing.T, current string) string { + t.Helper() w := &NPMRCWriter{} - return string(w.clearContent([]byte(current))) + 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 @@ -288,7 +293,7 @@ func TestClear_RestoresAndPreserves(t *testing.T) { // edge 9 current := "# [stepsecurity-dmg] registry=https://registry.npmjs.org/\n" + "# [stepsecurity] registry=https://mdm/\n" + block(stdBody) - got := clearOf(current) + 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) @@ -297,14 +302,14 @@ func TestClear_RestoresAndPreserves(t *testing.T) { // edge 9 func TestClear_ShellOnlyBlockRemoved(t *testing.T) { // edge 24 current := npmrcBeginMarker + "\n" + npmrcEndMarker + "\n" - if got := clearOf(current); got != "" { + 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(current) + 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) } @@ -314,7 +319,7 @@ 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(enforced) + got := clearOf(t, enforced) if got != "foo\n" { t.Fatalf("clear should leave %q, got %q", "foo\n", got) } @@ -399,7 +404,7 @@ func TestSharedClassifier_SpacedForms(t *testing.T) { 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(out); got != spaced+"\n" { + 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 @@ -487,6 +492,78 @@ func TestRewrite_CoercibleQuotedKeyFailsClosed(t *testing.T) { } } +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. diff --git a/internal/devicepolicy/npmrc_unix_test.go b/internal/devicepolicy/npmrc_unix_test.go index c9abd1a..adfe67f 100644 --- a/internal/devicepolicy/npmrc_unix_test.go +++ b/internal/devicepolicy/npmrc_unix_test.go @@ -535,3 +535,207 @@ func TestRestoreSnapshot_ConsumedAfterUse(t *testing.T) { 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/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_npm_test.go b/internal/devicepolicy/reconcile_npm_test.go index 3858ff7..ce4b31d 100644 --- a/internal/devicepolicy/reconcile_npm_test.go +++ b/internal/devicepolicy/reconcile_npm_test.go @@ -7,6 +7,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "testing" "time" ) @@ -93,8 +94,10 @@ func npmPolicyEP(hash string) EffectivePolicy { } // newNPMRec builds a marker-owned, State-backed reconciler wired like the -// ~/.npmrc path: OwnsByMarker, a Render seam that produces the managed block, a -// content-aware ProbeExpected, and a Converged seam. Defaults: Render → 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. No withTempCache — State routes // every ownership access away from the shared file. @@ -111,6 +114,7 @@ func newNPMRec(t *testing.T, ep EffectivePolicy, w *fakeWriter, st *memStateStor Category: CategoryPackageConfig, Target: TargetNPM, OwnsByMarker: true, + OwnershipKey: NPMOwnedKey, State: st, Render: npmRenderOK, ProbeExpected: func(string) (bool, string) { return false, "" }, @@ -145,7 +149,7 @@ func TestNPMEnforceRendersBlockAndWrites(t *testing.T) { t.Fatal("ownership must be recorded through the State seam") } rec, ok := st.get(CategoryPackageConfig, TargetNPM) - if !ok || rec.WrittenValue != npmRendered || rec.AppliedHash != "sha256:N" { + if !ok || rec.WrittenSettings[NPMOwnedKey] != npmRendered || rec.AppliedHash != "sha256:N" { t.Fatalf("state record = %+v ok=%v, want the rendered block + hash", rec, ok) } } @@ -207,7 +211,7 @@ func TestNPMConvergedSeamOverridesBodyEquality(t *testing.T) { // reconciler still rewrites, where plain body-equality would have skipped. w := &fakeWriter{value: npmRendered, present: true} st := &memStateStore{m: map[string]AppliedTargetState{ - msKey(CategoryPackageConfig, TargetNPM): {AppliedHash: "sha256:N", WrittenValue: npmRendered}, + msKey(CategoryPackageConfig, TargetNPM): {AppliedHash: "sha256:N", WrittenSettings: npmOwnRec(npmRendered)}, }} r, rep := newNPMRec(t, npmPolicyEP("sha256:N"), w, st) r.Converged = func(string) (bool, error) { return false, nil } @@ -228,7 +232,7 @@ func TestNPMConvergedTrueIsIdempotent(t *testing.T) { // evaluation. w := &fakeWriter{value: npmRendered, present: true} st := &memStateStore{m: map[string]AppliedTargetState{ - msKey(CategoryPackageConfig, TargetNPM): {AppliedHash: "sha256:N", WrittenValue: npmRendered}, + msKey(CategoryPackageConfig, TargetNPM): {AppliedHash: "sha256:N", WrittenSettings: npmOwnRec(npmRendered)}, }} r, rep := newNPMRec(t, npmPolicyEP("sha256:N"), w, st) r.Converged = func(string) (bool, error) { return true, nil } @@ -255,7 +259,7 @@ func TestNPMAdoptsAlreadyConvergedState(t *testing.T) { }{ {"empty store (other mode applied)", &memStateStore{}}, {"stale hash in store", &memStateStore{m: map[string]AppliedTargetState{ - msKey(CategoryPackageConfig, TargetNPM): {AppliedHash: "sha256:OLD", WrittenValue: npmRendered}, + msKey(CategoryPackageConfig, TargetNPM): {AppliedHash: "sha256:OLD", WrittenSettings: npmOwnRec(npmRendered)}, }}}, } for _, tc := range cases { @@ -273,7 +277,7 @@ func TestNPMAdoptsAlreadyConvergedState(t *testing.T) { t.Fatalf("report = %+v, want compliant + adopted hash", got) } rec, ok := tc.st.get(CategoryPackageConfig, TargetNPM) - if !ok || rec.AppliedHash != "sha256:NEW" || rec.WrittenValue != npmRendered { + if !ok || rec.AppliedHash != "sha256:NEW" || rec.WrittenSettings[NPMOwnedKey] != npmRendered { t.Fatalf("state not adopted: rec=%+v ok=%v", rec, ok) } }) @@ -351,7 +355,7 @@ func TestNPMClearByMarkerAlwaysClearsAndDrops(t *testing.T) { }{ {"no record", &memStateStore{}}, {"stale record", &memStateStore{m: map[string]AppliedTargetState{ - msKey(CategoryPackageConfig, TargetNPM): {WrittenValue: "old-block"}, + msKey(CategoryPackageConfig, TargetNPM): {WrittenSettings: npmOwnRec("old-block")}, }}}, } for _, tc := range cases { @@ -574,3 +578,436 @@ func TestSeamFallbacksMatchIDEBehavior(t *testing.T) { 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 := &memStateStore{} + 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 := &memStateStore{} + 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 || st.reads != 0 { + t.Fatalf("mdm mode touched the state store: writes=%d drops=%d reads=%d", st.writes, st.drops, st.reads) + } +} + +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, &memStateStore{}) + 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, &memStateStore{}) + 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 := &memStateStore{m: map[string]AppliedTargetState{ + msKey(CategoryPackageConfig, TargetNPM): {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 := &memStateStore{} + 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 := &memStateStore{} + 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. Every state + // access routes through the injected StateStore (r.readState), never the shared + // file. + w := &fakeWriter{} + st := &memStateStore{} + 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 := &memStateStore{m: map[string]AppliedTargetState{ + msKey(CategoryPackageConfig, TargetNPM): { + 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, &memStateStore{}) + 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..97cd66e 100644 --- a/internal/devicepolicy/reconcile_test.go +++ b/internal/devicepolicy/reconcile_test.go @@ -108,11 +108,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 +172,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 +180,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 +204,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 +224,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 +233,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 +324,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 +390,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 +474,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 +494,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) } } @@ -525,7 +550,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 +562,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 +586,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 +655,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 +666,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) } } @@ -766,7 +791,10 @@ func (w *fakeManagedWriter) Location() string { return "fake://managed-settings. 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 +1205,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 +1267,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 +1298,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 +1320,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 +1350,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 +1367,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 +1413,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_test.go b/internal/devicepolicy/settings_writer_test.go index fa02041..7a8466d 100644 --- a/internal/devicepolicy/settings_writer_test.go +++ b/internal/devicepolicy/settings_writer_test.go @@ -626,7 +626,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 diff --git a/internal/devicepolicy/statestore.go b/internal/devicepolicy/statestore.go index 6340a46..c22ecdb 100644 --- a/internal/devicepolicy/statestore.go +++ b/internal/devicepolicy/statestore.go @@ -155,9 +155,9 @@ func (s *fileStateStore) Drop(category, target string) error { return errFutureSchema case stateAbsentOrCorrupt: // Absent → nothing to drop. Corrupt (present but unparseable) → the bytes on - // disk can still carry a token-bearing WrittenValue; a Drop that left the file - // in place would strand that credential after offboarding. Remove it so no - // stale record — readable or not — survives the clear. + // disk can still carry a token-bearing WrittenSettings entry; a Drop that left + // the file in place would strand that credential after offboarding. Remove it + // so no stale record — readable or not — survives the clear. return s.removeIfPresent() } cat, ok := f.Categories[category] diff --git a/internal/devicepolicy/statestore_test.go b/internal/devicepolicy/statestore_test.go index a5a12b8..5398a8d 100644 --- a/internal/devicepolicy/statestore_test.go +++ b/internal/devicepolicy/statestore_test.go @@ -25,15 +25,15 @@ func TestFileStateStoreRoundTrip(t *testing.T) { t.Fatal("an empty store must read absent") } want := AppliedTargetState{ - AppliedHash: "sha256:N", - WrittenValue: "managed-block", - FetchedAt: time.Date(2026, 7, 19, 0, 0, 0, 0, time.UTC), + AppliedHash: "sha256:N", + WrittenSettings: npmOwnRec("managed-block"), + FetchedAt: time.Date(2026, 7, 19, 0, 0, 0, 0, time.UTC), } if err := s.Write(CategoryPackageConfig, TargetNPM, want); err != nil { t.Fatalf("Write: %v", err) } got, ok := s.Read(CategoryPackageConfig, TargetNPM) - if !ok || got.AppliedHash != want.AppliedHash || got.WrittenValue != want.WrittenValue || !got.FetchedAt.Equal(want.FetchedAt) { + if !ok || got.AppliedHash != want.AppliedHash || got.WrittenSettings[NPMOwnedKey] != want.WrittenSettings[NPMOwnedKey] || !got.FetchedAt.Equal(want.FetchedAt) { t.Fatalf("Read = %+v ok=%v, want %+v", got, ok, want) } // The file is created 0600 (not group/other-accessible). POSIX-only: Windows @@ -60,8 +60,8 @@ func TestFileStateStorePreservesSiblings(t *testing.T) { // The read-modify-write must preserve every other category/target — the same // guarantee the shared cache makes, applied to this category's own file. s := newFileStore(t) - npm := AppliedTargetState{AppliedHash: "sha256:N", WrittenValue: "npm-block"} - ide := AppliedTargetState{AppliedHash: "sha256:V", WrittenValue: "vscode-value"} + npm := AppliedTargetState{AppliedHash: "sha256:N", WrittenSettings: npmOwnRec("npm-block")} + ide := AppliedTargetState{AppliedHash: "sha256:V", WrittenSettings: ownRec("vscode-value")} if err := s.Write(CategoryPackageConfig, TargetNPM, npm); err != nil { t.Fatal(err) } @@ -75,7 +75,7 @@ func TestFileStateStorePreservesSiblings(t *testing.T) { t.Fatal("the dropped npm record must be gone") } got, ok := s.Read(CategoryIDEExtension, TargetVSCode) - if !ok || got.WrittenValue != "vscode-value" { + if !ok || got.WrittenSettings[allowedExtensionsSettingKey] != "vscode-value" { t.Fatalf("the sibling record must survive, got %+v ok=%v", got, ok) } } @@ -96,7 +96,7 @@ func TestFileStateStoreRefusesFutureSchema(t *testing.T) { if _, ok := s.Read(CategoryPackageConfig, TargetNPM); ok { t.Fatal("a future-schema file must read as absent") } - if err := s.Write(CategoryPackageConfig, TargetNPM, AppliedTargetState{WrittenValue: "x"}); err != errFutureSchema { + if err := s.Write(CategoryPackageConfig, TargetNPM, AppliedTargetState{WrittenSettings: npmOwnRec("x")}); err != errFutureSchema { t.Fatalf("Write err = %v, want errFutureSchema", err) } if err := s.Drop(CategoryPackageConfig, TargetNPM); err != errFutureSchema { @@ -123,11 +123,11 @@ func TestFileStateStoreRecreatesCorruptFile(t *testing.T) { if _, ok := s.Read(CategoryPackageConfig, TargetNPM); ok { t.Fatal("a corrupt file must read as absent") } - if err := s.Write(CategoryPackageConfig, TargetNPM, AppliedTargetState{WrittenValue: "blk"}); err != nil { + if err := s.Write(CategoryPackageConfig, TargetNPM, AppliedTargetState{WrittenSettings: npmOwnRec("blk")}); err != nil { t.Fatalf("Write over a corrupt file must recreate it, got %v", err) } got, ok := s.Read(CategoryPackageConfig, TargetNPM) - if !ok || got.WrittenValue != "blk" { + if !ok || got.WrittenSettings[NPMOwnedKey] != "blk" { t.Fatalf("record after recreate = %+v ok=%v", got, ok) } } @@ -139,7 +139,7 @@ func TestFileStateStoreDropAbsentIsNoOp(t *testing.T) { t.Fatalf("dropping from an absent store must be a no-op, got %v", err) } // A file that exists but holds no such record. - if err := s.Write(CategoryPackageConfig, TargetNPM, AppliedTargetState{WrittenValue: "x"}); err != nil { + if err := s.Write(CategoryPackageConfig, TargetNPM, AppliedTargetState{WrittenSettings: npmOwnRec("x")}); err != nil { t.Fatal(err) } if err := s.Drop(CategoryIDEExtension, TargetVSCode); err != nil { @@ -151,13 +151,13 @@ func TestFileStateStoreDropAbsentIsNoOp(t *testing.T) { } func TestFileStateStoreDropRemovesCorruptFile(t *testing.T) { - // A corrupt state file can still carry token-bearing WrittenValue bytes. Drop + // A corrupt state file can still carry token-bearing ownership bytes. Drop // (offboarding) must not report success while leaving those bytes on disk — it // removes the file so no stale credential survives the clear. (Contrast an // absent file, which stays a no-op — nothing to remove.) dir := t.TempDir() path := filepath.Join(dir, packageConfigStateBasename+".json") - corrupt := `{ broken "written_value": "ssabc123::dev:S1` // invalid JSON, token-shaped bytes + corrupt := `{ broken "written_settings": "ssabc123::dev:S1` // invalid JSON, token-shaped bytes if err := os.WriteFile(path, []byte(corrupt), 0o600); err != nil { t.Fatal(err) } From d6ae31ae0bbafeb87154527214a37d1ac5a11aa6 Mon Sep 17 00:00:00 2001 From: Subham Ray Date: Mon, 27 Jul 2026 02:23:47 +0530 Subject: [PATCH 06/10] refactor(devicepolicy): route every category's ownership through one locked state file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The npm lane had its own per-mode/per-user ownership store (statestore.go, Reconciler.State) so its record could not share the IDE file's unlocked read-modify-write. That solved the lost update by splitting the file, which costs the property the state file exists to have: one place that answers "what has this agent written". A root cycle and a user cycle could each hold a different partial truth, and nothing could enumerate ownership across categories. Inverted: every category now reads and writes device-policy-state.json keyed by (category, target), and the lost update is prevented by locking instead of by separation. - StateStore, fileStateStore, NewStateStoreFor, Reconciler.State and NPMRCWriter.TargetUser are deleted. readState/persistState/dropState call the package-level accessors directly; the writeState/clearState test seams stay. The IDE wiring set no State, so its behavior is byte-identical. - statelock.go adds the cross-process lock the shared file now needs: an advisory lock on a sibling device-policy-state.json.lock, held only for the read-modify-write. The lock file is a rendezvous point, never a record — created empty, left empty, and deliberately not unlinked on release, since unlinking would let a peer holding the old inode take a lock nobody else can see. flock(2) on POSIX, LockFileEx on Windows; both are owned by the open file description, so a crashed agent cannot wedge state. - Acquisition fails closed. A lock that cannot be taken fails the operation rather than proceeding unlocked: "busy" is precisely the state in which a concurrent writer is proven to exist, and running anyway is the one case that can actually drop another category's record. The caller reports write_failed and the next cycle retries; for npm the enforce preflight persists through the same locked accessor before the settings file is touched, so contention is normally caught with nothing written and nothing to roll back. The wait budget is 10s — the critical section is microseconds, so the budget only has to absorb a rename serialized behind on-access antivirus, a slow network home, or a descheduled peer without mistaking one for a wedged process. The single waiver is a filesystem that does not implement locking at all (ENOTSUP/EOPNOTSUPP, ERROR_NOT_SUPPORTED/ERROR_INVALID_FUNCTION): there no peer can hold a lock either, so there is no race to lose, while failing closed would leave state permanently unpersistable on a network or FUSE home. Deliberately excluded are two errnos that look like they belong there and do not — ENOLCK is exhausted kernel lock records on a mount where peers may already HOLD locks, and EINVAL is an invalid request; both fail closed. - An unreadable state file is no longer treated as absent. readStateFile is now three-valued: absent-or-corrupt (nothing interpretable to lose, so a write recreates and a clear's remove already takes ENOENT as success) versus unreadable — present, but its bytes were never obtained. The mutators refuse the latter and return the read error, because on POSIX a file we cannot read is still rewritable and unlinkable through a writable parent: an npm clear would have removed live IDE ownership, and both paths returned nil, reporting the loss as success. The realistic trigger is not a mode-000 toy but a root-owned 0600 state file inside a user-owned directory. ReadAppliedState keeps its no-error contract and still reads unreadable as "owns nothing", which is the safe direction: the reconciler re-applies rather than clearing what it has no record of writing. - settings.json writes now preserve a leading UTF-8 BOM. load splits it off before both the blank check and the parse, and store re-prepends it. A BOM is neither whitespace to bytes.TrimSpace nor a valid start of a JSON value, so a seeded file carrying one read as unparseable and stayed that way permanently: the only way to remove the bytes was the write the never-clobber contract then refused to attempt. PowerShell 5.1's Set-Content -Encoding UTF8 and editors set to utf8bom both emit it. stripBOM is now shared with the ~/.npmrc writer. Also corrects the enforceSingle adoption comment, which credited a per-mode store for the "converged on disk but not in our record" case. With one file the remaining causes are a stale or hand-removed record, or a cycle that resolved a different home for the state file. --- cmd/stepsecurity-dev-machine-guard/main.go | 3 +- internal/devicepolicy/cache.go | 208 +++++-- internal/devicepolicy/cache_test.go | 565 ++++++++++++++++++ internal/devicepolicy/npmrc.go | 11 +- internal/devicepolicy/reconcile.go | 42 +- internal/devicepolicy/reconcile_npm_test.go | 279 +++++---- internal/devicepolicy/reconcile_test.go | 23 + internal/devicepolicy/settings_writer.go | 57 +- internal/devicepolicy/settings_writer_test.go | 212 +++++++ internal/devicepolicy/statelock.go | 132 ++++ internal/devicepolicy/statelock_other.go | 18 + internal/devicepolicy/statelock_unix.go | 60 ++ internal/devicepolicy/statelock_unix_test.go | 43 ++ internal/devicepolicy/statelock_windows.go | 57 ++ internal/devicepolicy/statestore.go | 274 --------- internal/devicepolicy/statestore_test.go | 215 ------- 16 files changed, 1472 insertions(+), 727 deletions(-) create mode 100644 internal/devicepolicy/statelock.go create mode 100644 internal/devicepolicy/statelock_other.go create mode 100644 internal/devicepolicy/statelock_unix.go create mode 100644 internal/devicepolicy/statelock_unix_test.go create mode 100644 internal/devicepolicy/statelock_windows.go delete mode 100644 internal/devicepolicy/statestore.go delete mode 100644 internal/devicepolicy/statestore_test.go diff --git a/cmd/stepsecurity-dev-machine-guard/main.go b/cmd/stepsecurity-dev-machine-guard/main.go index 9eb5ed5..93d4f86 100644 --- a/cmd/stepsecurity-dev-machine-guard/main.go +++ b/cmd/stepsecurity-dev-machine-guard/main.go @@ -846,11 +846,12 @@ func runPackageConfigEnforce(exec executor.Executor, log *progress.Logger) { // 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 - r.State = devicepolicy.NewStateStoreFor(w.TargetUser()) // 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 diff --git a/internal/devicepolicy/cache.go b/internal/devicepolicy/cache.go index 4a2df11..0beb86b 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": … } +// } // } // } // } @@ -77,12 +86,10 @@ type AppliedTargetState struct { 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 / @@ -120,9 +127,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. @@ -147,31 +164,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 @@ -185,16 +208,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() @@ -202,7 +231,7 @@ func ReadAppliedState(category, target string) (AppliedTargetState, bool) { if target == "" { target = TargetVSCode } - f, status := readStateFile() + f, status, _ := readStateFile() if status != stateReadable { return AppliedTargetState{}, false } @@ -219,7 +248,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() @@ -227,31 +265,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() @@ -259,27 +315,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 @@ -340,4 +416,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 71eb4e4..f8bbb90 100644 --- a/internal/devicepolicy/cache_test.go +++ b/internal/devicepolicy/cache_test.go @@ -5,7 +5,9 @@ import ( "errors" "os" "path/filepath" + "runtime" "strings" + "sync" "testing" "time" ) @@ -477,3 +479,566 @@ func TestAppliedTargetLegacyWrittenValueReadsAsUnowned(t *testing.T) { 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 index fa9ebc8..b2552ee 100644 --- a/internal/devicepolicy/npmrc.go +++ b/internal/devicepolicy/npmrc.go @@ -248,12 +248,6 @@ func (w *NPMRCWriter) Close() error { return err } -// TargetUser is the console user resolved once at construction and immutable -// for the writer's lifetime. The per-user state store must be built from this -// identity, never a second independent resolution — two resolutions can -// straddle a console-user switch and bind one user's file to another's record. -func (w *NPMRCWriter) TargetUser() *user.User { return w.targetUser } - // Location is a human-readable target description for logs. It never includes // file contents or key material. func (w *NPMRCWriter) Location() string { @@ -1111,8 +1105,9 @@ func (w *NPMRCWriter) clearContent(current []byte) ([]byte, error) { } // stripBOM splits a leading UTF-8 BOM off the content. The BOM is removed for -// parsing (so a first-line key is matched correctly) and re-prepended on -// rewrite so the byte is preserved. +// 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)) { diff --git a/internal/devicepolicy/reconcile.go b/internal/devicepolicy/reconcile.go index 356e5e5..11e1d84 100644 --- a/internal/devicepolicy/reconcile.go +++ b/internal/devicepolicy/reconcile.go @@ -112,13 +112,6 @@ type Reconciler struct { // Writer is the ordinary unsupported-platform silent no-op. WriterInitErr error - // State, when set, is the ownership store this cycle reads and writes through, - // replacing the package-level shared-file functions. The npm category uses - // its own per-mode/per-user store so its record can never share the IDE - // file's unlocked read-modify-write. nil → the shared package-level store - // (settings.json), byte-identical to before. - State StateStore - // Now and Logf are optional seams. Now defaults to time.Now().UTC; Logf to a // no-op. Now func() time.Time @@ -126,7 +119,6 @@ type Reconciler struct { // writeState and clearState are test seams over the ownership store // (WriteAppliedState / ClearAppliedState). nil → the real implementation. - // They apply only to the shared store (State == nil). writeState func(category, target string, s AppliedTargetState) error clearState func(category, target string) error @@ -137,21 +129,17 @@ type Reconciler struct { enforcement string } -// readState / persistState / dropState route through the injected StateStore -// when one is set, and otherwise fall back to the shared package-level store -// (with the writeState/clearState test seams) exactly as before — so the IDE -// wiring, which sets no State, is unchanged. +// 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) { - if r.State != nil { - return r.State.Read(cat, tgt) - } return ReadAppliedState(cat, tgt) } func (r *Reconciler) persistState(cat, tgt string, s AppliedTargetState) error { - if r.State != nil { - return r.State.Write(cat, tgt, s) - } if r.writeState != nil { return r.writeState(cat, tgt, s) } @@ -159,9 +147,6 @@ func (r *Reconciler) persistState(cat, tgt string, s AppliedTargetState) error { } func (r *Reconciler) dropState(cat, tgt string) error { - if r.State != nil { - return r.State.Drop(cat, tgt) - } if r.clearState != nil { return r.clearState(cat, tgt) } @@ -700,13 +685,14 @@ func (r *Reconciler) enforceSingle(ctx context.Context, cat, tgt string, ep Effe // 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 THIS cycle's store does not carry this hash. That happens when - // the other privilege mode applied it and recorded in its own per-mode store, - // or our record is stale. Adopt the on-disk state into this store rather than - // churn a redundant rewrite or misreport it as drift, and report compliant. - // Best-effort: the block is already applied, so a store hiccup only defers the - // record one cycle. Gated on the Converged seam so the settings.json path - // (body equality, shared store) is byte-identical to before. + // 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, diff --git a/internal/devicepolicy/reconcile_npm_test.go b/internal/devicepolicy/reconcile_npm_test.go index ce4b31d..038e947 100644 --- a/internal/devicepolicy/reconcile_npm_test.go +++ b/internal/devicepolicy/reconcile_npm_test.go @@ -13,63 +13,55 @@ import ( ) // The tests here drive the reconciler with the seams the ~/.npmrc path sets -// (Render, Converged, ProbeExpected, RestoreSnapshot, OwnsByMarker, State). The +// (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. -// --- in-memory StateStore fake --------------------------------------------- +// --- state fixture ---------------------------------------------------------- -// memStateStore is an in-memory StateStore for the npm reconcile tests. The npm -// category always drives ownership through the State seam (never the shared -// package-level cache), so these tests inject this instead of using -// withTempCache. It counts calls so a test can assert the ladder routed through -// the store, and failWriteFrom forces the post-write persist to fail. -type memStateStore struct { - m map[string]AppliedTargetState +// 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, Write errors on the Nth call and after + failWriteFrom int // when >0, the Nth persist and every one after it fails writes int drops int - reads int } -func msKey(cat, tgt string) string { return cat + "\x00" + tgt } - -func (s *memStateStore) Read(cat, tgt string) (AppliedTargetState, bool) { - s.reads++ - st, ok := s.m[msKey(cat, tgt)] - return st, ok +func newNPMStore(t *testing.T) *npmStore { + t.Helper() + path := filepath.Join(t.TempDir(), CacheFilename) + t.Cleanup(SetCachePathForTest(path)) + return &npmStore{path: path} } -func (s *memStateStore) Write(cat, tgt string, st AppliedTargetState) error { - s.writes++ - if s.failWriteFrom > 0 && s.writes >= s.failWriteFrom { - return errors.New("state store write failed") - } - if s.writeErr != nil { - return s.writeErr - } - if s.m == nil { - s.m = map[string]AppliedTargetState{} +// 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) } - s.m[msKey(cat, tgt)] = st - return nil + return s } -func (s *memStateStore) Drop(cat, tgt string) error { - s.drops++ - if s.dropErr != nil { - return s.dropErr - } - delete(s.m, msKey(cat, tgt)) - return nil +func (s *npmStore) get(cat, tgt string) (AppliedTargetState, bool) { + return ReadAppliedState(cat, tgt) } -func (s *memStateStore) get(cat, tgt string) (AppliedTargetState, bool) { - st, ok := s.m[msKey(cat, tgt)] - return st, ok +// 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 ----------------------------------------------------------- @@ -93,15 +85,15 @@ func npmPolicyEP(hash string) EffectivePolicy { } } -// newNPMRec builds a marker-owned, State-backed 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 +// 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. No withTempCache — State routes -// every ownership access away from the shared file. -func newNPMRec(t *testing.T, ep EffectivePolicy, w *fakeWriter, st *memStateStore) (*Reconciler, *fakeReporter) { +// 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{ @@ -115,12 +107,30 @@ func newNPMRec(t *testing.T, ep EffectivePolicy, w *fakeWriter, st *memStateStor Target: TargetNPM, OwnsByMarker: true, OwnershipKey: NPMOwnedKey, - State: st, 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 } @@ -128,7 +138,7 @@ func newNPMRec(t *testing.T, ep EffectivePolicy, w *fakeWriter, st *memStateStor func TestNPMEnforceRendersBlockAndWrites(t *testing.T) { w := &fakeWriter{} - st := &memStateStore{} + 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) @@ -144,9 +154,9 @@ func TestNPMEnforceRendersBlockAndWrites(t *testing.T) { if got.AppliedHash != "sha256:N" { t.Fatalf("applied_hash = %q, want sha256:N", got.AppliedHash) } - // Ownership recorded through the State seam (never the shared file). + // Ownership recorded in the one shared state file, under this category/target. if st.writes == 0 { - t.Fatal("ownership must be recorded through the State seam") + 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" { @@ -159,7 +169,7 @@ func TestNPMRenderFailureReportsPolicyNotApplied(t *testing.T) { // 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 := &memStateStore{} + st := newNPMStore(t) r, rep := newNPMRec(t, npmPolicyEP("sha256:N"), w, st) probed := false r.ProbeExpected = func(string) (bool, string) { probed = true; return false, "" } @@ -182,7 +192,7 @@ func TestNPMProbeExpectedReceivesRenderedBlockAndYields(t *testing.T) { // 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 := &memStateStore{} + st := newNPMStore(t) r, rep := newNPMRec(t, npmPolicyEP("sha256:N"), w, st) var gotArg string r.ProbeExpected = func(expected string) (bool, string) { @@ -210,9 +220,8 @@ func TestNPMConvergedSeamOverridesBodyEquality(t *testing.T) { // (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 := &memStateStore{m: map[string]AppliedTargetState{ - msKey(CategoryPackageConfig, TargetNPM): {AppliedHash: "sha256:N", WrittenSettings: npmOwnRec(npmRendered)}, - }} + 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 { @@ -231,9 +240,8 @@ func TestNPMConvergedTrueIsIdempotent(t *testing.T) { // and effective. No write; still reports compliant so the backend sees a fresh // evaluation. w := &fakeWriter{value: npmRendered, present: true} - st := &memStateStore{m: map[string]AppliedTargetState{ - msKey(CategoryPackageConfig, TargetNPM): {AppliedHash: "sha256:N", WrittenSettings: npmOwnRec(npmRendered)}, - }} + 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 { @@ -248,24 +256,26 @@ func TestNPMConvergedTrueIsIdempotent(t *testing.T) { } func TestNPMAdoptsAlreadyConvergedState(t *testing.T) { - // Cross-mode store split: the exact block is fully applied on disk - // (Converged=true) but THIS store carries no matching hash — the other - // privilege mode applied and recorded it in its own per-mode store, or our - // record is stale. The reconciler must adopt the on-disk state (no rewrite, no - // false drift) and report compliant, recording the current hash for next cycle. + // 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 - st *memStateStore + seed *AppliedTargetState }{ - {"empty store (other mode applied)", &memStateStore{}}, - {"stale hash in store", &memStateStore{m: map[string]AppliedTargetState{ - msKey(CategoryPackageConfig, TargetNPM): {AppliedHash: "sha256:OLD", WrittenSettings: npmOwnRec(npmRendered)}, - }}}, + {"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, tc.st) + 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) @@ -276,7 +286,7 @@ func TestNPMAdoptsAlreadyConvergedState(t *testing.T) { if got := lastReport(t, rep); got.State != StateCompliant || got.AppliedHash != "sha256:NEW" { t.Fatalf("report = %+v, want compliant + adopted hash", got) } - rec, ok := tc.st.get(CategoryPackageConfig, TargetNPM) + 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) } @@ -299,7 +309,7 @@ func TestNPMReadErrorClassification(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { w := &fakeWriter{readErr: tc.err} - st := &memStateStore{} + 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") @@ -328,7 +338,7 @@ func TestNPMConvergedErrorClassification(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { w := &fakeWriter{value: "x", present: true} - st := &memStateStore{} + 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 { @@ -351,18 +361,20 @@ func TestNPMClearByMarkerAlwaysClearsAndDrops(t *testing.T) { // record can never strand a token-bearing block. cases := []struct { name string - st *memStateStore + seed *AppliedTargetState }{ - {"no record", &memStateStore{}}, - {"stale record", &memStateStore{m: map[string]AppliedTargetState{ - msKey(CategoryPackageConfig, TargetNPM): {WrittenSettings: npmOwnRec("old-block")}, - }}}, + {"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, tc.st) + r, rep := newNPMRec(t, ep, w, st) if err := r.Reconcile(context.Background()); err != nil { t.Fatalf("Reconcile: %v", err) } @@ -372,10 +384,10 @@ func TestNPMClearByMarkerAlwaysClearsAndDrops(t *testing.T) { if w.reads != 0 { t.Fatalf("marker clear must not read the file, reads=%d", w.reads) } - if tc.st.drops != 1 { - t.Fatalf("marker clear must Drop the record unconditionally, drops=%d", tc.st.drops) + if st.drops != 1 { + t.Fatalf("marker clear must Drop the record unconditionally, drops=%d", st.drops) } - if _, ok := tc.st.get(CategoryPackageConfig, TargetNPM); ok { + if _, ok := st.get(CategoryPackageConfig, TargetNPM); ok { t.Fatal("state record must be gone after a marker clear") } if len(rep.reports) != 0 { @@ -403,7 +415,8 @@ func TestNPMRestoreSnapshotRollbackClassification(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { w := &fakeWriter{} - st := &memStateStore{failWriteFrom: 2} // preflight ok, post-write persist fails + 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 } @@ -443,7 +456,7 @@ func TestNPMWriteErrorClassification(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { w := &fakeWriter{writeErr: tc.err} - st := &memStateStore{} + 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") @@ -478,6 +491,7 @@ func TestNPMWriterInitErrClassification(t *testing.T) { } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { + withTempCache(t) rep := &fakeReporter{} r := &Reconciler{ Fetcher: &fakeFetcher{ep: tc.ep}, @@ -511,28 +525,55 @@ func TestNPMWriterInitErrClassification(t *testing.T) { } } -func TestNPMStateRoutingBypassesSharedFile(t *testing.T) { - // With the State seam set, every ownership access routes to the injected store; - // the shared device-policy-state.json is never created or read. This keeps the - // npm record out of the shared file's unlocked read-modify-write. - path := filepath.Join(t.TempDir(), CacheFilename) - restore := SetCachePathForTest(path) - defer restore() - +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 := &memStateStore{} + 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 st.writes == 0 { - t.Fatal("ownership must have routed through the State seam") + 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 _, err := os.Stat(path); !os.IsNotExist(err) { - t.Fatalf("shared state file must never be created when State is set; stat err = %v", err) + if f.SchemaVersion != CacheSchemaVersion { + t.Fatalf("schema_version = %d, want %d", f.SchemaVersion, CacheSchemaVersion) } - if _, ok := ReadAppliedState(CategoryPackageConfig, TargetNPM); ok { - t.Fatal("shared store must hold no npm record") + 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) } } @@ -607,7 +648,7 @@ func TestNPMDMGChannelStillWrites(t *testing.T) { for _, channel := range []string{"", "dmg", "DMG", " dmg ", "wat"} { t.Run("channel="+channel, func(t *testing.T) { w := &fakeWriter{} - st := &memStateStore{} + st := newNPMStore(t) ep := npmPolicyEP("sha256:N") ep.Enforcement = channel r, rep := newNPMRec(t, ep, w, st) @@ -628,7 +669,7 @@ func TestNPMDMGChannelStillWrites(t *testing.T) { func TestNPMMDMChannelVerifiesAndNeverWrites(t *testing.T) { w := &fakeWriter{} - st := &memStateStore{} + 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 @@ -665,8 +706,12 @@ func TestNPMMDMChannelVerifiesAndNeverWrites(t *testing.T) { 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 || st.reads != 0 { - t.Fatalf("mdm mode touched the state store: writes=%d drops=%d reads=%d", st.writes, st.drops, st.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") } } @@ -699,7 +744,7 @@ func TestNPMMDMChannelStatesFromProbe(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { w := &fakeWriter{} - r, rep := newNPMRec(t, npmMDMEP("sha256:N"), w, &memStateStore{}) + 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) @@ -726,7 +771,7 @@ func TestNPMMDMChannelRenderFailureIsVerificationFailed(t *testing.T) { // 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, &memStateStore{}) + 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) { @@ -752,9 +797,8 @@ func TestNPMMDMChannelClearIsANoOp(t *testing.T) { // 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 := &memStateStore{m: map[string]AppliedTargetState{ - msKey(CategoryPackageConfig, TargetNPM): {AppliedHash: "sha256:OLD", WrittenSettings: npmOwnRec(npmRendered)}, - }} + st := newNPMStore(t).seed(t, CategoryPackageConfig, TargetNPM, + AppliedTargetState{AppliedHash: "sha256:OLD", WrittenSettings: npmOwnRec(npmRendered)}) ep := npmMDMEP("") ep.Clear = true ep.Policy = nil @@ -781,7 +825,7 @@ 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 := &memStateStore{} + st := newNPMStore(t) r, rep := newNPMRec(t, npmMDMEP("sha256:N"), nil, st) r.Writer = nil r.Converged = nil @@ -806,7 +850,7 @@ func TestNPMMDMChannelNoProbeSeamIsVerificationFailed(t *testing.T) { // 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 := &memStateStore{} + st := newNPMStore(t) r, rep := newNPMRec(t, npmMDMEP("sha256:N"), nil, st) r.Writer = nil r.Converged = nil @@ -858,11 +902,10 @@ 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. Every state - // access routes through the injected StateStore (r.readState), never the shared - // file. + // 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 := &memStateStore{} + 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 } @@ -937,12 +980,10 @@ func TestNPMEnforceIgnoresForeignOwnershipKeys(t *testing.T) { // own value was changed) — and the post-write persist records the npm key alone, // never inheriting the foreign one. w := &fakeWriter{} - st := &memStateStore{m: map[string]AppliedTargetState{ - msKey(CategoryPackageConfig, TargetNPM): { - AppliedHash: "sha256:N", - WrittenSettings: map[string]string{allowedExtensionsSettingKey: "not-ours"}, - }, - }} + 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) @@ -974,7 +1015,7 @@ func TestNPMNeverLogsOrReportsTheToken(t *testing.T) { w := &fakeWriter{} ep := npmPolicyEP("sha256:N") ep.Enforcement = channel - r, rep := newNPMRec(t, ep, w, &memStateStore{}) + 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 diff --git a/internal/devicepolicy/reconcile_test.go b/internal/devicepolicy/reconcile_test.go index 97cd66e..bd5ce5a 100644 --- a/internal/devicepolicy/reconcile_test.go +++ b/internal/devicepolicy/reconcile_test.go @@ -518,6 +518,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 diff --git a/internal/devicepolicy/settings_writer.go b/internal/devicepolicy/settings_writer.go index 06dc773..27baa01 100644 --- a/internal/devicepolicy/settings_writer.go +++ b/internal/devicepolicy/settings_writer.go @@ -198,33 +198,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 +280,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 +300,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 +310,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() @@ -312,7 +324,7 @@ func (w *settingsWriter) Write(value string) (string, error) { // 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() + v, existed, bom, err := w.load() if err != nil { return err } @@ -328,15 +340,24 @@ func (w *settingsWriter) Clear() error { if err := v.Patch([]byte(patch)); err != nil { return fmt.Errorf("devicepolicy: patch %s: %w", w.path, err) } - return w.store(v) + return w.store(v, bom) } // 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 +367,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 +424,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 +469,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 7a8466d..4d55887 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" @@ -654,3 +655,214 @@ 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) + } +} 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..4f3b53a --- /dev/null +++ b/internal/devicepolicy/statelock_unix.go @@ -0,0 +1,60 @@ +//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) { + 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) { + _ = 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..6e88ff7 --- /dev/null +++ b/internal/devicepolicy/statelock_windows.go @@ -0,0 +1,57 @@ +//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. +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/statestore.go b/internal/devicepolicy/statestore.go deleted file mode 100644 index c22ecdb..0000000 --- a/internal/devicepolicy/statestore.go +++ /dev/null @@ -1,274 +0,0 @@ -package devicepolicy - -import ( - "encoding/json" - "errors" - "os" - "os/user" - "path/filepath" - "runtime" - "strconv" - "sync" -) - -// StateStore is the ownership store the reconciler reads and writes through. It -// abstracts WHERE a category's applied-state records live so a category can keep -// its records in its own file instead of the shared device-policy-state.json. -// The three methods mirror the package-level ReadAppliedState / -// WriteAppliedState / ClearAppliedState the shared IDE path still calls directly. -// -// The npm category always uses one of these; the IDE category leaves it nil and -// keeps the shared store, byte-for-byte unchanged. -type StateStore interface { - Read(category, target string) (AppliedTargetState, bool) - Write(category, target string, s AppliedTargetState) error - Drop(category, target string) error -} - -// packageConfigStateBasename is the state-file basename for the package_config -// category. It is DELIBERATELY separate from CacheFilename (the shared IDE -// state): neither store takes a cross-process lock, so the only thing that keeps -// a package reconcile in one process from silently dropping a concurrent IDE -// reconcile's record in another (or vice versa) is that they never share a file. -// Separate files make that lost update structurally impossible. -const packageConfigStateBasename = "package-config-state" - -// NewStateStoreFor builds the package_config ownership store for a resolved -// target user. It is bound to the identity handed in — the writer's -// TargetUser(), resolved exactly once — never a second independent lookup, so a -// console-user switch between two resolutions cannot file one user's state under -// another's record. -// -// Placement: -// - user mode (the process IS the target user; the common case, and every -// Windows case) → /.stepsecurity/package-config-state.json (0600). -// - root mode (a POSIX daemon running as uid 0) → a machine-owned directory -// (/Library/Application Support/StepSecurity on macOS, /var/lib/stepsecurity -// on Linux; 0700 root) with ONE record file per target uid -// (package-config-state-.json). The shared store's ~/.stepsecurity is -// unsafe under root here: the LaunchDaemon bakes the install-time console -// user's HOME into its plist, so os.UserHomeDir() resolves into a -// user-controlled tree and stays pinned to that user even after someone else -// logs in. Per-uid files also keep one console user's ~/.npmrc state from -// conflating with another's across cycles (false drift / wrong ownership). -func NewStateStoreFor(u *user.User) StateStore { - if dir, uid, ok := rootMachineStateDir(u); ok { - return &fileStateStore{ - dir: dir, - path: filepath.Join(dir, packageConfigStateBasename+"-"+uid+".json"), - } - } - home := "" - if u != nil { - home = u.HomeDir - } - dir := filepath.Join(home, ".stepsecurity") - return &fileStateStore{ - dir: dir, - path: filepath.Join(dir, packageConfigStateBasename+".json"), - } -} - -// rootMachineStateDir returns the machine-owned state directory and the target -// user's uid string when the process is a POSIX root daemon with a usable -// per-user identity. ok=false — meaning user-mode placement — when the process -// is not root (Geteuid is -1 on Windows, so it never is there), the OS has no -// defined machine path, or the uid is unresolvable. -func rootMachineStateDir(u *user.User) (dir, uid string, ok bool) { - if os.Geteuid() != 0 || u == nil { - return "", "", false - } - if _, err := strconv.Atoi(u.Uid); err != nil { - return "", "", false - } - switch runtime.GOOS { - case "darwin": - return "/Library/Application Support/StepSecurity", u.Uid, true - case "linux": - return "/var/lib/stepsecurity", u.Uid, true - default: - return "", "", false - } -} - -// fileStateStore is a StateStore backed by one JSON file at a fixed path. It -// carries the same schema-versioned category→target→record shape and the same -// atomic-replace (temp + fsync + rename) + future-schema-refusal discipline as -// the shared cache.go store — only the path differs, and the file belongs to -// this category alone. A process-local mutex serializes its read-modify-write -// within a process; across processes there is no lock — the atomic temp+rename -// keeps the file from tearing, and a concurrent overlap is eventually consistent -// (identical records while the policy is stable; a transient stale record only -// during a policy transition, reconverged on the next cycle). -type fileStateStore struct { - dir string - path string - mu sync.Mutex -} - -func (s *fileStateStore) Read(category, target string) (AppliedTargetState, bool) { - s.mu.Lock() - defer s.mu.Unlock() - - f, status := s.load() - if status != stateReadable { - return AppliedTargetState{}, false - } - cat, ok := f.Categories[category] - if !ok { - return AppliedTargetState{}, false - } - st, ok := cat.Targets[target] - return st, ok -} - -func (s *fileStateStore) Write(category, target string, st AppliedTargetState) error { - s.mu.Lock() - defer s.mu.Unlock() - - f, status := s.load() - 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] = st - f.Categories[category] = cat - return s.persist(f) -} - -func (s *fileStateStore) Drop(category, target string) error { - s.mu.Lock() - defer s.mu.Unlock() - - f, status := s.load() - switch status { - case stateFuture: - return errFutureSchema - case stateAbsentOrCorrupt: - // Absent → nothing to drop. Corrupt (present but unparseable) → the bytes on - // disk can still carry a token-bearing WrittenSettings entry; a Drop that left - // the file in place would strand that credential after offboarding. Remove it - // so no stale record — readable or not — survives the clear. - return s.removeIfPresent() - } - 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 s.persist(f) -} - -// removeIfPresent deletes this store's file, treating an already-absent file as -// success. It underpins Drop's corrupt-file cleanup: a corrupt state file is -// removed rather than left to strand a token-bearing record on disk. A symlink at -// the path is unlinked (not its target), so it cannot redirect the delete. -func (s *fileStateStore) removeIfPresent() error { - if s.path == "" { - return nil - } - if err := os.Remove(s.path); err != nil && !errors.Is(err, os.ErrNotExist) { - return err - } - return nil -} - -// load reads and classifies this store's file. UNLOCKED: callers hold s.mu. It -// is the path-parameterized twin of cache.go's readStateFile and reuses the same -// schema-version classification (corrupt → recreate, newer → refuse). -func (s *fileStateStore) load() (AppliedStateFile, readStatus) { - if s.path == "" { - return AppliedStateFile{}, stateAbsentOrCorrupt - } - // #nosec G304 -- s.path is built from the resolved target user's home or a - // fixed machine directory plus package constants, never external input. - b, err := os.ReadFile(s.path) - if err != nil { - return AppliedStateFile{}, stateAbsentOrCorrupt - } - ver, ok := peekSchemaVersion(b) - if !ok { - return AppliedStateFile{}, stateAbsentOrCorrupt - } - if ver > CacheSchemaVersion { - return AppliedStateFile{}, stateFuture - } - var f AppliedStateFile - if err := json.Unmarshal(b, &f); err != nil { - return AppliedStateFile{}, stateAbsentOrCorrupt - } - if f.SchemaVersion == 0 { - f.SchemaVersion = CacheSchemaVersion - } - if f.Categories == nil { - f.Categories = map[string]AppliedCategoryState{} - } - return f, stateReadable -} - -// persist stamps the schema version and atomically replaces the file, creating -// the parent dir 0700 and the file 0600. UNLOCKED: callers hold s.mu. The -// path-parameterized twin of cache.go's persistStateFile. On POSIX root the dir -// and file land root-owned (machine state); in user mode they are the target -// user's own — either way not group/other-accessible. -func (s *fileStateStore) persist(f AppliedStateFile) error { - f.SchemaVersion = CacheSchemaVersion - if f.Categories == nil { - f.Categories = map[string]AppliedCategoryState{} - } - if s.path == "" || s.dir == "" { - return errNoHomeDir - } - data, err := json.MarshalIndent(f, "", " ") - if err != nil { - return err - } - data = append(data, '\n') - - if err := os.MkdirAll(s.dir, cacheParentDirMode); err != nil { - return err - } - tmp, err := os.CreateTemp(s.dir, "."+packageConfigStateBasename+".tmp-*") - if err != nil { - return err - } - tmpPath := tmp.Name() - defer func() { - if _, statErr := os.Stat(tmpPath); statErr == nil { - _ = os.Remove(tmpPath) - } - }() - - if _, err := tmp.Write(data); err != nil { - _ = tmp.Close() - return err - } - if err := tmp.Sync(); err != nil { - _ = tmp.Close() - return err - } - if err := tmp.Close(); err != nil { - return err - } - if err := os.Chmod(tmpPath, cacheFileMode); err != nil { - return err - } - return os.Rename(tmpPath, s.path) -} diff --git a/internal/devicepolicy/statestore_test.go b/internal/devicepolicy/statestore_test.go deleted file mode 100644 index 5398a8d..0000000 --- a/internal/devicepolicy/statestore_test.go +++ /dev/null @@ -1,215 +0,0 @@ -package devicepolicy - -import ( - "os" - "os/user" - "path/filepath" - "runtime" - "strings" - "testing" - "time" -) - -func newFileStore(t *testing.T) *fileStateStore { - t.Helper() - dir := t.TempDir() - return &fileStateStore{ - dir: dir, - path: filepath.Join(dir, packageConfigStateBasename+".json"), - } -} - -func TestFileStateStoreRoundTrip(t *testing.T) { - s := newFileStore(t) - if _, ok := s.Read(CategoryPackageConfig, TargetNPM); ok { - t.Fatal("an empty store must read absent") - } - want := AppliedTargetState{ - AppliedHash: "sha256:N", - WrittenSettings: npmOwnRec("managed-block"), - FetchedAt: time.Date(2026, 7, 19, 0, 0, 0, 0, time.UTC), - } - if err := s.Write(CategoryPackageConfig, TargetNPM, want); err != nil { - t.Fatalf("Write: %v", err) - } - got, ok := s.Read(CategoryPackageConfig, TargetNPM) - if !ok || got.AppliedHash != want.AppliedHash || got.WrittenSettings[NPMOwnedKey] != want.WrittenSettings[NPMOwnedKey] || !got.FetchedAt.Equal(want.FetchedAt) { - t.Fatalf("Read = %+v ok=%v, want %+v", got, ok, want) - } - // The file is created 0600 (not group/other-accessible). POSIX-only: Windows - // has no permission bits, so Chmod can't set 0600 and Stat reports 0666 for any - // writable file. - if runtime.GOOS != "windows" { - info, err := os.Stat(s.path) - if err != nil { - t.Fatal(err) - } - if info.Mode().Perm() != cacheFileMode { - t.Fatalf("file mode = %o, want %o", info.Mode().Perm(), cacheFileMode) - } - } - if err := s.Drop(CategoryPackageConfig, TargetNPM); err != nil { - t.Fatalf("Drop: %v", err) - } - if _, ok := s.Read(CategoryPackageConfig, TargetNPM); ok { - t.Fatal("a dropped record must read absent") - } -} - -func TestFileStateStorePreservesSiblings(t *testing.T) { - // The read-modify-write must preserve every other category/target — the same - // guarantee the shared cache makes, applied to this category's own file. - s := newFileStore(t) - npm := AppliedTargetState{AppliedHash: "sha256:N", WrittenSettings: npmOwnRec("npm-block")} - ide := AppliedTargetState{AppliedHash: "sha256:V", WrittenSettings: ownRec("vscode-value")} - if err := s.Write(CategoryPackageConfig, TargetNPM, npm); err != nil { - t.Fatal(err) - } - if err := s.Write(CategoryIDEExtension, TargetVSCode, ide); err != nil { - t.Fatal(err) - } - if err := s.Drop(CategoryPackageConfig, TargetNPM); err != nil { - t.Fatal(err) - } - if _, ok := s.Read(CategoryPackageConfig, TargetNPM); ok { - t.Fatal("the dropped npm record must be gone") - } - got, ok := s.Read(CategoryIDEExtension, TargetVSCode) - if !ok || got.WrittenSettings[allowedExtensionsSettingKey] != "vscode-value" { - t.Fatalf("the sibling record must survive, got %+v ok=%v", got, ok) - } -} - -func TestFileStateStoreRefusesFutureSchema(t *testing.T) { - // An older agent meeting a NEWER agent's state file must not overwrite it: - // Read yields "owns nothing", and Write/Drop refuse rather than clobber - // metadata they can't interpret. The file stays byte-identical. - dir := t.TempDir() - path := filepath.Join(dir, packageConfigStateBasename+".json") - future := `{"schema_version":999,"categories":{"package_config":{"targets":` + - `{"npm":{"applied_hash":"sha256:z","written_value":"blk","fetched_at":"2026-07-01T00:00:00Z"}}}}}` + "\n" - if err := os.WriteFile(path, []byte(future), 0o600); err != nil { - t.Fatal(err) - } - s := &fileStateStore{dir: dir, path: path} - - if _, ok := s.Read(CategoryPackageConfig, TargetNPM); ok { - t.Fatal("a future-schema file must read as absent") - } - if err := s.Write(CategoryPackageConfig, TargetNPM, AppliedTargetState{WrittenSettings: npmOwnRec("x")}); err != errFutureSchema { - t.Fatalf("Write err = %v, want errFutureSchema", err) - } - if err := s.Drop(CategoryPackageConfig, TargetNPM); err != errFutureSchema { - t.Fatalf("Drop err = %v, want errFutureSchema", err) - } - after, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - if string(after) != future { - t.Fatalf("a future-schema file must be left byte-identical, got %q", string(after)) - } -} - -func TestFileStateStoreRecreatesCorruptFile(t *testing.T) { - // A corrupt (non-JSON-object) file reads as absent and is recreated cleanly on - // the next write — never surfaced as an error. - dir := t.TempDir() - path := filepath.Join(dir, packageConfigStateBasename+".json") - if err := os.WriteFile(path, []byte("not json"), 0o600); err != nil { - t.Fatal(err) - } - s := &fileStateStore{dir: dir, path: path} - if _, ok := s.Read(CategoryPackageConfig, TargetNPM); ok { - t.Fatal("a corrupt file must read as absent") - } - if err := s.Write(CategoryPackageConfig, TargetNPM, AppliedTargetState{WrittenSettings: npmOwnRec("blk")}); err != nil { - t.Fatalf("Write over a corrupt file must recreate it, got %v", err) - } - got, ok := s.Read(CategoryPackageConfig, TargetNPM) - if !ok || got.WrittenSettings[NPMOwnedKey] != "blk" { - t.Fatalf("record after recreate = %+v ok=%v", got, ok) - } -} - -func TestFileStateStoreDropAbsentIsNoOp(t *testing.T) { - s := newFileStore(t) - // No file yet. - if err := s.Drop(CategoryPackageConfig, TargetNPM); err != nil { - t.Fatalf("dropping from an absent store must be a no-op, got %v", err) - } - // A file that exists but holds no such record. - if err := s.Write(CategoryPackageConfig, TargetNPM, AppliedTargetState{WrittenSettings: npmOwnRec("x")}); err != nil { - t.Fatal(err) - } - if err := s.Drop(CategoryIDEExtension, TargetVSCode); err != nil { - t.Fatalf("dropping an absent record must be a no-op, got %v", err) - } - if _, ok := s.Read(CategoryPackageConfig, TargetNPM); !ok { - t.Fatal("the existing record must be untouched") - } -} - -func TestFileStateStoreDropRemovesCorruptFile(t *testing.T) { - // A corrupt state file can still carry token-bearing ownership bytes. Drop - // (offboarding) must not report success while leaving those bytes on disk — it - // removes the file so no stale credential survives the clear. (Contrast an - // absent file, which stays a no-op — nothing to remove.) - dir := t.TempDir() - path := filepath.Join(dir, packageConfigStateBasename+".json") - corrupt := `{ broken "written_settings": "ssabc123::dev:S1` // invalid JSON, token-shaped bytes - if err := os.WriteFile(path, []byte(corrupt), 0o600); err != nil { - t.Fatal(err) - } - s := &fileStateStore{dir: dir, path: path} - if err := s.Drop(CategoryPackageConfig, TargetNPM); err != nil { - t.Fatalf("Drop over a corrupt file must succeed, got %v", err) - } - if _, err := os.Stat(path); !os.IsNotExist(err) { - t.Fatalf("Drop must remove a corrupt state file, stat err = %v", err) - } -} - -func TestNewStateStoreForPlacement(t *testing.T) { - // Placement depends on whether the process is a POSIX root daemon. Assert the - // branch that matches this test process's euid so the test is host-portable. - u := &user.User{Uid: "501", HomeDir: filepath.Join(t.TempDir(), "home")} - s, ok := NewStateStoreFor(u).(*fileStateStore) - if !ok { - t.Fatal("NewStateStoreFor must return a *fileStateStore") - } - base := filepath.Base(s.path) - if os.Geteuid() == 0 { - // Root daemon → machine-owned dir + ONE record file per target uid. - if !strings.HasPrefix(s.path, "/Library/Application Support/StepSecurity") && - !strings.HasPrefix(s.path, "/var/lib/stepsecurity") { - t.Fatalf("root-mode path must be machine-owned, got %q", s.path) - } - if base != packageConfigStateBasename+"-501.json" { - t.Fatalf("root-mode file must be per-uid, got %q", base) - } - } else { - // User mode → under the target user's ~/.stepsecurity. - wantDir := filepath.Join(u.HomeDir, ".stepsecurity") - if s.dir != wantDir { - t.Fatalf("user-mode dir = %q, want %q", s.dir, wantDir) - } - if base != packageConfigStateBasename+".json" { - t.Fatalf("user-mode file = %q, want %q", base, packageConfigStateBasename+".json") - } - } -} - -func TestNewStateStoreForNonNumericUIDIsUserMode(t *testing.T) { - // A non-numeric uid (a Windows SID, or an unresolved identity) cannot select a - // per-uid machine file, so placement falls back to user mode — proven by the - // user-mode basename regardless of euid. - u := &user.User{Uid: "S-1-5-21-abc", HomeDir: filepath.Join(t.TempDir(), "home")} - s, ok := NewStateStoreFor(u).(*fileStateStore) - if !ok { - t.Fatal("NewStateStoreFor must return a *fileStateStore") - } - if base := filepath.Base(s.path); base != packageConfigStateBasename+".json" { - t.Fatalf("a non-numeric uid must yield the user-mode file, got %q", base) - } -} From c92c9f8814b08ff5ae043cb38b47f8baf1d4cbd1 Mon Sep 17 00:00:00 2001 From: Subham Ray Date: Mon, 27 Jul 2026 19:42:58 +0530 Subject: [PATCH 07/10] test(devicepolicy): cover the Windows lock-unavailable waiver and document its exclusions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lockUnavailable is the one place the state lock's fail-closed rule is waived: an error classified there runs the read-modify-write unlocked. The POSIX list has a table test pinning it. The Windows list had none on any platform — a _windows_test.go file does not compile on a POSIX host, and no such file existed for the native job to run — so a widened carve-out would have silently reintroduced the lost update the lock exists to prevent. The plausible way in is a field fix: a managed volume answers LockFileEx with "Access is denied.", and the error gets added to the waiver to stop the noise. - statelock_windows_test.go mirrors the POSIX table, including the three near misses that correspond to ENOLCK and EINVAL. ERROR_NO_SYSTEM_RESOURCES and ERROR_NOT_ENOUGH_MEMORY are a lock record the kernel could not allocate on a volume where peers may already hold locks; ERROR_INVALID_PARAMETER is a malformed request, which says nothing about whether the volume can lock. ERROR_LOCK_VIOLATION is asserted as well, even though tryLockHandle filters contention before it can reach the function: it is the one error that proves a peer holds the lock, making it the worst possible thing to waive, and the assertion is what keeps the two classifications from being merged. The test runs in the existing windows-latest devicepolicy job, so there is no CI change. - The Windows doc comment gains the "here is what looks like it belongs and does not" paragraph the POSIX one already carried. That asymmetry was the real rot surface, and it is the half that acts first: the comment sits where the edit happens, whereas a test can only redden afterwards in CI, named in the failure and mirroring the line just changed. The assertions have not executed yet. There is no Windows host on the dev box, so this was verified by cross-compiling the test binary (GOOS=windows go test -c) and by swapping one constant for a bogus name to confirm the file is genuinely in the Windows test build rather than tag-excluded. First real run is CI. --- internal/devicepolicy/statelock_windows.go | 13 +++++ .../devicepolicy/statelock_windows_test.go | 58 +++++++++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 internal/devicepolicy/statelock_windows_test.go diff --git a/internal/devicepolicy/statelock_windows.go b/internal/devicepolicy/statelock_windows.go index 6e88ff7..104edfe 100644 --- a/internal/devicepolicy/statelock_windows.go +++ b/internal/devicepolicy/statelock_windows.go @@ -44,6 +44,19 @@ func tryLockHandle(f *os.File) (bool, error) { // 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) 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) + } + }) + } +} From 26a562c8b41d295d8329f47617934b96ac1d8fb1 Mon Sep 17 00:00:00 2001 From: Subham Ray Date: Mon, 27 Jul 2026 21:44:29 +0530 Subject: [PATCH 08/10] chore(devicepolicy): annotate the false G115 on flock descriptor narrowing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gosec flags int(f.Fd()) in tryLockHandle and unlockHandle as an integer overflow conversion, which reddened the PR's code-scanning gate with two alerts it cannot resolve on its own. Fd() returns a live *os.File descriptor: a small non-negative int widened to uintptr, so narrowing it back cannot overflow. The one edge value is the ^uintptr(0) a closed file returns, and that converts to -1, which flock rejects with EBADF — an error tryLockHandle already fails closed on. Annotated with the #nosec idiom the package already uses rather than reworked to SyscallConn: int(f.Fd()) is the canonical argument form for syscall.Flock, and routing it through a RawConn.Control callback would change how the descriptor is obtained purely to satisfy a static-analysis false positive. Verified with gosec v2.26.1, the version the workflow pins: devicepolicy now reports no findings, and none of the findings remaining elsewhere in the tree sits in a file this PR touches. --- internal/devicepolicy/statelock_unix.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/internal/devicepolicy/statelock_unix.go b/internal/devicepolicy/statelock_unix.go index 4f3b53a..3c444ba 100644 --- a/internal/devicepolicy/statelock_unix.go +++ b/internal/devicepolicy/statelock_unix.go @@ -18,6 +18,10 @@ const stateLockSupported = true // 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 @@ -32,6 +36,8 @@ func tryLockHandle(f *os.File) (bool, error) { // 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) } From 6ef5a55dbf416d65e480fae724a05a7184473025 Mon Sep 17 00:00:00 2001 From: Subham Ray Date: Mon, 27 Jul 2026 22:42:58 +0530 Subject: [PATCH 09/10] fix(devicepolicy): report a clear as a removal only when something was removed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit handleClearByMarker logged "cleared managed block at " on every clear, including the ones that removed nothing. Clear itself is deliberately unconditional there — marker ownership is intrinsic to the writer, so a lost or corrupt ownership record must never be the reason a token-bearing block survives an unassignment — but the log inherited that unconditionality and claimed a removal regardless. An unassigned device therefore announced a clear on every cycle forever, which in fleet logs reads as a device being remediated over and over when it has been clean for weeks. "Did anything change" cannot be inferred at the call site: the whole point of calling Clear unconditionally is that the caller does not know. Both writers already computed it and discarded it — NPMRCWriter has two no-op paths (absent file, no block and no prefixed lines) and settingsWriter has two (absent file, key not present) — so Clear now returns it. The call stays unconditional; only the description of what happened changed. - Writer.Clear becomes Clear() (changed bool, err error), documented so a future caller does not read the bool as permission to skip the call. - handleClearByMarker logs a removal or a nothing-to-remove, matching the distinction the settings lane already drew between "cleared N agent-owned key(s)" and "holds no agent-owned value; leaving it". - clearSingle no longer claims a removal when Clear turned out to be a no-op, which is reachable if the value disappears between the read and the clear. - rollbackWrite ignores the bool: a rollback to "absent" is complete whether or not there was anything to remove. Both lanes get the three-way contract pinned — absent file, present-but-nothing- of-ours, and a real removal — because only the last may be reported as a removal, and the middle case is the one that regressed silently. Verified the assertions bite by inverting the success return and watching the live-block subtest fail. --- internal/devicepolicy/npmrc.go | 21 +++--- internal/devicepolicy/npmrc_unix_test.go | 70 +++++++++++++++++-- internal/devicepolicy/reconcile.go | 20 ++++-- internal/devicepolicy/reconcile_test.go | 10 +-- internal/devicepolicy/settings_writer.go | 24 ++++--- internal/devicepolicy/settings_writer_test.go | 60 ++++++++++++++-- 6 files changed, 166 insertions(+), 39 deletions(-) diff --git a/internal/devicepolicy/npmrc.go b/internal/devicepolicy/npmrc.go index b2552ee..244e50c 100644 --- a/internal/devicepolicy/npmrc.go +++ b/internal/devicepolicy/npmrc.go @@ -574,29 +574,30 @@ func (w *NPMRCWriter) Write(value string) (string, error) { // 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. -func (w *NPMRCWriter) Clear() error { +// 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 err + return false, err } defer rt.close() cur, existed, mode, err := w.readCurrent(rt) if err != nil { - return err + 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 nil + return false, nil } next, err := w.clearContent(cur) if err != nil { - return err + return false, err } if bytes.Equal(next, cur) { // No managed block and no prefixed lines — a no-op that performs no write at @@ -604,7 +605,7 @@ func (w *NPMRCWriter) Clear() error { // 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 nil + return false, nil } snap := &pendingSnapshot{existed: true, data: cur, mode: mode, leaf: rt.rel} @@ -617,9 +618,9 @@ func (w *NPMRCWriter) Clear() error { // 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 w.afterFailedRollback(rt, snap, err, "clear commit verification") + return false, w.afterFailedRollback(rt, snap, err, "clear commit verification") } - return err + return false, err } snap.committed = out.committed w.pending = snap @@ -629,7 +630,7 @@ func (w *NPMRCWriter) Clear() error { // would defeat offboarding, so drop our own backups here. Rollback is // unaffected: RestoreSnapshot reverts from snap's in-memory bytes. w.purgeBackups(rt) - return nil + return true, nil } // RestoreSnapshot reverts the last successful Write/Clear. It re-resolves the diff --git a/internal/devicepolicy/npmrc_unix_test.go b/internal/devicepolicy/npmrc_unix_test.go index adfe67f..7d74b81 100644 --- a/internal/devicepolicy/npmrc_unix_test.go +++ b/internal/devicepolicy/npmrc_unix_test.go @@ -186,7 +186,7 @@ func TestClear_RemovesBlockKeepsFile(t *testing.T) { // edge 9 / 24 on disk if _, err := w.Write(stdBody); err != nil { t.Fatalf("Write: %v", err) } - if err := w.Clear(); err != nil { + if _, err := w.Clear(); err != nil { t.Fatalf("Clear: %v", err) } got := readFile(t, npmrcPath(home)) @@ -201,7 +201,7 @@ func TestClear_RemovesBlockKeepsFile(t *testing.T) { // edge 9 / 24 on disk func TestClear_AbsentFileIsNoOp(t *testing.T) { home := t.TempDir() w := newDiskWriter(t, home) - if err := w.Clear(); err != nil { + if _, err := w.Clear(); err != nil { t.Fatalf("Clear on absent file: %v", err) } if _, err := os.Stat(npmrcPath(home)); !os.IsNotExist(err) { @@ -209,6 +209,64 @@ func TestClear_AbsentFileIsNoOp(t *testing.T) { } } +// 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) @@ -480,7 +538,7 @@ func TestClear_RemovesDuplicateBlocks(t *testing.T) { t.Fatalf("seed: %v", err) } w := newDiskWriter(t, home) - if err := w.Clear(); err != nil { + if _, err := w.Clear(); err != nil { t.Fatalf("Clear: %v", err) } got := readFile(t, npmrcPath(home)) @@ -632,7 +690,7 @@ func TestClear_PurgesTokenBearingBackups(t *testing.T) { if backups := dmgBackups(t, home); len(backups) == 0 { t.Fatal("expected the writes to leave backups to purge") } - if err := w.Clear(); err != nil { + if _, err := w.Clear(); err != nil { t.Fatalf("Clear: %v", err) } if backups := dmgBackups(t, home); len(backups) != 0 { @@ -685,7 +743,7 @@ func TestClear_NoOpClearRetriesTheBackupPurge(t *testing.T) { t.Fatal(err) } w := newDiskWriter(t, home) - if err := w.Clear(); err != nil { + if _, err := w.Clear(); err != nil { t.Fatalf("Clear: %v", err) } if _, err := os.Stat(stale); !os.IsNotExist(err) { @@ -712,7 +770,7 @@ func TestClear_LoneCRIsAnErrorAndKeepsTheBlock(t *testing.T) { t.Fatal(err) } w := newDiskWriter(t, home) - err := w.Clear() + _, err := w.Clear() if !isTargetUnusable(err) { t.Fatalf("Clear must fail closed with ErrTargetUnusable, got %v", err) } diff --git a/internal/devicepolicy/reconcile.go b/internal/devicepolicy/reconcile.go index 11e1d84..327dd87 100644 --- a/internal/devicepolicy/reconcile.go +++ b/internal/devicepolicy/reconcile.go @@ -487,10 +487,13 @@ func (r *Reconciler) clearSingle(cat, tgt string, prev AppliedTargetState, hadPr 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()) @@ -554,10 +557,15 @@ func (r *Reconciler) dropClearedState(cat, tgt string, hadPrev bool) error { // 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 { - if err := r.Writer.Clear(); err != nil { + changed, err := r.Writer.Clear() + if err != nil { return fmt.Errorf("devicepolicy: clear %s: %w", r.Writer.Location(), err) } - r.logf("devicepolicy: cleared managed block at %s", r.Writer.Location()) + 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) } @@ -1028,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_test.go b/internal/devicepolicy/reconcile_test.go index bd5ce5a..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" } @@ -805,10 +806,11 @@ 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" } diff --git a/internal/devicepolicy/settings_writer.go b/internal/devicepolicy/settings_writer.go index 27baa01..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 @@ -323,24 +328,27 @@ 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 { +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) } - return w.store(v, bom) + if err := w.store(v, bom); err != nil { + return false, err + } + return true, nil } // store atomically replaces the settings file with the packed tree, preserving diff --git a/internal/devicepolicy/settings_writer_test.go b/internal/devicepolicy/settings_writer_test.go index 4d55887..db51001 100644 --- a/internal/devicepolicy/settings_writer_test.go +++ b/internal/devicepolicy/settings_writer_test.go @@ -266,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) @@ -283,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) { @@ -292,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 { @@ -312,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 { @@ -734,7 +734,7 @@ func TestSettingsClearPreservesBOM(t *testing.T) { t.Fatalf("Write: %v", err) } - if err := w.Clear(); err != nil { + if _, err := w.Clear(); err != nil { t.Fatalf("Clear on BOM file: %v", err) } if _, present, err := w.Read(); err != nil || present { @@ -866,3 +866,53 @@ func TestReconcileEnforcesAndClearsThroughABOM(t *testing.T) { 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) + } + }) +} From 5b8257359fd4392f04bca29c406928a7c720db14 Mon Sep 17 00:00:00 2001 From: Subham Ray Date: Mon, 27 Jul 2026 22:43:11 +0530 Subject: [PATCH 10/10] docs(devicepolicy): state the invariant the convergence short-circuit relies on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The AppliedTargetState comment described how a file carrying the retired written_value key decodes, and asserted that "the next enforce re-converges and re-records it". That is not true on the settings lane: a device whose target is already converged with an unchanged hash short-circuits before persisting anything, so there is no next write to repopulate the record. Left in place, the sentence invites someone to add a legacy decode believing it repairs itself, when the actual outcome would be a value-based clear that silently declines to remove keys the agent did write. The shape it describes exists on no device, and the baseline is the next release, so there is nothing to migrate and no reason to document a migration. Replaced with the property that actually makes the short-circuit safe: AppliedHash and WrittenSettings are persisted as one struct in one write, so a record whose hash matches the freshly-fetched hash necessarily carries the complete ownership map for that policy — a matching hash cannot coexist with partial ownership. Also names the one way to break the pairing, a hand-edited state file, and says plainly that it is outside the supported inputs and what it would cost. Comment only; no behavior change. --- internal/devicepolicy/cache.go | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/internal/devicepolicy/cache.go b/internal/devicepolicy/cache.go index 0beb86b..9f3e598 100644 --- a/internal/devicepolicy/cache.go +++ b/internal/devicepolicy/cache.go @@ -76,10 +76,15 @@ type AppliedCategoryState struct { // does not own. // // A zero-value entry means "the agent owns nothing on disk" for that -// category/target. A pre-collapse file carrying the retired written_value key -// decodes with an empty WrittenSettings — i.e. "owns nothing" — and the next -// enforce re-converges and re-records it; there are no production devices, so no -// migrator is owed. +// 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"` WrittenSettings map[string]string `json:"written_settings,omitempty"`