ktstr/
export.rs

1//! `cargo ktstr export` — package a registered test as a self-extracting
2//! `.run` file that reproduces the scenario on bare metal without a VM.
3//!
4//! [`export_test`] is the entry point invoked from the test binary's
5//! `#[ctor]` dispatch (see
6//! `crate::test_support::dispatch::maybe_dispatch_export`) when
7//! `cargo ktstr export` exec's the binary with
8//! `--ktstr-export-test=NAME`. The export pipeline locates the named
9//! test in the `KTSTR_TESTS` distributed slice, gathers the
10//! binaries it needs (the running test binary itself via
11//! `current_exe()`, the scheduler binary, and per-test include
12//! files), tarballs them with gzip, and emits a single shell script:
13//!
14//! ```text
15//! #!/bin/bash
16//! ... preamble: root check, prereq check, sched_ext conflict check,
17//!     topology check, arg parsing, mktemp+trap, archive extract,
18//!     scheduler launch, test run ...
19//! __ARCHIVE__
20//! <base64-encoded gzipped tarball>
21//! ```
22//!
23//! The result is `chmod +x` so the operator can `./repro.run`
24//! directly on a target host. `ktstr run --ktstr-test-fn <name>` is
25//! the same dispatch the in-guest test harness already uses
26//! (`test_support::eval` invokes it after VM boot), so the bare-metal
27//! path reuses every existing test entry — no separate registry, no
28//! rebuilt scenarios.
29//!
30//! # Why bare-metal repro?
31//!
32//! The framework's primary execution path runs every test inside a
33//! KVM VM. That gets us deterministic topology, fast spin-up, and
34//! kernel/scheduler isolation; it also abstracts away from real
35//! hardware. When a test fails on bare metal but passes in the VM
36//! (or vice versa) the operator wants to bisect. A self-contained
37//! `.run` file means they can hand the failing test to any host with
38//! a compatible kernel and topology, run it without re-building the
39//! workspace, and capture the output through ordinary stdout/stderr
40//! channels.
41//!
42//! # Out of scope
43//!
44//! - `host_only` tests: they orchestrate cargo invocations and nested
45//!   VMs themselves; running them outside the framework's harness
46//!   isn't useful.
47//! - `bpf_map_write` tests: they need the framework's runtime
48//!   probe-based map-write surface, not yet replicated outside the
49//!   VM dispatch.
50//! - `KernelBuiltin` schedulers: they activate via shell commands
51//!   (`enable` / `disable` slots on the spec) rather than launching a
52//!   userspace binary. The preamble doesn't generate those commands
53//!   in v1; export rejects the variant with an actionable error.
54//!
55//! # Include-file directories
56//!
57//! The framework's full include-file resolver (re-exported as
58//! [`crate::cli::resolve_include_files`]) walks directories
59//! recursively and produces an `archive_path/host_path` map that
60//! preserves directory structure. Export uses a simpler subset:
61//! every included entry must be a regular file, and the archive
62//! layout flattens by basename to `include/<basename>`. Directory
63//! specs error with EISDIR. Recursive directory packaging is a v2
64//! enhancement.
65//!
66//! # Bash-only
67//!
68//! The preamble's heredoc shebang names `/bin/bash` and uses
69//! features bash carries — indexed-array syntax
70//! (`RUN_ARGS=(...)`, `${RUN_ARGS[@]}` expansion) and
71//! `set -o pipefail` (the `o`-form syntax). Bourne / dash /
72//! busybox sh would mis-parse the script; the operator must run
73//! on a host with bash installed.
74
75use std::fs::OpenOptions;
76use std::io::Write;
77use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
78use std::path::{Path, PathBuf};
79
80use anyhow::{Context, Result, bail};
81use base64::Engine;
82use base64::engine::general_purpose::STANDARD as BASE64;
83use flate2::Compression;
84use flate2::write::GzEncoder;
85
86use crate::test_support::{
87    KtstrTestEntry, SchedulerSpec, content_hash, find_test, resolve_scheduler, scratch_dir,
88};
89
90/// Build a self-extracting `.run` file for the given test.
91///
92/// `test_name` must match a `#[ktstr_test]` registration's `name`
93/// field exactly (case-sensitive). Use `cargo nextest list` to
94/// enumerate names; strip the `<binary>::` prefix the way
95/// `cargo ktstr show-thresholds` does.
96///
97/// `output` is the destination path. `None` defaults to
98/// `<test_name>.run` in the current directory. The output file is
99/// written with mode 0o755 so the operator can invoke it directly.
100pub fn export_test(test_name: &str, output: Option<PathBuf>) -> Result<()> {
101    let entry = find_test(test_name)
102        .ok_or_else(|| anyhow::anyhow!("no registered test named '{test_name}'"))?;
103
104    if entry.host_only {
105        bail!(
106            "test '{test_name}' is host_only — it orchestrates cargo / nested VMs \
107             from inside the test body and cannot be reproduced outside the \
108             framework harness. host_only tests are out of scope for export."
109        );
110    }
111    if !entry.bpf_map_write.is_empty() {
112        bail!(
113            "test '{test_name}' uses bpf_map_write — runtime BPF map writes are \
114             driven by the framework's host-side probe machinery, which is not \
115             reproduced bare-metal. bpf_map_write tests are out of scope for v1 \
116             export."
117        );
118    }
119    // KernelBuiltin schedulers don't ship a userspace binary; they
120    // activate via shell commands stored on the spec's `enable` /
121    // `disable` slots. The framework runs those commands in the VM
122    // around the scheduler binary launch (eval/mod.rs's
123    // run_ktstr_test_inner_impl builds sched_enable_cmds /
124    // sched_disable_cmds on the VmBuilder). The
125    // preamble in v1 does not generate equivalent shell commands —
126    // running the .run file on a host without applying those
127    // settings would silently mis-launch the scheduler. Reject with
128    // an actionable diagnostic.
129    if let SchedulerSpec::KernelBuiltin { .. } = &entry.scheduler.binary {
130        bail!(
131            "test '{test_name}' uses a KernelBuiltin scheduler — it activates via \
132             host-side shell commands (`enable` / `disable` slots) rather than a \
133             userspace binary. The export preamble does not yet emit those \
134             commands; KernelBuiltin export is out of scope for v1."
135        );
136    }
137
138    let test_binary =
139        std::env::current_exe().context("locate the current test binary via /proc/self/exe")?;
140
141    let scheduler_path = resolve_scheduler_for_export(entry)?;
142    let mut include_files = resolve_include_files(entry)?;
143    let config_additions = compute_config_export_additions(entry)
144        .context("resolve scheduler config file for export")?;
145    for addition in &config_additions {
146        include_files.push(addition.host_path.clone());
147    }
148
149    let output_path = output.unwrap_or_else(|| PathBuf::from(format!("{test_name}.run")));
150
151    let archive = build_archive(&test_binary, scheduler_path.as_deref(), &include_files)
152        .context("build embedded gzip tarball")?;
153
154    let preamble = generate_preamble(entry, scheduler_path.is_some(), &config_additions);
155
156    write_runfile(&output_path, &preamble, &archive)
157        .with_context(|| format!("write runfile to {}", output_path.display()))?;
158
159    eprintln!(
160        "wrote {} ({} bytes archive, {} include files)",
161        output_path.display(),
162        archive.len(),
163        include_files.len()
164    );
165    Ok(())
166}
167
168/// Resolve the scheduler binary for an entry, returning `None` for
169/// EEVDF / kernel-builtin payloads (which don't ship a binary).
170///
171/// Reuses `crate::test_support::eval::resolve_scheduler` so the
172/// resolution matches the in-guest path: `KTSTR_SCHEDULER` env wins;
173/// then, outside cargo-test-mode, a fresh `cargo build -p {name}` so an
174/// edited scheduler is never exported stale. A build FAILURE REFUSES by
175/// default — the error propagates (the `?` below), so export rejects
176/// rather than packaging a possibly-stale binary — falling back to a
177/// pre-built sibling-exe / target/debug / target/release binary only
178/// under `KTSTR_SCHEDULER_ALLOW_STALE_FALLBACK`. cargo-test-mode
179/// resolves via `$PATH` first.
180fn resolve_scheduler_for_export(entry: &KtstrTestEntry) -> Result<Option<PathBuf>> {
181    let (path, _source) = resolve_scheduler(&entry.scheduler.binary)
182        .with_context(|| format!("resolve scheduler binary for test '{}'", entry.name))?;
183    Ok(path)
184}
185
186/// A scheduler config file or inline content contributes one
187/// host-side file plus one set of CLI arguments to the export.
188///
189/// Mirrors the in-VM path at
190/// `crate::test_support::eval::run_ktstr_test_inner` which calls
191/// `crate::test_support::runtime::config_file_parts` +
192/// `crate::test_support::runtime::config_content_parts` to
193/// resolve the same two slots.
194#[derive(Debug)]
195struct ConfigExportAddition {
196    /// Host-filesystem path of the config file. For
197    /// `scheduler.config_file` (on-disk file), this is the file
198    /// itself. For `entry.config_content` (inline content), this
199    /// is a temp file written under `$TMPDIR` containing the
200    /// content bytes; the export-side caller doesn't differentiate
201    /// — both go through the same `build_archive` path.
202    host_path: PathBuf,
203    /// Shell-ready CLI argument string PREPENDED to the launched
204    /// scheduler's argv at preamble-render time so the rendered
205    /// argv matches the in-VM `run_ktstr_test_inner_impl` ordering
206    /// (`--config FIRST, append_base_sched_args LAST`). Uses
207    /// `"$DIR/include/<basename>"` (with the `$DIR` shell variable
208    /// preserved for runtime expansion by the .run extractor) so
209    /// the path resolves to the operator's extracted .run tree on
210    /// the target host. No leading space — the caller manages
211    /// spacing between additions and the base args.
212    args_shell_prefix: String,
213}
214
215/// Compute the scheduler-config export additions for an entry.
216///
217/// Returns 0, 1, or 2 additions matching the in-VM path's
218/// dual-slot handling:
219///   - `entry.scheduler.config_file` (`Option<host path>`)
220///   - `entry.config_content` (`Option<inline content>`) paired
221///     with `entry.scheduler.config_file_def`
222///     (`Option<(arg_template, guest_path)>`)
223///
224/// Both slots are processed independently; in practice
225/// `crate::test_support::KtstrTestEntry::validate` gates
226/// `config_content` to require a matching `config_file_def` and
227/// rejects an unpaired `config_content`, so the inline path emits
228/// at most one addition. The on-disk path is orthogonal and could
229/// in theory co-exist with the inline path — handled in lockstep
230/// with the in-VM `crate::test_support::eval` behavior so a single
231/// .run runs the scheduler with the same argv as a normal test
232/// invocation.
233fn compute_config_export_additions(entry: &KtstrTestEntry) -> Result<Vec<ConfigExportAddition>> {
234    let mut out = Vec::new();
235    if let Some(addition) = config_file_addition(entry)? {
236        out.push(addition);
237    }
238    if let Some(addition) = config_content_addition(entry)? {
239        out.push(addition);
240    }
241    Ok(out)
242}
243
244/// Translate `entry.scheduler.config_file` (`Option<host path>`)
245/// into a [`ConfigExportAddition`]. Hardcoded `--config` arg
246/// matches the in-VM behavior at
247/// `crate::test_support::runtime::config_file_parts` and the
248/// surrounding push at `crate::test_support::eval`.
249fn config_file_addition(entry: &KtstrTestEntry) -> Result<Option<ConfigExportAddition>> {
250    let Some(config_path) = entry.scheduler.config_file else {
251        return Ok(None);
252    };
253    let host_path = PathBuf::from(config_path);
254    if !host_path.exists() {
255        bail!(
256            "scheduler '{}' declares config_file {} but the file is not present on the host",
257            entry.scheduler.name,
258            host_path.display()
259        );
260    }
261    // Mirror resolve_include_files's directory-reject (export.rs near
262    // is_dir() rejection in that helper): export packs regular files,
263    // so a directory-shaped config_file would silently fail later in
264    // build_archive's `std::fs::read` with a less-actionable EISDIR.
265    if host_path.is_dir() {
266        bail!(
267            "scheduler '{}' declares config_file {} but the path is a directory — \
268             config_file must point at a regular file. Recursive directory packaging \
269             is a v2 enhancement; for now, list a single file or split the directory \
270             contents across `include_files` declarations.",
271            entry.scheduler.name,
272            host_path.display()
273        );
274    }
275    let basename = host_path
276        .file_name()
277        .and_then(|n| n.to_str())
278        .ok_or_else(|| {
279            anyhow::anyhow!(
280                "scheduler config_file {} has no valid basename",
281                host_path.display()
282            )
283        })?
284        .to_string();
285    reject_shell_metacharacters_in_basename(&basename, &host_path.display().to_string())?;
286    let args_shell_prefix = format!("--config \"$DIR/include/{basename}\"");
287    Ok(Some(ConfigExportAddition {
288        host_path,
289        args_shell_prefix,
290    }))
291}
292
293/// Translate `entry.config_content` (`Option<inline content>`) +
294/// `entry.scheduler.config_file_def`
295/// (`Option<(arg_template, guest_path)>`) into a
296/// [`ConfigExportAddition`] by writing the
297/// content bytes to a temp file under `$TMPDIR` and substituting
298/// `{file}` in the arg template with the export-side runtime path
299/// `"$DIR/include/<basename>"`.
300///
301/// The basename derives from the scheduler's declared guest path
302/// (e.g. `/include-files/layers.json` → `layers.json`) so a
303/// scheduler family that declares a stable guest_path naming
304/// convention sees the same basename in the .run archive that it
305/// would see in the in-VM /include-files mount.
306fn config_content_addition(entry: &KtstrTestEntry) -> Result<Option<ConfigExportAddition>> {
307    let Some(content) = entry.config_content else {
308        return Ok(None);
309    };
310    let Some((arg_template, guest_path)) = entry.scheduler.config_file_def else {
311        return Ok(None);
312    };
313    let basename = std::path::Path::new(guest_path)
314        .file_name()
315        .and_then(|n| n.to_str())
316        .ok_or_else(|| {
317            anyhow::anyhow!(
318                "scheduler '{}' config_file_def guest_path '{}' has no valid basename",
319                entry.scheduler.name,
320                guest_path
321            )
322        })?
323        .to_string();
324    reject_shell_metacharacters_in_basename(&basename, guest_path)?;
325    let hash = content_hash(content);
326    // Write to a uniquely-named scratch file inside the shared
327    // process-owned 0o700 scratch directory, then atomic-rename to
328    // the canonical content-addressed path. See
329    // [`crate::test_support::runtime::scratch_dir`] for the
330    // symlink-defense + leak-bound rationale — that helper is
331    // shared with the in-VM `config_content_parts` path so both
332    // sites get the same atexit cleanup + per-process 0o700
333    // directory without divergent maintenance.
334    let dir = scratch_dir();
335    let canonical = dir.join(format!("ktstr-export-config-{hash:016x}-{basename}"));
336    let mut scratch = tempfile::NamedTempFile::new_in(dir)
337        .with_context(|| "create ktstr export-config scratch file")?;
338    scratch
339        .as_file_mut()
340        .write_all(content.as_bytes())
341        .with_context(|| "write inline config_content to scratch")?;
342    scratch.persist(&canonical).with_context(|| {
343        format!(
344            "atomic-rename export-config scratch to {}",
345            canonical.display()
346        )
347    })?;
348    let runtime_path = format!("\"$DIR/include/{basename}\"");
349    let expanded = arg_template.replace("{file}", &runtime_path);
350    Ok(Some(ConfigExportAddition {
351        host_path: canonical,
352        args_shell_prefix: expanded,
353    }))
354}
355
356/// Reject basenames containing shell-metacharacters that would
357/// break the double-quote-interpolation context the addition's
358/// `args_shell_prefix` ends up in (e.g.
359/// `--config "$DIR/include/<basename>"`). Test-author input is
360/// trusted (static string slots), but defense-in-depth catches
361/// any future regression that would land a basename with `"`,
362/// `\`, `$`, or `` ` `` and silently produce a broken .run script.
363fn reject_shell_metacharacters_in_basename(basename: &str, source: &str) -> Result<()> {
364    for c in basename.chars() {
365        if c == '"' || c == '\\' || c == '$' || c == '`' {
366            bail!(
367                "scheduler config file basename {basename:?} (from {source}) contains shell-metacharacter {c:?}; \
368                 this would break the double-quoted .run preamble interpolation. \
369                 Rename the file to use only ASCII letters, digits, `_`, `-`, and `.`."
370            );
371        }
372    }
373    Ok(())
374}
375
376/// Resolve every `all_include_files()` spec to a host-side path.
377///
378/// The framework's full PATH / directory-walking resolver lives at
379/// [`crate::cli::resolve_include_files`] and returns an
380/// `archive_path/host_path` map that preserves recursive directory
381/// structure. Export uses a deliberately simpler subset:
382///   - explicit absolute paths → use as-is when they exist
383///   - explicit relative paths (containing `/` or starting with `.`)
384///     → relative to current dir
385///   - bare names → search `PATH`
386///   - directories → reject with an actionable "is a directory" error
387///     (export packs regular files only; recursive directory
388///     packaging is a v2 enhancement)
389///
390/// The simpler layout (flat `include/<basename>`) keeps the
391/// extracted .run tree predictable for the operator, at the cost of
392/// not handling tests whose include specs name directories.
393///
394/// Missing files are surfaced as a hard error so the operator can
395/// fix the include spec rather than discovering the gap on the
396/// target host.
397fn resolve_include_files(entry: &KtstrTestEntry) -> Result<Vec<PathBuf>> {
398    let mut out = Vec::new();
399    for spec in entry.all_include_files() {
400        let path = if spec.starts_with('/')
401            || spec.starts_with("./")
402            || spec.starts_with("../")
403            || spec.contains('/')
404        {
405            PathBuf::from(spec)
406        } else {
407            // Bare name — search PATH.
408            search_path_for(spec).ok_or_else(|| {
409                anyhow::anyhow!(
410                    "include file '{spec}' not found in PATH (test \
411                     declared it but the host doesn't have it; install or \
412                     supply an absolute path)"
413                )
414            })?
415        };
416        if !path.exists() {
417            bail!("include file does not exist on host: {}", path.display());
418        }
419        // Reject directories explicitly: export packs files only,
420        // and a directory spec would silently fail later inside
421        // `append_file`'s `std::fs::read` with a less-actionable
422        // error message.
423        if path.is_dir() {
424            bail!(
425                "include file '{}' is a directory — export packs regular files \
426                 only. Recursive directory packaging is a v2 enhancement; for \
427                 now, list each file individually in the test's \
428                 `include_files` slot.",
429                path.display()
430            );
431        }
432        out.push(path);
433    }
434    Ok(out)
435}
436
437/// Search `PATH` for an executable named `name`. Returns the first
438/// match. Mirrors the simplest case of the framework's PATH resolver
439/// — sufficient for export's needs since tests typically declare
440/// either bare standard tools (stress-ng, schbench) or paths to
441/// build artifacts.
442///
443/// A "match" requires the candidate to be (a) a regular file and
444/// (b) executable (any of the user/group/other execute bits set).
445/// Without the executable check, a non-binary file with a colliding
446/// name (e.g. a `stress-ng` documentation file in a PATH dir) would
447/// be picked up first and silently fail at .run time when the guest
448/// tries to exec it.
449fn search_path_for(name: &str) -> Option<PathBuf> {
450    let path_var = std::env::var_os("PATH")?;
451    for dir in std::env::split_paths(&path_var) {
452        let candidate = dir.join(name);
453        if !candidate.is_file() {
454            continue;
455        }
456        let executable = candidate
457            .metadata()
458            .map(|m| m.permissions().mode() & 0o111 != 0)
459            .unwrap_or(false);
460        if executable {
461            return Some(candidate);
462        }
463    }
464    None
465}
466
467/// Tar+gzip the binaries into an in-memory blob.
468///
469/// Layout inside the archive:
470///   - `ktstr` — the runner binary (the one calling
471///     [`export_test`])
472///   - `scheduler` — the scheduler binary (when present)
473///   - `include/<basename>` — every include file, flattened by
474///     basename. Collisions on basename are not allowed.
475///
476/// Permissions: every entry is chmod 0755. The `.run` extractor
477/// preserves these, so the operator can invoke them directly under
478/// `$DIR/ktstr` / `$DIR/scheduler` without re-chmod.
479fn build_archive(ktstr: &Path, scheduler: Option<&Path>, includes: &[PathBuf]) -> Result<Vec<u8>> {
480    let buf: Vec<u8> = Vec::new();
481    let gz = GzEncoder::new(buf, Compression::default());
482    let mut tar = tar::Builder::new(gz);
483
484    append_file(&mut tar, ktstr, "ktstr")?;
485    if let Some(s) = scheduler {
486        append_file(&mut tar, s, "scheduler")?;
487    }
488
489    let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
490    for inc in includes {
491        let name = inc
492            .file_name()
493            .ok_or_else(|| anyhow::anyhow!("include file has no basename: {}", inc.display()))?
494            .to_string_lossy()
495            .into_owned();
496        if !seen.insert(name.clone()) {
497            bail!(
498                "include-file basename collision: two specs both flatten to \
499                 'include/{name}'. Rename one or use distinct paths."
500            );
501        }
502        let archive_name = format!("include/{name}");
503        append_file(&mut tar, inc, &archive_name)?;
504    }
505
506    let gz = tar.into_inner().context("finalise tar stream")?;
507    let blob = gz.finish().context("finalise gzip stream")?;
508    Ok(blob)
509}
510
511/// Append one host file at `host_path` into `tar` under `archive_name`.
512/// Forces mode 0o755 so the extracted entry is executable on the
513/// target host. Regenerates the tar header rather than reusing the
514/// host's path metadata (which could leak environment-specific
515/// information into the published artifact).
516fn append_file<W: Write>(
517    tar: &mut tar::Builder<W>,
518    host_path: &Path,
519    archive_name: &str,
520) -> Result<()> {
521    let bytes = std::fs::read(host_path)
522        .with_context(|| format!("read {} for archive", host_path.display()))?;
523    crate::tar_util::pack_tar_entry(
524        tar,
525        archive_name,
526        0o755,
527        bytes.len() as u64,
528        bytes.as_slice(),
529    )
530    .with_context(|| format!("append {archive_name} to tar"))?;
531    Ok(())
532}
533
534/// Generate the bash preamble. The output is a complete shell script
535/// up to (but not including) the `__ARCHIVE__` marker; [`write_runfile`]
536/// concatenates the preamble, the marker line, and the base64 archive.
537///
538/// The preamble is verbose by default: a banner identifying test +
539/// scheduler + git provenance, every prereq/conflict check spelled
540/// out with actionable error text, and a security-posture line
541/// warning the operator to inspect the script (everything before
542/// `__ARCHIVE__`) before running on a system they do not control.
543/// `--quiet` suppresses the banner only — error paths still print
544/// so a failing repro is never silent.
545fn generate_preamble(
546    entry: &KtstrTestEntry,
547    has_scheduler: bool,
548    config_additions: &[ConfigExportAddition],
549) -> String {
550    let topology = entry.topology;
551    let need_llcs = topology.llcs;
552    let need_cores = topology.cores_per_llc;
553    let need_threads = topology.threads_per_core;
554    let need_numa = topology.numa_nodes;
555
556    let sched_args_joined = compose_sched_args_joined(entry, config_additions);
557
558    // Defensive shell-quoting on every interpolated runtime value.
559    // The names come from compile-time const slots that are
560    // `[A-Za-z0-9_-]+` in practice today, but interpolating
561    // unquoted lets a future producer regression land an
562    // unescaped value with shell metacharacters in the preamble.
563    // Quoting at the producer is cheap and matches the same defense
564    // applied to extra_sched_args above.
565    let test_name = shell_quote(entry.name);
566    let scheduler_name = shell_quote(entry.scheduler.name);
567    let git_hash = shell_quote(&git_provenance());
568
569    let duration_secs = entry.duration.as_secs();
570    let watchdog_secs = entry.watchdog_timeout.as_secs();
571
572    let scheduler_launch = scheduler_launch_block(has_scheduler, &sched_args_joined);
573
574    let mut script = script_header_and_spec(
575        &test_name,
576        &scheduler_name,
577        &git_hash,
578        need_llcs,
579        need_cores,
580        need_threads,
581        need_numa,
582        duration_secs,
583        watchdog_secs,
584    );
585    script.push_str(&script_arg_parser_and_help());
586    script.push_str(&script_prereq_and_topology_checks());
587    script.push_str(&script_dispatch(&scheduler_launch));
588    script
589}
590
591/// Assemble the scheduler argv string interpolated into the
592/// launch line: config-addition prefixes first, then the joined
593/// base sched-args.
594fn compose_sched_args_joined(
595    entry: &KtstrTestEntry,
596    config_additions: &[ConfigExportAddition],
597) -> String {
598    // Compose scheduler args via the same host-side builder the
599    // in-VM test path uses to assemble the scheduler argv
600    // (`append_base_sched_args`, invoked from both `eval/mod.rs` and
601    // `probe.rs`): fail-fast validation of any user-supplied
602    // `--cell-parent-cgroup` value, then the scheduler def's own
603    // `sched_args`, then per-test `extra_sched_args`. Reusing the
604    // helper keeps the bare-metal repro aligned with a normal test
605    // run for these sources — without it, the export would
606    // silently drop any sched-def baseline args, and a malformed
607    // `--cell-parent-cgroup` would slip past the runtime gate.
608    //
609    // Config-driven schedulers (declared `config_file` and/or
610    // `config_content`) are handled via the `config_additions`
611    // parameter: each addition contributes a shell-ready prefix
612    // that lands BEFORE the joined base args. The ordering matches
613    // the in-VM path at `run_ktstr_test_inner_impl` which pushes
614    // `--config <path>` (and any `config_file_def`-templated arg)
615    // first and then appends `append_base_sched_args` last.
616    // Keeping in-VM and export argv-order in lockstep means an
617    // exported reproducer's scheduler-launch line is byte-similar
618    // (modulo path expansion) to the live test's, so clap parsers
619    // with order-sensitive semantics (e.g. trailing-args,
620    // override-on-conflict) behave identically across both paths.
621    //
622    // Each prefix uses `"$DIR/include/<basename>"` so the path
623    // resolves to the operator's extracted .run tree at script-run
624    // time — the matching include-file is packed into the archive
625    // at `include/<basename>` by [`compute_config_export_additions`]
626    // and [`build_archive`].
627    let mut sched_arg_tokens_raw: Vec<String> = Vec::new();
628    crate::test_support::append_base_sched_args(entry, &mut sched_arg_tokens_raw);
629    let base_joined: String = sched_arg_tokens_raw
630        .iter()
631        .map(|a| shell_quote(a))
632        .collect::<Vec<_>>()
633        .join(" ");
634    let mut sched_args_joined = String::new();
635    for addition in config_additions {
636        if !sched_args_joined.is_empty() {
637            sched_args_joined.push(' ');
638        }
639        sched_args_joined.push_str(&addition.args_shell_prefix);
640    }
641    if !base_joined.is_empty() {
642        if !sched_args_joined.is_empty() {
643            sched_args_joined.push(' ');
644        }
645        sched_args_joined.push_str(&base_joined);
646    }
647    sched_args_joined
648}
649
650/// Build the scheduler-launch shell block (launch + 10s attach-wait
651/// loop) for a scheduler-bearing test, or the empty string for a
652/// binary-kind / EEVDF payload.
653fn scheduler_launch_block(has_scheduler: bool, sched_args_joined: &str) -> String {
654    if has_scheduler {
655        format!(
656            r#"
657# --- scheduler launch ---
658echo "ktstr export: launching scheduler $KTSTR_SCHED_NAME"
659"$DIR/scheduler" {sched_args_joined} &
660SCHED_PID=$!
661
662# Wait up to 10s for the scheduler to attach. The kernel's sysfs
663# layout exposes attach state under two files; both are accepted
664# so the wait loop works on every kernel that ships sched_ext:
665#   - `/sys/kernel/sched_ext/root/ops` — non-empty when a scheduler
666#     is currently attached. Present on every kernel revision that
667#     has sched_ext, but the path moved structurally between early
668#     6.x revisions and the upstream-stabilized layout. Treat the
669#     file's absence as "no scheduler attached" rather than an
670#     error; the secondary check below catches stabilized kernels.
671#   - `/sys/kernel/sched_ext/state` (introduced upstream in 6.12)
672#     reads `enabled` once a scheduler attaches, `disabled`
673#     otherwise. Use as the primary signal where available; it has
674#     a stable wire format across kernel versions.
675# Bail if the scheduler exits before attaching, or if the timeout
676# elapses while the scheduler is still alive but unattached.
677ATTACHED=""
678for _ in $(seq 1 100); do
679    if ! kill -0 "$SCHED_PID" 2>/dev/null; then
680        echo "error: scheduler $KTSTR_SCHED_NAME exited before attaching" >&2
681        wait "$SCHED_PID" || true
682        exit 1
683    fi
684    if [ -r /sys/kernel/sched_ext/state ]; then
685        STATE=$(cat /sys/kernel/sched_ext/state 2>/dev/null || true)
686        if [ "$STATE" = "enabled" ]; then
687            ATTACHED="$STATE"
688            break
689        fi
690    fi
691    if [ -f /sys/kernel/sched_ext/root/ops ]; then
692        OPS=$(cat /sys/kernel/sched_ext/root/ops 2>/dev/null || true)
693        if [ -n "$OPS" ]; then
694            ATTACHED="$OPS"
695            break
696        fi
697    fi
698    sleep 0.1
699done
700if [ -z "$ATTACHED" ]; then
701    echo "error: scheduler $KTSTR_SCHED_NAME launched but did not attach within 10s" >&2
702    echo "       (process is still alive; check kernel log for BPF verifier or load errors)" >&2
703    exit 1
704fi
705"#
706        )
707    } else {
708        // Binary-kind / EEVDF payload — no scheduler.
709        String::new()
710    }
711}
712
713/// Script segment 1: shebang, `set -euo pipefail`, and the frozen
714/// test-specification variable block. Ends with the blank line
715/// before the arg parser.
716#[allow(clippy::too_many_arguments)]
717fn script_header_and_spec(
718    test_name: &str,
719    scheduler_name: &str,
720    git_hash: &str,
721    need_llcs: u32,
722    need_cores: u32,
723    need_threads: u32,
724    need_numa: u32,
725    duration_secs: u64,
726    watchdog_secs: u64,
727) -> String {
728    format!(
729        r#"#!/bin/bash
730# Generated by `cargo ktstr export`. Do not edit; regenerate to update.
731set -euo pipefail
732
733# --- frozen test specification ---
734KTSTR_TEST_NAME={test_name}
735KTSTR_SCHED_NAME={scheduler_name}
736KTSTR_GIT_HASH={git_hash}
737NEED_LLCS={need_llcs}
738NEED_CORES_PER_LLC={need_cores}
739NEED_THREADS_PER_CORE={need_threads}
740NEED_NUMA_NODES={need_numa}
741TEST_DURATION_SECS={duration_secs}
742TEST_WATCHDOG_SECS={watchdog_secs}
743"#
744    )
745}
746
747/// Script segment 2: arg parser, `--help` heredoc, banner, and the
748/// root check. Leads with the blank line after the spec block; ends
749/// with the root-check `fi`. No interpolation vars — every value is
750/// a runtime shell variable.
751fn script_arg_parser_and_help() -> String {
752    String::from(
753        r#"
754QUIET=0
755DURATION_OVERRIDE=""
756WATCHDOG_OVERRIDE=""
757while [ $# -gt 0 ]; do
758    case "$1" in
759        --quiet) QUIET=1; shift ;;
760        --duration) DURATION_OVERRIDE="$2"; shift 2 ;;
761        --watchdog-timeout) WATCHDOG_OVERRIDE="$2"; shift 2 ;;
762        --cpus|--topology|--affinity)
763            echo "error: --$1 is frozen for repro fidelity. Re-export to change." >&2
764            exit 1 ;;
765        -h|--help)
766            cat <<EOF
767Usage: $0 [--quiet] [--duration SECS] [--watchdog-timeout SECS]
768
769Reproduces ktstr test '$KTSTR_TEST_NAME' on bare metal. The script
770extracts an embedded gzip tarball containing the ktstr binary and
771the scheduler binary, then dispatches the test directly without
772booting a VM.
773
774Frozen (cannot be overridden):
775  scheduler         $KTSTR_SCHED_NAME
776  topology          $NEED_NUMA_NODES NUMA / $NEED_LLCS LLCs / $NEED_CORES_PER_LLC cores/LLC / $NEED_THREADS_PER_CORE threads/core
777  scheduler args    (compiled into the script)
778  --cpus, --topology, --affinity reject any override
779
780Overridable:
781  --duration SECS         workload duration (default $TEST_DURATION_SECS)
782  --watchdog-timeout SECS scheduler watchdog (default $TEST_WATCHDOG_SECS)
783  --quiet                 suppress the banner (errors still print)
784
785Requirements:
786  Run as root. The script attaches a kernel BPF scheduler and sets
787  up cgroup v2 subgroups; both need CAP_SYS_ADMIN.
788
789  Host must satisfy the frozen topology (LLCs, cores per LLC,
790  threads per core, NUMA nodes); the script's topology check bails
791  with a specific "host has X, test needs Y" message if not.
792
793  /sys/kernel/sched_ext must exist (kernel built with
794  CONFIG_SCHED_CLASS_EXT) and no other sched_ext scheduler may be
795  attached.
796
797Exit codes:
798  0   test passed
799  1   prerequisite or topology check failed, scheduler attach
800      failed, or test failed
801EOF
802            exit 0 ;;
803        *) echo "error: unknown arg '$1' (use --help)" >&2; exit 1 ;;
804    esac
805done
806
807if [ "$QUIET" != "1" ]; then
808    cat <<EOF
809ktstr export: test=$KTSTR_TEST_NAME scheduler=$KTSTR_SCHED_NAME git=$KTSTR_GIT_HASH
810Generated by cargo ktstr export. This script attaches a kernel BPF scheduler
811and runs as root. Inspect this script (everything before __ARCHIVE__) before
812running on a system you do not control.
813EOF
814fi
815
816# --- root check ---
817if [ "$(id -u)" != "0" ]; then
818    echo "error: must run as root (need CAP_SYS_ADMIN for sched_ext + cgroup ops)" >&2
819    exit 1
820fi
821"#,
822    )
823}
824
825/// Script segment 3: prereq checks, sched_ext conflict check, and
826/// topology check. Leads with the blank line after the root check;
827/// ends with the blank line before the archive-extract block. No
828/// interpolation vars — every value is a runtime shell variable.
829fn script_prereq_and_topology_checks() -> String {
830    String::from(
831        r#"
832# --- prereq checks ---
833if [ ! -d /sys/kernel/sched_ext ]; then
834    echo "error: kernel lacks sched_ext support (no /sys/kernel/sched_ext)" >&2
835    exit 1
836fi
837if [ ! -d /sys/fs/cgroup ]; then
838    echo "error: cgroup2 not mounted at /sys/fs/cgroup" >&2
839    exit 1
840fi
841if ! grep -q '^cgroup2 /sys/fs/cgroup ' /proc/mounts; then
842    echo "error: /sys/fs/cgroup is not a cgroup2 mount" >&2
843    exit 1
844fi
845
846# --- sched_ext conflict check ---
847# Mirror the attach-detection logic below: prefer
848# /sys/kernel/sched_ext/state (stabilized in 6.12) when readable,
849# fall back to /sys/kernel/sched_ext/root/ops otherwise. Either
850# file reporting an attached scheduler aborts here so we don't
851# silently displace someone else's running scheduler.
852if [ -r /sys/kernel/sched_ext/state ]; then
853    CURRENT_STATE=$(cat /sys/kernel/sched_ext/state 2>/dev/null || true)
854    if [ "$CURRENT_STATE" = "enabled" ]; then
855        CURRENT_OPS=""
856        if [ -f /sys/kernel/sched_ext/root/ops ]; then
857            CURRENT_OPS=$(cat /sys/kernel/sched_ext/root/ops 2>/dev/null || true)
858        fi
859        echo "error: another sched_ext scheduler is already attached (state=enabled, ops=${CURRENT_OPS:-unknown})." >&2
860        echo "       Detach it before running this repro (e.g. kill its supervisor)." >&2
861        exit 1
862    fi
863elif [ -f /sys/kernel/sched_ext/root/ops ]; then
864    CURRENT=$(cat /sys/kernel/sched_ext/root/ops 2>/dev/null || true)
865    if [ -n "$CURRENT" ]; then
866        echo "error: another sched_ext scheduler '$CURRENT' is already attached." >&2
867        echo "       Detach it before running this repro (e.g. kill its supervisor)." >&2
868        exit 1
869    fi
870fi
871
872# --- topology check ---
873# LLC count: find the highest cache-index level under cpu0 (index3
874# on most x86, but skylake-x has a dedicated L4 at index4 and ARM
875# machines vary). Sum distinct shared_cpu_lists at that level.
876HIGHEST_INDEX=$(ls -d /sys/devices/system/cpu/cpu0/cache/index* 2>/dev/null \
877    | sort -V | tail -n1 || true)
878if [ -n "$HIGHEST_INDEX" ]; then
879    HIGHEST_LEVEL=$(basename "$HIGHEST_INDEX")
880    HOST_LLCS=$(ls -d /sys/devices/system/cpu/cpu*/cache/$HIGHEST_LEVEL 2>/dev/null \
881        | xargs -I{} cat {}/shared_cpu_list 2>/dev/null \
882        | sort -u | wc -l)
883else
884    HOST_LLCS=0
885fi
886HOST_NUMA=$(ls -d /sys/devices/system/node/node* 2>/dev/null | wc -l || echo 0)
887[ "$HOST_NUMA" -lt 1 ] && HOST_NUMA=1
888
889# Cores per LLC: count distinct core_id values among cpus that share
890# the highest-level cache with cpu0. threads per core: count cpus
891# that share the same core_id within one LLC.
892if [ -n "$HIGHEST_INDEX" ]; then
893    CPU0_LLC=$(cat "$HIGHEST_INDEX/shared_cpu_list" 2>/dev/null || echo "")
894else
895    CPU0_LLC=""
896fi
897HOST_CORES_PER_LLC=0
898HOST_THREADS_PER_CORE=0
899if [ -n "$CPU0_LLC" ]; then
900    # Expand cpu list ranges (e.g. "0-3,8-11") into individual ids.
901    CPU_IDS=$(echo "$CPU0_LLC" | tr ',' '\n' | while read range; do
902        if [ -z "$range" ]; then continue; fi
903        if echo "$range" | grep -q '-'; then
904            start=$(echo "$range" | cut -d- -f1)
905            end=$(echo "$range" | cut -d- -f2)
906            seq "$start" "$end"
907        else
908            echo "$range"
909        fi
910    done)
911    HOST_CORES_PER_LLC=$(for id in $CPU_IDS; do
912        cat "/sys/devices/system/cpu/cpu$id/topology/core_id" 2>/dev/null || echo
913    done | sort -u | wc -l)
914    CPU0_CORE=$(cat /sys/devices/system/cpu/cpu0/topology/core_id 2>/dev/null || echo)
915    if [ -n "$CPU0_CORE" ]; then
916        HOST_THREADS_PER_CORE=$(for id in $CPU_IDS; do
917            this_core=$(cat "/sys/devices/system/cpu/cpu$id/topology/core_id" 2>/dev/null || echo)
918            if [ "$this_core" = "$CPU0_CORE" ]; then echo "$id"; fi
919        done | wc -l)
920    fi
921fi
922
923if [ "$HOST_LLCS" = "0" ]; then
924    echo "warning: could not detect host LLC count from sysfs (no cache/index* found for cpu0); the topology check below will fail" >&2
925fi
926if [ "$HOST_LLCS" -lt "$NEED_LLCS" ]; then
927    echo "error: host has $HOST_LLCS LLCs, test needs $NEED_LLCS" >&2
928    exit 1
929fi
930if [ "$HOST_NUMA" -lt "$NEED_NUMA_NODES" ]; then
931    echo "error: host has $HOST_NUMA NUMA nodes, test needs $NEED_NUMA_NODES" >&2
932    exit 1
933fi
934if [ "$HOST_CORES_PER_LLC" -gt 0 ] && [ "$HOST_CORES_PER_LLC" -lt "$NEED_CORES_PER_LLC" ]; then
935    echo "error: host has $HOST_CORES_PER_LLC cores per LLC, test needs $NEED_CORES_PER_LLC" >&2
936    exit 1
937fi
938if [ "$HOST_THREADS_PER_CORE" -gt 0 ] && [ "$HOST_THREADS_PER_CORE" -lt "$NEED_THREADS_PER_CORE" ]; then
939    echo "error: host has $HOST_THREADS_PER_CORE threads per core, test needs $NEED_THREADS_PER_CORE" >&2
940    exit 1
941fi
942"#,
943    )
944}
945
946/// Script segment 4: archive extraction, the EXIT-trap cleanup,
947/// base64 decode, the interpolated scheduler-launch block, and the
948/// in-process test dispatch. Leads with the blank line after the
949/// topology check. `{scheduler_launch}` is the only interpolation;
950/// every other value is a runtime shell variable.
951fn script_dispatch(scheduler_launch: &str) -> String {
952    format!(
953        r#"
954# --- extract embedded archive ---
955DIR=$(mktemp -d -t ktstr-export-XXXXXX)
956chmod 700 "$DIR"
957# The ktstr in-process dispatch creates its cgroup tree under
958# /sys/fs/cgroup/ktstr — the export-relevant path goes through the
959# ctor early-dispatch into `test_support::probe::build_dispatch_ctx_parts`
960# which calls `test_support::args::resolve_cgroup_root` (fn at
961# args.rs:336, `/sys/fs/cgroup/ktstr` fallback at args.rs:377), and
962# the in-VM init follows the same convention.
963# Capture the path here so the trap teardown can clean any subgroups
964# the dispatch created. The rmdir must walk depth-first because
965# cgroup v2 forbids rmdir on a subtree that still contains child
966# groups.
967#
968# WARNING: this cleanup removes ALL subgroups under
969# /sys/fs/cgroup/ktstr, including those created by concurrent
970# ktstr processes. Do not run multiple ktstr workloads on the same
971# host simultaneously.
972KTSTR_CGROUP_PARENT="/sys/fs/cgroup/ktstr"
973SCHED_PID=""
974cleanup() {{
975    if [ -n "$SCHED_PID" ]; then
976        kill "$SCHED_PID" 2>/dev/null || true
977        wait "$SCHED_PID" 2>/dev/null || true
978    fi
979    rm -rf "$DIR"
980    # Cgroup teardown: depth-first rmdir over every subgroup the
981    # test created. cgroup v2's interface files (cgroup.procs,
982    # cgroup.controllers, ...) are auto-removed when their parent
983    # directory rmdirs, so a recursive `rm -rf` is wrong (would
984    # ENOTEMPTY on every interior node). `find -depth` visits
985    # leaves before parents; rmdir succeeds at each step because
986    # children are gone. Errors swallowed via `2>/dev/null` so a
987    # cleanup race with another tool doesn't bleed into the test
988    # exit status.
989    if [ -d "$KTSTR_CGROUP_PARENT" ]; then
990        find "$KTSTR_CGROUP_PARENT" -mindepth 1 -depth -type d \
991            -exec rmdir {{}} + 2>/dev/null || true
992        rmdir "$KTSTR_CGROUP_PARENT" 2>/dev/null || true
993    fi
994}}
995trap cleanup EXIT
996
997# Decode embedded base64 archive (everything after __ARCHIVE__).
998sed -n '/^__ARCHIVE__$/,$p' "$0" | tail -n+2 | base64 -d | tar xz -C "$DIR"
999
1000if [ ! -x "$DIR/ktstr" ]; then
1001    echo "error: extracted ktstr binary missing or not executable" >&2
1002    exit 1
1003fi
1004{scheduler_launch}
1005# --- run the test ---
1006# `--ktstr-test-fn $KTSTR_TEST_NAME` is intercepted by the ktstr
1007# binary's `#[ctor(unsafe)] ktstr_test_early_dispatch` (in
1008# `src/test_support/dispatch.rs`), which fires from `.init_array`
1009# BEFORE `main()` runs. The ctor reads the argv directly via
1010# `extract_test_fn_arg` and dispatches via
1011# `maybe_dispatch_vm_test_with_args` (in
1012# `src/test_support/probe.rs`) which calls `(entry.func)(&ctx)`
1013# directly, then exits the process on completion. The leading
1014# `"run"` token is cosmetic — it's never parsed because the ctor
1015# exits before clap sees it. This early-dispatch path is the
1016# contract for in-process repro and is load-bearing: a future
1017# refactor that moves dispatch out of the ctor must keep an
1018# equivalent argv-intercept path in place, or this preamble must
1019# change to match the new dispatch shape.
1020#
1021# IMPORTANT: do NOT use `exec` here. `exec` replaces the bash
1022# shell with the ktstr binary and DESTROYS the EXIT trap before
1023# it can fire — leaking the scheduler PID, the tempdir, and the
1024# cgroup tree. Run as a child and forward the exit code so the
1025# trap fires on bash exit.
1026RUN_ARGS=("run" "--ktstr-test-fn" "$KTSTR_TEST_NAME")
1027if [ -n "$DURATION_OVERRIDE" ]; then
1028    RUN_ARGS+=("--duration" "$DURATION_OVERRIDE")
1029fi
1030if [ -n "$WATCHDOG_OVERRIDE" ]; then
1031    RUN_ARGS+=("--watchdog-timeout" "$WATCHDOG_OVERRIDE")
1032fi
1033# Disable errexit just for the ktstr invocation so a non-zero
1034# exit from the test (the legitimate "test failed" outcome)
1035# propagates as our exit code instead of triggering set -e and
1036# bypassing the cleanup. The `|| true` would also keep going,
1037# but `set +e` makes the intent explicit.
1038set +e
1039"$DIR/ktstr" "${{RUN_ARGS[@]}}"
1040EXIT_CODE=$?
1041set -e
1042exit $EXIT_CODE
1043"#
1044    )
1045}
1046
1047/// Best-effort git provenance: the project HEAD short hex, or
1048/// `"unknown"` when not in a git checkout. Stamped into the
1049/// preamble's banner so an operator running an old `.run` can tell
1050/// what code was packaged.
1051///
1052/// Uses gix in-process rather than shelling out to `git rev-parse`.
1053/// Same shape as [`crate::fetch::inspect_local_source_state`]: walk
1054/// up from the current directory with `gix::discover`, read the head
1055/// id, format and truncate. No process fork, no PATH dependency —
1056/// the export pipeline never depends on a `git` binary being
1057/// installed on the host running `cargo ktstr export`.
1058fn git_provenance() -> String {
1059    std::env::current_dir()
1060        .ok()
1061        .and_then(|cwd| gix::discover(&cwd).ok())
1062        .and_then(|repo| {
1063            // `head_id()` returns an Id<'_> borrowing `repo`, so format
1064            // and truncate to an owned String inside the same scope as
1065            // `repo` to satisfy the borrow checker. Mirrors the
1066            // head_id/format/truncate pattern in
1067            // `crate::fetch::inspect_local_source_state`.
1068            repo.head_id()
1069                .ok()
1070                .map(|id| format!("{id}").chars().take(7).collect::<String>())
1071        })
1072        .unwrap_or_else(|| "unknown".to_string())
1073}
1074
1075/// Single-quote a shell argument. Embedded single quotes are
1076/// terminated, escaped via `'\''`, and re-opened. Sufficient for
1077/// passing arbitrary `extra_sched_args` strings through the
1078/// preamble's word-split positional context.
1079///
1080/// Empty input is quoted to `''` rather than left as the empty
1081/// string. An unquoted empty arg word-splits to nothing in bash,
1082/// silently dropping the slot — quoting preserves the empty
1083/// positional argument so the scheduler's argv index is preserved.
1084fn shell_quote(s: &str) -> String {
1085    if s.is_empty() {
1086        return "''".to_string();
1087    }
1088    if !s.contains('\'')
1089        && s.chars()
1090            .all(|c| c.is_ascii_alphanumeric() || "._-+=/:".contains(c))
1091    {
1092        return s.to_string();
1093    }
1094    let mut out = String::with_capacity(s.len() + 2);
1095    out.push('\'');
1096    for c in s.chars() {
1097        if c == '\'' {
1098            out.push_str("'\\''");
1099        } else {
1100            out.push(c);
1101        }
1102    }
1103    out.push('\'');
1104    out
1105}
1106
1107/// Write the final `.run` file: preamble bytes, the `__ARCHIVE__`
1108/// marker line, then the base64-encoded archive split into 76-column
1109/// lines (POSIX-friendly width). Sets executable mode 0o755.
1110fn write_runfile(path: &Path, preamble: &str, archive: &[u8]) -> Result<()> {
1111    let mut f = OpenOptions::new()
1112        .write(true)
1113        .create(true)
1114        .truncate(true)
1115        .mode(0o755)
1116        .open(path)
1117        .with_context(|| format!("open {} for write", path.display()))?;
1118
1119    f.write_all(preamble.as_bytes()).context("write preamble")?;
1120    f.write_all(b"__ARCHIVE__\n")
1121        .context("write archive marker")?;
1122
1123    let encoded = BASE64.encode(archive);
1124    // Split into 76-char lines so the file works through legacy
1125    // text-only transports (email MIME, some line editors).
1126    for chunk in encoded.as_bytes().chunks(76) {
1127        f.write_all(chunk).context("write base64 chunk")?;
1128        f.write_all(b"\n").context("write newline")?;
1129    }
1130    f.sync_all().context("fsync runfile")?;
1131    drop(f);
1132    let mut perms = std::fs::metadata(path)
1133        .with_context(|| format!("stat {}", path.display()))?
1134        .permissions();
1135    perms.set_mode(0o755);
1136    std::fs::set_permissions(path, perms)
1137        .with_context(|| format!("chmod 755 {}", path.display()))?;
1138    Ok(())
1139}
1140
1141#[cfg(test)]
1142#[path = "export_tests.rs"]
1143mod tests;