ref:b84c623f8e3e11c7b22f54834ed59b0144a8ef88

fix(runner): never run a step outside its declared image in silence (#55)

Runner half of fangorn/anvil#374. The server half (why a job arrived with `image: null` at all) is a separate PR on `fangorn/anvil`. ## The failure A `compile` job ran **on the runner host** against the host's Elixir 1.17.3 and died on `mix.exs`'s `~> 1.20.2`, while the pipeline declares `hexpm/elixir:1.20.2-erlang-29.0.3-debian-trixie-20260713`. The whole job log was: ``` ** (Mix) You're trying to run :anvil on Elixir v1.17.3 but it has declared in its mix.exs file it supports only Elixir ~> 1.20.2 ``` Not one line said where it ran. `execute_bare` announced itself with `eprintln!` — the runner's own stderr, which nobody reading a failed job in the UI can see — so a step that ran outside its image was indistinguishable from one that ran inside it. Diagnosing this took an hour and started in the wrong place (the Elixir version) because the log gave nothing else to go on. ## Changes **Every job logs its execution environment, to the job log, before the command runs.** ``` Running in container image: hexpm/elixir:1.20.2-erlang-29.0.3-debian-trixie-20260713 Running in prepared image: anvil-prepared:9f2c1a04b7de Running directly on the runner host (linux/x86_64) — no container image is declared for this step, so it runs against whatever toolchain the runner has ``` The host line names the platform: the runner cannot tell "the step declared `image: bare`" from "the server sent no image", so it states what it actually did and where. **A declared image that cannot be obtained fails the step, naming the image and docker's reason.** `ensure_image_available` pulls, and on failure falls back to a local copy if one exists (air-gapped runner, locally built tag, registry blip). With no local copy there is no correct environment left, so the job fails: ``` Cannot run this step in its declared image ghcr.io/foo/bar:1: <docker's stderr> ``` The previous code logged `Warning: docker pull failed: …` and continued into `docker run`, which then failed with a raw daemon error. It never ran on the host — but it never named the cause either. **`prepare` gets the same treatment for its base image.** A sibling job on this same host reported `Prepare failed: Prepare commands failed with exit code 1` when the docker daemon was simply unreachable; the prepare commands had never run. It now fails with `cannot obtain base image <image>: <reason>`. **`Using cached prepared image: <tag>`** now names the base it was built from — the tag alone is a hash. ## Tests `src/runner/executor.rs` (wiremock log server, asserting on the lines the server actually received): * `host_execution_is_announced_in_the_job_log` — REQ-RUN-001 * `container_execution_names_the_image_in_the_job_log` — REQ-RUN-001 * `unobtainable_image_fails_and_never_runs_on_the_host` — REQ-RUN-002. Runs `echo ran > ran-on-host` with an unobtainable image and asserts the marker file does not exist: proof the command did not run on the host. `src/runner/prepare.rs`: * `missing_base_image_fails_naming_image_and_reason` — REQ-RUN-002, and asserts the message is *not* "Prepare commands failed", which was the misleading one. All deterministic with or without a docker daemon (the test image uses the `.invalid` TLD, and a missing `docker` binary fails the same way) — the CI image has no docker. ``` cargo test 196 + 249 passed, 0 failed cargo clippy --all-targets -- -D warnings clean cargo fmt -- --check clean ``` ## Not fixed here The runner still cannot distinguish "no image declared" from "the server failed to send one" — the wire contract has one nullable `image` field. Making that explicit needs a server-side field and is not worth it while the server half of \#374 removes the way a job got a wrong `image: null` in the first place. `restore_from` (fangorn/anvil#312) is sent by the server in the claim payload and the runner has no handling for it at all. Untouched here. --- ## Proof the tests fail before the fix Checked out `origin/main`, applied **only** the new tests, and reverted the production changes in place (`describe_exec_env` left defined so the pure unit test still compiles; its call site removed, and both warn-and-continue pull blocks restored verbatim). `cargo test --lib`: ``` test runner::executor::exec_env_tests::host_execution_is_announced_in_the_job_log ... FAILED test runner::executor::exec_env_tests::unobtainable_image_fails_and_never_runs_on_the_host ... FAILED test runner::prepare::tests::missing_base_image_fails_naming_image_and_reason ... FAILED test result: FAILED. 193 passed; 3 failed ``` with these assertions: ``` no line announced host execution; got [] a step whose declared image is unavailable must fail: ExecResult { exit_code: 1, end_reason: Finished } the failure must name the base image; got "Prepare commands failed with exit code 1" ``` The first is the incident: a job ran on the host and its log said `[]` about it. The third is the sibling `deps` job's real failure text, which blamed the prepare commands for an unreachable daemon. On this branch all lib tests + integration tests pass (`cargo test`: 198 lib + 249 integration, 0 failures), along with `cargo fmt --check` and `cargo clippy --all-targets --all-features -- -D warnings`. **One test passes before and after, deliberately**: `container_execution_names_the_image_in_the_job_log`. The old code did log `Pulling image: <image>`, so a containerized step was already identifiable. It is a regression guard, not a reproduction — recorded here so the "fails before" claim isn't overstated. **`unobtainable_image_fails_and_never_runs_on_the_host` deserves the same precision.** Its marker-file assertion (`echo ran > ran-on-host`, then assert the file does not exist) passed pre-fix too — there was **no silent host fallback in the runner's docker path**; a failed pull fell into `docker run`, which failed. What did not exist was the loud failure: pre-fix the job returned `Ok(exit_code: 1)`, indistinguishable from "the test suite failed". The marker assertion pins that no such fallback is ever introduced; the `expect_err` pins the new behaviour. ## A second silence, in the same file tree: a failed checkout Review of fangorn/anvil#239 flagged the sibling of this bug one module over, in `workspace.rs`. `prepare` runs `git checkout --force <commit_sha>` to decide the tree the job compiles. If that had gone through the warn-and-continue helper, a bad SHA would return `Ok(())` — and because there is deliberately no `git clean` (the workspace is reused to keep build caches warm), the directory does not end up empty. **It keeps the previous job's fully-built tree.** The job then compiles and tests yesterday's code, passes, and reports green in the correct container, with nothing in its log naming the commit it was supposed to build. `loop_runner.rs` only logs on `Err`, which never arrived. That is the same class this PR exists to remove — a step that ran against something other than what it declared, with no line saying so — so it belongs here. **The `Ok`-swallowing itself was already fixed on `main` by #53**, which routed the clone and the checkout through `run_git_checked`. Reporting that accurately: the review was written against the pre-#53 file. What was genuinely missing is what makes it a regression rather than a passing detail — **nothing tested it**. This PR adds that test, plus the SHA in the error message. Reverting `run_git_checked` to the lenient helper, the new test fails with the bug's own signature: ``` warning: git checkout failed: fatal: unable to read tree (1234567890abcdef1234567890abcdef12345678) panicked at src/runner/workspace.rs:767: a commit that cannot be checked out must fail the job: "/var/folders/.../anvil-work-68208/.../a28bf26b-1" ``` `prepare` handing back a workspace path, with the first job's tree still in it — the test asserts that tree is still on disk precisely to show what the job would have built. The error now names the SHA as well as the operation (`git checkout <sha> failed: …`). Git's own stderr usually mentions it, but the one operation whose silent failure substitutes a different commit should not depend on that. ## The blind spot The runner's execution path had **no test that ran `execute()` at all**. Everything under `src/runner/` that was tested was pure: tag computation, prune policy, workspace paths, `EndReason` mapping. The container-vs-host branch — four lines, and the single most consequential decision the runner makes — was reachable only by running a real job on a real host with a real daemon. That has a specific consequence: **the unavailable-Docker branch had never been executed by anything.** Not in CI (the `rust:1.95-trixie` image has no docker), not locally (developers have a daemon). So warn-and-continue-into-`docker run` looked fine on inspection and had never been observed. The new tests run in both environments precisely because every way of not having an image — no `docker` binary, no daemon, no such image — converges on the same assertion. The same shape as fangorn/ex_git_objectstore#78, found today: its one thin-pack test injects a stub resolver, so the real resolver has zero coverage. Both are a seam mocked on the side that is broken. ## Prior art fangorn/anvil-cli#42 (inspect/prune/force-rebuild prepared images) is adjacent and **not** a duplicate — commented there with the relationship. Short version: this job never entered the prepared-image path at all, so no image was stale or missing; #42's asks are unaffected except that `Using cached prepared image: <tag> (built from <base>)` now supplies, for the image a job used, the base-image back-reference #42 notes the tag lacks.
SHA: b84c623f8e3e11c7b22f54834ed59b0144a8ef88
Author: Anvil <noreply@anvil.fangorn.io>
Date: 2026-07-31 05:14
Parents: 8ef773d
3 files changed +380 -46
Type
src/runner/executor.rs +222 −22
@@ -44,6 +44,7 @@
}
}
#[derive(Debug)]
pub struct ExecResult {
pub exit_code: i32,
pub end_reason: EndReason,
@@ -69,6 +70,14 @@
) -> Result<ExecResult, Box<dyn std::error::Error + Send + Sync>> {
let timeout = timeout_seconds.unwrap_or(DEFAULT_TIMEOUT_SECS);
// Record the environment in the JOB log before anything runs. Host
// execution used to be visible only on the runner's own stderr, so a step
// that ran outside its declared image looked identical to one that ran
// inside it — which is how a job spent an hour being diagnosed as an Elixir
// version problem when it was really running on the wrong machine
// (fangorn/anvil#374).
log_reporter.append(&describe_exec_env(image)).await;
match image {
Some(img) => {
eprintln!("Executing (docker): {img} — {command}");
@@ -91,6 +100,29 @@
}
}
/// One line naming where the job's command is about to run: the container
/// image, or the host and its platform.
///
/// No image means none was declared (`image: bare`, or a pipeline with no
/// `image:` at all) — the runner cannot tell that apart from a server that
/// failed to send one, so the line states the platform too. Whoever reads a
/// failed job log then sees immediately that it ran against the runner's own
/// toolchain.
fn describe_exec_env(image: Option<&str>) -> String {
match image {
Some(img) if img.starts_with("anvil-prepared:") => {
format!("Running in prepared image: {img}")
}
Some(img) => format!("Running in container image: {img}"),
None => format!(
"Running directly on the runner host ({}/{}) — no container image is declared \
for this step, so it runs against whatever toolchain the runner has",
std::env::consts::OS,
std::env::consts::ARCH
),
}
}
#[allow(clippy::too_many_arguments)]
async fn execute_docker(
command: &str,
@@ -107,28 +139,17 @@
// Login to Anvil registry if credentials are available and image is from that registry
ensure_registry_login(image, env, log_reporter).await;
eprintln!("Pulling image: {image}");
log_reporter
.append(&format!("Pulling image: {image}"))
.await;
let pull_output = std::process::Command::new("docker")
.args(["pull", image])
.output();
match pull_output {
Ok(output) if output.status.success() => {
eprintln!("Image ready: {image}");
}
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
let msg = format!("Warning: docker pull failed: {stderr}");
eprintln!("{msg}");
log_reporter.append(&msg).await;
}
Err(e) => {
let msg = format!("Warning: docker pull error: {e}");
eprintln!("{msg}");
log_reporter.append(&msg).await;
}
// Fail here rather than letting `docker run` fail later with a raw
// daemon error, and never fall through to the host: a step that cannot
// run in the image it declared has no correct alternative environment
// (fangorn/anvil#374).
if let Err(reason) =
crate::runner::prepare::ensure_image_available(image, log_reporter).await
{
let msg = format!("Cannot run this step in its declared image {image}: {reason}");
eprintln!("{msg}");
log_reporter.append(&msg).await;
return Err(msg.into());
}
} else {
eprintln!("Using local prepared image: {image}");
@@ -444,5 +465,184 @@
))
.await;
let _ = child.kill().await;
}
}
#[cfg(test)]
mod exec_env_tests {
use super::*;
use wiremock::matchers::method;
use wiremock::{Mock, MockServer, ResponseTemplate};
async fn received_lines(server: &MockServer) -> Vec<String> {
let reqs = server.received_requests().await.unwrap();
let mut out = Vec::new();
for r in reqs {
let v: serde_json::Value = serde_json::from_slice(&r.body).unwrap();
if let Some(arr) = v["lines"].as_array() {
for l in arr {
out.push(l.as_str().unwrap().to_string());
}
}
}
out
}
// REQ-RUN-001 — the branch that decides container-vs-host, pinned directly.
// `execute` dispatches on exactly this `Option`, so these three cases are
// the whole decision. The host arm must be distinguishable from the
// container arm by reading the log alone: a job that ran outside its image
// and one that ran inside it produced identical logs before #374.
#[test]
fn exec_env_description_distinguishes_host_from_container() {
let container = describe_exec_env(Some("hexpm/elixir:1.20.2-erlang-29.0.3"));
assert!(container.contains("hexpm/elixir:1.20.2-erlang-29.0.3"));
assert!(!container.contains("runner host"));
let prepared = describe_exec_env(Some("anvil-prepared:9f2c1a04b7de"));
assert!(prepared.contains("anvil-prepared:9f2c1a04b7de"));
assert!(!prepared.contains("runner host"));
let host = describe_exec_env(None);
assert!(
host.contains("runner host"),
"host execution must name itself: {host:?}"
);
assert!(
host.contains(std::env::consts::OS) && host.contains(std::env::consts::ARCH),
"host execution must name the platform it fell back to: {host:?}"
);
}
async fn log_server() -> MockServer {
let server = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200))
.mount(&server)
.await;
server
}
// REQ-RUN-001 — a step that runs on the host must SAY SO in its own job
// log. The job in fangorn/anvil#374 ran on the runner host against the
// host's Elixir and its log contained no hint of it — the bare path only
// ever wrote to the runner's stderr, which nobody reading the failed job
// can see.
#[tokio::test]
async fn host_execution_is_announced_in_the_job_log() {
let server = log_server().await;
let reporter = LogReporter::new(&server.uri(), "job-host", "tok");
let ws = std::env::temp_dir();
let result = execute(
"exit 0",
None,
&ws,
&HashMap::new(),
None,
Some(60),
&reporter,
Arc::new(Notify::new()),
)
.await
.expect("bare execution should run");
assert_eq!(result.exit_code, 0);
reporter.drain().await;
let lines = received_lines(&server).await;
assert!(
lines.iter().any(|l| l.contains("runner host")),
"no line announced host execution; got {lines:?}"
);
assert!(
lines
.iter()
.any(|l| l.contains(std::env::consts::OS) && l.contains(std::env::consts::ARCH)),
"the host line must name the platform the step ran on; got {lines:?}"
);
}
// REQ-RUN-001 — the counterpart: a containerized step names its image up
// front, so the log answers "what did this run in?" on its own.
#[tokio::test]
async fn container_execution_names_the_image_in_the_job_log() {
let server = log_server().await;
let reporter = LogReporter::new(&server.uri(), "job-img", "tok");
let ws = std::env::temp_dir();
// The image cannot be obtained (invalid TLD → immediate failure, and no
// docker at all on the CI image), so this returns Err — but the
// announcement happens before any docker call either way.
let _ = execute(
"exit 0",
Some("anvil-test.invalid/nope:1"),
&ws,
&HashMap::new(),
None,
Some(60),
&reporter,
Arc::new(Notify::new()),
)
.await;
reporter.drain().await;
let lines = received_lines(&server).await;
assert!(
lines
.iter()
.any(|l| l.contains("anvil-test.invalid/nope:1")),
"the job log must name the image the step runs in; got {lines:?}"
);
}
// REQ-RUN-002 — a step that declares an image it cannot get must FAIL,
// naming the image and the reason. Falling through to the host would run
// the step in an unknown environment — either failing confusingly
// (fangorn/anvil#374) or, worse, passing while testing the wrong thing.
#[tokio::test]
async fn unobtainable_image_fails_and_never_runs_on_the_host() {
let server = log_server().await;
let reporter = LogReporter::new(&server.uri(), "job-fail", "tok");
let ws = std::env::temp_dir().join(format!(
"anvil-exec-test-{}",
std::process::id() as u64 * 7 + 1
));
std::fs::create_dir_all(&ws).unwrap();
let marker = ws.join("ran-on-host");
let _ = std::fs::remove_file(&marker);
let image = "anvil-test.invalid/nope:1";
let err = execute(
"echo ran > ran-on-host",
Some(image),
&ws,
&HashMap::new(),
None,
Some(60),
&reporter,
Arc::new(Notify::new()),
)
.await
.expect_err("a step whose declared image is unavailable must fail");
let msg = err.to_string();
assert!(
msg.contains(image),
"the failure must name the declared image; got {msg:?}"
);
assert!(
!marker.exists(),
"the command ran on the host after the image was unavailable"
);
reporter.drain().await;
let lines = received_lines(&server).await;
assert!(
lines.iter().any(|l| l.contains(image)),
"the job log must explain the failure and name the image; got {lines:?}"
);
let _ = std::fs::remove_dir_all(&ws);
}
}
src/runner/prepare.rs +81 −22
@@ -126,6 +126,48 @@
format!("anvil-prepared:{}", &hex[..12])
}
/// Make a declared image present on this host, or say why it cannot be.
///
/// A pull failure is not fatal by itself — the image may already be on the host
/// (an air-gapped runner, a locally built tag, a registry that is momentarily
/// down). It is fatal when the image is *also* absent locally: there is then no
/// way to run the step as declared, and the caller must fail the job rather
/// than proceed in some other environment (fangorn/anvil#374).
///
/// The `Err` carries docker's own reason so the job log names both the image
/// and what actually went wrong — "Prepare commands failed with exit code 1"
/// when the daemon was simply unreachable sent one investigation down an
/// hour-long detour.
pub(crate) async fn ensure_image_available(
image: &str,
log_reporter: &LogReporter,
) -> Result<(), String> {
eprintln!("Pulling image: {image}");
log_reporter
.append(&format!("Pulling image: {image}"))
.await;
let reason = match Command::new("docker").args(["pull", image]).output() {
Ok(output) if output.status.success() => {
eprintln!("Image ready: {image}");
return Ok(());
}
Ok(output) => String::from_utf8_lossy(&output.stderr).trim().to_string(),
Err(e) => format!("could not run `docker pull`: {e}"),
};
if image_exists_locally(image) {
let msg = format!(
"Warning: docker pull failed ({reason}) — using the copy of {image} already on this host"
);
eprintln!("{msg}");
log_reporter.append(&msg).await;
return Ok(());
}
Err(reason)
}
/// Check if a Docker image exists locally.
fn image_exists_locally(tag: &str) -> bool {
Command::new("docker")
@@ -150,7 +192,7 @@
// Record the use so LRU pruning keeps images that are still in service,
// regardless of how long ago they were built.
touch_prepared_image(&tag);
let msg = format!("Using cached prepared image: {tag} (built from {image})");
let msg = format!("Using cached prepared image: {tag}");
eprintln!("{msg}");
log_reporter.append(&msg).await;
return Ok(tag);
@@ -160,27 +202,11 @@
.append(&format!("Building prepared image from {image}..."))
.await;
// The base image is what the step declared; without it there is nothing to
// prepare. Fail naming it, instead of running the prepare commands against
// Pull the base image first
eprintln!("Pulling base image: {image}");
log_reporter
// an image that isn't there and reporting their exit code as the cause.
if let Err(reason) = ensure_image_available(image, log_reporter).await {
return Err(format!("cannot obtain base image {image}: {reason}").into());
.append(&format!("Pulling base image: {image}"))
.await;
let pull_output = Command::new("docker").args(["pull", image]).output();
match pull_output {
Ok(output) if output.status.success() => {
eprintln!("Base image ready: {image}");
}
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
let msg = format!("Warning: docker pull failed: {stderr}");
eprintln!("{msg}");
log_reporter.append(&msg).await;
}
Err(e) => {
let msg = format!("Warning: docker pull error: {e}");
eprintln!("{msg}");
log_reporter.append(&msg).await;
}
}
// Join prepare commands so they run in sequence and fail fast, using the
@@ -420,6 +446,39 @@
#[cfg(test)]
mod tests {
use super::*;
// REQ-RUN-002 — a prepare block whose base image cannot be obtained must
// fail naming the image and docker's reason. The old code warned and
// ran the prepare commands anyway, so the job reported "Prepare commands
// failed with exit code 1" when the real cause was an unreachable daemon
// (fangorn/anvil#374).
#[tokio::test]
async fn missing_base_image_fails_naming_image_and_reason() {
use wiremock::matchers::method;
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200))
.mount(&server)
.await;
let reporter = LogReporter::new(&server.uri(), "job-prep", "tok");
let image = "anvil-test.invalid/no-such-base:1";
let err = prepare_image(image, &["true".to_string()], &reporter)
.await
.expect_err("an unobtainable base image must fail the job");
let msg = err.to_string();
assert!(
msg.contains(image),
"the failure must name the base image; got {msg:?}"
);
assert!(
!msg.contains("Prepare commands failed"),
"the prepare commands never ran — reporting their exit code hides the real cause: {msg:?}"
);
}
#[test]
fn test_compute_prepared_tag_deterministic() {
src/runner/workspace.rs +77 −2
@@ -347,7 +347,15 @@
}
// Checkout target commit — force to discard any local changes. Hard failure:
// this is what decides the tree the job compiles.
run_git_checked(&workspace, &["checkout", "--force", commit_sha], "checkout")?;
// this is what decides the tree the job compiles. The label carries the SHA
// because the workspace is deliberately reused (see below), so a swallowed
// failure here leaves the *previous* job's tree in place — and the job then
// builds that, green, in the right container, with nothing in its log
// naming the commit it was supposed to have.
run_git_checked(
&workspace,
&["checkout", "--force", commit_sha],
&format!("checkout {commit_sha}"),
)?;
// NOTE: no `git clean -fdx` — preserves build caches (deps, _build, node_modules, target/)
@@ -710,6 +718,73 @@
);
let _ = std::fs::remove_dir_all(&root);
let _ = std::fs::remove_dir_all(&outside);
}
/// A real git repo with one commit on `main`, plus that commit's SHA.
/// Returns `None` when git is unavailable, so the suite still runs.
fn origin_repo(tag: &str) -> Option<(PathBuf, String)> {
let root = std::env::temp_dir().join(format!("anvil-origin-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).ok()?;
let git = |args: &[&str]| -> Option<std::process::Output> {
let out = Command::new("git")
.current_dir(&root)
.args(args)
.output()
.ok()?;
out.status.success().then_some(out)
};
git(&["init", "--initial-branch=main", "."])?;
git(&["config", "user.email", "test@example.com"])?;
git(&["config", "user.name", "Test"])?;
std::fs::write(root.join("only-in-first-commit"), b"x").ok()?;
git(&["add", "."])?;
git(&["commit", "-m", "first"])?;
let sha = String::from_utf8(git(&["rev-parse", "HEAD"])?.stdout)
.ok()?
.trim()
.to_string();
Some((root, sha))
}
#[test]
fn a_checkout_that_fails_fails_the_job_instead_of_serving_the_previous_tree() {
// The workspace is reused between jobs and there is no `git clean`, so a
// swallowed checkout failure does not leave an empty directory the job
// would trip over — it leaves the PREVIOUS job's fully-built tree. The
// job then compiles and tests yesterday's code, passes, and reports
// green for a commit that was never checked out. That is worse than a
// failed job: it reads as the developer's commit being fine.
let Some((origin, sha)) = origin_repo("checkout") else {
eprintln!("skipping: git unavailable");
return;
};
let work = std::env::temp_dir().join(format!("anvil-work-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&work);
let origin_url = origin.to_str().unwrap();
let ws = prepare(&work, origin_url, &sha, 1, RUNNER_A).expect("first job checks out");
assert!(ws.join("only-in-first-commit").exists());
// Second job, same runner and slot — so the same workspace — at a commit
// the remote does not have. The tree from the first job is still there.
let missing = "1234567890abcdef1234567890abcdef12345678";
let err = prepare(&work, origin_url, missing, 1, RUNNER_A)
.expect_err("a commit that cannot be checked out must fail the job");
let msg = err.to_string();
assert!(msg.contains("checkout"), "must name the operation: {msg}");
assert!(msg.contains(missing), "must name the commit: {msg}");
assert!(
ws.join("only-in-first-commit").exists(),
"the stale tree is still on disk — which is exactly what the job would \
have built had prepare returned Ok"
);
let _ = std::fs::remove_dir_all(&work);
let _ = std::fs::remove_dir_all(&origin);
}
#[test]