@@ -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>
3951internal 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