chadtmiller.com/knotless
git that lives in your Atmosphere account
1
10
knotless-serve answers Cargo's registry for a repository's crates
Only what this pull adds. A release not made says why, rather than always that it exists landed as e784063, so its changes do not show here again.
.changeset/cargo-registry.mdnew+7−0
| @@ -0,0 +1,7 @@ | ||
--- | ||
default: minor | ||
--- | ||
| ||
# A repository's crates can be published and used with Cargo | ||
| ||
knotless-serve answers Cargo's registry protocol for each repository, so `cargo publish`, `cargo yank` and `cargo search` work with `index = "sparse+https://knotless.dev/cargo/<did>/<repo key>/"`, and the token is `<handle>:<app password>`. | ||
crates/knotless/src/records.rs+12−0
| @@ -981,12 +981,24 @@ pub struct ReleaseRecord { | ||
pub notes: Option<String>, | ||
pub files: Vec<ReleaseFile>, | ||
pub created_at: String, | ||
/// What Cargo's registry says of it, if it is a crate. | ||
#[serde(default, skip_serializing_if = "Option::is_none")] | ||
pub cargo: Option<CargoCrate>, | ||
/// Whether its owner yanked it: read from its | ||
/// [`nsid::RELEASE_YANK`] record, and no part of this one. | ||
#[serde(skip)] | ||
pub yanked: bool, | ||
} | ||
| ||
/// A release as a crate: its file is `<name>-<version>.crate`. | ||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] | ||
pub struct CargoCrate { | ||
/// Its line of Cargo's index, as JSON, but for `yanked`. | ||
pub index: String, | ||
#[serde(default, skip_serializing_if = "Option::is_none")] | ||
pub description: Option<String>, | ||
} | ||
| ||
/// That its owner yanked a release, keyed as the release is, which never | ||
/// changes: deleting this undoes it. | ||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] | ||
crates/knotless/src/release.rs+19−4
| @@ -148,6 +148,7 @@ pub async fn publish(ctx: &Ctx, url: &RepoUrl, spec: Publish) -> Result<String> | ||
commit: spec.commit, | ||
check: spec.check, | ||
notes: spec.notes, | ||
cargo: None, | ||
}; | ||
target.create(&auth, files, made).await | ||
} | ||
| @@ -184,6 +185,7 @@ pub struct Made { | ||
pub commit: Option<String>, | ||
pub check: Option<String>, | ||
pub notes: Option<String>, | ||
pub cargo: Option<records::CargoCrate>, | ||
} | ||
| ||
/// Where a version of a package is to be published: a public repository, | ||
| @@ -248,6 +250,7 @@ impl Target { | ||
notes: made.notes, | ||
files, | ||
created_at: records::now(), | ||
cargo: made.cargo, | ||
yanked: false, | ||
}; | ||
let typed = records::typed(nsid::RELEASE, &record); | ||
| @@ -280,16 +283,27 @@ pub async fn yank( | ||
undo: bool, | ||
) -> Result<String> { | ||
let remote = Remote::open(ctx, url).await?; | ||
let auth = session::ensure(ctx, &remote.identity).await?.auth(); | ||
yank_as(&remote, &auth, package, version, undo).await | ||
} | ||
| ||
/// [`yank`], with `auth`, the owner's authority. | ||
pub async fn yank_as( | ||
remote: &Remote, | ||
auth: &Auth, | ||
package: &str, | ||
version: &str, | ||
undo: bool, | ||
) -> Result<String> { | ||
let Some(repo) = remote.repo().await? else { | ||
bail!("{url}: no such repository"); | ||
bail!("{}: no such repository", remote.url); | ||
}; | ||
let key = repo.value.key; | ||
let rkey = keys::release_rkey(&key, package, version); | ||
let found = remote.get_record::<ReleaseRecord>(nsid::RELEASE, &rkey); | ||
if found.await?.is_none() { | ||
bail!("{url} has published no {package} {version}"); | ||
bail!("{} has published no {package} {version}", remote.url); | ||
} | ||
let auth = session::ensure(ctx, &remote.identity).await?.auth(); | ||
let held = remote.get_record::<ReleaseYankRecord>(nsid::RELEASE_YANK, &rkey); | ||
if held.await?.is_some() == undo { | ||
let write = if undo { | ||
| @@ -302,7 +316,7 @@ pub async fn yank( | ||
let typed = records::typed(nsid::RELEASE_YANK, &record); | ||
remote::create(nsid::RELEASE_YANK, &rkey, typed) | ||
}; | ||
remote.apply(vec![write], &auth).await?; | ||
remote.apply(vec![write], auth).await?; | ||
} | ||
Ok(format!("at://{}/{}/{rkey}", remote.did(), nsid::RELEASE)) | ||
} | ||
| @@ -477,6 +491,7 @@ mod tests { | ||
notes: None, | ||
files: Vec::new(), | ||
created_at: "2026-10-27T00:00:00.000Z".into(), | ||
cargo: None, | ||
yanked: false, | ||
} | ||
} | ||
crates/knotless-serve/src/web/cargo.rsnew+663−0
663 lines changed: not loaded until asked for.
crates/knotless-serve/src/web/releases.rs+2−1
| @@ -276,7 +276,7 @@ fn released(app: &App, repo: &Repo, rest: &str) -> Option<(String, ReleaseFile)> | ||
/// What a release's file `f` is sent with: as bytes to save, never a page | ||
/// of the site; checked again each time, so a takedown holds at once; and | ||
/// `etag`, its hash, for a cache to ask by. | ||
fn sending(f: &ReleaseFile, etag: String) -> [(HeaderName, String); 6] { | ||
pub(super) fn sending(f: &ReleaseFile, etag: String) -> [(HeaderName, String); 6] { | ||
let says = |s: &str| s.to_string(); | ||
let saved = format!("attachment; filename=\"{}\"", f.name); | ||
[ | ||
| @@ -320,6 +320,7 @@ mod tests { | ||
notes: None, | ||
files: files.collect(), | ||
created_at: "2026-10-01T00:00:00Z".into(), | ||
cargo: None, | ||
yanked: false, | ||
}; | ||
( | ||
crates/knotless-serve/src/web/steps.rs+1−0
| @@ -268,6 +268,7 @@ mod tests { | ||
notes: None, | ||
files: Vec::new(), | ||
created_at: String::new(), | ||
cargo: None, | ||
yanked: false, | ||
}; | ||
(v.into(), record, Value::Null) | ||
crates/knotless-serve/src/web.rs+20−0
| @@ -47,6 +47,7 @@ mod account_settings; | ||
mod actor; | ||
mod archive; | ||
mod badges; | ||
mod cargo; | ||
mod controls; | ||
mod delegation; | ||
mod feed; | ||
| @@ -274,6 +275,25 @@ pub fn routes() -> Router<Arc<App>> { | ||
"/{authority}/{repo}/releases/download/{*rest}", | ||
get(releases::download), | ||
) | ||
.route("/cargo/{did}/{key}/config.json", get(cargo::config)) | ||
.route("/cargo/{did}/{key}/api/v1/crates", get(cargo::search)) | ||
.route( | ||
"/cargo/{did}/{key}/api/v1/crates/new", | ||
axum::routing::put(cargo::publish), | ||
) | ||
.route( | ||
"/cargo/{did}/{key}/api/v1/crates/{name}/{version}/yank", | ||
axum::routing::delete(cargo::yank), | ||
) | ||
.route( | ||
"/cargo/{did}/{key}/api/v1/crates/{name}/{version}/unyank", | ||
axum::routing::put(cargo::unyank), | ||
) | ||
.route( | ||
"/cargo/{did}/{key}/dl/{name}/{version}/download", | ||
get(cargo::download), | ||
) | ||
.route("/cargo/{did}/{key}/{*path}", get(cargo::index)) | ||
.route("/{authority}/{repo}/archive/{*rest}", get(archive::archive)) | ||
.merge(compressed) | ||
.layer(middleware::from_fn(renamed)) | ||
crates/knotless-serve/tests/web.rs+362−0
| @@ -3212,6 +3212,368 @@ fn delete_record(owner: &Sandbox, collection: &str, rkey: &str) { | ||
assert!(deleted.status().is_success()); | ||
} | ||
| ||
/// Real Cargo, with a home of its own, knowing nothing but `registries`. | ||
struct Cargo { | ||
home: std::path::PathBuf, | ||
token: String, | ||
} | ||
| ||
impl Cargo { | ||
fn run(&self, dir: &std::path::Path, args: &[&str]) -> (bool, String) { | ||
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".into()); | ||
let mut command = std::process::Command::new(cargo); | ||
for (var, _) in std::env::vars() { | ||
// What building this test set, as a coverage run does. | ||
let building = ["CARGO", "RUSTC", "RUSTFLAGS", "LLVM_", "RUSTDOC"]; | ||
if building.iter().any(|b| var.starts_with(b)) { | ||
command.env_remove(var); | ||
} | ||
} | ||
let out = command | ||
.args(args) | ||
.current_dir(dir) | ||
.env("CARGO_HOME", &self.home) | ||
.env("CARGO_TARGET_DIR", dir.join("target")) | ||
.env("CARGO_TERM_COLOR", "never") | ||
.env("CARGO_REGISTRIES_HERE_TOKEN", &self.token) | ||
.output() | ||
.unwrap(); | ||
let said = String::from_utf8_lossy(&out.stderr).into_owned(); | ||
( | ||
out.status.success(), | ||
said + &String::from_utf8_lossy(&out.stdout), | ||
) | ||
} | ||
| ||
fn ok(&self, dir: &std::path::Path, args: &[&str]) -> String { | ||
let (ok, said) = self.run(dir, args); | ||
assert!(ok, "cargo {args:?}: {said}"); | ||
said | ||
} | ||
} | ||
| ||
/// A crate's directory, of `toml` after its `[package]`, and `lib` or `main`. | ||
fn cargo_crate( | ||
root: &std::path::Path, | ||
name: &str, | ||
version: &str, | ||
toml: &str, | ||
file: &str, | ||
code: &str, | ||
) -> std::path::PathBuf { | ||
let dir = root.join(format!("{name}-{version}")); | ||
std::fs::create_dir_all(dir.join("src")).unwrap(); | ||
let manifest = format!( | ||
"[package]\nname = \"{name}\"\nversion = \"{version}\"\nedition = \"2021\"\ndescription = \"says {name}\"\nlicense = \"MIT\"\n{toml}\n" | ||
); | ||
std::fs::write(dir.join("Cargo.toml"), manifest).unwrap(); | ||
std::fs::write(dir.join("src").join(file), code).unwrap(); | ||
dir | ||
} | ||
| ||
#[test] | ||
fn a_cargo_registry() { | ||
use serde_json::json; | ||
| ||
const PUBLISH: [&str; 4] = ["publish", "--registry", "here", "--allow-dirty"]; | ||
let site = serve(true); | ||
let owner = person(account(), &site); | ||
let handle = owner.account.handle.clone(); | ||
let url = owner.url("crates"); | ||
ok(&owner, &["repo", "create", &url]); | ||
let work = owner.work("w", 1); | ||
owner.git(&work, &["push", "-q", &url, "main"]); | ||
let did = owner.account.did.clone(); | ||
let key = owner.record("dev.knotless.repo", "crates")["key"] | ||
.as_str() | ||
.unwrap() | ||
.to_string(); | ||
let here = format!("/cargo/{did}/{key}"); | ||
wait_until(|| site.status(&format!("{here}/config.json")).0 == 200); | ||
| ||
let config = site.json(&format!("{here}/config.json")); | ||
assert_eq!( | ||
config["api"].as_str().unwrap(), | ||
format!("{}{here}", site.base) | ||
); | ||
assert_eq!( | ||
config["dl"].as_str().unwrap(), | ||
format!("{}{here}/dl/{{crate}}/{{version}}/download", site.base) | ||
); | ||
| ||
let root = owner.path("cargo"); | ||
let home = root.join("home"); | ||
std::fs::create_dir_all(&home).unwrap(); | ||
std::fs::write( | ||
home.join("config.toml"), | ||
format!( | ||
"[registries.here]\nindex = \"sparse+{}{here}/\"\n", | ||
site.base | ||
), | ||
) | ||
.unwrap(); | ||
let cargo = Cargo { | ||
home, | ||
token: format!("{handle}:{}", owner.account.password), | ||
}; | ||
| ||
// A crate, and one depending on it, renamed and with a namespaced feature. | ||
let leaf = cargo_crate( | ||
&root, | ||
"leaf", | ||
"1.0.0", | ||
"[features]\nnothing = []\n", | ||
"lib.rs", | ||
"pub fn says() -> u8 { 1 }\n", | ||
); | ||
cargo.ok(&leaf, &PUBLISH); | ||
let extra = cargo_crate( | ||
&root, | ||
"extra", | ||
"1.0.0", | ||
"", | ||
"lib.rs", | ||
"pub fn says() -> u8 { 2 }\n", | ||
); | ||
cargo.ok(&extra, &PUBLISH); | ||
let mid = cargo_crate( | ||
&root, | ||
"mid", | ||
"0.2.0", | ||
"[dependencies]\nnamed = { package = \"leaf\", version = \"1\", registry = \"here\" }\nextra = { version = \"1\", registry = \"here\", optional = true }\n[features]\nwith-extra = [\"dep:extra\"]\n", | ||
"lib.rs", | ||
"pub fn says() -> u8 { named::says() + 40 }\n", | ||
); | ||
let said = cargo.ok(&mid, &PUBLISH); | ||
// Found at once, with no wait for the firehose. | ||
assert!(!said.contains("timed out"), "{said}"); | ||
assert!(said.contains("Published mid v0.2.0"), "{said}"); | ||
| ||
// The index's line and a read of it, by its ETag. | ||
let line = site.status(&format!("{here}/3/m/mid")).1; | ||
let line: Value = serde_json::from_str(line.trim()).unwrap(); | ||
assert_eq!( | ||
(line["vers"].as_str(), line["v"].as_i64()), | ||
(Some("0.2.0"), Some(2)) | ||
); | ||
assert_eq!(line["yanked"], false); | ||
let index = format!("{}{here}/3/m/mid", site.base); | ||
let first = reqwest::blocking::get(&index).unwrap(); | ||
let etag = first.headers()["etag"].to_str().unwrap().to_string(); | ||
let again = reqwest::blocking::Client::new() | ||
.get(&index) | ||
.header("if-none-match", &etag) | ||
.send() | ||
.unwrap(); | ||
assert_eq!(again.status(), 304); | ||
assert_eq!(site.status(&format!("{here}/m/id/mid")).0, 404); | ||
assert_eq!(site.status(&format!("{here}/no/ne/none")).0, 404); | ||
assert_eq!( | ||
site.status(&format!("{here}/dl/none/1.0.0/download")).0, | ||
404 | ||
); | ||
| ||
// Used by something that names only the registry, from nothing kept. | ||
let app_toml = "[dependencies]\nmid = { version = \"0.2\", registry = \"here\", features = [\"with-extra\"] }\n"; | ||
let app = cargo_crate( | ||
&root, | ||
"app", | ||
"0.0.1", | ||
app_toml, | ||
"main.rs", | ||
"fn main() { println!(\"{}\", mid::says()); }\n", | ||
); | ||
let said = cargo.ok(&app, &["run", "-q"]); | ||
assert!(said.contains("41"), "{said}"); | ||
| ||
// Refused: what is there, a token that is no good, another's, or none. | ||
let (done, said) = cargo.run(&leaf, &PUBLISH); | ||
assert!(!done && said.contains("already exists"), "{said}"); | ||
let other = cargo_crate(&root, "other", "1.0.0", "", "lib.rs", ""); | ||
let denied = |token: &str| { | ||
let cargo = Cargo { | ||
home: cargo.home.clone(), | ||
token: token.into(), | ||
}; | ||
let (done, said) = cargo.run(&other, &PUBLISH); | ||
assert!(!done && said.contains("403"), "{said}"); | ||
said | ||
}; | ||
assert!(denied(&format!("{handle}:wrong")).contains("could not log in")); | ||
assert!(denied("nocolon").contains("log in: cargo login")); | ||
let stranger = account(); | ||
let theirs = format!("{}:{}", stranger.handle, stranger.password); | ||
assert!(denied(&theirs).contains("only ")); | ||
| ||
// What Cargo would never send is refused too. | ||
let put = |crate_: &[u8], said: Value, token: &str| { | ||
let said = said.to_string(); | ||
let mut body = (said.len() as u32).to_le_bytes().to_vec(); | ||
body.extend(said.as_bytes()); | ||
body.extend((crate_.len() as u32).to_le_bytes()); | ||
body.extend(crate_); | ||
reqwest::blocking::Client::new() | ||
.put(format!("{}{here}/api/v1/crates/new", site.base)) | ||
.header("authorization", token) | ||
.body(body) | ||
.send() | ||
.unwrap() | ||
}; | ||
let packed = |dir: &std::path::Path, name: &str, version: &str| { | ||
cargo.ok( | ||
dir, | ||
&[ | ||
"package", | ||
"--allow-dirty", | ||
"--no-verify", | ||
"--registry", | ||
"here", | ||
], | ||
); | ||
std::fs::read( | ||
dir.join("target/package") | ||
.join(format!("{name}-{version}.crate")), | ||
) | ||
.unwrap() | ||
}; | ||
let token = cargo.token.as_str(); | ||
let crate_ = packed(&other, "other", "1.0.0"); | ||
let says = |res: reqwest::blocking::Response| (res.status().as_u16(), res.text().unwrap()); | ||
let meta = |name: &str, version: &str| json!({"name": name, "vers": version}); | ||
let (status, said) = says(put(&crate_, meta("claims", "1.0.0"), token)); | ||
assert!( | ||
status == 400 && said.contains("its Cargo.toml is not"), | ||
"{said}" | ||
); | ||
let (status, said) = says(put(&crate_, meta("Other", "1.0.0"), token)); | ||
assert!(status == 400 && said.contains("package's name"), "{said}"); | ||
let (status, said) = says(put(b"not a crate", meta("other", "1.0.0"), token)); | ||
assert!(status == 400 && said.contains("no Cargo.toml"), "{said}"); | ||
let (status, said) = says(put(&crate_, json!("no"), token)); | ||
assert_eq!(status, 400, "{said}"); | ||
let raw = reqwest::blocking::Client::new() | ||
.put(format!("{}{here}/api/v1/crates/new", site.base)) | ||
.header("authorization", token) | ||
.body("xx") | ||
.send() | ||
.unwrap(); | ||
assert_eq!(raw.status(), 400); | ||
let long = "x".repeat(100_001); | ||
let (status, said) = says(put( | ||
&crate_, | ||
json!({"name": "other", "vers": "1.0.0", "links": long}), | ||
token, | ||
)); | ||
assert!(status == 400 && said.contains("index line"), "{said}"); | ||
| ||
// Build metadata makes a record of another key, which then goes. | ||
let twin = cargo_crate(&root, "leaf", "1.0.0+b", "", "lib.rs", ""); | ||
let crate_ = packed(&twin, "leaf", "1.0.0+b"); | ||
let (status, said) = says(put(&crate_, meta("leaf", "1.0.0+b"), token)); | ||
assert!( | ||
status == 400 && said.contains("leaf 1.0.0 is published already"), | ||
"{said}" | ||
); | ||
let versions = |name: &str| -> Vec<String> { | ||
owner | ||
.records("dev.knotless.release") | ||
.into_iter() | ||
.filter(|(_, r)| r["package"] == name) | ||
.map(|(_, r)| r["version"].as_str().unwrap().to_string()) | ||
.collect() | ||
}; | ||
assert_eq!(versions("leaf"), ["1.0.0"]); | ||
| ||
// A yank: a built lockfile still builds from nothing kept, and nothing | ||
// new takes it, until it is undone. Told at once, as a publish is. | ||
let yank = ["yank", "--registry", "here", "--version", "1.0.0", "extra"]; | ||
cargo.ok(&extra, &yank); | ||
let line = site.status(&format!("{here}/ex/tr/extra")).1; | ||
assert!(line.contains("\"yanked\":true"), "{line}"); | ||
std::fs::remove_dir_all(cargo.home.join("registry")).unwrap(); | ||
std::fs::remove_dir_all(app.join("target")).unwrap(); | ||
assert!(cargo.ok(&app, &["run", "-q", "--locked"]).contains("41")); | ||
let new_toml = "[dependencies]\nextra = { version = \"1\", registry = \"here\" }\n"; | ||
let new = cargo_crate(&root, "new", "0.0.1", new_toml, "main.rs", "fn main() {}\n"); | ||
let (done, said) = cargo.run(&new, &["build"]); | ||
assert!(!done && said.contains("yanked"), "{said}"); | ||
cargo.ok( | ||
&extra, | ||
&[ | ||
"yank", | ||
"--registry", | ||
"here", | ||
"--undo", | ||
"--version", | ||
"1.0.0", | ||
"extra", | ||
], | ||
); | ||
cargo.ok(&new, &["build"]); | ||
let (done, said) = cargo.run( | ||
&extra, | ||
&["yank", "--registry", "here", "--version", "9.0.0", "extra"], | ||
); | ||
assert!( | ||
!done && said.contains("has published no extra 9.0.0"), | ||
"{said}" | ||
); | ||
| ||
// Searched for by name, each at its latest, as many as asked. | ||
let found = site.json(&format!("{here}/api/v1/crates?q=e&per_page=1")); | ||
assert_eq!(found["meta"]["total"], 2); | ||
assert_eq!( | ||
found["crates"][0], | ||
json!({"name": "extra", "max_version": "1.0.0", "description": "says extra"}) | ||
); | ||
assert_eq!(found["crates"].as_array().unwrap().len(), 1); | ||
let said = cargo.ok(&root, &["search", "--registry", "here", "mid"]); | ||
assert!( | ||
said.contains("mid = \"0.2.0\"") && said.contains("says mid"), | ||
"{said}" | ||
); | ||
let everything = site.json(&format!("{here}/api/v1/crates")); | ||
assert_eq!(everything["meta"]["total"], 3); | ||
| ||
// A file whose bytes are not what its record says is not sent, and a | ||
// body too large is refused as it is read. | ||
let blob = owner.upload_blob(b"x", "application/octet-stream"); | ||
let bad = serde_json::json!({ | ||
"$type": "dev.knotless.release", "repo": key, "package": "bad", "version": "1.0.0", | ||
"cargo": {"index": json!({"name": "bad", "vers": "1.0.0"}).to_string()}, | ||
"files": [{"name": "bad-1.0.0.crate", "size": 1, "sha256": "a".repeat(64), | ||
"parts": [{"blob": blob}]}], | ||
"createdAt": "2026-10-27T00:00:00.000Z", | ||
}); | ||
owner.put_record("dev.knotless.release", "bad", bad); | ||
wait_until(|| site.status(&format!("{here}/3/b/bad")).0 == 200); | ||
assert_eq!(site.status(&format!("{here}/dl/bad/1.0.0/download")).0, 502); | ||
let (status, _) = says( | ||
reqwest::blocking::Client::new() | ||
.put(format!("{}{here}/api/v1/crates/new", site.base)) | ||
.header("authorization", token) | ||
.body(vec![0u8; (64 << 20) + 1]) | ||
.send() | ||
.unwrap(), | ||
); | ||
assert_eq!(status, 413); | ||
| ||
// A repository that is not there is none of it. | ||
assert_eq!( | ||
site.status(&format!("/cargo/{did}/nokey/config.json")).0, | ||
404 | ||
); | ||
let (status, _) = says( | ||
reqwest::blocking::Client::new() | ||
.put(format!("{}/cargo/{did}/nokey/api/v1/crates/new", site.base)) | ||
.header("authorization", token) | ||
.body("xx") | ||
.send() | ||
.unwrap(), | ||
); | ||
assert_eq!(status, 404); | ||
} | ||
| ||
#[test] | ||
fn the_releases() { | ||
let site = serve(true); | ||
docs/design/packages.md+59−0
| @@ -260,6 +260,63 @@ entry point. deploy/README.md lists the commands. It is one platform, | ||
linux/amd64, as the host builds it, and its layers are as docker keeps | ||
them, uncompressed. | ||
| ||
## Crates | ||
| ||
A crate is a release too, and knotless-serve answers Cargo's registry | ||
protocol from those records, one registry for each repository: | ||
| ||
```toml | ||
# .cargo/config.toml | ||
[registries.tools] | ||
index = "sparse+https://knotless.dev/cargo/<did>/<repo key>/" | ||
``` | ||
| ||
`cargo login --registry tools` takes `<handle>:<app password>`. Then | ||
`cargo publish --registry tools` and `cargo yank` work as with any | ||
registry, and a dependency names the registry: | ||
`tool = { version = "1", registry = "tools" }`. Reads need no login: Cargo | ||
sends the token only to publish and yank. The password logs in to the | ||
owner's PDS for that request, and is kept nowhere but on the publisher's | ||
machine, in Cargo's `credentials.toml`. | ||
| ||
**One registry for each repository.** A crate's name has no owner in it, so | ||
one registry for all of knotless would need someone to decide who owns a | ||
name. Nor can one for each account work, since nothing stops two of an | ||
account's repositories publishing a name, and a publish would not say which | ||
it is for. A crate of one repository can depend on a crate of another: | ||
Cargo keeps the other's address in the index line, and follows it. | ||
| ||
**An address that does not change.** Cargo keeps the registry's address in | ||
`Cargo.lock` and in those index lines, and takes another address for another | ||
registry, so a rename would make the same crate look like two. So the | ||
address holds the owner's DID and the repository's key, which never change, | ||
and no names. | ||
| ||
**Its line of the index is in the record.** A release of a crate has a | ||
`cargo` object, whose `index` is the line of Cargo's index as `cargo publish` | ||
described it, with the `.crate`'s SHA-256 as `cksum`. It is kept as Cargo's | ||
JSON rather than as fields of knotless's, since Cargo's format is Cargo's to | ||
change, and readers pass it on. The index is then made from records alone: | ||
no blob is read to answer it, and a publish uploads one blob-sized part | ||
fewer. Only `yanked` is added, as it is read, from the | ||
[yank](#yanking) of its key. | ||
| ||
**What it reads is what the index has,** so a takedown holds and a private | ||
repository has none. `cargo publish` waits up to a minute to find its crate, | ||
and warns if it does not, so a publish and a yank put the record in the index | ||
themselves, without waiting to hear of it. | ||
| ||
**One version, however its build metadata differs.** Cargo's index holds one | ||
of `1.0.0` and `1.0.0+b`, which have different record keys, so creating the | ||
record cannot refuse the second. A publish looks once its record is made: if | ||
an older record holds that version, it deletes its own and is refused. The | ||
index takes the oldest should both remain. | ||
| ||
A package's name here is lowercase, as a release's is, so a crate with | ||
capitals is refused. `cargo owner` is not answered: a repository has one | ||
owner. Neither `commit` nor `check` is set by a publish, as Cargo sends | ||
neither. | ||
| ||
## On the pages | ||
| ||
knotless-serve indexes release records as it indexes the rest, so a | ||
| @@ -326,6 +383,8 @@ split git refs into one record each ([prior-art.md](prior-art.md)). With | ||
one record per version, two publishes of different versions never touch | ||
the same record. | ||
| ||
A registry beside the release records, in knotless-serve, needs nothing of | ||
the PDS, as [crates](#crates) and [images](#images) show. | ||
npm-compatible package names leave room to serve the npm protocol later, | ||
from knotless-serve rather than the PDS. | ||
| ||
docs/reference/api.md+21−0
| @@ -512,6 +512,27 @@ A version or file the releases do not have is 404. A repository that | ||
cannot be read, or a file that cannot be read or kept, is 502 with the | ||
error. | ||
| ||
## Crates | ||
| ||
A Cargo registry of the crates a repository publishes as releases | ||
([packages.md](../design/packages.md#crates)), under | ||
`/cargo/<did>/<repo key>/`, where `<did>` is its owner's. What it reads is what | ||
the index has: with none, or for a repository the index lacks or has taken | ||
down, all of it is 404. A token, in `Authorization`, is `<handle>:<app | ||
password>`, which logs in to the owner's PDS. | ||
| ||
| Route | | | ||
| --- | --- | | ||
| `GET config.json` | `{dl, api}`, at the request's origin | | ||
| `GET <index path>` | a crate's file of the index, by Cargo's layout (`1/a`, `2/ab`, `3/a/abc`, `ab/cd/abcd`): a line per version, the oldest first, `no-cache`, with an `ETag`; 304 to one seen, 404 for none | | ||
| `GET dl/<name>/<version>/download` | the `.crate`, as a release's files are sent | | ||
| `PUT api/v1/crates/new` | as Cargo publishes: the crate uploaded to the owner's PDS and its release made, at most 64 MiB. 400 for a body that is not one, a name or version a release does not take, a `Cargo.toml` that says otherwise, an index line over 100,000 bytes, or a version published already | | ||
| `DELETE api/v1/crates/<name>/<version>/yank`, `PUT .../unyank` | `{ok: true}`: [yanks](../design/packages.md#yanking) it, or takes that back | | ||
| `GET api/v1/crates?q=&per_page=` | the crates whose names hold `q`, each at its latest: `{crates: [{name, max_version, description}], meta: {total}}`, 10 by default, 100 at most | | ||
| ||
Errors are Cargo's, `{"errors": [{"detail"}]}`. A write with no token, a token | ||
not of that form, one the PDS refuses or another account's is 403. | ||
| ||
## Images | ||
| ||
An OCI registry of the images repositories publish as releases, with | ||
harness/lexicons.mjs+4−1
| @@ -1,5 +1,5 @@ | ||
// The lexicons, as the Lexicon Style Guide and CLAUDE.md ("Lexicons") ask: | ||
// goat's lint, less the two warnings kept on purpose; what that lint does | ||
// goat's lint, less the three warnings kept on purpose; what that lint does | ||
// not see; and that each one only grows from what `base` has, since a | ||
// schema already published is evolved, never changed, with records and | ||
// clients written to it out there. | ||
| @@ -51,8 +51,11 @@ const problems = [] | ||
const say = (at, what) => retracted.has(at) || problems.push(`${at}: ${what}`) | ||
| ||
// goat (github.com/bluesky-social/goat), one finding a line. | ||
// `release`'s `cargo.index` is Cargo's own line, whose size is the crate's: | ||
// `web-sys`'s is 54 KB, and a limit once published cannot be raised. | ||
const kept = new Set([ | ||
'dev.knotless.pack large-bytes', | ||
'dev.knotless.release large-string', | ||
'dev.knotless.listReports field-name-syntax', | ||
]) | ||
let linted | ||