@@ -12,17 +12,6 @@ export const PYLON_PUBLICATION_MANIFEST_MAX_BYTES = 64 * 1024;
1212export const PYLON_STABLE_HISTORY_MAX_MANIFESTS = 4096 ;
1313export const PYLON_STABLE_HISTORY_MAX_BYTES = 32 * 1024 * 1024 ;
1414
15- export class BoundedFileUnlinkedDuringReadError extends Error {
16- constructor ( path , description , bytes , expectedSha256 ) {
17- super ( `${ description } changed while it was read because the same opened inode was removed.` ) ;
18- this . name = "BoundedFileUnlinkedDuringReadError" ;
19- this . path = path ;
20- this . description = description ;
21- this . bytes = Buffer . from ( bytes ) ;
22- this . expectedSha256 = expectedSha256 ;
23- }
24- }
25-
2615function statEvidence ( stat ) {
2716 return Object . freeze ( {
2817 dev : stat . dev ,
@@ -34,6 +23,24 @@ function statEvidence(stat) {
3423 } ) ;
3524}
3625
26+ export class BoundedFileUnlinkedDuringReadError extends Error {
27+ constructor ( path , description , bytes , expectedSha256 , pathEntry , before , after , confirmedHandle = null ) {
28+ super ( `${ description } changed while it was read because the same opened inode was removed.` ) ;
29+ this . name = "BoundedFileUnlinkedDuringReadError" ;
30+ this . path = path ;
31+ this . description = description ;
32+ this . bytes = Buffer . from ( bytes ) ;
33+ this . expectedSha256 = expectedSha256 ;
34+ this . sha256 = createHash ( "sha256" ) . update ( this . bytes ) . digest ( "hex" ) ;
35+ this . statTransition = Object . freeze ( {
36+ pathEntry : statEvidence ( pathEntry ) ,
37+ before : statEvidence ( before ) ,
38+ after : statEvidence ( after ) ,
39+ confirmedHandle : confirmedHandle === null ? null : statEvidence ( confirmedHandle ) ,
40+ } ) ;
41+ }
42+ }
43+
3744export class BoundedFileLinkRetiredBeforeReadError extends Error {
3845 constructor ( path , description , bytes , expectedSha256 , pathEntry , openedHandle ) {
3946 super ( `${ description } changed while it was read because one publication hardlink was retired before the file was opened.` ) ;
@@ -50,6 +57,24 @@ export class BoundedFileLinkRetiredBeforeReadError extends Error {
5057 }
5158}
5259
60+ export class BoundedFileLinkRetiredDuringReadError extends Error {
61+ constructor ( path , description , bytes , expectedSha256 , pathEntry , before , after , finalPathEntry ) {
62+ super ( `${ description } changed while it was read because one publication hardlink was retired during the bounded read.` ) ;
63+ this . name = "BoundedFileLinkRetiredDuringReadError" ;
64+ this . path = path ;
65+ this . description = description ;
66+ this . bytes = Buffer . from ( bytes ) ;
67+ this . expectedSha256 = expectedSha256 ;
68+ this . sha256 = createHash ( "sha256" ) . update ( this . bytes ) . digest ( "hex" ) ;
69+ this . statTransition = Object . freeze ( {
70+ pathEntry : statEvidence ( pathEntry ) ,
71+ before : statEvidence ( before ) ,
72+ after : statEvidence ( after ) ,
73+ finalPathEntry : statEvidence ( finalPathEntry ) ,
74+ } ) ;
75+ }
76+ }
77+
5378function sameInodeReadBounds ( left , right ) {
5479 return left . dev === right . dev && left . ino === right . ino && left . size === right . size &&
5580 left . mtimeMs === right . mtimeMs ;
@@ -59,28 +84,40 @@ function sameStat(left, right) {
5984 return sameInodeReadBounds ( left , right ) && left . ctimeMs === right . ctimeMs && left . nlink === right . nlink ;
6085}
6186
62- function isPinnedHandleRemoval ( pathEntry , before , after , extraBytes , finalPathMissing ) {
63- return finalPathMissing && extraBytes === 0 && sameStat ( pathEntry , before ) &&
64- sameInodeReadBounds ( before , after ) && before . nlink > 0 && after . nlink === 0 ;
65- }
66-
67- function initialRetirementKind ( pathEntry , openedHandle , expectedSha256 ) {
87+ function exactMonotoneStatCut ( observations , fromLinks , toLinks ) {
6888 if (
69- expectedSha256 === null || ! sameInodeReadBounds ( pathEntry , openedHandle ) ||
70- pathEntry . ctimeMs === openedHandle . ctimeMs
89+ observations . length < 2 || observations [ 0 ] . nlink !== fromLinks ||
90+ observations . at ( - 1 ) . nlink !== toLinks ||
91+ observations . some ( ( stat ) => ! sameInodeReadBounds ( observations [ 0 ] , stat ) )
7192 ) return null ;
72- if ( pathEntry . nlink === 2 && openedHandle . nlink === 1 ) return "link-retired" ;
73- if ( pathEntry . nlink === 1 && openedHandle . nlink === 0 ) return "unlinked" ;
74- return null ;
93+ let cut = null ;
94+ for ( let index = 1 ; index < observations . length ; index += 1 ) {
95+ const previous = observations [ index - 1 ] ;
96+ const current = observations [ index ] ;
97+ if ( previous . nlink === current . nlink ) {
98+ if ( previous . ctimeMs !== current . ctimeMs ) return null ;
99+ continue ;
100+ }
101+ if (
102+ cut !== null || previous . nlink !== fromLinks || current . nlink !== toLinks ||
103+ previous . ctimeMs === current . ctimeMs
104+ ) return null ;
105+ cut = index ;
106+ }
107+ return cut ;
75108}
76109
77- function isStableLinkRetirementBeforeRead ( before , after , extraBytes , finalPathEntry , finalPathMissing ) {
78- return ! finalPathMissing && extraBytes === 0 && ! finalPathEntry . isSymbolicLink ?. ( ) && finalPathEntry . isFile ( ) &&
79- sameStat ( before , after ) && sameStat ( after , finalPathEntry ) ;
110+ function permitsInitialStatTransition ( pathEntry , before , expectedSha256 ) {
111+ return sameStat ( pathEntry , before ) || (
112+ expectedSha256 !== null && (
113+ exactMonotoneStatCut ( [ pathEntry , before ] , 2 , 1 ) === 1 ||
114+ exactMonotoneStatCut ( [ pathEntry , before ] , 1 , 0 ) === 1
115+ )
116+ ) ;
80117}
81118
82- function isStableHandleRemovalBeforeRead ( before , after , extraBytes , finalPathMissing ) {
83- return finalPathMissing && extraBytes === 0 && sameStat ( before , after ) ;
119+ function isRegularPathEntry ( pathEntry ) {
120+ return ! pathEntry . isSymbolicLink ?. ( ) && pathEntry . isFile ( ) ;
84121}
85122
86123function exactSha256 ( bytes , expectedSha256 ) {
@@ -130,10 +167,8 @@ export async function readBoundedRegularFile(
130167 let before = await handle . stat ( ) ;
131168 if ( ! before . isFile ( ) ) throw new Error ( `${ description } is not one regular non-symlink file.` ) ;
132169 if ( validateHandle ) before = await validateHandle ( handle , before , description ) ;
133- let retirementBeforeRead = null ;
134- if ( ! sameStat ( pathEntry , before ) ) {
135- retirementBeforeRead = initialRetirementKind ( pathEntry , before , expectedSha256 ) ;
136- if ( retirementBeforeRead === null ) throw new Error ( `${ description } changed while it was read.` ) ;
170+ if ( ! permitsInitialStatTransition ( pathEntry , before , expectedSha256 ) ) {
171+ throw new Error ( `${ description } changed while it was read.` ) ;
137172 }
138173 if ( before . size < minBytes || before . size > maxBytes ) throw new Error ( `${ description } exceeds its format byte limit or is malformed.` ) ;
139174 await hooks ?. afterInitialStat ?. ( { path, handle, stat : before } ) ;
@@ -148,6 +183,7 @@ export async function readBoundedRegularFile(
148183 const { bytesRead : extraBytes } = await handle . read ( extra , 0 , 1 , bytes . length ) ;
149184 await hooks ?. beforeFinalStat ?. ( { path, handle, bytes } ) ;
150185 const after = await handle . stat ( ) ;
186+ await hooks ?. afterFinalStat ?. ( { path, handle, stat : after , bytes } ) ;
151187 let finalPathEntry ;
152188 let finalPathMissing = false ;
153189 try {
@@ -156,37 +192,56 @@ export async function readBoundedRegularFile(
156192 if ( error ?. code !== "ENOENT" ) throw error ;
157193 finalPathMissing = true ;
158194 }
159- if ( exactSha256 ( bytes , expectedSha256 ) ) {
160- if (
161- retirementBeforeRead === "link-retired" &&
162- isStableLinkRetirementBeforeRead ( before , after , extraBytes , finalPathEntry , finalPathMissing )
163- ) {
164- throw new BoundedFileLinkRetiredBeforeReadError (
165- path ,
166- description ,
167- bytes ,
168- expectedSha256 ,
169- pathEntry ,
170- before ,
171- ) ;
172- }
173- if (
174- retirementBeforeRead === "unlinked" &&
175- isStableHandleRemovalBeforeRead ( before , after , extraBytes , finalPathMissing )
176- ) {
177- throw new BoundedFileUnlinkedDuringReadError ( path , description , bytes , expectedSha256 ) ;
195+ let confirmedHandle = null ;
196+ if ( finalPathMissing && after . nlink === 1 && sameStat ( pathEntry , before ) && sameStat ( before , after ) ) {
197+ confirmedHandle = await handle . stat ( ) ;
198+ }
199+ if ( extraBytes === 0 && exactSha256 ( bytes , expectedSha256 ) ) {
200+ if ( ! finalPathMissing && isRegularPathEntry ( finalPathEntry ) ) {
201+ const retirementCut = exactMonotoneStatCut ( [ pathEntry , before , after , finalPathEntry ] , 2 , 1 ) ;
202+ if ( retirementCut === 1 ) {
203+ throw new BoundedFileLinkRetiredBeforeReadError (
204+ path ,
205+ description ,
206+ bytes ,
207+ expectedSha256 ,
208+ pathEntry ,
209+ before ,
210+ ) ;
211+ }
212+ if ( retirementCut !== null ) {
213+ throw new BoundedFileLinkRetiredDuringReadError (
214+ path ,
215+ description ,
216+ bytes ,
217+ expectedSha256 ,
218+ pathEntry ,
219+ before ,
220+ after ,
221+ finalPathEntry ,
222+ ) ;
223+ }
178224 }
179- if (
180- retirementBeforeRead === null &&
181- isPinnedHandleRemoval ( pathEntry , before , after , extraBytes , finalPathMissing )
182- ) {
183- throw new BoundedFileUnlinkedDuringReadError ( path , description , bytes , expectedSha256 ) ;
225+ if ( finalPathMissing ) {
226+ const unlinkStats = [ pathEntry , before , after ] ;
227+ if ( confirmedHandle !== null ) unlinkStats . push ( confirmedHandle ) ;
228+ if ( exactMonotoneStatCut ( unlinkStats , 1 , 0 ) !== null ) {
229+ throw new BoundedFileUnlinkedDuringReadError (
230+ path ,
231+ description ,
232+ bytes ,
233+ expectedSha256 ,
234+ pathEntry ,
235+ before ,
236+ after ,
237+ confirmedHandle ,
238+ ) ;
239+ }
184240 }
185241 }
186242 if (
187- retirementBeforeRead !== null || extraBytes !== 0 || finalPathMissing ||
188- finalPathEntry . isSymbolicLink ?. ( ) || ! finalPathEntry . isFile ( ) ||
189- ! sameStat ( before , after ) || ! sameStat ( after , finalPathEntry )
243+ extraBytes !== 0 || finalPathMissing || ! isRegularPathEntry ( finalPathEntry ) ||
244+ ! sameStat ( pathEntry , before ) || ! sameStat ( before , after ) || ! sameStat ( after , finalPathEntry )
190245 ) throw new Error ( `${ description } changed while it was read.` ) ;
191246 return bytes ;
192247 } finally {
@@ -237,10 +292,8 @@ export function readBoundedRegularFileSync(
237292 try {
238293 const before = statFile ( descriptor ) ;
239294 if ( ! before . isFile ( ) ) throw new Error ( `${ description } is not one regular non-symlink file.` ) ;
240- let retirementBeforeRead = null ;
241- if ( ! sameStat ( pathEntry , before ) ) {
242- retirementBeforeRead = initialRetirementKind ( pathEntry , before , expectedSha256 ) ;
243- if ( retirementBeforeRead === null ) throw new Error ( `${ description } changed while it was read.` ) ;
295+ if ( ! permitsInitialStatTransition ( pathEntry , before , expectedSha256 ) ) {
296+ throw new Error ( `${ description } changed while it was read.` ) ;
244297 }
245298 if ( before . size < minBytes || before . size > maxBytes ) throw new Error ( `${ description } exceeds its format byte limit or is malformed.` ) ;
246299 hooks ?. afterInitialStat ?. ( { path, descriptor, stat : before } ) ;
@@ -255,6 +308,7 @@ export function readBoundedRegularFileSync(
255308 const extraBytes = readFile ( descriptor , extra , 0 , 1 , bytes . length ) ;
256309 hooks ?. beforeFinalStat ?. ( { path, descriptor, bytes } ) ;
257310 const after = statFile ( descriptor ) ;
311+ hooks ?. afterFinalStat ?. ( { path, descriptor, stat : after , bytes } ) ;
258312 let finalPathEntry ;
259313 let finalPathMissing = false ;
260314 try {
@@ -263,37 +317,56 @@ export function readBoundedRegularFileSync(
263317 if ( error ?. code !== "ENOENT" ) throw error ;
264318 finalPathMissing = true ;
265319 }
266- if ( exactSha256 ( bytes , expectedSha256 ) ) {
267- if (
268- retirementBeforeRead === "link-retired" &&
269- isStableLinkRetirementBeforeRead ( before , after , extraBytes , finalPathEntry , finalPathMissing )
270- ) {
271- throw new BoundedFileLinkRetiredBeforeReadError (
272- path ,
273- description ,
274- bytes ,
275- expectedSha256 ,
276- pathEntry ,
277- before ,
278- ) ;
279- }
280- if (
281- retirementBeforeRead === "unlinked" &&
282- isStableHandleRemovalBeforeRead ( before , after , extraBytes , finalPathMissing )
283- ) {
284- throw new BoundedFileUnlinkedDuringReadError ( path , description , bytes , expectedSha256 ) ;
320+ let confirmedHandle = null ;
321+ if ( finalPathMissing && after . nlink === 1 && sameStat ( pathEntry , before ) && sameStat ( before , after ) ) {
322+ confirmedHandle = statFile ( descriptor ) ;
323+ }
324+ if ( extraBytes === 0 && exactSha256 ( bytes , expectedSha256 ) ) {
325+ if ( ! finalPathMissing && isRegularPathEntry ( finalPathEntry ) ) {
326+ const retirementCut = exactMonotoneStatCut ( [ pathEntry , before , after , finalPathEntry ] , 2 , 1 ) ;
327+ if ( retirementCut === 1 ) {
328+ throw new BoundedFileLinkRetiredBeforeReadError (
329+ path ,
330+ description ,
331+ bytes ,
332+ expectedSha256 ,
333+ pathEntry ,
334+ before ,
335+ ) ;
336+ }
337+ if ( retirementCut !== null ) {
338+ throw new BoundedFileLinkRetiredDuringReadError (
339+ path ,
340+ description ,
341+ bytes ,
342+ expectedSha256 ,
343+ pathEntry ,
344+ before ,
345+ after ,
346+ finalPathEntry ,
347+ ) ;
348+ }
285349 }
286- if (
287- retirementBeforeRead === null &&
288- isPinnedHandleRemoval ( pathEntry , before , after , extraBytes , finalPathMissing )
289- ) {
290- throw new BoundedFileUnlinkedDuringReadError ( path , description , bytes , expectedSha256 ) ;
350+ if ( finalPathMissing ) {
351+ const unlinkStats = [ pathEntry , before , after ] ;
352+ if ( confirmedHandle !== null ) unlinkStats . push ( confirmedHandle ) ;
353+ if ( exactMonotoneStatCut ( unlinkStats , 1 , 0 ) !== null ) {
354+ throw new BoundedFileUnlinkedDuringReadError (
355+ path ,
356+ description ,
357+ bytes ,
358+ expectedSha256 ,
359+ pathEntry ,
360+ before ,
361+ after ,
362+ confirmedHandle ,
363+ ) ;
364+ }
291365 }
292366 }
293367 if (
294- retirementBeforeRead !== null || extraBytes !== 0 || finalPathMissing ||
295- finalPathEntry . isSymbolicLink ?. ( ) || ! finalPathEntry . isFile ( ) ||
296- ! sameStat ( before , after ) || ! sameStat ( after , finalPathEntry )
368+ extraBytes !== 0 || finalPathMissing || ! isRegularPathEntry ( finalPathEntry ) ||
369+ ! sameStat ( pathEntry , before ) || ! sameStat ( before , after ) || ! sameStat ( after , finalPathEntry )
297370 ) throw new Error ( `${ description } changed while it was read.` ) ;
298371 return bytes ;
299372 } finally {
0 commit comments