use std::collections::{BTreeMap, BTreeSet};
use serde::{Deserialize, Serialize};
use crate::{
normalize_managed_paths, validate_file_tree_manifest, validate_version_upgrade, Artifact,
Error, FileTreeManifest, ManifestSignature, Result,
};
pub const DIRECTORY_UPDATE_SCHEMA_VERSION: u32 = 2;
pub const DIRECTORY_UPDATE_ALGORITHM: &str = "hdiffpatch-v5-dir-single-lzma2";
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DirectoryDelta {
pub from_version: String,
pub source: FileTreeManifest,
pub patch: Artifact,
#[serde(default = "default_directory_algorithm")]
pub algorithm: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DirectoryPlatformRelease {
pub full: Artifact,
pub managed_paths: Vec<String>,
pub target: FileTreeManifest,
#[serde(default)]
pub deltas: Vec<DirectoryDelta>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DirectoryUpdateManifest {
pub schema_version: u32,
pub version: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub notes: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub published_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub signature: Option<ManifestSignature>,
pub platforms: BTreeMap<String, DirectoryPlatformRelease>,
}
impl DirectoryUpdateManifest {
pub fn signing_payload(&self) -> Result<Vec<u8>> {
let mut unsigned = self.clone();
unsigned.signature = None;
Ok(serde_json::to_vec(&unsigned)?)
}
pub fn platform(&self, platform: &str) -> Result<&DirectoryPlatformRelease> {
self.platforms
.get(platform)
.ok_or_else(|| Error::MissingPlatform {
platform: platform.to_string(),
})
}
pub fn validate(&self) -> Result<()> {
if self.schema_version != DIRECTORY_UPDATE_SCHEMA_VERSION {
return Err(Error::Message(format!(
"unsupported directory update schema version: {}",
self.schema_version
)));
}
if self.version.trim().is_empty() {
return Err(Error::Message(
"directory update version is empty".to_string(),
));
}
if self.platforms.is_empty() {
return Err(Error::Message(
"directory update manifest has no platforms".to_string(),
));
}
for (platform, release) in &self.platforms {
let managed_paths = normalize_managed_paths(&release.managed_paths)?;
if managed_paths != release.managed_paths {
return Err(Error::Message(format!(
"managed paths for {platform} are not canonical"
)));
}
validate_artifact(&release.full, "full installer")?;
validate_file_tree_manifest(&release.target, &managed_paths)?;
let mut versions = BTreeSet::new();
for delta in &release.deltas {
if delta.from_version.trim().is_empty() {
return Err(Error::Message(format!(
"directory delta for {platform} has an empty source version"
)));
}
if !versions.insert(delta.from_version.clone()) {
return Err(Error::Message(format!(
"duplicate directory delta for {platform} from {}",
delta.from_version
)));
}
if delta.from_version == self.version {
return Err(Error::Message(format!(
"directory delta source equals target version: {}",
self.version
)));
}
validate_version_upgrade(&delta.from_version, &self.version)?;
if delta.algorithm != DIRECTORY_UPDATE_ALGORITHM {
return Err(Error::UnsupportedAlgorithm(delta.algorithm.clone()));
}
validate_file_tree_manifest(&delta.source, &managed_paths)?;
validate_artifact(&delta.patch, "directory patch")?;
}
}
Ok(())
}
}
impl DirectoryPlatformRelease {
pub fn delta_from(&self, version: &str) -> Option<&DirectoryDelta> {
self.deltas
.iter()
.find(|delta| delta.from_version == version)
}
}
fn validate_artifact(artifact: &Artifact, label: &str) -> Result<()> {
if artifact.url.trim().is_empty() {
return Err(Error::Message(format!("{label} URL is empty")));
}
if artifact.size == 0 {
return Err(Error::Message(format!("{label} size is zero")));
}
if artifact.sha256.len() != 64
|| hex::decode(&artifact.sha256).map_or(true, |bytes| bytes.len() != 32)
{
return Err(Error::Message(format!("{label} SHA-256 is invalid")));
}
Ok(())
}
fn default_directory_algorithm() -> String {
DIRECTORY_UPDATE_ALGORITHM.to_string()
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use crate::{Artifact, FileTreeManifest, TreeFile};
use super::{
DirectoryDelta, DirectoryPlatformRelease, DirectoryUpdateManifest,
DIRECTORY_UPDATE_ALGORITHM, DIRECTORY_UPDATE_SCHEMA_VERSION,
};
#[test]
fn rejects_noncanonical_managed_paths() {
let tree = FileTreeManifest {
tree_sha256: "0".repeat(64),
total_size: 1,
files: vec![TreeFile {
path: "xCodex.exe".to_string(),
sha256: "1".repeat(64),
size: 1,
executable: true,
}],
};
let manifest = DirectoryUpdateManifest {
schema_version: DIRECTORY_UPDATE_SCHEMA_VERSION,
version: "0.2.0".to_string(),
notes: None,
published_at: None,
signature: None,
platforms: BTreeMap::from([(
"windows-x86_64".to_string(),
DirectoryPlatformRelease {
full: Artifact {
url: "full.exe".to_string(),
sha256: "2".repeat(64),
size: 1,
signature: None,
},
managed_paths: vec!["xCodex.exe".to_string()],
target: tree.clone(),
deltas: vec![DirectoryDelta {
from_version: "0.1.0".to_string(),
source: tree,
patch: Artifact {
url: "patch.hdiff".to_string(),
sha256: "3".repeat(64),
size: 1,
signature: None,
},
algorithm: DIRECTORY_UPDATE_ALGORITHM.to_string(),
}],
},
)]),
};
assert!(manifest.validate().is_err());
}
}