g1t/services/packages/src/composer.rs

471 lines19,278 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Composer from the workspace's own repositories, and go get from g1t.sh1//! What the Composer registry needs that does not touch the network:
2//! package names, versions read from tags and branches as Composer reads
3//! them, the metadata Composer installs from, `export-ignore`, and zips.
4//!
5//! A Composer package is not uploaded: it is a repository of the workspace
6//! with a `composer.json` at its root. Each tag that reads as a version is a
7//! version, and each branch a `dev-` one, with the metadata of that ref's
8//! `composer.json`, a git source on g1t.sh, and a zip of the commit as its
9//! dist, made when it is first asked for.
10
11use serde_json::{Map, Value, json};
12
13/// Composer's rule for a package name: `vendor/name`, lowercase, words
14/// joined by `.`, `_` or `-`.
15pub fn valid_name(name: &str) -> bool {
16 let Some((vendor, project)) = name.split_once('/') else {
17 return false;
18 };
19 let part = |text: &str, double_dash: bool| {
20 let bytes = text.as_bytes();
21 if bytes.is_empty() || !bytes[0].is_ascii_alphanumeric() || !bytes[bytes.len() - 1].is_ascii_alphanumeric() {
22 return false;
23 }
24 let mut run = String::new();
25 for byte in bytes {
26 if byte.is_ascii_lowercase() || byte.is_ascii_digit() {
27 if !run.is_empty() && !(run == "." || run == "_" || run == "-" || (double_dash && run == "--")) {
28 return false;
29 }
30 run.clear();
31 } else if matches!(byte, b'.' | b'_' | b'-') {
32 run.push(*byte as char);
33 } else {
34 return false;
35 }
36 }
37 true
38 };
39 !project.contains('/') && part(vendor, false) && part(project, true) && name.len() <= 200
40}
41
42/// A version Composer can install, as Packagist names it: the tag or
43/// branch's own `version`, and `version_normalized` to compare by.
44#[derive(Clone, Debug, PartialEq, Eq)]
45pub struct Version {
46 pub version: String,
47 pub normalized: String,
48}
49
50impl Version {
51 pub fn is_dev(&self) -> bool {
52 self.normalized.starts_with("dev-") || self.normalized.ends_with("-dev")
53 }
54}
55
56/// The stability words Composer reads after a version, as it writes them.
57fn stability(word: &str) -> Option<&'static str> {
58 match word.to_ascii_lowercase().as_str() {
59 "stable" => Some(""),
60 "beta" | "b" => Some("beta"),
61 "rc" => Some("RC"),
62 "alpha" | "a" => Some("alpha"),
63 "patch" | "pl" | "p" => Some("patch"),
64 _ => None,
65 }
66}
67
68/// A tag's version, the way Composer's version parser reads it:
69/// `v1.2.3`, `1.2`, `1.0.0-beta.2`, `2.0.0-RC1`. Anything else is not a
70/// version, and its tag is left out.
71pub fn tag_version(tag: &str) -> Option<Version> {
72 let text = tag.strip_prefix('v').or_else(|| tag.strip_prefix('V')).unwrap_or(tag);
73 let digits_end = text.find(|c: char| !(c.is_ascii_digit() || c == '.')).unwrap_or(text.len());
74 let (numbers, rest) = text.split_at(digits_end);
75 let numbers = numbers.trim_end_matches('.');
76 let parts: Vec<&str> = numbers.split('.').collect();
77 if parts.is_empty() || parts.len() > 4 || parts.iter().any(|p| p.is_empty() || p.len() > 9) {
78 return None;
79 }
80 if numbers.len() != text[..digits_end].len() && !rest.is_empty() {
81 // `1.2.` followed by more is not a version.
82 return None;
83 }
84 let mut normalized: Vec<String> = parts.iter().map(|p| p.trim_start_matches('0').to_owned()).map(|p| if p.is_empty() { "0".into() } else { p }).collect();
85 while normalized.len() < 4 {
86 normalized.push("0".into());
87 }
88 let mut normalized = normalized.join(".");
89 let rest = rest.trim_start_matches(['-', '_', '.']);
90 if !rest.is_empty() {
91 let (word, tail) = rest.split_at(rest.find(|c: char| !c.is_ascii_alphabetic()).unwrap_or(rest.len()));
92 let word = stability(word)?;
93 let number = tail.trim_start_matches(['-', '.']);
94 let (number, dev) = match number.strip_suffix("dev") {
95 Some(n) => (n.trim_end_matches(['-', '.']), true),
96 None => (number, false),
97 };
98 if !number.bytes().all(|b| b.is_ascii_digit() || b == b'.') {
99 return None;
100 }
101 if !word.is_empty() {
102 normalized.push('-');
103 normalized.push_str(word);
104 normalized.push_str(&number.replace('.', ""));
105 }
106 if dev {
107 normalized.push_str("-dev");
108 }
109 }
110 Some(Version { version: tag.to_owned(), normalized })
111}
112
113/// A branch's version: `dev-<branch>`, or for a branch named like a
114/// version (`1.x`, `2.1`) that version's `-dev`, as Composer reads them.
115pub fn branch_version(branch: &str) -> Version {
116 let text = branch.strip_prefix('v').unwrap_or(branch);
117 let parts: Vec<&str> = text.split('.').collect();
118 let numeric = !text.is_empty()
119 && parts.len() <= 4
120 && parts[0].bytes().all(|b| b.is_ascii_digit())
121 && !parts[0].is_empty()
122 && parts.iter().all(|p| !p.is_empty() && (p.bytes().all(|b| b.is_ascii_digit()) || matches!(*p, "x" | "X" | "*")));
123 if !numeric {
124 return Version { version: format!("dev-{branch}"), normalized: format!("dev-{branch}") };
125 }
126 let mut nines: Vec<String> = parts
127 .iter()
128 .map(|p| if p.bytes().all(|b| b.is_ascii_digit()) { (*p).to_owned() } else { "9999999".to_owned() })
129 .collect();
130 while nines.len() < 4 {
131 nines.push("9999999".to_owned());
132 }
133 let version = if parts.last().is_some_and(|p| matches!(*p, "x" | "X" | "*")) {
134 format!("{branch}-dev")
135 } else {
136 format!("{branch}.x-dev")
137 };
138 Version { version, normalized: format!("{}-dev", nines.join(".")) }
139}
140
141/// The fields of `composer.json` a version's metadata keeps, as Packagist
142/// serves them.
143const KEPT: [&str; 25] = [
144 "name",
145 "description",
146 "keywords",
147 "homepage",
148 "readme",
149 "license",
150 "authors",
151 "type",
152 "support",
153 "funding",
154 "autoload",
155 "autoload-dev",
156 "extra",
157 "bin",
158 "include-path",
159 "target-dir",
160 "require",
161 "require-dev",
162 "suggest",
163 "provide",
164 "replace",
165 "conflict",
166 "archive",
167 "abandoned",
168 "notification-url",
169];
170
171/// Where one version installs from.
172pub struct Origin<'a> {
173 /// `https://g1t.sh/acme/lib.git`.
174 pub git_url: &'a str,
175 /// `https://g1t.sh/-/composer/acme/dist/acme/lib/<commit>.zip`.
176 pub dist_url: &'a str,
177 pub commit: &'a str,
178 /// Set on the default branch's version, as Composer 2 marks it.
179 pub default_branch: bool,
180}
181
182/// A version's metadata: its `composer.json`, kept fields only, named
183/// `name`, with its version, source and dist.
184pub fn version_entry(composer: &Value, name: &str, version: &Version, origin: &Origin<'_>) -> Value {
185 let mut entry = Map::new();
186 if let Value::Object(fields) = composer {
187 for key in KEPT {
188 if let Some(value) = fields.get(key) {
189 entry.insert(key.to_owned(), value.clone());
190 }
191 }
192 }
193 // A single license is a list of one, as Packagist writes it.
194 if let Some(Value::String(license)) = entry.get("license") {
195 let license = license.clone();
196 entry.insert("license".into(), json!([license]));
197 }
198 entry.entry("type").or_insert(json!("library"));
199 entry.insert("name".into(), json!(name));
200 entry.insert("version".into(), json!(version.version));
201 entry.insert("version_normalized".into(), json!(version.normalized));
202 entry.insert("source".into(), json!({ "type": "git", "url": origin.git_url, "reference": origin.commit }));
203 entry.insert(
204 "dist".into(),
205 json!({ "type": "zip", "url": origin.dist_url, "reference": origin.commit, "shasum": "" }),
206 );
207 if origin.default_branch {
208 entry.insert("default-branch".into(), json!(true));
209 }
210 Value::Object(entry)
211}
212
213/// `p2/<vendor>/<name>.json`: every version, as Composer reads them
214/// (not minified: each entry whole).
215pub fn p2(name: &str, versions: &[Value]) -> Value {
216 json!({ "packages": { name: versions } })
217}
218
219/// `packages.json` of a workspace.
220pub fn root(workspace: &str, available: &[String]) -> Value {
221 json!({
222 "packages": [],
223 "metadata-url": format!("/-/composer/{workspace}/p2/%package%.json"),
224 "available-packages": available,
225 "notify-batch": format!("/-/composer/{workspace}/downloads"),
226 })
227}
228
229/// Whether `pattern` matches `text`, `*` any run of characters but `/`,
230/// `?` any one.
231fn glob(pattern: &[u8], text: &[u8]) -> bool {
232 match (pattern.first(), text.first()) {
233 (None, None) => true,
234 (Some(b'*'), _) => {
235 glob(&pattern[1..], text) || (!text.is_empty() && text[0] != b'/' && glob(pattern, &text[1..]))
236 }
237 (Some(b'?'), Some(c)) if *c != b'/' => glob(&pattern[1..], &text[1..]),
238 (Some(p), Some(c)) if p == c => glob(&pattern[1..], &text[1..]),
239 _ => false,
240 }
241}
242
243/// The patterns a `.gitattributes` marks `export-ignore`.
244pub fn export_ignores(gitattributes: &str) -> Vec<String> {
245 gitattributes
246 .lines()
247 .filter_map(|line| {
248 let line = line.trim();
249 if line.starts_with('#') {
250 return None;
251 }
252 let mut words = line.split_whitespace();
253 let pattern = words.next()?;
254 words.any(|attr| attr == "export-ignore").then(|| pattern.to_owned())
255 })
256 .collect()
257}
258
259/// Whether a file is left out of an archive by an `export-ignore`
260/// pattern: a pattern without `/` matches a name at any depth, one with a
261/// `/` matches from the root, and a directory's pattern everything in it.
262pub fn ignored(patterns: &[String], path: &str) -> bool {
263 patterns.iter().any(|pattern| {
264 let (pattern, dir_only) = match pattern.strip_suffix('/') {
265 Some(p) => (p, true),
266 None => (pattern.as_str(), false),
267 };
268 let anchored = pattern.contains('/');
269 let pattern = pattern.trim_start_matches('/');
270 let parts: Vec<&str> = path.split('/').collect();
271 // Each directory the file is in, and (unless only directories
272 // match) the file itself.
273 let candidates = (1..=parts.len()).filter(|n| !dir_only || *n < parts.len());
274 for n in candidates {
275 let prefix = parts[..n].join("/");
276 let subject = if anchored { prefix.as_str() } else { parts[n - 1] };
277 if glob(pattern.as_bytes(), subject.as_bytes()) {
278 return true;
279 }
280 }
281 false
282 })
283}
284
285/// CRC-32, as zip records each file's.
286fn crc32(bytes: &[u8]) -> u32 {
287 let mut table = [0u32; 256];
288 for (i, entry) in table.iter_mut().enumerate() {
289 let mut c = i as u32;
290 for _ in 0..8 {
291 c = if c & 1 != 0 { 0xEDB8_8320 ^ (c >> 1) } else { c >> 1 };
292 }
293 *entry = c;
294 }
295 let mut crc = 0xFFFF_FFFFu32;
296 for byte in bytes {
297 crc = table[((crc ^ u32::from(*byte)) & 0xFF) as usize] ^ (crc >> 8);
298 }
299 !crc
300}
301
302/// A zip of `files` (path and bytes), deflated where that is smaller. No
303/// directory entries, a fixed time, so the same files make the same zip.
304pub fn zip(files: &[(String, Vec<u8>)]) -> Vec<u8> {
305 let mut out = Vec::new();
306 let mut central = Vec::new();
307 for (path, data) in files {
308 let crc = crc32(data);
309 let deflated = miniz_oxide::deflate::compress_to_vec(data, 6);
310 let (method, body): (u16, &[u8]) = if deflated.len() < data.len() { (8, &deflated) } else { (0, data) };
311 let offset = out.len() as u32;
312 let name = path.as_bytes();
313 let header = |sig: u32, central_entry: bool| {
314 let mut h = Vec::with_capacity(46 + name.len());
315 h.extend_from_slice(&sig.to_le_bytes());
316 if central_entry {
317 h.extend_from_slice(&0x031Eu16.to_le_bytes()); // made by: Unix, 3.0
318 }
319 h.extend_from_slice(&20u16.to_le_bytes()); // version needed
320 h.extend_from_slice(&0x0800u16.to_le_bytes()); // UTF-8 names
321 h.extend_from_slice(&method.to_le_bytes());
322 h.extend_from_slice(&0u16.to_le_bytes()); // time
323 h.extend_from_slice(&0x0021u16.to_le_bytes()); // date: 1980-01-01
324 h.extend_from_slice(&crc.to_le_bytes());
325 h.extend_from_slice(&(body.len() as u32).to_le_bytes());
326 h.extend_from_slice(&(data.len() as u32).to_le_bytes());
327 h.extend_from_slice(&(name.len() as u16).to_le_bytes());
328 h.extend_from_slice(&0u16.to_le_bytes()); // extra
329 if central_entry {
330 h.extend_from_slice(&0u16.to_le_bytes()); // comment
331 h.extend_from_slice(&0u16.to_le_bytes()); // disk
332 h.extend_from_slice(&0u16.to_le_bytes()); // internal attributes
333 h.extend_from_slice(&(0o100644u32 << 16).to_le_bytes());
334 h.extend_from_slice(&offset.to_le_bytes());
335 }
336 h.extend_from_slice(name);
337 h
338 };
339 out.extend_from_slice(&header(0x0403_4b50, false));
340 out.extend_from_slice(body);
341 central.extend_from_slice(&header(0x0201_4b50, true));
342 }
343 let central_offset = out.len() as u32;
344 out.extend_from_slice(&central);
345 out.extend_from_slice(&0x0605_4b50u32.to_le_bytes());
346 out.extend_from_slice(&[0, 0, 0, 0]); // disks
347 out.extend_from_slice(&(files.len() as u16).to_le_bytes());
348 out.extend_from_slice(&(files.len() as u16).to_le_bytes());
349 out.extend_from_slice(&(central.len() as u32).to_le_bytes());
350 out.extend_from_slice(&central_offset.to_le_bytes());
351 out.extend_from_slice(&0u16.to_le_bytes());
352 out
353}
354
355#[cfg(test)]
356mod tests {
357 use super::*;
358
359 #[test]
360 fn names_follow_composers_rule() {
361 for good in ["acme/lib", "acme-co/http.client", "a1/b_2", "acme/my--lib"] {
362 assert!(valid_name(good), "{good}");
363 }
364 for bad in ["acme", "Acme/lib", "acme/lib/x", "-acme/lib", "acme/lib-", "acme/l b", "acme/a..b", "ac--me/lib"] {
365 assert!(!valid_name(bad), "{bad}");
366 }
367 }
368
369 #[test]
370 fn tags_read_as_composer_reads_them() {
371 let v = |tag: &str| tag_version(tag).map(|v| v.normalized);
372 assert_eq!(v("v1.2.3").as_deref(), Some("1.2.3.0"));
373 assert_eq!(v("1.2").as_deref(), Some("1.2.0.0"));
374 assert_eq!(v("2.0.0-RC1").as_deref(), Some("2.0.0.0-RC1"));
375 assert_eq!(v("1.0.0-beta.2").as_deref(), Some("1.0.0.0-beta2"));
376 assert_eq!(v("1.0.0-alpha").as_deref(), Some("1.0.0.0-alpha"));
377 assert_eq!(v("1.0.0-p1").as_deref(), Some("1.0.0.0-patch1"));
378 assert_eq!(v("1.0.0-stable").as_deref(), Some("1.0.0.0"));
379 assert_eq!(v("01.02.003").as_deref(), Some("1.2.3.0"));
380 assert_eq!(tag_version("v1.0.0").unwrap().version, "v1.0.0", "the tag's own name is the version");
381 for bad in ["latest", "release-1", "1.0.0-nope", "1.2.3.4.5", "v", ""] {
382 assert_eq!(tag_version(bad), None, "{bad}");
383 }
384 assert!(!tag_version("1.0.0").unwrap().is_dev());
385 }
386
387 #[test]
388 fn branches_are_dev_versions() {
389 assert_eq!(branch_version("main"), Version { version: "dev-main".into(), normalized: "dev-main".into() });
390 assert_eq!(
391 branch_version("1.x"),
392 Version { version: "1.x-dev".into(), normalized: "1.9999999.9999999.9999999-dev".into() }
393 );
394 assert_eq!(
395 branch_version("2.1"),
396 Version { version: "2.1.x-dev".into(), normalized: "2.1.9999999.9999999-dev".into() }
397 );
398 assert_eq!(branch_version("feature/x").version, "dev-feature/x");
399 assert!(branch_version("main").is_dev());
400 }
401
402 #[test]
403 fn a_versions_metadata_is_its_composer_json_with_where_it_installs_from() {
404 let composer = json!({
405 "name": "acme/lib",
406 "description": "A library",
407 "license": "MIT",
408 "require": { "php": ">=8.1" },
409 "autoload": { "psr-4": { "Acme\\Lib\\": "src/" } },
410 "scripts": { "test": "phpunit" },
411 "config": { "sort-packages": true },
412 });
413 let version = tag_version("v1.0.0").unwrap();
414 let origin = Origin {
415 git_url: "https://g1t.sh/acme/lib.git",
416 dist_url: "https://g1t.sh/-/composer/acme/dist/acme/lib/abc.zip",
417 commit: "abc",
418 default_branch: false,
419 };
420 let entry = version_entry(&composer, "acme/lib", &version, &origin);
421 assert_eq!(entry["version"], "v1.0.0");
422 assert_eq!(entry["version_normalized"], "1.0.0.0");
423 assert_eq!(entry["license"], json!(["MIT"]));
424 assert_eq!(entry["type"], "library");
425 assert_eq!(entry["require"]["php"], ">=8.1");
426 assert_eq!(entry["autoload"]["psr-4"]["Acme\\Lib\\"], "src/");
427 assert_eq!(entry["source"], json!({ "type": "git", "url": "https://g1t.sh/acme/lib.git", "reference": "abc" }));
428 assert_eq!(entry["dist"]["type"], "zip");
429 assert_eq!(entry["dist"]["reference"], "abc");
430 assert!(entry.get("scripts").is_none(), "only what installs");
431 assert!(entry.get("config").is_none());
432 assert!(entry.get("default-branch").is_none());
433 let p2 = p2("acme/lib", &[entry]);
434 assert_eq!(p2["packages"]["acme/lib"][0]["version"], "v1.0.0");
435 let root = root("acme", &["acme/lib".to_owned()]);
436 assert_eq!(root["metadata-url"], "/-/composer/acme/p2/%package%.json");
437 assert_eq!(root["available-packages"], json!(["acme/lib"]));
438 }
439
440 #[test]
441 fn export_ignore_leaves_files_out_as_git_archive_does() {
442 let patterns = export_ignores("# dev only\n/tests export-ignore\n.github/ export-ignore\n*.md export-ignore\n/phpunit.xml.dist export-ignore\nsrc/* text\n");
443 assert_eq!(patterns, ["/tests", ".github/", "*.md", "/phpunit.xml.dist"]);
444 assert!(ignored(&patterns, "tests/LibTest.php"));
445 assert!(ignored(&patterns, ".github/workflows/ci.yml"));
446 assert!(ignored(&patterns, "README.md"));
447 assert!(ignored(&patterns, "docs/guide.md"));
448 assert!(ignored(&patterns, "phpunit.xml.dist"));
449 assert!(!ignored(&patterns, "src/Lib.php"));
450 assert!(!ignored(&patterns, "src/tests/Helper.php"), "/tests is anchored at the root");
451 assert!(!ignored(&patterns, "composer.json"));
452 }
453
454 #[test]
455 fn a_zip_holds_its_files_and_their_checksums() {
456 assert_eq!(crc32(b"123456789"), 0xCBF4_3926);
457 let files = vec![
458 ("composer.json".to_owned(), br#"{"name":"acme/lib"}"#.to_vec()),
459 ("src/Lib.php".to_owned(), "<?php\n".repeat(100).into_bytes()),
460 ];
461 let zip = zip(&files);
462 assert_eq!(&zip[..4], &0x0403_4b50u32.to_le_bytes());
463 // The end record counts both files.
464 let end = &zip[zip.len() - 22..];
465 assert_eq!(&end[..4], &0x0605_4b50u32.to_le_bytes());
466 assert_eq!(u16::from_le_bytes([end[10], end[11]]), 2);
467 // The repetitive file was deflated, the short one stored.
468 assert!(zip.len() < 600, "{}", zip.len());
469 assert_eq!(super::zip(&files), zip, "the same files make the same zip");
470 }
471}