Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ dist/
venv/
env/

# editor
.vscode/

# capsule artifacts
*.mcap
.capsule/
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
42 changes: 42 additions & 0 deletions magicicapsula/commands/seal.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import os
import shutil
import sys
from datetime import datetime, timezone

Expand All @@ -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)


Expand Down Expand Up @@ -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"))

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

before removing, check if capsule was written correctly

example

with open(out, "wb") as fh:
    fh.write(blob)

with open(out, "rb") as fh:
    written = fh.read()

if written != blob:
    raise IOError("File content mismatch after write")

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@melogtm address this

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)
147 changes: 139 additions & 8 deletions tests/commands/test_capsule_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"""

import json
import os
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace

Expand Down Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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"
Expand All @@ -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):
Expand All @@ -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


Expand All @@ -107,15 +110,15 @@ 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):
d = SimpleNamespace(unlock_at="2030-01-01", note="", out="c.mcap", staged=[], root=str(tmp_path))
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):
Expand All @@ -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 ---------------------------------------------------------------
Expand Down
Loading