Prune commentary and stale Rust port notes

This commit is contained in:
iris committed 2026-09-10 00:44:13 -04:00
1 parent 5428cd75c9
commit 25370731d0
193 files changed
+693 -16219

No files matched your search

+9 -120
View File
@@ -1,22 +1,3 @@
//! 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;
@@ -28,31 +9,15 @@ use crate::session::transport::{Input, Launch, Transport};
/// 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>,
}
@@ -61,13 +26,9 @@ pub struct Listing {
#[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,
}
@@ -82,8 +43,6 @@ pub enum EntryKind {
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
@@ -94,19 +53,19 @@ 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 },
Binary {
size: u64,
modified: i64,
},
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 {
@@ -115,16 +74,8 @@ pub struct Written {
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() {
@@ -139,24 +90,12 @@ pub fn check_path(path: &str) -> Result<String> {
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(),
];
let mut args = vec!["-c".to_string(), script, "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 && \
@@ -176,20 +115,14 @@ pub async fn list(transport: &Transport, path: &str) -> Result<Listing> {
})
}
/// 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 {
@@ -208,10 +141,6 @@ fn parse_entries(text: &str) -> Vec<Entry> {
}
/// 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
@@ -244,7 +173,6 @@ pub async fn read(transport: &Transport, path: &str) -> Result<FileRead> {
})
}
/// 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)?;
@@ -260,21 +188,6 @@ fn split_read(out: &[u8]) -> Result<(u64, i64, &str, &[u8])> {
))
}
/// 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,
@@ -329,9 +242,6 @@ pub async fn create_file(transport: &Transport, path: &str) -> Result<()> {
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
@@ -351,8 +261,6 @@ fn text(captured: crate::session::transport::Captured) -> Result<String> {
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| {
@@ -381,8 +289,6 @@ mod tests {
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);
@@ -406,9 +312,6 @@ mod tests {
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();
@@ -428,8 +331,6 @@ mod tests {
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();
@@ -472,8 +373,6 @@ mod tests {
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,
@@ -495,9 +394,6 @@ mod tests {
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())
@@ -543,7 +439,6 @@ mod tests {
.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"))
@@ -566,9 +461,6 @@ mod tests {
);
}
/// 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();
@@ -588,8 +480,6 @@ mod tests {
);
}
/// 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 {
@@ -611,7 +501,6 @@ mod tests {
.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)