513 lines
18 KiB
Rust
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);
|
|
}
|
|
}
|
|
}
|