Files
ai-app/server/src/files.rs
T

513 lines
18 KiB
Rust

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 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.
const PATH_PRELUDE: &str = r#"p=$1; case $p in "~") p=$HOME;; "~/"*) p=$HOME/${p#"~/"};; esac; "#;
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Listing {
pub path: String,
pub entries: Vec<Entry>,
}
#[derive(Debug, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct Entry {
pub name: String,
pub kind: EntryKind,
pub size: u64,
pub modified: i64,
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,
}
/// 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,
sha256: String,
content: String,
},
Binary {
size: u64,
modified: i64,
},
TooBig {
size: u64,
modified: i64,
},
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Written {
pub size: u64,
pub modified: i64,
pub sha256: String,
}
pub const STALE: i32 = 3;
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())
}
fn launch(script: String, path: &str, extra: Option<&str>) -> Launch {
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)
}
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),
})
}
fn parse_entries(text: &str) -> Vec<Entry> {
text.split('\0')
.filter(|record| !record.is_empty())
.filter_map(|record| {
let mut fields = record.splitn(5, '\t');
let own = fields.next()?;
let target = fields.next()?;
let size = fields.next()?.parse().ok()?;
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.
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 },
})
}
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..],
))
}
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(())
}
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::*;
#[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);
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}");
}
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();
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 { .. }));
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");
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()
);
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()
);
}
#[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; '")
);
}
#[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());
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);
}
}
}