Skip to content
594 linesCodeBlameRaw
1//! The Composer registry: `g1t.sh/-/composer/<workspace>/`, one per
2//! workspace, built from its repositories (composer.rs says how).
3//!
4//! ```sh
5//! composer config repositories.acme composer https://g1t.sh/-/composer/acme/
6//! composer config --global --auth http-basic.g1t.sh <you> <g1t token>
7//! composer require acme/lib
8//! ```
9//!
10//! Nothing is uploaded. A repository's versions are read again when it is
11//! pushed to (`git.push`), restored, renamed or moved, and once for every
12//! repository by the backfill; a request only reads what that left. A zip
13//! of a commit is made the first time it is asked for, and kept by its
14//! digest like every file here.
15
16use std::collections::{HashMap, HashSet};
17
18use base64::Engine;
19use base64::engine::general_purpose::STANDARD;
20use g1t_contracts::User;
21use g1t_contracts::events::PackageEvent;
22use g1t_contracts::new_id;
23use g1t_contracts::repos::{
24 AllIdsArgs, FileList, IdPage, ListFilesArgs, MAX_LISTED_FILES, MAX_READ_BLOBS, RawBlob, RawBlobsArgs, RawFile, RawFileArgs, RefsArgs,
25 RepoRefs,
26};
27use g1t_kit::now_ms;
28use serde_json::{Value, json};
29use worker::{Context, Headers, Method, Request, Response, Result, Url};
30
31use crate::access::Action;
32use crate::composer::{self, Origin, Version};
33use crate::db::{NewVersion, PackageRow};
34use crate::digest::Digest;
35use crate::oci::{Credentials, origin};
36use crate::store::BlobStore;
37use crate::{Caller, Packages};
38
39const COMPOSER: &str = "composer";
40/// The biggest `composer.json` or README read.
41const MAX_FILE_BYTES: u32 = 1024 * 1024;
42/// The most branches and tags a repository's package lists.
43const MAX_BRANCHES: usize = 50;
44const MAX_TAGS: usize = 300;
45/// The most a zip may hold before it is made, in all and per file.
46const MAX_ARCHIVE_BYTES: u64 = 64 * 1024 * 1024;
47const MAX_ARCHIVED_FILE: u32 = 32 * 1024 * 1024;
48/// Repositories the backfill reads each hour.
49const BACKFILL_PAGE: u32 = 25;
50const README_NAMES: [&str; 4] = ["README.md", "readme.md", "README.markdown", "README"];
51
52fn error(status: u16, message: impl Into<String>) -> Result<Response> {
53 let mut response = Response::from_json(&json!({ "status": "error", "message": message.into() }))?.with_status(status);
54 if status == 401 {
55 response.headers_mut().set("www-authenticate", "Basic realm=\"g1t\"")?;
56 }
57 Ok(response)
58}
59
60/// One of the registry's endpoints, under `/-/composer/<workspace>/`.
61#[derive(Clone, Debug, PartialEq, Eq)]
62pub enum ComposerRoute {
63 Root { workspace: String },
64 /// `p2/<vendor>/<name>.json`, or `~dev.json` for the branches.
65 Metadata { workspace: String, name: String, dev: bool },
66 Dist { workspace: String, name: String, commit: String },
67 Downloads { workspace: String },
68}
69
70pub fn route(path: &str) -> Option<ComposerRoute> {
71 let rest = path.strip_prefix("/-/composer/")?;
72 let (workspace, rest) = rest.split_once('/')?;
73 let workspace = workspace.to_ascii_lowercase();
74 if rest == "packages.json" || rest.is_empty() {
75 return Some(ComposerRoute::Root { workspace });
76 }
77 if rest == "downloads" {
78 return Some(ComposerRoute::Downloads { workspace });
79 }
80 if let Some(file) = rest.strip_prefix("p2/") {
81 let (name, dev) = match file.strip_suffix("~dev.json") {
82 Some(name) => (name, true),
83 None => (file.strip_suffix(".json")?, false),
84 };
85 return composer::valid_name(name).then(|| ComposerRoute::Metadata { workspace, name: name.to_owned(), dev });
86 }
87 let file = rest.strip_prefix("dist/")?;
88 let (name, zip) = file.rsplit_once('/')?;
89 let commit = zip.strip_suffix(".zip")?;
90 (composer::valid_name(name) && commit.len() == 40 && commit.bytes().all(|b| b.is_ascii_hexdigit())).then(|| ComposerRoute::Dist {
91 workspace,
92 name: name.to_owned(),
93 commit: commit.to_ascii_lowercase(),
94 })
95}
96
97/// What a version keeps of its ref, to make its entry from on each read.
98fn stored_metadata(composer_json: &Value, version: &Version, git_ref: &str, default_branch: bool) -> Value {
99 json!({
100 "composer": composer_json,
101 "version_normalized": version.normalized,
102 "ref": git_ref,
103 "default_branch": default_branch,
104 })
105}
106
107/// The versions a repository's refs make: `(version, commit, ref, default)`.
108fn wanted_versions(refs: &[g1t_contracts::repos::GitRefEntry], default_branch: &str) -> Vec<(Version, String, String, bool)> {
109 let mut branches = Vec::new();
110 let mut tags = Vec::new();
111 for entry in refs {
112 if let Some(branch) = entry.name.strip_prefix("refs/heads/") {
113 branches.push((composer::branch_version(branch), entry.commit.clone(), entry.name.clone(), branch == default_branch));
114 } else if let Some(tag) = entry.name.strip_prefix("refs/tags/")
115 && let Some(version) = composer::tag_version(tag)
116 {
117 tags.push((version, entry.commit.clone(), entry.name.clone(), false));
118 }
119 }
120 // The default branch first, then the newest tags.
121 branches.sort_by_key(|(_, _, _, default)| !*default);
122 branches.truncate(MAX_BRANCHES);
123 tags.sort_by(|a, b| b.0.normalized.cmp(&a.0.normalized));
124 tags.truncate(MAX_TAGS);
125 let mut seen = HashSet::new();
126 branches.into_iter().chain(tags).filter(|(v, ..)| seen.insert(v.version.clone())).collect()
127}
128
129impl Packages {
130 pub async fn composer(&self, request: Request, ctx: &Context) -> Result<Response> {
131 let url = request.url()?;
132 let Some(route) = route(url.path()) else {
133 return error(404, "There is nothing at this address.");
134 };
135 match self.composer_route(request, &url, route, ctx).await {
136 Ok(response) => Ok(response),
137 Err(problem) => {
138 worker::console_error!("packages: composer {}: {problem}", url.path());
139 error(500, "Something went wrong on our side. Try again in a moment.")
140 }
141 }
142 }
143
144 async fn composer_route(&self, mut request: Request, url: &Url, route: ComposerRoute, ctx: &Context) -> Result<Response> {
145 let credentials = self.credentials(&request).await?;
146 if matches!(request.method(), Method::Get | Method::Head)
147 && let Some(refused) = self.limited(&request, &credentials, "http-basic credentials").await?
148 {
149 return Ok(refused);
150 }
151 let viewer = match credentials {
152 Credentials::Viewer(viewer) => viewer,
153 Credentials::None => None,
154 Credentials::Token(_) | Credentials::Bad => {
155 return error(401, "The username or token is not right. Use a g1t access token: composer config --auth http-basic.g1t.sh <you> <token>");
156 }
157 };
158 match route {
159 ComposerRoute::Root { workspace } => self.composer_root(&workspace, viewer.as_ref()).await,
160 ComposerRoute::Metadata { workspace, name, dev } => self.composer_metadata(url, &workspace, &name, dev, viewer.as_ref()).await,
161 ComposerRoute::Dist { workspace, name, commit } => self.composer_dist(&workspace, &name, &commit, viewer.as_ref(), ctx).await,
162 ComposerRoute::Downloads { workspace } => {
163 let body: Value = request.json().await.unwrap_or_default();
164 let names: HashSet<&str> = body["downloads"]
165 .as_array()
166 .map(|list| list.iter().filter_map(|d| d["name"].as_str()).collect())
167 .unwrap_or_default();
168 for name in names.into_iter().take(50) {
169 if let Some(package) = self.composer_package(&workspace, name).await? {
170 self.count_download(&package.id, ctx);
171 }
172 }
173 Ok(Response::empty()?.with_status(204))
174 }
175 }
176 }
177
178 async fn composer_package(&self, workspace: &str, name: &str) -> Result<Option<PackageRow>> {
179 Ok(self.db.package(workspace, COMPOSER, name).await?.filter(|p| !p.hidden()))
180 }
181
182 /// The answer when `viewer` may not pull `package`, if they may not.
183 async fn composer_check(&self, viewer: Option<&User>, package: &PackageRow) -> Result<Option<Result<Response>>> {
184 if self.check(viewer, package, Action::Pull).await?.0.allowed {
185 return Ok(None);
186 }
187 Ok(Some(if viewer.is_none() {
188 error(401, "Sign in to install this package: composer config --auth http-basic.g1t.sh <you> <g1t token>")
189 } else {
190 error(404, "Not found: no such package, or you cannot see it.")
191 }))
192 }
193
194 async fn composer_root(&self, workspace: &str, viewer: Option<&User>) -> Result<Response> {
195 let rows = self.db.list(workspace, Some(COMPOSER), None, None, 1000).await?;
196 let packages: Vec<&PackageRow> = rows.iter().map(|row| &row.package).collect();
197 let may = self.may_all(viewer, &packages, Action::Pull).await?;
198 let available: Vec<String> = rows
199 .iter()
200 .zip(may)
201 .filter(|(_, may)| *may)
202 .map(|(row, _)| row.package.name.clone())
203 .collect();
204 Response::from_json(&composer::root(workspace, &available))
205 }
206
207 async fn composer_metadata(&self, url: &Url, workspace: &str, name: &str, dev: bool, viewer: Option<&User>) -> Result<Response> {
208 let Some(package) = self.composer_package(workspace, name).await? else {
209 return error(404, format!("There is no package {name} in {workspace}."));
210 };
211 if let Some(refusal) = self.composer_check(viewer, &package).await? {
212 return refusal;
213 }
214 let base = origin(url);
215 let repo = package.repo_name.clone().unwrap_or_default();
216 let git_url = format!("{base}/{}/{repo}.git", package.workspace);
217 let mut entries = Vec::new();
218 for row in self.db.versions(&package.id, 1000).await? {
219 let meta = row.meta();
220 let version = Version {
221 version: row.version.clone(),
222 normalized: meta["version_normalized"].as_str().unwrap_or(&row.version).to_owned(),
223 };
224 if version.is_dev() != dev {
225 continue;
226 }
227 let dist_url = format!("{base}/-/composer/{}/dist/{name}/{}.zip", package.workspace, row.digest);
228 let origin = Origin {
229 git_url: &git_url,
230 dist_url: &dist_url,
231 commit: &row.digest,
232 default_branch: meta["default_branch"].as_bool().unwrap_or(false),
233 };
234 entries.push(composer::version_entry(&meta["composer"], name, &version, &origin));
235 }
236 let mut response = Response::from_json(&composer::p2(name, &entries))?;
237 response.headers_mut().set("last-modified", &package.updated_at)?;
238 Ok(response)
239 }
240
241 async fn composer_dist(&self, workspace: &str, name: &str, commit: &str, viewer: Option<&User>, ctx: &Context) -> Result<Response> {
242 let Some(package) = self.composer_package(workspace, name).await? else {
243 return error(404, format!("There is no package {name} in {workspace}."));
244 };
245 if let Some(refusal) = self.composer_check(viewer, &package).await? {
246 return refusal;
247 }
248 // Only the commits of its versions: a zip is never made of any
249 // other commit of the repository.
250 let Some(version) = self.db.version_by_digest(&package.id, commit).await? else {
251 return error(404, format!("{commit} is not a version of {name}."));
252 };
253 let blob = match self.db.dist_for_commit(&package.id, commit).await? {
254 Some(blob) => blob,
255 None => match self.build_dist(&package, commit).await? {
256 Ok(blob) => blob,
257 Err(refusal) => return refusal,
258 },
259 };
260 let Some(got) = self.store.get(&blob.object_key, None).await? else {
261 return error(404, "The archive is missing. Try again.");
262 };
263 self.count_version_download(&package.id, &version.id, ctx);
264 let headers = Headers::new();
265 headers.set("content-type", "application/zip")?;
266 headers.set("content-length", &blob.size.to_string())?;
267 headers.set("cache-control", "max-age=31536000")?;
268 Ok(Response::from_body(got.body)?.with_headers(headers))
269 }
270
271 /// Makes the zip of a commit: its files but those `.gitattributes`
272 /// marks `export-ignore`, as `git archive` would leave them out.
273 async fn build_dist(&self, package: &PackageRow, commit: &str) -> Result<std::result::Result<crate::db::BlobRow, Result<Response>>> {
274 let Some(repo_id) = package.repo_id.clone() else {
275 return Ok(Err(error(404, "This package has no repository.")));
276 };
277 let listed: FileList = g1t_kit::call(
278 &self.repos,
279 "list_files",
280 &ListFilesArgs { repo_id: repo_id.clone(), git_ref: Some(commit.to_owned()), skip_dirs: Vec::new(), limit: MAX_LISTED_FILES },
281 )
282 .await?;
283 if listed.truncated {
284 return Ok(Err(error(507, format!("The commit has more than {MAX_LISTED_FILES} files, too many for an archive. Install from source: composer install --prefer-source"))));
285 }
286 let attributes = self.repo_file(&repo_id, commit, ".gitattributes").await?;
287 let ignores = attributes.map(|text| composer::export_ignores(&String::from_utf8_lossy(&text))).unwrap_or_default();
288 let files: Vec<(String, String)> = listed
289 .files
290 .into_iter()
291 .filter_map(|file| Some((file.path, file.hash?)))
292 .filter(|(path, _)| !composer::ignored(&ignores, path))
293 .collect();
294 let mut bytes_of: HashMap<String, Vec<u8>> = HashMap::new();
295 let unique: Vec<String> = files.iter().map(|(_, hash)| hash.clone()).collect::<HashSet<_>>().into_iter().collect();
296 let mut total = 0u64;
297 for chunk in unique.chunks(MAX_READ_BLOBS) {
298 let read: Vec<RawBlob> = g1t_kit::call(
299 &self.repos,
300 "raw_blobs",
301 &RawBlobsArgs { repo_id: repo_id.clone(), hashes: chunk.to_vec(), max_bytes: MAX_ARCHIVED_FILE },
302 )
303 .await?;
304 for blob in read {
305 total += blob.size;
306 if total > MAX_ARCHIVE_BYTES || (blob.data.is_none() && blob.size > 0) {
307 return Ok(Err(error(
308 507,
309 format!("The commit is too large for an archive (over {} MB). Install from source: composer install --prefer-source", MAX_ARCHIVE_BYTES / 1_048_576),
310 )));
311 }
312 let data = blob.data.as_deref().map(|d| STANDARD.decode(d).unwrap_or_default()).unwrap_or_default();
313 bytes_of.insert(blob.hash, data);
314 }
315 }
316 let entries: Vec<(String, Vec<u8>)> = files
317 .into_iter()
318 .map(|(path, hash)| {
319 let data = bytes_of.get(&hash).cloned().unwrap_or_default();
320 (path, data)
321 })
322 .collect();
323 let zip = composer::zip(&entries);
324 let digest = Digest::of(&zip);
325 let size = zip.len() as u64;
326 let now = now_ms();
327 if self.db.blob(&digest).await?.is_none() {
328 self.store.put(&digest.object_key(), zip).await?;
329 }
330 self.db.keep_blob(&package.id, &digest, size, Some("application/zip"), &digest.object_key(), now).await?;
331 self.db.add_dist(&package.id, commit, &digest, size).await?;
332 self.db.measure(&package.workspace).await?;
333 Ok(Ok(crate::db::BlobRow { digest: digest.to_string(), size, media_type: Some("application/zip".into()), object_key: digest.object_key() }))
334 }
335
336 /// A file of a repository at a commit, if it is there and not large.
337 async fn repo_file(&self, repo_id: &str, git_ref: &str, path: &str) -> Result<Option<Vec<u8>>> {
338 let file: Option<RawFile> = g1t_kit::call(
339 &self.repos,
340 "raw_file",
341 &RawFileArgs { repo_id: repo_id.to_owned(), git_ref: git_ref.to_owned(), path: path.to_owned(), max_bytes: MAX_FILE_BYTES },
342 )
343 .await?;
344 Ok(file.and_then(|file| STANDARD.decode(file.data).ok()))
345 }
346
347 /// Reads a repository's Composer package again from its refs: makes it
348 /// when its default branch gained a `composer.json`, records new and
349 /// moved versions, lets go of deleted ones, and deletes the package
350 /// when the repository stopped being one. Says whether it is one.
351 pub(crate) async fn sync_composer(&self, repo_id: &str) -> Result<bool> {
352 let found: Option<RepoRefs> = g1t_kit::call(&self.repos, "refs", &RefsArgs { repo_id: repo_id.to_owned() }).await?;
353 let existing = self.db.package_for_repo(repo_id, COMPOSER).await?;
354 let Some(RepoRefs { repo, refs }) = found else {
355 if let Some(package) = existing {
356 self.drop_composer(&package).await?;
357 }
358 return Ok(false);
359 };
360 let workspace = repo.namespace.to_lowercase();
361 if self.db.workspace_hidden(&workspace).await? {
362 return Ok(false);
363 }
364 let default = refs.iter().find(|r| r.name == format!("refs/heads/{}", repo.default_branch)).map(|r| r.commit.clone());
365 let manifest = match &default {
366 Some(commit) => self.composer_json(repo_id, commit).await?,
367 None => None,
368 };
369 let Some((name, root_manifest)) = manifest else {
370 if let Some(package) = existing {
371 self.drop_composer(&package).await?;
372 }
373 return Ok(false);
374 };
375 // A repository moved to another workspace takes its package along.
376 let existing = match existing {
377 Some(package) if package.workspace != workspace => {
378 self.drop_composer(&package).await?;
379 None
380 }
381 other => other,
382 };
383 let now = now_ms();
384 let package = match existing {
385 Some(package) if package.name == name => package,
386 Some(package) => {
387 if self.db.package(&workspace, COMPOSER, &name).await?.is_some() {
388 worker::console_error!("packages: {workspace}/{} names {name}, which another repository has", repo.name);
389 package
390 } else {
391 self.db.rename_package(&package.id, &name, now).await?;
392 PackageRow { name: name.clone(), ..package }
393 }
394 }
395 None => {
396 if let Some(other) = self.db.package(&workspace, COMPOSER, &name).await?
397 && other.repo_id.as_deref() != Some(repo_id)
398 {
399 worker::console_error!("packages: {workspace}/{} names {name}, which another repository has", repo.name);
400 return Ok(false);
401 }
402 self.db
403 .create_package(&new_id("pkg", now), &workspace, COMPOSER, &name, Some((&repo.id, &repo.name, repo.is_private)), "g1t", now)
404 .await?
405 }
406 };
407
408 // Versions follow git, so g1t records them: what made them is the
409 // push, already in the log as `git.push`.
410 let caller = Caller::system();
411 let wanted = wanted_versions(&refs, &repo.default_branch);
412 let stored = self.db.versions(&package.id, 1000).await?;
413 let mut manifests: HashMap<String, Option<Value>> = HashMap::new();
414 if let Some(commit) = &default {
415 manifests.insert(commit.clone(), Some(root_manifest.clone()));
416 }
417 let mut changed = false;
418 for (version, commit, git_ref, is_default) in &wanted {
419 let current = stored.iter().find(|row| row.version == version.version);
420 if let Some(row) = current
421 && row.digest == *commit
422 && row.meta()["default_branch"].as_bool().unwrap_or(false) == *is_default
423 {
424 continue;
425 }
426 if !manifests.contains_key(commit) {
427 let read = self.composer_json(repo_id, commit).await?.map(|(_, json)| json);
428 manifests.insert(commit.clone(), read);
429 }
430 // A ref without a composer.json of its own is not a version.
431 let Some(Some(json)) = manifests.get(commit) else { continue };
432 self.db
433 .replace_version(
434 &NewVersion {
435 id: new_id("ver", now),
436 package_id: package.id.clone(),
437 version: version.version.clone(),
438 digest: commit.clone(),
439 size: 0,
440 metadata: stored_metadata(json, version, git_ref, *is_default).to_string(),
441 subject: None,
442 published_by: None,
443 files: Vec::new(),
444 },
445 now,
446 )
447 .await?;
448 changed = true;
449 if current.is_none() {
450 let event = PackageEvent { version: Some(version.version.clone()), digest: Some(commit.clone()), ..self.event_of(&package) };
451 self.announce("package.published", &package, event, &caller).await;
452 self.audit(&caller, "package.publish", &package, Some(&format!("{name}@{}", version.version)), None).await;
453 }
454 }
455 let kept: HashSet<&str> = wanted.iter().map(|(v, ..)| v.version.as_str()).collect();
456 for row in stored.iter().filter(|row| !kept.contains(row.version.as_str())) {
457 self.db.delete_version(&row.id).await?;
458 let event = PackageEvent { version: Some(row.version.clone()), digest: Some(row.digest.clone()), ..self.event_of(&package) };
459 self.announce("package.version_deleted", &package, event, &caller).await;
460 self.audit(&caller, "package.delete_version", &package, Some(&format!("{}@{}", package.name, row.version)), None).await;
461 changed = true;
462 }
463 if let Some(commit) = &default {
464 self.composer_readme(&package, repo_id, commit, &root_manifest, now).await?;
465 }
466 if changed {
467 self.db.measure(&workspace).await?;
468 }
469 Ok(true)
470 }
471
472 /// The package's README and description, from the default branch.
473 async fn composer_readme(&self, package: &PackageRow, repo_id: &str, commit: &str, manifest: &Value, now: u64) -> Result<()> {
474 let mut readme = None;
475 for name in README_NAMES {
476 if let Some(bytes) = self.repo_file(repo_id, commit, name).await? {
477 readme = Some(bytes);
478 break;
479 }
480 }
481 let digest = match readme.filter(|b| !b.is_empty()) {
482 Some(bytes) => {
483 let digest = Digest::of(&bytes);
484 if self.db.blob(&digest).await?.is_none() {
485 self.store.put(&digest.object_key(), bytes.clone()).await?;
486 }
487 self.db.keep_blob(&package.id, &digest, bytes.len() as u64, Some("text/markdown"), &digest.object_key(), now).await?;
488 Some(digest.to_string())
489 }
490 None => None,
491 };
492 self.db.set_readme(&package.id, digest.as_deref(), manifest["description"].as_str(), now).await
493 }
494
495 /// A commit's `composer.json`, when it has one naming a valid package.
496 async fn composer_json(&self, repo_id: &str, commit: &str) -> Result<Option<(String, Value)>> {
497 let Some(bytes) = self.repo_file(repo_id, commit, "composer.json").await? else {
498 return Ok(None);
499 };
500 let Ok(json) = serde_json::from_slice::<Value>(&bytes) else {
501 return Ok(None);
502 };
503 let Some(name) = json["name"].as_str().map(str::to_lowercase).filter(|n| composer::valid_name(n)) else {
504 return Ok(None);
505 };
506 Ok(Some((name, json)))
507 }
508
509 async fn drop_composer(&self, package: &PackageRow) -> Result<()> {
510 self.db.delete_package(&package.id).await?;
511 self.db.measure(&package.workspace).await?;
512 let caller = Caller::system();
513 self.announce("package.deleted", package, self.event_of(package), &caller).await;
514 self.audit(&caller, "package.delete", package, Some(&package.name), None).await;
515 Ok(())
516 }
517
518 /// Deletes the Composer package built from a deleted repository.
519 pub(crate) async fn composer_repo_gone(&self, repo_id: &str) -> Result<()> {
520 if let Some(package) = self.db.package_for_repo(repo_id, COMPOSER).await? {
521 self.drop_composer(&package).await?;
522 }
523 Ok(())
524 }
525
526 /// Reads a page of repositories the backfill has not yet, until it has
527 /// read them all once. Says how many were packages.
528 pub(crate) async fn composer_backfill(&self) -> Result<u32> {
529 let (after, finished) = self.db.backfill().await?;
530 if finished {
531 return Ok(0);
532 }
533 let page: IdPage = g1t_kit::call(&self.repos, "all_ids", &AllIdsArgs { after: after.clone(), limit: BACKFILL_PAGE }).await?;
534 let mut found = 0;
535 for id in &page.ids {
536 match self.sync_composer(id).await {
537 Ok(true) => found += 1,
538 Ok(false) => {}
539 Err(error) => worker::console_error!("packages: composer backfill of {id}: {error}"),
540 }
541 }
542 let last = page.ids.last().cloned().or(after);
543 self.db.set_backfill(last.as_deref(), page.next.is_none(), now_ms()).await?;
544 Ok(found)
545 }
546}
547
548#[cfg(test)]
549mod tests {
550 use super::*;
551 use g1t_contracts::repos::GitRefEntry;
552
553 #[test]
554 fn every_endpoint_is_routed() {
555 let commit = "a".repeat(40);
556 assert_eq!(route("/-/composer/acme/packages.json"), Some(ComposerRoute::Root { workspace: "acme".into() }));
557 assert_eq!(route("/-/composer/acme/"), Some(ComposerRoute::Root { workspace: "acme".into() }));
558 assert_eq!(
559 route("/-/composer/acme/p2/acme/lib.json"),
560 Some(ComposerRoute::Metadata { workspace: "acme".into(), name: "acme/lib".into(), dev: false })
561 );
562 assert_eq!(
563 route("/-/composer/acme/p2/acme/lib~dev.json"),
564 Some(ComposerRoute::Metadata { workspace: "acme".into(), name: "acme/lib".into(), dev: true })
565 );
566 assert_eq!(
567 route(&format!("/-/composer/acme/dist/acme/lib/{commit}.zip")),
568 Some(ComposerRoute::Dist { workspace: "acme".into(), name: "acme/lib".into(), commit: commit.clone() })
569 );
570 assert_eq!(route("/-/composer/acme/downloads"), Some(ComposerRoute::Downloads { workspace: "acme".into() }));
571 assert_eq!(route("/-/composer/acme/p2/Acme/lib.json"), None);
572 assert_eq!(route("/-/composer/acme/dist/acme/lib/short.zip"), None);
573 assert_eq!(route("/-/composer/acme"), None);
574 }
575
576 #[test]
577 fn a_repositorys_refs_make_its_versions() {
578 let entry = |name: &str, commit: &str| GitRefEntry { name: name.into(), commit: commit.into() };
579 let refs = [
580 entry("refs/heads/feature", "f"),
581 entry("refs/heads/main", "m"),
582 entry("refs/tags/v1.0.0", "a"),
583 entry("refs/tags/v1.1.0", "b"),
584 entry("refs/tags/nightly", "n"),
585 ];
586 let wanted = wanted_versions(&refs, "main");
587 let names: Vec<(&str, &str, bool)> = wanted.iter().map(|(v, c, _, d)| (v.version.as_str(), c.as_str(), *d)).collect();
588 assert_eq!(
589 names,
590 [("dev-main", "m", true), ("dev-feature", "f", false), ("v1.1.0", "b", false), ("v1.0.0", "a", false)],
591 "the default branch first, newest tags next, tags that are not versions left out"
592 );
593 }
594}