Skip to main content

yah_qed/
native.rs

1//! Native-tarball packaging (R407-T2, W154).
2//!
3//! Emits a `.tar.gz` containing a static musl Rust binary plus a workload-spec
4//! manifest. Kamaji consumes the tarball at deploy time and directly
5//! fork+exec+cgroup+pidfd-supervises the binary — no systemd Portable Service,
6//! no per-workload `.service` unit. The tarball doubles as the deploy artifact
7//! and the manifest-of-record describing how to launch the workload.
8//!
9//! ## Layout inside the tarball
10//!
11//! ```text
12//! bin/<basename>          ← the static musl binary, 0o755
13//! manifest.toml           ← [`NativeTarballManifest`] serialized
14//! ```
15//!
16//! Pure filesystem work; the runner-side dispatch (catalog lookup, validation
17//! that the catalog entry actually declares `produces = ["native-tarball"]`)
18//! lives in [`crate::runner::PipelineRunner::execute_step_package_native_tarball`].
19
20use std::collections::BTreeMap;
21use std::fs;
22use std::path::{Path, PathBuf};
23
24use async_trait::async_trait;
25use flate2::write::GzEncoder;
26use flate2::Compression;
27use serde::{Deserialize, Serialize};
28
29/// The `manifest.toml` written into every native-tarball.
30///
31/// Forward-compatible — Kamaji readers should accept additive fields. Today
32/// this carries the bare minimum needed to launch a workload: the binary's
33/// in-tarball path, the target triple it was built for, and the env vars the
34/// catalog entry declared. Capabilities, drain hooks, and probe shape land
35/// alongside the Kamaji workload-spec proper.
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
37pub struct NativeTarballManifest {
38    /// Catalog entry name (matches `[image].name` in the source TOML).
39    pub name: String,
40    /// Release version. Resolved at packaging time from
41    /// `YAH_RELEASE_VERSION` env or the qed crate's compiled version
42    /// (see [`crate::publish::resolve_release_version`]).
43    pub version: String,
44    /// Target-triple shorthand the binary was compiled for, e.g.
45    /// `x86_64-unknown-linux-musl`.
46    pub triple: String,
47    /// Path to the executable *inside the tarball* (e.g. `bin/yubaba`).
48    pub binary: String,
49    /// Short human description (mirrors the catalog entry's `description`).
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub description: Option<String>,
52    /// Env vars the catalog entry declared. Kamaji applies these to the
53    /// child before exec.
54    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
55    pub env: BTreeMap<String, String>,
56}
57
58/// Write `<binary>` and `manifest.toml` into a `.tar.gz` at `output_path`.
59///
60/// The output directory is created if missing. The binary is stored at
61/// `bin/<filename>` inside the tarball with mode `0o755`; the manifest is
62/// stored at the top level as `manifest.toml`. Existing `output_path` is
63/// truncated.
64pub fn pack_native_tarball(
65    binary_path: &Path,
66    manifest: &NativeTarballManifest,
67    output_path: &Path,
68) -> std::io::Result<()> {
69    if let Some(parent) = output_path.parent() {
70        fs::create_dir_all(parent)?;
71    }
72    let manifest_toml = toml::to_string_pretty(manifest)
73        .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
74
75    let file = fs::File::create(output_path)?;
76    let gz = GzEncoder::new(file, Compression::default());
77    let mut builder = tar::Builder::new(gz);
78
79    let bin_basename = binary_path.file_name().ok_or_else(|| {
80        std::io::Error::new(
81            std::io::ErrorKind::InvalidInput,
82            format!("binary path has no filename: {}", binary_path.display()),
83        )
84    })?;
85    let in_tar_path = format!("bin/{}", bin_basename.to_string_lossy());
86
87    let mut bin = fs::File::open(binary_path)?;
88    let bin_meta = bin.metadata()?;
89    let mut bin_header = tar::Header::new_gnu();
90    bin_header.set_size(bin_meta.len());
91    bin_header.set_mode(0o755);
92    bin_header.set_mtime(0);
93    bin_header.set_cksum();
94    builder.append_data(&mut bin_header, &in_tar_path, &mut bin)?;
95
96    let manifest_bytes = manifest_toml.as_bytes();
97    let mut manifest_header = tar::Header::new_gnu();
98    manifest_header.set_size(manifest_bytes.len() as u64);
99    manifest_header.set_mode(0o644);
100    manifest_header.set_mtime(0);
101    manifest_header.set_cksum();
102    builder.append_data(&mut manifest_header, "manifest.toml", manifest_bytes)?;
103
104    let gz = builder.into_inner()?;
105    gz.finish()?;
106    Ok(())
107}
108
109/// Filesystem-safe tarball stem for a catalog image + triple pair. The runner
110/// uses this for the on-disk filename so packaging and signing both resolve
111/// the same path without re-deriving it (R407-T2 / R407-T5).
112pub fn tarball_stem(image_name: &str, triple: &str) -> String {
113    let raw = format!("{image_name}-{triple}");
114    raw.chars()
115        .map(|c| {
116            if c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-') {
117                c
118            } else {
119                '_'
120            }
121        })
122        .collect()
123}
124
125/// Path the packaging step writes (and the signing step reads) under
126/// `<camp_root>/.yah/cache/native/<safe-stem>.tar.gz`. Single source of truth
127/// for the on-disk convention so signing never drifts from packaging.
128pub fn native_tarball_output_path(camp_root: &Path, image_name: &str, triple: &str) -> PathBuf {
129    camp_root
130        .join(".yah/cache/native")
131        .join(format!("{}.tar.gz", tarball_stem(image_name, triple)))
132}
133
134// ── Sigstore signing seam (R407-T5, W154) ──────────────────────────────────
135//
136// W154: "Sigstore signing extends to native tarballs (same trust model,
137// different artifact shape)." For OCI images, cosign signs the registry
138// digest (`cosign sign --yes <ref>@<digest>`). For tarballs, the equivalent
139// is `cosign sign-blob --yes`, which produces a detached signature and an
140// associated certificate / Rekor bundle. The trust model is the same:
141// keyless OIDC via the GHA token, identity matched at verification time by
142// regex against the workflow identity, transparency log entry in Rekor.
143//
144// This module owns the abstraction; the runner attaches a concrete signer
145// via [`crate::runner::PipelineRunner::with_signer`]. The default in every
146// constructor is [`LoggingSigner`] — local `yah qed run` flows write
147// placeholder bytes and log a warning rather than fail when cosign isn't on
148// PATH. A real release pipeline (GHA or yubaba-run) wires [`CosignSigner`]
149// explicitly so an unsigned tarball never silently ships.
150
151/// On-disk paths emitted by a successful [`SigstoreSigner::sign_blob`] call.
152#[derive(Debug, Clone, PartialEq, Eq)]
153pub struct SignedBlob {
154    /// Detached signature, conventionally `<blob>.sig`.
155    pub signature_path: PathBuf,
156    /// Signing certificate (the leaf cert with the OIDC identity), `<blob>.crt`.
157    pub certificate_path: PathBuf,
158    /// Cosign bundle (signature + cert + Rekor inclusion proof), `<blob>.bundle`.
159    /// `None` when the signer doesn't emit a bundle.
160    pub bundle_path: Option<PathBuf>,
161}
162
163/// Sign a single blob (a native tarball, conventionally) with the same
164/// Sigstore keyless OIDC trust model that signs the OCI images today. The
165/// signer writes the resulting `.sig` / `.crt` / `.bundle` files next to the
166/// blob and reports their paths back so the caller can publish them.
167#[async_trait]
168pub trait SigstoreSigner: Send + Sync {
169    async fn sign_blob(&self, blob_path: &Path) -> std::io::Result<SignedBlob>;
170}
171
172/// Append `suffix` (e.g. `.sig`) to the blob's full filename — extends, does
173/// not replace. `Path::with_extension` would turn `foo.tar.gz` into
174/// `foo.tar.sig`; we want `foo.tar.gz.sig` so the channel layout shows the
175/// signature next to the artifact it covers.
176fn append_suffix(blob: &Path, suffix: &str) -> PathBuf {
177    let mut s = blob.as_os_str().to_owned();
178    s.push(suffix);
179    PathBuf::from(s)
180}
181
182/// Default production signer — shells out to `cosign sign-blob --yes`.
183/// Set `cosign_bin` to `"cosign"` (PATH lookup) or an absolute path; a
184/// missing binary surfaces as a `NotFound` IO error so the runner reports a
185/// clean step-failure message at the call site.
186pub struct CosignSigner {
187    pub cosign_bin: PathBuf,
188}
189
190impl Default for CosignSigner {
191    fn default() -> Self {
192        Self {
193            cosign_bin: PathBuf::from("cosign"),
194        }
195    }
196}
197
198#[async_trait]
199impl SigstoreSigner for CosignSigner {
200    async fn sign_blob(&self, blob_path: &Path) -> std::io::Result<SignedBlob> {
201        let sig = append_suffix(blob_path, ".sig");
202        let crt = append_suffix(blob_path, ".crt");
203        let bundle = append_suffix(blob_path, ".bundle");
204
205        let status = tokio::process::Command::new(&self.cosign_bin)
206            .arg("sign-blob")
207            .arg("--yes")
208            .arg("--output-signature")
209            .arg(&sig)
210            .arg("--output-certificate")
211            .arg(&crt)
212            .arg("--bundle")
213            .arg(&bundle)
214            .arg(blob_path)
215            .status()
216            .await?;
217        if !status.success() {
218            return Err(std::io::Error::new(
219                std::io::ErrorKind::Other,
220                format!(
221                    "cosign sign-blob exited with status {} (blob: {})",
222                    status,
223                    blob_path.display(),
224                ),
225            ));
226        }
227        Ok(SignedBlob {
228            signature_path: sig,
229            certificate_path: crt,
230            bundle_path: Some(bundle),
231        })
232    }
233}
234
235/// Test / local-dev fake — writes deterministic placeholder bytes so a
236/// pipeline's `sign-native-tarball` step succeeds without a real cosign
237/// install. NOT suitable for releases; release CI must wire [`CosignSigner`]
238/// explicitly.
239pub struct LoggingSigner;
240
241#[async_trait]
242impl SigstoreSigner for LoggingSigner {
243    async fn sign_blob(&self, blob_path: &Path) -> std::io::Result<SignedBlob> {
244        if !blob_path.is_file() {
245            return Err(std::io::Error::new(
246                std::io::ErrorKind::NotFound,
247                format!("blob to sign not found: {}", blob_path.display()),
248            ));
249        }
250        let sig = append_suffix(blob_path, ".sig");
251        let crt = append_suffix(blob_path, ".crt");
252        let bundle = append_suffix(blob_path, ".bundle");
253        fs::write(
254            &sig,
255            b"# yah logging-signer: placeholder signature (NOT a real cosign signature)\n",
256        )?;
257        fs::write(
258            &crt,
259            b"# yah logging-signer: placeholder certificate (NOT a real cosign cert)\n",
260        )?;
261        fs::write(
262            &bundle,
263            b"{\"_comment\":\"yah logging-signer placeholder bundle\"}\n",
264        )?;
265        tracing::warn!(
266            blob = %blob_path.display(),
267            "qed sign-native-tarball: LoggingSigner emitted placeholder \
268             .sig/.crt/.bundle (cosign not wired)"
269        );
270        Ok(SignedBlob {
271            signature_path: sig,
272            certificate_path: crt,
273            bundle_path: Some(bundle),
274        })
275    }
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281    use flate2::read::GzDecoder;
282    use std::io::{Read, Write};
283    use tempfile::TempDir;
284
285    fn write_dummy_binary(dir: &Path, name: &str, body: &[u8]) -> std::path::PathBuf {
286        let path = dir.join(name);
287        let mut f = fs::File::create(&path).unwrap();
288        f.write_all(body).unwrap();
289        path
290    }
291
292    fn sample_manifest() -> NativeTarballManifest {
293        NativeTarballManifest {
294            name: "yah-yubaba".into(),
295            version: "0.8.6".into(),
296            triple: "x86_64-unknown-linux-musl".into(),
297            binary: "bin/yubaba".into(),
298            description: Some("Native musl-static yubaba".into()),
299            env: BTreeMap::from([("RUST_LOG".into(), "info".into())]),
300        }
301    }
302
303    fn list_tar_entries(path: &Path) -> Vec<(String, Vec<u8>, u32)> {
304        let f = fs::File::open(path).unwrap();
305        let gz = GzDecoder::new(f);
306        let mut archive = tar::Archive::new(gz);
307        let mut out = Vec::new();
308        for entry in archive.entries().unwrap() {
309            let mut entry = entry.unwrap();
310            let header_path = entry.path().unwrap().to_string_lossy().into_owned();
311            let mode = entry.header().mode().unwrap();
312            let mut buf = Vec::new();
313            entry.read_to_end(&mut buf).unwrap();
314            out.push((header_path, buf, mode));
315        }
316        out.sort_by(|a, b| a.0.cmp(&b.0));
317        out
318    }
319
320    #[test]
321    fn pack_writes_binary_and_manifest_with_expected_modes() {
322        let dir = TempDir::new().unwrap();
323        let bin = write_dummy_binary(dir.path(), "yubaba", b"\x7fELF-fake-musl-binary");
324        let out = dir
325            .path()
326            .join("out/yah-yubaba-x86_64-unknown-linux-musl.tar.gz");
327
328        let manifest = sample_manifest();
329        pack_native_tarball(&bin, &manifest, &out).unwrap();
330
331        assert!(out.is_file(), "tarball materialised at {}", out.display());
332        let entries = list_tar_entries(&out);
333        assert_eq!(entries.len(), 2);
334        assert_eq!(entries[0].0, "bin/yubaba");
335        assert_eq!(entries[0].1, b"\x7fELF-fake-musl-binary");
336        assert_eq!(entries[0].2, 0o755);
337        assert_eq!(entries[1].0, "manifest.toml");
338        assert_eq!(entries[1].2, 0o644);
339    }
340
341    #[test]
342    fn pack_manifest_roundtrips_through_toml() {
343        let dir = TempDir::new().unwrap();
344        let bin = write_dummy_binary(dir.path(), "yubaba", b"x");
345        let out = dir.path().join("yubaba.tar.gz");
346        let manifest = sample_manifest();
347        pack_native_tarball(&bin, &manifest, &out).unwrap();
348
349        let entries = list_tar_entries(&out);
350        let manifest_entry = entries
351            .iter()
352            .find(|(p, _, _)| p == "manifest.toml")
353            .expect("manifest.toml present");
354        let text = std::str::from_utf8(&manifest_entry.1).unwrap();
355        let parsed: NativeTarballManifest = toml::from_str(text).expect("manifest.toml parses");
356        assert_eq!(parsed, manifest);
357    }
358
359    #[test]
360    fn pack_creates_missing_parent_dirs() {
361        let dir = TempDir::new().unwrap();
362        let bin = write_dummy_binary(dir.path(), "yubaba", b"x");
363        let out = dir.path().join("deeply/nested/path/yubaba.tar.gz");
364        pack_native_tarball(&bin, &sample_manifest(), &out).unwrap();
365        assert!(out.is_file());
366    }
367
368    #[test]
369    fn pack_missing_binary_is_io_error() {
370        let dir = TempDir::new().unwrap();
371        let bogus = dir.path().join("does-not-exist");
372        let out = dir.path().join("out.tar.gz");
373        let err = pack_native_tarball(&bogus, &sample_manifest(), &out).unwrap_err();
374        assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
375    }
376
377    // ── R407-T5 path helper + signer ──────────────────────────────────────
378
379    #[test]
380    fn tarball_stem_replaces_unsafe_chars() {
381        assert_eq!(
382            tarball_stem("yah-yubaba", "x86_64-unknown-linux-musl"),
383            "yah-yubaba-x86_64-unknown-linux-musl",
384        );
385        // `/` and `:` are not in the [A-Za-z0-9_.-] allowlist — both rewrite.
386        assert_eq!(
387            tarball_stem("ghcr.io/yah-ai/yah-yubaba", "linux:musl"),
388            "ghcr.io_yah-ai_yah-yubaba-linux_musl",
389        );
390    }
391
392    #[test]
393    fn native_tarball_output_path_matches_runner_convention() {
394        // The runner writes/reads `<camp>/.yah/cache/native/<stem>.tar.gz` —
395        // this helper is the single source of truth, so packaging (T2) and
396        // signing (T5) never drift.
397        let camp = Path::new("/camp");
398        let out = native_tarball_output_path(camp, "yah-yubaba", "x86_64-unknown-linux-musl");
399        assert_eq!(
400            out,
401            Path::new("/camp/.yah/cache/native/yah-yubaba-x86_64-unknown-linux-musl.tar.gz"),
402        );
403    }
404
405    #[tokio::test]
406    async fn logging_signer_writes_placeholder_sig_crt_bundle_next_to_blob() {
407        let dir = TempDir::new().unwrap();
408        let blob = dir
409            .path()
410            .join("yah-yubaba-x86_64-unknown-linux-musl.tar.gz");
411        fs::write(&blob, b"<fake tarball bytes>").unwrap();
412
413        let signer = LoggingSigner;
414        let signed = signer.sign_blob(&blob).await.unwrap();
415
416        // Suffixes are appended, not substituted — keep the `.tar.gz` so the
417        // signature reads next to its artifact in the channel layout.
418        assert_eq!(
419            signed.signature_path,
420            blob.with_file_name(format!(
421                "{}.sig",
422                blob.file_name().unwrap().to_string_lossy()
423            )),
424        );
425        assert_eq!(
426            signed.certificate_path,
427            blob.with_file_name(format!(
428                "{}.crt",
429                blob.file_name().unwrap().to_string_lossy()
430            )),
431        );
432        let bundle = signed.bundle_path.expect("LoggingSigner emits a bundle");
433        assert_eq!(
434            bundle,
435            blob.with_file_name(format!(
436                "{}.bundle",
437                blob.file_name().unwrap().to_string_lossy()
438            )),
439        );
440
441        // The placeholder files are non-empty so downstream tooling that
442        // counts bytes / hashes contents doesn't get an empty-file footgun.
443        assert!(fs::read(&signed.signature_path).unwrap().len() > 10);
444        assert!(fs::read(&signed.certificate_path).unwrap().len() > 10);
445        assert!(fs::read(&bundle).unwrap().len() > 10);
446    }
447
448    #[tokio::test]
449    async fn logging_signer_missing_blob_is_not_found_error() {
450        let dir = TempDir::new().unwrap();
451        let bogus = dir.path().join("does-not-exist.tar.gz");
452        let err = LoggingSigner.sign_blob(&bogus).await.unwrap_err();
453        assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
454    }
455
456    #[tokio::test]
457    async fn cosign_signer_missing_binary_surfaces_not_found() {
458        // No cosign on PATH in the test sandbox — point at an absolute path
459        // we know doesn't exist. Exercises the std::io::ErrorKind::NotFound
460        // branch the runner converts into a `StepFailed` with a clean
461        // operator-facing message.
462        let dir = TempDir::new().unwrap();
463        let blob = dir.path().join("artifact.tar.gz");
464        fs::write(&blob, b"x").unwrap();
465        let signer = CosignSigner {
466            cosign_bin: PathBuf::from("/definitely/not/a/real/cosign-binary"),
467        };
468        let err = signer.sign_blob(&blob).await.unwrap_err();
469        assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
470    }
471}