obaaf 0.1.0__py3-none-any.whl

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.
obaaf/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Open Build and Agent Assurance Framework (OBAAF) tools.
2
+
3
+ Version 0.x is a draft. Nothing in this package has been piloted yet.
4
+ """
5
+
6
+ __version__ = "0.1.0"
obaaf/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from obaaf.cli import main
2
+
3
+ raise SystemExit(main())
obaaf/assess.py ADDED
@@ -0,0 +1,344 @@
1
+ """Score an OBAAF self-assessment and rank the fixes (FR2).
2
+
3
+ Scoring follows Paper 2, design requirement DR6: a studio reaches a tier when
4
+ every item at that tier and below is answered yes. An item may be marked not
5
+ applicable only when its condition is absent, and then counts as satisfied.
6
+
7
+ Fixes are ranked lowest tier first, then lowest effort, then by the number of
8
+ documented compromises for which the item is a deciding control (Paper 2,
9
+ Table 7). See DECISIONS.md, D-006.
10
+
11
+ Everything here runs locally. Nothing is sent over the network (NFR5).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import re
18
+ from dataclasses import dataclass, field
19
+ from datetime import date
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ from obaaf import __version__, yamlutil
24
+ from obaaf.catalog import Catalog, load
25
+
26
+ ANSWER_FORMAT = "obaaf-assessment/v0.1"
27
+ VALID = {"yes", "no", "na", "unanswered"}
28
+ ALIASES = {
29
+ "y": "yes", "true": "yes", "n": "no", "false": "no", "n/a": "na", "not applicable": "na",
30
+ "": "unanswered", "?": "unanswered", "unknown": "unanswered", "unsure": "unanswered",
31
+ }
32
+
33
+
34
+ class AnswerError(ValueError):
35
+ """The answer file cannot be scored as written."""
36
+
37
+
38
+ @dataclass
39
+ class Answer:
40
+ value: str
41
+ evidence: str = ""
42
+ note: str = ""
43
+
44
+
45
+ @dataclass
46
+ class AnswerSet:
47
+ answers: dict[str, Answer]
48
+ studio: str = ""
49
+ respondent: str = ""
50
+ date: str = ""
51
+
52
+
53
+ @dataclass
54
+ class Fix:
55
+ rank: int
56
+ item: dict
57
+ answer: str
58
+ prevents: list[dict]
59
+ limits: list[dict]
60
+
61
+
62
+ @dataclass
63
+ class Result:
64
+ tier: int
65
+ tier_name: str
66
+ per_tier: dict[int, dict[str, int]]
67
+ fixes: list[Fix]
68
+ answers: AnswerSet
69
+ warnings: list[str] = field(default_factory=list)
70
+
71
+ @property
72
+ def next_tier(self) -> int | None:
73
+ return self.tier + 1 if self.tier < 4 else None
74
+
75
+
76
+ def _normalize(raw: Any, iid: str) -> Answer:
77
+ evidence = note = ""
78
+ if isinstance(raw, dict):
79
+ evidence = str(raw.get("evidence") or "")
80
+ note = str(raw.get("note") or "")
81
+ raw = raw.get("answer")
82
+ if raw is True:
83
+ raw = "yes"
84
+ elif raw is False:
85
+ raw = "no"
86
+ elif raw is None:
87
+ raw = "unanswered"
88
+ value = str(raw).strip().lower()
89
+ value = ALIASES.get(value, value)
90
+ if value not in VALID:
91
+ raise AnswerError(f"item {iid}: answer {raw!r} is not one of yes, no, na or unanswered")
92
+ return Answer(value=value, evidence=evidence, note=note)
93
+
94
+
95
+ def parse_answers(data: Any, catalog: Catalog | None = None) -> AnswerSet:
96
+ catalog = catalog or load()
97
+ if not isinstance(data, dict) or "answers" not in data:
98
+ raise AnswerError("the file must be a mapping with an 'answers' key (run 'obaaf assess init' for a template)")
99
+ fmt = data.get("format", ANSWER_FORMAT)
100
+ if fmt != ANSWER_FORMAT:
101
+ raise AnswerError(f"unsupported answer format {fmt!r}; expected {ANSWER_FORMAT}")
102
+ raw_answers = data["answers"]
103
+ if isinstance(raw_answers, list):
104
+ pairs = []
105
+ for entry in raw_answers:
106
+ if not isinstance(entry, dict) or "item" not in entry:
107
+ raise AnswerError("each entry in a list of answers needs an 'item' key")
108
+ pairs.append((entry["item"], entry))
109
+ elif isinstance(raw_answers, dict):
110
+ pairs = list(raw_answers.items())
111
+ else:
112
+ raise AnswerError("'answers' must be a mapping or a list")
113
+
114
+ answers: dict[str, Answer] = {}
115
+ for key, raw in pairs:
116
+ if not isinstance(key, str):
117
+ raise AnswerError(
118
+ f"item ID {key!r} must be quoted (write \"2.10\", not 2.10, or YAML reads it as the number 2.1)"
119
+ )
120
+ iid = key.strip()
121
+ if iid not in catalog.items:
122
+ raise AnswerError(f"unknown item {iid!r}")
123
+ if iid in answers:
124
+ raise AnswerError(f"item {iid} is answered twice")
125
+ answer = _normalize(raw, iid)
126
+ if answer.value == "na" and not catalog.items[iid].get("na_when"):
127
+ raise AnswerError(
128
+ f"item {iid} cannot be marked not applicable; only items whose condition can be absent can "
129
+ "(Paper 2, DR6). Answer yes or no."
130
+ )
131
+ answers[iid] = answer
132
+ for iid in catalog.items:
133
+ answers.setdefault(iid, Answer(value="unanswered"))
134
+ return AnswerSet(
135
+ answers=answers,
136
+ studio=str(data.get("studio") or ""),
137
+ respondent=str(data.get("respondent") or ""),
138
+ date=str(data.get("date") or ""),
139
+ )
140
+
141
+
142
+ def load_answers(path: str | Path, catalog: Catalog | None = None) -> AnswerSet:
143
+ text = Path(path).read_text(encoding="utf-8")
144
+ if str(path).endswith(".json"):
145
+ data = json.loads(text)
146
+ else:
147
+ try:
148
+ data = yamlutil.load(text)
149
+ except yamlutil.DuplicateKeyError as exc:
150
+ raise AnswerError(str(exc)) from exc
151
+ return parse_answers(data, catalog)
152
+
153
+
154
+ def _item_order(iid: str) -> tuple[int, int]:
155
+ tier, num = iid.split(".")
156
+ return int(tier), int(num)
157
+
158
+
159
+ def score(answers: AnswerSet, catalog: Catalog | None = None) -> Result:
160
+ catalog = catalog or load()
161
+ per_tier: dict[int, dict[str, int]] = {}
162
+ for tier in sorted(catalog.tiers):
163
+ counts = {"items": 0, "yes": 0, "na": 0, "no": 0, "unanswered": 0}
164
+ for iid, item in catalog.items.items():
165
+ if item["tier"] == tier:
166
+ counts["items"] += 1
167
+ counts[answers.answers[iid].value] += 1
168
+ per_tier[tier] = counts
169
+
170
+ reached = 0
171
+ for tier in sorted(per_tier):
172
+ counts = per_tier[tier]
173
+ if counts["yes"] + counts["na"] == counts["items"]:
174
+ reached = tier
175
+ else:
176
+ break
177
+
178
+ effort_rank = {k: v["rank"] for k, v in catalog.assessment["effort"].items()}
179
+ open_items = [
180
+ (iid, item) for iid, item in catalog.items.items()
181
+ if answers.answers[iid].value in ("no", "unanswered")
182
+ ]
183
+
184
+ def key(entry: tuple[str, dict]) -> tuple:
185
+ iid, item = entry
186
+ cases = catalog.item_cases(iid)
187
+ return (item["tier"], effort_rank[item["effort"]], -len(cases["prevent"]), -len(cases["limit"]), _item_order(iid))
188
+
189
+ fixes = []
190
+ for rank, (iid, item) in enumerate(sorted(open_items, key=key), start=1):
191
+ cases = catalog.item_cases(iid)
192
+ fixes.append(Fix(rank=rank, item=item, answer=answers.answers[iid].value,
193
+ prevents=cases["prevent"], limits=cases["limit"]))
194
+
195
+ warnings = []
196
+ unanswered = [iid for iid, a in answers.answers.items() if a.value == "unanswered"]
197
+ if unanswered:
198
+ warnings.append(
199
+ f"{len(unanswered)} item(s) unanswered; they count as 'no' until answered: "
200
+ + ", ".join(sorted(unanswered, key=_item_order))
201
+ )
202
+ tier_name = catalog.tiers[reached]["name"] if reached else "Not yet Accounted"
203
+ return Result(tier=reached, tier_name=tier_name, per_tier=per_tier, fixes=fixes, answers=answers, warnings=warnings)
204
+
205
+
206
+ # ---------------------------------------------------------------------- output
207
+ def template(catalog: Catalog | None = None) -> str:
208
+ """An answer file with every question, ready to fill in."""
209
+ catalog = catalog or load()
210
+ lines = [
211
+ "# OBAAF self-assessment answers. Answer each item yes, no, or na where allowed.",
212
+ "# Keep this file inside your studio: it is never sent anywhere.",
213
+ "# Score it with: obaaf assess score this-file.yaml",
214
+ f"format: {ANSWER_FORMAT}",
215
+ 'studio: ""',
216
+ 'respondent: ""',
217
+ f'date: "{date.today().isoformat()}"',
218
+ "answers:",
219
+ ]
220
+ for tier in sorted(catalog.tiers):
221
+ t = catalog.tiers[tier]
222
+ lines.append(f" # Tier {tier}, {t['name']}: {t['summary']}")
223
+ for iid, item in sorted(catalog.items.items(), key=lambda kv: _item_order(kv[0])):
224
+ if item["tier"] != tier:
225
+ continue
226
+ lines.append(f" # {iid} {item['question']}")
227
+ lines.append(f" # Evidence: {item['evidence']}.")
228
+ if item.get("na_when"):
229
+ lines.append(f" # Answer na only if: {item['na_when']}")
230
+ lines.append(f' "{iid}": {{answer: unanswered, evidence: ""}}')
231
+ return "\n".join(lines) + "\n"
232
+
233
+
234
+ def _case_names(cases: list[dict]) -> str:
235
+ return ", ".join(f"{c['name']} ({c['reported']})" for c in cases)
236
+
237
+
238
+ def to_text(result: Result, catalog: Catalog | None = None, limit: int = 10) -> str:
239
+ catalog = catalog or load()
240
+ out = []
241
+ who = f" for {result.answers.studio}" if result.answers.studio else ""
242
+ out.append(f"OBAAF self-assessment{who}")
243
+ out.append(f"Tier reached: {result.tier} ({result.tier_name})")
244
+ for tier, counts in result.per_tier.items():
245
+ satisfied = counts["yes"] + counts["na"]
246
+ out.append(f" Tier {tier} {catalog.tiers[tier]['name']:<11} {satisfied:>2}/{counts['items']} satisfied")
247
+ for warning in result.warnings:
248
+ out.append(f"Note: {warning}")
249
+ if result.fixes:
250
+ shown = result.fixes[:limit] if limit else result.fixes
251
+ out.append("")
252
+ out.append(f"Fix next ({len(shown)} of {len(result.fixes)}, lowest tier and effort first):")
253
+ effort = catalog.assessment["effort"]
254
+ for fix in shown:
255
+ item = fix.item
256
+ label = effort[item["effort"]]["label"].lower()
257
+ out.append(f"{fix.rank:>3}. {item['id']} {item['short']} [tier {item['tier']}, {label}]")
258
+ if fix.prevents:
259
+ out.append(f" Would have prevented: {_case_names(fix.prevents)}")
260
+ if fix.limits:
261
+ out.append(f" Detects or limits: {_case_names(fix.limits)}")
262
+ out.append(f" How: {item['fix'][0]}")
263
+ else:
264
+ out.append("All items satisfied.")
265
+ return "\n".join(out) + "\n"
266
+
267
+
268
+ def to_markdown(result: Result, catalog: Catalog | None = None, link_base: str = "") -> str:
269
+ catalog = catalog or load()
270
+ who = f" - {result.answers.studio}" if result.answers.studio else ""
271
+ out = [f"# OBAAF self-assessment{who}", ""]
272
+ meta = []
273
+ if result.answers.date:
274
+ meta.append(f"Date: {result.answers.date}")
275
+ if result.answers.respondent:
276
+ meta.append(f"Respondent: {result.answers.respondent}")
277
+ meta.append(f"Scored by obaaf {__version__}")
278
+ out += [" · ".join(meta), ""]
279
+ out.append(f"**Tier reached: {result.tier} ({result.tier_name}).** "
280
+ "A tier describes controls in place, not a guarantee of security.")
281
+ out.append("")
282
+ out.append("| Tier | Satisfied | No | Unanswered |")
283
+ out.append("| --- | --- | --- | --- |")
284
+ for tier, counts in result.per_tier.items():
285
+ out.append(f"| {tier} {catalog.tiers[tier]['name']} | {counts['yes'] + counts['na']} of {counts['items']} "
286
+ f"| {counts['no']} | {counts['unanswered']} |")
287
+ out.append("")
288
+ if result.fixes:
289
+ out.append("## What to fix next")
290
+ out.append("")
291
+ out.append("Ranked lowest tier first, then lowest effort, then by the documented compromises each item "
292
+ "addresses.")
293
+ out.append("")
294
+ out.append("| # | Item | Tier | Effort | Documented cases it addresses | How to fix |")
295
+ out.append("| --- | --- | --- | --- | --- | --- |")
296
+ effort = catalog.assessment["effort"]
297
+ for fix in result.fixes:
298
+ item = fix.item
299
+ cases = _case_names(fix.prevents + fix.limits) or "-"
300
+ links = ", ".join(f"[{_label(link)}]({link_base}{link})" for link in item["fix"])
301
+ out.append(f"| {fix.rank} | {item['id']} {item['short']} | {item['tier']} | "
302
+ f"{effort[item['effort']]['label']} | {cases} | {links} |")
303
+ out.append("")
304
+ if result.warnings:
305
+ out.append("## Notes")
306
+ out.append("")
307
+ out.extend(f"- {w}" for w in result.warnings)
308
+ out.append("")
309
+ return "\n".join(out)
310
+
311
+
312
+ def _label(link: str) -> str:
313
+ from obaaf.render import _label as render_label
314
+
315
+ return render_label(link)
316
+
317
+
318
+ def to_json(result: Result) -> str:
319
+ payload = {
320
+ "format": "obaaf-assessment-result/v0.1",
321
+ "scoredBy": f"obaaf {__version__}",
322
+ "studio": result.answers.studio,
323
+ "date": result.answers.date,
324
+ "tier": result.tier,
325
+ "tierName": result.tier_name,
326
+ "perTier": {str(k): v for k, v in result.per_tier.items()},
327
+ "fixes": [
328
+ {
329
+ "rank": f.rank,
330
+ "item": f.item["id"],
331
+ "short": f.item["short"],
332
+ "tier": f.item["tier"],
333
+ "effort": f.item["effort"],
334
+ "answer": f.answer,
335
+ "profile": f.item["profile"],
336
+ "prevents": [c["id"] for c in f.prevents],
337
+ "limits": [c["id"] for c in f.limits],
338
+ "fix": f.item["fix"],
339
+ }
340
+ for f in result.fixes
341
+ ],
342
+ "warnings": result.warnings,
343
+ }
344
+ return json.dumps(payload, indent=2) + "\n"
@@ -0,0 +1,6 @@
1
+ """Generators for OBAAF's records: snapshot, agent run, approval, build record, manifest.
2
+
3
+ The generators write in-toto predicates (and optionally whole statements). In
4
+ the GitHub Actions templates the predicates are signed by actions/attest in a
5
+ job that holds the OIDC permission, so no long-lived key exists (FR11).
6
+ """
@@ -0,0 +1,67 @@
1
+ """The agent record that travels with an agent-authored change (R8, G8.3).
2
+
3
+ When the effect lane applies a proposal, it commits a record file next to the
4
+ change, at `.obaaf/records/<bundle digest>.json`, and names it in a commit
5
+ trailer (`OBAAF-Agent-Record: <path>`). The record holds the proposal bundle and
6
+ the two signed attestations: the agent-run record from the agent lane and the
7
+ approval record from the effect lane. The release verifier reads it from the
8
+ commit, so the evidence is archived with the source and needs no network
9
+ lookups beyond signature verification.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import base64
15
+ import json
16
+ from pathlib import Path
17
+
18
+ from obaaf.attest.common import RecordError, canonical_json, files_under, sha256_bytes, write_json
19
+ from obaaf.attest.proposal import ALLOWED_FILES, bundle_digest
20
+
21
+ FORMAT = "obaaf-agent-record/v0.1"
22
+ TRAILER = "OBAAF-Agent-Record"
23
+
24
+
25
+ __all__ = ["FORMAT", "TRAILER", "bundle_digest", "digest_of_listing", "build", "write", "parse"]
26
+
27
+
28
+ def digest_of_listing(files: dict[str, bytes]) -> str:
29
+ listing = sorted([name, sha256_bytes(data)] for name, data in files.items())
30
+ return sha256_bytes(canonical_json(listing))
31
+
32
+
33
+ def build(proposal_dir: str | Path, agent_run: dict, approval: dict, agent_run_url: str = "") -> dict:
34
+ root = Path(proposal_dir)
35
+ files = {p.relative_to(root).as_posix(): base64.b64encode(p.read_bytes()).decode("ascii") for p in files_under(root)}
36
+ return {
37
+ "format": FORMAT,
38
+ "bundleDigest": {"sha256": bundle_digest(root)},
39
+ "proposal": {"files": files},
40
+ "attestations": {"agentRun": agent_run, "approval": approval},
41
+ "agentRunUrl": agent_run_url,
42
+ }
43
+
44
+
45
+ def write(record: dict, records_dir: str | Path) -> Path:
46
+ path = Path(records_dir) / f"{record['bundleDigest']['sha256']}.json"
47
+ write_json(path, record)
48
+ return path
49
+
50
+
51
+ def parse(text: str) -> tuple[dict, dict[str, bytes]]:
52
+ """Return (record, proposal files) after checking the record's own digest."""
53
+ try:
54
+ record = json.loads(text)
55
+ except ValueError as exc:
56
+ raise RecordError(f"agent record is not JSON: {exc}") from exc
57
+ if record.get("format") != FORMAT:
58
+ raise RecordError(f"unexpected agent record format {record.get('format')!r}")
59
+ files = {}
60
+ for name, encoded in record["proposal"]["files"].items():
61
+ if name not in ALLOWED_FILES:
62
+ raise RecordError(f"unexpected file in agent record: {name!r}")
63
+ files[name] = base64.b64decode(encoded)
64
+ expected = record.get("bundleDigest", {}).get("sha256")
65
+ if digest_of_listing(files) != expected:
66
+ raise RecordError("the proposal files in the record do not match its bundle digest")
67
+ return record, files
obaaf/attest/common.py ADDED
@@ -0,0 +1,111 @@
1
+ """Shared helpers: digests, in-toto statements, schema validation, the CI environment."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ from datetime import datetime, timezone
9
+ from pathlib import Path
10
+ from typing import Any, Iterable
11
+
12
+ from obaaf import __version__
13
+ from obaaf.catalog import data_text
14
+ from obaaf.predicates import SCHEMA_FOR_TYPE, STATEMENT_TYPE
15
+
16
+
17
+ class RecordError(ValueError):
18
+ """A record cannot be produced or does not validate."""
19
+
20
+
21
+ def sha256_bytes(data: bytes) -> str:
22
+ return hashlib.sha256(data).hexdigest()
23
+
24
+
25
+ def sha256_file(path: str | Path) -> str:
26
+ digest = hashlib.sha256()
27
+ with open(path, "rb") as handle:
28
+ for chunk in iter(lambda: handle.read(1 << 20), b""):
29
+ digest.update(chunk)
30
+ return digest.hexdigest()
31
+
32
+
33
+ def canonical_json(obj: Any) -> bytes:
34
+ return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
35
+
36
+
37
+ def now() -> str:
38
+ return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
39
+
40
+
41
+ def files_under(path: str | Path) -> list[Path]:
42
+ """Files under a directory (sorted, relative order stable), or the file itself."""
43
+ path = Path(path)
44
+ if path.is_file():
45
+ return [path]
46
+ if not path.is_dir():
47
+ raise RecordError(f"not found: {path}")
48
+ return sorted(p for p in path.rglob("*") if p.is_file() and not p.is_symlink())
49
+
50
+
51
+ def subjects_for(paths: Iterable[str | Path], base: str | Path | None = None) -> list[dict]:
52
+ """in-toto ResourceDescriptors (name + sha256) for files; names are relative to `base`."""
53
+ out = []
54
+ for path in paths:
55
+ path = Path(path)
56
+ name = path.relative_to(base).as_posix() if base else path.name
57
+ out.append({"name": name, "digest": {"sha256": sha256_file(path)}})
58
+ if not out:
59
+ raise RecordError("no subjects: the record must cover at least one file")
60
+ return out
61
+
62
+
63
+ def statement(subjects: list[dict], predicate_type: str, predicate: dict) -> dict:
64
+ return {"_type": STATEMENT_TYPE, "subject": subjects, "predicateType": predicate_type, "predicate": predicate}
65
+
66
+
67
+ def validate_predicate(predicate_type: str, predicate: dict) -> list[str]:
68
+ """Errors from validating a predicate against its JSON Schema (empty when valid)."""
69
+ schema_path = SCHEMA_FOR_TYPE.get(predicate_type)
70
+ if not schema_path:
71
+ return []
72
+ import jsonschema # imported lazily: only the record commands need it
73
+
74
+ schema = json.loads(data_text(schema_path))
75
+ validator = jsonschema.Draft202012Validator(schema, format_checker=jsonschema.FormatChecker())
76
+ return [
77
+ f"{'/'.join(str(p) for p in err.absolute_path) or '(root)'}: {err.message}"
78
+ for err in sorted(validator.iter_errors(predicate), key=lambda e: list(e.absolute_path))
79
+ ]
80
+
81
+
82
+ def ci_run() -> dict:
83
+ """Facts about the current GitHub Actions run, from the runner's environment."""
84
+ env = os.environ
85
+ server = env.get("GITHUB_SERVER_URL", "https://github.com")
86
+ repo = env.get("GITHUB_REPOSITORY", "")
87
+ run_id = env.get("GITHUB_RUN_ID", "")
88
+ out = {
89
+ "platform": "github-actions" if env.get("GITHUB_ACTIONS") == "true" else "local",
90
+ "repository": repo,
91
+ "runId": run_id,
92
+ "runAttempt": env.get("GITHUB_RUN_ATTEMPT", ""),
93
+ "url": f"{server}/{repo}/actions/runs/{run_id}" if repo and run_id else "",
94
+ "workflowRef": env.get("GITHUB_WORKFLOW_REF", ""),
95
+ "workflowSha": env.get("GITHUB_WORKFLOW_SHA", ""),
96
+ "eventName": env.get("GITHUB_EVENT_NAME", ""),
97
+ "actor": env.get("GITHUB_ACTOR", ""),
98
+ "triggeringActor": env.get("GITHUB_TRIGGERING_ACTOR", ""),
99
+ "runnerEnvironment": env.get("RUNNER_ENVIRONMENT", ""),
100
+ }
101
+ return {k: v for k, v in out.items() if v}
102
+
103
+
104
+ def write_json(path: str | Path, obj: Any) -> None:
105
+ path = Path(path)
106
+ path.parent.mkdir(parents=True, exist_ok=True)
107
+ path.write_text(json.dumps(obj, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
108
+
109
+
110
+ def obaaf_version() -> str:
111
+ return __version__
@@ -0,0 +1,143 @@
1
+ """The binary manifest (G2.1, item 2.6).
2
+
3
+ A manifest records every vendored binary, SDK, engine build and plugin with its
4
+ source, version and digest. `check` fails on any unrecorded change: a new file,
5
+ a changed file or a missing file under a tracked path. Symbolic links are
6
+ recorded with their targets, so replacing a file with a link, or retargeting a
7
+ link, is a change too.
8
+
9
+ Components are declared in a small YAML file, for example:
10
+
11
+ components:
12
+ - id: steamworks-sdk
13
+ name: Steamworks SDK
14
+ version: "1.62"
15
+ source: Steamworks partner site
16
+ kind: sdk
17
+ paths: ["ThirdParty/Steamworks/**"]
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import fnmatch
23
+ import json
24
+ import os
25
+ from pathlib import Path
26
+
27
+ from obaaf import yamlutil
28
+ from obaaf.attest.common import RecordError, now, sha256_bytes, sha256_file, write_json
29
+
30
+ FORMAT = "obaaf-manifest/v0.1"
31
+ KINDS = {"sdk", "engine", "plugin", "middleware", "binary", "asset-pack", "tool"}
32
+
33
+
34
+ def load_components(path: str | Path) -> list[dict]:
35
+ data = yamlutil.load_file(path) or {}
36
+ components = data.get("components") if isinstance(data, dict) else None
37
+ if not isinstance(components, list) or not components:
38
+ raise RecordError(f"{path}: expected a 'components' list")
39
+ seen = set()
40
+ for comp in components:
41
+ for key in ("id", "name", "version", "source", "kind", "paths"):
42
+ if not comp.get(key):
43
+ raise RecordError(f"{path}: component {comp.get('id', '?')} is missing {key!r}")
44
+ if comp["kind"] not in KINDS:
45
+ raise RecordError(f"{path}: component {comp['id']} has unknown kind {comp['kind']!r}")
46
+ if comp["id"] in seen:
47
+ raise RecordError(f"{path}: duplicate component id {comp['id']}")
48
+ seen.add(comp["id"])
49
+ comp["version"] = str(comp["version"])
50
+ return components
51
+
52
+
53
+ def _matches(rel: str, patterns: list[str]) -> bool:
54
+ for pattern in patterns:
55
+ if fnmatch.fnmatchcase(rel, pattern):
56
+ return True
57
+ if pattern.endswith("/**") and (rel == pattern[:-3] or rel.startswith(pattern[:-2])):
58
+ return True
59
+ return False
60
+
61
+
62
+ def _tracked_files(root: Path, components: list[dict]) -> dict[str, str]:
63
+ """Map each tracked file (relative path) to its component id. First matching component wins."""
64
+ owned: dict[str, str] = {}
65
+ for path in sorted(root.rglob("*")):
66
+ if not (path.is_symlink() or path.is_file()) or ".git" in path.relative_to(root).parts:
67
+ continue
68
+ rel = path.relative_to(root).as_posix()
69
+ for comp in components:
70
+ if _matches(rel, comp["paths"]):
71
+ owned[rel] = comp["id"]
72
+ break
73
+ return owned
74
+
75
+
76
+ def create(root: str | Path, components_file: str | Path) -> dict:
77
+ root = Path(root)
78
+ components = load_components(components_file)
79
+ owned = _tracked_files(root, components)
80
+ empty = [c["id"] for c in components if c["id"] not in owned.values()]
81
+ if empty:
82
+ raise RecordError(f"no files match the paths of component(s): {', '.join(empty)}")
83
+ entries = [dict(_describe(root / rel), path=rel, component=comp) for rel, comp in sorted(owned.items())]
84
+ return {
85
+ "format": FORMAT,
86
+ "createdAt": now(),
87
+ "components": [
88
+ {k: c[k] for k in ("id", "name", "version", "source", "kind", "paths")} for c in components
89
+ ],
90
+ "entries": entries,
91
+ }
92
+
93
+
94
+ def _describe(path: Path) -> dict:
95
+ """The digest of a file, or of a symbolic link's target text (the link is never followed)."""
96
+ if path.is_symlink():
97
+ target = os.readlink(path)
98
+ return {"sha256": sha256_bytes(target.encode("utf-8", "surrogateescape")), "size": 0, "symlink": target}
99
+ return {"sha256": sha256_file(path), "size": path.stat().st_size}
100
+
101
+
102
+ def load(path: str | Path) -> dict:
103
+ manifest = json.loads(Path(path).read_text(encoding="utf-8"))
104
+ if manifest.get("format") != FORMAT:
105
+ raise RecordError(f"{path}: not an OBAAF manifest ({manifest.get('format')!r})")
106
+ return manifest
107
+
108
+
109
+ def check(root: str | Path, manifest: dict) -> list[str]:
110
+ """Differences between the files on disk and the manifest (empty when they match)."""
111
+ root = Path(root)
112
+ recorded = {e["path"]: e for e in manifest["entries"]}
113
+ current = _tracked_files(root, manifest["components"])
114
+ problems = []
115
+ for rel in sorted(set(recorded) - set(current)):
116
+ problems.append(f"missing: {rel} is in the manifest but not on disk")
117
+ for rel in sorted(set(current) - set(recorded)):
118
+ problems.append(f"unrecorded: {rel} ({current[rel]}) is not in the manifest")
119
+ for rel in sorted(set(current) & set(recorded)):
120
+ actual = _describe(root / rel)
121
+ expected = recorded[rel]
122
+ if ("symlink" in actual) != ("symlink" in expected):
123
+ what = "a symbolic link" if "symlink" in actual else "a regular file"
124
+ problems.append(f"changed: {rel} is now {what}")
125
+ elif "symlink" in actual and actual["symlink"] != expected["symlink"]:
126
+ problems.append(f"changed: {rel} now links to {actual['symlink']!r}, the manifest records "
127
+ f"{expected['symlink']!r}")
128
+ elif actual["sha256"] != expected["sha256"]:
129
+ problems.append(f"changed: {rel} has sha256 {actual['sha256']}, the manifest records {expected['sha256']}")
130
+ return problems
131
+
132
+
133
+ def sdk_versions(manifest: dict) -> list[dict]:
134
+ """Components that ship to players or build the game, for the build record."""
135
+ return [
136
+ {"name": c["name"], "id": c["id"], "version": c["version"], "kind": c["kind"], "reportedBy": "manifest"}
137
+ for c in manifest["components"]
138
+ if c["kind"] in ("sdk", "engine", "plugin", "middleware")
139
+ ]
140
+
141
+
142
+ def write(manifest: dict, out: str | Path) -> None:
143
+ write_json(out, manifest)