Merge hardening: fuzz targets, cargo-deny policy, CI, two parser fixes
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -1,13 +1,58 @@
|
|||||||
name: ci
|
name: ci
|
||||||
on: [push, pull_request]
|
on:
|
||||||
|
push:
|
||||||
|
pull_request:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
env:
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
with: { persist-credentials: false }
|
||||||
with: { components: "clippy,rustfmt" }
|
- uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master, 2026-10
|
||||||
|
with: { toolchain: stable, components: "clippy, rustfmt" }
|
||||||
|
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||||
- run: cargo fmt --all --check
|
- run: cargo fmt --all --check
|
||||||
- run: cargo clippy --workspace --all-targets -- -D warnings
|
- run: cargo clippy --workspace --all-targets -- -D warnings
|
||||||
- run: cargo test --workspace
|
- run: cargo test --workspace
|
||||||
- uses: EmbarkStudios/cargo-deny-action@v2
|
|
||||||
|
deny:
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
with: { persist-credentials: false }
|
||||||
|
- uses: EmbarkStudios/cargo-deny-action@3c6349835b2b7b196a839186cb8b78e02f7b5f25 # v2.1.1
|
||||||
|
with: { command: check, arguments: --all-features }
|
||||||
|
|
||||||
|
# Builds every fuzz target and runs each for a minute from the checked-in seeds (fuzz/README.md).
|
||||||
|
fuzz:
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
with: { persist-credentials: false }
|
||||||
|
- uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master, 2026-10
|
||||||
|
with: { toolchain: nightly }
|
||||||
|
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||||
|
with: { workspaces: fuzz }
|
||||||
|
- run: cargo install cargo-fuzz --locked
|
||||||
|
- name: Fuzz smoke run
|
||||||
|
working-directory: fuzz
|
||||||
|
run: |
|
||||||
|
for t in $(cargo +nightly fuzz list); do
|
||||||
|
mkdir -p "$RUNNER_TEMP/corpus/$t"
|
||||||
|
cargo +nightly fuzz run "$t" "$RUNNER_TEMP/corpus/$t" "seeds/$t" -- -max_total_time=60 -max_len=4096
|
||||||
|
done
|
||||||
|
- if: failure()
|
||||||
|
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||||
|
with: { name: fuzz-artifacts, path: fuzz/artifacts }
|
||||||
|
|||||||
@@ -47,7 +47,8 @@ impl SecretKey {
|
|||||||
s.chars().filter(|c| !c.is_whitespace() && *c != '-').map(|c| c.to_ascii_uppercase()).collect(),
|
s.chars().filter(|c| !c.is_whitespace() && *c != '-').map(|c| c.to_ascii_uppercase()).collect(),
|
||||||
);
|
);
|
||||||
let rest = cleaned.strip_prefix(PREFIX).ok_or(CryptoError::BadSecretKey)?;
|
let rest = cleaned.strip_prefix(PREFIX).ok_or(CryptoError::BadSecretKey)?;
|
||||||
if rest.len() != 27 {
|
// ASCII only: `split_at` below works on bytes and must not land inside a character.
|
||||||
|
if !rest.is_ascii() || rest.len() != 27 {
|
||||||
return Err(CryptoError::BadSecretKey);
|
return Err(CryptoError::BadSecretKey);
|
||||||
}
|
}
|
||||||
let (body, check) = rest.split_at(26);
|
let (body, check) = rest.split_at(26);
|
||||||
@@ -96,6 +97,13 @@ fn decode(s: &str) -> Result<[u8; 16]> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn non_ascii_is_rejected_without_panicking() {
|
||||||
|
// Found by fuzz/secret_key_parse: 27 bytes whose 26th..27th byte is a Cyrillic 'е'.
|
||||||
|
assert!(SecretKey::parse("K1-10Y5-00Y0-000000p00-000000p0\u{435}-").is_err());
|
||||||
|
assert!(SecretKey::parse("K1-000000-00000-00000-00000-0000\u{e9}-0").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn roundtrip() {
|
fn roundtrip() {
|
||||||
for _ in 0..200 {
|
for _ in 0..200 {
|
||||||
|
|||||||
@@ -379,7 +379,7 @@ impl SyncContext<'_> {
|
|||||||
open_record(acct, vault, vk, &local.record)?.ok_or(SyncError::Protocol("empty item".into()))?;
|
open_record(acct, vault, vk, &local.record)?.ok_or(SyncError::Protocol("empty item".into()))?;
|
||||||
let tomb =
|
let tomb =
|
||||||
open_sealed(acct, vault, vk, remote)?.ok_or(SyncError::Protocol("empty tombstone".into()))?;
|
open_sealed(acct, vault, vk, remote)?.ok_or(SyncError::Protocol("empty tombstone".into()))?;
|
||||||
let seq = replay::seq_of(&mine).max(replay::seq_of(&tomb)) + 1;
|
let seq = replay::seq_of(&mine).max(replay::seq_of(&tomb)).saturating_add(1);
|
||||||
replay::stamp(&mut mine, seq);
|
replay::stamp(&mut mine, seq);
|
||||||
let sealed = seal_record(acct, vault, vk, id, &mine, self.device_id)?;
|
let sealed = seal_record(acct, vault, vk, id, &mine, self.device_id)?;
|
||||||
self.store.put(vault, &rebased(sealed))?;
|
self.store.put(vault, &rebased(sealed))?;
|
||||||
@@ -408,7 +408,7 @@ impl SyncContext<'_> {
|
|||||||
if replay::same_content(&out.merged, &theirs) {
|
if replay::same_content(&out.merged, &theirs) {
|
||||||
self.store.put_remote(vault, remote)?;
|
self.store.put_remote(vault, remote)?;
|
||||||
} else {
|
} else {
|
||||||
let seq = replay::seq_of(&mine).max(replay::seq_of(&theirs)) + 1;
|
let seq = replay::seq_of(&mine).max(replay::seq_of(&theirs)).saturating_add(1);
|
||||||
replay::stamp(&mut out.merged, seq);
|
replay::stamp(&mut out.merged, seq);
|
||||||
let sealed = seal_record(acct, vault, vk, id, &out.merged, self.device_id)?;
|
let sealed = seal_record(acct, vault, vk, id, &out.merged, self.device_id)?;
|
||||||
self.store.put(vault, &rebased(sealed))?;
|
self.store.put(vault, &rebased(sealed))?;
|
||||||
|
|||||||
@@ -113,7 +113,8 @@ pub fn next_version(local: Option<&Value>, base: Base<'_>) -> u64 {
|
|||||||
Base::Sealed(v) => seq_of(v),
|
Base::Sealed(v) => seq_of(v),
|
||||||
_ => 0,
|
_ => 0,
|
||||||
};
|
};
|
||||||
local.map_or(0, seq_of).max(held) + 1
|
// Saturates: `seq` comes from decrypted plaintext, and a version at u64::MAX must not panic or wrap to 0.
|
||||||
|
local.map_or(0, seq_of).max(held).saturating_add(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -122,6 +123,14 @@ mod tests {
|
|||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn next_version_saturates_at_u64_max() {
|
||||||
|
// A plaintext carrying seq = u64::MAX used to overflow (panic in debug, wrap to 0 in release).
|
||||||
|
let max = json!({"title": "a", "seq": u64::MAX});
|
||||||
|
assert_eq!(next_version(Some(&max), Base::None), u64::MAX);
|
||||||
|
assert_eq!(next_version(None, Base::Sealed(&max)), u64::MAX);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn stamp_and_same_content() {
|
fn stamp_and_same_content() {
|
||||||
let mut v = json!({"title": "a"});
|
let mut v = json!({"title": "a"});
|
||||||
|
|||||||
@@ -2,6 +2,13 @@
|
|||||||
[advisories]
|
[advisories]
|
||||||
version = 2
|
version = 2
|
||||||
yanked = "deny"
|
yanked = "deny"
|
||||||
|
ignore = [
|
||||||
|
# Unmaintained (no known vulnerability), build-time only: pulled in by uniffi 0.28's proc-macros and
|
||||||
|
# bindgen, never linked into the shipped library's runtime paths. Goes away with a uniffi upgrade,
|
||||||
|
# which regenerates the Swift/Kotlin bindings (tracked in fuzz/README.md "Dependency policy").
|
||||||
|
{ id = "RUSTSEC-2025-0141", reason = "bincode 1.x via uniffi_macros 0.28 (build-time)" },
|
||||||
|
{ id = "RUSTSEC-2024-0436", reason = "paste via uniffi_core/uniffi_bindgen 0.28 (proc-macro)" },
|
||||||
|
]
|
||||||
|
|
||||||
[licenses]
|
[licenses]
|
||||||
version = 2
|
version = 2
|
||||||
@@ -13,6 +20,8 @@ confidence-threshold = 0.9
|
|||||||
[bans]
|
[bans]
|
||||||
multiple-versions = "warn"
|
multiple-versions = "warn"
|
||||||
wildcards = "deny"
|
wildcards = "deny"
|
||||||
|
# The workspace's own crates are path dependencies without a version; they are never published.
|
||||||
|
allow-wildcard-paths = true
|
||||||
|
|
||||||
[sources]
|
[sources]
|
||||||
unknown-registry = "deny"
|
unknown-registry = "deny"
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
target/
|
||||||
|
corpus/
|
||||||
|
artifacts/
|
||||||
|
coverage/
|
||||||
|
logs/
|
||||||
Generated
+2266
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,66 @@
|
|||||||
|
[package]
|
||||||
|
name = "kestrove-core-fuzz"
|
||||||
|
version = "0.0.0"
|
||||||
|
publish = false
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[package.metadata]
|
||||||
|
cargo-fuzz = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
libfuzzer-sys = "0.4"
|
||||||
|
serde_json = "1"
|
||||||
|
uuid = "1"
|
||||||
|
kv-proto = { path = "../crates/kv-proto" }
|
||||||
|
kv-crypto = { path = "../crates/kv-crypto" }
|
||||||
|
kv-model = { path = "../crates/kv-model" }
|
||||||
|
kv-sync = { path = "../crates/kv-sync" }
|
||||||
|
|
||||||
|
# Not part of the core workspace (crates/*); built only by `cargo +nightly fuzz`.
|
||||||
|
[workspace]
|
||||||
|
members = ["."]
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
debug = 1
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "envelope_json"
|
||||||
|
path = "fuzz_targets/envelope_json.rs"
|
||||||
|
test = false
|
||||||
|
doc = false
|
||||||
|
bench = false
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "manifest_decode"
|
||||||
|
path = "fuzz_targets/manifest_decode.rs"
|
||||||
|
test = false
|
||||||
|
doc = false
|
||||||
|
bench = false
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "item_replay"
|
||||||
|
path = "fuzz_targets/item_replay.rs"
|
||||||
|
test = false
|
||||||
|
doc = false
|
||||||
|
bench = false
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "secret_key_parse"
|
||||||
|
path = "fuzz_targets/secret_key_parse.rs"
|
||||||
|
test = false
|
||||||
|
doc = false
|
||||||
|
bench = false
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "totp_parse"
|
||||||
|
path = "fuzz_targets/totp_parse.rs"
|
||||||
|
test = false
|
||||||
|
doc = false
|
||||||
|
bench = false
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "url_match"
|
||||||
|
path = "fuzz_targets/url_match.rs"
|
||||||
|
test = false
|
||||||
|
doc = false
|
||||||
|
bench = false
|
||||||
+133
@@ -0,0 +1,133 @@
|
|||||||
|
# Fuzzing and hardening report
|
||||||
|
|
||||||
|
libFuzzer targets (via `cargo-fuzz`) for every parser that sees untrusted input: data from the server,
|
||||||
|
the browser, the user, or the network. This file also records the October 2026 hardening pass across
|
||||||
|
all repos: fuzzing, dependency policy, CI and the deploy check.
|
||||||
|
|
||||||
|
## Running
|
||||||
|
|
||||||
|
```sh
|
||||||
|
rustup toolchain install nightly && cargo install cargo-fuzz --locked
|
||||||
|
cd fuzz
|
||||||
|
cargo +nightly fuzz list
|
||||||
|
mkdir -p corpus/<target>
|
||||||
|
cargo +nightly fuzz run <target> corpus/<target> seeds/<target> -- -max_total_time=600
|
||||||
|
```
|
||||||
|
|
||||||
|
`seeds/` holds a few valid inputs per target and is committed. `corpus/`, `artifacts/` and `logs/` are
|
||||||
|
gitignored. CI runs every target for 60 s from the seeds (the `fuzz` job in `.github/workflows/ci.yml`).
|
||||||
|
A crash input lands in `artifacts/<target>/`. Reproduce it with `cargo +nightly fuzz run <target> <file>`.
|
||||||
|
|
||||||
|
The server and desktop targets live in their own repos (`kestrove-server/fuzz`,
|
||||||
|
`kestrove-desktop/src-tauri/fuzz`) and run the same way.
|
||||||
|
|
||||||
|
## Targets and results (2026-10-16, Apple M-series, 1 job per target, ASan)
|
||||||
|
|
||||||
|
| Target | Code under test | Checks beyond "no panic" | Time | Execs | Coverage (edges) | Result |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| `envelope_json` | `kv_proto::Envelope` JSON decode | accepted ⇒ re-encode/decode round-trips | 8 min | 36.9 M | 2002 | clean |
|
||||||
|
| `manifest_decode` | `kv_sync::manifest::ManifestBody::decode` (strict canonical CBOR) | accepted ⇒ input is exactly `encode()` | 8 min | 2.1 M | 711 | clean |
|
||||||
|
| `item_replay` | item plaintext → `serde_json::Value` → `kv_model::Item`; `replay::check_pulled` / `check_successor` / `next_version`; `merge::merge3` | identical record never a replay; `next_version > seq` | 8 + 8 min | 1.5 M + 2.9 M | 2248 | **bug 2, fixed** |
|
||||||
|
| `secret_key_parse` | `kv_crypto::secret_key::SecretKey::parse` | accepted ⇒ display form parses to the same key | 8 + 8 min | 19.7 M | 423 | **bug 1, fixed** |
|
||||||
|
| `totp_parse` | `kv_model::totp::Totp::parse` (otpauth:// and bare base32), `code_at`, `remaining` | code has `digits` ASCII digits; 1 ≤ remaining ≤ period | 8 min | 3.0 M | 1688 | clean |
|
||||||
|
| `url_match` | `kv_model::match_url` | an https item never matches an http page | 8 min | 2.7 M | 15307 | clean |
|
||||||
|
| server `signature_headers` | `Signature-Input`, `Signature`, `Content-Digest` parsers (`kestrove-server/src/sigparse.rs`) | accepted ⇒ header == `kv=` + params; nonce valid; fixed component list | 10 min | 59.0 M | 696 | clean |
|
||||||
|
| desktop `nmh_session` | whole `kestrove-nmh` connection into `Broker::serve`: framing, outer JSON, `nmh_hello` / `ping` / `pair_init` / `pair_confirm` / `open` / `msg` | every reply is a valid frame with a `t` field | 8 min | 2.5 M | 2082 | clean |
|
||||||
|
| desktop `frame_read` | `framing::read_frame` (u32 length + payload, 1 MiB cap) | frame ≤ `MAX_FRAME`; re-framing round-trips | 8 min | 2.7 M | 63 | clean |
|
||||||
|
|
||||||
|
### Bug 1: `SecretKey::parse` panicked on non-ASCII input (fixed)
|
||||||
|
|
||||||
|
`parse` stripped separators, checked that the remainder was 27 **bytes**, then called `split_at(26)`. A
|
||||||
|
multibyte character straddling byte 26 panicked (`end byte index 26 is not a char boundary`). Release
|
||||||
|
builds use `panic = "abort"`, so a mistyped or pasted Secret Key containing, say, a Cyrillic `е` would
|
||||||
|
have crashed the app through FFI instead of showing "invalid Secret Key".
|
||||||
|
|
||||||
|
Fix: reject non-ASCII before the length check (one line in `crates/kv-crypto/src/secret_key.rs`).
|
||||||
|
Regression test: `secret_key::tests::non_ascii_is_rejected_without_panicking`, which uses the fuzzer's
|
||||||
|
crash input. It fails without the fix. Commit: "kv-crypto: SecretKey::parse rejects non-ASCII input
|
||||||
|
instead of panicking".
|
||||||
|
|
||||||
|
### Bug 2: `seq` + 1 overflowed at `u64::MAX` (fixed)
|
||||||
|
|
||||||
|
`replay::next_version` and the engine's two merge paths (`engine.rs`, "edit beats delete" and the
|
||||||
|
three-way merge) computed `max(seq) + 1` on `seq` values read from decrypted plaintext. A version carrying
|
||||||
|
`"seq": 18446744073709551615` panicked in debug and fuzz builds. In release, which has no overflow checks,
|
||||||
|
it wrapped to `seq` 0, so the device's next save looked like a replay to every other device (an
|
||||||
|
integrity alarm). The 8-minute random run didn't produce a 20-digit number. A targeted input, written while
|
||||||
|
checking the target's `next_version` assertion, triggered it at once. Reaching it needs an authentic
|
||||||
|
plaintext (one written by a device holding the vault key), so severity is low.
|
||||||
|
|
||||||
|
Fix: `saturating_add(1)` in the three places. Regression test:
|
||||||
|
`replay::tests::next_version_saturates_at_u64_max`. The input is now a seed (`seeds/item_replay/seq_max`),
|
||||||
|
so CI's smoke run covers it. Commit: "kv-sync: saturate seq increments instead of overflowing at
|
||||||
|
u64::MAX".
|
||||||
|
|
||||||
|
`kv-ffi`'s `save_item` has the same pattern on `modified_at` (`p.modified_at + 1`, i64). In release it
|
||||||
|
wraps to `i64::MIN` and `now.max(…)` absorbs it, so it's harmless there. It was left alone because the
|
||||||
|
passkeys branch is changing `kv-ffi`. Worth a `saturating_add` when that merges.
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
|
||||||
|
- The desktop's `msg` path only reaches `handle_request` with a valid AES-GCM session, so the fuzzer covers
|
||||||
|
the session layer's rejection paths, not request handling. That is the expected boundary: an
|
||||||
|
unauthenticated peer can't get past it.
|
||||||
|
- To fuzz `parse_signature_input`, the pure header parsers moved from `kestrove-server/src/auth.rs` into
|
||||||
|
`src/sigparse.rs` unchanged. The server is a bin-only crate, and the fuzz crate includes that file by path.
|
||||||
|
|
||||||
|
## Dependency policy
|
||||||
|
|
||||||
|
| Repo | Check | Result |
|
||||||
|
|---|---|---|
|
||||||
|
| kestrove-core | `cargo deny check` | passes. `allow-wildcard-paths` for the workspace's own path crates. Ignored: RUSTSEC-2025-0141 (bincode 1.x) and RUSTSEC-2024-0436 (paste), both *unmaintained* notices for build-time crates pulled in by uniffi 0.28's proc-macros and bindgen. **Not fixable here:** the fix is a uniffi upgrade, which regenerates the Swift and Kotlin bindings for both apps. |
|
||||||
|
| kestrove-server | `cargo deny check` (new `deny.toml`) | passes, no ignores |
|
||||||
|
| kestrove-desktop/src-tauri | `cargo deny check` (new `deny.toml`) | passes. Also allows BSL-1.0 (clipboard-win/error-code via arboard on Windows) and CDLA-Permissive-2.0 (webpki-roots). Ignores the two uniffi notices plus RUSTSEC-2024-0370 (proc-macro-error via gtk-rs glib-macros 0.18, Tauri's Linux stack, build-time). **Not fixable here:** it goes away when Tauri moves to gtk-rs ≥ 0.19 / gtk4. |
|
||||||
|
| kestrove-browser | `npm audit --omit=dev` | 0 vulnerabilities. Dev-only: 1 moderate, esbuild ≤ 0.24.2 dev-server CORS issue (GHSA-67mh-4wv8-2f99). It only matters for `esbuild serve`, which the build script doesn't use, and none of it ships. |
|
||||||
|
| kestrove-desktop | `npm audit --omit=dev` and `npm audit` | 0 vulnerabilities |
|
||||||
|
| kestrove-android | Gradle dependency verification | new `gradle/verification-metadata.xml` pins sha256 for all 388 components used by `assembleDebug test lint`. The Linux and Windows `aapt2` jars were added by hand: the macOS hash Gradle recorded matched dl.google.com. The 106 release-runtime artifacts were checked against OSV.dev: 0 advisories. |
|
||||||
|
|
||||||
|
## CI (all repos)
|
||||||
|
|
||||||
|
All workflows pass `actionlint` (with shellcheck). Actions are pinned to commit SHAs with the version in a
|
||||||
|
comment, `permissions: contents: read`, `persist-credentials: false`. Sibling repos are checked out side by
|
||||||
|
side using `secrets.CORE_READ_TOKEN || github.token`. Add that secret (a fine-grained token with read
|
||||||
|
access) if the repos are private.
|
||||||
|
|
||||||
|
- **server, desktop:** the old workflows were invalid YAML (an unquoted `${{ }}` inside a `{ }` flow
|
||||||
|
mapping), so GitHub would have rejected them and never run anything.
|
||||||
|
- **core:** adds a fuzz smoke job. Also fixed `scripts/build-android.sh`, which ran uniffi-bindgen on
|
||||||
|
`libkv_ffi.dylib`, a file that doesn't exist on the Linux CI runner (`.so`).
|
||||||
|
- **server:** adds fmt, cargo-deny, the fuzz smoke job and a `docker build` of the deploy image.
|
||||||
|
- **desktop:** adds fmt and cargo-deny + `npm audit`. The code had never been through rustfmt, so this
|
||||||
|
commit adds a `rustfmt.toml` (the same one as core and server) and a formatting-only commit.
|
||||||
|
- **apple:** adds an `app` job: core → XCFramework, Safari JS, xcodegen, unsigned xcodebuild for iOS
|
||||||
|
Simulator and macOS. Ran it locally (Xcode 27): both builds succeed. `swift test` failed once under heavy
|
||||||
|
load and then passed 4 times in a row. The `SyncController` tests in `SyncTests.swift` rely on 100 ms
|
||||||
|
debounces and fixed sleeps, so expect flakes on slow CI runners (follow-up).
|
||||||
|
- **android:** gives the core checkout a token, adds Rust Android targets, `setup-gradle` (wrapper
|
||||||
|
validation) and lint report upload.
|
||||||
|
- **browser:** Node 24 (as used locally), plus `npm audit --omit=dev`.
|
||||||
|
|
||||||
|
## Deploy check (kestrove-server/deploy, Docker Desktop 29.4, DOMAIN=localhost)
|
||||||
|
|
||||||
|
Passed after these fixes:
|
||||||
|
|
||||||
|
1. **The image never built.** kv-proto inherits `edition`/deps from `kestrove-core/Cargo.toml`, which the
|
||||||
|
Dockerfile didn't copy (`error inheriting edition from workspace root`). Now copied. Also removed the
|
||||||
|
`--locked || cargo build` fallback that silently ignored the lockfile, and added
|
||||||
|
`Dockerfile.dockerignore`: the build context is the parent folder, and every `target/` and
|
||||||
|
`node_modules/` was being sent to the daemon.
|
||||||
|
2. **`docker stop` SIGKILLed the server** (exit 137). It only listened for Ctrl-C, and as PID 1 it has no
|
||||||
|
default SIGTERM action. It now shuts down on SIGTERM too and closes the pool, so the WAL is
|
||||||
|
checkpointed (exit 0, single `kestrove.db` left).
|
||||||
|
3. **backup.sh never handed restic its credentials.** It used `source` without `export`, so `RESTIC_*`
|
||||||
|
never reached restic. Fixed with `set -a`. It now also runs `sqlite3` as the volume owner (uid 65532),
|
||||||
|
so it can't leave root-owned `-wal`/`-shm` files. It runs `PRAGMA integrity_check` on the copy before
|
||||||
|
uploading, and `KV_VOLUME` overrides the volume name.
|
||||||
|
4. Smaller changes: `tmpfs: /tmp` for SQLite temp files under `read_only: true`, and Caddy drops `Via`.
|
||||||
|
|
||||||
|
Verified: `GET https://localhost/v1/server-info` → 200 over HTTP/2 via Caddy's internal CA, with HSTS,
|
||||||
|
nosniff and no-referrer. `/` → 404. `http://` → 308 to https. TLS 1.2 is refused. `SETUP_TOKEN=…` is
|
||||||
|
printed on first start. The container runs as uid/gid 65532 with `ReadonlyRootfs=true`, `CapDrop=[ALL]`
|
||||||
|
and `no-new-privileges`. Data files are owned by 65532. backup.sh ran in a Linux container standing in for
|
||||||
|
the VM host (real `sqlite3`, `docker volume inspect` and `restic` stubbed) while the server was live: the
|
||||||
|
`.backup` copy passed `integrity_check` with all migrations present.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
//! kv-proto Envelope JSON decode: anything accepted must re-encode and decode to the same value.
|
||||||
|
#![no_main]
|
||||||
|
use kv_proto::Envelope;
|
||||||
|
use libfuzzer_sys::fuzz_target;
|
||||||
|
|
||||||
|
fuzz_target!(|data: &[u8]| {
|
||||||
|
if let Ok(e) = serde_json::from_slice::<Envelope>(data) {
|
||||||
|
let again = serde_json::to_vec(&e).expect("re-encode");
|
||||||
|
let back: Envelope = serde_json::from_slice(&again).expect("decode own encoding");
|
||||||
|
assert_eq!(back, e);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
//! Decrypted item plaintext: JSON parse, Item model parse, and the per-item replay checks and merge that
|
||||||
|
//! run on every pulled record. Input: two plaintexts (held base, incoming) separated by a 0xFF byte.
|
||||||
|
#![no_main]
|
||||||
|
use kv_model::Item;
|
||||||
|
use kv_sync::{merge::merge3, replay};
|
||||||
|
use libfuzzer_sys::fuzz_target;
|
||||||
|
use serde_json::Value;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
fuzz_target!(|data: &[u8]| {
|
||||||
|
let (a, b) = match data.iter().position(|&c| c == 0xff) {
|
||||||
|
Some(i) => (&data[..i], &data[i + 1..]),
|
||||||
|
None => (data, &data[..0]),
|
||||||
|
};
|
||||||
|
let Ok(incoming) = serde_json::from_slice::<Value>(a) else { return };
|
||||||
|
let _ = serde_json::from_value::<Item>(incoming.clone());
|
||||||
|
let id = Uuid::from_u128(1);
|
||||||
|
let held: Option<Value> = serde_json::from_slice(b).ok();
|
||||||
|
let base = match &held {
|
||||||
|
Some(v) => replay::Base::Sealed(v),
|
||||||
|
None => replay::Base::LegacyTombstone,
|
||||||
|
};
|
||||||
|
for deleted in [false, true] {
|
||||||
|
let _ = replay::check_pulled(base, id, deleted, Some(&incoming));
|
||||||
|
}
|
||||||
|
// An identical record is never a replay.
|
||||||
|
assert!(replay::check_successor(replay::Base::Sealed(&incoming), &incoming).is_ok());
|
||||||
|
let next = replay::next_version(Some(&incoming), base);
|
||||||
|
assert!(next > replay::seq_of(&incoming) || replay::seq_of(&incoming) == u64::MAX);
|
||||||
|
if let Some(h) = &held {
|
||||||
|
let _ = merge3(None, h, &incoming);
|
||||||
|
let _ = merge3(Some(h), &incoming, h);
|
||||||
|
let _ = replay::same_content(h, &incoming);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
//! kv-sync ManifestBody::decode (strict canonical CBOR): accepted input must be exactly its own encoding.
|
||||||
|
#![no_main]
|
||||||
|
use kv_sync::manifest::ManifestBody;
|
||||||
|
use libfuzzer_sys::fuzz_target;
|
||||||
|
|
||||||
|
fuzz_target!(|data: &[u8]| {
|
||||||
|
if let Ok(body) = ManifestBody::decode(data) {
|
||||||
|
assert_eq!(body.encode(), data, "non-canonical input accepted");
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
//! kv-crypto SecretKey::parse: never panics; an accepted key round-trips through its display form.
|
||||||
|
#![no_main]
|
||||||
|
use kv_crypto::secret_key::SecretKey;
|
||||||
|
use libfuzzer_sys::fuzz_target;
|
||||||
|
|
||||||
|
fuzz_target!(|data: &[u8]| {
|
||||||
|
let Ok(s) = std::str::from_utf8(data) else { return };
|
||||||
|
if let Ok(k) = SecretKey::parse(s) {
|
||||||
|
let again = SecretKey::parse(&k.to_display()).expect("parse own display form");
|
||||||
|
assert_eq!(again.expose(), k.expose());
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
//! kv-model TOTP / otpauth:// parse, then code generation at fuzzed times.
|
||||||
|
#![no_main]
|
||||||
|
use kv_model::totp::Totp;
|
||||||
|
use libfuzzer_sys::fuzz_target;
|
||||||
|
|
||||||
|
fuzz_target!(|data: &[u8]| {
|
||||||
|
if data.len() < 8 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let t = u64::from_le_bytes(data[..8].try_into().unwrap());
|
||||||
|
let Ok(s) = std::str::from_utf8(&data[8..]) else { return };
|
||||||
|
if let Ok(totp) = Totp::parse(s) {
|
||||||
|
let code = totp.code_at(t);
|
||||||
|
assert_eq!(code.len(), totp.digits as usize);
|
||||||
|
assert!(code.bytes().all(|c| c.is_ascii_digit()));
|
||||||
|
let r = totp.remaining(t);
|
||||||
|
assert!(r >= 1 && r <= totp.period);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
//! kv-model URL matching: never panics, a URL always matches itself at least at host level, and an
|
||||||
|
//! https item never matches an http page.
|
||||||
|
#![no_main]
|
||||||
|
use kv_model::{match_url, MatchLevel};
|
||||||
|
use libfuzzer_sys::fuzz_target;
|
||||||
|
|
||||||
|
fuzz_target!(|data: &[u8]| {
|
||||||
|
let Ok(s) = std::str::from_utf8(data) else { return };
|
||||||
|
let (a, b) = s.split_once('\n').unwrap_or((s, ""));
|
||||||
|
let m = match_url(a, b);
|
||||||
|
if b.trim_start().to_ascii_lowercase().starts_with("http://")
|
||||||
|
&& a.trim_start().to_ascii_lowercase().starts_with("https://")
|
||||||
|
{
|
||||||
|
assert_eq!(m, MatchLevel::None, "downgrade matched: {a:?} -> {b:?}");
|
||||||
|
}
|
||||||
|
let _ = match_url(a, a);
|
||||||
|
});
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"v":1,"alg":"hpke-x25519-chacha","kid":"00000000-0000-0000-0000-000000000001","ct":"AAEC","enc":"AAEC"}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"v":1,"alg":"xc20p","kid":"00000000-0000-0000-0000-000000000001","n":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","ct":"AAEC"}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"id":"00000000-0000-0000-0000-000000000001","kind":"login","title":"t","username":"u","password":"p","urls":["https://example.com"],"created_at":1,"modified_at":2,"seq":3}ÿ{"id":"00000000-0000-0000-0000-000000000001","kind":"login","title":"t","username":"u","password":"p","urls":["https://example.com"],"created_at":1,"modified_at":2,"seq":2}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"id":"00000000-0000-0000-0000-000000000001","seq":18446744073709551615}˙{"seq":18446744073709551614}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"id":"00000000-0000-0000-0000-000000000001","seq":4,"deleted":true}ÿ{"id":"00000000-0000-0000-0000-000000000001","kind":"login","title":"t","username":"u","password":"p","urls":["https://example.com"],"created_at":1,"modified_at":2,"seq":3}
|
||||||
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
K1-000000-00000-00000-00000-00000-0
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
JBSW Y3DP EHPK 3PXP
|
||||||
Binary file not shown.
@@ -0,0 +1,2 @@
|
|||||||
|
https://login.example.co.uk:8443/a
|
||||||
|
example.co.uk
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
http://[::1]:80/
|
||||||
|
https://xn--bcher-kva.example/
|
||||||
@@ -5,6 +5,7 @@ cd "$(dirname "$0")/.."
|
|||||||
OUT=../kestrove-android/core-ffi/src/main
|
OUT=../kestrove-android/core-ffi/src/main
|
||||||
rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android
|
rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android
|
||||||
cargo ndk -t arm64-v8a -t armeabi-v7a -t x86_64 -o "$OUT/jniLibs" build -p kv-ffi --release
|
cargo ndk -t arm64-v8a -t armeabi-v7a -t x86_64 -o "$OUT/jniLibs" build -p kv-ffi --release
|
||||||
cargo build -p kv-ffi
|
cargo build -p kv-ffi # host library for library-mode bindgen (.so on Linux CI, .dylib on macOS)
|
||||||
cargo run -p kv-ffi --bin uniffi-bindgen -- generate --library target/debug/libkv_ffi.dylib --language kotlin --out-dir "$OUT/java"
|
case "$(uname -s)" in Darwin) HOST_LIB=libkv_ffi.dylib ;; *) HOST_LIB=libkv_ffi.so ;; esac
|
||||||
|
cargo run -p kv-ffi --bin uniffi-bindgen -- generate --library "target/debug/$HOST_LIB" --language kotlin --out-dir "$OUT/java"
|
||||||
echo "✅ Android libs + bindings written to $OUT"
|
echo "✅ Android libs + bindings written to $OUT"
|
||||||
|
|||||||
Reference in New Issue
Block a user