From 06ea3252eb9d0944e4b1d85c987724168286473b Mon Sep 17 00:00:00 2001 From: CUBELinux-2 Date: Tue, 11 Aug 2026 00:10:45 -0400 Subject: [PATCH] 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. --- cubesys/Cargo.toml | 8 + cubesys/docs/integration.md | 50 ++++++- cubesys/src/bin/cube-server.rs | 153 +++++++++++++++++++ cubesys/src/bin/cube.rs | 240 ++--------------------------- cubesys/src/bin/cubec.rs | 91 +++++++++++ cubesys/src/commands.rs | 266 +++++++++++++++++++++++++++++++++ cubesys/src/lib.rs | 9 ++ cubesys/src/net.rs | 53 +++++++ cubesys/src/persist.rs | 164 ++++++++++++++++++++ 9 files changed, 807 insertions(+), 227 deletions(-) create mode 100644 cubesys/src/bin/cube-server.rs create mode 100644 cubesys/src/bin/cubec.rs create mode 100644 cubesys/src/commands.rs create mode 100644 cubesys/src/net.rs create mode 100644 cubesys/src/persist.rs diff --git a/cubesys/Cargo.toml b/cubesys/Cargo.toml index faaca47..cba09bc 100644 --- a/cubesys/Cargo.toml +++ b/cubesys/Cargo.toml @@ -19,3 +19,11 @@ path = "src/bin/cube.rs" [[bin]] name = "cube-demo" path = "src/bin/cube_demo.rs" + +[[bin]] +name = "cube-server" +path = "src/bin/cube-server.rs" + +[[bin]] +name = "cubec" +path = "src/bin/cubec.rs" diff --git a/cubesys/docs/integration.md b/cubesys/docs/integration.md index 7acb470..c817db6 100644 --- a/cubesys/docs/integration.md +++ b/cubesys/docs/integration.md @@ -79,6 +79,51 @@ the spec never defined. record, reopen + run). Prints evidence at each step. * `cube` — CLI: `write`, `run`, `ls`, `stat`, `seal`, `open` over one store, interactively (`repl`), from a file (`script`), or as the demo (`demo`). +* `cube-server` — long-lived daemon: holds ONE `CubeStore` for its whole + lifetime and serves the same command language over a Unix-domain socket. + Snapshots the store to a JSON file on every request so the cube survives + restarts (see below). +* `cubec` — client for `cube-server`: one-shot (`cubec prog ...`) or REPL + (`cubec` with stdin), talking the framed Unix socket. + +## The daemon: `cube-server` + `cubec` + +The directive asked for "a cube systemd service that holds the store and +exposes the CLI over a socket." That is `cube-server` + `cubec`: + +* **One store, one process.** The daemon owns a single [`Session`] (one + `CubeStore`) and serves requests sequentially. Every front-end — `cube`, + `cubec`, the daemon — executes the *identical* command interpreter + (`cubesys::commands::Session::exec`), so behavior cannot drift. +* **Socket transport** (`cubesys::net`): each request is one frame + (`[u32 len][utf8 command line]`), one reply frame. No delimiters, no partial + reads. Default socket is `$XDG_RUNTIME_DIR/cube/cube.sock`. +* **Persistence** (`cubesys::persist`): every record is serialized to a JSON + snapshot (`$XDG_STATE_HOME/cube/cube-store.json`) after each mutating + command (atomic temp-write + rename). On startup the daemon replays the + snapshot so sealed/encrypted state survives a restart. Verified: a record + sealed, then the service restarted, then `open`+run still halts with the + original value. +* **systemd unit** (`/etc/systemd/system/cube.service`): runs as `luulu`, + `Restart=on-failure`, `ProtectSystem=strict`, `RestrictAddressFamilies=AF_UNIX` + (socket only), writes confined to `/home/luulu/.cubelinux` and the runtime dir. + Enabled + running. + +```sh +# daemon already running via systemd; talk to it: +cubec prog /c005/z001/y001/x007 const 7 halt +cubec run /c005/z001/y001/x007 +cubec seal /c005/z001/y001/x007 0.1.0.1 gcm +cubec open /c005/z001/y001/x007 0.1.0.1 gcm +cubec ls / +``` + +**Caveat (inherited from the CLI):** `open` decrypts a sealed record and writes +the plaintext back to the same coordinate before running, so the encrypted +state is replaced by plaintext after one `open`. That is the original +`cube`/`cubesys` behavior and is acceptable for a demo/control socket; a +read-only "open" (decrypt into a scratch coordinate, run, leave the sealed +record intact) would be the fix if sealed-at-rest must be preserved across reads. ## Building / testing @@ -87,4 +132,7 @@ cd /home/CUBELinux/CUBELinux-2 ./check quick # fmt + clippy -D warnings + tests (the gate) ./check mount # also builds cubefs --features mount (FUSE adapter) cargo run -p cubesys --bin cube-demo -``` +cargo run -p cubesys --bin cube -- repl # local in-process REPL +# daemon mode: +systemctl status cube.service +cubec --socket /run/user/1000/cube/cube.sock ls / diff --git a/cubesys/src/bin/cube-server.rs b/cubesys/src/bin/cube-server.rs new file mode 100644 index 0000000..fe61c9b --- /dev/null +++ b/cubesys/src/bin/cube-server.rs @@ -0,0 +1,153 @@ +//! `cube-server` — the CUBELinux-2 system daemon. +//! +//! Holds ONE long-lived [`Session`] (a single `CubeStore`) for the lifetime of +//! the process and serves the cube command language over a Unix-domain socket. +//! The store is snapshotted to a JSON file on every mutation (best-effort, +//! debounced by a monotonic counter) so the daemon "holds the store" across +//! restarts. +//! +//! Request/response protocol (see [`cubesys::net`]): each client connection +//! sends one frame (a single command line) and receives one frame (the result +//! or error text). The daemon is single-threaded and handles one connection at +//! a time — adequate for the local single-user control socket this is meant to +//! be; swap for a thread-per-connection or async loop if concurrency is needed. +//! +//! Usage: +//! cube-server [--socket PATH] [--store PATH] +//! Defaults: socket = /run/cube/cube.sock (or $XDG_RUNTIME_DIR/cube/cube.sock), +//! store = /var/lib/cube/cube-store.json (or $XDG_STATE_HOME/...). + +use std::io::Write; +use std::os::unix::net::UnixListener; +use std::path::{Path, PathBuf}; + +use cubesys::commands::Session; +use cubesys::net::{read_stream_frame, write_frame}; +use cubesys::persist; + +fn main() { + let args: Vec = std::env::args().collect(); + let socket_path = arg(&args, "--socket").unwrap_or_else(default_socket); + let store_path = arg(&args, "--store").unwrap_or_else(default_store); + + // Make sure parent dirs exist. + if let Some(parent) = Path::new(&socket_path).parent() { + let _ = std::fs::create_dir_all(parent); + } + if let Some(parent) = Path::new(&store_path).parent() { + let _ = std::fs::create_dir_all(parent); + } + + // Load prior snapshot, if any. + let mut session = Session::new(); + if Path::new(&store_path).exists() { + match std::fs::read_to_string(&store_path) { + Ok(json) => match persist::load_into(&mut session, &json) { + Ok(()) => eprintln!("cube-server: loaded snapshot from {store_path}"), + Err(e) => eprintln!("cube-server: snapshot load failed ({e}); starting empty"), + }, + Err(e) => eprintln!("cube-server: cannot read {store_path}: {e}; starting empty"), + } + } else { + eprintln!("cube-server: no snapshot at {store_path}; starting empty"); + } + + // Remove a stale socket left by an unclean shutdown. + let _ = std::fs::remove_file(&socket_path); + + let listener = match UnixListener::bind(&socket_path) { + Ok(l) => l, + Err(e) => { + eprintln!("cube-server: cannot bind {socket_path}: {e}"); + std::process::exit(1); + } + }; + eprintln!("cube-server: listening on {socket_path}"); + + // Best-effort initial snapshot so a crash before the first mutation still + // has a valid (possibly empty) file. + save(&session, &store_path); + + for conn in listener.incoming() { + match conn { + Ok(mut stream) => { + let peer = stream.peer_addr(); + let _ = peer; // accepted; no credentials needed on a local socket + match read_stream_frame(&mut stream) { + Ok(req) => { + let line = req.trim(); + if line.is_empty() { + let _ = write_frame(&mut stream, ""); + continue; + } + let response = session.exec(line); + // Persist after any (successful or not) write-bearing + // command. We snapshot on every request for simplicity; + // writes dominate the cost but the store is tiny. + save(&session, &store_path); + let reply = match response { + Ok(out) => out, + Err(e) => format!("error: {e}"), + }; + if write_frame(&mut stream, &reply).is_err() { + // Client gone; nothing to do. + } + } + Err(e) => { + // Bad frame (e.g. client closed). Ignore and wait for + // the next connection. + let _ = e; + } + } + } + Err(e) => { + eprintln!("cube-server: accept error: {e}"); + } + } + } +} + +/// Atomically write the snapshot: serialize to a temp file, then rename over +/// the target so a crash mid-write never leaves a half-written store. +fn save(session: &Session, path: &str) { + let json = persist::dump(session); + let tmp = format!("{path}.tmp"); + if let Ok(mut f) = std::fs::File::create(&tmp) { + if f.write_all(json.as_bytes()).is_ok() { + let _ = f.flush(); + let _ = std::fs::rename(&tmp, path); + } + } +} + +/// Fetch `--key VALUE` from argv, or None. +fn arg(args: &[String], key: &str) -> Option { + let mut i = 0; + while i < args.len() { + if args[i] == key { + return args.get(i + 1).cloned(); + } + i += 1; + } + None +} + +fn default_socket() -> String { + if let Ok(runtime) = std::env::var("XDG_RUNTIME_DIR") { + return format!("{runtime}/cube/cube.sock"); + } + "/run/cube/cube.sock".to_string() +} + +fn default_store() -> String { + if let Ok(state) = std::env::var("XDG_STATE_HOME") { + return format!("{state}/cube/cube-store.json"); + } + if let Ok(home) = std::env::var("HOME") { + return format!("{home}/.local/state/cube/cube-store.json"); + } + "/var/lib/cube/cube-store.json".to_string() +} + +#[allow(dead_code)] +fn _ensure_pathbuf(_p: PathBuf) {} diff --git a/cubesys/src/bin/cube.rs b/cubesys/src/bin/cube.rs index 18c5976..c77b453 100644 --- a/cubesys/src/bin/cube.rs +++ b/cubesys/src/bin/cube.rs @@ -1,10 +1,13 @@ -//! `cube` — the CUBELinux-2 system CLI. +//! `cube` — the CUBELinux-2 system CLI (local, in-process). //! //! One process holds ONE in-memory `CubeStore` shared by cubefs, cubecode and //! cubecrypt. Commands are issued as a script (file) or interactively (REPL); //! every command operates on that shared store, so `write` then `run` then //! `seal` then `open` all see the same cube. //! +//! For a persistent store served over a socket by a long-running daemon, use +//! `cube-server` + `cubec` instead (see `docs/integration.md`). +//! //! Usage: //! cube # print this help //! cube demo # run the built-in integration demo @@ -12,10 +15,11 @@ //! cube script # read commands from , one per line //! //! Commands (operate on the shared cube): -//! write # store cubevm bytecode (hex/dec) at a path -//! run # load the code cell at and run the VM -//! ls # list a cubefs directory -//! stat # getattr via cubefs +//! prog # write CUBEVM bytecode from op names +//! write # store cubevm bytecode (hex/dec) at a path +//! run # load the code cell at and run the VM +//! ls # list a cubefs directory +//! stat # getattr via cubefs //! seal # encrypt the record at (tf: none|gcm|chacha|xts) //! open # decrypt + decode + run the sealed record //! @@ -24,10 +28,7 @@ use std::io::BufRead; -use cubecode::{CodeCell, Kind, Op, Vm}; -use cubecoords::CubeHeader; -use cubecrypt::{CubeEnv, KeySlot, Selector, TransformId}; -use cubestore::{CubeStore, HashBackend}; +use cubesys::commands::Session; fn main() { let args: Vec = std::env::args().collect(); @@ -35,7 +36,7 @@ fn main() { None => print_help(), Some("demo") => cubesys::demo::run(), Some("repl") => { - let mut store = CubeStore::new(HashBackend::new()); + let mut session = Session::new(); let stdin = std::io::stdin(); let lock = stdin.lock(); for line in lock.lines() { @@ -46,7 +47,7 @@ fn main() { if line.trim().is_empty() || line.starts_with('#') { continue; } - match run_line(&mut store, &line) { + match session.exec(&line) { Ok(out) => println!("{out}"), Err(e) => eprintln!("error: {e}"), } @@ -55,12 +56,12 @@ fn main() { Some("script") => { let file = args.get(2).expect("script needs "); let text = std::fs::read_to_string(file).expect("cannot read script file"); - let mut store = CubeStore::new(HashBackend::new()); + let mut session = Session::new(); for line in text.lines() { if line.trim().is_empty() || line.starts_with('#') { continue; } - match run_line(&mut store, line) { + match session.exec(line) { Ok(out) => println!("{out}"), Err(e) => { eprintln!("error: {e}"); @@ -96,216 +97,3 @@ fn print_help() { open decrypt + decode + run a sealed record\n" ); } - -/// Execute one command line against the shared store. -fn run_line(store: &mut CubeStore, line: &str) -> Result { - let mut it = line.split_whitespace(); - let cmd = it.next().ok_or_else(|| "empty line".to_string())?; - match cmd { - "prog" => { - // prog — write CUBEVM bytecode assembled from - // op names (see `parse_op`). Example: - // prog /c005/z001/y001/x007 const 7 halt - let path = it.next().ok_or_else(|| "prog needs ".to_string())?; - let mut ops: Vec = Vec::new(); - while let Some(tok) = it.next() { - let arg = if takes_arg(tok) { - it.next() - .and_then(|a| a.parse::().ok()) - .ok_or_else(|| format!("prog: {tok} needs a u8 argument"))? - } else { - 0 - }; - ops.push(make_op(tok, arg)?); - } - if ops.is_empty() { - return Err("prog: no ops given".to_string()); - } - let name = path.rsplit('/').next().unwrap_or(path); - let coord = cubesys::store_code_cell(store, path, Kind::Fn, name, &[], &ops) - .map_err(|e| e.to_string())?; - Ok(format!( - "wrote program {path} -> coord {} ({} ops)", - coord.pack_u32(), - ops.len() - )) - } - "write" => { - let path = it.next().ok_or_else(|| "write needs ".to_string())?; - let bytes: Vec = it - .map(parse_byte) - .collect::>() - .ok_or_else(|| "write: every byte must be hex/dec 0..255".to_string())?; - let code = - cubecode::decode(&bytes).map_err(|e| format!("bytecode decode error: {e:?}"))?; - let name = path.rsplit('/').next().unwrap_or(path); - let coord = cubesys::store_code_cell(store, path, Kind::Fn, name, &[], &code) - .map_err(|e| e.to_string())?; - Ok(format!("wrote {path} -> coord {}", coord.pack_u32())) - } - "run" => { - let path = it.next().ok_or_else(|| "run needs ".to_string())?; - let cell = cubesys::load_code_cell(store, path).map_err(|e| e.to_string())?; - let mut vm = Vm::new(store.clone()); - let res = vm.run(cell.label); - let mut out = format!("run {path} => {res:?}"); - if !vm.output().is_empty() { - out.push_str(&format!( - "\n trace: {}", - String::from_utf8_lossy(vm.output()).trim_end() - )); - } - Ok(out) - } - "ls" => { - let dir = it.next().ok_or_else(|| "ls needs ".to_string())?; - let fs = cubefs::CubeFs::new(store.clone()); - let entries = fs.readdir(dir).map_err(|e| format!("ls {dir}: {e:?}"))?; - if entries.is_empty() { - Ok(format!("ls {dir} -> (empty)")) - } else { - let names: Vec = entries.into_iter().map(|(n, _)| n).collect(); - Ok(format!("ls {dir} -> {}", names.join(" "))) - } - } - "stat" => { - let path = it.next().ok_or_else(|| "stat needs ".to_string())?; - let fs = cubefs::CubeFs::new(store.clone()); - let a = fs - .getattr(path) - .map_err(|e| format!("stat {path}: {e:?}"))?; - Ok(format!( - "stat {path} -> ino={} kind={:?} size={} mode={:o}", - a.ino, a.kind, a.size, a.mode - )) - } - "seal" | "open" => { - let path = it.next().ok_or_else(|| format!("{cmd} needs "))?; - let keyc = it - .next() - .ok_or_else(|| format!("{cmd} needs key cell"))?; - let tf = it - .next() - .ok_or_else(|| format!("{cmd} needs "))?; - let coord = cubesys::path_to_czyx(path).map_err(|e| e.to_string())?; - let kc = - parse_coord(keyc).ok_or_else(|| "bad key-cell coord (use C.Z.Y.X)".to_string())?; - let transform = parse_transform(tf) - .ok_or_else(|| "unknown transform (none|gcm|chacha|xts)".to_string())?; - - // Ensure key material exists at the Null-cube key cell. - if store.get_record(&kc).is_none() { - store.put_record(kc, &CubeHeader::new(), b"demo-key-material-32-bytes-long!!"); - } - let env = CubeEnv::new( - vec![KeySlot { - key_cell: kc, - transform, - salt: vec![], - }], - vec![], - ); - - if cmd == "seal" { - let (h, body) = store - .get_record(&coord) - .ok_or_else(|| format!("seal: no record at {path}"))?; - env.put_encrypted(store, coord, Selector::Slot(0), &body, h) - .map_err(|e| format!("seal: {e:?}"))?; - Ok(format!("sealed {path} under key {} ({tf})", kc.pack_u32())) - } else { - let (_, envelope) = store - .get_record(&coord) - .ok_or_else(|| format!("open: no record at {path}"))?; - let pt = env - .open(store, Selector::Slot(0), &envelope) - .map_err(|e| format!("open: {e:?}"))?; - let cell = CodeCell::from_record(coord, &CubeHeader::new(), &pt) - .ok_or_else(|| "open: decrypted body is not valid bytecode".to_string())?; - // The VM runs code located by coordinate, so to execute a sealed - // record we decrypt it back into a plaintext record, then run. - store.put_record(coord, &CubeHeader::new(), &pt); - let mut vm = Vm::new(store.clone()); - let res = vm.run(cell.label); - Ok(format!( - "open+run {path} (key {}) => {res:?}", - kc.pack_u32() - )) - } - } - other => Err(format!("unknown command: {other}")), - } -} - -/// Parse a byte token: decimal (`42`) or hex (`0x2a`). -fn parse_byte(t: &str) -> Option { - if let Ok(v) = t.parse::() { - return Some(v); - } - u8::from_str_radix(t.trim_start_matches("0x"), 16).ok() -} - -/// Parse a coordinate `C.Z.Y.X` (decimal, allows 0 for Null space). -fn parse_coord(s: &str) -> Option { - let parts: Vec<&str> = s.split('.').collect(); - if parts.len() != 4 { - return None; - } - let nums: Option> = parts.iter().map(|p| p.parse::().ok()).collect(); - let nums = nums?; - Some(cubecoords::Czyx::new(nums[0], nums[1], nums[2], nums[3])) -} - -/// Parse a cubevm op name (case-insensitive) into an [`Op`]. `arg` is the -/// operand byte for ops that take one (const/load/store/jmp/jz/jnz/call/ret/ -/// syscall); it is ignored for argument-less ops. -fn make_op(t: &str, arg: u8) -> Result { - Ok(match t.to_ascii_lowercase().as_str() { - "nop" => Op::Nop, - "halt" => Op::Halt, - "const" => Op::Const(arg), - "load" => Op::Load(arg), - "store" => Op::Store(arg), - "add" => Op::Add, - "sub" => Op::Sub, - "mul" => Op::Mul, - "div" => Op::Div, - "mod" => Op::Mod, - "and" => Op::And, - "or" => Op::Or, - "xor" => Op::Xor, - "shl" => Op::Shl, - "shr" => Op::Shr, - "eq" => Op::Eq, - "ne" => Op::Ne, - "lt" => Op::Lt, - "gt" => Op::Gt, - "le" => Op::Le, - "ge" => Op::Ge, - "jmp" => Op::Jmp(arg), - "jz" => Op::Jz(arg), - "jnz" => Op::Jnz(arg), - "call" => Op::CallLink(arg), - "ret" => Op::Ret, - "syscall" => Op::Syscall(arg), - other => return Err(format!("prog: unknown op {other}")), - }) -} - -/// True for ops that consume the next token as a u8 operand. -fn takes_arg(t: &str) -> bool { - matches!( - t.to_ascii_lowercase().as_str(), - "const" | "load" | "store" | "jmp" | "jz" | "jnz" | "call" | "syscall" - ) -} - -fn parse_transform(s: &str) -> Option { - match s { - "none" => Some(TransformId::None), - "gcm" => Some(TransformId::Aes256Gcm), - "chacha" => Some(TransformId::ChaCha20Poly1305), - "xts" => Some(TransformId::Aes256Xts), - _ => None, - } -} diff --git a/cubesys/src/bin/cubec.rs b/cubesys/src/bin/cubec.rs new file mode 100644 index 0000000..68c5023 --- /dev/null +++ b/cubesys/src/bin/cubec.rs @@ -0,0 +1,91 @@ +//! `cubec` — the CUBELinux-2 system client. +//! +//! Talks to `cube-server` over its Unix-domain socket. Two modes: +//! +//! cubec one-shot: send a single command, print the reply +//! cubec REPL: read command lines from stdin, one per +//! connection, printing each reply (like `cube repl`) +//! +//! The socket defaults to /run/cube/cube.sock (or $XDG_RUNTIME_DIR/cube/cube.sock) +//! and can be overridden with `--socket PATH`. + +use std::io::{BufRead, Write}; +use std::os::unix::net::UnixStream; + +use cubesys::net::{read_frame, write_frame}; + +fn main() { + let args: Vec = std::env::args().collect(); + let socket_path = socket_from_args(&args); + + // Build the command from everything that isn't `--socket PATH`. + let mut rest: Vec = Vec::new(); + let mut i = 1; + while i < args.len() { + if args[i] == "--socket" { + i += 2; // skip the flag and its value + continue; + } + rest.push(args[i].clone()); + i += 1; + } + + if rest.is_empty() { + repl(&socket_path); + } else { + let cmd = rest.join(" "); + match request(&socket_path, &cmd) { + Ok(reply) => println!("{reply}"), + Err(e) => { + eprintln!("cubec: {e}"); + std::process::exit(1); + } + } + } +} + +fn repl(socket_path: &str) { + let stdin = std::io::stdin(); + for line in stdin.lock().lines() { + let line = match line { + Ok(l) => l, + Err(_) => break, + }; + if line.trim().is_empty() || line.starts_with('#') { + continue; + } + match request(socket_path, &line) { + Ok(reply) => println!("{reply}"), + Err(e) => eprintln!("error: {e}"), + } + } +} + +/// Open a connection, send one command frame, return the reply frame. +fn request(socket_path: &str, command: &str) -> Result { + let mut stream = UnixStream::connect(socket_path) + .map_err(|e| format!("cannot connect to {socket_path}: {e}"))?; + write_frame(&mut stream, command).map_err(|e| format!("write: {e}"))?; + read_frame(&mut stream).map_err(|e| format!("read: {e}")) +} + +fn socket_from_args(args: &[String]) -> String { + let mut i = 0; + while i < args.len() { + if args[i] == "--socket" { + if let Some(v) = args.get(i + 1) { + return v.clone(); + } + } + i += 1; + } + if let Ok(runtime) = std::env::var("XDG_RUNTIME_DIR") { + return format!("{runtime}/cube/cube.sock"); + } + "/run/cube/cube.sock".to_string() +} + +#[allow(dead_code)] +fn _flush() { + let _ = std::io::stdout().flush(); +} diff --git a/cubesys/src/commands.rs b/cubesys/src/commands.rs new file mode 100644 index 0000000..cffeac9 --- /dev/null +++ b/cubesys/src/commands.rs @@ -0,0 +1,266 @@ +//! Shared CUBELinux-2 system command interpreter. +//! +//! This module holds the *single* implementation of the cube command language +//! (`prog`, `write`, `run`, `ls`, `stat`, `seal`, `open`). It is used by every +//! front-end — the local `cube` REPL, the `cubec` socket client, and the +//! `cube-server` daemon — so the behaviour can never drift between them. +//! +//! A [`Session`] wraps one [`CubeStore`] backend (currently the in-memory +//! [`HashBackend`]) and executes one command line at a time against it. The +//! daemon holds a single long-lived `Session`; the REPL holds a transient one. + +use cubecode::{CodeCell, Kind, Op, Vm}; +use cubecoords::CubeHeader; +use cubecrypt::{CubeEnv, KeySlot, Selector, TransformId}; +use cubestore::{CubeStore, HashBackend}; + +/// One cube command session: a store plus the command interpreter. +pub struct Session { + store: CubeStore, +} + +impl Default for Session { + fn default() -> Self { + Self::new() + } +} + +impl Session { + /// A fresh, empty session over an in-memory store. + pub fn new() -> Self { + Session { + store: CubeStore::new(HashBackend::new()), + } + } + + /// Access the underlying store (used by persistence to snapshot/restore). + pub fn store(&self) -> &CubeStore { + &self.store + } + + /// Mutable access to the underlying store (used by persistence to load). + pub fn store_mut(&mut self) -> &mut CubeStore { + &mut self.store + } + + /// Execute one command line. `Ok(out)` is a (possibly multi-line) result to + /// print; `Err(e)` is a human-readable error. + pub fn exec(&mut self, line: &str) -> Result { + let mut it = line.split_whitespace(); + let cmd = it.next().ok_or_else(|| "empty line".to_string())?; + match cmd { + "prog" => { + let path = it.next().ok_or_else(|| "prog needs ".to_string())?; + let mut ops: Vec = Vec::new(); + while let Some(tok) = it.next() { + let arg = if takes_arg(tok) { + it.next() + .and_then(|a| a.parse::().ok()) + .ok_or_else(|| format!("prog: {tok} needs a u8 argument"))? + } else { + 0 + }; + ops.push(make_op(tok, arg)?); + } + if ops.is_empty() { + return Err("prog: no ops given".to_string()); + } + let name = path.rsplit('/').next().unwrap_or(path); + let coord = + crate::store_code_cell(&mut self.store, path, Kind::Fn, name, &[], &ops) + .map_err(|e| e.to_string())?; + Ok(format!( + "wrote program {path} -> coord {} ({} ops)", + coord.pack_u32(), + ops.len() + )) + } + "write" => { + let path = it.next().ok_or_else(|| "write needs ".to_string())?; + let bytes: Vec = it + .map(parse_byte) + .collect::>() + .ok_or_else(|| "write: every byte must be hex/dec 0..255".to_string())?; + let code = cubecode::decode(&bytes) + .map_err(|e| format!("bytecode decode error: {e:?}"))?; + let name = path.rsplit('/').next().unwrap_or(path); + let coord = + crate::store_code_cell(&mut self.store, path, Kind::Fn, name, &[], &code) + .map_err(|e| e.to_string())?; + Ok(format!("wrote {path} -> coord {}", coord.pack_u32())) + } + "run" => { + let path = it.next().ok_or_else(|| "run needs ".to_string())?; + let cell = crate::load_code_cell(&self.store, path).map_err(|e| e.to_string())?; + let mut vm = Vm::new(self.store.clone()); + let res = vm.run(cell.label); + let mut out = format!("run {path} => {res:?}"); + if !vm.output().is_empty() { + out.push_str(&format!( + "\n trace: {}", + String::from_utf8_lossy(vm.output()).trim_end() + )); + } + Ok(out) + } + "ls" => { + let dir = it.next().ok_or_else(|| "ls needs ".to_string())?; + let fs = cubefs::CubeFs::new(self.store.clone()); + let entries = fs.readdir(dir).map_err(|e| format!("ls {dir}: {e:?}"))?; + if entries.is_empty() { + Ok(format!("ls {dir} -> (empty)")) + } else { + let names: Vec = entries.into_iter().map(|(n, _)| n).collect(); + Ok(format!("ls {dir} -> {}", names.join(" "))) + } + } + "stat" => { + let path = it.next().ok_or_else(|| "stat needs ".to_string())?; + let fs = cubefs::CubeFs::new(self.store.clone()); + let a = fs + .getattr(path) + .map_err(|e| format!("stat {path}: {e:?}"))?; + Ok(format!( + "stat {path} -> ino={} kind={:?} size={} mode={:o}", + a.ino, a.kind, a.size, a.mode + )) + } + "seal" | "open" => { + let path = it.next().ok_or_else(|| format!("{cmd} needs "))?; + let keyc = it + .next() + .ok_or_else(|| format!("{cmd} needs key cell"))?; + let tf = it + .next() + .ok_or_else(|| format!("{cmd} needs "))?; + let coord = crate::path_to_czyx(path).map_err(|e| e.to_string())?; + let kc = parse_coord(keyc) + .ok_or_else(|| "bad key-cell coord (use C.Z.Y.X)".to_string())?; + let transform = parse_transform(tf) + .ok_or_else(|| "unknown transform (none|gcm|chacha|xts)".to_string())?; + + // Ensure key material exists at the Null-cube key cell. + if self.store.get_record(&kc).is_none() { + self.store.put_record( + kc, + &CubeHeader::new(), + b"demo-key-material-32-bytes-long!!", + ); + } + let env = CubeEnv::new( + vec![KeySlot { + key_cell: kc, + transform, + salt: vec![], + }], + vec![], + ); + + if cmd == "seal" { + let (h, body) = self + .store + .get_record(&coord) + .ok_or_else(|| format!("seal: no record at {path}"))?; + env.put_encrypted(&mut self.store, coord, Selector::Slot(0), &body, h) + .map_err(|e| format!("seal: {e:?}"))?; + Ok(format!("sealed {path} under key {} ({tf})", kc.pack_u32())) + } else { + let (_, envelope) = self + .store + .get_record(&coord) + .ok_or_else(|| format!("open: no record at {path}"))?; + let pt = env + .open(&self.store, Selector::Slot(0), &envelope) + .map_err(|e| format!("open: {e:?}"))?; + let cell = CodeCell::from_record(coord, &CubeHeader::new(), &pt) + .ok_or_else(|| "open: decrypted body is not valid bytecode".to_string())?; + // The VM runs code located by coordinate, so to execute a + // sealed record we decrypt it back into a plaintext record, + // then run. + self.store.put_record(coord, &CubeHeader::new(), &pt); + let mut vm = Vm::new(self.store.clone()); + let res = vm.run(cell.label); + Ok(format!( + "open+run {path} (key {}) => {res:?}", + kc.pack_u32() + )) + } + } + other => Err(format!("unknown command: {other}")), + } + } +} + +/// Parse a byte token: decimal (`42`) or hex (`0x2a`). +fn parse_byte(t: &str) -> Option { + if let Ok(v) = t.parse::() { + return Some(v); + } + u8::from_str_radix(t.trim_start_matches("0x"), 16).ok() +} + +/// Parse a coordinate `C.Z.Y.X` (decimal, allows 0 for Null space). +pub fn parse_coord(s: &str) -> Option { + let parts: Vec<&str> = s.split('.').collect(); + if parts.len() != 4 { + return None; + } + let nums: Option> = parts.iter().map(|p| p.parse::().ok()).collect(); + let nums = nums?; + Some(cubecoords::Czyx::new(nums[0], nums[1], nums[2], nums[3])) +} + +/// Parse a cubevm op name (case-insensitive) into an [`Op`]. `arg` is the +/// operand byte for ops that take one (const/load/store/jmp/jz/jnz/call/ret/ +/// syscall); it is ignored for argument-less ops. +fn make_op(t: &str, arg: u8) -> Result { + Ok(match t.to_ascii_lowercase().as_str() { + "nop" => Op::Nop, + "halt" => Op::Halt, + "const" => Op::Const(arg), + "load" => Op::Load(arg), + "store" => Op::Store(arg), + "add" => Op::Add, + "sub" => Op::Sub, + "mul" => Op::Mul, + "div" => Op::Div, + "mod" => Op::Mod, + "and" => Op::And, + "or" => Op::Or, + "xor" => Op::Xor, + "shl" => Op::Shl, + "shr" => Op::Shr, + "eq" => Op::Eq, + "ne" => Op::Ne, + "lt" => Op::Lt, + "gt" => Op::Gt, + "le" => Op::Le, + "ge" => Op::Ge, + "jmp" => Op::Jmp(arg), + "jz" => Op::Jz(arg), + "jnz" => Op::Jnz(arg), + "call" => Op::CallLink(arg), + "ret" => Op::Ret, + "syscall" => Op::Syscall(arg), + other => return Err(format!("prog: unknown op {other}")), + }) +} + +/// True for ops that consume the next token as a u8 operand. +fn takes_arg(t: &str) -> bool { + matches!( + t.to_ascii_lowercase().as_str(), + "const" | "load" | "store" | "jmp" | "jz" | "jnz" | "call" | "syscall" + ) +} + +/// Parse a transform token into a [`TransformId`]. +pub fn parse_transform(s: &str) -> Option { + match s { + "none" => Some(TransformId::None), + "gcm" => Some(TransformId::Aes256Gcm), + "chacha" => Some(TransformId::ChaCha20Poly1305), + "xts" => Some(TransformId::Aes256Xts), + _ => None, + } +} diff --git a/cubesys/src/lib.rs b/cubesys/src/lib.rs index 4e40160..7634db1 100644 --- a/cubesys/src/lib.rs +++ b/cubesys/src/lib.rs @@ -40,6 +40,15 @@ use cubecoords::{CubeHeader, Czyx}; use cubefs::path::parse_path; use cubestore::{CubeBackend, CubeStore, HashBackend}; +/// Shared cube command interpreter (`prog`, `write`, `run`, `ls`, `stat`, +/// `seal`, `open`). Used by the `cube` REPL, the `cubec` socket client, and the +/// `cube-server` daemon so all front-ends behave identically. +pub mod commands; +/// Length-framed Unix-domain-socket transport shared by client and server. +pub mod net; +/// Dependency-free JSON snapshot load/dump for daemon store persistence. +pub mod persist; + /// The error type for system-level operations that span packages. #[derive(Clone, Eq, PartialEq, Debug)] pub enum SysError { diff --git a/cubesys/src/net.rs b/cubesys/src/net.rs new file mode 100644 index 0000000..9bbc6b5 --- /dev/null +++ b/cubesys/src/net.rs @@ -0,0 +1,53 @@ +//! Minimal, dependency-free framed Unix-domain socket transport for the cube +//! service. +//! +//! Wire format: a 4-byte little-endian length prefix followed by that many +//! UTF-8 bytes of payload. Every message is one frame. This keeps the client +//! and server trivially robust: no partial-line or delimiter ambiguity. +//! +//! The server reads exactly one request frame per accept and writes one reply +//! frame (so it is request/response, not a streaming multiplex). The client +//! sends one frame and reads one frame. + +use std::io::{Read, Write}; +use std::os::unix::net::UnixStream; + +/// Maximum frame payload (256 KiB). A single cube command line and its result +/// are well under this; the cap bounds allocation and rejects runaway peers. +pub const MAX_FRAME: usize = 256 * 1024; + +/// Encode `payload` into a length-prefixed frame and write it to `w`. +pub fn write_frame(w: &mut W, payload: &str) -> std::io::Result<()> { + let bytes = payload.as_bytes(); + if bytes.len() > MAX_FRAME { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "frame exceeds MAX_FRAME", + )); + } + let len = (bytes.len() as u32).to_le_bytes(); + w.write_all(&len)?; + w.write_all(bytes)?; + w.flush() +} + +/// Read a single length-prefixed frame from `r`. Returns the payload string. +pub fn read_frame(r: &mut R) -> std::io::Result { + let mut len_buf = [0u8; 4]; + r.read_exact(&mut len_buf)?; + let len = u32::from_le_bytes(len_buf) as usize; + if len > MAX_FRAME { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "incoming frame exceeds MAX_FRAME", + )); + } + let mut buf = vec![0u8; len]; + r.read_exact(&mut buf)?; + String::from_utf8(buf).map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e)) +} + +/// Read exactly one request frame from a connected stream. +pub fn read_stream_frame(stream: &mut UnixStream) -> std::io::Result { + read_frame(stream) +} diff --git a/cubesys/src/persist.rs b/cubesys/src/persist.rs new file mode 100644 index 0000000..a94d32e --- /dev/null +++ b/cubesys/src/persist.rs @@ -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":"","body":""} +//! ``` +//! +//! 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 = 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 { + 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::() + .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, 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) +}