@runeditrun/rer 0.2.1 → 0.2.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.
Files changed (121) hide show
  1. package/README.md +21 -4
  2. package/cli/README.md +21 -0
  3. package/cli/auth-copy.mjs +25 -18
  4. package/cli/bounded.mjs +13 -4
  5. package/cli/container.mjs +11 -10
  6. package/cli/contract.mjs +2 -2
  7. package/cli/demo-host.mjs +47 -0
  8. package/cli/demo.mjs +47 -0
  9. package/cli/dependencies.mjs +26 -4
  10. package/cli/deploy-command.mjs +94 -0
  11. package/cli/deploy-inputs.mjs +55 -0
  12. package/cli/deploy-observation.mjs +28 -0
  13. package/cli/deploy-owner.mjs +76 -0
  14. package/cli/deploy-readiness.mjs +16 -0
  15. package/cli/deploy.mjs +99 -17
  16. package/cli/detached-server.mjs +1 -1
  17. package/cli/door.mjs +1 -1
  18. package/cli/export.mjs +1 -1
  19. package/cli/hosted-output.mjs +2 -1
  20. package/cli/inspect.mjs +24 -32
  21. package/cli/lifecycle.mjs +31 -8
  22. package/cli/local.mjs +7 -0
  23. package/cli/main.mjs +1 -1
  24. package/cli/managed-server.mjs +27 -18
  25. package/cli/parse.mjs +9 -2
  26. package/cli/pnpm-probe.mjs +17 -0
  27. package/cli/pnpm-runtime.mjs +22 -22
  28. package/cli/portability-policy.mjs +55 -0
  29. package/cli/portability-worker.mjs +8 -4
  30. package/cli/portability.mjs +24 -5
  31. package/cli/project-pnpm.mjs +67 -0
  32. package/cli/release.mjs +35 -0
  33. package/cli/scheduled.mjs +2 -2
  34. package/cli/server-registry.mjs +2 -2
  35. package/cli/setup-input.mjs +1 -1
  36. package/cli/site-check.mjs +15 -6
  37. package/cli/site-data.mjs +6 -5
  38. package/cli/site-references.mjs +153 -3
  39. package/global/CHANGELOG.md +60 -0
  40. package/global/README.md +21 -4
  41. package/global/cli/install.mjs +26 -9
  42. package/global/cli/path-link.mjs +30 -0
  43. package/global/cli/status.mjs +1 -1
  44. package/global/cli/uninstall.mjs +5 -1
  45. package/global/launch/capacity.mjs +1 -1
  46. package/global/launch/facts.mjs +12 -6
  47. package/global/launch/install-pinned.mjs +30 -2
  48. package/global/launch/prepare.mjs +20 -8
  49. package/global/launch/state.mjs +54 -1
  50. package/global/main.mjs +43 -20
  51. package/global/registry.mjs +2 -2
  52. package/global/update/command.mjs +2 -1
  53. package/modules/container/build.mjs +4 -2
  54. package/modules/container/deploy-input.mjs +5 -9
  55. package/modules/container/host.mjs +1 -1
  56. package/modules/container/ingress.mjs +4 -1
  57. package/modules/container/migrations.mjs +2 -1
  58. package/modules/container/worker-engine.mjs +3 -1
  59. package/modules/demo-host/README.md +30 -0
  60. package/modules/demo-host/capacity.mjs +19 -0
  61. package/modules/demo-host/claim.mjs +21 -0
  62. package/modules/demo-host/container.mjs +74 -0
  63. package/modules/demo-host/pages.mjs +56 -0
  64. package/modules/demo-host/plan.mjs +52 -0
  65. package/modules/demo-host/worker.mjs +72 -0
  66. package/modules/dependencies/auth.mjs +2 -1
  67. package/modules/deploy-kit/README.md +52 -7
  68. package/modules/deploy-kit/d1-import.mjs +35 -0
  69. package/modules/deploy-kit/deploy.mjs +4 -3
  70. package/modules/deploy-kit/diagnostics.mjs +11 -1
  71. package/modules/deploy-kit/env-file.mjs +27 -0
  72. package/modules/deploy-kit/health-request.mjs +13 -3
  73. package/modules/deploy-kit/health.mjs +35 -15
  74. package/modules/deploy-kit/migration-ledger.mjs +85 -0
  75. package/modules/deploy-kit/migrations.mjs +48 -16
  76. package/modules/deploy-kit/owner-cli.mjs +42 -8
  77. package/modules/deploy-kit/owner-remove.mjs +108 -0
  78. package/modules/deploy-kit/owner-secret.mjs +94 -0
  79. package/modules/deploy-kit/owner.mjs +124 -19
  80. package/modules/deploy-kit/prepare.mjs +3 -1
  81. package/modules/deploy-kit/provider.mjs +18 -2
  82. package/modules/erasure/README.md +2 -3
  83. package/modules/job-queue/config.mjs +4 -2
  84. package/modules/operator-auth/accounts/settings-page.mjs +8 -3
  85. package/modules/operator-auth/csp.mjs +26 -0
  86. package/modules/operator-auth/dev/badge.mjs +1 -22
  87. package/modules/operator-auth/identity/oidc/README.md +18 -0
  88. package/modules/operator-auth/identity/oidc/config.mjs +4 -2
  89. package/modules/operator-auth/identity/oidc/index.d.mts +1 -1
  90. package/modules/operator-auth/identity/oidc/index.mjs +28 -9
  91. package/modules/operator-auth/identity/oidc/network.mjs +9 -4
  92. package/modules/operator-auth/identity/oidc/store.mjs +19 -3
  93. package/modules/operator-auth/index.d.mts +2 -0
  94. package/modules/operator-auth/page.mjs +2 -2
  95. package/modules/operator-auth/routes.mjs +2 -2
  96. package/modules/portability/README.md +37 -0
  97. package/modules/portability/bridge.mjs +14 -4
  98. package/modules/portability/catalogue.mjs +9 -2
  99. package/modules/portability/import.mjs +4 -2
  100. package/modules/portability/index.d.mts +4 -0
  101. package/modules/portability/index.mjs +1 -0
  102. package/modules/portability/lines.mjs +3 -1
  103. package/modules/release/produce.mjs +1 -1
  104. package/modules/release/source.mjs +5 -2
  105. package/onboarding/adapter-labels.mjs +1 -0
  106. package/onboarding/adapter.mjs +9 -4
  107. package/onboarding/commands.mjs +3 -1
  108. package/onboarding/dependency-environment.mjs +33 -0
  109. package/onboarding/local/owner.mjs +1 -1
  110. package/onboarding/operator-installer.mjs +10 -4
  111. package/onboarding/redaction.mjs +22 -3
  112. package/package.json +6 -1
  113. package/scripts/publish-check/README.md +22 -1
  114. package/scripts/publish-check/c2pa.py +121 -0
  115. package/scripts/publish-check/content_identities.py +3 -1
  116. package/scripts/publish-check/controller_metadata.py +31 -0
  117. package/scripts/publish-check/history_scan.py +13 -3
  118. package/scripts/publish-check/media.py +283 -0
  119. package/scripts/publish-check/png_text.py +34 -0
  120. package/scripts/publish-check/public_hygiene.py +39 -4
  121. package/scripts/publish-check/publication-config.json +7 -1
@@ -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
- const defaults=['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'];
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:[...defaults,...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');
@@ -8,11 +8,13 @@ import { fence, mergeJournal, tombstone } from './journal.mjs';
8
8
  import { writeObject } from './payloads.mjs';
9
9
  import { createCopy,sidecar,readCopy,restoreCopy } from './copies.mjs';
10
10
  import { excludedDependentGuards } from './restore-safety.mjs';
11
- import { additiveBridge, sameSchemaMigrations } from './bridge.mjs';
11
+ import { additiveBridge, sameSchemaMigrations, appliedMigrations } from './bridge.mjs';
12
12
  import { legacyEmptyGuards, legacyAuditStatements, requireLegacyEmpty } from './legacy-safety.mjs';
13
13
  export async function createImport(context,manifest,actor='direct',legacyRecovery) {
14
- const {db,config,buckets}=context;await checkManifest(manifest);await state(db);
14
+ const {db,config,buckets}=context;await checkManifest(manifest);
15
15
  insist(manifest.product===config.product,'wrong_product');
16
+ // Explicit schema upgrades can reconcile rows, but cannot attest an interrupted deployment.
17
+ await appliedMigrations(db);await state(db);
16
18
  const cat=await catalogue(db,config),upgrade=config.acceptedSchemas[manifest.schema];
17
19
  const same=manifest.schema===cat.identity;
18
20
  const bridge=same || upgrade?undefined:await additiveBridge(db,manifest,cat,context.migrations);
@@ -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
- const value = decoder.decode(buffer.subarray(0, bytes)); bytes = 0;
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
- export async function command(command, args, cwd) {
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={...process.env,FORCE_COLOR:'0'};delete env.NODE_TEST_CONTEXT;
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()};
@@ -9,5 +9,6 @@ export const labels = Object.freeze({
9
9
  signIn: 'Opening sign-in through the development outbox',
10
10
  hosting: 'Deploying to your Cloudflare account',
11
11
  complete: 'Your application is running locally',
12
+ built: 'Your application is built',
12
13
  });
13
14
 
@@ -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
- if (['installer', 'setup'].includes(verb) && hooks.migrate) await phase(context, hooks, 'migrate', hooks.migrate);
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
- await context.run(labels[name], command, args, { step: steps[name], ...options });
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
 
@@ -161,6 +161,8 @@ export async function createCommands(directory,state,{terminal=text=>writeSync(1
161
161
  const readinessSignal=signal&&options.signal?AbortSignal.any([signal,options.signal]):options.signal||signal;
162
162
  return waitForReadiness(label,child,health,{...options,signal:readinessSignal},capture,report,(step,detail)=>failure(step,detail,capture));
163
163
  },
164
- async closeCommands(){await stopCommands();if(!closed){closed=true;await log.close();await policy.close();}}
164
+ // The log and the queue policy socket close even when a child's stop fails: an
165
+ // open policy socket keeps this process alive after its command has finished.
166
+ async closeCommands(){try{await stopCommands();}finally{if(!closed){closed=true;try{await log.close();}finally{await policy.close();}}}}
165
167
  };
166
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 and run rer reset --yes. Existing data and private settings were kept.`);
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
- migrate: ['pnpm', ['exec', 'wrangler', 'd1', 'migrations', 'apply', database, '--local', '--persist-to', '.wrangler/state', '--config', 'dist/server/wrangler.json']],
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
- return {...lifecycle,localPrerequisites:async({root,vars,email})=>vars.AUTH_LOCAL==='true'&&vars.MAIL_PROVIDER==='development'&&Boolean(vars.SESSION_SECRET)&&await operatorPrerequisites(root,email),install:(root,input,store,onApp,signal)=>installOperator(root,input,store,onApp,signal,lifecycle.hooks)};
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
- export const developmentTarget = ({ root, origin, runtimePort, devKey, devLogin, modelsLive }) => [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 } : {}) } }];
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 } : {}) } }];
@@ -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
- const CLI_PREFIX = '(?<![A-Za-z0-9_-])--(?:[A-Za-z0-9]+[_-])*';
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
- '(?<![A-Za-z0-9_])(?: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_)[A-Za-z0-9_./+=-]+',
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 && (CREDENTIAL_NAME.test(key) || CAMEL_CREDENTIAL_NAME.test(key) || CREDENTIAL_URL.test(value))) ? [value] : [];
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.1",
3
+ "version": "0.2.4",
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
- Binary metadata decoding, Git protocol/object/ref modelling, frozen comparison,
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)
@@ -38,6 +38,9 @@ def scan(repo, ref='HEAD', public_base=None, env_file=None, project=None, reposi
38
38
  recorded = registry() + exceptions(history.selected(EXCEPTIONS))
39
39
  resolver = GitHubResolver()
40
40
  public_blobs = {e['oid'] for c in history.public_commits for e in history.entries(c)}
41
+ # A file byte-identical to the same path on the public base is already public.
42
+ public_paths = {e['path']: e['oid'] for e in history.entries(history.public_commit)
43
+ if e['kind'] == 'blob'} if history.public_commit else {}
41
44
  report['history']['preExistingCommits'] = sorted(history.public_commits)
42
45
  report['history']['newlyPublicCommits'] = [c for c in history.commits if c not in history.public_commits]
43
46
  seen_trees, seen_blobs, inspected = set(), set(), {}
@@ -73,7 +76,7 @@ def scan(repo, ref='HEAD', public_base=None, env_file=None, project=None, reposi
73
76
  if key not in inspected:
74
77
  inspected[key] = text_rules(history.blob(oid), path, config_tree, resolver, hashes)
75
78
  rules, provenance = inspected[key]
76
- entry_scope = 'pre-existing' if scope != 'release-tree' and oid in public_blobs else scope
79
+ entry_scope = 'pre-existing' if (scope != 'release-tree' and oid in public_blobs) or public_paths.get(path) == oid else scope
77
80
  for rule in rules:
78
81
  add(rule, path, commit, oid, entry_scope)
79
82
  if provenance:
@@ -92,8 +95,10 @@ def scan(repo, ref='HEAD', public_base=None, env_file=None, project=None, reposi
92
95
  entry = entries_by_commit[history.commit].get(path)
93
96
  oid = entry['oid'] if entry and entry['kind'] == 'blob' and history.blob(entry['oid']) == data else None
94
97
  rules, _ = text_rules(data, path, selected_context, resolver, hashes)
98
+ public_oid = public_paths.get(path)
99
+ scope = 'pre-existing' if public_oid and (public_oid == oid or history.blob(public_oid) == data) else 'working-tree'
95
100
  for rule in rules:
96
- add(rule, path, history.commit, oid, 'working-tree')
101
+ add(rule, path, history.commit, oid, scope)
97
102
  if path == 'LICENSE':
98
103
  for rule in license_rules(data, recorded):
99
104
  add(rule, path, history.commit, oid, 'working-tree')
@@ -109,7 +114,12 @@ def scan(repo, ref='HEAD', public_base=None, env_file=None, project=None, reposi
109
114
  path = Path(path).resolve().relative_to(Path(history.repo).resolve()).as_posix()
110
115
  entry = entries_by_commit.get(commit, {}).get(path)
111
116
  oid = entry['oid'] if entry and entry['kind'] == 'blob' else None
112
- scope = 'working-tree' if mode == 'dir' else 'release-tree' if commit == history.commit else 'pre-existing' if oid in public_blobs else 'newly-public'
117
+ if mode == 'dir':
118
+ public_oid = public_paths.get(path)
119
+ held = working_regular(Path(history.repo), path) if public_oid else None
120
+ scope = 'pre-existing' if held and history.blob(public_oid) == held[0] else 'working-tree'
121
+ else:
122
+ scope = 'pre-existing' if (oid and public_paths.get(path) == oid) or (commit != history.commit and oid in public_blobs) else 'release-tree' if commit == history.commit else 'newly-public'
113
123
  if hit['RuleID'] in ('generic-api-key', 'hex-api-key'):
114
124
  identity_scope = 'working-tree' if mode == 'dir' else commit
115
125
  scoped_entries = entries_by_commit.get(commit, {})