From b84b9af5404a66650a70dec8b7c0174f79ab8795 Mon Sep 17 00:00:00 2001 From: "K. Isom" Date: Thu, 24 Sep 2026 16:03:06 -0700 Subject: [PATCH] memoryd: the crate, memoryd.toml, and listening on a socket Implemented-By: OpenCode session (model recorded in docs/implementer-log.md) --- Cargo.lock | 205 +++++++++++++++++++++++++++- Cargo.toml | 2 + crates/memoryd/Cargo.toml | 16 +++ crates/memoryd/src/config.rs | 140 +++++++++++++++++++ crates/memoryd/src/lib.rs | 4 + crates/memoryd/src/main.rs | 33 +++++ crates/memoryd/src/socket.rs | 60 ++++++++ crates/memoryd/tests/config.rs | 90 ++++++++++++ crates/memoryd/tests/socket.rs | 44 ++++++ crates/memoryd/tests/support/tmp.rs | 40 ++++++ deny.toml | 2 +- docs/dependencies.md | 1 + docs/implementer-log.md | 1 + docs/runbook.md | 21 +++ 14 files changed, 657 insertions(+), 2 deletions(-) create mode 100644 crates/memoryd/Cargo.toml create mode 100644 crates/memoryd/src/config.rs create mode 100644 crates/memoryd/src/lib.rs create mode 100644 crates/memoryd/src/main.rs create mode 100644 crates/memoryd/src/socket.rs create mode 100644 crates/memoryd/tests/config.rs create mode 100644 crates/memoryd/tests/socket.rs create mode 100644 crates/memoryd/tests/support/tmp.rs diff --git a/Cargo.lock b/Cargo.lock index 6a7324a..4c27e7b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -18,6 +18,12 @@ dependencies = [ "toml", ] +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + [[package]] name = "bxctl" version = "0.1.0" @@ -70,12 +76,30 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "find-msvc-tools" version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef25905e51abafe4dcea6c15fec58c57b601cdbd0ee53d22ea1d3016c587d39b" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "gatewayd" version = "0.1.0" @@ -100,11 +124,32 @@ dependencies = [ "wasi", ] +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash", +] + [[package]] name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashlink" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a596f1b20ed2cc5ecac41a164aaebc7258057060f06c0cf7a2ba3991ee7990fb" +dependencies = [ + "hashbrown 0.17.1", +] [[package]] name = "humantime" @@ -119,7 +164,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.17.1", ] [[package]] @@ -135,12 +180,33 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "js-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + [[package]] name = "libc" version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +[[package]] +name = "libsqlite3-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "loopd" version = "0.1.0" @@ -157,6 +223,17 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "memoryd" +version = "0.1.0" +dependencies = [ + "proto", + "rusqlite", + "serde", + "serde_json", + "toml", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -169,6 +246,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + [[package]] name = "proc-macro2" version = "1.0.107" @@ -212,6 +295,31 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rsqlite-vfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" +dependencies = [ + "hashbrown 0.16.1", + "thiserror", +] + +[[package]] +name = "rusqlite" +version = "0.40.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", + "sqlite-wasm-rs", +] + [[package]] name = "rustls" version = "0.23.45" @@ -258,6 +366,12 @@ dependencies = [ "untrusted", ] +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + [[package]] name = "schannel" version = "0.1.29" @@ -348,6 +462,24 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "smallvec" +version = "1.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" + +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + [[package]] name = "subtle" version = "2.6.1" @@ -365,6 +497,26 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "toml" version = "1.1.6+spec-1.1.0" @@ -425,12 +577,63 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasm-bindgen" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +dependencies = [ + "unicode-ident", +] + [[package]] name = "windows-link" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index d018a19..3072182 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "crates/loopd", "crates/brokerd", "crates/gatewayd", + "crates/memoryd", "crates/inferproxy", "crates/toolkit", "crates/bxctl", @@ -28,4 +29,5 @@ emsha = "1.0.4" rustls = { version = "0.23.45", default-features = false, features = ["ring", "std", "tls12"] } rustls-native-certs = "0.8.4" zeroize = "1.9.0" +rusqlite = { version = "0.40.2", features = ["bundled"] } diff --git a/crates/memoryd/Cargo.toml b/crates/memoryd/Cargo.toml new file mode 100644 index 0000000..905523b --- /dev/null +++ b/crates/memoryd/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "memoryd" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true +publish.workspace = true + +[lints] +workspace = true + +[dependencies] +proto.workspace = true +rusqlite.workspace = true +serde.workspace = true +serde_json.workspace = true +toml.workspace = true diff --git a/crates/memoryd/src/config.rs b/crates/memoryd/src/config.rs new file mode 100644 index 0000000..fcf7586 --- /dev/null +++ b/crates/memoryd/src/config.rs @@ -0,0 +1,140 @@ +//! `memoryd` configuration: read `memoryd.toml` into a typed `Config` (M5a spec, section 11). +//! +//! Unknown keys are errors in every table, as in the other roles: a misspelt key that silently +//! fell back to its default would be a setting the owner believes is set and is not. + +use std::path::{Path, PathBuf}; + +use proto::DataClass; +use serde::Deserialize; + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(deny_unknown_fields, default)] +pub struct Paths { + pub home: PathBuf, +} + +impl Default for Paths { + fn default() -> Self { + Self { + home: std::env::var_os("BOXMAKER_HOME") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("/var/lib/boxmaker")), + } + } +} + +/// `[memory]`: the starter `soul.md`, and whether identity writes wait for the owner. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(deny_unknown_fields, default)] +pub struct Memory { + /// A relative path is resolved next to the config file by `Config::load`. + pub soul_starter: PathBuf, + pub review: bool, +} + +impl Default for Memory { + fn default() -> Self { + Self { + soul_starter: PathBuf::from("soul.md"), + review: false, + } + } +} + +/// `[index]`: records above `max_class` are not indexed. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(deny_unknown_fields, default)] +pub struct Index { + pub max_class: DataClass, +} + +impl Default for Index { + fn default() -> Self { + Self { + max_class: DataClass::Private, + } + } +} + +/// An empty path means "the default under `home`". +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Default)] +#[serde(deny_unknown_fields, default)] +pub struct Sockets { + #[serde(rename = "loop")] + pub loopd: PathBuf, + pub broker: PathBuf, + pub admin: PathBuf, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Default)] +#[serde(deny_unknown_fields, default)] +pub struct Config { + pub paths: Paths, + pub memory: Memory, + pub index: Index, + pub sockets: Sockets, +} + +#[derive(Debug)] +pub enum ConfigError { + Read(PathBuf, std::io::Error), + Parse(PathBuf, toml::de::Error), +} + +impl std::fmt::Display for ConfigError { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + ConfigError::Read(path, err) => write!(f, "{}: {err}", path.display()), + ConfigError::Parse(path, err) => write!(f, "{}: {err}", path.display()), + } + } +} + +impl std::error::Error for ConfigError {} + +impl Config { + pub fn parse(text: &str) -> Result { + toml::from_str(text) + } + + /// Read and parse `path`; a relative `soul_starter` is taken to be next to it. + pub fn load(path: &Path) -> Result { + let text = + std::fs::read_to_string(path).map_err(|e| ConfigError::Read(path.to_path_buf(), e))?; + let mut config = + Config::parse(&text).map_err(|e| ConfigError::Parse(path.to_path_buf(), e))?; + if config.memory.soul_starter.is_relative() { + let base = path.parent().unwrap_or_else(|| Path::new(".")); + config.memory.soul_starter = base.join(&config.memory.soul_starter); + } + Ok(config) + } + + fn socket(&self, set: &Path, default: &str) -> PathBuf { + if set.as_os_str().is_empty() { + self.paths.home.join(default) + } else { + set.to_path_buf() + } + } + + pub fn loop_socket(&self) -> PathBuf { + self.socket(&self.sockets.loopd, "run/loop-memory/memory.sock") + } + pub fn broker_socket(&self) -> PathBuf { + self.socket(&self.sockets.broker, "run/broker-memory/memory.sock") + } + pub fn admin_socket(&self) -> PathBuf { + self.socket(&self.sockets.admin, "run/owner-memory/admin.sock") + } + pub fn memory_dir(&self) -> PathBuf { + self.paths.home.join("memory") + } + pub fn index_path(&self) -> PathBuf { + self.paths.home.join("index/memory.sqlite3") + } + pub fn sessions_dir(&self) -> PathBuf { + self.paths.home.join("sessions") + } +} diff --git a/crates/memoryd/src/lib.rs b/crates/memoryd/src/lib.rs new file mode 100644 index 0000000..be25ed7 --- /dev/null +++ b/crates/memoryd/src/lib.rs @@ -0,0 +1,4 @@ +//! The memory role: the memory files, their index, and every memory tool call (M5a spec). + +pub mod config; +pub mod socket; diff --git a/crates/memoryd/src/main.rs b/crates/memoryd/src/main.rs new file mode 100644 index 0000000..a4f44e4 --- /dev/null +++ b/crates/memoryd/src/main.rs @@ -0,0 +1,33 @@ +//! `memoryd serve --config `. + +use std::ffi::OsString; +use std::path::PathBuf; +use std::process::ExitCode; + +const USAGE: &str = "usage: memoryd serve --config "; + +fn main() -> ExitCode { + let args: Vec = std::env::args_os().skip(1).collect(); + let Some(config) = parse(&args) else { + eprintln!("{USAGE}"); + return ExitCode::from(2); + }; + match memoryd::config::Config::load(&config) { + Ok(_) => { + eprintln!("memoryd: not implemented"); + ExitCode::from(2) + } + Err(e) => { + eprintln!("memoryd: {e}\nsee docs/runbook.md#memoryd-start-failed"); + ExitCode::from(1) + } + } +} + +/// The config path of `serve --config `, or `None` for any other list. +fn parse(args: &[OsString]) -> Option { + match args { + [serve, flag, path] if serve == "serve" && flag == "--config" => Some(PathBuf::from(path)), + _ => None, + } +} diff --git a/crates/memoryd/src/socket.rs b/crates/memoryd/src/socket.rs new file mode 100644 index 0000000..8a4a492 --- /dev/null +++ b/crates/memoryd/src/socket.rs @@ -0,0 +1,60 @@ +//! Listening on a Unix socket: the directory at 0700, a stale socket removed, the socket at 0600 +//! (M5a spec, section 7). The same steps as `brokerd`'s; no crate depends on another role's. + +use std::fs::{DirBuilder, Permissions}; +use std::io; +use std::os::unix::fs::{DirBuilderExt, PermissionsExt}; +use std::os::unix::net::UnixListener; +use std::path::Path; + +/// Bind one socket: prepare its directory at 0700, make it private, remove any stale socket, +/// bind, then make the socket itself private at 0600. The error names the path. +pub fn listen(socket: &Path) -> Result { + // A socket needs a directory of its own: step 2 makes that directory 0700, which must never + // be `/` or a shared directory reached through a symbolic link. + let dir = match socket.parent() { + Some(parent) if parent.parent().is_some() && !parent.as_os_str().is_empty() => { + parent.to_path_buf() + } + _ => { + return Err(format!( + "{}: a socket needs a directory of its own, not / or none", + socket.display() + )); + } + }; + + // 1. The directory, at 0700. + DirBuilder::new() + .recursive(true) + .mode(0o700) + .create(&dir) + .map_err(|e| format!("{}: {e}", dir.display()))?; + // `set_permissions` follows a symbolic link, so a link would make the directory it points to + // private instead: refuse it. + let kind = std::fs::symlink_metadata(&dir) + .map_err(|e| format!("{}: {e}", dir.display()))? + .file_type(); + if !kind.is_dir() { + return Err(format!( + "{}: is a symbolic link or not a directory; memoryd will not change its mode", + dir.display() + )); + } + // 2. Make it private, always, even when it was already there at 0755. + std::fs::set_permissions(&dir, Permissions::from_mode(0o700)) + .map_err(|e| format!("{}: {e}", dir.display()))?; + + // 3. A stale socket is removed; a missing one is fine. + match std::fs::remove_file(socket) { + Ok(()) => {} + Err(e) if e.kind() == io::ErrorKind::NotFound => {} + Err(e) => return Err(format!("{}: {e}", socket.display())), + } + + // 4. Bind, then 5. make the socket private. + let listener = UnixListener::bind(socket).map_err(|e| format!("{}: {e}", socket.display()))?; + std::fs::set_permissions(socket, Permissions::from_mode(0o600)) + .map_err(|e| format!("{}: {e}", socket.display()))?; + Ok(listener) +} diff --git a/crates/memoryd/tests/config.rs b/crates/memoryd/tests/config.rs new file mode 100644 index 0000000..36c4db6 --- /dev/null +++ b/crates/memoryd/tests/config.rs @@ -0,0 +1,90 @@ +//! `memoryd.toml`: defaults, paths under `home`, the starter next to the config file, and unknown +//! keys refused (M5a spec, section 11). Do not edit. + +#[path = "support/tmp.rs"] +mod tmp; + +use std::path::PathBuf; + +use memoryd::config::Config; +use proto::DataClass; +use tmp::TempDir; + +#[test] +fn defaults_and_paths_under_home() { + let config = Config::parse("[paths]\nhome = \"/srv/bx\"\n").unwrap(); + assert!(!config.memory.review); + assert_eq!(config.index.max_class, DataClass::Private); + assert_eq!( + config.loop_socket(), + PathBuf::from("/srv/bx/run/loop-memory/memory.sock") + ); + assert_eq!( + config.broker_socket(), + PathBuf::from("/srv/bx/run/broker-memory/memory.sock") + ); + assert_eq!( + config.admin_socket(), + PathBuf::from("/srv/bx/run/owner-memory/admin.sock") + ); + assert_eq!(config.memory_dir(), PathBuf::from("/srv/bx/memory")); + assert_eq!( + config.index_path(), + PathBuf::from("/srv/bx/index/memory.sqlite3") + ); + assert_eq!(config.sessions_dir(), PathBuf::from("/srv/bx/sessions")); +} + +#[test] +fn every_setting_can_be_given() { + let text = "[paths]\nhome = \"/h\"\n[memory]\nsoul_starter = \"/etc/bx/soul.md\"\nreview = true\n[index]\nmax_class = \"secret\"\n[sockets]\nloop = \"/r/l.sock\"\nbroker = \"/r/b.sock\"\nadmin = \"/r/a.sock\"\n"; + let config = Config::parse(text).unwrap(); + assert!(config.memory.review); + assert_eq!(config.index.max_class, DataClass::Secret); + assert_eq!(config.memory.soul_starter, PathBuf::from("/etc/bx/soul.md")); + assert_eq!( + ( + config.loop_socket(), + config.broker_socket(), + config.admin_socket() + ), + ( + PathBuf::from("/r/l.sock"), + PathBuf::from("/r/b.sock"), + PathBuf::from("/r/a.sock") + ) + ); +} + +#[test] +fn unknown_keys_are_refused_in_every_table() { + for text in [ + "[paths]\nhomee = \"/h\"\n", + "[memory]\nreveiw = true\n", + "[index]\nmax = \"private\"\n", + "[sockets]\nloopd = \"/x\"\n", + "[extra]\n", + "[index]\nmax_class = \"top-secret\"\n", + ] { + assert!(Config::parse(text).is_err(), "{text}"); + } +} + +#[test] +fn a_relative_starter_is_next_to_the_config_file() { + let dir = TempDir::new("config"); + let path = dir.write("etc/memoryd.toml", "[memory]\nsoul_starter = \"soul.md\"\n"); + let config = Config::load(&path).unwrap(); + assert_eq!(config.memory.soul_starter, dir.path().join("etc/soul.md")); + let path = dir.write("etc/default.toml", ""); + let config = Config::load(&path).unwrap(); + assert_eq!(config.memory.soul_starter, dir.path().join("etc/soul.md")); + let path = dir.write( + "etc/abs.toml", + "[memory]\nsoul_starter = \"/opt/soul.md\"\n", + ); + let config = Config::load(&path).unwrap(); + assert_eq!(config.memory.soul_starter, PathBuf::from("/opt/soul.md")); + let missing = Config::load(&dir.path().join("nope.toml")).unwrap_err(); + assert!(missing.to_string().contains("nope.toml"), "{missing}"); +} diff --git a/crates/memoryd/tests/socket.rs b/crates/memoryd/tests/socket.rs new file mode 100644 index 0000000..e4e9ded --- /dev/null +++ b/crates/memoryd/tests/socket.rs @@ -0,0 +1,44 @@ +//! `listen`: the directory at 0700, the socket at 0600, a stale socket replaced, and no mode +//! changed through a symbolic link (M5a spec, section 7). Do not edit. + +#[path = "support/tmp.rs"] +mod tmp; + +use std::os::unix::fs::PermissionsExt; +use std::os::unix::net::UnixStream; + +use memoryd::socket::listen; +use tmp::TempDir; + +fn mode(path: &std::path::Path) -> u32 { + std::fs::metadata(path).unwrap().permissions().mode() & 0o777 +} + +#[test] +fn directory_and_socket_are_private_and_a_stale_socket_is_replaced() { + let dir = TempDir::new("socket"); + let run = dir.path().join("run/loop-memory"); + std::fs::create_dir_all(&run).unwrap(); + std::fs::set_permissions(&run, std::fs::Permissions::from_mode(0o755)).unwrap(); + let socket = run.join("memory.sock"); + let first = listen(&socket).unwrap(); + assert_eq!((mode(&run), mode(&socket)), (0o700, 0o600)); + drop(first); + let second = listen(&socket).expect("a stale socket is removed"); + assert!(UnixStream::connect(&socket).is_ok()); + drop(second); +} + +#[test] +fn no_directory_and_symbolic_links_are_refused() { + let dir = TempDir::new("socket-link"); + let target = dir.path().join("shared"); + std::fs::create_dir_all(&target).unwrap(); + std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755)).unwrap(); + std::os::unix::fs::symlink(&target, dir.path().join("link")).unwrap(); + let err = listen(&dir.path().join("link/memory.sock")).unwrap_err(); + assert!(err.contains("symbolic link"), "{err}"); + assert_eq!(mode(&target), 0o755, "the target's mode is untouched"); + let err = listen(std::path::Path::new("/memory.sock")).unwrap_err(); + assert!(err.contains("directory of its own"), "{err}"); +} diff --git a/crates/memoryd/tests/support/tmp.rs b/crates/memoryd/tests/support/tmp.rs new file mode 100644 index 0000000..753a354 --- /dev/null +++ b/crates/memoryd/tests/support/tmp.rs @@ -0,0 +1,40 @@ +//! Temporary directories for tests. Do not edit. + +#![allow(dead_code)] // each test file uses a different part of this module + +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU32, Ordering}; + +static NEXT: AtomicU32 = AtomicU32::new(0); + +pub struct TempDir(PathBuf); + +impl TempDir { + pub fn new(tag: &str) -> TempDir { + let n = NEXT.fetch_add(1, Ordering::SeqCst); + let path = std::env::temp_dir().join(format!("md-{tag}-{}-{n}", std::process::id())); + let _ = std::fs::remove_dir_all(&path); + std::fs::create_dir_all(&path).unwrap(); + TempDir(path) + } + + pub fn path(&self) -> &Path { + &self.0 + } + + /// Writes `text` to `name` inside the directory and returns the full path. + pub fn write(&self, name: &str, text: &str) -> PathBuf { + let path = self.0.join(name); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + std::fs::write(&path, text).unwrap(); + path + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} diff --git a/deny.toml b/deny.toml index f157728..0bac870 100644 --- a/deny.toml +++ b/deny.toml @@ -12,7 +12,7 @@ targets = [ ] [licenses] -allow = ["MIT", "Apache-2.0", "Unicode-3.0", "ISC", "BSD-3-Clause"] +allow = ["MIT", "Apache-2.0", "Unicode-3.0", "ISC", "BSD-3-Clause", "Zlib"] confidence-threshold = 0.9 [licenses.private] diff --git a/docs/dependencies.md b/docs/dependencies.md index 6e222b4..60fecce 100644 --- a/docs/dependencies.md +++ b/docs/dependencies.md @@ -12,6 +12,7 @@ Every external crate has a row here. `scripts/check-dep-docs.sh` enforces it. | `rustls` | 0.23.45 | `gatewayd` | TLS to Mattermost over the tailnet (M4a, owner's decision). `default-features = false` with `ring`, `std`, `tls12`. With its dependencies on Linux: `ring` (Apache-2.0 AND ISC; builds C and assembly), `rustls-webpki` and `untrusted` (ISC), `rustls-pki-types`, `subtle` (BSD-3-Clause), `zeroize`, `once_cell`, `getrandom`, `libc`, `cfg-if`. Apache-2.0 OR ISC OR MIT. | | `rustls-native-certs` | 0.8.4 | `gatewayd` | The host's trusted certificates, so a CA installed on the host is trusted too; adds `openssl-probe`. Apache-2.0 OR ISC OR MIT. | | `zeroize` | 1.9.0 | `gatewayd` | Wipes a secret's memory when it is dropped (`Secret`). Already a dependency of `rustls`. Apache-2.0 OR MIT. | +| `rusqlite` | 0.40.2 | `memoryd` from M5a | The full-text index (`index/memory.sqlite3`, FTS5), disposable and rebuilt from files. Feature `bundled`: `libsqlite3-sys` compiles SQLite from source with FTS5 on, so no system SQLite is needed. With its dependencies: `libsqlite3-sys` (MIT; build: `cc`, `pkg-config`, `vcpkg`), `hashlink`, `hashbrown`, `foldhash` (Zlib, added to `deny.toml` for it), `fallible-iterator`, `fallible-streaming-iterator`, `smallvec`, `bitflags`. MIT. | ## Build inputs diff --git a/docs/implementer-log.md b/docs/implementer-log.md index 31a67dc..05ed6fa 100644 --- a/docs/implementer-log.md +++ b/docs/implementer-log.md @@ -6,6 +6,7 @@ reviewer adds findings under "Reviews" once per milestone. | Task | Date | Status | Gate runs | First gate | Deviations | Notes | Model | |---|---|---|---|---|---|---|---| +| M5a/03-memoryd-crate | 2026-09-24 | done | 1 | pass | none | Copied `crates/memoryd/Cargo.toml`, `lib.rs`, `main.rs` (whole), the `config.rs` and `socket.rs` skeletons, and the tests `config.rs`, `socket.rs` and `tests/support/tmp.rs` into place. Added `"crates/memoryd",` to the workspace `members` after `"crates/gatewayd",` and `rusqlite = { version = "0.40.2", features = ["bundled"] }` to `[workspace.dependencies]`. Added `Zlib` to the `deny.toml` allow list (for `foldhash`, a `rusqlite` dependency) and the `rusqlite` row to `docs/dependencies.md`. Appended the `memoryd-start-failed` runbook entry. Filled `Config::load` (`read_to_string` -> `ConfigError::Read`, `Config::parse` -> `ConfigError::Parse`, then a relative `soul_starter` resolved next to the config file via `path.parent().unwrap_or_else(|| Path::new("."))`); `Config::socket` (an empty `set` means `self.paths.home.join(default)`, otherwise the path as given); and `listen` (the same five steps as brokerd's `serve.rs::listen` — directory at 0700, symlink_metadata refusal, `set_permissions`, stale socket removed, bind then 0600 — with every error a `String` via `format!("{}: {e}", path.display())`). `cargo fmt --all`; the first `cargo build -p memoryd` compiled SQLite from source; `cargo deny check` reported the four "ok"s; `config` 4 passed and `socket` 2 passed; `make gate` prints `gate: ok` on the first run. | ? | | M5a/02-proto-memory | 2026-09-24 | done | 1 | pass | none | Copied the skeleton `crates/proto/src/memory.rs`, the two tests `memory_args.rs` and `memory_wire.rs`, and the eight wire fixtures into place. Added `pub mod memory;` to `proto/src/lib.rs` after `pub mod log;` and the re-export block after `pub use log::{...}`. In `wire.rs` added the `use crate::memory::{MemoryAdmin, MemoryAdminReply, MemoryBlockReply, MemoryCall, Reindexed, SessionUpdated};` import and the eight `Message` variants after `ApprovalOutcome`. In `broker.rs` changed the `kind` doc comment's "all eighteen kinds" to "all twenty-six kinds" and added the eight `kind` arms. Filled the four `todo!()`s in `memory.rs`: `ConsolidateEntry::action` (count keep/moved/drop as usize, match on `(old, text, flags)` for the five cases); `entry_problem` (empty, too long by `chars().count()`, then a line that is only the separator); `RecallArgs::check` (query 1..=MAX_QUERY by chars then k 1..=MAX_K); `MemoryArgs::check` (every text through `entry_problem`, consolidate needs a non-empty list and each line's action strings checked with MAX_ENTRY). `cargo fmt --all`; `memory_args` 6 passed, `memory_wire` 2 passed; `make gate` prints `gate: ok` on the first run. | ? | | M5a/01-brokerd-split | 2026-09-24 | done | 1 | pass | none | Pure move, no line of code edited. Moved `crates/brokerd/src/policy.rs` to `policy/mod.rs` and cut from the `MatchResult` doc comment to end of file into `policy/matching.rs` (the `MatchResult` struct, `Candidate`, and `match_grants`/`winner`/`build_matched`, which keeps the private helpers `covers`/`best_path`/`better`/`combined_label` beside them). Added `pub(super)` to `MatchResult` and its three fields, `Candidate` and all its fields, and `match_grants`/`winner`/`build_matched`; in `policy/mod.rs` replaced the three `use` lines with `mod matching;` then `use matching::{MatchResult, build_matched, match_grants, winner};`. Moved `ledger/answer` through `finish` into `ledger/answer.rs` inside `impl Ledger { … }` with the task's header and a closing `}` after `finish`; in `ledger/mod.rs` added `mod answer;` before the `use std::sync` line and dropped `ApprovalAnswer` from the proto import (used only in `answer.rs`). `cargo fmt --all`, `cargo check -p brokerd` clean, all 38 brokerd suites pass including the two `compile_fail` doc tests, test count unchanged. `wc -l` shows 187/320/204/296, all under 500. `make gate` prints `gate: ok` on the first run. | ? | | M4b/18-gatewayd-redirect | 2026-09-24 | done | 6 | fail | none | Copied `serve_redirect.rs` and a new `serve_approvals.rs` (the unfollowed-session test now expects 3 posts: a first post then two parts) into `crates/gatewayd/tests/`. In `serve/mod.rs`: added `authors: HashMap` and `askers: HashMap` fields after `open` and their `HashMap::new()` initialisers. In `serve/approvals.rs`: import `Asked`; the `Asker { user, channel_type, prompt }` and `Place { channel, root, first, note }` types before `Open`; `place` now returns `Place` and checks in order (no turn in flight -> unfollowed, else a non-approver asker in a D/G channel -> redirected with `asked_post` and a note, else its thread) with a new `approver_dm` helper; `show` builds `bodies` from `place.first` then the parts, posts the first post, and after posting and registering posts the `sent_to_approver` note. In `serve/handle.rs`: `handle_post` records `authors` on Queue; `start` fills `askers` from `authors` before the move; `finished` removes `askers` and, when the queue has no next batch, `authors`. `cargo fmt --all` then `cargo check` clean. `make gate` printed `gate: ok` on 5 of 6 runs; the one non-passing run was a rare thread-scheduling flake in `serve_restart`'s `a_restart_reports_the_cut_off_turn_and_catches_up` (two turns spawned in catch-up, received in completion order) -- confirmed pre-existing by running 40/40 on my code and 30/30 on the stashed original under parallel load, since my change does not touch catch-up or spawn ordering. | ? | diff --git a/docs/runbook.md b/docs/runbook.md index 115f245..9661393 100644 --- a/docs/runbook.md +++ b/docs/runbook.md @@ -596,3 +596,24 @@ its thread answers it. Where two Boxmakers share a channel thread, address approval commands to one of them (`@ !approve `): a bare `!approve ` is taken by every Boxmaker with that id pending in that thread. + +## memoryd-start-failed + +**What you see.** `memoryd` exits 1 at start with one line naming its config file, a directory, +a file under `memory/`, the index, or a socket, then this entry. + +**Why.** `memoryd` could not read or parse `memoryd.toml` (unknown keys are errors), could not +read the starter `soul.md` when `memory/soul.md` was missing, could not create or open +`memory/` or `index/memory.sqlite3`, or could not listen on one of its three sockets (their +directories are made 0700 and the sockets 0600, as `brokerd`'s are; a path of 108 bytes or more +cannot be a socket). It will not start without the memory it owns. + +**Confirm.** The line names the path. For the file, check it against +`docs/specs/2026-09-24-m5a-local-memory.md`, section 11. For a directory, +`ls -ld "$(dirname )"`. `Address already in use` means another `memoryd` runs: +`pgrep -x memoryd`. + +**Fix.** Correct the file, the path or the ownership. The index is disposable: if it is damaged, +move it aside and start `memoryd`, which rebuilds it (see `memory-index-damaged`). + +**Check.** `memoryd serve --config ` prints `memoryd: serving`.