Skip to main content

max / makenotwork

143.0 KB · 3437 lines History Blame Raw
1 //! Rhai recipe engine + host-function API.
2 //!
3 //! A `(app, target)` resolves to a `.rhai` recipe composed from a shared step
4 //! vocabulary. The daemon embeds Rhai and registers the host functions recipes
5 //! call; the recipe is the orchestration, the host functions are the
6 //! privileged primitives (run a command, read a secret, collect artifacts,
7 //! publish). Recipes are otherwise sandboxed — no arbitrary FS/network except
8 //! through these functions — matching the Balanced Breakfast plugin model.
9 //!
10 //! Rhai is synchronous; the engine runs each recipe on a blocking thread
11 //! (`spawn_blocking`, see [`crate::runner`]) and host functions bridge to async
12 //! work via `Handle::block_on`. That is sound only off a runtime worker thread,
13 //! which `spawn_blocking` guarantees.
14
15 use crate::config::Config;
16 use crate::domain::{AppId, Status, Step, StepRunId, Target, Version};
17 use crate::events::{self, Event, EventTx};
18 use crate::ota::{OtaRegistry, PublishAuthority, Release};
19 use crate::state::ExecutorMap;
20 use crate::topology::{DeployTarget, Kind};
21 use anyhow::{Context as _, Result};
22 use ops_core::live_log::LiveLog;
23 use ops_exec::{Action, Executor, ObserveKind, Step as OpStep, SyncOpts};
24 use rhai::{Engine, EvalAltResult, Map};
25 use sha2::{Digest, Sha256};
26 use sqlx::SqlitePool;
27 use std::collections::HashMap;
28 use std::path::{Path, PathBuf};
29 use std::sync::atomic::{AtomicBool, Ordering};
30 use std::sync::{Arc, Mutex};
31 use tokio::runtime::Handle;
32 use tokio::sync::Mutex as AsyncMutex;
33
34 /// The capability label for a command, derived from the open recipe step. A
35 /// recipe's `sh("mbp", …)` under `step("sign")` becomes an `Action::Sign`, gated
36 /// by the mac host's `sign` grant — so recipes stay unchanged while every command
37 /// is capability-checked at its transport. `Verify` is read-only (an observe).
38 /// The capability a step's commands are gated on.
39 ///
40 /// `Verify` depends on what is being released, which is the one place this is
41 /// not a property of the step alone. An app's verify is a Gatekeeper check on a
42 /// signed bundle, and the `gatekeeper` observe is granted implicitly to hosts
43 /// that can `sign` (`CapabilitySet::from_tokens`) precisely so that pairing
44 /// holds. A library's verify is a crate preflight: it runs `cargo` on the build
45 /// host and asks the registry a question. Gating that on Gatekeeper asks a Linux
46 /// host for a macOS code-signing capability it can never honestly hold, and the
47 /// only way to satisfy it would be to declare the capability falsely.
48 fn action_for(step: Step, kind: Kind) -> Action {
49 match step {
50 Step::Checkout | Step::Prebuild | Step::Build => Action::Build,
51 Step::Sign => Action::Sign,
52 Step::Notarize => Action::Notarize,
53 Step::Staple => Action::Staple,
54 Step::Package => Action::Package,
55 Step::Verify => match kind {
56 Kind::App => Action::Observe(ObserveKind::Custom("gatekeeper".into())),
57 // Running the build toolchain to inspect a crate or a service
58 // binary, which is what `build` means on a host. Neither has a
59 // bundle for Gatekeeper to have an opinion about.
60 Kind::Library | Kind::Service => Action::Build,
61 },
62 // Publish/Collect/Handoff run on the daemon, not through a host
63 // executor; this label only applies if a recipe runs a bare `sh` while
64 // one is open. `handoff` is the daemon's own post-recipe motion and no
65 // recipe should open it at all — naming it here costs nothing and beats
66 // a wildcard that would silently absorb the next step somebody adds.
67 Step::Publish | Step::Collect | Step::Handoff => Action::Package,
68 // The one step that dispatches to a host OUTSIDE the build topology.
69 // Every command a recipe runs while `deploy` is open — the install, the
70 // restart, the health assertion — carries this action, so it reaches the
71 // service host only through the deploy grant and reaches a build host
72 // not at all (no build host is granted `deploy`).
73 Step::Deploy => Action::Deploy,
74 }
75 }
76
77 /// The currently-open step within a recipe run: its DB row id, which step it
78 /// is, and the live-log sink that `sh`/`log` stream into.
79 struct StepState {
80 run_id: StepRunId,
81 step: Step,
82 log: Arc<AsyncMutex<LiveLog>>,
83 /// Set when something in the step recorded a hard failure the recipe did
84 /// not abort on (e.g. `verify_gatekeeper` rejected the artifact but the
85 /// recipe ignored the bool). Forces the step's recorded status to `Failed`
86 /// and bars `publish` (the step-success ledger).
87 failed: bool,
88 /// Wall-clock deadline for this step. A command that runs past it fails the
89 /// step (and unwinds the recipe) rather than wedging under the old
90 /// whole-build guillotine, which a legitimate 5-target fan-out plus notary
91 /// queueing could trip — mismarking every target failed while the blocking
92 /// recipe bodies kept signing.
93 deadline: std::time::Instant,
94 }
95
96 /// Per-step wall-clock budget: a generous ceiling that catches a wedged command
97 /// (a hung ssh, a stuck notary poll) without killing legitimately slow work.
98 /// The old design bounded the whole build at 2h; this bounds each step so one
99 /// slow step can't be blamed on another and a fan-out of slow-but-fine targets
100 /// isn't guillotined. `Config::step_timeout_secs` overrides these per-kind
101 /// defaults for every step; see [`RecipeCtx::step_budget`].
102 fn default_step_budget(step: Step) -> std::time::Duration {
103 use std::time::Duration;
104 let mins = match step {
105 Step::Checkout => 10,
106 // clippy + full test suite, cold, on a workspace.
107 Step::Prebuild => 45,
108 // cargo tauri build, cold, universal bundles.
109 Step::Build => 90,
110 Step::Sign => 15,
111 // Apple's notary queue + this step's bounded retries.
112 Step::Notarize => 60,
113 Step::Staple => 10,
114 Step::Package => 30,
115 Step::Verify => 10,
116 // rsync of multi-GiB artifacts off the build host.
117 Step::Collect => 30,
118 Step::Publish => 20,
119 // A binary push, an install, a unit restart, and a health poll. Minutes
120 // of work; the ceiling is for a wedged transport, not slow work.
121 Step::Deploy => 15,
122 // Unused by any recipe — the daemon runs the handoff itself, outside a
123 // step's clock — and matched to `collect`, since it moves the same
124 // bytes the same way and would wedge for the same reasons.
125 Step::Handoff => 30,
126 };
127 Duration::from_secs(mins * 60)
128 }
129
130 /// Where a service's binary is staged on the host that will run it, before the
131 /// privileged installer moves it into place.
132 ///
133 /// A fixed, unguessable-by-accident path rather than a recipe-chosen one,
134 /// because the installer refuses any source outside it. That refusal is the
135 /// only thing standing between the NOPASSWD sudo grant and `install`-as-root to
136 /// an arbitrary path, so both ends have to name the same constant. `/var/tmp`
137 /// rather than `/tmp` so a staged binary survives a `PrivateTmp` unit and a
138 /// systemd tmpfiles sweep between staging and install.
139 pub const DEPLOY_STAGING_ROOT: &str = "/var/tmp/bento-deploy";
140
141 /// Everything a recipe's host functions need, shared (Arc) into each closure.
142 pub struct RecipeCtx {
143 pub app: AppId,
144 pub version: Version,
145 pub target: Target,
146 /// Name of the host this target builds on (resolved from the topology by the
147 /// runner). Recipes read it via `build_host()` so one per-platform recipe can
148 /// dispatch to the right host across arches (linux x86_64 -> fw13, aarch64 ->
149 /// astra) without hard-coding a host name.
150 pub build_host: String,
151 /// The build host's SSH destination (topology `ssh`), as opposed to its
152 /// name. Deploy compares it against the service host's to tell "build and
153 /// run on the same box" from "two hosts that need a transfer" — a question
154 /// the host NAMES cannot answer, since a build host and a deploy
155 /// destination are declared in different files and need not agree on one.
156 pub build_host_ssh: String,
157 /// This release's git tag, rendered from the app's `tag_format`. Held here
158 /// rather than derived from the version because a repo holding several
159 /// products spells it per product (`pom-v0.4.1`), and the recipe, the
160 /// preflight barrier and the failure message all have to agree on it.
161 pub tag: String,
162 /// The app's default checkout path (topology `repo`, `~`-prefixed). Read it
163 /// through [`RecipeCtx::repo_for`] for anything that runs on a build host;
164 /// this field alone is the daemon-local answer.
165 pub repo: String,
166 /// Per-host checkout overrides (topology `repo_by_host`). Empty for every app
167 /// that has not declared one, which is all of them but the Windows-shipping
168 /// ones.
169 pub repo_by_host: HashMap<String, String>,
170 /// Cargo features this app's release builds enable (topology `features`).
171 /// Recipes read it via `feature_flags()`.
172 pub features: Vec<String>,
173 /// App or library. Decides which capability a `verify` step is gated on;
174 /// see [`action_for`].
175 pub kind: Kind,
176 pub target_run_id: i64,
177 /// Capability-scoped executor per build host. Recipe commands dispatch
178 /// through these — the transport (local / ssh / in-session agent) and the
179 /// capability gate are the executor's, not the engine's.
180 pub execs: Arc<ExecutorMap>,
181 /// Sync transport per build host, used only by `collect` to pull artifacts
182 /// back. Never the agent, even for an agent host — see `state::build_sync`.
183 /// Not an execution path.
184 pub syncs: Arc<ExecutorMap>,
185 /// Where this target installs, for a `kind = "service"` app. `None` for an
186 /// app or a library, which makes every deploy host function fail with that
187 /// as the reason rather than with a missing-host error.
188 ///
189 /// The runner resolves it from the app manifest's `[[deploy]]` table and
190 /// registers its executor into `execs` under the destination's host string,
191 /// so `sh_ok(deploy_host(), ...)` reaches the service host through the same
192 /// capability gate as everything else.
193 pub deploy: Option<DeployTarget>,
194 pub pool: SqlitePool,
195 pub events: EventTx,
196 pub cfg: Arc<Config>,
197 pub ota: Arc<OtaRegistry>,
198 pub rt: Handle,
199 current: Mutex<Option<StepState>>,
200 /// Gatekeeper verdict recorded by `verify_gatekeeper`: `None` = never run,
201 /// `Some(false)` = ran and rejected, `Some(true)` = accepted. `publish`
202 /// requires `Some(true)` for a macOS/iOS artifact (the proof it is signed +
203 /// notarized).
204 gatekeeper_ok: Mutex<Option<bool>>,
205 /// Steps finalized as `Failed` during this run. A non-empty ledger bars
206 /// `publish` — an artifact is never shipped after a step failed, even if the
207 /// recipe ignored the failure and ran on.
208 failed_steps: Mutex<Vec<Step>>,
209 /// Set when a newer build supersedes this run. Checked at step boundaries
210 /// and before publish so a superseded recipe stops promptly rather than
211 /// running to completion (the blocking Rhai body can't be `abort()`ed).
212 cancel: Arc<AtomicBool>,
213 /// The all-targets-green publish gate (topology `require_all_targets`).
214 /// `Some(declared)` ⇒ `publish` refuses unless every one of `declared` has a
215 /// successful latest run for this `(app, version)`. `None` ⇒ gate off, each
216 /// target publishes independently.
217 all_green_required: Option<Vec<Target>>,
218 /// sha256 of each artifact hashed at `collect`, keyed by file name. `publish`
219 /// reads it to record `releases.artifact_hash` for the bytes it ships, so the
220 /// hash is the one computed when the artifact landed rather than a re-read
221 /// that could see a different file. Absent ⇒ `publish` hashes on demand.
222 artifact_hashes: Mutex<HashMap<String, String>>,
223 }
224
225 impl RecipeCtx {
226 /// Versions of `name` already on crates.io. A network failure yields an
227 /// empty list: preflight then cannot claim a version is a duplicate, and
228 /// `cargo publish` still refuses one, so the check degrades to advisory
229 /// rather than blocking a release on registry availability.
230 fn published_versions(name: &str) -> Vec<String> {
231 let url = format!("https://crates.io/api/v1/crates/{name}");
232 let Ok(out) = std::process::Command::new("curl")
233 .args([
234 "-sS",
235 "--max-time",
236 "15",
237 "-H",
238 "User-Agent: bento-preflight",
239 &url,
240 ])
241 .output()
242 else {
243 return Vec::new();
244 };
245 let Ok(v) = serde_json::from_slice::<serde_json::Value>(&out.stdout) else {
246 return Vec::new();
247 };
248 v.get("versions")
249 .and_then(|x| x.as_array())
250 .map(|a| {
251 a.iter()
252 .filter_map(|x| x.get("num").and_then(|n| n.as_str()).map(str::to_string))
253 .collect()
254 })
255 .unwrap_or_default()
256 }
257
258 #[allow(clippy::too_many_arguments)]
259 pub fn new(
260 app: AppId,
261 version: Version,
262 target: Target,
263 build_host: String,
264 build_host_ssh: String,
265 tag: String,
266 repo: String,
267 features: Vec<String>,
268 kind: Kind,
269 target_run_id: i64,
270 execs: Arc<ExecutorMap>,
271 syncs: Arc<ExecutorMap>,
272 deploy: Option<DeployTarget>,
273 pool: SqlitePool,
274 events: EventTx,
275 cfg: Arc<Config>,
276 ota: Arc<OtaRegistry>,
277 rt: Handle,
278 cancel: Arc<AtomicBool>,
279 all_green_required: Option<Vec<Target>>,
280 ) -> Self {
281 Self {
282 app,
283 version,
284 target,
285 build_host,
286 build_host_ssh,
287 tag,
288 repo,
289 repo_by_host: HashMap::new(),
290 features,
291 kind,
292 target_run_id,
293 execs,
294 syncs,
295 deploy,
296 pool,
297 events,
298 cfg,
299 ota,
300 rt,
301 current: Mutex::new(None),
302 gatekeeper_ok: Mutex::new(None),
303 failed_steps: Mutex::new(Vec::new()),
304 cancel,
305 all_green_required,
306 artifact_hashes: Mutex::new(HashMap::new()),
307 }
308 }
309
310 /// Declare the app's per-host checkout overrides (topology `repo_by_host`).
311 ///
312 /// Separate from `new` because it is empty for every app that has not opted
313 /// in, and `new` already carries twenty arguments no test wants a
314 /// twenty-first of.
315 #[must_use]
316 pub fn with_repo_by_host(mut self, repo_by_host: HashMap<String, String>) -> Self {
317 self.repo_by_host = repo_by_host;
318 self
319 }
320
321 /// Where this app is checked out on `host` (topology `AppConfig::repo_for`).
322 ///
323 /// Every git command a recipe or the engine runs on a build host goes
324 /// through this. The bare `repo` field is the daemon-local path.
325 pub fn repo_for(&self, host: &str) -> &str {
326 self.repo_by_host
327 .get(host)
328 .map_or(self.repo.as_str(), String::as_str)
329 }
330
331 /// Whether a newer build has superseded this run.
332 fn is_cancelled(&self) -> bool {
333 self.cancel.load(Ordering::SeqCst)
334 }
335
336 fn now() -> String {
337 chrono::Utc::now().to_rfc3339()
338 }
339
340 /// `<logs_root>/<app>/<version>/<target-with-slash-as-dash>/<step>.<run_id>.log`.
341 ///
342 /// The run id is in the filename because the rest of the key is not unique:
343 /// re-running an app at a version it already built (a retry, or a rebuild of
344 /// an already-published release) reopens the same path, and the old file
345 /// appended to shows two runs' output with nothing marking the boundary. The
346 /// step ledger records a run id per step, so keying the file on it makes a
347 /// ledger row resolve to exactly one file and keeps the earlier run readable.
348 fn log_path(&self, step: Step, run_id: StepRunId) -> PathBuf {
349 self.log_dir()
350 .join(format!("{}.{}.log", step.as_str(), run_id.0))
351 }
352
353 /// `<logs_root>/<app>/<version>/<target-with-slash-as-dash>/`.
354 fn log_dir(&self) -> PathBuf {
355 let target_dir = self.target.to_string().replace('/', "-");
356 self.cfg
357 .logs_root
358 .join(self.app.as_str())
359 .join(self.version.to_string())
360 .join(target_dir)
361 }
362
363 /// Close the previous step (as `Ok`), open a new one: insert its DB row,
364 /// open a live log whose chunks broadcast `StepLogChunk`, emit `StepStart`.
365 fn begin_step(self: &Arc<Self>, step: Step) -> Result<()> {
366 anyhow::ensure!(
367 !self.is_cancelled(),
368 "build superseded by a newer request; aborting before `{}`",
369 step.as_str()
370 );
371 self.finish_step(Status::Ok)?;
372 let me = self.clone();
373 let started = Self::now();
374 let started_for_header = started.clone();
375 let run_id = self.rt.block_on(async move {
376 // The step row and the target's current_step pointer are one logical
377 // state — write them atomically so a failure can't leave a `running`
378 // step row while current_step still names the previous step.
379 let mut tx = me.pool.begin().await.context("begin step tx")?;
380 // log_ref names the run id, which only exists once the row does, so
381 // the path is written back inside the same transaction rather than
382 // guessed beforehand.
383 let id: i64 = sqlx::query_scalar(
384 "INSERT INTO step_runs (target_run_id, step, status, started_at)
385 VALUES (?, ?, 'running', ?) RETURNING id",
386 )
387 .bind(me.target_run_id)
388 .bind(step.as_str())
389 .bind(&started)
390 .fetch_one(&mut *tx)
391 .await
392 .context("insert step_run")?;
393 let log_ref = me
394 .log_path(step, StepRunId(id))
395 .to_string_lossy()
396 .into_owned();
397 sqlx::query("UPDATE step_runs SET log_ref = ? WHERE id = ?")
398 .bind(&log_ref)
399 .bind(id)
400 .execute(&mut *tx)
401 .await
402 .context("set step_run log_ref")?;
403 sqlx::query("UPDATE target_runs SET current_step = ? WHERE id = ?")
404 .bind(step.as_str())
405 .bind(me.target_run_id)
406 .execute(&mut *tx)
407 .await
408 .context("update current_step")?;
409 tx.commit().await.context("commit step tx")?;
410 anyhow::Ok(StepRunId(id))
411 })?;
412
413 // Live log: each chunk fans out as a StepLogChunk event keyed by run_id.
414 let events = self.events.clone();
415 let cb_run_id = run_id;
416 let mut log = self.rt.block_on(LiveLog::open(
417 self.log_path(step, run_id),
418 Box::new(move |seq, text| {
419 events::emit(
420 &events,
421 Event::StepLogChunk {
422 run_id: cb_run_id,
423 seq,
424 text: text.to_string(),
425 },
426 );
427 }),
428 ));
429
430 events::emit(
431 &self.events,
432 Event::StepStart {
433 run_id,
434 app: self.app.clone(),
435 version: self.version.clone(),
436 target: self.target,
437 step,
438 },
439 );
440
441 // A log that names its own run: reading one tells you which ledger row
442 // it belongs to without going back to the DB, and a file that somehow
443 // does get appended to still shows where the second run began. Written
444 // after `StepStart` so its chunk event cannot precede the step it
445 // belongs to.
446 let header = format!(
447 "=== bento {app} {version} {target} step={step} run_id={run_id} started={started_for_header} ===\n",
448 app = self.app.as_str(),
449 version = self.version,
450 target = self.target,
451 step = step.as_str(),
452 );
453 self.rt.block_on(async {
454 use ops_core::remote::LogSink as _;
455 log.write_chunk(header.as_bytes()).await;
456 });
457
458 *self.current.lock().unwrap() = Some(StepState {
459 run_id,
460 step,
461 log: Arc::new(AsyncMutex::new(log)),
462 failed: false,
463 deadline: std::time::Instant::now() + self.step_budget(step),
464 });
465 Ok(())
466 }
467
468 /// This build's budget for `step`: the `Config` override if set, else the
469 /// per-kind default.
470 fn step_budget(&self, step: Step) -> std::time::Duration {
471 self.cfg
472 .step_timeout_secs
473 .map_or_else(|| default_step_budget(step), std::time::Duration::from_secs)
474 }
475
476 /// The current step's wall-clock deadline (or a default if no step is open,
477 /// which only happens before the first `step()` — commands then run under an
478 /// implicit `Build` step opened by `ensure_step`).
479 fn step_deadline(&self) -> std::time::Instant {
480 self.current.lock().unwrap().as_ref().map_or_else(
481 || std::time::Instant::now() + self.step_budget(Step::Build),
482 |s| s.deadline,
483 )
484 }
485
486 /// Drive `fut` on the runtime, but stop early on two conditions the recipe
487 /// bodies otherwise cannot observe (they run synchronously on a blocking
488 /// thread): the current step's deadline, and supersession by a newer build.
489 /// Either turns into an error that fails the step and unwinds the recipe, so
490 /// a wedged command no longer runs unbounded and a superseded build stops
491 /// mid-step instead of only at the next step boundary.
492 fn run_bounded<F, T>(&self, what: &str, fut: F) -> Result<T>
493 where
494 F: std::future::Future<Output = Result<T>>,
495 {
496 let deadline = self.step_deadline();
497 let cancel = self.cancel.clone();
498 self.rt.block_on(async move {
499 tokio::pin!(fut);
500 let watch = async {
501 // Poll the cooperative cancel flag; the finalizer and a
502 // superseding build both set it. Cheap next to a build step.
503 while !cancel.load(Ordering::SeqCst) {
504 tokio::time::sleep(std::time::Duration::from_millis(250)).await;
505 }
506 };
507 tokio::select! {
508 r = &mut fut => r,
509 () = tokio::time::sleep_until(deadline.into()) => {
510 Err(anyhow::anyhow!("`{what}` exceeded its per-step deadline"))
511 }
512 () = watch => {
513 Err(anyhow::anyhow!("build superseded by a newer request; aborting `{what}`"))
514 }
515 }
516 })
517 }
518
519 /// Flag the currently-open step as failed (no-op if none is open). Forces
520 /// its recorded status to `Failed` at `finish_step` and adds it to the
521 /// publish-barring ledger, even though the recipe kept running.
522 fn fail_current_step(&self) {
523 if let Some(st) = self.current.lock().unwrap().as_mut() {
524 st.failed = true;
525 }
526 }
527
528 /// Finalize the open step (if any): close its log, stamp the DB row, emit
529 /// `StepDone`. Idempotent when no step is open. A step flagged via
530 /// [`fail_current_step`] is recorded `Failed` regardless of the requested
531 /// status, and added to the ledger `publish` consults.
532 pub fn finish_step(self: &Arc<Self>, status: Status) -> Result<()> {
533 let st = self.current.lock().unwrap().take();
534 let Some(st) = st else { return Ok(()) };
535 let status = if st.failed { Status::Failed } else { status };
536 if status == Status::Failed {
537 self.failed_steps.lock().unwrap().push(st.step);
538 }
539 let me = self.clone();
540 self.rt.block_on(async move {
541 // Drop all log refs so the sink can be owned + flushed.
542 if let Ok(m) = Arc::try_unwrap(st.log) {
543 m.into_inner().close().await;
544 }
545 if let Err(e) = sqlx::query(
546 "UPDATE step_runs SET status = ?, finished_at = ? WHERE id = ?",
547 )
548 .bind(status.as_str())
549 .bind(Self::now())
550 .bind(st.run_id.0)
551 .execute(&me.pool)
552 .await
553 {
554 tracing::error!(step = st.step.as_str(), error = %e, "could not stamp step_run status");
555 }
556 });
557 events::emit(
558 &self.events,
559 Event::StepDone {
560 run_id: st.run_id,
561 app: self.app.clone(),
562 target: self.target,
563 step: st.step,
564 status,
565 },
566 );
567 Ok(())
568 }
569
570 /// Ensure a step is open; default to `Build` if a recipe runs a command
571 /// before declaring one.
572 fn ensure_step(self: &Arc<Self>) -> Result<Arc<AsyncMutex<LiveLog>>> {
573 if self.current.lock().unwrap().is_none() {
574 self.begin_step(Step::Build)?;
575 }
576 Ok(self.current.lock().unwrap().as_ref().unwrap().log.clone())
577 }
578
579 /// The step currently open, or `Build` as a default for failure
580 /// attribution before any step was declared.
581 pub fn current_step(&self) -> Step {
582 self.current
583 .lock()
584 .unwrap()
585 .as_ref()
586 .map_or(Step::Build, |s| s.step)
587 }
588
589 /// The capability-scoped executor for `name`, or an error if the host isn't
590 /// in the topology.
591 fn exec(&self, name: &str) -> Result<Arc<dyn ops_exec::Executor>> {
592 self.execs
593 .get(name)
594 .cloned()
595 .ok_or_else(|| anyhow::anyhow!("unknown build host `{name}` (not in topology)"))
596 }
597
598 /// The ssh string for `name` (for `collect`'s remote scp source).
599 /// The transport that moves artifacts off `name`. Distinct from
600 /// [`RecipeCtx::exec_for`]'s executor: an agent host signs over `AgentRpc`
601 /// but is collected from over ssh (`state::build_sync`).
602 fn host_sync(&self, name: &str) -> Result<Arc<dyn Executor>> {
603 self.syncs
604 .get(name)
605 .cloned()
606 .ok_or_else(|| anyhow::anyhow!("unknown build host `{name}` (not in topology)"))
607 }
608
609 /// Run `cmd` on `host` through its capability-scoped executor, streaming into
610 /// the current step's log. The command's [`Action`] is derived from the open
611 /// step (see [`action_for`]) and gated at the transport before dispatch — so a
612 /// `build` step on a host without the `build` grant is denied, and the macOS
613 /// sign steps ride the in-session `AgentRpc` transport automatically. Returns
614 /// exit code + a tail of stdout for the recipe to branch on.
615 fn run(self: &Arc<Self>, host: &str, cmd: &str) -> Result<(i32, String)> {
616 // The service host is addressed as a service host whatever step is open.
617 // Deriving the action from the step is right for a build host, where the
618 // step IS the work; on a service host it would ask for `build` during a
619 // `verify` and be denied for a reason unrelated to what was attempted.
620 let action = match &self.deploy {
621 Some(d) if d.host == host => Action::Deploy,
622 _ => action_for(self.current_step(), self.kind),
623 };
624 self.run_as(host, cmd, action)
625 }
626
627 /// `run`, with the [`Action`] stated rather than resolved. Used where the
628 /// caller already knows which plane it is on.
629 fn run_as(self: &Arc<Self>, host: &str, cmd: &str, action: Action) -> Result<(i32, String)> {
630 let sink = self.ensure_step()?;
631 let exec = self.exec(host)?;
632 let cur = self.current_step();
633 let step = OpStep::shell(action, cmd.to_string());
634 // Echo the command before running it. Without this a log says what
635 // happened but not what was asked, and a gate that prints nothing when
636 // it passes (`cargo fmt --all --check`) is indistinguishable from a gate
637 // that never ran.
638 let echo = format!("$ [{host}] {cmd}\n");
639 // Bounded by the step's deadline and interruptible on supersession, so a
640 // hung command fails its step instead of running unbounded, and a
641 // superseded build stops mid-step rather than only at the next boundary.
642 let label = format!("{cur} command on `{host}`");
643 let out = self.run_bounded(&label, async move {
644 use ops_core::remote::LogSink as _;
645 let mut guard = sink.lock().await;
646 guard.write_chunk(echo.as_bytes()).await;
647 exec.run_streaming(&step, &mut *guard).await
648 })?;
649 let code = out.status.code().unwrap_or(-1);
650 let stdout = String::from_utf8_lossy(&out.stdout);
651 let tail: String = stdout
652 .chars()
653 .rev()
654 .take(2000)
655 .collect::<Vec<_>>()
656 .into_iter()
657 .rev()
658 .collect();
659 Ok((code, tail))
660 }
661
662 /// Pin `host` to the release tag `v<version>` and return the commit it now
663 /// has checked out. Fetch + checkout stream into the current step's log;
664 /// the sha comes from a separate `rev-parse` so its stdout is only the sha.
665 fn checkout_sha(self: &Arc<Self>, host: &str) -> Result<String> {
666 // Every command below runs ON `host`, so the path is that host's, not the
667 // daemon's. Windows is why: its checkout is at `C:/Users/me/Code/...`.
668 let repo = self.repo_for(host).to_string();
669 // A failing mirror is not a failing release: fetch is advisory, and only
670 // the checkout decides. Its output still streams into the step log, so an
671 // unreachable remote stays visible without being fatal.
672 let _ = self.run(host, &git_fetch_cmd(&repo))?;
673 let (code, _) = self.run(host, &git_checkout_tag_cmd(&repo, &self.tag))?;
674 if code != 0 {
675 let (probe, _) = self.run(host, &git_tag_exists_cmd(&repo, &self.tag))?;
676 anyhow::bail!(
677 "checkout of {} failed on `{host}`: {}",
678 self.tag,
679 checkout_failure_reason(&self.tag, probe == 0)
680 );
681 }
682 let (code, tail) = self.run(host, &git_rev_parse_cmd(&repo))?;
683 anyhow::ensure!(code == 0, "rev-parse failed on `{host}`");
684 Ok(tail.trim().to_string())
685 }
686
687 /// Every artifact this run collected, `file name -> sha256`.
688 ///
689 /// Already computed at `collect`, which is the only moment the bytes are
690 /// known to be the ones that landed.
691 pub fn artifact_hashes(&self) -> HashMap<String, String> {
692 self.artifact_hashes.lock().unwrap().clone()
693 }
694
695 /// Resolve `glob` on `host` to the single artifact it names. The `for` loop
696 /// lists each existing match on its own line (and prints nothing — rather
697 /// than a literal unexpanded pattern — when the glob matches no file), so
698 /// the count is unambiguous. `required` controls whether zero matches is an
699 /// error; more than one always is. See `resolve_artifact_match`.
700 fn resolve_artifact(
701 self: &Arc<Self>,
702 host: &str,
703 glob: &str,
704 required: bool,
705 ) -> Result<String> {
706 ensure_glob_safe(glob)?;
707 // `[ -e ]` guards against a non-matching glob surviving as its literal
708 // self, and lists one path per line for the count.
709 let cmd = format!("for __f in {glob}; do [ -e \"$__f\" ] && printf '%s\\n' \"$__f\"; done");
710 let (code, tail) = self.run(host, &cmd)?;
711 anyhow::ensure!(
712 code == 0,
713 "resolving artifact glob `{glob}` on `{host}` exited {code}"
714 );
715 resolve_artifact_match(&tail, glob, required)
716 }
717 }
718
719 // ----- error bridging: anyhow -> Rhai runtime error -----
720
721 // Rhai host functions return `Result<_, Box<EvalAltResult>>` by convention, so
722 // this bridge must yield the boxed form to be usable with `.map_err(rhai_err)`.
723 #[allow(
724 clippy::unnecessary_box_returns,
725 reason = "rhai's error type is used boxed throughout its host-function API"
726 )]
727 fn rhai_err(e: impl std::fmt::Display) -> Box<EvalAltResult> {
728 Box::new(EvalAltResult::ErrorRuntime(
729 e.to_string().into(),
730 rhai::Position::NONE,
731 ))
732 }
733
734 /// A crate's publish-relevant metadata, read from `cargo metadata`.
735 #[derive(Debug, Clone)]
736 pub struct CrateMeta {
737 pub name: String,
738 pub version: String,
739 pub repository: Option<String>,
740 pub description: Option<String>,
741 pub licensed: bool,
742 }
743
744 /// Parse the fields that matter for publishing out of `cargo metadata` JSON.
745 pub fn crate_meta_from_json(raw: &str) -> Result<CrateMeta> {
746 let v: serde_json::Value = serde_json::from_str(raw).context("parsing cargo metadata")?;
747 let p = v
748 .get("packages")
749 .and_then(|p| p.as_array())
750 .and_then(|a| a.first())
751 .context("cargo metadata reported no package")?;
752 let str_field = |k: &str| {
753 p.get(k)
754 .and_then(|x| x.as_str())
755 .filter(|s| !s.is_empty())
756 .map(str::to_string)
757 };
758 Ok(CrateMeta {
759 name: str_field("name").context("package has no name")?,
760 version: str_field("version").context("package has no version")?,
761 repository: str_field("repository"),
762 description: str_field("description"),
763 licensed: str_field("license").is_some() || str_field("license_file").is_some(),
764 })
765 }
766
767 /// Everything wrong with a crate's metadata, as messages. Empty means publishable.
768 ///
769 /// Checks only what crates.io records permanently. A published version cannot
770 /// be edited, only yanked, and yanking does not correct a wrong URL — so these
771 /// are the last moment any of it can be fixed.
772 pub fn crate_publish_problems(
773 meta: &CrateMeta,
774 repo_clonable: bool,
775 published: &[String],
776 credentials_present: bool,
777 ) -> Vec<String> {
778 let mut out = Vec::new();
779 if !credentials_present {
780 out.push(
781 "no crates.io credentials on the publishing host: `cargo login` there first. \
782 Checked now rather than at the upload, so this fails in seconds instead of \
783 after a full build and verify."
784 .to_string(),
785 );
786 }
787 match &meta.repository {
788 None => out.push(
789 "no `repository` field: the crates.io page will show no source link, permanently"
790 .to_string(),
791 ),
792 Some(url) if !repo_clonable => out.push(format!(
793 "`repository` is not publicly clonable: {url} \
794 (wrong URL, or the repo is private)"
795 )),
796 Some(_) => {}
797 }
798 if meta.description.is_none() {
799 out.push("no `description`: crates.io requires one".to_string());
800 }
801 if !meta.licensed {
802 out.push("no `license` or `license-file`".to_string());
803 }
804 if published.iter().any(|v| v == &meta.version) {
805 out.push(format!(
806 "version {} is already published; bump it",
807 meta.version
808 ));
809 }
810 out
811 }
812
813 /// Read the app's version from its checkout on the daemon host. With
814 /// `version_path` set (topology `version_path`), read exactly that file — a
815 /// `.json` as a Tauri config, anything else as a `Cargo.toml`. Unset (the Tauri
816 /// default), try `src-tauri/tauri.conf.json` then the root `Cargo.toml`. Used by
817 /// the runner's default-version path.
818 pub fn version_from_repo(repo: &str, version_path: Option<&str>) -> Result<Version> {
819 let root = expand_tilde(repo);
820 if let Some(vp) = version_path {
821 let path = root.join(vp);
822 let raw = std::fs::read_to_string(&path)
823 .with_context(|| format!("reading version file {}", path.display()))?;
824 let ver = if std::path::Path::new(vp)
825 .extension()
826 .is_some_and(|e| e.eq_ignore_ascii_case("json"))
827 {
828 version_from_tauri_json(&raw)?
829 } else {
830 version_from_cargo_toml(&raw)?
831 };
832 return Version::parse(&ver).map_err(|e| anyhow::anyhow!(e));
833 }
834 let tauri_conf = root.join("src-tauri").join("tauri.conf.json");
835 if tauri_conf.exists() {
836 let raw = std::fs::read_to_string(&tauri_conf)
837 .with_context(|| format!("reading {}", tauri_conf.display()))?;
838 return Version::parse(&version_from_tauri_json(&raw)?).map_err(|e| anyhow::anyhow!(e));
839 }
840 let cargo_toml = root.join("Cargo.toml");
841 let raw = std::fs::read_to_string(&cargo_toml).with_context(|| {
842 format!(
843 "reading {} (no tauri.conf.json either)",
844 cargo_toml.display()
845 )
846 })?;
847 Version::parse(&version_from_cargo_toml(&raw)?).map_err(|e| anyhow::anyhow!(e))
848 }
849
850 /// Extract `version` from raw `tauri.conf.json` text.
851 fn version_from_tauri_json(raw: &str) -> Result<String> {
852 let v: serde_json::Value = serde_json::from_str(raw).context("parsing tauri.conf.json")?;
853 v.get("version")
854 .and_then(|x| x.as_str())
855 .map(str::to_owned)
856 .context("no `version` in tauri.conf.json")
857 }
858
859 /// Extract the version from raw `Cargo.toml` text — `[package].version` (a leaf
860 /// crate) or `[workspace.package].version` (a workspace that sets it).
861 fn version_from_cargo_toml(raw: &str) -> Result<String> {
862 let doc: toml::Value = toml::from_str(raw).context("parsing Cargo.toml")?;
863 doc.get("package")
864 .and_then(|p| p.get("version"))
865 .or_else(|| {
866 doc.get("workspace")
867 .and_then(|w| w.get("package"))
868 .and_then(|p| p.get("version"))
869 })
870 .and_then(|v| v.as_str())
871 .map(str::to_owned)
872 .context("no `[package].version` or `[workspace.package].version` in Cargo.toml")
873 }
874
875 /// Cross-check every version source in a repo and confirm they all agree with
876 /// the version being built, before a single host pulls or compiles.
877 ///
878 /// `version_from_repo` reads exactly one file, so a `tauri.conf.json` at 0.5.0
879 /// and a root `Cargo.toml` still at 0.4.0 build happily and file artifacts under
880 /// whichever the runner happened to read. This reads every source present —
881 /// `version_path` (when set), `src-tauri/tauri.conf.json`, and the root
882 /// `Cargo.toml` — and fails loudly when any disagree, naming each file and its
883 /// version. A source that is absent is skipped (a library crate with only a
884 /// `Cargo.toml` has nothing to disagree with); the check never invents drift.
885 ///
886 /// Scope: the JSON/TOML sources bentod itself reads. The iOS `gen/apple/project.yml`
887 /// path (rewritten by a build-time `sed`) is out of scope here — it is asserted at
888 /// its own build step — but the same drift class motivated this guard.
889 pub fn check_version_consistency(
890 repo: &str,
891 version_path: Option<&str>,
892 expected: &Version,
893 ) -> Result<()> {
894 let root = expand_tilde(repo);
895 // (human-readable source label, parsed version) for every source present.
896 let mut found: Vec<(String, Version)> = Vec::new();
897
898 let mut consider = |rel: &str, raw: &str, as_json: bool| -> Result<()> {
899 let ver = if as_json {
900 version_from_tauri_json(raw)
901 } else {
902 version_from_cargo_toml(raw)
903 }?;
904 let parsed = Version::parse(&ver).map_err(|e| anyhow::anyhow!(e))?;
905 found.push((rel.to_string(), parsed));
906 Ok(())
907 };
908
909 if let Some(vp) = version_path {
910 let path = root.join(vp);
911 let raw = std::fs::read_to_string(&path)
912 .with_context(|| format!("reading version file {}", path.display()))?;
913 let is_json = std::path::Path::new(vp)
914 .extension()
915 .is_some_and(|e| e.eq_ignore_ascii_case("json"));
916 consider(vp, &raw, is_json)?;
917 }
918 let tauri_conf = root.join("src-tauri").join("tauri.conf.json");
919 if version_path != Some("src-tauri/tauri.conf.json") && tauri_conf.exists() {
920 let raw = std::fs::read_to_string(&tauri_conf)
921 .with_context(|| format!("reading {}", tauri_conf.display()))?;
922 consider("src-tauri/tauri.conf.json", &raw, true)?;
923 }
924 let cargo_toml = root.join("Cargo.toml");
925 if version_path != Some("Cargo.toml") && cargo_toml.exists() {
926 // A Cargo.toml with neither `[package].version` nor
927 // `[workspace.package].version` (a pure virtual workspace) carries no
928 // version to check — skip it rather than fail.
929 if let Ok(raw) = std::fs::read_to_string(&cargo_toml)
930 && version_from_cargo_toml(&raw).is_ok()
931 {
932 consider("Cargo.toml", &raw, false)?;
933 }
934 }
935
936 let disagree: Vec<&(String, Version)> = found.iter().filter(|(_, v)| v != expected).collect();
937 anyhow::ensure!(
938 disagree.is_empty(),
939 "version drift in {repo}: building {expected} but {}",
940 disagree
941 .iter()
942 .map(|(src, v)| format!("{src} says {v}"))
943 .collect::<Vec<_>>()
944 .join(", ")
945 );
946 Ok(())
947 }
948
949 /// Every `X.Y.Z`-shaped version embedded in an artifact file name. Each maximal
950 /// run of digits-and-dots contributes its first three numeric fields:
951 /// `GoingsOn_0.5.0_aarch64.dmg` and `demo-9.9.9.bin` both yield one version (the
952 /// trailing `.bin`/`.dmg` dot is tolerated), while `latest.json` yields `[]` and
953 /// the `64` in `x86_64` is not three fields. Only the `major.minor.patch` core is
954 /// taken; a prerelease/build suffix is separated by `-`/`+` and not needed here.
955 fn versions_in_filename(name: &str) -> Vec<Version> {
956 name.split(|c: char| !(c.is_ascii_digit() || c == '.'))
957 .filter_map(|run| {
958 let f: Vec<&str> = run.split('.').filter(|s| !s.is_empty()).collect();
959 if f.len() >= 3 && f[..3].iter().all(|s| s.chars().all(|c| c.is_ascii_digit())) {
960 Version::parse(&format!("{}.{}.{}", f[0], f[1], f[2])).ok()
961 } else {
962 None
963 }
964 })
965 .collect()
966 }
967
968 /// Fail when a collected file's name embeds a version whose `major.minor.patch`
969 /// is not the one being built. This is the guard against a stale checked-in
970 /// artifact winning a glob: `ls -t <glob>` once let
971 /// `AudioFiles-0.4.0-x86_64.AppImage` ship against 0.5.0. A file whose name
972 /// carries no version (an updater `latest.json`, a `.sig`) is not asserted —
973 /// there is nothing to compare. Compared on the core so a prerelease build's
974 /// plain `X.Y.Z` in the filename still matches.
975 fn assert_artifact_version(name: &str, expected: &Version) -> Result<()> {
976 let versions = versions_in_filename(name);
977 anyhow::ensure!(
978 versions.is_empty() || versions.iter().any(|v| v.core() == expected.core()),
979 "collected artifact `{name}` carries version {} but the build is {expected}; \
980 a stale artifact was left in the output dir — clean it so only {expected} remains",
981 versions
982 .iter()
983 .map(ToString::to_string)
984 .collect::<Vec<_>>()
985 .join("/"),
986 );
987 Ok(())
988 }
989
990 /// sha256 of a file, lowercase hex. Streams in 64 KiB chunks so a multi-GiB
991 /// bundle never lands in memory whole.
992 fn sha256_file(path: &Path) -> Result<String> {
993 let mut file =
994 std::fs::File::open(path).with_context(|| format!("hashing {}", path.display()))?;
995 let mut hasher = Sha256::new();
996 std::io::copy(&mut file, &mut hasher)
997 .with_context(|| format!("reading {} to hash", path.display()))?;
998 Ok(hex_lower(&hasher.finalize()))
999 }
1000
1001 /// Every regular file under `root`, as `(path relative to root, absolute path)`,
1002 /// sorted by the relative path.
1003 ///
1004 /// **This walk has to match `bundle::digest_dir` in sando, file for file.** That
1005 /// function re-hashes an incoming bundle and refuses it when the bytes disagree
1006 /// with the manifest they arrived with, so a producer that walks differently
1007 /// produces a manifest the consumer will reject for an artifact nothing is wrong
1008 /// with. Three properties carry that agreement, and none is incidental:
1009 ///
1010 /// - **Recursive.** A bundle may carry a directory (migrations, resources), and
1011 /// a top-level-only listing would omit its contents from the manifest while
1012 /// the verifier hashed them.
1013 /// - **Symlinks are not followed, and not recorded.** Following one would let
1014 /// content from outside the bundle into its identity; recording the link
1015 /// itself would name a file the verifier does not hash.
1016 /// - **Relative paths, `/`-separated, sorted.** Readdir order is not guaranteed,
1017 /// so an unsorted manifest would differ run to run on one machine, never mind
1018 /// between two.
1019 fn collected_files(root: &Path) -> std::io::Result<Vec<(String, PathBuf)>> {
1020 fn walk(dir: &Path, root: &Path, out: &mut Vec<(String, PathBuf)>) -> std::io::Result<()> {
1021 for entry in std::fs::read_dir(dir)? {
1022 let entry = entry?;
1023 let ft = entry.file_type()?;
1024 let path = entry.path();
1025 if ft.is_dir() {
1026 walk(&path, root, out)?;
1027 } else if ft.is_file() {
1028 let rel = path
1029 .strip_prefix(root)
1030 .unwrap_or(&path)
1031 .components()
1032 .map(|c| c.as_os_str().to_string_lossy())
1033 .collect::<Vec<_>>()
1034 .join("/");
1035 out.push((rel, path));
1036 }
1037 // Symlinks and other special files are intentionally ignored,
1038 // matching the verifier.
1039 }
1040 Ok(())
1041 }
1042 let mut out = Vec::new();
1043 walk(root, root, &mut out)?;
1044 out.sort_by(|a, b| a.0.cmp(&b.0));
1045 Ok(out)
1046 }
1047
1048 /// Lowercase-hex encode without pulling in a hex crate.
1049 fn hex_lower(bytes: &[u8]) -> String {
1050 use std::fmt::Write as _;
1051 let mut s = String::with_capacity(bytes.len() * 2);
1052 for b in bytes {
1053 let _ = write!(s, "{b:02x}");
1054 }
1055 s
1056 }
1057
1058 /// Refresh every remote's refs and tags, so the tag a release names is present
1059 /// locally however it was pushed. No branch/upstream assumptions — a bare
1060 /// `git pull --ff-only` needs a tracking branch the release path shouldn't
1061 /// depend on.
1062 ///
1063 /// Best-effort on purpose. `fetch --all` exits non-zero if ANY remote fails, and
1064 /// the library repos carry three (`astra`, `mnw`, `srht`), so chaining this into
1065 /// the checkout with `&&` meant one unreachable mirror aborted the release and
1066 /// reported it as a missing tag. The checkout below is the step allowed to fail;
1067 /// this one only has to try. See [`git_checkout_tag_cmd`].
1068 ///
1069 /// `repo` is interpolated UNQUOTED so a leading `~` is expanded by the remote
1070 /// host's shell (the checkout path is trusted topology config, not user input),
1071 /// matching how the recipes `cd` into it.
1072 pub fn git_fetch_cmd(repo: &str) -> String {
1073 format!("git -C {repo} fetch --all --tags --prune")
1074 }
1075
1076 /// Pin the host's checkout to the release tag. Runs after [`git_fetch_cmd`], and
1077 /// is the operation whose exit code decides whether the release proceeds.
1078 pub fn git_checkout_tag_cmd(repo: &str, tag: &str) -> String {
1079 format!("git -C {repo} checkout \"{tag}\"")
1080 }
1081
1082 /// Does `tag` resolve to a commit in this checkout? Run only when the checkout
1083 /// has already failed, to say WHY: an absent tag is an untagged or unpushed
1084 /// release, while a tag that resolves fine means the checkout was refused for a
1085 /// local reason (a dirty tree, most often) and the operator needs to hear that
1086 /// instead.
1087 pub fn git_tag_exists_cmd(repo: &str, tag: &str) -> String {
1088 format!("git -C {repo} rev-parse -q --verify \"refs/tags/{tag}^{{commit}}\"")
1089 }
1090
1091 /// The operator-facing explanation for a failed tag checkout, given whether the
1092 /// tag turned out to exist locally.
1093 pub fn checkout_failure_reason(tag: &str, tag_exists: bool) -> String {
1094 if tag_exists {
1095 format!(
1096 "tag {tag} exists but could not be checked out \
1097 (uncommitted changes in the checkout?)"
1098 )
1099 } else {
1100 format!("tag {tag} does not exist there (is it created and pushed?)")
1101 }
1102 }
1103
1104 /// Uncommitted changes to TRACKED files, one `XY path` line each, empty when the
1105 /// tree is clean.
1106 ///
1107 /// `--untracked-files=no` on purpose: an untracked file is not built into the
1108 /// binary and a build host accumulates them (editor scratch, stray logs), so
1109 /// failing a release on one would be noise. A modified tracked file is the
1110 /// opposite — it is exactly what `cargo build` would pick up instead of the
1111 /// tagged content.
1112 ///
1113 /// Scoped to `repo` with `-- .` rather than asking about the whole repository,
1114 /// which matters only for the repos holding more than one product. `repo` for
1115 /// pom is `~/Code/MNW/pom` inside the MNW monorepo, and an edit in `server/` is
1116 /// not something pom's build can compile. Refusing pom's release for it would be
1117 /// a gate that fires on unrelated work, which is how a gate gets bypassed. For a
1118 /// single-product repo `repo` is the root and this is the whole tree, unchanged.
1119 pub fn git_dirty_cmd(repo: &str) -> String {
1120 format!("git -C {repo} status --porcelain --untracked-files=no -- .")
1121 }
1122
1123 /// The branch a host's checkout is on, empty (and non-zero) on a detached HEAD.
1124 /// Read BEFORE the release pins the tag, so the checkout can be put back
1125 /// afterwards — see [`git_restore_branch_cmd`].
1126 pub fn git_current_branch_cmd(repo: &str) -> String {
1127 format!("git -C {repo} symbolic-ref -q --short HEAD")
1128 }
1129
1130 /// Put a checkout back on the branch it was on before the release pinned it to
1131 /// the tag.
1132 ///
1133 /// The pin itself is correct and deliberate: a release must build the tagged
1134 /// commit, not the branch tip. What was missing is the other half. Leaving the
1135 /// tree detached is invisible — git does not warn, and commits made afterwards
1136 /// succeed normally while belonging to no branch. makeover shipped 2.3.0 from
1137 /// exactly that state on 2026-07-28: three commits, including the published one,
1138 /// existed only as a detached HEAD on one machine, on no branch and no remote.
1139 pub fn git_restore_branch_cmd(repo: &str, branch: &str) -> String {
1140 format!("git -C {repo} checkout \"{branch}\"")
1141 }
1142
1143 /// The command a host runs to report the commit it has checked out, for the
1144 /// release preflight barrier.
1145 pub fn git_rev_parse_cmd(repo: &str) -> String {
1146 format!("git -C {repo} rev-parse HEAD")
1147 }
1148
1149 /// Expand a leading `~/` to `$HOME`. Paths in the topology are written with `~`.
1150 pub fn expand_tilde(p: &str) -> PathBuf {
1151 if let Some(rest) = p.strip_prefix("~/")
1152 && let Ok(home) = std::env::var("HOME")
1153 {
1154 return Path::new(&home).join(rest);
1155 }
1156 PathBuf::from(p)
1157 }
1158
1159 /// Reject a glob that carries shell command metacharacters. Path and wildcard
1160 /// characters (`/ . * ? [ ] ~` etc.) are fine — the pattern reaches a login
1161 /// shell to be expanded — but a `;` or `$(...)` must not ride along and run.
1162 /// Not a privilege boundary (a recipe already runs arbitrary shell via `sh_ok`)
1163 /// but it keeps a malformed pattern from turning into a command. Shared by
1164 /// `collect` and `resolve_artifact`.
1165 fn ensure_glob_safe(glob: &str) -> Result<()> {
1166 anyhow::ensure!(
1167 !glob.chars().any(|c| matches!(
1168 c,
1169 ';' | '&' | '|' | '$' | '`' | '\'' | '"' | '\\' | ' ' | '\n' | '(' | ')' | '<' | '>'
1170 )),
1171 "glob `{glob}` contains shell metacharacters"
1172 );
1173 Ok(())
1174 }
1175
1176 /// Decide the single artifact a glob resolves to from a newline-separated
1177 /// listing of the paths that matched it.
1178 ///
1179 /// The recipes used to select an artifact with `ls -t <glob> | head -1` and
1180 /// guard only on an empty string, so a non-zero `ls` slipped past quietly and a
1181 /// stale newest-by-mtime file could win. This is the strict replacement: it
1182 /// demands exactly one match. Zero matches fail when `required` (return `""`
1183 /// when optional); more than one is always an error rather than an arbitrary
1184 /// newest-wins pick, because an ambiguous match means the build left stale
1185 /// artifacts behind and the wrong one could ship.
1186 fn resolve_artifact_match(listing: &str, glob: &str, required: bool) -> Result<String> {
1187 let matches: Vec<&str> = listing
1188 .lines()
1189 .map(str::trim)
1190 .filter(|l| !l.is_empty())
1191 .collect();
1192 match matches.as_slice() {
1193 [] if required => anyhow::bail!("no artifact matched glob `{glob}`"),
1194 [] => Ok(String::new()),
1195 [one] => Ok((*one).to_string()),
1196 many => anyhow::bail!(
1197 "glob `{glob}` is ambiguous: {} artifacts matched ({}). \
1198 The build left more than one behind; clean stale artifacts so exactly one remains.",
1199 many.len(),
1200 many.join(", ")
1201 ),
1202 }
1203 }
1204
1205 /// Build a Rhai engine with the host API bound to `ctx`. Sandboxed: recipes
1206 /// touch the outside world only through these functions.
1207 pub fn build_engine(ctx: &Arc<RecipeCtx>) -> Engine {
1208 let mut engine = Engine::new();
1209 // Defensive caps — recipes are first-party but bound the blast radius.
1210 engine.set_max_operations(5_000_000);
1211 engine.set_max_call_levels(64);
1212 engine.set_max_string_size(0);
1213
1214 // --- step(name) ---
1215 {
1216 let ctx = ctx.clone();
1217 engine.register_fn(
1218 "step",
1219 move |name: &str| -> Result<(), Box<EvalAltResult>> {
1220 let step: Step = name.parse().map_err(rhai_err)?;
1221 ctx.begin_step(step).map_err(rhai_err)
1222 },
1223 );
1224 }
1225
1226 // --- sh(host, cmd) -> #{ code, stdout_tail } ---
1227 //
1228 // The branch-on-exit-code primitive: the recipe OWNS the outcome. A non-zero
1229 // exit is returned, not raised, and does NOT fail the step or bar publish —
1230 // use this only when the recipe inspects `code` and decides. For a command
1231 // that must succeed (build/sign/etc.), use `sh_ok`, which fails the step (and
1232 // therefore bars publish via the failed-step ledger) on a non-zero exit.
1233 {
1234 let ctx = ctx.clone();
1235 engine.register_fn(
1236 "sh",
1237 move |host: &str, cmd: &str| -> Result<Map, Box<EvalAltResult>> {
1238 let (code, tail) = ctx.run(host, cmd).map_err(rhai_err)?;
1239 let mut m = Map::new();
1240 m.insert("code".into(), (code as i64).into());
1241 m.insert("stdout_tail".into(), tail.into());
1242 Ok(m)
1243 },
1244 );
1245 }
1246
1247 // --- sh_ok(host, cmd): run + assert exit 0 (the must-succeed primitive) ---
1248 //
1249 // A non-zero exit fails the current step (added to the publish-barring
1250 // ledger) and aborts the recipe, so an artifact is never shipped after a
1251 // must-succeed command failed.
1252 {
1253 let ctx = ctx.clone();
1254 engine.register_fn(
1255 "sh_ok",
1256 move |host: &str, cmd: &str| -> Result<(), Box<EvalAltResult>> {
1257 let (code, _) = ctx.run(host, cmd).map_err(rhai_err)?;
1258 if code != 0 {
1259 // Attribute the failure to the current step explicitly so the
1260 // ledger bars publish even if a future caller swallowed the error.
1261 ctx.fail_current_step();
1262 return Err(rhai_err(format!(
1263 "command on `{host}` exited {code}: {cmd}"
1264 )));
1265 }
1266 Ok(())
1267 },
1268 );
1269 }
1270
1271 // --- resolve_artifact(host, glob) -> path: the ONE artifact matching glob ---
1272 //
1273 // The artifact-selection primitive. Replaces `sh(host, "ls -t <glob> | head
1274 // -1").stdout_tail.trim()` guarded on an empty string, which let a non-zero
1275 // `ls` pass quietly and a stale newest-by-mtime file win. This resolves the
1276 // glob on the host and demands exactly one match: zero matches or more than
1277 // one both throw (an ambiguous match means the build left stale artifacts,
1278 // and silently picking the newest is how the wrong bytes ship). Use
1279 // `resolve_artifact_opt` for an artifact that may legitimately be absent.
1280 {
1281 let ctx = ctx.clone();
1282 engine.register_fn(
1283 "resolve_artifact",
1284 move |host: &str, glob: &str| -> Result<String, Box<EvalAltResult>> {
1285 ctx.resolve_artifact(host, glob, true).map_err(rhai_err)
1286 },
1287 );
1288 }
1289
1290 // --- resolve_artifact_opt(host, glob) -> path | "": zero-or-one match ---
1291 //
1292 // Same strict resolution as `resolve_artifact` but tolerates zero matches
1293 // (returns ""); more than one is still an error. For optional outputs like a
1294 // `.deb` or an updater bundle a recipe collects only when present.
1295 {
1296 let ctx = ctx.clone();
1297 engine.register_fn(
1298 "resolve_artifact_opt",
1299 move |host: &str, glob: &str| -> Result<String, Box<EvalAltResult>> {
1300 ctx.resolve_artifact(host, glob, false).map_err(rhai_err)
1301 },
1302 );
1303 }
1304
1305 // --- log(msg): operator-visible line into the current step's tail ---
1306 {
1307 let ctx = ctx.clone();
1308 engine.register_fn("log", move |msg: &str| -> Result<(), Box<EvalAltResult>> {
1309 let sink = ctx.ensure_step().map_err(rhai_err)?;
1310 let line = format!("[recipe] {msg}\n");
1311 ctx.rt.block_on(async {
1312 use ops_core::remote::LogSink;
1313 sink.lock().await.write_chunk(line.as_bytes()).await;
1314 });
1315 Ok(())
1316 });
1317 }
1318
1319 // --- version_of(app) -> string ---
1320 {
1321 let ctx = ctx.clone();
1322 engine.register_fn(
1323 "version_of",
1324 move |app: &str| -> Result<String, Box<EvalAltResult>> {
1325 // Only the current app is in scope; cross-app reads aren't needed.
1326 if app != ctx.app.as_str() {
1327 return Err(rhai_err(format!(
1328 "version_of: `{app}` is not the app being built"
1329 )));
1330 }
1331 Ok(ctx.version.to_string())
1332 },
1333 );
1334 }
1335
1336 // --- version() -> string: the version being built (no-arg form) ---
1337 {
1338 let ctx = ctx.clone();
1339 engine.register_fn("version", move || -> String { ctx.version.to_string() });
1340 }
1341
1342 // --- build_host() -> string: the host this target builds on ---
1343 {
1344 let ctx = ctx.clone();
1345 engine.register_fn("build_host", move || -> String { ctx.build_host.clone() });
1346 }
1347
1348 // --- repo() -> string: the app's checkout path on this target's build host
1349 // (`~`-prefixed on a unix host). Host-correct rather than one path per
1350 // app, so a recipe for a host whose checkout is elsewhere still calls
1351 // this instead of hard-coding the path — which is what kept the Windows
1352 // recipes off `checkout_sha`. ---
1353 {
1354 let ctx = ctx.clone();
1355 engine.register_fn("repo", move || -> String {
1356 ctx.repo_for(&ctx.build_host).to_string()
1357 });
1358 }
1359
1360 // --- checkout_sha(host) -> sha: pin this host to the release tag and report
1361 // its commit. Replaces a recipe's `git pull --ff-only`, which builds
1362 // whatever `main` is at pull time; the daemon also runs the same pin as
1363 // a cross-host preflight barrier before any target builds. ---
1364 {
1365 let ctx = ctx.clone();
1366 engine.register_fn(
1367 "checkout_sha",
1368 move |host: &str| -> Result<String, Box<EvalAltResult>> {
1369 ctx.checkout_sha(host).map_err(rhai_err)
1370 },
1371 );
1372 }
1373
1374 // --- crate_preflight() -> string: verify this crate is safe to publish,
1375 // or abort the run. Everything it checks is immutable once published:
1376 // crates.io versions can be yanked but never edited, so a wrong
1377 // repository URL is permanent. pter 0.1.0 shipped with a dead one. ---
1378 {
1379 let ctx = ctx.clone();
1380 engine.register_fn(
1381 "crate_preflight",
1382 move || -> Result<String, Box<EvalAltResult>> {
1383 // `repo`, not `repo_for(...)`: `cargo metadata` runs on the
1384 // daemon's own box, so this is the one checkout that is always
1385 // the local one. It is not a missed call site.
1386 let repo = expand_tilde(&ctx.repo);
1387
1388 let out = std::process::Command::new("cargo")
1389 .args(["metadata", "--no-deps", "--format-version", "1"])
1390 .current_dir(&repo)
1391 .output()
1392 .map_err(|e| format!("running cargo metadata in {}: {e}", repo.display()))?;
1393 if !out.status.success() {
1394 return Err(format!(
1395 "cargo metadata failed in {}: {}",
1396 repo.display(),
1397 String::from_utf8_lossy(&out.stderr).trim()
1398 )
1399 .into());
1400 }
1401 let meta = crate_meta_from_json(&String::from_utf8_lossy(&out.stdout))
1402 .map_err(|e| e.to_string())?;
1403
1404 // The real question is not whether a page renders but whether a
1405 // stranger with no credentials can fetch the source, so ask git.
1406 let clonable = meta.repository.as_ref().is_some_and(|url| {
1407 std::process::Command::new("git")
1408 .args(["ls-remote", url])
1409 .env("GIT_TERMINAL_PROMPT", "0")
1410 .output()
1411 .is_ok_and(|o| o.status.success())
1412 });
1413
1414 // Ask the publishing host whether cargo has credentials, rather
1415 // than moving the token anywhere. It stays in cargo's own 0600
1416 // store; a shell line carrying it would be visible in `ps`.
1417 // An exit code answers "are there credentials"; an Err answers
1418 // "the question could not be asked". Collapsing the second into
1419 // the first reported a capability denial as "no crates.io
1420 // credentials", which sent a real diagnosis three rounds the
1421 // wrong way. A check that cannot run is not a failed check.
1422 let creds =
1423 ctx.run(
1424 &ctx.build_host.clone(),
1425 "cargo login --help >/dev/null 2>&1 && \
1426 test -s \"${CARGO_HOME:-$HOME/.cargo}/credentials.toml\" \
1427 || test -s \"${CARGO_HOME:-$HOME/.cargo}/credentials\"",
1428 )
1429 .map_err(|e| {
1430 format!(
1431 "could not check crates.io credentials on `{}`: {e}",
1432 ctx.build_host
1433 )
1434 })?
1435 .0 == 0;
1436
1437 let published = RecipeCtx::published_versions(&meta.name);
1438 let problems = crate_publish_problems(&meta, clonable, &published, creds);
1439 if !problems.is_empty() {
1440 return Err(format!(
1441 "{} {} is not safe to publish:\n - {}",
1442 meta.name,
1443 meta.version,
1444 problems.join("\n - ")
1445 )
1446 .into());
1447 }
1448 Ok(format!("{} {} passed preflight", meta.name, meta.version))
1449 },
1450 );
1451 }
1452
1453 // --- feature_flags() -> string: `--features a,b`, or "" when the app
1454 // declares none. Returns the whole flag rather than a bare list so an
1455 // app with no features cannot produce a dangling `--features`. ---
1456 {
1457 let ctx = ctx.clone();
1458 engine.register_fn("feature_flags", move || -> String {
1459 if ctx.features.is_empty() {
1460 String::new()
1461 } else {
1462 format!("--features {}", ctx.features.join(","))
1463 }
1464 });
1465 }
1466
1467 // --- target() / platform() / arch(): the target axis, for one per-platform
1468 // recipe to branch on arch (bundle paths differ between x86_64/aarch64). ---
1469 {
1470 let ctx = ctx.clone();
1471 engine.register_fn("target", move || -> String { ctx.target.to_string() });
1472 }
1473 {
1474 let ctx = ctx.clone();
1475 engine.register_fn("platform", move || -> String {
1476 ctx.target.platform.as_str().to_string()
1477 });
1478 }
1479 {
1480 let ctx = ctx.clone();
1481 engine.register_fn("arch", move || -> String {
1482 ctx.target.arch.as_str().to_string()
1483 });
1484 }
1485
1486 // --- secret(key) -> string (file under secrets_root; never logged) ---
1487 {
1488 let ctx = ctx.clone();
1489 engine.register_fn("secret", move |key: &str| -> Result<String, Box<EvalAltResult>> {
1490 // Guard against traversal out of secrets_root. Require every path
1491 // component to be `Normal` (rejects `..`, `.`, absolute roots and
1492 // drive prefixes) and forbid backslashes (a literal filename char on
1493 // Linux, but a separator elsewhere) — the per-component strength of
1494 // Sando's `safe()`. A multi-segment key like `app/token` is still
1495 // allowed; `foo..bar` (a legit filename) is no longer falsely blocked.
1496 let safe = !key.is_empty()
1497 && !key.contains('\\')
1498 && std::path::Path::new(key)
1499 .components()
1500 .all(|c| matches!(c, std::path::Component::Normal(_)));
1501 if !safe {
1502 return Err(rhai_err(
1503 "secret key must be a relative path under secrets_root (no `..`, `.`, absolute paths, or backslashes)",
1504 ));
1505 }
1506 let path = ctx.cfg.secrets_root.join(key);
1507 std::fs::read_to_string(&path)
1508 .map(|s| s.trim_end().to_string())
1509 .map_err(|e| rhai_err(format!("secret `{key}`: {e}")))
1510 });
1511 }
1512
1513 // --- env(host, key) -> string ---
1514 {
1515 let ctx = ctx.clone();
1516 engine.register_fn(
1517 "env",
1518 move |host: &str, key: &str| -> Result<String, Box<EvalAltResult>> {
1519 // The key is interpolated into a `${...}` shell expansion, so it must
1520 // be a bare shell identifier — anything else (quotes, `}`, `$`, `;`)
1521 // could break out and run arbitrary commands on the host. Validate
1522 // before building the command; this is the one env read that can't
1523 // sh-quote its argument (a quoted var name doesn't expand).
1524 if key.is_empty()
1525 || !key
1526 .chars()
1527 .next()
1528 .is_some_and(|c| c == '_' || c.is_ascii_alphabetic())
1529 || !key.chars().all(|c| c == '_' || c.is_ascii_alphanumeric())
1530 {
1531 return Err(rhai_err(format!(
1532 "env name `{key}` must be a shell identifier ([A-Za-z_][A-Za-z0-9_]*)"
1533 )));
1534 }
1535 // Read via the shell so it works on remote hosts too.
1536 let (code, tail) = ctx
1537 .run(host, &format!("printf '%s' \"${{{key}}}\""))
1538 .map_err(rhai_err)?;
1539 if code != 0 {
1540 return Err(rhai_err(format!("env `{key}` on `{host}` failed")));
1541 }
1542 Ok(tail.trim().to_string())
1543 },
1544 );
1545 }
1546
1547 // --- collect(host, glob, app, version): pull artifacts to dist_root ---
1548 {
1549 let ctx = ctx.clone();
1550 engine.register_fn(
1551 "collect",
1552 move |host: &str,
1553 glob: &str,
1554 app: &str,
1555 version: &str|
1556 -> Result<(), Box<EvalAltResult>> {
1557 ctx.collect(host, glob, app, version).map_err(rhai_err)
1558 },
1559 );
1560 }
1561
1562 // --- publish(channel, app, target, version, artifact, meta) ---
1563 {
1564 let ctx = ctx.clone();
1565 engine.register_fn(
1566 "publish",
1567 move |channel: &str,
1568 app: &str,
1569 target: &str,
1570 version: &str,
1571 artifact: &str,
1572 meta: Map|
1573 -> Result<String, Box<EvalAltResult>> {
1574 ctx.publish(channel, app, target, version, artifact, &meta)
1575 .map_err(rhai_err)
1576 },
1577 );
1578 }
1579
1580 // --- deploy(binary) -> summary: install a service binary and restart its
1581 // unit. The terminal step for `kind = "service"`, the counterpart of
1582 // `publish` for something that is run rather than distributed.
1583 //
1584 // Takes only the binary's path on the build host: where it lands, on
1585 // which machine, and which unit restarts all come from the `[[deploy]]`
1586 // entry for the target already being built. A recipe cannot deploy the
1587 // aarch64 binary to the x86_64 box by naming the wrong host, because it
1588 // never names a host at all.
1589 {
1590 let ctx = ctx.clone();
1591 engine.register_fn(
1592 "deploy",
1593 move |binary: &str| -> Result<String, Box<EvalAltResult>> {
1594 ctx.deploy(binary).map_err(rhai_err)
1595 },
1596 );
1597 }
1598
1599 // --- deploy_host() -> string: the service host's ssh destination, so a
1600 // recipe can run its own assertions there (`sh_ok(deploy_host(), ...)`).
1601 // Commands run through it while the `deploy` step is open, so they are
1602 // gated on the deploy grant like the install itself. ---
1603 {
1604 let ctx = ctx.clone();
1605 engine.register_fn(
1606 "deploy_host",
1607 move || -> Result<String, Box<EvalAltResult>> {
1608 ctx.deploy_target()
1609 .map(|d| d.host.clone())
1610 .map_err(rhai_err)
1611 },
1612 );
1613 }
1614
1615 // --- service_name() / install_path() / health_url(): the rest of the
1616 // `[[deploy]]` entry, so a recipe asserts against the configured values
1617 // rather than repeating them as literals that can drift. `health_url`
1618 // is "" when unset. ---
1619 {
1620 let ctx = ctx.clone();
1621 engine.register_fn(
1622 "service_name",
1623 move || -> Result<String, Box<EvalAltResult>> {
1624 ctx.deploy_target()
1625 .map(|d| d.service.clone())
1626 .map_err(rhai_err)
1627 },
1628 );
1629 }
1630 {
1631 let ctx = ctx.clone();
1632 engine.register_fn(
1633 "install_path",
1634 move || -> Result<String, Box<EvalAltResult>> {
1635 ctx.deploy_target()
1636 .map(|d| d.install_path.clone())
1637 .map_err(rhai_err)
1638 },
1639 );
1640 }
1641 {
1642 let ctx = ctx.clone();
1643 engine.register_fn(
1644 "health_url",
1645 move || -> Result<String, Box<EvalAltResult>> {
1646 ctx.deploy_target()
1647 .map(|d| d.health_url.clone().unwrap_or_default())
1648 .map_err(rhai_err)
1649 },
1650 );
1651 }
1652
1653 // --- glibc_check(binary) -> string: assert the build host did not produce
1654 // a binary the service host's glibc is too old to exec. Aborts the run
1655 // if it did; returns "needs X, host has Y" for the log if it did not. ---
1656 {
1657 let ctx = ctx.clone();
1658 engine.register_fn(
1659 "glibc_check",
1660 move |binary: &str| -> Result<String, Box<EvalAltResult>> {
1661 let (needs, has) = ctx.glibc_check(binary).map_err(rhai_err)?;
1662 Ok(format!(
1663 "glibc: binary needs {needs}, service host has {has}"
1664 ))
1665 },
1666 );
1667 }
1668
1669 // --- macOS signing helpers. They dispatch through the named host's
1670 // executor like any other step; when that host is the mac (transport =
1671 // "agent"), codesign/notarize/staple ride the in-session `AgentRpc`
1672 // transport — the only security session where the Developer ID key is
1673 // usable (design §7 "THE WALL"). Capability-gated by the host's `sign`
1674 // grant. ---
1675 register_macos_fns(&mut engine, ctx);
1676
1677 engine
1678 }
1679
1680 /// Highest `GLIBC_x.y` version referenced by a built binary, and the glibc a
1681 /// host actually has, both parsed from the text the commands print.
1682 ///
1683 /// Native-per-architecture builds remove the cross-compile hazard `deploy.sh`
1684 /// was written against, but not this one: fw13 tracks a newer glibc than the
1685 /// Ubuntu 24.04 box in Hetzner, so a binary built here can reference a symbol
1686 /// version that box does not have and fail at exec — after the unit has already
1687 /// been restarted onto it. Comparing the two before the install is what makes
1688 /// that a failed step instead of a downed service.
1689 fn max_glibc_symbol(objdump_out: &str) -> Option<(u64, u64)> {
1690 objdump_out
1691 .split(|c: char| !(c.is_ascii_digit() || c == '.' || c == '_' || c.is_ascii_alphabetic()))
1692 .filter_map(|tok| tok.strip_prefix("GLIBC_"))
1693 .filter_map(parse_glibc_version)
1694 .max()
1695 }
1696
1697 /// Parse `2.39` (or `2.39.1`, keeping major/minor) into a comparable pair.
1698 fn parse_glibc_version(s: &str) -> Option<(u64, u64)> {
1699 let mut parts = s.split('.');
1700 let major = parts.next()?.parse().ok()?;
1701 let minor = parts.next()?.parse().ok()?;
1702 Some((major, minor))
1703 }
1704
1705 /// The glibc version out of `ldd --version`'s first line, whose tail is the
1706 /// version however the distro decorates the rest (`ldd (Ubuntu GLIBC
1707 /// 2.39-0ubuntu8.8) 2.39`).
1708 fn glibc_from_ldd(ldd_out: &str) -> Option<(u64, u64)> {
1709 let first = ldd_out.lines().find(|l| !l.trim().is_empty())?;
1710 parse_glibc_version(first.split_whitespace().last()?)
1711 }
1712
1713 impl RecipeCtx {
1714 /// This target's install destination, or an error naming why there is none.
1715 fn deploy_target(&self) -> Result<&DeployTarget> {
1716 self.deploy.as_ref().ok_or_else(|| {
1717 anyhow::anyhow!(
1718 "no deploy destination for {} {}: the app is `kind = \"{}\"`, and only a \
1719 service declares [[deploy]] entries",
1720 self.app,
1721 self.target,
1722 match self.kind {
1723 Kind::App => "app",
1724 Kind::Library => "library",
1725 Kind::Service => "service",
1726 }
1727 )
1728 })
1729 }
1730
1731 /// Compare the built binary's glibc requirement against the service host's.
1732 /// Returns the two versions for the recipe to log.
1733 fn glibc_check(self: &Arc<Self>, binary: &str) -> Result<(String, String)> {
1734 let d = self.deploy_target()?.clone();
1735 // `objdump -T` on the build host; no symbols at all (a static binary)
1736 // means nothing to check, which is a pass rather than a failure.
1737 let (code, out) = self.run(
1738 &self.build_host.clone(),
1739 &format!(
1740 "objdump -T {binary} 2>/dev/null | grep -o 'GLIBC_[0-9.]*' | sort -uV || true"
1741 ),
1742 )?;
1743 anyhow::ensure!(code == 0, "reading glibc symbols from {binary} failed");
1744 let Some(needs) = max_glibc_symbol(&out) else {
1745 return Ok(("none".into(), "n/a".into()));
1746 };
1747 let (code, ldd) = self.run(&d.host, "ldd --version")?;
1748 anyhow::ensure!(
1749 code == 0,
1750 "could not read glibc version on service host `{}`",
1751 d.host
1752 );
1753 let has = glibc_from_ldd(&ldd).ok_or_else(|| {
1754 anyhow::anyhow!(
1755 "could not parse glibc version from `ldd --version` on `{}`",
1756 d.host
1757 )
1758 })?;
1759 anyhow::ensure!(
1760 needs <= has,
1761 "binary needs glibc {}.{} but `{}` has {}.{} — it would fail to exec after the \
1762 unit restarted onto it. Build on a host no newer than the service host.",
1763 needs.0,
1764 needs.1,
1765 d.host,
1766 has.0,
1767 has.1,
1768 );
1769 Ok((
1770 format!("{}.{}", needs.0, needs.1),
1771 format!("{}.{}", has.0, has.1),
1772 ))
1773 }
1774
1775 /// Install `binary` (a path on the BUILD host) onto the service host and
1776 /// restart its unit, via the privileged installer the host holds a scoped
1777 /// sudo grant for.
1778 ///
1779 /// Bento never runs the install itself. It stages the bytes and calls a
1780 /// root script whose arguments are re-checked on the far side — the same
1781 /// shape as Sando's `install-companion.sh`, and for the same reason: the
1782 /// sudoers grant is then ONE auditable script rather than a broad
1783 /// `install`+`systemctl` grant on a production box.
1784 ///
1785 /// Only the binary moves. Config is deliberately untouched: pom's
1786 /// `pom-astra.toml` / `pom-hetzner.toml` differ per instance, and prod's
1787 /// carried a `[targets.mnw.tests]` block the repo did not have. A deploy
1788 /// that copies config over is how that block gets silently deleted.
1789 fn deploy(self: &Arc<Self>, binary: &str) -> Result<String> {
1790 anyhow::ensure!(
1791 !self.is_cancelled(),
1792 "build superseded by a newer request; refusing to deploy"
1793 );
1794 // A failed earlier step bars a deploy exactly as it bars a publish. An
1795 // artifact that failed its gates must not reach a production host just
1796 // because the recipe kept running.
1797 let failed = self.failed_steps.lock().unwrap().clone();
1798 anyhow::ensure!(
1799 failed.is_empty(),
1800 "refusing to deploy {} {}: {} failed earlier in this run",
1801 self.app,
1802 self.version,
1803 failed
1804 .iter()
1805 .map(ToString::to_string)
1806 .collect::<Vec<_>>()
1807 .join(", "),
1808 );
1809 let d = self.deploy_target()?.clone();
1810 ensure_glob_safe(binary)?;
1811
1812 // Stage under a fixed root the installer also insists on, so "what was
1813 // checked" and "what is installed" cannot drift apart.
1814 let staged = format!("{DEPLOY_STAGING_ROOT}/{}", self.app);
1815 let staged_bin = format!("{staged}/{}", self.app);
1816 let deploy_exec = self.exec(&d.host)?;
1817 anyhow::ensure!(
1818 deploy_exec.capabilities().permits(&Action::Deploy),
1819 "service host `{}` is not granted the `deploy` capability",
1820 d.host
1821 );
1822
1823 self.run_ok(&d.host, &format!("mkdir -p {staged}"))?;
1824 if self.build_host_ssh == d.host {
1825 // Same box: the binary is already there. Routing it through the
1826 // daemon would be two transfers to end up where it started. This is
1827 // pom's aarch64 leg — astra builds it and astra runs it.
1828 self.run_ok(&d.host, &format!("cp -f {binary} {staged_bin}"))?;
1829 } else {
1830 // Build host -> daemon -> service host. Two hops because an executor
1831 // reaches one host; a direct host-to-host transport would mean the
1832 // build host holding a credential for the production box.
1833 let tmp = tempfile::tempdir().context("staging dir for deploy")?;
1834 let local = tmp.path().join(self.app.as_str());
1835 self.pull_for_deploy(binary, &local)?;
1836 let (dest, opts) = (PathBuf::from(&staged), SyncOpts::default());
1837 let dir = tmp.path().to_path_buf();
1838 self.run_bounded(&format!("stage {} on `{}`", self.app, d.host), async move {
1839 deploy_exec.push_dir(&dir, &dest, &opts).await
1840 })
1841 .with_context(|| format!("staging {} onto `{}`", self.app, d.host))?;
1842 }
1843
1844 // The privileged half. Every argument is re-validated by the script,
1845 // which is the thing actually holding the sudo grant.
1846 self.run_ok(
1847 &d.host,
1848 &format!(
1849 "{} {staged_bin} {} {}",
1850 self.cfg.deploy_installer, d.install_path, d.service
1851 ),
1852 )?;
1853 Ok(format!(
1854 "{} {} installed at {} on `{}`; {} restarted",
1855 self.app, self.version, d.install_path, d.host, d.service
1856 ))
1857 }
1858
1859 /// Fetch one file off a host into a daemon-local path for re-pushing.
1860 ///
1861 /// A local build host is read directly: `fw13` is the daemon's own box, so
1862 /// the file is already on this filesystem. Routing it through the
1863 /// artifact-pull gate instead would demand a `pull_root` covering every repo
1864 /// a service could be built in — today that is `~/Code/Apps`, and pom lives
1865 /// in `~/Code/MNW`. Widening it to `~/Code` would put `_private`, the
1866 /// secrets root, inside the collectable tree. This is pom's x86_64 leg.
1867 fn pull_for_deploy(self: &Arc<Self>, remote: &str, local: &Path) -> Result<()> {
1868 let host = self.build_host.clone();
1869 let remote_path = expand_tilde(remote);
1870 if self.build_host_ssh == "local" || self.build_host_ssh.is_empty() {
1871 std::fs::copy(&remote_path, local).with_context(|| {
1872 format!("staging {} from the daemon host", remote_path.display())
1873 })?;
1874 return Ok(());
1875 }
1876 let sync = self.host_sync(&host)?;
1877 let (src, dst, opts) = (remote_path, local.to_path_buf(), SyncOpts::default());
1878 self.run_bounded(&format!("fetch {remote} from `{host}`"), async move {
1879 sync.pull_file(&src, &dst, &opts).await
1880 })
1881 .with_context(|| format!("fetching {remote} from `{host}` to deploy"))
1882 }
1883
1884 /// `run`, failing the step on a non-zero exit. The Rust-side twin of the
1885 /// recipe's `sh_ok`, for commands the deploy machinery issues itself.
1886 fn run_ok(self: &Arc<Self>, host: &str, cmd: &str) -> Result<String> {
1887 let (code, tail) = self.run(host, cmd)?;
1888 if code != 0 {
1889 self.fail_current_step();
1890 anyhow::bail!("command on `{host}` exited {code}: {cmd}\n{tail}");
1891 }
1892 Ok(tail)
1893 }
1894
1895 /// Where this run's collected files land locally.
1896 ///
1897 /// Per target, not per version. Every target used to share one
1898 /// `dist_root/<app>/<version>/`, so the hash loop below (which lists the
1899 /// directory) attributed a sibling's AppImage to the mac build's artifact
1900 /// record. It is also the layout the archive uses, and the two have to agree
1901 /// or the local copy and the deposited one are different shapes.
1902 fn collect_dest(&self, app: &str, version: &str) -> PathBuf {
1903 self.cfg
1904 .dist_root
1905 .join(app)
1906 .join(version)
1907 .join(crate::archive::target_slug(self.target))
1908 }
1909
1910 fn collect(self: &Arc<Self>, host: &str, glob: &str, app: &str, version: &str) -> Result<()> {
1911 let dest = self.collect_dest(app, version);
1912 let dest_s = dest.to_string_lossy().into_owned();
1913 // The glob reaches a remote login shell intact (that's what expands it),
1914 // so command metacharacters stay barred. Path/wildcard chars are fine.
1915 ensure_glob_safe(glob)?;
1916 std::fs::create_dir_all(&dest)
1917 .with_context(|| format!("creating collect dest {dest_s}"))?;
1918 // The SYNC transport, not the host's exec executor: artifacts move over
1919 // ssh/rsync even from an agent host, whose `/pull` is confined to a
1920 // narrow `pull_root` that deliberately excludes the repo checkout these
1921 // artifacts are built in (see `state::build_sync`). The daemon still
1922 // runs the transfer itself, as it always has.
1923 let sync = self.host_sync(host)?;
1924 let opts = SyncOpts::precompressed();
1925 // Bounded by the collect step's deadline (rsync of a multi-GiB artifact
1926 // can wedge on a stalled transport) and interruptible on supersession.
1927 let dest_pull = dest.clone();
1928 self.run_bounded(&format!("collect {glob} from `{host}`"), async move {
1929 sync.pull_glob(glob, &dest_pull, &opts).await
1930 })
1931 .with_context(|| format!("collect {glob} from `{host}`"))?;
1932 // Assert the version and hash every collected file. This is where a
1933 // stale artifact is caught: a file whose name embeds a different version
1934 // fails the collect (rather than silently winning a later glob), and the
1935 // sha256 recorded here is what `publish` writes into the release ledger
1936 // and what the artifact record's manifest is built from.
1937 //
1938 // Recursive, and keyed by path relative to the collect dir. That is not
1939 // a preference: Sando's intake re-hashes the bundle with its own walker,
1940 // which recurses and keys the same way, and refuses a bundle whose bytes
1941 // do not match the manifest it was handed. A top-level `read_dir` keyed
1942 // by file name agrees with that walker for a flat directory and diverges
1943 // the moment a bundle carries a subdirectory — the honest artifact would
1944 // be refused for a manifest that omitted everything nested. The two
1945 // walkers have to be the same walk. See `bundle::digest_dir` in sando.
1946 for (rel, path) in
1947 collected_files(&dest).with_context(|| format!("listing collect dest {dest_s}"))?
1948 {
1949 // The version check stays on the file NAME rather than the relative
1950 // path: it is looking for a stale `app_1.2.3.AppImage` beside the
1951 // one this release built, and a directory component is not that.
1952 let name = path
1953 .file_name()
1954 .map_or_else(|| rel.clone(), |n| n.to_string_lossy().into_owned());
1955 assert_artifact_version(&name, &self.version)?;
1956 let digest = sha256_file(&path)?;
1957 self.artifact_hashes.lock().unwrap().insert(rel, digest);
1958 }
1959 // Deposit at the archive path, so this target's bytes have one address
1960 // whichever host produced them. A no-op when no archive is configured.
1961 //
1962 // Inside `collect`, not after the recipe: a failure here fails the
1963 // collect step, before sign and publish, rather than putting a red mark
1964 // on a release that has already shipped. And it is a failure, not a
1965 // warning — a deposit that is quietly skipped leaves the archive path
1966 // wrong for exactly the release nobody was watching, which is the thing
1967 // having one address is for.
1968 let (cfg, app_id, version, target) = (
1969 self.cfg.clone(),
1970 self.app.clone(),
1971 self.version.clone(),
1972 self.target,
1973 );
1974 let dest_archive = dest.clone();
1975 self.run_bounded("deposit in the archive", async move {
1976 crate::archive::deposit(&cfg, &dest_archive, &app_id, &version, target).await
1977 })?;
1978 // Best-effort size accounting for the event.
1979 events::emit(
1980 &self.events,
1981 Event::ArtifactCollected {
1982 app: self.app.clone(),
1983 target: self.target,
1984 path: dest_s,
1985 bytes: dir_size(&dest).unwrap_or(0),
1986 },
1987 );
1988 Ok(())
1989 }
1990
1991 /// The all-targets-green gate: err unless every declared target OTHER than
1992 /// the one publishing has a latest `target_runs` row of `ok` for this
1993 /// `(app, version)`. A sibling with no run, a running run, or a failed
1994 /// latest run all block the publish, naming what is not green.
1995 fn assert_siblings_green(self: &Arc<Self>, declared: &[Target]) -> Result<()> {
1996 let me = self.clone();
1997 let (app_s, ver_s) = (self.app.to_string(), self.version.to_string());
1998 let rows: Vec<(String, String)> = self.rt.block_on(async move {
1999 sqlx::query_as(
2000 "SELECT target, status FROM target_runs tr
2001 WHERE app = ?1 AND version = ?2
2002 AND id = (SELECT MAX(id) FROM target_runs
2003 WHERE app = ?1 AND version = ?2 AND target = tr.target)",
2004 )
2005 .bind(app_s)
2006 .bind(ver_s)
2007 .fetch_all(&me.pool)
2008 .await
2009 .unwrap_or_default()
2010 });
2011 let status_of = |t: &Target| -> Option<String> {
2012 let key = t.to_string();
2013 rows.iter()
2014 .find(|(name, _)| name == &key)
2015 .map(|(_, s)| s.clone())
2016 };
2017 let not_green: Vec<String> = declared
2018 .iter()
2019 .filter(|t| **t != self.target) // the publishing target is the last mile
2020 .filter(|t| status_of(t).as_deref() != Some("ok"))
2021 .map(|t| format!("{t} ({})", status_of(t).unwrap_or_else(|| "no run".into())))
2022 .collect();
2023 anyhow::ensure!(
2024 not_green.is_empty(),
2025 "all-targets-green gate: refusing to publish {} {} — not green: {}",
2026 self.app,
2027 self.version,
2028 not_green.join(", "),
2029 );
2030 Ok(())
2031 }
2032
2033 fn publish(
2034 self: &Arc<Self>,
2035 channel: &str,
2036 app: &str,
2037 target: &str,
2038 version: &str,
2039 artifact: &str,
2040 meta: &Map,
2041 ) -> Result<String> {
2042 // Never let a superseded build ship. This is the last and most important
2043 // cooperative-cancel checkpoint: even if a long-running step finished
2044 // after supersession, the artifact must not reach the backend.
2045 anyhow::ensure!(
2046 !self.is_cancelled(),
2047 "build superseded by a newer request; refusing to publish"
2048 );
2049 // Opt-in all-targets-green gate: refuse a partial release. Every OTHER
2050 // declared target of this (app, version) must have a successful latest
2051 // run before this one ships, so macOS can't publish while windows is red
2052 // or still building. The publishing target itself is the last mile (it
2053 // reached publish, so its steps passed) and is not required to be green
2054 // in the ledger yet.
2055 if let Some(declared) = self.all_green_required.clone() {
2056 self.assert_siblings_green(&declared)?;
2057 }
2058 let backend = self
2059 .ota
2060 .get(channel)
2061 .ok_or_else(|| anyhow::anyhow!("unknown publish channel `{channel}`"))?;
2062 let target: Target = target.parse().map_err(|e: String| anyhow::anyhow!(e))?;
2063 let version = Version::parse(version).map_err(|e| anyhow::anyhow!(e))?;
2064 let app = AppId::new(app);
2065
2066 // The backend must actually handle this target (e.g. the desktop updater
2067 // disclaims iOS) — otherwise publish would push an artifact through a
2068 // backend that does not support it.
2069 anyhow::ensure!(
2070 backend.supports(target),
2071 "publish channel `{channel}` does not support target {target}",
2072 );
2073
2074 // Monotonicity: never publish a version that is not strictly newer than
2075 // the latest already published for this (app, target, channel). Without
2076 // this an older build could republish over a live newer release. The
2077 // `releases` column is TEXT, so compare by parsed semver precedence
2078 // (Version: Ord), not lexically.
2079 {
2080 let (app_s, target_s, chan_s) =
2081 (app.to_string(), target.to_string(), channel.to_string());
2082 let me = self.clone();
2083 let latest: Option<Version> = self.rt.block_on(async move {
2084 let rows: Vec<(String,)> = sqlx::query_as(
2085 "SELECT version FROM releases WHERE app = ? AND target = ? AND channel = ?",
2086 )
2087 .bind(app_s)
2088 .bind(target_s)
2089 .bind(chan_s)
2090 .fetch_all(&me.pool)
2091 .await
2092 .unwrap_or_default();
2093 rows.into_iter()
2094 .filter_map(|(v,)| Version::parse(&v).ok())
2095 .max()
2096 });
2097 if let Some(latest) = latest {
2098 anyhow::ensure!(
2099 version > latest,
2100 "refusing to publish {app} {version} to `{channel}` ({target}): \
2101 not newer than the last published {latest}",
2102 );
2103 }
2104 }
2105
2106 // Step-success ledger (the Bento analogue of Sando's gate fail-closed),
2107 // minted as an unforgeable PublishAuthority. `backend.publish` cannot be
2108 // called without one, so the unverified/post-failure ship path is sealed
2109 // at the type level rather than guarded by a separate runtime check.
2110 let authority = {
2111 let failed = self.failed_steps.lock().unwrap();
2112 let gatekeeper = *self.gatekeeper_ok.lock().unwrap();
2113 PublishAuthority::prove(target, failed.as_slice(), gatekeeper)?
2114 };
2115 let notes = meta
2116 .get("notes")
2117 .and_then(|v| v.clone().into_string().ok())
2118 .unwrap_or_default();
2119 // Resolve the artifact relative to the collected dist dir if not absolute.
2120 let artifact_path = {
2121 let p = PathBuf::from(artifact);
2122 if p.is_absolute() {
2123 p
2124 } else {
2125 self.collect_dest(app.as_str(), &version.to_string())
2126 .join(artifact)
2127 }
2128 };
2129 let rel = Release {
2130 app: &app,
2131 target,
2132 version: &version,
2133 notes,
2134 };
2135 let receipt = backend
2136 .publish(&rel, &artifact_path, &authority)
2137 .with_context(|| format!("publish to `{channel}`"))?;
2138 // Record for idempotency / monotonicity. This write is CHECKED, not
2139 // fire-and-forget: a swallowed failure here would silently re-arm the
2140 // monotonicity guard (which reads this same table), letting an older
2141 // version republish over a live release. Concurrent same-(app,target)
2142 // publishers can't race the read-then-insert because the latest-wins slot
2143 // (state::ActiveSlot) serializes them and a superseded run is cancelled
2144 // before it reaches publish.
2145 // The artifact's hash, recorded so the release ledger says exactly which
2146 // bytes shipped. Prefer the digest computed at `collect`; fall back to
2147 // hashing the file now (an absolute-path artifact never routed through
2148 // `collect`). A hash failure must not fail an already-published release,
2149 // so degrade to NULL rather than erroring.
2150 let artifact_hash: Option<String> = artifact_path
2151 .file_name()
2152 .and_then(|n| n.to_str())
2153 .and_then(|n| self.artifact_hashes.lock().unwrap().get(n).cloned())
2154 .or_else(|| sha256_file(&artifact_path).ok());
2155 let me = self.clone();
2156 let (app_s, target_s, ver_s, chan_s) = (
2157 app.to_string(),
2158 target.to_string(),
2159 version.to_string(),
2160 channel.to_string(),
2161 );
2162 self.rt
2163 .block_on(async move {
2164 sqlx::query(
2165 "INSERT OR IGNORE INTO releases (app, target, version, channel, artifact_hash, published_at)
2166 VALUES (?, ?, ?, ?, ?, ?)",
2167 )
2168 .bind(app_s)
2169 .bind(target_s)
2170 .bind(ver_s)
2171 .bind(chan_s)
2172 .bind(artifact_hash)
2173 .bind(Self::now())
2174 .execute(&me.pool)
2175 .await
2176 })
2177 .context("recording release in the idempotency ledger (artifact published but ledger write failed)")?;
2178 events::emit(
2179 &self.events,
2180 Event::PublishOk {
2181 app: self.app.clone(),
2182 target: self.target,
2183 channel: channel.to_string(),
2184 },
2185 );
2186 Ok(receipt)
2187 }
2188 }
2189
2190 fn dir_size(p: &Path) -> Option<i64> {
2191 let mut total = 0i64;
2192 for entry in std::fs::read_dir(p).ok()? {
2193 let entry = entry.ok()?;
2194 let md = entry.metadata().ok()?;
2195 if md.is_file() {
2196 total += md.len() as i64;
2197 } else if md.is_dir() {
2198 // Recurse so a bundle dir (a `.app`) reports its real size, not ~0.
2199 total += dir_size(&entry.path()).unwrap_or(0);
2200 }
2201 }
2202 Some(total)
2203 }
2204
2205 /// macOS signing/notarization host functions. Thin wrappers over the right
2206 /// shell incantations, dispatched through the named host's executor. On the mac
2207 /// host (`transport = "agent"`) they run via the in-session `ops-agent`, the only
2208 /// context where codesign can use the Developer ID key (design §7 "THE WALL"); a
2209 /// plain SSH session cannot. Each is gated by the host's `sign` capability.
2210 fn register_macos_fns(engine: &mut Engine, ctx: &Arc<RecipeCtx>) {
2211 {
2212 let ctx = ctx.clone();
2213 engine.register_fn(
2214 "verify_gatekeeper",
2215 move |host: &str, path: &str| -> Result<bool, Box<EvalAltResult>> {
2216 // spctl has no JSON mode, so assess on-host and decide there,
2217 // emitting an unambiguous sentinel as the final line. We match the
2218 // sentinel rather than substring-hunting `source=Notarized...` in a
2219 // 2000-char tail: truncation only drops the front, so the sentinel
2220 // is always present, and it can't be spoofed by spctl's own prose.
2221 // The full assess output is still streamed to the step log.
2222 let q = ops_core::remote::sh_quote(path);
2223 let cmd = format!(
2224 "out=$(spctl --assess -vv --type install {q} 2>&1); printf '%s\\n' \"$out\"; \
2225 printf '%s' \"$out\" | grep -q 'source=Notarized Developer ID' \
2226 && echo BENTO_GATEKEEPER_OK || echo BENTO_GATEKEEPER_FAIL",
2227 );
2228 let (_, tail) = ctx.run(host, &cmd).map_err(rhai_err)?;
2229 let accepted = tail.contains("BENTO_GATEKEEPER_OK");
2230 // Record the verdict for the publish gate. A rejection also
2231 // fails the step, so the matrix shows red and `publish` is barred
2232 // even if the recipe ignores the returned bool.
2233 *ctx.gatekeeper_ok.lock().unwrap() = Some(accepted);
2234 if !accepted {
2235 ctx.fail_current_step();
2236 }
2237 Ok(accepted)
2238 },
2239 );
2240 }
2241 {
2242 let ctx = ctx.clone();
2243 engine.register_fn(
2244 "codesign",
2245 move |host: &str, identity: &str, path: &str| -> Result<(), Box<EvalAltResult>> {
2246 let cmd = format!(
2247 "codesign --force --options runtime --timestamp --sign {} {}",
2248 ops_core::remote::sh_quote(identity),
2249 ops_core::remote::sh_quote(path),
2250 );
2251 let (code, _) = ctx.run(host, &cmd).map_err(rhai_err)?;
2252 if code != 0 {
2253 return Err(rhai_err("codesign failed"));
2254 }
2255 Ok(())
2256 },
2257 );
2258 }
2259 {
2260 let ctx = ctx.clone();
2261 engine.register_fn(
2262 "staple",
2263 move |host: &str, path: &str| -> Result<(), Box<EvalAltResult>> {
2264 let (code, _) = ctx
2265 .run(
2266 host,
2267 &format!("xcrun stapler staple {}", ops_core::remote::sh_quote(path)),
2268 )
2269 .map_err(rhai_err)?;
2270 if code != 0 {
2271 return Err(rhai_err("stapler failed"));
2272 }
2273 Ok(())
2274 },
2275 );
2276 }
2277 {
2278 let ctx = ctx.clone();
2279 engine.register_fn(
2280 "notarize",
2281 move |host: &str, path: &str| -> Result<String, Box<EvalAltResult>> {
2282 ctx.notarize(host, path).map_err(rhai_err)
2283 },
2284 );
2285 }
2286 {
2287 let ctx = ctx.clone();
2288 engine.register_fn(
2289 "keychain_open",
2290 move |host: &str, name: &str| -> Result<(), Box<EvalAltResult>> {
2291 // The full build-keychain lifecycle lives in dist/build-keychain.sh
2292 // (design §7); this drives it by name so the recipe stays short.
2293 let (code, _) = ctx
2294 .run(
2295 host,
2296 &format!(
2297 ". ~/.tauri/passwords.env && ./dist/build-keychain.sh open {}",
2298 ops_core::remote::sh_quote(name)
2299 ),
2300 )
2301 .map_err(rhai_err)?;
2302 if code != 0 {
2303 return Err(rhai_err("keychain_open failed"));
2304 }
2305 Ok(())
2306 },
2307 );
2308 }
2309 {
2310 let ctx = ctx.clone();
2311 engine.register_fn(
2312 "keychain_close",
2313 move |host: &str, name: &str| -> Result<(), Box<EvalAltResult>> {
2314 let _ = ctx.run(
2315 host,
2316 &format!(
2317 "./dist/build-keychain.sh close {}",
2318 ops_core::remote::sh_quote(name)
2319 ),
2320 );
2321 Ok(())
2322 },
2323 );
2324 }
2325 }
2326
2327 impl RecipeCtx {
2328 /// `xcrun notarytool submit --wait` with bounded retry (the one flaky,
2329 /// network-bound step). Emits `NotarizeRetry` per attempt.
2330 fn notarize(self: &Arc<Self>, host: &str, path: &str) -> Result<String> {
2331 const MAX_ATTEMPTS: u32 = 3;
2332 let backoff = self
2333 .cfg
2334 .notarize_backoff_secs
2335 .map_or(std::time::Duration::from_secs(15), |s| {
2336 std::time::Duration::from_secs(s)
2337 });
2338 let cmd = format!(
2339 ". ~/.tauri/passwords.env && xcrun notarytool submit {} \
2340 --key \"$NOTARY_KEY\" --key-id \"$NOTARY_KEY_ID\" --issuer \"$NOTARY_ISSUER\" \
2341 --wait --output-format json",
2342 ops_core::remote::sh_quote(path),
2343 );
2344 let mut last = String::new();
2345 for attempt in 1..=MAX_ATTEMPTS {
2346 let (code, tail) = self.run(host, &cmd)?;
2347 if code == 0 && notary_accepted(&tail) {
2348 return Ok(tail);
2349 }
2350 last = tail;
2351 if attempt < MAX_ATTEMPTS {
2352 events::emit(
2353 &self.events,
2354 Event::NotarizeRetry {
2355 app: self.app.clone(),
2356 target: self.target,
2357 attempt,
2358 reason: format!("exit {code}"),
2359 },
2360 );
2361 self.rt.block_on(tokio::time::sleep(backoff));
2362 }
2363 }
2364 anyhow::bail!("notarization failed after {MAX_ATTEMPTS} attempts: {last}")
2365 }
2366 }
2367
2368 /// True iff `notarytool --output-format json` output reports `status: Accepted`.
2369 /// Isolates the JSON object (`{`..`}`) from any shell-sourcing noise and reads
2370 /// the typed `status` field, rather than substring-matching `"status":"Accepted"`
2371 /// in a possibly-truncated tail — which could match the literal inside an error
2372 /// message or miss it across a whitespace variant. Fails closed: any parse or
2373 /// field miss returns false.
2374 fn notary_accepted(output: &str) -> bool {
2375 let (Some(start), Some(end)) = (output.find('{'), output.rfind('}')) else {
2376 return false;
2377 };
2378 if start > end {
2379 return false;
2380 }
2381 serde_json::from_str::<serde_json::Value>(&output[start..=end])
2382 .ok()
2383 .and_then(|v| {
2384 v.get("status")
2385 .and_then(|s| s.as_str())
2386 .map(|s| s.eq_ignore_ascii_case("accepted"))
2387 })
2388 .unwrap_or(false)
2389 }
2390
2391 #[cfg(test)]
2392 mod tests {
2393 use super::*;
2394
2395 /// Build the shared cross-crate bundle fixture under `root`.
2396 ///
2397 /// A binary at the top and two files in a subdirectory — the shape a service
2398 /// that ships its migrations has, which is the case the flat walk used to
2399 /// get wrong.
2400 pub(crate) fn write_bundle_fixture(root: &Path) {
2401 std::fs::create_dir_all(root.join("migrations")).unwrap();
2402 std::fs::write(root.join("pom"), b"binary-bytes").unwrap();
2403 std::fs::write(root.join("migrations/001_init.sql"), b"create table a;").unwrap();
2404 std::fs::write(root.join("migrations/002_next.sql"), b"alter table a;").unwrap();
2405 }
2406
2407 /// The manifest text the fixture must produce, in BOTH crates.
2408 ///
2409 /// Sando's `bundle::digest_dir` has the identical constant and the identical
2410 /// fixture. That is the whole point: bento writes this text into the artifact
2411 /// record, sando recomputes it from the bytes that arrive, and an artifact is
2412 /// refused when they differ. Two walks, one answer, pinned from both ends —
2413 /// if either crate's walk drifts, its own test fails and names the drift
2414 /// rather than a release failing intake for a bundle nothing is wrong with.
2415 pub(crate) const BUNDLE_FIXTURE_MANIFEST: &str = concat!(
2416 "e4c908e219c533fa7ad7ea1634398f9bf51637ba20717769ada545bab26d7368 migrations/001_init.sql\n",
2417 "b026fd51bae096b34672cefdb781b6585b13efb53bc301d50c305f422552a380 migrations/002_next.sql\n",
2418 "71227a7f160afca3fb3c39f448735886dda7bd366252580c2222fb87d4bb4d85 pom\n",
2419 );
2420
2421 /// The producer half of the contract above: what `collect` hashes, turned
2422 /// into a manifest, is exactly the text the verifier will recompute.
2423 ///
2424 /// Nested files are included and addressed by relative path. Before this,
2425 /// `collect` listed only the top level, so `migrations/` contributed nothing
2426 /// to the manifest while sando's walker hashed both files in it — and the
2427 /// honest bundle was refused for a manifest that had omitted them.
2428 #[test]
2429 fn a_collected_bundle_manifests_exactly_as_the_verifier_will_read_it() {
2430 let dir = tempfile::tempdir().unwrap();
2431 write_bundle_fixture(dir.path());
2432
2433 let files = collected_files(dir.path()).unwrap();
2434 assert_eq!(
2435 files.iter().map(|(r, _)| r.as_str()).collect::<Vec<_>>(),
2436 vec!["migrations/001_init.sql", "migrations/002_next.sql", "pom"],
2437 "recursive, relative, sorted"
2438 );
2439
2440 let hashes: Vec<(String, String)> = files
2441 .into_iter()
2442 .map(|(rel, path)| (rel, sha256_file(&path).unwrap()))
2443 .collect();
2444 let manifest = ops_artifact::Manifest::new(hashes).unwrap();
2445 assert_eq!(manifest.to_text(), BUNDLE_FIXTURE_MANIFEST);
2446 }
2447
2448 /// A symlink is neither followed nor named. Following one would let bytes
2449 /// from outside the bundle into its identity; naming it would put a path in
2450 /// the manifest the verifier does not hash, which reads as a corrupt bundle.
2451 #[test]
2452 #[cfg(unix)]
2453 fn a_symlink_in_the_collect_dir_is_not_part_of_the_bundle() {
2454 let dir = tempfile::tempdir().unwrap();
2455 write_bundle_fixture(dir.path());
2456 let outside = dir.path().join("..").join("secret.env");
2457 std::fs::write(&outside, b"TOKEN=1").ok();
2458 std::os::unix::fs::symlink(&outside, dir.path().join("link.env")).unwrap();
2459
2460 let files = collected_files(dir.path()).unwrap();
2461 assert!(
2462 !files.iter().any(|(rel, _)| rel.contains("link.env")),
2463 "{files:?}"
2464 );
2465 }
2466
2467 /// Run 3 S1: once the cooperative cancel flag is set (a newer build
2468 /// superseded this run), a step boundary refuses to proceed — the blocking
2469 /// recipe stops at the next `step()` instead of running on and publishing.
2470 #[tokio::test]
2471 async fn begin_step_bails_when_cancelled() {
2472 let dir = tempfile::tempdir().unwrap();
2473 let cfg = Arc::new(Config::for_tests(dir.path()));
2474 let pool = crate::db::open(&cfg.db_path).await.unwrap();
2475 let cancel = Arc::new(AtomicBool::new(true));
2476 let ctx = Arc::new(RecipeCtx::new(
2477 AppId::new("demo"),
2478 Version::parse("0.1.0").unwrap(),
2479 "linux/x86_64".parse().unwrap(),
2480 "fw13".into(),
2481 "local".into(),
2482 "v0.1.0".into(),
2483 "/tmp".into(),
2484 vec![],
2485 Kind::App,
2486 1,
2487 Arc::new(std::collections::HashMap::new()),
2488 Arc::new(std::collections::HashMap::new()),
2489 None,
2490 pool,
2491 crate::events::channel(),
2492 cfg,
2493 Arc::new(OtaRegistry::standard("https://makenot.work")),
2494 tokio::runtime::Handle::current(),
2495 cancel.clone(),
2496 None,
2497 ));
2498 // Cancelled: begin_step refuses before touching the DB (the ensure! is
2499 // ahead of any block_on, so this is safe to call from the async test).
2500 let err = ctx.begin_step(Step::Build).unwrap_err();
2501 assert!(err.to_string().contains("supersede"), "got: {err}");
2502 assert!(ctx.is_cancelled());
2503 }
2504
2505 /// `feature_flags()` returns a whole flag or nothing at all. An app with
2506 /// no declared features must not yield a bare `--features`, which would
2507 /// swallow the next word of the build command as its argument.
2508 #[tokio::test]
2509 async fn feature_flags_renders_whole_flag_or_empty() {
2510 async fn flags_for(features: Vec<String>) -> String {
2511 let dir = tempfile::tempdir().unwrap();
2512 let cfg = Arc::new(Config::for_tests(dir.path()));
2513 let pool = crate::db::open(&cfg.db_path).await.unwrap();
2514 let ctx = Arc::new(RecipeCtx::new(
2515 AppId::new("demo"),
2516 Version::parse("0.1.0").unwrap(),
2517 "linux/x86_64".parse().unwrap(),
2518 "fw13".into(),
2519 "local".into(),
2520 "v0.1.0".into(),
2521 "/tmp".into(),
2522 features,
2523 Kind::App,
2524 1,
2525 Arc::new(std::collections::HashMap::new()),
2526 Arc::new(std::collections::HashMap::new()),
2527 None,
2528 pool,
2529 crate::events::channel(),
2530 cfg,
2531 Arc::new(OtaRegistry::standard("https://makenot.work")),
2532 tokio::runtime::Handle::current(),
2533 Arc::new(AtomicBool::new(false)),
2534 None,
2535 ));
2536 let engine = build_engine(&ctx);
2537 engine.eval::<String>("feature_flags()").unwrap()
2538 }
2539
2540 assert_eq!(flags_for(vec![]).await, "");
2541 assert_eq!(
2542 flags_for(vec!["supernote".into()]).await,
2543 "--features supernote"
2544 );
2545 assert_eq!(
2546 flags_for(vec!["supernote".into(), "extra".into()]).await,
2547 "--features supernote,extra"
2548 );
2549 }
2550
2551 /// `repo()` answers for the host this target builds on, not for the daemon.
2552 ///
2553 /// This is what lets a Windows recipe call `repo()` and `checkout_sha(h)`
2554 /// instead of hard-coding `C:/Users/me/...` — and hard-coding it is what
2555 /// kept those recipes off the release-tag pin, since `checkout_sha` builds
2556 /// its git commands from the app's path and takes no override.
2557 #[tokio::test]
2558 async fn repo_resolves_per_build_host() {
2559 async fn repo_on(build_host: &str) -> String {
2560 let dir = tempfile::tempdir().unwrap();
2561 let cfg = Arc::new(Config::for_tests(dir.path()));
2562 let pool = crate::db::open(&cfg.db_path).await.unwrap();
2563 let ctx = Arc::new(
2564 RecipeCtx::new(
2565 AppId::new("demo"),
2566 Version::parse("0.1.0").unwrap(),
2567 "linux/x86_64".parse().unwrap(),
2568 build_host.into(),
2569 "local".into(),
2570 "v0.1.0".into(),
2571 "~/Code/Apps/demo".into(),
2572 vec![],
2573 Kind::App,
2574 1,
2575 Arc::new(std::collections::HashMap::new()),
2576 Arc::new(std::collections::HashMap::new()),
2577 None,
2578 pool,
2579 crate::events::channel(),
2580 cfg,
2581 Arc::new(OtaRegistry::standard("https://makenot.work")),
2582 tokio::runtime::Handle::current(),
2583 Arc::new(AtomicBool::new(false)),
2584 None,
2585 )
2586 .with_repo_by_host(HashMap::from([(
2587 "windows-x86".to_string(),
2588 "C:/Users/me/Code/Apps/demo".to_string(),
2589 )])),
2590 );
2591 build_engine(&ctx).eval::<String>("repo()").unwrap()
2592 }
2593
2594 assert_eq!(repo_on("windows-x86").await, "C:/Users/me/Code/Apps/demo");
2595 assert_eq!(repo_on("fw13").await, "~/Code/Apps/demo");
2596 }
2597
2598 /// `secret(key)` reads a file under `secrets_root`, trims its trailing
2599 /// newline (the shape of a here-doc'd token file), and refuses any key that
2600 /// could escape the root. Covers the host-fn registered in `build_engine`.
2601 #[tokio::test]
2602 async fn secret_reads_under_root_and_blocks_traversal() {
2603 let dir = tempfile::tempdir().unwrap();
2604 let cfg = Config::for_tests(dir.path());
2605 // Seed a secret and one in a nested subdir; a trailing newline that the
2606 // read must strip.
2607 std::fs::create_dir_all(&cfg.secrets_root).unwrap();
2608 std::fs::write(cfg.secrets_root.join("token"), "s3cr3t\n").unwrap();
2609 std::fs::create_dir_all(cfg.secrets_root.join("app")).unwrap();
2610 std::fs::write(cfg.secrets_root.join("app").join("key"), "nested").unwrap();
2611 // Plant a file OUTSIDE the root that a traversal key would reach.
2612 std::fs::write(dir.path().join("outside"), "leak").unwrap();
2613
2614 let cfg = Arc::new(cfg);
2615 let pool = crate::db::open(&cfg.db_path).await.unwrap();
2616 let ctx = Arc::new(RecipeCtx::new(
2617 AppId::new("demo"),
2618 Version::parse("0.1.0").unwrap(),
2619 "linux/x86_64".parse().unwrap(),
2620 "fw13".into(),
2621 "local".into(),
2622 "v0.1.0".into(),
2623 "/tmp".into(),
2624 vec![],
2625 Kind::App,
2626 1,
2627 Arc::new(std::collections::HashMap::new()),
2628 Arc::new(std::collections::HashMap::new()),
2629 None,
2630 pool,
2631 crate::events::channel(),
2632 cfg,
2633 Arc::new(OtaRegistry::standard("https://makenot.work")),
2634 tokio::runtime::Handle::current(),
2635 Arc::new(AtomicBool::new(false)),
2636 None,
2637 ));
2638 let engine = build_engine(&ctx);
2639
2640 // Happy path: read + trim.
2641 assert_eq!(
2642 engine.eval::<String>(r#"secret("token")"#).unwrap(),
2643 "s3cr3t"
2644 );
2645 // A multi-segment relative key is allowed.
2646 assert_eq!(
2647 engine.eval::<String>(r#"secret("app/key")"#).unwrap(),
2648 "nested"
2649 );
2650
2651 // Traversal, absolute paths, and empty keys are refused BEFORE any read,
2652 // so the file one `..` above the root is never disclosed.
2653 for bad in [
2654 r#"secret("../outside")"#,
2655 r#"secret("/etc/passwd")"#,
2656 r#"secret("")"#,
2657 ] {
2658 let err = engine.eval::<String>(bad).unwrap_err().to_string();
2659 assert!(
2660 err.contains("relative path under secrets_root"),
2661 "`{bad}` should hit the traversal guard, got: {err}"
2662 );
2663 }
2664 // A missing key surfaces the filesystem error, not a panic, and does not
2665 // trip the traversal guard (it is a legitimate relative path).
2666 let err = engine
2667 .eval::<String>(r#"secret("nope")"#)
2668 .unwrap_err()
2669 .to_string();
2670 assert!(err.contains("secret `nope`"), "got: {err}");
2671 }
2672
2673 /// The two failures that actually shipped, as regression cases.
2674 #[test]
2675 fn preflight_catches_a_dead_repository_url() {
2676 // pter 0.1.0: repository pointed at a URL that does not exist. It
2677 // published clean and the link is now permanent for that version.
2678 let meta = CrateMeta {
2679 name: "pter".into(),
2680 version: "0.1.0".into(),
2681 repository: Some("https://github.com/maxjacobson/pter".into()),
2682 description: Some("d".into()),
2683 licensed: true,
2684 };
2685 let problems = crate_publish_problems(&meta, false, &[], true);
2686 assert_eq!(problems.len(), 1, "{problems:?}");
2687 assert!(
2688 problems[0].contains("not publicly clonable"),
2689 "{problems:?}"
2690 );
2691
2692 // Same metadata, reachable URL: nothing to report.
2693 assert!(crate_publish_problems(&meta, true, &[], true).is_empty());
2694 }
2695
2696 #[test]
2697 fn preflight_requires_the_fields_crates_io_bakes_in() {
2698 let bare = CrateMeta {
2699 name: "x".into(),
2700 version: "0.1.0".into(),
2701 repository: None,
2702 description: None,
2703 licensed: false,
2704 };
2705 let problems = crate_publish_problems(&bare, false, &[], true);
2706 assert_eq!(problems.len(), 3, "{problems:?}");
2707 assert!(problems.iter().any(|p| p.contains("repository")));
2708 assert!(problems.iter().any(|p| p.contains("description")));
2709 assert!(problems.iter().any(|p| p.contains("license")));
2710 }
2711
2712 // A library's verify is a crate preflight, not a Gatekeeper check on a
2713 // signed bundle. Gating it on `gatekeeper` asked a Linux host for a macOS
2714 // code-signing capability it can never hold, so the step was denied before
2715 // it ran a command; the denial then surfaced as "no crates.io credentials",
2716 // which is not what went wrong. The only way to satisfy the old gate was to
2717 // declare the capability falsely in the topology.
2718 #[test]
2719 fn a_library_verify_is_not_gated_on_gatekeeper() {
2720 assert_eq!(
2721 action_for(Step::Verify, Kind::Library),
2722 Action::Build,
2723 "a crate preflight runs the build toolchain; that is what it needs",
2724 );
2725 assert_eq!(
2726 action_for(Step::Verify, Kind::App),
2727 Action::Observe(ObserveKind::Custom("gatekeeper".into())),
2728 "an app's verify still proves the bundle is signed and notarized",
2729 );
2730 }
2731
2732 // The capability the default host grant actually carries. Without this the
2733 // fix above is only true by inspection.
2734 #[test]
2735 fn a_default_host_can_run_a_library_verify_and_not_an_app_one() {
2736 let caps =
2737 ops_exec::CapabilitySet::from_tokens(["build", "package"], ["build-log", "artifact"]);
2738 assert!(caps.permits(&action_for(Step::Verify, Kind::Library)));
2739 assert!(!caps.permits(&action_for(Step::Verify, Kind::App)));
2740 }
2741
2742 // Every other step is a property of the step alone; verify is the one that
2743 // depends on what is being released.
2744 #[test]
2745 fn no_other_step_changes_with_the_kind() {
2746 for step in [
2747 Step::Checkout,
2748 Step::Prebuild,
2749 Step::Build,
2750 Step::Sign,
2751 Step::Notarize,
2752 Step::Staple,
2753 Step::Package,
2754 Step::Publish,
2755 Step::Collect,
2756 ] {
2757 assert_eq!(
2758 action_for(step, Kind::App),
2759 action_for(step, Kind::Library),
2760 "{step:?} should not depend on the kind",
2761 );
2762 }
2763 }
2764
2765 #[test]
2766 fn preflight_rejects_republishing_the_same_version() {
2767 let meta = CrateMeta {
2768 name: "makeover".into(),
2769 version: "0.10.0".into(),
2770 repository: Some("https://git.sr.ht/~maxmj/makeover".into()),
2771 description: Some("d".into()),
2772 licensed: true,
2773 };
2774 let problems =
2775 crate_publish_problems(&meta, true, &["0.9.0".into(), "0.10.0".into()], true);
2776 assert_eq!(problems.len(), 1, "{problems:?}");
2777 assert!(problems[0].contains("already published"), "{problems:?}");
2778
2779 // An unreleased version against the same history is fine.
2780 let mut next = meta.clone();
2781 next.version = "0.11.0".into();
2782 assert!(crate_publish_problems(&next, true, &["0.10.0".into()], true).is_empty());
2783 }
2784
2785 /// Missing credentials must surface at preflight, not at the upload. The
2786 /// publish step is the irreversible one and runs last, after a full build
2787 /// and verify; discovering there that cargo cannot authenticate wastes the
2788 /// whole run.
2789 #[test]
2790 fn preflight_reports_missing_credentials_up_front() {
2791 let meta = CrateMeta {
2792 name: "makeover".into(),
2793 version: "0.11.0".into(),
2794 repository: Some("https://git.sr.ht/~maxmj/makeover".into()),
2795 description: Some("d".into()),
2796 licensed: true,
2797 };
2798 // Metadata is perfect; only the token is absent.
2799 let problems = crate_publish_problems(&meta, true, &[], false);
2800 assert_eq!(problems.len(), 1, "{problems:?}");
2801 assert!(problems[0].contains("credentials"), "{problems:?}");
2802 assert!(
2803 problems[0].contains("cargo login"),
2804 "should say how to fix it"
2805 );
2806
2807 // Present: nothing to report.
2808 assert!(crate_publish_problems(&meta, true, &[], true).is_empty());
2809 }
2810
2811 #[test]
2812 fn crate_meta_reads_cargo_metadata_json() {
2813 let raw = r#"{"packages":[{"name":"makeover","version":"0.10.0",
2814 "repository":"https://git.sr.ht/~maxmj/makeover","description":"themes",
2815 "license":"MIT"}]}"#;
2816 let m = crate_meta_from_json(raw).unwrap();
2817 assert_eq!(m.name, "makeover");
2818 assert_eq!(m.version, "0.10.0");
2819 assert!(m.licensed);
2820 assert_eq!(
2821 m.repository.as_deref(),
2822 Some("https://git.sr.ht/~maxmj/makeover")
2823 );
2824
2825 // license_file alone also counts as licensed; empty strings do not
2826 // count as present.
2827 let lf = r#"{"packages":[{"name":"x","version":"0.1.0","license":"",
2828 "license_file":"LICENSE","description":""}]}"#;
2829 let m = crate_meta_from_json(lf).unwrap();
2830 assert!(m.licensed);
2831 assert!(m.description.is_none());
2832 }
2833
2834 /// Reads the ambient `HOME` rather than setting one. `set_var` is
2835 /// process-global and unsynchronized, so a test that overwrote HOME changed
2836 /// it for every other test in the binary — which is what silently disabled
2837 /// `topology::live_config_smoke` (it skips when `$HOME/.config/bento` is
2838 /// absent, and `/home/test` always is).
2839 #[test]
2840 fn expand_tilde_handles_home() {
2841 let home = PathBuf::from(std::env::var("HOME").expect("HOME is set"));
2842 assert_eq!(expand_tilde("~/Code/x"), home.join("Code/x"));
2843 assert_eq!(expand_tilde("/abs/path"), PathBuf::from("/abs/path"));
2844 }
2845
2846 // ---- artifact resolution (the M3 silent-`sh` fix) ----
2847
2848 #[test]
2849 fn resolve_artifact_match_wants_exactly_one() {
2850 // Exactly one match: the path, trimmed of the listing's line noise.
2851 assert_eq!(
2852 resolve_artifact_match(" /d/App.AppImage \n", "*.AppImage", true).unwrap(),
2853 "/d/App.AppImage"
2854 );
2855 }
2856
2857 #[test]
2858 fn resolve_artifact_match_zero_depends_on_required() {
2859 // Required + zero matches is the case the old empty-string guard caught;
2860 // keep failing it.
2861 let err = resolve_artifact_match("", "*.dmg", true).unwrap_err();
2862 assert!(err.to_string().contains("no artifact matched"), "{err}");
2863 // Optional + zero matches resolves to empty (recipe skips the collect).
2864 assert_eq!(
2865 resolve_artifact_match("\n \n", "*.deb", false).unwrap(),
2866 ""
2867 );
2868 }
2869
2870 #[test]
2871 fn resolve_artifact_match_rejects_ambiguous() {
2872 // Two matches must throw rather than silently pick one — this is the
2873 // stale-newest-mtime hole the audit flagged. Applies even when optional.
2874 for required in [true, false] {
2875 let err =
2876 resolve_artifact_match("/d/old.deb\n/d/new.deb\n", "*.deb", required).unwrap_err();
2877 let msg = err.to_string();
2878 assert!(msg.contains("ambiguous"), "{msg}");
2879 assert!(msg.contains("old.deb") && msg.contains("new.deb"), "{msg}");
2880 }
2881 }
2882
2883 #[test]
2884 fn ensure_glob_safe_allows_paths_bars_commands() {
2885 // Path and wildcard characters pass.
2886 assert!(ensure_glob_safe("~/Code/app/dist/*.AppImage").is_ok());
2887 assert!(ensure_glob_safe("/t/App_1.2.3-x86_64.dmg").is_ok());
2888 // A command substitution or separator does not.
2889 for bad in ["*.dmg; rm -rf /", "$(evil)", "a|b", "a b"] {
2890 assert!(ensure_glob_safe(bad).is_err(), "should reject {bad:?}");
2891 }
2892 }
2893
2894 // ---- version resolution ----
2895
2896 #[test]
2897 fn version_from_tauri_json_reads_version() {
2898 assert_eq!(
2899 version_from_tauri_json(r#"{"version":"0.4.2"}"#).unwrap(),
2900 "0.4.2"
2901 );
2902 assert!(version_from_tauri_json(r#"{"productName":"X"}"#).is_err());
2903 }
2904
2905 #[test]
2906 fn version_from_cargo_toml_prefers_package_then_workspace() {
2907 // A leaf crate's [package].version.
2908 assert_eq!(
2909 version_from_cargo_toml("[package]\nname = \"x\"\nversion = \"0.5.0\"\n").unwrap(),
2910 "0.5.0"
2911 );
2912 // A workspace that sets [workspace.package].version.
2913 assert_eq!(
2914 version_from_cargo_toml("[workspace.package]\nversion = \"1.2.3\"\n").unwrap(),
2915 "1.2.3"
2916 );
2917 // No version anywhere -> error, not a panic.
2918 assert!(version_from_cargo_toml("[workspace]\nmembers = []\n").is_err());
2919 }
2920
2921 #[test]
2922 fn version_from_repo_default_and_explicit_paths() {
2923 let tmp = tempfile::tempdir().unwrap();
2924 let root = tmp.path();
2925
2926 // Tauri app: default path reads src-tauri/tauri.conf.json.
2927 let tauri = root.join("tauri");
2928 std::fs::create_dir_all(tauri.join("src-tauri")).unwrap();
2929 std::fs::write(
2930 tauri.join("src-tauri/tauri.conf.json"),
2931 r#"{"version":"0.4.2"}"#,
2932 )
2933 .unwrap();
2934 assert_eq!(
2935 version_from_repo(tauri.to_str().unwrap(), None)
2936 .unwrap()
2937 .to_string(),
2938 "0.4.2"
2939 );
2940
2941 // Workspace egui app: no tauri.conf.json, explicit version_path at a member crate.
2942 let ws = root.join("ws");
2943 std::fs::create_dir_all(ws.join("crates/app")).unwrap();
2944 std::fs::write(
2945 ws.join("Cargo.toml"),
2946 "[workspace]\nmembers = [\"crates/app\"]\n",
2947 )
2948 .unwrap();
2949 std::fs::write(
2950 ws.join("crates/app/Cargo.toml"),
2951 "[package]\nname = \"app\"\nversion = \"0.5.0\"\n",
2952 )
2953 .unwrap();
2954 assert_eq!(
2955 version_from_repo(ws.to_str().unwrap(), Some("crates/app/Cargo.toml"))
2956 .unwrap()
2957 .to_string(),
2958 "0.5.0"
2959 );
2960 }
2961
2962 // ---- version-source cross-check (drift preflight) ----
2963
2964 fn ver(s: &str) -> Version {
2965 Version::parse(s).unwrap()
2966 }
2967
2968 #[test]
2969 fn version_consistency_passes_when_all_sources_agree() {
2970 let tmp = tempfile::tempdir().unwrap();
2971 let repo = tmp.path();
2972 std::fs::create_dir_all(repo.join("src-tauri")).unwrap();
2973 std::fs::write(
2974 repo.join("src-tauri/tauri.conf.json"),
2975 r#"{"version":"0.5.0"}"#,
2976 )
2977 .unwrap();
2978 std::fs::write(
2979 repo.join("Cargo.toml"),
2980 "[package]\nname = \"app\"\nversion = \"0.5.0\"\n",
2981 )
2982 .unwrap();
2983 check_version_consistency(repo.to_str().unwrap(), None, &ver("0.5.0")).unwrap();
2984 }
2985
2986 #[test]
2987 fn version_consistency_flags_tauri_vs_cargo_drift() {
2988 // The concrete finding: tauri.conf.json bumped to 0.5.0 but the root
2989 // Cargo.toml left at 0.4.0. version_from_repo (one file) would miss it.
2990 let tmp = tempfile::tempdir().unwrap();
2991 let repo = tmp.path();
2992 std::fs::create_dir_all(repo.join("src-tauri")).unwrap();
2993 std::fs::write(
2994 repo.join("src-tauri/tauri.conf.json"),
2995 r#"{"version":"0.5.0"}"#,
2996 )
2997 .unwrap();
2998 std::fs::write(
2999 repo.join("Cargo.toml"),
3000 "[package]\nname = \"app\"\nversion = \"0.4.0\"\n",
3001 )
3002 .unwrap();
3003 let err =
3004 check_version_consistency(repo.to_str().unwrap(), None, &ver("0.5.0")).unwrap_err();
3005 let msg = format!("{err:#}");
3006 assert!(msg.contains("Cargo.toml says 0.4.0"), "{msg}");
3007 }
3008
3009 #[test]
3010 fn version_consistency_flags_explicit_version_the_repo_does_not_reflect() {
3011 let tmp = tempfile::tempdir().unwrap();
3012 let repo = tmp.path();
3013 std::fs::create_dir_all(repo.join("src-tauri")).unwrap();
3014 std::fs::write(
3015 repo.join("src-tauri/tauri.conf.json"),
3016 r#"{"version":"0.5.0"}"#,
3017 )
3018 .unwrap();
3019 let err =
3020 check_version_consistency(repo.to_str().unwrap(), None, &ver("9.9.9")).unwrap_err();
3021 assert!(format!("{err:#}").contains("building 9.9.9"));
3022 }
3023
3024 #[test]
3025 fn version_consistency_single_source_never_invents_drift() {
3026 // A virtual-workspace root Cargo.toml (no version) alongside the member
3027 // crate the version_path points at: only one real source, so no drift.
3028 let tmp = tempfile::tempdir().unwrap();
3029 let repo = tmp.path();
3030 std::fs::create_dir_all(repo.join("crates/app")).unwrap();
3031 std::fs::write(
3032 repo.join("Cargo.toml"),
3033 "[workspace]\nmembers = [\"crates/app\"]\n",
3034 )
3035 .unwrap();
3036 std::fs::write(
3037 repo.join("crates/app/Cargo.toml"),
3038 "[package]\nname = \"app\"\nversion = \"0.5.0\"\n",
3039 )
3040 .unwrap();
3041 check_version_consistency(
3042 repo.to_str().unwrap(),
3043 Some("crates/app/Cargo.toml"),
3044 &ver("0.5.0"),
3045 )
3046 .unwrap();
3047 }
3048
3049 // ---- artifact filename version assertion + hashing ----
3050
3051 #[test]
3052 fn versions_in_filename_extracts_only_real_semvers() {
3053 assert_eq!(
3054 versions_in_filename("GoingsOn_0.5.0_aarch64.dmg"),
3055 vec![ver("0.5.0")]
3056 );
3057 assert_eq!(
3058 versions_in_filename("AudioFiles-0.4.0-x86_64.AppImage"),
3059 vec![ver("0.4.0")]
3060 );
3061 // No three-part token ⇒ nothing (an updater manifest, a bare signature).
3062 assert!(versions_in_filename("latest.json").is_empty());
3063 assert!(versions_in_filename("app.sig").is_empty());
3064 }
3065
3066 #[test]
3067 fn assert_artifact_version_rejects_a_stale_artifact() {
3068 // The 0.4.0 file sitting in the output dir against a 0.5.0 build.
3069 let err =
3070 assert_artifact_version("AudioFiles-0.4.0-x86_64.AppImage", &ver("0.5.0")).unwrap_err();
3071 assert!(format!("{err:#}").contains("stale artifact"), "{err:#}");
3072 // The matching version passes, and a versionless file is not asserted.
3073 assert_artifact_version("GoingsOn_0.5.0_aarch64.dmg", &ver("0.5.0")).unwrap();
3074 assert_artifact_version("latest.json", &ver("0.5.0")).unwrap();
3075 }
3076
3077 /// The comparison that decides whether a binary can exec on the box that is
3078 /// about to be restarted onto it. Both sides are parsed out of text a tool
3079 /// printed, so both parsers are worth pinning: fw13 tracks a newer glibc
3080 /// than the Ubuntu 24.04 host in Hetzner, and getting this backwards means a
3081 /// dead unit rather than a failed step.
3082 #[test]
3083 fn glibc_versions_parse_from_what_the_tools_actually_print() {
3084 // `objdump -T | grep -o 'GLIBC_[0-9.]*'` output: highest wins, and the
3085 // comparison is numeric (2.9 must not beat 2.34 lexically).
3086 let objdump = "GLIBC_2.2.5\nGLIBC_2.34\nGLIBC_2.9\nGLIBC_2.17\n";
3087 assert_eq!(max_glibc_symbol(objdump), Some((2, 34)));
3088 // A static binary references none: nothing to check.
3089 assert_eq!(max_glibc_symbol(""), None);
3090
3091 // `ldd --version` first line, however the distro decorates it.
3092 assert_eq!(
3093 glibc_from_ldd("ldd (Ubuntu GLIBC 2.39-0ubuntu8.8) 2.39\nCopyright...\n"),
3094 Some((2, 39))
3095 );
3096 assert_eq!(
3097 glibc_from_ldd("ldd (GNU libc) 2.41\nCopyright (C) 2025\n"),
3098 Some((2, 41))
3099 );
3100 assert_eq!(glibc_from_ldd(""), None);
3101 }
3102
3103 /// A binary needing MORE than the host has is the failure this check exists
3104 /// for; equal and less are both fine (glibc symbol versioning is backward
3105 /// compatible, so an older requirement runs on a newer host).
3106 #[test]
3107 fn glibc_requirement_is_satisfied_by_equal_or_newer_only() {
3108 let needs = max_glibc_symbol("GLIBC_2.41").unwrap();
3109 assert!(needs > glibc_from_ldd("ldd (Ubuntu GLIBC 2.39) 2.39").unwrap());
3110 assert!(needs <= glibc_from_ldd("ldd (GNU libc) 2.41").unwrap());
3111 assert!(needs <= glibc_from_ldd("ldd (GNU libc) 2.42").unwrap());
3112 assert!(needs <= glibc_from_ldd("ldd (GNU libc) 3.0").unwrap());
3113 }
3114
3115 /// Every deploy host function fails with the app's KIND as the reason when
3116 /// there is no destination, rather than with a missing-host error from
3117 /// somewhere deeper. A recipe calling `deploy()` on a library is a recipe
3118 /// written against the wrong kind, and the message should say so.
3119 #[tokio::test]
3120 async fn deploy_host_fns_explain_a_missing_destination_by_kind() {
3121 let dir = tempfile::tempdir().unwrap();
3122 let cfg = Arc::new(Config::for_tests(dir.path()));
3123 let pool = crate::db::open(&cfg.db_path).await.unwrap();
3124 let ctx = Arc::new(RecipeCtx::new(
3125 AppId::new("demo"),
3126 Version::parse("0.1.0").unwrap(),
3127 "linux/x86_64".parse().unwrap(),
3128 "fw13".into(),
3129 "local".into(),
3130 "v0.1.0".into(),
3131 "/tmp".into(),
3132 vec![],
3133 Kind::Library,
3134 1,
3135 Arc::new(std::collections::HashMap::new()),
3136 Arc::new(std::collections::HashMap::new()),
3137 None,
3138 pool,
3139 crate::events::channel(),
3140 cfg,
3141 Arc::new(OtaRegistry::standard("https://makenot.work")),
3142 tokio::runtime::Handle::current(),
3143 Arc::new(AtomicBool::new(false)),
3144 None,
3145 ));
3146 let engine = build_engine(&ctx);
3147 for call in [
3148 "deploy_host()",
3149 "service_name()",
3150 "install_path()",
3151 "health_url()",
3152 r#"deploy("/tmp/x")"#,
3153 ] {
3154 let err = engine.eval::<String>(call).unwrap_err().to_string();
3155 assert!(
3156 err.contains("library") && err.contains("no deploy destination"),
3157 "`{call}` must fail on the kind, got: {err}"
3158 );
3159 }
3160 }
3161
3162 /// A service host is addressed on the DEPLOY plane whatever step is open.
3163 ///
3164 /// The subtle one. Actions are normally derived from the step, which is
3165 /// right for a build host — the step is what that host is being asked to do.
3166 /// A service host is granted `deploy`/`restart` and must never be granted
3167 /// `build`, so the same rule would have `glibc_check` ask it for `build`
3168 /// during a `verify` step and get denied for a reason unrelated to what was
3169 /// attempted. `verify` is the step that check belongs in, so without this
3170 /// routing the glibc gate cannot run at all.
3171 #[tokio::test]
3172 async fn a_service_host_is_addressed_on_the_deploy_plane_in_any_step() {
3173 let dir = tempfile::tempdir().unwrap();
3174 let cfg = Arc::new(Config::for_tests(dir.path()));
3175 let pool = crate::db::open(&cfg.db_path).await.unwrap();
3176 sqlx::query(
3177 "INSERT INTO builds (id, app, version, status, created_at) \
3178 VALUES (1, 'demo', '0.1.0', 'running', '2026-07-30T00:00:00Z')",
3179 )
3180 .execute(&pool)
3181 .await
3182 .unwrap();
3183 sqlx::query(
3184 "INSERT INTO target_runs (id, build_id, app, version, target, status, started_at) \
3185 VALUES (1, 1, 'demo', '0.1.0', 'linux/x86_64', 'running', '2026-07-30T00:00:00Z')",
3186 )
3187 .execute(&pool)
3188 .await
3189 .unwrap();
3190
3191 let deploy = crate::topology::DeployTarget {
3192 target: "linux/x86_64".parse().unwrap(),
3193 host: "local".into(),
3194 port: None,
3195 install_path: "/usr/local/bin/demo".into(),
3196 service: "demo.service".into(),
3197 health_url: None,
3198 };
3199 let mut execs: crate::state::ExecutorMap = std::collections::HashMap::new();
3200 execs.insert("local".into(), crate::state::build_deploy_executor(&deploy));
3201 // The service host's grant is exactly deploy + restart. If this ever
3202 // widens to include `build`, the test below stops proving anything.
3203 assert!(!execs["local"].capabilities().permits(&Action::Build));
3204 assert!(execs["local"].capabilities().permits(&Action::Deploy));
3205
3206 let ctx = Arc::new(RecipeCtx::new(
3207 AppId::new("demo"),
3208 Version::parse("0.1.0").unwrap(),
3209 "linux/x86_64".parse().unwrap(),
3210 "fw13".into(),
3211 "local".into(),
3212 "v0.1.0".into(),
3213 "/tmp".into(),
3214 vec![],
3215 Kind::Service,
3216 1,
3217 Arc::new(execs),
3218 Arc::new(std::collections::HashMap::new()),
3219 Some(deploy),
3220 pool,
3221 crate::events::channel(),
3222 cfg,
3223 Arc::new(OtaRegistry::standard("https://makenot.work")),
3224 tokio::runtime::Handle::current(),
3225 Arc::new(AtomicBool::new(false)),
3226 None,
3227 ));
3228
3229 let ctx_blocking = ctx.clone();
3230 tokio::task::spawn_blocking(move || {
3231 // `verify` on a service derives Action::Build — which the service
3232 // host does not grant. The command must still run.
3233 ctx_blocking.begin_step(Step::Verify).unwrap();
3234 assert_eq!(
3235 action_for(Step::Verify, Kind::Service),
3236 Action::Build,
3237 "the step's own action is the one that would be denied",
3238 );
3239 let (code, out) = ctx_blocking
3240 .run("local", "echo reached-the-service-host")
3241 .expect("a service host must be reachable during a verify step");
3242 assert_eq!(code, 0, "{out}");
3243 assert!(out.contains("reached-the-service-host"), "{out}");
3244 })
3245 .await
3246 .unwrap();
3247 }
3248
3249 /// A step that finalized `Failed` bars the deploy, exactly as it bars a
3250 /// publish. Without this, a recipe that inspects `sh(...).code` and carries
3251 /// on regardless still lands a binary on a production host — the precise
3252 /// hazard a pipeline exists to remove. The check is the ledger, not the
3253 /// control flow, so it holds whether or not the recipe noticed.
3254 #[tokio::test]
3255 async fn a_failed_step_bars_the_deploy() {
3256 let dir = tempfile::tempdir().unwrap();
3257 let cfg = Arc::new(Config::for_tests(dir.path()));
3258 let pool = crate::db::open(&cfg.db_path).await.unwrap();
3259 let deploy = crate::topology::DeployTarget {
3260 target: "linux/x86_64".parse().unwrap(),
3261 host: "local".into(),
3262 port: None,
3263 install_path: "/usr/local/bin/demo".into(),
3264 service: "demo.service".into(),
3265 health_url: None,
3266 };
3267 // A real build + target run, so the step rows this test finalizes have
3268 // the parents the schema requires.
3269 sqlx::query(
3270 "INSERT INTO builds (id, app, version, status, created_at) \
3271 VALUES (1, 'demo', '0.1.0', 'running', '2026-07-30T00:00:00Z')",
3272 )
3273 .execute(&pool)
3274 .await
3275 .unwrap();
3276 sqlx::query(
3277 "INSERT INTO target_runs (id, build_id, app, version, target, status, started_at) \
3278 VALUES (1, 1, 'demo', '0.1.0', 'linux/x86_64', 'running', '2026-07-30T00:00:00Z')",
3279 )
3280 .execute(&pool)
3281 .await
3282 .unwrap();
3283
3284 let mut execs: crate::state::ExecutorMap = std::collections::HashMap::new();
3285 execs.insert("local".into(), crate::state::build_deploy_executor(&deploy));
3286 let ctx = Arc::new(RecipeCtx::new(
3287 AppId::new("demo"),
3288 Version::parse("0.1.0").unwrap(),
3289 "linux/x86_64".parse().unwrap(),
3290 "fw13".into(),
3291 "local".into(),
3292 "v0.1.0".into(),
3293 "/tmp".into(),
3294 vec![],
3295 Kind::Service,
3296 1,
3297 Arc::new(execs),
3298 Arc::new(std::collections::HashMap::new()),
3299 Some(deploy),
3300 pool,
3301 crate::events::channel(),
3302 cfg,
3303 Arc::new(OtaRegistry::standard("https://makenot.work")),
3304 tokio::runtime::Handle::current(),
3305 Arc::new(AtomicBool::new(false)),
3306 None,
3307 ));
3308
3309 // A gate ran, failed, and the recipe did not abort — the swallowed
3310 // failure. Finalizing it is what puts it in the ledger.
3311 let ctx_blocking = ctx.clone();
3312 tokio::task::spawn_blocking(move || {
3313 ctx_blocking.begin_step(Step::Prebuild).unwrap();
3314 ctx_blocking.fail_current_step();
3315 ctx_blocking.finish_step(Status::Ok).unwrap();
3316
3317 let err = ctx_blocking.deploy("/tmp/demo").unwrap_err().to_string();
3318 assert!(
3319 err.contains("refusing to deploy") && err.contains("prebuild"),
3320 "must refuse and name the failed step, got: {err}"
3321 );
3322 })
3323 .await
3324 .unwrap();
3325 }
3326
3327 #[test]
3328 fn every_step_has_a_nonzero_default_budget() {
3329 // A zero/missing budget would deadline-fail a step instantly. Cover the
3330 // whole matrix so a new Step variant can't silently get a 0 budget.
3331 for step in Step::ALL {
3332 assert!(
3333 default_step_budget(step) >= std::time::Duration::from_mins(1),
3334 "{step} budget must be a sane ceiling",
3335 );
3336 }
3337 }
3338
3339 #[test]
3340 fn sha256_file_is_lowercase_hex_of_contents() {
3341 let tmp = tempfile::tempdir().unwrap();
3342 let f = tmp.path().join("a.bin");
3343 std::fs::write(&f, b"abc").unwrap();
3344 // Known SHA-256 of "abc".
3345 assert_eq!(
3346 sha256_file(&f).unwrap(),
3347 "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
3348 );
3349 }
3350
3351 // ---- publish step-success gate ----
3352
3353 fn target(s: &str) -> Target {
3354 s.parse().unwrap()
3355 }
3356
3357 #[test]
3358 fn publish_gate_blocks_macos_without_verification() {
3359 // Never verified -> blocked, with a message pointing at verify_gatekeeper.
3360 let err = PublishAuthority::prove(target("macos/aarch64"), &[], None).unwrap_err();
3361 assert!(format!("{err:#}").contains("never verified"), "{err:#}");
3362 }
3363
3364 #[test]
3365 fn publish_gate_blocks_macos_when_gatekeeper_rejected() {
3366 let err = PublishAuthority::prove(target("macos/aarch64"), &[], Some(false)).unwrap_err();
3367 assert!(
3368 format!("{err:#}").contains("Gatekeeper rejected"),
3369 "{err:#}"
3370 );
3371 }
3372
3373 #[test]
3374 fn publish_gate_allows_macos_when_gatekeeper_accepted() {
3375 PublishAuthority::prove(target("macos/aarch64"), &[], Some(true)).unwrap();
3376 // iOS is gated the same way.
3377 PublishAuthority::prove(target("ios/universal"), &[], Some(true)).unwrap();
3378 assert!(PublishAuthority::prove(target("ios/universal"), &[], None).is_err());
3379 }
3380
3381 #[test]
3382 fn publish_gate_does_not_require_gatekeeper_for_non_apple_targets() {
3383 // Linux/Windows aren't notarized; no gatekeeper proof needed.
3384 PublishAuthority::prove(target("linux/x86_64"), &[], None).unwrap();
3385 PublishAuthority::prove(target("windows/x86_64"), &[], None).unwrap();
3386 }
3387
3388 #[test]
3389 fn publish_gate_blocks_when_any_prior_step_failed() {
3390 // A failed step bars publish on every target, even a verified macOS one.
3391 let err =
3392 PublishAuthority::prove(target("linux/x86_64"), &[Step::Build], None).unwrap_err();
3393 assert!(
3394 format!("{err:#}").contains("prior step(s) failed"),
3395 "{err:#}"
3396 );
3397 assert!(
3398 format!("{err:#}").contains("build"),
3399 "names the failed step: {err:#}"
3400 );
3401
3402 let err = PublishAuthority::prove(target("macos/aarch64"), &[Step::Sign], Some(true))
3403 .unwrap_err();
3404 assert!(
3405 format!("{err:#}").contains("prior step(s) failed"),
3406 "{err:#}"
3407 );
3408 }
3409
3410 #[test]
3411 fn notary_accepted_parses_status_field() {
3412 assert!(notary_accepted(
3413 r#"{"id":"abc","status":"Accepted","message":"ok"}"#
3414 ));
3415 // Embedded in shell-sourcing noise: the object is isolated and parsed.
3416 assert!(notary_accepted(
3417 "sourcing env...\n{\n \"status\": \"Accepted\"\n}\nbye"
3418 ));
3419 // Whitespace variant that a tight substring `"status":"Accepted"` misses.
3420 assert!(notary_accepted(r#"{ "status" : "Accepted" }"#));
3421 }
3422
3423 #[test]
3424 fn notary_accepted_rejects_non_accepted_and_garbage() {
3425 assert!(!notary_accepted(r#"{"status":"Invalid"}"#));
3426 assert!(!notary_accepted(r#"{"status":"In Progress"}"#));
3427 assert!(!notary_accepted("no json here"));
3428 assert!(!notary_accepted("")); // empty / truncated -> fail closed
3429 // A truncated tail whose opening brace was cut off cannot parse -> closed.
3430 assert!(!notary_accepted(r#""status":"Accepted"}"#));
3431 // The literal appearing inside an error string must NOT pass as success.
3432 assert!(!notary_accepted(
3433 r#"{"status":"Invalid","message":"expected status:Accepted"}"#
3434 ));
3435 }
3436 }
3437