1use 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#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
22pub struct Requirement {
23 pub package: String,
24 pub range: String,
25}
26
27#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct Refusal {
31 pub code: &'static str,
32 pub message: String,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct Admitted {
38 pub name: String,
40 pub version: String,
41 pub archive: Vec<u8>,
43 pub checksum: String,
45 pub deps: Vec<Requirement>,
47 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
58pub 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
123fn 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 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
154fn 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
163fn 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
195fn 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}