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/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..9ab4751 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) @@ -53,8 +55,48 @@ 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()}") if pw is None: print(_style.dim("no password set, so anyone can open it after that date")) + + if args.rm: + deleted = 0 + failed = [] + out_real = os.path.realpath(out) + for path in d.staged: + 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: + 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..0f5e464 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 @@ -45,7 +46,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 +62,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 +75,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) + 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 +91,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 +101,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 +110,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 +118,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 +129,135 @@ 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)) + + +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 ----------------------------------------------------- + + +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 ---------------------------------------------------------------