Stream attachments end to end, ship files to a remote session's machine, and write up the transcript work
Uploads no longer sit whole in memory anywhere: the phone writes the multipart body chunked as it reads the picked file, and the server writes each chunk to a `.part` file under the session and renames it when whole. The per-request cap is 4 GB and bounds disk, not memory. A file attached to a session on another machine is copied there in the same request: one ssh invocation takes the bytes on stdin into the setup's `attachmentsDir` (new, optional, on the machine form and in the config), else the session's cwd, else the login home, and answers with `pwd -P`, which is recorded beside the file as `<name>.remote` and is the path the driver tells the CLI. A failed copy fails the upload and says why, so no message ever names a file that is not there. The host keeps its copy so transcripts can reference and fetch it. Measured against the Gentoo test guest: a 40 MB file shared from the phone arrived there byte for byte. The tilde in that setting is the remote home, so it is not expanded on the server the way other setup paths are. TRANSCRIPT_RENDERING.md records the week of transcript work -- the measurements behind each decision, the harness, what was rejected, and what to do next -- so a new session can start from it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
6180663f14
commit
801618ba0e
12 files changed
+499
-57
No files matched your search
@@ -1220,6 +1220,14 @@ fn attachment_path(session_dir: &Path, id: &str) -> Result<PathBuf> {
|
||||
anyhow::bail!("invalid attachment id");
|
||||
}
|
||||
let path = session_dir.join("attachments").join(id);
|
||||
// A file copied to the session's own machine is named where it landed
|
||||
// there -- `routes::upload_attachment` writes that down beside it --
|
||||
// because the path has to be one the CLI can open, not one this server
|
||||
// can.
|
||||
let shipped = path.with_file_name(format!("{id}.remote"));
|
||||
if let Ok(remote) = std::fs::read_to_string(&shipped) {
|
||||
return Ok(PathBuf::from(remote.trim()));
|
||||
}
|
||||
std::fs::canonicalize(&path).with_context(|| format!("find {}", path.display()))
|
||||
}
|
||||
|
||||
@@ -1244,6 +1252,30 @@ fn attachment_block(session_dir: &Path, id: &str) -> Result<Value> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn an_attachment_is_named_where_the_cli_can_open_it() {
|
||||
let dir = tempfile::tempdir().expect("temp dir");
|
||||
let attachments = dir.path().join("attachments");
|
||||
std::fs::create_dir(&attachments).unwrap();
|
||||
std::fs::write(attachments.join("ab12-x.bin"), b"x").unwrap();
|
||||
assert_eq!(
|
||||
attachment_path(dir.path(), "ab12-x.bin").unwrap(),
|
||||
attachments.join("ab12-x.bin").canonicalize().unwrap()
|
||||
);
|
||||
// Shipped to the session's machine: the path there, not here.
|
||||
std::fs::write(
|
||||
attachments.join("ab12-x.bin.remote"),
|
||||
"/home/t/in/ab12-x.bin\n",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
attachment_path(dir.path(), "ab12-x.bin").unwrap(),
|
||||
PathBuf::from("/home/t/in/ab12-x.bin")
|
||||
);
|
||||
assert!(attachment_path(dir.path(), "../config.ron").is_err());
|
||||
assert!(attachment_path(dir.path(), "missing.bin").is_err());
|
||||
}
|
||||
|
||||
/// Drives real CLI output lines through the reader and collects what
|
||||
/// came out, which is the only way to check the wiring between "the
|
||||
/// CLI said this" and "the transcript records that".
|
||||
|
||||
+45
-10
@@ -485,9 +485,11 @@ impl LiveSession {
|
||||
.expect("transcript lives in the session dir")
|
||||
}
|
||||
|
||||
/// Stores one uploaded attachment, returning the id `POST /message`
|
||||
/// references it by. Removed with the session directory on delete --
|
||||
/// the same path out as everything else in it.
|
||||
/// Reserves the name and path for one uploaded attachment; the caller
|
||||
/// writes the bytes, since a trace is bigger than this should hold.
|
||||
/// The name is the id `POST /message` references it by. Removed with
|
||||
/// the session directory on delete -- the same path out as everything
|
||||
/// else in it.
|
||||
///
|
||||
/// An image is named `<hex>.<extension>` and nothing else, since the
|
||||
/// model is shown the picture rather than told its name. Anything else
|
||||
@@ -496,21 +498,19 @@ impl LiveSession {
|
||||
/// more to it than `3f9a…` would. The name is cleaned to characters a
|
||||
/// path and a URL both take unquoted, and the hex keeps two uploads of
|
||||
/// the same name apart. `AttachmentRef` documents the two shapes.
|
||||
pub fn save_attachment(
|
||||
pub fn new_attachment(
|
||||
&self,
|
||||
bytes: &[u8],
|
||||
content_type: &str,
|
||||
file_name: Option<&str>,
|
||||
) -> Result<AttachmentRef> {
|
||||
) -> Result<(AttachmentRef, PathBuf)> {
|
||||
let name = match crate::media::extension_for(content_type) {
|
||||
Some(extension) => format!("{}.{extension}", random_hex()),
|
||||
None => format!("{}-{}", random_hex(), safe_file_name(file_name)),
|
||||
};
|
||||
let dir = self.dir().join("attachments");
|
||||
wg_app_link::private::create_dir(&dir)?;
|
||||
std::fs::write(dir.join(&name), bytes)
|
||||
.with_context(|| format!("write attachment {name}"))?;
|
||||
Ok(name)
|
||||
let path = dir.join(&name);
|
||||
Ok((name, path))
|
||||
}
|
||||
|
||||
/// `setup_name` and `cwd` are passed in rather than read from the
|
||||
@@ -983,6 +983,20 @@ impl SessionManager {
|
||||
/// direction, and it exists for the same delete the phone offers a
|
||||
/// toggle for: removing a session here can also remove the machine's
|
||||
/// own transcript of it, and only the server knows which file that is.
|
||||
/// The machine a session runs on when that is not this one, with the
|
||||
/// session's working directory there: what an upload needs to put a
|
||||
/// file where the session can read it. `None` for a local session.
|
||||
pub fn remote_of(&self, id: &str) -> Option<(crate::config::SshConfig, Option<PathBuf>)> {
|
||||
let inner = self.inner.read().unwrap();
|
||||
let meta = inner.config.sessions.iter().find(|meta| meta.id == id)?;
|
||||
let setup = inner
|
||||
.config
|
||||
.setups
|
||||
.iter()
|
||||
.find(|setup| setup.id == meta.setup)?;
|
||||
Some((setup.ssh.clone()?, meta.cwd.clone()))
|
||||
}
|
||||
|
||||
pub fn foreign_transcript(&self, id: &str) -> Option<(String, String)> {
|
||||
let inner = self.inner.read().unwrap();
|
||||
let meta = inner.config.sessions.iter().find(|meta| meta.id == id)?;
|
||||
@@ -1802,7 +1816,9 @@ fn safe_file_name(name: Option<&str>) -> String {
|
||||
})
|
||||
.collect();
|
||||
let cleaned = cleaned.replace("..", "_").trim_matches('.').to_string();
|
||||
if cleaned.is_empty() {
|
||||
// Nothing a person would recognise as a name is left: say so rather
|
||||
// than store a file called `_`.
|
||||
if !cleaned.chars().any(|c| c.is_ascii_alphanumeric()) {
|
||||
return "file".to_string();
|
||||
}
|
||||
let excess = cleaned.chars().count().saturating_sub(FILE_NAME_LIMIT);
|
||||
@@ -2393,6 +2409,25 @@ async fn pump(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_file_name_is_reduced_to_what_an_id_may_hold() {
|
||||
assert_eq!(
|
||||
safe_file_name(Some("trace komodo (1).perfetto-trace")),
|
||||
"trace_komodo__1_.perfetto-trace"
|
||||
);
|
||||
let hostile = safe_file_name(Some("../../etc/passwd"));
|
||||
assert!(
|
||||
!hostile.contains('/') && !hostile.contains(".."),
|
||||
"{hostile}"
|
||||
);
|
||||
assert_eq!(safe_file_name(Some("...")), "file");
|
||||
assert_eq!(safe_file_name(None), "file");
|
||||
let long = "x".repeat(200) + ".pftrace";
|
||||
let kept = safe_file_name(Some(&long));
|
||||
assert_eq!(kept.len(), FILE_NAME_LIMIT);
|
||||
assert!(kept.ends_with(".pftrace"));
|
||||
}
|
||||
use std::time::Duration;
|
||||
|
||||
fn echo_spec() -> SpawnSpec {
|
||||
|
||||
Reference in new issue
Block a user