max / makenotwork
1 file changed,
+152 insertions,
-21 deletions
| @@ -391,6 +391,29 @@ impl RecipeCtx { | |||
| 391 | 391 | .collect(); | |
| 392 | 392 | Ok((code, tail)) | |
| 393 | 393 | } | |
| 394 | + | ||
| 395 | + | /// Resolve `glob` on `host` to the single artifact it names. The `for` loop | |
| 396 | + | /// lists each existing match on its own line (and prints nothing — rather | |
| 397 | + | /// than a literal unexpanded pattern — when the glob matches no file), so | |
| 398 | + | /// the count is unambiguous. `required` controls whether zero matches is an | |
| 399 | + | /// error; more than one always is. See `resolve_artifact_match`. | |
| 400 | + | fn resolve_artifact( | |
| 401 | + | self: &Arc<Self>, | |
| 402 | + | host: &str, | |
| 403 | + | glob: &str, | |
| 404 | + | required: bool, | |
| 405 | + | ) -> Result<String> { | |
| 406 | + | ensure_glob_safe(glob)?; | |
| 407 | + | // `[ -e ]` guards against a non-matching glob surviving as its literal | |
| 408 | + | // self, and lists one path per line for the count. | |
| 409 | + | let cmd = format!("for __f in {glob}; do [ -e \"$__f\" ] && printf '%s\\n' \"$__f\"; done"); | |
| 410 | + | let (code, tail) = self.run(host, &cmd)?; | |
| 411 | + | anyhow::ensure!( | |
| 412 | + | code == 0, | |
| 413 | + | "resolving artifact glob `{glob}` on `{host}` exited {code}" | |
| 414 | + | ); | |
| 415 | + | resolve_artifact_match(&tail, glob, required) | |
| 416 | + | } | |
| 394 | 417 | } | |
| 395 | 418 | ||
| 396 | 419 | // ----- error bridging: anyhow -> Rhai runtime error ----- | |
| @@ -550,6 +573,52 @@ pub fn expand_tilde(p: &str) -> PathBuf { | |||
| 550 | 573 | PathBuf::from(p) | |
| 551 | 574 | } | |
| 552 | 575 | ||
| 576 | + | /// Reject a glob that carries shell command metacharacters. Path and wildcard | |
| 577 | + | /// characters (`/ . * ? [ ] ~` etc.) are fine — the pattern reaches a login | |
| 578 | + | /// shell to be expanded — but a `;` or `$(...)` must not ride along and run. | |
| 579 | + | /// Not a privilege boundary (a recipe already runs arbitrary shell via `sh_ok`) | |
| 580 | + | /// but it keeps a malformed pattern from turning into a command. Shared by | |
| 581 | + | /// `collect` and `resolve_artifact`. | |
| 582 | + | fn ensure_glob_safe(glob: &str) -> Result<()> { | |
| 583 | + | anyhow::ensure!( | |
| 584 | + | !glob.chars().any(|c| matches!( | |
| 585 | + | c, | |
| 586 | + | ';' | '&' | '|' | '$' | '`' | '\'' | '"' | '\\' | ' ' | '\n' | '(' | ')' | '<' | '>' | |
| 587 | + | )), | |
| 588 | + | "glob `{glob}` contains shell metacharacters" | |
| 589 | + | ); | |
| 590 | + | Ok(()) | |
| 591 | + | } | |
| 592 | + | ||
| 593 | + | /// Decide the single artifact a glob resolves to from a newline-separated | |
| 594 | + | /// listing of the paths that matched it. | |
| 595 | + | /// | |
| 596 | + | /// The recipes used to select an artifact with `ls -t <glob> | head -1` and | |
| 597 | + | /// guard only on an empty string, so a non-zero `ls` slipped past quietly and a | |
| 598 | + | /// stale newest-by-mtime file could win. This is the strict replacement: it | |
| 599 | + | /// demands exactly one match. Zero matches fail when `required` (return `""` | |
| 600 | + | /// when optional); more than one is always an error rather than an arbitrary | |
| 601 | + | /// newest-wins pick, because an ambiguous match means the build left stale | |
| 602 | + | /// artifacts behind and the wrong one could ship. | |
| 603 | + | fn resolve_artifact_match(listing: &str, glob: &str, required: bool) -> Result<String> { | |
| 604 | + | let matches: Vec<&str> = listing | |
| 605 | + | .lines() | |
| 606 | + | .map(str::trim) | |
| 607 | + | .filter(|l| !l.is_empty()) | |
| 608 | + | .collect(); | |
| 609 | + | match matches.as_slice() { | |
| 610 | + | [] if required => anyhow::bail!("no artifact matched glob `{glob}`"), | |
| 611 | + | [] => Ok(String::new()), | |
| 612 | + | [one] => Ok((*one).to_string()), | |
| 613 | + | many => anyhow::bail!( | |
| 614 | + | "glob `{glob}` is ambiguous: {} artifacts matched ({}). \ | |
| 615 | + | The build left more than one behind; clean stale artifacts so exactly one remains.", | |
| 616 | + | many.len(), | |
| 617 | + | many.join(", ") | |
| 618 | + | ), | |
| 619 | + | } | |
| 620 | + | } | |
| 621 | + | ||
| 553 | 622 | /// Build a Rhai engine with the host API bound to `ctx`. Sandboxed: recipes | |
| 554 | 623 | /// touch the outside world only through these functions. | |
| 555 | 624 | pub fn build_engine(ctx: Arc<RecipeCtx>) -> Engine { | |
| @@ -616,6 +685,40 @@ pub fn build_engine(ctx: Arc<RecipeCtx>) -> Engine { | |||
| 616 | 685 | ); | |
| 617 | 686 | } | |
| 618 | 687 | ||
| 688 | + | // --- resolve_artifact(host, glob) -> path: the ONE artifact matching glob --- | |
| 689 | + | // | |
| 690 | + | // The artifact-selection primitive. Replaces `sh(host, "ls -t <glob> | head | |
| 691 | + | // -1").stdout_tail.trim()` guarded on an empty string, which let a non-zero | |
| 692 | + | // `ls` pass quietly and a stale newest-by-mtime file win. This resolves the | |
| 693 | + | // glob on the host and demands exactly one match: zero matches or more than | |
| 694 | + | // one both throw (an ambiguous match means the build left stale artifacts, | |
| 695 | + | // and silently picking the newest is how the wrong bytes ship). Use | |
| 696 | + | // `resolve_artifact_opt` for an artifact that may legitimately be absent. | |
| 697 | + | { | |
| 698 | + | let ctx = ctx.clone(); | |
| 699 | + | engine.register_fn( | |
| 700 | + | "resolve_artifact", | |
| 701 | + | move |host: &str, glob: &str| -> Result<String, Box<EvalAltResult>> { | |
| 702 | + | ctx.resolve_artifact(host, glob, true).map_err(rhai_err) | |
| 703 | + | }, | |
| 704 | + | ); | |
| 705 | + | } | |
| 706 | + | ||
| 707 | + | // --- resolve_artifact_opt(host, glob) -> path | "": zero-or-one match --- | |
| 708 | + | // | |
| 709 | + | // Same strict resolution as `resolve_artifact` but tolerates zero matches | |
| 710 | + | // (returns ""); more than one is still an error. For optional outputs like a | |
| 711 | + | // `.deb` or an updater bundle a recipe collects only when present. | |
| 712 | + | { | |
| 713 | + | let ctx = ctx.clone(); | |
| 714 | + | engine.register_fn( | |
| 715 | + | "resolve_artifact_opt", | |
| 716 | + | move |host: &str, glob: &str| -> Result<String, Box<EvalAltResult>> { | |
| 717 | + | ctx.resolve_artifact(host, glob, false).map_err(rhai_err) | |
| 718 | + | }, | |
| 719 | + | ); | |
| 720 | + | } | |
| 721 | + | ||
| 619 | 722 | // --- log(msg): operator-visible line into the current step's tail --- | |
| 620 | 723 | { | |
| 621 | 724 | let ctx = ctx.clone(); | |
| @@ -876,27 +979,7 @@ impl RecipeCtx { | |||
| 876 | 979 | let dest_s = dest.to_string_lossy().into_owned(); | |
| 877 | 980 | // The glob reaches a remote login shell intact (that's what expands it), | |
| 878 | 981 | // so command metacharacters stay barred. Path/wildcard chars are fine. | |
| 879 | - | // Not a privilege boundary — a recipe already runs arbitrary shell via | |
| 880 | - | // `sh_ok` — but it keeps a malformed pattern from becoming a command. | |
| 881 | - | anyhow::ensure!( | |
| 882 | - | !glob.chars().any(|c| matches!( | |
| 883 | - | c, | |
| 884 | - | ';' | '&' | |
| 885 | - | | '|' | |
| 886 | - | | '$' | |
| 887 | - | | '`' | |
| 888 | - | | '\'' | |
| 889 | - | | '"' | |
| 890 | - | | '\\' | |
| 891 | - | | ' ' | |
| 892 | - | | '\n' | |
| 893 | - | | '(' | |
| 894 | - | | ')' | |
| 895 | - | | '<' | |
| 896 | - | | '>' | |
| 897 | - | )), | |
| 898 | - | "collect glob `{glob}` contains shell metacharacters" | |
| 899 | - | ); | |
| 982 | + | ensure_glob_safe(glob)?; | |
| 900 | 983 | std::fs::create_dir_all(&dest) | |
| 901 | 984 | .with_context(|| format!("creating collect dest {dest_s}"))?; | |
| 902 | 985 | // The SYNC transport, not the host's exec executor: artifacts move over | |
| @@ -1451,6 +1534,54 @@ mod tests { | |||
| 1451 | 1534 | assert_eq!(expand_tilde("/abs/path"), PathBuf::from("/abs/path")); | |
| 1452 | 1535 | } | |
| 1453 | 1536 | ||
| 1537 | + | // ---- artifact resolution (the M3 silent-`sh` fix) ---- | |
| 1538 | + | ||
| 1539 | + | #[test] | |
| 1540 | + | fn resolve_artifact_match_wants_exactly_one() { | |
| 1541 | + | // Exactly one match: the path, trimmed of the listing's line noise. | |
| 1542 | + | assert_eq!( | |
| 1543 | + | resolve_artifact_match(" /d/App.AppImage \n", "*.AppImage", true).unwrap(), | |
| 1544 | + | "/d/App.AppImage" | |
| 1545 | + | ); | |
| 1546 | + | } | |
| 1547 | + | ||
| 1548 | + | #[test] | |
| 1549 | + | fn resolve_artifact_match_zero_depends_on_required() { | |
| 1550 | + | // Required + zero matches is the case the old empty-string guard caught; | |
| 1551 | + | // keep failing it. | |
| 1552 | + | let err = resolve_artifact_match("", "*.dmg", true).unwrap_err(); | |
| 1553 | + | assert!(err.to_string().contains("no artifact matched"), "{err}"); | |
| 1554 | + | // Optional + zero matches resolves to empty (recipe skips the collect). | |
| 1555 | + | assert_eq!( | |
| 1556 | + | resolve_artifact_match("\n \n", "*.deb", false).unwrap(), | |
| 1557 | + | "" | |
| 1558 | + | ); | |
| 1559 | + | } | |
| 1560 | + | ||
| 1561 | + | #[test] | |
| 1562 | + | fn resolve_artifact_match_rejects_ambiguous() { | |
| 1563 | + | // Two matches must throw rather than silently pick one — this is the | |
| 1564 | + | // stale-newest-mtime hole the audit flagged. Applies even when optional. | |
| 1565 | + | for required in [true, false] { | |
| 1566 | + | let err = | |
| 1567 | + | resolve_artifact_match("/d/old.deb\n/d/new.deb\n", "*.deb", required).unwrap_err(); | |
| 1568 | + | let msg = err.to_string(); | |
| 1569 | + | assert!(msg.contains("ambiguous"), "{msg}"); | |
| 1570 | + | assert!(msg.contains("old.deb") && msg.contains("new.deb"), "{msg}"); | |
| 1571 | + | } | |
| 1572 | + | } | |
| 1573 | + | ||
| 1574 | + | #[test] | |
| 1575 | + | fn ensure_glob_safe_allows_paths_bars_commands() { | |
| 1576 | + | // Path and wildcard characters pass. | |
| 1577 | + | assert!(ensure_glob_safe("~/Code/app/dist/*.AppImage").is_ok()); | |
| 1578 | + | assert!(ensure_glob_safe("/t/App_1.2.3-x86_64.dmg").is_ok()); | |
| 1579 | + | // A command substitution or separator does not. | |
| 1580 | + | for bad in ["*.dmg; rm -rf /", "$(evil)", "a|b", "a b"] { | |
| 1581 | + | assert!(ensure_glob_safe(bad).is_err(), "should reject {bad:?}"); | |
| 1582 | + | } | |
| 1583 | + | } | |
| 1584 | + | ||
| 1454 | 1585 | // ---- version resolution ---- | |
| 1455 | 1586 | ||
| 1456 | 1587 | #[test] |