use std::path::{Path, PathBuf};
use super::checksums;
use super::error::PackageError;
use super::licenses::LicenseInventory;
use super::manifest::{Manifest, ManifestFile, relative_path};
use super::sbom::SpdxDocument;
const FORBIDDEN_SUFFIXES: &[&str] = &[
".env", ".pem", ".key", ".p12", ".pfx", ".rs", ".ts", ".tsx", ".vue", ".jsx", ".sql",
];
const FORBIDDEN_COMPONENTS: &[&str] = &[
"node_modules",
".git",
"target",
".arcature",
"dist",
".vscode",
".idea",
];
pub(crate) fn is_forbidden(path: &Path) -> bool {
for comp in path.components() {
let name = comp.as_os_str().to_string_lossy().to_lowercase();
if FORBIDDEN_COMPONENTS.iter().any(|c| name == *c) {
return true;
}
}
let name = path
.file_name()
.map(|n| n.to_string_lossy().to_lowercase())
.unwrap_or_default();
if name.starts_with(".env") {
return true;
}
FORBIDDEN_SUFFIXES
.iter()
.any(|suffix| name.ends_with(suffix))
}
fn copy_dir_filtered(
src: &Path,
dest: &Path,
bundle_root: &Path,
) -> Result<Vec<PathBuf>, PackageError> {
let mut copied = Vec::new();
let entries = std::fs::read_dir(src).map_err(|source| PackageError::Walk {
root: src.to_path_buf(),
source,
})?;
for entry in entries {
let entry = entry.map_err(|source| PackageError::Walk {
root: src.to_path_buf(),
source,
})?;
let path = entry.path();
let rel = path.strip_prefix(src).unwrap_or(&path);
let dest_path = dest.join(rel);
let bundle_rel = dest_path.strip_prefix(bundle_root).unwrap_or(&dest_path);
if is_forbidden(bundle_rel) {
return Err(PackageError::Forbidden {
what: "file",
path: path.clone(),
});
}
if path.is_dir() {
std::fs::create_dir_all(&dest_path).map_err(|source| PackageError::Write {
path: dest_path.clone(),
source,
})?;
copied.extend(copy_dir_filtered(&path, &dest_path, bundle_root)?);
} else {
std::fs::create_dir_all(dest_path.parent().unwrap_or(dest_path.as_path())).map_err(
|source| PackageError::Write {
path: dest_path.clone(),
source,
},
)?;
std::fs::copy(&path, &dest_path).map_err(|err| PackageError::Copy {
what: "asset",
from: path.clone(),
source: err,
})?;
copied.push(dest_path);
}
}
Ok(copied)
}
#[derive(Debug)]
pub(crate) struct Bundle {
pub root: PathBuf,
pub files: Vec<PathBuf>,
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn assemble(
dist_root: &Path,
app_target: &str,
app_binary: &Path,
public_build: &Path,
application: &str,
framework_version: &str,
target: &str,
created_at: &str,
framework_root: &Path,
app_root: &Path,
dependencies: Vec<(String, String)>,
) -> Result<Bundle, PackageError> {
let bundle_root = dist_root.join(app_target);
if bundle_root.exists() {
std::fs::remove_dir_all(&bundle_root).map_err(|source| PackageError::Write {
path: bundle_root.clone(),
source,
})?;
}
std::fs::create_dir_all(&bundle_root).map_err(|source| PackageError::Write {
path: bundle_root.clone(),
source,
})?;
let mut bundled: Vec<PathBuf> = Vec::new();
if !app_binary.is_file() {
return Err(PackageError::Missing {
what: "app binary",
path: app_binary.to_path_buf(),
});
}
let binary_dest = bundle_root.join("app-binary");
std::fs::copy(app_binary, &binary_dest).map_err(|err| PackageError::Copy {
what: "app binary",
from: app_binary.to_path_buf(),
source: err,
})?;
bundled.push(binary_dest);
if !public_build.is_dir() {
return Err(PackageError::Missing {
what: "frontend build",
path: public_build.to_path_buf(),
});
}
let assets_dest = bundle_root.join("public").join("build");
std::fs::create_dir_all(&assets_dest).map_err(|source| PackageError::Write {
path: assets_dest.clone(),
source,
})?;
bundled.extend(copy_dir_filtered(public_build, &assets_dest, &bundle_root)?);
let licenses_dir = bundle_root.join("licenses");
let inv = LicenseInventory::discover(app_root, framework_root)?;
let copied_licenses = inv.write(&licenses_dir)?;
for (_name, dest) in copied_licenses {
bundled.push(dest);
}
let mut manifest_files = Vec::with_capacity(bundled.len());
let mut checksum_entries: Vec<(String, String)> = Vec::with_capacity(bundled.len());
for file in &bundled {
let rel = relative_path(&bundle_root, file);
let size = std::fs::metadata(file)
.map_err(|source| PackageError::Read {
path: file.clone(),
source,
})?
.len();
let sha = checksums::file_sha256(file)?;
manifest_files.push(ManifestFile {
path: rel.clone(),
size,
sha256: sha.clone(),
});
checksum_entries.push((sha, rel));
}
let manifest = Manifest::new(
application.to_owned(),
framework_version.to_owned(),
target.to_owned(),
created_at.to_owned(),
manifest_files,
);
let manifest_path = bundle_root.join("manifest.json");
manifest.write(&manifest_path)?;
bundled.push(manifest_path.clone());
checksum_entries.sort_by(|a, b| a.1.cmp(&b.1));
let mut checksums_content = String::new();
for (sha, rel) in &checksum_entries {
checksums_content.push_str(sha);
checksums_content.push_str(" ");
checksums_content.push_str(rel);
checksums_content.push('\n');
}
let checksums_path = bundle_root.join("checksums.sha256");
checksums::write_checksums(&checksums_path, &checksums_content)?;
bundled.push(checksums_path.clone());
let sbom = SpdxDocument::new(application, framework_version, created_at, &dependencies);
let sbom_path = bundle_root.join("sbom.spdx.json");
sbom.write(&sbom_path)?;
bundled.push(sbom_path.clone());
bundled.sort();
Ok(Bundle {
root: bundle_root,
files: bundled,
})
}
#[cfg(test)]
mod tests {
use super::{assemble, is_forbidden};
use std::fs;
use std::path::Path;
pub(crate) fn build_fixture(root: &Path) {
let bin_dir = root.join("target").join("release");
fs::create_dir_all(&bin_dir).expect("dir");
fs::write(bin_dir.join("demo"), b"#!/bin/sh\necho demo\n").expect("binary");
let build_dir = root.join("public").join("build").join("assets");
fs::create_dir_all(&build_dir).expect("dir");
fs::write(build_dir.join("app.js"), b"console.log('app');\n").expect("app.js");
fs::write(build_dir.join("style.css"), b"body{color:#000}\n").expect("css");
fs::write(
root.join("public").join("build").join("manifest.json"),
b"{}\n",
)
.expect("manifest");
fs::write(root.join("LICENSE"), b"MIT\n").expect("license");
}
#[test]
fn forbidlist_catches_secrets_and_dev_source() {
assert!(is_forbidden(Path::new(".env")));
assert!(is_forbidden(Path::new(".env.local")));
assert!(is_forbidden(Path::new(".env.production")));
assert!(is_forbidden(Path::new("secret.pem")));
assert!(is_forbidden(Path::new("id_rsa.key")));
assert!(is_forbidden(Path::new("src/main.rs")));
assert!(is_forbidden(Path::new("src/App.tsx")));
assert!(is_forbidden(Path::new("frontend/src/main.ts")));
assert!(is_forbidden(Path::new("schema.sql")));
assert!(is_forbidden(Path::new("node_modules/react/index.js")));
assert!(is_forbidden(Path::new(".git/config")));
assert!(is_forbidden(Path::new("target/release/demo")));
assert!(is_forbidden(Path::new(".arcature/app-manifest.json")));
}
#[test]
fn forbidlist_allows_production_assets() {
assert!(!is_forbidden(Path::new("app-binary")));
assert!(!is_forbidden(Path::new("public/build/assets/app.js")));
assert!(!is_forbidden(Path::new("public/build/assets/style.css")));
assert!(!is_forbidden(Path::new("public/build/manifest.json")));
assert!(!is_forbidden(Path::new("manifest.json")));
assert!(!is_forbidden(Path::new("checksums.sha256")));
assert!(!is_forbidden(Path::new("sbom.spdx.json")));
assert!(!is_forbidden(Path::new("licenses/LICENSE")));
assert!(!is_forbidden(Path::new("licenses/LICENSE-arcature")));
}
#[test]
fn assemble_produces_full_bundle_layout() {
let dir = tempfile::tempdir().expect("tempdir");
let app_root = dir.path().join("app");
fs::create_dir_all(&app_root).expect("dir");
build_fixture(&app_root);
let dist_root = dir.path().join("dist");
let bundle = assemble(
&dist_root,
"demo-x86_64-unknown-linux-gnu",
&app_root.join("target").join("release").join("demo"),
&app_root.join("public").join("build"),
"demo",
"2026.1.0",
"x86_64-unknown-linux-gnu",
"2026-08-17T00:00:00Z",
dir.path(), &app_root,
vec![("serde".to_owned(), "1.0.229".to_owned())],
)
.expect("assemble");
assert!(bundle.root.join("app-binary").is_file());
assert!(bundle.root.join("public/build/assets/app.js").is_file());
assert!(bundle.root.join("public/build/assets/style.css").is_file());
assert!(bundle.root.join("public/build/manifest.json").is_file());
assert!(bundle.root.join("manifest.json").is_file());
assert!(bundle.root.join("checksums.sha256").is_file());
assert!(bundle.root.join("sbom.spdx.json").is_file());
assert!(bundle.root.join("licenses/LICENSE").is_file());
let manifest_text =
fs::read_to_string(bundle.root.join("manifest.json")).expect("manifest");
let manifest: serde_json::Value =
serde_json::from_str(&manifest_text).expect("parse manifest");
assert_eq!(manifest["schema_version"], 1);
assert_eq!(manifest["application"], "demo");
assert_eq!(manifest["framework_version"], "2026.1.0");
assert_eq!(manifest["target"], "x86_64-unknown-linux-gnu");
let files = manifest["files"].as_array().expect("files array");
assert!(files.iter().any(|f| f["path"] == "app-binary"));
assert!(
files
.iter()
.any(|f| f["path"] == "public/build/assets/app.js")
);
for f in files {
let sha = f["sha256"].as_str().expect("sha");
assert_eq!(sha.len(), 64);
assert!(sha.chars().all(|c| c.is_ascii_hexdigit()));
}
let checksums_text =
fs::read_to_string(bundle.root.join("checksums.sha256")).expect("checksums");
let lines: Vec<&str> = checksums_text.lines().collect();
assert!(lines.iter().all(|l| l.contains(" ")));
let paths: Vec<&str> = lines
.iter()
.map(|l| l.split(" ").nth(1).unwrap_or(""))
.collect();
let mut sorted = paths.clone();
sorted.sort();
assert_eq!(paths, sorted);
assert!(!checksums_text.contains('\\'));
let sbom_text = fs::read_to_string(bundle.root.join("sbom.spdx.json")).expect("sbom");
let sbom: serde_json::Value = serde_json::from_str(&sbom_text).expect("parse sbom");
assert_eq!(sbom["spdxVersion"], "SPDX-2.3");
assert_eq!(sbom["dataLicense"], "CC0-1.0");
let packages = sbom["packages"].as_array().expect("packages");
assert_eq!(packages.len(), 3);
let _ = Path::new(&bundle.root);
}
#[test]
fn assemble_refuses_to_bundle_secrets() {
let dir = tempfile::tempdir().expect("tempdir");
let app_root = dir.path().join("app");
fs::create_dir_all(&app_root).expect("dir");
build_fixture(&app_root);
fs::write(
app_root.join("public").join("build").join(".env"),
b"SECRET=leaked\n",
)
.expect("env");
let dist_root = dir.path().join("dist");
let result = assemble(
&dist_root,
"demo",
&app_root.join("target").join("release").join("demo"),
&app_root.join("public").join("build"),
"demo",
"2026.1.0",
"x86_64-unknown-linux-gnu",
"2026-08-17T00:00:00Z",
dir.path(),
&app_root,
vec![],
);
assert!(
matches!(result, Err(super::PackageError::Forbidden { .. })),
"must refuse to bundle .env, got: {result:?}"
);
}
#[test]
fn assemble_fails_without_app_binary() {
let dir = tempfile::tempdir().expect("tempdir");
let app_root = dir.path().join("app");
fs::create_dir_all(&app_root).expect("dir");
let dist_root = dir.path().join("dist");
let result = assemble(
&dist_root,
"demo",
&app_root.join("target").join("release").join("demo"),
&app_root.join("public").join("build"),
"demo",
"2026.1.0",
"x86_64-unknown-linux-gnu",
"2026-08-17T00:00:00Z",
dir.path(),
&app_root,
vec![],
);
assert!(matches!(result, Err(super::PackageError::Missing { .. })));
}
#[test]
fn assemble_is_reproducible() {
let dir = tempfile::tempdir().expect("tempdir");
let app_root = dir.path().join("app");
fs::create_dir_all(&app_root).expect("dir");
build_fixture(&app_root);
let dist_root = dir.path().join("dist");
let assemble_once = || {
let bundle = assemble(
&dist_root,
"demo",
&app_root.join("target").join("release").join("demo"),
&app_root.join("public").join("build"),
"demo",
"2026.1.0",
"x86_64-unknown-linux-gnu",
"2026-08-17T00:00:00Z",
dir.path(),
&app_root,
vec![],
)
.expect("assemble");
(
fs::read_to_string(bundle.root.join("manifest.json")).expect("manifest"),
fs::read_to_string(bundle.root.join("checksums.sha256")).expect("checksums"),
fs::read_to_string(bundle.root.join("sbom.spdx.json")).expect("sbom"),
)
};
let (m1, c1, s1) = assemble_once();
let (m2, c2, s2) = assemble_once();
assert_eq!(m1, m2, "manifest deterministic");
assert_eq!(c1, c2, "checksums deterministic");
assert_eq!(s1, s2, "sbom deterministic");
}
}