diff --git a/.gitattributes b/.gitattributes index 107e94cf..2bc97a7b 100644 --- a/.gitattributes +++ b/.gitattributes @@ -5,3 +5,7 @@ # clean tree. Keeping the stored bytes is also what makes the oasdiff gate's # verdict reproducible off a local checkout. pg-pkg/api-description.yaml -text + +# cryptify's `mod api_gate_tests` in src/main.rs pins its own gate the same +# way, with anchors copied verbatim out of its spec. Same reasoning. +cryptify/api-description.yaml -text diff --git a/.github/workflows/api-diff.yml b/.github/workflows/api-diff.yml index 7652d30e..5f262982 100644 --- a/.github/workflows/api-diff.yml +++ b/.github/workflows/api-diff.yml @@ -1,6 +1,16 @@ name: API diff # -# Breaking-change gate on the pg-pkg OpenAPI contract (#249). +# Breaking-change gate on both OpenAPI contracts (#249, cryptify#196/#202). +# +# Two specs, one matrix: pg-pkg/api-description.yaml (versioned /v2 routes) +# and cryptify/api-description.yaml (unversioned). The matrix is not +# cosmetic. Both pg-pkg/tests/api_gate.rs and cryptify's `mod +# api_gate_tests` read this file and require exactly one `fail-on:` and one +# `include-checks:` line, so that neither can claim a setting the committed +# job does not use. Two separate oasdiff steps would give two of each and +# fail both suites; the matrix varies only the spec paths, leaving the two +# settings written once. Keep it that way: moving `fail-on` into the matrix +# would remove the literal both tests look for. # # pg-pkg/api-description.yaml is the pinned v2 HTTP contract (#242), one of the # three seams COMPATIBILITY.md guarantees. This job diffs the PR's spec against @@ -73,8 +83,15 @@ permissions: jobs: breaking-changes: - name: API breaking changes (oasdiff) + name: API breaking changes (${{ matrix.spec }}) runs-on: ubuntu-latest + strategy: + # One spec's verdict must not cancel the other's. + fail-fast: false + matrix: + spec: + - pg-pkg/api-description.yaml + - cryptify/api-description.yaml steps: - name: Check out the pull request uses: actions/checkout@v6 @@ -94,8 +111,8 @@ jobs: # `oasdiff v1.26.1` reproduces what CI decides here. uses: oasdiff/oasdiff-action/breaking@0ab8ad204b00d25acc5ae87106281433e288d0c1 # v0.1.10 with: - base: base/pg-pkg/api-description.yaml - revision: pg-pkg/api-description.yaml + base: base/${{ matrix.spec }} + revision: ${{ matrix.spec }} fail-on: WARN # Both of these rate ERR but are opt-in, so they do not run unless # named: a changed non-success status (401 -> 403) and an enum value diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 61fc23ac..34149057 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -26,46 +26,57 @@ jobs: # uses: SonarSource/sonarqube-scan-action@v6 # env: # SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + # The matrix keys on the crate directory rather than a `pg-` suffix, because + # `cryptify` does not carry that prefix. This renames the check contexts from + # `Test workspace (core)` to `Test workspace (pg-core)`; nothing required + # points at them today (only the two `Wire compat` contexts are required), but + # a ruleset added later must use the new names. test: name: Test workspace strategy: matrix: - workspace: [core, pkg, cli, ffi] + crate: [pg-core, pg-pkg, pg-cli, pg-ffi, cryptify] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable - - if: ${{ matrix.workspace == 'core' }} - run: cargo test --manifest-path pg-${{ matrix.workspace }}/Cargo.toml --features test,rust,stream - - if: ${{ matrix.workspace != 'core' }} - run: cargo test --manifest-path pg-${{ matrix.workspace }}/Cargo.toml --all-features + - if: ${{ matrix.crate == 'pg-core' }} + run: cargo test --manifest-path pg-core/Cargo.toml --features test,rust,stream + # cryptify declares no features of its own, so `--all-targets` (what its + # own CI ran) is the equivalent invocation. + - if: ${{ matrix.crate == 'cryptify' }} + run: cargo test --manifest-path cryptify/Cargo.toml --all-targets + - if: ${{ matrix.crate != 'pg-core' && matrix.crate != 'cryptify' }} + run: cargo test --manifest-path ${{ matrix.crate }}/Cargo.toml --all-features format: name: Format workspace strategy: matrix: - workspace: [core, pkg, cli, ffi] + crate: [pg-core, pg-pkg, pg-cli, pg-ffi, cryptify] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable - - run: cargo fmt --manifest-path pg-${{ matrix.workspace }}/Cargo.toml --all -- --check + - run: cargo fmt --manifest-path ${{ matrix.crate }}/Cargo.toml --all -- --check clippy: name: Clippy workspace strategy: matrix: - workspace: [core, pkg, cli, ffi] + crate: [pg-core, pg-pkg, pg-cli, pg-ffi, cryptify] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable with: components: clippy - - if: ${{ matrix.workspace == 'core' }} + - if: ${{ matrix.crate == 'pg-core' }} run: cargo clippy --manifest-path pg-core/Cargo.toml --all-targets --features test,rust,stream -- -D warnings - - if: ${{ matrix.workspace != 'core' }} - run: cargo clippy --manifest-path pg-${{ matrix.workspace }}/Cargo.toml --all-targets --all-features -- -D warnings + - if: ${{ matrix.crate == 'cryptify' }} + run: cargo clippy --manifest-path cryptify/Cargo.toml --all-targets -- -D warnings + - if: ${{ matrix.crate != 'pg-core' && matrix.crate != 'cryptify' }} + run: cargo clippy --manifest-path ${{ matrix.crate }}/Cargo.toml --all-targets --all-features -- -D warnings test-wasm-browsers: name: Run wasm tests in browsers diff --git a/Cargo.lock b/Cargo.lock index c916fb76..85a24572 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -56,7 +56,7 @@ dependencies = [ "actix-rt", "actix-service", "actix-utils", - "base64", + "base64 0.22.1", "bitflags", "brotli", "bytes 1.11.1", @@ -77,7 +77,7 @@ dependencies = [ "percent-encoding", "pin-project-lite", "rand 0.10.1", - "sha1 0.11.0", + "sha1", "smallvec", "tokio", "tokio-util", @@ -92,7 +92,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e01ed3140b2f8d422c68afa1ed2e85d996ea619c988ac834d255db32138655cb" dependencies = [ "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -176,7 +176,7 @@ dependencies = [ "bytes 1.11.1", "bytestring", "cfg-if", - "cookie", + "cookie 0.16.2", "derive_more", "encoding_rs", "foldhash 0.1.5", @@ -210,7 +210,7 @@ dependencies = [ "actix-router", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -221,7 +221,7 @@ checksum = "456348ed9dcd72a13a1f4a660449fafdecee9ac8205552e286809eb5b0b29bd3" dependencies = [ "actix-utils", "actix-web", - "base64", + "base64 0.22.1", "futures-core", "futures-util", "log", @@ -308,6 +308,15 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anes" version = "0.1.6" @@ -376,6 +385,81 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" +[[package]] +name = "askama" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1bf825125edd887a019d0a3a837dcc5499a68b0d034cc3eb594070c3e18addc" +dependencies = [ + "askama_macros", + "itoa", + "percent-encoding", + "serde", + "serde_json", +] + +[[package]] +name = "askama_derive" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1c7065972a130eafa84215f21352ae15b4a7393da48c1f5e103904490736738" +dependencies = [ + "askama_parser", + "basic-toml", + "glob", + "memchr", + "proc-macro2", + "quote", + "rustc-hash", + "serde", + "serde_derive", + "syn 2.0.117", +] + +[[package]] +name = "askama_macros" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e23b1d2c4bd39a41971f6124cef4cc6fd0540913ecb90919b69ab3bbe44ae1a" +dependencies = [ + "askama_derive", +] + +[[package]] +name = "askama_parser" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7db09fde9143e7ac4513358fb32ee32847125b63b18ea715afd487956da715da" +dependencies = [ + "rustc-hash", + "serde", + "serde_derive", + "unicode-ident", + "winnow 1.0.4", +] + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -384,7 +468,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -396,6 +480,21 @@ dependencies = [ "num-traits", ] +[[package]] +name = "atomic" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c59bdb34bc650a32731b31bd8f0829cc15d24a708ee31559e0bb34f2bc320cba" + +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -442,12 +541,33 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b25655df2c3cdd83c5e5b293b88acd880332b2ddadd7c30ac43144fdc0033da9" + [[package]] name = "base64ct" version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "basic-toml" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" +dependencies = [ + "serde", +] + +[[package]] +name = "binascii" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383d29d513d8764dcdc42ea295d979eb99c3c9f00607b3692cf68a431f7dca72" + [[package]] name = "bincode-next" version = "3.0.0-rc.14" @@ -526,6 +646,12 @@ version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + [[package]] name = "byteorder" version = "1.5.0" @@ -598,6 +724,20 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "pure-rust-locales", + "wasm-bindgen", + "windows-link", +] + [[package]] name = "ciborium" version = "0.2.2" @@ -666,7 +806,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -684,6 +824,12 @@ dependencies = [ "cc", ] +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "colorchoice" version = "1.0.5" @@ -753,6 +899,17 @@ dependencies = [ "version_check", ] +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -923,6 +1080,33 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "cryptify" +version = "0.1.27" +dependencies = [ + "askama", + "chrono", + "futures 0.3.32", + "lettre", + "log", + "minreq", + "pg-core", + "rand 0.10.1", + "rand 0.8.6", + "reqwest 0.13.4", + "rocket", + "rocket_cors", + "rusqlite", + "serde", + "serde_json", + "sha2 0.11.0", + "subtle", + "tokio", + "tokio-util", + "url", + "uuid", +] + [[package]] name = "crypto-bigint" version = "0.5.5" @@ -964,6 +1148,15 @@ dependencies = [ "cipher", ] +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + [[package]] name = "curve25519-dalek" version = "4.1.3" @@ -990,7 +1183,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1046,10 +1239,43 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn", + "syn 2.0.117", "unicode-xid", ] +[[package]] +name = "devise" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1d90b0c4c777a2cad215e3c7be59ac7c15adf45cf76317009b7d096d46f651d" +dependencies = [ + "devise_codegen", + "devise_core", +] + +[[package]] +name = "devise_codegen" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71b28680d8be17a570a2334922518be6adc3f58ecc880cbb404eaeb8624fd867" +dependencies = [ + "devise_core", + "quote", +] + +[[package]] +name = "devise_core" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b035a542cf7abf01f2e3c4d5a7acbaebfefe120ae4efc7bde3df98186e4b8af7" +dependencies = [ + "bitflags", + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn 2.0.117", +] + [[package]] name = "digest" version = "0.10.7" @@ -1071,6 +1297,7 @@ dependencies = [ "block-buffer 0.12.0", "const-oid 0.10.2", "crypto-common 0.2.1", + "ctutils", ] [[package]] @@ -1081,7 +1308,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1170,7 +1397,7 @@ dependencies = [ "ff", "generic-array", "group", - "hkdf", + "hkdf 0.12.4", "pem-rfc7468", "pkcs8", "rand_core 0.6.4", @@ -1179,6 +1406,22 @@ dependencies = [ "zeroize", ] +[[package]] +name = "email-encoding" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "420b9da095f052ea597503e39073b5b3c522f7db933fbac202d91d24492693fd" +dependencies = [ + "base64 0.23.0", + "memchr", +] + +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" + [[package]] name = "encode_unicode" version = "1.0.0" @@ -1235,13 +1478,12 @@ dependencies = [ [[package]] name = "etcetera" -version = "0.8.0" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" dependencies = [ "cfg-if", - "home", - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -1255,6 +1497,18 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "2.4.1" @@ -1278,6 +1532,20 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +[[package]] +name = "figment" +version = "0.10.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cb01cd46b0cf372153850f4c6c272d9cbea2da513e07538405148f95bd789f3" +dependencies = [ + "atomic 0.6.1", + "pear", + "serde", + "toml", + "uncased", + "version_check", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -1296,9 +1564,9 @@ dependencies = [ [[package]] name = "flume" -version = "0.11.1" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" dependencies = [ "futures-core", "futures-sink", @@ -1432,7 +1700,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1482,6 +1750,19 @@ dependencies = [ "thread_local", ] +[[package]] +name = "generator" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc16584ff22b460a382b7feec54b23d2908d858152e5739a120b949293bd74e" +dependencies = [ + "cc", + "libc", + "log", + "rustversion", + "windows", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -1544,6 +1825,12 @@ dependencies = [ "polyval", ] +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + [[package]] name = "governor" version = "0.10.4" @@ -1639,8 +1926,6 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "allocator-api2", - "equivalent", "foldhash 0.1.5", ] @@ -1663,11 +1948,11 @@ checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] name = "hashlink" -version = "0.10.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" dependencies = [ - "hashbrown 0.15.5", + "hashbrown 0.16.1", ] [[package]] @@ -1676,6 +1961,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hex" version = "0.4.3" @@ -1688,7 +1979,16 @@ version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" dependencies = [ - "hmac", + "hmac 0.12.1", +] + +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac 0.13.0", ] [[package]] @@ -1701,12 +2001,23 @@ dependencies = [ ] [[package]] -name = "home" -version = "0.5.12" +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + +[[package]] +name = "hostname" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" dependencies = [ - "windows-sys 0.61.2", + "cfg-if", + "libc", + "windows-link", ] [[package]] @@ -1730,6 +2041,17 @@ dependencies = [ "itoa", ] +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes 1.11.1", + "http 0.2.12", + "pin-project-lite", +] + [[package]] name = "http-body" version = "1.0.1" @@ -1749,7 +2071,7 @@ dependencies = [ "bytes 1.11.1", "futures-core", "http 1.4.0", - "http-body", + "http-body 1.0.1", "pin-project-lite", ] @@ -1774,6 +2096,30 @@ dependencies = [ "typenum", ] +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes 1.11.1", + "futures-channel", + "futures-core", + "futures-util", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", + "want", +] + [[package]] name = "hyper" version = "1.9.0" @@ -1786,7 +2132,7 @@ dependencies = [ "futures-core", "h2 0.4.14", "http 1.4.0", - "http-body", + "http-body 1.0.1", "httparse", "itoa", "pin-project-lite", @@ -1802,7 +2148,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ "http 1.4.0", - "hyper", + "hyper 1.9.0", "hyper-util", "rustls", "tokio", @@ -1818,7 +2164,7 @@ checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" dependencies = [ "bytes 1.11.1", "http-body-util", - "hyper", + "hyper 1.9.0", "hyper-util", "native-tls", "tokio", @@ -1832,13 +2178,13 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", + "base64 0.22.1", "bytes 1.11.1", "futures-channel", "futures-util", "http 1.4.0", - "http-body", - "hyper", + "http-body 1.0.1", + "hyper 1.9.0", "ipnet", "libc", "percent-encoding", @@ -1851,6 +2197,30 @@ dependencies = [ "windows-registry", ] +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "ibe" version = "0.3.0" @@ -2020,6 +2390,12 @@ dependencies = [ "web-time", ] +[[package]] +name = "inlinable_string" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" + [[package]] name = "inout" version = "0.1.4" @@ -2071,6 +2447,17 @@ dependencies = [ "url", ] +[[package]] +name = "is-terminal" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -2113,7 +2500,7 @@ checksum = "e000de030ff8022ea1da3f466fbb0f3a809f5e51ed31f6dd931c35181ad8e6d7" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2143,7 +2530,7 @@ dependencies = [ "quote", "rustc_version", "simd_cesu8", - "syn", + "syn 2.0.117", ] [[package]] @@ -2162,7 +2549,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2193,10 +2580,10 @@ version = "10.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc" dependencies = [ - "base64", + "base64 0.22.1", "ed25519-dalek", "getrandom 0.2.17", - "hmac", + "hmac 0.12.1", "js-sys", "p256", "p384", @@ -2241,6 +2628,30 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" +[[package]] +name = "lettre" +version = "0.11.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0da65617f6cb926332d039cb578aad56178da86e128db6a1b09f4c94fa5b3349" +dependencies = [ + "base64 0.22.1", + "email-encoding", + "email_address", + "fastrand", + "futures-util", + "hostname", + "httpdate", + "idna", + "mime", + "native-tls", + "nom", + "percent-encoding", + "quoted_printable", + "socket2 0.6.3", + "tokio", + "url", +] + [[package]] name = "libc" version = "0.2.186" @@ -2253,24 +2664,13 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" -[[package]] -name = "libredox" -version = "0.1.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" -dependencies = [ - "bitflags", - "libc", - "plain", - "redox_syscall 0.7.5", -] - [[package]] name = "libsqlite3-sys" -version = "0.30.1" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" dependencies = [ + "cc", "pkg-config", "vcpkg", ] @@ -2321,9 +2721,24 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "loom" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff50ecb28bb86013e935fb6683ab1f6d3a20016f123c76fd4c27470076ac30f5" +dependencies = [ + "cfg-if", + "generator", + "scoped-tls", + "serde", + "serde_json", + "tracing", + "tracing-subscriber", +] [[package]] name = "lru-slab" @@ -2331,14 +2746,23 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + [[package]] name = "md-5" -version = "0.10.6" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" dependencies = [ "cfg-if", - "digest 0.10.7", + "digest 0.11.3", ] [[package]] @@ -2363,6 +2787,17 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "minreq" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "659579df697b372ef9e36f02fcbb41f6d6f157dcec7db9c9618fa0f23cf0fc20" +dependencies = [ + "native-tls", + "serde", + "serde_json", +] + [[package]] name = "mio" version = "1.2.0" @@ -2375,6 +2810,25 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes 1.11.1", + "encoding_rs", + "futures-util", + "http 1.4.0", + "httparse", + "memchr", + "mime", + "spin", + "tokio", + "tokio-util", + "version_check", +] + [[package]] name = "native-tls" version = "0.2.18" @@ -2392,12 +2846,30 @@ dependencies = [ "tempfile", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "nonzero_ext" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21" +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -2460,6 +2932,16 @@ dependencies = [ "libm", ] +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -2506,7 +2988,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2594,7 +3076,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall 0.5.18", + "redox_syscall", "smallvec", "windows-link", ] @@ -2605,13 +3087,36 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c5a797f0e07bdf071d15742978fc3128ec6c22891c31a3a931513263904c982a" +[[package]] +name = "pear" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdeeaa00ce488657faba8ebf44ab9361f9365a97bd39ffb8a60663f57ff4b467" +dependencies = [ + "inlinable_string", + "pear_codegen", + "yansi", +] + +[[package]] +name = "pear_codegen" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bab5b985dc082b345f812b7df84e1bef27e7207b39e448439ba8bd69c93f147" +dependencies = [ + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn 2.0.117", +] + [[package]] name = "pem" version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" dependencies = [ - "base64", + "base64 0.22.1", "serde_core", ] @@ -2643,7 +3148,7 @@ dependencies = [ "pg-core", "qrcode", "rand 0.8.6", - "reqwest 0.13.3", + "reqwest 0.13.4", "serde", "serde_json", "tokio", @@ -2717,7 +3222,7 @@ dependencies = [ "actix-web-httpauth", "arrayref", "async-trait", - "base64", + "base64 0.22.1", "bincode-next", "clap", "env_logger", @@ -2730,7 +3235,7 @@ dependencies = [ "pg-core", "prometheus", "rand 0.8.6", - "reqwest 0.13.3", + "reqwest 0.13.4", "serde", "serde_json", "sha2 0.11.0", @@ -2773,12 +3278,6 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" -[[package]] -name = "plain" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" - [[package]] name = "plotters" version = "0.3.7" @@ -2865,7 +3364,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.117", ] [[package]] @@ -2886,6 +3385,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proc-macro2-diagnostics" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "version_check", + "yansi", +] + [[package]] name = "prometheus" version = "0.14.0" @@ -2900,6 +3412,12 @@ dependencies = [ "thiserror", ] +[[package]] +name = "pure-rust-locales" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "869675ad2d7541aea90c6d88c81f46a7f4ea9af8cd0395d38f11a95126998a0d" + [[package]] name = "qrcode" version = "0.14.1" @@ -2986,6 +3504,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "quoted_printable" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972" + [[package]] name = "r-efi" version = "5.3.0" @@ -3128,12 +3652,23 @@ dependencies = [ ] [[package]] -name = "redox_syscall" -version = "0.7.5" +name = "ref-cast" +version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b" +checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" dependencies = [ - "bitflags", + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", ] [[package]] @@ -3177,15 +3712,15 @@ version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64", + "base64 0.22.1", "bytes 1.11.1", "encoding_rs", "futures-core", "h2 0.4.14", "http 1.4.0", - "http-body", + "http-body 1.0.1", "http-body-util", - "hyper", + "hyper 1.9.0", "hyper-rustls", "hyper-tls", "hyper-util", @@ -3213,20 +3748,21 @@ dependencies = [ [[package]] name = "reqwest" -version = "0.13.3" +version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ - "base64", + "base64 0.22.1", "bytes 1.11.1", "encoding_rs", + "futures-channel", "futures-core", "futures-util", "h2 0.4.14", "http 1.4.0", - "http-body", + "http-body 1.0.1", "http-body-util", - "hyper", + "hyper 1.9.0", "hyper-rustls", "hyper-util", "js-sys", @@ -3260,7 +3796,7 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" dependencies = [ - "hmac", + "hmac 0.12.1", "subtle", ] @@ -3278,6 +3814,105 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rocket" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a516907296a31df7dc04310e7043b61d71954d703b603cc6867a026d7e72d73f" +dependencies = [ + "async-stream", + "async-trait", + "atomic 0.5.3", + "binascii", + "bytes 1.11.1", + "either", + "figment", + "futures 0.3.32", + "indexmap", + "log", + "memchr", + "multer", + "num_cpus", + "parking_lot", + "pin-project-lite", + "rand 0.8.6", + "ref-cast", + "rocket_codegen", + "rocket_http", + "serde", + "serde_json", + "state", + "tempfile", + "time", + "tokio", + "tokio-stream", + "tokio-util", + "ubyte", + "version_check", + "yansi", +] + +[[package]] +name = "rocket_codegen" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "575d32d7ec1a9770108c879fc7c47815a80073f96ca07ff9525a94fcede1dd46" +dependencies = [ + "devise", + "glob", + "indexmap", + "proc-macro2", + "quote", + "rocket_http", + "syn 2.0.117", + "unicode-xid", + "version_check", +] + +[[package]] +name = "rocket_cors" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfac3a1df83f8d4fc96aa41dba3b86c786417b7fc0f52ec76295df2ba781aa69" +dependencies = [ + "http 0.2.12", + "log", + "regex", + "rocket", + "serde", + "serde_derive", + "unicase", + "unicase_serde", + "url", +] + +[[package]] +name = "rocket_http" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e274915a20ee3065f611c044bd63c40757396b6dbc057d6046aec27f14f882b9" +dependencies = [ + "cookie 0.18.1", + "either", + "futures 0.3.32", + "http 0.2.12", + "hyper 0.14.32", + "indexmap", + "log", + "memchr", + "pear", + "percent-encoding", + "pin-project-lite", + "ref-cast", + "serde", + "smallvec", + "stable-pattern", + "state", + "time", + "tokio", + "uncased", +] + [[package]] name = "rsa" version = "0.9.10" @@ -3298,6 +3933,31 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rsqlite-vfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" +dependencies = [ + "hashbrown 0.16.1", + "thiserror", +] + +[[package]] +name = "rusqlite" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0d2b0146dd9661bf67bb107c0bb2a55064d556eeb3fc314151b957f313bcd4e" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", + "sqlite-wasm-rs", +] + [[package]] name = "rustc-hash" version = "2.1.2" @@ -3431,6 +4091,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + [[package]] name = "scopeguard" version = "1.2.0" @@ -3516,14 +4182,14 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "itoa", "memchr", @@ -3532,6 +4198,15 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -3544,17 +4219,6 @@ dependencies = [ "serde", ] -[[package]] -name = "sha1" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "digest 0.10.7", -] - [[package]] name = "sha1" version = "0.11.0" @@ -3598,6 +4262,15 @@ dependencies = [ "keccak", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shlex" version = "1.3.0" @@ -3742,11 +4415,23 @@ dependencies = [ "der", ] +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + [[package]] name = "sqlx" -version = "0.8.6" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +checksum = "378620ccc25c62c89d8be1c819e76a88d59bdcc3304733330788948e619bfd71" dependencies = [ "sqlx-core", "sqlx-macros", @@ -3757,12 +4442,13 @@ dependencies = [ [[package]] name = "sqlx-core" -version = "0.8.6" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb" dependencies = [ - "base64", + "base64 0.22.1", "bytes 1.11.1", + "cfg-if", "crc", "crossbeam-queue", "either", @@ -3771,13 +4457,12 @@ dependencies = [ "futures-intrusive", "futures-io", "futures-util", - "hashbrown 0.15.5", + "hashbrown 0.16.1", "hashlink", "indexmap", "log", "memchr", "native-tls", - "once_cell", "percent-encoding", "serde", "serde_json", @@ -3792,28 +4477,28 @@ dependencies = [ [[package]] name = "sqlx-macros" -version = "0.8.6" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +checksum = "bd2b84f2bc39a5705ef27ec785a11c934a41bbd4a24941e257927cddc26b60bf" dependencies = [ "proc-macro2", "quote", "sqlx-core", "sqlx-macros-core", - "syn", + "syn 2.0.117", ] [[package]] name = "sqlx-macros-core" -version = "0.8.6" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +checksum = "fb8d96de5fdc85a5c4ec813432b523ec637e80ba98f046555f75f7908ddac7c3" dependencies = [ + "cfg-if", "dotenvy", "either", "heck", "hex", - "once_cell", "proc-macro2", "quote", "serde", @@ -3823,61 +4508,46 @@ dependencies = [ "sqlx-mysql", "sqlx-postgres", "sqlx-sqlite", - "syn", + "syn 2.0.117", + "thiserror", "tokio", "url", ] [[package]] name = "sqlx-mysql" -version = "0.8.6" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +checksum = "90b8020fe17c5f2c245bfa2505d7ef59c5604839527c740266ad2214acebea27" dependencies = [ - "atoi", - "base64", "bitflags", "byteorder", "bytes 1.11.1", "crc", - "digest 0.10.7", + "digest 0.11.3", "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 0.10.6", - "sha2 0.10.9", - "smallvec", + "sha1", + "sha2 0.11.0", "sqlx-core", - "stringprep", "thiserror", "tracing", - "whoami", ] [[package]] name = "sqlx-postgres" -version = "0.8.6" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e" dependencies = [ "atoi", - "base64", + "base64 0.22.1", "bitflags", "byteorder", "crc", @@ -3887,18 +4557,16 @@ dependencies = [ "futures-core", "futures-util", "hex", - "hkdf", - "hmac", - "home", + "hkdf 0.13.0", + "hmac 0.13.0", "itoa", "log", "md-5", "memchr", - "once_cell", - "rand 0.8.6", + "rand 0.10.1", "serde", "serde_json", - "sha2 0.10.9", + "sha2 0.11.0", "smallvec", "sqlx-core", "stringprep", @@ -3909,12 +4577,13 @@ dependencies = [ [[package]] name = "sqlx-sqlite" -version = "0.8.6" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +checksum = "488e99c397a62007e4229aec669a179816339afc6d2620ca6fa420dbee2e982c" dependencies = [ "atoi", "flume", + "form_urlencoded", "futures-channel", "futures-core", "futures-executor", @@ -3924,19 +4593,36 @@ dependencies = [ "log", "percent-encoding", "serde", - "serde_urlencoded", "sqlx-core", "thiserror", "tracing", "url", ] +[[package]] +name = "stable-pattern" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4564168c00635f88eaed410d5efa8131afa8d8699a612c80c455a0ba05c21045" +dependencies = [ + "memchr", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "state" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b8c4a4445d81357df8b1a650d0d0d6fbbbfe99d064aa5e02f3e4022061476d8" +dependencies = [ + "loom", +] + [[package]] name = "stringprep" version = "0.1.5" @@ -3971,6 +4657,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -3988,7 +4685,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4048,7 +4745,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4171,7 +4868,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4219,6 +4916,47 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow 0.7.15", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "tower" version = "0.5.3" @@ -4244,7 +4982,7 @@ dependencies = [ "bytes 1.11.1", "futures-util", "http 1.4.0", - "http-body", + "http-body 1.0.1", "pin-project-lite", "tower", "tower-layer", @@ -4284,7 +5022,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4294,6 +5032,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", ] [[package]] @@ -4317,6 +5085,41 @@ version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +[[package]] +name = "ubyte" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f720def6ce1ee2fc44d40ac9ed6d3a59c361c80a75a7aa8e75bb9baed31cf2ea" +dependencies = [ + "serde", +] + +[[package]] +name = "uncased" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697" +dependencies = [ + "serde", + "version_check", +] + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicase_serde" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ef53697679d874d69f3160af80bc28de12730a985d57bdf2b47456ccb8b11f1" +dependencies = [ + "serde", + "unicase", +] + [[package]] name = "unicode-bidi" version = "0.3.18" @@ -4414,6 +5217,23 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "vcpkg" version = "0.2.15" @@ -4469,12 +5289,6 @@ dependencies = [ "wit-bindgen 0.51.0", ] -[[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.121" @@ -4517,7 +5331,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -4608,13 +5422,9 @@ dependencies = [ [[package]] name = "whoami" -version = "1.6.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" -dependencies = [ - "libredox", - "wasite", -] +checksum = "998767ef88740d1f5b0682a9c53c24431453923962269c2db68ee43788c5a40d" [[package]] name = "winapi" @@ -4647,6 +5457,50 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e686886bc078bc1b0b600cac0147aadb815089b6e4da64016cbd754b6342700f" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "windows-link" version = "0.2.1" @@ -4682,15 +5536,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-sys" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" -dependencies = [ - "windows-targets 0.48.5", -] - [[package]] name = "windows-sys" version = "0.52.0" @@ -4904,6 +5749,24 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen" version = "0.51.0" @@ -4940,7 +5803,7 @@ dependencies = [ "heck", "indexmap", "prettyplease", - "syn", + "syn 2.0.117", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -4956,7 +5819,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -5013,6 +5876,15 @@ dependencies = [ "tap", ] +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" +dependencies = [ + "is-terminal", +] + [[package]] name = "yoke" version = "0.8.2" @@ -5032,7 +5904,7 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -5053,7 +5925,7 @@ checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -5073,7 +5945,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -5094,7 +5966,7 @@ checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -5127,7 +5999,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 87fffde3..db9a9258 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["pg-core", "pg-cli", "pg-pkg", "pg-ffi"] +members = ["pg-core", "pg-cli", "pg-pkg", "pg-ffi", "cryptify"] exclude = ["pg-wasm", "pg-compat"] resolver = "2" diff --git a/Dockerfile b/Dockerfile index d0e638f2..a7b0c1c2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,10 @@ # syntax=docker/dockerfile:1 # ── Stage 1: install cargo-chef once ───────────────────────────────────────── -FROM rust:1.91.1-slim AS chef +# 1.96.1 matches cryptify's Dockerfile, and >=1.94 is now a hard floor: sqlx +# 0.9 declares rust-version 1.94.0, and sqlx 0.9 is what the workspace needs +# for pg-pkg and cryptify to agree on one libsqlite3-sys. +FROM rust:1.96.1-slim-trixie AS chef RUN apt-get update && apt-get --no-install-recommends install -y libssl-dev pkg-config \ && rm -rf /var/lib/apt/lists/* RUN cargo install cargo-chef --locked @@ -9,11 +12,14 @@ WORKDIR /app # ── Stage 2: compute the dependency recipe ─────────────────────────────────── FROM chef AS planner -COPY pg-core ./pg-core -COPY pg-pkg ./pg-pkg -COPY pg-cli ./pg-cli -COPY pg-ffi ./pg-ffi -COPY pg-wasm ./pg-wasm +COPY pg-core ./pg-core +COPY pg-pkg ./pg-pkg +COPY pg-cli ./pg-cli +COPY pg-ffi ./pg-ffi +COPY pg-wasm ./pg-wasm +# A workspace member cargo cannot read is a hard error even for a build that +# never compiles it: `cargo chef prepare` loads every member's manifest. +COPY cryptify ./cryptify COPY Cargo.toml Cargo.lock ./ RUN cargo chef prepare --recipe-path recipe.json @@ -25,11 +31,14 @@ COPY --from=planner /app/recipe.json recipe.json RUN cargo chef cook --profile ${CARGO_PROFILE} --bin pg-pkg --recipe-path recipe.json # Copy sources and build the application binary -COPY pg-core ./pg-core -COPY pg-pkg ./pg-pkg -COPY pg-cli ./pg-cli -COPY pg-ffi ./pg-ffi -COPY pg-wasm ./pg-wasm +COPY pg-core ./pg-core +COPY pg-pkg ./pg-pkg +COPY pg-cli ./pg-cli +COPY pg-ffi ./pg-ffi +COPY pg-wasm ./pg-wasm +# A workspace member cargo cannot read is a hard error even for a build that +# never compiles it: `cargo chef prepare` loads every member's manifest. +COPY cryptify ./cryptify COPY Cargo.toml Cargo.lock ./ RUN cargo build --profile ${CARGO_PROFILE} --bin pg-pkg diff --git a/cryptify/.dockerignore b/cryptify/.dockerignore new file mode 100644 index 00000000..8e58c468 --- /dev/null +++ b/cryptify/.dockerignore @@ -0,0 +1,7 @@ +**/target +.git +.github +.gitignore +*.md +.vscode +.idea diff --git a/cryptify/.gitignore b/cryptify/.gitignore new file mode 100644 index 00000000..13faff6f --- /dev/null +++ b/cryptify/.gitignore @@ -0,0 +1,8 @@ +dist/ +data/ +irma/ +target/ + +.idea +.vscode +/config.toml \ No newline at end of file diff --git a/cryptify/CHANGELOG.md b/cryptify/CHANGELOG.md new file mode 100644 index 00000000..5fa630cd --- /dev/null +++ b/cryptify/CHANGELOG.md @@ -0,0 +1,620 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Security + +- require a validated API key on `GET /usage` and reject unauthenticated callers with 401 (GHSA-5rhx-xgvv-h78h) +- compare `cryptify_token` values in constant time, matching the recovery-token path + +## [0.1.27](https://github.com/encryption4all/cryptify/compare/v0.1.26...v0.1.27) - 2026-05-16 + +### Added + +- staging_mode config that logs instead of sending email +- *(upload)* GET /fileupload/{uuid}/status for cross-refresh resume + +### Fixed + +- address dead UUID check and copy-paste error in upload_chunk +- add HTTP Range support to /filedownload + +### Other + +- add regression test for invalid_uuid reason on upload_chunk +- resolve conflicts with main (download route) +- *(upload)* cover /status preflight + deadline-extension AC + +## [0.1.26](https://github.com/encryption4all/cryptify/compare/v0.1.25...v0.1.26) - 2026-05-07 + +### Added + +- *(upload)* idempotent retry of the last committed chunk +- *(upload)* structured 404 body, configurable session TTL + +### Fixed + +- validate API key against pg-pkg, not a local allowlist (closes #123) + +### Other + +- *(upload)* apply review feedback from #145 +- Merge pull request #132 from encryption4all/fix/extend-upload-eviction-on-chunk +- Merge pull request #141 from encryption4all/dependabot/cargo/openssl-0.10.79 +- Merge remote-tracking branch 'origin/main' into fix/upload-init-orphan-files-125 +- update dependencies +- add Rust quality job (fmt, clippy, test) + +## [0.1.25](https://github.com/encryption4all/cryptify/compare/v0.1.24...v0.1.25) - 2026-05-02 + +### Added + +- *(upload)* add notifyRecipients toggle on /fileupload/init + +### Other + +- Merge pull request #135 from encryption4all/feat/notify-recipients-toggle + +## [0.1.24](https://github.com/encryption4all/cryptify/compare/v0.1.23...v0.1.24) - 2026-04-30 + +### Other + +- Merge pull request #113 from encryption4all/dependabot/cargo/rustls-webpki-0.103.13 +- Merge pull request #128 from encryption4all/dependabot/cargo/openssl-0.10.78 +- Merge remote-tracking branch 'origin/main' into chore/strum-0.28 +- Merge remote-tracking branch 'origin/main' into dobby/reqwest-0.13 +- Merge pull request #121 from encryption4all/dobby/sha2-rand +- Merge pull request #122 from encryption4all/dobby/pg-core-0.5 +- *(deps)* bump pg-core to 0.5 + +## [0.1.23](https://github.com/encryption4all/cryptify/compare/v0.1.22...v0.1.23) - 2026-04-26 + +### Other + +- *(api)* align upload limit descriptions with current constants + +## [0.1.22](https://github.com/encryption4all/cryptify/compare/v0.1.21...v0.1.22) - 2026-04-24 + +### Added + +- make chunk size configurable via TOML, default 5 MB +- tiered upload limits for API key users, 10 MiB chunks, resets_at in 413 +- enforce server-side upload limits (5 GiB/upload, 15 GiB rolling/email) + +### Fixed + +- align upload limits to round GB values (5 GB / 100 GB) +- use GB instead of GiB in API description +- *(upload)* raise Rocket data limits to match CHUNK_SIZE + +### Other + +- Merge branch 'main' into feat/x-postguard-email-header +- Merge pull request #105 from encryption4all/fix/upload-chunk-data-limit +- remove outdated cryptify-frontend/backend references +- Add PostGuard logo to README +- Standardize README + +## [0.1.21](https://github.com/encryption4all/cryptify/compare/v0.1.20...v0.1.21) - 2026-04-20 + +### Other + +- cargo update to refresh dependencies and resolve advisories +- Fix release-plz creating PRs when nothing changed + +## [0.1.20](https://github.com/encryption4all/cryptify/compare/v0.1.19...v0.1.20) - 2026-04-03 + +### Other + +- Use high-res PostGuard logo with text in email template + +## [0.1.19](https://github.com/encryption4all/cryptify/compare/v0.1.18...v0.1.19) - 2026-03-30 + +### Other + +- Merge branch 'main' into add-explanation +- Update build pipeline versions + +## [0.1.18](https://github.com/encryption4all/cryptify/compare/v0.1.17...v0.1.18) - 2026-03-30 + +### Other + +- Use native runners +- Try 24375738 to make the pipeline work + +## [0.1.17](https://github.com/encryption4all/cryptify/compare/v0.1.16...v0.1.17) - 2026-03-27 + +### Other + +- Upload to ghcr instead of docker +- Switch to docker specified workflow +- Move anchore scan +- Disable arm/amd build publish +- Try 100 version of automated release + +## [0.1.16](https://github.com/encryption4all/cryptify/compare/v0.1.15...v0.1.16) - 2026-03-27 + +### Other + +- Disable main build on push + +## [0.1.15](https://github.com/encryption4all/cryptify/compare/v0.1.14...v0.1.15) - 2026-03-27 + +### Other + +- try the rlsplz way + +## [0.1.14](https://github.com/encryption4all/cryptify/compare/v0.1.13...v0.1.14) - 2026-03-27 + +### Other + +- use one workflow file + +## [0.1.13](https://github.com/encryption4all/cryptify/compare/v0.1.12...v0.1.13) - 2026-03-27 + +### Other + +- Run on tag push + +## [0.1.12](https://github.com/encryption4all/cryptify/compare/v0.1.11...v0.1.12) - 2026-03-27 + +### Other + +- Run on release-plz completion + +## [0.1.11](https://github.com/encryption4all/cryptify/compare/v0.1.10...v0.1.11) - 2026-03-27 + +### Other + +- Try other tag based flow + +## [0.1.10](https://github.com/encryption4all/cryptify/compare/v0.1.9...v0.1.10) - 2026-03-27 + +### Other + +- Add rlsplz dependency + +## [0.1.9](https://github.com/encryption4all/cryptify/compare/v0.1.8...v0.1.9) - 2026-03-27 + +### Added + +- one qr code for signature +- add button to include sender confirmation +- add sent confirmation, also encrypt for sender +- keep the border around the file box +- apply more of Jorrits new design +- add filesharing to multiple recipients +- more work on signatures +- update pg-wasm package +- change postguard pkg url +- add sender verification and update rocket to rc3 +- include metrics header in all PKG requests +- retrieve lang setting via message +- add example irma server configuration +- determine backend url automatically +- bump wasm dependency to 0.2.2 +- add swapped font +- minor style changes to match embedded design +- remove rocket cors for now, since backend and frontend are on the same host +- update docker-compose config +- feat add/update docker-compose configurations +- only expose nginx service from host +- frontend and backend on same origin + +### Fixed + +- semver version on release +- scope config.toml gitignore pattern to repo root only +- add initial v0.1.0 changelog entry to prevent release-plz from including all history +- trigger delivery on tag push so semver Docker tags are applied +- remove invalid command value from release-plz workflow +- replace checkmark SVG with HTML/unicode equivalent in email ([#29](https://github.com/encryption4all/cryptify/pull/29)) +- replace SVG with PNG in email template ([#29](https://github.com/encryption4all/cryptify/pull/29)) +- keep one recipient, clear when removed +- scrollable column +- use correct language in EncryptPanel +- translation and layout fixes +- start command in dev setup +- remove/rename irma/mailhog correctly +- wrong expiry date calculation +- height input file button in dutch +- minor changes to message textarea css +- actually use irma token in onEncrypt() +- sending e-mails now work in debug and release mode +- several front-end bugfixes +- typos +- backend config read correctly +- trailing slash backend url +- set public path correctly +- production-like config.toml +- error in dev config +- fix some post-merge errors +- fix conflicts +- force lowercase email address +- dont use form in DecryptPanel, since button in form uses has + +### Other + +- release v0.1.8 +- Disable release-plz cargo publishing +- Add id-token write +- release v0.1.7 +- Reset release-plz to defaults +- release v0.1.6 +- release v0.1.5 +- move Rust crate from cryptify/ subdirectory to repo root +- release v0.1.4 +- Merge pull request #68 from encryption4all/fix/release-plz-setup +- release v0.1.1 +- add package description to Cargo.toml +- update Rust edition from 2018 to 2021 +- add repository and license metadata to Cargo.toml +- Merge pull request #58 from encryption4all/feat/release-plz +- Update pipeline action versions +- Add release-plz +- *(deps)* bump rustls-webpki from 0.103.8 to 0.103.10 in /cryptify +- Merge pull request #50 from encryption4all/dependabot/cargo/cryptify/time-0.3.47 +- Merge pull request #49 from encryption4all/dependabot/cargo/cryptify/bytes-1.11.1 +- *(deps)* bump bytes from 1.10.1 to 1.11.1 in /cryptify +- upgrade anchore/scan-action to v7.3.2 and codeql-action to v4 +- Move imdage name from cryptify-backend to cryptify +- Split Docker build into native amd64/arm64 jobs, add cargo-chef caching +- Add poll watching for claude code +- Change email template to match design +- Merge pull request #46 from encryption4all/rm-frontend +- Change url send confirmation +- Add SMTP logging and connection timeout to email sending +- Add 10s timeout to PKG fetch to prevent silent startup hang +- Change error to properly print url +- Add better error msg for pkg fetch +- Fix CI deployment +- Change email url +- Rename cryptify-backend to cryptify +- Add dockerignore to cut build context +- Change pkg_url in dev.toml +- Improve dev setup and update API description +- Remove frontend +- Remove frontend and clean up unused deployment files +- Add docker-compose.dev.yml for local development +- Update dev configuration for Rocket compatibility +- Add development Dockerfile for frontend +- Add development Dockerfile for backend with cargo-chef +- Remove double wasm types definition nginx.conf +- Use matrix builds for faster build times +- Use hashes instead of tags to prevent potential sidechain attacks +- External config file ([#31](https://github.com/encryption4all/cryptify/pull/31)) +- Frontend docker file ([#30](https://github.com/encryption4all/cryptify/pull/30)) +- Frontend docker file ([#29](https://github.com/encryption4all/cryptify/pull/29)) +- Made a docker file for the frontend ([#28](https://github.com/encryption4all/cryptify/pull/28)) +- I do not know how it got built with docker, now builds with command line too +- Added health endpoint ([#27](https://github.com/encryption4all/cryptify/pull/27)) +- expose port +- Updated deps and made workflow for delivery ([#26](https://github.com/encryption4all/cryptify/pull/26)) +- Update PKG URL +- Updated env var +- Update env variable +- Updated env vars +- Updated backend url to use main env +- Merge branch 'main' of https://github.com/encryption4all/cryptify +- Updated PKG URL +- Merge branch 'main' of https://github.com/encryption4all/cryptify +- Updated pg-wasm version +- Merge branch 'stable' into main +- *(deps)* update pg-wasm 0.3.0 +- remove yivi css +- more work on layout +- change to sign and send button +- small changes +- merge main +- small changes +- use new pkg urls +- initial signature support +- remove unused encrypt panel code +- update to released version of lettre, minor other changes +- use PUBLIC_URL env variable +- small changes +- embed version of cryptify +- postguard embed version +- Merge branch 'dev' +- update readme +- add backend dockerfile +- update package-lock.json +- update docker-compose config +- update gitignore +- simplify decryptPanel +- small changes to compose +- remove old deployment files +- for now, don't include cors settings +- move dev config file to conf/ +- remove unused old CORS config +- remove unused responders structs +- changes to verification code and setup CORS configuration +- simplify encryption process +- Merge main including sender authentication in dev branch +- Merge branch 'main' into add-email-verification +- Merge pull request #1 from arjentz/add-rust-backend +- Processed review +- Fix docker-compose.dev.yml +- Add development setup +- Remove metadata, cargo clippy, cargo fmt +- Update backend to match frontend changes +- Uncomment some proper checks +- Add code from rust backend +- Final sync to github +- Initial commit. +- Update README.md +- Update README.md +- Delete LICENSE +- Create LICENSE +- Initial commit + +## [0.1.8](https://github.com/encryption4all/cryptify/compare/v0.1.7...v0.1.8) - 2026-03-27 + +### Fixed + +- semver version on release + +## [0.1.7](https://github.com/encryption4all/cryptify/compare/v0.1.6...v0.1.7) - 2026-03-27 + +### Other + +- Reset release-plz to defaults + +## [0.1.6](https://github.com/encryption4all/cryptify/compare/v0.1.5...v0.1.6) - 2026-03-27 + +### Added + +- one qr code for signature +- add button to include sender confirmation +- add sent confirmation, also encrypt for sender +- keep the border around the file box +- apply more of Jorrits new design +- add filesharing to multiple recipients +- more work on signatures +- update pg-wasm package +- change postguard pkg url +- add sender verification and update rocket to rc3 +- include metrics header in all PKG requests +- retrieve lang setting via message +- add example irma server configuration +- determine backend url automatically +- bump wasm dependency to 0.2.2 +- add swapped font +- minor style changes to match embedded design +- remove rocket cors for now, since backend and frontend are on the same host +- update docker-compose config +- feat add/update docker-compose configurations +- only expose nginx service from host +- frontend and backend on same origin + +### Fixed + +- scope config.toml gitignore pattern to repo root only +- add initial v0.1.0 changelog entry to prevent release-plz from including all history +- trigger delivery on tag push so semver Docker tags are applied +- remove invalid command value from release-plz workflow +- replace checkmark SVG with HTML/unicode equivalent in email ([#29](https://github.com/encryption4all/cryptify/pull/29)) +- replace SVG with PNG in email template ([#29](https://github.com/encryption4all/cryptify/pull/29)) +- keep one recipient, clear when removed +- scrollable column +- use correct language in EncryptPanel +- translation and layout fixes +- start command in dev setup +- remove/rename irma/mailhog correctly +- wrong expiry date calculation +- height input file button in dutch +- minor changes to message textarea css +- actually use irma token in onEncrypt() +- sending e-mails now work in debug and release mode +- several front-end bugfixes +- typos +- backend config read correctly +- trailing slash backend url +- set public path correctly +- production-like config.toml +- error in dev config +- fix some post-merge errors +- fix conflicts +- force lowercase email address +- dont use form in DecryptPanel, since button in form uses has + +### Other + +- release v0.1.5 +- move Rust crate from cryptify/ subdirectory to repo root +- release v0.1.4 +- Merge pull request #68 from encryption4all/fix/release-plz-setup +- release v0.1.1 +- add package description to Cargo.toml +- update Rust edition from 2018 to 2021 +- add repository and license metadata to Cargo.toml +- Merge pull request #58 from encryption4all/feat/release-plz +- Update pipeline action versions +- Add release-plz +- *(deps)* bump rustls-webpki from 0.103.8 to 0.103.10 in /cryptify +- Merge pull request #50 from encryption4all/dependabot/cargo/cryptify/time-0.3.47 +- Merge pull request #49 from encryption4all/dependabot/cargo/cryptify/bytes-1.11.1 +- *(deps)* bump bytes from 1.10.1 to 1.11.1 in /cryptify +- upgrade anchore/scan-action to v7.3.2 and codeql-action to v4 +- Move imdage name from cryptify-backend to cryptify +- Split Docker build into native amd64/arm64 jobs, add cargo-chef caching +- Add poll watching for claude code +- Change email template to match design +- Merge pull request #46 from encryption4all/rm-frontend +- Change url send confirmation +- Add SMTP logging and connection timeout to email sending +- Add 10s timeout to PKG fetch to prevent silent startup hang +- Change error to properly print url +- Add better error msg for pkg fetch +- Fix CI deployment +- Change email url +- Rename cryptify-backend to cryptify +- Add dockerignore to cut build context +- Change pkg_url in dev.toml +- Improve dev setup and update API description +- Remove frontend +- Remove frontend and clean up unused deployment files +- Add docker-compose.dev.yml for local development +- Update dev configuration for Rocket compatibility +- Add development Dockerfile for frontend +- Add development Dockerfile for backend with cargo-chef +- Remove double wasm types definition nginx.conf +- Use matrix builds for faster build times +- Use hashes instead of tags to prevent potential sidechain attacks +- External config file ([#31](https://github.com/encryption4all/cryptify/pull/31)) +- Frontend docker file ([#30](https://github.com/encryption4all/cryptify/pull/30)) +- Frontend docker file ([#29](https://github.com/encryption4all/cryptify/pull/29)) +- Made a docker file for the frontend ([#28](https://github.com/encryption4all/cryptify/pull/28)) +- I do not know how it got built with docker, now builds with command line too +- Added health endpoint ([#27](https://github.com/encryption4all/cryptify/pull/27)) +- expose port +- Updated deps and made workflow for delivery ([#26](https://github.com/encryption4all/cryptify/pull/26)) +- Update PKG URL +- Updated env var +- Update env variable +- Updated env vars +- Updated backend url to use main env +- Merge branch 'main' of https://github.com/encryption4all/cryptify +- Updated PKG URL +- Merge branch 'main' of https://github.com/encryption4all/cryptify +- Updated pg-wasm version +- Merge branch 'stable' into main +- *(deps)* update pg-wasm 0.3.0 +- remove yivi css +- more work on layout +- change to sign and send button +- small changes +- merge main +- small changes +- use new pkg urls +- initial signature support +- remove unused encrypt panel code +- update to released version of lettre, minor other changes +- use PUBLIC_URL env variable +- small changes +- embed version of cryptify +- postguard embed version +- Merge branch 'dev' +- update readme +- add backend dockerfile +- update package-lock.json +- update docker-compose config +- update gitignore +- simplify decryptPanel +- small changes to compose +- remove old deployment files +- for now, don't include cors settings +- move dev config file to conf/ +- remove unused old CORS config +- remove unused responders structs +- changes to verification code and setup CORS configuration +- simplify encryption process +- Merge main including sender authentication in dev branch +- Merge branch 'main' into add-email-verification +- Merge pull request #1 from arjentz/add-rust-backend +- Processed review +- Fix docker-compose.dev.yml +- Add development setup +- Remove metadata, cargo clippy, cargo fmt +- Update backend to match frontend changes +- Uncomment some proper checks +- Add code from rust backend +- Final sync to github +- Initial commit. +- Update README.md +- Update README.md +- Delete LICENSE +- Create LICENSE +- Initial commit + +## [0.1.5](https://github.com/encryption4all/cryptify/compare/v0.1.4...v0.1.5) - 2026-03-27 + +### Fixed + +- add initial v0.1.0 changelog entry to prevent release-plz from including all history +- replace checkmark SVG with HTML/unicode equivalent in email ([#29](https://github.com/encryption4all/cryptify/pull/29)) +- replace SVG with PNG in email template ([#29](https://github.com/encryption4all/cryptify/pull/29)) + +### Other + +- release v0.1.4 +- Merge pull request #68 from encryption4all/fix/release-plz-setup +- release v0.1.1 +- add package description to Cargo.toml +- update Rust edition from 2018 to 2021 +- add repository and license metadata to Cargo.toml +- Split smtp credentials into username password +- *(deps)* bump rustls-webpki from 0.103.8 to 0.103.10 in /cryptify +- Merge pull request #50 from encryption4all/dependabot/cargo/cryptify/time-0.3.47 +- Merge pull request #49 from encryption4all/dependabot/cargo/cryptify/bytes-1.11.1 +- *(deps)* bump bytes from 1.10.1 to 1.11.1 in /cryptify +- Split Docker build into native amd64/arm64 jobs, add cargo-chef caching +- Change email template to match design +- Change url send confirmation +- Add SMTP logging and connection timeout to email sending +- Add 10s timeout to PKG fetch to prevent silent startup hang +- Change error to properly print url +- Add better error msg for pkg fetch +- Change email url +- Rename cryptify-backend to cryptify + +## [0.1.4](https://github.com/encryption4all/cryptify/compare/v0.1.3...v0.1.4) - 2026-03-27 + +### Fixed + +- add initial v0.1.0 changelog entry to prevent release-plz from including all history + +### Other + +- Merge pull request #68 from encryption4all/fix/release-plz-setup + +## [0.1.3](https://github.com/encryption4all/cryptify/compare/v0.1.2...v0.1.3) - 2026-03-26 + +### Fixed + +- replace checkmark SVG with HTML/unicode equivalent in email ([#29](https://github.com/encryption4all/cryptify/pull/29)) +- replace SVG with PNG in email template ([#29](https://github.com/encryption4all/cryptify/pull/29)) + +### Other + +- release v0.1.2 +- release v0.1.1 +- add package description to Cargo.toml +- update Rust edition from 2018 to 2021 +- add repository and license metadata to Cargo.toml +- Split smtp credentials into username password +- *(deps)* bump rustls-webpki from 0.103.8 to 0.103.10 in /cryptify +- Merge pull request #50 from encryption4all/dependabot/cargo/cryptify/time-0.3.47 +- Merge pull request #49 from encryption4all/dependabot/cargo/cryptify/bytes-1.11.1 +- *(deps)* bump bytes from 1.10.1 to 1.11.1 in /cryptify +- Split Docker build into native amd64/arm64 jobs, add cargo-chef caching +- Change email template to match design +- Change url send confirmation +- Add SMTP logging and connection timeout to email sending +- Add 10s timeout to PKG fetch to prevent silent startup hang +- Change error to properly print url +- Add better error msg for pkg fetch +- Change email url +- Rename cryptify-backend to cryptify + +## [0.1.2](https://github.com/encryption4all/cryptify/compare/v0.1.1...v0.1.2) - 2026-03-26 + +### Other + +- update Cargo.toml dependencies + +## [0.1.1](https://github.com/encryption4all/cryptify/compare/v0.1.0...v0.1.1) - 2026-03-26 + +### Other + +- add package description to Cargo.toml +- update Rust edition from 2018 to 2021 + +## [0.1.0] - 2026-03-26 + +Initial release. diff --git a/cryptify/CLAUDE.md b/cryptify/CLAUDE.md new file mode 100644 index 00000000..073a7878 --- /dev/null +++ b/cryptify/CLAUDE.md @@ -0,0 +1,306 @@ + +--- + +## Agent notes (migrated from the dobby memory repo) + +## Overview +`encryption4all/cryptify` is a Rocket/Rust file-upload service: a sender uploads a +file, cryptify PostGuard-seals it for a signed recipient, and emails a notification. +Backend only. `pdf-signature` is a fork sharing the same README, with a divergent +frontend and a slightly different config shape (see that repo's own notes). + +## Config +Backend config lives in `conf/config.toml` (prod) and `conf/config.dev.toml` (dev). +Keys: `server_url`, `address`, `data_dir`, `email_from`, `smtp_*`, `allowed_origins`, +`pkg_url`. The backend reads `ROCKET_CONFIG=config.toml` baked into the Dockerfile, +`[global]` profile in `conf/config.toml`. Compose bind-mounts `./conf/config.toml` +to `/app/config.toml:ro`; mutating the bind-mounted file requires a container +restart to take effect. + +## Release process +Release-plz automation. + +## Build / test +- `cargo check`, `cargo build --release`, `cargo test`, `cargo clippy --all-targets` + all work from repo root. +- No library target: tests live in `src/**` under `#[cfg(test)] mod tests`. +- CI (`.github/workflows/ci.yml`, `quality` job) runs `cargo fmt --all -- --check`, + `cargo clippy --all-targets -- -D warnings`, and `cargo test --all-targets` on + every PR. Run all three locally before pushing; a fmt/clippy failure blocks the PR. +- **Docker build Rust version can lag behind CI's stable toolchain.** The CI + `Rust quality` job uses `dtolnay/rust-toolchain@stable` (always latest), but the + Docker `Build (amd64/arm64)` jobs use the pinned `FROM rust:-slim-trixie` in + the `Dockerfile`. These can diverge enough that a dependency's build script needs + a newer Rust than the pinned Docker image ships (happened when `rusqlite`'s + `bundled` feature started needing `cfg_select`, stabilized in Rust 1.94, while + Docker was pinned to 1.93). When adding a dep, check whether it needs a newer Rust + than the Dockerfile's pin and bump the Dockerfile if so. `rust:*-slim-trixie` + already ships gcc, so `bundled` C compilation works without extra apt installs. +- **Tests that touch `Store` need a tokio runtime.** `Store::new()` spawns a purge + task via `rocket::tokio::spawn`; under plain `#[test]` it panics with "no reactor + running". Use `#[rocket::async_test]` and `async fn`, even when the body never + awaits. + +## Dependencies +- cryptify has no IRMA/Yivi client of its own. Attributes arrive already signed + inside the PostGuard-sealed file and are read back through `pg-core`'s Unsealer, + so nothing here talks to a Yivi session server. +- `pg-core` depends on the `irma` crate, so `irma` 0.2.1 is still compiled into the + binary even though cryptify does not declare it (`cargo tree -i irma`). It drags + in `reqwest` 0.11.27 too, alongside cryptify's own `reqwest` 0.13.4. Dropping the + direct declaration does not take `irma` out of the build; check `Cargo.toml` to + tell direct from transitive. + +## Running the binary +- Needs a reachable PKG server (`pkg_url`) at startup or it panics on + `/v2/sign/parameters`. For config tests, prefer a serde-roundtrip unit test over + booting the server. +- SMTP, `data_dir`, `pkg_url` are all required. + +## Request pipeline (build_rocket layering) +- Two seams in `src/main.rs`: `default_figment()` returns the bare + `rocket::Config::figment()`; `build_rocket(figment, vk)` extracts + `CryptifyConfig`, computes body-size limits from `config.chunk_size()`, merges + them, then constructs `rocket::custom(...)`. +- **Do NOT extract config inside `default_figment()`.** Integration tests layer + config on top with `default_figment().merge(...)`. Extracting too early panics + with `MissingField`. +- Body-size headroom is `chunk_size + 1 MiB` on `bytes`, `data-form`, `file`. + Per-request reads are still capped by `data.open((end - start).bytes())` in + `upload_chunk`. + +## Upload flow and state lifetime +- `POST /fileupload/init`: in-memory `FileState` keyed by UUID. Sender unknown at + this point. +- `PUT /fileupload/`: write a chunk (<= 1 MiB), advance `state.uploaded`. The + cryptify token rolls per chunk as `SHA256(prev_token || chunk)`. +- `POST /fileupload/finalize/`: run the postguard Unsealer over the whole + file to extract attributes; `sender` (`pbdf.sidn-pbdf.email.email`) becomes + known. +- **Purge timer:** `state.expirations` (a `BTreeMap` populated in `Store::create` + at `src/store.rs:292` with `Instant::now() + self.shared.idle_ttl`) is what + `purge_task` walks. `idle_ttl` defaults to `DEFAULT_UPLOAD_SESSION_IDLE_TIMEOUT_SECS` + (`60 * 60`, 1 hour); this is a resettable idle timeout, not a hard deadline from + creation. `Store::touch` (`src/store.rs:318`) removes the old `expirations` key and + re-inserts `Instant::now() + idle_ttl` on each chunk PUT and status check, so the + hour counts from the last activity (see the `touch_extends_eviction_deadline` test + at `src/store.rs:545`). `FileState.expires` (current_time + 14d) is NOT what drives + eviction; it's a different field, never read by the purge loop. When tracing + eviction, follow `state.expirations`. +- Purge does not delete the on-disk file. Rejecting at finalize must manually + `tokio::fs::remove_file` and `store.remove(uuid)`. +- In-memory only, no persistence. Process restart wipes all upload sessions and + orphans on-disk files in `data_dir/` (tracked as cryptify#116). +- Per-sender usage tracking is a `HashMap` in `StoreState.usage`, optionally backed + by SQLite when config `usage_db = ""` is set: `UsageDb` (rusqlite, + `bundled`) is the source of truth, the map is a cache loaded on startup and + written through on each `record_upload` (which also prunes rows outside the 14d + rolling window). `usage_db` unset means in-memory only (old behaviour). A + configured-but-unopenable DB panics at startup. + +## api-description.yaml is tied to the mounted routes by a test +`api_routes()` in `src/main.rs` is the single mount list; `build_rocket` mounts it +and `mod api_description_tests` compares it against `api-description.yaml`. A new, +removed, or renamed route fails `cargo test` until the spec is updated too. The +test only checks method + path shape (placeholder *names* are ignored, so the +route's `` binding and the spec's `{uuid}` compare equal) — response +codes and schemas are still on you. + +The spec is also the external contract for pg-js, pg-dotnet, and the add-ins, so +a breaking edit to it needs a new versioned route rather than an in-place change +(cryptify's routes are unversioned, so there is no other escape hatch). + +## The oasdiff gate's settings, and the test that pins them + +`.github/workflows/api-diff.yml` diffs the PR's `api-description.yaml` against +the base branch. Its whole behaviour is two step inputs, and a wrong pair fails +open: the job goes green and nobody learns the change went through. So +`mod api_gate_tests` in `src/main.rs` mutates the real spec one way per rule, +runs the real engine with the flags the action's entrypoint builds, and asserts +stop-or-pass. It also reads the committed workflow and asserts its two inputs +are the constants the module pins, and that the job still triggers on +`pull_request` with no path filter and no `if:` — settings on a gate that never +runs fail open just as quietly. So the two cannot drift apart unnoticed. + +The settings the gate needs are `fail-on: WARN` plus +`include-checks: response-non-success-status-removed,response-property-enum-value-removed`. +**The committed workflow does not have them yet**: it is still `fail-on: ERR` +with no `include-checks`. The new pair sits in the `api-diff.yml` patch on +PR #203 and needs a maintainer to apply it, because the App cannot push +`.github/workflows/`. Until that lands, the gate is passing everything in the +list below, and `the_workflow_uses_the_settings_this_module_pins` is red saying +so. Tighten this paragraph back to plain present tense in that same PR once the +maintainer's commit is on the branch. + +Measured on this spec against oasdiff v1.26.1, `fail-on: ERR` on its own passes +several changes the contract forbids. A `401` that becomes a `403` and a +dropped response enum value rate ERR but are opt-in, so they never run unless +named, which is what `include-checks` is for. The rest rate WARN, not ERR: a removed +or renamed optional response property, a removed request parameter, a removed +request property, and the constraint-narrowing `*-set` family. Those gaps are +spec-independent, so they apply here even though this spec marks most fields +`required` (which does make a removed *required* response property an ERR). + +WARN adds 30 checks on top of ERR's 212 (`oasdiff checks -s warn -f json`; the +table output has two rows more than that, a header and a trailing blank). All +but one of the 30 are changes the contract +already forbids. The exception is `response-property-enum-value-added`: adding +a value to `UploadSessionNotFound.reason` or `PayloadTooLarge.limit` fails the +gate even though a wider response enum is additive on paper, and today's +consumers do tolerate it (pg-js reads `reason` as `parsed.reason ?? 'unknown'`, +a plain string, and the tb-addon passes it through). It is kept anyway: nothing +stops a future client from switching on those codes, and a red gate that asks +for a decision beats a silent pass. It is also the only rule here that WARN +alone enforces, so it is the first casualty of a revert to ERR, which is why +the test pins it. + +Two things to know before reaching for a suppression. `--warn-ignore` and +`--err-ignore` do not take check ids or partial regexes: the ignore file is +matched by asking whether an ignore line *contains* the rendered change text, +so a line has to spell out the whole thing in lowercase, per affected +operation, including the new value: + +``` +in api put /fileupload/{uuid} added the new `quota_exceeded` enum value to the `reason` response property for the response status `404` +``` + +A bare `response-property-enum-value-added`, or even `.*`, suppresses nothing +(verified on v1.26.1). So there is no standing "ignore this check" setting; +every future enum value needs its own lines. And `x-extensible-enum` in place +of `enum:`, which oasdiff's own message suggests, makes it skip that property +altogether: adding a value passes, but so does *removing* one, so that trade +buys the false positive back with a gap. + +The mutation test needs the engine, which no runner has, so it skips in CI. The +other two do run there: `every_api_gate_mutation_still_applies` catches a spec +edit that strands an anchor, and +`the_workflow_uses_the_settings_this_module_pins` catches the workflow and the +constants disagreeing. To run the mutation test for real: + +``` +go install github.com/oasdiff/oasdiff@v1.26.1 # the version the action tag pins +cargo test --all-targets api_gate +``` + +## Content-Range end byte is EXCLUSIVE on the chunk PUT +`upload_chunk` rejects `start >= end` and takes the chunk length to be +`end - start`, so `bytes 200-1000/*` is 800 bytes at offset 200, not the 801 that +RFC 7233 would mean. `postguard-js` (`src/api/cryptify.ts:storeChunk`) sends +`bytes -/*` to match — that is correct, not an off-by-one. Do not +"fix" it in either repo alone; both sides move together or neither does. + +## Token chain must be checked on every route touching a FileState +The upload token chain (`SHA256(prev || chunk)`) must be validated on every route +that operates on an existing `FileState`, not just `PUT`. An earlier version only +checked it on `PUT`, letting anyone who guessed a live UUID finalize another user's +upload (fixed). When adding new routes, mirror the token check `upload_chunk` uses; +don't trust UUID knowledge alone as authorization. + +## CORS +`allowed_origins` is a single regex string in `rocket_cors` 0.6.0. +`AllowedOrigins::some_regex` compiles via `regex::RegexSet`; standard alternation +works fine. The regex is anchored (`^...$`), so there's no subdomain/wildcard +bypass. + +## Metrics +- `GET /metrics`: Prometheus text format, unauthenticated by design. Lock down at + the firewall, not the endpoint. +- Channel label derived in priority: `X-Cryptify-Source`, then + `Authorization: Bearer` / `X-Api-Key` (-> `api`), then `Origin` (-> `website` / + `staging-website`), then `User-Agent` (-> `outlook` / `thunderbird`), then + `unknown`. Sanitized to `[a-z0-9_-]`, max 32 chars. +- Storage gauges are sampled from `data_dir` on a background task (default 60s, + `metrics_scan_interval_secs`). +- `FileState.source_channel` is populated at `upload_init` from request headers; + populate it in any new test fixtures too. + +## Integration test harness +- `build_rocket(figment, vk)` is the injection point. `#[launch] rocket()` wraps it + and fetches vk via `minreq` for production. +- `CryptifyConfig.email_stub: bool` (default false) short-circuits `send_email`; + set true in test figments. +- `pg_core::test::TestSetup` provides `VerifyingKey` plus an encryption policy and + signing keys. The test policy includes `pbdf.sidn-pbdf.email.email = + "bob@example.com"`; seal with `signing_keys[2]` (Bob) for finalize to succeed. +- pg-core's Sealer API uses rand 0.8; cryptify uses rand 0.9. Dev-deps alias + `rand08 = { package = "rand", version = "0.8" }`; use `rand08::thread_rng()` only + in test code calling pg-core directly. +- Integration tests live inline in `src/main.rs` under `mod integration`, not in + `tests/` (that would require a library target). +- Each test gets a per-test temp `data_dir` under `std::env::temp_dir()` with a + uuid suffix for parallel safety. + +For handler-level tests that need `State` and `State` +without the full `build_rocket` injection point: + +```rust +use rocket::figment::{providers::Serialized, Figment}; +use rocket::local::asynchronous::Client; + +let figment = Figment::from(rocket::Config::default()).merge(Serialized::defaults( + serde_json::json!({ + "server_url": "http://localhost", + "data_dir": data_dir.to_str().unwrap(), + "email_from": "Test ", + "smtp_url": "localhost", + "smtp_port": 1025u16, + "allowed_origins": ".*", + "pkg_url": "http://localhost", + }), +)); + +let rocket = rocket::custom(figment) + .mount("/", routes![upload_init]) + .attach(AdHoc::config::()) + .manage(Store::new()); +let client = Client::tracked(rocket).await.unwrap(); +``` + +Gotchas: `#[rocket::async_test]` is required (Store spawns purge_task, needs a +reactor). `InitBody` is camelCase; send `mailContent` and `mailLang` (not +snake_case) or you get a 422. `email::Language` serializes uppercase (`"EN"`, +`"NL"`). This minimal harness only works for routes that don't need the verifying +key (`upload_init`, `health`, `usage`); routes needing vk need the full +`build_rocket(figment, vk)`. + +## X-PostGuard header convention +Cryptify's notification emails set an `X-PostGuard` header using the `pg-core` +crate version as the value (e.g. `X-PostGuard: 0.6.1`), wired at build time via +`build.rs` reading `Cargo.lock`. This gives operational visibility into which +postguard version processed a given email, and it advances automatically as +`pg-core` is bumped, no manual updates needed. This supersedes an earlier +preference for a semantic token like `notification` (cryptify#170). + +For reference, the tb-addon (Thunderbird) implementation sets +`x-postguard: 0.1.0` via `customHeaders` on `onBeforeSend`; detection of "is this a +PostGuard message" elsewhere uses the `postguard.encrypted` attachment or the +inline `-----BEGIN POSTGUARD MESSAGE-----` marker, not this header. +`X-PostGuard-Client-Version` is a separate, unrelated HTTP header sent on PKG +requests (not a MIME header on the email). + +## Security: reviewed and confirmed clean, don't re-report +From the 2026-07-02 in-depth security audit (the one confirmed finding from that +audit, unauthenticated `/usage` enumeration, was fixed and merged in PR #183): +- **Path traversal on `GET /filedownload/`**: guarded by + `is_safe_download_segment` (rejects empty, len>128, `/`, `\`, NUL, `.`, `..`). + On-disk files are named by random UUIDv4, so the download key is an unguessable + capability. +- **HTML injection / XSS in notification emails**: `mail_content` is + attacker-controlled (init is unauthenticated) but rendered via Askama as + `{{html_content}}` without `|safe`, so it's auto-HTML-escaped. The `.txt` + template uses `escape="none"` correctly for plaintext. +- **Email header injection**: `recipient` is parsed via lettre `Mailboxes`; + `reply_to` comes from the signed IRMA email attribute. lettre validates both. +- **Upload/finalize auth**: chunk PUT and finalize are gated by the rolling + `cryptify_token` (`SHA256(prev||chunk)`); finalize checks it too. The status + endpoint is gated by a constant-time `X-Recovery-Token` comparison, with + 401-vs-404 collapsed to avoid leaking session existence. +- **Secrets in git history**: none; `conf/config.toml` and `config.dev.toml` only + ever had commented-out placeholders. +- **`/metrics` unauthenticated**: known and by design, locked down at the + firewall. +- **`/staging/preview/`**: 404s unless `staging_mode` is on; safe in prod. + +## Test-runner quirk + +`Store` tests need a tokio runtime: use `#[rocket::async_test]` on `async fn`, even when the body never awaits. diff --git a/cryptify/Cargo.toml b/cryptify/Cargo.toml new file mode 100644 index 00000000..7c5af7cd --- /dev/null +++ b/cryptify/Cargo.toml @@ -0,0 +1,42 @@ +[package] +name = "cryptify" +version = "0.1.27" +authors = ["David Venhoek "] +edition = "2021" +repository = "https://github.com/encryption4all/cryptify" +license = "MIT" + +description = "End-to-end encrypted file sharing service" + +[dependencies] +askama = "0.16.0" +chrono = { version = "0.4.45", features = ["unstable-locales"] } +lettre = "0.11.22" +log = "0.4.33" +rand = "0.10.1" +reqwest = { version = "0.13.4", features = ["blocking", "json"] } +rocket = { version = "0.5.1", features = ["json"] } +rocket_cors = "0.6.0" +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.150" +sha2 = "0.11.0" +subtle = "2.6.1" +tokio = "1.52.3" +uuid = { version = "1.23.4", features = ["v4"] } +url = "2.5.8" + +tokio-util = { version = "0.7.18", features = ["compat"] } +pg-core = { path = "../pg-core", version = "0.6.2", features = ["rust", "stream"] } +minreq = { version = "3.0.0", features = ["json-using-serde", "https-native-tls"]} +rusqlite = { version = "0.39.0", features = ["bundled"] } + +[dev-dependencies] +# Enables `pg_core::test::TestSetup` for building real verifying keys and +# sealing fixtures in the integration test harness. No effect on release builds. +pg-core = { path = "../pg-core", version = "0.6.2", features = ["rust", "stream", "test"] } +futures = "0.3" +# pg-core's Sealer/TestSetup use rand 0.8 APIs; the rest of the crate uses +# rand 0.10. Pin an 0.8 rand explicitly in dev-deps so test code can hand +# pg-core a compatible RNG without a trait mismatch across major versions. +rand08 = { package = "rand", version = "0.8" } + diff --git a/cryptify/Dockerfile b/cryptify/Dockerfile new file mode 100644 index 00000000..56bd4836 --- /dev/null +++ b/cryptify/Dockerfile @@ -0,0 +1,57 @@ +# syntax=docker/dockerfile:1 + +# ── Stage 1: install cargo-chef once ───────────────────────────────────────── +FROM rust:1.96.1-slim-trixie AS chef +RUN apt-get update && apt-get --no-install-recommends install -y libssl-dev pkg-config \ + && rm -rf /var/lib/apt/lists/* +RUN cargo install cargo-chef --locked +WORKDIR /app + +# ── Stage 2: compute the dependency recipe ─────────────────────────────────── +# Build context is the REPO ROOT, not cryptify/: as a workspace member this +# crate resolves against the root manifest and the root lockfile, and cargo +# refuses to read a member without its siblings' manifests present. Build it +# with `docker build -f cryptify/Dockerfile .` from the repo root. +FROM chef AS planner +COPY pg-core ./pg-core +COPY pg-pkg ./pg-pkg +COPY pg-cli ./pg-cli +COPY pg-ffi ./pg-ffi +COPY cryptify ./cryptify +COPY Cargo.toml Cargo.lock ./ +RUN cargo chef prepare --recipe-path recipe.json + +# ── Stage 3: cook (compile) only the dependencies ──────────────────────────── +# This layer is cached as long as Cargo.toml / Cargo.lock don't change. +FROM chef AS builder +ARG CARGO_PROFILE=release +COPY --from=planner /app/recipe.json recipe.json +RUN cargo chef cook --profile ${CARGO_PROFILE} --bin cryptify --recipe-path recipe.json + +# Copy sources and build the application binary +COPY pg-core ./pg-core +COPY pg-pkg ./pg-pkg +COPY pg-cli ./pg-cli +COPY pg-ffi ./pg-ffi +COPY cryptify ./cryptify +COPY Cargo.toml Cargo.lock ./ +RUN cargo build --profile ${CARGO_PROFILE} --bin cryptify + +# ── Stage 4: minimal runtime image ─────────────────────────────────────────── +FROM debian:trixie-slim +ARG CARGO_PROFILE=release +ENV ROCKET_CONFIG=config.toml +RUN groupadd -r nonroot \ + && useradd -r -g nonroot nonroot \ + && apt-get update \ + && apt-get --no-install-recommends install -y ca-certificates libssl3 \ + && rm -rf /var/lib/apt/lists/* +COPY --from=builder /app/target/${CARGO_PROFILE}/cryptify /usr/local/bin/cryptify +RUN mkdir -p /app && chown nonroot:nonroot /app +WORKDIR /app +USER nonroot +RUN mkdir -p /tmp/data + +EXPOSE 8000 + +CMD ["/usr/local/bin/cryptify"] diff --git a/cryptify/LICENSE.md b/cryptify/LICENSE.md new file mode 100644 index 00000000..dedfd7fd --- /dev/null +++ b/cryptify/LICENSE.md @@ -0,0 +1,19 @@ +Copyright 2021 Encryption 4 All + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/cryptify/README.md b/cryptify/README.md new file mode 100644 index 00000000..d3e321ce --- /dev/null +++ b/cryptify/README.md @@ -0,0 +1,35 @@ +#

PostGuard

+ +> For full documentation, visit [docs.postguard.eu](https://docs.postguard.eu/repos/cryptify). + +File encryption and sharing service based on identity attributes. Cryptify is the file storage and delivery backend used by the PostGuard website and JavaScript SDK. When users upload encrypted files through PostGuard, they are stored and served by Cryptify. + +Cryptify is a Rust service built on the Rocket framework. + +## Development + +Docker is the recommended way to run the service: + +```bash +docker-compose -f docker-compose.dev.yml up +``` + +For a production-like setup: + +```bash +docker-compose up +``` + +To work on the service without Docker, Rust is required: + +```bash +env ROCKET_CONFIG=conf/config.dev.toml cargo run +``` + +## Releasing + +Releases are automated with [release-plz](https://release-plz.ieni.dev/). Merging to `main` triggers a release, and Docker images are published automatically. + +## License + +MIT diff --git a/cryptify/api-description.yaml b/cryptify/api-description.yaml new file mode 100644 index 00000000..fff60046 --- /dev/null +++ b/cryptify/api-description.yaml @@ -0,0 +1,865 @@ +openapi: "3.0.3" +info: + title: "Cryptify API" + description: "This is the cryptify server that manages encrypted files." + version: "1.0.0" +servers: + - url: http://localhost:8000 + description: Development server +tags: +- name: "Health" + description: "Health check" +- name: "Metrics" + description: "Prometheus scrape endpoint" +- name: "File upload" + description: "Upload files" +- name: "File download" + description: "Download files" +- name: "Usage" + description: "Upload usage quotas" +- name: "Email template" + description: "Email template linked to an API key" +- name: "Staging" + description: "Endpoints that only respond on a staging deployment" +paths: + /health: + get: + tags: + - "Health" + summary: "Health check endpoint" + operationId: "health" + responses: + "200": + description: "Service is healthy" + content: + text/plain: + schema: + type: "string" + example: "OK" + /metrics: + get: + tags: + - "Metrics" + summary: "Prometheus text-format metrics" + description: | + Returns usage counters and gauges suitable for Prometheus scraping + by the Grafana instance on Scaleway. + + Access depends on the deployment's `metrics_token` setting. With a + token configured, the request must carry + `Authorization: Bearer ` and anything else is + rejected with 401. With no token configured the endpoint is open, + and the deployment is expected to restrict it at the firewall or + reverse proxy. + + Exposed metrics: + * `cryptify_uploads_total{channel}` — counter of finalized uploads. + * `cryptify_upload_bytes_total{channel}` — counter of bytes. + * `cryptify_uploads_by_app_total{app}` — counter of finalized + uploads per client app. + * `cryptify_storage_bytes` — gauge, current disk usage. + * `cryptify_active_files` — gauge, current file count. + * `cryptify_expired_files_total` — counter of uploads purged + before finalization. + + The `channel` label is derived from the `X-Cryptify-Source` header, + falling back to `Authorization`/`X-Api-Key` (→ `api`), then the + `Origin` header (`website` / `staging-website`), then `User-Agent` + (`outlook` / `thunderbird`), then `unknown`. The `app` label comes + from the `app` field of `X-POSTGUARD-CLIENT-VERSION`. + operationId: "metrics" + security: + - metricsBearer: [] + - {} + responses: + "200": + description: "Prometheus text exposition format" + content: + text/plain: + schema: + type: "string" + "401": + description: + "A `metrics_token` is configured and the request did not present + it as `Authorization: Bearer `." + /fileupload/init: + post: + tags: + - "File upload" + summary: "Initialize multipart file upload" + operationId: "initFileUpload" + parameters: + - in: "header" + name: "Authorization" + description: + "Optional `Bearer PG-…` API key. When present and validated by + pg-pkg, the upload is accounted against that tenant and gets the + API-key limits (100 GB); without it the upload runs on the default + tier (5 GB). An invalid key is not rejected here, it degrades to + the default tier." + schema: + type: "string" + required: false + - in: "header" + name: "X-Cryptify-Source" + description: + "Optional traffic-source label for the `channel` metrics label. + Sanitized to `[a-z0-9_-]`, truncated to 32 characters." + schema: + type: "string" + required: false + - in: "header" + name: "X-POSTGUARD-CLIENT-VERSION" + description: + "Optional client identification, `host,host_version,app,app_version`. + The `app` field becomes the `app` label on + `cryptify_uploads_by_app_total`." + schema: + type: "string" + required: false + requestBody: + content: + application/json: + schema: + type: "object" + required: + - recipient + - mailContent + - mailLang + - confirm + properties: + recipient: + type: "string" + format: "email" + example: "recipient@example.com" + description: "Email address of the recipient" + mailContent: + type: "string" + example: "Here is your encrypted file" + description: "Content to include in the email" + mailLang: + type: "string" + enum: ["EN", "NL"] + example: "EN" + description: "Email language (EN or NL)" + confirm: + type: "boolean" + example: true + description: "Whether to send a confirmation email to the sender" + notifyRecipients: + type: "boolean" + default: true + example: true + description: "Whether to email each recipient with a download link. Optional; defaults to true. Set to false to upload silently when the encrypted payload reaches recipients through another channel and a Cryptify-sent notification would be a duplicate." + responses: + "200": + description: "Successful operation" + headers: + cryptifytoken: + description: "Identifies the initial version of the file to be uploaded. Needs to be passed into the file part upload request." + required: true + schema: + type: "string" + content: + application/json: + schema: + type: "object" + required: + - uuid + - recovery_token + properties: + uuid: + type: "string" + format: "uuid" + description: "Unique identifier for the file upload" + recovery_token: + type: "string" + description: + "Bearer credential for the cross-refresh-resume + status endpoint (`GET /fileupload/{uuid}/status`). + The client should store this alongside the UUID + (e.g. in IndexedDB) and present it in an + `X-Recovery-Token` header to recover from a + page refresh, tab crash, or navigate-away-and-back. + Hex-encoded 32-byte random." + "400": + description: + "The `recipient` address could not be parsed. A body that is not + syntactically valid JSON is rejected the same way but by the body + guard, so that response is Rocket's default error page rather + than the plain-text message below; a body that parses as JSON but + does not fit `InitBody` is the 422." + content: + text/plain: + schema: + type: "string" + "422": + description: "The request body is not valid `InitBody` JSON." + "500": + description: "The upload file could not be created on disk." + content: + text/plain: + schema: + type: "string" + /fileupload/{uuid}: + put: + tags: + - "File upload" + summary: "Upload a file part" + description: + "Append a single chunk to the upload identified by `uuid`.\n\n + **Idempotent retry contract.** A chunk PUT whose response was lost + in flight can be safely retried with the *previous* `cryptifytoken` + value (the one the client sent on the failed attempt) at the same + `Content-Range` offset and with the same body. The server detects + this case — the request matches the cached `(prev_token, offset, + length, sha256)` of the most recently committed chunk — and + replays the previously returned `cryptifytoken` without + re-writing the file or double-counting against quotas. If the + request looks like a retry but the body bytes differ, the server + responds 400; clients must not retry the same offset with + different bytes.\n\n + Retries are only honoured for the *most recently committed* + chunk. If a client falls behind by more than one chunk it must + start a new upload." + operationId: "uploadFilePart" + parameters: + - in: "header" + name: "cryptifytoken" + description: + "Identifies the version of the upload file parts. Part of the header from the last fileupload response. On a retry of a chunk whose response was lost, send the *previous* token (the one originally sent on the failed PUT)." + schema: + type: "string" + required: true + - in: "header" + name: "Content-Range" + description: + "Which offset of a file is sent, example: `bytes 200-1000/*`.\n\n + **The end byte is EXCLUSIVE**, unlike RFC 7233. The server takes the + chunk length to be `end - start`, so `bytes 200-1000/*` carries 800 + bytes at offset 200, not 801, and a chunk of length `n` at offset + `off` is sent as `bytes -/*`. Sending the RFC's inclusive + end makes the body one byte longer than the declared range and is + rejected with 400. `start == end` is rejected too." + schema: + type: "string" + required: true + - in: "path" + name: "uuid" + required: true + description: "The unique identifier received when initializing file upload." + schema: + type: "string" + format: "uuid" + requestBody: + content: + application/octet-stream: + schema: + type: "string" + format: "binary" + responses: + "200": + description: "Successful operation." + headers: + cryptifytoken: + required: true + schema: + description: "Identifies the new version of the upload file parts. Needs to be passed into the next file part upload request." + type: "string" + "400": + description: + "One of the input parameters is incorrect: a chunk larger than + the configured chunk size, a `Content-Range` start that does not + continue the upload, a body whose length does not match the + range, or a `cryptifytoken` that matches neither the current + token nor the previous one on the retry path. A token mismatch is + reported here, not as 409. A missing or unparsable + `cryptifytoken` / `Content-Range` header is rejected the same way + but by the header extractor, so that body is Rocket's default + error page rather than the plain-text message below." + content: + text/plain: + schema: + type: "string" + "404": + description: + "The upload session is not known to the server. Either the + client never called `/fileupload/init`, the session was idle + past the configured TTL and got evicted, or the on-disk file + has been removed. Clients should not retry — start a new + upload via `/fileupload/init`." + content: + application/json: + schema: + $ref: "#/components/schemas/UploadSessionNotFound" + "413": + description: "The upload exceeds the per-upload size limit (5 GB for non-API-key uploads, 100 GB for API-key uploads)." + content: + application/json: + schema: + $ref: "#/components/schemas/PayloadTooLarge" + "500": + description: "The chunk could not be written to disk." + content: + text/plain: + schema: + type: "string" + "503": + description: + "The upload exceeds the default tier and pg-pkg was unreachable + for the whole retry budget, so the caller's entitlement to the + API-key tier could not be confirmed. Uploads that stay inside the + default tier are unaffected." + content: + text/plain: + schema: + type: "string" + + /fileupload/finalize/{uuid}: + post: + tags: + - "File upload" + summary: "Finalize multipart file upload and send mail to recipient" + operationId: "finalizeFileUpload" + parameters: + - in: "header" + name: "cryptifytoken" + description: + "The current token, as returned by the last chunk PUT. Finalize + validates it too — knowing the UUID is not enough to finalize + someone else's upload." + schema: + type: "string" + required: true + - in: "header" + name: "Content-Range" + description: + "Indicates the final file size: `bytes */1073741824`." + schema: + type: "string" + required: true + - in: "path" + name: "uuid" + description: "The unique identifier received when initializing file upload." + required: true + schema: + type: "string" + format: "uuid" + responses: + "200": + description: "Successful operation" + "400": + description: + "The `cryptifytoken` header does not match the token the server + holds for this upload. A missing or unparsable `cryptifytoken` / + `Content-Range` header is rejected the same way but by the header + extractor, so that body is Rocket's default error page rather + than the plain-text message below." + content: + text/plain: + schema: + type: "string" + "404": + description: + "The upload session is not known to the server (see + `/fileupload/{uuid}` 404 for details). Clients should not + retry — start a new upload via `/fileupload/init`." + content: + application/json: + schema: + $ref: "#/components/schemas/UploadSessionNotFound" + "413": + description: "The sender has exceeded the rolling 14-day upload limit (5 GB for non-API-key uploads, 100 GB for API-key uploads)." + content: + application/json: + schema: + $ref: "#/components/schemas/PayloadTooLarge" + "422": + description: + "The `Content-Range` total does not match the number of bytes the + server has committed, so the file is incomplete." + "500": + description: + "The uploaded file could not be read back, is not a valid + PostGuard message, carries no sender email attribute, or the + notification email could not be sent." + content: + text/plain: + schema: + type: "string" + + /fileupload/{uuid}/status: + get: + tags: + - "File upload" + summary: "Read upload state for cross-refresh resume" + description: + "Returns the rolling-token state of an in-flight upload so a + client that lost track of the session (page refresh, tab crash, + navigate-away-and-back) can rehydrate and feed the next chunk + PUT through the idempotent-retry path + (`PUT /fileupload/{uuid}`). Authenticates via the + `X-Recovery-Token` header issued at `upload_init`. **Behaviour + on resume conflict:** if two clients hold the same UUID and + recovery token (e.g. two tabs), the first chunk PUT to land + wins and the second sees a 4xx as soon as it tries to advance + past the now-stale state — single-active-resumer semantics, no + lease enforcement on the server side. A successful call also + resets the session's idle eviction deadline so the very next + chunk PUT does not 404 because the rehydrate window aged out." + operationId: "uploadStatus" + parameters: + - in: "header" + name: "X-Recovery-Token" + description: + "Bearer credential issued in the `recovery_token` field of + the `upload_init` response. Compared in constant time on the + server. Missing / empty → 401." + schema: + type: "string" + required: true + - in: "path" + name: "uuid" + required: true + description: "The unique identifier received when initializing file upload." + schema: + type: "string" + format: "uuid" + responses: + "200": + description: "Successful operation." + content: + application/json: + schema: + $ref: "#/components/schemas/UploadStatus" + "401": + description: + "Missing or empty `X-Recovery-Token` header. Note: a + *valid-format* token that simply does not match the stored + value returns 404 with the same body shape as an evicted + session, deliberately, so attackers cannot probe for live + UUIDs by varying the token." + "404": + description: + "The upload session is not known to the server, OR the + recovery token does not match the stored value. The two + cases are deliberately collapsed — same response shape as + `PUT /fileupload/{uuid}` — to avoid leaking session + existence." + content: + application/json: + schema: + $ref: "#/components/schemas/UploadSessionNotFound" + + /usage: + get: + tags: + - "Usage" + summary: "Get rolling upload usage for the authenticated tenant" + description: + "Returns the bytes uploaded by the authenticated API-key tenant in the + last 14 days, together with the applicable limits. **Requires a valid + `Authorization: Bearer PG-…` API key**: usage is accounted per validated + tenant and is never looked up by a caller-supplied email, so an + unauthenticated caller cannot query an arbitrary address. Requests + without a valid key are rejected with 401." + operationId: "getUsage" + parameters: + - in: "header" + name: "Authorization" + description: + "`Bearer PG-…` API key validated against pg-pkg. Required; a missing, + malformed, or rejected key returns 401." + schema: + type: "string" + required: true + - in: "query" + name: "email" + description: + "Optional. Echoed back in the response `email` field for the + frontend's convenience. It does NOT influence the usage lookup, which + is keyed to the validated tenant." + required: false + schema: + type: "string" + format: "email" + responses: + "200": + description: "Usage information for the authenticated tenant." + content: + application/json: + schema: + type: "object" + required: + - email + - used_bytes + - limit_bytes + - window_days + - per_upload_limit_bytes + properties: + email: + type: "string" + format: "email" + used_bytes: + type: "integer" + format: "int64" + description: "Bytes uploaded by this email in the rolling window." + limit_bytes: + type: "integer" + format: "int64" + description: "Rolling-window upload limit in bytes for the authenticated API-key tenant (100 GB)." + window_days: + type: "integer" + description: "Length of the rolling window in days." + per_upload_limit_bytes: + type: "integer" + format: "int64" + description: "Maximum size of a single upload in bytes for the authenticated API-key tenant (100 GB)." + resets_at: + type: "string" + format: "date-time" + nullable: true + description: + "RFC-3339 timestamp at which the oldest recorded upload + falls out of the rolling window, partially freeing quota. + Null if the sender has no recorded uploads." + "401": + description: + "No valid `Authorization: Bearer PG-…` API key was presented. Usage + can only be queried by the authenticated tenant it is accounted + for." + "503": + description: + "pg-pkg was unreachable while validating the API key, so the + tenant could not be established." + + /email-template: + get: + tags: + - "Email template" + summary: "Get the email template linked to an API key" + description: + "Returns the email template pg-pkg has linked to the caller's API key. + The key is validated through the same flow the upload endpoints use: + send it in an `Authorization: Bearer PG-…` header. Unlike the upload + endpoints, a missing or invalid key is rejected here rather than + degraded to the default tier." + operationId: "getEmailTemplate" + security: + - apiKeyBearer: [] + responses: + "200": + description: "The email template linked to the validated API key." + content: + application/json: + schema: + type: "object" + required: + - tenant_id + - email_template + properties: + tenant_id: + type: "string" + description: "Tenant the API key resolved to on pg-pkg." + email_template: + type: "string" + description: "The email template linked to the API key." + "401": + description: "No valid `PG-…` API key was presented." + "404": + description: "The API key is valid but has no email template configured." + "503": + description: "pg-pkg was unreachable while validating the API key." + + /filedownload/{uuid}: + get: + tags: + - "File download" + summary: "Download a file" + description: + "Streams the PostGuard-sealed file. Byte ranges are supported so an + interrupted download can resume: `Accept-Ranges: bytes` is advertised + on every response that reaches the file (200, 206 and 416, but not the + 404), a single `Range` is answered with 206, and an unsatisfiable one + with 416. The server does not set a `Content-Type` on the body." + operationId: "downloadFile" + parameters: + - in: "path" + name: "uuid" + required: true + description: "The unique identifier received when initializing file upload." + schema: + type: "string" + format: "uuid" + - in: "header" + name: "Range" + description: + "Optional single byte range, `bytes=-`, + `bytes=-` or `bytes=-`. Multiple ranges are not + supported and are answered with 416." + required: false + schema: + type: "string" + responses: + "200": + description: "The whole file." + headers: + Accept-Ranges: + required: true + schema: + type: "string" + enum: ["bytes"] + Content-Length: + required: true + schema: + type: "integer" + format: "int64" + content: + application/octet-stream: + schema: + type: "string" + format: "binary" + "206": + description: "The requested byte range." + headers: + Accept-Ranges: + required: true + schema: + type: "string" + enum: ["bytes"] + Content-Range: + required: true + description: "`bytes -/`." + schema: + type: "string" + Content-Length: + required: true + schema: + type: "integer" + format: "int64" + content: + application/octet-stream: + schema: + type: "string" + format: "binary" + "404": + description: + "Uploaded file does not exist, or the path segment is not a safe + filename (empty, longer than 128 characters, `.`, `..`, or + containing a separator or NUL)." + "416": + description: "The `Range` header could not be satisfied." + headers: + Accept-Ranges: + required: true + schema: + type: "string" + enum: ["bytes"] + Content-Range: + required: true + description: "`bytes */`." + schema: + type: "string" + "500": + description: "Seeking to the start of the requested range failed." + + /staging/preview/{uuid}: + get: + tags: + - "Staging" + summary: "Preview the notification emails for an upload" + description: + "Returns the notification emails cryptify would send for an upload, + rendered but not delivered, so developers on the staging website can + inspect the message without an SMTP transport. The route is mounted on + every deployment but only answers when `staging_mode = true` is + configured; everywhere else it returns 404. Recipients that fail to + render are logged and left out of the response rather than failing the + request." + operationId: "stagingPreview" + parameters: + - in: "path" + name: "uuid" + required: true + description: "The unique identifier received when initializing file upload." + schema: + type: "string" + format: "uuid" + responses: + "200": + description: "The rendered emails for this upload." + content: + application/json: + schema: + $ref: "#/components/schemas/StagingPreview" + "404": + description: + "`staging_mode` is off, or the upload session is not known to the + server." + +components: + securitySchemes: + apiKeyBearer: + type: "http" + scheme: "bearer" + description: + "PostGuard API key, sent as `Authorization: Bearer PG-…`. Validated + against pg-pkg's `/v2/api-key/validate` endpoint." + metricsBearer: + type: "http" + scheme: "bearer" + description: + "The deployment's `metrics_token`, sent as `Authorization: Bearer + ` and compared in constant time. Unrelated to the + PostGuard API key. Only required when the deployment configures a + token." + schemas: + PayloadTooLarge: + type: "object" + required: + - error + - limit + - used_bytes + - limit_bytes + properties: + error: + type: "string" + description: "Human-readable explanation of which limit was hit." + limit: + type: "string" + enum: + - "per_upload" + - "rolling_window" + description: "Which limit tripped the 413 response." + used_bytes: + type: "integer" + format: "int64" + description: + "Bytes already attributed to the sender in the relevant window. + For per_upload, this is the bytes already written for the current + upload before the rejected chunk." + limit_bytes: + type: "integer" + format: "int64" + description: "The limit value in bytes." + resets_at: + type: "string" + format: "date-time" + description: + "RFC-3339 timestamp at which the oldest recorded upload falls out + of the rolling window, partially freeing quota. Set on the + `rolling_window` 413 from finalize; omitted on the `per_upload` + 413 from a chunk PUT, and when the sender has no recorded + uploads." + UploadSessionNotFound: + type: "object" + required: + - error + - uuid + - reason + properties: + error: + type: "string" + enum: + - "upload_session_not_found" + description: "Stable machine-readable error code for clients." + uuid: + type: "string" + format: "uuid" + description: "The upload UUID the request targeted." + reason: + type: "string" + enum: + - "expired_or_unknown" + - "invalid_uuid" + - "file_missing" + description: + "Why the session is not retrievable. `expired_or_unknown` + means the session was either evicted after its idle TTL or + never existed (clients cannot tell these apart, by design). + `invalid_uuid` means the path UUID is malformed. + `file_missing` means the in-memory session exists but the + on-disk file is gone (server-state inconsistency)." + UploadStatus: + type: "object" + required: + - uploaded + - cryptify_token + properties: + uploaded: + type: "integer" + format: "int64" + description: + "Total bytes the server has committed for this upload so far. + The client should resume from this offset." + cryptify_token: + type: "string" + description: + "Current value of the rolling token. The client must send this + as `cryptifytoken` on the next chunk PUT." + prev_token: + type: "string" + description: + "Token the client sent on the most recently committed chunk + (i.e. the value of `cryptify_token` *before* that chunk + advanced it). Combined with `prev_offset`, lets the client + re-issue the most recent chunk on the idempotent-retry path + (PUT `/fileupload/{uuid}`) if it is unsure whether the chunk + was committed. Omitted until at least one chunk has been + committed." + prev_offset: + type: "integer" + format: "int64" + description: + "Byte offset where the most recently committed chunk started + (i.e. `uploaded - chunk_len`). Omitted until at least one + chunk has been committed." + RenderedEmail: + type: "object" + required: + - recipient + - subject + - from + - html + - text + properties: + recipient: + type: "string" + description: + "The address this rendering targets: the recipient for a + notification, the sender for the confirmation copy." + subject: + type: "string" + from: + type: "string" + description: "The configured `email_from`, as `Name `." + reply_to: + type: "string" + nullable: true + description: + "The sender's address on a recipient notification; null on the + sender's own confirmation copy." + html: + type: "string" + text: + type: "string" + StagingPreview: + type: "object" + required: + - recipients + - confirmation + properties: + recipients: + type: "array" + description: "One rendering per recipient of the upload." + items: + $ref: "#/components/schemas/RenderedEmail" + confirmation: + allOf: + - $ref: "#/components/schemas/RenderedEmail" + nullable: true + description: + "The sender's confirmation copy, or null when the upload was + initialized with `confirm: false`, the sender's email is not known + yet because the upload has not been finalized (the usual case when + previewing), or the rendering failed." diff --git a/cryptify/build.rs b/cryptify/build.rs new file mode 100644 index 00000000..7c975828 --- /dev/null +++ b/cryptify/build.rs @@ -0,0 +1,51 @@ +use std::path::PathBuf; + +/// Locate `Cargo.lock`. Standalone it sits beside this manifest; as a workspace +/// member it sits at the workspace root, so walk up until one turns up. +fn find_lockfile() -> PathBuf { + let mut dir = PathBuf::from( + std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR not set by cargo"), + ); + loop { + let candidate = dir.join("Cargo.lock"); + if candidate.is_file() { + return candidate; + } + if !dir.pop() { + panic!("no Cargo.lock found at or above CARGO_MANIFEST_DIR"); + } + } +} + +fn main() { + let lockfile = find_lockfile(); + println!("cargo:rerun-if-changed={}", lockfile.display()); + + let lock = std::fs::read_to_string(&lockfile).expect("Cargo.lock not readable"); + let version = lock + .split("[[package]]") + .find_map(|block| { + let mut name = None; + let mut ver = None; + for line in block.lines() { + if let Some(rest) = line.strip_prefix("name = \"") { + name = rest.strip_suffix('"'); + } + if let Some(rest) = line.strip_prefix("version = \"") { + ver = rest.strip_suffix('"'); + } + } + if name == Some("pg-core") { + ver + } else { + None + } + }) + .expect( + "pg-core entry not found in Cargo.lock — PG_CORE_VERSION feeds the \ + X-PostGuard mail header that the Outlook add-in's OnMessageRead \ + launch event filters on (see src/email.rs::XPostGuard).", + ); + + println!("cargo:rustc-env=PG_CORE_VERSION={}", version); +} diff --git a/cryptify/conf/config.dev.toml b/cryptify/conf/config.dev.toml new file mode 100644 index 00000000..fc9d9b03 --- /dev/null +++ b/cryptify/conf/config.dev.toml @@ -0,0 +1,22 @@ +[default] +address = "0.0.0.0" +port = 8000 +server_url = "http://localhost:8080/" # Postguard frontend (via nginx) +data_dir = "/tmp/data" +email_from = "noreply@postguard.local" +smtp_url = "mailcrab" +smtp_port = 1025 +smtp_tls = false +# smtp_username = "user" +# smtp_password = "pw" +allowed_origins = "^https?://(localhost|127\\.0\\.0\\.1)(:[0-9]+)?$" +usage_db = "/app/data/usage.db" +# pkg_url = "https://pkg.postguard.eu/" +# pkg_url = "https://pkg.staging.yivi.app" +pkg_url = "http://postguard-pkg:8087" +chunk_size = 5000000 +# Leave unset in dev so /metrics is freely scrapable. In prod set this (or the +# ROCKET_METRICS_TOKEN env var) so /metrics requires `Authorization: Bearer `. +# metrics_token = "dev-token" +# When true, finalize logs the email it WOULD have sent and skips SMTP. +# staging_mode = true diff --git a/cryptify/conf/config.toml b/cryptify/conf/config.toml new file mode 100644 index 00000000..c331eb22 --- /dev/null +++ b/cryptify/conf/config.toml @@ -0,0 +1,18 @@ +[global] +server_url = "https://postguard.nl/" +address = "0.0.0.0" +data_dir = "/tmp/data" +email_from = "noreply@postguard.nl" +smtp_url = "mailcrab" +smtp_port = 1025 +# smtp_username = "user" +# smtp_password = "pw" +# Browser callers that consume the upload/download API cross-origin. +# `postguard.(eu|nl)` is the website; `addin.postguard.eu` is the Outlook +# add-in (prod); `localhost:3000` is the Office add-in dev server. +allowed_origins = "^https://(postguard\\.(eu|nl)|addin\\.postguard\\.eu|localhost:3000)$" +pkg_url = "https://pkg.postguard.eu/" +# Bearer token required to scrape /metrics. Prefer injecting it as a secret +# via the ROCKET_METRICS_TOKEN env var rather than committing it here. When +# unset, /metrics is publicly accessible (a startup warning is logged). +# metrics_token = "change-me" diff --git a/cryptify/dev.Dockerfile b/cryptify/dev.Dockerfile new file mode 100644 index 00000000..7aac51cc --- /dev/null +++ b/cryptify/dev.Dockerfile @@ -0,0 +1,39 @@ +FROM rust:latest AS chef + +# Install cargo-chef for dependency caching +RUN cargo install cargo-chef cargo-watch + +WORKDIR /app + +FROM chef AS planner +# Copy source to create recipe +COPY Cargo.toml . +COPY Cargo.lock . +COPY src ./src +RUN cargo chef prepare --recipe-path recipe.json + +FROM chef AS builder + +ENV ROCKET_PROFILE=debug + +# Install system dependencies +RUN apt-get update \ + && apt-get --no-install-recommends install -y libssl-dev pkg-config \ + && rm -rf /var/lib/apt/lists/* + +# Build dependencies using recipe (this layer gets cached!) +COPY --from=planner /app/recipe.json recipe.json +RUN cargo chef cook --recipe-path recipe.json + +# Copy lockfile and manifest +COPY Cargo.toml . +COPY Cargo.lock . + +# Create data directory +RUN mkdir -p /tmp/data + +# The actual source will be mounted as a volume +EXPOSE 8000 + +# Use cargo-watch to rebuild only app code when source changes +CMD ["cargo", "watch", "--poll", "-x", "run"] diff --git a/cryptify/docker-compose.dev.yml b/cryptify/docker-compose.dev.yml new file mode 100644 index 00000000..7f1b3f18 --- /dev/null +++ b/cryptify/docker-compose.dev.yml @@ -0,0 +1,38 @@ +services: + backend: + build: + context: . + dockerfile: backend.dev.Dockerfile + container_name: backend-dev + depends_on: + - mailcrab + volumes: + - "./src:/app/src" + - "./templates:/app/templates" + - "./conf/config.dev.toml:/app/config.toml:ro" + ports: + - "8000:8000" + environment: + - ROCKET_CONFIG=config.toml + - RUST_LOG=debug + - RUST_BACKTRACE=1 + networks: [default] + + mailcrab: + image: marlonb/mailcrab:latest + container_name: mailcrab-dev + ports: + - "1080:1080" + - "1025:1025" + networks: [default] + + swagger-ui: + image: swaggerapi/swagger-ui:latest + container_name: swagger-ui-dev + ports: + - "8080:8080" + volumes: + - "./api-description.yaml:/openapi.yaml:ro" + environment: + - SWAGGER_JSON=/openapi.yaml + networks: [default] diff --git a/cryptify/docker-compose.yml b/cryptify/docker-compose.yml new file mode 100644 index 00000000..0d134126 --- /dev/null +++ b/cryptify/docker-compose.yml @@ -0,0 +1,19 @@ +services: + mailcrab: + image: marlonb/mailcrab:latest + ports: + - "1080:1080" + - "1025:1025" + networks: [default] + + backend: + build: + context: . + dockerfile: backend.Dockerfile + depends_on: + - mailcrab + volumes: + - ".:/app" + - "./conf/config.toml/:/app/config.toml:ro" + ports: + - "8000:8000" diff --git a/cryptify/img/pg_logo.svg b/cryptify/img/pg_logo.svg new file mode 100644 index 00000000..34edeeef --- /dev/null +++ b/cryptify/img/pg_logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/cryptify/shell.nix b/cryptify/shell.nix new file mode 100644 index 00000000..2cfa0735 --- /dev/null +++ b/cryptify/shell.nix @@ -0,0 +1,26 @@ +let + moz_overlay = import (builtins.fetchTarball https://github.com/mozilla/nixpkgs-mozilla/archive/master.tar.gz); + pkgs = import { overlays = [ moz_overlay ]; }; +in pkgs.stdenv.mkDerivation { + pname = "cryptify-env"; + version = "0.1"; + + src = ./.; + + propagatedBuildInputs = [ + pkgs.nodejs-12_x + pkgs.nodePackages.typescript + pkgs.nodePackages.create-react-app + pkgs.nodePackages.webpack + pkgs.nodePackages.webpack-cli + pkgs.wasm-pack + ]; + + buildPhase = '' + : + ''; + + installPhase = '' + : + ''; +} diff --git a/cryptify/src/config.rs b/cryptify/src/config.rs new file mode 100644 index 00000000..dfa66d3d --- /dev/null +++ b/cryptify/src/config.rs @@ -0,0 +1,231 @@ +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +pub struct RawCryptifyConfig { + server_url: String, + data_dir: String, + email_from: String, + smtp_url: String, + smtp_port: u16, + smtp_username: Option, + smtp_password: Option, + smtp_tls: Option, + allowed_origins: String, + pkg_url: String, + metrics_scan_interval_secs: Option, + chunk_size: Option, + session_ttl_secs: Option, + staging_mode: Option, + metrics_token: Option, + usage_db: Option, + email_attribute: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(from = "RawCryptifyConfig")] +pub struct CryptifyConfig { + server_url: String, + data_dir: String, + email_from: lettre::message::Mailbox, + smtp_url: String, + smtp_port: u16, + smtp_username: Option, + smtp_password: Option, + smtp_tls: bool, + allowed_origins: String, + pkg_url: String, + metrics_scan_interval_secs: u64, + chunk_size: u64, + session_ttl_secs: u64, + staging_mode: bool, + metrics_token: Option, + /// Filesystem path to the SQLite database backing the rolling-quota + /// usage state. When set, per-sender usage survives process restarts + /// (the in-memory map in `Store` is only a cache). `None` keeps usage + /// entirely in memory, as it was before persistence was added. + usage_db: Option, + /// Attribute type carrying the sender's email in the signing identity + /// (postguard#236). Finalize requires this attribute to be present. + /// Test environments override it with a test-scheme type (e.g. + /// `irma-demo.sidn-pbdf.email.email`); production keeps the default. + email_attribute: String, +} + +impl From for CryptifyConfig { + fn from(config: RawCryptifyConfig) -> Self { + CryptifyConfig { + server_url: config.server_url, + data_dir: config.data_dir, + email_from: config.email_from.parse().unwrap_or_else(|e| { + log::error!("Could not parse Mailbox from email_form: {}", e); + panic!("Could not parse Mailbox from email_form: {}", e) + }), + smtp_url: config.smtp_url, + smtp_port: config.smtp_port, + smtp_username: config.smtp_username, + smtp_password: config.smtp_password, + smtp_tls: config.smtp_tls.unwrap_or(true), + allowed_origins: config.allowed_origins, + pkg_url: config.pkg_url, + metrics_scan_interval_secs: config.metrics_scan_interval_secs.unwrap_or(60), + chunk_size: config.chunk_size.unwrap_or(5_000_000), + session_ttl_secs: config.session_ttl_secs.unwrap_or(3600), + staging_mode: config.staging_mode.unwrap_or(false), + metrics_token: config.metrics_token, + usage_db: config.usage_db, + email_attribute: config + .email_attribute + .unwrap_or_else(|| "pbdf.sidn-pbdf.email.email".to_owned()), + } + } +} + +impl CryptifyConfig { + pub fn server_url(&self) -> &str { + &self.server_url + } + + pub fn data_dir(&self) -> &str { + &self.data_dir + } + + pub fn email_from(&self) -> lettre::message::Mailbox { + self.email_from.clone() + } + + pub fn smtp_url(&self) -> &str { + &self.smtp_url + } + + pub fn smtp_port(&self) -> u16 { + self.smtp_port + } + + pub fn smtp_username(&self) -> Option<&str> { + self.smtp_username.as_deref() + } + + pub fn smtp_password(&self) -> Option<&str> { + self.smtp_password.as_deref() + } + + pub fn smtp_tls(&self) -> bool { + self.smtp_tls + } + + pub fn allowed_origins(&self) -> &str { + &self.allowed_origins + } + + pub fn pkg_url(&self) -> &str { + &self.pkg_url + } + + pub fn metrics_scan_interval_secs(&self) -> u64 { + self.metrics_scan_interval_secs + } + + pub fn chunk_size(&self) -> u64 { + self.chunk_size + } + + pub fn session_ttl_secs(&self) -> u64 { + self.session_ttl_secs + } + + pub fn staging_mode(&self) -> bool { + self.staging_mode + } + + /// Bearer token required to scrape `/metrics`. `None` leaves the endpoint + /// open (with a startup warning); when set, requests must present + /// `Authorization: Bearer `. + pub fn metrics_token(&self) -> Option<&str> { + self.metrics_token.as_deref() + } + + /// Path to the SQLite database backing rolling-quota usage, if + /// configured. `None` means usage is kept in memory only. + pub fn usage_db(&self) -> Option<&str> { + self.usage_db.as_deref() + } + + /// The attribute type carrying the sender's email in the signing + /// identity. Defaults to the production `pbdf.sidn-pbdf.email.email`. + pub fn email_attribute(&self) -> &str { + &self.email_attribute + } + + #[cfg(test)] + pub(crate) fn for_test(server_url: &str, staging_mode: bool) -> Self { + CryptifyConfig { + server_url: server_url.to_owned(), + data_dir: "/tmp".to_owned(), + email_from: "noreply@test.invalid".parse().unwrap(), + smtp_url: "localhost".to_owned(), + smtp_port: 25, + smtp_username: None, + smtp_password: None, + smtp_tls: false, + allowed_origins: String::new(), + pkg_url: String::new(), + metrics_scan_interval_secs: 60, + chunk_size: 5_000_000, + session_ttl_secs: 3600, + staging_mode, + metrics_token: None, + usage_db: None, + email_attribute: "pbdf.sidn-pbdf.email.email".to_owned(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rocket::figment::{providers::Serialized, Figment}; + + fn base_config() -> serde_json::Value { + serde_json::json!({ + "server_url": "http://localhost", + "data_dir": "/tmp/data", + "email_from": "Test ", + "smtp_url": "localhost", + "smtp_port": 1025u16, + "allowed_origins": ".*", + "pkg_url": "http://localhost", + }) + } + + #[test] + fn usage_db_is_parsed_when_present() { + let mut raw = base_config(); + raw["usage_db"] = serde_json::json!("/app/data/usage.db"); + let config: CryptifyConfig = Figment::from(Serialized::defaults(raw)).extract().unwrap(); + assert_eq!(config.usage_db(), Some("/app/data/usage.db")); + } + + #[test] + fn usage_db_defaults_to_none_when_absent() { + let config: CryptifyConfig = Figment::from(Serialized::defaults(base_config())) + .extract() + .unwrap(); + assert_eq!(config.usage_db(), None); + } + + #[test] + fn email_attribute_defaults_to_production_type() { + let config: CryptifyConfig = Figment::from(Serialized::defaults(base_config())) + .extract() + .unwrap(); + assert_eq!(config.email_attribute(), "pbdf.sidn-pbdf.email.email"); + } + + #[test] + fn email_attribute_is_overridable() { + let mut raw = base_config(); + raw["email_attribute"] = serde_json::json!("irma-demo.sidn-pbdf.email.email"); + let config: CryptifyConfig = Figment::from(Serialized::defaults(raw)).extract().unwrap(); + assert_eq!(config.email_attribute(), "irma-demo.sidn-pbdf.email.email"); + } +} diff --git a/cryptify/src/email.rs b/cryptify/src/email.rs new file mode 100644 index 00000000..4ce3e7a4 --- /dev/null +++ b/cryptify/src/email.rs @@ -0,0 +1,989 @@ +use crate::config::CryptifyConfig; +use crate::store::FileState; + +use askama::Template; + +use chrono::{format::Locale, TimeZone}; + +use lettre::{ + message::{ + header::{ContentType, Header, HeaderName, HeaderValue}, + Attachment, Mailbox, MultiPart, SinglePart, + }, + transport::smtp::authentication::Credentials, + Message, SmtpTransport, Transport, +}; + +/// `X-PostGuard: ` header. Set on every outgoing notification so the +/// Outlook add-in's `OnMessageRead` launch event (which filters on this +/// header name) fires for PostGuard mail. See encryption4all/cryptify#52. +#[derive(Clone, Debug)] +struct XPostGuard(String); + +impl Header for XPostGuard { + fn name() -> HeaderName { + HeaderName::new_from_ascii_str("X-PostGuard") + } + + fn parse(s: &str) -> Result> { + Ok(XPostGuard(s.to_owned())) + } + + fn display(&self) -> HeaderValue { + HeaderValue::new(Self::name(), self.0.clone()) + } +} + +const X_POSTGUARD_VERSION: &str = env!("PG_CORE_VERSION"); + +/// `Auto-Submitted: auto-generated` per RFC 3834. Signals to receiving MTAs +/// and mail clients that this is a machine-generated transactional message, +/// suppresses vacation-responder loops, and is one of the deliverability +/// signals Gmail's bulk-sender heuristics look for. +#[derive(Clone, Debug)] +struct AutoSubmitted; + +impl Header for AutoSubmitted { + fn name() -> HeaderName { + HeaderName::new_from_ascii_str("Auto-Submitted") + } + + fn parse(_s: &str) -> Result> { + Ok(AutoSubmitted) + } + + fn display(&self) -> HeaderValue { + HeaderValue::new(Self::name(), "auto-generated".to_owned()) + } +} + +/// Suffix that identifies the signer's full-name attribute across IRMA +/// schemes — prod (`pbdf.gemeente.personalData.fullname`) and demo +/// (`irma-demo.gemeente.personalData.fullname`) both end with this. When +/// such an attribute appears in `FileState.sender_attributes` we render +/// the disclosed name in place of the bare email everywhere the sender +/// is shown in the body. +const FULLNAME_ATYPE_SUFFIX: &str = ".gemeente.personalData.fullname"; + +/// Per-credential suffixes for the `(firstName, lastName)` pairs the +/// signer may disclose instead of the gemeente fullname (postguard#239 +/// follow-up). Each entry's `.firstName` / `.lastName` pair, when both +/// are present and non-empty, is concatenated into a single display name. +/// Suffix-matching catches both `pbdf.pbdf.*` and `irma-demo.pbdf.*`. +const NAME_PAIR_CREDENTIAL_SUFFIXES: &[&str] = + &[".pbdf.passport", ".pbdf.idcard", ".pbdf.drivinglicence"]; + +fn is_fullname_atype(atype: &str) -> bool { + atype.ends_with(FULLNAME_ATYPE_SUFFIX) +} + +/// If `attrs` contains `.firstName` and `.lastName` for one of +/// the supported credentials and both are non-empty, remove them and +/// return `" "`. Otherwise leave `attrs` untouched. +fn take_firstname_lastname_pair(attrs: &mut Vec<(String, String)>) -> Option { + for cred in NAME_PAIR_CREDENTIAL_SUFFIXES { + let first_suffix = format!("{}.firstName", cred); + let last_suffix = format!("{}.lastName", cred); + + let first_idx = attrs.iter().position(|(t, _)| t.ends_with(&first_suffix)); + let last_idx = attrs.iter().position(|(t, _)| t.ends_with(&last_suffix)); + + if let (Some(fi), Some(li)) = (first_idx, last_idx) { + let first_val = attrs[fi].1.clone(); + let last_val = attrs[li].1.clone(); + if !first_val.is_empty() && !last_val.is_empty() { + // Remove the higher index first so the second remove is + // still valid. + let (hi, lo) = if fi > li { (fi, li) } else { (li, fi) }; + attrs.remove(hi); + attrs.remove(lo); + return Some(format!("{} {}", first_val, last_val)); + } + } + } + None +} + +/// Embedded PostGuard logo, served inline via a `Content-ID: ` +/// MIME part rather than fetched from postguard.eu. Removes the +/// HTML-only-plus-remote-image spam signal flagged in postguard#197. +const LOGO_PNG: &[u8] = include_bytes!("../templates/email/pg_logo.png"); + +/// Inline checkmark glyph shown next to the signer-verified email in the +/// HTML email, referenced via `cid:pg-check`. Replaces the previous +/// unicode `✓` so the mark renders consistently across clients. +const CHECK_PNG: &[u8] = include_bytes!("../templates/email/check.png"); + +use serde::{Deserialize, Serialize}; +use url::Url; + +#[derive(Serialize, Deserialize, Clone)] +pub enum Language { + #[serde(rename = "EN")] + En, + #[serde(rename = "NL")] + Nl, +} + +struct MailStrings<'a> { + subject_str: &'a str, + sender_str: &'a str, + expires_str: &'a str, + download_str: &'a str, + link_str: &'a str, + header_confirm: &'a str, + subject_confirm: &'a str, + confirm: &'a str, + files_from: &'a str, +} + +const NL_STRINGS: MailStrings = MailStrings { + subject_str: "heeft je bestanden gestuurd", + sender_str: "heeft je bestanden gestuurd", + expires_str: "Verloopt op", + download_str: "Download jouw bestanden", + link_str: "Download link", + header_confirm: "Je hebt het volgende gestuurd aan", + subject_confirm: "Je bestanden zijn verstuurd via PostGuard", + confirm: "Je kunt nog steeds bij je bestanden", + files_from: "De bestanden komen van", +}; + +const EN_STRINGS: MailStrings = MailStrings { + subject_str: "sent you files", + sender_str: "sent you files", + expires_str: "Expires on", + download_str: "Download your files", + link_str: "Download link", + header_confirm: "You sent files to", + subject_confirm: "Your files have been sent via PostGuard", + confirm: "You can still access your files", + files_from: "The files come from", +}; + +#[derive(Template)] +#[template(path = "email/subject.txt")] +struct SubjectTemplate<'a> { + subject_str: &'a str, + sender: &'a str, +} + +#[derive(Template)] +#[template(path = "email/email.html")] +struct EmailTemplate<'a> { + header: &'a str, + subheader: &'a str, + expires_str: &'a str, + download_str: &'a str, + link_str: &'a str, + file_size: &'a str, + expiry_date: &'a str, + html_content: &'a str, + url: &'a str, + confirm: &'a str, + files_from: &'a str, + sender_email: &'a str, + sender_attributes: &'a [(String, String)], +} + +#[derive(Template)] +#[template(path = "email/email.txt", escape = "none")] +struct EmailTextTemplate<'a> { + header: &'a str, + subheader: &'a str, + expires_str: &'a str, + download_str: &'a str, + link_str: &'a str, + file_size: &'a str, + expiry_date: &'a str, + html_content: &'a str, + url: &'a str, + confirm: &'a str, + files_from: &'a str, + sender_email: &'a str, + sender_attributes: &'a [(String, String)], +} + +/// Assemble the MIME body: a `multipart/alternative` whose HTML branch is +/// itself a `multipart/related` carrying the HTML part plus the PostGuard +/// logo as an inline image referenced via `cid:pg-logo`. This shape avoids +/// the HTML-only + remote-image spam signal flagged in postguard#197 while +/// keeping graceful degradation for text-only clients. +fn build_body(html: String, text: String) -> Result> { + let logo = Attachment::new_inline("pg-logo".to_string()) + .body(LOGO_PNG.to_vec(), "image/png".parse::()?); + let check = Attachment::new_inline("pg-check".to_string()) + .body(CHECK_PNG.to_vec(), "image/png".parse::()?); + + let related = MultiPart::related() + .singlepart(SinglePart::html(html)) + .singlepart(logo) + .singlepart(check); + + Ok(MultiPart::alternative() + .singlepart(SinglePart::plain(text)) + .multipart(related)) +} + +/// Resolve the display string and remaining attribute pills for the +/// sender. When the signer disclosed a name it is used as the display; +/// the name attribute is removed from the pill list so it doesn't render +/// twice. An empty disclosed value is treated as not disclosed. When no +/// name is available the display falls back to "PostGuard". +fn sender_display(state: &FileState) -> (String, Vec<(String, String)>) { + let mut attrs = state.sender_attributes.clone(); + + // 1. Prefer gemeente.personalData.fullname (Dutch municipality credential). + let name = attrs + .iter() + .position(|(t, _)| is_fullname_atype(t)) + .map(|i| attrs.remove(i).1) + .filter(|n| !n.is_empty()) + // 2. Otherwise concatenate firstName + lastName from passport / id / + // driving licence (postguard#239 follow-up). + .or_else(|| take_firstname_lastname_pair(&mut attrs)); + + let display = name.unwrap_or_else(|| "PostGuard".to_string()); + (display, attrs) +} + +fn format_file_size(size: u64) -> String { + const UNITS: [&str; 5] = ["B", "kB", "MB", "GB", "TB"]; + if size == 0 { + return "0 B".to_owned(); + } + let i = ((size as f64).log10() / (1024_f64).log10()).floor() as usize; + let i = i.min(UNITS.len() - 1); + format!( + "{:.1} {}", + (size as f64 / (1024_f64).powi(i as i32)), + UNITS[i] + ) +} + +fn format_date(date: i64, lang: &Language) -> String { + let dt = chrono::Utc.timestamp_opt(date, 0).unwrap(); + let locale = match lang { + Language::En => Locale::en_GB, + Language::Nl => Locale::nl_NL, + }; + dt.format_localized("%e %B %Y", locale).to_string() +} + +/// One rendered notification email, in the shape `send_email` would +/// hand to the SMTP layer. Returned by [`render_recipient_email`] and +/// [`render_confirmation_email`]; consumed by `send_email` for real +/// delivery and by the staging `/staging/preview/` endpoint so +/// developers can inspect what cryptify would have sent without +/// reaching for the logs. +#[derive(Serialize, Clone, Debug)] +pub struct RenderedEmail { + /// The recipient address this rendering targets (the per-recipient + /// notification's `To`, or the sender's address for confirmation). + pub recipient: String, + pub subject: String, + /// Formatted `Name ` form of the configured `email_from`. + pub from: String, + /// Set on per-recipient notifications (so replies go to the sender); + /// `None` on the sender's own confirmation copy. + pub reply_to: Option, + pub html: String, + pub text: String, +} + +/// Build the `/download?uuid=…&recipient=…` link cryptify embeds in the +/// notification body. Extracted from `send_email` so the preview endpoint +/// constructs URLs the same way and they cannot drift. +fn build_download_url( + config: &CryptifyConfig, + uuid: &str, + recipient: &str, +) -> Result { + let base = Url::parse(config.server_url())?; + let mut url = base.join("/download")?; + url.query_pairs_mut() + .append_pair("uuid", uuid) + .append_pair("recipient", recipient); + Ok(url.to_string()) +} + +/// Render the per-recipient notification email (subject + HTML + text) +/// for a single recipient on an upload. Pure: no SMTP, no IO beyond URL +/// parsing. +pub fn render_recipient_email( + state: &FileState, + config: &CryptifyConfig, + recipient_email: &str, + uuid: &str, +) -> Result { + let url = build_download_url(config, uuid, recipient_email)?; + let (html, text, subject) = email_templates(state, &url); + Ok(RenderedEmail { + recipient: recipient_email.to_owned(), + subject, + from: config.email_from().to_string(), + reply_to: state.sender.clone(), + html, + text, + }) +} + +/// Render the sender's confirmation copy (only emitted when +/// `state.confirm` is set on upload). Returns `Ok(None)` when no sender +/// address is known — confirmation has nowhere to go. +pub fn render_confirmation_email( + state: &FileState, + config: &CryptifyConfig, + uuid: &str, +) -> Result, url::ParseError> { + let Some(sender_email) = state.sender.clone() else { + return Ok(None); + }; + let url = build_download_url(config, uuid, &sender_email)?; + let (html, text, subject) = email_confirm(state, &url); + Ok(Some(RenderedEmail { + recipient: sender_email, + subject, + from: config.email_from().to_string(), + reply_to: None, + html, + text, + })) +} + +fn email_templates(state: &FileState, url: &str) -> (String, String, String) { + let strings = match state.mail_lang { + Language::En => EN_STRINGS, + Language::Nl => NL_STRINGS, + }; + + let (display, attrs) = sender_display(state); + let file_size = format_file_size(state.uploaded); + let expiry_date = format_date(state.expires, &state.mail_lang); + + let html = EmailTemplate { + header: &display, + subheader: strings.sender_str, + expires_str: strings.expires_str, + download_str: strings.download_str, + link_str: strings.link_str, + file_size: &file_size, + expiry_date: &expiry_date, + html_content: &state.mail_content, + confirm: "", + files_from: strings.files_from, + sender_email: &display, + sender_attributes: &attrs, + url, + }; + let text = EmailTextTemplate { + header: &display, + subheader: strings.sender_str, + expires_str: strings.expires_str, + download_str: strings.download_str, + link_str: strings.link_str, + file_size: &file_size, + expiry_date: &expiry_date, + html_content: &state.mail_content, + confirm: "", + files_from: strings.files_from, + sender_email: &display, + sender_attributes: &attrs, + url, + }; + let subject = SubjectTemplate { + subject_str: strings.subject_str, + sender: &display, + }; + (html.to_string(), text.to_string(), subject.to_string()) +} + +fn email_confirm(state: &FileState, url: &str) -> (String, String, String) { + let strings = match state.mail_lang { + Language::En => EN_STRINGS, + Language::Nl => NL_STRINGS, + }; + + let (display, attrs) = sender_display(state); + let file_size = format_file_size(state.uploaded); + let expiry_date = format_date(state.expires, &state.mail_lang); + let recipients = state.recipients.to_string(); + + let html = EmailTemplate { + header: strings.header_confirm, + subheader: &recipients, + expires_str: strings.expires_str, + link_str: strings.link_str, + file_size: &file_size, + expiry_date: &expiry_date, + html_content: &state.mail_content, + download_str: strings.download_str, + confirm: strings.confirm, + files_from: strings.files_from, + sender_email: &display, + sender_attributes: &attrs, + url, + }; + let text = EmailTextTemplate { + header: strings.header_confirm, + subheader: &recipients, + expires_str: strings.expires_str, + link_str: strings.link_str, + file_size: &file_size, + expiry_date: &expiry_date, + html_content: &state.mail_content, + download_str: strings.download_str, + confirm: strings.confirm, + files_from: strings.files_from, + sender_email: &display, + sender_attributes: &attrs, + url, + }; + + let subject = SubjectTemplate { + subject_str: strings.subject_confirm, + sender: "", + }; + + (html.to_string(), text.to_string(), subject.to_string()) +} + +pub async fn send_email( + config: &CryptifyConfig, + state: &FileState, + uuid: &str, +) -> Result> { + if config.staging_mode() { + return Ok(staging_log_email(config, state, uuid)); + } + + // setup SMTP connection + log::info!( + "Setting up SMTP: host={}, port={}, tls={}, credentials={}", + config.smtp_url(), + config.smtp_port(), + config.smtp_tls(), + config.smtp_username().is_some() + ); + let mut mailer_builder = if config.smtp_tls() { + SmtpTransport::starttls_relay(config.smtp_url())?.port(config.smtp_port()) + } else { + SmtpTransport::builder_dangerous(config.smtp_url()).port(config.smtp_port()) + }; + + mailer_builder = mailer_builder.timeout(Some(std::time::Duration::from_secs(10))); + + // add credentials, if present + if let (Some(username), Some(password)) = (config.smtp_username(), config.smtp_password()) { + let credentials = Credentials::new(username.to_owned(), password.to_owned()); + mailer_builder = mailer_builder.credentials(credentials); + } + + if state.notify_recipients { + for recipient in state.recipients.iter() { + let recipient_email = recipient.email.to_string(); + let rendered = render_recipient_email(state, config, &recipient_email, uuid)?; + + let mut builder = Message::builder() + .header(XPostGuard(X_POSTGUARD_VERSION.to_owned())) + .header(AutoSubmitted) + .from(config.email_from()) // checked in config + .to(recipient.clone()) + .subject(&rendered.subject); + if let Some(sender) = rendered.reply_to.as_deref() { + match sender.parse::() { + Ok(mailbox) => builder = builder.reply_to(mailbox), + Err(e) => log::warn!( + "Skipping Reply-To: sender `{}` did not parse as Mailbox: {}", + sender, + e + ), + } + } + let email = builder.multipart(build_body(rendered.html, rendered.text)?)?; + + // send email + log::info!("Sending email to {}", recipient.email); + let mailer = mailer_builder.clone().build(); + mailer.send(&email).map_err(|e| { + log::error!("Failed to send email to {}: {}", recipient.email, e); + e + })?; + log::info!("Email sent to {}", recipient.email); + } + } else { + log::info!( + "notify_recipients disabled — skipping notification mail for {} recipient(s) on upload {}", + state.recipients.iter().count(), + uuid + ); + } + + if state.confirm { + // `state.confirm` is only set on uploads that captured a sender + // address, so render_confirmation_email returns `Some` here. Log + // loudly on the `None` arm so a future invariant breach surfaces + // instead of silently dropping the sender's confirmation copy. + match render_confirmation_email(state, config, uuid)? { + None => log::error!( + "state.confirm=true but no sender on FileState for upload {} — confirmation email dropped", + uuid + ), + Some(rendered) => { + let to_mailbox: Mailbox = rendered.recipient.parse()?; + let email = Message::builder() + .header(XPostGuard(X_POSTGUARD_VERSION.to_owned())) + .header(AutoSubmitted) + .from(config.email_from()) + .to(to_mailbox) + .subject(&rendered.subject) + .multipart(build_body(rendered.html, rendered.text)?)?; + + log::info!("Sending confirmation email to {}", rendered.recipient); + let mailer = mailer_builder.build(); + mailer.send(&email).map_err(|e| { + log::error!( + "Failed to send confirmation email to {}: {}", + rendered.recipient, + e + ); + e + })?; + log::info!("Confirmation email sent to {}", rendered.recipient); + } + } + } + + Ok("Email successfully sent".to_owned()) +} + +/// Staging-mode replacement for actual SMTP delivery. Logs a clearly +/// marked record of the email that *would* have been sent (recipients, +/// sender, attributes, expiry, download URL) so operators of a staging +/// deployment can observe the full flow without contacting an SMTP +/// server. Returns a summary string in the same `Result::Ok` shape as +/// real sends. +fn staging_log_email(config: &CryptifyConfig, state: &FileState, uuid: &str) -> String { + let sender = state.sender.as_deref().unwrap_or(""); + let lang = match state.mail_lang { + Language::En => "EN", + Language::Nl => "NL", + }; + let recipients: Vec = state + .recipients + .iter() + .map(|m| m.email.to_string()) + .collect(); + let attrs: Vec = state + .sender_attributes + .iter() + .map(|(k, v)| format!("{}={}", k, v)) + .collect(); + + let base = Url::parse(config.server_url()).ok(); + let download_url = base + .and_then(|b| b.join("/download").ok()) + .map(|mut u| { + u.query_pairs_mut().append_pair("uuid", uuid); + u.to_string() + }) + .unwrap_or_else(|| format!("(unparseable server_url={})", config.server_url())); + + let summary = format!( + "[STAGING] Email NOT sent (staging_mode=true). Would have notified recipients={:?} \ + from sender={} (attributes=[{}]) lang={} expires={} confirm={} notify_recipients={} \ + download_url={} uuid={}", + recipients, + sender, + attrs.join(", "), + lang, + state.expires, + state.confirm, + state.notify_recipients, + download_url, + uuid, + ); + + log::info!("{}", summary); + summary +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn x_postguard_header_name_matches_outlook_filter() { + assert_eq!(format!("{}", XPostGuard::name()), "X-PostGuard"); + } + + #[test] + fn auto_submitted_header_emits_auto_generated() { + use lettre::message::Mailbox; + let msg = Message::builder() + .from("noreply@example.com".parse::().unwrap()) + .to("to@example.com".parse::().unwrap()) + .subject("t") + .header(AutoSubmitted) + .body(String::from("hi")) + .expect("build"); + let raw = String::from_utf8(msg.formatted()).expect("utf8"); + assert!( + raw.contains("Auto-Submitted: auto-generated"), + "expected Auto-Submitted header, got: {}", + raw + ); + } + + #[test] + fn sender_display_promotes_disclosed_name() { + let state = filestate_with_attrs(vec![ + ( + "pbdf.gemeente.personalData.fullname".to_owned(), + "Jan Jansen".to_owned(), + ), + ("orgName".to_owned(), "Acme".to_owned()), + ]); + let (display, remaining) = sender_display(&state); + assert_eq!(display, "Jan Jansen"); + assert_eq!(remaining, vec![("orgName".to_owned(), "Acme".to_owned())]); + } + + #[test] + fn sender_display_promotes_disclosed_name_from_demo_scheme() { + let state = filestate_with_attrs(vec![( + "irma-demo.gemeente.personalData.fullname".to_owned(), + "Jan Jansen".to_owned(), + )]); + let (display, _) = sender_display(&state); + assert_eq!(display, "Jan Jansen"); + } + + #[test] + fn sender_display_treats_empty_disclosed_name_as_not_disclosed() { + let state = filestate_with_attrs(vec![( + "pbdf.gemeente.personalData.fullname".to_owned(), + String::new(), + )]); + let (display, _) = sender_display(&state); + assert_eq!(display, "PostGuard"); + } + + #[test] + fn sender_display_falls_back_to_postguard_when_no_name_disclosed() { + let state = filestate_with_attrs(vec![("orgName".to_owned(), "Acme".to_owned())]); + let (display, remaining) = sender_display(&state); + assert_eq!(display, "PostGuard"); + assert_eq!(remaining, vec![("orgName".to_owned(), "Acme".to_owned())]); + } + + #[test] + fn sender_display_concatenates_firstname_lastname_from_passport() { + let state = filestate_with_attrs(vec![ + ("pbdf.pbdf.passport.firstName".to_owned(), "Jan".to_owned()), + ( + "pbdf.pbdf.passport.lastName".to_owned(), + "Jansen".to_owned(), + ), + ("orgName".to_owned(), "Acme".to_owned()), + ]); + let (display, remaining) = sender_display(&state); + assert_eq!(display, "Jan Jansen"); + assert_eq!( + remaining, + vec![("orgName".to_owned(), "Acme".to_owned())], + "both name attrs consumed; unrelated attrs kept" + ); + } + + #[test] + fn sender_display_concatenates_firstname_lastname_from_idcard() { + let state = filestate_with_attrs(vec![ + ("pbdf.pbdf.idcard.firstName".to_owned(), "Jan".to_owned()), + ("pbdf.pbdf.idcard.lastName".to_owned(), "Jansen".to_owned()), + ]); + let (display, remaining) = sender_display(&state); + assert_eq!(display, "Jan Jansen"); + assert!(remaining.is_empty()); + } + + #[test] + fn sender_display_concatenates_firstname_lastname_from_drivinglicence() { + let state = filestate_with_attrs(vec![ + ( + "pbdf.pbdf.drivinglicence.firstName".to_owned(), + "Jan".to_owned(), + ), + ( + "pbdf.pbdf.drivinglicence.lastName".to_owned(), + "Jansen".to_owned(), + ), + ]); + let (display, _) = sender_display(&state); + assert_eq!(display, "Jan Jansen"); + } + + #[test] + fn sender_display_concatenates_firstname_lastname_from_demo_scheme() { + let state = filestate_with_attrs(vec![ + ( + "irma-demo.pbdf.passport.firstName".to_owned(), + "Jan".to_owned(), + ), + ( + "irma-demo.pbdf.passport.lastName".to_owned(), + "Jansen".to_owned(), + ), + ]); + let (display, _) = sender_display(&state); + assert_eq!(display, "Jan Jansen"); + } + + #[test] + fn sender_display_prefers_gemeente_fullname_over_passport_pair() { + // If both are disclosed (unlikely in practice), gemeente wins + // because that path runs first. + let state = filestate_with_attrs(vec![ + ( + "pbdf.gemeente.personalData.fullname".to_owned(), + "Marie Smit".to_owned(), + ), + ("pbdf.pbdf.passport.firstName".to_owned(), "Jan".to_owned()), + ( + "pbdf.pbdf.passport.lastName".to_owned(), + "Jansen".to_owned(), + ), + ]); + let (display, _) = sender_display(&state); + assert_eq!(display, "Marie Smit"); + } + + #[test] + fn sender_display_falls_through_when_firstname_present_without_lastname() { + let state = filestate_with_attrs(vec![( + "pbdf.pbdf.passport.firstName".to_owned(), + "Jan".to_owned(), + )]); + let (display, remaining) = sender_display(&state); + // No lastName → no concatenation; the orphan firstName stays as a + // pill so the recipient at least sees it instead of having it + // silently dropped. + assert_eq!(display, "PostGuard"); + assert_eq!( + remaining, + vec![("pbdf.pbdf.passport.firstName".to_owned(), "Jan".to_owned())] + ); + } + + #[test] + fn sender_display_treats_empty_firstname_lastname_as_not_disclosed() { + let state = filestate_with_attrs(vec![ + ("pbdf.pbdf.passport.firstName".to_owned(), String::new()), + ( + "pbdf.pbdf.passport.lastName".to_owned(), + "Jansen".to_owned(), + ), + ]); + let (display, _) = sender_display(&state); + assert_eq!(display, "PostGuard"); + } + + #[test] + fn sender_display_uses_postguard_when_no_name_disclosed() { + let mut state = filestate_with_attrs(vec![]); + state.sender = None; + let (display, remaining) = sender_display(&state); + assert_eq!(display, "PostGuard"); + assert!(remaining.is_empty()); + } + + #[test] + fn x_postguard_header_round_trips() { + let parsed = XPostGuard::parse(X_POSTGUARD_VERSION).expect("parse"); + assert_eq!(parsed.0, X_POSTGUARD_VERSION); + } + + #[test] + fn x_postguard_header_serialises_into_message() { + use lettre::message::Mailbox; + let msg = Message::builder() + .from("noreply@example.com".parse::().unwrap()) + .to("to@example.com".parse::().unwrap()) + .subject("t") + .header(XPostGuard(X_POSTGUARD_VERSION.to_owned())) + .body(String::from("hi")) + .expect("build"); + let raw = String::from_utf8(msg.formatted()).expect("utf8"); + let expected = format!("X-PostGuard: {}", X_POSTGUARD_VERSION); + assert!( + raw.contains(&expected), + "expected `{}` header in message, got: {}", + expected, + raw + ); + } + + #[test] + fn format_file_size_zero() { + assert_eq!(format_file_size(0), "0 B"); + } + + #[test] + fn format_file_size_bytes() { + assert_eq!(format_file_size(1), "1.0 B"); + assert_eq!(format_file_size(1023), "1023.0 B"); + } + + #[test] + fn format_file_size_kibibytes() { + assert_eq!(format_file_size(1024), "1.0 kB"); + assert_eq!(format_file_size(1536), "1.5 kB"); + } + + #[test] + fn format_file_size_mebibytes() { + assert_eq!(format_file_size(1024 * 1024), "1.0 MB"); + } + + #[test] + fn format_file_size_gibibytes() { + assert_eq!(format_file_size(1024 * 1024 * 1024), "1.0 GB"); + } + + #[test] + fn format_file_size_tebibytes() { + assert_eq!(format_file_size(1024_u64.pow(4)), "1.0 TB"); + } + + fn filestate_with_attrs(attrs: Vec<(String, String)>) -> FileState { + let mut state = staging_filestate(); + state.sender_attributes = attrs; + state + } + + fn staging_filestate() -> FileState { + use lettre::message::{Mailbox, Mailboxes}; + let mut mboxes = Mailboxes::new(); + mboxes.push("alice@example.com".parse::().unwrap()); + mboxes.push("bob@example.com".parse::().unwrap()); + FileState { + uploaded: 1234, + cryptify_token: String::new(), + expires: 1_700_000_000, + recipients: mboxes, + mail_content: String::new(), + mail_lang: Language::En, + sender: Some("sender@example.com".to_owned()), + sender_attributes: vec![ + ("orgName".to_owned(), "Acme".to_owned()), + ("phone".to_owned(), "+31123".to_owned()), + ], + confirm: true, + source_channel: String::new(), + client_version: None, + client_app: None, + notify_recipients: true, + api_key_tenant: None, + api_key_validation_failed: false, + last_chunk: None, + recovery_token: String::new(), + } + } + + #[rocket::async_test] + async fn staging_mode_skips_smtp_and_returns_summary() { + let config = CryptifyConfig::for_test("https://staging.example.com/", true); + let state = staging_filestate(); + let res = send_email(&config, &state, "uuid-abc") + .await + .expect("staging mode should return Ok without contacting SMTP"); + assert!(res.starts_with("[STAGING]"), "got: {}", res); + assert!(res.contains("alice@example.com"), "got: {}", res); + assert!(res.contains("bob@example.com"), "got: {}", res); + assert!(res.contains("sender@example.com"), "got: {}", res); + assert!(res.contains("orgName=Acme"), "got: {}", res); + assert!(res.contains("uuid=uuid-abc"), "got: {}", res); + assert!( + res.contains("https://staging.example.com/download?uuid=uuid-abc"), + "got: {}", + res + ); + } + + #[test] + fn render_recipient_email_embeds_download_url_with_uuid_and_recipient() { + let config = CryptifyConfig::for_test("https://staging.example.com/", true); + let state = staging_filestate(); + let rendered = render_recipient_email(&state, &config, "alice@example.com", "uuid-abc") + .expect("render"); + assert_eq!(rendered.recipient, "alice@example.com"); + assert_eq!( + rendered.reply_to.as_deref(), + Some("sender@example.com"), + "reply_to should mirror state.sender" + ); + // HTML escapes `&` to `&`; the plain-text branch is the + // cleanest place to assert URL composition. + assert!( + rendered.text.contains( + "https://staging.example.com/download?uuid=uuid-abc&recipient=alice%40example.com" + ), + "text missing download URL: {}", + rendered.text + ); + assert!( + rendered.subject.contains("sent you files"), + "subject: {}", + rendered.subject + ); + // The download-link block must render as a prominent, selectable + // monospace code block that is not smaller than the 16px primary + // button (see issue #186). Pin a contiguous substring unique to the + // restyled `` (the primary button is `display:inline-block` and not + // monospace), so a font-size regression here genuinely fails — a bare + // `font-size:16px` check would pass on the button alone. + assert!( + rendered.html.contains( + "display:block;font-family:'Courier New',Consolas,Monaco,monospace;font-size:16px;" + ), + "download-link block should be a >=16px monospace code block: {}", + rendered.html + ); + } + + #[test] + fn render_confirmation_email_targets_sender_and_drops_reply_to() { + let config = CryptifyConfig::for_test("https://staging.example.com/", true); + let state = staging_filestate(); + let rendered = render_confirmation_email(&state, &config, "uuid-xyz") + .expect("render") + .expect("confirmation present when state.sender is Some"); + assert_eq!(rendered.recipient, "sender@example.com"); + assert!( + rendered.reply_to.is_none(), + "confirmation should not set Reply-To" + ); + assert!( + rendered.html.contains("uuid=uuid-xyz"), + "html missing uuid: {}", + rendered.html + ); + } + + #[test] + fn render_confirmation_email_returns_none_without_sender() { + let config = CryptifyConfig::for_test("https://staging.example.com/", true); + let mut state = staging_filestate(); + state.sender = None; + let rendered = render_confirmation_email(&state, &config, "uuid-xyz").expect("render"); + assert!(rendered.is_none()); + } + + #[test] + fn format_file_size_clamps_above_tb() { + // u64 max is ~16 EB, far beyond TB — previously UNITS[i] would panic. + // The clamp keeps us at TB and produces a sensible large-TB number. + let result = format_file_size(u64::MAX); + assert!(result.ends_with(" TB"), "got {}", result); + } +} diff --git a/cryptify/src/error.rs b/cryptify/src/error.rs new file mode 100644 index 00000000..b6dfc3d5 --- /dev/null +++ b/cryptify/src/error.rs @@ -0,0 +1,100 @@ +#![allow(clippy::enum_variant_names)] + +use rocket::http::ContentType; +use rocket::response::{self, Responder}; +use rocket::serde::json::Json; +use serde::Serialize; + +#[derive(Debug, Serialize)] +pub struct PayloadTooLargeBody { + pub error: String, + pub limit: &'static str, + pub used_bytes: u64, + pub limit_bytes: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub resets_at: Option, +} + +#[derive(Debug, Serialize)] +pub struct UploadSessionNotFoundBody { + pub error: &'static str, + pub uuid: String, + pub reason: &'static str, +} + +#[derive(Debug)] +pub enum Error { + BadRequest(Option), + /// 401 — the request did not present a valid API key on an endpoint + /// that requires one. Distinct from the upload flow, which degrades a + /// missing/invalid key to the default tier rather than rejecting. + Unauthorized(Option), + /// 404 — the resource (e.g. the email template for a validated API + /// key) does not exist. Carries an optional human-readable message. + NotFound(Option), + UnprocessableEntity(Option), + InternalServerError(Option), + PayloadTooLarge(PayloadTooLargeBody), + /// 503 — pg-pkg was unreachable for the full retry budget while + /// validating an API key. Returned when the upload exceeds the default + /// tier and we couldn't confirm the caller is entitled to the higher + /// tier. Smaller uploads degrade silently to the default tier. + ServiceUnavailable(Option), + UploadSessionNotFound(UploadSessionNotFoundBody), +} + +impl Error { + pub fn upload_session_not_found(uuid: impl Into, reason: &'static str) -> Self { + Error::UploadSessionNotFound(UploadSessionNotFoundBody { + error: "upload_session_not_found", + uuid: uuid.into(), + reason, + }) + } +} + +impl<'r, 'o: 'r> Responder<'r, 'o> for Error { + fn respond_to(self, request: &'r rocket::Request<'_>) -> response::Result<'o> { + match self { + Error::BadRequest(e) => response::status::BadRequest(e).respond_to(request), + Error::Unauthorized(e) => response::status::Custom::( + rocket::http::Status::Unauthorized, + e.unwrap_or_else(|| "".to_owned()), + ) + .respond_to(request), + Error::NotFound(e) => response::status::Custom::( + rocket::http::Status::NotFound, + e.unwrap_or_else(|| "".to_owned()), + ) + .respond_to(request), + // response::status::Custom apparently doesn't support Option + Error::UnprocessableEntity(e) => response::status::Custom::( + rocket::http::Status::UnprocessableEntity, + e.unwrap_or_else(|| "".to_owned()), + ) + .respond_to(request), + Error::InternalServerError(e) => response::status::Custom::( + rocket::http::Status::InternalServerError, + e.unwrap_or_else(|| "".to_owned()), + ) + .respond_to(request), + Error::PayloadTooLarge(body) => { + response::Response::build_from(Json(body).respond_to(request)?) + .status(rocket::http::Status::PayloadTooLarge) + .header(ContentType::JSON) + .ok() + } + Error::ServiceUnavailable(e) => response::status::Custom::( + rocket::http::Status::ServiceUnavailable, + e.unwrap_or_else(|| "".to_owned()), + ) + .respond_to(request), + Error::UploadSessionNotFound(body) => { + response::Response::build_from(Json(body).respond_to(request)?) + .status(rocket::http::Status::NotFound) + .header(ContentType::JSON) + .ok() + } + } + } +} diff --git a/cryptify/src/main.rs b/cryptify/src/main.rs new file mode 100644 index 00000000..e1c7c176 --- /dev/null +++ b/cryptify/src/main.rs @@ -0,0 +1,4682 @@ +mod config; +mod email; +mod error; +mod metrics; +mod store; + +use std::sync::Arc; +use std::time::Duration; + +use crate::config::CryptifyConfig; +use crate::email::{render_confirmation_email, render_recipient_email, send_email, RenderedEmail}; +use crate::error::{Error, PayloadTooLargeBody}; +use crate::metrics::{ + detect_channel, parse_client_version, storage_sampler, Metrics, CHANNEL_UNKNOWN, + CLIENT_VERSION_HEADER, +}; +use crate::store::{ + API_KEY_PER_UPLOAD_LIMIT, API_KEY_ROLLING_LIMIT, PER_UPLOAD_LIMIT, ROLLING_LIMIT, + ROLLING_WINDOW_SECS, +}; + +use std::path::Path; +use std::str::FromStr; + +use pg_core::api::Parameters; +use pg_core::artifacts::VerifyingKey; +use pg_core::client::rust::stream::UnsealerStreamConfig; +use pg_core::client::Unsealer; + +use tokio_util::compat::TokioAsyncReadCompatExt; + +use sha2::Digest; +use std::fmt::Write; + +use rocket::tokio::{ + fs::{File, OpenOptions}, + io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt}, +}; +use rocket::{ + data::ToByteUnit, fairing::AdHoc, figment::Figment, get, http::Header, launch, post, put, + request::FromRequest, response::Responder, routes, serde::json::Json, Build, Data, Rocket, + State, +}; + +use rocket::http::Method; +use rocket_cors::{AllowedHeaders, AllowedOrigins, CorsOptions}; + +use serde::{Deserialize, Serialize}; +use store::{FileState, LastChunkRecord, Store}; + +#[derive(Serialize, Deserialize)] +struct InitBody { + recipient: String, + #[serde(rename = "mailContent")] + mail_content: String, + #[serde(rename = "mailLang")] + mail_lang: email::Language, + confirm: bool, + /// Whether to email each recipient with a download link. Optional; + /// defaults to `true` to preserve existing client behaviour. Set to + /// `false` when the encrypted payload reaches the recipients through + /// another channel (e.g. an email add-in delivering the message from + /// the user's own mailbox) and a Cryptify-sent notification would be + /// a duplicate. The recipient list itself is still validated and + /// stored — only the SMTP delivery is skipped. + #[serde(rename = "notifyRecipients", default = "default_true")] + notify_recipients: bool, +} + +fn default_true() -> bool { + true +} + +#[derive(Serialize, Deserialize)] +#[serde(rename = "camelCase")] +struct InitResponse { + uuid: String, + /// Bearer credential for the cross-refresh-resume status endpoint + /// (`GET /fileupload/{uuid}/status`). The client stores this alongside + /// the UUID — typically in IndexedDB — and sends it back in an + /// `X-Recovery-Token` header on resume. Hex-encoded 32-byte random. + recovery_token: String, +} + +struct CryptifyToken(String); + +impl From for Header<'static> { + fn from(token: CryptifyToken) -> Header<'static> { + Header::new("cryptifytoken", token.0) + } +} + +#[derive(Responder)] +struct InitResponder { + inner: Json, + cryptify_token: CryptifyToken, +} + +/// Request guard that derives the traffic source channel from the request +/// headers for metrics labelling. +struct ClientHeaders { + channel: String, + /// Raw `X-POSTGUARD-CLIENT-VERSION` value (`host,host_version,app,app_version`), + /// kept verbatim for logging so exact client versions are greppable. + client_version: Option, + /// The `app` field of the client-version header, used as the + /// `cryptify_uploads_by_app_total` metric label. `None` when the header + /// is absent or malformed. + client_app: Option, +} + +#[rocket::async_trait] +impl<'r> FromRequest<'r> for ClientHeaders { + type Error = std::convert::Infallible; + + async fn from_request( + request: &'r rocket::Request<'_>, + ) -> rocket::request::Outcome { + let client_version = request + .headers() + .get_one(CLIENT_VERSION_HEADER) + .map(str::to_string); + let client_app = client_version + .as_deref() + .and_then(parse_client_version) + .map(|cv| cv.app); + rocket::request::Outcome::Success(ClientHeaders { + channel: detect_channel(request.headers()), + client_version, + client_app, + }) + } +} + +#[get("/health")] +fn health() -> &'static str { + "OK" +} + +/// Request guard protecting `/metrics`. When `metrics_token` is configured, +/// the endpoint requires `Authorization: Bearer ` (constant-time +/// compared); otherwise it stays open (a startup warning is logged). This +/// auth lives in the app rather than the ingress so the protection travels +/// with cryptify to every deployment, including external hosts we can't +/// firewall. +struct MetricsAuth; + +#[rocket::async_trait] +impl<'r> FromRequest<'r> for MetricsAuth { + type Error = (); + async fn from_request(request: &'r rocket::Request<'_>) -> rocket::request::Outcome { + let expected = request + .rocket() + .state::() + .and_then(CryptifyConfig::metrics_token); + + // No token configured → metrics is open (warned about at startup). + let Some(expected) = expected else { + return rocket::request::Outcome::Success(MetricsAuth); + }; + + let presented = request + .headers() + .get_one("Authorization") + .and_then(|h| { + h.strip_prefix("Bearer ") + .or_else(|| h.strip_prefix("bearer ")) + }) + .map(str::trim); + + match presented { + Some(token) if constant_time_eq(token, expected) => { + rocket::request::Outcome::Success(MetricsAuth) + } + _ => rocket::request::Outcome::Error((rocket::http::Status::Unauthorized, ())), + } + } +} + +#[get("/metrics")] +fn metrics_endpoint( + _auth: MetricsAuth, + metrics: &State>, +) -> rocket::response::content::RawText { + rocket::response::content::RawText(metrics.render()) +} + +/// Extract a `PG-…` bearer token from an Authorization header value, or +/// `None` for any other shape (missing, wrong scheme, non-PG prefix). Kept +/// as a pure helper so the parsing rules are unit-testable without HTTP. +fn extract_pg_bearer(header: Option<&str>) -> Option<&str> { + let token = header + .and_then(|h| { + h.strip_prefix("Bearer ") + .or_else(|| h.strip_prefix("bearer ")) + }) + .map(str::trim)?; + if token.starts_with("PG-") { + Some(token) + } else { + None + } +} + +/// HTTP client for talking to pg-pkg's `/v2/api-key/validate` endpoint. +/// Held as Rocket state so the per-request `ApiKey` guard can call it. +struct PkgClient { + http: reqwest::Client, + pkg_url: String, +} + +/// Total wall-clock budget for retrying pg-pkg validation when the call +/// errors out (network errors, 5xx). Authoritative responses (2xx with +/// validated tenant, 401/403 unrecognised key) short-circuit immediately — +/// retrying them would not change the outcome. +const PKG_VALIDATE_RETRY_BUDGET: Duration = Duration::from_secs(30); +const PKG_VALIDATE_INITIAL_BACKOFF: Duration = Duration::from_millis(250); +const PKG_VALIDATE_MAX_BACKOFF: Duration = Duration::from_secs(5); + +/// Total wall-clock budget for fetching the IBS verifying key from pg-pkg at +/// startup. Long enough to ride out a PKG that is still booting during a +/// rolling deploy; when the budget is exhausted the process still exits with +/// a clear error, so a misconfigured `pkg_url` does not fail silently. +const PKG_PARAMS_RETRY_BUDGET: Duration = Duration::from_secs(120); +const PKG_PARAMS_INITIAL_BACKOFF: Duration = Duration::from_millis(500); +const PKG_PARAMS_MAX_BACKOFF: Duration = Duration::from_secs(10); + +#[derive(Debug, Deserialize)] +struct ValidateResponse { + tenant_id: String, + #[allow(dead_code)] + #[serde(default)] + organisation_name: Option, + /// Email template linked to this API key on pg-pkg (postguard#86). + /// `None` when the tenant has no template configured. Surfaced by the + /// `GET /email-template` endpoint so API-key callers can fetch the + /// notification body associated with their key. + #[serde(default)] + email_template: Option, +} + +#[derive(Debug)] +enum ValidationOutcome { + /// No `Authorization: Bearer PG-…` header — caller is default tier. + NoCredentials, + /// pg-pkg confirmed the key. Carries the tenant id (uuid) and the + /// email template linked to the key, if any. + Validated { + tenant: String, + email_template: Option, + }, + /// pg-pkg returned an authoritative rejection (401/403). Caller is + /// degraded to default tier — their fake/expired key won't earn the + /// higher tier, but they can still upload up to the default cap. + Rejected, + /// pg-pkg was unreachable for the full retry budget. Caller is treated + /// as default tier *unless* they exceed the default cap, at which point + /// the chunk/finalize handler returns 503. + PkgUnreachable, +} + +impl PkgClient { + fn new(pkg_url: String) -> Self { + let http = reqwest::Client::builder() + // Per-request timeout — bounded by the retry budget regardless, + // but a low ceiling per attempt keeps the loop responsive. + .timeout(Duration::from_secs(5)) + .build() + .expect("reqwest client build"); + Self { http, pkg_url } + } + + async fn validate(&self, header: Option<&str>) -> ValidationOutcome { + let Some(token) = extract_pg_bearer(header).map(str::to_owned) else { + return ValidationOutcome::NoCredentials; + }; + + let url = format!("{}/v2/api-key/validate", self.pkg_url.trim_end_matches('/')); + + let deadline = rocket::tokio::time::Instant::now() + PKG_VALIDATE_RETRY_BUDGET; + let mut backoff = PKG_VALIDATE_INITIAL_BACKOFF; + loop { + match self.http.get(&url).bearer_auth(&token).send().await { + Ok(resp) if resp.status().is_success() => { + match resp.json::().await { + Ok(body) => { + return ValidationOutcome::Validated { + tenant: body.tenant_id, + email_template: body.email_template, + } + } + Err(e) => { + log::error!("pg-pkg /api-key/validate parse failed: {}", e); + return ValidationOutcome::PkgUnreachable; + } + } + } + Ok(resp) if matches!(resp.status().as_u16(), 401 | 403) => { + return ValidationOutcome::Rejected; + } + Ok(resp) => { + log::warn!( + "pg-pkg /api-key/validate returned status {} — will retry", + resp.status() + ); + } + Err(e) => { + log::warn!( + "pg-pkg /api-key/validate request failed: {} — will retry", + e + ); + } + } + + let now = rocket::tokio::time::Instant::now(); + if now + backoff >= deadline { + return ValidationOutcome::PkgUnreachable; + } + rocket::tokio::time::sleep(backoff).await; + backoff = (backoff * 2).min(PKG_VALIDATE_MAX_BACKOFF); + } + } +} + +/// Result of validating an `Authorization: Bearer PG-…` header against +/// pg-pkg. `tenant` is `Some` only on success; `validation_failed` is true +/// only when a PG-prefixed bearer was supplied but pg-pkg was unreachable. +/// `email_template` carries the template pg-pkg linked to the key, when the +/// key validated and a template is configured. +struct ApiKey { + tenant: Option, + validation_failed: bool, + email_template: Option, +} + +#[rocket::async_trait] +impl<'r> FromRequest<'r> for ApiKey { + type Error = (); + async fn from_request(req: &'r rocket::Request<'_>) -> rocket::request::Outcome { + let header = req.headers().get_one("Authorization"); + let Some(client) = req.rocket().state::() else { + log::error!("PkgClient missing from Rocket state — treating request as default tier"); + return rocket::request::Outcome::Success(ApiKey { + tenant: None, + validation_failed: false, + email_template: None, + }); + }; + let outcome = client.validate(header).await; + let api_key = match outcome { + ValidationOutcome::Validated { + tenant, + email_template, + } => ApiKey { + tenant: Some(tenant), + validation_failed: false, + email_template, + }, + ValidationOutcome::NoCredentials | ValidationOutcome::Rejected => ApiKey { + tenant: None, + validation_failed: false, + email_template: None, + }, + ValidationOutcome::PkgUnreachable => { + log::warn!( + "pg-pkg unreachable during API-key validation; degrading to default tier (over-default uploads will 503)" + ); + ApiKey { + tenant: None, + validation_failed: true, + email_template: None, + } + } + }; + rocket::request::Outcome::Success(api_key) + } +} + +/// Request guard for routes that require a *validated* pg-pkg API key. +/// +/// Unlike [`ApiKey`], whose `FromRequest` always succeeds (degrading callers +/// without a valid key to the anonymous "default tier"), this guard **fails** +/// the request when no valid credentials are presented: +/// - `NoCredentials` / `Rejected` → `401 Unauthorized` +/// - `PkgUnreachable` → `503 Service Unavailable` (we cannot confirm the key) +/// +/// A route carrying this guard is therefore authenticated by construction — +/// the "authenticated" intent is enforced by the type system rather than by a +/// guard that always succeeds and leaves the check to the handler. +struct ValidatedApiKey { + tenant: String, +} + +#[rocket::async_trait] +impl<'r> FromRequest<'r> for ValidatedApiKey { + type Error = (); + async fn from_request(req: &'r rocket::Request<'_>) -> rocket::request::Outcome { + let header = req.headers().get_one("Authorization"); + let Some(client) = req.rocket().state::() else { + log::error!("PkgClient missing from Rocket state — rejecting authenticated request"); + return rocket::request::Outcome::Error((rocket::http::Status::ServiceUnavailable, ())); + }; + match client.validate(header).await { + ValidationOutcome::Validated { tenant, .. } => { + rocket::request::Outcome::Success(ValidatedApiKey { tenant }) + } + ValidationOutcome::NoCredentials | ValidationOutcome::Rejected => { + rocket::request::Outcome::Error((rocket::http::Status::Unauthorized, ())) + } + ValidationOutcome::PkgUnreachable => { + log::warn!( + "pg-pkg unreachable during API-key validation on an authenticated route; returning 503" + ); + rocket::request::Outcome::Error((rocket::http::Status::ServiceUnavailable, ())) + } + } + } +} + +#[post("/fileupload/init", data = "")] +async fn upload_init( + config: &State, + store: &State, + api_key: ApiKey, + request: Json, + client_headers: ClientHeaders, +) -> Result { + let current_time = chrono::offset::Utc::now().timestamp(); + + let recipient: lettre::message::Mailboxes = request + .recipient + .parse() + .map_err(|e| Error::BadRequest(Some(format!("Could not parse e-mail address: {}", e))))?; + + let uuid = uuid::Uuid::new_v4().hyphenated().to_string(); + + if let Err(e) = File::create(Path::new(config.data_dir()).join(&uuid)).await { + log::error!("{}", e); + return Err(Error::InternalServerError(None)); + } + + let init_cryptify_token = bytes_to_hex(&rand::random::<[u8; 32]>()); + let recovery_token = bytes_to_hex(&rand::random::<[u8; 32]>()); + + log::info!( + "upload_init uuid={} channel={} client_version={:?}", + uuid, + client_headers.channel, + client_headers.client_version + ); + + store.create( + uuid.clone(), + FileState { + cryptify_token: init_cryptify_token.clone(), + uploaded: 0, + expires: current_time + 1_209_600, + recipients: recipient, + mail_content: request.mail_content.clone(), + mail_lang: request.mail_lang.clone(), + sender: None, + sender_attributes: Vec::new(), + confirm: request.confirm, + source_channel: client_headers.channel, + client_version: client_headers.client_version, + client_app: client_headers.client_app, + notify_recipients: request.notify_recipients, + api_key_tenant: api_key.tenant, + api_key_validation_failed: api_key.validation_failed, + last_chunk: None, + recovery_token: recovery_token.clone(), + }, + ); + + Ok(InitResponder { + inner: Json(InitResponse { + uuid, + recovery_token, + }), + cryptify_token: CryptifyToken(init_cryptify_token), + }) +} + +struct ContentRange { + size: Option, + start: Option, + end: Option, +} + +impl FromStr for ContentRange { + type Err = String; + + fn from_str(s: &str) -> Result { + let mut parts = s.split_whitespace(); + let unit = parts.next().ok_or("Missing unit")?; + let range = parts.next().ok_or("Missing range")?; + if parts.next().is_some() { + return Err("Excess data".into()); + } + if unit != "bytes" { + return Err(format!("Unknown unit {}", unit)); + } + let mut rangeparts = range.split('/'); + let range = rangeparts + .next() + .ok_or("Missing lower-upper part of range")?; + let size = rangeparts.next().ok_or("Missing size part of range")?; + if rangeparts.next().is_some() { + return Err("Excess data in range".into()); + } + let size = if size != "*" { + Some(size.parse::().map_err(|e| e.to_string())?) + } else { + None + }; + if range != "*" { + let mut rangeparts = range.split('-'); + let start = rangeparts + .next() + .ok_or("Missing start of range")? + .parse::() + .map_err(|e| e.to_string())?; + let end = rangeparts + .next() + .ok_or("Missing end of range")? + .parse::() + .map_err(|e| e.to_string())?; + Ok(ContentRange { + size, + start: Some(start), + end: Some(end), + }) + } else { + Ok(ContentRange { + size, + start: None, + end: None, + }) + } + } +} + +struct UploadHeaders { + cryptify_token: String, + content_range: ContentRange, +} + +#[rocket::async_trait] +impl<'r> FromRequest<'r> for UploadHeaders { + type Error = String; + + async fn from_request( + request: &'r rocket::Request<'_>, + ) -> rocket::request::Outcome { + let cryptify_token = match request.headers().get_one("CryptifyToken") { + Some(cryptify_token) => cryptify_token, + None => { + return rocket::request::Outcome::Error(( + rocket::http::Status::BadRequest, + "Missing Cryptify Token header".into(), + )) + } + } + .to_string(); + let content_range = match request.headers().get_one("Content-Range") { + Some(content_range) => content_range, + None => { + return rocket::request::Outcome::Error(( + rocket::http::Status::BadRequest, + "Missing content range".into(), + )) + } + } + .parse::(); + let content_range = match content_range { + Ok(v) => v, + Err(e) => { + return rocket::request::Outcome::Error((rocket::http::Status::BadRequest, e)) + } + }; + + rocket::request::Outcome::Success(UploadHeaders { + cryptify_token, + content_range, + }) + } +} + +#[derive(Responder)] +struct UploadResponder { + body: (), + cryptify_token: CryptifyToken, +} + +fn bytes_to_hex(bytes: &[u8]) -> String { + let mut s = String::with_capacity(2 * bytes.len()); + for byte in bytes { + write!(s, "{:02x}", byte).unwrap(); + } + s +} + +fn compute_hash(cryptify_token: &[u8], data: &[u8]) -> String { + let mut hash = sha2::Sha256::new(); + hash.update(cryptify_token); + hash.update(data); + bytes_to_hex(&hash.finalize()) +} + +/// Wire-level error message for a `CryptifyToken` mismatch. Reused by both +/// `check_cryptify_token` (the finalize path) and the chunk classifier so the +/// message can't drift silently between call sites. +const TOKEN_MISMATCH_MSG: &str = "Cryptify Token header does not match"; + +/// Caller-facing body for 5xx (`InternalServerError` / `ServiceUnavailable`) +/// responses. Detailed diagnostics are written server-side via `log::error!` +/// rather than returned to HTTP clients, so operators keep observability +/// without leaking internal implementation details to callers +/// (GHSA-r95f-qf3j-xccw). +const GENERIC_INTERNAL_ERROR_MSG: &str = "an internal error occurred"; + +/// Constant-time compare of a presented `CryptifyToken` against the expected +/// value. Both are hex-encoded SHA-256 strings; `subtle::ConstantTimeEq` keeps +/// the timing independent of where the bytes first differ, mirroring +/// `recovery_tokens_match`. A network timing oracle against these 256-bit +/// high-entropy tokens is impractical, but we compare them in constant time +/// for consistency with the recovery-token path. +fn cryptify_tokens_match(presented: &str, expected: &str) -> bool { + use subtle::ConstantTimeEq; + if presented.len() != expected.len() { + return false; + } + presented.as_bytes().ct_eq(expected.as_bytes()).into() +} + +fn check_cryptify_token(header: &str, expected: &str) -> Result<(), Error> { + if !cryptify_tokens_match(header, expected) { + return Err(Error::BadRequest(Some(TOKEN_MISMATCH_MSG.to_owned()))); + } + Ok(()) +} + +#[put("/fileupload/", data = "")] +async fn upload_chunk( + config: &State, + store: &State, + uuid: &str, + headers: UploadHeaders, + data: Data<'_>, +) -> Result { + if uuid::Uuid::parse_str(uuid).is_err() { + return Err(Error::upload_session_not_found(uuid, "invalid_uuid")); + } + + let state = match store.get(uuid) { + Some(v) => v, + None => return Err(Error::upload_session_not_found(uuid, "expired_or_unknown")), + }; + let mut state = state.lock().await; + + let start = headers + .content_range + .start + .ok_or_else(|| Error::BadRequest(Some("Could not read Content-Range start".to_owned())))?; + let end = headers + .content_range + .end + .ok_or_else(|| Error::BadRequest(Some("Could not read Content-Range end".to_owned())))?; + + if start >= end { + return Err(Error::BadRequest(Some( + "Incorrect Content-Range header".to_owned(), + ))); + } + + if end - start > config.chunk_size() { + return Err(Error::BadRequest(Some(format!( + "File chunk too large; the maximum is {} bytes", + config.chunk_size() + )))); + } + + // Cheap pre-check before reading the body, so a leaked UUID can't be + // used to force the server to buffer up to `chunk_size` bytes per + // request just to be rejected. Mirrors the structural part of + // `classify_chunk_request` — we only commit to reading the body when + // the request looks like either a normal next chunk or a candidate + // replay of the last committed chunk. + let is_normal_next = state.uploaded == start + && cryptify_tokens_match(&headers.cryptify_token, &state.cryptify_token); + let is_replay_candidate = state.last_chunk.as_ref().is_some_and(|last| { + last.prev_uploaded == start + && cryptify_tokens_match(&headers.cryptify_token, &last.prev_token) + }); + if !is_normal_next && !is_replay_candidate { + if state.uploaded != start { + return Err(Error::BadRequest(Some( + "Incorrect Content-Range header".to_owned(), + ))); + } + return Err(Error::BadRequest(Some(TOKEN_MISMATCH_MSG.to_owned()))); + } + + let body = data + .open((end - start).bytes()) + .into_bytes() + .await + .map_err(|_| Error::BadRequest(Some("Could not read data from request".to_owned())))?; + if !body.is_complete() || body.len() as u64 != end - start { + return Err(Error::BadRequest(Some("Data not complete".to_owned()))); + } + let body = body.into_inner(); + + // Three branches: normal next chunk, idempotent retry of the last + // committed chunk, or rejection. + match classify_chunk_request(&state, &headers.cryptify_token, start, &body) { + ChunkClassification::NormalNext => {} + ChunkClassification::ReplayLastChunk(token) => { + drop(state); + store.touch(uuid); + return Ok(UploadResponder { + body: (), + cryptify_token: CryptifyToken(token), + }); + } + ChunkClassification::Reject(err) => return Err(err), + } + + let per_upload_limit = if state.api_key_tenant.is_some() { + API_KEY_PER_UPLOAD_LIMIT + } else { + PER_UPLOAD_LIMIT + }; + if end > per_upload_limit { + // If the caller presented an API key but pg-pkg was unreachable at + // init time, we degraded them to the default tier. Below the default + // cap that's silent; here, where we'd reject, surface 503 so the + // client knows the higher tier *might* have applied if pg-pkg had + // been reachable. Within-default uploads keep flowing as today. + if state.api_key_validation_failed { + log::error!( + "pg-pkg was unreachable while validating the API key; cannot apply the higher upload tier" + ); + return Err(Error::ServiceUnavailable(Some( + GENERIC_INTERNAL_ERROR_MSG.to_owned(), + ))); + } + return Err(Error::PayloadTooLarge(PayloadTooLargeBody { + error: format!( + "Upload exceeds the per-upload limit of {} bytes", + per_upload_limit + ), + limit: "per_upload", + used_bytes: state.uploaded, + limit_bytes: per_upload_limit, + resets_at: None, + })); + } + + let mut file = match OpenOptions::new() + .write(true) + .open(Path::new(config.data_dir()).join(uuid)) + .await + { + Ok(v) => v, + Err(_) => return Err(Error::upload_session_not_found(uuid, "file_missing")), + }; + + file.seek(std::io::SeekFrom::Start(start)) + .await + .map_err(|e| { + log::error!("could not seek in upload file: {}", e); + Error::InternalServerError(Some(GENERIC_INTERNAL_ERROR_MSG.to_owned())) + })?; + + file.write_all(&body).await.map_err(|e| { + log::error!("could not write chunk to upload file: {}", e); + Error::InternalServerError(Some(GENERIC_INTERNAL_ERROR_MSG.to_owned())) + })?; + + let prev_token = headers.cryptify_token; + let shasum = compute_hash(prev_token.as_bytes(), &body); + state.cryptify_token = shasum.clone(); + state.uploaded += end - start; + state.last_chunk = Some(LastChunkRecord { + prev_token, + prev_uploaded: start, + response_token: shasum.clone(), + }); + + drop(state); + store.touch(uuid); + + Ok(UploadResponder { + body: (), + cryptify_token: CryptifyToken(shasum), + }) +} + +/// Outcome of inspecting a chunk PUT against the current `FileState`. +enum ChunkClassification { + /// The expected next chunk in the rolling-token chain — caller proceeds + /// to the normal write path. + NormalNext, + /// The just-completed chunk being retried after a lost response. Caller + /// returns this token to the client without re-writing or double-counting. + ReplayLastChunk(String), + /// Reject the request with this error — the standard 400 you'd get + /// before idempotent-retry support, plus a stricter 400 when the + /// request looks like a retry but the body bytes (or their length) + /// diverge from the cached chunk. Never accept different bytes for + /// the same offset. + Reject(Error), +} + +fn classify_chunk_request( + state: &FileState, + request_token: &str, + start: u64, + body: &[u8], +) -> ChunkClassification { + if state.uploaded == start && cryptify_tokens_match(request_token, &state.cryptify_token) { + return ChunkClassification::NormalNext; + } + + if let Some(last) = state.last_chunk.as_ref() { + if cryptify_tokens_match(request_token, &last.prev_token) && start == last.prev_uploaded { + // Recompute the rolling hash over the incoming body. Identity + // is implicit in the rolling-token construction itself: if the + // hash matches `response_token`, the body is byte-identical to + // the original chunk (modulo a SHA-256 collision, which would + // also break the rolling chain). Length divergence surfaces + // here too. + if compute_hash(last.prev_token.as_bytes(), body) == last.response_token { + return ChunkClassification::ReplayLastChunk(last.response_token.clone()); + } + return ChunkClassification::Reject(Error::BadRequest(Some( + "Idempotent retry: body differs from the original chunk".to_owned(), + ))); + } + } + + if state.uploaded != start { + return ChunkClassification::Reject(Error::BadRequest(Some( + "Incorrect Content-Range header".to_owned(), + ))); + } + + ChunkClassification::Reject(Error::BadRequest(Some(TOKEN_MISMATCH_MSG.to_owned()))) +} + +struct FinalizeHeaders { + cryptify_token: String, + content_range: ContentRange, +} + +#[rocket::async_trait] +impl<'r> FromRequest<'r> for FinalizeHeaders { + type Error = String; + + async fn from_request( + request: &'r rocket::Request<'_>, + ) -> rocket::request::Outcome { + let cryptify_token = match request.headers().get_one("CryptifyToken") { + Some(cryptify_token) => cryptify_token, + None => { + return rocket::request::Outcome::Error(( + rocket::http::Status::BadRequest, + "Missing Cryptify Token header".into(), + )) + } + } + .to_string(); + + let content_range = match request.headers().get_one("Content-Range") { + Some(content_range) => content_range, + None => { + return rocket::request::Outcome::Error(( + rocket::http::Status::BadRequest, + "Missing content range".into(), + )) + } + }; + + let content_range = match content_range.parse::() { + Ok(v) => v, + Err(e) => { + return rocket::request::Outcome::Error((rocket::http::Status::BadRequest, e)) + } + }; + rocket::request::Outcome::Success(FinalizeHeaders { + cryptify_token, + content_range, + }) + } +} + +#[post("/fileupload/finalize/")] +async fn upload_finalize( + config: &State, + store: &State, + vk: &State>, + metrics: &State>, + headers: FinalizeHeaders, + uuid: &str, +) -> Result<(), Error> { + let state = match store.get(uuid) { + Some(v) => v, + None => return Err(Error::upload_session_not_found(uuid, "expired_or_unknown")), + }; + let mut state = state.lock().await; + + check_cryptify_token(&headers.cryptify_token, &state.cryptify_token)?; + + if headers.content_range.size != Some(state.uploaded) { + return Err(Error::UnprocessableEntity(None)); + } + + let mut file = File::open(Path::new(config.data_dir()).join(uuid)) + .await + .map_err(|e| { + log::error!("could not open upload file for finalize: {}", e); + Error::InternalServerError(Some(GENERIC_INTERNAL_ERROR_MSG.to_owned())) + })? + .compat(); + + let attributes = Unsealer::<_, UnsealerStreamConfig>::new(&mut file, &vk.public_key) + .await + .map_err(|e| { + log::error!("could not read postguard file during finalize: {}", e); + Error::InternalServerError(Some(GENERIC_INTERNAL_ERROR_MSG.to_owned())) + })? + .pub_id + .con; + + // The attribute type carrying the sender's email is configurable + // (postguard#236): test environments use a test-scheme type since pbdf + // credentials cannot be issued outside production. + let email_attribute = config.email_attribute(); + let sender = attributes + .iter() + .find(|x| x.atype == email_attribute) + .ok_or_else(|| { + log::error!( + "finalized upload has no email attribute ({email_attribute}) in postguard metadata" + ); + Error::InternalServerError(Some(GENERIC_INTERNAL_ERROR_MSG.to_owned())) + })? + .value + .clone(); + + let sender_attributes: Vec<(String, String)> = attributes + .into_iter() + .filter(|x| x.atype != email_attribute) + .filter_map(|x| { + let atype = x.atype; + x.value.map(|v| (atype, v)) + }) + .collect(); + + let rolling_limit = if state.api_key_tenant.is_some() { + API_KEY_ROLLING_LIMIT + } else { + ROLLING_LIMIT + }; + let now_secs = chrono::offset::Utc::now().timestamp(); + // Account per-tenant when an API key was validated, otherwise per + // sender email. The tenant key (`api-key:`) prevents a single + // tenant from evading quota by varying sender attributes. + let accounting_key = state + .api_key_tenant + .as_deref() + .map(|t| format!("api-key:{}", t)) + .or_else(|| sender.clone()); + if let Some(key) = accounting_key.as_deref() { + let usage = store.get_usage(key, now_secs); + log::info!( + "Rolling limit check for {} (api_key_tenant={:?}): used={} + current={} vs limit={}", + key, + state.api_key_tenant, + usage.used_bytes, + state.uploaded, + rolling_limit + ); + if usage.used_bytes.saturating_add(state.uploaded) > rolling_limit { + drop(state); + store.remove(uuid); + let _ = rocket::tokio::fs::remove_file(Path::new(config.data_dir()).join(uuid)).await; + let resets_at = usage + .oldest_expires_at + .and_then(|ts| chrono::DateTime::from_timestamp(ts, 0)) + .map(|dt| dt.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)); + return Err(Error::PayloadTooLarge(PayloadTooLargeBody { + error: format!( + "Sender has exceeded the {}-day rolling limit of {} bytes", + ROLLING_WINDOW_SECS / 86_400, + rolling_limit + ), + limit: "rolling_window", + used_bytes: usage.used_bytes, + limit_bytes: rolling_limit, + resets_at, + })); + } + } + + state.sender = sender.clone(); + state.sender_attributes = sender_attributes; + + send_email(config, &state, uuid).await.map_err(|e| { + log::error!("could not send notification email: {}", e); + Error::InternalServerError(Some(GENERIC_INTERNAL_ERROR_MSG.to_owned())) + })?; + + metrics.record_upload(&state.source_channel, state.uploaded); + metrics.record_upload_app(state.client_app.as_deref().unwrap_or(CHANNEL_UNKNOWN)); + + log::info!( + "upload_finalize uuid={} channel={} client_version={:?} app={:?} bytes={}", + uuid, + state.source_channel, + state.client_version, + state.client_app, + state.uploaded + ); + + if let Some(key) = accounting_key { + store.record_upload(key, state.uploaded, now_secs); + } + + Ok(()) +} + +/// Snapshot of an in-flight upload's rolling-token state, returned by +/// `GET /fileupload/{uuid}/status`. The client uses this to rehydrate a +/// session it lost track of (page refresh, tab crash) and feed the next +/// chunk PUT through the idempotent-retry path from #145. `prev_token` +/// and `prev_offset` are `None` until at least one chunk has been +/// committed — in that case the client just resumes from offset 0 with +/// `cryptify_token`. +#[derive(Serialize)] +struct UploadStatusResponse { + uploaded: u64, + cryptify_token: String, + #[serde(skip_serializing_if = "Option::is_none")] + prev_token: Option, + #[serde(skip_serializing_if = "Option::is_none")] + prev_offset: Option, +} + +/// Constant-time string equality. `subtle::ConstantTimeEq` makes the timing +/// independent of where the bytes start to differ — defeats timing oracles +/// on secret comparisons. Used for the recovery token and the `/metrics` +/// bearer token. A length difference returns early (lengths aren't secret). +fn constant_time_eq(presented: &str, expected: &str) -> bool { + use subtle::ConstantTimeEq; + if presented.len() != expected.len() { + return false; + } + presented.as_bytes().ct_eq(expected.as_bytes()).into() +} + +#[get("/fileupload//status")] +async fn upload_status( + store: &State, + uuid: &str, + recovery_token: RecoveryTokenHeader, +) -> Result, Error> { + // Two-step auth-versus-existence ordering: present a 401 for missing / + // malformed credentials regardless of UUID; once the credential is + // structurally present, an unknown UUID *or* a value mismatch both + // surface as 404 with `upload_session_not_found`. That collapsing is + // deliberate — otherwise an attacker with a guessable UUID could send + // any value and read 401 vs 404 to confirm which UUIDs have live + // sessions. + let state = store + .get(uuid) + .ok_or_else(|| Error::upload_session_not_found(uuid, "expired_or_unknown"))?; + let state = state.lock().await; + + if !constant_time_eq(&recovery_token.0, &state.recovery_token) { + // Same body shape as evicted/unknown so the response doesn't leak + // session existence to a token-guessing attacker. + return Err(Error::upload_session_not_found(uuid, "expired_or_unknown")); + } + + let (prev_token, prev_offset) = match state.last_chunk.as_ref() { + Some(last) => (Some(last.prev_token.clone()), Some(last.prev_uploaded)), + None => (None, None), + }; + let response = UploadStatusResponse { + uploaded: state.uploaded, + cryptify_token: state.cryptify_token.clone(), + prev_token, + prev_offset, + }; + + drop(state); + // The whole point of cross-refresh resume is to hand control back to + // the client mid-upload — push the eviction deadline so the very next + // chunk PUT doesn't 404 because the rehydrate window aged out. + store.touch(uuid); + Ok(Json(response)) +} + +/// Extractor for the `X-Recovery-Token` header. Missing or malformed +/// header → 401 from the route handler. Deliberately not reusing the +/// `Authorization: Bearer …` scheme: that channel already carries +/// `PG-…` API-key credentials for the upload-tier flow, and crossing +/// the two would invite middleware misrouting. +struct RecoveryTokenHeader(String); + +#[rocket::async_trait] +impl<'r> FromRequest<'r> for RecoveryTokenHeader { + type Error = (); + async fn from_request(request: &'r rocket::Request<'_>) -> rocket::request::Outcome { + let token = request.headers().get_one("X-Recovery-Token").and_then(|t| { + let trimmed = t.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_owned()) + } + }); + match token { + Some(t) => rocket::request::Outcome::Success(RecoveryTokenHeader(t)), + None => rocket::request::Outcome::Error((rocket::http::Status::Unauthorized, ())), + } + } +} + +#[derive(Serialize)] +#[serde(rename_all = "snake_case")] +struct UsageResponse { + email: String, + used_bytes: u64, + limit_bytes: u64, + window_days: u64, + per_upload_limit_bytes: u64, + resets_at: Option, +} + +#[get("/usage?")] +fn usage( + store: &State, + api_key: ValidatedApiKey, + email: Option, +) -> Json { + let now = chrono::offset::Utc::now().timestamp(); + // Usage is accounted per validated tenant, keyed by the tenant proven via + // the `Authorization` header — never by a caller-supplied email. The + // `ValidatedApiKey` guard has already rejected any unauthenticated caller + // with 401, so there is no way to query usage for an arbitrary address and + // hence no user-enumeration / activity-monitoring oracle. The `email` + // query parameter is retained only so the response can echo it back for + // frontends; it does not influence the lookup. + let lookup_key = format!("api-key:{}", api_key.tenant); + let usage = store.get_usage(&lookup_key, now); + let resets_at = usage + .oldest_expires_at + .and_then(|ts| chrono::DateTime::from_timestamp(ts, 0)) + .map(|dt| dt.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)); + Json(UsageResponse { + email: email.unwrap_or_default(), + used_bytes: usage.used_bytes, + limit_bytes: API_KEY_ROLLING_LIMIT, + window_days: (ROLLING_WINDOW_SECS / 86_400) as u64, + per_upload_limit_bytes: API_KEY_PER_UPLOAD_LIMIT, + resets_at, + }) +} + +/// Body returned by `GET /email-template` for a validated API key that has +/// a template configured on pg-pkg. +#[derive(Serialize)] +struct EmailTemplateResponse { + /// Tenant the API key resolved to on pg-pkg. + tenant_id: String, + /// The email template linked to the key. + email_template: String, +} + +/// Map a validated (or rejected) [`ApiKey`] to the `GET /email-template` +/// outcome. Kept as a pure function so every branch — authorized, no +/// template, rejected key, pg-pkg unreachable — is unit-testable without +/// standing up the HTTP stack or a live pg-pkg. +fn resolve_email_template(api_key: ApiKey) -> Result { + match api_key.tenant { + Some(tenant_id) => match api_key.email_template { + Some(email_template) => Ok(EmailTemplateResponse { + tenant_id, + email_template, + }), + // The key is valid but no template is linked to it on pg-pkg. + None => Err(Error::NotFound(Some( + "No email template is configured for this API key".to_owned(), + ))), + }, + // No tenant: either no/invalid key (validation_failed == false) or + // pg-pkg was unreachable while validating (validation_failed == true). + None if api_key.validation_failed => { + // Keep the pg-pkg dependency detail server-side rather than + // leaking it in the response body (GHSA-r95f-qf3j-xccw); mirrors + // the upload-tier 503 in `upload_chunk`. + log::error!( + "pg-pkg was unreachable while validating the API key for GET /email-template" + ); + Err(Error::ServiceUnavailable(Some( + GENERIC_INTERNAL_ERROR_MSG.to_owned(), + ))) + } + None => Err(Error::Unauthorized(Some( + "A valid `Authorization: Bearer PG-…` API key is required".to_owned(), + ))), + } +} + +/// Return the email template linked to the caller's API key on pg-pkg. The +/// key is validated through the same `ApiKey` request guard the upload +/// endpoints use. Returns 401 when no valid key is presented, 404 when the +/// key is valid but has no template configured, and 503 when pg-pkg could +/// not be reached to validate the key. +#[get("/email-template")] +fn email_template(api_key: ApiKey) -> Result, Error> { + resolve_email_template(api_key).map(Json) +} + +/// Staging-only endpoint that returns the rendered notification email(s) +/// cryptify *would* deliver for an upload, so developers on the staging +/// website can preview the message without an SMTP transport. Gated on +/// `staging_mode = true`; returns `404 Not Found` everywhere else so +/// production reveals no surface. +#[derive(serde::Serialize)] +struct StagingPreviewResponse { + recipients: Vec, + confirmation: Option, +} + +#[get("/staging/preview/")] +async fn staging_preview( + config: &State, + store: &State, + uuid: &str, +) -> Result, rocket::http::Status> { + if !config.staging_mode() { + return Err(rocket::http::Status::NotFound); + } + let state_arc = store.get(uuid).ok_or(rocket::http::Status::NotFound)?; + let state = state_arc.lock().await; + + let mut recipients = Vec::with_capacity(state.recipients.iter().count()); + for mailbox in state.recipients.iter() { + let email = mailbox.email.to_string(); + match render_recipient_email(&state, config, &email, uuid) { + Ok(r) => recipients.push(r), + Err(e) => log::warn!( + "staging_preview: failed to render recipient {} for {}: {}", + email, + uuid, + e + ), + } + } + + let confirmation = if state.confirm { + match render_confirmation_email(&state, config, uuid) { + Ok(opt) => opt, + Err(e) => { + log::warn!( + "staging_preview: failed to render confirmation for {}: {}", + uuid, + e + ); + None + } + } + } else { + None + }; + + Ok(Json(StagingPreviewResponse { + recipients, + confirmation, + })) +} + +/// Parsed byte range derived from an HTTP `Range` header and the resource's +/// total size. Both endpoints are inclusive, per RFC 7233 §2.1. +#[derive(Debug, PartialEq, Eq)] +struct ByteRange { + start: u64, + end_inclusive: u64, +} + +impl ByteRange { + fn len(&self) -> u64 { + self.end_inclusive - self.start + 1 + } +} + +/// Parse a single-range `Range` header against a resource of `total_size` +/// bytes. Returns `None` for malformed, multi-range, or unsatisfiable +/// requests — the caller turns that into a 416. Supports `bytes=N-M`, +/// `bytes=N-`, and the suffix form `bytes=-N`. Multi-range is rejected +/// deliberately; resume only needs one range and the multipart/byteranges +/// response is not worth the complexity. +fn parse_range_header(header: &str, total_size: u64) -> Option { + let rest = header.strip_prefix("bytes=")?.trim(); + if rest.contains(',') { + return None; + } + let (s, e) = rest.split_once('-')?; + let s = s.trim(); + let e = e.trim(); + if s.is_empty() { + let n: u64 = e.parse().ok()?; + if n == 0 || total_size == 0 { + return None; + } + let n = n.min(total_size); + return Some(ByteRange { + start: total_size - n, + end_inclusive: total_size - 1, + }); + } + let start: u64 = s.parse().ok()?; + if start >= total_size { + return None; + } + let end_inclusive = if e.is_empty() { + total_size - 1 + } else { + let v: u64 = e.parse().ok()?; + v.min(total_size - 1) + }; + if end_inclusive < start { + return None; + } + Some(ByteRange { + start, + end_inclusive, + }) +} + +/// Cryptify stores upload payloads as flat UUID-named files under +/// `data_dir`. Reject anything that could escape that or address an +/// unintended path before touching the filesystem. +fn is_safe_download_segment(name: &str) -> bool { + !name.is_empty() + && name.len() <= 128 + && !name.contains('/') + && !name.contains('\\') + && !name.contains('\0') + && name != ".." + && name != "." +} + +/// Captures the inbound `Range` header (if any) without failing the request +/// when it's absent. +struct RangeHeader(Option); + +#[rocket::async_trait] +impl<'r> FromRequest<'r> for RangeHeader { + type Error = std::convert::Infallible; + async fn from_request( + req: &'r rocket::Request<'_>, + ) -> rocket::request::Outcome { + rocket::request::Outcome::Success(RangeHeader( + req.headers().get_one("Range").map(|s| s.to_owned()), + )) + } +} + +/// Wraps a pre-built `rocket::Response` so the route handler below can +/// return it as a `Responder`. +struct RawResponse(rocket::Response<'static>); + +impl<'r> Responder<'r, 'static> for RawResponse { + fn respond_to(self, _req: &'r rocket::Request<'_>) -> rocket::response::Result<'static> { + Ok(self.0) + } +} + +#[get("/filedownload/")] +async fn download( + filename: &str, + range: RangeHeader, + config: &State, +) -> Result { + use rocket::http::Status; + use std::io::SeekFrom; + + if !is_safe_download_segment(filename) { + return Err(Status::NotFound); + } + let path = Path::new(config.data_dir()).join(filename); + let mut file = File::open(&path).await.map_err(|_| Status::NotFound)?; + let total_size = file.metadata().await.map_err(|_| Status::NotFound)?.len(); + + let mut builder = rocket::Response::build(); + builder.raw_header("Accept-Ranges", "bytes"); + + match range.0 { + Some(header) => match parse_range_header(&header, total_size) { + Some(br) => { + file.seek(SeekFrom::Start(br.start)) + .await + .map_err(|_| Status::InternalServerError)?; + let len = br.len(); + builder + .status(Status::PartialContent) + .raw_header( + "Content-Range", + format!("bytes {}-{}/{}", br.start, br.end_inclusive, total_size), + ) + .raw_header("Content-Length", len.to_string()) + .streamed_body(file.take(len)); + } + None => { + builder + .status(Status::RangeNotSatisfiable) + .raw_header("Content-Range", format!("bytes */{}", total_size)); + } + }, + None => { + builder + .status(Status::Ok) + .raw_header("Content-Length", total_size.to_string()) + .streamed_body(file); + } + } + Ok(RawResponse(builder.finalize())) +} + +/// Base Rocket figment shared by the production launch path and the integration +/// test harness. Body-size limits are applied later in [`build_rocket`] once +/// the merged config has been extracted (chunk_size is now configurable via +/// TOML, so it isn't known at this point in the test path). +pub fn default_figment() -> Figment { + rocket::Config::figment() +} + +/// Build the CORS fairing. Shared by the production launch path and the +/// preflight smoke tests so the header allow-list under test is the one +/// actually deployed (a test-local copy is how `X-Cryptify-Source` regressed +/// unnoticed when the website started sending it). +fn build_cors(allowed_origins: AllowedOrigins) -> rocket_cors::Cors { + CorsOptions::default() + .allowed_origins(allowed_origins) + .allowed_methods( + vec![Method::Get, Method::Post, Method::Put, Method::Delete] + .into_iter() + .map(From::from) + .collect(), + ) + // Browser preflight needs to allow our custom request headers. + // `Authorization` is here for the Bearer-API-key tier flow; + // `cryptifytoken`, `content-range`, and `content-type` ride on + // chunk PUTs; `x-recovery-token` authenticates GET /…/status; + // `x-cryptify-source` tags requests for per-channel metrics. + .allowed_headers(AllowedHeaders::some(&[ + "Authorization", + "Content-Type", + "Content-Range", + "CryptifyToken", + "Range", + "X-Cryptify-Source", + "X-Recovery-Token", + // Browser clients (pg-js) send this on every request; without it + // in the preflight allowlist the browser blocks cross-origin + // uploads. Captured for the per-app upload metric + logs. + "X-POSTGUARD-CLIENT-VERSION", + ])) + .expose_headers(["cryptifytoken"].iter().map(ToString::to_string).collect()) + .max_age(Some(86400)) + .to_cors() + .expect("unable to configure CORS") +} + +/// Every route the service mounts. Single source of truth so the +/// `api-description.yaml` drift test can compare the spec against the routes +/// production actually serves instead of a hand-copied list. +fn api_routes() -> Vec { + routes![ + health, + metrics_endpoint, + upload_init, + upload_chunk, + upload_finalize, + upload_status, + usage, + email_template, + download, + staging_preview + ] +} + +/// Build a Rocket instance from a pre-loaded config figment and verifying key. +/// +/// Extracted so integration tests can inject their own figment (temp data_dir, +/// stubbed email sending) and their own `VerifyingKey` (from +/// `pg_core::test::TestSetup`) without needing a live PKG at startup. +pub fn build_rocket(figment: Figment, vk: Parameters) -> Rocket { + let config = figment + .extract::() + .expect("Missing configuration"); + + // Raise Rocket's default body-size limits so chunked uploads up to + // chunk_size do not trip "Data limit reached while reading the request + // body". `data.open((end - start).bytes())` already caps the per-request + // read; this lifts the framework-level cap that runs before it. + // A small headroom above chunk_size leaves room for HTTP overhead. + let chunk_size = config.chunk_size(); + let limits = rocket::data::Limits::default() + .limit("bytes", (chunk_size + 1024 * 1024).bytes()) + .limit("data-form", (chunk_size + 1024 * 1024).bytes()) + .limit("file", (chunk_size + 1024 * 1024).bytes()); + + let rocket = rocket::custom(figment.merge(("limits", limits))); + + let cors = build_cors(AllowedOrigins::some_regex(&[config.allowed_origins()])); + + let metrics = Arc::new(Metrics::new()); + rocket::tokio::spawn(storage_sampler( + metrics.clone(), + std::path::PathBuf::from(config.data_dir()), + Duration::from_secs(config.metrics_scan_interval_secs()), + )); + + let pkg_client = PkgClient::new(config.pkg_url().to_string()); + + rocket + .attach(cors) + .mount("/", api_routes()) + .attach(AdHoc::config::()) + .manage(Store::with_idle_ttl( + std::time::Duration::from_secs(config.session_ttl_secs()), + metrics.clone(), + config.usage_db(), + )) + .manage(vk) + .manage(pkg_client) + .manage(metrics) +} + +/// Fetch the IBS verifying key from pg-pkg, retrying transient failures +/// (unreachable, non-2xx, unparsable body) with exponential backoff instead of +/// panicking on the first attempt: a brief PKG unavailability window — e.g. a +/// rolling deploy where cryptify comes up before pg-pkg — must not take +/// cryptify down with it. Returns `None` once the budget is exhausted. +async fn try_fetch_verifying_key( + pkg_params_url: &str, + budget: Duration, + initial_backoff: Duration, + max_backoff: Duration, +) -> Option> { + let deadline = rocket::tokio::time::Instant::now() + budget; + let mut backoff = initial_backoff; + + loop { + // minreq is blocking; keep it off the async workers. + let url = pkg_params_url.to_owned(); + let result = + rocket::tokio::task::spawn_blocking(move || minreq::get(&url).with_timeout(10).send()) + .await + .expect("blocking fetch task panicked"); + + match result { + // minreq returns Ok for any completed HTTP exchange, so surface a + // non-2xx (e.g. a 503 while the PKG is still booting) as a status + // problem rather than a confusing parse failure. + Ok(response) if !(200..300).contains(&response.status_code) => log::warn!( + "PKG at {pkg_params_url} returned status {} — will retry", + response.status_code + ), + Ok(response) => match response.json::>() { + Ok(vk) => return Some(vk), + Err(e) => log::warn!( + "Failed to parse verification key from {pkg_params_url}: {e} — will retry" + ), + }, + Err(e) => log::warn!("Failed to reach PKG at {pkg_params_url}: {e} — will retry"), + } + + let now = rocket::tokio::time::Instant::now(); + if now + backoff >= deadline { + return None; + } + rocket::tokio::time::sleep(backoff).await; + backoff = (backoff * 2).min(max_backoff); + } +} + +#[launch] +async fn rocket() -> _ { + let figment = default_figment(); + let config = figment + .extract::() + .expect("Missing configuration"); + + if config.metrics_token().is_none() { + log::warn!( + "metrics_token is not set — /metrics is publicly accessible without authentication. \ + Set `metrics_token` in config (or ROCKET_METRICS_TOKEN) to require a Bearer token." + ); + } + + let pkg_params_url = format!( + "{}/v2/sign/parameters", + config.pkg_url().trim_end_matches('/') + ); + let vk = try_fetch_verifying_key( + &pkg_params_url, + PKG_PARAMS_RETRY_BUDGET, + PKG_PARAMS_INITIAL_BACKOFF, + PKG_PARAMS_MAX_BACKOFF, + ) + .await + .unwrap_or_else(|| { + panic!( + "Could not fetch a valid verification key from {} within {:?}", + pkg_params_url, PKG_PARAMS_RETRY_BUDGET + ) + }); + + build_rocket(figment, vk) +} + +#[cfg(test)] +mod tests { + use super::*; + use rocket::http::{Header, Status}; + use rocket::local::asynchronous::Client; + + // Test-only route exercising the FinalizeHeaders extractor in isolation. + // Echoes the extracted fields so the test can verify successful parsing. + #[post("/__test/finalize_headers")] + fn finalize_headers_echo(h: FinalizeHeaders) -> String { + format!("{}|{}", h.cryptify_token, h.content_range.size.unwrap_or(0)) + } + + async fn headers_client() -> Client { + let r = rocket::build().mount("/", routes![finalize_headers_echo]); + Client::tracked(r).await.expect("valid rocket") + } + + #[rocket::async_test] + async fn finalize_headers_reject_missing_cryptify_token() { + let client = headers_client().await; + let res = client + .post("/__test/finalize_headers") + .header(Header::new("Content-Range", "bytes 0-99/100")) + .dispatch() + .await; + assert_eq!(res.status(), Status::BadRequest); + } + + #[rocket::async_test] + async fn finalize_headers_reject_missing_content_range() { + let client = headers_client().await; + let res = client + .post("/__test/finalize_headers") + .header(Header::new("CryptifyToken", "abc123")) + .dispatch() + .await; + assert_eq!(res.status(), Status::BadRequest); + } + + #[rocket::async_test] + async fn finalize_headers_reject_malformed_content_range() { + let client = headers_client().await; + let res = client + .post("/__test/finalize_headers") + .header(Header::new("CryptifyToken", "abc123")) + .header(Header::new("Content-Range", "not a real range")) + .dispatch() + .await; + assert_eq!(res.status(), Status::BadRequest); + } + + #[rocket::async_test] + async fn finalize_headers_extract_both_headers() { + let client = headers_client().await; + let res = client + .post("/__test/finalize_headers") + .header(Header::new("CryptifyToken", "deadbeef")) + .header(Header::new("Content-Range", "bytes 0-99/100")) + .dispatch() + .await; + assert_eq!(res.status(), Status::Ok); + assert_eq!(res.into_string().await.as_deref(), Some("deadbeef|100")); + } + + #[test] + fn content_range_parses_well_formed_range() { + let cr: ContentRange = "bytes 0-99/100".parse().unwrap(); + assert_eq!(cr.start, Some(0)); + assert_eq!(cr.end, Some(99)); + assert_eq!(cr.size, Some(100)); + } + + #[test] + fn content_range_accepts_wildcard_range() { + let cr: ContentRange = "bytes */100".parse().unwrap(); + assert_eq!(cr.start, None); + assert_eq!(cr.end, None); + assert_eq!(cr.size, Some(100)); + } + + #[test] + fn content_range_accepts_wildcard_size() { + let cr: ContentRange = "bytes 0-99/*".parse().unwrap(); + assert_eq!(cr.start, Some(0)); + assert_eq!(cr.end, Some(99)); + assert_eq!(cr.size, None); + } + + #[test] + fn content_range_rejects_wrong_unit() { + assert!("items 0-99/100".parse::().is_err()); + } + + #[test] + fn content_range_rejects_empty_string() { + assert!("".parse::().is_err()); + } + + #[test] + fn check_cryptify_token_accepts_matching_token() { + assert!(check_cryptify_token("abc123", "abc123").is_ok()); + } + + #[test] + fn check_cryptify_token_rejects_mismatched_token() { + let result = check_cryptify_token("wrong", "expected"); + match result { + Err(Error::BadRequest(Some(msg))) => { + assert_eq!(msg, "Cryptify Token header does not match"); + } + other => panic!("expected BadRequest, got {:?}", other), + } + } + + #[test] + fn check_cryptify_token_rejects_empty_header_when_token_expected() { + assert!(matches!( + check_cryptify_token("", "expected"), + Err(Error::BadRequest(_)) + )); + } + + #[test] + fn check_cryptify_token_is_case_sensitive() { + assert!(matches!( + check_cryptify_token("ABC123", "abc123"), + Err(Error::BadRequest(_)) + )); + } + + #[test] + fn compute_hash_is_deterministic() { + let h1 = compute_hash(b"token", b"data"); + let h2 = compute_hash(b"token", b"data"); + assert_eq!(h1, h2); + assert_eq!(h1.len(), 64); + } + + #[test] + fn compute_hash_differs_for_different_tokens() { + assert_ne!( + compute_hash(b"token-a", b"data"), + compute_hash(b"token-b", b"data") + ); + } + + #[test] + fn cryptify_tokens_match_accepts_equal_and_rejects_different() { + assert!(cryptify_tokens_match("abc123", "abc123")); + assert!(!cryptify_tokens_match("abc123", "abc124")); + // Differing lengths must not match (and must not panic). + assert!(!cryptify_tokens_match("abc", "abc123")); + assert!(!cryptify_tokens_match("", "abc")); + } + + // Mounts only the `/usage` route with the state its guard depends on + // (`Store` + `PkgClient`). The `PkgClient` url is never contacted for the + // unauthenticated case: `PkgClient::validate(None)` short-circuits to + // `NoCredentials` before any network call. + async fn usage_client() -> Client { + let rocket = rocket::build() + .mount("/", routes![usage]) + .manage(Store::new(Arc::new(Metrics::new()))) + .manage(PkgClient::new("http://localhost:1".to_string())); + Client::tracked(rocket).await.expect("valid rocket") + } + + // Regression test for GHSA-5rhx-xgvv-h78h: an unauthenticated caller (no + // `Authorization: Bearer PG-…` header) must be rejected with 401 rather + // than served usage for an arbitrary, caller-supplied email address. + #[rocket::async_test] + async fn usage_rejects_unauthenticated_request() { + let client = usage_client().await; + let res = client + .get("/usage?email=alice@example.com") + .dispatch() + .await; + assert_eq!( + res.status(), + Status::Unauthorized, + "unauthenticated /usage must be 401, not a usage oracle for an arbitrary email" + ); + } + + // Builds a minimal rocket instance that mounts only `upload_init` and the + // state it depends on, with a fresh per-test `data_dir` under + // `std::env::temp_dir()`. Used to verify upload_init's rejection path + // does not leave orphan files behind (issue #125). + async fn upload_init_client(data_dir: &std::path::Path) -> Client { + use rocket::figment::{providers::Serialized, Figment}; + + std::fs::create_dir_all(data_dir).expect("create test data_dir"); + + let figment = Figment::from(rocket::Config::default()).merge(Serialized::defaults( + serde_json::json!({ + "server_url": "http://localhost", + "data_dir": data_dir.to_str().unwrap(), + "email_from": "Test ", + "smtp_url": "localhost", + "smtp_port": 1025u16, + "allowed_origins": ".*", + "pkg_url": "http://localhost", + }), + )); + + let rocket = rocket::custom(figment) + .mount("/", routes![upload_init]) + .attach(AdHoc::config::()) + .manage(Store::new(Arc::new(Metrics::new()))); + + Client::tracked(rocket).await.expect("valid rocket") + } + + fn dir_entry_count(dir: &std::path::Path) -> usize { + std::fs::read_dir(dir) + .map(|rd| rd.filter_map(Result::ok).count()) + .unwrap_or(0) + } + + // Regression test for issue #125: a malformed recipient must not leave + // an empty file behind in data_dir. + #[rocket::async_test] + async fn upload_init_bad_recipient_does_not_create_file() { + let data_dir = std::env::temp_dir().join(format!( + "cryptify-test-{}", + uuid::Uuid::new_v4().hyphenated() + )); + let client = upload_init_client(&data_dir).await; + + assert_eq!(dir_entry_count(&data_dir), 0, "data_dir starts empty"); + + let res = client + .post("/fileupload/init") + .header(rocket::http::ContentType::JSON) + .body( + r#"{"recipient":"not a valid address","mailContent":"hi","mailLang":"EN","confirm":false}"#, + ) + .dispatch() + .await; + + assert_eq!(res.status(), Status::BadRequest); + assert_eq!( + dir_entry_count(&data_dir), + 0, + "no orphan file should be created when recipient parsing fails" + ); + + let _ = std::fs::remove_dir_all(&data_dir); + } + + // Happy-path complement: a valid recipient still creates exactly one file + // in data_dir, so the reorder did not regress the success case. + #[rocket::async_test] + async fn upload_init_good_recipient_creates_file() { + let data_dir = std::env::temp_dir().join(format!( + "cryptify-test-{}", + uuid::Uuid::new_v4().hyphenated() + )); + let client = upload_init_client(&data_dir).await; + + let res = client + .post("/fileupload/init") + .header(rocket::http::ContentType::JSON) + .body( + r#"{"recipient":"alice@example.com","mailContent":"hi","mailLang":"EN","confirm":false}"#, + ) + .dispatch() + .await; + + assert_eq!(res.status(), Status::Ok); + assert_eq!(dir_entry_count(&data_dir), 1); + + let _ = std::fs::remove_dir_all(&data_dir); + } + + // Builds a rocket instance with both upload_init and upload_status + // mounted. Used for the cross-refresh-resume status-endpoint tests. + async fn status_client(data_dir: &std::path::Path) -> Client { + use rocket::figment::{providers::Serialized, Figment}; + + std::fs::create_dir_all(data_dir).expect("create test data_dir"); + + let figment = Figment::from(rocket::Config::default()).merge(Serialized::defaults( + serde_json::json!({ + "server_url": "http://localhost", + "data_dir": data_dir.to_str().unwrap(), + "email_from": "Test ", + "smtp_url": "localhost", + "smtp_port": 1025u16, + "allowed_origins": ".*", + "pkg_url": "http://localhost", + }), + )); + + let rocket = rocket::custom(figment) + .mount("/", routes![upload_init, upload_status]) + .attach(AdHoc::config::()) + .manage(Store::new(Arc::new(Metrics::new()))); + + Client::tracked(rocket).await.expect("valid rocket") + } + + /// Variant of `status_client` that also attaches the production cors + /// fairing, so tests can exercise browser-preflight behaviour for the + /// new `/status` route. + async fn status_client_with_cors(data_dir: &std::path::Path) -> Client { + use rocket::figment::{providers::Serialized, Figment}; + + std::fs::create_dir_all(data_dir).expect("create test data_dir"); + + let figment = Figment::from(rocket::Config::default()).merge(Serialized::defaults( + serde_json::json!({ + "server_url": "http://localhost", + "data_dir": data_dir.to_str().unwrap(), + "email_from": "Test ", + "smtp_url": "localhost", + "smtp_port": 1025u16, + "allowed_origins": ".*", + "pkg_url": "http://localhost", + }), + )); + + let cors = build_cors(AllowedOrigins::all()); + + let rocket = rocket::custom(figment) + .attach(cors) + .mount("/", routes![upload_init, upload_status]) + .attach(AdHoc::config::()) + .manage(Store::new(Arc::new(Metrics::new()))); + + Client::tracked(rocket).await.expect("valid rocket") + } + + /// Init an upload via the test client and return `(uuid, recovery_token)`. + async fn init_upload(client: &Client) -> (String, String) { + let res = client + .post("/fileupload/init") + .header(rocket::http::ContentType::JSON) + .body( + r#"{"recipient":"alice@example.com","mailContent":"hi","mailLang":"EN","confirm":false}"#, + ) + .dispatch() + .await; + assert_eq!(res.status(), Status::Ok); + let body: serde_json::Value = res.into_json().await.expect("init body"); + let uuid = body["uuid"].as_str().expect("uuid in init body").to_owned(); + let recovery_token = body["recovery_token"] + .as_str() + .expect("recovery_token in init body") + .to_owned(); + (uuid, recovery_token) + } + + #[rocket::async_test] + async fn status_returns_initial_state_after_init() { + let data_dir = std::env::temp_dir().join(format!( + "cryptify-test-{}", + uuid::Uuid::new_v4().hyphenated() + )); + let client = status_client(&data_dir).await; + + let (uuid, recovery_token) = init_upload(&client).await; + + let res = client + .get(format!("/fileupload/{}/status", uuid)) + .header(Header::new("X-Recovery-Token", recovery_token)) + .dispatch() + .await; + assert_eq!(res.status(), Status::Ok); + + let body: serde_json::Value = res.into_json().await.expect("status body"); + assert_eq!(body["uploaded"].as_u64(), Some(0)); + assert!(body["cryptify_token"].as_str().is_some()); + // No chunk committed yet — prev_token / prev_offset are absent. + assert!(body.get("prev_token").is_none()); + assert!(body.get("prev_offset").is_none()); + + let _ = std::fs::remove_dir_all(&data_dir); + } + + #[rocket::async_test] + async fn status_returns_401_when_recovery_header_missing() { + let data_dir = std::env::temp_dir().join(format!( + "cryptify-test-{}", + uuid::Uuid::new_v4().hyphenated() + )); + let client = status_client(&data_dir).await; + + let (uuid, _) = init_upload(&client).await; + + let res = client + .get(format!("/fileupload/{}/status", uuid)) + .dispatch() + .await; + assert_eq!(res.status(), Status::Unauthorized); + + let _ = std::fs::remove_dir_all(&data_dir); + } + + #[rocket::async_test] + async fn status_returns_401_when_recovery_header_blank() { + let data_dir = std::env::temp_dir().join(format!( + "cryptify-test-{}", + uuid::Uuid::new_v4().hyphenated() + )); + let client = status_client(&data_dir).await; + + let (uuid, _) = init_upload(&client).await; + + let res = client + .get(format!("/fileupload/{}/status", uuid)) + .header(Header::new("X-Recovery-Token", " ")) + .dispatch() + .await; + assert_eq!(res.status(), Status::Unauthorized); + + let _ = std::fs::remove_dir_all(&data_dir); + } + + // Wrong recovery token must return the same shape as an unknown UUID + // — otherwise an attacker can probe for live UUIDs. + #[rocket::async_test] + async fn status_returns_404_for_token_mismatch_same_as_unknown_uuid() { + let data_dir = std::env::temp_dir().join(format!( + "cryptify-test-{}", + uuid::Uuid::new_v4().hyphenated() + )); + let client = status_client(&data_dir).await; + + let (uuid, _) = init_upload(&client).await; + + // Real UUID, wrong token. + let res = client + .get(format!("/fileupload/{}/status", uuid)) + .header(Header::new("X-Recovery-Token", "00".repeat(32))) + .dispatch() + .await; + assert_eq!(res.status(), Status::NotFound); + let body_real: serde_json::Value = res.into_json().await.expect("404 body"); + assert_eq!( + body_real["error"].as_str(), + Some("upload_session_not_found") + ); + + // Unknown UUID, any token. + let res = client + .get(format!( + "/fileupload/{}/status", + uuid::Uuid::new_v4().hyphenated() + )) + .header(Header::new("X-Recovery-Token", "ff".repeat(32))) + .dispatch() + .await; + assert_eq!(res.status(), Status::NotFound); + let body_fake: serde_json::Value = res.into_json().await.expect("404 body"); + assert_eq!( + body_fake["error"].as_str(), + Some("upload_session_not_found") + ); + assert_eq!(body_real["error"], body_fake["error"]); + + let _ = std::fs::remove_dir_all(&data_dir); + } + + #[rocket::async_test] + async fn constant_time_eq_helper() { + // The function under test is the constant-time wrapper itself — + // we can't observe timing in a unit test, but we can pin the + // value-equality semantics so a future refactor doesn't silently + // turn it into `presented == expected`. + assert!(constant_time_eq("abc123", "abc123")); + assert!(!constant_time_eq("abc123", "abc124")); + assert!(!constant_time_eq("abc123", "abc12")); // length mismatch + assert!(!constant_time_eq("", "abc")); + assert!(constant_time_eq("", "")); + } + + // Browser preflight regression: design AC for #146 explicitly required + // a CORS smoke test so the `X-Recovery-Token` allow-list entry can't + // silently regress. Sends an `OPTIONS /fileupload/{uuid}/status` + // preflight and asserts the response advertises `X-Recovery-Token` + // among `Access-Control-Allow-Headers`. + #[rocket::async_test] + async fn status_preflight_advertises_x_recovery_token() { + let data_dir = std::env::temp_dir().join(format!( + "cryptify-test-{}", + uuid::Uuid::new_v4().hyphenated() + )); + let client = status_client_with_cors(&data_dir).await; + + let res = client + .req( + rocket::http::Method::Options, + "/fileupload/00000000-0000-0000-0000-000000000000/status", + ) + .header(Header::new("Origin", "https://example.com")) + .header(Header::new("Access-Control-Request-Method", "GET")) + .header(Header::new( + "Access-Control-Request-Headers", + "X-Recovery-Token", + )) + .dispatch() + .await; + + // rocket_cors echoes successful preflights back as 2xx. + assert!( + res.status().code < 400, + "expected 2xx preflight, got {}", + res.status() + ); + let allow_headers = res + .headers() + .get_one("Access-Control-Allow-Headers") + .expect("CORS allow-headers in preflight response"); + // Header names compare case-insensitively per RFC 7230, but the + // standard cors fairing emits the names verbatim from our config. + let allow_headers_lc = allow_headers.to_ascii_lowercase(); + assert!( + allow_headers_lc.contains("x-recovery-token"), + "Access-Control-Allow-Headers `{}` should include x-recovery-token", + allow_headers + ); + + let _ = std::fs::remove_dir_all(&data_dir); + } + + // Browser preflight regression: the website tags its uploads with + // `X-Cryptify-Source` (postguard-website#228), which rides on every + // pg-js request including `POST /fileupload/init`. If the header drops + // out of the CORS allow-list, rocket_cors rejects the preflight with a + // 403 that carries no `Access-Control-Allow-Origin`, and browsers + // refuse the upload before it starts. + #[rocket::async_test] + async fn init_preflight_advertises_x_cryptify_source() { + let data_dir = std::env::temp_dir().join(format!( + "cryptify-test-{}", + uuid::Uuid::new_v4().hyphenated() + )); + let client = status_client_with_cors(&data_dir).await; + + let res = client + .req(rocket::http::Method::Options, "/fileupload/init") + .header(Header::new("Origin", "https://example.com")) + .header(Header::new("Access-Control-Request-Method", "POST")) + .header(Header::new( + "Access-Control-Request-Headers", + "Content-Type, X-Cryptify-Source", + )) + .dispatch() + .await; + + assert!( + res.status().code < 400, + "expected 2xx preflight, got {}", + res.status() + ); + let allow_headers = res + .headers() + .get_one("Access-Control-Allow-Headers") + .expect("CORS allow-headers in preflight response"); + let allow_headers_lc = allow_headers.to_ascii_lowercase(); + assert!( + allow_headers_lc.contains("x-cryptify-source"), + "Access-Control-Allow-Headers `{}` should include x-cryptify-source", + allow_headers + ); + + let _ = std::fs::remove_dir_all(&data_dir); + } + + // Design AC for #146: a successful `/status` call must reset the idle + // eviction deadline (otherwise rehydrate succeeds, then the very next + // chunk PUT 404s because the session aged out between the GET and the + // PUT). Inspect the deadline directly via the test-only accessor. + #[rocket::async_test] + async fn status_extends_eviction_deadline() { + let data_dir = std::env::temp_dir().join(format!( + "cryptify-test-{}", + uuid::Uuid::new_v4().hyphenated() + )); + let client = status_client(&data_dir).await; + + let (uuid, recovery_token) = init_upload(&client).await; + + let store = client.rocket().state::().expect("Store managed"); + let before = store + .deadline_for(&uuid) + .expect("session has a deadline after init"); + + // tokio::time::Instant has millisecond resolution on most + // platforms; sleep enough that a fresh `now() + ttl` is strictly + // later than the value captured at init. + rocket::tokio::time::sleep(Duration::from_millis(10)).await; + + let res = client + .get(format!("/fileupload/{}/status", uuid)) + .header(Header::new("X-Recovery-Token", recovery_token)) + .dispatch() + .await; + assert_eq!(res.status(), Status::Ok); + + let after = store + .deadline_for(&uuid) + .expect("session still alive after status call"); + assert!( + after > before, + "successful /status should extend the eviction deadline" + ); + + let _ = std::fs::remove_dir_all(&data_dir); + } + + // Negative complement: failed auth (wrong recovery token) must NOT + // extend the deadline. Otherwise an attacker with a known UUID could + // keep a session alive past its eviction window just by hitting + // `/status` with bogus tokens. + #[rocket::async_test] + async fn status_does_not_extend_deadline_on_token_mismatch() { + let data_dir = std::env::temp_dir().join(format!( + "cryptify-test-{}", + uuid::Uuid::new_v4().hyphenated() + )); + let client = status_client(&data_dir).await; + + let (uuid, _) = init_upload(&client).await; + + let store = client.rocket().state::().expect("Store managed"); + let before = store + .deadline_for(&uuid) + .expect("session has a deadline after init"); + + rocket::tokio::time::sleep(Duration::from_millis(10)).await; + + let res = client + .get(format!("/fileupload/{}/status", uuid)) + .header(Header::new("X-Recovery-Token", "00".repeat(32))) + .dispatch() + .await; + assert_eq!(res.status(), Status::NotFound); + + let after = store + .deadline_for(&uuid) + .expect("session still alive (token mismatch doesn't evict)"); + assert_eq!( + before, after, + "failed-auth /status must not extend the deadline" + ); + + let _ = std::fs::remove_dir_all(&data_dir); + } + + fn empty_filestate(uploaded: u64, current_token: &str) -> FileState { + FileState { + uploaded, + cryptify_token: current_token.to_owned(), + expires: 0, + recipients: lettre::message::Mailboxes::new(), + mail_content: String::new(), + mail_lang: email::Language::En, + sender: None, + sender_attributes: Vec::new(), + confirm: false, + source_channel: String::new(), + client_version: None, + client_app: None, + notify_recipients: true, + api_key_tenant: None, + api_key_validation_failed: false, + last_chunk: None, + recovery_token: String::new(), + } + } + + fn filestate_with_last_chunk( + uploaded: u64, + current_token: &str, + last: LastChunkRecord, + ) -> FileState { + let mut s = empty_filestate(uploaded, current_token); + s.last_chunk = Some(last); + s + } + + /// Build a `LastChunkRecord` whose `response_token` correctly encodes + /// `prev_token + body`, the same construction the production handler + /// uses. Tests use this so the replay path's hash check passes on a + /// genuine retry and fails when the body is tampered with. + fn last_chunk_for(prev_token: &str, prev_uploaded: u64, body: &[u8]) -> LastChunkRecord { + LastChunkRecord { + prev_token: prev_token.to_owned(), + prev_uploaded, + response_token: compute_hash(prev_token.as_bytes(), body), + } + } + + #[test] + fn classify_normal_next_chunk() { + let state = empty_filestate(100, "tok-current"); + match classify_chunk_request(&state, "tok-current", 100, b"chunk") { + ChunkClassification::NormalNext => {} + _ => panic!("expected NormalNext"), + } + } + + #[test] + fn classify_replays_last_chunk_on_matching_retry() { + let body = b"hello world"; + let last = last_chunk_for("tok-prev", 100, body); + let response_token = last.response_token.clone(); + let state = filestate_with_last_chunk(100 + body.len() as u64, &response_token, last); + match classify_chunk_request(&state, "tok-prev", 100, body) { + ChunkClassification::ReplayLastChunk(t) => assert_eq!(t, response_token), + _ => panic!("expected ReplayLastChunk"), + } + } + + #[test] + fn classify_rejects_retry_with_different_body() { + let body = b"original"; + let last = last_chunk_for("tok-prev", 100, body); + let response_token = last.response_token.clone(); + let state = filestate_with_last_chunk(100 + body.len() as u64, &response_token, last); + let tampered = b"tampered"; + let result = classify_chunk_request(&state, "tok-prev", 100, tampered); + match result { + ChunkClassification::Reject(Error::BadRequest(Some(msg))) => { + assert!(msg.contains("body differs"), "got: {}", msg); + } + _ => panic!("expected BadRequest about body differs"), + } + } + + #[test] + fn classify_rejects_retry_with_different_length() { + // Same prev_token + start, but a shorter body. The recomputed + // rolling hash won't match, so the body-differs path catches this + // case too — we no longer need a length-specific record. + let body = b"original"; + let last = last_chunk_for("tok-prev", 100, body); + let response_token = last.response_token.clone(); + let state = filestate_with_last_chunk(100 + body.len() as u64, &response_token, last); + let result = classify_chunk_request(&state, "tok-prev", 100, b"short"); + match result { + ChunkClassification::Reject(Error::BadRequest(Some(msg))) => { + assert!(msg.contains("body differs"), "got: {}", msg); + } + _ => panic!("expected BadRequest about body differs"), + } + } + + #[test] + fn classify_rejects_offset_mismatch_with_no_replay() { + // No last_chunk recorded → offset mismatch is just the regular 400. + let state = empty_filestate(100, "tok-current"); + let result = classify_chunk_request(&state, "tok-current", 50, b"abc"); + match result { + ChunkClassification::Reject(Error::BadRequest(Some(msg))) => { + assert_eq!(msg, "Incorrect Content-Range header"); + } + _ => panic!("expected BadRequest about Content-Range"), + } + } + + #[test] + fn classify_rejects_token_mismatch_at_correct_offset() { + let state = empty_filestate(100, "tok-current"); + let result = classify_chunk_request(&state, "tok-wrong", 100, b"chunk"); + match result { + ChunkClassification::Reject(Error::BadRequest(Some(msg))) => { + assert_eq!(msg, TOKEN_MISMATCH_MSG); + } + _ => panic!("expected BadRequest about token mismatch"), + } + } + + #[test] + fn classify_does_not_replay_when_prev_token_does_not_match() { + // Last chunk exists but the retry presents a *different* prev_token. + // Falls through to the regular offset-mismatch rejection. + let body = b"original"; + let last = last_chunk_for("tok-prev", 100, body); + let response_token = last.response_token.clone(); + let state = filestate_with_last_chunk(100 + body.len() as u64, &response_token, last); + let result = classify_chunk_request(&state, "tok-something-else", 100, body); + match result { + ChunkClassification::Reject(Error::BadRequest(Some(msg))) => { + assert_eq!(msg, "Incorrect Content-Range header"); + } + _ => panic!("expected BadRequest about Content-Range"), + } + } + + #[test] + fn extract_pg_bearer_accepts_pg_prefixed_token() { + assert_eq!( + extract_pg_bearer(Some("Bearer PG-abc123")), + Some("PG-abc123") + ); + } + + #[test] + fn extract_pg_bearer_accepts_lowercase_scheme() { + assert_eq!( + extract_pg_bearer(Some("bearer PG-abc123")), + Some("PG-abc123") + ); + } + + #[test] + fn extract_pg_bearer_rejects_missing_header() { + assert_eq!(extract_pg_bearer(None), None); + } + + #[test] + fn extract_pg_bearer_rejects_empty_header() { + assert_eq!(extract_pg_bearer(Some("")), None); + } + + #[test] + fn extract_pg_bearer_rejects_non_pg_token() { + // A JWT-style bearer must not be treated as a PG key. + assert_eq!( + extract_pg_bearer(Some("Bearer eyJhbGciOiJSUzI1NiJ9.foo.bar")), + None + ); + } + + #[test] + fn extract_pg_bearer_rejects_wrong_scheme() { + // `Basic` and other schemes must not pass through. + assert_eq!(extract_pg_bearer(Some("Basic PG-abc")), None); + } + + #[test] + fn extract_pg_bearer_rejects_pg_prefix_without_scheme() { + // The PG- prefix alone (no `Bearer `) is not a valid bearer. + assert_eq!(extract_pg_bearer(Some("PG-abc123")), None); + } + + // ----- Range header parser unit tests ----- + + #[test] + fn parse_range_full() { + let r = parse_range_header("bytes=0-99", 100).unwrap(); + assert_eq!( + r, + ByteRange { + start: 0, + end_inclusive: 99 + } + ); + assert_eq!(r.len(), 100); + } + + #[test] + fn parse_range_open_end() { + let r = parse_range_header("bytes=50-", 100).unwrap(); + assert_eq!( + r, + ByteRange { + start: 50, + end_inclusive: 99 + } + ); + } + + #[test] + fn parse_range_suffix() { + let r = parse_range_header("bytes=-10", 100).unwrap(); + assert_eq!( + r, + ByteRange { + start: 90, + end_inclusive: 99 + } + ); + } + + #[test] + fn parse_range_suffix_larger_than_size_clamps() { + let r = parse_range_header("bytes=-500", 100).unwrap(); + assert_eq!( + r, + ByteRange { + start: 0, + end_inclusive: 99 + } + ); + } + + #[test] + fn parse_range_end_past_size_clamps() { + let r = parse_range_header("bytes=10-9999", 100).unwrap(); + assert_eq!( + r, + ByteRange { + start: 10, + end_inclusive: 99 + } + ); + } + + #[test] + fn parse_range_rejects_start_past_size() { + assert!(parse_range_header("bytes=100-200", 100).is_none()); + } + + #[test] + fn parse_range_rejects_inverted() { + assert!(parse_range_header("bytes=50-10", 100).is_none()); + } + + #[test] + fn parse_range_rejects_wrong_unit() { + assert!(parse_range_header("items=0-9", 100).is_none()); + } + + #[test] + fn parse_range_rejects_multi_range() { + // Multi-range is intentionally unsupported. + assert!(parse_range_header("bytes=0-9,20-29", 100).is_none()); + } + + #[test] + fn parse_range_rejects_empty_suffix() { + assert!(parse_range_header("bytes=-0", 100).is_none()); + assert!(parse_range_header("bytes=-", 100).is_none()); + } + + #[test] + fn parse_range_rejects_garbage() { + assert!(parse_range_header("nonsense", 100).is_none()); + assert!(parse_range_header("bytes=abc-def", 100).is_none()); + } + + #[test] + fn safe_segment_rejects_traversal_and_separators() { + assert!(is_safe_download_segment("abc-123")); + assert!(!is_safe_download_segment("")); + assert!(!is_safe_download_segment("..")); + assert!(!is_safe_download_segment(".")); + assert!(!is_safe_download_segment("a/b")); + assert!(!is_safe_download_segment("a\\b")); + assert!(!is_safe_download_segment("a\0b")); + } + + // ----- /filedownload integration tests ----- + + async fn download_client(data_dir: &std::path::Path) -> Client { + use rocket::figment::{providers::Serialized, Figment}; + + std::fs::create_dir_all(data_dir).expect("create test data_dir"); + + let figment = Figment::from(rocket::Config::default()).merge(Serialized::defaults( + serde_json::json!({ + "server_url": "http://localhost", + "data_dir": data_dir.to_str().unwrap(), + "email_from": "Test ", + "smtp_url": "localhost", + "smtp_port": 1025u16, + "allowed_origins": ".*", + "pkg_url": "http://localhost", + }), + )); + + let rocket = rocket::custom(figment) + .mount("/", routes![download]) + .attach(AdHoc::config::()); + + Client::tracked(rocket).await.expect("valid rocket") + } + + fn fresh_data_dir() -> std::path::PathBuf { + std::env::temp_dir().join(format!("cryptify-dl-{}", uuid::Uuid::new_v4().hyphenated())) + } + + #[rocket::async_test] + async fn download_full_returns_200_with_accept_ranges() { + let data_dir = fresh_data_dir(); + let client = download_client(&data_dir).await; + let body: Vec = (0u8..100).collect(); + std::fs::write(data_dir.join("file1"), &body).unwrap(); + + let res = client.get("/filedownload/file1").dispatch().await; + assert_eq!(res.status(), Status::Ok); + assert_eq!( + res.headers().get_one("Accept-Ranges"), + Some("bytes"), + "Accept-Ranges must be advertised so browsers expose the resume button" + ); + assert_eq!(res.headers().get_one("Content-Length"), Some("100")); + let bytes = res.into_bytes().await.unwrap(); + assert_eq!(bytes, body); + + let _ = std::fs::remove_dir_all(&data_dir); + } + + #[rocket::async_test] + async fn download_partial_returns_206_with_content_range() { + let data_dir = fresh_data_dir(); + let client = download_client(&data_dir).await; + let body: Vec = (0u8..100).collect(); + std::fs::write(data_dir.join("file1"), &body).unwrap(); + + let res = client + .get("/filedownload/file1") + .header(Header::new("Range", "bytes=10-19")) + .dispatch() + .await; + assert_eq!(res.status(), Status::PartialContent); + assert_eq!( + res.headers().get_one("Content-Range"), + Some("bytes 10-19/100") + ); + assert_eq!(res.headers().get_one("Content-Length"), Some("10")); + assert_eq!(res.headers().get_one("Accept-Ranges"), Some("bytes")); + let bytes = res.into_bytes().await.unwrap(); + assert_eq!(bytes, (10u8..20).collect::>()); + + let _ = std::fs::remove_dir_all(&data_dir); + } + + #[rocket::async_test] + async fn download_open_ended_range_resumes_from_offset() { + let data_dir = fresh_data_dir(); + let client = download_client(&data_dir).await; + let body: Vec = (0u8..100).collect(); + std::fs::write(data_dir.join("file1"), &body).unwrap(); + + let res = client + .get("/filedownload/file1") + .header(Header::new("Range", "bytes=80-")) + .dispatch() + .await; + assert_eq!(res.status(), Status::PartialContent); + assert_eq!( + res.headers().get_one("Content-Range"), + Some("bytes 80-99/100") + ); + let bytes = res.into_bytes().await.unwrap(); + assert_eq!(bytes, (80u8..100).collect::>()); + + let _ = std::fs::remove_dir_all(&data_dir); + } + + #[rocket::async_test] + async fn download_suffix_range_returns_tail() { + let data_dir = fresh_data_dir(); + let client = download_client(&data_dir).await; + let body: Vec = (0u8..100).collect(); + std::fs::write(data_dir.join("file1"), &body).unwrap(); + + let res = client + .get("/filedownload/file1") + .header(Header::new("Range", "bytes=-5")) + .dispatch() + .await; + assert_eq!(res.status(), Status::PartialContent); + assert_eq!( + res.headers().get_one("Content-Range"), + Some("bytes 95-99/100") + ); + let bytes = res.into_bytes().await.unwrap(); + assert_eq!(bytes, (95u8..100).collect::>()); + + let _ = std::fs::remove_dir_all(&data_dir); + } + + #[rocket::async_test] + async fn download_unsatisfiable_range_returns_416() { + let data_dir = fresh_data_dir(); + let client = download_client(&data_dir).await; + let body: Vec = (0u8..100).collect(); + std::fs::write(data_dir.join("file1"), &body).unwrap(); + + let res = client + .get("/filedownload/file1") + .header(Header::new("Range", "bytes=200-300")) + .dispatch() + .await; + assert_eq!(res.status(), Status::RangeNotSatisfiable); + assert_eq!(res.headers().get_one("Content-Range"), Some("bytes */100")); + + let _ = std::fs::remove_dir_all(&data_dir); + } + + #[rocket::async_test] + async fn download_missing_file_returns_404() { + let data_dir = fresh_data_dir(); + let client = download_client(&data_dir).await; + + let res = client.get("/filedownload/nope").dispatch().await; + assert_eq!(res.status(), Status::NotFound); + + let _ = std::fs::remove_dir_all(&data_dir); + } + + #[rocket::async_test] + async fn download_rejects_path_traversal() { + let data_dir = fresh_data_dir(); + let client = download_client(&data_dir).await; + // Plant a "secret" file outside data_dir to make sure traversal + // would actually leak something if the guard were bypassed. + let secret_dir = data_dir.parent().unwrap().join(format!( + "cryptify-dl-secret-{}", + uuid::Uuid::new_v4().hyphenated() + )); + std::fs::create_dir_all(&secret_dir).unwrap(); + let secret_path = secret_dir.join("secret"); + std::fs::write(&secret_path, b"do not leak").unwrap(); + + // Rocket's router parses `` as a single URI segment, so + // a literal `..` arrives as `..` and must be rejected by the guard. + let res = client.get("/filedownload/..").dispatch().await; + assert_eq!(res.status(), Status::NotFound); + + let _ = std::fs::remove_dir_all(&data_dir); + let _ = std::fs::remove_dir_all(&secret_dir); + } + + /// Build a minimal rocket exposing only the `staging_preview` route, + /// with `staging_mode` controlled by the caller. The returned UUID + /// (when `seed_uuid` is `Some`) is pre-inserted into the store so the + /// happy-path test has something to render. + async fn staging_preview_client(staging_mode: bool, seed_uuid: Option<&str>) -> Client { + use rocket::figment::{providers::Serialized, Figment}; + + let figment = Figment::from(rocket::Config::default()).merge(Serialized::defaults( + serde_json::json!({ + "server_url": "https://staging.example.com", + "data_dir": std::env::temp_dir().to_str().unwrap(), + "email_from": "Test ", + "smtp_url": "localhost", + "smtp_port": 1025u16, + "allowed_origins": ".*", + "pkg_url": "http://localhost", + "staging_mode": staging_mode, + }), + )); + + let store = Store::new(Arc::new(Metrics::new())); + if let Some(uuid) = seed_uuid { + let mut mboxes = lettre::message::Mailboxes::new(); + mboxes.push("alice@example.com".parse().unwrap()); + mboxes.push("bob@example.com".parse().unwrap()); + let state = FileState { + uploaded: 1234, + cryptify_token: String::new(), + expires: 1_700_000_000, + recipients: mboxes, + mail_content: String::new(), + mail_lang: email::Language::En, + sender: Some("sender@example.com".to_owned()), + sender_attributes: Vec::new(), + confirm: true, + source_channel: String::new(), + client_version: None, + client_app: None, + notify_recipients: true, + api_key_tenant: None, + api_key_validation_failed: false, + last_chunk: None, + recovery_token: String::new(), + }; + store.create(uuid.to_owned(), state); + } + + let rocket = rocket::custom(figment) + .mount("/", routes![staging_preview]) + .attach(AdHoc::config::()) + .manage(store); + + Client::tracked(rocket).await.expect("valid rocket") + } + + #[rocket::async_test] + async fn staging_preview_returns_404_in_production_mode() { + let client = staging_preview_client(false, Some("uuid-known")).await; + let res = client.get("/staging/preview/uuid-known").dispatch().await; + assert_eq!( + res.status(), + Status::NotFound, + "the staging_mode gate must hide the route in production" + ); + } + + #[rocket::async_test] + async fn staging_preview_returns_404_for_unknown_uuid() { + let client = staging_preview_client(true, None).await; + let res = client + .get("/staging/preview/uuid-does-not-exist") + .dispatch() + .await; + assert_eq!(res.status(), Status::NotFound); + } + + #[rocket::async_test] + async fn staging_preview_renders_recipients_and_confirmation() { + let client = staging_preview_client(true, Some("uuid-known")).await; + let res = client.get("/staging/preview/uuid-known").dispatch().await; + assert_eq!(res.status(), Status::Ok); + + let body = res.into_string().await.expect("body"); + let v: serde_json::Value = serde_json::from_str(&body).expect("valid JSON"); + + let recipients = v + .get("recipients") + .and_then(|r| r.as_array()) + .expect("recipients array"); + let emails: Vec<&str> = recipients + .iter() + .filter_map(|r| r.get("recipient").and_then(|s| s.as_str())) + .collect(); + assert_eq!(emails, vec!["alice@example.com", "bob@example.com"]); + for r in recipients { + assert!(r.get("subject").and_then(|s| s.as_str()).is_some()); + assert!(r.get("html").and_then(|s| s.as_str()).is_some()); + assert!(r.get("text").and_then(|s| s.as_str()).is_some()); + } + + let confirmation = v.get("confirmation").expect("confirmation key present"); + assert_eq!( + confirmation + .get("recipient") + .and_then(|s| s.as_str()) + .expect("confirmation.recipient"), + "sender@example.com" + ); + } +} + +/// End-to-end integration tests for the upload pipeline +/// (`POST /fileupload/init` → `PUT /fileupload/` → +/// `POST /fileupload/finalize/`). +/// +/// These tests boot a full Rocket instance via [`build_rocket`] with an +/// injected `VerifyingKey` from `pg_core::test::TestSetup`, so they exercise +/// the real extractors, state machine, token chain, and `Unsealer`-based +/// attribute extraction. SMTP is short-circuited by `staging_mode = true` so +/// the finalize happy-path does not require a live mail server. +#[cfg(test)] +mod integration { + use super::*; + use pg_core::client::rust::stream::SealerStreamConfig; + use pg_core::client::Sealer; + use pg_core::test::TestSetup; + use rocket::http::{ContentType, Header, Status}; + use rocket::local::asynchronous::Client; + + // One of the test policies from `pg_core::test::TestSetup` includes + // `pbdf.sidn-pbdf.email.email = "bob@example.com"`, and the encryption + // policy seals for Bob & Charlie. Finalize's attribute extraction looks + // for exactly this attribute type. + const SENDER_EMAIL: &str = "bob@example.com"; + + /// Build a figment that points at a freshly-created temp `data_dir` and + /// disables outgoing email. Each test gets its own directory so they can + /// run in parallel without clobbering each other's files. + fn test_figment() -> (rocket::figment::Figment, std::path::PathBuf) { + let dir = + std::env::temp_dir().join(format!("cryptify-it-{}", uuid::Uuid::new_v4().hyphenated())); + std::fs::create_dir_all(&dir).expect("create temp data_dir"); + + let figment = default_figment() + .merge(("server_url", "http://localhost:8000")) + .merge(("data_dir", dir.to_string_lossy().to_string())) + .merge(("email_from", "test@example.com")) + .merge(("smtp_url", "localhost")) + .merge(("smtp_port", 2525u16)) + .merge(("smtp_tls", false)) + .merge(("staging_mode", true)) + .merge(("allowed_origins", ".*")) + .merge(("pkg_url", "http://localhost:8080")); + + (figment, dir) + } + + /// Seal `payload` for the encryption policy from `TestSetup`, producing a + /// byte stream that `Unsealer` (and therefore `upload_finalize`) accepts. + async fn seal_payload(setup: &TestSetup, payload: &[u8]) -> Vec { + let mut rng = rand08::thread_rng(); + let signing_key = &setup.signing_keys[2]; // Bob: email + name + let mut input = futures::io::Cursor::new(payload.to_vec()); + let mut sealed = Vec::new(); + Sealer::<_, SealerStreamConfig>::new(&setup.ibe_pk, &setup.policy, signing_key, &mut rng) + .expect("build sealer") + .seal(&mut input, &mut sealed) + .await + .expect("seal payload"); + sealed + } + + /// Boot Rocket with the test figment and a verifying key from `TestSetup`. + async fn test_client(setup: &TestSetup) -> (Client, std::path::PathBuf) { + let (figment, dir) = test_figment(); + let vk = Parameters { + format_version: 0, + public_key: VerifyingKey(setup.ibs_pk.0.clone()), + }; + let rocket = build_rocket(figment, vk); + let client = Client::tracked(rocket).await.expect("valid rocket"); + (client, dir) + } + + /// Boot Rocket like [`test_client`] but with a caller-supplied + /// `allowed_origins` regex, so CORS-preflight behaviour can be exercised + /// against the exact regex shipped in `conf/config.toml`. + async fn cors_client(setup: &TestSetup, allowed_origins: &str) -> (Client, std::path::PathBuf) { + let (figment, dir) = test_figment(); + let figment = figment.merge(("allowed_origins", allowed_origins.to_string())); + let vk = Parameters { + format_version: 0, + public_key: VerifyingKey(setup.ibs_pk.0.clone()), + }; + let rocket = build_rocket(figment, vk); + let client = Client::tracked(rocket).await.expect("valid rocket"); + (client, dir) + } + + // A copy of the production CORS regex from `conf/config.toml`, used to + // assert the preflight shape (allowed origins, methods, headers) of the + // regex we actually ship for the Office add-in (encryption4all/postguard#154). + // This is a hand-maintained copy — the tests do NOT read `conf/config.toml`, + // so keep the two in sync when either changes. + const PROD_ALLOWED_ORIGINS: &str = + r"^https://(postguard\.(eu|nl)|addin\.postguard\.eu|localhost:3000)$"; + + #[rocket::async_test] + async fn cors_preflight_allows_addin_and_localhost_origins() { + let mut rng = rand08::thread_rng(); + let setup = TestSetup::new(&mut rng); + let (client, dir) = cors_client(&setup, PROD_ALLOWED_ORIGINS).await; + + for origin in ["https://addin.postguard.eu", "https://localhost:3000"] { + let res = client + .req(rocket::http::Method::Options, "/fileupload/init") + .header(Header::new("Origin", origin)) + .header(Header::new("Access-Control-Request-Method", "POST")) + .header(Header::new( + "Access-Control-Request-Headers", + "Content-Type, Authorization", + )) + .dispatch() + .await; + + // rocket_cors answers a valid preflight with a 2xx. + assert!( + res.status().code < 400, + "preflight from {origin} should succeed, got {}", + res.status() + ); + assert_eq!( + res.headers().get_one("Access-Control-Allow-Origin"), + Some(origin), + "Allow-Origin should echo {origin}" + ); + + let allow_methods = res + .headers() + .get_one("Access-Control-Allow-Methods") + .expect("Allow-Methods in preflight") + .to_ascii_uppercase(); + for m in ["GET", "POST", "PUT", "DELETE"] { + assert!( + allow_methods.contains(m), + "Allow-Methods `{allow_methods}` should include {m}" + ); + } + + let allow_headers = res + .headers() + .get_one("Access-Control-Allow-Headers") + .expect("Allow-Headers in preflight") + .to_ascii_lowercase(); + for h in ["content-type", "authorization"] { + assert!( + allow_headers.contains(h), + "Allow-Headers `{allow_headers}` should include {h}" + ); + } + } + + let _ = std::fs::remove_dir_all(dir); + } + + #[rocket::async_test] + async fn cors_preflight_rejects_unlisted_origin() { + let mut rng = rand08::thread_rng(); + let setup = TestSetup::new(&mut rng); + let (client, dir) = cors_client(&setup, PROD_ALLOWED_ORIGINS).await; + + let res = client + .req(rocket::http::Method::Options, "/fileupload/init") + .header(Header::new("Origin", "https://evil.example.com")) + .header(Header::new("Access-Control-Request-Method", "POST")) + .dispatch() + .await; + + // A non-matching origin must not be granted access: rocket_cors omits + // the Allow-Origin header entirely for a rejected preflight. + assert!( + res.headers() + .get_one("Access-Control-Allow-Origin") + .is_none(), + "unlisted origin must not receive an Access-Control-Allow-Origin header" + ); + + let _ = std::fs::remove_dir_all(dir); + } + + fn init_body_json(recipient: &str) -> String { + serde_json::json!({ + "recipient": recipient, + "mailContent": "hello", + "mailLang": "EN", + "confirm": false, + }) + .to_string() + } + + async fn do_init(client: &Client, recipient: &str) -> (String, String, Status) { + let res = client + .post("/fileupload/init") + .header(ContentType::JSON) + .body(init_body_json(recipient)) + .dispatch() + .await; + let status = res.status(); + let token = res + .headers() + .get_one("cryptifytoken") + .map(|s| s.to_string()) + .unwrap_or_default(); + let body = res.into_string().await.unwrap_or_default(); + let uuid = serde_json::from_str::(&body) + .ok() + .and_then(|v| { + v.get("uuid") + .and_then(|u| u.as_str().map(|s| s.to_string())) + }) + .unwrap_or_default(); + (uuid, token, status) + } + + /// PUT one chunk and return the response status plus the advanced token. + async fn do_chunk( + client: &Client, + uuid: &str, + token: &str, + chunk: &[u8], + start: u64, + ) -> (Status, String) { + let end = start + chunk.len() as u64; + let res = client + .put(format!("/fileupload/{}", uuid)) + .header(Header::new("CryptifyToken", token.to_string())) + .header(Header::new( + "Content-Range", + format!("bytes {}-{}/*", start, end), + )) + .body(chunk) + .dispatch() + .await; + let status = res.status(); + let next = res + .headers() + .get_one("cryptifytoken") + .map(|s| s.to_string()) + .unwrap_or_default(); + (status, next) + } + + async fn do_finalize(client: &Client, uuid: &str, token: &str, total: u64) -> Status { + client + .post(format!("/fileupload/finalize/{}", uuid)) + .header(Header::new("CryptifyToken", token.to_string())) + .header(Header::new("Content-Range", format!("bytes */{}", total))) + .dispatch() + .await + .status() + } + + #[rocket::async_test] + async fn upload_happy_path_init_chunk_finalize() { + let mut rng = rand08::thread_rng(); + let setup = TestSetup::new(&mut rng); + let sealed = seal_payload(&setup, b"hello integration test").await; + + let (client, dir) = test_client(&setup).await; + + let (uuid, mut token, status) = do_init(&client, SENDER_EMAIL).await; + assert_eq!(status, Status::Ok); + assert!(!uuid.is_empty()); + assert!(!token.is_empty()); + + // Upload in a single chunk (payload is well under CHUNK_SIZE). + let (chunk_status, next) = do_chunk(&client, &uuid, &token, &sealed, 0).await; + assert_eq!(chunk_status, Status::Ok); + token = next; + + let final_status = do_finalize(&client, &uuid, &token, sealed.len() as u64).await; + assert_eq!(final_status, Status::Ok); + + let _ = std::fs::remove_dir_all(dir); + } + + /// Finalizing a session whose bytes are not a valid postguard stream makes + /// the `Unsealer` fail, driving `upload_finalize` down its 500 path. The + /// response body must carry only the generic message — never the internal + /// diagnostic detail, which now goes to the server log (GHSA-r95f-qf3j-xccw). + #[rocket::async_test] + async fn finalize_internal_error_body_is_generic() { + let mut rng = rand08::thread_rng(); + let setup = TestSetup::new(&mut rng); + let (client, dir) = test_client(&setup).await; + + // Upload arbitrary bytes: the chunk path only advances the rolling + // token, it does not validate the postguard framing. + let garbage = b"this is not a postguard sealed stream"; + let (uuid, token, status) = do_init(&client, SENDER_EMAIL).await; + assert_eq!(status, Status::Ok); + let (chunk_status, next) = do_chunk(&client, &uuid, &token, garbage, 0).await; + assert_eq!(chunk_status, Status::Ok); + + let res = client + .post(format!("/fileupload/finalize/{}", uuid)) + .header(Header::new("CryptifyToken", next)) + .header(Header::new( + "Content-Range", + format!("bytes */{}", garbage.len()), + )) + .dispatch() + .await; + + assert_eq!(res.status(), Status::InternalServerError); + let body = res.into_string().await.unwrap_or_default(); + assert_eq!(body, GENERIC_INTERNAL_ERROR_MSG); + // Guard against re-introducing the old leaky diagnostics. + assert!( + !body.contains("postguard") && !body.contains("file"), + "500 body must not leak internal detail, got: {body}" + ); + + let _ = std::fs::remove_dir_all(dir); + } + + #[rocket::async_test] + async fn upload_records_client_app_metric() { + let mut rng = rand08::thread_rng(); + let setup = TestSetup::new(&mut rng); + let sealed = seal_payload(&setup, b"hello metric test").await; + + let (client, dir) = test_client(&setup).await; + + // Init carrying a client-version header whose `app` field is pg-js. + let res = client + .post("/fileupload/init") + .header(ContentType::JSON) + .header(Header::new( + "X-POSTGUARD-CLIENT-VERSION", + "node,22.1.0,pg-js,1.2.3", + )) + .body(init_body_json(SENDER_EMAIL)) + .dispatch() + .await; + assert_eq!(res.status(), Status::Ok); + let mut token = res + .headers() + .get_one("cryptifytoken") + .map(|s| s.to_string()) + .unwrap_or_default(); + let body = res.into_string().await.unwrap_or_default(); + let uuid = serde_json::from_str::(&body) + .ok() + .and_then(|v| v.get("uuid").and_then(|u| u.as_str().map(String::from))) + .unwrap_or_default(); + assert!(!uuid.is_empty()); + + let (chunk_status, next) = do_chunk(&client, &uuid, &token, &sealed, 0).await; + assert_eq!(chunk_status, Status::Ok); + token = next; + + let final_status = do_finalize(&client, &uuid, &token, sealed.len() as u64).await; + assert_eq!(final_status, Status::Ok); + + // The finalized upload is attributed to app="pg-js". + let metrics = client + .get("/metrics") + .dispatch() + .await + .into_string() + .await + .unwrap_or_default(); + assert!( + metrics.contains("cryptify_uploads_by_app_total{app=\"pg-js\"} 1"), + "expected pg-js app counter in metrics:\n{metrics}" + ); + + let _ = std::fs::remove_dir_all(dir); + } + + // Minimal Rocket exposing only /metrics, with the given config managed so + // the MetricsAuth guard can read `metrics_token`. Avoids needing a real + // VerifyingKey / TestSetup. + fn metrics_only_config(with_token: bool) -> CryptifyConfig { + let (figment, _dir) = test_figment(); + let figment = if with_token { + figment.merge(("metrics_token", "s3cret")) + } else { + figment + }; + figment.extract::().expect("extract config") + } + + async fn metrics_only_client(config: CryptifyConfig) -> Client { + let rocket = rocket::build() + .mount("/", routes![metrics_endpoint]) + .manage(config) + .manage(std::sync::Arc::new(Metrics::new())); + Client::tracked(rocket).await.expect("valid rocket") + } + + #[rocket::async_test] + async fn metrics_requires_bearer_when_token_configured() { + let client = metrics_only_client(metrics_only_config(true)).await; + + // No Authorization header → 401. + assert_eq!( + client.get("/metrics").dispatch().await.status(), + Status::Unauthorized + ); + // Wrong token → 401. + assert_eq!( + client + .get("/metrics") + .header(Header::new("Authorization", "Bearer wrong")) + .dispatch() + .await + .status(), + Status::Unauthorized + ); + // Correct token → 200 with the metrics body. + let ok = client + .get("/metrics") + .header(Header::new("Authorization", "Bearer s3cret")) + .dispatch() + .await; + assert_eq!(ok.status(), Status::Ok); + assert!(ok + .into_string() + .await + .unwrap_or_default() + .contains("cryptify_uploads_total")); + } + + #[rocket::async_test] + async fn metrics_open_when_token_unset() { + let client = metrics_only_client(metrics_only_config(false)).await; + assert_eq!(client.get("/metrics").dispatch().await.status(), Status::Ok); + } + + #[rocket::async_test] + async fn upload_happy_path_multi_chunk() { + // Two chunks >1 MiB to exercise the rolling token chain across + // multiple PUTs. Keeps payload well under CHUNK_SIZE. + let mut rng = rand08::thread_rng(); + let setup = TestSetup::new(&mut rng); + let payload: Vec = (0..(2 * 1024 * 1024 + 17)) + .map(|i| (i % 251) as u8) + .collect(); + let sealed = seal_payload(&setup, &payload).await; + + let (client, dir) = test_client(&setup).await; + + let (uuid, mut token, _) = do_init(&client, SENDER_EMAIL).await; + + let split = sealed.len() / 2; + let (s1, next1) = do_chunk(&client, &uuid, &token, &sealed[..split], 0).await; + assert_eq!(s1, Status::Ok); + token = next1; + + let (s2, next2) = do_chunk(&client, &uuid, &token, &sealed[split..], split as u64).await; + assert_eq!(s2, Status::Ok); + token = next2; + + let final_status = do_finalize(&client, &uuid, &token, sealed.len() as u64).await; + assert_eq!(final_status, Status::Ok); + + let _ = std::fs::remove_dir_all(dir); + } + + #[rocket::async_test] + async fn upload_init_rejects_invalid_email() { + let mut rng = rand08::thread_rng(); + let setup = TestSetup::new(&mut rng); + let (client, dir) = test_client(&setup).await; + + let res = client + .post("/fileupload/init") + .header(ContentType::JSON) + .body(init_body_json("not-a-valid-email")) + .dispatch() + .await; + assert_eq!(res.status(), Status::BadRequest); + + let _ = std::fs::remove_dir_all(dir); + } + + #[rocket::async_test] + async fn upload_chunk_rejects_wrong_cryptify_token() { + let mut rng = rand08::thread_rng(); + let setup = TestSetup::new(&mut rng); + let (client, dir) = test_client(&setup).await; + let (uuid, _token, _) = do_init(&client, SENDER_EMAIL).await; + + let (status, _) = do_chunk(&client, &uuid, "bogus-token", b"xxxx", 0).await; + assert_eq!(status, Status::BadRequest); + + let _ = std::fs::remove_dir_all(dir); + } + + #[rocket::async_test] + async fn upload_chunk_unknown_uuid_returns_404() { + let mut rng = rand08::thread_rng(); + let setup = TestSetup::new(&mut rng); + let (client, dir) = test_client(&setup).await; + + let fake = uuid::Uuid::new_v4().hyphenated().to_string(); + let (status, _) = do_chunk(&client, &fake, "any-token", b"xxxx", 0).await; + assert_eq!(status, Status::NotFound); + + let _ = std::fs::remove_dir_all(dir); + } + + #[rocket::async_test] + async fn upload_chunk_invalid_uuid_reports_invalid_uuid_reason() { + let mut rng = rand08::thread_rng(); + let setup = TestSetup::new(&mut rng); + let (client, dir) = test_client(&setup).await; + + let res = client + .put("/fileupload/not-a-uuid") + .header(Header::new("CryptifyToken", "any-token")) + .header(Header::new("Content-Range", "bytes 0-4/*")) + .body(b"xxxx" as &[u8]) + .dispatch() + .await; + assert_eq!(res.status(), Status::NotFound); + let body = res.into_string().await.unwrap_or_default(); + assert!( + body.contains("\"reason\":\"invalid_uuid\""), + "expected invalid_uuid reason, got: {body}" + ); + + let _ = std::fs::remove_dir_all(dir); + } + + #[rocket::async_test] + async fn upload_finalize_rejects_wrong_cryptify_token() { + let mut rng = rand08::thread_rng(); + let setup = TestSetup::new(&mut rng); + let sealed = seal_payload(&setup, b"hello").await; + let (client, dir) = test_client(&setup).await; + + let (uuid, token, _) = do_init(&client, SENDER_EMAIL).await; + let (_, new_token) = do_chunk(&client, &uuid, &token, &sealed, 0).await; + assert!(!new_token.is_empty()); + + // Finalize with a bogus token — must be rejected before Unsealer runs. + let status = do_finalize(&client, &uuid, "not-the-token", sealed.len() as u64).await; + assert_eq!(status, Status::BadRequest); + + let _ = std::fs::remove_dir_all(dir); + } + + #[rocket::async_test] + async fn upload_finalize_rejects_size_mismatch() { + let mut rng = rand08::thread_rng(); + let setup = TestSetup::new(&mut rng); + let sealed = seal_payload(&setup, b"hello").await; + let (client, dir) = test_client(&setup).await; + + let (uuid, token, _) = do_init(&client, SENDER_EMAIL).await; + let (_, new_token) = do_chunk(&client, &uuid, &token, &sealed, 0).await; + + // Claim the wrong total size in Content-Range. + let wrong_total = (sealed.len() as u64).saturating_sub(1); + let status = do_finalize(&client, &uuid, &new_token, wrong_total).await; + assert_eq!(status, Status::UnprocessableEntity); + + let _ = std::fs::remove_dir_all(dir); + } + + #[rocket::async_test] + async fn upload_finalize_unknown_uuid_returns_404() { + let mut rng = rand08::thread_rng(); + let setup = TestSetup::new(&mut rng); + let (client, dir) = test_client(&setup).await; + + let fake = uuid::Uuid::new_v4().hyphenated().to_string(); + let status = do_finalize(&client, &fake, "any-token", 0).await; + assert_eq!(status, Status::NotFound); + + let _ = std::fs::remove_dir_all(dir); + } + + #[rocket::async_test] + async fn upload_chunk_rejects_content_range_misalignment() { + // Start must equal state.uploaded (currently 0). + let mut rng = rand08::thread_rng(); + let setup = TestSetup::new(&mut rng); + let (client, dir) = test_client(&setup).await; + let (uuid, token, _) = do_init(&client, SENDER_EMAIL).await; + + let (status, _) = do_chunk(&client, &uuid, &token, b"xxxx", 100).await; + assert_eq!(status, Status::BadRequest); + + let _ = std::fs::remove_dir_all(dir); + } +} + +/// Tests for `GET /email-template` (issue #54). Covers both the pure +/// branch-mapping helper and the full route through the production +/// `ApiKey` request guard, exercised against a mock pg-pkg server so the +/// real validation flow (reqwest → `/v2/api-key/validate`) is on the path. +#[cfg(test)] +mod email_template_tests { + use super::*; + use rocket::http::{Header, Status}; + use rocket::local::asynchronous::Client; + use std::time::Duration; + + // ----- Pure unit tests for the branch-mapping helper ----- + + #[test] + fn resolve_returns_template_for_validated_key_with_template() { + let api_key = ApiKey { + tenant: Some("tenant-123".to_owned()), + validation_failed: false, + email_template: Some("Hello {{name}}".to_owned()), + }; + let resp = resolve_email_template(api_key).expect("validated key with template resolves"); + assert_eq!(resp.tenant_id, "tenant-123"); + assert_eq!(resp.email_template, "Hello {{name}}"); + } + + #[test] + fn resolve_returns_404_for_validated_key_without_template() { + let api_key = ApiKey { + tenant: Some("tenant-123".to_owned()), + validation_failed: false, + email_template: None, + }; + match resolve_email_template(api_key) { + Err(Error::NotFound(_)) => {} + _ => panic!("expected NotFound for a valid key with no template"), + } + } + + #[test] + fn resolve_returns_401_for_missing_or_invalid_key() { + let api_key = ApiKey { + tenant: None, + validation_failed: false, + email_template: None, + }; + match resolve_email_template(api_key) { + Err(Error::Unauthorized(_)) => {} + _ => panic!("expected Unauthorized when no tenant resolved"), + } + } + + #[test] + fn resolve_returns_503_when_pkg_unreachable() { + let api_key = ApiKey { + tenant: None, + validation_failed: true, + email_template: None, + }; + match resolve_email_template(api_key) { + Err(Error::ServiceUnavailable(_)) => {} + _ => panic!("expected ServiceUnavailable when pg-pkg was unreachable"), + } + } + + // ----- End-to-end tests through the real ApiKey guard ----- + + /// Authorization-header capture for the mock pg-pkg route. + struct MockAuth(Option); + + #[rocket::async_trait] + impl<'r> FromRequest<'r> for MockAuth { + type Error = std::convert::Infallible; + async fn from_request( + req: &'r rocket::Request<'_>, + ) -> rocket::request::Outcome { + rocket::request::Outcome::Success(MockAuth( + req.headers().get_one("Authorization").map(str::to_owned), + )) + } + } + + /// Stand-in for pg-pkg's `GET /v2/api-key/validate`. Mirrors the + /// authoritative responses the real `PkgClient` distinguishes: + /// 200 + tenant (optionally with `email_template`) for a recognised + /// key, 401 for anything else. + #[get("/v2/api-key/validate")] + fn mock_validate(auth: MockAuth) -> Result, Status> { + match auth.0.as_deref() { + Some("Bearer PG-key-with-template") => Ok(Json(serde_json::json!({ + "tenant_id": "tenant-abc", + "organisation_name": "Acme", + "email_template": "Beste {{naam}}, u heeft bestanden ontvangen." + }))), + Some("Bearer PG-key-no-template") => Ok(Json(serde_json::json!({ + "tenant_id": "tenant-xyz" + }))), + _ => Err(Status::Unauthorized), + } + } + + /// Launch the mock pg-pkg on a real ephemeral port and return its base + /// URL. A real listener is required because the `ApiKey` guard reaches + /// it via reqwest over TCP, not Rocket's in-process local client. + async fn spawn_mock_pkg() -> String { + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind ephemeral port"); + let port = listener.local_addr().expect("local addr").port(); + drop(listener); + + let figment = rocket::Config::figment() + .merge(("port", port)) + .merge(("address", "127.0.0.1")) + .merge(("log_level", "off")); + let rocket = rocket::custom(figment).mount("/", routes![mock_validate]); + rocket::tokio::spawn(async move { + let _ = rocket.launch().await; + }); + + // Wait until the listener accepts connections so the first guard + // call doesn't race startup. The PkgClient retry budget would cover + // it anyway, but readiness keeps the test fast and quiet. + for _ in 0..200 { + if std::net::TcpStream::connect(("127.0.0.1", port)).is_ok() { + break; + } + rocket::tokio::time::sleep(Duration::from_millis(10)).await; + } + format!("http://127.0.0.1:{}", port) + } + + /// Cryptify client mounting only `/email-template`, with a `PkgClient` + /// pointed at `pkg_url`. + async fn email_template_client(pkg_url: String) -> Client { + let rocket = rocket::build() + .mount("/", routes![email_template]) + .manage(PkgClient::new(pkg_url)); + Client::tracked(rocket).await.expect("valid rocket") + } + + #[rocket::async_test] + async fn returns_template_for_valid_key() { + let pkg_url = spawn_mock_pkg().await; + let client = email_template_client(pkg_url).await; + + let res = client + .get("/email-template") + .header(Header::new("Authorization", "Bearer PG-key-with-template")) + .dispatch() + .await; + assert_eq!(res.status(), Status::Ok); + + let body: serde_json::Value = res.into_json().await.expect("json body"); + assert_eq!(body["tenant_id"].as_str(), Some("tenant-abc")); + assert_eq!( + body["email_template"].as_str(), + Some("Beste {{naam}}, u heeft bestanden ontvangen.") + ); + } + + #[rocket::async_test] + async fn returns_401_for_missing_key() { + // No Authorization header: the guard short-circuits to NoCredentials + // without calling pg-pkg, so the PkgClient URL is never dialled. + let client = email_template_client("http://127.0.0.1:1".to_owned()).await; + let res = client.get("/email-template").dispatch().await; + assert_eq!(res.status(), Status::Unauthorized); + } + + #[rocket::async_test] + async fn returns_401_for_invalid_key() { + // A PG-prefixed key the mock rejects with 401 → guard yields no + // tenant → endpoint returns 401. + let pkg_url = spawn_mock_pkg().await; + let client = email_template_client(pkg_url).await; + + let res = client + .get("/email-template") + .header(Header::new("Authorization", "Bearer PG-not-a-real-key")) + .dispatch() + .await; + assert_eq!(res.status(), Status::Unauthorized); + } + + #[rocket::async_test] + async fn returns_404_for_valid_key_without_template() { + let pkg_url = spawn_mock_pkg().await; + let client = email_template_client(pkg_url).await; + + let res = client + .get("/email-template") + .header(Header::new("Authorization", "Bearer PG-key-no-template")) + .dispatch() + .await; + assert_eq!(res.status(), Status::NotFound); + } + + /// Serve `/v2/sign/parameters` from a plain thread: the first `failures` + /// requests get a 503, subsequent ones the given JSON body. Returns the + /// URL and a counter of requests seen. + fn spawn_flaky_params_server( + failures: usize, + body: String, + ) -> (String, std::sync::Arc) { + use std::io::{Read, Write}; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind test server"); + let port = listener.local_addr().unwrap().port(); + let hits = Arc::new(AtomicUsize::new(0)); + let hits_srv = hits.clone(); + + std::thread::spawn(move || { + for stream in listener.incoming() { + let Ok(mut stream) = stream else { continue }; + let mut buf = [0u8; 1024]; + let _ = stream.read(&mut buf); + let n = hits_srv.fetch_add(1, Ordering::SeqCst); + let resp = if n < failures { + "HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + .to_string() + } else { + format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ) + }; + let _ = stream.write_all(resp.as_bytes()); + } + }); + + (format!("http://127.0.0.1:{port}/v2/sign/parameters"), hits) + } + + /// Regression test for encryption4all/postguard#235: cryptify panicked at + /// startup when the PKG was briefly unreachable. The verifying-key fetch + /// must retry transient failures and succeed once the PKG comes up. + #[rocket::async_test] + async fn verifying_key_fetch_survives_transient_pkg_outage() { + use std::sync::atomic::Ordering; + + let mut rng = rand08::thread_rng(); + let setup = pg_core::test::TestSetup::new(&mut rng); + let vk_json = serde_json::to_string(&Parameters { + format_version: 0, + public_key: VerifyingKey(setup.ibs_pk.0.clone()), + }) + .expect("serialize test verifying key"); + + // Fails twice, then serves a valid key. + let (url, hits) = spawn_flaky_params_server(2, vk_json); + + let vk = try_fetch_verifying_key( + &url, + Duration::from_secs(10), + Duration::from_millis(25), + Duration::from_millis(100), + ) + .await; + + assert!(vk.is_some(), "fetch must succeed after transient failures"); + assert!( + hits.load(Ordering::SeqCst) >= 3, + "expected at least 3 attempts (2 failures + 1 success)" + ); + } + + /// When the PKG never becomes reachable, the fetch gives up after the + /// budget (the caller then exits with a clear error) instead of retrying + /// forever. + #[rocket::async_test] + async fn verifying_key_fetch_gives_up_after_budget() { + // Always fails (no request ever gets past `failures`). + let (url, _hits) = spawn_flaky_params_server(usize::MAX, String::new()); + + let vk = try_fetch_verifying_key( + &url, + Duration::from_millis(200), + Duration::from_millis(50), + Duration::from_millis(50), + ) + .await; + + assert!(vk.is_none(), "fetch must give up once the budget is spent"); + } +} + +/// Guards `api-description.yaml` against the routes the service actually +/// mounts. The spec is hand-maintained, so a new or renamed route silently +/// drifts away from it; this test fails the build instead. +#[cfg(test)] +mod api_description_tests { + use super::*; + + const SPEC: &str = include_str!("../api-description.yaml"); + + /// Operations declared in the spec, as `("GET", "/health")` pairs. + /// + /// Hand-rolled rather than parsed with a YAML crate to keep the + /// dependency tree unchanged. It relies on the file's layout: path keys + /// sit at two spaces of indentation under `paths:`, HTTP methods at four. + /// `spec_layout_assumption_holds` fails loudly if that stops being true. + fn spec_operations() -> Vec<(String, String)> { + const METHODS: [&str; 5] = ["get", "post", "put", "delete", "patch"]; + let mut ops = Vec::new(); + let mut in_paths = false; + let mut path: Option = None; + + for line in SPEC.lines() { + if line.trim().is_empty() { + continue; + } + // A top-level key ends the `paths:` block. + if !line.starts_with(' ') { + in_paths = line.starts_with("paths:"); + path = None; + continue; + } + if !in_paths { + continue; + } + let indent = line.len() - line.trim_start().len(); + let key = line.trim_end().trim_start().trim_end_matches(':'); + if indent == 2 && key.starts_with('/') { + path = Some(key.to_owned()); + } else if indent == 4 && METHODS.contains(&key) { + if let Some(path) = path.as_ref() { + ops.push((key.to_uppercase(), path.clone())); + } + } + } + ops + } + + /// Normalize a path so a Rocket route and a spec path compare equal: + /// `/fileupload//status` and `/fileupload/{uuid}/status` both become + /// `/fileupload/{}/status`. Placeholder *names* are dropped on purpose — + /// they are labels with no effect on the wire contract, and the Rocket + /// binding name (``) is not always the name that documents the + /// value best (`{uuid}`). + fn normalize_path(path: &str) -> String { + let mut out = String::with_capacity(path.len()); + let mut in_placeholder = false; + for c in path.chars() { + match c { + '<' | '{' => { + in_placeholder = true; + out.push_str("{}"); + } + '>' | '}' => in_placeholder = false, + _ if in_placeholder => {} + _ => out.push(c), + } + } + out + } + + #[test] + fn spec_layout_assumption_holds() { + assert!( + !spec_operations().is_empty(), + "no operations parsed out of api-description.yaml — the indentation \ + layout the parser assumes (paths at 2 spaces, methods at 4) changed" + ); + } + + /// Mounted routes as `("GET", "/fileupload//status")` pairs. + fn mounted_operations() -> Vec<(String, String)> { + api_routes() + .iter() + .map(|route| (route.method.to_string(), route.uri.path().to_string())) + .collect() + } + + fn matches(ops: &[(String, String)], method: &str, path: &str) -> bool { + ops.iter() + .any(|(m, p)| m == method && normalize_path(p) == normalize_path(path)) + } + + #[test] + fn every_mounted_route_is_in_the_spec() { + let spec = spec_operations(); + for (method, path) in mounted_operations() { + assert!( + matches(&spec, &method, &path), + "{} {} is mounted but missing from api-description.yaml", + method, + path + ); + } + } + + #[test] + fn every_spec_operation_is_mounted() { + let mounted = mounted_operations(); + for (method, path) in spec_operations() { + assert!( + matches(&mounted, &method, &path), + "{} {} is in api-description.yaml but no route is mounted for it", + method, + path + ); + } + } +} + +/// Guards the settings of the API breaking-change gate (issue #202). +/// +/// The gate is `.github/workflows/api-diff.yml`, which runs `oasdiff breaking` +/// over `api-description.yaml` and is what stops a careless edit from breaking +/// a deployed client. Its whole behaviour is two step inputs, `fail-on` and +/// `include-checks`, and getting them wrong **fails open**: the job goes green +/// and nobody learns that the change it was supposed to stop went through. +/// +/// So the inputs are pinned here. This module mutates the real spec one way +/// per rule, runs the real engine with the flags the action's entrypoint +/// builds, and asserts which mutations the gate stops. +/// +/// The two halves are tied together rather than kept in step by hand: +/// [`the_workflow_uses_the_settings_this_module_pins`] reads the committed +/// workflow and asserts its `fail-on` and `include-checks` are [`FAIL_ON`] and +/// [`INCLUDE_CHECKS`], and that the job still runs at all — on `pull_request`, +/// with no path filter and no `if:`. Edit either side alone and that test says +/// so. It reads nothing but the repo tree, so unlike the mutation test it runs +/// on every runner. +/// +/// The engine is not vendored, so these tests need `oasdiff` on `PATH` (or +/// `OASDIFF` pointing at it) and skip when it is absent, which is the case on +/// every runner. Install the version the action pins, so a local verdict is +/// CI's verdict: +/// +/// ```text +/// go install github.com/oasdiff/oasdiff@v1.26.1 +/// cargo test --all-targets api_gate +/// ``` +#[cfg(test)] +mod api_gate_tests { + use std::env; + use std::fs; + use std::path::{Path, PathBuf}; + use std::process::Command; + + /// `fail-on` in the workflow's oasdiff step. WARN, not ERR: at ERR the gate + /// passes a removed or renamed optional response property, a removed + /// request parameter or property, and the constraint-narrowing `*-set` + /// family, all of which break a pinned client on unversioned routes. + const FAIL_ON: &str = "WARN"; + + /// `include-checks` in the workflow's oasdiff step. Both rate ERR but are + /// opt-in, so they do not run unless named. + const INCLUDE_CHECKS: &str = + "response-non-success-status-removed,response-property-enum-value-removed"; + + /// The gate itself, embedded so the two constants above cannot claim + /// settings the committed job does not use. + const WORKFLOW: &str = include_str!("../../.github/workflows/api-diff.yml"); + + /// Path of the workflow, for failure messages. + const WORKFLOW_PATH: &str = ".github/workflows/api-diff.yml"; + + /// The value of the `key: value` step input in the workflow, or `None` + /// when the key is absent. + /// + /// Hand-rolled rather than parsed with a YAML crate to keep the dependency + /// tree unchanged, the same way `mod api_description_tests` above reads the + /// spec. It relies on the input sitting alone on its line; comment lines + /// are skipped, so the header comment's prose about `fail-on` does not + /// count. More than one occurrence is a panic, because then "the + /// workflow's setting" is not a single thing. + fn workflow_input(key: &str) -> Option { + let prefix = format!("{key}:"); + let values: Vec = WORKFLOW + .lines() + .map(str::trim) + .filter(|line| !line.starts_with('#')) + .filter_map(|line| line.strip_prefix(prefix.as_str())) + .map(|value| value.trim().trim_matches(['"', '\'']).to_owned()) + .collect(); + assert!( + values.len() <= 1, + "{WORKFLOW_PATH} sets {key} {} times, so which one the gate runs \ + with is anyone's guess: {values:?}", + values.len() + ); + values.into_iter().next() + } + + /// The non-comment lines of the workflow's `on:` block, from `on:` up to + /// `jobs:`. + /// + /// Deliberately loose about the YAML shape, because `on:` is legal as a + /// mapping, a list or a bare scalar and all three name the event inside + /// this window. Matching the mapping form byte-for-byte would go red on a + /// rewrite that changes nothing about when the gate runs, and a check that + /// cries wolf is the one that gets deleted. + fn trigger_block() -> Vec<&'static str> { + WORKFLOW + .lines() + .map(str::trim) + .filter(|line| !line.starts_with('#')) + .skip_while(|line| !line.starts_with("on:")) + .take_while(|line| !line.starts_with("jobs:")) + .collect() + } + + /// The constants above are only worth something if they describe the job + /// that actually runs. Nothing else checks that: a wrong pair fails open, + /// and so does a right pair the workflow never got. + /// + /// A gate that is present and correctly configured but never *triggered* + /// fails open the same way and is the one thing the settings cannot show, + /// so the trigger is asserted first: on `pull_request`, with no path filter + /// and no `if:`. The workflow's own header comment names the foreseeable + /// edit ("There is deliberately no `on: paths:` filter"), and a later + /// `paths:` would skip the gate on every PR that does not touch the spec + /// while everything below here stayed green. + /// + /// This needs no `oasdiff`, so it runs in CI where the mutation test skips. + /// It is red on the branch that changes the settings until a maintainer + /// applies the workflow patch (the App has no `workflows: write`), which is + /// the intended order: the test goes green when the gate is real. + #[test] + fn the_workflow_uses_the_settings_this_module_pins() { + assert!( + WORKFLOW.contains("uses: oasdiff/oasdiff-action/breaking@"), + "{WORKFLOW_PATH} no longer runs oasdiff-action/breaking, so this \ + module pins the settings of a job that is gone" + ); + + let triggers = trigger_block(); + assert!( + triggers.iter().any(|line| line.contains("pull_request")), + "{WORKFLOW_PATH} no longer triggers on `pull_request`, so the \ + settings pinned below belong to a gate that never sees one: \ + {triggers:?}" + ); + assert!( + !triggers + .iter() + .any(|line| line.starts_with("paths:") || line.starts_with("paths-ignore:")), + "{WORKFLOW_PATH} has a path filter on its trigger, so the gate is \ + skipped on the PRs that do not touch the spec rather than passing \ + them, and as a required check it leaves those PRs pending forever: \ + {triggers:?}" + ); + assert!( + !WORKFLOW + .lines() + .map(str::trim) + .filter(|line| !line.starts_with('#')) + .any(|line| line.starts_with("if:")), + "{WORKFLOW_PATH} has an `if:` condition, so the gate can be skipped \ + on the very PRs it exists to judge while the settings below still \ + read correctly" + ); + + let expected = [ + ("fail-on", Some(FAIL_ON)), + ("include-checks", Some(INCLUDE_CHECKS)), + ]; + let wrong: Vec = expected + .iter() + .filter_map(|(key, want)| { + let got = workflow_input(key); + (got.as_deref() != *want).then(|| { + format!( + " {key}: the gate runs with {}, this module pins {}", + got.as_deref().unwrap_or("nothing"), + want.unwrap_or("nothing"), + ) + }) + }) + .collect(); + + assert!( + wrong.is_empty(), + "{WORKFLOW_PATH} and this module disagree about what the gate \ + does:\n{}\n\ + Whichever side is behind, the other is a claim nothing backs: at \ + fail-on=ERR with no include-checks the gate passes ten of the \ + changes the spec's contract forbids, and the mutation test below \ + would certify settings it does not use.", + wrong.join("\n"), + ); + } + + /// Whether the gate stops a change, i.e. whether the job goes red. + #[derive(Debug, PartialEq, Eq)] + enum Gate { + /// Additive as far as a deployed client is concerned. + Passes, + /// Breaking: cryptify's routes are unversioned, so this reaches every + /// client pinned to the spec the moment it deploys. + Stops, + } + + impl Gate { + fn verb(&self) -> &'static str { + match self { + Gate::Passes => "pass", + Gate::Stops => "stop", + } + } + + fn past(&self) -> &'static str { + match self { + Gate::Passes => "passed", + Gate::Stops => "stopped", + } + } + } + + fn spec_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("api-description.yaml") + } + + /// The `oasdiff` binary, or `None` when it is not installed. + fn oasdiff() -> Option { + if let Some(explicit) = env::var_os("OASDIFF") { + return Some(PathBuf::from(explicit)); + } + let found = Command::new("oasdiff") + .arg("--help") + .output() + .is_ok_and(|out| out.status.success()); + found.then(|| PathBuf::from("oasdiff")) + } + + /// Replaces `old` with `new`, requiring `old` to occur exactly once so a + /// spec edit that moves an anchor fails loudly instead of silently mutating + /// nothing. + fn once(text: &str, old: &str, new: &str) -> String { + assert_eq!( + text.matches(old).count(), + 1, + "anchor is not unique in the spec, so this mutation no longer means \ + what it says: {old:?}" + ); + text.replacen(old, new, 1) + } + + /// The half-open byte range of the block starting at `start` and ending + /// where the next `end` begins. + fn block(text: &str, start: &str, end: &str) -> (usize, usize) { + let from = text.find(start).unwrap_or_else(|| panic!("no {start:?}")); + let to = text[from..] + .find(end) + .unwrap_or_else(|| panic!("no {end:?} after {start:?}")); + (from, from + to) + } + + /// Runs the gate exactly as the workflow's step does: the committed spec as + /// the base, `revision` as the PR's version. + /// + /// The entrypoint of `oasdiff/oasdiff-action/breaking@v0.1.10` turns the + /// step inputs into `--allow-external-refs=false --include-checks + /// --composed=false --fail-on `, so those are the flags used here. + /// Exit 0 is a clean diff and exit 1 is "breaking changes found"; anything + /// else is the engine refusing the input, which means the mutation produced + /// a spec oasdiff cannot load and any verdict read off it is meaningless. + fn gate(oasdiff: &Path, revision: &Path) -> Gate { + let out = Command::new(oasdiff) + .arg("breaking") + .arg(spec_path()) + .arg(revision) + .arg("--allow-external-refs=false") + .args(["--include-checks", INCLUDE_CHECKS]) + .arg("--composed=false") + .args(["--fail-on", FAIL_ON]) + .output() + .expect("run oasdiff"); + + match out.status.code() { + Some(0) => Gate::Passes, + Some(1) => Gate::Stops, + other => panic!( + "oasdiff exited {other:?} instead of 0 or 1, so it never reached \ + a verdict:\n{}\n{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr), + ), + } + } + + // The spec blocks the mutations below anchor on. Each is unique in the + // spec, and `once` fails the test if that ever stops being true. + + const RECOVERY_TOKEN_PARAMETER: &str = r##" - in: "header" + name: "X-Recovery-Token" + description: + "Bearer credential issued in the `recovery_token` field of + the `upload_init` response. Compared in constant time on the + server. Missing / empty → 401." + schema: + type: "string" + required: true +"##; + + const RANGE_PARAMETER: &str = r##" - in: "header" + name: "Range" + description: + "Optional single byte range, `bytes=-`, + `bytes=-` or `bytes=-`. Multiple ranges are not + supported and are answered with 416." + required: false + schema: + type: "string" +"##; + + const NOTIFY_RECIPIENTS_PROPERTY: &str = r##" notifyRecipients: + type: "boolean" + default: true + example: true + description: "Whether to email each recipient with a download link. Optional; defaults to true. Set to false to upload silently when the encrypted payload reaches recipients through another channel and a Cryptify-sent notification would be a duplicate." +"##; + + const SESSION_NOT_FOUND_REASONS: &str = r##" - "expired_or_unknown" + - "invalid_uuid" + - "file_missing" +"##; + + const UPLOAD_STATUS_REQUIRED: &str = r##" required: + - uploaded + - cryptify_token + properties: + uploaded: +"##; + + const USAGE_EMAIL_SCHEMA: &str = r##" required: false + schema: + type: "string" + format: "email" +"##; + + const EMAIL_TEMPLATE_503: &str = + " \"503\":\n description: \"pg-pkg was unreachable while validating the API key.\"\n"; + + // ----------------------------------------------------------------------- + // Additive: allowed, so the gate must let these through. A gate that stops + // an additive change is worse than no gate, because the way around it is to + // switch it off. + // ----------------------------------------------------------------------- + + fn add_endpoint(spec: &str) -> String { + once( + spec, + " /metrics:\n", + r##" /echo: + get: + tags: + - "Health" + summary: "Echo the request back" + operationId: "echo" + responses: + "200": + description: "ok" + /metrics: +"##, + ) + } + + fn add_optional_response_property(spec: &str) -> String { + once( + spec, + " prev_token:\n type: \"string\"\n", + r##" stalled: + type: "boolean" + description: "Whether the upload has seen no chunk for a while." + prev_token: + type: "string" +"##, + ) + } + + fn add_optional_request_property(spec: &str) -> String { + once( + spec, + " notifyRecipients:\n", + r##" clientHint: + type: "string" + description: "Free-form client identification." + notifyRecipients: +"##, + ) + } + + fn add_required_response_property(spec: &str) -> String { + once( + spec, + UPLOAD_STATUS_REQUIRED, + r##" required: + - uploaded + - cryptify_token + - chunk_size + properties: + chunk_size: + type: "integer" + format: "int64" + uploaded: +"##, + ) + } + + fn add_optional_query_parameter(spec: &str) -> String { + once( + spec, + " operationId: \"health\"\n", + r##" operationId: "health" + parameters: + - in: "query" + name: "verbose" + required: false + schema: + type: "boolean" +"##, + ) + } + + fn add_response_status(spec: &str) -> String { + once( + spec, + EMAIL_TEMPLATE_503, + &format!( + " \"429\":\n description: \"Rate limited.\"\n{EMAIL_TEMPLATE_503}" + ), + ) + } + + fn edit_a_description(spec: &str) -> String { + once( + spec, + "summary: \"Health check endpoint\"", + "summary: \"Health check endpoint (liveness)\"", + ) + } + + /// The only escape hatch cryptify has. Its routes are unversioned, so a + /// change the current shape cannot take additively ships as a new versioned + /// route with the old one left running. If the gate stopped this there + /// would be no way to make a breaking change at all. + fn add_versioned_route_beside_the_unversioned_one(spec: &str) -> String { + let (from, to) = block(spec, " /usage:\n", " /email-template:\n"); + let versioned = once(&spec[from..to], " /usage:\n", " /v2/usage:\n"); + let versioned = once( + &versioned, + "operationId: \"getUsage\"\n", + "operationId: \"getUsageV2\"\n", + ); + once( + spec, + " /email-template:\n", + &format!("{versioned} /email-template:\n"), + ) + } + + /// A wider *request* enum is additive: the server accepts a language it did + /// not before, and no deployed client sends one it does not know about. + fn add_a_request_enum_value(spec: &str) -> String { + once( + spec, + "enum: [\"EN\", \"NL\"]", + "enum: [\"EN\", \"NL\", \"DE\"]", + ) + } + + fn add_optional_response_header(spec: &str) -> String { + once( + spec, + r##" description: "Successful operation" + "400": + description: + "The `cryptifytoken` header does not match the token the server +"##, + r##" description: "Successful operation" + headers: + X-Upload-Id: + schema: + type: "string" + "400": + description: + "The `cryptifytoken` header does not match the token the server +"##, + ) + } + + // ----------------------------------------------------------------------- + // Breaking: each one breaks a client written against today's spec, and on + // unversioned routes there is nowhere for such a client to stay. + // ----------------------------------------------------------------------- + + fn remove_route(spec: &str) -> String { + let (from, to) = block(spec, " /email-template:\n", " /filedownload/{uuid}:\n"); + format!("{}{}", &spec[..from], &spec[to..]) + } + + fn request_property_becomes_required(spec: &str) -> String { + once( + spec, + " - confirm\n", + " - confirm\n - notifyRecipients\n", + ) + } + + /// A client that handles 401 by prompting for an API key sees an unhandled + /// 403. + fn change_a_status_code(spec: &str) -> String { + once( + spec, + r##" "401": + description: + "No valid `Authorization: Bearer PG-…` API key was presented. Usage +"##, + r##" "403": + description: + "No valid `Authorization: Bearer PG-…` API key was presented. Usage +"##, + ) + } + + fn remove_a_non_success_status(spec: &str) -> String { + once(spec, EMAIL_TEMPLATE_503, "") + } + + fn remove_a_response_enum_value(spec: &str) -> String { + once( + spec, + SESSION_NOT_FOUND_REASONS, + " - \"expired_or_unknown\"\n - \"invalid_uuid\"\n", + ) + } + + /// The one rule the gate adds on top of "no removing or narrowing": a + /// widened *response* enum is a change a client cannot see coming, and it + /// is caught only at WARN, so a revert to `fail-on: ERR` drops it silently + /// unless it is pinned here. See CLAUDE.md for the reasoning. + fn add_a_response_enum_value(spec: &str) -> String { + once( + spec, + SESSION_NOT_FOUND_REASONS, + &format!("{SESSION_NOT_FOUND_REASONS} - \"quota_exceeded\"\n"), + ) + } + + /// `prev_offset` is optional only because it is absent until the first + /// chunk lands; a resuming client reads it on every recovery. + fn remove_optional_response_property(spec: &str) -> String { + once( + spec, + r##" prev_offset: + type: "integer" + format: "int64" + description: + "Byte offset where the most recently committed chunk started + (i.e. `uploaded - chunk_len`). Omitted until at least one + chunk has been committed." +"##, + "", + ) + } + + fn rename_optional_response_property(spec: &str) -> String { + once( + spec, + " prev_token:\n type: \"string\"\n", + " previous_token:\n type: \"string\"\n", + ) + } + + fn remove_required_response_property(spec: &str) -> String { + once( + spec, + r##" required: + - uploaded + - cryptify_token + properties: + uploaded: + type: "integer" + format: "int64" + description: + "Total bytes the server has committed for this upload so far. + The client should resume from this offset." +"##, + " required:\n - cryptify_token\n properties:\n", + ) + } + + fn narrow_a_response_property_type(spec: &str) -> String { + once( + spec, + " window_days:\n type: \"integer\"\n", + " window_days:\n type: \"string\"\n", + ) + } + + /// The constraint-narrowing `*-set` family: a value the server used to + /// accept now fails validation. + fn narrow_a_request_parameter(spec: &str) -> String { + once( + spec, + USAGE_EMAIL_SCHEMA, + &format!("{USAGE_EMAIL_SCHEMA} maxLength: 64\n"), + ) + } + + fn remove_a_required_request_parameter(spec: &str) -> String { + once(spec, RECOVERY_TOKEN_PARAMETER, "") + } + + fn remove_an_optional_request_parameter(spec: &str) -> String { + once(spec, RANGE_PARAMETER, "") + } + + fn remove_a_request_property(spec: &str) -> String { + once(spec, NOTIFY_RECIPIENTS_PROPERTY, "") + } + + fn remove_a_response_media_type(spec: &str) -> String { + once( + spec, + r##" "200": + description: "Service is healthy" + content: + text/plain: + schema: + type: "string" + example: "OK" +"##, + " \"200\":\n description: \"Service is healthy\"\n", + ) + } + + fn remove_a_required_response_header(spec: &str) -> String { + once( + spec, + r##" headers: + cryptifytoken: + required: true + schema: + description: "Identifies the new version of the upload file parts. Needs to be passed into the next file part upload request." + type: "string" +"##, + "", + ) + } + + type Mutation = (&'static str, fn(&str) -> String, Gate); + + fn mutations() -> Vec { + vec![ + ("a new endpoint", add_endpoint, Gate::Passes), + ( + "a new optional response property", + add_optional_response_property, + Gate::Passes, + ), + ( + "a new optional request property", + add_optional_request_property, + Gate::Passes, + ), + ( + "a new required response property", + add_required_response_property, + Gate::Passes, + ), + ( + "a new optional query parameter", + add_optional_query_parameter, + Gate::Passes, + ), + ("a new response status", add_response_status, Gate::Passes), + ("an edited description", edit_a_description, Gate::Passes), + ( + "a versioned route beside the unversioned one", + add_versioned_route_beside_the_unversioned_one, + Gate::Passes, + ), + ( + "a new request enum value", + add_a_request_enum_value, + Gate::Passes, + ), + ( + "a new optional response header", + add_optional_response_header, + Gate::Passes, + ), + ("a removed route", remove_route, Gate::Stops), + ( + "a request property becoming required", + request_property_becomes_required, + Gate::Stops, + ), + ("a changed status code", change_a_status_code, Gate::Stops), + ( + "a removed non-success status", + remove_a_non_success_status, + Gate::Stops, + ), + ( + "a removed response enum value", + remove_a_response_enum_value, + Gate::Stops, + ), + ( + "a new response enum value", + add_a_response_enum_value, + Gate::Stops, + ), + ( + "a removed optional response property", + remove_optional_response_property, + Gate::Stops, + ), + ( + "a renamed optional response property", + rename_optional_response_property, + Gate::Stops, + ), + ( + "a removed required response property", + remove_required_response_property, + Gate::Stops, + ), + ( + "a narrowed response property type", + narrow_a_response_property_type, + Gate::Stops, + ), + ( + "a narrowed request parameter constraint", + narrow_a_request_parameter, + Gate::Stops, + ), + ( + "a removed required request parameter", + remove_a_required_request_parameter, + Gate::Stops, + ), + ( + "a removed optional request parameter", + remove_an_optional_request_parameter, + Gate::Stops, + ), + ( + "a removed request property", + remove_a_request_property, + Gate::Stops, + ), + ( + "a removed response media type", + remove_a_response_media_type, + Gate::Stops, + ), + ( + "a removed required response header", + remove_a_required_response_header, + Gate::Stops, + ), + ] + } + + /// Every mutation must still edit the spec, whether or not oasdiff is + /// installed, so a spec edit that strands an anchor is caught in CI too. + #[test] + fn every_api_gate_mutation_still_applies() { + let spec = fs::read_to_string(spec_path()).expect("read the spec"); + for (name, mutate, _) in mutations() { + assert_ne!( + mutate(&spec), + spec, + "the mutation for {name} changed nothing, so whatever it \ + asserts is vacuous" + ); + } + } + + #[test] + fn the_api_gate_stops_breaking_changes_and_passes_additive_ones() { + let Some(oasdiff) = oasdiff() else { + eprintln!( + "skipping: oasdiff is not installed, which is the case on every \ + runner. To run this test, `go install \ + github.com/oasdiff/oasdiff@v1.26.1` (the version the action \ + pins), or set OASDIFF." + ); + return; + }; + + let spec = fs::read_to_string(spec_path()).expect("read the spec"); + let dir = env::temp_dir().join(format!("cryptify-api-gate-{}", std::process::id())); + fs::create_dir_all(&dir).expect("create the scratch directory"); + + // The unmutated spec first: without this, a gate that stopped + // everything would satisfy every Stops case below and only look half + // broken. + let unchanged = dir.join("unchanged.yaml"); + fs::write(&unchanged, &spec).expect("write the spec"); + let baseline = gate(&oasdiff, &unchanged); + + let mut wrong = Vec::new(); + if baseline != Gate::Passes { + wrong.push(" no change at all: the gate should pass it, it stopped it".to_owned()); + } + for (name, mutate, expected) in mutations() { + let slug: String = name + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) + .collect(); + let revision = dir.join(format!("{slug}.yaml")); + fs::write(&revision, mutate(&spec)).expect("write the mutated spec"); + let actual = gate(&oasdiff, &revision); + if actual != expected { + wrong.push(format!( + " {name}: the gate should {} it, it {} it", + expected.verb(), + actual.past() + )); + } + } + + fs::remove_dir_all(&dir).ok(); + assert!( + wrong.is_empty(), + "the gate's verdict on {} of {} changes is not what fail-on={FAIL_ON} \ + and include-checks={INCLUDE_CHECKS} are supposed to deliver:\n{}", + wrong.len(), + mutations().len() + 1, + wrong.join("\n"), + ); + } +} diff --git a/cryptify/src/metrics.rs b/cryptify/src/metrics.rs new file mode 100644 index 00000000..6d44c81d --- /dev/null +++ b/cryptify/src/metrics.rs @@ -0,0 +1,546 @@ +//! Usage metrics for Grafana scraping. +//! +//! Exposes a Prometheus text-format `/metrics` endpoint covering: +//! - uploads completed, split by traffic source ("channel") +//! - bytes uploaded, split by channel +//! - current on-disk storage bytes and active file count (sampled +//! periodically by a background task) +//! +//! See `docs/grafana/` for the reference dashboard JSON. + +use std::collections::BTreeMap; +use std::fmt::Write as _; +use std::path::Path; +use std::sync::atomic::{AtomicI64, AtomicU64, Ordering}; +use std::sync::Mutex; +use std::time::Duration; + +use rocket::http::HeaderMap; + +/// Channel label used when no other source information is present. +pub const CHANNEL_UNKNOWN: &str = "unknown"; + +/// Channels pre-seeded at value 0 on startup so dashboards see the full +/// label set from the first scrape, rather than each channel popping into +/// existence the first time a request from it lands. Without this, PromQL +/// `increase()` over a window can read `0` for a channel whose first +/// observed sample is already non-zero — see #102 follow-up discussion. +pub const KNOWN_CHANNELS: &[&str] = &[ + "website", + "staging-website", + "outlook", + "thunderbird", + "api", + CHANNEL_UNKNOWN, +]; + +/// Client apps pre-seeded at value 0 on startup so dashboards see the full +/// label set from the first scrape (same rationale as `KNOWN_CHANNELS`). +/// These are the `app` field of the `X-POSTGUARD-CLIENT-VERSION` header. +pub const KNOWN_APPS: &[&str] = &["pg-js", "pg-dotnet", "pg4ol", "pg4tb", CHANNEL_UNKNOWN]; + +/// Header clients can set to identify themselves (`outlook`, `thunderbird`, +/// `api`, ...). Leading whitespace is trimmed and the value is lowercased +/// and restricted to `[a-z0-9_-]` so it cannot inject Prometheus syntax. +pub const SOURCE_HEADER: &str = "X-Cryptify-Source"; + +/// Structured client-identity header shared with pg-pkg. Value format is +/// `host,host_version,app,app_version` (e.g. `node,22.1.0,pg-js,1.2.3` or +/// `Outlook,1.0,pg4ol,0.0.1`). Captured for logging (the full raw value) and +/// for the per-app upload metric (the `app` field only). +pub const CLIENT_VERSION_HEADER: &str = "X-POSTGUARD-CLIENT-VERSION"; + +/// Parsed form of the `X-POSTGUARD-CLIENT-VERSION` header. All four fields +/// are kept for completeness and logging/inspection; only `app` is consumed +/// for the metric label today. +#[allow(dead_code)] +pub struct ClientVersion { + pub host: String, + pub host_version: String, + pub app: String, + pub app_version: String, +} + +/// Parse the 4-field client-version header. Returns `None` unless the value +/// has exactly four comma-separated fields (matching pg-pkg's strict +/// destructuring). Fields are trimmed but otherwise left raw — callers that +/// want a metric label must `sanitize_label` the `app` field themselves. +pub fn parse_client_version(raw: &str) -> Option { + let parts: Vec<&str> = raw.split(',').map(str::trim).collect(); + if let [host, host_version, app, app_version] = parts[..] { + Some(ClientVersion { + host: host.to_string(), + host_version: host_version.to_string(), + app: app.to_string(), + app_version: app_version.to_string(), + }) + } else { + None + } +} + +pub struct Metrics { + uploads: Mutex>, + upload_bytes: Mutex>, + uploads_by_app: Mutex>, + storage_bytes: AtomicI64, + active_files: AtomicI64, + expired_files: AtomicU64, +} + +// `Default` is implemented manually (not derived) so it goes through +// `Metrics::new()` and pre-seeds `KNOWN_CHANNELS`. A derived `Default` +// would silently produce an empty-channel object, which diverges from +// `new()` and re-introduces the missing-baseline problem this module +// exists to solve. +impl Default for Metrics { + fn default() -> Self { + Self::new() + } +} + +impl Metrics { + pub fn new() -> Self { + let mut uploads = BTreeMap::new(); + let mut bytes = BTreeMap::new(); + for c in KNOWN_CHANNELS { + uploads.insert((*c).to_string(), 0u64); + bytes.insert((*c).to_string(), 0u64); + } + let mut by_app = BTreeMap::new(); + for a in KNOWN_APPS { + by_app.insert((*a).to_string(), 0u64); + } + Self { + uploads: Mutex::new(uploads), + upload_bytes: Mutex::new(bytes), + uploads_by_app: Mutex::new(by_app), + storage_bytes: AtomicI64::new(0), + active_files: AtomicI64::new(0), + expired_files: AtomicU64::new(0), + } + } + + /// Record a successfully finalized upload. + pub fn record_upload(&self, channel: &str, bytes: u64) { + let channel = sanitize_label(channel); + let mut uploads = self.uploads.lock().unwrap(); + *uploads.entry(channel.clone()).or_insert(0) += 1; + let mut bytes_map = self.upload_bytes.lock().unwrap(); + *bytes_map.entry(channel).or_insert(0) += bytes; + } + + /// Record a finalized upload against the client `app` that sent it (the + /// `app` field of `X-POSTGUARD-CLIENT-VERSION`). Cardinality-safe: the + /// full version is never a label (it lives in logs); only the sanitized + /// app name is used here. + pub fn record_upload_app(&self, app: &str) { + let app = sanitize_label(app); + let mut by_app = self.uploads_by_app.lock().unwrap(); + *by_app.entry(app).or_insert(0) += 1; + } + + /// Record an upload that expired / was purged without finalizing. + pub fn record_expired(&self) { + self.expired_files.fetch_add(1, Ordering::Relaxed); + } + + /// Update the current on-disk storage sample. + pub fn set_storage(&self, bytes: i64, active_files: i64) { + self.storage_bytes.store(bytes, Ordering::Relaxed); + self.active_files.store(active_files, Ordering::Relaxed); + } + + /// Render all metrics in Prometheus text-exposition format. + pub fn render(&self) -> String { + let mut out = String::new(); + + let _ = writeln!( + out, + "# HELP cryptify_uploads_total Total finalized uploads per channel." + ); + let _ = writeln!(out, "# TYPE cryptify_uploads_total counter"); + let uploads = self.uploads.lock().unwrap(); + if uploads.is_empty() { + let _ = writeln!( + out, + "cryptify_uploads_total{{channel=\"{}\"}} 0", + CHANNEL_UNKNOWN + ); + } else { + for (channel, count) in uploads.iter() { + let _ = writeln!( + out, + "cryptify_uploads_total{{channel=\"{}\"}} {}", + channel, count + ); + } + } + drop(uploads); + + let _ = writeln!( + out, + "# HELP cryptify_upload_bytes_total Total bytes uploaded per channel." + ); + let _ = writeln!(out, "# TYPE cryptify_upload_bytes_total counter"); + let bytes = self.upload_bytes.lock().unwrap(); + if bytes.is_empty() { + let _ = writeln!( + out, + "cryptify_upload_bytes_total{{channel=\"{}\"}} 0", + CHANNEL_UNKNOWN + ); + } else { + for (channel, b) in bytes.iter() { + let _ = writeln!( + out, + "cryptify_upload_bytes_total{{channel=\"{}\"}} {}", + channel, b + ); + } + } + drop(bytes); + + let _ = writeln!( + out, + "# HELP cryptify_uploads_by_app_total Total finalized uploads per client app." + ); + let _ = writeln!(out, "# TYPE cryptify_uploads_by_app_total counter"); + let by_app = self.uploads_by_app.lock().unwrap(); + if by_app.is_empty() { + let _ = writeln!( + out, + "cryptify_uploads_by_app_total{{app=\"{}\"}} 0", + CHANNEL_UNKNOWN + ); + } else { + for (app, count) in by_app.iter() { + let _ = writeln!( + out, + "cryptify_uploads_by_app_total{{app=\"{}\"}} {}", + app, count + ); + } + } + drop(by_app); + + let _ = writeln!( + out, + "# HELP cryptify_storage_bytes Current bytes of uploads held on disk." + ); + let _ = writeln!(out, "# TYPE cryptify_storage_bytes gauge"); + let _ = writeln!( + out, + "cryptify_storage_bytes {}", + self.storage_bytes.load(Ordering::Relaxed) + ); + + let _ = writeln!( + out, + "# HELP cryptify_active_files Number of upload files currently on disk." + ); + let _ = writeln!(out, "# TYPE cryptify_active_files gauge"); + let _ = writeln!( + out, + "cryptify_active_files {}", + self.active_files.load(Ordering::Relaxed) + ); + + let _ = writeln!( + out, + "# HELP cryptify_expired_files_total Uploads that expired before being finalized." + ); + let _ = writeln!(out, "# TYPE cryptify_expired_files_total counter"); + let _ = writeln!( + out, + "cryptify_expired_files_total {}", + self.expired_files.load(Ordering::Relaxed) + ); + + out + } +} + +/// Derive the channel label for a request from its headers. +/// +/// Priority: +/// 1. `X-Cryptify-Source` explicit header. +/// 2. API auth (`Authorization: Bearer …` or `X-Api-Key`) → `api`. +/// 3. `Origin` → `staging-website` / `website`. +/// 4. `User-Agent` substring for Outlook / Thunderbird. +/// 5. `unknown`. +pub fn detect_channel(headers: &HeaderMap<'_>) -> String { + if let Some(raw) = headers.get_one(SOURCE_HEADER) { + let cleaned = sanitize_label(raw); + if !cleaned.is_empty() && cleaned != CHANNEL_UNKNOWN { + return cleaned; + } + } + if headers.get_one("X-Api-Key").is_some() + || headers + .get_one("Authorization") + .map(|v| v.trim_start().to_ascii_lowercase().starts_with("bearer ")) + .unwrap_or(false) + { + return "api".to_string(); + } + if let Some(origin) = headers.get_one("Origin") { + let o = origin.to_ascii_lowercase(); + if o.contains("staging.postguard") || o.contains("staging-postguard") { + return "staging-website".to_string(); + } + if o.contains("postguard.") { + return "website".to_string(); + } + } + if let Some(ua) = headers.get_one("User-Agent") { + let ua = ua.to_ascii_lowercase(); + if ua.contains("outlook") { + return "outlook".to_string(); + } + if ua.contains("thunderbird") { + return "thunderbird".to_string(); + } + } + CHANNEL_UNKNOWN.to_string() +} + +/// Reduce an arbitrary string to a safe Prometheus label value: +/// lower-case, `[a-z0-9_-]`, max 32 chars, non-empty (falls back to +/// `unknown`). This prevents clients from injecting label syntax or +/// exploding cardinality with arbitrary inputs. +fn sanitize_label(raw: &str) -> String { + let cleaned: String = raw + .trim() + .to_ascii_lowercase() + .chars() + .map(|c| match c { + 'a'..='z' | '0'..='9' | '-' | '_' => c, + _ => '-', + }) + .take(32) + .collect(); + let trimmed = cleaned.trim_matches('-').to_string(); + if trimmed.is_empty() { + CHANNEL_UNKNOWN.to_string() + } else { + trimmed + } +} + +/// Walk `data_dir` once and return `(total_bytes, file_count)`. Symlinks +/// and subdirectories are ignored — the upload directory is a flat +/// directory of files named by UUID. +pub fn sample_storage(data_dir: &Path) -> std::io::Result<(i64, i64)> { + let mut total: i64 = 0; + let mut count: i64 = 0; + match std::fs::read_dir(data_dir) { + Ok(rd) => { + for entry in rd.flatten() { + if let Ok(meta) = entry.metadata() { + if meta.is_file() { + total = total.saturating_add(meta.len() as i64); + count += 1; + } + } + } + Ok((total, count)) + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok((0, 0)), + Err(e) => Err(e), + } +} + +/// Periodically sample `data_dir` and push the numbers onto `metrics`. +pub async fn storage_sampler( + metrics: std::sync::Arc, + data_dir: std::path::PathBuf, + interval: Duration, +) { + loop { + match sample_storage(&data_dir) { + Ok((bytes, count)) => metrics.set_storage(bytes, count), + Err(e) => log::warn!("metrics: storage sampling failed for {:?}: {}", data_dir, e), + } + rocket::tokio::time::sleep(interval).await; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rocket::http::Header; + + fn headers(pairs: &[(&'static str, &'static str)]) -> rocket::http::HeaderMap<'static> { + let mut h = rocket::http::HeaderMap::new(); + for (k, v) in pairs { + h.add(Header::new(*k, *v)); + } + h + } + + #[test] + fn channel_explicit_header_wins() { + let h = headers(&[ + ("X-Cryptify-Source", "OUTLOOK"), + ("Origin", "https://postguard.eu"), + ]); + assert_eq!(detect_channel(&h), "outlook"); + } + + #[test] + fn channel_bearer_is_api() { + let h = headers(&[("Authorization", "Bearer abc123")]); + assert_eq!(detect_channel(&h), "api"); + } + + #[test] + fn channel_api_key_is_api() { + let h = headers(&[("X-Api-Key", "s3cret")]); + assert_eq!(detect_channel(&h), "api"); + } + + #[test] + fn channel_origin_staging() { + let h = headers(&[("Origin", "https://staging.postguard.eu")]); + assert_eq!(detect_channel(&h), "staging-website"); + } + + #[test] + fn channel_origin_production() { + let h = headers(&[("Origin", "https://postguard.eu")]); + assert_eq!(detect_channel(&h), "website"); + } + + #[test] + fn channel_user_agent_outlook() { + let h = headers(&[("User-Agent", "Mozilla Outlook/16.0")]); + assert_eq!(detect_channel(&h), "outlook"); + } + + #[test] + fn channel_user_agent_thunderbird() { + let h = headers(&[("User-Agent", "Thunderbird/115.0")]); + assert_eq!(detect_channel(&h), "thunderbird"); + } + + #[test] + fn channel_defaults_to_unknown() { + let h = headers(&[]); + assert_eq!(detect_channel(&h), "unknown"); + } + + #[test] + fn sanitize_strips_unsafe_chars_and_caps_length() { + assert_eq!(sanitize_label("Outlook\n\"}"), "outlook"); + assert_eq!(sanitize_label(""), "unknown"); + assert_eq!(sanitize_label(" "), "unknown"); + let long = "a".repeat(100); + assert_eq!(sanitize_label(&long).len(), 32); + } + + #[test] + fn parse_client_version_happy_path() { + let cv = parse_client_version("Outlook,1.0,pg4ol,0.0.1").unwrap(); + assert_eq!(cv.host, "Outlook"); + assert_eq!(cv.host_version, "1.0"); + assert_eq!(cv.app, "pg4ol"); + assert_eq!(cv.app_version, "0.0.1"); + } + + #[test] + fn parse_client_version_trims_fields() { + let cv = parse_client_version(" node , 22.1.0 , pg-js , 1.2.3 ").unwrap(); + assert_eq!(cv.host, "node"); + assert_eq!(cv.app, "pg-js"); + assert_eq!(cv.app_version, "1.2.3"); + } + + #[test] + fn parse_client_version_rejects_wrong_field_count() { + assert!(parse_client_version("").is_none()); + assert!(parse_client_version("a,b,c").is_none()); + assert!(parse_client_version("a,b,c,d,e").is_none()); + } + + #[test] + fn record_upload_app_aggregates_and_sanitizes() { + let m = Metrics::new(); + m.record_upload_app("pg-js"); + m.record_upload_app("pg-js"); + m.record_upload_app("pg-dotnet"); + // Unsafe input is sanitized to the same label as the clean form. + m.record_upload_app("pg-js\n\"}"); + let text = m.render(); + assert!(text.contains("cryptify_uploads_by_app_total{app=\"pg-js\"} 3")); + assert!(text.contains("cryptify_uploads_by_app_total{app=\"pg-dotnet\"} 1")); + } + + #[test] + fn render_preseeds_known_apps_at_zero() { + let m = Metrics::new(); + let text = m.render(); + for a in KNOWN_APPS { + assert!( + text.contains(&format!("cryptify_uploads_by_app_total{{app=\"{a}\"}} 0")), + "missing zero-seed for app={a} in:\n{text}" + ); + } + } + + #[test] + fn render_preseeds_known_channels_at_zero() { + let m = Metrics::new(); + let text = m.render(); + for c in KNOWN_CHANNELS { + assert!( + text.contains(&format!("cryptify_uploads_total{{channel=\"{c}\"}} 0")), + "missing zero-seed for uploads channel={c} in:\n{text}" + ); + assert!( + text.contains(&format!("cryptify_upload_bytes_total{{channel=\"{c}\"}} 0")), + "missing zero-seed for upload_bytes channel={c} in:\n{text}" + ); + } + assert!(text.contains("cryptify_storage_bytes 0")); + assert!(text.contains("cryptify_active_files 0")); + assert!(text.contains("cryptify_expired_files_total 0")); + } + + #[test] + fn render_aggregates_by_channel() { + let m = Metrics::new(); + m.record_upload("website", 1_000); + m.record_upload("website", 500); + m.record_upload("outlook", 250); + m.record_expired(); + m.set_storage(9_999, 3); + let text = m.render(); + assert!(text.contains("cryptify_uploads_total{channel=\"website\"} 2")); + assert!(text.contains("cryptify_uploads_total{channel=\"outlook\"} 1")); + assert!(text.contains("cryptify_upload_bytes_total{channel=\"website\"} 1500")); + assert!(text.contains("cryptify_upload_bytes_total{channel=\"outlook\"} 250")); + assert!(text.contains("cryptify_storage_bytes 9999")); + assert!(text.contains("cryptify_active_files 3")); + assert!(text.contains("cryptify_expired_files_total 1")); + } + + #[test] + fn sample_storage_missing_dir_is_zero() { + let tmp = std::env::temp_dir().join("cryptify-metrics-missing-xyz"); + let (bytes, count) = sample_storage(&tmp).unwrap(); + assert_eq!((bytes, count), (0, 0)); + } + + #[test] + fn sample_storage_counts_files() { + let tmp = std::env::temp_dir().join(format!("cryptify-metrics-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&tmp).unwrap(); + std::fs::write(tmp.join("a"), b"hello").unwrap(); + std::fs::write(tmp.join("b"), b"world!").unwrap(); + let (bytes, count) = sample_storage(&tmp).unwrap(); + assert_eq!(count, 2); + assert_eq!(bytes, 11); + std::fs::remove_dir_all(&tmp).unwrap(); + } +} diff --git a/cryptify/src/store.rs b/cryptify/src/store.rs new file mode 100644 index 00000000..b748b4f5 --- /dev/null +++ b/cryptify/src/store.rs @@ -0,0 +1,742 @@ +use crate::email; +use crate::metrics::Metrics; + +use std::{ + collections::{BTreeMap, HashMap, VecDeque}, + sync::Arc, + time::Duration, +}; + +use rocket::tokio::{sync::Notify, time::Instant}; + +pub const PER_UPLOAD_LIMIT: u64 = 5_000_000_000; +pub const ROLLING_LIMIT: u64 = 5_000_000_000; +pub const API_KEY_PER_UPLOAD_LIMIT: u64 = 100_000_000_000; +pub const API_KEY_ROLLING_LIMIT: u64 = 100_000_000_000; +pub const ROLLING_WINDOW_SECS: i64 = 14 * 24 * 60 * 60; + +/// Default idle window for an in-memory upload session when no value is +/// provided in config. Each successful chunk PUT resets it; if no activity +/// is seen for this long the session is evicted (the on-disk file is left +/// alone — `FileState.expires` covers that). +#[cfg(test)] +pub const DEFAULT_UPLOAD_SESSION_IDLE_TIMEOUT_SECS: u64 = 60 * 60; + +pub struct FileState { + pub uploaded: u64, + pub cryptify_token: String, + pub expires: i64, + pub recipients: lettre::message::Mailboxes, + pub mail_content: String, + pub mail_lang: email::Language, + pub sender: Option, + pub sender_attributes: Vec<(String, String)>, + pub confirm: bool, + /// Traffic source this upload originated from ("website", "outlook", + /// "thunderbird", "api", ...). Used only for metrics labelling. + pub source_channel: String, + /// Raw `X-POSTGUARD-CLIENT-VERSION` header value + /// (`host,host_version,app,app_version`) sent by the client, captured at + /// init. Logged verbatim at init and finalize so exact client versions are + /// greppable. `None` when the header was absent. + pub client_version: Option, + /// The `app` field parsed out of `client_version` (e.g. "pg-js", + /// "pg-dotnet", "pg4ol"). Used as the `cryptify_uploads_by_app_total` + /// metric label at finalize. `None` when absent or malformed. + pub client_app: Option, + /// When false, the recipient notification email is suppressed (the + /// recipients still appear in the parsed list, but the SMTP delivery + /// loop in `send_email` is skipped). The sender confirmation, if + /// `confirm` is true, is sent regardless. + pub notify_recipients: bool, + /// Tenant identifier when the request authenticated with a `PG-…` key + /// validated against pg-pkg. `None` for unauthenticated requests, which + /// receive the lower default quota tier. Used both for limit selection + /// and as the rolling-window accounting key (`api-key:`). + pub api_key_tenant: Option, + /// True when the caller sent an `Authorization: Bearer PG-…` header but + /// pg-pkg was unreachable during the full retry budget at init time. + /// Chunk and finalize handlers consult this to differentiate 503 + /// (pkg down — would have allowed the higher tier) from 413 (default + /// tier — would have rejected anyway) once the default cap is exceeded. + pub api_key_validation_failed: bool, + /// Replay record of the most recently committed chunk. Lets the chunk + /// handler detect a duplicate retry (when the client never saw the + /// previous response): if the request's `CryptifyToken` matches + /// `prev_token` and `Content-Range.start` matches `prev_uploaded`, and + /// recomputing the rolling hash over the incoming body equals + /// `response_token`, the server replays `response_token` instead of + /// advancing the rolling-token chain or double-writing the chunk. + /// `None` until at least one chunk has been successfully committed. + pub last_chunk: Option, + /// Bearer token for the cross-refresh-resume status endpoint + /// (`GET /fileupload/{uuid}/status`). Issued at `upload_init` and + /// returned to the client alongside the first `cryptifytoken`. The + /// path UUID alone isn't authoritative (URLs leak), so any read of + /// session state requires the client to present this token in an + /// `X-Recovery-Token` header. Compared in constant time to defeat + /// timing oracles. Hex-encoded 32-byte random. + pub recovery_token: String, +} + +/// Replay record of the most recently committed chunk. See +/// [`FileState::last_chunk`]. +/// +/// Body identity is checked by recomputing the rolling hash +/// `sha256(prev_token || body)` and comparing against `response_token` — +/// the same construction the rolling-token chain itself relies on, so no +/// separate digest needs to be cached. Length differences also surface as +/// a hash mismatch. +#[derive(Clone, Debug)] +pub struct LastChunkRecord { + /// The `CryptifyToken` the client sent in the chunk PUT — i.e., the + /// rolling token *before* this chunk advanced it. A retry that lost the + /// response will keep sending this same value. + pub prev_token: String, + /// `state.uploaded` *before* this chunk was applied — equals the + /// chunk's `Content-Range` start. + pub prev_uploaded: u64, + /// The token the server returned in response to the original PUT — + /// i.e., the value of `state.cryptify_token` after this chunk was + /// applied. Replayed verbatim on a detected retry. + pub response_token: String, +} + +#[derive(Clone, Copy, Debug)] +struct UploadRecord { + timestamp: i64, + bytes: u64, +} + +/// SQLite-backed persistence for the rolling-quota usage state. +/// +/// The in-memory `StoreState.usage` map is only a cache: this database is +/// the source of truth, so per-sender quota survives pod restarts and +/// redeploys. On startup the full table is loaded back into the cache +/// ([`UsageDb::load_all`]); every accounted upload is written through here +/// ([`UsageDb::record`]) before the cache is updated. +/// +/// The connection is wrapped in a `Mutex` because `rusqlite::Connection` +/// is `Send` but not `Sync`, and `SharedState` is shared across the purge +/// task via an `Arc`. +struct UsageDb { + conn: std::sync::Mutex, +} + +impl UsageDb { + /// Open (creating if necessary) the SQLite database at `path` and ensure + /// the schema exists. + fn open(path: &str) -> rusqlite::Result { + let conn = rusqlite::Connection::open(path)?; + // WAL keeps writes from blocking the (rare) concurrent reads and + // survives an unclean pod kill better than the default rollback + // journal. + conn.pragma_update(None, "journal_mode", "WAL")?; + conn.execute( + "CREATE TABLE IF NOT EXISTS usage ( + email TEXT NOT NULL, + timestamp INTEGER NOT NULL, + bytes INTEGER NOT NULL + )", + [], + )?; + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_usage_email_ts ON usage (email, timestamp)", + [], + )?; + Ok(UsageDb { + conn: std::sync::Mutex::new(conn), + }) + } + + /// Load every persisted record into an in-memory map, grouped by email + /// and ordered oldest-first so the resulting `VecDeque`s match what the + /// in-memory path would have built. Stale records are intentionally not + /// pruned here: pruning is relative to the caller-supplied `now`, which + /// only the request path knows. + fn load_all(&self) -> rusqlite::Result>> { + let conn = self.conn.lock().unwrap(); + let mut stmt = + conn.prepare("SELECT email, timestamp, bytes FROM usage ORDER BY timestamp ASC")?; + let rows = stmt.query_map([], |row| { + let email: String = row.get(0)?; + let timestamp: i64 = row.get(1)?; + let bytes: i64 = row.get(2)?; + Ok((email, timestamp, bytes)) + })?; + + let mut map: HashMap> = HashMap::new(); + for row in rows { + let (email, timestamp, bytes) = row?; + map.entry(email).or_default().push_back(UploadRecord { + timestamp, + bytes: bytes as u64, + }); + } + Ok(map) + } + + /// Persist one accounted upload and drop any rows for the same email that + /// have fallen outside the rolling window, keeping the table bounded for + /// active senders. Errors are logged rather than propagated: a database + /// hiccup must not fail an otherwise-successful upload, and the in-memory + /// cache still reflects the record for the lifetime of the process. + fn record(&self, email: &str, bytes: u64, now: i64) { + let conn = self.conn.lock().unwrap(); + if let Err(e) = conn.execute( + "INSERT INTO usage (email, timestamp, bytes) VALUES (?1, ?2, ?3)", + rusqlite::params![email, now, bytes as i64], + ) { + log::error!("Failed to persist usage record for {}: {}", email, e); + return; + } + let cutoff = now - ROLLING_WINDOW_SECS; + if let Err(e) = conn.execute( + "DELETE FROM usage WHERE email = ?1 AND timestamp < ?2", + rusqlite::params![email, cutoff], + ) { + log::error!("Failed to prune usage records for {}: {}", email, e); + } + } +} + +struct StoreState { + files: HashMap>>, + expirations: BTreeMap<(Instant, u64), String>, + /// Reverse index: file id → its current `(deadline, removal_id)` entry in + /// `expirations`. Lets `touch` extend the deadline without scanning. + expiration_keys: HashMap, + usage: HashMap>, + next_id: u64, + shutdown: bool, +} + +struct SharedState { + state: std::sync::Mutex, + notify: Notify, + idle_ttl: Duration, + metrics: Arc, + /// SQLite source of truth for rolling-quota usage. `None` keeps usage in + /// memory only (the pre-persistence behaviour, used by unit tests and + /// when `usage_db` is unset in config). + usage_db: Option, +} + +pub struct Store { + shared: Arc, +} + +impl Store { + #[cfg(test)] + pub fn new(metrics: Arc) -> Self { + Self::with_idle_ttl( + Duration::from_secs(DEFAULT_UPLOAD_SESSION_IDLE_TIMEOUT_SECS), + metrics, + None, + ) + } + + /// Construct a store with the given idle-eviction window. When + /// `usage_db` is `Some(path)` the rolling-quota state is backed by a + /// SQLite database at that path: existing usage is loaded from disk on + /// startup and every accounted upload is written through, so quota + /// survives process restarts. A configured-but-unopenable database is a + /// deployment error and panics here, the same way a malformed config + /// does — better a loud startup failure than silently losing quota + /// persistence. + pub fn with_idle_ttl( + idle_ttl: Duration, + metrics: Arc, + usage_db: Option<&str>, + ) -> Self { + let (usage_db, usage) = match usage_db { + Some(path) => { + let db = UsageDb::open(path) + .unwrap_or_else(|e| panic!("Failed to open usage database at {}: {}", path, e)); + let usage = db.load_all().unwrap_or_else(|e| { + panic!("Failed to load usage records from {}: {}", path, e) + }); + let records: usize = usage.values().map(VecDeque::len).sum(); + log::info!( + "Loaded {} usage record(s) for {} sender(s) from {}", + records, + usage.len(), + path + ); + (Some(db), usage) + } + None => (None, HashMap::new()), + }; + + let result = Store { + shared: Arc::new(SharedState { + state: std::sync::Mutex::new(StoreState { + files: HashMap::new(), + expirations: BTreeMap::new(), + expiration_keys: HashMap::new(), + usage, + next_id: 0, + shutdown: false, + }), + notify: Notify::new(), + idle_ttl, + metrics, + usage_db, + }), + }; + + rocket::tokio::spawn(purge_task(result.shared.clone())); + result + } + + pub fn create(&self, id: String, filestate: FileState) { + let mut state = self.shared.state.lock().unwrap(); // this will only panic if we already panicked elsewhere while holding the mutex, which is fine. + state.files.insert( + id.clone(), + Arc::new(rocket::tokio::sync::Mutex::new(filestate)), + ); + let removal_id = state.next_id; + state.next_id += 1; + let removal_instant = Instant::now() + self.shared.idle_ttl; + state + .expirations + .insert((removal_instant, removal_id), id.clone()); + state + .expiration_keys + .insert(id, (removal_instant, removal_id)); + self.shared.notify.notify_one() + } + + pub fn get(&self, id: &str) -> Option>> { + let state = self.shared.state.lock().unwrap(); // this will only panic if we already panicked elsewhere while holding the mutex, which is fine. + state.files.get(id).cloned() + } + + /// Reset the idle-eviction deadline for `id` to "now + idle timeout". + /// Called from `upload_chunk` after a successful chunk PUT so an upload + /// that takes longer than the idle window is not killed mid-flight. + pub fn touch(&self, id: &str) { + let mut state = self.shared.state.lock().unwrap(); + let Some(&(old_when, removal_id)) = state.expiration_keys.get(id) else { + return; + }; + state.expirations.remove(&(old_when, removal_id)); + let new_when = Instant::now() + self.shared.idle_ttl; + state + .expirations + .insert((new_when, removal_id), id.to_owned()); + state + .expiration_keys + .insert(id.to_owned(), (new_when, removal_id)); + self.shared.notify.notify_one(); + } + + pub fn remove(&self, id: &str) { + let mut state = self.shared.state.lock().unwrap(); + state.files.remove(id); + if let Some((when, removal_id)) = state.expiration_keys.remove(id) { + state.expirations.remove(&(when, removal_id)); + } + } + + /// Test-only accessor for the current eviction deadline of `id`. + /// Lets route-level integration tests assert that a successful + /// `GET /fileupload/{uuid}/status` reset the idle window via + /// `Store::touch` (the design AC for #146 explicitly calls this + /// out). Returns `None` if no session exists for `id`. + #[cfg(test)] + pub fn deadline_for(&self, id: &str) -> Option { + let state = self.shared.state.lock().unwrap(); + state.expiration_keys.get(id).map(|(when, _)| *when) + } + + pub fn record_upload(&self, email: String, bytes: u64, now: i64) { + // Persist to the source of truth first so a crash between the two + // updates loses nothing: the cache is rebuilt from the database on + // the next startup anyway. + if let Some(db) = &self.shared.usage_db { + db.record(&email, bytes, now); + } + let mut state = self.shared.state.lock().unwrap(); + let entry = state.usage.entry(email).or_default(); + prune_records(entry, now); + entry.push_back(UploadRecord { + timestamp: now, + bytes, + }); + } + + pub fn get_usage(&self, email: &str, now: i64) -> UsageSnapshot { + let mut state = self.shared.state.lock().unwrap(); + match state.usage.get_mut(email) { + Some(entry) => { + prune_records(entry, now); + let used_bytes = entry.iter().map(|r| r.bytes).sum(); + let oldest_expires_at = entry.front().map(|r| r.timestamp + ROLLING_WINDOW_SECS); + UsageSnapshot { + used_bytes, + oldest_expires_at, + } + } + None => UsageSnapshot { + used_bytes: 0, + oldest_expires_at: None, + }, + } + } +} + +#[derive(Clone, Copy, Debug)] +pub struct UsageSnapshot { + pub used_bytes: u64, + pub oldest_expires_at: Option, +} + +fn prune_records(records: &mut VecDeque, now: i64) { + let cutoff = now - ROLLING_WINDOW_SECS; + while let Some(front) = records.front() { + if front.timestamp < cutoff { + records.pop_front(); + } else { + break; + } + } +} + +impl Drop for Store { + fn drop(&mut self) { + if Arc::strong_count(&self.shared) == 2 { + self.shared.state.lock().unwrap().shutdown = true; // this will only panic if we already panicked elsewhere while holding the mutex, which is fine. + self.shared.notify.notify_one() + } + } +} + +impl SharedState { + fn purge_expired(&self) -> Option { + let mut state = self.state.lock().unwrap(); // this will only panic if we already panicked elsewhere while holding the mutex, which is fine. + + if state.shutdown { + return None; + } + + let state = &mut *state; // needed for borrow checker + + let now = Instant::now(); + while let Some((&(when, removal_id), id)) = state.expirations.iter().next() { + if when > now { + return Some(when); + } + + let id = id.clone(); + if let Some(entry) = state.files.remove(&id) { + // An entry that still had no `sender` set was never finalized. + // (`sender` is populated by `upload_finalize` once the file has + // been unsealed.) + let was_unfinalized = entry + .try_lock() + .map(|g| g.sender.is_none()) + .unwrap_or(false); + if was_unfinalized { + self.metrics.record_expired(); + } + } + state.expiration_keys.remove(&id); + state.expirations.remove(&(when, removal_id)); + } + + None + } + + fn is_shutdown(&self) -> bool { + self.state.lock().unwrap().shutdown // this will only panic if we already panicked elsewhere while holding the mutex, which is fine. + } +} + +async fn purge_task(shared: Arc) { + while !shared.is_shutdown() { + if let Some(when) = shared.purge_expired() { + rocket::tokio::select! { + _ = rocket::tokio::time::sleep_until(when) => {} + _ = shared.notify.notified() => {} + } + } else { + shared.notify.notified().await; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[rocket::async_test] + async fn usage_is_zero_for_unknown_email() { + let store = Store::new(Arc::new(Metrics::new())); + assert_eq!( + store.get_usage("unknown@example.com", 1_000_000).used_bytes, + 0 + ); + } + + #[rocket::async_test] + async fn usage_sums_records_in_window() { + let store = Store::new(Arc::new(Metrics::new())); + let now: i64 = 2_000_000; + store.record_upload("a@example.com".into(), 1_000_000_000, now - 3600); + store.record_upload("a@example.com".into(), 2_000_000_000, now - 60); + let snap = store.get_usage("a@example.com", now); + assert_eq!(snap.used_bytes, 3_000_000_000); + assert_eq!( + snap.oldest_expires_at, + Some(now - 3600 + ROLLING_WINDOW_SECS) + ); + } + + #[rocket::async_test] + async fn usage_excludes_records_outside_window() { + let store = Store::new(Arc::new(Metrics::new())); + let now: i64 = 2_000_000; + store.record_upload( + "b@example.com".into(), + 5_000_000_000, + now - ROLLING_WINDOW_SECS - 1, + ); + store.record_upload("b@example.com".into(), 1_000_000_000, now - 60); + assert_eq!( + store.get_usage("b@example.com", now).used_bytes, + 1_000_000_000 + ); + } + + #[rocket::async_test] + async fn usage_is_isolated_per_email() { + let store = Store::new(Arc::new(Metrics::new())); + let now: i64 = 2_000_000; + store.record_upload("a@example.com".into(), 1_000, now); + store.record_upload("b@example.com".into(), 2_000, now); + assert_eq!(store.get_usage("a@example.com", now).used_bytes, 1_000); + assert_eq!(store.get_usage("b@example.com", now).used_bytes, 2_000); + } + + fn dummy_filestate() -> FileState { + FileState { + uploaded: 0, + cryptify_token: String::new(), + expires: 0, + recipients: lettre::message::Mailboxes::new(), + mail_content: String::new(), + mail_lang: email::Language::En, + sender: None, + sender_attributes: Vec::new(), + confirm: false, + source_channel: String::new(), + client_version: None, + client_app: None, + notify_recipients: true, + api_key_tenant: None, + api_key_validation_failed: false, + last_chunk: None, + recovery_token: String::new(), + } + } + + #[rocket::async_test] + async fn touch_extends_eviction_deadline() { + let store = Store::new(Arc::new(Metrics::new())); + store.create("u1".into(), dummy_filestate()); + + let original = { + let s = store.shared.state.lock().unwrap(); + s.expiration_keys.get("u1").copied().unwrap() + }; + + // tokio::time::Instant has millisecond resolution on most platforms; + // sleep enough for the deadline to be strictly later. + rocket::tokio::time::sleep(Duration::from_millis(10)).await; + store.touch("u1"); + + let updated = { + let s = store.shared.state.lock().unwrap(); + s.expiration_keys.get("u1").copied().unwrap() + }; + + assert_eq!(original.1, updated.1, "removal_id should be stable"); + assert!( + updated.0 > original.0, + "touch should push the deadline forward" + ); + + let s = store.shared.state.lock().unwrap(); + assert!(!s.expirations.contains_key(&original)); + assert_eq!(s.expirations.get(&updated).map(String::as_str), Some("u1")); + } + + #[rocket::async_test] + async fn touch_on_unknown_id_is_noop() { + let store = Store::new(Arc::new(Metrics::new())); + store.touch("nope"); + let s = store.shared.state.lock().unwrap(); + assert!(s.expirations.is_empty()); + assert!(s.expiration_keys.is_empty()); + } + + #[rocket::async_test] + async fn remove_cleans_up_expirations() { + let store = Store::new(Arc::new(Metrics::new())); + store.create("u2".into(), dummy_filestate()); + store.remove("u2"); + let s = store.shared.state.lock().unwrap(); + assert!(s.files.is_empty()); + assert!(s.expirations.is_empty()); + assert!(s.expiration_keys.is_empty()); + } + + /// Unique temp path for a test database, cleaned up by [`TempDbPath`]. + struct TempDbPath { + path: std::path::PathBuf, + } + + impl TempDbPath { + fn new() -> Self { + let path = + std::env::temp_dir().join(format!("cryptify-usage-{}.db", uuid::Uuid::new_v4())); + TempDbPath { path } + } + + fn as_str(&self) -> &str { + self.path.to_str().unwrap() + } + } + + impl Drop for TempDbPath { + fn drop(&mut self) { + // Remove the database file and any WAL/SHM sidecars. + let _ = std::fs::remove_file(&self.path); + for ext in ["-wal", "-shm"] { + let mut p = self.path.clone().into_os_string(); + p.push(ext); + let _ = std::fs::remove_file(p); + } + } + } + + fn store_with_db(path: &str) -> Store { + Store::with_idle_ttl( + Duration::from_secs(DEFAULT_UPLOAD_SESSION_IDLE_TIMEOUT_SECS), + Arc::new(Metrics::new()), + Some(path), + ) + } + + #[rocket::async_test] + async fn usage_survives_simulated_restart() { + let db = TempDbPath::new(); + let now: i64 = 2_000_000; + + { + let store = store_with_db(db.as_str()); + store.record_upload("a@example.com".into(), 1_000_000_000, now - 3600); + store.record_upload("a@example.com".into(), 2_000_000_000, now - 60); + store.record_upload("b@example.com".into(), 500, now - 10); + // store dropped here — simulates the pod going away. + } + + // Fresh Store opening the same database file — simulates restart. + let store = store_with_db(db.as_str()); + let snap = store.get_usage("a@example.com", now); + assert_eq!( + snap.used_bytes, 3_000_000_000, + "usage for a@ must be reloaded from the database after restart" + ); + assert_eq!( + snap.oldest_expires_at, + Some(now - 3600 + ROLLING_WINDOW_SECS) + ); + assert_eq!( + store.get_usage("b@example.com", now).used_bytes, + 500, + "per-sender usage stays isolated across a restart" + ); + } + + #[rocket::async_test] + async fn restart_continues_accumulating() { + let db = TempDbPath::new(); + let now: i64 = 2_000_000; + + { + let store = store_with_db(db.as_str()); + store.record_upload("a@example.com".into(), 1_000, now - 100); + } + + let store = store_with_db(db.as_str()); + // A record made after the restart must add to the reloaded total. + store.record_upload("a@example.com".into(), 2_000, now); + assert_eq!(store.get_usage("a@example.com", now).used_bytes, 3_000); + } + + #[rocket::async_test] + async fn rolling_window_eviction_persists_across_restart() { + let db = TempDbPath::new(); + let now: i64 = 2_000_000; + + { + let store = store_with_db(db.as_str()); + // One record well outside the window, one inside. + store.record_upload( + "c@example.com".into(), + 9_000, + now - ROLLING_WINDOW_SECS - 10, + ); + store.record_upload("c@example.com".into(), 1_000, now - 60); + // A later record at `now` triggers the database-side prune of the + // stale row (DELETE WHERE timestamp < now - window). + store.record_upload("c@example.com".into(), 2_000, now); + } + + // After restart only the two in-window records should remain — the + // expired one must have been evicted from the database, not just the + // in-memory cache. + let store = store_with_db(db.as_str()); + assert_eq!( + store.get_usage("c@example.com", now).used_bytes, + 3_000, + "stale record must not resurrect from the database after restart" + ); + } + + #[rocket::async_test] + async fn rolling_window_evicts_in_memory_after_reload() { + let db = TempDbPath::new(); + let now: i64 = 2_000_000; + + { + let store = store_with_db(db.as_str()); + // Record that is in-window now but will fall out by `later`. + store.record_upload("d@example.com".into(), 4_000, now); + } + + let store = store_with_db(db.as_str()); + // Immediately after reload the record counts. + assert_eq!(store.get_usage("d@example.com", now).used_bytes, 4_000); + // Far in the future it has rolled out of the window. + let later = now + ROLLING_WINDOW_SECS + 1; + assert_eq!(store.get_usage("d@example.com", later).used_bytes, 0); + } + + #[rocket::async_test] + async fn pruning_removes_only_expired_records() { + let store = Store::new(Arc::new(Metrics::new())); + let now: i64 = 2_000_000; + store.record_upload( + "c@example.com".into(), + 1_000, + now - ROLLING_WINDOW_SECS - 10, + ); + store.record_upload("c@example.com".into(), 2_000, now - 10); + assert_eq!(store.get_usage("c@example.com", now).used_bytes, 2_000); + store.record_upload("c@example.com".into(), 3_000, now); + assert_eq!(store.get_usage("c@example.com", now).used_bytes, 5_000); + } +} diff --git a/cryptify/templates/email/check.png b/cryptify/templates/email/check.png new file mode 100644 index 00000000..8e107dad Binary files /dev/null and b/cryptify/templates/email/check.png differ diff --git a/cryptify/templates/email/email.html b/cryptify/templates/email/email.html new file mode 100644 index 00000000..41f60ef7 --- /dev/null +++ b/cryptify/templates/email/email.html @@ -0,0 +1,61 @@ + + + + + + + + +
+
+
+ PostGuard +
+
+

+ {{header}} {{subheader}} +

+

+ {{file_size}} - {{expires_str}} {{expiry_date}} +

+ {% if html_content != "" %} +
+ {{html_content}} +
+ {% endif %} +
+ {{download_str}} + +
+

{{link_str}}

+ {{url}} +
+ {% if confirm != "" %} +
+
+ {{confirm}} +
+
+ {% endif %} + {% if sender_email != "" %} +
+
+ +
+

{{files_from}}

+

{{sender_email}}

+ {% if !sender_attributes.is_empty() %} +
+ {% for attr in sender_attributes %} + {{attr.1}} + {% endfor %} +
+ {% endif %} +
+ {% endif %} +
+
+
+
+ + diff --git a/cryptify/templates/email/email.txt b/cryptify/templates/email/email.txt new file mode 100644 index 00000000..fe12a32d --- /dev/null +++ b/cryptify/templates/email/email.txt @@ -0,0 +1,25 @@ +{{header}} {{subheader}} +{{file_size}} - {{expires_str}} {{expiry_date}} +{% if html_content != "" %} + +{{html_content}} +{% endif %} + +{{download_str}}: +{{url}} + +{{link_str}}: +{{url}} +{% if confirm != "" %} + +{{confirm}} +{% endif %} +{% if sender_email != "" %} + +--- +{{files_from}} {{sender_email}} +{% if !sender_attributes.is_empty() %} +{% for attr in sender_attributes %}- {{attr.1}} +{% endfor %} +{% endif %} +{% endif %} diff --git a/cryptify/templates/email/pg_logo.png b/cryptify/templates/email/pg_logo.png new file mode 100644 index 00000000..0f59be11 Binary files /dev/null and b/cryptify/templates/email/pg_logo.png differ diff --git a/cryptify/templates/email/subject.txt b/cryptify/templates/email/subject.txt new file mode 100644 index 00000000..ec3c389e --- /dev/null +++ b/cryptify/templates/email/subject.txt @@ -0,0 +1 @@ +{{sender}} {{subject_str}} diff --git a/pg-pkg/Cargo.toml b/pg-pkg/Cargo.toml index 84b6cc0b..4c75ffbc 100644 --- a/pg-pkg/Cargo.toml +++ b/pg-pkg/Cargo.toml @@ -20,7 +20,7 @@ actix-web = "4.1.0" actix-http = "3" actix-web-httpauth = "0.8.0" async-trait = "0.1" -sqlx = { version = "0.8", features = [ "postgres", "runtime-tokio", "tls-native-tls" ] } +sqlx = { version = "0.9", features = [ "postgres", "runtime-tokio", "tls-native-tls" ] } arrayref = "0.3.5" futures-util = "0.3" irma = { package = "irmars", version = "0.2.2" } diff --git a/release-plz.toml b/release-plz.toml index d726cc10..8285fac8 100644 --- a/release-plz.toml +++ b/release-plz.toml @@ -22,3 +22,17 @@ git_only = true name = "pg-ffi" publish = false git_only = true + +# cryptify: Docker only, no crates.io publish +# +# Its tags in the old repo were bare `v0.1.27`; this workspace's git_tag_name +# makes them `cryptify-v0.1.28` onward. The old tags are not reachable from +# here (a subtree import brings commits, not tags), so release-plz sees no +# previous release for this package and would otherwise changelog the whole +# imported history into one entry. `git_release_enable = false` on the first +# release, or a hand-placed `cryptify-v0.1.27` tag on the import commit, is +# what stops that; see the note on encryption4all/postguard#255. +[[package]] +name = "cryptify" +publish = false +git_only = true