Skip to main content

greentic_bundle/build/
signing.rs

1//! C2 — DSSE+Ed25519 artifact signer for `.gtbundle` outputs.
2//!
3//! Reuses the cryptographic primitive in
4//! [`greentic_distributor_client::signing`] and emits a single-signature DSSE
5//! envelope alongside the artifact as `<artifact>.sig`. The envelope wraps an
6//! in-toto Statement v1 whose subject pins the artifact's SHA-256 digest and
7//! whose predicate is a minimal SLSA-provenance v1 document.
8//!
9//! Phase B scope: signature *authenticity* only. KMS-backed keys, Rekor
10//! transparency-log submission, and full provenance materials belong to the
11//! Trust plan.
12
13use 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/// Configuration for the bundle signer.
30#[derive(Debug, Clone)]
31pub struct SigningConfig {
32    /// Path to the Ed25519 PKCS#8 PEM private key.
33    pub signing_key_path: PathBuf,
34    /// Optional explicit DSSE `keyid`. When set, it must match the canonical
35    /// key id derived from the private key — a mismatch is rejected before any
36    /// build output is written.
37    pub key_id_override: Option<String>,
38    /// SLSA `builder.id`. Defaults to `greentic-bundle:<package version>`.
39    pub builder_id: Option<String>,
40    /// Override of the output signature path. Default: `<artifact>.sig`.
41    /// Rejected if it resolves to the artifact path.
42    pub signature_path_override: Option<PathBuf>,
43}
44
45/// SLSA `build_type` discriminator for a `.gtbundle` artifact.
46pub 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
53/// A validated signing context: a parsed private key and a resolved sidecar
54/// path that has been proven distinct from the artifact. Constructed once via
55/// [`PreparedSigner::prepare`] before any build output is written so that
56/// signing-configuration errors abort the build *before* the `.gtbundle` lands
57/// on disk (closes Codex finding #3).
58///
59/// `private_pem` is held in [`Zeroizing<String>`] so the key bytes are wiped
60/// from heap memory on drop. The manual [`std::fmt::Debug`] impl redacts the
61/// PEM so it can never leak via `{:?}` formatting (tracing, panic messages,
62/// anyhow context chains).
63pub 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    /// Validate the signing config against the intended artifact path. Performs:
85    /// 1. Reject non-UTF-8 artifact paths — DSSE subject names must round-trip
86    ///    losslessly through `to_str()` (closes review finding #8).
87    /// 2. Read + parse the PKCS#8 PEM, derive the canonical `(key_id, public_pem)`
88    ///    from the private key's verifying key — the only authoritative source.
89    /// 3. If `--key-id` override is given, require it to match the canonical id
90    ///    (case-insensitive hex).
91    /// 4. If a `<key>.pub` sibling exists, require its derived id to match the
92    ///    canonical id — a stale sibling after key rotation is a hard error.
93    ///    Read-without-exists to avoid a TOCTOU race (closes review finding #7).
94    /// 5. Resolve the signature output path and reject collisions with the
95    ///    artifact (lexical, lexically-normalized-absolute, parent-canonicalize,
96    ///    and full-canonicalize layers — closes review findings #1 and #2).
97    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        // SigningKey carries an in-memory copy of the private scalar; drop it
120        // here so only the Zeroizing<String> PEM lingers until signing runs.
121        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                // No sibling — canonical key id derived from private key is
150                // authoritative.
151            }
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        // Self-verify before publishing — defense in depth against a
189        // PKCS#8/key-id binding bug (closes Codex finding #2 recommendation).
190        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
201/// Atomically write a signed bundle: stage the artifact + sidecar adjacent to
202/// their final paths, self-verify, then rename both into place. On any error,
203/// best-effort remove both staged files so a release job collecting dist
204/// outputs after a failed step never sees an unsigned `.gtbundle` masquerading
205/// as a candidate (closes Codex finding #3).
206///
207/// **Failure semantics (fail-closed):** if the artifact rename succeeds but
208/// the sidecar rename fails, the just-published artifact is removed so the
209/// "never publish unsigned" invariant holds. The user must rebuild — the prior
210/// valid build is unrecoverable. This is intentional: a downstream automation
211/// that picks up `.gtbundle` files and never checks for `.sig` would otherwise
212/// ship unsigned outputs.
213///
214/// `write_artifact` is called with the *staged* artifact path; callers pass
215/// `bundle_fs::write_bundle(build_dir, ...)` here.
216pub 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    // Pre-clean any prior staging cruft so a partial leftover from an earlier
228    // crash doesn't masquerade as the current run's output.
229    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    // Same filesystem, sibling paths → POSIX rename is atomic.
264    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        // Sidecar rename failed AFTER the artifact rename — under the
273        // fail-closed invariant we remove the artifact too so no unsigned
274        // `.gtbundle` escapes downstream. The successful build is lost; the
275        // user must rebuild. We surface this explicitly so the choice is
276        // visible in the error chain rather than silent data loss.
277        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
288/// Streaming SHA-256 of `path` — chunked read so large bundles (multi-GB with
289/// warmup cache) don't allocate a whole-file `Vec<u8>` for hashing (closes
290/// review finding #4). Returns the lowercase hex digest.
291fn 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
308/// Default sidecar path: append `.sig` to the artifact path.
309pub fn default_signature_path(artifact: &Path) -> PathBuf {
310    append_suffix(artifact, SIGNATURE_SUFFIX)
311}
312
313/// Reject a signature output path that resolves to the artifact path. Closes
314/// review findings #1 and #2 in addition to the original Codex finding #1.
315/// Four layers, applied in order:
316/// 1. Lexical equality (cheap path).
317/// 2. `std::path::absolute` + lexical-normalize (collapses `.`/`..` components
318///    — catches `--signature-output /out/sub/../foo.gtbundle` aliasing the
319///    artifact).
320/// 3. Parent-canonicalize + file_name equality (catches symlinked parent
321///    directories even when the files themselves don't exist at prepare-time).
322/// 4. Full canonicalize when both files exist (catches symlinked files
323///    pointing at each other).
324fn 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
371/// Collapse `.` and `..` components from an absolute path without touching the
372/// filesystem. Used by [`reject_signature_artifact_collision`] to compare two
373/// paths that may differ only in redundant `..`/`.` segments.
374fn 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
388/// SLSA `builder.id` default. Captures the *library* version
389/// (`greentic-bundle:<version>`) at compile time, not the calling CLI's
390/// identity (e.g. `gtc`). Top-level binaries that embed this signer should
391/// pass `--builder-id` to record their own identity into the provenance
392/// predicate.
393fn 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    // Review finding #1: `..` components must be lexically collapsed so a
566    // path like `/out/sub/../demo.gtbundle` is recognised as aliasing
567    // `/out/demo.gtbundle`. Neither file exists at prepare-time so the
568    // canonicalize layer is skipped; the normalize_lexically layer catches
569    // it.
570    #[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        // Sibling subdir need not exist on disk — lexical normalize collapses
578        // `sub/..` regardless.
579        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    // Review finding #2: when --signature-output's parent canonicalizes to
599    // the artifact's parent (symlink), and both file_names match, this is a
600    // collision even when neither file exists yet.
601    #[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        // Neither file exists yet.
613        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    // Review finding #3: Debug output must not leak the private key PEM.
655    #[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    // Review finding #4: hashing must stream, not slurp the whole artifact
681    // into memory.
682    #[test]
683    fn hash_file_streams_chunks_and_matches_oneshot_digest() {
684        let dir = tempdir().unwrap();
685        // Just over two chunks of HASH_CHUNK_BYTES to exercise the read loop.
686        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    // Review finding #8: non-UTF-8 artifact paths are rejected at prepare()
695    // time so they never reach the DSSE statement subject.
696    #[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    // Review finding #5: when the sidecar rename fails AFTER the artifact has
761    // been published, the artifact is removed under the fail-closed
762    // invariant. The error chain must say so explicitly so the build's
763    // disappearance is not silent.
764    //
765    // Forcing only the SECOND rename to fail is tricky on POSIX — read-only
766    // directories trip the staged-sig write earlier. We instead pre-create
767    // sig_path as a non-empty directory: the staged sidecar write goes to
768    // `sig_path.partial` (a sibling file), which succeeds; the final
769    // `fs::rename(file, dir)` then fails with EISDIR.
770    #[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        // Make sig_path a non-empty directory so rename(file, dir) fails.
779        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        // sig_path (the directory) still exists — we only remove_file the
811        // staged sidecar, not the pre-existing directory.
812        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    // Replacement for the dropped happy-path coverage of --signature-output:
874    // a non-default, non-colliding custom sig path is honored, parent dir is
875    // created if absent, and the envelope verifies.
876    #[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    // Replacement for the dropped --key-id happy-path coverage: a matching
913    // (uppercase) override is accepted and produces a verifiable envelope.
914    #[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}