use std::path::Path;
use semver::Version;
use super::config::{BundleVersion, CodeSigning};
use crate::error::Result;
#[cfg_attr(
not(target_os = "macos"),
expect(dead_code, reason = "only macOS bundles carry this metadata")
)]
pub(super) struct Expectations<'a> {
pub(super) version: &'a Version,
pub(super) installed: &'a Path,
pub(super) bundle_version: BundleVersion,
pub(super) code_signing: &'a CodeSigning,
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
#[expect(clippy::unnecessary_wraps, reason = "same signature as on macOS")]
pub(super) fn check(_artifact: &Path, _expect: &Expectations<'_>) -> Result<()> {
Ok(())
}
#[cfg(target_os = "macos")]
pub(super) fn check(artifact: &Path, expect: &Expectations<'_>) -> Result<()> {
let team = match expect.code_signing {
CodeSigning::Off | CodeSigning::Publisher(_) => None,
CodeSigning::TeamId(team) => Some(team.clone()),
CodeSigning::MatchInstalled => macos::team_identifier(expect.installed),
};
if expect.bundle_version == BundleVersion::Ignore && team.is_none() {
return Ok(());
}
crate::install::macos::with_app(artifact, |app| {
if expect.bundle_version == BundleVersion::MustMatch {
macos::check_bundle_version(app, expect.version)?;
}
if let Some(team) = &team {
macos::check_code_signature(app, team)?;
}
Ok(())
})
}
#[cfg(target_os = "windows")]
pub(super) fn check(artifact: &Path, expect: &Expectations<'_>) -> Result<()> {
let publisher = match expect.code_signing {
CodeSigning::Publisher(name) => Some(name.clone()),
CodeSigning::MatchInstalled if !expect.installed.exists() => None,
CodeSigning::MatchInstalled => authenticode::query(expect.installed)?.valid_publisher(),
_ => None,
};
let Some(publisher) = publisher else {
return Ok(());
};
let signed = match expect.installed.file_name() {
Some(name) if artifact.is_dir() => artifact.join(name),
_ => artifact.to_path_buf(),
};
authenticode::query(&signed)?.require(&publisher)
}
#[cfg(any(target_os = "macos", test))]
fn lenient_version(text: &str) -> Option<Version> {
let text = text.trim();
let text = text.strip_prefix('v').unwrap_or(text);
let split = text.find(['-', '+']).unwrap_or(text.len());
let (core, rest) = text.split_at(split);
let mut parts: Vec<&str> = core.split('.').collect();
if parts.is_empty() || parts.len() > 3 {
return None;
}
parts.resize(3, "0");
Version::parse(&format!("{}{rest}", parts.join("."))).ok()
}
#[cfg(target_os = "macos")]
mod macos {
use std::path::Path;
use std::process::Command;
use semver::Version;
use super::lenient_version;
use crate::error::{Error, Result};
pub(super) fn check_bundle_version(app: &Path, expected: &Version) -> Result<()> {
let plist = app.join("Contents/Info.plist");
let out = Command::new("/usr/libexec/PlistBuddy")
.args(["-c", "Print :CFBundleShortVersionString"])
.arg(&plist)
.output()?;
if !out.status.success() {
return Err(Error::Payload(format!(
"cannot read CFBundleShortVersionString from {}",
plist.display()
)));
}
let declared = String::from_utf8_lossy(&out.stdout).trim().to_string();
match lenient_version(&declared) {
Some(v) if v.cmp_precedence(expected).is_eq() => Ok(()),
_ => Err(Error::Payload(format!(
"the app declares version {declared}, but the release is {expected}"
))),
}
}
pub(super) fn check_code_signature(app: &Path, team: &str) -> Result<()> {
let out = Command::new("codesign")
.args(["--verify", "--deep", "--strict"])
.arg(app)
.output()?;
if !out.status.success() {
return Err(Error::Payload(format!(
"code signature is invalid: {}",
String::from_utf8_lossy(&out.stderr).trim()
)));
}
match team_identifier(app) {
Some(found) if found == team => Ok(()),
found => Err(Error::Payload(format!(
"the app is signed by team {}, expected {team}",
found.as_deref().unwrap_or("(none)")
))),
}
}
pub(super) fn team_identifier(app: &Path) -> Option<String> {
let out = Command::new("codesign")
.args(["-d", "--verbose=2"])
.arg(app)
.output()
.ok()?;
if !out.status.success() {
return None;
}
String::from_utf8_lossy(&out.stderr)
.lines()
.find_map(|line| line.strip_prefix("TeamIdentifier="))
.map(str::trim)
.filter(|team| !team.is_empty() && *team != "not set")
.map(str::to_string)
}
}
#[cfg(any(target_os = "windows", test))]
mod authenticode {
use crate::error::{Error, Result};
#[derive(Debug, PartialEq, Eq)]
pub(super) struct Signature {
pub(super) status: String,
pub(super) subject: Option<String>,
}
impl Signature {
pub(super) fn parse(stdout: &str) -> Option<Self> {
let mut lines = stdout.trim_start_matches('\u{feff}').lines().map(str::trim);
let status = lines.next().filter(|s| !s.is_empty())?.to_string();
let subject = lines.next().filter(|s| !s.is_empty()).map(str::to_string);
Some(Self { status, subject })
}
pub(super) fn valid_publisher(&self) -> Option<String> {
if self.status != "Valid" {
return None;
}
common_name(self.subject.as_deref()?)
}
pub(super) fn require(&self, publisher: &str) -> Result<()> {
if self.status != "Valid" {
return Err(Error::Payload(format!(
"the Authenticode signature is not valid ({})",
self.status
)));
}
match self.valid_publisher() {
Some(found) if found == publisher => Ok(()),
found => Err(Error::Payload(format!(
"the update is signed by {}, expected {publisher}",
found.as_deref().unwrap_or("(no common name)")
))),
}
}
}
pub(super) fn common_name(subject: &str) -> Option<String> {
attributes(subject)
.into_iter()
.find_map(|(key, value)| key.eq_ignore_ascii_case("CN").then_some(value))
}
fn attributes(subject: &str) -> Vec<(String, String)> {
let mut out = Vec::new();
let mut chars = subject.chars().peekable();
while chars.peek().is_some() {
let key: String = chars.by_ref().take_while(|&c| c != '=').collect();
while chars.next_if_eq(&' ').is_some() {}
let mut value = String::new();
if chars.next_if_eq(&'"').is_some() {
while let Some(c) = chars.next() {
if c != '"' {
value.push(c);
} else if chars.next_if_eq(&'"').is_some() {
value.push('"');
} else {
break;
}
}
chars.by_ref().take_while(|&c| c != ',').for_each(drop);
} else {
value = chars.by_ref().take_while(|&c| c != ',').collect();
value.truncate(value.trim_end().len());
}
out.push((key.trim().to_string(), value));
}
out
}
#[cfg(target_os = "windows")]
pub(super) fn query(path: &std::path::Path) -> Result<Signature> {
const SCRIPT: &str = "$ErrorActionPreference = 'Stop'; \
try { [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false) } catch { }; \
$s = Get-AuthenticodeSignature -LiteralPath $env:GPUI_UPDATER_SIGNED_FILE; \
Write-Output ([string]$s.Status); \
if ($s.SignerCertificate) { Write-Output $s.SignerCertificate.Subject }";
let root = std::env::var_os("SystemRoot").map_or_else(
|| std::path::PathBuf::from(r"C:\Windows"),
std::path::PathBuf::from,
);
let out = std::process::Command::new(
root.join(r"System32\WindowsPowerShell\v1.0\powershell.exe"),
)
.args([
"-NoLogo",
"-NoProfile",
"-NonInteractive",
"-Command",
SCRIPT,
])
.env("GPUI_UPDATER_SIGNED_FILE", path)
.env_remove("PSModulePath")
.output()?;
let stdout = String::from_utf8_lossy(&out.stdout);
match Signature::parse(&stdout) {
Some(signature) if out.status.success() => Ok(signature),
_ => Err(Error::Payload(format!(
"could not read the Authenticode signature of {}: {}",
path.display(),
String::from_utf8_lossy(&out.stderr).trim()
))),
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, reason = "tests")]
mod tests {
use super::*;
#[test]
fn parses_the_query_output() {
let out = "\u{feff}Valid\r\nCN=Microsoft Windows, O=Microsoft Corporation, C=US\r\n";
let signature = Signature::parse(out).unwrap();
assert_eq!(signature.status, "Valid");
assert_eq!(
signature.valid_publisher().as_deref(),
Some("Microsoft Windows")
);
let unsigned = Signature::parse("NotSigned\r\n").unwrap();
assert_eq!(unsigned.subject, None);
assert_eq!(unsigned.valid_publisher(), None);
assert_eq!(Signature::parse(""), None);
assert_eq!(Signature::parse("\r\n"), None);
}
#[test]
fn finds_the_common_name_in_a_subject() {
let cn = |s: &str| common_name(s);
assert_eq!(
cn("CN=Example Corp, O=Example Corp, C=US").as_deref(),
Some("Example Corp")
);
assert_eq!(
cn("O=Example, CN=Example App ,C=US").as_deref(),
Some("Example App")
);
assert_eq!(
cn(r#"CN="Example, Inc.", O=Example"#).as_deref(),
Some("Example, Inc.")
);
assert_eq!(
cn(r#"CN="Say ""hi"" Ltd""#).as_deref(),
Some(r#"Say "hi" Ltd"#)
);
assert_eq!(cn("cn=lower").as_deref(), Some("lower"));
assert_eq!(cn("O=No Common Name, C=US"), None);
assert_eq!(cn(""), None);
assert_eq!(cn(r#"O="CN=Fake", CN=Real"#).as_deref(), Some("Real"));
}
#[test]
fn requires_a_valid_signature_by_the_publisher() {
let signed = |status: &str| Signature {
status: status.to_string(),
subject: Some("CN=Example Corp, O=Example Corp".to_string()),
};
signed("Valid").require("Example Corp").unwrap();
for (signature, publisher) in [
(signed("Valid"), "Other Corp"),
(signed("HashMismatch"), "Example Corp"),
(signed("NotTrusted"), "Example Corp"),
(
Signature {
status: "NotSigned".into(),
subject: None,
},
"Example Corp",
),
] {
assert!(
matches!(signature.require(publisher), Err(Error::Payload(_))),
"{signature:?} for {publisher}"
);
}
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, reason = "tests")]
mod tests {
use super::lenient_version;
use semver::Version;
#[test]
fn parses_bundle_versions_leniently() {
let v = |s: &str| lenient_version(s).map(|v| v.to_string());
assert_eq!(v("1.2.3").as_deref(), Some("1.2.3"));
assert_eq!(v(" v1.2 ").as_deref(), Some("1.2.0"));
assert_eq!(v("7").as_deref(), Some("7.0.0"));
assert_eq!(v("1.2-rc.1").as_deref(), Some("1.2.0-rc.1"));
assert_eq!(v("1.2.3.4"), None);
assert_eq!(v("one"), None);
assert!(
lenient_version("0.8.13+42")
.unwrap()
.cmp_precedence(&Version::new(0, 8, 13))
.is_eq()
);
}
}
#[cfg(all(test, target_os = "macos"))]
mod macos_tests {
use std::path::Path;
use semver::Version;
use super::{BundleVersion, CodeSigning, Expectations, check};
fn checked(artifact: &Path, signing: &CodeSigning) -> crate::Result<()> {
let version = Version::new(1, 0, 0);
check(
artifact,
&Expectations {
version: &version,
installed: artifact,
bundle_version: BundleVersion::Ignore,
code_signing: signing,
},
)
}
#[test]
fn a_windows_publisher_is_ignored() {
let missing = Path::new("/nonexistent/gpui-updater/update.dmg");
let publisher = CodeSigning::Publisher("Example Corp".into());
assert!(checked(missing, &publisher).is_ok());
assert!(checked(missing, &CodeSigning::Off).is_ok());
let team = CodeSigning::TeamId("ABCDE12345".into());
assert!(checked(missing, &team).is_err());
}
}
#[cfg(all(test, target_os = "windows"))]
#[allow(clippy::unwrap_used, reason = "tests")]
mod windows_tests {
use std::path::PathBuf;
use semver::Version;
use super::authenticode;
use super::{BundleVersion, CodeSigning, Expectations, check};
use crate::error::Error;
fn signed_system_file() -> PathBuf {
let root = std::env::var_os("SystemRoot")
.map_or_else(|| PathBuf::from(r"C:\Windows"), PathBuf::from);
root.join("explorer.exe")
}
fn unsigned_file(dir: &std::path::Path) -> PathBuf {
let path = dir.join("unsigned.exe");
std::fs::write(&path, b"MZ not really a program").unwrap();
path
}
fn checked(
artifact: &std::path::Path,
installed: &std::path::Path,
signing: &CodeSigning,
) -> crate::Result<()> {
let version = Version::new(1, 0, 0);
check(
artifact,
&Expectations {
version: &version,
installed,
bundle_version: BundleVersion::MustMatch,
code_signing: signing,
},
)
}
#[test]
fn reads_the_signature_of_a_system_file() {
let signature = authenticode::query(&signed_system_file()).unwrap();
assert_eq!(signature.status, "Valid", "{signature:?}");
assert!(signature.valid_publisher().is_some(), "{signature:?}");
let dir = tempfile::tempdir().unwrap();
let unsigned = authenticode::query(&unsigned_file(dir.path())).unwrap();
assert_ne!(unsigned.status, "Valid");
assert_eq!(unsigned.valid_publisher(), None);
}
#[test]
fn match_installed_requires_the_installed_publisher() {
let dir = tempfile::tempdir().unwrap();
let signed = signed_system_file();
let unsigned = unsigned_file(dir.path());
let matching = CodeSigning::MatchInstalled;
checked(&signed, &signed, &matching).unwrap();
assert!(matches!(
checked(&unsigned, &signed, &matching),
Err(Error::Payload(_))
));
checked(&unsigned, &unsigned, &matching).unwrap();
checked(&unsigned, &signed, &CodeSigning::Off).unwrap();
checked(
&unsigned,
&signed,
&CodeSigning::TeamId("ABCDE12345".into()),
)
.unwrap();
}
#[test]
fn publisher_names_the_required_signer() {
let dir = tempfile::tempdir().unwrap();
let signed = signed_system_file();
let unsigned = unsigned_file(dir.path());
let publisher = authenticode::query(&signed)
.unwrap()
.valid_publisher()
.unwrap();
checked(
&signed,
&unsigned,
&CodeSigning::Publisher(publisher.clone()),
)
.unwrap();
let other = CodeSigning::Publisher("Example Corp".into());
assert!(matches!(
checked(&signed, &unsigned, &other),
Err(Error::Payload(_))
));
let wanted = CodeSigning::Publisher(publisher);
assert!(matches!(
checked(&unsigned, &unsigned, &wanted),
Err(Error::Payload(_))
));
}
#[test]
fn a_missing_file_is_an_error_not_a_pass() {
let dir = tempfile::tempdir().unwrap();
let missing = dir.path().join("missing.exe");
assert!(authenticode::query(&missing).is_err());
let wanted = CodeSigning::Publisher("Example Corp".into());
assert!(checked(&missing, &missing, &wanted).is_err());
let matching = CodeSigning::MatchInstalled;
assert!(checked(&missing, &signed_system_file(), &matching).is_err());
let unsigned = unsigned_file(dir.path());
checked(&unsigned, &missing, &matching).unwrap();
}
#[test]
fn checks_the_executable_inside_a_rebuilt_directory() {
let dir = tempfile::tempdir().unwrap();
let signed = signed_system_file();
let matching = CodeSigning::MatchInstalled;
let rebuilt = dir.path().join("delta-1.1.0");
std::fs::create_dir(&rebuilt).unwrap();
let exe = rebuilt.join(signed.file_name().unwrap());
assert!(checked(&rebuilt, &signed, &matching).is_err());
std::fs::write(&exe, b"MZ not really a program").unwrap();
assert!(matches!(
checked(&rebuilt, &signed, &matching),
Err(Error::Payload(_))
));
std::fs::copy(&signed, &exe).unwrap();
checked(&rebuilt, &signed, &matching).unwrap();
}
}