kcode_k1_web_package/
source.rs1use 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}