Something went wrong. Try again.
A user-space CRDT filesystem
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230//! `fsclient` — tiny CLI for poking a running `subtext` dev daemon.//!//! Talks the FSKitBridge wire protocol (`fskit_rs::pb`) over plain TCP, so//! you can exercise the running daemon's [`fskit_rs::Filesystem`] impl//! without needing a signed FSKit bridge app.//!//! Examples://! fsclient smoke//! fsclient ls//! fsclient put hello.txt --content hi//! fsclient cat hello.txt//! fsclient rm hello.txt
use std::ffi::OsStr;use std::io::{self, Read};use std::process::ExitCode;
use clap::{Parser, Subcommand};use fskit_rs::ItemType;
use subtext::wire::{Connection, Error as WireError, ROOT_ID};
#[derive(Parser, Debug)]#[command(version, about = "Test client for a running subtext dev daemon")]struct Cli { /// Daemon address. #[arg(long, default_value = "127.0.0.1:8080", global = true)] addr: String,
#[command(subcommand)] command: Command,}
#[derive(Subcommand, Debug)]enum Command { /// Run an end-to-end protocol smoke test against the daemon. Smoke { /// File name to use during the test (auto-removed at the end). #[arg(long, default_value = "smoke.txt")] name: String, },
/// List entries in the root directory. Ls,
/// Read a file from the root directory and print its bytes to stdout. Cat { /// File name in the root directory. name: String, },
/// Create or overwrite a file in the root directory. /// /// Reads contents from `--content` if provided, otherwise from stdin. Put { /// File name in the root directory. name: String,
/// Inline file contents (instead of reading from stdin). #[arg(long)] content: Option<String>, },
/// Remove a file from the root directory. Rm { /// File name in the root directory. name: String, },}
#[tokio::main]async fn main() -> ExitCode { let _ = env_logger::try_init(); let cli = Cli::parse();
match run(cli).await { Ok(()) => ExitCode::SUCCESS, Err(err) => { eprintln!("error: {err}"); ExitCode::FAILURE } }}
async fn run(cli: Cli) -> Result<(), WireError> { let mut conn = Connection::connect(&cli.addr).await?; // Activating is cheap and ensures the daemon is alive before we try // anything destructive. conn.activate().await?;
match cli.command { Command::Smoke { name } => smoke(&mut conn, &name).await, Command::Ls => ls(&mut conn).await, Command::Cat { name } => cat(&mut conn, &name).await, Command::Put { name, content } => { let bytes = match content { Some(s) => s.into_bytes(), None => { let mut buf = Vec::new(); io::stdin().read_to_end(&mut buf).map_err(WireError::Io)?; buf } }; put(&mut conn, &name, bytes).await } Command::Rm { name } => rm(&mut conn, &name).await, }}
async fn smoke(conn: &mut Connection, name: &str) -> Result<(), WireError> { let name_os = OsStr::new(name);
// Ensure a clean slate for repeatability. if let Ok(item) = conn.lookup(name_os, ROOT_ID).await { let id = item.attributes.and_then(|a| a.file_id).unwrap_or(0); let _ = conn.remove(id, name_os, ROOT_ID).await; }
println!("[ok] activate"); let item = conn.create(name_os, ItemType::File, ROOT_ID).await?; let file_id = item .attributes .and_then(|a| a.file_id) .ok_or_else(|| WireError::UnexpectedResponse("create returned no file_id".into()))?; println!("[ok] create -> id {file_id}");
let n = conn.write(file_id, 0, b"hello".to_vec()).await?; println!("[ok] write -> {n} bytes"); if n != 5 { return Err(WireError::UnexpectedResponse(format!( "write returned {n}, expected 5" ))); }
let data = conn.read(file_id, 0, 5).await?; println!("[ok] read -> {:?}", String::from_utf8_lossy(&data)); if data != b"hello" { return Err(WireError::UnexpectedResponse(format!( "read returned {data:?}, expected \"hello\"" ))); }
let part = conn.read(file_id, 1, 3).await?; println!("[ok] read[1,4) -> {:?}", String::from_utf8_lossy(&part)); if part != b"ell" { return Err(WireError::UnexpectedResponse(format!( "partial read returned {part:?}, expected \"ell\"" ))); }
let entries = conn.enumerate(ROOT_ID).await?; let names: Vec<String> = entries .iter() .filter_map(|e| e.item.as_ref()) .map(|i| String::from_utf8_lossy(&i.name).into_owned()) .collect(); println!("[ok] ls -> {names:?}");
conn.remove(file_id, name_os, ROOT_ID).await?; println!("[ok] rm");
println!(); println!("PASS: round-trip OK; reverse FS stores reversed bytes but reads logical order."); Ok(())}
async fn ls(conn: &mut Connection) -> Result<(), WireError> { let entries = conn.enumerate(ROOT_ID).await?; if entries.is_empty() { println!("(empty)"); return Ok(()); } for entry in entries { let Some(item) = entry.item else { continue }; let name = String::from_utf8_lossy(&item.name); let attrs = item.attributes.unwrap_or_default(); let kind = match ItemType::try_from(attrs.r#type.unwrap_or(0)) { Ok(ItemType::Directory) => "dir", Ok(ItemType::File) => "file", Ok(ItemType::Symlink) => "symlink", _ => "?", }; let size = attrs.size.unwrap_or(0); let id = attrs.file_id.unwrap_or(0); println!("{id:>4} {kind:<7} {size:>8} {name}"); } Ok(())}
async fn cat(conn: &mut Connection, name: &str) -> Result<(), WireError> { let item = conn.lookup(OsStr::new(name), ROOT_ID).await?; let attrs = item.attributes.unwrap_or_default(); let id = attrs .file_id .ok_or_else(|| WireError::UnexpectedResponse("lookup returned no file_id".into()))?; let size = attrs.size.unwrap_or(0) as i64; let data = conn.read(id, 0, size).await?; use std::io::Write; io::stdout().write_all(&data).map_err(WireError::Io)?; Ok(())}
async fn put(conn: &mut Connection, name: &str, bytes: Vec<u8>) -> Result<(), WireError> { let name_os = OsStr::new(name); // Overwrite-by-replace; simpler than computing a truncate. if let Ok(existing) = conn.lookup(name_os, ROOT_ID).await { let id = existing.attributes.and_then(|a| a.file_id).unwrap_or(0); conn.remove(id, name_os, ROOT_ID).await?; } let item = conn.create(name_os, ItemType::File, ROOT_ID).await?; let id = item .attributes .and_then(|a| a.file_id) .ok_or_else(|| WireError::UnexpectedResponse("create returned no file_id".into()))?; let n = conn.write(id, 0, bytes).await?; eprintln!("wrote {n} bytes -> {name} (id {id})"); Ok(())}
async fn rm(conn: &mut Connection, name: &str) -> Result<(), WireError> { let name_os = OsStr::new(name); let item = conn.lookup(name_os, ROOT_ID).await?; let id = item .attributes .and_then(|a| a.file_id) .ok_or_else(|| WireError::UnexpectedResponse("lookup returned no file_id".into()))?; conn.remove(id, name_os, ROOT_ID).await?; Ok(())}