Phase 6a: read-only FUSE mount over the blob store
Build with clawstor cache / Cargo build (clawstor-cached) (pull_request) Failing after 3s

First slice of Phase 6. Ships a minimal, feature-gated `claw-fuse`
binary that mounts the local blob store read-only as a POSIX
filesystem:

  <mount>/blobs/<blob-id-hex>   ← file, content = assembled blob
  <mount>/blobs/                ← dir, ls shows all blob-ids
  <mount>/                      ← dir, contains `blobs`

Lets an operator `tar -tvzf`, `md5sum`, or grep at a cached
tarball without wiring a client. Debug + audit tool for now;
warm-tier git-worktrees + write path come in later slices.

Feature-gated so my macOS dev box doesn't need macFUSE headers
to build the rest of the tree:
* Cargo.toml declares `[[bin]] name = "claw-fuse"` with
  `required-features = ["fuse"]`.
* Feature `fuse` pulls in `fuser = "0.15"`, target-restricted
  to `cfg(target_os = "linux")` — dep resolution never
  considers fuser on other platforms.
* `cargo build` (default) leaves claw-fuse out entirely.
  `cargo build --features fuse --bin claw-fuse` on Linux builds it.

Design notes baked into the impl:
* Inode allocation is lazy — first `lookup` for a hex assigns an
  inode. Avoids pre-indexing the full blob store at mount time
  which would be O(blobs) fs walk before FUSE is even ready.
* getattr / read validate that the manifest exists on every
  call — no stale-inode reads if a blob is GC'd out from under
  us mid-mount. Extra read cost is negligible against the
  per-request FUSE overhead.
* size = manifest.total_size (bytes reported without touching
  chunk files) so `ls -l` is cheap.
* runtime = current-thread tokio, block_on per callback. fuser
  is sync; a full tokio worker pool would just add scheduling
  overhead when callbacks are already serialized by the kernel.

No new tests here — Filesystem impls are integration-heavy and
the underlying BlobStore methods are already covered. The
`fuse` feature build itself will be smoke-tested on tank.

381 tests pass unchanged (feature-gated bin doesn't affect the
existing test surface).
This commit is contained in:
Omar Sobh
2026-07-14 12:13:59 -07:00
parent cf12554128
commit c678c08c76
3 changed files with 408 additions and 0 deletions
Generated
+59
View File
@@ -379,6 +379,7 @@ dependencies = [
"chitchat", "chitchat",
"chrono", "chrono",
"clap", "clap",
"fuser",
"http-body-util", "http-body-util",
"libc", "libc",
"quinn", "quinn",
@@ -555,6 +556,22 @@ dependencies = [
"percent-encoding", "percent-encoding",
] ]
[[package]]
name = "fuser"
version = "0.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53274f494609e77794b627b1a3cddfe45d675a6b2e9ba9c0fdc8d8eee2184369"
dependencies = [
"libc",
"log",
"memchr",
"nix",
"page_size",
"pkg-config",
"smallvec",
"zerocopy",
]
[[package]] [[package]]
name = "futures-channel" name = "futures-channel"
version = "0.3.32" version = "0.3.32"
@@ -1067,6 +1084,18 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "nix"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
dependencies = [
"bitflags",
"cfg-if",
"cfg_aliases",
"libc",
]
[[package]] [[package]]
name = "nom" name = "nom"
version = "7.1.3" version = "7.1.3"
@@ -1150,6 +1179,16 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "page_size"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da"
dependencies = [
"libc",
"winapi",
]
[[package]] [[package]]
name = "parking_lot" name = "parking_lot"
version = "0.12.5" version = "0.12.5"
@@ -2653,6 +2692,26 @@ dependencies = [
"synstructure", "synstructure",
] ]
[[package]]
name = "zerocopy"
version = "0.8.54"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.54"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "zerofrom" name = "zerofrom"
version = "0.1.8" version = "0.1.8"
+21
View File
@@ -14,6 +14,27 @@ path = "src/main.rs"
name = "claw-cargo" name = "claw-cargo"
path = "src/claw_cargo.rs" path = "src/claw_cargo.rs"
# Phase 6 (2026-07-14): read-only FUSE mount that exposes the blob
# store as a filesystem. Gated behind the `fuse` cargo feature +
# Linux-only in dep resolution so my macOS dev box doesn't need
# macFUSE headers installed to build the other bins.
#
# Build with: cargo build --features fuse --bin claw-fuse
[[bin]]
name = "claw-fuse"
path = "src/claw_fuse.rs"
required-features = ["fuse"]
[features]
default = []
# Phase 6: enables the claw-fuse binary + pulls in the fuser dep.
fuse = ["dep:fuser"]
[target.'cfg(target_os = "linux")'.dependencies]
# v0.15 — Rust FUSE bindings. Only pulled on Linux + only when
# the `fuse` feature is on; keeps macOS + non-fuse builds clean.
fuser = { version = "0.15", optional = true }
[dependencies] [dependencies]
clap = { version = "4", features = ["derive", "env"] } clap = { version = "4", features = ["derive", "env"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
+328
View File
@@ -0,0 +1,328 @@
//! Phase 6 (2026-07-14): read-only FUSE mount over the clawstor
//! blob store.
//!
//! Layout at the mount point:
//!
//! ```text
//! <mount>/blobs/<blob-id-hex> # regular file, content = assembled blob bytes
//! <mount>/blobs/ # dir, ls shows every blob-id in the store
//! <mount>/ # dir, contains a single `blobs` entry
//! ```
//!
//! Read-only. No writes, no metadata mutation, no permissions changes.
//! Small, obviously safe first slice — later slices will layer warm-tier
//! git worktrees + smart-clean modes on top.
//!
//! Build gate: this file is compiled only when `--features fuse` is set
//! (see Cargo.toml). Linux-only dep on the fuser crate.
use anyhow::{Context, Result};
use clap::Parser;
use fuser::{FileAttr, FileType, Filesystem, MountOption, ReplyAttr, ReplyData, ReplyDirectory, ReplyEntry, Request};
use std::collections::HashMap;
use std::ffi::OsStr;
use std::path::PathBuf;
use std::time::{Duration, UNIX_EPOCH};
// Same module tree as main.rs / claw_cargo.rs — bin-only crate,
// so we redeclare the mods here. Only `cluster::blob` is actually
// used from this bin.
mod actions;
mod cargo_init;
mod cluster;
mod config;
mod daemon;
mod head_watch;
mod hot;
mod manifest;
mod restore;
mod serve;
use cluster::blob::{BlobId, BlobStore};
const TTL: Duration = Duration::from_secs(1);
// Inode number layout:
// * 1 = root ("/")
// * 2 = "/blobs" directory
// * 100_000.. = individual blob files. We assign these lazily on
// the first lookup so we don't have to pre-index
// the whole store at mount time.
const ROOT_INO: u64 = 1;
const BLOBS_DIR_INO: u64 = 2;
const FIRST_BLOB_INO: u64 = 100_000;
/// Read-only FUSE mount over a BlobStore.
struct ClawFuse {
store: BlobStore,
/// Runtime for async BlobStore calls. fuser is sync so we
/// block_on inside each callback.
runtime: tokio::runtime::Runtime,
/// blob-hex → allocated inode. Populated on lookup.
hex_to_ino: HashMap<String, u64>,
/// inode → blob-hex. Reverse lookup for getattr / read.
ino_to_hex: HashMap<u64, String>,
next_ino: u64,
}
impl ClawFuse {
fn new(store: BlobStore) -> Result<Self> {
Ok(Self {
store,
runtime: tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?,
hex_to_ino: HashMap::new(),
ino_to_hex: HashMap::new(),
next_ino: FIRST_BLOB_INO,
})
}
fn dir_attr(ino: u64) -> FileAttr {
FileAttr {
ino,
size: 0,
blocks: 0,
atime: UNIX_EPOCH,
mtime: UNIX_EPOCH,
ctime: UNIX_EPOCH,
crtime: UNIX_EPOCH,
kind: FileType::Directory,
perm: 0o555,
nlink: 2,
uid: unsafe { libc::getuid() },
gid: unsafe { libc::getgid() },
rdev: 0,
flags: 0,
blksize: 4096,
}
}
fn file_attr(ino: u64, size: u64) -> FileAttr {
FileAttr {
ino,
size,
blocks: size.div_ceil(512),
atime: UNIX_EPOCH,
mtime: UNIX_EPOCH,
ctime: UNIX_EPOCH,
crtime: UNIX_EPOCH,
kind: FileType::RegularFile,
perm: 0o444,
nlink: 1,
uid: unsafe { libc::getuid() },
gid: unsafe { libc::getgid() },
rdev: 0,
flags: 0,
blksize: 4096,
}
}
/// Resolve a blob-hex string to an inode, allocating one if
/// this is the first lookup. Returns None if the hex doesn't
/// name a real blob on disk.
fn resolve_hex(&mut self, hex: &str) -> Option<(u64, u64)> {
// Validate: exactly 64 hex chars, decodable.
let blob_id = BlobId::from_hex(hex).ok()?;
// Confirm the blob's manifest exists — else we'd claim a
// file that no read can satisfy.
let manifest = self
.runtime
.block_on(self.store.load_manifest(&blob_id))
.ok()
.flatten()?;
let ino = *self.hex_to_ino.entry(hex.to_string()).or_insert_with(|| {
let n = self.next_ino;
self.next_ino += 1;
n
});
self.ino_to_hex.entry(ino).or_insert_with(|| hex.to_string());
Some((ino, manifest.total_size))
}
}
impl Filesystem for ClawFuse {
fn lookup(&mut self, _req: &Request, parent: u64, name: &OsStr, reply: ReplyEntry) {
let name = match name.to_str() {
Some(s) => s,
None => {
reply.error(libc::ENOENT);
return;
}
};
match parent {
ROOT_INO if name == "blobs" => {
reply.entry(&TTL, &Self::dir_attr(BLOBS_DIR_INO), 0);
}
BLOBS_DIR_INO => match self.resolve_hex(name) {
Some((ino, size)) => reply.entry(&TTL, &Self::file_attr(ino, size), 0),
None => reply.error(libc::ENOENT),
},
_ => reply.error(libc::ENOENT),
}
}
fn getattr(&mut self, _req: &Request, ino: u64, _fh: Option<u64>, reply: ReplyAttr) {
match ino {
ROOT_INO | BLOBS_DIR_INO => reply.attr(&TTL, &Self::dir_attr(ino)),
_ => {
let hex = match self.ino_to_hex.get(&ino).cloned() {
Some(h) => h,
None => {
reply.error(libc::ENOENT);
return;
}
};
let blob_id = match BlobId::from_hex(&hex) {
Ok(id) => id,
Err(_) => {
reply.error(libc::EIO);
return;
}
};
let size = self
.runtime
.block_on(self.store.load_manifest(&blob_id))
.ok()
.flatten()
.map(|m| m.total_size)
.unwrap_or(0);
reply.attr(&TTL, &Self::file_attr(ino, size));
}
}
}
fn readdir(
&mut self,
_req: &Request,
ino: u64,
_fh: u64,
offset: i64,
mut reply: ReplyDirectory,
) {
let mut entries: Vec<(u64, FileType, String)> = Vec::new();
match ino {
ROOT_INO => {
entries.push((ROOT_INO, FileType::Directory, ".".into()));
entries.push((ROOT_INO, FileType::Directory, "..".into()));
entries.push((BLOBS_DIR_INO, FileType::Directory, "blobs".into()));
}
BLOBS_DIR_INO => {
entries.push((BLOBS_DIR_INO, FileType::Directory, ".".into()));
entries.push((ROOT_INO, FileType::Directory, "..".into()));
let ids = match self.runtime.block_on(self.store.list_blob_ids()) {
Ok(v) => v,
Err(_) => {
reply.error(libc::EIO);
return;
}
};
for id in ids {
let hex = id.to_hex();
let ino = *self
.hex_to_ino
.entry(hex.clone())
.or_insert_with(|| {
let n = self.next_ino;
self.next_ino += 1;
n
});
self.ino_to_hex
.entry(ino)
.or_insert_with(|| hex.clone());
entries.push((ino, FileType::RegularFile, hex));
}
}
_ => {
reply.error(libc::ENOTDIR);
return;
}
}
for (i, (ino, kind, name)) in entries.into_iter().enumerate().skip(offset as usize) {
if reply.add(ino, (i + 1) as i64, kind, name) {
break;
}
}
reply.ok();
}
fn read(
&mut self,
_req: &Request,
ino: u64,
_fh: u64,
offset: i64,
size: u32,
_flags: i32,
_lock: Option<u64>,
reply: ReplyData,
) {
let hex = match self.ino_to_hex.get(&ino).cloned() {
Some(h) => h,
None => {
reply.error(libc::ENOENT);
return;
}
};
let blob_id = match BlobId::from_hex(&hex) {
Ok(id) => id,
Err(_) => {
reply.error(libc::EIO);
return;
}
};
let bytes = match self.runtime.block_on(self.store.get_bytes(&blob_id)) {
Ok(Some(b)) => b,
_ => {
reply.error(libc::EIO);
return;
}
};
let start = (offset as usize).min(bytes.len());
let end = (start + size as usize).min(bytes.len());
reply.data(&bytes[start..end]);
}
}
#[derive(Parser, Debug)]
#[command(name = "claw-fuse", about = "Read-only FUSE mount over clawstor blob store")]
struct Cli {
/// Path to `cluster.blob_store_root` (same value the daemon
/// uses in config.toml).
#[arg(long)]
data_dir: PathBuf,
/// Mount point (existing empty directory).
#[arg(long)]
mount: PathBuf,
/// Allow other users to access the mount. Default: current
/// user only.
#[arg(long)]
allow_other: bool,
}
fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(std::env::var("RUST_LOG").unwrap_or_else(|_| "info".into()))
.init();
let cli = Cli::parse();
if !cli.mount.is_dir() {
anyhow::bail!("mount point {} does not exist or is not a directory", cli.mount.display());
}
let store = BlobStore::open(cli.data_dir.clone())
.with_context(|| format!("opening blob store at {}", cli.data_dir.display()))?;
let fs = ClawFuse::new(store)?;
let mut opts = vec![
MountOption::RO,
MountOption::FSName("clawstor".into()),
MountOption::Subtype("clawstor".into()),
MountOption::NoAtime,
];
if cli.allow_other {
opts.push(MountOption::AllowOther);
}
tracing::info!(mount = %cli.mount.display(), "mounting claw-fuse (read-only)");
// Blocks until SIGINT/umount.
fuser::mount2(fs, &cli.mount, &opts)
.with_context(|| format!("mounting FUSE at {}", cli.mount.display()))?;
Ok(())
}