Skip to main content

max / makenotwork

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