Skip to main content

kcode_k1_web_cache_resolution/
lib.rs

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}