387 lines
14 KiB
Rust
387 lines
14 KiB
Rust
//! 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
|
|
}
|
|
|
|
/// Parse the `.czyx.C.Z.Y.X` magic-prefix form — the Phase-3 "open by CZYX"
|
|
/// at the filesystem level. A single path component `.czyx.12.34.56.78`
|
|
/// resolves to the coordinate `(12,34,56,78)` regardless of the normal
|
|
/// `c012/z034/y056/x078` spelling, so the kernel opens the record *by its
|
|
/// coordinate*, not by path. Partial forms (`.czyx.12`, `.czyx.12.34`) address
|
|
/// the synthetic directory prefix (trailing axes default to 0).
|
|
///
|
|
/// Returns the four axis values (trailing axes 0 for partial forms).
|
|
pub fn parse_dot_czyx(name: &str) -> Option<[u8; 4]> {
|
|
let parts: Vec<&str> = name.split('.').collect();
|
|
// expect ["", "czyx", C, (Z)?, (Y)?, (X)?]
|
|
if parts.len() < 3 || !parts[0].is_empty() || parts[1] != "czyx" {
|
|
return None;
|
|
}
|
|
let mut axes = [0u8; 4];
|
|
let mut n = 0usize;
|
|
for p in &parts[2..] {
|
|
if n >= 4 {
|
|
return None; // too many axes
|
|
}
|
|
let v: u32 = p.parse().ok()?;
|
|
// 0 is Null control space; >255 is out of the addressable range.
|
|
if v == 0 || v > 255 {
|
|
return None;
|
|
}
|
|
axes[n] = v as u8;
|
|
n += 1;
|
|
}
|
|
if n == 0 {
|
|
return None;
|
|
}
|
|
Some(axes)
|
|
}
|
|
|
|
/// 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());
|
|
}
|
|
|
|
#[test]
|
|
fn dot_czyx_magic_prefix() {
|
|
// Full coordinate form resolves to (C,Z,Y,X).
|
|
assert_eq!(parse_dot_czyx(".czyx.12.34.56.78"), Some([12, 34, 56, 78]));
|
|
// Partial forms fill trailing axes with 0 (directory prefixes).
|
|
assert_eq!(parse_dot_czyx(".czyx.7"), Some([7, 0, 0, 0]));
|
|
assert_eq!(parse_dot_czyx(".czyx.1.2.3"), Some([1, 2, 3, 0]));
|
|
// Rejects malformed input and out-of-range / Null values.
|
|
assert_eq!(parse_dot_czyx("czyx.1.2.3.4"), None); // missing leading dot
|
|
assert_eq!(parse_dot_czyx(".czyx"), None); // no axes
|
|
assert_eq!(parse_dot_czyx(".czyx.0.1.2.3"), None); // Null axis 0
|
|
assert_eq!(parse_dot_czyx(".czyx.1.2.3.4.5"), None); // too many axes
|
|
assert_eq!(parse_dot_czyx(".czyx.256"), None); // out of range
|
|
assert_eq!(parse_dot_czyx(".czyx.abc"), None); // non-numeric
|
|
}
|
|
|
|
#[test]
|
|
fn dot_czyx_resolves_same_ino_as_canonical() {
|
|
// The magic prefix must map to the *same inode* as the canonical path,
|
|
// so the kernel opens the record by coordinate, not path.
|
|
let axes = parse_dot_czyx(".czyx.12.34.56.78").unwrap();
|
|
let c = Czyx::new(axes[0], axes[1], axes[2], axes[3]);
|
|
let canonical = parse_path("/c012/z034/y056/x078").unwrap().czyx().unwrap();
|
|
assert_eq!(c, canonical);
|
|
assert_eq!(czyx_to_ino(c), czyx_to_ino(canonical));
|
|
}
|
|
|
|
/// 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));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|