1use kcode_k1_transaction_id::TxId;
2use semver::Version;
3use std::fmt::{Display, Formatter};
4use std::ops::Range;
5
6mod source;
7pub use source::{SourceFile, SourcePackage, WebDependency};
8
9#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
10pub struct AuthorityId(TxId);
11
12impl AuthorityId {
13 pub const fn new(transaction_id: TxId) -> Self {
14 Self(transaction_id)
15 }
16
17 pub const fn transaction_id(&self) -> &TxId {
18 &self.0
19 }
20}
21
22impl Display for AuthorityId {
23 fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
24 Display::fmt(&self.0, formatter)
25 }
26}
27
28#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
29pub struct WebFamily(AuthorityId, String);
30
31impl WebFamily {
32 pub fn new(
33 authority: AuthorityId,
34 logical_name: impl Into<String>,
35 ) -> Result<Self, PackageError> {
36 let logical_name = logical_name.into();
37 validate_logical_name(&logical_name)?;
38 Ok(Self(authority, logical_name))
39 }
40
41 pub const fn authority(&self) -> AuthorityId {
42 self.0
43 }
44
45 pub fn logical_name(&self) -> &str {
46 &self.1
47 }
48}
49
50#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
51pub struct WebId(WebFamily, Version);
52
53impl WebId {
54 pub fn new(family: WebFamily, version: Version) -> Result<Self, PackageError> {
55 validate_stable(&version)?;
56 Ok(Self(family, version))
57 }
58
59 pub fn family(&self) -> &WebFamily {
60 &self.0
61 }
62
63 pub fn version(&self) -> &Version {
64 &self.1
65 }
66}
67
68#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
69enum SelectorKind {
70 Any,
71 Major(u64),
72 MajorMinor(u64, u64),
73 Exact(u64, u64, u64),
74}
75
76#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
77pub struct DependencySelector(SelectorKind);
78
79impl DependencySelector {
80 pub fn parse(text: &str) -> Result<Self, PackageError> {
81 if text == "*" {
82 return Ok(Self(SelectorKind::Any));
83 }
84 let numbers = text
85 .split('.')
86 .map(parse_selector_number)
87 .collect::<Result<Vec<_>, _>>()?;
88 let kind = match numbers.as_slice() {
89 [major] => SelectorKind::Major(*major),
90 [major, minor] => SelectorKind::MajorMinor(*major, *minor),
91 [major, minor, patch] => SelectorKind::Exact(*major, *minor, *patch),
92 _ => return fail("dependency selector must contain one to three numbers or *"),
93 };
94 Ok(Self(kind))
95 }
96
97 pub fn matches(&self, version: &Version) -> bool {
98 if !version.pre.is_empty() || !version.build.is_empty() {
99 return false;
100 }
101 match self.0 {
102 SelectorKind::Any => true,
103 SelectorKind::Major(major) => version.major == major,
104 SelectorKind::MajorMinor(major, minor) => {
105 version.major == major && version.minor == minor
106 }
107 SelectorKind::Exact(major, minor, patch) => {
108 version.major == major && version.minor == minor && version.patch == patch
109 }
110 }
111 }
112}
113
114impl Display for DependencySelector {
115 fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
116 match self.0 {
117 SelectorKind::Any => formatter.write_str("*"),
118 SelectorKind::Major(major) => write!(formatter, "{major}"),
119 SelectorKind::MajorMinor(major, minor) => write!(formatter, "{major}.{minor}"),
120 SelectorKind::Exact(major, minor, patch) => {
121 write!(formatter, "{major}.{minor}.{patch}")
122 }
123 }
124 }
125}
126
127#[derive(Clone, Debug, Eq, PartialEq)]
128pub struct PackageError(String);
129
130impl PackageError {
131 pub fn message(&self) -> &str {
132 &self.0
133 }
134}
135
136impl Display for PackageError {
137 fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
138 formatter.write_str(&self.0)
139 }
140}
141
142impl std::error::Error for PackageError {}
143
144pub fn validate_source_path(path: &str) -> Result<(), PackageError> {
145 let valid = !path.is_empty()
146 && path.len() <= 4096
147 && !path.starts_with('/')
148 && !path.contains([':', '\\', '\0'])
149 && path
150 .split('/')
151 .all(|part| !part.is_empty() && !matches!(part, "." | "..") && part.len() <= 255);
152 if !valid {
153 return fail("invalid source path");
154 }
155 Ok(())
156}
157
158pub fn project_manifest_version(
159 files: &[SourceFile],
160 version: &Version,
161) -> Result<Vec<SourceFile>, PackageError> {
162 validate_stable(version)?;
163
164 let mut output = files.to_vec();
165 output
166 .iter()
167 .try_for_each(|file| validate_source_path(file.path()))?;
168 output.sort();
169 if output
170 .windows(2)
171 .any(|pair| pair[0].path() == pair[1].path())
172 {
173 return fail("duplicate source path");
174 }
175 if output
176 .iter()
177 .any(|file| has_file_ancestor(&output, file.path()))
178 {
179 return fail("source path collides with a file ancestor");
180 }
181
182 let manifest_index = output
183 .binary_search_by(|file| file.path().cmp("k1-web.json"))
184 .map_err(|_| PackageError("source must contain exactly one k1-web.json".into()))?;
185 let source = std::str::from_utf8(output[manifest_index].bytes())
186 .map_err(|_| PackageError("k1-web.json must be UTF-8".into()))?;
187 source::validate_manifest_for_projection(source)?;
188 let range = top_level_string_value(source, "version")?;
189 let replacement = serde_json::to_string(&version.to_string())
190 .map_err(|cause| PackageError(format!("encode manifest version: {cause}")))?;
191 let mut projected = String::with_capacity(source.len() - range.len() + replacement.len());
192 projected.push_str(&source[..range.start]);
193 projected.push_str(&replacement);
194 projected.push_str(&source[range.end..]);
195 output[manifest_index] = SourceFile::new("k1-web.json", projected.as_bytes().to_vec());
196 Ok(output)
197}
198
199fn has_file_ancestor(files: &[SourceFile], path: &str) -> bool {
200 path.match_indices('/').any(|(index, _)| {
201 files
202 .binary_search_by(|file| file.path().cmp(&path[..index]))
203 .is_ok()
204 })
205}
206
207fn top_level_string_value(source: &str, wanted: &str) -> Result<Range<usize>, PackageError> {
208 let bytes = source.as_bytes();
209 let mut cursor = skip_whitespace(bytes, 0);
210 if bytes.get(cursor) != Some(&b'{') {
211 return fail("k1-web.json must contain a JSON object");
212 }
213 cursor += 1;
214 let mut found = None;
215
216 loop {
217 cursor = skip_whitespace(bytes, cursor);
218 if bytes.get(cursor) == Some(&b'}') {
219 cursor += 1;
220 break;
221 }
222 let key_start = cursor;
223 let key_end = json_string_end(bytes, key_start)?;
224 let key: String = serde_json::from_str(&source[key_start..key_end])
225 .map_err(|cause| PackageError(format!("invalid k1-web.json key: {cause}")))?;
226 cursor = skip_whitespace(bytes, key_end);
227 if bytes.get(cursor) != Some(&b':') {
228 return fail("invalid k1-web.json object separator");
229 }
230 cursor = skip_whitespace(bytes, cursor + 1);
231 let value_start = cursor;
232 let mut values = serde_json::Deserializer::from_str(&source[value_start..])
233 .into_iter::<serde_json::Value>();
234 let value = values
235 .next()
236 .ok_or_else(|| PackageError("missing k1-web.json value".into()))?
237 .map_err(|cause| PackageError(format!("invalid k1-web.json: {cause}")))?;
238 let value_end = value_start
239 .checked_add(values.byte_offset())
240 .ok_or_else(|| PackageError("k1-web.json value is too large".into()))?;
241 if key == wanted {
242 if !value.is_string() {
243 return fail("manifest version must be a string");
244 }
245 if found.replace(value_start..value_end).is_some() {
246 return fail("manifest contains duplicate version fields");
247 }
248 }
249 cursor = skip_whitespace(bytes, value_end);
250 match bytes.get(cursor) {
251 Some(b',') => cursor += 1,
252 Some(b'}') => {
253 cursor += 1;
254 break;
255 }
256 _ => return fail("invalid k1-web.json object terminator"),
257 }
258 }
259
260 if skip_whitespace(bytes, cursor) != bytes.len() {
261 return fail("k1-web.json contains trailing data");
262 }
263 found.ok_or_else(|| PackageError("manifest is missing version".into()))
264}
265
266fn json_string_end(bytes: &[u8], start: usize) -> Result<usize, PackageError> {
267 if bytes.get(start) != Some(&b'"') {
268 return fail("k1-web.json object key must be a string");
269 }
270 let mut cursor = start + 1;
271 while let Some(byte) = bytes.get(cursor) {
272 match byte {
273 b'"' => return Ok(cursor + 1),
274 b'\\' => {
275 cursor = cursor
276 .checked_add(2)
277 .ok_or_else(|| PackageError("k1-web.json key is too large".into()))?;
278 }
279 0x00..=0x1f => return fail("invalid control byte in k1-web.json key"),
280 _ => cursor += 1,
281 }
282 }
283 fail("unterminated k1-web.json object key")
284}
285
286fn skip_whitespace(bytes: &[u8], mut cursor: usize) -> usize {
287 while bytes
288 .get(cursor)
289 .is_some_and(|byte| matches!(byte, b' ' | b'\n' | b'\r' | b'\t'))
290 {
291 cursor += 1;
292 }
293 cursor
294}
295
296pub(crate) fn fail<T>(message: impl Into<String>) -> Result<T, PackageError> {
297 Err(PackageError(message.into()))
298}
299
300pub(crate) fn validate_logical_name(name: &str) -> Result<(), PackageError> {
301 let valid = !name.is_empty()
302 && name.len() <= 250
303 && name.split('-').all(|part| {
304 !part.is_empty()
305 && part
306 .bytes()
307 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit())
308 });
309 if !valid {
310 return fail("logical name must be 1-250 characters of lowercase kebab case");
311 }
312 Ok(())
313}
314
315pub(crate) fn validate_stable(version: &Version) -> Result<(), PackageError> {
316 if !version.pre.is_empty() || !version.build.is_empty() {
317 return fail("version must be stable SemVer without prerelease or build metadata");
318 }
319 Ok(())
320}
321
322fn parse_selector_number(text: &str) -> Result<u64, PackageError> {
323 let canonical = !text.is_empty()
324 && !(text.len() > 1 && text.starts_with('0'))
325 && text.bytes().all(|byte| byte.is_ascii_digit());
326 if !canonical {
327 return fail("dependency selector numbers must use canonical decimal spelling");
328 }
329 text.parse()
330 .map_err(|_| PackageError("dependency selector number is too large".into()))
331}
332
333#[cfg(test)]
334mod tests {
335 use super::*;
336
337 #[test]
338 fn projects_only_the_manifest_version_token() {
339 let manifest = "{\n \"name\":\"alpha\", \"version\" : \"1.0.0\",\n \"entry\":\"index.js\",\"tests\":\"index.js\",\"dependencies\":[]\n}\n";
340 let binary = vec![0, 159, 255];
341 let files = vec![
342 SourceFile::new("z.bin", binary.clone()),
343 SourceFile::new("k1-web.json", manifest.as_bytes().to_vec()),
344 SourceFile::new("Documentation.md", b"docs".to_vec()),
345 ];
346 let projected = project_manifest_version(&files, &Version::new(2, 3, 4)).unwrap();
347 let rendered = std::str::from_utf8(
348 projected
349 .iter()
350 .find(|file| file.path() == "k1-web.json")
351 .unwrap()
352 .bytes(),
353 )
354 .unwrap();
355 assert_eq!(rendered, manifest.replace("\"1.0.0\"", "\"2.3.4\""));
356 assert_eq!(
357 projected
358 .iter()
359 .find(|file| file.path() == "z.bin")
360 .unwrap()
361 .bytes(),
362 binary
363 );
364 assert!(projected.windows(2).all(|pair| pair[0] < pair[1]));
365 }
366
367 #[test]
368 fn projection_handles_escaped_keys_and_rejects_bad_versions() {
369 let escaped = r#"{"name":"alpha","ver\u0073ion":"1.0.0","entry":"index.js","tests":"index.js","dependencies":[]}"#;
370 let files = vec![SourceFile::new("k1-web.json", escaped.as_bytes().to_vec())];
371 let projected = project_manifest_version(&files, &Version::new(9, 8, 7)).unwrap();
372 assert_eq!(
373 std::str::from_utf8(projected[0].bytes()).unwrap(),
374 escaped.replace("\"1.0.0\"", "\"9.8.7\"")
375 );
376 assert!(
377 project_manifest_version(&files, &Version::parse("1.0.0-preview").unwrap()).is_err()
378 );
379
380 let missing = vec![SourceFile::new("index.js", Vec::new())];
381 assert!(project_manifest_version(&missing, &Version::new(1, 0, 0)).is_err());
382 let invalid = vec![SourceFile::new("k1-web.json", br#"{"version":1}"#.to_vec())];
383 assert!(project_manifest_version(&invalid, &Version::new(1, 0, 0)).is_err());
384 let invalid_structure = vec![SourceFile::new(
385 "k1-web.json",
386 br#"{"name":"alpha","version":"1.0.0","entry":"index.js","tests":"index.js","dependencies":[],"extra":true}"#.to_vec(),
387 )];
388 assert!(project_manifest_version(&invalid_structure, &Version::new(1, 0, 0)).is_err());
389 }
390
391 #[test]
392 fn projection_rejects_invalid_tree_paths() {
393 let manifest =
394 br#"{"name":"alpha","version":"1.0.0","entry":"index.js","tests":"index.js","dependencies":[]}"#;
395 let base = SourceFile::new("k1-web.json", manifest.to_vec());
396
397 assert!(
398 project_manifest_version(
399 &[base.clone(), SourceFile::new("../bad", Vec::new())],
400 &Version::new(1, 0, 0),
401 )
402 .is_err()
403 );
404 assert!(
405 project_manifest_version(
406 &[
407 base.clone(),
408 SourceFile::new("a", Vec::new()),
409 SourceFile::new("a", Vec::new()),
410 ],
411 &Version::new(1, 0, 0),
412 )
413 .is_err()
414 );
415 assert!(
416 project_manifest_version(
417 &[
418 base,
419 SourceFile::new("assets", Vec::new()),
420 SourceFile::new("assets/icon.png", Vec::new()),
421 ],
422 &Version::new(1, 0, 0),
423 )
424 .is_err()
425 );
426 }
427
428 #[test]
429 fn public_path_validation_matches_package_paths() {
430 for path in ["a", "nested/file.unknown", "assets/icon.png"] {
431 validate_source_path(path).unwrap();
432 }
433 for path in ["", "/a", "a//b", "a/./b", "a/../b", "a\\b", "a:b"] {
434 assert!(validate_source_path(path).is_err(), "{path}");
435 }
436 }
437}