Skip to main content

max / makenotwork

25.4 KB · 622 lines History Blame Raw
1 //! Registering the host functions a recipe may call.
2 //!
3 //! One function, because it is one act: the thirty names a `.rhai` file can
4 //! reach are the engine's whole API surface, and reading them as a list is the
5 //! point.
6
7 use super::RecipeCtx;
8 use super::crates_io;
9 use super::crates_io::{crate_meta_from_json, crate_publish_problems};
10 use super::git::{expand_tilde, tracked_lock_under_patch_cmd, tracked_lock_under_patch_problem};
11 use super::macos::register_macos_fns;
12 use super::rhai_err;
13 use crate::domain::Step;
14 use anyhow::Result;
15 use rhai::{Engine, EvalAltResult, Map};
16 use std::sync::Arc;
17
18 /// Build a Rhai engine with the host API bound to `ctx`. Sandboxed: recipes
19 /// touch the outside world only through these functions.
20 pub fn build_engine(ctx: &Arc<RecipeCtx>) -> Engine {
21 let mut engine = Engine::new();
22 // Defensive caps โ€” recipes are first-party but bound the blast radius.
23 engine.set_max_operations(5_000_000);
24 engine.set_max_call_levels(64);
25 engine.set_max_string_size(0);
26
27 // --- step(name) ---
28 {
29 let ctx = ctx.clone();
30 engine.register_fn(
31 "step",
32 move |name: &str| -> Result<(), Box<EvalAltResult>> {
33 let step: Step = name.parse().map_err(rhai_err)?;
34 ctx.begin_step(step).map_err(rhai_err)
35 },
36 );
37 }
38
39 // --- sh(host, cmd) -> #{ code, stdout_tail } ---
40 //
41 // The branch-on-exit-code primitive: the recipe OWNS the outcome. A non-zero
42 // exit is returned, not raised, and does NOT fail the step or bar publish โ€”
43 // use this only when the recipe inspects `code` and decides. For a command
44 // that must succeed (build/sign/etc.), use `sh_ok`, which fails the step (and
45 // therefore bars publish via the failed-step ledger) on a non-zero exit.
46 {
47 let ctx = ctx.clone();
48 engine.register_fn(
49 "sh",
50 move |host: &str, cmd: &str| -> Result<Map, Box<EvalAltResult>> {
51 let (code, tail) = ctx.run(host, cmd).map_err(rhai_err)?;
52 let mut m = Map::new();
53 m.insert("code".into(), (code as i64).into());
54 m.insert("stdout_tail".into(), tail.into());
55 Ok(m)
56 },
57 );
58 }
59
60 // --- sh_ok(host, cmd): run + assert exit 0 (the must-succeed primitive) ---
61 //
62 // A non-zero exit fails the current step (added to the publish-barring
63 // ledger) and aborts the recipe, so an artifact is never shipped after a
64 // must-succeed command failed.
65 {
66 let ctx = ctx.clone();
67 engine.register_fn(
68 "sh_ok",
69 move |host: &str, cmd: &str| -> Result<(), Box<EvalAltResult>> {
70 let (code, _) = ctx.run(host, cmd).map_err(rhai_err)?;
71 if code != 0 {
72 // Attribute the failure to the current step explicitly so the
73 // ledger bars publish even if a future caller swallowed the error.
74 ctx.fail_current_step();
75 return Err(rhai_err(format!(
76 "command on `{host}` exited {code}: {cmd}"
77 )));
78 }
79 Ok(())
80 },
81 );
82 }
83
84 // --- resolve_artifact(host, glob) -> path: the ONE artifact matching glob ---
85 //
86 // The artifact-selection primitive. Replaces `sh(host, "ls -t <glob> | head
87 // -1").stdout_tail.trim()` guarded on an empty string, which let a non-zero
88 // `ls` pass quietly and a stale newest-by-mtime file win. This resolves the
89 // glob on the host and demands exactly one match: zero matches or more than
90 // one both throw (an ambiguous match means the build left stale artifacts,
91 // and silently picking the newest is how the wrong bytes ship). Use
92 // `resolve_artifact_opt` for an artifact that may legitimately be absent.
93 {
94 let ctx = ctx.clone();
95 engine.register_fn(
96 "resolve_artifact",
97 move |host: &str, glob: &str| -> Result<String, Box<EvalAltResult>> {
98 ctx.resolve_artifact(host, glob, true).map_err(rhai_err)
99 },
100 );
101 }
102
103 // --- resolve_artifact_opt(host, glob) -> path | "": zero-or-one match ---
104 //
105 // Same strict resolution as `resolve_artifact` but tolerates zero matches
106 // (returns ""); more than one is still an error. For optional outputs like a
107 // `.deb` or an updater bundle a recipe collects only when present.
108 {
109 let ctx = ctx.clone();
110 engine.register_fn(
111 "resolve_artifact_opt",
112 move |host: &str, glob: &str| -> Result<String, Box<EvalAltResult>> {
113 ctx.resolve_artifact(host, glob, false).map_err(rhai_err)
114 },
115 );
116 }
117
118 // --- log(msg): operator-visible line into the current step's tail ---
119 {
120 let ctx = ctx.clone();
121 engine.register_fn("log", move |msg: &str| -> Result<(), Box<EvalAltResult>> {
122 let sink = ctx.ensure_step().map_err(rhai_err)?;
123 let line = format!("[recipe] {msg}\n");
124 ctx.rt.block_on(async {
125 use ops_core::remote::LogSink;
126 sink.lock().await.write_chunk(line.as_bytes()).await;
127 });
128 Ok(())
129 });
130 }
131
132 // --- version_of(app) -> string ---
133 {
134 let ctx = ctx.clone();
135 engine.register_fn(
136 "version_of",
137 move |app: &str| -> Result<String, Box<EvalAltResult>> {
138 // Only the current app is in scope; cross-app reads aren't needed.
139 if app != ctx.app.as_str() {
140 return Err(rhai_err(format!(
141 "version_of: `{app}` is not the app being built"
142 )));
143 }
144 Ok(ctx.version.to_string())
145 },
146 );
147 }
148
149 // --- version() -> string: the version being built (no-arg form) ---
150 {
151 let ctx = ctx.clone();
152 engine.register_fn("version", move || -> String { ctx.version.to_string() });
153 }
154
155 // --- build_host() -> string: the host this target builds on ---
156 {
157 let ctx = ctx.clone();
158 engine.register_fn("build_host", move || -> String { ctx.build_host.clone() });
159 }
160
161 // --- repo() -> string: the app's checkout path on this target's build host
162 // (`~`-prefixed on a unix host). Host-correct rather than one path per
163 // app, so a recipe for a host whose checkout is elsewhere still calls
164 // this instead of hard-coding the path โ€” which is what kept the Windows
165 // recipes off `checkout_sha`. ---
166 {
167 let ctx = ctx.clone();
168 engine.register_fn("repo", move || -> String {
169 ctx.repo_for(&ctx.build_host).to_string()
170 });
171 }
172
173 // --- checkout_sha(host) -> sha: pin this host to the release tag and report
174 // its commit. Replaces a recipe's `git pull --ff-only`, which builds
175 // whatever `main` is at pull time; the daemon also runs the same pin as
176 // a cross-host preflight barrier before any target builds. ---
177 {
178 let ctx = ctx.clone();
179 engine.register_fn(
180 "checkout_sha",
181 move |host: &str| -> Result<String, Box<EvalAltResult>> {
182 ctx.checkout_sha(host).map_err(rhai_err)
183 },
184 );
185 }
186
187 // --- crate_preflight() -> string: verify this crate is safe to publish,
188 // or abort the run. Everything it checks is immutable once published:
189 // crates.io versions can be yanked but never edited, so a wrong
190 // repository URL is permanent. pter 0.1.0 shipped with a dead one. ---
191 {
192 let ctx = ctx.clone();
193 engine.register_fn(
194 "crate_preflight",
195 move || -> Result<String, Box<EvalAltResult>> {
196 // `repo`, not `repo_for(...)`: `cargo metadata` runs on the
197 // daemon's own box, so this is the one checkout that is always
198 // the local one. It is not a missed call site.
199 let repo = expand_tilde(&ctx.repo);
200
201 let out = std::process::Command::new("cargo")
202 .args(["metadata", "--no-deps", "--format-version", "1"])
203 .current_dir(&repo)
204 .output()
205 .map_err(|e| format!("running cargo metadata in {}: {e}", repo.display()))?;
206 if !out.status.success() {
207 return Err(format!(
208 "cargo metadata failed in {}: {}",
209 repo.display(),
210 String::from_utf8_lossy(&out.stderr).trim()
211 )
212 .into());
213 }
214 let meta = crate_meta_from_json(&String::from_utf8_lossy(&out.stdout))
215 .map_err(|e| e.to_string())?;
216
217 // The real question is not whether a page renders but whether a
218 // stranger with no credentials can fetch the source, so ask git.
219 let clonable = meta.repository.as_ref().is_some_and(|url| {
220 std::process::Command::new("git")
221 .args(["ls-remote", url])
222 .env("GIT_TERMINAL_PROMPT", "0")
223 .output()
224 .is_ok_and(|o| o.status.success())
225 });
226
227 // Ask the publishing host whether cargo has credentials, rather
228 // than moving the token anywhere. It stays in cargo's own 0600
229 // store; a shell line carrying it would be visible in `ps`.
230 // An exit code answers "are there credentials"; an Err answers
231 // "the question could not be asked". Collapsing the second into
232 // the first reported a capability denial as "no crates.io
233 // credentials", which sent a real diagnosis three rounds the
234 // wrong way. A check that cannot run is not a failed check.
235 let creds =
236 ctx.run(
237 &ctx.build_host.clone(),
238 "cargo login --help >/dev/null 2>&1 && \
239 test -s \"${CARGO_HOME:-$HOME/.cargo}/credentials.toml\" \
240 || test -s \"${CARGO_HOME:-$HOME/.cargo}/credentials\"",
241 )
242 .map_err(|e| {
243 format!(
244 "could not check crates.io credentials on `{}`: {e}",
245 ctx.build_host
246 )
247 })?
248 .0 == 0;
249
250 // Asked of the build host rather than the daemon: the tree
251 // that gets published is the worktree over there, and it is the
252 // one whose `[patch]` ancestry decides this. An Err is "the
253 // question could not be asked" and is not a finding -- same
254 // rule as the credentials check above, for the same reason it
255 // was written that way.
256 let build_repo = ctx.repo_for(&ctx.build_host).to_string();
257 let patched_lock =
258 ctx.run(
259 &ctx.build_host.clone(),
260 &tracked_lock_under_patch_cmd(&build_repo),
261 )
262 .map_err(|e| {
263 format!(
264 "could not check for a tracked Cargo.lock on `{}`: {e}",
265 ctx.build_host
266 )
267 })?
268 .0 == 0;
269
270 let published = crates_io::published_versions(&meta.name);
271 let mut problems = crate_publish_problems(&meta, clonable, &published, creds);
272 if patched_lock {
273 problems.push(tracked_lock_under_patch_problem(&build_repo));
274 }
275 if !problems.is_empty() {
276 return Err(format!(
277 "{} {} is not safe to publish:\n - {}",
278 meta.name,
279 meta.version,
280 problems.join("\n - ")
281 )
282 .into());
283 }
284 Ok(format!("{} {} passed preflight", meta.name, meta.version))
285 },
286 );
287 }
288
289 // --- feature_flags() -> string: `--features a,b`, or "" when the app
290 // declares none. Returns the whole flag rather than a bare list so an
291 // app with no features cannot produce a dangling `--features`. ---
292 {
293 let ctx = ctx.clone();
294 engine.register_fn("feature_flags", move || -> String {
295 if ctx.features.is_empty() {
296 String::new()
297 } else {
298 format!("--features {}", ctx.features.join(","))
299 }
300 });
301 }
302
303 // --- target() / platform() / arch(): the target axis, for one per-platform
304 // recipe to branch on arch (bundle paths differ between x86_64/aarch64). ---
305 {
306 let ctx = ctx.clone();
307 engine.register_fn("target", move || -> String { ctx.target.to_string() });
308 }
309 {
310 let ctx = ctx.clone();
311 engine.register_fn("platform", move || -> String {
312 ctx.target.platform.as_str().to_string()
313 });
314 }
315 {
316 let ctx = ctx.clone();
317 engine.register_fn("arch", move || -> String {
318 ctx.target.arch.as_str().to_string()
319 });
320 }
321
322 // --- secret(key) -> string (file under secrets_root; never logged) ---
323 {
324 let ctx = ctx.clone();
325 engine.register_fn("secret", move |key: &str| -> Result<String, Box<EvalAltResult>> {
326 // Guard against traversal out of secrets_root. Require every path
327 // component to be `Normal` (rejects `..`, `.`, absolute roots and
328 // drive prefixes) and forbid backslashes (a literal filename char on
329 // Linux, but a separator elsewhere) โ€” the per-component strength of
330 // Sando's `safe()`. A multi-segment key like `app/token` is still
331 // allowed; `foo..bar` (a legit filename) is no longer falsely blocked.
332 let safe = !key.is_empty()
333 && !key.contains('\\')
334 && std::path::Path::new(key)
335 .components()
336 .all(|c| matches!(c, std::path::Component::Normal(_)));
337 if !safe {
338 return Err(rhai_err(
339 "secret key must be a relative path under secrets_root (no `..`, `.`, absolute paths, or backslashes)",
340 ));
341 }
342 let path = ctx.cfg.secrets_root.join(key);
343 std::fs::read_to_string(&path)
344 .map(|s| s.trim_end().to_string())
345 .map_err(|e| rhai_err(format!("secret `{key}`: {e}")))
346 });
347 }
348
349 // --- env(host, key) -> string ---
350 {
351 let ctx = ctx.clone();
352 engine.register_fn(
353 "env",
354 move |host: &str, key: &str| -> Result<String, Box<EvalAltResult>> {
355 // The key is interpolated into a `${...}` shell expansion, so it must
356 // be a bare shell identifier โ€” anything else (quotes, `}`, `$`, `;`)
357 // could break out and run arbitrary commands on the host. Validate
358 // before building the command; this is the one env read that can't
359 // sh-quote its argument (a quoted var name doesn't expand).
360 if key.is_empty()
361 || !key
362 .chars()
363 .next()
364 .is_some_and(|c| c == '_' || c.is_ascii_alphabetic())
365 || !key.chars().all(|c| c == '_' || c.is_ascii_alphanumeric())
366 {
367 return Err(rhai_err(format!(
368 "env name `{key}` must be a shell identifier ([A-Za-z_][A-Za-z0-9_]*)"
369 )));
370 }
371 // Read via the shell so it works on remote hosts too.
372 let (code, tail) = ctx
373 .run(host, &format!("printf '%s' \"${{{key}}}\""))
374 .map_err(rhai_err)?;
375 if code != 0 {
376 return Err(rhai_err(format!("env `{key}` on `{host}` failed")));
377 }
378 Ok(tail.trim().to_string())
379 },
380 );
381 }
382
383 // --- collect(host, glob, app, version): pull artifacts to dist_root ---
384 {
385 let ctx = ctx.clone();
386 engine.register_fn(
387 "collect",
388 move |host: &str,
389 glob: &str,
390 app: &str,
391 version: &str|
392 -> Result<(), Box<EvalAltResult>> {
393 ctx.collect(host, glob, app, version).map_err(rhai_err)
394 },
395 );
396 }
397
398 // --- publish(channel, app, target, version, artifact, meta) ---
399 {
400 let ctx = ctx.clone();
401 engine.register_fn(
402 "publish",
403 move |channel: &str,
404 app: &str,
405 target: &str,
406 version: &str,
407 artifact: &str,
408 meta: Map|
409 -> Result<String, Box<EvalAltResult>> {
410 ctx.publish(channel, app, target, version, artifact, &meta)
411 .map_err(rhai_err)
412 },
413 );
414 }
415
416 // --- deploy(binary) -> summary: install a service binary and restart its
417 // unit. The terminal step for `kind = "service"`, the counterpart of
418 // `publish` for something that is run rather than distributed.
419 //
420 // Takes only the binary's path on the build host: where it lands, on
421 // which machine, and which unit restarts all come from the `[[deploy]]`
422 // entry for the target already being built. A recipe cannot deploy the
423 // aarch64 binary to the x86_64 box by naming the wrong host, because it
424 // never names a host at all.
425 {
426 let ctx = ctx.clone();
427 engine.register_fn(
428 "deploy",
429 move |binary: &str| -> Result<String, Box<EvalAltResult>> {
430 ctx.deploy(binary).map_err(rhai_err)
431 },
432 );
433 }
434
435 // --- deploy_host() -> string: the service host's ssh destination, so a
436 // recipe can run its own assertions there (`sh_ok(deploy_host(), ...)`).
437 // Commands run through it while the `deploy` step is open, so they are
438 // gated on the deploy grant like the install itself. ---
439 {
440 let ctx = ctx.clone();
441 engine.register_fn(
442 "deploy_host",
443 move || -> Result<String, Box<EvalAltResult>> {
444 ctx.deploy_target()
445 .map(|d| d.host.clone())
446 .map_err(rhai_err)
447 },
448 );
449 }
450
451 // --- service_name() / install_path() / health_url(): the rest of the
452 // `[[deploy]]` entry, so a recipe asserts against the configured values
453 // rather than repeating them as literals that can drift. `health_url`
454 // is "" when unset. ---
455 {
456 let ctx = ctx.clone();
457 engine.register_fn(
458 "service_name",
459 move || -> Result<String, Box<EvalAltResult>> {
460 ctx.deploy_target()
461 .map(|d| d.service.clone())
462 .map_err(rhai_err)
463 },
464 );
465 }
466 {
467 let ctx = ctx.clone();
468 engine.register_fn(
469 "install_path",
470 move || -> Result<String, Box<EvalAltResult>> {
471 ctx.deploy_target()
472 .map(|d| d.install_path.clone())
473 .map_err(rhai_err)
474 },
475 );
476 }
477 {
478 let ctx = ctx.clone();
479 engine.register_fn(
480 "health_url",
481 move || -> Result<String, Box<EvalAltResult>> {
482 ctx.deploy_target()
483 .map(|d| d.health_url.clone().unwrap_or_default())
484 .map_err(rhai_err)
485 },
486 );
487 }
488
489 // --- glibc_check(binary) -> string: assert the build host did not produce
490 // a binary the service host's glibc is too old to exec. Aborts the run
491 // if it did; returns "needs X, host has Y" for the log if it did not.
492 //
493 // WHICH RECIPES CALL THIS, AND WHY THE OTHERS MUST NOT. The rule is not
494 // a style preference and it is not optional: this reads the recipe's
495 // `[[deploy]]` entry to learn which machine runs the bytes, so a recipe
496 // with no `[[deploy]]` cannot call it at all.
497 //
498 // - A service that installs ITSELF (`[[deploy]]` present: magicmirror,
499 // wam, mnw-cli) SHOULD call it. Bento is both builder and installer
500 // there, so it knows the service host, and nothing downstream will
501 // check on its behalf.
502 // - A service HANDED OFF to Sando (`[[deploy]]` absent: pom) MUST NOT,
503 // and the absence is the Sando/Bento boundary rather than an omission.
504 // Which machine runs the bytes is environment knowledge, which is
505 // Sando's half. Sando covers it on the far side, more strongly: it runs
506 // the node's own loader against the rsynced bytes before the symlink
507 // swap (`sando_daemon::deploy::ldd_guard_script`), and since 0.2.12
508 // also compares the bundle's glibc floor against the node's declared
509 // `libc` before the rsync (`check_bundle_fits_node`).
510 //
511 // So a new service recipe takes its answer from whether it carries a
512 // `[[deploy]]` table, not from whichever sibling recipe it was copied
513 // from. Wiki `sando-bento-boundary`, `host-base-images`. ---
514 {
515 let ctx = ctx.clone();
516 engine.register_fn(
517 "glibc_check",
518 move |binary: &str| -> Result<String, Box<EvalAltResult>> {
519 let (needs, has) = ctx.glibc_check(binary).map_err(rhai_err)?;
520 Ok(format!(
521 "glibc: binary needs {needs}, service host has {has}"
522 ))
523 },
524 );
525 }
526
527 // --- macOS signing helpers. They dispatch through the named host's
528 // executor like any other step; when that host is the mac (transport =
529 // "agent"), codesign/notarize/staple ride the in-session `AgentRpc`
530 // transport โ€” the only security session where the Developer ID key is
531 // usable (design ยง7 "THE WALL"). Capability-gated by the host's `sign`
532 // grant. ---
533 register_macos_fns(&mut engine, ctx);
534
535 engine
536 }
537
538 #[cfg(test)]
539 mod tests {
540 use super::*;
541 use crate::config::Config;
542 use crate::domain::{AppId, Version};
543 use crate::ota::OtaRegistry;
544 use crate::topology::Kind;
545 use std::sync::atomic::AtomicBool;
546
547 /// `secret(key)` reads a file under `secrets_root`, trims its trailing
548 /// newline (the shape of a here-doc'd token file), and refuses any key that
549 /// could escape the root. Covers the host-fn registered in `build_engine`.
550 #[tokio::test]
551 async fn secret_reads_under_root_and_blocks_traversal() {
552 let dir = tempfile::tempdir().unwrap();
553 let cfg = Config::for_tests(dir.path());
554 // Seed a secret and one in a nested subdir; a trailing newline that the
555 // read must strip.
556 std::fs::create_dir_all(&cfg.secrets_root).unwrap();
557 std::fs::write(cfg.secrets_root.join("token"), "s3cr3t\n").unwrap();
558 std::fs::create_dir_all(cfg.secrets_root.join("app")).unwrap();
559 std::fs::write(cfg.secrets_root.join("app").join("key"), "nested").unwrap();
560 // Plant a file OUTSIDE the root that a traversal key would reach.
561 std::fs::write(dir.path().join("outside"), "leak").unwrap();
562
563 let cfg = Arc::new(cfg);
564 let pool = crate::db::open(&cfg.db_path).await.unwrap();
565 let ctx = Arc::new(RecipeCtx::new(
566 AppId::new("demo"),
567 Version::parse("0.1.0").unwrap(),
568 "linux/x86_64".parse().unwrap(),
569 "fw13".into(),
570 "local".into(),
571 "v0.1.0".into(),
572 "/tmp".into(),
573 vec![],
574 Kind::App,
575 1,
576 Arc::new(std::collections::HashMap::new()),
577 Arc::new(std::collections::HashMap::new()),
578 None,
579 pool,
580 crate::events::channel(),
581 cfg,
582 Arc::new(OtaRegistry::standard("https://makenot.work")),
583 tokio::runtime::Handle::current(),
584 Arc::new(AtomicBool::new(false)),
585 None,
586 ));
587 let engine = build_engine(&ctx);
588
589 // Happy path: read + trim.
590 assert_eq!(
591 engine.eval::<String>(r#"secret("token")"#).unwrap(),
592 "s3cr3t"
593 );
594 // A multi-segment relative key is allowed.
595 assert_eq!(
596 engine.eval::<String>(r#"secret("app/key")"#).unwrap(),
597 "nested"
598 );
599
600 // Traversal, absolute paths, and empty keys are refused BEFORE any read,
601 // so the file one `..` above the root is never disclosed.
602 for bad in [
603 r#"secret("../outside")"#,
604 r#"secret("/etc/passwd")"#,
605 r#"secret("")"#,
606 ] {
607 let err = engine.eval::<String>(bad).unwrap_err().to_string();
608 assert!(
609 err.contains("relative path under secrets_root"),
610 "`{bad}` should hit the traversal guard, got: {err}"
611 );
612 }
613 // A missing key surfaces the filesystem error, not a panic, and does not
614 // trip the traversal guard (it is a legitimate relative path).
615 let err = engine
616 .eval::<String>(r#"secret("nope")"#)
617 .unwrap_err()
618 .to_string();
619 assert!(err.contains("secret `nope`"), "got: {err}");
620 }
621 }
622