Skip to main content

kcode_k1_web_package/
source.rs

1use crate::{
2    AuthorityId, DependencySelector, PackageError, WebId, fail, validate_logical_name,
3    validate_stable,
4};
5use kcode_k1_transaction_id::TxId;
6use semver::Version;
7use serde::Deserialize;
8use std::str::FromStr;
9
10#[derive(Deserialize)]
11#[serde(deny_unknown_fields)]
12struct Manifest {
13    name: String,
14    version: String,
15    entry: String,
16    tests: String,
17    dependencies: Vec<ManifestDependency>,
18}
19
20#[derive(Deserialize)]
21#[serde(deny_unknown_fields)]
22struct ManifestDependency {
23    authority: String,
24    name: String,
25    selector: String,
26}
27
28#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
29pub struct SourceFile(String, Vec<u8>);
30
31impl SourceFile {
32    pub fn new(path: impl Into<String>, bytes: Vec<u8>) -> Self {
33        Self(path.into(), bytes)
34    }
35
36    pub fn path(&self) -> &str {
37        &self.0
38    }
39
40    pub fn bytes(&self) -> &[u8] {
41        &self.1
42    }
43}
44
45#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
46pub struct WebDependency(AuthorityId, String, DependencySelector);
47
48impl WebDependency {
49    pub const fn authority(&self) -> AuthorityId {
50        self.0
51    }
52
53    pub fn name(&self) -> &str {
54        &self.1
55    }
56
57    pub fn selector(&self) -> &DependencySelector {
58        &self.2
59    }
60}
61
62#[derive(Debug, Eq, PartialEq)]
63pub struct SourcePackage {
64    id: WebId,
65    files: Vec<SourceFile>,
66    entry: String,
67    tests: String,
68    dependencies: Vec<WebDependency>,
69}
70
71impl SourcePackage {
72    pub fn new(id: WebId, mut files: Vec<SourceFile>) -> Result<Self, PackageError> {
73        files
74            .iter()
75            .try_for_each(|file| validate_path(file.path()))?;
76        files.sort();
77        if files
78            .windows(2)
79            .any(|pair| pair[0].path() == pair[1].path())
80        {
81            return fail("duplicate source path");
82        }
83        if files
84            .iter()
85            .any(|file| has_file_ancestor(&files, file.path()))
86        {
87            return fail("source path collides with a file ancestor");
88        }
89
90        let manifest_source = required_utf8(&files, "k1-web.json")?;
91        required_utf8(&files, "Documentation.md")?;
92        let manifest: Manifest = serde_json::from_str(manifest_source)
93            .map_err(|cause| PackageError(format!("invalid k1-web.json: {cause}")))?;
94        validate_identity(&id, &manifest)?;
95        required_javascript(&files, &manifest.entry, "entry")?;
96        required_javascript(&files, &manifest.tests, "tests")?;
97        let dependencies = parse_dependencies(manifest.dependencies)?;
98
99        Ok(Self {
100            id,
101            files,
102            entry: manifest.entry,
103            tests: manifest.tests,
104            dependencies,
105        })
106    }
107
108    pub fn id(&self) -> &WebId {
109        &self.id
110    }
111
112    pub fn files(&self) -> &[SourceFile] {
113        &self.files
114    }
115
116    pub fn entry(&self) -> &str {
117        &self.entry
118    }
119
120    pub fn tests(&self) -> &str {
121        &self.tests
122    }
123
124    pub fn dependencies(&self) -> &[WebDependency] {
125        &self.dependencies
126    }
127}
128
129fn validate_path(path: &str) -> Result<(), PackageError> {
130    let valid = !path.is_empty()
131        && path.len() <= 4096
132        && !path.starts_with('/')
133        && !path.contains([':', '\\', '\0'])
134        && path
135            .split('/')
136            .all(|part| !part.is_empty() && !matches!(part, "." | "..") && part.len() <= 255);
137    if !valid {
138        return fail("invalid source path");
139    }
140    Ok(())
141}
142
143fn has_file_ancestor(files: &[SourceFile], path: &str) -> bool {
144    path.match_indices('/').any(|(index, _)| {
145        files
146            .binary_search_by(|file| file.path().cmp(&path[..index]))
147            .is_ok()
148    })
149}
150
151fn required_utf8<'a>(files: &'a [SourceFile], path: &str) -> Result<&'a str, PackageError> {
152    let file = files
153        .iter()
154        .find(|file| file.path() == path)
155        .ok_or_else(|| PackageError(format!("missing {path}")))?;
156    std::str::from_utf8(file.bytes()).map_err(|_| PackageError(format!("{path} must be UTF-8")))
157}
158
159fn required_javascript(files: &[SourceFile], path: &str, field: &str) -> Result<(), PackageError> {
160    validate_path(path)?;
161    if !(path.ends_with(".js") || path.ends_with(".mjs")) {
162        return fail(format!("manifest {field} must name a JavaScript file"));
163    }
164    required_utf8(files, path)?;
165    Ok(())
166}
167
168fn validate_identity(id: &WebId, manifest: &Manifest) -> Result<(), PackageError> {
169    if manifest.name != id.family().logical_name() {
170        return fail("manifest name does not match package identity");
171    }
172    let version = Version::parse(&manifest.version)
173        .map_err(|_| PackageError("manifest version is not valid SemVer".into()))?;
174    validate_stable(&version)?;
175    if version.to_string() != manifest.version || &version != id.version() {
176        return fail("manifest version does not match package identity canonically");
177    }
178    Ok(())
179}
180
181fn parse_dependencies(
182    declarations: Vec<ManifestDependency>,
183) -> Result<Vec<WebDependency>, PackageError> {
184    let mut dependencies = Vec::with_capacity(declarations.len());
185    for declaration in declarations {
186        validate_logical_name(&declaration.name)?;
187        dependencies.push(WebDependency(
188            parse_authority(&declaration.authority)?,
189            declaration.name,
190            DependencySelector::parse(&declaration.selector)?,
191        ));
192    }
193    dependencies.sort();
194    if dependencies.windows(2).any(|pair| pair[0] == pair[1]) {
195        return fail("duplicate dependency declaration");
196    }
197    Ok(dependencies)
198}
199
200fn parse_authority(text: &str) -> Result<AuthorityId, PackageError> {
201    let transaction_id =
202        TxId::from_str(text).map_err(|_| PackageError("invalid dependency authority".into()))?;
203    if transaction_id.to_string() != text {
204        return fail("dependency authority must use canonical spelling");
205    }
206    Ok(AuthorityId::new(transaction_id))
207}