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
28 changes: 28 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
name: lint

# enforces style and catches common code smells (incl. AI-generated ones:
# dead/commented-out code, blind excepts, magic numbers, ...) on push and PR
on:
push:
branches: ["**"]
pull_request:

jobs:
ruff:
name: ruff
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: "3.x"

- name: install ruff
run: python -m pip install --upgrade "ruff>=0.15"

- name: lint
run: ruff check --output-format=github .

- name: format
run: ruff format --check --diff .
6 changes: 1 addition & 5 deletions magicicapsula/commands/_style.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,7 @@


def enabled():
return (
sys.stdout.isatty()
and os.environ.get("NO_COLOR") is None
and os.environ.get("TERM") != "dumb"
)
return sys.stdout.isatty() and os.environ.get("NO_COLOR") is None and os.environ.get("TERM") != "dumb"


def paint(text, code):
Expand Down
5 changes: 1 addition & 4 deletions magicicapsula/commands/_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,7 @@ def parse_unlock(s: str) -> datetime:
try:
dt = datetime.fromisoformat(s)
except ValueError:
raise SystemExit(
f"error: bad date {s!r} "
"(use YYYY-MM-DD, YYYY-MM-DDTHH:MM, or +30d/+2w/+6m/+1y)"
)
raise SystemExit(f"error: bad date {s!r} (use YYYY-MM-DD, YYYY-MM-DDTHH:MM, or +30d/+2w/+6m/+1y)") from None
return dt.astimezone() if dt.tzinfo is None else dt # naive means local time


Expand Down
5 changes: 3 additions & 2 deletions magicicapsula/commands/add.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ def register(sub):
p = sub.add_parser("add", help="stage files or folders to put in the capsule")
p.add_argument("paths", nargs="*", help="files or folders to stage (use - to read stdin)")
p.add_argument("--text", metavar="TEXT", help="stage this text directly, no file needed")
p.add_argument("--name", metavar="NAME", default="note.txt",
help="filename for --text or stdin (default: note.txt)")
p.add_argument(
"--name", metavar="NAME", default="note.txt", help="filename for --text or stdin (default: note.txt)"
)
p.set_defaults(func=run)


Expand Down
14 changes: 9 additions & 5 deletions magicicapsula/commands/config.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
import os

from magicicapsula.core import config
from magicicapsula.commands import _style
from magicicapsula.core import config


def register(sub):
p = sub.add_parser("config", help="show or edit configuration")
p.add_argument("action", nargs="?", default="list",
choices=["list", "get", "set", "unset"],
help="list (default), get, set, or unset a setting")
p.add_argument(
"action",
nargs="?",
default="list",
choices=["list", "get", "set", "unset"],
help="list (default), get, set, or unset a setting",
)
p.add_argument("key", nargs="?", help="setting name")
p.add_argument("value", nargs="?", help="value, for set")
p.add_argument("--reveal", action="store_true", help="show secret values instead of masking")
Expand Down Expand Up @@ -48,6 +52,6 @@ def _list(reveal):
print(f"config file: {path}")
print(_style.dim(" " + ("found" if os.path.exists(path) else "not present")))
print()
for key in config.keys():
for key in config.keys(): # noqa: SIM118 - config is a module, not a dict
value, source = config.resolve(key)
print(f" {key} = {config.display(key, value, reveal)} {_style.dim('(' + source + ')')}")
22 changes: 13 additions & 9 deletions magicicapsula/commands/info.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import json
from datetime import datetime, timezone

from magicicapsula.core import capsule
from magicicapsula.commands import _style
from magicicapsula.commands._util import fmt_remaining, read_capsule
from magicicapsula.core import capsule


def register(sub):
Expand All @@ -20,14 +20,18 @@ def run(args):
remaining = info.remaining(now)

if args.json:
print(json.dumps({
"created_at": info.created_at.astimezone().isoformat(),
"unlock_at": info.unlock_at.astimezone().isoformat(),
"cipher": info.cipher,
"note": info.note,
"open": is_open,
"remaining_seconds": int(remaining.total_seconds()),
}))
print(
json.dumps(
{
"created_at": info.created_at.astimezone().isoformat(),
"unlock_at": info.unlock_at.astimezone().isoformat(),
"cipher": info.cipher,
"note": info.note,
"open": is_open,
"remaining_seconds": int(remaining.total_seconds()),
}
)
)
return

print(f"created: {info.created_at.astimezone().isoformat()}")
Expand Down
4 changes: 2 additions & 2 deletions magicicapsula/commands/init.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from magicicapsula.core import draft
from magicicapsula.commands._util import parse_unlock
from magicicapsula.core import draft


def register(sub):
Expand All @@ -14,7 +14,7 @@ def run(args):
try:
d = draft.init()
except FileExistsError:
raise SystemExit("error: a capsule draft already exists here (.capsule/)")
raise SystemExit("error: a capsule draft already exists here (.capsule/)") from None

if args.unlock:
d.unlock_at = parse_unlock(args.unlock).isoformat() # resolve +30d/+1y etc. now
Expand Down
2 changes: 1 addition & 1 deletion magicicapsula/commands/open.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
from datetime import datetime, timezone

from magicicapsula.core import capsule
from magicicapsula.commands import _style
from magicicapsula.commands._util import ask_password, fmt_remaining, read_capsule
from magicicapsula.core import capsule


def register(sub):
Expand Down
8 changes: 6 additions & 2 deletions magicicapsula/commands/remind.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import os
from datetime import datetime, timezone

from magicicapsula.core import capsule, ics
from magicicapsula.commands import _style
from magicicapsula.commands._util import fmt_remaining, read_capsule
from magicicapsula.core import capsule, ics


def register(sub):
Expand All @@ -14,7 +14,11 @@ def register(sub):
p.add_argument("file", help="capsule file")
p.add_argument("-o", "--out", metavar="FILE", help="output .ics path (default: <capsule>.ics)")
p.add_argument(
"-b", "--before", type=int, default=0, metavar="DAYS",
"-b",
"--before",
type=int,
default=0,
metavar="DAYS",
help="remind this many days before the unlock date (default: on the day)",
)
p.add_argument("-f", "--force", action="store_true", help="overwrite the output if it exists")
Expand Down
7 changes: 4 additions & 3 deletions magicicapsula/commands/seal.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@
import sys
from datetime import datetime, timezone

from magicicapsula.core import capsule, draft
from magicicapsula.commands import _style
from magicicapsula.commands._util import ask_password, parse_unlock
from magicicapsula.core import capsule, draft


def register(sub):
Expand All @@ -13,8 +13,9 @@ def register(sub):
p.add_argument("-o", "--out", metavar="FILE", help="output capsule file, overrides the draft's")
p.add_argument("-n", "--note", help="plaintext note, overrides the draft's")
p.add_argument("-f", "--force", action="store_true", help="overwrite the output if it exists")
p.add_argument("-P", "--no-password", action="store_true",
help="seal without a password (anyone can open it after the date)")
p.add_argument(
"-P", "--no-password", action="store_true", help="seal without a password (anyone can open it after the date)"
)
p.set_defaults(func=run)


Expand Down
13 changes: 7 additions & 6 deletions magicicapsula/commands/status.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
import contextlib
import os
from datetime import datetime

from magicicapsula.core import draft
from magicicapsula.commands import _style
from magicicapsula.core import draft

_UNIT_STEP = 1024 # bytes per unit; promote to KB/MB/... at each step


def _fmt_size(n: int) -> str:
size: float = float(n)
for unit in ("B", "KB", "MB", "GB", "TB"):
# promote to the next unit once the value would render as 1024.x here
if round(size, 0 if unit == "B" else 1) < 1024:
if round(size, 0 if unit == "B" else 1) < _UNIT_STEP:
return f"{size:.0f} {unit}" if unit == "B" else f"{size:.1f} {unit}"
size /= 1024
size /= _UNIT_STEP
return f"{size:.1f} PB"


Expand Down Expand Up @@ -47,10 +50,8 @@ def run(args):
present = [p for p in d.staged if p not in gone]
total_size = 0
for p in present:
try:
with contextlib.suppress(OSError):
total_size += os.path.getsize(p)
except OSError:
pass
if present:
print(f", {_fmt_size(total_size)}")
else:
Expand Down
2 changes: 1 addition & 1 deletion magicicapsula/commands/verify.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from magicicapsula.core import capsule
from magicicapsula.commands import _style
from magicicapsula.commands._util import ask_password, read_capsule
from magicicapsula.core import capsule


def register(sub):
Expand Down
1 change: 1 addition & 0 deletions magicicapsula/commands/version.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import argparse

from magicicapsula import __version__
from magicicapsula.commands import _style

Expand Down
14 changes: 7 additions & 7 deletions magicicapsula/core/capsule.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,9 @@
from .errors import CapsuleLocked, InvalidCapsule, WrongPasswordOrCorrupt

MAGIC = b"MCAP"
VERSION = 2 # the container version seal() writes (v2: aes-256-gcm + aad)
MIN_READ_VERSION = 1 # oldest version this build can still read
_HEADER_START = 9 # magic(4) + version(1) + header length(4)
VERSION = 2 # the container version seal() writes (v2: aes-256-gcm + aad)
MIN_READ_VERSION = 1 # oldest version this build can still read
_HEADER_START = 9 # magic(4) + version(1) + header length(4)


@dataclass
Expand Down Expand Up @@ -79,10 +79,10 @@ def _pack(paths) -> bytes:
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
for path in paths:
path = os.path.normpath(path)
if not os.path.exists(path):
raise FileNotFoundError(path)
tar.add(path, arcname=os.path.basename(path))
norm = os.path.normpath(path)
if not os.path.exists(norm):
raise FileNotFoundError(norm)
tar.add(norm, arcname=os.path.basename(norm))
return buf.getvalue()


Expand Down
4 changes: 2 additions & 2 deletions magicicapsula/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,9 @@

@dataclass(frozen=True)
class _Setting:
env: str | None = None # environment variable that overrides the file
env: str | None = None # environment variable that overrides the file
default: object = None
secret: bool = False # masked by display() / the `config` command
secret: bool = False # masked by display() / the `config` command


# the registry. one line per setting; add new ones here.
Expand Down
10 changes: 5 additions & 5 deletions magicicapsula/core/crypto.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
from .errors import WrongPasswordOrCorrupt

# scrypt cost parameters. Memory cost ~= 128 * r * n bytes (~32 MB here).
SCRYPT_N = 2 ** 15
SCRYPT_N = 2**15
SCRYPT_R = 8
SCRYPT_P = 1
KEY_LEN = 32
Expand Down Expand Up @@ -69,15 +69,14 @@ def _scrypt(password: str, salt: bytes, params: KdfParams) -> bytes:

# --- v2: aes-256-gcm with authenticated header -----------------------------

def encrypt_gcm(data: bytes, password: str, salt: bytes, aad: bytes,
params: KdfParams = KdfParams()) -> bytes:

def encrypt_gcm(data: bytes, password: str, salt: bytes, aad: bytes, params: KdfParams = KdfParams()) -> bytes:
"""Encrypt, authenticating `aad` (the header) alongside. Output is nonce + ciphertext."""
nonce = os.urandom(GCM_NONCE_LEN)
return nonce + AESGCM(_scrypt(password, salt, params)).encrypt(nonce, data, aad)


def decrypt_gcm(token: bytes, password: str, salt: bytes, aad: bytes,
params: KdfParams = KdfParams()) -> bytes:
def decrypt_gcm(token: bytes, password: str, salt: bytes, aad: bytes, params: KdfParams = KdfParams()) -> bytes:
"""Decrypt and verify both the ciphertext and `aad`. Raises if either was altered."""
nonce, ct = token[:GCM_NONCE_LEN], token[GCM_NONCE_LEN:]
try:
Expand All @@ -88,6 +87,7 @@ def decrypt_gcm(token: bytes, password: str, salt: bytes, aad: bytes,

# --- v1: fernet, kept so old capsules still open ---------------------------


def derive_key(password: str, salt: bytes, params: KdfParams = KdfParams()) -> bytes:
return base64.urlsafe_b64encode(_scrypt(password, salt, params))

Expand Down
6 changes: 3 additions & 3 deletions magicicapsula/core/draft.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,14 @@

DRAFT_DIR = ".capsule"
CONFIG = "config.json"
FILES_SUBDIR = "files" # generated content (text/stdin) lives here until seal
FILES_SUBDIR = "files" # generated content (text/stdin) lives here until seal
VERSION = 1


@dataclass
class Draft:
root: str # directory containing .capsule/
unlock_at: str | None = None # ISO date/datetime as typed by the user
root: str # directory containing .capsule/
unlock_at: str | None = None # ISO date/datetime as typed by the user
note: str = ""
out: str = "capsule.mcap"
staged: list[str] = field(default_factory=list) # absolute paths
Expand Down
Loading
Loading