mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 11:18:48 +00:00
Аудит v1.71.0-beta.1 (§3.2 M1/M2/M3/M6/M9) прогнал по мутанту на каждый контракт: пять снятий защиты не покраснили ни один тест. Восьмой подряд случай проверок, не умеющих падать, и первый — в бете, закрывавшей #421, задачу ровно об этом. Каждый свидетель ниже проверен отрицательным прогоном: мутант краснит ровно свой тест и не задевает остальные. 1. Полное декодирование растра. `test_supported_raster_headers_and_full_decode` спрашивал w/h/mime — их даёт header-парсер; обрезка `PNG_1X1[:33]` теряет IEND и отбивается там же. No-op блока Pillow оставлял 35 pass. Новый свидетель: PNG с верными сигнатурой, IHDR, IEND, длинами и CRC, но с текстом вместо zlib-потока в IDAT. Оба прежних теста переименованы — их имена обещали то, чего они не проверяли. Пропуск без Pillow остался (validate_asset глотает ImportError осознанно), но в окружении с Home Assistant отсутствие Pillow теперь красное само по себе — иначе свидетель молча скипался бы в каноне. 2. Канонизация SVG. `ValidatedAsset(canonical,…)` → `ValidatedAsset(data,…)` не краснило ничего: все тесты смотрели w/h/mime, ни один — байты. Свидетель сверяет байты целиком: пролог и комментарий не переживают канонизацию, пустой элемент сжимается. Вторая половина — `_check_size(canonical)`. Экранирование `>` в тексте раздувает документ вчетверо: загрузка 1.84 МиБ канонизуется в 7.35 МиБ и до сих пор проходила входной контроль. Свидетель — ровно такая. 3. Гард внешних URL. Все три «внешних» кейса корпуса ловились другими правилами (тег не из словаря, атрибут не из словаря, ветвь href), и `if False:` не краснило ничего. Свидетель: разрешённый тег, разрешённый атрибут, пять токенов — сработать может только сам гард, сообщение это подтверждает. 4. Проекция декора. Единственный кейс задавал flip_h и opacity: 2 → 1; ожидание неотличимо от «opacity игнорируется», а flip_v не проверялся вовсе. Свидетели: четыре комбинации отражений, opacity 0.4 и 0 против заглушки, форма asset_id с согласованным url (прежняя строка ловилась сравнением url, поэтому регулярку id можно было удалить незаметно). 5. Гард benchmark из #423. Доказательство было циклическим: тест вырезал из текста подстроку и спрашивал регулярку, находит ли она её, — а регулярка искала именно её. Динамический режим `--guard-probe` существовал с #423 и не вызывался ни одним прогоном. Теперь он в `demo/guard/verify-guard.mjs` (умеет аргументы и файл выше каталога), а тест из обнаружения требует у нового page-benchmark режим пробы и запись в верификаторе. Мутантов в реестре стало восемь новых: четыре бэкендных, три юнитных, один на пробах гарда. У #423 их было ноль — единственная задача с циклическим тестом и она же единственная без мутантов. Гейты: typecheck зелёный; npm test 1790 tests, 1789 pass, 0 fail; pytest без HA 310 passed, 2 skipped; npm run build зелёный, dist не изменился (продуктовый код не тронут); mutation-gate --check применяет все восемь якорей. Полный прогон новых мутантов — следующим шагом. Issue: #430 User-Visible: no
This commit is contained in:
+13
-2
@@ -12,7 +12,18 @@
|
||||
(`scripts/source-fingerprint.mjs`), то есть каждый новый `.mjs` там объявляет
|
||||
устаревшими закоммиченный бандл, скриншот-индекс документации и golden-индекс.
|
||||
Пробы гарда ни одного пикселя не касаются, платить пересъёмкой за них нечем.
|
||||
3. **Каталог, а не файл** — проб три, и они читаются как набор.
|
||||
3. **Каталог, а не файл** — проб несколько, и они читаются как набор.
|
||||
|
||||
Запускает их `verify-guard.mjs`; он же вызывается из job «Смоки в браузере»
|
||||
и служит guard'ом двух мутантов в `scripts/mutation-gate.mjs`.
|
||||
и служит guard'ом мутантов в `scripts/mutation-gate.mjs`.
|
||||
|
||||
Одна проба живёт не здесь: `--guard-probe` у `demo/benchmark_backdrop_decode.mjs`
|
||||
(#430). Benchmark нельзя переселить в этот каталог — его запускают руками при
|
||||
рекалибровке порогов, — поэтому `verify-guard.mjs` умеет запускать файл выше
|
||||
каталога и с аргументами. Режим `--guard-probe` пропускает все замеры, бросает
|
||||
одно исключение внутри страницы и обязан выйти с кодом 1.
|
||||
|
||||
Новый benchmark, открывающий страницу Playwright, обязан получить такой же
|
||||
режим и запись здесь: этого требует `test/smoke-harness-contract.test.mjs`, и
|
||||
требует статически — потому что динамическое доказательство возможно только
|
||||
там, где есть браузер.
|
||||
|
||||
@@ -18,7 +18,13 @@ import { fileURLToPath } from 'node:url';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
/** Каждая проба: чего ждём от кода возврата и что обязано быть в выводе. */
|
||||
/**
|
||||
* Каждая проба: чего ждём от кода возврата и что обязано быть в выводе.
|
||||
*
|
||||
* `args` — необязательные аргументы запуска; `file` может указывать и выше
|
||||
* этого каталога (`../benchmark_*.mjs`), потому что benchmark в `demo/guard/`
|
||||
* не переселить: его гоняют руками при рекалибровке порогов (#430).
|
||||
*/
|
||||
const PROBES = [
|
||||
{
|
||||
file: 'guard_tail_exception.mjs',
|
||||
@@ -38,6 +44,19 @@ const PROBES = [
|
||||
expectOutput: /OK/,
|
||||
because: 'round-trip к закрытой странице не имеет права ронять вердикт',
|
||||
},
|
||||
{
|
||||
// #430: до этой задачи гард benchmark доказывался тестом, который искал
|
||||
// в тексте те самые подстроки, которые сам же и вырезал у мутанта, —
|
||||
// доказано было, что регулярка не пуста. Режим `--guard-probe` в
|
||||
// benchmark существовал с #423 и не вызывался ни одним прогоном; теперь
|
||||
// вызывается здесь, в единственной job с настоящим браузером.
|
||||
file: '../benchmark_backdrop_decode.mjs',
|
||||
args: ['--guard-probe'],
|
||||
expectExit: 1,
|
||||
expectOutput: /uncaught exception\(s\) inside the card/,
|
||||
because: 'benchmark открывает страницу Playwright и обязан выносить тот же вердикт,'
|
||||
+ ' что и смоки: исключение внутри карточки во время замера иначе не увидит никто',
|
||||
},
|
||||
{
|
||||
file: 'guard_report_page_errors.mjs',
|
||||
expectExit: 1,
|
||||
@@ -48,14 +67,14 @@ const PROBES = [
|
||||
|
||||
let failed = 0;
|
||||
for (const probe of PROBES) {
|
||||
const run = spawnSync(process.execPath, [resolve(HERE, probe.file)], {
|
||||
const run = spawnSync(process.execPath, [resolve(HERE, probe.file), ...probe.args || []], {
|
||||
encoding: 'utf8', cwd: resolve(HERE, '../..'), timeout: 90_000,
|
||||
});
|
||||
const output = `${run.stdout || ''}${run.stderr || ''}`;
|
||||
const exitOk = run.status === probe.expectExit;
|
||||
const textOk = probe.expectOutput.test(output);
|
||||
if (exitOk && textOk) {
|
||||
console.log(`ok ${probe.file} → exit ${run.status}`);
|
||||
console.log(`ok ${[probe.file, ...probe.args || []].join(' ')} → exit ${run.status}`);
|
||||
continue;
|
||||
}
|
||||
failed += 1;
|
||||
|
||||
@@ -4683,6 +4683,106 @@ const MUTANT_DEFINITIONS = [
|
||||
+ " errorCode: '',",
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'decor-raster-full-decode-skipped',
|
||||
guard: 'python3 -m pytest tests_backend/test_decor_assets.py -q -p no:cacheprovider',
|
||||
because: 'a valid-looking PNG whose IDAT is not a zlib stream must be refused before it '
|
||||
+ 'enters the authenticated store; header parsing answers w/h/mime and cannot answer '
|
||||
+ 'whether the raster decodes at all (#51 AC, аудит #430 п.1)',
|
||||
patches: [{
|
||||
file: 'custom_components/houseplan/decor_assets.py',
|
||||
find: ' with Image.open(BytesIO(data)) as image:\n'
|
||||
+ ' image.load()\n'
|
||||
+ ' if image.size != (width, height):\n'
|
||||
+ ' raise DecorAssetError("invalid_image", "Image dimensions are inconsistent")\n'
|
||||
+ ' if getattr(image, "is_animated", False):\n'
|
||||
+ ' raise DecorAssetError("unsupported_image", "Animated images are unsupported")',
|
||||
replace: ' _ = (Image, BytesIO)',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'decor-svg-canonical-bytes-discarded',
|
||||
guard: 'python3 -m pytest tests_backend/test_decor_assets.py -q -p no:cacheprovider',
|
||||
because: 'the stored SVG must be the re-serialised canonical form, not the upload: keeping '
|
||||
+ 'the original bytes silently reinstates whatever the parser dropped — prologue, '
|
||||
+ 'comments, exotic spelling of the same tree (#51 ТЗ §3, аудит #430 п.2)',
|
||||
patches: [{
|
||||
file: 'custom_components/houseplan/decor_assets.py',
|
||||
find: 'return ValidatedAsset(canonical, "image/svg+xml"',
|
||||
replace: 'return ValidatedAsset(data, "image/svg+xml"',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'decor-svg-canonical-size-unchecked',
|
||||
guard: 'python3 -m pytest tests_backend/test_decor_assets.py -q -p no:cacheprovider',
|
||||
because: 'canonicalisation can grow the document fourfold by escaping text, so the 2 MiB '
|
||||
+ 'limit must be re-applied to the canonical bytes: a 1.84 MiB upload otherwise lands '
|
||||
+ 'as 7.35 MiB in the store (аудит #430 п.2)',
|
||||
patches: [{
|
||||
file: 'custom_components/houseplan/decor_assets.py',
|
||||
find: ' _check_size(canonical)\n',
|
||||
replace: '',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'decor-svg-external-url-guard-off',
|
||||
guard: 'python3 -m pytest tests_backend/test_decor_assets.py -q -p no:cacheprovider',
|
||||
because: 'javascript:, data:, http:, https: and // inside an allowed attribute of an allowed '
|
||||
+ 'tag are caught by this rule alone; every "external" case of the original corpus was '
|
||||
+ 'caught by tag or attribute allowlists instead (аудит #430 п.3)',
|
||||
patches: [{
|
||||
file: 'custom_components/houseplan/decor_assets.py',
|
||||
find: 'if any(token in low for token in ("javascript:", "data:", "http:", "https:", "//")):',
|
||||
replace: 'if False:',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'decor-image-flip-v-ignored',
|
||||
guard: 'node --test test/decor-assets.test.mjs',
|
||||
because: 'vertical flip is half of the image projection contract and had no witness of its '
|
||||
+ 'own: the single #51 case set flip_h only, so dropping flip_v stayed green (#51 AC3, '
|
||||
+ 'аудит #430 п.4)',
|
||||
patches: [{
|
||||
file: 'src/decor-assets.ts',
|
||||
find: '${shape.flip_v ? -1 : 1}',
|
||||
replace: '1',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'decor-image-opacity-ignored',
|
||||
guard: 'node --test test/decor-assets.test.mjs',
|
||||
because: 'the projection must carry the shape opacity; the only case asserted opacity 2 → 1, '
|
||||
+ 'an expectation indistinguishable from hardcoding 1 (#51 AC4, аудит #430 п.4)',
|
||||
patches: [{
|
||||
file: 'src/decor-assets.ts',
|
||||
find: 'const opacity = clamp01(shape.opacity, 1);',
|
||||
replace: 'const opacity = 1;',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'decor-asset-id-shape-unchecked',
|
||||
guard: 'node --test test/decor-assets.test.mjs',
|
||||
because: 'the catalog row must prove its own asset_id shape: the malformed row of #51 kept '
|
||||
+ 'the url of a real asset, so the url comparison caught it and the id regex could be '
|
||||
+ 'deleted unnoticed (аудит #430 п.4)',
|
||||
patches: [{
|
||||
file: 'src/decor-assets.ts',
|
||||
find: " if (!DECOR_ASSET_ID_RE.test(String(row.asset_id || '')) || row.url !== expectedUrl",
|
||||
replace: ' if (row.url !== expectedUrl',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'benchmark-page-verdict-unwatched',
|
||||
guard: 'node demo/guard/verify-guard.mjs',
|
||||
because: 'the page benchmark of #423 must register its page with watchPage, and that must be '
|
||||
+ 'proven by running it: the previous proof asked a regexp whether it still finds the '
|
||||
+ 'substring the same test had just deleted (аудит #430 п.5)',
|
||||
patches: [{
|
||||
file: 'demo/benchmark_backdrop_decode.mjs',
|
||||
find: 'const page = watchPage(await (await browser.newContext()).newPage());',
|
||||
replace: 'const page = await (await browser.newContext()).newPage();',
|
||||
}],
|
||||
},
|
||||
];
|
||||
|
||||
const mutationCardSource = readFileSync(join(repoRoot, 'src/houseplan-card.ts'), 'utf8');
|
||||
|
||||
@@ -34,6 +34,33 @@ test('#51 full and static renderers share one fail-closed image projection', ()
|
||||
assert.equal(projectDecorImage({ ...shape, w: 0 }, 1000, 500), null);
|
||||
});
|
||||
|
||||
test('#430 проекция читает flip_v, а не только flip_h', () => {
|
||||
// Единственный кейс #51 задавал flip_h: true и ничего не говорил про flip_v,
|
||||
// поэтому `${shape.flip_v ? -1 : 1}` → `1` оставляло 7 pass. Вертикальное
|
||||
// отражение — половина контракта AC3, и своего свидетеля у неё не было.
|
||||
const shape = { id: 'image', kind: 'image', asset_id: id('d'), x: 0, y: 0, w: 1, h: 1 };
|
||||
const scaleOf = (extra) => projectDecorImage({ ...shape, ...extra }, 100, 100)[5]
|
||||
.match(/scale\([^)]*\)/)[0];
|
||||
assert.equal(scaleOf({}), 'scale(1 1)');
|
||||
assert.equal(scaleOf({ flip_h: true }), 'scale(-1 1)');
|
||||
assert.equal(scaleOf({ flip_v: true }), 'scale(1 -1)');
|
||||
assert.equal(scaleOf({ flip_h: true, flip_v: true }), 'scale(-1 -1)');
|
||||
});
|
||||
|
||||
test('#430 проекция переносит opacity, а не подставляет единицу', () => {
|
||||
// Кейс #51 задавал opacity: 2 и ждал 1 — ожидание, неотличимое от «opacity
|
||||
// игнорируется»: мутант `const opacity = 1` проходил. Промежуточное значение
|
||||
// отличает перенос от заглушки, крайности закрепляют clamp и fallback.
|
||||
const shape = { id: 'image', kind: 'image', asset_id: id('d'), x: 0, y: 0, w: 1, h: 1 };
|
||||
const opacityOf = (opacity) => projectDecorImage({ ...shape, opacity }, 100, 100)[4];
|
||||
assert.equal(opacityOf(0.4), 0.4);
|
||||
assert.equal(opacityOf(0), 0, 'полностью прозрачная картинка — законное состояние');
|
||||
assert.equal(opacityOf(-1), 0);
|
||||
assert.equal(opacityOf(2), 1);
|
||||
assert.equal(opacityOf(undefined), 1, 'нет значения — непрозрачная');
|
||||
assert.equal(opacityOf('nonsense'), 1);
|
||||
});
|
||||
|
||||
test('#51 resolve projection rejects malformed catalog rows', () => {
|
||||
const good = {
|
||||
asset_id: id('c'), name: 'safe.svg', mime: 'image/svg+xml',
|
||||
@@ -46,6 +73,21 @@ test('#51 resolve projection rejects malformed catalog rows', () => {
|
||||
assert.equal(adoptDecorAssets({ assets: [{ ...good, mime: 'image/png' }] }).size, 0);
|
||||
});
|
||||
|
||||
test('#430 форма asset_id проверяется сама, а не через совпадение с url', () => {
|
||||
// Строка `{ ...good, asset_id: 'bad' }` выше не свидетель формы id: у неё
|
||||
// остаётся url настоящего ассета, и её отбивает сравнение url. Поэтому
|
||||
// мутант `!DECOR_ASSET_ID_RE.test(...)` → `false` выживал. Здесь url
|
||||
// согласован с плохим id — сработать может только сама проверка формы.
|
||||
const row = (asset_id) => ({
|
||||
asset_id, name: 'safe.svg', mime: 'image/svg+xml', width: 20, height: 10, bytes: 100,
|
||||
url: `/api/houseplan/content/assets/_/${asset_id}.svg`,
|
||||
});
|
||||
assert.equal(adoptDecorAssets({ assets: [row('bad')] }).size, 0);
|
||||
assert.equal(adoptDecorAssets({ assets: [row(`${id('a')}a`)] }).size, 0, 'длиннее 64');
|
||||
assert.equal(adoptDecorAssets({ assets: [row(id('a').replace('a', 'A'))] }).size, 0, 'не hex');
|
||||
assert.equal(adoptDecorAssets({ assets: [row(id('a'))] }).size, 1, 'корректный id проходит');
|
||||
});
|
||||
|
||||
test('#51 a rotated image contributes its complete visible bounds to framing', () => {
|
||||
const item = decorBoxItem({ x: 0.1, y: 0.2, w: 0.2, h: 0.1, angle: 90 });
|
||||
assert.ok(item);
|
||||
|
||||
@@ -70,16 +70,24 @@ test('пробы вызываются в job с браузером и служа
|
||||
assert.match(smoke, /if: matrix\.shard == 1/, 'один раз, а не в каждом шарде');
|
||||
|
||||
const mutants = read('scripts/mutation-gate.mjs');
|
||||
for (const id of [
|
||||
// Правка #404 состоит из двух половин, и мутант на одну оставил бы другую
|
||||
// недоказанной. Список ведётся руками, и это осознанно: счётчик обязан
|
||||
// совпадать с ним, поэтому новый мутант на этих пробах нельзя добавить, не
|
||||
// назвав его здесь (в #430 так добавился четвёртый — гард page-benchmark).
|
||||
const guarded = [
|
||||
'smoke-guard-blind-to-tail',
|
||||
'smoke-guard-forgets-to-register-pages',
|
||||
'report-page-errors-skips-round-trip',
|
||||
]) {
|
||||
'benchmark-page-verdict-unwatched',
|
||||
];
|
||||
for (const id of guarded) {
|
||||
assert.match(mutants, new RegExp(`id: '${id}'`), `мутант ${id} не зарегистрирован`);
|
||||
}
|
||||
// Правка состоит из двух половин, и мутант на одну оставил бы другую
|
||||
// недоказанной.
|
||||
assert.equal((mutants.match(/node demo\/guard\/verify-guard\.mjs/g) || []).length, 3);
|
||||
assert.equal(
|
||||
(mutants.match(/node demo\/guard\/verify-guard\.mjs/g) || []).length,
|
||||
guarded.length,
|
||||
'мутант на пробах гарда есть, а в списке выше его нет — список отстал от реестра',
|
||||
);
|
||||
});
|
||||
|
||||
test('страницы, созданные вне launch(), подписаны общим гардом (#404, Medium-1)', () => {
|
||||
|
||||
@@ -86,26 +86,39 @@ test('#421 dedicated guard probe reaches reportPageErrors without finish', () =>
|
||||
'finish() замаскирует сломанный round-trip внутри reportPageErrors()');
|
||||
});
|
||||
|
||||
test('#423 every Playwright page benchmark has an enforceable pageerror verdict', () => {
|
||||
test('#423 каждый page-benchmark обязан иметь вердикт и динамическую пробу (#430)', () => {
|
||||
// Что здесь проверяется, а что — нет.
|
||||
//
|
||||
// Этот тест — ОБНАРУЖЕНИЕ: он находит benchmark, открывающий страницу
|
||||
// Playwright, и требует от него формы гарда и наличия отрицательной пробы.
|
||||
// Поведение гарда в рантайме он не доказывает и не может: браузера в job
|
||||
// «Фронтенд» нет. Доказывает запуск — `demo/guard/verify-guard.mjs`.
|
||||
//
|
||||
// До #430 здесь стояли две «отрицательные проверки»: результат
|
||||
// `pageBenchmarkIsGuarded(source.replace('watchPage(', '('))` обязан быть
|
||||
// false. Но сама функция буквально ищет подстроку `watchPage(` — вырезав её,
|
||||
// мы спрашивали регулярку, находит ли она то, что мы только что удалили.
|
||||
// Доказано было, что регулярка не пуста. Ровно тот вид проверки, против
|
||||
// которого заведён мутационный гейт, и он же — единственная находка аудита
|
||||
// v1.71.0-beta.1, где тест был циклическим, а не просто слабым.
|
||||
const benchmarks = readdirSync(DEMO)
|
||||
.filter((name) => name.startsWith('benchmark_') && name.endsWith('.mjs'))
|
||||
.filter((name) => /\.newPage\(/.test(read(name)));
|
||||
assert.deepEqual(benchmarks, ['benchmark_backdrop_decode.mjs']);
|
||||
const verifier = readFileSync(
|
||||
new URL('../demo/guard/verify-guard.mjs', import.meta.url), 'utf8',
|
||||
);
|
||||
for (const name of benchmarks) {
|
||||
const source = read(name);
|
||||
assert.equal(pageBenchmarkIsGuarded(source), true, `${name}: missing pageerror guard`);
|
||||
assert.equal(
|
||||
pageBenchmarkIsGuarded(source.replace('watchPage(', '(')),
|
||||
false,
|
||||
`${name}: removing watchPage must make the contract red`,
|
||||
);
|
||||
assert.equal(
|
||||
pageBenchmarkIsGuarded(source.replace('reportPageErrors()', 'false')),
|
||||
false,
|
||||
`${name}: removing the final verdict must make the contract red`,
|
||||
assert.match(source, /process\.argv\.includes\('--guard-probe'\)/,
|
||||
`${name}: нужен режим отрицательной пробы — без него гард не проверить запуском`);
|
||||
assert.match(source, /setTimeout\(\(\) => \{ throw new Error\(/,
|
||||
`${name}: проба обязана бросать исключение ВНУТРИ страницы, в хвосте замера`);
|
||||
assert.ok(
|
||||
new RegExp(`file: '\\.\\./${name}',\\s*\n\\s*args: \\['--guard-probe'\\]`).test(verifier),
|
||||
`${name}: нет записи в demo/guard/verify-guard.mjs — проба существует и не вызывается,`
|
||||
+ ' а это ровно то состояние, в котором #423 прожил до #430',
|
||||
);
|
||||
}
|
||||
const backdrop = read('benchmark_backdrop_decode.mjs');
|
||||
assert.match(backdrop, /process\.argv\.includes\('--guard-probe'\)/);
|
||||
assert.match(backdrop, /houseplan backdrop guard probe/);
|
||||
});
|
||||
|
||||
@@ -2,10 +2,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import importlib
|
||||
import json
|
||||
import struct
|
||||
import zlib
|
||||
|
||||
import pytest
|
||||
|
||||
from custom_components.houseplan.const import MAX_DECOR_ASSET_BYTES
|
||||
from custom_components.houseplan.decor_assets import (
|
||||
DecorAssetError,
|
||||
asset_meta_path,
|
||||
@@ -51,9 +55,11 @@ def test_declared_mime_must_agree_with_extension_and_bytes() -> None:
|
||||
(WEBP_VP8L_3X2, "lossless.webp", "image/webp"),
|
||||
(WEBP_VP8X_3X2, "alpha.webp", "image/webp"),
|
||||
])
|
||||
def test_supported_raster_headers_and_full_decode(
|
||||
def test_supported_raster_headers_and_dimensions(
|
||||
payload: bytes, filename: str, mime: str,
|
||||
) -> None:
|
||||
"""Имя до #430 обещало «and_full_decode», а проверялись w/h/mime — их даёт
|
||||
header-парсер, и мутация полного декодирования оставалась зелёной."""
|
||||
asset = validate_asset(payload, filename, "application/octet-stream")
|
||||
assert (asset.width, asset.height, asset.mime) == (3, 2, mime)
|
||||
|
||||
@@ -185,11 +191,121 @@ def test_svg_rejects_one_oversized_attribute_before_tree_use() -> None:
|
||||
validate_asset(payload, "bounded.svg")
|
||||
|
||||
|
||||
def test_valid_looking_but_truncated_raster_is_rejected_by_full_decode() -> None:
|
||||
with pytest.raises(DecorAssetError, match="corrupt|decode"):
|
||||
def test_truncated_raster_is_rejected_by_the_header_parser() -> None:
|
||||
"""Обрезка на 33 байтах убирает `IEND`, поэтому файл отбивает парсер
|
||||
заголовка. До #430 этот тест назывался «by_full_decode» и записывался в
|
||||
доказательство декодирования — при no-op блока Pillow он оставался
|
||||
зелёным. Настоящий свидетель декодирования — тест ниже."""
|
||||
with pytest.raises(DecorAssetError, match="corrupt or has the wrong type"):
|
||||
validate_asset(PNG_1X1[:33], "truncated.png", "image/png")
|
||||
|
||||
|
||||
def _png_with_corrupt_idat() -> bytes:
|
||||
"""PNG, безупречный для парсера заголовка и мёртвый для декодера.
|
||||
|
||||
Сигнатура, IHDR с честными 1x1, IEND на месте, длины и CRC всех чанков
|
||||
верны — придраться нечему, пока кто-нибудь не попробует распаковать IDAT,
|
||||
в котором лежит не zlib-поток, а текст.
|
||||
"""
|
||||
def chunk(kind: bytes, payload: bytes) -> bytes:
|
||||
return (struct.pack(">I", len(payload)) + kind + payload
|
||||
+ struct.pack(">I", zlib.crc32(kind + payload) & 0xFFFFFFFF))
|
||||
|
||||
return (b"\x89PNG\r\n\x1a\n"
|
||||
+ chunk(b"IHDR", struct.pack(">IIBBBBB", 1, 1, 8, 0, 0, 0, 0))
|
||||
+ chunk(b"IDAT", b"not a zlib stream at all")
|
||||
+ chunk(b"IEND", b""))
|
||||
|
||||
|
||||
def test_valid_looking_png_is_rejected_by_the_full_pillow_decode() -> None:
|
||||
"""Свидетель полного декодирования (#430, п.1 аудита v1.71.0-beta.1).
|
||||
|
||||
Раньше блок `with Image.open(...) as image: image.load()` можно было
|
||||
заменить на no-op, и все 35 тестов файла оставались зелёными: заголовок
|
||||
отвечал на все вопросы, которые они задавали. Здесь спрашивается то, на
|
||||
что заголовок ответить не может, — распаковывается ли растр вообще.
|
||||
"""
|
||||
pytest.importorskip("PIL", reason="полное декодирование делает Pillow")
|
||||
with pytest.raises(DecorAssetError, match="cannot be decoded"):
|
||||
validate_asset(_png_with_corrupt_idat(), "corrupt.png", "image/png")
|
||||
|
||||
|
||||
def test_pillow_is_present_wherever_home_assistant_is() -> None:
|
||||
"""Свидетель выше не имеет права молча пропускаться в каноне.
|
||||
|
||||
`validate_asset` глотает `ImportError` осознанно: чистое подмножество
|
||||
тестов должно работать без всего HA. Цена — пропуск теста там, где Pillow
|
||||
нет, а пропущенный тест выглядит как пройденный. Поэтому в окружении с
|
||||
Home Assistant (Linux CI, WSL) отсутствие Pillow — красное само по себе.
|
||||
"""
|
||||
pytest.importorskip("homeassistant", reason="чистая песочница без HA")
|
||||
importlib.import_module("PIL.Image")
|
||||
|
||||
|
||||
def test_svg_asset_carries_canonical_bytes_not_the_upload() -> None:
|
||||
"""Свидетель канонизации (#430, п.2). ТЗ §3 требует ре-сериализацию, а
|
||||
`ValidatedAsset(canonical, …)` → `ValidatedAsset(data, …)` не краснило ни
|
||||
один тест: все проверяли w/h/mime и ни один — сами байты.
|
||||
"""
|
||||
raw = (b'<?xml version="1.0" encoding="utf-8"?>\n'
|
||||
b'<!-- pipeline comment -->\n'
|
||||
b'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 20" >'
|
||||
b'<rect x="0" y="0" width="1" height="1"></rect>'
|
||||
b'<title>a>b</title>'
|
||||
b'</svg>')
|
||||
asset = validate_asset(raw, "canonical.svg")
|
||||
assert asset.data != raw
|
||||
assert not asset.data.startswith(b"<?xml"), "XML-пролог не переживает канонизацию"
|
||||
assert b"<!--" not in asset.data, "комментарий не переживает канонизацию"
|
||||
assert b"<rect " in asset.data and b"</rect>" not in asset.data, "пустой элемент сжимается"
|
||||
assert asset.data == (
|
||||
b'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 20">'
|
||||
b'<rect x="0" y="0" width="1" height="1" />'
|
||||
b'<title>a>b</title>'
|
||||
b'</svg>'
|
||||
)
|
||||
|
||||
|
||||
def test_svg_size_limit_applies_to_canonical_bytes() -> None:
|
||||
"""Свидетель второй половины п.2: `_check_size(canonical)`.
|
||||
|
||||
Экранирование `>` в тексте раздувает документ вчетверо, поэтому загрузка
|
||||
размером 1.84 МиБ канонизуется в 7.35 МиБ. Проверка размера на входе это
|
||||
пропускает; снятие `_check_size(canonical)` до #430 не краснило ничего, и
|
||||
в хранилище уезжал файл вчетверо больше объявленного предела.
|
||||
"""
|
||||
payload = (b'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 20">'
|
||||
+ (b"<desc>" + b">" * 4096 + b"</desc>") * 470
|
||||
+ b"</svg>")
|
||||
assert len(payload) < MAX_DECOR_ASSET_BYTES, "загрузка обязана проходить входной контроль"
|
||||
with pytest.raises(DecorAssetError, match="2 MiB"):
|
||||
validate_asset(payload, "canonical-bomb.svg")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [
|
||||
"javascript:alert(1)",
|
||||
"data:image/png;base64,AAAA",
|
||||
"http://example.com/x",
|
||||
"https://example.com/x",
|
||||
"//example.com/x",
|
||||
])
|
||||
def test_svg_external_resource_guard_catches_what_no_other_rule_does(value: str) -> None:
|
||||
"""Свидетель гарда внешних URL (#430, п.3).
|
||||
|
||||
Все три «внешних» кейса корпуса ловились другими правилами: тегом не из
|
||||
словаря, атрибутом не из словаря, ветвью `name == "href"`. Замена условия
|
||||
на `if False:` не краснила ничего. Здесь тег и атрибут разрешённые, `url(`
|
||||
нет, `href` нет — сработать может только сам токен-гард, и сообщение это
|
||||
подтверждает.
|
||||
"""
|
||||
payload = (
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 20">'
|
||||
f'<path fill="{value}" d="M0 0L1 1"/></svg>'
|
||||
).encode()
|
||||
with pytest.raises(DecorAssetError, match="External SVG resources are forbidden"):
|
||||
validate_asset(payload, "external.svg")
|
||||
|
||||
|
||||
def test_catalog_ignores_missing_or_malformed_sidecars(tmp_path) -> None:
|
||||
aid = "a" * 64
|
||||
(tmp_path / f"{aid}.png").write_bytes(PNG_1X1)
|
||||
|
||||
Reference in New Issue
Block a user