use std::io::Write;
use std::path::{Path, PathBuf};
use std::process;
use anyhow::Result;
use clap::{Parser, Subcommand};
use branchfs::daemon::{self, Request, Response};
#[derive(Parser)]
#[command(name = "branchfs")]
#[command(about = "FUSE filesystem with atomic branching")]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
Mount {
#[arg(long)]
base: Option<PathBuf>,
#[arg(long, default_value = "/var/lib/branchfs")]
storage: PathBuf,
#[arg(long)]
passthrough: bool,
#[arg(long, value_parser = parse_size)]
max_storage: Option<u64>,
mountpoint: PathBuf,
},
Create {
name: String,
mountpoint: PathBuf,
#[arg(long, short, default_value = "main")]
parent: String,
#[arg(long, default_value = "/var/lib/branchfs")]
storage: PathBuf,
},
Commit {
mountpoint: PathBuf,
#[arg(long, default_value = "/var/lib/branchfs")]
storage: PathBuf,
},
Abort {
mountpoint: PathBuf,
#[arg(long, default_value = "/var/lib/branchfs")]
storage: PathBuf,
},
List {
#[arg(long, default_value = "/var/lib/branchfs")]
storage: PathBuf,
},
Unmount {
mountpoint: PathBuf,
#[arg(long, default_value = "/var/lib/branchfs")]
storage: PathBuf,
},
#[command(hide = true)]
RunDaemon {
#[arg(long)]
base: PathBuf,
#[arg(long)]
storage: PathBuf,
#[arg(long)]
max_storage: Option<u64>,
},
}
fn parse_size(s: &str) -> std::result::Result<u64, String> {
let s = s.trim();
if s.is_empty() {
return Err("empty size string".to_string());
}
let (num_str, multiplier) = match s.as_bytes().last() {
Some(b'K' | b'k') => (&s[..s.len() - 1], 1024u64),
Some(b'M' | b'm') => (&s[..s.len() - 1], 1024 * 1024),
Some(b'G' | b'g') => (&s[..s.len() - 1], 1024 * 1024 * 1024),
Some(b'T' | b't') => (&s[..s.len() - 1], 1024 * 1024 * 1024 * 1024),
_ => (s, 1),
};
let num: u64 = num_str
.parse()
.map_err(|_| format!("invalid size: {}", s))?;
num.checked_mul(multiplier)
.ok_or_else(|| format!("size overflow: {}", s))
}
fn get_socket_path(storage: &Path) -> PathBuf {
storage.join("daemon.sock")
}
fn send_request(storage: &Path, request: &Request) -> Result<Response> {
let socket_path = get_socket_path(storage);
daemon::send_request(&socket_path, request)
.map_err(|e| anyhow::anyhow!("Failed to communicate with daemon: {}", e))
}
fn get_mount_branch(storage: &Path, mountpoint: &Path) -> Result<String> {
let resp = send_request(
storage,
&Request::GetMountBranch {
mountpoint: mountpoint.to_string_lossy().to_string(),
},
)?;
if !resp.ok {
anyhow::bail!("{}", resp.error.unwrap_or_else(|| "unknown error".into()));
}
resp.data
.and_then(|d| d.as_str().map(|s| s.to_string()))
.ok_or_else(|| anyhow::anyhow!("daemon returned no branch info"))
}
fn main() -> Result<()> {
env_logger::init();
let cli = Cli::parse();
match cli.command {
Commands::Mount {
base,
storage,
passthrough,
max_storage,
mountpoint,
} => {
if passthrough && nix::unistd::geteuid().as_raw() != 0 {
eprintln!("Error: --passthrough requires root (CAP_SYS_ADMIN)");
process::exit(1);
}
std::fs::create_dir_all(&storage)?;
let storage = storage.canonicalize()?;
let base = base.map(|b| b.canonicalize()).transpose()?;
daemon::ensure_daemon(base.as_deref(), &storage, max_storage)
.map_err(|e| anyhow::anyhow!("{}", e))?;
std::fs::create_dir_all(&mountpoint)?;
let mountpoint = mountpoint.canonicalize()?;
let response = send_request(
&storage,
&Request::Mount {
branch: "main".to_string(),
mountpoint: mountpoint.to_string_lossy().to_string(),
passthrough,
},
)?;
if response.ok {
println!("Mounted at {:?}", mountpoint);
} else {
eprintln!("Error: {}", response.error.unwrap_or_default());
process::exit(1);
}
}
Commands::Create {
name,
mountpoint,
parent,
storage,
} => {
let storage = storage.canonicalize()?;
let mountpoint = mountpoint.canonicalize()?;
let response = send_request(
&storage,
&Request::Create {
name: name.clone(),
parent: parent.clone(),
},
)?;
if response.ok {
let ctl_path = mountpoint.join(".branchfs_ctl");
let mut file = std::fs::OpenOptions::new()
.write(true)
.open(&ctl_path)
.map_err(|e| {
anyhow::anyhow!(
"Failed to open control file (is {} mounted?): {}",
mountpoint.display(),
e
)
})?;
file.write_all(format!("switch:{}", name).as_bytes())
.map_err(|e| anyhow::anyhow!("Failed to switch to branch: {}", e))?;
println!(
"Created and switched to branch '{}' (parent: '{}')",
name, parent
);
} else {
eprintln!("Error: {}", response.error.unwrap_or_default());
process::exit(1);
}
}
Commands::Commit {
mountpoint,
storage,
} => {
let mountpoint = mountpoint.canonicalize()?;
let storage = storage.canonicalize()?;
let branch = get_mount_branch(&storage, &mountpoint)?;
if branch == "main" {
anyhow::bail!("Cannot commit main branch");
}
let ctl_path = mountpoint
.join(format!("@{}", branch))
.join(".branchfs_ctl");
let mut file = std::fs::OpenOptions::new()
.write(true)
.open(&ctl_path)
.map_err(|e| anyhow::anyhow!("Failed to open branch control file: {}", e))?;
file.write_all(b"commit")
.map_err(|e| anyhow::anyhow!("Commit failed: {}", e))?;
println!("Committed branch at {:?}", mountpoint);
}
Commands::Abort {
mountpoint,
storage,
} => {
let mountpoint = mountpoint.canonicalize()?;
let storage = storage.canonicalize()?;
let branch = get_mount_branch(&storage, &mountpoint)?;
if branch == "main" {
anyhow::bail!("Cannot abort main branch");
}
let ctl_path = mountpoint
.join(format!("@{}", branch))
.join(".branchfs_ctl");
let mut file = std::fs::OpenOptions::new()
.write(true)
.open(&ctl_path)
.map_err(|e| anyhow::anyhow!("Failed to open branch control file: {}", e))?;
file.write_all(b"abort")
.map_err(|e| anyhow::anyhow!("Abort failed: {}", e))?;
println!("Aborted branch at {:?}", mountpoint);
}
Commands::List { storage } => {
let storage = storage.canonicalize()?;
let response = send_request(&storage, &Request::List)?;
if response.ok {
println!("{:<20} {:<20}", "BRANCH", "PARENT");
println!("{:<20} {:<20}", "------", "------");
if let Some(data) = response.data {
if let Some(branches) = data.as_array() {
for branch in branches {
let name = branch["name"].as_str().unwrap_or("-");
let parent = branch["parent"].as_str().unwrap_or("-");
println!("{:<20} {:<20}", name, parent);
}
}
}
} else {
eprintln!("Error: {}", response.error.unwrap_or_default());
process::exit(1);
}
}
Commands::Unmount {
mountpoint,
storage,
} => {
let storage = storage.canonicalize()?;
let mountpoint = mountpoint.canonicalize()?;
let response = send_request(
&storage,
&Request::Unmount {
mountpoint: mountpoint.to_string_lossy().to_string(),
},
)?;
if response.ok {
println!("Unmounted {:?}", mountpoint);
} else {
eprintln!("Error: {}", response.error.unwrap_or_default());
process::exit(1);
}
}
Commands::RunDaemon {
base,
storage,
max_storage,
} => {
std::fs::create_dir_all(&storage)?;
let storage = storage.canonicalize()?;
let base = base.canonicalize()?;
let d = daemon::Daemon::new(base.clone(), storage, base, max_storage)
.map_err(|e| anyhow::anyhow!("Failed to create daemon: {}", e))?;
d.run()
.map_err(|e| anyhow::anyhow!("Daemon error: {}", e))?;
}
}
Ok(())
}