Nightly Build / build (push) Successful in 8m6s
`send_attachment` handed its `file_path` argument straight to `InputFile::file`, which resolves against the **server process's** working directory. Every path the model can actually have — relative to the user's home, or absolute inside their container — failed the `path.exists()` check, and the one class that didn't (a name that happens to exist next to the binary) would have sent the wrong file. The routing already exists for the fs-tools, so expose it rather than repeat it: `UserFilesApi` (core-api) reads a path in the agent's own vocabulary and is obtained from `UserChannelHandle::files()`, so it is scoped to one user by construction. skald-core implements it over `resolve_view_target` — host mount read directly, container-only path through `docker exec` — holding the `SharedFs` cell rather than a snapshot, so a remount lands without a login. The size cap is checked before the read (a new `exec_fs::size` for the container branch): the point of a cap is to keep an oversized file out of RAM, so checking it afterwards would protect nothing. A photo above `sendPhoto`'s narrower 10 MB ceiling goes out as a document instead of as an API error.
156 lines
5.9 KiB
Rust
156 lines
5.9 KiB
Rust
//! Filesystem primitives that act **inside** a user's container, for the paths
|
|
//! their bind mounts do not cover (`/tmp`, `/etc`, an installed package's files…).
|
|
//!
|
|
//! The security boundary is the container, not the bind-mounted subtree: an agent
|
|
//! already has unrestricted reach in there through `execute_cmd`, which runs with
|
|
//! passwordless `sudo`. Tools that stopped at the mounts were therefore not
|
|
//! protecting anything — they offered a poorer view of the same sandbox, and the
|
|
//! model routinely worked around them by shelling out. These primitives close
|
|
//! that gap so the fs-tools see what the shell sees.
|
|
//!
|
|
//! What does *not* change is host containment. A path that lands on a mount keeps
|
|
//! the host fast path and its canonicalize-and-prefix-check, which is what stops a
|
|
//! symlink planted in the container from resolving against the **host's** `/etc`.
|
|
//! Nothing here ever touches the host filesystem, so there is no host to escape
|
|
//! from on this side.
|
|
//!
|
|
//! Paths are passed to `sh` **positionally** (`$1`), never interpolated into the
|
|
//! script, so a path containing quotes or `$(…)` is data and not shell syntax —
|
|
//! the same rule `execute_cmd` already follows for its pidfile.
|
|
|
|
use std::path::Path;
|
|
use std::process::Stdio;
|
|
|
|
use anyhow::{Context, Result, bail};
|
|
use tokio::io::AsyncWriteExt;
|
|
|
|
/// Runs a shell snippet inside `container` with `args` bound to `$1`, `$2`, …
|
|
/// Returns raw stdout — callers that expect text decode it themselves, so a
|
|
/// binary `cat` is not mangled on the way through.
|
|
pub(super) async fn sh(container: &str, script: &str, args: &[&str]) -> Result<Vec<u8>> {
|
|
let mut argv: Vec<&str> = vec!["exec", container, "sh", "-c", script, "_"];
|
|
argv.extend_from_slice(args);
|
|
|
|
let out = tokio::process::Command::new("docker")
|
|
.args(&argv)
|
|
.stdin(Stdio::null())
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped())
|
|
.output()
|
|
.await
|
|
.context("failed to spawn `docker` (is the Docker CLI installed?)")?;
|
|
|
|
if out.status.success() {
|
|
Ok(out.stdout)
|
|
} else {
|
|
let err = String::from_utf8_lossy(&out.stderr);
|
|
bail!("{}", err.trim());
|
|
}
|
|
}
|
|
|
|
/// True when the snippet exits 0 — for the `test`-style probes, where a non-zero
|
|
/// exit is the answer rather than a failure.
|
|
async fn sh_ok(container: &str, script: &str, args: &[&str]) -> bool {
|
|
sh(container, script, args).await.is_ok()
|
|
}
|
|
|
|
/// Reads a file from inside the container.
|
|
pub async fn read(container: &str, path: &Path) -> Result<Vec<u8>> {
|
|
let p = path.to_string_lossy();
|
|
sh(container, r#"cat -- "$1""#, &[&p])
|
|
.await
|
|
.with_context(|| format!("Cannot read file: {p}"))
|
|
}
|
|
|
|
/// Writes a file inside the container, creating its parent directories. The
|
|
/// bytes travel on stdin rather than inside the script, so content is never
|
|
/// shell-parsed and size is bounded by the pipe, not by `ARG_MAX`.
|
|
pub async fn write(container: &str, path: &Path, bytes: &[u8]) -> Result<()> {
|
|
let p = path.to_string_lossy();
|
|
let mut child = tokio::process::Command::new("docker")
|
|
.args([
|
|
"exec", "-i", container, "sh", "-c",
|
|
r#"mkdir -p -- "$(dirname -- "$1")" && cat > "$1""#, "_", &p,
|
|
])
|
|
.stdin(Stdio::piped())
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped())
|
|
.spawn()
|
|
.context("failed to spawn `docker`")?;
|
|
|
|
child
|
|
.stdin
|
|
.take()
|
|
.context("docker exec produced no stdin")?
|
|
.write_all(bytes)
|
|
.await
|
|
.with_context(|| format!("Failed to write: {p}"))?;
|
|
|
|
let out = child.wait_with_output().await.context("docker exec failed")?;
|
|
if !out.status.success() {
|
|
bail!("Failed to write {p}: {}", String::from_utf8_lossy(&out.stderr).trim());
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Byte size of a file inside the container — for the callers that must decide
|
|
/// whether to read it *before* pulling it through the pipe. `wc -c` rather than
|
|
/// `stat`, so the answer is the same on any of the image's shells.
|
|
pub async fn size(container: &str, path: &Path) -> Result<u64> {
|
|
let p = path.to_string_lossy();
|
|
let raw = sh(container, r#"wc -c < "$1""#, &[&p])
|
|
.await
|
|
.with_context(|| format!("Cannot stat file: {p}"))?;
|
|
String::from_utf8_lossy(&raw)
|
|
.trim()
|
|
.parse()
|
|
.with_context(|| format!("Cannot stat file: {p}"))
|
|
}
|
|
|
|
pub async fn exists(container: &str, path: &Path) -> bool {
|
|
sh_ok(container, r#"test -e "$1""#, &[&path.to_string_lossy()]).await
|
|
}
|
|
|
|
pub async fn is_dir(container: &str, path: &Path) -> bool {
|
|
sh_ok(container, r#"test -d "$1""#, &[&path.to_string_lossy()]).await
|
|
}
|
|
|
|
/// One entry of a container directory listing.
|
|
pub struct Entry {
|
|
pub name: String,
|
|
pub is_dir: bool,
|
|
pub size: u64,
|
|
}
|
|
|
|
/// Lists a directory inside the container, `depth` levels deep (1 = immediate
|
|
/// children). Emits `type\tsize\tpath` per line via `find`, which is in the image
|
|
/// and needs no parsing of `ls`'s locale-dependent output.
|
|
pub async fn list(container: &str, path: &Path, depth: usize) -> Result<Vec<Entry>> {
|
|
let p = path.to_string_lossy();
|
|
let d = depth.max(1).to_string();
|
|
let raw = sh(
|
|
container,
|
|
r#"find "$1" -mindepth 1 -maxdepth "$2" -printf '%y\t%s\t%p\n' 2>/dev/null || true"#,
|
|
&[&p, &d],
|
|
)
|
|
.await
|
|
.with_context(|| format!("Cannot list directory: {p}"))?;
|
|
|
|
let text = String::from_utf8_lossy(&raw);
|
|
let prefix = format!("{}/", p.trim_end_matches('/'));
|
|
let mut out = Vec::new();
|
|
for line in text.lines() {
|
|
let mut f = line.splitn(3, '\t');
|
|
let (Some(kind), Some(size), Some(full)) = (f.next(), f.next(), f.next()) else {
|
|
continue;
|
|
};
|
|
out.push(Entry {
|
|
name: full.strip_prefix(&prefix).unwrap_or(full).to_string(),
|
|
is_dir: kind == "d",
|
|
size: size.parse().unwrap_or(0),
|
|
});
|
|
}
|
|
out.sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then_with(|| a.name.cmp(&b.name)));
|
|
Ok(out)
|
|
}
|