diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml index a4dbe219..94ea7f02 100644 --- a/.github/workflows/python-ci.yml +++ b/.github/workflows/python-ci.yml @@ -76,12 +76,25 @@ jobs: key: ${{ runner.os }}-pip-${{ hashFiles('requirements.lock') }} restore-keys: | ${{ runner.os }}-pip- - - name: Verify lockfile and install dependencies + # Regenerating the lockfile and diffing it proves the committed file + # really came from src/requirements.txt. It can only be done on ONE + # interpreter: pip-compile resolves version-conditional dependencies + # per Python version, so 3.10 and 3.11 produce genuinely different + # files (argon2-cffi-bindings among others), and a single committed + # lockfile cannot equal both. Run as written across this matrix the + # check was unsatisfiable -- whichever version the lockfile was built + # on, the other job failed. Linux/3.11 is the canonical generator, so + # the verification lives there. + - name: Verify the lockfile matches src/requirements.txt + if: runner.os == 'Linux' run: | python -m pip install --upgrade pip pip install pip-tools pip-compile --generate-hashes --output-file=requirements.lock src/requirements.txt git diff --exit-code requirements.lock + - name: Install dependencies + run: | + python -m pip install --upgrade pip pip install --require-hashes -r requirements.lock - name: Run dependency scan run: scripts/dependency_scan.sh --ignore-vuln GHSA-wj6h-64fc-37mp --ignore-vuln CVE-2025-62727 diff --git a/requirements.lock b/requirements.lock index 4019e8d0..0836ecb6 100644 --- a/requirements.lock +++ b/requirements.lock @@ -1,8 +1,8 @@ # -# This file is autogenerated by pip-compile with Python 3.12 +# This file is autogenerated by pip-compile with Python 3.11 # by the following command: # -# pip-compile --generate-hashes --output-file=/tmp/claude-1000/-home-user/4efd790e-427f-430a-b379-ef8391e6744f/scratchpad/req.lock src/requirements.txt +# pip-compile --generate-hashes --output-file=requirements.lock src/requirements.txt # aiohappyeyeballs==2.7.1 \ --hash=sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d \ diff --git a/scripts/mutation_test.py b/scripts/mutation_test.py index d340f556..1a780365 100644 --- a/scripts/mutation_test.py +++ b/scripts/mutation_test.py @@ -77,6 +77,7 @@ def acquire_lock() -> None: def release_lock() -> None: LOCK.unlink(missing_ok=True) + # Each target names the source file and the tests that should defend it. # Running only the relevant suites keeps a full pass tractable; a mutant that # survives its own suite but would be caught by another is still a finding, @@ -123,8 +124,7 @@ SECURITY_HINTS = re.compile( # `kinds` list is half of what it may reach -- and leaving it out hid the # shape check that stops a string `kinds` becoming a substring match. # `mnemonic`/`seed` guard the material everything else protects. - r"kind|mnemonic|seed" - r")", + r"kind|mnemonic|seed" r")", re.IGNORECASE, ) @@ -192,9 +192,7 @@ def generate(source: str) -> list[Mutant]: mutated = line.replace(old, new, 1) if mutated == line: continue - mutants.append( - Mutant(i, line, mutated, f"{old.strip()} -> {new.strip()}") - ) + mutants.append(Mutant(i, line, mutated, f"{old.strip()} -> {new.strip()}")) # Force a guard to always allow. This is the single most informative # mutation for security code: if no test notices a check being removed # entirely, that check is unverified. @@ -221,7 +219,10 @@ def generate(source: str) -> list[Mutant]: if ( inline and not line.rstrip().endswith("{") - and (SECURITY_HINTS.search(inline.group(2)) or REFUSAL.search(inline.group(3))) + and ( + SECURITY_HINTS.search(inline.group(2)) + or REFUSAL.search(inline.group(3)) + ) ): mutants.append( Mutant( @@ -247,9 +248,7 @@ def run(cmd: list[str], cwd: Path, timeout: int) -> tuple[int, str]: def main() -> int: parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument( - "--target", default="all", choices=[*sorted(TARGETS), "all"] - ) + parser.add_argument("--target", default="all", choices=[*sorted(TARGETS), "all"]) parser.add_argument( "--limit", type=int, default=0, help="cap mutants per target (0 = all)" ) @@ -335,12 +334,12 @@ def main() -> int: lines[mutant.line_no - 1] = mutant.mutated + ending path.write_text("".join(lines)) - code, _ = run( - ["npx", "tsc", "--noEmit"], CLI, timeout=args.timeout - ) + code, _ = run(["npx", "tsc", "--noEmit"], CLI, timeout=args.timeout) if code != 0: overall_counts["invalid"] += 1 - print(f" [{idx}/{len(all_mutants)}] line {mutant.line_no}: invalid (does not typecheck)") + print( + f" [{idx}/{len(all_mutants)}] line {mutant.line_no}: invalid (does not typecheck)" + ) continue code, _ = run( @@ -348,11 +347,15 @@ def main() -> int: ) if code != 0: overall_counts["killed"] += 1 - print(f" [{idx}/{len(all_mutants)}] line {mutant.line_no}: killed ({mutant.description})") + print( + f" [{idx}/{len(all_mutants)}] line {mutant.line_no}: killed ({mutant.description})" + ) else: overall_counts["survived"] += 1 overall_survivors.append((f"{rel}:{mutant.line_no}", mutant)) - print(f" [{idx}/{len(all_mutants)}] line {mutant.line_no}: SURVIVED ({mutant.description})") + print( + f" [{idx}/{len(all_mutants)}] line {mutant.line_no}: SURVIVED ({mutant.description})" + ) finally: restore() # Prove the restore worked rather than assuming it: a mutant left @@ -360,7 +363,9 @@ def main() -> int: # tree waiting to be committed. if path.read_text() != original: release_lock() - raise SystemExit(f"FAILED TO RESTORE {rel} -- fix this before committing") + raise SystemExit( + f"FAILED TO RESTORE {rel} -- fix this before committing" + ) release_lock() @@ -372,7 +377,9 @@ def main() -> int: f"{overall_counts['invalid']} invalid (of {total})" ) if viable: - print(f"mutation score: {overall_counts['killed'] / viable:.0%} of viable mutants caught") + print( + f"mutation score: {overall_counts['killed'] / viable:.0%} of viable mutants caught" + ) if unmeasured: print( @@ -383,7 +390,9 @@ def main() -> int: print(f" {where}") if overall_survivors: - print(f"\nSURVIVORS — each is either a gap or an equivalent mutant, and needs judging:\n") + print( + f"\nSURVIVORS — each is either a gap or an equivalent mutant, and needs judging:\n" + ) for where, mutant in overall_survivors: print(f" {where} [{mutant.description}]") print(f" was: {mutant.original.strip()[:110]}") diff --git a/src/seedpass/tui_v3/screens/atlas.py b/src/seedpass/tui_v3/screens/atlas.py index b9b1a9a6..34d600e1 100644 --- a/src/seedpass/tui_v3/screens/atlas.py +++ b/src/seedpass/tui_v3/screens/atlas.py @@ -53,7 +53,9 @@ def render_wayfinder(payload: dict) -> str: class AtlasWayfinderScreen(Screen): - CSS = MAINTENANCE_CSS + """ + CSS = ( + MAINTENANCE_CSS + + """ #atlas-wayfinder-shell { background: #101010; } #atlas-wayfinder-actions { height: auto; @@ -71,6 +73,7 @@ class AtlasWayfinderScreen(Screen): margin: 1 2; } """ + ) def __init__(self, payload: dict) -> None: super().__init__() diff --git a/src/seedpass/tui_v3/screens/profile.py b/src/seedpass/tui_v3/screens/profile.py index 72948b7e..60d378fb 100644 --- a/src/seedpass/tui_v3/screens/profile.py +++ b/src/seedpass/tui_v3/screens/profile.py @@ -21,7 +21,9 @@ class ProfileManagementScreen(Screen): Binding("escape", "app.pop_screen", "Back", show=True), ] - CSS = MAINTENANCE_CSS + """ + CSS = ( + MAINTENANCE_CSS + + """ ProfileManagementScreen { background: #999999; } @@ -38,6 +40,7 @@ class ProfileManagementScreen(Screen): min-width: 14; } """ + ) def compose(self) -> ComposeResult: yield Static("SeedPass ◈ Profile Management", classes="maintenance-title") diff --git a/src/seedpass/tui_v3/screens/pubkey.py b/src/seedpass/tui_v3/screens/pubkey.py index 6db15a5e..b3931d82 100644 --- a/src/seedpass/tui_v3/screens/pubkey.py +++ b/src/seedpass/tui_v3/screens/pubkey.py @@ -17,7 +17,9 @@ class NostrPubkeyScreen(Screen): Binding("c", "copy_pubkey", "Copy npub", show=True), ] - CSS = MAINTENANCE_CSS + """ + CSS = ( + MAINTENANCE_CSS + + """ NostrPubkeyScreen { background: #999999; } @@ -34,6 +36,7 @@ class NostrPubkeyScreen(Screen): min-width: 16; } """ + ) def compose(self) -> ComposeResult: yield Static("SeedPass ◈ Active Profile npub", classes="maintenance-title") diff --git a/src/seedpass/tui_v3/screens/relays.py b/src/seedpass/tui_v3/screens/relays.py index 1f1ce83f..cba69664 100644 --- a/src/seedpass/tui_v3/screens/relays.py +++ b/src/seedpass/tui_v3/screens/relays.py @@ -24,7 +24,9 @@ class RelaysScreen(Screen): ("s", "sync_now", "Sync Now"), ] - CSS = MAINTENANCE_CSS + """ + CSS = ( + MAINTENANCE_CSS + + """ RelaysScreen { background: #999999; } @@ -51,6 +53,7 @@ class RelaysScreen(Screen): min-width: 16; } """ + ) def compose(self) -> ComposeResult: yield Static("SeedPass ◈ Nostr Relay Management", classes="maintenance-title") diff --git a/src/seedpass/tui_v3/screens/security.py b/src/seedpass/tui_v3/screens/security.py index 9201fa47..caab5874 100644 --- a/src/seedpass/tui_v3/screens/security.py +++ b/src/seedpass/tui_v3/screens/security.py @@ -17,7 +17,9 @@ class ChangePasswordScreen(Screen): Binding("escape", "app.pop_screen", "Cancel", show=True), ] - CSS = MAINTENANCE_CSS + """ + CSS = ( + MAINTENANCE_CSS + + """ ChangePasswordScreen { background: #999999; } @@ -29,6 +31,7 @@ class ChangePasswordScreen(Screen): min-width: 18; } """ + ) def compose(self) -> ComposeResult: yield Static("SeedPass ◈ Change Vault Password", classes="maintenance-title") @@ -107,7 +110,9 @@ class BackupParentSeedScreen(Screen): Binding("escape", "app.pop_screen", "Cancel", show=True), ] - CSS = MAINTENANCE_CSS + """ + CSS = ( + MAINTENANCE_CSS + + """ BackupParentSeedScreen { background: #999999; } @@ -119,6 +124,7 @@ class BackupParentSeedScreen(Screen): min-width: 18; } """ + ) def compose(self) -> ComposeResult: yield Static("SeedPass ◈ Backup Parent Seed", classes="maintenance-title")