//! Reading and changing files on the machine a setup names. //! //! Every operation here is one small POSIX shell script handed to `Transport`, //! the way `setups::discover` and `import::list` already ask a machine a //! question. That is what makes the local and the ssh case one implementation: //! a second one written against `std::fs` would be the one that gets tested, //! and the remote half -- the ordering of entries, what a symlink reports, how //! a permission error reads -- would drift until it shipped broken. The cost is //! an `sh` process per operation here, which is under a millisecond. //! //! The scripts assume GNU coreutils and findutils, which is what //! `session::import` already assumes. A machine without them fails with that //! tool's own message, which names what is missing. //! //! **The phone names a path, and that is deliberate** -- see PLAN.md's Security //! section. What is *not* given up: no route here accepts a command. Listing, //! reading and writing are the fixed scripts below, and the phone chooses only //! the path and the bytes. use anyhow::{Context, Result}; use serde::Serialize; use crate::session::transport::{Input, Launch, Transport}; /// The most of a file that crosses the tunnel, in bytes. Checked on the far /// machine before anything reads the file, so a 2 GB log costs a `stat` rather /// than a transfer. A file over it is [`FileRead::TooBig`] with its size, /// because "we did not read this" and "this is empty" must not look the same. pub const FILE_LIMIT: u64 = 1024 * 1024; /// The prelude every script here starts with: the path arrives as `$1`, and /// this is where a leading `~` becomes that machine's own home. /// /// The path is a **positional argument** and never text spliced into the script /// -- the rule `import::find` follows, for the reason `ssh::quote` exists: a /// path is attacker-adjacent input in a server whose job is running commands, /// and interpolated it would be syntax rather than data. /// /// `~` is the one character that costs something for it. A shell expands a /// tilde in *text*, so a path handed over as an argument arrives with a literal /// one; expanding it here gives it the same meaning `ssh::quote_path` gives it /// everywhere else, and it is the *far* machine's `$HOME`. `~user` stays /// literal and fails with the shell's own message. /// /// Everything below uses `$p` for the path and `$2` for whatever else. const PATH_PRELUDE: &str = r#"p=$1; case $p in "~") p=$HOME;; "~/"*) p=$HOME/${p#"~/"};; esac; "#; /// What a directory turned out to be, and what is in it. #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct Listing { /// `pwd -P` of the directory that was listed. Answered by the machine /// rather than worked out here, so the phone navigates on a resolved /// absolute path: the parent of one of these is a string operation, and a /// `~` a session was spawned with is shown as what it turned out to be. pub path: String, pub entries: Vec, } #[derive(Debug, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct Entry { pub name: String, /// What tapping it does, which for a symlink is decided by its *target* -- /// a link to a directory navigates. pub kind: EntryKind, pub size: u64, pub modified: i64, /// Whether the entry itself is a symlink, whatever [`Entry::kind`] says /// its target is. pub link: bool, } #[derive(Debug, Serialize, PartialEq, Eq, Clone, Copy)] #[serde(rename_all = "camelCase")] pub enum EntryKind { Directory, File, /// A socket, a device, a fifo -- and a symlink whose target is missing or /// loops, which `find` reports the same way. Shown, because a directory /// that hid what it held would be lying about being empty. Other, } /// What reading a file produced -- four answers, not content-or-error. /// /// A binary file drawn as text and a big file cut off silently are both wrong /// in ways the reader cannot see, and "couldn't read it" must not look like /// "it is empty". A genuinely empty file is [`FileRead::Text`] with nothing in /// it, which is what it is. #[derive(Debug, Serialize)] #[serde(tag = "kind", rename_all = "camelCase")] pub enum FileRead { Text { size: u64, modified: i64, /// What [`write`] is given back to prove the file has not moved on. sha256: String, content: String, }, /// Not UTF-8. Its size is reported; nothing is shown. Binary { size: u64, modified: i64 }, /// Over [`FILE_LIMIT`]. Its size is reported, so the reader knows what they /// are looking at rather than only that they cannot have it. TooBig { size: u64, modified: i64 }, } /// What a file is after being written, so the editor's precondition is /// fresh without a second read. #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct Written { pub size: u64, pub modified: i64, pub sha256: String, } /// The exit code the write script uses for "this is not the file you read", /// which the route turns into a 409. Distinct from every other failure, which /// is a message from the machine. pub const STALE: i32 = 3; /// A path the phone may name: absolute, or home-relative on that machine. /// /// Shared with `POST /sessions/{id}/cwd`, which asks the same question for the /// same reason -- a relative path is relative to something nobody looking at /// the screen can see, so it is refused rather than resolved against a guess. pub fn check_path(path: &str) -> Result { let path = path.trim(); if path.is_empty() { anyhow::bail!("that is a path with nothing in it"); } if !path.starts_with('/') && !path.starts_with('~') { anyhow::bail!( "{path} is not an absolute path, so where it would be depends on something \ nobody looking at this can see -- start it with / or ~" ); } Ok(path.to_string()) } /// Runs one of the scripts below with `path` as `$1` and `extra` as `$2`. fn launch(script: String, path: &str, extra: Option<&str>) -> Launch { let mut args = vec![ "-c".to_string(), script, // `$0`, which is what `sh` names itself in a message about the script; // the path is `$1`. "sh".to_string(), path.to_string(), ]; args.extend(extra.map(str::to_string)); Launch::new("sh", args, None) } /// Everything in `path`, and what `path` resolved to. Entries are separated by /// `\0` and their fields by `\t`, so a filename with a newline or a tab in it /// survives -- both are legal, and a listing that lost one would quietly show /// the wrong thing. pub async fn list(transport: &Transport, path: &str) -> Result { let script = format!( "{PATH_PRELUDE}cd -- \"$p\" && pwd -P && \ find . -mindepth 1 -maxdepth 1 -printf '%y\\t%Y\\t%s\\t%T@\\t%f\\0'" ); let out = transport .capture_with_input(&launch(script, path, None), Input::None) .await? .ok()?; let out = String::from_utf8_lossy(&out); let (resolved, entries) = out .split_once('\n') .context("the machine did not say which directory it listed")?; Ok(Listing { path: resolved.to_string(), entries: parse_entries(entries), }) } /// The `find` output above, as rows. A record without all five fields is /// dropped rather than guessed at: it can only come from a `find` that printed /// something else, and half a row is worse than no row. fn parse_entries(text: &str) -> Vec { text.split('\0') .filter(|record| !record.is_empty()) .filter_map(|record| { // Five, so that a name containing a tab keeps it: `%f` is last // exactly so the split can stop. let mut fields = record.splitn(5, '\t'); let own = fields.next()?; let target = fields.next()?; let size = fields.next()?.parse().ok()?; // `%T@` is seconds with a fractional part; the phone shows a date. let modified = fields.next()?.split('.').next()?.parse().ok()?; let name = fields.next()?; Some(Entry { name: name.to_string(), kind: match target { "d" => EntryKind::Directory, "f" => EntryKind::File, _ => EntryKind::Other, }, size, modified, link: own == "l", }) }) .collect() } /// One file's content, or the reason there is none to show. /// /// The size is checked on the far machine *before* anything reads the file, so /// a file over [`FILE_LIMIT`] costs a `stat` rather than a transfer. `stat -L` /// and `sha256sum` both follow symlinks, as `cat` does. pub async fn read(transport: &Transport, path: &str) -> Result { // Two header lines, then the bytes: ` `, then either `tooBig` // or the digest. A header rather than a JSON envelope because the content // is bytes and may not be text at all. let script = format!( "{PATH_PRELUDE}set -e; \ h=$(stat -L -c '%s %Y' -- \"$p\"); \ case ${{h%% *}} in *[!0-9]*) exit 1;; esac; \ if [ \"${{h%% *}}\" -gt {FILE_LIMIT} ]; then printf '%s\\ntooBig\\n' \"$h\"; exit 0; fi; \ d=$(sha256sum -- \"$p\"); \ printf '%s\\n%s\\n' \"$h\" \"${{d%% *}}\"; \ cat -- \"$p\"" ); let out = transport .capture_with_input(&launch(script, path, None), Input::None) .await? .ok()?; let (size, modified, second, content) = split_read(&out)?; if second == "tooBig" { return Ok(FileRead::TooBig { size, modified }); } Ok(match String::from_utf8(content.to_vec()) { Ok(content) => FileRead::Text { size, modified, sha256: second.to_string(), content, }, Err(_) => FileRead::Binary { size, modified }, }) } /// The read script's two header lines and the bytes after them. fn split_read(out: &[u8]) -> Result<(u64, i64, &str, &[u8])> { let missing = || anyhow::anyhow!("the machine did not describe the file it read"); let first = out.iter().position(|b| *b == b'\n').ok_or_else(missing)?; let rest = &out[first + 1..]; let second = rest.iter().position(|b| *b == b'\n').ok_or_else(missing)?; let header = std::str::from_utf8(&out[..first]).map_err(|_| missing())?; let (size, modified) = header.split_once(' ').ok_or_else(missing)?; Ok(( size.trim().parse()?, modified.trim().parse()?, std::str::from_utf8(&rest[..second]).map_err(|_| missing())?, &rest[second + 1..], )) } /// Replaces `path`'s contents, but only while it still hashes to `expected`. /// /// Agents edit files while people read them, so a stale copy landing on top of /// somebody else's edit is the common case rather than the exotic one. The /// digest the reader was shown is compared on the machine, and a file that has /// moved on comes back as [`STALE`] rather than being overwritten. /// /// A temp file and a rename, so a connection dropped mid-write leaves the old /// file whole, and `chmod --reference` so the mode survives -- an executable /// script written as a fresh file would stop being one. What that trades away: /// the inode changes, so a hard link elsewhere stops being the same file. /// /// The check and the write are **not** atomic against a writer landing between /// them -- a window of microseconds on that machine. Accepted: the alternative /// is a lock this has no way to make every other writer take. pub async fn write( transport: &Transport, path: &str, expected: &str, bytes: Vec, ) -> Result> { let script = format!( "{PATH_PRELUDE}set -e; \ have=$(sha256sum -- \"$p\"); \ [ \"${{have%% *}}\" = \"$2\" ] || exit {STALE}; \ cat > \"$p.ai-app-tmp\"; \ chmod --reference=\"$p\" \"$p.ai-app-tmp\"; \ mv -f -- \"$p.ai-app-tmp\" \"$p\"; \ stat -L -c '%s %Y' -- \"$p\"; \ now=$(sha256sum -- \"$p\"); \ printf '%s\\n' \"${{now%% *}}\"" ); let captured = transport .capture_with_input(&launch(script, path, Some(expected)), Input::Bytes(bytes)) .await?; if captured.status.code() == Some(STALE) { return Ok(Err(Stale)); } let out = String::from_utf8_lossy(&captured.ok()?).into_owned(); let mut lines = out.lines(); let missing = || anyhow::anyhow!("the machine did not describe the file it wrote"); let header = lines.next().ok_or_else(missing)?; let (size, modified) = header.split_once(' ').ok_or_else(missing)?; Ok(Ok(Written { size: size.trim().parse()?, modified: modified.trim().parse()?, sha256: lines.next().ok_or_else(missing)?.trim().to_string(), })) } /// The file is not the one that was read. Its own type rather than an error /// string, because the route answers it with a different status and the phone /// with a different question. #[derive(Debug)] pub struct Stale; /// Creates an empty file, refusing to truncate one that is already there. /// `set -C` is the shell's own noclobber, so an existing name fails with the /// shell's own message rather than with a check that could race the redirection /// it is guarding. pub async fn create_file(transport: &Transport, path: &str) -> Result<()> { let script = format!("{PATH_PRELUDE}set -C; : > \"$p\""); transport .capture_with_input(&launch(script, path, None), Input::None) .await? .ok()?; Ok(()) } /// Creates a directory. Plain `mkdir`, not `-p`, for the reason /// [`create_file`] sets noclobber: a name that exists is something the person /// typing it should be told about. pub async fn create_dir(transport: &Transport, path: &str) -> Result<()> { let script = format!("{PATH_PRELUDE}mkdir -- \"$p\""); transport .capture_with_input(&launch(script, path, None), Input::None) .await? .ok()?; Ok(()) } /// The stdout of a script that must have succeeded, as text. #[cfg(test)] fn text(captured: crate::session::transport::Captured) -> Result { Ok(String::from_utf8_lossy(&captured.ok()?).into_owned()) } #[cfg(test)] mod tests { use super::*; /// The names a listing has to survive. All four are legal, and each one /// broke a listing somewhere before it was separated with `\0`. #[test] fn a_listing_survives_the_names_a_filesystem_allows() { let record = |own: &str, target: &str, size: &str, time: &str, name: &str| { format!("{own}\t{target}\t{size}\t{time}\t{name}\0") }; let text = [ record("f", "f", "12", "1756900000.5", "with\ta tab"), record("f", "f", "13", "1756900001.0", "with\na newline"), record("d", "d", "4096", "1756900002.0", "-leading-dash"), record("l", "d", "7", "1756900003.9", "it's a link"), record("l", "N", "7", "1756900004.0", "dangling"), ] .concat(); let entries = parse_entries(&text); assert_eq!( entries.iter().map(|e| e.name.as_str()).collect::>(), [ "with\ta tab", "with\na newline", "-leading-dash", "it's a link", "dangling", ] ); assert_eq!(entries[0].kind, EntryKind::File); assert_eq!(entries[0].modified, 1756900000); assert_eq!(entries[2].kind, EntryKind::Directory); assert_eq!(entries[2].size, 4096); // The kind is the target's, so a link to a directory navigates -- and // one whose target is gone is neither a file nor a directory. assert!(entries[3].link); assert_eq!(entries[3].kind, EntryKind::Directory); assert_eq!(entries[4].kind, EntryKind::Other); } #[test] fn a_half_written_record_is_dropped_rather_than_guessed_at() { assert!(parse_entries("f\tf\t12\0").is_empty()); assert!(parse_entries("").is_empty()); assert!(parse_entries("\0\0").is_empty()); } #[test] fn a_path_is_absolute_or_home_relative() { assert_eq!(check_path(" /etc/hosts ").unwrap(), "/etc/hosts"); assert_eq!(check_path("~/repos").unwrap(), "~/repos"); assert!(check_path("").is_err()); assert!(check_path(" ").is_err()); let refused = check_path("repos/ai-app").unwrap_err().to_string(); assert!(refused.contains("repos/ai-app"), "{refused}"); assert!(refused.contains("start it with / or ~"), "{refused}"); } /// The scripts, against a real tree, through the transport that runs them /// here -- cheap, because `sh` is wherever `cargo test` is. The remote /// transport runs the identical text. fn tree() -> tempfile::TempDir { let dir = tempfile::tempdir().unwrap(); std::fs::write(dir.path().join("hello.txt"), "one\ntwo\n").unwrap(); std::fs::write(dir.path().join("it's a file"), "quoted\n").unwrap(); std::fs::write(dir.path().join("binary.bin"), [0xff, 0xfe, 0x00, 0x01]).unwrap(); std::fs::create_dir(dir.path().join("sub")).unwrap(); dir } fn at(dir: &tempfile::TempDir, name: &str) -> String { dir.path().join(name).to_string_lossy().into_owned() } #[tokio::test] async fn a_directory_lists_with_its_resolved_path() { let dir = tree(); let listing = list(&Transport::Here, &dir.path().to_string_lossy()) .await .unwrap(); // `pwd -P`, so a temp directory reached through a symlinked /tmp // answers with what it really is. assert!(listing.path.starts_with('/'), "{}", listing.path); let mut names: Vec<&str> = listing.entries.iter().map(|e| e.name.as_str()).collect(); names.sort_unstable(); assert_eq!(names, ["binary.bin", "hello.txt", "it's a file", "sub"]); } #[tokio::test] async fn a_missing_directory_fails_with_the_machine_s_own_message() { let dir = tree(); let err = list(&Transport::Here, &at(&dir, "nope")) .await .unwrap_err() .to_string(); assert!(err.contains("nope"), "{err}"); } #[tokio::test] async fn text_binary_and_too_big_are_three_different_answers() { let dir = tree(); let read_at = async |name: &str| read(&Transport::Here, &at(&dir, name)).await.unwrap(); match read_at("hello.txt").await { FileRead::Text { size, content, sha256, .. } => { assert_eq!(size, 8); assert_eq!(content, "one\ntwo\n"); assert_eq!(sha256.len(), 64); } other => panic!("{other:?}"), } assert!(matches!( read_at("binary.bin").await, FileRead::Binary { size: 4, .. } )); std::fs::write(dir.path().join("big"), vec![b'x'; FILE_LIMIT as usize + 1]).unwrap(); assert!(matches!(read_at("big").await, FileRead::TooBig { .. })); // Empty is text with nothing in it -- not a fourth state, and not the // same as any of the three above. std::fs::write(dir.path().join("empty"), "").unwrap(); assert!(matches!( read_at("empty").await, FileRead::Text { size: 0, .. } )); } #[tokio::test] async fn a_write_lands_only_while_the_file_is_the_one_that_was_read() { let dir = tree(); let path = at(&dir, "hello.txt"); let FileRead::Text { sha256, .. } = read(&Transport::Here, &path).await.unwrap() else { panic!("expected text"); }; let written = write(&Transport::Here, &path, &sha256, b"three\n".to_vec()) .await .unwrap() .expect("nothing had changed it"); assert_eq!(written.size, 6); assert_eq!(std::fs::read_to_string(&path).unwrap(), "three\n"); // The same digest again, against a file that has moved on: the // agent-edits-while-you-read case, which must refuse rather than // overwrite. std::fs::write(&path, "somebody else\n").unwrap(); assert!( write(&Transport::Here, &path, &sha256, b"mine\n".to_vec()) .await .unwrap() .is_err() ); assert_eq!(std::fs::read_to_string(&path).unwrap(), "somebody else\n"); } #[tokio::test] async fn a_write_keeps_the_mode_it_found() { use std::os::unix::fs::PermissionsExt; let dir = tree(); let path = at(&dir, "script.sh"); std::fs::write(&path, "#!/bin/sh\n").unwrap(); std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); let FileRead::Text { sha256, .. } = read(&Transport::Here, &path).await.unwrap() else { panic!("expected text"); }; write( &Transport::Here, &path, &sha256, b"#!/bin/sh\ntrue\n".to_vec(), ) .await .unwrap() .unwrap(); let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777; assert_eq!(mode, 0o755, "an executable script stopped being one"); } #[tokio::test] async fn creating_refuses_a_name_that_is_already_there() { let dir = tree(); create_file(&Transport::Here, &at(&dir, "new.txt")) .await .unwrap(); assert_eq!(std::fs::read_to_string(at(&dir, "new.txt")).unwrap(), ""); assert!( create_file(&Transport::Here, &at(&dir, "new.txt")) .await .is_err() ); // And the file that was there is untouched. std::fs::write(at(&dir, "new.txt"), "mine\n").unwrap(); assert!( create_file(&Transport::Here, &at(&dir, "new.txt")) .await .is_err() ); assert_eq!( std::fs::read_to_string(at(&dir, "new.txt")).unwrap(), "mine\n" ); create_dir(&Transport::Here, &at(&dir, "made")) .await .unwrap(); assert!(at(&dir, "made").parse::().is_ok()); assert!( create_dir(&Transport::Here, &at(&dir, "made")) .await .is_err() ); } /// A path that tries to close the quote and start a command of its own. It /// is an argument rather than syntax, so it stays one absurd filename -- the /// same property `ssh.rs` tests for the remote side. #[tokio::test] async fn a_path_full_of_shell_crosses_as_data() { let dir = tree(); let evil = at(&dir, "'; touch pwned; '"); create_file(&Transport::Here, &evil).await.unwrap(); assert!(std::path::Path::new(&evil).is_file()); assert!(!dir.path().join("pwned").exists()); let listing = list(&Transport::Here, &dir.path().to_string_lossy()) .await .unwrap(); assert!( listing .entries .iter() .any(|e| e.name == "'; touch pwned; '") ); } /// The tilde is the one character the prelude gives a meaning, and it is the /// *machine's* home -- here, this one. #[tokio::test] async fn a_leading_tilde_means_the_machine_s_own_home() { let Some(home) = std::env::home_dir() else { return; }; let script = format!("{PATH_PRELUDE}printf '%s' \"$p\""); let captured = Transport::Here .capture_with_input(&launch(script.clone(), "~/repos", None), Input::None) .await .unwrap(); assert_eq!( text(captured).unwrap(), home.join("repos").to_string_lossy() ); let captured = Transport::Here .capture_with_input(&launch(script.clone(), "~", None), Input::None) .await .unwrap(); assert_eq!(text(captured).unwrap(), home.to_string_lossy()); // Leading, and its own segment only -- `ssh::quote_path`'s rule. for literal in ["/tmp/~/x", "~user/x"] { let captured = Transport::Here .capture_with_input(&launch(script.clone(), literal, None), Input::None) .await .unwrap(); assert_eq!(text(captured).unwrap(), literal); } } }