diff --git a/Cargo.toml b/Cargo.toml index ed0638d17..ae4347b00 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -97,8 +97,6 @@ members = [ "crates/graphshell/graphshell-local", "crates/graphshell/graphshell-network", "ports/graphshell", - "ports/knot", - "ports/knot-document", "ports/djinn", "ports/castellan", "ports/distillery", @@ -111,9 +109,6 @@ exclude = [ # The unpublished browser host carries Git-only Genet presentation deps. # Keep it outside release resolution; it remains buildable by manifest path. "ports/graphshell/web", - # The desktop wrapper carries Git-only Genet presentation deps. Keep it - # buildable by manifest path without ordinary Mere resolution pulling winit. - "ports/knot/desktop", # A private cross-product port: it consumes the neighboring Retinue # checkout during the integration phase, so it must not make the Mere # workspace's ordinary build acquire a sibling checkout. @@ -197,8 +192,11 @@ graphshell-local = { path = "crates/graphshell/graphshell-local" } graphshell-endpoint = { version = "0.0.2", path = "crates/graphshell/graphshell-endpoint" } graphshell-stdio = { version = "0.0.2", path = "crates/graphshell/graphshell-stdio" } graphshell-network = { version = "0.0.1", path = "crates/graphshell/graphshell-network" } -knot-editor = { version = "0.0.3", path = "ports/knot" } -knot-document = { version = "0.0.1", path = "ports/knot-document" } +# Knot is an independently releasable product that embeds back into Mere. +# Pin one reviewed source revision; the patch table below makes Mere's own +# workspace the implementation source for Knot's Mere-facing contracts here. +knot-editor = { version = "0.0.3", git = "https://github.com/merely-made/knot-editor.git", rev = "c4d15aa66eee46060081902ba8459f01c9c82f98" } +knot-document = { version = "0.0.1", git = "https://github.com/merely-made/knot-editor.git", rev = "c4d15aa66eee46060081902ba8459f01c9c82f98" } # The credential-keeper port (dramatis plan D4). No longer a reservation: # C1/C2 landed the OTP core, sealed OTP items, the release gate, and the # `keeper` identity surface graphshell re-exports. @@ -422,12 +420,45 @@ retinue = { version = "0.1.1", git = "https://github.com/merely-made/retinue.git hkdf = "0.13" sha2 = "0.11" +# Full MSVC test debuginfo makes mere-canvas's archive exceed the linker's +# practical 4 GiB boundary. Line tables retain useful test backtraces while +# keeping every workspace test archive linkable on Windows. +[profile.test.package.mere-canvas] +debug = 1 + [workspace.lints.rust] unsafe_code = "warn" [workspace.lints.clippy] all = "warn" +# Knot pins a released Mere revision when it builds independently. While Knot +# is embedded in this workspace, use the checked-out Mere packages so shared +# contracts retain one Cargo source identity. +[patch."https://github.com/merely-made/mere.git"] +chartulary = { path = "crates/eidetic/chartulary" } +chirograph = { path = "crates/chirograph" } +mere-eidetic = { path = "crates/eidetic/eidetic-core" } +esp = { path = "crates/intel/esp" } +mere-fetch = { path = "crates/system/fetch" } +gemot = { path = "crates/moot/gemot" } +graphshell = { path = "ports/graphshell" } +graphshell-endpoint = { path = "crates/graphshell/graphshell-endpoint" } +graphshell-local = { path = "crates/graphshell/graphshell-local" } +graphshell-stdio = { path = "crates/graphshell/graphshell-stdio" } +mora-cmudict = { path = "crates/intel/mora-cmudict" } +muniment = { path = "crates/eidetic/muniment" } +notochord = { path = "crates/system/notochord" } +pandect = { path = "crates/system/pandect" } +personae = { path = "crates/dramatis/personae" } +mere-proofs = { path = "crates/system/proofs" } +sceno = { path = "crates/scenograph/sceno" } +scenotime = { path = "crates/scenograph/scenotime" } +script-rhai = { path = "crates/script/rhai" } +servitor = { path = "crates/servitor" } +stickleback = { path = "crates/stickleback" } +mere-transport = { path = "crates/murm/transport" } + # ─── Cross-repo render-stack alignment (Mere → Genet) ─────────────────────── # Livery/Buckram consume Genet's Taffy fork directly. The retired Stylo layout # cone has no root patch in this workspace. diff --git a/README.md b/README.md index c39537ea2..c0a22dc87 100644 --- a/README.md +++ b/README.md @@ -36,9 +36,11 @@ wiring the remaining unconsumed crates into hosts. Mere is consumed as a git dependency by [Turnstone](https://github.com/merely-made/turnstone) (the browser app) and -other sibling repos. Runnable consumers live under `ports/`: graphshell (the -reference graph host and web presenter), knot (files-in-place authoring), -castellan (credential keeper), and distillery (model works). +other sibling repos. Runnable consumers in this repository live under `ports/`: +graphshell (the reference graph host and web presenter), castellan (credential +keeper), and distillery (model works). [Knot](https://github.com/merely-made/knot-editor) +is an independent files-in-place authoring product that embeds through the same +Mere and Genet contracts. ```sh cargo build diff --git a/design_docs/2026-08-22_turnstone_suite_composition_and_capability_census.md b/design_docs/2026-08-22_turnstone_suite_composition_and_capability_census.md index 1312a1ee0..c47dc4ee2 100644 --- a/design_docs/2026-08-22_turnstone_suite_composition_and_capability_census.md +++ b/design_docs/2026-08-22_turnstone_suite_composition_and_capability_census.md @@ -388,9 +388,10 @@ preference and a per-resource override. ### Knot application surface -Knot's editor, status, publishing, shared-reader, and evidence work is spread -between `ports/knot` and Turnstone. The planned shared Knot UI plus a real -`knot-editor` host exposes the capability without creating another port. +Knot's editor, status, publishing, shared-reader, and evidence work is owned by +the independent [`knot-editor`](https://github.com/merely-made/knot-editor) +product and embedded into Turnstone. The shared Knot UI plus its concrete host +exposes the capability without giving Turnstone product authority. ### Djinn health and service management diff --git a/design_docs/2026-08-24_standards_survey_brief.md b/design_docs/2026-08-24_standards_survey_brief.md index 08d342853..227c7feca 100644 --- a/design_docs/2026-08-24_standards_survey_brief.md +++ b/design_docs/2026-08-24_standards_survey_brief.md @@ -710,7 +710,7 @@ recorded in §1, §3.3, §5, §6 and §8. | ADOPT | XDG Base Directory Specification | XDG Base Directory Specification, Version 0.8 | mere/ports/djinn (src/settings.rs already reads LOCALAPPDATA then falls back to XDG_DATA_HOME) and mere/ports/graphshell (native/app_admission.rs and … | | ADOPT | Desktop Entry Specification | Desktop Entry Specification, Version 1.5 | genet/ports/pelt (as a viewer that wants to be openable from a file manager and to claim http/https/gemini/gopher), mere/ports/graphshell (which … | | ADOPT | XDG Desktop Portals | XDG Desktop Portal D-Bus interfaces: org.freedesktop.portal.Settings (v2, org.freedesktop.appearance namespace), org.freedesktop.portal.FileChooser, org.freedesktop.portal.Screenshot, org.freedesktop.portal.OpenURI | genet/ports/pelt — this is the missing OS-preference source for livery's prefers-color-scheme / prefers-contrast evaluator (see the user-preference … | -| PULL | shared-mime-info and Icon Theme Specification | Shared MIME-info Database Specification 0.21 (2018-10-02); Icon Theme Specification 0.13 (2013-07-02) | genet/ports/pelt (file-manager association and window icon), mere/ports/knot (which serves a projection over a real directory and therefore has to … | +| PULL | shared-mime-info and Icon Theme Specification | Shared MIME-info Database Specification 0.21 (2018-10-02); Icon Theme Specification 0.13 (2013-07-02) | genet/ports/pelt (file-manager association and window icon), knot-editor (which serves a projection over a real directory and therefore has to … | | PULL | Windows and macOS application, file-type and URL-scheme registration | Windows: HKCU\Software\Classes ProgID + RegisteredApplications + UserChoice; macOS: Uniform Type Identifiers (UTType) + Info.plist CFBundleURLTypes / CFBundleDocumentTypes / LSHandlerRank | genet/ports/pelt and turnstone on Windows and macOS; mere/ports/djinn, which already ships install-windows.ps1 and installs itself as a Scheduled … | | PULL | Web Application Manifest (and protocol_handlers) | Web Application Manifest (W3C) + Manifest Incubations (WICG) for protocol_handlers, file_handlers, share_target | genet/ports/pelt as a *consumer* (a viewer that installs web apps), and — more interestingly for this stack — turnstone and graphshell, whose … | | ADOPT | Core Accessibility API Mappings | Core Accessibility API Mappings 1.2 (Core-AAM) | genet/components/genet-render (whose Cargo.toml comment already states its job is ScriptedDom → accesskit::TreeUpdate), … | diff --git a/design_docs/DOC_README.md b/design_docs/DOC_README.md index 190f6293b..615f5ede4 100644 --- a/design_docs/DOC_README.md +++ b/design_docs/DOC_README.md @@ -36,13 +36,14 @@ Root-level briefs that span multiple area-docs (not required reading). - [derived_faces_plan](mere_docs/implementation_strategy/2026-08-28_derived_faces_plan.md) — **D1 published as `pictograph` 0.1.0 and D2 landed as unpublished 0.2.0 on 2026-09-01; D3 open**: derivation v2 produces deterministic 34–211-byte IconVG faces across its current 68-address corpus, with digest-pinned fixtures, palette-only theming, two LOD arms, and fuzz coverage. The optional D2 bridge lowers emblem paths and paints into netrender's exact vello `Scene`, with ViewBox clipping, non-zero winding, flat and gradient paints, all spread modes, and typed refusal of an unrepresentable radial matrix. Its live headless receipts render action/info and one byte-identical derived face under red and blue palettes, prove winding at the centre pixel, and check linear and radial transform direction at pixels; 21 unit tests plus two GPU integration tests pass, with strict Clippy clean. D3 adds `Face::Derived` for favicon-less content and retains the stored-override contract. Editing stays deferred and operates on derivation parameters rather than decoded bytes. Open with Mark before D3: derived-by-default versus opt-in. The plan also records the family branding gap (three apps ship no icon; genet still ships Servo's). - [doc_policy_consolidation_plan](mere_docs/implementation_strategy/2026-08-24_doc_policy_consolidation_plan.md) (**complete 2026-08-24 — A, B and C all landed**: one canonical `DOC_POLICY.md` core distributed byte-identical to fifteen repos with per-repo addenda; the nine `crates/*/design_docs/` directories collapsed into area roots and all twenty docs indexed here for the first time; `smolweb/design_docs/` founded and given the two spec-level docs; `genet/design_docs/` founded and given the eight that described `components/{inker,nematic,verso-tile}` — which is why this tree no longer carries `inker_docs/`, `nematic_docs/` or `verso_docs/`. Also records a standing finding, corrected upward by an independent audit: **485 distinct broken link targets across 806 occurrences already in this tree**, overwhelmingly pre-existing cross-repo relative links and references to the archived graphshell and the deleted meerkat, with the bulk sitting in `archive_docs/`.) -- [knot_shared_surface_and_port_contribution_plan](mere_docs/implementation_strategy/2026-08-24_knot_shared_surface_and_port_contribution_plan.md) — **in progress 2026-08-26; K0 complete, T0 invocation/read-only landed, second provider implemented**: standalone Knot and Turnstone share the `knot.document.v1` model, Cambium surface, data-only descriptor, and object-safe retained-session seam. Distillery now supplies an unrelated `distillery.installed.v1` descriptor and retained read-only Cambium session from its installed authority, proving the provider side without another UI contract. Turnstone registration/admission for that provider, proof that it needs no renderer arm, generic AccessKit projection, and the full-shell build remain open before the seam freezes. +- [knot_shared_surface_and_port_contribution_plan](mere_docs/implementation_strategy/2026-08-24_knot_shared_surface_and_port_contribution_plan.md) — **K0 and P0 complete; T0 core landed; independent repository extraction complete 2026-09-01**: standalone Knot and Turnstone share the `knot.document.v1` model, Cambium surface, data-only descriptor, and object-safe retained-session seam. The provider contract is frozen at v1 after Knot, Distillery, and Sky proved one generic registration/admission path. Knot now lives in the public [`knot-editor`](https://github.com/merely-made/knot-editor) repository; Mere and Turnstone consume one immutable revision while product authority stays with Knot. F0 remains the next product-surface lane. > **Knot reconciliation, 2026-07-27:** the > [Djot editor/Knot nodes plan](archive_docs/2026-08-06_completed_plans/2026-06-24_djot_editor_knot_nodes_plan.md) > is now a historical Meerkat execution record. Its portable editor/readout -> survived in Genet and is consumed by `ports/knot`; its file, vault, writer, -> sync, conflict, and Commons work is complete under the +> survived in Genet and is consumed by the independent +> [`knot-editor`](https://github.com/merely-made/knot-editor) product; its file, +> vault, writer, sync, conflict, and Commons work is complete under the > [Knot port plan](mere_docs/implementation_strategy/2026-07-25_knot_port_plan.md). > Product authoring intents, Inspector-to-Knot clipping, and a shared > outline/fold surface remain open under the dedicated @@ -126,7 +127,7 @@ receipts. The banner gives the current mapping. - [browser_webrtc_carrier_plan](mere_docs/implementation_strategy/2026-08-25_browser_webrtc_carrier_plan.md) — **C0-C3 landed 2026-08-28 (forced relay proven, stop line cleared); C4 open**: direct browser-to-native WebRTC below Notochord, a private invite redeemed into a narrow delegation for a browser-generated ephemeral Personae subject, host-signed DTLS-fingerprint link binding, forced-TURN and reconnect gates, then the real Graphshell snapshot/diff/intent session and public `mer3ly.net/join/` rendezvous. `InviteV1` carries Luggage's manifest-hash + publisher-key `ReleaseRefV1`; C5 verifies the exact browser bundle before execution, and C6 makes the same release an explicit native adoption offer without changing publisher trust or feed settings. Iroh-over-WebRTC remains a measured later adapter. *(C0: `crates/murm/webrtc-carrier` builds for native and `wasm32-unknown-unknown`; `TransportKind::WebRtc` maps to `CarrierKind::Other` and reuses Reticulum's link binding unchanged; Graphshell's accept path split into `admit_accepted_session` with every call site untouched.)* - [repo_consolidation_plan](mere_docs/implementation_strategy/2026-07-23_repo_consolidation_plan.md) — **ruled with Mark 2026-07-23**: Mere is the platform and the extracted families remain its components; the separate-repo bar is coherent identity apart from the six primaries. Nine repos fold into mere (personae, armillary, the eidetic four, servitor, vates, sibylla, conatus, scenograph, graphshell), cambium and netfetcher fold into genet, misfin founds a smolweb bucket, the radio four merge into one workspace, tinct is consumed through genet, and netrender/wavicle/wgpu-* stay separate as passing the bar. Withdraws the murm/moot promotion and the Graphshell plan's neutral-commons repo posture; the portable-crate CI walls travel into mere; phases C0-C6 with per-phase done conditions. **Executed 2026-07-24**: C0-C6 done, the publish sweep republished 21 crates at their new homes, every absorbed repo deleted except graphshell (archived, donor docs); still open: the toolchain bump for isometry/hocket/turnstone (each blocked on its own pre-existing breakage) and the four `graphshell-*` crate publishes (held until the protocol settles through G5-G7). - [scenograph_0_0_3_release_plan](mere_docs/implementation_strategy/2026-07-24_scenograph_0_0_3_release_plan.md) — **completed historical release plan**: S1-S4 landed and the four 0.0.3 crates were published 2026-07-24. It records rulings D1-D4 for intent ownership, deletion of `measure`, item channels, and Scenotime picking. It is a release receipt, not a claim that the protocol stopped developing. -- [knot_port_plan](mere_docs/implementation_strategy/2026-07-25_knot_port_plan.md) — **K0-K7 complete locally 2026-07-27**: Knot is a Mere port at `ports/knot` beside `ports/graphshell`, shipping a host plus a `knot_endpoint` binary. The port has files-in-place projection with an autonomous attributed watcher, file/note content classes, a sealed vault, grant-filtered local analysis, encrypted Stickleback sync over real p2panda, format-selective writers, and a Cambium-backed editor. Knot now signs causal frontiers, reports per-document multi-writer conflicts without hiding unrelated documents, automatically merges exactly two compatible concurrent UTF-8 text versions with independent line edits, returns pending-history diagnostics, restores its author head after Redb reopen, and persists the projection and derived merge in its checkpoint. Overlapping edits remain visible conflicts, and Knot does not translate documents through chartulary facets. +- [knot_port_plan](mere_docs/implementation_strategy/2026-07-25_knot_port_plan.md) — **historical in-tree execution record; K0-K7 complete 2026-07-27, extracted 2026-09-01**: Knot began as the Mere port at `ports/knot` and now lives in the independent [`knot-editor`](https://github.com/merely-made/knot-editor) repository, retaining its host and `knot_endpoint` binary. It has files-in-place projection with an autonomous attributed watcher, file/note content classes, a sealed vault, grant-filtered local analysis, encrypted Stickleback sync over real p2panda, format-selective writers, and a Cambium-backed editor. Knot signs causal frontiers, reports per-document multi-writer conflicts without hiding unrelated documents, automatically merges exactly two compatible concurrent UTF-8 text versions with independent line edits, returns pending-history diagnostics, restores its author head after Redb reopen, and persists the projection and derived merge in its checkpoint. Overlapping edits remain visible conflicts, and Knot does not translate documents through chartulary facets. - [overmap_sessions_graph_plan](mere_docs/implementation_strategy/2026-07-20_overmap_sessions_graph_plan.md): **rungs O0-O3 COMPLETE 2026-07-20**: sessions as container nodes in a derived graph one level up (a pure builder over `ManifestStore`, no new storage), the switcher as a graph view on the shared swatch leaf, fork drawing its lineage edge, and session deletion through the manifest trash (the directory move IS the removed-sessions record; no session-level bin record). Held, correctly gated: stored-overmap promotion (needs an overmap-native edit), cross-session edge vocabulary (murm/moot's seam). - [low_power_managed_network_plan](mere_docs/implementation_strategy/2026-07-24_low_power_managed_network_plan.md): joins the Heltec V4 low-power continuous-RX radio personality (UART0 + Light-sleep, retinue-side) with owner-controlled Mere service access and Reticulum transit enforced from honest incoming-session facts. V1-V8 landed: Murm admission passes over Memory, Reticulum/TCP, and p2panda, while the Commons direct-PHY receipt proves a headed 1,177-byte encrypted operation over the connected T114 and Heltec V4. Still open: the V0/V2 current and sleep bench. - [notochord_session_policy_spine_plan](archive_docs/2026-08-06_completed_plans/2026-07-26_notochord_session_policy_spine_plan.md) — **N0-N4 complete 2026-07-27**: Notochord is the typed facts/claims/admitted-session spine used by real Murm and Graphshell carriers. The promoted package retains revocation-checkable claims, persists versioned owner rules and verified revocations without live session state, and has a headed independence receipt for service, discovery, and transit controls. diff --git a/design_docs/intel_docs/technical_architecture/2026-08-09_feature_target_matrix.md b/design_docs/intel_docs/technical_architecture/2026-08-09_feature_target_matrix.md index a29cf61e2..4349666ec 100644 --- a/design_docs/intel_docs/technical_architecture/2026-08-09_feature_target_matrix.md +++ b/design_docs/intel_docs/technical_architecture/2026-08-09_feature_target_matrix.md @@ -69,9 +69,9 @@ workspace Cargo processes can regenerate the ignored target-specific lockfile. their extracted crates, and published to crates.io. Knot's ESP consumer compiled during E1. Its later full library-test run met a -concurrent, unrelated borrow error in the new publication-client test at -`ports/knot/src/publish_host.rs`; that work is outside this consolidation and -was left untouched. +concurrent, unrelated borrow error in the new publication-client test, now at +`knot-editor/crates/knot-editor/src/publish_host.rs`; that work was outside this +consolidation and left untouched. ## Done boundary diff --git a/design_docs/mere_docs/implementation_strategy/2026-07-25_knot_port_plan.md b/design_docs/mere_docs/implementation_strategy/2026-07-25_knot_port_plan.md index 082aaf767..f4a7af774 100644 --- a/design_docs/mere_docs/implementation_strategy/2026-07-25_knot_port_plan.md +++ b/design_docs/mere_docs/implementation_strategy/2026-07-25_knot_port_plan.md @@ -1,5 +1,10 @@ # Knot Port Plan +> **Repository note (2026-09-01):** this is the historical in-tree execution +> record. Current Knot source and product authority live in the public +> [`knot-editor`](https://github.com/merely-made/knot-editor) repository; local +> `ports/knot` paths below name the source layout when each receipt landed. + **Date:** 2026-07-25 **Status:** implementation complete locally 2026-07-27. K0 through K7 are executable. Knot has now pulled Stickleback's causal projection seam: diff --git a/design_docs/mere_docs/implementation_strategy/2026-07-27_knot_authoring_consumer_plan.md b/design_docs/mere_docs/implementation_strategy/2026-07-27_knot_authoring_consumer_plan.md index 98bd0d112..5aa0bba83 100644 --- a/design_docs/mere_docs/implementation_strategy/2026-07-27_knot_authoring_consumer_plan.md +++ b/design_docs/mere_docs/implementation_strategy/2026-07-27_knot_authoring_consumer_plan.md @@ -1,5 +1,10 @@ # Knot Authoring Consumer Plan +> **Repository note (2026-09-01):** this is a historical integration record. +> Current Knot source and product authority live in the public +> [`knot-editor`](https://github.com/merely-made/knot-editor) repository; paths +> below retain the layout used by the recorded receipts. + **Date:** 2026-07-27 **Status:** all Knot-owned work in the reconciled sequence is complete locally: A1 through A4, typed Inspector clip insertion, production Resolve/Run diff --git a/design_docs/mere_docs/implementation_strategy/2026-08-02_knot_in_graphshell_plan.md b/design_docs/mere_docs/implementation_strategy/2026-08-02_knot_in_graphshell_plan.md index 33b4a9370..4f7f98067 100644 --- a/design_docs/mere_docs/implementation_strategy/2026-08-02_knot_in_graphshell_plan.md +++ b/design_docs/mere_docs/implementation_strategy/2026-08-02_knot_in_graphshell_plan.md @@ -1,5 +1,10 @@ # Knot in Graphshell Plan +> **Repository note (2026-09-01):** this is a historical integration record. +> Current Knot source and product authority live in the public +> [`knot-editor`](https://github.com/merely-made/knot-editor) repository; local +> `ports/knot` paths below name the source layout when each receipt landed. + **Date:** 2026-08-02 **Status:** K0-K3 complete. K1 chose Option A (Mark): shared documents are projected, personal documents replicate, and T4's done condition is replaced diff --git a/design_docs/mere_docs/implementation_strategy/2026-08-06_configuration_ownership_settings_projection_plan.md b/design_docs/mere_docs/implementation_strategy/2026-08-06_configuration_ownership_settings_projection_plan.md index d76d1731b..b4aa1f800 100644 --- a/design_docs/mere_docs/implementation_strategy/2026-08-06_configuration_ownership_settings_projection_plan.md +++ b/design_docs/mere_docs/implementation_strategy/2026-08-06_configuration_ownership_settings_projection_plan.md @@ -9,7 +9,7 @@ this plan holds pointers, not their work. **Code**: `genet/components/genet-host-api/tile.rs` (the `SettingsRef` lane, tile.rs:144), `genet/components/config` (opts/prefs), `mere/crates/system/session-runtime/src/{application_settings_store.rs,device_settings_store.rs,settings_store.rs}` + -`persona_settings_store.rs`, `mere/ports/knot/src/settings.rs`, +`persona_settings_store.rs`, `knot-editor/crates/knot-editor/src/settings.rs`, `mere/ports/graphshell/src/native/owner_settings.rs`, `turnstone/src/{apparatus_pane.rs,settings_provider.rs,settings_pane.rs}`, `woodshed/crates/woodshed-core/src/{settings.rs,storage.rs}`, @@ -147,7 +147,8 @@ demands one. so Pandect now owns the narrow `write_bytes_with_backup` replacement mechanism. Notochord and Distillery use it; their paths, schemas, and validation remain product-owned. Knot's local remove-then-rename write must migrate to this -mechanism once the WebRTC lane releases `ports/knot/src/settings.rs`; this +mechanism once the WebRTC lane releases +`knot-editor/crates/knot-editor/src/settings.rs`; this slice deliberately leaves that file and manifest untouched. Three focused Pandect tests prove replacement cleanup, interrupted-backup recovery, and restoration after a failed final rename. diff --git a/design_docs/mere_docs/implementation_strategy/2026-08-07_knot_publishing_protocol_plan.md b/design_docs/mere_docs/implementation_strategy/2026-08-07_knot_publishing_protocol_plan.md index 4030a8e6f..c84cb664f 100644 --- a/design_docs/mere_docs/implementation_strategy/2026-08-07_knot_publishing_protocol_plan.md +++ b/design_docs/mere_docs/implementation_strategy/2026-08-07_knot_publishing_protocol_plan.md @@ -1,5 +1,10 @@ # Knot Publishing Protocol Plan +> **Repository note (2026-09-01):** this is a historical integration record. +> Current Knot source and product authority live in the public +> [`knot-editor`](https://github.com/merely-made/knot-editor) repository; local +> `ports/knot` paths below name the source layout when each receipt landed. + **Date**: 2026-08-07 **Status**: Phase A implemented and physically receipted, including a public-client renewal on 2026-08-19. Direction remains **A then B** (§4). The diff --git a/design_docs/mere_docs/implementation_strategy/2026-08-08_esp_consolidation_plan.md b/design_docs/mere_docs/implementation_strategy/2026-08-08_esp_consolidation_plan.md index 00bf00c22..40e5e9b10 100644 --- a/design_docs/mere_docs/implementation_strategy/2026-08-08_esp_consolidation_plan.md +++ b/design_docs/mere_docs/implementation_strategy/2026-08-08_esp_consolidation_plan.md @@ -72,7 +72,7 @@ holds the ledger, it does not absorb the lanes. ## 2. The consumer graph (verified, and why this is small) -`vates` ← `mere-infer` only. `sibylla` ← `mere-embed` **and `ports/knot`** +`vates` ← `mere-infer` only. `sibylla` ← `mere-embed` **and `knot-editor`** (`sibylla.workspace = true`, knot:51). `mere-embed` ← `eidetic-search` only. **`mere-infer` ← nobody**: it exists to re-export vates under `infer::` paths, nothing imports it, and its only original content is one integration test. diff --git a/design_docs/mere_docs/implementation_strategy/2026-08-08_knot_mark_read_adapter.md b/design_docs/mere_docs/implementation_strategy/2026-08-08_knot_mark_read_adapter.md index 69b873eab..2f5f71847 100644 --- a/design_docs/mere_docs/implementation_strategy/2026-08-08_knot_mark_read_adapter.md +++ b/design_docs/mere_docs/implementation_strategy/2026-08-08_knot_mark_read_adapter.md @@ -1,5 +1,10 @@ # Knot Mark read adapter +> **Repository note (2026-09-01):** this is a historical integration record. +> Current Knot source and product authority live in the public +> [`knot-editor`](https://github.com/merely-made/knot-editor) repository; local +> `ports/knot` paths below name the source layout when each receipt landed. + Status: implemented bounded adapter, pending an external Demarkus-client receipt. diff --git a/design_docs/mere_docs/implementation_strategy/2026-08-10_wallet_carry_foldin_plan.md b/design_docs/mere_docs/implementation_strategy/2026-08-10_wallet_carry_foldin_plan.md index e1744d240..9a9417c0d 100644 --- a/design_docs/mere_docs/implementation_strategy/2026-08-10_wallet_carry_foldin_plan.md +++ b/design_docs/mere_docs/implementation_strategy/2026-08-10_wallet_carry_foldin_plan.md @@ -47,7 +47,7 @@ verbatim move wrong: doctrine; only the model beneath it moves. - `manifest::PersonaId` is already a re-export of `identity::PersonaId`; no type split exists. -- Consumers outside session-runtime: **ports/knot** (`knot_sync_host`, +- Consumers outside session-runtime: **knot-editor** (`knot_sync_host`, `startup.rs`, `tests/revision_bell.rs`). Re-exports must keep these compiling unchanged until W4 re-bases them. - Sizes: `wallet_store.rs` 1653 lines, `wallet_grant.rs` 2524. Both breach diff --git a/design_docs/mere_docs/implementation_strategy/2026-08-24_knot_shared_surface_and_port_contribution_plan.md b/design_docs/mere_docs/implementation_strategy/2026-08-24_knot_shared_surface_and_port_contribution_plan.md index fb1b258f6..8ebfa26a4 100644 --- a/design_docs/mere_docs/implementation_strategy/2026-08-24_knot_shared_surface_and_port_contribution_plan.md +++ b/design_docs/mere_docs/implementation_strategy/2026-08-24_knot_shared_surface_and_port_contribution_plan.md @@ -9,7 +9,9 @@ accessibility landed; P0 is complete — Turnstone admits `distillery.installed.v1` through the existing registry with no provider-specific renderer arm, the full shell binary builds from published sources, and the contract is reduced and frozen at v1 (Genet `001448d55`, -Turnstone `3f63671`); F0 is the next gated lane +Turnstone `3f63671`); the independent `knot-editor` repository extraction and +Mere/Turnstone consumer cutovers are complete as of 2026-09-01; F0 is the next +gated lane **Scope:** prove one Knot document surface in a standalone host and Turnstone, then prove the contribution seam with a second port. This plan does not require or privilege a `.knot` container format, a subprocess boundary, or a universal @@ -21,6 +23,7 @@ plugin API. - [Knot port plan](2026-07-25_knot_port_plan.md) - [Knot authoring consumer plan](2026-07-27_knot_authoring_consumer_plan.md) - [Knot in Graphshell plan](2026-08-02_knot_in_graphshell_plan.md) +- [Knot repository](https://github.com/merely-made/knot-editor) - [Device resident consolidation plan](2026-08-20_device_resident_consolidation_plan.md) - [Configuration ownership and settings projection plan](2026-08-06_configuration_ownership_settings_projection_plan.md) - Turnstone `design_docs/2026-08-08_pane_registry_and_graph_panes_plan.md` @@ -28,10 +31,11 @@ plugin API. ## 1. Ruling -Knot is a Mere port and a useful application by itself. Its first product is a -Djot-native editor over files in place, with a graph substrate, local search, -portable referenced evidence, and peer replication. Standalone Knot and -Turnstone consume the same Knot product model and Cambium surface. +Knot is an independent product and an embeddable Mere port. Its first product +is a Djot-native editor over files in place, with a graph substrate, local +search, portable referenced evidence, and peer replication. Standalone Knot, +Mere residents, and Turnstone consume the same Knot product model and Cambium +surface. Turnstone is the compositor. It owns placement, window and pane lifetime, focus, layout, hit testing, theme, AccessKit hosting, and shell policy. Knot @@ -47,6 +51,15 @@ works in two hosts. ## 2. Findings +### 2026-09-01: Knot is independently versioned and embeds back into Mere + +The product packages and desktop wrapper now live in the public +[`knot-editor`](https://github.com/merely-made/knot-editor) repository. Mere and +Turnstone consume an immutable Knot revision, while Knot independently pins the +Mere and Genet contracts it composes. References below to `ports/knot`, +`ports/knot-document`, and `ports/knot/desktop` describe the historical source +locations at the time of those receipts. + ### 2026-08-24: the closed seam is larger than `PaneRenderer` Turnstone's `PaneRenderer` and `BUILTIN_PANES` are closed, but the retained @@ -687,6 +700,20 @@ through UI admission. test fixtures drop the removed fields when each repo aligns past `001448d55`; the pointer-capture routing gap stays noted for the T lane. P0 is complete. +- 2026-09-01: Knot was extracted with preserved history to the public + [`knot-editor`](https://github.com/merely-made/knot-editor) repository. A + fresh checkout passed 15 `knot-document` tests, 94 editor-library tests, and + the desktop host receipt. Turnstone PR #4 consumes immutable Knot revision + `c4d15aa6`; its two five-test authoring suites and all five external-endpoint + tests pass with one identity for every shared Mere and Genet contract. +- 2026-09-01: Mere now consumes the same immutable Knot revision, patches + Knot's Mere dependencies back to this checkout, and has removed + `ports/knot`, `ports/knot-document`, and their workspace entries. Workspace + metadata contains one Git identity for each Knot package and one identity + for every shared Mere and Genet contract. Djinn's live pairing, joined-sync, + route-reopen resident test passes against the external package. The test + profile retains line-table debug info for `mere-canvas`, avoiding MSVC's + practical 4 GiB archive boundary while keeping useful backtraces. ## 8. Final done conditions diff --git a/design_docs/mere_docs/research/2026-08-18_scenograph_lane_handoffs.md b/design_docs/mere_docs/research/2026-08-18_scenograph_lane_handoffs.md index d7a399d7c..fa5933948 100644 --- a/design_docs/mere_docs/research/2026-08-18_scenograph_lane_handoffs.md +++ b/design_docs/mere_docs/research/2026-08-18_scenograph_lane_handoffs.md @@ -139,7 +139,8 @@ map, which is the observation that produced the scene register. ## Lane C — Rosette for text (mora's first consumer) -**Repos**: knot (`mere/ports/knot`), mere, mora. **Gate**: open, and this +**Repos**: [`knot-editor`](https://github.com/merely-made/knot-editor), mere, +mora. **Gate**: open, and this lane carries the session's one piece of unforced surface. `mora` 0.1.0 is published and consumed by nothing. The founding convention diff --git a/ports/djinn/Cargo.toml b/ports/djinn/Cargo.toml index ee7053047..6c15438d8 100644 --- a/ports/djinn/Cargo.toml +++ b/ports/djinn/Cargo.toml @@ -29,7 +29,7 @@ chirograph.workspace = true # resident, and `trainer` pulls it deliberately and by the owner's request. distillery.workspace = true graphshell = { version = "0.0.2", path = "../graphshell", features = ["personal-sync"] } -knot-editor = { version = "0.0.3", path = "../knot" } +knot-editor.workspace = true mere-resident.workspace = true # The policy vocabulary the device sensor fills in (`DeviceConditions`), and # the `ConditionSource` seam it plugs into. `mere-mesh` refuses to touch the diff --git a/ports/djinn/README.md b/ports/djinn/README.md index f7a9f9e72..01537eb2e 100644 --- a/ports/djinn/README.md +++ b/ports/djinn/README.md @@ -2,7 +2,8 @@ Djinn is Mere's local desktop resident. It composes the owner-held parts of a personal device: Personae authority, the SSH agent, Graphshell's local browser -and application brokers, personal sync, Knot's source/sync/evidence custody, +and application brokers, personal sync, +[Knot's](https://github.com/merely-made/knot-editor) source/sync/evidence custody, per-profile Castellan custody, and the shared physical blob store. Graphshell remains the local session and admission protocol. Knot remains the @@ -22,10 +23,10 @@ pairing records, and content-store migrations, not an additional resident. ## Publishing `0.0.2` is the source version of this workspace resident, not a crates.io -release. `cargo package` correctly refuses it because its Graphshell and Knot -Editor composition still uses workspace-only dependencies. A public Djinn -release needs an installable package boundary and a staged release of the Mere -dependencies it exposes. +release. Knot is pinned from its public repository; Djinn's Graphshell +composition still uses workspace-only dependencies, so `cargo package` +correctly refuses it. A public Djinn release needs an installable package +boundary and a staged release of the Mere dependencies it exposes. ## Security boundary diff --git a/ports/knot-document/Cargo.toml b/ports/knot-document/Cargo.toml deleted file mode 100644 index 614aeca9a..000000000 --- a/ports/knot-document/Cargo.toml +++ /dev/null @@ -1,36 +0,0 @@ -[package] -name = "knot-document" -version = "0.0.1" -edition.workspace = true -license.workspace = true -repository.workspace = true -description = "Djot-first local document authority and reusable Knot surface." - -[features] -default = [] -# Broad Knot conversion, preview, and canonicalization. The ordinary document -# surface intentionally retains only the lightweight editor readout. -engine = [ - "knot-editor-host/preview", - "dep:illume", - "dep:inker", - "dep:nematic", - "dep:serde_json", -] - -[dependencies] -cambium.workspace = true -genet-host-api = { workspace = true } -illume = { workspace = true, optional = true } -knot-editor-host = { workspace = true } -inker = { workspace = true, optional = true } -nematic = { workspace = true, optional = true } -serde_json = { workspace = true, optional = true } - -[dev-dependencies] -tempfile = "3" -genet-scripted-dom = { workspace = true } -layout-dom-api = { workspace = true } - -[lints] -workspace = true diff --git a/ports/knot-document/src/document_surface.rs b/ports/knot-document/src/document_surface.rs deleted file mode 100644 index 1c5d9b6d4..000000000 --- a/ports/knot-document/src/document_surface.rs +++ /dev/null @@ -1,322 +0,0 @@ -// Copyright 2026 Mark Alan Boykin -// 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 https://mozilla.org/MPL/2.0/. -// SPDX-License-Identifier: MPL-2.0 - -use crate::{DocumentFormat, KnotEditor, SaveOutcome}; -use cambium::{CaretSelection, TextCommand, TextInput}; -use std::path::Path; - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum KnotDocumentSourceKindV1 { - File, - Scratch, -} -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct KnotDocumentSourceV1 { - pub kind: KnotDocumentSourceKindV1, - pub address: String, -} -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum KnotDocumentWritePostureV1 { - FileTarget, - Scratch, - ReadOnly, -} -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum KnotDocumentSaveOutcomeV1 { - Written, - Unchanged, - Refused, - Failed, -} -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum KnotDocumentRefusalV1 { - ScratchHasNoSaveTarget, - ReadOnly, -} -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct KnotDocumentSaveFailureV1 { - pub message: String, -} -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum KnotDocumentIntentErrorV1 { - Refused(KnotDocumentRefusalV1), - SaveFailed(KnotDocumentSaveFailureV1), -} -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct KnotDocumentSnapshotV1 { - pub source: KnotDocumentSourceV1, - pub display_label: String, - pub format: DocumentFormat, - pub text: String, - pub selection: CaretSelection, - pub dirty: bool, - pub write_posture: KnotDocumentWritePostureV1, - pub last_save_outcome: Option, - pub refusal: Option, - pub last_save_failure: Option, -} -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum KnotDocumentIntentV1 { - Edit(TextCommand), - Save, -} - -/// One selected document over the retained Knot editor. There is no second buffer. -pub struct KnotDocumentSession { - editor: KnotEditor, - write_posture: KnotDocumentWritePostureV1, - last_save_outcome: Option, - refusal: Option, - last_save_failure: Option, -} -impl KnotDocumentSession { - pub fn open(path: impl Into) -> Result { - Self::open_with_posture(path, KnotDocumentWritePostureV1::FileTarget) - } - - /// Opens a local document for inspection while retaining its file identity. - /// - /// The session refuses both edit and save intents. A host uses this when it - /// has deliberately admitted a file without delegating write authority. - pub fn open_read_only(path: impl Into) -> Result { - Self::open_with_posture(path, KnotDocumentWritePostureV1::ReadOnly) - } - - fn open_with_posture( - path: impl Into, - write_posture: KnotDocumentWritePostureV1, - ) -> Result { - Ok(Self { - editor: KnotEditor::open(path)?, - write_posture, - last_save_outcome: None, - refusal: None, - last_save_failure: None, - }) - } - pub fn scratch(address: impl Into, source: impl Into) -> Self { - Self { - editor: KnotEditor::scratch(address, source), - write_posture: KnotDocumentWritePostureV1::Scratch, - last_save_outcome: None, - refusal: None, - last_save_failure: None, - } - } - - /// Builds an intentionally immutable in-memory document projection. - pub fn read_only(address: impl Into, source: impl Into) -> Self { - Self { - editor: KnotEditor::scratch(address, source), - write_posture: KnotDocumentWritePostureV1::ReadOnly, - last_save_outcome: None, - refusal: None, - last_save_failure: None, - } - } - pub fn input(&self) -> &TextInput { - self.editor.input() - } - /// Borrows the input only when this session delegated text-write authority. - /// - /// Hosts should route document mutations through [`Self::apply`]. This - /// guarded escape hatch remains for compatible editable text hosts. - pub fn input_mut(&mut self) -> Result<&mut TextInput, KnotDocumentRefusalV1> { - if self.write_posture == KnotDocumentWritePostureV1::ReadOnly { - self.refuse_read_only_edit(); - return Err(KnotDocumentRefusalV1::ReadOnly); - } - Ok(self.editor.input_mut()) - } - - /// The editable view has already selected its writable branch. Kept crate - /// private so a product cannot bypass [`Self::input_mut`] for a read-only - /// session. - pub(crate) fn input_mut_for_editable_view(&mut self) -> &mut TextInput { - debug_assert_ne!(self.write_posture, KnotDocumentWritePostureV1::ReadOnly); - self.editor.input_mut() - } - pub fn snapshot(&self) -> KnotDocumentSnapshotV1 { - let path = self.editor.path(); - let file = path.is_some(); - KnotDocumentSnapshotV1 { - source: KnotDocumentSourceV1 { - kind: if file { - KnotDocumentSourceKindV1::File - } else { - KnotDocumentSourceKindV1::Scratch - }, - address: self.editor.address().to_owned(), - }, - display_label: path - .and_then(Path::file_name) - .and_then(|name| name.to_str()) - .map(str::to_owned) - .unwrap_or_else(|| self.editor.address().to_owned()), - format: self.editor.format(), - text: self.editor.source().to_owned(), - selection: self.editor.selection(), - dirty: self.editor.is_dirty(), - write_posture: self.write_posture, - last_save_outcome: self.last_save_outcome, - refusal: self.refusal, - last_save_failure: self.last_save_failure.clone(), - } - } - pub fn apply( - &mut self, - intent: KnotDocumentIntentV1, - ) -> Result { - match intent { - KnotDocumentIntentV1::Edit(command) => { - if self.write_posture == KnotDocumentWritePostureV1::ReadOnly { - self.refuse_read_only_edit(); - return Err(KnotDocumentIntentErrorV1::Refused( - KnotDocumentRefusalV1::ReadOnly, - )); - } - self.editor.apply(command); - self.refusal = None; - } - KnotDocumentIntentV1::Save => self.save()?, - }; - Ok(self.snapshot()) - } - fn save(&mut self) -> Result<(), KnotDocumentIntentErrorV1> { - if self.write_posture == KnotDocumentWritePostureV1::ReadOnly { - self.last_save_outcome = Some(KnotDocumentSaveOutcomeV1::Refused); - self.refusal = Some(KnotDocumentRefusalV1::ReadOnly); - self.last_save_failure = None; - return Err(KnotDocumentIntentErrorV1::Refused( - KnotDocumentRefusalV1::ReadOnly, - )); - } - if self.editor.path().is_none() { - self.last_save_outcome = Some(KnotDocumentSaveOutcomeV1::Refused); - self.refusal = Some(KnotDocumentRefusalV1::ScratchHasNoSaveTarget); - self.last_save_failure = None; - return Err(KnotDocumentIntentErrorV1::Refused( - KnotDocumentRefusalV1::ScratchHasNoSaveTarget, - )); - } - match self.editor.save() { - Ok(SaveOutcome::Written) => { - self.last_save_outcome = Some(KnotDocumentSaveOutcomeV1::Written); - self.refusal = None; - self.last_save_failure = None; - Ok(()) - } - Ok(SaveOutcome::Unchanged) => { - self.last_save_outcome = Some(KnotDocumentSaveOutcomeV1::Unchanged); - self.refusal = None; - self.last_save_failure = None; - Ok(()) - } - Err(message) => { - self.last_save_outcome = Some(KnotDocumentSaveOutcomeV1::Failed); - let failure = KnotDocumentSaveFailureV1 { message }; - self.last_save_failure = Some(failure.clone()); - Err(KnotDocumentIntentErrorV1::SaveFailed(failure)) - } - } - } - - fn refuse_read_only_edit(&mut self) { - self.refusal = Some(KnotDocumentRefusalV1::ReadOnly); - self.last_save_failure = None; - } -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::tempdir; - #[test] - fn file_session_round_trips_edit_save_drop_and_reopen() { - let temp = tempdir().unwrap(); - let path = temp.path().join("field.djot"); - std::fs::write(&path, "# Field\n").unwrap(); - let mut session = KnotDocumentSession::open(&path).unwrap(); - assert_eq!(session.snapshot().format, DocumentFormat::Djot); - session - .apply(KnotDocumentIntentV1::Edit(TextCommand::Insert( - "body\n".into(), - ))) - .unwrap(); - let saved = session.apply(KnotDocumentIntentV1::Save).unwrap(); - assert!(!saved.dirty); - drop(session); - assert_eq!( - KnotDocumentSession::open(&path).unwrap().snapshot().text, - "# Field\nbody\n" - ); - } - #[test] - fn scratch_save_is_an_explicit_typed_refusal() { - let mut session = KnotDocumentSession::scratch("memory:field", ""); - assert!(matches!( - session.apply(KnotDocumentIntentV1::Save), - Err(KnotDocumentIntentErrorV1::Refused( - KnotDocumentRefusalV1::ScratchHasNoSaveTarget - )) - )); - } - - #[test] - fn read_only_file_refuses_edit_and_save_without_mutating_document_state() { - let temp = tempdir().unwrap(); - let path = temp.path().join("field.djot"); - std::fs::write(&path, "# Field\n").unwrap(); - let mut session = KnotDocumentSession::open_read_only(&path).unwrap(); - let before = session.snapshot(); - - assert!(matches!( - session.apply(KnotDocumentIntentV1::Edit(TextCommand::Insert( - "body\n".into() - ))), - Err(KnotDocumentIntentErrorV1::Refused( - KnotDocumentRefusalV1::ReadOnly - )) - )); - let after_edit = session.snapshot(); - assert_eq!(after_edit.text, before.text); - assert_eq!(after_edit.selection, before.selection); - assert!(!after_edit.dirty); - assert_eq!( - after_edit.write_posture, - KnotDocumentWritePostureV1::ReadOnly - ); - assert_eq!(after_edit.refusal, Some(KnotDocumentRefusalV1::ReadOnly)); - - assert!(matches!( - session.apply(KnotDocumentIntentV1::Save), - Err(KnotDocumentIntentErrorV1::Refused( - KnotDocumentRefusalV1::ReadOnly - )) - )); - let after_save = session.snapshot(); - assert_eq!(after_save.text, before.text); - assert!(!after_save.dirty); - assert_eq!( - after_save.last_save_outcome, - Some(KnotDocumentSaveOutcomeV1::Refused) - ); - assert!(matches!( - session.input_mut(), - Err(KnotDocumentRefusalV1::ReadOnly) - )); - assert_eq!(std::fs::read_to_string(path).unwrap(), "# Field\n"); - } - - #[test] - fn read_only_scratch_is_an_explicit_immutable_projection() { - let session = KnotDocumentSession::read_only("memory:field", "# Field\n"); - let snapshot = session.snapshot(); - assert_eq!(snapshot.source.kind, KnotDocumentSourceKindV1::Scratch); - assert_eq!(snapshot.write_posture, KnotDocumentWritePostureV1::ReadOnly); - assert!(!snapshot.dirty); - } -} diff --git a/ports/knot-document/src/document_view.rs b/ports/knot-document/src/document_view.rs deleted file mode 100644 index f17127624..000000000 --- a/ports/knot-document/src/document_view.rs +++ /dev/null @@ -1,245 +0,0 @@ -// Copyright 2026 Mark Alan Boykin -// 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 https://mozilla.org/MPL/2.0/. -// SPDX-License-Identifier: MPL-2.0 - -use crate::{ - DocumentFormat, KnotDocumentIntentErrorV1, KnotDocumentIntentV1, KnotDocumentSession, - KnotDocumentSnapshotV1, KnotDocumentSourceKindV1, KnotDocumentWritePostureV1, -}; -use cambium::{ - AnyView, DomHandle, GenetAppRunner, GenetCtx, GenetElement, RunnerSurfaceSession, TextInput, - button, div, el, lens, span, textarea_typed, -}; -use genet_host_api::{ - ProviderId, SourceKindId, SurfaceAvailability, SurfaceDescriptor, SurfaceId, SurfaceSourceShape, -}; - -pub const KNOT_DOCUMENT_CSS: &str = ".knot-document { display: flex; flex-direction: column; gap: 8px; } .knot-document-status { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; } .knot-document-status-item { white-space: nowrap; } .knot-document-save { margin-left: auto; } .knot-document-body { min-height: 240px; white-space: pre-wrap; } .knot-document-read-only { cursor: default; user-select: text; }"; -pub struct KnotDocumentSurfaceState { - session: KnotDocumentSession, -} -impl KnotDocumentSurfaceState { - pub fn new(session: KnotDocumentSession) -> Self { - Self { session } - } - pub fn session(&self) -> &KnotDocumentSession { - &self.session - } - pub fn session_mut(&mut self) -> &mut KnotDocumentSession { - &mut self.session - } - fn input_mut_for_editable_view(&mut self) -> &mut TextInput { - self.session.input_mut_for_editable_view() - } - pub fn snapshot(&self) -> KnotDocumentSnapshotV1 { - self.session.snapshot() - } - pub fn apply( - &mut self, - intent: KnotDocumentIntentV1, - ) -> Result { - self.session.apply(intent) - } -} -pub type KnotDocumentView = Box>; -pub fn knot_document_view(state: &KnotDocumentSurfaceState) -> KnotDocumentView { - let snapshot = state.snapshot(); - let read_only = snapshot.write_posture == KnotDocumentWritePostureV1::ReadOnly; - let save_affordance: Box> = - if read_only { - Box::new( - span("Save disabled: read-only") - .attr("class", "knot-document-status-item") - .attr("aria-live", "polite"), - ) - } else { - Box::new( - button("Save", |state: &mut KnotDocumentSurfaceState, _| { - let _ = state.apply(KnotDocumentIntentV1::Save); - }) - .attr("class", "knot-document-save"), - ) - }; - let status = div(( - span(format!("Source: {}", snapshot.display_label)) - .attr("class", "knot-document-status-item"), - span(format!("Format: {}", format_label(snapshot.format))) - .attr("class", "knot-document-status-item"), - span(if snapshot.dirty { "Dirty" } else { "Clean" }) - .attr("class", "knot-document-status-item"), - span(format!( - "Posture: {}", - posture_label(snapshot.write_posture) - )) - .attr("class", "knot-document-status-item"), - span(save_outcome_label(&snapshot)).attr("class", "knot-document-status-item"), - save_affordance, - )) - .attr("class", "knot-document-status"); - let body: Box> = if read_only - { - Box::new( - div(snapshot.text) - .attr("class", "knot-document-body knot-document-read-only") - .attr("role", "document") - .attr("aria-label", "Read-only document text") - .attr("aria-readonly", "true"), - ) - } else { - Box::new( - el( - "div", - lens( - |input: &mut TextInput| textarea_typed(input), - |state: &mut KnotDocumentSurfaceState| state.input_mut_for_editable_view(), - ), - ) - .attr("class", "knot-document-body") - .attr("role", "textbox") - .attr("aria-label", "Document text"), - ) - }; - Box::new( - el("section", (status, body)) - .attr("class", "knot-document") - .attr("data-surface", "knot.document.v1"), - ) -} -pub fn knot_document_descriptor() -> SurfaceDescriptor { - SurfaceDescriptor { - provider_id: ProviderId::from("knot"), - surface_id: SurfaceId::from("knot.document.v1"), - label: "Knot document".to_owned(), - accepted_source: SurfaceSourceShape::One(SourceKindId::from("knot.document.v1")), - } -} -pub fn knot_document_surface( - dom: DomHandle, - state: KnotDocumentSurfaceState, -) -> Box { - let runner = GenetAppRunner::new(dom, knot_document_view, state); - Box::new(RunnerSurfaceSession::new( - knot_document_descriptor(), - runner, - |state: &KnotDocumentSurfaceState| match state.snapshot().source.kind { - KnotDocumentSourceKindV1::File | KnotDocumentSourceKindV1::Scratch => { - SurfaceAvailability::Available - } - }, - |_state, _viewport| {}, - |_action: ()| Vec::new(), - )) -} -fn format_label(format: DocumentFormat) -> &'static str { - match format { - DocumentFormat::Djot => "Djot", - DocumentFormat::Knot => "legacy .knot", - DocumentFormat::Markdown => "Markdown", - DocumentFormat::Json => "JSON", - } -} -fn posture_label(posture: KnotDocumentWritePostureV1) -> &'static str { - match posture { - KnotDocumentWritePostureV1::FileTarget => "file target", - KnotDocumentWritePostureV1::Scratch => "scratch", - KnotDocumentWritePostureV1::ReadOnly => "read-only", - } -} -fn save_outcome_label(snapshot: &KnotDocumentSnapshotV1) -> String { - let outcome = match snapshot.last_save_outcome { - None => "Save: not attempted", - Some(crate::KnotDocumentSaveOutcomeV1::Written) => "Save: written", - Some(crate::KnotDocumentSaveOutcomeV1::Unchanged) => "Save: unchanged", - Some(crate::KnotDocumentSaveOutcomeV1::Refused) => "Save: refused", - Some(crate::KnotDocumentSaveOutcomeV1::Failed) => "Save: failed", - }; - if let Some(refusal) = snapshot.refusal { - format!("{outcome}: {}", refusal_label(refusal)) - } else if let Some(failure) = &snapshot.last_save_failure { - format!("{outcome} ({})", failure.message) - } else { - outcome.to_owned() - } -} - -fn refusal_label(refusal: crate::KnotDocumentRefusalV1) -> &'static str { - match refusal { - crate::KnotDocumentRefusalV1::ScratchHasNoSaveTarget => { - "scratch document has no file target" - } - crate::KnotDocumentRefusalV1::ReadOnly => "document is read-only", - } -} -#[cfg(test)] -mod tests { - use std::{cell::RefCell, rc::Rc}; - - use genet_scripted_dom::ScriptedDom; - use layout_dom_api::LayoutDom; - - use super::*; - - fn contains_element(dom: &ScriptedDom, node: genet_scripted_dom::NodeId, name: &str) -> bool { - dom.element_name(node) - .is_some_and(|qualified| qualified.local.as_ref() == name) - || dom - .dom_children(node) - .any(|child| contains_element(dom, child, name)) - } - #[test] - fn state_uses_the_session_input_as_the_component_buffer() { - let mut state = - KnotDocumentSurfaceState::new(KnotDocumentSession::scratch("memory:test", "hello")); - let first = state.session().input() as *const TextInput; - let second = state.input_mut_for_editable_view() as *mut TextInput; - assert_eq!(first, second.cast_const()); - } - - #[test] - fn read_only_posture_has_explicit_visible_labels() { - let snapshot = KnotDocumentSession::read_only("memory:test", "hello").snapshot(); - assert_eq!(posture_label(snapshot.write_posture), "read-only"); - assert_eq!( - save_outcome_label(&KnotDocumentSnapshotV1 { - refusal: Some(crate::KnotDocumentRefusalV1::ReadOnly), - last_save_outcome: Some(crate::KnotDocumentSaveOutcomeV1::Refused), - ..snapshot - }), - "Save: refused: document is read-only" - ); - } - - #[test] - fn read_only_view_has_no_editable_textbox_or_save_button() { - let dom: DomHandle = Rc::new(RefCell::new(ScriptedDom::new())); - let state = KnotDocumentSurfaceState::new(KnotDocumentSession::read_only( - "memory:field", - "# Field\n", - )); - let runner = GenetAppRunner::new(dom.clone(), knot_document_view, state); - let rendered = dom.borrow(); - let body = rendered - .all_with_class(rendered.document(), "knot-document-read-only") - .into_iter() - .next() - .expect("read-only body"); - assert_eq!( - rendered - .element_name(body) - .map(|name| name.local.to_string()), - Some("div".to_owned()) - ); - assert!( - !contains_element(&rendered, runner.root(), "textarea"), - "a read-only document must not retain an editable text control" - ); - assert!( - rendered - .all_with_class(rendered.document(), "knot-document-save") - .is_empty(), - "a read-only document must not render a save button" - ); - } -} diff --git a/ports/knot-document/src/editor.rs b/ports/knot-document/src/editor.rs deleted file mode 100644 index feb921eb1..000000000 --- a/ports/knot-document/src/editor.rs +++ /dev/null @@ -1,205 +0,0 @@ -// Copyright 2026 Mark Alan Boykin -// 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 https://mozilla.org/MPL/2.0/. -// SPDX-License-Identifier: MPL-2.0 - -use std::fs; -use std::path::{Path, PathBuf}; - -use cambium::{CaretSelection, TextCommand, TextInput}; -#[cfg(feature = "engine")] -use illume::{Fold, OutlineItem, Span}; -#[cfg(feature = "engine")] -use inker::EngineDocument; -pub use knot_editor_host::EditOutcome; -use knot_editor_host::KnotEditor as SharedKnotEditor; - -use crate::{DocumentFormat, SaveOutcome, write_if_distinct}; - -/// One Djot or legacy `.knot` session. Its Cambium input is the only source buffer. -pub struct KnotEditor { - path: Option, - address: String, - format: DocumentFormat, - editor: SharedKnotEditor, -} - -impl KnotEditor { - pub fn open(path: impl Into) -> Result { - let path = path.into(); - let format = DocumentFormat::from_path(&path) - .filter(|format| matches!(format, DocumentFormat::Knot | DocumentFormat::Djot)) - .ok_or_else(|| { - format!( - "KnotEditor requires a .djot or .knot file: {}", - path.display() - ) - })?; - let source = String::from_utf8( - fs::read(&path) - .map_err(|error| format!("could not read {}: {error}", path.display()))?, - ) - .map_err(|error| format!("{} is not UTF-8: {error}", path.display()))?; - let address = crate::writer::file_address(&path)?; - Ok(Self { - path: Some(path), - editor: SharedKnotEditor::scratch(address.clone(), source), - address, - format, - }) - } - - pub fn scratch(address: impl Into, source: impl Into) -> Self { - let address = address.into(); - Self { - path: None, - editor: SharedKnotEditor::scratch(address.clone(), source), - address, - format: DocumentFormat::Djot, - } - } - - pub fn input(&self) -> &TextInput { - self.editor.input() - } - pub fn input_mut(&mut self) -> &mut TextInput { - self.editor.input_mut() - } - pub fn source(&self) -> &str { - self.editor.source() - } - pub fn address(&self) -> &str { - &self.address - } - pub fn format(&self) -> DocumentFormat { - self.format - } - pub fn selection(&self) -> CaretSelection { - self.editor.selection() - } - pub fn apply(&mut self, command: TextCommand) -> EditOutcome { - self.editor.apply(command) - } - pub fn apply_layout_selection(&mut self, selection: CaretSelection) -> EditOutcome { - self.apply(TextCommand::SetSelection(selection)) - } - pub fn is_dirty(&self) -> bool { - self.editor.is_dirty() - } - pub fn path(&self) -> Option<&Path> { - self.path.as_deref() - } - - pub fn save(&mut self) -> Result { - let path = self - .path - .as_deref() - .ok_or_else(|| "scratch Knot editor has no save path".to_owned())?; - if !self.editor.is_dirty() { - return Ok(SaveOutcome::Unchanged); - } - let source = self.editor.source().to_owned(); - let existing = fs::read(path) - .map_err(|error| format!("could not read {}: {error}", path.display()))?; - let outcome = write_if_distinct(path, &existing, source.as_bytes())?; - self.editor.accept_saved_source(&source); - Ok(outcome) - } - - #[cfg(feature = "engine")] - pub fn highlights(&self) -> Vec { - self.editor.highlights() - } - #[cfg(feature = "engine")] - pub fn outline(&self) -> Vec { - self.editor.outline() - } - #[cfg(feature = "engine")] - pub fn folds(&self) -> Vec { - self.editor.folds() - } - #[cfg(feature = "engine")] - pub fn preview(&self) -> Result { - self.editor.preview() - } -} - -#[cfg(test)] -mod tests { - use std::fs; - - use cambium::{CaretAffinity, CaretPosition}; - use tempfile::tempdir; - - use super::*; - - #[cfg(feature = "engine")] - #[test] - fn commands_drive_the_one_source_used_by_every_readout() { - let mut editor = KnotEditor::scratch("memory:note", "# One\n"); - assert_eq!(editor.outline().len(), 1); - - let outcome = editor.apply(TextCommand::Insert("\n## Two\n".into())); - assert_eq!( - outcome, - EditOutcome { - state_changed: true, - source_changed: true, - } - ); - assert_eq!(editor.outline().len(), 2); - assert!(!editor.highlights().is_empty()); - assert!(!editor.preview().unwrap().blocks.is_empty()); - - editor.apply(TextCommand::Undo); - assert_eq!(editor.source(), "# One\n"); - assert_eq!(editor.outline().len(), 1); - } - - #[test] - fn layout_selection_preserves_byte_affinity() { - let mut editor = KnotEditor::scratch("memory:note", "abc"); - let selection = CaretSelection { - anchor: CaretPosition { - byte: 0, - affinity: CaretAffinity::Downstream, - }, - focus: CaretPosition { - byte: 2, - affinity: CaretAffinity::Upstream, - }, - }; - editor.apply_layout_selection(selection); - assert_eq!(editor.selection(), selection); - } - - #[cfg(feature = "engine")] - #[test] - fn ime_preedit_is_not_committed_or_fed_to_the_readout() { - let mut editor = KnotEditor::scratch("memory:note", "# One\n"); - let before = editor.preview().unwrap(); - let outcome = editor.apply(TextCommand::SetComposition { - text: "仮".into(), - selection: Some((3, 3)), - }); - assert!(outcome.state_changed); - assert!(!outcome.source_changed); - assert_eq!(editor.source(), "# One\n"); - assert_eq!(editor.preview().unwrap(), before); - assert_eq!(editor.input().render_text(), "# One\n仮"); - } - - #[test] - fn committed_commands_write_back_to_the_native_file() { - let temp = tempdir().unwrap(); - let path = temp.path().join("note.djot"); - fs::write(&path, "# One\n").unwrap(); - let mut editor = KnotEditor::open(&path).unwrap(); - editor.apply(TextCommand::Insert("\n## Two\n".into())); - assert!(editor.is_dirty()); - assert_eq!(editor.save().unwrap(), SaveOutcome::Written); - assert_eq!(editor.save().unwrap(), SaveOutcome::Unchanged); - assert_eq!(fs::read_to_string(path).unwrap(), "# One\n\n## Two\n"); - } -} diff --git a/ports/knot-document/src/lib.rs b/ports/knot-document/src/lib.rs deleted file mode 100644 index ee525f13e..000000000 --- a/ports/knot-document/src/lib.rs +++ /dev/null @@ -1,32 +0,0 @@ -// Copyright 2026 Mark Alan Boykin -// 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 https://mozilla.org/MPL/2.0/. -// SPDX-License-Identifier: MPL-2.0 - -//! Djot-first document authority and reusable Cambium presentation for Knot. -//! -//! The default dependency graph owns a single [`cambium::TextInput`] and native file -//! writes. Parsing, preview, and conversion are opt-in through [`engine`]. - -mod document_surface; -mod document_view; -mod editor; -mod writer; - -pub use document_surface::{ - KnotDocumentIntentErrorV1, KnotDocumentIntentV1, KnotDocumentRefusalV1, - KnotDocumentSaveFailureV1, KnotDocumentSaveOutcomeV1, KnotDocumentSession, - KnotDocumentSnapshotV1, KnotDocumentSourceKindV1, KnotDocumentSourceV1, - KnotDocumentWritePostureV1, -}; -pub use document_view::{ - KNOT_DOCUMENT_CSS, KnotDocumentSurfaceState, KnotDocumentView, knot_document_descriptor, - knot_document_surface, knot_document_view, -}; -pub use editor::{EditOutcome, KnotEditor}; -#[cfg(feature = "engine")] -pub use writer::AuthoredFile; -#[doc(hidden)] -pub use writer::write_if_distinct; -pub use writer::{DocumentFormat, SaveOutcome}; diff --git a/ports/knot-document/src/writer.rs b/ports/knot-document/src/writer.rs deleted file mode 100644 index 8585ec008..000000000 --- a/ports/knot-document/src/writer.rs +++ /dev/null @@ -1,326 +0,0 @@ -// Copyright 2026 Mark Alan Boykin -// 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 https://mozilla.org/MPL/2.0/. -// SPDX-License-Identifier: MPL-2.0 - -use std::fs; -use std::path::Path; -#[cfg(feature = "engine")] -use std::path::PathBuf; - -/// Formats Knot can author directly. Djot is the native current format; `.knot` -/// remains a compatibility format. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum DocumentFormat { - Knot, - Markdown, - Djot, - Json, -} - -impl DocumentFormat { - pub fn from_path(path: &Path) -> Option { - match path - .extension() - .and_then(|value| value.to_str()) - .map(str::to_ascii_lowercase) - .as_deref() - { - Some("knot") => Some(Self::Knot), - Some("md" | "markdown") => Some(Self::Markdown), - Some("djot") => Some(Self::Djot), - Some("json") => Some(Self::Json), - _ => None, - } - } - pub fn media_type(self) -> &'static str { - match self { - Self::Knot => "text/vnd.knot", - Self::Markdown => "text/markdown", - Self::Djot => "text/djot", - Self::Json => "application/vnd.knot.document+json", - } - } - pub fn from_media_type(value: &str) -> Option { - match value { - "text/vnd.knot" => Some(Self::Knot), - "text/markdown" => Some(Self::Markdown), - "text/djot" => Some(Self::Djot), - "application/vnd.knot.document+json" | "application/json" => Some(Self::Json), - _ => None, - } - } -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum SaveOutcome { - Unchanged, - Written, -} - -#[doc(hidden)] -pub fn write_if_distinct(path: &Path, before: &[u8], after: &[u8]) -> Result { - if before == after { - return Ok(SaveOutcome::Unchanged); - } - fs::write(path, after) - .map_err(|error| format!("could not write {}: {error}", path.display()))?; - Ok(SaveOutcome::Written) -} - -pub(crate) fn file_address(path: &Path) -> Result { - let path = fs::canonicalize(path) - .map_err(|error| format!("could not resolve {}: {error}", path.display()))?; - #[cfg(windows)] - { - let text = path.to_string_lossy(); - Ok(format!( - "file:///{}", - text.strip_prefix(r"\\?\") - .unwrap_or(&text) - .replace('\\', "/") - )) - } - #[cfg(not(windows))] - { - Ok(format!("file://{}", path.to_string_lossy())) - } -} - -#[cfg(feature = "engine")] -mod engine { - use super::*; - use inker::{DocumentTrustState, Engine, EngineDocument, EngineInput}; - use nematic::knot::djot::blocks_to_djot; - use nematic::{DjotKnotEngine, MarkdownEngine}; - use std::io; - - impl DocumentFormat { - pub fn validate_source(self, address: &str, source: &str) -> Result<(), String> { - self.parse(address, source.as_bytes()).map(|_| ()) - } - pub fn to_commonmark(self, address: &str, bytes: &[u8]) -> Result, String> { - self.parse(address, bytes) - .map(|document| document.to_markdown().into_bytes()) - } - fn parse(self, address: &str, bytes: &[u8]) -> Result { - if self == Self::Json { - return serde_json::from_slice(bytes) - .map_err(|error| format!("invalid Knot document JSON: {error}")); - } - let input = EngineInput { - address: address.to_owned(), - body: std::str::from_utf8(bytes) - .map_err(|error| format!("document is not UTF-8: {error}"))? - .to_owned(), - content_type: Some(self.media_type().to_owned()), - }; - match self { - Self::Knot | Self::Djot => DjotKnotEngine::new() - .render(&input) - .map_err(|error| format!("could not parse Djot document: {error}")), - Self::Markdown => MarkdownEngine::new() - .render(&input) - .map_err(|error| format!("could not parse Markdown document: {error}")), - Self::Json => unreachable!(), - } - } - fn serialize(self, document: &EngineDocument) -> Result, String> { - let text = match self { - Self::Knot => document_to_knot(document), - Self::Markdown => document.to_markdown(), - Self::Djot => blocks_to_djot(&document.blocks), - Self::Json => { - serde_json::to_string_pretty(document) - .map_err(|error| format!("could not encode Knot document JSON: {error}"))? - + "\n" - } - }; - Ok(text.into_bytes()) - } - } - pub struct AuthoredFile { - path: PathBuf, - format: DocumentFormat, - document: EngineDocument, - original: Vec, - dirty: bool, - } - impl AuthoredFile { - pub fn open(path: impl Into) -> Result { - let path = path.into(); - let format = DocumentFormat::from_path(&path) - .ok_or_else(|| format!("unsupported Knot authoring format: {}", path.display()))?; - let original = fs::read(&path) - .map_err(|error| format!("could not read {}: {error}", path.display()))?; - let document = format.parse(&file_address(&path)?, &original)?; - Ok(Self { - path, - format, - document, - original, - dirty: false, - }) - } - pub fn path(&self) -> &Path { - &self.path - } - pub fn format(&self) -> DocumentFormat { - self.format - } - pub fn document(&self) -> &EngineDocument { - &self.document - } - pub fn document_mut(&mut self) -> &mut EngineDocument { - self.dirty = true; - &mut self.document - } - pub fn save(&mut self) -> Result { - if !self.dirty { - return Ok(SaveOutcome::Unchanged); - } - let encoded = self.format.serialize(&self.document)?; - let outcome = write_if_distinct(&self.path, &self.original, &encoded)?; - self.original = encoded; - self.dirty = false; - Ok(outcome) - } - pub fn save_as( - &self, - path: impl AsRef, - format: DocumentFormat, - ) -> Result { - let path = path.as_ref(); - let existing = match fs::read(path) { - Ok(bytes) => bytes, - Err(error) if error.kind() == io::ErrorKind::NotFound => Vec::new(), - Err(error) => return Err(format!("could not read {}: {error}", path.display())), - }; - write_if_distinct(path, &existing, &format.serialize(&self.document)?) - } - pub fn canonicalize( - format: DocumentFormat, - address: &str, - bytes: &[u8], - ) -> Result, String> { - format - .parse(address, bytes) - .and_then(|document| format.serialize(&document)) - } - } - fn document_to_knot(document: &EngineDocument) -> String { - let mut output = String::new(); - let frontmatter = document.title.is_some() - || document.provenance.canonical_uri.is_some() - || document.provenance.fetched_at.is_some() - || document.provenance.source_label.is_some() - || document.trust != DocumentTrustState::Unknown; - if frontmatter { - output.push_str("---\n"); - if let Some(value) = &document.title { - output.push_str(&format!("title: {value}\n")); - } - if let Some(value) = &document.provenance.canonical_uri { - output.push_str(&format!("source: {value}\n")); - } - if let Some(value) = &document.provenance.fetched_at { - output.push_str(&format!("captured: {value}\n")); - } - if let Some(value) = &document.provenance.source_label { - output.push_str(&format!("source_label: {value}\n")); - } - let trust = match document.trust { - DocumentTrustState::Trusted => Some("trusted"), - DocumentTrustState::Tofu => Some("tofu"), - DocumentTrustState::Insecure => Some("insecure"), - DocumentTrustState::Broken => Some("broken"), - DocumentTrustState::Unknown => None, - }; - if let Some(value) = trust { - output.push_str(&format!("trust: {value}\n")); - } - output.push_str("---\n\n"); - } - output.push_str(&blocks_to_djot(&document.blocks)); - output - } -} -#[cfg(feature = "engine")] -pub use engine::AuthoredFile; - -#[cfg(all(test, feature = "engine"))] -mod tests { - use std::fs; - - use inker::EngineDocument; - use tempfile::tempdir; - - use super::*; - - #[test] - fn foreign_formats_reach_a_fixed_point_after_one_parse_write() { - let cases = [ - ( - DocumentFormat::Markdown, - b"# Heading\n\nA *small* note.\n".as_slice(), - ), - ( - DocumentFormat::Djot, - b"# Heading\n\nA small note.\n".as_slice(), - ), - ( - DocumentFormat::Json, - br#"{"address":"memory:test","title":null,"content_type":"text/plain","lang":null,"provenance":{},"trust":"Unknown","diagnostics":[],"blocks":[]}"#, - ), - ]; - for (format, source) in cases { - let once = AuthoredFile::canonicalize(format, "memory:test", source).unwrap(); - let twice = AuthoredFile::canonicalize(format, "memory:test", &once).unwrap(); - assert_eq!(once, twice, "{format:?} did not reach a fixed point"); - } - } - - #[test] - fn a_canonical_knot_round_trip_is_byte_exact() { - let source = - b"---\ntitle: Field note\ntrust: tofu\n---\n\n# Field note\n\norchard observations\n"; - let canonical = - AuthoredFile::canonicalize(DocumentFormat::Knot, "memory:field", source).unwrap(); - assert_eq!( - AuthoredFile::canonicalize(DocumentFormat::Knot, "memory:field", &canonical).unwrap(), - canonical - ); - } - - #[test] - fn untouched_files_never_enter_the_write_path() { - let temp = tempdir().unwrap(); - let path = temp.path().join("foreign.md"); - fs::write(&path, "# Deliberately foreign spacing\n").unwrap(); - let mut file = AuthoredFile::open(&path).unwrap(); - - let original_permissions = fs::metadata(&path).unwrap().permissions(); - let mut read_only = original_permissions.clone(); - read_only.set_readonly(true); - fs::set_permissions(&path, read_only).unwrap(); - assert_eq!(file.save().unwrap(), SaveOutcome::Unchanged); - - fs::set_permissions(&path, original_permissions).unwrap(); - } - - #[test] - fn caller_selects_the_output_format() { - let temp = tempdir().unwrap(); - let source = temp.path().join("note.md"); - let target = temp.path().join("note.json"); - fs::write(&source, "# Note\n\nA body.\n").unwrap(); - let file = AuthoredFile::open(&source).unwrap(); - assert_eq!( - file.save_as(&target, DocumentFormat::Json).unwrap(), - SaveOutcome::Written - ); - let document: EngineDocument = serde_json::from_slice(&fs::read(target).unwrap()).unwrap(); - assert_eq!(document.title.as_deref(), Some("Note")); - } -} diff --git a/ports/knot/Cargo.toml b/ports/knot/Cargo.toml deleted file mode 100644 index f3802dbf0..000000000 --- a/ports/knot/Cargo.toml +++ /dev/null @@ -1,93 +0,0 @@ -[package] -name = "knot-editor" -version = "0.0.3" -edition.workspace = true -license.workspace = true -repository.workspace = true -description = "Knot Editor, Mere's files-in-place local-first Djot editor." - -[[bin]] -name = "knot_endpoint" -path = "src/bin/knot_endpoint.rs" - -[[bin]] -name = "knot_sync_host" -path = "src/bin/knot_sync_host.rs" - -[dependencies] -base64.workspace = true -blake3.workspace = true -chartulary.workspace = true -eidetic = { workspace = true, features = ["json-schema"] } -fetch.workspace = true -fleece.workspace = true -graphshell-endpoint.workspace = true -chirograph.workspace = true -graphshell-stdio.workspace = true -inker.workspace = true -jotdown = "0.10" -knot-document = { workspace = true, features = ["engine"] } -muniment = { workspace = true, features = ["redb"] } -mora.workspace = true -mora-cmudict.workspace = true -nematic.workspace = true -notify = "8" -notochord = { workspace = true, features = ["tokio"] } -personae.workspace = true -p2panda-core = "0.7.0" -# Every p2panda-net feature except `sqlite`. The address book runs on -# stickleback's muniment-backed store, so p2panda-store's bundled SQLite -# backend (and sqlx with it) stays out of the graph entirely. Feature -# unification means EVERY consumer has to opt out, not just one. -p2panda-net = { package = "mere-p2panda-net", version = "=0.7.2", default-features = false, features = [ - "address_book", - "iroh_endpoint", - "iroh_mdns", - "discovery", - "gossip", - "sync", -] } -p2panda-store = { version = "0.7.0", default-features = false } -pollster = "0.4" -postcard = { version = "1", features = ["alloc"] } -proofs.workspace = true -quinn = "0.11" -rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } -sceno.workspace = true -scenotime.workspace = true -serde.workspace = true -serde_json.workspace = true -sha2.workspace = true -similar = "2.7" -script-rhai.workspace = true -servitor.workspace = true -pandect.workspace = true -esp.workspace = true -stickleback.workspace = true -thiserror = "1" -transport = { workspace = true, features = ["noise", "notochord"] } -tokio = { version = "1", features = ["io-util", "macros", "rt-multi-thread", "sync", "time"] } -tracing.workspace = true -tracing-subscriber.workspace = true -url = "2" -uuid.workspace = true -zeroize = { version = "1", features = ["derive"] } - -[dev-dependencies] -gemot.workspace = true -genet-probe = { workspace = true } -graphshell = { path = "../graphshell" } -graphshell-local.workspace = true -rcgen = "0.14" -tempfile = "3" -tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] } -transport.workspace = true - -[target.'cfg(windows)'.dependencies] -windows-sys = { version = "0.59", features = [ - "Win32_Foundation", - "Win32_Storage_FileSystem", -] } - -[lints] -workspace = true diff --git a/ports/knot/README.md b/ports/knot/README.md deleted file mode 100644 index 5d22e2ad2..000000000 --- a/ports/knot/README.md +++ /dev/null @@ -1,65 +0,0 @@ -# Knot Editor - -Knot Editor is Mere's files-in-place authoring port. It serves a Graphshell -projection over a real directory or a sealed personal vault, so a host can -mount documents without owning the source files or the vault keys. File bytes -stay on disk; containers carry `file:` references, titles, media types, and -facets. - -The Cargo package is `knot-editor` and the Rust crate is `knot_editor`. -The resident route and existing document addresses remain `knot` because they -name the content and protocol, rather than this package. - -## Modules - -`src/lib.rs` re-exports everything below. - -| Module | Contents | -|---|---| -| `content_classes` | `KnotContentClasses` (`registry`, `validator`), `FILE_CLASS` (`knot.file`), `NOTE_CLASS` (`knot.note`), `FILE_DOCUMENT_FACET`, `NOTE_DOCUMENT_FACET`. Facet schemas are built with `eidetic::MereNativeSchemaBuilder` and registered in a `session_runtime::SchemaFacetValidator`. | -| `directory` | `DirectorySource`, `DiskDocument`, `IgnorePolicy`. Discovery keyed by filesystem identity (volume + file index on Windows, device + inode on Unix, path as fallback), so a container and its facets survive rename. | -| `endpoint` | `KnotEndpoint`, `KnotWriteGrant`, `KnotEffectAuthority`, `KnotEffectFetcher`, `KnotEffectMode`, `KnotEffectPolicy`. Constructors: `open`, `open_writable`, `open_with_identity`, `open_writable_with_identity`, `fixture`, `from_vault`, `from_synced_vault`, `from_communal_vault`. Grants are revocable at runtime: `revoke_watcher`/`grant_watcher`, `revoke_writes`/`grant_writes`, `revoke_effects`/`grant_effects`, `lock_vault`/`unlock_vault`. | -| `watcher` | `DirectoryWatcher`. A `notify` recursive watch whose queued events collapse into one attributed Servitor journal transition under a revocable grant on the `watch` scope. | -| `writer` | `AuthoredFile`, `DocumentFormat` (`Knot`, `Markdown`, `Djot`, `Json`), `SaveOutcome`. Fixed-point writing: untouched files are not rewritten. | -| `editor` | `KnotEditor`, `EditOutcome`. Cambium's `TextInput` owns the sole source buffer; highlights, outline, folds, and preview are derived by `knot_editor_host::KnotReadout`. | -| `vault` | `KnotVault`, `VaultDocument`. Sealed document store on `personae::SealedRecordStorage`, with the Sibylla search index sealed in the same store. | -| `search` | `KnotSearch`, `SearchConfig`, `SearchHit`, `SearchLane` (`Disk`, `Vault`). Sibylla lexical index over both lanes under separate Servitor caps, `knot/search/disk` and `knot/search/vault`. | -| `sync` | `KnotSyncStore` and its `KnotSyncFileStore` redb alias, `KnotSyncEvent`, `KnotSyncExt`, `KnotSyncCipher`, `KnotSyncError`, `KnotDocumentProjection`, `KnotDocumentVersion`, `KnotDocumentConflict`, `KnotAutomaticTextMerge`, `KnotEncryptionProfile` (`PersonalVaultV1`, `CommonsDataV1`), `KNOT_COMMONS_ENCRYPTION_PROFILE`, `KnotCheckpointSnapshot`, `KnotProjectionCheckpoint`, `KnotEpochExecutionReceipt`, `KnotOfflineMemberEpochHold`, `KnotOfflineMemberRecovery`, `KnotTailReceipt`. Signed causal events over Stickleback and p2panda; the projection reports same-document conflicts and, where two concurrent versions share a base, a clean three-way text merge. | -| `resident` | `KnotSyncHost`, `KnotSyncHostConfig`, `KnotSyncHostError`. Keeps a persona's space joined over `transport::P2pandaTransport` without serving a projection. | -| `settings` | `KnotSettings`, `KnotSyncSettings`, `KnotSettingsError`, `knot_settings_path`, `hex32`, `parse_hex32`. Per-persona `knot-sync.json` holding paired writer keys, relay urls, and peer hints. | -| `startup` | `StartupUnlockedPersonalVault`, `local_device_root`, `persona_vault_root`. Recovers the persona epoch through session-runtime's wallet and derives the vault key, space id, and device-distinct writer key. | - -## Binaries and example - -| Target | Invocation | -|---|---| -| `knot_endpoint` | Serves over stdio with `graphshell_stdio::serve_resumable_notifying`. Modes: no argument (the deterministic K0 fixture), `[directory]`, `directory `, `directory-write `, `directory-write-effects `, `persona-vault `, `persona-vault-effects ...`, `communal-fixture-effects ...`. Effect modes are `auto`, `ask`, `never`. | -| `knot_sync_host` | `knot_sync_host [--label ] [--log-file ]`. Management verbs exit after reporting: `--pair-writer <64-hex>`, `--unpair-writer <64-hex>`, `--pairing-facts`. | -| `examples/k2_peer.rs` | Two-machine rehearsal for a place-held document. `cargo run -p knot-editor --example k2_peer -- hold --root ` on the holder; `visit --peer ` or `visit --discover` on the visitor. Env: `K2_OWNER`, `K2_SEED`, `K2_NETWORK`, and `K2_PEER` for `--discover`. It is an example rather than a bin because it uses the `graphshell` dev-dependency. | - -Integration tests: `tests/place_projection.rs`, `tests/revision_bell.rs`, -`tests/send_probe.rs`. - -## Dependencies - -- Disclosure: `graphshell-endpoint`, `chirograph`, `graphshell-stdio`, - `sceno`, `scenotime`. -- Graph and schema: `chartulary`, `eidetic` (`json-schema`), `session-runtime`, - `servitor`, `proofs`. -- Documents: `inker`, `nematic`, `illume`, `cambium`, `knot-editor-host`, - `similar`. -- Storage and identity: `muniment` (`redb`), `personae`, `zeroize`. -- Sync: `stickleback`, `transport`, `p2panda-core`, `p2panda-net`, - `p2panda-store` (all 0.7). -- Search: `esp::embed`. -- Effects: `fetch`, `script-rhai`, `url`. -- Filesystem and platform: `notify` 8, and `windows-sys` on Windows for file - identity. -- Dev-dependencies: `graphshell` (path `../graphshell`), `notochord`, - `tempfile`. - -## Plans - -- [Knot port plan](../../design_docs/mere_docs/implementation_strategy/2026-07-25_knot_port_plan.md) -- [Knot authoring consumer plan](../../design_docs/mere_docs/implementation_strategy/2026-07-27_knot_authoring_consumer_plan.md) -- [Knot in Graphshell plan](../../design_docs/mere_docs/implementation_strategy/2026-08-02_knot_in_graphshell_plan.md) diff --git a/ports/knot/desktop/Cargo.toml b/ports/knot/desktop/Cargo.toml deleted file mode 100644 index 21419c923..000000000 --- a/ports/knot/desktop/Cargo.toml +++ /dev/null @@ -1,18 +0,0 @@ -[package] -name = "knot-desktop" -version = "0.0.1" -edition = "2024" -publish = false - -[[bin]] -name = "knot" -path = "src/main.rs" - -[dependencies] -cambium-genet-winit-host = { git = "https://github.com/merely-made/genet.git", rev = "da8762fd910d855d3bccec8af75d474d360e35b6" } -knot-document = { path = "../../knot-document" } -layout-dom-api = { git = "https://github.com/merely-made/genet.git", rev = "da8762fd910d855d3bccec8af75d474d360e35b6", version = "=0.1.0" } - -[dev-dependencies] -genet-probe = { git = "https://github.com/merely-made/genet.git", rev = "da8762fd910d855d3bccec8af75d474d360e35b6" } -tempfile = "3" diff --git a/ports/knot/desktop/src/main.rs b/ports/knot/desktop/src/main.rs deleted file mode 100644 index f0c6f7366..000000000 --- a/ports/knot/desktop/src/main.rs +++ /dev/null @@ -1,159 +0,0 @@ -// Copyright 2026 Mark Alan Boykin -// 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 https://mozilla.org/MPL/2.0/. -// SPDX-License-Identifier: MPL-2.0 - -//! Thin standalone host for the reusable Knot document surface. -use cambium_genet_winit_host::{ - CloseDisposition, FocusedTextSlot, HostHooks, HostOptions, Init, Key, KeyPress, Runner, - inert_hooks, run, -}; -use knot_document::{ - KNOT_DOCUMENT_CSS, KnotDocumentIntentV1, KnotDocumentSession, KnotDocumentSurfaceState, - KnotDocumentView, knot_document_view, -}; -use layout_dom_api::LayoutDom; -use std::ffi::OsString; -use std::path::PathBuf; -const SCRATCH_ADDRESS: &str = "scratch:untitled"; -#[derive(Debug, PartialEq, Eq)] -enum DocumentSelection { - Scratch, - File(PathBuf), -} -fn select_document>(args: I) -> Result { - let mut args = args.into_iter(); - let _ = args.next(); - let path = args.next(); - if args.next().is_some() { - return Err("expected zero or one document path".into()); - } - Ok(path - .map(|path| DocumentSelection::File(PathBuf::from(path))) - .unwrap_or(DocumentSelection::Scratch)) -} -fn open_selection(selection: DocumentSelection) -> Result { - match selection { - DocumentSelection::Scratch => Ok(KnotDocumentSession::scratch(SCRATCH_ADDRESS, "")), - DocumentSelection::File(path) => KnotDocumentSession::open(path), - } -} -fn is_save_chord(press: &KeyPress) -> bool { - press.modifiers.is_command_chord() - && matches!(&press.key, Key::Character(key) if key.eq_ignore_ascii_case("s")) -} -fn focused_text( - runner: &Runner< - KnotDocumentSurfaceState, - fn(&KnotDocumentSurfaceState) -> KnotDocumentView, - KnotDocumentView, - >, -) -> Option> { - let focused = runner.focus()?; - if runner.state().snapshot().write_posture - == knot_document::KnotDocumentWritePostureV1::ReadOnly - { - return None; - } - let dom = runner.dom(); - let dom_ref = dom.borrow(); - let textarea = LayoutDom::element_name(&*dom_ref, focused) - .is_some_and(|name| name.local.as_ref() == "textarea"); - drop(dom_ref); - textarea.then(|| FocusedTextSlot { - node: focused, - get: Box::new(|state| state.session().input()), - get_mut: Box::new(|state| { - state - .session_mut() - .input_mut() - .expect("focused textarea requires writable document posture") - }), - }) -} -fn host_hooks() -> HostHooks< - KnotDocumentSurfaceState, - fn(&KnotDocumentSurfaceState) -> KnotDocumentView, - KnotDocumentView, -> { - let mut hooks = inert_hooks(); - hooks.close_request = Box::new(|_, _| CloseDisposition::Exit); - hooks.focused_text = Box::new(focused_text); - hooks.key_intercept = Box::new(|runner, press| { - if !is_save_chord(press) { - return false; - } - runner.update(|state| { - let _ = state.apply(KnotDocumentIntentV1::Save); - }); - true - }); - hooks -} -fn run_standalone(session: KnotDocumentSession) -> Result<(), String> { - run( - HostOptions { - title: "Knot".into(), - initial_logical_size: (1100.0, 700.0), - ..HostOptions::default() - }, - move |_, _, _| Init { - state: KnotDocumentSurfaceState::new(session), - logic: knot_document_view, - sheet: KNOT_DOCUMENT_CSS.into(), - }, - host_hooks(), - ) - .map_err(|error| error.to_string()) -} -fn main() { - let session = select_document(std::env::args_os()) - .and_then(open_selection) - .unwrap_or_else(|error| { - eprintln!("knot: {error}"); - std::process::exit(1) - }); - if let Err(error) = run_standalone(session) { - eprintln!("knot: host failed: {error}"); - std::process::exit(1); - } -} -#[cfg(test)] -mod tests { - use super::*; - use cambium_genet_winit_host::{CloseRequest, Harness, Modifiers}; - use genet_probe::Selector; - use tempfile::tempdir; - #[test] - fn app_authored_open_edit_save_close_reopen_receipt() { - let temp = tempdir().unwrap(); - let path = temp.path().join("receipt.djot"); - std::fs::write(&path, "# Receipt\n").unwrap(); - let init = Init { - state: KnotDocumentSurfaceState::new(KnotDocumentSession::open(&path).unwrap()), - logic: knot_document_view, - sheet: KNOT_DOCUMENT_CSS.into(), - }; - let mut harness = Harness::with_hooks(init, host_hooks()); - harness.layout_at(900.0, 640.0); - assert!(harness.click_on(&Selector::role("textbox").containing("Document text"))); - harness.key_injected("Body\n"); - assert!(harness.state().snapshot().dirty); - harness.set_modifiers(Modifiers { - ctrl: true, - ..Modifiers::NONE - }); - harness.key_char("s"); - assert!(!harness.state().snapshot().dirty); - harness.request_close(CloseRequest::Native); - drop(harness); - assert!( - KnotDocumentSession::open(&path) - .unwrap() - .snapshot() - .text - .contains("Body") - ); - } -} diff --git a/ports/knot/docs/2026-07-27_k0_k7_receipt.md b/ports/knot/docs/2026-07-27_k0_k7_receipt.md deleted file mode 100644 index 20fb10f3a..000000000 --- a/ports/knot/docs/2026-07-27_k0_k7_receipt.md +++ /dev/null @@ -1,102 +0,0 @@ -# Knot K0 through K7 receipt - -Date: 2026-07-27 - -## Claim - -Knot is a Mere workspace port with a real Graphshell endpoint. It owns -files-in-place projection, a sealed vault, grant-scoped search, encrypted -causal personal and Commons sync, format-selectable writers, and the editor -adapter. Source bytes, vault keys, group epochs, and edit state remain with the -endpoint. - -## Executed proof - -Focused verification: - -```powershell -cargo test -p knot -cargo test -p knot --test revision_bell -cargo test -p chirograph -p graphshell-endpoint -p graphshell-stdio -p graphshell --lib -cargo clippy -p knot --all-targets --no-deps -- -D warnings -cargo check -p knot --all-targets -``` - -Thirty-six Knot library tests pass, plus the real-process revision-bell test. -The focused supporting suites pass 39 Graphshell, 9 protocol, and 5 stdio -tests. They cover content classes, disk authority and -rename-stable identity, native watcher attribution and revocation, vault -sealing and lock behavior, grant-scoped disk/vault search, memory and real -p2panda convergence, visible same-document conflicts, explicit causal -resolution, Commons data-key rotation, signed encryption-profile separation, -format fixed points, untouched-file preservation, byte-exact canonical -`.knot`, and Cambium-backed command, IME, affinity, readout, undo, and save -behavior. - -The dependency-inclusive warning-denying Clippy command reaches existing -`numen::FieldId` and `CouplingId` `new_without_default` findings before Knot. -Knot's own `--no-deps` warning-denying gate passes. `cargo check -p knot ---all-targets` also passes. - -K7 resolves the completed Cambium primitive committed on local Genet `main` at -`44e291afe8b`. Until that commit is published, this remains a local integration -receipt rather than a remote clean-checkout one. - -## Communal and conflict receipt - -`PersonalVaultV1` and `CommonsDataV1` are signed into each operation's -addressing extension and admission rejects cross-profile replay. Two members -with different personal vault keys can project the same communal document -through a shared retained data epoch. After the group rotates without one -member, that member can still read the retained earlier operation but cannot -decrypt the new one. - -Same-document conflict is lossless. `Resolve` names the exact operation ids it -replaces and must causally follow each of them. Tests prove a chosen value -converges, an unseen concurrent value survives, and a forged resolution naming -an operation outside its causal history fails the fold. - -## Revision-bell receipt - -Graphshell protocol 1.1 carries `CarrierNotice { session, epoch, revision }` -without scene or presentation bytes. Knot's notifying stdio endpoint polls its -native watcher while input is quiet and emits notices through the same -line-atomic writer as keyed responses. Graphshell marks the mounted scene stale -and resumes from its own last acknowledgement. - -The integration test starts a real `knot_endpoint` child, snapshots a temporary -directory, edits a file, waits for the endpoint-initiated notice, sends -`Resume`, and receives a replacement snapshot at the announced revision. - -## Cross-process receipt - -The host and three endpoints were built independently: - -```powershell -# repos/mere -cargo build -p graphshell --bin g4_sessions -cargo build -p knot --bin knot_endpoint - -# repos/turnstone -cargo build --bin graphshell_endpoint - -# repos/isometry -cargo build -p isometry-graphshell --bin isometry_endpoint -``` - -One product-neutral host then mounted all three endpoint processes: - -```text -mounted 4 sessions from 3 endpoints into ports\knot\docs\receipts\k0_session_switch.html -``` - -The four sessions are Turnstone's browsing graph, Isometry's player overmap and -tile board, and Knot's authoring fixture. Repeating the generation produced the -same SHA-256 digest: - -```text -28459BF5591CFB67CCCAD09BF5D2AFCD1F829FADE644C724F1E4DEBC6A076E60 -``` - -The [HTML receipt](receipts/k0_session_switch.html) is the generated proof -artifact. diff --git a/ports/knot/docs/2026-08-08_k2_physical_two_machine_receipt.md b/ports/knot/docs/2026-08-08_k2_physical_two_machine_receipt.md deleted file mode 100644 index fe3787ab5..000000000 --- a/ports/knot/docs/2026-08-08_k2_physical_two_machine_receipt.md +++ /dev/null @@ -1,104 +0,0 @@ -# Knot K2 physical two-machine receipt - -Date: 2026-08-08 - -Status: passed. - -## Scope - -This closes the physical-machine remainder of K2. It proves that a Knot vault -held on one physical machine can be mounted and edited by a Graphshell -projection visitor on another physical machine, with the holder's file as -source truth and the revision bell carrying the change back to the visitor. - -This is not the Knot publishing Phase A receipt. K2 deliberately gives both -machines the same fixture owner secret so the visitor can mint its admission -grant. It does not prove an independently addressed reader capability or the -raw publishing protocol. - -## Machines and source - -- holder: `Q-PC.local`, Darwin 24.6.0, x86_64; -- visitor: `O-PC`, Windows 11 Home Insider Preview 10.0.26220, 64-bit; -- source base: `fd7e0459328a6edab761d3ae2c0a7d8b9067f808` plus the isolated - K2 working-tree snapshot; -- `Cargo.lock` SHA-256 on both machines: - `3863ea4ec127b33ef68fe516a5d85ba80f89b155c01daf54aab4ba769d8e7a76`; -- `k2_peer.rs` SHA-256 on both machines: - `1dc8fa2dcc72a9520f34aecc74f3be8d7e95fc2979cc75731b9f6a8ae81aaae0`. - -The live Windows checkout changed concurrently during the first build, so the -receipt used isolated scratch checkouts on both machines. Both runners were -built from the same lockfile with: - -```text -cargo build --locked -p knot --example k2_peer -``` - -Both builds passed. The Windows build retained existing workspace warnings; -the Q-PC build retained the same warnings plus one platform-specific unused -variable warning in `directory.rs`. - -The runner initially closed the Graphshell projection session but dropped its -iroh endpoint. K2 exposed that as an ungraceful endpoint-drop diagnostic. -`P2pandaTransport::close` now waits for iroh's endpoint close, and the visitor -calls it after the bounded session thread returns. - -## Passing run - -The final run used an explicit, redacted endpoint ticket. Both peers shared -`K2_OWNER` and `K2_NETWORK`; Q-PC and Windows used distinct `K2_SEED` values. -The ticket proves the out-of-band ticket path. The runner does not instrument -whether iroh selected a direct address or a relay from that ticket. - -Q-PC admitted the Windows subject and opened a live Knot endpoint. Windows -reported: - -```text -k2_peer visit - peer from ticket: [redacted] - admitted - endpoint: Knot - opened 55 bytes of source - save accepted by the holder - waiting for the holder's revision bell... - bell heard, and it carried a revision we had not seen - session status: Live - the holder's copy is what we wrote - closed -``` - -The visitor exited `0`. Holder and visitor stderr were both empty. The Q-PC -file grew from 55 to 82 bytes and ended with: - -```text -Visited at 1786165418684. -``` - -The file read back from Q-PC had SHA-256 -`550cb33561e3c7a727d2b08d5be3fcb18293f786cea0466895f6476658b381e5`, -matching the copy captured on Windows. The holder was stopped only after the -visitor had closed. - -Generated logs, binaries, tickets, source snapshots, and file copies remain -outside Git under: - -```text -C:\t\mere-k2-physical-20260808-61ea7a173ac0-final4 -/tmp/mere-k2-physical-20260808-61ea7a173ac0-final4 -``` - -## Rejected runs and remaining boundary - -The first physical direction, Windows holder to Q-PC visitor, parsed the ticket -but timed out before admission. The Windows file remained byte-identical. That -direction remains a reachability defect on the current network. - -An earlier reverse-direction pass was discarded after validation found that -the two scratch builds had resolved different lockfiles. The first locked pass -completed the edit and bell path but lost the carrier during final close. The -clean final receipt above uses one lockfile and the explicit transport close. - -K2's physical done condition is met by the Q-PC-holder to Windows-visitor run. -Bidirectional reachability, ticketless mDNS, and the independently authorized -Knot publishing protocol remain separate receipts. diff --git a/ports/knot/docs/2026-08-22_device_resident_v1_receipt.md b/ports/knot/docs/2026-08-22_device_resident_v1_receipt.md deleted file mode 100644 index b2ad8120f..000000000 --- a/ports/knot/docs/2026-08-22_device_resident_v1_receipt.md +++ /dev/null @@ -1,135 +0,0 @@ -# Device Resident V1 Receipt - -**Date:** 2026-08-22 -**Code:** `228213fe` -**Status:** Automated cone and Turnstone headed edit/close/restart complete. -Physical two-device and the remaining standalone/evidence-headed receipts stay -open. - -## What this closes - -The final audit found two claims that the earlier focused receipts did not yet -make: - -1. Personal unpairing had changed live authority, but the two-device artifact - flow had not attempted a new evidence fetch after unpairing. -2. Communal authority had been materialized from Gemot certificates, but a live - Knot host had not applied a signed grant and signed revocation while all - three consumers were running. - -`paired_peers_replicate_djot_then_fetch_and_reopen_verified_evidence` now -retains a second artifact, removes Device B from the personal authority, and -proves all three facts together: exact-hash serving is denied, a new source -fetch fails, and Device A keeps its retained bytes. - -`signed_gemot_grant_and_revocation_update_every_live_consumer` begins with an -empty communal authority, accepts signed Gemot certificates for independent -`document`, `evidence/read`, and `evidence/source` capabilities, applies their -materialized revision to one live host, and then accepts signed revocations. -Writing, exact-hash serving, source selection, and route hints move on the same -revision. Revocation removes all three rights without deleting locally retained -bytes. - -The full Graphshell run also exposed a shutdown defect. `PersonalSyncHost` was -dropping `JoinedSpace` and polling redb, even though `JoinedSpace` already owns a -waited shutdown that joins the drain and LogSync actor. With another peer still -live, the old path exhausted its database-lock retry. `close` now calls -`leave_and_wait` before closing the endpoint and probing the store. The original -three-host test passes without changing its shutdown order. - -## Automated evidence - -The following commands passed on the compatible p2panda 0.7.0 source: - -```text -cargo test -p knot --lib --offline -101 passed; 0 failed - -cargo test -p mere-transport --lib --offline -45 passed; 0 failed - -cargo test -p titulus --lib --offline -12 passed; 0 failed - -CARGO_PROFILE_TEST_DEBUG=0 cargo test -p graphshell \ - --features personal-sync --lib --offline -236 passed; 0 failed -``` - -The Graphshell command was repeated from a detached `228213fe` worktree using -the workspace's committed p2panda source. During verification, the adjacent -local p2panda checkout advanced from 0.7.0 to 0.7.1. Loading that checkout makes -Stickleback fail to compile because the new `Header` no longer supplies the -serialization and `to_bytes` API the current adapter expects. That integration -drift is outside this V1 change and is not counted as a test failure. - -## Headed Turnstone evidence - -The headed lane now has a real first-party composition receipt rather than a -sample-graph launch. Mere `4565d040` adds a receipt fixture that authors -`knot://vault/field-note` through `StartupUnlockedPersonalVault` and selects -that persona through Graphshell's ordinary owner settings. Turnstone -`02772e0`, `952f0df`, and `8637abe` add the scenario key/IME routing and the two -checked-in edit and reopen scenarios. Genet `9d3f2bd3031` makes the shared Probe -selector recognize a textarea's native `textbox` role. - -The final run used fresh persona `00000000-0000-0000-0000-00000000a502`, fresh -stores, and a private first-party named pipe. One hidden -`graphshell_device_host` process logged `resident Knot route open` and -`door="first-party"`. A headed Turnstone process then: - -1. opened the resident `knot` route; -2. focused the painted editor through Turnstone's ordinary pointer path; -3. inserted `headed edit` through the same IME seam as native input; -4. saved with the same `Ctrl+S` key seam as native input; -5. captured the saved editor; -6. closed the live content pane and asserted that its surface was absent. - -Its app-authored sentinel reported `RESULT ok`. Graphshell's original resident -process was still live after Turnstone exited. A second, fresh Turnstone process -then reopened `field-note`, asserted `Resident V1 headed edit` and `saved`, and -wrote a second `RESULT ok` sentinel. The app-authored frames are -`01_resident_edit.png`, `02_resident_after_close.png`, and -`03_resident_reopened.png` in the isolated receipt archive at -`C:\t\knot-v1-headed-run-20260822-a502`. - -The shared semantic selector exposed one bounded drift. Genet Probe re-derives -layout and aimed the textarea role at x621, outside the narrower textarea that -Turnstone actually painted. The receipt therefore records its fixed 1024 x 600 -painted point, x480/y65, and still routes that point through the ordinary -pointer lifecycle. Moving selector resolution onto Turnstone's retained, -painted layout is a separate Genet/Turnstone automation contract change. It is -not resident authority work. - -## Remaining evidence - -The Rust receipt simulates two device identities, two stores, real endpoints, -document-before-artifact delivery, verified fetch, restart, offline read, and -post-unpair refusal. It does not substitute for the plan's physical two-device -run. - -The remaining headed work is narrower now. There is no standalone Knot -executable yet, only the reserved `knot-editor` package name, so standalone sync -status, the standalone edit/restart/evidence-open flow, and directory-only -editing with the desktop resident stopped are not runnable claims. Turnstone's -resident route also has no evidence-open scenario yet. Those are product/UI -composition lanes, not reasons to reopen the resident document authority that -this receipt exercised. - -## Command-palette lag - -The reported lag while Turnstone's command palette is open is recorded as a -Turnstone chrome performance lane. Code reading shows a plausible hot path: the -open palette enlarges the chrome subtree, while chrome synchronization and -scene production run on redraw. A disposable unoptimized build reproduced the -whole-frame delta at 1024 x 600: two closed redraws took 25.9 ms and 26.7 ms, -while four open-palette redraws took 239.3 ms through 250.7 ms. This small -development sample confirms the symptom but does not attribute it. - -The next receipt should compare one release-build scene and profile with the -palette closed and open, publish median and p95 frame time plus -input-to-present latency, and attribute the delta among suggestion computation, -chrome cascade/layout/paint, and background resident work. The fix is done when -merely leaving the palette open causes no repeated work and the responsible -stage has an executable regression check. This observation does not widen the -resident lane unless profiling demonstrates resident work on the hot path. diff --git a/ports/knot/docs/receipts/k0_session_switch.html b/ports/knot/docs/receipts/k0_session_switch.html deleted file mode 100644 index c99553135..000000000 --- a/ports/knot/docs/receipts/k0_session_switch.html +++ /dev/null @@ -1,66 +0,0 @@ - - - -Graphshell G4 session switch -
diff --git a/ports/knot/examples/k2_peer.rs b/ports/knot/examples/k2_peer.rs deleted file mode 100644 index c55bf6a22..000000000 --- a/ports/knot/examples/k2_peer.rs +++ /dev/null @@ -1,497 +0,0 @@ -// Copyright 2026 Mark Alan Boykin -// 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 https://mozilla.org/MPL/2.0/. -// SPDX-License-Identifier: MPL-2.0 - -//! k2_peer — the two-machine rehearsal for a place-held Knot document. -//! -//! K2 is proven against a paired in-process fixture, which exercises every -//! layer except the physical one. This is the bin that puts it on two devices: -//! a real `KnotEndpoint` over a real directory on the holder, reached by a -//! visitor that never holds a replica and edits by sending intents. -//! -//! Modelled on `graphshell`'s `g5_peer`, down to the ticket exchange and the -//! environment-named identity, because that shape is already proven on this -//! LAN. What is new here is the product half: the holder registers Knot in a -//! resident catalog, and the visitor saves and then watches the holder's own -//! file become the truth both of them read. -//! -//! An example rather than a `[[bin]]` on purpose. Knot depends on graphshell -//! only as a dev-dependency, and examples get dev-dependencies where bins do -//! not, so this rehearsal costs the crate graph nothing. -//! -//! ```text -//! cargo run -p knot-editor --example k2_peer -- hold --root -//! cargo run -p knot-editor --example k2_peer -- visit --peer -//! cargo run -p knot-editor --example k2_peer -- visit --discover -//! -//! env: -//! K2_OWNER shared secret naming the owner that grants projections; -//! both devices set the same value -//! K2_SEED this device's identity seed; distinct per device -//! K2_NETWORK shared name for the network the policy governs -//! K2_PEER --discover only: the *other* device's K2_SEED, from which -//! its peer id is derived -//! ``` -//! -//! ## The rehearsal shortcut, stated plainly -//! -//! Both sides derive the owner keypair from `K2_OWNER`, so the visiting side -//! mints its own grant. A real deployment issues that certificate out of band -//! and the visitor never holds the owner key. What this proves is the -//! transport, admission, projection, save, and revision-bell path across two -//! machines; it proves nothing about how a grant is distributed. - -use std::path::PathBuf; -use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; - -use chirograph::{ - CapabilityProfile, IntentResult, PresentationCapability, ProjectionSession, SaveTextV1, - SessionStatus, -}; -use graphshell::admission::{CONNECT_ACTION, GRAPHSHELL_DOMAIN, PROJECTION_SERVICE, open_session}; -use graphshell::carrier::projection_policy; -use graphshell::client::{ResolvedContent, RetainedEndpointSession}; -use graphshell::native::endpoint_catalog::{ResidentEndpointCatalog, ResidentEndpointRoute}; -use graphshell::native::projection_host::ResidentProjectionHost; -use graphshell::network_carrier::{ - CarrierRuntime, NetworkCarrier, dial_projection_session, projection_binding, -}; -use notochord::{NetworkId, ProfileRef, TrustedRoot}; -use personae::delegation::{ - CapabilityScope, DelegationCertificate, DelegationParent, SignedDelegationCertificate, -}; -use personae::{IdentityProvider, InMemoryProvider}; -use transport::p2panda_transport::{MdnsDiscoveryMode, P2pandaTransport}; -use transport::{PeerID, Transport}; - -const ROOT_AUTHORITY: [u8; 32] = [7; 32]; -/// How long a dial waits for mDNS to name an address before giving up. -const DIAL_DEADLINE: Duration = Duration::from_secs(20); -/// The route the holder offers its vault on. -const ROUTE: &str = "knot"; - -fn env_hash(var: &str) -> Result<[u8; 32], String> { - let value = std::env::var(var).map_err(|_| format!("set {var} (any string)"))?; - Ok(*blake3::hash(value.as_bytes()).as_bytes()) -} - -fn now_ms() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("clock is after the epoch") - .as_millis() as u64 -} - -fn profile_ref() -> ProfileRef { - ProfileRef { - id: "mere.base".into(), - revision: 1, - } -} - -fn hex8(bytes: &[u8; 32]) -> String { - bytes.iter().take(4).map(|b| format!("{b:02x}")).collect() -} - -/// The grant the owner issues for projections on `network`. -/// -/// Back-dated a minute, with `not_before` matching `issued_at` because the rule -/// is `issued_at <= not_before`. The minute absorbs clock skew: these are two -/// machines and the responder judges validity by *its* clock, so a certificate -/// stamped "valid from now" by a visitor running slightly ahead is not yet -/// valid to the holder. -fn grant( - owner: &InMemoryProvider, - subject: [u8; 32], - network: NetworkId, - expires_at_ms: u64, -) -> SignedDelegationCertificate { - SignedDelegationCertificate::issue( - owner, - DelegationCertificate::new( - DelegationParent::Root(ROOT_AUTHORITY), - owner.master_public_key().to_bytes(), - subject, - CapabilityScope { - domain: GRAPHSHELL_DOMAIN.into(), - resource: network.0.to_vec(), - path_prefix: PROJECTION_SERVICE.into(), - actions: [CONNECT_ACTION.to_string()].into_iter().collect(), - }, - now_ms().saturating_sub(60_000), - now_ms().saturating_sub(60_000), - Some(expires_at_ms), - 1, - [11; 32], - ), - ) - .expect("issue certificate") -} - -/// The carrier and the Personae identity must be the same key. -/// -/// D6 requires the claimed subject to *be* the peer the carrier authenticated. -/// If these diverge the failure surfaces far away as `SessionProofInvalid` -/// during admission, so it is asserted where the cause is visible. -fn assert_same_key(carrier: &P2pandaTransport, me: &InMemoryProvider) -> Result<(), String> { - let carried = carrier.local_peer_id().to_bytes(); - let claimed = me.master_public_key().to_bytes(); - if carried != claimed { - return Err(format!( - "carrier identity {} is not the Personae identity {}; seed both from the same bytes", - hex8(&carried), - hex8(&claimed) - )); - } - Ok(()) -} - -fn viewing_profile() -> CapabilityProfile { - CapabilityProfile::new([ - PresentationCapability::EditableText, - PresentationCapability::PortableCard, - ]) -} - -/// Hold the vault: serve Knot projections to admitted visitors until stopped. -async fn hold( - owner: InMemoryProvider, - me: InMemoryProvider, - seed: [u8; 32], - network: NetworkId, - root: PathBuf, -) -> Result<(), String> { - if !root.is_dir() { - return Err(format!("{} is not a directory", root.display())); - } - let carrier = P2pandaTransport::builder_from_seed(seed) - .alpns(vec![graphshell::carrier::projection_alpn()]) - .mdns(MdnsDiscoveryMode::Active) - .bind() - .await - .map_err(|e| format!("bind: {e}"))?; - assert_same_key(&carrier, &me)?; - - let ticket = carrier.ticket().await.map_err(|e| format!("ticket: {e}"))?; - println!("k2_peer hold"); - println!(" vault: {}", root.display()); - println!(" ticket: {ticket}"); - println!(" run on the other device:"); - println!(" cargo run -p knot-editor --example k2_peer -- visit --peer {ticket}"); - - let policy = projection_policy( - network, - vec![TrustedRoot { - authority: ROOT_AUTHORITY, - issuer: owner.master_public_key().to_bytes(), - }], - vec![profile_ref()], - None, - ); - - // One route over one vault. Each admitted session opens its own endpoint, - // so two visitors hold live views of the same files and converge through - // the holder's own truth rather than through shared memory. - let mut catalog = ResidentEndpointCatalog::new(); - let vault = root.clone(); - catalog - .register_resumable_notifying(ROUTE, "Knot", move |_| { - // Resumable *and* notifying: a visitor recovering after a bell asks - // for a resume, and the typed silent registration would answer that - // refusal-shaped, which is what K2 found the hard way. - knot_editor::KnotEndpoint::open_writable( - &vault, - knot_editor::KnotWriteGrant::new(64 * 1024), - ) - .map_err(|error| error.to_string()) - }) - .map_err(|e| format!("register: {e}"))?; - let mut host = ResidentProjectionHost::new( - policy, - ResidentEndpointRoute::new(ROUTE, Duration::from_millis(250)) - .map_err(|e| format!("route: {e}"))?, - catalog, - ); - - for visit in 1.. { - println!(" waiting for visitor {visit}..."); - match host - .accept_one(&carrier, now_ms) - .await - .map_err(|e| format!("accept: {e}"))? - { - Ok(served) => println!( - " visitor {visit}: admitted {} ({} live)", - hex8(&served.subject()), - host.live_sessions() - ), - Err(refusal) => println!(" visitor {visit}: refused: {refusal:?}"), - } - // The served session runs in the background; the host goes straight - // back to accepting, which is what lets a second visitor in while the - // first still holds the document open. - } - Ok(()) -} - -/// How this run learned which peer to dial. -enum PeerSource { - /// A ticket carried by hand: id and address together, and the only form - /// that works off this LAN. - Ticket(String), - /// A peer id known in advance, with mDNS expected to resolve its address. - Discovered(PeerID), -} - -/// Visit the holder: mount its vault, read, save, and wait for the bell. -async fn visit( - owner: InMemoryProvider, - me: InMemoryProvider, - seed: [u8; 32], - network: NetworkId, - source: PeerSource, -) -> Result<(), String> { - let carrier = P2pandaTransport::builder_from_seed(seed) - .alpns(vec![graphshell::carrier::projection_alpn()]) - .mdns(MdnsDiscoveryMode::Active) - .bind() - .await - .map_err(|e| format!("bind: {e}"))?; - assert_same_key(&carrier, &me)?; - - println!("k2_peer visit"); - let peer = match source { - PeerSource::Ticket(ticket) => { - let peer = carrier - .add_peer_ticket(&ticket) - .await - .map_err(|e| format!("ticket: {e}"))?; - println!(" peer from ticket: {}", hex8(&peer.to_bytes())); - peer - } - PeerSource::Discovered(peer) => { - // Nothing is added to the address book. Asking for our own ticket - // forces the endpoint, and with it the mDNS actor, to start now - // rather than lazily on the first dial. - carrier.ticket().await.map_err(|e| format!("ticket: {e}"))?; - println!( - " no ticket, no add_peer; waiting for mDNS to resolve {}", - hex8(&peer.to_bytes()) - ); - peer - } - }; - - let subject = me.master_public_key().to_bytes(); - let hello = open_session( - &me, - network, - profile_ref(), - notochord::TrafficClass::Interactive, - session_nonce(seed), - &projection_binding(carrier.local_peer_id()), - vec![grant(&owner, subject, network, now_ms() + 3_600_000)], - ) - .map_err(|e| format!("hello: {e}"))?; - - // mDNS fills the address book asynchronously while p2panda reads it - // synchronously, so a ticketless dial races discovery. Retry rather than - // call a race an absent peer. - let started = Instant::now(); - let limits = Default::default(); - let stream = loop { - match dial_projection_session(&carrier, peer, &hello, &limits).await { - Ok(Ok(stream)) => break stream, - Ok(Err(reason)) => return Err(format!("the holder refused this visitor: {reason:?}")), - Err(error) => { - if started.elapsed() >= DIAL_DEADLINE { - return Err(format!("dial: {error}")); - } - tokio::time::sleep(Duration::from_millis(250)).await; - } - } - }; - println!(" admitted"); - - // The carrier blocks, so the whole visit runs on a thread that is not a - // runtime worker. The session never leaves it, which is what `Box` not being `Send` asks of every caller. - let handle = tokio::runtime::Handle::current(); - let visit_result = tokio::task::spawn_blocking(move || drive_visit(stream, handle)) - .await - .map_err(|e| format!("visit thread: {e}"))?; - let close_result = carrier.close().await; - match (visit_result, close_result) { - (Err(error), _) => Err(error), - (Ok(()), Err(error)) => Err(format!("transport close: {error}")), - (Ok(()), Ok(())) => Ok(()), - } -} - -/// A fresh nonce per session. -/// -/// The projection session id is a digest of the transcript and the transcript -/// carries this, so two sessions from one subject reusing a nonce land on the -/// same session id. Derived from the clock rather than an RNG because the -/// requirement is distinctness across this peer's own sessions, not secrecy, -/// and blake3 is already in this crate's graph. -fn session_nonce(seed: [u8; 32]) -> [u8; 32] { - let mut material = Vec::with_capacity(48); - material.extend_from_slice(&seed); - material.extend_from_slice(&now_ms().to_le_bytes()); - material.extend_from_slice(&std::process::id().to_le_bytes()); - *blake3::hash(&material).as_bytes() -} - -/// Mount, read, save, and wait for the holder to ring. -fn drive_visit(stream: S, handle: tokio::runtime::Handle) -> Result<(), String> -where - S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + 'static, -{ - let carrier = NetworkCarrier::over(stream, CarrierRuntime::borrowed(handle)); - let mut retained = RetainedEndpointSession::over(Box::new(carrier), viewing_profile()) - .map_err(|e| format!("discover: {e}"))?; - println!(" endpoint: {}", retained.descriptor().label); - let session = retained.mount(0).map_err(|e| format!("mount: {e}"))?; - - let (target, before, token, action) = read_document(&mut retained, &session)?; - println!(" opened {} bytes of source", before.len()); - - let stamp = now_ms(); - let edited = format!("{before}\nVisited at {stamp}.\n"); - let result = retained - .invoke( - &session, - target, - &action, - &SaveTextV1 { - base_token: token, - source: edited.clone(), - }, - ) - .map_err(|e| format!("save: {e}"))?; - match result { - IntentResult::Accepted => println!(" save accepted by the holder"), - other => return Err(format!("the holder refused the save: {other:?}")), - } - - // The holder wrote its own file; the bell says so and the ordinary resume - // path brings it back. This is the half a request/response loop cannot do. - println!(" waiting for the holder's revision bell..."); - match retained.wait_for_change() { - Ok(true) => println!(" bell heard, and it carried a revision we had not seen"), - Ok(false) => println!(" bell heard, already current"), - Err(error) => return Err(format!("bell: {error}")), - } - - let (_, after, _, _) = read_document(&mut retained, &session)?; - let status = retained - .client() - .mounted(&session) - .map(|scene| scene.status) - .unwrap_or(SessionStatus::Disconnected); - println!(" session status: {status:?}"); - if after == edited { - println!(" the holder's copy is what we wrote"); - } else { - println!(" NOTE: the holder's copy differs from what we sent"); - println!( - " sent {} bytes, read back {} bytes", - edited.len(), - after.len() - ); - } - - retained.close().map_err(|e| format!("close: {e}"))?; - println!(" closed"); - Ok(()) -} - -type Document = ( - sceno::InstanceId, - String, - Vec, - chirograph::AdvertisedAction, -); - -/// The first editable document the holder discloses, with the token that makes -/// a save revision-checked. -fn read_document( - retained: &mut RetainedEndpointSession, - session: &ProjectionSession, -) -> Result { - retained - .resolve_all(session) - .map_err(|e| format!("resolve: {e}"))? - .into_iter() - .find_map(|(target, presentation)| match presentation.content { - ResolvedContent::EditableText(editable) => presentation - .semantics - .actions - .first() - .cloned() - .map(|action| (target, editable.source, editable.base_token, action)), - _ => None, - }) - .ok_or_else(|| { - "the holder disclosed no editable document; put a .knot file in its vault".to_string() - }) -} - -fn usage() -> String { - "usage:\n k2_peer hold --root \n k2_peer visit --peer \n k2_peer visit --discover" - .to_string() -} - -#[tokio::main] -async fn main() -> Result<(), String> { - if std::env::var_os("RUST_LOG").is_some() { - // The discovery stack reports fatal conditions only through `tracing`: - // swarm-discovery tears its service down at `warn` with no error - // returned, which is how a peer sits there announcing nothing while - // still printing "waiting". `RUST_LOG=warn` makes that audible. - tracing_subscriber::fmt() - .with_writer(std::io::stderr) - .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) - .init(); - } - - let owner = InMemoryProvider::from_seed(env_hash("K2_OWNER")?); - let seed = env_hash("K2_SEED")?; - let me = InMemoryProvider::from_seed(seed); - let network = NetworkId(env_hash("K2_NETWORK")?); - - let args: Vec = std::env::args().skip(1).collect(); - let flag = |name: &str| -> Option { - args.iter() - .position(|a| a == name) - .and_then(|i| args.get(i + 1)) - .cloned() - }; - - match args.first().map(String::as_str) { - Some("hold") => { - let root = flag("--root").ok_or_else(|| format!("hold needs --root\n{}", usage()))?; - hold(owner, me, seed, network, PathBuf::from(root)).await - } - Some("visit") => { - let source = if let Some(ticket) = flag("--peer") { - PeerSource::Ticket(ticket) - } else if args.iter().any(|a| a == "--discover") { - let peer_seed = env_hash("K2_PEER")?; - let peer_key = InMemoryProvider::from_seed(peer_seed) - .master_public_key() - .to_bytes(); - PeerSource::Discovered( - PeerID::from_bytes(&peer_key).map_err(|e| format!("peer id: {e}"))?, - ) - } else { - return Err(format!("visit needs --peer or --discover\n{}", usage())); - }; - visit(owner, me, seed, network, source).await - } - _ => Err(usage()), - } -} diff --git a/ports/knot/examples/knot_publish_peer.rs b/ports/knot/examples/knot_publish_peer.rs deleted file mode 100644 index 3a7602670..000000000 --- a/ports/knot/examples/knot_publish_peer.rs +++ /dev/null @@ -1,352 +0,0 @@ -// Copyright 2026 Mark Alan Boykin -// 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 https://mozilla.org/MPL/2.0/. -// SPDX-License-Identifier: MPL-2.0 - -//! Manual two-process receipt runner for private Knot publishing. -//! -//! ```text -//! # On the reader device, make a public key from its private local seed: -//! KNOT_PUBLISH_READER_SEED=<64 hex> cargo run -p knot-editor --example knot_publish_peer -- reader-key -//! -//! # On the holder, issue a ticket only to that public key and print it: -//! KNOT_PUBLISH_HOLDER_SEED=<64 hex> KNOT_PUBLISH_READER_PUBLIC=<64 hex> \ -//! cargo run -p knot-editor --example knot_publish_peer -- hold -//! -//! # Paste the printed ticket on the reader. The reader never receives the -//! # holder's seed, vault key, paired-writer key, or sync store: -//! KNOT_PUBLISH_READER_SEED= \ -//! cargo run -p knot-editor --example knot_publish_peer -- visit -//! -//! # On the same LAN, prove mDNS discovery without adding the endpoint ticket -//! # to the address book. The ticket still supplies the holder identity and -//! # signed publication delegation, but never a dial address: -//! KNOT_PUBLISH_READER_SEED= \ -//! cargo run -p knot-editor --example knot_publish_peer -- visit-mdns -//! ``` -//! -//! `KNOT_PUBLISH_SOURCE` may set the holder's fixture source. The runner is a -//! receipt harness: it creates one in-memory retained source for its lifetime -//! and serves one request, rather than exposing a product UI or directory. - -use std::sync::Arc; -use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; - -use knot_editor::{ - KnotPublishCatalog, KnotPublishHost, KnotPublishHostLimits, KnotPublishRead, - KnotShareRecipient, KnotSyncEvent, KnotSyncStore, KnotVault, decode_share_ticket, - encode_share_ticket, fetch_published_document, publish_alpn, publish_policy, revoke_share, -}; -use notochord::{NetworkId, ProfileRef, TrustedRoot}; -use personae::{IdentityProvider, InMemoryProvider}; -use transport::p2panda_transport::{MdnsDiscoveryMode, P2pandaTransport}; -use transport::{PeerID, Transport}; - -const ROOT_AUTHORITY: [u8; 32] = [7; 32]; -/// Bounded time for mDNS to populate the ticket-bound holder's address. -const MDNS_DIAL_DEADLINE: Duration = Duration::from_secs(20); - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum PeerRoute { - /// Explicit endpoint information from the share ticket. This is the - /// off-LAN path and can also be used when discovery is unavailable. - Ticket, - /// mDNS names the known holder identity on the local network. No endpoint - /// information is read from the ticket, and this runner registers no - /// relay, so a successful path is direct LAN transport. - Mdns, -} - -#[tokio::main] -async fn main() { - if let Err(error) = run().await { - eprintln!("knot_publish_peer: {error}"); - std::process::exit(1); - } -} - -async fn run() -> Result<(), String> { - let mut args = std::env::args().skip(1); - match args.next().as_deref() { - Some("reader-key") => { - let reader = reader_identity()?; - println!( - "{}", - knot_editor::hex32(&reader.master_public_key().to_bytes()) - ); - Ok(()) - } - Some("hold") => hold(false).await, - Some("hold-revocation") => hold(true).await, - Some("visit") => { - let ticket = args - .next() - .ok_or_else(|| "visit needs the ticket printed by hold".to_string())?; - visit(&ticket, PeerRoute::Ticket).await - } - Some("visit-mdns") => { - let ticket = args - .next() - .ok_or_else(|| "visit-mdns needs the ticket printed by hold".to_string())?; - visit(&ticket, PeerRoute::Mdns).await - } - _ => Err( - "use reader-key, hold, hold-revocation, visit , or visit-mdns ".into(), - ), - } -} - -async fn hold(revoke_after_first_fetch: bool) -> Result<(), String> { - let seed = env_key("KNOT_PUBLISH_HOLDER_SEED")?; - let reader = env_key("KNOT_PUBLISH_READER_PUBLIC")?; - let holder = InMemoryProvider::from_seed(seed); - let network = network()?; - let carrier = P2pandaTransport::builder_from_seed(seed) - .alpns(vec![publish_alpn()]) - .mdns(MdnsDiscoveryMode::Active) - .bind() - .await - .map_err(|error| format!("bind holder carrier: {error}"))?; - if carrier.local_peer_id().to_bytes() != holder.master_public_key().to_bytes() { - return Err("holder carrier and Personae identity differ".into()); - } - - let source = - std::env::var("KNOT_PUBLISH_SOURCE").unwrap_or_else(|_| "# Shared privately\n".into()); - let vault_root = tempfile::tempdir().map_err(|error| format!("fixture vault: {error}"))?; - let vault = Arc::new( - KnotVault::open(vault_root.path(), [0x44; 32]) - .map_err(|error| format!("fixture vault: {error}"))?, - ); - let store = KnotSyncStore::in_memory(network.0, [holder.master_public_key().to_bytes()]); - store - .author( - holder.master_keypair().to_seed(), - &vault, - &KnotSyncEvent::Put(knot_editor::VaultDocument { - id: "receipt-source".into(), - title: "Private receipt source".into(), - body: source.into_bytes(), - media_type: "text/vnd.knot".into(), - }), - ) - .await - .map_err(|error| format!("author source: {error}"))?; - let mut catalog = KnotPublishCatalog::default(); - let publication = catalog.publish("receipt-source"); - let issued_at = now_ms().saturating_sub(60_000); - let ticket = catalog - .issue_share( - &holder, - KnotShareRecipient { - publication, - publisher: holder.master_public_key().to_bytes(), - reader, - network, - endpoint_ticket: carrier - .ticket() - .await - .map_err(|error| format!("holder ticket: {error}"))?, - root_authority: ROOT_AUTHORITY, - issued_at_ms: issued_at, - expires_at_ms: Some(issued_at.saturating_add(3_600_000)), - pinned_head: None, - }, - ) - .map_err(|error| format!("issue reader share: {error}"))?; - let encoded_ticket = - encode_share_ticket(&ticket).map_err(|error| format!("encode share ticket: {error}"))?; - let policy = publish_policy( - network, - vec![TrustedRoot { - authority: ROOT_AUTHORITY, - issuer: holder.master_public_key().to_bytes(), - }], - vec![profile_ref()], - Some(1), - ); - let host = KnotPublishHost::new( - holder.master_keypair().clone(), - policy, - store, - vault, - catalog, - KnotPublishHostLimits { - max_concurrent_sessions: 1, - ..KnotPublishHostLimits::default() - }, - ); - - println!("knot_publish_peer hold"); - println!(" publication: {}", publication.as_uuid()); - println!(" ticket: {encoded_ticket}"); - println!( - " same LAN (preferred): cargo run -p knot-editor --example knot_publish_peer -- visit-mdns " - ); - println!( - " ticket endpoint fallback: cargo run -p knot-editor --example knot_publish_peer -- visit " - ); - println!(" waiting for one distinct reader identity..."); - let outcome = host - .accept_and_serve(&carrier) - .await - .map_err(|error| format!("serve: {error}"))?; - println!(" holder outcome: {outcome:?}"); - if revoke_after_first_fetch { - let revocation = revoke_share(&holder, &ticket, now_ms()) - .map_err(|error| format!("issue revocation: {error}"))?; - if !host.revocations().write().await.fold(&revocation) { - return Err("holder could not fold its own signed revocation".into()); - } - println!(" reader delegation revoked; retry the same ticket for the refusal receipt..."); - let outcome = host - .accept_and_serve(&carrier) - .await - .map_err(|error| format!("serve revoked reader: {error}"))?; - println!(" holder post-revocation outcome: {outcome:?}"); - } - Ok(()) -} - -async fn visit(encoded_ticket: &str, route: PeerRoute) -> Result<(), String> { - let reader_seed = env_key("KNOT_PUBLISH_READER_SEED")?; - let reader = InMemoryProvider::from_seed(reader_seed); - let ticket = - decode_share_ticket(encoded_ticket).map_err(|error| format!("share ticket: {error}"))?; - let carrier = P2pandaTransport::builder_from_seed(reader_seed) - .alpns(vec![publish_alpn()]) - .mdns(MdnsDiscoveryMode::Active) - .bind() - .await - .map_err(|error| format!("bind reader carrier: {error}"))?; - if carrier.local_peer_id().to_bytes() != reader.master_public_key().to_bytes() { - return Err("reader carrier and Personae identity differ".into()); - } - let peer = PeerID::from_bytes(&ticket.publisher) - .map_err(|error| format!("ticket publisher identity: {error}"))?; - if route == PeerRoute::Ticket { - let registered = carrier - .add_peer_ticket(&ticket.endpoint_ticket) - .await - .map_err(|error| format!("add holder ticket: {error}"))?; - if registered != peer { - return Err("endpoint ticket identity does not match the share ticket".into()); - } - } else { - // mDNS starts asynchronously. Force the endpoint now, then retry the - // identity-only dial while the discovery actor fills its address book. - // The share ticket is still required below for Notochord delegation; - // it simply contributes no carrier address in this branch. - carrier - .ticket() - .await - .map_err(|error| format!("start local discovery: {error}"))?; - println!( - " waiting for mDNS to resolve holder {}", - short(&ticket.publisher) - ); - } - let read = match route { - PeerRoute::Ticket => { - fetch_published_document(&carrier, reader.master_keypair(), profile_ref(), &ticket) - .await - .map_err(|error| format!("read holder from ticket: {error}"))? - } - PeerRoute::Mdns => { - let started = Instant::now(); - loop { - match fetch_published_document( - &carrier, - reader.master_keypair(), - profile_ref(), - &ticket, - ) - .await - { - Ok(read) => break read, - Err(error) - if error.allows_endpoint_fallback() - && started.elapsed() < MDNS_DIAL_DEADLINE => - { - tokio::time::sleep(Duration::from_millis(250)).await; - } - Err(error) if error.allows_endpoint_fallback() => { - return Err(format!( - "mDNS did not resolve the holder before {} seconds: {error}", - MDNS_DIAL_DEADLINE.as_secs() - )); - } - Err(error) => return Err(format!("read holder over mDNS: {error}")), - } - } - } - }; - let KnotPublishRead::Document(document) = read else { - return Err("holder made the selected publication unavailable".into()); - }; - if !ticket.accepts(&document) { - return Err( - "response did not satisfy the ticket's publication, pin, and digest checks".into(), - ); - } - println!("knot_publish_peer visit"); - println!( - " route: {}", - match route { - PeerRoute::Ticket => "ticket endpoint", - PeerRoute::Mdns => "mDNS direct LAN", - } - ); - println!(" holder: {}", short(&ticket.publisher)); - println!(" publication: {}", document.publication.as_uuid()); - println!(" head: {}", short(&document.operation)); - println!(" digest: {}", short(&document.body_digest)); - println!(" media type: {}", document.media_type); - println!(" bytes: {}", document.body.len()); - Ok(()) -} - -fn reader_identity() -> Result { - Ok(InMemoryProvider::from_seed(env_key( - "KNOT_PUBLISH_READER_SEED", - )?)) -} - -fn env_key(name: &str) -> Result<[u8; 32], String> { - let value = std::env::var(name).map_err(|_| format!("set {name} to 64 hex characters"))?; - knot_editor::parse_hex32(&value).map_err(|error| error.to_string()) -} - -fn network() -> Result { - let label = - std::env::var("KNOT_PUBLISH_NETWORK").unwrap_or_else(|_| "knot-publish-receipt".into()); - if label.is_empty() { - return Err("KNOT_PUBLISH_NETWORK must not be empty".into()); - } - Ok(NetworkId(*blake3::hash(label.as_bytes()).as_bytes())) -} - -fn profile_ref() -> ProfileRef { - ProfileRef { - id: "mere.base".into(), - revision: 1, - } -} - -fn now_ms() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_millis() - .try_into() - .unwrap_or(u64::MAX) -} - -fn short(bytes: &[u8; 32]) -> String { - bytes - .iter() - .take(4) - .map(|byte| format!("{byte:02x}")) - .collect() -} diff --git a/ports/knot/src/authority.rs b/ports/knot/src/authority.rs deleted file mode 100644 index 75ced8314..000000000 --- a/ports/knot/src/authority.rs +++ /dev/null @@ -1,272 +0,0 @@ -// Copyright 2026 Mark Alan Boykin -// 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 https://mozilla.org/MPL/2.0/. -// SPDX-License-Identifier: MPL-2.0 - -//! One revisioned materialization of authority and routes for a Knot space. - -use std::collections::{BTreeMap, BTreeSet}; - -use servitor::cap::{Cap, Mode}; -use servitor::{AuthorityProvider, Subject}; - -use crate::{KnotSettingsError, KnotSyncSettings}; - -/// Durable source whose facts produced a space-authority snapshot. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum KnotAuthoritySource { - /// Personae device pairing for a personal vault. - PersonalPairing, - /// Gemot constitution and delegation facts for a communal space. - GemotCapabilities, -} - -/// One immutable, revisioned authority view consumed by operation admission, -/// evidence serving, evidence fetching, and route refresh. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct KnotSpaceAuthoritySnapshot { - source: KnotAuthoritySource, - revision: [u8; 32], - writers: BTreeSet<[u8; 32]>, - evidence_readers: BTreeSet<[u8; 32]>, - evidence_sources: BTreeSet<[u8; 32]>, - route_hints: BTreeMap<[u8; 32], String>, -} - -impl Default for KnotSpaceAuthoritySnapshot { - fn default() -> Self { - Self::new(KnotAuthoritySource::PersonalPairing, [], [], [], []) - } -} - -impl KnotSpaceAuthoritySnapshot { - /// Build a canonical snapshot. Collection order does not affect revision. - pub fn new( - source: KnotAuthoritySource, - writers: impl IntoIterator, - evidence_readers: impl IntoIterator, - evidence_sources: impl IntoIterator, - route_hints: impl IntoIterator, - ) -> Self { - let writers = writers.into_iter().collect::>(); - let evidence_readers = evidence_readers.into_iter().collect::>(); - let evidence_sources = evidence_sources.into_iter().collect::>(); - let route_hints = route_hints.into_iter().collect::>(); - let revision = authority_revision( - source, - &writers, - &evidence_readers, - &evidence_sources, - &route_hints, - ); - Self { - source, - revision, - writers, - evidence_readers, - evidence_sources, - route_hints, - } - } - - /// Materialize personal authority and cached routes from Personae pairing. - pub fn from_personal_settings(settings: &KnotSyncSettings) -> Result { - let writers = settings.paired_writer_keys()?; - let route_hints = writers - .iter() - .filter_map(|writer| { - settings - .endpoint_for(writer) - .map(|ticket| (*writer, ticket.to_string())) - }) - .collect::>(); - Ok(Self::new( - KnotAuthoritySource::PersonalPairing, - writers.iter().copied(), - writers.iter().copied(), - writers.iter().copied(), - route_hints, - )) - } - - /// Materialize independent communal rights from Gemot authority facts. - pub fn from_gemot_authority( - authority: &impl AuthorityProvider, - space_id: [u8; 32], - candidates: impl IntoIterator, - route_hints: impl IntoIterator, - ) -> Result { - let scope = format!("knot/{}", crate::hex32(&space_id)); - let document = Cap::scope(&format!("{scope}/document")) - .map_err(|error| format!("invalid Knot document capability: {error}"))?; - let evidence_read = Cap::scope(&format!("{scope}/evidence/read")) - .map_err(|error| format!("invalid Knot evidence-read capability: {error}"))?; - let evidence_source = Cap::scope(&format!("{scope}/evidence/source")) - .map_err(|error| format!("invalid Knot evidence-source capability: {error}"))?; - let candidates = candidates.into_iter().collect::>(); - let writers = candidates - .iter() - .copied() - .filter(|peer| authority.covers(Subject(*peer), &document, Mode::Write)); - let evidence_readers = candidates - .iter() - .copied() - .filter(|peer| authority.covers(Subject(*peer), &evidence_read, Mode::Read)); - let evidence_sources = candidates - .iter() - .copied() - .filter(|peer| authority.covers(Subject(*peer), &evidence_source, Mode::Write)); - Ok(Self::new( - KnotAuthoritySource::GemotCapabilities, - writers, - evidence_readers, - evidence_sources, - route_hints, - )) - } - - /// Authority source, used to reject cross-domain materializations. - pub const fn source(&self) -> KnotAuthoritySource { - self.source - } - - /// Content-derived revision of every set and route in this view. - pub const fn revision(&self) -> [u8; 32] { - self.revision - } - - /// Peers allowed to contribute document operations. - pub fn writers(&self) -> impl Iterator + '_ { - self.writers.iter().copied() - } - - /// Peers allowed to read retained evidence from this space. - pub fn evidence_readers(&self) -> impl Iterator + '_ { - self.evidence_readers.iter().copied() - } - - /// Peers this device may fetch evidence from. - pub fn evidence_sources(&self) -> impl Iterator + '_ { - self.evidence_sources.iter().copied() - } - - /// Cached routes keyed by authenticated peer identity. - pub fn route_hints(&self) -> impl Iterator { - self.route_hints - .iter() - .map(|(peer, ticket)| (peer, ticket.as_str())) - } - - /// Cached route for one authenticated peer. - pub fn route_hint(&self, peer: &[u8; 32]) -> Option<&str> { - self.route_hints.get(peer).map(String::as_str) - } -} - -fn authority_revision( - source: KnotAuthoritySource, - writers: &BTreeSet<[u8; 32]>, - evidence_readers: &BTreeSet<[u8; 32]>, - evidence_sources: &BTreeSet<[u8; 32]>, - route_hints: &BTreeMap<[u8; 32], String>, -) -> [u8; 32] { - let mut hasher = blake3::Hasher::new(); - hasher.update(b"knot.space-authority/v1\0"); - hasher.update(&[match source { - KnotAuthoritySource::PersonalPairing => 1, - KnotAuthoritySource::GemotCapabilities => 2, - }]); - hash_peers(&mut hasher, b"writers", writers); - hash_peers(&mut hasher, b"evidence-readers", evidence_readers); - hash_peers(&mut hasher, b"evidence-sources", evidence_sources); - hasher.update(b"route-hints"); - hasher.update(&(route_hints.len() as u64).to_be_bytes()); - for (peer, ticket) in route_hints { - hasher.update(peer); - hasher.update(&(ticket.len() as u64).to_be_bytes()); - hasher.update(ticket.as_bytes()); - } - *hasher.finalize().as_bytes() -} - -fn hash_peers(hasher: &mut blake3::Hasher, label: &[u8], peers: &BTreeSet<[u8; 32]>) { - hasher.update(label); - hasher.update(&(peers.len() as u64).to_be_bytes()); - for peer in peers { - hasher.update(peer); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn revision_covers_each_independent_dimension_but_not_input_order() { - let base = KnotSpaceAuthoritySnapshot::new( - KnotAuthoritySource::GemotCapabilities, - [[1; 32], [2; 32]], - [[3; 32]], - [[4; 32]], - [([4; 32], "route-a".into())], - ); - let reordered = KnotSpaceAuthoritySnapshot::new( - KnotAuthoritySource::GemotCapabilities, - [[2; 32], [1; 32]], - [[3; 32]], - [[4; 32]], - [([4; 32], "route-a".into())], - ); - assert_eq!(base.revision(), reordered.revision()); - - let changed_reader = KnotSpaceAuthoritySnapshot::new( - KnotAuthoritySource::GemotCapabilities, - [[1; 32], [2; 32]], - [[5; 32]], - [[4; 32]], - [([4; 32], "route-a".into())], - ); - let changed_route = KnotSpaceAuthoritySnapshot::new( - KnotAuthoritySource::GemotCapabilities, - [[1; 32], [2; 32]], - [[3; 32]], - [[4; 32]], - [([4; 32], "route-b".into())], - ); - assert_ne!(base.revision(), changed_reader.revision()); - assert_ne!(base.revision(), changed_route.revision()); - } - - #[test] - fn losing_a_route_changes_reachability_materialization_not_rights() { - let routed = KnotSpaceAuthoritySnapshot::new( - KnotAuthoritySource::GemotCapabilities, - [[1; 32]], - [[2; 32]], - [[3; 32]], - [([3; 32], "route".into())], - ); - let route_lost = KnotSpaceAuthoritySnapshot::new( - KnotAuthoritySource::GemotCapabilities, - [[1; 32]], - [[2; 32]], - [[3; 32]], - [], - ); - - assert_eq!( - routed.writers().collect::>(), - route_lost.writers().collect::>() - ); - assert_eq!( - routed.evidence_readers().collect::>(), - route_lost.evidence_readers().collect::>() - ); - assert_eq!( - routed.evidence_sources().collect::>(), - route_lost.evidence_sources().collect::>() - ); - assert_ne!(routed.revision(), route_lost.revision()); - } -} diff --git a/ports/knot/src/bin/knot_endpoint.rs b/ports/knot/src/bin/knot_endpoint.rs deleted file mode 100644 index c7c313414..000000000 --- a/ports/knot/src/bin/knot_endpoint.rs +++ /dev/null @@ -1,738 +0,0 @@ -// Copyright 2026 Mark Alan Boykin -// 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 https://mozilla.org/MPL/2.0/. -// SPDX-License-Identifier: MPL-2.0 - -use std::ffi::OsStr; -use std::fs; -use std::path::{Path, PathBuf}; -use std::time::Duration; - -use p2panda_core::SigningKey; -use stickleback::DataKeyring; - -fn main() { - let args = std::env::args_os().skip(1).collect::>(); - let mut endpoint = match args.as_slice() { - [] => knot_editor::KnotEndpoint::fixture(), - [root] => knot_editor::KnotEndpoint::open(PathBuf::from(root)) - .expect("Knot could not open the requested directory"), - [mode, root] if mode == "directory" => knot_editor::KnotEndpoint::open(PathBuf::from(root)) - .expect("Knot could not open the requested directory"), - [mode, root, max_source_bytes] if mode == "directory-write" => { - let max_source_bytes = max_source_bytes - .to_string_lossy() - .parse::() - .expect("directory-write byte limit must be an integer"); - knot_editor::KnotEndpoint::open_writable( - PathBuf::from(root), - knot_editor::KnotWriteGrant::new(max_source_bytes), - ) - .expect("Knot could not open the requested writable directory") - } - [ - mode, - root, - max_source_bytes, - evidence_root, - max_evidence_bytes, - ] if mode == "directory-write-evidence" => { - let mut endpoint = knot_editor::KnotEndpoint::open_writable( - PathBuf::from(root), - knot_editor::KnotWriteGrant::new(parse_u64( - max_source_bytes, - "directory-write byte limit", - )), - ) - .expect("Knot could not open the requested writable directory"); - endpoint.grant_clip_evidence( - knot_editor::BlobClipEvidenceStore::open( - PathBuf::from(evidence_root), - parse_u64(max_evidence_bytes, "clip evidence byte limit"), - ) - .expect("Knot could not open the clip evidence blob store"), - ); - endpoint - } - [ - mode, - root, - max_source_bytes, - resolve, - run, - schemes, - languages, - max_depth, - max_ops, - ] if mode == "directory-write-effects" => { - let root = PathBuf::from(root); - let mut endpoint = knot_editor::KnotEndpoint::open_writable( - &root, - knot_editor::KnotWriteGrant::new(parse_u64( - max_source_bytes, - "directory-write byte limit", - )), - ) - .expect("Knot could not open the requested writable directory"); - endpoint.grant_effects(effect_authority( - resolve, - run, - schemes, - languages, - max_depth, - max_ops, - max_source_bytes, - Some(&root), - )); - endpoint - } - [ - mode, - root, - max_source_bytes, - resolve, - run, - schemes, - languages, - max_depth, - max_ops, - evidence_root, - max_evidence_bytes, - ] if mode == "directory-write-effects-evidence" => { - let root = PathBuf::from(root); - let mut endpoint = knot_editor::KnotEndpoint::open_writable( - &root, - knot_editor::KnotWriteGrant::new(parse_u64( - max_source_bytes, - "directory-write byte limit", - )), - ) - .expect("Knot could not open the requested writable directory"); - endpoint.grant_effects(effect_authority( - resolve, - run, - schemes, - languages, - max_depth, - max_ops, - max_source_bytes, - Some(&root), - )); - endpoint.grant_clip_evidence( - knot_editor::BlobClipEvidenceStore::open( - PathBuf::from(evidence_root), - parse_u64(max_evidence_bytes, "clip evidence byte limit"), - ) - .expect("Knot could not open the clip evidence blob store"), - ); - endpoint - } - [mode, data_root, persona, max_source_bytes] if mode == "persona-vault" => { - let persona = persona - .to_string_lossy() - .parse::() - .map(personae::PersonaId::from_uuid) - .expect("persona-vault persona must be a UUID"); - let max_source_bytes = max_source_bytes - .to_string_lossy() - .parse::() - .expect("persona-vault byte limit must be an integer"); - knot_editor::StartupUnlockedPersonalVault::open( - PathBuf::from(data_root), - persona, - knot_editor::local_device_root(Path::new(data_root), "knot") - .expect("Knot could not open this device identity"), - [], - ) - .and_then(|authority| { - authority.into_endpoint(knot_editor::KnotWriteGrant::new(max_source_bytes)) - }) - .expect("Knot could not startup-unlock the requested persona vault") - } - [ - mode, - data_root, - persona, - max_source_bytes, - evidence_root, - max_evidence_bytes, - ] if mode == "persona-vault-evidence" => { - let persona = persona - .to_string_lossy() - .parse::() - .map(personae::PersonaId::from_uuid) - .expect("persona-vault persona must be a UUID"); - let mut endpoint = knot_editor::StartupUnlockedPersonalVault::open( - PathBuf::from(data_root), - persona, - knot_editor::local_device_root(Path::new(data_root), "knot") - .expect("Knot could not open this device identity"), - [], - ) - .and_then(|authority| { - authority.into_endpoint(knot_editor::KnotWriteGrant::new(parse_u64( - max_source_bytes, - "persona-vault byte limit", - ))) - }) - .expect("Knot could not startup-unlock the requested persona vault"); - endpoint.grant_clip_evidence( - knot_editor::BlobClipEvidenceStore::open( - PathBuf::from(evidence_root), - parse_u64(max_evidence_bytes, "clip evidence byte limit"), - ) - .expect("Knot could not open the clip evidence blob store"), - ); - endpoint - } - [ - mode, - data_root, - persona, - max_source_bytes, - resolve, - run, - schemes, - languages, - max_depth, - max_ops, - ] if mode == "persona-vault-effects" => { - let persona = persona - .to_string_lossy() - .parse::() - .map(personae::PersonaId::from_uuid) - .expect("persona-vault persona must be a UUID"); - let mut endpoint = knot_editor::StartupUnlockedPersonalVault::open( - PathBuf::from(data_root), - persona, - knot_editor::local_device_root(Path::new(data_root), "knot") - .expect("Knot could not open this device identity"), - [], - ) - .and_then(|authority| { - authority.into_endpoint(knot_editor::KnotWriteGrant::new(parse_u64( - max_source_bytes, - "persona-vault byte limit", - ))) - }) - .expect("Knot could not startup-unlock the requested persona vault"); - endpoint.grant_effects(effect_authority( - resolve, - run, - schemes, - languages, - max_depth, - max_ops, - max_source_bytes, - None, - )); - endpoint - } - [ - mode, - data_root, - persona, - max_source_bytes, - resolve, - run, - schemes, - languages, - max_depth, - max_ops, - evidence_root, - max_evidence_bytes, - ] if mode == "persona-vault-effects-evidence" => { - let persona = persona - .to_string_lossy() - .parse::() - .map(personae::PersonaId::from_uuid) - .expect("persona-vault persona must be a UUID"); - let mut endpoint = knot_editor::StartupUnlockedPersonalVault::open( - PathBuf::from(data_root), - persona, - knot_editor::local_device_root(Path::new(data_root), "knot") - .expect("Knot could not open this device identity"), - [], - ) - .and_then(|authority| { - authority.into_endpoint(knot_editor::KnotWriteGrant::new(parse_u64( - max_source_bytes, - "persona-vault byte limit", - ))) - }) - .expect("Knot could not startup-unlock the requested persona vault"); - endpoint.grant_effects(effect_authority( - resolve, - run, - schemes, - languages, - max_depth, - max_ops, - max_source_bytes, - None, - )); - endpoint.grant_clip_evidence( - knot_editor::BlobClipEvidenceStore::open( - PathBuf::from(evidence_root), - parse_u64(max_evidence_bytes, "clip evidence byte limit"), - ) - .expect("Knot could not open the clip evidence blob store"), - ); - endpoint - } - [ - mode, - root, - max_source_bytes, - resolve, - run, - schemes, - languages, - max_depth, - max_ops, - ] if mode == "communal-fixture-effects" => communal_fixture_endpoint( - PathBuf::from(root), - parse_u64(max_source_bytes, "communal fixture byte limit"), - effect_authority( - resolve, - run, - schemes, - languages, - max_depth, - max_ops, - max_source_bytes, - None, - ), - ), - _ => panic!( - "usage: knot_endpoint [directory] | directory | \ - directory-write | \ - directory-write-evidence | \ - directory-write-effects \ - | \ - directory-write-effects-evidence \ - \ - | \ - persona-vault | \ - persona-vault-evidence \ - | \ - persona-vault-effects \ - | \ - persona-vault-effects-evidence \ - \ - | \ - communal-fixture-effects \ - " - ), - }; - graphshell_stdio::serve_resumable_notifying( - &mut endpoint, - std::io::stdin(), - std::io::stdout().lock(), - Duration::from_millis(100), - ) - .expect("Knot Graphshell endpoint failed"); -} - -/// Process-only received-content fixture. Its keys are minted inside the -/// endpoint process and the root is caller-owned scratch storage, so this mode -/// does not turn group keys into CLI or environment authority. -fn communal_fixture_endpoint( - root: PathBuf, - max_source_bytes: u64, - effects: knot_editor::KnotEffectAuthority, -) -> knot_editor::KnotEndpoint { - const SPACE: [u8; 32] = [0xC1; 32]; - const RECEIVED_SEED: [u8; 32] = [0xC2; 32]; - const LOCAL_SEED: [u8; 32] = [0xC3; 32]; - const VAULT_KEY: [u8; 32] = [0xC4; 32]; - - fs::create_dir_all(&root).expect("could not create communal fixture root"); - let received_writer = *SigningKey::from_bytes(&RECEIVED_SEED) - .verifying_key() - .as_bytes(); - let local_writer = *SigningKey::from_bytes(&LOCAL_SEED) - .verifying_key() - .as_bytes(); - let store = knot_editor::KnotSyncFileStore::open_commons( - root.join("commons.redb"), - SPACE, - [received_writer, local_writer], - ) - .expect("could not open communal fixture sync store"); - let mut keys = DataKeyring::new(); - keys.rotate_random() - .expect("could not mint communal fixture data epoch"); - let source = "\ -# Received calculation - -```rhai eval -40 + 2 -``` -"; - pollster::block_on(store.author_communal( - RECEIVED_SEED, - &keys, - &knot_editor::KnotSyncEvent::Put(knot_editor::VaultDocument { - id: "received".into(), - title: "Received calculation".into(), - body: source.as_bytes().to_vec(), - media_type: "text/vnd.knot".into(), - }), - )) - .expect("could not author received communal fixture document"); - let vault = knot_editor::KnotVault::open(root.join("vault"), VAULT_KEY) - .expect("could not open fixture vault"); - let mut endpoint = knot_editor::KnotEndpoint::from_communal_vault( - vault, - store, - LOCAL_SEED, - keys, - knot_editor::KnotWriteGrant::new(max_source_bytes), - ) - .expect("could not open communal fixture endpoint"); - endpoint.grant_effects(effects); - endpoint -} - -fn parse_u64(value: &OsStr, label: &str) -> u64 { - value - .to_string_lossy() - .parse() - .unwrap_or_else(|_| panic!("{label} must be an integer")) -} - -fn parse_effect_mode(value: &OsStr) -> knot_editor::KnotEffectMode { - match value.to_string_lossy().as_ref() { - "auto" => knot_editor::KnotEffectMode::Auto, - "ask" => knot_editor::KnotEffectMode::Ask, - "never" => knot_editor::KnotEffectMode::Never, - other => panic!("effect mode must be auto, ask, or never; got {other}"), - } -} - -fn parse_csv(value: &OsStr) -> Vec { - value - .to_string_lossy() - .split(',') - .map(str::trim) - .filter(|item| !item.is_empty()) - .map(str::to_ascii_lowercase) - .collect() -} - -#[allow(clippy::too_many_arguments)] -fn effect_authority( - resolve: &OsStr, - run: &OsStr, - schemes: &OsStr, - languages: &OsStr, - max_depth: &OsStr, - max_ops: &OsStr, - max_fetch_bytes: &OsStr, - file_root: Option<&Path>, -) -> knot_editor::KnotEffectAuthority { - let policy = knot_editor::KnotEffectPolicy { - resolve: parse_effect_mode(resolve), - run: parse_effect_mode(run), - allowed_schemes: parse_csv(schemes), - allowed_languages: parse_csv(languages), - max_depth: parse_u64(max_depth, "effect max depth") - .try_into() - .expect("effect max depth must fit in u8"), - max_ops: parse_u64(max_ops, "effect operation limit"), - }; - assert!( - !policy - .allowed_schemes - .iter() - .any(|scheme| scheme == "titan"), - "Titan is an upload protocol and cannot be admitted as a Knot read effect", - ); - let has_file = policy.allowed_schemes.iter().any(|scheme| scheme == "file"); - let has_network = policy - .allowed_schemes - .iter() - .any(|scheme| is_read_network_scheme(scheme)); - let has_rhai = policy - .allowed_languages - .iter() - .any(|language| language == "rhai"); - let mut authority = knot_editor::KnotEffectAuthority::new(policy); - if has_file || has_network { - let file = has_file.then(|| { - RootedFileFetcher::new( - file_root.expect("file effects require a directory-root endpoint"), - ) - .expect("could not admit effect file root") - }); - let network = has_network.then(|| { - NetworkEffectFetcher::new( - parse_u64(max_fetch_bytes, "effect fetch byte limit") - .try_into() - .expect("effect fetch byte limit must fit in usize"), - ) - .expect("could not start Knot network effect provider") - }); - authority = authority.with_fetcher(RoutedEffectFetcher { file, network }); - } - if has_rhai { - authority = authority.register_evaluator(script_rhai::RhaiEvaluator::new()); - } - authority -} - -fn is_read_network_scheme(scheme: &str) -> bool { - matches!( - scheme, - "http" | "https" | "gemini" | "gopher" | "finger" | "spartan" | "nex" | "guppy" - ) -} - -struct RoutedEffectFetcher { - file: Option, - network: Option, -} - -impl knot_editor::KnotEffectFetcher for RoutedEffectFetcher { - fn fetch(&mut self, address: &str) -> Result { - let scheme = url::Url::parse(address) - .map_err(|error| format!("could not parse effect address {address}: {error}"))? - .scheme() - .to_ascii_lowercase(); - match scheme.as_str() { - "file" => self - .file - .as_mut() - .ok_or_else(|| "Knot has no file effect provider".to_string())? - .fetch(address), - scheme if is_read_network_scheme(scheme) => self - .network - .as_mut() - .ok_or_else(|| format!("Knot has no {scheme} effect provider"))? - .fetch(address), - _ => Err(format!("Knot has no fetch provider for {address}")), - } - } - - fn cache_version(&self) -> String { - let network = self - .network - .as_ref() - .map(knot_editor::KnotEffectFetcher::cache_version) - .unwrap_or_else(|| "none".into()); - format!( - "knot.routed-effect-fetcher/v1;file={};network={network}", - self.file.is_some() - ) - } -} - -struct NetworkEffectFetcher { - runtime: tokio::runtime::Runtime, - max_bytes: usize, -} - -impl NetworkEffectFetcher { - fn new(max_bytes: usize) -> Result { - fetch::install_in_memory_smolweb_tofu(); - let runtime = tokio::runtime::Builder::new_multi_thread() - .enable_all() - .build() - .map_err(|error| format!("could not build effect fetch runtime: {error}"))?; - Ok(Self { runtime, max_bytes }) - } -} - -impl knot_editor::KnotEffectFetcher for NetworkEffectFetcher { - fn fetch(&mut self, address: &str) -> Result { - let fetched = self - .runtime - .block_on(fetch::fetch_page_anonymous_capped(address, self.max_bytes))?; - Ok(inker::Fetched { - content_type: fetched.content_type, - body: fetched.body, - }) - } - - fn cache_version(&self) -> String { - format!( - "knot.anonymous-network-effect-fetcher/v1;max-bytes={}", - self.max_bytes - ) - } -} - -struct RootedFileFetcher { - root: PathBuf, -} - -impl RootedFileFetcher { - fn new(root: &Path) -> Result { - Ok(Self { - root: fs::canonicalize(root) - .map_err(|error| format!("could not canonicalize file effect root: {error}"))?, - }) - } -} - -impl knot_editor::KnotEffectFetcher for RootedFileFetcher { - fn fetch(&mut self, address: &str) -> Result { - let url = url::Url::parse(address) - .map_err(|error| format!("could not parse effect address {address}: {error}"))?; - if url.scheme() != "file" { - return Err(format!("Knot has no fetch provider for {address}")); - } - let path = url - .to_file_path() - .map_err(|_| format!("could not map {address} to a local file"))?; - let path = fs::canonicalize(&path) - .map_err(|error| format!("could not resolve {}: {error}", path.display()))?; - if !path.starts_with(&self.root) { - return Err(format!( - "{} is outside the admitted Knot directory", - path.display() - )); - } - let body = fs::read_to_string(&path) - .map_err(|error| format!("could not read {}: {error}", path.display()))?; - Ok(inker::Fetched { - content_type: content_type(&path), - body, - }) - } -} - -fn content_type(path: &Path) -> Option { - match path - .extension() - .and_then(OsStr::to_str) - .map(str::to_ascii_lowercase) - .as_deref() - { - Some("gmi" | "gemini") => Some("text/gemini".into()), - Some("md" | "markdown") => Some("text/markdown".into()), - Some("knot") => Some("text/x-knot".into()), - Some("txt") => Some("text/plain".into()), - _ => None, - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::io::{Read, Write}; - use std::net::TcpListener; - use std::thread; - - fn serve_http_cookie_probe(body: &'static str) -> (String, thread::JoinHandle>) { - let listener = TcpListener::bind("127.0.0.1:0").expect("bind HTTP fixture"); - let address = listener.local_addr().expect("read HTTP fixture address"); - let handle = thread::spawn(move || { - let mut requests = Vec::new(); - for seed_session in [true, false] { - let (mut stream, _) = listener.accept().expect("accept HTTP fixture"); - let mut request = [0_u8; 4096]; - let read = stream.read(&mut request).expect("read HTTP request"); - let set_cookie = if seed_session { - "Set-Cookie: knot-browser-authority=secret; Path=/\r\n" - } else { - "" - }; - write!( - stream, - "HTTP/1.1 200 OK\r\nContent-Type: text/markdown\r\n{set_cookie}Content-Length: {}\r\nConnection: close\r\n\r\n{}", - body.len(), - body, - ) - .expect("write HTTP response"); - requests.push(String::from_utf8_lossy(&request[..read]).into_owned()); - } - requests - }); - (format!("http://{address}/note"), handle) - } - - fn serve_gopher_once(body: &'static str) -> (String, thread::JoinHandle) { - let listener = TcpListener::bind("127.0.0.1:0").expect("bind Gopher fixture"); - let address = listener.local_addr().expect("read Gopher fixture address"); - let handle = thread::spawn(move || { - let (mut stream, _) = listener.accept().expect("accept Gopher fixture"); - let mut request = [0_u8; 256]; - let read = stream.read(&mut request).expect("read Gopher request"); - stream - .write_all(body.as_bytes()) - .expect("write Gopher response"); - String::from_utf8_lossy(&request[..read]).into_owned() - }); - (format!("gopher://{address}/0note"), handle) - } - - #[test] - fn network_effect_fetcher_reads_anonymous_http() { - let (url, server) = serve_http_cookie_probe("# from HTTP\n"); - let mut fetcher = NetworkEffectFetcher::new(1024).expect("start fetcher"); - fetcher - .runtime - .block_on(fetch::fetch_page_capped(&url, 1024)) - .expect("seed browser session cookie"); - let fetched = - knot_editor::KnotEffectFetcher::fetch(&mut fetcher, &url).expect("fetch HTTP effect"); - - assert_eq!(fetched.content_type.as_deref(), Some("text/markdown")); - assert_eq!(fetched.body, "# from HTTP\n"); - let requests = server.join().expect("join HTTP fixture"); - assert_eq!(requests.len(), 2); - assert!(requests[1].starts_with("GET /note HTTP/")); - assert!( - !requests[1].to_ascii_lowercase().contains("\r\ncookie:"), - "effect fetch must not borrow browser cookies: {}", - requests[1], - ); - } - - #[test] - fn network_effect_fetcher_reads_gopher_as_plain_text() { - let (url, server) = serve_gopher_once("from Gopher\r\n"); - let mut fetcher = NetworkEffectFetcher::new(1024).expect("start fetcher"); - let fetched = - knot_editor::KnotEffectFetcher::fetch(&mut fetcher, &url).expect("fetch Gopher effect"); - - assert_eq!(fetched.content_type.as_deref(), Some("text/plain")); - assert_eq!(fetched.body, "from Gopher\r\n"); - assert_eq!(server.join().expect("join Gopher fixture"), "note\r\n"); - } - - #[test] - fn effect_cache_identity_binds_the_fetch_byte_cap() { - let small = RoutedEffectFetcher { - file: None, - network: Some(NetworkEffectFetcher::new(1024).expect("start small fetcher")), - }; - let large = RoutedEffectFetcher { - file: None, - network: Some(NetworkEffectFetcher::new(2048).expect("start large fetcher")), - }; - - assert_ne!( - knot_editor::KnotEffectFetcher::cache_version(&small), - knot_editor::KnotEffectFetcher::cache_version(&large) - ); - } - - #[test] - #[should_panic(expected = "Titan is an upload protocol")] - fn effect_authority_rejects_titan_read_grants() { - effect_authority( - OsStr::new("ask"), - OsStr::new("never"), - OsStr::new("titan"), - OsStr::new(""), - OsStr::new("1"), - OsStr::new("1"), - OsStr::new("1024"), - None, - ); - } -} diff --git a/ports/knot/src/bin/knot_sync_host.rs b/ports/knot/src/bin/knot_sync_host.rs deleted file mode 100644 index 2fef853f9..000000000 --- a/ports/knot/src/bin/knot_sync_host.rs +++ /dev/null @@ -1,445 +0,0 @@ -// Copyright 2026 Mark Alan Boykin -// 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 https://mozilla.org/MPL/2.0/. -// SPDX-License-Identifier: MPL-2.0 - -//! Resident Knot sync for one persona. -//! -//! `knot_endpoint` serves a projection to whoever launches it and exits with -//! them. This process instead stays up and keeps the persona's vault space -//! joined, which is what makes an edit on one device reach the others without -//! either being opened at the time. -//! -//! It deliberately does not serve a projection. Knot's endpoint is mounted per -//! session by a host; the sync lane wants a different lifetime, and running -//! both from one process would tie the lane's uptime to a viewer's. -//! -//! ```text -//! knot_sync_host [] [] [--label ] [--log-file ] -//! pair and exit: --pair-writer <64-hex> -//! unpair and exit: --unpair-writer <64-hex> -//! what the others need: --pairing-facts -//! ``` -//! -//! Both positionals are optional. Omitted, the family answers: the shared -//! root ([`pandect::shared_root`]), and the sole persona wallet under -//! it — the ordinary machine runs `knot_sync_host` bare. Zero or several -//! personas are told plainly rather than guessed among, and a scratch root or -//! explicit persona still overrides, which is what the tests and receipts use. - -use std::path::{Path, PathBuf}; - -use knot_editor::{ - KnotSettings, KnotSyncHost, KnotSyncHostConfig, KnotSyncSettings, StartupUnlockedPersonalVault, - knot_settings_path, local_device_root, personal_vault_writer, -}; - -const PAIRING_POLL: std::time::Duration = std::time::Duration::from_secs(5); - -#[cfg_attr(test, derive(Debug))] -struct Args { - data_root: PathBuf, - persona: personae::PersonaId, - label: String, - log_file: Option, - pair: Option, - unpair: Option, - pairing_facts: bool, -} - -#[tokio::main(flavor = "multi_thread")] -async fn main() { - let args = match parse_args() { - Ok(args) => args, - Err(message) => { - eprintln!("{message}"); - std::process::exit(2); - } - }; - // Management verbs edit settings or derive public pairing facts. They do - // not open Knot's vault or operation store, so they run beside a resident. - if let Some(result) = management(&args) { - match result { - Ok(message) => { - println!("{message}"); - return; - } - Err(error) => { - eprintln!("knot sync host: {error}"); - std::process::exit(1); - } - } - } - if let Err(error) = init_logging(args.log_file.as_deref()) { - eprintln!("knot sync host: initialize logging: {error}"); - std::process::exit(1); - } - if let Err(error) = run(args).await { - tracing::error!(%error, "knot sync host stopped"); - std::process::exit(1); - } -} - -fn management(args: &Args) -> Option> { - match (&args.pair, &args.unpair, args.pairing_facts) { - (Some(_), Some(_), _) => Some(Err( - "--pair-writer and --unpair-writer are mutually exclusive".into(), - )), - (Some(writer), None, _) => Some(pair(args, writer, true)), - (None, Some(writer), _) => Some(pair(args, writer, false)), - (None, None, true) => Some(pairing_facts(args)), - (None, None, false) => None, - } -} - -fn pair(args: &Args, writer: &str, add: bool) -> Result { - let key = knot_editor::parse_hex32(writer).map_err(|error| error.to_string())?; - let path = knot_settings_path(&args.data_root, args.persona); - let mut settings = KnotSettings::load(&path).map_err(|error| error.to_string())?; - let sync = settings.sync.get_or_insert_with(KnotSyncSettings::default); - let changed = if add { - sync.pair(key) - } else { - sync.unpair(key) - }; - if !changed { - return Ok(format!( - "{writer} was already {}; settings unchanged", - if add { "paired" } else { "not paired" } - )); - } - settings.save(&path).map_err(|error| error.to_string())?; - Ok(format!( - "{} {writer} in {}", - if add { "paired" } else { "unpaired" }, - path.display() - )) -} - -/// What the persona's other devices need in order to admit and reach this one. -/// -/// The writer is epoch-derived, but derivation needs Personae startup unlock, -/// not a second Knot store owner. This remains usable while the resident runs. -fn pairing_facts(args: &Args) -> Result { - let device_root = local_device_root(&args.data_root, &args.label)?; - let writer = personal_vault_writer(&args.data_root, args.persona, device_root)?; - Ok(format!( - "writer {}\n\nOn each other device, run:\n knot_sync_host {} --pair-writer {}", - knot_editor::hex32(&writer), - args.persona.as_uuid(), - knot_editor::hex32(&writer), - )) -} - -async fn run(args: Args) -> Result<(), Box> { - let settings_file = knot_settings_path(&args.data_root, args.persona); - let stored = KnotSettings::load(&settings_file)?; - tracing::info!( - path = %settings_file.display(), - configured = stored.sync.is_some(), - "knot sync settings" - ); - let Some(sync) = stored.sync else { - return Err(format!( - "knot sync is not configured for this persona; add a sync section to {}", - settings_file.display() - ) - .into()); - }; - - let device_root = local_device_root(&args.data_root, &args.label)?; - let snapshot = knot_editor::KnotSpaceAuthoritySnapshot::from_personal_settings(&sync)?; - let authority = StartupUnlockedPersonalVault::open( - &args.data_root, - args.persona, - device_root, - snapshot.writers(), - )?; - - let relays = - transport::P2pandaHostPolicy::parse_relay_urls(sync.relay_urls.iter().map(String::as_str))?; - - let mut host = KnotSyncHost::open( - authority.store(), - authority.signing_seed(), - KnotSyncHostConfig { - authority: snapshot, - relay_urls: relays, - }, - ) - .await?; - - // The writer key is what the other devices admit AND dial, so this one - // line is the whole of what a peer needs. - tracing::info!( - persona = %args.persona.as_uuid(), - writer = %knot_editor::hex32(&host.node_id()), - paired = sync.paired_writers.len(), - relays = sync.relay_urls.len(), - "knot vault sync listening" - ); - if sync.paired_writers.is_empty() { - tracing::warn!( - "no paired writers: this device will hold its own vault and \ - converge with nothing" - ); - } - - // Reconcile pairing live. Writer admission and evidence access are shared - // mutable Personae materializations, while the address-book topic is only - // the route used to reach that admitted identity. - loop { - tokio::time::sleep(PAIRING_POLL).await; - let reloaded = match KnotSettings::load(&settings_file) { - Ok(settings) => settings, - Err(error) => { - tracing::warn!(%error, "could not reload knot sync settings"); - continue; - } - }; - let Some(sync) = reloaded.sync else { continue }; - let desired = match knot_editor::KnotSpaceAuthoritySnapshot::from_personal_settings(&sync) { - Ok(snapshot) => snapshot, - Err(error) => { - tracing::warn!(%error, "knot sync settings hold an unusable writer key"); - continue; - } - }; - match host.apply_authority(desired).await { - Ok(true) => tracing::info!( - revision = %knot_editor::hex32(&host.authority_revision()), - "applied a new Knot space-authority revision" - ), - Ok(false) => {} - Err(error) => { - tracing::warn!(%error, "could not apply Knot space authority"); - continue; - } - } - host.refresh_dial_hints(&settings_file).await; - } -} - -fn parse_args() -> Result { - parse_from(std::env::args().skip(1).collect()) -} - -fn parse_from(args: Vec) -> Result { - // Up to two leading positionals: a data root, a persona UUID, or both in - // that order. A UUID cannot be mistaken for a path in practice, so one - // positional is read as whichever it parses as. Omitted, the family - // answers: the shared root, and the sole persona wallet under it. - let positional: Vec<&String> = args - .iter() - .take_while(|arg| !arg.starts_with("--")) - .collect(); - let parse_persona = |value: &str| { - value - .parse::() - .map(personae::PersonaId::from_uuid) - .map_err(|error| format!("persona must be a UUID: {error}")) - }; - let (data_root, persona) = match positional.as_slice() { - [] => (None, None), - [one] => match one.parse::() { - Ok(uuid) => (None, Some(personae::PersonaId::from_uuid(uuid))), - Err(_) => (Some(PathBuf::from(one.as_str())), None), - }, - [root, persona] => ( - Some(PathBuf::from(root.as_str())), - Some(parse_persona(persona)?), - ), - _ => return Err(usage()), - }; - let data_root = data_root.unwrap_or_else(pandect::shared_root::shared_root); - let persona = match persona { - Some(persona) => persona, - // Resolving rather than guessing: personas are real cryptographic - // identities, and syncing the wrong one is not a recoverable oops. - None => { - let personas = pandect::wallet_store::list_personas(&data_root).map_err(|error| { - format!( - "could not list personas under {}: {error}", - data_root.display() - ) - })?; - match personas.as_slice() { - [only] => *only, - [] => { - return Err(format!( - "no persona wallet exists under {} yet; pair this device first, or \ - name a persona explicitly\n{}", - data_root.display(), - usage() - )); - } - several => { - return Err(format!( - "several personas live under {}; name one of: {}\n{}", - data_root.display(), - several - .iter() - .map(|persona| persona.as_uuid().to_string()) - .collect::>() - .join(", "), - usage() - )); - } - } - } - }; - let mut argv = args.iter().skip(positional.len()).cloned(); - let mut label = "knot".to_string(); - let mut log_file = None; - let mut pair = None; - let mut unpair = None; - let mut pairing_facts = false; - while let Some(arg) = argv.next() { - match arg.as_str() { - "--label" => label = argv.next().ok_or("--label needs a value")?, - "--log-file" => { - log_file = Some(PathBuf::from( - argv.next().ok_or("--log-file needs a value")?, - )) - } - "--pair-writer" => pair = Some(argv.next().ok_or("--pair-writer needs a value")?), - "--unpair-writer" => unpair = Some(argv.next().ok_or("--unpair-writer needs a value")?), - "--pairing-facts" => pairing_facts = true, - other => return Err(format!("unknown argument: {other}")), - } - } - Ok(Args { - data_root, - persona, - label, - log_file, - pair, - unpair, - pairing_facts, - }) -} - -fn usage() -> String { - "usage: knot_sync_host [] [] [--label ] \ - [--log-file ]\n\ - omitted, the family answers: the shared root (MERE_ROOT or the platform \ - data dir), and the sole persona wallet under it\n\ - pair and exit: --pair-writer <64-hex> | --unpair-writer <64-hex>\n\ - what the others need: --pairing-facts" - .to_string() -} - -fn init_logging(path: Option<&Path>) -> Result<(), std::io::Error> { - match path { - Some(path) => { - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; - } - let file = std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(path)?; - tracing_subscriber::fmt() - .with_max_level(tracing::Level::INFO) - .with_ansi(false) - .with_writer(std::sync::Mutex::new(file)) - .init(); - } - None => tracing_subscriber::fmt() - .with_max_level(tracing::Level::INFO) - .init(), - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use pandect::wallet_store::{ - KeyEpochId, PersonaChainRoot, PersonaWalletManifest, save_persona_wallet, - }; - - fn scratch(tag: &str) -> PathBuf { - let dir = - std::env::temp_dir().join(format!("knot-sync-host-args-{tag}-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - dir - } - - fn seed_wallet(root: &std::path::Path, uuid: u128) -> personae::PersonaId { - let persona = personae::PersonaId::from_uuid(uuid::Uuid::from_u128(uuid)); - save_persona_wallet( - root, - &PersonaWalletManifest::new( - persona, - PersonaChainRoot([7u8; 32]), - KeyEpochId(uuid::Uuid::from_u128(0x9999)), - ), - ) - .unwrap(); - persona - } - - fn args(parts: &[&str]) -> Vec { - parts.iter().map(|part| part.to_string()).collect() - } - - #[test] - fn a_root_alone_resolves_its_sole_persona() { - // The ordinary machine: nobody should have to hand-copy a UUID to - // sync the only persona they have. - let root = scratch("sole"); - let persona = seed_wallet(&root, 0x42); - let parsed = parse_from(args(&[root.to_str().unwrap(), "--label", "study"])).unwrap(); - assert_eq!(parsed.persona, persona); - assert_eq!(parsed.data_root, root); - assert_eq!(parsed.label, "study", "flags still parse after resolution"); - let _ = std::fs::remove_dir_all(&root); - } - - #[test] - fn a_lone_uuid_reads_as_a_persona_not_a_path() { - let parsed = parse_from(args(&["00000000-0000-0000-0000-000000000042"])).unwrap(); - assert_eq!( - parsed.persona, - personae::PersonaId::from_uuid(uuid::Uuid::from_u128(0x42)) - ); - } - - #[test] - fn both_positionals_still_work_exactly_as_before() { - let root = scratch("explicit"); - let parsed = parse_from(args(&[ - root.to_str().unwrap(), - "00000000-0000-0000-0000-000000000011", - ])) - .unwrap(); - assert_eq!(parsed.data_root, root); - assert_eq!( - parsed.persona, - personae::PersonaId::from_uuid(uuid::Uuid::from_u128(0x11)) - ); - let _ = std::fs::remove_dir_all(&root); - } - - #[test] - fn zero_and_several_personas_are_told_not_guessed() { - let empty = scratch("zero"); - let error = parse_from(args(&[empty.to_str().unwrap()])).unwrap_err(); - assert!(error.contains("no persona wallet exists"), "{error}"); - - let crowded = scratch("several"); - let a = seed_wallet(&crowded, 0x21); - let b = seed_wallet(&crowded, 0x22); - let error = parse_from(args(&[crowded.to_str().unwrap()])).unwrap_err(); - assert!(error.contains(&a.as_uuid().to_string()), "{error}"); - assert!( - error.contains(&b.as_uuid().to_string()), - "names what exists so the fix is a copy, not a hunt: {error}" - ); - let _ = std::fs::remove_dir_all(&empty); - let _ = std::fs::remove_dir_all(&crowded); - } -} diff --git a/ports/knot/src/clip_evidence.rs b/ports/knot/src/clip_evidence.rs deleted file mode 100644 index ef98196bb..000000000 --- a/ports/knot/src/clip_evidence.rs +++ /dev/null @@ -1,784 +0,0 @@ -// Copyright 2026 Mark Alan Boykin -// 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 https://mozilla.org/MPL/2.0/. -// SPDX-License-Identifier: MPL-2.0 - -//! Endpoint-owned retention for source artifacts attached to clips. - -use std::collections::BTreeMap; -use std::fs::{self, OpenOptions}; -use std::io::{self, Write}; -use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::{Arc, Mutex, mpsc}; -use std::thread::{self, JoinHandle}; - -use chirograph::{KnotClipArtifactRoleV1, KnotClipArtifactV1, PortableContentRefV1}; -use serde::{Deserialize, Serialize}; -use transport::{BlobHash, BlobLease, BlobReadAuthorizer, BlobScope, BlobStore}; - -static NEXT_TEMPORARY: AtomicU64 = AtomicU64::new(0); - -/// Portable content identity written into clip provenance. -/// -/// New references serialize a shared [`PortableContentRefV1`]: RFC 6920 -/// SHA-256 identity beside the BLAKE3 address iroh uses. The public normalized -/// fields preserve the established Knot API, while deserialization also -/// accepts the legacy `urn:blake3` record. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct KnotClipEvidenceRef { - pub content_uri: String, - pub digest: String, - pub byte_size: u64, - pub media_type: String, - pub canonical_uri: String, - pub role: KnotClipArtifactRoleV1, - portable: Option, -} - -impl KnotClipEvidenceRef { - fn portable(artifact: &KnotClipArtifactV1) -> Self { - let content = PortableContentRefV1::of(&artifact.bytes); - Self { - content_uri: content.portable_id.to_string(), - digest: content.transport.to_string(), - byte_size: content.byte_size, - media_type: artifact.media_type.clone(), - canonical_uri: artifact.canonical_uri.clone(), - role: artifact.role, - portable: Some(content), - } - } - - /// The shared portable reference, absent only for a decoded legacy clip. - pub fn portable_content(&self) -> Option<&PortableContentRefV1> { - self.portable.as_ref() - } - - /// Resolve the portable URI into the transport blob hash it names. - pub fn blob_hash(&self) -> Result { - if let Some(content) = &self.portable { - if self.content_uri != content.portable_id.to_string() - || self.digest != content.transport.to_string() - || self.byte_size != content.byte_size - { - return Err("clip evidence portable and normalized fields disagree".into()); - } - return Ok(BlobHash::from_bytes(*content.transport.as_bytes())); - } - let named = self - .content_uri - .strip_prefix("urn:blake3:") - .ok_or_else(|| "legacy clip evidence URI is not a urn:blake3 reference".to_string())?; - if named != self.digest { - return Err("legacy clip evidence URI and digest disagree".into()); - } - parse_digest(&self.digest).map(BlobHash::from_bytes) - } - - /// Check bytes before they are exposed as the retained source artifact. - pub fn verify_bytes(&self, bytes: &[u8]) -> Result<(), String> { - if u64::try_from(bytes.len()).ok() != Some(self.byte_size) { - return Err("clip evidence byte length does not match its reference".into()); - } - let actual = blake3::hash(bytes); - if actual.as_bytes() != self.blob_hash()?.as_bytes() { - return Err("clip evidence bytes do not match their BLAKE3 reference".into()); - } - if let Some(content) = &self.portable { - content - .verify_bytes(bytes) - .map_err(|error| error.to_string())?; - } - Ok(()) - } -} - -#[derive(Serialize)] -struct PortableEvidenceWire<'a> { - content: &'a PortableContentRefV1, - media_type: &'a str, - canonical_uri: &'a str, - role: KnotClipArtifactRoleV1, -} - -#[derive(Deserialize)] -#[serde(untagged)] -enum EvidenceWire { - Portable { - content: PortableContentRefV1, - media_type: String, - canonical_uri: String, - role: KnotClipArtifactRoleV1, - }, - Legacy { - content_uri: String, - digest: String, - byte_size: u64, - media_type: String, - canonical_uri: String, - role: KnotClipArtifactRoleV1, - }, -} - -impl Serialize for KnotClipEvidenceRef { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - if let Some(content) = &self.portable { - if self.content_uri != content.portable_id.to_string() - || self.digest != content.transport.to_string() - || self.byte_size != content.byte_size - { - return Err(serde::ser::Error::custom( - "clip evidence portable and normalized fields disagree", - )); - } - PortableEvidenceWire { - content, - media_type: &self.media_type, - canonical_uri: &self.canonical_uri, - role: self.role, - } - .serialize(serializer) - } else { - serde_json::json!({ - "content_uri": self.content_uri, - "digest": self.digest, - "byte_size": self.byte_size, - "media_type": self.media_type, - "canonical_uri": self.canonical_uri, - "role": self.role, - }) - .serialize(serializer) - } - } -} - -impl<'de> Deserialize<'de> for KnotClipEvidenceRef { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - Ok(match EvidenceWire::deserialize(deserializer)? { - EvidenceWire::Portable { - content, - media_type, - canonical_uri, - role, - } => Self { - content_uri: content.portable_id.to_string(), - digest: content.transport.to_string(), - byte_size: content.byte_size, - media_type, - canonical_uri, - role, - portable: Some(content), - }, - EvidenceWire::Legacy { - content_uri, - digest, - byte_size, - media_type, - canonical_uri, - role, - } => Self { - content_uri, - digest, - byte_size, - media_type, - canonical_uri, - role, - portable: None, - }, - }) - } -} - -/// Extract the portable evidence references authored by Knot clip v2 blocks. -/// -/// These fenced JSON records are Knot-owned metadata inside an ordinary Djot -/// document. Unknown provenance fields remain forward-compatible; malformed or -/// internally inconsistent evidence references fail closed. -pub fn clip_evidence_references(source: &[u8]) -> Result, String> { - const OPEN: &str = "```knot.clip.provenance\n"; - const CLOSE: &str = "\n```"; - - let source = std::str::from_utf8(source) - .map_err(|_| "evidence references require a UTF-8 Djot source".to_string())?; - let mut rest = source; - let mut references = BTreeMap::::new(); - while let Some(start) = rest.find(OPEN) { - let body = &rest[start + OPEN.len()..]; - let end = body - .find(CLOSE) - .ok_or_else(|| "clip provenance fence is not closed".to_string())?; - let value: serde_json::Value = serde_json::from_str(&body[..end]) - .map_err(|error| format!("clip provenance is malformed: {error}"))?; - if let Some(evidence) = value.get("evidence") { - let entries = evidence - .as_array() - .ok_or_else(|| "clip provenance evidence must be an array".to_string())?; - for entry in entries { - let reference: KnotClipEvidenceRef = serde_json::from_value(entry.clone()) - .map_err(|error| format!("clip evidence reference is malformed: {error}"))?; - reference.blob_hash()?; - if let Some(previous) = references.get(&reference.digest) { - if previous != &reference { - return Err("one clip evidence digest carries conflicting metadata".into()); - } - } else { - references.insert(reference.digest.clone(), reference); - } - } - } - rest = &body[end + CLOSE.len()..]; - } - Ok(references.into_values().collect()) -} - -/// Host-injected authority for retaining clip evidence. -pub trait KnotClipEvidenceStore: Send { - /// Retain an artifact under its content identity. Implementations must be - /// idempotent for identical bytes. - fn retain(&mut self, artifact: &KnotClipArtifactV1) -> Result; -} - -/// A content-addressed local store rooted at an explicitly configured path. -pub struct FileClipEvidenceStore { - root: PathBuf, - max_artifact_bytes: u64, -} - -impl FileClipEvidenceStore { - pub fn new(root: impl Into, max_artifact_bytes: u64) -> Self { - Self { - root: root.into(), - max_artifact_bytes, - } - } - - pub fn root(&self) -> &Path { - &self.root - } - - fn artifact_path(&self, digest: &str) -> PathBuf { - self.root.join("blake3").join(digest) - } -} - -impl KnotClipEvidenceStore for FileClipEvidenceStore { - fn retain(&mut self, artifact: &KnotClipArtifactV1) -> Result { - let byte_size = u64::try_from(artifact.bytes.len()) - .map_err(|_| "clip artifact byte length does not fit u64".to_string())?; - if byte_size > self.max_artifact_bytes { - return Err(format!( - "clip artifact is {byte_size} bytes; configured evidence limit is {}", - self.max_artifact_bytes - )); - } - let digest = blake3::hash(&artifact.bytes).to_hex().to_string(); - let path = self.artifact_path(&digest); - if path.exists() { - let existing = fs::read(&path) - .map_err(|error| format!("could not verify retained clip evidence: {error}"))?; - if existing != artifact.bytes { - return Err("retained clip evidence does not match its BLAKE3 address".into()); - } - } else { - let parent = path - .parent() - .ok_or_else(|| "clip evidence path has no parent".to_string())?; - fs::create_dir_all(parent) - .map_err(|error| format!("could not create clip evidence directory: {error}"))?; - let temporary = parent.join(format!( - ".{digest}.{}.{}.tmp", - std::process::id(), - NEXT_TEMPORARY.fetch_add(1, Ordering::Relaxed) - )); - if let Err(error) = write_new(&temporary, &artifact.bytes) { - let _ = fs::remove_file(&temporary); - return Err(format!("could not stage clip evidence: {error}")); - } - match fs::rename(&temporary, &path) { - Ok(()) => {} - Err(error) if path.exists() => { - let _ = fs::remove_file(&temporary); - let existing = fs::read(&path).map_err(|read_error| { - format!( - "clip evidence raced with another writer ({error}) and could not be verified: {read_error}" - ) - })?; - if existing != artifact.bytes { - return Err( - "retained clip evidence does not match its BLAKE3 address".into() - ); - } - } - Err(error) => { - let _ = fs::remove_file(&temporary); - return Err(format!("could not install clip evidence: {error}")); - } - } - } - let reference = KnotClipEvidenceRef::portable(artifact); - debug_assert_eq!(reference.digest, digest); - debug_assert_eq!(reference.byte_size, byte_size); - Ok(reference) - } -} - -/// Clip evidence retained in the Murm-owned iroh blob store. -pub struct BlobClipEvidenceStore { - blobs: Arc, - max_artifact_bytes: u64, - custody: Option<(BlobReadAuthorizer, BlobScope)>, -} - -impl BlobClipEvidenceStore { - /// Open the source-owned actor used by synchronous endpoint adapters. - /// - /// The associated constructor retains its old spelling for callers, but - /// returns the resident port rather than hiding a runtime inside this - /// store handle. - pub fn open( - root: impl AsRef, - max_artifact_bytes: u64, - ) -> Result { - KnotContentRetentionPort::open(root, max_artifact_bytes) - } - - /// Open a persistent store on the resident host's async runtime. - pub async fn open_async( - root: impl AsRef, - max_artifact_bytes: u64, - ) -> Result { - let blobs = BlobStore::open(root) - .await - .map(Arc::new) - .map_err(|error| format!("could not open clip evidence blob store: {error}"))?; - Ok(Self { - blobs, - max_artifact_bytes, - custody: None, - }) - } - - /// Shared store handle for a resident p2p transport. - /// - /// The direct async handle and [`KnotContentRetentionPort`] can both expose - /// the same resident blob actor to the transport host. The port keeps that - /// actor and its runtime under source-owned shutdown. - pub fn resident_blob_store(&self) -> Result, String> { - Ok(Arc::clone(&self.blobs)) - } - - /// Retain from an async resident host without nesting runtimes. - pub async fn retain_async( - &self, - artifact: &KnotClipArtifactV1, - ) -> Result { - let scope = self.custody.as_ref().map(|(_, scope)| *scope); - let reference = - retain_blob_artifact(&self.blobs, self.max_artifact_bytes, artifact, scope).await?; - if let Some((authority, scope)) = &self.custody { - authority.retain(*scope, reference.blob_hash()?); - } - Ok(reference) - } -} - -enum RetentionCommand { - Retain { - artifact: KnotClipArtifactV1, - reply: tokio::sync::oneshot::Sender>, - }, - Close, -} - -struct RetentionPortInner { - commands: mpsc::Sender, - join: Mutex>>, - blobs: Arc, -} - -impl Drop for RetentionPortInner { - fn drop(&mut self) { - let _ = self.commands.send(RetentionCommand::Close); - if let Some(join) = self - .join - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .take() - { - let _ = join.join(); - } - } -} - -/// Cloneable source-owned port to the async clip-evidence actor. -/// -/// Synchronous Graphshell endpoint traits submit work through this port; the -/// blob store and its Tokio runtime stay in one resident actor. Dropping the -/// final port flushes and shuts down the store, then joins the actor thread. -#[derive(Clone)] -pub struct KnotContentRetentionPort { - inner: Arc, -} - -impl KnotContentRetentionPort { - /// Open one persistent actor-backed retention service. - pub fn open(root: impl AsRef, max_artifact_bytes: u64) -> Result { - Self::open_inner( - RetentionBacking::Open(root.as_ref().to_path_buf()), - max_artifact_bytes, - None, - ) - } - - /// Open retention with serving custody bound to one domain scope. - /// - /// The binding lands only after the bytes have been retained and flushed, - /// so an authorized reader is never pointed at content the store lacks. - pub fn open_scoped( - root: impl AsRef, - max_artifact_bytes: u64, - authority: BlobReadAuthorizer, - scope: BlobScope, - ) -> Result { - Self::open_inner( - RetentionBacking::Open(root.as_ref().to_path_buf()), - max_artifact_bytes, - Some((authority, scope)), - ) - } - - /// Borrow the resident's physical blob store while retaining Knot's own - /// scoped custody and serving authority. - /// - /// Dropping the port flushes this lane's writes but does not shut down the - /// resident store. The process owner remains its sole lifetime authority. - pub fn borrow_scoped( - blobs: Arc, - max_artifact_bytes: u64, - authority: BlobReadAuthorizer, - scope: BlobScope, - ) -> Result { - Self::open_inner( - RetentionBacking::Borrowed(blobs), - max_artifact_bytes, - Some((authority, scope)), - ) - } - - fn open_inner( - backing: RetentionBacking, - max_artifact_bytes: u64, - custody: Option<(BlobReadAuthorizer, BlobScope)>, - ) -> Result { - let (commands, receiver) = mpsc::channel(); - let (ready, opened) = mpsc::sync_channel(1); - let join = thread::Builder::new() - .name("knot-content-retention".into()) - .spawn(move || { - let runtime = match tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - { - Ok(runtime) => runtime, - Err(error) => { - let _ = ready.send(Err(format!( - "could not create content-retention runtime: {error}" - ))); - return; - } - }; - let (blobs, owns_store) = match backing { - RetentionBacking::Open(root) => match runtime.block_on(BlobStore::open(root)) { - Ok(blobs) => (Arc::new(blobs), true), - Err(error) => { - let _ = ready.send(Err(format!( - "could not open clip evidence blob store: {error}" - ))); - return; - } - }, - RetentionBacking::Borrowed(blobs) => (blobs, false), - }; - let store = BlobClipEvidenceStore { - blobs: Arc::clone(&blobs), - max_artifact_bytes, - custody, - }; - if ready.send(Ok(blobs)).is_err() { - if owns_store { - let _ = runtime.block_on(store.blobs.shutdown()); - } else { - let _ = runtime.block_on(store.blobs.flush()); - } - return; - } - while let Ok(command) = receiver.recv() { - match command { - RetentionCommand::Retain { artifact, reply } => { - let result = runtime.block_on(store.retain_async(&artifact)); - let _ = reply.send(result); - } - RetentionCommand::Close => break, - } - } - if owns_store { - let _ = runtime.block_on(store.blobs.shutdown()); - } else { - let _ = runtime.block_on(store.blobs.flush()); - } - }) - .map_err(|error| format!("could not start content-retention actor: {error}"))?; - let blobs = match opened.recv() { - Ok(Ok(blobs)) => blobs, - Ok(Err(error)) => { - let _ = join.join(); - return Err(error); - } - Err(_) => { - let _ = join.join(); - return Err("content-retention actor stopped during startup".into()); - } - }; - Ok(Self { - inner: Arc::new(RetentionPortInner { - commands, - join: Mutex::new(Some(join)), - blobs, - }), - }) - } - - /// Retain one artifact through the resident actor. - pub async fn retain_async( - &self, - artifact: &KnotClipArtifactV1, - ) -> Result { - let (reply, result) = tokio::sync::oneshot::channel(); - self.inner - .commands - .send(RetentionCommand::Retain { - artifact: artifact.clone(), - reply, - }) - .map_err(|_| "content-retention actor has stopped".to_string())?; - result - .await - .map_err(|_| "content-retention actor dropped its reply".to_string())? - } - - /// Shared blob handle for the source's sync host. - pub fn blob_store(&self) -> Arc { - Arc::clone(&self.inner.blobs) - } -} - -enum RetentionBacking { - Open(PathBuf), - Borrowed(Arc), -} - -impl KnotClipEvidenceStore for KnotContentRetentionPort { - fn retain(&mut self, artifact: &KnotClipArtifactV1) -> Result { - pollster::block_on(self.retain_async(artifact)) - } -} - -async fn retain_blob_artifact( - blobs: &BlobStore, - max_artifact_bytes: u64, - artifact: &KnotClipArtifactV1, - scope: Option, -) -> Result { - let byte_size = u64::try_from(artifact.bytes.len()) - .map_err(|_| "clip artifact byte length does not fit u64".to_string())?; - if byte_size > max_artifact_bytes { - return Err(format!( - "clip artifact is {byte_size} bytes; configured evidence limit is {max_artifact_bytes}" - )); - } - let digest = blake3::hash(&artifact.bytes); - let digest_hex = digest.to_hex().to_string(); - let stored = match scope { - Some(scope) => { - let lease = BlobLease::new(scope, "knot.evidence", digest.as_bytes()) - .map_err(|error| format!("could not name clip evidence custody: {error}"))?; - blobs.put_bytes_leased(artifact.bytes.clone(), &lease).await - } - None => { - let tag = format!("knot/clip-evidence/{digest_hex}"); - blobs - .put_bytes_named(artifact.bytes.clone(), tag.as_bytes()) - .await - } - } - .map_err(|error| format!("could not retain clip evidence in blob store: {error}"))?; - if stored.as_bytes() != digest.as_bytes() { - return Err("transport blob store returned the wrong clip evidence digest".into()); - } - blobs - .flush() - .await - .map_err(|error| format!("could not flush retained clip evidence: {error}"))?; - let reference = KnotClipEvidenceRef::portable(artifact); - if reference.digest != digest_hex || reference.byte_size != byte_size { - return Err("portable clip evidence disagrees with its retained transport bytes".into()); - } - Ok(reference) -} - -fn parse_digest(value: &str) -> Result<[u8; 32], String> { - if value.len() != 64 - || !value - .bytes() - .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) - { - return Err("clip evidence digest must be 64 lowercase hexadecimal characters".into()); - } - let mut digest = [0u8; 32]; - for (index, pair) in value.as_bytes().chunks_exact(2).enumerate() { - let hex = std::str::from_utf8(pair).expect("ASCII hex was already checked"); - digest[index] = u8::from_str_radix(hex, 16) - .map_err(|_| "clip evidence digest is not hexadecimal".to_string())?; - } - Ok(digest) -} - -fn write_new(path: &Path, bytes: &[u8]) -> io::Result<()> { - let mut file = OpenOptions::new().write(true).create_new(true).open(path)?; - file.write_all(bytes)?; - file.sync_all() -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::tempdir; - - #[test] - fn file_store_is_content_addressed_and_idempotent() { - let temp = tempdir().unwrap(); - let mut store = FileClipEvidenceStore::new(temp.path(), 1024); - let artifact = KnotClipArtifactV1 { - role: KnotClipArtifactRoleV1::SourceResponse, - media_type: "text/html".into(), - canonical_uri: "https://example.test/post".into(), - bytes: b"

evidence

".to_vec(), - }; - let first = store.retain(&artifact).unwrap(); - let second = store.retain(&artifact).unwrap(); - assert_eq!(first, second); - assert_eq!( - fs::read(store.artifact_path(&first.digest)).unwrap(), - artifact.bytes - ); - assert!( - first - .content_uri - .starts_with(chirograph::Sha256NamedInformation::PREFIX) - ); - assert_eq!( - first.portable_content().unwrap().transport.to_string(), - first.digest - ); - } - - #[test] - fn transport_blob_store_retains_and_reopens_verified_evidence() { - let temp = tempdir().unwrap(); - let artifact = KnotClipArtifactV1 { - role: KnotClipArtifactRoleV1::SourceResponse, - media_type: "text/html".into(), - canonical_uri: "https://example.test/post".into(), - bytes: b"

portable evidence

".to_vec(), - }; - let reference = { - let mut store = BlobClipEvidenceStore::open(temp.path(), 1024).unwrap(); - let reference = store.retain(&artifact).unwrap(); - let runtime = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .unwrap(); - let bytes = runtime - .block_on(store.blob_store().get_bytes(reference.blob_hash().unwrap())) - .unwrap(); - reference.verify_bytes(&bytes).unwrap(); - reference - }; - - let reopened = BlobClipEvidenceStore::open(temp.path(), 1024).unwrap(); - let runtime = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .unwrap(); - let bytes = runtime - .block_on( - reopened - .blob_store() - .get_bytes(reference.blob_hash().unwrap()), - ) - .unwrap(); - reference.verify_bytes(&bytes).unwrap(); - let mut tampered = bytes.to_vec(); - tampered[0] ^= 1; - assert!(reference.verify_bytes(&tampered).is_err()); - } - - #[test] - fn djot_provenance_yields_deduplicated_validated_references() { - let bytes = b"portable evidence"; - let digest = blake3::hash(bytes).to_hex().to_string(); - let reference = KnotClipEvidenceRef { - content_uri: format!("urn:blake3:{digest}"), - digest, - byte_size: bytes.len() as u64, - media_type: "text/plain".into(), - canonical_uri: "https://example.test/evidence".into(), - role: KnotClipArtifactRoleV1::SourceResponse, - portable: None, - }; - let provenance = serde_json::json!({ - "schema": "knot.clip.insert/v2", - "evidence": [reference.clone(), reference.clone()] - }); - let source = format!( - "# Note\n\n```knot.clip.provenance\n{}\n```\n", - serde_json::to_string(&provenance).unwrap() - ); - let decoded = clip_evidence_references(source.as_bytes()).unwrap(); - assert_eq!(decoded, vec![reference]); - assert!(decoded[0].portable_content().is_none()); - decoded[0].verify_bytes(bytes).unwrap(); - } - - #[test] - fn portable_and_transport_hashes_must_both_match() { - let artifact = KnotClipArtifactV1 { - role: KnotClipArtifactRoleV1::SourceResponse, - media_type: "text/plain".into(), - canonical_uri: "https://example.test/evidence".into(), - bytes: b"portable evidence".to_vec(), - }; - let reference = KnotClipEvidenceRef::portable(&artifact); - let mut value = serde_json::to_value(&reference).unwrap(); - value["content"]["portable_id"] = - serde_json::to_value(chirograph::Sha256NamedInformation::of(b"different bytes")) - .unwrap(); - let conflicting: KnotClipEvidenceRef = serde_json::from_value(value).unwrap(); - assert!( - conflicting.blob_hash().is_ok(), - "the transport hash is valid" - ); - assert!( - conflicting.verify_bytes(&artifact.bytes).is_err(), - "the conflicting portable identity fails closed", - ); - } -} diff --git a/ports/knot/src/content_classes.rs b/ports/knot/src/content_classes.rs deleted file mode 100644 index 7f67c65c3..000000000 --- a/ports/knot/src/content_classes.rs +++ /dev/null @@ -1,191 +0,0 @@ -// Copyright 2026 Mark Alan Boykin -// 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 https://mozilla.org/MPL/2.0/. -// SPDX-License-Identifier: MPL-2.0 - -//! Knot's content-class pack. - -use chartulary::{CLASS_FACET, ClassRegistry, ContentClass, FacetId}; -use eidetic::{MereNativeFieldSpec, MereNativeSchemaBuilder, SchemaDefinition, SchemaFormat}; -use pandect::SchemaFacetValidator; -use serde_json::json; - -/// The general files-in-place class. -pub const FILE_CLASS: &str = "knot.file"; -/// Authored text. Its required facets include the general file profile. -pub const NOTE_CLASS: &str = "knot.note"; -/// Disk-observation metadata shared by every file. -pub const FILE_DOCUMENT_FACET: &str = "file.document"; -/// Format metadata for authored text. -pub const NOTE_DOCUMENT_FACET: &str = "note.document"; - -/// The classes and schemas Knot ships through the same data seams a pack uses. -pub struct KnotContentClasses { - /// Known class definitions. - pub registry: ClassRegistry, - /// Preloaded facet schemas. - pub validator: SchemaFacetValidator, -} - -impl KnotContentClasses { - /// Build Knot's built-in pack. - pub fn new() -> Self { - let mut validator = SchemaFacetValidator::new(); - validator.register( - FacetId::new(FILE_DOCUMENT_FACET), - MereNativeSchemaBuilder::new("knot.file/v1") - .description("A file observed in place by Knot") - .field("version", MereNativeFieldSpec::U64, true) - .field("address", MereNativeFieldSpec::String, true) - .field("byte_size", MereNativeFieldSpec::U64, true) - .field("extension", MereNativeFieldSpec::String, false) - .build(), - ); - validator.register( - FacetId::new(NOTE_DOCUMENT_FACET), - MereNativeSchemaBuilder::new("knot.note/v1") - .description("An authored text document observed in place by Knot") - .field("version", MereNativeFieldSpec::U64, true) - .field("format", MereNativeFieldSpec::String, true) - .build(), - ); - validator.register( - FacetId::new(CLASS_FACET), - SchemaDefinition { - format: SchemaFormat::JsonSchema, - schema_id: "chartulary.class/v1".to_string(), - body: json!({"type": "string", "minLength": 1}), - }, - ); - - let mut registry = ClassRegistry::new(); - registry.register( - ContentClass::new( - FILE_CLASS, - [( - FacetId::new(FILE_DOCUMENT_FACET), - "knot.file/v1".to_string(), - )], - ) - .with_label("File"), - ); - registry.register( - ContentClass::new( - NOTE_CLASS, - [ - ( - FacetId::new(FILE_DOCUMENT_FACET), - "knot.file/v1".to_string(), - ), - ( - FacetId::new(NOTE_DOCUMENT_FACET), - "knot.note/v1".to_string(), - ), - ], - ) - .with_label("Note"), - ); - - Self { - registry, - validator, - } - } -} - -impl Default for KnotContentClasses { - fn default() -> Self { - Self::new() - } -} - -#[cfg(test)] -mod tests { - use chartulary::{AcceptAll, ClassMembership, FacetId, FacetStore, FacetValidator, NodeFacets}; - use serde_json::json; - - use super::*; - - #[test] - fn note_class_admits_a_valid_file_and_note_profile() { - let classes = KnotContentClasses::new(); - let mut facets = FacetStore::::new(); - let id = "note".to_string(); - facets - .set( - id.clone(), - FacetId::new(CLASS_FACET), - json!(NOTE_CLASS), - &classes.validator, - ) - .unwrap(); - facets - .set( - id.clone(), - FacetId::new(FILE_DOCUMENT_FACET), - json!({ - "version": 1, - "address": "file:///notes/field.knot", - "byte_size": 42, - "extension": "knot", - }), - &classes.validator, - ) - .unwrap(); - facets - .set( - id.clone(), - FacetId::new(NOTE_DOCUMENT_FACET), - json!({"version": 1, "format": "knot"}), - &classes.validator, - ) - .unwrap(); - - let node = facets.facets_of(&id).unwrap(); - let ClassMembership::Known(class) = classes.registry.membership(node) else { - panic!("registered note class should be known"); - }; - class.admits(node, &classes.validator).unwrap(); - } - - #[test] - fn unknown_class_stays_inert_and_discoverable() { - let classes = KnotContentClasses::new(); - let mut facets = FacetStore::::new(); - let id = "foreign".to_string(); - facets - .set( - id.clone(), - FacetId::new(CLASS_FACET), - json!("pack.foreign"), - &AcceptAll, - ) - .unwrap(); - - match classes.registry.membership(facets.facets_of(&id).unwrap()) { - ClassMembership::Unknown(class) => assert_eq!(class.as_str(), "pack.foreign"), - other => panic!("expected an inert unknown class, got {other:?}"), - } - } - - #[test] - fn schemas_reject_malformed_known_profiles() { - let classes = KnotContentClasses::new(); - assert!( - classes - .validator - .validate( - &FacetId::new(NOTE_DOCUMENT_FACET), - &json!({"version": "one", "format": "knot"}), - ) - .is_err() - ); - let empty = NodeFacets::new(); - let note = classes - .registry - .get(&chartulary::ClassId::new(NOTE_CLASS)) - .unwrap(); - assert!(note.admits(&empty, &classes.validator).is_err()); - } -} diff --git a/ports/knot/src/directory.rs b/ports/knot/src/directory.rs deleted file mode 100644 index c741d989d..000000000 --- a/ports/knot/src/directory.rs +++ /dev/null @@ -1,495 +0,0 @@ -// Copyright 2026 Mark Alan Boykin -// 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 https://mozilla.org/MPL/2.0/. -// SPDX-License-Identifier: MPL-2.0 - -//! Read-only files-in-place discovery. - -use std::collections::{BTreeMap, BTreeSet}; -use std::fs::{self, Metadata}; -use std::io; -use std::path::{Path, PathBuf}; -use std::time::UNIX_EPOCH; - -use chartulary::{CLASS_FACET, Container, FacetId, FacetStore}; -use serde_json::json; - -use crate::{FILE_CLASS, FILE_DOCUMENT_FACET, KnotContentClasses, NOTE_CLASS, NOTE_DOCUMENT_FACET}; - -/// A file disclosed by Knot. Its bytes remain on disk and are not carried here. -#[derive(Clone, Debug, PartialEq)] -pub struct DiskDocument { - /// Stable within a filesystem across ordinary renames. - pub id: String, - /// Shared graph vocabulary. `body` and `content` remain absent. - pub container: Container, - /// Native path used for reads and later write-through. - pub path: PathBuf, - /// Observed byte size. - pub byte_size: u64, -} - -#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] -enum FileIdentity { - #[cfg(windows)] - Windows { - volume: u32, - index: u64, - }, - #[cfg(unix)] - Unix { - device: u64, - inode: u64, - }, - Fallback(PathBuf), -} - -impl FileIdentity { - fn read(path: &Path, _metadata: &Metadata) -> io::Result { - #[cfg(windows)] - { - if let Some((volume, index)) = windows_file_identity(path)? { - return Ok(Self::Windows { volume, index }); - } - } - #[cfg(unix)] - { - use std::os::unix::fs::MetadataExt; - return Ok(Self::Unix { - device: _metadata.dev(), - inode: _metadata.ino(), - }); - } - #[allow(unreachable_code)] - Ok(Self::Fallback(fs::canonicalize(path)?)) - } - - fn stable_id(&self) -> String { - let material = format!("{self:?}"); - format!("knot:file:{}", blake3::hash(material.as_bytes()).to_hex()) - } -} - -#[cfg(windows)] -#[allow(unsafe_code)] -fn windows_file_identity(path: &Path) -> io::Result> { - use std::mem::MaybeUninit; - use std::os::windows::io::AsRawHandle; - use windows_sys::Win32::Storage::FileSystem::{ - BY_HANDLE_FILE_INFORMATION, GetFileInformationByHandle, - }; - - let file = fs::File::open(path)?; - let mut information = MaybeUninit::::uninit(); - // SAFETY: `file` owns a valid handle for the duration of the call, and - // Windows initializes the output structure when it succeeds. - let succeeded = - unsafe { GetFileInformationByHandle(file.as_raw_handle() as _, information.as_mut_ptr()) }; - if succeeded == 0 { - return Ok(None); - } - // SAFETY: the successful call initialized the structure. - let information = unsafe { information.assume_init() }; - let index = - (u64::from(information.nFileIndexHigh) << 32) | u64::from(information.nFileIndexLow); - Ok(Some((information.dwVolumeSerialNumber, index))) -} - -#[derive(Clone, Debug, PartialEq, Eq)] -struct Observation { - identity: FileIdentity, - path: PathBuf, - byte_size: u64, - modified_nanos: u128, -} - -/// Configurable names a directory scan does not enter. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct IgnorePolicy { - names: BTreeSet, - ignore_hidden: bool, -} - -impl IgnorePolicy { - /// Start with an empty policy. - pub fn none() -> Self { - Self { - names: BTreeSet::new(), - ignore_hidden: false, - } - } - - /// Ignore a file or directory with this exact name. - pub fn with_name(mut self, name: impl Into) -> Self { - self.names.insert(name.into()); - self - } - - /// Choose whether dot-prefixed names are ignored. - pub fn with_hidden(mut self, ignore: bool) -> Self { - self.ignore_hidden = ignore; - self - } - - fn ignores(&self, path: &Path) -> bool { - let Some(name) = path.file_name().and_then(|name| name.to_str()) else { - return false; - }; - self.names.contains(name) || (self.ignore_hidden && name.starts_with('.')) - } -} - -impl Default for IgnorePolicy { - fn default() -> Self { - Self::none() - .with_name(".git") - .with_name("target") - .with_name("node_modules") - .with_hidden(true) - } -} - -/// A directory index whose graph state contains references and observations, -/// never file bodies. -pub struct DirectorySource { - root: PathBuf, - ignore: IgnorePolicy, - documents: BTreeMap, - observations: BTreeMap, - facets: FacetStore, - classes: KnotContentClasses, - revision: u64, -} - -impl DirectorySource { - /// Open and scan a directory. - pub fn open(root: impl AsRef) -> io::Result { - Self::with_ignore(root, IgnorePolicy::default()) - } - - /// Open with a caller-selected ignore policy. - pub fn with_ignore(root: impl AsRef, ignore: IgnorePolicy) -> io::Result { - let root = fs::canonicalize(root)?; - if !root.is_dir() { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - format!("{} is not a directory", root.display()), - )); - } - let mut source = Self { - root, - ignore, - documents: BTreeMap::new(), - observations: BTreeMap::new(), - facets: FacetStore::new(), - classes: KnotContentClasses::new(), - revision: 0, - }; - source.refresh()?; - Ok(source) - } - - /// Canonical directory being observed. - pub fn root(&self) -> &Path { - &self.root - } - - /// Current revision, incremented only when observed disk state changes. - pub fn revision(&self) -> u64 { - self.revision - } - - /// Files ordered by stable id. - pub fn documents(&self) -> impl Iterator { - self.documents.values() - } - - pub(crate) fn document(&self, id: &str) -> Option<&DiskDocument> { - self.documents.get(id) - } - - /// Resolve one indexed document back under the configured authority root - /// and prove the current target is still a readable regular file. - pub(crate) fn readable_document_path(&self, id: &str) -> io::Result { - let document = self - .document(id) - .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "document is not indexed"))?; - let path = fs::canonicalize(&document.path)?; - if !path.starts_with(&self.root) { - return Err(io::Error::new( - io::ErrorKind::PermissionDenied, - "document resolves outside the configured Knot root", - )); - } - let metadata = fs::metadata(&path)?; - if !metadata.is_file() { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "document target is not a regular file", - )); - } - fs::File::open(&path)?; - Ok(path) - } - - /// Resolve one indexed document back under the configured authority root - /// and prove the current target can be opened for writing. - pub(crate) fn writable_document_path(&self, id: &str) -> io::Result { - let path = self.readable_document_path(id)?; - fs::OpenOptions::new().write(true).open(&path)?; - Ok(path) - } - - /// Runtime facets associated with the files. - pub fn facets(&self) -> &FacetStore { - &self.facets - } - - /// Mutable facet access for host-owned metadata. - pub fn facets_mut(&mut self) -> &mut FacetStore { - &mut self.facets - } - - /// Built-in classes and schemas. - pub fn classes(&self) -> &KnotContentClasses { - &self.classes - } - - /// Re-scan the directory. Returns whether source-visible state changed. - pub fn refresh(&mut self) -> io::Result { - let mut next = Vec::new(); - self.walk(&self.root, &mut next)?; - next.sort_by(|left, right| left.path.cmp(&right.path)); - - let next_observations = next - .iter() - .cloned() - .map(|observation| (observation.identity.clone(), observation)) - .collect::>(); - if next_observations == self.observations { - return Ok(false); - } - - let live_ids = next - .iter() - .map(|observation| observation.identity.stable_id()) - .collect::>(); - let retired_ids = self - .documents - .keys() - .filter(|id| !live_ids.contains(*id)) - .cloned() - .collect::>(); - for id in retired_ids { - self.facets.remove_node(&id); - } - - let mut documents = BTreeMap::new(); - for observation in &next { - let id = observation.identity.stable_id(); - let address = file_address(&observation.path); - let extension = observation - .path - .extension() - .and_then(|extension| extension.to_str()) - .map(str::to_ascii_lowercase); - let class = if extension.as_deref().is_some_and(is_note_extension) { - NOTE_CLASS - } else { - FILE_CLASS - }; - let title = observation - .path - .file_stem() - .or_else(|| observation.path.file_name()) - .and_then(|name| name.to_str()) - .unwrap_or("Untitled") - .to_string(); - let media_type = extension.as_deref().map(media_type_for_extension); - let mut container = Container::new(id.clone()) - .with_address(address.clone()) - .with_title(title); - container.media_type = media_type.map(str::to_string); - - self.facets - .set( - id.clone(), - FacetId::new(CLASS_FACET), - json!(class), - &self.classes.validator, - ) - .map_err(io::Error::other)?; - self.facets - .set( - id.clone(), - FacetId::new(FILE_DOCUMENT_FACET), - json!({ - "version": 1, - "address": address, - "byte_size": observation.byte_size, - "extension": extension, - }), - &self.classes.validator, - ) - .map_err(io::Error::other)?; - if class == NOTE_CLASS { - self.facets - .set( - id.clone(), - FacetId::new(NOTE_DOCUMENT_FACET), - json!({ - "version": 1, - "format": extension.as_deref().unwrap_or("text"), - }), - &self.classes.validator, - ) - .map_err(io::Error::other)?; - } else { - self.facets.remove(&id, &FacetId::new(NOTE_DOCUMENT_FACET)); - } - documents.insert( - id.clone(), - DiskDocument { - id, - container, - path: observation.path.clone(), - byte_size: observation.byte_size, - }, - ); - } - - self.documents = documents; - self.observations = next_observations; - self.revision = self.revision.saturating_add(1).max(1); - Ok(true) - } - - fn walk(&self, directory: &Path, output: &mut Vec) -> io::Result<()> { - let mut entries = fs::read_dir(directory)?.collect::, _>>()?; - entries.sort_by_key(|entry| entry.path()); - for entry in entries { - let path = entry.path(); - if self.ignore.ignores(&path) { - continue; - } - let metadata = entry.metadata()?; - if metadata.is_dir() { - self.walk(&path, output)?; - } else if metadata.is_file() { - let modified_nanos = metadata - .modified() - .ok() - .and_then(|time| time.duration_since(UNIX_EPOCH).ok()) - .map_or(0, |duration| duration.as_nanos()); - output.push(Observation { - identity: FileIdentity::read(&path, &metadata)?, - path, - byte_size: metadata.len(), - modified_nanos, - }); - } - } - Ok(()) - } -} - -fn is_note_extension(extension: &str) -> bool { - matches!(extension, "knot" | "djot" | "md" | "markdown" | "txt") -} - -fn media_type_for_extension(extension: &str) -> &'static str { - match extension { - "knot" => "text/vnd.knot", - "djot" => "text/djot", - "md" | "markdown" => "text/markdown", - "txt" => "text/plain", - "json" => "application/json", - _ => "application/octet-stream", - } -} - -fn file_address(path: &Path) -> String { - #[cfg(windows)] - { - let path = path.to_string_lossy(); - let path = path.strip_prefix(r"\\?\").unwrap_or(&path); - format!("file:///{}", path.replace('\\', "/")) - } - #[cfg(not(windows))] - { - format!("file://{}", path.to_string_lossy()) - } -} - -#[cfg(test)] -mod tests { - use std::fs; - - use chartulary::{Addressed, FacetId}; - use serde_json::json; - use tempfile::tempdir; - - use super::*; - - #[test] - fn scan_keeps_file_bytes_out_of_graph_state() { - let temp = tempdir().unwrap(); - fs::write(temp.path().join("field.knot"), "# Field note\n").unwrap(); - let source = DirectorySource::open(temp.path()).unwrap(); - let document = source.documents().next().unwrap(); - - assert!(document.container.body.is_none()); - assert!(document.container.content.is_none()); - assert_eq!( - document.container.primary_address().unwrap().scheme(), - Some("file") - ); - assert_eq!( - document.container.media_type.as_deref(), - Some("text/vnd.knot") - ); - } - - #[test] - fn rename_preserves_identity_and_host_facets() { - let temp = tempdir().unwrap(); - let before = temp.path().join("before.md"); - let after = temp.path().join("after.md"); - fs::write(&before, "same bytes").unwrap(); - let mut source = DirectorySource::open(temp.path()).unwrap(); - let id = source.documents().next().unwrap().id.clone(); - source - .facets_mut() - .set( - id.clone(), - FacetId::new("knot.test-pin"), - json!({"x": 4}), - &chartulary::AcceptAll, - ) - .unwrap(); - - fs::rename(&before, &after).unwrap(); - assert!(source.refresh().unwrap()); - - let renamed = source.documents().next().unwrap(); - assert_eq!(renamed.id, id); - assert_eq!(renamed.path, fs::canonicalize(after).unwrap()); - assert_eq!( - source.facets().get(&id, &FacetId::new("knot.test-pin")), - Some(&json!({"x": 4})) - ); - } - - #[test] - fn ignore_policy_is_configurable() { - let temp = tempdir().unwrap(); - fs::write(temp.path().join(".private.md"), "hidden").unwrap(); - fs::write(temp.path().join("visible.md"), "visible").unwrap(); - let default_source = DirectorySource::open(temp.path()).unwrap(); - assert_eq!(default_source.documents().count(), 1); - - let all = DirectorySource::with_ignore(temp.path(), IgnorePolicy::none()).unwrap(); - assert_eq!(all.documents().count(), 2); - } -} diff --git a/ports/knot/src/djot_merge.rs b/ports/knot/src/djot_merge.rs deleted file mode 100644 index 2c2027393..000000000 --- a/ports/knot/src/djot_merge.rs +++ /dev/null @@ -1,426 +0,0 @@ -// Copyright 2026 Mark Alan Boykin -// 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 https://mozilla.org/MPL/2.0/. -// SPDX-License-Identifier: MPL-2.0 - -//! Knot's three-way merge: source-preserving merge at Djot block boundaries, -//! the line merge it falls back to, and the entry point the sync projection -//! calls to settle a two-writer document without asking a person. -//! -//! Jotdown supplies byte spans for the authored source. We use those spans to -//! divide a document into stable, section-local blocks and splice exact source -//! slices from each branch. Unchanged Djot spelling and whitespace are never -//! rendered back from an AST. -//! -//! Only [`automatic_text_merge`] reads operation history. Everything below it -//! merges plain text and stays clear of p2panda and stickleback, so the merge -//! rules can be exercised without a store. - -use std::collections::BTreeMap; - -use jotdown::{AttributeKind, Attributes, Container, Event, Parser}; -use p2panda_core::cbor::encode_cbor; -use similar::{Algorithm, TextDiff}; -use stickleback::CausalIndex; - -use crate::{KnotAutomaticTextMerge, KnotDocumentVersion, KnotSyncError, VaultDocument}; - -#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] -struct BlockKey { - section: Vec, - identity: String, -} - -#[derive(Clone, Debug)] -struct Block<'a> { - key: BlockKey, - source: &'a str, -} - -#[derive(Debug)] -struct PendingBlock { - key: BlockKey, - depth: usize, - start: usize, - end: usize, -} - -/// Merge two Djot branches while preserving the exact source spelling chosen -/// for every structural block. -/// -/// The first cut deliberately requires the same block identities and order in -/// all three versions. Insertions, deletions, moves, and edited headings fall -/// back to Knot's existing line merge or an explicit conflict. That is safer -/// than guessing identity when Djot has not supplied one. -pub(crate) fn merge_djot_sources(base: &str, left: &str, right: &str) -> Option { - if left == right { - return Some(left.to_owned()); - } - if left == base { - return Some(right.to_owned()); - } - if right == base { - return Some(left.to_owned()); - } - - let base = blocks(base)?; - let left = blocks(left)?; - let right = blocks(right)?; - if base.len() < 2 - || base - .iter() - .map(|block| &block.key) - .ne(left.iter().map(|block| &block.key)) - || base - .iter() - .map(|block| &block.key) - .ne(right.iter().map(|block| &block.key)) - { - return None; - } - - let mut output = String::new(); - for ((base, left), right) in base.iter().zip(&left).zip(&right) { - let source = if left.source == right.source { - left.source.to_owned() - } else if left.source == base.source { - right.source.to_owned() - } else if right.source == base.source { - left.source.to_owned() - } else { - merge_text_lines(base.source, left.source, right.source)? - }; - output.push_str(&source); - } - Some(output) -} - -fn blocks(source: &str) -> Option>> { - let mut sections = Vec::new(); - let mut stack = Vec::new(); - let mut ordinals: BTreeMap<(Vec, String), usize> = BTreeMap::new(); - let mut pending: Option = None; - let mut raw = Vec::new(); - - for (event, range) in Parser::new(source).into_offset_iter() { - if let Some(block) = &mut pending { - block.start = block.start.min(range.start); - block.end = block.end.max(range.end); - } - - match event { - Event::Start(container, attributes) => { - let parent_is_document_or_section = stack - .last() - .is_some_and(|parent| is_document_or_section(parent)); - if let Container::Section { id } = &container { - sections.push(id.to_string()); - } else if parent_is_document_or_section && is_merge_block(&container) { - if pending.is_some() { - return None; - } - let kind = container_kind(&container); - let explicit = explicit_id(&attributes); - let ordinal = ordinals - .entry((sections.clone(), kind.clone())) - .and_modify(|ordinal| *ordinal += 1) - .or_insert(0); - let identity = explicit - .map(|id| format!("id:{id}")) - .unwrap_or_else(|| format!("{kind}:{ordinal}")); - pending = Some(PendingBlock { - key: BlockKey { - section: sections.clone(), - identity, - }, - depth: stack.len() + 1, - start: range.start, - end: range.end, - }); - } - stack.push(container); - } - Event::End(container) => { - if pending - .as_ref() - .is_some_and(|block| block.depth == stack.len()) - { - raw.push(pending.take()?); - } - let opened = stack.pop()?; - if opened != container { - return None; - } - if matches!(container, Container::Section { .. }) { - sections.pop()?; - } - } - _ => {} - } - } - if pending.is_some() || !stack.is_empty() || raw.is_empty() { - return None; - } - - // Make the block spans a complete partition. Whitespace and unattached - // attributes between parser events stay attached to the preceding block. - raw[0].start = 0; - for index in 0..raw.len().saturating_sub(1) { - raw[index].end = raw[index + 1].start; - } - raw.last_mut()?.end = source.len(); - if raw - .windows(2) - .any(|pair| pair[0].start > pair[0].end || pair[0].end > pair[1].start) - || raw.last().is_some_and(|block| block.start > block.end) - { - return None; - } - - Some( - raw.into_iter() - .map(|block| Block { - key: block.key, - source: &source[block.start..block.end], - }) - .collect(), - ) -} - -fn explicit_id(attributes: &Attributes<'_>) -> Option { - attributes - .iter() - .find_map(|(kind, value)| matches!(kind, AttributeKind::Id).then(|| value.to_string())) -} - -fn is_document_or_section(container: &Container<'_>) -> bool { - matches!(container, Container::Document | Container::Section { .. }) -} - -fn is_merge_block(container: &Container<'_>) -> bool { - !matches!(container, Container::Document | Container::Section { .. }) -} - -fn container_kind(container: &Container<'_>) -> String { - let debug = format!("{container:?}"); - debug - .split([' ', '{', '(']) - .next() - .unwrap_or(&debug) - .to_owned() -} - -/// Settle a two-writer document from its own causal history, or decline. -/// -/// This is the only place in the merge subsystem that reads operation history: -/// the common ancestor has to be an operation for this document that both -/// current versions descend from, and `causal` answers that reachability -/// question. The caller owns the index so the walk below is not paying to -/// rebuild it per history entry. -pub(crate) fn automatic_text_merge( - causal: &CausalIndex<'_, u64>, - history: &[([u8; 32], String, Option)], - id: &str, - versions: &BTreeMap<[u8; 32], KnotDocumentVersion>, -) -> Option { - if versions.len() != 2 { - return None; - } - let versions: Vec<_> = versions.values().collect(); - let left = versions[0].document.as_ref()?; - let right = versions[1].document.as_ref()?; - let (base_operation, base) = - history - .iter() - .rev() - .find_map(|(operation, event_id, document)| { - let document = document.as_ref()?; - (event_id == id - && causal.happens_before(*operation, versions[0].operation) - && causal.happens_before(*operation, versions[1].operation)) - .then_some((*operation, document)) - })?; - let document = merge_text_document(base, left, right)?; - let mut supersedes = vec![versions[0].operation, versions[1].operation]; - supersedes.sort_unstable(); - Some(KnotAutomaticTextMerge { - id: id.into(), - base: base_operation, - supersedes, - document, - }) -} - -fn merge_text_document( - base: &VaultDocument, - left: &VaultDocument, - right: &VaultDocument, -) -> Option { - if base.id != left.id || base.id != right.id { - return None; - } - let title = merge_scalar(&base.title, &left.title, &right.title)?; - let media_type = merge_scalar(&base.media_type, &left.media_type, &right.media_type)?; - if !media_type.starts_with("text/") { - return None; - } - let base_body = std::str::from_utf8(&base.body).ok()?; - let left_body = std::str::from_utf8(&left.body).ok()?; - let right_body = std::str::from_utf8(&right.body).ok()?; - let body = if matches!(media_type.as_str(), "text/djot" | "text/vnd.knot") { - merge_djot_sources(base_body, left_body, right_body) - .or_else(|| merge_text_lines(base_body, left_body, right_body))? - } else { - merge_text_lines(base_body, left_body, right_body)? - } - .into_bytes(); - Some(VaultDocument { - id: base.id.clone(), - title, - body, - media_type, - }) -} - -fn merge_scalar(base: &T, left: &T, right: &T) -> Option { - if left == right { - Some(left.clone()) - } else if left == base { - Some(right.clone()) - } else if right == base { - Some(left.clone()) - } else { - None - } -} - -#[derive(Clone, Debug, PartialEq, Eq)] -struct LineEdit { - start: usize, - end: usize, - replacement: Vec, -} - -fn line_edits(base: &[&str], branch: &[&str]) -> Vec { - TextDiff::configure() - .algorithm(Algorithm::Myers) - .diff_slices(base, branch) - .ops() - .iter() - .filter_map(|operation| { - let old = operation.old_range(); - let new = operation.new_range(); - (old.len() != new.len() || base[old.clone()] != branch[new.clone()]).then(|| LineEdit { - start: old.start, - end: old.end, - replacement: branch[new].iter().map(|line| (*line).to_owned()).collect(), - }) - }) - .collect() -} - -fn merge_text_lines(base: &str, left: &str, right: &str) -> Option { - if left == right { - return Some(left.into()); - } - if left == base { - return Some(right.into()); - } - if right == base { - return Some(left.into()); - } - let base: Vec<_> = base.split_inclusive('\n').collect(); - let left: Vec<_> = left.split_inclusive('\n').collect(); - let right: Vec<_> = right.split_inclusive('\n').collect(); - let left_edits = line_edits(&base, &left); - let right_edits = line_edits(&base, &right); - for left in &left_edits { - for right in &right_edits { - if line_edits_conflict(left, right) { - return None; - } - } - } - let mut edits = left_edits; - edits.extend(right_edits); - edits.sort_by(|left, right| { - (left.start, left.end, &left.replacement).cmp(&(right.start, right.end, &right.replacement)) - }); - edits.dedup(); - - let mut output = String::new(); - let mut cursor = 0; - for edit in edits { - if edit.start < cursor { - return None; - } - output.extend(base[cursor..edit.start].iter().copied()); - output.extend(edit.replacement.iter().map(String::as_str)); - cursor = edit.end; - } - output.extend(base[cursor..].iter().copied()); - Some(output) -} - -fn line_edits_conflict(left: &LineEdit, right: &LineEdit) -> bool { - if left == right { - return false; - } - let left_insert = left.start == left.end; - let right_insert = right.start == right.end; - match (left_insert, right_insert) { - (true, true) => left.start == right.start, - (true, false) => left.start > right.start && left.start < right.end, - (false, true) => right.start > left.start && right.start < left.end, - (false, false) => left.start.max(right.start) < left.end.min(right.end), - } -} - -pub(crate) fn automatic_text_merge_head( - base: [u8; 32], - supersedes: &[[u8; 32]], - document: &VaultDocument, -) -> Result<[u8; 32], KnotSyncError> { - let bytes = encode_cbor(&(base, supersedes, document)) - .map_err(|error| KnotSyncError::Payload(error.to_string()))?; - let mut hasher = blake3::Hasher::new(); - hasher.update(b"mere.knot.automatic-text-merge.v1"); - hasher.update(&bytes); - Ok(*hasher.finalize().as_bytes()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn merges_edits_to_adjacent_paragraphs_in_one_section() { - let base = "# Field\n\nFirst paragraph.\n\nSecond paragraph.\n"; - let left = "# Field\n\nFirst paragraph, revised left.\n\nSecond paragraph.\n"; - let right = "# Field\n\nFirst paragraph.\n\nSecond paragraph, revised right.\n"; - assert_eq!( - merge_djot_sources(base, left, right).as_deref(), - Some("# Field\n\nFirst paragraph, revised left.\n\nSecond paragraph, revised right.\n") - ); - } - - #[test] - fn preserves_djot_attributes_and_authored_spacing() { - let base = "# Field\n\n{#one}\nFirst.\n\n{#two}\nSecond.\n"; - let left = "# Field\n\n{#one}\n*First*, left.\n\n{#two}\nSecond.\n"; - let right = "# Field\n\n{#one}\nFirst.\n\n{#two}\nSecond, right.\n"; - assert_eq!( - merge_djot_sources(base, left, right).as_deref(), - Some("# Field\n\n{#one}\n*First*, left.\n\n{#two}\nSecond, right.\n") - ); - } - - #[test] - fn refuses_ambiguous_structure_changes() { - let base = "# Field\n\nFirst.\n\nSecond.\n"; - let left = "# Field\n\nInserted.\n\nFirst.\n\nSecond.\n"; - let right = "# Field\n\nFirst.\n\nSecond revised.\n"; - assert_eq!(merge_djot_sources(base, left, right), None); - } -} diff --git a/ports/knot/src/endpoint.rs b/ports/knot/src/endpoint.rs deleted file mode 100644 index 05f641792..000000000 --- a/ports/knot/src/endpoint.rs +++ /dev/null @@ -1,4151 +0,0 @@ -// Copyright 2026 Mark Alan Boykin -// 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 https://mozilla.org/MPL/2.0/. -// SPDX-License-Identifier: MPL-2.0 - -//! Graphshell disclosure for Knot directory state. - -use std::collections::{BTreeMap, BTreeSet}; -use std::fs; -use std::io; -use std::path::Path; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::{Arc, Mutex, MutexGuard}; - -use chartulary::{Addressed, Labeled}; -use chirograph::{ - AdvertisedAction, BoundsRelationship, CachePolicy, CardValueV1, CarrierNotice, ContentHash, - DerivedCacheInfoV1, DerivedTextV1, EDITABLE_TEXT_SAVE_INTENT, EDITABLE_TEXT_SAVE_SCHEMA, - EditableTextV1, EndpointDescriptor, InsertKnotClipV1, InsertKnotClipV2, IntentEffect, - IntentInvocation, IntentReference, IntentResult, KNOT_BLOCK_RUN_INTENT, KNOT_BLOCK_RUN_SCHEMA, - KNOT_CLIP_INSERT_INTENT, KNOT_CLIP_INSERT_SCHEMA, KNOT_CLIP_INSERT_SCHEMA_V2, - KNOT_TRANSCLUSION_RESOLVE_INTENT, KNOT_TRANSCLUSION_RESOLVE_SCHEMA, KnotClipArtifactRoleV1, - KnotClipArtifactV1, KnotClipSelectorV1, KnotEffectV1, NativeGlyphV1, PortableCardV1, - PresentationBinding, PresentationCapability, PresentationCodec, PresentationKey, - PresentationManifest, PresentationOffer, PresentationSemantics, ProjectionAck, ProjectionOffer, - ProjectionRequest, ProjectionSession, ProjectionSnapshot, ProtocolVersion, ResourceRequest, - ResourceResponse, ResumeReply, ResumeRequest, SaveTextV1, SemanticRole, TextEncoding, -}; -use graphshell_endpoint::{ - IntentSink, PresentationSource, ProjectionCatalog, ProjectionNoticeSource, ProjectionSource, - ResumableProjectionSource, -}; -use inker::{ - BlockEvaluators, DocumentTrustState, Engine, EngineDocument, EngineInput, EvaluationPolicy, - Fetched, TransclusionPolicy, evaluate_blocks, resolve_transclusions, -}; -use personae::{IdentityProvider, InMemoryProvider}; -use sceno::{ - Arrangement, Footprint, InstanceId, ProjectedItem, Rect, Representation, Scene, Score, Size2, - SourceRef, Transform2, Vec2, -}; -use scenotime::{Revision, SceneEpoch, SceneSnapshot}; -use serde::{Deserialize, Serialize}; -use stickleback::{DataKeyring, GroupCiphertext, GroupSecretId}; -use zeroize::Zeroizing; - -use crate::{ - CmudictPronunciations, DirectorySource, DirectoryWatcher, DiskDocument, DocumentFormat, - KnotClipEvidenceRef, KnotClipEvidenceStore, KnotContentRetentionPort, KnotDocumentProjection, - KnotSyncEvent, KnotSyncFileStore, KnotVault, RosetteConfig, RosetteInteriorKind, VaultDocument, - project_rosette, -}; - -const FIXTURE_SESSION: &str = "loopback:knot:k0"; -const SOURCE_KIND: &str = "knot.file"; -const FILE_TOKEN_CONTEXT: &str = "mere.knot.file-base-token.v1"; -const VAULT_TOKEN_CONTEXT: &str = "mere.knot.vault-base-token.v1"; - -/// Host-selected limits and geometry for Knot's Rosette projections. -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct KnotRosetteConfig { - /// Scene geometry used by every document-scoped Rosette from this endpoint. - pub geometry: RosetteConfig, - /// Largest authored UTF-8 document the endpoint will disclose as a Rosette. - pub max_source_bytes: u64, -} - -impl Default for KnotRosetteConfig { - fn default() -> Self { - Self { - geometry: RosetteConfig::default(), - max_source_bytes: 2 * 1024 * 1024, - } - } -} - -/// Authority injected into one endpoint session after its caller has been -/// admitted. Keeping this separate from `IntentInvocation` prevents payloads -/// from claiming their own grant. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct KnotWriteGrant { - pub max_source_bytes: u64, -} - -impl KnotWriteGrant { - pub const fn new(max_source_bytes: u64) -> Self { - Self { max_source_bytes } - } -} - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub enum KnotEffectMode { - Auto, - Ask, - #[default] - Never, -} - -/// User settings and hard limits for Knot's derived document effects. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct KnotEffectPolicy { - pub resolve: KnotEffectMode, - pub run: KnotEffectMode, - pub allowed_schemes: Vec, - pub allowed_languages: Vec, - pub max_depth: u8, - pub max_ops: u64, -} - -impl Default for KnotEffectPolicy { - fn default() -> Self { - Self { - resolve: KnotEffectMode::Never, - run: KnotEffectMode::Never, - allowed_schemes: Vec::new(), - allowed_languages: Vec::new(), - max_depth: 1, - max_ops: 100_000, - } - } -} - -/// Fetch authority injected by the endpoint host. Implementations own any -/// path, network, or vault checks before returning source bytes. -pub trait KnotEffectFetcher: Send { - fn fetch(&mut self, address: &str) -> Result; - - /// Stable implementation identity bound into reusable derived cache - /// entries. Providers with behavior-affecting configuration must include - /// it here and change the value when their interpretation changes. - fn cache_version(&self) -> String { - std::any::type_name::().to_string() - } -} - -/// Effect capabilities admitted for one endpoint process. -pub struct KnotEffectAuthority { - policy: KnotEffectPolicy, - fetcher: Option>, - evaluators: BlockEvaluators, -} - -impl KnotEffectAuthority { - pub fn new(policy: KnotEffectPolicy) -> Self { - Self { - policy, - fetcher: None, - evaluators: BlockEvaluators::new(), - } - } - - pub fn with_fetcher(mut self, fetcher: impl KnotEffectFetcher + 'static) -> Self { - self.fetcher = Some(Box::new(fetcher)); - self - } - - pub fn register_evaluator(mut self, evaluator: impl inker::BlockEvaluator + 'static) -> Self { - self.evaluators.register(Box::new(evaluator)); - self - } -} - -enum Source { - Directory { - source: DirectorySource, - watcher: Box, - }, - Fixture(Vec), - Vault(KnotResidentSource), -} - -/// One opened Knot vault authority shared by every admitted local session. -/// -/// The resident owns source truth and serializes its mutations. Each -/// [`KnotEndpoint`] created from it keeps its own disclosure caches, revision -/// cursor, effects, and Graphshell session id. -#[derive(Clone)] -pub struct KnotResidentSource { - inner: Arc, -} - -struct ResidentVault { - retention: Mutex>, - state: Mutex, - session_prefix: String, - next_session: AtomicU64, -} - -struct VaultSource { - vault: KnotVault, - sync: Option, - conflicts: BTreeSet, - document_heads: BTreeMap, - pending_history: bool, -} - -enum VaultSyncAuthority { - Personal { - store: KnotSyncFileStore, - signing_seed: Zeroizing<[u8; 32]>, - }, - Commons { - store: KnotSyncFileStore, - signing_seed: Zeroizing<[u8; 32]>, - keys: DataKeyring, - }, -} - -impl KnotResidentSource { - fn new(state: VaultSource) -> Self { - let digest = blake3::hash(state.vault.root().to_string_lossy().as_bytes()); - Self { - inner: Arc::new(ResidentVault { - retention: Mutex::new(None), - state: Mutex::new(state), - session_prefix: format!("knot:vault:{}", &digest.to_hex()[..16]), - next_session: AtomicU64::new(1), - }), - } - } - - /// Open one read-only resident source around an already-unlocked vault. - pub fn from_vault(vault: KnotVault) -> Self { - Self::new(VaultSource { - vault, - sync: None, - conflicts: BTreeSet::new(), - document_heads: BTreeMap::new(), - pending_history: false, - }) - } - - /// Open one personal signed source authority. - pub fn from_synced_vault( - vault: KnotVault, - store: KnotSyncFileStore, - signing_seed: [u8; 32], - ) -> Result { - let projection = pollster::block_on(store.projection(&vault)) - .map_err(|error| format!("could not project Knot sync store: {error}"))?; - let mut state = VaultSource { - vault, - sync: Some(VaultSyncAuthority::Personal { - store, - signing_seed: Zeroizing::new(signing_seed), - }), - conflicts: BTreeSet::new(), - document_heads: BTreeMap::new(), - pending_history: false, - }; - state.install_projection(projection)?; - Ok(Self::new(state)) - } - - /// Open one Commons signed source authority. - pub fn from_communal_vault( - vault: KnotVault, - store: KnotSyncFileStore, - signing_seed: [u8; 32], - keys: DataKeyring, - ) -> Result { - let projection = pollster::block_on(store.communal_projection(&keys)) - .map_err(|error| format!("could not project Commons Knot store: {error}"))?; - let mut state = VaultSource { - vault, - sync: Some(VaultSyncAuthority::Commons { - store, - signing_seed: Zeroizing::new(signing_seed), - keys, - }), - conflicts: BTreeSet::new(), - document_heads: BTreeMap::new(), - pending_history: false, - }; - state.install_projection(projection)?; - Ok(Self::new(state)) - } - - /// Create an independently revisioned Graphshell session over this source. - pub fn session(&self, write_grant: Option) -> KnotEndpoint { - let sequence = self.inner.next_session.fetch_add(1, Ordering::Relaxed); - KnotEndpoint::from_resident_source( - self.clone(), - ProjectionSession(format!("{}:session:{sequence}", self.inner.session_prefix)), - write_grant, - ) - } - - /// Clone the one signed operation store for a resident sync host. - pub fn sync_store(&self) -> Option { - let state = self.state(); - match &state.sync { - Some(VaultSyncAuthority::Personal { store, .. }) - | Some(VaultSyncAuthority::Commons { store, .. }) => Some(store.clone()), - None => None, - } - } - - /// Install the one source-owned evidence service cloned into later - /// sessions and shared with the resident sync host. - pub fn grant_content_retention(&self, port: KnotContentRetentionPort) { - *self - .inner - .retention - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(port); - } - - /// Remove source-owned evidence authority for later sessions. - pub fn revoke_content_retention(&self) -> bool { - self.inner - .retention - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .take() - .is_some() - } - - /// Shared Murm blob handle for the resident sync host. - pub fn content_blob_store(&self) -> Option> { - self.content_retention() - .map(|retention| retention.blob_store()) - } - - /// Portable evidence references currently named by resident documents. - /// - /// A sync host replays this at startup to restore serving custody for - /// bytes retained on an earlier run. The Djot source remains the authority - /// for which hashes belong to the Knot space. - pub fn retained_evidence_references(&self) -> Result, String> { - let state = self.state(); - let mut references = Vec::new(); - for document in state.vault.documents() { - references.extend(crate::clip_evidence_references(&document.body)?); - } - references.sort_by(|left, right| left.digest.cmp(&right.digest)); - references.dedup_by(|left, right| left.digest == right.digest); - Ok(references) - } - - fn content_retention(&self) -> Option { - self.inner - .retention - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .clone() - } - - fn state(&self) -> MutexGuard<'_, VaultSource> { - self.inner - .state - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - } -} - -impl VaultSource { - fn install_projection(&mut self, projection: KnotDocumentProjection) -> Result<(), String> { - self.conflicts = projection - .conflicts - .iter() - .map(|conflict| conflict.id.clone()) - .collect(); - self.document_heads = projection.document_heads; - self.pending_history = !projection.pending.is_empty(); - self.vault.replace_projection(projection.documents)?; - Ok(()) - } - - fn refresh_projection(&mut self) -> Result<(), String> { - let projection = match &self.sync { - Some(VaultSyncAuthority::Personal { store, .. }) => Some( - pollster::block_on(store.projection(&self.vault)) - .map_err(|error| format!("could not project Knot sync store: {error}"))?, - ), - Some(VaultSyncAuthority::Commons { store, keys, .. }) => Some( - pollster::block_on(store.communal_projection(keys)) - .map_err(|error| format!("could not project Commons Knot store: {error}"))?, - ), - None => None, - }; - if let Some(projection) = projection { - self.install_projection(projection)?; - } - Ok(()) - } -} - -#[derive(Clone)] -struct PresentedDocument { - id: String, - container: chartulary::Container, - byte_size: u64, -} - -/// Fixed three-column card grid for the directory projection. Placement and the -/// scene bounds are derived from the same numbers here so the two cannot drift. -struct CardGrid { - columns: usize, - card: Size2, - step_x: f32, - step_y: f32, -} - -impl CardGrid { - /// Where the first card sits. The scene bounds begin above and to the left - /// of it, leaving the margin the host draws its chrome into. - const ORIGIN: (f32, f32) = (156.0, 146.0); - - fn new() -> Self { - Self { - columns: 3, - card: Size2::new(248.0, 168.0), - step_x: 298.0, - step_y: 218.0, - } - } - - fn placement(&self, index: usize) -> Transform2 { - Transform2::translation( - Self::ORIGIN.0 + (index % self.columns) as f32 * self.step_x, - Self::ORIGIN.1 + (index / self.columns) as f32 * self.step_y, - ) - } - - /// Bounds stay a full `columns` wide even when fewer documents are present, - /// so the viewport does not reflow horizontally as documents come and go. - fn bounds(&self, count: usize) -> Rect { - let rows = count.div_ceil(self.columns).max(1); - Rect::new( - Vec2::new(32.0, 62.0), - Size2::new( - self.card.w + self.step_x * self.columns.saturating_sub(1) as f32, - self.card.h + self.step_y * rows.saturating_sub(1) as f32, - ), - ) - } -} - -struct DerivedDocument { - base_token: Vec, - document: EngineDocument, - summary: String, - cache: Option, -} - -#[derive(Clone)] -struct CacheAttribution { - info: DerivedCacheInfoV1, - epoch: Option, -} - -#[derive(Clone, Serialize, Deserialize)] -struct DerivedCacheRecord { - version: u64, - document_id: String, - base_token: Vec, - document: EngineDocument, - summary: String, - info: DerivedCacheInfoV1, -} - -#[derive(Serialize, Deserialize)] -enum StoredDerivedCache { - Personal(DerivedCacheRecord), - Commons(GroupCiphertext), -} - -const DERIVED_CACHE_RECORD_VERSION: u64 = 1; - -/// Knot's read-only Graphshell endpoint. -pub struct KnotEndpoint { - // Drop the session's retention handle before its resident source clone so - // the source can join the final blob actor before dropping vault stores. - clip_evidence: Option>, - source: Source, - session: ProjectionSession, - write_grant: Option, - snapshot: Option, - resources: BTreeMap>, - bindings: BTreeMap, - protocol_version: ProtocolVersion, - last_announced: Option, - observed_source_revision: Option, - scene_revision: Revision, - effects: Option, - derived: BTreeMap, - rosette_config: KnotRosetteConfig, - rosette_snapshots: BTreeMap, - rosette_resources: BTreeMap>>, - rosette_last_announced: BTreeMap, - rosette_document_ids: BTreeMap, -} - -impl KnotEndpoint { - /// Serve a real directory. The session id is derived from its canonical path. - pub fn open(root: impl AsRef) -> io::Result { - Self::open_with_identity(root, &InMemoryProvider::random()) - } - - /// Serve a real directory with one explicitly injected write grant. - pub fn open_writable(root: impl AsRef, grant: KnotWriteGrant) -> io::Result { - Self::open_writable_with_identity(root, &InMemoryProvider::random(), grant) - } - - /// Serve a real directory with the watcher key derived from `identity`. - pub fn open_with_identity( - root: impl AsRef, - identity: &impl IdentityProvider, - ) -> io::Result { - Self::open_directory(root, identity, None) - } - - pub fn open_writable_with_identity( - root: impl AsRef, - identity: &impl IdentityProvider, - grant: KnotWriteGrant, - ) -> io::Result { - Self::open_directory(root, identity, Some(grant)) - } - - fn open_directory( - root: impl AsRef, - identity: &impl IdentityProvider, - write_grant: Option, - ) -> io::Result { - let source = DirectorySource::open(root)?; - let watcher = DirectoryWatcher::new(source.root(), identity).map_err(io::Error::other)?; - let digest = blake3::hash(source.root().to_string_lossy().as_bytes()); - Ok(Self { - source: Source::Directory { - source, - watcher: Box::new(watcher), - }, - session: ProjectionSession(format!("knot:directory:{}", &digest.to_hex()[..16])), - write_grant, - snapshot: None, - resources: BTreeMap::new(), - bindings: BTreeMap::new(), - protocol_version: ProtocolVersion::V1, - last_announced: None, - observed_source_revision: None, - scene_revision: Revision(0), - effects: None, - clip_evidence: None, - derived: BTreeMap::new(), - rosette_config: KnotRosetteConfig::default(), - rosette_snapshots: BTreeMap::new(), - rosette_resources: BTreeMap::new(), - rosette_last_announced: BTreeMap::new(), - rosette_document_ids: BTreeMap::new(), - }) - } - - /// Deterministic fixed disclosure used by K0's process receipt. - pub fn fixture() -> Self { - use chartulary::Container; - use std::path::PathBuf; - - let documents = [ - ( - "field-notes", - "Field notes", - "file:///fixture/field-notes.knot", - 184, - ), - ( - "reading-list", - "Reading list", - "file:///fixture/reading-list.md", - 96, - ), - ("sources", "Sources", "file:///fixture/sources.json", 412), - ] - .into_iter() - .map(|(id, title, address, byte_size)| { - let mut container = Container::new(format!("knot:fixture:{id}")) - .with_address(address) - .with_title(title); - container.media_type = Some( - if address.ends_with(".json") { - "application/json" - } else { - "text/markdown" - } - .into(), - ); - DiskDocument { - id: container.id.clone(), - container, - path: PathBuf::from(address), - byte_size, - } - }) - .collect(); - Self { - source: Source::Fixture(documents), - session: ProjectionSession(FIXTURE_SESSION.into()), - write_grant: None, - snapshot: None, - resources: BTreeMap::new(), - bindings: BTreeMap::new(), - protocol_version: ProtocolVersion::V1, - last_announced: None, - observed_source_revision: None, - scene_revision: Revision(0), - effects: None, - clip_evidence: None, - derived: BTreeMap::new(), - rosette_config: KnotRosetteConfig::default(), - rosette_snapshots: BTreeMap::new(), - rosette_resources: BTreeMap::new(), - rosette_last_announced: BTreeMap::new(), - rosette_document_ids: BTreeMap::new(), - } - } - - /// Serve one unlocked sealed vault read-only. - pub fn from_vault(vault: KnotVault) -> Self { - KnotResidentSource::from_vault(vault).session(None) - } - - fn from_resident_source( - source: KnotResidentSource, - session: ProjectionSession, - write_grant: Option, - ) -> Self { - let clip_evidence = source - .content_retention() - .map(|retention| Box::new(retention) as Box); - Self { - source: Source::Vault(source), - session, - write_grant, - snapshot: None, - resources: BTreeMap::new(), - bindings: BTreeMap::new(), - protocol_version: ProtocolVersion::V1, - last_announced: None, - observed_source_revision: None, - scene_revision: Revision(0), - effects: None, - clip_evidence, - derived: BTreeMap::new(), - rosette_config: KnotRosetteConfig::default(), - rosette_snapshots: BTreeMap::new(), - rosette_resources: BTreeMap::new(), - rosette_last_announced: BTreeMap::new(), - rosette_document_ids: BTreeMap::new(), - } - } - - /// Serve a personal sealed vault whose recorded truth is a signed Knot - /// sync log. The sealed vault index is rematerialized from that log. - pub fn from_synced_vault( - vault: KnotVault, - store: KnotSyncFileStore, - signing_seed: [u8; 32], - grant: KnotWriteGrant, - ) -> Result { - Ok(KnotResidentSource::from_synced_vault(vault, store, signing_seed)?.session(Some(grant))) - } - - /// Serve a Commons-backed vault using the group's retained data epochs. - pub fn from_communal_vault( - vault: KnotVault, - store: KnotSyncFileStore, - signing_seed: [u8; 32], - keys: DataKeyring, - grant: KnotWriteGrant, - ) -> Result { - Ok( - KnotResidentSource::from_communal_vault(vault, store, signing_seed, keys)? - .session(Some(grant)), - ) - } - - /// The opaque Graphshell session. - pub fn session(&self) -> &ProjectionSession { - &self.session - } - - /// Configure the Rosette scene before serving projection requests. - pub fn with_rosette_config(mut self, config: KnotRosetteConfig) -> Self { - self.rosette_config = config; - self - } - - /// The host-selected Rosette configuration. - pub fn rosette_config(&self) -> KnotRosetteConfig { - self.rosette_config - } - - /// Access the directory source when this endpoint owns one. - pub fn directory(&self) -> Option<&DirectorySource> { - match &self.source { - Source::Directory { source, .. } => Some(source), - Source::Fixture(_) | Source::Vault(_) => None, - } - } - - /// Revoke the directory watcher grant. The endpoint keeps serving its last - /// accepted revision. - pub fn revoke_watcher(&mut self) -> bool { - let Source::Directory { watcher, .. } = &mut self.source else { - return false; - }; - watcher.revoke(); - true - } - - /// Restore the directory watcher grant. - pub fn grant_watcher(&mut self) -> bool { - let Source::Directory { watcher, .. } = &mut self.source else { - return false; - }; - watcher.grant(); - true - } - - /// The watcher's attributed journal when this is a directory endpoint. - pub fn watcher_audit( - &self, - ) -> Option<&chartulary::GraphLog> { - match &self.source { - Source::Directory { watcher, .. } => Some(watcher.audit()), - Source::Fixture(_) | Source::Vault(_) => None, - } - } - - pub fn revoke_writes(&mut self) -> bool { - let had_grant = self.write_grant.take().is_some(); - if had_grant { - self.effects = None; - self.derived.clear(); - self.snapshot = None; - self.resources.clear(); - self.bindings.clear(); - } - had_grant - } - - pub fn grant_writes(&mut self, grant: KnotWriteGrant) { - self.write_grant = Some(grant); - self.derived.clear(); - if self.effects.is_some() && self.restore_derived_caches().is_err() { - self.derived.clear(); - } - self.snapshot = None; - self.resources.clear(); - self.bindings.clear(); - } - - pub fn grant_effects(&mut self, authority: KnotEffectAuthority) { - self.effects = Some(authority); - self.derived.clear(); - if self.restore_derived_caches().is_err() { - // A cache is never authority. Corruption, a missing epoch, or an - // incompatible record degrades to a miss. - self.derived.clear(); - } - self.snapshot = None; - self.resources.clear(); - self.bindings.clear(); - } - - pub fn revoke_effects(&mut self) -> bool { - let had_authority = self.effects.take().is_some(); - if had_authority { - self.derived.clear(); - self.snapshot = None; - self.resources.clear(); - self.bindings.clear(); - } - had_authority - } - - /// Lock a vault endpoint, dropping its key and decrypted documents. - pub fn lock_vault(&mut self) -> bool { - let Source::Vault(resident) = &self.source else { - return false; - }; - resident.state().vault.lock(); - self.derived.clear(); - self.snapshot = None; - self.resources.clear(); - self.bindings.clear(); - self.clear_rosette_projections(); - true - } - - /// Unlock a vault endpoint with a recovered root key. - pub fn unlock_vault(&mut self, key: [u8; 32]) -> Result { - let Source::Vault(resident) = &self.source else { - return Ok(false); - }; - { - let mut source = resident.state(); - source.vault.unlock(key)?; - source.refresh_projection()?; - } - if self.effects.is_some() && self.restore_derived_caches().is_err() { - self.derived.clear(); - } - Ok(true) - } - - /// Replace the Commons data-key view after the admitted membership layer - /// rotates or prunes epochs. Knot owns the keys after handoff and drops - /// every disclosed/derived resource before re-projecting under them. - pub fn replace_communal_keys(&mut self, keys: DataKeyring) -> Result { - let changed = { - let Source::Vault(resident) = &self.source else { - return Ok(false); - }; - let mut source = resident.state(); - let Some(VaultSyncAuthority::Commons { keys: current, .. }) = &mut source.sync else { - return Ok(false); - }; - let changed = current.epoch_ids() != keys.epoch_ids() - || current.current_epoch() != keys.current_epoch(); - *current = keys; - changed - }; - if changed { - self.derived.clear(); - self.snapshot = None; - self.resources.clear(); - self.bindings.clear(); - self.clear_rosette_projections(); - self.refresh_vault_projection()?; - self.sync_source_revision(); - } - Ok(changed) - } - - fn refresh(&mut self) -> Result<(), String> { - if let Source::Directory { source, watcher } = &mut self.source { - watcher.drain()?; - if !watcher.is_enabled() { - return Ok(()); - } - source - .refresh() - .map_err(|error| format!("directory refresh failed: {error}"))?; - } - if let Source::Vault(resident) = &self.source { - let mut source = resident.state(); - if source.sync.is_some() && !source.vault.is_locked() { - source.refresh_projection()?; - } - } - self.sync_source_revision(); - Ok(()) - } - - fn documents(&self) -> Vec { - match &self.source { - Source::Directory { source, .. } => source - .documents() - .map(|document| PresentedDocument { - id: document.id.clone(), - container: document.container.clone(), - byte_size: document.byte_size, - }) - .collect(), - Source::Fixture(documents) => documents - .iter() - .map(|document| PresentedDocument { - id: document.id.clone(), - container: document.container.clone(), - byte_size: document.byte_size, - }) - .collect(), - Source::Vault(source) => { - let source = source.state(); - let mut documents = source - .vault - .documents() - .map(|document| { - let mut container = - chartulary::Container::new(format!("knot:vault:{}", document.id)) - .with_address(format!("knot://vault/{}", document.id)) - .with_title(document.title.clone()); - container.media_type = Some(document.media_type.clone()); - PresentedDocument { - id: container.id.clone(), - container, - byte_size: document.body.len() as u64, - } - }) - .collect::>(); - documents.extend(source.conflicts.iter().map(|id| { - let mut container = chartulary::Container::new(format!("knot:vault:{id}")) - .with_address(format!("knot://vault/{id}")) - .with_title(format!("Conflict: {id}")); - container.media_type = Some("text/vnd.knot".into()); - PresentedDocument { - id: container.id.clone(), - container, - byte_size: 0, - } - })); - documents.sort_by(|left, right| left.id.cmp(&right.id)); - documents - } - } - } - - /// Install host-owned clip evidence retention. The configured store is the - /// authority for all paths, limits, and persistence; clip payloads cannot - /// choose a destination. - pub fn grant_clip_evidence(&mut self, store: impl KnotClipEvidenceStore + 'static) { - self.clip_evidence = Some(Box::new(store)); - self.snapshot = None; - self.resources.clear(); - self.bindings.clear(); - } - - /// Remove clip evidence authority and return to the v1 clip contract. - pub fn revoke_clip_evidence(&mut self) -> bool { - let had_authority = self.clip_evidence.take().is_some(); - if had_authority { - self.snapshot = None; - self.resources.clear(); - self.bindings.clear(); - } - had_authority - } - - fn rosette_documents(&self) -> Vec { - if matches!(&self.source, Source::Fixture(_)) { - return Vec::new(); - } - self.documents() - .into_iter() - .filter(|document| { - document.byte_size <= self.rosette_config.max_source_bytes - && document - .container - .media_type - .as_deref() - .is_some_and(is_rosette_media_type) - && match &self.source { - Source::Vault(source) => { - let source = source.state(); - let id = document - .id - .strip_prefix("knot:vault:") - .unwrap_or(&document.id); - source.vault.body(id).is_some() - } - Source::Directory { .. } => true, - Source::Fixture(_) => false, - } - }) - .collect() - } - - fn rosette_session(&self, document_id: &str) -> ProjectionSession { - ProjectionSession(format!( - "{}:rosette:{}", - self.session.0, - blake3::hash(document_id.as_bytes()).to_hex() - )) - } - - fn current_rosette_document(&self, session: &ProjectionSession) -> Option { - self.rosette_documents() - .into_iter() - .find(|document| self.rosette_session(&document.id) == *session) - } - - fn rosette_text(&self, document: &PresentedDocument) -> Result { - let bytes = match &self.source { - Source::Directory { source, .. } => { - let path = source - .readable_document_path(&document.id) - .map_err(|error| format!("could not open Rosette source: {error}"))?; - fs::read(path).map_err(|error| format!("could not read Rosette source: {error}"))? - } - Source::Vault(source) => { - let source = source.state(); - let id = document - .id - .strip_prefix("knot:vault:") - .unwrap_or(&document.id); - source - .vault - .body(id) - .ok_or_else(|| { - "Rosette source is unavailable while the vault is locked".to_string() - })? - .to_vec() - } - Source::Fixture(_) => return Err("fixture documents have no Rosette source".into()), - }; - if bytes.len() as u64 > self.rosette_config.max_source_bytes { - return Err(format!( - "Rosette source exceeds the configured {} byte limit", - self.rosette_config.max_source_bytes - )); - } - String::from_utf8(bytes).map_err(|_| "Rosette source is not UTF-8".to_string()) - } - - fn clear_rosette_projections(&mut self) { - self.rosette_snapshots.clear(); - self.rosette_resources.clear(); - self.rosette_last_announced.clear(); - self.rosette_document_ids.clear(); - } - - fn raw_source_revision(&self) -> u64 { - match &self.source { - Source::Directory { source, .. } => source.revision(), - Source::Fixture(_) => 1, - Source::Vault(source) => source.state().vault.revision(), - } - } - - fn sync_source_revision(&mut self) { - let current = self.raw_source_revision(); - if self.observed_source_revision != Some(current) { - let changed_after_observation = self.observed_source_revision.is_some(); - self.observed_source_revision = Some(current); - self.scene_revision = Revision(self.scene_revision.0.saturating_add(1).max(1)); - if changed_after_observation { - self.derived.clear(); - } - } - } - - fn advance_derived_revision(&mut self) { - self.scene_revision = Revision(self.scene_revision.0.saturating_add(1).max(1)); - } - - fn revision(&self) -> Revision { - self.scene_revision - } - - fn refresh_vault_projection(&mut self) -> Result<(), String> { - let Source::Vault(source) = &self.source else { - return Ok(()); - }; - source.state().refresh_projection() - } - - fn editable_text(&self, document: &PresentedDocument) -> Option { - let grant = self.write_grant?; - let address = document.container.primary_address()?.0; - let media_type = document.container.media_type.clone()?; - let format = DocumentFormat::from_media_type(&media_type)?; - match &self.source { - Source::Directory { source, .. } => { - let path = source.writable_document_path(&document.id).ok()?; - if DocumentFormat::from_path(&path) != Some(format) { - return None; - } - let bytes = fs::read(path).ok()?; - if bytes.len() as u64 > grant.max_source_bytes { - return None; - } - let source = String::from_utf8(bytes.clone()).ok()?; - let base_token = file_base_token(&document.id, &bytes); - Some(EditableTextV1 { - address, - media_type, - encoding: TextEncoding::Utf8, - source, - derived: self.derived_text(&document.id, &base_token), - base_token, - }) - } - Source::Vault(source) => { - let (text, base_token) = { - let source = source.state(); - let id = document - .id - .strip_prefix("knot:vault:") - .unwrap_or(&document.id); - if source.sync.is_none() - || source.pending_history - || source.conflicts.contains(id) - { - return None; - } - let body = source.vault.body(id)?; - if body.len() as u64 > grant.max_source_bytes { - return None; - } - let text = String::from_utf8(body.to_vec()).ok()?; - let head = source.document_heads.get(id)?; - (text, vault_base_token(id, head)) - }; - Some(EditableTextV1 { - address, - media_type, - encoding: TextEncoding::Utf8, - source: text, - derived: self.derived_text(&document.id, &base_token), - base_token, - }) - } - Source::Fixture(_) => None, - } - } - - fn derived_text(&self, id: &str, base_token: &[u8]) -> Option { - self.derived.get(id).and_then(|derived| { - let cache_is_current = derived - .cache - .as_ref() - .is_none_or(|cache| self.cache_attribution_is_current(cache)); - (derived.base_token == base_token && cache_is_current).then(|| DerivedTextV1 { - source: derived.document.to_knot(), - summary: derived.summary.clone(), - cache: (self.protocol_version.minor >= ProtocolVersion::V1.minor) - .then(|| derived.cache.as_ref().map(|cache| cache.info.clone())) - .flatten(), - }) - }) - } - - fn cache_attribution_is_current(&self, cache: &CacheAttribution) -> bool { - cache.info.source_revision == self.raw_source_revision() - && (cache.epoch.is_none() || cache.epoch == self.current_commons_epoch()) - } - - fn current_commons_epoch(&self) -> Option { - match &self.source { - Source::Vault(source) => match &source.state().sync { - Some(VaultSyncAuthority::Commons { keys, .. }) => keys.current_epoch(), - _ => None, - }, - _ => None, - } - } - - fn restore_derived_caches(&mut self) -> Result<(), String> { - let Some(effects) = &self.effects else { - return Ok(()); - }; - if effects.policy.resolve == KnotEffectMode::Never || effects.fetcher.is_none() { - return Ok(()); - } - let provider_version = effects - .fetcher - .as_ref() - .expect("checked above") - .cache_version(); - let policy_fingerprint = resolve_policy_fingerprint(&effects.policy); - let source_revision = self.raw_source_revision(); - let candidates = self - .documents() - .into_iter() - .filter_map(|document| { - let editable = self.editable_text(&document)?; - Some((document.id, editable.base_token)) - }) - .collect::>(); - - let Source::Vault(source) = &self.source else { - return Ok(()); - }; - let source = source.state(); - for (id, base_token) in candidates { - let Some(stored) = source.vault.load_derived_cache::(&id)? else { - continue; - }; - let (record, epoch) = match (&source.sync, stored) { - ( - Some(VaultSyncAuthority::Commons { keys, .. }), - StoredDerivedCache::Commons(envelope), - ) => { - let current = keys.current_epoch(); - if current != Some(envelope.epoch) { - continue; - } - let plaintext = match keys.open(&envelope) { - Ok(plaintext) => plaintext, - Err(_) => continue, - }; - let record = match serde_json::from_slice::(&plaintext) { - Ok(record) => record, - Err(_) => continue, - }; - (record, Some(envelope.epoch)) - } - (Some(VaultSyncAuthority::Commons { .. }), _) => continue, - (_, StoredDerivedCache::Personal(record)) => (record, None), - (_, StoredDerivedCache::Commons(_)) => continue, - }; - if record.version != DERIVED_CACHE_RECORD_VERSION - || record.document_id != id - || record.base_token != base_token - || record.info.effect != "resolve" - || record.info.provider_version != provider_version - || record.info.policy_fingerprint != policy_fingerprint - || record.info.source_revision != source_revision - { - continue; - } - self.derived.insert( - id, - DerivedDocument { - base_token: record.base_token, - document: record.document, - summary: record.summary, - cache: Some(CacheAttribution { - info: record.info, - epoch, - }), - }, - ); - } - Ok(()) - } - - fn persist_derived_cache(&self, id: &str, record: &DerivedCacheRecord) -> Result<(), String> { - let Source::Vault(source) = &self.source else { - // Files-in-place have no sealing profile. They retain only the - // in-memory projection. - return Ok(()); - }; - let source = source.state(); - let stored = match &source.sync { - Some(VaultSyncAuthority::Commons { keys, .. }) => { - let plaintext = serde_json::to_vec(record) - .map_err(|error| format!("could not encode Commons derived cache: {error}"))?; - StoredDerivedCache::Commons( - keys.seal_random(&plaintext).map_err(|error| { - format!("could not seal Commons derived cache: {error}") - })?, - ) - } - _ => StoredDerivedCache::Personal(record.clone()), - }; - source.vault.store_derived_cache(id, &stored) - } - - /// Which clip-insert contract this endpoint advertises. Retaining observed - /// source bytes needs an evidence store, so granting one moves the - /// advertised schema to v2. `IntentSink::invoke` gates arriving intents on - /// the same decision, and the two must not drift: an intent admitted - /// against one schema but parsed as the other would silently drop or - /// invent provenance. - fn clip_insert_schema(&self) -> &'static str { - if self.clip_evidence.is_some() { - KNOT_CLIP_INSERT_SCHEMA_V2 - } else { - KNOT_CLIP_INSERT_SCHEMA - } - } - - /// Human-facing wording for the clip action, keyed off the same decision as - /// [`Self::clip_insert_schema`] so the promise made to the host matches the - /// contract it is offered. - fn clip_insert_explanation(&self) -> &'static str { - if self.clip_insert_schema() == KNOT_CLIP_INSERT_SCHEMA_V2 { - "Retain observed source bytes and append a semantic clip with content-addressed provenance through Knot authority." - } else { - "Append a semantic clip with structured source provenance through Knot authority." - } - } - - /// A Resolve offer needs both a granted fetcher and a policy that admits - /// fetching; either alone leaves the effect unadvertised. - fn resolve_effect_admitted(&self) -> bool { - self.effects.as_ref().is_some_and(|effects| { - effects.policy.resolve != KnotEffectMode::Never && effects.fetcher.is_some() - }) - } - - /// A Run offer needs an evaluator for at least one language the policy - /// admits. An evaluator for a language outside `allowed_languages` never - /// makes Run advertisable, and neither does an allowed language with no - /// evaluator behind it. - fn run_effect_admitted(&self) -> bool { - self.effects.as_ref().is_some_and(|effects| { - effects.policy.run != KnotEffectMode::Never - && effects.evaluators.languages().into_iter().any(|language| { - effects - .policy - .allowed_languages - .iter() - .any(|allowed| allowed == language) - }) - }) - } - - /// The effects a host may invoke on an editable document, in advertised - /// order. This is the whole effect-gating policy for the editable-text - /// offer: Save and Insert clip ride along with editable text itself, while - /// the two external effects are each gated above. - /// - /// `IntentSink::invoke` re-checks every arriving intent against the - /// advertised list, so an effect omitted here cannot be invoked. - fn advertised_actions(&self) -> Vec { - let mut actions = vec![ - AdvertisedAction { - intent: IntentReference(EDITABLE_TEXT_SAVE_INTENT.into()), - label: "Save".into(), - explanation: "Write this document through Knot authority.".into(), - payload_schema: EDITABLE_TEXT_SAVE_SCHEMA.into(), - input_form: None, - effect: IntentEffect::DomainTruth, - }, - AdvertisedAction { - intent: IntentReference(KNOT_CLIP_INSERT_INTENT.into()), - label: "Insert clip".into(), - explanation: self.clip_insert_explanation().into(), - payload_schema: self.clip_insert_schema().into(), - input_form: None, - effect: IntentEffect::DomainTruth, - }, - ]; - if self.resolve_effect_admitted() { - actions.push(AdvertisedAction { - intent: IntentReference(KNOT_TRANSCLUSION_RESOLVE_INTENT.into()), - label: "Resolve".into(), - explanation: "Fetch admitted include fences into a temporary derived preview." - .into(), - payload_schema: KNOT_TRANSCLUSION_RESOLVE_SCHEMA.into(), - input_form: None, - effect: IntentEffect::ExternalEffect, - }); - } - if self.run_effect_admitted() { - actions.push(AdvertisedAction { - intent: IntentReference(KNOT_BLOCK_RUN_INTENT.into()), - label: "Run".into(), - explanation: "Evaluate admitted code fences into a temporary derived preview." - .into(), - payload_schema: KNOT_BLOCK_RUN_SCHEMA.into(), - input_form: None, - effect: IntentEffect::ExternalEffect, - }); - } - actions - } - - /// The portable card a host without any richer codec falls back to. Badges - /// disclose both where the bytes live and whether they can be written back; - /// a vault conflict outranks editability because the document is not - /// authorable until the conflict is settled. - fn card_payload( - &self, - document: &PresentedDocument, - title: String, - editable: bool, - ) -> PortableCardV1 { - let address = document - .container - .primary_address() - .map_or_else(String::new, |address| address.0); - let media_type = document - .container - .media_type - .as_deref() - .unwrap_or("application/octet-stream") - .to_string(); - let conflicted = match &self.source { - Source::Vault(source) => source.state().conflicts.contains( - document - .id - .strip_prefix("knot:vault:") - .unwrap_or(&document.id), - ), - _ => false, - }; - PortableCardV1 { - title, - values: vec![ - CardValueV1 { - label: "Address".into(), - value: address, - }, - CardValueV1 { - label: "Type".into(), - value: media_type, - }, - CardValueV1 { - label: "Size".into(), - value: format!("{} bytes", document.byte_size), - }, - ], - badges: match (&self.source, editable, conflicted) { - (Source::Vault(_), _, true) => { - vec!["sealed vault".into(), "conflict".into()] - } - (Source::Vault(_), true, false) => { - vec!["sealed vault".into(), "editable".into()] - } - (Source::Vault(_), false, false) => { - vec!["sealed vault".into(), "read only".into()] - } - (_, true, _) => vec!["files in place".into(), "editable".into()], - (_, false, _) => vec!["files in place".into(), "read only".into()], - }, - media: Vec::new(), - } - } - - fn build_snapshot(&mut self) -> Result { - let documents = self.documents(); - let grid = CardGrid::new(); - let mut scene = Scene::new(); - let mut presentation = PresentationManifest::default(); - let mut resources = BTreeMap::new(); - self.bindings.clear(); - - for (index, document) in documents.iter().enumerate() { - let source = scene.intern_source(SourceRef::new(SOURCE_KIND, document.id.clone())); - scene.items.push(ProjectedItem { - source, - space: Scene::WORLD, - transform: grid.placement(index), - footprint: Footprint::Rect { size: grid.card }, - representation: Representation::Card, - layer: 0, - visible: true, - hit: None, - channels: Vec::new(), - }); - - let title = document.container.title().unwrap_or("Untitled").to_string(); - // Read the editable projection before the card, so the vault lock is - // taken in the same order it always was. - let editable = (self.protocol_version.minor >= ProtocolVersion::V1_2.minor) - .then(|| self.editable_text(document)) - .flatten(); - let card_payload = self.card_payload(document, title.clone(), editable.is_some()); - let glyph = NativeGlyphV1 { - label: title.clone(), - icon: Some("◇".into()), - color: Some("#88a889".into()), - }; - let card_bytes = serde_json::to_vec(&card_payload) - .map_err(|error| format!("could not encode card: {error}"))?; - let glyph_bytes = serde_json::to_vec(&glyph) - .map_err(|error| format!("could not encode glyph: {error}"))?; - let card_hash = ContentHash::of(&card_bytes); - let glyph_hash = ContentHash::of(&glyph_bytes); - resources.insert(card_hash, card_bytes.clone()); - resources.insert(glyph_hash, glyph_bytes.clone()); - let key = PresentationKey(document.id.clone()); - let semantics = PresentationSemantics { - label: title, - role: SemanticRole::Article, - bounds: BoundsRelationship::FillFootprint, - actions: Vec::new(), - }; - presentation.bindings.push(PresentationBinding { - instance: InstanceId(index as u32), - key: key.clone(), - }); - self.bindings.insert(index as u32, document.id.clone()); - let mut offers = Vec::new(); - if let Some(editable) = editable { - let editable_bytes = serde_json::to_vec(&editable) - .map_err(|error| format!("could not encode editable text: {error}"))?; - let editable_hash = ContentHash::of(&editable_bytes); - resources.insert(editable_hash, editable_bytes.clone()); - let mut editable_semantics = semantics.clone(); - editable_semantics.actions = self.advertised_actions(); - offers.push(PresentationOffer { - codec: PresentationCodec::EditableTextV1, - resource: editable_hash, - byte_size: editable_bytes.len() as u64, - requires: PresentationCapability::EditableText, - semantics: editable_semantics, - }); - } - offers.extend([ - PresentationOffer { - codec: PresentationCodec::PortableCardV1, - resource: card_hash, - byte_size: card_bytes.len() as u64, - requires: PresentationCapability::PortableCard, - semantics: semantics.clone(), - }, - PresentationOffer { - codec: PresentationCodec::NativeGlyphV1, - resource: glyph_hash, - byte_size: glyph_bytes.len() as u64, - requires: PresentationCapability::NativeGlyph, - semantics, - }, - ]); - presentation.offers.insert(key, offers); - } - - scene.bounds = grid.bounds(documents.len()); - scene.generation = self.revision().0; - let scene = SceneSnapshot::from_dense(SceneEpoch(1), self.revision(), scene) - .map_err(|error| format!("invalid Knot scene: {error:?}"))?; - self.resources = resources; - let snapshot = ProjectionSnapshot { - version: self.protocol_version, - session: self.session.clone(), - scene, - presentation, - cache_policy: CachePolicy::default(), - }; - self.last_announced = Some(snapshot.scene.revision); - self.snapshot = Some(snapshot.clone()); - Ok(snapshot) - } - - fn build_rosette_snapshot( - &mut self, - session: ProjectionSession, - document: PresentedDocument, - version: ProtocolVersion, - ) -> Result { - let text = self.rosette_text(&document)?; - let projection = project_rosette( - SourceRef::new(SOURCE_KIND, document.id.clone()), - &text, - &CmudictPronunciations, - self.rosette_config.geometry, - ); - let document_title = document.container.title().unwrap_or("Untitled").to_string(); - let mut presentation = PresentationManifest::default(); - let mut resources = BTreeMap::new(); - - for interior in &projection.interiors { - let source = text - .get(interior.byte_start..interior.byte_end) - .ok_or_else(|| "Rosette interior does not address its source".to_string())?; - let (kind, glyph) = match interior.kind { - RosetteInteriorKind::Line => ("Line", "♪"), - RosetteInteriorKind::Stanza => ("Stanza", "✦"), - }; - let unresolved = projection - .coverage - .unresolved - .iter() - .filter(|token| { - token.byte_start >= interior.byte_start && token.byte_end <= interior.byte_end - }) - .count(); - let label = match interior.kind { - RosetteInteriorKind::Line => presentation_excerpt(source), - RosetteInteriorKind::Stanza => format!("Stanza {}", interior.ordinal + 1), - }; - let mut badges = vec!["Rosette".into(), kind.to_ascii_lowercase()]; - if unresolved > 0 { - badges.push(format!("{unresolved} unknown")); - } - let card = PortableCardV1 { - title: label.clone(), - values: vec![ - CardValueV1 { - label: "Document".into(), - value: document_title.clone(), - }, - CardValueV1 { - label: "Interior".into(), - value: format!( - "{kind} {} · bytes {}..{}", - interior.ordinal + 1, - interior.byte_start, - interior.byte_end - ), - }, - CardValueV1 { - label: "Text".into(), - value: presentation_excerpt(source), - }, - CardValueV1 { - label: "Lexicon".into(), - value: format!( - "{} of {} tokens resolved", - projection.coverage.resolved_tokens, projection.coverage.total_tokens - ), - }, - ], - badges, - media: Vec::new(), - }; - let glyph = NativeGlyphV1 { - label: label.clone(), - icon: Some(glyph.into()), - color: Some("#d8a657".into()), - }; - let card_bytes = serde_json::to_vec(&card) - .map_err(|error| format!("could not encode Rosette card: {error}"))?; - let glyph_bytes = serde_json::to_vec(&glyph) - .map_err(|error| format!("could not encode Rosette glyph: {error}"))?; - let card_hash = ContentHash::of(&card_bytes); - let glyph_hash = ContentHash::of(&glyph_bytes); - resources.insert(card_hash, card_bytes.clone()); - resources.insert(glyph_hash, glyph_bytes.clone()); - let key = PresentationKey(format!( - "{}:rosette:{}:{}", - document.id, kind, interior.ordinal - )); - let semantics = PresentationSemantics { - label, - role: SemanticRole::Article, - bounds: BoundsRelationship::FillFootprint, - actions: Vec::new(), - }; - presentation.bindings.push(PresentationBinding { - instance: interior.instance, - key: key.clone(), - }); - presentation.offers.insert( - key, - vec![ - PresentationOffer { - codec: PresentationCodec::PortableCardV1, - resource: card_hash, - byte_size: card_bytes.len() as u64, - requires: PresentationCapability::PortableCard, - semantics: semantics.clone(), - }, - PresentationOffer { - codec: PresentationCodec::NativeGlyphV1, - resource: glyph_hash, - byte_size: glyph_bytes.len() as u64, - requires: PresentationCapability::NativeGlyph, - semantics, - }, - ], - ); - } - - let scene = SceneSnapshot::from_dense(SceneEpoch(1), self.revision(), projection.scene) - .map_err(|error| format!("invalid Knot Rosette scene: {error:?}"))?; - let snapshot = ProjectionSnapshot { - version, - session: session.clone(), - scene, - presentation, - cache_policy: CachePolicy::default(), - }; - self.rosette_resources.insert(session.clone(), resources); - self.rosette_last_announced - .insert(session.clone(), snapshot.scene.revision); - self.rosette_document_ids - .insert(session.clone(), document.id.clone()); - self.rosette_snapshots.insert(session, snapshot.clone()); - Ok(snapshot) - } - - fn build_empty_rosette_snapshot( - &mut self, - session: ProjectionSession, - version: ProtocolVersion, - ) -> Result { - let mut scene = Scene::new(); - scene.generation = self.revision().0; - let scene = SceneSnapshot::from_dense(SceneEpoch(1), self.revision(), scene) - .map_err(|error| format!("invalid empty Knot Rosette scene: {error:?}"))?; - let snapshot = ProjectionSnapshot { - version, - session: session.clone(), - scene, - presentation: PresentationManifest::default(), - cache_policy: CachePolicy::default(), - }; - self.rosette_resources - .insert(session.clone(), BTreeMap::new()); - self.rosette_last_announced - .insert(session.clone(), snapshot.scene.revision); - self.rosette_snapshots.insert(session, snapshot.clone()); - Ok(snapshot) - } - - fn save_text(&mut self, id: &str, payload: SaveTextV1) -> Result { - let grant = self - .write_grant - .ok_or_else(|| "Knot session has no write grant".to_string())?; - if payload.source.len() as u64 > grant.max_source_bytes { - return Ok(IntentResult::Rejected { - reason: format!( - "source exceeds this grant's {} byte limit", - grant.max_source_bytes - ), - }); - } - let document = self - .documents() - .into_iter() - .find(|document| document.id == id) - .ok_or_else(|| "intent target is no longer present".to_string())?; - let Some(current) = self.editable_text(&document) else { - return Ok(IntentResult::Rejected { - reason: "this document is not currently writable".into(), - }); - }; - if payload.base_token != current.base_token { - return Ok(self.stale_result()); - } - if payload.source == current.source { - return Ok(IntentResult::Accepted); - } - let format = DocumentFormat::from_media_type(¤t.media_type) - .ok_or_else(|| "document format is not authorable".to_string())?; - format.validate_source(¤t.address, &payload.source)?; - - let stale_result = self.stale_result(); - // Every arm installs whatever it produced before returning; nothing is - // handed back for the caller to install. - match &mut self.source { - Source::Directory { source, .. } => { - let path = source - .writable_document_path(id) - .map_err(|error| format!("document target is not writable: {error}"))?; - let before = fs::read(&path) - .map_err(|error| format!("could not re-read document before save: {error}"))?; - if file_base_token(id, &before) != payload.base_token { - return Ok(self.stale_result()); - } - crate::writer::write_if_distinct(&path, &before, payload.source.as_bytes())?; - source - .refresh() - .map_err(|error| format!("directory refresh failed after save: {error}"))?; - } - Source::Vault(resident) => { - let mut source = resident.state(); - let native_id = id.strip_prefix("knot:vault:").unwrap_or(id); - let Some(head) = source.document_heads.get(native_id) else { - return Ok(stale_result); - }; - if vault_base_token(native_id, head) != payload.base_token { - return Ok(stale_result); - } - let Some(previous) = source - .vault - .documents() - .find(|document| document.id == native_id) - .cloned() - else { - return Err("vault document is no longer present".into()); - }; - let event = KnotSyncEvent::Put(VaultDocument { - id: previous.id, - title: previous.title, - body: payload.source.into_bytes(), - media_type: previous.media_type, - }); - let projection = match source - .sync - .as_ref() - .ok_or_else(|| "vault has no admitted sync author".to_string())? - { - VaultSyncAuthority::Personal { - store, - signing_seed, - } => { - pollster::block_on(store.author(**signing_seed, &source.vault, &event)) - .map_err(|error| format!("could not author Knot save: {error}"))?; - pollster::block_on(store.projection(&source.vault)) - .map_err(|error| format!("could not project Knot save: {error}"))? - } - VaultSyncAuthority::Commons { - store, - signing_seed, - keys, - } => { - pollster::block_on(store.author_communal(**signing_seed, keys, &event)) - .map_err(|error| { - format!("could not author Commons Knot save: {error}") - })?; - pollster::block_on(store.communal_projection(keys)).map_err(|error| { - format!("could not project Commons Knot save: {error}") - })? - } - }; - source.install_projection(projection)?; - } - Source::Fixture(_) => { - return Ok(IntentResult::Rejected { - reason: "fixture documents are read-only".into(), - }); - } - } - - self.sync_source_revision(); - let announced = self.last_announced; - self.build_snapshot()?; - self.last_announced = announced; - Ok(IntentResult::Accepted) - } - - fn insert_clip(&mut self, id: &str, payload: InsertKnotClipV1) -> Result { - if let Some(rejected) = validate_clip_header( - &payload.source_url, - payload.title.as_deref(), - &payload.knot_body, - ) { - return Ok(rejected); - } - if payload - .selector - .as_ref() - .is_some_and(|selector| selector.len() > 4096) - { - return Ok(IntentResult::Rejected { - reason: "clip selector exceeds 4096 bytes".into(), - }); - } - - let provenance = serde_json::json!({ - "schema": KNOT_CLIP_INSERT_SCHEMA, - "source_url": payload.source_url, - "title": payload.title, - "selector": payload.selector, - }); - self.append_clip(id, payload.base_token, payload.knot_body, provenance) - } - - fn insert_clip_v2( - &mut self, - id: &str, - payload: InsertKnotClipV2, - ) -> Result { - if let Some(rejected) = validate_clip_header( - &payload.source_url, - payload.title.as_deref(), - &payload.knot_body, - ) { - return Ok(rejected); - } - if payload.artifacts.is_empty() || payload.artifacts.len() > 2 { - return Ok(IntentResult::Rejected { - reason: "evidence-bearing clips require one or two source artifacts".into(), - }); - } - if payload.selectors.len() > 16 - || payload.fidelity.len() > 256 - || payload.discovered_edges.len() > 2048 - { - return Ok(IntentResult::Rejected { - reason: "clip evidence exceeds selector, fidelity, or edge count limits".into(), - }); - } - let structured_bytes = serde_json::to_vec(&( - &payload.selectors, - &payload.fidelity, - &payload.discovered_edges, - )) - .map_err(|error| format!("could not validate structured clip evidence: {error}"))?; - if structured_bytes.len() > 256 * 1024 { - return Ok(IntentResult::Rejected { - reason: "structured clip evidence exceeds 262144 bytes".into(), - }); - } - for artifact in &payload.artifacts { - if artifact.media_type.is_empty() - || artifact.media_type.len() > 256 - || artifact.canonical_uri.is_empty() - || artifact.canonical_uri.len() > 8 * 1024 - || !has_absolute_uri_scheme(&artifact.canonical_uri) - { - return Ok(IntentResult::Rejected { - reason: "clip artifact metadata is invalid".into(), - }); - } - } - if payload - .selectors - .iter() - .any(|selector| !selector_matches_artifacts(selector, &payload.artifacts)) - || payload.fidelity.iter().any(|entry| { - entry.selector.as_ref().is_some_and(|selector| { - !selector_matches_artifacts(selector, &payload.artifacts) - }) - }) - { - return Ok(IntentResult::Rejected { - reason: "clip selector names an artifact role the clip did not retain".into(), - }); - } - - // Check the revision before retaining bytes. A stale gesture must not - // grow the evidence store. - let current = match self.current_clip_target(id, &payload.base_token)? { - Ok(current) => current, - Err(result) => return Ok(result), - }; - let Some(store) = self.clip_evidence.as_mut() else { - return Ok(IntentResult::Rejected { - reason: "this Knot endpoint has no clip evidence authority".into(), - }); - }; - let mut evidence: Vec = Vec::with_capacity(payload.artifacts.len()); - for artifact in &payload.artifacts { - match store.retain(artifact) { - Ok(reference) => evidence.push(reference), - Err(reason) => return Ok(IntentResult::Rejected { reason }), - } - } - let provenance = serde_json::json!({ - "schema": KNOT_CLIP_INSERT_SCHEMA_V2, - "source_url": payload.source_url, - "title": payload.title, - "selectors": payload.selectors, - "evidence": evidence, - "fidelity": payload.fidelity, - "discovered_edges": payload.discovered_edges, - }); - self.append_clip_to_current( - id, - payload.base_token, - payload.knot_body, - provenance, - current, - ) - } - - fn append_clip( - &mut self, - id: &str, - base_token: Vec, - knot_body: String, - provenance: serde_json::Value, - ) -> Result { - let current = match self.current_clip_target(id, &base_token)? { - Ok(current) => current, - Err(result) => return Ok(result), - }; - self.append_clip_to_current(id, base_token, knot_body, provenance, current) - } - - fn current_clip_target( - &mut self, - id: &str, - base_token: &[u8], - ) -> Result, String> { - let document = self - .documents() - .into_iter() - .find(|document| document.id == id) - .ok_or_else(|| "intent target is no longer present".to_string())?; - let Some(current) = self.editable_text(&document) else { - return Ok(Err(IntentResult::Rejected { - reason: "this document is not currently writable".into(), - })); - }; - if base_token != current.base_token { - return Ok(Err(self.stale_result())); - } - Ok(Ok(current)) - } - - fn append_clip_to_current( - &mut self, - id: &str, - base_token: Vec, - knot_body: String, - provenance: serde_json::Value, - current: EditableTextV1, - ) -> Result { - let provenance = serde_json::to_string(&provenance) - .map_err(|error| format!("could not encode clip provenance: {error}"))?; - let mut source = current.source.trim_end().to_string(); - if !source.is_empty() { - source.push_str("\n\n"); - } - source.push_str("```knot.clip.provenance\n"); - source.push_str(&provenance); - source.push_str("\n```\n\n"); - source.push_str(knot_body.trim()); - source.push('\n'); - - self.save_text(id, SaveTextV1 { base_token, source }) - } - - fn resolve_transclusions( - &mut self, - id: &str, - payload: KnotEffectV1, - ) -> Result { - let Some((current, mut document)) = self.effect_input(id, &payload.base_token, false)? - else { - return Ok(self.stale_result()); - }; - let mode = self - .effects - .as_ref() - .map(|effects| effects.policy.resolve) - .unwrap_or(KnotEffectMode::Never); - if let Some(rejected) = self.check_effect_consent(mode, payload.confirmed) { - return Ok(rejected); - } - if document.trust == DocumentTrustState::Broken { - return Ok(IntentResult::Rejected { - reason: "Knot refuses effects for a document with broken trust".into(), - }); - } - - let source_revision = self.raw_source_revision(); - let encryption_epoch = self.current_commons_epoch(); - let has_sealed_cache = matches!(&self.source, Source::Vault(_)); - let retained_cache = self.derived.get(id).is_some_and(|derived| { - derived.base_token == current.base_token - && derived - .cache - .as_ref() - .is_some_and(|cache| self.cache_attribution_is_current(cache)) - }); - let (outcome, sources, provider_version, policy_fingerprint) = { - let effects = self - .effects - .as_mut() - .ok_or_else(|| "Knot session has no effect authority".to_string())?; - let provider_version = effects - .fetcher - .as_ref() - .ok_or_else(|| "Knot session has no transclusion fetcher".to_string())? - .cache_version(); - let policy_fingerprint = resolve_policy_fingerprint(&effects.policy); - let fetcher = effects - .fetcher - .as_mut() - .ok_or_else(|| "Knot session has no transclusion fetcher".to_string())?; - let policy = TransclusionPolicy::for_own_notes( - effects.policy.allowed_schemes.clone(), - effects.policy.max_depth, - ); - let mut sources = Vec::new(); - let mut fetch = |address: &str| { - let fetched = fetcher.fetch(address)?; - sources.push(address.to_string()); - Ok(fetched) - }; - let mut render = render_effect_input; - let outcome = resolve_transclusions(&mut document, &mut fetch, &mut render, &policy); - sources.sort(); - sources.dedup(); - (outcome, sources, provider_version, policy_fingerprint) - }; - let summary = format!( - "resolved {}; denied {}; failed {}", - outcome.resolved, - outcome.denied.len(), - outcome.failed.len() - ); - if retained_cache && outcome.resolved == 0 && !outcome.failed.is_empty() { - return Ok(IntentResult::Rejected { - reason: format!( - "resolve refresh failed; retained cached result ({} failure(s))", - outcome.failed.len() - ), - }); - } - let cache = (has_sealed_cache && outcome.resolved > 0).then(|| CacheAttribution { - info: DerivedCacheInfoV1 { - effect: "resolve".into(), - sources, - provider_version, - policy_fingerprint, - fetched_at_unix_ms: unix_time_ms(), - source_revision, - }, - epoch: encryption_epoch, - }); - self.accept_derived(id, current.base_token, document, summary, cache, true) - } - - fn run_blocks(&mut self, id: &str, payload: KnotEffectV1) -> Result { - let Some((current, mut document)) = self.effect_input(id, &payload.base_token, true)? - else { - return Ok(self.stale_result()); - }; - let mode = self - .effects - .as_ref() - .map(|effects| effects.policy.run) - .unwrap_or(KnotEffectMode::Never); - if let Some(rejected) = self.check_effect_consent(mode, payload.confirmed) { - return Ok(rejected); - } - if document.trust == DocumentTrustState::Broken { - return Ok(IntentResult::Rejected { - reason: "Knot refuses effects for a document with broken trust".into(), - }); - } - - let effects = self - .effects - .as_mut() - .ok_or_else(|| "Knot session has no effect authority".to_string())?; - let policy = EvaluationPolicy::for_own_notes(effects.policy.allowed_languages.clone()); - let max_ops = effects.policy.max_ops; - let evaluators = &mut effects.evaluators; - let mut evaluate = - |language: &str, source: &str| evaluators.evaluate(language, source, max_ops); - let mut render = render_effect_input; - let outcome = evaluate_blocks(&mut document, &mut evaluate, &mut render, &policy); - let summary = format!( - "ran {}; denied {}; failed {}", - outcome.evaluated, - outcome.denied.len(), - outcome.failed.len() - ); - // Evaluation providers do not yet expose a cacheability contract. - // The evaluated document is therefore process-local even when its - // input began as a separately cached resolve result. - self.accept_derived(id, current.base_token, document, summary, None, false) - } - - fn effect_input( - &self, - id: &str, - base_token: &[u8], - use_derived: bool, - ) -> Result, String> { - let document = self - .documents() - .into_iter() - .find(|document| document.id == id) - .ok_or_else(|| "intent target is no longer present".to_string())?; - let current = self - .editable_text(&document) - .ok_or_else(|| "this document is not currently writable".to_string())?; - if base_token != current.base_token { - return Ok(None); - } - let derived = use_derived - .then(|| { - self.derived - .get(id) - .filter(|derived| derived.base_token == current.base_token) - .map(|derived| derived.document.clone()) - }) - .flatten(); - let document = match derived { - Some(document) => document, - None => render_effect_input( - &EngineInput::new(current.address.clone(), current.source.clone()) - .with_content_type(current.media_type.clone()), - )?, - }; - Ok(Some((current, document))) - } - - fn check_effect_consent(&self, mode: KnotEffectMode, confirmed: bool) -> Option { - if mode == KnotEffectMode::Never { - return Some(IntentResult::Rejected { - reason: "this effect is disabled by Knot policy".into(), - }); - } - let received = match &self.source { - Source::Vault(source) => matches!( - &source.state().sync, - Some(VaultSyncAuthority::Commons { .. }) - ), - _ => false, - }; - if !confirmed && (mode == KnotEffectMode::Ask || received) { - return Some(IntentResult::Rejected { - reason: if received { - "received Commons documents require explicit effect confirmation".into() - } else { - "this effect requires explicit confirmation".into() - }, - }); - } - None - } - - fn accept_derived( - &mut self, - id: &str, - base_token: Vec, - document: EngineDocument, - summary: String, - cache: Option, - persist: bool, - ) -> Result { - if persist && let Some(cache) = &cache { - self.persist_derived_cache( - id, - &DerivedCacheRecord { - version: DERIVED_CACHE_RECORD_VERSION, - document_id: id.to_string(), - base_token: base_token.clone(), - document: document.clone(), - summary: summary.clone(), - info: cache.info.clone(), - }, - )?; - } - self.derived.insert( - id.to_string(), - DerivedDocument { - base_token, - document, - summary, - cache, - }, - ); - self.advance_derived_revision(); - let announced = self.last_announced; - self.build_snapshot()?; - self.last_announced = announced; - Ok(IntentResult::Accepted) - } - - fn stale_result(&self) -> IntentResult { - let (current_epoch, current_revision) = self - .snapshot - .as_ref() - .map(|snapshot| (snapshot.scene.epoch, snapshot.scene.revision)) - .unwrap_or((SceneEpoch(1), self.revision())); - IntentResult::Stale { - current_epoch, - current_revision, - } - } - - fn validate_request(&self, request: &ProjectionRequest) -> Result<(), String> { - if request.session != self.session { - return Err("projection request names the wrong Knot session".into()); - } - self.validate_projection_contract(request) - } - - fn validate_projection_contract(&self, request: &ProjectionRequest) -> Result<(), String> { - if request.version.major != ProtocolVersion::V1.major { - return Err("projection request uses an unsupported protocol".into()); - } - if request.version.minor > ProtocolVersion::V1.minor { - return Err("projection request uses a newer unsupported protocol minor".into()); - } - if request.score.version != sceno::SCORE_VERSION { - return Err("projection request uses an unsupported score".into()); - } - Ok(()) - } -} - -fn selector_matches_artifacts( - selector: &KnotClipSelectorV1, - artifacts: &[KnotClipArtifactV1], -) -> bool { - let role = match selector { - KnotClipSelectorV1::TextQuote { artifact_role, .. } - | KnotClipSelectorV1::TextPosition { artifact_role, .. } => *artifact_role, - KnotClipSelectorV1::DomRange { artifact_role, .. } => { - if *artifact_role != KnotClipArtifactRoleV1::ObservedRepresentation { - return false; - } - *artifact_role - } - }; - artifacts.iter().any(|artifact| artifact.role == role) -} - -fn render_effect_input(input: &EngineInput) -> Result { - let media_type = input.content_type.as_deref(); - let address = input.address.to_ascii_lowercase(); - let engine: Box = match media_type { - Some("text/gemini") => Box::new(nematic::GemtextEngine::new()), - Some("text/markdown") => Box::new(nematic::MarkdownEngine::new()), - Some("text/html" | "application/xhtml+xml") => Box::new(nematic::HtmlFragmentEngine::new()), - Some("text/x-knot" | "text/vnd.knot") => Box::new(nematic::KnotEngine::new()), - Some("text/plain") => Box::new(nematic::TextEngine::new()), - _ if address.ends_with(".gmi") || address.ends_with(".gemini") => { - Box::new(nematic::GemtextEngine::new()) - } - _ if address.ends_with(".md") || address.ends_with(".markdown") => { - Box::new(nematic::MarkdownEngine::new()) - } - _ if address.ends_with(".html") || address.ends_with(".htm") => { - Box::new(nematic::HtmlFragmentEngine::new()) - } - _ if address.ends_with(".knot") => Box::new(nematic::KnotEngine::new()), - _ => Box::new(nematic::TextEngine::new()), - }; - engine.render(input).map_err(|error| error.to_string()) -} - -fn resolve_policy_fingerprint(policy: &KnotEffectPolicy) -> String { - let mut schemes = policy.allowed_schemes.clone(); - schemes.sort(); - schemes.dedup(); - let mut hasher = blake3::Hasher::new_derive_key("mere.knot.resolve-cache-policy.v1"); - hasher.update(&[match policy.resolve { - KnotEffectMode::Auto => 0, - KnotEffectMode::Ask => 1, - KnotEffectMode::Never => 2, - }]); - hasher.update(&[policy.max_depth]); - for scheme in schemes { - hasher.update(&(scheme.len() as u64).to_le_bytes()); - hasher.update(scheme.as_bytes()); - } - hasher.finalize().to_hex().to_string() -} - -fn unix_time_ms() -> u64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_millis() - .try_into() - .unwrap_or(u64::MAX) -} - -fn file_base_token(id: &str, bytes: &[u8]) -> Vec { - let mut hasher = blake3::Hasher::new_derive_key(FILE_TOKEN_CONTEXT); - hasher.update(&(id.len() as u64).to_le_bytes()); - hasher.update(id.as_bytes()); - hasher.update(blake3::hash(bytes).as_bytes()); - hasher.finalize().as_bytes().to_vec() -} - -fn vault_base_token(id: &str, operation: &[u8; 32]) -> Vec { - let mut hasher = blake3::Hasher::new_derive_key(VAULT_TOKEN_CONTEXT); - hasher.update(&(id.len() as u64).to_le_bytes()); - hasher.update(id.as_bytes()); - hasher.update(operation); - hasher.finalize().as_bytes().to_vec() -} - -fn validate_clip_header( - source_url: &str, - title: Option<&str>, - knot_body: &str, -) -> Option { - if source_url.is_empty() - || source_url.len() > 8 * 1024 - || source_url.chars().any(char::is_control) - || !has_absolute_uri_scheme(source_url) - { - return Some(IntentResult::Rejected { - reason: "clip source_url must be an absolute URI of at most 8192 bytes".into(), - }); - } - if title.is_some_and(|title| title.len() > 1024) { - return Some(IntentResult::Rejected { - reason: "clip title exceeds 1024 bytes".into(), - }); - } - if knot_body.trim().is_empty() { - return Some(IntentResult::Rejected { - reason: "clip contains no semantic Knot body".into(), - }); - } - None -} - -fn has_absolute_uri_scheme(address: &str) -> bool { - let Some((scheme, _)) = address.split_once(':') else { - return false; - }; - let mut chars = scheme.chars(); - chars - .next() - .is_some_and(|first| first.is_ascii_alphabetic()) - && chars.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '+' | '-' | '.')) -} - -fn is_rosette_media_type(media_type: &str) -> bool { - matches!( - media_type, - "text/plain" | "text/markdown" | "text/djot" | "text/vnd.knot" - ) -} - -fn presentation_excerpt(source: &str) -> String { - let mut excerpt = source.split_whitespace().collect::>().join(" "); - const MAX_CHARS: usize = 160; - if let Some((byte, _)) = excerpt.char_indices().nth(MAX_CHARS) { - excerpt.truncate(byte); - excerpt.push('…'); - } - excerpt -} - -impl ProjectionCatalog for KnotEndpoint { - fn describe(&self) -> EndpointDescriptor { - let mut projections = vec![ProjectionOffer { - label: match &self.source { - Source::Directory { .. } => "Files in place".into(), - Source::Fixture(_) => "Authoring fixture".into(), - Source::Vault(_) => "Sealed vault".into(), - }, - request: ProjectionRequest { - version: ProtocolVersion::V1, - session: self.session.clone(), - score: Score::new(Arrangement::Spiral(Default::default())), - }, - }]; - projections.extend(self.rosette_documents().into_iter().map(|document| { - let title = document.container.title().unwrap_or("Untitled"); - ProjectionOffer { - label: format!("Rosette · {title}"), - request: ProjectionRequest { - version: ProtocolVersion::V1, - session: self.rosette_session(&document.id), - score: Score::new(Arrangement::Spiral(Default::default())), - }, - } - })); - EndpointDescriptor { - label: "Knot".into(), - projections, - } - } -} - -impl ProjectionSource for KnotEndpoint { - type Error = String; - - fn snapshot(&mut self, request: ProjectionRequest) -> Result { - if request.session == self.session { - self.validate_request(&request)?; - self.protocol_version = request.version; - self.refresh()?; - return self.build_snapshot(); - } - self.validate_projection_contract(&request)?; - self.refresh()?; - let document = self - .current_rosette_document(&request.session) - .ok_or_else(|| "projection request names an unavailable Knot Rosette".to_string())?; - self.build_rosette_snapshot(request.session, document, request.version) - } -} - -impl ResumableProjectionSource for KnotEndpoint { - type Error = String; - - fn resume(&mut self, request: ResumeRequest) -> Result { - self.refresh()?; - let current = self.revision(); - if request.session == self.session { - if request.epoch == SceneEpoch(1) && request.revision == current { - self.last_announced = Some(current); - return Ok(ResumeReply::Current(ProjectionAck { - session: self.session.clone(), - epoch: SceneEpoch(1), - revision: current, - })); - } - return Ok(ResumeReply::Snapshot(Box::new(self.build_snapshot()?))); - } - - let document = self.current_rosette_document(&request.session); - if document.is_none() && !self.rosette_document_ids.contains_key(&request.session) { - return Err("resume request names an unavailable Knot Rosette".into()); - } - if request.epoch == SceneEpoch(1) && request.revision == current { - self.rosette_last_announced - .insert(request.session.clone(), current); - return Ok(ResumeReply::Current(ProjectionAck { - session: request.session, - epoch: SceneEpoch(1), - revision: current, - })); - } - let version = self - .rosette_snapshots - .get(&request.session) - .map_or(ProtocolVersion::V1, |snapshot| snapshot.version); - let snapshot = match document { - Some(document) => self.build_rosette_snapshot(request.session, document, version)?, - None => self.build_empty_rosette_snapshot(request.session, version)?, - }; - Ok(ResumeReply::Snapshot(Box::new(snapshot))) - } -} - -impl ProjectionNoticeSource for KnotEndpoint { - type Error = String; - - fn poll_notice(&mut self) -> Result, Self::Error> { - self.refresh()?; - let revision = self.revision(); - if self.snapshot.is_some() - && self - .last_announced - .is_none_or(|announced| revision > announced) - { - self.last_announced = Some(revision); - return Ok(Some(CarrierNotice { - session: self.session.clone(), - epoch: SceneEpoch(1), - revision, - })); - } - let pending = self.rosette_snapshots.keys().find(|session| { - self.rosette_last_announced - .get(*session) - .is_none_or(|announced| revision > *announced) - }); - let Some(session) = pending.cloned() else { - return Ok(None); - }; - self.rosette_last_announced - .insert(session.clone(), revision); - Ok(Some(CarrierNotice { - session, - epoch: SceneEpoch(1), - revision, - })) - } -} - -impl PresentationSource for KnotEndpoint { - type Error = String; - - fn resource(&mut self, request: ResourceRequest) -> Result { - let bytes = if request.session == self.session { - self.resources.get(&request.resource) - } else { - self.rosette_resources - .get(&request.session) - .and_then(|resources| resources.get(&request.resource)) - } - .cloned() - .ok_or_else(|| "resource was not disclosed by this Knot session".to_string())?; - Ok(ResourceResponse { - session: request.session, - resource: request.resource, - bytes, - }) - } -} - -impl IntentSink for KnotEndpoint { - type Error = String; - - fn invoke(&mut self, intent: IntentInvocation) -> Result { - if intent.session != self.session { - if self.rosette_snapshots.contains_key(&intent.session) - || self.current_rosette_document(&intent.session).is_some() - { - return Ok(IntentResult::Rejected { - reason: "Knot Rosette projections are read-only".into(), - }); - } - return Err("intent names the wrong Knot session".into()); - } - self.refresh()?; - let source_revision = self.revision(); - let Some(snapshot) = &self.snapshot else { - return Err("intent arrived before a Knot snapshot".into()); - }; - if snapshot.scene.revision != source_revision { - let announced = self.last_announced; - let current = self.build_snapshot()?; - self.last_announced = announced; - return Ok(IntentResult::Stale { - current_epoch: current.scene.epoch, - current_revision: current.scene.revision, - }); - } - if intent.observed_epoch != snapshot.scene.epoch - || intent.observed_revision != snapshot.scene.revision - { - return Ok(IntentResult::Stale { - current_epoch: snapshot.scene.epoch, - current_revision: snapshot.scene.revision, - }); - } - let expected_schema = match intent.intent.as_str() { - EDITABLE_TEXT_SAVE_INTENT => EDITABLE_TEXT_SAVE_SCHEMA, - KNOT_CLIP_INSERT_INTENT => self.clip_insert_schema(), - KNOT_TRANSCLUSION_RESOLVE_INTENT => KNOT_TRANSCLUSION_RESOLVE_SCHEMA, - KNOT_BLOCK_RUN_INTENT => KNOT_BLOCK_RUN_SCHEMA, - _ => { - return Ok(IntentResult::Rejected { - reason: "intent was not advertised by this Knot endpoint".into(), - }); - } - }; - if !snapshot - .presentation - .offers_for(intent.target) - .into_iter() - .flatten() - .flat_map(|offer| &offer.semantics.actions) - .any(|action| { - action.intent.0 == intent.intent && action.payload_schema == expected_schema - }) - { - return Ok(IntentResult::Rejected { - reason: "intent was not advertised for this target".into(), - }); - } - let Some(document_id) = self.bindings.get(&intent.target.0).cloned() else { - return Ok(IntentResult::Rejected { - reason: "intent target is not bound in this snapshot".into(), - }); - }; - match intent.intent.as_str() { - EDITABLE_TEXT_SAVE_INTENT => { - let payload: SaveTextV1 = match serde_json::from_slice(&intent.payload) { - Ok(payload) => payload, - Err(_) => { - return Ok(IntentResult::Rejected { - reason: "save payload does not match graphshell.editable-text.save/v1" - .into(), - }); - } - }; - self.save_text(&document_id, payload) - } - KNOT_CLIP_INSERT_INTENT => { - if self.clip_evidence.is_some() { - let payload: InsertKnotClipV2 = match serde_json::from_slice(&intent.payload) { - Ok(payload) => payload, - Err(_) => { - return Ok(IntentResult::Rejected { - reason: "clip payload does not match knot.clip.insert/v2".into(), - }); - } - }; - self.insert_clip_v2(&document_id, payload) - } else { - let payload: InsertKnotClipV1 = match serde_json::from_slice(&intent.payload) { - Ok(payload) => payload, - Err(_) => { - return Ok(IntentResult::Rejected { - reason: "clip payload does not match knot.clip.insert/v1".into(), - }); - } - }; - self.insert_clip(&document_id, payload) - } - } - KNOT_TRANSCLUSION_RESOLVE_INTENT | KNOT_BLOCK_RUN_INTENT => { - let payload: KnotEffectV1 = match serde_json::from_slice(&intent.payload) { - Ok(payload) => payload, - Err(_) => { - return Ok(IntentResult::Rejected { - reason: format!( - "effect payload does not match {}", - if intent.intent == KNOT_TRANSCLUSION_RESOLVE_INTENT { - KNOT_TRANSCLUSION_RESOLVE_SCHEMA - } else { - KNOT_BLOCK_RUN_SCHEMA - } - ), - }); - } - }; - if intent.intent == KNOT_TRANSCLUSION_RESOLVE_INTENT { - self.resolve_transclusions(&document_id, payload) - } else { - self.run_blocks(&document_id, payload) - } - } - _ => unreachable!("intent kind was checked above"), - } - } -} - -#[cfg(test)] -mod tests { - use std::fs; - - use chirograph::{ - AdvertisedAction, EditableTextV1, InsertKnotClipV1, InsertKnotClipV2, - KnotClipArtifactRoleV1, KnotClipArtifactV1, KnotClipFidelityV1, KnotClipObservedEdgeV1, - KnotClipSelectorV1, PresentationCodec, ResourceRequest, ResumeReply, ResumeRequest, - SaveTextV1, - }; - use graphshell_endpoint::{ - IntentSink, PresentationSource, ProjectionCatalog, ProjectionNoticeSource, - ProjectionSource, ResumableProjectionSource, - }; - use p2panda_core::SigningKey; - use tempfile::tempdir; - - use super::*; - - fn editable_resource( - endpoint: &mut KnotEndpoint, - snapshot: &ProjectionSnapshot, - address_suffix: &str, - ) -> (InstanceId, EditableTextV1, AdvertisedAction) { - for (instance, _) in snapshot.scene.active_items_in_order() { - let offers = snapshot.presentation.offers_for(instance).unwrap(); - let Some(offer) = offers - .iter() - .find(|offer| offer.codec == PresentationCodec::EditableTextV1) - else { - continue; - }; - let response = endpoint - .resource(ResourceRequest { - session: snapshot.session.clone(), - resource: offer.resource, - }) - .unwrap(); - let editable: EditableTextV1 = serde_json::from_slice(&response.bytes).unwrap(); - if editable.address.ends_with(address_suffix) { - return (instance, editable, offer.semantics.actions[0].clone()); - } - } - panic!("snapshot did not disclose editable {address_suffix}"); - } - - fn save_invocation( - snapshot: &ProjectionSnapshot, - target: InstanceId, - action: &AdvertisedAction, - payload: &SaveTextV1, - ) -> IntentInvocation { - IntentInvocation { - session: snapshot.session.clone(), - target, - observed_epoch: snapshot.scene.epoch, - observed_revision: snapshot.scene.revision, - intent: action.intent.0.clone(), - payload: serde_json::to_vec(payload).unwrap(), - } - } - - fn action_for( - snapshot: &ProjectionSnapshot, - target: InstanceId, - intent: &str, - ) -> AdvertisedAction { - snapshot - .presentation - .offers_for(target) - .unwrap() - .iter() - .flat_map(|offer| &offer.semantics.actions) - .find(|action| action.intent.0 == intent) - .cloned() - .unwrap_or_else(|| panic!("{intent} was not advertised")) - } - - fn clip_invocation( - snapshot: &ProjectionSnapshot, - target: InstanceId, - payload: &InsertKnotClipV1, - ) -> IntentInvocation { - IntentInvocation { - session: snapshot.session.clone(), - target, - observed_epoch: snapshot.scene.epoch, - observed_revision: snapshot.scene.revision, - intent: KNOT_CLIP_INSERT_INTENT.into(), - payload: serde_json::to_vec(payload).unwrap(), - } - } - - fn effect_invocation( - snapshot: &ProjectionSnapshot, - target: InstanceId, - intent: &str, - payload: &KnotEffectV1, - ) -> IntentInvocation { - IntentInvocation { - session: snapshot.session.clone(), - target, - observed_epoch: snapshot.scene.epoch, - observed_revision: snapshot.scene.revision, - intent: intent.into(), - payload: serde_json::to_vec(payload).unwrap(), - } - } - - struct StubFetcher; - - impl KnotEffectFetcher for StubFetcher { - fn fetch(&mut self, address: &str) -> Result { - if address == "file://fixture/included.md" { - Ok(Fetched { - content_type: Some("text/markdown".into()), - body: "## Included\n\nFetched text.\n".into(), - }) - } else { - Err(format!("unexpected fetch: {address}")) - } - } - } - - struct OfflineStubFetcher; - - impl KnotEffectFetcher for OfflineStubFetcher { - fn fetch(&mut self, address: &str) -> Result { - Err(format!("offline: {address}")) - } - - fn cache_version(&self) -> String { - std::any::type_name::().to_string() - } - } - - struct StubHtmlFetcher; - - impl KnotEffectFetcher for StubHtmlFetcher { - fn fetch(&mut self, address: &str) -> Result { - if address == "https://fixture.test/article" { - Ok(Fetched { - content_type: Some("text/html".into()), - body: r#"
-

Visible HTML

-

A safe link.

- - -
"# - .into(), - }) - } else { - Err(format!("unexpected fetch: {address}")) - } - } - } - - #[test] - fn fixture_discloses_cards_and_resources() { - let mut endpoint = KnotEndpoint::fixture(); - let offer = endpoint.describe().projections.remove(0); - let snapshot = endpoint.snapshot(offer.request).unwrap(); - assert_eq!(snapshot.scene.active_item_count(), 3); - assert_eq!(snapshot.presentation.bindings.len(), 3); - let resource = snapshot.presentation.offers.values().next().unwrap()[0].resource; - let response = endpoint - .resource(ResourceRequest { - session: snapshot.session, - resource, - }) - .unwrap(); - assert!(response.has_valid_address()); - } - - #[test] - fn writable_file_discloses_editable_text_only_to_protocol_1_2() { - let temp = tempdir().unwrap(); - fs::write(temp.path().join("field.knot"), "# Field\n").unwrap(); - let grant = KnotWriteGrant::new(1024); - let mut endpoint = KnotEndpoint::open_writable(temp.path(), grant).unwrap(); - let request = endpoint.describe().projections.remove(0).request; - let snapshot = endpoint.snapshot(request).unwrap(); - let (_, editable, action) = editable_resource(&mut endpoint, &snapshot, "field.knot"); - assert_eq!(editable.source, "# Field\n"); - assert_eq!(action.intent.0, EDITABLE_TEXT_SAVE_INTENT); - assert_eq!(action.payload_schema, EDITABLE_TEXT_SAVE_SCHEMA); - let clip_action = action_for(&snapshot, InstanceId(0), KNOT_CLIP_INSERT_INTENT); - assert_eq!(clip_action.payload_schema, KNOT_CLIP_INSERT_SCHEMA); - assert_eq!(clip_action.effect, IntentEffect::DomainTruth); - assert!( - snapshot - .presentation - .offers_for(InstanceId(0)) - .unwrap() - .iter() - .flat_map(|offer| &offer.semantics.actions) - .all(|action| { - action.intent.0 != KNOT_TRANSCLUSION_RESOLVE_INTENT - && action.intent.0 != KNOT_BLOCK_RUN_INTENT - }), - "Never/default effect policy must not advertise Resolve or Run" - ); - assert_eq!( - snapshot.cache_policy, - CachePolicy { - retention: chirograph::CacheRetention::MemoryOnly, - expires_at_ms: None, - purge_on_revocation: true, - } - ); - - let mut old_endpoint = KnotEndpoint::open_writable(temp.path(), grant).unwrap(); - let mut old_request = old_endpoint.describe().projections.remove(0).request; - old_request.version = ProtocolVersion::V1_1; - let old_snapshot = old_endpoint.snapshot(old_request).unwrap(); - assert!( - old_snapshot - .presentation - .offers - .values() - .flatten() - .all(|offer| offer.codec != PresentationCodec::EditableTextV1) - ); - - let mut read_only = KnotEndpoint::open(temp.path()).unwrap(); - let request = read_only.describe().projections.remove(0).request; - let snapshot = read_only.snapshot(request).unwrap(); - assert!( - snapshot - .presentation - .offers - .values() - .flatten() - .all(|offer| offer.codec != PresentationCodec::EditableTextV1) - ); - } - - #[test] - fn file_save_is_revision_checked_and_rings_once() { - let temp = tempdir().unwrap(); - let path = temp.path().join("field.knot"); - fs::write(&path, "# Field\n").unwrap(); - let mut endpoint = - KnotEndpoint::open_writable(temp.path(), KnotWriteGrant::new(1024)).unwrap(); - let request = endpoint.describe().projections.remove(0).request; - let snapshot = endpoint.snapshot(request).unwrap(); - let (target, editable, action) = editable_resource(&mut endpoint, &snapshot, "field.knot"); - - let accepted = endpoint - .invoke(save_invocation( - &snapshot, - target, - &action, - &SaveTextV1 { - base_token: editable.base_token.clone(), - source: "# Revised\n".into(), - }, - )) - .unwrap(); - assert_eq!(accepted, IntentResult::Accepted); - assert_eq!(fs::read_to_string(&path).unwrap(), "# Revised\n"); - let notice = endpoint.poll_notice().unwrap().unwrap(); - assert!(notice.revision > snapshot.scene.revision); - assert_eq!(endpoint.poll_notice().unwrap(), None); - - let resumed = endpoint - .resume(ResumeRequest { - session: snapshot.session.clone(), - epoch: snapshot.scene.epoch, - revision: snapshot.scene.revision, - }) - .unwrap(); - let ResumeReply::Snapshot(current) = resumed else { - panic!("accepted save must advance the projection"); - }; - let stale = endpoint - .invoke(save_invocation( - ¤t, - target, - &action, - &SaveTextV1 { - base_token: editable.base_token, - source: "# Lost update\n".into(), - }, - )) - .unwrap(); - assert!(matches!(stale, IntentResult::Stale { .. })); - assert_eq!(fs::read_to_string(&path).unwrap(), "# Revised\n"); - - let malformed = IntentInvocation { - session: current.session.clone(), - target, - observed_epoch: current.scene.epoch, - observed_revision: current.scene.revision, - intent: action.intent.0, - payload: br#"{"source":"missing token"}"#.to_vec(), - }; - assert!(matches!( - endpoint.invoke(malformed).unwrap(), - IntentResult::Rejected { .. } - )); - assert_eq!(fs::read_to_string(&path).unwrap(), "# Revised\n"); - } - - #[test] - fn clip_insert_records_typed_provenance_and_refuses_stale_or_invalid_input() { - let temp = tempdir().unwrap(); - let path = temp.path().join("field.knot"); - fs::write(&path, "# Field\n").unwrap(); - let mut endpoint = - KnotEndpoint::open_writable(temp.path(), KnotWriteGrant::new(4096)).unwrap(); - let request = endpoint.describe().projections.remove(0).request; - let snapshot = endpoint.snapshot(request).unwrap(); - let (target, editable, _) = editable_resource(&mut endpoint, &snapshot, "field.knot"); - assert_eq!( - endpoint - .invoke(clip_invocation( - &snapshot, - target, - &InsertKnotClipV1 { - base_token: editable.base_token.clone(), - source_url: "https://example.test/post".into(), - title: Some("A finding".into()), - selector: Some("main > article".into()), - knot_body: "A useful paragraph.\n".into(), - }, - )) - .unwrap(), - IntentResult::Accepted - ); - let saved = fs::read_to_string(&path).unwrap(); - assert!(saved.starts_with("# Field\n\n```knot.clip.provenance\n")); - assert!(saved.contains(r#""schema":"knot.clip.insert/v1""#)); - assert!(saved.contains(r#""source_url":"https://example.test/post""#)); - assert!(saved.ends_with("A useful paragraph.\n")); - - let notice = endpoint.poll_notice().unwrap().unwrap(); - assert!(notice.revision > snapshot.scene.revision); - let ResumeReply::Snapshot(current) = endpoint - .resume(ResumeRequest { - session: snapshot.session.clone(), - epoch: snapshot.scene.epoch, - revision: snapshot.scene.revision, - }) - .unwrap() - else { - panic!("clip insert must refresh the projection"); - }; - let stale = endpoint - .invoke(clip_invocation( - ¤t, - target, - &InsertKnotClipV1 { - base_token: editable.base_token, - source_url: "https://example.test/stale".into(), - title: None, - selector: None, - knot_body: "Lost update.\n".into(), - }, - )) - .unwrap(); - assert!(matches!(stale, IntentResult::Stale { .. })); - assert_eq!(fs::read_to_string(&path).unwrap(), saved); - - let (_, current_editable, _) = editable_resource(&mut endpoint, ¤t, "field.knot"); - let invalid = endpoint - .invoke(clip_invocation( - ¤t, - target, - &InsertKnotClipV1 { - base_token: current_editable.base_token, - source_url: "relative/path".into(), - title: None, - selector: None, - knot_body: "Bad source.\n".into(), - }, - )) - .unwrap(); - assert!(matches!(invalid, IntentResult::Rejected { .. })); - assert_eq!(fs::read_to_string(&path).unwrap(), saved); - } - - #[test] - fn evidence_clip_retains_bytes_and_authors_only_a_portable_reference() { - let temp = tempdir().unwrap(); - let evidence_temp = tempdir().unwrap(); - let evidence = evidence_temp.path(); - let path = temp.path().join("field.djot"); - fs::write(&path, "# Field\n").unwrap(); - let mut endpoint = - KnotEndpoint::open_writable(temp.path(), KnotWriteGrant::new(4096)).unwrap(); - endpoint.grant_clip_evidence(crate::FileClipEvidenceStore::new(&evidence, 4096)); - let request = endpoint.describe().projections.remove(0).request; - let snapshot = endpoint.snapshot(request).unwrap(); - let (target, editable, _) = editable_resource(&mut endpoint, &snapshot, "field.djot"); - let action = action_for(&snapshot, target, KNOT_CLIP_INSERT_INTENT); - assert_eq!(action.payload_schema, KNOT_CLIP_INSERT_SCHEMA_V2); - - let bytes = b"

A useful finding.

".to_vec(); - let digest = blake3::hash(&bytes).to_hex().to_string(); - let accepted = endpoint - .invoke(clip_v2_invocation( - &snapshot, - target, - &InsertKnotClipV2 { - base_token: editable.base_token, - source_url: "https://example.test/report".into(), - title: Some("The report".into()), - selectors: vec![KnotClipSelectorV1::TextQuote { - artifact_role: KnotClipArtifactRoleV1::SourceResponse, - exact: "A useful finding.".into(), - prefix: None, - suffix: None, - }], - knot_body: "A useful finding.\n".into(), - artifacts: vec![KnotClipArtifactV1 { - role: KnotClipArtifactRoleV1::SourceResponse, - media_type: "text/html".into(), - canonical_uri: "https://example.test/report".into(), - bytes: bytes.clone(), - }], - fidelity: vec![KnotClipFidelityV1 { - class: "arrangement-unchecked".into(), - detail: "Static source capture did not compare computed layout.".into(), - selector: None, - }], - discovered_edges: vec![KnotClipObservedEdgeV1 { - target: "https://example.test/source".into(), - relation: "link".into(), - }], - }, - )) - .unwrap(); - assert_eq!(accepted, IntentResult::Accepted); - assert_eq!( - fs::read(evidence.join("blake3").join(&digest)).unwrap(), - bytes - ); - - let saved = fs::read_to_string(path).unwrap(); - assert!(saved.contains(r#""schema":"knot.clip.insert/v2""#)); - assert!(saved.contains(&chirograph::Sha256NamedInformation::of(&bytes).to_string())); - assert!(saved.contains(&format!("blake3:{digest}"))); - assert!(!saved.contains("urn:blake3:")); - assert!(saved.contains(r#""class":"arrangement-unchecked""#)); - assert!(saved.contains(r#""relation":"link""#)); - assert!(!saved.contains("
")); - } - - #[test] - fn dom_range_selectors_require_an_observed_representation() { - let source = KnotClipArtifactV1 { - role: KnotClipArtifactRoleV1::SourceResponse, - media_type: "text/html".into(), - canonical_uri: "https://example.test/report".into(), - bytes: b"

A useful finding.

".to_vec(), - }; - let selector = KnotClipSelectorV1::DomRange { - artifact_role: KnotClipArtifactRoleV1::SourceResponse, - anchor_path: vec![0, 1], - anchor_offset: 0, - focus_path: vec![0, 1], - focus_offset: 17, - quote: "A useful finding.".into(), - }; - assert!(!selector_matches_artifacts(&selector, &[source])); - - let observed = KnotClipArtifactV1 { - role: KnotClipArtifactRoleV1::ObservedRepresentation, - media_type: "application/vnd.mere.dom+json".into(), - canonical_uri: "https://example.test/report".into(), - bytes: br#"{"node":"p","text":"A useful finding."}"#.to_vec(), - }; - let selector = KnotClipSelectorV1::DomRange { - artifact_role: KnotClipArtifactRoleV1::ObservedRepresentation, - anchor_path: vec![0, 1], - anchor_offset: 0, - focus_path: vec![0, 1], - focus_offset: 17, - quote: "A useful finding.".into(), - }; - assert!(selector_matches_artifacts(&selector, &[observed])); - } - - #[test] - fn resolve_and_run_are_consented_revisioned_derived_state() { - let temp = tempdir().unwrap(); - let path = temp.path().join("field.knot"); - let authored = "\ -# Field - -```include file://fixture/included.md -Fallback. -``` - -```rhai eval -40 + 2 -``` -"; - fs::write(&path, authored).unwrap(); - let policy = KnotEffectPolicy { - resolve: KnotEffectMode::Ask, - run: KnotEffectMode::Ask, - allowed_schemes: vec!["file".into()], - allowed_languages: vec!["rhai".into()], - max_depth: 1, - max_ops: 10_000, - }; - let effects = KnotEffectAuthority::new(policy) - .with_fetcher(StubFetcher) - .register_evaluator(script_rhai::RhaiEvaluator::new()); - let mut endpoint = - KnotEndpoint::open_writable(temp.path(), KnotWriteGrant::new(4096)).unwrap(); - endpoint.grant_effects(effects); - let request = endpoint.describe().projections.remove(0).request; - let snapshot = endpoint.snapshot(request).unwrap(); - let (target, editable, _) = editable_resource(&mut endpoint, &snapshot, "field.knot"); - assert!(editable.derived.is_none()); - let resolve_action = action_for(&snapshot, target, KNOT_TRANSCLUSION_RESOLVE_INTENT); - let run_action = action_for(&snapshot, target, KNOT_BLOCK_RUN_INTENT); - assert_eq!(resolve_action.effect, IntentEffect::ExternalEffect); - assert_eq!(run_action.effect, IntentEffect::ExternalEffect); - - let unconfirmed = endpoint - .invoke(effect_invocation( - &snapshot, - target, - KNOT_TRANSCLUSION_RESOLVE_INTENT, - &KnotEffectV1 { - base_token: editable.base_token.clone(), - confirmed: false, - }, - )) - .unwrap(); - assert!(matches!(unconfirmed, IntentResult::Rejected { .. })); - assert_eq!(endpoint.poll_notice().unwrap(), None); - - assert_eq!( - endpoint - .invoke(effect_invocation( - &snapshot, - target, - KNOT_TRANSCLUSION_RESOLVE_INTENT, - &KnotEffectV1 { - base_token: editable.base_token.clone(), - confirmed: true, - }, - )) - .unwrap(), - IntentResult::Accepted - ); - assert_eq!(fs::read_to_string(&path).unwrap(), authored); - let resolved_notice = endpoint.poll_notice().unwrap().unwrap(); - assert!(resolved_notice.revision > snapshot.scene.revision); - let ResumeReply::Snapshot(resolved) = endpoint - .resume(ResumeRequest { - session: snapshot.session.clone(), - epoch: snapshot.scene.epoch, - revision: snapshot.scene.revision, - }) - .unwrap() - else { - panic!("resolve must refresh the derived presentation"); - }; - let (_, resolved_text, _) = editable_resource(&mut endpoint, &resolved, "field.knot"); - let derived = resolved_text.derived.expect("resolve result"); - assert!( - derived.source.contains("Included"), - "derived source: {}\nsummary: {}", - derived.source, - derived.summary - ); - assert!(derived.source.contains("Fetched text.")); - assert!(derived.source.contains("rhai eval")); - assert_eq!(derived.summary, "resolved 1; denied 0; failed 0"); - - assert_eq!( - endpoint - .invoke(effect_invocation( - &resolved, - target, - KNOT_BLOCK_RUN_INTENT, - &KnotEffectV1 { - base_token: resolved_text.base_token, - confirmed: true, - }, - )) - .unwrap(), - IntentResult::Accepted - ); - assert_eq!(fs::read_to_string(&path).unwrap(), authored); - let run_notice = endpoint.poll_notice().unwrap().unwrap(); - assert!(run_notice.revision > resolved.scene.revision); - let ResumeReply::Snapshot(ran) = endpoint - .resume(ResumeRequest { - session: resolved.session, - epoch: resolved.scene.epoch, - revision: resolved.scene.revision, - }) - .unwrap() - else { - panic!("run must refresh the derived presentation"); - }; - let (_, ran_text, _) = editable_resource(&mut endpoint, &ran, "field.knot"); - let ran_base_token = ran_text.base_token.clone(); - let derived = ran_text.derived.expect("run result"); - assert!(derived.source.contains("Included")); - assert!(derived.source.contains("42")); - assert!(!derived.source.contains("rhai eval")); - assert_eq!(derived.summary, "ran 1; denied 0; failed 0"); - - fs::write(&path, "# Changed elsewhere\n").unwrap(); - let stale = endpoint - .invoke(effect_invocation( - &ran, - target, - KNOT_BLOCK_RUN_INTENT, - &KnotEffectV1 { - base_token: ran_base_token, - confirmed: true, - }, - )) - .unwrap(); - assert!(matches!(stale, IntentResult::Stale { .. })); - assert_eq!(fs::read_to_string(path).unwrap(), "# Changed elsewhere\n"); - } - - #[test] - fn html_transclusion_lowers_only_the_sanitized_semantic_fragment() { - let temp = tempdir().unwrap(); - let path = temp.path().join("field.knot"); - let authored = "# Field\n\n```include https://fixture.test/article\nFallback.\n```\n"; - fs::write(&path, authored).unwrap(); - let effects = KnotEffectAuthority::new(KnotEffectPolicy { - resolve: KnotEffectMode::Ask, - allowed_schemes: vec!["https".into()], - max_depth: 1, - ..KnotEffectPolicy::default() - }) - .with_fetcher(StubHtmlFetcher); - let mut endpoint = - KnotEndpoint::open_writable(temp.path(), KnotWriteGrant::new(4096)).unwrap(); - endpoint.grant_effects(effects); - let request = endpoint.describe().projections.remove(0).request; - let snapshot = endpoint.snapshot(request).unwrap(); - let (target, editable, _) = editable_resource(&mut endpoint, &snapshot, "field.knot"); - - assert_eq!( - endpoint - .invoke(effect_invocation( - &snapshot, - target, - KNOT_TRANSCLUSION_RESOLVE_INTENT, - &KnotEffectV1 { - base_token: editable.base_token, - confirmed: true, - }, - )) - .unwrap(), - IntentResult::Accepted - ); - let ResumeReply::Snapshot(current) = endpoint - .resume(ResumeRequest { - session: snapshot.session, - epoch: snapshot.scene.epoch, - revision: snapshot.scene.revision, - }) - .unwrap() - else { - panic!("HTML resolve must refresh the derived presentation"); - }; - let (_, current, _) = editable_resource(&mut endpoint, ¤t, "field.knot"); - let derived = current.derived.expect("HTML resolve result"); - assert!(derived.source.contains("Visible HTML")); - assert!(derived.source.contains("safe link")); - assert!(!derived.source.contains("SECRET_SCRIPT")); - assert!(!derived.source.contains("SECRET_FRAME")); - assert!(!derived.source.contains("onclick")); - assert!(!derived.source.contains("display:none")); - assert_eq!(derived.summary, "resolved 1; denied 0; failed 0"); - assert_eq!(fs::read_to_string(path).unwrap(), authored); - } - - #[test] - fn auto_run_stops_at_the_injected_operation_budget() { - let temp = tempdir().unwrap(); - let path = temp.path().join("field.knot"); - let authored = "# Field\n\n```rhai eval\nloop { }\n```\n"; - fs::write(&path, authored).unwrap(); - let policy = KnotEffectPolicy { - run: KnotEffectMode::Auto, - allowed_languages: vec!["rhai".into()], - max_ops: 100, - ..KnotEffectPolicy::default() - }; - let effects = - KnotEffectAuthority::new(policy).register_evaluator(script_rhai::RhaiEvaluator::new()); - let mut endpoint = - KnotEndpoint::open_writable(temp.path(), KnotWriteGrant::new(4096)).unwrap(); - endpoint.grant_effects(effects); - let request = endpoint.describe().projections.remove(0).request; - let snapshot = endpoint.snapshot(request).unwrap(); - let (target, editable, _) = editable_resource(&mut endpoint, &snapshot, "field.knot"); - - assert_eq!( - endpoint - .invoke(effect_invocation( - &snapshot, - target, - KNOT_BLOCK_RUN_INTENT, - &KnotEffectV1 { - base_token: editable.base_token, - confirmed: false, - }, - )) - .unwrap(), - IntentResult::Accepted - ); - let ResumeReply::Snapshot(current) = endpoint - .resume(ResumeRequest { - session: snapshot.session, - epoch: snapshot.scene.epoch, - revision: snapshot.scene.revision, - }) - .unwrap() - else { - panic!("bounded run must produce a derived receipt"); - }; - let (_, current, _) = editable_resource(&mut endpoint, ¤t, "field.knot"); - let derived = current.derived.expect("bounded failure result"); - assert_eq!(derived.summary, "ran 0; denied 0; failed 1"); - assert!(derived.source.contains("loop { }")); - assert_eq!(fs::read_to_string(path).unwrap(), authored); - } - - #[test] - fn unrelated_directory_churn_does_not_invalidate_the_document_token() { - let temp = tempdir().unwrap(); - let field = temp.path().join("field.knot"); - let other = temp.path().join("other.knot"); - fs::write(&field, "# Field\n").unwrap(); - fs::write(&other, "# Other\n").unwrap(); - let mut endpoint = - KnotEndpoint::open_writable(temp.path(), KnotWriteGrant::new(1024)).unwrap(); - let request = endpoint.describe().projections.remove(0).request; - let snapshot = endpoint.snapshot(request).unwrap(); - let (_, editable, _) = editable_resource(&mut endpoint, &snapshot, "field.knot"); - - fs::write(&other, "# Other changed\n").unwrap(); - let resumed = endpoint - .resume(ResumeRequest { - session: snapshot.session.clone(), - epoch: snapshot.scene.epoch, - revision: snapshot.scene.revision, - }) - .unwrap(); - let ResumeReply::Snapshot(current) = resumed else { - panic!("unrelated edit must advance the scene"); - }; - let (target, refreshed, action) = editable_resource(&mut endpoint, ¤t, "field.knot"); - assert_eq!(editable.base_token, refreshed.base_token); - assert_eq!( - endpoint - .invoke(save_invocation( - ¤t, - target, - &action, - &SaveTextV1 { - base_token: editable.base_token, - source: "# Field changed\n".into(), - }, - )) - .unwrap(), - IntentResult::Accepted - ); - assert_eq!(fs::read_to_string(field).unwrap(), "# Field changed\n"); - } - - #[test] - fn disk_edit_is_visible_on_next_resume() { - let temp = tempdir().unwrap(); - let path = temp.path().join("field.knot"); - fs::write(&path, "one").unwrap(); - let mut endpoint = KnotEndpoint::open(temp.path()).unwrap(); - let request = endpoint.describe().projections.remove(0).request; - let snapshot = endpoint.snapshot(request.clone()).unwrap(); - - fs::write(&path, "one two three").unwrap(); - let reply = endpoint - .resume(ResumeRequest { - session: request.session, - epoch: snapshot.scene.epoch, - revision: snapshot.scene.revision, - }) - .unwrap(); - let ResumeReply::Snapshot(next) = reply else { - panic!("changed directory should return a replacement snapshot"); - }; - assert!(next.scene.revision > snapshot.scene.revision); - let offer = next.presentation.offers.values().next().unwrap(); - let bytes = endpoint - .resource(ResourceRequest { - session: next.session, - resource: offer[0].resource, - }) - .unwrap() - .bytes; - let card: PortableCardV1 = serde_json::from_slice(&bytes).unwrap(); - assert_eq!(card.values[2].value, "13 bytes"); - } - - #[test] - fn disk_edit_rings_once_before_the_host_resumes() { - let temp = tempdir().unwrap(); - let path = temp.path().join("field.knot"); - fs::write(&path, "one").unwrap(); - let mut endpoint = KnotEndpoint::open(temp.path()).unwrap(); - let request = endpoint.describe().projections.remove(0).request; - let snapshot = endpoint.snapshot(request).unwrap(); - - fs::write(&path, "one two three").unwrap(); - let notice = endpoint.poll_notice().unwrap().unwrap(); - assert_eq!(notice.session, snapshot.session); - assert_eq!(notice.epoch, snapshot.scene.epoch); - assert!(notice.revision > snapshot.scene.revision); - assert_eq!(endpoint.poll_notice().unwrap(), None); - } - - fn clip_v2_invocation( - snapshot: &ProjectionSnapshot, - target: InstanceId, - payload: &InsertKnotClipV2, - ) -> IntentInvocation { - IntentInvocation { - session: snapshot.session.clone(), - target, - observed_epoch: snapshot.scene.epoch, - observed_revision: snapshot.scene.revision, - intent: KNOT_CLIP_INSERT_INTENT.into(), - payload: serde_json::to_vec(payload).unwrap(), - } - } - - #[test] - fn rosette_sessions_ring_independently_and_drop_deleted_source() { - let temp = tempdir().unwrap(); - let poem = temp.path().join("poem.knot"); - fs::write( - &poem, - "Morning gathers light\nBranches answer night\n\nFootsteps cross the hill\nEvening settles still\n", - ) - .unwrap(); - fs::write( - temp.path().join("lyric.knot"), - "Raise your open hand\nWe will take a stand\n\nCarry home the song\nLet the road run long\n", - ) - .unwrap(); - let mut endpoint = KnotEndpoint::open(temp.path()).unwrap(); - let descriptor = endpoint.describe(); - assert_eq!(descriptor.projections.len(), 3); - let sessions = descriptor - .projections - .into_iter() - .map(|offer| { - let session = offer.request.session.clone(); - endpoint.snapshot(offer.request).unwrap(); - session - }) - .collect::>(); - - fs::write(&poem, "The final bell\nAnswers well\n").unwrap(); - let notices = (0..sessions.len()) - .map(|_| endpoint.poll_notice().unwrap().unwrap().session) - .collect::>(); - assert_eq!(notices, sessions); - assert_eq!(endpoint.poll_notice().unwrap(), None); - - let poem_request = endpoint - .describe() - .projections - .into_iter() - .find(|offer| offer.label.contains("poem")) - .unwrap() - .request; - let snapshot = endpoint.snapshot(poem_request.clone()).unwrap(); - let old_resource = snapshot.presentation.offers.values().next().unwrap()[0].resource; - fs::remove_file(poem).unwrap(); - let reply = endpoint - .resume(ResumeRequest { - session: poem_request.session.clone(), - epoch: snapshot.scene.epoch, - revision: snapshot.scene.revision, - }) - .unwrap(); - let ResumeReply::Snapshot(removed) = reply else { - panic!("a removed Rosette source must replace the stale scene"); - }; - assert_eq!(removed.scene.active_item_count(), 0); - assert!(removed.presentation.bindings.is_empty()); - assert!( - endpoint - .resource(ResourceRequest { - session: poem_request.session, - resource: old_resource, - }) - .is_err(), - "removed source resources must leave the session" - ); - } - - #[test] - fn unchanged_resume_is_current() { - let temp = tempdir().unwrap(); - fs::write(temp.path().join("field.knot"), "one").unwrap(); - let mut endpoint = KnotEndpoint::open(temp.path()).unwrap(); - let request = endpoint.describe().projections.remove(0).request; - let snapshot = endpoint.snapshot(request.clone()).unwrap(); - let reply = endpoint - .resume(ResumeRequest { - session: request.session, - epoch: snapshot.scene.epoch, - revision: snapshot.scene.revision, - }) - .unwrap(); - assert!(matches!(reply, ResumeReply::Current(_))); - } - - #[test] - fn revoked_watcher_holds_the_last_revision_until_regranted() { - let temp = tempdir().unwrap(); - let path = temp.path().join("field.knot"); - fs::write(&path, "one").unwrap(); - let mut endpoint = KnotEndpoint::open(temp.path()).unwrap(); - let request = endpoint.describe().projections.remove(0).request; - let snapshot = endpoint.snapshot(request.clone()).unwrap(); - - assert!(endpoint.revoke_watcher()); - fs::write(&path, "one two three").unwrap(); - let paused = endpoint - .resume(ResumeRequest { - session: request.session.clone(), - epoch: snapshot.scene.epoch, - revision: snapshot.scene.revision, - }) - .unwrap(); - assert!(matches!(paused, ResumeReply::Current(_))); - - assert!(endpoint.grant_watcher()); - let resumed = endpoint - .resume(ResumeRequest { - session: request.session, - epoch: snapshot.scene.epoch, - revision: snapshot.scene.revision, - }) - .unwrap(); - assert!(matches!(resumed, ResumeReply::Snapshot(_))); - } - - #[test] - fn sealed_vault_save_is_one_signed_event_then_a_rematerialized_view() { - let vault_dir = tempdir().unwrap(); - let sync_dir = tempdir().unwrap(); - let key = [0x91; 32]; - let seed = [0x41; 32]; - let writer = *SigningKey::from_bytes(&seed).verifying_key().as_bytes(); - let space = [0x51; 32]; - let vault = KnotVault::open(vault_dir.path(), key).unwrap(); - let store = - KnotSyncFileStore::open(sync_dir.path().join("knot.redb"), space, [writer]).unwrap(); - pollster::block_on(store.author( - seed, - &vault, - &KnotSyncEvent::Put(VaultDocument { - id: "field-note".into(), - title: "Field note".into(), - body: b"# Private\n".to_vec(), - media_type: "text/vnd.knot".into(), - }), - )) - .unwrap(); - let inspection_store = store.clone(); - let initial_head = pollster::block_on(inspection_store.projection(&vault)) - .unwrap() - .document_heads["field-note"]; - - let mut endpoint = - KnotEndpoint::from_synced_vault(vault, store, seed, KnotWriteGrant::new(4096)).unwrap(); - let request = endpoint.describe().projections.remove(0).request; - let snapshot = endpoint.snapshot(request).unwrap(); - let (target, editable, action) = editable_resource(&mut endpoint, &snapshot, "field-note"); - let editable_hash = snapshot - .presentation - .offers_for(target) - .unwrap() - .iter() - .find(|offer| offer.codec == PresentationCodec::EditableTextV1) - .unwrap() - .resource; - assert_eq!(editable.source, "# Private\n"); - assert_eq!( - endpoint - .invoke(save_invocation( - &snapshot, - target, - &action, - &SaveTextV1 { - base_token: editable.base_token, - source: "# Private revised\n".into(), - }, - )) - .unwrap(), - IntentResult::Accepted - ); - - let Source::Vault(source) = &endpoint.source else { - unreachable!() - }; - { - let source = source.state(); - let projection = - pollster::block_on(inspection_store.projection(&source.vault)).unwrap(); - assert_eq!(projection.documents[0].body, b"# Private revised\n"); - assert_ne!(projection.document_heads["field-note"], initial_head); - assert_eq!( - source.vault.body("field-note"), - Some(&b"# Private revised\n"[..]) - ); - } - let sealed = fs::read(vault_dir.path().join("knot/documents.json")).unwrap(); - assert!( - !sealed - .windows(b"# Private revised\n".len()) - .any(|window| window == b"# Private revised\n") - ); - - assert!(endpoint.lock_vault()); - assert!( - endpoint - .resource(ResourceRequest { - session: snapshot.session, - resource: editable_hash, - }) - .is_err(), - "locking purges previously disclosed source resources" - ); - drop(endpoint); - - let reopened = KnotVault::open(vault_dir.path(), key).unwrap(); - assert_eq!( - reopened.body("field-note"), - Some(&b"# Private revised\n"[..]) - ); - } - - #[test] - fn resident_vault_sessions_share_truth_without_sharing_notice_cursors() { - let vault_dir = tempdir().unwrap(); - let sync_dir = tempdir().unwrap(); - let evidence_dir = tempdir().unwrap(); - let database = sync_dir.path().join("knot.redb"); - let key = [0xa1; 32]; - let seed = [0x51; 32]; - let writer = *SigningKey::from_bytes(&seed).verifying_key().as_bytes(); - let space = [0x61; 32]; - let vault = KnotVault::open(vault_dir.path(), key).unwrap(); - let store = KnotSyncFileStore::open(&database, space, [writer]).unwrap(); - pollster::block_on(store.author( - seed, - &vault, - &KnotSyncEvent::Put(VaultDocument { - id: "field-note".into(), - title: "Field note".into(), - body: b"# Shared\n".to_vec(), - media_type: "text/vnd.knot".into(), - }), - )) - .unwrap(); - - let resident = KnotResidentSource::from_synced_vault(vault, store, seed).unwrap(); - resident.grant_content_retention( - crate::BlobClipEvidenceStore::open(evidence_dir.path(), 4096).unwrap(), - ); - assert!(resident.content_blob_store().is_some()); - let sync_handle = resident.sync_store().expect("resident owns one sync store"); - assert_eq!(sync_handle.space_id(), space); - let mut ada = resident.session(Some(KnotWriteGrant::new(4096))); - let mut bo = resident.session(Some(KnotWriteGrant::new(4096))); - assert_ne!(ada.session(), bo.session()); - assert!(ada.clip_evidence.is_some()); - assert!(bo.clip_evidence.is_some()); - - let ada_request = ada.describe().projections.remove(0).request; - let bo_request = bo.describe().projections.remove(0).request; - let ada_snapshot = ada.snapshot(ada_request).unwrap(); - let bo_snapshot = bo.snapshot(bo_request.clone()).unwrap(); - let (ada_target, ada_editable, ada_action) = - editable_resource(&mut ada, &ada_snapshot, "field-note"); - let (bo_target, bo_editable, bo_action) = - editable_resource(&mut bo, &bo_snapshot, "field-note"); - assert_eq!(ada_editable.base_token, bo_editable.base_token); - - assert_eq!( - ada.invoke(save_invocation( - &ada_snapshot, - ada_target, - &ada_action, - &SaveTextV1 { - base_token: ada_editable.base_token, - source: "# Ada revised\n".into(), - }, - )) - .unwrap(), - IntentResult::Accepted - ); - - let ada_notice = ada.poll_notice().unwrap().expect("Ada hears her save"); - let bo_notice = bo.poll_notice().unwrap().expect("Bo hears Ada's save"); - assert_eq!(ada_notice.session, *ada.session()); - assert_eq!(bo_notice.session, *bo.session()); - assert_eq!(ada.poll_notice().unwrap(), None); - assert_eq!(bo.poll_notice().unwrap(), None); - - let refreshed = match bo - .resume(ResumeRequest { - session: bo_request.session, - epoch: bo_snapshot.scene.epoch, - revision: bo_snapshot.scene.revision, - }) - .unwrap() - { - ResumeReply::Snapshot(snapshot) => *snapshot, - ResumeReply::Current(_) => panic!("Bo's old revision must refresh"), - ResumeReply::Diffs(_) => panic!("Knot currently refreshes with a snapshot"), - }; - let (bo_current_target, current, bo_current_action) = - editable_resource(&mut bo, &refreshed, "field-note"); - assert_eq!(current.source, "# Ada revised\n"); - assert_ne!(current.base_token, bo_editable.base_token); - assert!(matches!( - bo.invoke(save_invocation( - &bo_snapshot, - bo_target, - &bo_action, - &SaveTextV1 { - base_token: bo_editable.base_token, - source: "# Bo stale\n".into(), - }, - )) - .unwrap(), - IntentResult::Stale { .. } - )); - - assert_eq!( - bo.invoke(save_invocation( - &refreshed, - bo_current_target, - &bo_current_action, - &SaveTextV1 { - base_token: current.base_token, - source: "# Bo revised\n".into(), - }, - )) - .unwrap(), - IntentResult::Accepted - ); - let ada_notice = ada.poll_notice().unwrap().expect("Ada hears Bo's save"); - let bo_notice = bo.poll_notice().unwrap().expect("Bo hears his save"); - assert_eq!(ada_notice.session, *ada.session()); - assert_eq!(bo_notice.session, *bo.session()); - assert_eq!(ada.poll_notice().unwrap(), None); - assert_eq!(bo.poll_notice().unwrap(), None); - - let ada_refreshed = match ada - .resume(ResumeRequest { - session: ada_snapshot.session.clone(), - epoch: ada_snapshot.scene.epoch, - revision: ada_snapshot.scene.revision, - }) - .unwrap() - { - ResumeReply::Snapshot(snapshot) => *snapshot, - ResumeReply::Current(_) => panic!("Ada's old revision must refresh"), - ResumeReply::Diffs(_) => panic!("Knot currently refreshes with a snapshot"), - }; - let (_, current, _) = editable_resource(&mut ada, &ada_refreshed, "field-note"); - assert_eq!(current.source, "# Bo revised\n"); - - drop(ada); - drop(bo); - drop(sync_handle); - drop(resident); - - let reopened_vault = KnotVault::open(vault_dir.path(), key).unwrap(); - let reopened_store = KnotSyncFileStore::open(&database, space, [writer]).unwrap(); - let projection = pollster::block_on(reopened_store.projection(&reopened_vault)).unwrap(); - assert_eq!(projection.documents[0].body, b"# Bo revised\n"); - drop(crate::BlobClipEvidenceStore::open(evidence_dir.path(), 4096).unwrap()); - } - - #[test] - fn personal_vault_restores_only_matching_attributable_sealed_cache() { - let vault_dir = tempdir().unwrap(); - let sync_dir = tempdir().unwrap(); - let key = [0x92; 32]; - let seed = [0x42; 32]; - let writer = *SigningKey::from_bytes(&seed).verifying_key().as_bytes(); - let space = [0x52; 32]; - let database = sync_dir.path().join("knot.redb"); - let vault = KnotVault::open(vault_dir.path(), key).unwrap(); - let store = KnotSyncFileStore::open(&database, space, [writer]).unwrap(); - pollster::block_on( - store.author( - seed, - &vault, - &KnotSyncEvent::Put(VaultDocument { - id: "field-note".into(), - title: "Field note".into(), - body: b"# Private\n\n```include file://fixture/included.md\nFallback.\n```\n" - .to_vec(), - media_type: "text/vnd.knot".into(), - }), - ), - ) - .unwrap(); - let policy = KnotEffectPolicy { - resolve: KnotEffectMode::Ask, - allowed_schemes: vec!["file".into()], - max_depth: 1, - ..KnotEffectPolicy::default() - }; - let mut endpoint = - KnotEndpoint::from_synced_vault(vault, store, seed, KnotWriteGrant::new(4096)).unwrap(); - endpoint.grant_effects(KnotEffectAuthority::new(policy.clone()).with_fetcher(StubFetcher)); - let request = endpoint.describe().projections.remove(0).request; - let snapshot = endpoint.snapshot(request).unwrap(); - let (target, editable, _) = editable_resource(&mut endpoint, &snapshot, "field-note"); - assert_eq!( - endpoint - .invoke(effect_invocation( - &snapshot, - target, - KNOT_TRANSCLUSION_RESOLVE_INTENT, - &KnotEffectV1 { - base_token: editable.base_token, - confirmed: true, - }, - )) - .unwrap(), - IntentResult::Accepted - ); - let cache_files = fs::read_dir(vault_dir.path().join("knot/derived-cache")) - .unwrap() - .map(|entry| fs::read(entry.unwrap().path()).unwrap()) - .collect::>(); - assert_eq!(cache_files.len(), 1); - assert!(cache_files.iter().all(|sealed| { - !sealed - .windows(b"Fetched text.".len()) - .any(|window| window == b"Fetched text.") - })); - drop(endpoint); - - let vault = KnotVault::open(vault_dir.path(), key).unwrap(); - let store = KnotSyncFileStore::open(&database, space, [writer]).unwrap(); - let mut reopened = - KnotEndpoint::from_synced_vault(vault, store, seed, KnotWriteGrant::new(4096)).unwrap(); - reopened.grant_effects( - KnotEffectAuthority::new(policy.clone()).with_fetcher(OfflineStubFetcher), - ); - let request = reopened.describe().projections.remove(0).request; - let snapshot = reopened.snapshot(request).unwrap(); - let (target, editable, _) = editable_resource(&mut reopened, &snapshot, "field-note"); - let base_token = editable.base_token.clone(); - let restored = editable.derived.expect("sealed cache should restore"); - assert!(restored.source.contains("Fetched text.")); - let cache = restored.cache.expect("cache attribution"); - assert_eq!(cache.effect, "resolve"); - assert_eq!(cache.sources, vec!["file://fixture/included.md"]); - assert!(cache.provider_version.contains("StubFetcher")); - assert_eq!(cache.source_revision, 1); - assert!(cache.fetched_at_unix_ms > 0); - let refresh = reopened - .invoke(effect_invocation( - &snapshot, - target, - KNOT_TRANSCLUSION_RESOLVE_INTENT, - &KnotEffectV1 { - base_token, - confirmed: true, - }, - )) - .unwrap(); - assert!( - matches!( - refresh, - IntentResult::Rejected { ref reason } - if reason.contains("retained cached result") - ), - "an offline refresh must report failure without replacing the cache: {refresh:?}" - ); - reopened.snapshot = None; - reopened.resources.clear(); - reopened.bindings.clear(); - let request = reopened.describe().projections.remove(0).request; - let after_failed_refresh = reopened.snapshot(request).unwrap(); - let (_, editable, _) = - editable_resource(&mut reopened, &after_failed_refresh, "field-note"); - assert!( - editable - .derived - .is_some_and(|derived| derived.source.contains("Fetched text.")), - "an offline refresh must leave the restored document available" - ); - - reopened.snapshot = None; - reopened.resources.clear(); - // The version has to travel in the request, because that is where a - // real client puts it. `describe` advertises the endpoint's newest, and - // `snapshot` adopts whatever the request carries, so assigning the - // field alone is overwritten before the resource is ever built. - let mut request = reopened.describe().projections.remove(0).request; - request.version = ProtocolVersion::V1_2; - let snapshot = reopened.snapshot(request).unwrap(); - let (_, editable, _) = editable_resource(&mut reopened, &snapshot, "field-note"); - let compatible = editable.derived.expect("1.2 still receives derived text"); - assert!( - compatible.cache.is_none(), - "1.2 resources must omit the 1.3 cache field" - ); - reopened.snapshot = None; - reopened.resources.clear(); - - assert!(reopened.lock_vault()); - assert!(reopened.unlock_vault(key).unwrap()); - let request = reopened.describe().projections.remove(0).request; - let snapshot = reopened.snapshot(request).unwrap(); - let (_, editable, _) = editable_resource(&mut reopened, &snapshot, "field-note"); - assert!( - editable.derived.is_some(), - "unlock under the same source authority should restore the sealed cache" - ); - - assert!(reopened.revoke_effects()); - let request = reopened.describe().projections.remove(0).request; - let snapshot = reopened.snapshot(request).unwrap(); - let (_, editable, _) = editable_resource(&mut reopened, &snapshot, "field-note"); - assert!( - editable.derived.is_none(), - "effect revocation must make the cache unavailable" - ); - - reopened.grant_effects( - KnotEffectAuthority::new(KnotEffectPolicy { - max_depth: 2, - ..policy - }) - .with_fetcher(StubFetcher), - ); - let request = reopened.describe().projections.remove(0).request; - let snapshot = reopened.snapshot(request).unwrap(); - let (_, editable, _) = editable_resource(&mut reopened, &snapshot, "field-note"); - assert!( - editable.derived.is_none(), - "a changed resolve policy must invalidate the cache" - ); - } - - #[test] - fn commons_epoch_rotation_makes_the_old_cache_unavailable() { - let vault_dir = tempdir().unwrap(); - let sync_dir = tempdir().unwrap(); - let vault_key = [0x93; 32]; - let seed = [0x43; 32]; - let writer = *SigningKey::from_bytes(&seed).verifying_key().as_bytes(); - let space = [0x53; 32]; - let vault = KnotVault::open(vault_dir.path(), vault_key).unwrap(); - let store = - KnotSyncFileStore::open_commons(sync_dir.path().join("knot.redb"), space, [writer]) - .unwrap(); - let mut keys = DataKeyring::new(); - let first_epoch = keys.rotate_random().unwrap().id(); - pollster::block_on(store.author_communal( - seed, - &keys, - &KnotSyncEvent::Put(VaultDocument { - id: "shared-note".into(), - title: "Shared note".into(), - body: - b"# Shared\n\n```include file://fixture/included.md\nFallback.\n```\n".to_vec(), - media_type: "text/vnd.knot".into(), - }), - )) - .unwrap(); - let mut rotated = DataKeyring::from_bytes(&keys.to_bytes().unwrap()).unwrap(); - let second_epoch = rotated.rotate_random().unwrap().id(); - assert_ne!(first_epoch, second_epoch); - - let policy = KnotEffectPolicy { - resolve: KnotEffectMode::Ask, - allowed_schemes: vec!["file".into()], - max_depth: 1, - ..KnotEffectPolicy::default() - }; - let mut endpoint = - KnotEndpoint::from_communal_vault(vault, store, seed, keys, KnotWriteGrant::new(4096)) - .unwrap(); - endpoint.grant_effects(KnotEffectAuthority::new(policy.clone()).with_fetcher(StubFetcher)); - let request = endpoint.describe().projections.remove(0).request; - let snapshot = endpoint.snapshot(request).unwrap(); - let (target, editable, _) = editable_resource(&mut endpoint, &snapshot, "shared-note"); - assert_eq!( - endpoint - .invoke(effect_invocation( - &snapshot, - target, - KNOT_TRANSCLUSION_RESOLVE_INTENT, - &KnotEffectV1 { - base_token: editable.base_token, - confirmed: true, - }, - )) - .unwrap(), - IntentResult::Accepted - ); - let ResumeReply::Snapshot(resolved) = endpoint - .resume(ResumeRequest { - session: snapshot.session, - epoch: snapshot.scene.epoch, - revision: snapshot.scene.revision, - }) - .unwrap() - else { - panic!("resolve should advance"); - }; - let (_, editable, _) = editable_resource(&mut endpoint, &resolved, "shared-note"); - assert!(editable.derived.is_some()); - - assert!(endpoint.replace_communal_keys(rotated).unwrap()); - endpoint.grant_effects(KnotEffectAuthority::new(policy).with_fetcher(StubFetcher)); - let request = endpoint.describe().projections.remove(0).request; - let snapshot = endpoint.snapshot(request).unwrap(); - let (_, editable, _) = editable_resource(&mut endpoint, &snapshot, "shared-note"); - assert!( - editable.derived.is_none(), - "a cache sealed under the previous Commons epoch must stay unavailable" - ); - } - - #[test] - fn vault_disclosure_contains_neither_key_nor_authored_body() { - let temp = tempdir().unwrap(); - let key = [0xa7; 32]; - let private_body = b"private words that stay inside Knot"; - let mut vault = KnotVault::open(temp.path(), key).unwrap(); - vault - .put(crate::VaultDocument { - id: "private-note".into(), - title: "Private note".into(), - body: private_body.to_vec(), - media_type: "text/vnd.knot".into(), - }) - .unwrap(); - let mut endpoint = KnotEndpoint::from_vault(vault); - let descriptor = endpoint.describe(); - let request = descriptor.projections[0].request.clone(); - let snapshot = endpoint.snapshot(request).unwrap(); - let resource = snapshot.presentation.offers.values().next().unwrap()[0].resource; - let response = endpoint - .resource(ResourceRequest { - session: snapshot.session.clone(), - resource, - }) - .unwrap(); - - let protocol_bytes = [ - serde_json::to_vec(&descriptor).unwrap(), - serde_json::to_vec(&snapshot).unwrap(), - serde_json::to_vec(&response).unwrap(), - ] - .concat(); - assert!( - !protocol_bytes - .windows(key.len()) - .any(|window| window == key) - ); - assert!( - !protocol_bytes - .windows(private_body.len()) - .any(|window| window == private_body) - ); - assert_eq!(snapshot.scene.active_item_count(), 1); - } -} diff --git a/ports/knot/src/lib.rs b/ports/knot/src/lib.rs deleted file mode 100644 index c3366dd57..000000000 --- a/ports/knot/src/lib.rs +++ /dev/null @@ -1,117 +0,0 @@ -// Copyright 2026 Mark Alan Boykin -// 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 https://mozilla.org/MPL/2.0/. -// SPDX-License-Identifier: MPL-2.0 - -//! Knot: Mere's files-in-place authoring port. -//! -//! The first slice is deliberately read-only. [`DirectorySource`] discovers -//! files without storing their bytes in graph state, while [`KnotEndpoint`] -//! discloses those containers through Graphshell. The directory remains source -//! truth. - -mod authority; -mod clip_evidence; -mod content_classes; -mod directory; -mod djot_merge; -mod endpoint; -mod mark; -mod publish; -mod publish_carrier; -mod publish_client; -mod publish_host; -mod publish_wire; -mod resident; -mod rosette; -mod search; -mod settings; -mod startup; -mod sync; -mod vault; -mod watcher; -mod web_annotation; -mod writer; - -pub use authority::{KnotAuthoritySource, KnotSpaceAuthoritySnapshot}; -pub use clip_evidence::{ - BlobClipEvidenceStore, FileClipEvidenceStore, KnotClipEvidenceRef, KnotClipEvidenceStore, - KnotContentRetentionPort, clip_evidence_references, -}; -pub use content_classes::{ - FILE_CLASS, FILE_DOCUMENT_FACET, KnotContentClasses, NOTE_CLASS, NOTE_DOCUMENT_FACET, -}; -pub use directory::{DirectorySource, DiskDocument, IgnorePolicy}; -pub use endpoint::{ - KnotEffectAuthority, KnotEffectFetcher, KnotEffectMode, KnotEffectPolicy, KnotEndpoint, - KnotResidentSource, KnotRosetteConfig, KnotWriteGrant, -}; -pub use knot_document::{ - EditOutcome, KNOT_DOCUMENT_CSS, KnotDocumentIntentErrorV1, KnotDocumentIntentV1, - KnotDocumentRefusalV1, KnotDocumentSaveFailureV1, KnotDocumentSaveOutcomeV1, - KnotDocumentSession, KnotDocumentSnapshotV1, KnotDocumentSourceKindV1, KnotDocumentSourceV1, - KnotDocumentSurfaceState, KnotDocumentView, KnotDocumentWritePostureV1, KnotEditor, - knot_document_descriptor, knot_document_surface, knot_document_view, -}; -pub use mark::{ - MARK_ALPN, MARK_DEFAULT_PORT, MARK_MAX_DOCUMENT_BYTES, MARK_MAX_METADATA_BYTES, - MARK_MAX_REQUEST_BYTES, MarkAdapterError, MarkQuicHost, MarkReadAccess, MarkReadAdapter, - MarkReadAdapterLimits, MarkRequest, MarkResponse, MarkServerError, MarkSnapshotOutcome, - MarkTimestamp, MarkVersion, MarkVersionId, decode_mark_request, mark_server_config, -}; -/// Admission-policy vocabulary belongs to the same Notochord instance as -/// Knot's publishing carrier. Product hosts should take these through Knot, -/// not add a second direct Notochord dependency. -pub use notochord::{NetworkId, ProfileRef, TrustedRoot}; -pub use publish::{ - KNOT_PUBLISH_ALPN, KNOT_PUBLISH_DOMAIN, KNOT_PUBLISH_READ_ACTION, KNOT_PUBLISH_SERVICE, - KNOT_SHARE_TICKET_VERSION, KnotPublication, KnotPublishCandidate, KnotPublishCatalog, - KnotPublishEligibility, KnotPublishError, KnotPublishRead, KnotPublishedDocument, - KnotShareControlError, KnotShareRecipient, KnotShareTicket, PublicationId, publication_path, - revoke_share, -}; -pub use publish_carrier::{ - PublishCarrierError, PublishRefusal, accept_publish_session, publish_alpn, publish_policy, -}; -pub use publish_client::{ - KNOT_PUBLISH_READER_KEY_CONTEXT, KnotPublishClientError, decode_share_ticket, - encode_share_ticket, fetch_published_document, -}; -pub use publish_host::{ - KnotPublishHost, KnotPublishHostError, KnotPublishHostLimits, KnotPublishServeOutcome, - KnotPublishSource, -}; -pub use publish_wire::{ - CandidateFixture, CandidateFixtureOutcome, HARD_MAX_CATALOG_ENTRIES, HARD_MAX_DOCUMENT_BYTES, - HARD_MAX_REQUEST_BYTES, HARD_MAX_RESPONSE_BYTES, PublishRequest, PublishResponse, - PublishWireError, PublishWireLimits, candidate_fixture_corpus, decode_request, decode_response, - encode_request, encode_response, -}; -pub use resident::{ - KnotEvidenceFetchReceipt, KnotEvidenceFetchStatus, KnotSyncHost, KnotSyncHostConfig, - KnotSyncHostError, -}; -pub use rosette::{ - CmudictPronunciations, LexiconCoverage, LineMeter, MetricalBeat, MetricalFoot, - PronunciationLexicon, RosetteConfig, RosetteInterior, RosetteInteriorKind, RosetteProjection, - UnresolvedToken, project_rosette, -}; -pub use search::{KnotSearch, SearchConfig, SearchHit, SearchLane}; -pub use settings::{ - KnotSettings, KnotSettingsError, KnotSyncSettings, hex32, knot_settings_path, parse_hex32, -}; -pub use startup::{ - StartupUnlockedPersonalVault, local_device_root, persona_vault_root, personal_vault_writer, -}; -pub use sync::{ - KNOT_COMMONS_ENCRYPTION_PROFILE, KnotAutomaticTextMerge, KnotCheckpointSnapshot, - KnotDocumentConflict, KnotDocumentProjection, KnotDocumentVersion, KnotEncryptionProfile, - KnotEpochExecutionReceipt, KnotOfflineMemberEpochHold, KnotOfflineMemberRecovery, - KnotProjectionCheckpoint, KnotSyncCipher, KnotSyncError, KnotSyncEvent, KnotSyncExt, - KnotSyncFileStore, KnotSyncStore, KnotTailReceipt, -}; -pub use vault::{KnotVault, VaultDocument}; -pub use watcher::DirectoryWatcher; -pub use web_annotation::{SpecificResource, SpecificResourceSelector}; -pub use writer::{AuthoredFile, DocumentFormat, SaveOutcome}; diff --git a/ports/knot/src/mark.rs b/ports/knot/src/mark.rs deleted file mode 100644 index e454523e7..000000000 --- a/ports/knot/src/mark.rs +++ /dev/null @@ -1,1126 +0,0 @@ -// Copyright 2026 Mark Alan Boykin -// 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 https://mozilla.org/MPL/2.0/. -// SPDX-License-Identifier: MPL-2.0 - -//! A bounded, read-only Mark projection beside Knot's private publish lane. -//! -//! The two protocols deliberately remain separate. Native publishing is an -//! admitted Personae session over the `mere/knot-publish/v1` carrier, with -//! causal operation identifiers. Mark is a public QUIC/TLS protocol with -//! numeric, append-only versions and CommonMark bodies. This module therefore -//! snapshots an owner-selected Knot publication into a separate Mark history; -//! it never relabels raw Knot or Djot bytes as CommonMark and never exposes a -//! Personae delegation as a Mark token. - -use std::{ - collections::BTreeMap, - net::SocketAddr, - sync::Arc, - time::{SystemTime, UNIX_EPOCH}, -}; - -use muniment::Backend; -use quinn::crypto::rustls::QuicServerConfig; -use rustls::pki_types::{CertificateDer, PrivateKeyDer}; -use sha2::{Digest, Sha256}; -use tokio::sync::RwLock; - -use crate::{ - DocumentFormat, KnotPublishCatalog, KnotPublishError, KnotPublishedDocument, KnotSyncStore, - KnotVault, PublicationId, -}; - -/// The ALPN registered by the Mark Protocol working draft. -pub const MARK_ALPN: &[u8] = b"mark"; -/// Mark's assigned UDP port. Callers may bind another port explicitly. -pub const MARK_DEFAULT_PORT: u16 = 6309; -/// Mark's maximum request-line size. -pub const MARK_MAX_REQUEST_BYTES: usize = 4096; -/// Mark's maximum YAML metadata block size. -pub const MARK_MAX_METADATA_BYTES: usize = 64 * 1024; -/// Mark's recommended document bound, made a default here rather than a hard -/// global policy for native Knot publishing. -pub const MARK_MAX_DOCUMENT_BYTES: usize = 1024 * 1024; - -/// A configured bound for the Mark projection. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct MarkReadAdapterLimits { - /// Maximum CommonMark bytes an owner may snapshot into one Mark version. - pub max_document_bytes: usize, -} - -impl Default for MarkReadAdapterLimits { - fn default() -> Self { - Self { - max_document_bytes: MARK_MAX_DOCUMENT_BYTES, - } - } -} - -impl MarkReadAdapterLimits { - fn clamped(self) -> Self { - Self { - max_document_bytes: self.max_document_bytes.min(MARK_MAX_DOCUMENT_BYTES), - } - } -} - -/// An RFC 3339 UTC timestamp held with a Mark snapshot. -/// -/// Knot causal operations have no authored wall-clock time. The timestamp is -/// consequently the owner's projection time, not a claim about the causal -/// operation's time of authorship. -#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] -pub struct MarkTimestamp(String); - -impl MarkTimestamp { - /// Parse the UTC, second-precision form emitted by this adapter. - pub fn parse(value: impl Into) -> Result { - let value = value.into(); - if !is_utc_rfc3339_seconds(&value) { - return Err(MarkAdapterError::InvalidTimestamp); - } - Ok(Self(value)) - } - - /// Capture the local projection time in Mark's required UTC form. - pub fn now() -> Result { - let seconds = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map_err(|_| MarkAdapterError::Clock)? - .as_secs(); - let days = (seconds / 86_400) as i64; - let seconds_of_day = seconds % 86_400; - let (year, month, day) = civil_from_days(days); - let hour = seconds_of_day / 3_600; - let minute = (seconds_of_day % 3_600) / 60; - let second = seconds_of_day % 60; - Self::parse(format!( - "{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z" - )) - } - - /// The serialized response value. - pub fn as_str(&self) -> &str { - &self.0 - } -} - -/// One-based numeric Mark version. -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct MarkVersionId(u64); - -impl MarkVersionId { - /// Numeric representation used in Mark paths and metadata. - pub fn get(self) -> u64 { - self.0 - } -} - -/// A snapshot outcome. Identical CommonMark bodies deliberately remain the -/// current Mark version, matching Mark's no-op publish rule. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum MarkSnapshotOutcome { - Created(MarkVersionId), - Unchanged(MarkVersionId), -} - -/// A Mark read access rule. The token form stores only a SHA-256 digest and is -/// a separately-issued adapter credential, never a serialized Personae grant. -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum MarkReadAccess { - Public, - TokenHash([u8; 32]), -} - -impl MarkReadAccess { - /// Construct an adapter-local protected-read rule from a token the owner - /// distributes out of band. The raw token is not retained. - pub fn protected(token: impl AsRef<[u8]>) -> Self { - Self::TokenHash(sha256(token.as_ref())) - } - - fn allows(&self, candidate: Option<&str>) -> bool { - match self { - Self::Public => true, - Self::TokenHash(expected) => candidate - .is_some_and(|candidate| constant_time_eq(expected, &sha256(candidate.as_bytes()))), - } - } -} - -/// One served Mark snapshot. The stored bytes are retained privately so the -/// adapter can compute the specification's ETag and `previous-hash` chain. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct MarkVersion { - pub id: MarkVersionId, - pub modified: MarkTimestamp, - pub source_operation: [u8; 32], - pub source_media_type: String, - pub body: Vec, - stored: Vec, - etag: [u8; 32], - content_hash: [u8; 32], -} - -impl MarkVersion { - /// SHA-256 of the complete Mark store representation, formatted for ETag. - pub fn etag(&self) -> String { - hex(&self.etag) - } - - /// SHA-256 of the served CommonMark body, formatted for `content-hash`. - pub fn content_hash(&self) -> String { - format!("sha256-{}", hex(&self.content_hash)) - } - - #[cfg(test)] - fn stored(&self) -> &[u8] { - &self.stored - } -} - -#[derive(Clone, Debug)] -struct MarkDocument { - access: MarkReadAccess, - publication: PublicationId, - versions: Vec, -} - -/// Explicit Mark export selections and their independent immutable snapshots. -/// -/// `configure_export` and `snapshot_*` are owner actions. A native source -/// changing does not silently mutate a Mark document, and an unresolved causal -/// history cannot be invented as a numeric Mark history. -#[derive(Clone, Debug)] -pub struct MarkReadAdapter { - limits: MarkReadAdapterLimits, - documents: BTreeMap, - current_content: BTreeMap<[u8; 32], String>, -} - -impl MarkReadAdapter { - pub fn new(limits: MarkReadAdapterLimits) -> Self { - Self { - limits: limits.clamped(), - documents: BTreeMap::new(), - current_content: BTreeMap::new(), - } - } - - /// Create or update an owner-selected Mark path. A path keeps its existing - /// immutable history when its access rule changes, but cannot be rebound to - /// another native publication. - pub fn configure_export( - &mut self, - path: impl Into, - publication: PublicationId, - access: MarkReadAccess, - ) -> Result<(), MarkAdapterError> { - let path = validate_document_path(path.into())?; - if let Some(document) = self.documents.get_mut(&path) { - if document.publication != publication { - return Err(MarkAdapterError::RebindPath); - } - document.access = access; - return Ok(()); - } - self.documents.insert( - path, - MarkDocument { - access, - publication, - versions: Vec::new(), - }, - ); - Ok(()) - } - - /// Withdraw a Mark export and all future reads through this adapter. - pub fn withdraw(&mut self, path: &str) -> bool { - let removed = self.documents.remove(path).is_some(); - if removed { - self.rebuild_content_index(); - } - removed - } - - /// Convert and append one explicit native source snapshot. - pub fn snapshot( - &mut self, - path: &str, - source: &KnotPublishedDocument, - modified: MarkTimestamp, - ) -> Result { - let document = self - .documents - .get_mut(path) - .ok_or(MarkAdapterError::UnknownExport)?; - if document.publication != source.publication { - return Err(MarkAdapterError::PublicationMismatch); - } - if !source.body_digest_matches() { - return Err(MarkAdapterError::InvalidSourceDigest); - } - let format = DocumentFormat::from_media_type(&source.media_type) - .filter(|format| matches!(*format, DocumentFormat::Knot | DocumentFormat::Djot)) - .ok_or_else(|| MarkAdapterError::UnsupportedSource(source.media_type.clone()))?; - let body = format - .to_commonmark( - &format!("knot-publication:{}", source.publication.as_uuid()), - &source.body, - ) - .map_err(MarkAdapterError::CommonMarkConversion)?; - if body.len() > self.limits.max_document_bytes { - return Err(MarkAdapterError::DocumentTooLarge); - } - if let Some(current) = document.versions.last() - && current.body == body - { - return Ok(MarkSnapshotOutcome::Unchanged(current.id)); - } - - let next = document - .versions - .len() - .checked_add(1) - .and_then(|value| u64::try_from(value).ok()) - .map(MarkVersionId) - .ok_or(MarkAdapterError::VersionOverflow)?; - let previous_hash = document.versions.last().map(|version| version.etag); - let stored = mark_stored_version(next, previous_hash, source, &body); - let version = MarkVersion { - id: next, - modified, - source_operation: source.operation, - source_media_type: source.media_type.clone(), - etag: sha256(&stored), - content_hash: sha256(&body), - body, - stored, - }; - document.versions.push(version); - self.rebuild_content_index(); - Ok(MarkSnapshotOutcome::Created(next)) - } - - /// Materialize the holder's *currently eligible* native publication and - /// append it only when the owner explicitly invokes this method. - pub async fn snapshot_current( - &mut self, - path: &str, - catalog: &KnotPublishCatalog, - store: &KnotSyncStore, - vault: &KnotVault, - publication: PublicationId, - modified: MarkTimestamp, - ) -> Result - where - B: Backend + Clone, - { - let source = catalog - .current_for_mark_export(store, vault, publication) - .await - .map_err(MarkAdapterError::NativeSource)? - .ok_or(MarkAdapterError::NativeNotAvailable)?; - self.snapshot(path, &source, modified) - } - - /// Handle one complete Mark request. Absence and a protected export denied - /// by this adapter both use `not-found`, preserving the native lane's - /// catalog non-disclosure discipline. - pub fn respond(&self, request: &[u8]) -> MarkResponse { - match decode_mark_request(request) { - Ok(request) => self.respond_to(request), - Err(_) => MarkResponse::bad_request(), - } - } - - fn respond_to(&self, request: MarkRequest) -> MarkResponse { - match request { - MarkRequest::Fetch { - path, - auth, - if_none_match, - if_modified_since, - } => self.fetch( - &path, - auth.as_deref(), - if_none_match.as_deref(), - if_modified_since, - ), - MarkRequest::Versions { path, auth } => self.versions(&path, auth.as_deref()), - MarkRequest::Other { .. } => MarkResponse::not_permitted(), - } - } - - fn fetch( - &self, - path: &str, - auth: Option<&str>, - if_none_match: Option<&str>, - if_modified_since: Option, - ) -> MarkResponse { - if path == "/health" { - return MarkResponse::ok( - [( - "content-hash", - format!("sha256-{}", hex(&sha256(HEALTH_BODY))), - )], - HEALTH_BODY.to_vec(), - ); - } - let (path, requested_version) = match resolve_mark_path(path) { - Ok(value) => value, - Err(_) => return MarkResponse::not_found(), - }; - let path = if let Some(content_hash) = parse_content_hash(&path) { - let Some(path) = self.current_content.get(&content_hash) else { - return MarkResponse::not_found(); - }; - path.as_str() - } else { - path.as_str() - }; - let Some(document) = self.documents.get(path) else { - return MarkResponse::not_found(); - }; - if !document.access.allows(auth) { - return MarkResponse::not_found(); - } - let version = match requested_version { - Some(id) => document.versions.get(id.0.saturating_sub(1) as usize), - None => document.versions.last(), - }; - let Some(version) = version else { - return MarkResponse::not_found(); - }; - if if_none_match.is_some_and(|candidate| candidate == version.etag()) - || if_modified_since.is_some_and(|since| version.modified <= since) - { - return MarkResponse::not_modified(); - } - let mut metadata = vec![ - ("modified", version.modified.as_str().to_string()), - ("etag", version.etag()), - ("version", version.id.get().to_string()), - ("content-hash", version.content_hash()), - ]; - if requested_version.is_some() { - metadata.push(("current-version", document.versions.len().to_string())); - } - MarkResponse::ok(metadata, version.body.clone()) - } - - fn versions(&self, path: &str, auth: Option<&str>) -> MarkResponse { - let Ok((path, requested_version)) = resolve_mark_path(path) else { - return MarkResponse::not_found(); - }; - if requested_version.is_some() { - return MarkResponse::not_found(); - } - let Some(document) = self.documents.get(&path) else { - return MarkResponse::not_found(); - }; - if !document.access.allows(auth) || document.versions.is_empty() { - return MarkResponse::not_found(); - } - let mut body = format!("# Version History: {path}\n"); - for version in document.versions.iter().rev() { - body.push_str(&format!( - "- [v{}]({path}/v{}) - {}\n", - version.id.get(), - version.id.get(), - version.modified.as_str() - )); - } - let chain_valid = mark_chain_is_valid(&document.versions); - let mut metadata = vec![ - ("total", document.versions.len().to_string()), - ("current", document.versions.len().to_string()), - ("chain-valid", chain_valid.to_string()), - ]; - if !chain_valid { - metadata.push(("chain-error", "stored version hash chain is broken".into())); - } - MarkResponse::ok(metadata, body.into_bytes()) - } - - fn rebuild_content_index(&mut self) { - self.current_content.clear(); - for (path, document) in &self.documents { - if let Some(version) = document.versions.last() { - self.current_content - .entry(version.content_hash) - .or_insert_with(|| path.clone()); - } - } - } -} - -/// A parsed bounded Mark request. The read adapter recognizes FETCH and -/// VERSIONS; the remaining specified verbs parse but are refused as writes or -/// unsupported discovery rather than leaking a native catalog. -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum MarkRequest { - Fetch { - path: String, - auth: Option, - if_none_match: Option, - if_modified_since: Option, - }, - Versions { - path: String, - auth: Option, - }, - Other { - verb: String, - path: String, - }, -} - -/// A textual Mark response. `to_wire` always emits the mandatory YAML -/// frontmatter and preserves a body exactly as supplied by the projection. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct MarkResponse { - status: &'static str, - metadata: Vec<(&'static str, String)>, - body: Vec, -} - -impl MarkResponse { - fn ok(metadata: impl IntoIterator, body: Vec) -> Self { - Self { - status: "ok", - metadata: metadata.into_iter().collect(), - body, - } - } - - fn not_modified() -> Self { - Self { - status: "not-modified", - metadata: Vec::new(), - body: Vec::new(), - } - } - - fn not_found() -> Self { - Self { - status: "not-found", - metadata: Vec::new(), - body: b"# Not found\n\nThis document is not available.\n".to_vec(), - } - } - - fn bad_request() -> Self { - Self { - status: "bad-request", - metadata: Vec::new(), - body: b"# Bad request\n\nThe Mark request is malformed.\n".to_vec(), - } - } - - fn not_permitted() -> Self { - Self { - status: "not-permitted", - metadata: Vec::new(), - body: b"# Not permitted\n\nThis Mark adapter is read-only.\n".to_vec(), - } - } - - /// The Mark status string, useful to an embedding host before writing. - pub fn status(&self) -> &str { - self.status - } - - /// Serialize the response's mandatory YAML frontmatter and body. - pub fn to_wire(&self) -> Vec { - let mut output = format!("---\nstatus: {}\n", self.status).into_bytes(); - for (key, value) in &self.metadata { - output.extend_from_slice(format!("{key}: {value}\n").as_bytes()); - } - output.extend_from_slice(b"---\n"); - output.extend_from_slice(&self.body); - output - } -} - -/// Parse one complete bounded read-adapter request. -pub fn decode_mark_request(bytes: &[u8]) -> Result { - if bytes.len() > MARK_MAX_REQUEST_BYTES + MARK_MAX_METADATA_BYTES { - return Err(MarkAdapterError::RequestTooLarge); - } - let text = std::str::from_utf8(bytes).map_err(|_| MarkAdapterError::MalformedRequest)?; - let Some(line_end) = text.find('\n') else { - return Err(MarkAdapterError::MalformedRequest); - }; - if line_end > MARK_MAX_REQUEST_BYTES || text[..line_end].contains('\r') { - return Err(MarkAdapterError::MalformedRequest); - } - let line = &text[..line_end]; - let Some((verb, path)) = line.split_once(' ') else { - return Err(MarkAdapterError::MalformedRequest); - }; - if verb.is_empty() || path.is_empty() || path.contains(' ') { - return Err(MarkAdapterError::MalformedRequest); - } - let path = validate_request_path(path)?; - let (metadata, body) = parse_frontmatter(&text[line_end + 1..])?; - if !body.is_empty() { - return Err(MarkAdapterError::MalformedRequest); - } - let auth = metadata.get("auth").cloned(); - match verb { - "FETCH" => Ok(MarkRequest::Fetch { - path, - auth, - if_none_match: metadata.get("if-none-match").cloned(), - if_modified_since: metadata - .get("if-modified-since") - .cloned() - .map(MarkTimestamp::parse) - .transpose()?, - }), - "VERSIONS" => Ok(MarkRequest::Versions { path, auth }), - "LIST" | "PUBLISH" | "ARCHIVE" | "APPEND" | "LOOKUP" => Ok(MarkRequest::Other { - verb: verb.into(), - path, - }), - _ => Err(MarkAdapterError::MalformedRequest), - } -} - -/// A standard QUIC/TLS Mark listener. It is intentionally not layered over -/// the private p2panda carrier: external Mark clients connect with ALPN `mark`. -pub struct MarkQuicHost { - endpoint: quinn::Endpoint, - adapter: Arc>, -} - -impl MarkQuicHost { - /// Bind an independently configured direct QUIC listener. - pub fn bind( - address: SocketAddr, - server_config: quinn::ServerConfig, - adapter: Arc>, - ) -> Result { - let endpoint = quinn::Endpoint::server(server_config, address) - .map_err(|error| MarkServerError::Bind(error.to_string()))?; - Ok(Self { endpoint, adapter }) - } - - /// The actual socket address, including an OS-selected port when requested. - pub fn local_addr(&self) -> Result { - self.endpoint - .local_addr() - .map_err(|error| MarkServerError::Bind(error.to_string())) - } - - /// Accept one Mark connection and serve its bidirectional request streams - /// until the client closes it. Embedders call this repeatedly or place it - /// in their own task supervision for subsequent connections. - pub async fn serve_once(&self) -> Result<(), MarkServerError> { - let incoming = self - .endpoint - .accept() - .await - .ok_or(MarkServerError::Closed)?; - let connection = incoming - .await - .map_err(|error| MarkServerError::Connection(error.to_string()))?; - while let Ok((mut send, mut receive)) = connection.accept_bi().await { - let request = receive - .read_to_end(MARK_MAX_REQUEST_BYTES + MARK_MAX_METADATA_BYTES) - .await - .map_err(|error| MarkServerError::Stream(error.to_string()))?; - let response = self.adapter.read().await.respond(&request).to_wire(); - send.write_all(&response) - .await - .map_err(|error| MarkServerError::Stream(error.to_string()))?; - send.finish() - .map_err(|error| MarkServerError::Stream(error.to_string()))?; - } - Ok(()) - } -} - -/// Build the direct QUIC/TLS configuration required by a Mark listener. The -/// caller owns certificate issuance and renewal; no self-signed certificate is -/// silently generated for a resident service. -pub fn mark_server_config( - certificates: Vec>, - private_key: PrivateKeyDer<'static>, -) -> Result { - let _ = rustls::crypto::ring::default_provider().install_default(); - let mut tls = rustls::ServerConfig::builder() - .with_no_client_auth() - .with_single_cert(certificates, private_key) - .map_err(|error| MarkServerError::Config(error.to_string()))?; - tls.alpn_protocols = vec![MARK_ALPN.to_vec()]; - let crypto = QuicServerConfig::try_from(tls) - .map_err(|error| MarkServerError::Config(error.to_string()))?; - Ok(quinn::ServerConfig::with_crypto(Arc::new(crypto))) -} - -/// Source conversion, request parsing, or snapshot failure. -#[derive(Debug, thiserror::Error)] -pub enum MarkAdapterError { - #[error("Mark export paths must be absolute .md paths without traversal")] - InvalidPath, - #[error("the Mark request is malformed")] - MalformedRequest, - #[error("the Mark request exceeds its fixed limit")] - RequestTooLarge, - #[error("the Mark timestamp must be RFC 3339 UTC at second precision")] - InvalidTimestamp, - #[error("the system clock is before the Unix epoch")] - Clock, - #[error("the Mark export does not exist")] - UnknownExport, - #[error("a Mark path cannot be rebound to a different native publication")] - RebindPath, - #[error("the native source does not match this export selection")] - PublicationMismatch, - #[error("the native source digest does not match its bytes")] - InvalidSourceDigest, - #[error("the native media type cannot be converted to CommonMark: {0}")] - UnsupportedSource(String), - #[error("the Knot-to-CommonMark projection failed: {0}")] - CommonMarkConversion(String), - #[error("the projected CommonMark document exceeds the configured Mark limit")] - DocumentTooLarge, - #[error("the Mark numeric version sequence is exhausted")] - VersionOverflow, - #[error("the selected native publication is not available for export")] - NativeNotAvailable, - #[error(transparent)] - NativeSource(KnotPublishError), -} - -/// Listener setup or stream-service failure. -#[derive(Debug, thiserror::Error)] -pub enum MarkServerError { - #[error("could not configure Mark TLS: {0}")] - Config(String), - #[error("could not bind Mark QUIC listener: {0}")] - Bind(String), - #[error("Mark listener closed")] - Closed, - #[error("Mark QUIC connection failed: {0}")] - Connection(String), - #[error("Mark QUIC stream failed: {0}")] - Stream(String), -} - -const HEALTH_BODY: &[u8] = b"# Knot Mark read adapter\n\nReady.\n"; - -fn mark_stored_version( - id: MarkVersionId, - previous_hash: Option<[u8; 32]>, - source: &KnotPublishedDocument, - body: &[u8], -) -> Vec { - let mut output = format!("---\nversion: {}\narchived: false\n", id.get()); - if let Some(previous_hash) = previous_hash { - output.push_str(&format!("previous-hash: sha256-{}\n", hex(&previous_hash))); - } - output.push_str(&format!( - "meta.mere-source-operation: {}\nmeta.mere-source-body-blake3: {}\nmeta.mere-source-media-type: {}\nmeta.mere-projection: canonical-commonmark\n", - hex(&source.operation), - hex(&source.body_digest), - source.media_type, - )); - output.push_str("---\n"); - let mut stored = output.into_bytes(); - stored.extend_from_slice(body); - stored -} - -fn mark_chain_is_valid(versions: &[MarkVersion]) -> bool { - versions.windows(2).all(|pair| { - let previous = &pair[0]; - let current = &pair[1]; - current.etag == sha256(¤t.stored) - && std::str::from_utf8(¤t.stored).is_ok_and(|stored| { - stored.contains(&format!("previous-hash: sha256-{}\n", previous.etag())) - }) - }) -} - -fn parse_frontmatter(tail: &str) -> Result<(BTreeMap, &str), MarkAdapterError> { - if !tail.starts_with("---\n") { - return Ok((BTreeMap::new(), tail)); - } - let remaining = &tail[4..]; - let Some(close) = remaining.find("\n---\n") else { - return Err(MarkAdapterError::MalformedRequest); - }; - let metadata_text = &remaining[..close]; - if metadata_text.len() > MARK_MAX_METADATA_BYTES { - return Err(MarkAdapterError::RequestTooLarge); - } - let mut metadata = BTreeMap::new(); - for line in metadata_text.lines() { - let Some((key, value)) = line.split_once(": ") else { - return Err(MarkAdapterError::MalformedRequest); - }; - if key.is_empty() - || !key - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) - || value.contains(['\r', '\n']) - || metadata.insert(key.into(), value.into()).is_some() - { - return Err(MarkAdapterError::MalformedRequest); - } - } - Ok((metadata, &remaining[close + 5..])) -} - -fn validate_request_path(path: &str) -> Result { - if !path.starts_with('/') - || path - .bytes() - .any(|byte| byte == 0 || byte < 32 || byte == 127) - || path.contains(['?', '#']) - || path.split('/').any(|segment| matches!(segment, "." | "..")) - { - return Err(MarkAdapterError::InvalidPath); - } - Ok(path.into()) -} - -fn validate_document_path(path: String) -> Result { - let path = validate_request_path(&path)?; - if !path.ends_with(".md") || path == "/.md" { - return Err(MarkAdapterError::InvalidPath); - } - Ok(path) -} - -fn resolve_mark_path(path: &str) -> Result<(String, Option), MarkAdapterError> { - let path = validate_request_path(path)?; - let Some((base, tail)) = path.rsplit_once('/') else { - return Ok((path, None)); - }; - let Some(number) = tail.strip_prefix('v') else { - return Ok((path, None)); - }; - if number.is_empty() || !number.bytes().all(|byte| byte.is_ascii_digit()) { - return Ok((path, None)); - } - let version = number - .parse::() - .ok() - .filter(|value| *value > 0) - .map(MarkVersionId) - .ok_or(MarkAdapterError::InvalidPath)?; - Ok((base.into(), Some(version))) -} - -fn parse_content_hash(path: &str) -> Option<[u8; 32]> { - let value = path.strip_prefix("/sha256-")?; - if value.len() != 64 || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) { - return None; - } - let mut output = [0u8; 32]; - for (index, byte) in output.iter_mut().enumerate() { - *byte = u8::from_str_radix(&value[index * 2..index * 2 + 2], 16).ok()?; - } - Some(output) -} - -fn is_utc_rfc3339_seconds(value: &str) -> bool { - let bytes = value.as_bytes(); - if bytes.len() != 20 - || bytes[4] != b'-' - || bytes[7] != b'-' - || bytes[10] != b'T' - || bytes[13] != b':' - || bytes[16] != b':' - || bytes[19] != b'Z' - || [0..4, 5..7, 8..10, 11..13, 14..16, 17..19] - .into_iter() - .flatten() - .any(|index| !bytes[index].is_ascii_digit()) - { - return false; - } - let number = |start: usize, end: usize| { - bytes[start..end] - .iter() - .fold(0u8, |value, byte| value * 10 + (byte - b'0')) - }; - (1..=12).contains(&number(5, 7)) - && (1..=31).contains(&number(8, 10)) - && number(11, 13) < 24 - && number(14, 16) < 60 - && number(17, 19) < 60 -} - -fn civil_from_days(days: i64) -> (i64, u32, u32) { - let days = days + 719_468; - let era = if days >= 0 { days } else { days - 146_096 } / 146_097; - let day_of_era = days - era * 146_097; - let year_of_era = - (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365; - let year = year_of_era + era * 400; - let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100); - let month_prime = (5 * day_of_year + 2) / 153; - let day = day_of_year - (153 * month_prime + 2) / 5 + 1; - let month = month_prime + if month_prime < 10 { 3 } else { -9 }; - (year + i64::from(month <= 2), month as u32, day as u32) -} - -fn sha256(bytes: &[u8]) -> [u8; 32] { - Sha256::digest(bytes).into() -} - -fn hex(bytes: &[u8]) -> String { - const DIGITS: &[u8; 16] = b"0123456789abcdef"; - let mut output = String::with_capacity(bytes.len() * 2); - for byte in bytes { - output.push(DIGITS[(byte >> 4) as usize] as char); - output.push(DIGITS[(byte & 0x0f) as usize] as char); - } - output -} - -fn constant_time_eq(left: &[u8; 32], right: &[u8; 32]) -> bool { - left.iter() - .zip(right) - .fold(0u8, |difference, (left, right)| difference | (left ^ right)) - == 0 -} - -#[cfg(test)] -mod tests { - use super::*; - use quinn::crypto::rustls::QuicClientConfig; - use rustls::pki_types::{PrivatePkcs8KeyDer, ServerName, UnixTime}; - - fn source(operation: u8, body: &[u8]) -> KnotPublishedDocument { - KnotPublishedDocument { - publication: PublicationId::from_uuid(uuid::Uuid::from_u128(5)), - media_type: "text/vnd.knot".into(), - body: body.to_vec(), - operation: [operation; 32], - body_digest: *blake3::hash(body).as_bytes(), - } - } - - fn configured_adapter() -> MarkReadAdapter { - let publication = PublicationId::from_uuid(uuid::Uuid::from_u128(5)); - let mut adapter = MarkReadAdapter::new(MarkReadAdapterLimits::default()); - adapter - .configure_export( - "/shares/field-notes.md", - publication, - MarkReadAccess::protected("reader-token"), - ) - .unwrap(); - adapter - } - - fn request(path: &str, metadata: &str) -> Vec { - format!("FETCH {path}\n---\n{metadata}---\n").into_bytes() - } - - #[test] - fn snapshots_are_commonmark_versions_with_a_sha256_chain() { - let mut adapter = configured_adapter(); - let timestamp = MarkTimestamp::parse("2026-08-08T02:00:00Z").unwrap(); - assert_eq!( - adapter - .snapshot( - "/shares/field-notes.md", - &source(1, b"# Field notes\n\nFirst pass.\n"), - timestamp.clone(), - ) - .unwrap(), - MarkSnapshotOutcome::Created(MarkVersionId(1)) - ); - assert_eq!( - adapter - .snapshot( - "/shares/field-notes.md", - &source(2, b"# Field notes\n\nSecond pass.\n"), - MarkTimestamp::parse("2026-08-08T02:01:00Z").unwrap(), - ) - .unwrap(), - MarkSnapshotOutcome::Created(MarkVersionId(2)) - ); - let document = adapter.documents.get("/shares/field-notes.md").unwrap(); - let first = &document.versions[0]; - let second = &document.versions[1]; - assert!( - std::str::from_utf8(&second.body) - .unwrap() - .contains("Second pass.") - ); - assert!( - std::str::from_utf8(second.stored()) - .unwrap() - .contains(&format!("previous-hash: sha256-{}", first.etag())) - ); - - let response = adapter.respond(&request("/shares/field-notes.md", "auth: reader-token\n")); - let wire = String::from_utf8(response.to_wire()).unwrap(); - assert!(wire.starts_with("---\nstatus: ok\n")); - assert!(wire.contains("version: 2\n")); - assert!(wire.contains("content-hash: sha256-")); - assert!(wire.contains("Second pass.")); - } - - #[test] - fn conditional_and_denied_reads_do_not_reveal_an_export() { - let mut adapter = configured_adapter(); - adapter - .snapshot( - "/shares/field-notes.md", - &source(1, b"# Field notes\n\nOne.\n"), - MarkTimestamp::parse("2026-08-08T02:00:00Z").unwrap(), - ) - .unwrap(); - let current = adapter.documents["/shares/field-notes.md"] - .versions - .last() - .unwrap(); - let conditional_request = request( - "/shares/field-notes.md", - &format!("auth: reader-token\nif-none-match: {}\n", current.etag()), - ); - assert_eq!( - adapter.respond(&conditional_request).status(), - "not-modified" - ); - assert_eq!( - adapter - .respond(&request("/shares/field-notes.md", "auth: wrong\n")) - .status(), - "not-found" - ); - assert_eq!( - adapter - .respond(&request("/missing.md", "auth: wrong\n")) - .status(), - "not-found" - ); - } - - #[test] - fn request_parser_rejects_bodies_and_path_traversal() { - assert!(decode_mark_request(b"FETCH /safe.md\n").is_ok()); - assert!(decode_mark_request(b"FETCH /../safe.md\n").is_err()); - assert!(decode_mark_request(b"FETCH /safe.md\nbody").is_err()); - assert!(decode_mark_request(b"FETCH /safe.md\r\n").is_err()); - } - - #[derive(Debug)] - struct NoVerify; - - impl rustls::client::danger::ServerCertVerifier for NoVerify { - fn verify_server_cert( - &self, - _: &CertificateDer, - _: &[CertificateDer], - _: &ServerName, - _: &[u8], - _: UnixTime, - ) -> Result { - Ok(rustls::client::danger::ServerCertVerified::assertion()) - } - - fn verify_tls12_signature( - &self, - _: &[u8], - _: &CertificateDer, - _: &rustls::DigitallySignedStruct, - ) -> Result { - Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) - } - - fn verify_tls13_signature( - &self, - _: &[u8], - _: &CertificateDer, - _: &rustls::DigitallySignedStruct, - ) -> Result { - Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) - } - - fn supported_verify_schemes(&self) -> Vec { - rustls::crypto::ring::default_provider() - .signature_verification_algorithms - .supported_schemes() - } - } - - fn insecure_mark_client() -> quinn::ClientConfig { - let _ = rustls::crypto::ring::default_provider().install_default(); - let mut tls = rustls::ClientConfig::builder() - .dangerous() - .with_custom_certificate_verifier(Arc::new(NoVerify)) - .with_no_client_auth(); - tls.alpn_protocols = vec![MARK_ALPN.to_vec()]; - quinn::ClientConfig::new(Arc::new(QuicClientConfig::try_from(tls).unwrap())) - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn standard_quic_mark_alpn_serves_a_snapshot() { - let certificate = rcgen::generate_simple_self_signed(vec!["localhost".into()]).unwrap(); - let certificate_der = CertificateDer::from(certificate.cert.der().to_vec()); - let private_key = PrivatePkcs8KeyDer::from(certificate.signing_key.serialize_der()); - let config = mark_server_config(vec![certificate_der], private_key.into()).unwrap(); - let mut adapter = configured_adapter(); - adapter - .snapshot( - "/shares/field-notes.md", - &source(1, b"# Field notes\n\nOver QUIC.\n"), - MarkTimestamp::parse("2026-08-08T02:00:00Z").unwrap(), - ) - .unwrap(); - let expected_body = String::from_utf8( - adapter.documents["/shares/field-notes.md"].versions[0] - .body - .clone(), - ) - .unwrap(); - let host = Arc::new( - MarkQuicHost::bind( - "127.0.0.1:0".parse().unwrap(), - config, - Arc::new(RwLock::new(adapter)), - ) - .unwrap(), - ); - let address = host.local_addr().unwrap(); - let serving_host = Arc::clone(&host); - let serving = tokio::spawn(async move { serving_host.serve_once().await }); - - let mut client = quinn::Endpoint::client("127.0.0.1:0".parse().unwrap()).unwrap(); - client.set_default_client_config(insecure_mark_client()); - let connection = client.connect(address, "localhost").unwrap().await.unwrap(); - let (mut send, mut receive) = connection.open_bi().await.unwrap(); - send.write_all(&request("/shares/field-notes.md", "auth: reader-token\n")) - .await - .unwrap(); - send.finish().unwrap(); - let response = receive - .read_to_end(MARK_MAX_DOCUMENT_BYTES + 1024) - .await - .unwrap(); - drop(send); - drop(receive); - drop(connection); - serving.await.unwrap().unwrap(); - drop(host); - let response = String::from_utf8(response).unwrap(); - assert!(response.starts_with("---\nstatus: ok\n")); - assert!(response.ends_with(&expected_body)); - assert!(response.contains("Over QUIC.")); - } -} diff --git a/ports/knot/src/publish.rs b/ports/knot/src/publish.rs deleted file mode 100644 index b1da7672b..000000000 --- a/ports/knot/src/publish.rs +++ /dev/null @@ -1,961 +0,0 @@ -// Copyright 2026 Mark Alan Boykin -// 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 https://mozilla.org/MPL/2.0/. -// SPDX-License-Identifier: MPL-2.0 - -//! Explicit, read-only publication of selected retained Knot documents. -//! -//! This module owns publication selection and source eligibility. It has no -//! transport: a carrier must admit a reader before asking it for a catalog or -//! a document, and it must turn every unavailable source state into the same -//! wire result. - -use std::collections::{BTreeMap, BTreeSet}; - -use muniment::Backend; -use notochord::{NetworkId, RetainedAuthority, RevocationLedger}; -use personae::IdentityProvider; -use personae::delegation::{ - CapabilityScope, DelegationCertificate, DelegationError, DelegationParent, - DelegationRevocation, SignedDelegationCertificate, SignedDelegationRevocation, -}; -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -use crate::{KnotSyncError, KnotSyncStore, KnotVault, VaultDocument}; - -/// The authenticated ALPN served by the Phase A publishing host. -pub const KNOT_PUBLISH_ALPN: &[u8] = b"mere/knot-publish/v1"; -/// Notochord domain owning the publishing action vocabulary. -pub const KNOT_PUBLISH_DOMAIN: &str = "mere.knot"; -/// Notochord service path used to admit a publishing session. -pub const KNOT_PUBLISH_SERVICE: &str = "/services/knot-publish"; -/// Read-only action admitted and rechecked for every publication response. -pub const KNOT_PUBLISH_READ_ACTION: &str = "read"; -/// Serialized handoff version for a Phase A share ticket. -pub const KNOT_SHARE_TICKET_VERSION: u16 = 1; - -/// An owner-chosen opaque handle for one published document. -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] -pub struct PublicationId(Uuid); - -impl PublicationId { - /// Allocate a new opaque publication handle. - pub fn new() -> Self { - Self(Uuid::new_v4()) - } - - /// Construct a stable handle for persistence or test fixtures. - pub fn from_uuid(value: Uuid) -> Self { - Self(value) - } - - /// The UUID form used by a candidate codec and logs that are allowed to - /// name a publication. - pub fn as_uuid(self) -> Uuid { - self.0 - } -} - -impl Default for PublicationId { - fn default() -> Self { - Self::new() - } -} - -/// Scope path checked after admission for one publication. -pub fn publication_path(publication: PublicationId) -> String { - format!("{KNOT_PUBLISH_SERVICE}/{}", publication.as_uuid()) -} - -/// A selected source document in the holder's local causal vault. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct KnotPublication { - /// Opaque id exposed to readers. - pub id: PublicationId, - /// Holder-local Knot document id. This never crosses the publication wire. - pub source_document: String, -} - -/// Owner-visible eligibility for one retained source document. -/// -/// This is local control-plane information. A reader always receives the -/// single non-disclosing [`KnotPublishRead::NotAvailable`] outcome instead. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum KnotPublishEligibility { - Eligible, - PendingHistory, - Conflicted, - AutomaticMerge, - NoCurrentHead, - UnsupportedMediaType, -} - -/// A current source document the owner may inspect before explicitly selecting -/// it for publication. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct KnotPublishCandidate { - pub source_document: String, - pub title: String, - pub media_type: String, - pub head: Option<[u8; 32]>, - pub eligibility: KnotPublishEligibility, -} - -/// Owner-controlled, explicit publication selection. -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct KnotPublishCatalog { - publications: BTreeMap, -} - -impl KnotPublishCatalog { - /// Select a current retained source document for publication. - pub fn publish(&mut self, source_document: impl Into) -> PublicationId { - let id = PublicationId::new(); - self.publish_as(id, source_document); - id - } - - /// Select a source under a caller-provided opaque handle. - /// - /// Replacing an existing handle is intentional: it lets an owner correct a - /// local selection without exposing an intermediate catalog state. - pub fn publish_as(&mut self, id: PublicationId, source_document: impl Into) { - self.publications.insert( - id, - KnotPublication { - id, - source_document: source_document.into(), - }, - ); - } - - /// Withdraw a publication. Historical reads through this host stop too. - pub fn unpublish(&mut self, id: PublicationId) -> Option { - self.publications.remove(&id) - } - - /// Whether this catalog contains an explicit selection for `id`. - pub fn contains(&self, id: PublicationId) -> bool { - self.publications.contains_key(&id) - } - - /// Inspect the holder's retained sources before adding one explicitly to - /// the catalog. This intentionally belongs to the owner control plane, - /// never the reader protocol. - pub async fn candidates( - &self, - store: &KnotSyncStore, - vault: &KnotVault, - ) -> Result, KnotPublishError> - where - B: Backend + Clone, - { - let projection = store.projection(vault).await?; - let has_pending = !projection.pending.is_empty(); - let conflicts = projection - .conflicts - .iter() - .map(|conflict| conflict.id.clone()) - .collect::>(); - let automatic_merges = projection - .automatic_merges - .iter() - .map(|merge| merge.id.clone()) - .collect::>(); - let heads = projection.document_heads; - Ok(projection - .documents - .into_iter() - .map(|document| { - let head = heads.get(&document.id).copied(); - let eligibility = if has_pending { - KnotPublishEligibility::PendingHistory - } else if conflicts.contains(&document.id) { - KnotPublishEligibility::Conflicted - } else if automatic_merges.contains(&document.id) { - KnotPublishEligibility::AutomaticMerge - } else if head.is_none() { - KnotPublishEligibility::NoCurrentHead - } else if !is_publishable_media_type(&document.media_type) { - KnotPublishEligibility::UnsupportedMediaType - } else { - KnotPublishEligibility::Eligible - }; - KnotPublishCandidate { - source_document: document.id, - title: document.title, - media_type: document.media_type, - head, - eligibility, - } - }) - .collect()) - } - - /// The holder-local selection for a publication, for the host's final - /// unpublish check immediately before it writes a response. - pub(crate) fn selection(&self, id: PublicationId) -> Option<&KnotPublication> { - self.publications.get(&id) - } - - /// List only publication ids the live authority covers. - pub fn list( - &self, - authority: &RetainedAuthority, - ledger: &RevocationLedger, - now_ms: u64, - ) -> Vec { - if authority.lapse(ledger, now_ms).is_some() { - return Vec::new(); - } - self.publications - .keys() - .copied() - .filter(|id| authority.covers(&publication_path(*id), KNOT_PUBLISH_READ_ACTION, now_ms)) - .collect() - } - - /// Fetch the selected document's sole current causal head. - pub async fn get_current( - &self, - store: &KnotSyncStore, - vault: &KnotVault, - authority: &RetainedAuthority, - ledger: &RevocationLedger, - now_ms: u64, - id: PublicationId, - ) -> Result - where - B: Backend + Clone, - { - let Some(publication) = self.authorized_publication(authority, ledger, now_ms, id) else { - return Ok(KnotPublishRead::NotAvailable); - }; - let Some((document, head)) = self.current_eligible(store, vault, publication).await? else { - return Ok(KnotPublishRead::NotAvailable); - }; - Ok(KnotPublishRead::Document(KnotPublishedDocument::new( - id, document, head, - ))) - } - - /// Fetch one exact retained document-producing operation for a selected - /// publication. The source must still be currently eligible: publication - /// never turns an unresolved conflict, deletion, or incomplete history - /// into an historical-export policy. - pub async fn get_version( - &self, - store: &KnotSyncStore, - vault: &KnotVault, - authority: &RetainedAuthority, - ledger: &RevocationLedger, - now_ms: u64, - id: PublicationId, - operation: [u8; 32], - ) -> Result - where - B: Backend + Clone, - { - let Some(publication) = self.authorized_publication(authority, ledger, now_ms, id) else { - return Ok(KnotPublishRead::NotAvailable); - }; - if self - .current_eligible(store, vault, publication) - .await? - .is_none() - { - return Ok(KnotPublishRead::NotAvailable); - } - let Some(document) = store - .document_version(vault, &publication.source_document, operation) - .await? - else { - return Ok(KnotPublishRead::NotAvailable); - }; - if !is_publishable_media_type(&document.media_type) { - return Ok(KnotPublishRead::NotAvailable); - } - Ok(KnotPublishRead::Document(KnotPublishedDocument::new( - id, document, operation, - ))) - } - - /// Owner-side materialization for the separately configured Mark adapter. - /// - /// This intentionally has no reader authority parameter: the adapter has - /// its own explicit export selection and Mark access policy. It does retain - /// every source-eligibility rule from the native lane, so an unresolved - /// causal state cannot become a false numeric Mark history. - pub(crate) async fn current_for_mark_export( - &self, - store: &KnotSyncStore, - vault: &KnotVault, - id: PublicationId, - ) -> Result, KnotPublishError> - where - B: Backend + Clone, - { - let Some(publication) = self.publications.get(&id) else { - return Ok(None); - }; - let Some((document, head)) = self.current_eligible(store, vault, publication).await? else { - return Ok(None); - }; - Ok(Some(KnotPublishedDocument::new(id, document, head))) - } - - /// Issue a recipient-bound, single-publication read ticket. Publication is - /// never inferred from an open document: it must already be selected by - /// this catalog. The ticket is secret material when its grant is secret. - pub fn issue_share( - &self, - issuer: &P, - request: KnotShareRecipient, - ) -> Result { - if !self.contains(request.publication) { - return Err(KnotShareControlError::UnknownPublication); - } - let certificate = SignedDelegationCertificate::issue( - issuer, - DelegationCertificate::new( - DelegationParent::Root(request.root_authority), - issuer.master_public_key().to_bytes(), - request.reader, - CapabilityScope { - domain: KNOT_PUBLISH_DOMAIN.into(), - resource: request.network.0.to_vec(), - path_prefix: publication_path(request.publication), - actions: [KNOT_PUBLISH_READ_ACTION.into()].into_iter().collect(), - }, - request.issued_at_ms, - request.issued_at_ms, - request.expires_at_ms, - 0, - share_nonce(), - ), - )?; - Ok(KnotShareTicket::new( - request.publisher, - request.endpoint_ticket, - request.network, - request.publication, - vec![certificate], - request.pinned_head, - )) - } - - fn authorized_publication<'a>( - &'a self, - authority: &RetainedAuthority, - ledger: &RevocationLedger, - now_ms: u64, - id: PublicationId, - ) -> Option<&'a KnotPublication> { - authority - .lapse(ledger, now_ms) - .is_none() - .then(|| self.publications.get(&id)) - .flatten() - .filter(|_| authority.covers(&publication_path(id), KNOT_PUBLISH_READ_ACTION, now_ms)) - } - - async fn current_eligible( - &self, - store: &KnotSyncStore, - vault: &KnotVault, - publication: &KnotPublication, - ) -> Result, KnotPublishError> - where - B: Backend + Clone, - { - let projection = store.projection(vault).await?; - // A pending encrypted operation cannot safely be associated with a - // document without decoding causal history the holder does not have. - // Refusing publication while one exists is conservative and prevents - // a partial causal view being presented as a stable source. - if !projection.pending.is_empty() - || projection - .conflicts - .iter() - .any(|conflict| conflict.id == publication.source_document) - || projection - .automatic_merges - .iter() - .any(|merge| merge.id == publication.source_document) - { - return Ok(None); - } - let Some(head) = projection - .document_heads - .get(&publication.source_document) - .copied() - else { - return Ok(None); - }; - let Some(document) = projection - .documents - .into_iter() - .find(|document| document.id == publication.source_document) - else { - return Ok(None); - }; - if !is_publishable_media_type(&document.media_type) { - return Ok(None); - } - Ok(Some((document, head))) - } -} - -/// Source outcome presented to a carrier. Every unavailable source state maps -/// to the same variant so the carrier cannot become a catalog oracle. -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum KnotPublishRead { - Document(KnotPublishedDocument), - NotAvailable, -} - -/// The exact authored bytes and checks a reader may verify after a successful -/// source read. The holder-local document id is intentionally absent. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct KnotPublishedDocument { - pub publication: PublicationId, - pub media_type: String, - pub body: Vec, - pub operation: [u8; 32], - pub body_digest: [u8; 32], -} - -impl KnotPublishedDocument { - fn new(publication: PublicationId, document: VaultDocument, operation: [u8; 32]) -> Self { - let body_digest = *blake3::hash(&document.body).as_bytes(); - Self { - publication, - media_type: document.media_type, - body: document.body, - operation, - body_digest, - } - } - - /// Verify the advertised digest over the exact authored source bytes. - pub fn body_digest_matches(&self) -> bool { - *blake3::hash(&self.body).as_bytes() == self.body_digest - } -} - -/// A Phase A out-of-band handoff. Its delegation is supplied only to the -/// Notochord hello, never to a publication request. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct KnotShareTicket { - pub version: u16, - pub publisher: [u8; 32], - pub endpoint_ticket: String, - pub network: NetworkId, - pub service_path: String, - pub publication: PublicationId, - pub delegations: Vec, - pub pinned_head: Option<[u8; 32]>, -} - -/// The owner-visible facts required to share one selected publication with one -/// recipient. This has no vault key, writer key, or source pathname. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct KnotShareRecipient { - pub publication: PublicationId, - /// The stable transport identity serving this publication. It is allowed - /// to differ from the Personae root that signs the reader delegation: a - /// product root can authorize one device's retained publishing host - /// without turning that device key into the authority root. - pub publisher: [u8; 32], - pub reader: [u8; 32], - pub network: NetworkId, - pub endpoint_ticket: String, - pub root_authority: [u8; 32], - pub issued_at_ms: u64, - pub expires_at_ms: Option, - pub pinned_head: Option<[u8; 32]>, -} - -/// Revoke a share ticket's final certificate. The caller folds the returned -/// signed statement into the live [`RevocationLedger`] held by its host. -pub fn revoke_share( - issuer: &P, - ticket: &KnotShareTicket, - at_ms: u64, -) -> Result { - let certificate = ticket - .delegations - .last() - .ok_or(KnotShareControlError::MissingDelegation)?; - Ok(SignedDelegationRevocation::issue( - issuer, - DelegationRevocation::new( - certificate.certificate.id(), - issuer.master_public_key().to_bytes(), - certificate.certificate.scope.clone(), - at_ms, - share_nonce(), - ), - )?) -} - -impl KnotShareTicket { - /// Construct a ticket the recipient may carry out of band. - #[allow(clippy::too_many_arguments)] - pub fn new( - publisher: [u8; 32], - endpoint_ticket: impl Into, - network: NetworkId, - publication: PublicationId, - delegations: Vec, - pinned_head: Option<[u8; 32]>, - ) -> Self { - Self { - version: KNOT_SHARE_TICKET_VERSION, - publisher, - endpoint_ticket: endpoint_ticket.into(), - network, - service_path: KNOT_PUBLISH_SERVICE.into(), - publication, - delegations, - pinned_head, - } - } - - /// Check the invariant a reader applies before accepting a source body. - pub fn accepts(&self, document: &KnotPublishedDocument) -> bool { - self.version == KNOT_SHARE_TICKET_VERSION - && self.service_path == KNOT_PUBLISH_SERVICE - && self.publication == document.publication - && self - .pinned_head - .is_none_or(|expected| expected == document.operation) - && document.body_digest_matches() - } -} - -/// Internal source-materialization failure. This is never a wire refusal. -#[derive(Debug, thiserror::Error)] -pub enum KnotPublishError { - #[error(transparent)] - Sync(#[from] KnotSyncError), -} - -/// Explicit owner-control failure. These are local UI/API results, never a -/// response to a reader that could use them to enumerate a catalog. -#[derive(Debug, thiserror::Error)] -pub enum KnotShareControlError { - #[error("the document is not selected for publication")] - UnknownPublication, - #[error("the share ticket has no delegation to revoke")] - MissingDelegation, - #[error(transparent)] - Delegation(#[from] DelegationError), -} - -fn is_publishable_media_type(media_type: &str) -> bool { - matches!(media_type, "text/vnd.knot" | "text/djot") -} - -fn share_nonce() -> [u8; 32] { - let left = Uuid::new_v4(); - let right = Uuid::new_v4(); - let mut nonce = [0u8; 32]; - nonce[..16].copy_from_slice(left.as_bytes()); - nonce[16..].copy_from_slice(right.as_bytes()); - nonce -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::KnotSyncEvent; - use notochord::{AdmittedPrincipal, RequestedAction, TrafficClass}; - use personae::delegation::{CapabilityScope, DelegationCertificate, DelegationParent}; - use personae::{IdentityProvider, InMemoryProvider}; - use tempfile::tempdir; - - const NETWORK: NetworkId = NetworkId([0x91; 32]); - const ROOT: [u8; 32] = [0x92; 32]; - const NOW_MS: u64 = 50; - const EXPIRY_MS: u64 = 100; - const SPACE: [u8; 32] = [0x93; 32]; - const VAULT_KEY: [u8; 32] = [0x94; 32]; - - fn owner() -> InMemoryProvider { - InMemoryProvider::from_seed([0x95; 32]) - } - - fn reader() -> InMemoryProvider { - InMemoryProvider::from_seed([0x96; 32]) - } - - fn doc(id: &str, body: &str) -> VaultDocument { - VaultDocument { - id: id.into(), - title: id.into(), - body: body.as_bytes().to_vec(), - media_type: "text/vnd.knot".into(), - } - } - - fn authority(publication: PublicationId) -> RetainedAuthority { - let certificate = SignedDelegationCertificate::issue( - &owner(), - DelegationCertificate::new( - DelegationParent::Root(ROOT), - owner().master_public_key().to_bytes(), - reader().master_public_key().to_bytes(), - CapabilityScope { - domain: KNOT_PUBLISH_DOMAIN.into(), - resource: NETWORK.0.to_vec(), - path_prefix: publication_path(publication), - actions: [KNOT_PUBLISH_READ_ACTION.to_string()].into_iter().collect(), - }, - 5, - 10, - Some(EXPIRY_MS), - 1, - [0x97; 32], - ), - ) - .unwrap(); - RetainedAuthority::new( - AdmittedPrincipal { - subject: reader().master_public_key().to_bytes(), - class: TrafficClass::Interactive, - session_id: [0x98; 32], - action: RequestedAction { - domain: KNOT_PUBLISH_DOMAIN.into(), - path: KNOT_PUBLISH_SERVICE.into(), - action: KNOT_PUBLISH_READ_ACTION.into(), - }, - }, - vec![certificate], - ) - } - - #[test] - fn owner_controls_issue_and_revoke_one_recipient_share() { - let mut catalog = KnotPublishCatalog::default(); - let selected = catalog.publish("field-notes"); - let ticket = catalog - .issue_share( - &owner(), - KnotShareRecipient { - publication: selected, - publisher: [0x77; 32], - reader: reader().master_public_key().to_bytes(), - network: NETWORK, - endpoint_ticket: "endpoint-ticket".into(), - root_authority: ROOT, - issued_at_ms: NOW_MS, - expires_at_ms: Some(EXPIRY_MS), - pinned_head: Some([0x9a; 32]), - }, - ) - .unwrap(); - assert_eq!(ticket.publisher, [0x77; 32]); - assert_eq!( - ticket.delegations.last().unwrap().certificate.issuer, - owner().master_public_key().to_bytes(), - "the Personae issuer and carrier publisher may be different keys" - ); - assert_eq!(ticket.publication, selected); - assert_eq!(ticket.pinned_head, Some([0x9a; 32])); - let certificate = ticket.delegations.last().unwrap(); - assert!(certificate.verify()); - assert_eq!( - certificate.certificate.scope.path_prefix, - publication_path(selected) - ); - assert_eq!( - certificate.certificate.scope.actions, - [KNOT_PUBLISH_READ_ACTION.to_string()].into_iter().collect() - ); - - let revocation = revoke_share(&owner(), &ticket, NOW_MS + 1).unwrap(); - assert!(revocation.verify()); - assert_eq!( - revocation.revocation.certificate, - certificate.certificate.id() - ); - - let unselected = PublicationId::from_uuid(Uuid::from_u128(99)); - assert!(matches!( - catalog.issue_share( - &owner(), - KnotShareRecipient { - publication: unselected, - publisher: owner().master_public_key().to_bytes(), - reader: reader().master_public_key().to_bytes(), - network: NETWORK, - endpoint_ticket: "unreachable".into(), - root_authority: ROOT, - issued_at_ms: NOW_MS, - expires_at_ms: Some(EXPIRY_MS), - pinned_head: None, - }, - ), - Err(KnotShareControlError::UnknownPublication) - )); - } - - #[tokio::test] - async fn selected_current_and_retained_versions_are_the_only_source_reads() { - let directory = tempdir().unwrap(); - let vault = KnotVault::open(directory.path(), VAULT_KEY).unwrap(); - let writer = owner().master_public_key().to_bytes(); - let store = KnotSyncStore::in_memory(SPACE, [writer]); - let first = store - .author( - owner().master_keypair().to_seed(), - &vault, - &KnotSyncEvent::Put(doc("selected", "first")), - ) - .await - .unwrap(); - let second = store - .author( - owner().master_keypair().to_seed(), - &vault, - &KnotSyncEvent::Put(doc("selected", "second")), - ) - .await - .unwrap(); - let unrelated = store - .author( - owner().master_keypair().to_seed(), - &vault, - &KnotSyncEvent::Put(doc("unrelated", "hidden")), - ) - .await - .unwrap(); - - let mut catalog = KnotPublishCatalog::default(); - let selected = catalog.publish("selected"); - let other = catalog.publish("unrelated"); - let authority = authority(selected); - assert_eq!( - catalog.list(&authority, &RevocationLedger::default(), NOW_MS), - vec![selected], - "a one-publication grant does not enumerate a neighbour" - ); - - let current = catalog - .get_current( - &store, - &vault, - &authority, - &RevocationLedger::default(), - NOW_MS, - selected, - ) - .await - .unwrap(); - let KnotPublishRead::Document(current) = current else { - panic!("the selected current document must be available") - }; - assert_eq!(current.body, b"second"); - assert_eq!(current.operation, *second.hash.as_bytes()); - assert!(current.body_digest_matches()); - - let retained = catalog - .get_version( - &store, - &vault, - &authority, - &RevocationLedger::default(), - NOW_MS, - selected, - *first.hash.as_bytes(), - ) - .await - .unwrap(); - let KnotPublishRead::Document(retained) = retained else { - panic!("the exact retained Put must be available") - }; - assert_eq!(retained.body, b"first"); - assert_eq!(retained.operation, *first.hash.as_bytes()); - - for outcome in [ - catalog - .get_current( - &store, - &vault, - &authority, - &RevocationLedger::default(), - NOW_MS, - other, - ) - .await - .unwrap(), - catalog - .get_version( - &store, - &vault, - &authority, - &RevocationLedger::default(), - NOW_MS, - selected, - *unrelated.hash.as_bytes(), - ) - .await - .unwrap(), - ] { - assert_eq!(outcome, KnotPublishRead::NotAvailable); - } - } - - #[tokio::test] - async fn deletes_conflicts_and_pending_history_are_not_available() { - let directory = tempdir().unwrap(); - let vault = KnotVault::open(directory.path(), VAULT_KEY).unwrap(); - let alice = owner(); - let bob = InMemoryProvider::from_seed([0x99; 32]); - let writers = [ - alice.master_public_key().to_bytes(), - bob.master_public_key().to_bytes(), - ]; - - let deleted = KnotSyncStore::in_memory(SPACE, writers); - deleted - .author( - alice.master_keypair().to_seed(), - &vault, - &KnotSyncEvent::Put(doc("deleted", "before delete")), - ) - .await - .unwrap(); - deleted - .author( - alice.master_keypair().to_seed(), - &vault, - &KnotSyncEvent::Delete { - id: "deleted".into(), - }, - ) - .await - .unwrap(); - - let conflict_left = KnotSyncStore::in_memory(SPACE, writers); - let conflict_right = KnotSyncStore::in_memory(SPACE, writers); - let left = conflict_left - .author( - alice.master_keypair().to_seed(), - &vault, - &KnotSyncEvent::Put(doc("conflict", "alice")), - ) - .await - .unwrap(); - let right = conflict_right - .author( - bob.master_keypair().to_seed(), - &vault, - &KnotSyncEvent::Put(doc("conflict", "bob")), - ) - .await - .unwrap(); - conflict_left.accept(&right).await.unwrap(); - - let parent = KnotSyncStore::in_memory(SPACE, writers); - let child = KnotSyncStore::in_memory(SPACE, writers); - let pending = KnotSyncStore::in_memory(SPACE, writers); - let base = parent - .author( - alice.master_keypair().to_seed(), - &vault, - &KnotSyncEvent::Put(doc("pending", "base")), - ) - .await - .unwrap(); - child.accept(&base).await.unwrap(); - let child_operation = child - .author( - bob.master_keypair().to_seed(), - &vault, - &KnotSyncEvent::Put(doc("pending", "child")), - ) - .await - .unwrap(); - pending.accept(&child_operation).await.unwrap(); - - for (store, source) in [ - (&deleted, "deleted"), - (&conflict_left, "conflict"), - (&pending, "pending"), - ] { - let mut catalog = KnotPublishCatalog::default(); - let publication = catalog.publish(source); - let outcome = catalog - .get_current( - store, - &vault, - &authority(publication), - &RevocationLedger::default(), - NOW_MS, - publication, - ) - .await - .unwrap(); - assert_eq!(outcome, KnotPublishRead::NotAvailable); - } - assert_ne!(*left.hash.as_bytes(), *right.hash.as_bytes()); - } - - #[tokio::test] - async fn a_ticket_checks_its_pinned_causal_head_and_exact_bytes() { - let directory = tempdir().unwrap(); - let vault = KnotVault::open(directory.path(), VAULT_KEY).unwrap(); - let writer = owner().master_public_key().to_bytes(); - let store = KnotSyncStore::in_memory(SPACE, [writer]); - let operation = store - .author( - owner().master_keypair().to_seed(), - &vault, - &KnotSyncEvent::Put(doc("selected", "source")), - ) - .await - .unwrap(); - let mut catalog = KnotPublishCatalog::default(); - let publication = catalog.publish("selected"); - let current = catalog - .get_current( - &store, - &vault, - &authority(publication), - &RevocationLedger::default(), - NOW_MS, - publication, - ) - .await - .unwrap(); - let KnotPublishRead::Document(document) = current else { - panic!("selected source must be available") - }; - let ticket = KnotShareTicket::new( - writer, - "endpoint-ticket", - NETWORK, - publication, - Vec::new(), - Some(*operation.hash.as_bytes()), - ); - assert!(ticket.accepts(&document)); - - let wrong_head = KnotShareTicket::new( - writer, - "endpoint-ticket", - NETWORK, - publication, - Vec::new(), - Some([0; 32]), - ); - assert!(!wrong_head.accepts(&document)); - let mut tampered = document; - tampered.body.push(b'!'); - assert!(!ticket.accepts(&tampered)); - } -} diff --git a/ports/knot/src/publish_carrier.rs b/ports/knot/src/publish_carrier.rs deleted file mode 100644 index 9cf573ca7..000000000 --- a/ports/knot/src/publish_carrier.rs +++ /dev/null @@ -1,131 +0,0 @@ -// Copyright 2026 Mark Alan Boykin -// 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 https://mozilla.org/MPL/2.0/. -// SPDX-License-Identifier: MPL-2.0 - -//! Carrier admission for the private Knot publishing service. -//! -//! This is intentionally parallel to Graphshell's projection carrier rather -//! than calling it. The ALPN, service path, action vocabulary, and eventual -//! application bytes belong to Knot; carrier facts, Noise, and Notochord do -//! not. - -use notochord::{ - AdmittedSession, DenyReason, IoHandshakeError, LocalNetworkPolicy, NetworkId, ProfileRef, - RevocationLedger, ServiceAccess, ServiceRule, TrustedRoot, admit_session, -}; -use personae::Ed25519Keypair; -use personae::delegation::path_covers; -use tokio::io::AsyncWriteExt; -use transport::noise::{NoiseStream, secure_responder}; -use transport::{Alpn, Transport, TransportError}; - -use crate::{ - KNOT_PUBLISH_ALPN, KNOT_PUBLISH_DOMAIN, KNOT_PUBLISH_READ_ACTION, KNOT_PUBLISH_SERVICE, -}; - -/// ALPN accepted for one Phase A Knot publishing session. -pub fn publish_alpn() -> Alpn { - Alpn::from_bytes(KNOT_PUBLISH_ALPN) -} - -/// Owner policy for the read-only Knot publishing service. -/// -/// Publication-specific actions are structural children of the service path, -/// so a ticket may carry a leaf grant for exactly one publication while the -/// owner keeps one base service rule. -pub fn publish_policy( - network: NetworkId, - trusted_roots: Vec, - accepted_profiles: Vec, - max_sessions: Option, -) -> LocalNetworkPolicy { - let mut policy = LocalNetworkPolicy::closed(network); - policy.trusted_roots = trusted_roots; - policy.accepted_profiles = accepted_profiles; - policy.services.insert( - KNOT_PUBLISH_SERVICE.into(), - ServiceRule::new( - ServiceAccess::MemberOnly, - KNOT_PUBLISH_DOMAIN, - [KNOT_PUBLISH_READ_ACTION], - true, - max_sessions, - ), - ); - policy -} - -/// Why a candidate publishing stream never reached its source adapter. -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum PublishRefusal { - /// Notochord refused the signed session hello. - NotAdmitted(DenyReason), - /// The inner encrypted ALPN or identity disagreed with the outer carrier. - CarrierNoiseMismatch, - /// A policy may admit a broader action vocabulary than this host serves. - ActionNotServed(String), - /// The carrier admitted a handshake concurrently with another session; - /// the host's atomic serving budget refused it before application bytes. - CapacityExhausted, -} - -/// Failure before the carrier could decide whether to serve a publishing -/// session. -#[derive(Debug, thiserror::Error)] -pub enum PublishCarrierError { - #[error("Knot publishing carrier accept failed: {0}")] - Carrier(TransportError), - #[error("Knot publishing Noise handshake failed: {0}")] - Noise(TransportError), - #[error(transparent)] - Handshake(#[from] IoHandshakeError), -} - -/// Accept, Noise-secure, and Notochord-admit one publishing stream. -/// -/// On success, no application byte has been read. The caller owns the returned -/// `NoiseStream` and may hand it to the private candidate codec exactly once. -pub async fn accept_publish_session( - transport: &T, - identity: &Ed25519Keypair, - policy: &LocalNetworkPolicy, - ledger: &RevocationLedger, - now_ms: u64, - active_sessions: u32, -) -> Result>, PublishRefusal>, PublishCarrierError> { - let accepted = transport - .accept(publish_alpn()) - .await - .map_err(PublishCarrierError::Carrier)?; - let (stream, facts) = accepted.into_session(); - - let (mut stream, noise_peer, encrypted_alpn) = secure_responder(identity, stream) - .await - .map_err(PublishCarrierError::Noise)?; - if encrypted_alpn != publish_alpn() - || facts.authenticated_initiator != Some(noise_peer.to_bytes()) - { - let _ = stream.shutdown().await; - return Ok(Err(PublishRefusal::CarrierNoiseMismatch)); - } - - let admitted = admit_session(stream, policy, ledger, &facts, now_ms, active_sessions).await?; - let mut session = match admitted { - Ok(session) => session, - Err(reason) => return Ok(Err(PublishRefusal::NotAdmitted(reason))), - }; - if !serves_publish_action(&session.principal) { - let action = session.principal.action.action.clone(); - let _ = session.stream.shutdown().await; - return Ok(Err(PublishRefusal::ActionNotServed(action))); - } - Ok(Ok(session)) -} - -fn serves_publish_action(principal: ¬ochord::AdmittedPrincipal) -> bool { - principal.action.domain == KNOT_PUBLISH_DOMAIN - && principal.action.action == KNOT_PUBLISH_READ_ACTION - && path_covers(KNOT_PUBLISH_SERVICE, &principal.action.path) -} diff --git a/ports/knot/src/publish_client.rs b/ports/knot/src/publish_client.rs deleted file mode 100644 index 3de746f34..000000000 --- a/ports/knot/src/publish_client.rs +++ /dev/null @@ -1,220 +0,0 @@ -// Copyright 2026 Mark Alan Boykin -// 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 https://mozilla.org/MPL/2.0/. -// SPDX-License-Identifier: MPL-2.0 - -//! Recipient-side client for one private Knot publication. -//! -//! The client consumes an out-of-band ticket and asks for exactly the named -//! publication. It never uses the candidate catalog request, so a reader -//! cannot turn a valid share into a catalog probe. - -use base64::Engine as _; -use notochord::{ - FrameError, HandshakeError, IoHandshakeError, ProfileRef, RequestedAction, SessionHello, - TrafficClass, initiate_session, read_frame, write_frame, -}; -use personae::{Ed25519Keypair, InMemoryProvider}; -use tokio::io::AsyncWriteExt; -use transport::noise::secure_initiator; -use transport::{PeerID, Transport, TransportError, initiator_binding}; - -use crate::{ - KNOT_PUBLISH_DOMAIN, KNOT_PUBLISH_READ_ACTION, KNOT_PUBLISH_SERVICE, KNOT_SHARE_TICKET_VERSION, - KnotPublishRead, KnotShareTicket, PublishRequest, PublishWireError, PublishWireLimits, - decode_response, encode_request, publication_path, publish_alpn, -}; - -/// Stable derivation label for the reader identity advertised to a publisher. -/// -/// A product derives this key from its Personae root, uses it for the outer -/// carrier and inner Noise handshake, and gives only its public half to the -/// publisher. It is deliberately distinct from a device or root identity. -pub const KNOT_PUBLISH_READER_KEY_CONTEXT: &[u8] = b"mere/knot-publish/reader/v1"; - -/// Why a recipient could not import or read one private share. -#[derive(Debug, thiserror::Error)] -pub enum KnotPublishClientError { - #[error("the handoff ticket is malformed: {0}")] - Ticket(String), - #[error("the ticket is not issued to this reader key")] - WrongRecipient, - #[error("the reader carrier identity differs from its reader key")] - CarrierIdentity, - #[error("the ticket's publisher key is invalid")] - PublisherIdentity, - #[error("the publishing carrier failed: {0}")] - Transport(#[from] TransportError), - #[error("the publishing Noise handshake failed: {0}")] - Noise(TransportError), - #[error(transparent)] - Handshake(#[from] HandshakeError), - #[error(transparent)] - Session(#[from] IoHandshakeError), - #[error("the publishing host refused the reader before disclosure")] - Refused, - #[error(transparent)] - Frame(#[from] FrameError), - #[error(transparent)] - Wire(#[from] PublishWireError), - #[error("the host returned a document outside this ticket's commitment")] - TicketCommitment, -} - -impl KnotPublishClientError { - /// Whether trying the ticket's explicit endpoint is a sensible next hop. - /// Admission, commitment, and decoding failures are final: a fallback - /// address cannot make them legitimate. - pub const fn allows_endpoint_fallback(&self) -> bool { - matches!(self, Self::Transport(_)) - } -} - -/// Decode a private handoff ticket without accepting a malformed shape. -pub fn decode_share_ticket(encoded: &str) -> Result { - let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD - .decode(encoded.trim()) - .map_err(|_| KnotPublishClientError::Ticket("not URL-safe base64".into()))?; - let ticket = serde_json::from_slice::(&bytes) - .map_err(|_| KnotPublishClientError::Ticket("not a Knot share ticket".into()))?; - validate_ticket(&ticket)?; - Ok(ticket) -} - -/// Encode a previously validated ticket for a private handoff channel. -pub fn encode_share_ticket(ticket: &KnotShareTicket) -> Result { - validate_ticket(ticket)?; - let bytes = serde_json::to_vec(ticket) - .map_err(|_| KnotPublishClientError::Ticket("could not encode ticket".into()))?; - Ok(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)) -} - -/// Read the single current document named by `ticket`. -/// -/// `reader` must be the protocol-scoped key whose public half the publisher -/// certified. The carrier must use that same key, binding the outer P2P peer, -/// Noise identity, and Notochord session subject together. -pub async fn fetch_published_document( - carrier: &T, - reader: &Ed25519Keypair, - profile: ProfileRef, - ticket: &KnotShareTicket, -) -> Result { - validate_ticket(ticket)?; - let reader_peer = PeerID::from_bytes(&reader.public_key().to_bytes()) - .map_err(|_| KnotPublishClientError::CarrierIdentity)?; - if carrier.local_peer_id().to_bytes() != reader_peer.to_bytes() { - return Err(KnotPublishClientError::CarrierIdentity); - } - let recipient = ticket - .delegations - .last() - .map(|certificate| certificate.certificate.subject) - .ok_or_else(|| KnotPublishClientError::Ticket("has no delegation".into()))?; - if recipient != reader.public_key().to_bytes() { - return Err(KnotPublishClientError::WrongRecipient); - } - let publisher = PeerID::from_bytes(&ticket.publisher) - .map_err(|_| KnotPublishClientError::PublisherIdentity)?; - - let outer = carrier.connect(publisher, publish_alpn()).await?; - let (mut stream, noise_peer) = secure_initiator(reader, outer, &publish_alpn()) - .await - .map_err(KnotPublishClientError::Noise)?; - if noise_peer.to_bytes() != publisher.to_bytes() { - let _ = stream.shutdown().await; - return Err(KnotPublishClientError::PublisherIdentity); - } - - // SessionHello is signed by a derived session key beneath this reader - // protocol key. The temporary provider keeps that protocol key only for - // this request and zeroizes its copy on drop. - let provider = InMemoryProvider::from_seed(reader.to_seed()); - let hello = SessionHello::issue( - &provider, - ticket.network, - profile, - RequestedAction { - domain: KNOT_PUBLISH_DOMAIN.into(), - path: publication_path(ticket.publication), - action: KNOT_PUBLISH_READ_ACTION.into(), - }, - TrafficClass::Interactive, - nonce(), - &initiator_binding(&publish_alpn(), reader_peer), - ticket.delegations.clone(), - )?; - let reply = initiate_session(&mut stream, &hello, &Default::default()).await?; - if !reply.is_accept() { - let _ = stream.shutdown().await; - return Err(KnotPublishClientError::Refused); - } - - let limits = PublishWireLimits::default(); - let request = encode_request( - &PublishRequest::GetCurrent { - publication: ticket.publication, - }, - limits, - )?; - write_frame(&mut stream, &request, limits.max_request_bytes).await?; - let response = read_frame(&mut stream, limits.max_response_bytes).await?; - let read = decode_response(&response, limits)?.into_read()?; - if let KnotPublishRead::Document(document) = &read - && !ticket.accepts(document) - { - return Err(KnotPublishClientError::TicketCommitment); - } - Ok(read) -} - -fn validate_ticket(ticket: &KnotShareTicket) -> Result<(), KnotPublishClientError> { - if ticket.version != KNOT_SHARE_TICKET_VERSION { - return Err(KnotPublishClientError::Ticket("unsupported version".into())); - } - if ticket.service_path != KNOT_PUBLISH_SERVICE { - return Err(KnotPublishClientError::Ticket("wrong service path".into())); - } - if ticket.endpoint_ticket.trim().is_empty() { - return Err(KnotPublishClientError::Ticket( - "missing endpoint fallback".into(), - )); - } - if ticket.delegations.is_empty() { - return Err(KnotPublishClientError::Ticket("missing delegation".into())); - } - Ok(()) -} - -fn nonce() -> [u8; 32] { - let first = uuid::Uuid::new_v4(); - let second = uuid::Uuid::new_v4(); - let mut nonce = [0_u8; 32]; - nonce[..16].copy_from_slice(first.as_bytes()); - nonce[16..].copy_from_slice(second.as_bytes()); - nonce -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::PublicationId; - use notochord::NetworkId; - - #[test] - fn handoff_encoding_rejects_a_ticket_without_a_delegation() { - let ticket = KnotShareTicket::new( - [1; 32], - "endpoint-ticket", - NetworkId([2; 32]), - PublicationId::new(), - Vec::new(), - None, - ); - assert!(matches!( - encode_share_ticket(&ticket), - Err(KnotPublishClientError::Ticket(_)) - )); - } -} diff --git a/ports/knot/src/publish_host.rs b/ports/knot/src/publish_host.rs deleted file mode 100644 index a966432b0..000000000 --- a/ports/knot/src/publish_host.rs +++ /dev/null @@ -1,788 +0,0 @@ -// Copyright 2026 Mark Alan Boykin -// 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 https://mozilla.org/MPL/2.0/. -// SPDX-License-Identifier: MPL-2.0 - -//! Resident host for one private Knot publishing service. -//! -//! The host keeps publication selection, revocations, and live-session -//! accounting. It neither changes `KnotSyncHost` nor exposes its paired writer -//! transport: read publication has a different authority and lifetime. - -use std::sync::Arc; -use std::sync::atomic::{AtomicU32, Ordering}; -use std::time::{SystemTime, UNIX_EPOCH}; - -use muniment::Backend; -use notochord::{ - AdmittedSession, AuthorityLapse, FrameError, LocalNetworkPolicy, RetainedAuthority, - RevocationLedger, read_frame, write_frame, -}; -use personae::Ed25519Keypair; -use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt}; -use tokio::sync::RwLock; -use transport::Transport; - -use crate::{ - KnotPublishCandidate, KnotPublishCatalog, KnotPublishError, KnotShareControlError, - KnotShareRecipient, KnotShareTicket, KnotSyncStore, KnotVault, PublicationId, - PublishCarrierError, PublishRefusal, PublishRequest, PublishResponse, PublishWireError, - PublishWireLimits, accept_publish_session, decode_request, encode_response, -}; - -/// Owner-configurable serving limits. The candidate codec applies its own hard -/// caps before any request or body allocation. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct KnotPublishHostLimits { - pub wire: PublishWireLimits, - pub max_concurrent_sessions: u32, -} - -impl Default for KnotPublishHostLimits { - fn default() -> Self { - Self { - wire: PublishWireLimits::default(), - max_concurrent_sessions: 8, - } - } -} - -/// Terminal outcome for one accepted carrier stream. It intentionally carries -/// no source bytes: readers receive those only on the encrypted stream. -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum KnotPublishServeOutcome { - Responded, - Refused(PublishRefusal), - Lapsed(AuthorityLapse), -} - -/// Failure while running a host-owned carrier stream. -#[derive(Debug, thiserror::Error)] -pub enum KnotPublishHostError { - #[error(transparent)] - Carrier(#[from] PublishCarrierError), - #[error(transparent)] - Frame(#[from] FrameError), - #[error("Knot publishing stream failed: {0}")] - Stream(#[from] std::io::Error), - #[error(transparent)] - Wire(#[from] PublishWireError), - #[error(transparent)] - Publish(#[from] KnotPublishError), -} - -/// A library host for an explicitly selected retained Knot catalog. -#[derive(Clone)] -pub struct KnotPublishHost { - identity: Ed25519Keypair, - policy: LocalNetworkPolicy, - store: KnotSyncStore, - vault: Arc, - catalog: Arc>, - revocations: Arc>, - limits: KnotPublishHostLimits, - active_sessions: Arc, - now_ms: Arc u64 + Send + Sync>, -} - -/// The independently retained read material a startup-unlocked Knot vault -/// hands to the publishing service. It keeps the carrier identity equal to the -/// signed writer/device identity while leaving the editor's mutable vault -/// handle with the authoring endpoint. -pub struct KnotPublishSource { - identity: Ed25519Keypair, - store: KnotSyncStore, - vault: Arc, -} - -impl KnotPublishSource { - pub(crate) fn from_unlocked( - identity: Ed25519Keypair, - store: KnotSyncStore, - vault: Arc, - ) -> Self { - Self { - identity, - store, - vault, - } - } - - /// The device key that must own both the outer carrier and inner Noise - /// identity for this host. - pub fn transport_seed(&self) -> [u8; 32] { - self.identity.to_seed() - } - - /// The stable public carrier identity advertised in a share ticket. - pub fn publisher(&self) -> [u8; 32] { - self.identity.public_key().to_bytes() - } - - /// Move the retained source material into one live publishing host. - pub fn into_host( - self, - policy: LocalNetworkPolicy, - catalog: KnotPublishCatalog, - limits: KnotPublishHostLimits, - ) -> KnotPublishHost { - KnotPublishHost::new( - self.identity, - policy, - self.store, - self.vault, - catalog, - limits, - ) - } -} - -impl KnotPublishHost -where - B: Backend + Clone, -{ - /// Build a host from holder-only state. The caller retains owner controls - /// through [`Self::publish`], [`Self::unpublish`], and [`Self::revocations`]. - pub fn new( - identity: Ed25519Keypair, - policy: LocalNetworkPolicy, - store: KnotSyncStore, - vault: Arc, - catalog: KnotPublishCatalog, - limits: KnotPublishHostLimits, - ) -> Self { - Self { - identity, - policy, - store, - vault, - catalog: Arc::new(RwLock::new(catalog)), - revocations: Arc::new(RwLock::new(RevocationLedger::default())), - limits, - active_sessions: Arc::new(AtomicU32::new(0)), - now_ms: Arc::new(system_now_ms), - } - } - - /// Replace the wall clock for deterministic carrier receipts. - pub fn with_clock(mut self, now_ms: impl Fn() -> u64 + Send + Sync + 'static) -> Self { - self.now_ms = Arc::new(now_ms); - self - } - - /// Add one explicit owner selection to the served catalog. - pub async fn publish(&self, source_document: impl Into) -> PublicationId { - self.catalog.write().await.publish(source_document) - } - - /// Withdraw one selection. Holding the catalog read guard through response - /// writing makes this linearize before or after a bounded response. - pub async fn unpublish(&self, id: PublicationId) -> bool { - self.catalog.write().await.unpublish(id).is_some() - } - - /// Inspect owner-visible retained sources before selecting one. - pub async fn candidates(&self) -> Result, KnotPublishError> { - let catalog = self.catalog.read().await.clone(); - catalog.candidates(&self.store, &self.vault).await - } - - /// Issue one reader-bound share through this host's current catalog. - /// The host, not a product pane, supplies the transport identity encoded in - /// the ticket so the reader cannot be directed to a different carrier. - pub async fn issue_share( - &self, - issuer: &P, - mut request: KnotShareRecipient, - ) -> Result { - request.publisher = self.identity.public_key().to_bytes(); - self.catalog.read().await.issue_share(issuer, request) - } - - /// The owner-maintained revocation ledger. Admission snapshots it; a - /// response rereads it and holds the read guard through its final write. - pub fn revocations(&self) -> Arc> { - Arc::clone(&self.revocations) - } - - /// Currently reserved admitted serving slots. - pub fn active_sessions(&self) -> u32 { - self.active_sessions.load(Ordering::Acquire) - } - - /// Accept and serve one candidate one-request publishing stream. - pub async fn accept_and_serve( - &self, - transport: &T, - ) -> Result { - let now_ms = self.now(); - let admission_ledger = self.revocations.read().await.clone(); - let admitted = accept_publish_session( - transport, - &self.identity, - &self.policy, - &admission_ledger, - now_ms, - self.active_sessions(), - ) - .await?; - let mut session = match admitted { - Ok(session) => session, - Err(refusal) => return Ok(KnotPublishServeOutcome::Refused(refusal)), - }; - - let Some(_slot) = self.reserve_slot() else { - let _ = session.stream.shutdown().await; - return Ok(KnotPublishServeOutcome::Refused( - PublishRefusal::CapacityExhausted, - )); - }; - let authority = RetainedAuthority::from_admitted(&session); - self.serve_admitted(&mut session, authority).await - } - - async fn serve_admitted( - &self, - session: &mut AdmittedSession, - authority: RetainedAuthority, - ) -> Result - where - S: AsyncRead + AsyncWrite + Unpin, - { - let now_ms = self.now(); - let admission_ledger = self.revocations.read().await; - let admission_lapse = authority.lapse(&admission_ledger, now_ms); - drop(admission_ledger); - if let Some(lapse) = admission_lapse { - let _ = session.stream.shutdown().await; - return Ok(KnotPublishServeOutcome::Lapsed(lapse)); - } - - let bytes = read_frame( - &mut session.stream, - self.limits.wire.clamped().max_request_bytes, - ) - .await?; - let request = match decode_request(&bytes, self.limits.wire) { - Ok(request) => request, - Err(error) => { - let _ = session.stream.shutdown().await; - return Err(error.into()); - } - }; - let requested_publication = match request { - PublishRequest::List => None, - PublishRequest::GetCurrent { publication } - | PublishRequest::GetVersion { publication, .. } => Some(publication), - }; - - // Materialize under snapshots, then take both final read guards before - // writing. A revocation or unpublish that wins either writer lock - // prevents this response; a response guard already held denotes the - // bounded response that was in flight first. - let initial_ledger = self.revocations.read().await.clone(); - let initial_catalog = self.catalog.read().await.clone(); - let selected_source = requested_publication.and_then(|publication| { - initial_catalog - .selection(publication) - .map(|selection| selection.source_document.clone()) - }); - let response = match request { - PublishRequest::List => PublishResponse::Catalog { - publications: initial_catalog.list(&authority, &initial_ledger, now_ms), - }, - PublishRequest::GetCurrent { publication } => initial_catalog - .get_current( - &self.store, - &self.vault, - &authority, - &initial_ledger, - now_ms, - publication, - ) - .await? - .into(), - PublishRequest::GetVersion { - publication, - operation, - } => initial_catalog - .get_version( - &self.store, - &self.vault, - &authority, - &initial_ledger, - now_ms, - publication, - operation, - ) - .await? - .into(), - }; - - let ledger = self.revocations.read().await; - let catalog = self.catalog.read().await; - let now_ms = self.now(); - if let Some(lapse) = authority.lapse(&ledger, now_ms) { - let _ = session.stream.shutdown().await; - return Ok(KnotPublishServeOutcome::Lapsed(lapse)); - } - let response = match (requested_publication, response) { - (None, _) => PublishResponse::Catalog { - publications: catalog.list(&authority, &ledger, now_ms), - }, - (Some(publication), response) - if catalog.selection(publication).is_some_and(|selection| { - Some(&selection.source_document) == selected_source.as_ref() - }) => - { - response - } - (Some(_), _) => PublishResponse::NotAvailable, - }; - let encoded = match encode_response(&response, self.limits.wire) { - Ok(encoded) => encoded, - // A response that would exceed the owner's ceilings never writes a - // body. It joins other unavailable source states on the wire. - Err(PublishWireError::ResponseLimit | PublishWireError::TooLarge) => { - encode_response(&PublishResponse::NotAvailable, self.limits.wire)? - } - Err(error) => return Err(error.into()), - }; - write_frame( - &mut session.stream, - &encoded, - self.limits.wire.clamped().max_response_bytes, - ) - .await?; - session.stream.shutdown().await?; - Ok(KnotPublishServeOutcome::Responded) - } - - fn reserve_slot(&self) -> Option { - loop { - let current = self.active_sessions.load(Ordering::Acquire); - if current >= self.limits.max_concurrent_sessions { - return None; - } - if self - .active_sessions - .compare_exchange(current, current + 1, Ordering::AcqRel, Ordering::Acquire) - .is_ok() - { - return Some(LiveSessionSlot { - active_sessions: Arc::clone(&self.active_sessions), - }); - } - } - } - - fn now(&self) -> u64 { - (self.now_ms)() - } -} - -struct LiveSessionSlot { - active_sessions: Arc, -} - -impl Drop for LiveSessionSlot { - fn drop(&mut self) { - self.active_sessions.fetch_sub(1, Ordering::AcqRel); - } -} - -fn system_now_ms() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_millis() - .try_into() - .unwrap_or(u64::MAX) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::time::Duration; - - use crate::{ - KNOT_PUBLISH_DOMAIN, KNOT_PUBLISH_READ_ACTION, KnotPublishRead, KnotSyncEvent, - PublishRequest, decode_response, encode_request, publication_path, publish_alpn, - publish_policy, - }; - use notochord::{ - NetworkId, ProfileRef, RequestedAction, SessionHello, TrafficClass, TrustedRoot, - initiate_session, read_frame, write_frame, - }; - use personae::delegation::{ - CapabilityScope, DelegationCertificate, DelegationParent, DelegationRevocation, - SignedDelegationCertificate, SignedDelegationRevocation, - }; - use personae::{IdentityProvider, InMemoryProvider}; - use tempfile::tempdir; - use transport::memory::MemoryTransport; - use transport::noise::secure_initiator; - use transport::p2panda_transport::P2pandaTransport; - use transport::{PeerID, Transport, initiator_binding}; - - const NETWORK: NetworkId = NetworkId([0xa1; 32]); - const ROOT: [u8; 32] = [0xa2; 32]; - const NOW_MS: u64 = 50; - const EXPIRY_MS: u64 = 100; - - fn holder() -> InMemoryProvider { - InMemoryProvider::from_seed([0xa3; 32]) - } - - fn reader() -> InMemoryProvider { - InMemoryProvider::from_seed([0xa4; 32]) - } - - fn profile() -> ProfileRef { - ProfileRef { - id: "mere.base".into(), - revision: 1, - } - } - - fn grant(publication: PublicationId) -> SignedDelegationCertificate { - SignedDelegationCertificate::issue( - &holder(), - DelegationCertificate::new( - DelegationParent::Root(ROOT), - holder().master_public_key().to_bytes(), - reader().master_public_key().to_bytes(), - CapabilityScope { - domain: KNOT_PUBLISH_DOMAIN.into(), - resource: NETWORK.0.to_vec(), - path_prefix: publication_path(publication), - actions: [KNOT_PUBLISH_READ_ACTION.to_string()].into_iter().collect(), - }, - 0, - 0, - Some(EXPIRY_MS), - 1, - [0xa5; 32], - ), - ) - .unwrap() - } - - struct HostFixture { - _directory: tempfile::TempDir, - host: KnotPublishHost, - publication: PublicationId, - } - - async fn host_and_publication() -> HostFixture { - let directory = tempdir().unwrap(); - let vault = Arc::new(KnotVault::open(directory.path(), [0xa6; 32]).unwrap()); - let writer = holder().master_public_key().to_bytes(); - let store = KnotSyncStore::in_memory(NETWORK.0, [writer]); - store - .author( - holder().master_keypair().to_seed(), - &vault, - &KnotSyncEvent::Put(crate::VaultDocument { - id: "selected".into(), - title: "Selected".into(), - body: b"private source".to_vec(), - media_type: "text/vnd.knot".into(), - }), - ) - .await - .unwrap(); - let mut catalog = KnotPublishCatalog::default(); - let publication = catalog.publish("selected"); - let policy = publish_policy( - NETWORK, - vec![TrustedRoot { - authority: ROOT, - issuer: holder().master_public_key().to_bytes(), - }], - vec![profile()], - Some(2), - ); - HostFixture { - _directory: directory, - host: KnotPublishHost::new( - holder().master_keypair().clone(), - policy, - store, - vault, - catalog, - KnotPublishHostLimits::default(), - ) - .with_clock(|| NOW_MS), - publication, - } - } - - async fn fetch_once( - host: &KnotPublishHost, - publication: PublicationId, - certificate: SignedDelegationCertificate, - ) -> (KnotPublishServeOutcome, Result) { - let holder_peer = PeerID::from_bytes(&holder().master_public_key().to_bytes()).unwrap(); - let reader_peer = PeerID::from_bytes(&reader().master_public_key().to_bytes()).unwrap(); - let (server, client) = MemoryTransport::pair(holder_peer, reader_peer); - let server_future = host.accept_and_serve(&server); - let client_future = async move { - let outer = client - .connect(holder_peer, publish_alpn()) - .await - .map_err(|error| error.to_string())?; - let (mut stream, peer) = - secure_initiator(reader().master_keypair(), outer, &publish_alpn()) - .await - .map_err(|error| error.to_string())?; - if peer.to_bytes() != holder_peer.to_bytes() { - return Err("Noise peer differs from the carrier holder".into()); - } - let hello = SessionHello::issue( - &reader(), - NETWORK, - profile(), - RequestedAction { - domain: KNOT_PUBLISH_DOMAIN.into(), - path: publication_path(publication), - action: KNOT_PUBLISH_READ_ACTION.into(), - }, - TrafficClass::Interactive, - [0xa7; 32], - &initiator_binding(&publish_alpn(), reader_peer), - vec![certificate], - ) - .map_err(|error| error.to_string())?; - let reply = initiate_session(&mut stream, &hello, &Default::default()) - .await - .map_err(|error| error.to_string())?; - if !reply.is_accept() { - return Err(format!("admission refused: {reply:?}")); - } - let limits = PublishWireLimits::default(); - let request = encode_request(&PublishRequest::GetCurrent { publication }, limits) - .map_err(|error| error.to_string())?; - write_frame(&mut stream, &request, limits.max_request_bytes) - .await - .map_err(|error| error.to_string())?; - let response = read_frame(&mut stream, limits.max_response_bytes) - .await - .map_err(|error| error.to_string())?; - decode_response(&response, limits).map_err(|error| error.to_string()) - }; - let (served, response) = tokio::join!(server_future, client_future); - (served.unwrap(), response) - } - - #[tokio::test] - async fn memory_carrier_reaches_source_only_after_noise_and_notochord_then_revokes() { - let fixture = host_and_publication().await; - let certificate = grant(fixture.publication); - let (outcome, response) = - fetch_once(&fixture.host, fixture.publication, certificate.clone()).await; - assert_eq!(outcome, KnotPublishServeOutcome::Responded); - let read = response.unwrap().into_read().unwrap(); - let KnotPublishRead::Document(document) = read else { - panic!("the selected source must reach its admitted reader") - }; - assert_eq!(document.body, b"private source"); - assert!(document.body_digest_matches()); - - let revocation = SignedDelegationRevocation::issue( - &holder(), - DelegationRevocation::new( - certificate.certificate.id(), - holder().master_public_key().to_bytes(), - certificate.certificate.scope.clone(), - NOW_MS, - [0xa8; 32], - ), - ) - .unwrap(); - assert!(fixture.host.revocations().write().await.fold(&revocation)); - let (outcome, response) = fetch_once(&fixture.host, fixture.publication, certificate).await; - assert!(matches!( - outcome, - KnotPublishServeOutcome::Refused(PublishRefusal::NotAdmitted(_)) - )); - assert!( - response.is_err(), - "revoked admission reaches no application response" - ); - } - - #[tokio::test] - async fn ticket_client_reads_only_its_named_publication() { - let fixture = host_and_publication().await; - let certificate = grant(fixture.publication); - let holder_peer = PeerID::from_bytes(&holder().master_public_key().to_bytes()).unwrap(); - let reader_peer = PeerID::from_bytes(&reader().master_public_key().to_bytes()).unwrap(); - let (server, client) = MemoryTransport::pair(holder_peer, reader_peer); - let ticket = crate::KnotShareTicket::new( - holder_peer.to_bytes(), - "memory-carrier", - NETWORK, - fixture.publication, - vec![certificate], - None, - ); - - let server_future = fixture.host.accept_and_serve(&server); - let reader = reader(); - let client_future = - crate::fetch_published_document(&client, reader.master_keypair(), profile(), &ticket); - let (served, read) = tokio::join!(server_future, client_future); - assert_eq!(served.unwrap(), KnotPublishServeOutcome::Responded); - let KnotPublishRead::Document(document) = read.unwrap() else { - panic!("a granted, selected publication must disclose its current source") - }; - assert_eq!(document.publication, ticket.publication); - assert_eq!(document.body, b"private source"); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn p2panda_loopback_uses_the_real_noise_and_notochord_path() { - let fixture = host_and_publication().await; - let server = P2pandaTransport::builder_from_seed(holder().master_keypair().to_seed()) - .alpns(vec![publish_alpn()]) - .bind() - .await - .expect("holder P2panda transport binds"); - let client = P2pandaTransport::builder_from_seed(reader().master_keypair().to_seed()) - .alpns(vec![publish_alpn()]) - .bind() - .await - .expect("reader P2panda transport binds"); - let holder_peer = server.local_peer_id(); - let reader_peer = client.local_peer_id(); - assert_eq!( - holder_peer.to_bytes(), - holder().master_public_key().to_bytes() - ); - assert_eq!( - reader_peer.to_bytes(), - reader().master_public_key().to_bytes() - ); - server - .add_peer(client.endpoint_addr().await.expect("reader endpoint")) - .await - .expect("holder knows reader endpoint"); - client - .add_peer(server.endpoint_addr().await.expect("holder endpoint")) - .await - .expect("reader knows holder endpoint"); - - let publication = fixture.publication; - let certificate = grant(publication); - let server_future = fixture.host.accept_and_serve(&server); - let client_future = async move { - let outer = client - .connect(holder_peer, publish_alpn()) - .await - .map_err(|error| error.to_string())?; - let (mut stream, peer) = - secure_initiator(reader().master_keypair(), outer, &publish_alpn()) - .await - .map_err(|error| error.to_string())?; - if peer.to_bytes() != holder_peer.to_bytes() { - return Err("Noise peer differs from the P2panda holder".into()); - } - let hello = SessionHello::issue( - &reader(), - NETWORK, - profile(), - RequestedAction { - domain: KNOT_PUBLISH_DOMAIN.into(), - path: publication_path(publication), - action: KNOT_PUBLISH_READ_ACTION.into(), - }, - TrafficClass::Interactive, - [0xab; 32], - &initiator_binding(&publish_alpn(), reader_peer), - vec![certificate], - ) - .map_err(|error| error.to_string())?; - let reply = initiate_session(&mut stream, &hello, &Default::default()) - .await - .map_err(|error| error.to_string())?; - if !reply.is_accept() { - return Err(format!("admission refused: {reply:?}")); - } - let limits = PublishWireLimits::default(); - let request = encode_request(&PublishRequest::GetCurrent { publication }, limits) - .map_err(|error| error.to_string())?; - write_frame(&mut stream, &request, limits.max_request_bytes) - .await - .map_err(|error| error.to_string())?; - let response = read_frame(&mut stream, limits.max_response_bytes) - .await - .map_err(|error| error.to_string())?; - decode_response(&response, limits).map_err(|error| error.to_string()) - }; - let (served, response) = tokio::time::timeout(Duration::from_secs(20), async { - tokio::join!(server_future, client_future) - }) - .await - .expect("P2panda loopback completed"); - assert_eq!( - served.expect("holder server outcome"), - KnotPublishServeOutcome::Responded - ); - let KnotPublishRead::Document(document) = response - .expect("reader response") - .into_read() - .expect("document response") - else { - panic!("the admitted P2panda reader must receive the selected source") - }; - assert_eq!(document.body, b"private source"); - assert!(document.body_digest_matches()); - } - - #[tokio::test] - async fn response_guard_linearizes_revocation_and_slots_release_on_drop() { - let mut fixture = host_and_publication().await; - fixture.host.limits.max_concurrent_sessions = 1; - let first = fixture - .host - .reserve_slot() - .expect("first reader reserves capacity"); - assert!( - fixture.host.reserve_slot().is_none(), - "a third-party reader is refused at capacity" - ); - drop(first); - assert!( - fixture.host.reserve_slot().is_some(), - "RAII releases capacity after a served task ends" - ); - - let certificate = grant(fixture.publication); - let revocation = SignedDelegationRevocation::issue( - &holder(), - DelegationRevocation::new( - certificate.certificate.id(), - holder().master_public_key().to_bytes(), - certificate.certificate.scope.clone(), - NOW_MS, - [0xa9; 32], - ), - ) - .unwrap(); - let ledger = fixture.host.revocations(); - let response_guard = ledger.read().await; - let writer = { - let ledger = Arc::clone(&ledger); - tokio::spawn(async move { ledger.write().await.fold(&revocation) }) - }; - tokio::task::yield_now().await; - assert!( - !writer.is_finished(), - "a revocation waits while the final bounded response guard is held" - ); - drop(response_guard); - assert!(writer.await.unwrap()); - } -} diff --git a/ports/knot/src/publish_wire.rs b/ports/knot/src/publish_wire.rs deleted file mode 100644 index 40e6c39e2..000000000 --- a/ports/knot/src/publish_wire.rs +++ /dev/null @@ -1,385 +0,0 @@ -// Copyright 2026 Mark Alan Boykin -// 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 https://mozilla.org/MPL/2.0/. -// SPDX-License-Identifier: MPL-2.0 - -//! Candidate Phase A codec for private Knot publishing. -//! -//! This serde/postcard envelope is deliberately a prototype. Its messages are -//! kept here, behind the Knot port, with a fixture corpus that Phase B can use -//! to compare a replacement grammar rather than treating this first shape as -//! a compatibility promise. - -use serde::{Deserialize, Serialize}; - -use crate::{KnotPublishRead, KnotPublishedDocument, PublicationId}; - -/// Hard request ceiling, before an owner selects a lower runtime value. -pub const HARD_MAX_REQUEST_BYTES: u32 = 64 * 1024; -/// Hard response ceiling, before an owner selects a lower runtime value. -pub const HARD_MAX_RESPONSE_BYTES: u32 = 17 * 1024 * 1024; -/// Hard authored source-body ceiling for this candidate. -pub const HARD_MAX_DOCUMENT_BYTES: u32 = 16 * 1024 * 1024; -/// Hard catalog-size ceiling for this candidate. -pub const HARD_MAX_CATALOG_ENTRIES: u32 = 4096; - -/// Owner-configurable candidate codec limits, clamped to hard ceilings. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct PublishWireLimits { - pub max_request_bytes: u32, - pub max_response_bytes: u32, - pub max_document_bytes: u32, - pub max_catalog_entries: u32, -} - -impl Default for PublishWireLimits { - fn default() -> Self { - Self { - max_request_bytes: HARD_MAX_REQUEST_BYTES, - max_response_bytes: HARD_MAX_RESPONSE_BYTES, - max_document_bytes: HARD_MAX_DOCUMENT_BYTES, - max_catalog_entries: HARD_MAX_CATALOG_ENTRIES, - } - } -} - -impl PublishWireLimits { - /// Apply the protocol's absolute allocation and disclosure caps. - pub fn clamped(self) -> Self { - Self { - max_request_bytes: self.max_request_bytes.min(HARD_MAX_REQUEST_BYTES), - max_response_bytes: self.max_response_bytes.min(HARD_MAX_RESPONSE_BYTES), - max_document_bytes: self.max_document_bytes.min(HARD_MAX_DOCUMENT_BYTES), - max_catalog_entries: self.max_catalog_entries.min(HARD_MAX_CATALOG_ENTRIES), - } - } -} - -/// Candidate request vocabulary. These names are not a published protocol. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub enum PublishRequest { - List, - GetCurrent { - publication: PublicationId, - }, - GetVersion { - publication: PublicationId, - operation: [u8; 32], - }, -} - -/// Candidate response vocabulary. `NotAvailable` deliberately conflates every -/// unavailable source state so it cannot enumerate the holder's catalog. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub enum PublishResponse { - Catalog { - publications: Vec, - }, - Document { - publication: PublicationId, - media_type: String, - body: Vec, - operation: [u8; 32], - body_digest: [u8; 32], - }, - NotAvailable, -} - -impl From for PublishResponse { - fn from(read: KnotPublishRead) -> Self { - match read { - KnotPublishRead::Document(document) => Self::Document { - publication: document.publication, - media_type: document.media_type, - body: document.body, - operation: document.operation, - body_digest: document.body_digest, - }, - KnotPublishRead::NotAvailable => Self::NotAvailable, - } - } -} - -impl PublishResponse { - /// Convert a decoded document response into the model's checked value. - pub fn into_read(self) -> Result { - match self { - Self::Document { - publication, - media_type, - body, - operation, - body_digest, - } => { - if *blake3::hash(&body).as_bytes() != body_digest { - return Err(PublishWireError::InvalidDigest); - } - Ok(KnotPublishRead::Document(KnotPublishedDocument { - publication, - media_type, - body, - operation, - body_digest, - })) - } - Self::NotAvailable => Ok(KnotPublishRead::NotAvailable), - Self::Catalog { .. } => Err(PublishWireError::UnexpectedCatalog), - } - } -} - -/// Candidate codec failure. The carrier maps this to a clean close rather than -/// an application-level source result. -#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)] -pub enum PublishWireError { - #[error("candidate frame exceeds its configured bound")] - TooLarge, - #[error("candidate frame cannot be decoded")] - Codec, - #[error("candidate frame has trailing data")] - TrailingData, - #[error("candidate response exceeds its document or catalog ceiling")] - ResponseLimit, - #[error("candidate document body digest does not match")] - InvalidDigest, - #[error("expected a document or NotAvailable response, not a catalog")] - UnexpectedCatalog, -} - -/// Encode one candidate request after enforcing its bounded frame limit. -pub fn encode_request( - request: &PublishRequest, - limits: PublishWireLimits, -) -> Result, PublishWireError> { - encode(request, limits.clamped().max_request_bytes) -} - -/// Decode exactly one candidate request. Trailing bytes are refused so a peer -/// cannot smuggle a second request into a one-request Phase A stream. -pub fn decode_request( - bytes: &[u8], - limits: PublishWireLimits, -) -> Result { - decode(bytes, limits.clamped().max_request_bytes) -} - -/// Encode a candidate response, refusing a body or catalog before allocating a -/// postcard output frame that would exceed the owner's bound. -pub fn encode_response( - response: &PublishResponse, - limits: PublishWireLimits, -) -> Result, PublishWireError> { - let limits = limits.clamped(); - match response { - PublishResponse::Catalog { publications } - if publications.len() > limits.max_catalog_entries as usize => - { - return Err(PublishWireError::ResponseLimit); - } - PublishResponse::Document { body, .. } - if body.len() > limits.max_document_bytes as usize => - { - return Err(PublishWireError::ResponseLimit); - } - _ => {} - } - encode(response, limits.max_response_bytes) -} - -/// Decode exactly one candidate response and reject a frame whose advertised -/// digest does not match its authored bytes. -pub fn decode_response( - bytes: &[u8], - limits: PublishWireLimits, -) -> Result { - let response: PublishResponse = decode(bytes, limits.clamped().max_response_bytes)?; - if let PublishResponse::Document { - body, body_digest, .. - } = &response - { - if body.len() > limits.clamped().max_document_bytes as usize - || *blake3::hash(body).as_bytes() != *body_digest - { - return Err(PublishWireError::InvalidDigest); - } - } - if let PublishResponse::Catalog { publications } = &response - && publications.len() > limits.clamped().max_catalog_entries as usize - { - return Err(PublishWireError::ResponseLimit); - } - Ok(response) -} - -fn encode(value: &T, max_bytes: u32) -> Result, PublishWireError> { - let bytes = postcard::to_allocvec(value).map_err(|_| PublishWireError::Codec)?; - if bytes.len() > max_bytes as usize { - return Err(PublishWireError::TooLarge); - } - Ok(bytes) -} - -fn decode Deserialize<'de>>( - bytes: &[u8], - max_bytes: u32, -) -> Result { - if bytes.len() > max_bytes as usize { - return Err(PublishWireError::TooLarge); - } - let (value, remaining) = - postcard::take_from_bytes(bytes).map_err(|_| PublishWireError::Codec)?; - if !remaining.is_empty() { - return Err(PublishWireError::TrailingData); - } - Ok(value) -} - -/// Outcome recorded for the first candidate corpus. Phase B uses these -/// semantic cases to compare a replacement codec without adopting postcard's -/// enum layout by accident. -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum CandidateFixtureOutcome { - Response(PublishResponse), - Refused(PublishWireError), -} - -/// One deterministic candidate request/outcome pair. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct CandidateFixture { - pub request: Vec, - pub outcome: CandidateFixtureOutcome, -} - -/// The minimum Phase A candidate corpus: all semantic operations, absence, -/// malformed input, and a bounded response refusal. -pub fn candidate_fixture_corpus() -> Vec { - let limits = PublishWireLimits::default(); - let publication = PublicationId::from_uuid(uuid::Uuid::from_u128(1)); - let document = PublishResponse::Document { - publication, - media_type: "text/vnd.knot".into(), - body: b"fixture".to_vec(), - operation: [1; 32], - body_digest: *blake3::hash(b"fixture").as_bytes(), - }; - vec![ - CandidateFixture { - request: encode_request(&PublishRequest::List, limits).expect("fixture request"), - outcome: CandidateFixtureOutcome::Response(PublishResponse::Catalog { - publications: vec![publication], - }), - }, - CandidateFixture { - request: encode_request(&PublishRequest::GetCurrent { publication }, limits) - .expect("fixture request"), - outcome: CandidateFixtureOutcome::Response(document.clone()), - }, - CandidateFixture { - request: encode_request( - &PublishRequest::GetVersion { - publication, - operation: [1; 32], - }, - limits, - ) - .expect("fixture request"), - outcome: CandidateFixtureOutcome::Response(document), - }, - CandidateFixture { - request: encode_request(&PublishRequest::GetCurrent { publication }, limits) - .expect("fixture request"), - outcome: CandidateFixtureOutcome::Response(PublishResponse::NotAvailable), - }, - CandidateFixture { - request: vec![0xff], - outcome: CandidateFixtureOutcome::Refused(PublishWireError::Codec), - }, - CandidateFixture { - request: vec![0; HARD_MAX_REQUEST_BYTES as usize + 1], - outcome: CandidateFixtureOutcome::Refused(PublishWireError::TooLarge), - }, - ] -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn candidate_corpus_covers_semantics_and_refusals() { - let fixtures = candidate_fixture_corpus(); - assert_eq!(fixtures.len(), 6); - assert_eq!( - decode_request(&fixtures[0].request, PublishWireLimits::default()).unwrap(), - PublishRequest::List - ); - assert!(matches!( - decode_request(&fixtures[1].request, PublishWireLimits::default()), - Ok(PublishRequest::GetCurrent { .. }) - )); - assert!(matches!( - decode_request(&fixtures[2].request, PublishWireLimits::default()), - Ok(PublishRequest::GetVersion { .. }) - )); - assert!(matches!( - &fixtures[3].outcome, - CandidateFixtureOutcome::Response(PublishResponse::NotAvailable) - )); - assert_eq!( - decode_request(&fixtures[4].request, PublishWireLimits::default()), - Err(PublishWireError::Codec) - ); - assert_eq!( - decode_request(&fixtures[5].request, PublishWireLimits::default()), - Err(PublishWireError::TooLarge) - ); - } - - #[test] - fn trailing_or_oversized_data_is_refused_before_use() { - let limits = PublishWireLimits { - max_request_bytes: 8, - ..PublishWireLimits::default() - }; - let mut encoded = - encode_request(&PublishRequest::List, PublishWireLimits::default()).unwrap(); - encoded.push(0); - assert_eq!( - decode_request(&encoded, PublishWireLimits::default()), - Err(PublishWireError::TrailingData) - ); - assert_eq!( - decode_request(&[0; 9], limits), - Err(PublishWireError::TooLarge) - ); - } - - #[test] - fn response_limits_and_digests_fail_closed() { - let publication = PublicationId::from_uuid(uuid::Uuid::from_u128(2)); - let response = PublishResponse::Document { - publication, - media_type: "text/vnd.knot".into(), - body: vec![1; 5], - operation: [2; 32], - body_digest: [0; 32], - }; - assert_eq!( - encode_response( - &response, - PublishWireLimits { - max_document_bytes: 4, - ..PublishWireLimits::default() - } - ), - Err(PublishWireError::ResponseLimit) - ); - - let bytes = postcard::to_allocvec(&response).unwrap(); - assert_eq!( - decode_response(&bytes, PublishWireLimits::default()), - Err(PublishWireError::InvalidDigest) - ); - } -} diff --git a/ports/knot/src/resident.rs b/ports/knot/src/resident.rs deleted file mode 100644 index 6388ff23f..000000000 --- a/ports/knot/src/resident.rs +++ /dev/null @@ -1,1220 +0,0 @@ -// Copyright 2026 Mark Alan Boykin -// 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 https://mozilla.org/MPL/2.0/. -// SPDX-License-Identifier: MPL-2.0 - -//! Always-on Knot sync. -//! -//! [`KnotSyncStore::join`](crate::KnotSyncStore::join) has been production code -//! for a while, but nothing shipped ever called it: `transport` was a -//! dev-dependency, so Knot's p2panda convergence was real and exercised only by -//! tests. The shipped endpoint bound no transport at all and therefore never -//! synchronised anything. This is the missing half. -//! -//! Identity here is deliberately split, because carrying a persona's private -//! epoch between devices makes the two halves pull in opposite directions: -//! -//! - the **vault key** and **space id** must be identical across a persona's -//! devices, or they cannot decrypt or address the same space; -//! - the **writer key** must not be, because its public half is also the -//! transport node id. Two devices deriving one writer would be a single node -//! on the network and a single author in a per-author log. -//! -//! [`StartupUnlockedPersonalVault`](crate::StartupUnlockedPersonalVault) -//! resolves that by mixing the device's own Personae root into the writer -//! derivation only. - -use std::collections::BTreeSet; -use std::sync::Arc; - -use stickleback::{JoinError, JoinedSpace, SyncStatus}; -use transport::p2panda_transport::{KnownPeer, RelayUrl}; -use transport::{ - BlobPeerAuthorizer, BlobReadAuthorizer, BlobScope, BlobStore, P2pandaHostPolicy, - P2pandaOverlayHost, P2pandaTransport, PeerID, sync_overlay_topic, -}; - -use crate::VaultDocument; -use crate::authority::{KnotAuthoritySource, KnotSpaceAuthoritySnapshot}; -use crate::clip_evidence::{KnotClipEvidenceRef, clip_evidence_references}; -use crate::sync::{KnotEncryptionProfile, KnotSyncExt, KnotSyncFileStore}; - -/// How this device reaches the persona's other devices. -#[derive(Clone, Debug, Default)] -pub struct KnotSyncHostConfig { - /// One materialization consumed by operation, evidence, and route policy. - pub authority: KnotSpaceAuthoritySnapshot, - /// iroh relays. Empty leaves this device LAN-only: p2panda registers no - /// relay by default. - pub relay_urls: Vec, -} - -#[derive(Debug, thiserror::Error)] -pub enum KnotSyncHostError { - #[error("Knot sync transport failed: {0}")] - Transport(String), - #[error(transparent)] - Join(#[from] JoinError), - #[error("Knot evidence reference failed: {0}")] - EvidenceReference(String), - #[error("Knot evidence blob failed: {0}")] - EvidenceBlob(String), - #[error("peer is not authorized for this Knot evidence store")] - EvidenceUnauthorized, - #[error("Knot evidence is {actual} bytes; configured limit is {limit}")] - EvidenceTooLarge { actual: u64, limit: u64 }, - #[error("Knot resident authority failed: {0}")] - Authority(String), -} - -/// Result of resolving one portable evidence reference. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum KnotEvidenceFetchStatus { - /// Verified bytes were already present locally. - AlreadyPresent, - /// Verified bytes were fetched from the named peer. - Fetched, -} - -/// Receipt for one verified evidence reference. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct KnotEvidenceFetchReceipt { - pub reference: KnotClipEvidenceRef, - pub status: KnotEvidenceFetchStatus, -} - -struct KnotEvidenceHost { - blobs: Arc, - readers: BlobReadAuthorizer, - scope: BlobScope, - sources: BlobPeerAuthorizer, - max_artifact_bytes: u64, -} - -/// Which peers' addresses are worth writing back to settings. -/// -/// Only the ones this host currently holds a live path to. The distinction -/// between `reachable` and `connected` is the whole of this function, and it -/// is not pedantry: an address the endpoint holds for a peer it is *not* -/// talking to may be exactly the stale route a working hint would replace, so -/// writing it back would overwrite good information with bad. A firewall can -/// drop every packet to a device while its address stays in the book, which -/// makes `reachable` look healthy while nothing replicates at all. -fn writers_to_refresh(peers: &[KnownPeer]) -> Vec<[u8; 32]> { - peers - .iter() - .filter(|peer| peer.connected) - .map(|peer| peer.peer.to_bytes()) - .collect() -} - -/// A bound transport and live LogSync session over one Knot space. -pub struct KnotSyncHost { - joined: JoinedSpace, - network: P2pandaOverlayHost, - store: KnotSyncFileStore, - space_id: [u8; 32], - evidence: Option, - authority: KnotSpaceAuthoritySnapshot, -} - -impl KnotSyncHost { - /// Bind a transport for `signing_seed` and join `store`'s space. - /// - /// The transport key is the writer seed, so a device's node id and its - /// author identity are the same value. That is what lets a paired writer - /// serve as both the thing admitted and the thing dialled. - pub async fn open( - store: &KnotSyncFileStore, - signing_seed: [u8; 32], - config: KnotSyncHostConfig, - ) -> Result { - Self::open_inner(store, signing_seed, config, None).await - } - - /// Bind personal-device sync and serve clip evidence only to paired - /// Personae-derived writer identities. - pub async fn open_with_evidence( - store: &KnotSyncFileStore, - signing_seed: [u8; 32], - config: KnotSyncHostConfig, - blobs: Arc, - max_artifact_bytes: u64, - ) -> Result { - if store.encryption_profile() != KnotEncryptionProfile::PersonalVaultV1 { - return Err(KnotSyncHostError::Authority( - "personal pairing cannot authorize a communal Knot space".into(), - )); - } - let readers = BlobReadAuthorizer::new(); - Self::open_with_scoped_evidence( - store, - signing_seed, - config, - blobs, - readers, - max_artifact_bytes, - ) - .await - } - - /// Bind personal-device sync against a caller-shared serving authorizer. - /// - /// The content-retention actor receives the same handle and binds each - /// retained hash to this space. This keeps one custody truth across local - /// authoring and remote serving. - pub async fn open_with_scoped_evidence( - store: &KnotSyncFileStore, - signing_seed: [u8; 32], - config: KnotSyncHostConfig, - blobs: Arc, - readers: BlobReadAuthorizer, - max_artifact_bytes: u64, - ) -> Result { - if store.encryption_profile() != KnotEncryptionProfile::PersonalVaultV1 { - return Err(KnotSyncHostError::Authority( - "personal pairing cannot authorize a communal Knot space".into(), - )); - } - let scope = BlobScope::new(store.space_id()); - readers.replace_readers(scope, config.authority.evidence_readers()); - let sources = BlobPeerAuthorizer::from_peers(config.authority.evidence_sources()); - Self::open_inner( - store, - signing_seed, - config, - Some(KnotEvidenceHost { - blobs, - readers, - scope, - sources, - max_artifact_bytes, - }), - ) - .await - } - - /// Bind a communal space with an authorizer materialized from Gemot facts. - pub async fn open_with_communal_evidence( - store: &KnotSyncFileStore, - signing_seed: [u8; 32], - config: KnotSyncHostConfig, - blobs: Arc, - max_artifact_bytes: u64, - ) -> Result { - if store.encryption_profile() != KnotEncryptionProfile::CommonsDataV1 { - return Err(KnotSyncHostError::Authority( - "Gemot authority requires a communal Knot space".into(), - )); - } - if config.authority.source() != KnotAuthoritySource::GemotCapabilities { - return Err(KnotSyncHostError::Authority( - "communal Knot host requires Gemot capability authority".into(), - )); - } - if store.admitted_writers() != config.authority.writers().collect::>() { - return Err(KnotSyncHostError::Authority( - "communal store writers do not match materialized Gemot authority".into(), - )); - } - let sources = BlobPeerAuthorizer::from_peers(config.authority.evidence_sources()); - let scope = BlobScope::new(store.space_id()); - let readers = BlobReadAuthorizer::new(); - readers.replace_readers(scope, config.authority.evidence_readers()); - Self::open_inner( - store, - signing_seed, - config, - Some(KnotEvidenceHost { - blobs, - readers, - scope, - sources, - max_artifact_bytes, - }), - ) - .await - } - - async fn open_inner( - store: &KnotSyncFileStore, - signing_seed: [u8; 32], - config: KnotSyncHostConfig, - evidence: Option, - ) -> Result { - validate_authority_source(store.encryption_profile(), config.authority.source())?; - let mut builder = P2pandaTransport::builder_from_seed(signing_seed).gossip(); - if let Some(evidence) = &evidence { - builder = - builder.scoped_blobs(&evidence.blobs, evidence.scope, evidence.readers.clone()); - } - let network = P2pandaOverlayHost::bind( - builder, - sync_overlay_topic(store.space_id()), - &P2pandaHostPolicy { - relay_urls: config.relay_urls, - ..P2pandaHostPolicy::default() - }, - ) - .await - .map_err(|error| KnotSyncHostError::Transport(error.to_string()))?; - network - .seed_peers(config.authority.writers()) - .await - .map_err(|error| KnotSyncHostError::Transport(error.to_string()))?; - - // The cached-address rung, as Graphshell has it: a device that has - // connected once can redial after both ends restart with no discovery - // working at all. - for (expected, hint) in config.authority.route_hints() { - match network.add_peer_ticket(hint).await { - Ok(peer) if peer.to_bytes() == *expected => {} - Ok(peer) => tracing::warn!( - expected = %crate::hex32(expected), - actual = %crate::hex32(&peer.to_bytes()), - "a stored dial hint named another peer; skipping it" - ), - Err(error) => tracing::warn!( - %error, - "a stored dial hint was unusable; skipping it" - ), - } - } - - let (endpoint, gossip) = network - .transport() - .sync_parts() - .ok_or_else(|| KnotSyncHostError::Transport("gossip is unavailable".into()))?; - let joined = store.join(endpoint, gossip).await?; - Ok(Self { - joined, - network, - store: store.clone(), - space_id: store.space_id(), - evidence, - authority: config.authority, - }) - } - - /// This device's node id, which is also its writer key: what the other - /// devices must admit. - pub fn node_id(&self) -> [u8; 32] { - self.network.local_peer_id().to_bytes() - } - - /// Stable Knot space carried by this host. - pub fn space_id(&self) -> [u8; 32] { - self.space_id - } - - pub fn sync_status(&self) -> SyncStatus { - self.joined.sync_status() - } - - /// Leave LogSync and close the transport before a persistent blob store is - /// reopened by another resident. - pub async fn close(self) -> Result<(), KnotSyncHostError> { - let Self { - joined, network, .. - } = self; - joined.leave_and_wait().await?; - network - .close() - .await - .map_err(|error| KnotSyncHostError::Transport(error.to_string())) - } - - /// Current evidence-fetch admission handle, when this host serves blobs. - pub fn evidence_authorizer(&self) -> Option { - self.evidence - .as_ref() - .map(|evidence| evidence.readers.clone()) - } - - /// Bind an already-retained reference to this host's serving scope. - /// - /// Startup uses this while replaying resident documents whose evidence was - /// retained on an earlier run. It changes authority only; bytes are not - /// copied or opened a second time. - pub fn retain_evidence_custody( - &self, - reference: &KnotClipEvidenceRef, - ) -> Result { - let evidence = self - .evidence - .as_ref() - .ok_or_else(|| KnotSyncHostError::EvidenceBlob("blob serving is disabled".into()))?; - let hash = reference - .blob_hash() - .map_err(KnotSyncHostError::EvidenceReference)?; - Ok(evidence.readers.retain(evidence.scope, hash)) - } - - /// Revision currently applied by operation, evidence, and route policy. - pub fn authority_revision(&self) -> [u8; 32] { - self.authority.revision() - } - - /// Apply one new authority materialization to every live consumer. - pub async fn apply_authority( - &mut self, - next: KnotSpaceAuthoritySnapshot, - ) -> Result { - validate_authority_source(self.store.encryption_profile(), next.source())?; - if self.authority.revision() == next.revision() { - return Ok(false); - } - - let previous_writers = self.authority.writers().collect::>(); - let next_writers = next.writers().collect::>(); - match next.source() { - KnotAuthoritySource::PersonalPairing => { - for writer in previous_writers.difference(&next_writers) { - self.store.deny_writer(writer); - } - for writer in next_writers.difference(&previous_writers).copied() { - self.store.admit_writer(writer); - } - } - KnotAuthoritySource::GemotCapabilities => { - self.store - .replace_admitted_writers(next_writers.iter().copied()); - } - } - if let Some(evidence) = &self.evidence { - evidence - .readers - .replace_readers(evidence.scope, next.evidence_readers()); - evidence.sources.replace(next.evidence_sources()); - } - - for writer in previous_writers.difference(&next_writers).copied() { - if let Err(error) = self.network.remove_peer(writer).await { - tracing::warn!( - %error, - writer = %crate::hex32(&writer), - "authority was revoked but its stale route could not be detached" - ); - } - } - for writer in next_writers.difference(&previous_writers).copied() { - if let Err(error) = self.network.add_peer(writer).await { - tracing::warn!( - %error, - writer = %crate::hex32(&writer), - "authority was granted but its route is not yet available" - ); - } - } - for (expected, hint) in next.route_hints() { - match self.network.add_peer_ticket(hint).await { - Ok(peer) if peer.to_bytes() == *expected => {} - Ok(peer) => tracing::warn!( - expected = %crate::hex32(expected), - actual = %crate::hex32(&peer.to_bytes()), - "an authority route hint named another peer; ignoring the route" - ), - Err(error) => tracing::warn!( - %error, - "an authority route hint was unavailable; authority still applied" - ), - } - } - self.authority = next; - Ok(true) - } - - /// Read and verify local evidence bytes before exposing them to a caller. - pub async fn read_evidence( - &self, - reference: &KnotClipEvidenceRef, - ) -> Result, KnotSyncHostError> { - let evidence = self - .evidence - .as_ref() - .ok_or_else(|| KnotSyncHostError::EvidenceBlob("blob serving is disabled".into()))?; - if reference.byte_size > evidence.max_artifact_bytes { - return Err(KnotSyncHostError::EvidenceTooLarge { - actual: reference.byte_size, - limit: evidence.max_artifact_bytes, - }); - } - let hash = reference - .blob_hash() - .map_err(KnotSyncHostError::EvidenceReference)?; - let bytes = evidence - .blobs - .get_bytes(hash) - .await - .map_err(|error| KnotSyncHostError::EvidenceBlob(error.to_string()))?; - reference - .verify_bytes(&bytes) - .map_err(KnotSyncHostError::EvidenceReference)?; - Ok(bytes.to_vec()) - } - - /// Fetch one reference from an authorized peer and verify it before it can - /// be read through [`Self::read_evidence`]. - pub async fn fetch_evidence( - &self, - reference: &KnotClipEvidenceRef, - writer: [u8; 32], - ) -> Result { - let evidence = self - .evidence - .as_ref() - .ok_or_else(|| KnotSyncHostError::EvidenceBlob("blob serving is disabled".into()))?; - if !evidence.sources.allows(&writer) { - return Err(KnotSyncHostError::EvidenceUnauthorized); - } - if reference.byte_size > evidence.max_artifact_bytes { - return Err(KnotSyncHostError::EvidenceTooLarge { - actual: reference.byte_size, - limit: evidence.max_artifact_bytes, - }); - } - let hash = reference - .blob_hash() - .map_err(KnotSyncHostError::EvidenceReference)?; - if evidence - .blobs - .has(hash) - .await - .map_err(|error| KnotSyncHostError::EvidenceBlob(error.to_string()))? - { - self.read_evidence(reference).await?; - evidence.readers.retain(evidence.scope, hash); - return Ok(KnotEvidenceFetchReceipt { - reference: reference.clone(), - status: KnotEvidenceFetchStatus::AlreadyPresent, - }); - } - let peer = PeerID::from_bytes(&writer) - .map_err(|error| KnotSyncHostError::EvidenceBlob(error.to_string()))?; - let tag = evidence_tag(self.space_id, reference); - evidence - .blobs - .fetch_from_named(self.network.transport(), peer, hash, tag.as_bytes()) - .await - .map_err(|error| KnotSyncHostError::EvidenceBlob(error.to_string()))?; - if let Err(error) = self.read_evidence(reference).await { - let _ = evidence.blobs.release(tag.as_bytes()).await; - return Err(error); - } - evidence.readers.retain(evidence.scope, hash); - Ok(KnotEvidenceFetchReceipt { - reference: reference.clone(), - status: KnotEvidenceFetchStatus::Fetched, - }) - } - - /// Resolve every clip evidence reference carried by a replicated Djot - /// document from one authorized peer. - pub async fn fetch_document_evidence( - &self, - document: &VaultDocument, - writer: [u8; 32], - ) -> Result, KnotSyncHostError> { - let references = clip_evidence_references(&document.body) - .map_err(KnotSyncHostError::EvidenceReference)?; - let mut receipts = Vec::with_capacity(references.len()); - for reference in references { - receipts.push(self.fetch_evidence(&reference, writer).await?); - } - Ok(receipts) - } - - /// A ticket for the across-network case a relay cannot serve. Rebuilt on - /// every bind, so it is a bootstrap value and never a stored one. - pub async fn ticket(&self) -> Result { - self.network - .ticket() - .await - .map_err(|error| KnotSyncHostError::Transport(error.to_string())) - } - - /// Which paired devices the transport currently associates with this - /// space, and whether each is merely known or actually talking. - /// - /// Pairing records identity; this reports reachability, which is the fact - /// a writer key cannot carry on its own. - pub async fn known_peers(&self) -> Result, KnotSyncHostError> { - self.network - .known_peers() - .await - .map_err(|error| KnotSyncHostError::Transport(error.to_string())) - } - - /// Where the endpoint currently believes `writer` lives, as a ticket, if - /// it holds any addresses for it. The value the cached-address rung - /// persists back into settings. - pub async fn peer_ticket(&self, writer: [u8; 32]) -> Result, KnotSyncHostError> { - self.network - .peer_ticket(writer) - .await - .map_err(|error| KnotSyncHostError::Transport(error.to_string())) - } - - /// Write back the addresses of devices this host is actually talking to. - /// - /// The other half of the cached-address rung: [`open`](Self::open) seeds - /// stored hints, and this is what puts them there in the first place. - /// Without it a hint only ever arrives if something outside Knot records - /// one. - /// - /// Three disciplines, each of which the Graphshell lane learned the hard - /// way: - /// - /// - **Connected peers only**, per [`writers_to_refresh`]. - /// - **Only on change.** [`KnotSyncHost::peer_ticket`] sorts addresses - /// before serialising, so an unchanged address set yields an identical - /// string and costs no settings write. - /// - **Reload before saving.** The settings file has a second writer: a - /// `--pair-writer` invocation can land between the caller's read and - /// this write, so the refresh loads the latest, modifies, and saves - /// rather than persisting a snapshot taken seconds ago. - pub async fn refresh_dial_hints(&self, settings_file: &std::path::Path) { - let peers = match self.known_peers().await { - Ok(peers) => peers, - Err(error) => { - tracing::warn!(%error, "could not read the peer directory"); - return; - } - }; - - for writer in writers_to_refresh(&peers) { - let ticket = match self.peer_ticket(writer).await { - Ok(Some(ticket)) => ticket, - Ok(None) => continue, - Err(error) => { - tracing::warn!(%error, "could not read a peer's current address"); - continue; - } - }; - if self.authority.route_hint(&writer) == Some(ticket.as_str()) { - continue; - } - let mut latest = match crate::KnotSettings::load(settings_file) { - Ok(latest) => latest, - Err(error) => { - tracing::warn!(%error, "could not reload settings to refresh a hint"); - continue; - } - }; - let Some(live) = latest.sync.as_mut() else { - continue; - }; - // `remember_endpoint` ignores a writer that is no longer paired, - // so an unpair landing in this window cannot be undone by a route. - if !live.remember_endpoint(writer, &ticket) { - continue; - } - match latest.save(settings_file) { - Ok(()) => tracing::info!( - writer = %crate::hex32(&writer), - "recorded a fresh dial hint for a connected device" - ), - Err(error) => tracing::warn!(%error, "could not persist a refreshed dial hint"), - } - } - } -} - -fn validate_authority_source( - profile: KnotEncryptionProfile, - source: KnotAuthoritySource, -) -> Result<(), KnotSyncHostError> { - match (profile, source) { - (KnotEncryptionProfile::PersonalVaultV1, KnotAuthoritySource::PersonalPairing) - | (KnotEncryptionProfile::CommonsDataV1, KnotAuthoritySource::GemotCapabilities) => Ok(()), - (KnotEncryptionProfile::PersonalVaultV1, KnotAuthoritySource::GemotCapabilities) => { - Err(KnotSyncHostError::Authority( - "Gemot authority cannot alter a personal Knot space".into(), - )) - } - (KnotEncryptionProfile::CommonsDataV1, KnotAuthoritySource::PersonalPairing) => { - Err(KnotSyncHostError::Authority( - "Personae pairing cannot alter a communal Knot space".into(), - )) - } - } -} - -fn evidence_tag(space_id: [u8; 32], reference: &KnotClipEvidenceRef) -> String { - format!( - "knot/evidence/{}/{}", - crate::hex32(&space_id), - reference.digest - ) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::time::Duration; - - use chirograph::{KnotClipArtifactRoleV1, KnotClipArtifactV1, PortableContentRefV1}; - use gemot::moot::constitution::{CapabilityGrant, ConstitutionRules}; - use gemot::moot::{MOOT_ACT_ACTION, MOOT_DELEGATION_DOMAIN, MootAuthority, MootDelegations}; - use personae::delegation::{ - CapabilityScope, DelegationCertificate, DelegationParent, DelegationRevocation, - SignedDelegationCertificate, SignedDelegationRevocation, - }; - use personae::{IdentityProvider, InMemoryProvider}; - use servitor::cap::Cap; - use servitor::cap_path; - use tempfile::tempdir; - - use crate::{BlobClipEvidenceStore, KnotSyncEvent, KnotVault}; - - const SPACE: [u8; 32] = [0x51; 32]; - const VAULT_KEY: [u8; 32] = [0x52; 32]; - const MOOT: [u8; 32] = [0x53; 32]; - const ROOT_GRANT: [u8; 32] = [0x54; 32]; - - /// A real Ed25519 public key: a peer id is a curve point, so an array of - /// repeated bytes will not parse as one. - fn writer(seed: u8) -> [u8; 32] { - personae::Ed25519Keypair::from_seed([seed; 32]) - .public_key() - .to_bytes() - } - - fn peer(seed: u8, reachable: bool, connected: bool) -> KnownPeer { - KnownPeer { - peer: PeerID::from_bytes(&writer(seed)).expect("a valid peer key"), - reachable, - bootstrap: false, - connected, - } - } - - #[test] - fn only_connected_peers_have_their_addresses_written_back() { - let peers = [ - // Known and addressed, but nothing is flowing: its address may be - // the stale one a good hint would replace. - peer(1, true, false), - // Actually talking: this address is true right now. - peer(2, true, true), - // Named by discovery, no address at all. - peer(3, false, false), - ]; - - assert_eq!( - writers_to_refresh(&peers), - vec![writer(2)], - "a reachable-but-silent peer must not overwrite a working hint" - ); - } - - #[test] - fn nothing_is_written_back_when_no_device_is_talking() { - let peers = [peer(1, true, false), peer(2, true, false)]; - assert!( - writers_to_refresh(&peers).is_empty(), - "an address book full of unreachable devices records no routes" - ); - } - - #[test] - fn communal_blob_and_document_permissions_come_from_gemot_paths() { - let founder = InMemoryProvider::from_seed([61; 32]); - let document_writer = InMemoryProvider::from_seed([62; 32]); - let evidence_reader = InMemoryProvider::from_seed([63; 32]); - let outsider = InMemoryProvider::from_seed([64; 32]); - let evidence_source = InMemoryProvider::from_seed([65; 32]); - let space_scope = Cap::scope(&format!("knot/{}", crate::hex32(&SPACE))).unwrap(); - let document_scope = - Cap::scope(&format!("knot/{}/document", crate::hex32(&SPACE))).unwrap(); - let evidence_read_scope = - Cap::scope(&format!("knot/{}/evidence/read", crate::hex32(&SPACE))).unwrap(); - let evidence_source_scope = - Cap::scope(&format!("knot/{}/evidence/source", crate::hex32(&SPACE))).unwrap(); - let mut rules = ConstitutionRules::founder_only(founder.master_public_key().to_bytes()); - rules.grant(CapabilityGrant { - id: ROOT_GRANT, - subject: founder.master_public_key().to_bytes(), - path_prefix: cap_path(&space_scope), - not_before_ms: 10, - expires_at_ms: Some(1_000), - delegation_depth: 2, - }); - let issue = |subject: &InMemoryProvider, capability: &Cap, nonce: u8| { - SignedDelegationCertificate::issue( - &founder, - DelegationCertificate::new( - DelegationParent::Root(ROOT_GRANT), - founder.master_public_key().to_bytes(), - subject.master_public_key().to_bytes(), - CapabilityScope { - domain: MOOT_DELEGATION_DOMAIN.into(), - resource: MOOT.to_vec(), - path_prefix: cap_path(capability), - actions: [MOOT_ACT_ACTION.to_string()].into_iter().collect(), - }, - 15, - 20, - Some(900), - 0, - [nonce; 32], - ), - ) - .unwrap() - }; - let mut delegations = MootDelegations::new(); - delegations - .accept_certificate(MOOT, &rules, issue(&document_writer, &document_scope, 1)) - .unwrap(); - delegations - .accept_certificate( - MOOT, - &rules, - issue(&evidence_reader, &evidence_read_scope, 2), - ) - .unwrap(); - delegations - .accept_certificate( - MOOT, - &rules, - issue(&evidence_source, &evidence_source_scope, 3), - ) - .unwrap(); - let authority = MootAuthority { - delegations: &delegations, - rules: &rules, - moot_id: MOOT, - now_ms: 50, - }; - let materialized = KnotSpaceAuthoritySnapshot::from_gemot_authority( - &authority, - SPACE, - [ - outsider.master_public_key().to_bytes(), - evidence_reader.master_public_key().to_bytes(), - document_writer.master_public_key().to_bytes(), - evidence_source.master_public_key().to_bytes(), - ], - [], - ) - .unwrap(); - - assert_eq!( - materialized.writers().collect::>(), - vec![document_writer.master_public_key().to_bytes()] - ); - assert!( - materialized - .evidence_readers() - .any(|peer| peer == evidence_reader.master_public_key().to_bytes()) - ); - assert!( - !materialized - .evidence_readers() - .any(|peer| peer == document_writer.master_public_key().to_bytes()) - ); - assert!( - !materialized - .writers() - .any(|peer| peer == evidence_reader.master_public_key().to_bytes()) - ); - assert!( - materialized - .evidence_sources() - .any(|peer| peer == evidence_source.master_public_key().to_bytes()) - ); - assert!( - !materialized - .evidence_sources() - .any(|peer| peer == document_writer.master_public_key().to_bytes()) - ); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 4)] - async fn signed_gemot_grant_and_revocation_update_every_live_consumer() { - let roots = tempdir().unwrap(); - let founder = InMemoryProvider::from_seed([66; 32]); - let peer = InMemoryProvider::from_seed([67; 32]); - let resident = InMemoryProvider::from_seed([68; 32]); - let peer_id = peer.master_public_key().to_bytes(); - let space_scope = Cap::scope(&format!("knot/{}", crate::hex32(&SPACE))).unwrap(); - let document_scope = - Cap::scope(&format!("knot/{}/document", crate::hex32(&SPACE))).unwrap(); - let evidence_read_scope = - Cap::scope(&format!("knot/{}/evidence/read", crate::hex32(&SPACE))).unwrap(); - let evidence_source_scope = - Cap::scope(&format!("knot/{}/evidence/source", crate::hex32(&SPACE))).unwrap(); - let mut rules = ConstitutionRules::founder_only(founder.master_public_key().to_bytes()); - rules.grant(CapabilityGrant { - id: ROOT_GRANT, - subject: founder.master_public_key().to_bytes(), - path_prefix: cap_path(&space_scope), - not_before_ms: 10, - expires_at_ms: Some(1_000), - delegation_depth: 2, - }); - let issue = |capability: &Cap, nonce: u8| { - SignedDelegationCertificate::issue( - &founder, - DelegationCertificate::new( - DelegationParent::Root(ROOT_GRANT), - founder.master_public_key().to_bytes(), - peer_id, - CapabilityScope { - domain: MOOT_DELEGATION_DOMAIN.into(), - resource: MOOT.to_vec(), - path_prefix: cap_path(capability), - actions: [MOOT_ACT_ACTION.to_string()].into_iter().collect(), - }, - 15, - 20, - Some(900), - 0, - [nonce; 32], - ), - ) - .unwrap() - }; - let certificates = [ - issue(&document_scope, 4), - issue(&evidence_read_scope, 5), - issue(&evidence_source_scope, 6), - ]; - let mut delegations = MootDelegations::new(); - let empty = KnotSpaceAuthoritySnapshot::from_gemot_authority( - &MootAuthority { - delegations: &delegations, - rules: &rules, - moot_id: MOOT, - now_ms: 50, - }, - SPACE, - [peer_id], - [], - ) - .unwrap(); - let store = KnotSyncFileStore::open_commons( - roots.path().join("communal-authority.redb"), - SPACE, - [], - ) - .unwrap(); - let blobs = Arc::new(BlobStore::new()); - let bytes = b"communal evidence remains under owner custody"; - let portable = PortableContentRefV1::of(bytes); - let reference: KnotClipEvidenceRef = serde_json::from_value(serde_json::json!({ - "content": portable, - "media_type": "text/plain", - "canonical_uri": "https://example.test/communal-evidence", - "role": KnotClipArtifactRoleV1::SourceResponse, - })) - .unwrap(); - assert_eq!( - blobs.put_bytes(bytes.to_vec()).await.unwrap(), - reference.blob_hash().unwrap() - ); - let mut host = KnotSyncHost::open_with_communal_evidence( - &store, - resident.master_keypair().to_seed(), - KnotSyncHostConfig { - authority: empty, - relay_urls: vec![], - }, - Arc::clone(&blobs), - 4096, - ) - .await - .unwrap(); - assert!(host.retain_evidence_custody(&reference).unwrap()); - let scope = BlobScope::new(SPACE); - let readers = host.evidence_authorizer().unwrap(); - let empty_revision = host.authority_revision(); - assert!(store.admitted_writers().is_empty()); - assert!(!readers.allows(scope, &peer_id, reference.blob_hash().unwrap())); - assert!(matches!( - host.fetch_evidence(&reference, peer_id).await, - Err(KnotSyncHostError::EvidenceUnauthorized) - )); - - for certificate in certificates.iter().cloned() { - delegations - .accept_certificate(MOOT, &rules, certificate) - .unwrap(); - } - let granted = KnotSpaceAuthoritySnapshot::from_gemot_authority( - &MootAuthority { - delegations: &delegations, - rules: &rules, - moot_id: MOOT, - now_ms: 50, - }, - SPACE, - [peer_id], - [], - ) - .unwrap(); - assert!(host.apply_authority(granted).await.unwrap()); - let granted_revision = host.authority_revision(); - assert_ne!(granted_revision, empty_revision); - assert_eq!(store.admitted_writers(), vec![peer_id]); - assert!(readers.allows(scope, &peer_id, reference.blob_hash().unwrap())); - assert_eq!( - host.fetch_evidence(&reference, peer_id) - .await - .unwrap() - .status, - KnotEvidenceFetchStatus::AlreadyPresent - ); - - for (certificate, nonce) in certificates.iter().zip(7_u8..) { - let revocation = DelegationRevocation::new( - certificate.certificate.id(), - founder.master_public_key().to_bytes(), - certificate.certificate.scope.clone(), - 60, - [nonce; 32], - ); - delegations - .accept_revocation(SignedDelegationRevocation::issue(&founder, revocation).unwrap()) - .unwrap(); - } - let revoked = KnotSpaceAuthoritySnapshot::from_gemot_authority( - &MootAuthority { - delegations: &delegations, - rules: &rules, - moot_id: MOOT, - now_ms: 70, - }, - SPACE, - [peer_id], - [], - ) - .unwrap(); - assert!(host.apply_authority(revoked).await.unwrap()); - assert_ne!(host.authority_revision(), granted_revision); - assert_eq!(host.authority_revision(), empty_revision); - assert!(store.admitted_writers().is_empty()); - assert!(!readers.allows(scope, &peer_id, reference.blob_hash().unwrap())); - assert!(matches!( - host.fetch_evidence(&reference, peer_id).await, - Err(KnotSyncHostError::EvidenceUnauthorized) - )); - assert_eq!(host.read_evidence(&reference).await.unwrap(), bytes); - - host.close().await.unwrap(); - blobs.shutdown().await.unwrap(); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 4)] - async fn paired_peers_replicate_djot_then_fetch_and_reopen_verified_evidence() { - let roots = tempdir().unwrap(); - let alice = InMemoryProvider::from_seed([71; 32]); - let bob = InMemoryProvider::from_seed([72; 32]); - let alice_writer = alice.master_public_key().to_bytes(); - let bob_writer = bob.master_public_key().to_bytes(); - let writers = [alice_writer, bob_writer]; - let alice_store = - KnotSyncFileStore::open(roots.path().join("alice.redb"), SPACE, writers).unwrap(); - let bob_store = - KnotSyncFileStore::open(roots.path().join("bob.redb"), SPACE, writers).unwrap(); - let alice_vault = KnotVault::open(roots.path().join("alice-vault"), VAULT_KEY).unwrap(); - let bob_vault = KnotVault::open(roots.path().join("bob-vault"), VAULT_KEY).unwrap(); - - let artifact = KnotClipArtifactV1 { - role: KnotClipArtifactRoleV1::SourceResponse, - media_type: "text/html".into(), - canonical_uri: "https://example.test/source".into(), - bytes: b"
source bytes travel separately
".to_vec(), - }; - let alice_evidence_root = roots.path().join("alice-evidence"); - let bob_evidence_root = roots.path().join("bob-evidence"); - let alice_evidence = BlobClipEvidenceStore::open_async(&alice_evidence_root, 4096) - .await - .unwrap(); - let bob_evidence = BlobClipEvidenceStore::open_async(&bob_evidence_root, 4096) - .await - .unwrap(); - let reference = alice_evidence.retain_async(&artifact).await.unwrap(); - let provenance = serde_json::json!({ - "schema": "knot.clip.insert/v2", - "evidence": [reference.clone()] - }); - let source = format!( - "# Replicated note\n\nThe authored body is ordinary Djot.\n\n```knot.clip.provenance\n{}\n```\n", - serde_json::to_string(&provenance).unwrap() - ) - .into_bytes(); - assert!( - source - .windows(artifact.bytes.len()) - .all(|window| window != artifact.bytes.as_slice()) - ); - let authored = VaultDocument { - id: "portable-clip".into(), - title: "Portable clip".into(), - body: source, - media_type: "text/vnd.djot".into(), - }; - alice_store - .author( - alice.master_keypair().to_seed(), - &alice_vault, - &KnotSyncEvent::Put(authored.clone()), - ) - .await - .unwrap(); - - let alice_blobs = alice_evidence.resident_blob_store().unwrap(); - let bob_blobs = bob_evidence.resident_blob_store().unwrap(); - let mut alice_host = KnotSyncHost::open_with_evidence( - &alice_store, - alice.master_keypair().to_seed(), - KnotSyncHostConfig { - authority: KnotSpaceAuthoritySnapshot::new( - KnotAuthoritySource::PersonalPairing, - [bob_writer], - [bob_writer], - [bob_writer], - [], - ), - relay_urls: vec![], - }, - Arc::clone(&alice_blobs), - 4096, - ) - .await - .unwrap(); - assert!(alice_host.retain_evidence_custody(&reference).unwrap()); - let alice_ticket = alice_host.ticket().await.unwrap(); - let bob_host = KnotSyncHost::open_with_evidence( - &bob_store, - bob.master_keypair().to_seed(), - KnotSyncHostConfig { - authority: KnotSpaceAuthoritySnapshot::new( - KnotAuthoritySource::PersonalPairing, - [alice_writer], - [alice_writer], - [alice_writer], - [(alice_writer, alice_ticket)], - ), - relay_urls: vec![], - }, - Arc::clone(&bob_blobs), - 4096, - ) - .await - .unwrap(); - - let replicated = tokio::time::timeout(Duration::from_secs(30), async { - loop { - if let Some(document) = bob_store - .projection(&bob_vault) - .await - .unwrap() - .documents - .into_iter() - .find(|document| document.id == authored.id) - { - break document; - } - tokio::time::sleep(Duration::from_millis(100)).await; - } - }) - .await - .expect("paired Knot peers did not replicate the Djot operation"); - assert_eq!(replicated, authored); - assert!( - replicated - .body - .windows(reference.content_uri.len()) - .any(|window| window == reference.content_uri.as_bytes()) - ); - assert!( - replicated - .body - .windows(artifact.bytes.len()) - .all(|window| window != artifact.bytes.as_slice()) - ); - - let receipts = bob_host - .fetch_document_evidence(&replicated, alice_writer) - .await - .unwrap(); - assert_eq!(receipts.len(), 1); - assert_eq!(receipts[0].status, KnotEvidenceFetchStatus::Fetched); - assert_eq!( - bob_host.read_evidence(&reference).await.unwrap(), - artifact.bytes - ); - let second = bob_host - .fetch_evidence(&reference, alice_writer) - .await - .unwrap(); - assert_eq!(second.status, KnotEvidenceFetchStatus::AlreadyPresent); - let mut false_size = reference.clone(); - false_size.byte_size += 1; - assert!(matches!( - bob_host.read_evidence(&false_size).await, - Err(KnotSyncHostError::EvidenceReference(_)) - )); - - let post_unpair_artifact = KnotClipArtifactV1 { - role: KnotClipArtifactRoleV1::SourceResponse, - media_type: "text/plain".into(), - canonical_uri: "https://example.test/after-unpair".into(), - bytes: b"fresh bytes retained after peer revocation".to_vec(), - }; - let post_unpair_reference = alice_evidence - .retain_async(&post_unpair_artifact) - .await - .unwrap(); - assert!( - alice_host - .retain_evidence_custody(&post_unpair_reference) - .unwrap() - ); - assert!( - alice_host - .apply_authority(KnotSpaceAuthoritySnapshot::default()) - .await - .unwrap() - ); - assert!(!alice_host.evidence_authorizer().unwrap().allows( - BlobScope::new(SPACE), - &bob_writer, - post_unpair_reference.blob_hash().unwrap() - )); - assert!(matches!( - bob_host - .fetch_evidence(&post_unpair_reference, alice_writer) - .await, - Err(KnotSyncHostError::EvidenceBlob(_)) - )); - assert_eq!( - alice_host - .read_evidence(&post_unpair_reference) - .await - .unwrap(), - post_unpair_artifact.bytes - ); - - alice_host.close().await.unwrap(); - bob_host.close().await.unwrap(); - drop(alice_evidence); - drop(bob_evidence); - alice_blobs.shutdown().await.unwrap(); - bob_blobs.shutdown().await.unwrap(); - drop(alice_blobs); - drop(bob_blobs); - - let reopened = BlobStore::open(&bob_evidence_root).await.unwrap(); - let offline = reopened - .get_bytes(reference.blob_hash().unwrap()) - .await - .unwrap(); - reference.verify_bytes(&offline).unwrap(); - assert_eq!(offline.as_ref(), artifact.bytes.as_slice()); - reopened.shutdown().await.unwrap(); - } -} diff --git a/ports/knot/src/rosette.rs b/ports/knot/src/rosette.rs deleted file mode 100644 index 429c6c0b5..000000000 --- a/ports/knot/src/rosette.rs +++ /dev/null @@ -1,667 +0,0 @@ -// Copyright 2026 Mark Alan Boykin -// 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 https://mozilla.org/MPL/2.0/. -// SPDX-License-Identifier: MPL-2.0 - -//! Read-only Rosette projection over a document interior. -//! -//! The adapter discloses line and stanza spans as scene items, then uses Mora -//! to derive visible sound relations. It never writes those derived relations -//! into document or graph truth. - -use mora::Phone; -use mora::english::{SYLLABLE_RULE, WEIGHT_RULE}; -use mora::meter::{Beat, Foot, Mode, beats, scan_best}; -use mora::sonance::{is_perfect_rhyme, is_slant_rhyme}; -use mora::syllable::syllabify; -use sceno::{ - Footprint, InstanceId, ProjectedItem, Rect, Representation, RoutedRelation, Scene, Size2, - SourceRef, Transform2, Vec2, -}; - -const LINE_REPRESENTATION: &str = "knot.rosette.line"; -const STANZA_REPRESENTATION: &str = "knot.rosette.stanza"; -const PERFECT_RHYME: &str = "mora.perfect-rhyme"; -const SLANT_RHYME: &str = "mora.slant-rhyme"; - -/// Supplies every known pronunciation for one normalized token. -/// -/// The trait lives at the consumer boundary so Mora remains a phone-level -/// engine and writers may replace Knot's bundled English default. -pub trait PronunciationLexicon { - fn pronunciations(&self, token: &str) -> Option<&[Vec]>; -} - -impl PronunciationLexicon for mora_cmudict::Cmudict { - fn pronunciations(&self, token: &str) -> Option<&[Vec]> { - self.pronunciations(token) - } -} - -/// Knot's offline first-party English default. -#[derive(Debug, Clone, Copy, Default)] -pub struct CmudictPronunciations; - -impl PronunciationLexicon for CmudictPronunciations { - fn pronunciations(&self, token: &str) -> Option<&[Vec]> { - mora_cmudict::Cmudict::embedded().pronunciations(token) - } -} - -/// User-configurable Rosette geometry. Values are scene units. -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct RosetteConfig { - /// Radius of the line wheel. - pub radius: f32, - /// Radius of the inner stanza wheel. - pub stanza_radius: f32, - /// Disclosed footprint for a line item. - pub line_footprint: Size2, - /// Disclosed footprint for a stanza item. - pub stanza_footprint: Size2, - /// Angle of the first line item. - pub start_angle_radians: f32, - /// Whether terminal perfect-rhyme chords are derived. - pub perfect_rhyme: bool, - /// Whether terminal slant-rhyme chords are derived. - pub slant_rhyme: bool, - /// Whether line-level accentual scansion is returned. - pub meter: bool, -} - -impl Default for RosetteConfig { - fn default() -> Self { - Self { - radius: 220.0, - stanza_radius: 112.0, - line_footprint: Size2::new(176.0, 48.0), - stanza_footprint: Size2::new(72.0, 28.0), - start_angle_radians: -std::f32::consts::FRAC_PI_2, - perfect_rhyme: true, - slant_rhyme: true, - meter: true, - } - } -} - -/// One token Mora could not analyze because the lexicon had no pronunciation. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct UnresolvedToken { - /// Zero-based projected line ordinal. - pub line: u32, - /// Inclusive byte offset in the authored source. - pub byte_start: usize, - /// Exclusive byte offset in the authored source. - pub byte_end: usize, - /// Authored token text. - pub token: String, -} - -/// Explicit coverage for the pronunciation-dependent projection. -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct LexiconCoverage { - /// Number of word tokens offered to the pronunciation provider. - pub total_tokens: usize, - /// Number of tokens with at least one pronunciation. - pub resolved_tokens: usize, - /// Tokens left unresolved rather than guessed. - pub unresolved: Vec, -} - -/// The portable scene plus the analysis coverage needed to judge it honestly. -#[derive(Debug, Clone, PartialEq)] -pub struct RosetteProjection { - /// Portable scene containing the wheel and its sound-derived chords. - pub scene: Scene, - /// Explicit pronunciation coverage for this source. - pub coverage: LexiconCoverage, - /// Source ranges presented by the scene's items. - pub interiors: Vec, - /// Derived accentual meter for lines with at least one resolved token. - pub meter: Vec, -} - -/// A line-level metrical reading derived from the selected pronunciations. -#[derive(Debug, Clone, PartialEq)] -pub struct LineMeter { - /// Zero-based projected line ordinal. - pub line: u32, - /// Stress pattern in source order. - pub beats: Vec, - /// Best common foot for the available pronunciation coverage. - pub foot: MetricalFoot, - /// Number of repetitions of `foot` in the best scan. - pub feet: usize, - /// Share of compared positions matching that meter. - pub fit: f32, - /// Difference between observed syllables and expected positions. - pub overrun: isize, - /// Whether every position matched with no overrun. - pub regular: bool, - /// Tokens represented in this scan. Unresolved tokens remain in coverage. - pub resolved_tokens: usize, -} - -/// Portable stress strength used by [`LineMeter`]. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum MetricalBeat { - Weak, - Strong, -} - -/// Portable names for the common feet Mora scans. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum MetricalFoot { - Iamb, - Trochee, - Dactyl, - Anapest, -} - -/// Which document interior one Rosette item presents. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum RosetteInteriorKind { - /// One non-empty authored line. - Line, - /// One blank-line-delimited stanza. - Stanza, -} - -/// Stable source coordinates for one item in the projected scene. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct RosetteInterior { - /// Dense scene instance assigned to this interior. - pub instance: InstanceId, - /// Whether the item presents a line or a stanza. - pub kind: RosetteInteriorKind, - /// Zero-based ordinal within its kind. - pub ordinal: u32, - /// Inclusive byte offset in the authored source. - pub byte_start: usize, - /// Exclusive byte offset in the authored source. - pub byte_end: usize, -} - -/// Project a document interior as a read-only Rosette. -/// -/// Non-empty lines are placed around the wheel in source order. Stanzas occupy -/// an inner ring. Perfect rhymes between terminal words become individual -/// routed chords. Every item remains addressable as a byte span in the source -/// document, and unresolved tokens are returned rather than guessed. -pub fn project_rosette( - document: SourceRef, - text: &str, - lexicon: &impl PronunciationLexicon, - config: RosetteConfig, -) -> RosetteProjection { - let (lines, stanzas) = parse_document(text); - let mut scene = Scene::new(); - scene.generation = generation(&document, text); - let mut coverage = LexiconCoverage::default(); - let mut interiors = Vec::new(); - let mut meter = Vec::new(); - - if lines.is_empty() { - return RosetteProjection { - scene, - coverage, - interiors, - meter, - }; - } - - let positions: Vec = (0..lines.len()) - .map(|index| { - let angle = config.start_angle_radians - + std::f32::consts::TAU * index as f32 / lines.len() as f32; - Vec2::new(config.radius * angle.cos(), config.radius * angle.sin()) - }) - .collect(); - - for (index, line) in lines.iter().enumerate() { - let source = scene.intern_source(interior_source(&document, "line", line.start, line.end)); - let instance = InstanceId(scene.items.len() as u32); - scene.items.push(ProjectedItem { - source, - space: Scene::WORLD, - transform: Transform2::translation(positions[index].x, positions[index].y), - footprint: Footprint::Rect { - size: config.line_footprint, - }, - representation: Representation::Open { - kind: LINE_REPRESENTATION.into(), - }, - layer: 1, - visible: true, - hit: None, - channels: Vec::new(), - }); - interiors.push(RosetteInterior { - instance, - kind: RosetteInteriorKind::Line, - ordinal: index as u32, - byte_start: line.start, - byte_end: line.end, - }); - - for token in &line.tokens { - coverage.total_tokens += 1; - if lexicon - .pronunciations(&token.normalized) - .is_some_and(|p| !p.is_empty()) - { - coverage.resolved_tokens += 1; - } else { - coverage.unresolved.push(UnresolvedToken { - line: index as u32, - byte_start: token.start, - byte_end: token.end, - token: token.text.clone(), - }); - } - } - } - - for (index, stanza) in stanzas.iter().enumerate() { - let position = stanza_position(stanza, &positions, config.stanza_radius); - let source = scene.intern_source(interior_source( - &document, - "stanza", - stanza.start, - stanza.end, - )); - let instance = InstanceId(scene.items.len() as u32); - scene.items.push(ProjectedItem { - source, - space: Scene::WORLD, - transform: Transform2::translation(position.x, position.y), - footprint: Footprint::Rect { - size: config.stanza_footprint, - }, - representation: Representation::Open { - kind: STANZA_REPRESENTATION.into(), - }, - layer: 0, - visible: true, - hit: None, - channels: Vec::new(), - }); - interiors.push(RosetteInterior { - instance, - kind: RosetteInteriorKind::Stanza, - ordinal: index as u32, - byte_start: stanza.start, - byte_end: stanza.end, - }); - } - - if config.meter { - for (index, line) in lines.iter().enumerate() { - if let Some(scansion) = scan_line(index as u32, line, lexicon) { - meter.push(scansion); - } - } - } - - for left in 0..lines.len() { - let Some(left_word) = lines[left].tokens.last() else { - continue; - }; - let Some(left_pronunciations) = lexicon.pronunciations(&left_word.normalized) else { - continue; - }; - - for right in (left + 1)..lines.len() { - let Some(right_word) = lines[right].tokens.last() else { - continue; - }; - let Some(right_pronunciations) = lexicon.pronunciations(&right_word.normalized) else { - continue; - }; - let relation = pronunciations_rhyme(left_pronunciations, right_pronunciations); - let kind = match relation { - Some(RhymeKind::Perfect) if config.perfect_rhyme => Some((PERFECT_RHYME, 1.0)), - Some(RhymeKind::Slant) if config.slant_rhyme => Some((SLANT_RHYME, 0.6)), - _ => None, - }; - if let Some((kind, weight)) = kind { - scene.relations.push(RoutedRelation { - from: InstanceId(left as u32), - to: InstanceId(right as u32), - space: Scene::WORLD, - points: vec![positions[left], positions[right]], - kind: Some(kind.into()), - weight: Some(weight), - }); - } - } - } - - let half_w = config.line_footprint.w.max(config.stanza_footprint.w) * 0.5; - let half_h = config.line_footprint.h.max(config.stanza_footprint.h) * 0.5; - let outer_x = config.radius.abs().max(config.stanza_radius.abs()) + half_w; - let outer_y = config.radius.abs().max(config.stanza_radius.abs()) + half_h; - scene.bounds = Rect::new( - Vec2::new(-outer_x, -outer_y), - Size2::new(outer_x * 2.0, outer_y * 2.0), - ); - - RosetteProjection { - scene, - coverage, - interiors, - meter, - } -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum RhymeKind { - Perfect, - Slant, -} - -fn pronunciations_rhyme(left: &[Vec], right: &[Vec]) -> Option { - let mut slant = false; - for left in left { - let left_syllables = syllabify(left, SYLLABLE_RULE); - for right in right { - let right_syllables = syllabify(right, SYLLABLE_RULE); - if is_perfect_rhyme((left, &left_syllables), (right, &right_syllables)) { - return Some(RhymeKind::Perfect); - } - slant |= is_slant_rhyme((left, &left_syllables), (right, &right_syllables)); - } - } - slant.then_some(RhymeKind::Slant) -} - -fn scan_line( - line_index: u32, - line: &Line, - lexicon: &impl PronunciationLexicon, -) -> Option { - let mut line_beats = Vec::new(); - let mut resolved_tokens = 0; - for token in &line.tokens { - let Some(pronunciation) = lexicon - .pronunciations(&token.normalized) - .and_then(|pronunciations| pronunciations.first()) - else { - continue; - }; - let syllables = syllabify(pronunciation, SYLLABLE_RULE); - line_beats.extend(beats( - pronunciation, - &syllables, - Mode::Accentual, - WEIGHT_RULE, - )); - resolved_tokens += 1; - } - let scansion = scan_best(&line_beats, &Foot::COMMON)?; - Some(LineMeter { - line: line_index, - beats: line_beats - .into_iter() - .map(|beat| match beat { - Beat::Weak => MetricalBeat::Weak, - Beat::Strong => MetricalBeat::Strong, - }) - .collect(), - foot: match scansion.meter.foot { - Foot::Iamb => MetricalFoot::Iamb, - Foot::Trochee => MetricalFoot::Trochee, - Foot::Dactyl => MetricalFoot::Dactyl, - Foot::Anapest => MetricalFoot::Anapest, - _ => unreachable!("Mora's common-foot scan returned a non-common foot"), - }, - feet: scansion.meter.feet, - fit: scansion.fit(), - overrun: scansion.overrun, - regular: scansion.is_regular(), - resolved_tokens, - }) -} - -fn generation(document: &SourceRef, text: &str) -> u64 { - let mut hasher = blake3::Hasher::new(); - hasher.update(document.adapter.as_bytes()); - hasher.update(&[0]); - hasher.update(document.id.as_bytes()); - hasher.update(&[0]); - hasher.update(text.as_bytes()); - let mut generation = [0; 8]; - generation.copy_from_slice(&hasher.finalize().as_bytes()[..8]); - u64::from_le_bytes(generation) -} - -fn interior_source(document: &SourceRef, kind: &str, start: usize, end: usize) -> SourceRef { - SourceRef::new( - "knot.document-interior", - format!( - "{}:{}#{}:bytes={start}..{end}", - document.adapter, document.id, kind - ), - ) -} - -fn stanza_position(stanza: &Stanza, positions: &[Vec2], radius: f32) -> Vec2 { - let (x, y) = stanza.lines.iter().fold((0.0, 0.0), |(x, y), line| { - let position = positions[*line]; - let length = (position.x * position.x + position.y * position.y).sqrt(); - if length > f32::EPSILON { - (x + position.x / length, y + position.y / length) - } else { - (x, y) - } - }); - let length = (x * x + y * y).sqrt(); - if length > f32::EPSILON { - Vec2::new(radius * x / length, radius * y / length) - } else { - let first = positions[stanza.lines[0]]; - let first_length = (first.x * first.x + first.y * first.y).sqrt(); - if first_length > f32::EPSILON { - Vec2::new( - radius * first.x / first_length, - radius * first.y / first_length, - ) - } else { - Vec2::ZERO - } - } -} - -#[derive(Debug)] -struct Line { - start: usize, - end: usize, - tokens: Vec, -} - -#[derive(Debug)] -struct Stanza { - start: usize, - end: usize, - lines: Vec, -} - -#[derive(Debug)] -struct Token { - start: usize, - end: usize, - text: String, - normalized: String, -} - -fn parse_document(text: &str) -> (Vec, Vec) { - let mut lines = Vec::new(); - let mut stanza_lines = Vec::new(); - let mut stanzas = Vec::new(); - let mut cursor = 0; - - for segment in text.split_inclusive('\n') { - let without_lf = segment.strip_suffix('\n').unwrap_or(segment); - let raw = without_lf.strip_suffix('\r').unwrap_or(without_lf); - let leading = raw.len() - raw.trim_start().len(); - let trimmed = raw.trim(); - - if trimmed.is_empty() { - finish_stanza(&lines, &mut stanza_lines, &mut stanzas); - cursor += segment.len(); - continue; - } - - let start = cursor + leading; - let end = start + trimmed.len(); - let index = lines.len(); - lines.push(Line { - start, - end, - tokens: tokens(trimmed, start), - }); - stanza_lines.push(index); - cursor += segment.len(); - } - - finish_stanza(&lines, &mut stanza_lines, &mut stanzas); - (lines, stanzas) -} - -fn finish_stanza(lines: &[Line], pending: &mut Vec, stanzas: &mut Vec) { - let Some(first) = pending.first().copied() else { - return; - }; - let last = *pending.last().unwrap(); - stanzas.push(Stanza { - start: lines[first].start, - end: lines[last].end, - lines: std::mem::take(pending), - }); -} - -fn tokens(line: &str, absolute_start: usize) -> Vec { - let mut tokens = Vec::new(); - let mut start = None; - - for (offset, character) in line.char_indices() { - let in_word = character.is_alphabetic() || matches!(character, '\'' | '’'); - match (start, in_word) { - (None, true) => start = Some(offset), - (Some(word_start), false) => { - push_token(&mut tokens, line, absolute_start, word_start, offset); - start = None; - } - _ => {} - } - } - if let Some(word_start) = start { - push_token(&mut tokens, line, absolute_start, word_start, line.len()); - } - tokens -} - -fn push_token( - tokens: &mut Vec, - line: &str, - absolute_start: usize, - start: usize, - end: usize, -) { - let text = &line[start..end]; - if !text.chars().any(char::is_alphabetic) { - return; - } - tokens.push(Token { - start: absolute_start + start, - end: absolute_start + end, - text: text.to_owned(), - normalized: text.replace('’', "'").to_ascii_lowercase(), - }); -} - -#[cfg(test)] -mod tests { - use super::*; - use scenotime::{Revision, SceneEpoch, SceneSnapshot}; - - const POEM: &str = "Morning gathers light\nBranches answer night\n\nFootsteps cross the hill\nEvening settles still\n"; - const LYRIC: &str = "Raise your open hand\nWe will take a stand\n\nCarry home the song\nLet the road run long\n"; - - #[test] - fn poem_and_lyric_are_two_deterministic_rosette_receipts() { - let lexicon = CmudictPronunciations; - for (id, text) in [("poem", POEM), ("lyric", LYRIC)] { - let source = SourceRef::new("knot.fixture", id); - let projection = project_rosette(source.clone(), text, &lexicon, Default::default()); - let repeated = project_rosette(source, text, &lexicon, Default::default()); - - assert_eq!(projection, repeated); - assert_eq!( - projection.scene.items.len(), - 6, - "four lines plus two stanzas" - ); - assert!(projection.scene.relations.len() >= 2); - assert!( - projection - .scene - .relations - .iter() - .all(|relation| relation.kind.as_deref() == Some(PERFECT_RHYME)) - ); - assert!(projection.coverage.total_tokens > 0); - assert!(projection.coverage.resolved_tokens > 0); - assert!(projection.coverage.unresolved.len() < projection.coverage.total_tokens); - assert_eq!(projection.meter.len(), 4); - assert!(projection.meter.iter().all(|line| !line.beats.is_empty())); - - let first = serde_json::to_vec(&projection.scene).unwrap(); - let second = serde_json::to_vec(&repeated.scene).unwrap(); - assert_eq!(first, second); - SceneSnapshot::from_dense(SceneEpoch(1), Revision(1), projection.scene).unwrap(); - } - } - - #[test] - fn unknown_words_are_reported_with_source_spans() { - let projection = project_rosette( - SourceRef::new("knot.fixture", "unknown"), - "Known flibbertigibbet\n", - &CmudictPronunciations, - Default::default(), - ); - let unresolved = projection - .coverage - .unresolved - .iter() - .find(|token| token.token == "flibbertigibbet") - .unwrap(); - assert_eq!(unresolved.byte_start, 6); - assert_eq!(unresolved.byte_end, 21); - assert!(projection.scene.sources[0].id.contains("line:bytes=0..21")); - } - - #[test] - fn slant_rhyme_and_meter_are_derived_without_touching_source_truth() { - let projection = project_rosette( - SourceRef::new("knot.fixture", "slant"), - "A cat\nA cut\n", - &CmudictPronunciations, - Default::default(), - ); - assert!(projection.scene.relations.iter().any(|relation| { - relation.kind.as_deref() == Some(SLANT_RHYME) && relation.weight == Some(0.6) - })); - assert_eq!(projection.meter.len(), 2); - assert!( - projection - .meter - .iter() - .all(|line| line.resolved_tokens == 2) - ); - assert_eq!(projection.interiors[0].byte_start, 0); - assert_eq!(projection.interiors[0].byte_end, 5); - } -} diff --git a/ports/knot/src/search.rs b/ports/knot/src/search.rs deleted file mode 100644 index 2a6cb2e03..000000000 --- a/ports/knot/src/search.rs +++ /dev/null @@ -1,310 +0,0 @@ -// Copyright 2026 Mark Alan Boykin -// 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 https://mozilla.org/MPL/2.0/. -// SPDX-License-Identifier: MPL-2.0 - -//! Capability-scoped search across files-in-place and sealed vault documents. - -use std::fs; - -use esp::embed::{LexicalEmbeddingProvider, SemanticSearch}; -use serde::{Deserialize, Serialize}; -use servitor::{AuthorityProvider, Cap, Mode, Subject}; - -use crate::{DirectorySource, KnotVault}; - -const DISK_SCOPE: &str = "knot/search/disk"; -const VAULT_SCOPE: &str = "knot/search/vault"; - -/// Which source lane produced a search result. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub enum SearchLane { - /// A file whose bytes remain authoritative on disk. - Disk, - /// A document held by the sealed vault. - Vault, -} - -/// One capability-filtered search result. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct SearchHit { - pub id: String, - pub lane: SearchLane, - pub score: f32, -} - -/// Host-selected bounds for the local lexical index. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct SearchConfig { - /// Number of feature-hash buckets. - pub dimensions: usize, - /// Largest disk document Knot will read for indexing. - pub max_file_bytes: u64, -} - -impl Default for SearchConfig { - fn default() -> Self { - Self { - dimensions: 512, - max_file_bytes: 2 * 1024 * 1024, - } - } -} - -/// Search state. The disk index is live in memory; the vault index is sealed -/// and opened only for an authorized query while the vault is unlocked. -pub struct KnotSearch { - config: SearchConfig, - disk: SemanticSearch, -} - -impl KnotSearch { - /// Build both lanes. Disk decoding skips binary and over-limit files. - /// Vault embeddings are immediately sealed through Personae. - pub fn build( - directory: Option<&DirectorySource>, - vault: Option<&KnotVault>, - config: SearchConfig, - ) -> Result { - let provider = || { - LexicalEmbeddingProvider::new(config.dimensions) - .map_err(|error| format!("invalid Knot search configuration: {error}")) - }; - let mut disk = SemanticSearch::new(provider()?); - if let Some(directory) = directory { - for document in directory.documents() { - if document.byte_size > config.max_file_bytes { - continue; - } - let Ok(body) = fs::read_to_string(&document.path) else { - continue; - }; - let text = format!("{}\n{body}", document.container.title); - disk.ingest(document.id.clone(), &text).map_err(|error| { - format!("could not index {}: {error}", document.path.display()) - })?; - } - } - - if let Some(vault) = vault { - if vault.is_locked() { - return Err("cannot build the Knot vault index while locked".into()); - } - let mut sealed = SemanticSearch::new(provider()?); - for document in vault.documents() { - let Ok(body) = std::str::from_utf8(&document.body) else { - continue; - }; - let text = format!("{}\n{body}", document.title); - sealed.ingest(document.id.clone(), &text).map_err(|error| { - format!("could not index vault document {}: {error}", document.id) - })?; - } - vault.store_search_index(sealed.index())?; - } - - Ok(Self { config, disk }) - } - - /// Search only lanes covered by the caller's read grants. - /// - /// A locked vault contributes nothing even if the subject holds its grant. - pub fn query( - &self, - vault: Option<&KnotVault>, - query: &str, - k: usize, - subject: Subject, - authority: &impl AuthorityProvider, - ) -> Result, String> { - if k == 0 { - return Err("Knot search result count must be positive".into()); - } - let mut hits = Vec::new(); - if covers(authority, subject, DISK_SCOPE)? { - hits.extend( - self.disk - .search(query, k) - .map_err(|error| format!("could not search Knot disk index: {error}"))? - .into_iter() - .map(|(id, score)| SearchHit { - id, - lane: SearchLane::Disk, - score, - }), - ); - } - - if covers(authority, subject, VAULT_SCOPE)? - && let Some(vault) = vault.filter(|vault| !vault.is_locked()) - && let Some(index) = vault.load_search_index()? - { - let search = SemanticSearch::with_index( - LexicalEmbeddingProvider::new(self.config.dimensions) - .map_err(|error| format!("invalid Knot search configuration: {error}"))?, - index, - ) - .map_err(|error| format!("could not open Knot vault index: {error}"))?; - hits.extend( - search - .search(query, k) - .map_err(|error| format!("could not search Knot vault index: {error}"))? - .into_iter() - .map(|(id, score)| SearchHit { - id, - lane: SearchLane::Vault, - score, - }), - ); - } - - hits.sort_by(|left, right| { - right - .score - .partial_cmp(&left.score) - .unwrap_or(std::cmp::Ordering::Equal) - .then_with(|| left.id.cmp(&right.id)) - }); - hits.truncate(k); - Ok(hits) - } - - /// Capability a subject needs to read disk search results. - pub fn disk_cap() -> Cap { - Cap::scope(DISK_SCOPE).expect("static Knot disk scope is valid") - } - - /// Capability a subject needs to read vault search results. - pub fn vault_cap() -> Cap { - Cap::scope(VAULT_SCOPE).expect("static Knot vault scope is valid") - } -} - -fn covers( - authority: &impl AuthorityProvider, - subject: Subject, - scope: &str, -) -> Result { - let cap = Cap::scope(scope).map_err(|error| format!("invalid Knot search scope: {error}"))?; - Ok(authority.covers(subject, &cap, Mode::Read)) -} - -#[cfg(test)] -mod tests { - use std::fs; - - use servitor::{Grant, GrantTable}; - use tempfile::tempdir; - - use super::*; - use crate::VaultDocument; - use crate::vault::SEARCH_INDEX_PATH; - - fn subject() -> Subject { - Subject::new([0x61; 32]) - } - - fn grant(cap: Cap) -> GrantTable { - GrantTable::new().with_grant(Grant::new(subject(), cap, Mode::Read)) - } - - fn note(id: &str, title: &str, body: &str) -> VaultDocument { - VaultDocument { - id: id.into(), - title: title.into(), - body: body.as_bytes().to_vec(), - media_type: "text/vnd.knot".into(), - } - } - - #[test] - fn search_spans_disk_and_vault_but_respects_lane_grants() { - let temp = tempdir().unwrap(); - let disk_root = temp.path().join("files"); - let vault_root = temp.path().join("vault"); - fs::create_dir(&disk_root).unwrap(); - fs::write(disk_root.join("runtime.md"), "rust async runtime internals").unwrap(); - let directory = DirectorySource::open(&disk_root).unwrap(); - let mut vault = KnotVault::open(&vault_root, [0x62; 32]).unwrap(); - vault - .put(note( - "orchard", - "Private orchard", - "orchard pruning observations", - )) - .unwrap(); - - let search = - KnotSearch::build(Some(&directory), Some(&vault), SearchConfig::default()).unwrap(); - let both = GrantTable::new() - .with_grant(Grant::new(subject(), KnotSearch::disk_cap(), Mode::Read)) - .with_grant(Grant::new(subject(), KnotSearch::vault_cap(), Mode::Read)); - let orchard = search - .query(Some(&vault), "orchard observations", 2, subject(), &both) - .unwrap(); - assert_eq!(orchard[0].lane, SearchLane::Vault); - - let disk_only = search - .query( - Some(&vault), - "orchard observations", - 2, - subject(), - &grant(KnotSearch::disk_cap()), - ) - .unwrap(); - assert!(disk_only.iter().all(|hit| hit.lane == SearchLane::Disk)); - - let vault_only = search - .query( - Some(&vault), - "rust async", - 2, - subject(), - &grant(KnotSearch::vault_cap()), - ) - .unwrap(); - assert!(vault_only.iter().all(|hit| hit.lane == SearchLane::Vault)); - } - - #[test] - fn locked_vault_has_no_hits_and_its_derived_index_is_sealed() { - let temp = tempdir().unwrap(); - let vault_root = temp.path().join("vault"); - let mut vault = KnotVault::open(&vault_root, [0x63; 32]).unwrap(); - vault - .put(note( - "private-orchard", - "Private orchard", - "confidential quince harvest", - )) - .unwrap(); - let search = KnotSearch::build(None, Some(&vault), SearchConfig::default()).unwrap(); - - let sealed = fs::read(vault_root.join(SEARCH_INDEX_PATH)).unwrap(); - for plaintext in [b"private-orchard".as_slice(), b"confidential".as_slice()] { - assert!( - !sealed - .windows(plaintext.len()) - .any(|window| window == plaintext) - ); - } - - let authority = grant(KnotSearch::vault_cap()); - assert_eq!( - search - .query(Some(&vault), "quince harvest", 1, subject(), &authority,) - .unwrap()[0] - .id, - "private-orchard" - ); - vault.lock(); - assert!( - search - .query(Some(&vault), "quince harvest", 1, subject(), &authority,) - .unwrap() - .is_empty() - ); - } -} diff --git a/ports/knot/src/settings.rs b/ports/knot/src/settings.rs deleted file mode 100644 index 6dcec6884..000000000 --- a/ports/knot/src/settings.rs +++ /dev/null @@ -1,424 +0,0 @@ -// Copyright 2026 Mark Alan Boykin -// 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 https://mozilla.org/MPL/2.0/. -// SPDX-License-Identifier: MPL-2.0 - -//! Persisted sync settings for one persona's Knot vault. -//! -//! Scoped to the persona rather than to a machine or a profile, because that -//! is what a Knot space is scoped to: the space id derives from the persona -//! uuid, so every device carrying that persona's epoch addresses the same -//! space and needs the same answer to "who else writes here". -//! -//! Deliberately not `pandect::settings_store`, which is the app's -//! surface preferences (tab cap, theme, shellbar). Deliberately not -//! Graphshell's owner settings either: that file is keyed by Personae profile -//! and carries a graph name, lane selection, and paired *node* ids, none of -//! which mean the same thing here. The two share a shape, not a subject; if a -//! third consumer appears, the atomic-write mechanism is what to extract, not -//! the schema. -//! -//! Nothing secret lands here. Writer keys are public, and the epoch that makes -//! them useful never leaves the wallet. - -use std::path::{Path, PathBuf}; - -use personae::PersonaId; -use serde::{Deserialize, Serialize}; - -/// Where a persona's Knot sync settings live: beside the vault they configure. -pub fn knot_settings_path(data_root: &Path, persona: PersonaId) -> PathBuf { - data_root - .join(pandect::PERSONAS_DIR) - .join(persona.as_uuid().to_string()) - .join("knot-sync.json") -} - -#[derive(Debug, thiserror::Error)] -pub enum KnotSettingsError { - #[error("Knot sync settings at {path}: {message}")] - File { path: String, message: String }, - #[error("Knot sync settings: {value:?} is not a 64-character hex key")] - NotHex { value: String }, -} - -/// How this persona's devices reach and admit each other. -#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)] -#[serde(default, deny_unknown_fields)] -pub struct KnotSyncSettings { - /// The other devices this persona syncs with. - /// - /// A writer key serves twice: it is the key admitted to write this space - /// and the transport node id dialled to reach that device. Knot binds its - /// transport with the writer seed, so the two cannot drift apart the way - /// they can in the personal graph. - /// - /// Older settings files stored this as a flat list of hex strings and - /// still load: see [`PairedWriter`]. - pub paired_writers: Vec, - /// iroh relay urls. Empty leaves this device LAN-only, since p2panda - /// registers no relay by default. - pub relay_urls: Vec, - /// Label recorded for this machine's own device identity. - pub device_label: String, -} - -/// Everything the resident Knot host reads at start. -#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)] -#[serde(default, deny_unknown_fields)] -pub struct KnotSettings { - /// Absent means this persona's Knot vault does not sync on this device. - pub sync: Option, -} - -impl KnotSettings { - /// A missing file means "not configured", which is not an error. Malformed - /// content is one: falling back to defaults would quietly unpair every - /// device and drop the relay, and present as a device that simply stopped - /// syncing. - pub fn load(path: &Path) -> Result { - let text = match std::fs::read_to_string(path) { - Ok(text) => text, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - return Ok(Self::default()); - } - Err(error) => { - return Err(KnotSettingsError::File { - path: path.display().to_string(), - message: error.to_string(), - }); - } - }; - serde_json::from_str(&text).map_err(|error| KnotSettingsError::File { - path: path.display().to_string(), - message: error.to_string(), - }) - } - - /// Write by rename, so a crash mid-write leaves the previous file rather - /// than a truncated one. - pub fn save(&self, path: &Path) -> Result<(), KnotSettingsError> { - let fail = |message: String| KnotSettingsError::File { - path: path.display().to_string(), - message, - }; - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent).map_err(|error| fail(error.to_string()))?; - } - let mut text = - serde_json::to_string_pretty(self).map_err(|error| fail(error.to_string()))?; - text.push('\n'); - let temporary = path.with_extension("json.tmp"); - std::fs::write(&temporary, text.as_bytes()).map_err(|error| fail(error.to_string()))?; - if path.exists() { - std::fs::remove_file(path).map_err(|error| fail(error.to_string()))?; - } - std::fs::rename(&temporary, path).map_err(|error| fail(error.to_string())) - } -} - -/// One paired device: the writer key that identifies it, and a disposable -/// hint for reaching it. -/// -/// The split is the point. `key` is identity and is never guessed at; the -/// hint is a route that was true once. A stale hint costs a failed dial -/// candidate, never a wrong belief about who someone is, which is why a hint -/// that fails to parse or dial is skipped rather than fatal. -#[derive(Clone, Debug, Serialize, PartialEq, Eq)] -pub struct PairedWriter { - /// The device's writer key, 64-hex. Both what it may write and what is - /// dialled to reach it. - pub key: String, - /// The peer's last known endpoint ticket, seeded at open as a best-effort - /// dial candidate. `None` until this device has connected once. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub last_endpoint: Option, -} - -impl PairedWriter { - /// A newly paired device, with no route learned yet. - pub fn new(key: String) -> Self { - Self { - key, - last_endpoint: None, - } - } -} - -/// Accepts both the flat `"hex"` form written before dial hints existed and -/// the `{ "key": … }` form written since, so an existing settings file keeps -/// loading and is upgraded the next time it is saved. -impl<'de> Deserialize<'de> for PairedWriter { - fn deserialize>(deserializer: D) -> Result { - #[derive(Deserialize)] - #[serde(untagged)] - enum Repr { - Bare(String), - Full { - key: String, - #[serde(default)] - last_endpoint: Option, - }, - } - Ok(match Repr::deserialize(deserializer)? { - Repr::Bare(key) => Self::new(key), - Repr::Full { key, last_endpoint } => Self { key, last_endpoint }, - }) - } -} - -impl KnotSyncSettings { - /// The paired writers as raw keys. - pub fn paired_writer_keys(&self) -> Result, KnotSettingsError> { - self.paired_writers - .iter() - .map(|writer| parse_hex32(&writer.key)) - .collect() - } - - /// Every dial hint recorded so far, for seeding at open. - pub fn dial_hints(&self) -> Vec { - self.paired_writers - .iter() - .filter_map(|writer| writer.last_endpoint.clone()) - .collect() - } - - /// The hint recorded for one device, if any. - pub fn endpoint_for(&self, writer: &[u8; 32]) -> Option<&str> { - let writer = hex32(writer); - self.paired_writers - .iter() - .find(|known| known.key.eq_ignore_ascii_case(&writer)) - .and_then(|known| known.last_endpoint.as_deref()) - } - - /// Record where a device was last reachable. Returns whether anything - /// changed, so a caller only pays a settings write when it did. - /// - /// Pairing is not implied: a hint for an unpaired device is ignored, - /// because a route may never create an admission. - pub fn remember_endpoint(&mut self, writer: [u8; 32], ticket: &str) -> bool { - let writer = hex32(&writer); - let Some(known) = self - .paired_writers - .iter_mut() - .find(|known| known.key.eq_ignore_ascii_case(&writer)) - else { - return false; - }; - if known.last_endpoint.as_deref() == Some(ticket) { - return false; - } - known.last_endpoint = Some(ticket.to_string()); - true - } - - /// Record another device. False when already present, so re-pairing does - /// not accumulate duplicates or discard a learned route. - pub fn pair(&mut self, writer: [u8; 32]) -> bool { - let writer = hex32(&writer); - if self - .paired_writers - .iter() - .any(|known| known.key.eq_ignore_ascii_case(&writer)) - { - return false; - } - self.paired_writers.push(PairedWriter::new(writer)); - true - } - - /// Forget a device. False when it was not paired, so unpairing twice is - /// not an error. The hint goes with it: an unpaired device's route is not - /// ours to keep. - pub fn unpair(&mut self, writer: [u8; 32]) -> bool { - let writer = hex32(&writer); - let before = self.paired_writers.len(); - self.paired_writers - .retain(|known| !known.key.eq_ignore_ascii_case(&writer)); - self.paired_writers.len() != before - } -} - -pub fn parse_hex32(value: &str) -> Result<[u8; 32], KnotSettingsError> { - let value = value.trim(); - if value.len() != 64 || !value.chars().all(|c| c.is_ascii_hexdigit()) { - return Err(KnotSettingsError::NotHex { - value: value.to_string(), - }); - } - let mut out = [0u8; 32]; - for (index, slot) in out.iter_mut().enumerate() { - *slot = u8::from_str_radix(&value[index * 2..index * 2 + 2], 16).map_err(|_| { - KnotSettingsError::NotHex { - value: value.to_string(), - } - })?; - } - Ok(out) -} - -pub fn hex32(bytes: &[u8; 32]) -> String { - bytes.iter().map(|byte| format!("{byte:02x}")).collect() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn a_missing_file_is_unconfigured_but_a_malformed_one_is_an_error() { - let directory = tempfile::tempdir().unwrap(); - let path = directory.path().join("knot-sync.json"); - assert_eq!(KnotSettings::load(&path).unwrap(), KnotSettings::default()); - - std::fs::write(&path, b"{ not json").unwrap(); - assert!( - KnotSettings::load(&path).is_err(), - "reading a malformed file as defaults would unpair every device \ - and present as a machine that just stopped syncing" - ); - } - - #[test] - fn a_misspelled_key_is_refused_rather_than_ignored() { - let directory = tempfile::tempdir().unwrap(); - let path = directory.path().join("knot-sync.json"); - std::fs::write(&path, br#"{"sync":{"paired_writer":[]}}"#).unwrap(); - assert!(KnotSettings::load(&path).is_err()); - } - - #[test] - fn pairing_round_trips_and_is_idempotent() { - let directory = tempfile::tempdir().unwrap(); - let path = directory.path().join("knot-sync.json"); - let mut sync = KnotSyncSettings { - relay_urls: vec!["https://relay.example/".into()], - device_label: "o-pc".into(), - ..KnotSyncSettings::default() - }; - assert!(sync.pair([0x11; 32])); - assert!(!sync.pair([0x11; 32])); - assert!(sync.pair([0x22; 32])); - - KnotSettings { - sync: Some(sync.clone()), - } - .save(&path) - .unwrap(); - let reloaded = KnotSettings::load(&path).unwrap().sync.unwrap(); - assert_eq!(reloaded, sync); - assert_eq!( - reloaded.paired_writer_keys().unwrap(), - vec![[0x11; 32], [0x22; 32]] - ); - assert!( - !path.with_extension("json.tmp").exists(), - "the temporary file must not survive a successful write" - ); - } - - #[test] - fn unpairing_removes_one_and_twice_is_not_an_error() { - let mut sync = KnotSyncSettings::default(); - sync.pair([0x31; 32]); - sync.pair([0x32; 32]); - assert!(sync.unpair([0x31; 32])); - assert!(!sync.unpair([0x31; 32])); - assert_eq!(sync.paired_writer_keys().unwrap(), vec![[0x32; 32]]); - } - - #[test] - fn settings_sit_beside_the_vault_they_configure() { - let persona = PersonaId::new(); - let path = knot_settings_path(Path::new("/data"), persona); - assert_eq!( - path.parent(), - crate::persona_vault_root(Path::new("/data"), persona).parent(), - "a persona's Knot settings and its vault must not drift apart" - ); - } - - #[test] - fn an_older_flat_writer_list_still_loads() { - // The form written before dial hints existed. Refusing it would - // silently unpair every device on upgrade. - let json = r#"{"sync":{"paired_writers":["6161616161616161616161616161616161616161616161616161616161616161","6262626262626262626262626262626262626262626262626262626262626262"],"relay_urls":[],"device_label":""}}"#; - let settings: KnotSettings = serde_json::from_str(json).unwrap(); - let sync = settings.sync.unwrap(); - - assert_eq!(sync.paired_writers.len(), 2); - assert_eq!(sync.paired_writers[0].key, "61".repeat(32)); - assert_eq!(sync.paired_writers[0].last_endpoint, None); - assert_eq!(sync.paired_writer_keys().unwrap().len(), 2); - } - - #[test] - fn the_new_form_round_trips_through_a_save_and_load() { - let directory = tempfile::tempdir().unwrap(); - let path = directory.path().join("knot.json"); - - let mut sync = KnotSyncSettings::default(); - assert!(sync.pair([0xAB; 32])); - assert!(sync.remember_endpoint([0xAB; 32], "ticket-one")); - let settings = KnotSettings { sync: Some(sync) }; - settings.save(&path).unwrap(); - - let loaded = KnotSettings::load(&path).unwrap().sync.unwrap(); - assert_eq!(loaded.endpoint_for(&[0xAB; 32]), Some("ticket-one")); - assert_eq!(loaded.dial_hints(), vec!["ticket-one".to_string()]); - } - - #[test] - fn a_hint_is_only_written_when_it_changes() { - // The caller pays a settings write only on change, so an unchanged - // refresh must report false. - let mut sync = KnotSyncSettings::default(); - sync.pair([0x01; 32]); - - assert!(sync.remember_endpoint([0x01; 32], "first")); - assert!(!sync.remember_endpoint([0x01; 32], "first"), "unchanged"); - assert!(sync.remember_endpoint([0x01; 32], "second"), "changed"); - assert_eq!(sync.endpoint_for(&[0x01; 32]), Some("second")); - } - - #[test] - fn a_route_never_creates_an_admission() { - // A hint for a device that was never paired is ignored: routes do not - // grant write access. - let mut sync = KnotSyncSettings::default(); - assert!(!sync.remember_endpoint([0x09; 32], "uninvited")); - assert!(sync.paired_writers.is_empty()); - assert_eq!(sync.endpoint_for(&[0x09; 32]), None); - } - - #[test] - fn re_pairing_keeps_a_learned_route_and_unpairing_drops_it() { - let mut sync = KnotSyncSettings::default(); - sync.pair([0x02; 32]); - sync.remember_endpoint([0x02; 32], "learned"); - - assert!(!sync.pair([0x02; 32]), "already paired"); - assert_eq!( - sync.endpoint_for(&[0x02; 32]), - Some("learned"), - "re-pairing must not discard the route" - ); - - assert!(sync.unpair([0x02; 32])); - assert_eq!(sync.endpoint_for(&[0x02; 32]), None); - assert!(sync.dial_hints().is_empty()); - } - - #[test] - fn hints_are_absent_until_a_device_has_connected() { - let mut sync = KnotSyncSettings::default(); - sync.pair([0x03; 32]); - assert!( - sync.dial_hints().is_empty(), - "a freshly paired device has no route yet" - ); - } -} diff --git a/ports/knot/src/startup.rs b/ports/knot/src/startup.rs deleted file mode 100644 index 6e893a9c8..000000000 --- a/ports/knot/src/startup.rs +++ /dev/null @@ -1,349 +0,0 @@ -// Copyright 2026 Mark Alan Boykin -// 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 https://mozilla.org/MPL/2.0/. -// SPDX-License-Identifier: MPL-2.0 - -//! Startup-unlocked personal Knot authority. -//! -//! This is the production seam between pandect's Personae wallet and -//! Knot's sealed, signed document store. Callers name a data root and persona; -//! recovered epoch bytes and every derived key stay inside Knot. - -use std::fs; -use std::path::{Path, PathBuf}; -use std::sync::Arc; - -use p2panda_core::SigningKey; -use pandect::wallet_store; -use personae::{Ed25519Keypair, PersonaId}; -use zeroize::{Zeroize, Zeroizing}; - -use crate::{ - KnotEndpoint, KnotPublishSource, KnotResidentSource, KnotSyncEvent, KnotSyncFileStore, - KnotVault, KnotWriteGrant, VaultDocument, -}; - -const VAULT_KEY_CONTEXT: &str = "mere.knot.persona-vault.root.v1"; -const SIGNING_KEY_CONTEXT: &str = "mere.knot.persona-vault.writer.v1"; -const SPACE_ID_CONTEXT: &str = "mere.knot.persona-vault.space.v1"; -const KNOT_VAULT_DIR: &str = "vault"; -const KNOT_SYNC_FILE: &str = "knot/sync.redb"; - -struct StartupPersonalKeys { - vault_key: Zeroizing<[u8; 32]>, - signing_seed: Zeroizing<[u8; 32]>, - legacy_writer: [u8; 32], -} - -/// Unlocked authority held only long enough to seed or launch one endpoint. -pub struct StartupUnlockedPersonalVault { - vault: KnotVault, - store: KnotSyncFileStore, - signing_seed: Zeroizing<[u8; 32]>, -} - -impl StartupUnlockedPersonalVault { - /// Recover the current private epoch through the configured startup-unlock - /// policy and open this persona's sealed vault plus signed operation store. - /// - /// `device_root` is this machine's Personae master public key. It is what - /// makes the writer identity device-distinct, and carrying the persona - /// epoch to a second device is why that matters: the vault key and space - /// must be identical across devices so both can decrypt the same space, - /// but the writer must not be, because its public half is also the node - /// identity. Two devices deriving one writer would be one node on the - /// network and one author in a per-author log, and neither works. - /// - /// `admitted` carries the other devices' writer keys, which is how a - /// second device's operations pass admission. - pub fn open( - data_root: impl AsRef, - persona: PersonaId, - device_root: [u8; 32], - admitted: impl IntoIterator, - ) -> Result { - let data_root = data_root.as_ref(); - let keys = unlock_personal_keys(data_root, persona, device_root)?; - - let vault_root = persona_vault_root(data_root, persona); - fs::create_dir_all(vault_root.join("knot")) - .map_err(|error| format!("could not create Knot persona vault: {error}"))?; - let vault = KnotVault::open(&vault_root, *keys.vault_key)?; - let space_id = blake3::derive_key(SPACE_ID_CONTEXT, persona.as_uuid().as_bytes()); - let writer = *SigningKey::from_bytes(&keys.signing_seed) - .verifying_key() - .as_bytes(); - let mut writers = vec![writer, keys.legacy_writer]; - writers.extend(admitted); - writers.sort_unstable(); - writers.dedup(); - let store = KnotSyncFileStore::open(vault_root.join(KNOT_SYNC_FILE), space_id, writers) - .map_err(|error| { - format!( - "could not open Knot persona sync store; another resident may already own this persona: {error}" - ) - })?; - - let authority = Self { - vault, - store, - signing_seed: keys.signing_seed, - }; - authority.migrate_unsynced_vault()?; - Ok(authority) - } - - /// Author one seed/import document through the same signed event path Save - /// uses. This is an endpoint-owned setup seam, not a cleartext file write. - pub fn author_document(&self, document: VaultDocument) -> Result<(), String> { - pollster::block_on(self.store.author( - *self.signing_seed, - &self.vault, - &KnotSyncEvent::Put(document), - )) - .map(|_| ()) - .map_err(|error| format!("could not author Knot persona document: {error}")) - } - - /// This device's writer key, and so also its transport node id: what the - /// other devices must admit before its operations will fold. - pub fn writer(&self) -> [u8; 32] { - *SigningKey::from_bytes(&self.signing_seed) - .verifying_key() - .as_bytes() - } - - /// The signed operation store, for binding a transport to it. - pub fn store(&self) -> &KnotSyncFileStore { - &self.store - } - - /// The seed this device signs and binds its transport with. - pub fn signing_seed(&self) -> [u8; 32] { - *self.signing_seed - } - - /// Consume the recovered authority into a writable Graphshell endpoint. - pub fn into_endpoint(self, grant: KnotWriteGrant) -> Result { - Ok(self.into_resident_source()?.session(Some(grant))) - } - - /// Consume the startup unlock into one cloneable resident source. - pub fn into_resident_source(self) -> Result { - KnotResidentSource::from_synced_vault(self.vault, self.store, *self.signing_seed) - } - - /// Split one startup unlock between the mutable Graphshell editor endpoint - /// and the independently retained read-only publishing host. Both handles - /// retain the same synced source key, but only the endpoint receives the - /// mutable vault handle and write grant. - pub fn into_endpoint_and_publish_source( - self, - grant: KnotWriteGrant, - ) -> Result<(KnotEndpoint, KnotPublishSource), String> { - let (source, publish) = self.into_resident_source_and_publish_source()?; - Ok((source.session(Some(grant)), publish)) - } - - /// Split one startup unlock between a cloneable authoring source and the - /// independently retained read-only publishing source. - pub fn into_resident_source_and_publish_source( - self, - ) -> Result<(KnotResidentSource, KnotPublishSource), String> { - let publish_vault = Arc::new(self.vault.fork_read_handle()?); - let publish_store = self.store.clone(); - let publish_identity = Ed25519Keypair::from_seed(*self.signing_seed); - let source = - KnotResidentSource::from_synced_vault(self.vault, self.store, *self.signing_seed)?; - Ok(( - source, - KnotPublishSource::from_unlocked(publish_identity, publish_store, publish_vault), - )) - } - - fn migrate_unsynced_vault(&self) -> Result<(), String> { - let projection = pollster::block_on(self.store.projection(&self.vault)) - .map_err(|error| format!("could not inspect Knot persona sync store: {error}"))?; - if !projection.documents.is_empty() - || !projection.conflicts.is_empty() - || !projection.pending.is_empty() - { - return Ok(()); - } - let documents = self.vault.documents().cloned().collect::>(); - for document in documents { - self.author_document(document)?; - } - Ok(()) - } -} - -/// Derive this device's Knot writer identity without opening the Knot vault or -/// signed-operation store. -/// -/// Pairing tools may run while the resident owns those files. Personae still -/// performs the configured startup unlock because the writer is derived from -/// the current persona epoch, but the management read cannot become a second -/// Knot store owner. -pub fn personal_vault_writer( - data_root: impl AsRef, - persona: PersonaId, - device_root: [u8; 32], -) -> Result<[u8; 32], String> { - let keys = unlock_personal_keys(data_root.as_ref(), persona, device_root)?; - Ok(*SigningKey::from_bytes(&keys.signing_seed) - .verifying_key() - .as_bytes()) -} - -fn unlock_personal_keys( - data_root: &Path, - persona: PersonaId, - device_root: [u8; 32], -) -> Result { - let mut epoch = wallet_store::load_current_private_epoch(data_root, persona) - .map_err(|error| format!("could not load Knot persona epoch: {error}"))? - .ok_or_else(|| { - "Knot persona vault is locked or has no current private epoch".to_string() - })?; - let vault_key = Zeroizing::new(blake3::derive_key(VAULT_KEY_CONTEXT, &epoch.epoch_secret)); - // The pre-device-scoped writer. Admitted, never authored with, so - // operations written before this derivation existed still fold rather - // than becoming an unreadable log signed by nobody admitted. - let legacy_writer = *SigningKey::from_bytes(&blake3::derive_key( - SIGNING_KEY_CONTEXT, - &epoch.epoch_secret, - )) - .verifying_key() - .as_bytes(); - let mut material = Zeroizing::new(Vec::with_capacity(64)); - material.extend_from_slice(&epoch.epoch_secret); - material.extend_from_slice(&device_root); - let signing_seed = Zeroizing::new(blake3::derive_key(SIGNING_KEY_CONTEXT, &material)); - epoch.epoch_secret.zeroize(); - Ok(StartupPersonalKeys { - vault_key, - signing_seed, - legacy_writer, - }) -} - -/// This machine's public device key, minted once and reused thereafter. -/// -/// The device component of the writer derivation. It is public on purpose: it -/// only has to be *distinct* per device, since the secrecy of the writer comes -/// from the persona epoch it is mixed with. Reusing pandect's local -/// device identity rather than minting a Knot-private one keeps a device one -/// device across the whole system. -pub fn local_device_root(data_root: &Path, label: &str) -> Result<[u8; 32], String> { - let identity = wallet_store::ensure_local_device_identity(data_root, label) - .map_err(|error| format!("could not open this device's identity: {error}"))?; - Ok(*SigningKey::from_bytes(&identity.device_seed) - .verifying_key() - .as_bytes()) -} - -pub fn persona_vault_root(data_root: &Path, persona: PersonaId) -> PathBuf { - data_root - .join(pandect::PERSONAS_DIR) - .join(persona.as_uuid().to_string()) - .join(KNOT_VAULT_DIR) -} - -#[cfg(all(test, windows))] -mod tests { - use graphshell_endpoint::{ProjectionCatalog, ProjectionSource}; - use pandect::{DeviceSettings, save_device_settings}; - use tempfile::tempdir; - - use super::*; - - #[test] - fn auto_os_unlock_opens_signed_sealed_persona_truth() { - let root = tempdir().unwrap(); - let persona = PersonaId::new(); - let settings = DeviceSettings { - startup_unlock_mode: personae::StartupUnlockMode::AutoOs, - ..Default::default() - }; - save_device_settings(root.path(), &settings).unwrap(); - wallet_store::ensure_wallet_state(root.path(), persona, "Knot receipt").unwrap(); - - let authority = StartupUnlockedPersonalVault::open( - root.path(), - persona, - local_device_root(root.path(), "knot receipt").unwrap(), - [], - ) - .unwrap(); - let duplicate = match StartupUnlockedPersonalVault::open( - root.path(), - persona, - local_device_root(root.path(), "knot receipt").unwrap(), - [], - ) { - Ok(_) => panic!("a second persona owner must be refused promptly"), - Err(error) => error, - }; - assert!(duplicate.contains("another resident may already own this persona")); - assert_eq!( - personal_vault_writer( - root.path(), - persona, - local_device_root(root.path(), "knot receipt").unwrap(), - ) - .unwrap(), - authority.writer(), - "pairing facts derive beside the resident without reopening its stores", - ); - authority - .author_document(VaultDocument { - id: "field-note".into(), - title: "Field note".into(), - body: b"# Private\n".to_vec(), - media_type: "text/vnd.knot".into(), - }) - .unwrap(); - let mut endpoint = authority.into_endpoint(KnotWriteGrant::new(4096)).unwrap(); - let request = endpoint.describe().projections.remove(0).request; - let snapshot = endpoint.snapshot(request).unwrap(); - assert!(!snapshot.scene.tables.items.is_empty()); - - drop(endpoint); - let reopened = StartupUnlockedPersonalVault::open( - root.path(), - persona, - local_device_root(root.path(), "knot receipt").unwrap(), - [], - ) - .unwrap() - .into_endpoint(KnotWriteGrant::new(4096)) - .unwrap(); - drop(reopened); - - let clear = b"# Private\n"; - let mut found_cleartext = false; - for entry in walk_files(root.path()) { - let bytes = fs::read(entry).unwrap(); - found_cleartext |= bytes.windows(clear.len()).any(|window| window == clear); - } - assert!(!found_cleartext, "persona truth must remain opaque at rest"); - } - - fn walk_files(root: &Path) -> Vec { - let mut pending = vec![root.to_path_buf()]; - let mut files = Vec::new(); - while let Some(path) = pending.pop() { - for entry in fs::read_dir(path).unwrap() { - let path = entry.unwrap().path(); - if path.is_dir() { - pending.push(path); - } else { - files.push(path); - } - } - } - files - } -} diff --git a/ports/knot/src/sync.rs b/ports/knot/src/sync.rs deleted file mode 100644 index 3c0840cd4..000000000 --- a/ports/knot/src/sync.rs +++ /dev/null @@ -1,2227 +0,0 @@ -// Copyright 2026 Mark Alan Boykin -// 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 https://mozilla.org/MPL/2.0/. -// SPDX-License-Identifier: MPL-2.0 - -//! Causal personal and Commons document replication over Stickleback. - -use std::collections::{BTreeMap, BTreeSet}; -use std::path::Path; -use std::sync::{Arc, RwLock}; - -use muniment::{Backend, MemoryBackend, RedbBackend, StoreError, WriteOp}; -use p2panda_core::cbor::{decode_cbor, encode_cbor}; -use p2panda_core::{Body, Hash, Header, Operation, SigningKey, Topic, VerifyingKey}; -use p2panda_net::{Endpoint, Gossip}; -use p2panda_store::logs::LogStore; -use p2panda_store::topics::TopicStore; -use proofs::Digest; -use serde::{Deserialize, Serialize}; -use stickleback::CausalIndex; -use stickleback::{ - Admission, CausalEntry, CausalError, CausalLimits, DataKeyring, EpochCheckpointBasis, - EpochHold, EpochHoldReason, EpochPruningProposal, EpochRetentionFacts, GroupCiphertext, - GroupCryptoError, GroupEncryptionProfile, GroupSecretId, JoinError, JoinedSpace, MunimentStore, - OperationPolicy, OperationProcessor, PendingCausalOperation, ProcessError, Reject, StoreTarget, - author_head, causal_projection, observed_frontier, propose_epoch_pruning, - validate_causal_metadata, -}; -use zeroize::{Zeroize, Zeroizing}; - -use crate::{ - KnotVault, VaultDocument, - djot_merge::{automatic_text_merge, automatic_text_merge_head}, -}; - -const LOG_ID: u64 = 0; -const SYNC_AAD: &[u8] = b"mere.knot.sync-operation.v1"; -const KNOT_CAUSAL_LIMITS: CausalLimits = CausalLimits { - max_parents: 64, - max_payload_bytes: 16 * 1024 * 1024, -}; - -/// Communal Knot uses the same retained-data floor as Commons chat. -pub const KNOT_COMMONS_ENCRYPTION_PROFILE: GroupEncryptionProfile = - GroupEncryptionProfile::durable_data(8); - -/// The signed encryption contract for one Knot space. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -pub enum KnotEncryptionProfile { - /// Personal device sync derives a key from the local vault root. - #[default] - PersonalVaultV1, - /// Commons documents use the group's retained data-encryption epochs. - CommonsDataV1, -} - -/// Signed addressing extension for one Knot vault space. -#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -pub struct KnotSyncExt { - pub space_id: [u8; 32], - #[serde(default)] - pub encryption: KnotEncryptionProfile, - /// Exact per-author frontier observed before this event was authored. - #[serde(default)] - pub parents: Vec<[u8; 32]>, -} - -/// Plaintext event sealed inside the p2panda operation body. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Zeroize)] -pub enum KnotSyncEvent { - Put(VaultDocument), - Delete { - id: String, - }, - /// Replace the named causal document versions with one chosen value. - Resolve { - id: String, - supersedes: Vec<[u8; 32]>, - document: Option, - }, -} - -/// Encryption material used by a Knot replica. -#[derive(Clone, Copy)] -pub enum KnotSyncCipher<'a> { - Personal(&'a KnotVault), - CommonsData(&'a DataKeyring), -} - -impl KnotSyncCipher<'_> { - fn profile(self) -> KnotEncryptionProfile { - match self { - Self::Personal(_) => KnotEncryptionProfile::PersonalVaultV1, - Self::CommonsData(_) => KnotEncryptionProfile::CommonsDataV1, - } - } -} - -/// Knot sync failures. -#[derive(Debug, thiserror::Error)] -pub enum KnotSyncError { - #[error(transparent)] - Store(#[from] StoreError), - #[error(transparent)] - Process(#[from] ProcessError), - #[error(transparent)] - Causal(#[from] CausalError), - #[error(transparent)] - GroupCrypto(#[from] GroupCryptoError), - #[error("sync payload: {0}")] - Payload(String), - #[error("sync cipher does not match the space's signed encryption profile")] - WrongEncryptionProfile, - #[error("invalid conflict resolution: {0}")] - InvalidResolution(String), - #[error("Knot sync has no durable projection checkpoint")] - MissingCheckpoint, - #[error("reviewed Knot epoch proposal is stale")] - StaleRetentionProposal, - #[error("Knot epoch proposal is blocked")] - BlockedRetentionProposal, - #[error("document {0} has operations from more than one writer")] - ConcurrentWriter(String), -} - -#[derive(Clone)] -struct KnotSyncPolicy { - space_id: [u8; 32], - writers: Arc>>, - encryption: KnotEncryptionProfile, -} - -impl OperationPolicy for KnotSyncPolicy { - type LogId = u64; - - fn admit(&self, operation: &Operation) -> Result, Reject> { - if operation.header.extensions.space_id != self.space_id { - return Err(Reject::new( - "wrong-knot-space", - "operation addresses a different Knot vault", - )); - } - if operation.header.extensions.encryption != self.encryption { - return Err(Reject::new( - "wrong-knot-encryption-profile", - "operation uses a different Knot encryption profile", - )); - } - if !self - .writers - .read() - .is_ok_and(|writers| writers.contains(operation.header.verifying_key.as_bytes())) - { - return Err(Reject::new( - "unrecognized-knot-writer", - "operation author is not admitted to this Knot vault", - )); - } - let body = operation.body.as_ref().ok_or_else(|| { - Reject::new( - "missing-knot-event", - "Knot sync operations require a sealed body", - ) - })?; - if self.encryption == KnotEncryptionProfile::CommonsDataV1 { - decode_cbor::(body.to_bytes().as_slice()).map_err(|error| { - Reject::new( - "invalid-knot-group-ciphertext", - format!("Commons Knot body is not a data-envelope: {error}"), - ) - })?; - } - validate_causal_metadata( - operation, - &operation.header.extensions.parents, - KNOT_CAUSAL_LIMITS, - ) - .map_err(|error| Reject::new("invalid-knot-causality", error.to_string()))?; - Ok(Admission::keep(StoreTarget::new( - Topic::from(self.space_id), - LOG_ID, - ))) - } -} - -/// One writer's current contribution to a conflicted document id. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct KnotDocumentVersion { - pub writer: [u8; 32], - pub operation: [u8; 32], - /// `None` is that writer's current deletion. - pub document: Option, -} - -/// A document id touched by more than one writer. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct KnotDocumentConflict { - pub id: String, - pub versions: Vec, -} - -/// A clean three-way merge derived from concurrent Knot text versions. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct KnotAutomaticTextMerge { - pub id: String, - pub base: [u8; 32], - pub supersedes: Vec<[u8; 32]>, - pub document: VaultDocument, -} - -/// Knot's current causally closed document view. -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct KnotDocumentProjection { - pub documents: Vec, - pub conflicts: Vec, - pub automatic_merges: Vec, - pub pending: Vec, - /// Exact current operation for every causally resolved document id. - /// - /// Consumers use this as an opaque optimistic-concurrency head rather - /// than reducing a replicated document version to its plaintext digest. - pub document_heads: BTreeMap, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct KnotAuthorHead { - pub author: [u8; 32], - pub log_id: u64, - pub seq_num: u32, - pub operation: [u8; 32], -} - -/// Knot-native materialized base retained by a projection checkpoint. -#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -pub struct KnotCheckpointSnapshot { - pub documents: Vec, - pub conflicts: Vec, - #[serde(default)] - pub automatic_merges: Vec, - pub document_heads: BTreeMap, -} - -/// Durable projection boundary required before domain-authorized pruning. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct KnotProjectionCheckpoint { - pub version: u16, - pub space_id: [u8; 32], - pub heads: Vec, - pub document_digests: Vec<(String, [u8; 32])>, - pub conflict_ids: Vec, - pub pending: Vec<([u8; 32], Vec<[u8; 32]>)>, - /// Added after the original digest-only checkpoint. Legacy checkpoints - /// remain readable but cannot authorize epoch pruning. - #[serde(default)] - pub snapshot: Option, -} - -/// Exact retained tail after a durable checkpoint. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct KnotTailReceipt { - pub checkpoint: [u8; 32], - pub operations: Vec<[u8; 32]>, -} - -/// Recovery promise supplied by communal Knot's offline-member policy. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct KnotOfflineMemberEpochHold { - pub member: [u8; 32], - pub epoch: GroupSecretId, -} - -/// Atomic host receipt for explicit communal Knot epoch erasure. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct KnotEpochExecutionReceipt { - pub version: u16, - pub space_id: [u8; 32], - pub checkpoint: Digest, - pub authority_revision: Digest, - pub forgotten: Vec, - pub retained: Vec, - pub previous_keyring: Digest, - pub persisted_keyring: Digest, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum KnotOfflineMemberRecovery { - Resume, - BootstrapRequired { checkpoint: Option }, -} - -#[derive(Clone)] -struct StoredKnotOperation { - operation: Operation, - log_id: u64, -} - -/// Replicated encrypted event store for one personal Knot vault. -#[derive(Clone)] -pub struct KnotSyncStore { - store: MunimentStore, - policy: KnotSyncPolicy, -} - -pub type KnotSyncFileStore = KnotSyncStore; - -impl KnotSyncStore { - pub fn in_memory(space_id: [u8; 32], writers: impl IntoIterator) -> Self { - Self::in_memory_with_profile(space_id, writers, KnotEncryptionProfile::PersonalVaultV1) - } - - pub fn in_memory_commons( - space_id: [u8; 32], - writers: impl IntoIterator, - ) -> Self { - Self::in_memory_with_profile(space_id, writers, KnotEncryptionProfile::CommonsDataV1) - } - - pub fn in_memory_with_profile( - space_id: [u8; 32], - writers: impl IntoIterator, - encryption: KnotEncryptionProfile, - ) -> Self { - Self { - store: MunimentStore::new(MemoryBackend::new()), - policy: KnotSyncPolicy { - space_id, - writers: Arc::new(RwLock::new(writers.into_iter().collect())), - encryption, - }, - } - } -} - -impl KnotSyncStore { - pub fn open( - path: impl AsRef, - space_id: [u8; 32], - writers: impl IntoIterator, - ) -> Result { - Self::open_with_profile( - path, - space_id, - writers, - KnotEncryptionProfile::PersonalVaultV1, - ) - } - - pub fn open_commons( - path: impl AsRef, - space_id: [u8; 32], - writers: impl IntoIterator, - ) -> Result { - Self::open_with_profile( - path, - space_id, - writers, - KnotEncryptionProfile::CommonsDataV1, - ) - } - - pub fn open_with_profile( - path: impl AsRef, - space_id: [u8; 32], - writers: impl IntoIterator, - encryption: KnotEncryptionProfile, - ) -> Result { - Ok(Self { - store: MunimentStore::new(RedbBackend::open(path)?), - policy: KnotSyncPolicy { - space_id, - writers: Arc::new(RwLock::new(writers.into_iter().collect())), - encryption, - }, - }) - } -} - -impl KnotSyncStore -where - B: Backend + Clone, -{ - pub fn space_id(&self) -> [u8; 32] { - self.policy.space_id - } - - pub fn encryption_profile(&self) -> KnotEncryptionProfile { - self.policy.encryption - } - - /// Writers currently admitted by this replica's materialized policy. - pub fn admitted_writers(&self) -> Vec<[u8; 32]> { - self.policy - .writers - .read() - .map(|writers| writers.iter().copied().collect()) - .unwrap_or_default() - } - - /// Admit one newly paired writer without rebuilding the active LogSync - /// session. Returns whether the materialized policy changed. - pub fn admit_writer(&self, writer: [u8; 32]) -> bool { - self.policy - .writers - .write() - .map(|mut writers| writers.insert(writer)) - .unwrap_or(false) - } - - /// Revoke one paired writer for future operation admission. - pub fn deny_writer(&self, writer: &[u8; 32]) -> bool { - self.policy - .writers - .write() - .map(|mut writers| writers.remove(writer)) - .unwrap_or(false) - } - - /// Replace communal writer admission from a newly materialized Gemot view. - pub fn replace_admitted_writers(&self, writers: impl IntoIterator) -> bool { - let next = writers.into_iter().collect::>(); - self.policy - .writers - .write() - .map(|mut current| { - if *current == next { - return false; - } - *current = next; - true - }) - .unwrap_or(false) - } - - /// Seal, sign, admit, and store the next event in this device's log. - pub async fn author( - &self, - signing_seed: [u8; 32], - vault: &KnotVault, - event: &KnotSyncEvent, - ) -> Result, KnotSyncError> { - self.author_with_cipher(signing_seed, KnotSyncCipher::Personal(vault), event) - .await - } - - pub async fn author_communal( - &self, - signing_seed: [u8; 32], - keys: &DataKeyring, - event: &KnotSyncEvent, - ) -> Result, KnotSyncError> { - self.author_with_cipher(signing_seed, KnotSyncCipher::CommonsData(keys), event) - .await - } - - pub async fn author_with_cipher( - &self, - signing_seed: [u8; 32], - cipher: KnotSyncCipher<'_>, - event: &KnotSyncEvent, - ) -> Result, KnotSyncError> { - self.require_cipher(cipher)?; - let signing_key = SigningKey::from_bytes(&signing_seed); - let author = signing_key.verifying_key(); - let records = self.load_operations().await?; - let entries = causal_entries(&records); - let parents = observed_frontier(&entries)?; - let (seq_num, backlink) = author_head(&entries, *author.as_bytes(), &LOG_ID)?; - let plaintext = Zeroizing::new( - serde_json::to_vec(event).map_err(|error| KnotSyncError::Payload(error.to_string()))?, - ); - let aad = operation_aad(self.policy.space_id, author.as_bytes(), seq_num); - let ciphertext = seal_event(cipher, &aad, plaintext.as_slice())?; - let body = Body::from_bytes(&ciphertext); - // p2panda 0.7.1 made the header's CBOR cache, size and digest private - // and folded signing into the builder: `build` encodes, signs and - // caches the digest in one step, so the struct-literal + `sign` pair - // has no equivalent. The builder derives verifying_key from the - // signing key -- the same value `author` already held. - let header = Header::builder() - .body(&ciphertext) - .seq_num(seq_num) - .backlink(backlink.map(Hash::from)) - .build( - &signing_key, - KnotSyncExt { - space_id: self.policy.space_id, - encryption: self.policy.encryption, - parents, - }, - ); - let operation = Operation { - hash: header.hash(), - header, - body: Some(body), - }; - self.accept(&operation).await?; - Ok(operation) - } - - pub async fn resolve_conflict( - &self, - signing_seed: [u8; 32], - vault: &KnotVault, - conflict: &KnotDocumentConflict, - document: Option, - ) -> Result, KnotSyncError> { - self.resolve_conflict_with_cipher( - signing_seed, - KnotSyncCipher::Personal(vault), - conflict, - document, - ) - .await - } - - pub async fn resolve_communal_conflict( - &self, - signing_seed: [u8; 32], - keys: &DataKeyring, - conflict: &KnotDocumentConflict, - document: Option, - ) -> Result, KnotSyncError> { - self.resolve_conflict_with_cipher( - signing_seed, - KnotSyncCipher::CommonsData(keys), - conflict, - document, - ) - .await - } - - pub async fn resolve_conflict_with_cipher( - &self, - signing_seed: [u8; 32], - cipher: KnotSyncCipher<'_>, - conflict: &KnotDocumentConflict, - document: Option, - ) -> Result, KnotSyncError> { - if document - .as_ref() - .is_some_and(|document| document.id != conflict.id) - { - return Err(KnotSyncError::InvalidResolution( - "chosen document id does not match the conflict".into(), - )); - } - let mut supersedes: Vec<_> = conflict - .versions - .iter() - .map(|version| version.operation) - .collect(); - supersedes.sort(); - supersedes.dedup(); - self.author_with_cipher( - signing_seed, - cipher, - &KnotSyncEvent::Resolve { - id: conflict.id.clone(), - supersedes, - document, - }, - ) - .await - } - - fn require_cipher(&self, cipher: KnotSyncCipher<'_>) -> Result<(), KnotSyncError> { - if cipher.profile() != self.policy.encryption { - return Err(KnotSyncError::WrongEncryptionProfile); - } - Ok(()) - } - - /// The Knot-specific `accept` closure target used by Stickleback. - pub async fn accept(&self, operation: &Operation) -> Result { - let processor = OperationProcessor::new(self.store.clone(), self.policy.clone()); - Ok(processor.process(operation).await?.inserted()) - } - - async fn load_operations(&self) -> Result, KnotSyncError> { - let logs: BTreeMap> = self - .store - .resolve(&Topic::from(self.policy.space_id)) - .await?; - let mut records = Vec::new(); - for (author, mut log_ids) in logs { - log_ids.sort_unstable(); - log_ids.dedup(); - for log_id in log_ids { - let Some(entries) = self - .store - .get_log_entries(&author, &log_id, None, None) - .await? - else { - continue; - }; - for (operation, _) in entries { - records.push(StoredKnotOperation { operation, log_id }); - } - } - } - Ok(records) - } - - /// Fold the causally closed subset into documents while preserving - /// document conflicts and missing-history diagnostics. - pub async fn projection( - &self, - vault: &KnotVault, - ) -> Result { - self.projection_with_cipher(KnotSyncCipher::Personal(vault)) - .await - } - - pub async fn communal_projection( - &self, - keys: &DataKeyring, - ) -> Result { - self.projection_with_cipher(KnotSyncCipher::CommonsData(keys)) - .await - } - - pub async fn projection_with_cipher( - &self, - cipher: KnotSyncCipher<'_>, - ) -> Result { - self.require_cipher(cipher)?; - let records = self.load_operations().await?; - let entries = causal_entries(&records); - let projection = causal_projection(&entries)?; - // Indexed once for the whole fold: the retain below asks a reachability - // question per surviving version per operation, and rebuilding the hash - // index inside each of those made a save cost O(n^2 log n) in history. - let causal = CausalIndex::new(&entries); - let mut current = BTreeMap::>::new(); - let mut event_documents = BTreeMap::<[u8; 32], String>::new(); - let mut version_history = Vec::<([u8; 32], String, Option)>::new(); - - for index in projection.order { - let operation = &records[index].operation; - let writer = *operation.header.verifying_key.as_bytes(); - let operation_id = *operation.hash.as_bytes(); - let event = decode_event(cipher, operation)?; - let (id, document, replaces_observed) = match event { - KnotSyncEvent::Put(document) => (document.id.clone(), Some(document), true), - KnotSyncEvent::Delete { id } => (id, None, true), - KnotSyncEvent::Resolve { - id, - supersedes, - document, - } => { - let targets = validate_resolution( - &causal, - &event_documents, - operation_id, - &id, - &supersedes, - document.as_ref(), - )?; - if let Some(versions) = current.get_mut(&id) { - versions.retain(|_, version| !targets.contains(&version.operation)); - } - (id, document, false) - } - }; - if replaces_observed && let Some(versions) = current.get_mut(&id) { - versions - .retain(|_, version| !causal.happens_before(version.operation, operation_id)); - } - event_documents.insert(operation_id, id.clone()); - version_history.push((operation_id, id.clone(), document.clone())); - current.entry(id).or_default().insert( - writer, - KnotDocumentVersion { - writer, - operation: operation_id, - document, - }, - ); - } - - let mut documents = Vec::new(); - let mut conflicts = Vec::new(); - let mut automatic_merges = Vec::new(); - let mut document_heads = BTreeMap::new(); - for (id, versions) in current { - if versions.len() == 1 { - let version = versions.into_values().next().unwrap(); - document_heads.insert(id, version.operation); - if let Some(document) = version.document { - documents.push(document); - } - } else if let Some(automatic) = - automatic_text_merge(&causal, &version_history, &id, &versions) - { - document_heads.insert( - id, - automatic_text_merge_head( - automatic.base, - &automatic.supersedes, - &automatic.document, - )?, - ); - documents.push(automatic.document.clone()); - automatic_merges.push(automatic); - } else { - conflicts.push(KnotDocumentConflict { - id, - versions: versions.into_values().collect(), - }); - } - } - Ok(KnotDocumentProjection { - documents, - conflicts, - automatic_merges, - pending: projection.pending, - document_heads, - }) - } - - /// Materialize one exact retained document-producing operation. - /// - /// The operation must be in the causally closed projection and must name - /// `document_id` itself. Deletes, resolutions to no document, operations - /// for another document, and pending history all return `None`; callers do - /// not need to infer history from a current endpoint projection. - pub async fn document_version( - &self, - vault: &KnotVault, - document_id: &str, - operation_id: [u8; 32], - ) -> Result, KnotSyncError> { - self.document_version_with_cipher( - KnotSyncCipher::Personal(vault), - document_id, - operation_id, - ) - .await - } - - /// Cipher-generic form of [`Self::document_version`]. - pub async fn document_version_with_cipher( - &self, - cipher: KnotSyncCipher<'_>, - document_id: &str, - operation_id: [u8; 32], - ) -> Result, KnotSyncError> { - self.require_cipher(cipher)?; - let records = self.load_operations().await?; - let entries = causal_entries(&records); - let projection = causal_projection(&entries)?; - - for index in projection.order { - let operation = &records[index].operation; - if *operation.hash.as_bytes() != operation_id { - continue; - } - let event = decode_event(cipher, operation)?; - return Ok(match event { - KnotSyncEvent::Put(document) if document.id == document_id => Some(document), - KnotSyncEvent::Resolve { - id, - document: Some(document), - .. - } if id == document_id && document.id == id => Some(document), - _ => None, - }); - } - Ok(None) - } - - /// Compatibility view for existing callers. New consumers should use - /// [`Self::projection`] so unrelated documents remain available beside an - /// explicit conflict. - pub async fn documents(&self, vault: &KnotVault) -> Result, KnotSyncError> { - self.documents_with_cipher(KnotSyncCipher::Personal(vault)) - .await - } - - pub async fn communal_documents( - &self, - keys: &DataKeyring, - ) -> Result, KnotSyncError> { - self.documents_with_cipher(KnotSyncCipher::CommonsData(keys)) - .await - } - - pub async fn documents_with_cipher( - &self, - cipher: KnotSyncCipher<'_>, - ) -> Result, KnotSyncError> { - let projection = self.projection_with_cipher(cipher).await?; - if let Some(conflict) = projection.conflicts.first() { - return Err(KnotSyncError::ConcurrentWriter(conflict.id.clone())); - } - Ok(projection.documents) - } - - /// Persist the current projection frontier. This is a prerequisite receipt, - /// not permission to prune. - pub async fn save_checkpoint( - &self, - vault: &KnotVault, - ) -> Result { - self.save_checkpoint_with_cipher(KnotSyncCipher::Personal(vault)) - .await - } - - pub async fn save_communal_checkpoint( - &self, - keys: &DataKeyring, - ) -> Result { - self.save_checkpoint_with_cipher(KnotSyncCipher::CommonsData(keys)) - .await - } - - pub async fn save_checkpoint_with_cipher( - &self, - cipher: KnotSyncCipher<'_>, - ) -> Result { - let checkpoint = self.build_checkpoint_with_cipher(cipher).await?; - let bytes = serde_json::to_vec(&checkpoint) - .map_err(|error| KnotSyncError::Payload(error.to_string()))?; - self.store - .backend() - .put(&checkpoint_key(self.policy.space_id), &bytes) - .await?; - Ok(checkpoint) - } - - async fn build_checkpoint_with_cipher( - &self, - cipher: KnotSyncCipher<'_>, - ) -> Result { - let projection = self.projection_with_cipher(cipher).await?; - let records = self.load_operations().await?; - let mut heads = BTreeMap::<([u8; 32], u64), KnotAuthorHead>::new(); - for record in records { - let operation = &record.operation; - let key = (*operation.header.verifying_key.as_bytes(), record.log_id); - let candidate = KnotAuthorHead { - author: key.0, - log_id: key.1, - seq_num: operation.header.seq_num, - operation: *operation.hash.as_bytes(), - }; - if heads - .get(&key) - .is_none_or(|current| candidate.seq_num > current.seq_num) - { - heads.insert(key, candidate); - } - } - let mut document_digests = Vec::new(); - for document in &projection.documents { - let bytes = serde_json::to_vec(document) - .map_err(|error| KnotSyncError::Payload(error.to_string()))?; - document_digests.push((document.id.clone(), *blake3::hash(&bytes).as_bytes())); - } - let snapshot = KnotCheckpointSnapshot { - documents: projection.documents.clone(), - conflicts: projection.conflicts.clone(), - automatic_merges: projection.automatic_merges.clone(), - document_heads: projection.document_heads.clone(), - }; - Ok(KnotProjectionCheckpoint { - version: 1, - space_id: self.policy.space_id, - heads: heads.into_values().collect(), - document_digests, - conflict_ids: projection - .conflicts - .into_iter() - .map(|conflict| conflict.id) - .collect(), - pending: projection - .pending - .into_iter() - .map(|pending| (pending.operation, pending.missing)) - .collect(), - snapshot: Some(snapshot), - }) - } - - pub async fn load_checkpoint(&self) -> Result, KnotSyncError> { - let Some(bytes) = self - .store - .backend() - .get(&checkpoint_key(self.policy.space_id)) - .await? - else { - return Ok(None); - }; - let checkpoint: KnotProjectionCheckpoint = serde_json::from_slice(&bytes) - .map_err(|error| KnotSyncError::Payload(error.to_string()))?; - if checkpoint.version != 1 || checkpoint.space_id != self.policy.space_id { - return Err(KnotSyncError::Payload( - "checkpoint version or space does not match this store".into(), - )); - } - Ok(Some(checkpoint)) - } - - /// Name the exact operations newer than the last durable checkpoint. - pub async fn tail_receipt(&self) -> Result { - let checkpoint = self - .load_checkpoint() - .await? - .ok_or(KnotSyncError::MissingCheckpoint)?; - let bytes = serde_json::to_vec(&checkpoint) - .map_err(|error| KnotSyncError::Payload(error.to_string()))?; - let checkpoint_id = *blake3::hash(&bytes).as_bytes(); - let heads: BTreeMap<_, _> = checkpoint - .heads - .iter() - .map(|head| ((head.author, head.log_id), head.seq_num)) - .collect(); - let mut tail = Vec::new(); - for record in self.load_operations().await? { - let operation = &record.operation; - let key = (*operation.header.verifying_key.as_bytes(), record.log_id); - if heads - .get(&key) - .is_none_or(|seq_num| operation.header.seq_num > *seq_num) - { - tail.push(( - key.0, - key.1, - operation.header.seq_num, - *operation.hash.as_bytes(), - )); - } - } - tail.sort(); - Ok(KnotTailReceipt { - checkpoint: checkpoint_id, - operations: tail - .into_iter() - .map(|(_, _, _, operation)| operation) - .collect(), - }) - } - - /// Produce communal Knot's dry-run proposal without translating document - /// events through the Commons graph or chat grammars. - pub async fn communal_epoch_pruning_proposal( - &self, - keys: &DataKeyring, - checkpoint_authority_revision: Digest, - current_authority_revision: Digest, - authority_reevaluation_epochs: &[GroupSecretId], - offline_members: &[KnotOfflineMemberEpochHold], - ) -> Result { - if self.policy.encryption != KnotEncryptionProfile::CommonsDataV1 { - return Err(KnotSyncError::WrongEncryptionProfile); - } - let records = self.load_operations().await?; - let by_operation: BTreeMap<_, _> = records - .iter() - .map(|record| (*record.operation.hash.as_bytes(), record)) - .collect(); - let mut holds = Vec::new(); - - let checkpoint = if let Some(checkpoint) = self.load_checkpoint().await? { - let tail = self.tail_receipt().await?; - for operation in &tail.operations { - let record = by_operation.get(operation).ok_or_else(|| { - KnotSyncError::Payload( - "checkpoint tail names an operation absent from the retained store".into(), - ) - })?; - holds.push(EpochHold { - epoch: communal_operation_epoch(&record.operation)?, - reason: EpochHoldReason::DecryptionReachability, - }); - } - for (operation, _) in &checkpoint.pending { - let record = by_operation.get(operation).ok_or_else(|| { - KnotSyncError::Payload( - "checkpoint pending set names an operation absent from the retained store" - .into(), - ) - })?; - holds.push(EpochHold { - epoch: communal_operation_epoch(&record.operation)?, - reason: EpochHoldReason::PendingCausality, - }); - } - let current = self - .build_checkpoint_with_cipher(KnotSyncCipher::CommonsData(keys)) - .await?; - let author_continuation_ready = checkpoint.snapshot.is_some() - && tail.operations.is_empty() - && current == checkpoint; - let bytes = serde_json::to_vec(&checkpoint) - .map_err(|error| KnotSyncError::Payload(error.to_string()))?; - Some(EpochCheckpointBasis { - checkpoint: Digest::blake3(&bytes), - authority_revision: checkpoint_authority_revision, - current_authority_revision, - author_continuation_ready, - }) - } else { - for record in &records { - holds.push(EpochHold { - epoch: communal_operation_epoch(&record.operation)?, - reason: EpochHoldReason::DecryptionReachability, - }); - } - None - }; - holds.extend( - authority_reevaluation_epochs - .iter() - .copied() - .map(|epoch| EpochHold { - epoch, - reason: EpochHoldReason::AuthorityReevaluation, - }), - ); - holds.extend(offline_members.iter().map(|hold| EpochHold { - epoch: hold.epoch, - reason: EpochHoldReason::OfflineMember(hold.member), - })); - Ok(propose_epoch_pruning( - KNOT_COMMONS_ENCRYPTION_PROFILE, - keys, - &EpochRetentionFacts { checkpoint, holds }, - )) - } - - /// Revalidate and explicitly execute a reviewed communal proposal. - pub async fn execute_communal_epoch_pruning( - &self, - keys: &mut DataKeyring, - reviewed: &EpochPruningProposal, - checkpoint_authority_revision: Digest, - current_authority_revision: Digest, - authority_reevaluation_epochs: &[GroupSecretId], - offline_members: &[KnotOfflineMemberEpochHold], - ) -> Result { - let current = self - .communal_epoch_pruning_proposal( - keys, - checkpoint_authority_revision.clone(), - current_authority_revision, - authority_reevaluation_epochs, - offline_members, - ) - .await?; - if ¤t != reviewed { - return Err(KnotSyncError::StaleRetentionProposal); - } - if !current.is_executable() { - return Err(KnotSyncError::BlockedRetentionProposal); - } - let checkpoint = current - .checkpoint - .clone() - .ok_or(KnotSyncError::BlockedRetentionProposal)?; - let before = keys.to_bytes()?; - let mut reduced = DataKeyring::from_bytes(&before)?; - for epoch in ¤t.forget { - if !reduced.forget_authorized(epoch) { - return Err(KnotSyncError::StaleRetentionProposal); - } - } - let after = reduced.to_bytes()?; - let receipt = KnotEpochExecutionReceipt { - version: 1, - space_id: self.policy.space_id, - checkpoint, - authority_revision: checkpoint_authority_revision, - forgotten: current.forget, - retained: reduced - .epochs_oldest_first() - .ok_or_else(|| { - KnotSyncError::Payload( - "executed keyring lost its proven epoch chronology".into(), - ) - })? - .to_vec(), - previous_keyring: Digest::blake3(&before), - persisted_keyring: Digest::blake3(&after), - }; - self.store - .backend() - .apply(&[ - WriteOp::Put { - key: communal_keyring_key(self.policy.space_id), - value: after, - }, - WriteOp::Put { - key: communal_epoch_receipt_key(self.policy.space_id), - value: serde_json::to_vec(&receipt) - .map_err(|error| KnotSyncError::Payload(error.to_string()))?, - }, - ]) - .await?; - *keys = reduced; - Ok(receipt) - } - - pub async fn restore_communal_keyring( - &self, - keys: &mut DataKeyring, - ) -> Result { - let Some(bytes) = self - .store - .backend() - .get(&communal_keyring_key(self.policy.space_id)) - .await? - else { - return Ok(false); - }; - *keys = DataKeyring::from_bytes(&bytes)?; - Ok(true) - } - - pub async fn communal_epoch_execution_receipt( - &self, - ) -> Result, KnotSyncError> { - let Some(bytes) = self - .store - .backend() - .get(&communal_epoch_receipt_key(self.policy.space_id)) - .await? - else { - return Ok(None); - }; - serde_json::from_slice(&bytes) - .map(Some) - .map_err(|error| KnotSyncError::Payload(error.to_string())) - } - - pub async fn communal_offline_member_recovery( - &self, - keys: &DataKeyring, - required_epoch: GroupSecretId, - ) -> Result { - if keys.contains(&required_epoch) { - return Ok(KnotOfflineMemberRecovery::Resume); - } - let checkpoint = self - .load_checkpoint() - .await? - .map(|checkpoint| { - serde_json::to_vec(&checkpoint) - .map(|bytes| Digest::blake3(&bytes)) - .map_err(|error| KnotSyncError::Payload(error.to_string())) - }) - .transpose()?; - Ok(KnotOfflineMemberRecovery::BootstrapRequired { checkpoint }) - } - - pub fn sync_store(&self) -> MunimentStore { - self.store.clone() - } -} - -impl KnotSyncStore -where - B: Backend + Clone + Send + Sync + 'static, -{ - /// Join the real p2panda LogSync lane with Knot's admission closure. - pub async fn join( - &self, - endpoint: Endpoint, - gossip: Gossip, - ) -> Result, JoinError> { - let accept_store = self.clone(); - JoinedSpace::join::<_, u64, _, _>( - // Scoped to kind AND space: a persona's vault space and any other - // Knot space on one endpoint would otherwise share a protocol id. - stickleback::lane_id("knot/vault-space/v1", self.policy.space_id), - self.sync_store(), - endpoint, - gossip, - self.policy.space_id, - move |operation: Operation| { - let store = accept_store.clone(); - async move { matches!(store.accept(&operation).await, Ok(true)) } - }, - ) - .await - } -} - -fn causal_entries(records: &[StoredKnotOperation]) -> Vec> { - records - .iter() - .map(|record| { - CausalEntry::from_operation( - &record.operation, - record.log_id, - record.operation.header.extensions.parents.clone(), - ) - }) - .collect() -} - -fn checkpoint_key(space_id: [u8; 32]) -> String { - let hex: String = space_id.iter().map(|byte| format!("{byte:02x}")).collect(); - format!("knot-sync/checkpoint/{hex}") -} - -fn communal_keyring_key(space_id: [u8; 32]) -> String { - let hex: String = space_id.iter().map(|byte| format!("{byte:02x}")).collect(); - format!("knot-sync/commons-keyring/{hex}") -} - -fn communal_epoch_receipt_key(space_id: [u8; 32]) -> String { - let hex: String = space_id.iter().map(|byte| format!("{byte:02x}")).collect(); - format!("knot-sync/commons-epoch-receipt/{hex}") -} - -fn communal_operation_epoch( - operation: &Operation, -) -> Result { - let body = operation - .body - .as_ref() - .ok_or_else(|| KnotSyncError::Payload("operation body is absent".into()))?; - let envelope: GroupCiphertext = decode_cbor(body.to_bytes().as_slice()) - .map_err(|error| KnotSyncError::Payload(error.to_string()))?; - Ok(envelope.epoch) -} - -fn operation_aad(space_id: [u8; 32], author: &[u8; 32], seq_num: u32) -> Vec { - let mut aad = Vec::with_capacity(SYNC_AAD.len() + 68); - aad.extend_from_slice(SYNC_AAD); - aad.extend_from_slice(&space_id); - aad.extend_from_slice(author); - aad.extend_from_slice(&seq_num.to_le_bytes()); - aad -} - -fn seal_event( - cipher: KnotSyncCipher<'_>, - aad: &[u8], - plaintext: &[u8], -) -> Result, KnotSyncError> { - match cipher { - KnotSyncCipher::Personal(vault) => vault - .seal_sync_payload(aad, plaintext) - .map_err(KnotSyncError::Payload), - KnotSyncCipher::CommonsData(keys) => { - let envelope = keys.seal_random(plaintext)?; - encode_cbor(&envelope).map_err(|error| KnotSyncError::Payload(error.to_string())) - } - } -} - -fn decode_event( - cipher: KnotSyncCipher<'_>, - operation: &Operation, -) -> Result { - let body = operation - .body - .as_ref() - .ok_or_else(|| KnotSyncError::Payload("operation body is absent".into()))?; - let aad = operation_aad( - operation.header.extensions.space_id, - operation.header.verifying_key.as_bytes(), - operation.header.seq_num, - ); - let plaintext = Zeroizing::new(match cipher { - KnotSyncCipher::Personal(vault) => vault - .unseal_sync_payload(&aad, &body.to_bytes()) - .map_err(KnotSyncError::Payload)?, - KnotSyncCipher::CommonsData(keys) => { - let envelope: GroupCiphertext = decode_cbor(body.to_bytes().as_slice()) - .map_err(|error| KnotSyncError::Payload(error.to_string()))?; - keys.open(&envelope)? - } - }); - serde_json::from_slice(plaintext.as_slice()) - .map_err(|error| KnotSyncError::Payload(error.to_string())) -} - -fn validate_resolution( - causal: &CausalIndex<'_, u64>, - event_documents: &BTreeMap<[u8; 32], String>, - resolution: [u8; 32], - id: &str, - supersedes: &[[u8; 32]], - document: Option<&VaultDocument>, -) -> Result, KnotSyncError> { - if supersedes.is_empty() { - return Err(KnotSyncError::InvalidResolution( - "resolution names no document versions".into(), - )); - } - if supersedes.len() > KNOT_CAUSAL_LIMITS.max_parents { - return Err(KnotSyncError::InvalidResolution(format!( - "resolution names {} versions; maximum is {}", - supersedes.len(), - KNOT_CAUSAL_LIMITS.max_parents - ))); - } - if document.is_some_and(|document| document.id != id) { - return Err(KnotSyncError::InvalidResolution( - "chosen document id does not match the resolution".into(), - )); - } - let targets: BTreeSet<_> = supersedes.iter().copied().collect(); - if targets.len() != supersedes.len() { - return Err(KnotSyncError::InvalidResolution( - "resolution repeats a document version".into(), - )); - } - for target in &targets { - let Some(target_id) = event_documents.get(target) else { - return Err(KnotSyncError::InvalidResolution( - "resolution names an unavailable document version".into(), - )); - }; - if target_id != id { - return Err(KnotSyncError::InvalidResolution( - "resolution names a version of another document".into(), - )); - } - if !causal.happens_before(*target, resolution) { - return Err(KnotSyncError::InvalidResolution( - "resolution names a version outside its causal history".into(), - )); - } - } - Ok(targets) -} - -#[cfg(test)] -mod tests { - use std::sync::Arc; - use std::time::Duration; - - use personae::{IdentityProvider, InMemoryProvider}; - use tempfile::tempdir; - use transport::{P2pandaTransport, PeerID, sync_overlay_topic}; - - use super::*; - - const SPACE: [u8; 32] = [0x81; 32]; - const VAULT_KEY: [u8; 32] = [0x82; 32]; - - fn doc(id: &str, body: &str) -> VaultDocument { - VaultDocument { - id: id.into(), - title: id.into(), - body: body.as_bytes().to_vec(), - media_type: "text/vnd.knot".into(), - } - } - - fn identities() -> (InMemoryProvider, InMemoryProvider) { - ( - InMemoryProvider::from_seed([0x83; 32]), - InMemoryProvider::from_seed([0x84; 32]), - ) - } - - fn paired_group_keys() -> (DataKeyring, DataKeyring) { - let mut alice = DataKeyring::new(); - let secret = alice.rotate_random().unwrap(); - let mut bob = DataKeyring::new(); - bob.install(secret); - (alice, bob) - } - - #[tokio::test] - async fn communal_proposal_keeps_pending_and_policy_held_epochs() { - let (alice, bob) = identities(); - let writers = [ - alice.master_public_key().to_bytes(), - bob.master_public_key().to_bytes(), - ]; - let mut keys = DataKeyring::new(); - let mut epochs = vec![keys.rotate_random().unwrap().id()]; - let parent_store = KnotSyncStore::in_memory_commons(SPACE, writers); - let child_store = KnotSyncStore::in_memory_commons(SPACE, writers); - let receiver = KnotSyncStore::in_memory_commons(SPACE, writers); - let parent = parent_store - .author_communal( - alice.master_keypair().to_seed(), - &keys, - &KnotSyncEvent::Put(doc("shared", "parent")), - ) - .await - .unwrap(); - child_store.accept(&parent).await.unwrap(); - let child = child_store - .author_communal( - bob.master_keypair().to_seed(), - &keys, - &KnotSyncEvent::Put(doc("shared", "pending child")), - ) - .await - .unwrap(); - receiver.accept(&child).await.unwrap(); - for _ in 0..9 { - epochs.push(keys.rotate_random().unwrap().id()); - } - let checkpoint = receiver.save_communal_checkpoint(&keys).await.unwrap(); - assert_eq!(checkpoint.pending[0].0, *child.hash.as_bytes()); - - let revision = Digest::blake3(b"commons authority"); - let pending = receiver - .communal_epoch_pruning_proposal(&keys, revision.clone(), revision.clone(), &[], &[]) - .await - .unwrap(); - assert!(pending.is_executable()); - assert_eq!(pending.forget, vec![epochs[1]]); - assert!(pending.retain.iter().any(|retained| { - retained.epoch == epochs[0] - && retained - .reasons - .contains(&stickleback::EpochRetentionReason::Domain( - EpochHoldReason::PendingCausality, - )) - })); - - let policy_held = receiver - .communal_epoch_pruning_proposal( - &keys, - revision.clone(), - revision, - &[epochs[1]], - &[KnotOfflineMemberEpochHold { - member: [0xc3; 32], - epoch: epochs[1], - }], - ) - .await - .unwrap(); - assert!(policy_held.is_executable()); - assert!(policy_held.forget.is_empty()); - } - - #[tokio::test] - async fn communal_execution_revalidates_commits_and_reopens() { - let directory = tempdir().unwrap(); - let database = directory.path().join("communal-retention.redb"); - let alice = InMemoryProvider::from_seed([0x83; 32]); - let writer = alice.master_public_key().to_bytes(); - let seed = alice.master_keypair().to_seed(); - let mut keys = DataKeyring::new(); - let mut epochs = Vec::new(); - for _ in 0..10 { - epochs.push(keys.rotate_random().unwrap().id()); - } - let store = KnotSyncFileStore::open_commons(&database, SPACE, [writer]).unwrap(); - store - .author_communal( - seed, - &keys, - &KnotSyncEvent::Put(doc("one", "before checkpoint")), - ) - .await - .unwrap(); - store.save_communal_checkpoint(&keys).await.unwrap(); - let revision = Digest::blake3(b"commons authority"); - let stale = store - .communal_epoch_pruning_proposal(&keys, revision.clone(), revision.clone(), &[], &[]) - .await - .unwrap(); - - store - .author_communal( - seed, - &keys, - &KnotSyncEvent::Put(doc("two", "new checkpoint")), - ) - .await - .unwrap(); - store.save_communal_checkpoint(&keys).await.unwrap(); - assert!(matches!( - store - .execute_communal_epoch_pruning( - &mut keys, - &stale, - revision.clone(), - revision.clone(), - &[], - &[], - ) - .await, - Err(KnotSyncError::StaleRetentionProposal) - )); - assert!( - store - .communal_epoch_execution_receipt() - .await - .unwrap() - .is_none() - ); - assert_eq!(keys.epoch_count(), 10); - - let reviewed = store - .communal_epoch_pruning_proposal(&keys, revision.clone(), revision.clone(), &[], &[]) - .await - .unwrap(); - let receipt = store - .execute_communal_epoch_pruning( - &mut keys, - &reviewed, - revision.clone(), - revision, - &[], - &[], - ) - .await - .unwrap(); - assert_eq!(receipt.forgotten, epochs[..2]); - assert_eq!(receipt.retained, epochs[2..]); - assert_eq!(keys.epoch_count(), 8); - drop(store); - - let reopened = KnotSyncFileStore::open_commons(&database, SPACE, [writer]).unwrap(); - let mut reopened_keys = DataKeyring::new(); - assert!( - reopened - .restore_communal_keyring(&mut reopened_keys) - .await - .unwrap() - ); - assert_eq!(reopened_keys.epochs_oldest_first().unwrap(), &epochs[2..]); - assert_eq!( - reopened.communal_documents(&reopened_keys).await.unwrap(), - vec![ - doc("one", "before checkpoint"), - doc("two", "new checkpoint") - ] - ); - assert_eq!( - reopened - .communal_offline_member_recovery(&reopened_keys, epochs[2]) - .await - .unwrap(), - KnotOfflineMemberRecovery::Resume - ); - assert!(matches!( - reopened - .communal_offline_member_recovery(&reopened_keys, epochs[0]) - .await - .unwrap(), - KnotOfflineMemberRecovery::BootstrapRequired { - checkpoint: Some(_) - } - )); - assert_eq!( - reopened.communal_epoch_execution_receipt().await.unwrap(), - Some(receipt) - ); - } - - #[tokio::test] - async fn two_memory_stores_converge_through_the_accept_seam() { - let roots = tempdir().unwrap(); - let (alice, bob) = identities(); - let alice_seed = alice.master_keypair().to_seed(); - let bob_seed = bob.master_keypair().to_seed(); - let writers = [ - alice.master_public_key().to_bytes(), - bob.master_public_key().to_bytes(), - ]; - let a = KnotSyncStore::in_memory(SPACE, writers); - let b = KnotSyncStore::in_memory(SPACE, writers); - let alice_vault = KnotVault::open(roots.path().join("alice"), VAULT_KEY).unwrap(); - let bob_vault = KnotVault::open(roots.path().join("bob"), VAULT_KEY).unwrap(); - - let a_op = a - .author( - alice_seed, - &alice_vault, - &KnotSyncEvent::Put(doc("alice-note", "amber")), - ) - .await - .unwrap(); - let b_op = b - .author( - bob_seed, - &bob_vault, - &KnotSyncEvent::Put(doc("bob-note", "blue")), - ) - .await - .unwrap(); - assert!(a.accept(&b_op).await.unwrap()); - assert!(b.accept(&a_op).await.unwrap()); - - assert_eq!( - a.documents(&alice_vault).await.unwrap(), - b.documents(&bob_vault).await.unwrap() - ); - } - - #[tokio::test] - async fn refreshed_writer_authority_changes_live_operation_admission() { - let roots = tempdir().unwrap(); - let (alice, bob) = identities(); - let alice_writer = alice.master_public_key().to_bytes(); - let bob_writer = bob.master_public_key().to_bytes(); - let author = KnotSyncStore::in_memory(SPACE, [alice_writer]); - let receiver = KnotSyncStore::in_memory(SPACE, [bob_writer]); - let vault = KnotVault::open(roots.path().join("vault"), VAULT_KEY).unwrap(); - let first = author - .author( - alice.master_keypair().to_seed(), - &vault, - &KnotSyncEvent::Put(doc("first", "admitted after pairing")), - ) - .await - .unwrap(); - - assert!(receiver.accept(&first).await.is_err()); - assert!(receiver.admit_writer(alice_writer)); - assert!(receiver.accept(&first).await.unwrap()); - - let second = author - .author( - alice.master_keypair().to_seed(), - &vault, - &KnotSyncEvent::Put(doc("second", "rejected after revocation")), - ) - .await - .unwrap(); - assert!(receiver.replace_admitted_writers([bob_writer])); - assert!(receiver.accept(&second).await.is_err()); - } - - #[tokio::test] - async fn commons_documents_use_group_epochs_instead_of_personal_vault_keys() { - let roots = tempdir().unwrap(); - let (alice, bob) = identities(); - let writers = [ - alice.master_public_key().to_bytes(), - bob.master_public_key().to_bytes(), - ]; - let (mut alice_keys, bob_keys) = paired_group_keys(); - let a = KnotSyncStore::in_memory_commons(SPACE, writers); - let b = KnotSyncStore::in_memory_commons(SPACE, writers); - let alice_vault = KnotVault::open(roots.path().join("alice"), [0x91; 32]).unwrap(); - let bob_vault = KnotVault::open(roots.path().join("bob"), [0x92; 32]).unwrap(); - - let old = a - .author_communal( - alice.master_keypair().to_seed(), - &alice_keys, - &KnotSyncEvent::Put(doc("shared", "before removal")), - ) - .await - .unwrap(); - b.accept(&old).await.unwrap(); - assert_eq!( - a.communal_documents(&alice_keys).await.unwrap(), - b.communal_documents(&bob_keys).await.unwrap() - ); - assert!(matches!( - a.projection(&alice_vault).await, - Err(KnotSyncError::WrongEncryptionProfile) - )); - assert!(matches!( - b.projection(&bob_vault).await, - Err(KnotSyncError::WrongEncryptionProfile) - )); - - alice_keys.rotate_random().unwrap(); - let after_removal = a - .author_communal( - alice.master_keypair().to_seed(), - &alice_keys, - &KnotSyncEvent::Put(doc("new", "after removal")), - ) - .await - .unwrap(); - assert!(b.accept(&after_removal).await.unwrap()); - assert!(matches!( - b.communal_projection(&bob_keys).await, - Err(KnotSyncError::GroupCrypto(GroupCryptoError::UnknownEpoch( - _ - ))) - )); - assert_eq!(a.communal_documents(&alice_keys).await.unwrap().len(), 2); - } - - #[tokio::test] - async fn a_signed_encryption_profile_cannot_replay_into_another_knot_profile() { - let roots = tempdir().unwrap(); - let alice = InMemoryProvider::from_seed([0x83; 32]); - let writer = alice.master_public_key().to_bytes(); - let vault = KnotVault::open(roots.path(), VAULT_KEY).unwrap(); - let personal = KnotSyncStore::in_memory(SPACE, [writer]); - let communal = KnotSyncStore::in_memory_commons(SPACE, [writer]); - let operation = personal - .author( - alice.master_keypair().to_seed(), - &vault, - &KnotSyncEvent::Put(doc("note", "personal")), - ) - .await - .unwrap(); - - assert!(communal.accept(&operation).await.is_err()); - } - - #[tokio::test] - async fn concurrent_writers_for_one_document_are_refused_at_projection() { - let roots = tempdir().unwrap(); - let (alice, bob) = identities(); - let writers = [ - alice.master_public_key().to_bytes(), - bob.master_public_key().to_bytes(), - ]; - let a = KnotSyncStore::in_memory(SPACE, writers); - let b = KnotSyncStore::in_memory(SPACE, writers); - let vault = KnotVault::open(roots.path(), VAULT_KEY).unwrap(); - let alice_version = a - .author( - alice.master_keypair().to_seed(), - &vault, - &KnotSyncEvent::Put(doc("shared", "alice")), - ) - .await - .unwrap(); - let bob_version = b - .author( - bob.master_keypair().to_seed(), - &vault, - &KnotSyncEvent::Put(doc("shared", "bob")), - ) - .await - .unwrap(); - a.accept(&bob_version).await.unwrap(); - b.accept(&alice_version).await.unwrap(); - a.author( - alice.master_keypair().to_seed(), - &vault, - &KnotSyncEvent::Put(doc("solo", "still visible")), - ) - .await - .unwrap(); - let projection = a.projection(&vault).await.unwrap(); - assert_eq!(projection.documents, vec![doc("solo", "still visible")]); - assert_eq!(projection.conflicts.len(), 1); - assert_eq!(projection.conflicts[0].id, "shared"); - assert_eq!(projection.conflicts[0].versions.len(), 2); - assert!(projection.pending.is_empty()); - assert!(matches!( - a.documents(&vault).await, - Err(KnotSyncError::ConcurrentWriter(id)) if id == "shared" - )); - } - - #[tokio::test] - async fn independent_text_edits_merge_and_a_later_put_makes_them_durable() { - let roots = tempdir().unwrap(); - let (alice, bob) = identities(); - let writers = [ - alice.master_public_key().to_bytes(), - bob.master_public_key().to_bytes(), - ]; - let vault = KnotVault::open(roots.path(), VAULT_KEY).unwrap(); - let base_store = KnotSyncStore::in_memory(SPACE, writers); - let a = KnotSyncStore::in_memory(SPACE, writers); - let b = KnotSyncStore::in_memory(SPACE, writers); - let base = base_store - .author( - alice.master_keypair().to_seed(), - &vault, - &KnotSyncEvent::Put(doc("shared", "first\nsecond\nthird\n")), - ) - .await - .unwrap(); - a.accept(&base).await.unwrap(); - b.accept(&base).await.unwrap(); - - let left = a - .author( - alice.master_keypair().to_seed(), - &vault, - &KnotSyncEvent::Put(doc("shared", "FIRST\nsecond\nthird\n")), - ) - .await - .unwrap(); - let right = b - .author( - bob.master_keypair().to_seed(), - &vault, - &KnotSyncEvent::Put(doc("shared", "first\nsecond\nTHIRD\n")), - ) - .await - .unwrap(); - a.accept(&right).await.unwrap(); - b.accept(&left).await.unwrap(); - - let a_projection = a.projection(&vault).await.unwrap(); - let b_projection = b.projection(&vault).await.unwrap(); - let merged = doc("shared", "FIRST\nsecond\nTHIRD\n"); - assert_eq!(a_projection.documents, vec![merged.clone()]); - assert_eq!(a_projection, b_projection); - assert!(a_projection.conflicts.is_empty()); - assert_eq!(a_projection.automatic_merges.len(), 1); - assert_eq!(a_projection.automatic_merges[0].base, *base.hash.as_bytes()); - let mut supersedes = vec![*left.hash.as_bytes(), *right.hash.as_bytes()]; - supersedes.sort_unstable(); - assert_eq!(a_projection.automatic_merges[0].supersedes, supersedes); - - let durable = doc("shared", "FIRST\nsecond\nTHIRD\nafter merge\n"); - let durable_operation = a - .author( - alice.master_keypair().to_seed(), - &vault, - &KnotSyncEvent::Put(durable.clone()), - ) - .await - .unwrap(); - b.accept(&durable_operation).await.unwrap(); - for projection in [ - a.projection(&vault).await.unwrap(), - b.projection(&vault).await.unwrap(), - ] { - assert_eq!(projection.documents, vec![durable.clone()]); - assert!(projection.conflicts.is_empty()); - assert!(projection.automatic_merges.is_empty()); - assert_eq!( - projection.document_heads["shared"], - *durable_operation.hash.as_bytes() - ); - } - } - - #[tokio::test] - async fn overlapping_text_edits_remain_an_explicit_conflict() { - let roots = tempdir().unwrap(); - let (alice, bob) = identities(); - let writers = [ - alice.master_public_key().to_bytes(), - bob.master_public_key().to_bytes(), - ]; - let vault = KnotVault::open(roots.path(), VAULT_KEY).unwrap(); - let base_store = KnotSyncStore::in_memory(SPACE, writers); - let a = KnotSyncStore::in_memory(SPACE, writers); - let b = KnotSyncStore::in_memory(SPACE, writers); - let base = base_store - .author( - alice.master_keypair().to_seed(), - &vault, - &KnotSyncEvent::Put(doc("shared", "one\ntwo\n")), - ) - .await - .unwrap(); - a.accept(&base).await.unwrap(); - b.accept(&base).await.unwrap(); - let left = a - .author( - alice.master_keypair().to_seed(), - &vault, - &KnotSyncEvent::Put(doc("shared", "alice\ntwo\n")), - ) - .await - .unwrap(); - let right = b - .author( - bob.master_keypair().to_seed(), - &vault, - &KnotSyncEvent::Put(doc("shared", "bob\ntwo\n")), - ) - .await - .unwrap(); - a.accept(&right).await.unwrap(); - b.accept(&left).await.unwrap(); - - for projection in [ - a.projection(&vault).await.unwrap(), - b.projection(&vault).await.unwrap(), - ] { - assert!(projection.documents.is_empty()); - assert!(projection.automatic_merges.is_empty()); - assert_eq!(projection.conflicts.len(), 1); - assert_eq!(projection.conflicts[0].id, "shared"); - } - } - - #[tokio::test] - async fn an_explicit_resolution_replaces_exact_conflicting_versions() { - let roots = tempdir().unwrap(); - let (alice, bob) = identities(); - let writers = [ - alice.master_public_key().to_bytes(), - bob.master_public_key().to_bytes(), - ]; - let vault = KnotVault::open(roots.path(), VAULT_KEY).unwrap(); - let a = KnotSyncStore::in_memory(SPACE, writers); - let b = KnotSyncStore::in_memory(SPACE, writers); - let alice_op = a - .author( - alice.master_keypair().to_seed(), - &vault, - &KnotSyncEvent::Put(doc("shared", "alice")), - ) - .await - .unwrap(); - let bob_op = b - .author( - bob.master_keypair().to_seed(), - &vault, - &KnotSyncEvent::Put(doc("shared", "bob")), - ) - .await - .unwrap(); - a.accept(&bob_op).await.unwrap(); - b.accept(&alice_op).await.unwrap(); - let conflict = a.projection(&vault).await.unwrap().conflicts.remove(0); - let resolution = a - .resolve_conflict( - alice.master_keypair().to_seed(), - &vault, - &conflict, - Some(doc("shared", "chosen")), - ) - .await - .unwrap(); - b.accept(&resolution).await.unwrap(); - - assert_eq!( - a.documents(&vault).await.unwrap(), - vec![doc("shared", "chosen")] - ); - assert_eq!( - a.documents(&vault).await.unwrap(), - b.documents(&vault).await.unwrap() - ); - } - - #[tokio::test] - async fn a_resolution_does_not_erase_an_unseen_concurrent_version() { - let roots = tempdir().unwrap(); - let (alice, bob) = identities(); - let writers = [ - alice.master_public_key().to_bytes(), - bob.master_public_key().to_bytes(), - ]; - let vault = KnotVault::open(roots.path(), VAULT_KEY).unwrap(); - let a = KnotSyncStore::in_memory(SPACE, writers); - let b = KnotSyncStore::in_memory(SPACE, writers); - let alice_op = a - .author( - alice.master_keypair().to_seed(), - &vault, - &KnotSyncEvent::Put(doc("shared", "alice")), - ) - .await - .unwrap(); - let local = KnotDocumentConflict { - id: "shared".into(), - versions: vec![KnotDocumentVersion { - writer: alice.master_public_key().to_bytes(), - operation: *alice_op.hash.as_bytes(), - document: Some(doc("shared", "alice")), - }], - }; - a.resolve_conflict( - alice.master_keypair().to_seed(), - &vault, - &local, - Some(doc("shared", "alice resolved")), - ) - .await - .unwrap(); - let bob_op = b - .author( - bob.master_keypair().to_seed(), - &vault, - &KnotSyncEvent::Put(doc("shared", "bob unseen")), - ) - .await - .unwrap(); - a.accept(&bob_op).await.unwrap(); - - let projection = a.projection(&vault).await.unwrap(); - assert_eq!(projection.conflicts.len(), 1); - assert_eq!(projection.conflicts[0].versions.len(), 2); - } - - #[tokio::test] - async fn a_resolution_cannot_name_a_version_outside_its_causal_history() { - let roots = tempdir().unwrap(); - let (alice, bob) = identities(); - let writers = [ - alice.master_public_key().to_bytes(), - bob.master_public_key().to_bytes(), - ]; - let vault = KnotVault::open(roots.path(), VAULT_KEY).unwrap(); - let a = KnotSyncStore::in_memory(SPACE, writers); - let b = KnotSyncStore::in_memory(SPACE, writers); - let alice_op = a - .author( - alice.master_keypair().to_seed(), - &vault, - &KnotSyncEvent::Put(doc("shared", "alice")), - ) - .await - .unwrap(); - let forged_conflict = KnotDocumentConflict { - id: "shared".into(), - versions: vec![KnotDocumentVersion { - writer: alice.master_public_key().to_bytes(), - operation: *alice_op.hash.as_bytes(), - document: Some(doc("shared", "alice")), - }], - }; - let forged_resolution = b - .resolve_conflict( - bob.master_keypair().to_seed(), - &vault, - &forged_conflict, - Some(doc("shared", "forged")), - ) - .await - .unwrap(); - a.accept(&forged_resolution).await.unwrap(); - - assert!(matches!( - a.projection(&vault).await, - Err(KnotSyncError::InvalidResolution(_)) - )); - } - - #[tokio::test] - async fn missing_history_blocks_only_its_document_branch() { - let roots = tempdir().unwrap(); - let alice = InMemoryProvider::from_seed([0x83; 32]); - let bob = InMemoryProvider::from_seed([0x84; 32]); - let carol = InMemoryProvider::from_seed([0x85; 32]); - let writers = [ - alice.master_public_key().to_bytes(), - bob.master_public_key().to_bytes(), - carol.master_public_key().to_bytes(), - ]; - let vault = KnotVault::open(roots.path(), VAULT_KEY).unwrap(); - let parent_store = KnotSyncStore::in_memory(SPACE, writers); - let child_store = KnotSyncStore::in_memory(SPACE, writers); - let unrelated_store = KnotSyncStore::in_memory(SPACE, writers); - let receiver = KnotSyncStore::in_memory(SPACE, writers); - - let parent = parent_store - .author( - alice.master_keypair().to_seed(), - &vault, - &KnotSyncEvent::Put(doc("shared", "parent")), - ) - .await - .unwrap(); - child_store.accept(&parent).await.unwrap(); - let child = child_store - .author( - bob.master_keypair().to_seed(), - &vault, - &KnotSyncEvent::Put(doc("shared", "child")), - ) - .await - .unwrap(); - let unrelated = unrelated_store - .author( - carol.master_keypair().to_seed(), - &vault, - &KnotSyncEvent::Put(doc("solo", "visible")), - ) - .await - .unwrap(); - - receiver.accept(&child).await.unwrap(); - receiver.accept(&unrelated).await.unwrap(); - let partial = receiver.projection(&vault).await.unwrap(); - assert_eq!(partial.documents, vec![doc("solo", "visible")]); - assert_eq!(partial.pending.len(), 1); - assert_eq!(partial.pending[0].operation, *child.hash.as_bytes()); - assert_eq!(partial.pending[0].missing, vec![*parent.hash.as_bytes()]); - - receiver.accept(&parent).await.unwrap(); - let complete = receiver.projection(&vault).await.unwrap(); - assert!(complete.pending.is_empty()); - assert!(complete.conflicts.is_empty()); - assert_eq!( - complete.documents, - vec![doc("shared", "child"), doc("solo", "visible")] - ); - } - - #[tokio::test] - async fn redb_reopen_restores_author_head_and_observed_frontier() { - let roots = tempdir().unwrap(); - let database = roots.path().join("knot-sync.redb"); - let vault = KnotVault::open(roots.path().join("vault"), VAULT_KEY).unwrap(); - let alice = InMemoryProvider::from_seed([0x83; 32]); - let writer = alice.master_public_key().to_bytes(); - let seed = alice.master_keypair().to_seed(); - - let second = { - let store = KnotSyncFileStore::open(&database, SPACE, [writer]).unwrap(); - store - .author(seed, &vault, &KnotSyncEvent::Put(doc("one", "first"))) - .await - .unwrap(); - let second = store - .author(seed, &vault, &KnotSyncEvent::Put(doc("two", "second"))) - .await - .unwrap(); - let checkpoint = store.save_checkpoint(&vault).await.unwrap(); - assert_eq!(checkpoint.heads.len(), 1); - assert_eq!(checkpoint.heads[0].operation, *second.hash.as_bytes()); - second - }; - - let reopened = KnotSyncFileStore::open(&database, SPACE, [writer]).unwrap(); - assert_eq!( - reopened.load_checkpoint().await.unwrap().unwrap().heads[0].operation, - *second.hash.as_bytes() - ); - let third = reopened - .author(seed, &vault, &KnotSyncEvent::Put(doc("three", "third"))) - .await - .unwrap(); - assert_eq!(third.header.seq_num, second.header.seq_num + 1); - assert_eq!( - third.header.backlink.as_ref().map(|hash| *hash.as_bytes()), - Some(*second.hash.as_bytes()) - ); - assert_eq!( - third.header.extensions.parents, - vec![*second.hash.as_bytes()] - ); - assert_eq!( - reopened.tail_receipt().await.unwrap().operations, - vec![*third.hash.as_bytes()] - ); - assert_eq!( - reopened.projection(&vault).await.unwrap().documents.len(), - 3 - ); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 4)] - async fn two_instances_converge_over_real_p2panda_logsync() { - let roots = tempdir().unwrap(); - let (alice, bob) = identities(); - let alice_id = PeerID::from_public_key(alice.master_public_key()); - let bob_id = PeerID::from_public_key(bob.master_public_key()); - let writers = [ - alice.master_public_key().to_bytes(), - bob.master_public_key().to_bytes(), - ]; - let alice_transport = P2pandaTransport::builder(alice.master_keypair()) - .gossip() - .bind() - .await - .unwrap(); - let bob_transport = P2pandaTransport::builder(bob.master_keypair()) - .gossip() - .bind() - .await - .unwrap(); - let overlay = sync_overlay_topic(SPACE); - alice_transport - .add_peer(bob_transport.endpoint_addr().await.unwrap()) - .await - .unwrap(); - alice_transport - .set_topics(bob_id, &[overlay]) - .await - .unwrap(); - bob_transport - .add_peer(alice_transport.endpoint_addr().await.unwrap()) - .await - .unwrap(); - bob_transport - .set_topics(alice_id, &[overlay]) - .await - .unwrap(); - - let alice_store = KnotSyncStore::in_memory(SPACE, writers); - let bob_store = KnotSyncStore::in_memory(SPACE, writers); - let alice_vault = Arc::new(KnotVault::open(roots.path().join("alice"), VAULT_KEY).unwrap()); - let bob_vault = Arc::new(KnotVault::open(roots.path().join("bob"), VAULT_KEY).unwrap()); - alice_store - .author( - alice.master_keypair().to_seed(), - &alice_vault, - &KnotSyncEvent::Put(doc("alice-note", "amber")), - ) - .await - .unwrap(); - bob_store - .author( - bob.master_keypair().to_seed(), - &bob_vault, - &KnotSyncEvent::Put(doc("bob-note", "blue")), - ) - .await - .unwrap(); - - let (a_endpoint, a_gossip) = alice_transport.sync_parts().unwrap(); - let (b_endpoint, b_gossip) = bob_transport.sync_parts().unwrap(); - let alice_joined = alice_store.join(a_endpoint, a_gossip).await.unwrap(); - let bob_joined = bob_store.join(b_endpoint, b_gossip).await.unwrap(); - - tokio::time::timeout(Duration::from_secs(30), async { - loop { - if alice_store.documents(&alice_vault).await.unwrap().len() == 2 - && bob_store.documents(&bob_vault).await.unwrap().len() == 2 - { - break; - } - tokio::time::sleep(Duration::from_millis(100)).await; - } - }) - .await - .expect("Knot peers did not converge"); - - assert_eq!( - alice_store.documents(&alice_vault).await.unwrap(), - bob_store.documents(&bob_vault).await.unwrap() - ); - assert!(alice_joined.ops_received() >= 1); - assert!(bob_joined.ops_received() >= 1); - } -} diff --git a/ports/knot/src/vault.rs b/ports/knot/src/vault.rs deleted file mode 100644 index 83cb85fde..000000000 --- a/ports/knot/src/vault.rs +++ /dev/null @@ -1,448 +0,0 @@ -// Copyright 2026 Mark Alan Boykin -// 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 https://mozilla.org/MPL/2.0/. -// SPDX-License-Identifier: MPL-2.0 - -//! Small sealed document vault built on Personae's record store. - -use std::path::{Path, PathBuf}; - -use esp::embed::VectorIndex; -use personae::{SealedRecordStorage, seal_bytes, unseal_bytes}; -use serde::de::DeserializeOwned; -use serde::{Deserialize, Serialize}; -use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing}; - -const INDEX_PATH: &str = "knot/documents.json"; -pub(crate) const SEARCH_INDEX_PATH: &str = "knot/search-index.json"; -const DERIVED_CACHE_PATH_CONTEXT: &str = "mere.knot.derived-cache-path.v1"; -const DERIVED_CACHE_VERSION: u64 = 1; -const INDEX_VERSION: u64 = 1; -const SYNC_KEY_CONTEXT: &str = "mere.knot.personal-vault-sync.v1"; - -/// One authored document in the sealed vault. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Zeroize)] -pub struct VaultDocument { - /// Stable caller-selected identity. - pub id: String, - /// Display title. - pub title: String, - /// Authored source bytes, unsealed only inside the endpoint. - pub body: Vec, - /// Source media type. - pub media_type: String, -} - -#[derive(Clone, Debug, Default, Serialize, Deserialize, Zeroize, ZeroizeOnDrop)] -struct VaultIndex { - version: u64, - revision: u64, - documents: Vec, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -struct DerivedCacheBlob { - version: u64, - bytes: Vec, -} - -/// An unlockable sealed document store. -pub struct KnotVault { - root: PathBuf, - store: Option, - sync_key: Option>, - index: VaultIndex, -} - -impl KnotVault { - /// Open an unlocked vault with an already-recovered Personae root key. - pub fn open(root: impl Into, key: [u8; 32]) -> Result { - let root = root.into(); - let sync_key = Zeroizing::new(blake3::derive_key(SYNC_KEY_CONTEXT, &key)); - let store = SealedRecordStorage::open_with_key(&root, key); - let index = store - .load_record::(INDEX_PATH) - .map_err(|error| format!("could not load Knot vault: {error}"))? - .unwrap_or_else(|| VaultIndex { - version: INDEX_VERSION, - revision: 0, - documents: Vec::new(), - }); - if index.version != INDEX_VERSION { - return Err(format!( - "unsupported Knot vault index version {}", - index.version - )); - } - Ok(Self { - root, - store: Some(store), - sync_key: Some(sync_key), - index, - }) - } - - /// Root containing sealed record envelopes. - pub fn root(&self) -> &Path { - &self.root - } - - /// Create another in-process read handle over the same unlocked vault. - /// - /// A retained publishing host only needs the sealed sync key to materialize - /// signed source events. It must not take the authoring endpoint's mutable - /// vault handle, but it does need an independently zeroized copy of the - /// already-unlocked record-storage handle for the duration of the host. - pub(crate) fn fork_read_handle(&self) -> Result { - let store = self - .store - .as_ref() - .ok_or_else(|| "cannot fork a locked Knot vault".to_string())?; - let sync_key = self - .sync_key - .as_ref() - .ok_or_else(|| "cannot fork a locked Knot vault".to_string())?; - Ok(Self { - root: self.root.clone(), - store: Some(store.clone()), - sync_key: Some(sync_key.clone()), - index: self.index.clone(), - }) - } - - /// Whether document plaintext and the root key have been dropped. - pub fn is_locked(&self) -> bool { - self.store.is_none() - } - - /// Current authored revision. - pub fn revision(&self) -> u64 { - self.index.revision - } - - /// Unlocked documents. Locked vaults expose an empty iterator. - pub fn documents(&self) -> impl Iterator { - self.index.documents.iter() - } - - /// Insert or replace one document and persist the sealed index. - pub fn put(&mut self, document: VaultDocument) -> Result<(), String> { - self.require_unlocked()?; - if let Some(existing) = self - .index - .documents - .iter_mut() - .find(|existing| existing.id == document.id) - { - existing.zeroize(); - *existing = document; - } else { - self.index.documents.push(document); - } - self.index - .documents - .sort_by(|left, right| left.id.cmp(&right.id)); - self.index.revision = self.index.revision.saturating_add(1).max(1); - self.save() - } - - /// Replace the sealed local view with a projection of recorded sync - /// operations. - /// - /// This is deliberately crate-private: callers author through - /// `KnotSyncStore`; only the endpoint may rematerialize the derived vault - /// index after that operation is accepted. - pub(crate) fn replace_projection( - &mut self, - mut documents: Vec, - ) -> Result { - self.require_unlocked()?; - documents.sort_by(|left, right| left.id.cmp(&right.id)); - if self.index.documents == documents { - return Ok(false); - } - let removed = self - .index - .documents - .iter() - .filter(|current| { - !documents - .iter() - .any(|replacement| replacement.id == current.id) - }) - .map(|document| document.id.clone()) - .collect::>(); - for id in removed { - self.delete_derived_cache(&id)?; - } - self.index.documents.zeroize(); - self.index.documents = documents; - self.index.revision = self.index.revision.saturating_add(1).max(1); - self.save()?; - Ok(true) - } - - /// Read one source body while unlocked. - pub fn body(&self, id: &str) -> Option<&[u8]> { - if self.is_locked() { - return None; - } - self.index - .documents - .iter() - .find(|document| document.id == id) - .map(|document| document.body.as_slice()) - } - - /// Seal the derived vault search index beside the document index. - pub fn store_search_index(&self, index: &VectorIndex) -> Result<(), String> { - self.store - .as_ref() - .ok_or_else(|| "Knot vault is locked".to_string())? - .save_record(SEARCH_INDEX_PATH, index) - .map_err(|error| format!("could not save Knot vault search index: {error}")) - } - - /// Unseal the derived vault search index while the vault is unlocked. - pub fn load_search_index(&self) -> Result>, String> { - self.store - .as_ref() - .ok_or_else(|| "Knot vault is locked".to_string())? - .load_record(SEARCH_INDEX_PATH) - .map_err(|error| format!("could not load Knot vault search index: {error}")) - } - - /// Seal one non-authoritative derived-cache record beside the source - /// index. The record id is keyed with vault material before it becomes a - /// path, so it can neither escape the namespace nor act as a public - /// dictionary oracle for document ids. - pub(crate) fn store_derived_cache(&self, id: &str, value: &T) -> Result<(), String> - where - T: Serialize, - { - let bytes = serde_json::to_vec(value) - .map_err(|error| format!("could not encode Knot derived cache: {error}"))?; - let path = self.derived_cache_path(id)?; - self.store - .as_ref() - .ok_or_else(|| "Knot vault is locked".to_string())? - .save_record( - path, - &DerivedCacheBlob { - version: DERIVED_CACHE_VERSION, - bytes, - }, - ) - .map_err(|error| format!("could not seal Knot derived cache: {error}")) - } - - /// Open one derived-cache record while the source vault is unlocked. - pub(crate) fn load_derived_cache(&self, id: &str) -> Result, String> - where - T: DeserializeOwned, - { - let path = self.derived_cache_path(id)?; - let blob = self - .store - .as_ref() - .ok_or_else(|| "Knot vault is locked".to_string())? - .load_record::(path) - .map_err(|error| format!("could not unseal Knot derived cache: {error}"))?; - let Some(blob) = blob else { - return Ok(None); - }; - if blob.version != DERIVED_CACHE_VERSION { - return Ok(None); - } - serde_json::from_slice(&blob.bytes) - .map(Some) - .map_err(|error| format!("could not decode Knot derived cache: {error}")) - } - - fn delete_derived_cache(&self, id: &str) -> Result<(), String> { - let path = self.derived_cache_path(id)?; - self.store - .as_ref() - .ok_or_else(|| "Knot vault is locked".to_string())? - .delete_record(path) - .map_err(|error| format!("could not delete Knot derived cache: {error}")) - } - - fn derived_cache_path(&self, id: &str) -> Result { - let key = self - .sync_key - .as_ref() - .ok_or_else(|| "Knot vault is locked".to_string())?; - let path_key = Zeroizing::new(blake3::derive_key(DERIVED_CACHE_PATH_CONTEXT, &**key)); - let digest = blake3::keyed_hash(&*path_key, id.as_bytes()); - Ok(PathBuf::from(format!( - "knot/derived-cache/{}.json", - digest.to_hex() - ))) - } - - pub(crate) fn seal_sync_payload( - &self, - aad: &[u8], - plaintext: &[u8], - ) -> Result, String> { - let key = self - .sync_key - .as_ref() - .ok_or_else(|| "Knot vault is locked".to_string())?; - seal_bytes(key, aad, plaintext) - .map_err(|error| format!("could not seal Knot sync payload: {error}")) - } - - pub(crate) fn unseal_sync_payload( - &self, - aad: &[u8], - ciphertext: &[u8], - ) -> Result, String> { - let key = self - .sync_key - .as_ref() - .ok_or_else(|| "Knot vault is locked".to_string())?; - unseal_bytes(key, aad, ciphertext) - .map_err(|error| format!("could not unseal Knot sync payload: {error}")) - } - - /// Drop decrypted document state and the root key. - pub fn lock(&mut self) { - self.index.zeroize(); - self.index = VaultIndex::default(); - self.store = None; - self.sync_key = None; - } - - /// Re-open a locked vault with an already-recovered root key. - pub fn unlock(&mut self, key: [u8; 32]) -> Result<(), String> { - if !self.is_locked() { - return Ok(()); - } - let store = SealedRecordStorage::open_with_key(&self.root, key); - let index = store - .load_record::(INDEX_PATH) - .map_err(|error| format!("could not unlock Knot vault: {error}"))? - .ok_or_else(|| "Knot vault index is absent".to_string())?; - if index.version != INDEX_VERSION { - return Err(format!( - "unsupported Knot vault index version {}", - index.version - )); - } - self.store = Some(store); - self.sync_key = Some(Zeroizing::new(blake3::derive_key(SYNC_KEY_CONTEXT, &key))); - self.index = index; - Ok(()) - } - - fn require_unlocked(&self) -> Result<(), String> { - if self.is_locked() { - Err("Knot vault is locked".into()) - } else { - Ok(()) - } - } - - fn save(&self) -> Result<(), String> { - self.store - .as_ref() - .ok_or_else(|| "Knot vault is locked".to_string())? - .save_record(INDEX_PATH, &self.index) - .map_err(|error| format!("could not save Knot vault: {error}")) - } -} - -impl Drop for KnotVault { - fn drop(&mut self) { - self.index.zeroize(); - } -} - -#[cfg(test)] -mod tests { - use std::fs; - - use tempfile::tempdir; - - use super::*; - - fn note() -> VaultDocument { - VaultDocument { - id: "field-note".into(), - title: "Field note".into(), - body: b"private field observation".to_vec(), - media_type: "text/vnd.knot".into(), - } - } - - #[test] - fn sealed_document_survives_reopen_without_plaintext_on_disk() { - let temp = tempdir().unwrap(); - let key = [0x71; 32]; - let mut vault = KnotVault::open(temp.path(), key).unwrap(); - vault.put(note()).unwrap(); - drop(vault); - - let sealed = fs::read(temp.path().join(INDEX_PATH)).unwrap(); - assert!( - !sealed - .windows(b"private field observation".len()) - .any(|window| window == b"private field observation") - ); - - let vault = KnotVault::open(temp.path(), key).unwrap(); - assert_eq!( - vault.body("field-note"), - Some(&b"private field observation"[..]) - ); - } - - #[test] - fn lock_drops_documents_and_wrong_key_cannot_unlock() { - let temp = tempdir().unwrap(); - let key = [0x72; 32]; - let mut vault = KnotVault::open(temp.path(), key).unwrap(); - vault.put(note()).unwrap(); - vault.lock(); - assert!(vault.is_locked()); - assert!(vault.documents().next().is_none()); - assert!(vault.body("field-note").is_none()); - assert!(vault.unlock([0x73; 32]).is_err()); - assert!(vault.unlock(key).is_ok()); - assert_eq!( - vault.body("field-note"), - Some(&b"private field observation"[..]) - ); - } - - #[test] - fn removing_a_projected_document_collects_its_derived_cache() { - let temp = tempdir().unwrap(); - let mut vault = KnotVault::open(temp.path(), [0x74; 32]).unwrap(); - vault.put(note()).unwrap(); - vault - .store_derived_cache("field-note", &"cached result".to_string()) - .unwrap(); - let public_id_hash = blake3::hash(b"field-note").to_hex(); - assert!( - !temp - .path() - .join(format!("knot/derived-cache/{public_id_hash}.json")) - .exists(), - "cache paths must not expose a public dictionary hash of the document id" - ); - assert_eq!( - vault.load_derived_cache::("field-note").unwrap(), - Some("cached result".into()) - ); - - assert!(vault.replace_projection(Vec::new()).unwrap()); - assert_eq!( - vault.load_derived_cache::("field-note").unwrap(), - None - ); - } -} diff --git a/ports/knot/src/watcher.rs b/ports/knot/src/watcher.rs deleted file mode 100644 index 3e1b046c9..000000000 --- a/ports/knot/src/watcher.rs +++ /dev/null @@ -1,210 +0,0 @@ -// Copyright 2026 Mark Alan Boykin -// 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 https://mozilla.org/MPL/2.0/. -// SPDX-License-Identifier: MPL-2.0 - -//! Debounced OS watching under a revocable Servitor grant. - -use std::path::Path; -use std::sync::mpsc::{self, Receiver, TryRecvError}; - -use chartulary::{Container, EditSpec, GraphLog, Relation}; -use notify::{Event, RecommendedWatcher, RecursiveMode, Watcher}; -use personae::IdentityProvider; -use servitor::{AuthorityProvider, Cap, Gate, Grant, Mode, ScopePath, Subject}; - -const WATCH_SCOPE: &str = "watch"; - -struct RevocableAuthority { - grant: Grant, - enabled: bool, -} - -impl AuthorityProvider for RevocableAuthority { - fn covers(&self, subject: Subject, needed: &Cap, mode: Mode) -> bool { - self.enabled && self.grant.covers(subject, needed, mode) - } -} - -/// An OS watcher whose event batches are journalled through Servitor before -/// the endpoint refreshes directory state. -pub struct DirectoryWatcher { - _watcher: RecommendedWatcher, - events: Receiver>, - subject: Subject, - scope: ScopePath, - authority: RevocableAuthority, - gate: Gate, - audit: GraphLog, - next_batch: u64, -} - -impl DirectoryWatcher { - /// Watch `root` recursively under a key derived from the host identity. - pub fn new(root: &Path, identity: &impl IdentityProvider) -> Result { - let salt = format!("knot/directory-watcher/{}", root.to_string_lossy()); - let key = identity - .derive_keypair(salt.as_bytes()) - .map_err(|error| format!("could not derive Knot watcher identity: {error:?}"))?; - let subject = Subject::new(key.public_key().to_bytes()); - let scope = ScopePath::parse(WATCH_SCOPE) - .map_err(|error| format!("invalid Knot watcher scope: {error:?}"))?; - let grant = Grant::new(subject, Cap::Scope(scope.clone()), Mode::Write); - let authority = RevocableAuthority { - grant: grant.clone(), - enabled: true, - }; - let gate = Gate::new(); - let mut audit = GraphLog::new(); - gate.project_grant(&mut audit, &grant) - .map_err(|error| format!("could not project Knot watcher grant: {error:?}"))?; - - let (sender, events) = mpsc::channel(); - let mut watcher = notify::recommended_watcher(move |event| { - let _ = sender.send(event); - }) - .map_err(|error| format!("could not create Knot directory watcher: {error}"))?; - watcher - .watch(root, RecursiveMode::Recursive) - .map_err(|error| format!("could not watch {}: {error}", root.display()))?; - - Ok(Self { - _watcher: watcher, - events, - subject, - scope, - authority, - gate, - audit, - next_batch: 0, - }) - } - - /// The keyholder identity attributed to watcher journal transitions. - pub fn subject(&self) -> Subject { - self.subject - } - - /// Whether the watcher currently holds its observation grant. - pub fn is_enabled(&self) -> bool { - self.authority.enabled - } - - /// Revoke observation without stopping the endpoint or OS watcher. - pub fn revoke(&mut self) { - self.authority.enabled = false; - } - - /// Restore the watcher grant. - pub fn grant(&mut self) { - self.authority.enabled = true; - } - - /// Attributed watcher transitions, including the gate-authored grant - /// projection at revision one. - pub fn audit(&self) -> &GraphLog { - &self.audit - } - - /// Drain every queued OS event into at most one attributed journal batch. - /// - /// The returned count is the number of raw events collapsed. Errors from - /// the watcher are surfaced before any transition is committed. - pub fn drain(&mut self) -> Result { - let mut count = 0usize; - loop { - match self.events.try_recv() { - Ok(Ok(_event)) => count += 1, - Ok(Err(error)) => { - return Err(format!("Knot directory watch failed: {error}")); - } - Err(TryRecvError::Empty | TryRecvError::Disconnected) => break, - } - } - if count == 0 || !self.authority.enabled { - return Ok(0); - } - self.record_batch(count)?; - Ok(count) - } - - fn record_batch(&mut self, count: usize) -> Result<(), String> { - let id = format!("{WATCH_SCOPE}/events/{}", self.next_batch); - self.next_batch = self.next_batch.saturating_add(1); - let node = Container::new(id) - .with_title(format!("{count} filesystem events")) - .with_tag("knot.watcher") - .with_tag(format!("event-count:{count}")); - let expected = self.audit.revision(); - self.gate - .petition( - &self.authority, - &mut self.audit, - self.subject, - &self.scope, - expected, - vec![EditSpec::InsertNode(node)], - ) - .map_err(|error| format!("Knot watcher petition failed: {error:?}"))?; - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use std::fs; - use std::thread; - use std::time::{Duration, Instant}; - - use personae::InMemoryProvider; - use tempfile::tempdir; - - use super::*; - - fn wait_for_transition(watcher: &mut DirectoryWatcher, after: u64) { - let deadline = Instant::now() + Duration::from_secs(3); - while Instant::now() < deadline { - watcher.drain().unwrap(); - if watcher.audit().revision() > after { - return; - } - thread::sleep(Duration::from_millis(20)); - } - panic!("filesystem event did not reach the Knot watcher"); - } - - #[test] - fn os_events_commit_once_under_the_watcher_subject() { - let temp = tempdir().unwrap(); - let identity = InMemoryProvider::from_seed([0x51; 32]); - let mut watcher = DirectoryWatcher::new(temp.path(), &identity).unwrap(); - let before = watcher.audit().revision(); - - fs::write(temp.path().join("field.knot"), "one").unwrap(); - wait_for_transition(&mut watcher, before); - - assert_eq!(watcher.audit().revision(), before + 1); - let batch = watcher.audit().log().entries().last().unwrap(); - assert_eq!(batch.author, watcher.subject().to_author()); - } - - #[test] - fn revocation_discards_events_without_stopping_the_watcher() { - let temp = tempdir().unwrap(); - let identity = InMemoryProvider::from_seed([0x52; 32]); - let mut watcher = DirectoryWatcher::new(temp.path(), &identity).unwrap(); - let before = watcher.audit().revision(); - watcher.revoke(); - - fs::write(temp.path().join("paused.knot"), "paused").unwrap(); - thread::sleep(Duration::from_millis(100)); - assert_eq!(watcher.drain().unwrap(), 0); - assert_eq!(watcher.audit().revision(), before); - - watcher.grant(); - fs::write(temp.path().join("resumed.knot"), "resumed").unwrap(); - wait_for_transition(&mut watcher, before); - assert_eq!(watcher.audit().revision(), before + 1); - } -} diff --git a/ports/knot/src/web_annotation.rs b/ports/knot/src/web_annotation.rs deleted file mode 100644 index e28c0b12d..000000000 --- a/ports/knot/src/web_annotation.rs +++ /dev/null @@ -1,132 +0,0 @@ -// Copyright 2026 Mark Alan Boykin -// 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 https://mozilla.org/MPL/2.0/. -// SPDX-License-Identifier: MPL-2.0 - -//! W3C Web Annotation target serialization for Fleece text evidence. -//! -//! Knot owns the source identity. Fleece supplies only selectors over its -//! documented text stream, so this module deliberately accepts the source URI -//! from the caller and emits its quote and position descriptions as siblings. - -use fleece::TextAnchor; -use serde::Serialize; - -/// A W3C Web Annotation `SpecificResource` target. -#[derive(Clone, Debug, PartialEq, Eq, Serialize)] -pub struct SpecificResource { - #[serde(rename = "type")] - resource_type: &'static str, - pub source: String, - pub selector: Vec, -} - -/// The two alternative selector descriptions carried by a Fleece anchor. -#[derive(Clone, Debug, PartialEq, Eq, Serialize)] -#[serde(tag = "type")] -pub enum SpecificResourceSelector { - TextQuoteSelector { - exact: String, - prefix: String, - suffix: String, - }, - TextPositionSelector { - start: u64, - end: u64, - }, -} - -impl SpecificResource { - /// Serialize one Fleece anchor as sibling Web Annotation selectors. - /// - /// This is intentionally not an Annotation: Knot's caller still owns a - /// body, motivation, persistence, and the source resource identity. - pub fn from_fleece_anchor(source: impl Into, anchor: &TextAnchor) -> Self { - Self { - resource_type: "SpecificResource", - source: source.into(), - selector: vec![ - SpecificResourceSelector::TextQuoteSelector { - exact: anchor.quote.exact.clone(), - prefix: anchor.quote.prefix.clone(), - suffix: anchor.quote.suffix.clone(), - }, - SpecificResourceSelector::TextPositionSelector { - start: anchor.position.start, - end: anchor.position.end, - }, - ], - } - } -} - -#[cfg(test)] -mod tests { - use fleece::{TextAnchor, TextPositionSelector, TextQuoteSelector}; - - use super::SpecificResource; - - const DOCUMENT: &str = include_str!("../tests/fixtures/fleece_specific_resource.txt"); - - fn code_point_offset(document: &str, byte_offset: usize) -> u64 { - document[..byte_offset].chars().count() as u64 - } - - fn resolve_position(document: &str, start: u64, end: u64) -> String { - document - .chars() - .skip(start as usize) - .take((end - start) as usize) - .collect() - } - - fn resolve_quote(document: &str, quote: &TextQuoteSelector) -> Vec<(u64, u64)> { - document - .match_indices("e.exact) - .filter(|(byte_offset, _)| { - document[..*byte_offset].ends_with("e.prefix) - && document[*byte_offset + quote.exact.len()..].starts_with("e.suffix) - }) - .map(|(byte_offset, _)| { - let start = code_point_offset(document, byte_offset); - (start, start + quote.exact.chars().count() as u64) - }) - .collect() - } - - #[test] - fn serializes_and_independently_resolves_sibling_fleece_selectors() { - let document = DOCUMENT.trim_end(); - let exact = "Repeat this sentence."; - let byte_start = document.match_indices(exact).nth(1).unwrap().0; - let start = code_point_offset(document, byte_start); - let anchor = TextAnchor { - position: TextPositionSelector { - start, - end: start + exact.chars().count() as u64, - }, - quote: TextQuoteSelector { - exact: exact.to_string(), - prefix: "Middle. ".to_string(), - suffix: " End.".to_string(), - }, - }; - - let target = SpecificResource::from_fleece_anchor("https://example.test/article", &anchor); - let serialized = serde_json::to_value(&target).unwrap(); - assert_eq!(serialized["type"], "SpecificResource"); - assert_eq!(serialized["source"], "https://example.test/article"); - assert_eq!(serialized["selector"].as_array().unwrap().len(), 2); - assert!(serialized.get("refinedBy").is_none()); - - assert_eq!( - resolve_position(document, anchor.position.start, anchor.position.end), - anchor.quote.exact - ); - assert_eq!( - resolve_quote(document, &anchor.quote), - vec![(anchor.position.start, anchor.position.end)] - ); - } -} diff --git a/ports/knot/src/writer.rs b/ports/knot/src/writer.rs deleted file mode 100644 index 5f812c750..000000000 --- a/ports/knot/src/writer.rs +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright 2026 Mark Alan Boykin -// 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 https://mozilla.org/MPL/2.0/. -// SPDX-License-Identifier: MPL-2.0 - -//! Compatibility names for the document authority now packaged separately. - -pub use knot_document::{AuthoredFile, DocumentFormat, SaveOutcome}; - -#[doc(hidden)] -pub(crate) use knot_document::write_if_distinct; diff --git a/ports/knot/tests/fixtures/fleece_specific_resource.txt b/ports/knot/tests/fixtures/fleece_specific_resource.txt deleted file mode 100644 index 2c7d3b2ad..000000000 --- a/ports/knot/tests/fixtures/fleece_specific_resource.txt +++ /dev/null @@ -1 +0,0 @@ -Intro. Repeat this sentence. Middle. Repeat this sentence. End. diff --git a/ports/knot/tests/place_projection.rs b/ports/knot/tests/place_projection.rs deleted file mode 100644 index 29c1ed041..000000000 --- a/ports/knot/tests/place_projection.rs +++ /dev/null @@ -1,421 +0,0 @@ -// Copyright 2026 Mark Alan Boykin -// 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 https://mozilla.org/MPL/2.0/. -// SPDX-License-Identifier: MPL-2.0 - -//! K2: place members edit a document its holder keeps. -//! -//! Option A, end to end, with nothing stubbed. The holder owns the vault and -//! every byte on disk; a visitor never holds a replica, only a live projection -//! it edits by sending intents. Real `KnotEndpoint`s over a real directory, -//! registered in the resident host's catalog, admitted over a transport, and -//! driven by the blocking network carrier through the ordinary -//! `RetainedEndpointSession`. -//! -//! ## What the memory transport stands in for -//! -//! Each visitor gets its own paired transport, so each carries a distinct -//! authenticated subject with its own grant: these are two peers, not one peer -//! twice. The holder therefore holds two transports rather than one accepting -//! many, which is an artifact of a fixture that pairs exactly two nodes. The -//! host takes the transport per accept and is indifferent to how many it gets. - -use std::fs; -use std::sync::{Arc, Barrier}; -use std::time::Duration; - -use chirograph::{ - CapabilityProfile, IntentResult, PresentationCapability, ProjectionSession, ResumeRequest, - SaveTextV1, SessionStatus, -}; -use graphshell::admission::{CONNECT_ACTION, GRAPHSHELL_DOMAIN, PROJECTION_SERVICE, open_session}; -use graphshell::carrier::{accept_projection_session, projection_policy}; -use graphshell::client::{ResolvedContent, RetainedEndpointSession}; -use graphshell::lifecycle::SessionAuthority; -use graphshell::native::endpoint_catalog::{ResidentEndpointCatalog, ResidentEndpointRoute}; -use graphshell::native::projection_host::ResidentProjectionHost; -use graphshell::network_carrier::{ - CarrierRuntime, NetworkCarrier, dial_projection_session, projection_binding, -}; -use graphshell::session_notices::serve_admitted_session_notifying; -use graphshell_endpoint::ResumableProjectionSource; -use notochord::{ - LocalNetworkPolicy, NetworkId, ProfileRef, RevocationLedger, TrafficClass, TrustedRoot, -}; -use personae::delegation::{ - CapabilityScope, DelegationCertificate, DelegationParent, SignedDelegationCertificate, -}; -use personae::{IdentityProvider, InMemoryProvider}; -use tempfile::tempdir; -use transport::PeerID; -use transport::memory::MemoryTransport; - -const NETWORK: NetworkId = NetworkId([3; 32]); -const ROOT_AUTHORITY: [u8; 32] = [7; 32]; -const NOW_MS: u64 = 50; -const DOCUMENT: &str = "field.knot"; - -/// The holder: owns the vault, issues the grants, answers for every byte. -fn holder() -> InMemoryProvider { - InMemoryProvider::from_seed([1; 32]) -} - -fn profile_ref() -> ProfileRef { - ProfileRef { - id: "mere.base".into(), - revision: 1, - } -} - -fn grant(subject: [u8; 32]) -> SignedDelegationCertificate { - SignedDelegationCertificate::issue( - &holder(), - DelegationCertificate::new( - DelegationParent::Root(ROOT_AUTHORITY), - holder().master_public_key().to_bytes(), - subject, - CapabilityScope { - domain: GRAPHSHELL_DOMAIN.into(), - resource: NETWORK.0.to_vec(), - path_prefix: PROJECTION_SERVICE.into(), - actions: [CONNECT_ACTION.to_string()].into_iter().collect(), - }, - 5, - 10, - Some(NOW_MS + 3_600_000), - 1, - [1; 32], - ), - ) - .expect("issue certificate") -} - -fn policy() -> LocalNetworkPolicy { - projection_policy( - NETWORK, - vec![TrustedRoot { - authority: ROOT_AUTHORITY, - issuer: holder().master_public_key().to_bytes(), - }], - vec![profile_ref()], - None, - ) -} - -fn viewing_profile() -> CapabilityProfile { - CapabilityProfile::new([ - PresentationCapability::EditableText, - PresentationCapability::PortableCard, - ]) -} - -/// One visitor's transport pair with the holder, keyed so the claimed subject -/// is the peer the carrier proved. -fn pairing( - visitor: &InMemoryProvider, - holder_tag: u8, -) -> (MemoryTransport, MemoryTransport, PeerID, PeerID) { - let subject = visitor.master_public_key().to_bytes(); - let visitor_peer = PeerID::from_bytes(&subject).expect("visitor peer"); - let mut holder_bytes = holder().master_public_key().to_bytes(); - holder_bytes[0] = holder_tag; - let holder_peer = PeerID::from_bytes(&holder_bytes).expect("holder peer"); - let (server, client) = MemoryTransport::pair(holder_peer, visitor_peer); - (server, client, holder_peer, visitor_peer) -} - -/// Dial the holder and mount the vault as this visitor sees it. -/// -/// The session never leaves the thread that opens it and never has to: -/// `Box` is not `Send` by deliberate choice, so a carrier belongs -/// to its own thread. Only plain data crosses back out. -fn mount( - client: &MemoryTransport, - holder_peer: PeerID, - visitor_peer: PeerID, - visitor: &InMemoryProvider, - nonce: [u8; 32], - handle: tokio::runtime::Handle, -) -> (RetainedEndpointSession, ProjectionSession) { - let subject = visitor.master_public_key().to_bytes(); - let hello = open_session( - visitor, - NETWORK, - profile_ref(), - TrafficClass::Interactive, - nonce, - &projection_binding(visitor_peer), - vec![grant(subject)], - ) - .expect("hello"); - let stream = handle - .block_on(dial_projection_session( - client, - holder_peer, - &hello, - &policy().limits, - )) - .expect("dial") - .expect("the holder admits this visitor"); - let carrier = NetworkCarrier::over(stream, CarrierRuntime::borrowed(handle)); - let mut retained = RetainedEndpointSession::over(Box::new(carrier), viewing_profile()) - .expect("discover the holder's endpoint"); - let session = retained.mount(0).expect("mount the projected vault"); - (retained, session) -} - -/// The document as this visitor currently sees it, with the token that makes a -/// save revision-checked. -fn read_document( - retained: &mut RetainedEndpointSession, - session: &ProjectionSession, -) -> ( - sceno::InstanceId, - String, - Vec, - chirograph::AdvertisedAction, -) { - retained - .resolve_all(session) - .expect("resolve the projection") - .into_iter() - .find_map(|(target, presentation)| match presentation.content { - ResolvedContent::EditableText(editable) if editable.address.ends_with(DOCUMENT) => { - Some(( - target, - editable.source, - editable.base_token, - presentation.semantics.actions[0].clone(), - )) - } - _ => None, - }) - .expect("the holder disclosed the document as editable source") -} - -/// A resident host serving one vault on one route. -fn vault_host(root: std::path::PathBuf) -> ResidentProjectionHost { - let mut catalog = ResidentEndpointCatalog::new(); - catalog - .register_resumable_notifying("knot", "Knot", move |_| { - // The admitted context is deliberately unused: a Knot projection is - // identified by the vault it serves, not by who is looking at it. - // Authority is the holder's under Option A, so the write grant is - // the holder's own rather than anything a visitor presented. - knot_editor::KnotEndpoint::open_writable(&root, knot_editor::KnotWriteGrant::new(4096)) - .map_err(|error| error.to_string()) - }) - .expect("register the vault route"); - ResidentProjectionHost::new( - policy(), - ResidentEndpointRoute::new("knot", Duration::from_millis(10)).expect("route"), - catalog, - ) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 8)] -async fn two_place_members_edit_one_held_document_through_projection() { - let vault = tempdir().unwrap(); - let path = vault.path().join(DOCUMENT); - fs::write(&path, "# Field\n").unwrap(); - - let ada = InMemoryProvider::from_seed([4; 32]); - let bo = InMemoryProvider::from_seed([9; 32]); - let (ada_server, ada_client, ada_holder_peer, ada_peer) = pairing(&ada, 0xa1); - let (bo_server, bo_client, bo_holder_peer, bo_peer) = pairing(&bo, 0xb2); - let mut host = vault_host(vault.path().to_path_buf()); - - // Both visitors mount before either edits, so the one who is not editing - // has an acknowledgement to resume from. - let mounted = Arc::new(Barrier::new(2)); - let handle = tokio::runtime::Handle::current(); - - let ada_barrier = Arc::clone(&mounted); - let ada_handle = handle.clone(); - let ada_thread = tokio::task::spawn_blocking(move || { - let (mut retained, session) = mount( - &ada_client, - ada_holder_peer, - ada_peer, - &ada, - [21; 32], - ada_handle, - ); - let (target, source, token, action) = read_document(&mut retained, &session); - ada_barrier.wait(); - - // Ada edits. She holds no replica: this is an intent the holder runs. - let result = retained - .invoke( - &session, - target, - &action, - &SaveTextV1 { - base_token: token, - source: "# Field\n\nAda was here.\n".into(), - }, - ) - .expect("submit the save"); - retained.close().expect("close Ada's session"); - (source, result) - }); - - let bo_barrier = Arc::clone(&mounted); - let bo_handle = handle.clone(); - let bo_thread = tokio::task::spawn_blocking(move || { - let (mut retained, session) = mount( - &bo_client, - bo_holder_peer, - bo_peer, - &bo, - [22; 32], - bo_handle, - ); - let (_, before, _, _) = read_document(&mut retained, &session); - bo_barrier.wait(); - - // Bo asked for nothing. The holder rings, and the ordinary resume path - // brings Ada's edit to him. - let heard = retained.wait_for_change().expect("Bo hears the bell"); - let (_, after, _, _) = read_document(&mut retained, &session); - retained.close().expect("close Bo's session"); - (before, heard, after) - }); - - let ada_served = host - .accept_one(&ada_server, || NOW_MS) - .await - .expect("accept Ada") - .expect("Ada is admitted"); - let bo_served = host - .accept_one(&bo_server, || NOW_MS) - .await - .expect("accept Bo") - .expect("Bo is admitted"); - assert_ne!( - ada_served.subject(), - bo_served.subject(), - "two peers, not one peer twice" - ); - - let (ada_saw, saved) = ada_thread.await.unwrap(); - let (bo_before, bo_heard, bo_after) = bo_thread.await.unwrap(); - ada_served.finished().await.expect("join").expect("served"); - bo_served.finished().await.expect("join").expect("served"); - - assert_eq!(ada_saw, "# Field\n", "Ada opened the holder's document"); - assert_eq!(bo_before, "# Field\n", "so did Bo"); - assert_eq!(saved, IntentResult::Accepted, "the holder took Ada's edit"); - assert!(bo_heard, "the bell carried a revision Bo had not seen"); - assert_eq!( - bo_after, "# Field\n\nAda was here.\n", - "Bo sees what the holder holds" - ); - assert_eq!( - fs::read_to_string(&path).unwrap(), - "# Field\n\nAda was here.\n", - "and the holder's own file is the truth both were reading" - ); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 6)] -async fn a_visitor_whose_holder_goes_away_is_told_rather_than_shown_a_stale_copy() { - // The done condition's third clause. A visitor holds no replica, so when - // the holder is gone there is nothing to fall back to and the honest thing - // is to say so. What must not happen is a scene that still reads Live, - // which would offer a save that can never land. - let vault = tempdir().unwrap(); - let path = vault.path().join(DOCUMENT); - fs::write(&path, "# Field\n").unwrap(); - - let ada = InMemoryProvider::from_seed([4; 32]); - let (ada_server, ada_client, ada_holder_peer, ada_peer) = pairing(&ada, 0xa1); - - let (mounted_tx, mounted_rx) = std::sync::mpsc::channel(); - let (gone_tx, gone_rx) = std::sync::mpsc::channel(); - let handle = tokio::runtime::Handle::current(); - - let visitor = tokio::task::spawn_blocking(move || { - let (mut retained, session) = mount( - &ada_client, - ada_holder_peer, - ada_peer, - &ada, - [21; 32], - handle, - ); - let (_, source, _, _) = read_document(&mut retained, &session); - let live = retained.client().mounted(&session).unwrap().status; - mounted_tx.send(()).unwrap(); - - gone_rx.recv().unwrap(); - // Asking anything at all is enough to learn the holder is gone. - let refused = retained.resnapshot(&session).unwrap_err(); - let after = retained.client().mounted(&session).unwrap().status; - // The scene is kept, so a host can still show what was there. - let still_there = retained - .client() - .mounted(&session) - .map(|scene| scene.scene.active_item_count()) - .unwrap_or_default(); - (source, live, refused, after, still_there) - }); - - // Serve until the visitor has mounted, then stop being the holder. - let mut admitted = accept_projection_session( - &ada_server, - &policy(), - &RevocationLedger::default(), - NOW_MS, - 0, - ) - .await - .expect("accept") - .expect("admitted"); - let authority = SessionAuthority::retain_admitted(&admitted); - let revocations = std::sync::RwLock::new(RevocationLedger::new()); - let mut endpoint = knot_editor::KnotEndpoint::open_writable( - vault.path(), - knot_editor::KnotWriteGrant::new(4096), - ) - .expect("open the vault"); - let mut resume = |endpoint: &mut knot_editor::KnotEndpoint, request: ResumeRequest| { - ResumableProjectionSource::resume(endpoint, request).map_err(|error| error.to_string()) - }; - let waiting = tokio::task::spawn_blocking(move || mounted_rx.recv().unwrap()); - tokio::select! { - _ = serve_admitted_session_notifying( - &mut admitted, - &authority, - &revocations, - &mut endpoint, - &mut resume, - || NOW_MS, - Duration::from_millis(10), - ) => panic!("the visitor did not close this session"), - _ = waiting => {} - } - // The holder goes away: its stream, its endpoint, and its transport. - drop(admitted); - drop(endpoint); - drop(ada_server); - gone_tx.send(()).unwrap(); - - let (source, live, refused, after, still_there) = visitor.await.unwrap(); - assert_eq!(source, "# Field\n", "the visitor saw the holder's document"); - assert_eq!(live, SessionStatus::Live, "and it was live while served"); - assert!( - refused.contains("no longer reachable"), - "the refusal names the cause: {refused}" - ); - assert_eq!( - after, - SessionStatus::Disconnected, - "the visitor is told, rather than left holding a Live scene it cannot save" - ); - assert!( - still_there > 0, - "the scene is kept so a host can show what was there, just not offer to save it" - ); -} diff --git a/ports/knot/tests/resident_app_route.rs b/ports/knot/tests/resident_app_route.rs deleted file mode 100644 index ea237afae..000000000 --- a/ports/knot/tests/resident_app_route.rs +++ /dev/null @@ -1,101 +0,0 @@ -// Copyright 2026 Mark Alan Boykin -// 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 https://mozilla.org/MPL/2.0/. -// SPDX-License-Identifier: MPL-2.0 - -//! R1 receipt: a first-party Turnstone client opens Knot through the resident -//! application door, after both local admission and an app-to-route grant. - -use std::sync::Arc; -use std::time::Duration; - -use graphshell::identity::VaultProtectionView; -use graphshell::native::app_admission::{AllowedAppRoutes, AppId, AppRouteId}; -use graphshell::native::app_broker::{AppEndpointCatalog, serve_app_broker}; -use graphshell::native::app_client::AppBrokerClient; -use graphshell::native::endpoint_catalog::{ResidentEndpointCatalog, ResidentEndpointRoute}; -use graphshell::native::personae_host::PersonaeHost; -use personae::{Ed25519Keypair, IdentityVault, InMemoryStorage, Profile, ProfileId}; - -fn resident_host() -> Arc> { - let profile = Profile::new( - ProfileId("default".into()), - "Default", - Ed25519Keypair::from_seed([0xA1; 32]), - ); - Arc::new(PersonaeHost::new( - IdentityVault::with_profile(InMemoryStorage::new(), profile), - None, - VaultProtectionView::Ephemeral, - )) -} - -#[tokio::test(flavor = "multi_thread")] -async fn turnstone_opens_the_in_memory_knot_route() { - let mut catalog = ResidentEndpointCatalog::new(); - catalog - .register("knot", "Knot fixture", |_| { - Ok(knot_editor::KnotEndpoint::fixture()) - }) - .unwrap(); - let route = ResidentEndpointRoute::new("knot", Duration::from_millis(10)).unwrap(); - let grants = AllowedAppRoutes::new([(AppId::new("turnstone"), route)]); - - #[cfg(windows)] - let endpoint = format!(r"\\.\pipe\graphshell-knot-route-{}", uuid::Uuid::new_v4()); - #[cfg(not(windows))] - let endpoint = std::env::temp_dir() - .join(format!( - "graphshell-knot-route-{}.sock", - uuid::Uuid::new_v4() - )) - .display() - .to_string(); - - let server_endpoint = endpoint.clone(); - let server = tokio::spawn(async move { - let _ = serve_app_broker( - &server_endpoint, - resident_host(), - grants, - 60_000, - None, - AppEndpointCatalog::new(catalog), - ) - .await; - }); - - let mut client = None; - let mut last_error = String::new(); - for _ in 0..50 { - match AppBrokerClient::open_route_at( - &endpoint, - AppId::new("turnstone"), - AppRouteId::new("knot").unwrap(), - ) - .await - { - Ok(open) => { - client = Some(open); - break; - } - Err(error) => { - last_error = error.to_string(); - tokio::time::sleep(Duration::from_millis(20)).await; - } - } - } - let mut client = - client.unwrap_or_else(|| panic!("the Knot route never opened, last: {last_error}")); - let opened = client.open_session().await.unwrap(); - let request = opened.descriptor.projections[0].request.clone(); - let snapshot = client.snapshot(request).await.unwrap(); - assert_eq!( - snapshot.scene.active_item_count(), - 3, - "the selected endpoint is Knot's deterministic fixture", - ); - client.close().await.unwrap(); - server.abort(); -} diff --git a/ports/knot/tests/resident_ownership.rs b/ports/knot/tests/resident_ownership.rs deleted file mode 100644 index 7ee99afdc..000000000 --- a/ports/knot/tests/resident_ownership.rs +++ /dev/null @@ -1,47 +0,0 @@ -#![cfg(windows)] - -// Copyright 2026 Mark Alan Boykin -// 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 https://mozilla.org/MPL/2.0/. -// SPDX-License-Identifier: MPL-2.0 - - -use knot_editor::{StartupUnlockedPersonalVault, local_device_root, personal_vault_writer}; -use pandect::{DeviceSettings, save_device_settings, wallet_store}; -use personae::PersonaId; -use tempfile::tempdir; - -#[test] -fn second_owner_is_refused_while_pairing_facts_remain_available() { - let root = tempdir().unwrap(); - let persona = PersonaId::new(); - save_device_settings( - root.path(), - &DeviceSettings { - startup_unlock_mode: personae::StartupUnlockMode::AutoOs, - ..Default::default() - }, - ) - .unwrap(); - wallet_store::ensure_wallet_state(root.path(), persona, "Knot resident receipt").unwrap(); - let device = local_device_root(root.path(), "Knot resident receipt").unwrap(); - - let owner = StartupUnlockedPersonalVault::open(root.path(), persona, device, []).unwrap(); - let duplicate = match StartupUnlockedPersonalVault::open(root.path(), persona, device, []) { - Ok(_) => panic!("a second persona owner must be refused promptly"), - Err(error) => error, - }; - assert!( - duplicate.contains("another resident may already own this persona"), - "{duplicate}" - ); - assert_eq!( - personal_vault_writer(root.path(), persona, device).unwrap(), - owner.writer(), - "pairing facts must not reopen the resident-owned Knot stores", - ); - - drop(owner); - drop(StartupUnlockedPersonalVault::open(root.path(), persona, device, []).unwrap()); -} diff --git a/ports/knot/tests/revision_bell.rs b/ports/knot/tests/revision_bell.rs deleted file mode 100644 index b2329e4ad..000000000 --- a/ports/knot/tests/revision_bell.rs +++ /dev/null @@ -1,243 +0,0 @@ -// Copyright 2026 Mark Alan Boykin -// 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 https://mozilla.org/MPL/2.0/. -// SPDX-License-Identifier: MPL-2.0 - -use std::ffi::OsStr; -use std::fs; - -use chirograph::{ - CapabilityProfile, CarrierRequestBody, CarrierResponseBody, IntentResult, - PresentationCapability, ResumeReply, ResumeRequest, SaveTextV1, -}; -use graphshell::client::ResolvedContent; -use graphshell::sessions::spawn_endpoint_session; -use graphshell_stdio::StdioCarrier; -use tempfile::tempdir; - -#[test] -fn a_real_knot_process_rings_and_resumes_after_a_disk_edit() { - let root = tempdir().unwrap(); - let path = root.path().join("field.knot"); - fs::write(&path, "one").unwrap(); - let mut carrier = StdioCarrier::spawn( - env!("CARGO_BIN_EXE_knot_endpoint"), - [root.path().as_os_str()], - ) - .unwrap(); - - let descriptor = match carrier.request(CarrierRequestBody::Discover).unwrap() { - CarrierResponseBody::Descriptor(descriptor) => descriptor, - other => panic!("expected descriptor, got {other:?}"), - }; - let request = descriptor.projections[0].request.clone(); - let snapshot = match carrier - .request(CarrierRequestBody::Snapshot(request.clone())) - .unwrap() - { - CarrierResponseBody::Snapshot(snapshot) => snapshot, - other => panic!("expected snapshot, got {other:?}"), - }; - - fs::write(&path, "one two three").unwrap(); - let notice = carrier.wait_for_notice().unwrap(); - assert_eq!(notice.session, snapshot.session); - assert_eq!(notice.epoch, snapshot.scene.epoch); - assert!(notice.revision > snapshot.scene.revision); - - let reply = match carrier - .request(CarrierRequestBody::Resume(ResumeRequest { - session: snapshot.session.clone(), - epoch: snapshot.scene.epoch, - revision: snapshot.scene.revision, - })) - .unwrap() - { - CarrierResponseBody::Resume(reply) => reply, - other => panic!("expected resume reply, got {other:?}"), - }; - let ResumeReply::Snapshot(next) = reply else { - panic!("Knot should replace the stale snapshot"); - }; - assert_eq!(next.scene.revision, notice.revision); - - assert!(matches!( - carrier.request(CarrierRequestBody::Close).unwrap(), - CarrierResponseBody::Closed - )); - carrier.shutdown().unwrap(); -} - -#[test] -fn a_retained_graphshell_session_saves_a_real_knot_file() { - let root = tempdir().unwrap(); - let path = root.path().join("field.knot"); - fs::write(&path, "# Field\n").unwrap(); - let args: [&OsStr; 3] = [ - OsStr::new("directory-write"), - root.path().as_os_str(), - OsStr::new("4096"), - ]; - let mut retained = spawn_endpoint_session( - env!("CARGO_BIN_EXE_knot_endpoint"), - args, - CapabilityProfile::new([ - PresentationCapability::EditableText, - PresentationCapability::PortableCard, - ]), - ) - .unwrap(); - let session = retained.mount(0).unwrap(); - let (target, editable, action) = retained - .resolve_all(&session) - .unwrap() - .into_iter() - .find_map(|(target, presentation)| match presentation.content { - ResolvedContent::EditableText(editable) if editable.address.ends_with("field.knot") => { - Some((target, editable, presentation.semantics.actions[0].clone())) - } - _ => None, - }) - .expect("writable process disclosed editable source"); - - let result = retained - .invoke( - &session, - target, - &action, - &SaveTextV1 { - base_token: editable.base_token, - source: "# Saved over Graphshell\n".into(), - }, - ) - .unwrap(); - assert_eq!(result, IntentResult::Accepted); - assert!(retained.wait_for_change().unwrap()); - retained.close().unwrap(); - assert_eq!( - fs::read_to_string(path).unwrap(), - "# Saved over Graphshell\n" - ); -} - -#[cfg(windows)] -#[test] -fn a_real_startup_unlocked_vault_process_saves_restarts_and_stays_sealed() { - let root = tempdir().unwrap(); - let persona = personae::PersonaId::new(); - let settings = pandect::DeviceSettings { - startup_unlock_mode: personae::StartupUnlockMode::AutoOs, - ..Default::default() - }; - pandect::save_device_settings(root.path(), &settings).unwrap(); - pandect::wallet_store::ensure_wallet_state(root.path(), persona, "Knot process receipt") - .unwrap(); - let authority = knot_editor::StartupUnlockedPersonalVault::open( - root.path(), - persona, - knot_editor::local_device_root(root.path(), "knot receipt").unwrap(), - [], - ) - .unwrap(); - authority - .author_document(knot_editor::VaultDocument { - id: "field-note".into(), - title: "Field note".into(), - body: b"# Private\n".to_vec(), - media_type: "text/vnd.knot".into(), - }) - .unwrap(); - drop(authority); - - let args = vec![ - OsStr::new("persona-vault").to_os_string(), - root.path().as_os_str().to_os_string(), - persona.as_uuid().to_string().into(), - "4096".into(), - ]; - let profile = CapabilityProfile::new([ - PresentationCapability::EditableText, - PresentationCapability::PortableCard, - ]); - let mut retained = - spawn_endpoint_session(env!("CARGO_BIN_EXE_knot_endpoint"), &args, profile.clone()) - .unwrap(); - let session = retained.mount(0).unwrap(); - let (target, editable, action) = retained - .resolve_all(&session) - .unwrap() - .into_iter() - .find_map(|(target, presentation)| match presentation.content { - ResolvedContent::EditableText(editable) - if editable.address == "knot://vault/field-note" => - { - Some((target, editable, presentation.semantics.actions[0].clone())) - } - _ => None, - }) - .unwrap(); - assert_eq!(editable.source, "# Private\n"); - assert_eq!( - retained - .invoke( - &session, - target, - &action, - &SaveTextV1 { - base_token: editable.base_token, - source: "# Private revised\n".into(), - }, - ) - .unwrap(), - IntentResult::Accepted - ); - assert!(retained.wait_for_change().unwrap()); - retained.close().unwrap(); - - let mut reopened = - spawn_endpoint_session(env!("CARGO_BIN_EXE_knot_endpoint"), &args, profile).unwrap(); - let session = reopened.mount(0).unwrap(); - let source = reopened - .resolve_all(&session) - .unwrap() - .into_iter() - .find_map(|(_, presentation)| match presentation.content { - ResolvedContent::EditableText(editable) - if editable.address == "knot://vault/field-note" => - { - Some(editable.source) - } - _ => None, - }) - .unwrap(); - assert_eq!(source, "# Private revised\n"); - reopened.close().unwrap(); - - let clear = b"# Private revised\n"; - for path in walk_files(root.path()) { - let bytes = fs::read(&path).unwrap(); - assert!( - !bytes.windows(clear.len()).any(|window| window == clear), - "cleartext leaked to {}", - path.display() - ); - } -} - -#[cfg(windows)] -fn walk_files(root: &std::path::Path) -> Vec { - let mut pending = vec![root.to_path_buf()]; - let mut files = Vec::new(); - while let Some(path) = pending.pop() { - for entry in fs::read_dir(path).unwrap() { - let path = entry.unwrap().path(); - if path.is_dir() { - pending.push(path); - } else { - files.push(path); - } - } - } - files -} diff --git a/ports/knot/tests/rosette_projection.rs b/ports/knot/tests/rosette_projection.rs deleted file mode 100644 index bce60e6b4..000000000 --- a/ports/knot/tests/rosette_projection.rs +++ /dev/null @@ -1,102 +0,0 @@ -// Copyright 2026 Mark Alan Boykin -// 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 https://mozilla.org/MPL/2.0/. -// SPDX-License-Identifier: MPL-2.0 - -use chirograph::{CapabilityProfile, PresentationCapability, ProjectionSession}; -use graphshell::client::RetainedEndpointSession; -use graphshell::view::{ProjectionLayoutView, ProjectionReceiptView, render_projection_receipt}; -use graphshell_endpoint::ResumableProjectionSource; -use graphshell_local::LocalCarrier; -use knot_editor::{KnotEndpoint, KnotRosetteConfig, RosetteConfig}; -use tempfile::tempdir; - -const POEM: &str = "Morning gathers light\nBranches answer night\n\nFootsteps cross the hill\nEvening settles still\n"; -const LYRIC: &str = - "Raise your open hand\nWe will take a stand\n\nCarry home the song\nLet the road run long\n"; - -#[test] -fn two_real_knot_documents_mount_and_render_as_independent_rosettes() { - let root = tempdir().unwrap(); - std::fs::write(root.path().join("poem.knot"), POEM).unwrap(); - std::fs::write(root.path().join("lyric.knot"), LYRIC).unwrap(); - - let endpoint = KnotEndpoint::open(root.path()) - .unwrap() - .with_rosette_config(KnotRosetteConfig { - geometry: RosetteConfig { - radius: 180.0, - ..RosetteConfig::default() - }, - max_source_bytes: 64 * 1024, - }); - let carrier = LocalCarrier::new(endpoint, |endpoint, request| endpoint.resume(request)); - let mut retained = RetainedEndpointSession::over( - Box::new(carrier), - CapabilityProfile::new([ - PresentationCapability::PortableCard, - PresentationCapability::NativeGlyph, - ]), - ) - .unwrap(); - - let rosettes = retained - .descriptor() - .projections - .iter() - .enumerate() - .filter(|(_, offer)| offer.label.starts_with("Rosette · ")) - .map(|(index, offer)| (index, offer.label.clone())) - .collect::>(); - assert_eq!(rosettes.len(), 2); - - let mut mounted = Vec::new(); - for (index, label) in rosettes { - let session = retained.mount(index).unwrap(); - let expected = if label.contains("poem") { - "Morning gathers light" - } else { - "Raise your open hand" - }; - assert_rendered_rosette(&mut retained, &session, expected); - mounted.push(session); - } - - assert_ne!(mounted[0], mounted[1]); - assert!(retained.client().mounted(&mounted[0]).is_some()); - assert!(retained.client().mounted(&mounted[1]).is_some()); -} - -fn assert_rendered_rosette( - retained: &mut RetainedEndpointSession, - session: &ProjectionSession, - expected_line: &str, -) { - let scene = retained.client().mounted(session).unwrap().scene.clone(); - assert_eq!(scene.active_item_count(), 6); - assert!(scene.tables.relations.iter().flatten().count() >= 2); - let presentations = retained - .resolve_all(session) - .unwrap() - .into_iter() - .map(|(_, presentation)| presentation) - .collect::>(); - assert_eq!(presentations.len(), scene.active_item_count()); - - let html = render_projection_receipt(&ProjectionReceiptView { - eyebrow: "Knot · Rosette".into(), - title: "Rosette".into(), - lede: "A live sound projection.".into(), - session: session.0.clone(), - status: "Live".into(), - presentations, - layout: Some(ProjectionLayoutView::from_scene(&scene)), - intents: Vec::new(), - }); - assert!(html.contains(expected_line)); - assert!( - html.contains("() {} - -#[test] -fn a_knot_endpoint_can_be_scheduled_by_a_resident_host() { - assert_send::(); -}