Skip to main content

max / makenotwork

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