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:
@@ -1,2 +1,3 @@
|
|||||||
/target
|
/target
|
||||||
Cargo.lock
|
Cargo.lock
|
||||||
|
target/
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ resolver = "2"
|
|||||||
members = [
|
members = [
|
||||||
"cubecoords",
|
"cubecoords",
|
||||||
"cubestore",
|
"cubestore",
|
||||||
|
"cubefs",
|
||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
|
|||||||
@@ -52,10 +52,7 @@ impl Czyx {
|
|||||||
/// Pack into a single `u32` with `C` in the high byte, `X` in the low byte.
|
/// Pack into a single `u32` with `C` in the high byte, `X` in the low byte.
|
||||||
#[inline]
|
#[inline]
|
||||||
pub fn pack_u32(&self) -> u32 {
|
pub fn pack_u32(&self) -> u32 {
|
||||||
((self.c as u32) << 24)
|
((self.c as u32) << 24) | ((self.z as u32) << 16) | ((self.y as u32) << 8) | (self.x as u32)
|
||||||
| ((self.z as u32) << 16)
|
|
||||||
| ((self.y as u32) << 8)
|
|
||||||
| (self.x as u32)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Unpack a `u32` produced by [`pack_u32`].
|
/// Unpack a `u32` produced by [`pack_u32`].
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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};
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,34 @@ pub trait CubeBackend {
|
|||||||
fn get(&self, key: &Czyx) -> Option<Vec<u8>>;
|
fn get(&self, key: &Czyx) -> Option<Vec<u8>>;
|
||||||
/// Remove the value at `key`.
|
/// Remove the value at `key`.
|
||||||
fn delete(&mut self, key: &Czyx);
|
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
|
/// 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) {
|
fn delete(&mut self, key: &Czyx) {
|
||||||
self.0.remove(&key.pack_u32());
|
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.
|
/// 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) {
|
pub fn delete_raw(&mut self, key: &Czyx) {
|
||||||
self.backend.delete(key);
|
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)]
|
#[cfg(test)]
|
||||||
|
|||||||
Reference in New Issue
Block a user