test(gates): свидетели пяти защитных контрактов #51 и #423

Аудит 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:
Claude
2026-09-03 10:28:00 +03:00
parent c3eb225c8f
commit ee678352c7
7 changed files with 335 additions and 26 deletions
+13 -2
View File
@@ -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`, и
требует статически — потому что динамическое доказательство возможно только
там, где есть браузер.
+22 -3
View File
@@ -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;
+100
View File
@@ -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');
+42
View File
@@ -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);
+13 -5
View File
@@ -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)', () => {
+26 -13
View File
@@ -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/);
});
+119 -3
View File
@@ -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&gt;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&gt;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)