diff --git a/Cargo.lock b/Cargo.lock index 2b76711..0b2c527 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -63,12 +63,24 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +[[package]] +name = "byteview" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d74937895e761d5984206f82ca8ff7725d0fd8021921011921894b41ab1b9f7" + [[package]] name = "cfg-if" version = "1.0.5" @@ -117,6 +129,12 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" +[[package]] +name = "compare" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea0095f6103c2a8b44acd6fd15960c801dafebf02e21940360833e0673f48ba7" + [[package]] name = "cpufeatures" version = "0.3.1" @@ -126,6 +144,31 @@ dependencies = [ "libc", ] +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-skiplist" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df29de440c58ca2cc6e587ec3d22347551a32435fbde9d2bff64e78a9ffa151b" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + [[package]] name = "crypto-common" version = "0.2.2" @@ -171,6 +214,20 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "dashmap" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + [[package]] name = "digest" version = "0.11.3" @@ -220,6 +277,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "enum_dispatch" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa18ce2bc66555b3218614519ac839ddb759a7d6720732f979ef8d13be147ecd" +dependencies = [ + "once_cell", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "epitrope" version = "0.1.6" @@ -257,6 +326,31 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" +[[package]] +name = "fjall" +version = "3.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd201c93927cdf4712e8f7d4c9c8d34d68c7534380c05d4a58864a309f91c33f" +dependencies = [ + "byteorder-lite", + "byteview", + "dashmap", + "flume", + "log", + "lsm-tree", + "tempfile", + "xxhash-rust", +] + +[[package]] +name = "flume" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" +dependencies = [ + "spin", +] + [[package]] name = "futures-core" version = "0.3.34" @@ -292,6 +386,18 @@ dependencies = [ "r-efi", ] +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + [[package]] name = "hashbrown" version = "0.17.1" @@ -338,7 +444,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.17.1", ] [[package]] @@ -350,6 +456,15 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "interval-heap" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11274e5e8e89b8607cfedc2910b6626e998779b48a019151c7604d0adcb86ac6" +dependencies = [ + "compare", +] + [[package]] name = "js-sys" version = "0.3.106" @@ -379,6 +494,42 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lsm-tree" +version = "3.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5498808f4d41cf785079d3ef3f28716f1b4364af1b512f3097bfb651bded1b3e" +dependencies = [ + "byteorder-lite", + "byteview", + "crossbeam-skiplist", + "enum_dispatch", + "interval-heap", + "log", + "quick_cache", + "rustc-hash", + "self_cell", + "sfa", + "tempfile", + "varint-rs", + "xxhash-rust", +] + [[package]] name = "mio" version = "1.2.3" @@ -426,18 +577,35 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + [[package]] name = "phylake" version = "0.1.6" dependencies = [ "chacha20poly1305", + "epitrope", + "fjall", "getrandom", "hkdf", "hmac", + "rkyv", "rustix", "secrecy", "sha2", "snafu", + "syntheke", "tempfile", "zeroize", ] @@ -510,6 +678,16 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "quick_cache" +version = "0.6.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9c6658afe513a3b484e3abfdaa0d03ef3c0bbf017542c178dd55f94eb3051f9" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", +] + [[package]] name = "quote" version = "1.0.47" @@ -568,6 +746,15 @@ dependencies = [ "rand_core", ] +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + [[package]] name = "rend" version = "0.5.4" @@ -585,7 +772,7 @@ checksum = "d9776093b7ca170454ab1406954f7b7d97a57c51dc6c0642957fb2ef25c2d399" dependencies = [ "bytecheck", "bytes", - "hashbrown", + "hashbrown 0.17.1", "indexmap", "munge", "ptr_meta", @@ -607,6 +794,12 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + [[package]] name = "rustc_version" version = "0.4.1" @@ -635,6 +828,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "secrecy" version = "0.10.3" @@ -644,6 +843,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "self_cell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813" + [[package]] name = "semver" version = "1.0.28" @@ -679,6 +884,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "sfa" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1296838937cab56cd6c4eeeb8718ec777383700c33f060e2869867bd01d1175" +dependencies = [ + "byteorder-lite", + "log", + "xxhash-rust", +] + [[package]] name = "sha2" version = "0.11.0" @@ -718,6 +934,12 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +[[package]] +name = "smallvec" +version = "1.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" + [[package]] name = "snafu" version = "0.9.2" @@ -749,6 +971,15 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] + [[package]] name = "subtle" version = "2.6.1" @@ -928,6 +1159,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "varint-rs" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa6c38708f6257f1ec2ca7e5a11f9bbf58a27d7060078b6b333624968183d96" + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -1011,6 +1248,12 @@ dependencies = [ "tempfile", ] +[[package]] +name = "xxhash-rust" +version = "0.8.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aee1b19627c7c60102ab80d3a9cbe18de90bfe03bfa6c3715447681f0e8c8af6" + [[package]] name = "zerocopy" version = "0.8.59" diff --git a/Cargo.toml b/Cargo.toml index e957561..a7faaab 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -105,8 +105,8 @@ syntheke = { path = "crates/syntheke", version = "0.1.6" } rustix = { version = "1.1.5", default-features = false } # WHY snafu: the fleet error library (kanon RUST.md); thiserror is banned. snafu = "0.9.2" -# WHY tempfile (dev only): tests that bind a unix socket path create it in a -# fresh temporary directory. +# WHY tempfile (dev only): tests that bind a unix socket path or write key +# and store files create them in a fresh temporary directory. tempfile = "3.27" # WHY tokio: the daemon's async runtime and Unix socket server. The # workspace entry enables no features; each crate names what it uses. @@ -136,6 +136,15 @@ hmac = { version = "0.13", features = ["zeroize"] } secrecy = "0.10" sha2 = { version = "0.11", default-features = false, features = ["zeroize"] } zeroize = "1.8" +# WHY fjall: the custody store's substrate under the named STORAGE-TIERS +# migration exception (docs/design/custody-store.md, D17.14). Version 3 +# offers a single-writer transactional database whose commit takes a +# persist mode. Default features are off: the store seals every value, so +# lz4 compression would only spend CPU on ciphertext. +fjall = { version = "3.1", default-features = false } +# WHY epitrope: phylake runs epitrope's transition table, reservation +# arithmetic, and recovery rules inside its transactions. +epitrope = { path = "crates/epitrope", version = "0.1.6" } [profile.dev.package."*"] opt-level = 2 diff --git a/crates/phylake/Cargo.toml b/crates/phylake/Cargo.toml index 565019d..d2e0541 100644 --- a/crates/phylake/Cargo.toml +++ b/crates/phylake/Cargo.toml @@ -9,13 +9,17 @@ repository.workspace = true [dependencies] chacha20poly1305.workspace = true +epitrope.workspace = true +fjall.workspace = true getrandom.workspace = true hkdf.workspace = true hmac.workspace = true +rkyv.workspace = true rustix = { workspace = true, features = ["std", "fs", "process"] } secrecy.workspace = true sha2.workspace = true snafu.workspace = true +syntheke.workspace = true zeroize.workspace = true [dev-dependencies] diff --git a/crates/phylake/src/crypto/mod.rs b/crates/phylake/src/crypto/mod.rs index a8262bb..bf0ac9f 100644 --- a/crates/phylake/src/crypto/mod.rs +++ b/crates/phylake/src/crypto/mod.rs @@ -39,6 +39,8 @@ pub use seal::{ }; pub use wrap::{WRAPPED_KEY_LEN, WRAPPED_KEY_VERSION}; +pub(crate) use seal::seal_with; + /// Length of every symmetric key and subkey in bytes. pub const KEY_LEN: usize = 32; @@ -180,6 +182,11 @@ impl Keyspace { pub struct BlobAddress([u8; 32]); impl BlobAddress { + /// An address read back from a sealed record. + pub(crate) const fn from_bytes(bytes: [u8; 32]) -> Self { + Self(bytes) + } + /// The address bytes. #[must_use] pub const fn as_bytes(&self) -> &[u8; 32] { diff --git a/crates/phylake/src/error.rs b/crates/phylake/src/error.rs index 24f2989..2930ae2 100644 --- a/crates/phylake/src/error.rs +++ b/crates/phylake/src/error.rs @@ -229,6 +229,189 @@ pub enum Error { #[snafu(implicit)] location: snafu::Location, }, + + /// The custody database failed a read, write, or commit. + #[snafu(display("custody database operation failed"))] + Database { + /// Underlying storage error (external, so named `error`). + #[snafu(source)] + error: fjall::Error, + /// Source location where the error was raised. + #[snafu(implicit)] + location: snafu::Location, + }, + + /// Creating or inspecting the store directory failed. + #[snafu(display("store directory {}: I/O failure", path.display()))] + StoreIo { + /// Directory that was accessed. + path: PathBuf, + /// Underlying I/O error (external, so named `error`). + #[snafu(source)] + error: io::Error, + /// Source location where the error was raised. + #[snafu(implicit)] + location: snafu::Location, + }, + + /// Creating a store at a path that is not an empty directory. + #[snafu(display("store path {} is not an empty directory", path.display()))] + StoreExists { + /// Path that was requested. + path: PathBuf, + /// Source location where the error was raised. + #[snafu(implicit)] + location: snafu::Location, + }, + + /// Opening a store at a path that holds none, or holds an incomplete one. + #[snafu(display("no custody store at {} ({missing} is absent)", path.display()))] + StoreMissing { + /// Path that was opened. + path: PathBuf, + /// The first part found missing. + missing: &'static str, + /// Source location where the error was raised. + #[snafu(implicit)] + location: snafu::Location, + }, + + /// A plaintext `meta` field has an impossible value. + #[snafu(display("store metadata field {field} is malformed"))] + MetaMalformed { + /// The field. + field: &'static str, + /// Source location where the error was raised. + #[snafu(implicit)] + location: snafu::Location, + }, + + /// The store was written by a newer schema than this build reads. It + /// is never opened optimistically. + #[snafu(display("store schema version {found} is newer than supported version {supported}"))] + SchemaTooNew { + /// Version recorded in the store. + found: u32, + /// Version this build writes. + supported: u32, + /// Source location where the error was raised. + #[snafu(implicit)] + location: snafu::Location, + }, + + /// The store was written by an older schema and needs the `migrate` + /// command before this build opens it. + #[snafu(display("store schema version {found} requires migration to version {supported}"))] + MigrationRequired { + /// Version recorded in the store. + found: u32, + /// Version this build writes. + supported: u32, + /// Source location where the error was raised. + #[snafu(implicit)] + location: snafu::Location, + }, + + /// A record could not be encoded. + #[snafu(display("record encoding failed"))] + Encode { + /// Underlying serializer error (external, so named `error`). + #[snafu(source)] + error: rkyv::rancor::Error, + /// Source location where the error was raised. + #[snafu(implicit)] + location: snafu::Location, + }, + + /// An opened record failed archive validation. + #[snafu(display("record in keyspace {keyspace} failed validation"))] + Decode { + /// Keyspace the record was read from. + keyspace: &'static str, + /// Underlying validator error (external, so named `error`). + #[snafu(source)] + error: rkyv::rancor::Error, + /// Source location where the error was raised. + #[snafu(implicit)] + location: snafu::Location, + }, + + /// Records that must agree do not: a reference points at nothing, or + /// a stored key has an impossible shape. + #[snafu(display("store records are inconsistent: {what}"))] + Inconsistent { + /// What was found wrong. + what: &'static str, + /// Source location where the error was raised. + #[snafu(implicit)] + location: snafu::Location, + }, + + /// An authorization decision or ledger computation failed, including a + /// lifecycle step the transition table forbids. + #[snafu(display("authorization rule refused the operation"))] + Authz { + /// The authorization crate's error. + #[snafu(source(from(epitrope::Error, Box::new)))] + source: Box, + /// Source location where the error was raised. + #[snafu(implicit)] + location: snafu::Location, + }, + + /// A settlement named an outcome that does not fit the invocation's + /// state: success before publish, or a failure after it. + #[snafu(display("settlement outcome does not fit invocation state {state}"))] + SettleMismatch { + /// The invocation's state. + state: syntheke::InvocationState, + /// Source location where the error was raised. + #[snafu(implicit)] + location: snafu::Location, + }, + + /// The named invocation has no record. + #[snafu(display("invocation {invocation} has no record"))] + InvocationMissing { + /// The invocation. + invocation: syntheke::InvocationId, + /// Source location where the error was raised. + #[snafu(implicit)] + location: snafu::Location, + }, + + /// The named tenant is not registered. + #[snafu(display("tenant {tenant} is not registered"))] + TenantMissing { + /// The tenant. + tenant: syntheke::TenantId, + /// Source location where the error was raised. + #[snafu(implicit)] + location: snafu::Location, + }, + + /// A record with the caller-chosen id already exists with different + /// content. + #[snafu(display("a different {what} already exists under that id"))] + Conflict { + /// The kind of record. + what: &'static str, + /// Source location where the error was raised. + #[snafu(implicit)] + location: snafu::Location, + }, + + /// A failpoint simulated a crash at a lifecycle boundary. + #[snafu(display("injected crash {phase} {boundary}"))] + InjectedCrash { + /// The boundary. + boundary: crate::store::Boundary, + /// Before or after the commit. + phase: crate::store::Phase, + /// Source location where the error was raised. + #[snafu(implicit)] + location: snafu::Location, + }, } /// Result alias for `phylake` operations. diff --git a/crates/phylake/src/lib.rs b/crates/phylake/src/lib.rs index f76b249..2766238 100644 --- a/crates/phylake/src/lib.rs +++ b/crates/phylake/src/lib.rs @@ -8,15 +8,18 @@ //! transaction so that restart recovery can settle or release each //! invocation exactly once. //! -//! This slice provides the encryption layer (`docs/design/custody-store.md`, -//! "Encryption at rest"): [`keyfile`] loads and creates the root key, and -//! [`crypto`] derives subkeys, seals records, wraps tenant data keys, -//! computes keyed blob addresses, and checks the key-check value that gates -//! a locked start. The keyspaces themselves land in a later slice. +//! [`keyfile`] loads and creates the root key; [`crypto`] derives subkeys, +//! seals records, wraps tenant data keys, computes keyed blob addresses, +//! and checks the key-check value that gates a locked start +//! (`docs/design/custody-store.md`, "Encryption at rest"). [`store`] holds +//! the keyspaces, the invocation transactions B1 through B5, failure +//! injection, and restart recovery. #![deny(missing_docs)] pub mod crypto; mod error; pub mod keyfile; +pub mod store; pub use error::{Error, Result}; +pub use store::{Store, StoreOptions}; diff --git a/crates/phylake/src/store/audit.rs b/crates/phylake/src/store/audit.rs new file mode 100644 index 0000000..319fea3 --- /dev/null +++ b/crates/phylake/src/store/audit.rs @@ -0,0 +1,326 @@ +//! Audit: a per-tenant sequenced entry plus a global stub, written in the +//! same transaction as the state they record. +//! +//! The sequence is global: the next value is one past the last key in +//! `audit_stub`, read inside the writing transaction, so writers (which +//! serialize on the database's single-writer lock) never reuse one. A +//! tenant's entries sit under a keyed prefix of that tenant followed by +//! the big-endian sequence, so they sort in sequence order. + +use std::collections::HashMap; + +use fjall::{Readable, Snapshot}; +use snafu::{OptionExt as _, ResultExt as _}; +use syntheke::{ + AuditRecord, AuditScope, AuditSeq, Capability, InvocationId, InvocationState, OutcomeKind, + ReleaseReason, SessionId, TenantId, +}; + +use super::codec::StoredRecord as _; +use super::record_key::{self, HashedKey, SEQ_LEN}; +use super::records::{AuditEntryRecord, AuditStubRecord, TenantRecord, Terminal}; +use super::view::View; +use super::{Store, WriteTx, slot}; +use crate::Result; +use crate::crypto::Keyspace; +use crate::error::{DatabaseSnafu, InconsistentSnafu}; + +/// One audit entry to append. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct AuditEntry { + tenant: TenantId, + session: Option, + invocation: InvocationId, + capability: Capability, + state: InvocationState, + outcome: OutcomeKind, + release_reason: Option, +} + +impl AuditEntry { + /// An entry for a non-release state of `invocation` by `tenant`. + pub(crate) const fn new( + tenant: TenantId, + invocation: InvocationId, + capability: Capability, + state: InvocationState, + outcome: OutcomeKind, + ) -> Self { + Self { + tenant, + session: None, + invocation, + capability, + state, + outcome, + release_reason: None, + } + } + + /// The entry recording `terminal`, with its release reason when it has + /// one. + pub(crate) const fn terminal( + tenant: TenantId, + invocation: InvocationId, + capability: Capability, + terminal: Terminal, + ) -> Self { + Self { + release_reason: terminal.release_reason(), + ..Self::new( + tenant, + invocation, + capability, + terminal.state(), + terminal.outcome(), + ) + } + } + + /// The same entry, acting in `session`. + pub(crate) const fn in_session(mut self, session: Option) -> Self { + self.session = session; + self + } +} + +/// An audit record as stored: the contract's record plus the release +/// reason, which the contract's record has no field for. +#[derive(Clone, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub struct AuditEvent { + /// The record the contract's `AuditQuery` returns. + pub record: AuditRecord, + /// Why the reservation was released; present exactly when + /// `record.state` is `Released`. + pub release_reason: Option, +} + +/// An audit read across tenants, as an `AuditQuery` needs it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub struct AuditQuery { + /// The reading tenant. + pub actor: TenantId, + /// The scope the lifecycle applied (contract § Read scopes, D17.7). + pub scope: AuditScope, + /// Narrows the scope to one session. + pub session: Option, + /// Only records after this sequence. + pub after: Option, + /// At most this many records. + pub limit: u32, +} + +impl AuditQuery { + /// A query by `actor` under `scope`, from the start, unnarrowed. + #[must_use] + pub const fn new(actor: TenantId, scope: AuditScope, limit: u32) -> Self { + Self { + actor, + scope, + session: None, + after: None, + limit, + } + } +} + +impl Store { + /// Stages `entry` and its stub in `tx` and returns its sequence. + pub(crate) fn append_audit(&self, tx: &mut WriteTx<'_>, entry: AuditEntry) -> Result { + let seq = self.next_audit_seq(tx)?; + let time = self.now(); + let seq_key = seq.get().to_be_bytes(); + self.put_global( + tx, + slot::AUDIT_STUB, + &seq_key, + &AuditStubRecord { + seq, + invocation: entry.invocation, + capability: entry.capability, + outcome: entry.outcome, + time, + }, + )?; + let tenant_keys = self.tenant_keys(&*tx, entry.tenant)?; + let prefix = record_key::tenant::audit(tenant_keys.index(), entry.tenant)?; + let key = record_key::join(&prefix, &seq_key); + self.put( + tx, + slot::AUDIT, + &key, + tenant_keys.audit(), + &AuditEntryRecord { + seq, + time, + tenant: entry.tenant, + session: entry.session, + invocation: entry.invocation, + capability: entry.capability, + state: entry.state, + outcome: entry.outcome, + release_reason: entry.release_reason, + }, + )?; + Ok(seq) + } + + /// One past the last sequence in `audit_stub`; 1 for an empty log. + fn next_audit_seq(&self, tx: &WriteTx<'_>) -> Result { + let last = match tx.last_key_value(self.ks.get(Keyspace::AuditStub)?) { + Some(guard) => { + let key = guard.key().context(DatabaseSnafu)?; + let bytes = <[u8; SEQ_LEN]>::try_from(&*key) + .ok() + .context(InconsistentSnafu { + what: "audit stub key is not a sequence", + })?; + u64::from_be_bytes(bytes) + } + None => 0, + }; + let next = last.checked_add(1).context(InconsistentSnafu { + what: "audit sequence is exhausted", + })?; + Ok(AuditSeq::new(next)) + } + + /// `tenant`'s own audit records after `after`, at most `limit`, in + /// sequence order: one partition, with no scope applied. + /// + /// # Errors + /// + /// A storage, decryption, or decoding failure; an unknown tenant is + /// [`crate::Error::TenantMissing`]. + pub fn audit_records( + &self, + tenant: TenantId, + after: Option, + limit: u32, + ) -> Result> { + let snapshot = self.db.read_tx(); + let limit = usize::try_from(limit).unwrap_or(usize::MAX); + self.partition(&snapshot, tenant, after, limit, |_| Ok(true)) + } + + /// The audit records `query` selects, from every tenant's partition, in + /// global sequence order. + /// + /// `All` selects every record. `OwnAndOwnedSessions` selects the + /// actor's own records plus records any tenant wrote in a session the + /// actor owns. `session` then narrows either scope to one session. The + /// store applies the scope it is given; deciding which scope the + /// actor's grant allows is the lifecycle's (epitrope's + /// `applied_audit_scope`). A scope a later contract adds selects + /// nothing until the store learns it. + /// + /// PERF: every partition is scanned from `after`, keeping at most + /// `limit` matches each, then merged. Phase 01 volumes do not need an + /// index by session. + /// + /// # Errors + /// + /// A storage, decryption, or decoding failure. + pub fn audit_query(&self, query: &AuditQuery) -> Result> { + let snapshot = self.db.read_tx(); + let limit = usize::try_from(query.limit).unwrap_or(usize::MAX); + let view = View { + store: self, + reader: &snapshot, + }; + let mut owners: HashMap> = HashMap::new(); + let mut selected = |entry: &AuditEntryRecord| -> Result { + if query.session.is_some() && entry.session != query.session { + return Ok(false); + } + match query.scope { + AuditScope::All => Ok(true), + AuditScope::OwnAndOwnedSessions => { + if entry.tenant == query.actor { + return Ok(true); + } + let Some(session) = entry.session else { + return Ok(false); + }; + let owner = if let Some(owner) = owners.get(&session) { + *owner + } else { + let owner = view.session_record(session)?.map(|record| record.owner); + owners.insert(session, owner); + owner + }; + Ok(owner == Some(query.actor)) + } + _ => Ok(false), + } + }; + let mut events = Vec::new(); + for tenant in self.tenant_ids(&snapshot)? { + events.extend(self.partition(&snapshot, tenant, query.after, limit, &mut selected)?); + } + events.sort_by_key(|event| event.record.seq); + events.truncate(limit); + Ok(events) + } + + /// Every registered tenant. + /// + /// NOTE: a crypto-shred must remove or tombstone the tenant record in + /// the transaction that deletes its data key, or this scan fails on + /// the shredded tenant's partition. + fn tenant_ids(&self, snapshot: &Snapshot) -> Result> { + let mut ids = Vec::new(); + for guard in snapshot.iter(self.ks.get(slot::TENANT.keyspace)?) { + let (key, sealed) = guard.into_inner().context(DatabaseSnafu)?; + let plain = Self::open_bytes(&[self.keys.meta()], slot::TENANT, &key, &sealed)?; + ids.push(TenantRecord::decode(&plain, slot::TENANT.keyspace.name())?.id); + } + Ok(ids) + } + + /// Up to `limit` records of `tenant`'s partition after `after` that + /// `keep` selects, in sequence order. + fn partition( + &self, + reader: &R, + tenant: TenantId, + after: Option, + limit: usize, + mut keep: impl FnMut(&AuditEntryRecord) -> Result, + ) -> Result> { + let tenant_keys = self.tenant_keys(reader, tenant)?; + let prefix: HashedKey = record_key::tenant::audit(tenant_keys.index(), tenant)?; + let Some(start) = after.map_or(Some(0), |seq| seq.get().checked_add(1)) else { + return Ok(Vec::new()); + }; + let from = record_key::join(&prefix, &start.to_be_bytes()); + let to = record_key::join(&prefix, &u64::MAX.to_be_bytes()); + let mut events = Vec::new(); + for guard in reader.range(self.ks.get(Keyspace::Audit)?, from..=to) { + if events.len() >= limit { + break; + } + let (key, sealed) = guard.into_inner().context(DatabaseSnafu)?; + let plain = Self::open_bytes(&[tenant_keys.audit()], slot::AUDIT, &key, &sealed)?; + let entry = AuditEntryRecord::decode(&plain, Keyspace::Audit.name())?; + if keep(&entry)? { + events.push(AuditEvent { + record: AuditRecord { + seq: entry.seq, + time: entry.time, + tenant: entry.tenant, + session: entry.session, + invocation: entry.invocation, + capability: entry.capability, + state: entry.state, + outcome: entry.outcome, + }, + release_reason: entry.release_reason, + }); + } + } + Ok(events) + } +} diff --git a/crates/phylake/src/store/codec.rs b/crates/phylake/src/store/codec.rs new file mode 100644 index 0000000..8258bf4 --- /dev/null +++ b/crates/phylake/src/store/codec.rs @@ -0,0 +1,67 @@ +//! Record encoding: rkyv, read back only through validated access. +//! +//! WHY rkyv: the wire contract already uses rkyv with bytecheck +//! validation, so the store adds no second serializer. A stored record is +//! opened (authenticated) before it is decoded, and then validated as a +//! whole archive before any field is read. + +use rkyv::rancor; +use rkyv::util::AlignedVec; +use snafu::ResultExt as _; +use zeroize::Zeroize as _; + +use super::records::{ + ArtifactRecord, AuditEntryRecord, AuditStubRecord, GrantRecord, IdemRecord, InvocationRecord, + LedgerRecord, LocatorRecord, RevocationRecord, SessionIndexRecord, SessionRecord, TenantRecord, +}; +use crate::Result; +use crate::error::{DecodeSnafu, EncodeSnafu}; + +/// A type the store persists as an rkyv archive. +pub(crate) trait StoredRecord: Sized { + /// Serializes the record. + fn encode(&self) -> Result; + + /// Validates and deserializes a record read from `keyspace`. + fn decode(bytes: &[u8], keyspace: &'static str) -> Result; +} + +/// Implements [`StoredRecord`] for each listed record type. +macro_rules! stored_record { + ($($name:ty),+ $(,)?) => { + $(impl StoredRecord for $name { + fn encode(&self) -> Result { + rkyv::to_bytes::(self).context(EncodeSnafu) + } + + fn decode(bytes: &[u8], keyspace: &'static str) -> Result { + let mut aligned = AlignedVec::<16>::with_capacity(bytes.len()); + aligned.extend_from_slice(bytes); + let decoded = rkyv::access::<<$name as rkyv::Archive>::Archived, rancor::Error>( + &aligned, + ) + .and_then(rkyv::deserialize::<$name, rancor::Error>) + .context(DecodeSnafu { keyspace }); + // WHY: the aligned copy holds opened plaintext. + let copy: &mut [u8] = &mut aligned; + copy.zeroize(); + decoded + } + })+ + }; +} + +stored_record! { + TenantRecord, + GrantRecord, + RevocationRecord, + SessionRecord, + InvocationRecord, + IdemRecord, + LedgerRecord, + ArtifactRecord, + LocatorRecord, + SessionIndexRecord, + AuditEntryRecord, + AuditStubRecord, +} diff --git a/crates/phylake/src/store/directory.rs b/crates/phylake/src/store/directory.rs new file mode 100644 index 0000000..711a7d4 --- /dev/null +++ b/crates/phylake/src/store/directory.rs @@ -0,0 +1,585 @@ +//! Tenant, grant, revocation, and session writes. +//! +//! Each write is one transaction. Writes that answer a tenant request +//! (grant issue and revoke, session create and fork) append an audit entry +//! in the same transaction. Each is idempotent on its caller-chosen id: a +//! repeat with identical content returns the stored result and writes +//! nothing; a different record under the same id is +//! [`crate::Error::Conflict`]. + +use std::collections::BTreeSet; + +use epitrope::{Grant, IssueContext, IssueDecision, Revocation, TargetScope, check_issue}; +use snafu::{OptionExt as _, ResultExt as _, ensure}; +use syntheke::{ + AuditScope, Capability, Ceilings, Failure, GrantId, GrantIssueRequest, InvocationId, + InvocationState, OutcomeKind, SessionId, SessionOpened, SessionScope, TenantClass, TenantId, + Timestamp, +}; + +use super::audit::AuditEntry; +use super::record_key::store as keys; +use super::records::{GrantRecord, RevocationRecord, SessionRecord, TenantRecord}; +use super::view::View; +use super::{INITIAL_DATA_KEY_ID, Store, WriteTx, slot}; +use crate::Result; +use crate::crypto::{Keyspace, TenantDataKey}; +use crate::error::{AuthzSnafu, ConflictSnafu, TenantMissingSnafu}; + +/// A tenant to register. +#[derive(Clone, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub struct TenantRegistration { + /// The tenant. + pub id: TenantId, + /// Its class. + pub class: TenantClass, + /// Its Ed25519 verifying key. + pub verifying_key: [u8; 32], + /// The local user ids it may connect from. + pub bound_uids: Vec, + /// Its parent tenant, which must already be registered. + pub parent: Option, + /// Ceilings on its tenant ledger. + pub ceilings: Ceilings, +} + +impl TenantRegistration { + /// A registration with no parent, no bound uids, and no ceilings. + #[must_use] + pub const fn new(id: TenantId, class: TenantClass, verifying_key: [u8; 32]) -> Self { + Self { + id, + class, + verifying_key, + bound_uids: Vec::new(), + parent: None, + ceilings: Ceilings { + wall_time_ms: None, + fetches: None, + bytes_transferred: None, + output_bytes: None, + tokens: None, + ops_band: None, + }, + } + } +} + +/// A root grant: no parent, depth 0, issued by its holder. Installed by +/// the operator's administration path, not through a tenant request. +#[derive(Clone, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub struct RootGrant { + /// The grant. + pub id: GrantId, + /// Its holder and issuer. + pub holder: TenantId, + /// Conferred capabilities. + pub capabilities: BTreeSet, + /// Session scope. + pub session_scope: SessionScope, + /// Target origin patterns. + pub target_scope: Vec, + /// Audit scope. + pub audit_scope: AuditScope, + /// Ceilings on the grant's ledger. + pub ceilings: Ceilings, + /// Start of validity. + pub not_before: Timestamp, + /// End of validity (exclusive). + pub expires_at: Timestamp, + /// The chain's maximum depth. + pub max_depth: u8, +} + +impl RootGrant { + /// A root grant with every field given; see the field docs. + #[must_use] + pub const fn new( + id: GrantId, + holder: TenantId, + capabilities: BTreeSet, + target_scope: Vec, + validity: (Timestamp, Timestamp), + max_depth: u8, + ) -> Self { + Self { + id, + holder, + capabilities, + session_scope: SessionScope::Own, + target_scope, + audit_scope: AuditScope::OwnAndOwnedSessions, + ceilings: Ceilings { + wall_time_ms: None, + fetches: None, + bytes_transferred: None, + output_bytes: None, + tokens: None, + ops_band: None, + }, + not_before: validity.0, + expires_at: validity.1, + max_depth, + } + } + + fn to_grant(&self) -> Result { + Ok(Grant { + id: self.id, + issuer: self.holder, + holder: self.holder, + capabilities: self.capabilities.clone(), + session_scope: self.session_scope.clone(), + target_scope: TargetScope::parse(&self.target_scope).context(AuthzSnafu)?, + audit_scope: self.audit_scope, + ceilings: self.ceilings, + not_before: self.not_before, + expires_at: self.expires_at, + parent: None, + depth: 0, + max_depth: self.max_depth, + }) + } +} + +/// A `GrantIssue` request, decided inside the writing transaction. +#[derive(Clone, Copy, Debug)] +#[non_exhaustive] +pub struct GrantIssue<'a> { + /// Issuer, designated parent, and the child's id. + pub context: IssueContext, + /// The child's requested shape. + pub request: &'a GrantIssueRequest, + /// The invocation the audit entry names. + pub invocation: InvocationId, +} + +impl<'a> GrantIssue<'a> { + /// A grant issue request. + #[must_use] + pub const fn new( + context: IssueContext, + request: &'a GrantIssueRequest, + invocation: InvocationId, + ) -> Self { + Self { + context, + request, + invocation, + } + } +} + +/// The result of [`Store::issue_grant`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum IssueOutcome { + /// The child grant is stored. + Issued { + /// The child. + grant: GrantId, + /// Its parent. + parent: GrantId, + }, + /// The request was refused; only the audit entry was written. + Refused { + /// What the caller observes. + failure: Failure, + }, +} + +/// A revocation, authorized by the caller. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub struct RevokeGrant { + /// The revoking tenant. + pub actor: TenantId, + /// The grant to revoke. + pub grant: GrantId, + /// The invocation the audit entry names. + pub invocation: InvocationId, +} + +impl RevokeGrant { + /// A revocation of `grant` by `actor`. + #[must_use] + pub const fn new(actor: TenantId, grant: GrantId, invocation: InvocationId) -> Self { + Self { + actor, + grant, + invocation, + } + } +} + +/// A session to open, authorized by the caller. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub struct NewSession { + /// The new session. + pub session: SessionId, + /// Its owner: the acting tenant. + pub owner: TenantId, + /// Ceilings on its ledger. + pub ceilings: Ceilings, + /// The invocation the audit entry names. + pub invocation: InvocationId, +} + +impl NewSession { + /// A session with no ceilings. + #[must_use] + pub const fn new(session: SessionId, owner: TenantId, invocation: InvocationId) -> Self { + Self { + session, + owner, + ceilings: Ceilings { + wall_time_ms: None, + fetches: None, + bytes_transferred: None, + output_bytes: None, + tokens: None, + ops_band: None, + }, + invocation, + } + } +} + +impl Store { + /// Registers a tenant with a fresh random data key, wrapped under the + /// store's key-encryption subkey. + /// + /// # Errors + /// + /// [`crate::Error::TenantMissing`] for an unregistered parent, + /// [`crate::Error::Conflict`] for a different tenant under the same id, + /// [`crate::Error::Entropy`] when the data key cannot be drawn, or a + /// storage failure. + pub fn register_tenant(&self, registration: &TenantRegistration) -> Result<()> { + let mut tx = self.write_tx(); + let id = registration.id; + if let Some(existing) = self.tenant_record(&tx, id)? { + ensure!( + same_identity(&existing, registration), + ConflictSnafu { what: "tenant" } + ); + return Ok(()); + } + if let Some(parent) = registration.parent { + self.tenant_record(&tx, parent)? + .context(TenantMissingSnafu { tenant: parent })?; + } + let wrapped = { + let mut entropy = self + .entropy + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let data_key = TenantDataKey::generate_with(INITIAL_DATA_KEY_ID, &mut *entropy)?; + self.keys + .wrap_tenant_key_with(&id.to_bytes(), &data_key, &mut *entropy)? + }; + let key_slot = keys::data_key(self.keys.index(), id, INITIAL_DATA_KEY_ID)?; + tx.insert(self.ks.get(Keyspace::Keys)?, key_slot, wrapped); + let record = TenantRecord { + id, + class: registration.class, + verifying_key: registration.verifying_key, + bound_uids: registration.bound_uids.clone(), + parent: registration.parent, + ceilings: registration.ceilings, + data_key_id: INITIAL_DATA_KEY_ID.get(), + registered_at: self.now(), + }; + self.put_global( + &mut tx, + slot::TENANT, + &keys::tenant(self.keys.index(), id)?, + &record, + )?; + self.commit(tx, None) + } + + /// Installs a root grant. + /// + /// # Errors + /// + /// [`crate::Error::TenantMissing`] for an unregistered holder, + /// [`crate::Error::Authz`] for a target pattern that does not parse, + /// [`crate::Error::Conflict`] for a different grant under the same id, + /// or a storage failure. + pub fn install_root_grant(&self, root: &RootGrant) -> Result<()> { + let grant = root.to_grant()?; + let mut tx = self.write_tx(); + self.tenant_record(&tx, root.holder)? + .context(TenantMissingSnafu { + tenant: root.holder, + })?; + let record = GrantRecord::from_grant(&grant, root.target_scope.clone()); + if self.put_new_grant(&mut tx, &record)? { + self.commit(tx, None)?; + } + Ok(()) + } + + /// Stages `record` unless the same grant exists; returns whether it + /// staged a write. + fn put_new_grant(&self, tx: &mut WriteTx<'_>, record: &GrantRecord) -> Result { + let view = View { + store: self, + reader: &*tx, + }; + if let Some(existing) = view.grant_record(record.id)? { + ensure!(existing == *record, ConflictSnafu { what: "grant" }); + return Ok(false); + } + let key = keys::grant(self.keys.index(), record.id)?; + self.put_global(tx, slot::GRANT, &key, record)?; + Ok(true) + } + + /// Decides and, when it attenuates its parent, stores a child grant. + /// The decision reads the parent's ledger inside the writing + /// transaction. Issued or refused, an audit entry records the call. + /// + /// A repeat of an issued request (same child id, issuer, parent, and + /// shape) returns `Issued` and writes nothing, before any decision: the + /// parent's remaining budget or validity may have moved since, and a + /// stored grant is not re-decided. + /// + /// # Errors + /// + /// [`crate::Error::Authz`] when the decision cannot be made (a broken + /// chain, a failed read), [`crate::Error::Conflict`] for a different + /// grant under the child's id, or a storage failure. + pub fn issue_grant(&self, issue: &GrantIssue<'_>) -> Result { + let mut tx = self.write_tx(); + let decision = { + let view = View { + store: self, + reader: &tx, + }; + if let Some(existing) = view.grant_record(issue.context.child)? + && existing.issuer == issue.context.issuer + && existing.parent == Some(issue.context.designated) + { + ensure!( + issued_from(&view, &existing, issue.request)?, + ConflictSnafu { what: "grant" } + ); + return Ok(IssueOutcome::Issued { + grant: existing.id, + parent: issue.context.designated, + }); + } + check_issue(&view, &issue.context, issue.request, &*self.clock).context(AuthzSnafu)? + }; + let (outcome, state) = match decision { + IssueDecision::Issued(child) => { + let record = GrantRecord::from_grant(&child, issue.request.target_scope.clone()); + if !self.put_new_grant(&mut tx, &record)? { + return Ok(IssueOutcome::Issued { + grant: record.id, + parent: issue.context.designated, + }); + } + let outcome = IssueOutcome::Issued { + grant: record.id, + parent: issue.context.designated, + }; + (outcome, InvocationState::Settled) + } + refused => { + let failure = refused.refusal().unwrap_or(Failure::NotFoundOrDenied); + (IssueOutcome::Refused { failure }, InvocationState::Denied) + } + }; + let kind = match outcome { + IssueOutcome::Issued { .. } => OutcomeKind::Success, + IssueOutcome::Refused { failure } => failure.kind(), + }; + let entry = AuditEntry::new( + issue.context.issuer, + issue.invocation, + Capability::GrantIssue, + state, + kind, + ); + self.append_audit(&mut tx, entry)?; + self.commit(tx, None)?; + Ok(outcome) + } + + /// Records the revocation of a grant, with the audit sequence of the + /// revoking call as its epoch. Revoking a revoked grant returns the + /// first record and writes nothing. + /// + /// Returns `None` when the grant does not exist. + /// + /// # Errors + /// + /// A storage failure, or [`crate::Error::TenantMissing`] for an + /// unregistered actor. + pub fn revoke_grant(&self, revoke: &RevokeGrant) -> Result> { + let mut tx = self.write_tx(); + { + let view = View { + store: self, + reader: &tx, + }; + if view.grant_record(revoke.grant)?.is_none() { + return Ok(None); + } + if let Some(existing) = view.revocation_record(revoke.grant)? { + return Ok(Some(existing.to_revocation())); + } + } + let entry = AuditEntry::new( + revoke.actor, + revoke.invocation, + Capability::GrantRevoke, + InvocationState::Settled, + OutcomeKind::Success, + ); + let at_seq = self.append_audit(&mut tx, entry)?; + let record = RevocationRecord { + grant: revoke.grant, + at_seq, + at_time: self.now(), + by: revoke.actor, + }; + let key = keys::revocation(self.keys.index(), revoke.grant)?; + self.put_global(&mut tx, slot::REVOCATION, &key, &record)?; + self.commit(tx, None)?; + Ok(Some(record.to_revocation())) + } + + /// Opens a new session owned by `new.owner`. + /// + /// # Errors + /// + /// [`crate::Error::TenantMissing`] for an unregistered owner, + /// [`crate::Error::Conflict`] for a different session under the same + /// id, or a storage failure. + pub fn create_session(&self, new: &NewSession) -> Result { + // NOTE: with no parent, `open_session` never reports a missing one. + self.open_session(new, None) + .map(|opened| opened.unwrap_or_else(|| session_opened(new, None))) + } + + /// Forks `parent` into a new session owned by `new.owner`, recording + /// the lineage. Returns `None` when `parent` does not exist. + /// + /// # Errors + /// + /// As [`Store::create_session`]. + pub fn fork_session( + &self, + new: &NewSession, + parent: SessionId, + ) -> Result> { + self.open_session(new, Some(parent)) + } + + fn open_session( + &self, + new: &NewSession, + parent: Option, + ) -> Result> { + let mut tx = self.write_tx(); + let opened = session_opened(new, parent); + { + let view = View { + store: self, + reader: &tx, + }; + view.tenant_record(new.owner)? + .context(TenantMissingSnafu { tenant: new.owner })?; + if let Some(parent) = parent + && view.session_record(parent)?.is_none() + { + return Ok(None); + } + if let Some(existing) = view.session_record(new.session)? { + ensure!( + existing.owner == new.owner + && existing.parent == parent + && existing.ceilings == new.ceilings, + ConflictSnafu { what: "session" } + ); + return Ok(Some(opened)); + } + } + let record = SessionRecord { + id: new.session, + owner: new.owner, + parent, + ceilings: new.ceilings, + created_at: self.now(), + }; + let key = keys::session(self.keys.index(), new.session)?; + self.put_global(&mut tx, slot::SESSION, &key, &record)?; + let capability = if parent.is_some() { + Capability::SessionFork + } else { + Capability::SessionCreate + }; + let entry = AuditEntry::new( + new.owner, + new.invocation, + capability, + InvocationState::Settled, + OutcomeKind::Success, + ) + .in_session(Some(new.session)); + self.append_audit(&mut tx, entry)?; + self.commit(tx, None)?; + Ok(Some(opened)) + } +} + +/// The reply for `new`, forked from `parent`. +const fn session_opened(new: &NewSession, parent: Option) -> SessionOpened { + SessionOpened { + session: new.session, + owner: new.owner, + parent_session: parent, + } +} + +/// Whether the stored child grant `existing` is the one `request` asks +/// for under its stored parent. +fn issued_from( + view: &View<'_, R>, + existing: &GrantRecord, + request: &GrantIssueRequest, +) -> Result { + let max_depth = match request.max_depth { + Some(max_depth) => Some(max_depth), + None => match existing.parent { + Some(parent) => view.grant_record(parent)?.map(|parent| parent.max_depth), + None => None, + }, + }; + let capabilities: BTreeSet = request.capabilities.iter().copied().collect(); + let stored: BTreeSet = existing.capabilities.iter().copied().collect(); + Ok(existing.holder == request.holder + && stored == capabilities + && existing.session_scope == request.session_scope + && existing.target_patterns == request.target_scope + && existing.ceilings == request.ceilings + && existing.not_before == request.not_before + && existing.expires_at == request.expires_at + && Some(existing.max_depth) == max_depth) +} + +/// Whether a stored tenant is the one `registration` describes. +fn same_identity(existing: &TenantRecord, registration: &TenantRegistration) -> bool { + existing.class == registration.class + && existing.verifying_key == registration.verifying_key + && existing.bound_uids == registration.bound_uids + && existing.parent == registration.parent + && existing.ceilings == registration.ceilings +} diff --git a/crates/phylake/src/store/failpoint.rs b/crates/phylake/src/store/failpoint.rs new file mode 100644 index 0000000..bb878b1 --- /dev/null +++ b/crates/phylake/src/store/failpoint.rs @@ -0,0 +1,112 @@ +//! Failure injection at lifecycle boundaries +//! (`docs/design/custody-store.md`, "Failure-injection specification"). + +use core::fmt; + +/// A durable lifecycle boundary: one store transaction each. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum Boundary { + /// B1: reservation, intent, and idempotency index. + PersistIntent, + /// B2: dispatch recorded before the producer call. + Dispatch, + /// B3: the blob written with a pending side record, not visible. + CompleteTransfer, + /// B4: the atomic publish point. + Publish, + /// B5: settle, release, or `UnknownEffect`. + Terminal, +} + +impl Boundary { + /// Every boundary, in lifecycle order. + pub const ALL: [Self; 5] = [ + Self::PersistIntent, + Self::Dispatch, + Self::CompleteTransfer, + Self::Publish, + Self::Terminal, + ]; + + /// The contract's name for the boundary. + #[must_use] + pub const fn name(self) -> &'static str { + match self { + Self::PersistIntent => "B1", + Self::Dispatch => "B2", + Self::CompleteTransfer => "B3", + Self::Publish => "B4", + Self::Terminal => "B5", + } + } +} + +impl fmt::Display for Boundary { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.name()) + } +} + +/// Which side of a commit a failpoint fires on. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum Phase { + /// The transaction is built and not committed; a crash discards it. + BeforeCommit, + /// The transaction is durable; a crash loses only the caller's reply. + AfterCommit, +} + +impl Phase { + /// Both phases. + pub const ALL: [Self; 2] = [Self::BeforeCommit, Self::AfterCommit]; +} + +impl fmt::Display for Phase { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::BeforeCommit => "before commit of", + Self::AfterCommit => "after commit of", + }) + } +} + +/// A simulated crash requested by a [`Failpoint`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Crash; + +/// Hooks around every boundary commit. +/// +/// The default methods do nothing, so the production store runs with +/// [`NoFailpoints`]. A test implementation returns [`Crash`] at one hook; +/// the store then returns [`crate::Error::InjectedCrash`] without running +/// the rest of the operation, and the test drops the store to simulate the +/// process dying there. +pub trait Failpoint: Send + Sync { + /// Called with the transaction built and not yet committed. + /// + /// # Errors + /// + /// [`Crash`] to abandon the transaction. + fn before_commit(&self, boundary: Boundary) -> Result<(), Crash> { + let _ = boundary; + Ok(()) + } + + /// Called after the transaction committed durably. + /// + /// # Errors + /// + /// [`Crash`] to stop before the caller sees the result. + fn after_commit(&self, boundary: Boundary) -> Result<(), Crash> { + let _ = boundary; + Ok(()) + } +} + +/// The production failpoint: never fires. +#[derive(Clone, Copy, Debug, Default)] +pub struct NoFailpoints; + +impl Failpoint for NoFailpoints {} diff --git a/crates/phylake/src/store/invocation.rs b/crates/phylake/src/store/invocation.rs new file mode 100644 index 0000000..37f2e54 --- /dev/null +++ b/crates/phylake/src/store/invocation.rs @@ -0,0 +1,446 @@ +//! Invocation intent (B1), the dry-run plan, and the types the lifecycle +//! transactions share. + +use epitrope::{AuthzRequest, Decision, ReservationPlan, authorize, plan, reserve}; +use sha2::{Digest as _, Sha256}; +use snafu::{OptionExt as _, ResultExt as _, ensure}; +use syntheke::{ + ArtifactRef, AuditSeq, Capability, Cost, Dimension, Failure, GrantId, IdempotencyKey, + InvocationId, InvocationState, Plan, ReleaseReason, SessionId, SourceRef, TenantId, +}; + +use super::audit::AuditEntry; +use super::record_key; +use super::records::{IdemRecord, InvocationRecord, LedgerRecord, LedgerRef, Terminal}; +use super::view::View; +use super::{Boundary, Store, WriteTx, slot}; +use crate::Result; +use crate::error::{AuthzSnafu, ConflictSnafu, InconsistentSnafu, InvocationMissingSnafu}; + +/// An `Execute` call to persist at B1. +#[derive(Clone, Copy, Debug)] +#[non_exhaustive] +pub struct Intent<'a> { + /// The new invocation's id. + pub invocation: InvocationId, + /// Tenant, designated grant, capability, target, session, and the + /// declared maximum cost. + pub authz: AuthzRequest<'a>, + /// The caller's idempotency key. + pub idempotency_key: &'a IdempotencyKey, + /// The caller's digest of the request body. The store binds it to the + /// designated grant, capability, session, target, and declared cost + /// itself, so a replay under any other of these + /// conflicts even when the caller's digest omits them. + pub request_digest: [u8; 32], +} + +impl<'a> Intent<'a> { + /// An intent for `authz` under `idempotency_key`. + #[must_use] + pub const fn new( + invocation: InvocationId, + authz: AuthzRequest<'a>, + idempotency_key: &'a IdempotencyKey, + request_digest: [u8; 32], + ) -> Self { + Self { + invocation, + authz, + idempotency_key, + request_digest, + } + } +} + +/// The result of [`Store::begin`]. +#[derive(Clone, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum Begin { + /// B1 committed: reservation, intent, and idempotency entry. + Persisted(InvocationStatus), + /// The idempotency key and digest match an existing invocation; nothing + /// was written. The caller reports its current state and never + /// dispatches it again. + Replayed(InvocationStatus), + /// The idempotency key is bound to a different request; nothing was + /// written. + Conflict, + /// Authorization refused the call; the audit entry is the only write. + Refused { + /// What the caller observes. + failure: Failure, + /// The audit entry's sequence. + audit_seq: AuditSeq, + }, +} + +/// An invocation as stored. +#[derive(Clone, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub struct InvocationStatus { + /// The invocation. + pub id: InvocationId, + /// The acting tenant. + pub tenant: TenantId, + /// The capability invoked. + pub capability: Capability, + /// The session it acts in. + pub session: Option, + /// Its lifecycle state. + pub state: InvocationState, + /// How it ended; `None` until a terminal state. + pub terminal: Option, + /// The artifact written at B3 (visible from B4). + pub artifact: Option, + /// The reservation debited from every ledger at B1. + pub reserved: Cost, + /// The amount kept as spent at B5. + pub debited: Option, + /// The authorizing chain, leaf first. + pub grant_chain: Vec, + /// Whether the authorizing grant was revoked after the effect started. + pub revoked_after_effect: bool, +} + +impl InvocationStatus { + /// Why it released its reservation, when `Released`. + #[must_use] + pub fn release_reason(&self) -> Option { + self.terminal.and_then(Terminal::release_reason) + } +} + +impl From<&InvocationRecord> for InvocationStatus { + fn from(record: &InvocationRecord) -> Self { + Self { + id: record.id, + tenant: record.tenant, + capability: record.capability, + session: record.session, + state: record.state, + terminal: record.terminal, + artifact: record.artifact, + reserved: record.reserved, + debited: record.debited, + grant_chain: record.grant_chain.clone(), + revoked_after_effect: record.revoked_after_effect, + } + } +} + +/// What the producer returned, stored at B3. +/// +/// The artifact id is not the caller's to choose: the store publishes the +/// capture under the invocation's own id ([`artifact_ref`]), so two +/// invocations can never claim one artifact and a roll-forward publish +/// can never collide. +#[derive(Clone, Debug)] +#[non_exhaustive] +pub struct Transfer<'a> { + /// The producer's envelope, stored verbatim. + pub envelope: &'a [u8], + /// Its evidence identity. + pub source: SourceRef, + /// The derived text view, already cut to the output bound. + pub text_view: Option, + /// Whether `text_view` was cut. + pub truncated: bool, + /// Length of `text_view` in bytes. + pub output_bytes: u64, + /// Actual consumption, settled at B5. + pub actual: Cost, + /// Whether the authorizing grant was revoked after the effect started. + pub revoked_after_effect: bool, +} + +impl<'a> Transfer<'a> { + /// A transfer of `envelope`, costing `actual`, with no text view. + #[must_use] + pub const fn new(envelope: &'a [u8], source: SourceRef, actual: Cost) -> Self { + Self { + envelope, + source, + text_view: None, + truncated: false, + output_bytes: 0, + actual, + revoked_after_effect: false, + } + } +} + +/// The artifact a capture by `invocation` publishes under: the +/// invocation id's bytes. Invocation ids are unique in the store (B1 +/// refuses a taken one), so artifact ids are too. +#[must_use] +pub const fn artifact_ref(invocation: InvocationId) -> ArtifactRef { + ArtifactRef::from_bytes(invocation.to_bytes()) +} + +/// How a B5 settlement ends. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum SettleOutcome { + /// From B4: the capture is published; settle the consumption recorded + /// at B3. + Success, + /// From B2: the producer started and failed; settle `actual`. The + /// failure must be one a started producer reports: `TransferFailed`, + /// `ExtractionFailed`, `DeadlineExceeded`, or `Cancelled`. + Failed { + /// What the caller observes. + failure: Failure, + /// Actual consumption. + actual: Cost, + }, +} + +impl Store { + /// The invocation `id`, or `None` when it has no record. + /// + /// # Errors + /// + /// A storage, decryption, or decoding failure. + pub fn invocation(&self, id: InvocationId) -> Result> { + let snapshot = self.db.read_tx(); + Ok(self + .invocation_record(&snapshot, id)? + .as_ref() + .map(InvocationStatus::from)) + } + + /// Plans a dry-run over a read snapshot. Writes nothing, including no + /// audit entry. + /// + /// # Errors + /// + /// [`crate::Error::Authz`] when the decision cannot be made. + pub fn plan(&self, request: &AuthzRequest<'_>) -> Result { + let snapshot = self.snapshot(); + plan(&snapshot, request, &*self.clock).context(AuthzSnafu) + } + + /// B1: authorizes `intent` and, when allowed, commits the reservation + /// against every ledger, the intent, and the idempotency entry in one + /// transaction. + /// + /// Authorization reads the ledgers inside the writing transaction, and + /// writers serialize, so two concurrent reservations cannot both spend + /// the same remaining budget. A refused call writes its audit entry + /// and nothing else. An idempotency key already bound to this request + /// returns the stored invocation; bound to another, a conflict. + /// + /// # Errors + /// + /// [`crate::Error::Authz`] when the decision cannot be made or a + /// ledger would overflow, [`crate::Error::Conflict`] when the + /// invocation id is taken, [`crate::Error::InjectedCrash`] from a + /// failpoint, or a storage failure. + pub fn begin(&self, intent: &Intent<'_>) -> Result { + let tenant = intent.authz.tenant; + let mut tx = self.write_tx(); + let tenant_keys = self.tenant_keys(&tx, tenant)?; + let idem_key = record_key::tenant::idem( + tenant_keys.index(), + tenant, + intent.authz.capability, + intent.idempotency_key, + )?; + if let Some(idem) = + self.get::(&tx, slot::IDEM, &idem_key, &[tenant_keys.meta()])? + { + return self.replay(&tx, intent, &idem); + } + ensure!( + self.invocation_record(&tx, intent.invocation)?.is_none(), + ConflictSnafu { what: "invocation" } + ); + let decision = { + let view = View { + store: self, + reader: &tx, + }; + authorize(&view, &intent.authz, &*self.clock).context(AuthzSnafu)? + }; + let Decision::Allowed { chain, reservation } = decision else { + let failure = decision.refusal().unwrap_or(Failure::NotFoundOrDenied); + return self.refuse(tx, intent, failure); + }; + let record = self.intent_record(&mut tx, intent, chain, &reservation)?; + self.put_invocation(&mut tx, &record)?; + let idem = IdemRecord { + invocation: intent.invocation, + request_binding: request_binding(intent), + }; + self.put(&mut tx, slot::IDEM, &idem_key, tenant_keys.meta(), &idem)?; + self.commit(tx, Some(Boundary::PersistIntent))?; + Ok(Begin::Persisted(InvocationStatus::from(&record))) + } + + /// The answer to a request whose idempotency key is already bound: the + /// stored invocation for the same digest, a conflict for another. + fn replay(&self, tx: &WriteTx<'_>, intent: &Intent<'_>, idem: &IdemRecord) -> Result { + if idem.request_binding != request_binding(intent) { + return Ok(Begin::Conflict); + } + let record = self + .invocation_record(tx, idem.invocation)? + .context(InconsistentSnafu { + what: "idempotency entry names a missing invocation", + })?; + Ok(Begin::Replayed(InvocationStatus::from(&record))) + } + + /// Commits the audit entry of a refused call, and nothing else. + fn refuse(&self, mut tx: WriteTx<'_>, intent: &Intent<'_>, failure: Failure) -> Result { + let entry = AuditEntry::new( + intent.authz.tenant, + intent.invocation, + intent.authz.capability, + InvocationState::Denied, + failure.kind(), + ) + .in_session(intent.authz.session); + let audit_seq = self.append_audit(&mut tx, entry)?; + self.commit(tx, None)?; + Ok(Begin::Refused { failure, audit_seq }) + } + + /// Debits the reservation and builds the B1 invocation record. + fn intent_record( + &self, + tx: &mut WriteTx<'_>, + intent: &Intent<'_>, + chain: Vec, + reservation: &ReservationPlan, + ) -> Result { + let ledgers = self.debit_reservation(tx, reservation)?; + let now = self.now(); + Ok(InvocationRecord { + id: intent.invocation, + tenant: intent.authz.tenant, + capability: intent.authz.capability, + session: intent.authz.session, + grant_chain: chain, + ledgers, + reserved: reservation.cost(), + state: InvocationState::IntentPersisted, + terminal: None, + actual: None, + debited: None, + artifact: None, + revoked_after_effect: false, + created_at: now, + updated_at: now, + }) + } + + /// Debits the reservation from every ledger it names. + fn debit_reservation( + &self, + tx: &mut WriteTx<'_>, + reservation: &ReservationPlan, + ) -> Result> { + let mut ledgers = Vec::with_capacity(reservation.ledgers().len()); + for (id, amount) in reservation.debits() { + let ledger = LedgerRef::from_ledger_id(id).context(InconsistentSnafu { + what: "ledger kind has no stored form", + })?; + let used = View { + store: self, + reader: &*tx, + } + .ledger(ledger)?; + let used = reserve(&used, &amount).context(AuthzSnafu)?; + self.put_ledger(tx, ledger, used)?; + ledgers.push(ledger); + } + Ok(ledgers) + } + + /// Stages a ledger's new `used` amount. + pub(crate) fn put_ledger( + &self, + tx: &mut WriteTx<'_>, + ledger: LedgerRef, + used: Cost, + ) -> Result<()> { + let key = record_key::store::ledger(self.keys.index(), ledger)?; + self.put_global(tx, slot::LEDGER, &key, &LedgerRecord { used }) + } + + /// The invocation record `id`. + pub(crate) fn invocation_record( + &self, + reader: &R, + id: InvocationId, + ) -> Result> { + let key = record_key::store::invocation(self.keys.index(), id)?; + self.get_global(reader, slot::INVOCATION, &key) + } + + /// The invocation record `id`, which must exist. + pub(crate) fn existing_invocation( + &self, + reader: &R, + id: InvocationId, + ) -> Result { + self.invocation_record(reader, id)? + .context(InvocationMissingSnafu { invocation: id }) + } + + /// Stages an invocation record. + pub(crate) fn put_invocation( + &self, + tx: &mut WriteTx<'_>, + record: &InvocationRecord, + ) -> Result<()> { + let key = record_key::store::invocation(self.keys.index(), record.id)?; + self.put_global(tx, slot::INVOCATION, &key, record) + } +} + +/// The store's binding of `intent` for its idempotency entry: SHA-256 +/// over a domain label, the caller's request digest, and every field the +/// store authorizes on (designated grant, capability, session, target, +/// declared cost), each length-prefixed or fixed-width. +/// +/// WHY in the store: the idempotency contract says the same key under a +/// different grant is a conflict. Binding the grant here makes that true +/// whatever the caller folded into its own digest. +fn request_binding(intent: &Intent<'_>) -> [u8; 32] { + let authz = &intent.authz; + let mut hash = Sha256::new(); + hash.update(b"dioptron/v1/idem-binding"); + hash.update(intent.request_digest); + hash.update(authz.grant.to_bytes()); + hash.update(length_prefixed(authz.capability.name().as_bytes())); + match authz.session { + Some(session) => { + hash.update([1]); + hash.update(session.to_bytes()); + } + None => hash.update([0]), + } + match authz.target { + Some(target) => { + hash.update([1]); + hash.update(length_prefixed(target.as_bytes())); + } + None => hash.update([0]), + } + for &dimension in Dimension::ALL { + hash.update(authz.declared.get(dimension).to_le_bytes()); + } + hash.finalize().into() +} + +/// `bytes` behind its u64 length. +fn length_prefixed(bytes: &[u8]) -> Vec { + let len = u64::try_from(bytes.len()).unwrap_or(u64::MAX); + let mut out = Vec::with_capacity(bytes.len().saturating_add(8)); + out.extend_from_slice(&len.to_le_bytes()); + out.extend_from_slice(bytes); + out +} diff --git a/crates/phylake/src/store/meta.rs b/crates/phylake/src/store/meta.rs new file mode 100644 index 0000000..8ecd36e --- /dev/null +++ b/crates/phylake/src/store/meta.rs @@ -0,0 +1,154 @@ +//! The plaintext `meta` keyspace: schema version, format, active root key +//! id, key-derivation salt, and key-check value. +//! +//! These fields are read before the store is unlocked, so they are stored +//! unsealed; none of them is secret. Tampering with them cannot open a +//! record: the schema version and key id are bound into every seal, and +//! the salt and key check gate the key derivation. +//! +//! Schema versioning: the store writes [`SCHEMA_VERSION`]. A store with a +//! newer version is refused ([`crate::Error::SchemaTooNew`]); a store with +//! an older one is refused with [`crate::Error::MigrationRequired`] until +//! the `migrate` command, which lands with the first schema change, has +//! upgraded it. Version 1 is the first schema, so no older store exists +//! yet. + +use std::path::Path; + +use fjall::{Readable, SingleWriterTxKeyspace}; +use snafu::{OptionExt as _, ResultExt as _, ensure}; + +use super::WriteTx; +use crate::Result; +use crate::crypto::{KeyId, SchemaVersion, StoreSalt}; +use crate::error::{ + DatabaseSnafu, MetaMalformedSnafu, MigrationRequiredSnafu, SchemaTooNewSnafu, StoreMissingSnafu, +}; + +/// The schema version this build reads and writes. +pub const SCHEMA_VERSION: SchemaVersion = SchemaVersion::new(1); + +/// The store format tag: the substrate and its layout generation. +const FORMAT: &[u8] = b"dioptron-custody/fjall3"; + +/// Field names in `meta`. +pub(crate) mod field { + pub(crate) const SCHEMA_VERSION: &str = "schema_version"; + pub(super) const FORMAT: &str = "format"; + pub(super) const ROOT_KEY_ID: &str = "active_root_key_id"; + pub(super) const KDF_SALT: &str = "kdf_salt"; + pub(super) const KEY_CHECK: &str = "key_check"; +} + +/// The unlocking fields of `meta`. +pub(crate) struct Meta { + root_key_id: KeyId, + salt: StoreSalt, + check: [u8; 32], +} + +impl Meta { + /// The fields of a new store. + pub(crate) const fn new(root_key_id: KeyId, salt: StoreSalt, check: [u8; 32]) -> Self { + Self { + root_key_id, + salt, + check, + } + } + + /// The active root key id. + pub(crate) const fn root_key_id(&self) -> KeyId { + self.root_key_id + } + + /// The key-derivation salt. + pub(crate) const fn salt(&self) -> &StoreSalt { + &self.salt + } + + /// The stored key-check value. + pub(crate) const fn check(&self) -> &[u8; 32] { + &self.check + } +} + +/// Stages every `meta` field of a new store. +pub(crate) fn write(tx: &mut WriteTx<'_>, keyspace: &SingleWriterTxKeyspace, meta: &Meta) { + tx.insert( + keyspace, + field::SCHEMA_VERSION, + SCHEMA_VERSION.get().to_le_bytes().to_vec(), + ); + tx.insert(keyspace, field::FORMAT, FORMAT.to_vec()); + tx.insert( + keyspace, + field::ROOT_KEY_ID, + meta.root_key_id.get().to_le_bytes().to_vec(), + ); + tx.insert(keyspace, field::KDF_SALT, meta.salt.as_bytes().to_vec()); + tx.insert(keyspace, field::KEY_CHECK, meta.check.to_vec()); +} + +/// Reads and checks `meta`: format, then schema version, then the +/// unlocking fields. Writes nothing. +pub(crate) fn read( + reader: &R, + keyspace: &SingleWriterTxKeyspace, + path: &Path, +) -> Result { + let format = field_bytes(reader, keyspace, field::FORMAT, path)?; + ensure!( + format == FORMAT, + MetaMalformedSnafu { + field: field::FORMAT + } + ); + let found = u32::from_le_bytes(fixed(reader, keyspace, field::SCHEMA_VERSION, path)?); + let supported = SCHEMA_VERSION.get(); + ensure!(found <= supported, SchemaTooNewSnafu { found, supported }); + ensure!( + found == supported, + MigrationRequiredSnafu { found, supported } + ); + Ok(Meta { + root_key_id: KeyId::new(u32::from_le_bytes(fixed( + reader, + keyspace, + field::ROOT_KEY_ID, + path, + )?)), + salt: StoreSalt::from_bytes(fixed(reader, keyspace, field::KDF_SALT, path)?), + check: fixed(reader, keyspace, field::KEY_CHECK, path)?, + }) +} + +/// The bytes of `name`; a missing field means no complete store. +fn field_bytes( + reader: &R, + keyspace: &SingleWriterTxKeyspace, + name: &'static str, + path: &Path, +) -> Result> { + let value = reader + .get(keyspace, name) + .context(DatabaseSnafu)? + .context(StoreMissingSnafu { + path: path.to_path_buf(), + missing: name, + })?; + Ok(value.to_vec()) +} + +/// The bytes of `name` as a fixed-length array. +fn fixed( + reader: &R, + keyspace: &SingleWriterTxKeyspace, + name: &'static str, + path: &Path, +) -> Result<[u8; N]> { + let bytes = field_bytes(reader, keyspace, name, path)?; + <[u8; N]>::try_from(bytes.as_slice()) + .ok() + .context(MetaMalformedSnafu { field: name }) +} diff --git a/crates/phylake/src/store/mod.rs b/crates/phylake/src/store/mod.rs new file mode 100644 index 0000000..779c0ca --- /dev/null +++ b/crates/phylake/src/store/mod.rs @@ -0,0 +1,560 @@ +//! The custody store (`docs/design/custody-store.md`). +//! +//! One fjall single-writer transactional database holds the fifteen +//! keyspaces of the design. Every transaction commits with +//! `PersistMode::SyncAll`, and every value outside `meta` is sealed with +//! [`crate::crypto::seal`], binding it to its schema version, record kind, +//! key id, keyspace, and record key. +//! +//! Two sealing scopes exist (the normative table is +//! `docs/design/custody-store.md`, "Sealing scope"): +//! +//! - store-sealed (root-derived keys): tenants, grants, revocations, +//! sessions, invocations, ledgers, artifact locators, and audit stubs. +//! Authorization walks grant chains across tenants and recovery scans +//! every invocation, so these must open without knowing a tenant first. +//! They carry identifiers and amounts, not acquired content. +//! - tenant-sealed (the tenant's data key): blobs, artifact side records, +//! session index entries, idempotency entries, and audit entries. Their +//! record keys hash the tenant id in, which is what binds them to the +//! tenant. +//! +//! The store exposes typed operations and makes no authorization decision +//! for reads: the caller (the daemon's lifecycle) authorizes, then reads. +//! Missing records read as `None`, never as a distinct error, so a read +//! cannot distinguish absence from a record the caller may not see. + +mod audit; +mod codec; +mod directory; +mod failpoint; +mod invocation; +mod meta; +mod read; +mod record_key; +mod records; +mod recovery; +mod transition; +mod view; + +#[cfg(test)] +mod test_support; +#[cfg(test)] +mod tests; + +use std::collections::HashMap; +use std::fmt; +use std::mem::MaybeUninit; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex, PoisonError}; + +use epitrope::Clock; +use fjall::{ + KeyspaceCreateOptions, PersistMode, Readable, SingleWriterTxDatabase, SingleWriterTxKeyspace, + SingleWriterWriteTx, +}; +use snafu::{OptionExt as _, ResultExt as _, ensure}; +use syntheke::TenantId; +use zeroize::Zeroize as _; + +use self::codec::StoredRecord; +use self::records::TenantRecord; +use crate::Result; +use crate::crypto::{ + Entropy, KeyId, Keyspace, OsEntropy, RecordKind, SealContext, SealingKey, StoreKeys, StoreSalt, + TenantKeys, open, seal_with, +}; +use crate::error::{ + DatabaseSnafu, InconsistentSnafu, InjectedCrashSnafu, StoreExistsSnafu, StoreIoSnafu, + StoreMissingSnafu, TenantMissingSnafu, +}; +use crate::keyfile::RootKey; + +pub use self::audit::{AuditEvent, AuditQuery}; +pub use self::directory::{ + GrantIssue, IssueOutcome, NewSession, RevokeGrant, RootGrant, TenantRegistration, +}; +pub use self::failpoint::{Boundary, Crash, Failpoint, NoFailpoints, Phase}; +pub use self::invocation::{ + Begin, Intent, InvocationStatus, SettleOutcome, Transfer, artifact_ref, +}; +pub use self::meta::SCHEMA_VERSION; +pub use self::read::ArtifactInfo; +pub use self::records::Terminal; +pub use self::recovery::RecoveryReport; +pub use self::view::{StoreSnapshot, TenantDirectory, TenantEntry}; + +/// The id of the root key a new store is created under. +const INITIAL_ROOT_KEY_ID: KeyId = KeyId::new(1); + +/// The id of a tenant's first data key. +const INITIAL_DATA_KEY_ID: KeyId = KeyId::new(1); + +/// The file fjall 3 writes last when it creates a database (verified in +/// the fjall 3.1 source, `file::VERSION_MARKER`). Its presence is what +/// separates an existing database from a directory fjall would initialize. +const FJALL_VERSION_MARKER: &str = "version"; + +/// Every keyspace, in the order of the design's table. +const ALL_KEYSPACES: [Keyspace; 15] = [ + Keyspace::Meta, + Keyspace::Keys, + Keyspace::Tenants, + Keyspace::Grants, + Keyspace::Revocations, + Keyspace::Sessions, + Keyspace::Invocations, + Keyspace::Idem, + Keyspace::Ledgers, + Keyspace::Artifacts, + Keyspace::Blobs, + Keyspace::SessionIndex, + Keyspace::Audit, + Keyspace::AuditStub, + Keyspace::Rekey, +]; + +/// A write transaction on the store. +type WriteTx<'a> = SingleWriterWriteTx<'a>; + +/// Where a record lives: its keyspace and record kind. +#[derive(Clone, Copy, Debug)] +pub(crate) struct Slot { + keyspace: Keyspace, + kind: RecordKind, +} + +/// The record slots. +pub(crate) mod slot { + use super::Slot; + use super::records::kind; + use crate::crypto::Keyspace; + + macro_rules! slots { + ($($name:ident => $keyspace:ident, $kind:ident;)+) => { + $(pub(crate) const $name: Slot = Slot { + keyspace: Keyspace::$keyspace, + kind: kind::$kind, + };)+ + }; + } + + slots! { + TENANT => Tenants, TENANT; + GRANT => Grants, GRANT; + REVOCATION => Revocations, REVOCATION; + SESSION => Sessions, SESSION; + INVOCATION => Invocations, INVOCATION; + IDEM => Idem, IDEM; + LEDGER => Ledgers, LEDGER; + ARTIFACT => Artifacts, ARTIFACT; + PENDING_ARTIFACT => Artifacts, PENDING_ARTIFACT; + LOCATOR => Artifacts, LOCATOR; + BLOB => Blobs, BLOB; + SESSION_INDEX => SessionIndex, SESSION_INDEX; + AUDIT => Audit, AUDIT; + AUDIT_STUB => AuditStub, AUDIT_STUB; + } +} + +/// Handles to every keyspace. +struct Keyspaces { + handles: Vec<(Keyspace, SingleWriterTxKeyspace)>, +} + +impl Keyspaces { + /// Opens (creating when absent) every keyspace. + fn open(db: &SingleWriterTxDatabase) -> Result { + let handles = ALL_KEYSPACES + .iter() + .map(|&keyspace| { + db.keyspace(keyspace.name(), KeyspaceCreateOptions::default) + .map(|handle| (keyspace, handle)) + .context(DatabaseSnafu) + }) + .collect::>>()?; + Ok(Self { handles }) + } + + /// The handle of `keyspace`. + fn get(&self, keyspace: Keyspace) -> Result<&SingleWriterTxKeyspace> { + self.handles + .iter() + .find_map(|(name, handle)| (*name == keyspace).then_some(handle)) + .context(InconsistentSnafu { + what: "keyspace handle not opened", + }) + } +} + +/// How to open or create a store. +pub struct StoreOptions { + path: PathBuf, + clock: Arc, + failpoint: Arc, + entropy: Box, +} + +impl fmt::Debug for StoreOptions { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("StoreOptions") + .field("path", &self.path) + .finish_non_exhaustive() + } +} + +impl StoreOptions { + /// Options for the store at `path`, reading time from `clock`. + #[must_use] + pub fn new(path: impl Into, clock: Arc) -> Self { + Self { + path: path.into(), + clock, + failpoint: Arc::new(NoFailpoints), + entropy: Box::new(OsEntropy), + } + } + + /// Installs a failpoint implementation (tests and the daemon's + /// failpoints build). + #[must_use] + pub fn failpoint(mut self, failpoint: Arc) -> Self { + self.failpoint = failpoint; + self + } + + /// Replaces the random source, so tests are reproducible. + #[cfg(test)] + pub(crate) fn entropy(mut self, entropy: Box) -> Self { + self.entropy = entropy; + self + } + + /// Creates a new store under `root`. + /// + /// The path must be absent or an empty directory; it is created with + /// mode 0700 when absent. The store gets a fresh key-derivation salt, + /// root key id 1, and schema version [`SCHEMA_VERSION`]. + /// + /// # Errors + /// + /// [`crate::Error::StoreExists`] for a non-empty path, + /// [`crate::Error::StoreIo`] or [`crate::Error::Database`] when the + /// filesystem or database fails, [`crate::Error::Entropy`] when the + /// salt cannot be drawn. + pub fn create(mut self, root: &RootKey) -> Result { + prepare_empty_dir(&self.path)?; + let salt = StoreSalt::generate_with(&mut self.entropy)?; + let keys = StoreKeys::derive(root, INITIAL_ROOT_KEY_ID, &salt)?; + let check = keys.key_check()?; + let db = open_database(&self.path)?; + let ks = Keyspaces::open(&db)?; + let store = self.into_store(db, ks, keys); + let mut tx = store.write_tx(); + meta::write( + &mut tx, + store.ks.get(Keyspace::Meta)?, + &meta::Meta::new(INITIAL_ROOT_KEY_ID, salt, *check.as_bytes()), + ); + store.commit(tx, None)?; + Ok(store) + } + + /// Opens the existing store at `path` under `root`. + /// + /// Opening reads the plaintext `meta` fields, refuses an unsupported + /// schema version, and checks the key before any keyspace is created + /// or written. Any failure leaves the store as it was. + /// + /// # Errors + /// + /// [`crate::Error::StoreMissing`] when no complete store exists at the + /// path, [`crate::Error::MetaMalformed`] for unreadable metadata, + /// [`crate::Error::SchemaTooNew`] and + /// [`crate::Error::MigrationRequired`] for a schema version this build + /// does not open, [`crate::Error::StoreLocked`] for the wrong root key, + /// [`crate::Error::Database`] when the database fails. + pub fn open(self, root: &RootKey) -> Result { + let marker = self.path.join(FJALL_VERSION_MARKER); + let exists = marker.try_exists().context(StoreIoSnafu { + path: self.path.clone(), + })?; + ensure!( + exists, + StoreMissingSnafu { + path: &self.path, + missing: "database", + } + ); + let db = open_database(&self.path)?; + if let Some(missing) = ALL_KEYSPACES + .iter() + .find(|keyspace| !db.keyspace_exists(keyspace.name())) + { + return StoreMissingSnafu { + path: &self.path, + missing: missing.name(), + } + .fail(); + } + let ks = Keyspaces::open(&db)?; + let stored = meta::read(&db.read_tx(), ks.get(Keyspace::Meta)?, &self.path)?; + let keys = StoreKeys::unlock(root, stored.root_key_id(), stored.salt(), stored.check())?; + Ok(self.into_store(db, ks, keys)) + } + + fn into_store(self, db: SingleWriterTxDatabase, ks: Keyspaces, keys: StoreKeys) -> Store { + Store { + path: self.path, + db, + ks, + keys, + clock: self.clock, + failpoint: self.failpoint, + entropy: Mutex::new(self.entropy), + tenant_keys: Mutex::new(HashMap::new()), + } + } +} + +/// Creates `path` (mode 0700) or checks that it is an empty directory. +fn prepare_empty_dir(path: &Path) -> Result<()> { + use std::os::unix::fs::DirBuilderExt as _; + match std::fs::read_dir(path) { + Ok(mut entries) => { + ensure!( + entries.next().is_none(), + StoreExistsSnafu { + path: path.to_path_buf() + } + ); + Ok(()) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => std::fs::DirBuilder::new() + .mode(0o700) + .create(path) + .context(StoreIoSnafu { + path: path.to_path_buf(), + }), + Err(error) if error.kind() == std::io::ErrorKind::NotADirectory => StoreExistsSnafu { + path: path.to_path_buf(), + } + .fail(), + Err(error) => Err(error).context(StoreIoSnafu { + path: path.to_path_buf(), + }), + } +} + +/// Opens the fjall database at `path`. +fn open_database(path: &Path) -> Result { + SingleWriterTxDatabase::builder(path) + .open() + .context(DatabaseSnafu) +} + +impl Entropy for Box { + fn fill<'a>(&mut self, dest: &'a mut [MaybeUninit]) -> Result<&'a mut [u8]> { + (**self).fill(dest) + } +} + +/// The custody store. `Send` and `Sync`: writers serialize on the +/// database's single-writer lock, readers use snapshots. +pub struct Store { + path: PathBuf, + db: SingleWriterTxDatabase, + ks: Keyspaces, + keys: StoreKeys, + clock: Arc, + failpoint: Arc, + entropy: Mutex>, + tenant_keys: Mutex>>, +} + +impl fmt::Debug for Store { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Store") + .field("path", &self.path) + .finish_non_exhaustive() + } +} + +impl Store { + /// The store directory. + #[must_use] + pub fn path(&self) -> &Path { + &self.path + } + + /// A consistent read-only view of the store at this moment. + #[must_use] + pub fn snapshot(&self) -> StoreSnapshot<'_> { + StoreSnapshot::new(self, self.db.read_tx()) + } + + /// Starts a write transaction that commits with `SyncAll`. + /// + /// WARNING: fjall's `write_tx` panics if its writer lock is poisoned, + /// which only a panic inside another write transaction causes. No code + /// path in this crate panics while holding it. + fn write_tx(&self) -> WriteTx<'_> { + self.db.write_tx().durability(Some(PersistMode::SyncAll)) + } + + /// Commits `tx`, running the failpoint hooks for `boundary`. + fn commit(&self, tx: WriteTx<'_>, boundary: Option) -> Result<()> { + if let Some(boundary) = boundary + && self.failpoint.before_commit(boundary).is_err() + { + return InjectedCrashSnafu { + boundary, + phase: Phase::BeforeCommit, + } + .fail(); + } + tx.commit().context(DatabaseSnafu)?; + if let Some(boundary) = boundary + && self.failpoint.after_commit(boundary).is_err() + { + return InjectedCrashSnafu { + boundary, + phase: Phase::AfterCommit, + } + .fail(); + } + Ok(()) + } + + /// Seals raw bytes for `slot` at `record_key`. + fn seal_bytes( + &self, + key: &SealingKey, + slot: Slot, + record_key: &[u8], + plaintext: &[u8], + ) -> Result> { + let ctx = SealContext::new(SCHEMA_VERSION, slot.keyspace, slot.kind, record_key); + let mut entropy = self.entropy.lock().unwrap_or_else(PoisonError::into_inner); + seal_with(key, &ctx, plaintext, &mut *entropy) + } + + /// Opens raw bytes read from `slot` at `record_key`. + fn open_bytes( + keys: &[&SealingKey], + slot: Slot, + record_key: &[u8], + sealed: &[u8], + ) -> Result>> { + let ctx = SealContext::new(SCHEMA_VERSION, slot.keyspace, slot.kind, record_key); + open(keys, &ctx, sealed) + } + + /// Reads and opens the record at `record_key` in `slot`. + fn get( + &self, + reader: &R, + slot: Slot, + record_key: &[u8], + keys: &[&SealingKey], + ) -> Result> { + let handle = self.ks.get(slot.keyspace)?; + let Some(sealed) = reader.get(handle, record_key).context(DatabaseSnafu)? else { + return Ok(None); + }; + let plain = Self::open_bytes(keys, slot, record_key, &sealed)?; + T::decode(&plain, slot.keyspace.name()).map(Some) + } + + /// Seals `value` and stages it at `record_key` in `slot`. + fn put( + &self, + tx: &mut WriteTx<'_>, + slot: Slot, + record_key: &[u8], + key: &SealingKey, + value: &T, + ) -> Result<()> { + let mut encoded = value.encode()?; + let sealed = self.seal_bytes(key, slot, record_key, &encoded); + let plain: &mut [u8] = &mut encoded; + plain.zeroize(); + tx.insert(self.ks.get(slot.keyspace)?, record_key, sealed?); + Ok(()) + } + + /// Reads a store-sealed record. + fn get_global( + &self, + reader: &R, + slot: Slot, + record_key: &[u8], + ) -> Result> { + self.get(reader, slot, record_key, &[self.keys.meta()]) + } + + /// Stages a store-sealed record. + fn put_global( + &self, + tx: &mut WriteTx<'_>, + slot: Slot, + record_key: &[u8], + value: &T, + ) -> Result<()> { + self.put(tx, slot, record_key, self.keys.meta(), value) + } + + /// The tenant record of `tenant`, read through `reader`. + fn tenant_record( + &self, + reader: &R, + tenant: TenantId, + ) -> Result> { + let key = record_key::store::tenant(self.keys.index(), tenant)?; + self.get_global(reader, slot::TENANT, &key) + } + + /// The derived keys of `tenant`, unwrapping its data key on first use. + /// + /// NOTE: keys are cached only after they are read from a committed + /// record, so a rolled-back registration never leaves a cached key. + /// Tenant data-key rotation and crypto-shredding must evict the entry + /// in the transaction that retires the key. + fn tenant_keys(&self, reader: &R, tenant: TenantId) -> Result> { + let cached = self + .tenant_keys + .lock() + .unwrap_or_else(PoisonError::into_inner) + .get(&tenant) + .cloned(); + if let Some(keys) = cached { + return Ok(keys); + } + let record = self + .tenant_record(reader, tenant)? + .context(TenantMissingSnafu { tenant })?; + let key_id = KeyId::new(record.data_key_id); + let slot_key = record_key::store::data_key(self.keys.index(), tenant, key_id)?; + let wrapped = reader + .get(self.ks.get(Keyspace::Keys)?, slot_key) + .context(DatabaseSnafu)? + .context(InconsistentSnafu { + what: "tenant data key is missing", + })?; + let derived = Arc::new( + self.keys + .unwrap_tenant_key(&tenant.to_bytes(), &wrapped)? + .derive()?, + ); + self.tenant_keys + .lock() + .unwrap_or_else(PoisonError::into_inner) + .insert(tenant, Arc::clone(&derived)); + Ok(derived) + } + + /// Now, from the injected clock. + fn now(&self) -> syntheke::Timestamp { + self.clock.now() + } +} diff --git a/crates/phylake/src/store/read.rs b/crates/phylake/src/store/read.rs new file mode 100644 index 0000000..1ba44b0 --- /dev/null +++ b/crates/phylake/src/store/read.rs @@ -0,0 +1,195 @@ +//! Artifact reads and session queries. +//! +//! The caller authorizes before it reads: these operations answer for any +//! artifact or session, and return `None` for one that does not exist or +//! is not yet published. They never return an error that distinguishes a +//! missing record from a present one, so the caller can map `None` and a +//! refusal to the same `NotFoundOrDenied` reply. Only published artifacts +//! are reachable: a B3 blob and its pending record have no locator and no +//! session index entry. + +use fjall::Readable as _; +use snafu::{OptionExt as _, ResultExt as _}; +use syntheke::{ + ArtifactRef, GrantId, QueryPage, ReadChunk, SessionId, SourceRef, TenantId, Timestamp, +}; + +use super::codec::StoredRecord as _; +use super::record_key::{self, HASHED_KEY_LEN}; +use super::records::{ArtifactRecord, LocatorRecord, SessionIndexRecord}; +use super::view::View; +use super::{Store, slot}; +use crate::Result; +use crate::crypto::{BlobAddress, TenantKeys}; +use crate::error::{DatabaseSnafu, InconsistentSnafu}; + +/// A published artifact's side record, without its envelope. +#[derive(Clone, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub struct ArtifactInfo { + /// The artifact. + pub artifact: ArtifactRef, + /// The tenant that captured it. + pub owner: TenantId, + /// The session it belongs to. + pub session: Option, + /// The chain it was captured under, leaf first. + pub grant_chain: Vec, + /// Its evidence identity. + pub source: SourceRef, + /// The derived text view. + pub text_view: Option, + /// Whether the text view was cut to the output bound. + pub truncated: bool, + /// Length of the text view in bytes. + pub output_bytes: u64, + /// Length of the verbatim envelope in bytes. + pub envelope_len: u64, + /// Whether the authorizing grant was revoked after the effect started. + pub revoked_after_effect: bool, + /// When it was published. + pub published_at: Timestamp, +} + +impl Store { + /// The published artifact `artifact`, or `None`. + /// + /// # Errors + /// + /// A storage, decryption, or decoding failure. + pub fn artifact(&self, artifact: ArtifactRef) -> Result> { + let snapshot = self.db.read_tx(); + let Some((record, _)) = self.published(&snapshot, artifact)? else { + return Ok(None); + }; + let published_at = record.published_at.context(InconsistentSnafu { + what: "published side record has no publish time", + })?; + Ok(Some(ArtifactInfo { + artifact: record.artifact, + owner: record.tenant, + session: record.session, + grant_chain: record.grant_chain, + source: record.source, + text_view: record.text_view, + truncated: record.truncated, + output_bytes: record.output_bytes, + envelope_len: record.blob_len, + revoked_after_effect: record.revoked_after_effect, + published_at, + })) + } + + /// Up to `len` bytes of the published artifact's verbatim envelope + /// from `offset`, or `None` when the artifact is not published. A + /// chunk past the end is empty. + /// + /// # Errors + /// + /// A storage, decryption, or decoding failure. + pub fn read_artifact( + &self, + artifact: ArtifactRef, + offset: u64, + len: u32, + ) -> Result> { + let snapshot = self.db.read_tx(); + let Some((record, keys)) = self.published(&snapshot, artifact)? else { + return Ok(None); + }; + let address = BlobAddress::from_bytes(record.blob_address); + let blob_key = record_key::tenant::blob(keys.index(), record.tenant, &address)?; + let sealed = snapshot + .get(self.ks.get(slot::BLOB.keyspace)?, blob_key) + .context(DatabaseSnafu)? + .context(InconsistentSnafu { + what: "published artifact has no blob", + })?; + let envelope = Self::open_bytes(&[keys.blob()], slot::BLOB, &blob_key, &sealed)?; + let total = envelope.len(); + let start = usize::try_from(offset).unwrap_or(usize::MAX).min(total); + let end = start + .saturating_add(usize::try_from(len).unwrap_or(usize::MAX)) + .min(total); + let bytes = envelope.get(start..end).unwrap_or_default().to_vec(); + Ok(Some(ReadChunk { + offset, + bytes, + total_len: u64::try_from(total).unwrap_or(u64::MAX), + })) + } + + /// The published artifacts of `session` after `after`, at most + /// `limit`, in artifact id order; `None` when the session does not + /// exist. + /// + /// # Errors + /// + /// A storage, decryption, or decoding failure. + pub fn session_artifacts( + &self, + session: SessionId, + after: Option, + limit: u32, + ) -> Result> { + let snapshot = self.db.read_tx(); + let view = View { + store: self, + reader: &snapshot, + }; + let Some(record) = view.session_record(session)? else { + return Ok(None); + }; + let keys = self.tenant_keys(&snapshot, record.owner)?; + let prefix = record_key::tenant::session_index(keys.index(), record.owner, session)?; + let from = after.map_or_else( + || prefix.to_vec(), + |artifact| { + // WHY append a byte: the range starts strictly after + // `artifact`, whose key is the prefix plus its 16 bytes. + let mut key = record_key::join(&prefix, &artifact.to_bytes()); + key.push(0); + key + }, + ); + let limit = usize::try_from(limit).unwrap_or(usize::MAX); + let mut result_refs = Vec::new(); + let mut more = false; + for guard in snapshot.range(self.ks.get(slot::SESSION_INDEX.keyspace)?, from..) { + let (key, sealed) = guard.into_inner().context(DatabaseSnafu)?; + if key.get(..HASHED_KEY_LEN) != Some(&prefix[..]) { + break; + } + if result_refs.len() >= limit { + more = true; + break; + } + let plain = Self::open_bytes(&[keys.meta()], slot::SESSION_INDEX, &key, &sealed)?; + let entry = SessionIndexRecord::decode(&plain, slot::SESSION_INDEX.keyspace.name())?; + result_refs.push(entry.artifact); + } + Ok(Some(QueryPage { result_refs, more })) + } + + /// The published side record of `artifact` and its owner's keys. + fn published( + &self, + reader: &R, + artifact: ArtifactRef, + ) -> Result)>> { + let locator_key = record_key::store::locator(self.keys.index(), artifact)?; + let Some(locator) = + self.get_global::(reader, slot::LOCATOR, &locator_key)? + else { + return Ok(None); + }; + let keys = self.tenant_keys(reader, locator.owner)?; + let side_key = record_key::tenant::artifact(keys.index(), locator.owner, artifact)?; + let record = self + .get::(reader, slot::ARTIFACT, &side_key, &[keys.meta()])? + .context(InconsistentSnafu { + what: "artifact locator has no side record", + })?; + Ok(Some((record, keys))) + } +} diff --git a/crates/phylake/src/store/record_key.rs b/crates/phylake/src/store/record_key.rs new file mode 100644 index 0000000..d4d500b --- /dev/null +++ b/crates/phylake/src/store/record_key.rs @@ -0,0 +1,199 @@ +//! Physical record keys. +//! +//! Every key that holds a tenant, session, grant, invocation, artifact, or +//! idempotency component is a keyed HMAC-SHA256, so a raw key reveals none +//! of them. Keys for records sealed under the store keys hash under the +//! store index subkey; keys for records sealed under a tenant's keys hash +//! under that tenant's index subkey and always include the tenant id, +//! because a tenant-sealed record is bound to its tenant only through its +//! record key (the seal's key id is per tenant, not global). +//! +//! The hash input is a domain label followed by length-prefixed parts, so +//! two different part lists never produce the same input. +//! +//! Two keys carry an unhashed suffix after a hashed prefix: an audit entry +//! ends in its big-endian sequence number, and a session index entry ends +//! in the artifact id, so each range scans in order. Neither suffix holds a +//! tenant, session, or idempotency component. The artifact id is the +//! capturing invocation's id ([`super::artifact_ref`]), a ULID that leaks +//! only its creation time, as the design accepts; the invocation record's +//! own key is hashed, so the suffix links to nothing else on disk. The +//! global `audit_stub` key is the bare sequence number. + +use syntheke::{ + ArtifactRef, Capability, GrantId, IdempotencyKey, InvocationId, SessionId, TenantId, +}; +use zeroize::Zeroizing; + +use super::records::LedgerRef; +use crate::Result; +use crate::crypto::{BlobAddress, KeyId, SubKey}; + +/// Length of every hashed key. +pub(crate) const HASHED_KEY_LEN: usize = 32; + +/// Length of the sequence suffix on audit keys. +pub(crate) const SEQ_LEN: usize = 8; + +/// A hashed record key. +pub(crate) type HashedKey = [u8; HASHED_KEY_LEN]; + +/// HMAC-SHA256 under `key` over `domain` and length-prefixed `parts`. +fn keyed(key: &SubKey, domain: &[u8], parts: &[&[u8]]) -> Result { + let mut input = Zeroizing::new(Vec::with_capacity(64)); + input.extend_from_slice(b"dioptron/v1/key/"); + input.extend_from_slice(domain); + for part in parts { + // WHY u32: every part is an id, a key of at most 64 bytes, or a + // 32-byte address, so the prefix never truncates. + let len = u32::try_from(part.len()).unwrap_or(u32::MAX); + input.extend_from_slice(&len.to_le_bytes()); + input.extend_from_slice(part); + } + key.keyed_hash(&input) +} + +/// Store-level keys: records sealed under the store keys. +pub(crate) mod store { + use super::{ + ArtifactRef, GrantId, HashedKey, InvocationId, KeyId, LedgerRef, Result, SessionId, SubKey, + TenantId, keyed, + }; + + /// A tenant record. + pub(crate) fn tenant(index: &SubKey, tenant: TenantId) -> Result { + keyed(index, b"tenant", &[&tenant.to_bytes()]) + } + + /// A tenant's wrapped data key `key_id`. + pub(crate) fn data_key(index: &SubKey, tenant: TenantId, key_id: KeyId) -> Result { + keyed( + index, + b"data-key", + &[&tenant.to_bytes(), &key_id.get().to_le_bytes()], + ) + } + + /// A grant record. + pub(crate) fn grant(index: &SubKey, grant: GrantId) -> Result { + keyed(index, b"grant", &[&grant.to_bytes()]) + } + + /// A revocation record. + pub(crate) fn revocation(index: &SubKey, grant: GrantId) -> Result { + keyed(index, b"revocation", &[&grant.to_bytes()]) + } + + /// A session record. + pub(crate) fn session(index: &SubKey, session: SessionId) -> Result { + keyed(index, b"session", &[&session.to_bytes()]) + } + + /// An invocation record. + pub(crate) fn invocation(index: &SubKey, invocation: InvocationId) -> Result { + keyed(index, b"invocation", &[&invocation.to_bytes()]) + } + + /// A ledger. + pub(crate) fn ledger(index: &SubKey, ledger: LedgerRef) -> Result { + match ledger { + LedgerRef::Grant(id) => keyed(index, b"ledger/grant", &[&id.to_bytes()]), + LedgerRef::Session(id) => keyed(index, b"ledger/session", &[&id.to_bytes()]), + LedgerRef::Tenant(id) => keyed(index, b"ledger/tenant", &[&id.to_bytes()]), + } + } + + /// An artifact locator. + pub(crate) fn locator(index: &SubKey, artifact: ArtifactRef) -> Result { + keyed(index, b"locator", &[&artifact.to_bytes()]) + } +} + +/// Tenant-level keys: records sealed under one tenant's keys. Each takes +/// the tenant's index subkey and the tenant id. +pub(crate) mod tenant { + use super::{ + ArtifactRef, BlobAddress, Capability, HashedKey, IdempotencyKey, InvocationId, Result, + SessionId, SubKey, TenantId, keyed, + }; + + /// An idempotency entry for `key` under `capability`. + pub(crate) fn idem( + index: &SubKey, + tenant: TenantId, + capability: Capability, + key: &IdempotencyKey, + ) -> Result { + keyed( + index, + b"idem", + &[ + &tenant.to_bytes(), + capability.name().as_bytes(), + key.as_bytes(), + ], + ) + } + + /// A published artifact side record. + pub(crate) fn artifact( + index: &SubKey, + tenant: TenantId, + artifact: ArtifactRef, + ) -> Result { + keyed( + index, + b"artifact", + &[&tenant.to_bytes(), &artifact.to_bytes()], + ) + } + + /// The pending (B3) side record of `invocation`. + pub(crate) fn pending( + index: &SubKey, + tenant: TenantId, + invocation: InvocationId, + ) -> Result { + keyed( + index, + b"pending", + &[&tenant.to_bytes(), &invocation.to_bytes()], + ) + } + + /// A blob at `address`. + pub(crate) fn blob( + index: &SubKey, + tenant: TenantId, + address: &BlobAddress, + ) -> Result { + keyed(index, b"blob", &[&tenant.to_bytes(), address.as_bytes()]) + } + + /// The prefix of session `session`'s index entries; `owner` is the + /// session's owner, whose keys seal the entries. + pub(crate) fn session_index( + index: &SubKey, + owner: TenantId, + session: SessionId, + ) -> Result { + keyed( + index, + b"session-index", + &[&owner.to_bytes(), &session.to_bytes()], + ) + } + + /// The prefix of `tenant`'s audit entries. + pub(crate) fn audit(index: &SubKey, tenant: TenantId) -> Result { + keyed(index, b"audit", &[&tenant.to_bytes()]) + } +} + +/// `prefix` followed by `suffix`. +pub(crate) fn join(prefix: &HashedKey, suffix: &[u8]) -> Vec { + let mut key = Vec::with_capacity(HASHED_KEY_LEN.saturating_add(suffix.len())); + key.extend_from_slice(prefix); + key.extend_from_slice(suffix); + key +} diff --git a/crates/phylake/src/store/records.rs b/crates/phylake/src/store/records.rs new file mode 100644 index 0000000..5cc021f --- /dev/null +++ b/crates/phylake/src/store/records.rs @@ -0,0 +1,405 @@ +//! Storage-side record types. +//! +//! WHY separate types: the store's schema changes only by migration, while +//! the contract and authorization types change with their own versions. +//! Contract types are embedded where their shape already is the stored +//! shape (identifiers, `Cost`, `Ceilings`, `SourceRef`, vocabulary enums); +//! everything else is converted at the boundary. Every record is encoded +//! with rkyv and read back only through validated access. + +use epitrope::{Grant, LedgerId, Revocation, TargetScope}; +use snafu::ResultExt as _; +use syntheke::{ + ArtifactRef, AuditScope, AuditSeq, Capability, Ceilings, Cost, DenyCode, Failure, GrantId, + InvocationId, InvocationState, OutcomeKind, ReleaseReason, SessionId, SessionScope, SourceRef, + TenantClass, TenantId, Timestamp, +}; + +use crate::error::AuthzSnafu; + +/// Record kinds bound into every sealed value's additional data. +pub(crate) mod kind { + use crate::crypto::RecordKind; + + /// A tenant record in `tenants`. + pub(crate) const TENANT: RecordKind = RecordKind::new(1); + /// A grant record in `grants`. + pub(crate) const GRANT: RecordKind = RecordKind::new(3); + /// A revocation record in `revocations`. + pub(crate) const REVOCATION: RecordKind = RecordKind::new(4); + /// A session record in `sessions`. + pub(crate) const SESSION: RecordKind = RecordKind::new(5); + /// An invocation record in `invocations`. + pub(crate) const INVOCATION: RecordKind = RecordKind::new(6); + /// An idempotency entry in `idem`. + pub(crate) const IDEM: RecordKind = RecordKind::new(7); + /// A ledger in `ledgers`. + pub(crate) const LEDGER: RecordKind = RecordKind::new(8); + /// A published artifact side record in `artifacts`. + pub(crate) const ARTIFACT: RecordKind = RecordKind::new(9); + /// A pending (B3, not visible) side record in `artifacts`. + pub(crate) const PENDING_ARTIFACT: RecordKind = RecordKind::new(10); + /// An artifact locator in `artifacts`. + pub(crate) const LOCATOR: RecordKind = RecordKind::new(11); + /// A verbatim envelope in `blobs`. + pub(crate) const BLOB: RecordKind = RecordKind::new(12); + /// A session index entry in `session_index`. + pub(crate) const SESSION_INDEX: RecordKind = RecordKind::new(13); + /// A per-tenant audit entry in `audit`. + pub(crate) const AUDIT: RecordKind = RecordKind::new(14); + /// A global audit stub in `audit_stub`. + pub(crate) const AUDIT_STUB: RecordKind = RecordKind::new(15); +} + +/// How an invocation ended. Each variant records only what its terminal +/// state means (contract § Invocation lifecycle): a settled call the reply +/// the caller observed, a released call the reason it released, and an +/// unknown effect nothing more. A release reason is never stored as a +/// failure, so `Released(Abandoned)` and `Released(Cancelled)` stay +/// distinct in every record. +#[derive( + Clone, Copy, Debug, PartialEq, Eq, Hash, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize, +)] +#[non_exhaustive] +pub enum Terminal { + /// `Settled`: the actual consumption kept, the remainder released. + Settled { + /// The failure the caller observed; `None` for a success. + failure: Option, + }, + /// `Released`: the whole reservation returned. + Released { + /// Why it was released. + reason: ReleaseReason, + }, + /// `UnknownEffect`: the whole reservation charged. + UnknownEffect, +} + +impl Terminal { + /// The terminal state this ending records. + #[must_use] + pub const fn state(self) -> InvocationState { + match self { + Self::Settled { .. } => InvocationState::Settled, + Self::Released { .. } => InvocationState::Released, + Self::UnknownEffect => InvocationState::UnknownEffect, + } + } + + /// The release reason, for `Released` only. + #[must_use] + pub const fn release_reason(self) -> Option { + match self { + Self::Released { reason } => Some(reason), + Self::Settled { .. } | Self::UnknownEffect => None, + } + } + + /// The failure an idempotent replay of this invocation reports, or + /// `None` for a success. + /// + /// For `Released` this is a reply mapping, not the record: `Revoked` + /// reads as `Denied{GrantRevoked}`; `ProducerUnavailable`, + /// `DeadlineExceeded`, and `Cancelled` as themselves; `Abandoned`, + /// which no caller ever received a reply for, as `Cancelled`, the + /// contract's outcome for a call that ended before any effect. The + /// reason itself stays in [`Terminal::release_reason`]. + #[must_use] + pub const fn reply_failure(self) -> Option { + match self { + Self::Settled { failure } => failure, + Self::UnknownEffect => Some(Failure::UnknownEffect), + Self::Released { reason } => Some(match reason { + ReleaseReason::Revoked => Failure::denied(DenyCode::GrantRevoked), + ReleaseReason::ProducerUnavailable => Failure::ProducerUnavailable, + ReleaseReason::DeadlineExceeded => Failure::DeadlineExceeded, + // WHY a wildcard: `ReleaseReason` is non-exhaustive; a + // reason a later contract adds reads as a call that ended + // before any effect until it states otherwise. + _ => Failure::Cancelled, + }), + } + } + + /// The reply kind of [`Terminal::reply_failure`]: `Success` when it is + /// `None`. + #[must_use] + pub const fn outcome(self) -> OutcomeKind { + match self.reply_failure() { + Some(failure) => failure.kind(), + None => OutcomeKind::Success, + } + } +} + +/// Derive list shared by every stored record. +macro_rules! record { + ($(#[$meta:meta])* pub(crate) struct $name:ident { $($body:tt)* }) => { + $(#[$meta])* + #[derive(Clone, Debug, PartialEq, Eq, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)] + pub(crate) struct $name { $($body)* } + }; +} + +record! { + /// A registered tenant. + pub(crate) struct TenantRecord { + pub(crate) id: TenantId, + pub(crate) class: TenantClass, + pub(crate) verifying_key: [u8; 32], + pub(crate) bound_uids: Vec, + pub(crate) parent: Option, + pub(crate) ceilings: Ceilings, + /// The id of the tenant's active data key. + pub(crate) data_key_id: u32, + pub(crate) registered_at: Timestamp, + } +} + +record! { + /// A grant. Target patterns are kept as their text so the record + /// round-trips without a pattern printer. + pub(crate) struct GrantRecord { + pub(crate) id: GrantId, + pub(crate) issuer: TenantId, + pub(crate) holder: TenantId, + pub(crate) capabilities: Vec, + pub(crate) session_scope: SessionScope, + pub(crate) target_patterns: Vec, + pub(crate) audit_scope: AuditScope, + pub(crate) ceilings: Ceilings, + pub(crate) not_before: Timestamp, + pub(crate) expires_at: Timestamp, + pub(crate) parent: Option, + pub(crate) depth: u8, + pub(crate) max_depth: u8, + } +} + +impl GrantRecord { + /// The stored form of `grant`, whose target scope parsed from + /// `target_patterns`. + pub(crate) fn from_grant(grant: &Grant, target_patterns: Vec) -> Self { + Self { + id: grant.id, + issuer: grant.issuer, + holder: grant.holder, + capabilities: grant.capabilities.iter().copied().collect(), + session_scope: grant.session_scope.clone(), + target_patterns, + audit_scope: grant.audit_scope, + ceilings: grant.ceilings, + not_before: grant.not_before, + expires_at: grant.expires_at, + parent: grant.parent, + depth: grant.depth, + max_depth: grant.max_depth, + } + } + + /// The authorization form of this record. + pub(crate) fn to_grant(&self) -> crate::Result { + Ok(Grant { + id: self.id, + issuer: self.issuer, + holder: self.holder, + capabilities: self.capabilities.iter().copied().collect(), + session_scope: self.session_scope.clone(), + target_scope: TargetScope::parse(&self.target_patterns).context(AuthzSnafu)?, + audit_scope: self.audit_scope, + ceilings: self.ceilings, + not_before: self.not_before, + expires_at: self.expires_at, + parent: self.parent, + depth: self.depth, + max_depth: self.max_depth, + }) + } +} + +record! { + /// A revocation record. + pub(crate) struct RevocationRecord { + pub(crate) grant: GrantId, + pub(crate) at_seq: AuditSeq, + pub(crate) at_time: Timestamp, + pub(crate) by: TenantId, + } +} + +impl RevocationRecord { + /// The authorization form of this record. + pub(crate) const fn to_revocation(&self) -> Revocation { + Revocation { + grant: self.grant, + at_seq: self.at_seq, + at_time: self.at_time, + } + } +} + +record! { + /// A session. + pub(crate) struct SessionRecord { + pub(crate) id: SessionId, + pub(crate) owner: TenantId, + /// The session this one was forked from. + pub(crate) parent: Option, + pub(crate) ceilings: Ceilings, + pub(crate) created_at: Timestamp, + } +} + +/// Identifies a ledger in storage. +#[derive( + Clone, Copy, Debug, PartialEq, Eq, Hash, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize, +)] +pub(crate) enum LedgerRef { + /// A grant's ledger. + Grant(GrantId), + /// A session's ledger. + Session(SessionId), + /// A tenant's ledger. + Tenant(TenantId), +} + +impl LedgerRef { + /// The stored form of an authorization ledger id, or `None` for a + /// ledger kind this schema does not store. + pub(crate) const fn from_ledger_id(id: LedgerId) -> Option { + match id { + LedgerId::Grant(grant) => Some(Self::Grant(grant)), + LedgerId::Session(session) => Some(Self::Session(session)), + LedgerId::Tenant(tenant) => Some(Self::Tenant(tenant)), + _ => None, + } + } +} + +record! { + /// A budget ledger: settled consumption plus unsettled reservations. + pub(crate) struct LedgerRecord { + pub(crate) used: Cost, + } +} + +record! { + /// An invocation's intent and lifecycle state. + pub(crate) struct InvocationRecord { + pub(crate) id: InvocationId, + pub(crate) tenant: TenantId, + pub(crate) capability: Capability, + pub(crate) session: Option, + /// The authorizing chain, leaf first. + pub(crate) grant_chain: Vec, + /// Every ledger the reservation debited. + pub(crate) ledgers: Vec, + /// The declared maximum debited from each ledger. + pub(crate) reserved: Cost, + pub(crate) state: InvocationState, + /// How the invocation ended; `None` until a terminal state. + pub(crate) terminal: Option, + /// Actual consumption, recorded at B3 or at a B2 settlement. + pub(crate) actual: Option, + /// Amount kept as spent at B5. + pub(crate) debited: Option, + /// The artifact written at B3 and published at B4. + pub(crate) artifact: Option, + pub(crate) revoked_after_effect: bool, + pub(crate) created_at: Timestamp, + pub(crate) updated_at: Timestamp, + } +} + +record! { + /// An idempotency index entry. It lives only in the tenant-sealed + /// `idem` keyspace, so the request binding is shredded with the + /// tenant. + pub(crate) struct IdemRecord { + pub(crate) invocation: InvocationId, + /// The store's binding of the caller's request digest to the + /// designated grant, capability, session, target, and declared + /// cost ([`crate::store::invocation`]). + pub(crate) request_binding: [u8; 32], + } +} + +record! { + /// An artifact side record, pending (B3) or published (B4). + pub(crate) struct ArtifactRecord { + pub(crate) artifact: ArtifactRef, + pub(crate) invocation: InvocationId, + pub(crate) tenant: TenantId, + pub(crate) session: Option, + pub(crate) grant_chain: Vec, + /// The reservation the capture ran under (the invocation's). + pub(crate) reservation: InvocationId, + /// No classifier exists in Phase 01; always `None` until D7 lands. + pub(crate) classification: Option, + /// The artifact this one corrects, if any. + pub(crate) lineage: Option, + /// SHA-256 of the envelope, kept only inside this sealed record. + pub(crate) provenance_digest: [u8; 32], + /// Tenant-keyed address of the envelope. + pub(crate) blob_address: [u8; 32], + pub(crate) blob_len: u64, + pub(crate) source: SourceRef, + pub(crate) text_view: Option, + pub(crate) truncated: bool, + pub(crate) output_bytes: u64, + pub(crate) revoked_after_effect: bool, + /// `None` while pending. + pub(crate) published_at: Option, + } +} + +record! { + /// Where a published artifact lives: its owner and session. Readable + /// with the store key alone, so a reader can find the owner's keys. + pub(crate) struct LocatorRecord { + pub(crate) artifact: ArtifactRef, + pub(crate) owner: TenantId, + pub(crate) session: Option, + } +} + +record! { + /// One artifact in a session's index. + pub(crate) struct SessionIndexRecord { + pub(crate) artifact: ArtifactRef, + pub(crate) captured_by: TenantId, + pub(crate) published_at: Timestamp, + } +} + +record! { + /// A per-tenant audit entry. + pub(crate) struct AuditEntryRecord { + pub(crate) seq: AuditSeq, + pub(crate) time: Timestamp, + pub(crate) tenant: TenantId, + pub(crate) session: Option, + pub(crate) invocation: InvocationId, + pub(crate) capability: Capability, + pub(crate) state: InvocationState, + /// The reply kind at that state; for `Released`, the reply a + /// replay observes ([`Terminal::outcome`]). + pub(crate) outcome: OutcomeKind, + /// Why the reservation was released; present exactly when `state` + /// is `Released`. + pub(crate) release_reason: Option, + } +} + +record! { + /// A global audit stub. Carries no tenant and no content, so it + /// survives a crypto-shred of the tenant. + pub(crate) struct AuditStubRecord { + pub(crate) seq: AuditSeq, + pub(crate) invocation: InvocationId, + pub(crate) capability: Capability, + pub(crate) outcome: OutcomeKind, + pub(crate) time: Timestamp, + } +} diff --git a/crates/phylake/src/store/recovery.rs b/crates/phylake/src/store/recovery.rs new file mode 100644 index 0000000..952e6ca --- /dev/null +++ b/crates/phylake/src/store/recovery.rs @@ -0,0 +1,100 @@ +//! Restart recovery: bring every non-terminal invocation to one known +//! state, never calling the producer. +//! +//! The action for each state is epitrope's [`recovery_action`]: B1 is +//! released as `Abandoned`, B2 becomes `UnknownEffect` (the reservation is +//! charged, the call is never dispatched again), B3 rolls forward to +//! publish and settle, B4 rolls forward to settle. Each step is the same +//! state-checked transaction the live path uses, so recovery is +//! idempotent: a second run finds only terminal states and writes nothing. + +use epitrope::{RecoveryAction, recovery_action}; +use fjall::Readable as _; +use snafu::ResultExt as _; +use syntheke::{InvocationId, ReleaseReason}; + +use super::codec::StoredRecord as _; +use super::invocation::SettleOutcome; +use super::records::InvocationRecord; +use super::{Store, slot}; +use crate::Result; +use crate::error::DatabaseSnafu; + +/// What one recovery run did. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[non_exhaustive] +pub struct RecoveryReport { + /// B1 invocations released as `Abandoned`. + pub released: u32, + /// B2 invocations charged as `UnknownEffect`. + pub unknown_effect: u32, + /// B3 invocations published and settled. + pub published: u32, + /// B4 invocations settled. + pub settled: u32, +} + +impl RecoveryReport { + /// Whether the run changed nothing. + #[must_use] + pub const fn is_empty(&self) -> bool { + self.released == 0 && self.unknown_effect == 0 && self.published == 0 && self.settled == 0 + } +} + +impl Store { + /// Scans every invocation and applies the recovery action to each one + /// not in a terminal state. + /// + /// PERF: the scan reads every invocation record, terminal ones + /// included. An index of open invocations would bound it by the number + /// in flight; Phase 01 volumes do not need one. + /// + /// # Errors + /// + /// A storage, decryption, or decoding failure, or + /// [`crate::Error::InjectedCrash`] when a failpoint is installed. + pub fn recover(&self) -> Result { + let mut report = RecoveryReport::default(); + for (id, action) in self.open_invocations()? { + match action { + RecoveryAction::ReleaseAbandoned => { + self.release(id, ReleaseReason::Abandoned)?; + report.released = report.released.saturating_add(1); + } + RecoveryAction::MarkUnknownEffect => { + self.mark_unknown_effect(id)?; + report.unknown_effect = report.unknown_effect.saturating_add(1); + } + RecoveryAction::RollForwardPublish => { + self.publish(id)?; + self.settle(id, SettleOutcome::Success)?; + report.published = report.published.saturating_add(1); + } + RecoveryAction::RollForwardSettle => { + self.settle(id, SettleOutcome::Success)?; + report.settled = report.settled.saturating_add(1); + } + // WHY skip: an action a later epitrope adds has no store + // step yet; leaving the invocation open is the closed side. + _ => {} + } + } + Ok(report) + } + + /// Every invocation with a recovery action, from one snapshot. + fn open_invocations(&self) -> Result> { + let snapshot = self.db.read_tx(); + let mut open = Vec::new(); + for guard in snapshot.iter(self.ks.get(slot::INVOCATION.keyspace)?) { + let (key, sealed) = guard.into_inner().context(DatabaseSnafu)?; + let plain = Self::open_bytes(&[self.keys.meta()], slot::INVOCATION, &key, &sealed)?; + let record = InvocationRecord::decode(&plain, slot::INVOCATION.keyspace.name())?; + if let Some(action) = recovery_action(record.state) { + open.push((record.id, action)); + } + } + Ok(open) + } +} diff --git a/crates/phylake/src/store/test_support.rs b/crates/phylake/src/store/test_support.rs new file mode 100644 index 0000000..bfb377a --- /dev/null +++ b/crates/phylake/src/store/test_support.rs @@ -0,0 +1,408 @@ +//! Test-only helpers for the store: a settable clock, reproducible +//! entropy, a crash failpoint, a seeded cast of tenants and grants, a +//! stand-in producer counter, and logical and raw-disk dumps. +#![expect(clippy::expect_used, reason = "test helpers must fail loudly")] + +use std::collections::BTreeSet; +use std::mem::MaybeUninit; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::sync::atomic::{AtomicI64, AtomicU32, AtomicU64, Ordering}; + +use epitrope::{AuthzRequest, Clock, IssueContext, LedgerId, LedgerView as _}; +use fjall::Readable as _; +use sha2::{Digest as _, Sha256}; +use syntheke::{ + ArtifactRef, Capability, Ceilings, Cost, GrantId, GrantIssueRequest, IdempotencyKey, + InvocationId, SessionId, SessionScope, SourceRef, TenantClass, TenantId, Timestamp, +}; + +use super::{ + ALL_KEYSPACES, Boundary, Crash, Failpoint, GrantIssue, IssueOutcome, NewSession, Phase, + RootGrant, Store, StoreOptions, TenantRegistration, +}; +use crate::Result; +use crate::crypto::Entropy; +use crate::keyfile::RootKey; + +/// The root key every fixture store uses. +pub(crate) const ROOT_BYTES: [u8; 32] = [0x5a; 32]; + +/// The instant tests run at. +pub(crate) const NOW: Timestamp = Timestamp::from_unix_millis(1_000_000); + +/// The operator. Ids are ASCII so a raw-disk scan can look for them. +pub(crate) const OPERATOR: TenantId = TenantId::from_bytes(*b"TENANT-OPERATOR1"); +/// An agent, child of the operator. +pub(crate) const AGENT: TenantId = TenantId::from_bytes(*b"TENANT-AGENT-017"); +/// A second agent with no grant on the first agent's sessions. +pub(crate) const OTHER: TenantId = TenantId::from_bytes(*b"TENANT-OTHER-042"); + +/// The operator's root grant. +pub(crate) const G_ROOT: GrantId = GrantId::from_bytes([0xa0; 16]); +/// The agent's grant, a child of the root. +pub(crate) const G_AGENT: GrantId = GrantId::from_bytes([0xb0; 16]); + +/// The agent's session. +pub(crate) const S_AGENT: SessionId = SessionId::from_bytes(*b"SESSION-AGENT-01"); + +/// The target every capture names. +pub(crate) const TARGET: &str = "https://example.com/custody-marker/7f3a"; + +/// A clock tests can move. +#[derive(Debug)] +pub(crate) struct TestClock(AtomicI64); + +impl TestClock { + /// A clock at [`NOW`]. + pub(crate) fn new() -> Arc { + Arc::new(Self(AtomicI64::new(NOW.unix_millis()))) + } +} + +impl Clock for TestClock { + fn now(&self) -> Timestamp { + Timestamp::from_unix_millis(self.0.load(Ordering::SeqCst)) + } +} + +/// Reproducible entropy: SHA-256 of a process-wide counter. Each draw is +/// distinct within a process, so nonces never repeat across reopens. +pub(crate) struct CountingEntropy; + +static ENTROPY_COUNTER: AtomicU64 = AtomicU64::new(0); + +impl Entropy for CountingEntropy { + fn fill<'a>(&mut self, dest: &'a mut [MaybeUninit]) -> Result<&'a mut [u8]> { + let mut drawn = Vec::with_capacity(dest.len()); + while drawn.len() < dest.len() { + let counter = ENTROPY_COUNTER.fetch_add(1, Ordering::SeqCst); + let block: [u8; 32] = Sha256::new() + .chain_update(b"phylake-test-entropy") + .chain_update(counter.to_le_bytes()) + .finalize() + .into(); + drawn.extend_from_slice(&block); + } + drawn.truncate(dest.len()); + Ok(dest.write_copy_of_slice(&drawn)) + } +} + +/// A failpoint that crashes at one boundary and phase. +#[derive(Debug)] +pub(crate) struct CrashAt { + pub(crate) boundary: Boundary, + pub(crate) phase: Phase, +} + +impl Failpoint for CrashAt { + fn before_commit(&self, boundary: Boundary) -> Result<(), Crash> { + if boundary == self.boundary && self.phase == Phase::BeforeCommit { + return Err(Crash); + } + Ok(()) + } + + fn after_commit(&self, boundary: Boundary) -> Result<(), Crash> { + if boundary == self.boundary && self.phase == Phase::AfterCommit { + return Err(Crash); + } + Ok(()) + } +} + +/// Stands in for the producer: counts calls, never touches the store. +#[derive(Debug, Default)] +pub(crate) struct ProducerCounter(AtomicU32); + +impl ProducerCounter { + /// Records one producer call. + pub(crate) fn call(&self) { + self.0.fetch_add(1, Ordering::SeqCst); + } + + /// Calls so far. + pub(crate) fn calls(&self) -> u32 { + self.0.load(Ordering::SeqCst) + } +} + +/// A store directory in a tempdir. +pub(crate) struct Fixture { + _dir: tempfile::TempDir, + pub(crate) path: PathBuf, + pub(crate) clock: Arc, +} + +impl Fixture { + /// A fresh tempdir; no store yet. + pub(crate) fn new() -> Self { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("store"); + Self { + _dir: dir, + path, + clock: TestClock::new(), + } + } + + /// Options for this fixture's store. + pub(crate) fn options(&self) -> StoreOptions { + let clock: Arc = self.clock.clone(); + StoreOptions::new(&self.path, clock).entropy(Box::new(CountingEntropy)) + } + + /// Creates the store. + pub(crate) fn create(&self) -> Store { + self.options() + .create(&RootKey::from_bytes(ROOT_BYTES)) + .expect("create store") + } + + /// Creates the store and seeds the cast. + pub(crate) fn seeded(&self) -> Store { + let store = self.create(); + seed(&store); + store + } + + /// Reopens the store with no failpoint. + pub(crate) fn reopen(&self) -> Store { + self.options() + .open(&RootKey::from_bytes(ROOT_BYTES)) + .expect("reopen store") + } + + /// Reopens the store with `failpoint`. + pub(crate) fn reopen_with(&self, failpoint: Arc) -> Store { + self.options() + .failpoint(failpoint) + .open(&RootKey::from_bytes(ROOT_BYTES)) + .expect("reopen store") + } +} + +/// Ceilings with `fetches` and `bytes_transferred` set. +pub(crate) const fn ceilings(fetches: u64, bytes: u64) -> Ceilings { + Ceilings { + wall_time_ms: None, + fetches: Some(fetches), + bytes_transferred: Some(bytes), + output_bytes: None, + tokens: None, + ops_band: None, + } +} + +/// Registers the operator, the agent, and the other agent; installs the +/// root grant; issues the agent's grant; opens the agent's session. +pub(crate) fn seed(store: &Store) { + for (id, class, parent) in [ + (OPERATOR, TenantClass::Operator, None), + (AGENT, TenantClass::Agent, Some(OPERATOR)), + (OTHER, TenantClass::Agent, Some(OPERATOR)), + ] { + let mut registration = TenantRegistration::new(id, class, [0x77; 32]); + registration.parent = parent; + registration.bound_uids = vec![1000]; + store.register_tenant(®istration).expect("register"); + } + let mut root = RootGrant::new( + G_ROOT, + OPERATOR, + Capability::ALL.iter().copied().collect(), + vec!["*".to_owned()], + ( + Timestamp::from_unix_millis(0), + Timestamp::from_unix_millis(9_000_000), + ), + 4, + ); + root.ceilings = ceilings(100, 1_048_576); + store.install_root_grant(&root).expect("root grant"); + issue_agent_grant(store, G_AGENT, ceilings(10, 524_288)); + store + .create_session(&NewSession::new(S_AGENT, AGENT, invocation(0xe0))) + .expect("session"); +} + +/// Issues a grant under the root to the agent. +pub(crate) fn issue_agent_grant(store: &Store, id: GrantId, ceilings: Ceilings) { + let request = GrantIssueRequest { + holder: AGENT, + capabilities: vec![ + Capability::SessionCreate, + Capability::Capture, + Capability::Read, + Capability::GrantIssue, + ], + session_scope: SessionScope::Own, + target_scope: vec!["example.com".to_owned()], + ceilings, + not_before: Timestamp::from_unix_millis(0), + expires_at: Timestamp::from_unix_millis(5_000_000), + max_depth: None, + }; + let context = IssueContext { + issuer: OPERATOR, + designated: G_ROOT, + child: id, + }; + let outcome = store + .issue_grant(&GrantIssue::new(context, &request, invocation(0xe1))) + .expect("issue"); + assert!( + matches!(outcome, IssueOutcome::Issued { .. }), + "the agent grant attenuates the root: {outcome:?}" + ); +} + +/// An invocation id from one byte. +pub(crate) const fn invocation(byte: u8) -> InvocationId { + InvocationId::from_bytes([byte; 16]) +} + +/// The artifact invocation `byte` publishes under. +pub(crate) const fn artifact(byte: u8) -> ArtifactRef { + super::artifact_ref(invocation(byte)) +} + +/// A 24-byte idempotency key from one byte. +pub(crate) fn idem(byte: u8) -> IdempotencyKey { + IdempotencyKey::new(vec![byte; 24]).expect("key length") +} + +/// The declared maximum of every test capture. +pub(crate) const DECLARED: Cost = Cost { + wall_time_ms: 1_000, + fetches: 1, + bytes_transferred: 4_096, + output_bytes: 1_024, + tokens: 0, + ops_band: 0, +}; + +/// The actual consumption of every test capture. +pub(crate) const ACTUAL: Cost = Cost { + wall_time_ms: 300, + fetches: 1, + bytes_transferred: 2_000, + output_bytes: 500, + tokens: 0, + ops_band: 0, +}; + +/// The agent's capture of [`TARGET`] into its session under `grant`. +pub(crate) const fn capture(grant: GrantId) -> AuthzRequest<'static> { + AuthzRequest { + tenant: AGENT, + grant, + capability: Capability::Capture, + target: Some(TARGET), + session: Some(S_AGENT), + declared: DECLARED, + } +} + +/// The envelope every test capture stores. +pub(crate) const ENVELOPE: &[u8] = + b"PHYLAKE-CUSTODY-PLAINTEXT-7f3a from https://example.com/custody-marker/7f3a"; + +/// The source reference of every test capture. +pub(crate) fn source() -> SourceRef { + SourceRef { + fingerprint: format!("fp:{TARGET}"), + schema_id: "zetesis.evidence.v1".to_owned(), + producer_revision: "fixture-1".to_owned(), + } +} + +/// Every ledger a capture under `G_AGENT` debits. +pub(crate) const CAPTURE_LEDGERS: [LedgerId; 4] = [ + LedgerId::Grant(G_AGENT), + LedgerId::Grant(G_ROOT), + LedgerId::Session(S_AGENT), + LedgerId::Tenant(AGENT), +]; + +/// The used amount on each capture ledger. +pub(crate) fn ledger_usage(store: &Store) -> Vec { + let snapshot = store.snapshot(); + CAPTURE_LEDGERS + .iter() + .map(|&ledger| snapshot.used(ledger).expect("ledger read")) + .collect() +} + +/// One keyspace's raw entries. +pub(crate) type Entries = Vec<(Vec, Vec)>; + +/// Every keyspace's raw keys and values, by keyspace name. +pub(crate) type Dump = Vec<(&'static str, Entries)>; + +/// Every keyspace's raw keys and values, from one snapshot. +pub(crate) fn dump(store: &Store) -> Dump { + let snapshot = store.db.read_tx(); + ALL_KEYSPACES + .iter() + .map(|&keyspace| { + let handle = store.ks.get(keyspace).expect("keyspace"); + let entries = snapshot + .iter(handle) + .map(|guard| { + let (key, value) = guard.into_inner().expect("entry"); + (key.to_vec(), value.to_vec()) + }) + .collect(); + (keyspace.name(), entries) + }) + .collect() +} + +/// The number of entries in `keyspace` of a dump. +pub(crate) fn count(dump: &Dump, keyspace: &str) -> usize { + dump.iter() + .find(|(name, _)| *name == keyspace) + .map_or(0, |(_, entries)| entries.len()) +} + +/// The contents of every file under `root`. +pub(crate) fn read_tree(root: &Path) -> Vec<(PathBuf, Vec)> { + let mut files = Vec::new(); + let mut pending = vec![root.to_path_buf()]; + while let Some(dir) = pending.pop() { + for entry in std::fs::read_dir(&dir).expect("read_dir") { + let path = entry.expect("entry").path(); + if path.is_dir() { + pending.push(path); + } else { + let mut bytes = std::fs::read(&path).expect("read file"); + // WHY: fjall preallocates each journal file to 64 MiB of + // zeros. Dropping the zero tail, less a margin longer than + // any needle, keeps the scan fast without hiding a needle + // that ends in zero bytes. + let used = bytes + .iter() + .rposition(|&byte| byte != 0) + .map_or(0, |last| last.saturating_add(65)); + bytes.truncate(used); + files.push((path, bytes)); + } + } + } + files +} + +/// Occurrences of `needle` in `haystack`. +pub(crate) fn occurrences(haystack: &[u8], needle: &[u8]) -> usize { + haystack + .windows(needle.len()) + .filter(|window| *window == needle) + .count() +} + +/// The set of capabilities in `list`. +pub(crate) fn caps(list: &[Capability]) -> BTreeSet { + list.iter().copied().collect() +} diff --git a/crates/phylake/src/store/tests.rs b/crates/phylake/src/store/tests.rs new file mode 100644 index 0000000..2131e45 --- /dev/null +++ b/crates/phylake/src/store/tests.rs @@ -0,0 +1,11 @@ +//! Store tests. Every test works in a tempdir with a fixed clock and +//! reproducible entropy. +#![expect(clippy::expect_used, reason = "test assertions must fail loudly")] + +mod audit; +mod crash; +mod directory; +mod disk; +mod lifecycle; +mod open; +mod terminal; diff --git a/crates/phylake/src/store/tests/audit.rs b/crates/phylake/src/store/tests/audit.rs new file mode 100644 index 0000000..a8870e5 --- /dev/null +++ b/crates/phylake/src/store/tests/audit.rs @@ -0,0 +1,161 @@ +//! Scoped audit reads across tenant partitions. + +use syntheke::{AuditScope, AuditSeq, Capability, InvocationId, SessionId, TenantId}; + +use crate::store::test_support::{ + ACTUAL, AGENT, ENVELOPE, Fixture, G_AGENT, G_ROOT, OPERATOR, OTHER, S_AGENT, capture, idem, + invocation, source, +}; +use crate::store::{AuditQuery, Begin, Intent, NewSession, SettleOutcome, Store, Transfer}; + +/// The other agent's own session. +const S_OTHER: SessionId = SessionId::from_bytes(*b"SESSION-OTHER-01"); + +/// A capture by `tenant` under `grant` into the agent's session, driven +/// to `Settled`. +fn settled_capture(store: &Store, tenant: TenantId, grant: syntheke::GrantId, byte: u8) { + let key = idem(byte); + let request = epitrope::AuthzRequest { + tenant, + grant, + ..capture(G_AGENT) + }; + let begin = store + .begin(&Intent::new(invocation(byte), request, &key, [0xd9; 32])) + .expect("B1"); + assert!(matches!(begin, Begin::Persisted(_)), "{begin:?}"); + store.dispatch(invocation(byte)).expect("B2"); + store + .complete_transfer(invocation(byte), &Transfer::new(ENVELOPE, source(), ACTUAL)) + .expect("B3"); + store.publish(invocation(byte)).expect("B4"); + store + .settle(invocation(byte), SettleOutcome::Success) + .expect("B5"); +} + +/// The seeded cast plus: the operator's capture in the agent's session +/// (invocation 6), the agent's own capture (invocation 7), and the other +/// agent's session (invocation 0xe2). +fn scenario(fixture: &Fixture) -> Store { + let store = fixture.seeded(); + settled_capture(&store, OPERATOR, G_ROOT, 6); + settled_capture(&store, AGENT, G_AGENT, 7); + store + .create_session(&NewSession::new(S_OTHER, OTHER, invocation(0xe2))) + .expect("other session"); + store +} + +/// The invocations `query` returns, in order. +fn invocations(store: &Store, query: &AuditQuery) -> Vec { + store + .audit_query(query) + .expect("audit query") + .into_iter() + .map(|event| event.record.invocation) + .collect() +} + +#[test] +fn all_scope_merges_every_partition_in_sequence_order() { + let fixture = Fixture::new(); + let store = scenario(&fixture); + let events = store + .audit_query(&AuditQuery::new(OPERATOR, AuditScope::All, 100)) + .expect("audit query"); + let seqs: Vec = events.iter().map(|event| event.record.seq.get()).collect(); + assert_eq!(seqs, [1, 2, 3, 4, 5], "every record once, in global order"); + let by_invocation: Vec = + events.iter().map(|event| event.record.invocation).collect(); + assert_eq!( + by_invocation, + [ + invocation(0xe1), + invocation(0xe0), + invocation(6), + invocation(7), + invocation(0xe2), + ], + "agent grant issue, agent session, operator capture, agent capture, other session" + ); +} + +#[test] +fn own_and_owned_sessions_adds_other_tenants_records_in_owned_sessions() { + let fixture = Fixture::new(); + let store = scenario(&fixture); + let query = AuditQuery::new(AGENT, AuditScope::OwnAndOwnedSessions, 100); + assert_eq!( + invocations(&store, &query), + [invocation(0xe0), invocation(6), invocation(7)], + "the agent's own records plus the operator's capture in its session" + ); + let other = AuditQuery::new(OTHER, AuditScope::OwnAndOwnedSessions, 100); + assert_eq!( + invocations(&store, &other), + [invocation(0xe2)], + "the other agent sees only its own session record" + ); + let operator = AuditQuery::new(OPERATOR, AuditScope::OwnAndOwnedSessions, 100); + assert_eq!( + invocations(&store, &operator), + [invocation(0xe1), invocation(6)], + "the operator's own records only: it owns no session" + ); +} + +#[test] +fn session_narrows_and_after_and_limit_page_across_partitions() { + let fixture = Fixture::new(); + let store = scenario(&fixture); + let mut query = AuditQuery::new(OPERATOR, AuditScope::All, 100); + query.session = Some(S_AGENT); + assert_eq!( + invocations(&store, &query), + [invocation(0xe0), invocation(6), invocation(7)], + "only the agent session's records" + ); + query.limit = 2; + let first = store.audit_query(&query).expect("first page"); + assert_eq!( + first + .iter() + .map(|event| event.record.invocation) + .collect::>(), + [invocation(0xe0), invocation(6)], + "first page spans two partitions" + ); + query.after = first.last().map(|event| event.record.seq); + assert_eq!(invocations(&store, &query), [invocation(7)], "second page"); + query.after = Some(AuditSeq::new(u64::MAX)); + assert!( + invocations(&store, &query).is_empty(), + "nothing follows the last sequence" + ); +} + +#[test] +fn audit_query_reads_what_each_record_committed() { + let fixture = Fixture::new(); + let store = scenario(&fixture); + let events = store + .audit_query(&AuditQuery::new( + AGENT, + AuditScope::OwnAndOwnedSessions, + 100, + )) + .expect("audit query"); + let operator_capture = events + .iter() + .find(|event| event.record.invocation == invocation(6)) + .expect("the operator's capture"); + assert_eq!(operator_capture.record.tenant, OPERATOR, "acting tenant"); + assert_eq!( + operator_capture.record.capability, + Capability::Capture, + "capability" + ); + assert_eq!(operator_capture.record.session, Some(S_AGENT), "session"); + assert_eq!(operator_capture.release_reason, None, "not a release"); +} diff --git a/crates/phylake/src/store/tests/crash.rs b/crates/phylake/src/store/tests/crash.rs new file mode 100644 index 0000000..394c68f --- /dev/null +++ b/crates/phylake/src/store/tests/crash.rs @@ -0,0 +1,235 @@ +//! The failure-injection table of `docs/design/custody-store.md`: crash +//! before and after each boundary commit, reopen, recover, and check the +//! exact recovered state, the ledgers, the audit trail, and the producer +//! call count. + +use std::sync::Arc; + +use syntheke::{Cost, InvocationState, OutcomeKind, ReleaseReason}; + +use crate::Error; +use crate::store::test_support::{ + ACTUAL, AGENT, CrashAt, DECLARED, ENVELOPE, Fixture, G_AGENT, ProducerCounter, artifact, + capture, count, dump, idem, invocation, ledger_usage, source, +}; +use crate::store::{ + AuditEvent, Begin, Boundary, Intent, Phase, RecoveryReport, SettleOutcome, Store, Terminal, + Transfer, +}; + +/// The state recovery must reach. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Expected { + /// No invocation and no reservation. + Nothing, + /// `Released(Abandoned)`, reservation returned. + Abandoned, + /// `UnknownEffect`, reservation charged, nothing visible. + UnknownEffect, + /// `Settled`, actual cost kept, artifact visible. + Settled, +} + +/// Runs one capture through every boundary, calling the stand-in producer +/// between B2 and B3, and stops at the first error. +fn drive(store: &Store, producer: &ProducerCounter) -> Result<(), Error> { + let key = idem(1); + let begin = store.begin(&Intent::new( + invocation(1), + capture(G_AGENT), + &key, + [0xd1; 32], + ))?; + assert!(matches!(begin, Begin::Persisted(_)), "{begin:?}"); + store.dispatch(invocation(1))?; + producer.call(); + let transfer = Transfer::new(ENVELOPE, source(), ACTUAL); + store.complete_transfer(invocation(1), &transfer)?; + store.publish(invocation(1))?; + store.settle(invocation(1), SettleOutcome::Success)?; + Ok(()) +} + +/// Crashes at `boundary`/`phase`, recovers, and checks the result. +fn check(boundary: Boundary, phase: Phase, expected: Expected, calls: u32) { + let fixture = Fixture::new(); + drop(fixture.seeded()); + let store = fixture.reopen_with(Arc::new(CrashAt { boundary, phase })); + let producer = ProducerCounter::default(); + let error = drive(&store, &producer).expect_err("the failpoint fires"); + assert!( + matches!(error, Error::InjectedCrash { boundary: b, phase: p, .. } if b == boundary && p == phase), + "crash at {phase} {boundary}: {error:?}" + ); + drop(store); + assert_eq!( + producer.calls(), + calls, + "calls before recovery at {phase} {boundary}" + ); + + let store = fixture.reopen(); + let report = store.recover().expect("recover"); + assert_eq!(producer.calls(), calls, "recovery never calls the producer"); + assert_eq!( + report, + expected_report(boundary, phase, expected), + "report at {phase} {boundary}" + ); + assert_state(&store, expected, &format!("{phase} {boundary}")); + + let settled = dump(&store); + let again = store.recover().expect("second recovery"); + assert!( + again.is_empty(), + "a second recovery does nothing: {again:?}" + ); + assert_eq!(dump(&store), settled, "a second recovery writes nothing"); + drop(store); + let store = fixture.reopen(); + assert!( + store.recover().expect("third").is_empty(), + "idempotent across reopen" + ); + assert_eq!(producer.calls(), calls, "the producer count never moves"); +} + +/// What recovery reports for the row: which roll-forward or terminal +/// step it took. +fn expected_report(boundary: Boundary, phase: Phase, expected: Expected) -> RecoveryReport { + let mut report = RecoveryReport::default(); + match (expected, boundary, phase) { + (Expected::Abandoned, ..) => report.released = 1, + (Expected::UnknownEffect, ..) => report.unknown_effect = 1, + (Expected::Settled, Boundary::CompleteTransfer, Phase::AfterCommit) + | (Expected::Settled, Boundary::Publish, Phase::BeforeCommit) => report.published = 1, + (Expected::Settled, Boundary::Publish, Phase::AfterCommit) + | (Expected::Settled, Boundary::Terminal, Phase::BeforeCommit) => report.settled = 1, + _ => {} + } + report +} + +fn assert_state(store: &Store, expected: Expected, case: &str) { + let status = store.invocation(invocation(1)).expect("read"); + let audits: Vec = store + .audit_records(AGENT, None, 100) + .expect("audit") + .into_iter() + .filter(|event| event.record.invocation == invocation(1)) + .collect(); + let visible = store + .read_artifact(artifact(1), 0, 4096) + .expect("read") + .map(|chunk| chunk.bytes); + let blobs = count(&dump(store), "blobs"); + // The exact terminal record, the amount kept, and what each ledger + // holds afterward. + let (terminal, kept, ledgers) = match expected { + Expected::Nothing => { + assert_eq!(status, None, "{case}: no invocation"); + assert_eq!( + ledger_usage(store), + vec![Cost::default(); 4], + "{case}: no reservation" + ); + assert!(audits.is_empty(), "{case}: nothing audited"); + return; + } + Expected::Abandoned => ( + Terminal::Released { + reason: ReleaseReason::Abandoned, + }, + Cost::default(), + Cost::default(), + ), + Expected::UnknownEffect => (Terminal::UnknownEffect, DECLARED, DECLARED), + Expected::Settled => (Terminal::Settled { failure: None }, ACTUAL, ACTUAL), + }; + let status = status.expect("recorded"); + assert_eq!(status.state, terminal.state(), "{case}: state"); + assert_eq!( + status.terminal, + Some(terminal), + "{case}: the exact terminal record" + ); + assert_eq!(status.debited, Some(kept), "{case}: amount kept"); + assert_eq!( + ledger_usage(store), + vec![ledgers; 4], + "{case}: settled or released once" + ); + if expected == Expected::UnknownEffect { + assert_eq!(blobs, 0, "{case}: an uncommitted blob is discarded"); + } + assert_terminal_audit(&audits, expected, case); + let expect_visible = expected == Expected::Settled; + assert_eq!( + visible.as_deref() == Some(ENVELOPE), + expect_visible, + "{case}: visibility" + ); + assert_eq!( + visible.is_some(), + expect_visible, + "{case}: nothing partial is visible" + ); +} + +/// The one terminal audit entry: its state, reply kind, and release +/// reason, exactly. +fn assert_terminal_audit(audits: &[AuditEvent], expected: Expected, case: &str) { + let (state, outcome, reason) = match expected { + Expected::Abandoned => ( + InvocationState::Released, + OutcomeKind::Cancelled, + Some(ReleaseReason::Abandoned), + ), + Expected::UnknownEffect => ( + InvocationState::UnknownEffect, + OutcomeKind::UnknownEffect, + None, + ), + _ => (InvocationState::Settled, OutcomeKind::Success, None), + }; + assert_eq!(audits.len(), 1, "{case}: one terminal audit entry"); + let audit = audits.first().expect("the terminal audit entry"); + assert_eq!(audit.record.state, state, "{case}: audited state"); + assert_eq!(audit.record.outcome, outcome, "{case}: audited reply kind"); + assert_eq!(audit.release_reason, reason, "{case}: audited reason"); +} + +/// One test per row of the failure-injection table. +macro_rules! crash_row { + ($($name:ident: $boundary:ident, $phase:ident => $expected:ident, calls $calls:literal;)+) => { + $(#[test] + fn $name() { + check(Boundary::$boundary, Phase::$phase, Expected::$expected, $calls); + })+ + }; +} + +crash_row! { + crash_before_b1_leaves_nothing: PersistIntent, BeforeCommit => Nothing, calls 0; + crash_after_b1_releases_abandoned: PersistIntent, AfterCommit => Abandoned, calls 0; + crash_before_b2_releases_abandoned: Dispatch, BeforeCommit => Abandoned, calls 0; + crash_after_b2_marks_unknown_effect: Dispatch, AfterCommit => UnknownEffect, calls 0; + crash_before_b3_marks_unknown_effect: CompleteTransfer, BeforeCommit => UnknownEffect, calls 1; + crash_after_b3_rolls_forward_to_settled: CompleteTransfer, AfterCommit => Settled, calls 1; + crash_before_b4_rolls_forward_to_settled: Publish, BeforeCommit => Settled, calls 1; + crash_after_b4_rolls_forward_to_settled: Publish, AfterCommit => Settled, calls 1; + crash_before_b5_rolls_forward_to_settled: Terminal, BeforeCommit => Settled, calls 1; + crash_after_b5_is_terminal: Terminal, AfterCommit => Settled, calls 1; +} + +#[test] +fn boundary_names_match_the_contract() { + let names: Vec = Boundary::ALL.iter().map(ToString::to_string).collect(); + assert_eq!(names, ["B1", "B2", "B3", "B4", "B5"], "boundary names"); + let phases: Vec = Phase::ALL.iter().map(ToString::to_string).collect(); + assert_eq!( + phases, + ["before commit of", "after commit of"], + "phase names" + ); +} diff --git a/crates/phylake/src/store/tests/directory.rs b/crates/phylake/src/store/tests/directory.rs new file mode 100644 index 0000000..5f68371 --- /dev/null +++ b/crates/phylake/src/store/tests/directory.rs @@ -0,0 +1,371 @@ +//! Grant, revocation, and session writes, and the read views over them. + +use epitrope::{GrantView as _, IssueContext, LedgerId, LedgerView as _, Origin}; +use syntheke::{ + Capability, DenyCode, Failure, GrantId, GrantIssueRequest, InvocationState, NarrowingAxis, + OutcomeKind, SessionId, SessionScope, TenantId, Timestamp, +}; + +use crate::Error; +use crate::store::test_support::{ + AGENT, Fixture, G_AGENT, G_ROOT, OPERATOR, OTHER, S_AGENT, caps, capture, ceilings, dump, idem, + invocation, +}; +use crate::store::{Begin, GrantIssue, Intent, IssueOutcome, NewSession, RevokeGrant, RootGrant}; + +/// A child of the agent's grant for the other agent. +fn child_request(fetches: u64) -> GrantIssueRequest { + GrantIssueRequest { + holder: OTHER, + capabilities: vec![Capability::Capture], + session_scope: SessionScope::Sessions(vec![S_AGENT]), + target_scope: vec!["example.com".to_owned()], + ceilings: ceilings(fetches, 1_024), + not_before: Timestamp::from_unix_millis(0), + expires_at: Timestamp::from_unix_millis(4_000_000), + max_depth: None, + } +} + +const G_CHILD: GrantId = GrantId::from_bytes([0xc0; 16]); + +fn issue(store: &crate::store::Store, request: &GrantIssueRequest) -> IssueOutcome { + let context = IssueContext { + issuer: AGENT, + designated: G_AGENT, + child: G_CHILD, + }; + store + .issue_grant(&GrantIssue::new(context, request, invocation(0x40))) + .expect("issue") +} + +#[test] +fn snapshot_reads_grants_sessions_and_tenants() { + let fixture = Fixture::new(); + let store = fixture.seeded(); + let snapshot = store.snapshot(); + let grant = snapshot.grant(G_AGENT).expect("read").expect("present"); + assert_eq!(grant.holder, AGENT, "holder"); + assert_eq!(grant.parent, Some(G_ROOT), "parent"); + let origin = Origin::parse("https://example.com/a").expect("origin"); + assert!( + grant.target_scope.matches(&origin), + "target scope round-trips" + ); + assert_eq!(snapshot.grant(G_CHILD).expect("read"), None, "absent grant"); + assert_eq!( + snapshot.session_owner(S_AGENT).expect("read"), + Some(AGENT), + "owner" + ); + assert_eq!( + snapshot.tenant_parent(AGENT).expect("read"), + Some(OPERATOR), + "parent" + ); + assert_eq!( + snapshot.revocation(G_AGENT).expect("read"), + None, + "not revoked" + ); + assert_eq!( + snapshot + .session_ceilings(SessionId::from_bytes([1; 16])) + .expect("read"), + syntheke::Ceilings::default(), + "an unknown session sets no ceiling" + ); +} + +#[test] +fn issued_child_is_stored_and_audited() { + let fixture = Fixture::new(); + let store = fixture.seeded(); + let outcome = issue(&store, &child_request(5)); + assert_eq!( + outcome, + IssueOutcome::Issued { + grant: G_CHILD, + parent: G_AGENT + }, + "the child attenuates its parent" + ); + let child = store + .snapshot() + .grant(G_CHILD) + .expect("read") + .expect("stored"); + assert_eq!(child.depth, 2, "depth"); + let audited = store + .audit_records(AGENT, None, 100) + .expect("audit") + .into_iter() + .map(|event| event.record) + .any(|record| { + record.invocation == invocation(0x40) + && record.capability == Capability::GrantIssue + && record.outcome == OutcomeKind::Success + }); + assert!(audited, "the issue is audited"); + + let before = dump(&store); + assert_eq!( + issue(&store, &child_request(5)), + outcome, + "a repeat returns the grant" + ); + assert_eq!(dump(&store), before, "a repeat writes nothing"); +} + +#[test] +fn repeat_issue_after_the_parent_spends_returns_the_grant() { + let fixture = Fixture::new(); + let store = fixture.seeded(); + let issued = issue(&store, &child_request(10)); + assert!( + matches!(issued, IssueOutcome::Issued { .. }), + "the child takes the parent's whole remaining 10 fetches: {issued:?}" + ); + let key = idem(0x42); + let begin = store + .begin(&Intent::new( + invocation(0x42), + capture(G_AGENT), + &key, + [0xd4; 32], + )) + .expect("B1"); + assert!(matches!(begin, Begin::Persisted(_)), "{begin:?}"); + assert_eq!( + store + .snapshot() + .used(LedgerId::Grant(G_AGENT)) + .expect("ledger") + .fetches, + 1, + "the parent has spent a fetch, so a fresh issue of 10 would be refused" + ); + let before = dump(&store); + assert_eq!( + issue(&store, &child_request(10)), + issued, + "a repeat of an issued request is not re-decided" + ); + assert_eq!(dump(&store), before, "a repeat writes nothing"); +} + +#[test] +fn broader_child_is_refused_and_audited() { + let fixture = Fixture::new(); + let store = fixture.seeded(); + let outcome = issue(&store, &child_request(11)); + assert_eq!( + outcome, + IssueOutcome::Refused { + failure: Failure::narrowing(NarrowingAxis::Ceilings) + }, + "11 fetches exceed the parent's 10" + ); + assert_eq!( + store.snapshot().grant(G_CHILD).expect("read"), + None, + "not stored" + ); + let denied = store + .audit_records(AGENT, None, 100) + .expect("audit") + .into_iter() + .map(|event| event.record) + .any(|record| { + record.invocation == invocation(0x40) && record.state == InvocationState::Denied + }); + assert!(denied, "the refusal is audited"); +} + +#[test] +fn different_grant_under_a_taken_id_conflicts() { + let fixture = Fixture::new(); + let store = fixture.seeded(); + issue(&store, &child_request(5)); + let context = IssueContext { + issuer: AGENT, + designated: G_AGENT, + child: G_CHILD, + }; + let other = child_request(4); + let error = store + .issue_grant(&GrantIssue::new(context, &other, invocation(0x41))) + .expect_err("conflict"); + assert!( + matches!(error, Error::Conflict { what: "grant", .. }), + "{error:?}" + ); +} + +#[test] +fn revocation_invalidates_the_subtree_and_is_idempotent() { + let fixture = Fixture::new(); + let store = fixture.seeded(); + let revocation = store + .revoke_grant(&RevokeGrant::new(OPERATOR, G_ROOT, invocation(0x50))) + .expect("revoke") + .expect("grant exists"); + assert_eq!(revocation.grant, G_ROOT, "revoked grant"); + let epoch = store + .audit_records(OPERATOR, None, 100) + .expect("audit") + .into_iter() + .map(|event| event.record) + .find(|record| record.invocation == invocation(0x50)) + .expect("audited"); + assert_eq!( + revocation.at_seq, epoch.seq, + "the epoch is the revoking call's sequence" + ); + + let plan = store.plan(&capture(G_AGENT)).expect("plan"); + assert_eq!( + plan.refusal, + Some(Failure::denied(DenyCode::GrantRevoked)), + "a revoked root invalidates the child" + ); + + let before = dump(&store); + let again = store + .revoke_grant(&RevokeGrant::new(OPERATOR, G_ROOT, invocation(0x51))) + .expect("revoke again"); + assert_eq!(again, Some(revocation), "the first record stands"); + assert_eq!(dump(&store), before, "a repeat writes nothing"); + let missing = store + .revoke_grant(&RevokeGrant::new(OPERATOR, G_CHILD, invocation(0x52))) + .expect("revoke missing"); + assert_eq!(missing, None, "a missing grant reads as absent"); +} + +#[test] +fn fork_records_lineage() { + let fixture = Fixture::new(); + let store = fixture.seeded(); + let fork = SessionId::from_bytes([0x5c; 16]); + let opened = store + .fork_session(&NewSession::new(fork, AGENT, invocation(0x60)), S_AGENT) + .expect("fork") + .expect("parent exists"); + assert_eq!(opened.parent_session, Some(S_AGENT), "lineage"); + assert_eq!(opened.owner, AGENT, "owner"); + assert_eq!( + store.snapshot().session_owner(fork).expect("read"), + Some(AGENT), + "the fork is stored" + ); + let missing = store + .fork_session( + &NewSession::new(SessionId::from_bytes([0x5d; 16]), AGENT, invocation(0x61)), + SessionId::from_bytes([0x5e; 16]), + ) + .expect("fork of missing"); + assert_eq!(missing, None, "a missing parent reads as absent"); +} + +#[test] +fn session_create_is_idempotent_and_conflicts_on_a_different_owner() { + let fixture = Fixture::new(); + let store = fixture.seeded(); + let before = dump(&store); + let opened = store + .create_session(&NewSession::new(S_AGENT, AGENT, invocation(0x62))) + .expect("repeat"); + assert_eq!(opened.session, S_AGENT, "the stored session"); + assert_eq!(dump(&store), before, "a repeat writes nothing"); + let error = store + .create_session(&NewSession::new(S_AGENT, OTHER, invocation(0x63))) + .expect_err("other owner"); + assert!( + matches!( + error, + Error::Conflict { + what: "session", + .. + } + ), + "{error:?}" + ); + let error = store + .create_session(&NewSession::new( + SessionId::from_bytes([0x5f; 16]), + TenantId::from_bytes([0x99; 16]), + invocation(0x64), + )) + .expect_err("unknown owner"); + assert!(matches!(error, Error::TenantMissing { .. }), "{error:?}"); +} + +#[test] +fn session_ceiling_bounds_reservations() { + let fixture = Fixture::new(); + let store = fixture.seeded(); + let tight = SessionId::from_bytes([0x5a; 16]); + let mut new = NewSession::new(tight, AGENT, invocation(0x65)); + new.ceilings = ceilings(0, 0); + store.create_session(&new).expect("session"); + assert_eq!( + store.snapshot().session_ceilings(tight).expect("read"), + ceilings(0, 0), + "stored ceilings" + ); + let mut request = capture(G_AGENT); + request.session = Some(tight); + let plan = store.plan(&request).expect("plan"); + assert!( + matches!(plan.refusal, Some(Failure::BudgetExceeded { .. })), + "{plan:?}" + ); +} + +#[test] +fn root_grant_needs_a_holder_and_parsable_patterns() { + let fixture = Fixture::new(); + let store = fixture.seeded(); + let validity = ( + Timestamp::from_unix_millis(0), + Timestamp::from_unix_millis(1), + ); + let orphan = RootGrant::new( + GrantId::from_bytes([0xa1; 16]), + TenantId::from_bytes([0x99; 16]), + caps(&[Capability::Read]), + vec!["*".to_owned()], + validity, + 1, + ); + let error = store.install_root_grant(&orphan).expect_err("no holder"); + assert!(matches!(error, Error::TenantMissing { .. }), "{error:?}"); + + let bad = RootGrant::new( + GrantId::from_bytes([0xa2; 16]), + OPERATOR, + caps(&[Capability::Read]), + vec!["user@example.com".to_owned()], + validity, + 1, + ); + let error = store.install_root_grant(&bad).expect_err("bad pattern"); + assert!(matches!(error, Error::Authz { .. }), "{error:?}"); + + let conflicting = RootGrant::new( + G_ROOT, + OPERATOR, + caps(&[Capability::Read]), + vec!["*".to_owned()], + validity, + 1, + ); + let error = store + .install_root_grant(&conflicting) + .expect_err("taken id"); + assert!( + matches!(error, Error::Conflict { what: "grant", .. }), + "{error:?}" + ); +} diff --git a/crates/phylake/src/store/tests/disk.rs b/crates/phylake/src/store/tests/disk.rs new file mode 100644 index 0000000..b5549d7 --- /dev/null +++ b/crates/phylake/src/store/tests/disk.rs @@ -0,0 +1,234 @@ +//! What reaches the disk: a raw scan of every file for plaintext, and the +//! keyed record keys' tenant separation. + +use sha2::{Digest as _, Sha256}; +use syntheke::{Capability, IdempotencyKey}; + +use crate::Error; +use crate::store::codec::StoredRecord as _; +use crate::store::record_key; +use crate::store::records::InvocationRecord; +use crate::store::test_support::{ + ACTUAL, AGENT, ENVELOPE, Fixture, G_AGENT, OPERATOR, OTHER, S_AGENT, TARGET, artifact, capture, + invocation, occurrences, read_tree, source, +}; +use crate::store::{Begin, Intent, SettleOutcome, Transfer, slot}; + +/// The idempotency key the scanned capture uses. +const IDEM_MARKER: &[u8] = b"IDEMPOTENCY-MARKER-7f3a-0001"; + +/// The text view the scanned capture stores. +const TEXT_MARKER: &str = "TEXT-VIEW-MARKER-7f3a derived from the envelope"; + +#[test] +fn raw_disk_holds_no_plaintext_ids_or_digests() { + let fixture = Fixture::new(); + let store = fixture.seeded(); + let key = IdempotencyKey::from_slice(IDEM_MARKER).expect("key"); + let begin = store + .begin(&Intent::new( + invocation(1), + capture(G_AGENT), + &key, + [0xd1; 32], + )) + .expect("B1"); + assert!(matches!(begin, Begin::Persisted(_)), "{begin:?}"); + store.dispatch(invocation(1)).expect("B2"); + let mut transfer = Transfer::new(ENVELOPE, source(), ACTUAL); + transfer.text_view = Some(TEXT_MARKER.to_owned()); + store + .complete_transfer(invocation(1), &transfer) + .expect("B3"); + store.publish(invocation(1)).expect("B4"); + store + .settle(invocation(1), SettleOutcome::Success) + .expect("B5"); + drop(store); + + let digest: [u8; 32] = Sha256::digest(ENVELOPE).into(); + let tenant_texts: Vec = [OPERATOR, AGENT, OTHER] + .iter() + .map(ToString::to_string) + .collect(); + let mut needles: Vec<(String, Vec)> = vec![ + ( + "envelope marker".to_owned(), + b"PHYLAKE-CUSTODY-PLAINTEXT-7f3a".to_vec(), + ), + ("target url".to_owned(), TARGET.as_bytes().to_vec()), + ("target host".to_owned(), b"example.com".to_vec()), + ("text view".to_owned(), TEXT_MARKER.as_bytes().to_vec()), + ("idempotency key".to_owned(), IDEM_MARKER.to_vec()), + ("envelope sha-256".to_owned(), digest.to_vec()), + ("session id".to_owned(), S_AGENT.to_bytes().to_vec()), + ("schema id".to_owned(), b"zetesis.evidence.v1".to_vec()), + ]; + for tenant in [OPERATOR, AGENT, OTHER] { + needles.push((format!("tenant bytes {tenant}"), tenant.to_bytes().to_vec())); + } + for text in tenant_texts { + needles.push((format!("tenant text {text}"), text.into_bytes())); + } + + let files = read_tree(&fixture.path); + let total: usize = files.iter().map(|(_, bytes)| bytes.len()).sum(); + assert!(total > 0, "the store wrote files"); + for (path, bytes) in &files { + for (name, needle) in &needles { + assert_eq!( + occurrences(bytes, needle), + 0, + "{name} found in {}", + path.display() + ); + } + } + // WHY a positive control: the plaintext format tag lives in `meta`, so + // the scan does read the bytes the store wrote. + let format_hits: usize = files + .iter() + .map(|(_, bytes)| occurrences(bytes, b"dioptron-custody/fjall3")) + .sum(); + assert!( + format_hits > 0, + "the scan sees plaintext that is meant to be there" + ); + assert_eq!( + occurrences(ENVELOPE, TARGET.as_bytes()), + 1, + "the envelope held the url" + ); +} + +#[test] +fn identical_inputs_key_differently_per_tenant() { + let fixture = Fixture::new(); + let store = fixture.seeded(); + let snapshot = store.db.read_tx(); + let agent = store.tenant_keys(&snapshot, AGENT).expect("agent keys"); + let other = store.tenant_keys(&snapshot, OTHER).expect("other keys"); + let key = IdempotencyKey::from_slice(IDEM_MARKER).expect("key"); + + let idem_a = + record_key::tenant::idem(agent.index(), AGENT, Capability::Capture, &key).expect("idem a"); + let idem_b = + record_key::tenant::idem(other.index(), OTHER, Capability::Capture, &key).expect("idem b"); + assert_ne!(idem_a, idem_b, "idempotency keys"); + + let address_a = agent.blob_address(ENVELOPE).expect("address a"); + let address_b = other.blob_address(ENVELOPE).expect("address b"); + assert_ne!(address_a, address_b, "blob addresses"); + let blob_a = record_key::tenant::blob(agent.index(), AGENT, &address_a).expect("blob a"); + let blob_b = record_key::tenant::blob(other.index(), OTHER, &address_b).expect("blob b"); + assert_ne!(blob_a, blob_b, "blob keys"); + + let audit_a = record_key::tenant::audit(agent.index(), AGENT).expect("audit a"); + let audit_b = record_key::tenant::audit(other.index(), OTHER).expect("audit b"); + assert_ne!(audit_a, audit_b, "audit prefixes"); + + let session_a = + record_key::tenant::session_index(agent.index(), AGENT, S_AGENT).expect("index a"); + let session_b = + record_key::tenant::session_index(other.index(), OTHER, S_AGENT).expect("index b"); + assert_ne!(session_a, session_b, "session index prefixes"); + + let artifact_a = + record_key::tenant::artifact(agent.index(), AGENT, artifact(1)).expect("artifact a"); + let artifact_b = + record_key::tenant::artifact(other.index(), OTHER, artifact(1)).expect("artifact b"); + assert_ne!(artifact_a, artifact_b, "artifact side record keys"); + + // WHY: the tenant id is an input, not only the tenant's key, so the + // same index key with another tenant id also keys differently. + let crossed = + record_key::tenant::idem(agent.index(), OTHER, Capability::Capture, &key).expect("crossed"); + assert_ne!(idem_a, crossed, "the tenant id is hashed in"); + let capability = record_key::tenant::idem(agent.index(), AGENT, Capability::Ingest, &key) + .expect("capability"); + assert_ne!(idem_a, capability, "the capability is hashed in"); +} + +#[test] +fn invalid_archive_is_a_decode_error() { + let error = InvocationRecord::decode(b"not an archive", "invocations").expect_err("garbage"); + assert!( + matches!( + error, + Error::Decode { + keyspace: "invocations", + .. + } + ), + "{error:?}" + ); +} + +#[test] +fn publish_without_its_pending_record_is_inconsistent() { + let fixture = Fixture::new(); + let store = fixture.seeded(); + let key = IdempotencyKey::from_slice(IDEM_MARKER).expect("key"); + store + .begin(&Intent::new( + invocation(1), + capture(G_AGENT), + &key, + [0xd1; 32], + )) + .expect("B1"); + store.dispatch(invocation(1)).expect("B2"); + let transfer = Transfer::new(ENVELOPE, source(), ACTUAL); + store + .complete_transfer(invocation(1), &transfer) + .expect("B3"); + + let pending_key = { + let snapshot = store.db.read_tx(); + let keys = store.tenant_keys(&snapshot, AGENT).expect("keys"); + record_key::tenant::pending(keys.index(), AGENT, invocation(1)).expect("pending key") + }; + let mut tx = store.write_tx(); + tx.remove( + store + .ks + .get(slot::PENDING_ARTIFACT.keyspace) + .expect("keyspace"), + pending_key, + ); + tx.commit().expect("remove pending"); + + let error = store.publish(invocation(1)).expect_err("pending gone"); + assert!(matches!(error, Error::Inconsistent { .. }), "{error:?}"); + let status = store + .invocation(invocation(1)) + .expect("read") + .expect("recorded"); + assert_eq!( + status.state, + syntheke::InvocationState::TransferComplete, + "a failed publish changes nothing" + ); +} + +#[test] +fn a_record_moved_to_another_key_does_not_open() { + let fixture = Fixture::new(); + let store = fixture.seeded(); + let snapshot = store.db.read_tx(); + let index = store.keys.index(); + let from = record_key::store::grant(index, G_AGENT).expect("key"); + let to = record_key::store::grant(index, crate::store::test_support::G_ROOT).expect("key"); + let grants = store.ks.get(slot::GRANT.keyspace).expect("keyspace"); + let sealed = fjall::Readable::get(&snapshot, grants, from) + .expect("read") + .expect("present"); + drop(snapshot); + let mut tx = store.write_tx(); + tx.insert(grants, to, sealed); + tx.commit().expect("swap"); + let error = store + .plan(&capture(G_AGENT)) + .expect_err("the moved grant fails authentication"); + assert!(matches!(error, Error::Authz { .. }), "{error:?}"); +} diff --git a/crates/phylake/src/store/tests/lifecycle.rs b/crates/phylake/src/store/tests/lifecycle.rs new file mode 100644 index 0000000..e13aa06 --- /dev/null +++ b/crates/phylake/src/store/tests/lifecycle.rs @@ -0,0 +1,718 @@ +//! B1 through B5, idempotency, refusals, dry-run, reads, and concurrency. + +use syntheke::{ + Capability, Cost, DenyCode, Dimension, Failure, InvocationState, OutcomeKind, ReleaseReason, + TransferClass, +}; + +use crate::Error; +use crate::store::test_support::{ + ACTUAL, AGENT, DECLARED, ENVELOPE, Fixture, G_AGENT, OPERATOR, S_AGENT, artifact, capture, + ceilings, count, dump, idem, invocation, issue_agent_grant, ledger_usage, source, +}; +use crate::store::{Begin, Intent, InvocationStatus, SettleOutcome, Store, Terminal, Transfer}; + +/// A digest standing in for the request digest. +const DIGEST: [u8; 32] = [0xd1; 32]; + +/// B1 for invocation `byte` under idempotency key `byte`. +fn begin(store: &Store, byte: u8) -> Begin { + let key = idem(byte); + store + .begin(&Intent::new( + invocation(byte), + capture(G_AGENT), + &key, + DIGEST, + )) + .expect("begin") +} + +/// B1, expecting a new invocation. +fn persist(store: &Store, byte: u8) -> InvocationStatus { + match begin(store, byte) { + Begin::Persisted(status) => status, + other => panic!("expected a persisted intent, got {other:?}"), + } +} + +/// The transfer every test capture reports. +fn transfer() -> Transfer<'static> { + let mut transfer = Transfer::new(ENVELOPE, source(), ACTUAL); + transfer.text_view = Some("PHYLAKE-CUSTODY-PLAINTEXT-7f3a".to_owned()); + transfer.output_bytes = 30; + transfer +} + +/// Drives invocation `byte` through B1 to B4. +fn publish(store: &Store, byte: u8) { + persist(store, byte); + store.dispatch(invocation(byte)).expect("B2"); + store + .complete_transfer(invocation(byte), &transfer()) + .expect("B3"); + store.publish(invocation(byte)).expect("B4"); +} + +/// `cost` on every capture ledger. +fn all(cost: Cost) -> Vec { + vec![cost; 4] +} + +#[test] +fn happy_path_runs_b1_through_b5() { + let fixture = Fixture::new(); + let store = fixture.seeded(); + + let status = persist(&store, 1); + assert_eq!(status.state, InvocationState::IntentPersisted, "B1 state"); + assert_eq!( + status.reserved, DECLARED, + "B1 reserves the declared maximum" + ); + assert_eq!( + status.grant_chain, + [G_AGENT, crate::store::test_support::G_ROOT], + "chain" + ); + assert_eq!( + ledger_usage(&store), + all(DECLARED), + "every ledger is debited at B1" + ); + + let status = store.dispatch(invocation(1)).expect("B2"); + assert_eq!(status.state, InvocationState::Dispatched, "B2 state"); + let status = store + .complete_transfer(invocation(1), &transfer()) + .expect("B3"); + assert_eq!(status.state, InvocationState::TransferComplete, "B3 state"); + let status = store.publish(invocation(1)).expect("B4"); + assert_eq!(status.state, InvocationState::Published, "B4 state"); + let status = store + .settle(invocation(1), SettleOutcome::Success) + .expect("B5"); + assert_eq!(status.state, InvocationState::Settled, "B5 state"); + assert_eq!( + status.terminal, + Some(Terminal::Settled { failure: None }), + "a success" + ); + assert_eq!( + status.debited, + Some(ACTUAL), + "the actual consumption is kept" + ); + assert_eq!( + ledger_usage(&store), + all(ACTUAL), + "the remainder is released" + ); + let terminal = store + .audit_records(AGENT, None, 100) + .expect("audit") + .into_iter() + .map(|event| event.record) + .filter(|record| record.invocation == invocation(1)) + .collect::>(); + assert_eq!(terminal.len(), 1, "one audit entry, at the terminal state"); + assert_eq!( + terminal.first().map(|r| r.state), + Some(InvocationState::Settled), + "state" + ); +} + +#[test] +fn published_artifact_reads_whole_and_in_chunks() { + let fixture = Fixture::new(); + let store = fixture.seeded(); + publish(&store, 1); + let info = store + .artifact(artifact(1)) + .expect("read") + .expect("published"); + assert_eq!(info.owner, AGENT, "owner"); + assert_eq!(info.session, Some(S_AGENT), "session"); + assert_eq!(info.source, source(), "source reference"); + assert_eq!( + usize::try_from(info.envelope_len).ok(), + Some(ENVELOPE.len()), + "envelope length" + ); + let whole = store + .read_artifact(artifact(1), 0, 4096) + .expect("read") + .expect("published"); + assert_eq!(whole.bytes, ENVELOPE, "the envelope is stored verbatim"); + let chunk = store + .read_artifact(artifact(1), 12, 10) + .expect("read") + .expect("published"); + assert_eq!(chunk.bytes, b"PHYLAKE-CU", "a chunk is a slice"); + assert_eq!( + usize::try_from(chunk.total_len).ok(), + Some(ENVELOPE.len()), + "chunks report the total" + ); + let tail = store + .read_artifact(artifact(1), 500, 10) + .expect("read") + .expect("published"); + assert!(tail.bytes.is_empty(), "a chunk past the end is empty"); + + let page = store + .session_artifacts(S_AGENT, None, 10) + .expect("query") + .expect("session exists"); + assert_eq!( + page.result_refs, + [artifact(1)], + "the session index lists it" + ); + assert!(!page.more, "one result"); +} + +#[test] +fn transfer_complete_blob_is_not_visible() { + let fixture = Fixture::new(); + let store = fixture.seeded(); + persist(&store, 1); + store.dispatch(invocation(1)).expect("B2"); + store + .complete_transfer(invocation(1), &transfer()) + .expect("B3"); + let blobs = count(&dump(&store), "blobs"); + assert_eq!(blobs, 1, "the blob is written at B3"); + assert_eq!( + store.artifact(artifact(1)).expect("read"), + None, + "no side record read" + ); + assert_eq!( + store.read_artifact(artifact(1), 0, 16).expect("read"), + None, + "no envelope read" + ); + let page = store + .session_artifacts(S_AGENT, None, 10) + .expect("query") + .expect("session exists"); + assert!(page.result_refs.is_empty(), "no session index entry"); +} + +#[test] +fn settling_twice_is_refused_and_settles_once() { + let fixture = Fixture::new(); + let store = fixture.seeded(); + publish(&store, 1); + store + .settle(invocation(1), SettleOutcome::Success) + .expect("first settle"); + let before = dump(&store); + let error = store + .settle(invocation(1), SettleOutcome::Success) + .expect_err("second settle"); + assert!(matches!(error, Error::Authz { .. }), "{error:?}"); + let error = store + .release(invocation(1), ReleaseReason::Cancelled) + .expect_err("release after settle"); + assert!(matches!(error, Error::Authz { .. }), "{error:?}"); + assert_eq!(dump(&store), before, "a refused step writes nothing"); + assert_eq!(ledger_usage(&store), all(ACTUAL), "settled exactly once"); +} + +#[test] +fn publish_before_transfer_is_refused() { + let fixture = Fixture::new(); + let store = fixture.seeded(); + persist(&store, 1); + store.dispatch(invocation(1)).expect("B2"); + let error = store.publish(invocation(1)).expect_err("publish at B2"); + assert!(matches!(error, Error::Authz { .. }), "{error:?}"); + let error = store + .settle(invocation(1), SettleOutcome::Success) + .expect_err("success before publish"); + assert!( + matches!( + error, + Error::SettleMismatch { + state: InvocationState::Dispatched, + .. + } + ), + "{error:?}" + ); + let error = store.dispatch(invocation(1)).expect_err("dispatch twice"); + assert!(matches!(error, Error::Authz { .. }), "{error:?}"); +} + +#[test] +fn failure_after_publish_is_a_mismatch() { + let fixture = Fixture::new(); + let store = fixture.seeded(); + publish(&store, 1); + let failed = SettleOutcome::Failed { + failure: Failure::Cancelled, + actual: ACTUAL, + }; + let error = store + .settle(invocation(1), failed) + .expect_err("failure at B4"); + assert!( + matches!( + error, + Error::SettleMismatch { + state: InvocationState::Published, + .. + } + ), + "{error:?}" + ); +} + +#[test] +fn dispatched_failure_settles_actual_cost() { + let fixture = Fixture::new(); + let store = fixture.seeded(); + persist(&store, 1); + store.dispatch(invocation(1)).expect("B2"); + let failure = Failure::TransferFailed { + class: TransferClass::Reset, + }; + let status = store + .settle( + invocation(1), + SettleOutcome::Failed { + failure, + actual: ACTUAL, + }, + ) + .expect("B5"); + assert_eq!(status.state, InvocationState::Settled, "settled"); + assert_eq!( + status.terminal, + Some(Terminal::Settled { + failure: Some(failure) + }), + "the failure is recorded" + ); + assert_eq!(ledger_usage(&store), all(ACTUAL), "actual cost kept"); +} + +#[test] +fn overrun_is_clamped_to_the_reservation() { + let fixture = Fixture::new(); + let store = fixture.seeded(); + persist(&store, 1); + store.dispatch(invocation(1)).expect("B2"); + let actual = Cost { + fetches: 3, + ..ACTUAL + }; + let status = store + .settle( + invocation(1), + SettleOutcome::Failed { + failure: Failure::Cancelled, + actual, + }, + ) + .expect("B5"); + let kept = Cost { + fetches: DECLARED.fetches, + ..ACTUAL + }; + assert_eq!(status.debited, Some(kept), "never more than reserved"); + assert_eq!( + ledger_usage(&store), + all(kept), + "ledgers hold the clamped debit" + ); +} + +#[test] +fn release_returns_the_whole_reservation() { + let fixture = Fixture::new(); + let store = fixture.seeded(); + persist(&store, 1); + let status = store + .release(invocation(1), ReleaseReason::Revoked) + .expect("release"); + assert_eq!(status.state, InvocationState::Released, "released"); + assert_eq!( + status.terminal, + Some(Terminal::Released { + reason: ReleaseReason::Revoked + }), + "the reason is the record" + ); + assert_eq!( + status.terminal.and_then(Terminal::reply_failure), + Some(Failure::denied(DenyCode::GrantRevoked)), + "a revoked release replies as a revoked grant" + ); + assert_eq!( + ledger_usage(&store), + all(Cost::default()), + "nothing is kept" + ); +} + +#[test] +fn unknown_effect_charges_the_reservation() { + let fixture = Fixture::new(); + let store = fixture.seeded(); + persist(&store, 1); + store.dispatch(invocation(1)).expect("B2"); + let status = store.mark_unknown_effect(invocation(1)).expect("B5"); + assert_eq!(status.state, InvocationState::UnknownEffect, "state"); + assert_eq!(status.debited, Some(DECLARED), "the reservation is charged"); + assert_eq!(ledger_usage(&store), all(DECLARED), "nothing is released"); +} + +#[test] +fn replay_returns_the_existing_invocation() { + let fixture = Fixture::new(); + let store = fixture.seeded(); + persist(&store, 1); + store.dispatch(invocation(1)).expect("B2"); + let before = dump(&store); + let key = idem(1); + let replay = store + .begin(&Intent::new(invocation(2), capture(G_AGENT), &key, DIGEST)) + .expect("replay"); + let Begin::Replayed(status) = replay else { + panic!("expected a replay, got {replay:?}"); + }; + assert_eq!(status.id, invocation(1), "the original invocation"); + assert_eq!( + status.state, + InvocationState::Dispatched, + "its current state" + ); + assert_eq!(dump(&store), before, "a replay writes nothing"); + assert_eq!( + ledger_usage(&store), + all(DECLARED), + "nothing reserved twice" + ); +} + +#[test] +fn same_key_with_a_different_digest_conflicts() { + let fixture = Fixture::new(); + let store = fixture.seeded(); + persist(&store, 1); + let before = dump(&store); + let key = idem(1); + let result = store + .begin(&Intent::new( + invocation(2), + capture(G_AGENT), + &key, + [0xd2; 32], + )) + .expect("begin"); + assert_eq!( + result, + Begin::Conflict, + "the key is bound to another request" + ); + assert_eq!(dump(&store), before, "a conflict writes nothing"); +} + +#[test] +fn reused_invocation_id_is_a_conflict() { + let fixture = Fixture::new(); + let store = fixture.seeded(); + persist(&store, 1); + let key = idem(2); + let error = store + .begin(&Intent::new(invocation(1), capture(G_AGENT), &key, DIGEST)) + .expect_err("id taken"); + assert!( + matches!( + error, + Error::Conflict { + what: "invocation", + .. + } + ), + "{error:?}" + ); +} + +#[test] +fn refused_call_writes_only_its_audit_entry() { + let fixture = Fixture::new(); + let store = fixture.seeded(); + let before = dump(&store); + let key = idem(3); + let mut request = capture(G_AGENT); + request.capability = Capability::Ingest; + let result = store + .begin(&Intent::new(invocation(3), request, &key, DIGEST)) + .expect("begin"); + let Begin::Refused { failure, audit_seq } = result else { + panic!("expected a refusal, got {result:?}"); + }; + assert_eq!( + failure, + Failure::denied(DenyCode::CapabilityNotGranted), + "the agent grant does not confer Ingest" + ); + let after = dump(&store); + for (name, entries) in &after { + let grew = entries.len() != count(&before, name); + let audit = *name == "audit" || *name == "audit_stub"; + assert_eq!( + grew, audit, + "only the audit keyspaces change, {name} did not" + ); + } + let records = store.audit_records(AGENT, None, 100).expect("audit"); + let denied = records + .iter() + .map(|event| &event.record) + .find(|record| record.invocation == invocation(3)) + .expect("the refusal is audited"); + assert_eq!(denied.seq, audit_seq, "sequence"); + assert_eq!(denied.state, InvocationState::Denied, "state"); + assert_eq!(denied.outcome, OutcomeKind::Denied, "outcome"); + assert_eq!( + store.invocation(invocation(3)).expect("read"), + None, + "no invocation record" + ); +} + +#[test] +fn budget_exceeded_on_an_own_ledger_names_the_dimension() { + let fixture = Fixture::new(); + let store = fixture.seeded(); + let key = idem(4); + let mut request = capture(G_AGENT); + request.declared.fetches = 11; + let result = store + .begin(&Intent::new(invocation(4), request, &key, DIGEST)) + .expect("begin"); + assert!( + matches!( + result, + Begin::Refused { + failure: Failure::BudgetExceeded { + dimension: Dimension::Fetches + }, + .. + } + ), + "{result:?}" + ); +} + +#[test] +fn dry_run_writes_nothing() { + let fixture = Fixture::new(); + let store = fixture.seeded(); + let before = dump(&store); + let allowed = store.plan(&capture(G_AGENT)).expect("plan"); + assert_eq!(allowed.refusal, None, "the plan would be allowed"); + assert_eq!(allowed.cost, DECLARED, "the plan carries the declared cost"); + let mut refused = capture(G_AGENT); + refused.capability = Capability::Ingest; + let refused = store.plan(&refused).expect("plan"); + assert!(refused.refusal.is_some(), "the plan reports the refusal"); + assert_eq!( + dump(&store), + before, + "two dry-runs leave the store byte for byte unchanged" + ); +} + +#[test] +fn unknown_invocation_is_missing() { + let fixture = Fixture::new(); + let store = fixture.seeded(); + let error = store.dispatch(invocation(9)).expect_err("missing"); + assert!( + matches!(error, Error::InvocationMissing { .. }), + "{error:?}" + ); + assert_eq!( + store.invocation(invocation(9)).expect("read"), + None, + "reads as absent" + ); +} + +#[test] +fn unknown_tenant_cannot_begin() { + let fixture = Fixture::new(); + let store = fixture.seeded(); + let key = idem(5); + let mut request = capture(G_AGENT); + request.tenant = syntheke::TenantId::from_bytes([0x99; 16]); + let error = store + .begin(&Intent::new(invocation(5), request, &key, DIGEST)) + .expect_err("unknown tenant"); + assert!(matches!(error, Error::TenantMissing { .. }), "{error:?}"); +} + +#[test] +fn reads_of_missing_records_are_absent() { + let fixture = Fixture::new(); + let store = fixture.seeded(); + assert_eq!( + store.artifact(artifact(0x55)).expect("read"), + None, + "artifact" + ); + assert_eq!( + store.read_artifact(artifact(0x55), 0, 1).expect("read"), + None, + "chunk" + ); + let missing = syntheke::SessionId::from_bytes([0x5e; 16]); + assert_eq!( + store.session_artifacts(missing, None, 1).expect("read"), + None, + "session" + ); +} + +#[test] +fn session_query_pages_in_artifact_order() { + let fixture = Fixture::new(); + let store = fixture.seeded(); + for byte in [3, 1, 2] { + persist(&store, byte); + store.dispatch(invocation(byte)).expect("B2"); + store + .complete_transfer(invocation(byte), &transfer()) + .expect("B3"); + store.publish(invocation(byte)).expect("B4"); + } + let first = store + .session_artifacts(S_AGENT, None, 2) + .expect("query") + .expect("session"); + assert_eq!(first.result_refs, [artifact(1), artifact(2)], "first page"); + assert!(first.more, "a third result remains"); + let second = store + .session_artifacts(S_AGENT, Some(artifact(2)), 2) + .expect("query") + .expect("session"); + assert_eq!(second.result_refs, [artifact(3)], "second page"); + assert!(!second.more, "no more"); + assert_eq!( + count(&dump(&store), "blobs"), + 1, + "identical envelopes share a blob" + ); +} + +#[test] +fn concurrent_reservations_cannot_overspend() { + // WHY eight racers on a ceiling of three, released together by a + // barrier: the reservation reads and debits every ledger inside one + // write transaction, so exactly three fit however the threads + // interleave. A read outside the writing transaction would let + // racers that read the same remaining budget all reserve. + const RACERS: u8 = 8; + const CEILING: u64 = 3; + let fixture = Fixture::new(); + let store = fixture.seeded(); + let tight = syntheke::GrantId::from_bytes([0xb1; 16]); + issue_agent_grant(&store, tight, ceilings(CEILING, 524_288)); + let barrier = std::sync::Barrier::new(usize::from(RACERS)); + let results: Vec = std::thread::scope(|scope| { + let handles: Vec<_> = (0..RACERS) + .map(|index| { + let (store, barrier) = (&store, &barrier); + scope.spawn(move || { + let byte = 0x10_u8.saturating_add(index); + let key = idem(byte); + let intent = Intent::new(invocation(byte), capture(tight), &key, DIGEST); + barrier.wait(); + store.begin(&intent).expect("begin") + }) + }) + .collect(); + handles + .into_iter() + .map(|handle| handle.join().expect("thread")) + .collect() + }); + let persisted = results + .iter() + .filter(|result| matches!(result, Begin::Persisted(_))) + .count(); + let refused = results + .iter() + .filter(|result| { + matches!( + result, + Begin::Refused { + failure: Failure::BudgetExceeded { + dimension: Dimension::Fetches + }, + .. + } + ) + }) + .count(); + assert_eq!( + (persisted, refused), + (3, 5), + "three fit, five are refused: {results:?}" + ); + let snapshot = store.snapshot(); + for ledger in [ + epitrope::LedgerId::Grant(tight), + epitrope::LedgerId::Grant(crate::store::test_support::G_ROOT), + epitrope::LedgerId::Session(S_AGENT), + epitrope::LedgerId::Tenant(AGENT), + ] { + let used = epitrope::LedgerView::used(&snapshot, ledger).expect("ledger"); + assert_eq!( + used.fetches, CEILING, + "{ledger:?} holds exactly the three reservations" + ); + } +} + +#[test] +fn operator_capture_in_an_agent_session_indexes_under_the_owner() { + let fixture = Fixture::new(); + let store = fixture.seeded(); + let key = idem(6); + let request = epitrope::AuthzRequest { + tenant: OPERATOR, + grant: crate::store::test_support::G_ROOT, + ..capture(G_AGENT) + }; + let result = store + .begin(&Intent::new(invocation(6), request, &key, DIGEST)) + .expect("begin"); + assert!(matches!(result, Begin::Persisted(_)), "{result:?}"); + store.dispatch(invocation(6)).expect("B2"); + store + .complete_transfer(invocation(6), &transfer()) + .expect("B3"); + store.publish(invocation(6)).expect("B4"); + let page = store + .session_artifacts(S_AGENT, None, 10) + .expect("query") + .expect("session"); + assert_eq!( + page.result_refs, + [artifact(6)], + "indexed in the agent's session" + ); + let info = store + .artifact(artifact(6)) + .expect("read") + .expect("published"); + assert_eq!(info.owner, OPERATOR, "owned by the capturing tenant"); +} diff --git a/crates/phylake/src/store/tests/open.rs b/crates/phylake/src/store/tests/open.rs new file mode 100644 index 0000000..3d2177b --- /dev/null +++ b/crates/phylake/src/store/tests/open.rs @@ -0,0 +1,277 @@ +//! Create, reopen, locked start, and schema version refusal. + +use std::sync::Arc; + +use epitrope::Clock; +use fjall::{KeyspaceCreateOptions, PersistMode, SingleWriterTxDatabase}; +use syntheke::TenantClass; + +use crate::Error; +use crate::keyfile::RootKey; +use crate::store::meta::field; +use crate::store::test_support::{AGENT, Fixture, OPERATOR, ROOT_BYTES, TestClock, dump}; +use crate::store::{ + SCHEMA_VERSION, StoreOptions, TenantDirectory as _, TenantEntry, TenantRegistration, +}; + +/// Rewrites one plaintext `meta` field of a closed store. +fn set_meta(fixture: &Fixture, name: &str, value: Vec) { + let db = SingleWriterTxDatabase::builder(&fixture.path) + .open() + .expect("open raw database"); + let meta = db + .keyspace("meta", KeyspaceCreateOptions::default) + .expect("meta keyspace"); + meta.insert(name, value).expect("write field"); + db.persist(PersistMode::SyncAll).expect("persist"); +} + +#[test] +fn create_reopen_and_read_round_trip() { + let fixture = Fixture::new(); + let store = fixture.create(); + let mut registration = TenantRegistration::new(OPERATOR, TenantClass::Operator, [0x42; 32]); + registration.bound_uids = vec![1000, 1001]; + store.register_tenant(®istration).expect("register"); + drop(store); + + let store = fixture.reopen(); + let entry = store.tenant(OPERATOR).expect("read").expect("registered"); + assert_eq!( + entry, + TenantEntry { + id: OPERATOR, + class: TenantClass::Operator, + verifying_key: [0x42; 32], + bound_uids: vec![1000, 1001], + parent: None, + }, + "the tenant survives a reopen" + ); + assert_eq!( + store.tenant(AGENT).expect("read"), + None, + "an unknown tenant reads as absent" + ); +} + +#[test] +fn register_tenant_is_idempotent_and_refuses_a_different_identity() { + let fixture = Fixture::new(); + let store = fixture.create(); + let registration = TenantRegistration::new(OPERATOR, TenantClass::Operator, [0x42; 32]); + store.register_tenant(®istration).expect("first"); + let before = dump(&store); + store.register_tenant(®istration).expect("repeat"); + assert_eq!(dump(&store), before, "a repeat writes nothing"); + + let other = TenantRegistration::new(OPERATOR, TenantClass::Agent, [0x42; 32]); + let error = store.register_tenant(&other).expect_err("conflict"); + assert!( + matches!(error, Error::Conflict { what: "tenant", .. }), + "{error:?}" + ); + + let mut orphan = TenantRegistration::new(AGENT, TenantClass::Agent, [0x43; 32]); + orphan.parent = Some(crate::store::test_support::OTHER); + let error = store.register_tenant(&orphan).expect_err("missing parent"); + assert!(matches!(error, Error::TenantMissing { .. }), "{error:?}"); +} + +#[test] +fn open_with_wrong_key_is_locked_and_writes_nothing() { + let fixture = Fixture::new(); + let store = fixture.seeded(); + let before = dump(&store); + drop(store); + + let error = fixture + .options() + .open(&RootKey::from_bytes([0x5b; 32])) + .expect_err("wrong key"); + assert!(matches!(error, Error::StoreLocked { .. }), "{error:?}"); + + let store = fixture.reopen(); + assert_eq!( + dump(&store), + before, + "a locked open leaves every keyspace as it was" + ); +} + +#[test] +fn open_refuses_a_newer_schema_version() { + let fixture = Fixture::new(); + drop(fixture.create()); + let newer = SCHEMA_VERSION.get().checked_add(1).expect("next version"); + set_meta( + &fixture, + field::SCHEMA_VERSION, + newer.to_le_bytes().to_vec(), + ); + + let error = fixture + .options() + .open(&RootKey::from_bytes(ROOT_BYTES)) + .expect_err("newer schema"); + assert!( + matches!(error, Error::SchemaTooNew { found, supported, .. } if found == newer && supported == SCHEMA_VERSION.get()), + "{error:?}" + ); +} + +#[test] +fn open_requires_migration_for_an_older_schema_version() { + let fixture = Fixture::new(); + drop(fixture.create()); + set_meta( + &fixture, + field::SCHEMA_VERSION, + 0_u32.to_le_bytes().to_vec(), + ); + + let error = fixture + .options() + .open(&RootKey::from_bytes(ROOT_BYTES)) + .expect_err("older schema"); + assert!( + matches!(error, Error::MigrationRequired { found: 0, .. }), + "{error:?}" + ); +} + +#[test] +fn open_refuses_malformed_metadata() { + let fixture = Fixture::new(); + drop(fixture.create()); + set_meta(&fixture, "format", b"another-format".to_vec()); + let error = fixture + .options() + .open(&RootKey::from_bytes(ROOT_BYTES)) + .expect_err("format"); + assert!( + matches!( + error, + Error::MetaMalformed { + field: "format", + .. + } + ), + "{error:?}" + ); + + let fixture = Fixture::new(); + drop(fixture.create()); + set_meta(&fixture, "kdf_salt", vec![1, 2, 3]); + let error = fixture + .options() + .open(&RootKey::from_bytes(ROOT_BYTES)) + .expect_err("short salt"); + assert!( + matches!( + error, + Error::MetaMalformed { + field: "kdf_salt", + .. + } + ), + "{error:?}" + ); +} + +#[test] +fn open_without_a_store_fails_and_creates_nothing() { + let fixture = Fixture::new(); + let error = fixture + .options() + .open(&RootKey::from_bytes(ROOT_BYTES)) + .expect_err("missing"); + assert!( + matches!( + error, + Error::StoreMissing { + missing: "database", + .. + } + ), + "{error:?}" + ); + assert!(!fixture.path.exists(), "open never creates a store"); +} + +#[test] +fn open_of_a_database_without_keyspaces_is_missing() { + let fixture = Fixture::new(); + drop( + SingleWriterTxDatabase::builder(&fixture.path) + .open() + .expect("raw database"), + ); + let error = fixture + .options() + .open(&RootKey::from_bytes(ROOT_BYTES)) + .expect_err("no keyspaces"); + assert!( + matches!( + error, + Error::StoreMissing { + missing: "meta", + .. + } + ), + "{error:?}" + ); +} + +#[test] +fn create_refuses_a_non_empty_path() { + let fixture = Fixture::new(); + std::fs::create_dir(&fixture.path).expect("dir"); + std::fs::write(fixture.path.join("unrelated"), b"x").expect("file"); + let error = fixture + .options() + .create(&RootKey::from_bytes(ROOT_BYTES)) + .expect_err("non-empty"); + assert!(matches!(error, Error::StoreExists { .. }), "{error:?}"); + + let fixture = Fixture::new(); + std::fs::write(&fixture.path, b"a file").expect("file"); + let error = fixture + .options() + .create(&RootKey::from_bytes(ROOT_BYTES)) + .expect_err("a file"); + assert!(matches!(error, Error::StoreExists { .. }), "{error:?}"); +} + +#[test] +fn create_under_a_missing_parent_is_an_io_error() { + let fixture = Fixture::new(); + let clock: Arc = TestClock::new(); + let error = StoreOptions::new(fixture.path.join("absent/store"), clock) + .create(&RootKey::from_bytes(ROOT_BYTES)) + .expect_err("missing parent"); + assert!(matches!(error, Error::StoreIo { .. }), "{error:?}"); +} + +#[test] +fn second_open_of_a_live_store_is_a_database_error() { + let fixture = Fixture::new(); + let _store = fixture.create(); + let error = fixture + .options() + .open(&RootKey::from_bytes(ROOT_BYTES)) + .expect_err("locked by the first handle"); + assert!(matches!(error, Error::Database { .. }), "{error:?}"); +} + +#[test] +fn new_store_directory_is_owner_only() { + use std::os::unix::fs::PermissionsExt as _; + let fixture = Fixture::new(); + let _store = fixture.create(); + let mode = std::fs::metadata(&fixture.path) + .expect("metadata") + .permissions() + .mode(); + assert_eq!(mode & 0o077, 0, "group and other have no access: {mode:o}"); +} diff --git a/crates/phylake/src/store/tests/terminal.rs b/crates/phylake/src/store/tests/terminal.rs new file mode 100644 index 0000000..826482b --- /dev/null +++ b/crates/phylake/src/store/tests/terminal.rs @@ -0,0 +1,322 @@ +//! Terminal records: every release reason recorded as given, the replies +//! derived from them, the failures a B2 settlement accepts, the +//! idempotency binding, and store-assigned artifact ids. + +use syntheke::{DenyCode, ExtractionClass, Failure, InvocationState, OutcomeKind, ReleaseReason}; + +use crate::Error; +use crate::store::test_support::{ + ACTUAL, AGENT, DECLARED, ENVELOPE, Fixture, G_AGENT, S_AGENT, artifact, capture, ceilings, + dump, idem, invocation, issue_agent_grant, ledger_usage, source, +}; +use crate::store::{ + Begin, Intent, InvocationStatus, RecoveryReport, SettleOutcome, Store, Terminal, Transfer, + artifact_ref, +}; + +/// The caller digest every intent here carries. +const DIGEST: [u8; 32] = [0xd7; 32]; + +/// B1 for invocation `byte` under idempotency key `byte`. +fn begin(store: &Store, byte: u8) -> Begin { + let key = idem(byte); + store + .begin(&Intent::new( + invocation(byte), + capture(G_AGENT), + &key, + DIGEST, + )) + .expect("begin") +} + +/// B1, expecting a new invocation. +fn persist(store: &Store, byte: u8) { + let begin = begin(store, byte); + assert!(matches!(begin, Begin::Persisted(_)), "{begin:?}"); +} + +/// Drives invocation `byte` to B3. +fn transfer(store: &Store, byte: u8) { + persist(store, byte); + store.dispatch(invocation(byte)).expect("B2"); + store + .complete_transfer(invocation(byte), &Transfer::new(ENVELOPE, source(), ACTUAL)) + .expect("B3"); +} + +/// The reply kind the contract gives each release reason, written out +/// independently of [`Terminal::outcome`]. +const fn expected_reply(reason: ReleaseReason) -> OutcomeKind { + match reason { + ReleaseReason::Revoked => OutcomeKind::Denied, + ReleaseReason::ProducerUnavailable => OutcomeKind::ProducerUnavailable, + ReleaseReason::DeadlineExceeded => OutcomeKind::DeadlineExceeded, + _ => OutcomeKind::Cancelled, + } +} + +/// Releases a fresh invocation `byte` for `reason` from B1 or B2 and checks +/// the invocation, its audit entry, its replay, and the ledgers. +fn check_release(reason: ReleaseReason, dispatched: bool, byte: u8) { + let case = format!("{reason} from {}", if dispatched { "B2" } else { "B1" }); + let fixture = Fixture::new(); + let store = fixture.seeded(); + persist(&store, byte); + if dispatched { + store.dispatch(invocation(byte)).expect("B2"); + } + let status = store.release(invocation(byte), reason).expect("release"); + let expected = Terminal::Released { reason }; + assert_eq!(status.state, InvocationState::Released, "{case}: state"); + assert_eq!(status.terminal, Some(expected), "{case}: terminal record"); + assert_eq!(status.release_reason(), Some(reason), "{case}: reason"); + assert_eq!( + ledger_usage(&store), + vec![syntheke::Cost::default(); 4], + "{case}: the whole reservation returns" + ); + + let audits: Vec<_> = store + .audit_records(AGENT, None, 100) + .expect("audit") + .into_iter() + .filter(|event| event.record.invocation == invocation(byte)) + .collect(); + assert_eq!(audits.len(), 1, "{case}: one terminal audit entry"); + let audit = audits.first().expect("entry"); + assert_eq!(audit.release_reason, Some(reason), "{case}: audited reason"); + assert_eq!( + audit.record.state, + InvocationState::Released, + "{case}: audited state" + ); + assert_eq!( + audit.record.outcome, + expected_reply(reason), + "{case}: audited reply kind" + ); + + let Begin::Replayed(replayed) = begin(&store, byte) else { + panic!("{case}: expected a replay"); + }; + assert_eq!( + replayed.terminal, + Some(expected), + "{case}: a replay reads the reason" + ); +} + +#[test] +fn release_from_intent_records_each_reason_exactly() { + for (byte, reason) in [ + (1, ReleaseReason::Abandoned), + (2, ReleaseReason::Revoked), + (3, ReleaseReason::Cancelled), + (4, ReleaseReason::DeadlineExceeded), + ] { + check_release(reason, false, byte); + } +} + +#[test] +fn release_from_dispatch_records_each_reason_exactly() { + for (byte, reason) in [ + (1, ReleaseReason::Revoked), + (2, ReleaseReason::Cancelled), + (3, ReleaseReason::DeadlineExceeded), + (4, ReleaseReason::ProducerUnavailable), + ] { + check_release(reason, true, byte); + } +} + +#[test] +fn abandoned_and_cancelled_releases_stay_distinct() { + let abandoned = Terminal::Released { + reason: ReleaseReason::Abandoned, + }; + let cancelled = Terminal::Released { + reason: ReleaseReason::Cancelled, + }; + assert_ne!(abandoned, cancelled, "the records differ"); + assert_eq!( + abandoned.reply_failure(), + Some(Failure::Cancelled), + "an abandoned call replies as a call ended before any effect" + ); + assert_eq!( + Terminal::Released { + reason: ReleaseReason::Revoked + } + .reply_failure(), + Some(Failure::denied(DenyCode::GrantRevoked)), + "revoked" + ); + assert_eq!( + Terminal::UnknownEffect.reply_failure(), + Some(Failure::UnknownEffect), + "unknown effect" + ); + assert_eq!( + Terminal::Settled { failure: None }.outcome(), + OutcomeKind::Success, + "success" + ); + assert_eq!( + Terminal::UnknownEffect.release_reason(), + None, + "only a release has a reason" + ); +} + +#[test] +fn dispatched_settle_refuses_a_failure_no_started_producer_reports() { + let fixture = Fixture::new(); + let store = fixture.seeded(); + persist(&store, 1); + store.dispatch(invocation(1)).expect("B2"); + let before = dump(&store); + for failure in [ + Failure::UnknownEffect, + Failure::denied(DenyCode::GrantRevoked), + Failure::ProducerUnavailable, + Failure::NotFoundOrDenied, + ] { + let error = store + .settle( + invocation(1), + SettleOutcome::Failed { + failure, + actual: ACTUAL, + }, + ) + .expect_err("not a started failure"); + assert!( + matches!( + error, + Error::SettleMismatch { + state: InvocationState::Dispatched, + .. + } + ), + "{failure:?}: {error:?}" + ); + } + assert_eq!(dump(&store), before, "a refused settlement writes nothing"); + let failure = Failure::ExtractionFailed { + class: ExtractionClass::Malformed, + }; + let status = store + .settle( + invocation(1), + SettleOutcome::Failed { + failure, + actual: ACTUAL, + }, + ) + .expect("an extraction failure settles"); + assert_eq!( + status.terminal, + Some(Terminal::Settled { + failure: Some(failure) + }), + "recorded" + ); +} + +#[test] +fn same_key_under_another_grant_conflicts_whatever_the_digest() { + let fixture = Fixture::new(); + let store = fixture.seeded(); + persist(&store, 1); + let second = syntheke::GrantId::from_bytes([0xb2; 16]); + issue_agent_grant(&store, second, ceilings(10, 524_288)); + let before = dump(&store); + let key = idem(1); + let under_second = store + .begin(&Intent::new(invocation(2), capture(second), &key, DIGEST)) + .expect("begin"); + assert_eq!( + under_second, + Begin::Conflict, + "the same caller digest under another grant" + ); + let mut other_target = capture(G_AGENT); + other_target.target = Some("https://example.com/another-page"); + let retargeted = store + .begin(&Intent::new(invocation(2), other_target, &key, DIGEST)) + .expect("begin"); + assert_eq!(retargeted, Begin::Conflict, "another target"); + let mut bigger = capture(G_AGENT); + bigger.declared.fetches = DECLARED.fetches.saturating_add(1); + let costlier = store + .begin(&Intent::new(invocation(2), bigger, &key, DIGEST)) + .expect("begin"); + assert_eq!(costlier, Begin::Conflict, "another declared cost"); + assert_eq!(dump(&store), before, "a conflict writes nothing"); +} + +#[test] +fn each_capture_publishes_under_its_invocation_id() { + let fixture = Fixture::new(); + let store = fixture.seeded(); + for byte in [1, 2] { + transfer(&store, byte); + let status: InvocationStatus = store.publish(invocation(byte)).expect("B4"); + assert_eq!( + status.artifact, + Some(artifact_ref(invocation(byte))), + "the artifact id is the invocation id" + ); + } + assert_ne!(artifact(1), artifact(2), "distinct ids"); + for byte in [1, 2] { + let info = store + .artifact(artifact(byte)) + .expect("read") + .expect("published"); + assert_eq!(info.artifact, artifact(byte), "readable under its id"); + } +} + +#[test] +fn recovery_publishes_every_pending_capture() { + let fixture = Fixture::new(); + let store = fixture.seeded(); + for byte in [1, 2, 3] { + transfer(&store, byte); + } + drop(store); + let store = fixture.reopen(); + let report = store.recover().expect("recover"); + let expected = RecoveryReport { + published: 3, + ..RecoveryReport::default() + }; + assert_eq!(report, expected, "three roll-forward publishes"); + for byte in [1, 2, 3] { + let status = store + .invocation(invocation(byte)) + .expect("read") + .expect("recorded"); + assert_eq!( + status.terminal, + Some(Terminal::Settled { failure: None }), + "settled" + ); + assert!( + store.artifact(artifact(byte)).expect("read").is_some(), + "visible" + ); + } + let page = store + .session_artifacts(S_AGENT, None, 10) + .expect("query") + .expect("session"); + assert_eq!( + page.result_refs, + [artifact(1), artifact(2), artifact(3)], + "all indexed" + ); +} diff --git a/crates/phylake/src/store/transition.rs b/crates/phylake/src/store/transition.rs new file mode 100644 index 0000000..f3e94aa --- /dev/null +++ b/crates/phylake/src/store/transition.rs @@ -0,0 +1,339 @@ +//! Lifecycle transactions B2 through B5. +//! +//! Each runs in one transaction that reads the invocation's current state +//! and asks epitrope's [`next_state`] whether the step is legal before it +//! writes anything, so a settlement or release commits exactly once: a +//! second attempt finds a terminal state and fails with +//! [`crate::Error::Authz`] wrapping `IllegalTransition`, leaving the +//! ledgers untouched. + +use epitrope::{Settlement, Step, next_state, release, settle, unknown_effect_settlement}; +use fjall::Readable as _; +use snafu::{OptionExt as _, ResultExt as _, ensure}; +use syntheke::{Failure, InvocationId, InvocationState, ReleaseReason}; + +use super::audit::AuditEntry; +use super::invocation::{InvocationStatus, SettleOutcome, Transfer, artifact_ref}; +use super::record_key; +use super::records::{ + ArtifactRecord, InvocationRecord, LocatorRecord, SessionIndexRecord, Terminal, +}; +use super::view::View; +use super::{Boundary, Store, WriteTx, slot}; +use crate::Result; +use crate::crypto::provenance_digest; +use crate::error::{AuthzSnafu, DatabaseSnafu, InconsistentSnafu, SettleMismatchSnafu}; + +impl Store { + /// Runs one state-checked step on `id` in one transaction: loads the + /// record, checks `step` against the transition table, lets `apply` + /// stage the step's writes, stores the record in its new state, and + /// commits at `boundary`. + fn transition( + &self, + id: InvocationId, + step: Step, + boundary: Boundary, + apply: F, + ) -> Result + where + F: FnOnce(&Self, &mut WriteTx<'_>, &mut InvocationRecord) -> Result<()>, + { + let mut tx = self.write_tx(); + let mut record = self.existing_invocation(&tx, id)?; + let next = next_state(record.state, step).context(AuthzSnafu)?; + apply(self, &mut tx, &mut record)?; + record.state = next; + record.updated_at = self.now(); + if next.is_terminal() { + let terminal = record + .terminal + .filter(|terminal| terminal.state() == next) + .context(InconsistentSnafu { + what: "terminal state without a matching terminal record", + })?; + let entry = AuditEntry::terminal(record.tenant, record.id, record.capability, terminal) + .in_session(record.session); + self.append_audit(&mut tx, entry)?; + } + self.put_invocation(&mut tx, &record)?; + self.commit(tx, Some(boundary))?; + Ok(InvocationStatus::from(&record)) + } + + /// B2: records dispatch before the producer is called. + /// + /// # Errors + /// + /// [`crate::Error::InvocationMissing`], [`crate::Error::Authz`] for an + /// illegal step, [`crate::Error::InjectedCrash`], or a storage failure. + pub fn dispatch(&self, id: InvocationId) -> Result { + self.transition(id, Step::Dispatch, Boundary::Dispatch, |_, _, _| Ok(())) + } + + /// B3: writes the envelope as a tenant-sealed blob at its keyed address + /// and a pending side record for the artifact [`super::artifact_ref`] + /// names. Nothing points readers at either until B4, so neither is + /// visible. + /// + /// # Errors + /// + /// As [`Store::dispatch`], plus [`crate::Error::PlaintextTooLarge`] for + /// an oversized envelope. + pub fn complete_transfer( + &self, + id: InvocationId, + transfer: &Transfer<'_>, + ) -> Result { + self.transition( + id, + Step::CompleteTransfer, + Boundary::CompleteTransfer, + |store, tx, record| store.stage_transfer(tx, record, transfer), + ) + } + + fn stage_transfer( + &self, + tx: &mut WriteTx<'_>, + record: &mut InvocationRecord, + transfer: &Transfer<'_>, + ) -> Result<()> { + let artifact = artifact_ref(record.id); + let tenant_keys = self.tenant_keys(&*tx, record.tenant)?; + let address = tenant_keys.blob_address(transfer.envelope)?; + let blob_key = record_key::tenant::blob(tenant_keys.index(), record.tenant, &address)?; + let blobs = self.ks.get(slot::BLOB.keyspace)?; + // WHY skip an existing blob: the address is a keyed hash of the + // plaintext, so an existing blob holds these exact bytes, and a + // published blob is never rewritten. + if !tx.contains_key(blobs, blob_key).context(DatabaseSnafu)? { + let sealed = + self.seal_bytes(tenant_keys.blob(), slot::BLOB, &blob_key, transfer.envelope)?; + tx.insert(blobs, blob_key, sealed); + } + let pending = ArtifactRecord { + artifact, + invocation: record.id, + tenant: record.tenant, + session: record.session, + grant_chain: record.grant_chain.clone(), + reservation: record.id, + classification: None, + lineage: None, + provenance_digest: *provenance_digest(transfer.envelope).as_bytes(), + blob_address: *address.as_bytes(), + blob_len: u64::try_from(transfer.envelope.len()).unwrap_or(u64::MAX), + source: transfer.source.clone(), + text_view: transfer.text_view.clone(), + truncated: transfer.truncated, + output_bytes: transfer.output_bytes, + revoked_after_effect: transfer.revoked_after_effect, + published_at: None, + }; + let pending_key = + record_key::tenant::pending(tenant_keys.index(), record.tenant, record.id)?; + self.put( + tx, + slot::PENDING_ARTIFACT, + &pending_key, + tenant_keys.meta(), + &pending, + )?; + record.artifact = Some(artifact); + record.actual = Some(transfer.actual); + record.revoked_after_effect = transfer.revoked_after_effect; + Ok(()) + } + + /// B4, the atomic publish point: commits the side record, the artifact + /// locator, the session index entry, and the state together, and + /// removes the pending record. + /// + /// # Errors + /// + /// As [`Store::dispatch`], plus [`crate::Error::Inconsistent`] when the + /// pending record is missing or its artifact is already published. + pub fn publish(&self, id: InvocationId) -> Result { + self.transition(id, Step::Publish, Boundary::Publish, Self::stage_publish) + } + + fn stage_publish(&self, tx: &mut WriteTx<'_>, record: &mut InvocationRecord) -> Result<()> { + let tenant_keys = self.tenant_keys(&*tx, record.tenant)?; + let pending_key = + record_key::tenant::pending(tenant_keys.index(), record.tenant, record.id)?; + let mut artifact: ArtifactRecord = self + .get( + &*tx, + slot::PENDING_ARTIFACT, + &pending_key, + &[tenant_keys.meta()], + )? + .context(InconsistentSnafu { + what: "transfer-complete invocation has no pending record", + })?; + let locator_key = record_key::store::locator(self.keys.index(), artifact.artifact)?; + // INVARIANT: the artifact id is the invocation id and B4 runs once + // per invocation, so no locator exists yet. Checked anyway, because + // a published artifact is never rewritten (R4.4). + ensure!( + !tx.contains_key(self.ks.get(slot::LOCATOR.keyspace)?, locator_key) + .context(DatabaseSnafu)?, + InconsistentSnafu { + what: "artifact is already published", + } + ); + let now = self.now(); + artifact.published_at = Some(now); + let side_key = + record_key::tenant::artifact(tenant_keys.index(), record.tenant, artifact.artifact)?; + self.put(tx, slot::ARTIFACT, &side_key, tenant_keys.meta(), &artifact)?; + tx.remove(self.ks.get(slot::PENDING_ARTIFACT.keyspace)?, pending_key); + let locator = LocatorRecord { + artifact: artifact.artifact, + owner: record.tenant, + session: record.session, + }; + self.put_global(tx, slot::LOCATOR, &locator_key, &locator)?; + if let Some(session) = record.session { + let owner = View { + store: self, + reader: &*tx, + } + .session_record(session)? + .context(InconsistentSnafu { + what: "invocation names a missing session", + })? + .owner; + let owner_keys = self.tenant_keys(&*tx, owner)?; + let prefix = record_key::tenant::session_index(owner_keys.index(), owner, session)?; + let key = record_key::join(&prefix, &artifact.artifact.to_bytes()); + let entry = SessionIndexRecord { + artifact: artifact.artifact, + captured_by: record.tenant, + published_at: now, + }; + self.put(tx, slot::SESSION_INDEX, &key, owner_keys.meta(), &entry)?; + } + Ok(()) + } + + /// B5: settles the actual consumption and releases the remainder of + /// the reservation to every ledger. + /// + /// `Success` settles a published capture at the consumption recorded + /// at B3; `Failed` settles a dispatched call whose producer started + /// and failed. Consumption above the reservation is clamped to it. + /// + /// # Errors + /// + /// As [`Store::dispatch`], plus [`crate::Error::SettleMismatch`] when + /// the outcome does not fit the state, including a `Failed` whose + /// failure is not one a started producer reports. + pub fn settle(&self, id: InvocationId, outcome: SettleOutcome) -> Result { + self.transition(id, Step::Settle, Boundary::Terminal, |store, tx, record| { + let (actual, failure) = match (record.state, outcome) { + (InvocationState::Published, SettleOutcome::Success) => ( + record.actual.context(InconsistentSnafu { + what: "published invocation has no recorded consumption", + })?, + None, + ), + (InvocationState::Dispatched, SettleOutcome::Failed { failure, actual }) + if started_failure(failure) => + { + (actual, Some(failure)) + } + (state, _) => return SettleMismatchSnafu { state }.fail(), + }; + let settlement = match settle(&record.reserved, &actual) { + Ok(settlement) | Err(epitrope::Error::SettleOverrun { settlement, .. }) => { + settlement + } + Err(error) => return Err(error).context(AuthzSnafu), + }; + record.actual = Some(actual); + record.terminal = Some(Terminal::Settled { failure }); + store.apply_settlement(tx, record, settlement) + }) + } + + /// B5: releases the whole reservation for `reason`, which the + /// invocation and its audit entry record as given. + /// + /// # Errors + /// + /// As [`Store::dispatch`]. + pub fn release(&self, id: InvocationId, reason: ReleaseReason) -> Result { + self.transition( + id, + Step::Release(reason), + Boundary::Terminal, + |store, tx, record| { + record.terminal = Some(Terminal::Released { reason }); + let settlement = Settlement { + debit: syntheke::Cost::default(), + release: record.reserved, + }; + store.apply_settlement(tx, record, settlement) + }, + ) + } + + /// B5: charges the whole reservation as `UnknownEffect`, because a + /// dispatched call's effect can be neither proven nor ruled out. + /// + /// # Errors + /// + /// As [`Store::dispatch`]. + pub fn mark_unknown_effect(&self, id: InvocationId) -> Result { + self.transition( + id, + Step::MarkUnknownEffect, + Boundary::Terminal, + |store, tx, record| { + record.terminal = Some(Terminal::UnknownEffect); + let settlement = unknown_effect_settlement(&record.reserved); + store.apply_settlement(tx, record, settlement) + }, + ) + } + + /// Returns `settlement.release` to every ledger the reservation + /// debited and records the debit. + fn apply_settlement( + &self, + tx: &mut WriteTx<'_>, + record: &mut InvocationRecord, + settlement: Settlement, + ) -> Result<()> { + for &ledger in &record.ledgers { + let used = View { + store: self, + reader: &*tx, + } + .ledger(ledger)?; + let used = release(&used, &settlement.release).context(AuthzSnafu)?; + self.put_ledger(tx, ledger, used)?; + } + record.debited = Some(settlement.debit); + Ok(()) + } +} + +/// Whether `failure` is one a producer that started can report, and so +/// fits a settlement from B2 (contract § Invocation lifecycle). +/// +/// WHY closed: `UnknownEffect` has its own conservative step, and a +/// denial or protocol failure is decided before dispatch; letting either +/// settle at the actual cost would under-charge a call whose effect is +/// unproven. +const fn started_failure(failure: Failure) -> bool { + matches!( + failure, + Failure::TransferFailed { .. } + | Failure::ExtractionFailed { .. } + | Failure::DeadlineExceeded + | Failure::Cancelled + ) +} diff --git a/crates/phylake/src/store/view.rs b/crates/phylake/src/store/view.rs new file mode 100644 index 0000000..ce45c7c --- /dev/null +++ b/crates/phylake/src/store/view.rs @@ -0,0 +1,217 @@ +//! Read views: epitrope's [`GrantView`] and [`LedgerView`] over a +//! snapshot or an open write transaction, and the tenant directory the +//! wire layer consults at the handshake. + +use epitrope::{Grant, GrantView, LedgerId, LedgerView, Revocation, ViewError}; +use fjall::Readable; +use snafu::OptionExt as _; +use syntheke::{Ceilings, Cost, GrantId, SessionId, TenantClass, TenantId}; + +use super::Store; +use super::record_key::store as keys; +use super::records::{ + GrantRecord, LedgerRecord, LedgerRef, RevocationRecord, SessionRecord, TenantRecord, +}; +use super::slot; +use crate::Result; +use crate::error::InconsistentSnafu; + +/// Store reads through one reader: a snapshot or a write transaction. +pub(crate) struct View<'a, R> { + pub(crate) store: &'a Store, + pub(crate) reader: &'a R, +} + +impl View<'_, R> { + /// The grant record `id`. + pub(crate) fn grant_record(&self, id: GrantId) -> Result> { + let key = keys::grant(self.store.keys.index(), id)?; + self.store.get_global(self.reader, slot::GRANT, &key) + } + + /// The revocation record of grant `id`. + pub(crate) fn revocation_record(&self, id: GrantId) -> Result> { + let key = keys::revocation(self.store.keys.index(), id)?; + self.store.get_global(self.reader, slot::REVOCATION, &key) + } + + /// The session record `id`. + pub(crate) fn session_record(&self, id: SessionId) -> Result> { + let key = keys::session(self.store.keys.index(), id)?; + self.store.get_global(self.reader, slot::SESSION, &key) + } + + /// The tenant record `id`. + pub(crate) fn tenant_record(&self, id: TenantId) -> Result> { + self.store.tenant_record(self.reader, id) + } + + /// The ledger `id`; zero on every dimension when it has no record. + pub(crate) fn ledger(&self, id: LedgerRef) -> Result { + let key = keys::ledger(self.store.keys.index(), id)?; + Ok(self + .store + .get_global::(self.reader, slot::LEDGER, &key)? + .map_or_else(Cost::default, |record| record.used)) + } +} + +impl GrantView for View<'_, R> { + fn grant(&self, id: GrantId) -> Result, ViewError> { + self.grant_record(id) + .and_then(|record| record.map(|record| record.to_grant()).transpose()) + .map_err(ViewError::new) + } + + fn revocation(&self, id: GrantId) -> Result, ViewError> { + self.revocation_record(id) + .map(|record| record.as_ref().map(RevocationRecord::to_revocation)) + .map_err(ViewError::new) + } + + fn session_owner(&self, id: SessionId) -> Result, ViewError> { + self.session_record(id) + .map(|record| record.map(|record| record.owner)) + .map_err(ViewError::new) + } + + fn tenant_parent(&self, id: TenantId) -> Result, ViewError> { + self.tenant_record(id) + .map(|record| record.and_then(|record| record.parent)) + .map_err(ViewError::new) + } +} + +impl LedgerView for View<'_, R> { + fn used(&self, id: LedgerId) -> Result { + LedgerRef::from_ledger_id(id) + .context(InconsistentSnafu { + what: "ledger kind has no stored form", + }) + .and_then(|ledger| self.ledger(ledger)) + .map_err(ViewError::new) + } + + fn session_ceilings(&self, id: SessionId) -> Result { + self.session_record(id) + .map(|record| record.map_or_else(Ceilings::default, |record| record.ceilings)) + .map_err(ViewError::new) + } + + fn tenant_ceilings(&self, id: TenantId) -> Result { + self.tenant_record(id) + .map(|record| record.map_or_else(Ceilings::default, |record| record.ceilings)) + .map_err(ViewError::new) + } +} + +/// What the wire layer needs to admit a tenant. +#[derive(Clone, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub struct TenantEntry { + /// The tenant. + pub id: TenantId, + /// Its class. + pub class: TenantClass, + /// The Ed25519 verifying key its handshake signature must verify under. + pub verifying_key: [u8; 32], + /// The local user ids a connection for this tenant may come from. + pub bound_uids: Vec, + /// Its parent tenant. + pub parent: Option, +} + +impl From for TenantEntry { + fn from(record: TenantRecord) -> Self { + Self { + id: record.id, + class: record.class, + verifying_key: record.verifying_key, + bound_uids: record.bound_uids, + parent: record.parent, + } + } +} + +/// Tenant lookup for the handshake. +pub trait TenantDirectory { + /// The tenant `id`, or `None` when it is not registered. + /// + /// # Errors + /// + /// A storage, decryption, or decoding failure. + fn tenant(&self, id: TenantId) -> Result>; +} + +/// A consistent read-only view of the store: the snapshot the dry-run +/// planner and every read decision consult. It implements epitrope's +/// read traits and has no write method. +pub struct StoreSnapshot<'a> { + store: &'a Store, + snapshot: fjall::Snapshot, +} + +impl core::fmt::Debug for StoreSnapshot<'_> { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("StoreSnapshot").finish_non_exhaustive() + } +} + +impl<'a> StoreSnapshot<'a> { + /// Wraps a fjall snapshot of `store`. + pub(crate) const fn new(store: &'a Store, snapshot: fjall::Snapshot) -> Self { + Self { store, snapshot } + } + + /// The view over this snapshot. + pub(crate) const fn view(&self) -> View<'_, fjall::Snapshot> { + View { + store: self.store, + reader: &self.snapshot, + } + } +} + +impl GrantView for StoreSnapshot<'_> { + fn grant(&self, id: GrantId) -> Result, ViewError> { + self.view().grant(id) + } + + fn revocation(&self, id: GrantId) -> Result, ViewError> { + self.view().revocation(id) + } + + fn session_owner(&self, id: SessionId) -> Result, ViewError> { + self.view().session_owner(id) + } + + fn tenant_parent(&self, id: TenantId) -> Result, ViewError> { + self.view().tenant_parent(id) + } +} + +impl LedgerView for StoreSnapshot<'_> { + fn used(&self, id: LedgerId) -> Result { + self.view().used(id) + } + + fn session_ceilings(&self, id: SessionId) -> Result { + self.view().session_ceilings(id) + } + + fn tenant_ceilings(&self, id: TenantId) -> Result { + self.view().tenant_ceilings(id) + } +} + +impl TenantDirectory for StoreSnapshot<'_> { + fn tenant(&self, id: TenantId) -> Result> { + Ok(self.view().tenant_record(id)?.map(TenantEntry::from)) + } +} + +impl TenantDirectory for Store { + fn tenant(&self, id: TenantId) -> Result> { + self.snapshot().tenant(id) + } +} diff --git a/deny.toml b/deny.toml index ca71d76..ba8f72c 100644 --- a/deny.toml +++ b/deny.toml @@ -18,14 +18,19 @@ ignore = [] # slice adds the licenses its new dependencies need, with the dependency # that needs them. # - MIT, Apache-2.0: rkyv and its support crates, the RustCrypto crates, -# secrecy, zeroize, snafu, getrandom, tempfile, rustix, toml, and their -# build dependencies (all offer MIT or Apache-2.0). +# secrecy, zeroize, snafu, getrandom, tempfile, rustix, toml, tokio, +# tracing, fjall and its storage crates, and their build dependencies +# (all offer MIT or Apache-2.0). # - BSD-3-Clause: ed25519-dalek, curve25519-dalek, and subtle (Ed25519 # tenant authentication: the daemon verifies, xenos signs). # - Unicode-3.0: unicode-ident, via proc-macro2 under snafu-derive. +# - 0BSD: varint-rs, the key-length encoding inside lsm-tree (under fjall). +# - BSL-1.0: xxhash-rust, the checksum inside fjall and lsm-tree. allow = [ + "0BSD", "Apache-2.0", "BSD-3-Clause", + "BSL-1.0", "LicenseRef-PolyForm-Noncommercial-1.0.0", "MIT", "Unicode-3.0", @@ -49,8 +54,24 @@ allow-wildcard-paths = true # its current release to dedupe a build tool; rejected. Retire this entry # when snafu-derive, munge_macro, and curve25519-dalek-derive release on # syn 3. +# +# WHY hashbrown: three copies meet in the custody store and none can be +# aligned from this workspace. rkyv 0.8.18 (the wire format, syntheke) +# requires hashbrown 0.17 through its `alloc` feature; fjall 3.1 depends on +# dashmap 6, which requires hashbrown 0.14; lsm-tree 3.1 (inside fjall) +# requires quick_cache 0.6, whose newest release requires hashbrown 0.16. +# No fjall 3 release uses a newer dashmap or quick_cache, and holding rkyv +# back would pin the wire validator below its current release. The copies +# are ordinary hash-table code with no shared types across the boundary. +# Retirement: the 0.16 entry retires when lsm-tree moves to quick_cache 0.7, +# which already uses hashbrown 0.17. The 0.14 entry retires when fjall's +# concurrent map reaches hashbrown 0.17; leaving dashmap 6 alone is not +# enough, because the dashmap 7 release candidates use hashbrown 0.15, so +# that move turns this entry into a 0.15 entry rather than removing it. skip = [ { crate = "syn@2", reason = "proc-macro build dependency; snafu-derive, munge_macro, and curve25519-dalek-derive have no syn 3 release yet" }, + { crate = "hashbrown@0.14", reason = "dashmap 6 under fjall 3.1; retire when fjall's map dependency is on hashbrown 0.17 (dashmap 7 rc uses 0.15)" }, + { crate = "hashbrown@0.16", reason = "quick_cache 0.6 under lsm-tree 3.1; retire when lsm-tree moves to quick_cache 0.7 (hashbrown 0.17)" }, ] [sources] diff --git a/docs/design/capability-contract.md b/docs/design/capability-contract.md index 5252f52..565da01 100644 --- a/docs/design/capability-contract.md +++ b/docs/design/capability-contract.md @@ -291,10 +291,10 @@ settlement or release runs exactly once even across a retry (D17.16). | `Planned` | memory | Authorized, cost computed. Dry-run ends here. Nothing durable, including no audit. | Nothing persisted; nothing to recover. Caller re-issues. | | `Denied` | durable, terminal | Authorization of an `Execute` request failed; an audit record is the only write. A dry-run that would be denied reports the denial in its plan and writes nothing. | Terminal. No effect, no reservation held. | | B1 `IntentPersisted` | durable | Reservation, invocation intent, and idempotency index committed in one transaction. Producer not yet contacted. | Release the reservation as `Released(Abandoned)`. The producer was never called. | -| B2 `Dispatched` | durable | Dispatch recorded before the producer call, so the call is known to have possibly started. | Settle conservatively at the reserved fetch count as `UnknownEffect`; never re-dispatch. The effect may or may not have happened; the contract refuses to repeat a possibly-live external action. | +| B2 `Dispatched` | durable | Dispatch recorded before the producer call, so the call is known to have possibly started. | Settle conservatively as `UnknownEffect`, charging the whole reservation on every dimension; never re-dispatch. The effect may or may not have happened; the contract refuses to repeat a possibly-live external action. | | B3 `TransferComplete` | durable | The producer returned; the blob is written but not yet visible. | Roll forward: publish, then settle. The bytes exist; completing publish is safe and idempotent. | | B4 `Published` | durable | Atomic publish point: the artifact record, session index, and state committed in one transaction. The capture is now visible. | Roll forward: settle. The effect is durable and visible; only reconciliation remains. | -| B5 `Settled{outcome}` / `Released{reason}` / `UnknownEffect` | durable, terminal | Actual budget settled and remainder released, the whole reservation released with a reason, or the reserved cost charged because the effect cannot be proven. | Terminal. | +| B5 `Settled{outcome}` / `Released{reason}` / `UnknownEffect` | durable, terminal | Actual budget settled and remainder released, the whole reservation released with a reason, or the whole reservation charged because the effect cannot be proven. | Terminal. | `Settled{outcome}` carries the reply kind the caller observed (`Success`, `TransferFailed`, `ExtractionFailed`, and so on). Version 1 `Released` reasons, @@ -323,8 +323,8 @@ half-written capture. `UnknownEffect` is a first-class terminal, not an error swallowed silently. It records that an external action may have taken effect, at most once, and the -runtime cannot prove whether it did, and it charges the reserved cost rather than -under-charging. A caller that needs to retry after `UnknownEffect` must issue a +runtime cannot prove whether it did, and it charges the whole reservation on +every dimension rather than under-charging. A caller that needs to retry after `UnknownEffect` must issue a new idempotency key; the same key replays the `UnknownEffect` outcome and never re-dispatches. @@ -332,14 +332,16 @@ re-dispatches. Every state-changing request carries a caller-supplied idempotency key of 16 to 64 bytes. The store holds an idempotency index keyed by a keyed hash over the -tenant, the capability, and the key, mapping to the invocation id and a digest -of the request. The digest covers the designated grant, so the same key sent -under a different grant is an `IdempotencyConflict`. +tenant, the capability, and the key, mapping to the invocation id and a binding +of the request. The binding covers the caller's request digest and, bound by +the store itself, the designated grant, session, target, and declared cost, so +the same key sent under a different grant is an `IdempotencyConflict` whatever +the caller's digest covers. -- Same key, same request digest: the current or terminal outcome of the +- Same key, same request binding: the current or terminal outcome of the existing invocation is returned. If it is still running, the caller observes `InProgress`. The call is never dispatched twice. -- Same key, different request digest: `IdempotencyConflict`. The key is already +- Same key, different request binding: `IdempotencyConflict`. The key is already bound to a different request and the contract refuses to reuse it. - A replay of an invocation that ended in `UnknownEffect` returns `UnknownEffect`; retrying requires a new key. diff --git a/docs/design/custody-store.md b/docs/design/custody-store.md index eac6192..97d21cf 100644 --- a/docs/design/custody-store.md +++ b/docs/design/custody-store.md @@ -56,9 +56,9 @@ holds sealed records. | `revocations` | revocation records: grant id, effect sequence, effect time | yes | | `sessions` | session records | yes | | `invocations` | invocation intent and lifecycle state | yes | -| `idem` | idempotency index: keyed hash to invocation id and request digest | yes | +| `idem` | idempotency index: keyed hash to invocation id and request binding | yes | | `ledgers` | per-dimension budget ledgers | yes | -| `artifacts` | side records: tenant, grant chain, session, reservation, classification, lineage, provenance digest, blob address | yes | +| `artifacts` | side records: tenant, grant chain, session, reservation, classification, lineage, provenance digest, blob address; pending side records; artifact locators (see below) | yes | | `blobs` | verbatim producer envelopes, addressed by content | yes | | `session_index` | per-session artifact index | yes | | `audit` | per-tenant sequenced audit records | yes | @@ -68,7 +68,71 @@ holds sealed records. Keys that contain a tenant, session, or idempotency component are keyed hashes under an index subkey, so a raw key does not reveal the plaintext component. ULIDs used as identifiers do leak their creation time by construction; this is -documented and accepted, not hidden. +documented and accepted, not hidden. Two keys carry an unhashed suffix after a +keyed prefix so their ranges scan in order: a per-tenant audit entry ends in +its big-endian sequence number, and a session index entry ends in the artifact +id. The global `audit_stub` key is the bare sequence number. + +### Sealing scope + +Two sealing scopes exist, and this table is normative: a record not listed +here is sealed under the root-derived keys only if it carries no acquired +content. + +| Scope | Sealed under | Keyspaces and record kinds | +|---|---|---| +| plaintext | nothing | `meta` | +| wrapped | the root-derived key-encryption subkey | `keys` (tenant data keys) | +| store | the root-derived metadata subkey; record keys hashed under the root-derived index subkey | `tenants`, `grants`, `revocations`, `sessions`, `invocations`, `ledgers`, artifact locators in `artifacts`, `audit_stub` | +| tenant | the tenant's data key: its blob subkey for `blobs`, its audit subkey for `audit`, its metadata subkey for the rest; record keys hashed under the tenant's index subkey and always including the tenant id | `blobs`, published and pending side records in `artifacts`, `session_index`, `idem`, `audit` | + +Directory and lifecycle records are store-sealed because authorization walks +grant chains across tenants and restart recovery scans every invocation, so +these records must open before any tenant is known. They carry identifiers, +amounts, lineage, and timestamps, never acquired content. Tenant data keys seal +acquired content and everything derived from a tenant's requests: envelopes, +text views, source references, provenance digests, blob addresses, idempotency +bindings, and the per-tenant audit trail. A tenant-sealed record is bound to its +tenant only through its record key, because the seal's key id is per tenant, +not global; every tenant-scoped record key therefore hashes the tenant id in. +A session index entry is sealed under the session owner's keys, which may +differ from the capturing tenant's. + +No record holds a capture's target URL: the target is authorized at B1 and +passed to the producer, and only the producer's envelope, which may contain +it, is stored, tenant-sealed. The artifact side record's classification is +always empty in Phase 01; it is filled when knowledge classification (D7) +lands. + +### The `artifacts` keyspace + +The `artifacts` keyspace holds three record kinds, each bound to its kind by +the seal's additional data so one cannot be read as another: + +- a **pending side record** (tenant-sealed), written at B3 under a key hashed + from the tenant and the invocation, and removed at B4; +- a **published side record** (tenant-sealed), written at B4 under a key hashed + from the tenant and the artifact id; +- an **artifact locator** (store-sealed), written at B4 under a key hashed from + the artifact id alone, naming the owning tenant and session so a reader can + find the owner's keys. + +The locator is the only way to reach a published side record, and a pending +record has none, so a pending capture is unreachable by artifact id. The store +assigns the artifact id: it is the invocation's own id. Invocation ids are +unique in the store, so two invocations never claim one artifact and a +roll-forward publish never collides with another. + +### Directory writes + +Tenant registration, root grant installation, grant issue, session create, and +session fork are idempotent by the caller-chosen id. A repeat with identical +content returns the stored result and writes nothing, including no audit +entry; the same id with different content is refused as a conflict. A repeated +grant issue is matched against the stored child before any authorization +decision, because the parent's remaining budget or validity may have moved +since the child was issued; a stored grant is not re-decided. Revoking a +revoked grant returns the first revocation record. ## Atomic publish point @@ -94,8 +158,8 @@ the producer call count. | before B1 commit | No invocation, no reservation. | 0 | | after B1 commit | `Released(Abandoned)`; reservation released. | 0 | | before B2 commit | `Released(Abandoned)`; producer never dispatched. | 0 | -| after B2 commit | `UnknownEffect`, settled at reserved fetches; never re-dispatched. | at most 1, never increased by recovery | -| before B3 commit | B2 is the last committed state, so the B2 rule applies: `UnknownEffect`, settled at reserved fetches; the uncommitted blob write is discarded with its transaction. | unchanged by recovery | +| after B2 commit | `UnknownEffect`; the whole reservation is charged on every dimension; never re-dispatched. | at most 1, never increased by recovery | +| before B3 commit | B2 is the last committed state, so the B2 rule applies: `UnknownEffect`, the whole reservation charged; the uncommitted blob write is discarded with its transaction. | unchanged by recovery | | after B3 commit | Roll forward: publish then settle; artifact becomes visible. | unchanged by recovery | | before B4 commit | Capture not visible; roll forward to publish and settle. | unchanged by recovery | | after B4 commit | Visible; roll forward to settle. | unchanged by recovery | @@ -103,6 +167,10 @@ the producer call count. | after B5 commit | Terminal; no change. | unchanged by recovery | | before or after a rekey batch commit | Rekey resumes from the last committed cursor. | not applicable | +Every recovered terminal is recorded exactly: `Released(Abandoned)` is stored +as that reason in the invocation record and its audit entry, never as a +cancellation, even though an idempotent replay of it replies `Cancelled`. + The invariant across every row: recovery never increases the producer call count. Recovery may release, settle, or publish already-transferred bytes, but it never dispatches a producer call, because a possibly-live external effect is @@ -151,6 +219,22 @@ no address that a foreign tenant could compute to test for another tenant's content. A plaintext content digest is sealed inside the record as provenance; the on-disk address never exposes it. +The address itself is not the physical key. A blob is stored under +HMAC-SHA256(tenant index subkey, tenant id ‖ address), with the same domain +label and length prefixes as every other record key, so the physical key binds +the tenant as every tenant-sealed key does, and the address appears on disk +only inside the sealed side record. Identical envelopes captured by one tenant +share one blob, which is never rewritten once written. + +### Tenant key cache + +The store caches each tenant's unwrapped, derived subkeys in memory after the +first read of its committed tenant record, so a rolled-back registration never +leaves a cached key. The cache holds the keys in zeroizing containers. Tenant +data-key rotation and crypto-shredding must evict the tenant's entry in the +transaction that retires or deletes its data key; until they land, no path +replaces a tenant's data key. + ### Locked start and fail-closed The store opens locked and fails closed. A missing root key, a root key with @@ -168,7 +252,10 @@ version newer than the running binary understands is refused, never opened optimistically. A version older than the binary is upgraded by an explicit `migrate` command that takes a backup path, runs resumable per-step transactions, and writes the new version with the migrated data; an interrupted -migration resumes from its last committed step. Rollback is restoring the backup; +migration resumes from its last committed step. Version 1 is the first schema, +so no older store exists and this build has no `migrate` command: it refuses +an older version with a migration-required error, and `migrate` lands with the +first schema change. Rollback is restoring the backup; the store never downgrades a version in place. The wire version and the schema version are independent: a wire change does not force a schema migration, and a schema migration does not change the wire. @@ -212,11 +299,35 @@ under that tenant's derived subkeys is unrecoverable, because the plaintext data key existed only wrapped. The global `audit_stub` records survive a shred: they carry only an id, a capability, an outcome kind, and a time, no tenant content, so the fact that calls happened remains auditable while the tenant's content is -irrecoverable. Because fjall deletes by tombstone, the old wrapped-key bytes can +irrecoverable. + +After a shred, the holder of the root key can still read every store-sealed +record (see the sealing scope above): identifiers (tenant, grant, session, +invocation, artifact), amounts (reservations, settled debits, ledger totals, +ceilings), lineage (grant chains, parent grants, parent tenants, session forks, +artifact owners and sessions), states, and timestamps, plus the tenant's +verifying key and bound user ids. Nothing acquired stays readable: no envelope, +text view, source reference, provenance digest, blob address, or idempotency +binding. The per-tenant audit trail is gone with the key; only the global stubs +remain. A shred must also remove the tenant from the tenant key cache and +retire its tenant record from the scans that open tenant partitions, such as a +scoped audit read. Because fjall deletes by tombstone, the old wrapped-key bytes can persist in journal or segment files until compaction removes them; a shred is complete only once the store has compacted past the deletion, and the raw-disk inspection test asserts the wrapped key's bytes are absent afterward. +## Audit reads + +A tenant's audit entries are sequenced globally and stored in that tenant's +partition. The store serves the two audit scopes of the contract across +partitions: `All` merges every partition in sequence order, and +`OwnAndOwnedSessions` returns the actor's own entries plus entries any tenant +wrote in a session the actor owns. An optional session narrows either scope, +and reads page by sequence. Which scope an actor's grant allows is decided by +the lifecycle before the store is asked. A released invocation's audit entry +carries its release reason beside the reply kind the contract's audit record +has room for. + ## Retrieval is a projection Full-text and semantic retrieval over stored content are rebuildable projections