Establish native Linux ARM64 Android CI and verification gates (#94)
This commit is contained in:
1 parent
1abd75282e
commit
bf54b9d0a2
28 files changed
+1686
-53
No files matched your search
@@ -0,0 +1,579 @@
|
||||
//! Build artifact gates. Inputs and diagnostics never contain application data.
|
||||
use roxmltree::{Document, Node};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
error::Error,
|
||||
fs,
|
||||
path::Path,
|
||||
};
|
||||
|
||||
const ANDROID: &str = "http://schemas.android.com/apk/res/android";
|
||||
const DOMAINS: [&str; 9] = [
|
||||
"root",
|
||||
"file",
|
||||
"database",
|
||||
"sharedpref",
|
||||
"external",
|
||||
"device_root",
|
||||
"device_file",
|
||||
"device_database",
|
||||
"device_sharedpref",
|
||||
];
|
||||
|
||||
pub fn manifest(manifest: &Path, backup: &Path, extraction: &Path) -> Result<(), Box<dyn Error>> {
|
||||
manifest_text(&fs::read_to_string(manifest)?)?;
|
||||
backup_text(&fs::read_to_string(backup)?, false)?;
|
||||
backup_text(&fs::read_to_string(extraction)?, true)?;
|
||||
println!("Verified packaged manifest and complete backup/transfer exclusions");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn manifest_text(source: &str) -> Result<(), Box<dyn Error>> {
|
||||
let document = Document::parse(source)?;
|
||||
let root = document.root_element();
|
||||
if root.tag_name().name() != "manifest"
|
||||
|| root.attribute("package") != Some("de.rfc1437.ironstorage")
|
||||
{
|
||||
return Err("unexpected manifest identity".into());
|
||||
}
|
||||
let application = root
|
||||
.children()
|
||||
.find(|node| node.has_tag_name("application"))
|
||||
.ok_or("application missing")?;
|
||||
for (name, expected) in [("allowBackup", "false"), ("usesCleartextTraffic", "false")] {
|
||||
if application.attribute((ANDROID, name)) != Some(expected) {
|
||||
return Err(format!("unsafe application flag: {name}").into());
|
||||
}
|
||||
}
|
||||
for name in ["fullBackupContent", "dataExtractionRules"] {
|
||||
if !application
|
||||
.attribute((ANDROID, name))
|
||||
.is_some_and(|value| value.starts_with('@'))
|
||||
{
|
||||
return Err("backup rules missing from packaged manifest".into());
|
||||
}
|
||||
}
|
||||
let allowed = [
|
||||
"android.permission.USE_BIOMETRIC",
|
||||
"android.permission.USE_FINGERPRINT",
|
||||
"de.rfc1437.ironstorage.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION",
|
||||
];
|
||||
for node in root.children().filter(|node| node.is_element()) {
|
||||
if node.tag_name().name().starts_with("uses-permission")
|
||||
&& !allowed.contains(&node.attribute((ANDROID, "name")).unwrap_or(""))
|
||||
{
|
||||
return Err("unreviewed packaged permission".into());
|
||||
}
|
||||
if node.has_tag_name("permission")
|
||||
&& (node.attribute((ANDROID, "name")) != Some(allowed[2])
|
||||
|| !matches!(
|
||||
node.attribute((ANDROID, "protectionLevel")),
|
||||
Some("signature" | "0x2")
|
||||
))
|
||||
{
|
||||
return Err("unreviewed custom permission".into());
|
||||
}
|
||||
}
|
||||
let mut launchers = 0;
|
||||
for node in application.children().filter(|node| {
|
||||
[
|
||||
"activity",
|
||||
"activity-alias",
|
||||
"service",
|
||||
"receiver",
|
||||
"provider",
|
||||
]
|
||||
.contains(&node.tag_name().name())
|
||||
}) {
|
||||
let launcher =
|
||||
node.attribute((ANDROID, "name")) == Some("de.rfc1437.ironstorage.MainActivity");
|
||||
if launcher {
|
||||
launchers += 1;
|
||||
if node.attribute((ANDROID, "exported")) != Some("true") {
|
||||
return Err("launcher must be exported".into());
|
||||
}
|
||||
let actions: BTreeSet<_> = node
|
||||
.descendants()
|
||||
.filter(|n| n.has_tag_name("action") || n.has_tag_name("category"))
|
||||
.filter_map(|n| n.attribute((ANDROID, "name")))
|
||||
.collect();
|
||||
if actions
|
||||
!= BTreeSet::from([
|
||||
"android.intent.action.MAIN",
|
||||
"android.intent.category.LAUNCHER",
|
||||
])
|
||||
{
|
||||
return Err("unreviewed launcher intent".into());
|
||||
}
|
||||
} else if node.has_tag_name("receiver")
|
||||
&& node.attribute((ANDROID, "name"))
|
||||
== Some("androidx.profileinstaller.ProfileInstallReceiver")
|
||||
{
|
||||
let actions: BTreeSet<_> = node
|
||||
.descendants()
|
||||
.filter(|n| n.has_tag_name("action"))
|
||||
.filter_map(|n| n.attribute((ANDROID, "name")))
|
||||
.collect();
|
||||
if node.attribute((ANDROID, "permission")) != Some("android.permission.DUMP")
|
||||
|| node.attribute((ANDROID, "exported")) != Some("true")
|
||||
|| actions
|
||||
!= BTreeSet::from([
|
||||
"androidx.profileinstaller.action.INSTALL_PROFILE",
|
||||
"androidx.profileinstaller.action.SKIP_FILE",
|
||||
"androidx.profileinstaller.action.SAVE_PROFILE",
|
||||
"androidx.profileinstaller.action.BENCHMARK_OPERATION",
|
||||
])
|
||||
{
|
||||
return Err(
|
||||
"profile receiver must retain its privileged permission and reviewed actions"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
} else if node.attribute((ANDROID, "exported")) != Some("false") {
|
||||
return Err("unreviewed exported component".into());
|
||||
}
|
||||
}
|
||||
if launchers != 1 {
|
||||
return Err("exactly one launcher required".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn backup_text(source: &str, extraction: bool) -> Result<(), Box<dyn Error>> {
|
||||
let document = Document::parse(source)?;
|
||||
let root = document.root_element();
|
||||
let sections = if extraction {
|
||||
if !root.has_tag_name("data-extraction-rules") {
|
||||
return Err("wrong extraction root".into());
|
||||
}
|
||||
let nodes: Vec<_> = root.children().filter(Node::is_element).collect();
|
||||
if nodes.len() != 2
|
||||
|| nodes
|
||||
.iter()
|
||||
.map(|n| n.tag_name().name())
|
||||
.collect::<BTreeSet<_>>()
|
||||
!= BTreeSet::from(["cloud-backup", "device-transfer"])
|
||||
{
|
||||
return Err("backup/transfer section missing".into());
|
||||
}
|
||||
nodes
|
||||
} else {
|
||||
if !root.has_tag_name("full-backup-content") {
|
||||
return Err("wrong backup root".into());
|
||||
}
|
||||
vec![root]
|
||||
};
|
||||
for section in sections {
|
||||
let mut domains = BTreeSet::new();
|
||||
for node in section.children().filter(Node::is_element) {
|
||||
if !node.has_tag_name("exclude")
|
||||
|| node.attribute("path") != Some(".")
|
||||
|| !domains.insert(node.attribute("domain").ok_or("missing domain")?)
|
||||
{
|
||||
return Err("backup rules must exclude each entire domain once".into());
|
||||
}
|
||||
}
|
||||
if domains != BTreeSet::from(DOMAINS) {
|
||||
return Err("backup excludes an incomplete domain set".into());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn hash(bytes: &[u8]) -> String {
|
||||
format!("{:x}", Sha256::digest(bytes))
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct TestResult {
|
||||
id: String,
|
||||
status: &'static str,
|
||||
}
|
||||
|
||||
fn report(source: &str) -> Result<Vec<TestResult>, Box<dyn Error>> {
|
||||
let document = Document::parse(source)?;
|
||||
let mut results = Vec::new();
|
||||
for node in document
|
||||
.descendants()
|
||||
.filter(|n| n.has_tag_name("testsuite"))
|
||||
{
|
||||
for attribute in ["errors", "failures"] {
|
||||
let count: usize = node.attribute(attribute).unwrap_or("0").parse()?;
|
||||
let actual = node
|
||||
.children()
|
||||
.filter(|n| {
|
||||
n.has_tag_name("testcase")
|
||||
&& n.children().any(|child| {
|
||||
child.has_tag_name(if attribute == "errors" {
|
||||
"error"
|
||||
} else {
|
||||
"failure"
|
||||
})
|
||||
})
|
||||
})
|
||||
.count();
|
||||
if count > actual {
|
||||
results.push(TestResult {
|
||||
id: hash(
|
||||
format!("{}:{attribute}", node.attribute("name").unwrap_or("")).as_bytes(),
|
||||
),
|
||||
status: "error",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
for node in document
|
||||
.descendants()
|
||||
.filter(|n| n.has_tag_name("testcase"))
|
||||
{
|
||||
let status = if node.children().any(|n| n.has_tag_name("failure")) {
|
||||
"failure"
|
||||
} else if node.children().any(|n| n.has_tag_name("error")) {
|
||||
"error"
|
||||
} else if node.children().any(|n| n.has_tag_name("skipped")) {
|
||||
"skipped"
|
||||
} else {
|
||||
"passed"
|
||||
};
|
||||
let identity = format!(
|
||||
"{}:{}",
|
||||
node.attribute("classname").unwrap_or(""),
|
||||
node.attribute("name").unwrap_or("")
|
||||
);
|
||||
results.push(TestResult {
|
||||
id: hash(identity.as_bytes()),
|
||||
status,
|
||||
});
|
||||
}
|
||||
if results.is_empty() {
|
||||
return Err("JUnit report has no results".into());
|
||||
}
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
pub fn reports(input: &Path, output: &Path) -> Result<(), Box<dyn Error>> {
|
||||
let input = input.canonicalize()?;
|
||||
let destination = if output.exists() {
|
||||
output.canonicalize()?
|
||||
} else {
|
||||
output
|
||||
.parent()
|
||||
.ok_or("safe report directory missing")?
|
||||
.canonicalize()?
|
||||
.join(output.file_name().ok_or("safe report filename missing")?)
|
||||
};
|
||||
if destination.starts_with(&input) {
|
||||
return Err("safe reports must be outside raw report directory".into());
|
||||
}
|
||||
let mut results = Vec::new();
|
||||
collect_reports(&input, &mut results)?;
|
||||
if results.is_empty() {
|
||||
return Err("JUnit reports missing".into());
|
||||
}
|
||||
let failed = results
|
||||
.iter()
|
||||
.any(|r| matches!(r.status, "error" | "failure"));
|
||||
fs::write(output, serde_json::to_vec_pretty(&results)?)?;
|
||||
if failed {
|
||||
return Err("test failures retained in sanitized report".into());
|
||||
}
|
||||
println!(
|
||||
"Sanitized {} test results; only hashed identifiers and status retained",
|
||||
results.len()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn collect_reports(input: &Path, output: &mut Vec<TestResult>) -> Result<(), Box<dyn Error>> {
|
||||
let mut entries: Vec<_> = fs::read_dir(input)?.collect::<Result<_, _>>()?;
|
||||
entries.sort_by_key(|e| e.file_name());
|
||||
for entry in entries {
|
||||
let kind = entry.file_type()?;
|
||||
if kind.is_symlink() {
|
||||
return Err("report symlink rejected".into());
|
||||
}
|
||||
if kind.is_dir() {
|
||||
collect_reports(&entry.path(), output)?;
|
||||
} else if entry.file_name().to_string_lossy().starts_with("TEST-")
|
||||
&& entry.path().extension().is_some_and(|ext| ext == "xml")
|
||||
{
|
||||
if entry.metadata()?.len() > 16 * 1024 * 1024 {
|
||||
return Err("report exceeds bound".into());
|
||||
}
|
||||
output.extend(report(&fs::read_to_string(entry.path())?)?);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Policy {
|
||||
android: BTreeMap<String, String>,
|
||||
rust_licenses: BTreeSet<String>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct AndroidDependency {
|
||||
group: String,
|
||||
name: String,
|
||||
version: String,
|
||||
sha256: String,
|
||||
scope: String,
|
||||
}
|
||||
|
||||
pub fn sbom(
|
||||
android: &Path,
|
||||
rust: &Path,
|
||||
lock: &Path,
|
||||
policy: &Path,
|
||||
output: &Path,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
let policy: Policy = serde_json::from_slice(&fs::read(policy)?)?;
|
||||
let dependencies: Vec<AndroidDependency> = serde_json::from_slice(&fs::read(android)?)?;
|
||||
if dependencies.is_empty() {
|
||||
return Err("Android dependency inventory empty".into());
|
||||
}
|
||||
let lock: toml::Value = toml::from_str(&fs::read_to_string(lock)?)?;
|
||||
let packages = lock
|
||||
.get("package")
|
||||
.and_then(toml::Value::as_array)
|
||||
.ok_or("Cargo lock packages missing")?;
|
||||
let mut components = BTreeMap::new();
|
||||
for dependency in dependencies {
|
||||
let license = android_license(
|
||||
&dependency.group,
|
||||
&dependency.name,
|
||||
&dependency.version,
|
||||
&policy,
|
||||
)?;
|
||||
if dependency.version.is_empty()
|
||||
|| dependency.version.contains(['+', '[', ']', '(', ')', ','])
|
||||
|| dependency.version.contains("SNAPSHOT")
|
||||
|| dependency.sha256.len() != 64
|
||||
|| !dependency.sha256.bytes().all(|b| b.is_ascii_hexdigit())
|
||||
{
|
||||
return Err("dependency must have a fixed version and SHA-256".into());
|
||||
}
|
||||
let purl = format!(
|
||||
"pkg:maven/{}/{}@{}",
|
||||
dependency.group, dependency.name, dependency.version
|
||||
);
|
||||
let key = format!("{purl}#{}#{}", dependency.sha256, dependency.scope);
|
||||
components.insert(key.clone(), serde_json::json!({"type":"library", "bom-ref":key, "group":dependency.group, "name":dependency.name, "version":dependency.version, "purl":purl, "hashes":[{"alg":"SHA-256","content":dependency.sha256}], "licenses":[{"expression":license}], "properties":[{"name":"ironstorage:configuration","value":dependency.scope}]}));
|
||||
}
|
||||
for line in fs::read_to_string(rust)?
|
||||
.lines()
|
||||
.filter(|line| !line.is_empty())
|
||||
{
|
||||
let fields: Vec<_> = line.split('|').collect();
|
||||
if fields.len() != 3 || !policy.rust_licenses.contains(fields[1]) {
|
||||
return Err("Rust dependency license needs review".into());
|
||||
}
|
||||
let (name, remainder) = fields[0]
|
||||
.split_once(" v")
|
||||
.ok_or("invalid Rust dependency")?;
|
||||
let version = remainder
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.ok_or("missing Rust version")?;
|
||||
let purl = format!("pkg:cargo/{name}@{version}");
|
||||
let package = packages
|
||||
.iter()
|
||||
.find(|p| {
|
||||
p.get("name").and_then(toml::Value::as_str) == Some(name)
|
||||
&& p.get("version").and_then(toml::Value::as_str) == Some(version)
|
||||
})
|
||||
.ok_or("Rust inventory differs from Cargo.lock")?;
|
||||
let mut component = serde_json::json!({"type":"library", "bom-ref":purl, "name":name,"version":version,"purl":purl,"licenses":[{"expression":fields[1].replace(" / ", " OR ").replace('/', " OR ")}],"properties":[{"name":"ironstorage:configuration","value":"Android ARM normal dependencies (Cargo feature graph)"}]});
|
||||
if let Some(checksum) = package.get("checksum").and_then(toml::Value::as_str) {
|
||||
component["hashes"] = serde_json::json!([{"alg":"SHA-256", "content":checksum}]);
|
||||
} else if !name.starts_with("ironstorage") {
|
||||
return Err("Rust dependency lacks a registry checksum".into());
|
||||
}
|
||||
components.insert(purl.clone(), component);
|
||||
}
|
||||
if !components
|
||||
.keys()
|
||||
.any(|k| k.starts_with("pkg:cargo/ironstorage-android@"))
|
||||
{
|
||||
return Err("Android Rust root missing".into());
|
||||
}
|
||||
let value = serde_json::json!({"bomFormat":"CycloneDX", "specVersion":"1.6", "version":1, "components":components.into_values().collect::<Vec<_>>()});
|
||||
fs::write(output, serde_json::to_vec_pretty(&value)?)?;
|
||||
println!("Dependency/license policy passed; deterministic ARM CycloneDX inventory written");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn android_license<'a>(
|
||||
group: &str,
|
||||
name: &str,
|
||||
version: &str,
|
||||
policy: &'a Policy,
|
||||
) -> Result<&'a str, Box<dyn Error>> {
|
||||
for forbidden in [
|
||||
"org.eclipse.jgit",
|
||||
"org.bouncycastle",
|
||||
"androidx.room",
|
||||
"com.squareup.retrofit2",
|
||||
"com.squareup.okhttp3",
|
||||
"org.pgpainless",
|
||||
"com.google.mlkit",
|
||||
] {
|
||||
if group == forbidden || group.starts_with(&format!("{forbidden}.")) {
|
||||
return Err("Android domain dependency forbidden".into());
|
||||
}
|
||||
}
|
||||
policy
|
||||
.android
|
||||
.get(&format!("{group}:{name}:{version}"))
|
||||
.map(String::as_str)
|
||||
.ok_or_else(|| "Android dependency license needs review".into())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn manifest_rejects_backup_permissions_and_exports() {
|
||||
let source = format!(
|
||||
r#"<manifest xmlns:android="{ANDROID}" package="de.rfc1437.ironstorage"><application android:allowBackup="false" android:usesCleartextTraffic="false" android:fullBackupContent="@xml/backup_rules" android:dataExtractionRules="@xml/data_extraction_rules"><activity android:name="de.rfc1437.ironstorage.MainActivity" android:exported="true"><intent-filter><action android:name="android.intent.action.MAIN"/><category android:name="android.intent.category.LAUNCHER"/></intent-filter></activity><provider android:name="fixture" android:exported="false"/></application></manifest>"#
|
||||
);
|
||||
assert!(manifest_text(&source).is_ok());
|
||||
for bad in [
|
||||
source.replace("allowBackup=\"false\"", "allowBackup=\"true\""),
|
||||
source.replace(
|
||||
"provider android:name=\"fixture\" android:exported=\"false\"",
|
||||
"provider android:name=\"fixture\" android:exported=\"true\"",
|
||||
),
|
||||
source.replace(
|
||||
"<application",
|
||||
"<uses-permission android:name=\"android.permission.INTERNET\"/><application",
|
||||
),
|
||||
] {
|
||||
assert!(manifest_text(&bad).is_err());
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn packaged_signature_and_profile_receiver_cannot_be_weakened() {
|
||||
let launcher = format!(
|
||||
r#"<manifest xmlns:android="{ANDROID}" package="de.rfc1437.ironstorage"><permission android:name="de.rfc1437.ironstorage.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION" android:protectionLevel="0x2"/><application android:allowBackup="false" android:usesCleartextTraffic="false" android:fullBackupContent="@xml/backup_rules" android:dataExtractionRules="@xml/data_extraction_rules"><activity android:name="de.rfc1437.ironstorage.MainActivity" android:exported="true"><intent-filter><action android:name="android.intent.action.MAIN"/><category android:name="android.intent.category.LAUNCHER"/></intent-filter></activity><receiver android:name="androidx.profileinstaller.ProfileInstallReceiver" android:permission="android.permission.DUMP" android:exported="true"><intent-filter><action android:name="androidx.profileinstaller.action.INSTALL_PROFILE"/><action android:name="androidx.profileinstaller.action.SKIP_FILE"/><action android:name="androidx.profileinstaller.action.SAVE_PROFILE"/><action android:name="androidx.profileinstaller.action.BENCHMARK_OPERATION"/></intent-filter></receiver></application></manifest>"#
|
||||
);
|
||||
assert!(manifest_text(&launcher).is_ok());
|
||||
assert!(manifest_text(&launcher.replace("0x2", "0x0")).is_err());
|
||||
assert!(
|
||||
manifest_text(
|
||||
&launcher.replace("android.permission.DUMP", "android.permission.INTERNET")
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(manifest_text(&launcher.replace("INSTALL_PROFILE", "UNREVIEWED")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backup_requires_both_sections_all_domains_and_no_include() {
|
||||
let exclusions = DOMAINS
|
||||
.map(|d| format!(r#"<exclude domain="{d}" path="."/>"#))
|
||||
.join("");
|
||||
let source = format!(
|
||||
"<data-extraction-rules><cloud-backup>{exclusions}</cloud-backup><device-transfer>{exclusions}</device-transfer></data-extraction-rules>"
|
||||
);
|
||||
assert!(backup_text(&source, true).is_ok());
|
||||
assert!(backup_text(&source.replace("device_sharedpref", "unknown"), true).is_err());
|
||||
assert!(backup_text(&source.replace("<exclude", "<include"), true).is_err());
|
||||
assert!(backup_text(&source.replace("<device-transfer>", "<unknown>"), true).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn reports_preserve_failures_without_names_messages_or_output() {
|
||||
let secret = "otpauth://totp/secret?secret=FIXTURE";
|
||||
let source = format!(
|
||||
r#"<testsuite name="{secret}" errors="1"><testcase classname="{secret}" name="{secret}"><failure message="{secret}">{secret}</failure></testcase><testcase name="ok"/><system-out>{secret}</system-out></testsuite>"#
|
||||
);
|
||||
let results = report(&source).unwrap();
|
||||
assert_eq!(
|
||||
results.iter().map(|r| r.status).collect::<Vec<_>>(),
|
||||
["error", "failure", "passed"]
|
||||
);
|
||||
let encoded = serde_json::to_string(&results).unwrap();
|
||||
assert!(!encoded.contains("otpauth"));
|
||||
assert!(!encoded.contains("FIXTURE"));
|
||||
assert!(report("<testsuite/>").is_err());
|
||||
}
|
||||
#[test]
|
||||
fn dependency_policy_rejects_transitive_domain_libraries_and_unknown_groups() {
|
||||
let policy = Policy {
|
||||
android: BTreeMap::from([
|
||||
("androidx.core:core:1".into(), "Apache-2.0".into()),
|
||||
("org.bouncycastle:fixture:1".into(), "MIT".into()),
|
||||
]),
|
||||
rust_licenses: BTreeSet::new(),
|
||||
};
|
||||
assert_eq!(
|
||||
android_license("androidx.core", "core", "1", &policy).unwrap(),
|
||||
"Apache-2.0"
|
||||
);
|
||||
assert!(android_license("org.bouncycastle", "fixture", "1", &policy).is_err());
|
||||
assert!(android_license("org.bouncycastle.extra", "fixture", "1", &policy).is_err());
|
||||
assert!(android_license("unknown", "fixture", "1", &policy).is_err());
|
||||
assert!(android_license("androidx.core", "core", "2", &policy).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filesystem_gates_reject_missing_hashes_lock_drift_and_raw_report_destination() {
|
||||
let directory = std::env::temp_dir().join(format!(
|
||||
"ironstorage-artifact-gates-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
fs::create_dir(&directory).unwrap();
|
||||
let android = directory.join("android.json");
|
||||
let rust = directory.join("rust.txt");
|
||||
let lock = directory.join("Cargo.lock");
|
||||
let policy = directory.join("policy.json");
|
||||
let output = directory.join("sbom.json");
|
||||
let inventory = serde_json::json!([{"group":"androidx.core", "name":"core", "version":"1", "sha256":"a".repeat(64), "scope":"debug"}]);
|
||||
fs::write(&android, serde_json::to_vec(&inventory).unwrap()).unwrap();
|
||||
fs::write(
|
||||
&rust,
|
||||
"ironstorage-android v1.0.1|MIT|\nfixture v1.0.0|MIT|\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(&lock, format!("[[package]]\nname='ironstorage-android'\nversion='1.0.1'\n[[package]]\nname='fixture'\nversion='1.0.0'\nchecksum='{}'\n", "b".repeat(64))).unwrap();
|
||||
fs::write(
|
||||
&policy,
|
||||
r#"{"android":{"androidx.core:core:1":"Apache-2.0"},"rust_licenses":["MIT"]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
sbom(&android, &rust, &lock, &policy, &output).unwrap();
|
||||
let encoded: serde_json::Value =
|
||||
serde_json::from_slice(&fs::read(&output).unwrap()).unwrap();
|
||||
assert_eq!(encoded["components"].as_array().unwrap().len(), 3);
|
||||
let mut invalid = inventory.clone();
|
||||
invalid[0]["sha256"] = "missing".into();
|
||||
fs::write(&android, serde_json::to_vec(&invalid).unwrap()).unwrap();
|
||||
assert!(sbom(&android, &rust, &lock, &policy, &output).is_err());
|
||||
fs::write(&android, serde_json::to_vec(&inventory).unwrap()).unwrap();
|
||||
fs::write(
|
||||
&rust,
|
||||
"ironstorage-android v1.0.1|MIT|\nfixture v2.0.0|MIT|\n",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(sbom(&android, &rust, &lock, &policy, &output).is_err());
|
||||
fs::write(&rust, "fixture v1.0.0|MIT|\n").unwrap();
|
||||
assert!(sbom(&android, &rust, &lock, &policy, &output).is_err());
|
||||
let raw = directory.join("raw");
|
||||
fs::create_dir(&raw).unwrap();
|
||||
fs::write(
|
||||
raw.join("TEST-fixture.xml"),
|
||||
"<testsuite><testcase name='public'/></testsuite>",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(reports(&raw, &raw.join("safe.json")).is_err());
|
||||
assert!(!raw.join("safe.json").exists());
|
||||
reports(&raw, &directory.join("safe.json")).unwrap();
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user