diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..364bd35 --- /dev/null +++ b/.github/workflows/lint.yml @@ -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 . diff --git a/magicicapsula/commands/_style.py b/magicicapsula/commands/_style.py index 6c4bac3..72918b5 100644 --- a/magicicapsula/commands/_style.py +++ b/magicicapsula/commands/_style.py @@ -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): diff --git a/magicicapsula/commands/_util.py b/magicicapsula/commands/_util.py index d2a8b5a..778699f 100644 --- a/magicicapsula/commands/_util.py +++ b/magicicapsula/commands/_util.py @@ -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 diff --git a/magicicapsula/commands/add.py b/magicicapsula/commands/add.py index 35901e2..2526e85 100644 --- a/magicicapsula/commands/add.py +++ b/magicicapsula/commands/add.py @@ -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) diff --git a/magicicapsula/commands/config.py b/magicicapsula/commands/config.py index e90828f..5b23bfc 100644 --- a/magicicapsula/commands/config.py +++ b/magicicapsula/commands/config.py @@ -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") @@ -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 + ')')}") diff --git a/magicicapsula/commands/info.py b/magicicapsula/commands/info.py index 0ff1d6b..9cd859c 100644 --- a/magicicapsula/commands/info.py +++ b/magicicapsula/commands/info.py @@ -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): @@ -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()}") diff --git a/magicicapsula/commands/init.py b/magicicapsula/commands/init.py index d73cb8a..8f9a82d 100644 --- a/magicicapsula/commands/init.py +++ b/magicicapsula/commands/init.py @@ -1,5 +1,5 @@ -from magicicapsula.core import draft from magicicapsula.commands._util import parse_unlock +from magicicapsula.core import draft def register(sub): @@ -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 diff --git a/magicicapsula/commands/open.py b/magicicapsula/commands/open.py index a0e142a..f23e296 100644 --- a/magicicapsula/commands/open.py +++ b/magicicapsula/commands/open.py @@ -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): diff --git a/magicicapsula/commands/remind.py b/magicicapsula/commands/remind.py index 62bed3a..e6b304f 100644 --- a/magicicapsula/commands/remind.py +++ b/magicicapsula/commands/remind.py @@ -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): @@ -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: .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") diff --git a/magicicapsula/commands/seal.py b/magicicapsula/commands/seal.py index fd12ffa..0f31d2f 100644 --- a/magicicapsula/commands/seal.py +++ b/magicicapsula/commands/seal.py @@ -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): @@ -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) diff --git a/magicicapsula/commands/status.py b/magicicapsula/commands/status.py index 827bede..f92f564 100644 --- a/magicicapsula/commands/status.py +++ b/magicicapsula/commands/status.py @@ -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" @@ -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: diff --git a/magicicapsula/commands/verify.py b/magicicapsula/commands/verify.py index 16e5928..3a43875 100644 --- a/magicicapsula/commands/verify.py +++ b/magicicapsula/commands/verify.py @@ -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): diff --git a/magicicapsula/commands/version.py b/magicicapsula/commands/version.py index 6a52a0e..3ce6ea4 100644 --- a/magicicapsula/commands/version.py +++ b/magicicapsula/commands/version.py @@ -1,4 +1,5 @@ import argparse + from magicicapsula import __version__ from magicicapsula.commands import _style diff --git a/magicicapsula/core/capsule.py b/magicicapsula/core/capsule.py index e1e0a34..524f5ab 100644 --- a/magicicapsula/core/capsule.py +++ b/magicicapsula/core/capsule.py @@ -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 @@ -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() diff --git a/magicicapsula/core/config.py b/magicicapsula/core/config.py index ba28d19..4c71c58 100644 --- a/magicicapsula/core/config.py +++ b/magicicapsula/core/config.py @@ -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. diff --git a/magicicapsula/core/crypto.py b/magicicapsula/core/crypto.py index f79369b..10b0821 100644 --- a/magicicapsula/core/crypto.py +++ b/magicicapsula/core/crypto.py @@ -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 @@ -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: @@ -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)) diff --git a/magicicapsula/core/draft.py b/magicicapsula/core/draft.py index 84e8745..1da6d23 100644 --- a/magicicapsula/core/draft.py +++ b/magicicapsula/core/draft.py @@ -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 diff --git a/magicicapsula/core/ics.py b/magicicapsula/core/ics.py index 76da8e1..3d7a699 100644 --- a/magicicapsula/core/ics.py +++ b/magicicapsula/core/ics.py @@ -9,6 +9,11 @@ import hashlib from datetime import datetime, timedelta, timezone +_FOLD_LIMIT = 75 # rfc 5545 3.1: max octets per line before folding +# utf-8 continuation bytes match 0b10xxxxxx; used to back off mid-sequence +_UTF8_CONT_MASK = 0xC0 +_UTF8_CONT_BITS = 0x80 + def _stamp(dt: datetime) -> str: return dt.astimezone(timezone.utc).strftime("%Y%m%dT%H%M%SZ") @@ -17,11 +22,7 @@ def _stamp(dt: datetime) -> str: def _escape(text: str) -> str: # rfc 5545 3.3.11: escape backslash first, then ; , and newlines. return ( - text.replace("\\", "\\\\") - .replace(";", "\\;") - .replace(",", "\\,") - .replace("\r\n", "\\n") - .replace("\n", "\\n") + text.replace("\\", "\\\\").replace(";", "\\;").replace(",", "\\,").replace("\r\n", "\\n").replace("\n", "\\n") ) @@ -30,17 +31,17 @@ def _fold(line: str) -> str: # begins with a single space. fold on octets, backing off so a utf-8 # multibyte sequence is never split across the boundary. raw = line.encode("utf-8") - if len(raw) <= 75: + if len(raw) <= _FOLD_LIMIT: return line chunks = [] - limit = 75 # the first line gets a full 75; folded lines lose one to the leading space + limit = _FOLD_LIMIT # the first line gets a full 75; folded lines lose one to the leading space while len(raw) > limit: cut = limit - while cut > 0 and (raw[cut] & 0xC0) == 0x80: + while cut > 0 and (raw[cut] & _UTF8_CONT_MASK) == _UTF8_CONT_BITS: cut -= 1 chunks.append(raw[:cut]) raw = raw[cut:] - limit = 74 + limit = _FOLD_LIMIT - 1 chunks.append(raw) return "\r\n ".join(c.decode("utf-8") for c in chunks) @@ -48,7 +49,7 @@ def _fold(line: str) -> str: def _uid(name: str, unlock_at: datetime) -> str: # stable across re-runs for the same capsule, so re-importing updates # the event instead of creating a duplicate. - digest = hashlib.sha1(f"{name}|{_stamp(unlock_at)}".encode("utf-8")).hexdigest() + digest = hashlib.sha1(f"{name}|{_stamp(unlock_at)}".encode()).hexdigest() return f"{digest[:16]}@magicicapsula" diff --git a/pyproject.toml b/pyproject.toml index 893d690..2670129 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,9 @@ Homepage = "https://github.com/iDavi/magicicapsula" Repository = "https://github.com/iDavi/magicicapsula" Issues = "https://github.com/iDavi/magicicapsula/issues" +[project.optional-dependencies] +dev = ["ruff>=0.15"] + [project.scripts] magicicapsula = "magicicapsula.cli:main" @@ -44,3 +47,36 @@ include = ["magicicapsula*"] [tool.setuptools.package-data] magicicapsula = ["assets/*.txt"] + +[tool.ruff] +line-length = 120 +target-version = "py310" + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "F", # pyflakes (unused imports/vars, undefined names) + "W", # pycodestyle warnings + "I", # import sorting + "UP", # pyupgrade (outdated syntax) + "B", # bugbear (likely bugs: mutable defaults, etc.) + "SIM", # simplifiable code + "C4", # comprehension cleanups + "RUF", # ruff-specific + # --- catches common AI-generated code smells --- + "ERA", # commented-out / dead code left behind + "BLE", # blind `except:` that swallows everything + "RET", # redundant else/return after return + "PERF", # obvious performance anti-patterns + "PIE", # misc unnecessary code (pass, dict updates, ...) + "TRY", # exception handling anti-patterns + "PLW", # pylint warnings (e.g. clobbered loop vars) + "PLR2004", # unexplained magic numbers in comparisons +] +ignore = [ + "TRY003", # long messages at the raise site read fine for a small CLI +] + +[tool.ruff.lint.flake8-bugbear] +# KdfParams is a frozen (immutable) dataclass, so it's safe as a default. +extend-immutable-calls = ["magicicapsula.core.crypto.KdfParams"]