Skip to main content

yah_qed/provider/
authenticode.rs

1//! Authenticode code-signing adapter (R509-F2).
2//!
3//! Signs a Windows `.exe` / `.msi` (and the other PE-family installers) in
4//! place using [`osslsigncode`] — a cross-platform Authenticode signer that
5//! needs no Windows host, so a release can sign Windows artifacts from the same
6//! Linux/macOS runner that built them. On a Windows host the caller can point
7//! `tool` at `signtool` instead; the argument shape is normalized behind the
8//! [`Signer`] enum.
9//!
10//! Authenticode is a *transform*, not a ship: it mutates the binary's embedded
11//! signature and returns the signed artifacts in [`ProviderReport::produced`]
12//! so a downstream `Outcome::Provider { provider = "winsparkle" }` ships the
13//! now-signed installer. It publishes nothing, so
14//! [`ProviderReport::published`] stays empty. The pipeline orders
15//! `authenticode` before the channel-ship outcome (mirrors notarize → sparkle
16//! on the mac slice).
17//!
18//! ## Credentials
19//!
20//! Two slots, resolved through [`crate::secrets_bridge`] (shared verbatim with
21//! the winsparkle adapter R509-F4 — same cert, no code dependency):
22//! - `AUTHENTICODE_CERT` — the PKCS#12 / `.pfx` cert+key, **base64-encoded**
23//!   (the secrets bridge resolves to a `String`; a `.pfx` is binary). Decoded
24//!   and materialized to a `0600` temp file under `ctx.work_dir`, never logged.
25//! - `AUTHENTICODE_CERT_PASSWORD` — the `.pfx` import password.
26//!
27//! ## Dry run
28//!
29//! `ctx.dry_run` validates that both slots resolve, that the cert decodes from
30//! base64, and that at least one signable artifact is selected, then reports
31//! `would sign <file> (sha256, ts <tsa>)` per artifact — spawning no signer and
32//! mutating nothing.
33//!
34//! [`osslsigncode`]: https://github.com/mtrojnar/osslsigncode
35
36use std::path::{Path, PathBuf};
37
38use async_trait::async_trait;
39use base64::Engine;
40use serde::Deserialize;
41
42use crate::provider::{ProviderContext, ProviderReport, ReleaseProvider};
43use crate::runner::RunnerError;
44use crate::types::ProducedArtifact;
45
46/// Credential slot names this adapter reads. Shared verbatim with winsparkle
47/// (R509-F4) — same cert family, resolved independently per adapter.
48const SLOT_CERT: &str = "AUTHENTICODE_CERT";
49const SLOT_CERT_PASSWORD: &str = "AUTHENTICODE_CERT_PASSWORD";
50
51/// Default RFC3161 timestamp authority — DigiCert's public TSA. Overridable via
52/// `with = { timestamp_url = "…" }`. A timestamp is what lets a signature stay
53/// valid after the signing cert expires, so it is on by default.
54const DEFAULT_TSA: &str = "http://timestamp.digicert.com";
55
56/// Default digest algorithm. SHA-1 Authenticode is deprecated; SHA-256 is the
57/// modern baseline.
58const DEFAULT_DIGEST: &str = "sha256";
59
60/// PE-family extensions Authenticode can sign. `.exe` / `.dll` / `.sys` are raw
61/// PE; `.msi` / `.cab` / `.cat` are the installer/catalog containers
62/// osslsigncode also handles. Matched case-insensitively.
63const SIGNABLE_EXTS: &[&str] = &["exe", "msi", "dll", "sys", "cab", "cat", "ps1"];
64
65/// The `with = { … }` config block for an `authenticode` outcome.
66#[derive(Debug, Clone, Deserialize)]
67#[serde(default)]
68struct AuthenticodeConfig {
69    /// Binary-name / basename globs selecting which produced artifacts to sign
70    /// (`*` is the only wildcard). An entry matches an artifact when it globs
71    /// the artifact's `binary` field *or* its file basename. When empty, every
72    /// produced artifact with a signable extension is taken.
73    artifacts: Vec<String>,
74    /// RFC3161 timestamp-authority URL. Defaults to [`DEFAULT_TSA`].
75    timestamp_url: String,
76    /// File-digest algorithm passed to the signer's `-h`. Defaults to
77    /// [`DEFAULT_DIGEST`] (`sha256`).
78    digest: String,
79}
80
81impl Default for AuthenticodeConfig {
82    fn default() -> Self {
83        Self {
84            artifacts: Vec::new(),
85            timestamp_url: DEFAULT_TSA.to_string(),
86            digest: DEFAULT_DIGEST.to_string(),
87        }
88    }
89}
90
91/// Which signer binary drives the sign — `osslsigncode` (default, cross
92/// platform) or Windows-native `signtool`. The argument shape differs; this
93/// enum normalizes it.
94#[derive(Debug, Clone, Copy, PartialEq, Eq)]
95enum Signer {
96    Osslsigncode,
97    Signtool,
98}
99
100/// `authenticode` — Windows code signing. See module docs.
101#[derive(Debug, Default)]
102pub struct AuthenticodeProvider {
103    /// Signer binary override; defaults to `osslsigncode` on `PATH`. When the
104    /// basename is `signtool` (or `signtool.exe`) the Windows argument shape is
105    /// used.
106    tool_bin: Option<String>,
107}
108
109impl AuthenticodeProvider {
110    /// Construct an adapter that drives an explicit signer binary (tests /
111    /// non-default toolchains / Windows `signtool`).
112    #[allow(dead_code)]
113    fn with_tool(tool: impl Into<String>) -> Self {
114        Self {
115            tool_bin: Some(tool.into()),
116        }
117    }
118
119    /// Path to the signer binary (defaults to `osslsigncode` on `PATH`).
120    fn tool(&self) -> &str {
121        self.tool_bin.as_deref().unwrap_or("osslsigncode")
122    }
123
124    /// Which signer flavor the configured binary is, inferred from its
125    /// basename. Anything that isn't `signtool` is treated as osslsigncode.
126    /// Splits on both `/` and `\` so a Windows `signtool.exe` path is detected
127    /// regardless of which host inspects it (`Path` is separator-sensitive to
128    /// the host OS; a `signtool` binary is only ever a Windows path).
129    fn signer(&self) -> Signer {
130        let tool = self.tool();
131        let base = tool
132            .rsplit(['/', '\\'])
133            .next()
134            .unwrap_or(tool)
135            .trim_end_matches(".exe")
136            .trim_end_matches(".EXE");
137        if base.eq_ignore_ascii_case("signtool") {
138            Signer::Signtool
139        } else {
140            Signer::Osslsigncode
141        }
142    }
143}
144
145#[async_trait]
146impl ReleaseProvider for AuthenticodeProvider {
147    fn name(&self) -> &str {
148        "authenticode"
149    }
150
151    fn required_slots(&self) -> Vec<&str> {
152        vec![SLOT_CERT, SLOT_CERT_PASSWORD]
153    }
154
155    async fn dispatch(&self, ctx: &ProviderContext<'_>) -> Result<ProviderReport, RunnerError> {
156        let cfg: AuthenticodeConfig = parse_config(ctx.config)?;
157
158        // Credentials are required even to build the action plan — a dry run is
159        // the plan-time credential-presence check. Decode the cert here too so
160        // a bad base64 blob fails the dry run, not only the live sign.
161        let cert_b64 = ctx.require_secret(SLOT_CERT)?;
162        let password = ctx.require_secret(SLOT_CERT_PASSWORD)?;
163        let cert_der = decode_cert(&cert_b64)?;
164
165        let selected = select_artifacts(ctx.artifacts, &cfg.artifacts)?;
166
167        let mut report = ProviderReport::default();
168
169        if ctx.dry_run {
170            for art in &selected {
171                report.actions.push(format!(
172                    "would sign {} ({}, ts {})",
173                    basename(&art.path),
174                    cfg.digest,
175                    cfg.timestamp_url,
176                ));
177            }
178            return Ok(report);
179        }
180
181        // Live path: materialize the .pfx once (0600, never logged) and reuse
182        // it for every signature.
183        let cert_path = write_cert(ctx.work_dir, &cert_der)?;
184
185        for art in selected {
186            sign_one(self.tool(), self.signer(), &art, &cert_path, &password, &cfg).await?;
187            report
188                .actions
189                .push(format!("signed {} ({})", basename(&art.path), cfg.digest));
190            // Signing mutates the binary in place — the path is unchanged, but
191            // threading the artifact through `produced` is how a downstream
192            // ship outcome (winsparkle) addresses the now-signed installer.
193            report.produced.push(art);
194        }
195
196        Ok(report)
197    }
198}
199
200/// Deserialize the opaque `with` blob into [`AuthenticodeConfig`]. A `null`
201/// config (no `with` table) is the default — sign every signable artifact with
202/// the default TSA + sha256.
203fn parse_config(value: &serde_json::Value) -> Result<AuthenticodeConfig, RunnerError> {
204    if value.is_null() {
205        return Ok(AuthenticodeConfig::default());
206    }
207    serde_json::from_value(value.clone())
208        .map_err(|e| RunnerError::Outcome(format!("authenticode: invalid `with` config: {e}")))
209}
210
211/// Decode the base64-stored PKCS#12 cert blob into raw `.pfx` bytes. Tolerates
212/// surrounding whitespace/newlines (a multi-line vault blob). Never logs the
213/// decoded bytes.
214fn decode_cert(b64: &str) -> Result<Vec<u8>, RunnerError> {
215    let cleaned: String = b64.split_whitespace().collect();
216    base64::engine::general_purpose::STANDARD
217        .decode(cleaned.as_bytes())
218        .map_err(|e| {
219            RunnerError::Outcome(format!(
220                "authenticode: `{SLOT_CERT}` is not valid base64 \
221                 (expected a base64-encoded PKCS#12/.pfx blob): {e}"
222            ))
223        })
224}
225
226/// Pick the artifacts to sign: the config globs filtered to signable
227/// extensions, or — when no globs are given — every signable artifact. Errors
228/// when the selection is empty (an authenticode outcome that signs nothing is a
229/// config bug, not a no-op).
230fn select_artifacts(
231    artifacts: &[ProducedArtifact],
232    globs: &[String],
233) -> Result<Vec<ProducedArtifact>, RunnerError> {
234    let selected: Vec<ProducedArtifact> = artifacts
235        .iter()
236        .filter(|a| is_signable(&a.path))
237        .filter(|a| globs.is_empty() || globs.iter().any(|g| artifact_matches(a, g)))
238        .cloned()
239        .collect();
240
241    if selected.is_empty() {
242        let detail = if globs.is_empty() {
243            format!(
244                "no signable artifact (.exe/.msi/.dll/.sys/.cab/.cat/.ps1) among {} produced",
245                artifacts.len()
246            )
247        } else {
248            format!(
249                "no signable artifact matched config globs {globs:?} (of {} produced)",
250                artifacts.len()
251            )
252        };
253        return Err(RunnerError::Outcome(format!("authenticode: {detail}")));
254    }
255    Ok(selected)
256}
257
258/// Whether `path` has a signable PE-family extension (case-insensitive).
259fn is_signable(path: &str) -> bool {
260    Path::new(path)
261        .extension()
262        .and_then(|e| e.to_str())
263        .map(|e| SIGNABLE_EXTS.iter().any(|n| e.eq_ignore_ascii_case(n)))
264        .unwrap_or(false)
265}
266
267/// A config glob matches an artifact when it globs the `binary` field or the
268/// file basename.
269fn artifact_matches(art: &ProducedArtifact, glob: &str) -> bool {
270    glob_match(glob, &art.binary) || glob_match(glob, basename(&art.path))
271}
272
273/// File basename of a path (the part after the last `/`).
274fn basename(path: &str) -> &str {
275    Path::new(path)
276        .file_name()
277        .and_then(|s| s.to_str())
278        .unwrap_or(path)
279}
280
281/// Minimal glob matcher — `*` matches any run (including empty); every other
282/// char is literal. Sufficient for `desktop`, `*.exe`, `yah-*` style filters.
283fn glob_match(pattern: &str, text: &str) -> bool {
284    // Classic two-pointer wildcard match with backtracking on `*`.
285    let (p, t): (Vec<char>, Vec<char>) = (pattern.chars().collect(), text.chars().collect());
286    let (mut pi, mut ti) = (0usize, 0usize);
287    let (mut star, mut star_t): (Option<usize>, usize) = (None, 0);
288    while ti < t.len() {
289        if pi < p.len() && (p[pi] == t[ti]) {
290            pi += 1;
291            ti += 1;
292        } else if pi < p.len() && p[pi] == '*' {
293            star = Some(pi);
294            star_t = ti;
295            pi += 1;
296        } else if let Some(s) = star {
297            pi = s + 1;
298            star_t += 1;
299            ti = star_t;
300        } else {
301            return false;
302        }
303    }
304    while pi < p.len() && p[pi] == '*' {
305        pi += 1;
306    }
307    pi == p.len()
308}
309
310/// Materialize the decoded `.pfx` cert to a `0600` file under the scratch dir.
311/// Never logs the contents.
312fn write_cert(work_dir: &Path, der: &[u8]) -> Result<PathBuf, RunnerError> {
313    let path = work_dir.join("authenticode-cert.pfx");
314    std::fs::write(&path, der)
315        .map_err(|e| RunnerError::Outcome(format!("authenticode: writing cert file: {e}")))?;
316    #[cfg(unix)]
317    {
318        use std::os::unix::fs::PermissionsExt;
319        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))
320            .map_err(|e| RunnerError::Outcome(format!("authenticode: chmod 600 cert file: {e}")))?;
321    }
322    Ok(path)
323}
324
325/// Sign one artifact in place. osslsigncode can't sign over itself, so it signs
326/// to a sibling `<name>.signed` and renames it back over the input; signtool
327/// signs in place natively.
328async fn sign_one(
329    tool: &str,
330    signer: Signer,
331    art: &ProducedArtifact,
332    cert_path: &Path,
333    password: &str,
334    cfg: &AuthenticodeConfig,
335) -> Result<(), RunnerError> {
336    match signer {
337        Signer::Osslsigncode => {
338            let signed = sibling_signed_path(&art.path);
339            let out = tokio::process::Command::new(tool)
340                .arg("sign")
341                .arg("-pkcs12")
342                .arg(cert_path)
343                .arg("-pass")
344                .arg(password)
345                .arg("-h")
346                .arg(&cfg.digest)
347                .arg("-t")
348                .arg(&cfg.timestamp_url)
349                .arg("-in")
350                .arg(&art.path)
351                .arg("-out")
352                .arg(&signed)
353                .output()
354                .await
355                .map_err(|e| {
356                    RunnerError::Outcome(format!("authenticode: spawning `{tool} sign`: {e}"))
357                })?;
358            if !out.status.success() {
359                return Err(RunnerError::Outcome(format!(
360                    "authenticode: osslsigncode failed on {} (status {}): {}",
361                    basename(&art.path),
362                    out.status,
363                    String::from_utf8_lossy(&out.stderr).trim(),
364                )));
365            }
366            std::fs::rename(&signed, &art.path).map_err(|e| {
367                RunnerError::Outcome(format!(
368                    "authenticode: replacing {} with signed output: {e}",
369                    basename(&art.path)
370                ))
371            })?;
372            Ok(())
373        }
374        Signer::Signtool => {
375            // signtool sign /fd sha256 /f cert.pfx /p PASS /tr TSA /td sha256 file
376            let out = tokio::process::Command::new(tool)
377                .arg("sign")
378                .arg("/fd")
379                .arg(&cfg.digest)
380                .arg("/f")
381                .arg(cert_path)
382                .arg("/p")
383                .arg(password)
384                .arg("/tr")
385                .arg(&cfg.timestamp_url)
386                .arg("/td")
387                .arg(&cfg.digest)
388                .arg(&art.path)
389                .output()
390                .await
391                .map_err(|e| {
392                    RunnerError::Outcome(format!("authenticode: spawning `{tool} sign`: {e}"))
393                })?;
394            if !out.status.success() {
395                return Err(RunnerError::Outcome(format!(
396                    "authenticode: signtool failed on {} (status {}): {}",
397                    basename(&art.path),
398                    out.status,
399                    String::from_utf8_lossy(&out.stderr).trim(),
400                )));
401            }
402            Ok(())
403        }
404    }
405}
406
407/// `<path>.signed` — the osslsigncode scratch output renamed back over the
408/// input.
409fn sibling_signed_path(path: &str) -> PathBuf {
410    PathBuf::from(format!("{path}.signed"))
411}
412
413#[cfg(test)]
414mod tests {
415    use super::*;
416    use crate::provider::MapSecrets;
417    use std::collections::BTreeMap;
418
419    fn art(binary: &str, path: &str) -> ProducedArtifact {
420        ProducedArtifact {
421            binary: binary.into(),
422            path: path.into(),
423            triple: Some("windows-x86_64".into()),
424        }
425    }
426
427    /// A valid base64 blob (contents are opaque to the adapter until handed to
428    /// the signer; the dry-run path only needs it to decode).
429    fn cert_b64() -> String {
430        base64::engine::general_purpose::STANDARD.encode(b"fake-pkcs12-der")
431    }
432
433    fn full_secrets() -> MapSecrets {
434        let mut m = BTreeMap::new();
435        m.insert(SLOT_CERT.into(), cert_b64());
436        m.insert(SLOT_CERT_PASSWORD.into(), "hunter2".into());
437        MapSecrets(m)
438    }
439
440    fn ctx<'a>(
441        secrets: &'a dyn crate::provider::SecretSource,
442        work: &'a Path,
443        cfg: &'a serde_json::Value,
444        artifacts: &'a [ProducedArtifact],
445        dry_run: bool,
446    ) -> ProviderContext<'a> {
447        ProviderContext {
448            version: "1.2.3",
449            artifacts,
450            base_url: None,
451            config: cfg,
452            work_dir: work,
453            secrets,
454            dry_run,
455        }
456    }
457
458    #[test]
459    fn declares_cert_slots() {
460        let p = AuthenticodeProvider::default();
461        assert_eq!(p.name(), "authenticode");
462        assert_eq!(p.required_slots(), vec![SLOT_CERT, SLOT_CERT_PASSWORD]);
463    }
464
465    #[test]
466    fn signer_inferred_from_tool_basename() {
467        assert_eq!(AuthenticodeProvider::default().signer(), Signer::Osslsigncode);
468        assert_eq!(
469            AuthenticodeProvider::with_tool("/usr/bin/osslsigncode").signer(),
470            Signer::Osslsigncode
471        );
472        assert_eq!(
473            AuthenticodeProvider::with_tool("signtool").signer(),
474            Signer::Signtool
475        );
476        assert_eq!(
477            AuthenticodeProvider::with_tool(r"C:\sdk\signtool.exe").signer(),
478            Signer::Signtool
479        );
480    }
481
482    #[tokio::test]
483    async fn dry_run_plans_per_artifact_without_spawning_signer() {
484        let work = tempfile::tempdir().unwrap();
485        let secrets = full_secrets();
486        let artifacts = vec![
487            art("desktop", "out/Desktop.exe"),
488            art("yah", "out/yah"),
489            art("installer", "out/Setup.msi"),
490        ];
491        let cfg = serde_json::Value::Null;
492        let report = AuthenticodeProvider::default()
493            .dispatch(&ctx(&secrets, work.path(), &cfg, &artifacts, true))
494            .await
495            .unwrap();
496        // The .exe and .msi are signable; the bare `yah` binary is skipped.
497        assert_eq!(report.actions.len(), 2);
498        assert!(report.actions[0].contains("would sign Desktop.exe"));
499        assert!(report.actions[0].contains("sha256"));
500        assert!(report.actions[0].contains(DEFAULT_TSA));
501        assert!(report.actions[1].contains("would sign Setup.msi"));
502        assert!(report.produced.is_empty(), "dry run mutates nothing");
503        assert!(report.published.is_empty());
504        // No cert written on the dry-run path.
505        assert!(!work.path().join("authenticode-cert.pfx").exists());
506    }
507
508    #[tokio::test]
509    async fn dry_run_missing_slot_is_typed_error_naming_slot() {
510        let work = tempfile::tempdir().unwrap();
511        let mut m = BTreeMap::new();
512        m.insert(SLOT_CERT.into(), cert_b64());
513        // AUTHENTICODE_CERT_PASSWORD missing.
514        let secrets = MapSecrets(m);
515        let artifacts = vec![art("desktop", "out/Desktop.exe")];
516        let cfg = serde_json::Value::Null;
517        let err = AuthenticodeProvider::default()
518            .dispatch(&ctx(&secrets, work.path(), &cfg, &artifacts, true))
519            .await
520            .unwrap_err();
521        assert!(
522            format!("{err}").contains(SLOT_CERT_PASSWORD),
523            "names the slot: {err}"
524        );
525    }
526
527    #[tokio::test]
528    async fn dry_run_bad_base64_cert_is_typed_error() {
529        let work = tempfile::tempdir().unwrap();
530        let mut m = BTreeMap::new();
531        m.insert(SLOT_CERT.into(), "not base64!!!@@@".into());
532        m.insert(SLOT_CERT_PASSWORD.into(), "hunter2".into());
533        let secrets = MapSecrets(m);
534        let artifacts = vec![art("desktop", "out/Desktop.exe")];
535        let cfg = serde_json::Value::Null;
536        let err = AuthenticodeProvider::default()
537            .dispatch(&ctx(&secrets, work.path(), &cfg, &artifacts, true))
538            .await
539            .unwrap_err();
540        assert!(format!("{err}").contains("not valid base64"), "{err}");
541    }
542
543    #[test]
544    fn whitespace_in_cert_blob_decodes() {
545        let raw = b"fake-pkcs12-der";
546        let b64 = base64::engine::general_purpose::STANDARD.encode(raw);
547        // Simulate a multi-line vault blob.
548        let multiline = format!("  {}\n  {}\n", &b64[..4], &b64[4..]);
549        assert_eq!(decode_cert(&multiline).unwrap(), raw);
550    }
551
552    #[tokio::test]
553    async fn no_signable_artifact_is_a_config_error() {
554        let work = tempfile::tempdir().unwrap();
555        let secrets = full_secrets();
556        let artifacts = vec![art("yah", "out/yah"), art("desktop", "out/Desktop.dmg")];
557        let cfg = serde_json::Value::Null;
558        let err = AuthenticodeProvider::default()
559            .dispatch(&ctx(&secrets, work.path(), &cfg, &artifacts, true))
560            .await
561            .unwrap_err();
562        assert!(format!("{err}").contains("no signable artifact"));
563    }
564
565    #[tokio::test]
566    async fn config_glob_filters_to_named_binary() {
567        let work = tempfile::tempdir().unwrap();
568        let secrets = full_secrets();
569        let artifacts = vec![
570            art("desktop", "out/Desktop.exe"),
571            art("helper", "out/Helper.exe"),
572        ];
573        let cfg = serde_json::json!({ "artifacts": ["desktop"] });
574        let report = AuthenticodeProvider::default()
575            .dispatch(&ctx(&secrets, work.path(), &cfg, &artifacts, true))
576            .await
577            .unwrap();
578        assert_eq!(report.actions.len(), 1);
579        assert!(report.actions[0].contains("Desktop.exe"));
580    }
581
582    #[tokio::test]
583    async fn config_overrides_digest_and_tsa() {
584        let work = tempfile::tempdir().unwrap();
585        let secrets = full_secrets();
586        let artifacts = vec![art("desktop", "out/Desktop.exe")];
587        let cfg = serde_json::json!({
588            "digest": "sha384",
589            "timestamp_url": "http://tsa.example/rfc3161",
590        });
591        let report = AuthenticodeProvider::default()
592            .dispatch(&ctx(&secrets, work.path(), &cfg, &artifacts, true))
593            .await
594            .unwrap();
595        assert!(report.actions[0].contains("sha384"));
596        assert!(report.actions[0].contains("http://tsa.example/rfc3161"));
597    }
598
599    #[tokio::test]
600    async fn config_glob_with_no_match_errors() {
601        let work = tempfile::tempdir().unwrap();
602        let secrets = full_secrets();
603        let artifacts = vec![art("desktop", "out/Desktop.exe")];
604        let cfg = serde_json::json!({ "artifacts": ["nonexistent-*"] });
605        let err = AuthenticodeProvider::default()
606            .dispatch(&ctx(&secrets, work.path(), &cfg, &artifacts, true))
607            .await
608            .unwrap_err();
609        assert!(format!("{err}").contains("no signable artifact matched"));
610    }
611
612    #[test]
613    fn signable_extension_is_case_insensitive() {
614        assert!(is_signable("App.EXE"));
615        assert!(is_signable("Setup.msi"));
616        assert!(is_signable("driver.SYS"));
617        assert!(!is_signable("yah"));
618        assert!(!is_signable("Desktop.dmg"));
619    }
620
621    #[test]
622    fn glob_match_supports_star() {
623        assert!(glob_match("*.exe", "Desktop.exe"));
624        assert!(glob_match("desktop", "desktop"));
625        assert!(glob_match("yah-*", "yah-helper"));
626        assert!(glob_match("*", "anything"));
627        assert!(!glob_match("*.exe", "Desktop.msi"));
628        assert!(!glob_match("desktop", "desktop-helper"));
629    }
630
631    #[test]
632    fn sibling_signed_path_appends_suffix() {
633        assert_eq!(
634            sibling_signed_path("out/Desktop.exe"),
635            PathBuf::from("out/Desktop.exe.signed")
636        );
637    }
638}