@runeditrun/rer 0.2.0 → 0.2.1

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 (70) hide show
  1. package/README.md +3 -2
  2. package/cli/README.md +4 -2
  3. package/cli/contract.mjs +2 -2
  4. package/cli/deploy.mjs +17 -3
  5. package/cli/detached-server.mjs +51 -0
  6. package/cli/hosted-output.mjs +2 -2
  7. package/cli/hydration/check.mjs +2 -2
  8. package/cli/inspect.mjs +1 -1
  9. package/cli/main.mjs +1 -0
  10. package/cli/managed-server.mjs +12 -5
  11. package/cli/parse.mjs +3 -2
  12. package/cli/release.mjs +1 -0
  13. package/cli/site-bindings.mjs +291 -0
  14. package/cli/site-browser-objects.mjs +36 -0
  15. package/cli/site-check.mjs +46 -31
  16. package/cli/site-client.mjs +50 -0
  17. package/cli/site-data.mjs +54 -0
  18. package/cli/site-entities.json +1 -0
  19. package/cli/site-input-assets.mjs +58 -0
  20. package/cli/site-inputs.mjs +79 -0
  21. package/cli/site-markup.mjs +102 -0
  22. package/cli/site-private.mjs +36 -0
  23. package/cli/site-references.mjs +93 -0
  24. package/cli/site-render.mjs +7 -5
  25. package/cli/site-syntax.mjs +21 -0
  26. package/cli/site.mjs +32 -11
  27. package/global/CHANGELOG.md +25 -0
  28. package/global/README.md +3 -2
  29. package/global/main.mjs +2 -0
  30. package/global/output.mjs +12 -0
  31. package/global/rer.mjs +3 -0
  32. package/modules/deploy-kit/deploy.mjs +5 -2
  33. package/modules/deploy-kit/diagnostics.mjs +55 -0
  34. package/modules/deploy-kit/owner-cli.mjs +10 -4
  35. package/modules/deploy-kit/owner.mjs +3 -0
  36. package/modules/deploy-kit/provider.mjs +45 -6
  37. package/modules/operator-auth/accounts/native.mjs +1 -1
  38. package/modules/operator-auth/dev/badge.mjs +4 -1
  39. package/modules/operator-auth/index.d.mts +4 -9
  40. package/modules/operator-auth/page.mjs +1 -1
  41. package/modules/operator-auth/service.mjs +1 -0
  42. package/modules/operator-auth/signin/index.d.mts +11 -0
  43. package/modules/operator-auth/signin/index.mjs +10 -7
  44. package/modules/release/README.md +63 -0
  45. package/modules/release/keygen.mjs +18 -0
  46. package/modules/release/produce.mjs +9 -0
  47. package/modules/release/sign.mjs +44 -0
  48. package/modules/release/signature.mjs +336 -0
  49. package/modules/release/signing-cli.mjs +56 -0
  50. package/modules/release/signing-io.mjs +61 -0
  51. package/modules/release/signing-key.mjs +22 -0
  52. package/modules/release/statement.mjs +80 -0
  53. package/modules/release/vectors/generate.mjs +381 -0
  54. package/modules/release/vectors/release-signature-vectors.json +2305 -0
  55. package/modules/release/verify.mjs +27 -0
  56. package/onboarding/production-assets.mjs +23 -0
  57. package/onboarding/production-runtime.mjs +8 -4
  58. package/package.json +2 -1
  59. package/scripts/publish-check/README.md +27 -17
  60. package/scripts/publish-check/content_identities.py +14 -233
  61. package/scripts/publish-check/git_history.py +2 -2
  62. package/scripts/publish-check/gitleaks.toml +1 -1
  63. package/scripts/publish-check/history_scan.py +6 -4
  64. package/scripts/publish-check/hygiene_config.py +3 -1
  65. package/scripts/publish-check/public_hygiene.py +29 -5
  66. package/scripts/publish-check/publication-config.json +3 -1
  67. package/scripts/publish-check/source_metadata.py +62 -0
  68. package/scripts/publish-check/source_shapes.py +91 -0
  69. package/scripts/publish-check/tree_scan.py +2 -2
  70. package/scripts/publish-check/source_hashes.mjs +0 -6
@@ -0,0 +1,27 @@
1
+ import { resolve } from 'node:path';
2
+ import { lstatSync } from 'node:fs';
3
+ import { loadAnchors, verifyRelease } from './signature.mjs';
4
+ import { readArtifact } from './statement.mjs';
5
+ import { SIGNATURE_FILE, readBounded, safePath, refuse } from './signing-io.mjs';
6
+
7
+ export async function verifyArtifact(artifact, anchors, environment, bytes, highest) {
8
+ const result = await verifyRelease(bytes, anchors, { environment, expect: artifact.subject,
9
+ files: artifact.files, highest, unsigned: { project: artifact.subject.project, version: artifact.subject.version } });
10
+ if (result.verdict !== 'VERIFIED') refuse(result.code ?? 'SIGNATURE_MISSING');
11
+ return result;
12
+ }
13
+
14
+ export async function receipt(options) {
15
+ const environment = options.environment ?? 'production';
16
+ const anchors = await loadAnchors(readBounded(options.anchors, 4 * 1024 * 1024), environment);
17
+ const signaturePath = resolve(safePath(options.artifact), SIGNATURE_FILE);
18
+ let bytes = null;
19
+ try { lstatSync(signaturePath); bytes = readBounded(signaturePath, 65536); }
20
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
21
+ // Authenticate before interpreting artifact bytes. Base never accepts a
22
+ // legacy unsigned floor; the shared verifier refuses null without one.
23
+ await verifyRelease(bytes, anchors, { environment });
24
+ const artifact = await readArtifact(options.artifact);
25
+ const highest = options.after ? { version: options.after.split(':')[0], statementSha256: options.after.split(':')[1] } : undefined;
26
+ await verifyArtifact(artifact, anchors, environment, bytes, highest);
27
+ }
@@ -0,0 +1,23 @@
1
+ import { watch } from 'node:fs';
2
+
3
+ // Miniflare computes its asset manifest (and ETags) at setOptions, not per
4
+ // request. Refresh it when built assets change; keep the same durable state.
5
+ // Workers/modules remain fixed production output: no source build runs here.
6
+ export function watchProductionAssets(runtime, options, convert, onError) {
7
+ const directories = [...new Set((options.workers ?? [options]).map(worker => worker.assets?.directory).filter(Boolean))];
8
+ const watchers = [];
9
+ let timer, pending = Promise.resolve(), closed = false;
10
+ const refresh = () => {
11
+ clearTimeout(timer);
12
+ timer = setTimeout(() => {
13
+ pending = pending.then(async () => { if (!closed) await runtime.setOptions(convert(options)); }).catch(error => { if (!closed) onError(error); });
14
+ }, 50);
15
+ };
16
+ try {
17
+ for (const directory of directories) {
18
+ const watcher = watch(directory,{recursive:true},refresh);
19
+ watcher.on('error',onError); watchers.push(watcher);
20
+ }
21
+ } catch (error) { for (const watcher of watchers) watcher.close(); throw error; }
22
+ return async () => {closed=true;clearTimeout(timer);for(const watcher of watchers)watcher.close();await pending;};
23
+ }
@@ -2,13 +2,14 @@ import {spawn} from 'node:child_process';
2
2
  import {fileURLToPath} from 'node:url';
3
3
  import {resolve} from 'node:path';
4
4
  import {projectMiniflare} from './production-options.mjs';
5
+ import {watchProductionAssets} from './production-assets.mjs';
5
6
 
6
7
  // Preview's resident process. Wrangler resolves the Worker's options in a
7
8
  // short-lived child that exits before serving starts; this process then holds
8
9
  // only the pinned Miniflare, which owns one workerd and no bundler or
9
- // file/config watcher.
10
- let planner,stopping,runtime;
11
- const stop=()=>stopping??=(async()=>{planner?.kill('SIGTERM');await runtime?.dispose();})();
10
+ // source/config watcher. Built-asset changes refresh its manifest.
11
+ let planner,stopping,runtime,closeAssets;
12
+ const stop=()=>stopping??=(async()=>{planner?.kill('SIGTERM');await closeAssets?.();await runtime?.dispose();})();
12
13
  process.on('SIGINT',()=>void stop());process.on('SIGTERM',()=>void stop());
13
14
  async function plan(){
14
15
  let text='';
@@ -25,6 +26,9 @@ try{
25
26
  const {Miniflare,convertV4MiniflareOptions}=await projectMiniflare(process.cwd());
26
27
  runtime=new Miniflare({...convertV4MiniflareOptions(options),resourcePersistencePath:resolve(process.cwd(),'.wrangler/state/v3')});
27
28
  if(stopping)await runtime.dispose();
28
- else await runtime.ready;
29
+ else {
30
+ await runtime.ready;
31
+ if (!stopping) closeAssets=watchProductionAssets(runtime,options,value=>({...convertV4MiniflareOptions(value),resourcePersistencePath:resolve(process.cwd(),'.wrangler/state/v3')}),error=>{process.stderr.write(`Preview asset refresh failed: ${error.message}\n`);process.exitCode=1;void stop();});
32
+ }
29
33
  }
30
34
  }catch(error){await stop();throw error;}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runeditrun/rer",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "The rer command: install, set up, run and launch Run Edit Run checkouts.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -70,6 +70,7 @@
70
70
  "./modules/release/export": "./modules/release/export.mjs",
71
71
  "./modules/release/publication-scan": "./modules/release/publication-scan.mjs",
72
72
  "./modules/release/source": "./modules/release/source.mjs",
73
+ "./modules/release/signature": "./modules/release/signature.mjs",
73
74
  "./modules/operator-auth": "./modules/operator-auth/index.mjs",
74
75
  "./modules/operator-auth/accounts/native": "./modules/operator-auth/accounts/native.mjs",
75
76
  "./modules/operator-auth/dev": "./modules/operator-auth/dev/index.mjs",
@@ -8,32 +8,42 @@ blob reads to system Git (`rev-parse`, `rev-list`, `ls-tree`, `cat-file --batch`
8
8
  its report private until translating it into the value-free v3 envelope.
9
9
  Hexadecimal credential assignments also use a Gitleaks rule without generic
10
10
  entropy or English stopword filters. Both assignment rules go through the same
11
- location-bound Source/module identity proof; provider and environment findings
11
+ location-bound Source/module identity classification; provider and environment findings
12
12
  remain independent.
13
13
  `tree_scan.py` applies the kept text and licence policy. `history_scan.py`
14
14
  assigns release, working-tree and historical scopes. The Node entry point bounds
15
15
  the whole operation and stops its private process group on timeout/cancellation.
16
16
 
17
+ Git message records carry their kind explicitly. Only commit records enable the
18
+ bounded public coauthor address rule described in the command guide; a file named
19
+ `@commit-message` has no special status. `public_hygiene.py` matches complete raw
20
+ `Co-Authored-By` lines in the final trailer paragraph case-insensitively and
21
+ masks only the accepted address for email inspection, leaving names, other
22
+ addresses and every other rule intact.
23
+
17
24
  Binary metadata decoding, Git protocol/object/ref modelling, frozen comparison,
18
25
  resumable inspection and their tests have been removed. Tests live in `tests.py`
19
26
  and `coverage_tests.py` and construct disposable repositories with actual Git.
20
27
 
21
- `content_identities.py` classifies only exact generic-api-key occurrences at
22
- proved Source key locations. It requires same-tree edition/lock selections,
23
- manifest and archive hashes, valid retained file records, and exact native match
24
- bytes. Retained archive bytes must hash to the maintained Source serializer's
25
- `hash(json(saved))` as well as the selected pin. `source_hashes.mjs` reuses that
26
- serializer. Duplicate JSON fields, non-JSON constants (including ignored fields),
27
- incorrect types, missing preimages, mismatched pins and unrecognized locations keep
28
- the native finding. Each selection's complete dependency closure must have valid
29
- archive and pin proofs before any location in that graph is allowed; unrelated
30
- coherent selections remain eligible. Normal origin repositories obey the maintained
31
- Source schema, including rejection of strings starting with `-`.
32
- Original/private keys and arbitrary copies of a known digest
33
- are not proofs. Provider and environment findings are unaffected. Native git/dir
34
- matches are held only in the private temporary report and memory for this proof;
35
- matched values are never added to the public report. This classification does not
36
- replace or relax the edition-authoring check.
28
+ `content_identities.py` binds generic-api-key and hex-api-key occurrences to
29
+ exact JSON value locations. `source_metadata.py` enumerates digest fields in the
30
+ formats emitted by `edition-authoring/cli.mjs` and `sources.mjs`: lock manifest,
31
+ toolchain, parent/baseline pins and file SHA-256 values; retained archive keys,
32
+ content digests and dependency pins; and the source origins' revision or
33
+ projection identities. These are format classifications independent of selected
34
+ graphs, canonical serialization, archive preimages and authoring freshness.
35
+ `source_shapes.py` first checks the documented local record types, immutable
36
+ origin identity, source selections and file/pin records. A malformed archive or
37
+ lock grants no identity roles; unknown extra fields never gain a role.
38
+ `provenance_pins.py` keeps the existing module revision classification.
39
+
40
+ `json_identity.py` rejects duplicate members and non-JSON constants and binds
41
+ native findings to exact string byte spans. Only documented structural fields
42
+ with the declared digest length qualify. An extra credential field, arbitrary
43
+ nested field or copy of the digest on the same minified line remains a finding.
44
+ Provider and known environment findings are independent. Native git/dir matches
45
+ stay in the private temporary report and memory; values are never added to the
46
+ public report. Authoring's integrity checks remain unchanged.
37
47
 
38
48
  `copy_identities.py` proves the maintained `base-copies.json` and portable
39
49
  `base/PROVENANCE.json` copy-inventory SHA-256 values
@@ -1,249 +1,30 @@
1
- """Classify module Git pins and prove Source keys at specific JSON locations.
2
-
3
- This is classification, not Source authoring validation. A stale lock still
4
- fails the authoring check. Missing original preimages remain ordinary findings.
5
- """
6
- import base64
7
- import hashlib
8
- import re
9
- import json
10
- from pathlib import Path
11
- import subprocess
12
- from json_identity import Document, HEX
1
+ """Classify documented JSON identities at exact native-match locations."""
2
+ from json_identity import Document
13
3
  from provenance_pins import module_pins
14
4
  from copy_identities import copy_pointers
15
-
16
- ARCHIVE = re.compile(r'\.edition/sources/([a-f0-9]{64})\.json\Z')
5
+ from source_metadata import ARCHIVE, source_pointers
17
6
 
18
7
 
19
8
  def candidate(path):
20
9
  return path in ('edition.json', 'sources.lock.json', 'base-copies.json', 'base/PROVENANCE.json') or bool(ARCHIVE.fullmatch(path))
21
10
 
22
11
 
23
- def safe_path(value):
24
- return (isinstance(value, str) and value and not re.search(r'[\\\x00-\x1f\x7f]', value)
25
- and all(part not in ('', '.', '..', '.git') for part in value.split('/')))
26
-
27
-
28
- def valid_files(files):
29
- if not isinstance(files, dict) or not files:
30
- return False
31
- for name, file in files.items():
32
- if (not safe_path(name) or not isinstance(file, dict) or not isinstance(file.get('data'), str)
33
- or type(file.get('executable')) is not bool):
34
- return False
35
- try:
36
- if base64.b64encode(base64.b64decode(file['data'], validate=True)).decode() != file['data']:
37
- return False
38
- except ValueError:
39
- return False
40
- return True
41
-
42
-
43
- def same_json(left, right):
44
- # Python equates booleans and numbers; Source JSON identities do not.
45
- return json.dumps(left, sort_keys=True) == json.dumps(right, sort_keys=True)
46
-
47
-
48
12
  class ContentIdentities:
49
13
  def __init__(self, files, read_regular=None):
50
- self.documents, self.allowed, self.archives, self.hashes = {}, {}, {}, {}
14
+ self.documents, self.allowed = {}, {}
51
15
  for path, data in files.items():
52
- if candidate(path):
53
- try:
54
- self.documents[path] = Document(data)
55
- except (ValueError, TypeError, IndexError, RecursionError, UnicodeError):
56
- pass
57
- if not self.documents:
58
- return
59
- for path, document in self.documents.items():
60
- for pointer in module_pins(path, document.value):
61
- self.allow(path, pointer)
62
- if path in ('base-copies.json', 'base/PROVENANCE.json') and read_regular is not None:
63
- for pointer in copy_pointers(document.value, read_regular):
64
- self.allow(path, pointer)
65
- recipe = self.document('edition.json')
66
- origins = [self.origin(recipe.get('parent'), True)]
67
- components = recipe.get('components', [])
68
- if isinstance(components, list):
69
- for component in components:
70
- source = component.get('source') if isinstance(component, dict) else None
71
- if isinstance(source, dict):
72
- origins.append(self.origin(source if source.get('kind') == 'git' else source.get('origin')))
73
- values = [recipe, *(v for v in origins if v is not None)]
74
- for path, document in self.documents.items():
75
- saved = document.value
76
- if ARCHIVE.fullmatch(path) and isinstance(saved, dict):
77
- values.extend([saved, saved.get('origin'), saved.get('files')])
78
- self.hashes = {}
79
- if values:
80
- try:
81
- result = subprocess.run(['node', str(Path(__file__).with_name('source_hashes.mjs'))],
82
- input=json.dumps(values).encode(), capture_output=True, timeout=60, check=True)
83
- hashes = json.loads(result.stdout)
84
- if len(hashes) != len(values) or not all(isinstance(h, str) and HEX.fullmatch(h) for h in hashes):
85
- return
86
- self.hashes = {json.dumps(v): h for v, h in zip(values, hashes)}
87
- except (OSError, ValueError, subprocess.SubprocessError):
88
- return
89
- # Authoring pins hash(json(saved)); a raw-byte pin alone cannot prove a
90
- # differently encoded retained archive, even when its fields agree.
91
- for path, document in self.documents.items():
92
- saved, match = document.value, ARCHIVE.fullmatch(path)
93
- if (match and isinstance(saved, dict) and saved.get('key') == match[1]
94
- and isinstance(saved.get('origin'), dict) and saved['origin']
95
- and valid_files(saved.get('files'))
96
- and isinstance(saved.get('dependencies', []), list)
97
- and saved['key'] == self.digest(saved['origin'])
98
- and saved.get('digest') == self.digest(saved['files'])
99
- and hashlib.sha256(document.data).hexdigest() == self.digest(saved)):
100
- self.archives[saved['key']] = (saved, hashlib.sha256(document.data).hexdigest())
101
- self.references()
102
-
103
- def digest(self, value):
104
- return self.hashes.get(json.dumps(value))
105
-
106
- def allow(self, path, pointer):
107
- self.allowed.setdefault(path, set()).add(pointer)
108
-
109
- def document(self, path):
110
- doc = self.documents.get(path)
111
- return doc.value if doc and isinstance(doc.value, dict) else {}
112
-
113
- def pin(self, pin, key):
114
- if not isinstance(pin, dict) or not isinstance(key, str) or key not in self.archives:
115
- return False
116
- archive, raw_sha = self.archives[key]
117
- return (pin.get('key') == key and pin.get('digest') == archive['digest']
118
- and pin.get('sha256') == raw_sha)
119
-
120
- def reference(self, ref, parent=False):
121
- if not isinstance(ref, dict):
122
- return None
123
- if not self.valid_selection(ref):
124
- return None
125
- selection = dict(path=ref.get('path') or '.', paths=ref.get('paths') or None,
126
- excludeSnapshots=True if parent else bool(ref.get('excludeSnapshots')))
127
- projection = ref.get('projection')
128
- if isinstance(projection, dict) and set(projection) == {'key', 'sha256'}:
129
- key = projection['key']
130
- if not isinstance(key, str) or key not in self.archives:
131
- return None
132
- saved, raw_sha = self.archives[key]
133
- if (projection['sha256'] == raw_sha and same_json(saved['origin'].get('selection'), selection)):
134
- return key
135
- return None
136
- origin = self.origin(ref, parent)
137
- key = self.digest(origin)
138
- if key in self.archives and same_json(self.archives[key][0]['origin'], origin):
139
- return key
140
- return None
141
-
142
- @staticmethod
143
- def valid_selection(ref):
144
- return (ref.get('path', '.') == '.' or safe_path(ref.get('path'))) and (
145
- ref.get('paths') is None or isinstance(ref['paths'], list) and bool(ref['paths'])
146
- and all(safe_path(p) for p in ref['paths'])) and (
147
- 'excludeSnapshots' not in ref or type(ref['excludeSnapshots']) is bool)
148
-
149
- @staticmethod
150
- def origin(ref, parent=False):
151
- if not isinstance(ref, dict) or not ContentIdentities.valid_selection(ref):
152
- return None
153
- projection = ref.get('projection')
154
- if (not isinstance(ref.get('repository'), str) or not ref['repository']
155
- or ref['repository'].startswith('-')):
156
- return None
157
- if 'projection' in ref:
158
- if ('revision' in ref or not isinstance(projection, dict) or set(projection) != {'schemaVersion', 'originalSha256', 'recipeSha256'}
159
- or type(projection['schemaVersion']) is not int or projection['schemaVersion'] != 1
160
- or not all(isinstance(projection[k], str) and HEX.fullmatch(projection[k])
161
- for k in ('originalSha256', 'recipeSha256'))):
162
- return None
163
- identity = dict(projection=projection)
164
- elif isinstance(ref.get('revision'), str) and re.fullmatch(r'[a-f0-9]{40}', ref['revision']):
165
- identity = dict(revision=ref['revision'])
166
- else:
167
- return None
168
- return dict(repository=ref['repository'], **identity, path=ref.get('path') or '.',
169
- paths=ref.get('paths') or None,
170
- excludeSnapshots=True if parent else bool(ref.get('excludeSnapshots')))
171
-
172
- def dependency_locations(self, key):
173
- # Stage the entire selected closure before granting any of its locations.
174
- # An unresolved later edge must not leave the root or earlier edges allowed.
175
- pending, visited, locations = [key], set(), []
176
- while pending:
177
- key = pending.pop()
178
- if key in visited:
16
+ if not candidate(path):
179
17
  continue
180
- visited.add(key)
181
- saved, _ = self.archives[key]
182
- path = '.edition/sources/' + key + '.json'
183
- locations.append((path, ('key',)))
184
- for i, pin in enumerate(saved.get('dependencies', [])):
185
- if not isinstance(pin, dict) or not self.pin(pin, pin.get('key')):
186
- return None
187
- locations.append((path, ('dependencies', i, 'key')))
188
- pending.append(pin['key'])
189
- return locations
190
-
191
- def references(self):
192
- recipe = self.document('edition.json')
193
- lock = self.document('sources.lock.json')
194
- if type(recipe.get('schemaVersion')) is not int or recipe['schemaVersion'] != 1 or not isinstance(recipe.get('components'), list):
195
- return
196
- components = lock.get('components')
197
- locked = (type(lock.get('schemaVersion')) is int and lock['schemaVersion'] == 1
198
- and isinstance(recipe.get('id'), str) and lock.get('edition') == recipe['id']
199
- and lock.get('manifest') == self.digest(recipe) and isinstance(components, list))
200
- if not locked:
201
- return
202
- def identifiers(entries):
203
- ids = [entry.get('id') for entry in entries if isinstance(entry, dict)]
204
- return (len(ids) == len(entries) and all(isinstance(i, str) and i for i in ids)
205
- and len(set(ids)) == len(ids))
206
- if not identifiers(recipe['components']) or not identifiers(components):
207
- return
208
- by_id = {c['id']: c for c in components}
209
- if (len(components) != len(recipe['components']) or any(
210
- c['id'] not in by_id or not same_json(c.get('source'), by_id[c['id']].get('source'))
211
- for c in recipe['components'])):
212
- return
213
- selected = []
214
- key = self.reference(recipe.get('parent'), parent=True)
215
- if key and locked and self.pin(lock.get('parent'), key):
216
- selected.append((key, [
217
- ('edition.json', ('parent', 'projection', 'key')),
218
- ('sources.lock.json', ('parent', 'key')),
219
- ]))
220
- for index, component in enumerate(recipe['components']):
221
- if not isinstance(component, dict):
222
- continue
223
- source = component.get('source')
224
- if not isinstance(source, dict):
225
- continue
226
- suffix = () if source.get('kind') == 'git' else ('origin',) if source.get('kind') == 'local' else None
227
- if suffix is None:
228
- continue
229
- key = self.reference(source if not suffix else source.get('origin'))
230
- if not key:
18
+ try:
19
+ document = Document(data)
20
+ except (ValueError, TypeError, IndexError, RecursionError, UnicodeError):
231
21
  continue
232
- for i, entry in enumerate(components):
233
- if (isinstance(entry, dict) and entry.get('id') == component.get('id')
234
- and same_json(entry.get('source'), source) and self.pin(entry.get('baseline'), key)):
235
- selected.append((key, [
236
- ('edition.json', ('components', index, 'source', *suffix, 'projection', 'key')),
237
- ('sources.lock.json', ('components', i, 'baseline', 'key')),
238
- ('sources.lock.json', ('components', i, 'source', *suffix, 'projection', 'key')),
239
- ]))
240
- # Validate each selection independently: unselected archives and unrelated
241
- # coherent selections cannot invalidate this graph's proof.
242
- for key, locations in selected:
243
- dependencies = self.dependency_locations(key)
244
- if dependencies is not None:
245
- for path, pointer in locations + dependencies:
246
- self.allow(path, pointer)
22
+ self.documents[path] = document
23
+ pointers = set(module_pins(path, document.value))
24
+ pointers.update(source_pointers(path, document.value))
25
+ if path in ('base-copies.json', 'base/PROVENANCE.json') and read_regular is not None:
26
+ pointers.update(copy_pointers(document.value, read_regular))
27
+ self.allowed[path] = pointers
247
28
 
248
29
  def matches(self, path, hit):
249
30
  document = self.documents.get(path)
@@ -96,12 +96,12 @@ class History:
96
96
  records = self.run('log', '-z', '--format=%H%x00%B', *self.revisions).split(b'\0')
97
97
  for index in range(0, len(records) - 1, 2):
98
98
  if records[index]:
99
- yield '@commit-message', records[index].decode().strip(), records[index + 1]
99
+ yield 'commit', records[index].decode().strip(), records[index + 1]
100
100
  for record in self.run('for-each-ref', '--format=%(objecttype) %(objectname)', 'refs/tags').splitlines():
101
101
  kind, oid = record.decode().split()
102
102
  if kind == 'tag':
103
103
  raw = self.run('cat-file', 'tag', oid)
104
- yield '@tag-message', oid, raw.partition(b'\n\n')[2]
104
+ yield 'tag', oid, raw.partition(b'\n\n')[2]
105
105
 
106
106
  def close(self):
107
107
  if self.reader:
@@ -29,7 +29,7 @@ regexes = ['''\.(?:test|fixture|fake|example|dummy|placeholder|synthetic)[_-][a-
29
29
 
30
30
  # SHA-shaped credentials must not disappear because their random bytes contain
31
31
  # an English stopword (for example dead/feed) or fall below generic entropy.
32
- # Source/module identities still require the location-bound proof in Python.
32
+ # Source/module identities still require exact structural JSON locations in Python.
33
33
  [[rules]]
34
34
  id = "hex-api-key"
35
35
  description = "Hexadecimal credential assignment"
@@ -128,7 +128,7 @@ def scan(repo, ref='HEAD', public_base=None, env_file=None, project=None, reposi
128
128
  if identities[identity_scope].matches(path, hit):
129
129
  continue
130
130
  else:
131
- # Content-identity documents keep their own stricter proof.
131
+ # Identity documents use their own exact structural locations.
132
132
  held = working_regular(Path(history.repo), path) if mode == 'dir' else read_regular(path)
133
133
  if held and checksums[identity_scope].matches(held[0], hit):
134
134
  continue
@@ -146,9 +146,11 @@ def scan(repo, ref='HEAD', public_base=None, env_file=None, project=None, reposi
146
146
  scope = 'working-tree' if path in working_paths else 'pre-existing' if commit in history.public_commits else 'newly-public'
147
147
  lines.append((count, count + length, path, commit, scope))
148
148
  count += length
149
- for path, commit, data in history.messages():
150
- report['history']['tagMessages' if path == '@tag-message' else 'commitMessages'] += 1
151
- rules, _ = text_rules(data, path, selected_context, resolver, hashes)
149
+ for message_type, commit, data in history.messages():
150
+ path = '@' + message_type + '-message'
151
+ report['history']['tagMessages' if message_type == 'tag' else 'commitMessages'] += 1
152
+ rules, _ = text_rules(data, path, selected_context, resolver, hashes,
153
+ commit_message=message_type == 'commit')
152
154
  scope = 'release-tree' if commit == history.commit else 'pre-existing' if commit in history.public_commits else 'newly-public'
153
155
  for rule in rules:
154
156
  add(rule, path, commit, scope=scope)
@@ -9,7 +9,9 @@ LOCAL_PATH = re.compile(r'''(?x)(?<![^\s("'`=:\[<>{|])(?:[*_]|~~)*(?:(?:(?i:(?a:
9
9
  CONFIG = '.publish-check/config.json'
10
10
  # An alphabetic top-level label ends before a following digit, such as the DER
11
11
  # tag after a certificate's email value; other labels keep the full extent.
12
- EMAIL = re.compile(r"(?<![A-Za-z0-9_.+%-])[A-Za-z0-9_][A-Za-z0-9_.+%'-]{0,63}@(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)*[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.(?:[A-Za-z]{2,63}(?![A-Za-z.-])|[A-Za-z][A-Za-z0-9-]{1,62}(?![A-Za-z0-9.-]))|(?<![A-Za-z0-9_.+%-])[A-Za-z0-9_][A-Za-z0-9_.+%'-]{0,63}@(?:example|test|invalid)(?![A-Za-z0-9.-])", re.I)
12
+ # A period is punctuation unless it starts another host label; never shorten
13
+ # a suffixed host into a reserved/public domain.
14
+ EMAIL = re.compile(r"(?<![A-Za-z0-9_.+%-])[A-Za-z0-9_][A-Za-z0-9_.+%'-]{0,63}@(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)*[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.(?:[A-Za-z]{2,63}(?![A-Za-z-]|\.[A-Za-z0-9-])|[A-Za-z][A-Za-z0-9-]{1,62}(?![A-Za-z0-9-]|\.[A-Za-z0-9-]))|(?<![A-Za-z0-9_.+%-])[A-Za-z0-9_][A-Za-z0-9_.+%'-]{0,63}@(?:example|test|invalid)(?![A-Za-z0-9-]|\.[A-Za-z0-9-])", re.I)
13
15
  RULES = {'hygiene.local-path', 'hygiene.github-private', 'hygiene.github-missing',
14
16
  'hygiene.email', 'hygiene.internal-reference'}
15
17
 
@@ -9,6 +9,29 @@ from hygiene_config import EMAIL, LOCAL_PATH, public_email
9
9
  from font_licence import without_required_notices
10
10
  from trust_bundle import anchor_values
11
11
  URI_AUTHORITY = re.compile(r'''(?i)(?a:(?P<scheme>[a-z][a-z0-9+.-]*:))?//(?P<authority>[^/?#\s<>"']+)''')
12
+ # Match raw, complete commit trailer lines; decoded or embedded prose cannot
13
+ # acquire trailer privileges. Only the address span is omitted from email checks.
14
+ COAUTHOR = re.compile(
15
+ rb'^Co-Authored-By:[ \t]+[^<>\r\n]*[^<>\s][ \t]+<(?P<address>'
16
+ rb'noreply@(?:anthropic|github)\.com|'
17
+ rb'[0-9]+\+[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){0,38}@users\.noreply\.github\.com'
18
+ rb')>[ \t]*\r?$', re.I | re.M)
19
+
20
+
21
+ def without_public_coauthor_addresses(data):
22
+ # A trailer block is the final paragraph and contains only trailer lines.
23
+ # A quoted attribution followed by ordinary prose stays ordinary body text.
24
+ tail = data.rstrip(b' \t\r\n')
25
+ block = re.split(rb'\r?\n[ \t]*\r?\n', tail)[-1]
26
+ if not all(re.fullmatch(rb'[A-Za-z0-9-]+:[ \t]+[^\r\n]+\r?', line)
27
+ for line in block.split(b'\n')):
28
+ return data
29
+ offset = len(tail) - len(block)
30
+ result = bytearray(data)
31
+ for match in COAUTHOR.finditer(block):
32
+ start, end = match.span('address')
33
+ result[offset + start:offset + end] = b' ' * (end - start)
34
+ return bytes(result)
12
35
 
13
36
 
14
37
  def email_userinfo(match):
@@ -25,7 +48,7 @@ def private_emails(text, config):
25
48
  # URL paths, queries and fragments remain visible to email inspection.
26
49
  email_text = URI_AUTHORITY.sub(email_userinfo, without_required_notices(text))
27
50
  email_text = re.sub(r'(?i)\b(?a:git@github\.com:)(?=[A-Za-z0-9])', '', email_text)
28
- # A package manager's patch file is named name@version.patch.
51
+ # A package manager's patch filename joins the name and version with an at sign.
29
52
  version = r'(?:v?[0-9]+(?:\.[0-9]+)*(?:-[A-Za-z0-9-]+)?|[a-f0-9]{40})(?:\.patch)?(?![A-Za-z0-9_.-])'
30
53
  email_text = re.sub(r'(?m)^\s*-?\s*uses:\s*[\"\']?[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+@[^\s\"\']+', '', email_text)
31
54
  email_text = re.sub(r'(?<![A-Za-z0-9])(?:@[A-Za-z0-9_.-]+/)?[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)?@' + version, '', email_text)
@@ -69,11 +92,11 @@ def certificate_metadata(data, bundle, emails, config):
69
92
  return not Counter(emails) - inside
70
93
 
71
94
 
72
- def inspect(data, path, config, resolver):
73
- return inspect_text(data, path, config, resolver)
95
+ def inspect(data, path, config, resolver, *, commit_message=False):
96
+ return inspect_text(data, path, config, resolver, commit_message=commit_message)
74
97
 
75
98
 
76
- def inspect_text(data, path, config, resolver):
99
+ def inspect_text(data, path, config, resolver, *, commit_message=False):
77
100
  # NUL removal covers UTF-16/32 ASCII content, matching the secret scanner.
78
101
  text = decoded_text(data)
79
102
  rules = set()
@@ -82,7 +105,8 @@ def inspect_text(data, path, config, resolver):
82
105
  if any(pattern.search(text) for pattern in config['internalReferencePatterns']):
83
106
  rules.add('hygiene.internal-reference')
84
107
  if '@' in text:
85
- emails = private_emails(text, config)
108
+ email_text = decoded_text(without_public_coauthor_addresses(data)) if commit_message else text
109
+ emails = private_emails(email_text, config)
86
110
  bundle = config.get('trustBundles', {}).get(path)
87
111
  if emails and not (bundle and certificate_metadata(data, bundle, emails, config)):
88
112
  # Inside binary data a mailbox is judged by its own shape: a host
@@ -20,6 +20,8 @@
20
20
  { "repository": "just-every/run-nook", "reason": "Owner-proposed public name (13 Oct 2026), accepted ahead of the rename; created by clean-history export at the first public tag." },
21
21
  { "repository": "just-every/run-orbit", "reason": "Owner-proposed public name (13 Oct 2026), accepted ahead of the rename; created by clean-history export at the first public tag." },
22
22
  { "repository": "just-every/run-oncall", "reason": "Owner-proposed public name (13 Oct 2026), accepted ahead of the rename; created by clean-history export at the first public tag." },
23
- { "repository": "just-every/run-spool", "reason": "Owner-proposed public name (13 Oct 2026), accepted ahead of the rename; created by clean-history export at the first public tag." }
23
+ { "repository": "just-every/run-spool", "reason": "Owner-proposed public name (13 Oct 2026), accepted ahead of the rename; created by clean-history export at the first public tag." },
24
+ { "repository": "just-every/run-happytohelp", "reason": "Owner-proposed public name (13 Oct 2026), accepted ahead of the rename; created by clean-history export at the first public tag." },
25
+ { "repository": "just-every/run-auth", "reason": "Owner-proposed public name (13 Oct 2026), accepted ahead of the rename; created by clean-history export at the first public tag." }
24
26
  ]
25
27
  }
@@ -0,0 +1,62 @@
1
+ """Digest fields in the formats emitted by edition-authoring cli/sources.mjs.
2
+
3
+ This recognizes metadata, not its freshness or integrity. Authoring's check
4
+ owns those validations; a retained, unselected archive is still metadata.
5
+ Only enumerated JSON value locations qualify, never arbitrary nested names.
6
+ """
7
+ import re
8
+ from json_identity import HEX, GIT_REVISION
9
+ import source_shapes
10
+
11
+ ARCHIVE = re.compile(r'\.edition/sources/([a-f0-9]{64})\.json\Z')
12
+
13
+
14
+ def fields(value, pointer, names, pattern=HEX):
15
+ if isinstance(value, dict):
16
+ for name in names:
17
+ if isinstance(value.get(name), str) and pattern.fullmatch(value[name]):
18
+ yield (*pointer, name)
19
+
20
+
21
+ def pin(value, pointer):
22
+ # sources.mjs and cli.mjs emit this same three-digest reference shape.
23
+ if source_shapes.pin(value):
24
+ yield from fields(value, pointer, ('key', 'digest', 'sha256'))
25
+
26
+
27
+ def origin(value, pointer):
28
+ # snapshotOrigin in projection-lock.mjs copies one immutable identity.
29
+ if not source_shapes.reference(value):
30
+ return
31
+ if 'projection' not in value:
32
+ yield from fields(value, pointer, ('revision',), GIT_REVISION)
33
+ else:
34
+ yield from fields(value['projection'], (*pointer, 'projection'), ('originalSha256', 'recipeSha256'))
35
+
36
+
37
+ def source_pointers(path, value):
38
+ if not isinstance(value, dict):
39
+ return
40
+ if path == 'sources.lock.json':
41
+ if not source_shapes.lock(value):
42
+ return
43
+ yield from fields(value, (), ('manifest',))
44
+ yield from fields(value['toolchain'], ('toolchain',), ('sha256',))
45
+ yield from pin(value.get('parent'), ('parent',))
46
+ for index, component in enumerate(value['components']):
47
+ pointer = ('components', index)
48
+ yield from pin(component.get('baseline'), (*pointer, 'baseline'))
49
+ source = component['source']
50
+ if source['kind'] == 'git':
51
+ yield from origin(source, (*pointer, 'source'))
52
+ else:
53
+ yield from origin(source.get('origin'), (*pointer, 'source', 'origin'))
54
+ for name, entry in value['files'].items():
55
+ yield from fields(entry, ('files', name), ('sha256',))
56
+ elif ARCHIVE.fullmatch(path):
57
+ if not source_shapes.archive(value):
58
+ return
59
+ yield from fields(value, (), ('key', 'digest'))
60
+ yield from origin(value['origin'], ('origin',))
61
+ for index, dependency in enumerate(value.get('dependencies', [])):
62
+ yield from pin(dependency, ('dependencies', index))