use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
fn repository_root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.canonicalize()
.expect("the repository root must exist")
}
fn read_workflow(name: &str) -> String {
let path = repository_root()
.join(".github")
.join("workflows")
.join(name);
std::fs::read_to_string(&path)
.unwrap_or_else(|err| panic!("cannot read {}: {err}", path.display()))
}
fn posix(path: &Path) -> String {
path.to_string_lossy().replace('\\', "/")
}
fn find_on_path(program: &str) -> Option<PathBuf> {
let path = std::env::var_os("PATH")?;
std::env::split_paths(&path)
.map(|directory| directory.join(program))
.find(|candidate| candidate.is_file())
}
fn bash_program() -> PathBuf {
if let Some(explicit) = std::env::var_os("RUNNER_MANAGER_BASH") {
return PathBuf::from(explicit);
}
if !cfg!(windows) {
return PathBuf::from("bash");
}
let mut tried: Vec<PathBuf> = Vec::new();
if let Some(git) = find_on_path("git.exe")
&& let Some(root) = git.parent().and_then(Path::parent)
{
let candidate = root.join("bin").join("bash.exe");
if candidate.is_file() {
return candidate;
}
tried.push(candidate);
}
let standard = PathBuf::from(r"C:\Program Files\Git\bin\bash.exe");
if standard.is_file() {
return standard;
}
tried.push(standard);
panic!(
"no usable bash found on Windows. Tried, in order: {tried:?}. \
`bash` on PATH is deliberately NOT used as a fallback: on Windows it \
resolves to C:\\Windows\\System32\\bash.exe, the WSL launcher, which \
is a different program and fails outright when no distribution is \
installed. Install Git for Windows, or set RUNNER_MANAGER_BASH to a \
bash executable."
);
}
fn release_script() -> PathBuf {
let path = repository_root()
.join(".github")
.join("scripts")
.join("release.sh");
assert!(
path.is_file(),
"{} must exist: it is where release.yml's decisions live",
path.display()
);
path
}
fn run_release_script(arguments: &[&str]) -> (bool, String) {
run_release_script_with_path(arguments, None)
}
fn run_release_script_with_path(arguments: &[&str], extra_path: Option<&Path>) -> (bool, String) {
let (ok, out, err) = release_script_streams(arguments, extra_path);
(ok, format!("{out}{err}"))
}
fn release_script_streams(arguments: &[&str], extra_path: Option<&Path>) -> (bool, String, String) {
let script = release_script();
let mut command = Command::new(bash_program());
command.arg(posix(&script));
command.args(arguments);
command.current_dir(repository_root());
if let Some(directory) = extra_path {
let existing = std::env::var_os("PATH").unwrap_or_default();
let mut entries = vec![directory.to_path_buf()];
entries.extend(std::env::split_paths(&existing));
let joined = std::env::join_paths(entries).expect("PATH entries must join");
command.env("PATH", joined);
}
let Output {
status,
stdout,
stderr,
} = command
.output()
.unwrap_or_else(|err| panic!("cannot run {}: {err}", posix(&script)));
(
status.success(),
String::from_utf8_lossy(&stdout).into_owned(),
String::from_utf8_lossy(&stderr).into_owned(),
)
}
fn release_script_stdout(arguments: &[&str]) -> String {
let (ok, output) = run_release_script(arguments);
assert!(
ok,
"release.sh {arguments:?} was expected to succeed:\n{output}"
);
output.trim().to_string()
}
#[test]
fn the_version_format_rejects_the_three_documented_bad_inputs() {
let rejected = [
"1.2", "v1.2.3", "abc", "", "01.2.3", "1.2.3-rc1", "1.2.3+b1",
"1.2.3.4",
" 1.2.3",
"1.2.3 ",
"latest",
"1.2.x",
"^1.2.3", "1.2.3\nrm -rf /", ];
for version in rejected {
let (accepted, output) = run_release_script(&["check-format", version]);
assert!(
!accepted,
"check-format accepted {version:?}, which must be rejected at step 1.\n{output}"
);
}
let accepted = ["1.2.3", "0.1.0", "0.0.0", "10.20.30", "1.0.0"];
for version in accepted {
let (ok, output) = run_release_script(&["check-format", version]);
assert!(
ok,
"check-format rejected {version:?}, which is a valid X.Y.Z version.\n{output}"
);
}
}
#[test]
fn monotonicity_is_enforced_against_the_manifest_source() {
for (version, manifest) in [("1.0.0", "1.0.0"), ("0.9.0", "1.0.0"), ("0.1.0", "1.0.0")] {
let (accepted, output) = run_release_script(&["check-monotonic", version, manifest, ""]);
assert!(
!accepted,
"check-monotonic accepted {version} against Cargo.toml {manifest}.\n{output}"
);
assert!(
output.contains("Cargo.toml"),
"the rejection must name the source that rejected it.\n{output}"
);
}
for (version, manifest) in [("1.0.1", "1.0.0"), ("2.0.0", "1.0.0"), ("0.1.1", "0.1.0")] {
let (ok, output) = run_release_script(&["check-monotonic", version, manifest, ""]);
assert!(
ok,
"check-monotonic rejected {version}, which is above Cargo.toml {manifest}.\n{output}"
);
}
}
#[test]
fn monotonicity_is_enforced_against_the_release_source_independently() {
for (version, release) in [("1.0.0", "1.0.0"), ("1.0.0", "1.1.0"), ("1.0.0", "2.0.0")] {
let (accepted, output) =
run_release_script(&["check-monotonic", version, "0.1.0", release]);
assert!(
!accepted,
"check-monotonic accepted {version} against published release {release}.\n{output}"
);
assert!(
output.contains("release"),
"the rejection must name the source that rejected it.\n{output}"
);
assert!(
output.contains("monotonic vs Cargo.toml OK"),
"the manifest source must have PASSED here, or this test is not \
exercising the release source at all.\n{output}"
);
}
for (version, release) in [("1.0.1", "1.0.0"), ("2.0.0", "1.9.9")] {
let (ok, output) = run_release_script(&["check-monotonic", version, "0.1.0", release]);
assert!(
ok,
"check-monotonic rejected {version}, which is above release {release}.\n{output}"
);
}
}
#[test]
fn an_unreadable_release_source_is_not_an_absent_one() {
let temporary = tempfile::tempdir().expect("a temporary directory");
let write = |name: &str, body: &str| -> String {
let path = temporary.path().join(name);
std::fs::write(&path, body).expect("the fixture must be writable");
posix(&path)
};
let ok_response = write(
"200.http",
"HTTP/2.0 200 OK\r\nContent-Type: application/json\r\n\r\n\
{\"id\":1,\"tag_name\":\"v2.0.0\",\"draft\":false}\n",
);
let (ok, stdout, stderr) =
release_script_streams(&["latest-release-version", &ok_response], None);
assert!(ok, "a 200 must succeed.\n{stdout}{stderr}");
assert_eq!(
stdout.trim(),
"2.0.0",
"STDOUT is the value the workflow captures with `$(...)`, so it must \
carry the version and nothing else.\nstdout: {stdout:?}\nstderr: {stderr:?}"
);
let (ok, stdout, stderr) = release_script_streams(
&[
"latest-release-version",
&write(
"404.http",
"HTTP/2.0 404 Not Found\r\n\r\n{\"message\":\"Not Found\"}\n",
),
],
None,
);
assert!(
ok,
"a 404 is the first-release state and must not fail the run.\n{stdout}{stderr}"
);
assert_eq!(
stdout.trim(),
"",
"a 404 must print no version at all: the EMPTY third argument is what \
tells check-monotonic that a first release cannot be regressed. \
Anything on stdout here becomes a version.\nstdout: {stdout:?}"
);
for (name, status_line) in [
("403.http", "HTTP/2.0 403 Forbidden"),
("429.http", "HTTP/2.0 429 Too Many Requests"),
("500.http", "HTTP/2.0 500 Internal Server Error"),
("502.http", "HTTP/1.1 502 Bad Gateway"),
("401.http", "HTTP/2.0 401 Unauthorized"),
] {
let response = write(
name,
&format!("{status_line}\r\n\r\n{{\"message\":\"nope\"}}\n"),
);
let (accepted, output) = run_release_script(&["latest-release-version", &response]);
assert!(
!accepted,
"{status_line} was treated as \"no release published yet\". It is \
not: it means the published-release source could not be read, and \
a run that continued would be checking monotonicity against \
Cargo.toml alone.\n{output}"
);
}
let (accepted, output) =
run_release_script(&["latest-release-version", &write("empty.http", "")]);
assert!(
!accepted,
"an empty response means `gh` never answered. Absence must not read as \
\"nothing has been released\".\n{output}"
);
}
#[test]
fn the_manifest_version_is_read_from_the_workspace_package_section() {
let manifest = repository_root().join("Cargo.toml");
let version = release_script_stdout(&["manifest-version", &posix(&manifest)]);
assert!(
version.lines().count() == 1,
"manifest-version must print exactly one version, got:\n{version}"
);
let (ok, _) = run_release_script(&["check-format", &version]);
assert!(
ok,
"manifest-version returned {version:?}, which is not a valid X.Y.Z version"
);
}
#[test]
fn setting_the_version_rewrites_every_line_that_pins_a_workspace_member() {
let temporary = tempfile::tempdir().expect("a temporary directory");
let source = repository_root().join("Cargo.toml");
let original = std::fs::read_to_string(&source).expect("the root manifest must be readable");
let current = release_script_stdout(&["manifest-version", &posix(&source)]);
let target = temporary.path().join("Cargo.toml");
std::fs::write(&target, &original).expect("the copy must be writable");
let output = release_script_stdout(&["set-version", "9.9.9", &posix(&target)]);
assert!(
output.contains("9.9.9"),
"set-version must report what it wrote:\n{output}"
);
let written =
std::fs::read_to_string(&target).expect("the rewritten manifest must be readable");
assert_eq!(
release_script_stdout(&["manifest-version", &posix(&target)]),
"9.9.9",
"[workspace.package] must carry the new version"
);
let stale: Vec<&str> = written
.lines()
.filter(|line| line.contains("path = \"crates/"))
.filter(|line| line.contains("version = ") && !line.contains("\"9.9.9\""))
.collect();
assert!(
stale.is_empty(),
"these member pins were left at the old version, which makes the \
workspace unresolvable:\n{stale:#?}"
);
let member_pins = written
.lines()
.filter(|line| line.contains("path = \"crates/") && line.contains("version = "))
.count();
assert!(
member_pins > 0,
"no `[workspace.dependencies]` entry pins a member by path and version. \
Finding none does NOT mean the manifest stopped doing that -- it is far \
more likely this scan stopped matching, in which case the staleness \
check above passed vacuously."
);
let split = original
.find("\n[workspace.dependencies]")
.map(|newline| newline + 1)
.expect("the root manifest must declare a [workspace.dependencies] section");
let (head, tail) = original.split_at(split);
assert!(
head.contains("[workspace.package]"),
"the split must land after [workspace.package] and before the member \
pins, or this fixture is not the one-line bump it claims to be"
);
let one_line_bump = format!(
"{}{}",
head.replace(&format!("version = \"{current}\""), "version = \"9.9.9\""),
tail
);
assert_ne!(
one_line_bump, original,
"the fixture must actually differ from the manifest it was built from"
);
let half_done = temporary.path().join("OneLine.toml");
std::fs::write(&half_done, &one_line_bump).expect("the fixture must be writable");
let (accepted, output) = run_release_script(&["verify-version", "9.9.9", &posix(&half_done)]);
assert!(
!accepted,
"verify-version accepted a manifest whose [workspace.dependencies] \
entries still pin {current}. Cargo would refuse to resolve it, and by \
then the release would already be tagged.\n{output}"
);
assert!(
output.contains(¤t),
"the rejection must show which pin was left behind.\n{output}"
);
}
const EVERY_PIN_SHAPE: &str = r#"[workspace]
resolver = "3"
members = [
"crates/agent",
"crates/newthing",
"tests",
"crates/expanded",
]
[workspace.package]
version = "0.1.0"
[workspace.dependencies]
# The shape that always worked.
runner-manager-agent = { path = "crates/agent", version = "0.1.0" }
# A new member under crates/, which is the easy case.
runner-manager-newthing = { path = "crates/newthing", version = "0.1.0" }
# Key order reversed. TOML does not care and neither may the rewrite.
reversed = { version = "0.1.0", path = "crates/reversed" }
# A MEMBER THAT DOES NOT LIVE UNDER crates/. The root manifest already has one
# of these: `tests`, which is `runner-manager-e2e` in the lock.
runner-manager-e2e = { path = "tests", version = "0.1.0" }
# An ordinary external dependency, whose version must NOT be touched.
tokio = { version = "1.53.1", features = [
"fs",
"macros",
] }
# An expanded table. `path` and `version` are separate lines, and a
# line-oriented rewrite reaching `version` first does not yet know there is a
# `path` below it.
[workspace.dependencies.expanded]
version = "0.1.0"
path = "crates/expanded"
"#;
#[test]
fn setting_the_version_reaches_members_written_in_any_shape() {
let temporary = tempfile::tempdir().expect("a temporary directory");
let manifest = temporary.path().join("Cargo.toml");
std::fs::write(&manifest, EVERY_PIN_SHAPE).expect("the fixture must be writable");
let output = release_script_stdout(&["set-version", "9.9.9", &posix(&manifest)]);
assert!(
output.contains("9.9.9"),
"set-version must report what it wrote:\n{output}"
);
let written = std::fs::read_to_string(&manifest).expect("the rewrite must be readable");
for (label, pinned) in [
(
"under crates/",
"path = \"crates/agent\", version = \"9.9.9\"",
),
(
"a new member",
"path = \"crates/newthing\", version = \"9.9.9\"",
),
(
"reversed keys",
"version = \"9.9.9\", path = \"crates/reversed\"",
),
("outside crates/", "path = \"tests\", version = \"9.9.9\""),
] {
assert!(
written.contains(pinned),
"the {label} pin was not rewritten. Cargo requires a path \
dependency to satisfy the version stated beside it, so this \
workspace would not resolve -- and in a release run that is \
discovered after the tag has been pushed.\nExpected: {pinned}\n\
Got:\n{written}"
);
}
let expanded = written
.split_once("[workspace.dependencies.expanded]")
.expect("the expanded table must survive the rewrite")
.1;
assert!(
expanded.contains("version = \"9.9.9\""),
"the expanded [workspace.dependencies.<name>] table still pins the old \
version. Its `path` sits BELOW its `version`, which is exactly the \
case a single line-oriented pass cannot decide.\n{expanded}"
);
assert!(
written.contains("tokio = { version = \"1.53.1\""),
"set-version rewrote an external dependency's version requirement. It \
may only touch entries that pin a PATH.\n{written}"
);
assert!(
!written.contains("\"0.1.0\""),
"some entry still carries the old version:\n{written}"
);
}
#[test]
fn verify_version_refuses_a_manifest_it_could_only_partly_read() {
let temporary = tempfile::tempdir().expect("a temporary directory");
let unreadable = temporary.path().join("Unreadable.toml");
std::fs::write(
&unreadable,
r#"[workspace]
members = ["crates/agent", "crates/orphan"]
[workspace.package]
version = "9.9.9"
[workspace.dependencies]
runner-manager-agent = { path = "crates/agent", version = "9.9.9" }
path = "crates/orphan"
version = "0.1.0"
"#,
)
.expect("the fixture must be writable");
let (accepted, output) = run_release_script(&["verify-version", "9.9.9", &posix(&unreadable)]);
assert!(
!accepted,
"verify-version affirmed a manifest in which one stated `path` belonged \
to no entry it could read. Nothing checked that entry's version, and \
nothing rewrote it either.\n{output}"
);
assert!(
output.contains("crates/orphan"),
"the rejection must name the path it could not attribute.\n{output}"
);
let real = repository_root().join("Cargo.toml");
let current = release_script_stdout(&["manifest-version", &posix(&real)]);
let (ok, output) = run_release_script(&["verify-version", ¤t, &posix(&real)]);
assert!(
ok,
"verify-version rejected the root manifest at its own current version \
{current}.\n{output}"
);
assert!(
output.contains("declared workspace members are pinned by path and version"),
"verify-version must say how much of the manifest it covered, not \
merely that what it looked at was fine.\n{output}"
);
}
fn stub_codesign(directory: &Path, display_body: &str, display_exit: i32, verify_exit: i32) {
let script = format!(
"#!/usr/bin/env bash\n\
case \"$1\" in\n\
--display) {display_body}; exit {display_exit} ;;\n\
--verify) exit {verify_exit} ;;\n\
esac\n"
);
let path = directory.join("codesign");
std::fs::write(&path, script).expect("the codesign stub must be writable");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755))
.expect("the codesign stub must be executable");
}
}
#[test]
fn the_macos_signature_check_refuses_an_unsigned_binary() {
let temporary = tempfile::tempdir().expect("a temporary directory");
let binary = temporary.path().join("runner-manager");
std::fs::write(&binary, b"not really a mach-o").expect("the fake binary must be writable");
let binary = posix(&binary);
let stub_directory = temporary.path().join("stub");
std::fs::create_dir_all(&stub_directory).expect("the stub directory must be creatable");
stub_codesign(
&stub_directory,
r#"printf 'Executable=%s\nIdentifier=runner-manager\nSignature=adhoc\n' "$3" >&2"#,
0,
0,
);
let (ok, output) =
run_release_script_with_path(&["verify-macos-signature", &binary], Some(&stub_directory));
assert!(
ok,
"an ad-hoc signature is what the linker produces and what D12 requires; \
it must pass.\n{output}"
);
stub_codesign(
&stub_directory,
r#"printf '%s: code object is not signed at all\n' "$3" >&2"#,
1,
1,
);
let (accepted, output) =
run_release_script_with_path(&["verify-macos-signature", &binary], Some(&stub_directory));
assert!(
!accepted,
"a binary carrying no signature must fail the run. An unsigned arm64 \
Mach-O does not execute on Apple Silicon at all (D12).\n{output}"
);
stub_codesign(
&stub_directory,
r#"printf 'Executable=%s\nSignature=adhoc\n' "$3" >&2; printf 'error reading resources\n' >&2"#,
1,
0,
);
let (accepted, output) =
run_release_script_with_path(&["verify-macos-signature", &binary], Some(&stub_directory));
assert!(
!accepted,
"`codesign --display` exiting non-zero must fail the run on the STATUS \
alone. Everything this configuration SAYS would pass -- there is a \
`Signature=` line, no \"not signed\" phrase, and `--verify` succeeds -- \
so if this is accepted, the exit status is not being read.\n{output}"
);
assert!(
output.contains("could not read a signature"),
"the rejection must be the one the status branch produces.\n{output}"
);
stub_codesign(
&stub_directory,
r#"printf 'Executable=%s\nSignature=adhoc\n' "$3" >&2"#,
0,
1,
);
let (accepted, output) =
run_release_script_with_path(&["verify-macos-signature", &binary], Some(&stub_directory));
assert!(
!accepted,
"`codesign --display` succeeding says a signature is THERE, not that it \
is valid. A tampered binary must still fail.\n{output}"
);
stub_codesign(
&stub_directory,
r#"printf 'Executable=%s\nIdentifier=runner-manager\n' "$3" >&2"#,
0,
0,
);
let (accepted, output) =
run_release_script_with_path(&["verify-macos-signature", &binary], Some(&stub_directory));
assert!(
!accepted,
"no `Signature=` or `Authority=` line means nothing established that a \
signature exists, and absence must not read as success.\n{output}"
);
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
struct LockedPackage {
name: String,
version: String,
checksum: Option<String>,
}
fn locked_packages() -> Vec<LockedPackage> {
let lock = repository_root().join("Cargo.lock");
let text = std::fs::read_to_string(&lock).expect("Cargo.lock must be readable");
let mut packages = Vec::new();
let mut name: Option<String> = None;
let mut version: Option<String> = None;
let mut checksum: Option<String> = None;
let quoted = |line: &str, key: &str| -> Option<String> {
let rest = line.trim_end().strip_prefix(key)?.trim_start();
let rest = rest.strip_prefix('=')?.trim();
rest.strip_prefix('"')?
.strip_suffix('"')
.map(str::to_string)
};
fn flush(
name: &mut Option<String>,
version: &mut Option<String>,
checksum: &mut Option<String>,
packages: &mut Vec<LockedPackage>,
) {
if let (Some(package), Some(at)) = (name.take(), version.take()) {
packages.push(LockedPackage {
name: package,
version: at,
checksum: checksum.take(),
});
}
*checksum = None;
}
for line in text.lines() {
let line = line.trim_end();
if line == "[[package]]" {
flush(&mut name, &mut version, &mut checksum, &mut packages);
continue;
}
if let Some(value) = quoted(line, "name") {
name = Some(value);
} else if let Some(value) = quoted(line, "version") {
version = Some(value);
} else if let Some(value) = quoted(line, "checksum") {
checksum = Some(value);
}
}
flush(&mut name, &mut version, &mut checksum, &mut packages);
assert!(
packages.len() > 100,
"Cargo.lock parsed as {} packages, which means this parser is broken \
rather than that the workspace has almost no dependencies",
packages.len()
);
packages
}
fn generate_sbom(directory: &Path, in_scope: Option<&Path>) -> serde_json::Value {
let lock = repository_root().join("Cargo.lock");
let output_path = directory.join("sbom.cdx.json");
let mut arguments = vec![
"sbom".to_string(),
posix(&lock),
posix(&output_path),
"runner-manager".to_string(),
"9.9.9".to_string(),
];
if let Some(list) = in_scope {
arguments.push(posix(list));
}
let borrowed: Vec<&str> = arguments.iter().map(String::as_str).collect();
release_script_stdout(&borrowed);
let text = std::fs::read_to_string(&output_path).expect("the SBOM must have been written");
serde_json::from_str(&text).expect("the SBOM must be valid JSON")
}
#[test]
fn the_sbom_describes_every_locked_package() {
let temporary = tempfile::tempdir().expect("a temporary directory");
let document = generate_sbom(temporary.path(), None);
assert_eq!(document["bomFormat"], "CycloneDX");
assert_eq!(document["specVersion"], "1.5");
assert_eq!(document["metadata"]["component"]["name"], "runner-manager");
assert_eq!(document["metadata"]["component"]["version"], "9.9.9");
let components = document["components"]
.as_array()
.expect("the SBOM must carry a components array");
let locked = locked_packages();
assert_eq!(
components.len(),
locked.len() - 1,
"the SBOM must list every locked package except the product itself, \
which is `metadata.component`"
);
let mut by_key: BTreeMap<(String, String), &serde_json::Value> = BTreeMap::new();
for component in components {
let name = component["name"]
.as_str()
.expect("every component is named");
let version = component["version"]
.as_str()
.expect("every component has a version");
assert!(
by_key
.insert((name.to_string(), version.to_string()), component)
.is_none(),
"{name} {version} was emitted twice"
);
}
let mut hashed = 0usize;
for package in &locked {
if package.name == "runner-manager" {
continue;
}
let key = (package.name.clone(), package.version.clone());
let component = by_key.get(&key).unwrap_or_else(|| {
panic!(
"Cargo.lock locks {} {} and the SBOM does not describe it",
package.name, package.version
)
});
assert_eq!(
component["purl"]
.as_str()
.expect("every component has a purl"),
format!("pkg:cargo/{}@{}", package.name, package.version),
"the package URL must be built from the LOCKED name and version"
);
match &package.checksum {
Some(expected) => {
let hashes = component["hashes"].as_array().unwrap_or_else(|| {
panic!(
"Cargo.lock records a checksum for {} {} and the SBOM \
carries no hash for it",
package.name, package.version
)
});
assert_eq!(hashes[0]["alg"], "SHA-256");
assert_eq!(
hashes[0]["content"].as_str().expect("a hash has content"),
expected,
"{} {}: the SBOM's SHA-256 is not the one Cargo.lock records",
package.name,
package.version
);
hashed += 1;
}
None => assert!(
component["hashes"].is_null(),
"{} {} has no checksum in Cargo.lock -- it is a path dependency \
-- so the SBOM must not claim one",
package.name,
package.version
),
}
}
for anchor in ["serde", "anyhow", "tokio"] {
assert!(
locked.iter().any(|package| package.name == anchor),
"{anchor} is a direct workspace dependency and must appear in \
Cargo.lock; not finding it means this parser read nothing useful"
);
}
assert!(
hashed > components.len() / 2,
"only {hashed} of {} components were cross-checked against a Cargo.lock \
checksum, which means the checksum field is not being read",
components.len()
);
for component in components {
assert!(
component["scope"].is_null(),
"no in-scope list was supplied, so no component may assert a scope: \
{component}"
);
}
}
#[test]
fn the_sbom_marks_what_the_released_binary_does_not_contain() {
let temporary = tempfile::tempdir().expect("a temporary directory");
let locked = locked_packages();
let in_scope_packages: Vec<&LockedPackage> = ["serde", "anyhow"]
.iter()
.map(|wanted| {
locked
.iter()
.find(|package| package.name == *wanted)
.unwrap_or_else(|| panic!("{wanted} must be locked"))
})
.collect();
let list = temporary.path().join("in-scope.txt");
let body: String = in_scope_packages
.iter()
.map(|package| format!("{} {}\n", package.name, package.version))
.collect();
std::fs::write(&list, &body).expect("the in-scope list must be writable");
let document = generate_sbom(temporary.path(), Some(&list));
let components = document["components"]
.as_array()
.expect("the SBOM must carry a components array");
let mut required = 0usize;
let mut excluded = 0usize;
for component in components {
let name = component["name"]
.as_str()
.expect("every component is named");
let version = component["version"]
.as_str()
.expect("every component has a version");
let scope = component["scope"]
.as_str()
.unwrap_or_else(|| panic!("{name} {version} carries no scope: {component}"));
let listed = in_scope_packages
.iter()
.any(|package| package.name == name && package.version == version);
if listed {
assert_eq!(
scope, "required",
"{name} {version} is in the in-scope list and must be marked as \
reaching the binary"
);
required += 1;
} else {
assert_eq!(
scope, "excluded",
"{name} {version} is NOT in the in-scope list, so the document \
must not assert it as part of the released binary"
);
excluded += 1;
}
}
assert_eq!(
required,
in_scope_packages.len(),
"every listed package must have come out `required`"
);
assert!(
excluded > 10,
"only {excluded} components came out `excluded`, which means the scope \
is not being applied rather than that the lock is nearly all runtime"
);
assert_eq!(
components.len(),
locked.len() - 1,
"scoping must not remove components from the inventory"
);
let empty = temporary.path().join("empty.txt");
std::fs::write(&empty, "\n \n").expect("the empty list must be writable");
let lock = repository_root().join("Cargo.lock");
let (accepted, output) = run_release_script(&[
"sbom",
&posix(&lock),
&posix(&temporary.path().join("never.json")),
"runner-manager",
"9.9.9",
&posix(&empty),
]);
assert!(
!accepted,
"an empty in-scope list must be refused: it would mark every component \
excluded and publish that as a finding.\n{output}"
);
}
#[test]
fn a_checksum_line_is_the_bare_asset_name_and_two_spaces() {
let license = repository_root().join("LICENSE");
let line = release_script_stdout(&["sha256", &posix(&license)]);
let (hash, name) = line
.split_once(" ")
.unwrap_or_else(|| panic!("a checksum line must be `<hash> <name>`, got: {line:?}"));
assert_eq!(hash.len(), 64, "not a SHA-256 digest: {hash:?}");
assert!(
hash.chars()
.all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()),
"the digest must be lower-case hex: {hash:?}"
);
assert_eq!(
name, "LICENSE",
"the recorded name must be the bare asset name. A SHA256SUMS carrying \
build-machine paths cannot be checked by whoever downloaded the assets."
);
}
fn significant(raw: &str) -> Option<(usize, &str)> {
let trimmed = raw.trim_end();
let body = trimmed.trim_start();
if body.is_empty() || body.starts_with('#') {
return None;
}
Some((trimmed.len() - body.len(), body))
}
fn job_dependencies(source: &str) -> BTreeMap<String, Vec<String>> {
let mut graph: BTreeMap<String, Vec<String>> = BTreeMap::new();
let mut inside_jobs = false;
let mut current: Option<String> = None;
for raw in source.lines() {
let Some((indent, body)) = significant(raw) else {
continue;
};
if indent == 0 {
inside_jobs = body.starts_with("jobs:");
current = None;
continue;
}
if !inside_jobs {
continue;
}
if indent == 2 {
if let Some((key, _)) = body.split_once(':') {
let name = key.trim().trim_matches(['"', '\'']).to_string();
graph.entry(name.clone()).or_default();
current = Some(name);
}
continue;
}
if indent == 4
&& let Some(rest) = body.strip_prefix("needs:")
{
let rest = rest.trim();
let items: Vec<String> = match rest.strip_prefix('[').and_then(|r| r.strip_suffix(']'))
{
Some(inner) => inner
.split(',')
.map(|item| item.trim().trim_matches(['"', '\'']).to_string())
.filter(|item| !item.is_empty())
.collect(),
None if !rest.is_empty() => vec![rest.trim_matches(['"', '\'']).to_string()],
None => Vec::new(),
};
if let Some(job) = current.as_ref() {
graph.insert(job.clone(), items);
}
}
}
graph
}
fn upstream_of(graph: &BTreeMap<String, Vec<String>>, job: &str) -> BTreeSet<String> {
let mut seen = BTreeSet::new();
let mut queue = vec![job.to_string()];
while let Some(current) = queue.pop() {
for parent in graph.get(¤t).into_iter().flatten() {
if seen.insert(parent.clone()) {
queue.push(parent.clone());
}
}
}
seen
}
fn run_bodies(source: &str) -> Vec<String> {
let mut bodies = Vec::new();
let lines: Vec<&str> = source.lines().collect();
let mut index = 0usize;
while index < lines.len() {
let Some((indent, body)) = significant(lines[index]) else {
index += 1;
continue;
};
let Some(rest) = body.strip_prefix("run:") else {
index += 1;
continue;
};
let rest = rest.trim();
index += 1;
if rest.starts_with('|') || rest.starts_with('>') {
let mut block = String::new();
while index < lines.len() {
let raw = lines[index];
if raw.trim().is_empty() {
index += 1;
continue;
}
let line_indent = raw.trim_end().len() - raw.trim().len();
if line_indent <= indent {
break;
}
block.push_str(raw);
block.push('\n');
index += 1;
}
bodies.push(block);
} else {
bodies.push(rest.to_string());
}
}
bodies
}
#[derive(Debug, Default, Clone)]
struct WorkflowStep {
job: String,
name: String,
condition: String,
run: String,
}
fn workflow_steps(source: &str) -> Vec<WorkflowStep> {
let lines: Vec<&str> = source.lines().collect();
let mut steps: Vec<WorkflowStep> = Vec::new();
let mut inside_jobs = false;
let mut job = String::new();
let mut steps_indent: Option<usize> = None;
let mut key_indent = 0usize;
let mut index = 0usize;
while index < lines.len() {
let Some((indent, body)) = significant(lines[index]) else {
index += 1;
continue;
};
if indent == 0 {
inside_jobs = body.starts_with("jobs:");
job.clear();
steps_indent = None;
index += 1;
continue;
}
if !inside_jobs {
index += 1;
continue;
}
if indent == 2 {
job = body
.split_once(':')
.map(|(key, _)| key.trim().trim_matches(['"', '\'']).to_string())
.unwrap_or_default();
steps_indent = None;
index += 1;
continue;
}
let Some(start) = steps_indent else {
if body == "steps:" {
steps_indent = Some(indent);
}
index += 1;
continue;
};
if indent <= start {
steps_indent = None;
continue;
}
let (key, starts_a_step) = match body.strip_prefix("- ") {
Some(rest) => (rest, true),
None => (body, false),
};
if starts_a_step {
key_indent = indent + 2;
steps.push(WorkflowStep {
job: job.clone(),
..WorkflowStep::default()
});
} else if indent != key_indent || steps.is_empty() {
index += 1;
continue;
}
let step = steps.last_mut().expect("a step was just pushed or exists");
if let Some(rest) = key.strip_prefix("name:") {
step.name = rest.trim().to_string();
index += 1;
} else if let Some(rest) = key.strip_prefix("if:") {
step.condition = rest.trim().to_string();
index += 1;
} else if let Some(rest) = key.strip_prefix("run:") {
let rest = rest.trim();
index += 1;
if rest.starts_with('|') || rest.starts_with('>') {
let mut block = String::new();
while index < lines.len() {
let raw = lines[index];
if raw.trim().is_empty() {
index += 1;
continue;
}
let line_indent = raw.trim_end().len() - raw.trim().len();
if line_indent <= key_indent {
break;
}
block.push_str(raw);
block.push('\n');
index += 1;
}
step.run = block;
} else {
step.run = rest.to_string();
}
} else {
index += 1;
}
}
steps
}
fn job_scalars(source: &str) -> BTreeMap<String, BTreeMap<String, String>> {
let mut jobs: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new();
let mut inside_jobs = false;
let mut current = String::new();
for raw in source.lines() {
let Some((indent, body)) = significant(raw) else {
continue;
};
if indent == 0 {
inside_jobs = body.starts_with("jobs:");
current.clear();
continue;
}
if !inside_jobs {
continue;
}
if indent == 2 {
current = body
.split_once(':')
.map(|(key, _)| key.trim().trim_matches(['"', '\'']).to_string())
.unwrap_or_default();
jobs.entry(current.clone()).or_default();
continue;
}
if indent == 4
&& let Some((key, value)) = body.split_once(':')
&& !value.trim().is_empty()
&& let Some(entry) = jobs.get_mut(¤t)
{
entry.insert(key.trim().to_string(), value.trim().to_string());
}
}
jobs
}
fn matrix_include(source: &str, job: &str) -> Vec<BTreeMap<String, String>> {
let mut entries: Vec<BTreeMap<String, String>> = Vec::new();
let mut inside_jobs = false;
let mut current = String::new();
let mut include_indent: Option<usize> = None;
for raw in source.lines() {
let Some((indent, body)) = significant(raw) else {
continue;
};
if indent == 0 {
inside_jobs = body.starts_with("jobs:");
current.clear();
include_indent = None;
continue;
}
if !inside_jobs {
continue;
}
if indent == 2 {
current = body
.split_once(':')
.map(|(key, _)| key.trim().trim_matches(['"', '\'']).to_string())
.unwrap_or_default();
include_indent = None;
continue;
}
if current != job {
continue;
}
match include_indent {
None => {
if body == "include:" {
include_indent = Some(indent);
}
}
Some(start) if indent <= start => include_indent = None,
Some(_) => {
if let Some(item) = body.strip_prefix("- ") {
let mut entry = BTreeMap::new();
if let Some((key, value)) = item.split_once(':') {
entry.insert(key.trim().to_string(), value.trim().to_string());
}
entries.push(entry);
} else if let Some((key, value)) = body.split_once(':')
&& let Some(entry) = entries.last_mut()
{
entry.insert(key.trim().to_string(), value.trim().to_string());
}
}
}
}
entries
}
#[test]
fn every_release_sh_decision_is_reached_from_a_step() {
let source = read_workflow("release.yml");
let steps = workflow_steps(&source);
assert!(
steps.len() > 20,
"only {} steps parsed out of release.yml, which means this scanner is \
broken rather than that the workflow has almost no steps",
steps.len()
);
for (subcommand, why) in [
(
"release.sh check-format",
"step 1 -- without it a malformed version is never rejected",
),
(
"release.sh manifest-version",
"step 2's first source -- the Cargo.toml version",
),
(
"release.sh latest-release-version",
"step 2's second source -- the latest published release",
),
(
"release.sh check-monotonic",
"step 2 -- without it a release can regress the version",
),
(
"release.sh set-version",
"step 4 -- without it the artifacts carry the old version",
),
(
"release.sh check-native-runner",
"steps 1-2 and 5 -- without it a repointed `runs-on` label builds \
the wrong platform under the right artifact name",
),
(
"release.sh verify-macos-signature",
"step 5 -- without it an unsigned arm64 binary ships, and it does \
not execute on Apple Silicon at all (D12)",
),
(
"release.sh sha256",
"step 6 -- without it there is nothing to build SHA256SUMS from",
),
(
"release.sh sbom",
"step 6 -- the release page must carry an SBOM",
),
] {
assert!(
steps.iter().any(|step| step.run.contains(subcommand)),
"no step in release.yml runs `{subcommand}`. It is {why}. The \
subcommand's own tests in this file pass whether or not the \
workflow still calls it, so this is the only assertion that \
notices the step being deleted."
);
}
let signature: Vec<&WorkflowStep> = steps
.iter()
.filter(|step| step.run.contains("release.sh verify-macos-signature"))
.collect();
assert_eq!(
signature.len(),
1,
"expected exactly one step to verify the macOS signature, found {}: {:?}",
signature.len(),
signature.iter().map(|step| &step.name).collect::<Vec<_>>()
);
let signature = signature[0];
assert!(
signature.condition.contains("runner.os == 'macOS'"),
"the signature check must be gated on `runner.os == 'macOS'`, not on \
the matrix target or the runner label: it is the OS that decides \
whether `codesign` exists. Found `if: {}`",
signature.condition
);
assert_eq!(
signature.job, "build",
"the signature check must run in the job that produced the binary, so \
that what it inspects is this leg's own output"
);
}
#[test]
fn every_build_label_is_proved_before_anything_is_written() {
let source = read_workflow("release.yml");
let graph = job_dependencies(&source);
assert!(
graph.contains_key("preflight"),
"release.yml must declare a `preflight` job. Parsed jobs: {:?}",
graph.keys().collect::<Vec<_>>()
);
let above_tag = upstream_of(&graph, "tag");
assert!(
above_tag.contains("preflight"),
"`tag` is the first job that cannot be undone, so `preflight` must sit \
above it. Resolved upstream of tag: {above_tag:?}"
);
let preflight = matrix_include(&source, "preflight");
let build = matrix_include(&source, "build");
assert_eq!(
build.len(),
5,
"expected the five documented targets in the build matrix, parsed: {build:?}"
);
let labels = |entries: &[BTreeMap<String, String>]| -> BTreeSet<(String, String)> {
entries
.iter()
.map(|entry| {
(
entry
.get("target")
.unwrap_or_else(|| panic!("a matrix entry has no target: {entry:?}"))
.clone(),
entry
.get("os")
.unwrap_or_else(|| panic!("a matrix entry has no os: {entry:?}"))
.clone(),
)
})
.collect()
};
assert_eq!(
labels(&preflight),
labels(&build),
"`preflight` must check the same target/label pairs `build` will use, \
including the repository-variable overrides. A label proved by nothing \
is the one that hangs after the tag is pushed."
);
let jobs = job_scalars(&source);
assert!(
jobs.len() >= 6,
"only {} jobs parsed out of release.yml: {:?}",
jobs.len(),
jobs.keys().collect::<Vec<_>>()
);
for (name, keys) in &jobs {
if !keys.contains_key("runs-on") {
continue;
}
let timeout = keys.get("timeout-minutes").unwrap_or_else(|| {
panic!(
"job `{name}` declares `runs-on` and no `timeout-minutes`, so \
GitHub's 360-minute default applies. This workflow pushes a tag \
before it builds anything: six hours of a wedged leg is six \
hours of a tag with nothing published."
)
});
let minutes: u32 = timeout.parse().unwrap_or_else(|_| {
panic!("job `{name}` has a non-numeric timeout-minutes: {timeout}")
});
assert!(
(1..=60).contains(&minutes),
"job `{name}` allows {minutes} minutes. A release build of this \
workspace is minutes, not hours, and the whole point of the value \
is to be far below the default."
);
}
}
#[test]
fn a_release_runner_must_be_native_in_both_os_and_architecture() {
let native = [
("x86_64-pc-windows-msvc", "Windows", "X64"),
("aarch64-apple-darwin", "macOS", "ARM64"),
("x86_64-apple-darwin", "macOS", "X64"),
("x86_64-unknown-linux-gnu", "Linux", "X64"),
("aarch64-unknown-linux-gnu", "Linux", "ARM64"),
];
for (target, os, arch) in native {
let (ok, output) = run_release_script(&["check-native-runner", target, os, arch]);
assert!(
ok,
"check-native-runner rejected {target} on a {os} {arch} runner, \
which is the native pairing this release matrix uses.\n{output}"
);
}
for (target, os, arch) in [
("x86_64-pc-windows-msvc", "Windows", "ARM64"),
("aarch64-apple-darwin", "macOS", "X64"),
("x86_64-unknown-linux-gnu", "Linux", "ARM64"),
("aarch64-unknown-linux-gnu", "Linux", "X64"),
] {
let (accepted, output) = run_release_script(&["check-native-runner", target, os, arch]);
assert!(
!accepted,
"{target} was accepted on a {arch} runner. Nothing here \
cross-compiles.\n{output}"
);
}
for (target, os, arch, why) in [
(
"x86_64-apple-darwin",
"Linux",
"X64",
"a Linux x64 label behind RUNNER_MANAGER_RELEASE_RUNS_ON_MACOS_X64 \
would produce an ELF named as a macOS artifact -- and skip the \
signature gate, which is conditional on runner.os",
),
(
"x86_64-pc-windows-msvc",
"Linux",
"X64",
"a Linux runner cannot produce an MSVC binary",
),
(
"x86_64-unknown-linux-gnu",
"macOS",
"X64",
"a macOS runner cannot produce a linux-gnu binary",
),
(
"aarch64-apple-darwin",
"Linux",
"ARM64",
"matching architecture is not matching platform",
),
] {
let (accepted, output) = run_release_script(&["check-native-runner", target, os, arch]);
assert!(
!accepted,
"{target} was accepted on a {os} {arch} runner: {why}.\n{output}"
);
assert!(
output.contains("must be built on a"),
"the rejection must name what the target needed.\n{output}"
);
}
for target in [
"riscv64gc-unknown-linux-gnu",
"x86_64-unknown-freebsd",
"nonsense",
] {
let (accepted, output) =
run_release_script(&["check-native-runner", target, "Linux", "X64"]);
assert!(
!accepted,
"check-native-runner accepted the unrecognised target {target}. A \
target it cannot classify is one it cannot vouch for.\n{output}"
);
}
let source = read_workflow("release.yml");
let steps = workflow_steps(&source);
for job in ["preflight", "build"] {
assert!(
steps
.iter()
.any(|step| step.job == job && step.run.contains("release.sh check-native-runner")),
"job `{job}` must run `release.sh check-native-runner`. Parsed \
steps for it: {:?}",
steps
.iter()
.filter(|step| step.job == job)
.map(|step| &step.name)
.collect::<Vec<_>>()
);
}
}
#[test]
fn nothing_is_published_without_the_full_test_matrix() {
let source = read_workflow("release.yml");
let graph = job_dependencies(&source);
for required in ["validate", "test", "tag", "build", "sbom", "publish"] {
assert!(
graph.contains_key(required),
"release.yml must declare a `{required}` job. Parsed jobs: {:?}",
graph.keys().collect::<Vec<_>>()
);
}
let upstream = upstream_of(&graph, "publish");
for required in ["validate", "test", "tag", "build", "sbom"] {
assert!(
upstream.contains(required),
"`publish` must wait, directly or transitively, on `{required}`. \
A release that can publish without it is a release that publishes \
untested or unbuilt code. Resolved upstream of publish: {upstream:?}"
);
}
let above_tag = upstream_of(&graph, "tag");
assert!(
above_tag.contains("validate") && above_tag.contains("test"),
"`tag` writes the version commit and pushes the tag, so it must wait on \
both `validate` and `test`. Resolved upstream of tag: {above_tag:?}"
);
let above_build = upstream_of(&graph, "build");
assert!(
above_build.contains("tag"),
"`build` must wait on `tag` so the artifacts carry the version that was \
written. Resolved upstream of build: {above_build:?}"
);
}
#[test]
fn the_test_gate_calls_ci_rather_than_reimplementing_it() {
let release = read_workflow("release.yml");
assert!(
release.contains("uses: ./.github/workflows/ci.yml"),
"release.yml's test gate must CALL ci.yml. A copied matrix drifts, and a \
drifted copy means the release is gated on checks CI used to run."
);
let ci = read_workflow("ci.yml");
let has_entry_point = ci
.lines()
.filter_map(significant)
.any(|(indent, body)| indent == 2 && body.starts_with("workflow_call:"));
assert!(
has_entry_point,
"ci.yml must declare `workflow_call:` in its `on:` block, or \
release.yml's `uses:` cannot reach it"
);
}
#[test]
fn every_published_target_is_covered_by_the_build_matrix() {
let source = read_workflow("release.yml");
let mut lines = source.lines();
let key_indent = loop {
match lines.next() {
Some(line) if line.trim_start().starts_with("RELEASE_TARGETS:") => {
break line.trim_end().len() - line.trim().len();
}
Some(_) => continue,
None => panic!("release.yml must declare `env.RELEASE_TARGETS`"),
}
};
let mut published: BTreeSet<String> = BTreeSet::new();
for line in lines {
let Some((indent, body)) = significant(line) else {
continue;
};
if indent <= key_indent {
break;
}
published.extend(body.split_whitespace().map(String::from));
}
let built: BTreeSet<String> = source
.lines()
.filter_map(|line| line.trim().strip_prefix("- target:"))
.map(|target| target.trim().to_string())
.collect();
assert!(
published.len() >= 5,
"expected the five documented targets in RELEASE_TARGETS, parsed: {published:?}"
);
assert_eq!(
built, published,
"the build matrix and RELEASE_TARGETS must name the same targets. They \
are two lists because the `env` context is not available in a matrix, \
and this is the assertion that keeps them from drifting -- a target \
added to one and not the other either never gets built or never gets \
checked for at publication."
);
for target in [
"x86_64-pc-windows-msvc",
"aarch64-apple-darwin",
"x86_64-apple-darwin",
"x86_64-unknown-linux-gnu",
"aarch64-unknown-linux-gnu",
] {
assert!(
published.contains(target),
"{target} must be published. Parsed: {published:?}"
);
}
}
#[test]
fn the_release_workflow_never_deletes_or_overwrites_what_it_published() {
let source = read_workflow("release.yml");
let executable = run_bodies(&source).join("\n");
assert!(
!executable.is_empty(),
"no `run:` bodies parsed out of release.yml; every absence asserted \
below would be vacuous"
);
for forbidden in [
"gh release delete",
"release delete-asset",
"git tag -d",
"push --delete",
"push -d ",
"push --force",
"push -f ",
"--force-with-lease",
"-X DELETE",
] {
assert!(
!executable.contains(forbidden),
"a `run:` body in release.yml executes `{forbidden}`. Recovery from \
a failed release is documented in the workflow header and performed \
by an operator, never by the workflow itself."
);
}
assert!(
executable.contains("gh release create"),
"release.yml must create the release -- the positive half, without which \
every absence asserted above is satisfied by a workflow that publishes \
nothing at all"
);
assert!(
executable.contains("--verify-tag"),
"`gh release create --verify-tag` refuses to invent a tag, so the \
release can only ever be published against the tag the `tag` job \
actually pushed"
);
assert!(
executable.contains("git push --atomic"),
"the version commit and the tag must be pushed atomically: a partial \
push is exactly the tagged-but-unpublished state an operator then has \
to clean up by hand"
);
for required in [
"release.sh check-format",
"release.sh check-monotonic",
"release.sh latest-release-version",
"release.sh set-version",
"release.sh check-native-runner",
"release.sh verify-macos-signature",
"release.sh sbom",
] {
assert!(
executable.contains(required),
"no `run:` body in release.yml executes `{required}`. The decision \
it makes is one this workflow is required to make, and its own \
tests cannot tell that the step invoking it is gone."
);
}
assert!(
executable.contains("cargo tree"),
"the SBOM's `scope` must be resolved from `cargo tree`, or the \
published document describes the whole workspace lock as the contents \
of the binary"
);
let generator = run_bodies(&source)
.into_iter()
.find(|body| body.contains("release.sh sbom"))
.expect("a step must invoke `release.sh sbom`");
assert!(
generator.contains("in-scope.txt"),
"the in-scope list must be passed to `release.sh sbom` as its fifth \
argument. Without it the generator emits no `scope` at all, and the \
release notes then describe a distinction the document does not \
make:\n{generator}"
);
assert!(
source.contains("OPERATOR RECOVERY"),
"release.yml must document the operator recovery for a failure after \
tagging (`09-release-distribution.md`)"
);
}
#[test]
fn the_dispatch_input_never_reaches_a_shell_through_interpolation() {
let source = read_workflow("release.yml");
let bodies = run_bodies(&source);
assert!(
bodies.len() > 10,
"only {} `run:` bodies parsed out of release.yml, which means this scan \
is broken rather than that the workflow has almost no steps",
bodies.len()
);
for body in &bodies {
assert!(
!body.contains("${{"),
"a `run:` body in release.yml interpolates a workflow expression. \
Pass the value through `env:` and read it as a shell variable \
instead:\n{body}"
);
}
assert!(
source.contains("VERSION: ${{ inputs.version }}"),
"the version input must reach the steps through an `env:` binding"
);
}