Files
ECC/tests/lib/guarded-write.test.js
T

224 lines
11 KiB
JavaScript

/** Focused descriptor-bound writes; every filesystem fixture is private. */
'use strict';
const assert = require('assert');
const crypto = require('crypto');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { writeFileNoFollow } = require('../../scripts/lib/install/guarded-write');
const digest = value => crypto.createHash('sha256').update(value).digest('hex');
let passed = 0;
let failed = 0;
function test(name, callback) {
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-guarded-')));
const file = path.join(root, 'entry.js');
const options = { action: 'replace activation', validateDestination: value => value };
try { callback({ root, file, options }); passed++; console.log(` PASS ${name}`); }
catch (error) { failed++; console.error(` FAIL ${name}: ${error.stack}`); }
finally { fs.rmSync(root, { recursive: true, force: true }); }
}
function replaceMethod(name, replacement, callback) {
const original = fs[name];
fs[name] = replacement(original);
try { callback(); } finally { fs[name] = original; }
}
function existing(options, content = 'old activation bytes') {
return { ...options, expectedContent: Object.freeze({ kind: 'sha256', digest: digest(content) }) };
}
test('hashing then writing shorter bytes starts at zero and preserves the requested mode', ({ file, options }) => {
fs.writeFileSync(file, 'old activation bytes');
writeFileNoFollow(file, 'off\n', { ...existing(options), mode: 0o600 });
assert.deepStrictEqual(fs.readFileSync(file), Buffer.from('off\n'));
assert.strictEqual(fs.statSync(file).size, 4);
if (process.platform !== 'win32') assert.strictEqual(fs.statSync(file).mode & 0o777, 0o600);
});
test('short writes are completed at explicit positions', ({ file, options }) => {
const positions = [];
replaceMethod('writeSync', original => (fd, buffer, offset, length, position) => {
positions.push(position);
return original(fd, buffer, offset, Math.min(length, 2), position);
}, () => writeFileNoFollow(file, 'abcdef', options));
assert.deepStrictEqual(positions, [0, 2, 4]);
assert.strictEqual(fs.readFileSync(file, 'utf8'), 'abcdef');
});
test('expected absence creates a new exact file', ({ file, options }) => {
writeFileNoFollow(file, Buffer.from('inert'), { ...options, expectedContent: { kind: 'absent' } });
assert.strictEqual(fs.readFileSync(file, 'utf8'), 'inert');
});
test('a competing file before exclusive open is preserved', ({ file, options }) => {
replaceMethod('openSync', original => (...args) => {
if (args[0] === file && typeof args[1] === 'number') fs.writeFileSync(file, 'user bytes');
return original(...args);
}, () => assert.throws(() => writeFileNoFollow(file, 'off', {
...options, expectedContent: { kind: 'absent' },
}), /changed after preflight/));
assert.strictEqual(fs.readFileSync(file, 'utf8'), 'user bytes');
});
test('existing content removed before open is never recreated', ({ file, options }) => {
fs.writeFileSync(file, 'old activation bytes');
replaceMethod('openSync', original => (...args) => {
if (args[0] === file) fs.unlinkSync(file);
return original(...args);
}, () => assert.throws(() => writeFileNoFollow(file, 'off', existing(options)), /changed after preflight/));
assert.strictEqual(fs.existsSync(file), false);
});
test('same-inode edit at the writable-open boundary refuses before truncate and closes once', ({ file, options }) => {
fs.writeFileSync(file, 'old activation bytes');
let descriptor;
let closes = 0;
let truncates = 0;
const originalOpen = fs.openSync;
const originalClose = fs.closeSync;
const originalTruncate = fs.ftruncateSync;
fs.openSync = (...args) => {
const fd = originalOpen(...args);
if (args[0] === file && typeof args[1] === 'number') {
descriptor = fd;
fs.writeFileSync(file, 'user edited activation');
}
return fd;
};
fs.closeSync = fd => { if (fd === descriptor) closes++; return originalClose(fd); };
fs.ftruncateSync = (...args) => { truncates++; return originalTruncate(...args); };
try { assert.throws(() => writeFileNoFollow(file, 'off', existing(options)), /changed after preflight/); }
finally { fs.openSync = originalOpen; fs.closeSync = originalClose; fs.ftruncateSync = originalTruncate; }
assert.strictEqual(closes, 1);
assert.strictEqual(truncates, 0);
assert.strictEqual(fs.readFileSync(file, 'utf8'), 'user edited activation');
});
test('a descriptor read concurrent edit is observed before truncation', ({ file, options }) => {
fs.writeFileSync(file, 'old activation bytes');
replaceMethod('readFileSync', original => (...args) => {
const result = original(...args);
if (typeof args[0] === 'number') fs.writeFileSync(file, 'raced during hash');
return result;
}, () => assert.throws(() => writeFileNoFollow(file, 'off', existing(options)), /changed after preflight/));
assert.strictEqual(fs.readFileSync(file, 'utf8'), 'raced during hash');
});
test('a pathname replacement after opening preserves both original and replacement', ({ file, options }) => {
fs.writeFileSync(file, 'old activation bytes');
replaceMethod('openSync', original => (...args) => {
const fd = original(...args);
if (args[0] === file && typeof args[1] === 'number') {
fs.renameSync(file, `${file}.old`);
fs.writeFileSync(file, 'replacement');
}
return fd;
}, () => assert.throws(() => writeFileNoFollow(file, 'off', existing(options)), /changed/));
assert.strictEqual(fs.readFileSync(file, 'utf8'), 'replacement');
assert.strictEqual(fs.readFileSync(`${file}.old`, 'utf8'), 'old activation bytes');
});
test('a directory destination is refused without writes', ({ file, options }) => {
fs.mkdirSync(file);
assert.throws(() => writeFileNoFollow(file, 'off', options));
assert.ok(fs.statSync(file).isDirectory());
});
test('a parent replacement at open is refused', ({ root, options }) => {
const parent = path.join(root, 'plugins');
fs.mkdirSync(parent);
const file = path.join(parent, 'entry.js');
fs.writeFileSync(file, 'old activation bytes');
replaceMethod('openSync', original => (...args) => {
const fd = original(...args);
if (args[0] === file && typeof args[1] === 'number') {
fs.renameSync(parent, `${parent}.old`);
fs.mkdirSync(parent);
fs.writeFileSync(file, 'replacement');
}
return fd;
}, () => assert.throws(() => writeFileNoFollow(file, 'off', existing(options)), /changed/));
assert.strictEqual(fs.readFileSync(file, 'utf8'), 'replacement');
});
test('destination validator is mandatory and must return the same path', ({ file, options }) => {
assert.throws(() => writeFileNoFollow(file, 'off', {}), /validateDestination/);
assert.throws(() => writeFileNoFollow(file, 'off', { ...options, validateDestination: () => `${file}.other` }), /changed/);
assert.strictEqual(fs.existsSync(file), false);
});
test('destination validation runs again after the descriptor hash', ({ file, options }) => {
fs.writeFileSync(file, 'old activation bytes');
let validations = 0;
assert.throws(() => writeFileNoFollow(file, 'off', {
...existing(options), validateDestination: value => {
validations++;
if (validations === 3) throw new Error('containment changed');
return value;
},
}), /containment changed/);
assert.strictEqual(fs.readFileSync(file, 'utf8'), 'old activation bytes');
});
for (const method of ['readFileSync', 'ftruncateSync', 'writeSync', 'fchmodSync']) {
test(`${method} failure closes once and preserves the primary error if close also fails`, ({ file, options }) => {
fs.writeFileSync(file, 'old activation bytes');
const primary = new Error(`${method} primary`);
const secondary = new Error('close secondary');
const original = fs[method];
const close = fs.closeSync;
let closes = 0;
fs[method] = (...args) => { if (typeof args[0] === 'number') throw primary; return original(...args); };
fs.closeSync = fd => { closes++; close(fd); throw secondary; };
try {
assert.throws(() => writeFileNoFollow(file, 'off', { ...existing(options), mode: 0o600 }), error => {
assert.strictEqual(error, primary);
assert.strictEqual(error.closeError, secondary);
return true;
});
} finally { fs[method] = original; fs.closeSync = close; }
assert.strictEqual(closes, 1);
});
}
test('close failure after a successful write is reported', ({ file, options }) => {
replaceMethod('closeSync', original => fd => { original(fd); throw new Error('close failed'); }, () => {
assert.throws(() => writeFileNoFollow(file, 'off', options), /close failed/);
});
assert.strictEqual(fs.readFileSync(file, 'utf8'), 'off');
});
test('zero-progress writes refuse instead of spinning', ({ file, options }) => {
replaceMethod('writeSync', () => () => 0, () => assert.throws(() => writeFileNoFollow(file, 'off', options), /progress/));
});
test('malformed expected-content guards fail before opening a destination', ({ file, options }) => {
for (const expectedContent of [null, true, { kind: 'sha256', digest: 'bad' }, { kind: 'missing' }]) {
assert.throws(() => writeFileNoFollow(file, 'off', { ...options, expectedContent }), /expectedContent/);
}
assert.strictEqual(fs.existsSync(file), false);
});
test('a new exclusive file changed through the owned descriptor is preserved', ({ file, options }) => {
replaceMethod('openSync', original => (...args) => {
const fd = original(...args);
if (args[0] === file && typeof args[1] === 'number') fs.writeSync(fd, Buffer.from('raced creation'), 0, 14, 0);
return fd;
}, () => assert.throws(() => writeFileNoFollow(file, 'off', {
...options, expectedContent: { kind: 'absent' },
}), /changed after preflight/));
assert.strictEqual(fs.readFileSync(file, 'utf8'), 'raced creation');
});
test('the final pathname must remain a regular non-symlink even without O_NOFOLLOW support', ({ file, options }) => {
fs.writeFileSync(file, 'old activation bytes');
replaceMethod('lstatSync', original => (...args) => {
const stats = original(...args);
if (args[0] === file) {
const simulatedSymlink = Object.create(stats);
simulatedSymlink.isSymbolicLink = () => true;
return simulatedSymlink;
}
return stats;
}, () => assert.throws(() => writeFileNoFollow(file, 'off', existing(options)), /changed/));
assert.strictEqual(fs.readFileSync(file, 'utf8'), 'old activation bytes');
});
test('the immutable preflight digest cannot be changed by a validator callback', ({ file, options }) => {
fs.writeFileSync(file, 'user bytes');
const expectedContent = { kind: 'sha256', digest: digest('old bytes') };
assert.throws(() => writeFileNoFollow(file, 'off', {
...options, expectedContent, validateDestination: value => {
expectedContent.digest = digest('user bytes');
return value;
},
}), /changed after preflight/);
assert.strictEqual(fs.readFileSync(file, 'utf8'), 'user bytes');
});
test('unexpected input fails without creating a file', ({ file, options }) => {
assert.throws(() => writeFileNoFollow(file, {}, options), /content/);
assert.strictEqual(fs.existsSync(file), false);
});
console.log(`Results: Passed: ${passed}, Failed: ${failed}`);
process.exitCode = failed ? 1 : 0;