kcode_k1_web_cache_resolution/
lib.rs1use kcode_k1_transaction_id::TxId;
2use kcode_k1_web_package::{DependencySelector, SourceFile, SourcePackage, WebFamily, WebId};
3use sha2::{Digest as _, Sha256};
4use std::collections::BTreeSet;
5use std::fs;
6use std::path::Path;
7use std::sync::Arc;
8use walkdir::WalkDir;
9
10#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
11pub struct Digest(pub [u8; 32]);
12#[derive(Clone, Debug, Eq, PartialEq)]
13pub struct ResolutionEntry {
14 pub family: WebFamily,
15 pub selector: DependencySelector,
16 pub resolved: WebId,
17 pub winning: TxId,
18}
19#[derive(Clone, Debug, Eq, PartialEq)]
20pub struct ResolutionView {
21 entries: Vec<ResolutionEntry>,
22 packages: Vec<Arc<SourcePackage>>,
23 manifest: Vec<u8>,
24 digest: Digest,
25}
26
27impl ResolutionView {
28 pub fn from_snapshot(
29 candidate: &SourcePackage,
30 mut selected: Vec<(ResolutionEntry, Arc<SourcePackage>)>,
31 ) -> Result<Self, String> {
32 selected.sort_by(|a, b| route_key(&a.0).cmp(&route_key(&b.0)));
33 if selected
34 .windows(2)
35 .any(|pair| route_key(&pair[0].0) == route_key(&pair[1].0))
36 {
37 return Err("duplicate resolution route".into());
38 }
39 validate_routes(candidate, &selected)?;
40 let packages = selected_packages(&selected)?;
41 let entries = selected.into_iter().map(|item| item.0).collect::<Vec<_>>();
42 let manifest = encode_manifest(&entries, &packages);
43 let digest = prepared_digest(&manifest, &packages);
44 Ok(Self {
45 entries,
46 packages,
47 manifest,
48 digest,
49 })
50 }
51 pub fn entries(&self) -> &[ResolutionEntry] {
52 &self.entries
53 }
54 pub fn route(
55 &self,
56 family: &WebFamily,
57 selector: &DependencySelector,
58 ) -> Option<&ResolutionEntry> {
59 self.entries
60 .iter()
61 .find(|entry| &entry.family == family && &entry.selector == selector)
62 }
63 pub const fn digest(&self) -> Digest {
64 self.digest
65 }
66 pub fn materialize(&self, empty_root: impl AsRef<Path>) -> Result<(), String> {
67 let root = empty_root.as_ref();
68 directory(root)?;
69 if fs::read_dir(root)
70 .map_err(|e| e.to_string())?
71 .next()
72 .is_some()
73 {
74 return Err("resolution target is not empty".into());
75 }
76 fs::write(root.join("manifest"), &self.manifest).map_err(|e| e.to_string())?;
77 let web_libs = root.join("web-libs");
78 fs::create_dir(&web_libs).map_err(|e| e.to_string())?;
79 for package in &self.packages {
80 let id = package.id();
81 let package_root = web_libs
82 .join(id.family().authority().to_string())
83 .join(id.family().logical_name())
84 .join(id.version().to_string());
85 write_files(&package_root, package.files())?;
86 }
87 if Self::inspect(root)? != Some(self.digest) {
88 return Err("materialized view mismatch".into());
89 }
90 Ok(())
91 }
92 pub fn inspect(root: impl AsRef<Path>) -> Result<Option<Digest>, String> {
93 let root = root.as_ref();
94 directory(root)?;
95 let mut files = Vec::new();
96 let (mut manifest, mut web_libs) = (None, false);
97 for entry in WalkDir::new(root).min_depth(1).sort_by_file_name() {
98 let entry = entry.map_err(|e| e.to_string())?;
99 let relative = entry.path().strip_prefix(root).map_err(|e| e.to_string())?;
100 let depth = relative.components().count();
101 if !(entry.file_type().is_file() || entry.file_type().is_dir()) {
102 return Err("non-ordinary resolution entry".into());
103 }
104 if depth == 1 && entry.file_type().is_file() && entry.file_name() == "manifest" {
105 manifest = Some(fs::read(entry.path()).map_err(|e| e.to_string())?);
106 } else if depth == 1 && entry.file_type().is_dir() && entry.file_name() == "web-libs" {
107 web_libs = true;
108 } else if relative.starts_with("web-libs") && entry.file_type().is_file() && depth >= 5
109 {
110 let path = relative
111 .to_str()
112 .ok_or("resolution path is not UTF-8")?
113 .replace('\\', "/");
114 if !safe_path(&path) {
115 return Err("unsafe resolution path".into());
116 }
117 files.push((path, fs::read(entry.path()).map_err(|e| e.to_string())?));
118 } else if relative.starts_with("web-libs") && entry.file_type().is_dir() {
119 if fs::read_dir(entry.path())
120 .map_err(|e| e.to_string())?
121 .next()
122 .is_none()
123 {
124 return Err("empty resolution directory".into());
125 }
126 } else {
127 return Err("unexpected resolution entry".into());
128 }
129 }
130 if manifest.is_none() && !web_libs && files.is_empty() {
131 return Ok(None);
132 }
133 let manifest = manifest.ok_or("incomplete resolution view")?;
134 if !web_libs {
135 return Err("incomplete resolution view".into());
136 }
137 validate_manifest(&manifest, &files)?;
138 files.push(("manifest".into(), manifest));
139 files.sort_by(|a, b| a.0.cmp(&b.0));
140 Ok(Some(digest_files(
141 files
142 .iter()
143 .map(|item| (item.0.as_str(), item.1.as_slice())),
144 )))
145 }
146}
147
148fn validate_routes(
149 candidate: &SourcePackage,
150 selected: &[(ResolutionEntry, Arc<SourcePackage>)],
151) -> Result<(), String> {
152 for (entry, package) in selected {
153 if entry.resolved.family() != &entry.family
154 || !entry.selector.matches(entry.resolved.version())
155 || package.id() != &entry.resolved
156 {
157 return Err("resolution route does not match its package".into());
158 }
159 }
160 let mut pending = dependency_keys(candidate)?;
161 let mut reached = BTreeSet::new();
162 while let Some(key) = pending.pop() {
163 if !reached.insert(key.clone()) {
164 continue;
165 }
166 let index = selected
167 .binary_search_by(|item| route_key(&item.0).cmp(&(&key.0, &key.1)))
168 .map_err(|_| "resolution closure is incomplete")?;
169 pending.extend(dependency_keys(selected[index].1.as_ref())?);
170 }
171 let actual: BTreeSet<_> = selected
172 .iter()
173 .map(|item| (item.0.family.clone(), item.0.selector.clone()))
174 .collect();
175 if actual != reached {
176 return Err("resolution contains an unrelated route".into());
177 }
178 Ok(())
179}
180fn selected_packages(
181 selected: &[(ResolutionEntry, Arc<SourcePackage>)],
182) -> Result<Vec<Arc<SourcePackage>>, String> {
183 let mut packages = selected
184 .iter()
185 .map(|(entry, package)| (entry.resolved.clone(), entry.winning, Arc::clone(package)))
186 .collect::<Vec<_>>();
187 packages.sort_by(|a, b| a.0.cmp(&b.0));
188 if packages.windows(2).any(|pair| {
189 pair[0].0 == pair[1].0 && (pair[0].1 != pair[1].1 || pair[0].2.files() != pair[1].2.files())
190 }) {
191 return Err("one resolved identity has differing winner or bytes".into());
192 }
193 packages.dedup_by(|a, b| a.0 == b.0);
194 Ok(packages.into_iter().map(|item| item.2).collect())
195}
196fn dependency_keys(
197 package: &SourcePackage,
198) -> Result<Vec<(WebFamily, DependencySelector)>, String> {
199 package
200 .dependencies()
201 .iter()
202 .map(|dependency| {
203 WebFamily::new(dependency.authority(), dependency.name().to_owned())
204 .map(|family| (family, dependency.selector().clone()))
205 .map_err(|e| e.to_string())
206 })
207 .collect()
208}
209fn route_key(entry: &ResolutionEntry) -> (&WebFamily, &DependencySelector) {
210 (&entry.family, &entry.selector)
211}
212fn encode_manifest(entries: &[ResolutionEntry], packages: &[Arc<SourcePackage>]) -> Vec<u8> {
213 let mut rows = entries
214 .iter()
215 .map(|entry| {
216 format!(
217 "r\t{}\t{}\t{}\t{}\t{}",
218 entry.family.authority(),
219 entry.family.logical_name(),
220 entry.selector,
221 entry.resolved.version(),
222 entry.winning
223 )
224 })
225 .collect::<Vec<_>>();
226 for (path, bytes) in package_files(packages) {
227 rows.push(format!("f\t{path}\t{}", hex(&hash(&bytes))));
228 }
229 rows.sort();
230 let mut text = String::from("K1WEBRESOLUTION3\n");
231 for row in rows {
232 text.push_str(&row);
233 text.push('\n');
234 }
235 text.into_bytes()
236}
237fn package_files(packages: &[Arc<SourcePackage>]) -> Vec<(String, Vec<u8>)> {
238 let mut files = Vec::new();
239 for package in packages {
240 let id = package.id();
241 for file in package.files() {
242 files.push((
243 format!(
244 "web-libs/{}/{}/{}/{}",
245 id.family().authority(),
246 id.family().logical_name(),
247 id.version(),
248 file.path()
249 ),
250 file.bytes().to_vec(),
251 ));
252 }
253 }
254 files.sort_by(|a, b| a.0.cmp(&b.0));
255 files
256}
257fn validate_manifest(manifest: &[u8], files: &[(String, Vec<u8>)]) -> Result<(), String> {
258 let text = std::str::from_utf8(manifest).map_err(|_| "manifest is not UTF-8")?;
259 let lines = text
260 .strip_prefix("K1WEBRESOLUTION3\n")
261 .ok_or("invalid manifest header")?
262 .split_terminator('\n');
263 let mut rows = Vec::new();
264 let mut routes = BTreeSet::new();
265 let mut winners = BTreeSet::new();
266 let mut expected = BTreeSet::new();
267 for line in lines {
268 let fields = line.split('\t').collect::<Vec<_>>();
269 match fields.as_slice() {
270 ["r", authority, name, selector, version, winner]
271 if fields[1..].iter().all(component) =>
272 {
273 if !routes.insert((*authority, *name, *selector)) {
274 return Err("duplicate manifest route".into());
275 }
276 winners.insert(((*authority, *name, *version), *winner));
277 rows.push(line);
278 }
279 ["f", path, digest]
280 if safe_path(path)
281 && digest.len() == 64
282 && digest
283 .bytes()
284 .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) =>
285 {
286 if !expected.insert(format!("{path}\t{digest}")) {
287 return Err("duplicate manifest file".into());
288 }
289 rows.push(line);
290 }
291 _ => return Err("malformed manifest record".into()),
292 }
293 }
294 if rows.is_empty() || rows.windows(2).any(|pair| pair[0] >= pair[1]) {
295 return Err("noncanonical manifest".into());
296 }
297 for (identity, winner) in &winners {
298 if winners
299 .iter()
300 .any(|(other, value)| other == identity && value != winner)
301 {
302 return Err("inconsistent manifest winner".into());
303 }
304 }
305 for (path, bytes) in files {
306 if !expected.remove(&format!("{path}\t{}", hex(&hash(bytes)))) {
307 return Err("manifest file mismatch".into());
308 }
309 }
310 if !expected.is_empty() {
311 return Err("missing manifest file".into());
312 }
313 for (path, _) in files {
314 let parts = path.split('/').collect::<Vec<_>>();
315 if !winners.iter().any(|((authority, name, version), _)| {
316 *authority == parts[1] && *name == parts[2] && *version == parts[3]
317 }) {
318 return Err("file has no resolved manifest identity".into());
319 }
320 }
321 Ok(())
322}
323fn component(value: &&str) -> bool {
324 !value.is_empty() && !value.contains(['\t', '\n', '\r'])
325}
326fn hash(bytes: &[u8]) -> [u8; 32] {
327 Sha256::digest(bytes).into()
328}
329fn hex(bytes: &[u8]) -> String {
330 bytes.iter().map(|byte| format!("{byte:02x}")).collect()
331}
332fn prepared_digest(manifest: &[u8], packages: &[Arc<SourcePackage>]) -> Digest {
333 let mut files = package_files(packages);
334 files.push(("manifest".to_owned(), manifest.to_vec()));
335 files.sort_by(|a, b| a.0.cmp(&b.0));
336 digest_files(
337 files
338 .iter()
339 .map(|item| (item.0.as_str(), item.1.as_slice())),
340 )
341}
342fn digest_files<'a>(files: impl IntoIterator<Item = (&'a str, &'a [u8])>) -> Digest {
343 let mut hasher = Sha256::new();
344 append(&mut hasher, b"K1WEBVIEW1");
345 for (path, bytes) in files {
346 append(&mut hasher, path.as_bytes());
347 append(&mut hasher, bytes);
348 }
349 Digest(hasher.finalize().into())
350}
351fn write_files(root: &Path, files: &[SourceFile]) -> Result<(), String> {
352 for file in files {
353 let target = root.join(file.path());
354 fs::create_dir_all(target.parent().ok_or("source path has no parent")?)
355 .map_err(|e| e.to_string())?;
356 fs::write(target, file.bytes()).map_err(|e| e.to_string())?;
357 }
358 Ok(())
359}
360fn safe_path(path: &str) -> bool {
361 let parts = path.split('/').collect::<Vec<_>>();
362 parts.len() >= 5
363 && parts[0] == "web-libs"
364 && parts.iter().all(|part| {
365 !part.is_empty() && !matches!(*part, "." | "..") && !part.contains([':', '\\', '\0'])
366 })
367}
368fn directory(path: &Path) -> Result<(), String> {
369 match fs::symlink_metadata(path) {
370 Ok(value) if value.is_dir() && !value.file_type().is_symlink() => Ok(()),
371 Ok(_) => Err(format!("unexpected path type: {}", path.display())),
372 Err(cause) => Err(cause.to_string()),
373 }
374}
375fn append(hasher: &mut Sha256, bytes: &[u8]) {
376 hasher.update((bytes.len() as u64).to_le_bytes());
377 hasher.update(bytes);
378}