Skip to main content

max / makenotwork

39.9 KB · 1006 lines History Blame Raw
1 //! Concrete [`Executor`] transports.
2 //!
3 //! - [`LocalExec`] — spawn on the local machine (Sando's `ssh_target = "local"`
4 //! fast-path; the agent's own in-process execution).
5 //! - [`SshExec`] — spawn `ssh` / `rsync` over the tailnet. Auth is the existing
6 //! SSH keys; the rsync push/pull is extracted near-verbatim from Sando's
7 //! `deploy.rs`.
8 //!
9 //! Both render a [`Step`] to one `/bin/sh` line and run it through the shared
10 //! [`RemoteHost`] command builder, so local and remote shell semantics are
11 //! identical. The capability gate is enforced here, caller-side, before any
12 //! command is dispatched.
13
14 use crate::capability::{CapabilityDenied, CapabilitySet};
15 use crate::executor::{Executor, SyncOpts, run_command_into_sink};
16 use crate::remote::{LogSink, RemoteHost, RunOutput, sh_quote};
17 use crate::step::{Action, ObserveKind, Step};
18 use anyhow::{Context, Result};
19 use async_trait::async_trait;
20 use std::fmt::Write as _;
21 use std::path::{Component, Path, PathBuf};
22 use tokio::process::Command;
23
24 /// Render a step to a single `/bin/sh` command line: optional `cd`, env
25 /// exports, then the body (a shell script verbatim, or sh-quoted argv).
26 fn render_shell_line(step: &Step) -> String {
27 let mut line = String::new();
28 if let Some(cwd) = &step.cwd {
29 let _ = write!(line, "cd {} && ", sh_quote(&cwd.to_string_lossy()));
30 }
31 for (k, v) in &step.env {
32 let _ = write!(line, "{}={} ", k, sh_quote(v));
33 }
34 match step.shell_script() {
35 Some(script) => line.push_str(script),
36 None => {
37 let parts: Vec<String> = step.argv.iter().map(|a| sh_quote(a)).collect();
38 line.push_str(&parts.join(" "));
39 }
40 }
41 line
42 }
43
44 /// Enforce the caller-side capability gate, returning [`CapabilityDenied`] as
45 /// an `anyhow::Error` the caller can downcast for audit logging.
46 fn gate(caps: &CapabilitySet, host: &str, step: &Step) -> Result<()> {
47 if caps.permits(&step.action) {
48 Ok(())
49 } else {
50 Err(CapabilityDenied::new(host, &step.action).into())
51 }
52 }
53
54 /// Gate a sync-plane artifact pull: the caller-side equivalent of the agent's
55 /// `/pull` handler (`agent::pull`). Two conditions, both **fail-closed**:
56 ///
57 /// 1. The grant must include `observe:artifact` — retrieving a produced release
58 /// artifact is an observe-plane capability, distinct from reading the build
59 /// log. Denied → [`CapabilityDenied`], downcastable for audit.
60 /// 2. `requested` must lie under one of the executor's declared artifact roots.
61 /// A transport with **no** roots pulls nothing — that is the "declared
62 /// per-host artifact root" the audit finding calls for.
63 ///
64 /// Without this, `pull_file`/`pull_dir`/`pull_glob` would rsync ANY path off the
65 /// host (`collect('mbp', '/Users/max/.tauri/passwords.env', …)` deposits the
66 /// notary credential into `dist_root`): "THE WALL" held on the agent plane and
67 /// not on the sync plane the agent hosts are actually collected over.
68 ///
69 /// **Several roots, not one, because a build host builds from several trees.**
70 /// One root was right while every collecting app lived under `~/Code/Apps`. pom
71 /// is the first that does not — it is at `~/Code/MNW/pom`, and its release
72 /// failed here on 2026-08-09. The fix is not a wider root: the only single path
73 /// covering both is `~/Code`, which contains `~/Code/_private` and its signing
74 /// keys, so widening would hand away exactly what this gate exists to hold. A
75 /// list says the true thing instead, which is that a host has some artifact
76 /// trees and not others.
77 ///
78 /// Confinement is **lexical** (reject `..`, require a component-wise prefix),
79 /// not canonicalizing like the agent's `confine_to_root`: the ssh transport's
80 /// path is on a remote host we cannot `canonicalize()` without an extra
81 /// round-trip, so both transports share the one check. The root is
82 /// operator-declared on trusted infra and the threat closed here is a recipe- or
83 /// daemon-supplied wild path; a symlink *inside* the root pointing out is the
84 /// agent plane's stronger (canonicalizing) guarantee, out of scope here.
85 fn gate_pull(
86 caps: &CapabilitySet,
87 host: &str,
88 pull_roots: &[PathBuf],
89 requested: &Path,
90 ) -> Result<()> {
91 if !caps.permits_observe(&ObserveKind::Artifact) {
92 return Err(CapabilityDenied::new(host, &Action::Observe(ObserveKind::Artifact)).into());
93 }
94 if pull_roots.is_empty() {
95 anyhow::bail!(
96 "pull from `{host}` denied: no artifact root declared for this host \
97 (set `pull_root` or `pull_roots` in the host's topology entry)"
98 );
99 }
100 // Checked before the prefix test, and separately: `..` inside a path that
101 // also happens to start with a root would otherwise pass the prefix and
102 // climb out of it afterwards.
103 anyhow::ensure!(
104 !requested
105 .components()
106 .any(|c| matches!(c, Component::ParentDir)),
107 "pull path `{}` from `{host}` contains `..`",
108 requested.display()
109 );
110 anyhow::ensure!(
111 pull_roots.iter().any(|root| requested.starts_with(root)),
112 "pull path `{}` escapes every declared artifact root on `{host}` ({})",
113 requested.display(),
114 pull_roots
115 .iter()
116 .map(|r| format!("`{}`", r.display()))
117 .collect::<Vec<_>>()
118 .join(", "),
119 );
120 Ok(())
121 }
122
123 /// An env *value* is sh-quoted at render time, but the *name* is interpolated
124 /// verbatim (`NAME=value`), so a name with shell metacharacters could inject a
125 /// command. Require each name to be a bare shell identifier before dispatch.
126 fn validate_env_names(step: &Step) -> Result<()> {
127 for (k, _) in &step.env {
128 let valid = !k.is_empty()
129 && k.chars()
130 .next()
131 .is_some_and(|c| c == '_' || c.is_ascii_alphabetic())
132 && k.chars().all(|c| c == '_' || c.is_ascii_alphanumeric());
133 anyhow::ensure!(
134 valid,
135 "env name `{k}` must be a shell identifier ([A-Za-z_][A-Za-z0-9_]*)"
136 );
137 }
138 Ok(())
139 }
140
141 /// Build the rsync `Command` shared by local and ssh push/pull. `src`/`dst` are
142 /// either plain paths (local) or `target:path` (ssh). `ssh_args`, when `Some`,
143 /// is the `ssh` argv rsync should transport over — [`RemoteHost::ssh_args`],
144 /// so the port and flags match the exec path exactly; `None` = a local rsync.
145 fn rsync_command(src: &str, dst: &str, ssh_args: Option<Vec<String>>, opts: &SyncOpts) -> Command {
146 rsync_command_multi(std::slice::from_ref(&src.to_string()), dst, ssh_args, opts)
147 }
148
149 /// As [`rsync_command`], but with several sources into one destination — what a
150 /// glob expands to. `srcs` are passed as separate argv entries, never joined,
151 /// so a path is never re-split on whitespace.
152 fn rsync_command_multi(
153 srcs: &[String],
154 dst: &str,
155 ssh_args: Option<Vec<String>>,
156 opts: &SyncOpts,
157 ) -> Command {
158 let mut rsync = Command::new("rsync");
159 // Kill the transfer if the caller's future is dropped (e.g. a promote whose
160 // HTTP handler was cancelled by a client disconnect). Without this the
161 // rsync orphans and keeps running; a retry then spawns a second rsync that
162 // fights the first over the same `--delete` destination dir, wedging the
163 // deploy. Matches the ssh-exec (remote.rs) and local-exec (executor.rs)
164 // paths, which already set it.
165 rsync.kill_on_drop(true);
166 rsync.arg("-a");
167 if opts.partial {
168 rsync.arg("--partial");
169 }
170 if opts.compress {
171 rsync.arg("-z");
172 }
173 if opts.delete {
174 rsync.arg("--delete");
175 }
176 if let Some(chmod) = &opts.chmod {
177 rsync.arg(format!("--chmod={chmod}"));
178 }
179 if opts.mkpath {
180 rsync.arg("--mkpath");
181 }
182 for pattern in &opts.exclude {
183 rsync.arg(format!("--exclude={pattern}"));
184 }
185 if let Some(args) = ssh_args {
186 rsync.arg("-e").arg(format!("ssh {}", args.join(" ")));
187 }
188 rsync.args(srcs).arg(dst);
189 rsync
190 }
191
192 /// Expand a glob against the local filesystem, for the transports that have no
193 /// remote shell to do it. Returns the matches sorted (deterministic argv), and
194 /// errors when nothing matches — a collect that quietly gathers zero files is
195 /// how an empty release ships.
196 fn expand_glob_locally(pattern: &str) -> Result<Vec<String>> {
197 let paths = glob::glob(pattern).with_context(|| format!("bad glob pattern `{pattern}`"))?;
198 let mut out: Vec<String> = Vec::new();
199 for entry in paths {
200 let path = entry.with_context(|| format!("reading glob match for `{pattern}`"))?;
201 out.push(path.to_string_lossy().into_owned());
202 }
203 anyhow::ensure!(!out.is_empty(), "glob `{pattern}` matched no files");
204 out.sort();
205 Ok(out)
206 }
207
208 async fn run_rsync(mut cmd: Command, what: &str) -> Result<()> {
209 let out = cmd
210 .output()
211 .await
212 .with_context(|| format!("spawning rsync ({what})"))?;
213 anyhow::ensure!(
214 out.status.success(),
215 "rsync {what} failed: {}",
216 String::from_utf8_lossy(&out.stderr),
217 );
218 Ok(())
219 }
220
221 /// The local-machine transport.
222 pub struct LocalExec {
223 host: RemoteHost,
224 caps: CapabilitySet,
225 pull_roots: Vec<PathBuf>,
226 }
227
228 impl LocalExec {
229 pub fn new(caps: CapabilitySet) -> Self {
230 Self {
231 host: RemoteHost::new("local"),
232 caps,
233 pull_roots: Vec::new(),
234 }
235 }
236
237 /// Confine this transport's artifact pulls to `root` (see [`gate_pull`]).
238 /// Required before any `pull_*` succeeds — pulls are fail-closed, so an
239 /// executor built without a root refuses every pull.
240 #[must_use]
241 pub fn with_pull_root(mut self, root: impl Into<PathBuf>) -> Self {
242 self.pull_roots.push(root.into());
243 self
244 }
245
246 /// Declare several artifact roots at once. A host builds out of more than
247 /// one tree (`~/Code/Apps` and `~/Code/MNW`), and naming them is the
248 /// alternative to a single root wide enough to cover both.
249 #[must_use]
250 pub fn with_pull_roots<I, P>(mut self, roots: I) -> Self
251 where
252 I: IntoIterator<Item = P>,
253 P: Into<PathBuf>,
254 {
255 self.pull_roots.extend(roots.into_iter().map(Into::into));
256 self
257 }
258 }
259
260 #[async_trait]
261 impl Executor for LocalExec {
262 async fn run_streaming(&self, step: &Step, sink: &mut dyn LogSink) -> Result<RunOutput> {
263 gate(&self.caps, "local", step)?;
264 validate_env_names(step)?;
265 let (cmd, sentinel) = self.host.command_for(&render_shell_line(step));
266 run_command_into_sink(cmd, sink, sentinel, "local").await
267 }
268
269 async fn pull_file(&self, remote: &Path, local: &Path, opts: &SyncOpts) -> Result<()> {
270 gate_pull(&self.caps, "local", &self.pull_roots, remote)?;
271 // No trailing slash: rsync copies the file itself.
272 let src = remote.to_string_lossy().to_string();
273 run_rsync(
274 rsync_command(&src, &local.to_string_lossy(), None, opts),
275 "pull_file(local)",
276 )
277 .await
278 }
279
280 async fn pull_dir(&self, remote: &Path, local: &Path, opts: &SyncOpts) -> Result<()> {
281 gate_pull(&self.caps, "local", &self.pull_roots, remote)?;
282 // Local "pull" is just a local rsync; trailing slash = contents.
283 let src = format!("{}/", remote.display());
284 run_rsync(
285 rsync_command(&src, &local.to_string_lossy(), None, opts),
286 "pull_dir(local)",
287 )
288 .await
289 }
290
291 async fn pull_glob(&self, remote_glob: &str, local_dir: &Path, opts: &SyncOpts) -> Result<()> {
292 gate_pull(
293 &self.caps,
294 "local",
295 &self.pull_roots,
296 Path::new(remote_glob),
297 )?;
298 // No remote shell to expand for us, and `rsync` is spawned directly (no
299 // shell), so expand in-process. This is the half of `pull_glob` that
300 // differs from the ssh transport, and the reason the trait's docs warn
301 // against assuming a shell.
302 let matches = expand_glob_locally(remote_glob)?;
303 let dst = format!("{}/", local_dir.display());
304 run_rsync(
305 rsync_command_multi(&matches, &dst, None, opts),
306 "pull_glob(local)",
307 )
308 .await
309 }
310
311 async fn push_dir(&self, local: &Path, remote: &Path, opts: &SyncOpts) -> Result<()> {
312 let src = format!("{}/", local.display());
313 run_rsync(
314 rsync_command(&src, &remote.to_string_lossy(), None, opts),
315 "push_dir(local)",
316 )
317 .await
318 }
319
320 fn capabilities(&self) -> &CapabilitySet {
321 &self.caps
322 }
323 }
324
325 /// The SSH transport: a tailnet host reached with the existing SSH keys.
326 pub struct SshExec {
327 host: RemoteHost,
328 caps: CapabilitySet,
329 pull_roots: Vec<PathBuf>,
330 }
331
332 impl SshExec {
333 pub fn new(ssh_target: impl Into<String>, caps: CapabilitySet) -> Self {
334 Self {
335 host: RemoteHost::new(ssh_target),
336 caps,
337 pull_roots: Vec::new(),
338 }
339 }
340
341 /// Reach this host on a non-default SSH port. `None` keeps ssh's default,
342 /// so a caller holding an `Option<u16>` can pass it straight through. The
343 /// port applies to both the exec and sync paths (see [`RemoteHost`]).
344 #[must_use]
345 pub fn with_port(mut self, port: Option<u16>) -> Self {
346 self.host = self.host.with_port(port);
347 self
348 }
349
350 /// Confine this transport's artifact pulls to `root` — an absolute path as
351 /// seen ON THIS HOST (a remote root, so it is never tilde-expanded by the
352 /// caller). See [`gate_pull`]; required before any `pull_*` succeeds.
353 #[must_use]
354 pub fn with_pull_root(mut self, root: impl Into<PathBuf>) -> Self {
355 self.pull_roots.push(root.into());
356 self
357 }
358
359 /// Declare several artifact roots at once. A host builds out of more than
360 /// one tree (`~/Code/Apps` and `~/Code/MNW`), and naming them is the
361 /// alternative to a single root wide enough to cover both.
362 #[must_use]
363 pub fn with_pull_roots<I, P>(mut self, roots: I) -> Self
364 where
365 I: IntoIterator<Item = P>,
366 P: Into<PathBuf>,
367 {
368 self.pull_roots.extend(roots.into_iter().map(Into::into));
369 self
370 }
371
372 pub fn ssh_target(&self) -> &str {
373 self.host.ssh_target()
374 }
375 }
376
377 #[async_trait]
378 impl Executor for SshExec {
379 async fn run_streaming(&self, step: &Step, sink: &mut dyn LogSink) -> Result<RunOutput> {
380 gate(&self.caps, self.host.ssh_target(), step)?;
381 validate_env_names(step)?;
382 let (cmd, sentinel) = self.host.command_for(&render_shell_line(step));
383 run_command_into_sink(cmd, sink, sentinel, self.host.ssh_target()).await
384 }
385
386 async fn pull_file(&self, remote: &Path, local: &Path, opts: &SyncOpts) -> Result<()> {
387 gate_pull(&self.caps, self.host.ssh_target(), &self.pull_roots, remote)?;
388 // No trailing slash: rsync copies the file itself, not "contents of".
389 let src = format!("{}:{}", self.host.ssh_target(), remote.display());
390 let ssh = Some(self.host.ssh_args());
391 run_rsync(
392 rsync_command(&src, &local.to_string_lossy(), ssh, opts),
393 "pull_file(ssh)",
394 )
395 .await
396 }
397
398 async fn pull_dir(&self, remote: &Path, local: &Path, opts: &SyncOpts) -> Result<()> {
399 gate_pull(&self.caps, self.host.ssh_target(), &self.pull_roots, remote)?;
400 let src = format!("{}:{}/", self.host.ssh_target(), remote.display());
401 let ssh = Some(self.host.ssh_args());
402 run_rsync(
403 rsync_command(&src, &local.to_string_lossy(), ssh, opts),
404 "pull_dir(ssh)",
405 )
406 .await
407 }
408
409 async fn pull_glob(&self, remote_glob: &str, local_dir: &Path, opts: &SyncOpts) -> Result<()> {
410 gate_pull(
411 &self.caps,
412 self.host.ssh_target(),
413 &self.pull_roots,
414 Path::new(remote_glob),
415 )?;
416 // The REMOTE shell expands this one: rsync hands an un-`--protect-args`
417 // remote path to the login shell on the far side, so the wildcard must
418 // reach it intact — hence no quoting here. rsync exits non-zero when the
419 // pattern matches nothing, which is the error we want.
420 let src = format!("{}:{}", self.host.ssh_target(), remote_glob);
421 let dst = format!("{}/", local_dir.display());
422 let ssh = Some(self.host.ssh_args());
423 run_rsync(rsync_command(&src, &dst, ssh, opts), "pull_glob(ssh)").await
424 }
425
426 async fn push_dir(&self, local: &Path, remote: &Path, opts: &SyncOpts) -> Result<()> {
427 let src = format!("{}/", local.display());
428 let dst = format!("{}:{}/", self.host.ssh_target(), remote.display());
429 let ssh = Some(self.host.ssh_args());
430 run_rsync(rsync_command(&src, &dst, ssh, opts), "push_dir(ssh)").await
431 }
432
433 fn capabilities(&self) -> &CapabilitySet {
434 &self.caps
435 }
436 }
437
438 #[cfg(test)]
439 mod tests {
440 use super::*;
441 use crate::step::Action;
442 use std::sync::Arc;
443
444 #[derive(Default)]
445 struct VecSink(Vec<u8>);
446 #[async_trait]
447 impl LogSink for VecSink {
448 async fn write_chunk(&mut self, bytes: &[u8]) {
449 self.0.extend_from_slice(bytes);
450 }
451 }
452
453 fn vec_sink() -> VecSink {
454 VecSink::default()
455 }
456
457 /// The grant a sync transport needs to pull artifacts: `observe:artifact`
458 /// and nothing else. Pair with `.with_pull_root(...)` in the pull tests.
459 fn artifact_caps() -> CapabilitySet {
460 CapabilitySet::from_tokens(Vec::<&str>::new(), ["artifact"])
461 }
462
463 #[test]
464 fn render_plain_argv_quotes_each_token() {
465 let step = Step::new(Action::Build, ["echo", "a b", "c"]);
466 assert_eq!(render_shell_line(&step), "'echo' 'a b' 'c'");
467 }
468
469 #[test]
470 fn render_shell_script_is_verbatim_with_env_and_cwd() {
471 let step = Step::shell(Action::Deploy, "set -e; echo hi")
472 .with_env("K", "v v")
473 .with_cwd("/tmp/x");
474 assert_eq!(
475 render_shell_line(&step),
476 "cd '/tmp/x' && K='v v' set -e; echo hi"
477 );
478 }
479
480 #[tokio::test]
481 async fn rejects_malicious_env_name_before_dispatch() {
482 let dir = tempfile::tempdir().unwrap();
483 let marker = dir.path().join("pwned");
484 let exec = LocalExec::new(CapabilitySet::actuate_only([Action::Build]));
485 let mut sink = vec_sink();
486 // A shell-metacharacter env name would otherwise inject a command.
487 let step = Step::new(Action::Build, ["true"])
488 .with_env(format!("X; touch {}", marker.display()), "v");
489 let err = exec.run_streaming(&step, &mut sink).await.unwrap_err();
490 assert!(err.to_string().contains("shell identifier"));
491 assert!(!marker.exists(), "rejected env name must not execute");
492 }
493
494 #[tokio::test]
495 async fn local_exec_runs_granted_step() {
496 let exec = LocalExec::new(CapabilitySet::actuate_only([Action::Deploy]));
497 let mut sink = vec_sink();
498 let step = Step::shell(Action::Deploy, "printf ok");
499 let out = exec.run_streaming(&step, &mut sink).await.unwrap();
500 assert!(out.success());
501 assert_eq!(sink.0, b"ok");
502 }
503
504 #[tokio::test]
505 async fn local_exec_denies_ungranted_step_before_dispatch() {
506 // Grant deploy only; ask it to sign. Must deny, and must NOT run the
507 // command (the file the command would create must not appear).
508 let dir = tempfile::tempdir().unwrap();
509 let marker = dir.path().join("ran");
510 let exec = LocalExec::new(CapabilitySet::actuate_only([Action::Deploy]));
511 let mut sink = vec_sink();
512 let step = Step::shell(Action::Sign, format!("touch {}", marker.display()));
513 let err = exec.run_streaming(&step, &mut sink).await.unwrap_err();
514 let denied = err
515 .downcast_ref::<CapabilityDenied>()
516 .expect("CapabilityDenied");
517 assert_eq!(denied.action, "sign");
518 assert!(!marker.exists(), "denied step must not execute");
519 }
520
521 #[tokio::test]
522 async fn dyn_executor_object_is_usable() {
523 // Prove the trait is object-safe and Arc<dyn Executor> works.
524 let exec: Arc<dyn Executor> = Arc::new(LocalExec::new(CapabilitySet::actuate_only([
525 Action::Restart,
526 ])));
527 assert!(exec.capabilities().permits(&Action::Restart));
528 let mut sink = vec_sink();
529 let out = exec
530 .run_streaming(&Step::shell(Action::Restart, "true"), &mut sink)
531 .await
532 .unwrap();
533 assert!(out.success());
534 }
535
536 #[tokio::test]
537 async fn local_push_and_pull_move_a_dir() {
538 let dir = tempfile::tempdir().unwrap();
539 let src = dir.path().join("src");
540 let mid = dir.path().join("mid");
541 let dst = dir.path().join("dst");
542 tokio::fs::create_dir_all(&src).await.unwrap();
543 tokio::fs::write(src.join("f.txt"), b"hi").await.unwrap();
544 let exec = LocalExec::new(artifact_caps()).with_pull_root(dir.path());
545 exec.push_dir(&src, &mid, &SyncOpts::default())
546 .await
547 .unwrap();
548 assert_eq!(tokio::fs::read(mid.join("f.txt")).await.unwrap(), b"hi");
549 exec.pull_dir(&mid, &dst, &SyncOpts::default())
550 .await
551 .unwrap();
552 assert_eq!(tokio::fs::read(dst.join("f.txt")).await.unwrap(), b"hi");
553 }
554
555 /// The distinction the split exists for: a single file lands AS a file.
556 /// Under the old dir-shaped `pull` this path was `{file}/` — rsync would
557 /// refuse it.
558 #[tokio::test]
559 async fn local_pull_file_copies_one_file() {
560 let dir = tempfile::tempdir().unwrap();
561 let src = dir.path().join("dump.sql.gz");
562 let dst = dir.path().join("fetched.sql.gz");
563 tokio::fs::write(&src, b"DUMPBYTES").await.unwrap();
564 let exec = LocalExec::new(artifact_caps()).with_pull_root(dir.path());
565 exec.pull_file(&src, &dst, &SyncOpts::default())
566 .await
567 .unwrap();
568 assert_eq!(tokio::fs::read(&dst).await.unwrap(), b"DUMPBYTES");
569 }
570
571 /// Pins what the file/dir split actually buys, which is NOT a type-level
572 /// refusal: `-a` implies `-r`, so `pull_file` pointed at a directory does
573 /// rsync's no-trailing-slash thing — copies the dir INTO the destination
574 /// (`out/adir/f.txt`) rather than erroring. `pull_dir` would have put the
575 /// contents at `out/f.txt`. Documented here because the difference is
576 /// silent, and the docs on `pull_file` promise only file→file.
577 #[tokio::test]
578 async fn local_pull_file_on_a_dir_nests_rather_than_flattening() {
579 let dir = tempfile::tempdir().unwrap();
580 let src = dir.path().join("adir");
581 let out = dir.path().join("out");
582 tokio::fs::create_dir_all(&src).await.unwrap();
583 tokio::fs::create_dir_all(&out).await.unwrap();
584 tokio::fs::write(src.join("f.txt"), b"hi").await.unwrap();
585 let exec = LocalExec::new(artifact_caps()).with_pull_root(dir.path());
586 exec.pull_file(&src, &out, &SyncOpts::default())
587 .await
588 .unwrap();
589 assert_eq!(
590 tokio::fs::read(out.join("adir").join("f.txt"))
591 .await
592 .unwrap(),
593 b"hi"
594 );
595 assert!(
596 !out.join("f.txt").exists(),
597 "pull_file does not flatten; that's pull_dir"
598 );
599 }
600
601 #[tokio::test]
602 async fn local_pull_glob_gathers_matches_and_ignores_the_rest() {
603 let dir = tempfile::tempdir().unwrap();
604 let src = dir.path().join("bundle");
605 let out = dir.path().join("dist");
606 tokio::fs::create_dir_all(&src).await.unwrap();
607 tokio::fs::create_dir_all(&out).await.unwrap();
608 tokio::fs::write(src.join("a.msi"), b"A").await.unwrap();
609 tokio::fs::write(src.join("b.msi"), b"B").await.unwrap();
610 tokio::fs::write(src.join("notes.txt"), b"N").await.unwrap();
611
612 let exec = LocalExec::new(artifact_caps()).with_pull_root(dir.path());
613 let pattern = format!("{}/*.msi", src.display());
614 exec.pull_glob(&pattern, &out, &SyncOpts::default())
615 .await
616 .unwrap();
617 assert_eq!(tokio::fs::read(out.join("a.msi")).await.unwrap(), b"A");
618 assert_eq!(tokio::fs::read(out.join("b.msi")).await.unwrap(), b"B");
619 assert!(
620 !out.join("notes.txt").exists(),
621 "glob must not gather non-matches"
622 );
623 }
624
625 /// A collect that silently gathers nothing is how an empty release ships.
626 #[tokio::test]
627 async fn local_pull_glob_errors_when_nothing_matches() {
628 let dir = tempfile::tempdir().unwrap();
629 let out = dir.path().join("dist");
630 tokio::fs::create_dir_all(&out).await.unwrap();
631 let exec = LocalExec::new(artifact_caps()).with_pull_root(dir.path());
632 let pattern = format!("{}/nope/*.msi", dir.path().display());
633 let err = exec
634 .pull_glob(&pattern, &out, &SyncOpts::default())
635 .await
636 .unwrap_err();
637 assert!(err.to_string().contains("matched no files"), "{err}");
638 }
639
640 /// An exact path is a glob with no wildcard — recipes resolve globs
641 /// host-side (`ls -t | head -1`) and pass concrete paths, so this is the
642 /// common case, not an edge case.
643 #[tokio::test]
644 async fn local_pull_glob_accepts_a_concrete_path() {
645 let dir = tempfile::tempdir().unwrap();
646 let out = dir.path().join("dist");
647 tokio::fs::create_dir_all(&out).await.unwrap();
648 let f = dir.path().join("GoingsOn.dmg");
649 tokio::fs::write(&f, b"DMG").await.unwrap();
650 let exec = LocalExec::new(artifact_caps()).with_pull_root(dir.path());
651 exec.pull_glob(&f.to_string_lossy(), &out, &SyncOpts::default())
652 .await
653 .unwrap();
654 assert_eq!(
655 tokio::fs::read(out.join("GoingsOn.dmg")).await.unwrap(),
656 b"DMG"
657 );
658 }
659
660 /// The exfiltration the gate closes: a pull with no `observe:artifact`
661 /// grant is denied before any rsync spawns, even with a root set.
662 #[tokio::test]
663 async fn pull_denied_without_artifact_grant() {
664 let dir = tempfile::tempdir().unwrap();
665 let f = dir.path().join("secret");
666 tokio::fs::write(&f, b"S").await.unwrap();
667 // A build/sign grant is not an artifact-read grant.
668 let caps = CapabilitySet::from_tokens(["build", "sign"], ["build-log"]);
669 let exec = LocalExec::new(caps).with_pull_root(dir.path());
670 let err = exec
671 .pull_file(&f, &dir.path().join("out"), &SyncOpts::default())
672 .await
673 .unwrap_err();
674 let denied = err
675 .downcast_ref::<CapabilityDenied>()
676 .expect("CapabilityDenied so a caller can audit-log it");
677 assert_eq!(denied.action, "observe:artifact");
678 assert!(!dir.path().join("out").exists(), "denied pull must not run");
679 }
680
681 /// Fail-closed: an executor with the grant but NO declared root pulls
682 /// nothing. This is what stops an un-configured host from being an open
683 /// read primitive.
684 #[tokio::test]
685 async fn pull_denied_without_pull_root() {
686 let dir = tempfile::tempdir().unwrap();
687 let f = dir.path().join("a.dmg");
688 tokio::fs::write(&f, b"D").await.unwrap();
689 let exec = LocalExec::new(artifact_caps()); // no with_pull_root
690 let err = exec
691 .pull_file(&f, &dir.path().join("out"), &SyncOpts::default())
692 .await
693 .unwrap_err();
694 assert!(
695 err.to_string().contains("no artifact root declared"),
696 "{err}"
697 );
698 }
699
700 /// A host that builds out of two trees can collect from either. This is the
701 /// pom case: apps under `~/Code/Apps`, pom under `~/Code/MNW`, and the only
702 /// single root covering both also covers `~/Code/_private`.
703 #[tokio::test]
704 async fn pull_allowed_from_any_declared_root() {
705 let dir = tempfile::tempdir().unwrap();
706 let (apps, mnw) = (dir.path().join("Apps"), dir.path().join("MNW"));
707 tokio::fs::create_dir_all(&apps).await.unwrap();
708 tokio::fs::create_dir_all(&mnw).await.unwrap();
709 let artifact = mnw.join("pom");
710 tokio::fs::write(&artifact, b"ELF").await.unwrap();
711 let out = dir.path().join("out");
712 let exec = LocalExec::new(artifact_caps()).with_pull_roots([&apps, &mnw]);
713 exec.pull_file(&artifact, &out, &SyncOpts::default())
714 .await
715 .expect("the second root is as good as the first");
716 assert!(out.exists());
717 }
718
719 /// Adding a root widens the gate by exactly that root and no further. The
720 /// secret sits beside both, and is reachable from neither.
721 #[tokio::test]
722 async fn extra_roots_do_not_widen_to_their_parent() {
723 let dir = tempfile::tempdir().unwrap();
724 let (apps, mnw) = (dir.path().join("Apps"), dir.path().join("MNW"));
725 tokio::fs::create_dir_all(&apps).await.unwrap();
726 tokio::fs::create_dir_all(&mnw).await.unwrap();
727 let secret = dir.path().join("_private").join("api-token");
728 tokio::fs::create_dir_all(secret.parent().unwrap())
729 .await
730 .unwrap();
731 tokio::fs::write(&secret, b"tok").await.unwrap();
732 let exec = LocalExec::new(artifact_caps()).with_pull_roots([&apps, &mnw]);
733 let err = exec
734 .pull_file(&secret, &dir.path().join("out"), &SyncOpts::default())
735 .await
736 .unwrap_err();
737 assert!(
738 err.to_string()
739 .contains("escapes every declared artifact root"),
740 "{err}"
741 );
742 assert!(!dir.path().join("out").exists());
743 }
744
745 /// The error names every root, because "escapes the artifact root" with one
746 /// root unnamed is a message an operator cannot act on when there are two.
747 #[tokio::test]
748 async fn refusal_names_every_declared_root() {
749 let dir = tempfile::tempdir().unwrap();
750 let (apps, mnw) = (dir.path().join("Apps"), dir.path().join("MNW"));
751 tokio::fs::create_dir_all(&apps).await.unwrap();
752 tokio::fs::create_dir_all(&mnw).await.unwrap();
753 let outside = dir.path().join("elsewhere");
754 tokio::fs::write(&outside, b"x").await.unwrap();
755 let exec = LocalExec::new(artifact_caps()).with_pull_roots([&apps, &mnw]);
756 let err = exec
757 .pull_file(&outside, &dir.path().join("out"), &SyncOpts::default())
758 .await
759 .unwrap_err()
760 .to_string();
761 assert!(err.contains("Apps") && err.contains("MNW"), "{err}");
762 }
763
764 /// The `passwords.env` case: an authorized caller with a legitimate root
765 /// still cannot reach a sibling path outside it.
766 #[tokio::test]
767 async fn pull_denied_outside_declared_root() {
768 let dir = tempfile::tempdir().unwrap();
769 let root = dir.path().join("artifacts");
770 let secret = dir.path().join("passwords.env"); // sibling of root, not under it
771 tokio::fs::create_dir_all(&root).await.unwrap();
772 tokio::fs::write(&secret, b"NOTARY_PW=hunter2")
773 .await
774 .unwrap();
775 let exec = LocalExec::new(artifact_caps()).with_pull_root(&root);
776 let err = exec
777 .pull_file(&secret, &dir.path().join("out"), &SyncOpts::default())
778 .await
779 .unwrap_err();
780 assert!(
781 err.to_string()
782 .contains("escapes every declared artifact root")
783 );
784 assert!(!dir.path().join("out").exists());
785 }
786
787 /// A `..` component is refused even when the resolved target would land back
788 /// inside the root — the check is lexical, so it never has to resolve it.
789 #[tokio::test]
790 async fn pull_denied_on_parent_dir_component() {
791 let dir = tempfile::tempdir().unwrap();
792 let root = dir.path().join("artifacts");
793 tokio::fs::create_dir_all(&root).await.unwrap();
794 let exec = LocalExec::new(artifact_caps()).with_pull_root(&root);
795 let sneaky = root.join("..").join("passwords.env");
796 let err = exec
797 .pull_file(&sneaky, &dir.path().join("out"), &SyncOpts::default())
798 .await
799 .unwrap_err();
800 assert!(err.to_string().contains("contains `..`"), "{err}");
801 }
802
803 /// `starts_with` is component-wise, so a root prefix that is a string prefix
804 /// but NOT a path prefix (`/x/artifacts` vs `/x/artifacts-evil`) does not
805 /// leak. Pins that the confinement isn't a naive string compare.
806 #[tokio::test]
807 async fn pull_root_is_a_path_prefix_not_a_string_prefix() {
808 let dir = tempfile::tempdir().unwrap();
809 let root = dir.path().join("artifacts");
810 let evil = dir.path().join("artifacts-evil");
811 tokio::fs::create_dir_all(&root).await.unwrap();
812 tokio::fs::create_dir_all(&evil).await.unwrap();
813 let f = evil.join("x.dmg");
814 tokio::fs::write(&f, b"D").await.unwrap();
815 let exec = LocalExec::new(artifact_caps()).with_pull_root(&root);
816 let err = exec
817 .pull_file(&f, &dir.path().join("out"), &SyncOpts::default())
818 .await
819 .unwrap_err();
820 assert!(
821 err.to_string()
822 .contains("escapes every declared artifact root")
823 );
824 }
825
826 #[test]
827 fn ssh_pull_glob_leaves_the_wildcard_for_the_remote_shell() {
828 // rsync hands an un---protect-args remote path to the far-side login
829 // shell; quoting it here would defeat the expansion this depends on.
830 let host = RemoteHost::new("windows-x86");
831 let cmd = rsync_command(
832 &format!("{}:{}", host.ssh_target(), "/c/build/bundle/msi/*.msi"),
833 "/dist/",
834 Some(host.ssh_args()),
835 &SyncOpts::default(),
836 );
837 let args = render_args(&cmd);
838 assert!(
839 args.iter()
840 .any(|a| a == "windows-x86:/c/build/bundle/msi/*.msi"),
841 "wildcard reaches the remote intact: {args:?}"
842 );
843 }
844
845 #[test]
846 fn sync_opts_flags_are_opt_out() {
847 // Default = the historical `-az --partial`.
848 let cmd = rsync_command("s", "d", None, &SyncOpts::default());
849 let args = render_args(&cmd);
850 assert!(
851 args.contains(&"-z".to_string()),
852 "compress on by default: {args:?}"
853 );
854 assert!(
855 args.contains(&"--partial".to_string()),
856 "partial on by default: {args:?}"
857 );
858
859 // A precompressed payload drops -z but keeps --partial.
860 let args = render_args(&rsync_command("s", "d", None, &SyncOpts::precompressed()));
861 assert!(
862 !args.contains(&"-z".to_string()),
863 "precompressed drops -z: {args:?}"
864 );
865 assert!(args.contains(&"--partial".to_string()));
866
867 // Sando's backup fetch drops both: a resumed dump could splice two
868 // different backups (CF4).
869 let opts = SyncOpts {
870 compress: false,
871 partial: false,
872 ..SyncOpts::default()
873 };
874 let args = render_args(&rsync_command("s", "d", None, &opts));
875 assert!(!args.contains(&"-z".to_string()));
876 assert!(!args.contains(&"--partial".to_string()));
877
878 // release_mirror keeps prune + chmod, and still compresses.
879 let args = render_args(&rsync_command("s", "d", None, &SyncOpts::release_mirror()));
880 assert!(args.contains(&"--delete".to_string()));
881 assert!(args.iter().any(|a| a.starts_with("--chmod=")));
882 assert!(args.contains(&"-z".to_string()));
883
884 // An archive deposit creates its destination tree (the first build of a
885 // version is what makes that directory exist) and never prunes it: a
886 // second target of the same release deposits beside the first.
887 let args = render_args(&rsync_command("s", "d", None, &SyncOpts::archive_deposit()));
888 assert!(args.contains(&"--mkpath".to_string()), "{args:?}");
889 assert!(!args.contains(&"--delete".to_string()), "{args:?}");
890 assert!(!args.contains(&"-z".to_string()), "{args:?}");
891
892 // ...and nothing else creates directories implicitly: a typo'd
893 // destination for every other caller stays an error.
894 let args = render_args(&rsync_command("s", "d", None, &SyncOpts::default()));
895 assert!(!args.contains(&"--mkpath".to_string()), "{args:?}");
896 }
897
898 /// Every exclude reaches rsync as its own `--exclude=`, and none is emitted
899 /// when the caller asked for none. A pattern lost here would ship a file the
900 /// caller meant to leave behind, which for the handoff case is the document
901 /// that changes the digest of what it describes.
902 #[test]
903 fn excludes_are_passed_through_one_flag_each() {
904 let opts = SyncOpts {
905 exclude: vec!["record.json".into(), "*.log".into()],
906 ..SyncOpts::default()
907 };
908 let args = render_args(&rsync_command("s", "d", None, &opts));
909 assert!(
910 args.contains(&"--exclude=record.json".to_string()),
911 "{args:?}"
912 );
913 assert!(args.contains(&"--exclude=*.log".to_string()), "{args:?}");
914
915 let args = render_args(&rsync_command("s", "d", None, &SyncOpts::default()));
916 assert!(!args.iter().any(|a| a.starts_with("--exclude")), "{args:?}");
917 }
918
919 /// End to end: an excluded file stays out of the destination while its
920 /// siblings arrive. The unit test above proves the flag is built; this
921 /// proves rsync honors it for the shape the handoff actually uses.
922 #[tokio::test]
923 async fn an_excluded_file_does_not_reach_the_destination() {
924 let dir = tempfile::tempdir().unwrap();
925 let src = dir.path().join("src");
926 std::fs::create_dir_all(&src).unwrap();
927 std::fs::write(src.join("demo.bin"), b"bytes").unwrap();
928 std::fs::write(src.join("record.json"), b"{}").unwrap();
929
930 let dst = dir.path().join("staged");
931 let exec = LocalExec::new(CapabilitySet::from_tokens::<[&str; 0], [&str; 0]>([], []));
932 exec.push_dir(
933 &src,
934 &dst,
935 &SyncOpts {
936 exclude: vec!["record.json".into()],
937 ..SyncOpts::archive_deposit()
938 },
939 )
940 .await
941 .unwrap();
942 assert!(dst.join("demo.bin").exists());
943 assert!(
944 !dst.join("record.json").exists(),
945 "the excluded document must not land in the bundle"
946 );
947 }
948
949 /// `--mkpath` end to end: a push into a destination whose parents do not
950 /// exist creates them, which is the whole reason Bento's archive can name a
951 /// per-`(app, version, target)` path before that version has ever built.
952 #[tokio::test]
953 async fn a_deposit_creates_its_missing_destination_tree() {
954 let dir = tempfile::tempdir().unwrap();
955 let src = dir.path().join("src");
956 std::fs::create_dir_all(&src).unwrap();
957 std::fs::write(src.join("demo.bin"), b"bytes").unwrap();
958
959 let dst = dir.path().join("archive/demo/0.0.1/linux-x86_64");
960 let exec = LocalExec::new(CapabilitySet::from_tokens::<[&str; 0], [&str; 0]>([], []));
961 exec.push_dir(&src, &dst, &SyncOpts::archive_deposit())
962 .await
963 .unwrap();
964 assert_eq!(std::fs::read(dst.join("demo.bin")).unwrap(), b"bytes");
965 }
966
967 #[test]
968 fn rsync_over_ssh_carries_the_port_into_dash_e() {
969 let host = RemoteHost::new("backup@db.example").with_port(Some(2222));
970 let cmd = rsync_command("src", "dst", Some(host.ssh_args()), &SyncOpts::default());
971 let args = render_args(&cmd);
972 let e = args.iter().position(|a| a == "-e").expect("-e present");
973 let ssh_spec = &args[e + 1];
974 assert!(
975 ssh_spec.contains("-p 2222"),
976 "port reaches rsync's ssh: {ssh_spec}"
977 );
978 assert!(
979 ssh_spec.contains("BatchMode=yes"),
980 "shared flags kept: {ssh_spec}"
981 );
982
983 // No port set ⇒ no -p at all (ssh/ssh_config decides).
984 let plain = RemoteHost::new("mbp");
985 let args = render_args(&rsync_command(
986 "s",
987 "d",
988 Some(plain.ssh_args()),
989 &SyncOpts::default(),
990 ));
991 let e = args.iter().position(|a| a == "-e").unwrap();
992 assert!(
993 !args[e + 1].contains("-p"),
994 "no port ⇒ no -p: {}",
995 args[e + 1]
996 );
997 }
998
999 fn render_args(cmd: &Command) -> Vec<String> {
1000 cmd.as_std()
1001 .get_args()
1002 .map(|a| a.to_string_lossy().to_string())
1003 .collect()
1004 }
1005 }
1006