@preventive/triage 1.0.0-alpha.44 → 1.0.0-alpha.46

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (207) hide show
  1. package/client/linked-findings.js +3 -0
  2. package/common/bundle-metadata.d.ts +1 -0
  3. package/common/bundle-metadata.js +1 -1
  4. package/common/bundle-sources.js +25 -2
  5. package/common/default-scan-models.ts +1 -1
  6. package/common/dependency-reviews.d.ts +0 -5
  7. package/common/dependency-reviews.js +0 -17
  8. package/common/line-diff.js +5 -4
  9. package/common/managed/agent-tasks.ts +292 -0
  10. package/common/managed/github-agents.ts +92 -0
  11. package/common/managed/github-logins.ts +49 -0
  12. package/common/managed/roles.ts +7 -0
  13. package/common/managed/routes.js +88 -7
  14. package/common/managed/team-app.d.ts +5 -2
  15. package/common/managed/team-app.js +2 -2
  16. package/common/server-info.ts +3 -0
  17. package/common/workspace-app.js +6 -3
  18. package/out/brotli-fallback.js +3 -3
  19. package/out/chunk-2XUEZHIP.js +18 -18
  20. package/out/chunk-2ZDZWLFY.js +1 -0
  21. package/out/chunk-3PS4KWD2.js +10 -0
  22. package/out/chunk-4HI2MSAV.js +17 -0
  23. package/out/chunk-4UFAMJAX.js +1 -0
  24. package/out/chunk-7C7DWL7I.js +1 -0
  25. package/out/chunk-7XRR4OFU.js +1 -0
  26. package/out/chunk-BC2UCLFJ.js +1 -0
  27. package/out/chunk-BOIXMUU5.js +3 -0
  28. package/out/chunk-C4Z6ZBXV.js +6 -6
  29. package/out/chunk-CC4OM6TZ.js +3 -0
  30. package/out/chunk-G24A42V6.js +2 -0
  31. package/out/chunk-GDPCLSOS.js +2 -0
  32. package/out/chunk-GO7FT4OD.js +1 -0
  33. package/out/chunk-I2MDGSQX.js +3 -0
  34. package/out/chunk-IYPY47MC.js +1 -0
  35. package/out/chunk-IZ7POWI3.js +18 -0
  36. package/out/chunk-JCIIBLKR.js +1 -1
  37. package/out/chunk-JKGQ3O7O.js +6 -0
  38. package/out/chunk-LE5ZWLG4.js +235 -0
  39. package/out/chunk-MSION3G4.js +36 -0
  40. package/out/chunk-NRPC74AM.js +1 -1
  41. package/out/chunk-OKRLLE5I.js +17 -0
  42. package/out/chunk-OOJM4CTU.js +1 -1
  43. package/out/chunk-PNHBZGDD.js +1 -1
  44. package/out/chunk-PXK7EU4V.js +1 -0
  45. package/out/chunk-QB4CBOX2.js +1 -0
  46. package/out/chunk-R3NZFXVR.js +1 -0
  47. package/out/chunk-R5O6FAW4.js +1 -1
  48. package/out/chunk-S6GIXWQW.js +52 -0
  49. package/out/chunk-SXV2T5C4.js +80 -0
  50. package/out/chunk-TUII34K2.js +1 -1
  51. package/out/chunk-V3KWNNMY.js +1 -0
  52. package/out/chunk-VFWMQ3LG.js +2 -0
  53. package/out/chunk-VH4XMZIW.js +2 -0
  54. package/out/chunk-WFYLMF6Z.js +10 -0
  55. package/out/chunk-WQ74ZBZF.js +7 -0
  56. package/out/client-managed-import.js +3 -3
  57. package/out/client-managed.js +31 -35
  58. package/out/client-sync.js +11 -11
  59. package/out/compare.js +35 -0
  60. package/out/dialogs.js +51 -0
  61. package/out/github-issue-authorized.html +1 -1
  62. package/out/github-issue-authorized.js +1 -1
  63. package/out/graph.js +17 -17
  64. package/out/index.html +30 -34
  65. package/out/prism.js +14 -14
  66. package/out/scan.js +2 -0
  67. package/out/terminal.js +294 -293
  68. package/out/view.css +1 -1
  69. package/out/view.js +16 -107
  70. package/package.json +53 -6
  71. package/risk/actions/findings.js +161 -0
  72. package/risk/actions/resolve.js +219 -0
  73. package/risk/actions/scripts/commands.js +182 -0
  74. package/risk/actions/scripts/grade.js +113 -0
  75. package/risk/actions/scripts/packages.js +123 -0
  76. package/risk/actions/scripts/shell.js +169 -0
  77. package/risk/actions/uses.js +76 -0
  78. package/risk/actions/workflow.js +110 -0
  79. package/risk/files/hosts.js +6 -6
  80. package/risk/files/readability.js +23 -17
  81. package/risk/files/runs-code.js +18 -6
  82. package/risk/files/script.js +7 -3
  83. package/risk/files/stamps.js +8 -7
  84. package/risk/index.d.ts +71 -1
  85. package/risk/index.js +6 -2
  86. package/risk/npm/assess.js +1 -1
  87. package/risk/npm/compare.js +34 -10
  88. package/risk/npm/files.js +413 -2
  89. package/risk/npm/tarball.js +56 -73
  90. package/server-e2e/app.ts +5 -4
  91. package/server-e2e/http.ts +5 -2
  92. package/server-managed/agent-merges.ts +198 -0
  93. package/server-managed/app-agents.ts +30 -0
  94. package/server-managed/avatar-store.ts +1 -0
  95. package/server-managed/bundle-cache.ts +7 -1
  96. package/server-managed/combined.ts +3 -2
  97. package/server-managed/commit-coauthors.ts +83 -0
  98. package/server-managed/commit-lockfiles.ts +72 -0
  99. package/server-managed/config.ts +6 -0
  100. package/server-managed/db-methods.ts +66 -19
  101. package/server-managed/db-neon.ts +89 -21
  102. package/server-managed/db-schema.ts +13 -2
  103. package/server-managed/db.ts +9 -1
  104. package/server-managed/dependabot-access.ts +60 -0
  105. package/server-managed/dependabot-alerts.ts +185 -0
  106. package/server-managed/dependency-review-comments.ts +97 -0
  107. package/server-managed/dependency-reviews.ts +78 -18
  108. package/server-managed/github-accounts.ts +33 -0
  109. package/server-managed/github-actions.ts +71 -0
  110. package/server-managed/github-app.ts +33 -13
  111. package/server-managed/github-errors.ts +2 -0
  112. package/server-managed/github-issue-links.ts +11 -5
  113. package/server-managed/github-link-oauth.ts +69 -0
  114. package/server-managed/github-links.ts +204 -0
  115. package/server-managed/github-oauth.ts +5 -2
  116. package/server-managed/github-pulls.ts +2 -2
  117. package/server-managed/github-requests.ts +39 -0
  118. package/server-managed/github-users.ts +30 -0
  119. package/server-managed/http-response.ts +10 -2
  120. package/server-managed/http.ts +1251 -97
  121. package/server-managed/index.ts +13 -8
  122. package/server-managed/kept-briefly.ts +37 -0
  123. package/server-managed/locked-packages.ts +37 -16
  124. package/server-managed/lockfile-scope.ts +49 -0
  125. package/server-managed/npm-delta-worker.js +23 -0
  126. package/server-managed/npm-delta.ts +178 -0
  127. package/server-managed/npm-insights.ts +20 -11
  128. package/server-managed/npm-kept.ts +83 -6
  129. package/server-managed/npm-loads.ts +114 -37
  130. package/server-managed/npm-packages.ts +110 -64
  131. package/server-managed/npm-risk.ts +4 -1
  132. package/server-managed/pretty-print.ts +67 -23
  133. package/server-managed/pull-comment-reviews.ts +161 -0
  134. package/server-managed/pull-dependencies.ts +300 -0
  135. package/server-managed/pull-dependency-reviews.ts +318 -0
  136. package/server-managed/pull-reviews.ts +205 -0
  137. package/server-managed/queue.ts +16 -0
  138. package/server-managed/release-commits.ts +84 -0
  139. package/server-managed/rename-diff.ts +22 -13
  140. package/server-managed/report-sources.ts +1 -1
  141. package/server-managed/report-store.ts +42 -9
  142. package/server-managed/repository-actions.ts +218 -0
  143. package/server-managed/repository-agents.ts +220 -0
  144. package/server-managed/repository-browser.ts +166 -37
  145. package/server-managed/repository-changes.ts +284 -0
  146. package/server-managed/repository-code.ts +52 -0
  147. package/server-managed/repository-dependency-reviews.ts +157 -0
  148. package/server-managed/repository-history.ts +461 -0
  149. package/server-managed/repository-issues.ts +181 -0
  150. package/server-managed/repository-pulse.ts +439 -0
  151. package/server-managed/repository-risk-audit.ts +13 -0
  152. package/server-managed/repository-risk-read.ts +170 -0
  153. package/server-managed/repository-risk.ts +133 -118
  154. package/server-managed/reviewed-dependencies.ts +149 -0
  155. package/server-managed/session.ts +27 -9
  156. package/server-managed/sql.ts +6 -3
  157. package/server-managed/storage-stores.ts +5 -0
  158. package/server-managed/storage.ts +4 -2
  159. package/server-managed/team-app.ts +19 -3
  160. package/server-managed/team-reports.ts +24 -12
  161. package/server-managed/upstream-cache.ts +12 -0
  162. package/server-managed/workspace-shares.ts +4 -1
  163. package/server.ts +25 -5
  164. package/out/chunk-2ABLKCOY.js +0 -46
  165. package/out/chunk-2KBF5TZ7.js +0 -2
  166. package/out/chunk-35VE6CDH.js +0 -2
  167. package/out/chunk-3BZ7JERB.js +0 -1
  168. package/out/chunk-4XCDFGDP.js +0 -10
  169. package/out/chunk-5LSXOLUS.js +0 -1
  170. package/out/chunk-5RCM2J63.js +0 -1
  171. package/out/chunk-7B6HB4D7.js +0 -1
  172. package/out/chunk-AJ4GTATZ.js +0 -3
  173. package/out/chunk-CH4UN6WK.js +0 -225
  174. package/out/chunk-CMQA4DM6.js +0 -1
  175. package/out/chunk-CVVJAXVY.js +0 -2
  176. package/out/chunk-DIJJNTRH.js +0 -10
  177. package/out/chunk-EDKLBQQN.js +0 -17
  178. package/out/chunk-F47DXCEV.js +0 -33
  179. package/out/chunk-FK3DZEKL.js +0 -1
  180. package/out/chunk-G6AJWENO.js +0 -1
  181. package/out/chunk-GJ4IUCNS.js +0 -1
  182. package/out/chunk-GNXOKIGP.js +0 -80
  183. package/out/chunk-GZ4MS3TE.js +0 -13
  184. package/out/chunk-GZWM7UQM.js +0 -2
  185. package/out/chunk-IU3CCINR.js +0 -3
  186. package/out/chunk-K3X5EJBQ.js +0 -2
  187. package/out/chunk-KEX2ATEW.js +0 -2
  188. package/out/chunk-KYCNJVB7.js +0 -1
  189. package/out/chunk-LPCTJYNA.js +0 -7
  190. package/out/chunk-O4RJY5TP.js +0 -2
  191. package/out/chunk-OD4LPN3H.js +0 -1
  192. package/out/chunk-QWDWTBM7.js +0 -1
  193. package/out/chunk-QYWKC557.js +0 -1
  194. package/out/chunk-R75XOKUP.js +0 -1
  195. package/out/chunk-SWTLADW5.js +0 -1
  196. package/out/chunk-T7OKH4VW.js +0 -1
  197. package/out/chunk-TZ5KB2Y5.js +0 -10
  198. package/out/chunk-UNTRGE26.js +0 -2
  199. package/out/chunk-UTR2LVME.js +0 -2
  200. package/out/chunk-UYAXVRVL.js +0 -1
  201. package/out/chunk-V3ME6FI5.js +0 -2
  202. package/out/chunk-V5O7M2YT.js +0 -1
  203. package/out/chunk-VIHGO77Y.js +0 -1
  204. package/out/chunk-W4J3MGLB.js +0 -1
  205. package/out/chunk-WGG7KQUH.js +0 -1
  206. package/out/chunk-XYFXXB2G.js +0 -10
  207. package/out/chunk-ZESLQCTF.js +0 -6
@@ -4,16 +4,22 @@
4
4
  // instance: public versions' documents, of the fields read of them
5
5
  // (npm-manifest.ts npmVersionFields), for a day (readNpmVersion asks npm again
6
6
  // then, as a version can be deprecated, its package made private, or itself
7
- // removed); and, for scans, each package's risk line, for as long as a scan
7
+ // removed), and when each was published, for a month (npmPublishTimeStore);
8
+ // and, for scans, each package's risk line, for as long as a scan
8
9
  // keeps it, and what a scan read of GitHub, for a day, and of npm's
9
- // advisories, for an hour (repository-risk.ts). Each store removes its own rows kept longer
10
+ // advisories, for an hour (repository-risk.ts); and what the code viewer read
11
+ // of a repository's GitHub, its issues, pull requests and agents' work, for a
12
+ // day (http.ts keptAnswer), and its history, a page from a commit at a time,
13
+ // for a month (repository-history.ts historyFrom), as are the files each of
14
+ // its commits changes and holds (repository-changes.ts), and the packages a
15
+ // pull request's lockfiles add (pull-dependencies.ts). Each store removes its own rows kept longer
10
16
  // than that as it writes, at most once an hour a database (pruneKept), so
11
17
  // that rows no read will take again don't pile up.
12
18
  // The database is an optimization: a failure reads as a miss, and writes
13
19
  // nothing.
14
20
  import { createHash } from 'node:crypto'
15
21
  import { plainObject } from './npm-manifest.ts'
16
- import { type NpmVersionStore, VERSION_KEPT_MS } from './npm-packages.ts'
22
+ import { type NpmPublishTimeStore, type NpmVersionStore, VERSION_KEPT_MS } from './npm-packages.ts'
17
23
  import type { UpstreamCacheStore } from './upstream-cache.ts'
18
24
 
19
25
  // A value kept by key, as JSON, `at` when it was.
@@ -21,7 +27,12 @@ export interface KeptStore<T> {
21
27
  read(key: string): Promise<{ at: number; value: T } | null>
22
28
  // Those of `keys` kept, by key, in one read of the database.
23
29
  readMany(keys: string[]): Promise<Map<string, { at: number; value: T }>>
30
+ // Those of `first` kept; where none is, those of `then`, in one read of the
31
+ // database (`first` whether they are `first`'s).
32
+ readFirst(first: string[], then: string[]): Promise<{ first: boolean; found: Map<string, { at: number; value: T }> }>
24
33
  write(key: string, value: T, at: number): Promise<void>
34
+ // Removes what is kept by `key`.
35
+ remove(key: string): Promise<void>
25
36
  }
26
37
 
27
38
  // When each database last had the rows under each prefix removed.
@@ -59,10 +70,20 @@ function keptStore<T>(db: UpstreamCacheStore, type: string, keptMs: number, debu
59
70
  return new Map(keys.flatMap(key => { const kept = valueOf(rows.get(keyOf(key))); return kept ? [[key, kept] as const] : [] }))
60
71
  } catch (error) { warn('read', error); return new Map() }
61
72
  },
73
+ async readFirst(first, then) {
74
+ try {
75
+ const { first: answered, entries } = await db.getFirstUpstreamCacheEntries(first.map(keyOf), then.map(keyOf))
76
+ const asked = answered ? first : then
77
+ return { first: answered, found: new Map(asked.flatMap(key => { const kept = valueOf(entries.get(keyOf(key))); return kept ? [[key, kept] as const] : [] })) }
78
+ } catch (error) { warn('read', error); return { first: false, found: new Map() } }
79
+ },
62
80
  async write(key, value, at) {
63
81
  try { await db.setUpstreamCacheEntry(keyOf(key), JSON.stringify({ at, value }), at) } catch (error) { warn('write', error) }
64
82
  await prune(at)
65
83
  },
84
+ async remove(key) {
85
+ try { await db.deleteUpstreamCacheEntries(keyOf(key), Number.MAX_SAFE_INTEGER) } catch (error) { warn('remove', error) }
86
+ },
66
87
  }
67
88
  }
68
89
 
@@ -73,17 +94,73 @@ export function npmVersionStore(db: UpstreamCacheStore, debug = false): NpmVersi
73
94
  const hit = await kept.read(`${name}@${version}`)
74
95
  return hit && plainObject(hit.value) ? { at: hit.at, json: hit.value } : null
75
96
  },
97
+ async readMany(versions) {
98
+ const hits = await kept.readMany(versions.map(({ name, version }) => `${name}@${version}`))
99
+ return new Map([...hits].flatMap(([key, hit]) => plainObject(hit.value) ? [[key, { at: hit.at, json: hit.value }] as const] : []))
100
+ },
76
101
  write: (name, version, json) => kept.write(`${name}@${version}`, json, Date.now()),
77
102
  }
78
103
  }
79
104
 
105
+ // When public versions were published (npm-packages.ts npmPublishTime), which
106
+ // never changes once they are: kept a month, and kept again by the next view
107
+ // of the version past it.
108
+ const PUBLISH_TIME_KEPT_MS = 30 * 24 * 60 * 60_000
109
+ export function npmPublishTimeStore(db: UpstreamCacheStore, debug = false): NpmPublishTimeStore {
110
+ const kept = keptStore<string>(db, 'npm/publish-times', PUBLISH_TIME_KEPT_MS, debug)
111
+ return {
112
+ async read(name, version) {
113
+ const hit = await kept.read(`${name}@${version}`)
114
+ return typeof hit?.value === 'string' ? hit.value : null
115
+ },
116
+ write: (name, version, at) => kept.write(`${name}@${version}`, at, Date.now()),
117
+ }
118
+ }
119
+
80
120
  // Risk lines, each kept `keptMs`, for good where Infinity, a scan telling one
81
121
  // past RISK_KEPT_MS as stale (repository-risk.ts).
82
122
  export const riskLineStore = <T>(db: UpstreamCacheStore, keptMs: number, debug = false): KeptStore<T> => keptStore<T>(db, 'repository-risk/lines', keptMs, debug)
83
123
  // What a scan read, each read for keptMs (repository-risk.ts): of GitHub, a
84
124
  // repository's lockfiles as listed at a commit (`listings`) and a lockfile's
85
125
  // packages by its blob (`lockfiles`), for SCAN_READ_KEPT_MS; of npm, a
86
- // lockfile's advisories by its blob (`advisories`), for RISK_KEPT_MS; and the
87
- // repositories a scan in parts told whole (`progress`).
88
- export const scanReadStore = <T>(db: UpstreamCacheStore, type: 'listings' | 'lockfiles' | 'advisories' | 'progress', keptMs: number, debug = false): KeptStore<T> =>
126
+ // lockfile's advisories by its blob (`advisories`), for RISK_KEPT_MS; the
127
+ // repositories a scan in parts told whole (`progress`); a repository's
128
+ // Actions' files by the commit read (`action-files`), and the actions and
129
+ // reusable workflows of others they name, each resolved (`actions`), of
130
+ // public repositories alone (repository-actions.ts).
131
+ export const scanReadStore = <T>(db: UpstreamCacheStore, type: 'listings' | 'lockfiles' | 'advisories' | 'progress' | 'action-files' | 'actions', keptMs: number, debug = false): KeptStore<T> =>
89
132
  keptStore<T>(db, `repository-risk/${type}`, keptMs, debug)
133
+ // What the code viewer read of a repository (http.ts readWholeRepository), by
134
+ // its id and what was asked, each kept REPOSITORY_READ_KEPT_MS.
135
+ export const REPOSITORY_READ_KEPT_MS = 24 * 60 * 60_000
136
+ export const repositoryReadStore = (db: UpstreamCacheStore, debug = false): KeptStore<object> =>
137
+ keptStore<object>(db, 'repository/reads', REPOSITORY_READ_KEPT_MS, debug)
138
+ // Pages of a repository's history (repository-history.ts historyFrom), by its
139
+ // id and the commit each starts at, which never change: kept a month after
140
+ // they were last read from GitHub.
141
+ export const HISTORY_PAGE_KEPT_MS = 30 * 24 * 60 * 60_000
142
+ export const historyPageStore = <T>(db: UpstreamCacheStore, debug = false): KeptStore<T> => keptStore<T>(db, 'repository/history', HISTORY_PAGE_KEPT_MS, debug)
143
+ // The pull requests squashed into a repository's commits, by the commit
144
+ // (repository-history.ts squashedPulls): kept as its history's pages are.
145
+ export const historySquashStore = <T>(db: UpstreamCacheStore, debug = false): KeptStore<T> => keptStore<T>(db, 'repository/squashes', HISTORY_PAGE_KEPT_MS, debug)
146
+ // The files each of a repository's commits changes (repository-changes.ts
147
+ // repositoryChanges), by the commit, which never change: kept as its
148
+ // history's pages are.
149
+ export const historyChangeStore = <T>(db: UpstreamCacheStore, debug = false): KeptStore<T> => keptStore<T>(db, 'repository/changes', HISTORY_PAGE_KEPT_MS, debug)
150
+ // The files a repository's commit holds (repository-changes.ts
151
+ // repositoryTree), by the commit, which never change: kept as its history's
152
+ // pages are.
153
+ export const historyTreeStore = <T>(db: UpstreamCacheStore, debug = false): KeptStore<T> => keptStore<T>(db, 'repository/trees', HISTORY_PAGE_KEPT_MS, debug)
154
+ // The packages a repository's pull request's lockfiles add
155
+ // (pull-dependencies.ts), by the commits of its base and head, which never
156
+ // change: kept as its history's pages are.
157
+ export const pullDependencyStore = <T>(db: UpstreamCacheStore, debug = false): KeptStore<T> => keptStore<T>(db, 'repository/pull-dependencies', HISTORY_PAGE_KEPT_MS, debug)
158
+ // The versions a repository's lockfiles locked at the commit its dependencies
159
+ // are reviewed since (pull-dependency-reviews.ts reviewBaseline), by the
160
+ // commit, which never change: kept as its history's pages are, never stored
161
+ // as reviews.
162
+ // The lines a version changes against another, or none (npm-delta.ts), by
163
+ // both sides' integrities, each kept HISTORY_PAGE_KEPT_MS.
164
+ export const npmDeltaStore = <T>(db: UpstreamCacheStore, debug = false): KeptStore<T> => keptStore<T>(db, 'npm/deltas', HISTORY_PAGE_KEPT_MS, debug)
165
+ export const reviewBaselineStore = (db: UpstreamCacheStore, debug = false): KeptStore<{ versions: string[]; unread: string[] }> =>
166
+ keptStore<{ versions: string[]; unread: string[] }>(db, 'repository/review-baselines', HISTORY_PAGE_KEPT_MS, debug)
@@ -1,13 +1,14 @@
1
1
  // Loading a published version for the npm package viewer (npm-packages.ts):
2
2
  // its tarball, kept on disk as bundle builds keep theirs, else read from the
3
- // registry, and the viewer's response built from it, each shared by the
4
- // readers asking for it meanwhile and held to a few at once per process.
3
+ // registry, its files unpacked from it, and the viewer's response built from
4
+ // them, each shared by the readers asking for it meanwhile and held to a few
5
+ // versions at once per process; its files kept a little while after.
5
6
  import { Buffer } from 'node:buffer'
6
7
  import { homedir } from 'node:os'
7
8
  import { join, resolve } from 'node:path'
8
- import { promisify } from 'node:util'
9
- import { gunzip } from 'node:zlib'
9
+ import { CompressionError, decompress } from '@preventive/archive/compression.js'
10
10
  import { encodeBrotli } from './brotli.ts'
11
+ import { keptBriefly } from './kept-briefly.ts'
11
12
  import { MAX_NPM_PACKAGE_BYTES, MAX_NPM_PACKAGE_FILES, NPM_REGISTRY, NpmPackageError, type NpmPackageFile, type NpmVersionDocument, askUpstream,
12
13
  npmFileRows, npmPublishTime, npmToken, readNpmTar } from './npm-packages.ts'
13
14
  import { keepFile, readKeptFile, upstreamCachePath } from './upstream-cache.ts'
@@ -17,22 +18,30 @@ import { keepFile, readKeptFile, upstreamCachePath } from './upstream-cache.ts'
17
18
  // arrives.
18
19
  export const MAX_TAR_BYTES = 96 * 1024 * 1024
19
20
  const TARBALL_TIMEOUT_MS = 120_000
20
- // Loads in flight per process; each can hold a tarball, its tar, its files
21
- // and their encoded JSON.
21
+ // Loads in flight per process, those of one version (its tarball, its files,
22
+ // its response) as one, as they read its tarball and unpack it once between
23
+ // them; each can hold a tarball, its tar, its files and their encoded JSON.
22
24
  const MAX_ACTIVE_LOADS = 4
23
-
24
- const gunzipAsync = promisify(gunzip)
25
+ // How long a public version's files are kept once unpacked, and what they
26
+ // may take between them, the least lately read going first past it:
27
+ // Compare's requests for a pair of versions, and the Code tab's, come one
28
+ // after another, each loading the same files. A private version's are never
29
+ // kept, as nothing else of it is past its readers.
30
+ const UNPACKED_KEPT_MS = 60_000
31
+ const MAX_UNPACKED_BYTES = 128 * 1024 * 1024
25
32
 
26
33
  // A load's result, shared by its readers, and a reader's release of it.
27
34
  export interface NpmLoad<T> { result: Promise<T>; release: () => void }
28
35
 
29
- interface Load { job: Promise<unknown>; readers: number; settled: boolean }
36
+ interface Load { job: Promise<unknown>; readers: number; settled: boolean; slot: string }
30
37
 
31
38
  // Loads by what they read; readers asking for the same share one. A load
32
39
  // keeps its place among the active ones until its work is done and each
33
40
  // reader has released it, as a reader does once its response is written or
34
- // abandoned, so what responses still hold counts too.
41
+ // abandoned, so what responses still hold counts too. Places by the slot
42
+ // their loads share, with how many hold each.
35
43
  const loads = new Map<string, Load>()
44
+ const slots = new Map<string, number>()
36
45
 
37
46
  const refused = <T>(status: number, code: string): NpmLoad<T> => {
38
47
  const result = Promise.reject(new NpmPackageError(status, code))
@@ -40,19 +49,30 @@ const refused = <T>(status: number, code: string): NpmLoad<T> => {
40
49
  return { result, release() {} }
41
50
  }
42
51
 
43
- function shared<T>(key: string, doc: NpmVersionDocument, work: () => Promise<T>): NpmLoad<T> {
52
+ // What names a version's tarball, and so its files: its integrity and URL,
53
+ // and whether it is read with the token.
54
+ const tarballKey = (doc: NpmVersionDocument) => `${doc.dist.integrity} ${doc.dist.tarball} ${doc.private}`
55
+
56
+ function shared<T>(what: string, doc: NpmVersionDocument, work: () => Promise<T>): NpmLoad<T> {
44
57
  const { unpackedSize, fileCount } = doc.dist
45
58
  if ((unpackedSize ?? 0) > MAX_NPM_PACKAGE_BYTES || (fileCount ?? 0) > MAX_NPM_PACKAGE_FILES) return refused(413, 'package-too-large')
59
+ const slot = tarballKey(doc)
60
+ return sharedLoad(`${what} ${slot}`, work, 'npm-busy', slot)
61
+ }
62
+
63
+ // A load among the active ones, by `key`, refused as `busy` past them: a
64
+ // repository directory's (repository-code.ts) as a package's, each holding
65
+ // alike a tarball, its tar, its files and their encoded JSON. Loads of one
66
+ // `slot` take one place between them.
67
+ export function sharedLoad<T>(key: string, work: () => Promise<T>, busy: string, slot = key): NpmLoad<T> {
46
68
  let load = loads.get(key)
47
69
  if (!load) {
48
- if (loads.size >= MAX_ACTIVE_LOADS) return refused(429, 'npm-busy')
49
- const created: Load = { job: work(), readers: 0, settled: false }
50
- const settle = () => {
51
- created.settled = true
52
- if (created.readers === 0 && loads.get(key) === created) loads.delete(key)
53
- }
54
- created.job.then(settle, settle)
70
+ if (!slots.has(slot) && slots.size >= MAX_ACTIVE_LOADS) return refused(429, busy)
71
+ const created: Load = { job: work(), readers: 0, settled: false, slot }
72
+ const finish = () => { settle(key, created) }
73
+ created.job.then(finish, finish)
55
74
  loads.set(key, created)
75
+ slots.set(slot, (slots.get(slot) ?? 0) + 1)
56
76
  load = created
57
77
  }
58
78
  const held = load
@@ -63,11 +83,27 @@ function shared<T>(key: string, doc: NpmVersionDocument, work: () => Promise<T>)
63
83
  release() {
64
84
  if (released) return
65
85
  released = true
66
- if (--held.readers === 0 && held.settled && loads.get(key) === held) loads.delete(key)
86
+ held.readers--
87
+ done(key, held)
67
88
  },
68
89
  }
69
90
  }
70
91
 
92
+ function settle(key: string, load: Load) {
93
+ load.settled = true
94
+ done(key, load)
95
+ }
96
+
97
+ // A load gone from the active ones, and its place with it, once its work is
98
+ // done and its readers have released it.
99
+ function done(key: string, load: Load) {
100
+ if (load.readers !== 0 || !load.settled || loads.get(key) !== load) return
101
+ loads.delete(key)
102
+ const holding = slots.get(load.slot)! - 1
103
+ if (holding === 0) slots.delete(load.slot)
104
+ else slots.set(load.slot, holding)
105
+ }
106
+
71
107
  // The sha512 an integrity names, in base64, or null where it names none.
72
108
  const sha512Of = (integrity: string) => /(?:^|\s)sha512-([\d+/A-Za-z]{86}==)(?=\s|$)/u.exec(integrity)?.[1] ?? null
73
109
  // Hashed off the event loop: a tarball runs to tens of MiB.
@@ -125,43 +161,84 @@ async function readTarball(doc: NpmVersionDocument): Promise<Uint8Array> {
125
161
  return bytes
126
162
  }
127
163
 
128
- async function readFiles(tarball: Uint8Array): Promise<NpmPackageFile[]> {
129
- let tar: Buffer
130
- try { tar = await gunzipAsync(tarball, { maxOutputLength: MAX_TAR_BYTES }) }
164
+ // Tarballs being read, and versions' files being unpacked, by what names
165
+ // them (tarballKey): readers asking for one meanwhile share it, its bytes
166
+ // those each one's own document names.
167
+ const reading = new Map<string, Promise<Uint8Array>>()
168
+ const unpacking = new Map<string, Promise<Unpacked>>()
169
+
170
+ function sharedRead<T>(pending: Map<string, Promise<T>>, key: string, read: () => Promise<T>): Promise<T> {
171
+ let job = pending.get(key)
172
+ if (!job) {
173
+ const started = read().finally(() => { if (pending.get(key) === started) pending.delete(key) })
174
+ pending.set(key, job = started)
175
+ }
176
+ return job
177
+ }
178
+
179
+ const sharedTarball = (doc: NpmVersionDocument) => sharedRead(reading, tarballKey(doc), () => readTarball(doc))
180
+
181
+ // A version's files, and the size of the tarball they came in.
182
+ interface Unpacked { files: NpmPackageFile[]; tarballSize: number }
183
+
184
+ // Public versions' files lately unpacked, by tarballKey.
185
+ const unpacked = keptBriefly<Unpacked>({ keptMs: UNPACKED_KEPT_MS, maxBytes: MAX_UNPACKED_BYTES })
186
+
187
+ // The version's files, kept from a load of them a little while since, else
188
+ // unpacked from its tarball (and kept, a public version's): bytes its
189
+ // reader's own document names, as its tarball's are.
190
+ function unpackedOf(doc: NpmVersionDocument): Promise<Unpacked> {
191
+ const key = tarballKey(doc)
192
+ const kept = unpacked.get(key)
193
+ if (kept) return Promise.resolve(kept)
194
+ return sharedRead(unpacking, key, async () => {
195
+ const tarball = await sharedTarball(doc)
196
+ const value = { files: await readTarballFiles(tarball), tarballSize: tarball.byteLength }
197
+ if (!doc.private) unpacked.keep(key, value, value.files.reduce((sum, file) => sum + file.path.length + file.bytes.byteLength, 0))
198
+ return value
199
+ })
200
+ }
201
+
202
+ // A gzipped tarball's files, its top directory left out, as npm extracts
203
+ // a package's: a repository tree's as GitHub archives it, too, its symlinks
204
+ // let climb as readNpmTar's `symlinksAbove` says.
205
+ export async function readTarballFiles(tarball: Uint8Array, options: { symlinksAbove?: number } = {}): Promise<NpmPackageFile[]> {
206
+ let tar: Uint8Array
207
+ try { tar = await decompress(tarball, 'gzip', { limit: MAX_TAR_BYTES }) }
131
208
  catch (err) {
132
- throw new NpmPackageError((err as { code?: string }).code === 'ERR_BUFFER_TOO_LARGE' ? 413 : 422,
133
- (err as { code?: string }).code === 'ERR_BUFFER_TOO_LARGE' ? 'package-too-large' : 'bad-tarball')
209
+ const limited = err instanceof CompressionError && err.limited
210
+ throw new NpmPackageError(limited ? 413 : 422, limited ? 'package-too-large' : 'bad-tarball')
134
211
  }
135
- return readNpmTar(tar)
212
+ return readNpmTar(tar, options)
136
213
  }
137
214
 
138
215
  // The version's tarball, for download.
139
216
  export function loadNpmTarball(doc: NpmVersionDocument): NpmLoad<Uint8Array> {
140
- return shared(`tarball ${doc.dist.integrity} ${doc.dist.tarball}`, doc, () => readTarball(doc))
217
+ return shared('tarball', doc, () => sharedTarball(doc))
141
218
  }
142
219
 
143
220
  // The version's files, for one of them to be pretty-printed (pretty-print.ts).
144
221
  export function loadNpmFiles(doc: NpmVersionDocument): NpmLoad<NpmPackageFile[]> {
145
- return shared(`files ${doc.dist.integrity} ${doc.dist.tarball}`, doc, async () => readFiles(await readTarball(doc)))
222
+ return shared('files', doc, async () => (await unpackedOf(doc)).files)
146
223
  }
147
224
 
148
225
  // The version as the viewer reads it, as brotli-encoded JSON: `{ name,
149
226
  // version, private, integrity, tarballSize, published, manifest, files,
150
227
  // known }`, its files as npmFileRows gives them, and with them, as its files
151
- // are encoded, when it was published where that is kept (npmPublishTime,
152
- // else null) and what `known` gives: what is kept about its package
228
+ // are encoded, when it was published where that is kept (`published`, asked
229
+ // then, else null) and what `known` gives: what is kept about its package
153
230
  // (npm-insights.ts npmKnown), which the viewer need not then ask for. Encoding
154
231
  // is where a load holds the most, so it takes its place among the active
155
232
  // loads until its body is done, and readers asking meanwhile share that body,
156
- // the first one's manifest and `known` in it; nothing is kept once its readers
157
- // are done.
158
- export function loadNpmPackageBody(doc: NpmVersionDocument, known: () => object = () => ({})): NpmLoad<Buffer> {
159
- return shared(`package ${doc.dist.integrity} ${doc.dist.tarball} ${doc.private}`, doc, async () => {
160
- const tarball = await readTarball(doc)
161
- const files = await readFiles(tarball)
233
+ // the first one's manifest, `published` and `known` in it; the body is not
234
+ // kept once its readers are done, its files a little while (unpackedOf).
235
+ export function loadNpmPackageBody(doc: NpmVersionDocument, known: () => object = () => ({}),
236
+ published: () => Promise<string | null> = npmPublishTime(doc).read): NpmLoad<Buffer> {
237
+ return shared('package', doc, async () => {
238
+ const { files, tarballSize } = await unpackedOf(doc)
162
239
  return encodeBrotli(Buffer.from(JSON.stringify({
163
- name: doc.name, version: doc.version, private: doc.private, integrity: doc.dist.integrity, tarballSize: tarball.byteLength,
164
- published: npmPublishTime(doc.name, doc.version), manifest: doc.manifest, files: npmFileRows(files), known: known(),
240
+ name: doc.name, version: doc.version, private: doc.private, integrity: doc.dist.integrity, tarballSize,
241
+ published: await published(), manifest: doc.manifest, files: npmFileRows(files), known: known(),
165
242
  })))
166
243
  })
167
244
  }
@@ -14,6 +14,7 @@ import { Buffer } from 'node:buffer'
14
14
  import { createHash } from 'node:crypto'
15
15
  import { rm } from 'node:fs/promises'
16
16
  import { resolve } from 'node:path'
17
+ import { ArchiveError, type Entry, unpack } from '@preventive/archive/tar.js'
17
18
  import { isExactVersion } from '@preventive/upstream/semver.js'
18
19
  import { isNpmPackageName, isNpmPackageSpec, npmPackageScope } from '../common/managed/npm-packages.js'
19
20
  import type { Role } from '../common/managed/roles.ts'
@@ -33,6 +34,9 @@ const MAX_DOCUMENT_BYTES = 4 * PACKUMENT_BYTES
33
34
  // stream holding them is bounded in npm-loads.ts.
34
35
  export const MAX_NPM_PACKAGE_BYTES = 64 * 1024 * 1024
35
36
  export const MAX_NPM_PACKAGE_FILES = 20_000
37
+ // The entries its tar stream may hold, read before any is counted as a file:
38
+ // a directory's too, as a GitHub tree's tarball has one for each, npm's none.
39
+ export const MAX_NPM_TAR_ENTRIES = 2 * MAX_NPM_PACKAGE_FILES
36
40
  // What the files may take as the viewer's JSON. Escaping grows a text up to
37
41
  // six times (a control character becomes `\u0001`), so it is counted before
38
42
  // anything is serialized.
@@ -149,14 +153,60 @@ async function publicWholeDocument(name: string, url: string, { limit, signal }:
149
153
  return json
150
154
  }
151
155
 
156
+ // Whole documents being asked of npm as anyone asks, by URL and limit:
157
+ // readers asking for one meanwhile share it, as opening a version asks for
158
+ // its package's for its versions, its advisories and its risk at once; each
159
+ // gives up on it alone once its own signal aborts, and it is given up on once
160
+ // they all have. None is asked with the token. Read alike by every reader,
161
+ // none changing it.
162
+ const wholeAsks = new Map<string, { asked: Promise<Record<string, unknown> | null>; controller: AbortController; readers: number }>()
163
+
164
+ function sharedWholeDocument(name: string, url: string, { limit, signal }: { limit: number; signal: AbortSignal }): Promise<Record<string, unknown> | null> {
165
+ if (signal.aborted) return Promise.reject(signal.reason)
166
+ const key = `${url} ${limit}`
167
+ let ask = wholeAsks.get(key)
168
+ if (!ask) {
169
+ const controller = new AbortController()
170
+ const created = { asked: publicWholeDocument(name, url, { limit, signal: controller.signal }), controller, readers: 0 }
171
+ const settle = () => { if (wholeAsks.get(key) === created) wholeAsks.delete(key) }
172
+ created.asked.then(settle, settle)
173
+ wholeAsks.set(key, ask = created)
174
+ }
175
+ const held = ask
176
+ held.readers++
177
+ return new Promise((answer, fail) => {
178
+ let left = false
179
+ const leave = () => {
180
+ if (left) return false
181
+ left = true
182
+ held.readers--
183
+ signal.removeEventListener('abort', onAbort)
184
+ return true
185
+ }
186
+ const onAbort = () => {
187
+ if (!leave()) return
188
+ if (held.readers === 0) {
189
+ if (wholeAsks.get(key) === held) wholeAsks.delete(key)
190
+ held.controller.abort(signal.reason)
191
+ }
192
+ fail(signal.reason)
193
+ }
194
+ const answered = (json: Record<string, unknown> | null) => { if (leave()) answer(json) }
195
+ const failed = (err: unknown) => { if (leave()) fail(err) }
196
+ signal.addEventListener('abort', onAbort, { once: true })
197
+ held.asked.then(answered, failed)
198
+ })
199
+ }
200
+
152
201
  // Asked anonymously first, unless its caller knows npm has none for anyone
153
202
  // (`anonymous` false): a reader without private access stops there. One
154
203
  // with it asks again with the token where npmTokenReads says so, and the
155
204
  // answer is private. A whole document (`whole`) anyone may read is kept
156
- // (publicWholeDocument).
205
+ // (publicWholeDocument), and asked once for those asking at once
206
+ // (sharedWholeDocument).
157
207
  async function readDocument(name: string, url: string, privileged: boolean,
158
208
  { whole = false, anonymous = true, ...options }: { accept: string; limit: number; signal: AbortSignal; whole?: boolean; anonymous?: boolean }) {
159
- const open = anonymous ? whole ? await publicWholeDocument(name, url, options) : await registryDocument(url, { ...options, token: null }) : null
209
+ const open = anonymous ? whole ? await sharedWholeDocument(name, url, options) : await registryDocument(url, { ...options, token: null }) : null
160
210
  if (open) return { json: open, private: false }
161
211
  if (!npmTokenReads(name, privileged)) return null
162
212
  const json = await registryDocument(url, { ...options, token: npmToken() })
@@ -199,6 +249,8 @@ function versionDocument(name: string, json: Record<string, unknown>, isPrivate:
199
249
  // (npm-manifest.ts npmVersionFields), as the database keeps them (npm-kept.ts).
200
250
  export interface NpmVersionStore {
201
251
  read(name: string, version: string): Promise<{ at: number; json: Record<string, unknown> } | null>
252
+ // Those of `versions` kept, by `name@version`, in one read.
253
+ readMany?(versions: { name: string; version: string }[]): Promise<Map<string, { at: number; json: Record<string, unknown> }>>
202
254
  write(name: string, version: string, json: Record<string, unknown>): Promise<void>
203
255
  }
204
256
 
@@ -224,6 +276,20 @@ export function readNpmVersion(name: string, spec: string, privileged: boolean,
224
276
  })
225
277
  }
226
278
 
279
+ // `store`, where it reads many at once, with the exact ones of `versions`
280
+ // read from it in one read, for readNpmVersion to read each of those from as
281
+ // it asks: a version and the one it is compared with, read together.
282
+ export function readTogether(store: NpmVersionStore, versions: { name: string; version: string }[]): NpmVersionStore {
283
+ const exact = versions.filter(({ version }) => isExactVersion(version))
284
+ if (!store.readMany || exact.length < 2) return store
285
+ const keys = new Set(exact.map(({ name, version }) => `${name}@${version}`))
286
+ const kept = store.readMany(exact)
287
+ return { ...store, async read(name, version) {
288
+ const key = `${name}@${version}`
289
+ return keys.has(key) ? (await kept).get(key) ?? null : store.read(name, version)
290
+ } }
291
+ }
292
+
227
293
  // The package's dist-tags and versions, newest published first, from its
228
294
  // abbreviated document; with `times`, from its whole one, which alone has
229
295
  // when each was published, `times` by version (ISO dates), and `created`,
@@ -273,77 +339,57 @@ const isPublishTime = (value: string | undefined) => typeof value === 'string' &
273
339
  const PUBLISH_TIMES = 100_000
274
340
  const publishTimes = new Map<string, string>()
275
341
 
276
- export const npmPublishTime = (name: string, version: string): string | null => publishTimes.get(`${name}@${version}`) ?? null
277
-
278
- export interface NpmPackageFile { path: string; bytes: Uint8Array }
279
-
280
- const textOf = (bytes: Uint8Array, start: number, length: number) => {
281
- const field = bytes.subarray(start, start + length)
282
- const end = field.indexOf(0)
283
- return Buffer.from(end === -1 ? field : field.subarray(0, end)).toString('utf8')
342
+ // Where public versions' publish times are kept beyond the instance, as the
343
+ // database keeps them (npm-kept.ts npmPublishTimeStore).
344
+ export interface NpmPublishTimeStore {
345
+ read(name: string, version: string): Promise<string | null>
346
+ write(name: string, version: string, at: string): Promise<void>
284
347
  }
285
348
 
286
- function sizeOf(bytes: Uint8Array, offset: number): number {
287
- // GNU base-256 for sizes past the 11 octal digits.
288
- if (bytes[offset]! & 0x80) {
289
- let size = bytes[offset]! & 0x7f
290
- for (let i = 1; i < 12; i++) size = size * 256 + bytes[offset + i]!
291
- return size
349
+ // When `doc` was published: as noted here, else as `store` keeps a public
350
+ // version's, so that another instance, or this one restarted, has it before
351
+ // it reads the whole document again. The store is read once, at once, beside
352
+ // whatever its caller reads meanwhile, a failure read as none; `read` looks
353
+ // here again as it is asked, as the whole document read meanwhile may have
354
+ // noted it by then, and `keep` keeps there the time noted here where it read
355
+ // none.
356
+ export function npmPublishTime(doc: Pick<NpmVersionDocument, 'name' | 'version' | 'private'>, store?: NpmPublishTimeStore) {
357
+ const noted = () => publishTimes.get(`${doc.name}@${doc.version}`) ?? null
358
+ const kept = store && !doc.private ? store.read(doc.name, doc.version).catch(() => null) : Promise.resolve(null)
359
+ return {
360
+ read: async (): Promise<string | null> => noted() ?? await kept ?? noted(),
361
+ async keep() {
362
+ const at = noted()
363
+ if (at !== null && store && !doc.private && await kept === null) await store.write(doc.name, doc.version, at)
364
+ },
292
365
  }
293
- const text = textOf(bytes, offset, 12).trim()
294
- return /^[0-7]+$/u.test(text) ? Number.parseInt(text, 8) : Number.NaN
295
366
  }
296
367
 
297
- // The path a package's file is installed at: npm drops the first component
298
- // (`package/`, or whatever the archive used), and a path that would escape the
299
- // package, or names nothing, is not extracted.
300
- function packagePath(raw: string): string | null {
301
- const parts = raw.split('/').slice(1).filter(part => part !== '' && part !== '.')
302
- return parts.length === 0 || parts.includes('..') ? null : parts.join('/')
303
- }
368
+ export interface NpmPackageFile { path: string; bytes: Uint8Array }
304
369
 
305
- // A pax header's path. Each record is `<length> <key>=<value>\n`, its length
306
- // in bytes, so records are walked as bytes and only their values decoded.
307
- function paxPath(data: Uint8Array): string | null {
308
- let path = null
309
- let at = 0
310
- while (at < data.length) {
311
- const space = data.indexOf(0x20, at)
312
- const length = Number.parseInt(Buffer.from(data.subarray(at, space)).toString('latin1'), 10)
313
- if (space === -1 || !Number.isSafeInteger(length) || length <= 0) break
314
- const record = Buffer.from(data.subarray(space + 1, at + length - 1)).toString('utf8')
315
- if (record.startsWith('path=')) path = record.slice(5)
316
- at += length
370
+ // The regular files of a tar stream, by package path: npm drops the first
371
+ // component (`package/`, or whatever the archive used), and a file at the top
372
+ // is not extracted. The stream is read by @preventive/archive, which refuses
373
+ // one malformed, a name or a symlink that would climb out of it -- a symlink
374
+ // past `symlinksAbove` levels above it, none unless given -- and a name
375
+ // repeated as another entry, and gives up past MAX_NPM_TAR_ENTRIES before
376
+ // reading on; a file under another top directory at the same path replaces
377
+ // an earlier one, as extraction would. Directories, links and devices hold no
378
+ // file content and are skipped.
379
+ export function readNpmTar(tar: Uint8Array, { symlinksAbove = 0 }: { symlinksAbove?: number } = {}): NpmPackageFile[] {
380
+ let entries: Entry[]
381
+ try { entries = unpack(tar, { maxEntries: MAX_NPM_TAR_ENTRIES, symlinksAbove }) }
382
+ catch (err) {
383
+ if (err instanceof ArchiveError) throw err.limited ? new NpmPackageError(413, 'package-too-large') : new NpmPackageError(422, 'bad-tarball')
384
+ throw err
317
385
  }
318
- return path
319
- }
320
-
321
- // The regular files of a tar stream, by package path, a later entry
322
- // replacing an earlier one as extraction would. Directories, links and
323
- // devices hold no file content and are skipped.
324
- export function readNpmTar(tar: Uint8Array): NpmPackageFile[] {
325
386
  const files = new Map<string, Uint8Array>()
326
387
  let total = 0
327
- let longName: string | null = null
328
- let offset = 0
329
- while (offset + 512 <= tar.length) {
330
- const header = tar.subarray(offset, offset + 512)
331
- if (header.every(byte => byte === 0)) break
332
- const size = sizeOf(header, 124)
333
- const type = String.fromCodePoint(header[156]!)
334
- const start = offset + 512
335
- if (!Number.isSafeInteger(size) || size < 0 || start + size > tar.length) throw new NpmPackageError(422, 'bad-tarball')
336
- const data = tar.subarray(start, start + size)
337
- offset = start + Math.ceil(size / 512) * 512
338
- if (type === 'x' || type === 'L') { longName = type === 'x' ? paxPath(data) : textOf(data, 0, data.length); continue }
339
- if (type === 'g' || type === 'K') continue
340
- const prefix = textOf(header, 257, 6) === 'ustar' ? textOf(header, 345, 155) : ''
341
- const name = longName ?? (prefix ? `${prefix}/${textOf(header, 0, 100)}` : textOf(header, 0, 100))
342
- longName = null
343
- if (!['0', '\0', '7'].includes(type)) continue
344
- const path = packagePath(name)
345
- if (path === null) continue
346
- total += size - (files.get(path)?.length ?? 0)
388
+ for (const { name, type, data } of entries) {
389
+ const slash = name.indexOf('/')
390
+ if ((type !== 'file' && type !== 'contiguous-file') || slash === -1) continue
391
+ const path = name.slice(slash + 1)
392
+ total += data.length - (files.get(path)?.length ?? 0)
347
393
  files.set(path, data)
348
394
  if (files.size > MAX_NPM_PACKAGE_FILES || total > MAX_NPM_PACKAGE_BYTES) throw new NpmPackageError(413, 'package-too-large')
349
395
  }
@@ -35,6 +35,7 @@ import { buffer } from 'node:stream/consumers'
35
35
  import type { CacheStore } from '@preventive/upstream/npm.js'
36
36
  import { DEFAULT_MINIMUM_RELEASE_AGE, type NpmAssessment, assessNpm, npmReadyDownload, npmReadyDownloads } from '../risk/index.js'
37
37
  import { CacheMissError, type CacheStorage } from './cache-storage.ts'
38
+ import { npmVersionStore } from './npm-kept.ts'
38
39
  import { loadNpmTarball } from './npm-loads.ts'
39
40
  import { NpmPackageError, type NpmVersionDocument, npmTokenReads, readNpmPackument, readNpmVersion } from './npm-packages.ts'
40
41
  import { npmPackageScope } from '../common/managed/npm-packages.js'
@@ -176,7 +177,9 @@ export function npmRisk(doc: NpmVersionDocument, old: NpmVersionDocument | 'pick
176
177
  return scopes.get(scope)!
177
178
  }
178
179
  const given = old === 'pick' ? null : old
179
- const documentOf = async (version: string) => [doc, given].find(read => read?.version === version) ?? await readNpmVersion(doc.name, version, privileged, signal) ?? undefined
180
+ // The one picked, read as the viewer reads a version, kept a day where public.
181
+ const documentOf = async (version: string) => [doc, given].find(read => read?.version === version)
182
+ ?? await readNpmVersion(doc.name, version, privileged, signal, npmVersionStore(stores.db, stores.debug)) ?? undefined
180
183
  return bounded(spare, () => assessNpm(doc.name, doc.version, { oldVersion: old === 'pick' ? true : old?.version ?? null, fromEmpty,
181
184
  minimumReleaseAge: teamScoped ? 0 : DEFAULT_MINIMUM_RELEASE_AGE,
182
185
  popularity: !teamScoped, cache: npmRiskCache(stores, !doc.private && !given?.private), signal, dependencies: !spare, advisories: !spare, osv: !doc.private, owned,