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}