Skip to main content

max / makenotwork

31.4 KB · 822 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 let Some(args) = ssh_args {
164 rsync.arg("-e").arg(format!("ssh {}", args.join(" ")));
165 }
166 rsync.args(srcs).arg(dst);
167 rsync
168 }
169
170 /// Expand a glob against the local filesystem, for the transports that have no
171 /// remote shell to do it. Returns the matches sorted (deterministic argv), and
172 /// errors when nothing matches — a collect that quietly gathers zero files is
173 /// how an empty release ships.
174 fn expand_glob_locally(pattern: &str) -> Result<Vec<String>> {
175 let paths = glob::glob(pattern).with_context(|| format!("bad glob pattern `{pattern}`"))?;
176 let mut out: Vec<String> = Vec::new();
177 for entry in paths {
178 let path = entry.with_context(|| format!("reading glob match for `{pattern}`"))?;
179 out.push(path.to_string_lossy().into_owned());
180 }
181 anyhow::ensure!(!out.is_empty(), "glob `{pattern}` matched no files");
182 out.sort();
183 Ok(out)
184 }
185
186 async fn run_rsync(mut cmd: Command, what: &str) -> Result<()> {
187 let out = cmd
188 .output()
189 .await
190 .with_context(|| format!("spawning rsync ({what})"))?;
191 anyhow::ensure!(
192 out.status.success(),
193 "rsync {what} failed: {}",
194 String::from_utf8_lossy(&out.stderr),
195 );
196 Ok(())
197 }
198
199 /// The local-machine transport.
200 pub struct LocalExec {
201 host: RemoteHost,
202 caps: CapabilitySet,
203 pull_root: Option<PathBuf>,
204 }
205
206 impl LocalExec {
207 pub fn new(caps: CapabilitySet) -> Self {
208 Self {
209 host: RemoteHost::new("local"),
210 caps,
211 pull_root: None,
212 }
213 }
214
215 /// Confine this transport's artifact pulls to `root` (see [`gate_pull`]).
216 /// Required before any `pull_*` succeeds — pulls are fail-closed, so an
217 /// executor built without a root refuses every pull.
218 #[must_use]
219 pub fn with_pull_root(mut self, root: impl Into<PathBuf>) -> Self {
220 self.pull_root = Some(root.into());
221 self
222 }
223 }
224
225 #[async_trait]
226 impl Executor for LocalExec {
227 async fn run_streaming(&self, step: &Step, sink: &mut dyn LogSink) -> Result<RunOutput> {
228 gate(&self.caps, "local", step)?;
229 validate_env_names(step)?;
230 let (cmd, sentinel) = self.host.command_for(&render_shell_line(step));
231 run_command_into_sink(cmd, sink, sentinel, "local").await
232 }
233
234 async fn pull_file(&self, remote: &Path, local: &Path, opts: &SyncOpts) -> Result<()> {
235 gate_pull(&self.caps, "local", self.pull_root.as_deref(), remote)?;
236 // No trailing slash: rsync copies the file itself.
237 let src = remote.to_string_lossy().to_string();
238 run_rsync(
239 rsync_command(&src, &local.to_string_lossy(), None, opts),
240 "pull_file(local)",
241 )
242 .await
243 }
244
245 async fn pull_dir(&self, remote: &Path, local: &Path, opts: &SyncOpts) -> Result<()> {
246 gate_pull(&self.caps, "local", self.pull_root.as_deref(), remote)?;
247 // Local "pull" is just a local rsync; trailing slash = contents.
248 let src = format!("{}/", remote.display());
249 run_rsync(
250 rsync_command(&src, &local.to_string_lossy(), None, opts),
251 "pull_dir(local)",
252 )
253 .await
254 }
255
256 async fn pull_glob(&self, remote_glob: &str, local_dir: &Path, opts: &SyncOpts) -> Result<()> {
257 gate_pull(
258 &self.caps,
259 "local",
260 self.pull_root.as_deref(),
261 Path::new(remote_glob),
262 )?;
263 // No remote shell to expand for us, and `rsync` is spawned directly (no
264 // shell), so expand in-process. This is the half of `pull_glob` that
265 // differs from the ssh transport, and the reason the trait's docs warn
266 // against assuming a shell.
267 let matches = expand_glob_locally(remote_glob)?;
268 let dst = format!("{}/", local_dir.display());
269 run_rsync(
270 rsync_command_multi(&matches, &dst, None, opts),
271 "pull_glob(local)",
272 )
273 .await
274 }
275
276 async fn push_dir(&self, local: &Path, remote: &Path, opts: &SyncOpts) -> Result<()> {
277 let src = format!("{}/", local.display());
278 run_rsync(
279 rsync_command(&src, &remote.to_string_lossy(), None, opts),
280 "push_dir(local)",
281 )
282 .await
283 }
284
285 fn capabilities(&self) -> &CapabilitySet {
286 &self.caps
287 }
288 }
289
290 /// The SSH transport: a tailnet host reached with the existing SSH keys.
291 pub struct SshExec {
292 host: RemoteHost,
293 caps: CapabilitySet,
294 pull_root: Option<PathBuf>,
295 }
296
297 impl SshExec {
298 pub fn new(ssh_target: impl Into<String>, caps: CapabilitySet) -> Self {
299 Self {
300 host: RemoteHost::new(ssh_target),
301 caps,
302 pull_root: None,
303 }
304 }
305
306 /// Reach this host on a non-default SSH port. `None` keeps ssh's default,
307 /// so a caller holding an `Option<u16>` can pass it straight through. The
308 /// port applies to both the exec and sync paths (see [`RemoteHost`]).
309 #[must_use]
310 pub fn with_port(mut self, port: Option<u16>) -> Self {
311 self.host = self.host.with_port(port);
312 self
313 }
314
315 /// Confine this transport's artifact pulls to `root` — an absolute path as
316 /// seen ON THIS HOST (a remote root, so it is never tilde-expanded by the
317 /// caller). See [`gate_pull`]; required before any `pull_*` succeeds.
318 #[must_use]
319 pub fn with_pull_root(mut self, root: impl Into<PathBuf>) -> Self {
320 self.pull_root = Some(root.into());
321 self
322 }
323
324 pub fn ssh_target(&self) -> &str {
325 self.host.ssh_target()
326 }
327 }
328
329 #[async_trait]
330 impl Executor for SshExec {
331 async fn run_streaming(&self, step: &Step, sink: &mut dyn LogSink) -> Result<RunOutput> {
332 gate(&self.caps, self.host.ssh_target(), step)?;
333 validate_env_names(step)?;
334 let (cmd, sentinel) = self.host.command_for(&render_shell_line(step));
335 run_command_into_sink(cmd, sink, sentinel, self.host.ssh_target()).await
336 }
337
338 async fn pull_file(&self, remote: &Path, local: &Path, opts: &SyncOpts) -> Result<()> {
339 gate_pull(
340 &self.caps,
341 self.host.ssh_target(),
342 self.pull_root.as_deref(),
343 remote,
344 )?;
345 // No trailing slash: rsync copies the file itself, not "contents of".
346 let src = format!("{}:{}", self.host.ssh_target(), remote.display());
347 let ssh = Some(self.host.ssh_args());
348 run_rsync(
349 rsync_command(&src, &local.to_string_lossy(), ssh, opts),
350 "pull_file(ssh)",
351 )
352 .await
353 }
354
355 async fn pull_dir(&self, remote: &Path, local: &Path, opts: &SyncOpts) -> Result<()> {
356 gate_pull(
357 &self.caps,
358 self.host.ssh_target(),
359 self.pull_root.as_deref(),
360 remote,
361 )?;
362 let src = format!("{}:{}/", self.host.ssh_target(), remote.display());
363 let ssh = Some(self.host.ssh_args());
364 run_rsync(
365 rsync_command(&src, &local.to_string_lossy(), ssh, opts),
366 "pull_dir(ssh)",
367 )
368 .await
369 }
370
371 async fn pull_glob(&self, remote_glob: &str, local_dir: &Path, opts: &SyncOpts) -> Result<()> {
372 gate_pull(
373 &self.caps,
374 self.host.ssh_target(),
375 self.pull_root.as_deref(),
376 Path::new(remote_glob),
377 )?;
378 // The REMOTE shell expands this one: rsync hands an un-`--protect-args`
379 // remote path to the login shell on the far side, so the wildcard must
380 // reach it intact — hence no quoting here. rsync exits non-zero when the
381 // pattern matches nothing, which is the error we want.
382 let src = format!("{}:{}", self.host.ssh_target(), remote_glob);
383 let dst = format!("{}/", local_dir.display());
384 let ssh = Some(self.host.ssh_args());
385 run_rsync(rsync_command(&src, &dst, ssh, opts), "pull_glob(ssh)").await
386 }
387
388 async fn push_dir(&self, local: &Path, remote: &Path, opts: &SyncOpts) -> Result<()> {
389 let src = format!("{}/", local.display());
390 let dst = format!("{}:{}/", self.host.ssh_target(), remote.display());
391 let ssh = Some(self.host.ssh_args());
392 run_rsync(rsync_command(&src, &dst, ssh, opts), "push_dir(ssh)").await
393 }
394
395 fn capabilities(&self) -> &CapabilitySet {
396 &self.caps
397 }
398 }
399
400 #[cfg(test)]
401 mod tests {
402 use super::*;
403 use crate::step::Action;
404 use std::sync::Arc;
405
406 #[derive(Default)]
407 struct VecSink(Vec<u8>);
408 #[async_trait]
409 impl LogSink for VecSink {
410 async fn write_chunk(&mut self, bytes: &[u8]) {
411 self.0.extend_from_slice(bytes);
412 }
413 }
414
415 fn vec_sink() -> VecSink {
416 VecSink::default()
417 }
418
419 /// The grant a sync transport needs to pull artifacts: `observe:artifact`
420 /// and nothing else. Pair with `.with_pull_root(...)` in the pull tests.
421 fn artifact_caps() -> CapabilitySet {
422 CapabilitySet::from_tokens(Vec::<&str>::new(), ["artifact"])
423 }
424
425 #[test]
426 fn render_plain_argv_quotes_each_token() {
427 let step = Step::new(Action::Build, ["echo", "a b", "c"]);
428 assert_eq!(render_shell_line(&step), "'echo' 'a b' 'c'");
429 }
430
431 #[test]
432 fn render_shell_script_is_verbatim_with_env_and_cwd() {
433 let step = Step::shell(Action::Deploy, "set -e; echo hi")
434 .with_env("K", "v v")
435 .with_cwd("/tmp/x");
436 assert_eq!(
437 render_shell_line(&step),
438 "cd '/tmp/x' && K='v v' set -e; echo hi"
439 );
440 }
441
442 #[tokio::test]
443 async fn rejects_malicious_env_name_before_dispatch() {
444 let dir = tempfile::tempdir().unwrap();
445 let marker = dir.path().join("pwned");
446 let exec = LocalExec::new(CapabilitySet::actuate_only([Action::Build]));
447 let mut sink = vec_sink();
448 // A shell-metacharacter env name would otherwise inject a command.
449 let step = Step::new(Action::Build, ["true"])
450 .with_env(format!("X; touch {}", marker.display()), "v");
451 let err = exec.run_streaming(&step, &mut sink).await.unwrap_err();
452 assert!(err.to_string().contains("shell identifier"));
453 assert!(!marker.exists(), "rejected env name must not execute");
454 }
455
456 #[tokio::test]
457 async fn local_exec_runs_granted_step() {
458 let exec = LocalExec::new(CapabilitySet::actuate_only([Action::Deploy]));
459 let mut sink = vec_sink();
460 let step = Step::shell(Action::Deploy, "printf ok");
461 let out = exec.run_streaming(&step, &mut sink).await.unwrap();
462 assert!(out.success());
463 assert_eq!(sink.0, b"ok");
464 }
465
466 #[tokio::test]
467 async fn local_exec_denies_ungranted_step_before_dispatch() {
468 // Grant deploy only; ask it to sign. Must deny, and must NOT run the
469 // command (the file the command would create must not appear).
470 let dir = tempfile::tempdir().unwrap();
471 let marker = dir.path().join("ran");
472 let exec = LocalExec::new(CapabilitySet::actuate_only([Action::Deploy]));
473 let mut sink = vec_sink();
474 let step = Step::shell(Action::Sign, format!("touch {}", marker.display()));
475 let err = exec.run_streaming(&step, &mut sink).await.unwrap_err();
476 let denied = err
477 .downcast_ref::<CapabilityDenied>()
478 .expect("CapabilityDenied");
479 assert_eq!(denied.action, "sign");
480 assert!(!marker.exists(), "denied step must not execute");
481 }
482
483 #[tokio::test]
484 async fn dyn_executor_object_is_usable() {
485 // Prove the trait is object-safe and Arc<dyn Executor> works.
486 let exec: Arc<dyn Executor> = Arc::new(LocalExec::new(CapabilitySet::actuate_only([
487 Action::Restart,
488 ])));
489 assert!(exec.capabilities().permits(&Action::Restart));
490 let mut sink = vec_sink();
491 let out = exec
492 .run_streaming(&Step::shell(Action::Restart, "true"), &mut sink)
493 .await
494 .unwrap();
495 assert!(out.success());
496 }
497
498 #[tokio::test]
499 async fn local_push_and_pull_move_a_dir() {
500 let dir = tempfile::tempdir().unwrap();
501 let src = dir.path().join("src");
502 let mid = dir.path().join("mid");
503 let dst = dir.path().join("dst");
504 tokio::fs::create_dir_all(&src).await.unwrap();
505 tokio::fs::write(src.join("f.txt"), b"hi").await.unwrap();
506 let exec = LocalExec::new(artifact_caps()).with_pull_root(dir.path());
507 exec.push_dir(&src, &mid, &SyncOpts::default())
508 .await
509 .unwrap();
510 assert_eq!(tokio::fs::read(mid.join("f.txt")).await.unwrap(), b"hi");
511 exec.pull_dir(&mid, &dst, &SyncOpts::default())
512 .await
513 .unwrap();
514 assert_eq!(tokio::fs::read(dst.join("f.txt")).await.unwrap(), b"hi");
515 }
516
517 /// The distinction the split exists for: a single file lands AS a file.
518 /// Under the old dir-shaped `pull` this path was `{file}/` — rsync would
519 /// refuse it.
520 #[tokio::test]
521 async fn local_pull_file_copies_one_file() {
522 let dir = tempfile::tempdir().unwrap();
523 let src = dir.path().join("dump.sql.gz");
524 let dst = dir.path().join("fetched.sql.gz");
525 tokio::fs::write(&src, b"DUMPBYTES").await.unwrap();
526 let exec = LocalExec::new(artifact_caps()).with_pull_root(dir.path());
527 exec.pull_file(&src, &dst, &SyncOpts::default())
528 .await
529 .unwrap();
530 assert_eq!(tokio::fs::read(&dst).await.unwrap(), b"DUMPBYTES");
531 }
532
533 /// Pins what the file/dir split actually buys, which is NOT a type-level
534 /// refusal: `-a` implies `-r`, so `pull_file` pointed at a directory does
535 /// rsync's no-trailing-slash thing — copies the dir INTO the destination
536 /// (`out/adir/f.txt`) rather than erroring. `pull_dir` would have put the
537 /// contents at `out/f.txt`. Documented here because the difference is
538 /// silent, and the docs on `pull_file` promise only file→file.
539 #[tokio::test]
540 async fn local_pull_file_on_a_dir_nests_rather_than_flattening() {
541 let dir = tempfile::tempdir().unwrap();
542 let src = dir.path().join("adir");
543 let out = dir.path().join("out");
544 tokio::fs::create_dir_all(&src).await.unwrap();
545 tokio::fs::create_dir_all(&out).await.unwrap();
546 tokio::fs::write(src.join("f.txt"), b"hi").await.unwrap();
547 let exec = LocalExec::new(artifact_caps()).with_pull_root(dir.path());
548 exec.pull_file(&src, &out, &SyncOpts::default())
549 .await
550 .unwrap();
551 assert_eq!(
552 tokio::fs::read(out.join("adir").join("f.txt"))
553 .await
554 .unwrap(),
555 b"hi"
556 );
557 assert!(
558 !out.join("f.txt").exists(),
559 "pull_file does not flatten; that's pull_dir"
560 );
561 }
562
563 #[tokio::test]
564 async fn local_pull_glob_gathers_matches_and_ignores_the_rest() {
565 let dir = tempfile::tempdir().unwrap();
566 let src = dir.path().join("bundle");
567 let out = dir.path().join("dist");
568 tokio::fs::create_dir_all(&src).await.unwrap();
569 tokio::fs::create_dir_all(&out).await.unwrap();
570 tokio::fs::write(src.join("a.msi"), b"A").await.unwrap();
571 tokio::fs::write(src.join("b.msi"), b"B").await.unwrap();
572 tokio::fs::write(src.join("notes.txt"), b"N").await.unwrap();
573
574 let exec = LocalExec::new(artifact_caps()).with_pull_root(dir.path());
575 let pattern = format!("{}/*.msi", src.display());
576 exec.pull_glob(&pattern, &out, &SyncOpts::default())
577 .await
578 .unwrap();
579 assert_eq!(tokio::fs::read(out.join("a.msi")).await.unwrap(), b"A");
580 assert_eq!(tokio::fs::read(out.join("b.msi")).await.unwrap(), b"B");
581 assert!(
582 !out.join("notes.txt").exists(),
583 "glob must not gather non-matches"
584 );
585 }
586
587 /// A collect that silently gathers nothing is how an empty release ships.
588 #[tokio::test]
589 async fn local_pull_glob_errors_when_nothing_matches() {
590 let dir = tempfile::tempdir().unwrap();
591 let out = dir.path().join("dist");
592 tokio::fs::create_dir_all(&out).await.unwrap();
593 let exec = LocalExec::new(artifact_caps()).with_pull_root(dir.path());
594 let pattern = format!("{}/nope/*.msi", dir.path().display());
595 let err = exec
596 .pull_glob(&pattern, &out, &SyncOpts::default())
597 .await
598 .unwrap_err();
599 assert!(err.to_string().contains("matched no files"), "{err}");
600 }
601
602 /// An exact path is a glob with no wildcard — recipes resolve globs
603 /// host-side (`ls -t | head -1`) and pass concrete paths, so this is the
604 /// common case, not an edge case.
605 #[tokio::test]
606 async fn local_pull_glob_accepts_a_concrete_path() {
607 let dir = tempfile::tempdir().unwrap();
608 let out = dir.path().join("dist");
609 tokio::fs::create_dir_all(&out).await.unwrap();
610 let f = dir.path().join("GoingsOn.dmg");
611 tokio::fs::write(&f, b"DMG").await.unwrap();
612 let exec = LocalExec::new(artifact_caps()).with_pull_root(dir.path());
613 exec.pull_glob(&f.to_string_lossy(), &out, &SyncOpts::default())
614 .await
615 .unwrap();
616 assert_eq!(
617 tokio::fs::read(out.join("GoingsOn.dmg")).await.unwrap(),
618 b"DMG"
619 );
620 }
621
622 /// The exfiltration the gate closes: a pull with no `observe:artifact`
623 /// grant is denied before any rsync spawns, even with a root set.
624 #[tokio::test]
625 async fn pull_denied_without_artifact_grant() {
626 let dir = tempfile::tempdir().unwrap();
627 let f = dir.path().join("secret");
628 tokio::fs::write(&f, b"S").await.unwrap();
629 // A build/sign grant is not an artifact-read grant.
630 let caps = CapabilitySet::from_tokens(["build", "sign"], ["build-log"]);
631 let exec = LocalExec::new(caps).with_pull_root(dir.path());
632 let err = exec
633 .pull_file(&f, &dir.path().join("out"), &SyncOpts::default())
634 .await
635 .unwrap_err();
636 let denied = err
637 .downcast_ref::<CapabilityDenied>()
638 .expect("CapabilityDenied so a caller can audit-log it");
639 assert_eq!(denied.action, "observe:artifact");
640 assert!(!dir.path().join("out").exists(), "denied pull must not run");
641 }
642
643 /// Fail-closed: an executor with the grant but NO declared root pulls
644 /// nothing. This is what stops an un-configured host from being an open
645 /// read primitive.
646 #[tokio::test]
647 async fn pull_denied_without_pull_root() {
648 let dir = tempfile::tempdir().unwrap();
649 let f = dir.path().join("a.dmg");
650 tokio::fs::write(&f, b"D").await.unwrap();
651 let exec = LocalExec::new(artifact_caps()); // no with_pull_root
652 let err = exec
653 .pull_file(&f, &dir.path().join("out"), &SyncOpts::default())
654 .await
655 .unwrap_err();
656 assert!(
657 err.to_string().contains("no artifact root declared"),
658 "{err}"
659 );
660 }
661
662 /// The `passwords.env` case: an authorized caller with a legitimate root
663 /// still cannot reach a sibling path outside it.
664 #[tokio::test]
665 async fn pull_denied_outside_declared_root() {
666 let dir = tempfile::tempdir().unwrap();
667 let root = dir.path().join("artifacts");
668 let secret = dir.path().join("passwords.env"); // sibling of root, not under it
669 tokio::fs::create_dir_all(&root).await.unwrap();
670 tokio::fs::write(&secret, b"NOTARY_PW=hunter2")
671 .await
672 .unwrap();
673 let exec = LocalExec::new(artifact_caps()).with_pull_root(&root);
674 let err = exec
675 .pull_file(&secret, &dir.path().join("out"), &SyncOpts::default())
676 .await
677 .unwrap_err();
678 assert!(
679 err.to_string()
680 .contains("escapes the declared artifact root")
681 );
682 assert!(!dir.path().join("out").exists());
683 }
684
685 /// A `..` component is refused even when the resolved target would land back
686 /// inside the root — the check is lexical, so it never has to resolve it.
687 #[tokio::test]
688 async fn pull_denied_on_parent_dir_component() {
689 let dir = tempfile::tempdir().unwrap();
690 let root = dir.path().join("artifacts");
691 tokio::fs::create_dir_all(&root).await.unwrap();
692 let exec = LocalExec::new(artifact_caps()).with_pull_root(&root);
693 let sneaky = root.join("..").join("passwords.env");
694 let err = exec
695 .pull_file(&sneaky, &dir.path().join("out"), &SyncOpts::default())
696 .await
697 .unwrap_err();
698 assert!(err.to_string().contains("contains `..`"), "{err}");
699 }
700
701 /// `starts_with` is component-wise, so a root prefix that is a string prefix
702 /// but NOT a path prefix (`/x/artifacts` vs `/x/artifacts-evil`) does not
703 /// leak. Pins that the confinement isn't a naive string compare.
704 #[tokio::test]
705 async fn pull_root_is_a_path_prefix_not_a_string_prefix() {
706 let dir = tempfile::tempdir().unwrap();
707 let root = dir.path().join("artifacts");
708 let evil = dir.path().join("artifacts-evil");
709 tokio::fs::create_dir_all(&root).await.unwrap();
710 tokio::fs::create_dir_all(&evil).await.unwrap();
711 let f = evil.join("x.dmg");
712 tokio::fs::write(&f, b"D").await.unwrap();
713 let exec = LocalExec::new(artifact_caps()).with_pull_root(&root);
714 let err = exec
715 .pull_file(&f, &dir.path().join("out"), &SyncOpts::default())
716 .await
717 .unwrap_err();
718 assert!(
719 err.to_string()
720 .contains("escapes the declared artifact root")
721 );
722 }
723
724 #[test]
725 fn ssh_pull_glob_leaves_the_wildcard_for_the_remote_shell() {
726 // rsync hands an un---protect-args remote path to the far-side login
727 // shell; quoting it here would defeat the expansion this depends on.
728 let host = RemoteHost::new("windows-x86");
729 let cmd = rsync_command(
730 &format!("{}:{}", host.ssh_target(), "/c/build/bundle/msi/*.msi"),
731 "/dist/",
732 Some(host.ssh_args()),
733 &SyncOpts::default(),
734 );
735 let args = render_args(&cmd);
736 assert!(
737 args.iter()
738 .any(|a| a == "windows-x86:/c/build/bundle/msi/*.msi"),
739 "wildcard reaches the remote intact: {args:?}"
740 );
741 }
742
743 #[test]
744 fn sync_opts_flags_are_opt_out() {
745 // Default = the historical `-az --partial`.
746 let cmd = rsync_command("s", "d", None, &SyncOpts::default());
747 let args = render_args(&cmd);
748 assert!(
749 args.contains(&"-z".to_string()),
750 "compress on by default: {args:?}"
751 );
752 assert!(
753 args.contains(&"--partial".to_string()),
754 "partial on by default: {args:?}"
755 );
756
757 // A precompressed payload drops -z but keeps --partial.
758 let args = render_args(&rsync_command("s", "d", None, &SyncOpts::precompressed()));
759 assert!(
760 !args.contains(&"-z".to_string()),
761 "precompressed drops -z: {args:?}"
762 );
763 assert!(args.contains(&"--partial".to_string()));
764
765 // Sando's backup fetch drops both: a resumed dump could splice two
766 // different backups (CF4).
767 let opts = SyncOpts {
768 compress: false,
769 partial: false,
770 ..SyncOpts::default()
771 };
772 let args = render_args(&rsync_command("s", "d", None, &opts));
773 assert!(!args.contains(&"-z".to_string()));
774 assert!(!args.contains(&"--partial".to_string()));
775
776 // release_mirror keeps prune + chmod, and still compresses.
777 let args = render_args(&rsync_command("s", "d", None, &SyncOpts::release_mirror()));
778 assert!(args.contains(&"--delete".to_string()));
779 assert!(args.iter().any(|a| a.starts_with("--chmod=")));
780 assert!(args.contains(&"-z".to_string()));
781 }
782
783 #[test]
784 fn rsync_over_ssh_carries_the_port_into_dash_e() {
785 let host = RemoteHost::new("backup@db.example").with_port(Some(2222));
786 let cmd = rsync_command("src", "dst", Some(host.ssh_args()), &SyncOpts::default());
787 let args = render_args(&cmd);
788 let e = args.iter().position(|a| a == "-e").expect("-e present");
789 let ssh_spec = &args[e + 1];
790 assert!(
791 ssh_spec.contains("-p 2222"),
792 "port reaches rsync's ssh: {ssh_spec}"
793 );
794 assert!(
795 ssh_spec.contains("BatchMode=yes"),
796 "shared flags kept: {ssh_spec}"
797 );
798
799 // No port set ⇒ no -p at all (ssh/ssh_config decides).
800 let plain = RemoteHost::new("mbp");
801 let args = render_args(&rsync_command(
802 "s",
803 "d",
804 Some(plain.ssh_args()),
805 &SyncOpts::default(),
806 ));
807 let e = args.iter().position(|a| a == "-e").unwrap();
808 assert!(
809 !args[e + 1].contains("-p"),
810 "no port ⇒ no -p: {}",
811 args[e + 1]
812 );
813 }
814
815 fn render_args(cmd: &Command) -> Vec<String> {
816 cmd.as_std()
817 .get_args()
818 .map(|a| a.to_string_lossy().to_string())
819 .collect()
820 }
821 }
822