Skip to content
459 linesCodeBlameRaw
1//! The Cargo registry: `g1t.sh/-/cargo/<workspace>/`, a sparse registry
2//! for each workspace. `.cargo/config.toml` names it, and `cargo login`
3//! keeps a g1t token for it:
4//!
5//! ```toml
6//! [registries.acme]
7//! index = "sparse+https://g1t.sh/-/cargo/acme/index/"
8//! ```
9//!
10//! Cargo sends the token as the whole `Authorization` header, with no
11//! scheme. The index is made from the versions on each read; a `.crate`
12//! is stored once, by its SHA-256, which is also its index `cksum`.
13
14use g1t_contracts::User;
15use g1t_contracts::events::PackageEvent;
16use g1t_contracts::new_id;
17use g1t_kit::now_ms;
18use serde_json::{Value, json};
19use worker::{Context, Headers, Method, Request, Response, ResponseBody, Result, Url};
20
21use crate::access::Action;
22use crate::cargo::{self, CargoRoute};
23use crate::db::{NewFile, NewVersion, PackageRow, VersionRow};
24use crate::digest::Digest;
25use crate::npm;
26use crate::oci::{Credentials, origin, published_by};
27use crate::store::BlobStore;
28use crate::{Caller, Packages, TargetOf, token};
29
30const CARGO: &str = "cargo";
31/// The most versions an index file lists.
32const MAX_VERSIONS: u32 = 5000;
33/// The longest README kept for a crate's page.
34const MAX_README_BYTES: usize = 1024 * 1024;
35/// The most crates `cargo search` is answered with.
36const MAX_SEARCH: u32 = 100;
37const DOCS: &str = "https://docs.g1t.sh/guides/cargo/";
38const TOKENS: &str = "https://g1t.sh/settings/tokens";
39
40/// Cargo's error shape: `{"errors": [{"detail": "..."}]}`, which it prints.
41/// A 401 says where to get a token, which cargo shows beside its own hint.
42fn error(status: u16, message: impl Into<String>) -> Result<Response> {
43 let mut response = Response::from_json(&json!({ "errors": [{ "detail": message.into() }] }))?.with_status(status);
44 if status == 401 {
45 response.headers_mut().set("www-authenticate", &format!("Cargo login_url=\"{TOKENS}\""))?;
46 }
47 Ok(response)
48}
49
50fn ok() -> Result<Response> {
51 Response::from_json(&json!({ "ok": true }))
52}
53
54fn not_found() -> Result<Response> {
55 error(404, "Not found: no such crate, or you cannot see it. Private crates need a token: cargo login --registry <workspace>.")
56}
57
58fn sign_in(workspace: &str) -> String {
59 format!("Sign in to use this registry: cargo login --registry {workspace}, with a g1t access token from {TOKENS}")
60}
61
62impl Packages {
63 /// Answers a Cargo request.
64 pub async fn cargo(&self, request: Request, ctx: &Context) -> Result<Response> {
65 let url = request.url()?;
66 let Some((workspace, route)) = cargo::route(url.path()) else {
67 return error(404, "There is nothing at this address.");
68 };
69 match self.cargo_route(request, &url, &workspace, route, ctx).await {
70 Ok(response) => Ok(response),
71 Err(problem) => {
72 worker::console_error!("packages: cargo {}: {problem}", url.path());
73 error(500, "Something went wrong on our side. Try again in a moment.")
74 }
75 }
76 }
77
78 /// Who the request is from: cargo's bare token, a `Bearer` one, or
79 /// Basic credentials with a g1t token as the password.
80 async fn cargo_credentials(&self, request: &Request) -> Result<Credentials> {
81 let Some(header) = request.headers().get("authorization")? else {
82 return Ok(Credentials::None);
83 };
84 let viewer = if let Some((username, secret)) = token::basic(&header) {
85 self.viewer_for(&username, &secret).await?
86 } else if let Some(token) = cargo::token(&header) {
87 self.viewer_for("token", token).await?
88 } else {
89 None
90 };
91 Ok(match viewer {
92 Some(user) => Credentials::Viewer(Some(user)),
93 None => Credentials::Bad,
94 })
95 }
96
97 async fn cargo_route(&self, mut request: Request, url: &Url, workspace: &str, route: CargoRoute, ctx: &Context) -> Result<Response> {
98 let method = request.method();
99 let credentials = self.cargo_credentials(&request).await?;
100 if matches!(method, Method::Get | Method::Head)
101 && let Some(refused) = self.limited(&request, &credentials, &format!("a token (cargo login --registry {workspace})")).await?
102 {
103 return Ok(refused);
104 }
105 let viewer = match credentials {
106 Credentials::Viewer(viewer) => viewer,
107 Credentials::None => None,
108 Credentials::Token(_) | Credentials::Bad => {
109 return error(
110 401,
111 format!("The token is not right, or has expired. Make an access token at {TOKENS}, then: cargo login --registry {workspace}"),
112 );
113 }
114 };
115 let viewer = viewer.as_ref();
116 let read = matches!(method, Method::Get | Method::Head);
117 match route {
118 CargoRoute::Config if read => self.cargo_config(url, workspace, viewer).await,
119 CargoRoute::Index { name } if read => self.cargo_index(&request, workspace, &name, viewer).await,
120 CargoRoute::Download { name, version } if read => self.crate_download(workspace, &name, &version, viewer, method == Method::Head, ctx).await,
121 CargoRoute::Search if read => self.cargo_search(url, workspace, viewer).await,
122 CargoRoute::Publish if method == Method::Put => self.cargo_publish(&mut request, workspace, viewer).await,
123 CargoRoute::Yank { name, version } if method == Method::Delete => self.cargo_yank(workspace, &name, &version, true, viewer).await,
124 CargoRoute::Unyank { name, version } if method == Method::Put => self.cargo_yank(workspace, &name, &version, false, viewer).await,
125 CargoRoute::Owners { .. } => error(
126 400,
127 "Crate owners are not kept here: who may publish a crate is decided by its repository's roles, or the workspace's. See https://docs.g1t.sh/guides/packages/#who-can-see-and-publish-a-package",
128 ),
129 _ => error(405, "Not a method this address takes."),
130 }
131 }
132
133 /// The crate, by its name in any case, if its workspace is not deleted.
134 async fn crate_package(&self, workspace: &str, name: &str) -> Result<Option<PackageRow>> {
135 Ok(self.db.package_any_case(workspace, CARGO, name).await?.filter(|p| !p.hidden()))
136 }
137
138 /// Whether `viewer` may `action` the crate, as the answer when not: 401
139 /// for someone not signed in who may not read it, 404 for anyone else
140 /// who may not read it, and 403 with the reason for one who may.
141 async fn cargo_check(&self, viewer: Option<&User>, package: &PackageRow, action: Action) -> Result<Option<Result<Response>>> {
142 let (decision, readable) = self.check(viewer, package, action).await?;
143 if decision.allowed {
144 return Ok(None);
145 }
146 if !readable && viewer.is_none() {
147 return Ok(Some(error(401, sign_in(&package.workspace))));
148 }
149 if !readable {
150 return Ok(Some(not_found()));
151 }
152 Ok(Some(error(403, decision.reason.unwrap_or_else(|| "Not allowed.".to_owned()))))
153 }
154
155 /// `index/config.json`. Signed in, cargo is told to send its token with
156 /// every request. Anonymous requests to a workspace that has private
157 /// crates get a 401, which makes cargo ask again with its token; one
158 /// with only public crates is open to anyone.
159 async fn cargo_config(&self, url: &Url, workspace: &str, viewer: Option<&User>) -> Result<Response> {
160 if viewer.is_none() && self.db.has_private(workspace, CARGO).await? {
161 return error(401, sign_in(workspace));
162 }
163 let base = format!("{}/-/cargo/{workspace}", origin(url));
164 let mut response = Response::from_json(&cargo::config(&base, viewer.is_some()))?;
165 response.headers_mut().set("cache-control", "no-cache")?;
166 Ok(response)
167 }
168
169 /// A crate's index file: one line per version, oldest first.
170 async fn cargo_index(&self, request: &Request, workspace: &str, name: &str, viewer: Option<&User>) -> Result<Response> {
171 let Some(package) = self.crate_package(workspace, name).await? else {
172 return not_found();
173 };
174 if let Some(refusal) = self.cargo_check(viewer, &package, Action::Pull).await? {
175 return refusal;
176 }
177 let mut versions = self.db.versions(&package.id, MAX_VERSIONS).await?;
178 if versions.is_empty() {
179 return not_found();
180 }
181 versions.reverse();
182 let mut body = String::new();
183 for version in &versions {
184 body.push_str(&cargo::index_line(&version.meta(), version.is_yanked()));
185 body.push('\n');
186 }
187 let etag = format!("\"{}\"", &Digest::of(body.as_bytes()).hex()[..32]);
188 let headers = Headers::new();
189 headers.set("content-type", "text/plain; charset=utf-8")?;
190 headers.set("cache-control", "no-cache")?;
191 headers.set("etag", &etag)?;
192 if request.headers().get("if-none-match")?.is_some_and(|sent| sent == etag) {
193 return Ok(Response::empty()?.with_status(304).with_headers(headers));
194 }
195 Ok(Response::ok(body)?.with_headers(headers))
196 }
197
198 async fn crate_download(&self, workspace: &str, name: &str, version: &str, viewer: Option<&User>, head: bool, ctx: &Context) -> Result<Response> {
199 let Some(package) = self.crate_package(workspace, name).await? else {
200 return not_found();
201 };
202 if let Some(refusal) = self.cargo_check(viewer, &package, Action::Pull).await? {
203 return refusal;
204 }
205 let gone = || error(404, format!("{name}@{version} is not there."));
206 let Some(row) = self.db.version_named(&package.id, version).await? else {
207 return gone();
208 };
209 let Some(digest) = Digest::parse(&row.digest) else {
210 return gone();
211 };
212 let Some(blob) = self.db.package_blob(&package.id, &digest).await? else {
213 return gone();
214 };
215 let headers = Headers::new();
216 headers.set("content-type", "application/gzip")?;
217 headers.set("content-length", &blob.size.to_string())?;
218 headers.set("cache-control", "max-age=31536000")?;
219 if head {
220 return Ok(Response::from_body(ResponseBody::Empty)?.with_headers(headers));
221 }
222 let Some(got) = self.store.get(&blob.object_key, None).await? else {
223 return gone();
224 };
225 self.count_version_download(&package.id, &row.id, ctx);
226 Ok(Response::from_body(got.body)?.with_headers(headers))
227 }
228
229 /// `cargo search`: the workspace's crates the viewer may see, by name.
230 async fn cargo_search(&self, url: &Url, workspace: &str, viewer: Option<&User>) -> Result<Response> {
231 let query = url.query_pairs().find(|(k, _)| k == "q").map(|(_, v)| v.into_owned()).unwrap_or_default();
232 let per_page = url
233 .query_pairs()
234 .find(|(k, _)| k == "per_page")
235 .and_then(|(_, v)| v.parse::<u32>().ok())
236 .unwrap_or(10)
237 .clamp(1, MAX_SEARCH);
238 let rows = self.db.list(workspace, Some(CARGO), None, Some(&query), MAX_SEARCH).await?;
239 let packages: Vec<&PackageRow> = rows.iter().map(|row| &row.package).collect();
240 let may = self.may_all(viewer, &packages, Action::Pull).await?;
241 let visible: Vec<_> = rows.iter().zip(may).filter(|(_, may)| *may).map(|(row, _)| row).collect();
242 let crates: Vec<Value> = visible
243 .iter()
244 .take(per_page as usize)
245 .map(|row| {
246 json!({
247 "name": row.package.name,
248 "max_version": crate::db::latest_shown(row).unwrap_or_default(),
249 "description": row.package.description,
250 })
251 })
252 .collect();
253 Response::from_json(&json!({ "crates": crates, "meta": { "total": visible.len() } }))
254 }
255
256 /// The package publishing makes, linked to the repository the crate's
257 /// `repository` names on g1t, or else the one named like it.
258 async fn cargo_target(&self, workspace: &str, name: &str, metadata: &Value) -> Result<TargetOf> {
259 let named = npm::repository_of(&metadata["repository"], &self.host)
260 .filter(|(owner, _)| owner == workspace)
261 .map(|(_, repo)| repo);
262 let lower = name.to_ascii_lowercase();
263 let dashed = lower.replace('_', "-");
264 let mut repo = None;
265 for candidate in named.iter().map(String::as_str).chain([lower.as_str(), dashed.as_str()]) {
266 if let Some(found) = self.repo_by_name(workspace, candidate).await? {
267 repo = Some(found);
268 break;
269 }
270 }
271 Ok(TargetOf::unmade(workspace, name, repo.map(|r| (r.id, r.name, r.is_private))))
272 }
273
274 /// `cargo publish`: the metadata and the `.crate` in one body.
275 async fn cargo_publish(&self, request: &mut Request, workspace: &str, viewer: Option<&User>) -> Result<Response> {
276 let declared = request.headers().get("content-length")?.and_then(|n| n.parse::<u64>().ok());
277 let too_large = || {
278 let mb = self.max_request / 1_000_000;
279 error(413, format!("A publish may be at most {mb} MB, the .crate file and its metadata together. See {DOCS}#size"))
280 };
281 if declared.is_some_and(|n| n > self.max_request) {
282 return too_large();
283 }
284 let bytes = request.bytes().await?;
285 if bytes.len() as u64 > self.max_request {
286 return too_large();
287 }
288 let (metadata, krate) = match cargo::parse_publish(&bytes) {
289 Ok(parts) => parts,
290 Err(message) => return error(400, message),
291 };
292 let name = metadata["name"].as_str().unwrap_or("").to_owned();
293 if let Err(message) = cargo::valid_name(&name) {
294 return error(400, message);
295 }
296 let version = metadata["vers"].as_str().unwrap_or("").to_owned();
297 if !npm::valid_version(&version) {
298 return error(400, format!("{version} is not a semver version."));
299 }
300 if krate.is_empty() {
301 return error(400, "The .crate file is empty.");
302 }
303 let digest = Digest::of(krate);
304 let entry = match cargo::index_entry(&metadata, digest.hex()) {
305 Ok(entry) => entry,
306 Err(message) => return error(400, message),
307 };
308
309 // A name is taken whatever its case, and `-` and `_` are one.
310 let found = self.db.package_folded(workspace, CARGO, &cargo::folded(&name)).await?;
311 if let Some(found) = &found {
312 if found.hidden() {
313 return error(403, Packages::hidden_refusal(found));
314 }
315 if found.name != name {
316 return error(400, format!("The name {name} is taken by the crate {}. Publish it under that name.", found.name));
317 }
318 } else if self.db.workspace_hidden(workspace).await? {
319 return error(403, format!("The workspace {workspace} is deleted; nothing can be published to it."));
320 }
321 let mut target = match &found {
322 Some(package) => TargetOf::package(package),
323 None => self.cargo_target(workspace, &name, &metadata).await?,
324 };
325 let decision = self.decide(viewer, &mut target, Action::Push).await?;
326 if !decision.allowed {
327 if viewer.is_none() {
328 return error(401, sign_in(workspace));
329 }
330 let readable = found.is_none() || self.decide(viewer, &mut target, Action::Pull).await?.allowed;
331 if !readable {
332 return not_found();
333 }
334 return error(403, decision.reason.unwrap_or_else(|| "Not allowed.".to_owned()));
335 }
336 let caller = Caller::of(viewer);
337 let package = match found {
338 Some(package) => package,
339 None => {
340 self.make_package(
341 workspace,
342 CARGO,
343 &name,
344 target.repo.as_ref().map(|(id, repo, private)| (id.as_str(), repo.as_str(), *private)),
345 &caller,
346 now_ms(),
347 )
348 .await?
349 }
350 };
351 let existing = self.db.versions(&package.id, MAX_VERSIONS).await?;
352 if let Some(taken) = existing.iter().find(|v| cargo::without_build(&v.version) == cargo::without_build(&version)) {
353 return error(
354 400,
355 format!("{name}@{} is already published, and a version is published once. Bump the version in Cargo.toml.", taken.version),
356 );
357 }
358 // A deleted version's number is not published again until it is purged.
359 let deleted = self.db.deleted_versions(&package.id, MAX_VERSIONS).await?;
360 if let Some(taken) = deleted.iter().find(|v| cargo::without_build(&v.version) == cargo::without_build(&version))
361 && let Some(refused) = self.reserved_refusal(&package, &taken.version).await?
362 {
363 return error(400, refused);
364 }
365
366 let size = krate.len() as u64;
367 if let Some(refusal) = self.storage_refusal(&package, &[(digest.to_string(), size)]).await? {
368 return error(403, refusal);
369 }
370 let now = now_ms();
371 let stored = match self.db.blob(&digest).await? {
372 Some(blob) => self.store.head(&blob.object_key).await?.is_some(),
373 None => false,
374 };
375 if !stored {
376 self.store.put(&digest.object_key(), krate.to_vec()).await?;
377 }
378 self.db
379 .keep_blob(&package.id, &digest, size, Some("application/gzip"), &digest.object_key(), now)
380 .await?;
381 self.db
382 .publish(
383 NewVersion {
384 id: new_id("ver", now),
385 package_id: package.id.clone(),
386 version: version.clone(),
387 digest: digest.to_string(),
388 size,
389 metadata: entry.to_string(),
390 subject: None,
391 published_by: published_by(&caller),
392 files: vec![NewFile {
393 name: "crate".to_owned(),
394 digest: digest.to_string(),
395 size,
396 media_type: Some("application/gzip".to_owned()),
397 }],
398 },
399 None,
400 now,
401 )
402 .await?;
403 // The README and description the crate's page shows: the highest
404 // stable version's, so a pre-release does not replace them.
405 let highest = std::iter::once(version.as_str())
406 .chain(existing.iter().map(|v| v.version.as_str()))
407 .collect::<Vec<_>>()
408 .join("\n");
409 if crate::db::newest_version(&highest).as_deref() == Some(version.as_str()) {
410 let readme = metadata["readme"].as_str().unwrap_or("").trim();
411 let readme_digest = if readme.is_empty() || readme.len() > MAX_README_BYTES {
412 None
413 } else {
414 let bytes = readme.as_bytes().to_vec();
415 let digest = Digest::of(&bytes);
416 if self.db.blob(&digest).await?.is_none() {
417 self.store.put(&digest.object_key(), bytes.clone()).await?;
418 }
419 self.db
420 .keep_blob(&package.id, &digest, bytes.len() as u64, Some("text/markdown"), &digest.object_key(), now)
421 .await?;
422 Some(digest.to_string())
423 };
424 self.db.set_readme(&package.id, readme_digest.as_deref(), metadata["description"].as_str(), now).await?;
425 }
426 self.db.measure(&package.workspace).await?;
427 let event = PackageEvent {
428 version: Some(version.clone()),
429 digest: Some(digest.to_string()),
430 size: Some(size),
431 ..self.event_of(&package)
432 };
433 self.announce("package.published", &package, event, &caller).await;
434 self.audit(&caller, "package.publish", &package, Some(&format!("{workspace}/{name}@{version}")), None).await;
435 Response::from_json(&json!({ "warnings": { "invalid_categories": [], "invalid_badges": [], "other": [] } }))
436 }
437
438 /// `cargo yank` and `cargo yank --undo`: the version stays, for
439 /// lockfiles that name it, but is no longer picked for new ones.
440 async fn cargo_yank(&self, workspace: &str, name: &str, version: &str, yank: bool, viewer: Option<&User>) -> Result<Response> {
441 let Some(package) = self.crate_package(workspace, name).await? else {
442 return not_found();
443 };
444 if let Some(refusal) = self.cargo_check(viewer, &package, Action::Push).await? {
445 return refusal;
446 }
447 let Some(row): Option<VersionRow> = self.db.version_named(&package.id, version).await? else {
448 return error(404, format!("{name}@{version} is not there."));
449 };
450 if row.is_yanked() != yank {
451 self.db.set_yanked(&row.id, yank).await?;
452 self.db.touch_package(&package.id, now_ms()).await?;
453 let caller = Caller::of(viewer);
454 let action = if yank { "package.yank" } else { "package.unyank" };
455 self.audit(&caller, action, &package, Some(&format!("{workspace}/{}@{version}", package.name)), None).await;
456 }
457 ok()
458 }
459}