leerness 1.36.176 → 1.36.178
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/README.md +4 -4
- package/bin/leerness.js +47 -77
- package/lib/drift.js +525 -360
- package/lib/health.js +7 -4
- package/lib/io.js +242 -1
- package/lib/session-close.js +47 -16
- package/lib/shell-encoding.js +175 -0
- package/package.json +4 -4
- package/scripts/e2e-core.js +14 -3
- package/scripts/e2e.js +14 -8
- package/scripts/mutation-integrity-probe.js +536 -0
package/scripts/e2e.js
CHANGED
|
@@ -6222,24 +6222,30 @@ total++;
|
|
|
6222
6222
|
if (!ok) failed++;
|
|
6223
6223
|
}
|
|
6224
6224
|
|
|
6225
|
-
// 1.9.409 회귀 (8번째 버그헌트, UR-0113): env encoding-check --apply
|
|
6225
|
+
// 1.9.409 회귀 (8번째 버그헌트, UR-0113): env encoding-check --apply 는 .sh/shebang 을 byte-exact 보존한다.
|
|
6226
|
+
// 1.36.177 데이터 무결성 계약: .ps1 BOM 교체는 Windows 에서만 메타데이터 보존 writer 로 수행하고,
|
|
6227
|
+
// POSIX 에서는 inode 교체가 ACL/xattr/ownership 을 잃을 수 있어 byte-exact no-op 한다.
|
|
6226
6228
|
total++;
|
|
6227
6229
|
{
|
|
6228
6230
|
let ok = false;
|
|
6229
6231
|
try {
|
|
6230
6232
|
const d = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-bomsh-'));
|
|
6231
6233
|
cp.spawnSync(process.execPath, [CLI, 'init', d, '--yes', '--language', 'ko'], { encoding: 'utf8', timeout: 30000 });
|
|
6232
|
-
|
|
6233
|
-
|
|
6234
|
-
|
|
6234
|
+
const shBefore = Buffer.from('#!/bin/bash\n# 한글 주석\necho hi\n', 'utf8');
|
|
6235
|
+
const psBefore = Buffer.from('# UTF-8 only 😀\nWrite-Host hi\n', 'utf8');
|
|
6236
|
+
fs.writeFileSync(path.join(d, 'script.sh'), shBefore);
|
|
6237
|
+
fs.writeFileSync(path.join(d, 'script.ps1'), psBefore);
|
|
6238
|
+
const applied = cp.spawnSync(process.execPath, [CLI, 'env', 'encoding-check', '--path', d, '--apply'], { encoding: 'utf8', timeout: 15000 });
|
|
6235
6239
|
const sh = fs.readFileSync(path.join(d, 'script.sh'));
|
|
6236
6240
|
const ps = fs.readFileSync(path.join(d, 'script.ps1'));
|
|
6237
|
-
const shOk = sh
|
|
6238
|
-
const psOk =
|
|
6241
|
+
const shOk = sh.equals(shBefore); // '#!' 및 전체 바이트 보존, BOM 없음
|
|
6242
|
+
const psOk = process.platform === 'win32'
|
|
6243
|
+
? ps.subarray(0, 3).equals(Buffer.from([0xEF, 0xBB, 0xBF])) && ps.subarray(3).equals(psBefore)
|
|
6244
|
+
: ps.equals(psBefore);
|
|
6239
6245
|
fs.rmSync(d, { recursive: true, force: true });
|
|
6240
|
-
ok = shOk && psOk;
|
|
6246
|
+
ok = applied.status === 0 && shOk && psOk;
|
|
6241
6247
|
} catch {}
|
|
6242
|
-
console.log(ok ? '✓ B(1.9.409)
|
|
6248
|
+
console.log(ok ? '✓ B(1.9.409/1.36.177) encoding-check --apply: .sh byte-exact + .ps1 platform-safe mutation contract (UR-0113/T-0022)' : '✗ encoding-check BOM platform contract 실패');
|
|
6243
6249
|
if (!ok) failed++;
|
|
6244
6250
|
}
|
|
6245
6251
|
|
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// T-0022: destructive shell-encoding repair must fail closed when encoding
|
|
5
|
+
// provenance, file identity, metadata, or concurrent-writer state is uncertain.
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const os = require('os');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
const cp = require('child_process');
|
|
10
|
+
|
|
11
|
+
const CLI = path.join(__dirname, '..', 'bin', 'leerness.js');
|
|
12
|
+
const BOM = Buffer.from([0xEF, 0xBB, 0xBF]);
|
|
13
|
+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-mutation-integrity-'));
|
|
14
|
+
const env = {
|
|
15
|
+
...process.env,
|
|
16
|
+
LEERNESS_INTERNAL: '1',
|
|
17
|
+
LEERNESS_NO_BANNER: '1',
|
|
18
|
+
LEERNESS_NO_PROMPT: '1',
|
|
19
|
+
LEERNESS_NO_AUTOCHCP: '1',
|
|
20
|
+
LEERNESS_OFFLINE: '1',
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function run(args, cwd = tempRoot) {
|
|
24
|
+
return cp.spawnSync(process.execPath, [CLI, ...args], {
|
|
25
|
+
cwd,
|
|
26
|
+
env,
|
|
27
|
+
encoding: 'utf8',
|
|
28
|
+
timeout: 120000,
|
|
29
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function check(ok, label, detail = '') {
|
|
34
|
+
const line = `${ok ? 'PASS' : 'FAIL'} ${label}${!ok && detail ? `: ${detail}` : ''}\n`;
|
|
35
|
+
(ok ? process.stdout : process.stderr).write(line);
|
|
36
|
+
return !!ok;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function bomAddedExactly(before, after) {
|
|
40
|
+
return after.length === before.length + BOM.length
|
|
41
|
+
&& after.subarray(0, BOM.length).equals(BOM)
|
|
42
|
+
&& after.subarray(BOM.length).equals(before);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function init(dir) {
|
|
46
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
47
|
+
const result = run(['init', dir, '--yes', '--language', 'en', '--no-stale-check', '--no-banner', '--json']);
|
|
48
|
+
if (result.status !== 0) throw new Error(`fixture init failed (${result.status}): ${(result.stdout || '')}${(result.stderr || '')}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function recoveryDirs(dir) {
|
|
52
|
+
const parents = [dir, path.join(dir, '.leerness', 'archive', 'mutation-recovery')];
|
|
53
|
+
return parents.flatMap(parent => {
|
|
54
|
+
try {
|
|
55
|
+
return fs.readdirSync(parent, { withFileTypes: true })
|
|
56
|
+
.filter(e => e.isDirectory() && e.name.startsWith('.leerness-write-'))
|
|
57
|
+
.map(e => path.join(parent, e.name));
|
|
58
|
+
} catch { return []; }
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function cleanupRecovery(paths) {
|
|
63
|
+
for (const p of new Set(paths.filter(Boolean))) {
|
|
64
|
+
const resolved = path.resolve(p);
|
|
65
|
+
if (!resolved.startsWith(path.resolve(tempRoot) + path.sep)) throw new Error(`unsafe recovery cleanup: ${resolved}`);
|
|
66
|
+
const target = fs.statSync(resolved).isDirectory() ? resolved : path.dirname(resolved);
|
|
67
|
+
fs.rmSync(target, { recursive: true, force: true });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
let passed = true;
|
|
72
|
+
try {
|
|
73
|
+
const commands = [
|
|
74
|
+
{ name: 'env-encoding-apply', json: true, args: dir => ['env', 'encoding-check', '--apply', '--path', dir, '--json'] },
|
|
75
|
+
{ name: 'drift-auto-fix', json: true, args: dir => ['drift', 'check', '--auto-fix', '--path', dir, '--json'] },
|
|
76
|
+
{
|
|
77
|
+
name: 'drift-auto-fix-with-security',
|
|
78
|
+
json: true,
|
|
79
|
+
beforeRun: dir => {
|
|
80
|
+
fs.writeFileSync(path.join(dir, '.env'), 'TOKEN=value\n');
|
|
81
|
+
fs.writeFileSync(path.join(dir, '.gitignore'), 'node_modules/\n');
|
|
82
|
+
},
|
|
83
|
+
args: dir => ['drift', 'check', '--auto-fix', '--path', dir, '--json'],
|
|
84
|
+
},
|
|
85
|
+
{ name: 'session-auto-fix-encoding', json: false, args: dir => ['session', 'close', '--auto-fix-encoding', '--path', dir] },
|
|
86
|
+
{ name: 'session-auto-fix-encoding-json', json: true, args: dir => ['session', 'close', '--auto-fix-encoding', '--path', dir, '--json'] },
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
for (const command of commands) {
|
|
90
|
+
const dir = path.join(tempRoot, command.name);
|
|
91
|
+
init(dir);
|
|
92
|
+
const fixtures = {
|
|
93
|
+
'legacy-cp949.ps1': Buffer.concat([Buffer.from('Write-Host "', 'ascii'), Buffer.from([0xBE, 0xC8, 0xB3, 0xE7]), Buffer.from('"\r\n', 'ascii')]),
|
|
94
|
+
'ambiguous.ps1': Buffer.from([0x41, 0xC2, 0xA1, 0x42]), // UTF-8 A¡B, CP949 A징B
|
|
95
|
+
'corrupt-bom.ps1': Buffer.concat([BOM, Buffer.from([0xBE, 0xC8, 0xB3, 0xE7])]),
|
|
96
|
+
'shebang.sh': Buffer.from('#!/bin/sh\nprintf "안녕\\n"\n', 'utf8'),
|
|
97
|
+
'batch.cmd': Buffer.from('@echo off\r\necho 안녕\r\n', 'utf8'),
|
|
98
|
+
'batch.bat': Buffer.from('@echo off\r\necho 안녕\r\n', 'utf8'),
|
|
99
|
+
// Emoji contains a CP949-invalid 0x80 trail byte, providing explicit
|
|
100
|
+
// byte-level evidence that this body is not a valid legacy Korean file.
|
|
101
|
+
'safe.ps1': Buffer.from('Write-Host "😀"\r\n', 'utf8'),
|
|
102
|
+
};
|
|
103
|
+
for (const [name, body] of Object.entries(fixtures)) fs.writeFileSync(path.join(dir, name), body);
|
|
104
|
+
|
|
105
|
+
const adsBody = '[ZoneTransfer]\r\nZoneId=3';
|
|
106
|
+
let adsSupported = false;
|
|
107
|
+
if (process.platform === 'win32') {
|
|
108
|
+
try { fs.writeFileSync(path.join(dir, 'safe.ps1') + ':Zone.Identifier', adsBody); adsSupported = true; } catch {}
|
|
109
|
+
}
|
|
110
|
+
if (command.beforeRun) command.beforeRun(dir);
|
|
111
|
+
|
|
112
|
+
const result = run(command.args(dir), dir);
|
|
113
|
+
passed = check(result.status === 0, `${command.name}: command succeeds`, `exit=${result.status} ${(result.stderr || '').slice(0, 240)}`) && passed;
|
|
114
|
+
let json = null;
|
|
115
|
+
if (command.json) {
|
|
116
|
+
try { json = JSON.parse(result.stdout); } catch {}
|
|
117
|
+
passed = check(!!json, `${command.name}: stdout is one valid JSON document`, (result.stdout || '').slice(0, 180)) && passed;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
for (const name of Object.keys(fixtures).filter(name => name !== 'safe.ps1')) {
|
|
121
|
+
const after = fs.readFileSync(path.join(dir, name));
|
|
122
|
+
passed = check(after.equals(fixtures[name]), `${command.name}: ${name} remains byte-exact`) && passed;
|
|
123
|
+
}
|
|
124
|
+
const safeAfter = fs.readFileSync(path.join(dir, 'safe.ps1'));
|
|
125
|
+
passed = check(
|
|
126
|
+
process.platform === 'win32' ? bomAddedExactly(fixtures['safe.ps1'], safeAfter) : safeAfter.equals(fixtures['safe.ps1']),
|
|
127
|
+
`${command.name}: platform mutation contract is exact`,
|
|
128
|
+
) && passed;
|
|
129
|
+
|
|
130
|
+
const retained = recoveryDirs(dir);
|
|
131
|
+
passed = check(retained.length === (process.platform === 'win32' ? 1 : 0), `${command.name}: displaced original retention is explicit`, `dirs=${retained.length}`) && passed;
|
|
132
|
+
if (process.platform === 'win32' && retained[0]) {
|
|
133
|
+
const backup = path.join(retained[0], 'before');
|
|
134
|
+
passed = check(fs.readFileSync(backup).equals(fixtures['safe.ps1']), `${command.name}: retained original is byte-exact`) && passed;
|
|
135
|
+
if (adsSupported) {
|
|
136
|
+
let adsAfter = null;
|
|
137
|
+
try { adsAfter = fs.readFileSync(path.join(dir, 'safe.ps1') + ':Zone.Identifier', 'utf8'); } catch {}
|
|
138
|
+
passed = check(adsAfter === adsBody, `${command.name}: target ADS remains byte-exact`) && passed;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const expectedAdded = process.platform === 'win32' ? 1 : 0;
|
|
143
|
+
const expectedSkipped = process.platform === 'win32' ? 6 : 7;
|
|
144
|
+
if (command.name === 'env-encoding-apply') {
|
|
145
|
+
passed = check(json && json.atRisk.length === 7 && json.scanErrors.length === 0
|
|
146
|
+
&& json.applied.filter(x => x.action === 'utf8-bom-added').length === expectedAdded
|
|
147
|
+
&& json.applied.filter(x => x.action.startsWith('skipped-')).length === expectedSkipped,
|
|
148
|
+
`${command.name}: JSON reports every mutation decision`, JSON.stringify(json && json.applied)) && passed;
|
|
149
|
+
}
|
|
150
|
+
if (command.name.startsWith('drift-auto-fix')) {
|
|
151
|
+
const summary = json && json.encodingAutoFix;
|
|
152
|
+
const all = json && json.autoFixSummary;
|
|
153
|
+
passed = check(summary && summary.atRisk === 7 && summary.added === expectedAdded
|
|
154
|
+
&& summary.skipped === expectedSkipped && summary.failed === 0 && summary.results.length === 7,
|
|
155
|
+
`${command.name}: encoding JSON is honest`, JSON.stringify(summary)) && passed;
|
|
156
|
+
passed = check(all && all.requested === true && all.failed === 0 && all.stages.encoding
|
|
157
|
+
&& all.stages.delivered && all.stages.idempotency && all.stages.releaseCleanup && all.stages.session,
|
|
158
|
+
`${command.name}: every auto-fix stage has structured JSON`, JSON.stringify(all)) && passed;
|
|
159
|
+
if (command.name.endsWith('security')) {
|
|
160
|
+
passed = check(all.stages.security.status === 'applied', `${command.name}: security repair is recorded`) && passed;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (command.name === 'session-auto-fix-encoding-json') {
|
|
164
|
+
const summary = json && json.encodingAutoFix;
|
|
165
|
+
passed = check(summary && summary.added === expectedAdded && summary.skipped === expectedSkipped
|
|
166
|
+
&& summary.failed === 0 && summary.scanErrors.length === 0,
|
|
167
|
+
`${command.name}: JSON reports applied/skipped/failed honestly`, JSON.stringify(summary)) && passed;
|
|
168
|
+
}
|
|
169
|
+
if (!command.json && process.platform === 'win32') {
|
|
170
|
+
passed = check(/retained original|복구본 보존/.test(result.stdout), `${command.name}: human output names retained recovery copy`) && passed;
|
|
171
|
+
}
|
|
172
|
+
cleanupRecovery(retained);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const ioPath = require.resolve('../lib/io');
|
|
176
|
+
const shellPath = require.resolve('../lib/shell-encoding');
|
|
177
|
+
const io = require(ioPath);
|
|
178
|
+
const originalWriter = io.writeBufferIfUnchanged;
|
|
179
|
+
const shellEncoding = require(shellPath);
|
|
180
|
+
|
|
181
|
+
// Encoding provenance: both-byte-valid input and a corrupt BOM body must be
|
|
182
|
+
// rejected, while a UTF-8 body invalid under CP949 remains eligible.
|
|
183
|
+
passed = check(shellEncoding.planShellScriptUtf8Bom('x.ps1', Buffer.from([0x41, 0xC2, 0xA1, 0x42])).action.startsWith('skipped-ambiguous'), 'UTF-8/CP949 ambiguity fails closed') && passed;
|
|
184
|
+
passed = check(shellEncoding.planShellScriptUtf8Bom('x.ps1', Buffer.concat([BOM, Buffer.from([0xBE, 0xC8])])).action.startsWith('skipped-invalid-bom-body'), 'BOM-prefixed CP949 corruption is detected') && passed;
|
|
185
|
+
passed = check(shellEncoding.planShellScriptUtf8Bom('x.ps1', Buffer.from('Write-Host "😀"', 'utf8')).action === 'utf8-bom-added', 'unambiguous UTF-8 remains repairable') && passed;
|
|
186
|
+
const NativeTextDecoder = global.TextDecoder;
|
|
187
|
+
try {
|
|
188
|
+
global.TextDecoder = class MissingLegacyDecoder extends NativeTextDecoder {
|
|
189
|
+
constructor(label, options) {
|
|
190
|
+
if (String(label).toLowerCase() === 'euc-kr') throw new RangeError('simulated small-ICU decoder set');
|
|
191
|
+
super(label, options);
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
passed = check(shellEncoding.planShellScriptUtf8Bom('x.ps1', Buffer.from([0x41, 0xC2, 0xA1, 0x42])).action.startsWith('skipped-ambiguous'), 'missing CP949 decoder fails closed') && passed;
|
|
195
|
+
} finally {
|
|
196
|
+
global.TextDecoder = NativeTextDecoder;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Scanner I/O denial is data, never a clean scan.
|
|
200
|
+
const scanDir = path.join(tempRoot, 'scan-errors');
|
|
201
|
+
fs.mkdirSync(scanDir);
|
|
202
|
+
const deniedFile = path.join(scanDir, 'denied.ps1');
|
|
203
|
+
fs.writeFileSync(deniedFile, Buffer.from('Write-Host "😀"', 'utf8'));
|
|
204
|
+
const originalRead = fs.readFileSync;
|
|
205
|
+
fs.readFileSync = (file, ...args) => {
|
|
206
|
+
if (path.resolve(String(file)) === path.resolve(deniedFile)) { const e = new Error('simulated read denial'); e.code = 'EACCES'; throw e; }
|
|
207
|
+
return originalRead(file, ...args);
|
|
208
|
+
};
|
|
209
|
+
let deniedScan;
|
|
210
|
+
try { deniedScan = shellEncoding.scanShellScriptsEncoding(scanDir); } finally { fs.readFileSync = originalRead; }
|
|
211
|
+
passed = check(deniedScan.scanned === 0 && deniedScan.scanErrors.length === 1 && deniedScan.scanErrors[0].code === 'EACCES', 'scanner I/O failure is structured and incomplete') && passed;
|
|
212
|
+
|
|
213
|
+
const concurrentDir = path.join(tempRoot, 'concurrent-cas');
|
|
214
|
+
fs.mkdirSync(concurrentDir);
|
|
215
|
+
if (process.platform === 'win32') {
|
|
216
|
+
// Stale plan between caller read and shared writer.
|
|
217
|
+
const staleFile = path.join(concurrentDir, 'stale.ps1');
|
|
218
|
+
const staleOriginal = Buffer.from('Write-Host "😀"\r\n', 'utf8');
|
|
219
|
+
const staleConcurrent = Buffer.from('Write-Host "CONCURRENT 😀"\r\n', 'utf8');
|
|
220
|
+
fs.writeFileSync(staleFile, staleOriginal);
|
|
221
|
+
io.writeBufferIfUnchanged = (file, expected, next, opts) => {
|
|
222
|
+
fs.writeFileSync(file, staleConcurrent);
|
|
223
|
+
return originalWriter(file, expected, next, opts);
|
|
224
|
+
};
|
|
225
|
+
delete require.cache[shellPath];
|
|
226
|
+
let staleError = null;
|
|
227
|
+
try { require(shellPath).applyShellScriptUtf8Bom(staleFile); } catch (e) { staleError = e; }
|
|
228
|
+
io.writeBufferIfUnchanged = originalWriter;
|
|
229
|
+
delete require.cache[shellPath];
|
|
230
|
+
passed = check(staleError && staleError.code === 'E_CONCURRENT_MODIFICATION', 'shared mutator rejects a stale concurrent plan') && passed;
|
|
231
|
+
passed = check(fs.readFileSync(staleFile).equals(staleConcurrent), 'stale-plan concurrent edit survives byte-exact') && passed;
|
|
232
|
+
|
|
233
|
+
// Final compare -> ReplaceFile window. No risky rollback: the concurrent
|
|
234
|
+
// file object remains at a reported recovery path.
|
|
235
|
+
const raceFile = path.join(concurrentDir, 'replace-race.ps1');
|
|
236
|
+
const raceExpected = Buffer.from('race-expected');
|
|
237
|
+
const racePlanned = Buffer.concat([BOM, raceExpected]);
|
|
238
|
+
const raceConcurrent = Buffer.from('RACE-CONCURRENT');
|
|
239
|
+
fs.writeFileSync(raceFile, raceExpected);
|
|
240
|
+
const originalSpawn = cp.spawnSync;
|
|
241
|
+
cp.spawnSync = (command, args, options) => {
|
|
242
|
+
if (options && options.env && options.env.LEERNESS_REPLACE_TO === raceFile) fs.writeFileSync(raceFile, raceConcurrent);
|
|
243
|
+
return originalSpawn(command, args, options);
|
|
244
|
+
};
|
|
245
|
+
let raceError = null;
|
|
246
|
+
try { originalWriter(raceFile, raceExpected, racePlanned); } catch (e) { raceError = e; }
|
|
247
|
+
finally { cp.spawnSync = originalSpawn; }
|
|
248
|
+
passed = check(raceError && raceError.code === 'E_CONCURRENT_MODIFICATION' && raceError.backupFile, 'final CAS race is reported with recovery') && passed;
|
|
249
|
+
passed = check(fs.readFileSync(raceError.backupFile).equals(raceConcurrent), 'final-window concurrent bytes survive in reported recovery') && passed;
|
|
250
|
+
passed = check(Array.isArray(raceError.recoveryArtifacts) && raceError.recoveryArtifacts.some(a => a.content === 'concurrent-or-unknown'), 'recovery artifacts expose byte roles') && passed;
|
|
251
|
+
cleanupRecovery([raceError.backupFile]);
|
|
252
|
+
|
|
253
|
+
// A handle opened before replacement may write to the displaced file after
|
|
254
|
+
// validation. Retaining (rather than deleting) that file prevents loss.
|
|
255
|
+
const lateFile = path.join(concurrentDir, 'late-handle.ps1');
|
|
256
|
+
const lateExpected = Buffer.from('late-original');
|
|
257
|
+
const latePlanned = Buffer.concat([BOM, lateExpected]);
|
|
258
|
+
const lateBytes = Buffer.from('LATE-EDIT-OK');
|
|
259
|
+
fs.writeFileSync(lateFile, lateExpected);
|
|
260
|
+
const lateFd = fs.openSync(lateFile, 'r+');
|
|
261
|
+
const lateResult = originalWriter(lateFile, lateExpected, latePlanned);
|
|
262
|
+
fs.writeSync(lateFd, lateBytes, 0, lateBytes.length, 0);
|
|
263
|
+
fs.ftruncateSync(lateFd, lateBytes.length);
|
|
264
|
+
fs.closeSync(lateFd);
|
|
265
|
+
passed = check(fs.readFileSync(lateFile).equals(latePlanned), 'planned target remains exact after displaced-handle write') && passed;
|
|
266
|
+
passed = check(fs.readFileSync(lateResult.backupFile).equals(lateBytes), 'late displaced-handle write remains recoverable') && passed;
|
|
267
|
+
cleanupRecovery([lateResult.backupFile]);
|
|
268
|
+
|
|
269
|
+
// ADS handle update follows the same retained file object.
|
|
270
|
+
const adsFile = path.join(concurrentDir, 'ads-late.ps1');
|
|
271
|
+
const adsExpected = Buffer.from('ads-original');
|
|
272
|
+
const adsPlanned = Buffer.concat([BOM, adsExpected]);
|
|
273
|
+
fs.writeFileSync(adsFile, adsExpected);
|
|
274
|
+
const adsPath = adsFile + ':Zone.Identifier';
|
|
275
|
+
fs.writeFileSync(adsPath, 'OLD-ADS');
|
|
276
|
+
const adsFd = fs.openSync(adsPath, 'r+');
|
|
277
|
+
const adsResult = originalWriter(adsFile, adsExpected, adsPlanned);
|
|
278
|
+
fs.writeSync(adsFd, Buffer.from('NEW-ADS'), 0, 7, 0);
|
|
279
|
+
fs.ftruncateSync(adsFd, 7);
|
|
280
|
+
fs.closeSync(adsFd);
|
|
281
|
+
passed = check(fs.readFileSync(adsResult.backupFile + ':Zone.Identifier', 'utf8') === 'NEW-ADS', 'late ADS update survives on retained recovery file') && passed;
|
|
282
|
+
cleanupRecovery([adsResult.backupFile]);
|
|
283
|
+
|
|
284
|
+
// Same-content hard-link creation in the last window is detected from the
|
|
285
|
+
// displaced file identity/link count and its alias remains intact.
|
|
286
|
+
const linkRaceFile = path.join(concurrentDir, 'link-race.ps1');
|
|
287
|
+
const linkAlias = path.join(concurrentDir, 'link-race-alias.ps1');
|
|
288
|
+
const linkExpected = Buffer.from('link-original');
|
|
289
|
+
const linkPlanned = Buffer.concat([BOM, linkExpected]);
|
|
290
|
+
fs.writeFileSync(linkRaceFile, linkExpected);
|
|
291
|
+
cp.spawnSync = (command, args, options) => {
|
|
292
|
+
if (options && options.env && options.env.LEERNESS_REPLACE_TO === linkRaceFile && !fs.existsSync(linkAlias)) fs.linkSync(linkRaceFile, linkAlias);
|
|
293
|
+
return originalSpawn(command, args, options);
|
|
294
|
+
};
|
|
295
|
+
let linkRaceError = null;
|
|
296
|
+
try { originalWriter(linkRaceFile, linkExpected, linkPlanned); } catch (e) { linkRaceError = e; }
|
|
297
|
+
finally { cp.spawnSync = originalSpawn; }
|
|
298
|
+
passed = check(linkRaceError && linkRaceError.code === 'E_CONCURRENT_MODIFICATION' && linkRaceError.backupFile, 'last-window hard-link identity change is reported') && passed;
|
|
299
|
+
passed = check(fs.readFileSync(linkAlias).equals(linkExpected) && fs.readFileSync(linkRaceError.backupFile).equals(linkExpected), 'hard-link aliases retain original bytes') && passed;
|
|
300
|
+
fs.unlinkSync(linkAlias);
|
|
301
|
+
cleanupRecovery([linkRaceError.backupFile]);
|
|
302
|
+
|
|
303
|
+
// Documented ReplaceFileW 1177 partial progress: target moved to backup,
|
|
304
|
+
// planned source remains, and target path is absent. Both roles are exposed.
|
|
305
|
+
const partialFile = path.join(concurrentDir, 'partial-1177.ps1');
|
|
306
|
+
const partialExpected = Buffer.from('partial-original');
|
|
307
|
+
const partialPlanned = Buffer.concat([BOM, partialExpected]);
|
|
308
|
+
fs.writeFileSync(partialFile, partialExpected);
|
|
309
|
+
cp.spawnSync = (command, args, options) => {
|
|
310
|
+
if (options && options.env && options.env.LEERNESS_REPLACE_TO === partialFile) {
|
|
311
|
+
fs.renameSync(partialFile, options.env.LEERNESS_REPLACE_BACKUP);
|
|
312
|
+
return { status: 1, stdout: '', stderr: 'LEERNESS_REPLACE_ERROR:1177:simulated partial progress', error: null };
|
|
313
|
+
}
|
|
314
|
+
return originalSpawn(command, args, options);
|
|
315
|
+
};
|
|
316
|
+
let partialError = null;
|
|
317
|
+
try { originalWriter(partialFile, partialExpected, partialPlanned); } catch (e) { partialError = e; }
|
|
318
|
+
finally { cp.spawnSync = originalSpawn; }
|
|
319
|
+
passed = check(partialError && partialError.win32Code === 1177 && !fs.existsSync(partialFile), 'ReplaceFileW 1177 partial state is reported') && passed;
|
|
320
|
+
const partialRoles = new Set((partialError.recoveryArtifacts || []).map(a => `${a.role}:${a.content}`));
|
|
321
|
+
passed = check(partialRoles.has('planned-source:planned-replacement') && partialRoles.has('displaced-original:expected-original'), '1177 exposes both artifacts with validated roles', JSON.stringify(partialError.recoveryArtifacts)) && passed;
|
|
322
|
+
cleanupRecovery([partialError.backupFile]);
|
|
323
|
+
|
|
324
|
+
// Cleanup exhaustion must become an explicit retained artifact, not success.
|
|
325
|
+
const cleanupFile = path.join(concurrentDir, 'cleanup-denied.ps1');
|
|
326
|
+
const cleanupExpected = Buffer.from('cleanup-original');
|
|
327
|
+
fs.writeFileSync(cleanupFile, cleanupExpected);
|
|
328
|
+
const originalWrite = fs.writeFileSync;
|
|
329
|
+
const originalRm = fs.rmSync;
|
|
330
|
+
fs.writeFileSync = (file, ...args) => {
|
|
331
|
+
if (path.basename(String(file)) === 'next' && String(file).includes('.leerness-write-')) { const e = new Error('simulated temp write denial'); e.code = 'EACCES'; throw e; }
|
|
332
|
+
return originalWrite(file, ...args);
|
|
333
|
+
};
|
|
334
|
+
fs.rmSync = (file, ...args) => {
|
|
335
|
+
if (String(file).includes('.leerness-write-')) { const e = new Error('simulated cleanup denial'); e.code = 'EACCES'; throw e; }
|
|
336
|
+
return originalRm(file, ...args);
|
|
337
|
+
};
|
|
338
|
+
let cleanupError = null;
|
|
339
|
+
try { originalWriter(cleanupFile, cleanupExpected, Buffer.concat([BOM, cleanupExpected])); } catch (e) { cleanupError = e; }
|
|
340
|
+
finally { fs.writeFileSync = originalWrite; fs.rmSync = originalRm; }
|
|
341
|
+
passed = check(cleanupError && cleanupError.code === 'EACCES' && Array.isArray(cleanupError.recoveryArtifacts)
|
|
342
|
+
&& cleanupError.recoveryArtifacts.some(a => a.role === 'cleanup-failed-scratch'), 'cleanup exhaustion is surfaced with retained scratch') && passed;
|
|
343
|
+
const cleanupScratch = cleanupError.recoveryArtifacts.find(a => a.role === 'cleanup-failed-scratch').path;
|
|
344
|
+
cleanupRecovery([cleanupScratch]);
|
|
345
|
+
|
|
346
|
+
// Retry only no-progress Win32 sharing violations.
|
|
347
|
+
const retryFile = path.join(concurrentDir, 'transient-sharing.ps1');
|
|
348
|
+
const retryExpected = Buffer.from('retry-original');
|
|
349
|
+
const retryPlanned = Buffer.concat([BOM, retryExpected]);
|
|
350
|
+
fs.writeFileSync(retryFile, retryExpected);
|
|
351
|
+
let retryCalls = 0;
|
|
352
|
+
cp.spawnSync = (command, args, options) => {
|
|
353
|
+
if (options && options.env && options.env.LEERNESS_REPLACE_TO === retryFile && retryCalls++ < 2) {
|
|
354
|
+
return { status: 1, stdout: '', stderr: 'LEERNESS_REPLACE_ERROR:32:simulated sharing violation', error: null };
|
|
355
|
+
}
|
|
356
|
+
return originalSpawn(command, args, options);
|
|
357
|
+
};
|
|
358
|
+
let retryResult = null;
|
|
359
|
+
let retryError = null;
|
|
360
|
+
try { retryResult = originalWriter(retryFile, retryExpected, retryPlanned); } catch (e) { retryError = e; }
|
|
361
|
+
finally { cp.spawnSync = originalSpawn; }
|
|
362
|
+
passed = check(!retryError && retryCalls === 3 && fs.readFileSync(retryFile).equals(retryPlanned), 'transient sharing violation is retried with a bound') && passed;
|
|
363
|
+
cleanupRecovery([retryResult && retryResult.backupFile]);
|
|
364
|
+
} else {
|
|
365
|
+
const posixFile = path.join(concurrentDir, 'posix.ps1');
|
|
366
|
+
const posixBytes = Buffer.from('Write-Host "😀"\r\n', 'utf8');
|
|
367
|
+
fs.writeFileSync(posixFile, posixBytes);
|
|
368
|
+
const result = shellEncoding.applyShellScriptUtf8Bom(posixFile);
|
|
369
|
+
passed = check(result.action.startsWith('skipped-platform') && fs.readFileSync(posixFile).equals(posixBytes), 'POSIX policy rejects metadata-loss replacement byte-exactly') && passed;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Filesystem preconditions outside the direct writer.
|
|
373
|
+
const protectedDir = path.join(tempRoot, 'filesystem-contracts');
|
|
374
|
+
fs.mkdirSync(protectedDir);
|
|
375
|
+
const readOnlyFile = path.join(protectedDir, 'readonly.ps1');
|
|
376
|
+
const readOnlyBytes = Buffer.from('Write-Host "😀"\r\n', 'utf8');
|
|
377
|
+
fs.writeFileSync(readOnlyFile, readOnlyBytes);
|
|
378
|
+
fs.chmodSync(readOnlyFile, 0o444);
|
|
379
|
+
let readOnlyError = null;
|
|
380
|
+
let readOnlyResult = null;
|
|
381
|
+
try { readOnlyResult = shellEncoding.applyShellScriptUtf8Bom(readOnlyFile); } catch (e) { readOnlyError = e; }
|
|
382
|
+
passed = check((process.platform === 'win32'
|
|
383
|
+
? (readOnlyError && readOnlyError.code === 'E_READ_ONLY')
|
|
384
|
+
: (readOnlyResult && readOnlyResult.action.startsWith('skipped-platform')))
|
|
385
|
+
&& fs.readFileSync(readOnlyFile).equals(readOnlyBytes), 'read-only eligible script follows platform mutation contract') && passed;
|
|
386
|
+
fs.chmodSync(readOnlyFile, 0o666);
|
|
387
|
+
|
|
388
|
+
const readOnlyShebang = path.join(protectedDir, 'readonly.sh');
|
|
389
|
+
const readOnlyShebangBytes = Buffer.from('#!/bin/sh\nprintf "안녕\\n"\n', 'utf8');
|
|
390
|
+
fs.writeFileSync(readOnlyShebang, readOnlyShebangBytes);
|
|
391
|
+
fs.chmodSync(readOnlyShebang, 0o444);
|
|
392
|
+
const readOnlyShebangResult = shellEncoding.applyShellScriptUtf8Bom(readOnlyShebang);
|
|
393
|
+
passed = check(readOnlyShebangResult.action.startsWith('skipped-shebang')
|
|
394
|
+
&& fs.readFileSync(readOnlyShebang).equals(readOnlyShebangBytes), 'read-only shebang is a successful byte-exact skip') && passed;
|
|
395
|
+
fs.chmodSync(readOnlyShebang, 0o666);
|
|
396
|
+
|
|
397
|
+
const hardlinkFile = path.join(protectedDir, 'hardlink.ps1');
|
|
398
|
+
const hardlinkAlias = path.join(protectedDir, 'hardlink-alias.ps1');
|
|
399
|
+
fs.writeFileSync(hardlinkFile, readOnlyBytes);
|
|
400
|
+
fs.linkSync(hardlinkFile, hardlinkAlias);
|
|
401
|
+
const hardlinkResult = shellEncoding.applyShellScriptUtf8Bom(hardlinkFile);
|
|
402
|
+
passed = check(hardlinkResult.action.startsWith('skipped-multiple-links')
|
|
403
|
+
&& fs.readFileSync(hardlinkFile).equals(readOnlyBytes) && fs.readFileSync(hardlinkAlias).equals(readOnlyBytes), 'hard-linked script and aliases remain exact') && passed;
|
|
404
|
+
|
|
405
|
+
const symlinkTarget = path.join(protectedDir, 'symlink-target.ps1');
|
|
406
|
+
const symlinkFile = path.join(protectedDir, 'symlink.ps1');
|
|
407
|
+
fs.writeFileSync(symlinkTarget, readOnlyBytes);
|
|
408
|
+
try {
|
|
409
|
+
fs.symlinkSync(symlinkTarget, symlinkFile, 'file');
|
|
410
|
+
const symlinkResult = shellEncoding.applyShellScriptUtf8Bom(symlinkFile);
|
|
411
|
+
passed = check(symlinkResult.action.startsWith('skipped-symlink') && fs.readFileSync(symlinkTarget).equals(readOnlyBytes), 'symbolic-link target remains exact') && passed;
|
|
412
|
+
} catch (e) {
|
|
413
|
+
if (!['EPERM', 'EACCES', 'UNKNOWN'].includes(e.code)) throw e;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Direct drift DI probes: one failed stage must be JSON-visible/exit 1.
|
|
417
|
+
const drift = require('../lib/drift');
|
|
418
|
+
|
|
419
|
+
// An unborn repository is valid and has no release refs to clean. Do not
|
|
420
|
+
// ask Git to resolve `main` until the cleanup threshold can be reached.
|
|
421
|
+
const unbornDir = path.join(tempRoot, 'drift-unborn-git');
|
|
422
|
+
const gitInit = cp.spawnSync('git', ['init', unbornDir], { encoding: 'utf8', timeout: 30000 });
|
|
423
|
+
passed = check(gitInit.status === 0, 'unborn Git fixture initializes', (gitInit.stderr || '').slice(0, 300)) && passed;
|
|
424
|
+
const unbornRun = run(['drift', 'check', '--auto-fix', '--path', unbornDir, '--json'], unbornDir);
|
|
425
|
+
let unbornJson = null;
|
|
426
|
+
try { unbornJson = JSON.parse(unbornRun.stdout); } catch {}
|
|
427
|
+
passed = check(unbornRun.status === 0 && unbornJson
|
|
428
|
+
&& unbornJson.autoFixSummary.stages.releaseCleanup.status === 'not-needed'
|
|
429
|
+
&& unbornJson.autoFixSummary.stages.releaseCleanup.candidates === 0,
|
|
430
|
+
'drift auto-fix accepts an unborn repository with zero release refs', `${unbornRun.stdout}${unbornRun.stderr}`.slice(0, 500)) && passed;
|
|
431
|
+
|
|
432
|
+
const driftDir = path.join(tempRoot, 'drift-structured-failure');
|
|
433
|
+
fs.mkdirSync(path.join(driftDir, '.leerness'), { recursive: true });
|
|
434
|
+
const driftDeps = {
|
|
435
|
+
VERSION: 'test',
|
|
436
|
+
has: flag => flag === '--json' || flag === '--auto-fix',
|
|
437
|
+
arg: (key, fallback) => fallback,
|
|
438
|
+
uiLang: 'en',
|
|
439
|
+
harnessPath: path.join(driftDir, 'fake-leerness.js'),
|
|
440
|
+
readProgressRows: () => [{ updated: new Date().toISOString().slice(0, 10) }],
|
|
441
|
+
planPath: root => path.join(root, '.leerness', 'plan.md'),
|
|
442
|
+
handoffPath: root => path.join(root, '.leerness', 'session-handoff.md'),
|
|
443
|
+
currentStatePath: root => path.join(root, '.leerness', 'current-state.md'),
|
|
444
|
+
taskLogPath: root => path.join(root, '.leerness', 'task-log.md'),
|
|
445
|
+
envDiff: () => ({ inEnvOnly: [] }),
|
|
446
|
+
_usageStatsPath: root => path.join(root, '.leerness', 'usage.json'),
|
|
447
|
+
_readUsageStats: () => ({}),
|
|
448
|
+
_updateUserRequest: () => null,
|
|
449
|
+
_scanShellScriptsEncoding: () => ({ scanned: 0, atRisk: [], scanErrors: [] }),
|
|
450
|
+
_readFeatureGraph: () => ({ nodes: [] }),
|
|
451
|
+
_detectDeliveredRequests: () => ({ candidates: [] }),
|
|
452
|
+
_autoFixIdempotency: () => [{ action: 'error', kind: 'tasks', detail: 'simulated dedup failure' }],
|
|
453
|
+
};
|
|
454
|
+
const savedExit = process.exitCode;
|
|
455
|
+
const savedWrite = process.stdout.write;
|
|
456
|
+
let driftOut = '';
|
|
457
|
+
process.exitCode = 0;
|
|
458
|
+
try {
|
|
459
|
+
process.stdout.write = chunk => { driftOut += chunk; return true; };
|
|
460
|
+
drift.driftCheckCmd(driftDir, {}, driftDeps);
|
|
461
|
+
} finally { process.stdout.write = savedWrite; }
|
|
462
|
+
let driftJson = null;
|
|
463
|
+
try { driftJson = JSON.parse(driftOut); } catch {}
|
|
464
|
+
passed = check(process.exitCode === 1 && driftJson && driftJson.autoFixSummary.failed === 1
|
|
465
|
+
&& driftJson.autoFixSummary.stages.idempotency.status === 'failed', 'non-encoding drift auto-fix failure is JSON-visible and exits 1', driftOut.slice(0, 300)) && passed;
|
|
466
|
+
process.exitCode = savedExit;
|
|
467
|
+
|
|
468
|
+
// A missing Git executable is an operational failure, not evidence that the
|
|
469
|
+
// path is outside a worktree. Preserve the failure in JSON and the exit code.
|
|
470
|
+
const missingGitDeps = {
|
|
471
|
+
...driftDeps,
|
|
472
|
+
_autoFixIdempotency: () => [],
|
|
473
|
+
_gitSpawn: () => ({ status: null, stdout: '', stderr: '', error: Object.assign(new Error('spawn git ENOENT'), { code: 'ENOENT' }) }),
|
|
474
|
+
};
|
|
475
|
+
let missingGitOut = '';
|
|
476
|
+
process.exitCode = 0;
|
|
477
|
+
try {
|
|
478
|
+
process.stdout.write = chunk => { missingGitOut += chunk; return true; };
|
|
479
|
+
drift.driftCheckCmd(driftDir, {}, missingGitDeps);
|
|
480
|
+
} finally { process.stdout.write = savedWrite; }
|
|
481
|
+
let missingGitJson = null;
|
|
482
|
+
try { missingGitJson = JSON.parse(missingGitOut); } catch {}
|
|
483
|
+
passed = check(process.exitCode === 1 && missingGitJson
|
|
484
|
+
&& missingGitJson.autoFixSummary.stages.releaseCleanup.status === 'failed'
|
|
485
|
+
&& missingGitJson.autoFixSummary.stages.releaseCleanup.results.some(r => r.code === 'ENOENT'),
|
|
486
|
+
'missing Git executable is JSON-visible and exits 1', missingGitOut.slice(0, 300)) && passed;
|
|
487
|
+
process.exitCode = savedExit;
|
|
488
|
+
|
|
489
|
+
// Security + critical drift must still run session recovery in the same pass.
|
|
490
|
+
const combinedDir = path.join(tempRoot, 'drift-security-critical');
|
|
491
|
+
fs.mkdirSync(path.join(combinedDir, '.leerness'), { recursive: true });
|
|
492
|
+
fs.writeFileSync(path.join(combinedDir, '.env'), 'TOKEN=value\n');
|
|
493
|
+
fs.writeFileSync(path.join(combinedDir, '.gitignore'), 'node_modules/\n');
|
|
494
|
+
fs.writeFileSync(path.join(combinedDir, '.leerness', 'session-handoff.md'), 'Last generated: 2020-01-01T00:00:00.000Z\n');
|
|
495
|
+
for (let i = 0; i < 5; i++) fs.mkdirSync(path.join(combinedDir, '_apps', `a${i}`, '.leerness'), { recursive: true });
|
|
496
|
+
let auditCalls = 0;
|
|
497
|
+
let sessionCalls = 0;
|
|
498
|
+
const originalSpawn = cp.spawnSync;
|
|
499
|
+
cp.spawnSync = (command, args, options) => {
|
|
500
|
+
if (command === process.execPath && args && args[0] === driftDeps.harnessPath && args[1] === 'audit') {
|
|
501
|
+
auditCalls++;
|
|
502
|
+
fs.writeFileSync(path.join(combinedDir, '.gitignore'), '.env\n.env.local\n.env.production\n.env.*.local\n*.pem\ncredentials.json\n');
|
|
503
|
+
return { status: 0, stdout: '', stderr: '', error: null };
|
|
504
|
+
}
|
|
505
|
+
if (command === process.execPath && args && args[0] === driftDeps.harnessPath && args[1] === 'session') {
|
|
506
|
+
sessionCalls++;
|
|
507
|
+
fs.writeFileSync(path.join(combinedDir, '.leerness', 'session-handoff.md'), `Last generated: ${new Date().toISOString()}\n`);
|
|
508
|
+
return { status: 0, stdout: '', stderr: '', error: null };
|
|
509
|
+
}
|
|
510
|
+
return originalSpawn(command, args, options);
|
|
511
|
+
};
|
|
512
|
+
const combinedDeps = { ...driftDeps, readProgressRows: () => [], _autoFixIdempotency: () => [] };
|
|
513
|
+
let combinedOut = '';
|
|
514
|
+
process.exitCode = 0;
|
|
515
|
+
try {
|
|
516
|
+
process.stdout.write = chunk => { combinedOut += chunk; return true; };
|
|
517
|
+
drift.driftCheckCmd(combinedDir, {}, combinedDeps);
|
|
518
|
+
} finally { process.stdout.write = savedWrite; cp.spawnSync = originalSpawn; }
|
|
519
|
+
let combinedJson = null;
|
|
520
|
+
try { combinedJson = JSON.parse(combinedOut); } catch {}
|
|
521
|
+
passed = check(auditCalls === 1 && sessionCalls === 1 && combinedJson
|
|
522
|
+
&& combinedJson.autoFixSummary.stages.security.status === 'applied'
|
|
523
|
+
&& combinedJson.autoFixSummary.stages.session.status === 'applied', 'critical session recovery runs after security repair', combinedOut.slice(0, 300)) && passed;
|
|
524
|
+
process.exitCode = savedExit;
|
|
525
|
+
} finally {
|
|
526
|
+
const resolved = path.resolve(tempRoot);
|
|
527
|
+
const resolvedTmp = path.resolve(os.tmpdir());
|
|
528
|
+
if (!resolved.startsWith(resolvedTmp + path.sep) || path.dirname(resolved) !== resolvedTmp) throw new Error(`unsafe cleanup target: ${resolved}`);
|
|
529
|
+
fs.rmSync(resolved, { recursive: true, force: true });
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
if (!passed) {
|
|
533
|
+
process.stderr.write('MUTATION_INTEGRITY_FAILED\n');
|
|
534
|
+
process.exit(1);
|
|
535
|
+
}
|
|
536
|
+
process.stdout.write('MUTATION_INTEGRITY_OK\n');
|