cubesys: add cube systemd daemon (cube-server) + socket client (cubec)
Implements the requested cube service: a long-lived daemon that holds ONE CubeStore for its whole lifetime and serves the cube command language over a Unix-domain socket, plus cubec to talk to it. - cubesys::commands: factored the single command interpreter (Session::exec) so cube REPL, cubec client, and the daemon run identical logic - cubesys::net: dependency-free length-framed AF_UNIX transport - cubesys::persist: dependency-free JSON snapshot (atomic tmp+rename) so the store -- including sealed/encrypted records -- survives daemon restarts - cube-server: listens on $XDG_RUNTIME_DIR/cube/cube.sock, snapshots to $XDG_STATE_HOME/cube/cube-store.json, replays on startup - cubec: one-shot + REPL client over the socket - cube.rs trimmed to a thin REPL/script/demo driver (help text updated) - /etc/systemd/system/cube.service: runs as luulu, ProtectSystem=strict, RestrictAddressFamilies=AF_UNIX, Restart=on-failure; enabled + active - integration.md documents the daemon + caveat (open rewrites plaintext) Verified: ./check (fmt+tests+clippy -D warnings) green; ./check mount (27 FUSE e2e) green; socket CLI round-trips; sealed record survived a full service restart and reopened+r with original value.
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
//! Dependency-free snapshot persistence for a [`Session`].
|
||||
//!
|
||||
//! The store backend is currently the in-memory [`HashBackend`]. To make a
|
||||
//! long-lived daemon actually "hold the store" across restarts, we snapshot
|
||||
//! every record to a single JSON file on disk. The on-wire form is intentionally
|
||||
//! simple and std-only (no serde): each record becomes
|
||||
//!
|
||||
//! ```text
|
||||
//! {"c":u8,"z":u8,"y":u8,"x":u8,"hdr":"<hex>","body":"<hex>"}
|
||||
//! ```
|
||||
//!
|
||||
//! where `hdr`/`body` are the raw bytes the store persists already (header +
|
||||
//! body concatenated as the value). On load we replay those exact bytes back
|
||||
//! through the same backend `put`, so the codec is never re-implemented here.
|
||||
|
||||
use crate::commands::Session;
|
||||
use cubecoords::Czyx;
|
||||
/// Serialize the session's entire store to a JSON string.
|
||||
///
|
||||
/// The backend stores each record as `u32 header_len || hdr || body`. We split
|
||||
/// it so the snapshot carries the encoded header and body separately, and
|
||||
/// [`load_into`] reassembles the identical byte string on replay.
|
||||
pub fn dump(session: &Session) -> String {
|
||||
let store = session.store();
|
||||
let mut entries: Vec<String> = Vec::new();
|
||||
for coord in store.keys() {
|
||||
if let Some(raw) = store.get_raw(&coord) {
|
||||
if raw.len() < 4 {
|
||||
continue;
|
||||
}
|
||||
let mut len = [0u8; 4];
|
||||
len.copy_from_slice(&raw[..4]);
|
||||
let hlen = u32::from_le_bytes(len) as usize;
|
||||
if raw.len() < 4 + hlen {
|
||||
continue;
|
||||
}
|
||||
let hdr = &raw[4..4 + hlen];
|
||||
let body = &raw[4 + hlen..];
|
||||
entries.push(format!(
|
||||
"{{\"c\":{},\"z\":{},\"y\":{},\"x\":{},\"hdr\":\"{}\",\"body\":\"{}\"}}",
|
||||
coord.c,
|
||||
coord.z,
|
||||
coord.y,
|
||||
coord.x,
|
||||
to_hex(hdr),
|
||||
to_hex(body)
|
||||
));
|
||||
}
|
||||
}
|
||||
// Stable, readable array (order doesn't matter on load).
|
||||
let mut out = String::from("[\n");
|
||||
for (i, e) in entries.iter().enumerate() {
|
||||
out.push_str(" ");
|
||||
out.push_str(e);
|
||||
if i + 1 < entries.len() {
|
||||
out.push(',');
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
out.push_str("]\n");
|
||||
out
|
||||
}
|
||||
|
||||
/// Replay a previously [`dump`]ed JSON snapshot into the session's store.
|
||||
///
|
||||
/// Records not present in the snapshot are left untouched; this is a merge, so
|
||||
/// callers that want a clean reload should start from an empty [`Session`].
|
||||
pub fn load_into(session: &mut Session, json: &str) -> Result<(), String> {
|
||||
let trimmed = json.trim();
|
||||
if !trimmed.starts_with('[') {
|
||||
return Err("snapshot is not a JSON array".to_string());
|
||||
}
|
||||
// Minimal array scanner: find each `{"...` object between `[` and `]`.
|
||||
let inner = &trimmed[1..];
|
||||
let mut i = 0;
|
||||
while let Some(start) = inner[i..].find("{\"") {
|
||||
let obj_start = i + start;
|
||||
let obj_end = match inner[obj_start..].find("}") {
|
||||
Some(o) => obj_start + o,
|
||||
None => return Err("unterminated snapshot object".to_string()),
|
||||
};
|
||||
let obj = &inner[obj_start..=obj_end];
|
||||
replay_object(session, obj)?;
|
||||
i = obj_end + 1;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Parse one `{...}` object and write it back through the backend.
|
||||
fn replay_object(session: &mut Session, obj: &str) -> Result<(), String> {
|
||||
let c = field_u8(obj, "c")?;
|
||||
let z = field_u8(obj, "z")?;
|
||||
let y = field_u8(obj, "y")?;
|
||||
let x = field_u8(obj, "x")?;
|
||||
let hdr_hex = field_str(obj, "hdr")?;
|
||||
let body_hex = field_str(obj, "body")?;
|
||||
let coord = Czyx::new(c, z, y, x);
|
||||
|
||||
// The value stored by the backend is exactly header_len(4) || hdr || body
|
||||
// (see CubeStore::put_record). Reconstruct that so the load path is
|
||||
// identical to a normal write path.
|
||||
let hdr = from_hex(hdr_hex)?;
|
||||
let body = from_hex(body_hex)?;
|
||||
let mut value = Vec::with_capacity(4 + hdr.len() + body.len());
|
||||
value.extend_from_slice(&(hdr.len() as u32).to_le_bytes());
|
||||
value.extend_from_slice(&hdr);
|
||||
value.extend_from_slice(&body);
|
||||
|
||||
session.store_mut().put_raw(coord, value);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn field_u8(obj: &str, key: &str) -> Result<u8, String> {
|
||||
let key_pat = format!("\"{key}\":");
|
||||
let pos = obj
|
||||
.find(&key_pat)
|
||||
.ok_or_else(|| format!("snapshot object missing {key}"))?;
|
||||
let after = &obj[pos + key_pat.len()..];
|
||||
let end = after.find([',', '}', ' ']).unwrap_or(after.len());
|
||||
after[..end]
|
||||
.trim()
|
||||
.parse::<u8>()
|
||||
.map_err(|e| format!("bad {key}: {e}"))
|
||||
}
|
||||
|
||||
fn field_str<'a>(obj: &'a str, key: &str) -> Result<&'a str, String> {
|
||||
let key_pat = format!("\"{key}\":\"");
|
||||
let pos = obj
|
||||
.find(&key_pat)
|
||||
.ok_or_else(|| format!("snapshot object missing {key}"))?;
|
||||
let after = &obj[pos + key_pat.len()..];
|
||||
let end = after
|
||||
.find('"')
|
||||
.ok_or_else(|| format!("unterminated {key}"))?;
|
||||
Ok(&after[..end])
|
||||
}
|
||||
|
||||
fn to_hex(bytes: &[u8]) -> String {
|
||||
let mut s = String::with_capacity(bytes.len() * 2);
|
||||
for b in bytes {
|
||||
s.push_str(&format!("{b:02x}"));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn from_hex(s: &str) -> Result<Vec<u8>, String> {
|
||||
if !s.len().is_multiple_of(2) {
|
||||
return Err("odd-length hex".to_string());
|
||||
}
|
||||
let mut out = Vec::with_capacity(s.len() / 2);
|
||||
let bytes = s.as_bytes();
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
let hi = (bytes[i] as char)
|
||||
.to_digit(16)
|
||||
.ok_or_else(|| "bad hex digit".to_string())?;
|
||||
let lo = (bytes[i + 1] as char)
|
||||
.to_digit(16)
|
||||
.ok_or_else(|| "bad hex digit".to_string())?;
|
||||
out.push((hi * 16 + lo) as u8);
|
||||
i += 2;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
Reference in New Issue
Block a user