@@ -4,7 +4,13 @@ import { execFile } from "child_process"
44import type { ChildProcess } from "child_process"
55import * as path from "path"
66
7- import { PostCommitDurabilityError , resolveLockKey , safeWriteText , StagingPathError } from "../safeWriteText"
7+ import {
8+ PostCommitDurabilityError ,
9+ resolveLockKey ,
10+ safeWriteText ,
11+ StagingPathError ,
12+ StagingReplacedError ,
13+ } from "../safeWriteText"
814
915// Full mock for fs/promises — all methods are vi.fn() stubs
1016vi . mock ( "fs/promises" , ( ) => ( {
@@ -71,6 +77,37 @@ function _fileStatsWithIdentity(ino: bigint, dev: bigint): fsSync.BigIntStats {
7177 s . dev = dev
7278 return s
7379}
80+ // The same stand-in for a stat that carries no bigint identity: pass numbers or undefined
81+ // here to model a platform whose lstat answer has no bigint ino or dev to compare.
82+ function _statsCarrying ( ino : unknown , dev : unknown ) : fsSync . BigIntStats {
83+ // Double assertion: the runtime value is the Stats stand-in, the type is the bigint
84+ // variant. The fields come from unknown on purpose, so a test can hand over a value
85+ // that is not bigint and watch the comparison decline to run.
86+ const stats = _fileStats ( false ) as unknown as fsSync . BigIntStats
87+ stats . ino = ino as bigint
88+ stats . dev = dev as bigint
89+ return stats
90+ }
91+
92+ // Stands in for fs.lstat and answers only the identity reads - the calls that pass
93+ // { bigint: true } - from a queue, in the order this write performs them: the
94+ // staging guard's file, the target that file must not be, the staged entry once this
95+ // write's content landed, the entry at the commit rename, and the entry that landed on
96+ // the target. Every other read gets a stat without identity. A dry queue fails the test
97+ // loudly instead of passing quietly.
98+ function _lstatIdentityReads ( identityReads : fsSync . BigIntStats [ ] ) {
99+ return async ( readPath : unknown , options ?: unknown ) : Promise < fsSync . BigIntStats > => {
100+ const wantsBigint = typeof options === "object" && options !== null && Reflect . get ( options , "bigint" ) === true
101+ if ( ! wantsBigint ) {
102+ return _statsCarrying ( undefined , undefined )
103+ }
104+ const next = identityReads . shift ( )
105+ if ( next === undefined ) {
106+ throw new Error ( `no identity read left in the queue for ${ String ( readPath ) } ` )
107+ }
108+ return next
109+ }
110+ }
74111
75112function mockDefaults ( ) : void {
76113 vi . resetAllMocks ( )
@@ -1278,6 +1315,219 @@ describe("caller-supplied staging path", () => {
12781315 expect ( fs . rename ) . not . toHaveBeenCalled ( )
12791316 } )
12801317
1318+ it ( "refuses to publish when the caller's staging entry is replaced before the commit rename" , async ( ) => {
1319+ const targetPath = "/tmp/test-dir/target.txt"
1320+ const stagingPath = "/tmp/test-dir/caller-staged.txt"
1321+ vi . mocked ( fs . realpath ) . mockResolvedValue ( targetPath )
1322+ vi . mocked ( fsSync . openSync ) . mockReturnValue ( 1 )
1323+ vi . mocked ( fs . lstat ) . mockImplementation (
1324+ _lstatIdentityReads ( [
1325+ _fileStatsWithIdentity ( 42n , 7n ) ,
1326+ _fileStatsWithIdentity ( 43n , 7n ) ,
1327+ _fileStatsWithIdentity ( 42n , 7n ) ,
1328+ _fileStatsWithIdentity ( 99n , 7n ) ,
1329+ ] ) ,
1330+ )
1331+
1332+ let failure : unknown
1333+ await safeWriteText ( targetPath , "data" , { tempPath : stagingPath , platform : "linux" } ) . catch (
1334+ ( error : unknown ) => {
1335+ failure = error
1336+ } ,
1337+ )
1338+ if ( ! ( failure instanceof StagingReplacedError ) ) {
1339+ throw new Error ( `expected a StagingReplacedError, got ${ String ( failure ) } ` )
1340+ }
1341+ expect ( failure . name ) . toBe ( "StagingReplacedError" )
1342+ expect ( failure . stage ) . toBe ( "before-commit" )
1343+ expect ( failure . message ) . toContain ( stagingPath )
1344+ expect ( failure . message ) . toContain ( "no longer names" )
1345+
1346+ // The publish was skipped, not merely reported as a failure: no rename of any kind ran,
1347+ // so the peer's file was never committed onto the target.
1348+ expect ( fs . rename ) . not . toHaveBeenCalled ( )
1349+ // The write got as far as staging its content, so this is not the early rejection the
1350+ // staging guard performs before any work.
1351+ expect ( fsSync . openSync ) . toHaveBeenCalledTimes ( 1 )
1352+ } )
1353+
1354+ it ( "reports a replaced staging entry when the swap lands on the target through the commit rename" , async ( ) => {
1355+ const targetPath = "/tmp/test-dir/target.txt"
1356+ const stagingPath = "/tmp/test-dir/caller-staged.txt"
1357+ vi . mocked ( fs . realpath ) . mockResolvedValue ( targetPath )
1358+ vi . mocked ( fsSync . openSync ) . mockReturnValue ( 1 )
1359+ vi . mocked ( fs . lstat ) . mockImplementation (
1360+ _lstatIdentityReads ( [
1361+ _fileStatsWithIdentity ( 42n , 7n ) ,
1362+ _fileStatsWithIdentity ( 43n , 7n ) ,
1363+ _fileStatsWithIdentity ( 42n , 7n ) ,
1364+ _fileStatsWithIdentity ( 42n , 7n ) ,
1365+ _fileStatsWithIdentity ( 99n , 7n ) ,
1366+ ] ) ,
1367+ )
1368+
1369+ let failure : unknown
1370+ await safeWriteText ( targetPath , "data" , { tempPath : stagingPath , platform : "linux" } ) . catch (
1371+ ( error : unknown ) => {
1372+ failure = error
1373+ } ,
1374+ )
1375+ if ( ! ( failure instanceof StagingReplacedError ) ) {
1376+ throw new Error ( `expected a StagingReplacedError, got ${ String ( failure ) } ` )
1377+ }
1378+ expect ( failure . name ) . toBe ( "StagingReplacedError" )
1379+ expect ( failure . stage ) . toBe ( "after-commit" )
1380+ expect ( failure . message ) . toContain ( stagingPath )
1381+ expect ( failure . message ) . toContain ( "was replaced before the commit rename landed" )
1382+
1383+ // The swap landed inside the window between the pre-commit comparison and the rename, so
1384+ // the rename ran and moved a file this write did not stage.
1385+ expect ( fs . rename ) . toHaveBeenCalledTimes ( 1 )
1386+ expect ( fs . rename ) . toHaveBeenCalledWith ( stagingPath , targetPath )
1387+ } )
1388+
1389+ it ( "publishes when the entry at the commit rename is still the entry this write staged" , async ( ) => {
1390+ const targetPath = "/tmp/test-dir/target.txt"
1391+ const stagingPath = "/tmp/test-dir/caller-staged.txt"
1392+ vi . mocked ( fs . realpath ) . mockResolvedValue ( targetPath )
1393+ vi . mocked ( fsSync . openSync ) . mockReturnValue ( 1 )
1394+ vi . mocked ( fs . lstat ) . mockImplementation (
1395+ _lstatIdentityReads ( [
1396+ _fileStatsWithIdentity ( 42n , 7n ) ,
1397+ _fileStatsWithIdentity ( 43n , 7n ) ,
1398+ _fileStatsWithIdentity ( 42n , 7n ) ,
1399+ _fileStatsWithIdentity ( 42n , 7n ) ,
1400+ _fileStatsWithIdentity ( 42n , 7n ) ,
1401+ ] ) ,
1402+ )
1403+
1404+ await expect (
1405+ safeWriteText ( targetPath , "data" , { tempPath : stagingPath , platform : "linux" } ) ,
1406+ ) . resolves . toBeUndefined ( )
1407+ expect ( fs . rename ) . toHaveBeenCalledTimes ( 1 )
1408+ expect ( fs . rename ) . toHaveBeenCalledWith ( stagingPath , targetPath )
1409+ } )
1410+
1411+ it ( "refuses to publish when the staged entry and the entry at the commit share an inode but differ in device" , async ( ) => {
1412+ const targetPath = "/tmp/test-dir/target.txt"
1413+ const stagingPath = "/tmp/test-dir/caller-staged.txt"
1414+ vi . mocked ( fs . realpath ) . mockResolvedValue ( targetPath )
1415+ vi . mocked ( fsSync . openSync ) . mockReturnValue ( 1 )
1416+ vi . mocked ( fs . lstat ) . mockImplementation (
1417+ _lstatIdentityReads ( [
1418+ _fileStatsWithIdentity ( 42n , 7n ) ,
1419+ _fileStatsWithIdentity ( 43n , 7n ) ,
1420+ _fileStatsWithIdentity ( 42n , 7n ) ,
1421+ _fileStatsWithIdentity ( 42n , 8n ) ,
1422+ ] ) ,
1423+ )
1424+
1425+ let failure : unknown
1426+ await safeWriteText ( targetPath , "data" , { tempPath : stagingPath , platform : "linux" } ) . catch (
1427+ ( error : unknown ) => {
1428+ failure = error
1429+ } ,
1430+ )
1431+ if ( ! ( failure instanceof StagingReplacedError ) ) {
1432+ throw new Error ( `expected a StagingReplacedError, got ${ String ( failure ) } ` )
1433+ }
1434+ expect ( failure . name ) . toBe ( "StagingReplacedError" )
1435+ expect ( failure . stage ) . toBe ( "before-commit" )
1436+ expect ( failure . message ) . toContain ( stagingPath )
1437+ expect ( failure . message ) . toContain ( "no longer names" )
1438+
1439+ // Same inode on a different device is a different file: comparing inodes alone would publish it.
1440+ } )
1441+
1442+ it ( "skips the comparison when the staged entry's stats carry no bigint device" , async ( ) => {
1443+ const targetPath = "/tmp/test-dir/target.txt"
1444+ const stagingPath = "/tmp/test-dir/caller-staged.txt"
1445+ vi . mocked ( fs . realpath ) . mockResolvedValue ( targetPath )
1446+ vi . mocked ( fsSync . openSync ) . mockReturnValue ( 1 )
1447+ vi . mocked ( fs . lstat ) . mockImplementation (
1448+ _lstatIdentityReads ( [
1449+ _statsCarrying ( 42n , 7 ) ,
1450+ _statsCarrying ( 43n , 7 ) ,
1451+ _statsCarrying ( 42n , 7 ) ,
1452+ _statsCarrying ( 42n , 8 ) ,
1453+ ] ) ,
1454+ )
1455+
1456+ await expect (
1457+ safeWriteText ( targetPath , "data" , { tempPath : stagingPath , platform : "linux" } ) ,
1458+ ) . resolves . toBeUndefined ( )
1459+ expect ( fs . rename ) . toHaveBeenCalledTimes ( 1 )
1460+ expect ( fs . rename ) . toHaveBeenCalledWith ( stagingPath , targetPath )
1461+
1462+ // Nothing bigint to compare, so this is the same reading the staging-alias guard already applies.
1463+ } )
1464+
1465+ it ( "skips the comparison when the staged entry's stats carry no bigint inode" , async ( ) => {
1466+ const targetPath = "/tmp/test-dir/target.txt"
1467+ const stagingPath = "/tmp/test-dir/caller-staged.txt"
1468+ vi . mocked ( fs . realpath ) . mockResolvedValue ( targetPath )
1469+ vi . mocked ( fsSync . openSync ) . mockReturnValue ( 1 )
1470+ vi . mocked ( fs . lstat ) . mockImplementation (
1471+ _lstatIdentityReads ( [
1472+ _statsCarrying ( 42 , 7n ) ,
1473+ _statsCarrying ( 43 , 7n ) ,
1474+ _statsCarrying ( 42 , 7n ) ,
1475+ _statsCarrying ( 43 , 7n ) ,
1476+ ] ) ,
1477+ )
1478+
1479+ await expect (
1480+ safeWriteText ( targetPath , "data" , { tempPath : stagingPath , platform : "linux" } ) ,
1481+ ) . resolves . toBeUndefined ( )
1482+ expect ( fs . rename ) . toHaveBeenCalledTimes ( 1 )
1483+ expect ( fs . rename ) . toHaveBeenCalledWith ( stagingPath , targetPath )
1484+ } )
1485+
1486+ it ( "publishes when the entry at the commit rename cannot be compared" , async ( ) => {
1487+ const targetPath = "/tmp/test-dir/target.txt"
1488+ const stagingPath = "/tmp/test-dir/caller-staged.txt"
1489+ vi . mocked ( fs . realpath ) . mockResolvedValue ( targetPath )
1490+ vi . mocked ( fsSync . openSync ) . mockReturnValue ( 1 )
1491+ vi . mocked ( fs . lstat ) . mockImplementation (
1492+ _lstatIdentityReads ( [
1493+ _fileStatsWithIdentity ( 42n , 7n ) ,
1494+ _fileStatsWithIdentity ( 43n , 7n ) ,
1495+ _fileStatsWithIdentity ( 42n , 7n ) ,
1496+ _statsCarrying ( undefined , undefined ) ,
1497+ _statsCarrying ( undefined , undefined ) ,
1498+ ] ) ,
1499+ )
1500+
1501+ await expect (
1502+ safeWriteText ( targetPath , "data" , { tempPath : stagingPath , platform : "linux" } ) ,
1503+ ) . resolves . toBeUndefined ( )
1504+ expect ( fs . rename ) . toHaveBeenCalledTimes ( 1 )
1505+ expect ( fs . rename ) . toHaveBeenCalledWith ( stagingPath , targetPath )
1506+
1507+ // The guard declines rather than guessing: an unreadable comparison must not refuse a sound write.
1508+ } )
1509+
1510+ it ( "publishes when the staged entry carries no identity but the entry at the commit does" , async ( ) => {
1511+ const targetPath = "/tmp/test-dir/target.txt"
1512+ const stagingPath = "/tmp/test-dir/caller-staged.txt"
1513+ vi . mocked ( fs . realpath ) . mockResolvedValue ( targetPath )
1514+ vi . mocked ( fsSync . openSync ) . mockReturnValue ( 1 )
1515+ vi . mocked ( fs . lstat ) . mockImplementation (
1516+ _lstatIdentityReads ( [
1517+ _statsCarrying ( undefined , undefined ) ,
1518+ _statsCarrying ( 43n , 7n ) ,
1519+ _statsCarrying ( undefined , undefined ) ,
1520+ _fileStatsWithIdentity ( 99n , 7n ) ,
1521+ _fileStatsWithIdentity ( 99n , 7n ) ,
1522+ ] ) ,
1523+ )
1524+
1525+ await expect (
1526+ safeWriteText ( targetPath , "data" , { tempPath : stagingPath , platform : "linux" } ) ,
1527+ ) . resolves . toBeUndefined ( )
1528+ expect ( fs . rename ) . toHaveBeenCalledTimes ( 1 )
1529+ expect ( fs . rename ) . toHaveBeenCalledWith ( stagingPath , targetPath )
1530+ } )
12811531 it ( "rejects a staging path that is a symlink" , async ( ) => {
12821532 const targetPath = "/tmp/test-dir/target.txt"
12831533 vi . mocked ( fs . realpath ) . mockResolvedValue ( targetPath )
0 commit comments