use super::sandbox::{SandboxConfig, SandboxLevel};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq)]
pub struct SandboxStep {
pub description: String,
pub command: Option<String>,
pub step: u8,
}
#[derive(Debug, Clone, PartialEq)]
pub struct SandboxPlan {
pub steps: Vec<SandboxStep>,
pub namespace_id: String,
pub overlay: OverlayConfig,
pub seccomp_rules: Vec<SeccompRule>,
pub cgroup_path: String,
}
#[derive(Debug, Clone, PartialEq)]
pub struct OverlayConfig {
pub lower_dirs: Vec<PathBuf>,
pub upper_dir: PathBuf,
pub work_dir: PathBuf,
pub merged_dir: PathBuf,
}
#[derive(Debug, Clone, PartialEq)]
pub struct SeccompRule {
pub syscall: String,
pub action: String,
}
#[derive(Debug, Clone, PartialEq)]
pub struct SandboxResult {
pub output_hash: String,
pub store_path: String,
pub steps_executed: Vec<String>,
}
pub fn plan_sandbox_build(
config: &SandboxConfig,
build_hash: &str,
input_paths: &BTreeMap<String, PathBuf>,
script: &str,
store_dir: &Path,
) -> SandboxPlan {
let hash_short = &build_hash[..16.min(build_hash.len())];
let namespace_id = format!("forjar-build-{hash_short}");
let build_root = PathBuf::from(format!("/tmp/forjar-sandbox/{namespace_id}"));
let cgroup_path = super::sandbox::cgroup_path(build_hash);
let overlay = OverlayConfig {
lower_dirs: input_paths.values().cloned().collect(),
upper_dir: build_root.join("upper"),
work_dir: build_root.join("work"),
merged_dir: build_root.join("merged"),
};
let seccomp_rules = seccomp_rules_for_level(config.level);
let mut steps = Vec::new();
steps.push(SandboxStep {
step: 1,
description: "Create PID/mount/net namespace".to_string(),
command: Some(format!(
"unshare --pid --mount --net --fork --map-root-user -- /bin/true # ns={namespace_id}"
)),
});
let lower = overlay
.lower_dirs
.iter()
.map(|p| p.display().to_string())
.collect::<Vec<_>>()
.join(":");
steps.push(SandboxStep {
step: 2,
description: "Mount overlayfs (lower=inputs, upper=tmpfs)".to_string(),
command: Some(format!(
"mount -t overlay overlay -o lowerdir={lower},upperdir={},workdir={} {}",
overlay.upper_dir.display(),
overlay.work_dir.display(),
overlay.merged_dir.display(),
)),
});
for (name, path) in input_paths {
steps.push(SandboxStep {
step: 3,
description: format!("Bind input '{name}' read-only"),
command: Some(format!(
"mount --bind --read-only {} {}/inputs/{name}",
path.display(),
overlay.merged_dir.display(),
)),
});
}
steps.push(SandboxStep {
step: 4,
description: format!(
"Apply cgroup limits (memory={}MB, cpus={})",
config.memory_mb, config.cpus
),
command: Some(format!(
"mkdir -p {cg} && echo {mem} > {cg}/memory.max && echo {cpu_quota} 100000 > {cg}/cpu.max",
cg = cgroup_path,
mem = config.memory_mb * 1024 * 1024,
cpu_quota = (config.cpus * 100_000.0) as u64,
)),
});
if !seccomp_rules.is_empty() {
let denied: Vec<&str> = seccomp_rules.iter().map(|r| r.syscall.as_str()).collect();
steps.push(SandboxStep {
step: 5,
description: format!("Apply seccomp BPF (deny: {})", denied.join(", ")),
command: Some(format!(
"seccomp-bpf --deny {} -- /bin/sh",
denied.join(",")
)),
});
}
let script_hash = blake3::hash(script.as_bytes());
steps.push(SandboxStep {
step: 6,
description: format!(
"Execute bashrs-purified build (script hash: {})",
&script_hash.to_hex()[..16]
),
command: Some(format!(
"timeout {}s nsenter --target $PID --pid --mount --net -- /bin/sh -c '{}'",
config.timeout,
script.replace('\'', "'\\''"),
)),
});
let out_dir = overlay.merged_dir.join("out");
steps.push(SandboxStep {
step: 7,
description: "Extract outputs from $out".to_string(),
command: Some(format!("test -d {}", out_dir.display())),
});
steps.push(SandboxStep {
step: 8,
description: "Compute BLAKE3 hash of output directory".to_string(),
command: Some(format!("forjar-hash-dir {}", out_dir.display())),
});
steps.push(SandboxStep {
step: 9,
description: "Atomic move to content-addressed store".to_string(),
command: Some(format!(
"mv {} {}/HASH/content",
out_dir.display(),
store_dir.display(),
)),
});
steps.push(SandboxStep {
step: 10,
description: "Destroy namespace and clean up".to_string(),
command: Some(format!(
"umount {merged} && rm -rf {root}",
merged = overlay.merged_dir.display(),
root = build_root.display(),
)),
});
SandboxPlan {
steps,
namespace_id,
overlay,
seccomp_rules,
cgroup_path,
}
}
pub fn seccomp_rules_for_level(level: SandboxLevel) -> Vec<SeccompRule> {
match level {
SandboxLevel::Full => vec![
SeccompRule {
syscall: "connect".to_string(),
action: "deny".to_string(),
},
SeccompRule {
syscall: "mount".to_string(),
action: "deny".to_string(),
},
SeccompRule {
syscall: "ptrace".to_string(),
action: "deny".to_string(),
},
],
_ => Vec::new(),
}
}
pub fn validate_plan(plan: &SandboxPlan) -> Vec<String> {
let mut errors = Vec::new();
if plan.steps.is_empty() {
errors.push("sandbox plan has no steps".to_string());
}
if plan.namespace_id.is_empty() {
errors.push("namespace_id cannot be empty".to_string());
}
if plan.overlay.lower_dirs.is_empty() {
errors.push("overlay requires at least one lower directory".to_string());
}
let mut prev_step = 0u8;
for step in &plan.steps {
if step.step < prev_step {
errors.push(format!(
"step {} appears after step {} (out of order)",
step.step, prev_step
));
}
prev_step = step.step;
}
errors
}
pub fn simulate_sandbox_build(
config: &SandboxConfig,
build_hash: &str,
input_paths: &BTreeMap<String, PathBuf>,
script: &str,
store_dir: &Path,
) -> SandboxResult {
let plan = plan_sandbox_build(config, build_hash, input_paths, script, store_dir);
let mut hash_inputs: Vec<&str> = input_paths
.values()
.map(|p| p.to_str().unwrap_or(""))
.collect();
hash_inputs.sort();
hash_inputs.push(script);
let output_hash = crate::tripwire::hasher::composite_hash(&hash_inputs);
let hash_bare = output_hash.strip_prefix("blake3:").unwrap_or(&output_hash);
let store_path = format!("{}/{hash_bare}/content", store_dir.display());
SandboxResult {
output_hash,
store_path,
steps_executed: plan.steps.iter().map(|s| s.description.clone()).collect(),
}
}
pub fn export_overlay_upper(overlay: &OverlayConfig, output_path: &Path) -> Vec<SandboxStep> {
let upper = &overlay.upper_dir;
let mut steps = Vec::new();
steps.push(SandboxStep {
step: 1,
description: "Convert overlayfs whiteouts to OCI format".to_string(),
command: Some(format!(
"find {} -name '.wh.*' -exec sh -c 'f=\"{{}}\" && mv \"$f\" \"$(dirname \"$f\")/$(basename \"$f\" | sed s/.wh.//)\"' \\;",
upper.display(),
)),
});
steps.push(SandboxStep {
step: 2,
description: "Create OCI layer tarball from overlay upper".to_string(),
command: Some(format!(
"tar -cf {} -C {} .",
output_path.display(),
upper.display(),
)),
});
steps.push(SandboxStep {
step: 3,
description: "Compute DiffID (sha256 of uncompressed layer)".to_string(),
command: Some(format!(
"sha256sum {} | awk '{{print \"sha256:\"$1}}'",
output_path.display(),
)),
});
steps
}
pub fn oci_layout_plan(output_dir: &std::path::Path, tag: &str) -> Vec<SandboxStep> {
let blobs = output_dir.join("blobs/sha256");
let oci_layout = output_dir.join("oci-layout");
let index_json = output_dir.join("index.json");
let docker_manifest = output_dir.join("manifest.json");
vec![
SandboxStep {
step: 1,
description: "Create OCI layout directory structure".into(),
command: Some(format!("mkdir -p {}", blobs.display())),
},
SandboxStep {
step: 2,
description: "Write oci-layout version file".into(),
command: Some(format!(
r#"echo '{{"imageLayoutVersion":"1.0.0"}}' > {}"#,
oci_layout.display(),
)),
},
SandboxStep {
step: 3,
description: "Write OCI index.json".into(),
command: Some(format!(
r#"echo '{{"schemaVersion":2,"manifests":[]}}' > {}"#,
index_json.display(),
)),
},
SandboxStep {
step: 4,
description: "Write Docker-compat manifest.json".into(),
command: Some(format!(
r#"echo '[{{"RepoTags":["{tag}"],"Layers":[]}}]' > {}"#,
docker_manifest.display(),
)),
},
]
}
pub fn multi_arch_index(
platforms: &[crate::core::types::ArchBuild],
) -> crate::core::types::OciIndex {
use crate::core::types::{OciDescriptor, OciIndex};
let manifests: Vec<OciDescriptor> = platforms
.iter()
.filter_map(|p| {
p.manifest_digest.as_ref().map(|digest| OciDescriptor {
media_type: "application/vnd.oci.image.manifest.v1+json".into(),
digest: digest.clone(),
size: 0,
annotations: [(
"org.opencontainers.image.platform".into(),
p.platform.clone(),
)]
.into_iter()
.collect(),
})
})
.collect();
OciIndex {
schema_version: 2,
manifests,
annotations: Default::default(),
}
}
pub fn sha256_digest(data: &[u8]) -> String {
use sha2::{Digest, Sha256};
let hash = Sha256::digest(data);
format!("sha256:{:x}", hash)
}
pub fn gzip_compress(data: &[u8]) -> Result<Vec<u8>, String> {
use flate2::write::GzEncoder;
use flate2::Compression;
use std::io::Write;
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
encoder
.write_all(data)
.map_err(|e| format!("gzip write: {e}"))?;
encoder.finish().map_err(|e| format!("gzip finish: {e}"))
}
pub fn plan_step_count(plan: &SandboxPlan) -> usize {
plan.steps.len()
}