1use 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
37pub struct NativeTarballManifest {
38 pub name: String,
40 pub version: String,
44 pub triple: String,
47 pub binary: String,
49 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub description: Option<String>,
52 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
55 pub env: BTreeMap<String, String>,
56}
57
58pub 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
109pub 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
125pub 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#[derive(Debug, Clone, PartialEq, Eq)]
153pub struct SignedBlob {
154 pub signature_path: PathBuf,
156 pub certificate_path: PathBuf,
158 pub bundle_path: Option<PathBuf>,
161}
162
163#[async_trait]
168pub trait SigstoreSigner: Send + Sync {
169 async fn sign_blob(&self, blob_path: &Path) -> std::io::Result<SignedBlob>;
170}
171
172fn 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
182pub 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
235pub 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 #[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 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 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 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 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 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}