Skip to content

fix(config): keep restrictive config permissions during atomic geo-filter save (#340) - #357

Merged
dborup merged 2 commits into
masterfrom
codex/issue-340-config-perms
Oct 8, 2026
Merged

dborup merged 2 commits into
masterfrom
codex/issue-340-config-perms

Conversation

@dborup

@dborup dborup commented Oct 7, 2026

Copy link
Copy Markdown
Owner

Relates to #340

Problem

SaveGeoFilter in cmd/server/config.go wrote config.json.tmp with
os.WriteFile(..., 0644) and renamed it over config.json. Three consequences:

  1. A config deliberately restricted to 0600 came back 0644 after any
    geo-filter edit in the UI. config.json can hold broker credentials.
  2. The resulting mode also depended on the process umask, so a deliberately
    group-readable 0644 config could come back 0600 under a tight umask and
    break whatever else reads it.
  3. The temp path was a fixed, predictable name. Anything able to pre-create
    config.json.tmp in the config directory — as a symlink or a hard link —
    got the full config, credentials included, written through to a path of its
    choosing. The old cleanup also removed that path on failure, whether or not
    this function had created it.

Plan

  1. Tests first, against the current behaviour, so each acceptance point in the
    issue has a failing assertion before any production line moves.
  2. Replace the write with a permission-preserving atomic replacement.
  3. Mutate the new code one property at a time and confirm the suite catches
    each mutation.

Approach

Read mode and contents from one descriptor, so the bits written back belong
to the bytes that were parsed, and fail if that metadata is unavailable rather
than falling back to a permissive default.

Create the replacement under a unique name in the destination directory via
os.CreateTemp (which opens O_EXCL), then rename. A pre-existing
config.json.tmp is therefore never opened, never written through, and never
removed — cleanup is scoped to the file this call created.

Apply the preserved mode with an explicit chmod on our own descriptor. That is
what makes the result umask-independent. The issue is right that simply copying
the upstream WriteFile mode argument fixes neither half: the mode argument is
ignored for a file that already exists, and masked by the umask for one that
does not.

Carry uid/gid over best-effort. This is a no-op in the normal case, where
the saving process already owns the file — the only arrangement in which a
non-root server (#339) can write at all. It matters for a privileged process
rewriting a bind-mounted config owned by a host user, which would otherwise
silently become root-owned. The chown failure is deliberately not fatal, so
an unprivileged server can still save; the mutant below shows the suite would
catch it if that changed.

Two helper files (config_owner_unix.go / _windows.go) follow the existing
cmd/ingestor/stats_file_owner_*.go pattern rather than inventing a new one.

Three package-level test seams (configCreateTemp, configRename,
configStatFile) make the create/write/rename/stat failure branches reachable;
production always holds the os functions. Test seams are an established idiom
in this package (nodeRegionQueryHook, lruClock, distanceBuildHook, …).

Tests

New: cmd/server/config_geofilter_perms_340_test.go (portable) and
config_geofilter_perms_340_unix_test.go (umask, owner IDs).

Acceptance point Test
Preserve existing mode TestSaveGeoFilterPreservesMode — 0600 / 0640 / 0644 / 0664, on both a set and a removal save
…independent of umask TestSaveGeoFilterModeIsUmaskIndependent — 0644 under umask 0077, 0600 under umask 0000 and 0022
Metadata failure is not a permissive default TestSaveGeoFilterStatFailureIsFatal, TestSaveGeoFilterFailsSafelyOnUnreadableConfig, TestSaveGeoFilterDanglingSymlinkConfigFails
Unique temp in the destination dir, atomic rename TestSaveGeoFilterIgnoresPreexistingTempPath (stale config.json.tmp is a directory), plus a stray-temp sweep after every success
No following a pre-existing temp symlink / hard link TestSaveGeoFilterDoesNotFollowTempSymlink, TestSaveGeoFilterDoesNotFollowTempHardLink
Cleanup scoped to our own temp TestSaveGeoFilterWriteFailureCleansUpOwnTemp, TestSaveGeoFilterRenameFailureCleansUpOwnTemp, TestSaveGeoFilterCreateTempFailure
Unrelated fields preserved TestSaveGeoFilterPreservesUnrelatedFields — incl. _comment, which the Config struct does not model
Supported config locations TestSaveGeoFilterConfigLocations — top-level, data/, and precedence
Owner / group, incl. non-root container TestSaveGeoFilterPreservesOwner

The existing TestSaveGeoFilter in routes_test.go is unchanged and still
passes.

Mutants

Seven, run locally against the new suite; all seven were caught.

Mutant Killed by
Chmod(mode) → Chmod(0o644) PreservesMode (3 modes), ModeIsUmaskIndependent, ConfigLocations (3), PreservesUnrelatedFields, IgnoresPreexistingTempPath, DoesNotFollowTempSymlink
Stat failure falls back to mode 0644 StatFailureIsFatal
Fixed configPath + ".tmp" via OpenFile instead of CreateTemp DoesNotFollowTempSymlink, DoesNotFollowTempHardLink, IgnoresPreexistingTempPath, both cleanup tests
Cleanup removes configPath + ".tmp" instead of our own temp WriteFailureCleansUpOwnTemp, RenameFailureCleansUpOwnTemp
delete(raw, "_comment") before marshal PreservesUnrelatedFields
Drop the data/config.json lookup entry ConfigLocations/data_subdirectory
chown to a foreign uid, failure fatal every success test, incl. PreservesOwner

Verification

  • go test ./... green in all 18 modules (cmd/server 97s, cmd/ingestor 110s); the three internal/ modules without test files are unchanged.
  • go test . -run TestSaveGeoFilter -race green.
  • go vet ./... clean in cmd/server.
  • sh test-all.sh — 232/232 files, incl. test-geo-filter.js, test-geofilter-draft.js, test-nodes-geo-scope-filter.js.
  • node test-frontend-helpers.js — 709 passed.
  • node test-e2e-playwright.js against a local Go server on e2e-fixture.db, prepared as in CI (freshen, inline seed, migrate, three seed files) — 132/135, 3 skipped, 0 failed.
  • scripts/check-xss-sinks.sh --diff origin/master — clean (no public/** changes).
  • Fork guards unchanged: 9 in deploy.yml, 1 in release-fast-path.yml.
  • No new map[string]interface{} outside tests; cmd/server adds no DB write.
  • No real credentials anywhere: the test fixture uses redacted-test-user / redacted-test-secret.

Out of scope

The non-root container migration itself (#339). This PR only makes
SaveGeoFilter behave correctly under it and pins that behaviour with a test.

🤖 Generated with Claude Code

dborup added 2 commits October 7, 2026 19:51
…filter saves

SaveGeoFilter wrote config.json.tmp with os.WriteFile(..., 0644) and renamed
it over config.json. A config deliberately restricted to 0600 therefore came
back 0644 after any geo-filter edit in the UI, and config.json can hold broker
credentials. The fixed temp path was also a write target an attacker with
directory access could pre-create as a symlink or hard link.

The replacement now:
  - reads mode and contents from the same descriptor, and fails rather than
    guessing a default when that metadata is unavailable;
  - creates a uniquely named temp file in the destination directory
    (os.CreateTemp, O_EXCL), so a pre-existing config.json.tmp is never
    opened, written through or removed;
  - applies the preserved mode with an explicit chmod on its own descriptor,
    which makes the result umask-independent — copying upstream's WriteFile
    mode argument would fix neither case;
  - carries uid/gid over best-effort, a no-op in the normal case and
    deliberately non-fatal so an unprivileged server (#339) can still save.

Relates to #340.
The create-temp seam hands back a closed descriptor, so the first operation
on it fails with os.ErrClosed. Asserting that specific error keeps the test
from passing on some unrelated failure, and the comment now says "before the
rename" rather than "mid-write": the closed fd trips the chmod first.

Relates to #340.
@dborup

dborup commented Oct 7, 2026 •

Copy link
Copy Markdown
Owner Author

Rapport — CS-Minimax PR#357 #340 — head 9906c04

Status: Done — issue implemented, 9 tests covering all 8 acceptance points, 7/7 mutants killed, CI green on every job that ran (no reruns needed).

Evidence legend

[T] test run locally · [A] code/artifact inspected · [K] CI / known issue

Requirements

# Acceptance point Test (red before → green after) Mutant Evidence
1 Preserve the existing mode TestSaveGeoFilterPreservesMode — 0600 / 0640 / 0644 / 0664, on a set and a removal save. Red on the base: mode after save = 0644, want 0600 for every non-0644 case. Chmod(mode) → Chmod(0o644) → killed by PreservesMode (3 subtests), ModeIsUmaskIndependent, ConfigLocations (3), PreservesUnrelatedFields, IgnoresPreexistingTempPath, DoesNotFollowTempSymlink [T]
1b …and independently of the process umask TestSaveGeoFilterModeIsUmaskIndependent — 0644 under umask 0077, 0600 under umask 0000 and 0022. Red on the base: 0600, want 0644 and 0644, want 0600. same as #1 (also killed by the Chmod(0o644) mutant) [T]
2 Fail safely when metadata cannot be read TestSaveGeoFilterStatFailureIsFatal (stat seam returns an error → save must wrap it, config and mode untouched, no stray temp). Guards: TestSaveGeoFilterFailsSafelyOnUnreadableConfig (mode 0000), TestSaveGeoFilterDanglingSymlinkConfigFails. stat failure falls back to mode = 0o644 and continues → killed by StatFailureIsFatal [T]
3 Unique, secure temp file in the same directory + atomic rename TestSaveGeoFilterIgnoresPreexistingTempPath — a stale config.json.tmp that is a directory must not block the save. Red on the base: write config: … config.json.tmp: is a directory. Every success test also sweeps the directory for stray temp files, and ConfigLocations/data_subdirectory asserts the temp lands next to the config, not in the parent. fixed configPath + ".tmp" via os.OpenFile instead of os.CreateTemp → killed by DoesNotFollowTempSymlink, DoesNotFollowTempHardLink, IgnoresPreexistingTempPath and both cleanup tests [T]
4 Do not follow a pre-existing temp symlink or hard link TestSaveGeoFilterDoesNotFollowTempSymlink and …TempHardLink. Red on the base: the full config — credentials included — was written through the planted link, and the symlink itself was replaced. same as #3 [T]
5 Clean up only our own temp file TestSaveGeoFilterWriteFailureCleansUpOwnTemp (create-temp seam hands back a closed fd, so the first operation on it fails with os.ErrClosed after the temp file already exists on disk) and TestSaveGeoFilterRenameFailureCleansUpOwnTemp (rename seam). Both assert: our temp is gone, the pre-existing config.json.tmp is byte-identical, the config and its 0600 are untouched. Plus TestSaveGeoFilterCreateTempFailure (read-only config dir). Red on the base: no error was returned at all. cleanup removes configPath + ".tmp" instead of our own temp → killed by both cleanup tests [T]
6 Preserve unrelated fields TestSaveGeoFilterPreservesUnrelatedFields — deep-equal on every key, incl. _comment, which the Config struct does not model; then a removal save must leave exactly the same keys minus geo_filter; mode asserted in both halves. Red on the base via the mode assertion. delete(raw, "_comment") before marshal → killed by PreservesUnrelatedFields [T]
7 Supported config lookup locations TestSaveGeoFilterConfigLocations — top-level config.json, data/config.json, and top-level precedence (the nested file must stay byte-identical). Mode asserted in each. Red on the base: mode = 0644, want 0600 in all three. drop the data/config.json lookup entry → killed by ConfigLocations/data_subdirectory [T]
8 Evaluate owner and group, incl. the future non-root container (#339) TestSaveGeoFilterPreservesOwner — uid/gid unchanged across a save. This is a guard, not a red-before test: ownership preservation is unfalsifiable from an unprivileged test process, which cannot chown to a foreign uid at all. The falsifiable property is that a chown attempt must never break a save, which the mutant covers. preserveConfigOwner chowns to a foreign uid and treats the failure as fatal → killed by every success test, incl. PreservesOwner. This is exactly the #339 regression: an unprivileged server would stop being able to save. [T] [A]

7 mutants run, 7 killed, 0 survived. The mutant runner restored the snapshot between every mutation and re-confirmed a green baseline at the start and the end of the run.

Approach note

The issue's warning about upstream's WriteFile mode argument is correct and the fix does not use it: the mode argument is ignored for a file that already exists, and masked by the umask for one that does not. Instead the mode is applied with an explicit chmod on our own descriptor, and read from the same descriptor the contents are read from. [A]

CI per job

All six jobs of the pipeline run on head 9906c04e. Run conclusion: success.

Job Result Duration
Go Build & Test success 24m 42s
Playwright E2E Tests success 27m 34s
Release Artifacts skipped (tag-gated) —
Build & Publish Docker Image success 49s
Deploy Staging skipped (fork guard) —
Publish Badges & Summary skipped (fork guard) —

No job failed, so no rerun was needed and the known-flaky #271 did not come up. [K]

One note for the record: the first run on head 33b2e140 was cancelled, not failed — the ci-357 concurrency group superseded it when the second commit landed ("Canceling since a higher priority waiting request for ci-357 exists"). The reported run is the one for the current head. [K]

Local verification

  • go test ./... green in all 18 Go modules — cmd/server 97s, cmd/ingestor 110s; internal/geofilter, internal/mbcapqueue, internal/perfio have no test files and are untouched. [T]
  • go test . -run TestSaveGeoFilter -race green. [T]
  • go vet ./... clean in cmd/server. [T]
  • sh test-all.sh — 232/232 files, 0 failed, incl. test-geo-filter.js, test-geofilter-draft.js, test-nodes-geo-scope-filter.js. [T]
  • node test-frontend-helpers.js — 709 passed, 0 failed. [T]
  • node test-e2e-playwright.js against a local Go server on e2e-fixture.db, prepared exactly as in CI (freshen, the inline grouped-packet/GRP_DATA seed, migrate, then the three seed files) — 132/135, 3 skipped, 0 failed. The first run was short one seed step and failed on the #1791 Group Data filter; with the full prep it is green. [T]
  • scripts/check-xss-sinks.sh --diff origin/master — clean, no public/** changes to scan. [T]
  • Fork guards unchanged: 9 github.repository == … clauses in deploy.yml, 1 in release-fast-path.yml. [A]
  • No new map[string]interface{} outside tests; the existing one in SaveGeoFilter is unchanged. cmd/server adds no DB write, so the read/write separation invariant holds. [A]
  • No hardcoded colors — the change is Go only, no frontend files touched. [A]
  • No real credentials: the test fixture uses redacted-test-user / redacted-test-secret. [A]

Remainders

  • The non-root container migration itself (security(docker): run Go services without root with safe volume-permission migration #339) is out of scope. This PR only makes SaveGeoFilter behave correctly under it and pins that with a test.
  • Ownership preservation when running privileged over a host-owned bind mount is best-effort and intentionally non-fatal. It cannot be asserted positively from an unprivileged test; it is covered by the mutant and by the doc comment instead.
  • Three test seams (configCreateTemp, configRename, configStatFile) were added to make the create / write / rename / stat failure branches reachable. Production always holds the os functions; test seams are an existing idiom in this package.
  • tmpFile.Sync() was added before the rename so the replacement's bytes are durable before it becomes visible. Not required by the issue, included because the write is advertised as atomic.

@dborup-agent

Copy link
Copy Markdown
Collaborator

Review — CS-pve-agent1 PR#357 — head 9906c04

Dom: APPROVE med nits

Independent, read-only review. Evidence: [T] = I ran it myself, [A] = I read the code or artifact, [K] = CI or a known issue.

Trees used: PR head 9906c04e, base origin/master = 2dc04642, and the merged tree from git merge-tree --write-tree origin/master 9906c04e… = 638d8694 (clean, 5 files, Go only). The head was 9906c04e on git ls-remote before and after the review.

Findings

# Severity Finding Evidence
1 Minor (test gap) No test pins the "temp file is created in the destination directory" half of acceptance point 3. Two mutants survive the full TestSaveGeoFilter* suite: configCreateTemp("", …), which uses os.TempDir(), and configCreateTemp(filepath.Dir(filepath.Dir(configPath)), …), which uses the parent directory. In tests, both directories sit on the same filesystem as t.TempDir(), so the rename succeeds and no stray file is left behind. assertNoStrayTemp only sees leftovers. The PR and report say ConfigLocations/data_subdirectory "asserts the temp lands next to the config"; it does not. The production code is correct (filepath.Dir(configPath)). Suggested fix: wrap the existing configCreateTemp seam, record the dir argument or f.Name(), and assert filepath.Dir(name) == filepath.Dir(configPath) for both lookup locations. [T] [A]
2 Observation (pre-existing, not a regression) The official Go container (docker/entrypoint-go.sh) symlinks /app/config.json → /app/data/config.json and starts the server with -config-dir /app. SaveGeoFilter finds the symlink, reads the target, and renames over the symlink. The result is a regular file in /app, and the persistent data/config.json never gets the edit. The next container start re-runs ln -sf, so the edit is lost. A probe test confirmed this on base and head alike. This PR improves it: the replacement now keeps the target's 0600 (base: 0644). That is good, but the layout has no test, and the persistence problem belongs in a separate follow-up or in #339. [T] [A]
3 Nit preserveConfigOwner ignores a Chown failure silently. That is the right call for availability. But if a root-set group (for example a 0640 config with a dedicated reader group) cannot be preserved by an unprivileged server, the group changes without a trace, and the group's readers lose access. One log.Printf on chown failure would make that diagnosable without changing behaviour. [A]
4 Nit No fsync of the parent directory after the rename, so the rename itself is not durable across a crash. This is optional and outside the scope of #340; the added tmpFile.Sync() already covers the data. [A]

There are no blocking findings. The security-relevant behaviour is correct: mode is preserved independently of umask; it fails closed on stat errors; temp files are O_EXCL and uniquely named; cleanup only removes our own file.

Acceptance points

  1. 0600 stays 0600, 0644 stays 0644. Yes. TestSaveGeoFilterPreservesMode covers 0600, 0640, 0644 and 0664, on both a set save and a removal save. TestSaveGeoFilterModeIsUmaskIndependent adds umask 0077, 0000 and 0022. On base, every non-0644 case is red (0644, want 0600), and all three umask cases are red. The 0644 subtest is green on base, which is expected. [T]
  2. Fails safely when metadata is unreadable, with no permissive fallback. Yes. Mode comes from fstat on the same descriptor that is read. A stat error returns stat config: …; the config, its mode and the directory stay untouched. TestSaveGeoFilterStatFailureIsFatal uses the seam. My mutant M4 used a restrictive 0600 fallback instead of an error, and it was still killed, so the test pins "no fallback at all", not just "no 0644". The 0000-mode and dangling-symlink tests are guards (green on base too). [T] [A]
  3. Unique, secure temp file in the same directory, with atomic rename. The code is correct: os.CreateTemp(filepath.Dir(configPath), "config.json.*.tmp") opens with O_EXCL at 0600, then Chmod, Write, Sync, Close and Rename. Uniqueness and the O_EXCL behaviour are tested: IgnoresPreexistingTempPath is red on base with is a directory. The "same directory" half has no test; see finding 1. [T] [A]
  4. Does not follow a pre-existing .tmp symlink or hard link. Yes. DoesNotFollowTempSymlink and DoesNotFollowTempHardLink are both red on base, where the full config, credential fixture included, was written through. Both are green on head. [T]
  5. Cleans up only its own temp file. Yes. The deferred cleanup removes tmpName only when the rename did not happen. WriteFailureCleansUpOwnTemp and RenameFailureCleansUpOwnTemp assert that the stale config.json.tmp is byte-identical afterwards and that no stray temp file is left. Both are red on base (error = <nil>). [T]
  6. Unrelated config fields kept. Yes. PreservesUnrelatedFields does a deep-equal on every key, including the un-modelled _comment, and checks the removal save. It is red on base only through the mode assertion; field preservation itself was already correct on base. [T]

Red-before method: base 2dc04642 plus the PR's two test files, the two config_owner_*.go helpers, and a review-only shim that declares the three seam variables, so the tests compile. Base production code does not use the seams. Result on base: 10 TestSaveGeoFilter* tests fail. Passing on base: the 0644 subtest and the guard tests (FailsSafelyOnUnreadableConfig, DanglingSymlinkConfigFails, CreateTempFailure, PreservesOwner). All of them pass on head. [T]

Owner/group and the non-root container (#339)

  • The best-effort uid/gid carry-over is reasonable. It is a no-op when the process already owns the file. When running as root, it keeps a host-owned bind-mounted file from becoming root-owned. When unprivileged, chown failure is non-fatal. TestSaveGeoFilterPreservesOwner is a guard only. The author is right that an unprivileged test cannot falsify foreign-uid preservation. [T] [A]
  • The rationale is documented in the doc comments of SaveGeoFilter and preserveConfigOwner, and in the PR body. Nothing is in docs/, which is acceptable for this scope. [A]
  • What the evaluation leaves out, relevant to security(docker): run Go services without root with safe volume-permission migration #339: the save needs write permission on the config directory, not only on the file, because it creates a temp file and renames. In the Go container that directory is /app, which holds the symlink (finding 2). security(docker): run Go services without root with safe volume-permission migration #339 aims to "avoid broad writable application directories", and under that the save will fail with create temp config: …. That failure is clean: the config is untouched (TestSaveGeoFilterCreateTempFailure). security(docker): run Go services without root with safe volume-permission migration #339 still needs to settle the symlink layout and the directory-write requirement. That is not a blocker for this PR. [A]

Supported config locations

Both lookup paths are covered: <dir>/config.json, <dir>/data/config.json, and top-level precedence. They match LoadConfig. Not covered: the symlink layout from the Go container, and single-file bind mounts (docs/deployment.md mounts config.json:ro). In the container, the :ro mount sits behind the /app/config.json symlink, so the save "succeeds" in the container layer; see finding 2. Both behave exactly as on base, apart from the now-correct mode. [T] [A]

Invariants

  • No real credentials: the fixtures use redacted-test-user / redacted-test-secret. [A]
  • No new map[string]interface{} in non-test code (0 added lines). The tests use map[string]any for JSON comparison, which is allowed for test fixtures. [A]
  • cmd/server stays read-only toward the DB: no SQL or DB writes added. The only write is the existing config.json save. [A]
  • No behaviour change beyond the purpose: callers (routes.go, under saveMu) are unchanged; the error strings are new but are only logged. [A]
  • No frontend changes, so no hardcoded colors. scripts/check-xss-sinks.sh --diff origin/master reports no public/** changes to scan (exit 0). [T]
  • Fork guards: deploy.yml 9, release-fast-path.yml 1, unchanged. [A]
  • No closing keywords in the PR body or commits ("Relates to security(config): preserve restrictive permissions during atomic geo-filter saves #340"). [A]
  • Both commits (33b2e140, 9906c04e) have author dborup <kontakt@meshview.dk>. [A]
  • The package-level seams and syscall.Umask are process-global. That is safe because cmd/server has no t.Parallel (I grepped), and the seams are restored with t.Cleanup. [A]

Tests and mutants

On the merged tree 638d8694:

Run Result
cmd/server: go vet ./... clean [T]
cmd/server: go test ./... ok (816 s, -timeout 20m as in CI, run alone). A first run in parallel with other load hit the default 10 min timeout, with one load-induced 503 in TestHandleAnalyticsSubpathDetailWithStore; that test passes 3/3 in isolation and in the full sequential run [T]
cmd/server: go test -race -run TestSaveGeoFilter . ok [T]
cmd/ingestor: go test ./... (untouched, as a sanity check) not completed: it hit the 20 min timeout on this 4-core VM with no failing test (it was still in TestInsertTransmission_RouteMaskIsOrderIndependent). cmd/ingestor and internal/ are byte-identical to origin/master in the merged tree (empty git diff), so this says nothing about the PR. CI's Go job ran it green [T]
GOOS=windows go build . in cmd/server (exercises config_owner_windows.go) ok [T]
sh test-all.sh 233/233 files passed [T]
node test-frontend-helpers.js 709 passed, 0 failed [T]
test-e2e-playwright.js 132/135 passed, 3 skipped, 0 failed [T]
test-issue-332-no-carto-tiles-e2e.js (includes the customizer geo-filter preview) 15 passed, 0 failed [T]
test-issue-1329-map-controls-accordion-e2e.js 5/5 [T]
test-issue-199-inactive-observer-e2e.js 3 passed [T]
test-issue-2073-recent-adverts-e2e.js 10/10 [T]

The E2E runs used a local Go server on e2e-fixture.db, prepared as in CI: freshen, the inline Kpa-clawbot#1486/Kpa-clawbot#1791 seed SQL extracted from deploy.yml, corescope-migrate, then the 2073, 199 and 245 seeds. The server was stopped by its pid, looked up from the listening port. The first two runs of test-e2e-playwright.js failed on "Version info lives on Perf dashboard". The cause was my environment: a git archive tree has no .git, so /api/health reported commit: "unknown" and the Perf version card is correctly not rendered. CI runs from a checkout. With a .git-commit file present, the suite is green. This is unrelated to the PR, which changes no frontend or health code.

Mutants, against TestSaveGeoFilter* on the merged tree, with the baseline green before and after:

Mutant Result
M1: fixed mode, mode := os.FileMode(0o644) killed: PreservesMode, IgnoresPreexistingTempPath, DoesNotFollowTempSymlink, PreservesUnrelatedFields, ConfigLocations, ModeIsUmaskIndependent
M2: follow the symlink, fixed configPath+".tmp" via os.OpenFile(O_CREATE|O_TRUNC) killed: DoesNotFollowTempSymlink, DoesNotFollowTempHardLink, IgnoresPreexistingTempPath, both cleanup tests
M3a: temp outside the directory, CreateTemp("", …) survived (finding 1)
M3b: temp outside the directory, in the config's parent directory survived (finding 1)
M4: stat failure falls back to a restrictive 0600 killed: StatFailureIsFatal
M5: no os.Remove(tmpName) in cleanup killed: Write/RenameFailureCleansUpOwnTemp
M6: widened mode, Chmod(mode|0o044) killed: PreservesMode, ModeIsUmaskIndependent, ConfigLocations, and 3 more

Probe, not committed: the Go container symlink layout (config.json → data/config.json, 0600). Base gives a regular file at 0644 and the target is not updated. Head gives a regular file at 0600 and the target is not updated (finding 2). [T]

CI

Run on 9906c04e (pull_request), conclusion success: Go Build & Test ✅, Playwright E2E ✅, Docker build ✅; Release Artifacts, Deploy Staging and Badges were skipped (tag and fork guards). Neither #256 nor #267 came up. [K]

Not verified

  • Owner preservation under a privileged process with a foreign uid. I ran as an unprivileged user (uid ≠ 0), so the 0000-mode and read-only-directory guard tests actually ran rather than being skipped, but foreign-uid chown was not exercised.
  • Real container or bind-mount behaviour (single-file mount EBUSY, :ro mount). This was reasoned from entrypoint-go.sh and the docs, and checked with a filesystem probe only. No staging or prod access.
  • The full CI E2E list beyond the suites listed above, and frontend coverage collection.
  • A completed local cmd/ingestor run (see the table; the module is unchanged by this PR).

@dborup
dborup marked this pull request as ready for review October 8, 2026 04:34
@dborup
dborup merged commit ff9a501 into master Oct 8, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants