Read and change a machine's files from the backend

The first half of EXPLORER.md: server/src/files.rs, which lists a
directory, reads a file, writes one, and creates a file or a directory on
whichever machine a setup names.

Each operation is one small POSIX script run through `Transport`, the way
the import listing and the usage fetch already ask a machine a question,
so the local and the ssh case are one implementation rather than two that
drift. The path crosses as a positional argument and never as script
text; `PATH_PRELUDE` is the one line that gives a leading `~` its
meaning, because a shell expands a tilde in text and not in an argument,
and it is the far machine's home that has to answer.

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 with a 409 when the file has
moved on, which is what happens whenever an agent is editing the file
somebody is reading.

`Transport::capture_with_input` is the one description of "run this
there, with this on stdin", and `ship_attachment` moves onto it rather
than assembling a second ssh invocation of its own. It is also the only
capture that hands back the exit status, which is how the write says
"this is not the file you read" without that answer looking like a
failure.

Exercised on both transports against the sandbox -- ssh to this VM with a
throwaway key, since the quoting and the stdin path are what that proves
-- including a filename with an apostrophe, one with a tab, an unreadable
file, a binary one, one over the limit, and the 409.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-09-03 23:28:00 -04:00
1 parent f6bee1b8a5
commit cc7e4f63ef
8 files changed
+1008 -46

No files matched your search

+649
View File
@@ -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);
}
}
}