The reader disagreed with itself about how wide a pointer is inside an indirect block.
// SysVReader.EnumerateIndirectPointers — the layout walk
public const int IndirectPointerBytes = 4;
var pointer = BinaryPrimitives.ReadUInt32LittleEndian(...);
// SysVReader.ReadIndirect — the extraction path
var ptrsPerBlock = this.BlockSize / 3; // 24-bit ptrs
var ptr = Read24(this._data.AsSpan((int)offset + i * 3));
An s5fs inode squeezes thirteen block numbers into thirty-nine bytes, three each. The blocks below it hold plain four-byte little-endian words. The extraction path read those three at a time, so everything past the first pointer of an indirect block resolved to the wrong block, and a file came back correct through its tenth block and wrong from the eleventh on.
What settled it
Not a document — the kernel. A volume written with four-byte indirect pointers:
$ sudo mount -t sysv -o loop,ro v.img m
$ md5sum m/*
4b4e0da58aa21d56260eb08d9cabb4ef m/A.BIN <- matches the source
f4b761342e24d7e167bfb85906ed1ca4 m/B.BIN <- matches
374e535fb9f335816b1c744a586b32ba m/C.BIN <- matches
Our own reader gave zeros from offset 11264 on for the same volume. The driver read it perfectly.
The hole spans beside those calls had the same mistake — an absent indirect root stood for BlockSize / 3 blocks of hole rather than BlockSize / 4.
Why it was never seen
Our writers refused to produce a file large enough to need an indirect block at all: SysV and Xenix capped at 10 240 bytes, MinixFs at 7 168. So the whole indirect path was reachable only by a volume from a real System V or Xenix machine, and nothing here had one. Two halves of one reader disagreed for as long as neither was exercised.
Fixed in SysVReader and XenixReader. CoherentReader is deliberately left alone: its writer emits three-byte pointers in indirect blocks and its reader and in-place modifier agree with it, there is no kernel driver or fsck for Coherent on this machine, and changing it on the strength of a SysV result would be a guess dressed as a fix.
Related, in the same place
SysVBlockMover and XenixBlockMover found the run to repoint by its starting offset alone:
if (owner == null || namedAt < 0 || runOffset != oldOffset) continue;
Once a file can span more than its inode's ten pointers, a defragmentation can put another file where this one had been, and two files of one length are then told apart by nothing — the pointers of whichever turned up first got rewritten. Xenix after ConsolidateAtEnd: 'SAME1.BIN' came back holding 'SAME5.BIN's bytes. The run is now matched by owner as well as offset.
The reader disagreed with itself about how wide a pointer is inside an indirect block.
An s5fs inode squeezes thirteen block numbers into thirty-nine bytes, three each. The blocks below it hold plain four-byte little-endian words. The extraction path read those three at a time, so everything past the first pointer of an indirect block resolved to the wrong block, and a file came back correct through its tenth block and wrong from the eleventh on.
What settled it
Not a document — the kernel. A volume written with four-byte indirect pointers:
Our own reader gave zeros from offset 11264 on for the same volume. The driver read it perfectly.
The hole spans beside those calls had the same mistake — an absent indirect root stood for
BlockSize / 3blocks of hole rather thanBlockSize / 4.Why it was never seen
Our writers refused to produce a file large enough to need an indirect block at all: SysV and Xenix capped at 10 240 bytes, MinixFs at 7 168. So the whole indirect path was reachable only by a volume from a real System V or Xenix machine, and nothing here had one. Two halves of one reader disagreed for as long as neither was exercised.
Fixed in
SysVReaderandXenixReader.CoherentReaderis deliberately left alone: its writer emits three-byte pointers in indirect blocks and its reader and in-place modifier agree with it, there is no kernel driver orfsckfor Coherent on this machine, and changing it on the strength of a SysV result would be a guess dressed as a fix.Related, in the same place
SysVBlockMoverandXenixBlockMoverfound the run to repoint by its starting offset alone:Once a file can span more than its inode's ten pointers, a defragmentation can put another file where this one had been, and two files of one length are then told apart by nothing — the pointers of whichever turned up first got rewritten.
Xenix after ConsolidateAtEnd: 'SAME1.BIN' came back holding 'SAME5.BIN's bytes. The run is now matched by owner as well as offset.