Files
ai-app/server/src/files.rs
T
irisandClaude Opus 5 79682f03a7 Condense the documentation and thin the server's comments
The markdown had accumulated a lot that was stale rather than wrong.
PLAN.md still described pi as the llama.cpp harness, a refcounted
LlamaServerManager, and a providers-by-hosts cross-product, all of which
were superseded or never built; it also carried a second copy of the HTTP
table that routes.rs owns. EXPLORER.md and TRANSCRIPT_CACHE.md held
implementation checklists for work that has since landed. AGENTS.md
restated most of PLAN.md's design instead of being the working-notes
layer it says it is. 3225 lines of markdown to 2180, with the stale
sections gone rather than reworded.

On the server, comments explaining what the code already says are out and
the ones recording a constraint, a measurement or an incident are kept but
cut to a few lines each: 5504 comment lines to 4586.

Four doc comments in session/mod.rs, and one each in process.rs and
usage.rs, had drifted onto the item above the one they describe --
functions were reordered without them, so `stop_session`'s doc sat on
`set_session_cwd`, `stat_of`'s on `struct Stat`, and `UsageMonitor`'s on
`type Cached`. Each is back on its own item.

routes.rs's module table also claimed later phases would add `/hosts`,
which setups replaced.

cargo test (127 passed), clippy --all-targets and fmt are clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 15:45:43 -04:00

624 lines
24 KiB
Rust

//! 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<Entry>,
}
#[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<String> {
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<Listing> {
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<Entry> {
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<FileRead> {
// Two header lines, then the bytes: `<size> <mtime>`, 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<u8>,
) -> Result<Result<Written, Stale>> {
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<String> {
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::<Vec<_>>(),
[
"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::<std::path::PathBuf>().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);
}
}
}