feat(cubelinux-2): Package 3 — cubefs (POSIX/FUSE namespace over CZYX)

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.
This commit is contained in:
CUBELinux-2
2026-08-10 19:23:59 -04:00
parent c23a45def2
commit a308f8422d
11 changed files with 2885 additions and 4 deletions
+1
View File
@@ -1,2 +1,3 @@
/target
Cargo.lock
target/
+1
View File
@@ -3,6 +3,7 @@ resolver = "2"
members = [
"cubecoords",
"cubestore",
"cubefs",
]
[workspace.package]
+1 -4
View File
@@ -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`].
+25
View File
@@ -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"]
+85
View File
@@ -0,0 +1,85 @@
//! `cubefs-mount` — mount a cube as a POSIX filesystem.
//!
//! Usage: `cubefs-mount <mountpoint> [--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<String> = std::env::args().collect();
let Some(mountpoint) = args.get(1).filter(|a| !a.starts_with("--")) else {
eprintln!("usage: cubefs-mount <mountpoint> [--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
}
}
}
+562
View File
@@ -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<u8> {
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<String> {
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<B: CubeBackend> {
fs: CubeFs<B>,
}
impl<B: CubeBackend> CubeFuse<B> {
/// Wrap a [`CubeFs`].
pub fn new(fs: CubeFs<B>) -> 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<String, FsError> {
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<B: CubeBackend + 'static> Filesystem for CubeFuse<B> {
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<u64>, 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<u32>,
uid: Option<u32>,
gid: Option<u32>,
size: Option<u64>,
_atime: Option<fuser::TimeOrNow>,
_mtime: Option<fuser::TimeOrNow>,
_ctime: Option<SystemTime>,
_fh: Option<u64>,
_crtime: Option<SystemTime>,
_chgtime: Option<SystemTime>,
_bkuptime: Option<SystemTime>,
_flags: Option<u32>,
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<u64>,
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<u64>,
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());
}
}
+51
View File
@@ -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};
+782
View File
@@ -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<Self> {
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<u8>, v: u32) {
o.extend_from_slice(&v.to_le_bytes());
}
fn put_u64(o: &mut Vec<u8>, v: u64) {
o.extend_from_slice(&v.to_le_bytes());
}
fn put_bytes(o: &mut Vec<u8>, 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>, &[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<B: CubeBackend>(store: &CubeStore<B>) -> 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<B: CubeBackend>(store: &mut CubeStore<B>, m: &VolumeMeta) {
store.put_raw(Self::volume_coord(), Self::encode_volume(m));
}
fn encode_volume(m: &VolumeMeta) -> Vec<u8> {
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<VolumeMeta> {
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<B: CubeBackend>(store: &CubeStore<B>, subject: Czyx) -> Option<Acl> {
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<B: CubeBackend>(store: &mut CubeStore<B>, 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<u8> {
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<B: CubeBackend>(store: &mut CubeStore<B>, 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<B: CubeBackend>(store: &CubeStore<B>) -> Vec<Czyx> {
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<B: CubeBackend>(
store: &CubeStore<B>,
subject: Czyx,
) -> Vec<(String, Vec<u8>)> {
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<B: CubeBackend>(
store: &CubeStore<B>,
subject: Czyx,
name: &str,
) -> Option<Vec<u8>> {
Self::list_xattr(store, subject)
.into_iter()
.find(|(n, _)| n == name)
.map(|(_, v)| v)
}
/// Set an xattr.
pub fn set_xattr<B: CubeBackend>(
store: &mut CubeStore<B>,
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<B: CubeBackend>(
store: &mut CubeStore<B>,
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<u8>)]) -> Vec<u8> {
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<u8>)> {
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<B: CubeBackend>(
store: &mut CubeStore<B>,
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<B: CubeBackend>(store: &CubeStore<B>, seq: u64) -> Option<JournalEntry> {
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<B: CubeBackend>(store: &CubeStore<B>, max: usize) -> Vec<JournalEntry> {
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<HashBackend> {
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);
}
}
+325
View File
@@ -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<u8, PathError> {
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<u8>,
}
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<Czyx> {
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<ParsedPath, PathError> {
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<Czyx> {
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<u8> = (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::<u8>::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));
}
}
}
}
}
}
+968
View File
@@ -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<B: CubeBackend> {
store: CubeStore<B>,
}
impl<B: CubeBackend> CubeFs<B> {
/// Wrap a store.
pub fn new(store: CubeStore<B>) -> 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<B> {
&self.store
}
/// Mutably borrow the underlying store.
pub fn store_mut(&mut self) -> &mut CubeStore<B> {
&mut self.store
}
fn parse(&self, p: &str) -> Result<ParsedPath, FsError> {
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<Kind, FsError> {
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<Attr, FsError> {
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<Vec<(String, Kind)>, 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<u8> = 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<Czyx> = 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<Attr, 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_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<Vec<u8>, 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<u32, 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)?;
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<Vec<u8>, 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<Vec<String>, 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<HashBackend> {
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<JournalOp> = 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());
}
}
}
+84
View File
@@ -28,6 +28,34 @@ pub trait CubeBackend {
fn get(&self, key: &Czyx) -> Option<Vec<u8>>;
/// 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<Czyx> {
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<u8>, y: Option<u8>) -> Vec<Czyx> {
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<u32, Vec<u8>>` 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<Czyx> {
let mut v: Vec<Czyx> = 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<B: CubeBackend> CubeStore<B> {
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<Czyx> {
self.backend.keys()
}
/// Coordinates under a `C`/`Z`/`Y` prefix.
pub fn scan_prefix(&self, c: u8, z: Option<u8>, y: Option<u8>) -> Vec<Czyx> {
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<Czyx> {
let mut out: Vec<Czyx> = 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)]