feat: gate the store behind a feature and document the schema as a contract
This commit is contained in:
parent
8fb5661b53
commit
aa3950bc80
6 changed files with 122 additions and 16 deletions
28
README.md
28
README.md
|
|
@ -64,7 +64,7 @@ nix build # or: nix develop -c cargo build
|
||||||
nix develop -c cargo test --workspace
|
nix develop -c cargo test --workspace
|
||||||
```
|
```
|
||||||
|
|
||||||
The crate is a workspace: `core/` is the `fumi-core` library — everything but the command line — and `cli/` is the `fumi` binary. GUI hosts depend on `fumi-core` alone; it pulls no argument parsing, no logging globals, and no keyfile conventions. `bundled-sqlite` (default) vendors libsqlite3; embedders that link their own SQLite, as Android's NDK does, build with `default-features = false`.
|
The crate is a workspace: `core/` is the `fumi-core` library — everything but the command line — and `cli/` is the `fumi` binary. GUI hosts depend on `fumi-core` alone; it pulls no argument parsing, no logging globals, and no keyfile conventions. `store` (default) is the SQLite local-state module and the operations that take it; `bundled-sqlite` (default) vendors libsqlite3, so embedders that link their own SQLite, as Android's NDK does, build with `default-features = false` and that flag alone. With both defaults off the crate keeps identities, addresses, seal/open and the raw transport operations (`resolve`, `walk_chain`, `accept_mac`) — for hosts with their own database and thin FFI consumers that need only the crypto and envelope layer.
|
||||||
|
|
||||||
`bunshin` and the reference clients make a complete test rig: register against a local server, exchange mail in both directions, then rotate and fetch the mail that the superseded key still receives.
|
`bunshin` and the reference clients make a complete test rig: register against a local server, exchange mail in both directions, then rotate and fetch the mail that the superseded key still receives.
|
||||||
|
|
||||||
|
|
@ -73,7 +73,8 @@ The crate is a workspace: `core/` is the `fumi-core` library — everything but
|
||||||
`fumi-core` is the protocol core of a GUI client, not just this CLI's engine. The contract an embedder can rely on:
|
`fumi-core` is the protocol core of a GUI client, not just this CLI's engine. The contract an embedder can rely on:
|
||||||
|
|
||||||
- **Secrets stay bytes in the API, files stay in the CLI.** `Account::new(master, index)` takes the master as bytes and `Store::open(path)` takes only mail and state; `identity.key` next to `fumi.db` is a CLI convention. A GUI keeps the master wherever its platform wants it (Android Keystore, OS keychain) and hands it over per operation.
|
- **Secrets stay bytes in the API, files stay in the CLI.** `Account::new(master, index)` takes the master as bytes and `Store::open(path)` takes only mail and state; `identity.key` next to `fumi.db` is a CLI convention. A GUI keeps the master wherever its platform wants it (Android Keystore, OS keychain) and hands it over per operation.
|
||||||
- **One shareable store handle.** `Store` is `Send + Sync`: every operation locks an inner mutex, and the database runs in WAL mode, so a host holds one `Store` behind an executor and reads it while a fetch writes. Each envelope is committed as it is verified — a concurrent reader listing the inbox sees fetch progress without any callback.
|
- **A storeless build.** `default-features = false` drops rusqlite entirely: identities, addresses, seal/open and the raw transport operations remain, for hosts with their own database. `store` and `bundled-sqlite` compose with it as separate features.
|
||||||
|
- **One shareable store handle.** `Store` is `Send + Sync`: every operation locks an inner mutex, and the database runs in WAL mode, so a host holds one `Store` behind an executor and reads it while a fetch writes. Each envelope is committed as it is verified — a concurrent reader listing the inbox sees fetch progress without any callback. `Store::open_in_memory()` gives a scratch store with the schema and version already in place — for tests, foreign importers assembling rows in code, and FFI round-trips that never touch disk.
|
||||||
- **Decisions, not prose.** `error::Error` is an enum: `NotPinned`, `PinMismatch`, `KeyChanged { address, known, offered }`, `NotRegistered`, `AuthFailed`, `RateLimited`, `QuotaExceeded`, `SchemaVersion`, ... — the distinctions a UI routes on. `Display` still produces the message the CLI prints.
|
- **Decisions, not prose.** `error::Error` is an enum: `NotPinned`, `PinMismatch`, `KeyChanged { address, known, offered }`, `NotRegistered`, `AuthFailed`, `RateLimited`, `QuotaExceeded`, `SchemaVersion`, ... — the distinctions a UI routes on. `Display` still produces the message the CLI prints.
|
||||||
- **Long operations can stop.** `fetch_with` takes a `FetchOptions` with a cancellation token checked between pages; the cursor is saved per page, so a cancelled fetch resumes rather than repeats.
|
- **Long operations can stop.** `fetch_with` takes a `FetchOptions` with a cancellation token checked between pages; the cursor is saved per page, so a cancelled fetch resumes rather than repeats.
|
||||||
- **Composable flows.** `restore` remains the one-call convenience, but its pieces are public: `connect` + `resolve`, `account::find_rotation_index`, then the `Store` setters — a wizard can hold the master and the address before it has connectivity, and show each step. Trust outcomes arrive as values: `TrustChange` from `trust_key` and `send`, `Session::unpinned_static` for the sec 4 warning.
|
- **Composable flows.** `restore` remains the one-call convenience, but its pieces are public: `connect` + `resolve`, `account::find_rotation_index`, then the `Store` setters — a wizard can hold the master and the address before it has connectivity, and show each step. Trust outcomes arrive as values: `TrustChange` from `trust_key` and `send`, `Session::unpinned_static` for the sec 4 warning.
|
||||||
|
|
@ -82,6 +83,25 @@ The crate is a workspace: `core/` is the `fumi-core` library — everything but
|
||||||
|
|
||||||
The intended call graph: `Store::open` once, `Account::new(master, rotations)` per session, then `client::*` operations taking `&Store` and `&Account`. The store outlives accounts; nothing in the library caches between calls.
|
The intended call graph: `Store::open` once, `Account::new(master, rotations)` per session, then `client::*` operations taking `&Store` and `&Account`. The store outlives accounts; nothing in the library caches between calls.
|
||||||
|
|
||||||
|
## The store schema
|
||||||
|
|
||||||
|
The schema is a contract, not an implementation detail: `SCHEMA_VERSION` is 1, written to SQLite's `PRAGMA user_version` at creation and checked at open, so a foreign importer — a migration from another client, a sync tool — can build rows directly instead of reading `store.rs`. `Store::open_in_memory()` gives an importer a scratch database with schema and version in place. Within a version the layout below is stable; a version bump comes with a documented migration.
|
||||||
|
|
||||||
|
Every mail column holds a sealed envelope, never plaintext. Timestamps are Unix seconds; keys and ids are 32-byte blobs.
|
||||||
|
|
||||||
|
| Table | Columns | Meaning and invariants |
|
||||||
|
|---|---|---|
|
||||||
|
| `state` | one row, `id = 1`, always present | `username`, `host`, `port`, `scheme` (`tcp` or `rns`) are the account; `rotations` is the sec 2 rotation index the account is at; `after_time` and `after_id` are the sec 6.1 fetch cursor; `sync_ok` (0/1) says whether the local accept-token set may replace the server's — a client restored from the master alone must not erase it (sec 4) |
|
||||||
|
| `servers` | `host`, `static`, `pinned_at` | The sec 4 pins: a server's Noise static key, keyed by host without port |
|
||||||
|
| `contacts` | `address`, `identity`, `verified`, `seen_at` | Every key known for an address; `verified` is 1 only when it came from a self-certifying URI |
|
||||||
|
| `accepted` | `address`, `identity`, `active`, `added_at` | Main-tier admission (sec 5.8). `identity` is frozen at acceptance because the token the correspondent holds is derived from it; `active = 0` is a block and keeps the row |
|
||||||
|
| `tokens` | `address`, `token`, `seen_at` | Accept tokens correspondents issued us, filed under their address, which outlives the keys behind it (sec 5.8) |
|
||||||
|
| `seen` | `id`, `at` | Every message id ever fetched; a replay is not re-stored even after a local delete (sec 10) |
|
||||||
|
| `inbox` | `id`, `envelope`, `received_at`, `tier` | Sealed mail; `tier` is 0 main, 1 requests (arrived without a matching accept token) |
|
||||||
|
| `sent` | `id`, `recipient`, `envelope`, `sent_at` | Sealed self-copies (sec 5.6), with the recipient for the listing |
|
||||||
|
|
||||||
|
An importer should set `user_version` to 1, keep the single `state` row, and respect the frozen `accepted.identity` and `sync_ok` semantics above — those are the two rows where a wrong guess loses data (an erased server-side token set, or a contact's token silently changing).
|
||||||
|
|
||||||
## Modules
|
## Modules
|
||||||
|
|
||||||
| File | Contents |
|
| File | Contents |
|
||||||
|
|
@ -93,8 +113,8 @@ The intended call graph: `Store::open` once, `Account::new(master, rotations)` p
|
||||||
| `core/src/transport.rs` | `Transport` trait, bind values, operation and status constants |
|
| `core/src/transport.rs` | `Transport` trait, bind values, operation and status constants |
|
||||||
| `core/src/tcp.rs` | Noise_NX initiator, `len u32 \|\| op u8 \|\| body` framing, static-key pinning |
|
| `core/src/tcp.rs` | Noise_NX initiator, `len u32 \|\| op u8 \|\| body` framing, static-key pinning |
|
||||||
| `core/src/rns/` | the Reticulum carrier behind the `rns` feature: FFI over `core/shim/`, path discovery, one link per session |
|
| `core/src/rns/` | the Reticulum carrier behind the `rns` feature: FFI over `core/shim/`, path discovery, one link per session |
|
||||||
| `core/src/client.rs` | the six operations, AUTH, chain walking, token sync, fetch pipeline with cancellation |
|
| `core/src/client.rs` | the six operations, AUTH, chain walking, token sync, fetch pipeline with cancellation; store-taking items behind the `store` feature |
|
||||||
| `core/src/store.rs` | SQLite: state, contacts, accepted, tokens, seen ids, inbox, sent; `Send + Sync`, WAL, versioned schema |
|
| `core/src/store.rs` | SQLite behind the `store` feature: state, contacts, accepted, tokens, seen ids, inbox, sent; `Send + Sync`, WAL, versioned schema |
|
||||||
| `core/src/error.rs` | the `Error` enum embedders match on, the status-code mapping |
|
| `core/src/error.rs` | the `Error` enum embedders match on, the status-code mapping |
|
||||||
| `core/vectors.json` | the committed reference vectors, the tests' source of truth |
|
| `core/vectors.json` | the committed reference vectors, the tests' source of truth |
|
||||||
| `cli/src/main.rs` | the command line: keyfile and `--db` conventions, hints, output |
|
| `cli/src/main.rs` | the command line: keyfile and `--db` conventions, hints, output |
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,12 @@ description = "Smol Mail client library: identities, sealing, mailbox operations
|
||||||
license = "Apache-2.0"
|
license = "Apache-2.0"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["bundled-sqlite"]
|
default = ["store", "bundled-sqlite"]
|
||||||
|
# Local state in SQLite: the store module and the client operations that take
|
||||||
|
# one. Off, the crate keeps identities, addresses, seal/open and the raw
|
||||||
|
# transport operations for hosts with their own database and thin FFI
|
||||||
|
# consumers that need only the crypto and envelope layer.
|
||||||
|
store = ["dep:rusqlite"]
|
||||||
# Bundle libsqlite3 so the store needs no system SQLite; embedders with their
|
# Bundle libsqlite3 so the store needs no system SQLite; embedders with their
|
||||||
# own SQLite (Android's NDK case) build with default-features = false.
|
# own SQLite (Android's NDK case) build with default-features = false.
|
||||||
bundled-sqlite = ["rusqlite/bundled"]
|
bundled-sqlite = ["rusqlite/bundled"]
|
||||||
|
|
@ -23,7 +28,7 @@ sha2 = "0.10"
|
||||||
hmac = "0.12"
|
hmac = "0.12"
|
||||||
hkdf = "0.12"
|
hkdf = "0.12"
|
||||||
rand_core = { version = "0.6", features = ["getrandom"] }
|
rand_core = { version = "0.6", features = ["getrandom"] }
|
||||||
rusqlite = "0.32"
|
rusqlite = { version = "0.32", optional = true }
|
||||||
data-encoding = "2"
|
data-encoding = "2"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,48 @@
|
||||||
//! The six operations, AUTH session setup, chain walking, token sync and the
|
//! The six operations, AUTH session setup, chain walking, token sync and the
|
||||||
//! send/fetch pipelines. Everything here is carrier-neutral: it speaks
|
//! send/fetch pipelines. Everything here is carrier-neutral: it speaks
|
||||||
//! through the `Transport` trait and the bodies are byte-identical on both
|
//! through the `Transport` trait and the bodies are byte-identical on both
|
||||||
//! carriers (RNS.md sec 13.5, sec 15).
|
//! carriers (RNS.md sec 13.5, sec 15). The operations that take a `Store`
|
||||||
|
//! need the `store` feature; `resolve`, `walk_chain` and `accept_mac` are
|
||||||
|
//! always built.
|
||||||
|
|
||||||
|
use crate::account::RotationCert;
|
||||||
|
use crate::crypto::{ct_eq, hmac_sha256, KEY_LEN};
|
||||||
|
use crate::error::{expect_ok, Error};
|
||||||
|
use crate::transport::{
|
||||||
|
Reader, Transport, CERT_LEN, ID_LEN, LABEL_MAC, MAX_CHAIN, OP_RESOLVE, TOKEN_LEN,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(feature = "store")]
|
||||||
|
use crate::account::{Account, Identity};
|
||||||
|
#[cfg(feature = "store")]
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
use crate::account::{Account, Identity, RotationCert};
|
#[cfg(feature = "store")]
|
||||||
use crate::address::{Address, Scheme};
|
use crate::address::{Address, Scheme};
|
||||||
use crate::crypto::{b32, ct_eq, hmac_sha256, KEY_LEN};
|
#[cfg(feature = "store")]
|
||||||
use crate::error::{expect_ok, Error};
|
use crate::crypto::b32;
|
||||||
|
#[cfg(feature = "store")]
|
||||||
use crate::message::{
|
use crate::message::{
|
||||||
accept_field, build_frontmatter, message_id, parse_frontmatter, seal, unseal, Opened,
|
accept_field, build_frontmatter, message_id, parse_frontmatter, seal, unseal, Opened,
|
||||||
};
|
};
|
||||||
|
#[cfg(feature = "store")]
|
||||||
use crate::store::{now, Store, Stored};
|
use crate::store::{now, Store, Stored};
|
||||||
|
#[cfg(feature = "store")]
|
||||||
use crate::tcp::TcpTransport;
|
use crate::tcp::TcpTransport;
|
||||||
|
#[cfg(feature = "store")]
|
||||||
use crate::transport::{
|
use crate::transport::{
|
||||||
Reader, Transport, CERT_LEN, FLAG_REQUESTS, ID_LEN, LABEL_AUTH, LABEL_MAC, LABEL_REGISTER,
|
FLAG_REQUESTS, LABEL_AUTH, LABEL_REGISTER, OP_AUTH, OP_DELETE, OP_FETCH, OP_REGISTER, OP_SEND,
|
||||||
MAX_CHAIN, OP_AUTH, OP_DELETE, OP_FETCH, OP_REGISTER, OP_RESOLVE, OP_SEND, TOKEN_LEN,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/// A connection plus what the caller needs to know about how it was made:
|
/// A connection plus what the caller needs to know about how it was made:
|
||||||
/// the server's static key when no pin was checked, for the sec 4 warning.
|
/// the server's static key when no pin was checked, for the sec 4 warning.
|
||||||
|
#[cfg(feature = "store")]
|
||||||
pub struct Session {
|
pub struct Session {
|
||||||
transport: Box<dyn Transport>,
|
transport: Box<dyn Transport>,
|
||||||
pub unpinned_static: Option<[u8; KEY_LEN]>,
|
pub unpinned_static: Option<[u8; KEY_LEN]>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "store")]
|
||||||
impl Session {
|
impl Session {
|
||||||
pub fn transport(&mut self) -> &mut dyn Transport {
|
pub fn transport(&mut self) -> &mut dyn Transport {
|
||||||
self.transport.as_mut()
|
self.transport.as_mut()
|
||||||
|
|
@ -39,6 +56,7 @@ impl Session {
|
||||||
/// Opens a session, enforcing sec 4's rule about unpinned servers. TCP pins
|
/// Opens a session, enforcing sec 4's rule about unpinned servers. TCP pins
|
||||||
/// the server's Noise static key; on RNS the destination hash is the pin
|
/// the server's Noise static key; on RNS the destination hash is the pin
|
||||||
/// (RNS.md sec 13.4).
|
/// (RNS.md sec 13.4).
|
||||||
|
#[cfg(feature = "store")]
|
||||||
pub fn connect(
|
pub fn connect(
|
||||||
store: &Store,
|
store: &Store,
|
||||||
addr: &Address,
|
addr: &Address,
|
||||||
|
|
@ -174,6 +192,7 @@ pub enum TrustChange {
|
||||||
/// Resolves a contact and applies sec 8's trust rules. A key change without
|
/// Resolves a contact and applies sec 8's trust rules. A key change without
|
||||||
/// a valid chain is an error here: the user confirms out of band and
|
/// a valid chain is an error here: the user confirms out of band and
|
||||||
/// imports, rather than clicking through.
|
/// imports, rather than clicking through.
|
||||||
|
#[cfg(feature = "store")]
|
||||||
pub fn trust_key(
|
pub fn trust_key(
|
||||||
store: &Store,
|
store: &Store,
|
||||||
transport: &mut dyn Transport,
|
transport: &mut dyn Transport,
|
||||||
|
|
@ -211,6 +230,7 @@ pub fn trust_key(
|
||||||
/// trailing block carries the mailbox's tokens (sec 5.8): `sync = 0` leaves
|
/// trailing block carries the mailbox's tokens (sec 5.8): `sync = 0` leaves
|
||||||
/// the stored set untouched, `sync = 1` replaces it with exactly what
|
/// the stored set untouched, `sync = 1` replaces it with exactly what
|
||||||
/// follows.
|
/// follows.
|
||||||
|
#[cfg(feature = "store")]
|
||||||
pub fn authenticate(
|
pub fn authenticate(
|
||||||
transport: &mut dyn Transport,
|
transport: &mut dyn Transport,
|
||||||
username: &str,
|
username: &str,
|
||||||
|
|
@ -242,6 +262,7 @@ pub fn authenticate(
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What pushing the accept-token set achieved (sec 5.8).
|
/// What pushing the accept-token set achieved (sec 5.8).
|
||||||
|
#[cfg(feature = "store")]
|
||||||
pub enum Pushed {
|
pub enum Pushed {
|
||||||
/// Not registered yet; the set travels with the first fetch instead.
|
/// Not registered yet; the set travels with the first fetch instead.
|
||||||
NotRegistered,
|
NotRegistered,
|
||||||
|
|
@ -251,6 +272,7 @@ pub enum Pushed {
|
||||||
|
|
||||||
/// An accept or a block only takes effect once the server holds the changed
|
/// An accept or a block only takes effect once the server holds the changed
|
||||||
/// set, so it is pushed now rather than at the next fetch.
|
/// set, so it is pushed now rather than at the next fetch.
|
||||||
|
#[cfg(feature = "store")]
|
||||||
pub fn push_tokens(store: &Store, account: &Account, timeout: u64) -> Result<Pushed, Error> {
|
pub fn push_tokens(store: &Store, account: &Account, timeout: u64) -> Result<Pushed, Error> {
|
||||||
let Some(addr) = store.account()? else {
|
let Some(addr) = store.account()? else {
|
||||||
return Ok(Pushed::NotRegistered);
|
return Ok(Pushed::NotRegistered);
|
||||||
|
|
@ -264,6 +286,7 @@ pub fn push_tokens(store: &Store, account: &Account, timeout: u64) -> Result<Pus
|
||||||
/// REGISTER (sec 6.1): binds this identity to a username, with proof of
|
/// REGISTER (sec 6.1): binds this identity to a username, with proof of
|
||||||
/// possession over the server's static key so the attestation cannot be
|
/// possession over the server's static key so the attestation cannot be
|
||||||
/// replayed to another server.
|
/// replayed to another server.
|
||||||
|
#[cfg(feature = "store")]
|
||||||
pub fn register(
|
pub fn register(
|
||||||
store: &Store,
|
store: &Store,
|
||||||
addr: &Address,
|
addr: &Address,
|
||||||
|
|
@ -300,6 +323,7 @@ pub fn register(
|
||||||
|
|
||||||
/// A completed rotation: the new key and the certificate the server now
|
/// A completed rotation: the new key and the certificate the server now
|
||||||
/// holds, ready to be pushed to contacts as an ordinary message (sec 7).
|
/// holds, ready to be pushed to contacts as an ordinary message (sec 7).
|
||||||
|
#[cfg(feature = "store")]
|
||||||
pub struct Rotated {
|
pub struct Rotated {
|
||||||
pub new_pk: [u8; KEY_LEN],
|
pub new_pk: [u8; KEY_LEN],
|
||||||
pub cert: [u8; CERT_LEN],
|
pub cert: [u8; CERT_LEN],
|
||||||
|
|
@ -308,6 +332,7 @@ pub struct Rotated {
|
||||||
/// Advances the rotation index and pushes the certificate (sec 7). The master
|
/// Advances the rotation index and pushes the certificate (sec 7). The master
|
||||||
/// is untouched; only the index moves, and the superseded key stays
|
/// is untouched; only the index moves, and the superseded key stays
|
||||||
/// derivable from it (sec 2).
|
/// derivable from it (sec 2).
|
||||||
|
#[cfg(feature = "store")]
|
||||||
pub fn rotate(store: &Store, account: &Account, timeout: u64) -> Result<Rotated, Error> {
|
pub fn rotate(store: &Store, account: &Account, timeout: u64) -> Result<Rotated, Error> {
|
||||||
let Some(addr) = store.account()? else {
|
let Some(addr) = store.account()? else {
|
||||||
return Err(Error::NotRegistered);
|
return Err(Error::NotRegistered);
|
||||||
|
|
@ -360,6 +385,7 @@ pub fn rotate(store: &Store, account: &Account, timeout: u64) -> Result<Rotated,
|
||||||
/// The pieces are public for hosts that want intermediate UI states:
|
/// The pieces are public for hosts that want intermediate UI states:
|
||||||
/// `connect` + `resolve`, `find_rotation_index`, then the four `set_*`
|
/// `connect` + `resolve`, `find_rotation_index`, then the four `set_*`
|
||||||
/// calls below.
|
/// calls below.
|
||||||
|
#[cfg(feature = "store")]
|
||||||
pub fn restore(store: &Store, master: &[u8; KEY_LEN], addr: &Address, timeout: u64) -> Result<u32, Error> {
|
pub fn restore(store: &Store, master: &[u8; KEY_LEN], addr: &Address, timeout: u64) -> Result<u32, Error> {
|
||||||
let mut session = connect(store, addr, false, timeout)?;
|
let mut session = connect(store, addr, false, timeout)?;
|
||||||
let (identity, _) = resolve(session.transport(), &addr.user)?;
|
let (identity, _) = resolve(session.transport(), &addr.user)?;
|
||||||
|
|
@ -379,6 +405,7 @@ pub fn restore(store: &Store, master: &[u8; KEY_LEN], addr: &Address, timeout: u
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One message to send, as the CLI assembled it.
|
/// One message to send, as the CLI assembled it.
|
||||||
|
#[cfg(feature = "store")]
|
||||||
pub struct SendDraft<'a> {
|
pub struct SendDraft<'a> {
|
||||||
pub address: &'a Address,
|
pub address: &'a Address,
|
||||||
pub text: String,
|
pub text: String,
|
||||||
|
|
@ -393,6 +420,7 @@ pub struct SendDraft<'a> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What `send` did, for the caller to report.
|
/// What `send` did, for the caller to report.
|
||||||
|
#[cfg(feature = "store")]
|
||||||
pub struct Sent {
|
pub struct Sent {
|
||||||
pub id: [u8; ID_LEN],
|
pub id: [u8; ID_LEN],
|
||||||
pub bytes: usize,
|
pub bytes: usize,
|
||||||
|
|
@ -407,6 +435,7 @@ pub struct Sent {
|
||||||
/// Seals and delivers one message (sec 5, sec 6.1). Recipient selection
|
/// Seals and delivers one message (sec 5, sec 6.1). Recipient selection
|
||||||
/// prefers a key we already trust: a self-certifying address, then a stored
|
/// prefers a key we already trust: a self-certifying address, then a stored
|
||||||
/// contact, and only then RESOLVE with trust on first use.
|
/// contact, and only then RESOLVE with trust on first use.
|
||||||
|
#[cfg(feature = "store")]
|
||||||
pub fn send(
|
pub fn send(
|
||||||
store: &Store,
|
store: &Store,
|
||||||
account: &Account,
|
account: &Account,
|
||||||
|
|
@ -490,12 +519,14 @@ pub fn send(
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One rejected fetch record: the id and why it did not enter the inbox.
|
/// One rejected fetch record: the id and why it did not enter the inbox.
|
||||||
|
#[cfg(feature = "store")]
|
||||||
pub struct Rejected {
|
pub struct Rejected {
|
||||||
pub id: [u8; ID_LEN],
|
pub id: [u8; ID_LEN],
|
||||||
pub reason: String,
|
pub reason: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What `fetch` did, for the caller to report.
|
/// What `fetch` did, for the caller to report.
|
||||||
|
#[cfg(feature = "store")]
|
||||||
pub struct Fetched {
|
pub struct Fetched {
|
||||||
pub total: u32,
|
pub total: u32,
|
||||||
pub stored: u32,
|
pub stored: u32,
|
||||||
|
|
@ -512,6 +543,7 @@ pub struct Fetched {
|
||||||
/// The knobs a host with a UI needs on `fetch_with`. Progress needs no
|
/// The knobs a host with a UI needs on `fetch_with`. Progress needs no
|
||||||
/// callback: each envelope is committed to the `Store` as it is verified, so
|
/// callback: each envelope is committed to the `Store` as it is verified, so
|
||||||
/// a concurrent reader (the store is `Send + Sync`) can list incrementally.
|
/// a concurrent reader (the store is `Send + Sync`) can list incrementally.
|
||||||
|
#[cfg(feature = "store")]
|
||||||
pub struct FetchOptions<'a> {
|
pub struct FetchOptions<'a> {
|
||||||
/// Remember the cursor instead of acknowledging with DELETE.
|
/// Remember the cursor instead of acknowledging with DELETE.
|
||||||
pub keep: bool,
|
pub keep: bool,
|
||||||
|
|
@ -524,6 +556,7 @@ pub struct FetchOptions<'a> {
|
||||||
pub cancel: Option<&'a std::sync::atomic::AtomicBool>,
|
pub cancel: Option<&'a std::sync::atomic::AtomicBool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "store")]
|
||||||
impl FetchOptions<'_> {
|
impl FetchOptions<'_> {
|
||||||
/// The defaults a plain fetch uses.
|
/// The defaults a plain fetch uses.
|
||||||
fn new(keep: bool, reset: bool, timeout: u64) -> Self {
|
fn new(keep: bool, reset: bool, timeout: u64) -> Self {
|
||||||
|
|
@ -541,6 +574,7 @@ impl FetchOptions<'_> {
|
||||||
/// it takes, so it always pages from the start; `keep` instead remembers the
|
/// it takes, so it always pages from the start; `keep` instead remembers the
|
||||||
/// cursor. A message that fails verification is left on the server, so a
|
/// cursor. A message that fails verification is left on the server, so a
|
||||||
/// client-side bug cannot lose mail.
|
/// client-side bug cannot lose mail.
|
||||||
|
#[cfg(feature = "store")]
|
||||||
pub fn fetch(
|
pub fn fetch(
|
||||||
store: &Store,
|
store: &Store,
|
||||||
account: &Account,
|
account: &Account,
|
||||||
|
|
@ -552,6 +586,7 @@ pub fn fetch(
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `fetch` with the full option set, for hosts that need cancellation.
|
/// `fetch` with the full option set, for hosts that need cancellation.
|
||||||
|
#[cfg(feature = "store")]
|
||||||
pub fn fetch_with(
|
pub fn fetch_with(
|
||||||
store: &Store,
|
store: &Store,
|
||||||
account: &Account,
|
account: &Account,
|
||||||
|
|
@ -641,6 +676,7 @@ pub fn fetch_with(
|
||||||
Ok(summary)
|
Ok(summary)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "store")]
|
||||||
fn verify_and_open(
|
fn verify_and_open(
|
||||||
account: &Account,
|
account: &Account,
|
||||||
mid: &[u8; ID_LEN],
|
mid: &[u8; ID_LEN],
|
||||||
|
|
@ -655,6 +691,7 @@ fn verify_and_open(
|
||||||
/// Files the accept token a verified payload carried, under the address that
|
/// Files the accept token a verified payload carried, under the address that
|
||||||
/// signed it (sec 5.8). The signature has already been checked by `unseal`,
|
/// signed it (sec 5.8). The signature has already been checked by `unseal`,
|
||||||
/// so the attribution is the signer's own claim.
|
/// so the attribution is the signer's own claim.
|
||||||
|
#[cfg(feature = "store")]
|
||||||
fn learn_token(store: &Store, opened: &Opened) -> Result<(), Error> {
|
fn learn_token(store: &Store, opened: &Opened) -> Result<(), Error> {
|
||||||
let text = String::from_utf8_lossy(&opened.body).into_owned();
|
let text = String::from_utf8_lossy(&opened.body).into_owned();
|
||||||
let (fields, _) = parse_frontmatter(&text);
|
let (fields, _) = parse_frontmatter(&text);
|
||||||
|
|
@ -669,6 +706,7 @@ fn learn_token(store: &Store, opened: &Opened) -> Result<(), Error> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "store")]
|
||||||
fn delete_ids(transport: &mut dyn Transport, ids: &[[u8; ID_LEN]]) -> Result<u16, Error> {
|
fn delete_ids(transport: &mut dyn Transport, ids: &[[u8; ID_LEN]]) -> Result<u16, Error> {
|
||||||
let mut body = Vec::with_capacity(2 + ids.len() * ID_LEN);
|
let mut body = Vec::with_capacity(2 + ids.len() * ID_LEN);
|
||||||
body.extend_from_slice(&(ids.len() as u16).to_be_bytes());
|
body.extend_from_slice(&(ids.len() as u16).to_be_bytes());
|
||||||
|
|
@ -687,6 +725,7 @@ fn delete_ids(transport: &mut dyn Transport, ids: &[[u8; ID_LEN]]) -> Result<u16
|
||||||
|
|
||||||
/// DELETE (sec 6.1) over an authenticated session: remove ids from the
|
/// DELETE (sec 6.1) over an authenticated session: remove ids from the
|
||||||
/// server explicitly. Unknown ids are not an error.
|
/// server explicitly. Unknown ids are not an error.
|
||||||
|
#[cfg(feature = "store")]
|
||||||
pub fn delete(
|
pub fn delete(
|
||||||
store: &Store,
|
store: &Store,
|
||||||
account: &Account,
|
account: &Account,
|
||||||
|
|
@ -705,6 +744,7 @@ pub fn delete(
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An opened, described message for listing and reading.
|
/// An opened, described message for listing and reading.
|
||||||
|
#[cfg(feature = "store")]
|
||||||
pub struct Described {
|
pub struct Described {
|
||||||
pub sender: [u8; KEY_LEN],
|
pub sender: [u8; KEY_LEN],
|
||||||
pub time: i64,
|
pub time: i64,
|
||||||
|
|
@ -715,6 +755,7 @@ pub struct Described {
|
||||||
pub subject: String,
|
pub subject: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "store")]
|
||||||
impl Described {
|
impl Described {
|
||||||
/// Whether this payload carried its signer's accept token (sec 5.8):
|
/// Whether this payload carried its signer's accept token (sec 5.8):
|
||||||
/// machinery, not content.
|
/// machinery, not content.
|
||||||
|
|
@ -724,6 +765,7 @@ impl Described {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Opens one sealed message and splits its body into frontmatter and text.
|
/// Opens one sealed message and splits its body into frontmatter and text.
|
||||||
|
#[cfg(feature = "store")]
|
||||||
pub fn describe(store: &Store, account: &Account, stored: &Stored) -> Result<Described, Error> {
|
pub fn describe(store: &Store, account: &Account, stored: &Stored) -> Result<Described, Error> {
|
||||||
let opened = unseal(account.keys(), &stored.envelope, now())?;
|
let opened = unseal(account.keys(), &stored.envelope, now())?;
|
||||||
let text = String::from_utf8_lossy(&opened.body).into_owned();
|
let text = String::from_utf8_lossy(&opened.body).into_owned();
|
||||||
|
|
@ -747,7 +789,7 @@ pub fn accept_mac(token: &[u8; TOKEN_LEN], mid: &[u8; ID_LEN]) -> [u8; 32] {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Proof of possession over a server's static key (sec 6.1), for tests.
|
/// Proof of possession over a server's static key (sec 6.1), for tests.
|
||||||
#[cfg(test)]
|
#[cfg(all(test, feature = "store"))]
|
||||||
fn register_signed(server_static: &[u8], username: &str, identity: &[u8]) -> Vec<u8> {
|
fn register_signed(server_static: &[u8], username: &str, identity: &[u8]) -> Vec<u8> {
|
||||||
[LABEL_REGISTER, server_static, username.as_bytes(), identity].concat()
|
[LABEL_REGISTER, server_static, username.as_bytes(), identity].concat()
|
||||||
}
|
}
|
||||||
|
|
@ -755,7 +797,7 @@ fn register_signed(server_static: &[u8], username: &str, identity: &[u8]) -> Vec
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::account::{identity_seed, verify_sig, Identity, RotationCert};
|
use crate::account::{identity_seed, verify_sig, Account, Identity, RotationCert};
|
||||||
use crate::transport::{TransportBindValues, SIG_LEN};
|
use crate::transport::{TransportBindValues, SIG_LEN};
|
||||||
|
|
||||||
fn master() -> [u8; 32] {
|
fn master() -> [u8; 32] {
|
||||||
|
|
@ -850,6 +892,7 @@ mod tests {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "store")]
|
||||||
#[test]
|
#[test]
|
||||||
fn bind_values_feed_the_auth_and_register_signatures() {
|
fn bind_values_feed_the_auth_and_register_signatures() {
|
||||||
let bind = TransportBindValues::tcp(&[2u8; 32], &[7u8; 32]).unwrap();
|
let bind = TransportBindValues::tcp(&[2u8; 32], &[7u8; 32]).unwrap();
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,7 @@ pub enum Error {
|
||||||
port: u16,
|
port: u16,
|
||||||
source: std::io::Error,
|
source: std::io::Error,
|
||||||
},
|
},
|
||||||
|
#[cfg(feature = "store")]
|
||||||
Storage(rusqlite::Error),
|
Storage(rusqlite::Error),
|
||||||
Noise(snow::Error),
|
Noise(snow::Error),
|
||||||
Io(std::io::Error),
|
Io(std::io::Error),
|
||||||
|
|
@ -111,6 +112,7 @@ impl fmt::Display for Error {
|
||||||
Self::Unreachable { host, port, source } => {
|
Self::Unreachable { host, port, source } => {
|
||||||
write!(f, "cannot reach {host}:{port}: {source}")
|
write!(f, "cannot reach {host}:{port}: {source}")
|
||||||
}
|
}
|
||||||
|
#[cfg(feature = "store")]
|
||||||
Self::Storage(e) => write!(f, "storage error: {e}"),
|
Self::Storage(e) => write!(f, "storage error: {e}"),
|
||||||
Self::Noise(e) => write!(f, "noise error: {e}"),
|
Self::Noise(e) => write!(f, "noise error: {e}"),
|
||||||
Self::Io(e) => write!(f, "{e}"),
|
Self::Io(e) => write!(f, "{e}"),
|
||||||
|
|
@ -123,6 +125,7 @@ impl std::error::Error for Error {
|
||||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||||
match self {
|
match self {
|
||||||
Self::Unreachable { source, .. } => Some(source),
|
Self::Unreachable { source, .. } => Some(source),
|
||||||
|
#[cfg(feature = "store")]
|
||||||
Self::Storage(e) => Some(e),
|
Self::Storage(e) => Some(e),
|
||||||
Self::Noise(e) => Some(e),
|
Self::Noise(e) => Some(e),
|
||||||
Self::Io(e) => Some(e),
|
Self::Io(e) => Some(e),
|
||||||
|
|
@ -131,6 +134,7 @@ impl std::error::Error for Error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "store")]
|
||||||
impl From<rusqlite::Error> for Error {
|
impl From<rusqlite::Error> for Error {
|
||||||
fn from(e: rusqlite::Error) -> Self {
|
fn from(e: rusqlite::Error) -> Self {
|
||||||
Error::Storage(e)
|
Error::Storage(e)
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ pub mod error;
|
||||||
pub mod message;
|
pub mod message;
|
||||||
#[cfg(feature = "rns")]
|
#[cfg(feature = "rns")]
|
||||||
pub mod rns;
|
pub mod rns;
|
||||||
|
#[cfg(feature = "store")]
|
||||||
pub mod store;
|
pub mod store;
|
||||||
pub mod tcp;
|
pub mod tcp;
|
||||||
pub mod transport;
|
pub mod transport;
|
||||||
|
|
|
||||||
|
|
@ -84,12 +84,29 @@ pub type ContactRow = (String, [u8; KEY_LEN], bool, Option<i64>);
|
||||||
pub const SCHEMA_VERSION: i32 = 1;
|
pub const SCHEMA_VERSION: i32 = 1;
|
||||||
|
|
||||||
impl Store {
|
impl Store {
|
||||||
|
/// Opens the store at `path`. SQLite's own `:memory:` path works too;
|
||||||
|
/// `open_in_memory` spells that case without the magic string.
|
||||||
pub fn open(path: &Path) -> Result<Store, Error> {
|
pub fn open(path: &Path) -> Result<Store, Error> {
|
||||||
let db = Connection::open(path)?;
|
Store::init(Connection::open(path)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An in-memory store: the same schema, version and contract, gone with
|
||||||
|
/// the handle. For tests, foreign importers assembling rows in code,
|
||||||
|
/// and FFI round-trips that never touch disk.
|
||||||
|
pub fn open_in_memory() -> Result<Store, Error> {
|
||||||
|
Store::init(Connection::open_in_memory()?)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn init(db: Connection) -> Result<Store, Error> {
|
||||||
// WAL keeps concurrent readers cheap while a writer commits, and a
|
// WAL keeps concurrent readers cheap while a writer commits, and a
|
||||||
// short busy timeout absorbs the contention the mutex cannot (a
|
// short busy timeout absorbs the contention the mutex cannot (a
|
||||||
// second connection in the same process, or a CLI run alongside).
|
// second connection in the same process, or a CLI run alongside).
|
||||||
db.pragma_update(None, "journal_mode", "WAL")?;
|
// An in-memory database journals in memory, so WAL applies to
|
||||||
|
// files only.
|
||||||
|
let journal: String = db.query_row("PRAGMA journal_mode", [], |row| row.get(0))?;
|
||||||
|
if journal != "memory" {
|
||||||
|
db.pragma_update(None, "journal_mode", "WAL")?;
|
||||||
|
}
|
||||||
db.busy_timeout(std::time::Duration::from_secs(5))?;
|
db.busy_timeout(std::time::Duration::from_secs(5))?;
|
||||||
let version: i32 = db.query_row("PRAGMA user_version", [], |row| row.get(0))?;
|
let version: i32 = db.query_row("PRAGMA user_version", [], |row| row.get(0))?;
|
||||||
if version == 0 {
|
if version == 0 {
|
||||||
|
|
@ -706,6 +723,22 @@ mod tests {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn in_memory_store_round_trips() {
|
||||||
|
let store = Store::open_in_memory().expect("memory store opens");
|
||||||
|
let addr = Address::parse("alice@example.org").unwrap();
|
||||||
|
store.set_account(&addr).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
store.account().unwrap().unwrap().short(),
|
||||||
|
"alice@example.org"
|
||||||
|
);
|
||||||
|
store.store_inbox(&[1u8; 32], b"envelope", 5, false).unwrap();
|
||||||
|
assert_eq!(store.mail("inbox").unwrap().len(), 1);
|
||||||
|
// The same handle through the path spelling.
|
||||||
|
let path = std::path::Path::new(":memory:");
|
||||||
|
assert!(Store::open(path).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn store_is_shareable_across_threads() {
|
fn store_is_shareable_across_threads() {
|
||||||
fn assert_send_sync<T: Send + Sync>() {}
|
fn assert_send_sync<T: Send + Sync>() {}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue