use std::path::{Path, PathBuf};
use semver::Version;
use crate::error::{Error, Result};
use crate::http::{self, HttpOptions, Metadata};
use crate::install::InstallOptions;
use crate::release::{DeltaAsset, Release};
use crate::verify;
pub const DEFAULT_MAX_DELTA_HOPS: usize = 4;
pub const DEFAULT_MAX_DOWNLOAD_SIZE: u64 = 4 * 1024 * 1024 * 1024;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[non_exhaustive]
pub enum Verification {
#[default]
BestEffort,
Off,
Checksum,
Signature,
Strict,
}
impl Verification {
pub(super) fn skips_all(self) -> bool {
matches!(self, Self::Off)
}
fn requires_signature(self) -> bool {
matches!(self, Self::Signature | Self::Strict)
}
fn requires_checksum(self) -> bool {
matches!(self, Self::Checksum | Self::Strict)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[non_exhaustive]
pub enum VersionBinding {
#[default]
IfPresent,
Required,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
#[non_exhaustive]
pub enum CodeSigning {
#[default]
MatchInstalled,
TeamId(String),
Publisher(String),
Off,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[non_exhaustive]
pub enum BundleVersion {
#[default]
MustMatch,
Ignore,
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct EngineConfig {
pub current_version: Version,
pub minisign_public_keys: Vec<String>,
pub ed25519_public_key: Option<String>,
pub verification: Verification,
pub version_binding: VersionBinding,
pub code_signing: CodeSigning,
pub bundle_version: BundleVersion,
pub install_root: Option<PathBuf>,
pub download_dir: Option<PathBuf>,
pub max_download_size: u64,
pub deltas: bool,
pub max_delta_hops: usize,
pub http: HttpOptions,
pub install_options: InstallOptions,
}
impl EngineConfig {
pub fn new(current_version: Version) -> Self {
Self {
current_version,
minisign_public_keys: Vec::new(),
ed25519_public_key: None,
verification: Verification::default(),
version_binding: VersionBinding::default(),
code_signing: CodeSigning::default(),
bundle_version: BundleVersion::default(),
install_root: None,
download_dir: None,
max_download_size: DEFAULT_MAX_DOWNLOAD_SIZE,
deltas: true,
max_delta_hops: DEFAULT_MAX_DELTA_HOPS,
http: HttpOptions::new(),
install_options: InstallOptions::new(),
}
}
#[must_use]
pub fn minisign_public_key(mut self, key: impl Into<String>) -> Self {
self.minisign_public_keys.push(key.into());
self
}
#[must_use]
pub fn ed25519_public_key(mut self, key: impl Into<String>) -> Self {
self.ed25519_public_key = Some(key.into());
self
}
#[must_use]
pub fn verification(mut self, verification: Verification) -> Self {
self.verification = verification;
self
}
#[must_use]
pub fn version_binding(mut self, binding: VersionBinding) -> Self {
self.version_binding = binding;
self
}
#[must_use]
pub fn code_signing(mut self, code_signing: CodeSigning) -> Self {
self.code_signing = code_signing;
self
}
#[must_use]
pub fn install_options(mut self, options: InstallOptions) -> Self {
self.install_options = options;
self
}
#[must_use]
pub fn bundle_version(mut self, bundle_version: BundleVersion) -> Self {
self.bundle_version = bundle_version;
self
}
#[must_use]
pub fn install_root(mut self, path: impl Into<PathBuf>) -> Self {
self.install_root = Some(path.into());
self
}
#[must_use]
pub fn download_dir(mut self, path: impl Into<PathBuf>) -> Self {
self.download_dir = Some(path.into());
self
}
#[must_use]
pub fn max_download_size(mut self, bytes: u64) -> Self {
self.max_download_size = bytes;
self
}
#[must_use]
pub fn deltas(mut self, enabled: bool) -> Self {
self.deltas = enabled;
self
}
#[must_use]
pub fn max_delta_hops(mut self, hops: usize) -> Self {
self.max_delta_hops = hops;
self
}
#[must_use]
pub fn http(mut self, http: HttpOptions) -> Self {
self.http = http;
self
}
pub(super) fn precheck(&self, release: &Release) -> Result<()> {
self.precheck_integrity(Integrity::of_release(release))
}
pub(super) fn precheck_integrity(&self, file: Integrity<'_>) -> Result<()> {
let what = file.what;
if self.verification.requires_signature() && !self.can_verify_signature_of(file) {
if self.minisign_public_keys.is_empty() && self.ed25519_public_key.is_none() {
return Err(Error::VerificationRequired(
"policy requires a signature, but no minisign or Ed25519 public key is configured"
.into(),
));
}
return Err(Error::VerificationRequired(format!(
"policy requires a signature, but the {what} publishes none that a configured key can verify"
)));
}
if self.verification.requires_checksum() && !file.has_checksum() {
return Err(Error::VerificationRequired(format!(
"policy requires a SHA-256 checksum, but the {what} publishes none"
)));
}
Ok(())
}
fn can_verify_signature_of(&self, file: Integrity<'_>) -> bool {
let minisign = !self.minisign_public_keys.is_empty() && file.has_minisign();
let ed25519 =
self.ed25519_public_key.is_some() && non_empty(file.ed25519_signature).is_some();
minisign || ed25519
}
pub(super) fn verify_file(
&self,
path: &Path,
file: Integrity<'_>,
version: &Version,
) -> Result<bool> {
let policy = self.verification;
if policy.skips_all() {
return Ok(false);
}
self.precheck_integrity(file)?;
let mut verified_checksum = false;
match resolve_checksum(file, &self.http)? {
Some(expected) => {
verify::verify_sha256(path, &expected)?;
verified_checksum = true;
}
None if policy.requires_checksum() => {
return Err(Error::VerificationRequired(format!(
"policy requires a SHA-256 checksum, but the published checksums list no `{}`",
file.name
)));
}
None => {}
}
let mut verified_signature = false;
if let (Some(key), Some(signature)) =
(&self.ed25519_public_key, non_empty(file.ed25519_signature))
{
verify::verify_ed25519(path, key, signature)?;
verified_signature = true;
}
if !self.minisign_public_keys.is_empty() {
if let Some(signature) = resolve_minisign(file, &self.http)? {
let keys: Vec<&str> = self
.minisign_public_keys
.iter()
.map(String::as_str)
.collect();
let comment = verify::verify_minisign_file(path, &keys, &signature)?;
self.check_signed_version(&comment, version)?;
verified_signature = true;
}
}
if policy.requires_signature() && !verified_signature {
return Err(Error::VerificationRequired(
"policy requires a signature, but the published signature is empty".into(),
));
}
Ok(verified_checksum || verified_signature)
}
fn check_signed_version(&self, trusted_comment: &str, expected: &Version) -> Result<()> {
match verify::signed_version(trusted_comment) {
Some(signed) => {
let matches = crate::release::parse_tag(signed)
.is_ok_and(|v| v.cmp_precedence(expected).is_eq());
if matches {
Ok(())
} else {
Err(Error::Signature(format!(
"file is signed for version {signed}, expected {expected}"
)))
}
}
None if self.version_binding == VersionBinding::Required => Err(Error::Signature(
"the signature's trusted comment names no `version:`".into(),
)),
None => Ok(()),
}
}
}
#[derive(Clone, Copy)]
pub(super) struct Integrity<'a> {
what: &'static str,
name: &'a str,
sha256: Option<&'a str>,
checksums_url: Option<&'a str>,
signature: Option<&'a str>,
signature_url: Option<&'a str>,
ed25519_signature: Option<&'a str>,
}
impl<'a> Integrity<'a> {
pub(super) fn of_release(release: &'a Release) -> Self {
Self {
what: "release",
name: &release.asset.name,
sha256: release.sha256.as_deref(),
checksums_url: None,
signature: release.signature.as_deref(),
signature_url: release.signature_url.as_deref(),
ed25519_signature: release.ed25519_signature.as_deref(),
}
}
#[cfg_attr(
all(not(feature = "delta"), not(test)),
expect(dead_code, reason = "only deltas are verified this way")
)]
pub(super) fn of_delta(delta: &'a DeltaAsset) -> Self {
Self {
what: "delta",
name: &delta.asset.name,
sha256: delta.sha256.as_deref(),
checksums_url: delta.checksums_url.as_deref(),
signature: delta.signature.as_deref(),
signature_url: delta.signature_url.as_deref(),
ed25519_signature: delta.ed25519_signature.as_deref(),
}
}
fn has_minisign(self) -> bool {
non_empty(self.signature).is_some() || non_empty(self.signature_url).is_some()
}
fn has_checksum(self) -> bool {
non_empty(self.sha256).is_some() || non_empty(self.checksums_url).is_some()
}
}
fn non_empty(s: Option<&str>) -> Option<&str> {
s.map(str::trim).filter(|s| !s.is_empty())
}
fn resolve_checksum(file: Integrity<'_>, http: &HttpOptions) -> Result<Option<String>> {
if let Some(sha256) = non_empty(file.sha256) {
return Ok(Some(sha256.to_string()));
}
match non_empty(file.checksums_url) {
Some(url) => Ok(verify::sha256_from_sums(
&http::get_string(url, &[], Metadata::Sidecar, http)?,
file.name,
)),
None => Ok(None),
}
}
fn resolve_minisign(file: Integrity<'_>, http: &HttpOptions) -> Result<Option<String>> {
if let Some(signature) = non_empty(file.signature) {
return Ok(Some(signature.to_string()));
}
if let Some(url) = non_empty(file.signature_url) {
let body = http::get_string(url, &[], Metadata::Sidecar, http)?;
return Ok(non_empty(Some(&body)).map(str::to_string));
}
Ok(None)
}