Files
cc-1c-skills/tests/skills/check-nonascii-fs.mjs
T
0442aa015a fix(tests,web-test): не отдавать rmSync/cpSync пути с не-ASCII символами
На Windows fs.rmSync и fs.cpSync молча не делают ничего, когда не-ASCII символы
есть в самом аргументе пути (nodejs/node#61067). Для аудитории проекта это боевой
сценарий: кириллическое имя пользователя даёт кириллический %TEMP%, где раннер
создаёт воркспейсы, плюс кириллические имена объектов 1С в путях внутри кейсов.

Замерено на восьми сборках. Затронуты 22.18.0–22.23.2 (последняя LTS Jod) и
24.12.0–24.14.0; исправны 22.15.1, 24.15.0+, 25.x, 26.x. Фикс приехал в fs-слой
Node 24.15 и в ветку 22.x не бэкпортирован, поэтому «обновить Node» вопрос не
закрывает. Зависимость не монотонна по версиям (24.14 чинит rmSync, но не
cpSync) — гард по номеру версии невозможен, решает только сам путь.

Симптомы: rmSync молча ничего не удаляет; cpSync с не-ASCII приёмником молча
ничего не копирует; cpSync с не-ASCII источником валит процесс нативно
(0xC0000409) мимо try/catch. Не затронуты mkdirSync, readdirSync, lstatSync,
copyFileSync (включая перезапись), unlinkSync, rmdirSync — на них стоит обход.

Что ломалось: под кириллическим %TEMP% фикстуры не доезжали до воркспейсов
(meta-info — 15 ложных падений из 26, один кейс ложно-зелёный на пустом
воркспейсе); cfe-validate/module-state-flag-without-file был красным даже при
ASCII %TEMP% (кириллица в самом deletePath); --update-snapshots молча не сносил
старый эталон, то есть портил коммитимые артефакты.

Реализация — единый fsutil в двух побайтно одинаковых копиях (tests/common/ и
внутри автономного навыка web-test), раскатанный на все 42 точки вызова в 11
файлах: tests/skills/*, tests/web-test/*, hooks/test/run.mjs, движок web-test.
Предикат судит по resolve(p), а не по строке аргумента: относительный
ASCII-аргумент при не-ASCII cwd платформа роняет так же молча. Ретраи доживают
до ручного обхода, перезапись как у cpSync с force: true, настоящие ошибки не
глотаются, выживший после удаления путь — громкая ошибка.

Гард tests/skills/check-nonascii-fs.mjs в check-all.mjs проверяет хелпер, а не
платформу (поэтому зелёный и на исправной Node), сверяет хеши обеих копий и
печатает справкой состояние текущей сборки.

Co-Authored-By: androman.pro <5669019+andromanpro@users.noreply.github.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 19:27:02 +03:00

185 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
// Гард хелпера tests/common/fsutil.mjs: удаление и копирование обязаны работать, когда в пути
// есть не-ASCII символы. На Windows fs.rmSync/fs.cpSync в такой ситуации МОЛЧА не делают ничего
// (nodejs/node#61067) — снапшот-тесты этого не ловят, они видят лишь последствия: «фикстура не
// доехала», «эталон не обновился», утёкшие воркспейсы.
//
// Гард проверяет НАШ хелпер, а не платформу, поэтому зелёный и на исправной сборке Node —
// именно это защищает его от гниения. Состояние самой платформы печатается справочно.
//
// Ловушка при доработке: cpSync из не-ASCII ИСТОЧНИКА на сломанных сборках валит процесс
// нативно (0xC0000409) мимо try/catch. Пробовать платформу этим вариантом нельзя — только
// тихими (rmSync и cpSync в не-ASCII приёмник).
//
// Выход 1 при нарушении. Запуск: node tests/skills/check-nonascii-fs.mjs
import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync,
rmdirSync, rmSync, unlinkSync, writeFileSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { tmpdir } from 'node:os';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const IS_WIN = process.platform === 'win32';
// Ручной обход исполняется только на не-ASCII путях, а на macOS/Linux их не бывает по природе
// гарда. Шов включает обход на любой ОС — иначе половина кода была бы покрыта только на Windows.
process.env.CC1C_FSUTIL_FORCE_WALK = '1';
const { removePathSync, copyTreeSync } = await import('../common/fsutil.mjs');
let failures = 0;
const fail = (msg) => { console.log(` x ${msg}`); failures++; };
const pass = (msg) => console.log(` + ${msg}`);
const check = (cond, msg) => cond ? pass(msg) : fail(msg);
// Уборка сознательно НЕ через хелпер: он здесь подопытный, его отказ не должен маскироваться.
function nuke(p) {
if (!existsSync(p)) return;
for (const e of readdirSync(p, { withFileTypes: true })) {
const c = join(p, e.name);
if (e.isDirectory()) nuke(c); else { try { unlinkSync(c); } catch { rmdirSync(c); } }
}
rmdirSync(p);
}
const ROOTDIR = mkdtempSync(join(tmpdir(), 'nonascii-fs-'));
const CYR = join(ROOTDIR, 'Каталог Ы');
mkdirSync(CYR, { recursive: true });
const cwd0 = process.cwd();
// --- 1. Предикат -----------------------------------------------------------
console.log('\nПредикат pathIsUnsafe');
{
delete process.env.CC1C_FSUTIL_FORCE_WALK;
const mod = await import('../common/fsutil.mjs?noseam=1');
process.chdir(CYR);
// Главная ловушка: относительный ASCII-аргумент при не-ASCII cwd платформа роняет так же
// молча, поэтому судить по строке аргумента нельзя — только по разрешённому пути.
check(mod.pathIsUnsafe('жертва') === IS_WIN, 'не-ASCII в самом аргументе');
check(mod.pathIsUnsafe('victim') === IS_WIN, 'относительный ASCII-аргумент при не-ASCII cwd');
process.chdir(cwd0);
check(mod.pathIsUnsafe(join(ROOTDIR, 'plain')) === false, 'полностью ASCII-путь — быстрый путь');
process.env.CC1C_FSUTIL_FORCE_WALK = '1';
}
// --- 2. removePathSync -----------------------------------------------------
console.log('\nremovePathSync');
{
removePathSync(join(CYR, 'нет-такого'));
pass('отсутствующий путь — не ошибка (семантика force)');
const f = join(CYR, 'Файл.txt');
writeFileSync(f, 'x');
removePathSync(f);
check(!existsSync(f), 'одиночный файл');
const tree = join(CYR, 'Дерево');
mkdirSync(join(tree, 'Вложенный', 'Глубже'), { recursive: true });
writeFileSync(join(tree, 'Вложенный', 'Данные.xml'), 'x');
writeFileSync(join(tree, 'Вложенный', 'Глубже', 'plain.txt'), 'x');
removePathSync(tree);
check(!existsSync(tree), 'вложенное дерево с не-ASCII именами');
const rel = join(CYR, 'Отн');
mkdirSync(join(rel, 'victim'), { recursive: true });
writeFileSync(join(rel, 'victim', 'f.txt'), 'x');
process.chdir(rel);
removePathSync('victim');
process.chdir(cwd0);
check(!existsSync(join(rel, 'victim')), 'относительный ASCII-аргумент при не-ASCII cwd');
// Ретраи обязаны дожить до ручного обхода: если они теряются, под не-ASCII %TEMP%
// воркспейсы утекают ровно так же, как без гарда вообще.
if (IS_WIN) {
const busy = join(CYR, 'Занятый');
mkdirSync(busy, { recursive: true });
process.chdir(busy); // rmdir собственного cwd на Windows даёт EBUSY детерминированно
const t0 = Date.now();
let threw = false;
try { removePathSync(busy, { maxRetries: 3, retryDelay: 40 }); } catch { threw = true; }
const spent = Date.now() - t0;
process.chdir(cwd0);
check(threw, 'занятый каталог — отказ громкий, не тихий');
check(spent >= 120, `ретраи дожили до ручного обхода (ждали >=120 мс, потрачено ${spent} мс)`);
rmdirSync(busy);
} else {
console.log(' o ретраи: пропуск — детерминированный EBUSY есть только на Windows');
}
}
// --- 3. copyTreeSync -------------------------------------------------------
console.log('\ncopyTreeSync');
{
const src = join(CYR, 'Источник');
mkdirSync(join(src, 'Languages'), { recursive: true });
writeFileSync(join(src, 'Languages', 'Русский.xml'), 'RU');
writeFileSync(join(src, 'Configuration.xml'), 'CFG');
const dst = join(CYR, 'Приёмник');
copyTreeSync(src, dst);
check(readFileSync(join(dst, 'Configuration.xml'), 'utf8') === 'CFG'
&& readFileSync(join(dst, 'Languages', 'Русский.xml'), 'utf8') === 'RU',
'копия из не-ASCII источника в не-ASCII приёмник');
// Семантика cpSync — force: true. Обход, который не перезатирает, ломает её незаметно.
writeFileSync(join(dst, 'Configuration.xml'), 'СТАРОЕ');
copyTreeSync(src, dst);
check(readFileSync(join(dst, 'Configuration.xml'), 'utf8') === 'CFG',
'существующий файл перезаписывается (как cpSync с force: true)');
// Настоящая ошибка обязана долететь до вызывающего, а не раствориться в обходе.
let threw = false;
try { copyTreeSync(join(CYR, 'нет-источника'), join(CYR, 'куда')); } catch { threw = true; }
check(threw, 'отсутствующий источник — ошибка не проглочена');
}
// --- 4. Анти-дрейф копий ---------------------------------------------------
console.log('\nКопии fsutil совпадают');
{
const COPIES = [
'tests/common/fsutil.mjs',
'.claude/skills/web-test/scripts/engine/core/fsutil.mjs',
];
const MARKER = '// ─── fsutil:begin';
const hashes = COPIES.map((rel) => {
const text = readFileSync(join(ROOT, rel), 'utf8');
const at = text.indexOf(MARKER);
if (at < 0) { fail(`${rel}: нет маркера «${MARKER}»`); return null; }
return createHash('sha256').update(text.slice(at)).digest('hex');
});
if (hashes.every(Boolean)) {
const same = new Set(hashes).size === 1;
check(same, `тело ниже маркера идентично в ${COPIES.length} копиях (${hashes[0].slice(0, 12)})`);
if (!same) COPIES.forEach((c, i) => console.log(` ${hashes[i].slice(0, 12)} ${c}`));
}
}
// --- 5. Справка о самой платформе ------------------------------------------
// Только тихие варианты. cpSync из не-ASCII источника не трогаем — он валит процесс.
console.log('\nСостояние этой сборки Node (справочно, на вердикт не влияет)');
{
const probe = join(CYR, 'Проба');
mkdirSync(probe, { recursive: true });
writeFileSync(join(probe, 'f.txt'), 'x');
let rmBroken = false;
let cpBroken = false;
try { rmSync(join(probe, 'f.txt'), { force: true }); rmBroken = existsSync(join(probe, 'f.txt')); } catch {}
const asciiSrc = join(ROOTDIR, 'ascii-src');
mkdirSync(asciiSrc, { recursive: true });
writeFileSync(join(asciiSrc, 'f.txt'), 'x');
const cyrDst = join(CYR, 'Проба-приёмник');
try { cpSync(asciiSrc, cyrDst, { recursive: true }); cpBroken = !existsSync(join(cyrDst, 'f.txt')); }
catch { cpBroken = true; }
const verdict = rmBroken || cpBroken
? `ЗАТРОНУТА (rmSync: ${rmBroken ? 'молчит' : 'ок'}, cpSync: ${cpBroken ? 'молчит' : 'ок'}) — гард здесь несёт нагрузку`
: 'чистая — гард работает вхолостую, и это нормально';
console.log(` ${process.version} на ${process.platform}: ${verdict}`);
}
nuke(ROOTDIR);
console.log('');
if (failures) console.log(`${failures} НАРУШЕНИЙ: хелпер не держит не-ASCII пути.`);
else console.log('OK — fsutil держит не-ASCII пути, копии не разошлись');
process.exit(failures ? 1 : 0);