g1t/services/packages/src/npm.rs

544 lines22,903 bytesCodeBlame
1//! What the npm registry needs that does not touch the network: package
2//! names, versions, the integrity of a tarball, the documents npm reads
3//! (packuments), and when a version may still be unpublished.
4//!
5//! A package is `@<workspace>/<name>`: the scope is the workspace. Its
6//! versions' `package.json` fields are kept as npm sent them, beside the
7//! tarball's digest; the packument is put together from them on each read.
8
9use base64::Engine;
10use base64::engine::general_purpose::STANDARD;
11use serde_json::{Map, Value, json};
12use sha1::Sha1;
13use sha2::{Digest as _, Sha512};
14
15/// The longest name npm allows, scope included.
16const MAX_NAME: usize = 214;
17/// How long after publishing a version anyone who may publish may still
18/// unpublish it; after that it takes Admin.
19pub const UNPUBLISH_WINDOW_MS: u64 = 72 * 60 * 60 * 1000;
20pub const ABBREVIATED: &str = "application/vnd.npm.install-v1+json";
21
22/// A scoped package's name.
23#[derive(Clone, Debug, PartialEq, Eq)]
24pub struct NpmName {
25 /// The scope without `@`: the workspace.
26 pub workspace: String,
27 pub name: String,
28}
29
30impl NpmName {
31 /// `@acme/web`.
32 pub fn full(&self) -> String {
33 format!("@{}/{}", self.workspace, self.name)
34 }
35
36 /// The tarball's file name, as npm names it: `web-1.0.0.tgz`.
37 pub fn tarball(&self, version: &str) -> String {
38 format!("{}-{version}.tgz", self.name)
39 }
40}
41
42/// Reads `@scope/name` (the `/` as is or as `%2f`) and checks npm's rules:
43/// lowercase, URL-safe, not starting with `.` or `_`, at most 214
44/// characters. The scope must look like a workspace's slug.
45pub fn parse_name(text: &str) -> Result<NpmName, String> {
46 let text = text.replace("%2f", "/").replace("%2F", "/");
47 let Some(scoped) = text.strip_prefix('@') else {
48 return Err(format!(
49 "{text} has no scope. Packages on g1t are scoped by workspace: @<workspace>/<name>."
50 ));
51 };
52 let Some((workspace, name)) = scoped.split_once('/') else {
53 return Err(format!("{text} is not @<workspace>/<name>."));
54 };
55 if text.len() > MAX_NAME {
56 return Err(format!("A package name is at most {MAX_NAME} characters."));
57 }
58 let workspace_ok = !workspace.is_empty()
59 && workspace.len() <= 39
60 && workspace.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
61 && !workspace.starts_with('-')
62 && !workspace.ends_with('-')
63 && !workspace.contains("--");
64 if !workspace_ok {
65 return Err(format!("@{workspace} is not a workspace."));
66 }
67 let name_ok = !name.is_empty()
68 && !name.starts_with('.')
69 && !name.starts_with('_')
70 && name.trim() == name
71 && name
72 .bytes()
73 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'-' | b'.' | b'_' | b'~'));
74 if !name_ok {
75 return Err(format!(
76 "{name} is not a valid package name: lowercase letters, digits, `-`, `.`, `_` and `~`, not starting with `.` or `_`."
77 ));
78 }
79 Ok(NpmName { workspace: workspace.to_owned(), name: name.to_owned() })
80}
81
82/// Whether `version` is semver: `MAJOR.MINOR.PATCH`, with an optional
83/// `-prerelease` and `+build`, numbers without leading zeros.
84pub fn valid_version(version: &str) -> bool {
85 let (core, build) = version.split_once('+').map_or((version, None), |(c, b)| (c, Some(b)));
86 let (core, pre) = core.split_once('-').map_or((core, None), |(c, p)| (c, Some(p)));
87 let ident = |part: &str| !part.is_empty() && part.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-');
88 let number = |part: &str| {
89 !part.is_empty() && part.bytes().all(|b| b.is_ascii_digit()) && (part == "0" || !part.starts_with('0'))
90 };
91 let numbers: Vec<&str> = core.split('.').collect();
92 numbers.len() == 3
93 && numbers.iter().all(|n| number(n))
94 && pre.is_none_or(|p| p.split('.').all(|part| ident(part) && (!part.bytes().all(|b| b.is_ascii_digit()) || number(part))))
95 && build.is_none_or(|b| b.split('.').all(ident))
96 && version.len() <= 256
97}
98
99/// A tag's shape: anything npm accepts that is not itself a version.
100pub fn valid_tag(tag: &str) -> bool {
101 !tag.is_empty()
102 && tag.len() <= 128
103 && !valid_version(tag)
104 && tag.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_'))
105}
106
107/// What a tarball is checked and named by: `sha512-<base64>` (`dist.integrity`)
108/// and its SHA-1 in hex (`dist.shasum`).
109#[derive(Clone, Debug, PartialEq, Eq)]
110pub struct Integrity {
111 pub integrity: String,
112 pub shasum: String,
113}
114
115pub fn integrity(bytes: &[u8]) -> Integrity {
116 Integrity {
117 integrity: format!("sha512-{}", STANDARD.encode(Sha512::digest(bytes))),
118 shasum: hex::encode(Sha1::digest(bytes)),
119 }
120}
121
122/// Whether what a client says about the tarball agrees with the tarball.
123/// What it leaves out is not checked. An `integrity` may list several
124/// hashes; the sha512 one must match when there is one.
125pub fn agrees(computed: &Integrity, integrity: Option<&str>, shasum: Option<&str>) -> bool {
126 let integrity_ok = match integrity {
127 None => true,
128 Some(given) => {
129 let sha512: Vec<&str> = given.split_whitespace().filter(|h| h.starts_with("sha512-")).collect();
130 sha512.is_empty() || sha512.contains(&computed.integrity.as_str())
131 }
132 };
133 integrity_ok && shasum.is_none_or(|given| given.eq_ignore_ascii_case(&computed.shasum))
134}
135
136/// Whether a version published at `published_ms` may be unpublished at
137/// `now_ms` by someone who may publish: within 72 hours. Admins may always.
138pub fn may_unpublish(published_ms: u64, now_ms: u64, admin: bool) -> bool {
139 admin || now_ms.saturating_sub(published_ms) < UNPUBLISH_WINDOW_MS
140}
141
142/// The repository a package's `repository` field names, as `(workspace,
143/// name)`, when it is on `host`: `git+https://g1t.sh/acme/web.git`,
144/// `https://g1t.sh/acme/web`, `{ "url": ... }`.
145pub fn repository_of(field: &Value, host: &str) -> Option<(String, String)> {
146 let url = match field {
147 Value::String(url) => url.as_str(),
148 Value::Object(map) => map.get("url")?.as_str()?,
149 _ => return None,
150 };
151 let url = url.trim().trim_start_matches("git+");
152 let rest = url.split_once("://").map_or(url, |(_, rest)| rest);
153 let rest = rest.split_once('@').map_or(rest, |(_, rest)| rest);
154 let rest = rest.strip_prefix(host)?;
155 let rest = rest.strip_prefix('/').or_else(|| rest.strip_prefix(':'))?;
156 let mut parts = rest.trim_end_matches('/').split('/');
157 let (workspace, name) = (parts.next()?, parts.next()?);
158 let name = name.strip_suffix(".git").unwrap_or(name);
159 (!workspace.is_empty() && !name.is_empty()).then(|| (workspace.to_lowercase(), name.to_lowercase()))
160}
161
162/// One version as kept: its `package.json` fields as npm sent them (with
163/// `dist` holding the integrity), and what the registry adds.
164pub struct StoredVersion {
165 pub version: String,
166 pub manifest: Value,
167 pub deprecated: Option<String>,
168 pub published_at: String,
169}
170
171/// What a packument is made from.
172pub struct Packument<'a> {
173 pub name: &'a NpmName,
174 pub versions: &'a [StoredVersion],
175 /// Tag and the version it points to.
176 pub tags: &'a [(String, String)],
177 pub created: &'a str,
178 pub modified: &'a str,
179 pub readme: Option<&'a str>,
180 /// Where tarballs are: `https://g1t.sh/-/npm`.
181 pub base: &'a str,
182}
183
184/// The fields npm's abbreviated packument keeps of each version.
185const ABBREVIATED_FIELDS: [&str; 16] = [
186 "name",
187 "version",
188 "dependencies",
189 "optionalDependencies",
190 "devDependencies",
191 "bundleDependencies",
192 "bundledDependencies",
193 "peerDependencies",
194 "peerDependenciesMeta",
195 "bin",
196 "directories",
197 "engines",
198 "os",
199 "cpu",
200 "_hasShrinkwrap",
201 "hasInstallScript",
202];
203
204impl Packument<'_> {
205 /// The revision npm sends back when it changes the packument. Any
206 /// revision is taken; this one only changes when the package does.
207 pub fn rev(&self) -> String {
208 let digest = hex::encode(Sha1::digest(self.modified.as_bytes()));
209 format!("{}-{}", self.versions.len().max(1), &digest[..16])
210 }
211
212 fn version_json(&self, stored: &StoredVersion) -> Value {
213 let mut manifest = match &stored.manifest {
214 Value::Object(map) => map.clone(),
215 _ => Map::new(),
216 };
217 let full = self.name.full();
218 manifest.insert("name".into(), json!(full));
219 manifest.insert("version".into(), json!(stored.version));
220 manifest.insert("_id".into(), json!(format!("{full}@{}", stored.version)));
221 let mut dist = match manifest.remove("dist") {
222 Some(Value::Object(dist)) => dist,
223 _ => Map::new(),
224 };
225 dist.insert(
226 "tarball".into(),
227 json!(format!("{}/{full}/-/{}", self.base, self.name.tarball(&stored.version))),
228 );
229 manifest.insert("dist".into(), Value::Object(dist));
230 match &stored.deprecated {
231 Some(message) => {
232 manifest.insert("deprecated".into(), json!(message));
233 }
234 None => {
235 manifest.remove("deprecated");
236 }
237 }
238 Value::Object(manifest)
239 }
240
241 fn dist_tags(&self) -> Value {
242 Value::Object(self.tags.iter().map(|(tag, version)| (tag.clone(), json!(version))).collect())
243 }
244
245 /// The whole document, as `npm view` and `npm publish` read it.
246 pub fn full(&self) -> Value {
247 let mut versions = Map::new();
248 let mut time = Map::new();
249 time.insert("created".into(), json!(self.created));
250 time.insert("modified".into(), json!(self.modified));
251 for stored in self.versions {
252 versions.insert(stored.version.clone(), self.version_json(stored));
253 time.insert(stored.version.clone(), json!(stored.published_at));
254 }
255 let latest = self
256 .tags
257 .iter()
258 .find(|(tag, _)| tag == "latest")
259 .and_then(|(_, v)| self.versions.iter().find(|s| &s.version == v));
260 let mut document = json!({
261 "_id": self.name.full(),
262 "_rev": self.rev(),
263 "name": self.name.full(),
264 "dist-tags": self.dist_tags(),
265 "versions": versions,
266 "time": time,
267 });
268 if let Some(latest) = latest {
269 for key in ["description", "keywords", "license", "repository", "homepage", "bugs", "author"] {
270 if let Some(value) = latest.manifest.get(key) {
271 document[key] = value.clone();
272 }
273 }
274 }
275 if let Some(readme) = self.readme {
276 document["readme"] = json!(readme);
277 }
278 document
279 }
280
281 /// What `npm install` asks for: each version's install fields only.
282 pub fn abbreviated(&self) -> Value {
283 let mut versions = Map::new();
284 for stored in self.versions {
285 let full = self.version_json(stored);
286 let mut kept = Map::new();
287 for key in ABBREVIATED_FIELDS.iter().chain(["dist", "deprecated"].iter()) {
288 if let Some(value) = full.get(*key) {
289 kept.insert((*key).to_owned(), value.clone());
290 }
291 }
292 versions.insert(stored.version.clone(), Value::Object(kept));
293 }
294 json!({
295 "name": self.name.full(),
296 "modified": self.modified,
297 "dist-tags": self.dist_tags(),
298 "versions": versions,
299 })
300 }
301}
302
303/// A version's `package.json` as kept: without its README (kept once, for
304/// the package) and the registry's own fields, with the integrity in `dist`.
305pub fn stored_manifest(sent: &Value, computed: &Integrity) -> Value {
306 let mut manifest = match sent {
307 Value::Object(map) => map.clone(),
308 _ => Map::new(),
309 };
310 for key in ["readme", "readmeFilename", "_id", "_rev", "_attachments", "deprecated", "_npmUser", "maintainers"] {
311 manifest.remove(key);
312 }
313 manifest.insert(
314 "dist".into(),
315 json!({ "integrity": computed.integrity, "shasum": computed.shasum }),
316 );
317 Value::Object(manifest)
318}
319
320/// One of the registry's endpoints, under `/-/npm`. Names are unchecked.
321#[derive(Clone, Debug, PartialEq, Eq)]
322pub enum NpmRoute {
323 Ping,
324 Whoami,
325 /// `npm login --auth-type=legacy`: `/-/user/org.couchdb.user:<name>`.
326 Login,
327 DistTags { name: String, tag: Option<String> },
328 /// The packument: read, publish, or (with a revision) change.
329 Package { name: String, rev: Option<String> },
330 Tarball { name: String, file: String, rev: Option<String> },
331}
332
333/// Which endpoint a path is. `/-/npm/@acme%2fweb`, `/-/npm/@acme/web`,
334/// `/-/npm/@acme/web/-/web-1.0.0.tgz`, `/-/npm/-/package/@acme%2fweb/dist-tags/next`...
335pub fn route(path: &str) -> Option<NpmRoute> {
336 let rest = path.strip_prefix("/-/npm")?;
337 let rest = rest.strip_prefix('/').unwrap_or(rest);
338 match rest {
339 "-/ping" => return Some(NpmRoute::Ping),
340 "-/whoami" => return Some(NpmRoute::Whoami),
341 _ => {}
342 }
343 if rest.starts_with("-/user/org.couchdb.user:") {
344 return Some(NpmRoute::Login);
345 }
346 // The name: `@scope%2fname`, one segment, or `@scope/name`, two.
347 let split = |text: &str| -> Option<(String, String)> {
348 let lower = text.to_ascii_lowercase();
349 if lower.starts_with('@') && !lower.split('/').next()?.contains("%2f") {
350 let mut parts = text.splitn(3, '/');
351 let (scope, name) = (parts.next()?, parts.next()?);
352 Some((format!("{scope}/{name}"), parts.next().unwrap_or("").to_owned()))
353 } else {
354 let mut parts = text.splitn(2, '/');
355 Some((parts.next()?.to_owned(), parts.next().unwrap_or("").to_owned()))
356 }
357 };
358 if let Some(after) = rest.strip_prefix("-/package/") {
359 let (name, tail) = split(after)?;
360 let tag = match tail.as_str() {
361 "dist-tags" => None,
362 t => Some(t.strip_prefix("dist-tags/")?.to_owned()).filter(|t| !t.is_empty() && !t.contains('/')),
363 };
364 if tail != "dist-tags" && tag.is_none() {
365 return None;
366 }
367 return Some(NpmRoute::DistTags { name, tag });
368 }
369 if rest.starts_with("-/") || rest.is_empty() {
370 return None;
371 }
372 let (name, tail) = split(rest)?;
373 if tail.is_empty() {
374 return Some(NpmRoute::Package { name, rev: None });
375 }
376 if let Some(rev) = tail.strip_prefix("-rev/") {
377 return Some(NpmRoute::Package { name, rev: Some(rev.to_owned()) });
378 }
379 let file = tail.strip_prefix("-/")?;
380 let (file, rev) = match file.split_once("/-rev/") {
381 Some((file, rev)) => (file, Some(rev.to_owned())),
382 None => (file, None),
383 };
384 (file.ends_with(".tgz") && !file.contains('/')).then(|| NpmRoute::Tarball { name, file: file.to_owned(), rev })
385}
386
387/// The version a tarball's file name names: `web-1.2.3.tgz` of `web`.
388pub fn version_of_file(name: &NpmName, file: &str) -> Option<String> {
389 let version = file.strip_prefix(&format!("{}-", name.name))?.strip_suffix(".tgz")?;
390 valid_version(version).then(|| version.to_owned())
391}
392
393#[cfg(test)]
394mod tests {
395 use super::*;
396
397 #[test]
398 fn every_endpoint_is_routed() {
399 let package = |name: &str, rev: Option<&str>| Some(NpmRoute::Package { name: name.into(), rev: rev.map(str::to_owned) });
400 assert_eq!(route("/-/npm/-/ping"), Some(NpmRoute::Ping));
401 assert_eq!(route("/-/npm/-/whoami"), Some(NpmRoute::Whoami));
402 assert_eq!(route("/-/npm/-/user/org.couchdb.user:ana"), Some(NpmRoute::Login));
403 assert_eq!(route("/-/npm/@acme%2fweb"), package("@acme%2fweb", None));
404 assert_eq!(route("/-/npm/@acme%2Fweb"), package("@acme%2Fweb", None));
405 assert_eq!(route("/-/npm/@acme/web"), package("@acme/web", None));
406 assert_eq!(route("/-/npm/@acme%2fweb/-rev/3-abc"), package("@acme%2fweb", Some("3-abc")));
407 assert_eq!(
408 route("/-/npm/@acme/web/-/web-1.0.0.tgz"),
409 Some(NpmRoute::Tarball { name: "@acme/web".into(), file: "web-1.0.0.tgz".into(), rev: None })
410 );
411 assert_eq!(
412 route("/-/npm/@acme/web/-/web-1.0.0.tgz/-rev/2-x"),
413 Some(NpmRoute::Tarball { name: "@acme/web".into(), file: "web-1.0.0.tgz".into(), rev: Some("2-x".into()) })
414 );
415 assert_eq!(route("/-/npm/-/package/@acme%2fweb/dist-tags"), Some(NpmRoute::DistTags { name: "@acme%2fweb".into(), tag: None }));
416 assert_eq!(
417 route("/-/npm/-/package/@acme/web/dist-tags/next"),
418 Some(NpmRoute::DistTags { name: "@acme/web".into(), tag: Some("next".into()) })
419 );
420 assert_eq!(route("/-/npm/-/package/@acme/web/other"), None);
421 assert_eq!(route("/-/npm/"), None);
422 assert_eq!(route("/-/npm/@acme/web/-/notes.txt"), None);
423 assert_eq!(route("/v2/acme/web"), None);
424 let name = parse_name("@acme/web").unwrap();
425 assert_eq!(version_of_file(&name, "web-1.2.3-rc.1.tgz").as_deref(), Some("1.2.3-rc.1"));
426 assert_eq!(version_of_file(&name, "other-1.2.3.tgz"), None);
427 }
428
429 #[test]
430 fn names_are_scoped_by_workspace_and_follow_npms_rules() {
431 let name = parse_name("@acme/web-ui").unwrap();
432 assert_eq!((name.workspace.as_str(), name.name.as_str()), ("acme", "web-ui"));
433 assert_eq!(parse_name("@acme%2fweb").unwrap().full(), "@acme/web");
434 assert_eq!(parse_name("@acme%2Fweb.js").unwrap().tarball("1.0.0"), "web.js-1.0.0.tgz");
435 assert!(parse_name("web").unwrap_err().contains("scope"));
436 assert!(parse_name("@acme").is_err());
437 assert!(parse_name("@Acme/web").is_err());
438 assert!(parse_name("@acme/Web").is_err());
439 assert!(parse_name("@acme/.hidden").is_err());
440 assert!(parse_name("@acme/_private").is_err());
441 assert!(parse_name("@acme/a b").is_err());
442 assert!(parse_name("@acme/a/b").is_err());
443 assert!(parse_name(&format!("@acme/{}", "a".repeat(210))).is_err());
444 }
445
446 #[test]
447 fn versions_are_semver_and_tags_are_not() {
448 for good in ["1.0.0", "0.0.1", "10.20.30", "1.0.0-rc.1", "1.0.0-alpha-2.x", "1.0.0+build.5", "1.0.0-0"] {
449 assert!(valid_version(good), "{good}");
450 }
451 for bad in ["1.0", "01.0.0", "1.0.0-", "1.0.0-01", "v1.0.0", "1.0.0+", "a.b.c", ""] {
452 assert!(!valid_version(bad), "{bad}");
453 }
454 assert!(valid_tag("latest"));
455 assert!(valid_tag("next-1"));
456 assert!(!valid_tag("1.0.0"));
457 assert!(!valid_tag("bad tag"));
458 }
459
460 #[test]
461 fn integrity_is_npms_sha512_and_sha1() {
462 let computed = integrity(b"hello");
463 assert_eq!(computed.shasum, "aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d");
464 assert_eq!(
465 computed.integrity,
466 "sha512-m3HSJL1i83hdltRq0+o9czGb+8KJDKra4t/3JRlnPKcjI8PZm6XBHXx6zG4UuMXaDEZjR1wuXDre9G9zvN7AQw=="
467 );
468 assert!(agrees(&computed, Some(&computed.integrity), Some("AAF4C61DDCC5E8A2DABEDE0F3B482CD9AEA9434D")));
469 assert!(agrees(&computed, None, None));
470 assert!(agrees(&computed, Some("sha1-whatever"), None), "only sha512 is checked");
471 assert!(!agrees(&computed, Some("sha512-AAAA"), None));
472 assert!(!agrees(&computed, None, Some("0000")));
473 }
474
475 #[test]
476 fn unpublishing_is_open_for_72_hours_then_needs_admin() {
477 let hour = 60 * 60 * 1000;
478 assert!(may_unpublish(0, 71 * hour, false));
479 assert!(!may_unpublish(0, 72 * hour, false));
480 assert!(may_unpublish(0, 1000 * hour, true));
481 }
482
483 #[test]
484 fn a_repository_on_g1t_is_read_from_package_json() {
485 let at = |w: &str, n: &str| Some((w.to_owned(), n.to_owned()));
486 assert_eq!(repository_of(&json!("git+https://g1t.sh/acme/web.git"), "g1t.sh"), at("acme", "web"));
487 assert_eq!(repository_of(&json!({ "type": "git", "url": "https://g1t.sh/Acme/Web" }), "g1t.sh"), at("acme", "web"));
488 assert_eq!(repository_of(&json!("git@g1t.sh:acme/web.git"), "g1t.sh"), at("acme", "web"));
489 assert_eq!(repository_of(&json!("https://example.com/acme/web"), "g1t.sh"), None);
490 assert_eq!(repository_of(&json!("https://g1t.sh/acme"), "g1t.sh"), None);
491 assert_eq!(repository_of(&json!(42), "g1t.sh"), None);
492 }
493
494 fn stored(version: &str, deprecated: Option<&str>) -> StoredVersion {
495 StoredVersion {
496 version: version.to_owned(),
497 manifest: stored_manifest(
498 &json!({ "name": "@acme/web", "version": version, "description": "Web", "dependencies": { "a": "^1" }, "scripts": { "test": "x" }, "readme": "# big" }),
499 &integrity(version.as_bytes()),
500 ),
501 deprecated: deprecated.map(str::to_owned),
502 published_at: "2026-10-06T00:00:00.000Z".to_owned(),
503 }
504 }
505
506 #[test]
507 fn the_packument_names_every_version_its_tarball_and_tags() {
508 let name = parse_name("@acme/web").unwrap();
509 let versions = [stored("1.0.0", None), stored("1.1.0", Some("use 2"))];
510 let tags = [("latest".to_owned(), "1.1.0".to_owned())];
511 let packument = Packument {
512 name: &name,
513 versions: &versions,
514 tags: &tags,
515 created: "2026-10-01T00:00:00.000Z",
516 modified: "2026-10-06T00:00:00.000Z",
517 readme: Some("# Web"),
518 base: "https://g1t.sh/-/npm",
519 };
520 let full = packument.full();
521 assert_eq!(full["name"], "@acme/web");
522 assert_eq!(full["dist-tags"]["latest"], "1.1.0");
523 assert_eq!(full["description"], "Web");
524 assert_eq!(full["readme"], "# Web");
525 let v1 = &full["versions"]["1.0.0"];
526 assert_eq!(v1["_id"], "@acme/web@1.0.0");
527 assert_eq!(v1["dist"]["tarball"], "https://g1t.sh/-/npm/@acme/web/-/web-1.0.0.tgz");
528 assert_eq!(v1["dist"]["integrity"], integrity(b"1.0.0").integrity);
529 assert!(v1.get("readme").is_none(), "the README is kept once, not per version");
530 assert!(v1.get("deprecated").is_none());
531 assert_eq!(full["versions"]["1.1.0"]["deprecated"], "use 2");
532 assert_eq!(full["time"]["1.0.0"], "2026-10-06T00:00:00.000Z");
533 assert!(full["_rev"].as_str().unwrap().starts_with("2-"));
534
535 let short = packument.abbreviated();
536 let v1 = &short["versions"]["1.0.0"];
537 assert_eq!(v1["dependencies"]["a"], "^1");
538 assert!(v1.get("scripts").is_none(), "install fields only");
539 assert!(v1.get("description").is_none());
540 assert_eq!(v1["dist"]["shasum"], integrity(b"1.0.0").shasum);
541 assert_eq!(short["versions"]["1.1.0"]["deprecated"], "use 2");
542 assert!(short.get("readme").is_none());
543 }
544}