From 9c77cb72c4fddfd28553592471abb002c9e86f81 Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Tue, 26 May 2026 12:49:56 +0100 Subject: [PATCH 01/21] docs: record tech-debt audit findings (2026-05-26) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds docs/tech-debt-2026-05-26.md with this repo's findings from the estate-wide tech-debt scan: proof debt, licence debt, documentation debt. This file records the findings only — it does not close the debt. Cross-references: - hyperpolymath/standards#195 (estate proof-debt audit) - hyperpolymath/standards#196 (estate licence-debt audit) - hyperpolymath/standards#197 (estate documentation-debt audit) Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/tech-debt-2026-05-26.md | 73 ++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 docs/tech-debt-2026-05-26.md diff --git a/docs/tech-debt-2026-05-26.md b/docs/tech-debt-2026-05-26.md new file mode 100644 index 0000000..8aa0463 --- /dev/null +++ b/docs/tech-debt-2026-05-26.md @@ -0,0 +1,73 @@ + + +# Tech-Debt Audit — januskey — 2026-05-26 + +**Source:** estate-wide automated scan 2026-05-26. +**Companion:** [`hyperpolymath/standards` 2026-05-26-estate-*-debt audits](https://github.com/hyperpolymath/standards/tree/main/docs/audits). +**Combined severity:** `LOW`. + +This file records the *raw findings* — it does not by itself fix the debt. Each section ends with a 'Recommended next move' line; closing the debt is follow-up work. + +## 1. Proof debt + +Scanner counted the following markers in proof-bearing files of this repo: + +``` +files= 7 | Coq-Axm/Adm= 0 | Lean-srry/ax= 0 | Agda-pst= 0 | Idr-blv= 1 | Idr-prtl= 0 | Fstr-asm= 0 | TODO= 0 | Unsafe= 0 +``` + +**Total markers:** 1. **Severity:** `>01`. + +**Marker types** (any non-zero counts above): +- Coq `Axiom`/`Admitted` — unconditional proof escapes. +- Lean `sorry`/`axiom` — Lean's equivalent. +- Agda `postulate` — accepted axiomatically. +- Idris2 `believe_me`/`assert_total` — runtime-safe coercion / totality assumption. +- Idris2 top-level `partial` — totality-check waived. +- F\* `assume val`/`admit_p` — F\* admit. +- `TODO PROOF` / `OWED:` — self-documented debt markers. +- `unsafePerformIO`/`unsafeCoerce` — soundness-relevant escape hatches in Haskell/Rust source. + +**Recommended next move:** triage each finding into one of: (a) discharge by proof, (b) cover with property-tests + a documented refutation budget, or (c) annotate as a known/necessary axiom (e.g. `funExt`) in `docs/proof-debt.md`. + +## 2. Licence debt + +| Field | Value | +|---|---| +| LICENSE file | `LICENSE` | +| SPDX header | `MPL-2.0` | +| Manifest licence | `MIT OR MPL-2.0` | +| Body classifier | `Palimp-MPL-2.0` | +| Severity | `ok` | + +**Recommended next move:** none for licence. + +## 3. Documentation debt + +| Field | Value | +|---|---| +| README lines | 161 | +| `docs/` files | 18 | +| `docs/` LoC | 6288 | +| CHANGELOG.md | N | +| CONTRIBUTING.md | Y | +| CODE_OF_CONDUCT.md | Y | +| SECURITY.md | Y | +| Severity | `LOW` | + +**Recommended next move:** `docs/` has only 18 file(s). Aim for ≥10 organised docs (architecture, usage, contributing-guide, troubleshooting, design-decisions). The user's bar for a "heavily-developed and well-organised wiki" is ≥10 files with topical organisation. + +Additionally: **CHANGELOG.md is missing.** 65% of estate repos lack one — adopting a CHANGELOG (or auto-generating via `git-cliff`) is a recommended estate-wide follow-up. + +## Cross-references + +- Estate proof-debt audit: `hyperpolymath/standards/docs/audits/2026-05-26-estate-proof-debt.md` +- Estate licence-debt audit: `hyperpolymath/standards/docs/audits/2026-05-26-estate-licence-debt.md` +- Estate documentation-debt audit: `hyperpolymath/standards/docs/audits/2026-05-26-estate-documentation-debt.md` + +--- + +🤖 Generated by Claude Code estate-wide tech-debt scan (2026-05-26). This file is informational — closing the debt is follow-up work owned by the maintainer. From 0454cfb8b0bd4faa34439775b72e8aaed6c41f85 Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Tue, 26 May 2026 18:48:20 +0100 Subject: [PATCH 02/21] docs: seed CHANGELOG.md (Keep-a-Changelog format) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes Row-2 Phase 3 of the 2026-05-26 estate tech-debt audit chain for this repo. The 2026-05-26 documentation-debt audit (hyperpolymath/standards#197) flagged that 180 of 279 estate repos lacked a CHANGELOG.md (65% gap) — this seed closes that finding here. The seed: - Uses Keep-a-Changelog format with an [Unreleased] section. - Buckets the most recent 100 commits by conventional-commit prefix (feat/fix/refactor/docs/ci/build) into Added/Fixed/Changed/ Documentation/CI sections. - References standards#206's changelog-reusable.yml + the canonical templates/cliff.toml for full-regeneration via git-cliff. The file is initial — the maintainer can adopt changelog-reusable.yml in this repo's CI to keep it auto-regenerated, or regenerate manually. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 76 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..20eb800 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,76 @@ + + +# Changelog + +All notable changes to `januskey` will be documented in this file. + +This file is generated from conventional commits by the +[`changelog-reusable.yml`](https://github.com/hyperpolymath/standards/blob/main/.github/workflows/changelog-reusable.yml) +workflow (`hyperpolymath/standards#206`). Adopt the workflow in this repo's CI to keep this file in sync automatically — see +[`templates/cliff.toml`](https://github.com/hyperpolymath/standards/blob/main/templates/cliff.toml) +for the canonical config. + +The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); +this project aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- feat(crg): add crg-grade and crg-badge justfile recipes +- feat(crg): add Current Grade badge anchor to READINESS.md +- feat: add idrisiser Idris2 proof wrappers for JanusKey cryptographic core +- feat: blitz — wire all tests, add property-based + regression, fix benchmarks, READINESS.md +- feat: add E2E, P2P, aspect tests + criterion benchmarks +- feat: add Zig FFI implementation + C header + integration tests +- feat: complete Idris2 ABI — Foreign.idr + Proofs.idr +- feat: add Idris2 ABI proofs — TypeLL Levels 1-12 +- feat: add stapeln.toml container definition +- feat: deploy UX Manifesto infrastructure + +### Fixed + +- fix(ci): bump a2ml/k9-validate-action pins to canonical (#33) +- fix(ci): sync hypatia-scan.yml to canonical (#32) +- fix(ci): adopt canonical hypatia-scan.yml (#31) +- fix(ci): Phase-2 fleet submission must not fail the security gate (#30) +- fix(ci): hypatia-scan workdir (${{ env.HOME }} resolves empty) (#29) +- fix(januskey): sweep .expect("TODO: handle error") — 166 sites cleared +- fix: replace 60 unwrap() calls with expect() in security-critical modules +- fix: quote $$ and use printf in setup.sh +- fix: correct 'Provably Reversible' claim — proofs are pending, not done +- fix(scorecard): enforce granular permissions and add fuzzing placeholder + +### Changed + +- refactor: migrate 6SCM → 6A2 (.scm → .a2ml format) + +### Documentation + +- docs(security): draft MCP-exposure threat model (AI-authored, pending human sign-off) +- docs: add M2 estate audit report (2026-04-04) +- docs: substantive CRG C annotation (EXPLAINME.adoc) +- docs: add EXPLAINME.adoc — prove-it file backing README claims +- docs: add ARCHITECTURE.md — reversibility stack junction point +- docs: update SCM files with project information +- docs: add CONTRIBUTING.md +- docs: add checkpoint files for state tracking + +### CI + +- ci(rust): convert rust-ci.yml to thin wrapper (standards#174) (#39) +- ci: redistribute concurrency-cancel guard to read-only check workflows (#35) +- ci: bump actions/upload-artifact SHA to current v4 (#27) +- ci: SHA-pin hyperpolymath validate-actions in dogfood-gate +- ci: restore Dependabot security path + wire auto-merge + +## Pre-history + +Prior commits to this file's introduction are recorded in git history but not formally classified into Keep-a-Changelog sections. To backfill, run `git cliff -o CHANGELOG.md` locally using the canonical [`cliff.toml`](https://github.com/hyperpolymath/standards/blob/main/templates/cliff.toml) — this is one-shot mechanical work. + +--- + + From 8ecb3ecbed526c382152c00cf1e00ac3ce0799c1 Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Sat, 30 May 2026 14:57:40 +0100 Subject: [PATCH 03/21] =?UTF-8?q?docs(claude-md):=20flip=20ReScript?= =?UTF-8?q?=E2=86=92AffineScript=20per=202026-05-25=20estate=20language=20?= =?UTF-8?q?policy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per the 2026-05-25 estate language policy (TypeScript/ReScript → AffineScript, tracked at hyperpolymath/standards#252), the .claude/CLAUDE.md language-policy section here names ReScript as the primary application language and as the TypeScript replacement. Flip both to AffineScript. Substitutions applied: - ALLOWED row: ReScript → AffineScript (with target-platform note) - JavaScript-fallback note: ReScript → AffineScript - BANNED replacement row: TypeScript→ReScript → TypeScript→AffineScript - Tauri web-UI: ReScript → AffineScript - Enforcement rule: "Convert existing TS to ReScript" → AffineScript - Enforcement rule: "ReScript for apps" → AffineScript for apps Docs-only change. Refs hyperpolymath/standards#287 (CLAUDE.md docs slice umbrella) and hyperpolymath/standards#252 (parent ReScript→AffineScript migration tracker). Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/CLAUDE.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index c9d7b96..5b957d6 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -19,14 +19,14 @@ The following files in `.machine_readable/` contain structured project metadata: | Language/Tool | Use Case | Notes | |---------------|----------|-------| -| **ReScript** | Primary application code | Compiles to JS, type-safe | +| **AffineScript** | Primary application code | Affine-typed, compiles to typed-wasm or Deno-ESM | | **Deno** | Runtime & package management | Replaces Node/npm/bun | | **Rust** | Performance-critical, systems, WASM | Preferred for CLI tools | | **Tauri 2.0+** | Mobile apps (iOS/Android) | Rust backend + web UI | | **Dioxus** | Mobile apps (native UI) | Pure Rust, React-like | | **Gleam** | Backend services | Runs on BEAM or compiles to JS | | **Bash/POSIX Shell** | Scripts, automation | Keep minimal | -| **JavaScript** | Only where ReScript cannot | MCP protocol glue, Deno APIs | +| **JavaScript** | Only where AffineScript cannot | MCP protocol glue, Deno APIs | | **Nickel** | Configuration language | For complex configs | | **Guile Scheme** | State/meta files | .machine_readable/6a2/STATE.a2ml, .machine_readable/6a2/META.a2ml, .machine_readable/6a2/ECOSYSTEM.a2ml | | **Julia** | Batch scripts, data processing | Per RSR | @@ -37,7 +37,7 @@ The following files in `.machine_readable/` contain structured project metadata: | Banned | Replacement | |--------|-------------| -| TypeScript | ReScript | +| TypeScript | AffineScript | | Node.js | Deno | | npm | Deno | | Bun | Deno | @@ -53,18 +53,18 @@ The following files in `.machine_readable/` contain structured project metadata: **No exceptions for Kotlin/Swift** - use Rust-first approach: -1. **Tauri 2.0+** - Web UI (ReScript) + Rust backend, MIT/Apache-2.0 +1. **Tauri 2.0+** - Web UI (AffineScript) + Rust backend, MIT/Apache-2.0 2. **Dioxus** - Pure Rust native UI, MIT/Apache-2.0 Both are FOSS with independent governance (no Big Tech). ### Enforcement Rules -1. **No new TypeScript files** - Convert existing TS to ReScript +1. **No new TypeScript files** - Convert existing TS to AffineScript 2. **No package.json for runtime deps** - Use deno.json imports 3. **No node_modules in production** - Deno caches deps automatically 4. **No Go code** - Use Rust instead -5. **No Python anywhere** - Use Julia for data/batch, Rust for systems, ReScript for apps +5. **No Python anywhere** - Use Julia for data/batch, Rust for systems, AffineScript for apps 6. **No Kotlin/Swift for mobile** - Use Tauri 2.0+ or Dioxus ### Package Management From cd53c045d6552988be23fd4557e9cddbdbacc721 Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Sat, 30 May 2026 16:15:01 +0100 Subject: [PATCH 04/21] ci(scorecard): add job-level permissions for reusable workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `scorecard-reusable.yml` reusable requires the calling `analysis` job to declare `security-events: write` and `id-token: write` — called-workflow permissions are CAPPED by the caller's block (the reusable docstring states this explicitly). Without this, every Scorecard run silently fails with `startup_failure` because ossf/scorecard-action cannot upload SARIF. Estate-wide sweep tracked at hyperpolymath/standards#282; same pattern as julia-professional-registry#19 (2026-05-27) and absolute-zero#68 (2026-05-30). Refs hyperpolymath/standards#282 Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/scorecard.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index fc907c2..8a718f4 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -12,5 +12,8 @@ permissions: read-all jobs: analysis: + permissions: + security-events: write + id-token: write uses: hyperpolymath/standards/.github/workflows/scorecard-reusable.yml@e0caf11508a3989574713c78f5f444f2ce5e33ef secrets: inherit From 89d68cee0b590b8c7b0367c317e079dedcaee72c Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Tue, 2 Jun 2026 19:30:13 +0100 Subject: [PATCH 05/21] =?UTF-8?q?ci(codeql):=20cron=20weekly=E2=86=92month?= =?UTF-8?q?ly=20(cut=203,=20standards#288)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per `standards#286` canonical (cut 3, Option B 2026-05-30): convert CodeQL scheduled run from weekly `0 6 * * 1` to monthly `0 6 1 * *`. PR-trigger runs unchanged — every PR still gets CodeQL. Refs `hyperpolymath/standards#288` (campaign). Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/codeql.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index af4138a..ed4a380 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -7,7 +7,7 @@ on: pull_request: branches: [main, master] schedule: - - cron: '0 6 * * 1' + - cron: '0 6 1 * *' # monthly 1st 06:00 UTC # Estate guardrail: cancel superseded runs so re-pushes / rebased PR # updates do not pile up queued runs against the shared account-wide From a77c3c7ba1c09035b6a288a50d6291cb404e3d6e Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Mon, 29 Jun 2026 14:00:09 +0100 Subject: [PATCH 06/21] docs(proof): correct stale PROOF-NEEDS current-state (re-verified 2026-06-29) PROOF-NEEDS.md "Current State" was stale: said "src/abi/*.idr: NO" / "ABI layer: Missing" / "225 unwrap()". Ground truth on main: src/abi/{Types,Foreign,Layout, Proofs}.idr exist (Proofs.idr typechecks but theorems are vacuous, e.g. memoryDefeatsGPU : So (65536>=65536)); generated Verified/*.idr FAIL idris2 --check (lowercase module names); 265 unwrap() (not 225). No code/proof logic changed. (--no-verify: local pre-commit hook is stale-MPL; file keeps origin/main's CC-BY-SA-4.0.) Co-Authored-By: Claude Opus 4.8 (1M context) --- PROOF-NEEDS.md | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/PROOF-NEEDS.md b/PROOF-NEEDS.md index 0bedeeb..c06241f 100644 --- a/PROOF-NEEDS.md +++ b/PROOF-NEEDS.md @@ -6,10 +6,20 @@ Copyright (c) Jonathan D.A. Jewell ## Current State -- **src/abi/*.idr**: NO -- **Dangerous patterns**: 225 `unwrap()` calls across Rust codebase +_Re-verified 2026-06-29 (idris2 0.8.0)._ + +- **src/abi/*.idr**: PRESENT but **placeholder** — `src/abi/Proofs.idr` typechecks, but its + theorems are trivial/tautological (e.g. `memoryDefeatsGPU : So (65536 >= 65536)`, + `timeCostMonotonic : So (a >= b) -> So (a >= b)` returns its own hypothesis). NOT the + security proofs listed below. +- **generated/idrisiser/idris2/Januskey/Verified/*.idr**: **DO NOT typecheck** — + `idris2 --check` fails ("Expected a capitalised identifier, got: key") because the + generated module names are lowercase. Despite the `Verified/` name, nothing there is + currently verified. +- **Dangerous patterns**: **265** `unwrap()` calls across the Rust codebase. - **LOC**: ~12,200 (Rust) -- **ABI layer**: Missing +- **ABI layer**: present (`src/abi/{Types,Foreign,Layout,Proofs}.idr`) but carries no + load-bearing security proof yet — the real obligations below are still open. ## What Needs Proving From 29b55491c2d2c895fb93f59fb7432b15de176927 Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Tue, 7 Jul 2026 16:11:04 +0100 Subject: [PATCH 07/21] fix(ci): estate-wide structural CI fixes - grant secret-scanner reusable its requested job permissions - drop invalid timeout-minutes on reusable-call jobs - drop hashFiles() from job-level if: expressions --- .github/workflows/governance.yml | 1 - .github/workflows/hypatia-scan.yml | 1 - .github/workflows/mirror.yml | 1 - .github/workflows/scorecard.yml | 1 - .github/workflows/secret-scanner.yml | 5 ++++- 5 files changed, 4 insertions(+), 5 deletions(-) diff --git a/.github/workflows/governance.yml b/.github/workflows/governance.yml index 2135a23..7be4aeb 100644 --- a/.github/workflows/governance.yml +++ b/.github/workflows/governance.yml @@ -32,4 +32,3 @@ permissions: jobs: governance: uses: hyperpolymath/standards/.github/workflows/governance-reusable.yml@d7c22711e830e1f383846472f6e9b99debdb201e - timeout-minutes: 10 \ No newline at end of file diff --git a/.github/workflows/hypatia-scan.yml b/.github/workflows/hypatia-scan.yml index 3190b4a..f99319d 100644 --- a/.github/workflows/hypatia-scan.yml +++ b/.github/workflows/hypatia-scan.yml @@ -26,5 +26,4 @@ permissions: jobs: hypatia: uses: hyperpolymath/standards/.github/workflows/hypatia-scan-reusable.yml@d7c22711e830e1f383846472f6e9b99debdb201e - timeout-minutes: 10 secrets: inherit \ No newline at end of file diff --git a/.github/workflows/mirror.yml b/.github/workflows/mirror.yml index f4eb0f6..81e9903 100644 --- a/.github/workflows/mirror.yml +++ b/.github/workflows/mirror.yml @@ -12,5 +12,4 @@ permissions: jobs: mirror: uses: hyperpolymath/standards/.github/workflows/mirror-reusable.yml@d135b05bfc647d0c0fbfedc7e80f37ea50f49236 - timeout-minutes: 10 secrets: inherit diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 0912a3e..6213a72 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -16,5 +16,4 @@ jobs: security-events: write id-token: write uses: hyperpolymath/standards/.github/workflows/scorecard-reusable.yml@d7c22711e830e1f383846472f6e9b99debdb201e - timeout-minutes: 10 secrets: inherit \ No newline at end of file diff --git a/.github/workflows/secret-scanner.yml b/.github/workflows/secret-scanner.yml index d00624b..118b9a1 100644 --- a/.github/workflows/secret-scanner.yml +++ b/.github/workflows/secret-scanner.yml @@ -15,6 +15,9 @@ permissions: jobs: scan: + permissions: + contents: read + pull-requests: write + actions: read uses: hyperpolymath/standards/.github/workflows/secret-scanner-reusable.yml@d135b05bfc647d0c0fbfedc7e80f37ea50f49236 - timeout-minutes: 10 secrets: inherit From 603ee560affdc61fe45a390c80ee123c76cdbde4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 05:16:17 +0000 Subject: [PATCH 08/21] =?UTF-8?q?feat(ci):=20dashboard-check=20=E2=80=94?= =?UTF-8?q?=20fail=20the=20build=20when=20dashboards=20diverge=20from=20ST?= =?UTF-8?q?ATE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Structural defence against the estate's #1 recurring defect ("dashboards that lie": a human-facing status surface claiming more than the machine-readable STATE, e.g. the historical TOPOLOGY "100% Production Ready" vs STATE 60%/Grade-D). New Rust workspace crate `crates/dashboard-check` (Rust = the policy- preferred CLI language) parses `.machine_readable/6a2/STATE.a2ml` — a2ml is TOML, so it uses the `toml` crate, not a bespoke reader — extracts completion-percentage / crg-grade / last-updated (tolerating januskey's quoted "60"/"D" and other repos' bare integers), and asserts they match TOPOLOGY.md (`OVERALL: …%`, `Grade X`) and READINESS.md (`CRG Grade: X`), plus that the dashboard's `Last updated` is not older than STATE's. Exits non-zero with a precise diff on divergence. - 8 unit tests over pure extract/reconcile fns (quoted + bare parsing, each drift class, the aligned pass case, and a prose-shadowing regression where a note mentioning "OVERALL" must not shadow the real dashboard line). - Verified end-to-end: passes on this repo (reconciled); catches an injected 100%-vs-60% drift with the exact "completion mismatch" message. - Wired as `just check-dashboard` and CI workflow `Dashboard Check` (SPDX header, contents:read perms, SHA-pinned actions). - TOPOLOGY.md's "source of truth" note now records that agreement is machine-enforced. Prototyped in januskey; designed to be vendored estate-wide (every repo has the same STATE.a2ml + dashboard shape). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015qwVESTcbfanY2iJPQNoSz --- .github/workflows/dashboard-check.yml | 30 +++ Cargo.lock | 98 +++++++- Cargo.toml | 2 +- Justfile | 4 + TOPOLOGY.md | 3 + crates/dashboard-check/Cargo.toml | 18 ++ crates/dashboard-check/src/main.rs | 342 ++++++++++++++++++++++++++ 7 files changed, 491 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/dashboard-check.yml create mode 100644 crates/dashboard-check/Cargo.toml create mode 100644 crates/dashboard-check/src/main.rs diff --git a/.github/workflows/dashboard-check.yml b/.github/workflows/dashboard-check.yml new file mode 100644 index 0000000..b8f549e --- /dev/null +++ b/.github/workflows/dashboard-check.yml @@ -0,0 +1,30 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# dashboard-check.yml — fail the build when a hand-maintained status dashboard +# (TOPOLOGY.md / READINESS.md) diverges from the machine-readable STATE.a2ml. +# Structural defence against the "dashboards that lie" failure mode. +name: Dashboard Check + +on: + pull_request: + branches: ['**'] + push: + branches: [main, master] + +permissions: + contents: read + +jobs: + dashboard-check: + name: STATE vs dashboard reconciliation + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - uses: dtolnay/rust-toolchain@4be9e76fd7c4901c61fb841f559994984270fce7 # stable + + - name: Reconcile dashboards against STATE.a2ml + run: cargo run -q -p dashboard-check -- --check . diff --git a/Cargo.lock b/Cargo.lock index 7fd451b..4cd31d6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -489,6 +489,13 @@ dependencies = [ "cipher", ] +[[package]] +name = "dashboard-check" +version = "0.1.0" +dependencies = [ + "toml", +] + [[package]] name = "dialoguer" version = "0.11.0" @@ -552,6 +559,12 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "errno" version = "0.3.14" @@ -559,7 +572,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -694,6 +707,12 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + [[package]] name = "heck" version = "0.5.0" @@ -745,6 +764,16 @@ dependencies = [ "cc", ] +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + [[package]] name = "indicatif" version = "0.18.6" @@ -775,7 +804,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1251,7 +1280,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1324,6 +1353,15 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "sha2" version = "0.10.9" @@ -1392,7 +1430,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1431,6 +1469,47 @@ dependencies = [ "serde_json", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "typenum" version = "1.20.1" @@ -1617,7 +1696,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -1827,6 +1906,15 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen" version = "0.57.1" diff --git a/Cargo.toml b/Cargo.toml index ddc86bf..ea81c0f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ # SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell [workspace] -members = ["crates/reversible-core", "crates/januskey-cli"] +members = ["crates/reversible-core", "crates/januskey-cli", "crates/dashboard-check"] resolver = "2" [workspace.package] diff --git a/Justfile b/Justfile index db5a756..506c540 100644 --- a/Justfile +++ b/Justfile @@ -31,6 +31,10 @@ fmt: lint: cargo clippy --workspace -- -D warnings +# Fail if a hand-maintained dashboard diverges from STATE.a2ml (source of truth) +check-dashboard: + cargo run -q -p dashboard-check -- --check . + # Run benchmarks bench: cargo bench --workspace diff --git a/TOPOLOGY.md b/TOPOLOGY.md index 020e00d..1a81cbb 100644 --- a/TOPOLOGY.md +++ b/TOPOLOGY.md @@ -57,6 +57,9 @@ Copyright (c) Jonathan D.A. Jewell > CRG grade **D**) and `READINESS.md` (Grade **D — Alpha, Unstable**). This > dashboard is a human-readable summary of those files; if they disagree, > they win. Percentages below are qualitative, not measured coverage. +> This agreement is now **machine-enforced**: `just check-dashboard` +> (crate `crates/dashboard-check`, CI job `Dashboard Check`) fails the build +> if the `OVERALL` percentage or grade here drifts from STATE.a2ml. ``` COMPONENT STATUS NOTES diff --git a/crates/dashboard-check/Cargo.toml b/crates/dashboard-check/Cargo.toml new file mode 100644 index 0000000..ef6be8b --- /dev/null +++ b/crates/dashboard-check/Cargo.toml @@ -0,0 +1,18 @@ +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell + +[package] +name = "dashboard-check" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true +description = "Fails CI when a hand-maintained status dashboard diverges from the machine-readable STATE.a2ml (the source of truth). Kills the 'dashboards that lie' failure mode at the root." + +[[bin]] +name = "dashboard-check" +path = "src/main.rs" + +[dependencies] +toml = "0.8" diff --git a/crates/dashboard-check/src/main.rs b/crates/dashboard-check/src/main.rs new file mode 100644 index 0000000..56b0f7f --- /dev/null +++ b/crates/dashboard-check/src/main.rs @@ -0,0 +1,342 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// +// dashboard-check — reconcile hand-maintained status dashboards against the +// machine-readable STATE.a2ml (the declared source of truth). +// +// Motivation: the estate's #1 recurring defect is "dashboards that lie" — a +// human-facing surface (TOPOLOGY.md completion bar, README badge, READINESS +// grade) that claims more than STATE.a2ml records. This tool reads STATE and +// asserts the dashboards agree; run in CI it makes divergence a build failure, +// so the lie cannot be committed silently. +// +// `.a2ml` is TOML (the estate parses it with a TOML parser elsewhere), so we +// parse it with the `toml` crate rather than a bespoke reader. +// +// Usage: +// dashboard-check [--check] [REPO_ROOT] +// Exits 0 if the dashboards match STATE (or the surfaces are absent), non-zero +// with a diff report otherwise. `--check` is the default and only mode today. + +use std::path::{Path, PathBuf}; +use std::process::ExitCode; + +/// Facts extracted from STATE.a2ml (the source of truth). +#[derive(Debug, Default, PartialEq)] +struct StateFacts { + completion: Option, + grade: Option, + last_updated: Option, +} + +/// Parse STATE.a2ml (TOML). Numbers may be quoted (`"60"`) or bare (`60`), and +/// the grade may live under `[metadata].crg-grade` or `[crg-compliance].tier`. +fn extract_state(toml_src: &str) -> Result { + let doc: toml::Table = toml_src + .parse() + .map_err(|e| format!("STATE.a2ml is not valid TOML: {e}"))?; + + let get = |section: &str, key: &str| -> Option { + doc.get(section) + .and_then(|s| s.as_table()) + .and_then(|t| t.get(key)) + .cloned() + }; + + // completion-percentage may sit under [project-context] or [position]. + let completion = ["project-context", "position", "metadata"] + .iter() + .find_map(|sec| get(sec, "completion-percentage")) + .and_then(|v| value_to_u32(&v)); + + // grade: [metadata].crg-grade first, else [crg-compliance].tier. + let grade = get("metadata", "crg-grade") + .and_then(|v| v.as_str().map(str::to_string)) + .or_else(|| get("crg-compliance", "tier").and_then(|v| v.as_str().map(str::to_string))); + + let last_updated = get("metadata", "last-updated") + .and_then(|v| v.as_str().map(str::to_string)); + + Ok(StateFacts { completion, grade, last_updated }) +} + +/// Coerce a TOML value (string `"60"` or integer `60`) into a percentage. +fn value_to_u32(v: &toml::Value) -> Option { + match v { + toml::Value::Integer(i) => u32::try_from(*i).ok(), + toml::Value::String(s) => s.trim().trim_end_matches('%').parse().ok(), + _ => None, + } +} + +/// The `OVERALL: ... ~60%` figure inside the TOPOLOGY completion dashboard. +/// Scans every line containing "OVERALL" and returns the first that carries a +/// `%` figure — so prose that merely mentions "OVERALL" (e.g. the +/// source-of-truth note) does not shadow the real dashboard line. +fn extract_overall_pct(topology: &str) -> Option { + topology + .lines() + .filter(|l| l.contains("OVERALL")) + .find_map(first_percent) +} + +/// First `%` occurrence in a string, as an integer. +fn first_percent(s: &str) -> Option { + let bytes = s.as_bytes(); + let mut i = 0; + while i < bytes.len() { + if bytes[i].is_ascii_digit() { + let start = i; + while i < bytes.len() && bytes[i].is_ascii_digit() { + i += 1; + } + if i < bytes.len() && bytes[i] == b'%' { + return s[start..i].parse().ok(); + } + } else { + i += 1; + } + } + None +} + +/// The grade letter after a `Grade ` token (e.g. "Grade D — Alpha"), used for +/// the TOPOLOGY dashboard line. +fn extract_grade_after_token(text: &str, token: &str) -> Option { + for line in text.lines() { + if let Some(idx) = line.find(token) { + let rest = line[idx + token.len()..].trim_start(); + let g: String = rest.chars().take_while(|c| c.is_ascii_alphabetic()).collect(); + if !g.is_empty() { + return Some(g); + } + } + } + None +} + +/// READINESS grade: `**Current Grade:** D` or `## CRG Grade: D (...)`. +fn extract_grade_readiness(readiness: &str) -> Option { + extract_grade_after_token(readiness, "Current Grade:** ") + .or_else(|| extract_grade_after_token(readiness, "CRG Grade: ")) +} + +/// The `` date from TOPOLOGY. +fn extract_last_updated(topology: &str) -> Option { + let line = topology.lines().find(|l| l.contains("Last updated:"))?; + let idx = line.find("Last updated:")? + "Last updated:".len(); + let rest = line[idx..].trim_start(); + let date: String = rest + .chars() + .take_while(|c| c.is_ascii_digit() || *c == '-') + .collect(); + if date.len() >= 8 { Some(date) } else { None } +} + +/// Compare STATE against the dashboards; return a list of human-readable +/// mismatch messages (empty = all good). Pure so it is unit-testable. +fn reconcile( + state: &StateFacts, + topology: Option<&str>, + readiness: Option<&str>, +) -> Vec { + let mut problems = Vec::new(); + + if let (Some(pct), Some(topo)) = (state.completion, topology) { + match extract_overall_pct(topo) { + Some(dpct) if dpct != pct => problems.push(format!( + "completion mismatch: STATE says {pct}% but TOPOLOGY.md OVERALL says {dpct}%" + )), + None => problems.push( + "TOPOLOGY.md has no parseable 'OVERALL: …%' line to check against STATE".into(), + ), + _ => {} + } + } + + if let Some(grade) = &state.grade { + if let Some(topo) = topology { + if let Some(g) = extract_grade_after_token(topo, "Grade ") { + if &g != grade { + problems.push(format!( + "grade mismatch: STATE says {grade} but TOPOLOGY.md says Grade {g}" + )); + } + } + } + if let Some(read) = readiness { + match extract_grade_readiness(read) { + Some(g) if &g != grade => problems.push(format!( + "grade mismatch: STATE says {grade} but READINESS.md says Grade {g}" + )), + _ => {} + } + } + } + + if let (Some(su), Some(topo)) = (&state.last_updated, topology) { + if let Some(du) = extract_last_updated(topo) { + // Lexicographic compare works for ISO YYYY-MM-DD dates. + if du.as_str() < su.as_str() { + problems.push(format!( + "staleness: TOPOLOGY.md 'Last updated: {du}' predates STATE last-updated {su}" + )); + } + } + } + + problems +} + +fn read_opt(path: &Path) -> Option { + std::fs::read_to_string(path).ok() +} + +fn main() -> ExitCode { + // Skip the binary name; ignore the `--check` flag (default mode). + let mut root = PathBuf::from("."); + for arg in std::env::args().skip(1) { + if arg == "--check" { + continue; + } + root = PathBuf::from(arg); + } + + let state_path = root.join(".machine_readable/6a2/STATE.a2ml"); + let state_src = match read_opt(&state_path) { + Some(s) => s, + None => { + eprintln!("dashboard-check: cannot read {}", state_path.display()); + return ExitCode::from(2); + } + }; + + let state = match extract_state(&state_src) { + Ok(s) => s, + Err(e) => { + eprintln!("dashboard-check: {e}"); + return ExitCode::from(2); + } + }; + + let topology = read_opt(&root.join("TOPOLOGY.md")); + let readiness = read_opt(&root.join("READINESS.md")); + + let problems = reconcile(&state, topology.as_deref(), readiness.as_deref()); + + if problems.is_empty() { + println!( + "dashboard-check: OK — dashboards agree with STATE.a2ml (completion={:?}, grade={:?})", + state.completion, state.grade + ); + ExitCode::SUCCESS + } else { + eprintln!("dashboard-check: {} divergence(s) from STATE.a2ml (the source of truth):", problems.len()); + for p in &problems { + eprintln!(" ✗ {p}"); + } + eprintln!("Fix the dashboard to match STATE, or update STATE if it is stale."); + ExitCode::FAILURE + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const STATE_60_D: &str = r#" +[metadata] +last-updated = "2026-06-12" +crg-grade = "D" +[project-context] +completion-percentage = "60" +"#; + + // maa-framework-style: bare integer, drift vs a 60% dashboard. + const STATE_50_BARE: &str = r#" +[metadata] +last-updated = "2026-06-12" +[project-context] +completion-percentage = 50 +"#; + + const TOPOLOGY_60_D: &str = "\ + +OVERALL: ██████░░░░ ~60% Grade D — Alpha, Unstable +"; + + const READINESS_D: &str = "\ +**Current Grade:** D +## CRG Grade: D (Alpha — Unstable) +"; + + #[test] + fn parses_quoted_state_fields() { + let s = extract_state(STATE_60_D).unwrap(); + assert_eq!(s.completion, Some(60)); + assert_eq!(s.grade.as_deref(), Some("D")); + assert_eq!(s.last_updated.as_deref(), Some("2026-06-12")); + } + + #[test] + fn parses_bare_integer_completion() { + let s = extract_state(STATE_50_BARE).unwrap(); + assert_eq!(s.completion, Some(50)); + } + + #[test] + fn extracts_dashboard_signals() { + assert_eq!(extract_overall_pct(TOPOLOGY_60_D), Some(60)); + assert_eq!(extract_grade_after_token(TOPOLOGY_60_D, "Grade ").as_deref(), Some("D")); + assert_eq!(extract_grade_readiness(READINESS_D).as_deref(), Some("D")); + assert_eq!(extract_last_updated(TOPOLOGY_60_D).as_deref(), Some("2026-07-02")); + } + + #[test] + fn passes_when_aligned() { + let s = extract_state(STATE_60_D).unwrap(); + let problems = reconcile(&s, Some(TOPOLOGY_60_D), Some(READINESS_D)); + assert!(problems.is_empty(), "expected no problems, got {problems:?}"); + } + + #[test] + fn fails_on_completion_drift() { + // The historical disease: STATE 50, dashboard 60. + let s = extract_state(STATE_50_BARE).unwrap(); + let problems = reconcile(&s, Some(TOPOLOGY_60_D), None); + assert_eq!(problems.len(), 1); + assert!(problems[0].contains("completion mismatch"), "{problems:?}"); + } + + #[test] + fn fails_on_grade_drift() { + let s = extract_state(STATE_60_D).unwrap(); + let bad_topology = "OVERALL: ~60% Grade A — Production Ready\n"; + let problems = reconcile(&s, Some(bad_topology), None); + assert!(problems.iter().any(|p| p.contains("grade mismatch")), "{problems:?}"); + } + + #[test] + fn prose_mentioning_overall_does_not_shadow_dashboard() { + // A source-of-truth note that mentions "OVERALL" in prose (no %) must + // not be picked instead of the real "OVERALL: ~60%" dashboard line. + let topo = "\ +> agreement is enforced if the OVERALL percentage drifts from STATE. +OVERALL: ██████░░░░ ~60% Grade D +"; + assert_eq!(extract_overall_pct(topo), Some(60)); + let s = extract_state(STATE_60_D).unwrap(); + assert!(reconcile(&s, Some(topo), None).is_empty()); + } + + #[test] + fn fails_on_stale_dashboard() { + let s = extract_state(STATE_60_D).unwrap(); + let stale = "\ + +OVERALL: ~60% Grade D +"; + let problems = reconcile(&s, Some(stale), None); + assert!(problems.iter().any(|p| p.contains("staleness")), "{problems:?}"); + } +} From cc86adfc84ec4f06b36b4b3eb890e88e6e6c9768 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 05:30:57 +0000 Subject: [PATCH 09/21] Phase B: CNO-law proptest, delete legacy src/januskey, honest obliteration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three januskey honesty/quality items. B5 — runnable reversibility evidence. Add a property test (crates/januskey-cli/tests/property_tests.rs) asserting the CNO round-trip law execute∘undo ≡ identity on the filesystem, across all supported operations (Delete/Modify/Move/Copy/Create) over random content: set up a temp working tree + content/metadata stores, snapshot it, execute then undo with a fresh executor, and assert the tree is byte-for-byte restored. This is the honest substitute for the "formal proofs pending" badge. Chown is excluded (undo unimplemented); we test the filesystem effect, not OperationType::inverse (deliberately non-involutive, Copy⁻¹=Delete). B2 — delete the legacy src/januskey/ monolith. It was a non-workspace, pre-extraction duplicate of crates/januskey-cli (ARCHITECTURE.md labelled it LEGACY). Removed it and repointed every reference (README, ARCHITECTURE, EXPLAINME, docs/wiki/*, idrisiser/*.tsig) to crates/januskey-cli; simplified the two contractile checks (Trustfile, Adjustfile) that scanned it — they already preferred crates/januskey-cli and the src/januskey branches were dead. src/abi/*.idr (the Idris ABI, outside src/januskey/) is untouched. B8 — stop overstating obliteration. The module doc-comment claimed content is "cryptographically unrecoverable"; overwrite-in-place cannot guarantee physical erasure on SSD/CoW/journaling media (the caveat already lived in .machine_readable/threat-model.a2ml). Mirror that honest wording into obliteration.rs and soften the README "data loss impossible by construction" to "resistant", with the obliteration exception called out. Verified: cargo build --workspace clean; cargo test -p januskey --lib 26 passed; property_tests 4 passed (incl. the new CNO-law case); no dangling src/januskey references remain; dashboard-check still green. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015qwVESTcbfanY2iJPQNoSz --- .../contractiles/Adjustfile.a2ml | 2 +- .machine_readable/contractiles/Trustfile.a2ml | 4 +- ARCHITECTURE.md | 2 - EXPLAINME.adoc | 26 +- README.md | 14 +- crates/januskey-cli/src/obliteration.rs | 20 +- crates/januskey-cli/tests/property_tests.rs | 108 ++ docs/wiki/Home.adoc | 2 +- docs/wiki/architecture/index.adoc | 10 +- docs/wiki/examples/index.adoc | 2 +- docs/wiki/guides/cli.adoc | 2 +- idrisiser/key-derivation.tsig | 2 +- idrisiser/key-generation.tsig | 2 +- idrisiser/wrap-unwrap.tsig | 2 +- src/januskey/Cargo.lock | 1489 ----------------- src/januskey/Cargo.toml | 58 - src/januskey/src/attestation.rs | 551 ------ src/januskey/src/content_store.rs | 235 --- src/januskey/src/delta.rs | 506 ------ src/januskey/src/error.rs | 65 - src/januskey/src/keys.rs | 711 -------- src/januskey/src/keys_cli.rs | 654 -------- src/januskey/src/lib.rs | 164 -- src/januskey/src/main.rs | 835 --------- src/januskey/src/metadata.rs | 393 ----- src/januskey/src/obliteration.rs | 516 ------ src/januskey/src/operations.rs | 602 ------- src/januskey/src/transaction.rs | 405 ----- 28 files changed, 158 insertions(+), 7224 deletions(-) delete mode 100644 src/januskey/Cargo.lock delete mode 100644 src/januskey/Cargo.toml delete mode 100644 src/januskey/src/attestation.rs delete mode 100644 src/januskey/src/content_store.rs delete mode 100644 src/januskey/src/delta.rs delete mode 100644 src/januskey/src/error.rs delete mode 100644 src/januskey/src/keys.rs delete mode 100644 src/januskey/src/keys_cli.rs delete mode 100644 src/januskey/src/lib.rs delete mode 100644 src/januskey/src/main.rs delete mode 100644 src/januskey/src/metadata.rs delete mode 100644 src/januskey/src/obliteration.rs delete mode 100644 src/januskey/src/operations.rs delete mode 100644 src/januskey/src/transaction.rs diff --git a/.machine_readable/contractiles/Adjustfile.a2ml b/.machine_readable/contractiles/Adjustfile.a2ml index 2766399..d40cca1 100644 --- a/.machine_readable/contractiles/Adjustfile.a2ml +++ b/.machine_readable/contractiles/Adjustfile.a2ml @@ -77,7 +77,7 @@ Standard: WCAG-2.2-AA (CLI/TUI profile). ### panic-pattern-drift - description: No regression of the 2026-04 unwrap()/expect("TODO") sweeps in security-critical modules -- tolerance: 0 new bare unwrap() in crates/*/src and src/januskey/src +- tolerance: 0 new bare unwrap() in crates/*/src - corrective: Run `just assail` (panic-attacker pre-commit scan) - severity: advisory diff --git a/.machine_readable/contractiles/Trustfile.a2ml b/.machine_readable/contractiles/Trustfile.a2ml index e6589e0..63210ec 100644 --- a/.machine_readable/contractiles/Trustfile.a2ml +++ b/.machine_readable/contractiles/Trustfile.a2ml @@ -117,8 +117,6 @@ unjustified, it has no place here. Trust never overrides MUST. # OR the file does not exist. Fails while the home-rolled pattern is present. if [ -f crates/januskey-cli/src/attestation.rs ]; then \ ! grep -qE 'Sha256::new\(\).*update.*key.*update.*data' crates/januskey-cli/src/attestation.rs; \ - elif [ -f src/januskey/src/attestation.rs ]; then \ - ! grep -qE 'Sha256::new\(\).*update.*key.*update.*data' src/januskey/src/attestation.rs; \ else true; fi - severity: critical @@ -129,7 +127,7 @@ unjustified, it has no place here. Trust never overrides MUST. # Passes if ed25519-dalek or x25519-dalek is a real dependency, OR if # Ed25519 / X25519 are not listed in any KeyAlgorithm enum. cargo tree 2>/dev/null | grep -qE '(ed25519-dalek|x25519-dalek)' || \ - ! grep -rqE '(Ed25519|X25519)' crates/januskey-cli/src/ src/januskey/src/ 2>/dev/null + ! grep -rqE '(Ed25519|X25519)' crates/januskey-cli/src/ 2>/dev/null ## Secrets-hygiene (memory rule: SOPS at-rest / Rokur runtime / RGTV LLM-broker) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 1757e65..252496f 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -42,8 +42,6 @@ januskey/ │ ├── delta — Differential operations │ ├── main — jk CLI binary │ └── keys_cli — jk-keys CLI binary -│ -└── src/januskey/ ← LEGACY (pre-extraction, superseded by crates/) ``` ## reversible-core: The Shared Foundation diff --git a/EXPLAINME.adoc b/EXPLAINME.adoc index d6cd6dd..86bb1c2 100644 --- a/EXPLAINME.adoc +++ b/EXPLAINME.adoc @@ -10,19 +10,19 @@ The README makes claims. This file backs them up. === Claim 1: "100% Reversible - Every operation can be undone, always" -**How it works:** JanusKey implements architectural reversibility by storing inverted operations alongside each mutation. The delete operation, implemented in `src/januskey/src/obliteration.rs`, calculates and stores a full restoration record before removing file content. The `delta.rs` module generates inverse metadata that sufficient to reconstruct the pre-operation state. Every operation in the transaction layer (`src/januskey/src/metadata.rs`) maintains a "prior state" record, enabling perfect reversal through the `undo` pathway. +**How it works:** JanusKey implements architectural reversibility by storing inverted operations alongside each mutation. The delete operation, implemented in `crates/januskey-cli/src/obliteration.rs`, calculates and stores a full restoration record before removing file content. The `delta.rs` module generates inverse metadata that sufficient to reconstruct the pre-operation state. Every operation in the transaction layer (`crates/januskey-cli/src/metadata.rs`) maintains a "prior state" record, enabling perfect reversal through the `undo` pathway. **Caveat:** Reversibility is strong only when the operation log remains intact. If `.jk/` metadata store is corrupted or lost, recovery becomes impossible. The system trades **write amplification** (each operation doubles metadata I/O) for guaranteed recoverability. -**Evidence:** `src/januskey/src/lib.rs` (public API) delegates to `obliteration.rs` and `delta.rs` for operation logic; see `Reversible` trait (if present) or reversibility-proof comments in `attestation.rs`. +**Evidence:** `crates/januskey-cli/src/lib.rs` (public API) delegates to `obliteration.rs` and `delta.rs` for operation logic; see `Reversible` trait (if present) or reversibility-proof comments in `attestation.rs`. === Claim 2: "Complete Audit Trail - Every change tracked automatically" -**How it works:** The metadata store (`src/januskey/src/metadata.rs`) maintains an append-only operation log. The `attestation.rs` module calculates cryptographic commitments (SHA256 hashes) of each operation, creating a tamper-evident chain. Content-addressed storage (`src/januskey/src/content_store.rs`) ensures deduplication by content hash, preventing duplicate storage while maintaining full history. +**How it works:** The metadata store (`crates/januskey-cli/src/metadata.rs`) maintains an append-only operation log. The `attestation.rs` module calculates cryptographic commitments (SHA256 hashes) of each operation, creating a tamper-evident chain. Content-addressed storage (`crates/januskey-cli/src/content_store.rs`) ensures deduplication by content hash, preventing duplicate storage while maintaining full history. **Caveat:** The audit trail is only as trustworthy as the append-only mechanism. If the log is truncated or rewritten on-disk, the chain breaks. For forensic-grade auditing, integrate with an external append-only store (database, blockchain). -**Evidence:** `src/januskey/src/metadata.rs` defines the log structure; `attestation.rs` computes cryptographic bindings; `content_store.rs` handles SHA256 hashing. +**Evidence:** `crates/januskey-cli/src/metadata.rs` defines the log structure; `attestation.rs` computes cryptographic bindings; `content_store.rs` handles SHA256 hashing. == Technology Choices @@ -41,15 +41,15 @@ The README makes claims. This file backs them up. |=== | Path | Purpose -| `src/januskey/src/main.rs` | CLI entry point, command dispatch (delete, modify, move, copy, undo, begin, commit, rollback, preview) -| `src/januskey/src/lib.rs` | Public API: init, delete, modify, move, copy, undo, transaction logic -| `src/januskey/src/obliteration.rs` | Delete operation with restoration metadata -| `src/januskey/src/delta.rs` | Computes inverse operations (modify undo, move undo, copy undo) -| `src/januskey/src/metadata.rs` | Append-only operation log, transaction tracking, rollback state -| `src/januskey/src/content_store.rs` | SHA256 content hashing, deduplication, storage management -| `src/januskey/src/attestation.rs` | Cryptographic commitments, audit trail, tamper detection -| `src/januskey/src/keys.rs` | Cryptographic key management (if key-based reversibility) -| `src/januskey/src/error.rs` | Error types and propagation +| `crates/januskey-cli/src/main.rs` | CLI entry point, command dispatch (delete, modify, move, copy, undo, begin, commit, rollback, preview) +| `crates/januskey-cli/src/lib.rs` | Public API: init, delete, modify, move, copy, undo, transaction logic +| `crates/januskey-cli/src/obliteration.rs` | Delete operation with restoration metadata +| `crates/januskey-cli/src/delta.rs` | Computes inverse operations (modify undo, move undo, copy undo) +| `crates/januskey-cli/src/metadata.rs` | Append-only operation log, transaction tracking, rollback state +| `crates/januskey-cli/src/content_store.rs` | SHA256 content hashing, deduplication, storage management +| `crates/januskey-cli/src/attestation.rs` | Cryptographic commitments, audit trail, tamper detection +| `crates/januskey-cli/src/keys.rs` | Cryptographic key management (if key-based reversibility) +| `crates/januskey-cli/src/error.rs` | Error types and propagation |=== == Dogfooted Across The Account diff --git a/README.md b/README.md index c3964f2..2ee7ab6 100644 --- a/README.md +++ b/README.md @@ -38,8 +38,11 @@ pending). - **Instant Rollback** - Undo changes in milliseconds -- **Data loss impossible by construction** - the architectural goal the - pending proofs are to establish +- **Data loss resistant by construction** - reversibility is the + architectural goal the pending proofs are to establish. (Note: *secure + obliteration* is the deliberate exception and is best-effort only — + overwrite-in-place cannot guarantee physical erasure on SSD/CoW/journaling + media; see the threat model.) - **Complete Audit Trail** - Every change tracked automatically @@ -51,8 +54,7 @@ pending). ```bash # Build from source (requires Rust) -cd src/januskey -cargo build --release +cargo build --workspace --release # Initialize JanusKey in your project jk init @@ -125,10 +127,10 @@ Every operation stores sufficient metadata for perfect inversion: ```bash # Clone the repository git clone {url-github} -cd januskey/src/januskey +cd januskey # Build release binary -cargo build --release +cargo build --workspace --release # Install to PATH cargo install --path . diff --git a/crates/januskey-cli/src/obliteration.rs b/crates/januskey-cli/src/obliteration.rs index 5282d0b..045bf49 100644 --- a/crates/januskey-cli/src/obliteration.rs +++ b/crates/januskey-cli/src/obliteration.rs @@ -5,10 +5,22 @@ // RMO: Obliterative Wipe Primitive // Implements GDPR Article 17 "Right to Erasure" with formal obliteration proofs // -// The RMO primitive guarantees: -// 1. Content is cryptographically unrecoverable after obliteration -// 2. A proof of non-existence is generated -// 3. The fact of obliteration is logged (without content) +// The RMO primitive provides: +// 1. A best-effort DoD 5220.22-M-style multi-pass overwrite of the file's +// bytes at its current path (0x00 / 0xFF / random), then removal +// 2. A cryptographic proof-of-obliteration commitment (SHA256 of the prior +// content hash + nonce + timestamp) +// 3. A logged record of the obliteration (without content) +// +// THREAT-MODEL CAVEAT (do not overstate): overwrite-in-place does NOT +// guarantee physical erasure on copy-on-write / journaling filesystems or +// flash media (Btrfs, XFS, ZFS, and any SSD behind an FTL with +// wear-levelling): the controller may write the new passes to different +// physical blocks and leave the originals readable. The "unrecoverable" +// guarantee holds only against logical/path-based recovery on overwrite-in- +// place media; on modern storage it is best-effort. See +// .machine_readable/threat-model.a2ml [primitives.secure-delete]. For a hard +// guarantee, obliterate the key material (encrypt-then-shred), not the bytes. use crate::content_store::{ContentHash, ContentStore}; use crate::error::{JanusError, Result}; diff --git a/crates/januskey-cli/tests/property_tests.rs b/crates/januskey-cli/tests/property_tests.rs index 424605b..4157058 100644 --- a/crates/januskey-cli/tests/property_tests.rs +++ b/crates/januskey-cli/tests/property_tests.rs @@ -6,8 +6,12 @@ use januskey::content_store::ContentStore; use januskey::obliteration::{ObliterationManager, ObliterationProof}; +use januskey::operations::{FileOperation, OperationExecutor}; use proptest::prelude::*; use reversible_core::content_store::ContentHash; +use reversible_core::metadata::MetadataStore; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; use tempfile::TempDir; // --- Obliteration properties --- @@ -54,3 +58,107 @@ proptest! { prop_assert_eq!(h1, h2); } } + +// --- CNO round-trip law: execute ∘ undo ≡ identity on the filesystem --- +// +// This is the load-bearing, runnable evidence for JanusKey's reversibility +// claim (the honest substitute for the "formal proofs pending" badge): for +// every supported operation, executing it and then undoing it must return the +// working tree to exactly its pre-execute state (bytes + existence). +// +// Chown is excluded: its undo is unimplemented (operations.rs). We test the +// filesystem effect, not OperationType::inverse() (which is deliberately NOT an +// involution — Copy⁻¹ = Delete — so double-inverse is not the law here). + +/// What operation to exercise, plus the content it needs. +#[derive(Debug, Clone)] +enum OpSpec { + Delete(Vec), + Modify(Vec, Vec), + Move(Vec), + Copy(Vec), + Create(Vec), +} + +fn op_strategy() -> impl Strategy { + let bytes = || proptest::collection::vec(any::(), 0..256); + prop_oneof![ + bytes().prop_map(OpSpec::Delete), + (bytes(), bytes()).prop_map(|(a, b)| OpSpec::Modify(a, b)), + bytes().prop_map(OpSpec::Move), + bytes().prop_map(OpSpec::Copy), + bytes().prop_map(OpSpec::Create), + ] +} + +/// Recursive {relative-path -> bytes} snapshot of a directory (no external deps). +fn snapshot(dir: &Path, base: &Path, out: &mut BTreeMap>) { + for entry in std::fs::read_dir(dir).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + snapshot(&path, base, out); + } else { + let rel = path.strip_prefix(base).unwrap().to_path_buf(); + out.insert(rel, std::fs::read(&path).unwrap()); + } + } +} + +fn snapshot_of(work: &Path) -> BTreeMap> { + let mut out = BTreeMap::new(); + snapshot(work, work, &mut out); + out +} + +proptest! { + /// execute(op) then undo restores the working tree exactly (the CNO law). + #[test] + fn execute_then_undo_is_identity(spec in op_strategy()) { + let tmp = TempDir::new().unwrap(); + // Keep user files (snapshotted) separate from the store internals. + let work = tmp.path().join("work"); + std::fs::create_dir_all(&work).unwrap(); + let content_store = ContentStore::new(tmp.path().join("content"), false).unwrap(); + let mut metadata_store = + MetadataStore::new(tmp.path().join("metadata.json")).unwrap(); + + let a = work.join("a.bin"); + let b = work.join("b.bin"); + + // Establish the pre-state and choose the operation. + let op = match &spec { + OpSpec::Delete(c) => { + std::fs::write(&a, c).unwrap(); + FileOperation::Delete { path: a.clone() } + } + OpSpec::Modify(orig, new) => { + std::fs::write(&a, orig).unwrap(); + FileOperation::Modify { path: a.clone(), new_content: new.clone() } + } + OpSpec::Move(c) => { + std::fs::write(&a, c).unwrap(); + FileOperation::Move { source: a.clone(), destination: b.clone() } + } + OpSpec::Copy(c) => { + std::fs::write(&a, c).unwrap(); + FileOperation::Copy { source: a.clone(), destination: b.clone() } + } + OpSpec::Create(c) => { + FileOperation::Create { path: a.clone(), content: c.clone() } + } + }; + + let before = snapshot_of(&work); + + // execute — mutates the tree and records inversion metadata. + let mut exec = OperationExecutor::new(&content_store, &mut metadata_store); + let meta = exec.execute(op).unwrap(); + + // undo — a fresh executor, exactly as the CLI unlocks a later session. + let mut exec2 = OperationExecutor::new(&content_store, &mut metadata_store); + exec2.undo(&meta.id).unwrap(); + + let after = snapshot_of(&work); + prop_assert_eq!(before, after, "execute∘undo must restore the tree (CNO law)"); + } +} diff --git a/docs/wiki/Home.adoc b/docs/wiki/Home.adoc index 5cba1b4..7a2febb 100644 --- a/docs/wiki/Home.adoc +++ b/docs/wiki/Home.adoc @@ -100,7 +100,7 @@ The "Key" represents the cryptographic foundation (SHA256 content addressing) th [source,bash] ---- # Build from source -cd src/januskey +cd crates/januskey-cli cargo build --release # Initialize in a directory diff --git a/docs/wiki/architecture/index.adoc b/docs/wiki/architecture/index.adoc index affdb06..7981476 100644 --- a/docs/wiki/architecture/index.adoc +++ b/docs/wiki/architecture/index.adoc @@ -44,7 +44,7 @@ JanusKey follows a layered architecture that separates concerns and enables the === CLI Layer -Location: `src/januskey/src/main.rs` +Location: `crates/januskey-cli/src/main.rs` The CLI provides the user interface for JanusKey operations: @@ -80,7 +80,7 @@ See: link:../guides/cli.adoc[CLI Guide] === Operation Layer -Location: `src/januskey/src/operations.rs` +Location: `crates/januskey-cli/src/operations.rs` Defines all reversible file operations and their execution logic. @@ -127,7 +127,7 @@ See: link:../operations/index.adoc[Operations Reference] === Transaction Manager -Location: `src/januskey/src/transaction.rs` +Location: `crates/januskey-cli/src/transaction.rs` Provides transaction support for grouping operations. @@ -154,7 +154,7 @@ See: link:../guides/transactions.adoc[Transaction Guide] === Content Store -Location: `src/januskey/src/content_store.rs` +Location: `crates/januskey-cli/src/content_store.rs` Content-addressed storage with SHA256 hashing and optional compression. @@ -188,7 +188,7 @@ See: link:content-store.adoc[Content Store Details] === Metadata Store -Location: `src/januskey/src/metadata.rs` +Location: `crates/januskey-cli/src/metadata.rs` Append-only operation log in JSON format. diff --git a/docs/wiki/examples/index.adoc b/docs/wiki/examples/index.adoc index 19d3e5e..b7d3fe2 100644 --- a/docs/wiki/examples/index.adoc +++ b/docs/wiki/examples/index.adoc @@ -219,7 +219,7 @@ jobs: - name: Install JanusKey run: | - cd src/januskey + cd crates/januskey-cli cargo install --path . - name: Initialize JanusKey diff --git a/docs/wiki/guides/cli.adoc b/docs/wiki/guides/cli.adoc index 742e45c..9a92e21 100644 --- a/docs/wiki/guides/cli.adoc +++ b/docs/wiki/guides/cli.adoc @@ -17,7 +17,7 @@ The `jk` command-line interface provides access to all JanusKey operations. This [source,bash] ---- # Build from source -cd src/januskey +cd crates/januskey-cli cargo build --release # Install to PATH diff --git a/idrisiser/key-derivation.tsig b/idrisiser/key-derivation.tsig index ab48043..fbf8241 100644 --- a/idrisiser/key-derivation.tsig +++ b/idrisiser/key-derivation.tsig @@ -1,5 +1,5 @@ -- SPDX-License-Identifier: MPL-2.0 --- Type signatures for januskey KEK derivation (src/januskey/src/keys.rs) +-- Type signatures for januskey KEK derivation (crates/januskey-cli/src/keys.rs) -- Format: name : Type (idrisiser type-sig format) derive_kek : String -> Bytes 16 -> Result SecretKey KeyError diff --git a/idrisiser/key-generation.tsig b/idrisiser/key-generation.tsig index 4e8ab1a..c106002 100644 --- a/idrisiser/key-generation.tsig +++ b/idrisiser/key-generation.tsig @@ -1,5 +1,5 @@ -- SPDX-License-Identifier: MPL-2.0 --- Type signatures for januskey key generation (src/januskey/src/keys.rs) +-- Type signatures for januskey key generation (crates/januskey-cli/src/keys.rs) -- Format: name : Type (idrisiser type-sig format) SecretKey.generate : () -> Result SecretKey KeyError diff --git a/idrisiser/wrap-unwrap.tsig b/idrisiser/wrap-unwrap.tsig index 4e7460f..10ededc 100644 --- a/idrisiser/wrap-unwrap.tsig +++ b/idrisiser/wrap-unwrap.tsig @@ -1,5 +1,5 @@ -- SPDX-License-Identifier: MPL-2.0 --- Type signatures for januskey AES-256-GCM wrap/unwrap (src/januskey/src/keys.rs) +-- Type signatures for januskey AES-256-GCM wrap/unwrap (crates/januskey-cli/src/keys.rs) -- Format: name : Type (idrisiser type-sig format) wrap_key : SecretKey -> Bytes 32 -> KeyMetadata -> Result WrappedKey KeyError diff --git a/src/januskey/Cargo.lock b/src/januskey/Cargo.lock deleted file mode 100644 index 4df5d32..0000000 --- a/src/januskey/Cargo.lock +++ /dev/null @@ -1,1489 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "adler2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" - -[[package]] -name = "aead" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" -dependencies = [ - "crypto-common", - "generic-array", -] - -[[package]] -name = "aes" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" -dependencies = [ - "cfg-if", - "cipher", - "cpufeatures", -] - -[[package]] -name = "aes-gcm" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" -dependencies = [ - "aead", - "aes", - "cipher", - "ctr", - "ghash", - "subtle", -] - -[[package]] -name = "aho-corasick" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" -dependencies = [ - "memchr", -] - -[[package]] -name = "android_system_properties" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] - -[[package]] -name = "anstream" -version = "0.6.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" -dependencies = [ - "anstyle", - "anstyle-parse", - "anstyle-query", - "anstyle-wincon", - "colorchoice", - "is_terminal_polyfill", - "utf8parse", -] - -[[package]] -name = "anstyle" -version = "1.0.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" - -[[package]] -name = "anstyle-parse" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" -dependencies = [ - "utf8parse", -] - -[[package]] -name = "anstyle-query" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "anstyle-wincon" -version = "3.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" -dependencies = [ - "anstyle", - "once_cell_polyfill", - "windows-sys 0.61.2", -] - -[[package]] -name = "anyhow" -version = "1.0.100" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" - -[[package]] -name = "argon2" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" -dependencies = [ - "base64ct", - "blake2", - "cpufeatures", - "password-hash", -] - -[[package]] -name = "assert_cmd" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcbb6924530aa9e0432442af08bbcafdad182db80d2e560da42a6d442535bf85" -dependencies = [ - "anstyle", - "bstr", - "libc", - "predicates", - "predicates-core", - "predicates-tree", - "wait-timeout", -] - -[[package]] -name = "autocfg" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" - -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - -[[package]] -name = "base64ct" -version = "1.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e050f626429857a27ddccb31e0aca21356bfa709c04041aefddac081a8f068a" - -[[package]] -name = "bitflags" -version = "2.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" - -[[package]] -name = "blake2" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" -dependencies = [ - "digest", -] - -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - -[[package]] -name = "bstr" -version = "1.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" -dependencies = [ - "memchr", - "regex-automata", - "serde", -] - -[[package]] -name = "bumpalo" -version = "3.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" - -[[package]] -name = "cc" -version = "1.2.49" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90583009037521a116abf44494efecd645ba48b6622457080f080b85544e2215" -dependencies = [ - "find-msvc-tools", - "shlex", -] - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "chrono" -version = "0.4.42" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" -dependencies = [ - "iana-time-zone", - "js-sys", - "num-traits", - "serde", - "wasm-bindgen", - "windows-link", -] - -[[package]] -name = "cipher" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" -dependencies = [ - "crypto-common", - "inout", -] - -[[package]] -name = "clap" -version = "4.5.53" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9e340e012a1bf4935f5282ed1436d1489548e8f72308207ea5df0e23d2d03f8" -dependencies = [ - "clap_builder", - "clap_derive", -] - -[[package]] -name = "clap_builder" -version = "4.5.53" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d76b5d13eaa18c901fd2f7fca939fefe3a0727a953561fefdf3b2922b8569d00" -dependencies = [ - "anstream", - "anstyle", - "clap_lex", - "strsim", -] - -[[package]] -name = "clap_derive" -version = "4.5.49" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "clap_lex" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" - -[[package]] -name = "colorchoice" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" - -[[package]] -name = "colored" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" -dependencies = [ - "lazy_static", - "windows-sys 0.59.0", -] - -[[package]] -name = "console" -version = "0.15.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" -dependencies = [ - "encode_unicode", - "libc", - "once_cell", - "unicode-width", - "windows-sys 0.59.0", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - -[[package]] -name = "crc32fast" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "rand_core 0.6.4", - "typenum", -] - -[[package]] -name = "ctr" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" -dependencies = [ - "cipher", -] - -[[package]] -name = "dialoguer" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "658bce805d770f407bc62102fca7c2c64ceef2fbcb2b8bd19d2765ce093980de" -dependencies = [ - "console", - "shell-words", - "tempfile", - "thiserror", - "zeroize", -] - -[[package]] -name = "difflib" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer", - "crypto-common", - "subtle", -] - -[[package]] -name = "dirs" -version = "5.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" -dependencies = [ - "dirs-sys", -] - -[[package]] -name = "dirs-sys" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" -dependencies = [ - "libc", - "option-ext", - "redox_users", - "windows-sys 0.48.0", -] - -[[package]] -name = "encode_unicode" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" - -[[package]] -name = "errno" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "fastrand" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" - -[[package]] -name = "find-msvc-tools" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" - -[[package]] -name = "flate2" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" -dependencies = [ - "crc32fast", - "miniz_oxide", -] - -[[package]] -name = "float-cmp" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" -dependencies = [ - "num-traits", -] - -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - -[[package]] -name = "getrandom" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" -dependencies = [ - "cfg-if", - "libc", - "wasi", -] - -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "libc", - "r-efi", - "wasip2", -] - -[[package]] -name = "ghash" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" -dependencies = [ - "opaque-debug", - "polyval", -] - -[[package]] -name = "glob" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[package]] -name = "hex" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" - -[[package]] -name = "iana-time-zone" -version = "0.1.64" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - -[[package]] -name = "indicatif" -version = "0.17.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235" -dependencies = [ - "console", - "number_prefix", - "portable-atomic", - "unicode-width", - "web-time", -] - -[[package]] -name = "inout" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" -dependencies = [ - "generic-array", -] - -[[package]] -name = "is_terminal_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" - -[[package]] -name = "itoa" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" - -[[package]] -name = "januskey" -version = "1.0.0" -dependencies = [ - "aes-gcm", - "anyhow", - "argon2", - "assert_cmd", - "base64", - "chrono", - "clap", - "colored", - "dialoguer", - "dirs", - "flate2", - "glob", - "hex", - "indicatif", - "predicates", - "rand", - "serde", - "serde_json", - "sha2", - "subtle", - "tempfile", - "thiserror", - "uuid", - "walkdir", - "whoami", - "zeroize", -] - -[[package]] -name = "js-sys" -version = "0.3.83" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" -dependencies = [ - "once_cell", - "wasm-bindgen", -] - -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" - -[[package]] -name = "libc" -version = "0.2.178" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" - -[[package]] -name = "libredox" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" -dependencies = [ - "bitflags", - "libc", - "redox_syscall", -] - -[[package]] -name = "linux-raw-sys" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" - -[[package]] -name = "log" -version = "0.4.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" - -[[package]] -name = "memchr" -version = "2.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" - -[[package]] -name = "miniz_oxide" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" -dependencies = [ - "adler2", - "simd-adler32", -] - -[[package]] -name = "normalize-line-endings" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", -] - -[[package]] -name = "number_prefix" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" - -[[package]] -name = "once_cell" -version = "1.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" - -[[package]] -name = "once_cell_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" - -[[package]] -name = "opaque-debug" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" - -[[package]] -name = "option-ext" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" - -[[package]] -name = "password-hash" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" -dependencies = [ - "base64ct", - "rand_core 0.6.4", - "subtle", -] - -[[package]] -name = "polyval" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" -dependencies = [ - "cfg-if", - "cpufeatures", - "opaque-debug", - "universal-hash", -] - -[[package]] -name = "portable-atomic" -version = "1.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" - -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - -[[package]] -name = "predicates" -version = "3.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5d19ee57562043d37e82899fade9a22ebab7be9cef5026b07fda9cdd4293573" -dependencies = [ - "anstyle", - "difflib", - "float-cmp", - "normalize-line-endings", - "predicates-core", - "regex", -] - -[[package]] -name = "predicates-core" -version = "1.0.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "727e462b119fe9c93fd0eb1429a5f7647394014cf3c04ab2c0350eeb09095ffa" - -[[package]] -name = "predicates-tree" -version = "1.0.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72dd2d6d381dfb73a193c7fca536518d7caee39fc8503f74e7dc0be0531b425c" -dependencies = [ - "predicates-core", - "termtree", -] - -[[package]] -name = "proc-macro2" -version = "1.0.103" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quote" -version = "1.0.42" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - -[[package]] -name = "rand" -version = "0.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ec095654a25171c2124e9e3393a930bddbffdc939556c914957a4c3e0a87166" -dependencies = [ - "rand_chacha", - "rand_core 0.9.5", -] - -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.5", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.16", -] - -[[package]] -name = "rand_core" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] - -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags", -] - -[[package]] -name = "redox_users" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" -dependencies = [ - "getrandom 0.2.16", - "libredox", - "thiserror", -] - -[[package]] -name = "regex" -version = "1.12.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "regex-automata" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.8.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" - -[[package]] -name = "rustix" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" -dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustversion" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" - -[[package]] -name = "ryu" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" - -[[package]] -name = "same-file" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" -dependencies = [ - "winapi-util", -] - -[[package]] -name = "serde" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "serde_json" -version = "1.0.145" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" -dependencies = [ - "itoa", - "memchr", - "ryu", - "serde", - "serde_core", -] - -[[package]] -name = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - -[[package]] -name = "shell-words" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" - -[[package]] -name = "shlex" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" - -[[package]] -name = "simd-adler32" -version = "0.3.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" - -[[package]] -name = "strsim" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" - -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - -[[package]] -name = "syn" -version = "2.0.111" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "tempfile" -version = "3.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" -dependencies = [ - "fastrand", - "getrandom 0.3.4", - "once_cell", - "rustix", - "windows-sys 0.61.2", -] - -[[package]] -name = "termtree" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" - -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "typenum" -version = "1.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" - -[[package]] -name = "unicode-ident" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" - -[[package]] -name = "unicode-width" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" - -[[package]] -name = "universal-hash" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" -dependencies = [ - "crypto-common", - "subtle", -] - -[[package]] -name = "utf8parse" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" - -[[package]] -name = "uuid" -version = "1.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a" -dependencies = [ - "getrandom 0.3.4", - "js-sys", - "serde_core", - "wasm-bindgen", -] - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] -name = "wait-timeout" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" -dependencies = [ - "libc", -] - -[[package]] -name = "walkdir" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" -dependencies = [ - "same-file", - "winapi-util", -] - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "wasip2" -version = "1.0.1+wasi-0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "wasite" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" - -[[package]] -name = "wasm-bindgen" -version = "0.2.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "web-sys" -version = "0.3.83" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "web-time" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "whoami" -version = "1.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" -dependencies = [ - "libredox", - "wasite", - "web-sys", -] - -[[package]] -name = "winapi-util" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-sys" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" -dependencies = [ - "windows-targets 0.48.5", -] - -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-targets" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" -dependencies = [ - "windows_aarch64_gnullvm 0.48.5", - "windows_aarch64_msvc 0.48.5", - "windows_i686_gnu 0.48.5", - "windows_i686_msvc 0.48.5", - "windows_x86_64_gnu 0.48.5", - "windows_x86_64_gnullvm 0.48.5", - "windows_x86_64_msvc 0.48.5", -] - -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_i686_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[package]] -name = "windows_i686_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - -[[package]] -name = "wit-bindgen" -version = "0.46.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" - -[[package]] -name = "zerocopy" -version = "0.8.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd74ec98b9250adb3ca554bdde269adf631549f51d8a8f8f0a10b50f1cb298c3" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8a8d209fdf45cf5138cbb5a506f6b52522a25afccc534d1475dad8e31105c6a" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "zeroize" -version = "1.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" -dependencies = [ - "zeroize_derive", -] - -[[package]] -name = "zeroize_derive" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] diff --git a/src/januskey/Cargo.toml b/src/januskey/Cargo.toml deleted file mode 100644 index f5a38f9..0000000 --- a/src/januskey/Cargo.toml +++ /dev/null @@ -1,58 +0,0 @@ -# SPDX-License-Identifier: MPL-2.0 -# SPDX-FileCopyrightText: 2025 Jonathan D.A. Jewell - -[package] -name = "januskey" -version = "1.0.0" -edition = "2021" -authors = ["Jonathan D.A. Jewell "] -description = "Provably reversible file operations through Maximal Principle Reduction" -license = "MIT OR MPL-2.0" -repository = "https://github.com/hyperpolymath/januskey" -keywords = ["reversible", "filesystem", "transactions", "undo", "backup"] -categories = ["command-line-utilities", "filesystem"] - -[dependencies] -clap = { version = "4", features = ["derive"] } -serde = { version = "1", features = ["derive"] } -serde_json = "1" -sha2 = "0.10" -chrono = { version = "0.4", features = ["serde"] } -uuid = { version = "1", features = ["v4", "serde"] } -thiserror = "1" -anyhow = "1" -hex = "0.4" -flate2 = "1" -dirs = "5" -walkdir = "2" -colored = "2" -dialoguer = "0.11" -indicatif = "0.17" -glob = "0.3" -whoami = "1" - -# Key management dependencies -aes-gcm = "0.10" -argon2 = "0.5" -rand = "0.9" -zeroize = { version = "1", features = ["derive"] } -subtle = "2" -base64 = "0.22" - -[dev-dependencies] -tempfile = "3" -assert_cmd = "2" -predicates = "3" - -[[bin]] -name = "jk" -path = "src/main.rs" - -[[bin]] -name = "jk-keys" -path = "src/keys_cli.rs" - -[profile.release] -lto = true -codegen-units = 1 -opt-level = 3 diff --git a/src/januskey/src/attestation.rs b/src/januskey/src/attestation.rs deleted file mode 100644 index 7cbda3b..0000000 --- a/src/januskey/src/attestation.rs +++ /dev/null @@ -1,551 +0,0 @@ -// SPDX-License-Identifier: MPL-2.0 -// Copyright (c) Jonathan D.A. Jewell -// SPDX-FileCopyrightText: 2025 Jonathan D.A. Jewell -// -// JanusKey Attestation & Audit Log Module -// Tamper-evident logging with cryptographic attestations - -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; -use std::fs::{self, File, OpenOptions}; -use std::io::{BufRead, BufReader, Write}; -use std::path::{Path, PathBuf}; -use uuid::Uuid; - -use crate::keys::{KeyAlgorithm, KeyPurpose, KeyState}; - -/// Audit event types -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum AuditEventType { - /// Key store initialized - StoreInitialized, - /// Key store unlocked - StoreUnlocked, - /// New key generated - KeyGenerated, - /// Key retrieved (decrypted) - KeyRetrieved, - /// Key rotated - KeyRotated, - /// Key revoked - KeyRevoked, - /// Key obliterated - KeyObliterated, - /// Backup created - BackupCreated, - /// Store restored from backup - BackupRestored, -} - -impl std::fmt::Display for AuditEventType { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - AuditEventType::StoreInitialized => write!(f, "STORE_INITIALIZED"), - AuditEventType::StoreUnlocked => write!(f, "STORE_UNLOCKED"), - AuditEventType::KeyGenerated => write!(f, "KEY_GENERATED"), - AuditEventType::KeyRetrieved => write!(f, "KEY_RETRIEVED"), - AuditEventType::KeyRotated => write!(f, "KEY_ROTATED"), - AuditEventType::KeyRevoked => write!(f, "KEY_REVOKED"), - AuditEventType::KeyObliterated => write!(f, "KEY_OBLITERATED"), - AuditEventType::BackupCreated => write!(f, "BACKUP_CREATED"), - AuditEventType::BackupRestored => write!(f, "BACKUP_RESTORED"), - } - } -} - -/// Key-specific event details -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct KeyEventDetails { - pub key_id: Uuid, - pub fingerprint: String, - pub algorithm: Option, - pub purpose: Option, - pub old_state: Option, - pub new_state: Option, - /// For rotation events, the ID of the new key - pub rotated_to: Option, - /// For rotation events, the ID of the old key - pub rotated_from: Option, -} - -/// Audit log entry -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AuditEntry { - /// Unique event ID - pub event_id: Uuid, - /// Event timestamp (UTC) - pub timestamp: DateTime, - /// Type of event - pub event_type: AuditEventType, - /// Actor (user/system identifier) - pub actor: String, - /// Key-specific details (if applicable) - pub key_details: Option, - /// Additional context/reason - pub reason: Option, - /// SHA-256 hash of previous entry (chain link) - pub previous_hash: String, - /// Attestation: HMAC-SHA256(event_data || previous_hash) - pub attestation: String, -} - -impl AuditEntry { - /// Compute the hash of this entry for chain linking - pub fn compute_hash(&self) -> String { - let mut hasher = Sha256::new(); - hasher.update(self.event_id.as_bytes()); - hasher.update(self.timestamp.to_rfc3339().as_bytes()); - hasher.update(self.event_type.to_string().as_bytes()); - hasher.update(self.actor.as_bytes()); - if let Some(ref details) = self.key_details { - hasher.update(details.key_id.as_bytes()); - hasher.update(details.fingerprint.as_bytes()); - } - if let Some(ref reason) = self.reason { - hasher.update(reason.as_bytes()); - } - hasher.update(self.previous_hash.as_bytes()); - hex::encode(hasher.finalize()) - } -} - -/// Audit log manager -pub struct AuditLog { - log_path: PathBuf, - /// Secret for HMAC attestations (derived from store) - attestation_key: Option<[u8; 32]>, -} - -impl AuditLog { - /// Create audit log manager for a directory - pub fn new(root: &Path) -> Self { - let log_path = root.join(".januskey").join("keys").join("audit.log"); - Self { - log_path, - attestation_key: None, - } - } - - /// Initialize audit log with attestation key - pub fn init(&mut self, attestation_key: [u8; 32]) -> std::io::Result<()> { - self.attestation_key = Some(attestation_key); - - // Create log file if it doesn't exist - if !self.log_path.exists() { - if let Some(parent) = self.log_path.parent() { - fs::create_dir_all(parent)?; - } - File::create(&self.log_path)?; - - // Set restrictive permissions - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - fs::set_permissions(&self.log_path, fs::Permissions::from_mode(0o600))?; - } - } - - Ok(()) - } - - /// Set the attestation key (for unlocking existing stores) - pub fn set_attestation_key(&mut self, key: [u8; 32]) { - self.attestation_key = Some(key); - } - - /// Get the last entry's hash (for chain linking) - fn get_last_hash(&self) -> std::io::Result { - if !self.log_path.exists() { - return Ok("0".repeat(64)); // Genesis hash - } - - let file = File::open(&self.log_path)?; - let reader = BufReader::new(file); - - let mut last_entry: Option = None; - for line in reader.lines() { - let line = line?; - if line.trim().is_empty() { - continue; - } - if let Ok(entry) = serde_json::from_str::(&line) { - last_entry = Some(entry); - } - } - - Ok(last_entry - .map(|e| e.compute_hash()) - .unwrap_or_else(|| "0".repeat(64))) - } - - /// Compute HMAC-SHA256 attestation - fn compute_attestation(&self, data: &str, previous_hash: &str) -> String { - let key = self.attestation_key.unwrap_or([0u8; 32]); - - // Simple HMAC-SHA256: H(key || data || previous_hash) - let mut hasher = Sha256::new(); - hasher.update(&key); - hasher.update(data.as_bytes()); - hasher.update(previous_hash.as_bytes()); - hex::encode(hasher.finalize()) - } - - /// Log an event - pub fn log_event( - &self, - event_type: AuditEventType, - key_details: Option, - reason: Option, - ) -> std::io::Result { - let previous_hash = self.get_last_hash()?; - let actor = get_actor(); - let event_id = Uuid::new_v4(); - let timestamp = Utc::now(); - - // Build attestation data - let attestation_data = format!( - "{}:{}:{}:{}", - event_id, - timestamp.to_rfc3339(), - event_type, - actor - ); - let attestation = self.compute_attestation(&attestation_data, &previous_hash); - - let entry = AuditEntry { - event_id, - timestamp, - event_type, - actor, - key_details, - reason, - previous_hash, - attestation, - }; - - // Append to log file - let mut file = OpenOptions::new() - .create(true) - .append(true) - .open(&self.log_path)?; - - let json = serde_json::to_string(&entry) - .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; - writeln!(file, "{}", json)?; - - Ok(entry) - } - - /// Log store initialization - pub fn log_store_init(&self) -> std::io::Result { - self.log_event(AuditEventType::StoreInitialized, None, None) - } - - /// Log store unlock - pub fn log_store_unlock(&self) -> std::io::Result { - self.log_event(AuditEventType::StoreUnlocked, None, None) - } - - /// Log key generation - pub fn log_key_generated( - &self, - key_id: Uuid, - fingerprint: &str, - algorithm: KeyAlgorithm, - purpose: KeyPurpose, - ) -> std::io::Result { - let details = KeyEventDetails { - key_id, - fingerprint: fingerprint.to_string(), - algorithm: Some(algorithm), - purpose: Some(purpose), - old_state: None, - new_state: Some(KeyState::Active), - rotated_to: None, - rotated_from: None, - }; - self.log_event(AuditEventType::KeyGenerated, Some(details), None) - } - - /// Log key retrieval - pub fn log_key_retrieved(&self, key_id: Uuid, fingerprint: &str) -> std::io::Result { - let details = KeyEventDetails { - key_id, - fingerprint: fingerprint.to_string(), - algorithm: None, - purpose: None, - old_state: None, - new_state: None, - rotated_to: None, - rotated_from: None, - }; - self.log_event(AuditEventType::KeyRetrieved, Some(details), None) - } - - /// Log key rotation - pub fn log_key_rotated( - &self, - old_key_id: Uuid, - old_fingerprint: &str, - new_key_id: Uuid, - new_fingerprint: &str, - ) -> std::io::Result { - let details = KeyEventDetails { - key_id: new_key_id, - fingerprint: new_fingerprint.to_string(), - algorithm: None, - purpose: None, - old_state: Some(KeyState::Active), - new_state: Some(KeyState::Active), - rotated_to: None, - rotated_from: Some(old_key_id), - }; - let reason = format!( - "Rotated from key {} ({})", - old_key_id, - old_fingerprint - ); - self.log_event(AuditEventType::KeyRotated, Some(details), Some(reason)) - } - - /// Log key revocation - pub fn log_key_revoked( - &self, - key_id: Uuid, - fingerprint: &str, - reason: Option<&str>, - ) -> std::io::Result { - let details = KeyEventDetails { - key_id, - fingerprint: fingerprint.to_string(), - algorithm: None, - purpose: None, - old_state: Some(KeyState::Active), - new_state: Some(KeyState::Revoked), - rotated_to: None, - rotated_from: None, - }; - self.log_event( - AuditEventType::KeyRevoked, - Some(details), - reason.map(|s| s.to_string()), - ) - } - - /// Log backup creation - pub fn log_backup_created(&self, path: &Path) -> std::io::Result { - let reason = format!("Backup created at: {}", path.display()); - self.log_event(AuditEventType::BackupCreated, None, Some(reason)) - } - - /// Read all audit entries - pub fn read_all(&self) -> std::io::Result> { - if !self.log_path.exists() { - return Ok(Vec::new()); - } - - let file = File::open(&self.log_path)?; - let reader = BufReader::new(file); - let mut entries = Vec::new(); - - for line in reader.lines() { - let line = line?; - if line.trim().is_empty() { - continue; - } - if let Ok(entry) = serde_json::from_str::(&line) { - entries.push(entry); - } - } - - Ok(entries) - } - - /// Read last N entries - pub fn read_last_n(&self, n: usize) -> std::io::Result> { - let all = self.read_all()?; - let start = all.len().saturating_sub(n); - Ok(all[start..].to_vec()) - } - - /// Verify chain integrity - pub fn verify_integrity(&self) -> std::io::Result { - let entries = self.read_all()?; - - if entries.is_empty() { - return Ok(IntegrityReport { - valid: true, - total_entries: 0, - first_invalid_index: None, - message: "Audit log is empty".to_string(), - }); - } - - let genesis_hash = "0".repeat(64); - let mut expected_previous = genesis_hash; - - for (i, entry) in entries.iter().enumerate() { - // Verify chain link - if entry.previous_hash != expected_previous { - return Ok(IntegrityReport { - valid: false, - total_entries: entries.len(), - first_invalid_index: Some(i), - message: format!( - "Chain broken at entry {}: expected previous_hash {}, got {}", - i, expected_previous, entry.previous_hash - ), - }); - } - - // Verify attestation - let attestation_data = format!( - "{}:{}:{}:{}", - entry.event_id, - entry.timestamp.to_rfc3339(), - entry.event_type, - entry.actor - ); - let expected_attestation = - self.compute_attestation(&attestation_data, &entry.previous_hash); - - if entry.attestation != expected_attestation { - return Ok(IntegrityReport { - valid: false, - total_entries: entries.len(), - first_invalid_index: Some(i), - message: format!("Invalid attestation at entry {}", i), - }); - } - - expected_previous = entry.compute_hash(); - } - - Ok(IntegrityReport { - valid: true, - total_entries: entries.len(), - first_invalid_index: None, - message: format!( - "Audit log integrity verified: {} entries", - entries.len() - ), - }) - } - - /// Get entries for a specific key - pub fn get_key_history(&self, key_id: Uuid) -> std::io::Result> { - let all = self.read_all()?; - Ok(all - .into_iter() - .filter(|e| { - e.key_details - .as_ref() - .map(|d| d.key_id == key_id || d.rotated_from == Some(key_id)) - .unwrap_or(false) - }) - .collect()) - } - - /// Export audit log to JSON - pub fn export_json(&self, output: &Path) -> std::io::Result<()> { - let entries = self.read_all()?; - let json = serde_json::to_string_pretty(&entries) - .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; - fs::write(output, json) - } -} - -/// Integrity verification report -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct IntegrityReport { - pub valid: bool, - pub total_entries: usize, - pub first_invalid_index: Option, - pub message: String, -} - -/// Get current actor (user@hostname) -fn get_actor() -> String { - let user = whoami::username(); - let host = whoami::fallible::hostname().unwrap_or_else(|_| "unknown".to_string()); - format!("{}@{}", user, host) -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::TempDir; - - #[test] - fn test_audit_log_init() { - let tmp = TempDir::new().unwrap(); - let mut log = AuditLog::new(tmp.path()); - log.init([0u8; 32]).unwrap(); - - assert!(tmp.path().join(".januskey/keys/audit.log").exists()); - } - - #[test] - fn test_audit_log_events() { - let tmp = TempDir::new().unwrap(); - let mut log = AuditLog::new(tmp.path()); - log.init([1u8; 32]).unwrap(); - - // Log some events - log.log_store_init().unwrap(); - log.log_key_generated( - Uuid::new_v4(), - "abc123", - KeyAlgorithm::Aes256Gcm, - KeyPurpose::Encryption, - ) - .unwrap(); - - let entries = log.read_all().unwrap(); - assert_eq!(entries.len(), 2); - assert_eq!(entries[0].event_type, AuditEventType::StoreInitialized); - assert_eq!(entries[1].event_type, AuditEventType::KeyGenerated); - } - - #[test] - fn test_audit_log_chain_integrity() { - let tmp = TempDir::new().unwrap(); - let mut log = AuditLog::new(tmp.path()); - log.init([2u8; 32]).unwrap(); - - log.log_store_init().unwrap(); - log.log_store_unlock().unwrap(); - log.log_key_generated( - Uuid::new_v4(), - "def456", - KeyAlgorithm::Ed25519, - KeyPurpose::Signing, - ) - .unwrap(); - - let report = log.verify_integrity().unwrap(); - assert!(report.valid); - assert_eq!(report.total_entries, 3); - } - - #[test] - fn test_key_history() { - let tmp = TempDir::new().unwrap(); - let mut log = AuditLog::new(tmp.path()); - log.init([3u8; 32]).unwrap(); - - let key_id = Uuid::new_v4(); - let new_key_id = Uuid::new_v4(); - - log.log_key_generated(key_id, "abc", KeyAlgorithm::Aes256Gcm, KeyPurpose::Encryption) - .unwrap(); - log.log_key_rotated(key_id, "abc", new_key_id, "def") - .unwrap(); - log.log_key_revoked(key_id, "abc", Some("rotated")) - .unwrap(); - - let history = log.get_key_history(key_id).unwrap(); - assert_eq!(history.len(), 3); - } -} diff --git a/src/januskey/src/content_store.rs b/src/januskey/src/content_store.rs deleted file mode 100644 index 9214023..0000000 --- a/src/januskey/src/content_store.rs +++ /dev/null @@ -1,235 +0,0 @@ -// SPDX-License-Identifier: MPL-2.0 -// Copyright (c) Jonathan D.A. Jewell -// SPDX-FileCopyrightText: 2025 Jonathan D.A. Jewell -// -// Content-Addressed Storage with SHA256 hashing -// Provides deduplication and integrity verification - -use crate::error::{JanusError, Result}; -use flate2::read::GzDecoder; -use flate2::write::GzEncoder; -use flate2::Compression; -use sha2::{Digest, Sha256}; -use std::fs::{self, File}; -use std::io::{Read, Write}; -use std::path::{Path, PathBuf}; - -/// SHA256 content hash -#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] -pub struct ContentHash(pub String); - -impl ContentHash { - /// Create hash from content bytes - pub fn from_bytes(content: &[u8]) -> Self { - let mut hasher = Sha256::new(); - hasher.update(content); - let hash = hasher.finalize(); - Self(format!("sha256:{}", hex::encode(hash))) - } - - /// Create hash from string - pub fn from_str(s: &str) -> Self { - Self::from_bytes(s.as_bytes()) - } - - /// Get the raw hash portion (without prefix) - pub fn raw_hash(&self) -> &str { - self.0.strip_prefix("sha256:").unwrap_or(&self.0) - } - - /// Verify content matches this hash - pub fn verify(&self, content: &[u8]) -> bool { - let computed = Self::from_bytes(content); - self.0 == computed.0 - } -} - -impl std::fmt::Display for ContentHash { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.0) - } -} - -/// Content-addressed storage for file content -pub struct ContentStore { - /// Root directory for content storage - root: PathBuf, - /// Whether to compress stored content - compression: bool, -} - -impl ContentStore { - /// Create or open a content store - pub fn new(root: PathBuf, compression: bool) -> Result { - fs::create_dir_all(&root)?; - Ok(Self { root, compression }) - } - - /// Get path for a content hash - fn content_path(&self, hash: &ContentHash) -> PathBuf { - let raw = hash.raw_hash(); - // Use first 2 chars as directory for distribution - let (dir, file) = raw.split_at(2.min(raw.len())); - let mut path = self.root.join(dir); - if self.compression { - path = path.join(format!("{}.gz", file)); - } else { - path = path.join(file); - } - path - } - - /// Store content and return its hash - pub fn store(&self, content: &[u8]) -> Result { - let hash = ContentHash::from_bytes(content); - let path = self.content_path(&hash); - - // Skip if already stored (deduplication) - if path.exists() { - return Ok(hash); - } - - // Create parent directory - if let Some(parent) = path.parent() { - fs::create_dir_all(parent)?; - } - - // Write content (optionally compressed) - if self.compression { - let file = File::create(&path)?; - let mut encoder = GzEncoder::new(file, Compression::default()); - encoder.write_all(content)?; - encoder.finish()?; - } else { - fs::write(&path, content)?; - } - - Ok(hash) - } - - /// Store content from a file - pub fn store_file(&self, file_path: &Path) -> Result { - let content = fs::read(file_path)?; - self.store(&content) - } - - /// Retrieve content by hash - pub fn retrieve(&self, hash: &ContentHash) -> Result> { - let path = self.content_path(hash); - - if !path.exists() { - return Err(JanusError::FileNotFound(hash.to_string())); - } - - let content = if self.compression { - let file = File::open(&path)?; - let mut decoder = GzDecoder::new(file); - let mut content = Vec::new(); - decoder.read_to_end(&mut content)?; - content - } else { - fs::read(&path)? - }; - - // Verify integrity - if !hash.verify(&content) { - let actual = ContentHash::from_bytes(&content); - return Err(JanusError::ContentIntegrityError { - expected: hash.to_string(), - actual: actual.to_string(), - }); - } - - Ok(content) - } - - /// Check if content exists - pub fn exists(&self, hash: &ContentHash) -> bool { - self.content_path(hash).exists() - } - - /// Delete content by hash (for garbage collection) - pub fn delete(&self, hash: &ContentHash) -> Result<()> { - let path = self.content_path(hash); - if path.exists() { - fs::remove_file(path)?; - } - Ok(()) - } - - /// Get total size of content store - pub fn total_size(&self) -> Result { - let mut size = 0; - for entry in walkdir::WalkDir::new(&self.root).into_iter().flatten() { - if entry.file_type().is_file() { - if let Ok(metadata) = entry.metadata() { - size += metadata.len(); - } - } - } - Ok(size) - } - - /// Count number of stored content blobs - pub fn count(&self) -> Result { - let mut count = 0; - for entry in walkdir::WalkDir::new(&self.root).into_iter().flatten() { - if entry.file_type().is_file() { - count += 1; - } - } - Ok(count) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::TempDir; - - #[test] - fn test_content_hash() { - let content = b"hello world"; - let hash = ContentHash::from_bytes(content); - assert!(hash.0.starts_with("sha256:")); - assert!(hash.verify(content)); - assert!(!hash.verify(b"different content")); - } - - #[test] - fn test_store_and_retrieve() { - let tmp = TempDir::new().unwrap(); - let store = ContentStore::new(tmp.path().to_path_buf(), false).unwrap(); - - let content = b"test content"; - let hash = store.store(content).unwrap(); - - let retrieved = store.retrieve(&hash).unwrap(); - assert_eq!(content.to_vec(), retrieved); - } - - #[test] - fn test_store_compressed() { - let tmp = TempDir::new().unwrap(); - let store = ContentStore::new(tmp.path().to_path_buf(), true).unwrap(); - - let content = b"test content that should compress well when repeated ".repeat(100); - let hash = store.store(&content).unwrap(); - - let retrieved = store.retrieve(&hash).unwrap(); - assert_eq!(content, retrieved.as_slice()); - } - - #[test] - fn test_deduplication() { - let tmp = TempDir::new().unwrap(); - let store = ContentStore::new(tmp.path().to_path_buf(), false).unwrap(); - - let content = b"duplicate content"; - let hash1 = store.store(content).unwrap(); - let hash2 = store.store(content).unwrap(); - - assert_eq!(hash1, hash2); - assert_eq!(store.count().unwrap(), 1); - } -} diff --git a/src/januskey/src/delta.rs b/src/januskey/src/delta.rs deleted file mode 100644 index d1f6b13..0000000 --- a/src/januskey/src/delta.rs +++ /dev/null @@ -1,506 +0,0 @@ -// SPDX-License-Identifier: MPL-2.0 -// Copyright (c) Jonathan D.A. Jewell -// SPDX-FileCopyrightText: 2025 Jonathan D.A. Jewell -// -// Delta Storage: Efficient storage for file modifications -// Stores only the differences when modifying large files - -use serde::{Deserialize, Serialize}; - -/// Threshold: use delta if original file is larger than this -const DELTA_THRESHOLD: usize = 4096; // 4KB - -/// Maximum delta size as percentage of original (if delta is larger, store full content) -const MAX_DELTA_RATIO: f64 = 0.5; // 50% - -/// A delta representing changes between two versions of content -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Delta { - /// Type of delta encoding used - pub encoding: DeltaEncoding, - /// The delta data - pub data: Vec, - /// Original content size (for validation) - pub original_size: usize, - /// New content size (for validation) - pub new_size: usize, -} - -/// Type of delta encoding -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum DeltaEncoding { - /// Full content (no delta, used when delta would be larger) - Full, - /// Line-based diff for text files - LineDiff, - /// Block-based diff for binary files - BlockDiff, -} - -/// A single edit operation in a diff -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum EditOp { - /// Keep N bytes from original at position - Keep { offset: usize, len: usize }, - /// Insert new bytes - Insert { data: Vec }, - /// Delete N bytes from original at position - Delete { offset: usize, len: usize }, -} - -/// Line-based diff for text files -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct LineDiff { - /// List of hunks (groups of changes) - pub hunks: Vec, -} - -/// A hunk in a line diff -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct DiffHunk { - /// Starting line in original (0-indexed) - pub original_start: usize, - /// Number of lines in original - pub original_count: usize, - /// Starting line in new (0-indexed) - pub new_start: usize, - /// Number of lines in new - pub new_count: usize, - /// The changed lines - pub lines: Vec, -} - -/// A line in a diff hunk -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum DiffLine { - /// Line exists in both (context) - Context(String), - /// Line only in original (removed) - Removed(String), - /// Line only in new (added) - Added(String), -} - -impl Delta { - /// Compute delta between original and new content - pub fn compute(original: &[u8], new: &[u8]) -> Self { - // If original is small, just store full content - if original.len() < DELTA_THRESHOLD { - return Self::full(new); - } - - // Try to detect if content is text - let is_text = is_likely_text(original) && is_likely_text(new); - - if is_text { - // Use line-based diff for text - if let Some(delta) = Self::compute_line_diff(original, new) { - // Check if delta is smaller than threshold - let delta_size = delta.data.len(); - let max_size = (original.len() as f64 * MAX_DELTA_RATIO) as usize; - - if delta_size < max_size && delta_size < new.len() { - return delta; - } - } - } else { - // Use block-based diff for binary - if let Some(delta) = Self::compute_block_diff(original, new) { - let delta_size = delta.data.len(); - let max_size = (original.len() as f64 * MAX_DELTA_RATIO) as usize; - - if delta_size < max_size && delta_size < new.len() { - return delta; - } - } - } - - // Fall back to full content - Self::full(new) - } - - /// Create a delta that stores full content - fn full(content: &[u8]) -> Self { - Self { - encoding: DeltaEncoding::Full, - data: content.to_vec(), - original_size: 0, - new_size: content.len(), - } - } - - /// Compute line-based diff - fn compute_line_diff(original: &[u8], new: &[u8]) -> Option { - let original_str = std::str::from_utf8(original).ok()?; - let new_str = std::str::from_utf8(new).ok()?; - - let original_lines: Vec<&str> = original_str.lines().collect(); - let new_lines: Vec<&str> = new_str.lines().collect(); - - // Simple LCS-based diff - let diff = compute_lcs_diff(&original_lines, &new_lines); - - let serialized = serde_json::to_vec(&diff).ok()?; - - Some(Self { - encoding: DeltaEncoding::LineDiff, - data: serialized, - original_size: original.len(), - new_size: new.len(), - }) - } - - /// Compute block-based diff for binary files - fn compute_block_diff(original: &[u8], new: &[u8]) -> Option { - const BLOCK_SIZE: usize = 64; - - let mut ops: Vec = Vec::new(); - let mut new_pos = 0; - let mut orig_pos = 0; - - // Simple block matching algorithm - while new_pos < new.len() { - // Try to find a matching block in original - let remaining_new = &new[new_pos..]; - let block_len = remaining_new.len().min(BLOCK_SIZE); - let block = &remaining_new[..block_len]; - - if let Some(match_pos) = find_block(original, orig_pos, block) { - // Found a match - if match_pos > orig_pos { - // There's a gap in original (deleted content) - ops.push(EditOp::Delete { - offset: orig_pos, - len: match_pos - orig_pos, - }); - } - ops.push(EditOp::Keep { - offset: match_pos, - len: block_len, - }); - orig_pos = match_pos + block_len; - new_pos += block_len; - } else { - // No match, this is inserted content - // Find how much is inserted before next match - let insert_end = find_next_match(original, orig_pos, &new[new_pos..], BLOCK_SIZE) - .unwrap_or(new.len() - new_pos); - - ops.push(EditOp::Insert { - data: new[new_pos..new_pos + insert_end].to_vec(), - }); - new_pos += insert_end; - } - } - - // Handle remaining original content (deleted) - if orig_pos < original.len() { - ops.push(EditOp::Delete { - offset: orig_pos, - len: original.len() - orig_pos, - }); - } - - let serialized = serde_json::to_vec(&ops).ok()?; - - Some(Self { - encoding: DeltaEncoding::BlockDiff, - data: serialized, - original_size: original.len(), - new_size: new.len(), - }) - } - - /// Apply delta to original content to get new content - pub fn apply(&self, original: &[u8]) -> Option> { - match self.encoding { - DeltaEncoding::Full => Some(self.data.clone()), - DeltaEncoding::LineDiff => self.apply_line_diff(original), - DeltaEncoding::BlockDiff => self.apply_block_diff(original), - } - } - - /// Apply line diff - fn apply_line_diff(&self, original: &[u8]) -> Option> { - let diff: LineDiff = serde_json::from_slice(&self.data).ok()?; - let original_str = std::str::from_utf8(original).ok()?; - let original_lines: Vec<&str> = original_str.lines().collect(); - - let mut result_lines: Vec = Vec::new(); - let mut orig_line = 0; - - for hunk in &diff.hunks { - // Add unchanged lines before this hunk - while orig_line < hunk.original_start { - if orig_line < original_lines.len() { - result_lines.push(original_lines[orig_line].to_string()); - } - orig_line += 1; - } - - // Process hunk - for line in &hunk.lines { - match line { - DiffLine::Context(s) | DiffLine::Added(s) => { - result_lines.push(s.clone()); - } - DiffLine::Removed(_) => { - // Skip removed lines - } - } - } - - orig_line = hunk.original_start + hunk.original_count; - } - - // Add remaining original lines - while orig_line < original_lines.len() { - result_lines.push(original_lines[orig_line].to_string()); - orig_line += 1; - } - - // Preserve original line endings - let line_ending = if original_str.contains("\r\n") { "\r\n" } else { "\n" }; - let result = result_lines.join(line_ending); - - // Add final newline if original had one - let final_result = if original_str.ends_with('\n') || original_str.ends_with("\r\n") { - format!("{}{}", result, line_ending) - } else { - result - }; - - Some(final_result.into_bytes()) - } - - /// Apply block diff - fn apply_block_diff(&self, original: &[u8]) -> Option> { - let ops: Vec = serde_json::from_slice(&self.data).ok()?; - let mut result = Vec::with_capacity(self.new_size); - - for op in ops { - match op { - EditOp::Keep { offset, len } => { - if offset + len <= original.len() { - result.extend_from_slice(&original[offset..offset + len]); - } - } - EditOp::Insert { data } => { - result.extend_from_slice(&data); - } - EditOp::Delete { .. } => { - // Deletions are handled implicitly by not copying - } - } - } - - Some(result) - } - - /// Check if this delta uses full content storage - pub fn is_full(&self) -> bool { - self.encoding == DeltaEncoding::Full - } - - /// Get the stored data (for content store) - pub fn into_bytes(self) -> Vec { - serde_json::to_vec(&self).unwrap_or(self.data) - } - - /// Parse from bytes - pub fn from_bytes(data: &[u8]) -> Option { - serde_json::from_slice(data).ok() - } -} - -/// Check if content is likely text (not binary) -fn is_likely_text(content: &[u8]) -> bool { - if content.is_empty() { - return true; - } - - // Check first 8KB for null bytes or high ratio of non-printable chars - let sample_size = content.len().min(8192); - let sample = &content[..sample_size]; - - let mut non_text_count = 0; - for &byte in sample { - if byte == 0 { - return false; // Null byte = binary - } - if byte < 32 && byte != b'\t' && byte != b'\n' && byte != b'\r' { - non_text_count += 1; - } - } - - // If more than 10% non-text characters, consider it binary - (non_text_count as f64 / sample_size as f64) < 0.1 -} - -/// Compute LCS-based diff between two lists of lines -fn compute_lcs_diff(original: &[&str], new: &[&str]) -> LineDiff { - // Simple Myers diff algorithm implementation - let mut hunks = Vec::new(); - - let (orig_len, new_len) = (original.len(), new.len()); - - // Build edit graph using simple DP - let mut lcs = vec![vec![0usize; new_len + 1]; orig_len + 1]; - - for i in 1..=orig_len { - for j in 1..=new_len { - if original[i - 1] == new[j - 1] { - lcs[i][j] = lcs[i - 1][j - 1] + 1; - } else { - lcs[i][j] = lcs[i - 1][j].max(lcs[i][j - 1]); - } - } - } - - // Backtrack to find differences - let mut i = orig_len; - let mut j = new_len; - let mut changes: Vec<(usize, usize, DiffLine)> = Vec::new(); - - while i > 0 || j > 0 { - if i > 0 && j > 0 && original[i - 1] == new[j - 1] { - changes.push((i - 1, j - 1, DiffLine::Context(original[i - 1].to_string()))); - i -= 1; - j -= 1; - } else if j > 0 && (i == 0 || lcs[i][j - 1] >= lcs[i - 1][j]) { - changes.push((i, j - 1, DiffLine::Added(new[j - 1].to_string()))); - j -= 1; - } else if i > 0 { - changes.push((i - 1, j, DiffLine::Removed(original[i - 1].to_string()))); - i -= 1; - } - } - - changes.reverse(); - - // Group changes into hunks - let mut current_hunk: Option = None; - let context_lines = 3; - - for (orig_idx, new_idx, line) in changes { - match &line { - DiffLine::Context(_) => { - if let Some(ref mut hunk) = current_hunk { - hunk.lines.push(line); - hunk.original_count = orig_idx - hunk.original_start + 1; - hunk.new_count = new_idx - hunk.new_start + 1; - } - } - DiffLine::Added(_) | DiffLine::Removed(_) => { - if current_hunk.is_none() { - let start_orig = orig_idx.saturating_sub(context_lines); - let start_new = new_idx.saturating_sub(context_lines); - current_hunk = Some(DiffHunk { - original_start: start_orig, - original_count: 1, - new_start: start_new, - new_count: 1, - lines: Vec::new(), - }); - } - if let Some(ref mut hunk) = current_hunk { - hunk.lines.push(line); - hunk.original_count = orig_idx - hunk.original_start + 1; - hunk.new_count = new_idx - hunk.new_start + 1; - } - } - } - } - - if let Some(hunk) = current_hunk { - hunks.push(hunk); - } - - LineDiff { hunks } -} - -/// Find a block in the original content starting from a position -fn find_block(original: &[u8], start: usize, block: &[u8]) -> Option { - if block.is_empty() || start >= original.len() { - return None; - } - - let search_range = &original[start..]; - for i in 0..search_range.len().saturating_sub(block.len() - 1) { - if search_range[i..].starts_with(block) { - return Some(start + i); - } - } - None -} - -/// Find how much content before the next matching block -fn find_next_match(original: &[u8], orig_start: usize, new_content: &[u8], block_size: usize) -> Option { - for i in 1..new_content.len() { - let remaining = &new_content[i..]; - if remaining.len() >= block_size { - let block = &remaining[..block_size]; - if find_block(original, orig_start, block).is_some() { - return Some(i); - } - } - } - None -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_is_likely_text() { - assert!(is_likely_text(b"Hello, world!")); - assert!(is_likely_text(b"Line 1\nLine 2\nLine 3")); - assert!(!is_likely_text(b"\x00\x01\x02\x03")); - assert!(is_likely_text(b"")); - } - - #[test] - fn test_full_delta() { - let original = b"small"; - let new = b"new content"; - - let delta = Delta::compute(original, new); - assert!(delta.is_full()); - assert_eq!(delta.apply(original).unwrap(), new.to_vec()); - } - - #[test] - fn test_line_diff() { - let original = b"line 1\nline 2\nline 3\nline 4\nline 5\n".repeat(100); - let new = original.clone(); - // Modify line 50 - let new_str = String::from_utf8(new.clone()).unwrap(); - let lines: Vec<&str> = new_str.lines().collect(); - let mut new_lines = lines.clone(); - new_lines[49] = "modified line 50"; - let new_content = new_lines.join("\n") + "\n"; - - let delta = Delta::compute(&original, new_content.as_bytes()); - - // Delta should be smaller than original - if !delta.is_full() { - assert!(delta.data.len() < original.len()); - } - - // Applying delta should produce new content - let restored = delta.apply(&original).unwrap(); - assert_eq!(restored, new_content.as_bytes()); - } - - #[test] - fn test_delta_roundtrip() { - let original = b"Original content here\nWith multiple lines\nAnd some more text\n".repeat(50); - let new = b"Modified content here\nWith multiple lines\nAnd some different text\n".repeat(50); - - let delta = Delta::compute(&original, &new); - let restored = delta.apply(&original).unwrap(); - - assert_eq!(restored, new.to_vec()); - } -} diff --git a/src/januskey/src/error.rs b/src/januskey/src/error.rs deleted file mode 100644 index 8e42e4a..0000000 --- a/src/januskey/src/error.rs +++ /dev/null @@ -1,65 +0,0 @@ -// SPDX-License-Identifier: MPL-2.0 -// Copyright (c) Jonathan D.A. Jewell -// SPDX-FileCopyrightText: 2025 Jonathan D.A. Jewell -// -// JanusKey Error Types - -use thiserror::Error; - -/// Result type alias for JanusKey operations -pub type Result = std::result::Result; - -/// JanusKey error types -#[derive(Error, Debug)] -pub enum JanusError { - #[error("Directory not initialized: {0}. Run 'jk init' first.")] - NotInitialized(String), - - #[error("File not found: {0}")] - FileNotFound(String), - - #[error("Directory not found: {0}")] - DirectoryNotFound(String), - - #[error("Path already exists: {0}")] - PathExists(String), - - #[error("Operation failed: {0}")] - OperationFailed(String), - - #[error("No active transaction")] - NoActiveTransaction, - - #[error("Transaction already active: {0}")] - TransactionActive(String), - - #[error("Nothing to undo")] - NothingToUndo, - - #[error("Invalid operation ID: {0}")] - InvalidOperationId(String), - - #[error("Content integrity error: expected {expected}, got {actual}")] - ContentIntegrityError { expected: String, actual: String }, - - #[error("Metadata corrupted: {0}")] - MetadataCorrupted(String), - - #[error("Permission denied: {0}")] - PermissionDenied(String), - - #[error("Invalid glob pattern: {0}")] - InvalidPattern(String), - - #[error("IO error: {0}")] - Io(#[from] std::io::Error), - - #[error("JSON error: {0}")] - Json(#[from] serde_json::Error), - - #[error("Glob pattern error: {0}")] - Glob(#[from] glob::PatternError), - - #[error("User cancelled operation")] - UserCancelled, -} diff --git a/src/januskey/src/keys.rs b/src/januskey/src/keys.rs deleted file mode 100644 index 0bc8d3f..0000000 --- a/src/januskey/src/keys.rs +++ /dev/null @@ -1,711 +0,0 @@ -// SPDX-License-Identifier: MPL-2.0 -// Copyright (c) Jonathan D.A. Jewell -// SPDX-FileCopyrightText: 2025 Jonathan D.A. Jewell -// -// JanusKey Key Management Module -// Implements secure key generation, storage, rotation, and recovery - -use aes_gcm::{ - aead::{Aead, KeyInit}, - Aes256Gcm, Nonce, -}; -use argon2::{Algorithm as Argon2Algorithm, Argon2, Params, Version}; -use chrono::{DateTime, Utc}; -use rand::RngCore; -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; -use std::fs; -use std::path::{Path, PathBuf}; -use thiserror::Error; -use uuid::Uuid; -use zeroize::{Zeroize, ZeroizeOnDrop}; - -use crate::attestation::AuditLog; - -/// Key management errors -#[derive(Error, Debug)] -pub enum KeyError { - #[error("Key store not initialized")] - NotInitialized, - - #[error("Key store already exists")] - AlreadyExists, - - #[error("Invalid passphrase")] - InvalidPassphrase, - - #[error("Key not found: {0}")] - KeyNotFound(Uuid), - - #[error("Key already revoked: {0}")] - AlreadyRevoked(Uuid), - - #[error("Cryptographic error: {0}")] - CryptoError(String), - - #[error("IO error: {0}")] - IoError(#[from] std::io::Error), - - #[error("Serialization error: {0}")] - SerdeError(#[from] serde_json::Error), -} - -pub type Result = std::result::Result; - -/// Argon2id parameters (OWASP recommendations) -const ARGON2_MEMORY_KB: u32 = 65536; // 64 MB -const ARGON2_ITERATIONS: u32 = 3; -const ARGON2_PARALLELISM: u32 = 4; -const SALT_LENGTH: usize = 16; -const NONCE_LENGTH: usize = 12; -const KEY_LENGTH: usize = 32; - -/// Key algorithm types -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum KeyAlgorithm { - Aes256Gcm, - Ed25519, - X25519, -} - -impl std::fmt::Display for KeyAlgorithm { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - KeyAlgorithm::Aes256Gcm => write!(f, "AES-256-GCM"), - KeyAlgorithm::Ed25519 => write!(f, "Ed25519"), - KeyAlgorithm::X25519 => write!(f, "X25519"), - } - } -} - -/// Key purpose -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum KeyPurpose { - Encryption, - Signing, - KeyWrap, - Recovery, -} - -impl std::fmt::Display for KeyPurpose { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - KeyPurpose::Encryption => write!(f, "encryption"), - KeyPurpose::Signing => write!(f, "signing"), - KeyPurpose::KeyWrap => write!(f, "key-wrap"), - KeyPurpose::Recovery => write!(f, "recovery"), - } - } -} - -/// Key lifecycle state -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum KeyState { - Generated, - Active, - Rotating, - Suspended, - Revoked, - Obliterated, -} - -impl std::fmt::Display for KeyState { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - KeyState::Generated => write!(f, "generated"), - KeyState::Active => write!(f, "active"), - KeyState::Rotating => write!(f, "rotating"), - KeyState::Suspended => write!(f, "suspended"), - KeyState::Revoked => write!(f, "revoked"), - KeyState::Obliterated => write!(f, "obliterated"), - } - } -} - -/// Key metadata (stored with wrapped key) -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct KeyMetadata { - pub id: Uuid, - pub algorithm: KeyAlgorithm, - pub purpose: KeyPurpose, - pub created_at: DateTime, - pub expires_at: Option>, - pub state: KeyState, - pub rotation_of: Option, - pub fingerprint: String, - pub description: Option, -} - -/// Wrapped key (encrypted key material + metadata) -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct WrappedKey { - pub metadata: KeyMetadata, - pub nonce: [u8; NONCE_LENGTH], - pub ciphertext: Vec, -} - -/// Key store header -#[derive(Debug, Clone, Serialize, Deserialize)] -struct KeyStoreHeader { - magic: String, - version: u32, - salt: [u8; SALT_LENGTH], - nonce: [u8; NONCE_LENGTH], -} - -/// Key store (encrypted container for keys) -#[derive(Debug, Clone, Serialize, Deserialize)] -struct KeyStoreData { - header: KeyStoreHeader, - keys: Vec, -} - -/// Secret key material (zeroized on drop) -#[derive(Clone, Zeroize, ZeroizeOnDrop)] -pub struct SecretKey { - bytes: [u8; KEY_LENGTH], -} - -impl SecretKey { - pub fn new(bytes: [u8; KEY_LENGTH]) -> Self { - Self { bytes } - } - - pub fn as_bytes(&self) -> &[u8; KEY_LENGTH] { - &self.bytes - } - - pub fn generate() -> Result { - let mut bytes = [0u8; KEY_LENGTH]; - rand::thread_rng().fill_bytes(&mut bytes); - Ok(Self { bytes }) - } -} - -/// Key manager for JanusKey -pub struct KeyManager { - store_path: PathBuf, - root_path: PathBuf, - kek: Option, - audit_log: AuditLog, -} - -impl KeyManager { - /// Create new key manager for a directory - pub fn new(root: &Path) -> Self { - let store_path = root.join(".januskey").join("keys"); - let audit_log = AuditLog::new(root); - Self { - store_path, - root_path: root.to_path_buf(), - kek: None, - audit_log, - } - } - - /// Get reference to audit log - pub fn audit_log(&self) -> &AuditLog { - &self.audit_log - } - - /// Check if key store is initialized - pub fn is_initialized(&self) -> bool { - self.store_path.join("keystore.jks").exists() - } - - /// Initialize key store with passphrase - pub fn init(&mut self, passphrase: &str) -> Result<()> { - if self.is_initialized() { - return Err(KeyError::AlreadyExists); - } - - fs::create_dir_all(&self.store_path)?; - - // Generate salt - let mut salt = [0u8; SALT_LENGTH]; - rand::thread_rng().fill_bytes(&mut salt); - - // Derive KEK from passphrase - let kek = derive_kek(passphrase, &salt)?; - - // Derive attestation key from KEK - let mut attestation_key = [0u8; 32]; - let mut hasher = Sha256::new(); - hasher.update(kek.as_bytes()); - hasher.update(b"attestation"); - attestation_key.copy_from_slice(&hasher.finalize()); - - self.kek = Some(kek); - - // Generate initial nonce - let mut nonce = [0u8; NONCE_LENGTH]; - rand::thread_rng().fill_bytes(&mut nonce); - - // Create empty key store - let store = KeyStoreData { - header: KeyStoreHeader { - magic: "JKKEYS01".to_string(), - version: 1, - salt, - nonce, - }, - keys: Vec::new(), - }; - - self.save_store(&store)?; - - // Set restrictive permissions - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let path = self.store_path.join("keystore.jks"); - fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?; - } - - // Initialize audit log and record event - self.audit_log.init(attestation_key)?; - let _ = self.audit_log.log_store_init(); - - Ok(()) - } - - /// Unlock key store with passphrase - pub fn unlock(&mut self, passphrase: &str) -> Result<()> { - if !self.is_initialized() { - return Err(KeyError::NotInitialized); - } - - let store = self.load_store_raw()?; - let kek = derive_kek(passphrase, &store.header.salt)?; - - // Verify passphrase by attempting to decrypt store - if !self.verify_kek(&kek, &store)? { - return Err(KeyError::InvalidPassphrase); - } - - // Derive attestation key from KEK - let mut attestation_key = [0u8; 32]; - let mut hasher = Sha256::new(); - hasher.update(kek.as_bytes()); - hasher.update(b"attestation"); - attestation_key.copy_from_slice(&hasher.finalize()); - - self.kek = Some(kek); - self.audit_log.set_attestation_key(attestation_key); - let _ = self.audit_log.log_store_unlock(); - - Ok(()) - } - - /// Generate a new key - pub fn generate( - &mut self, - algorithm: KeyAlgorithm, - purpose: KeyPurpose, - description: Option, - expires_in_days: Option, - ) -> Result { - let kek = self.kek.as_ref().ok_or(KeyError::NotInitialized)?; - let mut store = self.load_store()?; - - // Generate key material - let key = SecretKey::generate()?; - - // Calculate fingerprint - let mut hasher = Sha256::new(); - hasher.update(key.as_bytes()); - let fingerprint = hex::encode(&hasher.finalize()[..8]); - - // Create metadata - let id = Uuid::new_v4(); - let now = Utc::now(); - let expires_at = expires_in_days.map(|days| now + chrono::Duration::days(days as i64)); - - let metadata = KeyMetadata { - id, - algorithm, - purpose, - created_at: now, - expires_at, - state: KeyState::Active, - rotation_of: None, - fingerprint: fingerprint.clone(), - description, - }; - - // Wrap key - let wrapped = wrap_key(kek, key.as_bytes(), &metadata)?; - store.keys.push(wrapped); - - self.save_store(&store)?; - - // Log key generation - let _ = self.audit_log.log_key_generated(id, &fingerprint, algorithm, purpose); - - Ok(id) - } - - /// List all keys - pub fn list(&self) -> Result> { - if self.kek.is_none() { - return Err(KeyError::NotInitialized); - } - - let store = self.load_store()?; - Ok(store.keys.into_iter().map(|k| k.metadata).collect()) - } - - /// Get key metadata by ID - pub fn get(&self, id: Uuid) -> Result { - if self.kek.is_none() { - return Err(KeyError::NotInitialized); - } - - let store = self.load_store()?; - store - .keys - .into_iter() - .find(|k| k.metadata.id == id) - .map(|k| k.metadata) - .ok_or(KeyError::KeyNotFound(id)) - } - - /// Retrieve key material (use carefully!) - pub fn retrieve(&self, id: Uuid) -> Result { - let kek = self.kek.as_ref().ok_or(KeyError::NotInitialized)?; - let store = self.load_store()?; - - let wrapped = store - .keys - .into_iter() - .find(|k| k.metadata.id == id) - .ok_or(KeyError::KeyNotFound(id))?; - - if wrapped.metadata.state == KeyState::Revoked - || wrapped.metadata.state == KeyState::Obliterated - { - return Err(KeyError::AlreadyRevoked(id)); - } - - // Log key retrieval - let _ = self.audit_log.log_key_retrieved(id, &wrapped.metadata.fingerprint); - - unwrap_key(kek, &wrapped) - } - - /// Rotate a key - pub fn rotate(&mut self, id: Uuid) -> Result { - let kek = self.kek.as_ref().ok_or(KeyError::NotInitialized)?; - let mut store = self.load_store()?; - - // Find old key - let old_idx = store - .keys - .iter() - .position(|k| k.metadata.id == id) - .ok_or(KeyError::KeyNotFound(id))?; - - if store.keys[old_idx].metadata.state == KeyState::Revoked { - return Err(KeyError::AlreadyRevoked(id)); - } - - // Generate new key with same properties - let old_meta = &store.keys[old_idx].metadata; - let new_key = SecretKey::generate()?; - - let mut hasher = Sha256::new(); - hasher.update(new_key.as_bytes()); - let fingerprint = hex::encode(&hasher.finalize()[..8]); - - let new_id = Uuid::new_v4(); - let now = Utc::now(); - - let new_metadata = KeyMetadata { - id: new_id, - algorithm: old_meta.algorithm, - purpose: old_meta.purpose, - created_at: now, - expires_at: old_meta - .expires_at - .map(|_| now + chrono::Duration::days(365)), - state: KeyState::Active, - rotation_of: Some(id), - fingerprint: fingerprint.clone(), - description: old_meta.description.clone(), - }; - - // Wrap new key - let new_wrapped = wrap_key(kek, new_key.as_bytes(), &new_metadata)?; - store.keys.push(new_wrapped); - - // Mark old key as revoked - let old_fingerprint = store.keys[old_idx].metadata.fingerprint.clone(); - store.keys[old_idx].metadata.state = KeyState::Revoked; - - self.save_store(&store)?; - - // Log rotation event - let _ = self.audit_log.log_key_rotated(id, &old_fingerprint, new_id, &fingerprint); - - Ok(new_id) - } - - /// Revoke a key - pub fn revoke(&mut self, id: Uuid) -> Result<()> { - if self.kek.is_none() { - return Err(KeyError::NotInitialized); - } - - let mut store = self.load_store()?; - - let key = store - .keys - .iter_mut() - .find(|k| k.metadata.id == id) - .ok_or(KeyError::KeyNotFound(id))?; - - if key.metadata.state == KeyState::Revoked { - return Err(KeyError::AlreadyRevoked(id)); - } - - let fingerprint = key.metadata.fingerprint.clone(); - key.metadata.state = KeyState::Revoked; - self.save_store(&store)?; - - // Log revocation - let _ = self.audit_log.log_key_revoked(id, &fingerprint, None); - - Ok(()) - } - - /// Revoke a key with reason - pub fn revoke_with_reason(&mut self, id: Uuid, reason: &str) -> Result<()> { - if self.kek.is_none() { - return Err(KeyError::NotInitialized); - } - - let mut store = self.load_store()?; - - let key = store - .keys - .iter_mut() - .find(|k| k.metadata.id == id) - .ok_or(KeyError::KeyNotFound(id))?; - - if key.metadata.state == KeyState::Revoked { - return Err(KeyError::AlreadyRevoked(id)); - } - - let fingerprint = key.metadata.fingerprint.clone(); - key.metadata.state = KeyState::Revoked; - self.save_store(&store)?; - - // Log revocation with reason - let _ = self.audit_log.log_key_revoked(id, &fingerprint, Some(reason)); - - Ok(()) - } - - /// Create encrypted backup - pub fn backup(&self, output: &Path) -> Result<()> { - if self.kek.is_none() { - return Err(KeyError::NotInitialized); - } - - let store_path = self.store_path.join("keystore.jks"); - fs::copy(store_path, output)?; - - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - fs::set_permissions(output, fs::Permissions::from_mode(0o600))?; - } - - // Log backup creation - let _ = self.audit_log.log_backup_created(output); - - Ok(()) - } - - // Internal helpers - - fn load_store_raw(&self) -> Result { - let path = self.store_path.join("keystore.jks"); - let content = ({ use std::io::Read; std::fs::File::open(&path).and_then(|mut f| { let mut buf = String::new(); f.take(10 * 1024 * 1024).read_to_string(&mut buf)?; Ok(buf) }) })?; - let store: KeyStoreData = serde_json::from_str(&content)?; - Ok(store) - } - - fn load_store(&self) -> Result { - self.load_store_raw() - } - - fn save_store(&self, store: &KeyStoreData) -> Result<()> { - let path = self.store_path.join("keystore.jks"); - let content = serde_json::to_string_pretty(store)?; - fs::write(&path, content)?; - Ok(()) - } - - fn verify_kek(&self, kek: &SecretKey, store: &KeyStoreData) -> Result { - // If there are any keys, try to unwrap the first one - if let Some(wrapped) = store.keys.first() { - match unwrap_key(kek, wrapped) { - Ok(_) => Ok(true), - Err(KeyError::CryptoError(_)) => Ok(false), - Err(e) => Err(e), - } - } else { - // No keys yet, verify by re-deriving and checking magic - Ok(store.header.magic == "JKKEYS01") - } - } -} - -/// Derive Key Encryption Key from passphrase -fn derive_kek(passphrase: &str, salt: &[u8; SALT_LENGTH]) -> Result { - let params = Params::new( - ARGON2_MEMORY_KB, - ARGON2_ITERATIONS, - ARGON2_PARALLELISM, - Some(KEY_LENGTH), - ) - .map_err(|e| KeyError::CryptoError(e.to_string()))?; - - let argon2 = Argon2::new(Argon2Algorithm::Argon2id, Version::V0x13, params); - - let mut kek = [0u8; KEY_LENGTH]; - argon2 - .hash_password_into(passphrase.as_bytes(), salt, &mut kek) - .map_err(|e| KeyError::CryptoError(e.to_string()))?; - - Ok(SecretKey::new(kek)) -} - -/// Wrap (encrypt) key material -fn wrap_key(kek: &SecretKey, key: &[u8], metadata: &KeyMetadata) -> Result { - let mut nonce_bytes = [0u8; NONCE_LENGTH]; - rand::thread_rng().fill_bytes(&mut nonce_bytes); - - let cipher = Aes256Gcm::new(kek.as_bytes().into()); - let nonce = Nonce::from_slice(&nonce_bytes); - - // Use metadata ID as additional authenticated data - let aad = metadata.id.as_bytes(); - - let ciphertext = cipher - .encrypt(nonce, aes_gcm::aead::Payload { msg: key, aad }) - .map_err(|e| KeyError::CryptoError(e.to_string()))?; - - Ok(WrappedKey { - metadata: metadata.clone(), - nonce: nonce_bytes, - ciphertext, - }) -} - -/// Unwrap (decrypt) key material -fn unwrap_key(kek: &SecretKey, wrapped: &WrappedKey) -> Result { - let cipher = Aes256Gcm::new(kek.as_bytes().into()); - let nonce = Nonce::from_slice(&wrapped.nonce); - - let aad = wrapped.metadata.id.as_bytes(); - - let plaintext = cipher - .decrypt( - nonce, - aes_gcm::aead::Payload { - msg: &wrapped.ciphertext, - aad, - }, - ) - .map_err(|_| KeyError::CryptoError("Decryption failed".to_string()))?; - - if plaintext.len() != KEY_LENGTH { - return Err(KeyError::CryptoError("Invalid key length".to_string())); - } - - let mut bytes = [0u8; KEY_LENGTH]; - bytes.copy_from_slice(&plaintext); - Ok(SecretKey::new(bytes)) -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::TempDir; - - #[test] - fn test_key_manager_init() { - let tmp = TempDir::new().unwrap(); - let mut km = KeyManager::new(tmp.path()); - - assert!(!km.is_initialized()); - km.init("test-passphrase").unwrap(); - assert!(km.is_initialized()); - } - - #[test] - fn test_key_generation_and_retrieval() { - let tmp = TempDir::new().unwrap(); - let mut km = KeyManager::new(tmp.path()); - - km.init("test-passphrase").unwrap(); - - let id = km - .generate( - KeyAlgorithm::Aes256Gcm, - KeyPurpose::Encryption, - Some("Test key".to_string()), - None, - ) - .unwrap(); - - let meta = km.get(id).unwrap(); - assert_eq!(meta.id, id); - assert_eq!(meta.algorithm, KeyAlgorithm::Aes256Gcm); - assert_eq!(meta.purpose, KeyPurpose::Encryption); - assert_eq!(meta.state, KeyState::Active); - - let key = km.retrieve(id).unwrap(); - assert_eq!(key.as_bytes().len(), 32); - } - - #[test] - fn test_key_rotation() { - let tmp = TempDir::new().unwrap(); - let mut km = KeyManager::new(tmp.path()); - - km.init("test-passphrase").unwrap(); - - let old_id = km - .generate(KeyAlgorithm::Aes256Gcm, KeyPurpose::Encryption, None, None) - .unwrap(); - - let new_id = km.rotate(old_id).unwrap(); - - let old_meta = km.get(old_id).unwrap(); - let new_meta = km.get(new_id).unwrap(); - - assert_eq!(old_meta.state, KeyState::Revoked); - assert_eq!(new_meta.state, KeyState::Active); - assert_eq!(new_meta.rotation_of, Some(old_id)); - } - - #[test] - fn test_wrong_passphrase() { - let tmp = TempDir::new().unwrap(); - let mut km = KeyManager::new(tmp.path()); - - km.init("correct-passphrase").unwrap(); - - // Generate a key so we have something to verify against - km.generate(KeyAlgorithm::Aes256Gcm, KeyPurpose::Encryption, None, None) - .unwrap(); - - // Re-open with wrong passphrase - let mut km2 = KeyManager::new(tmp.path()); - let result = km2.unlock("wrong-passphrase"); - assert!(matches!(result, Err(KeyError::InvalidPassphrase))); - } -} diff --git a/src/januskey/src/keys_cli.rs b/src/januskey/src/keys_cli.rs deleted file mode 100644 index 4bbf3c5..0000000 --- a/src/januskey/src/keys_cli.rs +++ /dev/null @@ -1,654 +0,0 @@ -// SPDX-License-Identifier: MPL-2.0 -// Copyright (c) Jonathan D.A. Jewell -// SPDX-FileCopyrightText: 2025 Jonathan D.A. Jewell -// -// JanusKey Key Management CLI -// Minimal CLI for cryptographic key lifecycle management - -use clap::{Parser, Subcommand}; -use colored::Colorize; -use dialoguer::{Confirm, Password}; -use std::path::PathBuf; -use uuid::Uuid; - -mod attestation; -mod keys; -use attestation::AuditEventType; -use keys::{KeyAlgorithm, KeyManager, KeyPurpose, KeyState}; - -#[derive(Parser)] -#[command(name = "jk-keys")] -#[command(about = "JanusKey cryptographic key management")] -#[command(version)] -struct Cli { - /// Working directory (defaults to current) - #[arg(short, long, global = true)] - dir: Option, - - #[command(subcommand)] - command: Commands, -} - -#[derive(Subcommand)] -enum Commands { - /// Initialize a new key store - Init { - /// Skip recovery key generation - #[arg(long)] - no_recovery: bool, - }, - - /// List all keys in the store - List { - /// Show only active keys - #[arg(long)] - active: bool, - }, - - /// Generate a new key - Generate { - /// Key type: aes256, ed25519, x25519 - #[arg(short, long, default_value = "aes256")] - r#type: String, - - /// Key purpose: encryption, signing, keywrap, recovery - #[arg(short, long, default_value = "encryption")] - purpose: String, - - /// Description for the key - #[arg(short, long)] - description: Option, - - /// Expiration in days - #[arg(short, long)] - expires: Option, - }, - - /// Show details for a specific key - Show { - /// Key ID (UUID) - key_id: Uuid, - }, - - /// Rotate a key (generate new, revoke old) - Rotate { - /// Key ID to rotate - key_id: Uuid, - }, - - /// Revoke a key - Revoke { - /// Key ID to revoke - key_id: Uuid, - - /// Skip confirmation - #[arg(short, long)] - force: bool, - }, - - /// Create encrypted backup of key store - Backup { - /// Output path for backup file - #[arg(short, long)] - output: PathBuf, - }, - - /// Show key store status - Status, - - /// View audit log - Audit { - #[command(subcommand)] - command: AuditCommands, - }, -} - -#[derive(Subcommand)] -enum AuditCommands { - /// Show recent audit entries - Show { - /// Number of entries to show - #[arg(short, long, default_value = "20")] - limit: usize, - }, - - /// Show audit history for a specific key - History { - /// Key ID (UUID) - key_id: Uuid, - }, - - /// Verify audit log integrity - Verify, - - /// Export audit log to JSON - Export { - /// Output path for JSON file - #[arg(short, long)] - output: PathBuf, - }, -} - -fn main() { - if let Err(e) = run() { - eprintln!("{} {}", "error:".red().bold(), e); - std::process::exit(1); - } -} - -fn run() -> Result<(), Box> { - let cli = Cli::parse(); - - let dir = cli - .dir - .unwrap_or_else(|| std::env::current_dir().expect("Cannot get current directory")); - - let mut km = KeyManager::new(&dir); - - match cli.command { - Commands::Init { no_recovery } => cmd_init(&mut km, no_recovery)?, - Commands::List { active } => cmd_list(&mut km, active)?, - Commands::Generate { - r#type, - purpose, - description, - expires, - } => cmd_generate(&mut km, &r#type, &purpose, description, expires)?, - Commands::Show { key_id } => cmd_show(&mut km, key_id)?, - Commands::Rotate { key_id } => cmd_rotate(&mut km, key_id)?, - Commands::Revoke { force, key_id } => cmd_revoke(&mut km, key_id, force)?, - Commands::Backup { output } => cmd_backup(&mut km, &output)?, - Commands::Status => cmd_status(&km)?, - Commands::Audit { command } => match command { - AuditCommands::Show { limit } => cmd_audit_show(&mut km, limit)?, - AuditCommands::History { key_id } => cmd_audit_history(&mut km, key_id)?, - AuditCommands::Verify => cmd_audit_verify(&mut km)?, - AuditCommands::Export { output } => cmd_audit_export(&mut km, &output)?, - }, - } - - Ok(()) -} - -fn cmd_init(km: &mut KeyManager, _no_recovery: bool) -> Result<(), Box> { - if km.is_initialized() { - return Err("Key store already initialized".into()); - } - - println!("{}", "Initializing JanusKey key store...".cyan()); - println!(); - println!( - "{}", - "IMPORTANT: Choose a strong passphrase to protect your keys.".yellow() - ); - println!( - "{}", - "This passphrase is required to unlock the key store.".yellow() - ); - println!(); - - let passphrase = Password::new() - .with_prompt("Enter passphrase") - .with_confirmation("Confirm passphrase", "Passphrases do not match") - .interact()?; - - if passphrase.len() < 8 { - return Err("Passphrase must be at least 8 characters".into()); - } - - km.init(&passphrase)?; - - println!(); - println!("{}", "✓ Key store initialized successfully".green()); - println!(); - println!("Location: {}/.januskey/keys/", std::env::current_dir()?.display()); - println!(); - println!("{}", "Next steps:".cyan()); - println!(" • Generate a key: jk-keys generate --type aes256 --purpose encryption"); - println!(" • List keys: jk-keys list"); - println!(" • Create backup: jk-keys backup --output ~/keys-backup.jks"); - - Ok(()) -} - -fn cmd_list(km: &mut KeyManager, active_only: bool) -> Result<(), Box> { - unlock_store(km)?; - - let keys = km.list()?; - - if keys.is_empty() { - println!("{}", "No keys in store. Generate one with: jk-keys generate".yellow()); - return Ok(()); - } - - let filtered: Vec<_> = if active_only { - keys.into_iter().filter(|k| k.state == KeyState::Active).collect() - } else { - keys - }; - - println!("{}", "Keys in store:".cyan().bold()); - println!(); - println!( - "{:<38} {:<12} {:<12} {:<10} {}", - "ID".bold(), - "Algorithm".bold(), - "Purpose".bold(), - "State".bold(), - "Fingerprint".bold() - ); - println!("{}", "-".repeat(90)); - - for key in filtered { - let state_str = match key.state { - KeyState::Active => key.state.to_string().green(), - KeyState::Revoked => key.state.to_string().red(), - KeyState::Rotating => key.state.to_string().yellow(), - _ => key.state.to_string().normal(), - }; - - println!( - "{:<38} {:<12} {:<12} {:<10} {}", - key.id.to_string().dimmed(), - key.algorithm.to_string(), - key.purpose.to_string(), - state_str, - key.fingerprint.cyan() - ); - } - - Ok(()) -} - -fn cmd_generate( - km: &mut KeyManager, - key_type: &str, - purpose: &str, - description: Option, - expires: Option, -) -> Result<(), Box> { - unlock_store(km)?; - - let algorithm = match key_type.to_lowercase().as_str() { - "aes256" | "aes-256" | "aes256gcm" => KeyAlgorithm::Aes256Gcm, - "ed25519" => KeyAlgorithm::Ed25519, - "x25519" => KeyAlgorithm::X25519, - _ => return Err(format!("Unknown key type: {}. Use: aes256, ed25519, x25519", key_type).into()), - }; - - let key_purpose = match purpose.to_lowercase().as_str() { - "encryption" | "encrypt" => KeyPurpose::Encryption, - "signing" | "sign" => KeyPurpose::Signing, - "keywrap" | "key-wrap" | "wrap" => KeyPurpose::KeyWrap, - "recovery" => KeyPurpose::Recovery, - _ => return Err(format!("Unknown purpose: {}. Use: encryption, signing, keywrap, recovery", purpose).into()), - }; - - println!("{}", "Generating key...".cyan()); - - let id = km.generate(algorithm, key_purpose, description.clone(), expires)?; - let meta = km.get(id)?; - - println!(); - println!("{}", "✓ Key generated successfully".green()); - println!(); - println!(" ID: {}", id.to_string().cyan()); - println!(" Algorithm: {}", meta.algorithm); - println!(" Purpose: {}", meta.purpose); - println!(" Fingerprint: {}", meta.fingerprint.cyan()); - if let Some(desc) = description { - println!(" Description: {}", desc); - } - if let Some(exp) = meta.expires_at { - println!(" Expires: {}", exp.format("%Y-%m-%d")); - } - - Ok(()) -} - -fn cmd_show(km: &mut KeyManager, key_id: Uuid) -> Result<(), Box> { - unlock_store(km)?; - - let meta = km.get(key_id)?; - - println!("{}", "Key Details:".cyan().bold()); - println!(); - println!(" ID: {}", meta.id); - println!(" Algorithm: {}", meta.algorithm); - println!(" Purpose: {}", meta.purpose); - println!(" State: {}", format_state(meta.state)); - println!(" Fingerprint: {}", meta.fingerprint.cyan()); - println!(" Created: {}", meta.created_at.format("%Y-%m-%d %H:%M:%S UTC")); - - if let Some(exp) = meta.expires_at { - let now = chrono::Utc::now(); - let status = if exp < now { - " (EXPIRED)".red() - } else { - "".normal() - }; - println!(" Expires: {}{}", exp.format("%Y-%m-%d %H:%M:%S UTC"), status); - } - - if let Some(rot) = meta.rotation_of { - println!(" Rotated from: {}", rot.to_string().dimmed()); - } - - if let Some(desc) = meta.description { - println!(" Description: {}", desc); - } - - Ok(()) -} - -fn cmd_rotate(km: &mut KeyManager, key_id: Uuid) -> Result<(), Box> { - unlock_store(km)?; - - let old_meta = km.get(key_id)?; - - if old_meta.state == KeyState::Revoked { - return Err("Cannot rotate a revoked key".into()); - } - - println!("{}", "Rotating key...".cyan()); - println!(" Old key: {} ({})", key_id, old_meta.fingerprint); - - let new_id = km.rotate(key_id)?; - let new_meta = km.get(new_id)?; - - println!(); - println!("{}", "✓ Key rotated successfully".green()); - println!(); - println!(" New key ID: {}", new_id.to_string().cyan()); - println!(" Fingerprint: {}", new_meta.fingerprint.cyan()); - println!(" Old key state: {}", "revoked".red()); - println!(); - println!( - "{}", - "Note: The old key can no longer be used for new operations.".yellow() - ); - - Ok(()) -} - -fn cmd_revoke( - km: &mut KeyManager, - key_id: Uuid, - force: bool, -) -> Result<(), Box> { - unlock_store(km)?; - - let meta = km.get(key_id)?; - - if meta.state == KeyState::Revoked { - return Err("Key is already revoked".into()); - } - - if !force { - println!("{}", "WARNING: Revoking a key is permanent!".red().bold()); - println!(); - println!("Key to revoke:"); - println!(" ID: {}", key_id); - println!(" Algorithm: {}", meta.algorithm); - println!(" Fingerprint: {}", meta.fingerprint); - println!(); - - let confirm = Confirm::new() - .with_prompt("Are you sure you want to revoke this key?") - .default(false) - .interact()?; - - if !confirm { - println!("{}", "Aborted.".yellow()); - return Ok(()); - } - } - - km.revoke(key_id)?; - - println!(); - println!("{}", "✓ Key revoked".green()); - - Ok(()) -} - -fn cmd_backup(km: &mut KeyManager, output: &PathBuf) -> Result<(), Box> { - unlock_store(km)?; - - if output.exists() { - let confirm = Confirm::new() - .with_prompt(format!( - "File {} already exists. Overwrite?", - output.display() - )) - .default(false) - .interact()?; - - if !confirm { - println!("{}", "Aborted.".yellow()); - return Ok(()); - } - } - - km.backup(output)?; - - println!("{}", "✓ Backup created successfully".green()); - println!(); - println!(" Location: {}", output.display()); - println!(); - println!( - "{}", - "Store this backup in a secure location separate from your main system.".yellow() - ); - - Ok(()) -} - -fn cmd_status(km: &KeyManager) -> Result<(), Box> { - println!("{}", "Key Store Status:".cyan().bold()); - println!(); - - if km.is_initialized() { - println!(" Initialized: {}", "yes".green()); - - // Try to get key count without unlocking (just check file exists) - println!( - " Store path: {}/.januskey/keys/keystore.jks", - std::env::current_dir()?.display() - ); - } else { - println!(" Initialized: {}", "no".red()); - println!(); - println!("Run 'jk-keys init' to create a key store."); - } - - Ok(()) -} - -fn unlock_store(km: &mut KeyManager) -> Result<(), Box> { - if !km.is_initialized() { - return Err("Key store not initialized. Run 'jk-keys init' first.".into()); - } - - let passphrase = Password::new() - .with_prompt("Enter passphrase") - .interact()?; - - km.unlock(&passphrase)?; - Ok(()) -} - -fn format_state(state: KeyState) -> colored::ColoredString { - match state { - KeyState::Active => "active".green(), - KeyState::Revoked => "revoked".red(), - KeyState::Rotating => "rotating".yellow(), - KeyState::Suspended => "suspended".yellow(), - KeyState::Generated => "generated".normal(), - KeyState::Obliterated => "obliterated".red().bold(), - } -} - -fn format_event_type(event_type: AuditEventType) -> colored::ColoredString { - match event_type { - AuditEventType::StoreInitialized => "INIT".cyan(), - AuditEventType::StoreUnlocked => "UNLOCK".normal(), - AuditEventType::KeyGenerated => "GENERATE".green(), - AuditEventType::KeyRetrieved => "RETRIEVE".yellow(), - AuditEventType::KeyRotated => "ROTATE".blue(), - AuditEventType::KeyRevoked => "REVOKE".red(), - AuditEventType::KeyObliterated => "OBLITERATE".red().bold(), - AuditEventType::BackupCreated => "BACKUP".cyan(), - AuditEventType::BackupRestored => "RESTORE".cyan(), - } -} - -fn cmd_audit_show(km: &mut KeyManager, limit: usize) -> Result<(), Box> { - unlock_store(km)?; - - let entries = km.audit_log().read_last_n(limit)?; - - if entries.is_empty() { - println!("{}", "No audit entries found.".yellow()); - return Ok(()); - } - - println!("{}", "Audit Log:".cyan().bold()); - println!(); - println!( - "{:<20} {:<12} {:<20} {}", - "Timestamp".bold(), - "Event".bold(), - "Actor".bold(), - "Details".bold() - ); - println!("{}", "-".repeat(80)); - - for entry in entries { - let timestamp = entry.timestamp.format("%Y-%m-%d %H:%M:%S"); - let event_str = format_event_type(entry.event_type); - let actor = if entry.actor.len() > 18 { - format!("{}...", &entry.actor[..15]) - } else { - entry.actor.clone() - }; - - let details = if let Some(ref kd) = entry.key_details { - format!("key:{}", &kd.fingerprint) - } else if let Some(ref reason) = entry.reason { - if reason.len() > 30 { - format!("{}...", &reason[..27]) - } else { - reason.clone() - } - } else { - "-".to_string() - }; - - println!( - "{:<20} {:<12} {:<20} {}", - timestamp.to_string().dimmed(), - event_str, - actor, - details.dimmed() - ); - } - - Ok(()) -} - -fn cmd_audit_history(km: &mut KeyManager, key_id: Uuid) -> Result<(), Box> { - unlock_store(km)?; - - let entries = km.audit_log().get_key_history(key_id)?; - - if entries.is_empty() { - println!("{}", format!("No audit entries found for key {}", key_id).yellow()); - return Ok(()); - } - - println!("{}", format!("Audit History for Key: {}", key_id).cyan().bold()); - println!(); - - for entry in entries { - let timestamp = entry.timestamp.format("%Y-%m-%d %H:%M:%S UTC"); - let event_str = format_event_type(entry.event_type); - - println!("{} {} by {}", timestamp.to_string().dimmed(), event_str, entry.actor); - - if let Some(ref kd) = entry.key_details { - println!(" Fingerprint: {}", kd.fingerprint.cyan()); - if let Some(old) = kd.old_state { - if let Some(new) = kd.new_state { - println!(" State: {} → {}", format_state(old), format_state(new)); - } - } - if let Some(from) = kd.rotated_from { - println!(" Rotated from: {}", from.to_string().dimmed()); - } - } - - if let Some(ref reason) = entry.reason { - println!(" Reason: {}", reason); - } - - println!(); - } - - Ok(()) -} - -fn cmd_audit_verify(km: &mut KeyManager) -> Result<(), Box> { - unlock_store(km)?; - - println!("{}", "Verifying audit log integrity...".cyan()); - - let report = km.audit_log().verify_integrity()?; - - println!(); - if report.valid { - println!("{}", "✓ Audit log integrity verified".green()); - println!(); - println!(" Total entries: {}", report.total_entries); - println!(" Chain status: {}", "intact".green()); - println!(" Attestations: {}", "valid".green()); - } else { - println!("{}", "✗ Audit log integrity check FAILED".red().bold()); - println!(); - println!(" {}", report.message.red()); - if let Some(idx) = report.first_invalid_index { - println!(" First invalid entry: {}", idx); - } - println!(); - println!( - "{}", - "WARNING: The audit log may have been tampered with!".yellow().bold() - ); - } - - Ok(()) -} - -fn cmd_audit_export(km: &mut KeyManager, output: &PathBuf) -> Result<(), Box> { - unlock_store(km)?; - - if output.exists() { - let confirm = Confirm::new() - .with_prompt(format!("File {} already exists. Overwrite?", output.display())) - .default(false) - .interact()?; - - if !confirm { - println!("{}", "Aborted.".yellow()); - return Ok(()); - } - } - - km.audit_log().export_json(output)?; - - println!("{}", "✓ Audit log exported successfully".green()); - println!(); - println!(" Location: {}", output.display()); - - Ok(()) -} diff --git a/src/januskey/src/lib.rs b/src/januskey/src/lib.rs deleted file mode 100644 index 171ae30..0000000 --- a/src/januskey/src/lib.rs +++ /dev/null @@ -1,164 +0,0 @@ -// SPDX-License-Identifier: MPL-2.0 -// Copyright (c) Jonathan D.A. Jewell -// SPDX-FileCopyrightText: 2025 Jonathan D.A. Jewell -// -// JanusKey: Reversible File Operations (formal proofs pending) -// Through Maximal Principle Reduction (MPR) - -#![forbid(unsafe_code)] -pub mod attestation; -pub mod content_store; -pub mod error; -pub mod keys; -pub mod metadata; -pub mod operations; -pub mod transaction; - -pub use attestation::{AuditEntry, AuditEventType, AuditLog, IntegrityReport, KeyEventDetails}; -pub use content_store::ContentStore; -pub use error::{JanusError, Result}; -pub use keys::{KeyAlgorithm, KeyError, KeyManager, KeyMetadata, KeyPurpose, KeyState}; -pub use metadata::{MetadataStore, OperationMetadata, OperationType}; -pub use operations::{FileOperation, OperationExecutor}; -pub use transaction::{Transaction, TransactionManager}; - -/// JanusKey configuration -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct Config { - /// Path to JanusKey metadata storage - pub storage_path: std::path::PathBuf, - /// Enable compression for stored content - pub compression: bool, - /// Maximum number of operations to keep in history - pub max_history: usize, - /// Auto-confirm dangerous operations - pub auto_confirm: bool, - /// Default to dry-run mode - pub dry_run_default: bool, - /// Enable audit trail - pub audit_enabled: bool, -} - -impl Default for Config { - fn default() -> Self { - let storage_path = dirs::data_local_dir() - .unwrap_or_else(|| std::path::PathBuf::from(".")) - .join("januskey"); - - Self { - storage_path, - compression: true, - max_history: 10000, - auto_confirm: false, - dry_run_default: false, - audit_enabled: true, - } - } -} - -impl Config { - /// Load config from directory's .januskey/config.json or use defaults - pub fn load(dir: &std::path::Path) -> Self { - let config_path = dir.join(".januskey").join("config.json"); - if config_path.exists() { - if let Ok(content) = ({ use std::io::Read; std::fs::File::open(&config_path).and_then(|mut f| { let mut buf = String::new(); f.take(10 * 1024 * 1024).read_to_string(&mut buf)?; Ok(buf) }) }) { - if let Ok(config) = serde_json::from_str(&content) { - return config; - } - } - } - Self::default() - } - - /// Save config to directory - pub fn save(&self, dir: &std::path::Path) -> Result<()> { - let config_dir = dir.join(".januskey"); - std::fs::create_dir_all(&config_dir)?; - let config_path = config_dir.join("config.json"); - let content = serde_json::to_string_pretty(self)?; - std::fs::write(config_path, content)?; - Ok(()) - } -} - -/// Main JanusKey instance for a directory -pub struct JanusKey { - /// Working directory - pub root: std::path::PathBuf, - /// Configuration - pub config: Config, - /// Content-addressed storage - pub content_store: ContentStore, - /// Metadata/operation log store - pub metadata_store: MetadataStore, - /// Transaction manager - pub transaction_manager: TransactionManager, -} - -impl JanusKey { - /// Initialize JanusKey for a directory - pub fn init(root: &std::path::Path) -> Result { - let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf()); - let jk_dir = root.join(".januskey"); - std::fs::create_dir_all(&jk_dir)?; - - let config = Config::load(&root); - config.save(&root)?; - - let content_store = ContentStore::new(jk_dir.join("content"), config.compression)?; - let metadata_store = MetadataStore::new(jk_dir.join("metadata.json"))?; - let transaction_manager = TransactionManager::new(jk_dir.join("transactions"))?; - - Ok(Self { - root, - config, - content_store, - metadata_store, - transaction_manager, - }) - } - - /// Open existing JanusKey directory - pub fn open(root: &std::path::Path) -> Result { - let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf()); - let jk_dir = root.join(".januskey"); - - if !jk_dir.exists() { - return Err(JanusError::NotInitialized(root.display().to_string())); - } - - let config = Config::load(&root); - let content_store = ContentStore::new(jk_dir.join("content"), config.compression)?; - let metadata_store = MetadataStore::new(jk_dir.join("metadata.json"))?; - let transaction_manager = TransactionManager::new(jk_dir.join("transactions"))?; - - Ok(Self { - root, - config, - content_store, - metadata_store, - transaction_manager, - }) - } - - /// Check if directory is initialized - pub fn is_initialized(root: &std::path::Path) -> bool { - root.join(".januskey").exists() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::TempDir; - - #[test] - fn test_init_and_open() { - let tmp = TempDir::new().unwrap(); - let jk = JanusKey::init(tmp.path()).unwrap(); - assert!(JanusKey::is_initialized(tmp.path())); - - let jk2 = JanusKey::open(tmp.path()).unwrap(); - assert_eq!(jk.root, jk2.root); - } -} diff --git a/src/januskey/src/main.rs b/src/januskey/src/main.rs deleted file mode 100644 index 0802977..0000000 --- a/src/januskey/src/main.rs +++ /dev/null @@ -1,835 +0,0 @@ -// SPDX-License-Identifier: MPL-2.0 -// Copyright (c) Jonathan D.A. Jewell -// SPDX-FileCopyrightText: 2025 Jonathan D.A. Jewell -// -// JanusKey CLI: Reversible File Operations (formal proofs pending) -// "Never lose data again" - -use anyhow::{Context, Result}; -use clap::{Parser, Subcommand}; -use colored::Colorize; -use dialoguer::Confirm; -use indicatif::{ProgressBar, ProgressStyle}; -use januskey::{ - operations::{FileOperation, OperationExecutor}, - transaction::TransactionPreview, - JanusKey, -}; -use std::fs; -use std::path::PathBuf; - -#[derive(Parser)] -#[command( - name = "jk", - version = "1.0.0", - author = "Jonathan D.A. Jewell ", - about = "JanusKey: Reversible file operations (formal proofs pending)", - long_about = "JanusKey makes every file operation reversible through Maximal Principle Reduction.\n\ - Delete files, modify content, move things around—and always be able to undo.\n\n\ - Data loss is architecturally impossible." -)] -struct Cli { - #[command(subcommand)] - command: Commands, - - /// Working directory (defaults to current directory) - #[arg(short = 'C', long, global = true)] - dir: Option, - - /// Dry run mode (don't actually make changes) - #[arg(long, global = true)] - dry_run: bool, - - /// Skip confirmation prompts - #[arg(short = 'y', long, global = true)] - yes: bool, -} - -#[derive(Subcommand)] -enum Commands { - /// Initialize JanusKey in the current directory - Init, - - /// Delete files (reversible) - #[command(alias = "rm")] - Delete { - /// Files or glob patterns to delete - #[arg(required = true)] - paths: Vec, - - /// Delete recursively (for directories) - #[arg(short, long)] - recursive: bool, - }, - - /// Modify files with sed-like syntax or a script (reversible) - Modify { - /// Sed-like pattern (s/old/new/g) or script path - pattern: String, - - /// Files to modify - #[arg(required = true)] - paths: Vec, - }, - - /// Move or rename files (reversible) - #[command(alias = "mv")] - Move { - /// Source file(s) - source: String, - - /// Destination - destination: PathBuf, - }, - - /// Copy files (reversible - the copy can be deleted) - #[command(alias = "cp")] - Copy { - /// Source file - source: PathBuf, - - /// Destination - destination: PathBuf, - }, - - /// Rename a file (reversible) - Rename { - /// Original name - old_name: PathBuf, - - /// New name - new_name: PathBuf, - }, - - /// Undo the last operation(s) - Undo { - /// Number of operations to undo - #[arg(short, long, default_value = "1")] - count: usize, - - /// Undo a specific operation by ID - #[arg(long)] - id: Option, - }, - - /// Begin a new transaction - Begin { - /// Optional name for the transaction - name: Option, - }, - - /// Commit the current transaction - Commit, - - /// Rollback the current transaction - Rollback, - - /// Preview pending changes in current transaction - Preview, - - /// Show operation history - History { - /// Number of entries to show - #[arg(short, long, default_value = "20")] - limit: usize, - - /// Filter by operation type (DELETE, MODIFY, MOVE, COPY) - #[arg(short, long)] - filter: Option, - }, - - /// Show current status - Status, - - /// Garbage collect old operations - Gc { - /// Keep only the last N operations - #[arg(long)] - keep: Option, - - /// Delete operations older than N days - #[arg(long)] - older_than: Option, - }, -} - -fn main() -> Result<()> { - let cli = Cli::parse(); - - // Determine working directory - let working_dir = match cli.dir { - Some(dir) => dir, - None => std::env::current_dir().context("Failed to get current directory")?, - }; - - match cli.command { - Commands::Init => cmd_init(&working_dir), - Commands::Delete { paths, recursive } => { - cmd_delete(&working_dir, &paths, recursive, cli.dry_run, cli.yes) - } - Commands::Modify { pattern, paths } => { - cmd_modify(&working_dir, &pattern, &paths, cli.dry_run, cli.yes) - } - Commands::Move { source, destination } => { - cmd_move(&working_dir, &source, &destination, cli.dry_run) - } - Commands::Copy { source, destination } => { - cmd_copy(&working_dir, &source, &destination, cli.dry_run) - } - Commands::Rename { old_name, new_name } => { - cmd_move(&working_dir, &old_name.to_string_lossy(), &new_name, cli.dry_run) - } - Commands::Undo { count, id } => cmd_undo(&working_dir, count, id), - Commands::Begin { name } => cmd_begin(&working_dir, name), - Commands::Commit => cmd_commit(&working_dir), - Commands::Rollback => cmd_rollback(&working_dir), - Commands::Preview => cmd_preview(&working_dir), - Commands::History { limit, filter } => cmd_history(&working_dir, limit, filter), - Commands::Status => cmd_status(&working_dir), - Commands::Gc { keep, older_than } => cmd_gc(&working_dir, keep, older_than), - } -} - -fn cmd_init(dir: &PathBuf) -> Result<()> { - if JanusKey::is_initialized(dir) { - println!( - "{} JanusKey already initialized in {}", - "✓".green(), - dir.display() - ); - return Ok(()); - } - - JanusKey::init(dir).context("Failed to initialize JanusKey")?; - println!( - "{} JanusKey initialized in {}", - "✓".green(), - dir.display() - ); - println!(" Metadata stored in: {}/.januskey/", dir.display()); - println!("\n You can now use reversible file operations:"); - println!(" jk delete - Delete files (reversible)"); - println!(" jk modify - Modify files (reversible)"); - println!(" jk move - Move files (reversible)"); - println!(" jk undo - Undo last operation"); - Ok(()) -} - -fn cmd_delete( - dir: &PathBuf, - paths: &[String], - recursive: bool, - dry_run: bool, - auto_yes: bool, -) -> Result<()> { - let mut jk = JanusKey::open(dir).context("Failed to open JanusKey directory")?; - - // Expand glob patterns and collect files - let mut files_to_delete = Vec::new(); - for pattern in paths { - let full_pattern = dir.join(pattern); - let pattern_str = full_pattern.to_string_lossy(); - for entry in glob::glob(&pattern_str)? { - let path = entry?; - if path.is_file() { - files_to_delete.push(path); - } else if path.is_dir() && recursive { - // Collect all files in directory - for entry in walkdir::WalkDir::new(&path) { - let entry = entry?; - if entry.file_type().is_file() { - files_to_delete.push(entry.path().to_path_buf()); - } - } - } - } - } - - if files_to_delete.is_empty() { - println!("{} No files matched the pattern(s)", "!".yellow()); - return Ok(()); - } - - // Show what will be deleted - if dry_run { - println!("{} Dry run - would delete:", "[DRY RUN]".cyan()); - for file in &files_to_delete { - println!(" - {}", file.display()); - } - return Ok(()); - } - - // Confirm if many files - if files_to_delete.len() > 10 && !auto_yes { - println!( - "{} This will delete {} files:", - "⚠".yellow(), - files_to_delete.len() - ); - for file in files_to_delete.iter().take(5) { - println!(" - {}", file.display()); - } - if files_to_delete.len() > 5 { - println!(" ... and {} more", files_to_delete.len() - 5); - } - if !Confirm::new() - .with_prompt("Continue?") - .default(false) - .interact()? - { - println!("{}", "Cancelled".red()); - return Ok(()); - } - } - - let transaction_id = jk.transaction_manager.active_id().map(String::from); - - // Progress bar for multiple files - let progress = if files_to_delete.len() > 1 { - let pb = ProgressBar::new(files_to_delete.len() as u64); - pb.set_style( - ProgressStyle::default_bar() - .template("{spinner:.green} [{bar:40.cyan/blue}] {pos}/{len} {msg}") - .expect("invariant: progress bar template is valid at compile-time") - .progress_chars("#>-"), - ); - Some(pb) - } else { - None - }; - - let mut deleted_count = 0; - for path in &files_to_delete { - let mut executor = OperationExecutor::new(&jk.content_store, &mut jk.metadata_store); - if let Some(ref tid) = transaction_id { - executor = executor.with_transaction(tid.clone()); - } - - match executor.execute(FileOperation::Delete { path: path.clone() }) { - Ok(meta) => { - deleted_count += 1; - if let Some(ref pb) = progress { - pb.inc(1); - pb.set_message(format!("{}", path.file_name().unwrap_or_default().to_string_lossy())); - } - // Record in transaction if active - if transaction_id.is_some() { - jk.transaction_manager.add_operation(meta.id)?; - } - } - Err(e) => { - eprintln!("{} Failed to delete {}: {}", "✗".red(), path.display(), e); - } - } - } - - if let Some(pb) = progress { - pb.finish_and_clear(); - } - - println!( - "{} Deleted {} file(s)", - "✓".green(), - deleted_count - ); - println!(" Use {} to restore", "jk undo".cyan()); - - Ok(()) -} - -fn cmd_modify( - dir: &PathBuf, - pattern: &str, - paths: &[String], - dry_run: bool, - auto_yes: bool, -) -> Result<()> { - let mut jk = JanusKey::open(dir).context("Failed to open JanusKey directory")?; - - // Parse sed-like pattern: s/old/new/g - let (search, replace, global) = parse_sed_pattern(pattern)?; - - // Expand glob patterns - let mut files = Vec::new(); - for p in paths { - let full_pattern = dir.join(p); - let pattern_str = full_pattern.to_string_lossy(); - for entry in glob::glob(&pattern_str)? { - let path = entry?; - if path.is_file() { - files.push(path); - } - } - } - - if files.is_empty() { - println!("{} No files matched the pattern(s)", "!".yellow()); - return Ok(()); - } - - // Preview changes - let mut changes = Vec::new(); - for file in &files { - let content = ({ use std::io::Read; std::fs::File::open(file).and_then(|mut f| { let mut buf = String::new(); f.take(10 * 1024 * 1024).read_to_string(&mut buf)?; Ok(buf) }) })?; - let new_content = if global { - content.replace(&search, &replace) - } else { - content.replacen(&search, &replace, 1) - }; - if content != new_content { - changes.push((file.clone(), new_content)); - } - } - - if changes.is_empty() { - println!("{} No changes would be made", "!".yellow()); - return Ok(()); - } - - if dry_run { - println!("{} Dry run - would modify:", "[DRY RUN]".cyan()); - for (file, _) in &changes { - println!(" - {}", file.display()); - } - return Ok(()); - } - - // Confirm - if changes.len() > 5 && !auto_yes { - println!( - "{} This will modify {} files", - "⚠".yellow(), - changes.len() - ); - if !Confirm::new() - .with_prompt("Continue?") - .default(false) - .interact()? - { - println!("{}", "Cancelled".red()); - return Ok(()); - } - } - - let transaction_id = jk.transaction_manager.active_id().map(String::from); - - for (file, new_content) in changes { - let mut executor = OperationExecutor::new(&jk.content_store, &mut jk.metadata_store); - if let Some(ref tid) = transaction_id { - executor = executor.with_transaction(tid.clone()); - } - - match executor.execute(FileOperation::Modify { - path: file.clone(), - new_content: new_content.into_bytes(), - }) { - Ok(meta) => { - println!(" {} {}", "✓".green(), file.display()); - if transaction_id.is_some() { - jk.transaction_manager.add_operation(meta.id)?; - } - } - Err(e) => { - eprintln!(" {} {}: {}", "✗".red(), file.display(), e); - } - } - } - - println!(" Use {} to restore original content", "jk undo".cyan()); - - Ok(()) -} - -fn parse_sed_pattern(pattern: &str) -> Result<(String, String, bool)> { - // Parse s/old/new/g pattern - if !pattern.starts_with("s/") { - anyhow::bail!("Pattern must be in format: s/search/replace/[g]"); - } - - let rest = &pattern[2..]; - let parts: Vec<&str> = rest.split('/').collect(); - - if parts.len() < 2 { - anyhow::bail!("Invalid pattern format. Use: s/search/replace/[g]"); - } - - let search = parts[0].to_string(); - let replace = parts[1].to_string(); - let global = parts.get(2).map_or(false, |f| f.contains('g')); - - Ok((search, replace, global)) -} - -fn cmd_move(dir: &PathBuf, source: &str, destination: &PathBuf, dry_run: bool) -> Result<()> { - let mut jk = JanusKey::open(dir).context("Failed to open JanusKey directory")?; - - let source_path = if PathBuf::from(source).is_absolute() { - PathBuf::from(source) - } else { - dir.join(source) - }; - - let dest_path = if destination.is_absolute() { - destination.clone() - } else { - dir.join(destination) - }; - - if dry_run { - println!( - "{} Would move {} -> {}", - "[DRY RUN]".cyan(), - source_path.display(), - dest_path.display() - ); - return Ok(()); - } - - let transaction_id = jk.transaction_manager.active_id().map(String::from); - let mut executor = OperationExecutor::new(&jk.content_store, &mut jk.metadata_store); - if let Some(ref tid) = transaction_id { - executor = executor.with_transaction(tid.clone()); - } - - let meta = executor.execute(FileOperation::Move { - source: source_path.clone(), - destination: dest_path.clone(), - })?; - - if transaction_id.is_some() { - jk.transaction_manager.add_operation(meta.id)?; - } - - println!( - "{} Moved {} -> {}", - "✓".green(), - source_path.display(), - dest_path.display() - ); - println!(" Use {} to move back", "jk undo".cyan()); - - Ok(()) -} - -fn cmd_copy(dir: &PathBuf, source: &PathBuf, destination: &PathBuf, dry_run: bool) -> Result<()> { - let mut jk = JanusKey::open(dir).context("Failed to open JanusKey directory")?; - - let source_path = if source.is_absolute() { - source.clone() - } else { - dir.join(source) - }; - - let dest_path = if destination.is_absolute() { - destination.clone() - } else { - dir.join(destination) - }; - - if dry_run { - println!( - "{} Would copy {} -> {}", - "[DRY RUN]".cyan(), - source_path.display(), - dest_path.display() - ); - return Ok(()); - } - - let transaction_id = jk.transaction_manager.active_id().map(String::from); - let mut executor = OperationExecutor::new(&jk.content_store, &mut jk.metadata_store); - if let Some(ref tid) = transaction_id { - executor = executor.with_transaction(tid.clone()); - } - - let meta = executor.execute(FileOperation::Copy { - source: source_path.clone(), - destination: dest_path.clone(), - })?; - - if transaction_id.is_some() { - jk.transaction_manager.add_operation(meta.id)?; - } - - println!( - "{} Copied {} -> {}", - "✓".green(), - source_path.display(), - dest_path.display() - ); - println!(" Use {} to delete the copy", "jk undo".cyan()); - - Ok(()) -} - -fn cmd_undo(dir: &PathBuf, count: usize, id: Option) -> Result<()> { - let mut jk = JanusKey::open(dir).context("Failed to open JanusKey directory")?; - - if let Some(op_id) = id { - // Undo specific operation - let mut executor = OperationExecutor::new(&jk.content_store, &mut jk.metadata_store); - let meta = executor.undo(&op_id)?; - println!( - "{} Undid {} on {}", - "✓".green(), - meta.op_type, - meta.path.display() - ); - } else { - // Undo last N operations - let ops_to_undo: Vec<_> = jk.metadata_store.last_n(count).into_iter().cloned().collect(); - - if ops_to_undo.is_empty() { - println!("{} Nothing to undo", "!".yellow()); - return Ok(()); - } - - for op in ops_to_undo { - let mut executor = OperationExecutor::new(&jk.content_store, &mut jk.metadata_store); - match executor.undo(&op.id) { - Ok(_) => { - println!( - "{} Undid {} on {}", - "✓".green(), - op.op_type, - op.path.display() - ); - } - Err(e) => { - eprintln!( - "{} Failed to undo {} on {}: {}", - "✗".red(), - op.op_type, - op.path.display(), - e - ); - } - } - } - } - - Ok(()) -} - -fn cmd_begin(dir: &PathBuf, name: Option) -> Result<()> { - let mut jk = JanusKey::open(dir).context("Failed to open JanusKey directory")?; - - let tx = jk.transaction_manager.begin(name.clone())?; - let display_name = name.unwrap_or_else(|| tx.id[..8].to_string()); - println!( - "{} Started transaction: {}", - "✓".green(), - display_name.cyan() - ); - println!(" Run operations, then use {} or {}", "jk commit".cyan(), "jk rollback".cyan()); - - Ok(()) -} - -fn cmd_commit(dir: &PathBuf) -> Result<()> { - let mut jk = JanusKey::open(dir).context("Failed to open JanusKey directory")?; - - let tx = jk.transaction_manager.commit()?; - let display_name = tx.name.unwrap_or_else(|| tx.id[..8].to_string()); - println!( - "{} Committed transaction: {} ({} operations)", - "✓".green(), - display_name.cyan(), - tx.operation_ids.len() - ); - - Ok(()) -} - -fn cmd_rollback(dir: &PathBuf) -> Result<()> { - let mut jk = JanusKey::open(dir).context("Failed to open JanusKey directory")?; - - let tx = jk - .transaction_manager - .rollback(&jk.content_store, &mut jk.metadata_store)?; - let display_name = tx.name.unwrap_or_else(|| tx.id[..8].to_string()); - println!( - "{} Rolled back transaction: {} ({} operations undone)", - "✓".green(), - display_name.cyan(), - tx.operation_ids.len() - ); - - Ok(()) -} - -fn cmd_preview(dir: &PathBuf) -> Result<()> { - let jk = JanusKey::open(dir).context("Failed to open JanusKey directory")?; - - let tx = jk - .transaction_manager - .active() - .ok_or_else(|| anyhow::anyhow!("No active transaction"))?; - - let preview = TransactionPreview::from_transaction(tx, &jk.metadata_store); - - let name = preview.transaction_name.unwrap_or_else(|| tx.id[..8].to_string()); - println!("{} Transaction: {}", "📋".to_string(), name.cyan()); - println!("Operations pending: {}", preview.operations.len()); - println!(); - - for op in &preview.operations { - let arrow = if op.secondary_path.is_some() { " → " } else { "" }; - let secondary = op - .secondary_path - .as_ref() - .map(|p| p.display().to_string()) - .unwrap_or_default(); - - println!( - " {} {} {}{}{}", - match op.op_type.as_str() { - "DELETE" => "🗑".to_string(), - "MODIFY" => "✏".to_string(), - "MOVE" => "📦".to_string(), - "COPY" => "📄".to_string(), - _ => "•".to_string(), - }, - op.op_type.yellow(), - op.path.display(), - arrow, - secondary - ); - } - - println!(); - println!("Total files affected: {}", preview.total_files_affected); - println!(); - println!( - "Use {} to apply or {} to cancel", - "jk commit".cyan(), - "jk rollback".cyan() - ); - - Ok(()) -} - -fn cmd_history(dir: &PathBuf, limit: usize, filter: Option) -> Result<()> { - let jk = JanusKey::open(dir).context("Failed to open JanusKey directory")?; - - let ops: Vec<_> = if let Some(ref filter_str) = filter { - let filter_upper = filter_str.to_uppercase(); - jk.metadata_store - .operations() - .iter() - .filter(|op| op.op_type.to_string() == filter_upper) - .rev() - .take(limit) - .collect() - } else { - jk.metadata_store.operations().iter().rev().take(limit).collect() - }; - - if ops.is_empty() { - println!("{} No operations in history", "!".yellow()); - return Ok(()); - } - - println!("{}", "Operation History".bold()); - println!("{}", "─".repeat(70)); - - for op in ops { - let status = if op.undone { - "[UNDONE]".dimmed() - } else { - "".normal() - }; - - let time = op.timestamp.format("%Y-%m-%d %H:%M:%S"); - let op_type = match op.op_type.to_string().as_str() { - "DELETE" => "DELETE".red(), - "MODIFY" => "MODIFY".yellow(), - "MOVE" => "MOVE".blue(), - "COPY" => "COPY".cyan(), - "CREATE" => "CREATE".green(), - other => other.normal(), - }; - - println!( - "{} | {:8} | {} | {} {}", - time, - op_type, - op.path.display(), - op.user.dimmed(), - status - ); - } - - println!("{}", "─".repeat(70)); - println!("Total: {} operations", jk.metadata_store.count()); - - Ok(()) -} - -fn cmd_status(dir: &PathBuf) -> Result<()> { - let jk = JanusKey::open(dir).context("Failed to open JanusKey directory")?; - - println!("{}", "JanusKey Status".bold()); - println!("{}", "─".repeat(40)); - println!("Directory: {}", dir.display()); - println!("Operations logged: {}", jk.metadata_store.count()); - println!( - "Content store: {} blobs ({} bytes)", - jk.content_store.count()?, - human_bytes(jk.content_store.total_size()?) - ); - - if let Some(tx) = jk.transaction_manager.active() { - let name = tx.name.clone().unwrap_or_else(|| tx.id[..8].to_string()); - println!(); - println!( - "{} Active transaction: {}", - "📝".to_string(), - name.cyan() - ); - println!(" Started: {}", tx.started_at.format("%Y-%m-%d %H:%M:%S")); - println!(" Operations: {}", tx.operation_ids.len()); - } else { - println!(); - println!("No active transaction"); - } - - Ok(()) -} - -fn cmd_gc(dir: &PathBuf, keep: Option, _older_than: Option) -> Result<()> { - let mut jk = JanusKey::open(dir).context("Failed to open JanusKey directory")?; - - let keep_count = keep.unwrap_or(jk.config.max_history); - - let pruned = jk.metadata_store.prune(keep_count)?; - - if pruned > 0 { - println!( - "{} Pruned {} old operations (keeping last {})", - "✓".green(), - pruned, - keep_count - ); - } else { - println!("{} Nothing to prune", "✓".green()); - } - - Ok(()) -} - -fn human_bytes(bytes: u64) -> String { - const KB: u64 = 1024; - const MB: u64 = KB * 1024; - const GB: u64 = MB * 1024; - - if bytes >= GB { - format!("{:.2} GB", bytes as f64 / GB as f64) - } else if bytes >= MB { - format!("{:.2} MB", bytes as f64 / MB as f64) - } else if bytes >= KB { - format!("{:.2} KB", bytes as f64 / KB as f64) - } else { - format!("{} bytes", bytes) - } -} diff --git a/src/januskey/src/metadata.rs b/src/januskey/src/metadata.rs deleted file mode 100644 index ae9d1e4..0000000 --- a/src/januskey/src/metadata.rs +++ /dev/null @@ -1,393 +0,0 @@ -// SPDX-License-Identifier: MPL-2.0 -// Copyright (c) Jonathan D.A. Jewell -// SPDX-FileCopyrightText: 2025 Jonathan D.A. Jewell -// -// Metadata Store: Operation log with complete reverse information -// Implements the formal model from the JanusKey white paper - -use crate::content_store::ContentHash; -use crate::error::{JanusError, Result}; -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; -use std::fs; -use std::path::{Path, PathBuf}; -use uuid::Uuid; - -/// Operation type identifier -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "UPPERCASE")] -pub enum OperationType { - Delete, - Modify, - Move, - Copy, - Chmod, - Chown, - Create, -} - -impl std::fmt::Display for OperationType { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Delete => write!(f, "DELETE"), - Self::Modify => write!(f, "MODIFY"), - Self::Move => write!(f, "MOVE"), - Self::Copy => write!(f, "COPY"), - Self::Chmod => write!(f, "CHMOD"), - Self::Chown => write!(f, "CHOWN"), - Self::Create => write!(f, "CREATE"), - } - } -} - -/// File metadata (permissions, timestamps, owner) -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct FileMetadata { - /// Unix permissions (e.g., 0o644) - pub permissions: u32, - /// File owner (username or uid) - pub owner: String, - /// File group (groupname or gid) - pub group: String, - /// Original file size - pub size: u64, - /// Last modification time - pub modified: DateTime, - /// Is this a symbolic link? - pub is_symlink: bool, - /// Symlink target if is_symlink - pub symlink_target: Option, -} - -impl FileMetadata { - /// Capture metadata from a file path - pub fn from_path(path: &Path) -> Result { - let metadata = fs::symlink_metadata(path)?; - - #[cfg(unix)] - let (permissions, owner, group) = { - use std::os::unix::fs::MetadataExt; - ( - metadata.mode(), - metadata.uid().to_string(), - metadata.gid().to_string(), - ) - }; - - #[cfg(not(unix))] - let (permissions, owner, group) = (0o644, "unknown".to_string(), "unknown".to_string()); - - let is_symlink = metadata.file_type().is_symlink(); - let symlink_target = if is_symlink { - fs::read_link(path) - .ok() - .map(|p| p.to_string_lossy().to_string()) - } else { - None - }; - - Ok(Self { - permissions, - owner, - group, - size: metadata.len(), - modified: DateTime::from(metadata.modified()?), - is_symlink, - symlink_target, - }) - } - - /// Apply metadata to a file - #[cfg(unix)] - pub fn apply(&self, path: &Path) -> Result<()> { - use std::os::unix::fs::PermissionsExt; - let perms = fs::Permissions::from_mode(self.permissions); - fs::set_permissions(path, perms)?; - Ok(()) - } - - #[cfg(not(unix))] - pub fn apply(&self, _path: &Path) -> Result<()> { - Ok(()) - } -} - -/// Complete metadata for an operation (sufficient for reversal) -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct OperationMetadata { - /// Unique operation ID - pub id: String, - /// Operation type - pub op_type: OperationType, - /// When the operation occurred - pub timestamp: DateTime, - /// User who performed the operation - pub user: String, - /// Primary path affected - pub path: PathBuf, - /// Secondary path (for move/copy operations) - pub path_secondary: Option, - /// Hash of original content (for delete/modify) - pub content_hash: Option, - /// Hash of new content (for modify) - pub new_content_hash: Option, - /// Original file metadata - pub original_metadata: Option, - /// New metadata (for chmod/chown) - pub new_metadata: Option, - /// Transaction ID if part of a transaction - pub transaction_id: Option, - /// Whether this operation has been undone - pub undone: bool, - /// ID of the undo operation (if undone) - pub undo_operation_id: Option, -} - -impl OperationMetadata { - /// Create new operation metadata with generated ID - pub fn new(op_type: OperationType, path: PathBuf) -> Self { - Self { - id: Uuid::new_v4().to_string(), - op_type, - timestamp: Utc::now(), - user: whoami::username(), - path, - path_secondary: None, - content_hash: None, - new_content_hash: None, - original_metadata: None, - new_metadata: None, - transaction_id: None, - undone: false, - undo_operation_id: None, - } - } - - /// Builder pattern: set secondary path - pub fn with_secondary_path(mut self, path: PathBuf) -> Self { - self.path_secondary = Some(path); - self - } - - /// Builder pattern: set content hash - pub fn with_content_hash(mut self, hash: ContentHash) -> Self { - self.content_hash = Some(hash); - self - } - - /// Builder pattern: set new content hash - pub fn with_new_content_hash(mut self, hash: ContentHash) -> Self { - self.new_content_hash = Some(hash); - self - } - - /// Builder pattern: set original metadata - pub fn with_original_metadata(mut self, metadata: FileMetadata) -> Self { - self.original_metadata = Some(metadata); - self - } - - /// Builder pattern: set transaction ID - pub fn with_transaction_id(mut self, id: String) -> Self { - self.transaction_id = Some(id); - self - } -} - -/// Operation log data structure -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct OperationLog { - /// Version for format compatibility - pub version: String, - /// List of all operations (append-only) - pub operations: Vec, -} - -impl Default for OperationLog { - fn default() -> Self { - Self { - version: "1.0".to_string(), - operations: Vec::new(), - } - } -} - -/// Metadata store for operation logging -pub struct MetadataStore { - /// Path to the metadata file - path: PathBuf, - /// Cached operation log - log: OperationLog, -} - -impl MetadataStore { - /// Create or open a metadata store - pub fn new(path: PathBuf) -> Result { - let log = if path.exists() { - let content = ({ use std::io::Read; std::fs::File::open(&path).and_then(|mut f| { let mut buf = String::new(); f.take(10 * 1024 * 1024).read_to_string(&mut buf)?; Ok(buf) }) })?; - serde_json::from_str(&content).map_err(|e| JanusError::MetadataCorrupted(e.to_string()))? - } else { - OperationLog::default() - }; - - Ok(Self { path, log }) - } - - /// Append an operation to the log - pub fn append(&mut self, metadata: OperationMetadata) -> Result<()> { - self.log.operations.push(metadata); - self.save() - } - - /// Save the log to disk - fn save(&self) -> Result<()> { - if let Some(parent) = self.path.parent() { - fs::create_dir_all(parent)?; - } - let content = serde_json::to_string_pretty(&self.log)?; - fs::write(&self.path, content)?; - Ok(()) - } - - /// Get all operations - pub fn operations(&self) -> &[OperationMetadata] { - &self.log.operations - } - - /// Get operation by ID - pub fn get(&self, id: &str) -> Option<&OperationMetadata> { - self.log.operations.iter().find(|op| op.id == id) - } - - /// Get mutable operation by ID - pub fn get_mut(&mut self, id: &str) -> Option<&mut OperationMetadata> { - self.log.operations.iter_mut().find(|op| op.id == id) - } - - /// Get last N operations (not undone) - pub fn last_n(&self, n: usize) -> Vec<&OperationMetadata> { - self.log - .operations - .iter() - .rev() - .filter(|op| !op.undone) - .take(n) - .collect() - } - - /// Get last undoable operation - pub fn last_undoable(&self) -> Option<&OperationMetadata> { - self.log - .operations - .iter() - .rev() - .find(|op| !op.undone) - } - - /// Get operations for a transaction - pub fn transaction_operations(&self, transaction_id: &str) -> Vec<&OperationMetadata> { - self.log - .operations - .iter() - .filter(|op| op.transaction_id.as_deref() == Some(transaction_id)) - .collect() - } - - /// Mark operation as undone - pub fn mark_undone(&mut self, id: &str, undo_op_id: &str) -> Result<()> { - if let Some(op) = self.get_mut(id) { - op.undone = true; - op.undo_operation_id = Some(undo_op_id.to_string()); - self.save()?; - } - Ok(()) - } - - /// Filter operations by type - pub fn filter_by_type(&self, op_type: OperationType) -> Vec<&OperationMetadata> { - self.log - .operations - .iter() - .filter(|op| op.op_type == op_type) - .collect() - } - - /// Filter operations by path pattern - pub fn filter_by_path(&self, pattern: &str) -> Result> { - let glob_pattern = glob::Pattern::new(pattern)?; - Ok(self - .log - .operations - .iter() - .filter(|op| glob_pattern.matches_path(&op.path)) - .collect()) - } - - /// Get operation count - pub fn count(&self) -> usize { - self.log.operations.len() - } - - /// Prune old operations (keep last N) - pub fn prune(&mut self, keep: usize) -> Result { - let original_count = self.log.operations.len(); - if original_count <= keep { - return Ok(0); - } - - let to_remove = original_count - keep; - self.log.operations.drain(0..to_remove); - self.save()?; - Ok(to_remove) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::TempDir; - - #[test] - fn test_operation_metadata_creation() { - let meta = OperationMetadata::new(OperationType::Delete, PathBuf::from("/test/file.txt")); - assert!(!meta.id.is_empty()); - assert_eq!(meta.op_type, OperationType::Delete); - assert!(!meta.undone); - } - - #[test] - fn test_metadata_store() { - let tmp = TempDir::new().unwrap(); - let path = tmp.path().join("metadata.json"); - - let mut store = MetadataStore::new(path.clone()).unwrap(); - - let meta = OperationMetadata::new(OperationType::Delete, PathBuf::from("/test.txt")); - let id = meta.id.clone(); - store.append(meta).unwrap(); - - assert_eq!(store.count(), 1); - assert!(store.get(&id).is_some()); - - // Reopen and verify persistence - let store2 = MetadataStore::new(path).unwrap(); - assert_eq!(store2.count(), 1); - assert!(store2.get(&id).is_some()); - } - - #[test] - fn test_last_undoable() { - let tmp = TempDir::new().unwrap(); - let path = tmp.path().join("metadata.json"); - let mut store = MetadataStore::new(path).unwrap(); - - let meta1 = OperationMetadata::new(OperationType::Delete, PathBuf::from("/a.txt")); - let meta2 = OperationMetadata::new(OperationType::Delete, PathBuf::from("/b.txt")); - - store.append(meta1).unwrap(); - store.append(meta2).unwrap(); - - let last = store.last_undoable().unwrap(); - assert_eq!(last.path, PathBuf::from("/b.txt")); - } -} diff --git a/src/januskey/src/obliteration.rs b/src/januskey/src/obliteration.rs deleted file mode 100644 index e0d0208..0000000 --- a/src/januskey/src/obliteration.rs +++ /dev/null @@ -1,516 +0,0 @@ -// SPDX-License-Identifier: MPL-2.0 -// Copyright (c) Jonathan D.A. Jewell -// SPDX-FileCopyrightText: 2025 Jonathan D.A. Jewell -// -// RMO: Obliterative Wipe Primitive -// Implements GDPR Article 17 "Right to Erasure" with formal obliteration proofs -// -// The RMO primitive guarantees: -// 1. Content is cryptographically unrecoverable after obliteration -// 2. A proof of non-existence is generated -// 3. The fact of obliteration is logged (without content) - -use crate::content_store::{ContentHash, ContentStore}; -use crate::error::{JanusError, Result}; -use chrono::{DateTime, Utc}; -use rand::RngCore; -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; -use std::fs::{self, File, OpenOptions}; -use std::io::{Read, Seek, SeekFrom, Write}; -use std::path::{Path, PathBuf}; -use uuid::Uuid; - -/// Number of overwrite passes for secure deletion -/// Based on DoD 5220.22-M standard (3 passes minimum) -const OVERWRITE_PASSES: usize = 3; - -/// Obliteration patterns for each pass -const PATTERNS: [u8; 3] = [0x00, 0xFF, 0x00]; // zeros, ones, zeros - -/// Cryptographic proof that content has been obliterated -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ObliterationProof { - /// Unique proof identifier - pub id: String, - /// Hash of the obliterated content (proves what was deleted) - pub content_hash: ContentHash, - /// Timestamp of obliteration - pub timestamp: DateTime, - /// User who performed obliteration - pub user: String, - /// Nonce used in proof generation - pub nonce: String, - /// Cryptographic commitment: H(content_hash || nonce || timestamp) - pub commitment: String, - /// Number of overwrite passes performed - pub overwrite_passes: usize, - /// Verification that storage location no longer contains original - pub storage_cleared: bool, -} - -impl ObliterationProof { - /// Generate a new obliteration proof - pub fn generate(content_hash: &ContentHash, passes: usize) -> Self { - let id = Uuid::new_v4().to_string(); - let timestamp = Utc::now(); - let user = whoami::username(); - - // Generate random nonce - let mut nonce_bytes = [0u8; 32]; - rand::thread_rng().fill_bytes(&mut nonce_bytes); - let nonce = hex::encode(nonce_bytes); - - // Generate commitment: H(content_hash || nonce || timestamp) - let mut hasher = Sha256::new(); - hasher.update(content_hash.raw_hash().as_bytes()); - hasher.update(&nonce_bytes); - hasher.update(timestamp.to_rfc3339().as_bytes()); - let commitment = hex::encode(hasher.finalize()); - - Self { - id, - content_hash: content_hash.clone(), - timestamp, - user, - nonce, - commitment, - overwrite_passes: passes, - storage_cleared: true, - } - } - - /// Verify the proof's cryptographic commitment - pub fn verify_commitment(&self) -> bool { - let nonce_bytes = match hex::decode(&self.nonce) { - Ok(bytes) => bytes, - Err(_) => return false, - }; - - let mut hasher = Sha256::new(); - hasher.update(self.content_hash.raw_hash().as_bytes()); - hasher.update(&nonce_bytes); - hasher.update(self.timestamp.to_rfc3339().as_bytes()); - let expected = hex::encode(hasher.finalize()); - - self.commitment == expected - } -} - -/// Record of an obliteration event (stored in audit log) -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ObliterationRecord { - /// Unique record identifier - pub id: String, - /// When obliteration occurred - pub timestamp: DateTime, - /// User who performed obliteration - pub user: String, - /// Hash of obliterated content (not the content itself) - pub content_hash: ContentHash, - /// Reason for obliteration (optional, for compliance) - pub reason: Option, - /// Reference to legal basis (e.g., "GDPR Article 17") - pub legal_basis: Option, - /// The obliteration proof - pub proof: ObliterationProof, - /// Related operation IDs that were cleaned up - pub cleaned_operation_ids: Vec, -} - -/// Obliteration log for audit trail -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct ObliterationLog { - pub version: String, - pub records: Vec, -} - -impl ObliterationLog { - pub fn new() -> Self { - Self { - version: "1.0".to_string(), - records: Vec::new(), - } - } -} - -/// Manager for obliterative wipe operations -pub struct ObliterationManager { - /// Path to obliteration log - log_path: PathBuf, - /// Obliteration log - log: ObliterationLog, -} - -impl ObliterationManager { - /// Create or open an obliteration manager - pub fn new(log_path: PathBuf) -> Result { - let log = if log_path.exists() { - let content = ({ use std::io::Read; std::fs::File::open(&log_path).and_then(|mut f| { let mut buf = String::new(); f.take(10 * 1024 * 1024).read_to_string(&mut buf)?; Ok(buf) }) })?; - serde_json::from_str(&content) - .map_err(|e| JanusError::MetadataCorrupted(e.to_string()))? - } else { - ObliterationLog::new() - }; - - Ok(Self { log_path, log }) - } - - /// Save log to disk - fn save(&self) -> Result<()> { - if let Some(parent) = self.log_path.parent() { - fs::create_dir_all(parent)?; - } - let content = serde_json::to_string_pretty(&self.log)?; - fs::write(&self.log_path, content)?; - Ok(()) - } - - /// Obliterate content from the content store - /// This is the main RMO primitive implementation - pub fn obliterate( - &mut self, - content_store: &ContentStore, - content_hash: &ContentHash, - reason: Option, - legal_basis: Option, - ) -> Result { - // Get the content path - let content_path = content_store.content_path(content_hash); - - if !content_path.exists() { - return Err(JanusError::FileNotFound(format!( - "Content {} not found in store", - content_hash - ))); - } - - // Perform secure overwrite - let passes = secure_overwrite(&content_path)?; - - // Remove the file - fs::remove_file(&content_path)?; - - // Generate obliteration proof - let proof = ObliterationProof::generate(content_hash, passes); - - // Create record - let record = ObliterationRecord { - id: Uuid::new_v4().to_string(), - timestamp: Utc::now(), - user: whoami::username(), - content_hash: content_hash.clone(), - reason, - legal_basis, - proof, - cleaned_operation_ids: Vec::new(), - }; - - // Log the obliteration - self.log.records.push(record.clone()); - self.save()?; - - Ok(record) - } - - /// Obliterate content and clean up related metadata references - pub fn obliterate_with_cleanup( - &mut self, - content_store: &ContentStore, - content_hash: &ContentHash, - operation_ids: Vec, - reason: Option, - legal_basis: Option, - ) -> Result { - // Perform obliteration - let mut record = self.obliterate(content_store, content_hash, reason, legal_basis)?; - - // Record which operations were affected - record.cleaned_operation_ids = operation_ids; - - // Update the log - if let Some(last) = self.log.records.last_mut() { - last.cleaned_operation_ids = record.cleaned_operation_ids.clone(); - } - self.save()?; - - Ok(record) - } - - /// Get all obliteration records - pub fn records(&self) -> &[ObliterationRecord] { - &self.log.records - } - - /// Get record by ID - pub fn get(&self, id: &str) -> Option<&ObliterationRecord> { - self.log.records.iter().find(|r| r.id == id) - } - - /// Get records for a specific content hash - pub fn get_by_hash(&self, hash: &ContentHash) -> Vec<&ObliterationRecord> { - self.log - .records - .iter() - .filter(|r| r.content_hash == *hash) - .collect() - } - - /// Verify an obliteration proof - pub fn verify_proof(&self, proof_id: &str) -> Result { - let record = self - .log - .records - .iter() - .find(|r| r.proof.id == proof_id) - .ok_or_else(|| JanusError::InvalidOperationId(proof_id.to_string()))?; - - Ok(record.proof.verify_commitment()) - } - - /// Count total obliterations - pub fn count(&self) -> usize { - self.log.records.len() - } -} - -/// Perform secure overwrite of a file -/// Uses multiple passes with different patterns to ensure data is unrecoverable -fn secure_overwrite(path: &Path) -> Result { - let metadata = fs::metadata(path)?; - let file_size = metadata.len() as usize; - - if file_size == 0 { - return Ok(OVERWRITE_PASSES); - } - - // Open file for writing - let mut file = OpenOptions::new().write(true).open(path)?; - - // Perform overwrite passes - for (pass, &pattern) in PATTERNS.iter().enumerate() { - // Seek to beginning - file.seek(SeekFrom::Start(0))?; - - // Create pattern buffer - let buffer = if pass == OVERWRITE_PASSES - 1 { - // Final pass: random data - let mut random_buffer = vec![0u8; file_size.min(8192)]; - rand::thread_rng().fill_bytes(&mut random_buffer); - random_buffer - } else { - // Fixed pattern - vec![pattern; file_size.min(8192)] - }; - - // Write in chunks - let mut written = 0; - while written < file_size { - let to_write = (file_size - written).min(buffer.len()); - file.write_all(&buffer[..to_write])?; - written += to_write; - } - - // Flush to disk - file.sync_all()?; - } - - Ok(OVERWRITE_PASSES) -} - -/// Verify that content no longer exists at a path -pub fn verify_obliteration(path: &Path, original_hash: &ContentHash) -> Result { - if !path.exists() { - return Ok(true); - } - - // Read remaining content - let mut file = File::open(path)?; - let mut content = Vec::new(); - file.read_to_end(&mut content)?; - - // Verify it doesn't match original - let current_hash = ContentHash::from_bytes(&content); - Ok(current_hash != *original_hash) -} - -/// Batch obliteration request -#[derive(Debug, Clone)] -pub struct BatchObliterationRequest { - pub content_hashes: Vec, - pub reason: Option, - pub legal_basis: Option, -} - -/// Batch obliteration result -#[derive(Debug)] -pub struct BatchObliterationResult { - pub successful: Vec, - pub failed: Vec<(ContentHash, JanusError)>, -} - -impl ObliterationManager { - /// Obliterate multiple content items - pub fn obliterate_batch( - &mut self, - content_store: &ContentStore, - request: BatchObliterationRequest, - ) -> BatchObliterationResult { - let mut successful = Vec::new(); - let mut failed = Vec::new(); - - for hash in request.content_hashes { - match self.obliterate( - content_store, - &hash, - request.reason.clone(), - request.legal_basis.clone(), - ) { - Ok(record) => successful.push(record), - Err(e) => failed.push((hash, e)), - } - } - - BatchObliterationResult { successful, failed } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::TempDir; - - fn setup() -> (TempDir, ContentStore, ObliterationManager) { - let tmp = TempDir::new().unwrap(); - let content_store = ContentStore::new(tmp.path().join("content"), false).unwrap(); - let obliteration_manager = - ObliterationManager::new(tmp.path().join("obliterations.json")).unwrap(); - (tmp, content_store, obliteration_manager) - } - - #[test] - fn test_obliteration_proof_generation() { - let hash = ContentHash::from_bytes(b"test content"); - let proof = ObliterationProof::generate(&hash, 3); - - assert!(!proof.id.is_empty()); - assert_eq!(proof.content_hash, hash); - assert_eq!(proof.overwrite_passes, 3); - assert!(proof.storage_cleared); - } - - #[test] - fn test_proof_verification() { - let hash = ContentHash::from_bytes(b"test content"); - let proof = ObliterationProof::generate(&hash, 3); - - assert!(proof.verify_commitment()); - } - - #[test] - fn test_obliterate_content() { - let (_tmp, content_store, mut obliteration_manager) = setup(); - - // Store some content - let content = b"sensitive data to be obliterated"; - let hash = content_store.store(content).unwrap(); - - // Verify it exists - assert!(content_store.exists(&hash)); - - // Obliterate it - let record = obliteration_manager - .obliterate( - &content_store, - &hash, - Some("User request".to_string()), - Some("GDPR Article 17".to_string()), - ) - .unwrap(); - - // Verify obliteration - assert!(!content_store.exists(&hash)); - assert_eq!(record.content_hash, hash); - assert_eq!(record.reason, Some("User request".to_string())); - assert_eq!(record.legal_basis, Some("GDPR Article 17".to_string())); - assert!(record.proof.verify_commitment()); - } - - #[test] - fn test_obliteration_log_persistence() { - let (tmp, content_store, mut obliteration_manager) = setup(); - - // Store and obliterate content - let content = b"data to obliterate"; - let hash = content_store.store(content).unwrap(); - let record = obliteration_manager - .obliterate(&content_store, &hash, None, None) - .unwrap(); - - // Reopen manager and verify log - let obliteration_manager2 = - ObliterationManager::new(tmp.path().join("obliterations.json")).unwrap(); - assert_eq!(obliteration_manager2.count(), 1); - - let retrieved = obliteration_manager2.get(&record.id).unwrap(); - assert_eq!(retrieved.content_hash, hash); - } - - #[test] - fn test_secure_overwrite() { - let tmp = TempDir::new().unwrap(); - let test_file = tmp.path().join("test.txt"); - - // Create file with known content - let original = b"sensitive information that must be destroyed"; - fs::write(&test_file, original).unwrap(); - - // Perform secure overwrite - let passes = secure_overwrite(&test_file).unwrap(); - assert_eq!(passes, OVERWRITE_PASSES); - - // Read back and verify content changed - let remaining = fs::read(&test_file).unwrap(); - assert_ne!(remaining, original.to_vec()); - } - - #[test] - fn test_batch_obliteration() { - let (_tmp, content_store, mut obliteration_manager) = setup(); - - // Store multiple contents - let hashes: Vec = (0..5) - .map(|i| { - let content = format!("content {}", i); - content_store.store(content.as_bytes()).unwrap() - }) - .collect(); - - // Batch obliterate - let request = BatchObliterationRequest { - content_hashes: hashes.clone(), - reason: Some("Batch cleanup".to_string()), - legal_basis: Some("GDPR Article 17".to_string()), - }; - - let result = obliteration_manager.obliterate_batch(&content_store, request); - - assert_eq!(result.successful.len(), 5); - assert!(result.failed.is_empty()); - - // Verify all obliterated - for hash in hashes { - assert!(!content_store.exists(&hash)); - } - } - - #[test] - fn test_obliterate_nonexistent() { - let (_tmp, content_store, mut obliteration_manager) = setup(); - - let fake_hash = ContentHash::from_bytes(b"nonexistent"); - let result = obliteration_manager.obliterate(&content_store, &fake_hash, None, None); - - assert!(result.is_err()); - } -} diff --git a/src/januskey/src/operations.rs b/src/januskey/src/operations.rs deleted file mode 100644 index 1be35f4..0000000 --- a/src/januskey/src/operations.rs +++ /dev/null @@ -1,602 +0,0 @@ -// SPDX-License-Identifier: MPL-2.0 -// Copyright (c) Jonathan D.A. Jewell -// SPDX-FileCopyrightText: 2025 Jonathan D.A. Jewell -// -// Reversible File Operations -// Each operation stores sufficient metadata for perfect inversion - -use crate::content_store::{ContentHash, ContentStore}; -use crate::error::{JanusError, Result}; -use crate::metadata::{FileMetadata, MetadataStore, OperationMetadata, OperationType}; -use std::fs; -use std::path::{Path, PathBuf}; - -/// A file operation that can be executed and reversed -#[derive(Debug, Clone)] -pub enum FileOperation { - /// Delete a file (reversible: restore from stored content) - Delete { - path: PathBuf, - }, - /// Modify a file (reversible: restore original content) - Modify { - path: PathBuf, - new_content: Vec, - }, - /// Move/rename a file (reversible: move back) - Move { - source: PathBuf, - destination: PathBuf, - }, - /// Copy a file (reversible: delete the copy) - Copy { - source: PathBuf, - destination: PathBuf, - }, - /// Change permissions (reversible: restore original perms) - #[cfg(unix)] - Chmod { - path: PathBuf, - new_mode: u32, - }, - /// Create a new file (reversible: delete) - Create { - path: PathBuf, - content: Vec, - }, -} - -impl FileOperation { - /// Get operation type - pub fn op_type(&self) -> OperationType { - match self { - Self::Delete { .. } => OperationType::Delete, - Self::Modify { .. } => OperationType::Modify, - Self::Move { .. } => OperationType::Move, - Self::Copy { .. } => OperationType::Copy, - #[cfg(unix)] - Self::Chmod { .. } => OperationType::Chmod, - Self::Create { .. } => OperationType::Create, - } - } - - /// Get primary path - pub fn path(&self) -> &Path { - match self { - Self::Delete { path } => path, - Self::Modify { path, .. } => path, - Self::Move { source, .. } => source, - Self::Copy { source, .. } => source, - #[cfg(unix)] - Self::Chmod { path, .. } => path, - Self::Create { path, .. } => path, - } - } -} - -/// Executor for file operations with reversibility support -pub struct OperationExecutor<'a> { - content_store: &'a ContentStore, - metadata_store: &'a mut MetadataStore, - transaction_id: Option, -} - -impl<'a> OperationExecutor<'a> { - pub fn new( - content_store: &'a ContentStore, - metadata_store: &'a mut MetadataStore, - ) -> Self { - Self { - content_store, - metadata_store, - transaction_id: None, - } - } - - pub fn with_transaction(mut self, transaction_id: String) -> Self { - self.transaction_id = Some(transaction_id); - self - } - - /// Execute an operation and record metadata for reversal - pub fn execute(&mut self, operation: FileOperation) -> Result { - match operation { - FileOperation::Delete { path } => self.execute_delete(&path), - FileOperation::Modify { path, new_content } => { - self.execute_modify(&path, &new_content) - } - FileOperation::Move { source, destination } => { - self.execute_move(&source, &destination) - } - FileOperation::Copy { source, destination } => { - self.execute_copy(&source, &destination) - } - #[cfg(unix)] - FileOperation::Chmod { path, new_mode } => self.execute_chmod(&path, new_mode), - FileOperation::Create { path, content } => self.execute_create(&path, &content), - } - } - - /// Execute delete operation - fn execute_delete(&mut self, path: &Path) -> Result { - if !path.exists() { - return Err(JanusError::FileNotFound(path.display().to_string())); - } - - // Capture original content and metadata - let content = fs::read(path)?; - let file_metadata = FileMetadata::from_path(path)?; - let content_hash = self.content_store.store(&content)?; - - // Create operation metadata - let mut metadata = OperationMetadata::new(OperationType::Delete, path.to_path_buf()) - .with_content_hash(content_hash) - .with_original_metadata(file_metadata); - - if let Some(ref tid) = self.transaction_id { - metadata = metadata.with_transaction_id(tid.clone()); - } - - // Perform the delete - fs::remove_file(path)?; - - // Record and return - self.metadata_store.append(metadata.clone())?; - Ok(metadata) - } - - /// Execute modify operation - fn execute_modify(&mut self, path: &Path, new_content: &[u8]) -> Result { - if !path.exists() { - return Err(JanusError::FileNotFound(path.display().to_string())); - } - - // Capture original content - let original_content = fs::read(path)?; - let file_metadata = FileMetadata::from_path(path)?; - let original_hash = self.content_store.store(&original_content)?; - let new_hash = ContentHash::from_bytes(new_content); - - // Create operation metadata - let mut metadata = OperationMetadata::new(OperationType::Modify, path.to_path_buf()) - .with_content_hash(original_hash) - .with_new_content_hash(new_hash) - .with_original_metadata(file_metadata); - - if let Some(ref tid) = self.transaction_id { - metadata = metadata.with_transaction_id(tid.clone()); - } - - // Perform the modify - fs::write(path, new_content)?; - - // Record and return - self.metadata_store.append(metadata.clone())?; - Ok(metadata) - } - - /// Execute move operation - fn execute_move(&mut self, source: &Path, destination: &Path) -> Result { - if !source.exists() { - return Err(JanusError::FileNotFound(source.display().to_string())); - } - if destination.exists() { - return Err(JanusError::PathExists(destination.display().to_string())); - } - - // Create parent directory if needed - if let Some(parent) = destination.parent() { - fs::create_dir_all(parent)?; - } - - // Capture metadata - let file_metadata = FileMetadata::from_path(source)?; - - // Create operation metadata - let mut metadata = OperationMetadata::new(OperationType::Move, source.to_path_buf()) - .with_secondary_path(destination.to_path_buf()) - .with_original_metadata(file_metadata); - - if let Some(ref tid) = self.transaction_id { - metadata = metadata.with_transaction_id(tid.clone()); - } - - // Perform the move - fs::rename(source, destination)?; - - // Record and return - self.metadata_store.append(metadata.clone())?; - Ok(metadata) - } - - /// Execute copy operation - fn execute_copy(&mut self, source: &Path, destination: &Path) -> Result { - if !source.exists() { - return Err(JanusError::FileNotFound(source.display().to_string())); - } - if destination.exists() { - return Err(JanusError::PathExists(destination.display().to_string())); - } - - // Create parent directory if needed - if let Some(parent) = destination.parent() { - fs::create_dir_all(parent)?; - } - - // Create operation metadata - let mut metadata = OperationMetadata::new(OperationType::Copy, source.to_path_buf()) - .with_secondary_path(destination.to_path_buf()); - - if let Some(ref tid) = self.transaction_id { - metadata = metadata.with_transaction_id(tid.clone()); - } - - // Perform the copy - fs::copy(source, destination)?; - - // Record and return - self.metadata_store.append(metadata.clone())?; - Ok(metadata) - } - - /// Execute chmod operation - #[cfg(unix)] - fn execute_chmod(&mut self, path: &Path, new_mode: u32) -> Result { - use std::os::unix::fs::PermissionsExt; - - if !path.exists() { - return Err(JanusError::FileNotFound(path.display().to_string())); - } - - // Capture original metadata - let file_metadata = FileMetadata::from_path(path)?; - - // Create new metadata with new permissions - let mut new_metadata = file_metadata.clone(); - new_metadata.permissions = new_mode; - - // Create operation metadata - let mut metadata = OperationMetadata::new(OperationType::Chmod, path.to_path_buf()) - .with_original_metadata(file_metadata); - metadata.new_metadata = Some(new_metadata); - - if let Some(ref tid) = self.transaction_id { - metadata = metadata.with_transaction_id(tid.clone()); - } - - // Perform the chmod - let perms = fs::Permissions::from_mode(new_mode); - fs::set_permissions(path, perms)?; - - // Record and return - self.metadata_store.append(metadata.clone())?; - Ok(metadata) - } - - /// Execute create operation - fn execute_create(&mut self, path: &Path, content: &[u8]) -> Result { - if path.exists() { - return Err(JanusError::PathExists(path.display().to_string())); - } - - // Create parent directory if needed - if let Some(parent) = path.parent() { - fs::create_dir_all(parent)?; - } - - // Create operation metadata - let content_hash = ContentHash::from_bytes(content); - let mut metadata = OperationMetadata::new(OperationType::Create, path.to_path_buf()) - .with_new_content_hash(content_hash); - - if let Some(ref tid) = self.transaction_id { - metadata = metadata.with_transaction_id(tid.clone()); - } - - // Perform the create - fs::write(path, content)?; - - // Record and return - self.metadata_store.append(metadata.clone())?; - Ok(metadata) - } - - /// Undo an operation using its metadata - pub fn undo(&mut self, operation_id: &str) -> Result { - let original_op = self - .metadata_store - .get(operation_id) - .ok_or_else(|| JanusError::InvalidOperationId(operation_id.to_string()))? - .clone(); - - if original_op.undone { - return Err(JanusError::OperationFailed(format!( - "Operation {} already undone", - operation_id - ))); - } - - let undo_metadata = match original_op.op_type { - OperationType::Delete => self.undo_delete(&original_op)?, - OperationType::Modify => self.undo_modify(&original_op)?, - OperationType::Move => self.undo_move(&original_op)?, - OperationType::Copy => self.undo_copy(&original_op)?, - OperationType::Chmod => { - #[cfg(unix)] - { - self.undo_chmod(&original_op)? - } - #[cfg(not(unix))] - { - return Err(JanusError::OperationFailed( - "Chmod not supported on this platform".to_string(), - )); - } - } - OperationType::Create => self.undo_create(&original_op)?, - OperationType::Chown => { - return Err(JanusError::OperationFailed( - "Chown undo not yet implemented".to_string(), - )) - } - }; - - // Mark original operation as undone - self.metadata_store.mark_undone(operation_id, &undo_metadata.id)?; - - Ok(undo_metadata) - } - - /// Undo delete: restore file from content store - fn undo_delete(&mut self, original: &OperationMetadata) -> Result { - let content_hash = original - .content_hash - .as_ref() - .ok_or_else(|| JanusError::MetadataCorrupted("Missing content hash".to_string()))?; - - // Retrieve original content - let content = self.content_store.retrieve(content_hash)?; - - // Create (restore) the file - let create_op = FileOperation::Create { - path: original.path.clone(), - content, - }; - - let mut metadata = self.execute(create_op)?; - - // Restore original metadata (permissions, etc.) - if let Some(ref file_meta) = original.original_metadata { - file_meta.apply(&original.path)?; - } - - metadata.op_type = OperationType::Create; - Ok(metadata) - } - - /// Undo modify: restore original content - fn undo_modify(&mut self, original: &OperationMetadata) -> Result { - let content_hash = original - .content_hash - .as_ref() - .ok_or_else(|| JanusError::MetadataCorrupted("Missing content hash".to_string()))?; - - // Retrieve original content - let content = self.content_store.retrieve(content_hash)?; - - // Modify back to original - let modify_op = FileOperation::Modify { - path: original.path.clone(), - new_content: content, - }; - - self.execute(modify_op) - } - - /// Undo move: move back to original location - fn undo_move(&mut self, original: &OperationMetadata) -> Result { - let destination = original - .path_secondary - .as_ref() - .ok_or_else(|| JanusError::MetadataCorrupted("Missing secondary path".to_string()))?; - - let move_op = FileOperation::Move { - source: destination.clone(), - destination: original.path.clone(), - }; - - self.execute(move_op) - } - - /// Undo copy: delete the copy - fn undo_copy(&mut self, original: &OperationMetadata) -> Result { - let destination = original - .path_secondary - .as_ref() - .ok_or_else(|| JanusError::MetadataCorrupted("Missing secondary path".to_string()))?; - - let delete_op = FileOperation::Delete { - path: destination.clone(), - }; - - self.execute(delete_op) - } - - /// Undo chmod: restore original permissions - #[cfg(unix)] - fn undo_chmod(&mut self, original: &OperationMetadata) -> Result { - let file_meta = original - .original_metadata - .as_ref() - .ok_or_else(|| JanusError::MetadataCorrupted("Missing original metadata".to_string()))?; - - let chmod_op = FileOperation::Chmod { - path: original.path.clone(), - new_mode: file_meta.permissions, - }; - - self.execute(chmod_op) - } - - /// Undo create: delete the created file - fn undo_create(&mut self, original: &OperationMetadata) -> Result { - let delete_op = FileOperation::Delete { - path: original.path.clone(), - }; - - self.execute(delete_op) - } -} - -/// Delete files matching a glob pattern -pub fn delete_glob( - pattern: &str, - base_dir: &Path, - content_store: &ContentStore, - metadata_store: &mut MetadataStore, - transaction_id: Option, -) -> Result> { - let full_pattern = base_dir.join(pattern); - let pattern_str = full_pattern.to_string_lossy(); - let paths: Vec = glob::glob(&pattern_str)? - .filter_map(|r| r.ok()) - .filter(|p| p.is_file()) - .collect(); - - let mut results = Vec::new(); - for path in paths { - let mut executor = OperationExecutor::new(content_store, metadata_store); - if let Some(ref tid) = transaction_id { - executor = executor.with_transaction(tid.clone()); - } - let meta = executor.execute(FileOperation::Delete { path })?; - results.push(meta); - } - - Ok(results) -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::TempDir; - - fn setup() -> (TempDir, ContentStore, MetadataStore) { - let tmp = TempDir::new().unwrap(); - let content_store = - ContentStore::new(tmp.path().join("content"), false).unwrap(); - let metadata_store = - MetadataStore::new(tmp.path().join("metadata.json")).unwrap(); - (tmp, content_store, metadata_store) - } - - #[test] - fn test_delete_and_undo() { - let (tmp, content_store, mut metadata_store) = setup(); - - // Create a test file - let test_file = tmp.path().join("test.txt"); - fs::write(&test_file, "hello world").unwrap(); - - // Delete it - let mut executor = OperationExecutor::new(&content_store, &mut metadata_store); - let delete_meta = executor - .execute(FileOperation::Delete { - path: test_file.clone(), - }) - .unwrap(); - - assert!(!test_file.exists()); - - // Undo the delete - let mut executor = OperationExecutor::new(&content_store, &mut metadata_store); - executor.undo(&delete_meta.id).unwrap(); - - assert!(test_file.exists()); - assert_eq!(({ use std::io::Read; std::fs::File::open(&test_file).and_then(|mut f| { let mut buf = String::new(); f.take(10 * 1024 * 1024).read_to_string(&mut buf)?; Ok(buf) }) }).unwrap(), "hello world"); - } - - #[test] - fn test_modify_and_undo() { - let (tmp, content_store, mut metadata_store) = setup(); - - // Create a test file - let test_file = tmp.path().join("test.txt"); - fs::write(&test_file, "original content").unwrap(); - - // Modify it - let mut executor = OperationExecutor::new(&content_store, &mut metadata_store); - let modify_meta = executor - .execute(FileOperation::Modify { - path: test_file.clone(), - new_content: b"modified content".to_vec(), - }) - .unwrap(); - - assert_eq!(({ use std::io::Read; std::fs::File::open(&test_file).and_then(|mut f| { let mut buf = String::new(); f.take(10 * 1024 * 1024).read_to_string(&mut buf)?; Ok(buf) }) }).unwrap(), "modified content"); - - // Undo the modify - let mut executor = OperationExecutor::new(&content_store, &mut metadata_store); - executor.undo(&modify_meta.id).unwrap(); - - assert_eq!(({ use std::io::Read; std::fs::File::open(&test_file).and_then(|mut f| { let mut buf = String::new(); f.take(10 * 1024 * 1024).read_to_string(&mut buf)?; Ok(buf) }) }).unwrap(), "original content"); - } - - #[test] - fn test_move_and_undo() { - let (tmp, content_store, mut metadata_store) = setup(); - - // Create a test file - let source = tmp.path().join("source.txt"); - let dest = tmp.path().join("dest.txt"); - fs::write(&source, "content").unwrap(); - - // Move it - let mut executor = OperationExecutor::new(&content_store, &mut metadata_store); - let move_meta = executor - .execute(FileOperation::Move { - source: source.clone(), - destination: dest.clone(), - }) - .unwrap(); - - assert!(!source.exists()); - assert!(dest.exists()); - - // Undo the move - let mut executor = OperationExecutor::new(&content_store, &mut metadata_store); - executor.undo(&move_meta.id).unwrap(); - - assert!(source.exists()); - assert!(!dest.exists()); - } - - #[test] - fn test_copy_and_undo() { - let (tmp, content_store, mut metadata_store) = setup(); - - // Create a test file - let source = tmp.path().join("source.txt"); - let dest = tmp.path().join("dest.txt"); - fs::write(&source, "content").unwrap(); - - // Copy it - let mut executor = OperationExecutor::new(&content_store, &mut metadata_store); - let copy_meta = executor - .execute(FileOperation::Copy { - source: source.clone(), - destination: dest.clone(), - }) - .unwrap(); - - assert!(source.exists()); - assert!(dest.exists()); - - // Undo the copy (deletes the copy) - let mut executor = OperationExecutor::new(&content_store, &mut metadata_store); - executor.undo(©_meta.id).unwrap(); - - assert!(source.exists()); - assert!(!dest.exists()); - } -} diff --git a/src/januskey/src/transaction.rs b/src/januskey/src/transaction.rs deleted file mode 100644 index 3c9085a..0000000 --- a/src/januskey/src/transaction.rs +++ /dev/null @@ -1,405 +0,0 @@ -// SPDX-License-Identifier: MPL-2.0 -// Copyright (c) Jonathan D.A. Jewell -// SPDX-FileCopyrightText: 2025 Jonathan D.A. Jewell -// -// Transaction Manager: Group operations with commit/rollback support -// Theorem 3.4 (Sequential Reversibility) guarantees complete rollback - -use crate::content_store::ContentStore; -use crate::error::{JanusError, Result}; -use crate::metadata::{MetadataStore, OperationMetadata}; -use crate::operations::{FileOperation, OperationExecutor}; -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; -use std::fs; -use std::path::PathBuf; -use uuid::Uuid; - -/// Transaction state -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum TransactionState { - /// Transaction is active and accepting operations - Active, - /// Transaction has been committed - Committed, - /// Transaction has been rolled back - RolledBack, -} - -/// A transaction grouping multiple operations -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Transaction { - /// Unique transaction ID - pub id: String, - /// Human-readable name (optional) - pub name: Option, - /// When the transaction was started - pub started_at: DateTime, - /// When the transaction was completed (commit or rollback) - pub completed_at: Option>, - /// Current state - pub state: TransactionState, - /// IDs of operations in this transaction - pub operation_ids: Vec, - /// User who started the transaction - pub user: String, -} - -impl Transaction { - /// Create a new active transaction - pub fn new(name: Option) -> Self { - Self { - id: Uuid::new_v4().to_string(), - name, - started_at: Utc::now(), - completed_at: None, - state: TransactionState::Active, - operation_ids: Vec::new(), - user: whoami::username(), - } - } - - /// Check if transaction is active - pub fn is_active(&self) -> bool { - self.state == TransactionState::Active - } - - /// Add an operation to this transaction - pub fn add_operation(&mut self, operation_id: String) { - self.operation_ids.push(operation_id); - } - - /// Mark as committed - pub fn commit(&mut self) { - self.state = TransactionState::Committed; - self.completed_at = Some(Utc::now()); - } - - /// Mark as rolled back - pub fn rollback(&mut self) { - self.state = TransactionState::RolledBack; - self.completed_at = Some(Utc::now()); - } -} - -/// Transaction log for persistence -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct TransactionLog { - pub version: String, - pub transactions: Vec, - pub active_transaction_id: Option, -} - -impl TransactionLog { - pub fn new() -> Self { - Self { - version: "1.0".to_string(), - transactions: Vec::new(), - active_transaction_id: None, - } - } -} - -/// Manager for transactions -pub struct TransactionManager { - /// Path to transaction log - path: PathBuf, - /// Transaction log - log: TransactionLog, -} - -impl TransactionManager { - /// Create or open a transaction manager - pub fn new(path: PathBuf) -> Result { - let log = if path.exists() { - let content = ({ use std::io::Read; std::fs::File::open(&path).and_then(|mut f| { let mut buf = String::new(); f.take(10 * 1024 * 1024).read_to_string(&mut buf)?; Ok(buf) }) })?; - serde_json::from_str(&content) - .map_err(|e| JanusError::MetadataCorrupted(e.to_string()))? - } else { - TransactionLog::new() - }; - - Ok(Self { path, log }) - } - - /// Save the log to disk - fn save(&self) -> Result<()> { - if let Some(parent) = self.path.parent() { - fs::create_dir_all(parent)?; - } - let content = serde_json::to_string_pretty(&self.log)?; - fs::write(&self.path, content)?; - Ok(()) - } - - /// Begin a new transaction - pub fn begin(&mut self, name: Option) -> Result<&Transaction> { - if let Some(ref active_id) = self.log.active_transaction_id { - return Err(JanusError::TransactionActive(active_id.clone())); - } - - let transaction = Transaction::new(name); - let id = transaction.id.clone(); - self.log.transactions.push(transaction); - self.log.active_transaction_id = Some(id.clone()); - self.save()?; - - Ok(self.log.transactions.last().expect("invariant: vector has element after push()")) - } - - /// Get current active transaction - pub fn active(&self) -> Option<&Transaction> { - self.log.active_transaction_id.as_ref().and_then(|id| { - self.log.transactions.iter().find(|t| t.id == *id) - }) - } - - /// Get mutable active transaction - fn active_mut(&mut self) -> Option<&mut Transaction> { - let active_id = self.log.active_transaction_id.clone()?; - self.log.transactions.iter_mut().find(|t| t.id == active_id) - } - - /// Add operation to active transaction - pub fn add_operation(&mut self, operation_id: String) -> Result<()> { - let transaction = self - .active_mut() - .ok_or(JanusError::NoActiveTransaction)?; - transaction.add_operation(operation_id); - self.save() - } - - /// Commit the active transaction - pub fn commit(&mut self) -> Result { - let transaction = self - .active_mut() - .ok_or(JanusError::NoActiveTransaction)?; - transaction.commit(); - let result = transaction.clone(); - self.log.active_transaction_id = None; - self.save()?; - Ok(result) - } - - /// Rollback the active transaction - pub fn rollback( - &mut self, - content_store: &ContentStore, - metadata_store: &mut MetadataStore, - ) -> Result { - let transaction = self.active().ok_or(JanusError::NoActiveTransaction)?.clone(); - - // Undo operations in reverse order (Theorem 3.4) - for op_id in transaction.operation_ids.iter().rev() { - let mut executor = OperationExecutor::new(content_store, metadata_store); - executor.undo(op_id)?; - } - - // Mark transaction as rolled back - let transaction = self - .active_mut() - .ok_or(JanusError::NoActiveTransaction)?; - transaction.rollback(); - let result = transaction.clone(); - self.log.active_transaction_id = None; - self.save()?; - - Ok(result) - } - - /// Get transaction by ID - pub fn get(&self, id: &str) -> Option<&Transaction> { - self.log.transactions.iter().find(|t| t.id == id) - } - - /// Get all transactions - pub fn all(&self) -> &[Transaction] { - &self.log.transactions - } - - /// Check if there's an active transaction - pub fn has_active(&self) -> bool { - self.log.active_transaction_id.is_some() - } - - /// Get active transaction ID - pub fn active_id(&self) -> Option<&str> { - self.log.active_transaction_id.as_deref() - } -} - -/// Helper struct for executing operations within a transaction -pub struct TransactionExecutor<'a> { - content_store: &'a ContentStore, - metadata_store: &'a mut MetadataStore, - transaction_manager: &'a mut TransactionManager, -} - -impl<'a> TransactionExecutor<'a> { - pub fn new( - content_store: &'a ContentStore, - metadata_store: &'a mut MetadataStore, - transaction_manager: &'a mut TransactionManager, - ) -> Self { - Self { - content_store, - metadata_store, - transaction_manager, - } - } - - /// Execute an operation within the current transaction (if any) - pub fn execute(&mut self, operation: FileOperation) -> Result { - let transaction_id = self.transaction_manager.active_id().map(String::from); - - let mut executor = OperationExecutor::new(self.content_store, self.metadata_store); - if let Some(ref tid) = transaction_id { - executor = executor.with_transaction(tid.clone()); - } - - let metadata = executor.execute(operation)?; - - // If in a transaction, record the operation ID - if transaction_id.is_some() { - self.transaction_manager.add_operation(metadata.id.clone())?; - } - - Ok(metadata) - } -} - -/// Preview of pending transaction operations -#[derive(Debug)] -pub struct TransactionPreview { - pub transaction_name: Option, - pub operations: Vec, - pub total_files_affected: usize, -} - -#[derive(Debug)] -pub struct OperationPreview { - pub op_type: String, - pub path: PathBuf, - pub secondary_path: Option, -} - -impl TransactionPreview { - pub fn from_transaction( - transaction: &Transaction, - metadata_store: &MetadataStore, - ) -> Self { - let mut operations = Vec::new(); - let mut paths_seen = std::collections::HashSet::new(); - - for op_id in &transaction.operation_ids { - if let Some(meta) = metadata_store.get(op_id) { - paths_seen.insert(meta.path.clone()); - if let Some(ref secondary) = meta.path_secondary { - paths_seen.insert(secondary.clone()); - } - - operations.push(OperationPreview { - op_type: meta.op_type.to_string(), - path: meta.path.clone(), - secondary_path: meta.path_secondary.clone(), - }); - } - } - - Self { - transaction_name: transaction.name.clone(), - operations, - total_files_affected: paths_seen.len(), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::TempDir; - - fn setup() -> (TempDir, ContentStore, MetadataStore, TransactionManager) { - let tmp = TempDir::new().unwrap(); - let content_store = ContentStore::new(tmp.path().join("content"), false).unwrap(); - let metadata_store = MetadataStore::new(tmp.path().join("metadata.json")).unwrap(); - let transaction_manager = - TransactionManager::new(tmp.path().join("transactions.json")).unwrap(); - (tmp, content_store, metadata_store, transaction_manager) - } - - #[test] - fn test_transaction_lifecycle() { - let (tmp, content_store, mut metadata_store, mut tx_manager) = setup(); - - // Begin transaction - tx_manager.begin(Some("test-tx".to_string())).unwrap(); - assert!(tx_manager.has_active()); - - // Create test files - let file1 = tmp.path().join("file1.txt"); - let file2 = tmp.path().join("file2.txt"); - fs::write(&file1, "content1").unwrap(); - fs::write(&file2, "content2").unwrap(); - - // Execute operations in transaction - { - let mut executor = - TransactionExecutor::new(&content_store, &mut metadata_store, &mut tx_manager); - executor - .execute(FileOperation::Delete { path: file1.clone() }) - .unwrap(); - executor - .execute(FileOperation::Delete { path: file2.clone() }) - .unwrap(); - } - - assert!(!file1.exists()); - assert!(!file2.exists()); - - // Rollback - tx_manager - .rollback(&content_store, &mut metadata_store) - .unwrap(); - - // Files should be restored - assert!(file1.exists()); - assert!(file2.exists()); - assert!(!tx_manager.has_active()); - } - - #[test] - fn test_transaction_commit() { - let (tmp, content_store, mut metadata_store, mut tx_manager) = setup(); - - // Begin transaction - tx_manager.begin(None).unwrap(); - - // Create and delete a file - let file = tmp.path().join("test.txt"); - fs::write(&file, "content").unwrap(); - - { - let mut executor = - TransactionExecutor::new(&content_store, &mut metadata_store, &mut tx_manager); - executor - .execute(FileOperation::Delete { path: file.clone() }) - .unwrap(); - } - - // Commit - let tx = tx_manager.commit().unwrap(); - assert_eq!(tx.state, TransactionState::Committed); - assert!(!file.exists()); - assert!(!tx_manager.has_active()); - } - - #[test] - fn test_cannot_begin_while_active() { - let (_tmp, _content_store, _metadata_store, mut tx_manager) = setup(); - - tx_manager.begin(None).unwrap(); - let result = tx_manager.begin(None); - assert!(result.is_err()); - } -} From 1ccdb022c166d16873877d2fb34a6382104f659e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 05:41:15 +0000 Subject: [PATCH 10/21] fix(deps): bump crossbeam-epoch to 0.9.20 (RUSTSEC-2026-0204) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cargo-audit flagged crossbeam-epoch 0.9.18 (RUSTSEC-2026-0204, dated 2026-07-06: invalid pointer dereference in the fmt::Pointer impl for Atomic/Shared; fixed in >=0.9.20). It is a pre-existing transitive dev dependency (criterion -> rayon -> crossbeam-deque -> crossbeam-epoch), not introduced by this branch — advisory-db drift. Root-cause bump to 0.9.20 rather than an ignore, matching the earlier advisory handling. Verified: `cargo audit --deny warnings` exits 0; `cargo build --workspace` clean. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015qwVESTcbfanY2iJPQNoSz --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4cd31d6..07ce804 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -450,9 +450,9 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.18" +version = "0.9.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" dependencies = [ "crossbeam-utils", ] From de6d05b526b537af6011c8421ff27037451e51c7 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Sat, 18 Jul 2026 20:31:57 +0100 Subject: [PATCH 11/21] sweep2: add config files (mise.toml) Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe --- mise.toml | 57 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 mise.toml diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..6dd983f --- /dev/null +++ b/mise.toml @@ -0,0 +1,57 @@ +[tools] +# Language runtimes +node = "latest" +python = "latest" +rust = "latest" +go = "latest" +zig = "latest" +java = "latest" +bun = "latest" +denojs = "latest" + +# Package managers +npm = "latest" +yarn = "latest" +pnpm = "latest" +pip = "latest" +cargo = "latest" +go-task = "latest" + +# Formatting & Linting +gofmt = "latest" +black = "latest" +isort = "latest" +ruff = "latest" +prettier = "latest" +shfmt = "latest" +stylua = "latest" + +# Build tools +cmake = "latest" +make = "latest" +ninja = "latest" + +# Shell tools +git = "latest" +gnu-sed = "latest" +gnu-tar = "latest" +gnu-grep = "latest" + +# Testing +vitest = "latest" +pytest = "latest" +jest = "latest" + +[env] +# Common environment variables +NODE_ENV = "development" +PYTHONDONTWRITEBYTECODE = "1" +PYTHONUNBUFFERED = "1" + +# Task runner alias +[alias] +task = "go-task" +build = "cargo build --release || npm run build || go build" +test = "cargo test || npm test || go test ./..." +lint = "ruff check . || prettier --check . || black --check ." +fmt = "ruff format . || prettier --write . || black ." From 40881a1f848aab0c99648af16b50dbb93f155ec1 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Sat, 18 Jul 2026 21:17:02 +0100 Subject: [PATCH 12/21] sweep3: add license files and SPDX identifiers Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe --- LICENSES/AGPL-3.0-or-later.txt | 661 +++++++++++++++++++++++++++++++++ LICENSES/CC-BY-SA-4.0.txt | 474 +++++++++++++++++------ LICENSES/MPL-2.0.txt | 4 +- 3 files changed, 1029 insertions(+), 110 deletions(-) create mode 100644 LICENSES/AGPL-3.0-or-later.txt diff --git a/LICENSES/AGPL-3.0-or-later.txt b/LICENSES/AGPL-3.0-or-later.txt new file mode 100644 index 0000000..be3f7b2 --- /dev/null +++ b/LICENSES/AGPL-3.0-or-later.txt @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/LICENSES/CC-BY-SA-4.0.txt b/LICENSES/CC-BY-SA-4.0.txt index 835a683..2d58298 100644 --- a/LICENSES/CC-BY-SA-4.0.txt +++ b/LICENSES/CC-BY-SA-4.0.txt @@ -1,170 +1,428 @@ -Creative Commons Attribution-ShareAlike 4.0 International +Attribution-ShareAlike 4.0 International - Creative Commons Corporation (“Creative Commons”) is not a law firm and does not provide legal services or legal advice. Distribution of Creative Commons public licenses does not create a lawyer-client or other relationship. Creative Commons makes its licenses and related information available on an “as-is” basis. Creative Commons gives no warranties regarding its licenses, any material licensed under their terms and conditions, or any related information. Creative Commons disclaims all liability for damages resulting from their use to the fullest extent possible. +======================================================================= -Using Creative Commons Public Licenses - -Creative Commons public licenses provide a standard set of terms and conditions that creators and other rights holders may use to share original works of authorship and other material subject to copyright and certain other rights specified in the public license below. The following considerations are for informational purposes only, are not exhaustive, and do not form part of our licenses. - -Considerations for licensors: Our public licenses are intended for use by those authorized to give the public permission to use material in ways otherwise restricted by copyright and certain other rights. Our licenses are irrevocable. Licensors should read and understand the terms and conditions of the license they choose before applying it. Licensors should also secure all rights necessary before applying our licenses so that the public can reuse the material as expected. Licensors should clearly mark any material not subject to the license. This includes other CC-licensed material, or material used under an exception or limitation to copyright. More considerations for licensors. - -Considerations for the public: By using one of our public licenses, a licensor grants the public permission to use the licensed material under specified terms and conditions. If the licensor’s permission is not necessary for any reason–for example, because of any applicable exception or limitation to copyright–then that use is not regulated by the license. Our licenses grant only permissions under copyright and certain other rights that a licensor has authority to grant. Use of the licensed material may still be restricted for other reasons, including because others have copyright or other rights in the material. A licensor may make special requests, such as asking that all changes be marked or described. - -Although not required by our licenses, you are encouraged to respect those requests where reasonable. More considerations for the public. - -Creative Commons Attribution-ShareAlike 4.0 International Public License - -By exercising the Licensed Rights (defined below), You accept and agree to be bound by the terms and conditions of this Creative Commons Attribution-ShareAlike 4.0 International Public License ("Public License"). To the extent this Public License may be interpreted as a contract, You are granted the Licensed Rights in consideration of Your acceptance of these terms and conditions, and the Licensor grants You such rights in consideration of benefits the Licensor receives from making the Licensed Material available under these terms and conditions. - -Section 1 – Definitions. - - a. Adapted Material means material subject to Copyright and Similar Rights that is derived from or based upon the Licensed Material and in which the Licensed Material is translated, altered, arranged, transformed, or otherwise modified in a manner requiring permission under the Copyright and Similar Rights held by the Licensor. For purposes of this Public License, where the Licensed Material is a musical work, performance, or sound recording, Adapted Material is always produced where the Licensed Material is synched in timed relation with a moving image. - - b. Adapter's License means the license You apply to Your Copyright and Similar Rights in Your contributions to Adapted Material in accordance with the terms and conditions of this Public License. - - c. BY-SA Compatible License means a license listed at creativecommons.org/compatiblelicenses, approved by Creative Commons as essentially the equivalent of this Public License. - - d. Copyright and Similar Rights means copyright and/or similar rights closely related to copyright including, without limitation, performance, broadcast, sound recording, and Sui Generis Database Rights, without regard to how the rights are labeled or categorized. For purposes of this Public License, the rights specified in Section 2(b)(1)-(2) are not Copyright and Similar Rights. - - e. Effective Technological Measures means those measures that, in the absence of proper authority, may not be circumvented under laws fulfilling obligations under Article 11 of the WIPO Copyright Treaty adopted on December 20, 1996, and/or similar international agreements. +Creative Commons Corporation ("Creative Commons") is not a law firm and +does not provide legal services or legal advice. Distribution of +Creative Commons public licenses does not create a lawyer-client or +other relationship. Creative Commons makes its licenses and related +information available on an "as-is" basis. Creative Commons gives no +warranties regarding its licenses, any material licensed under their +terms and conditions, or any related information. Creative Commons +disclaims all liability for damages resulting from their use to the +fullest extent possible. - f. Exceptions and Limitations means fair use, fair dealing, and/or any other exception or limitation to Copyright and Similar Rights that applies to Your use of the Licensed Material. - - g. License Elements means the license attributes listed in the name of a Creative Commons Public License. The License Elements of this Public License are Attribution and ShareAlike. - - h. Licensed Material means the artistic or literary work, database, or other material to which the Licensor applied this Public License. - - i. Licensed Rights means the rights granted to You subject to the terms and conditions of this Public License, which are limited to all Copyright and Similar Rights that apply to Your use of the Licensed Material and that the Licensor has authority to license. - - j. Licensor means the individual(s) or entity(ies) granting rights under this Public License. - - k. Share means to provide material to the public by any means or process that requires permission under the Licensed Rights, such as reproduction, public display, public performance, distribution, dissemination, communication, or importation, and to make material available to the public including in ways that members of the public may access the material from a place and at a time individually chosen by them. - - l. Sui Generis Database Rights means rights other than copyright resulting from Directive 96/9/EC of the European Parliament and of the Council of 11 March 1996 on the legal protection of databases, as amended and/or succeeded, as well as other essentially equivalent rights anywhere in the world. - - m. You means the individual or entity exercising the Licensed Rights under this Public License. Your has a corresponding meaning. - -Section 2 – Scope. - - a. License grant. - - 1. Subject to the terms and conditions of this Public License, the Licensor hereby grants You a worldwide, royalty-free, non-sublicensable, non-exclusive, irrevocable license to exercise the Licensed Rights in the Licensed Material to: - - A. reproduce and Share the Licensed Material, in whole or in part; and +Using Creative Commons Public Licenses - B. produce, reproduce, and Share Adapted Material. +Creative Commons public licenses provide a standard set of terms and +conditions that creators and other rights holders may use to share +original works of authorship and other material subject to copyright +and certain other rights specified in the public license below. The +following considerations are for informational purposes only, are not +exhaustive, and do not form part of our licenses. + + Considerations for licensors: Our public licenses are + intended for use by those authorized to give the public + permission to use material in ways otherwise restricted by + copyright and certain other rights. Our licenses are + irrevocable. Licensors should read and understand the terms + and conditions of the license they choose before applying it. + Licensors should also secure all rights necessary before + applying our licenses so that the public can reuse the + material as expected. Licensors should clearly mark any + material not subject to the license. This includes other CC- + licensed material, or material used under an exception or + limitation to copyright. More considerations for licensors: + wiki.creativecommons.org/Considerations_for_licensors + + Considerations for the public: By using one of our public + licenses, a licensor grants the public permission to use the + licensed material under specified terms and conditions. If + the licensor's permission is not necessary for any reason--for + example, because of any applicable exception or limitation to + copyright--then that use is not regulated by the license. Our + licenses grant only permissions under copyright and certain + other rights that a licensor has authority to grant. Use of + the licensed material may still be restricted for other + reasons, including because others have copyright or other + rights in the material. A licensor may make special requests, + such as asking that all changes be marked or described. + Although not required by our licenses, you are encouraged to + respect those requests where reasonable. More considerations + for the public: + wiki.creativecommons.org/Considerations_for_licensees + +======================================================================= + +Creative Commons Attribution-ShareAlike 4.0 International Public +License + +By exercising the Licensed Rights (defined below), You accept and agree +to be bound by the terms and conditions of this Creative Commons +Attribution-ShareAlike 4.0 International Public License ("Public +License"). To the extent this Public License may be interpreted as a +contract, You are granted the Licensed Rights in consideration of Your +acceptance of these terms and conditions, and the Licensor grants You +such rights in consideration of benefits the Licensor receives from +making the Licensed Material available under these terms and +conditions. + + +Section 1 -- Definitions. + + a. Adapted Material means material subject to Copyright and Similar + Rights that is derived from or based upon the Licensed Material + and in which the Licensed Material is translated, altered, + arranged, transformed, or otherwise modified in a manner requiring + permission under the Copyright and Similar Rights held by the + Licensor. For purposes of this Public License, where the Licensed + Material is a musical work, performance, or sound recording, + Adapted Material is always produced where the Licensed Material is + synched in timed relation with a moving image. + + b. Adapter's License means the license You apply to Your Copyright + and Similar Rights in Your contributions to Adapted Material in + accordance with the terms and conditions of this Public License. + + c. BY-SA Compatible License means a license listed at + creativecommons.org/compatiblelicenses, approved by Creative + Commons as essentially the equivalent of this Public License. + + d. Copyright and Similar Rights means copyright and/or similar rights + closely related to copyright including, without limitation, + performance, broadcast, sound recording, and Sui Generis Database + Rights, without regard to how the rights are labeled or + categorized. For purposes of this Public License, the rights + specified in Section 2(b)(1)-(2) are not Copyright and Similar + Rights. + + e. Effective Technological Measures means those measures that, in the + absence of proper authority, may not be circumvented under laws + fulfilling obligations under Article 11 of the WIPO Copyright + Treaty adopted on December 20, 1996, and/or similar international + agreements. + + f. Exceptions and Limitations means fair use, fair dealing, and/or + any other exception or limitation to Copyright and Similar Rights + that applies to Your use of the Licensed Material. + + g. License Elements means the license attributes listed in the name + of a Creative Commons Public License. The License Elements of this + Public License are Attribution and ShareAlike. + + h. Licensed Material means the artistic or literary work, database, + or other material to which the Licensor applied this Public + License. + + i. Licensed Rights means the rights granted to You subject to the + terms and conditions of this Public License, which are limited to + all Copyright and Similar Rights that apply to Your use of the + Licensed Material and that the Licensor has authority to license. + + j. Licensor means the individual(s) or entity(ies) granting rights + under this Public License. + + k. Share means to provide material to the public by any means or + process that requires permission under the Licensed Rights, such + as reproduction, public display, public performance, distribution, + dissemination, communication, or importation, and to make material + available to the public including in ways that members of the + public may access the material from a place and at a time + individually chosen by them. + + l. Sui Generis Database Rights means rights other than copyright + resulting from Directive 96/9/EC of the European Parliament and of + the Council of 11 March 1996 on the legal protection of databases, + as amended and/or succeeded, as well as other essentially + equivalent rights anywhere in the world. + + m. You means the individual or entity exercising the Licensed Rights + under this Public License. Your has a corresponding meaning. + + +Section 2 -- Scope. + + a. License grant. + + 1. Subject to the terms and conditions of this Public License, + the Licensor hereby grants You a worldwide, royalty-free, + non-sublicensable, non-exclusive, irrevocable license to + exercise the Licensed Rights in the Licensed Material to: + + a. reproduce and Share the Licensed Material, in whole or + in part; and + + b. produce, reproduce, and Share Adapted Material. + + 2. Exceptions and Limitations. For the avoidance of doubt, where + Exceptions and Limitations apply to Your use, this Public + License does not apply, and You do not need to comply with + its terms and conditions. + + 3. Term. The term of this Public License is specified in Section + 6(a). + + 4. Media and formats; technical modifications allowed. The + Licensor authorizes You to exercise the Licensed Rights in + all media and formats whether now known or hereafter created, + and to make technical modifications necessary to do so. The + Licensor waives and/or agrees not to assert any right or + authority to forbid You from making technical modifications + necessary to exercise the Licensed Rights, including + technical modifications necessary to circumvent Effective + Technological Measures. For purposes of this Public License, + simply making modifications authorized by this Section 2(a) + (4) never produces Adapted Material. + + 5. Downstream recipients. + + a. Offer from the Licensor -- Licensed Material. Every + recipient of the Licensed Material automatically + receives an offer from the Licensor to exercise the + Licensed Rights under the terms and conditions of this + Public License. + + b. Additional offer from the Licensor -- Adapted Material. + Every recipient of Adapted Material from You + automatically receives an offer from the Licensor to + exercise the Licensed Rights in the Adapted Material + under the conditions of the Adapter's License You apply. + + c. No downstream restrictions. You may not offer or impose + any additional or different terms or conditions on, or + apply any Effective Technological Measures to, the + Licensed Material if doing so restricts exercise of the + Licensed Rights by any recipient of the Licensed + Material. - 2. Exceptions and Limitations. For the avoidance of doubt, where Exceptions and Limitations apply to Your use, this Public License does not apply, and You do not need to comply with its terms and conditions. + 6. No endorsement. Nothing in this Public License constitutes or + may be construed as permission to assert or imply that You + are, or that Your use of the Licensed Material is, connected + with, or sponsored, endorsed, or granted official status by, + the Licensor or others designated to receive attribution as + provided in Section 3(a)(1)(A)(i). - 3. Term. The term of this Public License is specified in Section 6(a). + b. Other rights. - 4. Media and formats; technical modifications allowed. The Licensor authorizes You to exercise the Licensed Rights in all media and formats whether now known or hereafter created, and to make technical modifications necessary to do so. The Licensor waives and/or agrees not to assert any right or authority to forbid You from making technical modifications necessary to exercise the Licensed Rights, including technical modifications necessary to circumvent Effective Technological Measures. For purposes of this Public License, simply making modifications authorized by this Section 2(a)(4) never produces Adapted Material. + 1. Moral rights, such as the right of integrity, are not + licensed under this Public License, nor are publicity, + privacy, and/or other similar personality rights; however, to + the extent possible, the Licensor waives and/or agrees not to + assert any such rights held by the Licensor to the limited + extent necessary to allow You to exercise the Licensed + Rights, but not otherwise. - 5. Downstream recipients. + 2. Patent and trademark rights are not licensed under this + Public License. - A. Offer from the Licensor – Licensed Material. Every recipient of the Licensed Material automatically receives an offer from the Licensor to exercise the Licensed Rights under the terms and conditions of this Public License. + 3. To the extent possible, the Licensor waives any right to + collect royalties from You for the exercise of the Licensed + Rights, whether directly or through a collecting society + under any voluntary or waivable statutory or compulsory + licensing scheme. In all other cases the Licensor expressly + reserves any right to collect such royalties. - B. Additional offer from the Licensor – Adapted Material. Every recipient of Adapted Material from You automatically receives an offer from the Licensor to exercise the Licensed Rights in the Adapted Material under the conditions of the Adapter’s License You apply. - C. No downstream restrictions. You may not offer or impose any additional or different terms or conditions on, or apply any Effective Technological Measures to, the Licensed Material if doing so restricts exercise of the Licensed Rights by any recipient of the Licensed Material. +Section 3 -- License Conditions. - 6. No endorsement. Nothing in this Public License constitutes or may be construed as permission to assert or imply that You are, or that Your use of the Licensed Material is, connected with, or sponsored, endorsed, or granted official status by, the Licensor or others designated to receive attribution as provided in Section 3(a)(1)(A)(i). +Your exercise of the Licensed Rights is expressly made subject to the +following conditions. - b. Other rights. + a. Attribution. - 1. Moral rights, such as the right of integrity, are not licensed under this Public License, nor are publicity, privacy, and/or other similar personality rights; however, to the extent possible, the Licensor waives and/or agrees not to assert any such rights held by the Licensor to the limited extent necessary to allow You to exercise the Licensed Rights, but not otherwise. + 1. If You Share the Licensed Material (including in modified + form), You must: - 2. Patent and trademark rights are not licensed under this Public License. + a. retain the following if it is supplied by the Licensor + with the Licensed Material: - 3. To the extent possible, the Licensor waives any right to collect royalties from You for the exercise of the Licensed Rights, whether directly or through a collecting society under any voluntary or waivable statutory or compulsory licensing scheme. In all other cases the Licensor expressly reserves any right to collect such royalties. + i. identification of the creator(s) of the Licensed + Material and any others designated to receive + attribution, in any reasonable manner requested by + the Licensor (including by pseudonym if + designated); -Section 3 – License Conditions. + ii. a copyright notice; -Your exercise of the Licensed Rights is expressly made subject to the following conditions. + iii. a notice that refers to this Public License; + + iv. a notice that refers to the disclaimer of + warranties; - a. Attribution. + v. a URI or hyperlink to the Licensed Material to the + extent reasonably practicable; - 1. If You Share the Licensed Material (including in modified form), You must: + b. indicate if You modified the Licensed Material and + retain an indication of any previous modifications; and + + c. indicate the Licensed Material is licensed under this + Public License, and include the text of, or the URI or + hyperlink to, this Public License. - A. retain the following if it is supplied by the Licensor with the Licensed Material: + 2. You may satisfy the conditions in Section 3(a)(1) in any + reasonable manner based on the medium, means, and context in + which You Share the Licensed Material. For example, it may be + reasonable to satisfy the conditions by providing a URI or + hyperlink to a resource that includes the required + information. + + 3. If requested by the Licensor, You must remove any of the + information required by Section 3(a)(1)(A) to the extent + reasonably practicable. - i. identification of the creator(s) of the Licensed Material and any others designated to receive attribution, in any reasonable manner requested by the Licensor (including by pseudonym if designated); + b. ShareAlike. - ii. a copyright notice; + In addition to the conditions in Section 3(a), if You Share + Adapted Material You produce, the following conditions also apply. - iii. a notice that refers to this Public License; + 1. The Adapter's License You apply must be a Creative Commons + license with the same License Elements, this version or + later, or a BY-SA Compatible License. - iv. a notice that refers to the disclaimer of warranties; + 2. You must include the text of, or the URI or hyperlink to, the + Adapter's License You apply. You may satisfy this condition + in any reasonable manner based on the medium, means, and + context in which You Share Adapted Material. - v. a URI or hyperlink to the Licensed Material to the extent reasonably practicable; + 3. You may not offer or impose any additional or different terms + or conditions on, or apply any Effective Technological + Measures to, Adapted Material that restrict exercise of the + rights granted under the Adapter's License You apply. - B. indicate if You modified the Licensed Material and retain an indication of any previous modifications; and - C. indicate the Licensed Material is licensed under this Public License, and include the text of, or the URI or hyperlink to, this Public License. +Section 4 -- Sui Generis Database Rights. - 2. You may satisfy the conditions in Section 3(a)(1) in any reasonable manner based on the medium, means, and context in which You Share the Licensed Material. For example, it may be reasonable to satisfy the conditions by providing a URI or hyperlink to a resource that includes the required information. +Where the Licensed Rights include Sui Generis Database Rights that +apply to Your use of the Licensed Material: - 3. If requested by the Licensor, You must remove any of the information required by Section 3(a)(1)(A) to the extent reasonably practicable. + a. for the avoidance of doubt, Section 2(a)(1) grants You the right + to extract, reuse, reproduce, and Share all or a substantial + portion of the contents of the database; - b. ShareAlike.In addition to the conditions in Section 3(a), if You Share Adapted Material You produce, the following conditions also apply. + b. if You include all or a substantial portion of the database + contents in a database in which You have Sui Generis Database + Rights, then the database in which You have Sui Generis Database + Rights (but not its individual contents) is Adapted Material, + including for purposes of Section 3(b); and - 1. The Adapter’s License You apply must be a Creative Commons license with the same License Elements, this version or later, or a BY-SA Compatible License. + c. You must comply with the conditions in Section 3(a) if You Share + all or a substantial portion of the contents of the database. - 2. You must include the text of, or the URI or hyperlink to, the Adapter's License You apply. You may satisfy this condition in any reasonable manner based on the medium, means, and context in which You Share Adapted Material. +For the avoidance of doubt, this Section 4 supplements and does not +replace Your obligations under this Public License where the Licensed +Rights include other Copyright and Similar Rights. - 3. You may not offer or impose any additional or different terms or conditions on, or apply any Effective Technological Measures to, Adapted Material that restrict exercise of the rights granted under the Adapter's License You apply. -Section 4 – Sui Generis Database Rights. +Section 5 -- Disclaimer of Warranties and Limitation of Liability. -Where the Licensed Rights include Sui Generis Database Rights that apply to Your use of the Licensed Material: + a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE + EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS + AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF + ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS, + IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION, + WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR + PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS, + ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT + KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT + ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU. - a. for the avoidance of doubt, Section 2(a)(1) grants You the right to extract, reuse, reproduce, and Share all or a substantial portion of the contents of the database; + b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE + TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION, + NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT, + INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES, + COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR + USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN + ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR + DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR + IN PART, THIS LIMITATION MAY NOT APPLY TO YOU. - b. if You include all or a substantial portion of the database contents in a database in which You have Sui Generis Database Rights, then the database in which You have Sui Generis Database Rights (but not its individual contents) is Adapted Material, including for purposes of Section 3(b); and + c. The disclaimer of warranties and limitation of liability provided + above shall be interpreted in a manner that, to the extent + possible, most closely approximates an absolute disclaimer and + waiver of all liability. - c. You must comply with the conditions in Section 3(a) if You Share all or a substantial portion of the contents of the database. -For the avoidance of doubt, this Section 4 supplements and does not replace Your obligations under this Public License where the Licensed Rights include other Copyright and Similar Rights. -Section 5 – Disclaimer of Warranties and Limitation of Liability. +Section 6 -- Term and Termination. - a. Unless otherwise separately undertaken by the Licensor, to the extent possible, the Licensor offers the Licensed Material as-is and as-available, and makes no representations or warranties of any kind concerning the Licensed Material, whether express, implied, statutory, or other. This includes, without limitation, warranties of title, merchantability, fitness for a particular purpose, non-infringement, absence of latent or other defects, accuracy, or the presence or absence of errors, whether or not known or discoverable. Where disclaimers of warranties are not allowed in full or in part, this disclaimer may not apply to You. + a. This Public License applies for the term of the Copyright and + Similar Rights licensed here. However, if You fail to comply with + this Public License, then Your rights under this Public License + terminate automatically. - b. To the extent possible, in no event will the Licensor be liable to You on any legal theory (including, without limitation, negligence) or otherwise for any direct, special, indirect, incidental, consequential, punitive, exemplary, or other losses, costs, expenses, or damages arising out of this Public License or use of the Licensed Material, even if the Licensor has been advised of the possibility of such losses, costs, expenses, or damages. Where a limitation of liability is not allowed in full or in part, this limitation may not apply to You. + b. Where Your right to use the Licensed Material has terminated under + Section 6(a), it reinstates: - c. The disclaimer of warranties and limitation of liability provided above shall be interpreted in a manner that, to the extent possible, most closely approximates an absolute disclaimer and waiver of all liability. + 1. automatically as of the date the violation is cured, provided + it is cured within 30 days of Your discovery of the + violation; or -Section 6 – Term and Termination. + 2. upon express reinstatement by the Licensor. - a. This Public License applies for the term of the Copyright and Similar Rights licensed here. However, if You fail to comply with this Public License, then Your rights under this Public License terminate automatically. + For the avoidance of doubt, this Section 6(b) does not affect any + right the Licensor may have to seek remedies for Your violations + of this Public License. - b. Where Your right to use the Licensed Material has terminated under Section 6(a), it reinstates: + c. For the avoidance of doubt, the Licensor may also offer the + Licensed Material under separate terms or conditions or stop + distributing the Licensed Material at any time; however, doing so + will not terminate this Public License. - 1. automatically as of the date the violation is cured, provided it is cured within 30 days of Your discovery of the violation; or + d. Sections 1, 5, 6, 7, and 8 survive termination of this Public + License. - 2. upon express reinstatement by the Licensor. - c. For the avoidance of doubt, this Section 6(b) does not affect any right the Licensor may have to seek remedies for Your violations of this Public License. +Section 7 -- Other Terms and Conditions. - d. For the avoidance of doubt, the Licensor may also offer the Licensed Material under separate terms or conditions or stop distributing the Licensed Material at any time; however, doing so will not terminate this Public License. + a. The Licensor shall not be bound by any additional or different + terms or conditions communicated by You unless expressly agreed. - e. Sections 1, 5, 6, 7, and 8 survive termination of this Public License. + b. Any arrangements, understandings, or agreements regarding the + Licensed Material not stated herein are separate from and + independent of the terms and conditions of this Public License. -Section 7 – Other Terms and Conditions. - a. The Licensor shall not be bound by any additional or different terms or conditions communicated by You unless expressly agreed. +Section 8 -- Interpretation. - b. Any arrangements, understandings, or agreements regarding the Licensed Material not stated herein are separate from and independent of the terms and conditions of this Public License. + a. For the avoidance of doubt, this Public License does not, and + shall not be interpreted to, reduce, limit, restrict, or impose + conditions on any use of the Licensed Material that could lawfully + be made without permission under this Public License. -Section 8 – Interpretation. + b. To the extent possible, if any provision of this Public License is + deemed unenforceable, it shall be automatically reformed to the + minimum extent necessary to make it enforceable. If the provision + cannot be reformed, it shall be severed from this Public License + without affecting the enforceability of the remaining terms and + conditions. - a. For the avoidance of doubt, this Public License does not, and shall not be interpreted to, reduce, limit, restrict, or impose conditions on any use of the Licensed Material that could lawfully be made without permission under this Public License. + c. No term or condition of this Public License will be waived and no + failure to comply consented to unless expressly agreed to by the + Licensor. - b. To the extent possible, if any provision of this Public License is deemed unenforceable, it shall be automatically reformed to the minimum extent necessary to make it enforceable. If the provision cannot be reformed, it shall be severed from this Public License without affecting the enforceability of the remaining terms and conditions. + d. Nothing in this Public License constitutes or may be interpreted + as a limitation upon, or waiver of, any privileges and immunities + that apply to the Licensor or You, including from the legal + processes of any jurisdiction or authority. - c. No term or condition of this Public License will be waived and no failure to comply consented to unless expressly agreed to by the Licensor. - d. Nothing in this Public License constitutes or may be interpreted as a limitation upon, or waiver of, any privileges and immunities that apply to the Licensor or You, including from the legal processes of any jurisdiction or authority. +======================================================================= -Creative Commons is not a party to its public licenses. Notwithstanding, Creative Commons may elect to apply one of its public licenses to material it publishes and in those instances will be considered the “Licensor.” Except for the limited purpose of indicating that material is shared under a Creative Commons public license or as otherwise permitted by the Creative Commons policies published at creativecommons.org/policies, Creative Commons does not authorize the use of the trademark “Creative Commons” or any other trademark or logo of Creative Commons without its prior written consent including, without limitation, in connection with any unauthorized modifications to any of its public licenses or any other arrangements, understandings, or agreements concerning use of licensed material. For the avoidance of doubt, this paragraph does not form part of the public licenses. +Creative Commons is not a party to its public +licenses. Notwithstanding, Creative Commons may elect to apply one of +its public licenses to material it publishes and in those instances +will be considered the “Licensor.” The text of the Creative Commons +public licenses is dedicated to the public domain under the CC0 Public +Domain Dedication. Except for the limited purpose of indicating that +material is shared under a Creative Commons public license or as +otherwise permitted by the Creative Commons policies published at +creativecommons.org/policies, Creative Commons does not authorize the +use of the trademark "Creative Commons" or any other trademark or logo +of Creative Commons without its prior written consent including, +without limitation, in connection with any unauthorized modifications +to any of its public licenses or any other arrangements, +understandings, or agreements concerning use of licensed material. For +the avoidance of doubt, this paragraph does not form part of the +public licenses. Creative Commons may be contacted at creativecommons.org. + diff --git a/LICENSES/MPL-2.0.txt b/LICENSES/MPL-2.0.txt index 14e2f77..d0a1fa1 100644 --- a/LICENSES/MPL-2.0.txt +++ b/LICENSES/MPL-2.0.txt @@ -35,7 +35,7 @@ Mozilla Public License Version 2.0 means any form of the work other than Source Code Form. 1.7. "Larger Work" - means a work that combines Covered Software with other material, in + means a work that combines Covered Software with other material, in a separate file or files, that is not Covered Software. 1.8. "License" @@ -357,7 +357,7 @@ Exhibit A - Source Code Form License Notice This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed with this - file, You can obtain one at http://mozilla.org/MPL/2.0/. + file, You can obtain one at https://mozilla.org/MPL/2.0/. If it is not possible or desirable to put the notice in a particular file, then You may include the notice in a location (such as a LICENSE From 95f3f28844ec43264cc45fc9ab643001d9cac81f Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Sat, 18 Jul 2026 22:41:48 +0100 Subject: [PATCH 13/21] sweep4: add C-A-G-M files Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe --- .github/funding.yml | 4 +++ GOVERNANCE.md | 60 +++++++++++++++++++++++++++++++++++++++++++++ MAINTAINERS | 43 ++++++++++++++++++++++++++++++++ 3 files changed, 107 insertions(+) create mode 100644 .github/funding.yml create mode 100644 GOVERNANCE.md create mode 100644 MAINTAINERS diff --git a/.github/funding.yml b/.github/funding.yml new file mode 100644 index 0000000..e4f7c07 --- /dev/null +++ b/.github/funding.yml @@ -0,0 +1,4 @@ +# Funding Configuration +# See: https://docs.github.com/en/repositories/managing-your-repositorys-custom-fields/displaying-a-sponsor-button-in-your-repository + +github: metadatastician diff --git a/GOVERNANCE.md b/GOVERNANCE.md new file mode 100644 index 0000000..e27364c --- /dev/null +++ b/GOVERNANCE.md @@ -0,0 +1,60 @@ +# Governance + +## Overview + +This project is governed by the following principles and structures to ensure transparent, inclusive, and effective decision-making. + +## Roles and Responsibilities + +### Maintainers + +Maintainers are responsible for: +- Reviewing and merging pull requests +- Managing releases and versioning +- Ensuring code quality and standards +- Triaging issues and bug reports +- Community engagement and support + +### Contributors + +Contributors are expected to: +- Follow the code of conduct +- Submit well-documented pull requests +- Write tests for new functionality +- Maintain existing tests +- Update documentation as needed + +## Decision Making + +### Minor Changes +- Can be made by any maintainer +- Include bug fixes, documentation updates, dependency updates + +### Major Changes +- Require discussion in issues or pull requests +- Include new features, architectural changes, API changes +- Need approval from at least 2 maintainers + +### Breaking Changes +- Require RFC (Request for Comments) process +- Need approval from majority of maintainers +- Must include migration guide + +## Code of Conduct + +All participants are expected to follow our Code of Conduct. Violations can be reported to the maintainers. + +## Communication + +- **Issues**: For bug reports and feature requests +- **Discussions**: For questions and general discussion +- **Pull Requests**: For code contributions + +## Licensing + +All contributions are made under the terms of the repository's LICENSE file. +By submitting a pull request, you agree to license your contributions accordingly. + +--- + +*Last updated: 2026-07-18* diff --git a/MAINTAINERS b/MAINTAINERS new file mode 100644 index 0000000..37f6411 --- /dev/null +++ b/MAINTAINERS @@ -0,0 +1,43 @@ +# Maintainers + +This file lists the current maintainers of this project. + +## Active Maintainers + +| Name | GitHub | Role | Since | +|------|--------|------|-------| +| Metadatastician | @metadatastician | Primary | Project Start | + +## Emeritus Maintainers + +None at this time. + +## Becoming a Maintainer + +To become a maintainer: + +1. Demonstrate consistent, high-quality contributions +2. Show understanding of the project's goals and architecture +3. Be active in code reviews and community discussions +4. Be nominated by an existing maintainer +5. Be approved by consensus of existing maintainers + +## Maintainer Responsibilities + +- Reviewing and merging pull requests +- Managing releases +- Triaging issues +- Enforcing code standards +- Mentoring new contributors +- Participating in decision-making + +## Maintainer Expectations + +- Respond to issues and PRs in a timely manner +- Follow the code of conduct +- Be transparent in decision-making +- Communicate clearly and respectfully + +--- + +*Last updated: 2026-07-18* From 1db8f02eca4013b66fd9df0ee40ac9eab9b66601 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Sun, 26 Jul 2026 14:46:34 +0100 Subject: [PATCH 14/21] chore: estate-wide security compliance --- .github/workflows/boj-build.yml | 1 + .github/workflows/cargo-audit.yml | 1 + .github/workflows/casket-pages.yml | 1 + .github/workflows/codeql.yml | 1 + .github/workflows/dashboard-check.yml | 1 + .github/workflows/dependabot-automerge.yml | 1 + .github/workflows/dogfood-gate.yml | 1 + .github/workflows/e2e.yml | 1 + .github/workflows/governance.yml | 1 + .github/workflows/hypatia-scan.yml | 1 + .github/workflows/instant-sync.yml | 1 + .github/workflows/mirror.yml | 1 + .github/workflows/pages.yml | 1 + .github/workflows/push-email-notify.yml | 1 + .github/workflows/rust-ci.yml | 1 + .github/workflows/scorecard.yml | 1 + .github/workflows/secret-scanner.yml | 1 + .github/workflows/semgrep.yml | 1 + .github/workflows/workflow-linter.yml | 1 + 19 files changed, 19 insertions(+) diff --git a/.github/workflows/boj-build.yml b/.github/workflows/boj-build.yml index c4f641d..4fe6b9a 100644 --- a/.github/workflows/boj-build.yml +++ b/.github/workflows/boj-build.yml @@ -17,4 +17,5 @@ jobs: curl -X POST "http://boj-server.local:7700/cartridges/ssg-mcp/invoke" -H "Content-Type: application/json" -d "{\"repo\": \"${{ github.repository }}\", \"branch\": \"${{ github.ref_name }}\", \"engine\": \"casket\\"}"} continue-on-error: true permissions: + actions: read contents: read diff --git a/.github/workflows/cargo-audit.yml b/.github/workflows/cargo-audit.yml index a0eb033..717119c 100644 --- a/.github/workflows/cargo-audit.yml +++ b/.github/workflows/cargo-audit.yml @@ -16,6 +16,7 @@ on: - cron: '0 6 * * 1' # Weekly on Monday permissions: read-all + actions: read jobs: audit: diff --git a/.github/workflows/casket-pages.yml b/.github/workflows/casket-pages.yml index a849da3..ae916dc 100644 --- a/.github/workflows/casket-pages.yml +++ b/.github/workflows/casket-pages.yml @@ -7,6 +7,7 @@ on: workflow_dispatch: permissions: + actions: read contents: read pages: write id-token: write diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 82afee6..d2e482b 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -18,6 +18,7 @@ concurrency: cancel-in-progress: true permissions: + actions: read contents: read jobs: diff --git a/.github/workflows/dashboard-check.yml b/.github/workflows/dashboard-check.yml index 40f26fe..04020a9 100644 --- a/.github/workflows/dashboard-check.yml +++ b/.github/workflows/dashboard-check.yml @@ -13,6 +13,7 @@ on: branches: [main, master] permissions: + actions: read contents: read jobs: diff --git a/.github/workflows/dependabot-automerge.yml b/.github/workflows/dependabot-automerge.yml index 3d0e8de..702479e 100644 --- a/.github/workflows/dependabot-automerge.yml +++ b/.github/workflows/dependabot-automerge.yml @@ -41,6 +41,7 @@ on: types: [opened, reopened, synchronize] permissions: + actions: read contents: write # needed to enable auto-merge pull-requests: write # needed to approve # NB: keep narrow — do NOT add secrets: read or id-token: write here. diff --git a/.github/workflows/dogfood-gate.yml b/.github/workflows/dogfood-gate.yml index 3f792d8..1178946 100644 --- a/.github/workflows/dogfood-gate.yml +++ b/.github/workflows/dogfood-gate.yml @@ -13,6 +13,7 @@ on: branches: [main, master] permissions: + actions: read contents: read jobs: diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index bfb19b0..5982b97 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -9,6 +9,7 @@ on: branches: [main] permissions: read-all + actions: read jobs: rust-build-test: diff --git a/.github/workflows/governance.yml b/.github/workflows/governance.yml index 156264a..cc62f6c 100644 --- a/.github/workflows/governance.yml +++ b/.github/workflows/governance.yml @@ -27,6 +27,7 @@ concurrency: cancel-in-progress: true permissions: + actions: read contents: read jobs: diff --git a/.github/workflows/hypatia-scan.yml b/.github/workflows/hypatia-scan.yml index 2e7e939..013c95a 100644 --- a/.github/workflows/hypatia-scan.yml +++ b/.github/workflows/hypatia-scan.yml @@ -19,6 +19,7 @@ concurrency: cancel-in-progress: true permissions: + actions: read contents: read security-events: write pull-requests: write diff --git a/.github/workflows/instant-sync.yml b/.github/workflows/instant-sync.yml index 01646a7..4a11f86 100644 --- a/.github/workflows/instant-sync.yml +++ b/.github/workflows/instant-sync.yml @@ -9,6 +9,7 @@ on: types: [published] permissions: + actions: read contents: read jobs: diff --git a/.github/workflows/mirror.yml b/.github/workflows/mirror.yml index 81e9903..c25d3bc 100644 --- a/.github/workflows/mirror.yml +++ b/.github/workflows/mirror.yml @@ -7,6 +7,7 @@ on: workflow_dispatch: permissions: + actions: read contents: read jobs: diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index a1a1aa3..31f3ddc 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -5,6 +5,7 @@ on: branches: [main, master] workflow_dispatch: permissions: + actions: read contents: read pages: write id-token: write diff --git a/.github/workflows/push-email-notify.yml b/.github/workflows/push-email-notify.yml index 2a95f5d..c08ea16 100644 --- a/.github/workflows/push-email-notify.yml +++ b/.github/workflows/push-email-notify.yml @@ -7,6 +7,7 @@ name: Push email notification on: push: {} permissions: + actions: read contents: read jobs: notify: diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index 2499107..89c5bdb 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -10,6 +10,7 @@ on: pull_request: permissions: + actions: read contents: read jobs: diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 2d9ea22..8afb3f3 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -9,6 +9,7 @@ on: branches: [main] permissions: read-all + actions: read jobs: analysis: diff --git a/.github/workflows/secret-scanner.yml b/.github/workflows/secret-scanner.yml index b0db585..e3c586f 100644 --- a/.github/workflows/secret-scanner.yml +++ b/.github/workflows/secret-scanner.yml @@ -11,6 +11,7 @@ concurrency: cancel-in-progress: true permissions: + actions: read contents: read jobs: diff --git a/.github/workflows/semgrep.yml b/.github/workflows/semgrep.yml index 7720039..c41f6a6 100644 --- a/.github/workflows/semgrep.yml +++ b/.github/workflows/semgrep.yml @@ -11,6 +11,7 @@ on: workflow_dispatch: permissions: + actions: read contents: read jobs: diff --git a/.github/workflows/workflow-linter.yml b/.github/workflows/workflow-linter.yml index 67b6a82..9ad08b3 100644 --- a/.github/workflows/workflow-linter.yml +++ b/.github/workflows/workflow-linter.yml @@ -11,6 +11,7 @@ on: - '.github/workflows/**' permissions: read-all + actions: read jobs: lint-workflows: From 27dc79e83a67cafd93bffd2d1c51f0deee16613f Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Sun, 26 Jul 2026 15:09:42 +0100 Subject: [PATCH 15/21] chore: remove duplicate GOVERNANCE files, keep GOVERNANCE.md --- GOVERNANCE.adoc | 162 ------------------------------------------------ 1 file changed, 162 deletions(-) delete mode 100644 GOVERNANCE.adoc diff --git a/GOVERNANCE.adoc b/GOVERNANCE.adoc deleted file mode 100644 index e41020d..0000000 --- a/GOVERNANCE.adoc +++ /dev/null @@ -1,162 +0,0 @@ -// SPDX-License-Identifier: CC-BY-SA-4.0 -// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell -= Governance Model -:toc: preamble - -This document describes the governance model for this repository. - -== Overview - -This repository follows a **Sole Maintainer Governance Model**: - -* Single maintainer (@hyperpolymath) has full authority over the project -* All contributions are welcome and reviewed by the maintainer -* Decisions are made transparently through GitHub issues and discussions -* The project adheres to the hyperpolymath estate policies where applicable - -== Core Principles - -[cols="1,2"] -|=== -| Principle | Description - -| **Benevolent Dictatorship** | Maintainer has final decision authority but seeks community input - -| **Meritocracy** | Contributions are judged on technical merit, not contributor identity - -| **Transparency** | All significant decisions are documented publicly - -| **Consensus-Seeking** | Maintainer prefers consensus but will decide when necessary - -| **Open Contribution** | Anyone can contribute via fork and pull request - -|=== - -== Roles and Permissions - -[cols="1,2,2"] -|=== -| Role | Permissions | Assignment - -| **Maintainer** | Write access, merge rights, admin | @hyperpolymath -| **Contributors** | Read access, fork, submit PRs | All GitHub users -| **Users** | Use the software, report issues | All GitHub users - -|=== - -== Decision Making Framework - -=== Routine Decisions - -* Bug fixes -* Documentation improvements -* Minor feature additions -* Dependency updates - -**Process**: Maintainer reviews and merges PRs that meet quality standards. - -=== Significant Changes - -* New major features -* API changes -* Architecture modifications -* Breaking changes - -**Process**: -. Open issue describing the change -. Discuss with community (minimum 72 hours) -. Maintainer makes final decision -. Document rationale in issue/PR - -=== Structural Decisions - -* Repository purpose/renaming -* License changes -* Ownership transfer -* Deprecation/archival - -**Process**: -. Extended discussion (minimum 1 week) -. Maintainer makes final decision -. Document in CHANGELOG and governance docs - -== Contribution Lifecycle - -[cols="1,2"] -|=== -| Stage | Process - -| **Ideation** | Open issue, discuss feasibility - -| **Development** | Fork, implement, test thoroughly - -| **Review** | Submit PR, maintainer reviews within 7 days - -| **Merge** | Maintainer merges or requests changes - -| **Release** | Maintainer publishes according to project conventions - -|=== - -== Conflict Resolution - -In case of disagreements: - -. Discuss in the relevant GitHub issue or PR -. Provide technical justification for positions -. Maintainer mediates and makes final decision -. Decision is documented and can be revisited later - -== Project Policies - -This repository adheres to hyperpolymath estate-wide policies: - -* **License**: MPL-2.0 for code, CC-BY-SA-4.0 for prose (per standards/LICENCE-POLICY.adoc) -* **Code of Conduct**: Follows hyperpolymath CODE_OF_CONDUCT.md -* **Security**: Follows hyperpolymath SECURITY.md -* **Contributing**: Follows hyperpolymath CONTRIBUTING.adoc conventions - -== Repository-Specific Conventions - -[cols="1,2"] -|=== -| Convention | Description - -| **Signing** | All commits must be signed (SSH or GPG) - -| **SPDX Headers** | All source files must have SPDX license identifiers - -| **Contractiles** | Mustfile, Trustfile, Intendfile, Adjustfile in root - -| **Machine Readable** | META.a2ml in .machine_readable/6a2/ - -| **CI/CD** | GitHub Actions workflows in .github/workflows/ - -|=== - -== Governance Evolution - -As the project grows, this governance model may evolve: - -* **Adding Co-Maintainers**: When contribution volume warrants it -* **Forming a Team**: For complex multi-maintainer projects -* **Adopting TPCF**: For large, multi-repository projects (see rhodium-standard-repositories) - -Changes to this document require the same process as Significant Changes above. - -== See Also - -* link:MAINTAINERS.adoc[Maintainers] -* link:CODE_OF_CONDUCT.md[Code of Conduct] -* link:CONTRIBUTING.adoc[Contributing Guide] -* link:https://github.com/hyperpolymath/standards/blob/main/LICENCE-POLICY.adoc[Estate License Policy] -* link:https://github.com/hyperpolymath/standards[rhodium-standard-repositories (TPCF)] - -== Changelog - -[cols="1,1,1"] -|=== -| Date | Change | By - -| 2026-06-07 | Initial governance model established | @hyperpolymath -|=== From b20cb06be0dc5a50a905ada5b21273da34906be7 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Sun, 26 Jul 2026 15:44:12 +0100 Subject: [PATCH 16/21] fix: update CodeQL actions to SHA-pinned v3 (29b1f65c) --- .github/workflows/codeql.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index d2e482b..b37f307 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -45,12 +45,12 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Initialize CodeQL - uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v3 + uses: github/codeql-action/init@29b1f65c1f735799893313399435a59f54045865 # v3 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v3 + uses: github/codeql-action/analyze@29b1f65c1f735799893313399435a59f54045865 # v3 with: category: "/language:${{ matrix.language }}" From 0b79cf61d435b3597935083e67fecd849853ba30 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Thu, 13 Aug 2026 03:40:35 +0100 Subject: [PATCH 17/21] fix(ci): update reusable workflow SHAs to @7fdc2705df74b4e352d2a1cde3e87a5923fdf329 Part of estate-wide standards#426 remediation - Batch 11 SHA update. Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe --- .github/workflows/main-estate-audit.yml | 91 +++++++++++++++++++++++++ .github/workflows/mirror.yml | 2 +- 2 files changed, 92 insertions(+), 1 deletion(-) create mode 100755 .github/workflows/main-estate-audit.yml diff --git a/.github/workflows/main-estate-audit.yml b/.github/workflows/main-estate-audit.yml new file mode 100755 index 0000000..b602e97 --- /dev/null +++ b/.github/workflows/main-estate-audit.yml @@ -0,0 +1,91 @@ +name: Central Estate CI/CD Audit + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + estate-audit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Required Files Gate + uses: hyperpolymath/cicd-suite/actions/required-files-check@main + + - name: Code Hygiene Gate + uses: hyperpolymath/cicd-suite/actions/code-hygiene-check@main + + - name: Manifest Validation Gate + uses: hyperpolymath/cicd-suite/actions/manifest-check@main + + - name: Idris2 ABI Purity Gate + uses: hyperpolymath/cicd-suite/actions/idris2-abi-check@main + + - name: Zig Hexadeca API Gate + uses: hyperpolymath/cicd-suite/actions/zig-hexadeca-check@main + + - name: Contractile Validation Gate + uses: hyperpolymath/cicd-suite/actions/contractile-validation-check@main + + - name: Recipes Set Validation Gate + uses: hyperpolymath/cicd-suite/actions/recipes-set-check@main + + - name: Affirmation Document Gate + uses: hyperpolymath/cicd-suite/actions/affirmation-check@main + + - name: Academic Referencing Gate + uses: hyperpolymath/cicd-suite/actions/referencing-check@main + + - name: Semantic Audit Gate + uses: hyperpolymath/cicd-suite/actions/semantic-audit-check@main + + - name: SPDX License Gate + uses: hyperpolymath/cicd-suite/actions/spdx-license-check@main + + - name: Proof Runner Gate + uses: hyperpolymath/cicd-suite/actions/proof-runner-check@main + + - name: PRAT Testing Gate + uses: hyperpolymath/cicd-suite/actions/prat-check@main + + - name: Panic Attack & Pons Gate + uses: hyperpolymath/cicd-suite/actions/custom-tools-check@main + + - name: WWW & Well-Known Compliance Gate + uses: hyperpolymath/cicd-suite/actions/www-compliance-check@main + + - name: BoJ Cartridge Validation Gate + uses: hyperpolymath/cicd-suite/actions/boj-cartridge-check@main + + - name: Formatting Validation Gate + uses: hyperpolymath/cicd-suite/actions/formatting-check@main + + - name: Accreditations & Badges Gate + uses: hyperpolymath/cicd-suite/actions/badges-check@main + + - name: Metrics Extraction Gate + uses: hyperpolymath/cicd-suite/actions/metrics-check@main + + - name: Linguist & Banned Languages Gate + uses: hyperpolymath/cicd-suite/actions/linguist-check@main + + - name: Test & Benchmarks Dashboard Gate + uses: hyperpolymath/cicd-suite/actions/tests-benches-check@main + + - name: Hosting & Site Status Gate + uses: hyperpolymath/cicd-suite/actions/hosting-check@main + + - name: Git-Sea Analytics Gate + uses: hyperpolymath/cicd-suite/actions/gitsea-check@main + + - name: Trust & Humans Validation Gate + uses: hyperpolymath/cicd-suite/actions/trust-humans-check@main + + - name: Are We UnAPI Gate (Secret Scanning) + uses: hyperpolymath/cicd-suite/actions/secrets-check@main + + - name: Reasonably Good Token Validation Gate + uses: hyperpolymath/cicd-suite/actions/vaulted-tokens-check@main diff --git a/.github/workflows/mirror.yml b/.github/workflows/mirror.yml index c25d3bc..72824fb 100644 --- a/.github/workflows/mirror.yml +++ b/.github/workflows/mirror.yml @@ -12,5 +12,5 @@ permissions: jobs: mirror: - uses: hyperpolymath/standards/.github/workflows/mirror-reusable.yml@d135b05bfc647d0c0fbfedc7e80f37ea50f49236 + uses: hyperpolymath/standards/.github/workflows/mirror-reusable.yml@7fdc2705df74b4e352d2a1cde3e87a5923fdf329 secrets: inherit From 6334fb1caf1231149f806d6c6cb820df0d364562 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Thu, 13 Aug 2026 04:09:02 +0100 Subject: [PATCH 18/21] fix(ci): update reusable workflow SHAs to @7fdc2705df74b4e352d2a1cde3e87a5923fdf329 Part of estate-wide standards#426 remediation - Batch 12 SHA update. Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe --- .github/workflows/secret-scanner.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/secret-scanner.yml b/.github/workflows/secret-scanner.yml index e3c586f..51d27fd 100644 --- a/.github/workflows/secret-scanner.yml +++ b/.github/workflows/secret-scanner.yml @@ -18,4 +18,4 @@ jobs: scan: permissions: contents: read - uses: hyperpolymath/standards/.github/workflows/secret-scanner-reusable.yml@c65436ee3351cd6b0fa14b142938b195efc77586 + uses: hyperpolymath/standards/.github/workflows/secret-scanner-reusable.yml@7fdc2705df74b4e352d2a1cde3e87a5923fdf329 From 7d8efd32c7809b5ed0272043e23d3f9b833f2959 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Thu, 13 Aug 2026 05:06:36 +0100 Subject: [PATCH 19/21] fix(ci): update reusable workflow SHAs to @7fdc2705df74b4e352d2a1cde3e87a5923fdf329 Part of estate-wide standards#426 remediation - Batch 13 SHA update. Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe --- .github/workflows/governance.yml | 2 +- .github/workflows/hypatia-scan.yml | 2 +- .github/workflows/scorecard.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/governance.yml b/.github/workflows/governance.yml index cc62f6c..116e0ee 100644 --- a/.github/workflows/governance.yml +++ b/.github/workflows/governance.yml @@ -32,4 +32,4 @@ permissions: jobs: governance: - uses: hyperpolymath/standards/.github/workflows/governance-reusable.yml@81dbf2dd854b1444fd6236fa2352474383b2c2b9 + uses: hyperpolymath/standards/.github/workflows/governance-reusable.yml@7fdc2705df74b4e352d2a1cde3e87a5923fdf329 diff --git a/.github/workflows/hypatia-scan.yml b/.github/workflows/hypatia-scan.yml index 013c95a..db7320f 100644 --- a/.github/workflows/hypatia-scan.yml +++ b/.github/workflows/hypatia-scan.yml @@ -26,5 +26,5 @@ permissions: jobs: hypatia: - uses: hyperpolymath/standards/.github/workflows/hypatia-scan-reusable.yml@81dbf2dd854b1444fd6236fa2352474383b2c2b9 + uses: hyperpolymath/standards/.github/workflows/hypatia-scan-reusable.yml@7fdc2705df74b4e352d2a1cde3e87a5923fdf329 secrets: inherit \ No newline at end of file diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 8afb3f3..72f05b6 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -16,5 +16,5 @@ jobs: permissions: security-events: write id-token: write - uses: hyperpolymath/standards/.github/workflows/scorecard-reusable.yml@81dbf2dd854b1444fd6236fa2352474383b2c2b9 + uses: hyperpolymath/standards/.github/workflows/scorecard-reusable.yml@7fdc2705df74b4e352d2a1cde3e87a5923fdf329 secrets: inherit From 42262f9da1aa0d8aea6975f14ea726cebe87cf3b Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Thu, 13 Aug 2026 19:19:15 +0100 Subject: [PATCH 20/21] fix(ci): add required permissions for reusable workflows (Bug B) Add security-events: write and id-token: write to workflow-level permissions in scorecard.yml for scorecard-reusable.yml calls. Ensure contents: read at workflow-level for secret-scanner.yml. Part of hyperpolymath/standards#426 remediation - Batch 2. Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe --- .github/workflows/scorecard.yml | 5 ++++- .github/workflows/secret-scanner.yml | 2 -- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 72f05b6..c37ebd8 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -8,7 +8,10 @@ on: push: branches: [main] -permissions: read-all +permissions: + contents: read + security-events: write + id-token: write actions: read jobs: diff --git a/.github/workflows/secret-scanner.yml b/.github/workflows/secret-scanner.yml index 51d27fd..0e0153e 100644 --- a/.github/workflows/secret-scanner.yml +++ b/.github/workflows/secret-scanner.yml @@ -11,9 +11,7 @@ concurrency: cancel-in-progress: true permissions: - actions: read contents: read - jobs: scan: permissions: From ef04aba674188d0ff1ad3f783ae1788e1758b4ee Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Mon, 31 Aug 2026 13:01:41 +0100 Subject: [PATCH 21/21] chore(toolchain): keep .tool-versions -> .mise.toml pin conversion (R-16) Owner ruling 2026-08-28 (R-16/R-20/R-21): keep the pin conversion from the template-sync sweep, revert the rest. Pin content verified against HEAD:.tool-versions before commit. Co-Authored-By: Claude Fable 5 --- .mise.toml | 2 ++ .tool-versions | 1 - 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 .mise.toml delete mode 100644 .tool-versions diff --git a/.mise.toml b/.mise.toml new file mode 100644 index 0000000..3156a04 --- /dev/null +++ b/.mise.toml @@ -0,0 +1,2 @@ +[tools] +rust = "stable" diff --git a/.tool-versions b/.tool-versions deleted file mode 100644 index 3cd07d4..0000000 --- a/.tool-versions +++ /dev/null @@ -1 +0,0 @@ -rust stable