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:
@@ -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"
|
||||
|
||||
@@ -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 /
|
||||
|
||||
@@ -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<String> = 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<String> {
|
||||
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) {}
|
||||
+10
-222
@@ -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,6 +15,7 @@
|
||||
//! cube script <file> # read commands from <file>, one per line
|
||||
//!
|
||||
//! Commands (operate on the shared cube):
|
||||
//! prog <path> <ops...> # write CUBEVM bytecode from op names
|
||||
//! write <path> <bytes...> # store cubevm bytecode (hex/dec) at a path
|
||||
//! run <path> # load the code cell at <path> and run the VM
|
||||
//! ls <dir> # list a cubefs directory
|
||||
@@ -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<String> = 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 <file>");
|
||||
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 <path> <K.Z.Y.X> <tf> decrypt + decode + run a sealed record\n"
|
||||
);
|
||||
}
|
||||
|
||||
/// Execute one command line against the shared store.
|
||||
fn run_line(store: &mut CubeStore<HashBackend>, line: &str) -> Result<String, String> {
|
||||
let mut it = line.split_whitespace();
|
||||
let cmd = it.next().ok_or_else(|| "empty line".to_string())?;
|
||||
match cmd {
|
||||
"prog" => {
|
||||
// prog <path> <ops...> — 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 <path>".to_string())?;
|
||||
let mut ops: Vec<Op> = Vec::new();
|
||||
while let Some(tok) = it.next() {
|
||||
let arg = if takes_arg(tok) {
|
||||
it.next()
|
||||
.and_then(|a| a.parse::<u8>().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 <path>".to_string())?;
|
||||
let bytes: Vec<u8> = it
|
||||
.map(parse_byte)
|
||||
.collect::<Option<_>>()
|
||||
.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 <path>".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 <dir>".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<String> = entries.into_iter().map(|(n, _)| n).collect();
|
||||
Ok(format!("ls {dir} -> {}", names.join(" ")))
|
||||
}
|
||||
}
|
||||
"stat" => {
|
||||
let path = it.next().ok_or_else(|| "stat needs <path>".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 <path>"))?;
|
||||
let keyc = it
|
||||
.next()
|
||||
.ok_or_else(|| format!("{cmd} needs <K.Z.Y.X> key cell"))?;
|
||||
let tf = it
|
||||
.next()
|
||||
.ok_or_else(|| format!("{cmd} needs <transform>"))?;
|
||||
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<u8> {
|
||||
if let Ok(v) = t.parse::<u8>() {
|
||||
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<cubecoords::Czyx> {
|
||||
let parts: Vec<&str> = s.split('.').collect();
|
||||
if parts.len() != 4 {
|
||||
return None;
|
||||
}
|
||||
let nums: Option<Vec<u8>> = parts.iter().map(|p| p.parse::<u8>().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<Op, String> {
|
||||
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<TransformId> {
|
||||
match s {
|
||||
"none" => Some(TransformId::None),
|
||||
"gcm" => Some(TransformId::Aes256Gcm),
|
||||
"chacha" => Some(TransformId::ChaCha20Poly1305),
|
||||
"xts" => Some(TransformId::Aes256Xts),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
//! `cubec` — the CUBELinux-2 system client.
|
||||
//!
|
||||
//! Talks to `cube-server` over its Unix-domain socket. Two modes:
|
||||
//!
|
||||
//! cubec <command...> 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<String> = 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<String> = 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<String, String> {
|
||||
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();
|
||||
}
|
||||
@@ -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<HashBackend>,
|
||||
}
|
||||
|
||||
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<HashBackend> {
|
||||
&self.store
|
||||
}
|
||||
|
||||
/// Mutable access to the underlying store (used by persistence to load).
|
||||
pub fn store_mut(&mut self) -> &mut CubeStore<HashBackend> {
|
||||
&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<String, String> {
|
||||
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 <path>".to_string())?;
|
||||
let mut ops: Vec<Op> = Vec::new();
|
||||
while let Some(tok) = it.next() {
|
||||
let arg = if takes_arg(tok) {
|
||||
it.next()
|
||||
.and_then(|a| a.parse::<u8>().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 <path>".to_string())?;
|
||||
let bytes: Vec<u8> = it
|
||||
.map(parse_byte)
|
||||
.collect::<Option<_>>()
|
||||
.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 <path>".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 <dir>".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<String> = entries.into_iter().map(|(n, _)| n).collect();
|
||||
Ok(format!("ls {dir} -> {}", names.join(" ")))
|
||||
}
|
||||
}
|
||||
"stat" => {
|
||||
let path = it.next().ok_or_else(|| "stat needs <path>".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 <path>"))?;
|
||||
let keyc = it
|
||||
.next()
|
||||
.ok_or_else(|| format!("{cmd} needs <K.Z.Y.X> key cell"))?;
|
||||
let tf = it
|
||||
.next()
|
||||
.ok_or_else(|| format!("{cmd} needs <transform>"))?;
|
||||
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<u8> {
|
||||
if let Ok(v) = t.parse::<u8>() {
|
||||
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<cubecoords::Czyx> {
|
||||
let parts: Vec<&str> = s.split('.').collect();
|
||||
if parts.len() != 4 {
|
||||
return None;
|
||||
}
|
||||
let nums: Option<Vec<u8>> = parts.iter().map(|p| p.parse::<u8>().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<Op, String> {
|
||||
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<TransformId> {
|
||||
match s {
|
||||
"none" => Some(TransformId::None),
|
||||
"gcm" => Some(TransformId::Aes256Gcm),
|
||||
"chacha" => Some(TransformId::ChaCha20Poly1305),
|
||||
"xts" => Some(TransformId::Aes256Xts),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: Write>(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: Read>(r: &mut R) -> std::io::Result<String> {
|
||||
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<String> {
|
||||
read_frame(stream)
|
||||
}
|
||||
@@ -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