forjar 1.4.2

Rust-native Infrastructure as Code — bare-metal first, BLAKE3 state, provenance tracing
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
//! FJ-1316–FJ-1319: Sandbox lifecycle executor.
//!
//! Implements the 10-step sandbox build lifecycle:
//! 1. Create namespace (PID/mount/net via pepita)
//! 2. Overlay mount (lower=inputs, upper=tmpfs)
//! 3. Bind inputs read-only
//! 4. cgroup limits (memory_mb, cpus)
//! 5. Seccomp BPF (Full level: deny connect/mount/ptrace)
//! 6. Execute bashrs-purified build script
//! 7. Extract outputs from $out
//! 8. hash_directory() → store hash
//! 9. Atomic move to store
//! 10. Destroy namespace
//!
//! All I/O operations produce plans (command lists) rather than executing
//! directly, following forjar's dry-run-first principle.

use super::sandbox::{SandboxConfig, SandboxLevel};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};

/// A single step in the sandbox lifecycle.
#[derive(Debug, Clone, PartialEq)]
pub struct SandboxStep {
    /// Human-readable description
    pub description: String,
    /// Shell command to execute (if applicable)
    pub command: Option<String>,
    /// Step number (1-based)
    pub step: u8,
}

/// The full sandbox execution plan.
#[derive(Debug, Clone, PartialEq)]
pub struct SandboxPlan {
    /// Ordered steps to execute
    pub steps: Vec<SandboxStep>,
    /// Namespace identifier (derived from store hash prefix)
    pub namespace_id: String,
    /// Overlay mount points
    pub overlay: OverlayConfig,
    /// Seccomp BPF rules (empty for non-Full levels)
    pub seccomp_rules: Vec<SeccompRule>,
    /// cgroup path
    pub cgroup_path: String,
}

/// Overlay filesystem configuration.
#[derive(Debug, Clone, PartialEq)]
pub struct OverlayConfig {
    /// Read-only lower layers (input store paths)
    pub lower_dirs: Vec<PathBuf>,
    /// Writable upper directory (tmpfs)
    pub upper_dir: PathBuf,
    /// Work directory for overlayfs
    pub work_dir: PathBuf,
    /// Merged mount point
    pub merged_dir: PathBuf,
}

/// A seccomp BPF deny rule.
#[derive(Debug, Clone, PartialEq)]
pub struct SeccompRule {
    /// Syscall name to deny
    pub syscall: String,
    /// Action (always "deny" for sandbox)
    pub action: String,
}

/// Result of a completed sandbox build.
#[derive(Debug, Clone, PartialEq)]
pub struct SandboxResult {
    /// BLAKE3 hash of the output directory
    pub output_hash: String,
    /// Store path where the output was placed
    pub store_path: String,
    /// All lifecycle steps that were executed
    pub steps_executed: Vec<String>,
}

/// Generate the full sandbox execution plan for a build.
///
/// This produces a plan but does NOT execute it. The plan describes
/// every namespace, mount, cgroup, and seccomp operation needed.
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();

    // Step 1: Create namespace
    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}"
        )),
    });

    // Step 2: Overlay mount
    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(),
        )),
    });

    // Step 3: Bind inputs read-only
    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(),
            )),
        });
    }

    // Step 4: cgroup limits
    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,
        )),
    });

    // Step 5: Seccomp BPF (Full level only)
    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(",")
            )),
        });
    }

    // Step 6: Execute build script
    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('\'', "'\\''"),
        )),
    });

    // Step 7: Extract outputs
    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())),
    });

    // Step 8: hash_directory
    steps.push(SandboxStep {
        step: 8,
        description: "Compute BLAKE3 hash of output directory".to_string(),
        command: Some(format!("forjar-hash-dir {}", out_dir.display())),
    });

    // Step 9: Atomic move to store
    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(),
        )),
    });

    // Step 10: Destroy namespace
    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,
    }
}

/// Generate seccomp BPF rules for a given sandbox level.
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(),
    }
}

/// Validate that a sandbox plan is well-formed.
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());
    }

    // Verify step ordering
    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
}

/// Simulate sandbox execution (dry-run) and produce a result.
///
/// Used for testing and CI gates — computes what the sandbox WOULD produce
/// without actually creating namespaces or mounts.
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);

    // Simulate: the output hash is derived from inputs + script
    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(),
    }
}

/// FJ-2103: Export overlay upper directory as an OCI-compatible layer tarball plan.
///
/// After a sandbox build completes, the upper directory contains all files
/// created or modified during the build. This function generates a plan to
/// tar the upper directory into an OCI layer, converting overlayfs whiteout
/// files (`.wh.*`) to OCI-format whiteouts.
pub fn export_overlay_upper(overlay: &OverlayConfig, output_path: &Path) -> Vec<SandboxStep> {
    let upper = &overlay.upper_dir;
    let mut steps = Vec::new();

    // Step 1: Convert overlayfs whiteouts to OCI whiteouts
    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(),
        )),
    });

    // Step 2: Create layer tarball from upper directory
    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(),
        )),
    });

    // Step 3: Compute DiffID (uncompressed sha256) — placeholder for sha2 crate
    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
}

/// FJ-2101: Generate OCI Image Layout directory structure.
///
/// Creates the spec-compliant layout:
/// ```text
/// <output>/
///   oci-layout          # {"imageLayoutVersion": "1.0.0"}
///   index.json          # OCI Image Index pointing to manifest
///   blobs/sha256/       # content-addressed blobs
/// ```
///
/// Also writes a Docker-compat `manifest.json` for `docker load`.
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(),
            )),
        },
    ]
}

/// FJ-2105: Build a multi-arch OCI Image Index from per-platform manifests.
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(),
    }
}

/// FJ-2101: Compute SHA-256 digest of a byte slice (for OCI DiffID).
pub fn sha256_digest(data: &[u8]) -> String {
    use sha2::{Digest, Sha256};
    let hash = Sha256::digest(data);
    format!("sha256:{:x}", hash)
}

/// FJ-2101: Gzip-compress a byte slice (for OCI layer compression).
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}"))
}

/// Count the total steps in a plan (for progress reporting).
pub fn plan_step_count(plan: &SandboxPlan) -> usize {
    plan.steps.len()
}