1+ import { createHash } from "node:crypto" ;
12import {
23 closeSync ,
34 constants ,
@@ -11,10 +12,33 @@ export const PYLON_PUBLICATION_MANIFEST_MAX_BYTES = 64 * 1024;
1112export const PYLON_STABLE_HISTORY_MAX_MANIFESTS = 4096 ;
1213export const PYLON_STABLE_HISTORY_MAX_BYTES = 32 * 1024 * 1024 ;
1314
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+ }
1425
15- function sameStat ( left , right ) {
26+ function sameInodeReadBounds ( left , right ) {
1627 return left . dev === right . dev && left . ino === right . ino && left . size === right . size &&
17- left . mtimeMs === right . mtimeMs && left . ctimeMs === right . ctimeMs ;
28+ left . mtimeMs === right . mtimeMs ;
29+ }
30+
31+ function sameStat ( left , right ) {
32+ return sameInodeReadBounds ( left , right ) && left . ctimeMs === right . ctimeMs && left . nlink === right . nlink ;
33+ }
34+
35+ function isPinnedHandleRemoval ( pathEntry , before , after , extraBytes , finalPathMissing ) {
36+ return finalPathMissing && extraBytes === 0 && sameStat ( pathEntry , before ) &&
37+ sameInodeReadBounds ( before , after ) && before . nlink > 0 && after . nlink === 0 ;
38+ }
39+
40+ function exactSha256 ( bytes , expectedSha256 ) {
41+ return expectedSha256 !== null && createHash ( "sha256" ) . update ( bytes ) . digest ( "hex" ) === expectedSha256 ;
1842}
1943
2044export async function readBoundedRegularFile (
@@ -27,9 +51,13 @@ export async function readBoundedRegularFile(
2751 lstatEntry = lstat ,
2852 validateHandle,
2953 hooks,
54+ expectedSha256 = null ,
3055 } = { } ,
3156) {
32- if ( ! Number . isSafeInteger ( maxBytes ) || maxBytes < 1 || ! Number . isSafeInteger ( minBytes ) || minBytes < 0 || minBytes > maxBytes ) {
57+ if (
58+ ! Number . isSafeInteger ( maxBytes ) || maxBytes < 1 || ! Number . isSafeInteger ( minBytes ) || minBytes < 0 || minBytes > maxBytes ||
59+ ! ( expectedSha256 === null || / ^ [ 0 - 9 a - f ] { 64 } $ / . test ( expectedSha256 ) )
60+ ) {
3361 throw new Error ( "Bounded file limits are invalid." ) ;
3462 }
3563 let pathEntry ;
@@ -56,6 +84,7 @@ export async function readBoundedRegularFile(
5684 let before = await handle . stat ( ) ;
5785 if ( ! before . isFile ( ) ) throw new Error ( `${ description } is not one regular non-symlink file.` ) ;
5886 if ( validateHandle ) before = await validateHandle ( handle , before , description ) ;
87+ if ( ! sameStat ( pathEntry , before ) ) throw new Error ( `${ description } changed while it was read.` ) ;
5988 if ( before . size < minBytes || before . size > maxBytes ) throw new Error ( `${ description } exceeds its format byte limit or is malformed.` ) ;
6089 await hooks ?. afterInitialStat ?. ( { path, handle, stat : before } ) ;
6190 const bytes = Buffer . alloc ( before . size ) ;
@@ -69,7 +98,24 @@ export async function readBoundedRegularFile(
6998 const { bytesRead : extraBytes } = await handle . read ( extra , 0 , 1 , bytes . length ) ;
7099 await hooks ?. beforeFinalStat ?. ( { path, handle, bytes } ) ;
71100 const after = await handle . stat ( ) ;
72- if ( extraBytes !== 0 || ! sameStat ( before , after ) ) throw new Error ( `${ description } changed while it was read.` ) ;
101+ let finalPathEntry ;
102+ let finalPathMissing = false ;
103+ try {
104+ finalPathEntry = await lstatEntry ( path ) ;
105+ } catch ( error ) {
106+ if ( error ?. code !== "ENOENT" ) throw error ;
107+ finalPathMissing = true ;
108+ }
109+ if (
110+ isPinnedHandleRemoval ( pathEntry , before , after , extraBytes , finalPathMissing ) &&
111+ exactSha256 ( bytes , expectedSha256 )
112+ ) {
113+ throw new BoundedFileUnlinkedDuringReadError ( path , description , bytes , expectedSha256 ) ;
114+ }
115+ if (
116+ extraBytes !== 0 || finalPathMissing || finalPathEntry . isSymbolicLink ?. ( ) || ! finalPathEntry . isFile ( ) ||
117+ ! sameStat ( before , after ) || ! sameStat ( after , finalPathEntry )
118+ ) throw new Error ( `${ description } changed while it was read.` ) ;
73119 return bytes ;
74120 } finally {
75121 await handle . close ( ) ;
@@ -89,9 +135,13 @@ export function readBoundedRegularFileSync(
89135 readFile = readSync ,
90136 closeFile = closeSync ,
91137 hooks,
138+ expectedSha256 = null ,
92139 } = { } ,
93140) {
94- if ( ! Number . isSafeInteger ( maxBytes ) || maxBytes < 1 || ! Number . isSafeInteger ( minBytes ) || minBytes < 0 || minBytes > maxBytes ) {
141+ if (
142+ ! Number . isSafeInteger ( maxBytes ) || maxBytes < 1 || ! Number . isSafeInteger ( minBytes ) || minBytes < 0 || minBytes > maxBytes ||
143+ ! ( expectedSha256 === null || / ^ [ 0 - 9 a - f ] { 64 } $ / . test ( expectedSha256 ) )
144+ ) {
95145 throw new Error ( "Bounded file limits are invalid." ) ;
96146 }
97147 let pathEntry ;
@@ -115,6 +165,7 @@ export function readBoundedRegularFileSync(
115165 try {
116166 const before = statFile ( descriptor ) ;
117167 if ( ! before . isFile ( ) ) throw new Error ( `${ description } is not one regular non-symlink file.` ) ;
168+ if ( ! sameStat ( pathEntry , before ) ) throw new Error ( `${ description } changed while it was read.` ) ;
118169 if ( before . size < minBytes || before . size > maxBytes ) throw new Error ( `${ description } exceeds its format byte limit or is malformed.` ) ;
119170 hooks ?. afterInitialStat ?. ( { path, descriptor, stat : before } ) ;
120171 const bytes = Buffer . alloc ( before . size ) ;
@@ -128,7 +179,24 @@ export function readBoundedRegularFileSync(
128179 const extraBytes = readFile ( descriptor , extra , 0 , 1 , bytes . length ) ;
129180 hooks ?. beforeFinalStat ?. ( { path, descriptor, bytes } ) ;
130181 const after = statFile ( descriptor ) ;
131- if ( extraBytes !== 0 || ! sameStat ( before , after ) ) throw new Error ( `${ description } changed while it was read.` ) ;
182+ let finalPathEntry ;
183+ let finalPathMissing = false ;
184+ try {
185+ finalPathEntry = lstatEntry ( path ) ;
186+ } catch ( error ) {
187+ if ( error ?. code !== "ENOENT" ) throw error ;
188+ finalPathMissing = true ;
189+ }
190+ if (
191+ isPinnedHandleRemoval ( pathEntry , before , after , extraBytes , finalPathMissing ) &&
192+ exactSha256 ( bytes , expectedSha256 )
193+ ) {
194+ throw new BoundedFileUnlinkedDuringReadError ( path , description , bytes , expectedSha256 ) ;
195+ }
196+ if (
197+ extraBytes !== 0 || finalPathMissing || finalPathEntry . isSymbolicLink ?. ( ) || ! finalPathEntry . isFile ( ) ||
198+ ! sameStat ( before , after ) || ! sameStat ( after , finalPathEntry )
199+ ) throw new Error ( `${ description } changed while it was read.` ) ;
132200 return bytes ;
133201 } finally {
134202 closeFile ( descriptor ) ;
0 commit comments