use std::cmp::Ordering;
use std::fmt;
use std::path::Path;
use serde::{Deserialize, Serialize};
pub const INSTALL_CHECK_SCHEMA_V1: &str = "ee.install.check.v1";
pub const INSTALL_FRESHNESS_SCHEMA_V1: &str = "ee.install.freshness.v1";
pub const INSTALL_PLAN_SCHEMA_V1: &str = "ee.install.plan.v1";
pub const UPDATE_PLAN_SCHEMA_V1: &str = "ee.update.plan.v1";
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum InstallFindingCode {
ArtifactChecksumMismatch,
ArtifactMissing,
BinaryNotOnPath,
ChecksumVerificationPending,
CurrentBinaryShadowed,
DuplicatePathBinary,
ExistingUnknownFile,
InstalledBinaryStale,
InstalledVersionUnknown,
PathBinaryVersionMismatch,
RequiredSurfaceMissing,
InstallDirMissing,
InstallDirNotWritable,
DuplicateTarget,
ManifestInvalid,
ManifestMissing,
NoArtifacts,
NoUpdateSourceConfigured,
OfflineNoManifest,
SignatureMissing,
SourceVersionUnknown,
TargetMismatch,
UnsupportedTarget,
UnsafeArtifact,
UnsafeTargetPath,
UpdateApplyUnsupported,
WouldDowngrade,
}
impl InstallFindingCode {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::ArtifactChecksumMismatch => "artifact_checksum_mismatch",
Self::ArtifactMissing => "artifact_missing",
Self::BinaryNotOnPath => "binary_not_on_path",
Self::ChecksumVerificationPending => "checksum_verification_pending",
Self::CurrentBinaryShadowed => "current_binary_shadowed",
Self::DuplicatePathBinary => "duplicate_path_binary",
Self::ExistingUnknownFile => "existing_unknown_file",
Self::InstalledBinaryStale => "installed_binary_stale",
Self::InstalledVersionUnknown => "installed_version_unknown",
Self::PathBinaryVersionMismatch => "path_binary_version_mismatch",
Self::RequiredSurfaceMissing => "required_surface_missing",
Self::InstallDirMissing => "install_dir_missing",
Self::InstallDirNotWritable => "install_dir_not_writable",
Self::DuplicateTarget => "duplicate_target",
Self::ManifestInvalid => "manifest_invalid",
Self::ManifestMissing => "manifest_missing",
Self::NoArtifacts => "no_artifacts",
Self::NoUpdateSourceConfigured => "no_update_source_configured",
Self::OfflineNoManifest => "offline_no_manifest",
Self::SignatureMissing => "signature_missing",
Self::SourceVersionUnknown => "source_version_unknown",
Self::TargetMismatch => "target_mismatch",
Self::UnsupportedTarget => "unsupported_target",
Self::UnsafeArtifact => "unsafe_artifact",
Self::UnsafeTargetPath => "unsafe_target_path",
Self::UpdateApplyUnsupported => "update_apply_unsupported",
Self::WouldDowngrade => "would_downgrade",
}
}
}
impl fmt::Display for InstallFindingCode {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum InstallFindingSeverity {
Info,
Warning,
Error,
}
impl InstallFindingSeverity {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Info => "info",
Self::Warning => "warning",
Self::Error => "error",
}
}
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct InstallFinding {
pub code: InstallFindingCode,
pub severity: InstallFindingSeverity,
pub message: String,
pub next_action: String,
}
impl InstallFinding {
#[must_use]
pub fn info(
code: InstallFindingCode,
message: impl Into<String>,
next_action: impl Into<String>,
) -> Self {
Self {
code,
severity: InstallFindingSeverity::Info,
message: message.into(),
next_action: next_action.into(),
}
}
#[must_use]
pub fn warning(
code: InstallFindingCode,
message: impl Into<String>,
next_action: impl Into<String>,
) -> Self {
Self {
code,
severity: InstallFindingSeverity::Warning,
message: message.into(),
next_action: next_action.into(),
}
}
#[must_use]
pub fn error(
code: InstallFindingCode,
message: impl Into<String>,
next_action: impl Into<String>,
) -> Self {
Self {
code,
severity: InstallFindingSeverity::Error,
message: message.into(),
next_action: next_action.into(),
}
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum InstallPathStatus {
Ok,
Missing,
Duplicate,
Shadowed,
}
impl InstallPathStatus {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Ok => "ok",
Self::Missing => "missing",
Self::Duplicate => "duplicate",
Self::Shadowed => "shadowed",
}
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum InstallPermissionStatus {
Writable,
MissingParentWritable,
MissingParentUnknown,
NotWritable,
}
impl InstallPermissionStatus {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Writable => "writable",
Self::MissingParentWritable => "missing_parent_writable",
Self::MissingParentUnknown => "missing_parent_unknown",
Self::NotWritable => "not_writable",
}
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum InstallPlanStatus {
Ready,
Blocked,
Degraded,
Idempotent,
}
impl InstallPlanStatus {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Ready => "ready",
Self::Blocked => "blocked",
Self::Degraded => "degraded",
Self::Idempotent => "idempotent",
}
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum InstallOperation {
Install,
Update,
}
impl InstallOperation {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Install => "install",
Self::Update => "update",
}
}
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PathBinary {
pub path: String,
pub ordinal: usize,
pub is_current_binary: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub version_status: Option<String>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct InstallPathAnalysis {
pub status: InstallPathStatus,
pub path_entries: Vec<String>,
pub binaries: Vec<PathBinary>,
pub first_binary: Option<String>,
pub current_binary_on_path: bool,
pub duplicate_count: usize,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct InstallPermissionCheck {
pub status: InstallPermissionStatus,
pub install_dir: String,
pub target_path: String,
pub exists: bool,
pub writable: bool,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct InstallTarget {
pub target_triple: String,
pub supported: bool,
pub binary_name: String,
pub executable_name: String,
pub install_dir: String,
pub install_path: String,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CurrentBinary {
pub path: Option<String>,
pub version: String,
pub source: String,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateSourcePosture {
pub configured: bool,
pub offline: bool,
pub source: Option<String>,
pub status: String,
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum InstallFreshnessVerdict {
Fresh,
Stale,
UnknownSourceVersion,
UnknownInstalledVersion,
MissingRequiredSurface,
PathBinaryMissing,
ShadowedBinary,
}
impl InstallFreshnessVerdict {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Fresh => "fresh",
Self::Stale => "stale",
Self::UnknownSourceVersion => "unknown_source_version",
Self::UnknownInstalledVersion => "unknown_installed_version",
Self::MissingRequiredSurface => "missing_required_surface",
Self::PathBinaryMissing => "path_binary_missing",
Self::ShadowedBinary => "shadowed_binary",
}
}
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct InstallVersionEvidence {
pub version: Option<String>,
pub source: String,
pub status: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub path_class: Option<String>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct InstallFreshnessReport {
pub schema: String,
pub verdict: InstallFreshnessVerdict,
pub authoritative: bool,
pub comparison: String,
pub source_version: InstallVersionEvidence,
pub installed_version: InstallVersionEvidence,
pub path_status: InstallPathStatus,
pub required_surfaces: Vec<String>,
pub missing_required_surfaces: Vec<String>,
pub blocking_findings: Vec<InstallFindingCode>,
pub repair: String,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct InstallCheckReport {
pub command: String,
pub schema: String,
pub version: String,
pub current_binary: CurrentBinary,
pub target: InstallTarget,
pub path: InstallPathAnalysis,
pub permissions: InstallPermissionCheck,
pub update_source: UpdateSourcePosture,
pub freshness: InstallFreshnessReport,
pub findings: Vec<InstallFinding>,
}
impl InstallCheckReport {
#[must_use]
pub fn status(&self) -> InstallPlanStatus {
findings_status(&self.findings)
}
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct InstallArtifactSelection {
pub artifact_id: String,
pub release_version: String,
pub file_name: String,
pub target_triple: String,
pub archive_format: String,
pub checksum_algorithm: String,
pub checksum: String,
pub signature: String,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PlannedInstallOperation {
pub action: String,
pub path: String,
pub mode: String,
pub requires_verification: bool,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct InstallVerificationPlan {
pub manifest_status: String,
pub checksum_status: String,
pub signature_status: String,
pub target_status: String,
pub overwrite_status: String,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct InstallPlanReport {
pub command: String,
pub schema: String,
pub version: String,
pub operation: InstallOperation,
pub dry_run: bool,
pub status: InstallPlanStatus,
pub current_version: String,
pub target_version: Option<String>,
pub pinned_version: Option<String>,
pub target: InstallTarget,
pub artifact: Option<InstallArtifactSelection>,
pub verification: InstallVerificationPlan,
pub planned_operations: Vec<PlannedInstallOperation>,
pub idempotency_key: String,
pub rollback: String,
pub findings: Vec<InstallFinding>,
}
#[must_use]
pub fn compare_versions(current: &str, target: &str) -> Ordering {
let current_version = ParsedVersion::parse(current);
let target_version = ParsedVersion::parse(target);
let width = current_version.core.len().max(target_version.core.len());
for index in 0..width {
let left = current_version.core.get(index).copied().unwrap_or(0);
let right = target_version.core.get(index).copied().unwrap_or(0);
match left.cmp(&right) {
Ordering::Equal => {}
ordering => return ordering,
}
}
compare_prerelease(
current_version.prerelease.as_deref(),
target_version.prerelease.as_deref(),
)
}
#[must_use]
pub fn is_safe_install_path(path: &Path) -> bool {
path.is_absolute()
&& path.components().all(|component| {
!matches!(
component,
std::path::Component::ParentDir | std::path::Component::CurDir
)
})
}
#[must_use]
pub fn findings_status(findings: &[InstallFinding]) -> InstallPlanStatus {
if findings
.iter()
.any(|finding| finding.severity == InstallFindingSeverity::Error)
{
InstallPlanStatus::Blocked
} else if findings
.iter()
.any(|finding| finding.severity == InstallFindingSeverity::Warning)
{
InstallPlanStatus::Degraded
} else {
InstallPlanStatus::Ready
}
}
#[derive(Debug, Eq, PartialEq)]
struct ParsedVersion {
core: Vec<u64>,
prerelease: Option<Vec<PrereleaseIdentifier>>,
}
impl ParsedVersion {
fn parse(version: &str) -> Self {
let trimmed = version.trim().trim_start_matches('v');
let without_build = trimmed.split_once('+').map_or(trimmed, |(core, _)| core);
let (core, prerelease) = without_build
.split_once('-')
.map_or((without_build, None), |(core, prerelease)| {
(core, Some(prerelease))
});
Self {
core: version_parts(core),
prerelease: prerelease.and_then(parse_prerelease_identifiers),
}
}
}
#[derive(Debug, Eq, PartialEq)]
enum PrereleaseIdentifier {
Numeric(u64),
Text(String),
}
fn parse_prerelease_identifiers(prerelease: &str) -> Option<Vec<PrereleaseIdentifier>> {
let identifiers = prerelease
.split('.')
.filter(|part| !part.is_empty())
.map(|part| {
if part.chars().all(|ch| ch.is_ascii_digit()) {
part.parse::<u64>()
.map(PrereleaseIdentifier::Numeric)
.unwrap_or_else(|_| PrereleaseIdentifier::Text(part.to_owned()))
} else {
PrereleaseIdentifier::Text(part.to_owned())
}
})
.collect::<Vec<_>>();
if identifiers.is_empty() {
None
} else {
Some(identifiers)
}
}
fn compare_prerelease(
current: Option<&[PrereleaseIdentifier]>,
target: Option<&[PrereleaseIdentifier]>,
) -> Ordering {
match (current, target) {
(None, None) => Ordering::Equal,
(None, Some(_)) => Ordering::Greater,
(Some(_), None) => Ordering::Less,
(Some(current), Some(target)) => compare_prerelease_identifiers(current, target),
}
}
fn compare_prerelease_identifiers(
current: &[PrereleaseIdentifier],
target: &[PrereleaseIdentifier],
) -> Ordering {
let width = current.len().max(target.len());
for index in 0..width {
let Some(left) = current.get(index) else {
return Ordering::Less;
};
let Some(right) = target.get(index) else {
return Ordering::Greater;
};
let ordering = match (left, right) {
(PrereleaseIdentifier::Numeric(left), PrereleaseIdentifier::Numeric(right)) => {
left.cmp(right)
}
(PrereleaseIdentifier::Numeric(_), PrereleaseIdentifier::Text(_)) => Ordering::Less,
(PrereleaseIdentifier::Text(_), PrereleaseIdentifier::Numeric(_)) => Ordering::Greater,
(PrereleaseIdentifier::Text(left), PrereleaseIdentifier::Text(right)) => {
left.cmp(right)
}
};
if ordering != Ordering::Equal {
return ordering;
}
}
Ordering::Equal
}
fn version_parts(version: &str) -> Vec<u64> {
version
.trim()
.trim_start_matches('v')
.split('.')
.map(|part| {
part.chars()
.take_while(|ch| ch.is_ascii_digit())
.collect::<String>()
})
.take_while(|part| !part.is_empty())
.filter_map(|part| part.parse::<u64>().ok())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
type TestResult = Result<(), String>;
fn ensure(condition: bool, context: &str) -> TestResult {
if condition {
Ok(())
} else {
Err(context.to_owned())
}
}
fn ensure_equal<T: std::fmt::Debug + PartialEq>(
actual: T,
expected: T,
context: &str,
) -> TestResult {
if actual == expected {
Ok(())
} else {
Err(format!("{context}: expected {expected:?}, got {actual:?}"))
}
}
#[test]
fn version_comparison_orders_patch_releases() -> TestResult {
ensure_equal(
compare_versions("0.1.9", "0.1.10"),
Ordering::Less,
"patch ordering",
)?;
ensure_equal(
compare_versions("v0.2.0", "0.1.10"),
Ordering::Greater,
"v prefix ordering",
)?;
ensure_equal(
compare_versions("0.2.0", "0.2.0+build"),
Ordering::Equal,
"build metadata ignored",
)?;
ensure_equal(
compare_versions("0.2.0-alpha", "0.2.0"),
Ordering::Less,
"prerelease sorts before stable",
)?;
ensure_equal(
compare_versions("0.2.0", "0.2.0-rc.1"),
Ordering::Greater,
"stable sorts after prerelease",
)?;
ensure_equal(
compare_versions("0.2.0-alpha.2", "0.2.0-alpha.10"),
Ordering::Less,
"numeric prerelease identifiers sort numerically",
)?;
ensure_equal(
compare_versions("0.2.0-alpha", "0.2.0-alpha+build"),
Ordering::Equal,
"build metadata ignored after prerelease",
)
}
#[test]
fn findings_status_is_conservative() -> TestResult {
ensure_equal(findings_status(&[]), InstallPlanStatus::Ready, "empty")?;
ensure_equal(
findings_status(&[InstallFinding::warning(
InstallFindingCode::SignatureMissing,
"missing",
"attach signature",
)]),
InstallPlanStatus::Degraded,
"warning",
)?;
ensure_equal(
findings_status(&[InstallFinding::error(
InstallFindingCode::UnsupportedTarget,
"unsupported",
"pick supported target",
)]),
InstallPlanStatus::Blocked,
"error",
)
}
#[test]
fn safe_install_path_rejects_relative_traversal() -> TestResult {
ensure(is_safe_install_path(Path::new("/tmp/ee")), "absolute path")?;
ensure(!is_safe_install_path(Path::new("bin/ee")), "relative path")?;
ensure(
!is_safe_install_path(Path::new("/tmp/../ee")),
"parent traversal",
)
}
}