//! Registering the host functions a recipe may call. //! //! One function, because it is one act: the thirty names a `.rhai` file can //! reach are the engine's whole API surface, and reading them as a list is the //! point. use super::RecipeCtx; use super::crates_io; use super::crates_io::{crate_meta_from_json, crate_publish_problems}; use super::git::{expand_tilde, tracked_lock_under_patch_cmd, tracked_lock_under_patch_problem}; use super::macos::register_macos_fns; use super::rhai_err; use crate::domain::Step; use anyhow::Result; use rhai::{Engine, EvalAltResult, Map}; use std::sync::Arc; /// Build a Rhai engine with the host API bound to `ctx`. Sandboxed: recipes /// touch the outside world only through these functions. pub fn build_engine(ctx: &Arc) -> Engine { let mut engine = Engine::new(); // Defensive caps — recipes are first-party but bound the blast radius. engine.set_max_operations(5_000_000); engine.set_max_call_levels(64); engine.set_max_string_size(0); // --- step(name) --- { let ctx = ctx.clone(); engine.register_fn( "step", move |name: &str| -> Result<(), Box> { let step: Step = name.parse().map_err(rhai_err)?; ctx.begin_step(step).map_err(rhai_err) }, ); } // --- sh(host, cmd) -> #{ code, stdout_tail } --- // // The branch-on-exit-code primitive: the recipe OWNS the outcome. A non-zero // exit is returned, not raised, and does NOT fail the step or bar publish — // use this only when the recipe inspects `code` and decides. For a command // that must succeed (build/sign/etc.), use `sh_ok`, which fails the step (and // therefore bars publish via the failed-step ledger) on a non-zero exit. { let ctx = ctx.clone(); engine.register_fn( "sh", move |host: &str, cmd: &str| -> Result> { let (code, tail) = ctx.run(host, cmd).map_err(rhai_err)?; let mut m = Map::new(); m.insert("code".into(), (code as i64).into()); m.insert("stdout_tail".into(), tail.into()); Ok(m) }, ); } // --- sh_ok(host, cmd): run + assert exit 0 (the must-succeed primitive) --- // // A non-zero exit fails the current step (added to the publish-barring // ledger) and aborts the recipe, so an artifact is never shipped after a // must-succeed command failed. { let ctx = ctx.clone(); engine.register_fn( "sh_ok", move |host: &str, cmd: &str| -> Result<(), Box> { let (code, _) = ctx.run(host, cmd).map_err(rhai_err)?; if code != 0 { // Attribute the failure to the current step explicitly so the // ledger bars publish even if a future caller swallowed the error. ctx.fail_current_step(); return Err(rhai_err(format!( "command on `{host}` exited {code}: {cmd}" ))); } Ok(()) }, ); } // --- resolve_artifact(host, glob) -> path: the ONE artifact matching glob --- // // The artifact-selection primitive. Replaces `sh(host, "ls -t | head // -1").stdout_tail.trim()` guarded on an empty string, which let a non-zero // `ls` pass quietly and a stale newest-by-mtime file win. This resolves the // glob on the host and demands exactly one match: zero matches or more than // one both throw (an ambiguous match means the build left stale artifacts, // and silently picking the newest is how the wrong bytes ship). Use // `resolve_artifact_opt` for an artifact that may legitimately be absent. { let ctx = ctx.clone(); engine.register_fn( "resolve_artifact", move |host: &str, glob: &str| -> Result> { ctx.resolve_artifact(host, glob, true).map_err(rhai_err) }, ); } // --- resolve_artifact_opt(host, glob) -> path | "": zero-or-one match --- // // Same strict resolution as `resolve_artifact` but tolerates zero matches // (returns ""); more than one is still an error. For optional outputs like a // `.deb` or an updater bundle a recipe collects only when present. { let ctx = ctx.clone(); engine.register_fn( "resolve_artifact_opt", move |host: &str, glob: &str| -> Result> { ctx.resolve_artifact(host, glob, false).map_err(rhai_err) }, ); } // --- log(msg): operator-visible line into the current step's tail --- { let ctx = ctx.clone(); engine.register_fn("log", move |msg: &str| -> Result<(), Box> { let sink = ctx.ensure_step().map_err(rhai_err)?; let line = format!("[recipe] {msg}\n"); ctx.rt.block_on(async { use ops_core::remote::LogSink; sink.lock().await.write_chunk(line.as_bytes()).await; }); Ok(()) }); } // --- version_of(app) -> string --- { let ctx = ctx.clone(); engine.register_fn( "version_of", move |app: &str| -> Result> { // Only the current app is in scope; cross-app reads aren't needed. if app != ctx.app.as_str() { return Err(rhai_err(format!( "version_of: `{app}` is not the app being built" ))); } Ok(ctx.version.to_string()) }, ); } // --- version() -> string: the version being built (no-arg form) --- { let ctx = ctx.clone(); engine.register_fn("version", move || -> String { ctx.version.to_string() }); } // --- build_host() -> string: the host this target builds on --- { let ctx = ctx.clone(); engine.register_fn("build_host", move || -> String { ctx.build_host.clone() }); } // --- repo() -> string: the app's checkout path on this target's build host // (`~`-prefixed on a unix host). Host-correct rather than one path per // app, so a recipe for a host whose checkout is elsewhere still calls // this instead of hard-coding the path — which is what kept the Windows // recipes off `checkout_sha`. --- { let ctx = ctx.clone(); engine.register_fn("repo", move || -> String { ctx.repo_for(&ctx.build_host).to_string() }); } // --- checkout_sha(host) -> sha: pin this host to the release tag and report // its commit. Replaces a recipe's `git pull --ff-only`, which builds // whatever `main` is at pull time; the daemon also runs the same pin as // a cross-host preflight barrier before any target builds. --- { let ctx = ctx.clone(); engine.register_fn( "checkout_sha", move |host: &str| -> Result> { ctx.checkout_sha(host).map_err(rhai_err) }, ); } // --- crate_preflight() -> string: verify this crate is safe to publish, // or abort the run. Everything it checks is immutable once published: // crates.io versions can be yanked but never edited, so a wrong // repository URL is permanent. pter 0.1.0 shipped with a dead one. --- { let ctx = ctx.clone(); engine.register_fn( "crate_preflight", move || -> Result> { // `repo`, not `repo_for(...)`: `cargo metadata` runs on the // daemon's own box, so this is the one checkout that is always // the local one. It is not a missed call site. let repo = expand_tilde(&ctx.repo); let out = std::process::Command::new("cargo") .args(["metadata", "--no-deps", "--format-version", "1"]) .current_dir(&repo) .output() .map_err(|e| format!("running cargo metadata in {}: {e}", repo.display()))?; if !out.status.success() { return Err(format!( "cargo metadata failed in {}: {}", repo.display(), String::from_utf8_lossy(&out.stderr).trim() ) .into()); } let meta = crate_meta_from_json(&String::from_utf8_lossy(&out.stdout)) .map_err(|e| e.to_string())?; // The real question is not whether a page renders but whether a // stranger with no credentials can fetch the source, so ask git. let clonable = meta.repository.as_ref().is_some_and(|url| { std::process::Command::new("git") .args(["ls-remote", url]) .env("GIT_TERMINAL_PROMPT", "0") .output() .is_ok_and(|o| o.status.success()) }); // Ask the publishing host whether cargo has credentials, rather // than moving the token anywhere. It stays in cargo's own 0600 // store; a shell line carrying it would be visible in `ps`. // An exit code answers "are there credentials"; an Err answers // "the question could not be asked". Collapsing the second into // the first reported a capability denial as "no crates.io // credentials", which sent a real diagnosis three rounds the // wrong way. A check that cannot run is not a failed check. let creds = ctx.run( &ctx.build_host.clone(), "cargo login --help >/dev/null 2>&1 && \ test -s \"${CARGO_HOME:-$HOME/.cargo}/credentials.toml\" \ || test -s \"${CARGO_HOME:-$HOME/.cargo}/credentials\"", ) .map_err(|e| { format!( "could not check crates.io credentials on `{}`: {e}", ctx.build_host ) })? .0 == 0; // Asked of the build host rather than the daemon: the tree // that gets published is the worktree over there, and it is the // one whose `[patch]` ancestry decides this. An Err is "the // question could not be asked" and is not a finding -- same // rule as the credentials check above, for the same reason it // was written that way. let build_repo = ctx.repo_for(&ctx.build_host).to_string(); let patched_lock = ctx.run( &ctx.build_host.clone(), &tracked_lock_under_patch_cmd(&build_repo), ) .map_err(|e| { format!( "could not check for a tracked Cargo.lock on `{}`: {e}", ctx.build_host ) })? .0 == 0; let published = crates_io::published_versions(&meta.name); let mut problems = crate_publish_problems(&meta, clonable, &published, creds); if patched_lock { problems.push(tracked_lock_under_patch_problem(&build_repo)); } if !problems.is_empty() { return Err(format!( "{} {} is not safe to publish:\n - {}", meta.name, meta.version, problems.join("\n - ") ) .into()); } Ok(format!("{} {} passed preflight", meta.name, meta.version)) }, ); } // --- feature_flags() -> string: `--features a,b`, or "" when the app // declares none. Returns the whole flag rather than a bare list so an // app with no features cannot produce a dangling `--features`. --- { let ctx = ctx.clone(); engine.register_fn("feature_flags", move || -> String { if ctx.features.is_empty() { String::new() } else { format!("--features {}", ctx.features.join(",")) } }); } // --- target() / platform() / arch(): the target axis, for one per-platform // recipe to branch on arch (bundle paths differ between x86_64/aarch64). --- { let ctx = ctx.clone(); engine.register_fn("target", move || -> String { ctx.target.to_string() }); } { let ctx = ctx.clone(); engine.register_fn("platform", move || -> String { ctx.target.platform.as_str().to_string() }); } { let ctx = ctx.clone(); engine.register_fn("arch", move || -> String { ctx.target.arch.as_str().to_string() }); } // --- secret(key) -> string (file under secrets_root; never logged) --- { let ctx = ctx.clone(); engine.register_fn("secret", move |key: &str| -> Result> { // Guard against traversal out of secrets_root. Require every path // component to be `Normal` (rejects `..`, `.`, absolute roots and // drive prefixes) and forbid backslashes (a literal filename char on // Linux, but a separator elsewhere) — the per-component strength of // Sando's `safe()`. A multi-segment key like `app/token` is still // allowed; `foo..bar` (a legit filename) is no longer falsely blocked. let safe = !key.is_empty() && !key.contains('\\') && std::path::Path::new(key) .components() .all(|c| matches!(c, std::path::Component::Normal(_))); if !safe { return Err(rhai_err( "secret key must be a relative path under secrets_root (no `..`, `.`, absolute paths, or backslashes)", )); } let path = ctx.cfg.secrets_root.join(key); std::fs::read_to_string(&path) .map(|s| s.trim_end().to_string()) .map_err(|e| rhai_err(format!("secret `{key}`: {e}"))) }); } // --- env(host, key) -> string --- { let ctx = ctx.clone(); engine.register_fn( "env", move |host: &str, key: &str| -> Result> { // The key is interpolated into a `${...}` shell expansion, so it must // be a bare shell identifier — anything else (quotes, `}`, `$`, `;`) // could break out and run arbitrary commands on the host. Validate // before building the command; this is the one env read that can't // sh-quote its argument (a quoted var name doesn't expand). if key.is_empty() || !key .chars() .next() .is_some_and(|c| c == '_' || c.is_ascii_alphabetic()) || !key.chars().all(|c| c == '_' || c.is_ascii_alphanumeric()) { return Err(rhai_err(format!( "env name `{key}` must be a shell identifier ([A-Za-z_][A-Za-z0-9_]*)" ))); } // Read via the shell so it works on remote hosts too. let (code, tail) = ctx .run(host, &format!("printf '%s' \"${{{key}}}\"")) .map_err(rhai_err)?; if code != 0 { return Err(rhai_err(format!("env `{key}` on `{host}` failed"))); } Ok(tail.trim().to_string()) }, ); } // --- collect(host, glob, app, version): pull artifacts to dist_root --- { let ctx = ctx.clone(); engine.register_fn( "collect", move |host: &str, glob: &str, app: &str, version: &str| -> Result<(), Box> { ctx.collect(host, glob, app, version).map_err(rhai_err) }, ); } // --- publish(channel, app, target, version, artifact, meta) --- { let ctx = ctx.clone(); engine.register_fn( "publish", move |channel: &str, app: &str, target: &str, version: &str, artifact: &str, meta: Map| -> Result> { ctx.publish(channel, app, target, version, artifact, &meta) .map_err(rhai_err) }, ); } // --- deploy(binary) -> summary: install a service binary and restart its // unit. The terminal step for `kind = "service"`, the counterpart of // `publish` for something that is run rather than distributed. // // Takes only the binary's path on the build host: where it lands, on // which machine, and which unit restarts all come from the `[[deploy]]` // entry for the target already being built. A recipe cannot deploy the // aarch64 binary to the x86_64 box by naming the wrong host, because it // never names a host at all. { let ctx = ctx.clone(); engine.register_fn( "deploy", move |binary: &str| -> Result> { ctx.deploy(binary).map_err(rhai_err) }, ); } // --- deploy_host() -> string: the service host's ssh destination, so a // recipe can run its own assertions there (`sh_ok(deploy_host(), ...)`). // Commands run through it while the `deploy` step is open, so they are // gated on the deploy grant like the install itself. --- { let ctx = ctx.clone(); engine.register_fn( "deploy_host", move || -> Result> { ctx.deploy_target() .map(|d| d.host.clone()) .map_err(rhai_err) }, ); } // --- service_name() / install_path() / health_url(): the rest of the // `[[deploy]]` entry, so a recipe asserts against the configured values // rather than repeating them as literals that can drift. `health_url` // is "" when unset. --- { let ctx = ctx.clone(); engine.register_fn( "service_name", move || -> Result> { ctx.deploy_target() .map(|d| d.service.clone()) .map_err(rhai_err) }, ); } { let ctx = ctx.clone(); engine.register_fn( "install_path", move || -> Result> { ctx.deploy_target() .map(|d| d.install_path.clone()) .map_err(rhai_err) }, ); } { let ctx = ctx.clone(); engine.register_fn( "health_url", move || -> Result> { ctx.deploy_target() .map(|d| d.health_url.clone().unwrap_or_default()) .map_err(rhai_err) }, ); } // --- glibc_check(binary) -> string: assert the build host did not produce // a binary the service host's glibc is too old to exec. Aborts the run // if it did; returns "needs X, host has Y" for the log if it did not. // // WHICH RECIPES CALL THIS, AND WHY THE OTHERS MUST NOT. The rule is not // a style preference and it is not optional: this reads the recipe's // `[[deploy]]` entry to learn which machine runs the bytes, so a recipe // with no `[[deploy]]` cannot call it at all. // // - A service that installs ITSELF (`[[deploy]]` present: magicmirror, // wam, mnw-cli) SHOULD call it. Bento is both builder and installer // there, so it knows the service host, and nothing downstream will // check on its behalf. // - A service HANDED OFF to Sando (`[[deploy]]` absent: pom) MUST NOT, // and the absence is the Sando/Bento boundary rather than an omission. // Which machine runs the bytes is environment knowledge, which is // Sando's half. Sando covers it on the far side, more strongly: it runs // the node's own loader against the rsynced bytes before the symlink // swap (`sando_daemon::deploy::ldd_guard_script`), and since 0.2.12 // also compares the bundle's glibc floor against the node's declared // `libc` before the rsync (`check_bundle_fits_node`). // // So a new service recipe takes its answer from whether it carries a // `[[deploy]]` table, not from whichever sibling recipe it was copied // from. Wiki `sando-bento-boundary`, `host-base-images`. --- { let ctx = ctx.clone(); engine.register_fn( "glibc_check", move |binary: &str| -> Result> { let (needs, has) = ctx.glibc_check(binary).map_err(rhai_err)?; Ok(format!( "glibc: binary needs {needs}, service host has {has}" )) }, ); } // --- macOS signing helpers. They dispatch through the named host's // executor like any other step; when that host is the mac (transport = // "agent"), codesign/notarize/staple ride the in-session `AgentRpc` // transport — the only security session where the Developer ID key is // usable (design §7 "THE WALL"). Capability-gated by the host's `sign` // grant. --- register_macos_fns(&mut engine, ctx); engine } #[cfg(test)] mod tests { use super::*; use crate::config::Config; use crate::domain::{AppId, Version}; use crate::ota::OtaRegistry; use crate::topology::Kind; use std::sync::atomic::AtomicBool; /// `secret(key)` reads a file under `secrets_root`, trims its trailing /// newline (the shape of a here-doc'd token file), and refuses any key that /// could escape the root. Covers the host-fn registered in `build_engine`. #[tokio::test] async fn secret_reads_under_root_and_blocks_traversal() { let dir = tempfile::tempdir().unwrap(); let cfg = Config::for_tests(dir.path()); // Seed a secret and one in a nested subdir; a trailing newline that the // read must strip. std::fs::create_dir_all(&cfg.secrets_root).unwrap(); std::fs::write(cfg.secrets_root.join("token"), "s3cr3t\n").unwrap(); std::fs::create_dir_all(cfg.secrets_root.join("app")).unwrap(); std::fs::write(cfg.secrets_root.join("app").join("key"), "nested").unwrap(); // Plant a file OUTSIDE the root that a traversal key would reach. std::fs::write(dir.path().join("outside"), "leak").unwrap(); let cfg = Arc::new(cfg); let pool = crate::db::open(&cfg.db_path).await.unwrap(); let ctx = Arc::new(RecipeCtx::new( AppId::new("demo"), Version::parse("0.1.0").unwrap(), "linux/x86_64".parse().unwrap(), "fw13".into(), "local".into(), "v0.1.0".into(), "/tmp".into(), vec![], Kind::App, 1, Arc::new(std::collections::HashMap::new()), Arc::new(std::collections::HashMap::new()), None, pool, crate::events::channel(), cfg, Arc::new(OtaRegistry::standard("https://makenot.work")), tokio::runtime::Handle::current(), Arc::new(AtomicBool::new(false)), None, )); let engine = build_engine(&ctx); // Happy path: read + trim. assert_eq!( engine.eval::(r#"secret("token")"#).unwrap(), "s3cr3t" ); // A multi-segment relative key is allowed. assert_eq!( engine.eval::(r#"secret("app/key")"#).unwrap(), "nested" ); // Traversal, absolute paths, and empty keys are refused BEFORE any read, // so the file one `..` above the root is never disclosed. for bad in [ r#"secret("../outside")"#, r#"secret("/etc/passwd")"#, r#"secret("")"#, ] { let err = engine.eval::(bad).unwrap_err().to_string(); assert!( err.contains("relative path under secrets_root"), "`{bad}` should hit the traversal guard, got: {err}" ); } // A missing key surfaces the filesystem error, not a panic, and does not // trip the traversal guard (it is a legitimate relative path). let err = engine .eval::(r#"secret("nope")"#) .unwrap_err() .to_string(); assert!(err.contains("secret `nope`"), "got: {err}"); } }