@runeditrun/rer 0.2.2 → 0.2.5
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/README.md +9 -2
- package/cli/README.md +30 -0
- package/cli/container.mjs +11 -10
- package/cli/contract.mjs +5 -2
- package/cli/demo-host.mjs +47 -0
- package/cli/demo.mjs +47 -0
- package/cli/dependencies.mjs +26 -4
- package/cli/deploy-command.mjs +108 -0
- package/cli/deploy-hook.mjs +31 -0
- package/cli/deploy-inputs.mjs +22 -0
- package/cli/deploy-observation.mjs +29 -0
- package/cli/deploy-owner.mjs +59 -39
- package/cli/deploy-readiness.mjs +16 -0
- package/cli/deploy-source.mjs +24 -0
- package/cli/deploy.mjs +22 -10
- package/cli/detached-server.mjs +1 -1
- package/cli/export.mjs +1 -1
- package/cli/hosted-output.mjs +2 -1
- package/cli/inspect.mjs +2 -2
- package/cli/lifecycle.mjs +20 -7
- package/cli/local.mjs +7 -0
- package/cli/main.mjs +1 -1
- package/cli/managed-server.mjs +27 -18
- package/cli/parse.mjs +7 -2
- package/cli/pnpm-probe.mjs +4 -1
- package/cli/portability.mjs +4 -1
- package/cli/scheduled.mjs +2 -2
- package/cli/setup-input.mjs +1 -1
- package/global/CHANGELOG.md +63 -0
- package/global/README.md +9 -2
- package/global/launch/capacity.mjs +1 -1
- package/global/launch/facts.mjs +12 -6
- package/global/launch/install-pinned.mjs +26 -2
- package/global/launch/prepare.mjs +20 -8
- package/global/launch/state.mjs +64 -11
- package/global/main.mjs +18 -20
- package/global/update/command.mjs +2 -1
- package/modules/container/README.md +3 -1
- package/modules/container/boundary.mjs +5 -2
- package/modules/container/build.mjs +4 -2
- package/modules/container/host.mjs +1 -1
- package/modules/container/ingress.mjs +4 -1
- package/modules/container/migrations.mjs +2 -1
- package/modules/container/worker-engine.mjs +3 -1
- package/modules/demo-host/README.md +30 -0
- package/modules/demo-host/capacity.mjs +19 -0
- package/modules/demo-host/claim.mjs +21 -0
- package/modules/demo-host/container.mjs +74 -0
- package/modules/demo-host/pages.mjs +56 -0
- package/modules/demo-host/plan.mjs +52 -0
- package/modules/demo-host/worker.mjs +72 -0
- package/modules/deploy-kit/README.md +36 -7
- package/modules/deploy-kit/deploy.mjs +9 -2
- package/modules/deploy-kit/env-file.mjs +2 -1
- package/modules/deploy-kit/health-request.mjs +14 -7
- package/modules/deploy-kit/migration-ledger.mjs +38 -1
- package/modules/deploy-kit/migrations.mjs +38 -2
- package/modules/deploy-kit/owner-cli.mjs +30 -9
- package/modules/deploy-kit/owner-remove.mjs +6 -2
- package/modules/deploy-kit/owner-secret-names.mjs +31 -0
- package/modules/deploy-kit/owner-secret.mjs +71 -23
- package/modules/deploy-kit/owner.mjs +78 -25
- package/modules/deploy-kit/prepare.mjs +3 -1
- package/modules/deploy-kit/provider.mjs +3 -0
- package/modules/erasure/README.md +2 -3
- package/modules/operator-auth/accounts/settings-page.mjs +8 -3
- package/modules/operator-auth/csp.mjs +26 -0
- package/modules/operator-auth/dev/badge.mjs +1 -22
- package/modules/operator-auth/page.mjs +2 -2
- package/modules/operator-auth/routes.mjs +1 -1
- package/modules/portability/README.md +9 -0
- package/modules/portability/bridge.mjs +4 -1
- package/modules/portability/catalogue.mjs +9 -2
- package/modules/portability/index.d.mts +4 -0
- package/modules/portability/index.mjs +1 -0
- package/modules/portability/lines.mjs +3 -1
- package/modules/release/produce.mjs +1 -1
- package/modules/release/source.mjs +5 -2
- package/onboarding/adapter-labels.mjs +1 -0
- package/onboarding/adapter.mjs +9 -4
- package/onboarding/dependency-environment.mjs +33 -0
- package/onboarding/local/owner.mjs +1 -1
- package/onboarding/operator-installer.mjs +10 -4
- package/onboarding/redaction.mjs +22 -3
- package/package.json +6 -1
- package/scripts/publish-check/README.md +22 -1
- package/scripts/publish-check/c2pa.py +121 -0
- package/scripts/publish-check/content_identities.py +3 -1
- package/scripts/publish-check/controller_metadata.py +31 -0
- package/scripts/publish-check/history_scan.py +13 -3
- package/scripts/publish-check/media.py +283 -0
- package/scripts/publish-check/png_text.py +34 -0
- package/scripts/publish-check/public_hygiene.py +39 -4
- package/scripts/publish-check/publication-config.json +11 -1
|
@@ -15,8 +15,8 @@ function stylesheet(accentColor) {
|
|
|
15
15
|
return `:root{color-scheme:light;font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#172033;background:#f3f5f8}*{box-sizing:border-box}body{margin:0;min-height:100vh;padding:32px 20px;display:grid;place-items:center}main{width:100%;max-width:480px;background:#fff;border:1px solid #dce2ea;border-radius:20px;padding:36px;box-shadow:0 12px 40px #1720330a;overflow-wrap:anywhere}.brand{font-size:14px;font-weight:650;line-height:1.5;margin:0 0 32px;color:#475569}.mark{display:block;width:32px;height:6px;border-radius:3px;border:1px solid currentColor;background:${accentColor};margin-bottom:16px}h1,h2{text-wrap:balance}h1{font-size:30px;line-height:1.3;letter-spacing:-.02em;margin:0 0 24px;font-weight:700}p{font-size:16px;line-height:1.6;margin:0 0 24px;color:#475569;text-wrap:pretty}form{margin:0}button,.action{display:block;width:100%;border:1px solid #64748b;border-radius:10px;background:${accentColor};color:${accentInk(accentColor)};padding:13px 18px;font:inherit;font-weight:650;text-align:center;text-decoration:none;cursor:pointer;line-height:1.5}button:hover,.action:hover{box-shadow:inset 0 0 0 2px currentColor}button:focus-visible,.action:focus-visible{outline:3px solid #172033;outline-offset:4px}@media(max-width:480px){body{padding:24px 16px}main{padding:28px 24px}h1{font-size:28px}}@media(prefers-color-scheme:dark){:root{color-scheme:dark;background:#111827;color:#f8fafc}main{background:#1e293b;border-color:#475569;box-shadow:none}.brand,p{color:#cbd5e1}button:focus-visible,.action:focus-visible{outline-color:#f8fafc}}`;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
// Form controls and notices used by the sign-in
|
|
19
|
-
const FORMS = `label{display:block;font-weight:600;margin:0 0 8px}input{display:block;width:100%;margin:0 0 16px;padding:12px 14px;border:1px solid #64748b;border-radius:10px;font:inherit;line-height:1.5;color:inherit;background:transparent}input:focus-visible{outline:3px solid #172033;outline-offset:2px}.stack{display:grid;gap:12px;margin:0 0 24px}button.secondary{background:transparent;color:inherit}.divider{display:flex;align-items:center;gap:12px;margin:0 0 24px;color:#475569;font-size:14px}.divider::before,.divider::after{content:"";flex:1;border-top:1px solid #dce2ea}.notice{border-left:4px solid #64748b;padding:8px 12px;margin:0 0 24px}h2{font-size:20px;line-height:1.4;margin:32px 0 16px}.secondary-link{margin:16px 0 0;text-align:center;font-size:15px}.secondary-link a{display:inline-block;min-width:24px;min-height:24px;color:#334155;font-weight:600;text-decoration:underline;text-decoration-thickness:1px;text-underline-offset:3px}.secondary-link a:hover{text-decoration-thickness:2px}.secondary-link a:focus-visible{outline:3px solid #172033;outline-offset:2px;border-radius:2px}@media(prefers-color-scheme:dark){input:focus-visible,.secondary-link a:focus-visible{outline-color:#f8fafc}.divider{color:#cbd5e1}.divider::before,.divider::after{border-color:#475569}.secondary-link a{color:#cbd5e1}}`;
|
|
18
|
+
// Form controls and notices used by the sign-in and settings pages; part of the same hashed sheet.
|
|
19
|
+
const FORMS = `label{display:block;font-weight:600;margin:0 0 8px}input{display:block;width:100%;margin:0 0 16px;padding:12px 14px;border:1px solid #64748b;border-radius:10px;font:inherit;line-height:1.5;color:inherit;background:transparent}input:focus-visible{outline:3px solid #172033;outline-offset:2px}.stack{display:grid;gap:12px;margin:0 0 24px}.check{display:flex;align-items:center;gap:12px;margin:0 0 24px}.check input{width:20px;height:20px;margin:0;padding:0;flex:none}button.secondary{background:transparent;color:inherit}.divider{display:flex;align-items:center;gap:12px;margin:0 0 24px;color:#475569;font-size:14px}.divider::before,.divider::after{content:"";flex:1;border-top:1px solid #dce2ea}.notice{border-left:4px solid #64748b;padding:8px 12px;margin:0 0 24px}h2{font-size:20px;line-height:1.4;margin:32px 0 16px}.secondary-link{margin:16px 0 0;text-align:center;font-size:15px}.secondary-link a{display:inline-block;min-width:24px;min-height:24px;color:#334155;font-weight:600;text-decoration:underline;text-decoration-thickness:1px;text-underline-offset:3px}.secondary-link a:hover{text-decoration-thickness:2px}.secondary-link a:focus-visible{outline:3px solid #172033;outline-offset:2px;border-radius:2px}@media(prefers-color-scheme:dark){input:focus-visible,.secondary-link a:focus-visible{outline-color:#f8fafc}.divider{color:#cbd5e1}.divider::before,.divider::after{border-color:#475569}.secondary-link a{color:#cbd5e1}}`;
|
|
20
20
|
|
|
21
21
|
export async function pageResponse({ branding, title, heading, content, status = 200, method = 'GET', referrer, formTargets = [] }) {
|
|
22
22
|
const css = stylesheet(branding.accentColor) + FORMS + (branding.theme ? themed(branding.accentColor) : '');
|
|
@@ -42,7 +42,7 @@ export function createAuthHandler(auth) {
|
|
|
42
42
|
if (path === '/auth/operators' && request.method === 'GET') return json(await auth.listOperators(request));
|
|
43
43
|
if (path === '/auth/sessions' && request.method === 'GET') return json(await auth.listSessions(request, readParameters(url.searchParams).operatorId));
|
|
44
44
|
if (path === '/auth/domain-rules' && request.method === 'GET') return json(await auth.listDomainRules(request));
|
|
45
|
-
if (path === '/auth/settings/identity' && request.method === 'GET') {const value=await auth.identitySettings(request);return /text\/html/.test(request.headers.get('accept')??'')?identitySettingsPage(value):json(value);}
|
|
45
|
+
if (path === '/auth/settings/identity' && request.method === 'GET') {const value=await auth.identitySettings(request);return /text\/html/.test(request.headers.get('accept')??'')?await identitySettingsPage(value,auth.presentation):json(value);}
|
|
46
46
|
if (path === '/auth/providers' && request.method === 'GET') return json(await auth.listProviders(request));
|
|
47
47
|
const posts = new Set(['/auth/bootstrap', '/auth/magic-link', '/auth/confirm', '/auth/signout', '/auth/invite', '/auth/invite/cancel', '/auth/review-session',
|
|
48
48
|
'/auth/operator/status', '/auth/operator/role', '/auth/session/revoke', '/auth/step-up', '/auth/domain-rules', '/auth/providers/unlink', '/auth/providers/relink', '/auth/settings/identity', '/auth/setup-complete']);
|
|
@@ -32,6 +32,15 @@ schema at construction. Apply the bookkeeping migration first. Policy fields:
|
|
|
32
32
|
| `extraPayloads` | Named `{produce,consume}` pairs. Producers return bytes or a byte stream. Consumers receive a verified byte stream and descriptor before D1 commit. |
|
|
33
33
|
| `partBytes` | Maximum part size, default 16 MiB, configurable from 1 KiB to 16 MiB. |
|
|
34
34
|
|
|
35
|
+
Archives always skip Base's own tables: SQLite and Cloudflare internals, the
|
|
36
|
+
migration ledgers (`d1_migrations`, `_rer_deploy_migrations`,
|
|
37
|
+
`_rer_portability_migrations`), portability bookkeeping, development outboxes,
|
|
38
|
+
operator identity and dependency secrets. `excludedTables` lists them (exact
|
|
39
|
+
names, or prefixes ending in `%`), and `excludedTable(name, policy)` says whether
|
|
40
|
+
an archive under that policy skips a table. A seed's own table inventory, which
|
|
41
|
+
refuses tables it does not know, uses the same predicate, so it does not refuse a
|
|
42
|
+
table Base created and never archives.
|
|
43
|
+
|
|
35
44
|
For native local CLI transfers, declare executable policy through
|
|
36
45
|
`localPortability({database, buckets, policyModule})`. `policyModule` is a
|
|
37
46
|
seed-root-relative path or a `file:` URL (including `new URL('./archive-policy.mjs',
|
|
@@ -54,10 +54,13 @@ async function placement(db,manifest,cat,replay,applied) {
|
|
|
54
54
|
// migrations that changed only data (a purge or a backfill) or added an index.
|
|
55
55
|
// Those that change restorable data are refused like any other. Without a
|
|
56
56
|
// replayer the statements cannot be read, so an archive whose recorded
|
|
57
|
-
// migrations the destination's ledger has outgrown is refused.
|
|
57
|
+
// migrations the destination's ledger has outgrown is refused. An archive that
|
|
58
|
+
// records a migration the destination never applied is newer than it, even
|
|
59
|
+
// when that migration left the schema as it was (an index, a data change).
|
|
58
60
|
export async function sameSchemaMigrations(db,manifest,cat,migrations) {
|
|
59
61
|
const applied=await appliedMigrations(db);
|
|
60
62
|
if(!applied)return;
|
|
63
|
+
insist(!manifest.migrations || manifest.migrations.every(name=>applied.includes(name)),'schema_mismatch:migrations');
|
|
61
64
|
if(!migrations){insist(!manifest.migrations || applied.every(name=>manifest.migrations.includes(name)),'schema_mismatch:migrations');return;}
|
|
62
65
|
await placement(db,manifest,cat,await migrations(),applied);
|
|
63
66
|
}
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { all, insist, quote, PortabilityError } from './storage.mjs';
|
|
2
2
|
import { canonicalJson, sha256, base64, unbase64, utf8 } from './hash.mjs';
|
|
3
|
-
|
|
3
|
+
/** Tables no archive carries: SQLite/Cloudflare internals, migration ledgers,
|
|
4
|
+
* portability bookkeeping, development outboxes, operator identity and dependency
|
|
5
|
+
* secrets. Exact names, or prefixes ending in `%`. Seeds' own table inventories
|
|
6
|
+
* skip the same tables through `excludedTable`. */
|
|
7
|
+
export const excludedTables=Object.freeze(['sqlite_%','_cf_%','d1_migrations','_rer_deploy_migrations','_rer_portability_migrations','rer_portability_%','rer_mail_development_outbox','rer_ai_development_outbox','oa_%','oc_%','rer_dependency_keys','rer_dependency_pending','rer_dependency_codes']);
|
|
4
8
|
const fields=['product','excludeTables','includeTables','derivedTables','secretColumns','exportRow','replaceableTables','requiredConfiguration','validateStaged','acceptedSchemas','buckets','extraPayloads','partBytes'];
|
|
5
9
|
const name=value=>typeof value==='string' && value.length>0 && value.length<=512 && !/[\x00-\x1f]/.test(value);
|
|
6
10
|
const names=value=>Array.isArray(value) && value.every(name) && new Set(value).size===value.length;
|
|
@@ -19,10 +23,13 @@ export function policy(input) {
|
|
|
19
23
|
}
|
|
20
24
|
for(const [key,entry] of Object.entries(result.extraPayloads)) insist(name(key) && typeof entry.produce==='function' && typeof entry.consume==='function','invalid_extra_payload');
|
|
21
25
|
for(const key of ['secretColumns','acceptedSchemas','buckets','extraPayloads'])result[key]=Object.assign(Object.create(null),result[key]);
|
|
22
|
-
return {...result,excludeTables:[...
|
|
26
|
+
return {...result,excludeTables:[...excludedTables,...result.excludeTables]};
|
|
23
27
|
}
|
|
24
28
|
const identifier=value=>value.replace(/[A-Z]/g,letter=>letter.toLowerCase());
|
|
25
29
|
const matches=(table,pattern)=>pattern.endsWith('%') ? identifier(table).startsWith(identifier(pattern.slice(0,-1))) : identifier(table)===identifier(pattern);
|
|
30
|
+
/** True when no archive carries `table`: one of Base's `excludedTables`, or one of
|
|
31
|
+
* the policy's own `excludeTables`, unless the policy includes it explicitly. */
|
|
32
|
+
export const excludedTable=(table,input={})=>!(input.includeTables??[]).includes(table) && [...excludedTables,...(input.excludeTables??[])].some(pattern=>matches(table,pattern));
|
|
26
33
|
async function archived(db,config) {
|
|
27
34
|
const objects=await all(db,"SELECT name,sql,type,tbl_name FROM sqlite_master WHERE sql IS NOT NULL ORDER BY name");
|
|
28
35
|
const kinds=await all(db,'PRAGMA table_list');
|
|
@@ -54,5 +54,9 @@ export function makePart(index:number,items:Item[]):Promise<Part>;
|
|
|
54
54
|
export function canonicalJson(value:unknown):string;
|
|
55
55
|
export function sha256(value:string|Uint8Array):Promise<string>;
|
|
56
56
|
export const MAX_LINE_BYTES:number;
|
|
57
|
+
/** Tables no archive carries (exact names, or prefixes ending in `%`). */
|
|
58
|
+
export const excludedTables:readonly string[];
|
|
59
|
+
/** True when no archive under this policy carries `table`. */
|
|
60
|
+
export function excludedTable(table:string,policy?:Pick<ArchivePolicy,'excludeTables'|'includeTables'>):boolean;
|
|
57
61
|
export const MAX_PART_BYTES:number;
|
|
58
62
|
export class PortabilityError extends Error {code:string;status:number;constructor(code:string,status?:number)}
|
|
@@ -4,6 +4,7 @@ import { createExport, advanceExport, exportStatus, downloadExport, abortExport
|
|
|
4
4
|
import { createImport, uploadPart, finalizeImport, importStatus, abortImport } from './import.mjs';
|
|
5
5
|
import { erasureStatements, redactionStatements, invalidateTransfersStatements } from './journal.mjs';
|
|
6
6
|
export { archiveLines } from './lines.mjs';
|
|
7
|
+
export { excludedTables, excludedTable } from './catalogue.mjs';
|
|
7
8
|
export { readArchive, makePart, MAX_LINE_BYTES, MAX_PART_BYTES } from './format.mjs';
|
|
8
9
|
export { canonicalJson, sha256 } from './hash.mjs';
|
|
9
10
|
export { PortabilityError } from './storage.mjs';
|
|
@@ -14,7 +14,9 @@ export async function* archiveLines(source, maxLineBytes) {
|
|
|
14
14
|
buffer.set(fragment, bytes); bytes = required;
|
|
15
15
|
}
|
|
16
16
|
function line() {
|
|
17
|
-
|
|
17
|
+
// Invalid UTF-8 is malformed archive text: the same constant diagnostic as invalid JSON.
|
|
18
|
+
let value; try { value = decoder.decode(buffer.subarray(0, bytes)); } catch { throw new Error('archive_json'); }
|
|
19
|
+
bytes = 0;
|
|
18
20
|
return value.endsWith('\r') ? value.slice(0, -1) : value;
|
|
19
21
|
}
|
|
20
22
|
try {
|
|
@@ -39,7 +39,7 @@ export async function release({ repo, ref = 'HEAD', output, project, repository,
|
|
|
39
39
|
git(checkout, 'checkout', '--detach', source.commit);
|
|
40
40
|
// Honour the seed's committed install policy. Base's native-build settings
|
|
41
41
|
// are not interchangeable with pnpm 8's locked onlyBuiltDependencies.
|
|
42
|
-
const install = await command('pnpm', ['install', '--frozen-lockfile'], checkout);
|
|
42
|
+
const install = await command('pnpm', ['install', '--frozen-lockfile'], checkout, { dependencies: true });
|
|
43
43
|
const verify = install.status === 'PASS' ? await command('pnpm', ['run', 'verify'], checkout) : { status: 'FAIL', reason: 'frozen-install-failed', tests: 0, passed: 0, failed: 0, output: install.output };
|
|
44
44
|
// Release-only scale and capacity cases stay outside routine CI. The
|
|
45
45
|
// committed seed script is an additional gate in previews and releases.
|
|
@@ -68,12 +68,15 @@ function failureTail(logs) {
|
|
|
68
68
|
return bytes.subarray(Math.max(0, bytes.length - TAIL_BYTES)).toString('utf8').replace(/^\uFFFD+/, '');
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
|
|
71
|
+
// A dependency install runs code the seed did not write, so it gets the
|
|
72
|
+
// shared credential-free environment; the seed's own verify keeps the caller's.
|
|
73
|
+
export async function command(command, args, cwd, { dependencies = false } = {}) {
|
|
72
74
|
const {createCommands}=await shared('commands.mjs');
|
|
75
|
+
const inherited=dependencies?(await shared('dependency-environment.mjs')).dependencyEnvironment():process.env;
|
|
73
76
|
const directory=await mkdtemp(resolve(tmpdir(),'rer-release-'));
|
|
74
77
|
const state={logs:[]};
|
|
75
78
|
const store=await createCommands(directory,state,{terminal:()=>{},queue:{verb:'release'}});
|
|
76
|
-
const summary=testSummary(),env={...
|
|
79
|
+
const summary=testSummary(),env={...inherited,FORCE_COLOR:'0'};delete env.NODE_TEST_CONTEXT;
|
|
77
80
|
try {
|
|
78
81
|
await store.run('Release verification',command,args,{cwd,env,onChild:child=>child.stdout.on('data',bytes=>summary.write(bytes))});
|
|
79
82
|
return {status:'PASS',exitCode:0,...summary.finish()};
|
package/onboarding/adapter.mjs
CHANGED
|
@@ -7,6 +7,7 @@ import { buildEnvironment } from './local/environment.mjs';
|
|
|
7
7
|
import { localOperation } from './local/operation.mjs';
|
|
8
8
|
import { recoverInterrupted } from './local/recovery.mjs';
|
|
9
9
|
import { credentialValues } from './redaction.mjs';
|
|
10
|
+
import { dependencyEnvironment } from './dependency-environment.mjs';
|
|
10
11
|
import { labels } from './adapter-labels.mjs';
|
|
11
12
|
import { chooseOrigin, prepareSettings, readSettings } from './adapter-settings.mjs';
|
|
12
13
|
import {listenerPorts} from './process-diagnostics.mjs';
|
|
@@ -66,9 +67,12 @@ export function localInstaller(hooks = {}) {
|
|
|
66
67
|
if (['dev','preview'].includes(verb) && hooks.secrets?.originKey) context.vars[hooks.secrets.originKey] = context.origin;
|
|
67
68
|
store.addSecrets?.(Object.entries(context.vars).filter(([key]) => /secret|token|password/i.test(key)).map(([, value]) => value));
|
|
68
69
|
if (['installer', 'setup', 'build'].includes(verb) && hooks.build) await build(context, hooks);
|
|
69
|
-
|
|
70
|
+
// A demo (dev with options.demo) starts on fresh state in options.demo.state:
|
|
71
|
+
// migrate it, then claim the saved owner there exactly as setup does.
|
|
72
|
+
const demo = verb === 'dev' && Boolean(options.demo);
|
|
73
|
+
if ((['installer', 'setup'].includes(verb) || demo) && hooks.migrate) await phase(context, hooks, 'migrate', hooks.migrate);
|
|
70
74
|
if (['installer', 'setup', 'dev', 'preview'].includes(verb) && hooks.start) await start(context, verb === 'preview' ? {...hooks,start:options.preview} : verb === 'dev' && options.dev ? { ...hooks, start: options.dev } : hooks);
|
|
71
|
-
if (['installer', 'setup'].includes(verb) && hooks.owner) {
|
|
75
|
+
if ((['installer', 'setup'].includes(verb) || demo) && hooks.owner) {
|
|
72
76
|
if (typeof hooks.owner === 'function' || Array.isArray(hooks.owner)) await phase(context, hooks, 'owner', hooks.owner);
|
|
73
77
|
else await owner(context, hooks.owner);
|
|
74
78
|
}
|
|
@@ -78,7 +82,7 @@ export function localInstaller(hooks = {}) {
|
|
|
78
82
|
if (verb === 'installer' && hooks.hosting) await hosting(context, hooks.hosting);
|
|
79
83
|
store.state.appUrl = context.appUrl ?? (context.app ? context.origin : context.hostedUrl ?? context.origin);
|
|
80
84
|
if (context.hostedUrl) store.state.hostedUrl = context.hostedUrl;
|
|
81
|
-
await store.step(labels.complete);
|
|
85
|
+
await store.step(verb === 'build' ? labels.built : labels.complete);
|
|
82
86
|
await store.phase('complete');
|
|
83
87
|
return context;
|
|
84
88
|
}
|
|
@@ -157,7 +161,8 @@ async function commandsFor(context, hook) {
|
|
|
157
161
|
async function phase(context, hooks, name, hook) {
|
|
158
162
|
await context.store.step(labels[name]);
|
|
159
163
|
for (const [command, args = [], options = {}] of await commandsFor(context, hook)) {
|
|
160
|
-
|
|
164
|
+
// Dependency installs run third-party build scripts without credentials.
|
|
165
|
+
await context.run(labels[name], command, args, { step: steps[name], ...options, ...(name === 'dependencies' ? { env: dependencyEnvironment(options.env ?? process.env) } : {}) });
|
|
161
166
|
}
|
|
162
167
|
}
|
|
163
168
|
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// A dependency install runs code the seed did not write: the package manager
|
|
2
|
+
// and every build script it allows (esbuild, workerd, sharp, oxide). It gets
|
|
3
|
+
// only the variables an install needs, named below; every other variable,
|
|
4
|
+
// whatever it is called, stays with the caller. The seed's own commands that
|
|
5
|
+
// need credentials, such as deploy, still receive the whole environment.
|
|
6
|
+
import { applicationBindings } from './local/environment.mjs';
|
|
7
|
+
import { credentialName, credentialValue } from './redaction.mjs';
|
|
8
|
+
|
|
9
|
+
// The shell, locale, terminal and temporary folders, Node's own settings, and
|
|
10
|
+
// the SSH agent a git+ssh dependency is fetched through. Kept whatever their
|
|
11
|
+
// value looks like: a directory may resemble a token.
|
|
12
|
+
const system = /^(PATH|HOME|USER|LOGNAME|SHELL|TMPDIR|TMP|TEMP|TERM|LANG|LANGUAGE|LC_[A-Z]+|TZ|CI|SSH_AUTH_SOCK|NODE_OPTIONS|NODE_EXTRA_CA_CERTS|XDG_[A-Z_]+)$/;
|
|
13
|
+
// The install reaches its registry through these; any user:password is dropped.
|
|
14
|
+
const proxy = /^(https?_proxy|all_proxy|no_proxy)$/i;
|
|
15
|
+
// Package manager settings and rer's control variables, except credentials.
|
|
16
|
+
const settings = /^(npm_config_|PNPM_|COREPACK_|RER_)/i;
|
|
17
|
+
// Credential words inside a name, also run together: NPM_CONFIG__AUTHTOKEN,
|
|
18
|
+
// PNPM_REGISTRY_PASSWORD, RER_PREVIEW_TOKEN, RER_SIGNING_KEY.
|
|
19
|
+
const credentialPart = /TOKEN|SECRET|PASSW(OR)?D|PASSPHRASE|CREDENTIAL|CREDS|JWT|COOKIE|WEBHOOK|SESSION|AUTH(?!OR)|(API|ACCESS|PRIVATE|SIGNING|ENCRYPTION|MASTER|LICENSE)_?KEY/i;
|
|
20
|
+
|
|
21
|
+
// Up to the last @ of the authority, whatever the password holds (spaces included), after any leading whitespace.
|
|
22
|
+
const withoutUserinfo = value => value.replace(/^\s*([a-z][a-z0-9+.-]*:\/\/)?[^/?#]*@/i, '$1');
|
|
23
|
+
|
|
24
|
+
export function dependencyEnvironment(env = process.env) {
|
|
25
|
+
const kept = {};
|
|
26
|
+
for (const [key, value] of Object.entries(env)) {
|
|
27
|
+
if (value === undefined) continue;
|
|
28
|
+
if (system.test(key)) kept[key] = value;
|
|
29
|
+
else if (proxy.test(key)) kept[key] = withoutUserinfo(value);
|
|
30
|
+
else if (settings.test(key) && !(credentialName(key) || credentialPart.test(key) || applicationBindings.test(key) || credentialValue(value))) kept[key] = value;
|
|
31
|
+
}
|
|
32
|
+
return kept;
|
|
33
|
+
}
|
|
@@ -27,7 +27,7 @@ export async function assertLocalOwner(root,email,failure=message=>Error(message
|
|
|
27
27
|
if(!Array.isArray(owners)||owners.some(owner=>typeof owner!=='string'))throw failure('The localOwner hook must return existing owner email addresses without changing state.');
|
|
28
28
|
if(owners.length&&!owners.some(owner=>owner.toLowerCase()===email.toLowerCase())){
|
|
29
29
|
const address=safeOwner(owners[0]);
|
|
30
|
-
throw failure(`Local database already belongs to ${address}. Restore ${setting} to ${address}, then run rer setup --owner-email ${address}; or back up data
|
|
30
|
+
throw failure(`Local database already belongs to ${address}. Restore ${setting} to ${address}, then run rer setup --owner-email ${address}; or back up data, run rer dev --stop, then rer reset --yes followed by rer setup --owner-email ${safeOwner(email)}. Reset deletes local data. Existing data and private settings were kept.`);
|
|
31
31
|
}
|
|
32
32
|
return owners;
|
|
33
33
|
}
|
|
@@ -19,13 +19,16 @@ export function operatorInstaller({ database, features, prepare, prepareData })
|
|
|
19
19
|
values: ({ origin }) => ({ AUTH_LOCAL: 'true', AUTH_ORIGIN: origin, MAIL_PROVIDER: 'development', MAIL_FROM: 'auth@localhost.test' }),
|
|
20
20
|
},
|
|
21
21
|
build: ['pnpm', ['run', 'build:app']],
|
|
22
|
-
|
|
22
|
+
// A demo migrates its own fresh state folder, never the checkout's.
|
|
23
|
+
migrate: ({ demo }) => ['pnpm', ['exec', 'wrangler', 'd1', 'migrations', 'apply', database, '--local', '--persist-to', demo?.state ?? '.wrangler/state', '--config', 'dist/server/wrangler.json']],
|
|
23
24
|
// Setup uses preview's production runtime behind the same public proxy.
|
|
24
25
|
start: previewTarget,
|
|
25
26
|
owner: { existingOwnerViaSignIn: true, displayName: input => input.displayName || 'Owner' },
|
|
26
27
|
signIn: { emailKey: 'OWNER_EMAIL', returnTo: '/setup' },
|
|
27
28
|
});
|
|
28
|
-
|
|
29
|
+
// A demo migrates and claims the owner in its own state, so it never opens the
|
|
30
|
+
// checkout's database: even read-only, SQLite rewrites its -shm index.
|
|
31
|
+
return {...lifecycle,localPrerequisites:async({root,vars,email,demo})=>vars.AUTH_LOCAL==='true'&&vars.MAIL_PROVIDER==='development'&&Boolean(vars.SESSION_SECRET)&&(demo||await operatorPrerequisites(root,email)),install:(root,input,store,onApp,signal)=>installOperator(root,input,store,onApp,signal,lifecycle.hooks)};
|
|
29
32
|
}
|
|
30
33
|
|
|
31
34
|
// Only local runtimes receive the dev key; vite.config.ts passes it to `vite dev` alone.
|
|
@@ -33,7 +36,10 @@ export function operatorInstaller({ database, features, prepare, prepareData })
|
|
|
33
36
|
// without the key. trafficProxy also refuses /cdn-cgi/* on the public listener.
|
|
34
37
|
const devBindings = (devKey, devLogin) => devKey ? { RER_DEV_KEY: devKey, RER_DEV_IDENTITY: devLogin ? 'off' : 'on' } : {};
|
|
35
38
|
const internalPort = port => { if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('A local runtime requires the traffic proxy internal port.'); return String(port); };
|
|
36
|
-
const developmentEnvironment = () => { const { RER_MODELS_SERVICE, RER_MODELS_KEY, ...env } = process.env; return env; };
|
|
37
|
-
|
|
39
|
+
const developmentEnvironment = () => { const { RER_MODELS_SERVICE, RER_MODELS_KEY, RER_DEMO, RER_DEMO_EXPIRES, RER_STATE_DIR, ...env } = process.env; return env; };
|
|
40
|
+
// A demo's Vite persists to its own folder (the seed's vite.config reads RER_STATE_DIR)
|
|
41
|
+
// and its Worker sees RER_DEMO=1 and the demo's end (modules/demo).
|
|
42
|
+
const demoBindings = demo => demo ? { RER_DEMO: '1', RER_DEMO_EXPIRES: demo.ends, RER_STATE_DIR: demo.state } : {};
|
|
43
|
+
export const developmentTarget = ({ root, origin, runtimePort, devKey, devLogin, modelsLive, demo }) => [process.execPath, [projectViteBinary(root), 'dev', '--host', '127.0.0.1', '--port', internalPort(runtimePort), '--strictPort'], { env: { ...developmentEnvironment(), PATH: join(root, 'node_modules', '.bin') + delimiter + (process.env.PATH ?? ''), RER_DEV_ORIGIN: origin, WRANGLER_SEND_METRICS: 'false', X_LOCAL_EXPLORER: 'false', ...devBindings(devKey, devLogin), ...(modelsLive ? { RER_MODELS_SERVICE: modelsLive.baseUrl, RER_MODELS_KEY: modelsLive.apiKey } : {}), ...demoBindings(demo) } }];
|
|
38
44
|
|
|
39
45
|
export const previewTarget = ({ origin, runtimePort, devKey, scheduled }) => [process.execPath, [fileURLToPath(new URL('./production-runtime.mjs', import.meta.url))], { env: { ...process.env, RER_DEV_ORIGIN: origin, RER_RUNTIME_HOST: '127.0.0.1', RER_RUNTIME_PORT: internalPort(runtimePort), WRANGLER_SEND_METRICS: 'false', CLOUDFLARE_INCLUDE_PROCESS_ENV: 'false', X_LOCAL_EXPLORER: 'false', RER_RUNTIME_TRIGGERS: scheduled ? '1' : '0', ...(devKey ? { RER_DEV_KEY: devKey } : {}) } }];
|
package/onboarding/redaction.mjs
CHANGED
|
@@ -35,7 +35,9 @@ const DOUBLE_QUOTED = /"(?:\\[^\r\n]|[^"\\\r\n])*(?:\\(?=[\r\n]|$))?/.source;
|
|
|
35
35
|
const SINGLE_QUOTED = /'(?:\\[^\r\n]|[^'\\\r\n])*(?:\\(?=[\r\n]|$))?/.source;
|
|
36
36
|
const PRIVATE_VALUE = `(?:${DOUBLE_QUOTED}(?:"|(?=[\\r\\n]|$))|${SINGLE_QUOTED}(?:'|(?=[\\r\\n]|$))|[^\\s"'<>;&]+)`;
|
|
37
37
|
const FIELD_VALUE = '["\']?\\s*[=:]\\s*' + NOT_FLAG + PRIVATE_VALUE;
|
|
38
|
-
|
|
38
|
+
// These deploy flags take a binding name, never the secret value. Explicitly
|
|
39
|
+
// registered private bytes remain redacted by the exact-value pass.
|
|
40
|
+
const CLI_PREFIX = '(?<![A-Za-z0-9_-])--(?!(?:set|unset)-secret(?:\\s|$))(?:[A-Za-z0-9]+[_-])*';
|
|
39
41
|
const CLI_ARGUMENT = CLI_PREFIX + '(' + FIELD_NAME + ')\\s+' + NOT_FLAG + '(' + PRIVATE_VALUE + ')';
|
|
40
42
|
// A bare Bearer/Basic value must look like a credential, not the next word of
|
|
41
43
|
// prose such as "Basic checks passed"; after Authorization any value is private.
|
|
@@ -57,9 +59,12 @@ const COOKIE_HEADER = '(?<![A-Za-z0-9_-])(?:Set-)?Cookie["\']?\\s*:\\s*(?:[^\\s=
|
|
|
57
59
|
const SCHEME = '(?<![a-z0-9+.-])[a-z][a-z0-9+.-]*://';
|
|
58
60
|
const USERINFO = SCHEME + '([^\\s/?#"<>`]*)@';
|
|
59
61
|
const SLASHED_USERINFO = SCHEME + '([^\\s/?#:@"<>`\\[\\]]*:(?![0-9]*(?:[/?#\\s"<>`]|$))(?=(?<password>[^\\s"<>`@]{0,128}))\\k<password>)@';
|
|
62
|
+
// Provider token formats recognisable by their prefix alone.
|
|
63
|
+
const TOKEN_PREFIX = '(?:gh[pousr]_|github_pat_|sk-(?:proj-)?|sk_live_|sk_test_|rk_live_|rk_test_|pk_live_|whsec_|SG\\.|xox[baprs]-|AKIA|ASIA|AIza|npm_|glpat-|pypi-|hf_|re_)';
|
|
64
|
+
const KNOWN_TOKEN = '(?<![A-Za-z0-9_])' + TOKEN_PREFIX + '[A-Za-z0-9_./+=-]+';
|
|
60
65
|
const PATTERNS = new RegExp('(?:' + [
|
|
61
66
|
AUTH_HEADER, BARE_AUTH,
|
|
62
|
-
|
|
67
|
+
KNOWN_TOKEN,
|
|
63
68
|
FIELD + FIELD_VALUE, COOKIE_HEADER,
|
|
64
69
|
USERINFO, SLASHED_USERINFO,
|
|
65
70
|
].join('|') + ')', 'gi');
|
|
@@ -287,12 +292,26 @@ export function createRedaction({mask = MASK} = {}) {
|
|
|
287
292
|
return {addSecrets,redact,stream};
|
|
288
293
|
}
|
|
289
294
|
|
|
295
|
+
// A variable whose name marks a credential, or whose value is a URL carrying one.
|
|
296
|
+
export const credentialName = key => CREDENTIAL_NAME.test(key) || CAMEL_CREDENTIAL_NAME.test(key);
|
|
297
|
+
export const credentialUrl = value => CREDENTIAL_URL.test(value);
|
|
298
|
+
// A value that carries a credential whatever its variable is called: a known
|
|
299
|
+
// provider token of at least 16 characters, a JWT, or a URL with userinfo or a
|
|
300
|
+
// credential query or connection-string parameter (libsql://db?authToken=...,
|
|
301
|
+
// ...;AccountKey=...), a private key, or an Authorization header value.
|
|
302
|
+
// A token never continues a path or word (/opt/hf_models_cache is a path).
|
|
303
|
+
const TOKEN_VALUE = new RegExp('(?<![A-Za-z0-9_./-])' + TOKEN_PREFIX + '[A-Za-z0-9_./+=-]{12,}');
|
|
304
|
+
const CREDENTIAL_QUERY = /[?&;](?:auth[_-]?token|access[_-]?token|api[_-]?key|account[_-]?key|shared[_-]?access[_-]?key|token|key|secret|password|sig|signature)=[^&#\s]/i;
|
|
305
|
+
const PRIVATE_KEY_VALUE = /-----BEGIN [A-Z0-9 ]{0,64}PRIVATE KEY/;
|
|
306
|
+
const AUTHORIZATION_VALUE = new RegExp(AUTH_HEADER + '|' + BARE_AUTH, 'i');
|
|
307
|
+
export const credentialValue = value => TOKEN_VALUE.test(value) || jwtRanges(value).length > 0 || CREDENTIAL_URL.test(value) || CREDENTIAL_QUERY.test(value) || PRIVATE_KEY_VALUE.test(value) || AUTHORIZATION_VALUE.test(value);
|
|
308
|
+
|
|
290
309
|
export function credentialValues(input, declared = []) {
|
|
291
310
|
if (!input || typeof input !== 'object') return [];
|
|
292
311
|
return Object.entries(input).flatMap(([key,value]) => {
|
|
293
312
|
// An explicit model declaration remains an exact secret contract, including
|
|
294
313
|
// short values. Environment heuristics must not turn boolean flags into masks.
|
|
295
|
-
if (typeof value === 'string') return declared.includes(key) || (value.length>=8 && (
|
|
314
|
+
if (typeof value === 'string') return declared.includes(key) || (value.length>=8 && (credentialName(key) || credentialUrl(value))) ? [value] : [];
|
|
296
315
|
return value && typeof value==='object' ? credentialValues(value,declared) : [];
|
|
297
316
|
});
|
|
298
317
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@runeditrun/rer",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.5",
|
|
4
4
|
"description": "The rer command: install, set up, run and launch Run Edit Run checkouts.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -63,6 +63,7 @@
|
|
|
63
63
|
"./modules/portability/migration-history": "./modules/portability/migration-history.mjs",
|
|
64
64
|
"./modules/portability/storage": "./modules/portability/storage.mjs",
|
|
65
65
|
"./modules/erasure/fence": "./modules/erasure/fence.mjs",
|
|
66
|
+
"./modules/deploy-kit/migration-ledger": "./modules/deploy-kit/migration-ledger.mjs",
|
|
66
67
|
"./modules/deploy-kit/owner": "./modules/deploy-kit/owner.mjs",
|
|
67
68
|
"./modules/deploy-kit/owner-cli": "./modules/deploy-kit/owner-cli.mjs",
|
|
68
69
|
"./modules/deploy-kit/prepare": "./modules/deploy-kit/prepare.mjs",
|
|
@@ -84,5 +85,9 @@
|
|
|
84
85
|
},
|
|
85
86
|
"engines": {
|
|
86
87
|
"node": ">=22"
|
|
88
|
+
},
|
|
89
|
+
"repository": {
|
|
90
|
+
"type": "git",
|
|
91
|
+
"url": "git+https://github.com/runeditrun/base.git"
|
|
87
92
|
}
|
|
88
93
|
}
|
|
@@ -21,7 +21,20 @@ bounded public coauthor address rule described in the command guide; a file name
|
|
|
21
21
|
masks only the accepted address for email inspection, leaving names, other
|
|
22
22
|
addresses and every other rule intact.
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
`png_text.py` separates the fields of framed PNG `tEXt` and uncompressed `iTXt`
|
|
25
|
+
chunks before text hygiene inspection. Container length, type and CRC bytes
|
|
26
|
+
cannot extend a mailbox, and these explicit text fields do not use the binary
|
|
27
|
+
mixed-case host heuristic. Raw-byte hygiene inspection retains the original
|
|
28
|
+
binary classification outside those chunks; checksums do not suppress text
|
|
29
|
+
inspection. Gitleaks still receives the original bytes. Compressed metadata is
|
|
30
|
+
not decoded. `media.py` walks image and video containers to find their
|
|
31
|
+
pixel and sample spans; only those spans skip text hygiene, never
|
|
32
|
+
environment or Gitleaks inspection, and metadata around them is still text.
|
|
33
|
+
`c2pa.py` finds the strictly parsed X.509 certificates inside a PNG's C2PA
|
|
34
|
+
manifest store (`caBX`) and returns their Name and rfc822Name value spans,
|
|
35
|
+
which skip text hygiene the same way.
|
|
36
|
+
|
|
37
|
+
Other binary metadata decoding, Git protocol/object/ref modelling, frozen comparison,
|
|
25
38
|
resumable inspection and their tests have been removed. Tests live in `tests.py`
|
|
26
39
|
and `coverage_tests.py` and construct disposable repositories with actual Git.
|
|
27
40
|
|
|
@@ -80,3 +93,11 @@ The build that produces the artifacts (`modules/egress-service/tls/build.mjs`)
|
|
|
80
93
|
regenerates the manifest, and its `--check` mode rejects a stale one. An
|
|
81
94
|
alphabetic top-level domain label ends before a following digit, so a match
|
|
82
95
|
ends at the DER string's boundary rather than absorbing the next tag byte.
|
|
96
|
+
|
|
97
|
+
`controller_metadata.py` recognizes the standalone controller review manifest by
|
|
98
|
+
its exact four-field structure, immutable source revision, descriptor digest,
|
|
99
|
+
relative directory list and path-to-SHA-256 file map, at any JSON path. Only
|
|
100
|
+
those digest value locations qualify. Extra fields, malformed paths or digests
|
|
101
|
+
retain findings. The live-proof controller still independently checks all copied
|
|
102
|
+
file bytes against the pinned source before execution; the scanner does not claim
|
|
103
|
+
to fetch that source. Provider tokens and known environment values remain findings.
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Certificate metadata inside a PNG's C2PA manifest store (caBX chunk).
|
|
2
|
+
|
|
3
|
+
Image generators sign a C2PA manifest and embed the signing certificate chain
|
|
4
|
+
in it; a certificate names its holder, often by address. Only values that a
|
|
5
|
+
strict X.509 parse places in an issuer or subject Name, or in a subject or
|
|
6
|
+
issuer alternative name's rfc822Name, are certificate metadata. Every other
|
|
7
|
+
byte of the manifest, assertions included, remains text to inspect.
|
|
8
|
+
"""
|
|
9
|
+
from der import OID, SEQUENCE, BIT_STRING, rdn_values, tlv
|
|
10
|
+
|
|
11
|
+
INTEGER, OCTET_STRING, VERSION, EXTENSIONS, RFC822_NAME = 0x02, 0x04, 0xA0, 0xA3, 0x81
|
|
12
|
+
ALTERNATIVE_NAMES = (bytes.fromhex('551d11'), bytes.fromhex('551d12'))
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def manifest_payloads(data):
|
|
16
|
+
if not data.startswith(b'\x89PNG\r\n\x1a\n'):
|
|
17
|
+
return
|
|
18
|
+
offset = 8
|
|
19
|
+
while offset + 12 <= len(data):
|
|
20
|
+
length = int.from_bytes(data[offset:offset + 4], 'big')
|
|
21
|
+
kind, start, end = data[offset + 4:offset + 8], offset + 8, offset + 8 + length
|
|
22
|
+
if end + 4 > len(data):
|
|
23
|
+
return
|
|
24
|
+
if kind == b'caBX':
|
|
25
|
+
yield start, end
|
|
26
|
+
if kind == b'IEND':
|
|
27
|
+
return
|
|
28
|
+
offset = end + 4
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def name_values(data, name):
|
|
32
|
+
# A Name is RDN SETs filling its SEQUENCE exactly.
|
|
33
|
+
values, cursor = [], name[1]
|
|
34
|
+
while cursor < name[2]:
|
|
35
|
+
rdn = rdn_values(data, cursor, name[2])
|
|
36
|
+
if not rdn:
|
|
37
|
+
return None
|
|
38
|
+
cursor, found = rdn
|
|
39
|
+
values += found
|
|
40
|
+
return values
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def alternative_names(data, extensions):
|
|
44
|
+
spans, outer = [], tlv(data, extensions[1], extensions[2])
|
|
45
|
+
if not outer or outer[0] != SEQUENCE or outer[2] != extensions[2]:
|
|
46
|
+
return None
|
|
47
|
+
cursor = outer[1]
|
|
48
|
+
while cursor < outer[2]:
|
|
49
|
+
extension = tlv(data, cursor, outer[2])
|
|
50
|
+
oid = extension and extension[0] == SEQUENCE and tlv(data, extension[1], extension[2])
|
|
51
|
+
if not oid or oid[0] != OID:
|
|
52
|
+
return None
|
|
53
|
+
value = tlv(data, oid[2], extension[2])
|
|
54
|
+
if value and value[0] == 0x01:
|
|
55
|
+
value = tlv(data, value[2], extension[2])
|
|
56
|
+
if not value or value[0] != OCTET_STRING or value[2] != extension[2]:
|
|
57
|
+
return None
|
|
58
|
+
if data[oid[1]:oid[2]] in ALTERNATIVE_NAMES:
|
|
59
|
+
general = tlv(data, value[1], value[2])
|
|
60
|
+
if not general or general[0] != SEQUENCE:
|
|
61
|
+
return None
|
|
62
|
+
entry = general[1]
|
|
63
|
+
while entry < general[2]:
|
|
64
|
+
item = tlv(data, entry, general[2])
|
|
65
|
+
if not item:
|
|
66
|
+
return None
|
|
67
|
+
if item[0] == RFC822_NAME:
|
|
68
|
+
spans.append((item[1], item[2]))
|
|
69
|
+
entry = item[2]
|
|
70
|
+
cursor = extension[2]
|
|
71
|
+
return spans
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def certificate(data, offset, end):
|
|
75
|
+
"""Name and alternative-name value spans of one X.509 Certificate, or None."""
|
|
76
|
+
outer = tlv(data, offset, end)
|
|
77
|
+
if not outer or outer[0] != SEQUENCE:
|
|
78
|
+
return None
|
|
79
|
+
tbs = tlv(data, outer[1], outer[2])
|
|
80
|
+
algorithm = tbs and tbs[0] == SEQUENCE and tlv(data, tbs[2], outer[2])
|
|
81
|
+
signature = algorithm and algorithm[0] == SEQUENCE and tlv(data, algorithm[2], outer[2])
|
|
82
|
+
if not signature or signature[0] != BIT_STRING or signature[2] != outer[2]:
|
|
83
|
+
return None
|
|
84
|
+
fields, cursor = [], tbs[1]
|
|
85
|
+
while cursor < tbs[2]:
|
|
86
|
+
field = tlv(data, cursor, tbs[2])
|
|
87
|
+
if not field:
|
|
88
|
+
return None
|
|
89
|
+
fields.append(field)
|
|
90
|
+
cursor = field[2]
|
|
91
|
+
if fields and fields[0][0] == VERSION:
|
|
92
|
+
fields = fields[1:]
|
|
93
|
+
# serialNumber, signature, issuer, validity, subject, subjectPublicKeyInfo.
|
|
94
|
+
if len(fields) < 6 or [field[0] for field in fields[:6]] != [INTEGER] + [SEQUENCE] * 5:
|
|
95
|
+
return None
|
|
96
|
+
issuer, subject = name_values(data, fields[2]), name_values(data, fields[4])
|
|
97
|
+
if issuer is None or subject is None:
|
|
98
|
+
return None
|
|
99
|
+
spans = issuer + subject
|
|
100
|
+
for field in fields[6:]:
|
|
101
|
+
if field[0] == EXTENSIONS:
|
|
102
|
+
names = alternative_names(data, field)
|
|
103
|
+
if names is None:
|
|
104
|
+
return None
|
|
105
|
+
spans += names
|
|
106
|
+
return spans
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def certificate_spans(data):
|
|
110
|
+
"""Value spans of the certificates a PNG's C2PA manifest store carries."""
|
|
111
|
+
spans = []
|
|
112
|
+
for start, end in manifest_payloads(data):
|
|
113
|
+
offset = data.find(SEQUENCE.to_bytes(1, 'big'), start, end)
|
|
114
|
+
while 0 <= offset < end:
|
|
115
|
+
found = certificate(data, offset, end)
|
|
116
|
+
if found is None:
|
|
117
|
+
offset = data.find(SEQUENCE.to_bytes(1, 'big'), offset + 1, end)
|
|
118
|
+
continue
|
|
119
|
+
spans += found
|
|
120
|
+
offset = data.find(SEQUENCE.to_bytes(1, 'big'), tlv(data, offset, end)[2], end)
|
|
121
|
+
return spans
|
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
from json_identity import Document
|
|
3
3
|
from provenance_pins import module_pins
|
|
4
4
|
from copy_identities import copy_pointers
|
|
5
|
+
from controller_metadata import controller_pointers
|
|
5
6
|
from source_metadata import ARCHIVE, source_pointers
|
|
6
7
|
|
|
7
8
|
|
|
8
9
|
def candidate(path):
|
|
9
|
-
return path in ('edition.json', 'sources.lock.json', 'base-copies.json', 'base/PROVENANCE.json') or bool(ARCHIVE.fullmatch(path))
|
|
10
|
+
return path in ('edition.json', 'sources.lock.json', 'base-copies.json', 'base/PROVENANCE.json') or bool(ARCHIVE.fullmatch(path)) or path.endswith('.json')
|
|
10
11
|
|
|
11
12
|
|
|
12
13
|
class ContentIdentities:
|
|
@@ -22,6 +23,7 @@ class ContentIdentities:
|
|
|
22
23
|
self.documents[path] = document
|
|
23
24
|
pointers = set(module_pins(path, document.value))
|
|
24
25
|
pointers.update(source_pointers(path, document.value))
|
|
26
|
+
pointers.update(controller_pointers(document.value))
|
|
25
27
|
if path in ('base-copies.json', 'base/PROVENANCE.json') and read_regular is not None:
|
|
26
28
|
pointers.update(copy_pointers(document.value, read_regular))
|
|
27
29
|
self.allowed[path] = pointers
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Exact digest locations in immutable standalone-controller review manifests.
|
|
2
|
+
|
|
3
|
+
The live-proof controller verifies these digests against its pinned Git source
|
|
4
|
+
before executing any copied file. This identifies the documented format; it does
|
|
5
|
+
not claim to perform that remote integrity check during publication inspection.
|
|
6
|
+
"""
|
|
7
|
+
from copy_identities import relative_path
|
|
8
|
+
from json_identity import HEX, GIT_REVISION
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def controller_pointers(value):
|
|
12
|
+
if not isinstance(value, dict) or set(value) != {
|
|
13
|
+
'sourceSha', 'descriptorSha256', 'controllerDirectories', 'controllerFiles'}:
|
|
14
|
+
return
|
|
15
|
+
if (not isinstance(value['sourceSha'], str) or not GIT_REVISION.fullmatch(value['sourceSha'])
|
|
16
|
+
or not isinstance(value['descriptorSha256'], str)
|
|
17
|
+
or not HEX.fullmatch(value['descriptorSha256'])):
|
|
18
|
+
return
|
|
19
|
+
directories, files = value['controllerDirectories'], value['controllerFiles']
|
|
20
|
+
if (not isinstance(directories, list) or not all(relative_path(p) for p in directories)
|
|
21
|
+
or len(set(directories)) != len(directories)
|
|
22
|
+
or not isinstance(files, dict) or not files
|
|
23
|
+
or not all(relative_path(p) and isinstance(digest, str) and HEX.fullmatch(digest)
|
|
24
|
+
for p, digest in files.items())
|
|
25
|
+
or any(directory == path or directory.startswith(path + '/')
|
|
26
|
+
for directory in directories for path in files)):
|
|
27
|
+
return
|
|
28
|
+
yield ('sourceSha',)
|
|
29
|
+
yield ('descriptorSha256',)
|
|
30
|
+
for path in files:
|
|
31
|
+
yield ('controllerFiles', path)
|