Skip to content

Commit 5f8e9ef

Browse files
committed
Owen facade: verify the CURRENT cache's actual content, not just its existence
Round-4 review finding: a hit at the content-addressed ~/.owen/core/<version>/<fingerprint>/ownlang path was trusted on Directory.Exists alone, and the two concurrent-publisher race checks around the atomic move made the same assumption. A path name is only a claim, not proof -- a destination already sitting at the "right" fingerprint could be modified, corrupted, or hand-assembled after publication and would then be served as valid. Every current-path hit now recomputes the fingerprint over what is actually on disk and only trusts an exact match, the same standard the legacy-cache fallback already held itself to. A mismatch quarantines the invalid destination (atomic rename to a .invalid-<guid> sibling, then best-effort recursive delete -- no in-place delete a concurrent reader could observe mid-way) and falls through to the same temp-directory + atomic-move rebuild a fresh unpack uses. Added the round-4 regression to ci.yml: create the current cache, tamper ownir.py and add a stale_module.py directly under the fingerprint-named path, and assert the second run rejects it, rebuilds clean, and the stale file is gone from whatever cache directory actually got used. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SSXTDuh1ZHdQc4QqmYwshw
1 parent f555f6c commit 5f8e9ef

3 files changed

Lines changed: 131 additions & 19 deletions

File tree

.github/workflows/ci.yml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2292,5 +2292,29 @@ jobs:
22922292
echo "$out"
22932293
[ "$rc" -eq 1 ] || { echo "FAIL: expected exit 1 for uppercase .CS extension, got $rc"; exit 1; }
22942294
echo "$out" | grep -q "OWN001" || { echo "FAIL: expected OWN001"; exit 1; }
2295+
- name: A tampered CURRENT (fingerprint-named) cache is rejected and rebuilt
2296+
# Review, PR #246 round 4 -- the earlier fixes verified a LEGACY cache's
2297+
# actual content, but a hit at the current ~/.owen/core/<version>/<fingerprint>/
2298+
# path itself was still trusted on existence alone. Reproduction: run once
2299+
# to create it, then tamper the file content directly under that exact
2300+
# fingerprint-named path (not the legacy location) and add a stale extra
2301+
# file, then confirm the second run rejects it, rebuilds cleanly, and the
2302+
# stale file is gone from whatever cache directory actually got used.
2303+
run: |
2304+
rm -rf "$HOME/.owen" "$HOME/.ownsharp"
2305+
owen check "$RUNNER_TEMP/owen-sample" --fail-on-finding > /dev/null 2>&1 || true
2306+
cache_dir=$(find "$HOME/.owen/core" -mindepth 2 -maxdepth 2 -type d)
2307+
[ -n "$cache_dir" ] || { echo "FAIL: first run did not create a current-cache directory"; exit 1; }
2308+
echo "# tampered" >> "$cache_dir/ownlang/ownir.py"
2309+
echo "# stale leftover module" > "$cache_dir/ownlang/stale_module.py"
2310+
driver=$(owen check "$RUNNER_TEMP/owen-sample" --format sarif | python -c "import json,sys; print(json.load(sys.stdin)['runs'][0]['tool']['driver']['name'])")
2311+
[ "$driver" = "Owen" ] || { echo "FAIL: driver name '$driver' after rebuild"; exit 1; }
2312+
if find "$HOME/.owen" -name "stale_module.py" | grep -q .; then
2313+
echo "FAIL: the stale extra file survived under a used cache directory"; exit 1
2314+
fi
2315+
if grep -q "# tampered" "$cache_dir/ownlang/ownir.py" 2>/dev/null; then
2316+
echo "FAIL: the tampered file content is still being served from the original path"; exit 1
2317+
fi
2318+
echo "OK: tampered current-cache destination rejected and rebuilt clean"
22952319
- name: Clean up cache state left by the edge-case tests above
22962320
run: rm -rf "$HOME/.owen" "$HOME/.ownsharp"

docs/notes/owen-public-facade.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,23 @@ Per the guardrail this rebrand was scoped to: no mass rename.
157157
written to, moved, or deleted by this code — content-addressing didn't
158158
change that guardrail, only how "is this destination actually still
159159
correct" gets decided.
160+
**Correction (review, PR #246 round 4):** round 3 still trusted a hit at
161+
the CURRENT fingerprint-named path (`Directory.Exists(finalOwnlang)`) on
162+
existence alone — the concurrent-publisher race checks did too. But a
163+
path name is only ever a claim; nothing stopped a destination already
164+
living at the "right" fingerprint from being modified, corrupted, or
165+
hand-assembled after the fact, and that content would then be served as
166+
if it were still the exact bytes the fingerprint names. Every current-
167+
path hit (the initial check, and both concurrent-publisher checks around
168+
the atomic move) now recomputes the fingerprint over what is actually on
169+
disk there and only trusts it on an exact match, exactly like the legacy
170+
fallback already did. A mismatch quarantines the invalid destination —
171+
an atomic rename to a `.invalid-<guid>` sibling (so no concurrent reader
172+
ever observes an in-place delete mid-way), then a best-effort recursive
173+
delete of the renamed copy — and falls through to the same temp-
174+
directory-plus-atomic-move rebuild a fresh unpack uses; since the source
175+
didn't change, the rebuild lands back at the identical fingerprint-named
176+
path, now holding a verified copy.
160177

161178
## Tests
162179

@@ -215,6 +232,14 @@ before being encoded as assertions:
215232
round 2, now with a real permission-denied subdirectory to exercise it.
216233
- **Uppercase extension (`Leak.CS`)** asserts exit 1 with `OWN001` found —
217234
the `StringComparer.OrdinalIgnoreCase` fix from review round 3.
235+
- **A tampered CURRENT (fingerprint-named) cache is rejected and rebuilt**
236+
(review round 4) — runs once to create `~/.owen/core/<version>/<fingerprint>/`,
237+
tampers `ownlang/ownir.py`'s bytes and adds an `ownlang/stale_module.py`
238+
directly under that exact path (not the legacy location the earlier
239+
tests already covered), then asserts the second run's SARIF driver is
240+
still `Owen`, `stale_module.py` is gone from whatever cache directory
241+
actually got used, and `ownir.py` no longer contains the tampered
242+
content anywhere under `~/.owen`.
218243

219244
## PR separation
220245

frontend/roslyn/OwnSharp.Cli/CoreVendor.cs

Lines changed: 82 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,18 @@ namespace OwnSharp.Cli;
3535
/// falls through to a fresh unpack. This is still a plain fallback *read*,
3636
/// not a migration subsystem: the legacy location is never written to,
3737
/// moved, or deleted by this code.
38+
///
39+
/// A hit at the CURRENT (fingerprint-named) path is verified the same way
40+
/// (review, PR #246 round 4) — the path's name is only ever a claim, not
41+
/// proof; something could have modified, corrupted, or hand-assembled a
42+
/// directory that happens to sit at the "right" fingerprint since it was
43+
/// published. <see cref="Fingerprint"/> is recomputed over what is actually
44+
/// there on every hit (both the initial existence check and the
45+
/// concurrent-publisher race checks further down) and only trusted on an
46+
/// exact match; a mismatch quarantines the invalid destination (an atomic
47+
/// rename out of the way, then best-effort delete — never an in-place
48+
/// delete a concurrent reader could observe mid-way) and falls through to
49+
/// the same temp-directory + atomic-move rebuild used for a fresh unpack.
3850
/// </summary>
3951
internal static class CoreVendor
4052
{
@@ -62,27 +74,31 @@ public static string EnsureUnpacked()
6274
var finalRoot = Path.Combine(versionRoot, fingerprint);
6375
var finalOwnlang = Path.Combine(finalRoot, "ownlang");
6476

65-
// Content-addressed: existence at the fingerprint-named path IS proof of
66-
// validity (see the atomic-publish note below) -- no separate marker to
67-
// go stale or drift from the directory's actual contents.
77+
// Content-addressed cache hit: verify the DESTINATION's actual content,
78+
// not just its existence at the fingerprint-named path (review, PR #246
79+
// round 4) -- a directory living under the "right" path is not proof it
80+
// still holds the exact bytes that path name claims; only recomputing
81+
// the fingerprint over what is actually there is. A mismatch means this
82+
// path is invalid -- content-addressing has no business trusting it (it
83+
// is not a "different, still-valid" cache the way a different
84+
// fingerprint would be) -- quarantine it and fall through to rebuild.
6885
if (Directory.Exists(finalOwnlang))
6986
{
70-
return finalRoot;
87+
if (DestinationMatches(finalOwnlang, fingerprint))
88+
{
89+
return finalRoot;
90+
}
91+
QuarantineInvalidDestination(finalRoot);
7192
}
7293

7394
// Legacy fallback: verify the LEGACY DESTINATION's actual content, not a
7495
// marker file's say-so (review, PR #246) -- a marker only proves "an
7596
// unpack happened here once", never that nothing since removed, added,
7697
// or modified a file in that directory.
7798
var legacyOwnlang = Path.Combine(userProfile, ".ownsharp", "core", ToolVersion.Current, "ownlang");
78-
if (Directory.Exists(legacyOwnlang))
99+
if (Directory.Exists(legacyOwnlang) && DestinationMatches(legacyOwnlang, fingerprint))
79100
{
80-
var legacyFiles = SortedPyFiles(legacyOwnlang);
81-
var legacyFingerprint = Fingerprint(legacyFiles);
82-
if (legacyFingerprint == fingerprint)
83-
{
84-
return Path.Combine(userProfile, ".ownsharp", "core", ToolVersion.Current);
85-
}
101+
return Path.Combine(userProfile, ".ownsharp", "core", ToolVersion.Current);
86102
}
87103

88104
// Fresh unpack: build into a temp sibling, verify the DESTINATION's own
@@ -111,10 +127,17 @@ public static string EnsureUnpacked()
111127

112128
if (Directory.Exists(finalOwnlang))
113129
{
114-
// Lost a race with a concurrent `owen` process that published the
115-
// same fingerprint first -- their content is provably identical
116-
// (same fingerprint), so just use it.
117-
return finalRoot;
130+
// Possibly lost a race with a concurrent `owen` process that
131+
// published the same fingerprint first -- but only trust that if
132+
// ITS destination actually verifies (review, PR #246 round 4).
133+
// Existence proves nothing about a path anyone (or anything)
134+
// could have written to since; "same fingerprint-named path" is
135+
// not the same claim as "same, provably identical content".
136+
if (DestinationMatches(finalOwnlang, fingerprint))
137+
{
138+
return finalRoot;
139+
}
140+
QuarantineInvalidDestination(finalRoot);
118141
}
119142
try
120143
{
@@ -125,10 +148,16 @@ public static string EnsureUnpacked()
125148
{
126149
// Narrower version of the same race (review, PR #246): a concurrent
127150
// process created finalOwnlang between the check above and this
128-
// Move. Same reasoning -- their content is provably identical
129-
// (same fingerprint), so just use it instead of surfacing the
130-
// IOException Move throws for an existing destination.
131-
return finalRoot;
151+
// Move. Same verification requirement as above -- only accept it
152+
// if it actually matches; otherwise quarantine it and retry the
153+
// move once with our own already-verified tempOwnlang copy.
154+
if (DestinationMatches(finalOwnlang, fingerprint))
155+
{
156+
return finalRoot;
157+
}
158+
QuarantineInvalidDestination(finalRoot);
159+
Directory.CreateDirectory(finalRoot);
160+
Directory.Move(tempOwnlang, finalOwnlang);
132161
}
133162
return finalRoot;
134163
}
@@ -144,6 +173,40 @@ public static string EnsureUnpacked()
144173
private static List<string> SortedPyFiles(string dir) =>
145174
Directory.EnumerateFiles(dir, "*.py").OrderBy(f => Path.GetFileName(f), StringComparer.Ordinal).ToList();
146175

176+
/// <summary>True only if every <c>.py</c> file actually on disk under
177+
/// <paramref name="ownlangDir"/> right now fingerprints to
178+
/// <paramref name="expectedFingerprint"/> (review, PR #246 round 4). This
179+
/// is the sole source of truth for "is this destination still valid" --
180+
/// a directory's location (even a content-addressed, fingerprint-named
181+
/// one) is only ever a claim about what was published there once, never
182+
/// proof of what is there now.</summary>
183+
private static bool DestinationMatches(string ownlangDir, string expectedFingerprint) =>
184+
Fingerprint(SortedPyFiles(ownlangDir)) == expectedFingerprint;
185+
186+
/// <summary>Moves an invalid cache destination out of the way of a rebuild
187+
/// (review, PR #246 round 4). Renames first -- an atomic same-volume
188+
/// rename can't be observed half-done the way an in-place recursive
189+
/// delete could -- then best-effort deletes the renamed copy; a failure
190+
/// there just leaves inert garbage that is never consulted again (the
191+
/// quarantined name is never re-derived by <see cref="EnsureUnpacked"/>),
192+
/// same reasoning as the orphaned-temp-directory cleanup above.</summary>
193+
private static void QuarantineInvalidDestination(string invalidRoot)
194+
{
195+
var quarantined = $"{invalidRoot}.invalid-{Guid.NewGuid():N}";
196+
try
197+
{
198+
Directory.Move(invalidRoot, quarantined);
199+
}
200+
catch (IOException)
201+
{
202+
// Lost a race with something else already handling this exact path
203+
// (e.g. a concurrent process's own quarantine of the same invalid
204+
// directory) -- nothing more to do; the caller re-checks fresh.
205+
return;
206+
}
207+
try { Directory.Delete(quarantined, recursive: true); } catch (IOException) { /* best-effort cleanup */ }
208+
}
209+
147210
/// <summary>SHA-256 over every file's name and content, each explicitly
148211
/// length-prefixed (review, PR #246) so two different (name, content) sets
149212
/// can never hash identically by having their bytes merely concatenate the

0 commit comments

Comments
 (0)