//! Path <-> coordinate bijection. //! //! # The mapping //! //! The PDF offers two options: "map C/Z/Y/X ranges to top-level directories //! (C), subdirs (Z,Y) and files (X)", or "keep the 4-D API but expose FUSE //! hooks". We implement the first, because it is the one that yields a real //! POSIX namespace a user can `ls`, and the second falls out of it for free //! (the 4-D API is just [`crate::vfs`] called directly). //! //! ```text //! / -> the volume root (synthetic) //! /c01 -> C = 1 (directory) //! /c01/z02 -> C = 1, Z = 2 (directory) //! /c01/z02/y03 -> C = 1, Z = 2, Y = 3 (directory) //! /c01/z02/y03/x04 -> C = 1, Z = 2, Y = 3, X = 4 (file / record) //! ``` //! //! # Decision: fixed-width zero-padded names, axis-letter prefixed //! //! Three candidate naming schemes were considered: //! //! 1. Bare decimal (`/1/2/3/4`) — shortest, but ambiguous with any future //! named-alias layer and sorts wrong lexically (`10` before `2`). //! 2. Hex (`/c01/z02/...` in hex) — compact but two representations for the //! same value once you allow upper/lower case, which breaks bijectivity. //! 3. Axis-letter + zero-padded decimal (chosen) — `c01`, `z255`. Sorts //! lexically in numeric order for a fixed width, is self-describing at the //! shell (`ls /mnt/cube` immediately shows which axis you're on), and is //! unambiguous because the parser demands the exact letter for the depth. //! //! Padding is to **3 digits** (`c001`..`c255`) so lexical order equals numeric //! order across the whole `1..=255` range. Parsing accepts only the canonical //! zero-padded form: accepting `c1` as well as `c001` would make the mapping a //! surjection rather than a bijection, and `rename`/`readdir` round-tripping //! would then not be an identity. //! //! # Decision: axis value 0 is not addressable via a path //! //! `0` on any axis is Null control space (see `cubecoords::NullClass`). The //! control plane is exposed through **xattrs and the journal file**, not as //! browsable directories, because presenting ACL storage as ordinary writable //! files would let a user corrupt the permission system with `echo >`. So //! [`parse_path`] rejects `c000` and friends with [`PathError::NullAxis`]. use cubecoords::Czyx; use std::fmt; /// The FUSE root inode. The kernel mandates `1`. /// /// `1` unpacks to `Czyx { c: 0, z: 0, y: 0, x: 1 }`, which is Null cube 3 — /// never a user record — so reserving it costs no user-addressable space. pub const ROOT_INO: u64 = 1; /// Errors from path parsing. #[derive(Clone, Eq, PartialEq, Debug)] pub enum PathError { /// A component did not have the required axis letter for its depth. BadAxisLetter { /// Depth at which the failure occurred (0 = C, 1 = Z, 2 = Y, 3 = X). depth: usize, /// The offending component. component: String, }, /// The numeric part was missing, non-canonical (not 3 zero-padded digits), /// or out of the `0..=255` range. BadNumber(String), /// The component addressed axis value 0, which is Null control space and /// is not exposed as a path. NullAxis(String), /// More than four components. TooDeep(usize), } impl fmt::Display for PathError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { PathError::BadAxisLetter { depth, component } => write!( f, "component {:?} at depth {} must start with '{}'", component, depth, AXIS_LETTERS[*depth] as char ), PathError::BadNumber(s) => { write!(f, "component {s:?} must be a 3-digit zero-padded 0..=255") } PathError::NullAxis(s) => write!(f, "component {s:?} addresses Null space (value 0)"), PathError::TooDeep(n) => write!(f, "path has {n} components, max 4"), } } } impl std::error::Error for PathError {} /// Axis letter per depth: C, Z, Y, X. pub const AXIS_LETTERS: [u8; 4] = *b"czyx"; /// Render an axis value as its canonical component name (e.g. `c007`). pub fn render_component(depth: usize, value: u8) -> String { format!("{}{:03}", AXIS_LETTERS[depth] as char, value) } /// Parse a single component at `depth`, returning the axis value. pub fn parse_component(depth: usize, component: &str) -> Result { let bytes = component.as_bytes(); if bytes.first().copied() != Some(AXIS_LETTERS[depth]) { return Err(PathError::BadAxisLetter { depth, component: component.to_string(), }); } let digits = &component[1..]; // Canonical form only: exactly three ASCII digits. if digits.len() != 3 || !digits.bytes().all(|b| b.is_ascii_digit()) { return Err(PathError::BadNumber(component.to_string())); } let v: u32 = digits .parse() .map_err(|_| PathError::BadNumber(component.to_string()))?; if v > 255 { return Err(PathError::BadNumber(component.to_string())); } if v == 0 { return Err(PathError::NullAxis(component.to_string())); } Ok(v as u8) } /// A parsed path: the axis values supplied so far. /// /// Length 0 = the volume root, 1 = a C directory, 2 = C/Z, 3 = C/Z/Y, /// 4 = a full record (a file). #[derive(Clone, Eq, PartialEq, Debug, Default)] pub struct ParsedPath { /// Axis values in C, Z, Y, X order (partial prefixes allowed). pub axes: Vec, } impl ParsedPath { /// True when all four axes are present (i.e. this names a record/file). pub fn is_record(&self) -> bool { self.axes.len() == 4 } /// The full coordinate, if all four axes are present. pub fn czyx(&self) -> Option { if self.axes.len() == 4 { Some(Czyx::new( self.axes[0], self.axes[1], self.axes[2], self.axes[3], )) } else { None } } /// The coordinate this prefix denotes, with unfilled axes set to 0. /// /// Decision: a *directory* is represented internally as the coordinate /// with its trailing axes zeroed. That collides with Null space by value, /// which is exactly why directories are never stored as records — they are /// synthesized by [`crate::vfs::CubeFs::readdir`] from a prefix scan. The /// zeroed form is used only as a scan key and an inode, never as a record /// address. pub fn prefix_coord(&self) -> Czyx { let g = |i: usize| self.axes.get(i).copied().unwrap_or(0); Czyx::new(g(0), g(1), g(2), g(3)) } } /// Parse a slash-separated POSIX path into axis values. /// /// Leading/trailing slashes and empty components are ignored, so `/c001/`, /// `c001`, and `//c001` all parse identically. pub fn parse_path(p: &str) -> Result { let comps: Vec<&str> = p.split('/').filter(|s| !s.is_empty()).collect(); if comps.len() > 4 { return Err(PathError::TooDeep(comps.len())); } let mut axes = Vec::with_capacity(comps.len()); for (depth, c) in comps.iter().enumerate() { axes.push(parse_component(depth, c)?); } Ok(ParsedPath { axes }) } /// Render axis values back to a canonical absolute path. pub fn render_path(axes: &[u8]) -> String { if axes.is_empty() { return "/".to_string(); } let mut s = String::new(); for (depth, v) in axes.iter().enumerate() { s.push('/'); s.push_str(&render_component(depth, *v)); } s } /// 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 { if ino > u32::MAX as u64 { return None; } Some(Czyx::unpack_u32(ino as u32)) } #[cfg(test)] mod tests { use super::*; #[test] fn round_trip_full_record_path() { let p = parse_path("/c001/z002/y003/x004").unwrap(); assert!(p.is_record()); assert_eq!(p.czyx(), Some(Czyx::new(1, 2, 3, 4))); assert_eq!(render_path(&p.axes), "/c001/z002/y003/x004"); } #[test] fn round_trip_every_depth() { for depth in 0..=4usize { let axes: Vec = (1..=depth as u8).map(|i| i * 7).collect(); let rendered = render_path(&axes); let back = parse_path(&rendered).unwrap(); assert_eq!(back.axes, axes, "failed at depth {depth}"); } } #[test] fn root_is_empty_prefix() { assert_eq!(parse_path("/").unwrap().axes, Vec::::new()); assert_eq!(render_path(&[]), "/"); } #[test] fn slashes_are_normalized() { assert_eq!(parse_path("//c001//z002/").unwrap().axes, vec![1, 2]); } #[test] fn wrong_axis_letter_rejected() { // 'z' at depth 0 must fail — depth determines the letter. assert!(matches!( parse_path("/z001"), Err(PathError::BadAxisLetter { depth: 0, .. }) )); } #[test] fn non_canonical_number_rejected() { // Bijectivity requires exactly one spelling per value. assert!(matches!(parse_path("/c1"), Err(PathError::BadNumber(_)))); assert!(matches!(parse_path("/c0001"), Err(PathError::BadNumber(_)))); assert!(matches!(parse_path("/c256"), Err(PathError::BadNumber(_)))); assert!(matches!(parse_path("/cxyz"), Err(PathError::BadNumber(_)))); } #[test] fn null_axis_not_addressable() { assert!(matches!(parse_path("/c000"), Err(PathError::NullAxis(_)))); assert!(matches!( parse_path("/c001/z002/y003/x000"), Err(PathError::NullAxis(_)) )); } #[test] fn too_deep_rejected() { assert_eq!( parse_path("/c001/z002/y003/x004/x005"), Err(PathError::TooDeep(5)) ); } #[test] fn ino_is_the_coordinate() { let c = Czyx::new(12, 34, 56, 78); assert_eq!(ino_to_czyx(czyx_to_ino(c)), Some(c)); // Root inode 1 is a Null cube, never a user record. let root = ino_to_czyx(ROOT_INO).unwrap(); assert!(root.is_null_cube()); assert_eq!(root, Czyx::new(0, 0, 0, 1)); } #[test] fn ino_out_of_range_is_none() { assert!(ino_to_czyx(u64::from(u32::MAX) + 1).is_none()); } #[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)); } } } } } }