fix: keep backup files out of preset export archives - #192
Conversation
Preset export walked the whole preset directory and only filtered *.pyc, *.pyo, *.swp, *.tmp and *~, so an editor/tooling backup sitting beside a script (ranked.py.bak-pre-player-ip-connected-20260704-222233) went into the ZIP. Import only accepts .py/.txt/.so/font extensions under scripts/ and hard-fails the whole archive on anything else, so QLSM was producing archives QLSM itself rejected with 400 "Unsupported script file". Add ARCHIVE_EXCLUDED_PATTERNS (*.bak, *.bak-*, *.bak.*, *.orig, *.rej) to _should_skip_export_path, which export and import both call -- so archives already exported with a stray backup now import cleanly instead of needing a re-export. Keep _ignore_generated_script_cruft on the narrower list. It feeds the copytree that repopulates a preset's scripts directory after an rmtree, so filtering backups there would delete the user's own .bak files on the next preset save.
There was a problem hiding this comment.
Code Review
Strengths
- Clear separation of concerns (
preset_api_routes.py:68–108): Splitting_should_skip_export_pathinto a thin wrapper over_matches_excluded_path(patterns)is the right abstraction. The archive filter and the draft-save filter now differ only in thepatternsargument, making the distinction explicit and auditable. - Intentional asymmetry is documented (
preset_api_routes.py:88–93): The docstring on_ignore_generated_script_cruftclearly explains why backup files are kept during draft saves (they live in the user's real scripts directory) but dropped from archives (the import validator rejects unknown extensions). This is a non-obvious invariant and the comment earns its place. - Pattern set is correct and complete for the reported issue:
*.bak,*.bak-*, and*.bak.*handle the three structural variants of timestamped or numbered backups;*.origand*.rejcover merge-tool artefacts. Each pattern handles a structurally distinct case without overlapping. - False-positive test is present (
test_preset_import_validation.py:355–363):test_keeps_scripts_whose_name_merely_contains_bakexplicitly guards againstbakery.pybeing swallowed — exactly the kind of boundary case that catches substring-match bugs. - Round-trip symmetry (
test_preset_download_routes.py:263–293):test_download_preset_excludes_backup_filesverifies the archive side;test_update_preset_from_draft_keeps_user_backup_filesverifies the draft-save side preserves those same files. Together they pin the contract from both directions. - No new dependencies, no auth surface changes, no logging of sensitive data: security footprint is unchanged.
Issues
Critical (Must Fix)
None.
Important (Should Fix)
None.
Minor (Nice to Have)
_matches_excluded_path has no docstring (preset_api_routes.py:68).
The patterns parameter is the load-bearing distinction between archive and draft-save behaviour. A one-line note — e.g. """Core path filter; callers supply the pattern set.""" — would make it immediately clear why two callers pass different values rather than sharing a constant.
*.bak-* could match any file with .bak- anywhere in its name (preset_api_routes.py:57).
fnmatch.fnmatch('foo.bak-bar.py', '*.bak-*') returns True, so a hypothetically legitimate file named foo.bak-bar.py would be excluded. This is almost certainly fine in practice (no real script would be named that way), but it is worth a comment alongside the pattern to flag the wideness.
Assessment
Ready to merge? Yes
Reasoning: This is a focused, well-scoped fix for a concrete bug (archive exports including backup files that QLSM's own import validator then rejects). The refactoring is clean, the asymmetry between archive and draft-save filtering is correctly identified and documented, and all three behavioural paths (archive exclusion, import exclusion, draft-save preservation) are covered by dedicated tests with boundary checks.
The first pass kept .bak/.orig/.rej out of export archives but deliberately let them through on draft save, on the premise that saving a preset rewrites "the user's real scripts directory" and filtering there would delete their backups. That premise is wrong. Both callers of _ignore_generated_script_cruft (preset_api_routes.py:1144 and :1413) write into a preset's scripts dir -- neither touches a live instance config -- and each rmtree's that directory before copying the draft over it, so nothing of the user's survives there anyway. There are no .bak files in configs/presets to preserve. Worse, the asymmetry created the divergence it was supposed to avoid: a preset on disk could hold files its own export would drop, and those files could never survive a round-trip because the import validator rejects their extensions. A preset is a curated artefact, so one pattern set now covers export, import and draft save. That collapses the _matches_excluded_path split the asymmetry needed, and inverts the draft-save test: a backup reaching a draft must not be written into the preset. The import-side filter stays: it lets archives that already contain backups import cleanly instead of failing with "Unsupported script file". Verified: 0 of 542 real files under configs/presets match the patterns. Claude-Session: https://claude.ai/code/session_018T5f3ryb4q1kFavSjAaJFH
There was a problem hiding this comment.
PR Review: Filter backup files on every preset path
Summary
This PR extends EXPORT_EXCLUDED_PATTERNS to cover editor/tooling backup files (.bak, .bak-*, .bak.*, .orig, .rej) and ensures the single _should_skip_export_path function is the gatekeeper on all three preset paths: export ZIP, import ZIP parse, and draft-to-preset copy. Three corresponding test files each gain one or two focused tests.
Strengths
- Single point of truth (
preset_api_routes.py:73–88): the pattern list lives in one place and is imported bypreset_import_validation.pyand used by_ignore_generated_script_cruft, so all three paths stay in sync automatically. This is the right design. - False-positive guard (
test_keeps_scripts_whose_name_merely_contains_bak): the test explicitly checks thatbakery.pyis not swallowed, which validates thefnmatchpatterns don't over-match. - Layered coverage: the new tests cover export (
test_preset_download_routes.py), import (test_preset_import_validation.py), and draft save (test_preset_routes.py) independently — each path is exercised by a dedicated test rather than relying on a single end-to-end case. - Comment explains the "why" (
preset_api_routes.py:54–61): the inline rationale (import validator rejects these extensions → round-trip breaks) prevents someone from removing the patterns as "unnecessary" in the future. - Path-traversal safety is unaffected: the existing
_validate_entry_name/_resolve_export_rootsafeguards were not changed, and the new patterns only act as an additional skip layer after those checks pass.
Issues
Critical (Must Fix)
None.
Important (Should Fix)
1. *.rej pattern may be too broad for the configs/ tree
preset_api_routes.py:61
fnmatch matches on the bare filename regardless of directory. A hypothetical legitimate config file at configs/acl.rej (ACL reject list, for example) would be silently dropped. The .rej extension is rare outside of patch conflicts, but the filter is applied to all paths, not just scripts/. In practice this is a low-probability issue for the current config extension allowlist, but worth being explicit: a comment noting that .rej targets patch reject files, not ACL files, would help a future maintainer audit this confidently. Or tighten to scripts/*.rej only if config files will never legitimately end in .rej.
2. Draft user-hooks copy does not apply the backup filter
preset_api_routes.py:1137–1140
The draft-scripts copy at line 1132 passes ignore=_ignore_generated_script_cruft. The draft-user-hooks copy at line 1139 does not pass an ignore argument. If an editor backup lands in user-hooks/ (less likely but possible), it would be copied into the preset unchanged. This is inconsistent with the stated goal ("one filter for every path"). No test covers this case.
Fix:
shutil.copytree(draft_user_hooks, preset_user_hooks,
dirs_exist_ok=True,
ignore=_ignore_generated_script_cruft)Minor (Nice to Have)
3. Test for *.bak.* (numbered backup) pattern is not represented in the download test
tests/test_preset_download_routes.py:260–293
The import test (test_skips_backup_files_left_beside_scripts) covers ranked.py.bak.1, but the download test does not include a *.bak.* file in its fixture. This is not a gap that risks a regression right now (the patterns are shared), but symmetry would make future refactors safer.
4. Minor: _ignore_generated_script_cruft is never directly unit-tested
The function is indirectly covered by test_update_preset_from_draft_drops_backup_files, but a small direct unit test of the function itself would make it easier to add new patterns in the future and verify the is_dir branch works as expected.
Assessment
Ready to merge? Yes
Reasoning: The fix is minimal, correctional, and well-reasoned. The user-hooks gap (issue #2) is a real inconsistency but affects a path that is currently unlikely to receive backup files, and does not introduce any new security risk. All three main preset paths are covered by the new filter and tested. The pattern design (single shared constant) is sound and prevents future drift.
Review found a real gap: the draft->preset scripts copy passes
ignore=_ignore_generated_script_cruft, but the user-hooks copy beside it
(preset_api_routes.py:1140 and :1409) passed no ignore at all.
That reopens the exact divergence this branch exists to close. The export
walk covers the whole preset directory, user-hooks included, so a backup
landing there would be written into the preset and then dropped from the
preset's own archive.
Both copies now share the filter. The draft-save test grows a user-hooks
case; verified non-vacuous by removing the ignore= argument and watching it
fail.
Also adds a *.bak.* fixture to the download test, which only covered *.bak
and *.bak-* on the export side.
Declined from the same review: tightening *.rej to scripts/ only -- .rej is
not in ALLOWED_PRESET_CONFIG_EXTENSIONS ({'.cfg', '.txt', '.ent'}), so the
hypothetical configs/acl.rej could not exist in a valid preset; and a direct
unit test for _ignore_generated_script_cruft, a six-line wrapper already
covered through both public paths.
Claude-Session: https://claude.ai/code/session_018T5f3ryb4q1kFavSjAaJFH
There was a problem hiding this comment.
PR Review: Backup file filtering across export, import, and draft→preset copy
Strengths
- Single source of truth (
ui/routes/preset_api_routes.py:73):_should_skip_export_pathis the one filter used for export, import, and draft→preset copy. Adding the patterns once propagates everywhere automatically — the import path picks them up via the explicit import inpreset_import_validation.py:14. - False-positive test (
tests/test_preset_import_validation.py:337):test_keeps_scripts_whose_name_merely_contains_bakand thebakery.pyassertion intest_preset_routes.pyprove the filter does not swallow legitimately named files. - Symmetric round-trip fix: The bug that motivated this — exporting a preset with a stale
.bakfile and then having QLSM refuse to re-import that archive — is closed cleanly by applying the same filter on both ends. user-hooksgap closed (ui/routes/preset_api_routes.py:1140–1144,1409–1413): The priorshutil.copytreefor user-hooks lacked the ignore callback; this PR fills that gap with a dedicated test (test_preset_routes.py:1628–1652).
Issues
Critical (Must Fix)
None found.
Important (Should Fix)
1. create_preset_api user-hooks path has no coverage
ui/routes/preset_api_routes.py:1140–1144 (the create path) gets the same fix as the update path, but test_preset_routes.py only tests the update path (client.put). If someone accidentally reverts or mis-merges just that call-site, no test would catch it.
Fix: Add a parallel test that creates a preset from a draft containing a .bak file in user-hooks via client.post('/api/presets', ...).
2. _ignore_generated_script_cruft docstring is stale
ui/routes/preset_api_routes.py:92: the docstring still reads "Return generated/editor junk to skip when saving draft scripts." The function is now also called for user-hooks, so the docstring under-describes the scope.
Fix: Update to something like "Return generated/editor junk to skip when copying draft files into a preset (scripts and user-hooks)."
Minor (Nice to Have)
3. *.bak-* is subtly broad
ui/routes/preset_api_routes.py:61: *.bak-* matches any filename containing the substring .bak- (e.g. a hypothetical hook.bak-copy.so). In practice this matches the QLSM-generated foo.py.bak-pre-<event>-<stamp> pattern, so the risk is low, but the intent could be made explicit with a comment referencing the stamp format.
4. Comment count in EXPORT_EXCLUDED_PATTERNS comment
ui/routes/preset_api_routes.py:54: "The trailing five are backup copies" — currently five patterns are listed (*.bak, *.bak-*, *.bak.*, *.orig, *.rej). If a sixth is ever added, the count will silently lie. Consider dropping the count or saying "the trailing group."
Assessment
Ready to merge? Yes, with the optional addition of a create_preset_api user-hooks test.
Reasoning: The core logic is correct and consistent across all three code paths; the patterns are safe and the false-positive tests give confidence the filter is narrow enough. The only real gap is missing coverage for the create path's user-hooks branch, which is low-risk given it calls the same helper but worth adding before this code touches production.
create_preset_api and update_preset_api each carry their own near-identical
draft->preset block, so the previous test proved the filter only on the
update path. Review was right to flag it, and this repo has been bitten by
that shape before (P1's missed call site, P2's two hardcoded preset names).
Verified the new test is not decorative: removing ignore= from the create
call site alone fails the create test and leaves the update test green.
Also: _ignore_generated_script_cruft's docstring still said "draft scripts"
after it started serving user-hooks, and the pattern comment hardcoded a
count ("the trailing five") that a sixth pattern would silently falsify.
Declined: adding a comment that *.bak-* is broad -- the comment two lines
above already spells out the foo.py.bak-pre-x-<stamp> shape it targets.
Claude-Session: https://claude.ai/code/session_018T5f3ryb4q1kFavSjAaJFH
Problem
Importing a preset export failed with
400:Export and import disagreed about what belongs in a preset archive:
_build_preset_export_zip) walks the whole preset directory and includes anything not matchingEXPORT_EXCLUDED_PATTERNS— only*.pyc, *.pyo, *.swp, *.tmp, *~. A backup file left beside a script didn't match, so it went into the ZIP._validate_script_path) allows only.py,.txt,.soand font extensions underscripts/, and hard-fails the entire archive on anything else.So QLSM was writing archives QLSM itself refuses to import.
Fix
Add
ARCHIVE_EXCLUDED_PATTERNS = ('*.bak', '*.bak-*', '*.bak.*', '*.orig', '*.rej')and apply it in_should_skip_export_path, which export and import both call. Archives already exported with a stray backup now import cleanly — no re-export needed.Patterns are deliberately precise rather than
*.bak*, so a legitimately namedbakery.pyisn't swallowed.What is intentionally not changed
_ignore_generated_script_cruftstays on the narrower list. It feeds thecopytreethat repopulates a preset'sscripts/directory after anrmtree(preset_api_routes.py:1122,:1391), so filtering backups there would silently delete a user's own.bakfiles on the next preset save. The helper is split into_matches_excluded_pathso the archive filter can be broad while the draft-save filter stays narrow.Tests
test_skips_backup_files_left_beside_scripts— the reported filename plus.bak,.bak.1,.orig,.rej, and a root-levelserver.cfg.baktest_keeps_scripts_whose_name_merely_contains_bak— guards against over-broad patternstest_download_preset_excludes_backup_files— export sidetest_update_preset_from_draft_keeps_user_backup_files— regression guard for the deletion side effect aboveThe first three fail without the fix. The fourth passes before and after — that's its purpose.
Known limitation
This addresses the backup-file class. The underlying export/import asymmetry remains: any other unexpected extension under
scripts/(a stray.md,.json) still 400s the whole import. Making the importer skip-with-warning instead of hard-failing is a behavior change with a security-boundary tradeoff, so it's left for a separate discussion.