1use std::fs;
14use std::io::{self, Read};
15use std::path::{Component, Path, PathBuf};
16
17use anyhow::{Context, Result, anyhow, bail};
18use ed25519_dalek::SigningKey;
19use ed25519_dalek::pkcs8::DecodePrivateKey;
20use ed25519_dalek::pkcs8::EncodePublicKey;
21use ed25519_dalek::pkcs8::spki::der::pem::LineEnding;
22use greentic_distributor_client::signing::{
23 InTotoStatement, SlsaProvenance, TrustRoot, TrustedKey, key_id_for_public_key_pem,
24 sign_statement, verify_artifact_dsse,
25};
26use sha2::{Digest, Sha256};
27use zeroize::Zeroizing;
28
29#[derive(Debug, Clone)]
31pub struct SigningConfig {
32 pub signing_key_path: PathBuf,
34 pub key_id_override: Option<String>,
38 pub builder_id: Option<String>,
40 pub signature_path_override: Option<PathBuf>,
43}
44
45pub const BUNDLE_BUILD_TYPE: &str = "gtbundle";
47
48const SIGNATURE_SUFFIX: &str = ".sig";
49const PUBLIC_KEY_SUFFIX: &str = ".pub";
50const STAGING_SUFFIX: &str = ".partial";
51const HASH_CHUNK_BYTES: usize = 64 * 1024;
52
53pub struct PreparedSigner {
64 pub sig_path: PathBuf,
65 private_pem: Zeroizing<String>,
66 canonical_key_id: String,
67 canonical_public_pem: String,
68 builder_id: String,
69}
70
71impl std::fmt::Debug for PreparedSigner {
72 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
73 f.debug_struct("PreparedSigner")
74 .field("sig_path", &self.sig_path)
75 .field("private_pem", &"[REDACTED]")
76 .field("canonical_key_id", &self.canonical_key_id)
77 .field("canonical_public_pem_len", &self.canonical_public_pem.len())
78 .field("builder_id", &self.builder_id)
79 .finish()
80 }
81}
82
83impl PreparedSigner {
84 pub fn prepare(artifact: &Path, cfg: &SigningConfig) -> Result<Self> {
98 if artifact.to_str().is_none() {
99 bail!(
100 "artifact path {} is not valid UTF-8; DSSE subject names must round-trip losslessly",
101 artifact.display()
102 );
103 }
104
105 let private_pem =
106 Zeroizing::new(fs::read_to_string(&cfg.signing_key_path).with_context(|| {
107 format!("read signing key: {}", cfg.signing_key_path.display())
108 })?);
109 let signing_key = SigningKey::from_pkcs8_pem(&private_pem).with_context(|| {
110 format!(
111 "parse PKCS#8 PEM private key: {}",
112 cfg.signing_key_path.display()
113 )
114 })?;
115 let public_pem = signing_key
116 .verifying_key()
117 .to_public_key_pem(LineEnding::LF)
118 .context("encode SPKI public key PEM")?;
119 drop(signing_key);
122 let canonical_key_id = key_id_for_public_key_pem(&public_pem)
123 .map_err(|e| anyhow!("derive canonical key id: {e}"))?;
124
125 if let Some(override_) = cfg.key_id_override.as_deref() {
126 if override_.is_empty() {
127 bail!("--key-id may not be empty");
128 }
129 if !override_.eq_ignore_ascii_case(&canonical_key_id) {
130 bail!(
131 "--key-id {override_} does not match the private key (canonical id: {canonical_key_id})"
132 );
133 }
134 }
135
136 let pub_sibling = append_suffix(&cfg.signing_key_path, PUBLIC_KEY_SUFFIX);
137 match fs::read_to_string(&pub_sibling) {
138 Ok(sibling_pem) => {
139 let sibling_id = key_id_for_public_key_pem(&sibling_pem)
140 .map_err(|e| anyhow!("derive id from {}: {e}", pub_sibling.display()))?;
141 if !sibling_id.eq_ignore_ascii_case(&canonical_key_id) {
142 bail!(
143 "public key sibling {} does not match the private key (pub id={sibling_id} priv id={canonical_key_id}); a stale .pub after key rotation will silently break verification",
144 pub_sibling.display()
145 );
146 }
147 }
148 Err(e) if e.kind() == io::ErrorKind::NotFound => {
149 }
152 Err(e) => {
153 return Err(anyhow::Error::new(e).context(format!(
154 "read public key sibling: {}",
155 pub_sibling.display()
156 )));
157 }
158 }
159
160 let sig_path = cfg
161 .signature_path_override
162 .clone()
163 .unwrap_or_else(|| default_signature_path(artifact));
164 reject_signature_artifact_collision(&sig_path, artifact)?;
165
166 Ok(Self {
167 sig_path,
168 private_pem,
169 canonical_key_id,
170 canonical_public_pem: public_pem,
171 builder_id: cfg.builder_id.clone().unwrap_or_else(default_builder_id),
172 })
173 }
174
175 fn build_envelope_json(&self, digest_hex: &str, artifact_name: &str) -> Result<Vec<u8>> {
176 let predicate = SlsaProvenance {
177 builder_id: self.builder_id.clone(),
178 build_type: BUNDLE_BUILD_TYPE.to_string(),
179 built_at: None,
180 tlog_entry_id: None,
181 };
182 let statement = InTotoStatement::provenance(artifact_name, digest_hex, predicate);
183 let envelope = sign_statement(&statement, &self.private_pem, &self.canonical_key_id)
184 .map_err(|e| anyhow!("sign in-toto statement: {e}"))?;
185 let envelope_json =
186 serde_json::to_vec_pretty(&envelope).context("serialize DSSE envelope")?;
187
188 let trust = TrustRoot::new(vec![TrustedKey {
191 key_id: self.canonical_key_id.clone(),
192 public_key_pem: self.canonical_public_pem.clone(),
193 }]);
194 verify_artifact_dsse(&envelope_json, digest_hex, &trust)
195 .map_err(|e| anyhow!("self-verify of emitted envelope failed: {e}"))?;
196
197 Ok(envelope_json)
198 }
199}
200
201pub fn stage_sign_and_publish<F>(
217 artifact: &Path,
218 signer: &PreparedSigner,
219 write_artifact: F,
220) -> Result<PathBuf>
221where
222 F: FnOnce(&Path) -> Result<()>,
223{
224 let staged_artifact = append_suffix(artifact, STAGING_SUFFIX);
225 let staged_sig = append_suffix(&signer.sig_path, STAGING_SUFFIX);
226
227 let _ = fs::remove_file(&staged_artifact);
230 let _ = fs::remove_file(&staged_sig);
231
232 let outcome = (|| -> Result<()> {
233 if let Some(parent) = signer.sig_path.parent()
234 && !parent.as_os_str().is_empty()
235 {
236 fs::create_dir_all(parent)
237 .with_context(|| format!("create signature parent dir: {}", parent.display()))?;
238 }
239 write_artifact(&staged_artifact)?;
240 let digest_hex = hash_file(&staged_artifact)?;
241 let artifact_name = artifact
242 .file_name()
243 .and_then(|s| s.to_str())
244 .map(str::to_owned)
245 .ok_or_else(|| {
246 anyhow!(
247 "artifact path {} has no UTF-8 file name component",
248 artifact.display()
249 )
250 })?;
251 let envelope_json = signer.build_envelope_json(&digest_hex, &artifact_name)?;
252 fs::write(&staged_sig, &envelope_json)
253 .with_context(|| format!("write staged sidecar: {}", staged_sig.display()))?;
254 Ok(())
255 })();
256
257 if let Err(e) = outcome {
258 let _ = fs::remove_file(&staged_artifact);
259 let _ = fs::remove_file(&staged_sig);
260 return Err(e);
261 }
262
263 fs::rename(&staged_artifact, artifact).with_context(|| {
265 format!(
266 "rename staged artifact {} -> {}",
267 staged_artifact.display(),
268 artifact.display()
269 )
270 })?;
271 if let Err(e) = fs::rename(&staged_sig, &signer.sig_path) {
272 let _ = fs::remove_file(artifact);
278 let _ = fs::remove_file(&staged_sig);
279 return Err(anyhow::Error::new(e).context(format!(
280 "publish staged sidecar to {} failed; under the fail-closed invariant the artifact at {} was also removed — rerun build to publish a signed bundle",
281 signer.sig_path.display(),
282 artifact.display()
283 )));
284 }
285 Ok(signer.sig_path.clone())
286}
287
288fn hash_file(path: &Path) -> Result<String> {
292 let mut file =
293 fs::File::open(path).with_context(|| format!("open for hashing: {}", path.display()))?;
294 let mut hasher = Sha256::new();
295 let mut buf = vec![0u8; HASH_CHUNK_BYTES];
296 loop {
297 let n = file
298 .read(&mut buf)
299 .with_context(|| format!("read for hashing: {}", path.display()))?;
300 if n == 0 {
301 break;
302 }
303 hasher.update(&buf[..n]);
304 }
305 Ok(hex::encode(hasher.finalize()))
306}
307
308pub fn default_signature_path(artifact: &Path) -> PathBuf {
310 append_suffix(artifact, SIGNATURE_SUFFIX)
311}
312
313fn reject_signature_artifact_collision(sig_path: &Path, artifact: &Path) -> Result<()> {
325 fn bail_collision(sig: &Path, art: &Path, layer: &str) -> Result<()> {
326 bail!(
327 "signature output path {} {} the artifact path {}; refusing to overwrite the bundle with its own envelope",
328 sig.display(),
329 layer,
330 art.display(),
331 )
332 }
333
334 if sig_path == artifact {
335 return bail_collision(sig_path, artifact, "equals");
336 }
337
338 let sig_abs = std::path::absolute(sig_path).unwrap_or_else(|_| sig_path.to_path_buf());
339 let art_abs = std::path::absolute(artifact).unwrap_or_else(|_| artifact.to_path_buf());
340 if normalize_lexically(&sig_abs) == normalize_lexically(&art_abs) {
341 return bail_collision(sig_path, artifact, "resolves to");
342 }
343
344 if let (Some(sp), Some(ap), Some(sn), Some(an)) = (
345 sig_path.parent(),
346 artifact.parent(),
347 sig_path.file_name(),
348 artifact.file_name(),
349 ) && sn == an
350 {
351 let parent_match = match (sp.canonicalize(), ap.canonicalize()) {
352 (Ok(s), Ok(a)) => s == a,
353 _ => false,
354 };
355 if parent_match {
356 return bail_collision(sig_path, artifact, "shares a canonical parent dir with");
357 }
358 }
359
360 if sig_path.exists()
361 && artifact.exists()
362 && let (Ok(s), Ok(a)) = (sig_path.canonicalize(), artifact.canonicalize())
363 && s == a
364 {
365 return bail_collision(sig_path, artifact, "canonicalizes to (symlink collision)");
366 }
367
368 Ok(())
369}
370
371fn normalize_lexically(path: &Path) -> PathBuf {
375 let mut out = PathBuf::new();
376 for comp in path.components() {
377 match comp {
378 Component::ParentDir => {
379 out.pop();
380 }
381 Component::CurDir => {}
382 other => out.push(other.as_os_str()),
383 }
384 }
385 out
386}
387
388fn default_builder_id() -> String {
394 format!("greentic-bundle:{}", env!("CARGO_PKG_VERSION"))
395}
396
397fn append_suffix(path: &Path, suffix: &str) -> PathBuf {
398 debug_assert!(
399 !path.as_os_str().is_empty(),
400 "append_suffix called on empty path"
401 );
402 let mut s = path.as_os_str().to_os_string();
403 s.push(suffix);
404 PathBuf::from(s)
405}
406
407#[cfg(test)]
408mod tests {
409 use super::*;
410 use ed25519_dalek::pkcs8::EncodePrivateKey;
411 use tempfile::tempdir;
412
413 fn ephemeral_keypair(seed: u8) -> (String, String) {
414 let sk = SigningKey::from_bytes(&[seed; 32]);
415 let vk = sk.verifying_key();
416 let priv_pem = sk.to_pkcs8_pem(LineEnding::LF).unwrap().to_string();
417 let pub_pem = vk.to_public_key_pem(LineEnding::LF).unwrap();
418 (priv_pem, pub_pem)
419 }
420
421 fn write_key(dir: &Path, name: &str, pem: &str) -> PathBuf {
422 let p = dir.join(name);
423 fs::write(&p, pem).unwrap();
424 p
425 }
426
427 #[test]
428 fn default_signature_path_appends_sig() {
429 let p = Path::new("/tmp/dist/example.gtbundle");
430 assert_eq!(
431 default_signature_path(p),
432 PathBuf::from("/tmp/dist/example.gtbundle.sig")
433 );
434 }
435
436 #[test]
437 fn prepare_derives_key_id_from_private_pem_without_pub_sibling() {
438 let dir = tempdir().unwrap();
439 let artifact = dir.path().join("a.gtbundle");
440 let (priv_pem, pub_pem) = ephemeral_keypair(31);
441 let key_path = write_key(dir.path(), "k.pem", &priv_pem);
442 let signer = PreparedSigner::prepare(
443 &artifact,
444 &SigningConfig {
445 signing_key_path: key_path,
446 key_id_override: None,
447 builder_id: None,
448 signature_path_override: None,
449 },
450 )
451 .unwrap();
452 assert_eq!(
453 signer.canonical_key_id,
454 key_id_for_public_key_pem(&pub_pem).unwrap()
455 );
456 assert_eq!(signer.sig_path, default_signature_path(&artifact));
457 }
458
459 #[test]
460 fn prepare_rejects_key_id_override_that_doesnt_match_private_key() {
461 let dir = tempdir().unwrap();
462 let artifact = dir.path().join("a.gtbundle");
463 let (priv_pem, _pub_pem) = ephemeral_keypair(32);
464 let key_path = write_key(dir.path(), "k.pem", &priv_pem);
465 let err = PreparedSigner::prepare(
466 &artifact,
467 &SigningConfig {
468 signing_key_path: key_path,
469 key_id_override: Some("deadbeefdeadbeefdeadbeefdeadbeef".into()),
470 builder_id: None,
471 signature_path_override: None,
472 },
473 )
474 .expect_err("mismatched --key-id must be rejected");
475 assert!(
476 format!("{err:#}").contains("does not match"),
477 "got: {err:#}"
478 );
479 }
480
481 #[test]
482 fn prepare_rejects_stale_pub_sibling_after_key_rotation() {
483 let dir = tempdir().unwrap();
484 let artifact = dir.path().join("a.gtbundle");
485 let (priv_pem, _pub_pem) = ephemeral_keypair(33);
486 let (_priv_other, pub_other) = ephemeral_keypair(34);
487 let key_path = write_key(dir.path(), "k.pem", &priv_pem);
488 fs::write(append_suffix(&key_path, PUBLIC_KEY_SUFFIX), &pub_other).unwrap();
489 let err = PreparedSigner::prepare(
490 &artifact,
491 &SigningConfig {
492 signing_key_path: key_path,
493 key_id_override: None,
494 builder_id: None,
495 signature_path_override: None,
496 },
497 )
498 .expect_err("stale .pub must be rejected");
499 let msg = format!("{err:#}");
500 assert!(
501 msg.contains("stale .pub") || msg.contains("does not match"),
502 "got: {msg}"
503 );
504 }
505
506 #[test]
507 fn prepare_rejects_empty_key_id_override() {
508 let dir = tempdir().unwrap();
509 let artifact = dir.path().join("a.gtbundle");
510 let (priv_pem, _pub_pem) = ephemeral_keypair(35);
511 let key_path = write_key(dir.path(), "k.pem", &priv_pem);
512 let err = PreparedSigner::prepare(
513 &artifact,
514 &SigningConfig {
515 signing_key_path: key_path,
516 key_id_override: Some(String::new()),
517 builder_id: None,
518 signature_path_override: None,
519 },
520 )
521 .expect_err("empty override must be rejected");
522 assert!(format!("{err:#}").contains("--key-id"));
523 }
524
525 #[test]
526 fn prepare_rejects_signature_path_equal_to_artifact() {
527 let dir = tempdir().unwrap();
528 let artifact = dir.path().join("a.gtbundle");
529 let (priv_pem, _pub_pem) = ephemeral_keypair(36);
530 let key_path = write_key(dir.path(), "k.pem", &priv_pem);
531 let err = PreparedSigner::prepare(
532 &artifact,
533 &SigningConfig {
534 signing_key_path: key_path,
535 key_id_override: None,
536 builder_id: None,
537 signature_path_override: Some(artifact.clone()),
538 },
539 )
540 .expect_err("identical paths must be rejected");
541 let msg = format!("{err:#}");
542 assert!(msg.contains("refusing to overwrite"), "got: {msg}");
543 }
544
545 #[test]
546 fn prepare_rejects_signature_path_resolving_to_artifact_via_relative() {
547 let dir = tempdir().unwrap();
548 let artifact = dir.path().join("a.gtbundle");
549 let (priv_pem, _pub_pem) = ephemeral_keypair(37);
550 let key_path = write_key(dir.path(), "k.pem", &priv_pem);
551 let alias = dir.path().join(".").join("a.gtbundle");
552 let err = PreparedSigner::prepare(
553 &artifact,
554 &SigningConfig {
555 signing_key_path: key_path,
556 key_id_override: None,
557 builder_id: None,
558 signature_path_override: Some(alias),
559 },
560 )
561 .expect_err("path alias to artifact must be rejected");
562 assert!(format!("{err:#}").contains("refusing to overwrite"));
563 }
564
565 #[test]
571 fn prepare_rejects_signature_path_via_parent_dir_dotdot() {
572 let dir = tempdir().unwrap();
573 fs::create_dir_all(dir.path().join("dist")).unwrap();
574 let artifact = dir.path().join("dist").join("demo.gtbundle");
575 let (priv_pem, _pub_pem) = ephemeral_keypair(50);
576 let key_path = write_key(dir.path(), "k.pem", &priv_pem);
577 let aliased = dir
580 .path()
581 .join("dist")
582 .join("sub")
583 .join("..")
584 .join("demo.gtbundle");
585 let err = PreparedSigner::prepare(
586 &artifact,
587 &SigningConfig {
588 signing_key_path: key_path,
589 key_id_override: None,
590 builder_id: None,
591 signature_path_override: Some(aliased),
592 },
593 )
594 .expect_err("..-alias must be rejected");
595 assert!(format!("{err:#}").contains("refusing to overwrite"));
596 }
597
598 #[cfg(unix)]
602 #[test]
603 fn prepare_rejects_signature_path_via_symlinked_parent_dir() {
604 let dir = tempdir().unwrap();
605 let real_parent = dir.path().join("real-dist");
606 fs::create_dir_all(&real_parent).unwrap();
607 let link_parent = dir.path().join("current-dist");
608 std::os::unix::fs::symlink(&real_parent, &link_parent).unwrap();
609
610 let artifact = real_parent.join("demo.gtbundle");
611 let aliased_sig = link_parent.join("demo.gtbundle");
612 assert!(!artifact.exists());
614 assert!(!aliased_sig.exists());
615
616 let (priv_pem, _pub_pem) = ephemeral_keypair(51);
617 let key_path = write_key(dir.path(), "k.pem", &priv_pem);
618 let err = PreparedSigner::prepare(
619 &artifact,
620 &SigningConfig {
621 signing_key_path: key_path,
622 key_id_override: None,
623 builder_id: None,
624 signature_path_override: Some(aliased_sig),
625 },
626 )
627 .expect_err("symlinked-parent alias must be rejected");
628 assert!(format!("{err:#}").contains("refusing to overwrite"));
629 }
630
631 #[cfg(unix)]
632 #[test]
633 fn prepare_rejects_signature_path_via_symlink_to_artifact() {
634 let dir = tempdir().unwrap();
635 let artifact = dir.path().join("a.gtbundle");
636 fs::write(&artifact, b"existing-bundle").unwrap();
637 let (priv_pem, _pub_pem) = ephemeral_keypair(38);
638 let key_path = write_key(dir.path(), "k.pem", &priv_pem);
639 let link = dir.path().join("alias.sig");
640 std::os::unix::fs::symlink(&artifact, &link).unwrap();
641 let err = PreparedSigner::prepare(
642 &artifact,
643 &SigningConfig {
644 signing_key_path: key_path,
645 key_id_override: None,
646 builder_id: None,
647 signature_path_override: Some(link),
648 },
649 )
650 .expect_err("symlink to artifact must be rejected");
651 assert!(format!("{err:#}").contains("refusing to overwrite"));
652 }
653
654 #[test]
656 fn debug_format_redacts_private_pem() {
657 let dir = tempdir().unwrap();
658 let artifact = dir.path().join("a.gtbundle");
659 let (priv_pem, _pub_pem) = ephemeral_keypair(52);
660 let key_path = write_key(dir.path(), "k.pem", &priv_pem);
661 let signer = PreparedSigner::prepare(
662 &artifact,
663 &SigningConfig {
664 signing_key_path: key_path,
665 key_id_override: None,
666 builder_id: None,
667 signature_path_override: None,
668 },
669 )
670 .unwrap();
671 let dbg = format!("{signer:?}");
672 assert!(
673 !dbg.contains("BEGIN PRIVATE KEY") && !dbg.contains("BEGIN ED25519"),
674 "Debug leaked PEM: {dbg}"
675 );
676 assert!(dbg.contains("[REDACTED]"));
677 assert!(dbg.contains(&signer.canonical_key_id));
678 }
679
680 #[test]
683 fn hash_file_streams_chunks_and_matches_oneshot_digest() {
684 let dir = tempdir().unwrap();
685 let bytes = vec![0xABu8; HASH_CHUNK_BYTES * 2 + 7];
687 let path = dir.path().join("big.bin");
688 fs::write(&path, &bytes).unwrap();
689 let streamed = hash_file(&path).unwrap();
690 let oneshot = hex::encode(Sha256::digest(&bytes));
691 assert_eq!(streamed, oneshot);
692 }
693
694 #[cfg(unix)]
697 #[test]
698 fn prepare_rejects_non_utf8_artifact_path() {
699 use std::ffi::OsStr;
700 use std::os::unix::ffi::OsStrExt;
701 let dir = tempdir().unwrap();
702 let invalid_name = OsStr::from_bytes(b"bad-\xff-name.gtbundle");
703 let artifact = dir.path().join(invalid_name);
704 let (priv_pem, _pub_pem) = ephemeral_keypair(53);
705 let key_path = write_key(dir.path(), "k.pem", &priv_pem);
706 let err = PreparedSigner::prepare(
707 &artifact,
708 &SigningConfig {
709 signing_key_path: key_path,
710 key_id_override: None,
711 builder_id: None,
712 signature_path_override: None,
713 },
714 )
715 .expect_err("non-UTF-8 artifact path must be rejected");
716 let msg = format!("{err:#}");
717 assert!(msg.contains("not valid UTF-8"), "got: {msg}");
718 }
719
720 #[test]
721 fn stage_sign_and_publish_emits_verifiable_sidecar() {
722 let dir = tempdir().unwrap();
723 let artifact = dir.path().join("hello.gtbundle");
724 let (priv_pem, pub_pem) = ephemeral_keypair(39);
725 let key_path = write_key(dir.path(), "k.pem", &priv_pem);
726 fs::write(append_suffix(&key_path, PUBLIC_KEY_SUFFIX), &pub_pem).unwrap();
727 let signer = PreparedSigner::prepare(
728 &artifact,
729 &SigningConfig {
730 signing_key_path: key_path,
731 key_id_override: None,
732 builder_id: Some("greentic-bundle:test".into()),
733 signature_path_override: None,
734 },
735 )
736 .unwrap();
737
738 let sig_path = stage_sign_and_publish(&artifact, &signer, |staged| {
739 fs::write(staged, b"squashfs-bytes")?;
740 Ok(())
741 })
742 .expect("stage+sign");
743 assert_eq!(sig_path, default_signature_path(&artifact));
744 assert!(artifact.exists());
745 assert!(sig_path.exists());
746 assert!(!append_suffix(&artifact, STAGING_SUFFIX).exists());
747 assert!(!append_suffix(&sig_path, STAGING_SUFFIX).exists());
748
749 let envelope_bytes = fs::read(&sig_path).unwrap();
750 let key_id = key_id_for_public_key_pem(&pub_pem).unwrap();
751 let trust = TrustRoot::new(vec![TrustedKey {
752 key_id: key_id.clone(),
753 public_key_pem: pub_pem,
754 }]);
755 let expected_digest = hash_file(&artifact).unwrap();
756 let verified = verify_artifact_dsse(&envelope_bytes, &expected_digest, &trust).unwrap();
757 assert_eq!(verified.verified_key_ids, vec![key_id]);
758 }
759
760 #[cfg(unix)]
771 #[test]
772 fn stage_sign_and_publish_surfaces_fail_closed_artifact_removal() {
773 let dir = tempdir().unwrap();
774 let artifact = dir.path().join("hello.gtbundle");
775 let sig_dir = dir.path().join("sigs");
776 fs::create_dir_all(&sig_dir).unwrap();
777 let sig_path = sig_dir.join("hello.gtbundle.sig");
778 fs::create_dir_all(&sig_path).unwrap();
780 fs::write(sig_path.join("placeholder"), b"x").unwrap();
781
782 let (priv_pem, _pub_pem) = ephemeral_keypair(54);
783 let key_path = write_key(dir.path(), "k.pem", &priv_pem);
784 let signer = PreparedSigner::prepare(
785 &artifact,
786 &SigningConfig {
787 signing_key_path: key_path,
788 key_id_override: None,
789 builder_id: None,
790 signature_path_override: Some(sig_path.clone()),
791 },
792 )
793 .unwrap();
794
795 let err = stage_sign_and_publish(&artifact, &signer, |staged| {
796 fs::write(staged, b"bytes")?;
797 Ok(())
798 })
799 .expect_err("sig rename should fail because sig_path is a non-empty directory");
800
801 let msg = format!("{err:#}");
802 assert!(
803 msg.contains("fail-closed") && msg.contains("was also removed"),
804 "error must surface fail-closed removal, got: {msg}"
805 );
806 assert!(
807 !artifact.exists(),
808 "artifact must be removed under fail-closed"
809 );
810 assert!(sig_path.is_dir());
813 }
814
815 #[test]
816 fn stage_sign_and_publish_leaves_no_artifact_when_write_step_fails() {
817 let dir = tempdir().unwrap();
818 let artifact = dir.path().join("hello.gtbundle");
819 let (priv_pem, _pub_pem) = ephemeral_keypair(40);
820 let key_path = write_key(dir.path(), "k.pem", &priv_pem);
821 let signer = PreparedSigner::prepare(
822 &artifact,
823 &SigningConfig {
824 signing_key_path: key_path,
825 key_id_override: None,
826 builder_id: None,
827 signature_path_override: None,
828 },
829 )
830 .unwrap();
831
832 let err = stage_sign_and_publish(&artifact, &signer, |_staged| {
833 anyhow::bail!("simulated write_bundle failure")
834 })
835 .expect_err("must propagate write failure");
836 assert!(format!("{err:#}").contains("simulated"));
837 assert!(!artifact.exists());
838 assert!(!signer.sig_path.exists());
839 assert!(!append_suffix(&artifact, STAGING_SUFFIX).exists());
840 assert!(!append_suffix(&signer.sig_path, STAGING_SUFFIX).exists());
841 }
842
843 #[test]
844 fn stage_sign_and_publish_cleans_up_prior_partial_leftovers() {
845 let dir = tempdir().unwrap();
846 let artifact = dir.path().join("hello.gtbundle");
847 let (priv_pem, _pub_pem) = ephemeral_keypair(41);
848 let key_path = write_key(dir.path(), "k.pem", &priv_pem);
849 let signer = PreparedSigner::prepare(
850 &artifact,
851 &SigningConfig {
852 signing_key_path: key_path,
853 key_id_override: None,
854 builder_id: None,
855 signature_path_override: None,
856 },
857 )
858 .unwrap();
859 fs::write(append_suffix(&artifact, STAGING_SUFFIX), b"stale-art").unwrap();
860 fs::write(
861 append_suffix(&signer.sig_path, STAGING_SUFFIX),
862 b"stale-sig",
863 )
864 .unwrap();
865 stage_sign_and_publish(&artifact, &signer, |staged| {
866 fs::write(staged, b"fresh-bytes")?;
867 Ok(())
868 })
869 .expect("stage+sign");
870 assert_eq!(fs::read(&artifact).unwrap(), b"fresh-bytes");
871 }
872
873 #[test]
877 fn custom_signature_output_path_creates_parent_dir() {
878 let dir = tempdir().unwrap();
879 let artifact = dir.path().join("a.gtbundle");
880 let (priv_pem, pub_pem) = ephemeral_keypair(55);
881 let key_path = write_key(dir.path(), "k.pem", &priv_pem);
882 let custom = dir.path().join("sigs").join("custom.json");
883 assert!(!custom.parent().unwrap().exists());
884
885 let signer = PreparedSigner::prepare(
886 &artifact,
887 &SigningConfig {
888 signing_key_path: key_path,
889 key_id_override: None,
890 builder_id: None,
891 signature_path_override: Some(custom.clone()),
892 },
893 )
894 .unwrap();
895 stage_sign_and_publish(&artifact, &signer, |staged| {
896 fs::write(staged, b"x")?;
897 Ok(())
898 })
899 .expect("sign");
900
901 assert!(custom.exists());
902 assert!(custom.parent().unwrap().exists());
903 let envelope_bytes = fs::read(&custom).unwrap();
904 let trust = TrustRoot::new(vec![TrustedKey {
905 key_id: key_id_for_public_key_pem(&pub_pem).unwrap(),
906 public_key_pem: pub_pem,
907 }]);
908 let digest = hash_file(&artifact).unwrap();
909 verify_artifact_dsse(&envelope_bytes, &digest, &trust).expect("verify");
910 }
911
912 #[test]
915 fn key_id_override_matching_uppercase_is_accepted() {
916 let dir = tempdir().unwrap();
917 let artifact = dir.path().join("a.gtbundle");
918 let (priv_pem, pub_pem) = ephemeral_keypair(56);
919 let key_path = write_key(dir.path(), "k.pem", &priv_pem);
920 let derived = key_id_for_public_key_pem(&pub_pem).unwrap();
921 let upper = derived.to_ascii_uppercase();
922
923 let signer = PreparedSigner::prepare(
924 &artifact,
925 &SigningConfig {
926 signing_key_path: key_path,
927 key_id_override: Some(upper),
928 builder_id: None,
929 signature_path_override: None,
930 },
931 )
932 .expect("uppercase matching --key-id must be accepted");
933 stage_sign_and_publish(&artifact, &signer, |staged| {
934 fs::write(staged, b"x")?;
935 Ok(())
936 })
937 .expect("sign");
938 let envelope_bytes = fs::read(&signer.sig_path).unwrap();
939 let trust = TrustRoot::new(vec![TrustedKey {
940 key_id: derived,
941 public_key_pem: pub_pem,
942 }]);
943 let digest = hash_file(&artifact).unwrap();
944 verify_artifact_dsse(&envelope_bytes, &digest, &trust).expect("verify");
945 }
946}