| … | … | @@ -0,0 +1,128 @@ |
| 1 | /** |
| 2 | * The file list a vdrive is published from must be deterministic. |
| 3 | * |
| 4 | * publishTree writes files in exactly the order it is handed them, and the |
| 5 | * order lands in the drive's merkle tree. Measured on the real path with |
| 6 | * hyper-overlay's chunker, against a real 6-file release: |
| 7 | * |
| 8 | * same bytes, same order -> tree 0997c75b… (identical across stores) |
| 9 | * same bytes, REVERSED order -> tree dacbc54b… (1844 blocks either way) |
| 10 | * |
| 11 | * `walk()` fed that list straight from `readdir`, whose order is filesystem |
| 12 | * defined — hash order on ext4, and not stable across filesystems, inode |
| 13 | * layouts, or a directory rewritten in a different sequence. |
| 14 | * |
| 15 | * Today only one machine ever builds a drive, so nothing notices. The moment a |
| 16 | * second machine builds the same release — the premise of seeding by |
| 17 | * reproduction rather than replication — it silently produces a DIFFERENT |
| 18 | * drive from identical artifacts. Under a content-derived (manifest) key that |
| 19 | * is worse than a mismatch: two machines would claim the same key for |
| 20 | * different content. |
| 21 | * |
| 22 | * hyper-overlay's own tree format already requires this ("Entries MUST be |
| 23 | * sorted byte-wise by name"), so sorting here removes an inconsistency between |
| 24 | * the two layers rather than inventing a rule. |
| 25 | */ |
| 26 | |
| 27 | import test from 'brittle' |
| 28 | import fs from 'fs' |
| 29 | import path from 'path' |
| 30 | import os from 'os' |
| 31 | |
| 32 | import { walkForTest as walk, sortDriveNames } from '../lib/vdrive-publish.js' |
| 33 | |
| 34 | function mktemp () { |
| 35 | return fs.mkdtempSync(path.join(os.tmpdir(), 'vdrive-walk-')) |
| 36 | } |
| 37 | |
| 38 | function isByteSorted (names) { |
| 39 | for (let i = 1; i < names.length; i++) { |
| 40 | if (Buffer.compare(Buffer.from(names[i - 1]), Buffer.from(names[i])) >= 0) return false |
| 41 | } |
| 42 | return true |
| 43 | } |
| 44 | |
| 45 | test('walk returns a byte-wise sorted list regardless of creation order', async (t) => { |
| 46 | const dir = mktemp() |
| 47 | t.teardown(() => fs.rmSync(dir, { recursive: true, force: true })) |
| 48 | |
| 49 | // Created in deliberately reverse-ish order, across nested dirs, so any |
| 50 | // filesystem that reports creation or hash order shows it. |
| 51 | fs.mkdirSync(path.join(dir, 'zeta'), { recursive: true }) |
| 52 | fs.mkdirSync(path.join(dir, 'alpha'), { recursive: true }) |
| 53 | fs.writeFileSync(path.join(dir, 'zzz.txt'), 'z') |
| 54 | fs.writeFileSync(path.join(dir, 'zeta', 'b.bin'), 'zb') |
| 55 | fs.writeFileSync(path.join(dir, 'mmm.txt'), 'm') |
| 56 | fs.writeFileSync(path.join(dir, 'alpha', 'a.bin'), 'aa') |
| 57 | fs.writeFileSync(path.join(dir, 'aaa.txt'), 'a') |
| 58 | |
| 59 | const names = await walk(dir) |
| 60 | |
| 61 | t.is(names.length, 5, 'found every file') |
| 62 | t.ok(isByteSorted(names), 'byte-wise sorted: ' + JSON.stringify(names)) |
| 63 | }) |
| 64 | |
| 65 | test('walk sorts even when readdir does not — many files', async (t) => { |
| 66 | const dir = mktemp() |
| 67 | t.teardown(() => fs.rmSync(dir, { recursive: true, force: true })) |
| 68 | |
| 69 | // A handful of files can come back alphabetical by luck — ext4 returns hash |
| 70 | // order, which only diverges visibly once the directory is large. That luck |
| 71 | // is exactly why this bug stayed invisible: the real 6-file ifsrenderer |
| 72 | // release walks in sorted order on this machine, so nothing looked wrong. |
| 73 | const names = [] |
| 74 | for (let i = 0; i < 200; i++) { |
| 75 | const n = 'file-' + String(i).padStart(3, '0') + '-' + 'abcdefghij'[i % 10] + '.bin' |
| 76 | fs.writeFileSync(path.join(dir, n), 'x') |
| 77 | names.push('/' + n) |
| 78 | } |
| 79 | |
| 80 | // NB: no assertion that readdir is unsorted — ext4's hash order is |
| 81 | // per-directory, so that is luck and made this test flaky. The invariant |
| 82 | // below holds either way; sortDriveNames is tested directly for the case |
| 83 | // the filesystem will not reliably produce. |
| 84 | const walked = await walk(dir) |
| 85 | t.is(walked.length, 200, 'found every file') |
| 86 | t.ok(isByteSorted(walked), 'walk output is sorted despite readdir order') |
| 87 | }) |
| 88 | |
| 89 | test('walk is stable across two calls on the same tree', async (t) => { |
| 90 | const dir = mktemp() |
| 91 | t.teardown(() => fs.rmSync(dir, { recursive: true, force: true })) |
| 92 | |
| 93 | fs.mkdirSync(path.join(dir, 'dist'), { recursive: true }) |
| 94 | fs.mkdirSync(path.join(dir, 'by-arch', 'linux-x64'), { recursive: true }) |
| 95 | fs.writeFileSync(path.join(dir, 'build.log'), 'log') |
| 96 | fs.writeFileSync(path.join(dir, 'dist', 'b.AppImage'), 'B') |
| 97 | fs.writeFileSync(path.join(dir, 'dist', 'a.tar.gz'), 'A') |
| 98 | fs.writeFileSync(path.join(dir, 'by-arch', 'linux-x64', 'app.bin'), 'X') |
| 99 | fs.writeFileSync(path.join(dir, 'package.json'), '{}') |
| 100 | |
| 101 | const first = await walk(dir) |
| 102 | const second = await walk(dir) |
| 103 | |
| 104 | t.alike(second, first, 'two walks agree') |
| 105 | t.ok(isByteSorted(first), 'and are sorted') |
| 106 | |
| 107 | // The property that actually matters: a nested path sorts as a whole path, |
| 108 | // so the order cannot depend on which directory was descended into first. |
| 109 | const expected = [...first].sort((a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b))) |
| 110 | t.alike(first, expected, 'matches an independent byte-wise sort') |
| 111 | }) |
| 112 | |
| 113 | |
| 114 | test('sortDriveNames orders byte-wise, deterministically', (t) => { |
| 115 | // The case the filesystem will not reliably hand us. Deliberately scrambled, |
| 116 | // with nested paths, so ordering cannot depend on descent order. |
| 117 | const scrambled = [ |
| 118 | '/zzz.txt', '/dist/b.AppImage', '/aaa.txt', '/by-arch/linux-x64/app.bin', |
| 119 | '/dist/a.tar.gz', '/manifest.json', '/Build.log' |
| 120 | ] |
| 121 | const sorted = sortDriveNames(scrambled) |
| 122 | |
| 123 | t.ok(isByteSorted(sorted), 'sorted: ' + JSON.stringify(sorted)) |
| 124 | t.is(sorted.length, scrambled.length, 'nothing lost') |
| 125 | t.alike(sortDriveNames([...scrambled].reverse()), sorted, 'input order is irrelevant') |
| 126 | // Uppercase sorts before lowercase byte-wise; a locale-aware sort would not. |
| 127 | t.is(sorted[0], '/Build.log', 'byte-wise, not locale-aware') |
| 128 | }) |