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