Skip to main content

bynk_registry_core/
admit.rs

1//! Admission (first-party registry track ยง3.4): the rules every registry โ€”
2//! the reference server, `bynk registry add`, and the hosted service โ€” applies
3//! to an uploaded archive, in memory.
4//!
5//! [`admit`] unpacks the upload with the hardened [`unpack`], reads its own
6//! `bynk.toml` (which must name a `[package]` with a `version`, and, when the
7//! caller names one, the very `org/package` and version the request named),
8//! refuses path and git dependencies, collects the npm packages its adapters
9//! require, and returns the canonical re-packed archive with its checksum.
10//! What it does not do: sign, check the index for an existing version, or write
11//! anything. The caller owns that I/O.
12
13use std::collections::BTreeSet;
14
15use bynk_project::lock::adapter_requires;
16use bynk_project::manifest::{DependencySource, dependencies_from, package_config_from};
17
18use crate::archive::{Tree, pack, unpack};
19
20/// A dependency or npm requirement in an index line.
21#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
22pub struct Requirement {
23    pub package: String,
24    pub range: String,
25}
26
27/// Why a registry refused an upload: a stable `code` for the publish API's
28/// JSON error, and a message.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct Refusal {
31    pub code: &'static str,
32    pub message: String,
33}
34
35/// An upload that passed admission.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct Admitted {
38    /// `org/package`.
39    pub name: String,
40    pub version: String,
41    /// The canonical archive: the upload's tree, re-packed.
42    pub archive: Vec<u8>,
43    /// The canonical-tree checksum of `archive` (`sha256-<base64>`).
44    pub checksum: String,
45    /// Its registry dependencies, by `version` range.
46    pub deps: Vec<Requirement>,
47    /// The npm packages its adapters require.
48    pub npm: Vec<Requirement>,
49}
50
51fn refuse(message: impl Into<String>) -> Refusal {
52    Refusal {
53        code: "invalid_package",
54        message: message.into(),
55    }
56}
57
58/// Admit `upload`. When `expected` is `Some((name, version))`, the package must
59/// be exactly that.
60pub fn admit(upload: &[u8], expected: Option<(&str, &str)>) -> Result<Admitted, Refusal> {
61    let tree = unpack(upload).map_err(|e| refuse(e.message))?;
62    let text = tree
63        .get("bynk.toml")
64        .and_then(|b| std::str::from_utf8(b).ok())
65        .ok_or_else(|| refuse("the package has no `bynk.toml`"))?;
66    bynk_project::check_manifest_str(text)
67        .map_err(|e| refuse(format!("its `bynk.toml` is invalid: {e}")))?;
68    let doc: toml::Table = text
69        .parse()
70        .map_err(|_| refuse("its `bynk.toml` isn't TOML"))?;
71    let package = doc
72        .get("package")
73        .and_then(|v| v.as_table())
74        .and_then(|t| package_config_from(t).ok())
75        .ok_or_else(|| refuse("it isn't a package: its `bynk.toml` has no `[package]`"))?;
76    let version = package
77        .version
78        .clone()
79        .ok_or_else(|| refuse("its `[package]` has no `version`, which a registry needs"))?;
80    let name = format!("{}/{}", package.org, package.package);
81    if let Some((want_name, want_version)) = expected
82        && (want_name != name || want_version != version)
83    {
84        return Err(refuse(format!(
85            "the upload is `{name}` {version}, but the request named `{want_name}` {want_version}"
86        )));
87    }
88    let mut deps = BTreeSet::new();
89    for spec in specs(&doc, "dependencies") {
90        match spec.source {
91            DependencySource::Version(range) => {
92                deps.insert(Requirement {
93                    package: spec.package,
94                    range,
95                });
96            }
97            _ => {
98                return Err(refuse(format!(
99                    "its dependency `{}` is a path or git dependency, which a registry refuses โ€” publish it, and depend on it by `version`",
100                    spec.alias
101                )));
102            }
103        }
104    }
105    let (archive, checksum) = pack(&tree);
106    Ok(Admitted {
107        name,
108        version,
109        archive,
110        checksum,
111        deps: deps.into_iter().collect(),
112        npm: npm_of(&tree, &doc),
113    })
114}
115
116fn specs(doc: &toml::Table, table: &'static str) -> Vec<bynk_project::manifest::DependencySpec> {
117    doc.get(table)
118        .and_then(|v| v.as_table())
119        .and_then(|t| dependencies_from(table, t).ok())
120        .unwrap_or_default()
121}
122
123/// The npm packages the adapters in `tree` require, for its index line: the
124/// `.bynk` files under the package's source roots (`[paths] include`, or
125/// `src`, never a `tests` tree), skipping hidden directories.
126fn npm_of(tree: &Tree, doc: &toml::Table) -> Vec<Requirement> {
127    let roots = source_roots(doc);
128    let mut out = BTreeSet::new();
129    for (path, bytes) in tree.iter() {
130        if !path.ends_with(".bynk") {
131            continue;
132        }
133        let Some(rest) = roots.iter().find_map(|root| under(path, root)) else {
134            continue;
135        };
136        // A hidden directory below a root is skipped; the file itself isn't.
137        let mut parts: Vec<&str> = rest.split('/').collect();
138        parts.pop();
139        if parts.iter().any(|p| p.starts_with('.')) {
140            continue;
141        }
142        let Ok(text) = std::str::from_utf8(bytes) else {
143            continue;
144        };
145        if let Some((_, requires)) = adapter_requires(text) {
146            for (package, range) in requires {
147                out.insert(Requirement { package, range });
148            }
149        }
150    }
151    out.into_iter().collect()
152}
153
154/// `path` relative to `root` (`""` is the package root), when it is inside it.
155fn under<'a>(path: &'a str, root: &str) -> Option<&'a str> {
156    if root.is_empty() {
157        Some(path)
158    } else {
159        path.strip_prefix(root)?.strip_prefix('/')
160    }
161}
162
163/// The package's source roots as normalised `/`-relative prefixes: its
164/// `[paths] include`, or `src`, without any `tests` tree. An absolute entry or
165/// one climbing out of the package has no files in the archive and is dropped.
166fn source_roots(doc: &toml::Table) -> Vec<String> {
167    let include: Vec<String> = match doc
168        .get("paths")
169        .and_then(|v| v.as_table())
170        .and_then(|t| t.get("include"))
171    {
172        Some(toml::Value::Array(items)) => items
173            .iter()
174            .filter_map(|v| v.as_str().map(str::to_owned))
175            .collect(),
176        Some(toml::Value::String(s)) => vec![s.clone()],
177        _ => vec!["src".to_string()],
178    };
179    let mut out: Vec<String> = Vec::new();
180    for entry in include {
181        let entry = entry.replace('\\', "/");
182        if entry.rsplit('/').find(|p| !p.is_empty()) == Some("tests") {
183            continue;
184        }
185        let Some(root) = normalise(&entry) else {
186            continue;
187        };
188        if !out.contains(&root) {
189            out.push(root);
190        }
191    }
192    out
193}
194
195/// A relative `/`-path with `.` and `..` resolved; `None` if absolute or it
196/// climbs out of the package.
197fn normalise(path: &str) -> Option<String> {
198    if path.starts_with('/') || path.contains(':') {
199        return None;
200    }
201    let mut parts: Vec<&str> = Vec::new();
202    for p in path.split('/') {
203        match p {
204            "" | "." => {}
205            ".." => {
206                parts.pop()?;
207            }
208            p => parts.push(p),
209        }
210    }
211    Some(parts.join("/"))
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217
218    fn upload(files: &[(&str, &str)]) -> Vec<u8> {
219        let tree: Tree = files
220            .iter()
221            .map(|(p, b)| ((*p).to_string(), b.as_bytes().to_vec()))
222            .collect();
223        pack(&tree).0
224    }
225
226    const MANIFEST: &str = "[package]\nname = \"acme/probe\"\nversion = \"1.2.3\"\n";
227
228    #[test]
229    fn a_good_package_is_admitted_with_its_canonical_archive() {
230        let up = upload(&[("bynk.toml", MANIFEST), ("src/a.bynk", "commons a\n")]);
231        let got = admit(&up, Some(("acme/probe", "1.2.3"))).unwrap();
232        assert_eq!(
233            (got.name.as_str(), got.version.as_str()),
234            ("acme/probe", "1.2.3")
235        );
236        assert_eq!(got.archive, up);
237        assert!(got.deps.is_empty() && got.npm.is_empty());
238    }
239
240    #[test]
241    fn a_mismatched_junk_or_path_dependency_upload_is_refused() {
242        let good = upload(&[("bynk.toml", MANIFEST)]);
243        for (up, want) in [
244            (good.clone(), Some(("acme/other", "1.2.3"))),
245            (good, Some(("acme/probe", "9.9.9"))),
246            (b"not an archive".to_vec(), None),
247            (
248                upload(&[(
249                    "bynk.toml",
250                    &format!(
251                        "{MANIFEST}[dependencies]\nh = {{ package = \"acme/h\", path = \"../h\" }}\n"
252                    ),
253                )]),
254                None,
255            ),
256        ] {
257            let err = admit(&up, want).unwrap_err();
258            assert_eq!(err.code, "invalid_package");
259        }
260    }
261
262    #[test]
263    fn source_roots_follow_paths_include_and_skip_tests() {
264        let doc: toml::Table =
265            "[paths]\ninclude = [\"./src/\", \"lib\", \"tests\", \"../x\", \"/abs\"]\n"
266                .parse()
267                .unwrap();
268        assert_eq!(source_roots(&doc), ["src", "lib"]);
269        let doc: toml::Table = "[paths]\ninclude = \".\"\n".parse().unwrap();
270        assert_eq!(source_roots(&doc), [""]);
271    }
272}