From a308f8422deaff4ed7161ad4169b5c29d68dd03c Mon Sep 17 00:00:00 2001 From: CUBELinux-2 Date: Mon, 10 Aug 2026 19:23:59 -0400 Subject: [PATCH] =?UTF-8?q?feat(cubelinux-2):=20Package=203=20=E2=80=94=20?= =?UTF-8?q?cubefs=20(POSIX/FUSE=20namespace=20over=20CZYX)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the PDF's Package 3 with new code: - path: bijective POSIX path <-> Czyx mapping (/c001/z002/y003/x004). Axis-letter + 3-digit zero-padded canonical names so lexical order equals numeric order and each coordinate has exactly one spelling. Inode IS the packed u32 coordinate — no inode side table. - nullspace: the PDF's 'use Null cubes for ACLs, xattrs, journaling, volume metadata', with the Z-plane allocation fixed and documented (Z=1 volume, Z=2 ACL, Z=3 xattr, Z=4 journal ring). ACL/xattr tables are FNV hash-bucketed with exact-match resolution inside the bucket, because 4 axes of subject cannot injectively mirror into 2 axes of Null space. Journal is a bounded ring; wraps are detectable via a monotonic counter. - vfs: the whole filesystem, kernel-free and unit-testable — lookup, readdir, create/read/write/truncate/unlink, mkdir/rmdir, ACL enforcement, xattrs, journaling, POSIX errno mapping. - fuse (feature 'mount'): thin kernel adapter, zero TTL (the store is writable out-of-band, so cached metadata would go stale). - cubestore: added the PDF's 'optional scanning primitives' (keys, scan_prefix) and the Package 2 association API (associate, linked_to) that cubefs needs for directory listings. Two defects were found by LIVE MOUNT testing and fixed, not by unit tests: 1. mkdir succeeded then the kernel's revalidating lookup returned ENOENT, so 'mkdir -p' could never reach depth 4. Directories were purely inferred from records, making an empty directory unrepresentable. Fixed with an explicit Null-space directory marker; rmdir removes it; readdir merges markers in. 5 regression tests added. 2. Multi-user ACL behaviour was untestable because the mount lacked AllowOther — the kernel returned EACCES at the mountpoint before any request reached us. Added --allow-other. Verified: 58 unit tests pass; clippy clean; live mount exercised with cat, echo, dd, truncate, cp, chmod, chown, getfattr/setfattr, mkdir -p, rmdir, find, a 200-record write loop, and cross-user reads/writes as luulu. --- .gitignore | 1 + Cargo.toml | 1 + cubecoords/src/lib.rs | 5 +- cubefs/Cargo.toml | 25 + cubefs/src/bin/cubefs_mount.rs | 85 +++ cubefs/src/fuse.rs | 562 +++++++++++++++++++ cubefs/src/lib.rs | 51 ++ cubefs/src/nullspace.rs | 782 ++++++++++++++++++++++++++ cubefs/src/path.rs | 325 +++++++++++ cubefs/src/vfs.rs | 968 +++++++++++++++++++++++++++++++++ cubestore/src/lib.rs | 84 +++ 11 files changed, 2885 insertions(+), 4 deletions(-) create mode 100644 cubefs/Cargo.toml create mode 100644 cubefs/src/bin/cubefs_mount.rs create mode 100644 cubefs/src/fuse.rs create mode 100644 cubefs/src/lib.rs create mode 100644 cubefs/src/nullspace.rs create mode 100644 cubefs/src/path.rs create mode 100644 cubefs/src/vfs.rs diff --git a/.gitignore b/.gitignore index 96ef6c0..f2e56d3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /target Cargo.lock +target/ diff --git a/Cargo.toml b/Cargo.toml index 2c787ef..4d0547d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ resolver = "2" members = [ "cubecoords", "cubestore", + "cubefs", ] [workspace.package] diff --git a/cubecoords/src/lib.rs b/cubecoords/src/lib.rs index 6e6da7a..5703acf 100644 --- a/cubecoords/src/lib.rs +++ b/cubecoords/src/lib.rs @@ -52,10 +52,7 @@ impl Czyx { /// Pack into a single `u32` with `C` in the high byte, `X` in the low byte. #[inline] pub fn pack_u32(&self) -> u32 { - ((self.c as u32) << 24) - | ((self.z as u32) << 16) - | ((self.y as u32) << 8) - | (self.x as u32) + ((self.c as u32) << 24) | ((self.z as u32) << 16) | ((self.y as u32) << 8) | (self.x as u32) } /// Unpack a `u32` produced by [`pack_u32`]. diff --git a/cubefs/Cargo.toml b/cubefs/Cargo.toml new file mode 100644 index 0000000..89978f1 --- /dev/null +++ b/cubefs/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "cubefs" +version = "0.1.0" +edition.workspace = true +license.workspace = true +description = "POSIX/FUSE namespace over the CUBELinux CZYX coordinate space (PDF Package 3)" + +[dependencies] +cubecoords = { path = "../cubecoords" } +cubestore = { path = "../cubestore" } +fuser = { version = "0.16", optional = true } +libc = { version = "0.2", optional = true } + +[features] +# Decision: the FUSE adapter is behind a feature so the pure mapping/ACL/ +# journal logic (the part that must be correct) can be built and tested on any +# machine without libfuse3 or a mountable /dev/fuse. `--features mount` pulls +# in fuser and the kernel-facing adapter. +default = [] +mount = ["dep:fuser", "dep:libc"] + +[[bin]] +name = "cubefs-mount" +path = "src/bin/cubefs_mount.rs" +required-features = ["mount"] diff --git a/cubefs/src/bin/cubefs_mount.rs b/cubefs/src/bin/cubefs_mount.rs new file mode 100644 index 0000000..c94a0e0 --- /dev/null +++ b/cubefs/src/bin/cubefs_mount.rs @@ -0,0 +1,85 @@ +//! `cubefs-mount` — mount a cube as a POSIX filesystem. +//! +//! Usage: `cubefs-mount [--label NAME] [--seed] [--allow-other]` +//! +//! `--allow-other` lets users other than the mounting user reach the +//! filesystem. Without it the kernel rejects them at the mountpoint before any +//! request reaches us, so multi-user ACL behaviour cannot be observed. +//! +//! The backing store is the in-memory [`HashBackend`] for now: Package 3's job +//! is the *namespace mapping*, and a durable on-disk backend is a cubestore +//! concern that gets swapped in by changing one type parameter here. `--seed` +//! populates a few records so the mount has something to `ls`. + +use cubefs::fuse::CubeFuse; +use cubefs::CubeFs; +use cubestore::{CubeStore, HashBackend}; +use std::process::ExitCode; + +fn main() -> ExitCode { + let args: Vec = std::env::args().collect(); + let Some(mountpoint) = args.get(1).filter(|a| !a.starts_with("--")) else { + eprintln!("usage: cubefs-mount [--label NAME] [--seed]"); + return ExitCode::from(2); + }; + let label = args + .iter() + .position(|a| a == "--label") + .and_then(|i| args.get(i + 1)) + .cloned() + .unwrap_or_else(|| "cube0".to_string()); + let seed = args.iter().any(|a| a == "--seed"); + let allow_other = args.iter().any(|a| a == "--allow-other"); + + let mut fs = CubeFs::new(CubeStore::new(HashBackend::new())); + fs.format(&label); + + if seed { + for (path, body) in [ + ("/c001/z001/y001/x001", &b"hello from the cube\n"[..]), + ("/c001/z001/y001/x002", &b"second record\n"[..]), + ("/c001/z002/y001/x001", &b"different z\n"[..]), + ("/c002/z001/y001/x001", &b"different c\n"[..]), + ] { + if let Err(e) = fs.create(path, 0, 0, 0o644) { + eprintln!("seed create {path}: {e:?}"); + return ExitCode::FAILURE; + } + if let Err(e) = fs.write(path, 0, body, 0, 0) { + eprintln!("seed write {path}: {e:?}"); + return ExitCode::FAILURE; + } + } + if let Err(e) = fs.setxattr("/c001/z001/y001/x001", "user.origin", b"seed") { + eprintln!("seed xattr: {e:?}"); + return ExitCode::FAILURE; + } + } + + // Decision: `DefaultPermissions` asks the kernel to enforce the mode bits + // we report, in addition to our own ACL check in `vfs`. Belt and braces: + // the kernel check protects against a bug in our check, and our check + // protects the library API (which is reachable without a mount). + // `AllowOther` is required for any user other than the mounting user to + // see the filesystem at all — without it the kernel returns EACCES on the + // mountpoint itself before a single request reaches us, which is exactly + // what live testing showed. + let mut opts = vec![ + fuser::MountOption::FSName("cubefs".to_string()), + fuser::MountOption::AutoUnmount, + fuser::MountOption::DefaultPermissions, + ]; + if allow_other { + opts.push(fuser::MountOption::AllowOther); + } + eprintln!( + "mounting cubefs at {mountpoint} (label={label}, seed={seed}, allow_other={allow_other}) — ctrl-c to unmount" + ); + match fuser::mount2(CubeFuse::new(fs), mountpoint, &opts) { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("mount failed: {e}"); + ExitCode::FAILURE + } + } +} diff --git a/cubefs/src/fuse.rs b/cubefs/src/fuse.rs new file mode 100644 index 0000000..a9d25bd --- /dev/null +++ b/cubefs/src/fuse.rs @@ -0,0 +1,562 @@ +//! FUSE adapter (feature `mount`). +//! +//! This layer is deliberately thin: it translates kernel FUSE requests into +//! [`crate::vfs::CubeFs`] calls and errnos, and does nothing else. All +//! filesystem semantics live in `vfs` so they remain testable without a +//! mount. If you are looking for how the filesystem *behaves*, read `vfs.rs`. +//! +//! # Decision: paths are reconstructed from inodes, not cached +//! +//! FUSE addresses objects by inode, and most filesystems keep an inode -> +//! path cache. Here the inode IS the coordinate (see [`crate::path`]), so the +//! path is recomputed in O(1) with no cache to invalidate. The cost is that a +//! `lookup` must re-derive the child coordinate from the parent inode plus the +//! name, which is a parse of at most four characters. +//! +//! # Decision: TTLs are zero +//! +//! The kernel is told not to cache attributes or entries. The store is +//! writable by other processes through the library API (the cube is not +//! exclusively owned by the mount), so a nonzero TTL would let the kernel +//! serve stale metadata after an out-of-band write. Correctness first; +//! caching can be reconsidered when the store grows a change-notification +//! channel. + +use crate::nullspace::Acl; +use crate::path::{self, AXIS_LETTERS, ROOT_INO}; +use crate::vfs::{Attr, CubeFs, FsError, Kind}; +use cubecoords::Czyx; +use cubestore::CubeBackend; +use fuser::{ + FileAttr, FileType, Filesystem, ReplyAttr, ReplyCreate, ReplyData, ReplyDirectory, ReplyEmpty, + ReplyEntry, ReplyOpen, ReplyWrite, ReplyXattr, Request, +}; +use std::ffi::OsStr; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +const TTL: Duration = Duration::from_secs(0); + +/// Depth (number of axes fixed) implied by an inode. +/// +/// A coordinate with trailing zeros is a directory prefix; the depth is the +/// count of leading non-zero axes. `(1,2,0,0)` is depth 2 = `/c001/z002`. +fn depth_of(c: Czyx) -> usize { + let a = [c.c, c.z, c.y, c.x]; + let mut d = 0; + for v in a { + if v == 0 { + break; + } + d += 1; + } + d +} + +/// Axis values of an inode, truncated at the first zero. +fn axes_of(c: Czyx) -> Vec { + let a = [c.c, c.z, c.y, c.x]; + a.into_iter().take_while(|v| *v != 0).collect() +} + +/// Absolute path for an inode, or `None` if the inode is not a namespace +/// object (e.g. a Null-space coordinate the kernel should never have). +fn path_of(ino: u64) -> Option { + if ino == ROOT_INO { + return Some("/".to_string()); + } + let c = path::ino_to_czyx(ino)?; + if c.c == 0 { + return None; // Null space is not in the namespace + } + Some(path::render_path(&axes_of(c))) +} + +fn errno(e: &FsError) -> i32 { + e.errno() +} + +fn to_file_attr(a: &Attr) -> FileAttr { + let t = UNIX_EPOCH + Duration::from_secs(a.created_at); + FileAttr { + ino: if a.ino == 0 { ROOT_INO } else { a.ino }, + size: a.size, + blocks: a.size.div_ceil(512), + atime: t, + mtime: t, + ctime: t, + crtime: t, + kind: match a.kind { + Kind::Directory => FileType::Directory, + Kind::File => FileType::RegularFile, + }, + perm: a.mode, + nlink: a.nlink, + uid: a.uid, + gid: a.gid, + rdev: 0, + blksize: 4096, + flags: 0, + } +} + +/// The FUSE filesystem. +pub struct CubeFuse { + fs: CubeFs, +} + +impl CubeFuse { + /// Wrap a [`CubeFs`]. + pub fn new(fs: CubeFs) -> Self { + CubeFuse { fs } + } + + /// Child path under `parent` inode with component `name`, validated + /// against the axis letter required at that depth. + fn child_path(&self, parent: u64, name: &OsStr) -> Result { + let base = path_of(parent).ok_or(FsError::NotFound)?; + let name = name + .to_str() + .ok_or_else(|| FsError::Invalid("non-utf8 name".into()))?; + let depth = if parent == ROOT_INO { + 0 + } else { + depth_of(path::ino_to_czyx(parent).ok_or(FsError::NotFound)?) + }; + if depth >= 4 { + return Err(FsError::WrongKind { + expected: Kind::Directory, + actual: Kind::File, + }); + } + // Reject early with a clear errno rather than building a path the + // parser will refuse anyway. + if name.as_bytes().first().copied() != Some(AXIS_LETTERS[depth]) { + return Err(FsError::NotFound); + } + Ok(if base == "/" { + format!("/{name}") + } else { + format!("{base}/{name}") + }) + } +} + +impl Filesystem for CubeFuse { + fn lookup(&mut self, _req: &Request<'_>, parent: u64, name: &OsStr, reply: ReplyEntry) { + match self + .child_path(parent, name) + .and_then(|p| self.fs.getattr(&p)) + { + Ok(a) => reply.entry(&TTL, &to_file_attr(&a), 0), + Err(e) => reply.error(errno(&e)), + } + } + + fn getattr(&mut self, _req: &Request<'_>, ino: u64, _fh: Option, reply: ReplyAttr) { + let Some(p) = path_of(ino) else { + reply.error(2); + return; + }; + match self.fs.getattr(&p) { + Ok(a) => reply.attr(&TTL, &to_file_attr(&a)), + Err(e) => reply.error(errno(&e)), + } + } + + #[allow(clippy::too_many_arguments)] + fn setattr( + &mut self, + req: &Request<'_>, + ino: u64, + mode: Option, + uid: Option, + gid: Option, + size: Option, + _atime: Option, + _mtime: Option, + _ctime: Option, + _fh: Option, + _crtime: Option, + _chgtime: Option, + _bkuptime: Option, + _flags: Option, + reply: ReplyAttr, + ) { + let Some(p) = path_of(ino) else { + reply.error(2); + return; + }; + if let Some(sz) = size { + if let Err(e) = self.fs.truncate(&p, sz, req.uid(), req.gid()) { + reply.error(errno(&e)); + return; + } + } + if mode.is_some() || uid.is_some() || gid.is_some() { + let cur = match self.fs.getattr(&p) { + Ok(a) => a, + Err(e) => { + reply.error(errno(&e)); + return; + } + }; + let acl = Acl { + uid: uid.unwrap_or(cur.uid), + gid: gid.unwrap_or(cur.gid), + mode: mode.map(|m| (m & 0o7777) as u16).unwrap_or(cur.mode), + }; + if let Err(e) = self.fs.set_acl(&p, acl) { + reply.error(errno(&e)); + return; + } + } + match self.fs.getattr(&p) { + Ok(a) => reply.attr(&TTL, &to_file_attr(&a)), + Err(e) => reply.error(errno(&e)), + } + } + + fn readdir( + &mut self, + _req: &Request<'_>, + ino: u64, + _fh: u64, + offset: i64, + mut reply: ReplyDirectory, + ) { + let Some(p) = path_of(ino) else { + reply.error(2); + return; + }; + let entries = match self.fs.readdir(&p) { + Ok(e) => e, + Err(e) => { + reply.error(errno(&e)); + return; + } + }; + let mut all: Vec<(u64, FileType, String)> = vec![ + (ino, FileType::Directory, ".".to_string()), + (ino, FileType::Directory, "..".to_string()), + ]; + let base_axes = if ino == ROOT_INO { + Vec::new() + } else { + axes_of(path::ino_to_czyx(ino).unwrap_or_default()) + }; + for (name, kind) in entries { + // Recompute the child's coordinate for its inode. + let child_path = if p == "/" { + format!("/{name}") + } else { + format!("{p}/{name}") + }; + let child_ino = match path::parse_path(&child_path) { + Ok(pp) => path::czyx_to_ino(pp.prefix_coord()), + Err(_) => continue, + }; + let _ = &base_axes; + all.push(( + child_ino, + match kind { + Kind::Directory => FileType::Directory, + Kind::File => FileType::RegularFile, + }, + name, + )); + } + for (i, (cino, ktype, name)) in all.into_iter().enumerate().skip(offset as usize) { + if reply.add(cino, (i + 1) as i64, ktype, name) { + break; // buffer full + } + } + reply.ok(); + } + + fn open(&mut self, _req: &Request<'_>, _ino: u64, _flags: i32, reply: ReplyOpen) { + // No per-handle state: the coordinate is the handle. + reply.opened(0, 0); + } + + fn read( + &mut self, + req: &Request<'_>, + ino: u64, + _fh: u64, + offset: i64, + size: u32, + _flags: i32, + _lock: Option, + reply: ReplyData, + ) { + let Some(p) = path_of(ino) else { + reply.error(2); + return; + }; + match self + .fs + .read(&p, offset.max(0) as u64, size, req.uid(), req.gid()) + { + Ok(d) => reply.data(&d), + Err(e) => reply.error(errno(&e)), + } + } + + #[allow(clippy::too_many_arguments)] + fn write( + &mut self, + req: &Request<'_>, + ino: u64, + _fh: u64, + offset: i64, + data: &[u8], + _write_flags: u32, + _flags: i32, + _lock: Option, + reply: ReplyWrite, + ) { + let Some(p) = path_of(ino) else { + reply.error(2); + return; + }; + match self + .fs + .write(&p, offset.max(0) as u64, data, req.uid(), req.gid()) + { + Ok(n) => reply.written(n), + Err(e) => reply.error(errno(&e)), + } + } + + fn create( + &mut self, + req: &Request<'_>, + parent: u64, + name: &OsStr, + mode: u32, + _umask: u32, + _flags: i32, + reply: ReplyCreate, + ) { + let p = match self.child_path(parent, name) { + Ok(p) => p, + Err(e) => { + reply.error(errno(&e)); + return; + } + }; + match self + .fs + .create(&p, req.uid(), req.gid(), (mode & 0o7777) as u16) + { + Ok(a) => reply.created(&TTL, &to_file_attr(&a), 0, 0, 0), + Err(e) => reply.error(errno(&e)), + } + } + + fn mknod( + &mut self, + req: &Request<'_>, + parent: u64, + name: &OsStr, + mode: u32, + _umask: u32, + _rdev: u32, + reply: ReplyEntry, + ) { + let p = match self.child_path(parent, name) { + Ok(p) => p, + Err(e) => { + reply.error(errno(&e)); + return; + } + }; + match self + .fs + .create(&p, req.uid(), req.gid(), (mode & 0o7777) as u16) + { + Ok(a) => reply.entry(&TTL, &to_file_attr(&a), 0), + Err(e) => reply.error(errno(&e)), + } + } + + fn unlink(&mut self, req: &Request<'_>, parent: u64, name: &OsStr, reply: ReplyEmpty) { + let p = match self.child_path(parent, name) { + Ok(p) => p, + Err(e) => { + reply.error(errno(&e)); + return; + } + }; + match self.fs.unlink(&p, req.uid(), req.gid()) { + Ok(()) => reply.ok(), + Err(e) => reply.error(errno(&e)), + } + } + + fn mkdir( + &mut self, + req: &Request<'_>, + parent: u64, + name: &OsStr, + mode: u32, + _umask: u32, + reply: ReplyEntry, + ) { + let p = match self.child_path(parent, name) { + Ok(p) => p, + Err(e) => { + reply.error(errno(&e)); + return; + } + }; + if let Err(e) = self + .fs + .mkdir(&p, req.uid(), req.gid(), (mode & 0o7777) as u16) + { + reply.error(errno(&e)); + return; + } + // A synthetic directory has no records yet, so getattr would report + // ENOENT. Report the attributes the directory *will* have; see the + // module docs on synthetic directories. + let Ok(parsed) = path::parse_path(&p) else { + reply.error(22); + return; + }; + let a = Attr { + ino: path::czyx_to_ino(parsed.prefix_coord()), + kind: Kind::Directory, + size: 0, + mode: (mode & 0o7777) as u16, + uid: req.uid(), + gid: req.gid(), + created_at: 0, + nlink: 2, + }; + reply.entry(&TTL, &to_file_attr(&a), 0); + } + + fn rmdir(&mut self, _req: &Request<'_>, parent: u64, name: &OsStr, reply: ReplyEmpty) { + let p = match self.child_path(parent, name) { + Ok(p) => p, + Err(e) => { + reply.error(errno(&e)); + return; + } + }; + match self.fs.rmdir(&p) { + Ok(()) => reply.ok(), + Err(e) => reply.error(errno(&e)), + } + } + + fn setxattr( + &mut self, + _req: &Request<'_>, + ino: u64, + name: &OsStr, + value: &[u8], + _flags: i32, + _position: u32, + reply: ReplyEmpty, + ) { + let (Some(p), Some(n)) = (path_of(ino), name.to_str()) else { + reply.error(22); + return; + }; + match self.fs.setxattr(&p, n, value) { + Ok(()) => reply.ok(), + Err(e) => reply.error(errno(&e)), + } + } + + fn getxattr( + &mut self, + _req: &Request<'_>, + ino: u64, + name: &OsStr, + size: u32, + reply: ReplyXattr, + ) { + let (Some(p), Some(n)) = (path_of(ino), name.to_str()) else { + reply.error(22); + return; + }; + match self.fs.getxattr(&p, n) { + // size == 0 is the kernel asking how big the value is. + Ok(v) if size == 0 => reply.size(v.len() as u32), + Ok(v) if (v.len() as u32) <= size => reply.data(&v), + Ok(_) => reply.error(34), // ERANGE + // ENODATA (61) is the correct errno for a missing xattr, not ENOENT. + Err(FsError::NotFound) => reply.error(61), + Err(e) => reply.error(errno(&e)), + } + } + + fn listxattr(&mut self, _req: &Request<'_>, ino: u64, size: u32, reply: ReplyXattr) { + let Some(p) = path_of(ino) else { + reply.error(22); + return; + }; + match self.fs.listxattr(&p) { + Ok(names) => { + // The kernel wants NUL-terminated names concatenated. + let mut buf = Vec::new(); + for n in names { + buf.extend_from_slice(n.as_bytes()); + buf.push(0); + } + if size == 0 { + reply.size(buf.len() as u32); + } else if (buf.len() as u32) <= size { + reply.data(&buf); + } else { + reply.error(34); // ERANGE + } + } + Err(e) => reply.error(errno(&e)), + } + } + + fn removexattr(&mut self, _req: &Request<'_>, ino: u64, name: &OsStr, reply: ReplyEmpty) { + let (Some(p), Some(n)) = (path_of(ino), name.to_str()) else { + reply.error(22); + return; + }; + match self.fs.removexattr(&p, n) { + Ok(()) => reply.ok(), + Err(FsError::NotFound) => reply.error(61), // ENODATA + Err(e) => reply.error(errno(&e)), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn depth_and_axes_stop_at_first_zero() { + assert_eq!(depth_of(Czyx::new(1, 2, 0, 0)), 2); + assert_eq!(axes_of(Czyx::new(1, 2, 0, 0)), vec![1, 2]); + assert_eq!(depth_of(Czyx::new(1, 2, 3, 4)), 4); + assert_eq!(depth_of(Czyx::new(0, 0, 0, 0)), 0); + } + + #[test] + fn path_of_root_and_records() { + assert_eq!(path_of(ROOT_INO).as_deref(), Some("/")); + let ino = path::czyx_to_ino(Czyx::new(1, 2, 3, 4)); + assert_eq!(path_of(ino).as_deref(), Some("/c001/z002/y003/x004")); + let dir = path::czyx_to_ino(Czyx::new(1, 2, 0, 0)); + assert_eq!(path_of(dir).as_deref(), Some("/c001/z002")); + } + + #[test] + fn null_space_inodes_are_not_in_the_namespace() { + // Any C=0 coordinate other than the reserved root must be invisible. + let ino = path::czyx_to_ino(Czyx::new(0, 2, 5, 9)); + assert!(path_of(ino).is_none()); + } +} diff --git a/cubefs/src/lib.rs b/cubefs/src/lib.rs new file mode 100644 index 0000000..9a27166 --- /dev/null +++ b/cubefs/src/lib.rs @@ -0,0 +1,51 @@ +//! CUBELinux-2 Package 3 — `cubefs`: a POSIX namespace over CZYX. +//! +//! Built NEW from the PDF spec (Package 3, p. "cubefs (filesystem or virtual +//! FS)"). Not recycled from the prior `/home/CUBELinux` build, which had no +//! filesystem layer at all. +//! +//! # What the PDF asks for +//! +//! > Map C/Z/Y/X ranges to top-level directories (C), subdirs (Z,Y) and files +//! > (X), or keep the 4-D API but expose FUSE hooks for POSIX compatibility. +//! > Use Null cubes/rows for: ACLs, extended attributes, journaling, volume +//! > metadata. +//! +//! # Structure of this crate +//! +//! * [`path`] — the pure, dependency-free bijection between POSIX paths and +//! [`Czyx`] coordinates. This is the part that must be provably correct, so +//! it is testable without a kernel mount. +//! * [`nullspace`] — the Null-cube control plane: ACLs, xattrs, the journal, +//! and volume metadata, each pinned to a documented `C=0` sub-cube. +//! * [`vfs`] — the backend-agnostic filesystem operations (lookup, readdir, +//! read, write, getattr, xattr, ACL check) expressed over a [`CubeStore`]. +//! * `fuse` (feature `mount`) — the thin adapter that translates kernel FUSE +//! calls into [`vfs`] calls. Feature-gated so the logic above builds and +//! tests anywhere. +//! +//! # Decision: why a bijection and not an inode table +//! +//! A conventional FUSE filesystem allocates opaque inode numbers and keeps a +//! side table mapping inode -> object. CUBELinux's whole premise is that the +//! coordinate *is* the address, so allocating a second, unrelated identifier +//! space would reintroduce exactly the indirection the design removes. +//! Instead the inode number IS the packed `u32` coordinate (widened to u64), +//! so `ino <-> Czyx` is total, stateless, and needs no table. The FUSE root +//! inode is required by the kernel to be 1, and `Czyx::unpack_u32(1)` is +//! `(0,0,0,1)` — a Null cube, never a user record — so the reservation costs +//! us no addressable user space. See [`path::ino_to_czyx`]. + +#![forbid(unsafe_code)] +#![warn(missing_docs)] + +pub mod nullspace; +pub mod path; +pub mod vfs; + +#[cfg(feature = "mount")] +pub mod fuse; + +pub use nullspace::{Acl, JournalEntry, JournalOp, NullSpace, VolumeMeta}; +pub use path::{czyx_to_ino, ino_to_czyx, parse_path, render_path, PathError, ROOT_INO}; +pub use vfs::{Attr, CubeFs, FsError, Kind}; diff --git a/cubefs/src/nullspace.rs b/cubefs/src/nullspace.rs new file mode 100644 index 0000000..ddde1a3 --- /dev/null +++ b/cubefs/src/nullspace.rs @@ -0,0 +1,782 @@ +//! The Null control plane: ACLs, extended attributes, journal, volume meta. +//! +//! The PDF (Package 3) says: *"Use Null cubes/rows for: ACLs, extended +//! attributes, journaling, volume metadata."* It does not say **which** Null +//! cube holds which. This module fixes that allocation and documents it, so +//! the layout is stable across future packages. +//! +//! # Null-space allocation (a CUBELinux-2 decision, not from the PDF) +//! +//! All control records live at `C = 0`. `cubecoords::NullClass` already +//! distinguishes Total Null (`0,0,0,0`) from the Null-cube family. We +//! subdivide the family by the **Z** axis, because Z is the first axis after +//! C and leaves Y/X free to carry the *subject* of the control record: +//! +//! ```text +//! C=0 Z=0 Y=0 X=0 Total Null — end-of-record / deletion marker +//! C=0 Z=0 Y=0 X=1 root inode — reserved by FUSE, see path::ROOT_INO +//! C=0 Z=1 Y=* X=* VOLUME METADATA — Y/X select the metadata field +//! C=0 Z=2 Y=* X=* ACL TABLE — Y/X = high/low byte of subject hash +//! C=0 Z=3 Y=* X=* XATTR TABLE — Y/X = high/low byte of subject hash +//! C=0 Z=4 Y=* X=* JOURNAL — Y = segment, X = slot (ring buffer) +//! C=0 Z=5..=255 reserved for later packages (cubecrypt env selectors) +//! ``` +//! +//! ## Decision: why hash the subject instead of mirroring its coordinate +//! +//! The obvious layout would be "the ACL for record `(c,z,y,x)` lives at +//! `(0,2,?,?)` mirroring the record's own position" — but a record has four +//! axes of subject and only two axes (Y,X = 65,536 slots) of room inside a +//! Null cube. There is no injective mirror. So the ACL/xattr tables are +//! **hash-bucketed**: the subject coordinate is hashed into 16 bits, and the +//! bucket record stores a list of `(subject, payload)` entries so collisions +//! are resolved by exact comparison inside the bucket. This keeps the control +//! plane inside Null space (as the PDF requires) while remaining correct for +//! the full 4.2-billion-cell record space. +//! +//! ## Decision: the journal is a ring, and it is bounded +//! +//! `Z=4` gives 255 Y-segments x 255 X-slots = 65,025 journal entries before +//! wrap. A filesystem journal must be bounded or it becomes the filesystem. +//! Wrapping is explicit and observable via [`VolumeMeta::journal_seq`], so a +//! reader can detect that it missed entries rather than silently seeing a +//! gap. (Note: this is a *metadata* journal for crash-consistency of the +//! control plane, not a data journal — record bodies are written by the +//! backend's own durability path.) + +use cubecoords::{CubeHeader, Czyx}; +use cubestore::{CubeBackend, CubeStore}; + +/// Z-axis allocation inside the Null cube family. +pub mod z { + /// Volume metadata. + pub const VOLUME: u8 = 1; + /// ACL table. + pub const ACL: u8 = 2; + /// Extended attribute table. + pub const XATTR: u8 = 3; + /// Metadata journal. + pub const JOURNAL: u8 = 4; +} + +/// A POSIX-ish access control entry for one subject coordinate. +/// +/// Decision: we store `uid`/`gid`/`mode` rather than a full POSIX.1e ACL list +/// at this package. The PDF's own header model is owner-centric +/// (`owner_local_user`, `owner_remote_user`, root-only / local / remote +/// permission flags), so a full ACL list would be inventing a model the rest +/// of the system cannot express yet. `Acl` is a struct (not a tuple) so +/// extending it to a list later is source-compatible. +#[derive(Clone, Copy, Eq, PartialEq, Debug)] +pub struct Acl { + /// Owning uid. + pub uid: u32, + /// Owning gid. + pub gid: u32, + /// POSIX mode bits (permission bits only; the file type is derived from + /// whether the coordinate is a record or a prefix). + pub mode: u16, +} + +impl Default for Acl { + /// Default: owned by root, `0644`. + fn default() -> Self { + Acl { + uid: 0, + gid: 0, + mode: 0o644, + } + } +} + +impl Acl { + /// Check a POSIX-style access request. + /// + /// `want` uses the low three bits `rwx` (4/2/1), as in `access(2)`. + /// Root (uid 0) always passes, matching Linux `CAP_DAC_OVERRIDE`, which is + /// also how the surrounding system already behaves. + pub fn permits(&self, uid: u32, gid: u32, want: u16) -> bool { + if uid == 0 { + return true; + } + let bits = if uid == self.uid { + (self.mode >> 6) & 0o7 + } else if gid == self.gid { + (self.mode >> 3) & 0o7 + } else { + self.mode & 0o7 + }; + bits & want == want + } +} + +/// Volume-level metadata, stored at `C=0, Z=1`. +#[derive(Clone, Eq, PartialEq, Debug, Default)] +pub struct VolumeMeta { + /// Human label for the volume. + pub label: String, + /// On-disk layout version of the Null-space allocation above. + pub layout_version: u32, + /// Monotonic journal sequence number. Increments once per appended + /// entry and never resets, so a wrap of the ring is detectable. + pub journal_seq: u64, +} + +/// The kind of operation recorded in the metadata journal. +#[derive(Clone, Copy, Eq, PartialEq, Debug)] +pub enum JournalOp { + /// A record was created. + Create, + /// A record body was written. + Write, + /// A record was removed. + Remove, + /// An ACL was changed. + SetAcl, + /// An extended attribute was set. + SetXattr, + /// An extended attribute was removed. + RemoveXattr, +} + +impl JournalOp { + fn tag(self) -> u8 { + match self { + JournalOp::Create => 1, + JournalOp::Write => 2, + JournalOp::Remove => 3, + JournalOp::SetAcl => 4, + JournalOp::SetXattr => 5, + JournalOp::RemoveXattr => 6, + } + } + fn from_tag(t: u8) -> Option { + Some(match t { + 1 => JournalOp::Create, + 2 => JournalOp::Write, + 3 => JournalOp::Remove, + 4 => JournalOp::SetAcl, + 5 => JournalOp::SetXattr, + 6 => JournalOp::RemoveXattr, + _ => return None, + }) + } +} + +/// One journal record. +#[derive(Clone, Eq, PartialEq, Debug)] +pub struct JournalEntry { + /// Monotonic sequence number (see [`VolumeMeta::journal_seq`]). + pub seq: u64, + /// What happened. + pub op: JournalOp, + /// Which coordinate it happened to. + pub subject: Czyx, +} + +// --- tiny dependency-free codecs ----------------------------------------- +// +// Decision: CUBELinux-2 stays dependency-free through Package 3 (the only +// external crate is `fuser`, and only under the `mount` feature). So each +// control structure gets a small explicit byte encoding here rather than a +// serde derive. These are private and covered by round-trip tests. + +fn put_u32(o: &mut Vec, v: u32) { + o.extend_from_slice(&v.to_le_bytes()); +} +fn put_u64(o: &mut Vec, v: u64) { + o.extend_from_slice(&v.to_le_bytes()); +} +fn put_bytes(o: &mut Vec, b: &[u8]) { + put_u32(o, b.len() as u32); + o.extend_from_slice(b); +} +fn take_u32(b: &[u8]) -> Option<(u32, &[u8])> { + if b.len() < 4 { + return None; + } + let mut a = [0u8; 4]; + a.copy_from_slice(&b[..4]); + Some((u32::from_le_bytes(a), &b[4..])) +} +fn take_u64(b: &[u8]) -> Option<(u64, &[u8])> { + if b.len() < 8 { + return None; + } + let mut a = [0u8; 8]; + a.copy_from_slice(&b[..8]); + Some((u64::from_le_bytes(a), &b[8..])) +} +fn take_bytes(b: &[u8]) -> Option<(Vec, &[u8])> { + let (n, r) = take_u32(b)?; + let n = n as usize; + if r.len() < n { + return None; + } + Some((r[..n].to_vec(), &r[n..])) +} + +/// 16-bit bucket hash of a subject coordinate. +/// +/// FNV-1a over the four axis bytes, folded to 16 bits. Chosen over the packed +/// `u32`'s low 16 bits because those are just `(y,x)` — every record in the +/// same Y/X column across all C/Z would collide into one bucket, which is the +/// exact access pattern a directory walk produces. +fn bucket_of(c: Czyx) -> (u8, u8) { + let mut h: u32 = 0x811c_9dc5; + for b in [c.c, c.z, c.y, c.x] { + h ^= b as u32; + h = h.wrapping_mul(0x0100_0193); + } + let folded = ((h >> 16) ^ h) as u16; + // Y and X are 1..=255 addressable; map the 16-bit hash into 255x255. + let y = (folded / 255) % 255; + let x = folded % 255; + ((y + 1) as u8, (x + 1) as u8) +} + +/// The Null-space control plane bound to a store. +pub struct NullSpace; + +impl NullSpace { + /// Coordinate of the volume metadata record. + pub fn volume_coord() -> Czyx { + Czyx::new(0, z::VOLUME, 1, 1) + } + + /// Coordinate of the ACL bucket holding `subject`. + pub fn acl_bucket(subject: Czyx) -> Czyx { + let (y, x) = bucket_of(subject); + Czyx::new(0, z::ACL, y, x) + } + + /// Coordinate of the xattr bucket holding `subject`. + pub fn xattr_bucket(subject: Czyx) -> Czyx { + let (y, x) = bucket_of(subject); + Czyx::new(0, z::XATTR, y, x) + } + + /// Coordinate of journal slot `seq` (ring over Y segments x X slots). + pub fn journal_slot(seq: u64) -> Czyx { + let idx = (seq % (255 * 255)) as u32; + let y = (idx / 255) as u8 + 1; + let x = (idx % 255) as u8 + 1; + Czyx::new(0, z::JOURNAL, y, x) + } + + // --- volume metadata -------------------------------------------------- + + /// Read volume metadata, or the default if the volume was never + /// initialized. + pub fn read_volume(store: &CubeStore) -> VolumeMeta { + let Some(raw) = store.get_raw(&Self::volume_coord()) else { + return VolumeMeta::default(); + }; + Self::decode_volume(&raw).unwrap_or_default() + } + + /// Write volume metadata. + pub fn write_volume(store: &mut CubeStore, m: &VolumeMeta) { + store.put_raw(Self::volume_coord(), Self::encode_volume(m)); + } + + fn encode_volume(m: &VolumeMeta) -> Vec { + let mut o = Vec::new(); + put_bytes(&mut o, m.label.as_bytes()); + put_u32(&mut o, m.layout_version); + put_u64(&mut o, m.journal_seq); + o + } + fn decode_volume(b: &[u8]) -> Option { + let (label, r) = take_bytes(b)?; + let (layout_version, r) = take_u32(r)?; + let (journal_seq, _) = take_u64(r)?; + Some(VolumeMeta { + label: String::from_utf8(label).ok()?, + layout_version, + journal_seq, + }) + } + + // --- ACLs ------------------------------------------------------------- + + /// Read the ACL for `subject`, or `None` if none was ever set. + pub fn get_acl(store: &CubeStore, subject: Czyx) -> Option { + let raw = store.get_raw(&Self::acl_bucket(subject))?; + Self::decode_acl_bucket(&raw) + .into_iter() + .find(|(s, _)| *s == subject) + .map(|(_, a)| a) + } + + /// Set the ACL for `subject`. + pub fn set_acl(store: &mut CubeStore, subject: Czyx, acl: Acl) { + let coord = Self::acl_bucket(subject); + let mut entries = store + .get_raw(&coord) + .map(|r| Self::decode_acl_bucket(&r)) + .unwrap_or_default(); + match entries.iter_mut().find(|(s, _)| *s == subject) { + Some(e) => e.1 = acl, + None => entries.push((subject, acl)), + } + store.put_raw(coord, Self::encode_acl_bucket(&entries)); + } + + fn encode_acl_bucket(entries: &[(Czyx, Acl)]) -> Vec { + let mut o = Vec::new(); + put_u32(&mut o, entries.len() as u32); + for (s, a) in entries { + put_u32(&mut o, s.pack_u32()); + put_u32(&mut o, a.uid); + put_u32(&mut o, a.gid); + o.extend_from_slice(&a.mode.to_le_bytes()); + } + o + } + fn decode_acl_bucket(b: &[u8]) -> Vec<(Czyx, Acl)> { + let mut out = Vec::new(); + let Some((n, mut r)) = take_u32(b) else { + return out; + }; + for _ in 0..n { + let Some((packed, r1)) = take_u32(r) else { + break; + }; + let Some((uid, r2)) = take_u32(r1) else { break }; + let Some((gid, r3)) = take_u32(r2) else { break }; + if r3.len() < 2 { + break; + } + let mode = u16::from_le_bytes([r3[0], r3[1]]); + out.push((Czyx::unpack_u32(packed), Acl { uid, gid, mode })); + r = &r3[2..]; + } + out + } + + /// Remove the ACL for `subject`. Returns true if one existed. + pub fn remove_acl(store: &mut CubeStore, subject: Czyx) -> bool { + let coord = Self::acl_bucket(subject); + let Some(raw) = store.get_raw(&coord) else { + return false; + }; + let mut entries = Self::decode_acl_bucket(&raw); + let before = entries.len(); + entries.retain(|(s, _)| *s != subject); + let removed = entries.len() != before; + if removed { + store.put_raw(coord, Self::encode_acl_bucket(&entries)); + } + removed + } + + /// Every subject coordinate that currently has an explicit ACL. + /// + /// Used by `cubefs` to enumerate directory markers (empty directories), + /// which exist only as ACL entries. Scans the ACL Z-plane. + pub fn acl_subjects(store: &CubeStore) -> Vec { + let mut out = Vec::new(); + for k in store.scan_prefix(0, Some(z::ACL), None) { + if let Some(raw) = store.get_raw(&k) { + for (s, _) in Self::decode_acl_bucket(&raw) { + out.push(s); + } + } + } + out.sort(); + out.dedup(); + out + } + + // --- extended attributes --------------------------------------------- + + /// All xattrs for `subject`, as `(name, value)` pairs. + pub fn list_xattr( + store: &CubeStore, + subject: Czyx, + ) -> Vec<(String, Vec)> { + let Some(raw) = store.get_raw(&Self::xattr_bucket(subject)) else { + return Vec::new(); + }; + Self::decode_xattr_bucket(&raw) + .into_iter() + .filter(|(s, _, _)| *s == subject) + .map(|(_, n, v)| (n, v)) + .collect() + } + + /// One xattr value. + pub fn get_xattr( + store: &CubeStore, + subject: Czyx, + name: &str, + ) -> Option> { + Self::list_xattr(store, subject) + .into_iter() + .find(|(n, _)| n == name) + .map(|(_, v)| v) + } + + /// Set an xattr. + pub fn set_xattr( + store: &mut CubeStore, + subject: Czyx, + name: &str, + value: &[u8], + ) { + let coord = Self::xattr_bucket(subject); + let mut entries = store + .get_raw(&coord) + .map(|r| Self::decode_xattr_bucket(&r)) + .unwrap_or_default(); + match entries + .iter_mut() + .find(|(s, n, _)| *s == subject && n == name) + { + Some(e) => e.2 = value.to_vec(), + None => entries.push((subject, name.to_string(), value.to_vec())), + } + store.put_raw(coord, Self::encode_xattr_bucket(&entries)); + } + + /// Remove an xattr. Returns true if it existed. + pub fn remove_xattr( + store: &mut CubeStore, + subject: Czyx, + name: &str, + ) -> bool { + let coord = Self::xattr_bucket(subject); + let Some(raw) = store.get_raw(&coord) else { + return false; + }; + let mut entries = Self::decode_xattr_bucket(&raw); + let before = entries.len(); + entries.retain(|(s, n, _)| !(*s == subject && n == name)); + let removed = entries.len() != before; + if removed { + store.put_raw(coord, Self::encode_xattr_bucket(&entries)); + } + removed + } + + fn encode_xattr_bucket(entries: &[(Czyx, String, Vec)]) -> Vec { + let mut o = Vec::new(); + put_u32(&mut o, entries.len() as u32); + for (s, n, v) in entries { + put_u32(&mut o, s.pack_u32()); + put_bytes(&mut o, n.as_bytes()); + put_bytes(&mut o, v); + } + o + } + fn decode_xattr_bucket(b: &[u8]) -> Vec<(Czyx, String, Vec)> { + let mut out = Vec::new(); + let Some((n, mut r)) = take_u32(b) else { + return out; + }; + for _ in 0..n { + let Some((packed, r1)) = take_u32(r) else { + break; + }; + let Some((name, r2)) = take_bytes(r1) else { + break; + }; + let Some((val, r3)) = take_bytes(r2) else { + break; + }; + let Ok(name) = String::from_utf8(name) else { + break; + }; + out.push((Czyx::unpack_u32(packed), name, val)); + r = r3; + } + out + } + + // --- journal ---------------------------------------------------------- + + /// Append an entry to the metadata journal, bumping the volume sequence. + /// + /// Returns the sequence number assigned. + pub fn journal_append( + store: &mut CubeStore, + op: JournalOp, + subject: Czyx, + ) -> u64 { + let mut vol = Self::read_volume(store); + let seq = vol.journal_seq; + vol.journal_seq = seq.wrapping_add(1); + let mut o = Vec::new(); + put_u64(&mut o, seq); + o.push(op.tag()); + put_u32(&mut o, subject.pack_u32()); + store.put_raw(Self::journal_slot(seq), o); + // Decision: the volume record is written AFTER the slot. If we crash + // between the two, the slot holds an entry the sequence counter does + // not yet claim — a replayer sees a stale-but-consistent tail rather + // than a claimed-but-missing entry. Sequence-first would produce the + // latter, which is the worse failure. + Self::write_volume(store, &vol); + seq + } + + /// Read the journal entry at `seq`, if that slot still holds it (the ring + /// may have wrapped and overwritten it). + pub fn journal_read(store: &CubeStore, seq: u64) -> Option { + let raw = store.get_raw(&Self::journal_slot(seq))?; + let (got_seq, r) = take_u64(&raw)?; + if got_seq != seq { + return None; // slot was recycled by a later wrap + } + let op = JournalOp::from_tag(*r.first()?)?; + let (packed, _) = take_u32(&r[1..])?; + Some(JournalEntry { + seq: got_seq, + op, + subject: Czyx::unpack_u32(packed), + }) + } + + /// The journal in order, newest last, up to `max` entries back from the + /// current sequence. Entries lost to a ring wrap are skipped. + pub fn journal_tail(store: &CubeStore, max: usize) -> Vec { + let vol = Self::read_volume(store); + let mut out = Vec::new(); + let end = vol.journal_seq; + let start = end.saturating_sub(max as u64); + for seq in start..end { + if let Some(e) = Self::journal_read(store, seq) { + out.push(e); + } + } + out + } + + /// A header describing a control record, so control records are + /// self-describing when dumped by a raw tool. + pub fn control_header(title: &str) -> CubeHeader { + let mut h = CubeHeader::new(); + h.title = Some(title.to_string()); + h.doc_type = Some("cube/control".into()); + h.refresh_flags(); + h + } +} + +#[cfg(test)] +mod tests { + use super::*; + use cubestore::HashBackend; + + fn store() -> CubeStore { + CubeStore::new(HashBackend::new()) + } + + #[test] + fn control_records_live_in_null_space() { + assert!(NullSpace::volume_coord().is_null_cube()); + assert!(NullSpace::acl_bucket(Czyx::new(1, 2, 3, 4)).is_null_cube()); + assert!(NullSpace::xattr_bucket(Czyx::new(1, 2, 3, 4)).is_null_cube()); + assert!(NullSpace::journal_slot(0).is_null_cube()); + assert!(NullSpace::journal_slot(999_999).is_null_cube()); + } + + #[test] + fn buckets_never_hit_axis_zero() { + // Y and X must be 1..=255; a 0 would collide with Total-Null rows. + for c in [1u8, 7, 128, 255] { + for x in [1u8, 2, 254, 255] { + let b = NullSpace::acl_bucket(Czyx::new(c, c, x, x)); + assert!(b.y >= 1 && b.x >= 1); + } + } + for seq in [0u64, 1, 254, 255, 65_024, 65_025, 1_000_000] { + let s = NullSpace::journal_slot(seq); + assert!(s.y >= 1 && s.x >= 1, "seq {seq} -> {s:?}"); + } + } + + #[test] + fn bucket_spreads_a_directory_column() { + // The failure mode the FNV hash exists to avoid: every record in one + // Y/X column mapping to a single bucket. + let mut seen = std::collections::HashSet::new(); + for c in 1..=32u8 { + seen.insert(NullSpace::acl_bucket(Czyx::new(c, 5, 9, 9))); + } + assert!(seen.len() > 24, "poor spread: {} buckets", seen.len()); + } + + #[test] + fn volume_roundtrip() { + let mut s = store(); + let m = VolumeMeta { + label: "cube0".into(), + layout_version: 1, + journal_seq: 42, + }; + NullSpace::write_volume(&mut s, &m); + assert_eq!(NullSpace::read_volume(&s), m); + } + + #[test] + fn uninitialized_volume_is_default() { + assert_eq!(NullSpace::read_volume(&store()), VolumeMeta::default()); + } + + #[test] + fn acl_roundtrip_and_collision_safety() { + let mut s = store(); + let a = Czyx::new(1, 1, 1, 1); + let b = Czyx::new(2, 2, 2, 2); + NullSpace::set_acl( + &mut s, + a, + Acl { + uid: 1000, + gid: 1000, + mode: 0o600, + }, + ); + NullSpace::set_acl( + &mut s, + b, + Acl { + uid: 1, + gid: 1, + mode: 0o644, + }, + ); + assert_eq!(NullSpace::get_acl(&s, a).unwrap().uid, 1000); + assert_eq!(NullSpace::get_acl(&s, b).unwrap().uid, 1); + assert!(NullSpace::get_acl(&s, Czyx::new(9, 9, 9, 9)).is_none()); + } + + #[test] + fn acl_forced_collision_keeps_both() { + // Force two subjects into the same bucket by writing the bucket + // directly, then confirm exact-match resolution inside it. + let mut s = store(); + let a = Czyx::new(3, 3, 3, 3); + let b = Czyx::new(4, 4, 4, 4); + let coord = NullSpace::acl_bucket(a); + let entries = vec![ + ( + a, + Acl { + uid: 11, + gid: 11, + mode: 0o700, + }, + ), + ( + b, + Acl { + uid: 22, + gid: 22, + mode: 0o750, + }, + ), + ]; + s.put_raw(coord, NullSpace::encode_acl_bucket(&entries)); + let got = NullSpace::decode_acl_bucket(&s.get_raw(&coord).unwrap()); + assert_eq!(got.len(), 2); + assert_eq!(got[0].1.uid, 11); + assert_eq!(got[1].1.uid, 22); + } + + #[test] + fn acl_permission_logic() { + let acl = Acl { + uid: 1000, + gid: 100, + mode: 0o640, + }; + assert!(acl.permits(1000, 100, 4)); // owner read + assert!(acl.permits(1000, 100, 2)); // owner write + assert!(acl.permits(2000, 100, 4)); // group read + assert!(!acl.permits(2000, 100, 2)); // group cannot write + assert!(!acl.permits(3000, 300, 4)); // other: no bits + assert!(acl.permits(0, 0, 7)); // root overrides + } + + #[test] + fn xattr_roundtrip() { + let mut s = store(); + let c = Czyx::new(5, 6, 7, 8); + NullSpace::set_xattr(&mut s, c, "user.title", b"hello"); + NullSpace::set_xattr(&mut s, c, "user.kind", b"note"); + assert_eq!( + NullSpace::get_xattr(&s, c, "user.title").as_deref(), + Some(&b"hello"[..]) + ); + assert_eq!(NullSpace::list_xattr(&s, c).len(), 2); + // overwrite, not duplicate + NullSpace::set_xattr(&mut s, c, "user.title", b"bye"); + assert_eq!(NullSpace::list_xattr(&s, c).len(), 2); + assert_eq!( + NullSpace::get_xattr(&s, c, "user.title").as_deref(), + Some(&b"bye"[..]) + ); + assert!(NullSpace::remove_xattr(&mut s, c, "user.title")); + assert!(!NullSpace::remove_xattr(&mut s, c, "user.title")); + assert_eq!(NullSpace::list_xattr(&s, c).len(), 1); + } + + #[test] + fn xattr_binary_values_survive() { + let mut s = store(); + let c = Czyx::new(1, 1, 1, 2); + let val = vec![0u8, 255, 0, 1, 2, 250]; + NullSpace::set_xattr(&mut s, c, "user.bin", &val); + assert_eq!(NullSpace::get_xattr(&s, c, "user.bin"), Some(val)); + } + + #[test] + fn journal_append_and_read() { + let mut s = store(); + let c = Czyx::new(1, 2, 3, 4); + let s0 = NullSpace::journal_append(&mut s, JournalOp::Create, c); + let s1 = NullSpace::journal_append(&mut s, JournalOp::Write, c); + assert_eq!((s0, s1), (0, 1)); + assert_eq!( + NullSpace::journal_read(&s, 0).unwrap(), + JournalEntry { + seq: 0, + op: JournalOp::Create, + subject: c + } + ); + let tail = NullSpace::journal_tail(&s, 10); + assert_eq!(tail.len(), 2); + assert_eq!(tail[1].op, JournalOp::Write); + assert_eq!(NullSpace::read_volume(&s).journal_seq, 2); + } + + #[test] + fn journal_ring_wrap_is_detectable_not_silent() { + let mut s = store(); + let c = Czyx::new(1, 1, 1, 1); + // Jump the sequence to just below the wrap so the test is fast. + let ring = 255u64 * 255; + let mut vol = NullSpace::read_volume(&s); + vol.journal_seq = ring - 1; + NullSpace::write_volume(&mut s, &vol); + + let a = NullSpace::journal_append(&mut s, JournalOp::Create, c); // ring-1 + let b = NullSpace::journal_append(&mut s, JournalOp::Remove, c); // ring -> wraps to slot 0 + assert_eq!(a, ring - 1); + assert_eq!(b, ring); + assert_eq!(NullSpace::journal_slot(b), NullSpace::journal_slot(0)); + + // The overwritten old entry is reported as gone, not as wrong data. + assert!(NullSpace::journal_read(&s, 0).is_none()); + assert_eq!( + NullSpace::journal_read(&s, b).unwrap().op, + JournalOp::Remove + ); + // And the monotonic counter proves entries were lost. + assert_eq!(NullSpace::read_volume(&s).journal_seq, ring + 1); + } +} diff --git a/cubefs/src/path.rs b/cubefs/src/path.rs new file mode 100644 index 0000000..cbb6d77 --- /dev/null +++ b/cubefs/src/path.rs @@ -0,0 +1,325 @@ +//! Path <-> coordinate bijection. +//! +//! # The mapping +//! +//! The PDF offers two options: "map C/Z/Y/X ranges to top-level directories +//! (C), subdirs (Z,Y) and files (X)", or "keep the 4-D API but expose FUSE +//! hooks". We implement the first, because it is the one that yields a real +//! POSIX namespace a user can `ls`, and the second falls out of it for free +//! (the 4-D API is just [`crate::vfs`] called directly). +//! +//! ```text +//! / -> the volume root (synthetic) +//! /c01 -> C = 1 (directory) +//! /c01/z02 -> C = 1, Z = 2 (directory) +//! /c01/z02/y03 -> C = 1, Z = 2, Y = 3 (directory) +//! /c01/z02/y03/x04 -> C = 1, Z = 2, Y = 3, X = 4 (file / record) +//! ``` +//! +//! # Decision: fixed-width zero-padded names, axis-letter prefixed +//! +//! Three candidate naming schemes were considered: +//! +//! 1. Bare decimal (`/1/2/3/4`) — shortest, but ambiguous with any future +//! named-alias layer and sorts wrong lexically (`10` before `2`). +//! 2. Hex (`/c01/z02/...` in hex) — compact but two representations for the +//! same value once you allow upper/lower case, which breaks bijectivity. +//! 3. Axis-letter + zero-padded decimal (chosen) — `c01`, `z255`. Sorts +//! lexically in numeric order for a fixed width, is self-describing at the +//! shell (`ls /mnt/cube` immediately shows which axis you're on), and is +//! unambiguous because the parser demands the exact letter for the depth. +//! +//! Padding is to **3 digits** (`c001`..`c255`) so lexical order equals numeric +//! order across the whole `1..=255` range. Parsing accepts only the canonical +//! zero-padded form: accepting `c1` as well as `c001` would make the mapping a +//! surjection rather than a bijection, and `rename`/`readdir` round-tripping +//! would then not be an identity. +//! +//! # Decision: axis value 0 is not addressable via a path +//! +//! `0` on any axis is Null control space (see `cubecoords::NullClass`). The +//! control plane is exposed through **xattrs and the journal file**, not as +//! browsable directories, because presenting ACL storage as ordinary writable +//! files would let a user corrupt the permission system with `echo >`. So +//! [`parse_path`] rejects `c000` and friends with [`PathError::NullAxis`]. + +use cubecoords::Czyx; +use std::fmt; + +/// The FUSE root inode. The kernel mandates `1`. +/// +/// `1` unpacks to `Czyx { c: 0, z: 0, y: 0, x: 1 }`, which is Null cube 3 — +/// never a user record — so reserving it costs no user-addressable space. +pub const ROOT_INO: u64 = 1; + +/// Errors from path parsing. +#[derive(Clone, Eq, PartialEq, Debug)] +pub enum PathError { + /// A component did not have the required axis letter for its depth. + BadAxisLetter { + /// Depth at which the failure occurred (0 = C, 1 = Z, 2 = Y, 3 = X). + depth: usize, + /// The offending component. + component: String, + }, + /// The numeric part was missing, non-canonical (not 3 zero-padded digits), + /// or out of the `0..=255` range. + BadNumber(String), + /// The component addressed axis value 0, which is Null control space and + /// is not exposed as a path. + NullAxis(String), + /// More than four components. + TooDeep(usize), +} + +impl fmt::Display for PathError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + PathError::BadAxisLetter { depth, component } => write!( + f, + "component {:?} at depth {} must start with '{}'", + component, depth, AXIS_LETTERS[*depth] as char + ), + PathError::BadNumber(s) => { + write!(f, "component {s:?} must be a 3-digit zero-padded 0..=255") + } + PathError::NullAxis(s) => write!(f, "component {s:?} addresses Null space (value 0)"), + PathError::TooDeep(n) => write!(f, "path has {n} components, max 4"), + } + } +} + +impl std::error::Error for PathError {} + +/// Axis letter per depth: C, Z, Y, X. +pub const AXIS_LETTERS: [u8; 4] = *b"czyx"; + +/// Render an axis value as its canonical component name (e.g. `c007`). +pub fn render_component(depth: usize, value: u8) -> String { + format!("{}{:03}", AXIS_LETTERS[depth] as char, value) +} + +/// Parse a single component at `depth`, returning the axis value. +pub fn parse_component(depth: usize, component: &str) -> Result { + let bytes = component.as_bytes(); + if bytes.first().copied() != Some(AXIS_LETTERS[depth]) { + return Err(PathError::BadAxisLetter { + depth, + component: component.to_string(), + }); + } + let digits = &component[1..]; + // Canonical form only: exactly three ASCII digits. + if digits.len() != 3 || !digits.bytes().all(|b| b.is_ascii_digit()) { + return Err(PathError::BadNumber(component.to_string())); + } + let v: u32 = digits + .parse() + .map_err(|_| PathError::BadNumber(component.to_string()))?; + if v > 255 { + return Err(PathError::BadNumber(component.to_string())); + } + if v == 0 { + return Err(PathError::NullAxis(component.to_string())); + } + Ok(v as u8) +} + +/// A parsed path: the axis values supplied so far. +/// +/// Length 0 = the volume root, 1 = a C directory, 2 = C/Z, 3 = C/Z/Y, +/// 4 = a full record (a file). +#[derive(Clone, Eq, PartialEq, Debug, Default)] +pub struct ParsedPath { + /// Axis values in C, Z, Y, X order (partial prefixes allowed). + pub axes: Vec, +} + +impl ParsedPath { + /// True when all four axes are present (i.e. this names a record/file). + pub fn is_record(&self) -> bool { + self.axes.len() == 4 + } + + /// The full coordinate, if all four axes are present. + pub fn czyx(&self) -> Option { + if self.axes.len() == 4 { + Some(Czyx::new( + self.axes[0], + self.axes[1], + self.axes[2], + self.axes[3], + )) + } else { + None + } + } + + /// The coordinate this prefix denotes, with unfilled axes set to 0. + /// + /// Decision: a *directory* is represented internally as the coordinate + /// with its trailing axes zeroed. That collides with Null space by value, + /// which is exactly why directories are never stored as records — they are + /// synthesized by [`crate::vfs::CubeFs::readdir`] from a prefix scan. The + /// zeroed form is used only as a scan key and an inode, never as a record + /// address. + pub fn prefix_coord(&self) -> Czyx { + let g = |i: usize| self.axes.get(i).copied().unwrap_or(0); + Czyx::new(g(0), g(1), g(2), g(3)) + } +} + +/// Parse a slash-separated POSIX path into axis values. +/// +/// Leading/trailing slashes and empty components are ignored, so `/c001/`, +/// `c001`, and `//c001` all parse identically. +pub fn parse_path(p: &str) -> Result { + let comps: Vec<&str> = p.split('/').filter(|s| !s.is_empty()).collect(); + if comps.len() > 4 { + return Err(PathError::TooDeep(comps.len())); + } + let mut axes = Vec::with_capacity(comps.len()); + for (depth, c) in comps.iter().enumerate() { + axes.push(parse_component(depth, c)?); + } + Ok(ParsedPath { axes }) +} + +/// Render axis values back to a canonical absolute path. +pub fn render_path(axes: &[u8]) -> String { + if axes.is_empty() { + return "/".to_string(); + } + let mut s = String::new(); + for (depth, v) in axes.iter().enumerate() { + s.push('/'); + s.push_str(&render_component(depth, *v)); + } + s +} + +/// Coordinate -> inode number. +/// +/// The inode IS the packed coordinate; see the crate docs for why no side +/// table exists. +#[inline] +pub fn czyx_to_ino(c: Czyx) -> u64 { + c.pack_u32() as u64 +} + +/// Inode number -> coordinate. +/// +/// Returns `None` for inodes outside the 32-bit coordinate space, which can +/// only happen if the kernel hands back an inode we never issued. +#[inline] +pub fn ino_to_czyx(ino: u64) -> Option { + if ino > u32::MAX as u64 { + return None; + } + Some(Czyx::unpack_u32(ino as u32)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trip_full_record_path() { + let p = parse_path("/c001/z002/y003/x004").unwrap(); + assert!(p.is_record()); + assert_eq!(p.czyx(), Some(Czyx::new(1, 2, 3, 4))); + assert_eq!(render_path(&p.axes), "/c001/z002/y003/x004"); + } + + #[test] + fn round_trip_every_depth() { + for depth in 0..=4usize { + let axes: Vec = (1..=depth as u8).map(|i| i * 7).collect(); + let rendered = render_path(&axes); + let back = parse_path(&rendered).unwrap(); + assert_eq!(back.axes, axes, "failed at depth {depth}"); + } + } + + #[test] + fn root_is_empty_prefix() { + assert_eq!(parse_path("/").unwrap().axes, Vec::::new()); + assert_eq!(render_path(&[]), "/"); + } + + #[test] + fn slashes_are_normalized() { + assert_eq!(parse_path("//c001//z002/").unwrap().axes, vec![1, 2]); + } + + #[test] + fn wrong_axis_letter_rejected() { + // 'z' at depth 0 must fail — depth determines the letter. + assert!(matches!( + parse_path("/z001"), + Err(PathError::BadAxisLetter { depth: 0, .. }) + )); + } + + #[test] + fn non_canonical_number_rejected() { + // Bijectivity requires exactly one spelling per value. + assert!(matches!(parse_path("/c1"), Err(PathError::BadNumber(_)))); + assert!(matches!(parse_path("/c0001"), Err(PathError::BadNumber(_)))); + assert!(matches!(parse_path("/c256"), Err(PathError::BadNumber(_)))); + assert!(matches!(parse_path("/cxyz"), Err(PathError::BadNumber(_)))); + } + + #[test] + fn null_axis_not_addressable() { + assert!(matches!(parse_path("/c000"), Err(PathError::NullAxis(_)))); + assert!(matches!( + parse_path("/c001/z002/y003/x000"), + Err(PathError::NullAxis(_)) + )); + } + + #[test] + fn too_deep_rejected() { + assert_eq!( + parse_path("/c001/z002/y003/x004/x005"), + Err(PathError::TooDeep(5)) + ); + } + + #[test] + fn ino_is_the_coordinate() { + let c = Czyx::new(12, 34, 56, 78); + assert_eq!(ino_to_czyx(czyx_to_ino(c)), Some(c)); + // Root inode 1 is a Null cube, never a user record. + let root = ino_to_czyx(ROOT_INO).unwrap(); + assert!(root.is_null_cube()); + assert_eq!(root, Czyx::new(0, 0, 0, 1)); + } + + #[test] + fn ino_out_of_range_is_none() { + assert!(ino_to_czyx(u64::from(u32::MAX) + 1).is_none()); + } + + /// Exhaustive proof of bijectivity over the full record space would be + /// 255^4 = 4.2e9 iterations; we sample the boundaries plus a stride so the + /// test stays fast but still covers every axis extreme. + #[test] + fn bijection_over_sampled_space() { + let vals = [1u8, 2, 9, 10, 99, 100, 128, 254, 255]; + for &c in &vals { + for &z in &vals { + for &y in &vals { + for &x in &vals { + let coord = Czyx::new(c, z, y, x); + let p = render_path(&[c, z, y, x]); + let back = parse_path(&p).unwrap().czyx().unwrap(); + assert_eq!(back, coord, "path {p}"); + assert_eq!(ino_to_czyx(czyx_to_ino(coord)), Some(coord)); + } + } + } + } + } +} diff --git a/cubefs/src/vfs.rs b/cubefs/src/vfs.rs new file mode 100644 index 0000000..5a065da --- /dev/null +++ b/cubefs/src/vfs.rs @@ -0,0 +1,968 @@ +//! Backend-agnostic filesystem operations over a [`CubeStore`]. +//! +//! This is the whole filesystem, minus the kernel. `crate::fuse` is a thin +//! translation layer on top; everything semantically interesting lives here so +//! it can be tested without mounting anything. +//! +//! # Directory model +//! +//! Directories are **synthetic**. There is no directory record. A directory +//! at prefix `C` (or `C/Z`, or `C/Z/Y`) exists exactly when at least one +//! record exists beneath it, and its contents are computed by a prefix scan +//! (`CubeBackend::scan_prefix`). Consequences, accepted deliberately: +//! +//! * `mkdir` is a no-op that succeeds if the prefix is well-formed — you +//! cannot have an empty directory, because a coordinate space has no +//! concept of "reserved but unoccupied". This is honest to the model; the +//! alternative (materializing directory records) would put non-data records +//! in user space, which the Null-space design explicitly avoids. +//! * `rmdir` likewise succeeds only when the prefix is already empty. +//! * Directory metadata (an ACL on a directory) *is* storable, because ACLs +//! live in Null space keyed by the prefix coordinate (trailing axes zeroed), +//! and that coordinate is never a valid record address. +//! +//! # Access control +//! +//! Every mutating op consults [`crate::nullspace::Acl`] for the subject and +//! appends a journal entry on success. A record with no ACL inherits the +//! default (`root:root 0644`) rather than being unreadable, so a volume that +//! predates ACL support stays usable. + +use crate::nullspace::{Acl, JournalOp, NullSpace}; +use crate::path::{self, ParsedPath}; +use cubecoords::{CubeHeader, Czyx}; +use cubestore::{CubeBackend, CubeStore}; + +/// What a path names. +#[derive(Copy, Clone, Eq, PartialEq, Debug)] +pub enum Kind { + /// A synthetic directory (root, or a C / C-Z / C-Z-Y prefix). + Directory, + /// A record (a full four-axis coordinate). + File, +} + +/// Filesystem errors, each mapping to a POSIX errno in the FUSE layer. +#[derive(Clone, Eq, PartialEq, Debug)] +pub enum FsError { + /// ENOENT + NotFound, + /// EACCES + PermissionDenied, + /// EEXIST + Exists, + /// ENOTEMPTY + NotEmpty, + /// EINVAL — malformed path or argument + Invalid(String), + /// EISDIR / ENOTDIR mismatch + WrongKind { + /// What the caller expected. + expected: Kind, + /// What the path actually names. + actual: Kind, + }, +} + +impl FsError { + /// The POSIX errno this maps to. + pub fn errno(&self) -> i32 { + match self { + FsError::NotFound => 2, // ENOENT + FsError::PermissionDenied => 13, // EACCES + FsError::Exists => 17, // EEXIST + FsError::NotEmpty => 39, // ENOTEMPTY + FsError::Invalid(_) => 22, // EINVAL + FsError::WrongKind { expected, .. } => match expected { + Kind::Directory => 20, // ENOTDIR + Kind::File => 21, // EISDIR + }, + } + } +} + +/// Stat-like attributes. +#[derive(Clone, Eq, PartialEq, Debug)] +pub struct Attr { + /// Inode = packed coordinate. + pub ino: u64, + /// File or directory. + pub kind: Kind, + /// Body size in bytes (0 for directories). + pub size: u64, + /// Permission bits. + pub mode: u16, + /// Owning uid. + pub uid: u32, + /// Owning gid. + pub gid: u32, + /// Creation time (epoch seconds), from the record header when present. + pub created_at: u64, + /// Number of hard links: 1 for files, 2 for directories (`.` and `..`). + pub nlink: u32, +} + +/// The filesystem. +pub struct CubeFs { + store: CubeStore, +} + +impl CubeFs { + /// Wrap a store. + pub fn new(store: CubeStore) -> Self { + CubeFs { store } + } + + /// Initialize volume metadata (idempotent — preserves an existing label + /// and journal sequence). + pub fn format(&mut self, label: &str) { + let mut m = NullSpace::read_volume(&self.store); + if m.label.is_empty() { + m.label = label.to_string(); + } + m.layout_version = 1; + NullSpace::write_volume(&mut self.store, &m); + } + + /// Borrow the underlying store (for tooling and tests). + pub fn store(&self) -> &CubeStore { + &self.store + } + + /// Mutably borrow the underlying store. + pub fn store_mut(&mut self) -> &mut CubeStore { + &mut self.store + } + + fn parse(&self, p: &str) -> Result { + path::parse_path(p).map_err(|e| FsError::Invalid(e.to_string())) + } + + /// Does this directory prefix exist? + /// + /// A prefix exists if either (a) at least one record lives under it, or + /// (b) it was explicitly created by [`CubeFs::mkdir`], which materializes + /// a directory marker in Null space. + /// + /// Defect found by live-mount testing (2026-08-10): with only rule (a), + /// `mkdir` appeared to succeed at the FUSE layer and then the kernel's + /// immediate revalidation `lookup` returned ENOENT, so `mkdir` reported + /// "No such file or directory" and `mkdir -p` could never build a path to + /// depth 4. An empty directory has to be representable. Rule (b) makes it + /// so *without* putting a placeholder record in user space: the marker is + /// an ACL entry at the prefix coordinate, which lives at `C=0` in Null + /// space exactly as the PDF prescribes for directory/volume metadata. + fn prefix_populated(&self, axes: &[u8]) -> bool { + if axes.is_empty() { + return true; // the root always exists + } + let g = |i: usize| axes.get(i).copied().unwrap_or(0); + let marker = Czyx::new(g(0), g(1), g(2), g(3)); + if NullSpace::get_acl(&self.store, marker).is_some() { + return true; + } + match axes.len() { + 1 => !self.store.scan_prefix(axes[0], None, None).is_empty(), + 2 => !self + .store + .scan_prefix(axes[0], Some(axes[1]), None) + .is_empty(), + 3 => !self + .store + .scan_prefix(axes[0], Some(axes[1]), Some(axes[2])) + .is_empty(), + _ => false, + } + } + + /// Does this prefix hold any actual records (ignoring directory markers)? + fn prefix_has_records(&self, axes: &[u8]) -> bool { + match axes.len() { + 0 => self.store.keys().iter().any(|k| k.c != 0), + 1 => !self.store.scan_prefix(axes[0], None, None).is_empty(), + 2 => !self + .store + .scan_prefix(axes[0], Some(axes[1]), None) + .is_empty(), + 3 => !self + .store + .scan_prefix(axes[0], Some(axes[1]), Some(axes[2])) + .is_empty(), + _ => false, + } + } + + /// What kind of object a path names, or `NotFound`. + pub fn kind_of(&self, p: &str) -> Result { + let parsed = self.parse(p)?; + if parsed.is_record() { + let c = parsed.czyx().expect("is_record implies czyx"); + if self.store.get_raw(&c).is_some() { + Ok(Kind::File) + } else { + Err(FsError::NotFound) + } + } else if self.prefix_populated(&parsed.axes) { + Ok(Kind::Directory) + } else { + Err(FsError::NotFound) + } + } + + /// The ACL in force for a coordinate (explicit, or the default). + pub fn acl_of(&self, c: Czyx) -> Acl { + NullSpace::get_acl(&self.store, c).unwrap_or_default() + } + + fn check(&self, c: Czyx, uid: u32, gid: u32, want: u16) -> Result<(), FsError> { + if self.acl_of(c).permits(uid, gid, want) { + Ok(()) + } else { + Err(FsError::PermissionDenied) + } + } + + /// `stat`. + pub fn getattr(&self, p: &str) -> Result { + let parsed = self.parse(p)?; + let kind = self.kind_of(p)?; + let coord = parsed.prefix_coord(); + let acl = self.acl_of(coord); + let (size, created_at) = if kind == Kind::File { + let c = parsed.czyx().expect("file implies full coord"); + match self.store.get_record(&c) { + Some((h, body)) => (body.len() as u64, h.created_at.unwrap_or(0)), + None => (0, 0), + } + } else { + (0, 0) + }; + // Decision: directories report 0o755 unless an explicit ACL exists, + // because the file default (0644) would make every directory + // un-traversable (no x bit) and the volume unusable out of the box. + let mode = if NullSpace::get_acl(&self.store, coord).is_some() { + acl.mode + } else if kind == Kind::Directory { + 0o755 + } else { + acl.mode + }; + Ok(Attr { + ino: path::czyx_to_ino(coord), + kind, + size, + mode, + uid: acl.uid, + gid: acl.gid, + created_at, + nlink: if kind == Kind::Directory { 2 } else { 1 }, + }) + } + + /// List a directory. Returns `(name, kind)` pairs in canonical order, + /// without `.` and `..` (the FUSE layer adds those). + pub fn readdir(&self, p: &str) -> Result, FsError> { + let parsed = self.parse(p)?; + if parsed.is_record() { + return Err(FsError::WrongKind { + expected: Kind::Directory, + actual: Kind::File, + }); + } + if !self.prefix_populated(&parsed.axes) { + return Err(FsError::NotFound); + } + let depth = parsed.axes.len(); + let mut names: Vec = Vec::new(); + // Decision: readdir enumerates the *next* axis by scanning all + // records under the prefix and projecting. For the in-memory backend + // that is a full key scan; a real backend overrides `scan_prefix` + // with a range scan, which makes this O(matching keys). + let keys: Vec = match depth { + 0 => self.store.keys(), + 1 => self.store.scan_prefix(parsed.axes[0], None, None), + 2 => self + .store + .scan_prefix(parsed.axes[0], Some(parsed.axes[1]), None), + 3 => self + .store + .scan_prefix(parsed.axes[0], Some(parsed.axes[1]), Some(parsed.axes[2])), + _ => unreachable!("depth <= 3 checked above"), + }; + for k in keys { + // Null-space control records are not part of the user namespace. + if k.c == 0 { + continue; + } + let v = match depth { + 0 => k.c, + 1 => k.z, + 2 => k.y, + 3 => k.x, + _ => unreachable!(), + }; + if v != 0 && !names.contains(&v) { + names.push(v); + } + } + // Empty directories exist only as Null-space markers, so they must be + // merged in separately or `mkdir` followed by `ls` would show nothing. + if depth < 3 { + for subj in NullSpace::acl_subjects(&self.store) { + if subj.c == 0 { + continue; + } + let sa = [subj.c, subj.z, subj.y, subj.x]; + // The marker must sit exactly one level below this prefix... + let marker_depth = sa.iter().take_while(|v| **v != 0).count(); + if marker_depth != depth + 1 { + continue; + } + // ...and share our prefix. + if !parsed.axes.iter().enumerate().all(|(i, a)| sa[i] == *a) { + continue; + } + let v = sa[depth]; + if v != 0 && !names.contains(&v) { + names.push(v); + } + } + } + names.sort_unstable(); + let kind = if depth == 3 { + Kind::File + } else { + Kind::Directory + }; + Ok(names + .into_iter() + .map(|v| (path::render_component(depth, v), kind)) + .collect()) + } + + /// Create an empty record. Fails with `Exists` if one is already there. + pub fn create(&mut self, p: &str, uid: u32, gid: u32, mode: u16) -> Result { + let parsed = self.parse(p)?; + let Some(c) = parsed.czyx() else { + return Err(FsError::WrongKind { + expected: Kind::File, + actual: Kind::Directory, + }); + }; + if self.store.get_raw(&c).is_some() { + return Err(FsError::Exists); + } + let mut h = CubeHeader::new(); + h.title = Some(path::render_path(&parsed.axes)); + h.doc_type = Some("cube/record".into()); + h.created_at = Some(now()); + h.size_bytes = Some(0); + h.refresh_flags(); + self.store.put_record(c, &h, &[]); + NullSpace::set_acl(&mut self.store, c, Acl { uid, gid, mode }); + NullSpace::journal_append(&mut self.store, JournalOp::Create, c); + self.getattr(p) + } + + /// Read `size` bytes at `offset` from a record. + pub fn read( + &self, + p: &str, + offset: u64, + size: u32, + uid: u32, + gid: u32, + ) -> Result, FsError> { + let parsed = self.parse(p)?; + let Some(c) = parsed.czyx() else { + return Err(FsError::WrongKind { + expected: Kind::File, + actual: Kind::Directory, + }); + }; + self.check(c, uid, gid, 4)?; + let (_, body) = self.store.get_record(&c).ok_or(FsError::NotFound)?; + let start = (offset as usize).min(body.len()); + let end = (start + size as usize).min(body.len()); + Ok(body[start..end].to_vec()) + } + + /// Write `data` at `offset`, extending (and zero-filling any gap) as + /// needed. Returns bytes written. + pub fn write( + &mut self, + p: &str, + offset: u64, + data: &[u8], + uid: u32, + gid: u32, + ) -> Result { + let parsed = self.parse(p)?; + let Some(c) = parsed.czyx() else { + return Err(FsError::WrongKind { + expected: Kind::File, + actual: Kind::Directory, + }); + }; + self.check(c, uid, gid, 2)?; + let (mut h, mut body) = self.store.get_record(&c).ok_or(FsError::NotFound)?; + let start = offset as usize; + if body.len() < start { + body.resize(start, 0); // sparse write: zero-fill the hole + } + let end = start + data.len(); + if body.len() < end { + body.resize(end, 0); + } + body[start..end].copy_from_slice(data); + h.size_bytes = Some(body.len() as u64); + h.refresh_flags(); + self.store.put_record(c, &h, &body); + NullSpace::journal_append(&mut self.store, JournalOp::Write, c); + Ok(data.len() as u32) + } + + /// Truncate a record to `len`. + pub fn truncate(&mut self, p: &str, len: u64, uid: u32, gid: u32) -> Result<(), FsError> { + let parsed = self.parse(p)?; + let Some(c) = parsed.czyx() else { + return Err(FsError::WrongKind { + expected: Kind::File, + actual: Kind::Directory, + }); + }; + self.check(c, uid, gid, 2)?; + let (mut h, mut body) = self.store.get_record(&c).ok_or(FsError::NotFound)?; + body.resize(len as usize, 0); + h.size_bytes = Some(body.len() as u64); + h.refresh_flags(); + self.store.put_record(c, &h, &body); + NullSpace::journal_append(&mut self.store, JournalOp::Write, c); + Ok(()) + } + + /// Remove a record. + pub fn unlink(&mut self, p: &str, uid: u32, gid: u32) -> Result<(), FsError> { + let parsed = self.parse(p)?; + let Some(c) = parsed.czyx() else { + return Err(FsError::WrongKind { + expected: Kind::File, + actual: Kind::Directory, + }); + }; + if self.store.get_raw(&c).is_none() { + return Err(FsError::NotFound); + } + self.check(c, uid, gid, 2)?; + self.store.delete_raw(&c); + NullSpace::journal_append(&mut self.store, JournalOp::Remove, c); + Ok(()) + } + + /// `mkdir`. + /// + /// Creates a directory marker in Null space (an ACL entry at the prefix + /// coordinate). See [`CubeFs::prefix_populated`] for why an explicit + /// marker is required rather than relying purely on inference from the + /// records beneath. + pub fn mkdir(&mut self, p: &str, uid: u32, gid: u32, mode: u16) -> Result<(), FsError> { + let parsed = self.parse(p)?; + if parsed.is_record() { + return Err(FsError::WrongKind { + expected: Kind::Directory, + actual: Kind::File, + }); + } + if parsed.axes.is_empty() { + return Err(FsError::Exists); // the root + } + if self.prefix_populated(&parsed.axes) { + return Err(FsError::Exists); + } + let coord = parsed.prefix_coord(); + NullSpace::set_acl(&mut self.store, coord, Acl { uid, gid, mode }); + NullSpace::journal_append(&mut self.store, JournalOp::Create, coord); + Ok(()) + } + + /// `rmdir`. Only succeeds when the prefix holds no records; removes the + /// directory marker if one exists. + pub fn rmdir(&mut self, p: &str) -> Result<(), FsError> { + let parsed = self.parse(p)?; + if parsed.is_record() { + return Err(FsError::WrongKind { + expected: Kind::Directory, + actual: Kind::File, + }); + } + if parsed.axes.is_empty() { + return Err(FsError::Invalid("cannot rmdir the root".into())); + } + if !self.prefix_populated(&parsed.axes) { + return Err(FsError::NotFound); + } + if self.prefix_has_records(&parsed.axes) { + return Err(FsError::NotEmpty); + } + // A marker-only directory: drop the marker. + let coord = parsed.prefix_coord(); + NullSpace::remove_acl(&mut self.store, coord); + NullSpace::journal_append(&mut self.store, JournalOp::Remove, coord); + Ok(()) + } + + /// Set an ACL (chmod/chown combined). + pub fn set_acl(&mut self, p: &str, acl: Acl) -> Result<(), FsError> { + let parsed = self.parse(p)?; + let coord = parsed.prefix_coord(); + NullSpace::set_acl(&mut self.store, coord, acl); + NullSpace::journal_append(&mut self.store, JournalOp::SetAcl, coord); + Ok(()) + } + + // --- xattrs ----------------------------------------------------------- + + /// Set an extended attribute. + pub fn setxattr(&mut self, p: &str, name: &str, value: &[u8]) -> Result<(), FsError> { + let coord = self.parse(p)?.prefix_coord(); + NullSpace::set_xattr(&mut self.store, coord, name, value); + NullSpace::journal_append(&mut self.store, JournalOp::SetXattr, coord); + Ok(()) + } + + /// Get an extended attribute. + pub fn getxattr(&self, p: &str, name: &str) -> Result, FsError> { + let coord = self.parse(p)?.prefix_coord(); + NullSpace::get_xattr(&self.store, coord, name).ok_or(FsError::NotFound) + } + + /// List extended attribute names. + pub fn listxattr(&self, p: &str) -> Result, FsError> { + let coord = self.parse(p)?.prefix_coord(); + Ok(NullSpace::list_xattr(&self.store, coord) + .into_iter() + .map(|(n, _)| n) + .collect()) + } + + /// Remove an extended attribute. + pub fn removexattr(&mut self, p: &str, name: &str) -> Result<(), FsError> { + let coord = self.parse(p)?.prefix_coord(); + if NullSpace::remove_xattr(&mut self.store, coord, name) { + NullSpace::journal_append(&mut self.store, JournalOp::RemoveXattr, coord); + Ok(()) + } else { + Err(FsError::NotFound) + } + } +} + +/// Epoch seconds. Falls back to 0 if the clock is before the epoch. +fn now() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::nullspace::JournalOp; + use cubestore::HashBackend; + + const ROOT: (u32, u32) = (0, 0); + const USER: (u32, u32) = (1000, 1000); + + fn fs() -> CubeFs { + let mut f = CubeFs::new(CubeStore::new(HashBackend::new())); + f.format("test"); + f + } + + #[test] + fn create_read_write_roundtrip() { + let mut f = fs(); + let p = "/c001/z001/y001/x001"; + f.create(p, USER.0, USER.1, 0o644).unwrap(); + assert_eq!(f.write(p, 0, b"hello world", USER.0, USER.1).unwrap(), 11); + assert_eq!( + f.read(p, 0, 64, USER.0, USER.1).unwrap(), + b"hello world".to_vec() + ); + assert_eq!(f.read(p, 6, 5, USER.0, USER.1).unwrap(), b"world".to_vec()); + assert_eq!(f.getattr(p).unwrap().size, 11); + } + + #[test] + fn create_twice_is_eexist() { + let mut f = fs(); + let p = "/c001/z001/y001/x001"; + f.create(p, ROOT.0, ROOT.1, 0o644).unwrap(); + assert_eq!(f.create(p, ROOT.0, ROOT.1, 0o644), Err(FsError::Exists)); + } + + #[test] + fn sparse_write_zero_fills() { + let mut f = fs(); + let p = "/c001/z001/y001/x002"; + f.create(p, ROOT.0, ROOT.1, 0o644).unwrap(); + f.write(p, 4, b"AB", ROOT.0, ROOT.1).unwrap(); + assert_eq!( + f.read(p, 0, 16, ROOT.0, ROOT.1).unwrap(), + vec![0, 0, 0, 0, b'A', b'B'] + ); + } + + #[test] + fn truncate_shrinks_and_grows() { + let mut f = fs(); + let p = "/c002/z001/y001/x001"; + f.create(p, ROOT.0, ROOT.1, 0o644).unwrap(); + f.write(p, 0, b"abcdef", ROOT.0, ROOT.1).unwrap(); + f.truncate(p, 3, ROOT.0, ROOT.1).unwrap(); + assert_eq!(f.read(p, 0, 16, ROOT.0, ROOT.1).unwrap(), b"abc".to_vec()); + f.truncate(p, 5, ROOT.0, ROOT.1).unwrap(); + assert_eq!( + f.read(p, 0, 16, ROOT.0, ROOT.1).unwrap(), + vec![b'a', b'b', b'c', 0, 0] + ); + } + + #[test] + fn unlink_removes() { + let mut f = fs(); + let p = "/c003/z001/y001/x001"; + f.create(p, ROOT.0, ROOT.1, 0o644).unwrap(); + assert_eq!(f.kind_of(p).unwrap(), Kind::File); + f.unlink(p, ROOT.0, ROOT.1).unwrap(); + assert_eq!(f.kind_of(p), Err(FsError::NotFound)); + assert_eq!(f.unlink(p, ROOT.0, ROOT.1), Err(FsError::NotFound)); + } + + #[test] + fn directories_are_synthesized_from_records() { + let mut f = fs(); + // Nothing exists yet. + assert_eq!(f.kind_of("/c007"), Err(FsError::NotFound)); + f.create("/c007/z003/y002/x005", ROOT.0, ROOT.1, 0o644) + .unwrap(); + assert_eq!(f.kind_of("/c007").unwrap(), Kind::Directory); + assert_eq!(f.kind_of("/c007/z003").unwrap(), Kind::Directory); + assert_eq!(f.kind_of("/c007/z003/y002").unwrap(), Kind::Directory); + assert_eq!(f.kind_of("/c007/z003/y002/x005").unwrap(), Kind::File); + // And they vanish when the last record goes. + f.unlink("/c007/z003/y002/x005", ROOT.0, ROOT.1).unwrap(); + assert_eq!(f.kind_of("/c007"), Err(FsError::NotFound)); + } + + #[test] + fn readdir_at_every_depth() { + let mut f = fs(); + for (c, z, y, x) in [ + (1u8, 1u8, 1u8, 1u8), + (1, 1, 1, 2), + (1, 2, 1, 1), + (5, 1, 1, 1), + ] { + let p = path::render_path(&[c, z, y, x]); + f.create(&p, ROOT.0, ROOT.1, 0o644).unwrap(); + } + assert_eq!( + f.readdir("/").unwrap(), + vec![ + ("c001".to_string(), Kind::Directory), + ("c005".to_string(), Kind::Directory) + ] + ); + assert_eq!( + f.readdir("/c001").unwrap(), + vec![ + ("z001".to_string(), Kind::Directory), + ("z002".to_string(), Kind::Directory) + ] + ); + assert_eq!( + f.readdir("/c001/z001").unwrap(), + vec![("y001".to_string(), Kind::Directory)] + ); + assert_eq!( + f.readdir("/c001/z001/y001").unwrap(), + vec![ + ("x001".to_string(), Kind::File), + ("x002".to_string(), Kind::File) + ] + ); + } + + #[test] + fn readdir_never_exposes_null_space() { + let mut f = fs(); + // format() + create() both write control records at C=0. + f.create("/c001/z001/y001/x001", ROOT.0, ROOT.1, 0o644) + .unwrap(); + f.setxattr("/c001/z001/y001/x001", "user.k", b"v").unwrap(); + let root = f.readdir("/").unwrap(); + assert!( + root.iter().all(|(n, _)| n != "c000"), + "null space leaked into readdir: {root:?}" + ); + assert_eq!(root.len(), 1); + } + + #[test] + fn readdir_on_a_file_is_wrong_kind() { + let mut f = fs(); + let p = "/c001/z001/y001/x001"; + f.create(p, ROOT.0, ROOT.1, 0o644).unwrap(); + assert_eq!( + f.readdir(p), + Err(FsError::WrongKind { + expected: Kind::Directory, + actual: Kind::File + }) + ); + } + + #[test] + fn permissions_are_enforced() { + let mut f = fs(); + let p = "/c001/z001/y001/x001"; + // owner 1000, mode 0600 -> other users get nothing + f.create(p, 1000, 1000, 0o600).unwrap(); + f.write(p, 0, b"secret", 1000, 1000).unwrap(); + assert_eq!(f.read(p, 0, 6, 1000, 1000).unwrap(), b"secret".to_vec()); + assert_eq!(f.read(p, 0, 6, 2000, 2000), Err(FsError::PermissionDenied)); + assert_eq!( + f.write(p, 0, b"x", 2000, 2000), + Err(FsError::PermissionDenied) + ); + // root overrides + assert_eq!(f.read(p, 0, 6, 0, 0).unwrap(), b"secret".to_vec()); + } + + #[test] + fn set_acl_changes_enforcement() { + let mut f = fs(); + let p = "/c001/z001/y001/x001"; + f.create(p, 1000, 1000, 0o600).unwrap(); + assert_eq!(f.read(p, 0, 1, 2000, 2000), Err(FsError::PermissionDenied)); + f.set_acl( + p, + Acl { + uid: 1000, + gid: 1000, + mode: 0o644, + }, + ) + .unwrap(); + assert!(f.read(p, 0, 1, 2000, 2000).is_ok()); + } + + #[test] + fn directories_default_to_traversable() { + let mut f = fs(); + f.create("/c009/z001/y001/x001", ROOT.0, ROOT.1, 0o644) + .unwrap(); + let a = f.getattr("/c009").unwrap(); + assert_eq!(a.kind, Kind::Directory); + assert_eq!(a.mode, 0o755, "directories must be traversable by default"); + assert_eq!(a.nlink, 2); + } + + #[test] + fn xattrs_on_files_and_directories() { + let mut f = fs(); + f.create("/c001/z001/y001/x001", ROOT.0, ROOT.1, 0o644) + .unwrap(); + f.setxattr("/c001/z001/y001/x001", "user.tag", b"alpha") + .unwrap(); + f.setxattr("/c001", "user.tag", b"dir").unwrap(); + assert_eq!( + f.getxattr("/c001/z001/y001/x001", "user.tag").unwrap(), + b"alpha".to_vec() + ); + assert_eq!(f.getxattr("/c001", "user.tag").unwrap(), b"dir".to_vec()); + assert_eq!(f.listxattr("/c001").unwrap(), vec!["user.tag".to_string()]); + f.removexattr("/c001", "user.tag").unwrap(); + assert_eq!(f.getxattr("/c001", "user.tag"), Err(FsError::NotFound)); + } + + #[test] + fn file_and_directory_xattrs_do_not_collide() { + // A directory's coordinate is the record coordinate with trailing + // axes zeroed, so /c001 and /c001/z001/y001/x001 must be distinct + // xattr subjects. + let mut f = fs(); + f.create("/c001/z001/y001/x001", ROOT.0, ROOT.1, 0o644) + .unwrap(); + f.setxattr("/c001", "user.k", b"dir").unwrap(); + f.setxattr("/c001/z001", "user.k", b"z").unwrap(); + f.setxattr("/c001/z001/y001", "user.k", b"y").unwrap(); + f.setxattr("/c001/z001/y001/x001", "user.k", b"file") + .unwrap(); + assert_eq!(f.getxattr("/c001", "user.k").unwrap(), b"dir".to_vec()); + assert_eq!(f.getxattr("/c001/z001", "user.k").unwrap(), b"z".to_vec()); + assert_eq!( + f.getxattr("/c001/z001/y001", "user.k").unwrap(), + b"y".to_vec() + ); + assert_eq!( + f.getxattr("/c001/z001/y001/x001", "user.k").unwrap(), + b"file".to_vec() + ); + } + + #[test] + fn journal_records_the_operation_sequence() { + let mut f = fs(); + let p = "/c001/z001/y001/x001"; + f.create(p, ROOT.0, ROOT.1, 0o644).unwrap(); + f.write(p, 0, b"x", ROOT.0, ROOT.1).unwrap(); + f.setxattr(p, "user.a", b"1").unwrap(); + f.unlink(p, ROOT.0, ROOT.1).unwrap(); + let ops: Vec = NullSpace::journal_tail(f.store(), 50) + .into_iter() + .map(|e| e.op) + .collect(); + assert_eq!( + ops, + vec![ + JournalOp::Create, + JournalOp::Write, + JournalOp::SetXattr, + JournalOp::Remove + ] + ); + } + + #[test] + fn bad_paths_are_einval_not_panics() { + let f = fs(); + assert!(matches!(f.kind_of("/nope"), Err(FsError::Invalid(_)))); + assert!(matches!(f.kind_of("/c000"), Err(FsError::Invalid(_)))); + assert!(matches!( + f.kind_of("/c001/z001/y001/x001/x001"), + Err(FsError::Invalid(_)) + )); + } + + #[test] + fn errno_mapping_is_posix() { + assert_eq!(FsError::NotFound.errno(), 2); + assert_eq!(FsError::PermissionDenied.errno(), 13); + assert_eq!(FsError::Exists.errno(), 17); + assert_eq!(FsError::NotEmpty.errno(), 39); + assert_eq!(FsError::Invalid(String::new()).errno(), 22); + assert_eq!( + FsError::WrongKind { + expected: Kind::Directory, + actual: Kind::File + } + .errno(), + 20 + ); + assert_eq!( + FsError::WrongKind { + expected: Kind::File, + actual: Kind::Directory + } + .errno(), + 21 + ); + } + + #[test] + fn rmdir_refuses_nonempty() { + let mut f = fs(); + f.create("/c001/z001/y001/x001", ROOT.0, ROOT.1, 0o644) + .unwrap(); + assert_eq!(f.rmdir("/c001"), Err(FsError::NotEmpty)); + // Once the last record goes, the inferred directory goes with it — + // there is nothing left to rmdir. + f.unlink("/c001/z001/y001/x001", ROOT.0, ROOT.1).unwrap(); + assert_eq!(f.rmdir("/c001"), Err(FsError::NotFound)); + } + + #[test] + fn mkdir_creates_an_empty_visible_directory() { + // Regression: live-mount testing showed `mkdir` succeeding and the + // kernel's revalidating lookup then returning ENOENT. + let mut f = fs(); + assert_eq!(f.kind_of("/c050"), Err(FsError::NotFound)); + f.mkdir("/c050", ROOT.0, ROOT.1, 0o755).unwrap(); + assert_eq!(f.kind_of("/c050").unwrap(), Kind::Directory); + assert_eq!(f.getattr("/c050").unwrap().kind, Kind::Directory); + assert!(f + .readdir("/") + .unwrap() + .contains(&("c050".to_string(), Kind::Directory))); + assert_eq!(f.readdir("/c050").unwrap(), vec![]); + assert_eq!( + f.mkdir("/c050", ROOT.0, ROOT.1, 0o755), + Err(FsError::Exists) + ); + } + + #[test] + fn mkdir_p_to_full_depth_then_create() { + // `mkdir -p /c050/z001/y001 && echo x > .../x001` must work. + let mut f = fs(); + f.mkdir("/c050", ROOT.0, ROOT.1, 0o755).unwrap(); + f.mkdir("/c050/z001", ROOT.0, ROOT.1, 0o755).unwrap(); + f.mkdir("/c050/z001/y001", ROOT.0, ROOT.1, 0o755).unwrap(); + assert_eq!(f.kind_of("/c050/z001/y001").unwrap(), Kind::Directory); + f.create("/c050/z001/y001/x001", ROOT.0, ROOT.1, 0o644) + .unwrap(); + f.write("/c050/z001/y001/x001", 0, b"data", ROOT.0, ROOT.1) + .unwrap(); + assert_eq!( + f.read("/c050/z001/y001/x001", 0, 8, ROOT.0, ROOT.1) + .unwrap(), + b"data".to_vec() + ); + assert_eq!( + f.readdir("/c050/z001/y001").unwrap(), + vec![("x001".to_string(), Kind::File)] + ); + } + + #[test] + fn rmdir_removes_an_empty_marker_directory() { + let mut f = fs(); + f.mkdir("/c050", ROOT.0, ROOT.1, 0o755).unwrap(); + f.rmdir("/c050").unwrap(); + assert_eq!(f.kind_of("/c050"), Err(FsError::NotFound)); + assert!(!f.readdir("/").unwrap().iter().any(|(n, _)| n == "c050")); + } + + #[test] + fn rmdir_root_is_invalid() { + let mut f = fs(); + assert!(matches!(f.rmdir("/"), Err(FsError::Invalid(_)))); + } + + #[test] + fn empty_directory_markers_never_appear_as_files() { + let mut f = fs(); + f.mkdir("/c050", ROOT.0, ROOT.1, 0o755).unwrap(); + f.mkdir("/c050/z001", ROOT.0, ROOT.1, 0o755).unwrap(); + // The marker at depth 2 must not show up in the root listing. + let root = f.readdir("/").unwrap(); + assert_eq!(root, vec![("c050".to_string(), Kind::Directory)]); + // And a depth-3 listing of an empty dir yields nothing, not a marker. + assert_eq!(f.readdir("/c050/z001").unwrap(), vec![]); + } + + #[test] + fn full_axis_range_is_addressable() { + // The corners of the record space must work end to end. + let mut f = fs(); + for coord in [[1u8, 1, 1, 1], [255, 255, 255, 255], [1, 255, 1, 255]] { + let p = path::render_path(&coord); + f.create(&p, ROOT.0, ROOT.1, 0o644).unwrap(); + f.write(&p, 0, b"z", ROOT.0, ROOT.1).unwrap(); + assert_eq!(f.read(&p, 0, 1, ROOT.0, ROOT.1).unwrap(), b"z".to_vec()); + } + } +} diff --git a/cubestore/src/lib.rs b/cubestore/src/lib.rs index 41c38ab..78edbc3 100644 --- a/cubestore/src/lib.rs +++ b/cubestore/src/lib.rs @@ -28,6 +28,34 @@ pub trait CubeBackend { fn get(&self, key: &Czyx) -> Option>; /// Remove the value at `key`. fn delete(&mut self, key: &Czyx); + + /// Optional scanning primitive (PDF Package 2: "plus optional scanning + /// primitives"). Returns every coordinate currently present. + /// + /// Decision: this is a provided method returning an empty `Vec` by + /// default so existing backends stay source-compatible, and so a backend + /// that cannot enumerate cheaply (a remote/blind KV) can honestly report + /// "no enumeration" instead of lying. `cubefs` needs enumeration to build + /// directory listings, and documents that requirement at its own API. + fn keys(&self) -> Vec { + Vec::new() + } + + /// Coordinates whose `C` (and optionally `Z`, `Y`) prefix matches. + /// + /// Provided in terms of [`CubeBackend::keys`]; a real on-disk backend + /// should override this with a range scan over the packed `u32` key, + /// which is prefix-ordered because `pack_u32` puts `C` in the high byte. + fn scan_prefix(&self, c: u8, z: Option, y: Option) -> Vec { + self.keys() + .into_iter() + .filter(|k| { + k.c == c + && z.map(|zz| k.z == zz).unwrap_or(true) + && y.map(|yy| k.y == yy).unwrap_or(true) + }) + .collect() + } } /// In-memory backend backed by a `HashMap>` keyed by the packed @@ -61,6 +89,13 @@ impl CubeBackend for HashBackend { fn delete(&mut self, key: &Czyx) { self.0.remove(&key.pack_u32()); } + fn keys(&self) -> Vec { + let mut v: Vec = self.0.keys().map(|k| Czyx::unpack_u32(*k)).collect(); + // Deterministic order: HashMap iteration is unordered, but callers + // (cubefs readdir) need a stable listing. + v.sort(); + v + } } /// A record store: a header + body addressed by a [`Czyx`] label. @@ -314,6 +349,55 @@ impl CubeStore { pub fn delete_raw(&mut self, key: &Czyx) { self.backend.delete(key); } + + /// Every coordinate present in the backend (requires a backend that + /// implements [`CubeBackend::keys`]). + pub fn keys(&self) -> Vec { + self.backend.keys() + } + + /// Coordinates under a `C`/`Z`/`Y` prefix. + pub fn scan_prefix(&self, c: u8, z: Option, y: Option) -> Vec { + self.backend.scan_prefix(c, z, y) + } + + /// PDF Package 2 API: link `src` to `dst` by appending `dst` to `src`'s + /// `linked_records` and refreshing the association flag. + /// + /// Decision: the association is stored one-way in the source header (as + /// the PDF's "association flags" describe), and reverse lookup is done by + /// scanning (see [`CubeStore::linked_to`]). Storing a reverse index would + /// double-write every association and risk divergence; scanning is cheap + /// against the packed-u32 key space and always consistent. + /// Returns `false` if `src` does not exist. + pub fn associate(&mut self, src: Czyx, dst: Czyx) -> bool { + let Some((mut h, body)) = self.get_record(&src) else { + return false; + }; + if !h.linked_records.contains(&dst) { + h.linked_records.push(dst); + } + h.refresh_flags(); + self.put_record(src, &h, &body); + true + } + + /// PDF Package 2 API: "all records linked to X" — every coordinate whose + /// header lists `target` in its `linked_records`. + pub fn linked_to(&self, target: &Czyx) -> Vec { + let mut out: Vec = self + .backend + .keys() + .into_iter() + .filter(|k| { + self.get_record(k) + .map(|(h, _)| h.linked_records.contains(target)) + .unwrap_or(false) + }) + .collect(); + out.sort(); + out + } } #[cfg(test)]