From 40c32dc47fc8908be3f4bdc9dd2e16e3b71599cc Mon Sep 17 00:00:00 2001 From: melogt Date: Tue, 30 Jun 2026 19:05:17 -0300 Subject: [PATCH 1/5] delete after seal --- README.md | 3 +- magicicapsula/commands/seal.py | 24 +++++ tests/commands/test_capsule_commands.py | 111 ++++++++++++++++++++++-- 3 files changed, 129 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 01510c7..1503d84 100644 --- a/README.md +++ b/README.md @@ -80,13 +80,14 @@ seal everything staged into a capsule file. flags override the draft's settings and stick. ``` -magicicapsula seal [-u DATE] [-o FILE] [-n NOTE] [-f] [-P] +magicicapsula seal [-u DATE] [-o FILE] [-n NOTE] [-f] [-P] [--rm] -u, --unlock DATE unlock date, overrides the draft's -o, --out FILE output capsule file, overrides the draft's -n, --note NOTE plaintext note, overrides the draft's -f, --force overwrite the output if it exists -P, --no-password seal without a password (anyone can open it after the date) + --rm delete staged files after sealing ``` ### info diff --git a/magicicapsula/commands/seal.py b/magicicapsula/commands/seal.py index 0f31d2f..f50aee9 100644 --- a/magicicapsula/commands/seal.py +++ b/magicicapsula/commands/seal.py @@ -1,4 +1,5 @@ import os +import shutil import sys from datetime import datetime, timezone @@ -16,6 +17,7 @@ def register(sub): p.add_argument( "-P", "--no-password", action="store_true", help="seal without a password (anyone can open it after the date)" ) + p.add_argument("--rm", action="store_true", help="delete staged files after sealing") p.set_defaults(func=run) @@ -58,3 +60,25 @@ def run(args): print(f"unlocks: {unlock_at.astimezone().isoformat()}") if pw is None: print(_style.dim("no password set, so anyone can open it after that date")) + + if args.rm: + deleted = 0 + failed = [] + for path in d.staged: + if path == os.path.normpath(out): + failed.append((path, "is the capsule output file")) + continue + if not os.path.exists(path): + continue + try: + if os.path.isdir(path): + shutil.rmtree(path) + else: + os.remove(path) + deleted += 1 + except OSError as exc: + failed.append((path, str(exc))) + if deleted: + print(_style.red(f"deleted {deleted} staged file(s)")) + for path, reason in failed: + print(_style.red(f"warning: could not delete {path}: {reason}"), file=sys.stderr) diff --git a/tests/commands/test_capsule_commands.py b/tests/commands/test_capsule_commands.py index ecc9be2..b3db9a1 100644 --- a/tests/commands/test_capsule_commands.py +++ b/tests/commands/test_capsule_commands.py @@ -45,7 +45,7 @@ def test_seal_writes_blob_and_reports(tmp_path, monkeypatch, capsys): monkeypatch.setattr(seal_cmd, "ask_password", lambda confirm=False: "pw") monkeypatch.setattr(seal_cmd.capsule, "seal", lambda *a, **k: b"BLOB") - args = SimpleNamespace(unlock=None, note=None, out=None, force=False, no_password=False) + args = SimpleNamespace(unlock=None, note=None, out=None, force=False, no_password=False, rm=False) seal_cmd.run(args) out = capsys.readouterr().out @@ -61,7 +61,7 @@ def test_seal_without_password_notes_it(tmp_path, monkeypatch, capsys): monkeypatch.setattr(seal_cmd, "parse_unlock", lambda s: datetime(2030, 1, 1, tzinfo=timezone.utc)) monkeypatch.setattr(seal_cmd.capsule, "seal", lambda *a, **k: b"B") - args = SimpleNamespace(unlock=None, note=None, out=None, force=False, no_password=True) + args = SimpleNamespace(unlock=None, note=None, out=None, force=False, no_password=True, rm=False) seal_cmd.run(args) assert "anyone can open it" in capsys.readouterr().out @@ -74,7 +74,7 @@ def test_seal_applies_flag_overrides(tmp_path, monkeypatch, capsys): monkeypatch.setattr(seal_cmd, "parse_unlock", lambda s: datetime(2030, 1, 1, tzinfo=timezone.utc)) monkeypatch.setattr(seal_cmd.capsule, "seal", lambda *a, **k: b"B") - args = SimpleNamespace(unlock="2030-01-01", note="overridden", out="new.mcap", force=False, no_password=True) + args = SimpleNamespace(unlock="2030-01-01", note="overridden", out="new.mcap", force=False, no_password=True, rm=False) seal_cmd.run(args) # the flags overrode the draft and stuck assert d.note == "overridden" and d.out == "new.mcap" @@ -88,7 +88,7 @@ def test_seal_errors_on_missing_staged_files(tmp_path, monkeypatch): monkeypatch.setattr(seal_cmd.draft, "missing", lambda d: ["/gone"]) monkeypatch.setattr(seal_cmd, "parse_unlock", lambda s: datetime(2030, 1, 1, tzinfo=timezone.utc)) with pytest.raises(SystemExit, match="no longer exist"): - seal_cmd.run(SimpleNamespace(unlock=None, note=None, out=None, force=False, no_password=True)) + seal_cmd.run(SimpleNamespace(unlock=None, note=None, out=None, force=False, no_password=True, rm=False)) def test_seal_warns_when_unlock_in_the_past(tmp_path, monkeypatch, capsys): @@ -98,7 +98,7 @@ def test_seal_warns_when_unlock_in_the_past(tmp_path, monkeypatch, capsys): monkeypatch.setattr(seal_cmd.draft, "missing", lambda d: []) monkeypatch.setattr(seal_cmd, "parse_unlock", lambda s: datetime(2000, 1, 1, tzinfo=timezone.utc)) monkeypatch.setattr(seal_cmd.capsule, "seal", lambda *a, **k: b"B") - seal_cmd.run(SimpleNamespace(unlock=None, note=None, out=None, force=False, no_password=True)) + seal_cmd.run(SimpleNamespace(unlock=None, note=None, out=None, force=False, no_password=True, rm=False)) assert "not in the future" in capsys.readouterr().err @@ -107,7 +107,7 @@ def test_seal_errors_without_unlock_date(tmp_path, monkeypatch): monkeypatch.setattr(seal_cmd.draft, "load", lambda: d) monkeypatch.setattr(seal_cmd.draft, "save", lambda d: None) with pytest.raises(SystemExit, match="no unlock date"): - seal_cmd.run(SimpleNamespace(unlock=None, note=None, out=None, force=False, no_password=True)) + seal_cmd.run(SimpleNamespace(unlock=None, note=None, out=None, force=False, no_password=True, rm=False)) def test_seal_errors_when_nothing_staged(tmp_path, monkeypatch): @@ -115,7 +115,7 @@ def test_seal_errors_when_nothing_staged(tmp_path, monkeypatch): monkeypatch.setattr(seal_cmd.draft, "load", lambda: d) monkeypatch.setattr(seal_cmd.draft, "save", lambda d: None) with pytest.raises(SystemExit, match="nothing staged"): - seal_cmd.run(SimpleNamespace(unlock=None, note=None, out=None, force=False, no_password=True)) + seal_cmd.run(SimpleNamespace(unlock=None, note=None, out=None, force=False, no_password=True, rm=False)) def test_seal_refuses_to_clobber_without_force(tmp_path, monkeypatch): @@ -126,7 +126,102 @@ def test_seal_refuses_to_clobber_without_force(tmp_path, monkeypatch): monkeypatch.setattr(seal_cmd.draft, "missing", lambda d: []) monkeypatch.setattr(seal_cmd, "parse_unlock", lambda s: datetime(2030, 1, 1, tzinfo=timezone.utc)) with pytest.raises(SystemExit, match="already exists"): - seal_cmd.run(SimpleNamespace(unlock=None, note=None, out=None, force=False, no_password=True)) + seal_cmd.run(SimpleNamespace(unlock=None, note=None, out=None, force=False, no_password=True, rm=False)) + + +# --- seal with --rm ----------------------------------------------------- + + +def test_seal_with_rm_deletes_staged_files(tmp_path, monkeypatch, capsys): + """--rm deletes staged files after sealing.""" + f1 = tmp_path / "letter.txt" + f2 = tmp_path / "diary.txt" + f1.write_text("hello") + f2.write_text("world") + + d = SimpleNamespace( + unlock_at="2030-01-01T00:00:00", note="", out="c.mcap", + staged=[str(f1), str(f2)], root=str(tmp_path), + ) + monkeypatch.setattr(seal_cmd.draft, "load", lambda: d) + monkeypatch.setattr(seal_cmd.draft, "save", lambda d: None) + monkeypatch.setattr(seal_cmd.draft, "missing", lambda d: []) + monkeypatch.setattr(seal_cmd, "parse_unlock", lambda s: datetime(2030, 1, 1, tzinfo=timezone.utc)) + monkeypatch.setattr(seal_cmd, "ask_password", lambda confirm=False: "pw") + monkeypatch.setattr(seal_cmd.capsule, "seal", lambda *a, **k: b"BLOB") + + seal_cmd.run(SimpleNamespace(unlock=None, note=None, out=None, force=False, no_password=False, rm=True)) + assert not f1.exists() + assert not f2.exists() + assert "deleted 2 staged file(s)" in capsys.readouterr().out + + +def test_seal_with_rm_skips_non_existent(tmp_path, monkeypatch, capsys): + """--rm silently skips files already gone (e.g. draft files inside .capsule/).""" + existing = tmp_path / "letter.txt" + existing.write_text("hello") + gone = str(tmp_path / "already_deleted.txt") + + d = SimpleNamespace( + unlock_at="2030-01-01T00:00:00", note="", out="c.mcap", + staged=[str(existing), gone], root=str(tmp_path), + ) + monkeypatch.setattr(seal_cmd.draft, "load", lambda: d) + monkeypatch.setattr(seal_cmd.draft, "save", lambda d: None) + monkeypatch.setattr(seal_cmd.draft, "missing", lambda d: []) + monkeypatch.setattr(seal_cmd, "parse_unlock", lambda s: datetime(2030, 1, 1, tzinfo=timezone.utc)) + monkeypatch.setattr(seal_cmd, "ask_password", lambda confirm=False: "pw") + monkeypatch.setattr(seal_cmd.capsule, "seal", lambda *a, **k: b"BLOB") + + seal_cmd.run(SimpleNamespace(unlock=None, note=None, out=None, force=False, no_password=False, rm=True)) + assert not existing.exists() + assert "deleted 1 staged file(s)" in capsys.readouterr().out + + +def test_seal_with_rm_skips_output_file(tmp_path, monkeypatch, capsys): + """--rm does not delete the output capsule file if it happens to be staged.""" + staged = tmp_path / "capsule.mcap" + staged.write_text("content") + + d = SimpleNamespace( + unlock_at="2030-01-01T00:00:00", note="", out="capsule.mcap", + staged=[str(staged)], root=str(tmp_path), + ) + monkeypatch.setattr(seal_cmd.draft, "load", lambda: d) + monkeypatch.setattr(seal_cmd.draft, "save", lambda d: None) + monkeypatch.setattr(seal_cmd.draft, "missing", lambda d: []) + monkeypatch.setattr(seal_cmd, "parse_unlock", lambda s: datetime(2030, 1, 1, tzinfo=timezone.utc)) + monkeypatch.setattr(seal_cmd, "ask_password", lambda confirm=False: "pw") + + # seal will overwrite it; after seal staged path == out path so it should warn + monkeypatch.setattr(seal_cmd.capsule, "seal", lambda *a, **k: b"NEW") + + seal_cmd.run(SimpleNamespace(unlock=None, note=None, out=None, force=True, no_password=False, rm=True)) + assert staged.exists() # capsule output survives + assert staged.read_bytes() == b"NEW" + err = capsys.readouterr().err + assert "warning:" in err + assert "is the capsule output file" in err + + +def test_seal_without_rm_does_not_delete(tmp_path, monkeypatch, capsys): + """Without --rm, staged files remain on disk.""" + f = tmp_path / "letter.txt" + f.write_text("hello") + + d = SimpleNamespace( + unlock_at="2030-01-01T00:00:00", note="", out="c.mcap", + staged=[str(f)], root=str(tmp_path), + ) + monkeypatch.setattr(seal_cmd.draft, "load", lambda: d) + monkeypatch.setattr(seal_cmd.draft, "save", lambda d: None) + monkeypatch.setattr(seal_cmd.draft, "missing", lambda d: []) + monkeypatch.setattr(seal_cmd, "parse_unlock", lambda s: datetime(2030, 1, 1, tzinfo=timezone.utc)) + monkeypatch.setattr(seal_cmd, "ask_password", lambda confirm=False: "pw") + monkeypatch.setattr(seal_cmd.capsule, "seal", lambda *a, **k: b"BLOB") + + seal_cmd.run(SimpleNamespace(unlock=None, note=None, out=None, force=False, no_password=False, rm=False)) + assert f.exists() # --- open --------------------------------------------------------------- From 814fe0d3d99c29c2b2cb33d45895b246cb750638 Mon Sep 17 00:00:00 2001 From: melogt Date: Tue, 30 Jun 2026 19:17:00 -0300 Subject: [PATCH 2/5] lint fix --- .gitignore | 3 +++ tests/commands/test_capsule_commands.py | 32 ++++++++++++++++++------- 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/.gitignore b/.gitignore index c9f97db..2537ff3 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,9 @@ dist/ venv/ env/ +# editor +.vscode/ + # capsule artifacts *.mcap .capsule/ diff --git a/tests/commands/test_capsule_commands.py b/tests/commands/test_capsule_commands.py index b3db9a1..6f4d38f 100644 --- a/tests/commands/test_capsule_commands.py +++ b/tests/commands/test_capsule_commands.py @@ -74,7 +74,9 @@ def test_seal_applies_flag_overrides(tmp_path, monkeypatch, capsys): monkeypatch.setattr(seal_cmd, "parse_unlock", lambda s: datetime(2030, 1, 1, tzinfo=timezone.utc)) monkeypatch.setattr(seal_cmd.capsule, "seal", lambda *a, **k: b"B") - args = SimpleNamespace(unlock="2030-01-01", note="overridden", out="new.mcap", force=False, no_password=True, rm=False) + args = SimpleNamespace( + unlock="2030-01-01", note="overridden", out="new.mcap", force=False, no_password=True, rm=False + ) seal_cmd.run(args) # the flags overrode the draft and stuck assert d.note == "overridden" and d.out == "new.mcap" @@ -140,8 +142,11 @@ def test_seal_with_rm_deletes_staged_files(tmp_path, monkeypatch, capsys): f2.write_text("world") d = SimpleNamespace( - unlock_at="2030-01-01T00:00:00", note="", out="c.mcap", - staged=[str(f1), str(f2)], root=str(tmp_path), + unlock_at="2030-01-01T00:00:00", + note="", + out="c.mcap", + staged=[str(f1), str(f2)], + root=str(tmp_path), ) monkeypatch.setattr(seal_cmd.draft, "load", lambda: d) monkeypatch.setattr(seal_cmd.draft, "save", lambda d: None) @@ -163,8 +168,11 @@ def test_seal_with_rm_skips_non_existent(tmp_path, monkeypatch, capsys): gone = str(tmp_path / "already_deleted.txt") d = SimpleNamespace( - unlock_at="2030-01-01T00:00:00", note="", out="c.mcap", - staged=[str(existing), gone], root=str(tmp_path), + unlock_at="2030-01-01T00:00:00", + note="", + out="c.mcap", + staged=[str(existing), gone], + root=str(tmp_path), ) monkeypatch.setattr(seal_cmd.draft, "load", lambda: d) monkeypatch.setattr(seal_cmd.draft, "save", lambda d: None) @@ -184,8 +192,11 @@ def test_seal_with_rm_skips_output_file(tmp_path, monkeypatch, capsys): staged.write_text("content") d = SimpleNamespace( - unlock_at="2030-01-01T00:00:00", note="", out="capsule.mcap", - staged=[str(staged)], root=str(tmp_path), + unlock_at="2030-01-01T00:00:00", + note="", + out="capsule.mcap", + staged=[str(staged)], + root=str(tmp_path), ) monkeypatch.setattr(seal_cmd.draft, "load", lambda: d) monkeypatch.setattr(seal_cmd.draft, "save", lambda d: None) @@ -210,8 +221,11 @@ def test_seal_without_rm_does_not_delete(tmp_path, monkeypatch, capsys): f.write_text("hello") d = SimpleNamespace( - unlock_at="2030-01-01T00:00:00", note="", out="c.mcap", - staged=[str(f)], root=str(tmp_path), + unlock_at="2030-01-01T00:00:00", + note="", + out="c.mcap", + staged=[str(f)], + root=str(tmp_path), ) monkeypatch.setattr(seal_cmd.draft, "load", lambda: d) monkeypatch.setattr(seal_cmd.draft, "save", lambda d: None) From 3574faade3b1c5c9f3781fb7910898c4774d20d2 Mon Sep 17 00:00:00 2001 From: melogt Date: Tue, 30 Jun 2026 21:08:43 -0300 Subject: [PATCH 3/5] light verification --- magicicapsula/commands/seal.py | 3 +++ tests/commands/test_capsule_commands.py | 23 ++++++++++++++++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/magicicapsula/commands/seal.py b/magicicapsula/commands/seal.py index f50aee9..a3a8067 100644 --- a/magicicapsula/commands/seal.py +++ b/magicicapsula/commands/seal.py @@ -55,6 +55,9 @@ def run(args): with open(out, "wb") as fh: fh.write(blob) + if os.path.getsize(out) != len(blob): + raise SystemExit(f"error: capsule file {out} was corrupted during write") + print(_style.logo()) print(_style.green(f"sealed {len(d.staged)} item(s) into {out}")) print(f"unlocks: {unlock_at.astimezone().isoformat()}") diff --git a/tests/commands/test_capsule_commands.py b/tests/commands/test_capsule_commands.py index 6f4d38f..9ce419b 100644 --- a/tests/commands/test_capsule_commands.py +++ b/tests/commands/test_capsule_commands.py @@ -6,6 +6,7 @@ """ import json +import os from datetime import datetime, timedelta, timezone from types import SimpleNamespace @@ -131,6 +132,27 @@ def test_seal_refuses_to_clobber_without_force(tmp_path, monkeypatch): seal_cmd.run(SimpleNamespace(unlock=None, note=None, out=None, force=False, no_password=True, rm=False)) +def test_seal_verifies_written_size(tmp_path, monkeypatch): + """A size mismatch after write raises an error.""" + d = SimpleNamespace(unlock_at="2030-01-01T00:00:00", note="", out="c.mcap", staged=["/a"], root=str(tmp_path)) + monkeypatch.setattr(seal_cmd.draft, "load", lambda: d) + monkeypatch.setattr(seal_cmd.draft, "save", lambda d: None) + monkeypatch.setattr(seal_cmd.draft, "missing", lambda d: []) + monkeypatch.setattr(seal_cmd, "parse_unlock", lambda s: datetime(2030, 1, 1, tzinfo=timezone.utc)) + monkeypatch.setattr(seal_cmd, "ask_password", lambda confirm=False: "pw") + monkeypatch.setattr(seal_cmd.capsule, "seal", lambda *a, **k: b"BLOB") + # Simulate a write corruption by lying about the file size + original_getsize = os.path.getsize + + def fake_getsize(path): + return original_getsize(path) + 99 + + monkeypatch.setattr(os.path, "getsize", fake_getsize) + + with pytest.raises(SystemExit, match="corrupted during write"): + seal_cmd.run(SimpleNamespace(unlock=None, note=None, out=None, force=False, no_password=True, rm=False)) + + # --- seal with --rm ----------------------------------------------------- @@ -185,7 +207,6 @@ def test_seal_with_rm_skips_non_existent(tmp_path, monkeypatch, capsys): assert not existing.exists() assert "deleted 1 staged file(s)" in capsys.readouterr().out - def test_seal_with_rm_skips_output_file(tmp_path, monkeypatch, capsys): """--rm does not delete the output capsule file if it happens to be staged.""" staged = tmp_path / "capsule.mcap" From 5a80c504ccb929207201360af580e1376b487635 Mon Sep 17 00:00:00 2001 From: melogt Date: Tue, 30 Jun 2026 21:11:38 -0300 Subject: [PATCH 4/5] lint --- tests/commands/test_capsule_commands.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/commands/test_capsule_commands.py b/tests/commands/test_capsule_commands.py index 9ce419b..0f5e464 100644 --- a/tests/commands/test_capsule_commands.py +++ b/tests/commands/test_capsule_commands.py @@ -207,6 +207,7 @@ def test_seal_with_rm_skips_non_existent(tmp_path, monkeypatch, capsys): assert not existing.exists() assert "deleted 1 staged file(s)" in capsys.readouterr().out + def test_seal_with_rm_skips_output_file(tmp_path, monkeypatch, capsys): """--rm does not delete the output capsule file if it happens to be staged.""" staged = tmp_path / "capsule.mcap" From 72c147839a1f6ba9086c22c47ccc583e64c0a93e Mon Sep 17 00:00:00 2001 From: d <69827032+iDavi@users.noreply.github.com> Date: Tue, 30 Jun 2026 21:13:41 -0300 Subject: [PATCH 5/5] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- magicicapsula/commands/seal.py | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/magicicapsula/commands/seal.py b/magicicapsula/commands/seal.py index a3a8067..9ab4751 100644 --- a/magicicapsula/commands/seal.py +++ b/magicicapsula/commands/seal.py @@ -67,13 +67,28 @@ def run(args): if args.rm: deleted = 0 failed = [] + out_real = os.path.realpath(out) for path in d.staged: - if path == os.path.normpath(out): - failed.append((path, "is the capsule output file")) - continue if not os.path.exists(path): continue try: + path_real = os.path.realpath(path) + + # never delete the capsule output itself + if os.path.samefile(path, out): + failed.append((path, "is the capsule output file")) + continue + + # and never delete a directory that would remove the output + if os.path.isdir(path): + try: + if os.path.commonpath([path_real, out_real]) == path_real: + failed.append((path, "contains the capsule output file")) + continue + except ValueError: + # different drives / invalid paths; ignore ancestry check + pass + if os.path.isdir(path): shutil.rmtree(path) else: