fix: exact upload quota, support palette allowlist, bounded SVG reference chains (#498)

Attachment uploads stage the body as `.upload-*` under files_root and then
asked the quota to count that file as stored usage *and* as the incoming
size, so the last file that still fit was refused at the boundary — by
bytes and by count. check_quota/dir_usage now take `exclude` for the
caller's own staged file; other staged files keep counting, so two
concurrent uploads can never both land past the limit.

The support package copied every string key of settings.fill_colors. The
schema stays open for compatibility, but the projection now keeps only the
eleven slots the card defines (SUPPORT_FILL_COLOR_KEYS, pinned to
src/logic.ts DEFAULT_FILL_COLORS by a test); an empty palette is omitted.

The SVG local-reference walk was a recursive DFS: a flat chain of a few
thousand hrefs passed every #436 bound and died with RecursionError, which
the upload view turned into a 500. The walk is iterative and measures the
longest chain through each node (memoised, order-independent); chains
deeper than MAX_SVG_REF_DEPTH = 64 are refused as too_large, cycles stay
invalid_image.

Tests: quota boundaries on the validator and the HA endpoint, a barrier
test for concurrent uploads, palette allowlist and TS parity, reference
chains (plain, hostile id order, cycle) on the validator and the endpoint;
six mutants caught by the standard runner.

Issue: #498
User-Visible: yes
This commit is contained in:
Codex
2026-09-09 07:06:20 +00:00
committed by claude[bot]
parent 5f8d40caf0
commit 4f040c4c8e
12 changed files with 420 additions and 31 deletions
+98
View File
@@ -8087,6 +8087,104 @@ const MUTANT_DEFINITIONS = [
+ ' # The store is the durable authority (#335): a drop that\n',
}],
},
{
id: 'quota-counts-the-staged-upload-twice',
guard: 'node scripts/backend-test-guard.mjs '
+ 'issue_498_upload_accepts_the_last_bytes '
+ 'tests_backend/test_ha_upload.py',
because: 'the staged .upload-* already lives under files_root; charging it as stored usage '
+ 'and as incoming refuses the last file that still fits (#498 AC1)',
patches: [{
file: 'custom_components/houseplan/http_api.py',
find: ' MAX_FILES_BYTES, MAX_FILES_COUNT, exclude=tmp_path,\n',
replace: ' MAX_FILES_BYTES, MAX_FILES_COUNT,\n',
}],
},
{
id: 'quota-ignores-foreign-staged-uploads',
guard: 'node scripts/backend-test-guard.mjs '
+ 'issue_498_concurrent_uploads_still_count_each_other '
+ 'tests_backend/test_ha_upload.py',
because: 'only the caller\'s own staged file is exempt: skipping every .upload-* would let two '
+ 'concurrent uploads pass a quota neither of them fits alone (#498 AC1)',
patches: [{
file: 'custom_components/houseplan/plans.py',
find: ' if exclude is not None and item == exclude:\n',
replace: ' if item.name.startswith(TMP_PREFIX): # mutant: every staged file is invisible\n',
}],
},
{
id: 'support-palette-copies-any-key',
guard: 'node scripts/backend-test-guard.mjs '
+ 'rich_plan_projection_preserves_safe_structure '
+ 'tests_backend/test_support_package.py',
because: 'the package must carry the fill palette only under the slot names the card defines; '
+ 'a private string used as a key must not leave the installation (#498 AC2)',
patches: [{
file: 'custom_components/houseplan/support_package.py',
find: ' for key in SUPPORT_FILL_COLOR_KEYS\n',
replace: ' for key in fill_colors\n',
}],
},
{
id: 'svg-reference-chain-unbounded',
guard: 'node scripts/backend-test-guard.mjs '
+ 'issue_498_flat_reference_chain_is_bounded '
+ 'tests_backend/test_decor_assets.py',
because: 'a reference chain must stop at MAX_SVG_REF_DEPTH with too_large; an unbounded walk '
+ 'accepts what rendering will choke on (#498 AC3)',
patches: [{
file: 'custom_components/houseplan/decor_assets.py',
find: ' if chain > MAX_SVG_REF_DEPTH:\n',
replace: ' if False: # mutant: no chain limit\n',
}, {
file: 'custom_components/houseplan/decor_assets.py',
find: ' if len(stack) > MAX_SVG_REF_DEPTH:\n',
replace: ' if False: # mutant: no stack limit\n',
}],
},
{
id: 'svg-reference-depth-per-start-not-per-chain',
guard: 'node scripts/backend-test-guard.mjs '
+ 'issue_498_flat_reference_chain_is_bounded '
+ 'tests_backend/test_decor_assets.py',
because: 'the limit is the longest chain through a node; measuring only the stack of the '
+ 'traversal that reached it first lets a hostile id order cut a long chain into short '
+ 'segments (#498 spec review r1)',
patches: [{
file: 'custom_components/houseplan/decor_assets.py',
find: ' stack[-1] = (node_id, children, max(chain, longest[ref] + 1))\n',
replace: ' stack[-1] = (node_id, children, chain) # mutant: forget the memoised chain\n',
}],
},
{
id: 'svg-reference-walk-recursive-again',
guard: 'node scripts/backend-test-guard.mjs '
+ 'issue_498_flat_reference_chain_is_bounded '
+ 'tests_backend/test_decor_assets.py',
because: 'the walk must be iterative: a recursive DFS over a flat 2500-link chain dies with '
+ 'RecursionError, which is not a DecorAssetError and reaches the client as a 500 (#498 AC3)',
patches: [{
file: 'custom_components/houseplan/decor_assets.py',
find: ' _walk_reference_graph(ids, ref_graph)\n',
replace: ' visiting: set[str] = set()\n'
+ ' visited: set[str] = set()\n'
+ '\n'
+ ' def _visit(node_id: str) -> None: # mutant: the old recursive walk\n'
+ ' if node_id in visiting:\n'
+ ' raise DecorAssetError("invalid_image", "The SVG contains a cyclic local reference")\n'
+ ' if node_id in visited:\n'
+ ' return\n'
+ ' visiting.add(node_id)\n'
+ ' for ref in ref_graph.get(node_id, ()):\n'
+ ' _visit(ref)\n'
+ ' visiting.remove(node_id)\n'
+ ' visited.add(node_id)\n'
+ '\n'
+ ' for node_id in ids:\n'
+ ' _visit(node_id)\n',
}],
},
];
const mutationCardSource = readFileSync(join(repoRoot, 'src/houseplan-card.ts'), 'utf8');