Skip to main content

kcode_k1_rust_package/
lib.rs

1use kcode_k1_transaction_id::TxId;
2use semver::{Version, VersionReq};
3use std::fmt::{Display, Formatter};
4use std::str::FromStr;
5use toml::{Table, Value};
6
7#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
8pub struct AuthorityId(TxId);
9
10impl AuthorityId {
11    pub const fn new(transaction_id: TxId) -> Self {
12        Self(transaction_id)
13    }
14
15    pub const fn transaction_id(&self) -> &TxId {
16        &self.0
17    }
18}
19
20impl Display for AuthorityId {
21    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
22        Display::fmt(&self.0, formatter)
23    }
24}
25
26#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
27pub struct LibraryFamily {
28    authority: AuthorityId,
29    logical_name: String,
30}
31
32impl LibraryFamily {
33    pub fn new(
34        authority: AuthorityId,
35        logical_name: impl Into<String>,
36    ) -> Result<Self, PackageError> {
37        let logical_name = logical_name.into();
38        validate_logical_name(&logical_name)?;
39        Ok(Self {
40            authority,
41            logical_name,
42        })
43    }
44
45    pub const fn authority(&self) -> AuthorityId {
46        self.authority
47    }
48
49    pub fn logical_name(&self) -> &str {
50        &self.logical_name
51    }
52
53    pub fn package_name(&self) -> String {
54        format!("k1-{}-{}", self.authority, self.logical_name)
55    }
56}
57
58#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
59pub struct LibraryId {
60    family: LibraryFamily,
61    version: Version,
62}
63
64impl LibraryId {
65    pub fn new(family: LibraryFamily, version: Version) -> Result<Self, PackageError> {
66        validate_stable(&version)?;
67        Ok(Self { family, version })
68    }
69
70    pub fn family(&self) -> &LibraryFamily {
71        &self.family
72    }
73
74    pub fn version(&self) -> &Version {
75        &self.version
76    }
77}
78
79#[derive(Clone, Debug, Eq, PartialEq)]
80pub struct SourceFile {
81    path: String,
82    bytes: Vec<u8>,
83}
84
85impl SourceFile {
86    pub fn new(path: impl Into<String>, bytes: Vec<u8>) -> Self {
87        Self {
88            path: path.into(),
89            bytes,
90        }
91    }
92
93    pub fn path(&self) -> &str {
94        &self.path
95    }
96
97    pub fn bytes(&self) -> &[u8] {
98        &self.bytes
99    }
100}
101
102#[derive(Clone, Debug, Eq, PartialEq)]
103pub struct K1Dependency {
104    alias: String,
105    family: LibraryFamily,
106    version: Version,
107}
108
109impl K1Dependency {
110    pub fn alias(&self) -> &str {
111        &self.alias
112    }
113
114    pub fn family(&self) -> &LibraryFamily {
115        &self.family
116    }
117
118    pub fn version(&self) -> &Version {
119        &self.version
120    }
121}
122
123#[derive(Clone, Debug, Eq, PartialEq)]
124pub struct SourcePackage {
125    id: LibraryId,
126    files: Vec<SourceFile>,
127    dependencies: Vec<K1Dependency>,
128}
129
130impl SourcePackage {
131    pub fn new(id: LibraryId, mut files: Vec<SourceFile>) -> Result<Self, PackageError> {
132        files
133            .iter()
134            .try_for_each(|file| validate_path(file.path()))?;
135        files.sort_by(|left, right| left.path.cmp(&right.path));
136        if files.windows(2).any(|pair| pair[0].path == pair[1].path) {
137            return fail("duplicate source path");
138        }
139        if files.iter().any(|file| has_ancestor(&files, file.path())) {
140            return fail("source path collides with an ancestor");
141        }
142        let manifest = required_utf8(&files, "Cargo.toml")?;
143        required_utf8(&files, "Documentation.md")?;
144        let dependencies = validate_manifest(&id, manifest)?;
145        Ok(Self {
146            id,
147            files,
148            dependencies,
149        })
150    }
151
152    pub fn id(&self) -> &LibraryId {
153        &self.id
154    }
155
156    pub fn files(&self) -> &[SourceFile] {
157        &self.files
158    }
159
160    pub fn dependencies(&self) -> &[K1Dependency] {
161        &self.dependencies
162    }
163}
164
165#[derive(Clone, Debug, Eq, PartialEq)]
166pub struct PackageError(String);
167
168impl PackageError {
169    pub fn message(&self) -> &str {
170        &self.0
171    }
172}
173
174impl Display for PackageError {
175    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
176        formatter.write_str(&self.0)
177    }
178}
179
180impl std::error::Error for PackageError {}
181
182fn fail<T>(message: impl Into<String>) -> Result<T, PackageError> {
183    Err(PackageError(message.into()))
184}
185
186fn validate_logical_name(name: &str) -> Result<(), PackageError> {
187    let valid = !name.is_empty()
188        && name.len() <= 36
189        && name.split('-').all(|part| {
190            !part.is_empty()
191                && part
192                    .bytes()
193                    .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit())
194        });
195    if !valid {
196        return fail("logical name must be 1-36 bytes of lowercase kebab case");
197    }
198    Ok(())
199}
200
201fn validate_stable(version: &Version) -> Result<(), PackageError> {
202    if !version.pre.is_empty() || !version.build.is_empty() {
203        return fail("version must be stable");
204    }
205    Ok(())
206}
207
208fn validate_path(path: &str) -> Result<(), PackageError> {
209    if path.is_empty()
210        || path.len() > 4096
211        || path.starts_with('/')
212        || path.contains(['\\', '\0'])
213        || path.split('/').any(|component| {
214            component.is_empty()
215                || matches!(component, "." | ".." | ".cargo")
216                || component.len() > 255
217        })
218    {
219        return fail("invalid source path");
220    }
221    let leaf = path.rsplit('/').next().expect("nonempty validated path");
222    if leaf.starts_with("rust-toolchain")
223        || path == "Cargo.lock"
224        || leaf == "Cargo.toml" && path != "Cargo.toml"
225    {
226        return fail("forbidden source path");
227    }
228    Ok(())
229}
230
231fn has_ancestor(files: &[SourceFile], path: &str) -> bool {
232    path.match_indices('/').any(|(index, _)| {
233        files
234            .binary_search_by(|file| file.path.as_str().cmp(&path[..index]))
235            .is_ok()
236    })
237}
238
239fn required_utf8<'a>(files: &'a [SourceFile], path: &str) -> Result<&'a str, PackageError> {
240    let file = files
241        .iter()
242        .find(|file| file.path == path)
243        .ok_or_else(|| PackageError(format!("missing {path}")))?;
244    std::str::from_utf8(file.bytes()).map_err(|_| PackageError(format!("{path} must be UTF-8")))
245}
246
247fn validate_manifest(id: &LibraryId, source: &str) -> Result<Vec<K1Dependency>, PackageError> {
248    let root: Table = source
249        .parse()
250        .map_err(|cause| PackageError(format!("invalid Cargo.toml: {cause}")))?;
251    let package = root
252        .get("package")
253        .and_then(Value::as_table)
254        .ok_or_else(|| PackageError("missing [package]".into()))?;
255    require_text(package, "name", &id.family.package_name())?;
256    require_text(package, "version", &id.version.to_string())?;
257    require_text(package, "edition", "2024")?;
258    for key in ["autobins", "autoexamples", "autotests", "autobenches"] {
259        if package.get(key) != Some(&Value::Boolean(false)) {
260            return fail(format!("package {key} must be false"));
261        }
262    }
263    if package.contains_key("include")
264        || package.contains_key("exclude")
265        || package.get("publish") == Some(&Value::Boolean(false))
266    {
267        return fail("forbidden package selection or publication setting");
268    }
269    if package
270        .iter()
271        .any(|(key, value)| key != "metadata" && inherits(value))
272        || root.get("lints").is_some_and(inherits)
273    {
274        return fail("workspace inheritance is forbidden");
275    }
276    if root.contains_key("patch") || root.contains_key("replace") {
277        return fail("patch and replace are forbidden");
278    }
279    match root.get("workspace") {
280        Some(Value::Table(table))
281            if table.len() == 1 && table.get("resolver").and_then(Value::as_str) == Some("3") => {}
282        None => {}
283        Some(_) => return fail("workspace may contain only resolver = 3"),
284    }
285    validate_targets(id, &root)?;
286    let mut dependencies = Vec::new();
287    scan_dependency_sets(&root, &mut dependencies)?;
288    if let Some(targets) = root.get("target") {
289        for target in targets
290            .as_table()
291            .ok_or_else(|| PackageError("target must be a table".into()))?
292            .values()
293        {
294            scan_dependency_sets(
295                target
296                    .as_table()
297                    .ok_or_else(|| PackageError("target entry must be a table".into()))?,
298                &mut dependencies,
299            )?;
300        }
301    }
302    dependencies.sort_by(|left, right| {
303        (&left.alias, &left.family, &left.version).cmp(&(
304            &right.alias,
305            &right.family,
306            &right.version,
307        ))
308    });
309    Ok(dependencies)
310}
311
312fn inherits(value: &Value) -> bool {
313    value
314        .as_table()
315        .and_then(|table| table.get("workspace"))
316        .and_then(Value::as_bool)
317        == Some(true)
318}
319
320fn validate_targets(id: &LibraryId, root: &Table) -> Result<(), PackageError> {
321    if ["example", "bench", "test"]
322        .iter()
323        .any(|key| root.contains_key(*key))
324    {
325        return fail("example, bench, and test targets are forbidden");
326    }
327    let has_library = match root.get("lib") {
328        None => false,
329        Some(value) => {
330            let library = value
331                .as_table()
332                .ok_or_else(|| PackageError("lib target must be a table".into()))?;
333            let expected = id.family.logical_name().replace('-', "_");
334            if library.get("name").and_then(Value::as_str) != Some(&expected) {
335                return fail(format!("lib name must equal {expected}"));
336            }
337            true
338        }
339    };
340    let mut bin_names = match root.get("bin") {
341        None => Vec::new(),
342        Some(value) => value
343            .as_array()
344            .ok_or_else(|| PackageError("bin targets must be an array".into()))?
345            .iter()
346            .map(bin_name)
347            .collect::<Result<Vec<_>, _>>()?,
348    };
349    bin_names
350        .iter()
351        .try_for_each(|name| validate_logical_name(name))?;
352    bin_names.sort_unstable();
353    if bin_names.windows(2).any(|pair| pair[0] == pair[1]) {
354        return fail("duplicate bin target name");
355    }
356    if !has_library && bin_names.is_empty() {
357        return fail("package needs a lib or bin target");
358    }
359    Ok(())
360}
361
362fn bin_name(value: &Value) -> Result<&str, PackageError> {
363    let table = value
364        .as_table()
365        .ok_or_else(|| PackageError("bin target must be a table".into()))?;
366    text(table, "name", "bin target needs a name".into())
367}
368
369fn text<'a>(table: &'a Table, key: &str, error: String) -> Result<&'a str, PackageError> {
370    table
371        .get(key)
372        .and_then(Value::as_str)
373        .ok_or(PackageError(error))
374}
375
376fn require_text(table: &Table, key: &str, expected: &str) -> Result<(), PackageError> {
377    if table.get(key).and_then(Value::as_str) != Some(expected) {
378        return fail(format!("package {key} must equal {expected}"));
379    }
380    Ok(())
381}
382
383fn scan_dependency_sets(
384    table: &Table,
385    dependencies: &mut Vec<K1Dependency>,
386) -> Result<(), PackageError> {
387    for section in ["dependencies", "dev-dependencies", "build-dependencies"] {
388        let Some(entries) = table.get(section) else {
389            continue;
390        };
391        for (alias, specification) in entries
392            .as_table()
393            .ok_or_else(|| PackageError(format!("{section} must be a table")))?
394        {
395            scan_dependency(alias, specification, dependencies)?;
396        }
397    }
398    Ok(())
399}
400
401fn scan_dependency(
402    alias: &str,
403    specification: &Value,
404    dependencies: &mut Vec<K1Dependency>,
405) -> Result<(), PackageError> {
406    if let Some(requirement) = specification.as_str() {
407        if alias.starts_with("k1-") {
408            return fail(format!("K1 dependency {alias} needs registry k1"));
409        }
410        return VersionReq::parse(requirement)
411            .map(|_| ())
412            .map_err(|_| PackageError(format!("invalid requirement for {alias}")));
413    }
414    let table = specification
415        .as_table()
416        .ok_or_else(|| PackageError(format!("invalid dependency {alias}")))?;
417    if ["path", "git", "workspace"]
418        .iter()
419        .any(|key| table.contains_key(*key))
420    {
421        return fail(format!("forbidden dependency source for {alias}"));
422    }
423    match table.get("registry") {
424        Some(Value::String(registry)) if registry == "k1" => {
425            let package = text(
426                table,
427                "package",
428                format!("K1 dependency {alias} needs package"),
429            )?;
430            let family = parse_package_name(package)?;
431            let requirement = text(
432                table,
433                "version",
434                format!("K1 dependency {alias} needs version"),
435            )?;
436            dependencies.push(K1Dependency {
437                alias: alias.into(),
438                family,
439                version: parse_exact_version(requirement, alias)?,
440            });
441        }
442        Some(Value::String(_)) => return fail(format!("custom registry forbidden for {alias}")),
443        Some(_) => return fail(format!("invalid registry for {alias}")),
444        None => {
445            let package = table
446                .get("package")
447                .and_then(Value::as_str)
448                .unwrap_or(alias);
449            if package.starts_with("k1-") {
450                return fail(format!("K1 dependency {alias} needs registry k1"));
451            }
452            let requirement = text(
453                table,
454                "version",
455                format!("dependency {alias} needs version"),
456            )?;
457            VersionReq::parse(requirement)
458                .map_err(|_| PackageError(format!("invalid requirement for {alias}")))?;
459        }
460    }
461    Ok(())
462}
463
464fn parse_exact_version(requirement: &str, alias: &str) -> Result<Version, PackageError> {
465    let text = requirement
466        .strip_prefix('=')
467        .ok_or_else(|| PackageError(format!("K1 dependency {alias} needs exact version")))?;
468    let version = Version::parse(text)
469        .map_err(|_| PackageError(format!("invalid K1 version for {alias}")))?;
470    validate_stable(&version)?;
471    if requirement != format!("={version}") {
472        return fail(format!("noncanonical K1 version for {alias}"));
473    }
474    Ok(version)
475}
476
477fn parse_package_name(name: &str) -> Result<LibraryFamily, PackageError> {
478    let rest = name
479        .strip_prefix("k1-")
480        .ok_or_else(|| PackageError("malformed K1 package name".into()))?;
481    if rest.len() < 26 || rest.as_bytes().get(24) != Some(&b'-') {
482        return fail("malformed K1 package name");
483    }
484    let authority =
485        TxId::from_str(&rest[..24]).map_err(|_| PackageError("malformed K1 authority".into()))?;
486    LibraryFamily::new(AuthorityId::new(authority), &rest[25..])
487}
488
489#[cfg(test)]
490mod tests {
491    use super::*;
492
493    const FLAGS: &str = "autobins=false\nautoexamples=false\nautotests=false\nautobenches=false";
494    const LIB: &str = "[lib]\nname='alpha'";
495
496    fn package_with(
497        package_extra: &str,
498        targets: &str,
499        rest: &str,
500        paths: &[&str],
501    ) -> Result<SourcePackage, PackageError> {
502        let family =
503            LibraryFamily::new(AuthorityId::new(TxId::from_bytes([1; 12])), "alpha").unwrap();
504        let id = LibraryId::new(family, Version::new(1, 2, 3)).unwrap();
505        let manifest = format!(
506            "[package]\nname='k1-010101010101010101010101-alpha'\nversion='1.2.3'\nedition='2024'\n{package_extra}\n{targets}\n{rest}"
507        );
508        let mut files = vec![
509            SourceFile::new("Cargo.toml", manifest.into_bytes()),
510            SourceFile::new("Documentation.md", b"docs".to_vec()),
511        ];
512        files.extend(paths.iter().map(|path| SourceFile::new(*path, Vec::new())));
513        SourcePackage::new(id, files)
514    }
515
516    fn package(rest: &str, paths: &[&str]) -> Result<SourcePackage, PackageError> {
517        package_with(FLAGS, LIB, rest, paths)
518    }
519
520    #[test]
521    fn validates_paths_manifests_and_workspaces() {
522        let value = package("", &["z", "a"]).unwrap();
523        assert_eq!(
524            value
525                .files()
526                .iter()
527                .map(SourceFile::path)
528                .collect::<Vec<_>>(),
529            ["Cargo.toml", "Documentation.md", "a", "z"]
530        );
531        for path in [
532            "/x",
533            "a\\b",
534            "a//b",
535            "a/../b",
536            ".cargo/x",
537            "nested/Cargo.toml",
538            "Cargo.lock",
539            "nested/rust-toolchain.future",
540        ] {
541            assert!(package("", &[path]).is_err());
542        }
543        assert!(package("", &["a", "a-b", "a/b"]).is_err());
544        for rest in [
545            "[patch.x]",
546            "[workspace]\nresolver='2'",
547            "[dependencies]\nx={path='x'}",
548            "[dependencies]\nx={registry='other',version='1'}",
549            "[lints]\nworkspace=true",
550        ] {
551            assert!(package(rest, &[]).is_err());
552        }
553        for extra in ["publish=false", "license={workspace=true}"] {
554            assert!(package_with(&format!("{FLAGS}\n{extra}"), LIB, "", &[]).is_err());
555        }
556        assert!(package("[workspace]\nresolver='3'", &[]).is_ok());
557    }
558
559    #[test]
560    fn validates_targets_and_extracts_dependencies() {
561        assert!(package_with(FLAGS, "", "", &[]).is_err());
562        assert!(package_with(FLAGS, "[lib]\nname='wrong'", "", &[]).is_err());
563        assert!(
564            package_with(
565                FLAGS,
566                "[[bin]]\nname='first-bin'\n[[bin]]\nname='second2'",
567                "",
568                &[]
569            )
570            .is_ok()
571        );
572        for targets in [
573            "[[bin]]",
574            "[[bin]]\nname='same'\n[[bin]]\nname='same'",
575            "[[bin]]\nname='Not-Kebab'",
576            "[[example]]\nname='x'",
577            "[[bench]]\nname='x'",
578            "[[test]]\nname='x'",
579        ] {
580            assert!(package_with(FLAGS, targets, "", &[]).is_err());
581        }
582        for flags in [
583            "",
584            "autobins=true",
585            "autoexamples=true",
586            "autotests=true",
587            "autobenches=true",
588        ] {
589            assert!(package_with(flags, LIB, "", &[]).is_err());
590        }
591        let other = "k1-020202020202020202020202-beta";
592        let rest = format!(
593            "[dependencies]\nserde='1'\n[target.'cfg(windows)'.dependencies]\nb={{registry='k1',package='{other}',version='=2.3.4'}}\n[target.'cfg(unix)'.dev-dependencies]\nc={{registry='k1',package='{other}',version='=3.0.0'}}"
594        );
595        let value = package(&rest, &[]).unwrap();
596        let aliases = value
597            .dependencies()
598            .iter()
599            .map(K1Dependency::alias)
600            .collect::<Vec<_>>();
601        assert_eq!(aliases, ["b", "c"]);
602        assert_eq!(
603            (
604                value.dependencies()[0].version(),
605                value.dependencies()[1].version()
606            ),
607            (&Version::new(2, 3, 4), &Version::new(3, 0, 0))
608        );
609    }
610}