diff --git a/CLAUDE.md b/CLAUDE.md index 52e9af40..1c4cade6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -220,6 +220,45 @@ The workspace is a set of focused crates (one responsibility each). Key groups: `TC-*` cases, embedded fixtures, page-count/glyph-coverage canaries, SSIM primitives, and the `load_bench` open-latency benchmark). See `loki-acid/README.md`. +- **Server (collaboration & storage)** — spec: + [docs/adr/LOKI_WEB_SERVER_SPEC.md](docs/adr/LOKI_WEB_SERVER_SPEC.md) + (ADRs C012–C020). `loki-model` (server-side IDs, `EncryptionTier`, + RBAC `Role`/`Action`, EU-pinned `Residency`), `loki-crypto` (DEK envelope + encryption + crypto-agile `KeyWrap`: symmetric KEK for Tiers 0/1, X25519 + for Tier-2 zero-knowledge), `loki-server-audit` (hash-chained audit log), + `loki-server-store` (Postgres/SQLx + `object_store` ports, with in-memory + test impls; `doc_meta.snapshot_seq` is the move-forward-only compaction + guard), `loki-server-collab` (WebSocket relay, `FanOutBus`: + `PgNotifyBus`/`InMemoryBus`, and the ADR-C013 `Compactor` — a periodic + task folds each Tier-0/1 oplog backlog into a Loro snapshot and + truncates; Tier-2 documents are compacted by clients via + `PUT /v1/documents/{doc}/snapshot`), `loki-server-auth` (OIDC relying + party + RBAC; keys come from the IdP's JWKS endpoint with caching and + rotation-on-unknown-kid, or a static PEM), `loki-server-api` (REST + `/v1`, problem+json errors, `E2eeCapabilityDisabled` = the canonical + Tier-2 409), and the `loki-server` binary (env config with sovereignty + validation, graceful shutdown). Deliberate deferrals are marked in-code: + `TODO(kms)` (KEK from Vault/KMS instead of env), `TODO(headless-c025)` + (apalis export job queue), `TODO(ws-membership)` (workspace-scope roles + + listing join). +- **Headless (print & conversion)** — spec: + [docs/adr/LOKI_HEADLESS_SERVER_SPEC.md](docs/adr/LOKI_HEADLESS_SERVER_SPEC.md) + (ADRs C021–C028). `loki-convert` (ADR-C024 conversion matrix over the + existing import/export crates — DOCX/ODT ↔ each other and → EPUB/PDF; + XLSX ↔ ODS; PPTX/ODP/ODG gated behind the unbuilt ACID PPTX generator, + ratified decision §5.1; unsupported pairs are a typed + `ConversionUnsupported`), `loki-print` (ADR-C023 blocking IPP client: + Print-Job dispatch of rendered PDF with copies/duplex/media/colour + attributes, Get-Job-Attributes polling), and the `loki-headless` CLI + (`convert`/`render`/`print`/`formats`; print renders non-PDF inputs via + `loki-convert` first). The whole print path is CPU-only already + (Parley layout → pdf-writer), so no GPU is involved. Deferrals marked + in-code in `loki-headless/src/main.rs`: `TODO(headless-c025)` (apalis + worker + HTTP endpoint), `TODO(headless-c021)` (vello_cpu thumbnails), + `TODO(headless-c022)` (krilla migration for PDF/A-2b — `pdf-a2b` is a + typed `ProfileUnsupported` today), `TODO(headless-c023-discovery)` + (DNS-SD printer discovery), `TODO(headless-c027)` (fail-closed fonts), + `TODO(headless-c028)` (TEE attestation). The **Publish** ribbon tab in `loki-text` drives PDF/X + EPUB export and the Dublin Core metadata editor. diff --git a/Cargo.lock b/Cargo.lock index edfbe562..5c7fa3c5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -116,6 +116,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + [[package]] name = "ahash" version = "0.8.12" @@ -219,12 +229,56 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + [[package]] name = "anstyle" version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.60.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.60.2", +] + [[package]] name = "anyhow" version = "1.0.102" @@ -584,6 +638,15 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + [[package]] name = "atomic-polyfill" version = "1.0.3" @@ -685,6 +748,73 @@ dependencies = [ "arrayvec", ] +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "axum-macros", + "base64", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha1", + "sync_wrapper", + "tokio", + "tokio-tungstenite", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-macros" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "base16" version = "0.2.1" @@ -697,6 +827,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bit-set" version = "0.8.0" @@ -1045,6 +1181,30 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", +] + [[package]] name = "chrono" version = "0.4.45" @@ -1084,6 +1244,17 @@ dependencies = [ "half", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", + "zeroize", +] + [[package]] name = "clap" version = "4.6.1" @@ -1091,6 +1262,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" dependencies = [ "clap_builder", + "clap_derive", ] [[package]] @@ -1099,8 +1271,22 @@ version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ + "anstream", "anstyle", "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.118", ] [[package]] @@ -1153,6 +1339,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "combine" version = "4.6.7" @@ -1172,6 +1364,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "const-serialize" version = "0.7.2" @@ -1323,6 +1521,21 @@ dependencies = [ "libc", ] +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + [[package]] name = "crc32fast" version = "1.5.0" @@ -1392,6 +1605,15 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -1411,6 +1633,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] @@ -1453,6 +1676,32 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "darling" version = "0.20.11" @@ -1540,6 +1789,23 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da220af51a1a335e9a930beaaef53d261e41ea9eecfb3d973a3ddae1a7284b9c" +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + [[package]] name = "derive_arbitrary" version = "1.4.2" @@ -1585,7 +1851,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", + "const-oid", "crypto-common", + "subtle", ] [[package]] @@ -1715,7 +1983,7 @@ dependencies = [ "subsecond", "thiserror 2.0.18", "tracing", - "tungstenite", + "tungstenite 0.28.0", ] [[package]] @@ -2071,6 +2339,12 @@ dependencies = [ "litrs", ] +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + [[package]] name = "downcast-rs" version = "1.2.1" @@ -2155,6 +2429,9 @@ name = "either" version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +dependencies = [ + "serde", +] [[package]] name = "embedded-io" @@ -2213,6 +2490,29 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "enum-as-inner" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0359ee92f81184d7985519e474bda2a5738476334edd3746c9b1265c067afe70" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "enum-primitive-derive" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba7795da175654fe16979af73f81f26a8ea27638d8d9823d317016888a63dc4c" +dependencies = [ + "num-traits", + "quote", + "syn 2.0.118", +] + [[package]] name = "enum_dispatch" version = "0.3.13" @@ -2319,6 +2619,17 @@ version = "3.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + [[package]] name = "euclid" version = "0.22.14" @@ -2389,6 +2700,12 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -2456,6 +2773,17 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + [[package]] name = "fnv" version = "1.0.7" @@ -2615,6 +2943,21 @@ dependencies = [ "new_debug_unreachable", ] +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -2631,6 +2974,17 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + [[package]] name = "futures-intrusive" version = "0.5.0" @@ -2690,6 +3044,7 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ + "futures-channel", "futures-core", "futures-io", "futures-macro", @@ -2918,6 +3273,25 @@ dependencies = [ "svg_fmt", ] +[[package]] +name = "h2" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "half" version = "2.7.1" @@ -2999,6 +3373,15 @@ dependencies = [ "foldhash 0.2.0", ] +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "heapless" version = "0.7.17" @@ -3063,6 +3446,33 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "html-escape" version = "0.2.13" @@ -3122,6 +3532,18 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "humantime" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" + [[package]] name = "hyper" version = "1.10.1" @@ -3132,9 +3554,11 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -3152,10 +3576,11 @@ dependencies = [ "hyper", "hyper-util", "rustls", + "rustls-native-certs", "tokio", "tokio-rustls", "tower-service", - "webpki-roots", + "webpki-roots 1.0.8", ] [[package]] @@ -3450,6 +3875,15 @@ dependencies = [ "cfb", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "interpolate_name" version = "0.2.4" @@ -3486,6 +3920,30 @@ version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +[[package]] +name = "ipp" +version = "6.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c50f52bbd6cf4cdaac254c696a8a479e3962b0f5858d3b659c7658ea728a94f" +dependencies = [ + "base64", + "bytes", + "enum-as-inner 0.7.0", + "enum-primitive-derive", + "http", + "log", + "num-traits", + "rustls-native-certs", + "thiserror 2.0.18", + "ureq", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itertools" version = "0.11.0" @@ -3629,6 +4087,21 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonwebtoken" +version = "9.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + [[package]] name = "keyboard-types" version = "0.7.0" @@ -3705,6 +4178,9 @@ name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] [[package]] name = "leb128" @@ -3762,6 +4238,16 @@ dependencies = [ "redox_syscall 0.8.1", ] +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "pkg-config", + "vcpkg", +] + [[package]] name = "linebender_resource_handle" version = "0.1.1" @@ -3845,6 +4331,35 @@ dependencies = [ "sys-locale", ] +[[package]] +name = "loki-convert" +version = "0.1.0" +dependencies = [ + "loki-doc-model", + "loki-epub", + "loki-odf", + "loki-ooxml", + "loki-pdf", + "loki-sheet-model", + "thiserror 2.0.18", +] + +[[package]] +name = "loki-crypto" +version = "0.1.0" +dependencies = [ + "base64", + "chacha20poly1305", + "hkdf", + "rand_core 0.6.4", + "serde", + "serde_json", + "sha2", + "thiserror 2.0.18", + "x25519-dalek", + "zeroize", +] + [[package]] name = "loki-doc-model" version = "0.1.1" @@ -3908,6 +4423,18 @@ dependencies = [ "serde", ] +[[package]] +name = "loki-headless" +version = "0.1.0" +dependencies = [ + "clap", + "loki-convert", + "loki-doc-model", + "loki-ooxml", + "loki-print", + "thiserror 2.0.18", +] + [[package]] name = "loki-i18n" version = "0.1.0" @@ -3936,6 +4463,16 @@ dependencies = [ "tracing", ] +[[package]] +name = "loki-model" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "thiserror 2.0.18", + "uuid", +] + [[package]] name = "loki-odf" version = "0.1.0" @@ -4064,6 +4601,14 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "loki-print" +version = "0.1.0" +dependencies = [ + "ipp", + "thiserror 2.0.18", +] + [[package]] name = "loki-render-cache" version = "0.1.0" @@ -4090,6 +4635,120 @@ dependencies = [ "wgpu", ] +[[package]] +name = "loki-server" +version = "0.1.0" +dependencies = [ + "axum", + "base64", + "jsonwebtoken", + "loki-crypto", + "loki-model", + "loki-server-api", + "loki-server-auth", + "loki-server-collab", + "loki-server-store", + "object_store", + "sqlx", + "thiserror 2.0.18", + "tokio", + "tracing", + "tracing-subscriber", + "uuid", +] + +[[package]] +name = "loki-server-api" +version = "0.1.0" +dependencies = [ + "async-trait", + "axum", + "base64", + "chrono", + "http-body-util", + "loki-crypto", + "loki-model", + "loki-server-audit", + "loki-server-auth", + "loki-server-collab", + "loki-server-store", + "object_store", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tower", + "tracing", + "uuid", +] + +[[package]] +name = "loki-server-audit" +version = "0.1.0" +dependencies = [ + "chrono", + "serde", + "sha2", + "thiserror 2.0.18", +] + +[[package]] +name = "loki-server-auth" +version = "0.1.0" +dependencies = [ + "async-trait", + "base64", + "jsonwebtoken", + "loki-model", + "reqwest", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tracing", +] + +[[package]] +name = "loki-server-collab" +version = "0.1.0" +dependencies = [ + "async-trait", + "axum", + "base64", + "chrono", + "futures-util", + "loki-model", + "loki-server-store", + "loro", + "object_store", + "serde", + "serde_json", + "sqlx", + "thiserror 2.0.18", + "tokio", + "tracing", + "uuid", +] + +[[package]] +name = "loki-server-store" +version = "0.1.0" +dependencies = [ + "async-trait", + "chrono", + "loki-crypto", + "loki-model", + "loki-server-audit", + "object_store", + "serde", + "serde_json", + "sqlx", + "thiserror 2.0.18", + "tokio", + "tracing", + "uuid", +] + [[package]] name = "loki-sheet-model" version = "0.1.0" @@ -4516,6 +5175,12 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "maybe-rayon" version = "0.1.1" @@ -4526,6 +5191,16 @@ dependencies = [ "rayon", ] +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + [[package]] name = "md5" version = "0.7.0" @@ -4746,6 +5421,22 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.6", + "smallvec", + "zeroize", +] + [[package]] name = "num-complex" version = "0.4.6" @@ -4755,6 +5446,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "num-derive" version = "0.4.2" @@ -5218,16 +5915,70 @@ dependencies = [ ] [[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - -[[package]] -name = "oorandom" -version = "11.1.5" +name = "object_store" +version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" +checksum = "fbfbfff40aeccab00ec8a910b57ca8ecf4319b335c542f2edcd19dd25a1e2a00" +dependencies = [ + "async-trait", + "base64", + "bytes", + "chrono", + "form_urlencoded", + "futures", + "http", + "http-body-util", + "humantime", + "hyper", + "itertools 0.14.0", + "md-5", + "parking_lot", + "percent-encoding", + "quick-xml 0.38.4", + "rand 0.9.4", + "reqwest", + "ring", + "serde", + "serde_json", + "serde_urlencoded", + "thiserror 2.0.18", + "tokio", + "tracing", + "url", + "walkdir", + "wasm-bindgen-futures", + "web-time", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "option-ext" @@ -5377,6 +6128,25 @@ dependencies = [ "ryu", ] +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "peniko" version = "0.5.0" @@ -5534,6 +6304,27 @@ dependencies = [ "futures-io", ] +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.33" @@ -5607,6 +6398,17 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -5646,6 +6448,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -5767,6 +6575,16 @@ dependencies = [ "serde", ] +[[package]] +name = "quick-xml" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "quick-xml" version = "0.39.4" @@ -6130,6 +6948,7 @@ dependencies = [ "futures-channel", "futures-core", "futures-util", + "h2", "http", "http-body", "http-body-util", @@ -6142,6 +6961,7 @@ dependencies = [ "pin-project-lite", "quinn", "rustls", + "rustls-native-certs", "rustls-pki-types", "serde", "serde_json", @@ -6149,14 +6969,16 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-rustls", + "tokio-util", "tower", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-streams", "web-sys", - "webpki-roots", + "webpki-roots 1.0.8", ] [[package]] @@ -6242,6 +7064,26 @@ dependencies = [ "memchr", ] +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + [[package]] name = "rust-embed" version = "8.11.0" @@ -6329,6 +7171,7 @@ version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ + "log", "once_cell", "ring", "rustls-pki-types", @@ -6337,6 +7180,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + [[package]] name = "rustls-pki-types" version = "1.14.1" @@ -6397,6 +7252,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scoped-tls" version = "1.0.1" @@ -6422,6 +7286,29 @@ dependencies = [ "tiny-skia", ] +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.0", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "selectors" version = "0.32.0" @@ -6543,6 +7430,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_repr" version = "0.1.20" @@ -6632,6 +7530,16 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + [[package]] name = "simd-adler32" version = "0.3.9" @@ -6663,6 +7571,18 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.18", + "time", +] + [[package]] name = "simplecss" version = "0.2.2" @@ -6866,21 +7786,229 @@ dependencies = [ ] [[package]] -name = "spin" -version = "0.9.8" +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spirv" +version = "0.3.0+sdk-1.3.268.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844" +dependencies = [ + "bitflags 2.13.0", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64", + "bytes", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.5", + "hashlink", + "indexmap", + "log", + "memchr", + "once_cell", + "percent-encoding", + "rustls", + "serde", + "serde_json", + "sha2", + "smallvec", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tracing", + "url", + "uuid", + "webpki-roots 0.26.11", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 2.0.118", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +dependencies = [ + "dotenvy", + "either", + "heck 0.5.0", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn 2.0.118", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +dependencies = [ + "atoi", + "base64", + "bitflags 2.13.0", + "byteorder", + "bytes", + "chrono", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand 0.8.6", + "rsa", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.18", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" dependencies = [ - "lock_api", + "atoi", + "base64", + "bitflags 2.13.0", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand 0.8.6", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.18", + "tracing", + "uuid", + "whoami", ] [[package]] -name = "spirv" -version = "0.3.0+sdk-1.3.268.0" +name = "sqlx-sqlite" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" dependencies = [ - "bitflags 2.13.0", + "atoi", + "chrono", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror 2.0.18", + "tracing", + "url", + "uuid", ] [[package]] @@ -6929,6 +8057,17 @@ dependencies = [ "quote", ] +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + [[package]] name = "strsim" version = "0.11.1" @@ -7331,6 +8470,36 @@ dependencies = [ "weezl", ] +[[package]] +name = "time" +version = "0.3.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tiny-skia" version = "0.11.4" @@ -7442,10 +8611,23 @@ dependencies = [ "libc", "mio", "pin-project-lite", + "signal-hook-registry", "socket2", + "tokio-macros", "windows-sys 0.61.2", ] +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "tokio-rustls" version = "0.26.4" @@ -7456,6 +8638,42 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite 0.29.0", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + [[package]] name = "toml" version = "0.8.23" @@ -7533,6 +8751,7 @@ dependencies = [ "tokio", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -7571,6 +8790,7 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -7608,6 +8828,16 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + [[package]] name = "tracing-subscriber" version = "0.3.23" @@ -7618,12 +8848,15 @@ dependencies = [ "nu-ansi-term", "once_cell", "regex-automata", + "serde", + "serde_json", "sharded-slab", "smallvec", "thread_local", "tracing", "tracing-core", "tracing-log", + "tracing-serde", ] [[package]] @@ -7669,6 +8902,22 @@ dependencies = [ "utf-8", ] +[[package]] +name = "tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.4", + "sha1", + "thiserror 2.0.18", +] + [[package]] name = "twox-hash" version = "2.1.2" @@ -7758,6 +9007,15 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + [[package]] name = "unicode-properties" version = "0.1.4" @@ -7794,12 +9052,50 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "untrusted" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" +dependencies = [ + "base64", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "ureq-proto", + "utf8-zero", + "webpki-roots 1.0.8", +] + +[[package]] +name = "ureq-proto" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" +dependencies = [ + "base64", + "http", + "httparse", + "log", +] + [[package]] name = "url" version = "2.5.8" @@ -7858,18 +9154,31 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1292c0d970b54115d14f2492fe0170adf21d68a1de108eebc51c1df4f346a091" +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + [[package]] name = "utf8_iter" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.23.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" dependencies = [ + "getrandom 0.4.3", "js-sys", "serde_core", "wasm-bindgen", @@ -7892,6 +9201,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "vello" version = "0.6.0" @@ -8036,6 +9351,12 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + [[package]] name = "wasm-bindgen" version = "0.2.125" @@ -8261,6 +9582,15 @@ dependencies = [ "web-sys", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.8", +] + [[package]] name = "webpki-roots" version = "1.0.8" @@ -8435,6 +9765,16 @@ dependencies = [ "wgpu", ] +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", +] + [[package]] name = "winapi" version = "0.3.9" @@ -8640,6 +9980,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -8691,6 +10040,21 @@ dependencies = [ "windows_x86_64_msvc 0.42.2", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -8739,6 +10103,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -8757,6 +10127,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -8775,6 +10151,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -8805,6 +10187,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -8823,6 +10211,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -8841,6 +10235,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -8859,6 +10259,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -8985,6 +10391,18 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core 0.6.4", + "serde", + "zeroize", +] + [[package]] name = "xcursor" version = "0.3.10" @@ -9306,6 +10724,20 @@ name = "zeroize" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] [[package]] name = "zerotrie" diff --git a/Cargo.toml b/Cargo.toml index 003d1836..94c40817 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "3" -members = ["loki-doc-model","loki-sheet-model","loki-opc","loki-primitives","loki-ooxml","loki-odf","loki-layout","loki-vello","appthere-ui","loki-app-shell","loki-text","loki-spreadsheet","loki-presentation","loki-render-cache","loki-renderer","loki-i18n","appthere-canvas","loki-fonts","loki-graphics","loki-presentation-model","loki-epub","loki-pdf","loki-acid","loki-templates","loki-spell"] +members = ["loki-doc-model","loki-sheet-model","loki-opc","loki-primitives","loki-ooxml","loki-odf","loki-layout","loki-vello","appthere-ui","loki-app-shell","loki-text","loki-spreadsheet","loki-presentation","loki-render-cache","loki-renderer","loki-i18n","appthere-canvas","loki-fonts","loki-graphics","loki-presentation-model","loki-epub","loki-pdf","loki-acid","loki-templates","loki-spell","loki-model","loki-crypto","loki-server-audit","loki-server-store","loki-server-collab","loki-server-auth","loki-server-api","loki-server","loki-convert","loki-print","loki-headless"] [workspace.dependencies] # Shared across appthere-ui and any other workspace member that needs Dioxus. @@ -20,6 +20,45 @@ loki-file-access = { git = "https://github.com/appthere/loki-file-access", branc loki-fonts = { path = "loki-fonts" } android-activity = "0.6" +# ── Server-side crates (LOKI_WEB_SERVER_SPEC.md, ADRs C012–C020) ───────────── +loki-model = { path = "loki-model" } +loki-crypto = { path = "loki-crypto" } +loki-server-audit = { path = "loki-server-audit" } +loki-server-store = { path = "loki-server-store" } +loki-server-collab = { path = "loki-server-collab" } +loki-server-auth = { path = "loki-server-auth" } +loki-server-api = { path = "loki-server-api" } +loki-convert = { path = "loki-convert" } +loki-print = { path = "loki-print" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +uuid = { version = "1", features = ["v4", "serde"] } +chrono = { version = "0.4", features = ["serde", "clock", "std"], default-features = false } +tokio = { version = "1", default-features = false } +axum = { version = "0.8", features = ["ws", "macros"] } +sqlx = { version = "0.8", default-features = false, features = [ + "runtime-tokio", + "tls-rustls", + "postgres", + "uuid", + "chrono", + "migrate", + "derive", + "macros", + "json", +] } +object_store = { version = "0.12", features = ["aws"] } +async-trait = "0.1" +tracing = "0.1" +chacha20poly1305 = "0.10" +x25519-dalek = { version = "2", features = ["static_secrets"] } +hkdf = "0.12" +sha2 = "0.10" +rand_core = { version = "0.6", features = ["getrandom"] } +zeroize = { version = "1", features = ["derive"] } +jsonwebtoken = "9" +base64 = "0.22" + [patch.crates-io] # PATCH: vendors dioxus-native-dom to avoid runtime unimplemented!() panics in # HtmlEventConverter (composition, touch, scroll, etc.) — remove when upstream diff --git a/docs/adr/LOKI_HEADLESS_SERVER_SPEC.md b/docs/adr/LOKI_HEADLESS_SERVER_SPEC.md new file mode 100644 index 00000000..089b8c8f --- /dev/null +++ b/docs/adr/LOKI_HEADLESS_SERVER_SPEC.md @@ -0,0 +1,308 @@ + + +# AppThere Loki — Headless Server Spec (Print & Conversion) + +**Status:** Ratified (v1) — open decisions closed 2026-07-01 +**Series:** AppThere Cloud, ADRs C021–C028 +**Companion:** `LOKI_SERVER_COLLABORATION_SPEC.md` (C012–C020) +**Target edition:** Rust 2024 + +--- + +## 0. Scope + +A headless Loki deployment for office environments: batch/on-demand **printing** and **file +conversion** with no display, no GPU, and deterministic output. It runs as a worker that +consumes jobs from the collaboration server's queue **or** as a standalone CLI/HTTP service for +customers who only want conversion and printing without collaboration. + +### Goals + +- Render Loki documents to print-ready output with no GPU and no window server. +- Deterministic, conformance-validated fidelity (byte-stable where the format allows). +- Convert between all formats Loki already imports/exports, plus PDF. +- Dispatch to office printers over standard IPP. +- Horizontally scalable, stateless workers. +- Honour the confidentiality tiers from C014–C015. + +### Non-goals (v1) + +- Interactive preview (this is headless by definition). +- Server-side processing of Tier-2 (zero-knowledge) documents (impossible; see C026). +- OCR / scanning (out of scope). + +### Engineering standards + +Inherited verbatim from the collaboration spec (300-line ceiling, no `unwrap()`/`expect()` in +library code, `#![forbid(unsafe_code)]` at crate roots, `thiserror`, SPDX line 1, `fl!()` for +user-visible strings, audit-first / implement-second). + +--- + +## 1. Architecture + +``` + ┌──────────────────────────────────────────────────────────────┐ + │ Job source │ + │ • apalis queue (from loki-server) • CLI • HTTP endpoint │ + └───────────────┬────────────────────────────────────────────────┘ + │ Job { Render | Print | Convert | Thumbnail | Export } + ┌───────────▼────────────┐ + │ loki-headless (N≥1) │ stateless, GPU-free + │ ┌───────────────────┐ │ + │ │ loki layout+text │ │ (existing PaginatedLayout, Parley) + │ ├───────────────────┤ │ + │ │ vello_cpu render │ │ deterministic, no wgpu/GPU + │ ├───────────────────┤ │ + │ │ krilla → PDF │ │ PDF / PDF-A / PDF-X profiles + │ ├───────────────────┤ │ + │ │ import/export │ │ DOCX/ODT/XLSX/PPTX/ODS/ODP/ODG + │ └───────────────────┘ │ + └───────┬─────────────┬────┘ + │ │ + ┌─────────▼───┐ ┌─────▼──────────────┐ + │ IPP / CUPS │ │ object storage / │ + │ office print│ │ return artifact │ + └─────────────┘ └─────────────────────┘ +``` + +Crates: + +| Crate | Responsibility | +|---|---| +| `loki-headless` | Worker binary + CLI + optional Axum HTTP endpoint. | +| `loki-render-cpu` | vello_cpu render path; deterministic rasterisation. | +| `loki-pdf` | krilla-based PDF/PDF-A/PDF-X emission. | +| `loki-print` | IPP client; printer discovery; job dispatch. | +| `loki-convert` | Format matrix orchestration over existing import/export. | +| (reuses) `loki-text`, `loki-doc-model`, `loki-fonts`, `appthere-conformance` | + +--- + +## 2. ADRs + +### ADR-C021 — Headless rendering via `vello_cpu` (GPU-free, deterministic) + +**Context.** Office/server hardware frequently has no GPU, and print fidelity demands +determinism. Loki already chose `vello_cpu` for deterministic headless conformance rendering +(the `appthere-conformance` crate, ~141 cases via the promoted `loki-acid` harness). + +**Decision.** The headless render path uses `vello_cpu` exclusively — the **same** path the +conformance harness exercises. No wgpu, no surface, no GPU dependency. This means the print +output and the conformance-tested output are produced by identical code, so print fidelity is +covered by the existing test suite rather than a parallel, untested path. + +**Consequences.** Runs on any Linux box (CCX server, office NUC, container). Print regressions +are caught by the conformance harness. No display server required. + +--- + +### ADR-C022 — PDF emission via `krilla`; PDF/A and PDF/X profiles + +**Context.** The Cloud spec already chose `krilla` as the PDF backend. Office printing and +archival need conformance profiles, not just "a PDF". + +**Decision.** `loki-pdf` emits via krilla with selectable profiles: + +- **PDF 1.7** — default general output. +- **PDF/A-2b** — archival (records retention, GDPR-adjacent recordkeeping). +- **PDF/X-4** — print-production colour fidelity (ties into `appthere-color` ICC/CMYK). + +Colour management routes through the published `appthere-color` crate (moxcms, CMYK, ICC, soft +proofing) so print colour matches Loki's on-screen soft-proof. + +**Consequences.** One PDF engine serves screen, print, and archive. Archival and print-shop +outputs are first-class, not conversions-of-conversions. + +--- + +### ADR-C023 — Printing via IPP dispatch of rendered PDF + +**Context.** "Print documents in an office environment" means network printers, print queues, +and (often) CUPS. Rendering directly to a printer PDL per device is a fidelity and maintenance +trap. + +**Decision.** Render → PDF (§C022) → dispatch over **IPP** (`ipp`/IPP-everywhere), optionally +through a CUPS server. `loki-print` supports: + +- Printer discovery (IPP / DNS-SD) and explicit printer URIs. +- Job options: copies, duplex, media size, colour/mono, staple/finishing where advertised. +- Job status polling and completion reporting back to the audit log. + +Direct-to-printer PDL (PCL/PostScript) is a documented later option, not v1. + +**Consequences.** Works with essentially all modern office printers via one code path. The PDF +intermediate keeps fidelity identical to on-screen and archive output. + +--- + +### ADR-C024 — Conversion pipeline reuses existing import/export, conformance-gated + +**Context.** Loki already has full OOXML and ODF round-tripping (DOCX/ODT/XLSX/PPTX/ODS/ODP/ODG) +plus PDF export. Conversion is orchestration, not new format code. + +**Decision.** `loki-convert` exposes a conversion matrix built entirely on the existing +import/export crates: + +- Import source → in-memory `doc-model` → export target (or PDF via `loki-pdf`). +- Every supported pair is registered in a static capability table; unsupported pairs return a + typed `ConversionUnsupported` error rather than a lossy best-effort. +- Fidelity of each pair is asserted by the `appthere-conformance` harness, extending the ACID + test plan (`LOKI_ACID_TEST_PLAN.md`). The PPTX generator gap noted there (29 cases documented, + file unbuilt) is a prerequisite before PPTX conversion is declared supported. + +**Consequences.** No second, drifting implementation of format handling. Conversion quality is +exactly the round-trip quality already measured. New format support lands in one place. + +--- + +### ADR-C025 — Job model via `apalis`; idempotency, retries, dead-letter + +**Context.** The Cloud spec already chose `apalis` for job queuing (Postgres backend, so no +Redis dependency is forced). + +**Decision.** Job types: `Render`, `Print`, `Convert`, `Thumbnail`, `Export`. Each carries: + +- an **idempotency key** (duplicate submissions coalesce), +- a **document reference + version** (so a job renders a pinned snapshot, not a moving target), +- **retry policy** (bounded, exponential backoff) and a **dead-letter** queue for poison jobs. + +Workers are stateless and horizontally scalable; concurrency is bounded per worker to protect +memory (Loki's steady-state is ~750 MB shared via `Arc`/`PaginatedLayout`/ +`FontResources`; the worker reuses that sharing across jobs in-process). + +**Consequences.** Print/convert scale by adding workers. Failures are visible and recoverable, +not silent. Batch office workloads (e.g. nightly statement runs) are natural. + +--- + +### ADR-C026 — Headless workers operate only on decryptable documents (Tier 0/1) + +**Context.** Per collaboration ADR-C015, a Tier-2 (zero-knowledge) document is ciphertext to the +server; a server-side worker cannot render it. This is a hard boundary. + +**Decision.** + +- Server-side headless jobs are accepted **only for Tier 0 and Tier 1** documents. Under Tier 1 + the worker obtains the document DEK from the customer KMS/HSM **within the trust boundary** to + decrypt, render, then discard the key from memory. +- For **Tier 2**, the headless engine is still available but must run **client-side / in-boundary** + with a **caller-supplied key**: the same `loki-headless` binary runs as a local process the + client feeds the DEK to, so a Tier-2 user can still print/convert locally without the key ever + reaching the shared server. The server-side queue rejects Tier-2 jobs with + `E2eeCapabilityDisabled`. + +**Consequences.** The confidentiality guarantee is never quietly broken. Tier-2 users keep +print/convert capability, relocated to where the key legitimately lives. + +--- + +### ADR-C027 — Deterministic font resolution and substitution policy + +**Context.** Print fidelity collapses if the server substitutes fonts differently from the +author's machine. Loki already bundles Gelasio (metric-compatible with Georgia) as a +substitution anchor. + +**Decision.** + +- `loki-fonts` performs **deterministic resolution**: bundled fonts first (Gelasio et al.), then + a configured, ordered fallback set, then a **fail-closed** option for print jobs that must not + substitute silently (returns `FontUnavailable` instead of guessing). +- The active substitution map is recorded in job metadata and the audit log, so a print run is + reproducible and explainable. +- Metric-compatible substitutes (e.g. Gelasio→Georgia, and the standard Liberation/Carlito style + pairings) are the documented defaults for office interchange. + +**Consequences.** Same document → same pages → same output on any worker. Font drift becomes a +policy choice, not an accident. + +--- + +### ADR-C028 — Confidential-computing hardening for Tier-1 workers (SEV-SNP / TDX) + +**Context.** Under Tier 1, a headless worker holds document plaintext **and** the DEK in memory +while it renders. On a shared or provider-operated host, that in-use plaintext is exposed to a +compromised or curious hypervisor / host operator — the one gap that at-rest and in-transit +encryption do not close. C5:2026 introduces explicit confidential-computing criteria. + +**Decision (adopted).** Tier-1 headless workers run inside a hardware Trusted Execution +Environment — **AMD SEV-SNP** (or equivalently **Intel TDX**) — which encrypts VM memory and CPU +register state per-VM and attests the running image. Key release is **attestation-gated**: the +customer KMS/HSM unwraps the Tier-1 KEK only for a worker presenting a valid remote-attestation +report (via `snpguest`/`snphost`) proving genuine AMD hardware, a verified TCB/firmware level, +and the expected `loki-headless` measurement. A worker that cannot attest never receives a key +and therefore cannot decrypt. + +**Hetzner reality check.** Hetzner **Cloud** VMs do **not** support confidential computing — no +vTPM, no data-in-use encryption, no UEFI secure boot. SEV-SNP therefore requires one of: + +- a **Hetzner dedicated / bare-metal AMD EPYC server** (3rd-gen "Milan" or newer) with + self-managed host enablement (QEMU + OVMF; e.g. an Ubuntu 25.04-class host), **or** +- an EU sovereign cloud that exposes confidential VMs. + +Azure/GCP confidential VMs exist but are **non-sovereign** for EU purposes and are noted only as +non-default fallbacks. The sovereign path is Hetzner dedicated EPYC bare metal. + +**Consequences.** Closes the in-use exposure gap: even the infrastructure operator cannot read +Tier-1 plaintext while it is being processed — the confidential-computing control C5:2026 now +expects. It is an **optional deployment mode** — Tier-1 without a TEE still works (trusting the +host); Tier-1 with a TEE removes that trust. Non-attesting hosts are simply ineligible to +process Tier-1 jobs. + +--- + +## 3. Interfaces + +**CLI (standalone, no server required):** + +``` +loki-headless convert --in report.docx --out report.pdf --profile pdf-a2b +loki-headless convert --in sheet.xlsx --out sheet.ods +loki-headless print --in report.docx --printer ipp://officeprinter.local/ipp/print \ + --duplex --copies 3 --media A4 +loki-headless render --in doc.odt --out doc.pdf --profile pdf-x4 +``` + +**HTTP (optional, for in-house automation):** + +``` +POST /v1/convert { source, target_format, profile } -> artifact | 409 (Tier 2) +POST /v1/print { source, printer_uri, options } -> job id +GET /v1/jobs/{id} -> status +``` + +**Worker:** subscribes to the apalis queue from `loki-server`; same job types as HTTP. + +All errors typed; the canonical rejections are `ConversionUnsupported`, `FontUnavailable`, +and `E2eeCapabilityDisabled`. + +--- + +## 4. Deployment + +- **Alongside `loki-server`** (Hetzner OpenTofu module / Helm chart): a worker pool of CCX + (dedicated-vCPU) instances; no GPU nodes needed. Scale by replica count. +- **Confidential-computing pool (Tier-1 hardening, per ADR-C028):** a separate worker pool on + SEV-SNP/TDX nodes — Hetzner dedicated EPYC bare metal (Milan+) or an EU confidential cloud — + with attestation-gated key release. Tier-1 jobs needing in-use protection route here; all + other jobs run on the standard CCX pool. +- **Standalone office box:** single container or binary + a CUPS/IPP printer on the LAN; no + Postgres required for pure CLI conversion/print. This is the "office printing" deployment. +- **Air-gapped:** fully offline — bundled fonts, local ICC profiles, no network egress; suits + the strictest sovereignty and classified-adjacent environments. + +Deterministic, GPU-free, and self-contained means the same binary serves a Hetzner worker pool +and a single office NUC by the print room. + +--- + +## 5. Ratified decisions + +1. **PPTX conversion gating — RATIFIED.** PPTX conversion stays behind the unbuilt PPTX + generator from `LOKI_ACID_TEST_PLAN.md` until its 29 cases pass. +2. **Fail-closed fonts — RATIFIED.** Fail-closed font substitution is the default for `print` + jobs; best-effort for `convert`. +3. **CUPS dependency — RATIFIED.** Direct IPP is the default; CUPS is an optional backend. +4. **Confidential-computing workers — ADOPTED.** Tier-1 workers run in a SEV-SNP/TDX TEE with + attestation-gated key release (ADR-C028). Optional deployment mode; sovereign path is Hetzner + dedicated EPYC bare metal. diff --git a/docs/adr/LOKI_WEB_SERVER_SPEC.md b/docs/adr/LOKI_WEB_SERVER_SPEC.md new file mode 100644 index 00000000..a165f40e --- /dev/null +++ b/docs/adr/LOKI_WEB_SERVER_SPEC.md @@ -0,0 +1,393 @@ + + +# AppThere Loki — Web Server & Collaboration Spec + +**Status:** Ratified (v1) — open decisions closed 2026-07-01 +**Series:** AppThere Cloud, ADRs C012–C020 (extends C009–C011) +**Supersedes:** nothing; extends the AppThere Cloud spec +**Target edition:** Rust 2024 + +--- + +## 0. Scope & relationship to existing specs + +This spec realises the Loki-specific server: the real-time collaboration relay, the +document/storage API, and the identity and deployment story. It builds directly on the +AppThere Cloud spec (Axum / SQLx / Tokio, OIDC delegation, CRDT relay, ADRs C009–C011) +and does **not** re-litigate those foundations. What is new here: + +1. A **tiered encryption model** that reconciles "optional E2EE" with enterprise + server-side features, resolving the E2EE-vs-server-side-export mutual exclusivity + already identified in the Cloud spec. +2. First-class **self-hostability**: the same artifact deploys to Hetzner Cloud or to + the customer's own infrastructure, with no functional gap. +3. An explicit **European Digital Sovereignty** posture aligned to C5:2026, NIS2, and the + EU Cloud Sovereignty Framework (SEAL / SOV objectives). + +The **headless render/print/convert worker** is specified separately in +`LOKI_HEADLESS_SERVER_SPEC.md` (ADRs C021–C027); the two are designed to interlock. + +### Goals + +- Real-time multi-user editing of Loki documents backed by Loro CRDTs. +- PostgreSQL as the durable system-of-record; object storage for large blobs and snapshots. +- Three selectable confidentiality tiers, including zero-knowledge E2EE. +- One binary, two deployment modes (Hetzner Cloud / self-host), no feature fork. +- Enterprise readiness: SSO, RBAC, audit trail, HA, backup/restore, observability. +- Digital-sovereignty defaults suitable for EU public sector and regulated industry. + +### Non-goals (v1) + +- Iris raster collaboration (deferred to per-layer ownership per Cloud spec). +- Server-side full-text search over E2EE documents (impossible by construction; see C015). +- Billing (already covered by the Cloud spec's `async-stripe` decision; out of scope here). +- Federation between independent Loki server instances. + +### Engineering standards (inherited, non-negotiable) + +300-line file ceiling; no `unwrap()`/`expect()` in library code; `#![forbid(unsafe_code)]` +at every crate root; typed errors via `thiserror`; Apache-2.0 SPDX header on line 1; +no hardcoded user-visible strings (`fl!()`); audit-first / implement-second Claude Code +discipline. + +--- + +## 1. Component architecture + +``` + ┌────────────────────────────────────────────┐ + │ Reverse proxy / LB │ + │ (TLS 1.3 termination, HTTP/2) │ + └───────────────┬──────────────┬───────────────┘ + │ │ + ┌─────────────────────▼──┐ ┌──▼─────────────────────┐ + │ loki-server (N≥1) │ ... │ loki-server (N≥1) │ + │ ── Axum binary ── │ │ │ + │ • REST/API module │ │ │ + │ • WS collaboration mod │ │ │ + │ • auth (OIDC) module │ │ │ + └───┬───────────┬─────────┘ └────────────────────────┘ + │ │ (fan-out bus: pg LISTEN/NOTIFY or Redis) + ┌──────────────▼──┐ ┌───▼─────────────┐ ┌───────────────────────────┐ + │ PostgreSQL │ │ Object storage │ │ Job queue (apalis) │ + │ system-of-record│ │ (S3-compatible)│ │ → headless workers │ + │ metadata/ACL/ │ │ blobs/snapshots│ │ (render/print/convert) │ + │ oplog/audit │ │ SSE-C / app-enc│ └───────────────────────────┘ + └──────────────────┘ └─────────────────┘ + ▲ + ┌──────────────┴───────────────┐ + │ OIDC IdP (external) │ + │ Keycloak / Authentik / Zitadel│ ← self-hostable for sovereignty + └────────────────────────────────┘ +``` + +Crates (all new unless noted): + +| Crate | Responsibility | +|---|---| +| `loki-server` | Axum binary; wires modules; config; graceful shutdown. | +| `loki-server-api` | REST surface: workspaces, documents, members, blobs. | +| `loki-server-collab` | WebSocket relay, Loro update ingest, presence/awareness. | +| `loki-server-auth` | OIDC verification, session/token issuance, RBAC checks. | +| `loki-server-store` | Persistence port: Postgres (SQLx) + `object_store` adapter. | +| `loki-crypto` | Envelope encryption, key wrapping, crypto-agility (Tier 1/2). | +| `loki-server-audit` | Append-only, hash-chained audit log. | +| `loki-model` (exists) | Shared `DocumentId`, `WorkspaceId`, ACL types (no server deps). | + +The transport port in `loki-server-store` and the fan-out bus in `loki-server-collab` are +traits, so single-node (Postgres LISTEN/NOTIFY) and clustered (Redis pub/sub) deployments +share code — a self-host requirement, not an afterthought. + +--- + +## 2. ADRs + +### ADR-C012 — Single modular Axum binary with a pluggable fan-out bus + +**Context.** We need horizontal scalability for HA behind a load balancer, but self-hosters +must be able to run everything on one small box. Splitting sync and API into separate +services now would double the operational surface for the 90% single-node case. + +**Decision.** Ship one `loki-server` binary containing API, collaboration, and auth as +route modules. Cross-instance awareness (so two collaborators pinned to different instances +see each other) goes through a `FanOutBus` trait with two implementations: +`PgNotifyBus` (default, zero extra infra) and `RedisBus` (for larger clusters). Sticky +sessions at the LB keep a document's WebSocket connections on one instance where possible; +the bus handles the residual cross-instance case. + +**Consequences.** One artifact, one config surface. Self-host stays trivial. If a document +becomes a hotspot, we can later shard by `DocumentId` without changing the public API. + +--- + +### ADR-C013 — Loro wire protocol over WebSocket; snapshot + oplog persistence + +**Context.** Loki already round-trips full `ParaProps`/`CharProps`/`PageLayout`/`HeaderFooter` +through Loro (per `loro_bridge.rs`). The Cloud spec already committed to Loro's official wire +protocol. The server is a relay, not an authority on document semantics. + +**Decision.** Clients exchange Loro update messages over a per-document WebSocket channel. +The server: + +- **Relays** updates to all connected members and appends them to a per-document **oplog**. +- **Compacts** periodically into a **snapshot** (Loro `export(Snapshot)`), stored in object + storage; the oplog is truncated to updates newer than the snapshot. +- **Never merges or interprets** CRDT semantics beyond opaque append + broadcast. This is + what makes Tier 2 (§ADR-C014) possible: under E2EE the server compacts nothing and merely + stores encrypted update blobs plus client-produced encrypted snapshots. + +**Persistence layout.** + +| Store | Data | +|---|---| +| Postgres `doc_oplog` | `(doc_id, seq, actor, payload, created_at)` — recent updates, hot. | +| Object storage | Snapshots (`{doc_id}/snap/{version}`), attachments, images. | +| Postgres `doc_meta` | Title, ACL, current snapshot pointer, encryption tier, residency. | + +Object storage is HDD/Ceph on Hetzner and therefore correct for warm snapshots and blobs +but **not** for the hot oplog — the oplog stays in Postgres on NVMe/volume. + +**Consequences.** Recovery = latest snapshot + replay of oplog tail. Presence is ephemeral +and never persisted. Awareness (cursors/selections) is broadcast-only. + +--- + +### ADR-C014 — Tiered confidentiality model (Tier 0 / Tier 1 CMK / Tier 2 zero-knowledge) + +**Context.** "Optional E2EE" and "enterprise-ready server-side printing/conversion" pull in +opposite directions. A single boolean E2EE flag would either cripple server features or fail +the sovereignty promise. The right primitive is a **tier**, chosen per workspace (default) or +per document (override). + +**Decision.** Three tiers, envelope-encrypted throughout via per-document **DEK**s wrapped by +a tier-specific **KEK**: + +| Tier | Name | Server sees plaintext? | Server-side render/print/convert/search? | Key custody | +|---|---|---|---|---| +| **0** | Transport + at-rest | Yes | Yes | Server / platform KMS | +| **1** | Customer-managed keys (CMK) | Yes, within trust boundary | Yes | Customer KMS/HSM (Vault, PKCS#11) | +| **2** | Zero-knowledge E2EE | No (ciphertext only) | No | Client only | + +- **Tier 0.** TLS 1.3 in transit. At rest: encrypted Postgres volume (LUKS/pgcrypto) and + **SSE-C or app-layer AEAD** for object storage — mandatory because Hetzner Object Storage + has **no default at-rest encryption**. Baseline; all features available. +- **Tier 1 (CMK).** Per-document DEK wrapped by a customer-controlled KEK in their KMS/HSM. + The server decrypts *inside the customer's trust boundary* to run server-side features. Keys + never leave the customer's jurisdiction. This is the sovereignty sweet spot: confidentiality + **and** server-side office printing, because in a self-hosted enterprise the server *is* the + trust boundary. +- **Tier 2 (Zero-knowledge).** DEK is generated and held client-side; the server stores only + ciphertext. Loro updates are AEAD-encrypted (XChaCha20-Poly1305) client-side before send; + snapshots are encrypted client-side. Sharing = re-wrapping the DEK to each new member's + public key (X25519). ACL/membership metadata remains server-visible; content does not. + +**Crypto-agility.** All wrapping is expressed through a `KeyWrap` trait so hybrid PQC +(X25519 + ML-KEM) can be adopted without a data migration — C5:2026 calls out post-quantum +readiness explicitly. + +**Consequences.** One code path, three policies. Enterprises needing both confidentiality and +server-side processing choose Tier 1. Tier 2 is reserved for maximal-confidentiality documents +that accept the loss of server-side processing. + +--- + +### ADR-C015 — E2EE (Tier 2) and server-side document processing are mutually exclusive + +**Context.** Under Tier 2 the server holds only ciphertext, so it cannot render, print, +convert, thumbnail, or index a document. This is a law of the design, not a limitation to be +engineered away. + +**Decision.** Make the exclusivity explicit and enforced, not implicit: + +- Encryption tier is a first-class field on `doc_meta` and on the workspace default. +- Server-side capabilities (headless print, convert, export, server search, thumbnails) are + **feature-gated on `tier != 2`**; the API returns a typed `E2eeCapabilityDisabled` error + rather than silently producing garbage. +- Under Tier 2, those operations are the **client's** responsibility (the client can drive a + local headless instance — see headless spec ADR-C026). +- The UI must surface the trade-off at the moment a workspace/document is set to Tier 2. + +**Consequences.** No surprising failures; the exclusivity is visible in the type system and the +product. A workspace can mix tiers per document if it needs both postures. + +--- + +### ADR-C016 — PostgreSQL system-of-record; object storage via `object_store` + +**Context.** The Cloud spec already chose `object_store` for S3 abstraction. We must run +equally on Hetzner Object Storage (managed, EU-only) and MinIO (self-host), with no code fork. + +**Decision.** + +- **PostgreSQL** holds all relational, transactional, and hot data: workspaces, users, ACLs, + `doc_meta`, `doc_oplog`, audit log, and the apalis job queue (Postgres backend, so no Redis + is *required* for jobs — Redis is optional, for the fan-out bus only at scale). +- **Object storage** (via `object_store`) holds snapshots, images, and attachments. Adapter is + chosen by config URL (`s3://…` for Hetzner/MinIO). SSE-C headers or app-layer AEAD supply the + at-rest encryption Hetzner does not provide by default. +- Schema migrations via `sqlx migrate`, forward-only, zero-downtime (expand/contract pattern). + +**Consequences.** A minimal self-host is Postgres + MinIO + `loki-server`. Managed Hetzner +deployments swap MinIO for Hetzner Object Storage by changing one URL and adding SSE-C keys. + +--- + +### ADR-C017 — OIDC identity delegation; self-hostable IdP; RBAC + +**Context.** Sovereignty forbids hard dependence on a US identity provider. Enterprises expect +SSO. The Cloud spec already chose OIDC delegation. + +**Decision.** + +- The server is an **OIDC relying party**; it never stores passwords. Any compliant IdP works; + the documented sovereign defaults are **Keycloak / Authentik / Zitadel** (all self-hostable, + EU-runnable). Azure AD / Google are supported but flagged as non-sovereign. +- Optional **SCIM 2.0** provisioning endpoint for enterprise user lifecycle. +- **RBAC** at workspace and document scope: + + | Role | Rights | + |---|---| + | Owner | Full control, membership, deletion, tier changes. | + | Editor | Read/write content and metadata. | + | Commenter | Read + comment; no content edit. | + | Viewer | Read only. | + +- Under Tier 2, granting a role additionally requires re-wrapping the document DEK to the new + member's public key (an out-of-band, client-driven step surfaced by the API). + +**Consequences.** No proprietary auth. Enterprises keep identity in-house. Access decisions are +always server-authoritative (metadata), independent of tier. + +--- + +### ADR-C018 — Deployment: Hetzner (OpenTofu) and self-host (Compose / Helm), EU region pinning + +**Context.** Users deploy to Hetzner Cloud *or* their own infrastructure. The two must not +diverge functionally. + +**Decision.** Three first-class deployment artifacts, all in-repo: + +1. **OpenTofu/Terraform module** targeting the `hcloud` provider: CCX (dedicated-vCPU) servers + for `loki-server` and render workers, Private Networks, a Hetzner Load Balancer, an encrypted + Volume for Postgres data, and Hetzner Object Storage buckets. **Region is pinned to `fsn1`, + `nbg1`, or `hel1` (DE/FI) only** — never a US location — with the choice recorded as the + document residency value. +2. **Docker Compose** (single node): Postgres + MinIO + `loki-server` + one headless worker, + for evaluation and small self-host. +3. **Helm chart** (k3s/k8s): HA `loki-server` replicas, external or in-cluster Postgres, + MinIO or external S3, horizontal worker pool. + +Config is env + optional file; secrets via env / Docker secrets / Vault. **No phone-home; +telemetry opt-in and off by default.** + +**Consequences.** "Deploy to the cloud or your own infrastructure" is literally one of three +supported paths, not a porting exercise. + +--- + +### ADR-C019 — Digital-sovereignty posture + +**Context.** Target market is European Digital Sovereignty: EU public sector and regulated +industry. Certifications don't change jurisdiction — a US-owned provider remains exposed to the +US CLOUD Act regardless of badges — so the architecture itself must carry the sovereignty +guarantee. + +**Decision.** Sovereignty is a design property, mapped to the EU Cloud Sovereignty Framework's +SOV objectives: + +- **Data residency (SOV-2).** All data in EU DCs; residency pinned and recorded per document; + object storage region enforced at config validation time. +- **No non-EU jurisdiction dependency (SOV-1).** Hetzner is a German GmbH operating EU-only + DCs; self-hostable IdP; no mandatory US SaaS in the data path. +- **Open standards / no lock-in / portability (SOV-6).** `object_store` abstraction, standard + S3, standard OIDC, open-source stack, documented backup/restore → workload is portable off + Hetzner or off the cloud entirely. +- **Operational sovereignty (SOV-4).** Full self-host path means the customer's own staff can + operate, patch, and recover with no vendor in the loop. +- **Crypto (C5:2026).** Crypto-agile key wrapping ready for hybrid PQC; confidential-computing + hardening for Tier-1 (AMD SEV-SNP / Intel TDX) is **adopted** — see headless ADR-C028. + +Design to **C5:2026 / EUCS-Substantial-level** controls. EUCS itself remains voluntary and its +"High/sovereignty" tier is still unresolved at EU level, so we target the stable C5:2026 baseline +and track the forthcoming BSI cloud-sovereignty catalogue. + +**Consequences.** The sovereignty story survives audit because it rests on ownership, +residency, and portability — not on marketing. + +--- + +### ADR-C020 — Audit, retention, and GDPR data-subject operations + +**Decision.** + +- **Append-only, hash-chained audit log** (`loki-server-audit`): auth events, ACL changes, tier + changes, exports, deletions. Each entry carries the prior entry's hash → tamper-evident. +- **Retention & legal hold** via object-storage **Object Lock** (retention + legal hold), + available on Hetzner and MinIO. +- **GDPR operations** as first-class endpoints: data export (portability), right-to-erasure + (crypto-shredding the document DEK renders Tier 1/2 ciphertext unrecoverable — clean erasure), + and records of processing. DPA-ready. + +**Consequences.** Erasure is provable under Tier 1/2 by destroying keys; audit is defensible. + +--- + +## 3. API surface (sketch) + +REST, JSON, versioned under `/v1`. WebSocket for collaboration. + +``` +POST /v1/workspaces +GET /v1/workspaces/{ws}/documents +POST /v1/workspaces/{ws}/documents # sets tier (default = workspace tier) +GET /v1/documents/{doc} # metadata; content via WS/snapshot +GET /v1/documents/{doc}/snapshot # latest snapshot (cipher or plain by tier) +POST /v1/documents/{doc}/members # role grant (+ DEK re-wrap under Tier 2) +POST /v1/documents/{doc}/blobs # attachment/image upload +WS /v1/documents/{doc}/collab # Loro updates + awareness +POST /v1/documents/{doc}/export # 409 E2eeCapabilityDisabled if tier==2 +GET /v1/gdpr/export POST /v1/gdpr/erase +``` + +Errors are typed (`thiserror`) and mapped to stable problem+json codes; `E2eeCapabilityDisabled` +is the canonical Tier-2/server-side rejection. + +--- + +## 4. Data model (essentials) + +```sql +workspace(id, name, default_tier, residency, created_at) +app_user(id, oidc_sub, display_name, public_key /* X25519, for Tier 2 */) +doc_meta(id, workspace_id, title, tier, residency, snapshot_ptr, dek_wrapped, created_at) +doc_member(doc_id, user_id, role, dek_wrapped_for_user /* Tier 2 only */) +doc_oplog(doc_id, seq, actor, payload, created_at) -- payload is ciphertext under Tier 2 +audit_log(id, prev_hash, hash, actor, action, target, created_at) +-- apalis job tables (Postgres backend) +``` + +--- + +## 5. HA, backup, observability + +- **HA:** ≥2 `loki-server` replicas behind the LB; Postgres primary + streaming replica; + fan-out bus makes collaboration instance-independent. +- **Backup:** Postgres PITR (WAL archived to object storage); object storage versioning + + Object Lock; documented restore runbook. +- **Observability:** OpenTelemetry traces, Prometheus metrics, structured JSON logs. No content + or plaintext ever logged; under Tier 2 nothing to leak by construction. +- **Security headers, rate limiting, CSP** at the proxy and app layers. + +--- + +## 6. Ratified decisions + +1. **Default tier — RATIFIED.** Tier 0 default for SaaS; Tier 1 default for enterprise + self-host; Tier 2 opt-in per document. +2. **Fan-out bus — RATIFIED.** `PgNotifyBus` is the default. Switch to `RedisBus` when any of: + `loki-server` replicas > 3; sustained cross-instance update fan-out > ~1,000 msg/s; or + concurrent collaborative WebSocket connections > ~2,000. The switch point is a single config + tunable; these are shipped defaults, not hard limits. +3. **Presence under Tier 2 — RATIFIED.** Awareness payloads are AEAD-encrypted client-side — + cursor sharing is retained, contents opaque to the server. +4. **ADR numbering — RATIFIED.** The C-series continues at C012. diff --git a/loki-convert/Cargo.toml b/loki-convert/Cargo.toml new file mode 100644 index 00000000..0d8da483 --- /dev/null +++ b/loki-convert/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "loki-convert" +version = "0.1.0" +edition = "2024" +license = "Apache-2.0" +description = "Format-conversion matrix over Loki's existing import/export crates (headless spec ADR-C024)" +repository = "https://github.com/appthere/loki" + +[dependencies] +thiserror = { workspace = true } +loki-doc-model = { path = "../loki-doc-model" } +loki-sheet-model = { path = "../loki-sheet-model" } +loki-ooxml = { path = "../loki-ooxml", features = ["docx", "xlsx"] } +loki-odf = { path = "../loki-odf" } +loki-epub = { path = "../loki-epub" } +loki-pdf = { path = "../loki-pdf" } diff --git a/loki-convert/src/error.rs b/loki-convert/src/error.rs new file mode 100644 index 00000000..9e7b534d --- /dev/null +++ b/loki-convert/src/error.rs @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Typed conversion errors — the canonical rejection is +//! [`ConvertError::ConversionUnsupported`] (headless spec §3). + +use crate::format::Format; + +/// Conversion failures. +#[derive(Debug, thiserror::Error)] +pub enum ConvertError { + /// The pair is not in the capability matrix (ADR-C024): either no code + /// path exists, or the pair is deliberately gated (PPTX/ODP/ODG, + /// ratified decision §5.1). Never silently lossy. + // (Field is `source_format`, not `source` — thiserror reserves the + // latter name for the error-source chain.) + #[error("conversion from {source_format} to {target_format} is not supported: {reason}")] + ConversionUnsupported { + /// Source format. + source_format: Format, + /// Target format. + target_format: Format, + /// Why the pair is absent (missing importer/exporter or gated). + reason: &'static str, + }, + /// A format name/extension was not recognised. + #[error("unknown format {0:?}")] + UnknownFormat(String), + /// A `--profile` value was not recognised. + #[error("unknown PDF profile {0:?} (expected pdf, pdf-x1a, pdf-x3, pdf-x4, or pdf-a2b)")] + UnknownProfile(String), + /// The profile is recognised but this engine cannot emit it yet. + #[error("PDF profile {0} is not yet supported by the PDF engine")] + ProfileUnsupported(&'static str), + /// A PDF profile was supplied for a non-PDF target. + #[error("a PDF profile applies only when the target format is pdf")] + ProfileWithoutPdfTarget, + /// OOXML import/export failed. + #[error("ooxml error: {0}")] + Ooxml(#[from] loki_ooxml::error::OoxmlError), + /// ODF import/export failed. + #[error("odf error: {0}")] + Odf(#[from] loki_odf::error::OdfError), + /// EPUB export failed. + #[error("epub error: {0}")] + Epub(#[from] loki_epub::EpubError), + /// PDF export failed. + #[error("pdf error: {0}")] + Pdf(#[from] loki_pdf::PdfError), +} diff --git a/loki-convert/src/format.rs b/loki-convert/src/format.rs new file mode 100644 index 00000000..717b9f8e --- /dev/null +++ b/loki-convert/src/format.rs @@ -0,0 +1,135 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! File formats and PDF conformance profiles. + +/// A file format Loki can (potentially) convert between. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Format { + /// Word document (OOXML). + Docx, + /// OpenDocument text. + Odt, + /// Excel workbook (OOXML). + Xlsx, + /// OpenDocument spreadsheet. + Ods, + /// PowerPoint presentation (OOXML) — gated, see ratified decision §5.1. + Pptx, + /// OpenDocument presentation — gated alongside PPTX. + Odp, + /// OpenDocument graphics — gated alongside PPTX. + Odg, + /// EPUB 3.3 (export only). + Epub, + /// PDF (export only; profile selected via [`crate::PdfProfile`]). + Pdf, +} + +impl Format { + /// All formats, for capability-table iteration. + pub const ALL: [Format; 9] = [ + Format::Docx, + Format::Odt, + Format::Xlsx, + Format::Ods, + Format::Pptx, + Format::Odp, + Format::Odg, + Format::Epub, + Format::Pdf, + ]; + + /// Guesses the format from a file extension (case-insensitive). + #[must_use] + pub fn from_extension(ext: &str) -> Option { + match ext.to_ascii_lowercase().as_str() { + "docx" => Some(Format::Docx), + "odt" => Some(Format::Odt), + "xlsx" => Some(Format::Xlsx), + "ods" => Some(Format::Ods), + "pptx" => Some(Format::Pptx), + "odp" => Some(Format::Odp), + "odg" => Some(Format::Odg), + "epub" => Some(Format::Epub), + "pdf" => Some(Format::Pdf), + _ => None, + } + } + + /// Canonical lowercase name (matches the file extension). + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Format::Docx => "docx", + Format::Odt => "odt", + Format::Xlsx => "xlsx", + Format::Ods => "ods", + Format::Pptx => "pptx", + Format::Odp => "odp", + Format::Odg => "odg", + Format::Epub => "epub", + Format::Pdf => "pdf", + } + } +} + +impl std::fmt::Display for Format { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +impl std::str::FromStr for Format { + type Err = crate::ConvertError; + + fn from_str(s: &str) -> Result { + Format::from_extension(s).ok_or_else(|| crate::ConvertError::UnknownFormat(s.to_owned())) + } +} + +/// PDF conformance profile (headless spec ADR-C022 / §3 `--profile`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum PdfProfile { + /// General output. Emitted by the current `loki-pdf` engine as its + /// default (PDF/X-1a-shaped output that any reader accepts). + #[default] + Default, + /// PDF/X-1a — CMYK/spot print production. + PdfX1a, + /// PDF/X-3 — CMYK plus ICC-tagged colour. + PdfX3, + /// PDF/X-4 — print production with live transparency. + PdfX4, + /// PDF/A-2b — archival. Recognised but not yet emitted; see + /// [`crate::ConvertError::ProfileUnsupported`]. + PdfA2b, +} + +impl PdfProfile { + /// CLI/API token (`pdf-x4`, `pdf-a2b`, …). + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + PdfProfile::Default => "pdf", + PdfProfile::PdfX1a => "pdf-x1a", + PdfProfile::PdfX3 => "pdf-x3", + PdfProfile::PdfX4 => "pdf-x4", + PdfProfile::PdfA2b => "pdf-a2b", + } + } +} + +impl std::str::FromStr for PdfProfile { + type Err = crate::ConvertError; + + fn from_str(s: &str) -> Result { + match s { + "pdf" => Ok(PdfProfile::Default), + "pdf-x1a" => Ok(PdfProfile::PdfX1a), + "pdf-x3" => Ok(PdfProfile::PdfX3), + "pdf-x4" => Ok(PdfProfile::PdfX4), + "pdf-a2b" => Ok(PdfProfile::PdfA2b), + other => Err(crate::ConvertError::UnknownProfile(other.to_owned())), + } + } +} diff --git a/loki-convert/src/lib.rs b/loki-convert/src/lib.rs new file mode 100644 index 00000000..58b75194 --- /dev/null +++ b/loki-convert/src/lib.rs @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Format-conversion matrix (headless spec ADR-C024). +//! +//! Conversion is orchestration, not new format code: every pair goes +//! *import source → in-memory model → export target* through the existing +//! `loki-ooxml` / `loki-odf` / `loki-epub` / `loki-pdf` crates, so +//! conversion quality is exactly the round-trip quality those crates +//! already measure. Unsupported pairs are a typed +//! [`ConvertError::ConversionUnsupported`], never a lossy best-effort; +//! PPTX (and ODP/ODG) stay gated until the ACID PPTX generator lands +//! (ratified decision §5.1). + +#![forbid(unsafe_code)] + +mod error; +mod format; +mod matrix; +mod pipeline; + +pub use error::ConvertError; +pub use format::{Format, PdfProfile}; +pub use matrix::{is_supported, supported_pairs}; +pub use pipeline::{ConvertOptions, ConvertOutput, convert}; diff --git a/loki-convert/src/matrix.rs b/loki-convert/src/matrix.rs new file mode 100644 index 00000000..8aa742fc --- /dev/null +++ b/loki-convert/src/matrix.rs @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! The static capability matrix (ADR-C024). +//! +//! Support is derived from which importers/exporters actually exist, plus +//! the deliberate PPTX/ODP/ODG gate (ratified decision §5.1). A pair absent +//! here is a typed error at the API surface, never a best-effort. + +use crate::format::Format; + +const GATED_PRESENTATION: &str = "presentation/graphics conversion is gated until the ACID PPTX generator's 29 cases pass \ + (ratified decision 5.1)"; + +/// Why a pair is unsupported, or `None` when it is supported. +pub(crate) fn unsupported_reason(source: Format, target: Format) -> Option<&'static str> { + use Format::{Docx, Epub, Odg, Odp, Ods, Odt, Pdf, Pptx, Xlsx}; + if matches!(source, Pptx | Odp | Odg) || matches!(target, Pptx | Odp | Odg) { + return Some(GATED_PRESENTATION); + } + match source { + Epub => return Some("EPUB is export-only; there is no EPUB importer"), + Pdf => return Some("PDF is export-only; there is no PDF importer"), + _ => {} + } + let text_source = matches!(source, Docx | Odt); + match target { + Docx | Odt | Epub | Pdf if text_source => None, + Xlsx | Ods if !text_source => None, + Xlsx | Ods => Some("spreadsheet targets require a spreadsheet source (XLSX/ODS)"), + _ => Some( + "text-document targets require a text-document source; spreadsheets have no \ + layout/PDF path yet", + ), + } +} + +/// Whether `source → target` is in the capability matrix. +#[must_use] +pub fn is_supported(source: Format, target: Format) -> bool { + unsupported_reason(source, target).is_none() +} + +/// Every supported `(source, target)` pair, for `--list`-style output and +/// conformance-plan iteration. +#[must_use] +pub fn supported_pairs() -> Vec<(Format, Format)> { + let mut pairs = Vec::new(); + for source in Format::ALL { + for target in Format::ALL { + if is_supported(source, target) { + pairs.push((source, target)); + } + } + } + pairs +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn matrix_matches_the_spec() { + // Text documents convert among themselves and to EPUB/PDF. + for source in [Format::Docx, Format::Odt] { + for target in [Format::Docx, Format::Odt, Format::Epub, Format::Pdf] { + assert!(is_supported(source, target), "{source}->{target}"); + } + } + // Spreadsheets convert among themselves only (no PDF path yet). + for source in [Format::Xlsx, Format::Ods] { + for target in [Format::Xlsx, Format::Ods] { + assert!(is_supported(source, target), "{source}->{target}"); + } + assert!(!is_supported(source, Format::Pdf)); + assert!(!is_supported(source, Format::Docx)); + } + // Cross-family and export-only sources are rejected. + assert!(!is_supported(Format::Docx, Format::Xlsx)); + assert!(!is_supported(Format::Epub, Format::Pdf)); + assert!(!is_supported(Format::Pdf, Format::Docx)); + // The presentation gate (ratified decision 5.1). + for gated in [Format::Pptx, Format::Odp, Format::Odg] { + assert!(!is_supported(gated, Format::Pdf)); + assert!(!is_supported(Format::Docx, gated)); + } + assert_eq!(supported_pairs().len(), 2 * 4 + 2 * 2); + } +} diff --git a/loki-convert/src/pipeline.rs b/loki-convert/src/pipeline.rs new file mode 100644 index 00000000..7dcf3415 --- /dev/null +++ b/loki-convert/src/pipeline.rs @@ -0,0 +1,172 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! The conversion pipeline: import source → in-memory model → export target. + +use std::io::Cursor; + +use loki_doc_model::document::Document; +use loki_doc_model::io::DocumentExport; +use loki_epub::{EpubExport, EpubOptions}; +use loki_odf::odt::import::OdtImporter; +use loki_odf::{OdsExport, OdsImport, OdsImportOptions, OdtExport, OdtImportOptions}; +use loki_ooxml::docx::import::DocxImporter; +use loki_ooxml::{DocxExport, DocxImportOptions, XlsxExport, XlsxImport, XlsxImportOptions}; +use loki_pdf::{PdfXLevel, PdfXOptions}; +use loki_sheet_model::workbook::Workbook; + +use crate::error::ConvertError; +use crate::format::{Format, PdfProfile}; +use crate::matrix::unsupported_reason; + +/// Options applied across the whole conversion. +#[derive(Debug, Clone, Default)] +pub struct ConvertOptions { + /// PDF conformance profile; only meaningful when the target is PDF. + pub pdf_profile: PdfProfile, + /// Overrides the document title in formats that carry one. + pub title: Option, +} + +/// A successful conversion. +#[derive(Debug)] +pub struct ConvertOutput { + /// The converted file bytes. + pub bytes: Vec, + /// Non-fatal import warnings (unrecognised elements, unresolved + /// relationships, …) — surfaced so batch jobs can log quality issues. + pub warnings: Vec, +} + +/// Converts `input` (bytes of a `source`-format file) into `target` format. +/// +/// Unsupported pairs return [`ConvertError::ConversionUnsupported`] before +/// any parsing happens (ADR-C024). +pub fn convert( + source: Format, + input: &[u8], + target: Format, + options: &ConvertOptions, +) -> Result { + if let Some(reason) = unsupported_reason(source, target) { + return Err(ConvertError::ConversionUnsupported { + source_format: source, + target_format: target, + reason, + }); + } + if options.pdf_profile != PdfProfile::Default && target != Format::Pdf { + return Err(ConvertError::ProfileWithoutPdfTarget); + } + match source { + Format::Docx | Format::Odt => { + let text_source = if source == Format::Docx { + TextSource::Docx + } else { + TextSource::Odt + }; + let (mut doc, warnings) = import_text(text_source, input)?; + if let Some(title) = &options.title { + doc.meta.title = Some(title.clone()); + } + let bytes = export_text(&doc, source, target, options)?; + Ok(ConvertOutput { bytes, warnings }) + } + Format::Xlsx | Format::Ods => { + let workbook = import_sheet(source, input)?; + let bytes = export_sheet(&workbook, target)?; + Ok(ConvertOutput { + bytes, + warnings: Vec::new(), + }) + } + // unsupported_reason() already rejected every other source; this arm + // exists only to keep the match exhaustive without panicking. + _ => Err(ConvertError::ConversionUnsupported { + source_format: source, + target_format: target, + reason: "source format has no import path", + }), + } +} + +/// Text-document sources, narrowed after the matrix check. +enum TextSource { + Docx, + Odt, +} + +fn import_text(source: TextSource, input: &[u8]) -> Result<(Document, Vec), ConvertError> { + let cursor = Cursor::new(input); + match source { + TextSource::Docx => { + let result = DocxImporter::new(DocxImportOptions::default()).run(cursor)?; + let warnings = result.warnings.iter().map(|w| format!("{w:?}")).collect(); + Ok((result.document, warnings)) + } + TextSource::Odt => { + let result = OdtImporter::new(OdtImportOptions::default()).run(cursor)?; + let warnings = result.warnings.iter().map(|w| format!("{w:?}")).collect(); + Ok((result.document, warnings)) + } + } +} + +fn export_text( + doc: &Document, + source: Format, + target: Format, + options: &ConvertOptions, +) -> Result, ConvertError> { + let mut cursor = Cursor::new(Vec::new()); + match target { + Format::Docx => DocxExport::export(doc, &mut cursor, ())?, + Format::Odt => OdtExport::export(doc, &mut cursor, Default::default())?, + Format::Epub => EpubExport::export(doc, &mut cursor, EpubOptions::default())?, + Format::Pdf => { + let level = match options.pdf_profile { + PdfProfile::Default | PdfProfile::PdfX1a => PdfXLevel::X1a, + PdfProfile::PdfX3 => PdfXLevel::X3, + PdfProfile::PdfX4 => PdfXLevel::X4, + // TODO(headless-c022): PDF/A-2b needs the krilla engine + // migration; the current pdf-writer engine emits PDF/X only. + PdfProfile::PdfA2b => return Err(ConvertError::ProfileUnsupported("pdf-a2b")), + }; + let pdf_options = PdfXOptions { + level, + title: options.title.clone(), + ..Default::default() + }; + let mut out = Vec::new(); + loki_pdf::export_document(doc, &pdf_options, &mut out)?; + return Ok(out); + } + // The matrix admits only the targets above for text sources. + other => { + return Err(ConvertError::ConversionUnsupported { + source_format: source, + target_format: other, + reason: "target format has no text-document export path", + }); + } + } + Ok(cursor.into_inner()) +} + +fn import_sheet(source: Format, input: &[u8]) -> Result { + let cursor = Cursor::new(input); + match source { + Format::Ods => Ok(OdsImport::import(cursor, OdsImportOptions::default())?), + // The caller only passes Xlsx or Ods; default to the XLSX importer. + _ => Ok(XlsxImport::import(cursor, XlsxImportOptions::default())?), + } +} + +fn export_sheet(workbook: &Workbook, target: Format) -> Result, ConvertError> { + let mut cursor = Cursor::new(Vec::new()); + match target { + Format::Ods => OdsExport::export(workbook, &mut cursor)?, + // The matrix admits only Xlsx | Ods here. + _ => XlsxExport::export(workbook, &mut cursor)?, + } + Ok(cursor.into_inner()) +} diff --git a/loki-convert/tests/matrix_round_trips.rs b/loki-convert/tests/matrix_round_trips.rs new file mode 100644 index 00000000..900d9244 --- /dev/null +++ b/loki-convert/tests/matrix_round_trips.rs @@ -0,0 +1,157 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! End-to-end conversions through real format bytes: build a document / +//! workbook, export it, run it through the matrix, and validate the output +//! parses as the target format (ADR-C024). + +use std::io::Cursor; + +use loki_convert::{ConvertError, ConvertOptions, Format, PdfProfile, convert}; +use loki_doc_model::content::block::Block; +use loki_doc_model::content::inline::Inline; +use loki_doc_model::document::Document; +use loki_doc_model::io::{DocumentExport, DocumentImport}; +use loki_ooxml::{DocxExport, DocxImport}; +use loki_sheet_model::workbook::{Workbook, Worksheet}; + +fn sample_docx() -> Vec { + let mut doc = Document::new(); + doc.meta.title = Some("Conversion fixture".into()); + if let Some(section) = doc.sections.first_mut() { + section.blocks.push(Block::Heading( + 1, + Default::default(), + vec![Inline::Str("Quarterly report".into())], + )); + section.blocks.push(Block::Para(vec![Inline::Str( + "Printed and converted without a GPU.".into(), + )])); + } + let mut cursor = Cursor::new(Vec::new()); + DocxExport::export(&doc, &mut cursor, ()).expect("fixture docx"); + cursor.into_inner() +} + +fn sample_ods() -> Vec { + let mut workbook = Workbook::new(); + workbook.sheets.push(Worksheet::new("Sheet1")); + let mut cursor = Cursor::new(Vec::new()); + loki_odf::OdsExport::export(&workbook, &mut cursor).expect("fixture ods"); + cursor.into_inner() +} + +#[test] +fn docx_to_odt_to_docx_preserves_text() { + let docx = sample_docx(); + let odt = convert(Format::Docx, &docx, Format::Odt, &ConvertOptions::default()).unwrap(); + assert_eq!(&odt.bytes[..2], b"PK"); + + let back = convert( + Format::Odt, + &odt.bytes, + Format::Docx, + &ConvertOptions::default(), + ) + .unwrap(); + let doc = DocxImport::import(Cursor::new(&back.bytes), Default::default()).unwrap(); + let text = format!("{:?}", doc.sections); + assert!( + text.contains("Quarterly report"), + "heading lost in round trip" + ); + assert!( + text.contains("without a GPU"), + "paragraph lost in round trip" + ); +} + +#[test] +fn docx_to_pdf_emits_requested_profile() { + let docx = sample_docx(); + let options = ConvertOptions { + pdf_profile: PdfProfile::PdfX4, + title: Some("Print run".into()), + }; + let pdf = convert(Format::Docx, &docx, Format::Pdf, &options).unwrap(); + assert!(pdf.bytes.starts_with(b"%PDF-1."), "missing PDF header"); + let text = String::from_utf8_lossy(&pdf.bytes); + assert!(text.contains("PDF/X-4"), "PDF/X-4 marker missing"); +} + +#[test] +fn docx_to_epub_produces_epub_container() { + let docx = sample_docx(); + let epub = convert( + Format::Docx, + &docx, + Format::Epub, + &ConvertOptions::default(), + ) + .unwrap(); + // OCF: ZIP whose first entry is the stored `mimetype` file. + assert_eq!(&epub.bytes[..2], b"PK"); + assert!( + epub.bytes.windows(20).any(|w| w == b"application/epub+zip"), + "EPUB mimetype missing" + ); +} + +#[test] +fn ods_and_xlsx_round_trip() { + let ods = sample_ods(); + let xlsx = convert(Format::Ods, &ods, Format::Xlsx, &ConvertOptions::default()).unwrap(); + assert_eq!(&xlsx.bytes[..2], b"PK"); + let back = convert( + Format::Xlsx, + &xlsx.bytes, + Format::Ods, + &ConvertOptions::default(), + ) + .unwrap(); + assert_eq!(&back.bytes[..2], b"PK"); +} + +#[test] +fn unsupported_pairs_are_typed_and_parse_nothing() { + // Cross-family: even valid bytes are rejected before parsing. + let err = convert( + Format::Docx, + b"garbage", + Format::Xlsx, + &ConvertOptions::default(), + ) + .unwrap_err(); + assert!(matches!(err, ConvertError::ConversionUnsupported { .. })); + + // The presentation gate (ratified decision 5.1). + let err = convert( + Format::Pptx, + b"garbage", + Format::Pdf, + &ConvertOptions::default(), + ) + .unwrap_err(); + assert!(matches!(err, ConvertError::ConversionUnsupported { .. })); +} + +#[test] +fn pdf_a2b_is_honestly_unsupported() { + let docx = sample_docx(); + let options = ConvertOptions { + pdf_profile: PdfProfile::PdfA2b, + title: None, + }; + let err = convert(Format::Docx, &docx, Format::Pdf, &options).unwrap_err(); + assert!(matches!(err, ConvertError::ProfileUnsupported("pdf-a2b"))); +} + +#[test] +fn profile_on_non_pdf_target_is_rejected() { + let docx = sample_docx(); + let options = ConvertOptions { + pdf_profile: PdfProfile::PdfX4, + title: None, + }; + let err = convert(Format::Docx, &docx, Format::Odt, &options).unwrap_err(); + assert!(matches!(err, ConvertError::ProfileWithoutPdfTarget)); +} diff --git a/loki-crypto/Cargo.toml b/loki-crypto/Cargo.toml new file mode 100644 index 00000000..46fa4406 --- /dev/null +++ b/loki-crypto/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "loki-crypto" +version = "0.1.0" +edition = "2024" +license = "Apache-2.0" +description = "Envelope encryption and crypto-agile key wrapping for the Loki server (ADR-C014)" +repository = "https://github.com/appthere/loki" + +[dependencies] +thiserror = { workspace = true } +serde = { workspace = true } +chacha20poly1305 = { workspace = true } +x25519-dalek = { workspace = true } +hkdf = { workspace = true } +sha2 = { workspace = true } +rand_core = { workspace = true } +zeroize = { workspace = true } +base64 = { workspace = true } + +[dev-dependencies] +serde_json = { workspace = true } diff --git a/loki-crypto/src/aead_wrap.rs b/loki-crypto/src/aead_wrap.rs new file mode 100644 index 00000000..9fb746da --- /dev/null +++ b/loki-crypto/src/aead_wrap.rs @@ -0,0 +1,120 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Symmetric-KEK key wrapping for Tiers 0 and 1 (ADR-C014). + +use zeroize::{Zeroize, ZeroizeOnDrop}; + +use crate::dek::{DEK_LEN, Dek}; +use crate::error::CryptoError; +use crate::wrap::{KeyWrap, WrappedDek}; + +/// Stable algorithm tag for this scheme. +pub const AEAD_WRAP_ALGORITHM: &str = "xchacha20-poly1305-kek.v1"; + +/// A symmetric key-encryption key. +/// +/// Under Tier 0 the KEK lives in the platform KMS; under Tier 1 it is +/// customer-managed (Vault, PKCS#11). In both cases the raw KEK bytes enter +/// this process only inside the relevant trust boundary. +#[derive(Clone, Zeroize, ZeroizeOnDrop)] +pub struct Kek([u8; DEK_LEN]); + +impl Kek { + /// Reconstructs a KEK from raw bytes supplied by the KMS integration. + pub fn from_bytes(bytes: &[u8]) -> Result { + let arr: [u8; DEK_LEN] = bytes + .try_into() + .map_err(|_| CryptoError::InvalidKeyLength { + expected: DEK_LEN, + actual: bytes.len(), + })?; + Ok(Self(arr)) + } + + /// Generates a fresh random KEK (used by tests and local-dev setups). + #[must_use] + pub fn generate() -> Self { + Self(*Dek::generate().as_bytes()) + } +} + +impl std::fmt::Debug for Kek { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("Kek(..)") + } +} + +/// Wraps DEKs under a symmetric KEK with XChaCha20-Poly1305. +#[derive(Debug)] +pub struct AeadKeyWrap { + kek: Kek, +} + +impl AeadKeyWrap { + /// Creates a wrapper around the given KEK. + #[must_use] + pub fn new(kek: Kek) -> Self { + Self { kek } + } +} + +impl KeyWrap for AeadKeyWrap { + fn algorithm(&self) -> &'static str { + AEAD_WRAP_ALGORITHM + } + + fn wrap(&self, dek: &Dek) -> Result { + // The KEK is structurally a 256-bit XChaCha20 key, so reuse Dek's + // sealed-blob layout (nonce || ciphertext+tag) with the algorithm + // tag as AAD. + let kek_cipher = Dek::from_bytes(&self.kek.0)?; + let blob = kek_cipher.seal(dek.as_bytes(), AEAD_WRAP_ALGORITHM.as_bytes())?; + Ok(WrappedDek { + algorithm: AEAD_WRAP_ALGORITHM.to_owned(), + blob, + }) + } + + fn unwrap_dek(&self, wrapped: &WrappedDek) -> Result { + if wrapped.algorithm != AEAD_WRAP_ALGORITHM { + return Err(CryptoError::UnsupportedAlgorithm(wrapped.algorithm.clone())); + } + let kek_cipher = Dek::from_bytes(&self.kek.0)?; + let raw = kek_cipher.open(&wrapped.blob, AEAD_WRAP_ALGORITHM.as_bytes())?; + Dek::from_bytes(&raw) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn wrap_unwrap_round_trip() { + let wrapper = AeadKeyWrap::new(Kek::generate()); + let dek = Dek::generate(); + let wrapped = wrapper.wrap(&dek).unwrap(); + assert_eq!(wrapped.algorithm, AEAD_WRAP_ALGORITHM); + let unwrapped = wrapper.unwrap_dek(&wrapped).unwrap(); + assert_eq!(unwrapped.as_bytes(), dek.as_bytes()); + } + + #[test] + fn wrong_kek_fails() { + let wrapper = AeadKeyWrap::new(Kek::generate()); + let other = AeadKeyWrap::new(Kek::generate()); + let wrapped = wrapper.wrap(&Dek::generate()).unwrap(); + assert!(other.unwrap_dek(&wrapped).is_err()); + } + + #[test] + fn algorithm_mismatch_is_typed() { + let wrapper = AeadKeyWrap::new(Kek::generate()); + let mut wrapped = wrapper.wrap(&Dek::generate()).unwrap(); + wrapped.algorithm = "x25519-xchacha20.v1".to_owned(); + assert!(matches!( + wrapper.unwrap_dek(&wrapped), + Err(CryptoError::UnsupportedAlgorithm(_)) + )); + } +} diff --git a/loki-crypto/src/dek.rs b/loki-crypto/src/dek.rs new file mode 100644 index 00000000..a39fbe4b --- /dev/null +++ b/loki-crypto/src/dek.rs @@ -0,0 +1,143 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Per-document data-encryption keys and AEAD sealing. + +use chacha20poly1305::aead::{Aead, AeadCore, KeyInit, Payload}; +use chacha20poly1305::{XChaCha20Poly1305, XNonce}; +use rand_core::OsRng; +use zeroize::{Zeroize, ZeroizeOnDrop}; + +use crate::error::CryptoError; + +/// Byte length of a DEK (XChaCha20-Poly1305 key). +pub const DEK_LEN: usize = 32; + +/// XChaCha20-Poly1305 nonce length; the nonce is prepended to ciphertext. +const NONCE_LEN: usize = 24; + +/// A per-document data-encryption key. +/// +/// The key material is zeroized on drop. Destroying every wrapped copy of a +/// DEK crypto-shreds the document (GDPR erasure, ADR-C020). +#[derive(Clone, Zeroize, ZeroizeOnDrop)] +pub struct Dek([u8; DEK_LEN]); + +impl Dek { + /// Generates a fresh random DEK. + #[must_use] + pub fn generate() -> Self { + let key = XChaCha20Poly1305::generate_key(&mut OsRng); + Self(key.into()) + } + + /// Reconstructs a DEK from raw bytes (e.g. after unwrapping). + pub fn from_bytes(bytes: &[u8]) -> Result { + let arr: [u8; DEK_LEN] = bytes + .try_into() + .map_err(|_| CryptoError::InvalidKeyLength { + expected: DEK_LEN, + actual: bytes.len(), + })?; + Ok(Self(arr)) + } + + /// Exposes the raw key bytes (needed to wrap the DEK; handle with care). + #[must_use] + pub fn as_bytes(&self) -> &[u8; DEK_LEN] { + &self.0 + } + + /// Seals `plaintext` with this DEK, binding `aad` (e.g. the document id). + /// + /// Output layout: `nonce (24 bytes) || ciphertext+tag`. A fresh random + /// nonce is used per call; XChaCha's 192-bit nonce makes random nonces + /// collision-safe at any realistic volume. + pub fn seal(&self, plaintext: &[u8], aad: &[u8]) -> Result, CryptoError> { + let cipher = XChaCha20Poly1305::new(self.0.as_slice().into()); + let nonce = XChaCha20Poly1305::generate_nonce(&mut OsRng); + let ciphertext = cipher + .encrypt( + &nonce, + Payload { + msg: plaintext, + aad, + }, + ) + .map_err(|_| CryptoError::EncryptFailed)?; + let mut out = Vec::with_capacity(NONCE_LEN + ciphertext.len()); + out.extend_from_slice(&nonce); + out.extend_from_slice(&ciphertext); + Ok(out) + } + + /// Opens a blob produced by [`Dek::seal`] with the same `aad`. + pub fn open(&self, sealed: &[u8], aad: &[u8]) -> Result, CryptoError> { + if sealed.len() < NONCE_LEN { + return Err(CryptoError::CiphertextTooShort(sealed.len())); + } + let (nonce, ciphertext) = sealed.split_at(NONCE_LEN); + let cipher = XChaCha20Poly1305::new(self.0.as_slice().into()); + cipher + .decrypt( + XNonce::from_slice(nonce), + Payload { + msg: ciphertext, + aad, + }, + ) + .map_err(|_| CryptoError::DecryptFailed) + } +} + +impl std::fmt::Debug for Dek { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // Never print key material. + f.write_str("Dek(..)") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn seal_open_round_trip() { + let dek = Dek::generate(); + let sealed = dek.seal(b"hello loki", b"doc-1").unwrap(); + assert_eq!(dek.open(&sealed, b"doc-1").unwrap(), b"hello loki"); + } + + #[test] + fn wrong_key_or_aad_fails() { + let dek = Dek::generate(); + let other = Dek::generate(); + let sealed = dek.seal(b"secret", b"doc-1").unwrap(); + assert!(matches!( + other.open(&sealed, b"doc-1"), + Err(CryptoError::DecryptFailed) + )); + assert!(matches!( + dek.open(&sealed, b"doc-2"), + Err(CryptoError::DecryptFailed) + )); + } + + #[test] + fn tampered_ciphertext_fails() { + let dek = Dek::generate(); + let mut sealed = dek.seal(b"secret", b"").unwrap(); + let last = sealed.len() - 1; + sealed[last] ^= 0x01; + assert!(dek.open(&sealed, b"").is_err()); + assert!(matches!( + dek.open(&sealed[..10], b""), + Err(CryptoError::CiphertextTooShort(10)) + )); + } + + #[test] + fn debug_never_leaks_key_material() { + let dek = Dek::generate(); + assert_eq!(format!("{dek:?}"), "Dek(..)"); + } +} diff --git a/loki-crypto/src/error.rs b/loki-crypto/src/error.rs new file mode 100644 index 00000000..a7ddd417 --- /dev/null +++ b/loki-crypto/src/error.rs @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Typed errors for envelope encryption and key wrapping. + +/// Errors produced by sealing, opening, wrapping, or unwrapping keys. +/// +/// AEAD failures are deliberately opaque (no distinction between a bad key, +/// a truncated ciphertext, and a forged tag) to avoid oracle behaviour. +#[derive(Debug, thiserror::Error)] +pub enum CryptoError { + /// Authenticated decryption failed (wrong key, tampered or truncated data). + #[error("authenticated decryption failed")] + DecryptFailed, + /// Authenticated encryption failed (should not happen with valid inputs). + #[error("authenticated encryption failed")] + EncryptFailed, + /// The ciphertext blob is too short to contain a nonce. + #[error("ciphertext too short ({0} bytes)")] + CiphertextTooShort(usize), + /// A wrapped DEK declared an algorithm this build does not implement. + #[error("unsupported key-wrap algorithm {0:?}")] + UnsupportedAlgorithm(String), + /// A key blob had the wrong length. + #[error("invalid key length {actual} (expected {expected})")] + InvalidKeyLength { + /// Expected byte length. + expected: usize, + /// Actual byte length received. + actual: usize, + }, +} diff --git a/loki-crypto/src/lib.rs b/loki-crypto/src/lib.rs new file mode 100644 index 00000000..45090987 --- /dev/null +++ b/loki-crypto/src/lib.rs @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Envelope encryption and crypto-agile key wrapping (ADR-C014). +//! +//! Every document has a per-document **DEK** (data-encryption key). Content +//! is sealed with the DEK via XChaCha20-Poly1305. The DEK itself is wrapped +//! by a tier-specific **KEK** through the [`KeyWrap`] trait: +//! +//! - Tier 0/1: a symmetric KEK ([`AeadKeyWrap`]) held by the platform KMS +//! (Tier 0) or the customer's KMS/HSM (Tier 1). +//! - Tier 2: the recipient's X25519 public key ([`X25519KeyWrap`]) — the +//! server only ever sees the wrapped form. +//! +//! [`WrappedDek`] records the wrapping algorithm alongside the blob so hybrid +//! PQC (X25519 + ML-KEM) can be introduced later without a data migration +//! (crypto-agility, C5:2026). + +#![forbid(unsafe_code)] + +mod aead_wrap; +mod dek; +mod error; +mod wrap; +mod x25519_wrap; + +pub use aead_wrap::{AeadKeyWrap, Kek}; +pub use dek::{DEK_LEN, Dek}; +pub use error::CryptoError; +pub use wrap::{KeyWrap, WrappedDek}; +pub use x25519_wrap::{X25519KeyWrap, X25519PublicKey, X25519SecretKey}; diff --git a/loki-crypto/src/wrap.rs b/loki-crypto/src/wrap.rs new file mode 100644 index 00000000..3fa92feb --- /dev/null +++ b/loki-crypto/src/wrap.rs @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! The crypto-agile key-wrapping port (ADR-C014). + +use serde::{Deserialize, Serialize}; + +use crate::dek::Dek; +use crate::error::CryptoError; + +/// A wrapped (encrypted) DEK, tagged with the algorithm that wrapped it. +/// +/// The `algorithm` tag is stored alongside the blob (in `doc_meta.dek_wrapped` +/// / `doc_member.dek_wrapped_for_user`) so a future hybrid-PQC wrap +/// (X25519 + ML-KEM) can coexist with current data — unwrapping dispatches on +/// the tag instead of assuming one scheme. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WrappedDek { + /// Identifier of the wrapping scheme, e.g. `"xchacha20-poly1305-kek.v1"`. + pub algorithm: String, + /// Opaque wrapped key material. + #[serde(with = "serde_bytes_base64")] + pub blob: Vec, +} + +/// A scheme that can wrap and unwrap document DEKs. +/// +/// Implementations: [`crate::AeadKeyWrap`] (symmetric KEK, Tiers 0/1) and +/// [`crate::X25519KeyWrap`] (per-member public key, Tier 2). A hybrid PQC +/// implementation slots in without touching stored data. +/// +/// `Send + Sync` because instances are shared server state (`Arc`). +pub trait KeyWrap: Send + Sync { + /// Stable identifier written into [`WrappedDek::algorithm`]. + fn algorithm(&self) -> &'static str; + + /// Wraps `dek` for the key custodian this instance represents. + fn wrap(&self, dek: &Dek) -> Result; + + /// Unwraps a DEK previously produced by [`KeyWrap::wrap`]. + /// + /// Returns [`CryptoError::UnsupportedAlgorithm`] if `wrapped.algorithm` + /// does not match this scheme. + fn unwrap_dek(&self, wrapped: &WrappedDek) -> Result; +} + +/// Serializes the wrapped blob as base64 for JSON transport. +mod serde_bytes_base64 { + use base64::Engine as _; + use base64::engine::general_purpose::STANDARD; + use serde::{Deserialize, Deserializer, Serializer}; + + pub fn serialize(bytes: &[u8], serializer: S) -> Result { + serializer.serialize_str(&STANDARD.encode(bytes)) + } + + pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result, D::Error> { + let s = String::deserialize(deserializer)?; + STANDARD.decode(&s).map_err(serde::de::Error::custom) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn wrapped_dek_json_round_trip() { + let wrapped = WrappedDek { + algorithm: "xchacha20-poly1305-kek.v1".to_owned(), + blob: (0..64u8).collect(), + }; + let json = serde_json::to_string(&wrapped).unwrap(); + let back: WrappedDek = serde_json::from_str(&json).unwrap(); + assert_eq!(back, wrapped); + } +} diff --git a/loki-crypto/src/x25519_wrap.rs b/loki-crypto/src/x25519_wrap.rs new file mode 100644 index 00000000..97879210 --- /dev/null +++ b/loki-crypto/src/x25519_wrap.rs @@ -0,0 +1,170 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Public-key DEK wrapping for Tier 2 (zero-knowledge E2EE, ADR-C014). +//! +//! Sharing a Tier-2 document means re-wrapping its DEK to each member's +//! X25519 public key (ECIES-style: ephemeral X25519 → HKDF-SHA256 → +//! XChaCha20-Poly1305). The server stores only the wrapped form; unwrapping +//! happens on clients holding the member's secret key. + +use hkdf::Hkdf; +use rand_core::OsRng; +use sha2::Sha256; +use x25519_dalek::{EphemeralSecret, PublicKey, StaticSecret}; + +use crate::dek::{DEK_LEN, Dek}; +use crate::error::CryptoError; +use crate::wrap::{KeyWrap, WrappedDek}; + +/// Stable algorithm tag for this scheme. +pub const X25519_WRAP_ALGORITHM: &str = "x25519-hkdf-sha256-xchacha20.v1"; + +/// Byte length of an X25519 public key. +const PK_LEN: usize = 32; + +/// A member's X25519 public key (stored in `app_user.public_key`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct X25519PublicKey(PublicKey); + +impl X25519PublicKey { + /// Reconstructs a public key from its 32-byte form. + pub fn from_bytes(bytes: &[u8]) -> Result { + let arr: [u8; PK_LEN] = bytes + .try_into() + .map_err(|_| CryptoError::InvalidKeyLength { + expected: PK_LEN, + actual: bytes.len(), + })?; + Ok(Self(PublicKey::from(arr))) + } + + /// Returns the 32-byte wire form. + #[must_use] + pub fn as_bytes(&self) -> &[u8; PK_LEN] { + self.0.as_bytes() + } +} + +/// A member's X25519 secret key (client-side only; never sent to the server). +pub struct X25519SecretKey(StaticSecret); + +impl X25519SecretKey { + /// Generates a fresh keypair secret. + #[must_use] + pub fn generate() -> Self { + Self(StaticSecret::random_from_rng(OsRng)) + } + + /// Reconstructs a secret key from its 32-byte form. + pub fn from_bytes(bytes: &[u8]) -> Result { + let arr: [u8; PK_LEN] = bytes + .try_into() + .map_err(|_| CryptoError::InvalidKeyLength { + expected: PK_LEN, + actual: bytes.len(), + })?; + Ok(Self(StaticSecret::from(arr))) + } + + /// Derives the matching public key. + #[must_use] + pub fn public_key(&self) -> X25519PublicKey { + X25519PublicKey(PublicKey::from(&self.0)) + } +} + +impl std::fmt::Debug for X25519SecretKey { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("X25519SecretKey(..)") + } +} + +/// Wraps DEKs to an X25519 recipient; unwraps with the recipient's secret. +/// +/// Blob layout: `ephemeral_pk (32 bytes) || nonce || ciphertext+tag`. +#[derive(Debug)] +pub struct X25519KeyWrap { + recipient_pk: X25519PublicKey, + recipient_sk: Option, +} + +impl X25519KeyWrap { + /// Wrap-only instance (all the server / a sharing client needs). + #[must_use] + pub fn for_recipient(recipient_pk: X25519PublicKey) -> Self { + Self { + recipient_pk, + recipient_sk: None, + } + } + + /// Full instance for the key holder (a client unwrapping its own DEKs). + #[must_use] + pub fn for_key_holder(secret: X25519SecretKey) -> Self { + Self { + recipient_pk: secret.public_key(), + recipient_sk: Some(secret), + } + } + + fn derive_wrapping_key( + shared: &x25519_dalek::SharedSecret, + ephemeral_pk: &PublicKey, + recipient_pk: &X25519PublicKey, + ) -> Result { + // Salt binds both public keys; info binds the algorithm tag. + let mut salt = Vec::with_capacity(PK_LEN * 2); + salt.extend_from_slice(ephemeral_pk.as_bytes()); + salt.extend_from_slice(recipient_pk.as_bytes()); + let hkdf = Hkdf::::new(Some(&salt), shared.as_bytes()); + let mut okm = [0u8; DEK_LEN]; + hkdf.expand(X25519_WRAP_ALGORITHM.as_bytes(), &mut okm) + .map_err(|_| CryptoError::EncryptFailed)?; + Dek::from_bytes(&okm) + } +} + +impl KeyWrap for X25519KeyWrap { + fn algorithm(&self) -> &'static str { + X25519_WRAP_ALGORITHM + } + + fn wrap(&self, dek: &Dek) -> Result { + let ephemeral = EphemeralSecret::random_from_rng(OsRng); + let ephemeral_pk = PublicKey::from(&ephemeral); + let shared = ephemeral.diffie_hellman(&self.recipient_pk.0); + let wrapping_key = Self::derive_wrapping_key(&shared, &ephemeral_pk, &self.recipient_pk)?; + let sealed = wrapping_key.seal(dek.as_bytes(), X25519_WRAP_ALGORITHM.as_bytes())?; + let mut blob = Vec::with_capacity(PK_LEN + sealed.len()); + blob.extend_from_slice(ephemeral_pk.as_bytes()); + blob.extend_from_slice(&sealed); + Ok(WrappedDek { + algorithm: X25519_WRAP_ALGORITHM.to_owned(), + blob, + }) + } + + fn unwrap_dek(&self, wrapped: &WrappedDek) -> Result { + if wrapped.algorithm != X25519_WRAP_ALGORITHM { + return Err(CryptoError::UnsupportedAlgorithm(wrapped.algorithm.clone())); + } + let Some(secret) = &self.recipient_sk else { + // A wrap-only instance (e.g. the server) cannot unwrap — that is + // the zero-knowledge property, expressed as a decrypt failure. + return Err(CryptoError::DecryptFailed); + }; + if wrapped.blob.len() < PK_LEN { + return Err(CryptoError::CiphertextTooShort(wrapped.blob.len())); + } + let (pk_bytes, sealed) = wrapped.blob.split_at(PK_LEN); + let ephemeral_pk = X25519PublicKey::from_bytes(pk_bytes)?; + let shared = secret.0.diffie_hellman(&ephemeral_pk.0); + let wrapping_key = Self::derive_wrapping_key(&shared, &ephemeral_pk.0, &self.recipient_pk)?; + let raw = wrapping_key.open(sealed, X25519_WRAP_ALGORITHM.as_bytes())?; + Dek::from_bytes(&raw) + } +} + +#[cfg(test)] +#[path = "x25519_wrap_tests.rs"] +mod tests; diff --git a/loki-crypto/src/x25519_wrap_tests.rs b/loki-crypto/src/x25519_wrap_tests.rs new file mode 100644 index 00000000..43e54402 --- /dev/null +++ b/loki-crypto/src/x25519_wrap_tests.rs @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Tests for the Tier-2 X25519 DEK wrap. + +use super::*; + +#[test] +fn share_flow_round_trips() { + // A sharing client wraps the DEK to a member's public key… + let member_secret = X25519SecretKey::generate(); + let member_pk = member_secret.public_key(); + let dek = Dek::generate(); + let sharer = X25519KeyWrap::for_recipient(member_pk); + let wrapped = sharer.wrap(&dek).unwrap(); + assert_eq!(wrapped.algorithm, X25519_WRAP_ALGORITHM); + + // …and only the member's client can unwrap it. + let member = X25519KeyWrap::for_key_holder(member_secret); + let unwrapped = member.unwrap_dek(&wrapped).unwrap(); + assert_eq!(unwrapped.as_bytes(), dek.as_bytes()); +} + +#[test] +fn server_side_wrap_only_instance_cannot_unwrap() { + let member_secret = X25519SecretKey::generate(); + let sharer = X25519KeyWrap::for_recipient(member_secret.public_key()); + let wrapped = sharer.wrap(&Dek::generate()).unwrap(); + // The zero-knowledge property: without the secret key, unwrap fails. + assert!(matches!( + sharer.unwrap_dek(&wrapped), + Err(CryptoError::DecryptFailed) + )); +} + +#[test] +fn wrong_member_cannot_unwrap() { + let alice = X25519SecretKey::generate(); + let mallory = X25519SecretKey::generate(); + let wrapped = X25519KeyWrap::for_recipient(alice.public_key()) + .wrap(&Dek::generate()) + .unwrap(); + assert!( + X25519KeyWrap::for_key_holder(mallory) + .unwrap_dek(&wrapped) + .is_err() + ); +} + +#[test] +fn truncated_blob_is_typed_error() { + let member_secret = X25519SecretKey::generate(); + let member = X25519KeyWrap::for_key_holder(member_secret); + let wrapped = WrappedDek { + algorithm: X25519_WRAP_ALGORITHM.to_owned(), + blob: vec![0u8; 16], + }; + assert!(matches!( + member.unwrap_dek(&wrapped), + Err(CryptoError::CiphertextTooShort(16)) + )); +} + +#[test] +fn public_key_round_trips_through_bytes() { + let secret = X25519SecretKey::generate(); + let pk = secret.public_key(); + let restored = X25519PublicKey::from_bytes(pk.as_bytes()).unwrap(); + assert_eq!(restored, pk); + assert!(X25519PublicKey::from_bytes(&[0u8; 16]).is_err()); +} diff --git a/loki-headless/Cargo.toml b/loki-headless/Cargo.toml new file mode 100644 index 00000000..01b4ef2d --- /dev/null +++ b/loki-headless/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "loki-headless" +version = "0.1.0" +edition = "2024" +license = "Apache-2.0" +description = "Headless Loki worker/CLI: GPU-free printing and file conversion (headless spec C021-C028)" +repository = "https://github.com/appthere/loki" + +[dependencies] +thiserror = { workspace = true } +clap = { version = "4", features = ["derive"] } +loki-convert = { workspace = true } +loki-print = { workspace = true } + +[dev-dependencies] +loki-doc-model = { path = "../loki-doc-model" } +loki-ooxml = { path = "../loki-ooxml" } diff --git a/loki-headless/src/cli.rs b/loki-headless/src/cli.rs new file mode 100644 index 00000000..dfe63a85 --- /dev/null +++ b/loki-headless/src/cli.rs @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Argument definitions (headless spec §3). + +use std::path::PathBuf; + +use clap::{Args, Parser, Subcommand}; + +/// Headless Loki: print and convert office documents with no GPU. +#[derive(Debug, Parser)] +#[command(name = "loki-headless", version, about)] +pub struct Cli { + #[command(subcommand)] + pub command: Command, +} + +#[derive(Debug, Subcommand)] +pub enum Command { + /// Convert a document to another format. + Convert(ConvertArgs), + /// Render a document to PDF (alias for `convert` with a PDF target). + Render(ConvertArgs), + /// Render a document to PDF and dispatch it to an IPP printer. + Print(PrintArgs), + /// List the supported conversion pairs. + Formats, +} + +#[derive(Debug, Args)] +pub struct ConvertArgs { + /// Input file (format inferred from the extension unless --from is set). + #[arg(long = "in", value_name = "FILE")] + pub input: PathBuf, + /// Output file (format inferred from the extension unless --to is set). + #[arg(long = "out", value_name = "FILE")] + pub output: PathBuf, + /// Source format override (docx, odt, xlsx, ods, …). + #[arg(long)] + pub from: Option, + /// Target format override. + #[arg(long)] + pub to: Option, + /// PDF profile: pdf, pdf-x1a, pdf-x3, pdf-x4, or pdf-a2b. + #[arg(long)] + pub profile: Option, + /// Override the document title in the output metadata. + #[arg(long)] + pub title: Option, +} + +#[derive(Debug, Args)] +pub struct PrintArgs { + /// Input file (printed as-is if already PDF, otherwise rendered first). + #[arg(long = "in", value_name = "FILE")] + pub input: PathBuf, + /// Printer URI (`ipp://printer.local/ipp/print`). + #[arg(long)] + pub printer: String, + /// Number of copies. + #[arg(long, default_value_t = 1)] + pub copies: u32, + /// Double-sided (long-edge binding). + #[arg(long)] + pub duplex: bool, + /// With --duplex: flip on the short edge instead. + #[arg(long, requires = "duplex")] + pub short_edge: bool, + /// Media size: A3, A4, A5, letter, legal, or a raw IPP media keyword. + #[arg(long)] + pub media: Option, + /// Force monochrome output. + #[arg(long)] + pub mono: bool, + /// Job title shown in the printer queue (defaults to the file name). + #[arg(long)] + pub title: Option, + /// Submit without waiting for the printer to finish the job. + #[arg(long)] + pub no_wait: bool, + /// Seconds to wait for job completion before giving up. + #[arg(long, default_value_t = 300)] + pub timeout: u64, +} diff --git a/loki-headless/src/commands.rs b/loki-headless/src/commands.rs new file mode 100644 index 00000000..1b203ddb --- /dev/null +++ b/loki-headless/src/commands.rs @@ -0,0 +1,144 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Command implementations over `loki-convert` and `loki-print`. + +use std::path::Path; +use std::time::Duration; + +use loki_convert::{ConvertError, ConvertOptions, Format, PdfProfile, convert}; +use loki_print::{ColorMode, Duplex, IppPrinter, PrintError, PrintOptions}; + +use crate::cli::{Cli, Command, ConvertArgs, PrintArgs}; + +/// CLI failures (all map to exit code 1). +#[derive(Debug, thiserror::Error)] +pub enum CliError { + #[error("cannot determine the format of {path}: {reason} (use --from/--to)")] + UnknownFileFormat { path: String, reason: &'static str }, + #[error(transparent)] + Convert(#[from] ConvertError), + #[error(transparent)] + Print(#[from] PrintError), + #[error("failed to read {path}: {source}")] + ReadInput { + path: String, + #[source] + source: std::io::Error, + }, + #[error("failed to write {path}: {source}")] + WriteOutput { + path: String, + #[source] + source: std::io::Error, + }, +} + +pub fn run(cli: Cli) -> Result<(), CliError> { + match cli.command { + Command::Convert(args) | Command::Render(args) => run_convert(&args), + Command::Print(args) => run_print(&args), + Command::Formats => { + for (source, target) in loki_convert::supported_pairs() { + println!("{source} -> {target}"); + } + Ok(()) + } + } +} + +fn format_of(path: &Path, explicit: Option<&str>) -> Result { + if let Some(name) = explicit { + return name.parse().map_err(CliError::Convert); + } + let ext = + path.extension() + .and_then(|e| e.to_str()) + .ok_or_else(|| CliError::UnknownFileFormat { + path: path.display().to_string(), + reason: "no file extension", + })?; + Format::from_extension(ext).ok_or_else(|| CliError::UnknownFileFormat { + path: path.display().to_string(), + reason: "unrecognised extension", + }) +} + +fn convert_options(profile: Option<&str>, title: Option<&str>) -> Result { + let pdf_profile = match profile { + Some(profile) => profile.parse::().map_err(CliError::Convert)?, + None => PdfProfile::Default, + }; + Ok(ConvertOptions { + pdf_profile, + title: title.map(str::to_owned), + }) +} + +fn run_convert(args: &ConvertArgs) -> Result<(), CliError> { + let source = format_of(&args.input, args.from.as_deref())?; + let target = format_of(&args.output, args.to.as_deref())?; + let options = convert_options(args.profile.as_deref(), args.title.as_deref())?; + let input = std::fs::read(&args.input).map_err(|e| CliError::ReadInput { + path: args.input.display().to_string(), + source: e, + })?; + let output = convert(source, &input, target, &options)?; + for warning in &output.warnings { + eprintln!("warning: {warning}"); + } + std::fs::write(&args.output, &output.bytes).map_err(|e| CliError::WriteOutput { + path: args.output.display().to_string(), + source: e, + })?; + println!( + "{} -> {} ({} bytes)", + args.input.display(), + args.output.display(), + output.bytes.len() + ); + Ok(()) +} + +fn run_print(args: &PrintArgs) -> Result<(), CliError> { + let source = format_of(&args.input, None)?; + let input = std::fs::read(&args.input).map_err(|e| CliError::ReadInput { + path: args.input.display().to_string(), + source: e, + })?; + // Already-rendered PDFs dispatch as-is; anything else renders first + // (render → PDF → IPP, ADR-C023). + let pdf = if source == Format::Pdf { + input + } else { + convert(source, &input, Format::Pdf, &ConvertOptions::default())?.bytes + }; + + let file_name = args + .input + .file_name() + .map(|n| n.to_string_lossy().into_owned()); + let options = PrintOptions { + copies: args.copies, + duplex: match (args.duplex, args.short_edge) { + (false, _) => Duplex::Simplex, + (true, false) => Duplex::LongEdge, + (true, true) => Duplex::ShortEdge, + }, + media: args.media.clone(), + color: if args.mono { + ColorMode::Monochrome + } else { + ColorMode::Auto + }, + job_title: args.title.clone().or(file_name), + }; + + let printer = IppPrinter::connect(&args.printer)?; + let job_id = printer.print_pdf(pdf, &options)?; + println!("submitted job {job_id} to {}", args.printer); + if !args.no_wait { + let state = printer.wait_for_completion(job_id, Duration::from_secs(args.timeout))?; + println!("job {job_id} finished: {state:?}"); + } + Ok(()) +} diff --git a/loki-headless/src/main.rs b/loki-headless/src/main.rs new file mode 100644 index 00000000..c1c572dd --- /dev/null +++ b/loki-headless/src/main.rs @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! `loki-headless` — GPU-free printing and file conversion (headless spec +//! §3). The standalone CLI is the "office printing" deployment: no server, +//! no Postgres, just files in, PDF/converted files or IPP print jobs out. +//! +//! Deliberately deferred (recorded here so the gaps are visible): +// TODO(headless-c025): apalis worker mode consuming loki-server's job queue +// (idempotency keys, retries, dead-letter) and the optional HTTP endpoint. +// TODO(headless-c021): vello_cpu rasterisation for Thumbnail jobs; the +// print/convert path needs no rasteriser (layout → PDF is already CPU-only). +// TODO(headless-c027): fail-closed font policy for print jobs — needs +// loki-layout to report substitutions instead of silently falling back. +// TODO(headless-c028): SEV-SNP/TDX attestation-gated key release for Tier-1 +// worker pools. + +mod cli; +mod commands; + +use clap::Parser; + +fn main() -> std::process::ExitCode { + let args = cli::Cli::parse(); + match commands::run(args) { + Ok(()) => std::process::ExitCode::SUCCESS, + Err(error) => { + eprintln!("error: {error}"); + std::process::ExitCode::FAILURE + } + } +} diff --git a/loki-headless/tests/cli.rs b/loki-headless/tests/cli.rs new file mode 100644 index 00000000..6a71454e --- /dev/null +++ b/loki-headless/tests/cli.rs @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! End-to-end CLI tests: run the real binary on real files. + +use std::io::Cursor; +use std::process::Command; + +use loki_doc_model::content::block::Block; +use loki_doc_model::content::inline::Inline; +use loki_doc_model::document::Document; +use loki_doc_model::io::DocumentExport; +use loki_ooxml::DocxExport; + +fn bin() -> Command { + Command::new(env!("CARGO_BIN_EXE_loki-headless")) +} + +fn write_sample_docx(path: &std::path::Path) { + let mut doc = Document::new(); + if let Some(section) = doc.sections.first_mut() { + section.blocks.push(Block::Para(vec![Inline::Str( + "Printed from the office NUC by the print room.".into(), + )])); + } + let mut cursor = Cursor::new(Vec::new()); + DocxExport::export(&doc, &mut cursor, ()).expect("fixture"); + std::fs::write(path, cursor.into_inner()).expect("write fixture"); +} + +#[test] +fn convert_docx_to_pdf_via_cli() { + let dir = std::env::temp_dir().join(format!("loki-headless-test-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let input = dir.join("report.docx"); + let output = dir.join("report.pdf"); + write_sample_docx(&input); + + let status = bin() + .args(["convert", "--in"]) + .arg(&input) + .arg("--out") + .arg(&output) + .args(["--profile", "pdf-x4"]) + .status() + .expect("run CLI"); + assert!(status.success()); + let pdf = std::fs::read(&output).expect("output written"); + assert!(pdf.starts_with(b"%PDF-1.")); + std::fs::remove_dir_all(&dir).ok(); +} + +#[test] +fn unsupported_conversion_fails_with_typed_message() { + let dir = std::env::temp_dir().join(format!("loki-headless-gate-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let input = dir.join("deck.pptx"); + std::fs::write(&input, b"not really a pptx").unwrap(); + + let output = bin() + .args(["convert", "--in"]) + .arg(&input) + .arg("--out") + .arg(dir.join("deck.pdf")) + .output() + .expect("run CLI"); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("not supported"), + "expected a ConversionUnsupported message, got: {stderr}" + ); + std::fs::remove_dir_all(&dir).ok(); +} + +#[test] +fn formats_lists_the_matrix() { + let output = bin().arg("formats").output().expect("run CLI"); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("docx -> pdf")); + assert!(stdout.contains("ods -> xlsx")); + assert!(!stdout.contains("pptx"), "gated formats must not be listed"); +} diff --git a/loki-model/Cargo.toml b/loki-model/Cargo.toml new file mode 100644 index 00000000..8ace8d9a --- /dev/null +++ b/loki-model/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "loki-model" +version = "0.1.0" +edition = "2024" +license = "Apache-2.0" +description = "Shared server-side identifiers, encryption tiers, and ACL types for the Loki suite (no server dependencies)" +repository = "https://github.com/appthere/loki" + +[dependencies] +thiserror = { workspace = true } +serde = { workspace = true } +uuid = { workspace = true } + +[dev-dependencies] +serde_json = { workspace = true } diff --git a/loki-model/src/ids.rs b/loki-model/src/ids.rs new file mode 100644 index 00000000..7600d201 --- /dev/null +++ b/loki-model/src/ids.rs @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Identifier newtypes for server-side entities. +//! +//! Each identifier wraps a UUID so that a `DocumentId` can never be passed +//! where a `WorkspaceId` is expected. All types serialize as plain UUID +//! strings. + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +macro_rules! id_newtype { + ($(#[$doc:meta])* $name:ident) => { + $(#[$doc])* + #[derive( + Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, + )] + #[serde(transparent)] + pub struct $name(Uuid); + + impl $name { + /// Creates a fresh random identifier. + #[must_use] + pub fn new() -> Self { + Self(Uuid::new_v4()) + } + + /// Wraps an existing UUID (e.g. one loaded from the database). + #[must_use] + pub const fn from_uuid(uuid: Uuid) -> Self { + Self(uuid) + } + + /// Returns the underlying UUID. + #[must_use] + pub const fn as_uuid(&self) -> Uuid { + self.0 + } + } + + impl Default for $name { + fn default() -> Self { + Self::new() + } + } + + impl std::fmt::Display for $name { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } + } + + impl std::str::FromStr for $name { + type Err = uuid::Error; + + fn from_str(s: &str) -> Result { + Ok(Self(Uuid::parse_str(s)?)) + } + } + + impl From for $name { + fn from(uuid: Uuid) -> Self { + Self(uuid) + } + } + + impl From<$name> for Uuid { + fn from(id: $name) -> Self { + id.0 + } + } + }; +} + +id_newtype!( + /// Identifies a workspace (the unit of membership and default policy). + WorkspaceId +); +id_newtype!( + /// Identifies a single collaborative document. + DocumentId +); +id_newtype!( + /// Identifies a user account (linked to an OIDC subject, never a password). + UserId +); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ids_round_trip_through_strings() { + let id = DocumentId::new(); + let parsed: DocumentId = id.to_string().parse().unwrap(); + assert_eq!(id, parsed); + } + + #[test] + fn ids_are_distinct_types() { + // Compile-time property: this test documents that the newtypes exist. + let doc = DocumentId::new(); + let ws = WorkspaceId::from_uuid(doc.as_uuid()); + assert_eq!(doc.as_uuid(), ws.as_uuid()); + } + + #[test] + fn serde_is_transparent() { + let id = UserId::new(); + let json = serde_json::to_string(&id).unwrap(); + assert_eq!(json, format!("\"{id}\"")); + } +} diff --git a/loki-model/src/lib.rs b/loki-model/src/lib.rs new file mode 100644 index 00000000..ad468d00 --- /dev/null +++ b/loki-model/src/lib.rs @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Shared server-side domain types for the Loki suite. +//! +//! This crate holds the identifier newtypes, the tiered-confidentiality model +//! (ADR-C014), the RBAC role/action matrix (ADR-C017), and the data-residency +//! type (ADR-C019) shared between `loki-server-*` crates and future clients. +//! It deliberately has **no** server dependencies (no Axum, no SQLx) so that +//! client crates can depend on it too. +//! +//! See `docs/adr/LOKI_WEB_SERVER_SPEC.md`. + +#![forbid(unsafe_code)] + +mod ids; +mod residency; +mod role; +mod tier; + +pub use ids::{DocumentId, UserId, WorkspaceId}; +pub use residency::{Residency, ResidencyError}; +pub use role::{Action, Role, RoleParseError}; +pub use tier::{EncryptionTier, TierParseError}; diff --git a/loki-model/src/residency.rs b/loki-model/src/residency.rs new file mode 100644 index 00000000..45267fcb --- /dev/null +++ b/loki-model/src/residency.rs @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Data-residency type (ADR-C018 / ADR-C019). +//! +//! Residency is recorded per workspace and per document. Managed (Hetzner) +//! deployments are pinned to the EU regions `fsn1`, `nbg1`, `hel1`; +//! self-hosted deployments declare a free-form `self-hosted: