Merge branch 'main' of git.arirex.me:iris/ai-app
This commit is contained in:
commit
9fdab777b4
8 files changed
+1008
-46
No files matched your search
@@ -57,6 +57,20 @@ repo is in PLAN.md's "Backend layout" section.
|
||||
finishes between two polls reads as idle at both, and its own output
|
||||
gets replayed on top of itself. That bug was visible on screen as
|
||||
`donedone`.
|
||||
- `server/src/files.rs` — the file explorer's half of the backend:
|
||||
listing a directory, reading a file, writing one, creating a file or a
|
||||
directory, on whichever machine a setup names. Each is one small POSIX
|
||||
script run through `Transport`, so the local and the ssh case are the
|
||||
same code and a machine the backend cannot reach fails with ssh's own
|
||||
message. The path is a **positional argument**, never text spliced into
|
||||
the script; `PATH_PRELUDE` is the one line that gives a leading `~` its
|
||||
meaning, since a shell expands a tilde in text and not in an argument.
|
||||
A read has four answers — `text`, `binary`, `tooBig`, or the machine's
|
||||
own error — because a binary file drawn as text and a big one cut off
|
||||
silently are both wrong in ways the reader cannot see. A write carries
|
||||
the sha256 the read reported and is refused (409) when the file has
|
||||
moved on, which is the ordinary case when an agent is editing the same
|
||||
file. `EXPLORER.md` is the design.
|
||||
- `server/src/usage.rs` — rate-limit windows, asked **of each machine that
|
||||
can run Claude**, not of the backend. Credentials are read through the
|
||||
session `Transport`, so a remote setup is an ssh round trip and the local
|
||||
|
||||
@@ -790,6 +790,11 @@ POST /sessions/:id/attachments multipart upload → id (referenced by /me
|
||||
GET /sessions/:id/files/:ref images the session produced or was sent
|
||||
DELETE /sessions/:id kill process, release llama-server, delete transcript+files
|
||||
GET /usage cached usage windows
|
||||
GET /setups/:id/dir?path=P entries of directory P, and P resolved
|
||||
GET /setups/:id/file?path=P content of file P, or why not
|
||||
PUT /setups/:id/file {path, content, ifSha256}; 409 if it moved on
|
||||
POST /setups/:id/file {path} create empty; refused if it exists
|
||||
POST /setups/:id/dir {path} create; refused if it exists
|
||||
GET/PUT /hosts, /models config editing from the phone
|
||||
```
|
||||
|
||||
@@ -798,6 +803,17 @@ directory under `$XDG_DATA_HOME/ai-app/sessions/` (transcript.jsonl,
|
||||
attachments, produced images), owner-only. Deleting a session is the
|
||||
complete path out of everything spawning one created.
|
||||
|
||||
### The file explorer (decided 2026-09-03)
|
||||
|
||||
**`EXPLORER.md` holds this design**, decision by decision with what was
|
||||
rejected, the same way this file does — it is long enough to be its own
|
||||
document and it is where a change to it belongs. The one-line version: a
|
||||
machine's filesystem, seen from the phone through the backend, keyed on
|
||||
the **setup** rather than on a session (a session only says where to
|
||||
start), with every operation one fixed shell script run through
|
||||
`Transport` so the local and the ssh case are one implementation. The
|
||||
security consequence is in the token paragraph below.
|
||||
|
||||
### Security
|
||||
|
||||
- TLS with a self-signed CA, pinned in the app — same
|
||||
@@ -853,6 +869,20 @@ complete path out of everything spawning one created.
|
||||
gates LAN-reachable RCE; it does not (and cannot) defend a compromised
|
||||
backend host or phone — those are inside the trust boundary, and a
|
||||
compromised phone is handled by rotation.
|
||||
- **The explorer's routes take a path, and that is deliberate**
|
||||
(2026-09-03; see EXPLORER.md's decision 3). Elsewhere the rule is that
|
||||
the phone picks an **id** and the server resolves which file it names —
|
||||
the import listing is written that way so an enrolled token cannot
|
||||
become "read me an arbitrary file". `/setups/{id}/dir` and
|
||||
`/setups/{id}/file` take the path, because the path is the whole
|
||||
feature. It grants nothing new: the same token already spawns a
|
||||
bypass-permissions agent in any directory on any machine a setup names,
|
||||
and that agent already reads and writes every file its user can, so
|
||||
this is a shorter path to authority the token holds either way. The
|
||||
import rule stands where it is, because there a path was unnecessary
|
||||
and refusing one cost nothing. What is unchanged is the harder line:
|
||||
**no route accepts a command.** Listing, reading and writing are fixed
|
||||
scripts in `files.rs`; the phone chooses only the path and the bytes.
|
||||
- **Generation**: 256 bits from the OS CSPRNG on first run, base64url. A
|
||||
machine credential, never typed twice, so unguessable costs nothing; at
|
||||
this entropy no key stretching is needed.
|
||||
|
||||
@@ -248,6 +248,39 @@ awk -v mb="$BIG_MB" 'BEGIN {
|
||||
}
|
||||
print "{\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"usage\":{\"input_tokens\":180000,\"output_tokens\":900}}}"
|
||||
}' > "$big"
|
||||
|
||||
# A tree for the file explorer, at the sandbox home's `~/files`, holding
|
||||
# the states that are otherwise only reachable by finding a real machine
|
||||
# in one of them. The default state is the one everybody looks at, so what
|
||||
# is worth having here is the rest: an empty directory, names a shell would
|
||||
# mangle, something that is not text, something too big to send, something
|
||||
# nobody may read, a link that navigates, and one file per language so the
|
||||
# highlighter is exercised rather than assumed.
|
||||
FILES=$ROOT/home/files
|
||||
mkdir -p "$FILES/empty" "$FILES/sub"
|
||||
printf 'one\ntwo\nthree\n' >"$FILES/hello.txt"
|
||||
# A real tab, via printf: `\t` inside double quotes is a backslash and a t,
|
||||
# which is a different (and easier) name than the one worth testing.
|
||||
tabbed=$(printf 'with\ta tab.txt')
|
||||
printf 'a tab in the name\n' >"$FILES/$tabbed"
|
||||
printf "an apostrophe in the name\n" >"$FILES/it's a file.txt"
|
||||
printf 'fn main() {\n // a comment\n println!("hello, {}", 1_000);\n}\n' >"$FILES/main.rs"
|
||||
printf 'fun main() {\n // a comment\n println("hello")\n}\n' >"$FILES/Main.kt"
|
||||
printf 'def main():\n # a comment\n print("hello")\n' >"$FILES/main.py"
|
||||
printf '#!/bin/sh\n# a comment\necho hello\n' >"$FILES/run.sh"
|
||||
chmod +x "$FILES/run.sh"
|
||||
printf '{"a": 1, "b": [true, null]}\n' >"$FILES/data.json"
|
||||
# Not UTF-8, so it reads as binary rather than as mojibake.
|
||||
printf '\377\376\000\001binary\n' >"$FILES/picture.bin"
|
||||
# Over FILE_LIMIT (1 MiB), so the read refuses before anything transfers.
|
||||
awk 'BEGIN { for (i = 0; i < 40000; i++) print "a line of a log that nobody is going to read" }' >"$FILES/big.log"
|
||||
# Unreadable on purpose: a directory listing still shows it, and opening it
|
||||
# fails with the machine's own words rather than with an empty file.
|
||||
printf 'secret\n' >"$FILES/unreadable.txt"
|
||||
chmod 000 "$FILES/unreadable.txt"
|
||||
printf 'in a subdirectory\n' >"$FILES/sub/inside.txt"
|
||||
ln -sfn sub "$FILES/link-to-sub"
|
||||
ln -sfn nowhere "$FILES/broken-link"
|
||||
fi
|
||||
|
||||
if [ -n "$regen_config" ]; then
|
||||
|
||||
@@ -0,0 +1,649 @@
|
||||
//! Reading and changing files on the machine a setup names.
|
||||
//!
|
||||
//! Every operation here is one small POSIX shell script handed to
|
||||
//! `Transport`, exactly 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 on this machine, which is under a millisecond.
|
||||
//!
|
||||
//! The scripts assume GNU coreutils and findutils (`find -printf`,
|
||||
//! `stat -c`, `sha256sum`, `chmod --reference`), which is what
|
||||
//! `session::import` already assumes and what both machines here run. One
|
||||
//! 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. The enrolled token already spawns an agent in any
|
||||
//! directory on any machine a setup names, and that agent reads and writes
|
||||
//! every file its user can; this is a shorter path to authority the token
|
||||
//! already holds. 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 reported
|
||||
/// as [`FileRead::TooBig`] with its size, because "we did not read this"
|
||||
/// and "this is empty" must not look the same on the phone.
|
||||
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 with `"$1"`, 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, once, gives it the same meaning
|
||||
/// `ssh::quote_path` and `ssh::expand_home` give it everywhere else, and
|
||||
/// it is the *far* machine's `$HOME` -- the only one that could be right.
|
||||
/// `~user` stays literal here too, and fails with the shell's own message.
|
||||
///
|
||||
/// Everything below uses `$p` for the path and `$2` for whatever else it
|
||||
/// was given.
|
||||
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,
|
||||
/// Seconds since the epoch.
|
||||
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 file that is genuinely empty 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. Returns the path with the whitespace a phone keyboard
|
||||
/// adds taken off.
|
||||
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 that does not have 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, so the fraction is dropped rather than carried.
|
||||
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, so a link to a file reports the file.
|
||||
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 rather than a truncated one, 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. Editors do the same.
|
||||
///
|
||||
/// 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 same 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 -- which is 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 -- which is the path the phone
|
||||
// then navigates on.
|
||||
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, which is what it is -- 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@
|
||||
|
||||
mod auth;
|
||||
mod config;
|
||||
mod files;
|
||||
mod media;
|
||||
mod models;
|
||||
mod routes;
|
||||
|
||||
+168
-27
@@ -7,6 +7,12 @@
|
||||
//! POST /setups add {name, ssh?} -- providers are discovered
|
||||
//! POST /setups/probe dry run {ssh?}: what would be found there
|
||||
//! GET /setups/{id} one machine, for refetching after a change
|
||||
//! GET /setups/{id}/dir?path=P entries of directory P, and P resolved
|
||||
//! GET /setups/{id}/file?path=P content of file P, or why not
|
||||
//! PUT /setups/{id}/file {path, content, ifSha256} -> new size/mtime/sha256
|
||||
//! (409 when the file no longer matches ifSha256)
|
||||
//! POST /setups/{id}/file {path} create empty; refused if it exists
|
||||
//! POST /setups/{id}/dir {path} create; refused if it exists
|
||||
//! PUT /setups/{id} rename {name?} and/or re-probe {rediscover?}
|
||||
//! DELETE /setups/{id} remove, refused while sessions use it
|
||||
//! GET /sessions list (id, provider, title, model, status, last activity)
|
||||
@@ -92,6 +98,15 @@ pub fn router(manager: Arc<SessionManager>) -> Router {
|
||||
"/setups/{id}",
|
||||
get(read_setup).put(update_setup).delete(delete_setup),
|
||||
)
|
||||
// The filesystem of the machine a setup names -- see
|
||||
// `crate::files`. Under the setup rather than under a session
|
||||
// because a filesystem is a property of a machine; a session only
|
||||
// says where to start looking.
|
||||
.route("/setups/{id}/dir", get(list_dir).post(create_dir))
|
||||
.route(
|
||||
"/setups/{id}/file",
|
||||
get(read_file).put(write_file).post(create_file),
|
||||
)
|
||||
.route("/sessions", get(list_sessions).post(spawn_session))
|
||||
.route("/sessions/{id}", get(read_session).delete(delete_session))
|
||||
.route("/sessions/{id}/events", get(events))
|
||||
@@ -447,6 +462,143 @@ async fn delete_setup(
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
/// The five explorer routes below all begin the same way: find the
|
||||
/// machine, and check that what the phone named is a path this will act on.
|
||||
///
|
||||
/// The check is `files::check_path`, shared with [`set_cwd`] -- one rule
|
||||
/// about what an acceptable path is, and one wording for refusing it.
|
||||
fn files_on(
|
||||
manager: &Arc<SessionManager>,
|
||||
id: &str,
|
||||
path: &str,
|
||||
) -> Result<(crate::session::transport::Transport, String), ApiError> {
|
||||
let setup = setup_by_id(manager, id)?;
|
||||
let path = crate::files::check_path(path).map_err(bad_request)?;
|
||||
Ok((
|
||||
crate::session::transport::Transport::for_setup(&setup),
|
||||
path,
|
||||
))
|
||||
}
|
||||
|
||||
/// A failure from one of the scripts is the *machine's* message -- "no
|
||||
/// such file or directory", "permission denied", ssh refusing the
|
||||
/// connection -- and it is written to be read where it happened, which is
|
||||
/// the phone. So it comes back as a 400 with those words rather than as a
|
||||
/// 500 and a log line only the backend can see.
|
||||
fn from_machine(err: anyhow::Error) -> ApiError {
|
||||
ApiError::BadRequest(format!("{err:#}"))
|
||||
}
|
||||
|
||||
/// Where a path is named for these routes.
|
||||
///
|
||||
/// Query rather than a path segment: a path contains slashes, and a
|
||||
/// segment that had to be escaped and unescaped would be a second encoding
|
||||
/// to keep in step with the phone's.
|
||||
#[derive(Deserialize)]
|
||||
struct PathQuery {
|
||||
path: String,
|
||||
}
|
||||
|
||||
/// What is in a directory, and what that directory resolved to.
|
||||
async fn list_dir(
|
||||
State(manager): State<Arc<SessionManager>>,
|
||||
UrlPath(id): UrlPath<String>,
|
||||
Query(query): Query<PathQuery>,
|
||||
) -> Result<axum::Json<crate::files::Listing>, ApiError> {
|
||||
let (transport, path) = files_on(&manager, &id, &query.path)?;
|
||||
crate::files::list(&transport, &path)
|
||||
.await
|
||||
.map(axum::Json)
|
||||
.map_err(from_machine)
|
||||
}
|
||||
|
||||
/// One file's content, or which of the three reasons there is none.
|
||||
///
|
||||
/// The path it was asked for rides along, so a phone that has moved on
|
||||
/// since can tell which answer this is.
|
||||
#[derive(Serialize)]
|
||||
struct FileResponse {
|
||||
path: String,
|
||||
#[serde(flatten)]
|
||||
read: crate::files::FileRead,
|
||||
}
|
||||
|
||||
async fn read_file(
|
||||
State(manager): State<Arc<SessionManager>>,
|
||||
UrlPath(id): UrlPath<String>,
|
||||
Query(query): Query<PathQuery>,
|
||||
) -> Result<axum::Json<FileResponse>, ApiError> {
|
||||
let (transport, path) = files_on(&manager, &id, &query.path)?;
|
||||
let read = crate::files::read(&transport, &path)
|
||||
.await
|
||||
.map_err(from_machine)?;
|
||||
Ok(axum::Json(FileResponse { path, read }))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct WriteFileRequest {
|
||||
path: String,
|
||||
content: String,
|
||||
/// The digest the read reported. Not optional: an editor that could
|
||||
/// omit it would be one overwrite away from losing an agent's edit,
|
||||
/// and "I did not check" is not something a caller should be able to
|
||||
/// say by leaving a field out.
|
||||
if_sha256: String,
|
||||
}
|
||||
|
||||
async fn write_file(
|
||||
State(manager): State<Arc<SessionManager>>,
|
||||
UrlPath(id): UrlPath<String>,
|
||||
axum::Json(body): axum::Json<WriteFileRequest>,
|
||||
) -> Result<axum::Json<crate::files::Written>, ApiError> {
|
||||
let (transport, path) = files_on(&manager, &id, &body.path)?;
|
||||
crate::files::write(
|
||||
&transport,
|
||||
&path,
|
||||
&body.if_sha256,
|
||||
body.content.into_bytes(),
|
||||
)
|
||||
.await
|
||||
.map_err(from_machine)?
|
||||
.map(axum::Json)
|
||||
.map_err(|crate::files::Stale| {
|
||||
ApiError::Conflict("this file changed on the machine since you opened it".to_string())
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct CreateRequest {
|
||||
path: String,
|
||||
}
|
||||
|
||||
async fn create_file(
|
||||
State(manager): State<Arc<SessionManager>>,
|
||||
UrlPath(id): UrlPath<String>,
|
||||
axum::Json(body): axum::Json<CreateRequest>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let (transport, path) = files_on(&manager, &id, &body.path)?;
|
||||
crate::files::create_file(&transport, &path)
|
||||
.await
|
||||
.map_err(from_machine)?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn create_dir(
|
||||
State(manager): State<Arc<SessionManager>>,
|
||||
UrlPath(id): UrlPath<String>,
|
||||
axum::Json(body): axum::Json<CreateRequest>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let (transport, path) = files_on(&manager, &id, &body.path)?;
|
||||
crate::files::create_dir(&transport, &path)
|
||||
.await
|
||||
.map_err(from_machine)?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(deny_unknown_fields)]
|
||||
@@ -1155,20 +1307,11 @@ async fn set_cwd(
|
||||
.into_iter()
|
||||
.find(|session| session.id == id)
|
||||
.ok_or_else(|| ApiError::NotFound(format!("no session {id}")))?;
|
||||
let cwd = body.cwd.to_string_lossy().trim().to_string();
|
||||
if cwd.is_empty() {
|
||||
return Err(ApiError::BadRequest(
|
||||
"a working directory is a path, and this one is empty".to_string(),
|
||||
));
|
||||
}
|
||||
// Absolute, because the alternative is relative to whatever the CLI is
|
||||
// launched from, which is not something the person typing it can see.
|
||||
if !cwd.starts_with('/') && !cwd.starts_with('~') {
|
||||
return Err(ApiError::BadRequest(format!(
|
||||
"{cwd} is not an absolute path, so where it would be depends on where the \
|
||||
session happens to start"
|
||||
)));
|
||||
}
|
||||
// The same question the explorer asks of every path it is given, so it
|
||||
// is asked in one place and refused in one wording.
|
||||
let cwd = crate::files::check_path(&body.cwd.to_string_lossy()).map_err(bad_request)?;
|
||||
let setup = setup_by_id(&manager, &session.setup)?;
|
||||
let transport = crate::session::transport::Transport::for_setup(&setup);
|
||||
if !crate::session::import::directory_exists(&transport, &cwd).await {
|
||||
@@ -1417,21 +1560,19 @@ async fn ship_attachment(
|
||||
}
|
||||
script.push_str(&format!("cat > {} && pwd -P", crate::ssh::quote(name)));
|
||||
let source = std::fs::File::open(local).with_context(|| format!("open {}", local.display()))?;
|
||||
let mut command = tokio::process::Command::from(crate::ssh::command(
|
||||
Some(ssh),
|
||||
"sh",
|
||||
&["-c".to_string(), script],
|
||||
None,
|
||||
));
|
||||
command
|
||||
.stdin(source)
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped());
|
||||
let output = command.output().await.context("run ssh")?;
|
||||
if !output.status.success() {
|
||||
anyhow::bail!("{}", String::from_utf8_lossy(&output.stderr).trim());
|
||||
}
|
||||
let dir = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
// Through the transport's own "with this on stdin", which the
|
||||
// explorer's write also uses -- one description of what that means
|
||||
// rather than an ssh invocation assembled here as well.
|
||||
let transport = crate::session::transport::Transport::Ssh {
|
||||
name: ssh.address.clone(),
|
||||
ssh: ssh.clone(),
|
||||
};
|
||||
let launch = crate::session::transport::Launch::new("sh", vec!["-c".to_string(), script], None);
|
||||
let stdout = transport
|
||||
.capture_with_input(&launch, crate::session::transport::Input::File(source))
|
||||
.await?
|
||||
.ok()?;
|
||||
let dir = String::from_utf8_lossy(&stdout).trim().to_string();
|
||||
if dir.is_empty() {
|
||||
anyhow::bail!("the remote shell did not say where it put the file");
|
||||
}
|
||||
|
||||
@@ -60,6 +60,11 @@ impl Launch {
|
||||
pub enum Streams {
|
||||
/// Pipes owned by this server; the child is killed when they drop.
|
||||
Piped,
|
||||
/// The same, except that stdin is already open on something this
|
||||
/// server holds -- the file being copied to another machine. Bytes
|
||||
/// this process has in memory do not need this: [`Streams::Piped`]
|
||||
/// gives a pipe to write them into as the child reads.
|
||||
PipedFrom(Stdio),
|
||||
/// Files -- and, for stdin, a fifo the child itself holds open so it
|
||||
/// never reads EOF -- that outlast this process.
|
||||
Detached {
|
||||
@@ -117,6 +122,13 @@ impl Transport {
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true);
|
||||
}
|
||||
Streams::PipedFrom(stdin) => {
|
||||
command
|
||||
.stdin(stdin)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true);
|
||||
}
|
||||
Streams::Detached {
|
||||
stdin,
|
||||
stdout,
|
||||
@@ -174,6 +186,57 @@ impl Transport {
|
||||
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
|
||||
}
|
||||
|
||||
/// Runs `launch` with `input` on its stdin and reports everything it
|
||||
/// produced -- stdout as bytes, stderr as text, and the exit status.
|
||||
///
|
||||
/// The one description of "run this there, with this on stdin", so
|
||||
/// that shipping an attachment and writing a file through the explorer
|
||||
/// are the same operation rather than two. It is also the only capture
|
||||
/// that hands back the **status**: a script can then answer with an
|
||||
/// exit code the caller distinguishes (the explorer's write says
|
||||
/// `exit 3` for "this file is not the one you read"), which
|
||||
/// [`Transport::capture`] cannot express because it turns every
|
||||
/// failure into one error.
|
||||
///
|
||||
/// Bytes rather than a `String`, because a file's contents are not
|
||||
/// text until something has checked, and lossy decoding would replace
|
||||
/// the evidence that they are not.
|
||||
///
|
||||
/// `Err` means the process could not be started at all; a process that
|
||||
/// ran and failed is a [`Captured`] with a status saying so.
|
||||
pub async fn capture_with_input(&self, launch: &Launch, input: Input) -> Result<Captured> {
|
||||
let (streams, to_write) = match input {
|
||||
Input::None => (Streams::Piped, None),
|
||||
Input::Bytes(bytes) => (Streams::Piped, Some(bytes)),
|
||||
Input::File(file) => (Streams::PipedFrom(file.into()), None),
|
||||
};
|
||||
let mut child = self.spawn(launch, streams)?;
|
||||
if let Some(bytes) = to_write {
|
||||
// Written from a task rather than before the wait, because the
|
||||
// child may not read all of it -- the write script exits
|
||||
// without reading when the file has changed underneath -- and
|
||||
// a caller blocked on filling a pipe nobody is draining would
|
||||
// deadlock instead of getting that answer. The broken pipe is
|
||||
// the expected end of this write, so it is dropped: what
|
||||
// happened is the exit status below.
|
||||
let mut stdin = child.stdin.take().context("the child has no stdin")?;
|
||||
tokio::spawn(async move {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
let _ = stdin.write_all(&bytes).await;
|
||||
let _ = stdin.shutdown().await;
|
||||
});
|
||||
}
|
||||
let output = child
|
||||
.wait_with_output()
|
||||
.await
|
||||
.with_context(|| format!("waiting for \"{}\" {}", launch.program, self.describe()))?;
|
||||
Ok(Captured {
|
||||
status: output.status,
|
||||
stdout: output.stdout,
|
||||
stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// How to say where this runs, for a log line a person reads.
|
||||
pub fn describe(&self) -> String {
|
||||
match self {
|
||||
@@ -182,3 +245,41 @@ impl Transport {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a command is given on its standard input.
|
||||
///
|
||||
/// Three cases rather than an `Option<Stdio>` because they are three
|
||||
/// genuinely different arrangements and only this knows which: nothing to
|
||||
/// say, bytes this process is holding, or a file it has open. The last one
|
||||
/// is how a several-hundred-megabyte attachment reaches another machine
|
||||
/// without passing through this server's memory.
|
||||
pub enum Input {
|
||||
None,
|
||||
Bytes(Vec<u8>),
|
||||
File(std::fs::File),
|
||||
}
|
||||
|
||||
/// Everything a finished command produced, including the status.
|
||||
pub struct Captured {
|
||||
pub status: std::process::ExitStatus,
|
||||
pub stdout: Vec<u8>,
|
||||
/// Trimmed, and what a failure is reported as: ssh's own refusals and
|
||||
/// a tool's own message about the file it could not open are both the
|
||||
/// useful half of why something did not work, and both are written to
|
||||
/// name the thing.
|
||||
pub stderr: String,
|
||||
}
|
||||
|
||||
impl Captured {
|
||||
/// The stdout of a command that succeeded, or the machine's own words.
|
||||
pub fn ok(self) -> Result<Vec<u8>> {
|
||||
if self.status.success() {
|
||||
return Ok(self.stdout);
|
||||
}
|
||||
anyhow::bail!(if self.stderr.is_empty() {
|
||||
format!("it failed with no explanation ({})", self.status)
|
||||
} else {
|
||||
self.stderr
|
||||
})
|
||||
}
|
||||
}
|
||||
+12
-19
@@ -18,7 +18,7 @@
|
||||
//! `config.ron` on the backend, which is exactly the authority the phone
|
||||
//! is not being given.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::config::{DriverKind, ProviderConfig};
|
||||
use crate::session::transport::{Launch, Transport};
|
||||
@@ -186,26 +186,19 @@ pub fn shorten_home(path: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs a launch to completion and returns its stdout.
|
||||
/// Runs a launch to completion and returns its stdout as text.
|
||||
///
|
||||
/// The common case of [`Transport::capture_with_input`]: nothing on stdin,
|
||||
/// a failure reported as the machine's own words (ssh's "Permission
|
||||
/// denied" or "Could not resolve hostname" is the useful half of why a
|
||||
/// setup cannot be reached), and the output read as text because every
|
||||
/// caller here is asking a question whose answer is words.
|
||||
impl Transport {
|
||||
pub async fn capture(&self, launch: &Launch) -> Result<String> {
|
||||
let child = self.spawn(launch, super::session::transport::Streams::Piped)?;
|
||||
let output = child
|
||||
.wait_with_output()
|
||||
.await
|
||||
.context("waiting for the probe to finish")?;
|
||||
if !output.status.success() {
|
||||
// ssh's own failures land on stderr -- "Permission denied",
|
||||
// "Could not resolve hostname" -- and are the useful half of
|
||||
// why a setup cannot be reached, so they are what comes back.
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
anyhow::bail!(if stderr.is_empty() {
|
||||
format!("couldn't reach it ({})", output.status)
|
||||
} else {
|
||||
stderr
|
||||
});
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
|
||||
let captured = self
|
||||
.capture_with_input(launch, super::session::transport::Input::None)
|
||||
.await?;
|
||||
Ok(String::from_utf8_lossy(&captured.ok()?).into_owned())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user