Skip to main content

kcode_k1_web_package/
lib.rs

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}