Repository navigation
Sweep every exported file on a failed export, and record the guards' ancestor blind spot (#1973) - #1974
Conversation
…ancestor blind spot (#1973) Two review findings deferred at #1968 merge time. A mid-export failure removed only the secret-bearing darling.json. Because each write deletes the previous export's copy before recreating it, a failure on server.crt or README.txt left a mix of new and stale files with no config beside them, none of it named in the error - which reads as "nothing was written" when something was. The catch now sweeps all three, best effort, and names each file as removed or as left behind with the reason. The darling.json line keeps its live-password framing; the cert and README are phrased as cleanup. Each removal is independent, so one that fails masks neither the original error nor the other removals. The existence probe uses File.GetAttributes rather than File.Exists, the idiom TryCheckExportTarget already uses, because Exists answers false for a dangling symlink and would leave a planted link unmentioned by the one sweep whose job is to account for what is on disk. The two directory-level guards - the lexical self-destruct compare and the leaf reparse-point check - do not see a junction on an ANCESTOR of the destination. Comment only, deliberately: the destructive case is already refused one layer down, because per-file TryCheckExportTarget runs through the OS's real path resolution and the service's own darling.json carries no export marker. What survives the gap is only "files land in a directory the junction-planter chose", which that attacker already controls. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| /* File.GetAttributes rather than File.Exists, and for the same reason TryCheckExportTarget uses | ||
| it: Exists answers FALSE for a dangling symlink, which would leave a planted link unmentioned | ||
| by the one sweep whose whole job is to account for what is on disk. */ | ||
| _ = File.GetAttributes(path); |
There was a problem hiding this comment.
Minor/non-blocking: there's a small TOCTOU gap here — File.GetAttributes (existence probe) and the later File.Delete are two separate filesystem calls, so a file that vanishes between them (e.g. another process races to delete it first) would hit the generic catch (Exception cleanupFailure) in the delete block rather than being treated as "already gone." Worst case is a slightly misleading "left behind" warning for a file that's actually not there — cosmetic only, given this is a single-process CLI cleanup path and File.Delete is itself a no-op-safe call for already-missing files in the common case. Not asking for a change, just flagging since the surrounding code is otherwise very precise about exactly what each exception type means.
ReviewScope: entirely within Item 1 — sweep all three files on a failed exportTraced the full path:
Left one non-blocking inline note on a minor TOCTOU gap between the Item 2 — ancestor-junction blind spot, comment-onlyConfirmed this is genuinely comment-only (no behavior change) on both the lexical Style/conventionsConsistent with the file's existing very-verbose, reasoning-first comment voice and the No correctness, security, or performance issues found beyond the one cosmetic note above. |
CI: all four checks green,
|
Closes #1973. Both items are the two review findings dispositioned on #1968 but deferred at merge time; all changes are in
Darling/PerformanceMonitor.Darling.Service/DarlingCliCommands.cs.Item 1 - a failed export sweeps all three files and names what survived
The catch around the three
WriteExportFileAsynccalls deleted onlydarling.json. Since each write deletes the previous export's copy before recreating it, a failure onserver.crtorREADME.txtleft a mix of new and stale files with no config beside them, and the error named none of it - which reads to an operator as "nothing was written" when something was.The catch now sweeps all three through a new
TryRemovePartialExporthelper. Every file still on disk is named, either as removed or as left behind with the OS reason. Thedarling.jsonline keeps its live-password framing verbatim; the cert and README are phrased as cleanup, because neither carries a secret and neither is usable without the config. Each removal is independent, so a delete that itself fails masks neither the original error nor the other two removals.One detail worth calling out: the existence probe is
File.GetAttributesin a try, notFile.Exists- the idiomTryCheckExportTargetalready uses ten lines away.File.Existsanswers false for a dangling symlink, which would leave a planted link unmentioned by the one sweep whose entire job is to account for what is on disk.The error shape, verbatim from a live run of the failure path
Captured from the rebuilt binary (
ExportViewerConfigAsyncwith the cert write forced to fail), stderr as written:(Paths elided to
C:\...for width; the run used a temp directory. The em dashes in the "held a live password" and "not a secret" clauses are real em dashes in the source, matching this file's existing operator-facing voice.README.txtis absent from the output because the run never reached its write - there is nothing on disk to name, which is the correct silence.)Item 2 - the directory guards' ancestor blind spot, recorded
Comment only, on both the lexical self-destruct compare and the leaf reparse-point check.
Path.GetFullPathnormalizes text without resolving links, andDirectoryInfo.Attributesdescribes only the last path component, so a junction on an ancestor of the destination slips past both.Deliberately not chased with a chain-resolving walk, and the comment records why: the destructive case the reviewer named - the export resolving back into the service's config directory and destroying the service's own
darling.json- is already refused one layer down.TryCheckExportTargetruns per file through the OS's real path resolution (File.GetAttributesandFile.ReadAllTextfollow the whole junction chain to the actual file), and the service's owndarling.jsoncarries no export marker, so such a run stops with "not written by --export-viewer-config" before anything is deleted. What survives the gap is only "the files land in a directory the junction-planter chose" - and planting a junction in the ancestor chain of the operator's destination already means controlling that destination's fate.Test
ExportViewerConfigAsync_FailsPartWayThrough_SweepsAllThreeAndNamesWhatSurvivedinDarling/Darling.Tests/DarlingExportViewerConfigTests.cs.The failure is forced where it actually bites: after
darling.jsonhas been written and hardened, on theserver.crtwrite. A pre-existing (marker-carrying) cert is held open withFileShare.Read- readable, so the pre-flight identity check passes and the run reaches the writes at all, but not deletable, soWriteExportFileAsync's delete-then-CreateNewfails with the sameIOExceptiona live antivirus or an open editor produces. No stub, no seam injection; this is the production write path.Asserts exit 1, that
darling.jsonis gone and named as the secret it was, that the survivingserver.crtis named as left behind (and still holds its stale content), thatREADME.txtwas never reached, and - the claim the whole sweep rests on - that every file remaining in the folder appears by name in stderr, enumerated fromDirectory.GetFiles. Also that the password reaches neither stream on the failure path.Watched red
With the source change reverted and the test project rebuilt (not
--no-buildagainst a stale DLL), the new test fails on exactly the old behavior:Line 845 is
Assert.Contains($"{exportedCert} was left behind", stderr, ...)- the old code removeddarling.jsonand left the cert behind unnamed. Source change restored, retested green.Gates
Build succeeded. 0 Warning(s) 0 Error(s)(checked the warnings line, not just the exit code).DarlingExportViewerConfigTests: 39 passed, 1 skipped, 0 failed. The skip is the pre-existing symlink test, which needs Developer Mode or elevation.Darling.Tests: 3898 passed, 0 failed, 223 skipped (the live-Postgres gated classes).[Unreleased]>Fixedwith the bottom link-ref.Windows-only conventions followed:
Assert.SkipUnless(OperatingSystem.IsWindows(), "DPAPI requires Windows."), temp subdirectory withfinallycleanup,StringWritercapture, exit-code asserts.🤖 Generated with Claude Code