ghcr-manager 0.0.4
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/CHANGELOG.md +44 -0
- package/LICENSE +21 -0
- package/README.md +97 -0
- package/dist/cli/_args.d.ts +6 -0
- package/dist/cli/_args.js +38 -0
- package/dist/cli/_logger.d.ts +9 -0
- package/dist/cli/_logger.js +24 -0
- package/dist/cli/_scan-command.d.ts +1 -0
- package/dist/cli/_scan-command.js +32 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +30 -0
- package/dist/core/_types.d.ts +49 -0
- package/dist/core/_types.js +1 -0
- package/dist/core/index.d.ts +1 -0
- package/dist/core/index.js +1 -0
- package/dist/db/_manifest-reachability.d.ts +2 -0
- package/dist/db/_manifest-reachability.js +94 -0
- package/dist/db/_scan-writer.d.ts +18 -0
- package/dist/db/_scan-writer.js +176 -0
- package/dist/db/_schema.d.ts +2 -0
- package/dist/db/_schema.js +19 -0
- package/dist/db/_snapshot-repository.d.ts +24 -0
- package/dist/db/_snapshot-repository.js +98 -0
- package/dist/db/index.d.ts +4 -0
- package/dist/db/index.js +9 -0
- package/dist/ingest/github/_manifest-client.d.ts +8 -0
- package/dist/ingest/github/_manifest-client.js +100 -0
- package/dist/ingest/github/_manifest-ingest.d.ts +3 -0
- package/dist/ingest/github/_manifest-ingest.js +104 -0
- package/dist/ingest/github/_package-version-page-load.d.ts +13 -0
- package/dist/ingest/github/_package-version-page-load.js +52 -0
- package/dist/ingest/github/_packages-client.d.ts +10 -0
- package/dist/ingest/github/_packages-client.js +59 -0
- package/dist/ingest/github/_paginated-ingest.d.ts +11 -0
- package/dist/ingest/github/_paginated-ingest.js +28 -0
- package/dist/ingest/github/_parallel-paginated-ingest.d.ts +11 -0
- package/dist/ingest/github/_parallel-paginated-ingest.js +49 -0
- package/dist/ingest/github/_registry-token-client.d.ts +6 -0
- package/dist/ingest/github/_registry-token-client.js +67 -0
- package/dist/ingest/github/_shared.d.ts +28 -0
- package/dist/ingest/github/_shared.js +102 -0
- package/dist/ingest/github/index.d.ts +7 -0
- package/dist/ingest/github/index.js +26 -0
- package/dist/tuning/index.d.ts +6 -0
- package/dist/tuning/index.js +6 -0
- package/package.json +59 -0
- package/resources/sql/schema/001_schema.sql +109 -0
- package/resources/sql/views/001_v_latest_scan_per_package.sql +27 -0
- package/resources/sql/views/002_v_missing_digests.sql +32 -0
- package/resources/sql/views/003_v_missing_digests_related_manifests.sql +78 -0
- package/resources/sql/views/004_v_manifests_related_manifests.sql +142 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to
|
|
6
|
+
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.0.4] - 2026-04-30
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- Publish to npmjs
|
|
15
|
+
|
|
16
|
+
## [0.0.3] - 2026-04-30
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- Released on GitHub marketplace as public action
|
|
21
|
+
|
|
22
|
+
## [0.0.1] - 2026-04-30
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
|
|
26
|
+
- Initial public release of `ghcr-manager` as a GitHub Action plus companion CLI.
|
|
27
|
+
- GHCR scan flow that loads package versions, tags, manifests, descriptors, and manifest graph edges into SQLite.
|
|
28
|
+
- Manifest reachability precomputation (`manifest_reachability`) for fast graph-based analysis queries.
|
|
29
|
+
- Raw payload storage for GitHub package-version items and GHCR manifests (`package_version_payloads`,
|
|
30
|
+
`manifest_payloads`).
|
|
31
|
+
- Scan lifecycle tracking with scan history (`package_scans`) and status transitions (`running|completed|failed`).
|
|
32
|
+
- Immutable per-scan UUID (`package_scans.scan_uuid`) for robust duplicate detection across merged databases.
|
|
33
|
+
- Optional action artifact upload for scan DB export (`upload-db-artifact`, optional retention override).
|
|
34
|
+
- Manual workflow for interactive scan runs (`.github/workflows/manual-run.yml`).
|
|
35
|
+
- Missing-manifest investigation SQL recipes (`docs/missing-manifests-queries.md`) and schema/terminology docs.
|
|
36
|
+
|
|
37
|
+
### Changed
|
|
38
|
+
|
|
39
|
+
- Enforced stricter repository conventions:
|
|
40
|
+
- source/test tree mirroring
|
|
41
|
+
- cross-folder imports via folder `index.ts`
|
|
42
|
+
- internal source naming (`_*.ts`)
|
|
43
|
+
- Hardened CI/workflows with explicit token permissions and immutable action reference checks.
|
|
44
|
+
- Refined action/runtime flow to focus on scan + DB export behavior for this release.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 gh-workflow
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# ghcr-manager
|
|
2
|
+
|
|
3
|
+
[](https://github.com/gh-workflow/ghcr-manager/releases)
|
|
4
|
+
[](https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/immutable-releases)
|
|
5
|
+
[](https://github.com/marketplace/actions/ghcr-manager)
|
|
6
|
+
[](https://github.com/gh-workflow/ghcr-manager/actions/workflows/change-validation.yml)
|
|
7
|
+
[](#usage)
|
|
8
|
+
|
|
9
|
+
Inspect, analyze, and manage GitHub Container Registry packages.
|
|
10
|
+
|
|
11
|
+
`ghcr-manager` is a public GitHub Action and companion CLI for safe GHCR cleanup and inspection, with a focus on large
|
|
12
|
+
packages and correct handling of multi-arch images, referrers, and attestations.
|
|
13
|
+
|
|
14
|
+
## Scope
|
|
15
|
+
|
|
16
|
+
- :white_check_mark: Full package and manifest scan per run for correctness
|
|
17
|
+
- :white_check_mark: Export database of Container Registry from runs for local analysis
|
|
18
|
+
- :construction: Safe cleanup of GHCR image artifacts in GitHub packages
|
|
19
|
+
|
|
20
|
+
## How
|
|
21
|
+
|
|
22
|
+
### :white_check_mark: Data Loading
|
|
23
|
+
|
|
24
|
+
1. Writes Container Registry metadata to a database
|
|
25
|
+
2. Pre-processes data for optimized lookups
|
|
26
|
+
3. Optional: Export of database from runs for local analysis
|
|
27
|
+
|
|
28
|
+
> :construction: Planned: Support for merging several such databases into one for local analysis
|
|
29
|
+
|
|
30
|
+
### :construction: Consistency Check
|
|
31
|
+
|
|
32
|
+
1. Run consistency check against the database
|
|
33
|
+
2. Optional: Export report of missing manifests from runs
|
|
34
|
+
|
|
35
|
+
### :construction: Safe cleanup of GHCR image artifacts
|
|
36
|
+
|
|
37
|
+
1. Use filter input to query database for related artifacts (images and manifests)
|
|
38
|
+
2. Optional `dry-run`: Export which image artifacts would be deleted
|
|
39
|
+
3. Delete image artifacts (without `dry-run`)
|
|
40
|
+
|
|
41
|
+
## Usage
|
|
42
|
+
|
|
43
|
+
```yaml
|
|
44
|
+
concurrency:
|
|
45
|
+
group: ghcr-manager__${{ inputs.owner }}__${{ inputs.package }}
|
|
46
|
+
jobs:
|
|
47
|
+
scan:
|
|
48
|
+
runs-on: ubuntu-latest
|
|
49
|
+
permissions:
|
|
50
|
+
contents: read
|
|
51
|
+
packages: read
|
|
52
|
+
actions: write # only required when upload-db-artifact is true
|
|
53
|
+
concurrency:
|
|
54
|
+
group: ghcr-manager
|
|
55
|
+
steps:
|
|
56
|
+
- uses: actions/checkout@v6
|
|
57
|
+
|
|
58
|
+
- name: Run ghcr-manager action
|
|
59
|
+
uses: gh-workflow/ghcr-manager@0.0.4
|
|
60
|
+
with:
|
|
61
|
+
github-token: ${{ github.token }}
|
|
62
|
+
owner: OWNER
|
|
63
|
+
package: PACKAGE
|
|
64
|
+
upload-db-artifact: true
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
> Copy the [Manual Run Workflow](.github/workflows/manual-run.yml) as a ready-to-run manual scan workflow.
|
|
68
|
+
|
|
69
|
+
## Inputs
|
|
70
|
+
|
|
71
|
+
<!-- markdownlint-disable MD013 -->
|
|
72
|
+
|
|
73
|
+
| Input | Description | Required | Default |
|
|
74
|
+
| ---------------------------- | --------------------------------------------------------------- | -------- | ------------------------------ |
|
|
75
|
+
| `github-token` | GitHub token used for GitHub/GHCR API calls | Yes | `${{ github.token }}` |
|
|
76
|
+
| `owner` | GitHub owner of the container package (user or org) | Yes | |
|
|
77
|
+
| `package` | Container package name | Yes | |
|
|
78
|
+
| `upload-db-artifact` | Whether to upload the scan database as a workflow run artifact | No | `false` |
|
|
79
|
+
| `db-artifact-retention-days` | Optional retention days override for uploaded database artifact | No | `${{ github.retention_days }}` |
|
|
80
|
+
|
|
81
|
+
<!-- markdownlint-enable MD013 -->
|
|
82
|
+
|
|
83
|
+
## Outputs
|
|
84
|
+
|
|
85
|
+
| Output | Description |
|
|
86
|
+
| --------- | ------------------------------------------------------- |
|
|
87
|
+
| `db-path` | Path to the SQLite database in the GitHub action runner |
|
|
88
|
+
|
|
89
|
+
## Artifacts
|
|
90
|
+
|
|
91
|
+
<!-- markdownlint-disable MD013 -->
|
|
92
|
+
|
|
93
|
+
| Name | Filename | Description |
|
|
94
|
+
| ----------------------------- | --------------------------------- | -------------------------------------------------------------------- |
|
|
95
|
+
| `${OWNER}__${PACKAGE}.sqlite` | `${OWNER}__${PACKAGE}.sqlite.zip` | Zipped SQLite database containing GitHub Container Registry metadata |
|
|
96
|
+
|
|
97
|
+
<!-- markdownlint-enable MD013 -->
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { type LogLevel } from "./_logger.js";
|
|
2
|
+
export declare function requireOption(args: string[], name: string): string;
|
|
3
|
+
export declare function findOption(args: string[], name: string): string | undefined;
|
|
4
|
+
export declare function collectRepeatedOption(args: string[], name: string): string[];
|
|
5
|
+
export declare function resolveGitHubToken(args: string[]): string;
|
|
6
|
+
export declare function resolveLogLevel(args: string[]): LogLevel;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { isLogLevel } from "./_logger.js";
|
|
2
|
+
export function requireOption(args, name) {
|
|
3
|
+
const value = findOption(args, name);
|
|
4
|
+
if (!value) {
|
|
5
|
+
throw new Error(`missing required option: ${name}`);
|
|
6
|
+
}
|
|
7
|
+
return value;
|
|
8
|
+
}
|
|
9
|
+
export function findOption(args, name) {
|
|
10
|
+
const index = args.indexOf(name);
|
|
11
|
+
if (index < 0) {
|
|
12
|
+
return undefined;
|
|
13
|
+
}
|
|
14
|
+
return args[index + 1];
|
|
15
|
+
}
|
|
16
|
+
export function collectRepeatedOption(args, name) {
|
|
17
|
+
const values = [];
|
|
18
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
19
|
+
if (args[index] === name && args[index + 1]) {
|
|
20
|
+
values.push(args[index + 1]);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return values;
|
|
24
|
+
}
|
|
25
|
+
export function resolveGitHubToken(args) {
|
|
26
|
+
const cliToken = findOption(args, "--token");
|
|
27
|
+
if (cliToken) {
|
|
28
|
+
return cliToken;
|
|
29
|
+
}
|
|
30
|
+
throw new Error("missing required option: --token");
|
|
31
|
+
}
|
|
32
|
+
export function resolveLogLevel(args) {
|
|
33
|
+
const rawLevel = findOption(args, "--log-level") ?? "info";
|
|
34
|
+
if (!isLogLevel(rawLevel)) {
|
|
35
|
+
throw new Error(`invalid log level: ${rawLevel}`);
|
|
36
|
+
}
|
|
37
|
+
return rawLevel;
|
|
38
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export type LogLevel = "debug" | "info" | "warn" | "error" | "silent";
|
|
2
|
+
export interface Logger {
|
|
3
|
+
debug(message: string): void;
|
|
4
|
+
info(message: string): void;
|
|
5
|
+
warn(message: string): void;
|
|
6
|
+
error(message: string): void;
|
|
7
|
+
}
|
|
8
|
+
export declare function isLogLevel(value: string): value is LogLevel;
|
|
9
|
+
export declare function createLogger(level: LogLevel, sink?: NodeJS.WritableStream): Logger;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
const _logLevelPriority = {
|
|
2
|
+
debug: 10,
|
|
3
|
+
info: 20,
|
|
4
|
+
warn: 30,
|
|
5
|
+
error: 40,
|
|
6
|
+
silent: 50
|
|
7
|
+
};
|
|
8
|
+
export function isLogLevel(value) {
|
|
9
|
+
return value in _logLevelPriority;
|
|
10
|
+
}
|
|
11
|
+
export function createLogger(level, sink = process.stderr) {
|
|
12
|
+
return {
|
|
13
|
+
debug: _write.bind(null, "debug", level, sink),
|
|
14
|
+
info: _write.bind(null, "info", level, sink),
|
|
15
|
+
warn: _write.bind(null, "warn", level, sink),
|
|
16
|
+
error: _write.bind(null, "error", level, sink)
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
function _write(level, threshold, sink, message) {
|
|
20
|
+
if (_logLevelPriority[level] < _logLevelPriority[threshold]) {
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
sink.write(`${new Date().toISOString()} ${level.toUpperCase()} ${message}\n`);
|
|
24
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function handleScan(args: string[]): Promise<number>;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { ScanWriter, SnapshotRepository, openDatabase } from "../db/index.js";
|
|
2
|
+
import { importGitHubScan } from "../ingest/github/index.js";
|
|
3
|
+
import { requireOption, resolveGitHubToken, resolveLogLevel } from "./_args.js";
|
|
4
|
+
import { createLogger } from "./_logger.js";
|
|
5
|
+
export async function handleScan(args) {
|
|
6
|
+
const databasePath = requireOption(args, "--db");
|
|
7
|
+
const owner = requireOption(args, "--owner");
|
|
8
|
+
const packageName = requireOption(args, "--package");
|
|
9
|
+
const logger = createLogger(resolveLogLevel(args));
|
|
10
|
+
const database = openDatabase(databasePath);
|
|
11
|
+
const repository = new SnapshotRepository(database);
|
|
12
|
+
const writer = new ScanWriter(database);
|
|
13
|
+
await importGitHubScan({
|
|
14
|
+
owner,
|
|
15
|
+
packageName,
|
|
16
|
+
token: resolveGitHubToken(args),
|
|
17
|
+
logger
|
|
18
|
+
}, writer, repository);
|
|
19
|
+
const scanId = writer.getActiveScanId();
|
|
20
|
+
const metadata = repository.getPackageMetadata(scanId);
|
|
21
|
+
console.log(JSON.stringify({
|
|
22
|
+
owner: metadata.owner,
|
|
23
|
+
packageName: metadata.packageName,
|
|
24
|
+
scanCompletedAt: metadata.scanCompletedAt,
|
|
25
|
+
packageVersions: repository.countPackageVersions(scanId),
|
|
26
|
+
tags: repository.countTags(scanId),
|
|
27
|
+
manifests: repository.countManifests(scanId),
|
|
28
|
+
manifestEdges: repository.countManifestEdges(scanId)
|
|
29
|
+
}, null, 2));
|
|
30
|
+
database.close();
|
|
31
|
+
return 0;
|
|
32
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { realpathSync } from "node:fs";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { handleScan } from "./_scan-command.js";
|
|
5
|
+
export async function main(argv) {
|
|
6
|
+
const [command, ...rest] = argv;
|
|
7
|
+
if (!command) {
|
|
8
|
+
printUsage();
|
|
9
|
+
return 1;
|
|
10
|
+
}
|
|
11
|
+
switch (command) {
|
|
12
|
+
case "scan":
|
|
13
|
+
return handleScan(rest);
|
|
14
|
+
default:
|
|
15
|
+
throw new Error(`unknown command: ${command}`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function printUsage() {
|
|
19
|
+
console.error(`Usage:
|
|
20
|
+
ghcr-manager scan --db <path> [--log-level <debug|info|warn|error|silent>] --owner <org> --package <name> --token <token>`);
|
|
21
|
+
}
|
|
22
|
+
const _entryPath = process.argv[1];
|
|
23
|
+
const _isDirectExecution = realpathSync(_entryPath) === realpathSync(fileURLToPath(import.meta.url));
|
|
24
|
+
if (_isDirectExecution) {
|
|
25
|
+
main(process.argv.slice(2)).catch((error) => {
|
|
26
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
27
|
+
console.error(message);
|
|
28
|
+
process.exitCode = 1;
|
|
29
|
+
});
|
|
30
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export interface PackageVersionRecord {
|
|
2
|
+
versionId: number;
|
|
3
|
+
digest: string;
|
|
4
|
+
createdAt: string;
|
|
5
|
+
updatedAt: string;
|
|
6
|
+
metadata?: Record<string, unknown>;
|
|
7
|
+
}
|
|
8
|
+
export interface TagRecord {
|
|
9
|
+
tag: string;
|
|
10
|
+
digest: string;
|
|
11
|
+
versionId: number;
|
|
12
|
+
}
|
|
13
|
+
export interface ManifestRecord {
|
|
14
|
+
digest: string;
|
|
15
|
+
mediaType: string;
|
|
16
|
+
artifactType?: string;
|
|
17
|
+
configMediaType?: string;
|
|
18
|
+
subjectDigest?: string;
|
|
19
|
+
annotations?: Record<string, unknown>;
|
|
20
|
+
platform?: {
|
|
21
|
+
architecture?: string;
|
|
22
|
+
os?: string;
|
|
23
|
+
variant?: string;
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
export interface ManifestDescriptorRecord {
|
|
27
|
+
parentDigest: string;
|
|
28
|
+
childDigest: string;
|
|
29
|
+
mediaType: string;
|
|
30
|
+
artifactType?: string;
|
|
31
|
+
platform?: {
|
|
32
|
+
architecture?: string;
|
|
33
|
+
os?: string;
|
|
34
|
+
variant?: string;
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
export interface ManifestEdgeRecord {
|
|
38
|
+
parentDigest: string;
|
|
39
|
+
childDigest: string;
|
|
40
|
+
edgeKind: "image-child" | "referrer";
|
|
41
|
+
}
|
|
42
|
+
export interface PackageSnapshot {
|
|
43
|
+
packageName: string;
|
|
44
|
+
scanCompletedAt: string;
|
|
45
|
+
packageVersions: PackageVersionRecord[];
|
|
46
|
+
tags: TagRecord[];
|
|
47
|
+
manifests: ManifestRecord[];
|
|
48
|
+
manifestEdges: ManifestEdgeRecord[];
|
|
49
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export type { ManifestEdgeRecord, ManifestDescriptorRecord, ManifestRecord, PackageSnapshot, PackageVersionRecord, TagRecord } from "./_types.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
export function rebuildManifestReachability(database, scanId) {
|
|
2
|
+
const manifestDigests = _loadManifestDigests(database, scanId);
|
|
3
|
+
const childDigestsByParent = new Map();
|
|
4
|
+
const parentDigestsByChild = new Map();
|
|
5
|
+
for (const digest of manifestDigests) {
|
|
6
|
+
childDigestsByParent.set(digest, new Set());
|
|
7
|
+
parentDigestsByChild.set(digest, new Set());
|
|
8
|
+
}
|
|
9
|
+
for (const manifestEdge of _loadManifestEdges(database, scanId)) {
|
|
10
|
+
childDigestsByParent.get(manifestEdge.parent_digest)?.add(manifestEdge.child_digest);
|
|
11
|
+
parentDigestsByChild.get(manifestEdge.child_digest)?.add(manifestEdge.parent_digest);
|
|
12
|
+
}
|
|
13
|
+
const remainingChildrenCount = new Map();
|
|
14
|
+
const descendantDistancesByDigest = new Map();
|
|
15
|
+
const readyDigests = [];
|
|
16
|
+
for (const digest of manifestDigests) {
|
|
17
|
+
const childCount = childDigestsByParent.get(digest)?.size ?? 0;
|
|
18
|
+
remainingChildrenCount.set(digest, childCount);
|
|
19
|
+
if (childCount === 0) {
|
|
20
|
+
readyDigests.push(digest);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
while (readyDigests.length > 0) {
|
|
24
|
+
const digest = readyDigests.shift();
|
|
25
|
+
if (!digest) {
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
const distances = new Map([[digest, 0]]);
|
|
29
|
+
for (const childDigest of childDigestsByParent.get(digest) ?? []) {
|
|
30
|
+
_setMinDistance(distances, childDigest, 1);
|
|
31
|
+
const childDistances = descendantDistancesByDigest.get(childDigest);
|
|
32
|
+
if (!childDistances) {
|
|
33
|
+
throw new Error(`manifest reachability build missing child results for ${childDigest}`);
|
|
34
|
+
}
|
|
35
|
+
for (const [descendantDigest, childDistance] of childDistances) {
|
|
36
|
+
if (descendantDigest === childDigest) {
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
_setMinDistance(distances, descendantDigest, childDistance + 1);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
descendantDistancesByDigest.set(digest, distances);
|
|
43
|
+
for (const parentDigest of parentDigestsByChild.get(digest) ?? []) {
|
|
44
|
+
const nextCount = (remainingChildrenCount.get(parentDigest) ?? 0) - 1;
|
|
45
|
+
remainingChildrenCount.set(parentDigest, nextCount);
|
|
46
|
+
if (nextCount === 0) {
|
|
47
|
+
readyDigests.push(parentDigest);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (descendantDistancesByDigest.size !== manifestDigests.length) {
|
|
52
|
+
throw new Error("manifest reachability build detected a cycle in manifest_edges");
|
|
53
|
+
}
|
|
54
|
+
const insertRow = database.prepare(`
|
|
55
|
+
INSERT OR REPLACE INTO manifest_reachability(
|
|
56
|
+
scan_id,
|
|
57
|
+
ancestor_digest,
|
|
58
|
+
descendant_digest,
|
|
59
|
+
min_distance
|
|
60
|
+
)
|
|
61
|
+
VALUES(?, ?, ?, ?)
|
|
62
|
+
`);
|
|
63
|
+
const rebuild = database.transaction(() => {
|
|
64
|
+
database.prepare("DELETE FROM manifest_reachability WHERE scan_id = ?").run(scanId);
|
|
65
|
+
for (const digest of manifestDigests) {
|
|
66
|
+
for (const [descendantDigest, distance] of descendantDistancesByDigest.get(digest) ?? []) {
|
|
67
|
+
insertRow.run(scanId, digest, descendantDigest, distance);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
rebuild();
|
|
72
|
+
}
|
|
73
|
+
function _loadManifestDigests(database, scanId) {
|
|
74
|
+
const rows = database
|
|
75
|
+
.prepare("SELECT digest FROM manifests WHERE scan_id = ? ORDER BY digest")
|
|
76
|
+
.all(scanId);
|
|
77
|
+
return rows.map((row) => row.digest);
|
|
78
|
+
}
|
|
79
|
+
function _loadManifestEdges(database, scanId) {
|
|
80
|
+
return database
|
|
81
|
+
.prepare(`
|
|
82
|
+
SELECT DISTINCT parent_digest, child_digest
|
|
83
|
+
FROM manifest_edges
|
|
84
|
+
WHERE scan_id = ?
|
|
85
|
+
ORDER BY parent_digest, child_digest
|
|
86
|
+
`)
|
|
87
|
+
.all(scanId);
|
|
88
|
+
}
|
|
89
|
+
function _setMinDistance(distances, digest, distance) {
|
|
90
|
+
const currentDistance = distances.get(digest);
|
|
91
|
+
if (currentDistance === undefined || distance < currentDistance) {
|
|
92
|
+
distances.set(digest, distance);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type Database from "better-sqlite3";
|
|
2
|
+
import type { ManifestDescriptorRecord, ManifestEdgeRecord, ManifestRecord, PackageVersionRecord, TagRecord } from "../core/index.js";
|
|
3
|
+
export declare class ScanWriter {
|
|
4
|
+
#private;
|
|
5
|
+
constructor(database: Database.Database);
|
|
6
|
+
resetScan(owner: string, packageName: string, scanStartedAt: string): void;
|
|
7
|
+
markScanCompleted(scanCompletedAt: string): void;
|
|
8
|
+
markScanFailed(scanCompletedAt: string): void;
|
|
9
|
+
insertPackageVersion(version: PackageVersionRecord): void;
|
|
10
|
+
insertPackageVersionPayload(versionId: number, rawJson: string): void;
|
|
11
|
+
insertTag(tag: TagRecord): void;
|
|
12
|
+
insertManifest(manifest: ManifestRecord): void;
|
|
13
|
+
insertManifestPayload(digest: string, rawJson: string): void;
|
|
14
|
+
insertManifestDescriptor(descriptor: ManifestDescriptorRecord): void;
|
|
15
|
+
insertManifestEdge(edge: ManifestEdgeRecord): void;
|
|
16
|
+
rebuildManifestReachability(): void;
|
|
17
|
+
getActiveScanId(): number;
|
|
18
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { rebuildManifestReachability } from "./_manifest-reachability.js";
|
|
3
|
+
export class ScanWriter {
|
|
4
|
+
#database;
|
|
5
|
+
#activeScanId = null;
|
|
6
|
+
constructor(database) {
|
|
7
|
+
this.#database = database;
|
|
8
|
+
}
|
|
9
|
+
resetScan(owner, packageName, scanStartedAt) {
|
|
10
|
+
const result = this.#database
|
|
11
|
+
.prepare(`
|
|
12
|
+
INSERT INTO package_scans(scan_uuid, owner, package_name, scan_started_at, scan_completed_at, status)
|
|
13
|
+
VALUES(?, ?, ?, ?, NULL, 'running')
|
|
14
|
+
`)
|
|
15
|
+
.run(randomUUID(), owner, packageName, scanStartedAt);
|
|
16
|
+
this.#activeScanId = Number(result.lastInsertRowid);
|
|
17
|
+
}
|
|
18
|
+
markScanCompleted(scanCompletedAt) {
|
|
19
|
+
this.#database
|
|
20
|
+
.prepare(`
|
|
21
|
+
UPDATE package_scans
|
|
22
|
+
SET scan_completed_at = ?, status = 'completed'
|
|
23
|
+
WHERE scan_id = ?
|
|
24
|
+
`)
|
|
25
|
+
.run(scanCompletedAt, this.#requireScanId());
|
|
26
|
+
}
|
|
27
|
+
markScanFailed(scanCompletedAt) {
|
|
28
|
+
this.#database
|
|
29
|
+
.prepare(`
|
|
30
|
+
UPDATE package_scans
|
|
31
|
+
SET scan_completed_at = ?, status = 'failed'
|
|
32
|
+
WHERE scan_id = ?
|
|
33
|
+
`)
|
|
34
|
+
.run(scanCompletedAt, this.#requireScanId());
|
|
35
|
+
}
|
|
36
|
+
insertPackageVersion(version) {
|
|
37
|
+
this.#database
|
|
38
|
+
.prepare(`
|
|
39
|
+
INSERT OR REPLACE INTO package_versions(scan_id, version_id, digest, created_at, updated_at)
|
|
40
|
+
VALUES(@scanId, @versionId, @digest, @createdAt, @updatedAt)
|
|
41
|
+
`)
|
|
42
|
+
.run({
|
|
43
|
+
scanId: this.#requireScanId(),
|
|
44
|
+
versionId: version.versionId,
|
|
45
|
+
digest: version.digest,
|
|
46
|
+
createdAt: version.createdAt,
|
|
47
|
+
updatedAt: version.updatedAt
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
insertPackageVersionPayload(versionId, rawJson) {
|
|
51
|
+
this.#database
|
|
52
|
+
.prepare(`
|
|
53
|
+
INSERT OR REPLACE INTO package_version_payloads(scan_id, version_id, raw_json)
|
|
54
|
+
VALUES(?, ?, ?)
|
|
55
|
+
`)
|
|
56
|
+
.run(this.#requireScanId(), versionId, rawJson);
|
|
57
|
+
}
|
|
58
|
+
insertTag(tag) {
|
|
59
|
+
this.#database
|
|
60
|
+
.prepare(`
|
|
61
|
+
INSERT OR REPLACE INTO tags(scan_id, tag, digest, version_id)
|
|
62
|
+
VALUES(@scanId, @tag, @digest, @versionId)
|
|
63
|
+
`)
|
|
64
|
+
.run({
|
|
65
|
+
scanId: this.#requireScanId(),
|
|
66
|
+
...tag
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
insertManifest(manifest) {
|
|
70
|
+
this.#database
|
|
71
|
+
.prepare(`
|
|
72
|
+
INSERT OR REPLACE INTO manifests(
|
|
73
|
+
scan_id,
|
|
74
|
+
digest,
|
|
75
|
+
media_type,
|
|
76
|
+
artifact_type,
|
|
77
|
+
config_media_type,
|
|
78
|
+
subject_digest,
|
|
79
|
+
annotations_json,
|
|
80
|
+
platform_os,
|
|
81
|
+
platform_architecture,
|
|
82
|
+
platform_variant
|
|
83
|
+
)
|
|
84
|
+
VALUES(
|
|
85
|
+
@scanId,
|
|
86
|
+
@digest,
|
|
87
|
+
@mediaType,
|
|
88
|
+
@artifactType,
|
|
89
|
+
@configMediaType,
|
|
90
|
+
@subjectDigest,
|
|
91
|
+
@annotationsJson,
|
|
92
|
+
@platformOs,
|
|
93
|
+
@platformArchitecture,
|
|
94
|
+
@platformVariant
|
|
95
|
+
)
|
|
96
|
+
`)
|
|
97
|
+
.run({
|
|
98
|
+
scanId: this.#requireScanId(),
|
|
99
|
+
digest: manifest.digest,
|
|
100
|
+
mediaType: manifest.mediaType,
|
|
101
|
+
artifactType: manifest.artifactType ?? null,
|
|
102
|
+
configMediaType: manifest.configMediaType ?? null,
|
|
103
|
+
subjectDigest: manifest.subjectDigest ?? null,
|
|
104
|
+
annotationsJson: manifest.annotations ? JSON.stringify(manifest.annotations) : null,
|
|
105
|
+
platformOs: manifest.platform?.os ?? null,
|
|
106
|
+
platformArchitecture: manifest.platform?.architecture ?? null,
|
|
107
|
+
platformVariant: manifest.platform?.variant ?? null
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
insertManifestPayload(digest, rawJson) {
|
|
111
|
+
this.#database
|
|
112
|
+
.prepare(`
|
|
113
|
+
INSERT OR REPLACE INTO manifest_payloads(scan_id, digest, raw_json)
|
|
114
|
+
VALUES(?, ?, ?)
|
|
115
|
+
`)
|
|
116
|
+
.run(this.#requireScanId(), digest, rawJson);
|
|
117
|
+
}
|
|
118
|
+
insertManifestDescriptor(descriptor) {
|
|
119
|
+
this.#database
|
|
120
|
+
.prepare(`
|
|
121
|
+
INSERT OR REPLACE INTO manifest_descriptors(
|
|
122
|
+
scan_id,
|
|
123
|
+
parent_digest,
|
|
124
|
+
child_digest,
|
|
125
|
+
media_type,
|
|
126
|
+
artifact_type,
|
|
127
|
+
platform_os,
|
|
128
|
+
platform_architecture,
|
|
129
|
+
platform_variant
|
|
130
|
+
)
|
|
131
|
+
VALUES(
|
|
132
|
+
@scanId,
|
|
133
|
+
@parentDigest,
|
|
134
|
+
@childDigest,
|
|
135
|
+
@mediaType,
|
|
136
|
+
@artifactType,
|
|
137
|
+
@platformOs,
|
|
138
|
+
@platformArchitecture,
|
|
139
|
+
@platformVariant
|
|
140
|
+
)
|
|
141
|
+
`)
|
|
142
|
+
.run({
|
|
143
|
+
scanId: this.#requireScanId(),
|
|
144
|
+
parentDigest: descriptor.parentDigest,
|
|
145
|
+
childDigest: descriptor.childDigest,
|
|
146
|
+
mediaType: descriptor.mediaType,
|
|
147
|
+
artifactType: descriptor.artifactType ?? null,
|
|
148
|
+
platformOs: descriptor.platform?.os ?? null,
|
|
149
|
+
platformArchitecture: descriptor.platform?.architecture ?? null,
|
|
150
|
+
platformVariant: descriptor.platform?.variant ?? null
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
insertManifestEdge(edge) {
|
|
154
|
+
this.#database
|
|
155
|
+
.prepare(`
|
|
156
|
+
INSERT OR IGNORE INTO manifest_edges(scan_id, parent_digest, child_digest, edge_kind)
|
|
157
|
+
VALUES(@scanId, @parentDigest, @childDigest, @edgeKind)
|
|
158
|
+
`)
|
|
159
|
+
.run({
|
|
160
|
+
scanId: this.#requireScanId(),
|
|
161
|
+
...edge
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
rebuildManifestReachability() {
|
|
165
|
+
rebuildManifestReachability(this.#database, this.#requireScanId());
|
|
166
|
+
}
|
|
167
|
+
getActiveScanId() {
|
|
168
|
+
return this.#requireScanId();
|
|
169
|
+
}
|
|
170
|
+
#requireScanId() {
|
|
171
|
+
if (this.#activeScanId === null) {
|
|
172
|
+
throw new Error("package not initialized; call resetScan(owner, packageName, scanStartedAt) first");
|
|
173
|
+
}
|
|
174
|
+
return this.#activeScanId;
|
|
175
|
+
}
|
|
176
|
+
}
|