hdiff-update-core 0.2.0

Core library for signed, transactional HDiffPatch directory updates.
Documentation
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());
    }
}