55// working-directory bookkeeping a borrowed chdir needs.
66
77import { execFileSync } from "child_process" ;
8- import fs from "fs" ;
8+ import fs , { type BigIntStats } from "fs" ;
99import path from "path" ;
1010import { afterEach , beforeEach , describe , expect , it , vi } from "vitest" ;
1111import {
@@ -20,6 +20,9 @@ import { makeTempDir } from "./helpers.js";
2020
2121const symlinks = process . platform !== "win32" ;
2222const realPlatform = process . platform ;
23+ // The identity check compares BigInt stats (#5154), so every expected stat a
24+ // test hands it is one.
25+ const lstatBig = ( p : string ) => fs . lstatSync ( p , { bigint : true } ) ;
2326const errno = ( code : string ) =>
2427 Object . assign ( new Error ( `${ code } : stand-in` ) , { code } ) ;
2528
@@ -144,8 +147,8 @@ describe("createFileInDirectory", () => {
144147 it ( "refuses, and closes the file, when the path no longer holds what was opened" , async ( ) => {
145148 const realLstat = fs . lstatSync ;
146149 vi . spyOn ( fs , "lstatSync" ) . mockImplementationOnce ( ( p ) => ( {
147- ...realLstat ( p ) ,
148- ino : - 1 ,
150+ ...realLstat ( p , { bigint : true } ) ,
151+ ino : - 1n ,
149152 } ) ) ;
150153 const closeSync = vi . spyOn ( fs , "closeSync" ) ;
151154 await expect (
@@ -183,8 +186,8 @@ describe("createFileInDirectory", () => {
183186 fs . symlinkSync ( outside , path . join ( dir , "link" ) ) ;
184187 const realLstat = fs . lstatSync ;
185188 vi . spyOn ( fs , "lstatSync" ) . mockImplementationOnce ( ( p ) => ( {
186- ...realLstat ( p ) ,
187- ino : - 1 ,
189+ ...realLstat ( p , { bigint : true } ) ,
190+ ino : - 1n ,
188191 } ) ) ;
189192 await expect (
190193 createFileInDirectory ( path . join ( dir , "link" , "x.txt" ) , "x" ) ,
@@ -197,16 +200,16 @@ describe("overwriteFileInDirectory", () => {
197200 it ( "replaces the content in place, keeping the inode" , async ( ) => {
198201 const file = path . join ( dir , "f.txt" ) ;
199202 fs . writeFileSync ( file , "a much longer original" ) ;
200- const expected = fs . lstatSync ( file ) ;
203+ const expected = lstatBig ( file ) ;
201204 await overwriteFileInDirectory ( file , "short" , expected ) ;
202205 expect ( fs . readFileSync ( file , "utf-8" ) ) . toBe ( "short" ) ;
203- expect ( fs . lstatSync ( file ) . ino ) . toBe ( expected . ino ) ;
206+ expect ( lstatBig ( file ) . ino ) . toBe ( expected . ino ) ;
204207 } ) ;
205208
206209 it ( "refuses a file other than the one expected" , async ( ) => {
207210 const file = path . join ( dir , "f.txt" ) ;
208211 fs . writeFileSync ( file , "original" ) ;
209- const expected = { ...fs . lstatSync ( file ) , ino : - 1 } ;
212+ const expected = { ...lstatBig ( file ) , ino : - 1n } ;
210213 await expect (
211214 overwriteFileInDirectory ( file , "new" , expected ) ,
212215 ) . rejects . toThrow (
@@ -218,10 +221,10 @@ describe("overwriteFileInDirectory", () => {
218221 it ( "refuses something that is not a regular file" , async ( ) => {
219222 const file = path . join ( dir , "f.txt" ) ;
220223 fs . writeFileSync ( file , "original" ) ;
221- const expected = fs . lstatSync ( file ) ;
224+ const expected = lstatBig ( file ) ;
222225 const realFstat = fs . fstatSync ;
223226 vi . spyOn ( fs , "fstatSync" ) . mockImplementationOnce ( ( fd ) => {
224- const stats = realFstat ( fd ) ;
227+ const stats = realFstat ( fd , { bigint : true } ) ;
225228 return Object . assign ( stats , { isFile : ( ) => false } ) ;
226229 } ) ;
227230 await expect (
@@ -237,7 +240,7 @@ describe("overwriteFileInDirectory", () => {
237240 } ) as typeof fs . ftruncate ) ;
238241 const close = vi . spyOn ( fs , "close" ) ;
239242 await expect (
240- overwriteFileInDirectory ( file , "new" , fs . lstatSync ( file ) ) ,
243+ overwriteFileInDirectory ( file , "new" , lstatBig ( file ) ) ,
241244 ) . rejects . toThrow ( "EIO" ) ;
242245 expect ( close ) . toHaveBeenCalled ( ) ;
243246 expect ( fs . readFileSync ( file , "utf-8" ) ) . toBe ( "original" ) ;
@@ -249,7 +252,11 @@ describe("overwriteFileInDirectory", () => {
249252 const link = path . join ( dir , "l.txt" ) ;
250253 fs . symlinkSync ( target , link ) ;
251254 await expect (
252- overwriteFileInDirectory ( link , "pwned" , fs . statSync ( target ) ) ,
255+ overwriteFileInDirectory (
256+ link ,
257+ "pwned" ,
258+ fs . statSync ( target , { bigint : true } ) ,
259+ ) ,
253260 ) . rejects . toMatchObject ( { code : "ELOOP" } ) ;
254261 expect ( fs . readFileSync ( target , "utf-8" ) ) . toBe ( "SECRET" ) ;
255262 } ) ;
@@ -265,7 +272,11 @@ describe("on Windows, where O_NOFOLLOW does not exist", () => {
265272 const link = path . join ( dir , "l.txt" ) ;
266273 fs . symlinkSync ( target , link ) ;
267274 await expect (
268- overwriteFileInDirectory ( link , "pwned" , fs . statSync ( target ) ) ,
275+ overwriteFileInDirectory (
276+ link ,
277+ "pwned" ,
278+ fs . statSync ( target , { bigint : true } ) ,
279+ ) ,
269280 ) . rejects . toThrow ( `Refusing to write: not a regular file: ${ link } ` ) ;
270281 expect ( fs . readFileSync ( target , "utf-8" ) ) . toBe ( "SECRET" ) ;
271282 } ,
@@ -275,11 +286,121 @@ describe("on Windows, where O_NOFOLLOW does not exist", () => {
275286 Object . defineProperty ( process , "platform" , { value : "win32" } ) ;
276287 const file = path . join ( dir , "f.txt" ) ;
277288 fs . writeFileSync ( file , "original" ) ;
278- await overwriteFileInDirectory ( file , "new" , fs . lstatSync ( file ) ) ;
289+ await overwriteFileInDirectory ( file , "new" , lstatBig ( file ) ) ;
279290 expect ( fs . readFileSync ( file , "utf-8" ) ) . toBe ( "new" ) ;
280291 } ) ;
281292} ) ;
282293
294+ describe ( "the identity check (#5154)" , ( ) => {
295+ it ( "compares inodes exactly, where a number stat would round a 64-bit file id" , async ( ) => {
296+ const file = path . join ( dir , "f.txt" ) ;
297+ fs . writeFileSync ( file , "original" ) ;
298+ const high = 2n ** 53n ;
299+ // The premise: as doubles, these two file ids are one number.
300+ expect ( Number ( high + 1n ) ) . toBe ( Number ( high ) ) ;
301+ const expected = { ...lstatBig ( file ) , ino : high } ;
302+ const realFstat = fs . fstatSync ;
303+ const realLstat = fs . lstatSync ;
304+ vi . spyOn ( fs , "fstatSync" ) . mockImplementationOnce ( ( fd ) => ( {
305+ ...realFstat ( fd , { bigint : true } ) ,
306+ isFile : ( ) => true ,
307+ ino : high + 1n ,
308+ } ) ) ;
309+ vi . spyOn ( fs , "lstatSync" ) . mockImplementationOnce ( ( p ) => ( {
310+ ...realLstat ( p , { bigint : true } ) ,
311+ ino : high + 1n ,
312+ } ) ) ;
313+ await expect (
314+ overwriteFileInDirectory ( file , "new" , expected ) ,
315+ ) . rejects . toThrow ( "was replaced while it was being opened" ) ;
316+ expect ( fs . readFileSync ( file , "utf-8" ) ) . toBe ( "original" ) ;
317+ } ) ;
318+
319+ describe ( "on Windows, where a path stat can report no device (libuv < 1.51.0)" , ( ) => {
320+ beforeEach ( ( ) => {
321+ Object . defineProperty ( process , "platform" , { value : "win32" } ) ;
322+ } ) ;
323+
324+ it ( "overwrites when inode, size and mtime all match" , async ( ) => {
325+ const file = path . join ( dir , "f.txt" ) ;
326+ fs . writeFileSync ( file , "original" ) ;
327+ const expected = { ...lstatBig ( file ) , dev : 0n } ;
328+ await overwriteFileInDirectory ( file , "new" , expected ) ;
329+ expect ( fs . readFileSync ( file , "utf-8" ) ) . toBe ( "new" ) ;
330+ } ) ;
331+
332+ it ( "creates a file whose path stat reports no device" , async ( ) => {
333+ const realLstat = fs . lstatSync ;
334+ vi . spyOn ( fs , "lstatSync" ) . mockImplementationOnce ( ( p ) => ( {
335+ ...realLstat ( p , { bigint : true } ) ,
336+ dev : 0n ,
337+ } ) ) ;
338+ const file = path . join ( dir , "new.txt" ) ;
339+ await createFileInDirectory ( file , "hello" ) ;
340+ expect ( fs . readFileSync ( file , "utf-8" ) ) . toBe ( "hello" ) ;
341+ } ) ;
342+
343+ it . each ( [
344+ [ "inode" , ( s : BigIntStats ) => ( { ino : s . ino + 1n } ) ] ,
345+ [ "size" , ( s : BigIntStats ) => ( { size : s . size + 1n } ) ] ,
346+ [ "mtime" , ( s : BigIntStats ) => ( { mtimeNs : s . mtimeNs - 1n } ) ] ,
347+ ] ) ( "refuses when the %s differs" , async ( _field , change ) => {
348+ const file = path . join ( dir , "f.txt" ) ;
349+ fs . writeFileSync ( file , "original" ) ;
350+ const stats = lstatBig ( file ) ;
351+ const expected = { ...stats , dev : 0n , ...change ( stats ) } ;
352+ await expect (
353+ overwriteFileInDirectory ( file , "new" , expected ) ,
354+ ) . rejects . toThrow ( "was replaced while it was being opened" ) ;
355+ expect ( fs . readFileSync ( file , "utf-8" ) ) . toBe ( "original" ) ;
356+ } ) ;
357+
358+ it ( "refuses a known device that differs" , async ( ) => {
359+ const file = path . join ( dir , "f.txt" ) ;
360+ fs . writeFileSync ( file , "original" ) ;
361+ const stats = lstatBig ( file ) ;
362+ const expected = { ...stats , dev : stats . dev + 1n } ;
363+ await expect (
364+ overwriteFileInDirectory ( file , "new" , expected ) ,
365+ ) . rejects . toThrow ( "was replaced while it was being opened" ) ;
366+ } ) ;
367+ } ) ;
368+
369+ it . skipIf ( realPlatform === "win32" ) (
370+ "off Windows, refuses a path stat that reports no device" ,
371+ async ( ) => {
372+ const file = path . join ( dir , "f.txt" ) ;
373+ fs . writeFileSync ( file , "original" ) ;
374+ const expected = { ...lstatBig ( file ) , dev : 0n } ;
375+ await expect (
376+ overwriteFileInDirectory ( file , "new" , expected ) ,
377+ ) . rejects . toThrow ( "was replaced while it was being opened" ) ;
378+ } ,
379+ ) ;
380+
381+ // Checked on the windows-latest leg: the device fallback above exists for
382+ // a Node line this package does not support. If a supported line ever
383+ // reports no device for a path, the fallback becomes the normal path, and
384+ // this fails to say so.
385+ it . skipIf ( realPlatform !== "win32" ) (
386+ "on a real Windows volume, the path stat and the handle agree on a nonzero device and the inode" ,
387+ ( ) => {
388+ const file = path . join ( dir , "f.txt" ) ;
389+ fs . writeFileSync ( file , "original" ) ;
390+ const fd = fs . openSync ( file , "r" ) ;
391+ try {
392+ const handle = fs . fstatSync ( fd , { bigint : true } ) ;
393+ const byPath = lstatBig ( file ) ;
394+ expect ( byPath . dev ) . not . toBe ( 0n ) ;
395+ expect ( byPath . dev ) . toBe ( handle . dev ) ;
396+ expect ( byPath . ino ) . toBe ( handle . ino ) ;
397+ } finally {
398+ fs . closeSync ( fd ) ;
399+ }
400+ } ,
401+ ) ;
402+ } ) ;
403+
283404describe ( "renameIntoDirectory" , ( ) => {
284405 it ( "moves the entry into the destination directory" , ( ) => {
285406 fs . mkdirSync ( path . join ( dir , "sub" ) ) ;
0 commit comments