nixfleet_release/
sign.rs

1//! Sign-hook + smoke-verify + atomic release-dir write.
2
3use std::path::Path;
4use std::process::{Command, Stdio};
5
6use anyhow::{Context, Result, bail};
7use nixfleet_proto::FleetResolved;
8use tempfile::NamedTempFile;
9
10use crate::canonicalize_resolved;
11
12pub(crate) fn sign(cmd: &str, canonical: &[u8]) -> Result<Vec<u8>> {
13    let input = NamedTempFile::new().context("create tempfile for canonical bytes")?;
14    let output = NamedTempFile::new().context("create tempfile for signature")?;
15
16    std::fs::write(input.path(), canonical).context("write canonical bytes to tempfile")?;
17    // Pre-create empty so the hook only needs to overwrite, not also create.
18    std::fs::write(output.path(), b"").ok();
19
20    tracing::info!("sign hook");
21    let status = Command::new("sh")
22        .arg("-c")
23        .arg(cmd)
24        .env("NIXFLEET_INPUT", input.path())
25        .env("NIXFLEET_OUTPUT", output.path())
26        .stdin(Stdio::null())
27        .status()
28        .with_context(|| format!("invoke sign hook ({cmd:?})"))?;
29    if !status.success() {
30        bail!(
31            "sign hook exited {} ({:?})",
32            status.code().unwrap_or(-1),
33            cmd,
34        );
35    }
36
37    let sig = std::fs::read(output.path()).context("read signature output")?;
38    if sig.is_empty() {
39        bail!("sign hook produced 0-byte signature - refusing to publish");
40    }
41    Ok(sig)
42}
43
44/// Structural smoke verify: byte-stable canonicalize round-trip + schema
45/// parse + non-zero sig. No cryptographic verification.
46pub(crate) fn smoke_verify(canonical: &[u8], signature: &[u8]) -> Result<()> {
47    let parsed: FleetResolved = serde_json::from_slice(canonical)
48        .context("smoke verify: canonical bytes don't parse as FleetResolved")?;
49    let recanonical =
50        canonicalize_resolved(&parsed).context("smoke verify: re-canonicalize failed")?;
51    if recanonical.as_bytes() != canonical {
52        bail!("smoke verify: canonicalization is not byte-stable round-trip");
53    }
54    if signature.is_empty() {
55        bail!("smoke verify: empty signature");
56    }
57    tracing::info!(sig_len = signature.len(), "smoke verify ok");
58    Ok(())
59}
60
61pub(crate) fn write_release(
62    release_dir: &Path,
63    artifact_name: &str,
64    canonical: &[u8],
65    signature: &[u8],
66) -> Result<()> {
67    std::fs::create_dir_all(release_dir)
68        .with_context(|| format!("create release dir {}", release_dir.display()))?;
69    let artifact_path = release_dir.join(artifact_name);
70    let signature_path = release_dir.join(format!("{artifact_name}.sig"));
71    atomic_write(&artifact_path, canonical)?;
72    atomic_write(&signature_path, signature)?;
73    Ok(())
74}
75
76fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> {
77    let dir = path.parent().unwrap_or_else(|| Path::new("."));
78    let mut tmp = tempfile::NamedTempFile::new_in(dir)
79        .with_context(|| format!("tempfile in {}", dir.display()))?;
80    use std::io::Write;
81    tmp.write_all(bytes).context("write release tempfile")?;
82    tmp.persist(path)
83        .with_context(|| format!("rename tempfile to {}", path.display()))?;
84    Ok(())
85}