@preventive/triage 1.0.0-alpha.45 → 1.0.0-alpha.47
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.
- package/client/linked-findings.js +3 -0
- package/common/bundle-metadata.d.ts +1 -0
- package/common/bundle-metadata.js +1 -1
- package/common/bundle-sources.js +5 -1
- package/common/line-diff.js +5 -4
- package/common/managed/agent-tasks.ts +5 -3
- package/common/managed/github-agents.ts +28 -10
- package/out/activity.js +4 -0
- package/out/{chunk-NPW52BM2.js → chunk-4HI2MSAV.js} +13 -13
- package/out/chunk-5AHIQFFG.js +36 -0
- package/out/chunk-6ANSHRFQ.js +6 -0
- package/out/chunk-6WF3KNTL.js +3 -0
- package/out/chunk-7BKKYVWR.js +7 -0
- package/out/{chunk-HMAJDVXN.js → chunk-FH24VHSJ.js} +1 -1
- package/out/chunk-NOZJULWS.js +52 -0
- package/out/{chunk-OKRLLE5I.js → chunk-PWFUOLZK.js} +1 -1
- package/out/chunk-S6U2J62U.js +232 -0
- package/out/chunk-SPEFPML6.js +2 -0
- package/out/chunk-UENXT2HK.js +3 -0
- package/out/chunk-VL43BTSO.js +1 -0
- package/out/{chunk-3PS4KWD2.js → chunk-WCPJDAYA.js} +1 -1
- package/out/chunk-ZCS4UYHI.js +1 -0
- package/out/chunk-ZGNMDGSB.js +1 -0
- package/out/client-managed-import.js +1 -1
- package/out/client-managed.js +15 -15
- package/out/client-sync.js +2 -2
- package/out/compare.js +12 -12
- package/out/dialogs.js +28 -28
- package/out/graph.js +11 -11
- package/out/index.html +11 -11
- package/out/scan.js +2 -2
- package/out/terminal.js +6 -6
- package/out/view.css +1 -1
- package/out/view.js +15 -15
- package/package.json +4 -1
- package/risk/actions/findings.js +14 -9
- package/risk/actions/scripts/shell.js +5 -1
- package/risk/index.d.ts +9 -4
- package/risk/npm/assess.js +15 -4
- package/risk/npm/registry.js +6 -1
- package/server-e2e/app.ts +5 -4
- package/server-e2e/http.ts +5 -2
- package/server-managed/activity.ts +24 -11
- package/server-managed/app-agents.ts +10 -21
- package/server-managed/bundle-build-worker.js +7 -3
- package/server-managed/bundle-build.ts +5 -3
- package/server-managed/bundle-cache.ts +16 -4
- package/server-managed/bundle.ts +3 -4
- package/server-managed/combined.ts +3 -2
- package/server-managed/commit-coauthors.ts +5 -9
- package/server-managed/commit-lockfiles.ts +21 -11
- package/server-managed/config.ts +6 -0
- package/server-managed/db-methods.ts +321 -156
- package/server-managed/db-neon.ts +71 -24
- package/server-managed/db-schema.ts +4 -0
- package/server-managed/db.ts +2 -1
- package/server-managed/dependency-reviews.ts +13 -3
- package/server-managed/github-app.ts +12 -6
- package/server-managed/github-issue-links.ts +11 -5
- package/server-managed/github-pulls.ts +2 -2
- package/server-managed/github-requests.ts +39 -0
- package/server-managed/http-response.ts +10 -2
- package/server-managed/http.ts +269 -143
- package/server-managed/index.ts +8 -7
- package/server-managed/kept-briefly.ts +37 -0
- package/server-managed/link-reports.ts +5 -1
- package/server-managed/npm-delta.ts +11 -6
- package/server-managed/npm-kept.ts +51 -7
- package/server-managed/npm-loads.ts +117 -35
- package/server-managed/npm-packages.ts +107 -14
- package/server-managed/npm-risk.ts +68 -18
- package/server-managed/pretty-print.ts +112 -35
- package/server-managed/pull-dependencies.ts +29 -9
- package/server-managed/pull-dependency-reviews.ts +8 -8
- package/server-managed/queue.ts +16 -0
- package/server-managed/rename-diff.ts +22 -13
- package/server-managed/report-sources.ts +1 -1
- package/server-managed/report-store.ts +50 -12
- package/server-managed/repository-actions.ts +15 -6
- package/server-managed/repository-agents.ts +1 -1
- package/server-managed/repository-browser.ts +123 -33
- package/server-managed/repository-code.ts +28 -11
- package/server-managed/repository-dependency-reviews.ts +41 -19
- package/server-managed/repository-history.ts +24 -8
- package/server-managed/repository-pulse.ts +9 -2
- package/server-managed/repository-risk-audit.ts +13 -0
- package/server-managed/repository-risk-read.ts +11 -6
- package/server-managed/repository-risk.ts +27 -20
- package/server-managed/session.ts +18 -4
- package/server-managed/solidity-entry-points.ts +5 -3
- package/server-managed/sql-postgres.ts +3 -0
- package/server-managed/sql.ts +6 -2
- package/server-managed/storage-db.ts +11 -3
- package/server-managed/storage-encryption.ts +7 -4
- package/server-managed/storage.ts +8 -4
- package/server-managed/team-feed.ts +3 -1
- package/server-managed/team-reports.ts +30 -13
- package/server-managed/uploads.ts +29 -9
- package/server-managed/upstream-cache.ts +12 -0
- package/server.ts +14 -5
- package/out/chunk-5DHL5NG3.js +0 -2
- package/out/chunk-6HNXNIQY.js +0 -52
- package/out/chunk-AFLZH7WG.js +0 -1
- package/out/chunk-NV7ES4EW.js +0 -1
- package/out/chunk-PTGDYLNS.js +0 -36
- package/out/chunk-Q6MVYGYV.js +0 -3
- package/out/chunk-QRD6GIG5.js +0 -229
- package/out/chunk-WI7Z5A2T.js +0 -6
- package/out/chunk-WQ74ZBZF.js +0 -7
- package/out/chunk-XVBEOGOS.js +0 -3
|
@@ -7,24 +7,36 @@
|
|
|
7
7
|
// active ones (npm-loads.ts sharedLoad).
|
|
8
8
|
import { Buffer } from 'node:buffer'
|
|
9
9
|
import { encodeBrotli } from './brotli.ts'
|
|
10
|
-
import {
|
|
11
|
-
import { type NpmLoad, readTarballFiles, sharedLoad } from './npm-loads.ts'
|
|
10
|
+
import { MAX_NPM_JSON_LENGTH, NpmPackageError, npmFileRows } from './npm-packages.ts'
|
|
11
|
+
import { type NpmLoad, loadPlaces, readTarballFiles, sharedLoad } from './npm-loads.ts'
|
|
12
12
|
|
|
13
|
-
//
|
|
14
|
-
|
|
15
|
-
export const
|
|
13
|
+
// What a directory may hold, bounded apart from a package's (npm-packages.ts),
|
|
14
|
+
// as the viewer holds every file in memory: its files' count and bytes.
|
|
15
|
+
export const MAX_REPOSITORY_CODE_FILES = 80_000
|
|
16
|
+
export const MAX_REPOSITORY_CODE_BYTES = 256 * 1024 * 1024
|
|
17
|
+
// The tarball they come in, bounded in proportion as a package's is: its tar
|
|
18
|
+
// stream, headers and padding included, and its entries, as many again as
|
|
19
|
+
// files, as GitHub's has one for each directory too.
|
|
20
|
+
const REPOSITORY_TAR = { maxFiles: MAX_REPOSITORY_CODE_FILES, maxBytes: MAX_REPOSITORY_CODE_BYTES, maxEntries: 2 * MAX_REPOSITORY_CODE_FILES, maxTarBytes: 384 * 1024 * 1024 }
|
|
21
|
+
// What the files may take as the viewer's JSON, written as one string: short
|
|
22
|
+
// of the longest V8 holds (2^29 - 24 characters).
|
|
23
|
+
const MAX_REPOSITORY_CODE_JSON_LENGTH = 384 * 1024 * 1024
|
|
16
24
|
|
|
17
25
|
// Whether a directory's files fit, by GitHub's listing of its tree and every
|
|
18
26
|
// tree under it (repository-browser.ts repositoryTreeFiles): asked before
|
|
19
27
|
// its tarball is, which may run to hundreds of MiB. One GitHub cut short
|
|
20
28
|
// (null) has too many.
|
|
21
|
-
export function repositoryCodeFits(files:
|
|
29
|
+
export function repositoryCodeFits(files: RepositoryCodeFiles | null): files is RepositoryCodeFiles {
|
|
22
30
|
return files !== null && files.files <= MAX_REPOSITORY_CODE_FILES && files.bytes <= MAX_REPOSITORY_CODE_BYTES
|
|
23
31
|
}
|
|
24
32
|
|
|
33
|
+
// A directory's files' count and bytes, as GitHub lists its tree.
|
|
34
|
+
export interface RepositoryCodeFiles { files: number; bytes: number }
|
|
35
|
+
|
|
25
36
|
// The directory read: the repository by its id and name, the commit, the
|
|
26
|
-
// directory (`''` for the root)
|
|
27
|
-
|
|
37
|
+
// directory (`''` for the root), its tree's id, and its files as listed,
|
|
38
|
+
// which weigh its load among the active ones.
|
|
39
|
+
export interface RepositoryCodeInput { repoId: number; repo: string; commit: string; path: string; tree: string; listed: RepositoryCodeFiles }
|
|
28
40
|
|
|
29
41
|
// A package's refusals, as a directory's: past the bounds, or an archive
|
|
30
42
|
// @preventive/archive refuses, as it does one holding a symlink that climbs
|
|
@@ -39,14 +51,19 @@ const directoryError = (err: unknown) => {
|
|
|
39
51
|
// repo, commit, path, tree, files }`, its files as npmFileRows gives them,
|
|
40
52
|
// by their paths in the directory, from `tarball`, the tree's.
|
|
41
53
|
export function loadRepositoryCodeBody(input: RepositoryCodeInput, tarball: () => Promise<Uint8Array>): NpmLoad<Buffer> {
|
|
42
|
-
const { repoId, repo, commit, path, tree } = input
|
|
54
|
+
const { repoId, repo, commit, path, tree, listed } = input
|
|
55
|
+
// Its places among the active loads, by its files as listed, hold their
|
|
56
|
+
// JSON too, which the listing cannot tell, as escaping may grow a text
|
|
57
|
+
// sixfold: as much as a package's may take for each.
|
|
58
|
+
const places = loadPlaces(listed)
|
|
59
|
+
const maxLength = Math.min(MAX_REPOSITORY_CODE_JSON_LENGTH, places * MAX_NPM_JSON_LENGTH)
|
|
43
60
|
return sharedLoad(`repository ${repoId} ${repo} ${commit} ${path} ${tree}`, async () => {
|
|
44
61
|
let files
|
|
45
62
|
// A symlink may reach the repository's root, as a monorepo package's
|
|
46
63
|
// LICENSE links the root's, and no further: the directory's depth, less
|
|
47
64
|
// the level the archive's root already stands above its top directory.
|
|
48
|
-
try { files = npmFileRows(await readTarballFiles(await tarball(), { symlinksAbove: path.split('/').length - 1 })) }
|
|
65
|
+
try { files = npmFileRows(await readTarballFiles(await tarball(), { ...REPOSITORY_TAR, symlinksAbove: path.split('/').length - 1 }), maxLength) }
|
|
49
66
|
catch (err) { throw directoryError(err) }
|
|
50
67
|
return encodeBrotli(Buffer.from(JSON.stringify({ repoId, repo, commit, path, tree, files })))
|
|
51
|
-
}, 'repository-busy')
|
|
68
|
+
}, 'repository-busy', { places })
|
|
52
69
|
}
|
|
@@ -38,8 +38,13 @@ export interface PublishedReview {
|
|
|
38
38
|
// published reviews of the packages asked.
|
|
39
39
|
export interface RepoReviewState extends Omit<RepoDependencyReview, 'enabled'> { canReview: boolean; reviews: PublishedReview[] }
|
|
40
40
|
|
|
41
|
+
// A package version whose published reviews are asked.
|
|
42
|
+
export interface ReviewedVersionKey { name: string; version: string }
|
|
43
|
+
|
|
41
44
|
export interface RepositoryDependencyReviewStore {
|
|
42
45
|
listRepoDependencyReviews(): Promise<Map<number, RepoDependencyReview>>
|
|
46
|
+
// One repository's, none where it isn't connected.
|
|
47
|
+
repoDependencyReview(repoId: number): Promise<RepoDependencyReview | null>
|
|
43
48
|
// The teams whose published reviews count for each repository (COUNTING_TEAM),
|
|
44
49
|
// by name.
|
|
45
50
|
listRepoReviewTeams(): Promise<Map<number, { id: string; name: string }[]>>
|
|
@@ -47,7 +52,9 @@ export interface RepositoryDependencyReviewStore {
|
|
|
47
52
|
// `since` or `lockfiles` is undefined, which keeps it; the setting then, or
|
|
48
53
|
// null for no repository.
|
|
49
54
|
setRepoDependencyReview(repoId: number, setting: Omit<RepoDependencyReview, 'since' | 'lockfiles'> & { since?: string | null; lockfiles?: LockfileScope }): Promise<RepoDependencyReview | null>
|
|
50
|
-
|
|
55
|
+
// Its review in effect for `user`, as `setting` tells it where read
|
|
56
|
+
// already, with the published reviews of `versions`.
|
|
57
|
+
repoDependencyReviewState(repoId: number, user: string, versions: ReviewedVersionKey[], setting?: RepoDependencyReview | null): Promise<RepoReviewState | null>
|
|
51
58
|
// Which of `users` publish reviews to a team counting for repository
|
|
52
59
|
// `repoId`, whose reviews its pull requests' comments make count.
|
|
53
60
|
listRepoReviewers(repoId: number, users: string[]): Promise<Set<string>>
|
|
@@ -59,33 +66,42 @@ const setting = (row: SettingRow): RepoDependencyReview => ({ enabled: row.enabl
|
|
|
59
66
|
// dependencies and have it, not hidden.
|
|
60
67
|
const COUNTING_TEAM = `t.hidden = 0 AND t.dependency_review = 1 AND EXISTS (
|
|
61
68
|
SELECT 1 FROM managed_team_repo tr WHERE tr.team_id = t.id AND tr.repo_id = ?)`
|
|
62
|
-
const
|
|
69
|
+
const VERSIONS_AT_ONCE = 250
|
|
63
70
|
|
|
64
|
-
// The published and unrevoked reviews of `
|
|
65
|
-
// repository `repoId`, each with whether it is full, a
|
|
66
|
-
// a query
|
|
67
|
-
|
|
71
|
+
// The published and unrevoked reviews of `versions` in teams counting for
|
|
72
|
+
// repository `repoId`, each with whether it is full, a couple of hundred
|
|
73
|
+
// versions a query: those of their packages' and versions' found, the
|
|
74
|
+
// versions asked alone kept, their files' reviews read of those alone.
|
|
75
|
+
async function publishedReviews(db: ManagedSql, repoId: number, versions: ReviewedVersionKey[]): Promise<PublishedReview[]> {
|
|
68
76
|
const found: PublishedReview[] = []
|
|
69
|
-
for (let at = 0; at <
|
|
70
|
-
const
|
|
71
|
-
const
|
|
77
|
+
for (let at = 0; at < versions.length; at += VERSIONS_AT_ONCE) {
|
|
78
|
+
const asked = versions.slice(at, at + VERSIONS_AT_ONCE)
|
|
79
|
+
const keys = new Set(asked.map(({ name, version }) => `${name}@${version}`))
|
|
80
|
+
const [names, numbers] = [[...new Set(asked.map(({ name }) => name))], [...new Set(asked.map(({ version }) => version))]]
|
|
81
|
+
const rows = (await db.prepare(`SELECT r.id, r.user_id AS userId, r.user_login AS userLogin, r.package, r.version, r.integrity,
|
|
72
82
|
NULLIF(r.base_package, '') AS basePackage, NULLIF(r.base_version, '') AS baseVersion, r.files, r.status, r.published_at AS publishedAt
|
|
73
83
|
FROM managed_dependency_review r
|
|
74
84
|
WHERE r.ecosystem = 'npm' AND r.published_at IS NOT NULL AND r.revoked_at IS NULL AND r.status IS NOT NULL
|
|
75
|
-
AND r.package IN (${names.map(() => '?').join(',')})
|
|
85
|
+
AND r.package IN (${names.map(() => '?').join(',')}) AND r.version IN (${numbers.map(() => '?').join(',')})
|
|
76
86
|
AND EXISTS (SELECT 1 FROM managed_dependency_review_team rt JOIN managed_team t ON t.id = rt.team_id
|
|
77
|
-
WHERE rt.review_id = r.id AND ${COUNTING_TEAM})`).all(...names, repoId) as
|
|
87
|
+
WHERE rt.review_id = r.id AND ${COUNTING_TEAM})`).all(...names, ...numbers, repoId) as
|
|
78
88
|
{ id: string; userId: string | null; userLogin: string; package: string; version: string; integrity: string
|
|
79
|
-
basePackage: string | null; baseVersion: string | null; files: string | null; status: ReviewVerdict; publishedAt: number }[]
|
|
89
|
+
basePackage: string | null; baseVersion: string | null; files: string | null; status: ReviewVerdict; publishedAt: number }[])
|
|
90
|
+
.filter(row => keys.has(`${row.package}@${row.version}`))
|
|
80
91
|
if (rows.length === 0) continue
|
|
81
|
-
const entries =
|
|
82
|
-
|
|
92
|
+
const entries = new Map<string, { file: string; status: ReviewVerdict | null }[]>()
|
|
93
|
+
for (const { reviewId, ...entry } of await db.prepare(`SELECT review_id AS reviewId, file, status FROM managed_dependency_file_review
|
|
94
|
+
WHERE review_id IN (${rows.map(() => '?').join(',')})`).all(...rows.map(row => row.id)) as { reviewId: string; file: string; status: ReviewVerdict | null }[]) {
|
|
95
|
+
const of = entries.get(reviewId)
|
|
96
|
+
if (of) of.push(entry)
|
|
97
|
+
else entries.set(reviewId, [entry])
|
|
98
|
+
}
|
|
83
99
|
for (const row of rows) {
|
|
84
100
|
const snapshot = row.files === null ? null : JSON.parse(row.files) as string[] | { paths: string[]; nonTextFiles: string[] }
|
|
85
101
|
// A snapshot of paths alone, before non-text files were told, can't be full.
|
|
86
102
|
const files = Array.isArray(snapshot) ? snapshot : snapshot?.paths ?? null
|
|
87
103
|
const nonText = Array.isArray(snapshot) ? null : snapshot?.nonTextFiles ?? []
|
|
88
|
-
const full = summarizeReview(entries.
|
|
104
|
+
const full = summarizeReview(entries.get(row.id) ?? [], files, nonText).coverage === 'full'
|
|
89
105
|
found.push({ id: row.id, package: row.package, version: row.version, integrity: row.integrity, basePackage: row.basePackage, baseVersion: row.baseVersion,
|
|
90
106
|
reviewer: row.userId ?? `login:${row.userLogin.toLowerCase()}`, status: row.status, full, time: Number(row.publishedAt) })
|
|
91
107
|
}
|
|
@@ -96,11 +112,16 @@ async function publishedReviews(db: ManagedSql, repoId: number, packages: string
|
|
|
96
112
|
export function repositoryDependencyReviewMethods(db: ManagedSql): RepositoryDependencyReviewStore {
|
|
97
113
|
const columns = `repo_id AS repoId, dependency_review AS enabled, dependency_reviews_required AS required, dependency_review_since AS since,
|
|
98
114
|
dependency_review_lockfiles AS lockfiles`
|
|
115
|
+
const readSetting = async (repoId: number) => {
|
|
116
|
+
const row = await db.prepare(`SELECT ${columns} FROM managed_selected_repo WHERE repo_id = ?`).get(repoId) as SettingRow | undefined
|
|
117
|
+
return row ? setting(row) : null
|
|
118
|
+
}
|
|
99
119
|
return {
|
|
100
120
|
async listRepoDependencyReviews() {
|
|
101
121
|
const rows = await db.prepare(`SELECT ${columns} FROM managed_selected_repo`).all() as SettingRow[]
|
|
102
122
|
return new Map(rows.map(row => [row.repoId, setting(row)]))
|
|
103
123
|
},
|
|
124
|
+
repoDependencyReview: readSetting,
|
|
104
125
|
async listRepoReviewTeams() {
|
|
105
126
|
const rows = await db.prepare(`SELECT DISTINCT tr.repo_id AS repoId, t.id AS id, t.name AS name FROM managed_team_repo tr
|
|
106
127
|
JOIN managed_team t ON t.id = tr.team_id WHERE t.hidden = 0 AND t.dependency_review = 1 ORDER BY t.name, t.id`).all() as { repoId: number; id: string; name: string }[]
|
|
@@ -123,13 +144,14 @@ export function repositoryDependencyReviewMethods(db: ManagedSql): RepositoryDep
|
|
|
123
144
|
WHERE u.id IN (${users.map(() => '?').join(',')}) AND ${reviewsIn(true)} AND ${COUNTING_TEAM}`).all(...users, repoId) as { id: string }[]
|
|
124
145
|
return new Set(rows.map(row => row.id))
|
|
125
146
|
},
|
|
126
|
-
async repoDependencyReviewState(repoId, user,
|
|
127
|
-
const
|
|
128
|
-
if (!
|
|
147
|
+
async repoDependencyReviewState(repoId, user, versions, read) {
|
|
148
|
+
const now = read === undefined ? await readSetting(repoId) : read
|
|
149
|
+
if (!now?.enabled) return null
|
|
129
150
|
if (!await db.prepare(`SELECT 1 FROM managed_team t WHERE ${COUNTING_TEAM}`).get(repoId)) return null
|
|
130
151
|
const canReview = await db.prepare(`SELECT 1 FROM managed_team t JOIN managed_user u ON u.id = ?
|
|
131
152
|
WHERE ${reviewsIn(true)} AND ${COUNTING_TEAM}`).get(user, repoId) != null
|
|
132
|
-
|
|
153
|
+
const asked = [...new Map(versions.map(version => [`${version.name}@${version.version}`, version])).values()]
|
|
154
|
+
return { required: now.required, since: now.since, lockfiles: now.lockfiles, canReview, reviews: await publishedReviews(db, repoId, asked) }
|
|
133
155
|
},
|
|
134
156
|
}
|
|
135
157
|
}
|
|
@@ -122,7 +122,7 @@ const TIMED_OUT = /\btimeout\b/iu
|
|
|
122
122
|
// are logged, those read past too.
|
|
123
123
|
export async function githubGraphql(query: string, variables: Record<string, unknown>, token: string, fetchImpl: typeof fetch,
|
|
124
124
|
tolerated: (error: GraphqlError) => boolean = () => false) {
|
|
125
|
-
const body = await githubJson('https://api.github.com/graphql', token, fetchImpl, 'POST', { query, variables }) as { data?: unknown; errors?: GraphqlError[] } | null
|
|
125
|
+
const body = await githubJson('https://api.github.com/graphql', token, fetchImpl, 'POST', { query, variables }, true) as { data?: unknown; errors?: GraphqlError[] } | null
|
|
126
126
|
const all = Array.isArray(body?.errors) ? body.errors : []
|
|
127
127
|
const unnamed = all.filter(error => !NAMED_ERRORS.has(String(error?.type)))
|
|
128
128
|
if (unnamed.length > 0) {
|
|
@@ -172,6 +172,9 @@ const GLUE_LOOKUP = 50
|
|
|
172
172
|
// changes.
|
|
173
173
|
export interface HistoryPages {
|
|
174
174
|
read(tops: string[]): Promise<Map<string, HistoryCommit[]>>
|
|
175
|
+
// The pages kept of `first`; where none is, those of `then`, in one read
|
|
176
|
+
// (`first` whether they are `first`'s). Without it, both are read together.
|
|
177
|
+
readFirst?(first: string[], then: string[]): Promise<{ first: boolean; pages: Map<string, HistoryCommit[]> }>
|
|
175
178
|
write(top: string, commits: HistoryCommit[]): Promise<void>
|
|
176
179
|
}
|
|
177
180
|
|
|
@@ -241,8 +244,11 @@ const graphCommit = (node: GraphCommit | null | undefined) => historyCommit(node
|
|
|
241
244
|
|
|
242
245
|
// The history from each of `starts`, a page of each, kept (`pages`), or read
|
|
243
246
|
// and then kept by its top; glued on to it, the pages kept that start at a
|
|
244
|
-
// parent of a commit listed not listed,
|
|
245
|
-
//
|
|
247
|
+
// parent of a commit listed not listed, each adding commits none listed has,
|
|
248
|
+
// and, where any of those has none kept and readable, at a commit listed, the
|
|
249
|
+
// oldest first (where each goes on furthest; a page at a commit listed lists
|
|
250
|
+
// mostly commits listed already), GLUE_LOOKUP of each looked up at a time, in
|
|
251
|
+
// one read where none of those parents' is kept, else two, and
|
|
246
252
|
// those that start where those go on, till HISTORY_GLUED are listed. The
|
|
247
253
|
// pages from `starts` are listed whole, however many that is (all branches'
|
|
248
254
|
// first, as many as ALL_BRANCHES of them): the cap is gluing's. Each commit
|
|
@@ -259,11 +265,21 @@ export async function historyFrom(fullName: string, token: string | null, starts
|
|
|
259
265
|
for (const top of starts) add(kept.get(top) ?? read.get(top) ?? [])
|
|
260
266
|
const looked = new Set(starts)
|
|
261
267
|
for (let round = 0; round < MAX_GLUE_ROUNDS && listed.size < HISTORY_GLUED; round++) {
|
|
262
|
-
const parents = [...listed.values()].flatMap(commit => commit.parents.filter(parent => !listed.has(parent)))
|
|
263
|
-
const
|
|
264
|
-
if (
|
|
265
|
-
|
|
266
|
-
const
|
|
268
|
+
const parents = [...new Set([...listed.values()].flatMap(commit => commit.parents.filter(parent => !listed.has(parent) && !looked.has(parent))))].slice(0, GLUE_LOOKUP)
|
|
269
|
+
const within = [...listed.keys()].toReversed().filter(sha => !looked.has(sha)).slice(0, GLUE_LOOKUP)
|
|
270
|
+
if (parents.length === 0 && within.length === 0) break
|
|
271
|
+
// Without readFirst, GLUE_LOOKUP of both at once, the parents first.
|
|
272
|
+
const asked = pages.readFirst ? null : [...parents, ...within].slice(0, GLUE_LOOKUP)
|
|
273
|
+
let { first, pages: glued } = asked ? { first: false, pages: await pages.read(asked) } : await pages.readFirst!(parents, within)
|
|
274
|
+
// A parent with no page kept, or none readable (one GitHub's REST API
|
|
275
|
+
// listed, read with GraphQL), its line going on only by a page at a
|
|
276
|
+
// commit listed: those read too, apart, the others' lines notwithstanding.
|
|
277
|
+
if (first && glued.size < parents.length && within.length > 0) {
|
|
278
|
+
glued = new Map([...glued, ...await pages.read(within)])
|
|
279
|
+
first = false
|
|
280
|
+
}
|
|
281
|
+
// Those at commits listed, unread where a parent's answered, are looked up again.
|
|
282
|
+
for (const sha of asked ?? (first ? parents : [...parents, ...within])) looked.add(sha)
|
|
267
283
|
for (const commit of [...glued.values()].flat()) {
|
|
268
284
|
if (listed.size >= HISTORY_GLUED) break
|
|
269
285
|
if (!listed.has(commit.sha)) listed.set(commit.sha, commit)
|
|
@@ -14,12 +14,13 @@
|
|
|
14
14
|
// issues closed and opened, how many and the latest of each.
|
|
15
15
|
import { agentOperated, githubAgent } from '../common/managed/github-agents.ts'
|
|
16
16
|
import { GithubApiError, githubJson } from './github-app.ts'
|
|
17
|
+
import { shareGithubRead } from './github-requests.ts'
|
|
17
18
|
import { ACTOR, type GraphAccount, type HistoryCommit, type HistoryPages, accountOf, actorOf, githubGraphql, historyFrom } from './repository-history.ts'
|
|
18
19
|
|
|
19
20
|
export const PULSE_WEEKS = [1, 4, 13, 52]
|
|
20
21
|
// What a year counted from its commits holds, its version: kept years of
|
|
21
22
|
// another are counted again.
|
|
22
|
-
export const PULSE_YEAR_VERSION =
|
|
23
|
+
export const PULSE_YEAR_VERSION = 9
|
|
23
24
|
const WEEK_MS = 7 * 24 * 60 * 60_000
|
|
24
25
|
const DAY_MS = 24 * 60 * 60_000
|
|
25
26
|
const MAX_CONTRIBUTORS = 100
|
|
@@ -99,7 +100,13 @@ const emptyPunch = () => Array.from({ length: 7 }, () => Array.from({ length: 24
|
|
|
99
100
|
// One statistic: its rows once GitHub has computed it, 'pending' while it
|
|
100
101
|
// computes them (202), none for a repository with no commits (204, no body),
|
|
101
102
|
// 'too-large' where it counts none (lines past 10,000 commits, 422).
|
|
102
|
-
|
|
103
|
+
function statistic(fullName: string, token: string | null, kind: string, fetchImpl: typeof fetch) {
|
|
104
|
+
// Stats and lines both need contributors. Share by the original transport;
|
|
105
|
+
// the per-call status wrapper below would otherwise give each its own read.
|
|
106
|
+
return shareGithubRead(fetchImpl, JSON.stringify(['statistic', fullName, token, kind]), () => readStatistic(fullName, token, kind, fetchImpl))
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function readStatistic(fullName: string, token: string | null, kind: string, fetchImpl: typeof fetch): Promise<unknown[] | 'pending' | 'too-large'> {
|
|
103
110
|
let status = 0
|
|
104
111
|
const answered: typeof fetch = async (input, init) => {
|
|
105
112
|
const res = await fetchImpl(input, init)
|
|
@@ -33,6 +33,19 @@ const AUDIT_FAILED_KEPT_MS = 10 * 60_000
|
|
|
33
33
|
// RISK_KEPT_MS, why they couldn't be had within AUDIT_FAILED_KEPT_MS.
|
|
34
34
|
const isFresh = ({ at, audit }: { at: number; audit: Audit }) => Date.now() - at < (Array.isArray(audit) ? RISK_KEPT_MS : AUDIT_FAILED_KEPT_MS)
|
|
35
35
|
|
|
36
|
+
// The audits of lockfiles, each of its `packages` by its `key` (auditLockfile),
|
|
37
|
+
// `inTurn` with others, asked at once: each null where it has none to ask
|
|
38
|
+
// of, or once `signal` aborts. Each is the caller's to tell once it may.
|
|
39
|
+
export function auditLockfiles(source: AuditSource, memory: Lru<{ at: number; audit: Audit }>, signal: AbortSignal,
|
|
40
|
+
lockfiles: { key: string; packages: readonly LockedPackage[] | null }[], inTurn: <T>(work: () => Promise<T>) => Promise<T>): Promise<Audit | null>[] {
|
|
41
|
+
return lockfiles.map(({ key, packages }) => {
|
|
42
|
+
const audit = packages ? inTurn(() => (signal.aborted ? Promise.resolve(null) : auditLockfile(source, memory, signal, key, packages))) : Promise.resolve(null)
|
|
43
|
+
// One no longer waited on, as the scan stopped, fails alone.
|
|
44
|
+
audit.catch(() => {})
|
|
45
|
+
return audit
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
|
|
36
49
|
// The audit of a lockfile's `packages`, by its `key` as kept (`memory`, then
|
|
37
50
|
// the source's), else asked and kept; null where it locks none to ask of, or
|
|
38
51
|
// once `signal` aborts.
|
|
@@ -16,6 +16,7 @@ import type { DependabotAlert } from './dependabot-alerts.ts'
|
|
|
16
16
|
import { type LockedPackage, isTestFixture, lockedPackages } from './locked-packages.ts'
|
|
17
17
|
import type { Lru } from './lru.ts'
|
|
18
18
|
import type { KeptStore } from './npm-kept.ts'
|
|
19
|
+
import { inTurns } from './queue.ts'
|
|
19
20
|
import { type WorkflowsRead, lastingRead, readActions } from './repository-actions.ts'
|
|
20
21
|
import { type RiskFailure, failureOf } from './repository-risk-assess.ts'
|
|
21
22
|
import type { RepositoryRiskLine, RepositoryRiskRepo } from './repository-risk.ts'
|
|
@@ -28,6 +29,9 @@ const READ_FORMATS: ReadonlySet<LockfileFormat> = new Set(['npm', 'pnpm', 'yarn'
|
|
|
28
29
|
// How long the source's `listings` and `lockfiles` are read for: what
|
|
29
30
|
// GitHub has at a commit or a blob doesn't change, how it is read here may.
|
|
30
31
|
export const SCAN_READ_KEPT_MS = 24 * 60 * 60_000
|
|
32
|
+
// How many of a repository's lockfiles are read at once: each one GitHub is
|
|
33
|
+
// asked for where it isn't kept, then parsed.
|
|
34
|
+
const LOCKFILES_AT_ONCE = 4
|
|
31
35
|
|
|
32
36
|
export type ListedLockfile = Pick<RepoLockfile, 'path' | 'format' | 'sha'>
|
|
33
37
|
export type LockfileRead = { packages: LockedPackage[] } | RiskFailure
|
|
@@ -140,8 +144,8 @@ async function readDependabot(opened: OpenedRepository, paths: readonly string[]
|
|
|
140
144
|
}
|
|
141
145
|
}
|
|
142
146
|
|
|
143
|
-
// A repository read: its lockfiles listed and read,
|
|
144
|
-
// asked and its Actions read meanwhile; its line telling why where listing
|
|
147
|
+
// A repository read: its lockfiles listed and read, a few at once, its
|
|
148
|
+
// Dependabot alerts asked and its Actions read meanwhile; its line telling why where listing
|
|
145
149
|
// failed; null where the scan stopped. A test's
|
|
146
150
|
// fixture or example its reader refuses (isTestFixture) is left out, as a
|
|
147
151
|
// test may keep one so on purpose.
|
|
@@ -153,11 +157,12 @@ export async function readRepository<R extends RepositoryRiskRepo>(reading: Repo
|
|
|
153
157
|
const acting = workflows ? keptRead(reading.caches.actionFiles, reading.source.actionFiles, `${repo.repoId}@${opened.commit}`,
|
|
154
158
|
() => readActions({ workflows, file: path => opened.file(path) }), lastingRead) : Promise.resolve(null)
|
|
155
159
|
acting.catch(() => {})
|
|
160
|
+
const inTurn = inTurns(LOCKFILES_AT_ONCE)
|
|
161
|
+
const lines = await Promise.all(listed.lockfiles.map(lockfile => inTurn(() => (reading.signal.aborted ? Promise.resolve(null) : readLockfile(reading, repo.repoId, opened, lockfile)))))
|
|
156
162
|
const lockfiles: RepositoryRead['lockfiles'] = []
|
|
157
|
-
for (const
|
|
158
|
-
if (
|
|
159
|
-
|
|
160
|
-
if (!('error' in line && line.error === 'lockfile-unreadable' && isTestFixture(line.path))) lockfiles.push({ sha: lockfile.sha, line })
|
|
163
|
+
for (const [at, line] of lines.entries()) {
|
|
164
|
+
if (!line) return null
|
|
165
|
+
if (!('error' in line && line.error === 'lockfile-unreadable' && isTestFixture(line.path))) lockfiles.push({ sha: listed.lockfiles[at]!.sha, line })
|
|
161
166
|
}
|
|
162
167
|
const dependabot = await alerting
|
|
163
168
|
const actions = await acting
|
|
@@ -34,10 +34,10 @@ import { type Lru, lru } from './lru.ts'
|
|
|
34
34
|
import type { KeptStore } from './npm-kept.ts'
|
|
35
35
|
import { MAX_SPARE_ASSESSMENTS } from './npm-risk.ts'
|
|
36
36
|
import type { LockedPackage } from './locked-packages.ts'
|
|
37
|
-
import { type Queue, queue } from './queue.ts'
|
|
37
|
+
import { type Queue, inTurns, queue } from './queue.ts'
|
|
38
38
|
import { type DownloadsSource, readyingDownloads } from './scan-downloads.ts'
|
|
39
39
|
import { type AssessedPackage, type Assessment, BUSY_RETRIES, BUSY_WAIT_MS, RISK_KEPT_MS, type RiskFailure, assessPackage, failureOf, isBusy } from './repository-risk-assess.ts'
|
|
40
|
-
import { type AdvisoryRow, type Audit, type AuditSource,
|
|
40
|
+
import { type AdvisoryRow, type Audit, type AuditSource, auditLockfiles } from './repository-risk-audit.ts'
|
|
41
41
|
import { type DependabotRead, type ListedLockfile, type LockfileRead, type OpenedRepository, type RepositoryFailure, type RepositoryRead, SCAN_READ_KEPT_MS,
|
|
42
42
|
readRepository } from './repository-risk-read.ts'
|
|
43
43
|
import { type DependabotAlert, alertVersions } from './dependabot-alerts.ts'
|
|
@@ -157,6 +157,9 @@ export const riskCaches = (): RiskCaches => ({
|
|
|
157
157
|
// the one told: each asks GitHub one thing after another (opening it, and
|
|
158
158
|
// its head) even where all else is kept.
|
|
159
159
|
const READ_AHEAD = 6
|
|
160
|
+
// How many lockfiles' advisories are asked of npm at once, those of the
|
|
161
|
+
// repositories read ahead as they are read, each told in its turn.
|
|
162
|
+
const AUDITS_AT_ONCE = 4
|
|
160
163
|
// How long a part's progress, written as it ends (`progress`), is waited on.
|
|
161
164
|
const PROGRESS_WRITE_MS = 5000
|
|
162
165
|
// How many kept risk lines are read of the source's `risks` at once.
|
|
@@ -180,6 +183,7 @@ interface Scan {
|
|
|
180
183
|
// The repositories told whole, this part or one before (ScanProgress).
|
|
181
184
|
told: Map<number, ToldRepository>
|
|
182
185
|
actions: ReturnType<typeof resolvingActions> | null
|
|
186
|
+
audits: ReturnType<typeof inTurns>
|
|
183
187
|
}
|
|
184
188
|
|
|
185
189
|
// A package as a scan meets it: by its name, version and a mirror's integrity.
|
|
@@ -285,16 +289,16 @@ function assessLater(scan: Scan, packages: LockedPackage[]) {
|
|
|
285
289
|
if (fresh.length > 0) scan.lookups.add(fresh)
|
|
286
290
|
}
|
|
287
291
|
|
|
288
|
-
type AdvisoriesLine = Extract<RepositoryRiskLine, { type: 'advisories' }>
|
|
289
292
|
type LockfileLine = Extract<RepositoryRiskLine, { type: 'lockfile' }>
|
|
290
293
|
|
|
291
|
-
// A lockfile's advisories line (repository-risk-audit.ts auditLockfile)
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
294
|
+
// A lockfile's advisories line, of its audit (repository-risk-audit.ts auditLockfile).
|
|
295
|
+
const advisoriesLine = ({ repo, path }: LockfileLine, audit: Audit) => ({ type: 'advisories', repo, path, ...Array.isArray(audit) ? { advisories: audit } : audit } as const)
|
|
296
|
+
|
|
297
|
+
// A repository read, its lockfiles' audits asked meanwhile, AUDITS_AT_ONCE
|
|
298
|
+
// across the scan (auditLockfiles), to tell in turn once access is checked again.
|
|
299
|
+
type Audited = (RepositoryRead & { audits: Promise<Audit | null>[] }) | RepositoryFailure
|
|
300
|
+
const audited = (scan: Scan, read: RepositoryRead | RepositoryFailure | null): Audited | null => (read === null || 'type' in read ? read : { ...read, audits: auditLockfiles(scan.source, scan.caches.advisories,
|
|
301
|
+
scan.signal, read.lockfiles.map(({ sha, line }) => ({ key: `${line.format}\0${sha}`, packages: 'packages' in line ? line.packages : null })), scan.audits) })
|
|
298
302
|
|
|
299
303
|
type DependabotLine = Extract<RepositoryRiskLine, { type: 'dependabot' }>
|
|
300
304
|
|
|
@@ -319,10 +323,11 @@ async function rechecked(read: DependabotRead): Promise<DependabotRead> {
|
|
|
319
323
|
|
|
320
324
|
// A repository read (repository-risk-read.ts readRepository) told: its
|
|
321
325
|
// line, or why it couldn't be read, then its lockfiles', and their
|
|
322
|
-
// advisories, its packages assessed meanwhile, then
|
|
326
|
+
// advisories, asked as it was read, its packages assessed meanwhile, then
|
|
327
|
+
// its Dependabot alerts.
|
|
323
328
|
// Access to it is checked again
|
|
324
329
|
// just before, however long ago it was read, as GitHub may have withdrawn it.
|
|
325
|
-
async function tellRepository(scan: Scan, repo: RepositoryRiskRepo, read:
|
|
330
|
+
async function tellRepository(scan: Scan, repo: RepositoryRiskRepo, read: Audited | null) {
|
|
326
331
|
if (!read || scan.signal.aborted) return
|
|
327
332
|
if ('type' in read) { scan.lines.add(read); return }
|
|
328
333
|
try { await read.recheck() } catch (error) {
|
|
@@ -337,10 +342,10 @@ async function tellRepository(scan: Scan, repo: RepositoryRiskRepo, read: Reposi
|
|
|
337
342
|
if ('packages' in line) assessLater(scan, line.packages)
|
|
338
343
|
}
|
|
339
344
|
const refs = read.actions ? tellActions(scan, repo, read.actions, lockfiles) : []
|
|
340
|
-
for (const {
|
|
345
|
+
for (const [at, { line }] of lockfiles.entries()) {
|
|
341
346
|
if (scan.signal.aborted) return
|
|
342
|
-
const
|
|
343
|
-
if (
|
|
347
|
+
const audit = await read.audits[at]
|
|
348
|
+
if (audit) scan.lines.add(advisoriesLine(line, audit))
|
|
344
349
|
}
|
|
345
350
|
if (scan.signal.aborted) return
|
|
346
351
|
const dependabot = read.dependabot && await rechecked(read.dependabot)
|
|
@@ -366,9 +371,10 @@ function tellActions(scan: Scan, repo: RepositoryRiskRepo, read: WorkflowsRead,
|
|
|
366
371
|
async function toldBefore(scan: Scan, repo: RepositoryRiskRepo) {
|
|
367
372
|
const told = scan.told.get(repo.repoId)
|
|
368
373
|
if (!told || told.name !== repo.fullName || JSON.stringify(told.paths) !== JSON.stringify(repo.paths ?? null)) return false
|
|
369
|
-
const reads: LockedPackage[][] = []
|
|
374
|
+
const reads: LockedPackage[][] = [], unknown = told.lockfiles.filter(key => !scan.caches.lockfiles.get(key))
|
|
375
|
+
const stored = unknown.length > 0 && scan.source.lockfiles ? await scan.source.lockfiles.readMany(unknown) : null
|
|
370
376
|
for (const key of told.lockfiles) {
|
|
371
|
-
const read = scan.caches.lockfiles.get(key) ??
|
|
377
|
+
const read = scan.caches.lockfiles.get(key) ?? stored?.get(key)?.value
|
|
372
378
|
if (!read || !('packages' in read)) return false
|
|
373
379
|
reads.push(read.packages)
|
|
374
380
|
}
|
|
@@ -388,11 +394,11 @@ async function readRepositories(scan: Scan) {
|
|
|
388
394
|
const kept = await scan.source.progress?.read()
|
|
389
395
|
const progress = kept && Date.now() - kept.at < SCAN_READ_KEPT_MS ? kept.value.repos : []
|
|
390
396
|
for (const told of progress) if (listed.has(told.id)) scan.told.set(told.id, told)
|
|
391
|
-
const reads: Promise<
|
|
397
|
+
const reads: Promise<Audited | null>[] = []
|
|
392
398
|
const read = (at: number) => {
|
|
393
399
|
if (reads[at]) return reads[at]
|
|
394
400
|
const repo = repos[at]!
|
|
395
|
-
const started = reads[at] = toldBefore(scan, repo).then(told => told ? null : readRepository(scan, repo))
|
|
401
|
+
const started = reads[at] = toldBefore(scan, repo).then(async told => (told ? null : audited(scan, await readRepository(scan, repo))))
|
|
396
402
|
// One read ahead and no longer waited on, as the scan stopped, fails alone.
|
|
397
403
|
started.catch(() => {})
|
|
398
404
|
return started
|
|
@@ -438,7 +444,8 @@ export async function* repositoryRiskLines<R extends RepositoryRiskRepo>(source:
|
|
|
438
444
|
const lines = queue<RepositoryRiskLine>(AbortSignal.any([signal, part.signal]), { drain: true })
|
|
439
445
|
const actions = source.actions ? resolvingActions({ source: source.actions, signal: running, tell: line => { lines.add(line) }, memory: caches.actions,
|
|
440
446
|
answers: caches.actionAnswers, store: source.actionLines }) : null
|
|
441
|
-
const scan: Scan = { source, caches, signal: running, lines, lookups: queue(running), seen: new Map(), dev: new Map(), stale: [], told: new Map(), actions
|
|
447
|
+
const scan: Scan = { source, caches, signal: running, lines, lookups: queue(running), seen: new Map(), dev: new Map(), stale: [], told: new Map(), actions,
|
|
448
|
+
audits: inTurns(AUDITS_AT_ONCE) }
|
|
442
449
|
const failed: unknown[] = []
|
|
443
450
|
void (async () => {
|
|
444
451
|
try {
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
//
|
|
6
6
|
// Async throughout — the store (db.ts) is async so a future non-SQLite
|
|
7
7
|
// backend slots in without touching these helpers.
|
|
8
|
+
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
8
9
|
import type { ManagedConfig } from './config.ts'
|
|
9
10
|
import type { ManagedDb, ManagedSession, ManagedUser, StoredUser } from './db.ts'
|
|
10
11
|
import { hashToken, randomToken } from './crypto.ts'
|
|
@@ -73,19 +74,32 @@ export async function createSession(
|
|
|
73
74
|
return { setCookie, csrfToken, userId }
|
|
74
75
|
}
|
|
75
76
|
|
|
77
|
+
// A request's sign-in, whose presence its first read to find it records.
|
|
78
|
+
// Every read after it in that request (the rechecks after its reads of
|
|
79
|
+
// storage, GitHub or npm among them) only reads: last_seen_at was written a
|
|
80
|
+
// moment before, by the read that let the request in.
|
|
81
|
+
const requests = new AsyncLocalStorage<{ seen: boolean }>()
|
|
82
|
+
export function runRequestPresence<T>(work: () => T): T {
|
|
83
|
+
return requests.run({ seen: false }, work)
|
|
84
|
+
}
|
|
85
|
+
|
|
76
86
|
// Resolve the current session (+ user) from the request's cookies, or null.
|
|
77
87
|
// A view cookie resolves only to that admin's view as another user: a view
|
|
78
88
|
// that has ended never falls back to the admin's own session. `own` skips the
|
|
79
89
|
// view and reads the signed-in session itself.
|
|
80
|
-
export function readSession(
|
|
90
|
+
export async function readSession(
|
|
81
91
|
config: ManagedConfig, db: ManagedDb, cookieHeader: string | undefined, now: number, { own = false } = {},
|
|
82
92
|
): Promise<{ session: ManagedSession; user: StoredUser } | null> {
|
|
83
93
|
const cookies = parseCookies(cookieHeader)
|
|
84
94
|
const token = cookies.get(config.sessionCookieName)
|
|
85
|
-
if (token == null || token === '') return
|
|
95
|
+
if (token == null || token === '') return null
|
|
86
96
|
const view = own ? undefined : cookies.get(cookieName(config, VIEW_COOKIE))
|
|
87
|
-
|
|
88
|
-
|
|
97
|
+
const request = requests.getStore(), seen = request?.seen === true
|
|
98
|
+
const s = view != null && view !== ''
|
|
99
|
+
? await (seen ? db.getViewSessionWithUser : db.viewSessionWithUser)(hashToken(view), hashToken(token), now)
|
|
100
|
+
: await (seen ? db.getSessionWithUser : db.sessionWithUser)(hashToken(token), now)
|
|
101
|
+
if (s && request) request.seen = true
|
|
102
|
+
return s
|
|
89
103
|
}
|
|
90
104
|
|
|
91
105
|
// Drop the session named by the request's cookie (logout). The caller also
|
|
@@ -49,10 +49,12 @@ export async function readSolidityEntryPoints(directory: string, listing: unknow
|
|
|
49
49
|
for (const entry of entries) {
|
|
50
50
|
if (entry.type === 'file' && sourcePath(entry.path) && addSoliditySuggestion(paths, entry.path)) limited = true
|
|
51
51
|
}
|
|
52
|
-
|
|
53
|
-
|
|
52
|
+
// Both roots' listings asked together, then read in order.
|
|
53
|
+
const listings = roots.map(root => Promise.resolve().then(() => (sourcePath(`${root.path}/Contract.sol`) ? read(`/git/trees/${root.sha}?recursive=1`) : null)))
|
|
54
|
+
for (const listed of listings) listed.catch(() => {})
|
|
55
|
+
for (const [i, root] of roots.entries()) {
|
|
54
56
|
try {
|
|
55
|
-
const data = await
|
|
57
|
+
const data = await listings[i] as { tree?: unknown; truncated?: unknown } | null
|
|
56
58
|
if (!Array.isArray(data?.tree)) continue
|
|
57
59
|
limited ||= data.truncated === true
|
|
58
60
|
for (const entry of data.tree) {
|
|
@@ -10,6 +10,9 @@ export function postgresSql(source: string): { sql: string; names: string[] } {
|
|
|
10
10
|
.replaceAll('INSERT OR IGNORE INTO', 'INSERT INTO')
|
|
11
11
|
.replaceAll('CROSS JOIN', 'JOIN')
|
|
12
12
|
.replaceAll(/\binstr\(/gu, 'strpos(')
|
|
13
|
+
// Rows gathered as one JSON array value, NULL (not '[]') where none are.
|
|
14
|
+
.replaceAll(/\bjson_group_array\(/gu, 'json_agg(')
|
|
15
|
+
.replaceAll(/\bjson_object\(/gu, 'json_build_object(')
|
|
13
16
|
// Quoted literals (including ':') are never placeholders or identifiers.
|
|
14
17
|
sql = sql.split(/('(?:''|[^'])*')/u).map((part, i) => i % 2 ? part : part
|
|
15
18
|
.replaceAll(/\?|(?<!:):([a-zA-Z]\w*)/gu, (_match, name: string | undefined) => {
|
package/server-managed/sql.ts
CHANGED
|
@@ -19,10 +19,14 @@ export interface ManagedSqlDriver extends ManagedSql {
|
|
|
19
19
|
// These operations execute exactly one SQL statement. Postgres already gives
|
|
20
20
|
// that statement an atomic snapshot; an explicit BEGIN/COMMIT adds only trips.
|
|
21
21
|
// Lease and cache writes coordinate through their conditional UPSERT/UPDATE alone.
|
|
22
|
-
const SINGLE_STATEMENTS = new Set(['getReport', 'getBundle', 'getStorageRow', 'getStorageEncryption',
|
|
22
|
+
const SINGLE_STATEMENTS = new Set(['getReport', 'getBundle', 'getStorageRow', 'getStorageEncryption', 'getTeam', 'getComment',
|
|
23
|
+
'userCanReadReport', 'userCanReadRepo', 'userCanReadRepoPath', 'userCanReadBundle', 'reportPermissionsFor', 'listComments', 'listManagedIssues',
|
|
24
|
+
'listTriageHistory', 'listUsers', 'listUserOptions', 'listActivityReports', 'getRepoFullName', 'getUser', 'getSessionWithUser', 'getViewSessionWithUser',
|
|
25
|
+
'listTriageFixes',
|
|
26
|
+
'sessionWithUser', 'getTeamReportAccessSnapshot', 'getReportAccessSnapshot', 'getReportView', 'getUserGithubId', 'getUserTeamFeedSnapshot', 'getAnnotations', 'getUserTokens',
|
|
23
27
|
'listReports', 'listBundles', 'listReadableBundleIds', 'listAllRepos', 'listSelectedRepos', 'listRepoScopesForUser', 'listTriage',
|
|
24
28
|
'getFeedState', 'getWorkspaceShareFeedState', 'claimMaintenanceLease', 'finishMaintenanceLease', 'cacheRepoDefaultBranch', 'releaseBundleBuildLease',
|
|
25
|
-
'getUpstreamCacheEntry', 'getUpstreamCacheEntries', 'setUpstreamCacheEntry', 'setUpstreamCacheEntries', 'ensureSessionUploadKey',
|
|
29
|
+
'getUpstreamCacheEntry', 'getUpstreamCacheEntries', 'getFirstUpstreamCacheEntries', 'setUpstreamCacheEntry', 'setUpstreamCacheEntries', 'ensureSessionUploadKey',
|
|
26
30
|
'listGithubCommits', 'listGithubCommitTags', 'setGithubCommits', 'getAvatarKey', 'listUsersByAccount'])
|
|
27
31
|
// Session presence and upload keys change only their own rows.
|
|
28
32
|
const UNLOCKED_WRITES = new Set(['sessionWithUser', 'viewSessionWithUser', 'ensureSessionUploadKey'])
|
|
@@ -47,10 +47,18 @@ export function unwrapDataKey(key: StorageKey, type: StorageRowKind, row: Pick<S
|
|
|
47
47
|
return value
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
// What storageState reads of managed_storage_encryption, as `alias` names it,
|
|
51
|
+
// for a statement reading it with more (storageStateOf).
|
|
52
|
+
export const storageStateColumns = (alias: string) => `${alias}.test_value AS testValue, ${alias}.enabled_at AS enabledAt,
|
|
53
|
+
${alias}.cursor AS cursor, ${alias}.complete AS complete, ${alias}.migrated AS migrated, ${alias}.cleanup_cursor AS cleanupCursor,
|
|
54
|
+
${alias}.cleanup_found AS cleanupFound, ${alias}.cleanup_complete AS cleanupComplete`
|
|
50
55
|
export async function storageState(db: ManagedSql, key: StorageKey | null): Promise<StorageEncryptionState | null> {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
56
|
+
return storageStateOf(await db.prepare(`SELECT ${storageStateColumns('e')} FROM managed_storage_encryption e WHERE e.id = 1`).get() as StoredState | undefined, key)
|
|
57
|
+
}
|
|
58
|
+
type StoredState = StorageEncryptionState & { testValue: string }
|
|
59
|
+
// The state storageStateColumns read, none where its row is missing, its
|
|
60
|
+
// test value checked against `key`.
|
|
61
|
+
export function storageStateOf(row: StoredState | undefined, key: StorageKey | null): StorageEncryptionState | null {
|
|
54
62
|
if (!row) return null
|
|
55
63
|
if (!key) throw new Error('Managed storage requires its configured encryption key')
|
|
56
64
|
const plain = unwrapStorageValue(key, 'managed_storage_encryption:1', row.testValue)
|
|
@@ -180,8 +180,10 @@ async function convertStoredReport(raw: RawObjectStorage, db: StorageDb, key: St
|
|
|
180
180
|
|
|
181
181
|
// Startup enables encryption before constructing stores; validate the persisted
|
|
182
182
|
// requirement even when this adapter is opened without a key.
|
|
183
|
-
|
|
184
|
-
|
|
183
|
+
// `known` is the state the caller read a moment before, where it found it
|
|
184
|
+
// enabled, so a cold start reads it once.
|
|
185
|
+
async function storagePolicy(db: StorageDb, key: StorageKey | null, known: StorageEncryptionState | null) {
|
|
186
|
+
let enabled = known ?? await db.getStorageEncryption()
|
|
185
187
|
let checkedAt = Date.now()
|
|
186
188
|
return async (fresh = false) => {
|
|
187
189
|
// Enabled is irreversible. Keyless instances cache "off" briefly, while
|
|
@@ -196,8 +198,9 @@ async function storagePolicy(db: StorageDb, key: StorageKey | null) {
|
|
|
196
198
|
}
|
|
197
199
|
}
|
|
198
200
|
|
|
199
|
-
export async function createEncryptedObjectStorage(raw: RawObjectStorage, db: StorageDb, key: StorageKey | null
|
|
200
|
-
|
|
201
|
+
export async function createEncryptedObjectStorage(raw: RawObjectStorage, db: StorageDb, key: StorageKey | null,
|
|
202
|
+
known: StorageEncryptionState | null = null): Promise<ObjectStorage> {
|
|
203
|
+
const mode = await storagePolicy(db, key, known)
|
|
201
204
|
async function open(identity: string): ReturnType<ObjectStorage['open']> {
|
|
202
205
|
logicalKey(identity)
|
|
203
206
|
const state = await mode()
|