mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
The guard introduced by #394 matched the literal sys.modules['custom_components... and therefore never looked at pure_imports.py, which writes through a variable — the third instance of the #389 class walked straight past the check created for it. The guard now inspects the write itself and decides by the key: a whole literal or the literal head of an f-string is safe unless it starts with custom_components (that is how tests register homeassistant.*, hp_pure.* and houseplan.trails); anything else — a variable, a concatenation, setdefault/update — counts as a violation whenever the file is able to name the package at all, i.e. contains a custom_components. literal. A file that never names the package cannot poison it through a variable, so restoring a snapshot stays legal. load_pure now removes what it registered. Removing its own name is not enough: relative imports pull neighbours in, so junction_limits leaves wall_segment_model and coordinate_canonicalization behind. It removes the whole custom_components difference accumulated during exec_module, in a finally, and a repeated call still works. pure_imports.py is a named exemption of the static guard precisely because that guard cannot see the cleanup — so the cleanup is proven by an executable test instead, and the mutant pure-imports-stops-cleaning reddens it. Both mutants were run by hand. User-Visible: no Issue: #398
177 lines
12 KiB
JavaScript
177 lines
12 KiB
JavaScript
import test from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { readFileSync, readdirSync } from 'node:fs';
|
||
import { fileURLToPath } from 'node:url';
|
||
|
||
// Гигиена backend-тестов (#393): тест не имеет права оставлять после себя
|
||
// изменённую импорт-машину. В #389 это
|
||
// стоило пяти часов красного dev: пустышка в sys.modules пережила свой тест,
|
||
// Home Assistant перестал видеть у интеграции async_setup, и 85 тестов
|
||
// харнесса падали с голым `assert False`, не намекая на причину.
|
||
//
|
||
// Проверка статическая — по исходникам, без запуска. Это намеренно: рантайм
|
||
// ловит последствие и только на том порядке тестов, который сегодня сложился,
|
||
// а исходник ловит намерение.
|
||
|
||
const BACKEND = fileURLToPath(new URL('../tests_backend/', import.meta.url));
|
||
|
||
/** Файлы, которым запись в `sys.modules` разрешена поимённо (#398 AC3).
|
||
*
|
||
* Список короткий не случайно. `conftest.py` ставит пустышки родительских
|
||
* пакетов, когда Home Assistant недоступен, — по условию и осознанно (#394).
|
||
* `pure_imports.py` регистрирует загружаемый модуль ровно на время
|
||
* `exec_module` и снимает регистрацию в `finally`; статический разбор этого
|
||
* знать не может, поэтому файл назван здесь, а то, что он действительно
|
||
* убирает за собой, доказывает исполняемая проверка ниже. */
|
||
const SYS_MODULES_WRITERS = ['conftest.py', 'pure_imports.py'];
|
||
|
||
const files = () => readdirSync(BACKEND).filter((name) => name.endsWith('.py'));
|
||
const read = (name) => readFileSync(new URL(name, `file://${BACKEND}`), 'utf8');
|
||
|
||
/** Записи в `sys.modules`, про которые нельзя доказать, что пакет интеграции
|
||
* они не трогают (#398).
|
||
*
|
||
* Прежняя проверка искала строковый литерал `sys.modules['custom_components`
|
||
* и не видела `sys.modules[name] = module` — так третий заход класса #389
|
||
* проехал мимо гварда, заведённого ради него. Здесь разбирается сам факт
|
||
* записи, а решение принимается по ключу:
|
||
*
|
||
* - ключ — литерал или f-строка с литеральным началом: безопасно, если это
|
||
* начало не `custom_components` (так тесты ставят `homeassistant.*`,
|
||
* `hp_pure.*`, `houseplan.trails` — свои имена, к пакету не относящиеся);
|
||
* - ключ — выражение, либо это `setdefault`/`update`: судить о содержимом
|
||
* статически нельзя, поэтому запись считается нарушением, КОГДА файл вообще
|
||
* способен назвать пакет интеграции — то есть содержит литерал вида
|
||
* `custom_components.` (с точкой: это имя модуля, а не кусок пути).
|
||
*
|
||
* Последнее и есть fail-closed в осмысленной форме: файл, который нигде не
|
||
* упоминает имя пакета, отравить его переменной не может, а файл, который
|
||
* упоминает, обязан делать это разрешённым способом. */
|
||
function sysModulesWrites(source) {
|
||
const code = source.replace(/#[^\n]*/g, '');
|
||
const namesThePackage = /(['"])custom_components\./.test(code);
|
||
// Начало ключа, если оно доказуемо литеральное: целая строка либо f-строка
|
||
// до первой подстановки. Всё прочее (конкатенация, вызов, переменная) — не
|
||
// литерал: доказать по нему ничего нельзя.
|
||
const literalStart = (key) => {
|
||
const whole = /^(['"])((?:[^'"\\]|\\.)*)\1$/.exec(key.trim());
|
||
if (whole) return whole[2];
|
||
const fstring = /^f(['"])((?:[^'"\\{]|\\.)*)/.exec(key.trim());
|
||
return fstring ? fstring[2] : null;
|
||
};
|
||
const hits = [];
|
||
for (const match of code.matchAll(/sys\.modules\s*\[([^\]]*)\]\s*=/g)) {
|
||
const start = literalStart(match[1]);
|
||
if (start === null) { if (namesThePackage) hits.push(match[0]); continue; }
|
||
if (start.startsWith('custom_components')) hits.push(match[0]);
|
||
}
|
||
for (const match of code.matchAll(/sys\.modules\.(?:setdefault|update)\s*\(/g)) {
|
||
if (namesThePackage) hits.push(match[0]);
|
||
}
|
||
return hits;
|
||
}
|
||
|
||
test('backend-тесты не правят sys.path (#393)', () => {
|
||
const offenders = files().filter((name) => /^\s*sys\.path\b/m.test(read(name)));
|
||
assert.deepEqual(offenders, [],
|
||
'sys.path, изменённый при коллекции, действует на всю сессию: модули пакета'
|
||
+ ' становятся импортируемыми ещё и как модули верхнего уровня, и один файл'
|
||
+ ' может оказаться в sys.modules дважды с разными объектами классов.'
|
||
+ ' Читать модуль без Home Assistant можно и без этого — см. test_trails.py:'
|
||
+ ' текст файла и exec нужного среза.');
|
||
});
|
||
|
||
test('пакет интеграции подменяет только conftest и только без HA (#394)', () => {
|
||
// Пустышка вместо `custom_components.houseplan` — единственный способ читать
|
||
// подмодули без Home Assistant, и запретить её нельзя. Но место у неё одно:
|
||
// conftest, где решение принимается по честному признаку «есть ли HA».
|
||
//
|
||
// Когда её ставили сами тесты под условием «если ещё не импортирован», в CI
|
||
// она не срабатывала лишь потому, что настоящий пакет успевал импортироваться
|
||
// из файла, который идёт раньше по алфавиту. Корректность харнесса держалась
|
||
// на именах файлов; чем это кончается, показал #389.
|
||
// #398: проверка смотрит на ФАКТ записи в `sys.modules`, а не на её запись
|
||
// строковым литералом. Прежняя регулярка искала `sys.modules['custom_...`
|
||
// и не видела `sys.modules[name] = module` в pure_imports.py — третий заход
|
||
// того же класса проехал мимо гварда, заведённого ради него.
|
||
//
|
||
// Разбор грубый и fail-closed: ключ, про который нельзя доказать, что он не
|
||
// из `custom_components`, считается нарушением. Догонять формы записи
|
||
// регуляркой — бесконечная гонка, отказывать на непонятном — нет.
|
||
const offenders = files()
|
||
.filter((name) => !SYS_MODULES_WRITERS.includes(name))
|
||
.filter((name) => sysModulesWrites(read(name)).length > 0);
|
||
assert.deepEqual(offenders, [],
|
||
'запись в sys.modules живёт только в tests_backend/conftest.py (условная'
|
||
+ ' подмена пакета) и tests_backend/pure_imports.py (регистрация, снимаемая'
|
||
+ ' тем же вызовом). В остальных файлах она переживает свой тест и'
|
||
+ ' достаётся всей сессии (#389, #394, #398).');
|
||
|
||
const conftest = read('conftest.py');
|
||
assert.match(conftest, /if not HAS_HA:/,
|
||
'подмена в conftest обязана быть под условием отсутствия Home Assistant');
|
||
const stub = conftest.slice(conftest.indexOf('if not HAS_HA:'));
|
||
assert.match(stub, /sys\.modules\[_name\] = _module/);
|
||
});
|
||
|
||
// --- #398: сам гвард обязан ловить свой класс ----------------------------
|
||
// Проверка, которая не умеет краснеть, — это не проверка. Формы записи
|
||
// перечислены поимённо, потому что каждая из них уже встречалась в проекте
|
||
// или на расстоянии одной правки от встречавшейся.
|
||
|
||
const guardSees = (code) => sysModulesWrites(code).length > 0;
|
||
// Файл, который где-то называет пакет интеграции как модуль — именно то
|
||
// условие, при котором запись «неизвестно чем» становится опасной.
|
||
const PACKAGE_NAMED = 'PKG = "custom_components.houseplan"\n';
|
||
|
||
test('#398 AC1: запись через переменную не проходит мимо гварда', () => {
|
||
assert.equal(guardSees(`${PACKAGE_NAMED}sys.modules[name] = module\n`), true,
|
||
'ключ-переменная — ровно та форма, которую прежняя регулярка не видела');
|
||
});
|
||
|
||
test('#398 AC2: остальные формы записи тоже ловятся или отклоняются', () => {
|
||
for (const line of [
|
||
'sys.modules[f"custom_components.{name}"] = module',
|
||
'sys.modules["custom_" + "components.houseplan"] = module',
|
||
'sys.modules.setdefault("custom_components.houseplan", module)',
|
||
'sys.modules.update({"custom_components.houseplan": module})',
|
||
'sys.modules[ key ] = module',
|
||
]) {
|
||
assert.equal(guardSees(`${PACKAGE_NAMED}${line}\n`), true, `не поймана форма: ${line}`);
|
||
}
|
||
});
|
||
|
||
test('#398 AC2: безопасное не объявляется нарушением', () => {
|
||
for (const line of [
|
||
'sys.modules["homeassistant.core"] = core',
|
||
'sys.modules[f"hp_pure.{dep}"] = mod',
|
||
'sys.modules["houseplan.trails"] = mod',
|
||
'# sys.modules["custom_components.houseplan"] = stub # так писать нельзя',
|
||
]) {
|
||
assert.equal(guardSees(`${line}\n`), false, `ложное срабатывание на: ${line}`);
|
||
}
|
||
// Файл, который нигде не называет пакет интеграции, отравить его
|
||
// переменной не может — восстановление снимка остаётся законным.
|
||
assert.equal(guardSees('sys.modules.update(saved)\n'), false);
|
||
});
|
||
|
||
test('#398 AC3: список файлов с правом записи закрыт', () => {
|
||
assert.deepEqual(SYS_MODULES_WRITERS, ['conftest.py', 'pure_imports.py'],
|
||
'третий файл в списке — это новое исключение, а не правка: оно требует'
|
||
+ ' своего обоснования и отдельного решения, а не молчаливого добавления');
|
||
});
|
||
|
||
test('#398 AC8: pure_imports снимает регистрацию, а не обещает', () => {
|
||
// Файл в списке исключений не потому, что ему доверяют, а потому, что
|
||
// статический разбор не видит очистки. Значит очистку проверяет этот тест —
|
||
// иначе исключение стало бы дырой ровно того размера, что #389.
|
||
const source = read('pure_imports.py');
|
||
assert.match(source, /before = frozenset\(sys\.modules\)/,
|
||
'снимок берётся до загрузки');
|
||
assert.match(source, /finally:[\s\S]*del sys\.modules\[key\]/,
|
||
'разница снимается в finally — даже если exec_module бросил');
|
||
assert.match(source, /key\.startswith\(PACKAGE_ROOT\.name\)/,
|
||
'снимается вся разница под префиксом пакета, а не одно собственное имя:'
|
||
+ ' относительные импорты подтягивают соседей');
|
||
});
|