Skip to main content

kcode_k1_web_cache_resolution/
lib.rs

1use 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}