Skip to main content

hdiff_update_core/
directory_manifest.rs

1use std::collections::{BTreeMap, BTreeSet};
2
3use serde::{Deserialize, Serialize};
4
5use crate::{
6    normalize_managed_paths, validate_file_tree_manifest, validate_version_upgrade, Artifact,
7    Error, FileTreeManifest, ManifestSignature, Result,
8};
9
10pub const DIRECTORY_UPDATE_SCHEMA_VERSION: u32 = 2;
11pub const DIRECTORY_UPDATE_ALGORITHM: &str = "hdiffpatch-v5-dir-single-lzma2";
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
14#[serde(rename_all = "camelCase")]
15pub struct DirectoryDelta {
16    pub from_version: String,
17    pub source: FileTreeManifest,
18    pub patch: Artifact,
19    #[serde(default = "default_directory_algorithm")]
20    pub algorithm: String,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
24#[serde(rename_all = "camelCase")]
25pub struct DirectoryPlatformRelease {
26    pub full: Artifact,
27    pub managed_paths: Vec<String>,
28    pub target: FileTreeManifest,
29    #[serde(default)]
30    pub deltas: Vec<DirectoryDelta>,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
34#[serde(rename_all = "camelCase")]
35pub struct DirectoryUpdateManifest {
36    pub schema_version: u32,
37    pub version: String,
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub notes: Option<String>,
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub published_at: Option<String>,
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub signature: Option<ManifestSignature>,
44    pub platforms: BTreeMap<String, DirectoryPlatformRelease>,
45}
46
47impl DirectoryUpdateManifest {
48    pub fn signing_payload(&self) -> Result<Vec<u8>> {
49        let mut unsigned = self.clone();
50        unsigned.signature = None;
51        Ok(serde_json::to_vec(&unsigned)?)
52    }
53
54    pub fn platform(&self, platform: &str) -> Result<&DirectoryPlatformRelease> {
55        self.platforms
56            .get(platform)
57            .ok_or_else(|| Error::MissingPlatform {
58                platform: platform.to_string(),
59            })
60    }
61
62    pub fn validate(&self) -> Result<()> {
63        if self.schema_version != DIRECTORY_UPDATE_SCHEMA_VERSION {
64            return Err(Error::Message(format!(
65                "unsupported directory update schema version: {}",
66                self.schema_version
67            )));
68        }
69        if self.version.trim().is_empty() {
70            return Err(Error::Message(
71                "directory update version is empty".to_string(),
72            ));
73        }
74        if self.platforms.is_empty() {
75            return Err(Error::Message(
76                "directory update manifest has no platforms".to_string(),
77            ));
78        }
79
80        for (platform, release) in &self.platforms {
81            let managed_paths = normalize_managed_paths(&release.managed_paths)?;
82            if managed_paths != release.managed_paths {
83                return Err(Error::Message(format!(
84                    "managed paths for {platform} are not canonical"
85                )));
86            }
87            validate_artifact(&release.full, "full installer")?;
88            validate_file_tree_manifest(&release.target, &managed_paths)?;
89
90            let mut versions = BTreeSet::new();
91            for delta in &release.deltas {
92                if delta.from_version.trim().is_empty() {
93                    return Err(Error::Message(format!(
94                        "directory delta for {platform} has an empty source version"
95                    )));
96                }
97                if !versions.insert(delta.from_version.clone()) {
98                    return Err(Error::Message(format!(
99                        "duplicate directory delta for {platform} from {}",
100                        delta.from_version
101                    )));
102                }
103                if delta.from_version == self.version {
104                    return Err(Error::Message(format!(
105                        "directory delta source equals target version: {}",
106                        self.version
107                    )));
108                }
109                validate_version_upgrade(&delta.from_version, &self.version)?;
110                if delta.algorithm != DIRECTORY_UPDATE_ALGORITHM {
111                    return Err(Error::UnsupportedAlgorithm(delta.algorithm.clone()));
112                }
113                validate_file_tree_manifest(&delta.source, &managed_paths)?;
114                validate_artifact(&delta.patch, "directory patch")?;
115            }
116        }
117        Ok(())
118    }
119}
120
121impl DirectoryPlatformRelease {
122    pub fn delta_from(&self, version: &str) -> Option<&DirectoryDelta> {
123        self.deltas
124            .iter()
125            .find(|delta| delta.from_version == version)
126    }
127}
128
129fn validate_artifact(artifact: &Artifact, label: &str) -> Result<()> {
130    if artifact.url.trim().is_empty() {
131        return Err(Error::Message(format!("{label} URL is empty")));
132    }
133    if artifact.size == 0 {
134        return Err(Error::Message(format!("{label} size is zero")));
135    }
136    if artifact.sha256.len() != 64
137        || hex::decode(&artifact.sha256).map_or(true, |bytes| bytes.len() != 32)
138    {
139        return Err(Error::Message(format!("{label} SHA-256 is invalid")));
140    }
141    Ok(())
142}
143
144fn default_directory_algorithm() -> String {
145    DIRECTORY_UPDATE_ALGORITHM.to_string()
146}
147
148#[cfg(test)]
149mod tests {
150    use std::collections::BTreeMap;
151
152    use crate::{Artifact, FileTreeManifest, TreeFile};
153
154    use super::{
155        DirectoryDelta, DirectoryPlatformRelease, DirectoryUpdateManifest,
156        DIRECTORY_UPDATE_ALGORITHM, DIRECTORY_UPDATE_SCHEMA_VERSION,
157    };
158
159    #[test]
160    fn rejects_noncanonical_managed_paths() {
161        let tree = FileTreeManifest {
162            tree_sha256: "0".repeat(64),
163            total_size: 1,
164            files: vec![TreeFile {
165                path: "xCodex.exe".to_string(),
166                sha256: "1".repeat(64),
167                size: 1,
168                executable: true,
169            }],
170        };
171        let manifest = DirectoryUpdateManifest {
172            schema_version: DIRECTORY_UPDATE_SCHEMA_VERSION,
173            version: "0.2.0".to_string(),
174            notes: None,
175            published_at: None,
176            signature: None,
177            platforms: BTreeMap::from([(
178                "windows-x86_64".to_string(),
179                DirectoryPlatformRelease {
180                    full: Artifact {
181                        url: "full.exe".to_string(),
182                        sha256: "2".repeat(64),
183                        size: 1,
184                        signature: None,
185                    },
186                    managed_paths: vec!["xCodex.exe".to_string()],
187                    target: tree.clone(),
188                    deltas: vec![DirectoryDelta {
189                        from_version: "0.1.0".to_string(),
190                        source: tree,
191                        patch: Artifact {
192                            url: "patch.hdiff".to_string(),
193                            sha256: "3".repeat(64),
194                            size: 1,
195                            signature: None,
196                        },
197                        algorithm: DIRECTORY_UPDATE_ALGORITHM.to_string(),
198                    }],
199                },
200            )]),
201        };
202        assert!(manifest.validate().is_err());
203    }
204}