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:
CUBELinux-2
2026-08-11 00:10:45 -04:00
parent 35e5183193
commit 06ea3252eb
9 changed files with 807 additions and 227 deletions
+164
View File
@@ -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)
}