use ic_json::Json;
const VERSION: &str = env!("CARGO_PKG_VERSION");
const LICENSE: &str = "AGPL-3.0-or-later";
const REPOSITORY: &str = "https://github.com/nervosys/IronCrypto";
struct Component {
name: &'static str,
description: &'static str,
}
const COMPONENTS: &[Component] = &[
Component {
name: "ic-core",
description: "Traits, error types, constant-time helpers, zeroization and entropy.",
},
Component {
name: "ic-hash",
description: "SHA-2, SHA-3, SHAKE, the SP 800-185 derived functions, and BLAKE2.",
},
Component {
name: "ic-mac",
description: "HMAC over SHA-2 and SHA-3, CMAC over AES, and KMAC.",
},
Component {
name: "ic-kdf",
description: "HKDF, KBKDF, PBKDF2 and Argon2.",
},
Component {
name: "ic-cipher",
description: "AES, ChaCha20, the AEAD modes, POLYVAL and key wrapping.",
},
Component {
name: "ic-drbg",
description: "CTR_DRBG and HMAC_DRBG, with the random source callers hold.",
},
Component {
name: "ic-ec",
description: "The NIST prime curves, X25519 and Ed25519.",
},
Component {
name: "ic-rsa",
description: "RSA keys, PKCS#1 v1.5 and PSS.",
},
Component {
name: "ic-pkix",
description: "A strict DER reader and writer, SubjectPublicKeyInfo, PKCS#8 and PEM.",
},
Component {
name: "ic-mlkem",
description: "ML-KEM-512, -768 and -1024 (FIPS 203), each checked against 50 NIST ACVP cases.",
},
Component {
name: "ic-mldsa",
description: "ML-DSA-44, -65 and -87 (FIPS 204), each checked against 55 NIST ACVP cases.",
},
Component {
name: "ic-json",
description: "A small JSON reader and writer, so the tooling needs no dependency.",
},
Component {
name: "ic-vectors",
description: "Loading test vectors that are not in the repository.",
},
Component {
name: "ic-fips",
description: "Approved-mode policy, self-tests, error state and service indicators.",
},
Component {
name: "ic-ontology",
description: "The algorithm registry, the standards knowledgebase and the frameworks.",
},
Component {
name: "ironcrypto",
description: "The facade crate that re-exports everything above.",
},
Component {
name: "ic-cli",
description: "The ic command line tool and its MCP server.",
},
Component {
name: "ic-rustls",
description: "IronCrypto as a rustls CryptoProvider. The one crate here that depends on anything outside the workspace: it implements rustls's traits, so it requires rustls.",
},
];
fn purl(name: &str) -> String {
format!("pkg:cargo/{name}@{VERSION}")
}
pub fn cyclonedx() -> Json {
let components: Vec<Json> = COMPONENTS
.iter()
.map(|c| {
Json::object([
("type", Json::str("library")),
("bom-ref", Json::str(purl(c.name))),
("name", Json::str(c.name)),
("version", Json::str(VERSION)),
("description", Json::str(c.description)),
("purl", Json::str(purl(c.name))),
("scope", Json::str("required")),
(
"licenses",
Json::Array(vec![Json::object([(
"license",
Json::object([("id", Json::str(LICENSE))]),
)])]),
),
])
})
.collect();
Json::object([
("bomFormat", Json::str("CycloneDX")),
("specVersion", Json::str("1.5")),
("version", Json::Number(1.0)),
(
"metadata",
Json::object([
(
"component",
Json::object([
("type", Json::str("library")),
("bom-ref", Json::str(purl("ironcrypto"))),
("name", Json::str("ironcrypto")),
("version", Json::str(VERSION)),
("purl", Json::str(purl("ironcrypto"))),
(
"description",
Json::str(
"A FIPS-disciplined cryptography library in pure Rust with \
zero third-party dependencies. Not FIPS-validated.",
),
),
(
"licenses",
Json::Array(vec![Json::object([(
"license",
Json::object([("id", Json::str(LICENSE))]),
)])]),
),
(
"externalReferences",
Json::Array(vec![Json::object([
("type", Json::str("vcs")),
("url", Json::str(REPOSITORY)),
])]),
),
]),
),
(
"properties",
Json::Array(vec![
Json::object([
("name", Json::str("ironcrypto:third-party-dependencies")),
("value", Json::str("0")),
]),
Json::object([
("name", Json::str("ironcrypto:fips-validated")),
("value", Json::str("false")),
]),
Json::object([
("name", Json::str("ironcrypto:deterministic")),
(
"value",
Json::str(
"true; no timestamp or serial number, so the document \
can be regenerated and compared",
),
),
]),
]),
),
]),
),
("components", Json::Array(components)),
])
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn workspace_root() -> PathBuf {
let mut here = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
for _ in 0..4 {
if here.join("Cargo.toml").is_file() && here.join("crates").is_dir() {
return here;
}
if !here.pop() {
break;
}
}
panic!("could not find the workspace root");
}
fn manifest_members() -> Vec<String> {
let text = std::fs::read_to_string(workspace_root().join("Cargo.toml")).unwrap();
let start = text.find("members = [").expect("a members list");
let end = text[start..].find(']').expect("a closing bracket") + start;
text[start..end]
.lines()
.filter_map(|l| {
let l = l.trim().trim_end_matches(',').trim_matches('"');
l.strip_prefix("crates/").map(str::to_string)
})
.collect()
}
fn manifest_field(key: &str) -> String {
let text = std::fs::read_to_string(workspace_root().join("Cargo.toml")).unwrap();
let section = text
.split_once("[workspace.package]")
.expect("a [workspace.package] section")
.1;
let section = section.split("\n[").next().unwrap();
for line in section.lines() {
if let Some((k, v)) = line.split_once('=') {
if k.trim() == key {
return v.trim().trim_matches('"').to_string();
}
}
}
panic!("the manifest has no {key} in [workspace.package]");
}
#[test]
fn ci_and_the_local_gate_run_the_same_checks() {
let root = workspace_root();
let ci = std::fs::read_to_string(root.join(".github/workflows/ci.yml"))
.expect("the CI workflow");
let check =
std::fs::read_to_string(root.join("scripts/check.sh")).expect("the check script");
let gates = [
("formatting", "cargo fmt --all --check"),
(
"clippy",
"cargo clippy --workspace --all-targets --all-features -- -D warnings",
),
("tests", "cargo test --workspace --all-features"),
("zero dependencies", "no-third-party.sh"),
("docs", "cargo doc --workspace --no-deps --all-features"),
("strict docs", "-D warnings"),
("no_std", "--no-default-features --target"),
];
for (name, fragment) in gates {
assert!(
check.contains(fragment),
"scripts/check.sh does not run the {name} gate ({fragment:?})"
);
assert!(
ci.contains(fragment),
"CI does not run the {name} gate ({fragment:?}), so it gates on \
less than a local run does"
);
}
assert!(
ci.contains("RUSTDOCFLAGS: -D warnings"),
"CI no longer fails the build on a rustdoc warning"
);
assert!(
check.contains(r#"RUSTDOCFLAGS="-D warnings""#),
"scripts/check.sh no longer fails on a rustdoc warning, so a broken \
doc link is a warning locally and an error in CI"
);
assert!(
!ci.contains("cargo tree"),
"the CI workflow has its own copy of the dependency check again; it \
belongs in scripts/no-third-party.sh, which reads the workspace \
members from the manifest instead of listing them"
);
assert!(
!check.contains("cargo tree"),
"scripts/check.sh has its own copy of the dependency check again"
);
let self_hosted = std::fs::read_to_string(root.join(".github/workflows/self-hosted.yml"))
.expect("the self-hosted workflow");
assert!(
self_hosted.contains("bash scripts/check.sh"),
"the self-hosted workflow no longer runs the gate script"
);
for copied in ["cargo tree", "cargo clippy", "cargo fmt"] {
assert!(
!self_hosted.contains(copied),
"the self-hosted workflow has its own copy of {copied:?}; it should call scripts/check.sh, which already runs it"
);
}
let shared = std::fs::read_to_string(root.join("scripts/no-third-party.sh"))
.expect("the shared dependency check");
assert!(
shared.contains("cargo tree") && shared.contains("Cargo.toml"),
"the shared check no longer reads the manifest, so it is back to \
carrying a list of its own"
);
}
#[test]
fn every_crate_publishes_together() {
let root = workspace_root();
let workspace = std::fs::read_to_string(root.join("Cargo.toml")).unwrap();
assert!(
workspace.contains("publish = true") || workspace.contains("publish = false"),
"the workspace states no publish setting, so the default decides it and nothing records why"
);
let mut checked = 0;
for name in manifest_members() {
let path = root.join("crates").join(&name).join("Cargo.toml");
let text =
std::fs::read_to_string(&path).unwrap_or_else(|_| panic!("{name} has no manifest"));
assert!(
text.contains("publish.workspace = true"),
"{name} does not inherit the workspace publish setting"
);
assert!(
!text.contains("publish = true") && !text.contains("publish = false"),
"{name} sets its own publish value instead of inheriting"
);
checked += 1;
}
assert!(
checked >= 10,
"only {checked} crates checked; the member list is wrong"
);
}
#[test]
fn the_declared_licence_is_the_workspace_licence() {
assert_eq!(
LICENSE,
manifest_field("license"),
"the SBOM claims a licence the workspace does not set"
);
assert_eq!(
REPOSITORY,
manifest_field("repository"),
"the SBOM points somewhere the workspace does not"
);
let bom = cyclonedx();
let components = bom.get("components").unwrap().as_array().unwrap();
let mut stated = 0;
for c in components {
let id = c
.get("licenses")
.and_then(|l| l.as_array())
.and_then(|l| l.first())
.and_then(|l| l.get("license"))
.and_then(|l| l.get("id"))
.and_then(Json::as_str)
.unwrap_or_else(|| {
panic!(
"{} states no licence",
c.get("name")
.and_then(Json::as_str)
.unwrap_or("a component")
)
});
assert_eq!(id, LICENSE);
stated += 1;
}
assert_eq!(stated, components.len(), "not every component was examined");
assert!(stated > 10, "only {stated} components in the document");
}
#[test]
fn the_bill_of_materials_matches_the_workspace() {
let mut expected = manifest_members();
let mut listed: Vec<String> = COMPONENTS.iter().map(|c| c.name.to_string()).collect();
assert!(
expected.len() >= 15,
"the manifest parse found only {} members, which suggests it broke rather than \
that the workspace shrank",
expected.len()
);
expected.sort();
listed.sort();
assert_eq!(
listed, expected,
"the SBOM component list and the workspace manifest disagree"
);
}
#[test]
fn components_are_complete() {
let bom = cyclonedx();
let components = bom.get("components").unwrap().as_array().unwrap();
assert_eq!(components.len(), COMPONENTS.len());
for c in components {
let name = c.get("name").unwrap().as_str().unwrap();
assert!(!name.is_empty());
assert_eq!(c.get("version").unwrap().as_str(), Some(VERSION));
assert_eq!(
c.get("purl").unwrap().as_str(),
Some(format!("pkg:cargo/{name}@{VERSION}").as_str())
);
let desc = c.get("description").unwrap().as_str().unwrap();
assert!(desc.len() > 20, "{name} has a thin description");
let licenses = c.get("licenses").unwrap().as_array().unwrap();
assert_eq!(
licenses[0]
.get("license")
.unwrap()
.get("id")
.unwrap()
.as_str(),
Some(LICENSE)
);
}
}
#[test]
fn the_document_carries_its_own_caveats() {
let bom = cyclonedx();
let props = bom
.get("metadata")
.unwrap()
.get("properties")
.unwrap()
.as_array()
.unwrap();
let find = |name: &str| {
props
.iter()
.find(|p| p.get("name").and_then(|v| v.as_str()) == Some(name))
.and_then(|p| p.get("value"))
.and_then(|v| v.as_str())
.unwrap_or("")
};
assert_eq!(find("ironcrypto:third-party-dependencies"), "0");
assert_eq!(find("ironcrypto:fips-validated"), "false");
assert!(find("ironcrypto:deterministic").starts_with("true"));
let desc = bom
.get("metadata")
.unwrap()
.get("component")
.unwrap()
.get("description")
.unwrap()
.as_str()
.unwrap();
assert!(
desc.contains("Not FIPS-validated"),
"the top-level description must say so: {desc}"
);
}
#[test]
fn the_document_is_reproducible() {
assert_eq!(cyclonedx().to_string(), cyclonedx().to_string());
let text = cyclonedx().to_string();
assert!(
!text.contains("\"timestamp\":"),
"a timestamp field would break reproducibility"
);
assert!(
!text.contains("\"serialNumber\":"),
"a random serial number would break reproducibility"
);
}
#[test]
fn the_document_is_well_formed_cyclonedx() {
let text = cyclonedx().to_string();
let parsed = ic_json::parse(&text).expect("valid JSON");
assert_eq!(parsed.get("bomFormat").unwrap().as_str(), Some("CycloneDX"));
assert_eq!(parsed.get("specVersion").unwrap().as_str(), Some("1.5"));
assert_eq!(parsed.get("version").unwrap().as_f64(), Some(1.0));
assert!(parsed.get("components").unwrap().as_array().is_some());
}
const STATUS_CLAIMS: &[&str] = &[
"**experimental",
"not vector-tested",
"not interoperability-tested",
"no acvp",
"no interoperability vector",
"no signature scheme yet",
"no signature scheme here yet",
];
fn claims_in(text: &str) -> Vec<&'static str> {
let lower = text
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.to_lowercase();
STATUS_CLAIMS
.iter()
.copied()
.filter(|c| lower.contains(c))
.collect()
}
fn crates_with_experimental_entries() -> std::collections::BTreeSet<String> {
ic_ontology::all()
.iter()
.filter(|e| e.status == ic_ontology::ImplStatus::Experimental)
.filter_map(|e| e.rust_path.split("::").next())
.filter(|c| !c.is_empty())
.map(|c| c.replace('_', "-"))
.collect()
}
#[test]
fn no_crate_documents_a_status_the_ontology_contradicts() {
let root = workspace_root();
let experimental = crates_with_experimental_entries();
let mut problems = Vec::new();
let (mut crates, mut doc_lines) = (0usize, 0usize);
for name in manifest_members() {
let dir = root.join("crates").join(&name);
if experimental.contains(&name) {
continue;
}
crates += 1;
let manifest = std::fs::read_to_string(dir.join("Cargo.toml")).unwrap();
if let Some(line) = manifest.lines().find(|l| l.starts_with("description")) {
for c in claims_in(line) {
problems.push(format!("{name}/Cargo.toml description says {c:?}"));
}
}
let mut stack = vec![dir.join("src")];
while let Some(d) = stack.pop() {
for entry in std::fs::read_dir(&d).unwrap() {
let path = entry.unwrap().path();
if path.is_dir() {
stack.push(path);
} else if path.extension().is_some_and(|x| x == "rs") {
let text = std::fs::read_to_string(&path).unwrap();
let docs: Vec<&str> = text
.lines()
.filter_map(|l| l.trim_start().strip_prefix("//!"))
.collect();
doc_lines += docs.len();
for c in claims_in(&docs.join(" ")) {
let rel = path.strip_prefix(&root).unwrap().display();
problems.push(format!("{rel} says {c:?}"));
}
}
}
}
}
for component in COMPONENTS {
if experimental.contains(component.name) {
continue;
}
for c in claims_in(component.description) {
problems.push(format!("SBOM entry {} says {c:?}", component.name));
}
}
assert!(crates >= 10, "only {crates} crates checked");
assert!(doc_lines >= 1000, "only {doc_lines} module-doc lines read");
assert!(
problems.is_empty(),
"documentation contradicts the ontology:\n {}",
problems.join("\n ")
);
}
#[test]
fn every_std_feature_reaches_every_dependency_that_has_one() {
let root = workspace_root();
let members = manifest_members();
let read = |name: &str| {
std::fs::read_to_string(root.join("crates").join(name).join("Cargo.toml"))
.unwrap_or_else(|_| panic!("{name} has no manifest"))
};
let std_list = |text: &str| -> Option<String> {
let start = text.find("\nstd = [")?;
let end = text[start..].find(']')? + start;
Some(text[start..end].to_string())
};
let deps = |text: &str| -> Vec<String> {
let mut out = Vec::new();
let mut in_deps = false;
for line in text.lines() {
let line = line.trim();
if line.starts_with('[') {
in_deps = line == "[dependencies]";
continue;
}
if in_deps {
if let Some((name, _)) = line.split_once('=') {
let name = name.trim();
if members.iter().any(|m| m == name) {
out.push(name.to_string());
}
}
}
}
out
};
let has_std: std::collections::BTreeSet<String> = members
.iter()
.filter(|m| std_list(&read(m)).is_some())
.cloned()
.collect();
assert!(
has_std.len() >= 10,
"only {} crates have a std feature",
has_std.len()
);
let mut problems = Vec::new();
for name in &has_std {
let text = read(name);
let list = std_list(&text).unwrap();
for dep in deps(&text) {
if has_std.contains(&dep) && !list.contains(&format!("\"{dep}/std\"")) {
problems.push(format!("{name}'s std does not turn on {dep}/std"));
}
}
}
assert!(problems.is_empty(), "{}", problems.join("\n"));
}
}