@arbiterforge/ca-pi 0.15.12 → 0.16.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.
- package/README.md +1 -1
- package/package.json +1 -1
- package/plugins/ca-pi/CHANGELOG.md +33 -0
- package/plugins/ca-pi/SKILLS.md +1 -1
- package/plugins/ca-pi/agents/infra-author.md +7 -1
- package/plugins/ca-pi/agents/scout.md +40 -8
- package/plugins/ca-pi/generated/command-catalog.json +1 -1
- package/plugins/ca-pi/helpers/artifacts/ca-artifact-darwin-amd64 +0 -0
- package/plugins/ca-pi/helpers/artifacts/ca-artifact-darwin-arm64 +0 -0
- package/plugins/ca-pi/helpers/artifacts/ca-artifact-linux-amd64 +0 -0
- package/plugins/ca-pi/helpers/artifacts/ca-artifact-linux-arm64 +0 -0
- package/plugins/ca-pi/helpers/artifacts/ca-artifact-windows-amd64.exe +0 -0
- package/plugins/ca-pi/helpers/artifacts/ca-artifact-windows-arm64.exe +0 -0
- package/plugins/ca-pi/helpers/artifacts/release.json +104 -6
- package/plugins/ca-pi/hooks/_activationlib.py +97 -13
- package/plugins/ca-pi/hooks/_arbiterstatelib.py +7 -2
- package/plugins/ca-pi/hooks/_artifactauthoritylib.py +313 -76
- package/plugins/ca-pi/hooks/_artifactlib.py +166 -2
- package/plugins/ca-pi/hooks/_artifactpromptlib.py +106 -2
- package/plugins/ca-pi/hooks/_bashguardlib.py +14 -0
- package/plugins/ca-pi/hooks/_contextreportlib.py +564 -6
- package/plugins/ca-pi/hooks/_contextselectlib.py +464 -0
- package/plugins/ca-pi/hooks/_contextsnapshotlib.py +999 -0
- package/plugins/ca-pi/hooks/_debughandofflib.py +767 -0
- package/plugins/ca-pi/hooks/_hooklib.py +1 -0
- package/plugins/ca-pi/hooks/_host.py +1 -1
- package/plugins/ca-pi/hooks/_intentlib.py +5 -5
- package/plugins/ca-pi/hooks/_protectedlib.py +3 -3
- package/plugins/ca-pi/hooks/_protectedstatelib.py +152 -2
- package/plugins/ca-pi/hooks/_provenancelib.py +604 -34
- package/plugins/ca-pi/hooks/_readinjectlib.py +131 -1
- package/plugins/ca-pi/hooks/artifact-authority.py +20 -1
- package/plugins/ca-pi/hooks/debug-handoff.py +85 -0
- package/plugins/ca-pi/hooks/doctor.py +4 -2
- package/plugins/ca-pi/hooks/hostapi.py +1 -1
- package/plugins/ca-pi/hooks/init-codearbiter.py +143 -15
- package/plugins/ca-pi/hooks/prompt-submit.py +14 -0
- package/plugins/ca-pi/hooks/session-start.py +26 -7
- package/plugins/ca-pi/includes/author-tdd-workflow.md +16 -4
- package/plugins/ca-pi/includes/helper-invocation.md +128 -0
- package/plugins/ca-pi/includes/pi-host-notes.md +13 -0
- package/plugins/ca-pi/includes/routing-table.md +1 -1
- package/plugins/ca-pi/package.json +1 -1
- package/plugins/ca-pi/routines/INDEX.md +1 -1
- package/plugins/ca-pi/routines/commit-gate/SKILL.md +5 -3
- package/plugins/ca-pi/routines/context-check/SKILL.md +103 -17
- package/plugins/ca-pi/routines/context-creation/SKILL.md +290 -54
- package/plugins/ca-pi/routines/debug/SKILL.md +303 -55
- package/plugins/ca-pi/routines/debug/references/debug-handoff.schema.json +879 -0
- package/plugins/ca-pi/routines/debug/references/handoff.md +155 -0
- package/plugins/ca-pi/routines/decompose/SKILL.md +13 -0
- package/plugins/ca-pi/routines/subagent-driven-development/SKILL.md +34 -0
- package/plugins/ca-pi/routines/tdd/SKILL.md +91 -17
- package/plugins/ca-pi/skills/ca-commit/SKILL.md +5 -3
- package/plugins/ca-pi/skills/ca-context-check/SKILL.md +103 -17
- package/plugins/ca-pi/skills/ca-create-context/SKILL.md +290 -54
- package/plugins/ca-pi/skills/ca-debug/SKILL.md +303 -55
- package/plugins/ca-pi/skills/ca-decompose/SKILL.md +13 -0
- package/plugins/ca-pi/skills/ca-feature/SKILL.md +1 -1
- package/plugins/ca-pi/skills/ca-fix/SKILL.md +36 -3
- package/plugins/ca-pi/skills/ca-init/SKILL.md +19 -0
- package/plugins/ca-pi/skills/ca-review/SKILL.md +33 -4
- package/plugins/ca-pi/skills/ca-status/SKILL.md +5 -0
- package/plugins/ca-pi/skills/ca-task/SKILL.md +55 -8
|
@@ -0,0 +1,999 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# codeArbiter -- inert, bounded source digest acquisition for scout snapshots.
|
|
3
|
+
#
|
|
4
|
+
# Public API: digest_file(root, relative_path, *, scope_paths, max_bytes,
|
|
5
|
+
# denied_paths) -> dict with path, digest_method, digest.
|
|
6
|
+
# DigestError(code) reports a bounded, code-owned failure without source bytes.
|
|
7
|
+
# Read only approved regular files beneath the actual worktree. Never invoke Git,
|
|
8
|
+
# filters, repository helpers, or network. Identity checks are cooperative drift
|
|
9
|
+
# checks, not protection against a hostile same-user ABA replacement.
|
|
10
|
+
# context_snapshot(start, *, source_paths, source_scopes, membership,
|
|
11
|
+
# target_paths, allowed_effect_paths) -> dict
|
|
12
|
+
# compare_context_snapshot(saved, start, *, boundary) -> dict
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import hashlib
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
import stat
|
|
21
|
+
import subprocess
|
|
22
|
+
import threading
|
|
23
|
+
|
|
24
|
+
import _contextreportlib as _report
|
|
25
|
+
from _gitexec import git_executable, root_bound_git_env
|
|
26
|
+
|
|
27
|
+
DIGEST_METHOD = 'sha256-raw'
|
|
28
|
+
DEFAULT_MAX_BYTES = 1024 * 1024
|
|
29
|
+
HARD_MAX_BYTES = 16 * 1024 * 1024
|
|
30
|
+
_CHUNK_BYTES = 64 * 1024
|
|
31
|
+
_SENSITIVE_NAMES = frozenset({'.env', '.ssh', '.aws', '.gnupg', '.npmrc', '.pypirc', 'credentials',
|
|
32
|
+
'credentials.json', 'id_rsa', 'id_ed25519',
|
|
33
|
+
'known_hosts', 'secrets.json'})
|
|
34
|
+
_SENSITIVE_SUFFIXES = ('.pem', '.key', '.p12', '.pfx')
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class DigestError(ValueError):
|
|
38
|
+
"""No usable identity was acquired; diagnostics never include file content."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, code):
|
|
41
|
+
self.code = code
|
|
42
|
+
super().__init__(code)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class MembershipError(ValueError):
|
|
46
|
+
"""No safe, bounded membership identity could be acquired."""
|
|
47
|
+
|
|
48
|
+
def __init__(self, code):
|
|
49
|
+
self.code = code
|
|
50
|
+
super().__init__(code)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# Closed acquisition vocabulary, deliberately narrower than report transport.
|
|
54
|
+
MEMBERSHIP_PREDICATES = frozenset({'manifests', 'instructions', 'configuration',
|
|
55
|
+
'tests', 'infrastructure', 'security', 'data'})
|
|
56
|
+
MEMBERSHIP_METHOD = 'sha256-membership-v1'
|
|
57
|
+
HARD_MAX_FILES = 4096
|
|
58
|
+
HARD_MAX_DIRECTORIES = 512
|
|
59
|
+
SOURCE_SNAPSHOT_METHOD = 'sha256-source-snapshot-v1'
|
|
60
|
+
_MAX_SNAPSHOT_PATHS = 64
|
|
61
|
+
_MAX_GIT_OUTPUT = 4 * 1024 * 1024
|
|
62
|
+
_GIT_TIMEOUT = 8
|
|
63
|
+
_MANIFESTS = frozenset({'package.json', 'pyproject.toml', 'cargo.toml', 'go.mod',
|
|
64
|
+
'pom.xml', 'build.gradle', 'build.gradle.kts', 'requirements.txt',
|
|
65
|
+
'pipfile', 'gemfile', 'composer.json'})
|
|
66
|
+
_INSTRUCTIONS = frozenset({'agents.md', 'claude.md', 'gemini.md', 'copilot-instructions.md'})
|
|
67
|
+
_CONFIGURATION = frozenset({'.editorconfig', '.gitattributes', 'tsconfig.json',
|
|
68
|
+
'pytest.ini', 'tox.ini', 'setup.cfg', 'mypy.ini'})
|
|
69
|
+
_INFRASTRUCTURE = frozenset({'dockerfile', 'compose.yaml', 'compose.yml'})
|
|
70
|
+
_SECURITY = frozenset({'security.md', 'codeowners', 'dependabot.yml', 'dependabot.yaml'})
|
|
71
|
+
_DATA = frozenset({'schema.sql', 'schema.prisma'})
|
|
72
|
+
_BOUNDARY_DIRECTORIES = {'tests': frozenset({'tests', 'test', '__tests__'}),
|
|
73
|
+
'infrastructure': frozenset({'infra', 'infrastructure', 'workflows'}),
|
|
74
|
+
'security': frozenset({'security'}),
|
|
75
|
+
'data': frozenset({'migrations', 'schemas'})}
|
|
76
|
+
_VENDOR_DIRECTORIES = frozenset({'vendor', 'node_modules', 'third_party'})
|
|
77
|
+
_GENERATED_DIRECTORIES = frozenset({'generated', 'dist', 'build', '__pycache__', 'target'})
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _membership_path(value, *, root=False):
|
|
81
|
+
try:
|
|
82
|
+
return _report._path(value, 'path', root=root)
|
|
83
|
+
except _report.ReportError:
|
|
84
|
+
raise MembershipError('UNSAFE_PATH') from None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _membership_match(predicate, path, directory):
|
|
88
|
+
name = path.rsplit('/', 1)[-1].casefold()
|
|
89
|
+
if directory:
|
|
90
|
+
return name in _BOUNDARY_DIRECTORIES.get(predicate, ())
|
|
91
|
+
if predicate == 'manifests':
|
|
92
|
+
return name in _MANIFESTS or name.endswith(('.csproj', '.fsproj', '.vbproj'))
|
|
93
|
+
if predicate == 'instructions':
|
|
94
|
+
return name in _INSTRUCTIONS
|
|
95
|
+
if predicate == 'configuration':
|
|
96
|
+
return name in _CONFIGURATION
|
|
97
|
+
if predicate == 'tests':
|
|
98
|
+
return ((name.startswith('test_') and name.endswith('.py')) or
|
|
99
|
+
name.endswith(('.test.ts', '.test.tsx', '.test.js', '.test.jsx',
|
|
100
|
+
'.spec.ts', '.spec.js')))
|
|
101
|
+
if predicate == 'infrastructure':
|
|
102
|
+
return name in _INFRASTRUCTURE or name.endswith(('.tf', '.tfvars'))
|
|
103
|
+
if predicate == 'security':
|
|
104
|
+
return name in _SECURITY
|
|
105
|
+
if predicate == 'data':
|
|
106
|
+
return name in _DATA or name.endswith(('.sql', '.prisma'))
|
|
107
|
+
return False
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _membership_info(path):
|
|
111
|
+
try:
|
|
112
|
+
info = path.lstat()
|
|
113
|
+
except OSError:
|
|
114
|
+
return None
|
|
115
|
+
if (stat.S_ISLNK(info.st_mode) or
|
|
116
|
+
getattr(info, 'st_file_attributes', 0) &
|
|
117
|
+
getattr(stat, 'FILE_ATTRIBUTE_REPARSE_POINT', 0)):
|
|
118
|
+
return None
|
|
119
|
+
return info
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _repository_boundary(directory):
|
|
123
|
+
"""A nested .git marker is a boundary; its contents are never read."""
|
|
124
|
+
try:
|
|
125
|
+
marker = (directory / '.git').lstat()
|
|
126
|
+
except FileNotFoundError:
|
|
127
|
+
return None
|
|
128
|
+
except OSError:
|
|
129
|
+
return 'unreadable'
|
|
130
|
+
if (stat.S_ISLNK(marker.st_mode) or
|
|
131
|
+
getattr(marker, 'st_file_attributes', 0) &
|
|
132
|
+
getattr(stat, 'FILE_ATTRIBUTE_REPARSE_POINT', 0)):
|
|
133
|
+
return 'unreadable'
|
|
134
|
+
if stat.S_ISDIR(marker.st_mode):
|
|
135
|
+
return 'nested_repository'
|
|
136
|
+
if stat.S_ISREG(marker.st_mode):
|
|
137
|
+
return 'submodule'
|
|
138
|
+
return 'unreadable'
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _scope_boundary(actual_root, relative):
|
|
142
|
+
"""Classify every ancestor before constructing a scoped traversal root."""
|
|
143
|
+
if relative == '.':
|
|
144
|
+
return None
|
|
145
|
+
current = actual_root
|
|
146
|
+
for part in relative.split('/'):
|
|
147
|
+
current = current / part
|
|
148
|
+
info = _membership_info(current)
|
|
149
|
+
if info is None or not stat.S_ISDIR(info.st_mode):
|
|
150
|
+
return 'unreadable'
|
|
151
|
+
folded = part.casefold()
|
|
152
|
+
if folded in _VENDOR_DIRECTORIES:
|
|
153
|
+
return 'vendor'
|
|
154
|
+
if folded in _GENERATED_DIRECTORIES:
|
|
155
|
+
return 'generated'
|
|
156
|
+
marker = _repository_boundary(current)
|
|
157
|
+
if marker:
|
|
158
|
+
return marker
|
|
159
|
+
return None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _validated_path(value, *, root=False):
|
|
163
|
+
try:
|
|
164
|
+
return _report._path(value, 'path', root=root)
|
|
165
|
+
except _report.ReportError:
|
|
166
|
+
raise DigestError('UNSAFE_PATH') from None
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _identity(info, *, content):
|
|
170
|
+
core = (info.st_dev, info.st_ino, info.st_mode)
|
|
171
|
+
if content:
|
|
172
|
+
return core + (info.st_size, info.st_mtime_ns, info.st_ctime_ns)
|
|
173
|
+
return core
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _components(root, parts):
|
|
177
|
+
"""Collect path identities without following an in-worktree link or reparse point."""
|
|
178
|
+
result = []
|
|
179
|
+
for index in range(len(parts) + 1):
|
|
180
|
+
path = root.joinpath(*parts[:index])
|
|
181
|
+
try:
|
|
182
|
+
info = path.lstat()
|
|
183
|
+
except OSError:
|
|
184
|
+
raise DigestError('UNREADABLE') from None
|
|
185
|
+
if stat.S_ISLNK(info.st_mode) or (
|
|
186
|
+
getattr(info, 'st_file_attributes', 0) &
|
|
187
|
+
getattr(stat, 'FILE_ATTRIBUTE_REPARSE_POINT', 0)
|
|
188
|
+
):
|
|
189
|
+
raise DigestError('UNSAFE_LINK')
|
|
190
|
+
final = index == len(parts)
|
|
191
|
+
if final:
|
|
192
|
+
if not stat.S_ISREG(info.st_mode):
|
|
193
|
+
raise DigestError('NOT_REGULAR')
|
|
194
|
+
elif not stat.S_ISDIR(info.st_mode):
|
|
195
|
+
raise DigestError('NOT_REGULAR')
|
|
196
|
+
result.append(_identity(info, content=final))
|
|
197
|
+
return result
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def digest_file(root, relative_path, *, scope_paths=('.',),
|
|
201
|
+
max_bytes=DEFAULT_MAX_BYTES, denied_paths=()):
|
|
202
|
+
"""Return an explicit SHA-256 identity of bounded raw bytes, or raise.
|
|
203
|
+
|
|
204
|
+
`root` is a caller-trusted worktree, resolved before path construction.
|
|
205
|
+
`relative_path`, scope and deny paths use the report envelope's lexical
|
|
206
|
+
grammar. Missing, short, oversized, changing or nonregular files never
|
|
207
|
+
yield a digest. No Git normalization or filter is consulted.
|
|
208
|
+
"""
|
|
209
|
+
path = _validated_path(relative_path)
|
|
210
|
+
if type(max_bytes) is not int or not 0 <= max_bytes <= HARD_MAX_BYTES:
|
|
211
|
+
raise DigestError('BYTE_LIMIT')
|
|
212
|
+
if type(scope_paths) not in (tuple, list) or not scope_paths:
|
|
213
|
+
raise DigestError('OUT_OF_SCOPE')
|
|
214
|
+
scopes = tuple(_validated_path(value, root=True) for value in scope_paths)
|
|
215
|
+
if not any(_report._within(path, scope) for scope in scopes):
|
|
216
|
+
raise DigestError('OUT_OF_SCOPE')
|
|
217
|
+
if type(denied_paths) not in (tuple, list):
|
|
218
|
+
raise DigestError('UNSAFE_PATH')
|
|
219
|
+
denied = tuple(_validated_path(value, root=True) for value in denied_paths)
|
|
220
|
+
parts = path.split('/')
|
|
221
|
+
if (any(part.casefold() in _SENSITIVE_NAMES or
|
|
222
|
+
part.casefold().startswith('.env.') or
|
|
223
|
+
part.casefold().endswith(_SENSITIVE_SUFFIXES) for part in parts)
|
|
224
|
+
or any(_report._within(path, value) for value in denied)):
|
|
225
|
+
raise DigestError('SENSITIVE_PATH')
|
|
226
|
+
try:
|
|
227
|
+
actual_root = Path(root).resolve(strict=True)
|
|
228
|
+
if not actual_root.is_dir():
|
|
229
|
+
raise DigestError('UNREADABLE')
|
|
230
|
+
except (OSError, RuntimeError, TypeError, ValueError):
|
|
231
|
+
raise DigestError('UNREADABLE') from None
|
|
232
|
+
candidate = actual_root.joinpath(*parts)
|
|
233
|
+
before = _components(actual_root, parts)
|
|
234
|
+
try:
|
|
235
|
+
candidate.resolve(strict=True).relative_to(actual_root)
|
|
236
|
+
except (OSError, RuntimeError, ValueError):
|
|
237
|
+
raise DigestError('UNSAFE_LINK') from None
|
|
238
|
+
if before[-1][3] > max_bytes:
|
|
239
|
+
raise DigestError('BYTE_LIMIT')
|
|
240
|
+
flags = os.O_RDONLY | getattr(os, 'O_BINARY', 0) | getattr(os, 'O_NOFOLLOW', 0)
|
|
241
|
+
try:
|
|
242
|
+
descriptor = os.open(candidate, flags)
|
|
243
|
+
except OSError:
|
|
244
|
+
raise DigestError('UNREADABLE') from None
|
|
245
|
+
try:
|
|
246
|
+
opened = os.fstat(descriptor)
|
|
247
|
+
if not stat.S_ISREG(opened.st_mode):
|
|
248
|
+
raise DigestError('NOT_REGULAR')
|
|
249
|
+
# Windows descriptor ctime can differ from path ctime for the same file.
|
|
250
|
+
# Compare it path-to-path below; descriptor identity still binds the open.
|
|
251
|
+
if _identity(opened, content=True)[:5] != before[-1][:5]:
|
|
252
|
+
raise DigestError('SOURCE_CHANGED')
|
|
253
|
+
digest = hashlib.sha256()
|
|
254
|
+
total = 0
|
|
255
|
+
while True:
|
|
256
|
+
chunk = os.read(descriptor, min(_CHUNK_BYTES, max_bytes - total + 1))
|
|
257
|
+
if not chunk:
|
|
258
|
+
break
|
|
259
|
+
total += len(chunk)
|
|
260
|
+
if total > max_bytes:
|
|
261
|
+
raise DigestError('BYTE_LIMIT')
|
|
262
|
+
digest.update(chunk)
|
|
263
|
+
if total != opened.st_size:
|
|
264
|
+
raise DigestError('PARTIAL_READ')
|
|
265
|
+
# Stable timestamps can conceal a same-size rewrite during the read.
|
|
266
|
+
# Re-read the bound descriptor before accepting its first byte digest.
|
|
267
|
+
os.lseek(descriptor, 0, os.SEEK_SET)
|
|
268
|
+
confirmation = hashlib.sha256()
|
|
269
|
+
confirmed = 0
|
|
270
|
+
while True:
|
|
271
|
+
chunk = os.read(descriptor, min(_CHUNK_BYTES, max_bytes - confirmed + 1))
|
|
272
|
+
if not chunk:
|
|
273
|
+
break
|
|
274
|
+
confirmed += len(chunk)
|
|
275
|
+
if confirmed > max_bytes:
|
|
276
|
+
raise DigestError('BYTE_LIMIT')
|
|
277
|
+
confirmation.update(chunk)
|
|
278
|
+
if confirmed != total or confirmation.digest() != digest.digest():
|
|
279
|
+
raise DigestError('SOURCE_CHANGED')
|
|
280
|
+
if _identity(os.fstat(descriptor), content=True)[:5] != before[-1][:5]:
|
|
281
|
+
raise DigestError('SOURCE_CHANGED')
|
|
282
|
+
if _components(actual_root, parts) != before:
|
|
283
|
+
raise DigestError('SOURCE_CHANGED')
|
|
284
|
+
try:
|
|
285
|
+
candidate.resolve(strict=True).relative_to(actual_root)
|
|
286
|
+
except (OSError, RuntimeError, ValueError):
|
|
287
|
+
raise DigestError('SOURCE_CHANGED') from None
|
|
288
|
+
return {'path': path, 'digest_method': DIGEST_METHOD, 'digest': digest.hexdigest()}
|
|
289
|
+
except OSError:
|
|
290
|
+
raise DigestError('UNREADABLE') from None
|
|
291
|
+
finally:
|
|
292
|
+
os.close(descriptor)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def membership_snapshot(root, predicate, *, scope_paths=('.',), excluded=(),
|
|
296
|
+
max_files=1024, max_directories=128):
|
|
297
|
+
"""Inventory code-owned path membership in caller-authorized directory scopes.
|
|
298
|
+
|
|
299
|
+
Exclusions are caller declarations, not verified ignore rules. Root .git
|
|
300
|
+
metadata is omitted. No file content, Git, helper, shell or network is used.
|
|
301
|
+
A digest identifies only a complete searched subset; any gap prevents a
|
|
302
|
+
broad negative claim. This does not bind T-008 worktree/HEAD identity.
|
|
303
|
+
"""
|
|
304
|
+
if type(predicate) is not str or predicate not in MEMBERSHIP_PREDICATES:
|
|
305
|
+
raise MembershipError('UNKNOWN_PREDICATE')
|
|
306
|
+
if (type(max_files) is not int or not 1 <= max_files <= HARD_MAX_FILES or
|
|
307
|
+
type(max_directories) is not int or not 1 <= max_directories <= HARD_MAX_DIRECTORIES):
|
|
308
|
+
raise MembershipError('INVALID_BOUND')
|
|
309
|
+
if type(scope_paths) not in (tuple, list) or not 1 <= len(scope_paths) <= _report.MAX_PATHS:
|
|
310
|
+
raise MembershipError('INVALID_SCOPE')
|
|
311
|
+
scopes = sorted(_membership_path(p, root=True) for p in scope_paths)
|
|
312
|
+
if len({p.casefold() for p in scopes}) != len(scopes) or any(
|
|
313
|
+
_report._within(a, b) or _report._within(b, a)
|
|
314
|
+
for index, a in enumerate(scopes) for b in scopes[index + 1:]):
|
|
315
|
+
raise MembershipError('OVERLAPPING_SCOPE')
|
|
316
|
+
if type(excluded) not in (tuple, list) or len(excluded) > _report.MAX_PATHS:
|
|
317
|
+
raise MembershipError('INVALID_EXCLUSION')
|
|
318
|
+
declared = {}
|
|
319
|
+
for entry in excluded:
|
|
320
|
+
if type(entry) is not dict or set(entry) != {'path', 'reason'}:
|
|
321
|
+
raise MembershipError('INVALID_EXCLUSION')
|
|
322
|
+
path = _membership_path(entry['path'], root=True)
|
|
323
|
+
if type(entry['reason']) is not str or entry['reason'] not in _report.EXCLUSIONS:
|
|
324
|
+
raise MembershipError('UNKNOWN_EXCLUSION')
|
|
325
|
+
if not any(_report._within(path, scope) for scope in scopes):
|
|
326
|
+
raise MembershipError('OUT_OF_SCOPE')
|
|
327
|
+
if path.casefold() in {p.casefold() for p in declared}:
|
|
328
|
+
raise MembershipError('DUPLICATE_EXCLUSION')
|
|
329
|
+
declared[path] = entry['reason']
|
|
330
|
+
paths = list(declared)
|
|
331
|
+
if any(_report._within(a, b) or _report._within(b, a)
|
|
332
|
+
for index, a in enumerate(paths) for b in paths[index + 1:]):
|
|
333
|
+
raise MembershipError('OVERLAPPING_EXCLUSION')
|
|
334
|
+
try:
|
|
335
|
+
actual_root = Path(root).resolve(strict=True)
|
|
336
|
+
if not actual_root.is_dir():
|
|
337
|
+
raise MembershipError('UNREADABLE_ROOT')
|
|
338
|
+
except (OSError, RuntimeError, TypeError, ValueError):
|
|
339
|
+
raise MembershipError('UNREADABLE_ROOT') from None
|
|
340
|
+
|
|
341
|
+
matches, dispositions = set(), dict(declared)
|
|
342
|
+
files = directories = 0
|
|
343
|
+
stack = list(reversed(scopes))
|
|
344
|
+
while stack:
|
|
345
|
+
relative = stack.pop()
|
|
346
|
+
if any(_report._within(relative, boundary) for boundary in dispositions):
|
|
347
|
+
continue
|
|
348
|
+
boundary = _scope_boundary(actual_root, relative)
|
|
349
|
+
if boundary:
|
|
350
|
+
dispositions[relative] = boundary
|
|
351
|
+
continue
|
|
352
|
+
path = actual_root if relative == '.' else actual_root.joinpath(*relative.split('/'))
|
|
353
|
+
info = _membership_info(path)
|
|
354
|
+
if info is None or not stat.S_ISDIR(info.st_mode):
|
|
355
|
+
dispositions[relative] = 'unreadable'
|
|
356
|
+
continue
|
|
357
|
+
directories += 1
|
|
358
|
+
if directories > max_directories:
|
|
359
|
+
raise MembershipError('BOUND_EXCEEDED')
|
|
360
|
+
try:
|
|
361
|
+
with os.scandir(path) as stream:
|
|
362
|
+
names = []
|
|
363
|
+
for entry in stream:
|
|
364
|
+
if relative == '.' and entry.name == '.git':
|
|
365
|
+
continue
|
|
366
|
+
names.append(entry.name)
|
|
367
|
+
if len(names) > max_files + max_directories:
|
|
368
|
+
raise MembershipError('BOUND_EXCEEDED')
|
|
369
|
+
except OSError:
|
|
370
|
+
dispositions[relative] = 'unreadable'
|
|
371
|
+
continue
|
|
372
|
+
if len({name.casefold() for name in names}) != len(names):
|
|
373
|
+
dispositions[relative] = 'unreadable'
|
|
374
|
+
continue
|
|
375
|
+
children = []
|
|
376
|
+
for name in sorted(names, key=lambda value: (value.casefold(), value)):
|
|
377
|
+
child = name if relative == '.' else relative + '/' + name
|
|
378
|
+
try:
|
|
379
|
+
_membership_path(child)
|
|
380
|
+
except MembershipError:
|
|
381
|
+
dispositions[relative] = 'unreadable'
|
|
382
|
+
break
|
|
383
|
+
if any(_report._within(child, boundary) for boundary in dispositions):
|
|
384
|
+
continue
|
|
385
|
+
child_info = _membership_info(path / name)
|
|
386
|
+
if child_info is None:
|
|
387
|
+
files += 1
|
|
388
|
+
dispositions[child] = 'unreadable'
|
|
389
|
+
elif stat.S_ISDIR(child_info.st_mode):
|
|
390
|
+
folded = name.casefold()
|
|
391
|
+
if folded in _VENDOR_DIRECTORIES:
|
|
392
|
+
dispositions[child] = 'vendor'
|
|
393
|
+
elif folded in _GENERATED_DIRECTORIES:
|
|
394
|
+
dispositions[child] = 'generated'
|
|
395
|
+
else:
|
|
396
|
+
if _membership_match(predicate, child, True):
|
|
397
|
+
matches.add(child)
|
|
398
|
+
children.append(child)
|
|
399
|
+
elif stat.S_ISREG(child_info.st_mode):
|
|
400
|
+
files += 1
|
|
401
|
+
if _membership_match(predicate, child, False):
|
|
402
|
+
matches.add(child)
|
|
403
|
+
else:
|
|
404
|
+
files += 1
|
|
405
|
+
dispositions[child] = 'unreadable'
|
|
406
|
+
if files > max_files:
|
|
407
|
+
raise MembershipError('BOUND_EXCEEDED')
|
|
408
|
+
else:
|
|
409
|
+
stack.extend(reversed(children))
|
|
410
|
+
after = _membership_info(path)
|
|
411
|
+
if after is None or _identity(after, content=True) != _identity(info, content=True):
|
|
412
|
+
dispositions[relative] = 'unreadable'
|
|
413
|
+
continue
|
|
414
|
+
# An unsafe spelling means no membership claim for this directory.
|
|
415
|
+
matches.difference_update({p for p in matches if _report._within(p, relative)})
|
|
416
|
+
|
|
417
|
+
ordered = sorted(matches, key=lambda value: (value.casefold(), value))
|
|
418
|
+
gaps = [{'path': path, 'reason': reason} for path, reason in sorted(
|
|
419
|
+
dispositions.items(), key=lambda item: (item[0].casefold(), item[0]))]
|
|
420
|
+
complete = not any(e['reason'] in ('sparse', 'unreadable', 'outside_authorized_scope')
|
|
421
|
+
for e in gaps)
|
|
422
|
+
identity = None
|
|
423
|
+
if complete:
|
|
424
|
+
payload = json.dumps({'method': MEMBERSHIP_METHOD, 'predicate': predicate,
|
|
425
|
+
'scope_paths': scopes, 'matches': ordered, 'excluded': gaps},
|
|
426
|
+
sort_keys=True, separators=(',', ':'), ensure_ascii=True).encode('utf-8')
|
|
427
|
+
identity = hashlib.sha256(payload).hexdigest()
|
|
428
|
+
return {'status': 'complete' if complete else 'not_inspected',
|
|
429
|
+
'predicate': predicate, 'scope_paths': scopes, 'matches': ordered,
|
|
430
|
+
'excluded': gaps, 'digest_method': MEMBERSHIP_METHOD, 'digest': identity,
|
|
431
|
+
'negative_proof': complete and not gaps and not ordered}
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
class SnapshotError(ValueError):
|
|
435
|
+
"""No complete source/target snapshot was acquired; code contains no source bytes."""
|
|
436
|
+
|
|
437
|
+
def __init__(self, code):
|
|
438
|
+
self.code = code
|
|
439
|
+
super().__init__(code)
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _snapshot_paths(values, *, root=False):
|
|
443
|
+
if type(values) not in (list, tuple) or len(values) > _MAX_SNAPSHOT_PATHS:
|
|
444
|
+
raise SnapshotError('INVALID_PATHS')
|
|
445
|
+
try:
|
|
446
|
+
paths = [_report._path(value, 'path', root=root) for value in values]
|
|
447
|
+
except _report.ReportError:
|
|
448
|
+
raise SnapshotError('INVALID_PATHS') from None
|
|
449
|
+
if len({value.casefold() for value in paths}) != len(paths):
|
|
450
|
+
raise SnapshotError('DUPLICATE_PATH')
|
|
451
|
+
return sorted(paths)
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _snapshot_membership(values):
|
|
455
|
+
if type(values) not in (list, tuple) or len(values) > 16:
|
|
456
|
+
raise SnapshotError('INVALID_MEMBERSHIP')
|
|
457
|
+
result = []
|
|
458
|
+
for item in values:
|
|
459
|
+
if type(item) not in (list, tuple) or len(item) != 2 or item[0] not in MEMBERSHIP_PREDICATES:
|
|
460
|
+
raise SnapshotError('INVALID_MEMBERSHIP')
|
|
461
|
+
scopes = _snapshot_paths(item[1], root=True)
|
|
462
|
+
if not scopes:
|
|
463
|
+
raise SnapshotError('INVALID_MEMBERSHIP')
|
|
464
|
+
result.append([item[0], scopes])
|
|
465
|
+
if len({json.dumps(item) for item in result}) != len(result):
|
|
466
|
+
raise SnapshotError('INVALID_MEMBERSHIP')
|
|
467
|
+
return sorted(result, key=lambda item: (item[0], item[1]))
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _git_read(root, args, *, limit=_MAX_GIT_OUTPUT, optional=False):
|
|
471
|
+
"""Fixed inert Git read with no shell, filters, hooks, fsmonitor or auto writes."""
|
|
472
|
+
env = root_bound_git_env()
|
|
473
|
+
for name in tuple(env):
|
|
474
|
+
if name.startswith('GIT_CONFIG_') or name in ('GIT_CONFIG', 'GIT_EXTERNAL_DIFF',
|
|
475
|
+
'GIT_DIFF_OPTS', 'GIT_ASKPASS',
|
|
476
|
+
'GIT_TRACE', 'GIT_NAMESPACE',
|
|
477
|
+
'GIT_EXEC_PATH'):
|
|
478
|
+
env.pop(name, None)
|
|
479
|
+
env.update({'GIT_OPTIONAL_LOCKS': '0', 'GIT_NO_REPLACE_OBJECTS': '1',
|
|
480
|
+
'GIT_CONFIG_NOSYSTEM': '1', 'GIT_CONFIG_GLOBAL': os.devnull,
|
|
481
|
+
'GIT_TERMINAL_PROMPT': '0', 'GIT_NO_LAZY_FETCH': '1',
|
|
482
|
+
'GIT_PROTOCOL_FROM_USER': '0'})
|
|
483
|
+
argv = [git_executable(), '-C', str(root), '--literal-pathspecs',
|
|
484
|
+
'-c', 'core.fsmonitor=false', *args]
|
|
485
|
+
try:
|
|
486
|
+
process = subprocess.Popen(argv, stdin=subprocess.DEVNULL,
|
|
487
|
+
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, env=env)
|
|
488
|
+
except (OSError, RuntimeError):
|
|
489
|
+
raise SnapshotError('GIT_UNAVAILABLE') from None
|
|
490
|
+
output = []
|
|
491
|
+
def read_bounded():
|
|
492
|
+
output.append(process.stdout.read(limit + 1))
|
|
493
|
+
worker = threading.Thread(target=read_bounded, daemon=True)
|
|
494
|
+
worker.start()
|
|
495
|
+
worker.join(_GIT_TIMEOUT)
|
|
496
|
+
if worker.is_alive():
|
|
497
|
+
process.kill()
|
|
498
|
+
worker.join(_GIT_TIMEOUT)
|
|
499
|
+
process.wait()
|
|
500
|
+
process.stdout.close()
|
|
501
|
+
raise SnapshotError('GIT_UNAVAILABLE')
|
|
502
|
+
if not output or len(output[0]) > limit:
|
|
503
|
+
process.kill()
|
|
504
|
+
process.wait()
|
|
505
|
+
process.stdout.close()
|
|
506
|
+
raise SnapshotError('GIT_BOUND_EXCEEDED')
|
|
507
|
+
try:
|
|
508
|
+
code = process.wait(timeout=_GIT_TIMEOUT)
|
|
509
|
+
except subprocess.TimeoutExpired:
|
|
510
|
+
process.kill()
|
|
511
|
+
process.wait()
|
|
512
|
+
process.stdout.close()
|
|
513
|
+
raise SnapshotError('GIT_UNAVAILABLE') from None
|
|
514
|
+
process.stdout.close()
|
|
515
|
+
if code and not optional:
|
|
516
|
+
raise SnapshotError('GIT_UNAVAILABLE')
|
|
517
|
+
return code, output[0]
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def _git_text(root, args, *, optional=False):
|
|
521
|
+
code, output = _git_read(root, args, optional=optional)
|
|
522
|
+
try:
|
|
523
|
+
return code, output.decode('utf-8', 'strict').strip()
|
|
524
|
+
except UnicodeDecodeError:
|
|
525
|
+
raise SnapshotError('GIT_UNAVAILABLE') from None
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _worktree_identity(start):
|
|
529
|
+
try:
|
|
530
|
+
start = Path(start).resolve(strict=True)
|
|
531
|
+
if not start.is_dir():
|
|
532
|
+
raise SnapshotError('UNREADABLE_ROOT')
|
|
533
|
+
except (OSError, RuntimeError, TypeError, ValueError):
|
|
534
|
+
raise SnapshotError('UNREADABLE_ROOT') from None
|
|
535
|
+
_, locations = _git_text(start, ['rev-parse', '--path-format=absolute',
|
|
536
|
+
'--show-toplevel', '--absolute-git-dir', '--git-common-dir'])
|
|
537
|
+
lines = locations.splitlines()
|
|
538
|
+
if len(lines) != 3:
|
|
539
|
+
raise SnapshotError('GIT_UNAVAILABLE')
|
|
540
|
+
try:
|
|
541
|
+
root, git_dir, common_dir = (str(Path(value).resolve(strict=True)) for value in lines)
|
|
542
|
+
if not start.is_relative_to(Path(root)):
|
|
543
|
+
raise SnapshotError('UNREADABLE_ROOT')
|
|
544
|
+
if not Path(git_dir).is_dir() or not Path(common_dir).is_dir():
|
|
545
|
+
raise SnapshotError('GIT_UNAVAILABLE')
|
|
546
|
+
except (OSError, RuntimeError, ValueError):
|
|
547
|
+
raise SnapshotError('GIT_UNAVAILABLE') from None
|
|
548
|
+
head_code, head = _git_text(root, ['rev-parse', '--verify', 'HEAD'], optional=True)
|
|
549
|
+
branch_code, branch = _git_text(root, ['symbolic-ref', '-q', '--short', 'HEAD'], optional=True)
|
|
550
|
+
head = head if head_code == 0 else None
|
|
551
|
+
branch = branch if branch_code == 0 else None
|
|
552
|
+
if head is not None and (len(head) not in (40, 64) or
|
|
553
|
+
any(ch not in '0123456789abcdef' for ch in head)):
|
|
554
|
+
raise SnapshotError('GIT_UNAVAILABLE')
|
|
555
|
+
upstream = {'status': 'none', 'ref': None, 'relation': 'unknown',
|
|
556
|
+
'freshness': 'local_only'}
|
|
557
|
+
if branch:
|
|
558
|
+
upstream['status'] = 'unknown'
|
|
559
|
+
code, ref = _git_text(root, ['rev-parse', '--symbolic-full-name',
|
|
560
|
+
'@{upstream}'], optional=True)
|
|
561
|
+
if code == 0:
|
|
562
|
+
if not ref.startswith('refs/') or any(ch not in
|
|
563
|
+
'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789/._-'
|
|
564
|
+
for ch in ref):
|
|
565
|
+
raise SnapshotError('GIT_UNAVAILABLE')
|
|
566
|
+
_, oid = _git_text(root, ['rev-parse', '--verify', ref])
|
|
567
|
+
relation = 'unknown'
|
|
568
|
+
if head:
|
|
569
|
+
_, counts = _git_text(root, ['rev-list', '--left-right', '--count',
|
|
570
|
+
head + '...' + oid])
|
|
571
|
+
parts = counts.split()
|
|
572
|
+
if len(parts) != 2 or not all(part.isdecimal() for part in parts):
|
|
573
|
+
raise SnapshotError('GIT_UNAVAILABLE')
|
|
574
|
+
ahead, behind = map(int, parts)
|
|
575
|
+
relation = ('same' if not ahead and not behind else
|
|
576
|
+
'ahead' if ahead and not behind else
|
|
577
|
+
'behind' if behind and not ahead else 'diverged')
|
|
578
|
+
upstream = {'status': 'known_local', 'ref': ref, 'relation': relation,
|
|
579
|
+
'freshness': 'local_only'}
|
|
580
|
+
return {'root': root, 'git_dir': git_dir, 'common_dir': common_dir,
|
|
581
|
+
'head': head, 'branch': branch, 'detached': branch is None and head is not None,
|
|
582
|
+
'upstream': upstream,
|
|
583
|
+
'fork': {'status': 'unknown', 'basis': 'local_refs_only'}}
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def _git_entries(root, args, *, tree=False):
|
|
587
|
+
_, data = _git_read(root, args)
|
|
588
|
+
result = {}
|
|
589
|
+
for raw in data.split(b'\0'):
|
|
590
|
+
if not raw:
|
|
591
|
+
continue
|
|
592
|
+
try:
|
|
593
|
+
prefix, path = raw.split(b'\t', 1)
|
|
594
|
+
path = _report._path(path.decode('utf-8', 'strict'), 'path')
|
|
595
|
+
fields = prefix.decode('ascii').split()
|
|
596
|
+
except (ValueError, UnicodeDecodeError, _report.ReportError):
|
|
597
|
+
raise SnapshotError('GIT_UNAVAILABLE') from None
|
|
598
|
+
if (len(fields) != 3 or fields[1] != 'blob') if tree else (len(fields) != 3 or fields[2] != '0'):
|
|
599
|
+
raise SnapshotError('GIT_UNAVAILABLE')
|
|
600
|
+
oid = fields[2] if tree else fields[1]
|
|
601
|
+
if path in result or len(oid) not in (40, 64) or any(
|
|
602
|
+
ch not in '0123456789abcdef' for ch in oid):
|
|
603
|
+
raise SnapshotError('GIT_UNAVAILABLE')
|
|
604
|
+
result[path] = {'mode': fields[0], 'oid': oid}
|
|
605
|
+
return result
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def _preimage(root, path):
|
|
609
|
+
candidate = Path(root)
|
|
610
|
+
for part in path.split('/'):
|
|
611
|
+
candidate = candidate / part
|
|
612
|
+
try:
|
|
613
|
+
info = candidate.lstat()
|
|
614
|
+
except FileNotFoundError:
|
|
615
|
+
return {'status': 'absent', 'digest_method': None, 'digest': None}
|
|
616
|
+
except OSError:
|
|
617
|
+
raise SnapshotError('UNREADABLE_TARGET') from None
|
|
618
|
+
if stat.S_ISLNK(info.st_mode) or getattr(info, 'st_file_attributes', 0) & getattr(
|
|
619
|
+
stat, 'FILE_ATTRIBUTE_REPARSE_POINT', 0):
|
|
620
|
+
raise SnapshotError('UNSAFE_TARGET')
|
|
621
|
+
try:
|
|
622
|
+
result = digest_file(root, path)
|
|
623
|
+
except DigestError:
|
|
624
|
+
raise SnapshotError('UNREADABLE_TARGET') from None
|
|
625
|
+
return {'status': 'file', 'digest_method': result['digest_method'],
|
|
626
|
+
'digest': result['digest']}
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def _member_info(root, path):
|
|
630
|
+
"""Recheck a T-007 match without following a changed ancestor or leaf."""
|
|
631
|
+
candidate = Path(root)
|
|
632
|
+
root_info = _membership_info(candidate)
|
|
633
|
+
if root_info is None or not stat.S_ISDIR(root_info.st_mode):
|
|
634
|
+
raise SnapshotError('MEMBERSHIP_UNKNOWN')
|
|
635
|
+
parts = path.split('/')
|
|
636
|
+
for index, part in enumerate(parts):
|
|
637
|
+
candidate = candidate / part
|
|
638
|
+
info = _membership_info(candidate)
|
|
639
|
+
if info is None:
|
|
640
|
+
raise SnapshotError('MEMBERSHIP_UNKNOWN')
|
|
641
|
+
if index < len(parts) - 1 and not stat.S_ISDIR(info.st_mode):
|
|
642
|
+
raise SnapshotError('MEMBERSHIP_UNKNOWN')
|
|
643
|
+
try:
|
|
644
|
+
candidate.resolve(strict=True).relative_to(Path(root))
|
|
645
|
+
except (OSError, RuntimeError, ValueError):
|
|
646
|
+
raise SnapshotError('MEMBERSHIP_UNKNOWN') from None
|
|
647
|
+
after = _membership_info(candidate)
|
|
648
|
+
if after is None or _identity(after, content=True) != _identity(info, content=True):
|
|
649
|
+
raise SnapshotError('SOURCE_CHANGED')
|
|
650
|
+
return info
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def _capture_context(start, selectors):
|
|
654
|
+
worktree = _worktree_identity(start)
|
|
655
|
+
root = worktree['root']
|
|
656
|
+
source_paths = set(selectors['source_paths'])
|
|
657
|
+
target_paths = selectors['target_paths']
|
|
658
|
+
effects = selectors['allowed_effect_paths']
|
|
659
|
+
membership = []
|
|
660
|
+
directory_checks = {}
|
|
661
|
+
for predicate, scopes in selectors['membership']:
|
|
662
|
+
try:
|
|
663
|
+
unexcluded = membership_snapshot(root, predicate, scope_paths=scopes)
|
|
664
|
+
except MembershipError:
|
|
665
|
+
raise SnapshotError('MEMBERSHIP_UNKNOWN') from None
|
|
666
|
+
if unexcluded['status'] != 'complete' or unexcluded['digest'] is None:
|
|
667
|
+
raise SnapshotError('MEMBERSHIP_UNKNOWN')
|
|
668
|
+
if any(_report._within(path, role) or _report._within(role, path)
|
|
669
|
+
for path in unexcluded['matches'] for role in target_paths + effects):
|
|
670
|
+
raise SnapshotError('OVERLAPPING_ROLE')
|
|
671
|
+
excluded = [{'path': path, 'reason': 'generated'} for path in target_paths + effects
|
|
672
|
+
if any(_report._within(path, scope) for scope in scopes)]
|
|
673
|
+
try:
|
|
674
|
+
result = membership_snapshot(root, predicate, scope_paths=scopes, excluded=excluded)
|
|
675
|
+
except MembershipError:
|
|
676
|
+
raise SnapshotError('MEMBERSHIP_UNKNOWN') from None
|
|
677
|
+
if result['status'] != 'complete' or result['digest'] is None:
|
|
678
|
+
raise SnapshotError('MEMBERSHIP_UNKNOWN')
|
|
679
|
+
for path in result['matches']:
|
|
680
|
+
if any(_report._within(path, role) or _report._within(role, path)
|
|
681
|
+
for role in target_paths + effects):
|
|
682
|
+
raise SnapshotError('OVERLAPPING_ROLE')
|
|
683
|
+
info = _member_info(root, path)
|
|
684
|
+
if stat.S_ISREG(info.st_mode):
|
|
685
|
+
source_paths.add(path)
|
|
686
|
+
elif stat.S_ISDIR(info.st_mode):
|
|
687
|
+
directory_checks[path] = _identity(info, content=True)
|
|
688
|
+
else:
|
|
689
|
+
raise SnapshotError('MEMBERSHIP_UNKNOWN')
|
|
690
|
+
membership.append(result)
|
|
691
|
+
if len(source_paths) > _MAX_SNAPSHOT_PATHS:
|
|
692
|
+
raise SnapshotError('BOUND_EXCEEDED')
|
|
693
|
+
for path in source_paths:
|
|
694
|
+
if any(_report._within(path, role) or _report._within(role, path)
|
|
695
|
+
for role in target_paths + effects):
|
|
696
|
+
raise SnapshotError('OVERLAPPING_ROLE')
|
|
697
|
+
files = {}
|
|
698
|
+
for path in sorted(source_paths):
|
|
699
|
+
try:
|
|
700
|
+
files[path] = digest_file(root, path, scope_paths=selectors['source_scopes'])
|
|
701
|
+
except DigestError:
|
|
702
|
+
raise SnapshotError('SOURCE_UNKNOWN') from None
|
|
703
|
+
wanted = sorted(files)
|
|
704
|
+
if wanted:
|
|
705
|
+
index = _git_entries(root, ['ls-files', '--stage', '-z', '--', *wanted])
|
|
706
|
+
tree = (_git_entries(root, ['ls-tree', '-r', '-z', 'HEAD', '--', *wanted], tree=True)
|
|
707
|
+
if worktree['head'] else {})
|
|
708
|
+
else:
|
|
709
|
+
index, tree = {}, {}
|
|
710
|
+
staged, untracked, raw_dirty = [], [], []
|
|
711
|
+
for path in wanted:
|
|
712
|
+
entry = index.get(path)
|
|
713
|
+
if entry is None:
|
|
714
|
+
untracked.append(path)
|
|
715
|
+
else:
|
|
716
|
+
if entry != tree.get(path):
|
|
717
|
+
staged.append(path)
|
|
718
|
+
_, blob = _git_read(root, ['cat-file', 'blob', entry['oid']],
|
|
719
|
+
limit=HARD_MAX_BYTES + 1)
|
|
720
|
+
if hashlib.sha256(blob).hexdigest() != files[path]['digest']:
|
|
721
|
+
raw_dirty.append(path)
|
|
722
|
+
for path, identity in directory_checks.items():
|
|
723
|
+
info = _member_info(root, path)
|
|
724
|
+
if not stat.S_ISDIR(info.st_mode) or _identity(info, content=True) != identity:
|
|
725
|
+
raise SnapshotError('SOURCE_CHANGED')
|
|
726
|
+
git_state = {'staged': staged, 'untracked': untracked, 'raw_dirty': raw_dirty,
|
|
727
|
+
'index': index, 'head_tree': tree}
|
|
728
|
+
source = {'digest_method': SOURCE_SNAPSHOT_METHOD, 'files': files,
|
|
729
|
+
'membership': membership, 'git_state': git_state}
|
|
730
|
+
payload = {'worktree': worktree, 'selectors': selectors, 'source': source}
|
|
731
|
+
source['digest'] = hashlib.sha256(json.dumps(payload, sort_keys=True, separators=(',', ':'),
|
|
732
|
+
ensure_ascii=True).encode('utf-8')).hexdigest()
|
|
733
|
+
targets = {path: _preimage(root, path) for path in target_paths}
|
|
734
|
+
return {'method': SOURCE_SNAPSHOT_METHOD, 'selectors': selectors,
|
|
735
|
+
'worktree': worktree, 'source': source, 'targets': targets,
|
|
736
|
+
'allowed_effects': effects}
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
def context_snapshot(start, *, source_paths=(), source_scopes=('.',), membership=(),
|
|
740
|
+
target_paths=(), allowed_effect_paths=()):
|
|
741
|
+
"""Bind declared external inputs separately from target and allowed output paths."""
|
|
742
|
+
selectors = {'source_paths': _snapshot_paths(source_paths),
|
|
743
|
+
'source_scopes': _snapshot_paths(source_scopes, root=True),
|
|
744
|
+
'membership': _snapshot_membership(membership),
|
|
745
|
+
'target_paths': _snapshot_paths(target_paths),
|
|
746
|
+
'allowed_effect_paths': _snapshot_paths(allowed_effect_paths)}
|
|
747
|
+
if not selectors['source_scopes']:
|
|
748
|
+
raise SnapshotError('INVALID_SCOPE')
|
|
749
|
+
if not selectors['source_paths'] and not selectors['membership']:
|
|
750
|
+
raise SnapshotError('EMPTY_SOURCE')
|
|
751
|
+
roles = selectors['source_paths'] + selectors['target_paths'] + selectors['allowed_effect_paths']
|
|
752
|
+
if any(_report._within(a, b) or _report._within(b, a)
|
|
753
|
+
for index, a in enumerate(roles) for b in roles[index + 1:]):
|
|
754
|
+
raise SnapshotError('OVERLAPPING_ROLE')
|
|
755
|
+
if any(not any(_report._within(path, scope) for scope in selectors['source_scopes'])
|
|
756
|
+
for path in selectors['source_paths']):
|
|
757
|
+
raise SnapshotError('OUT_OF_SCOPE')
|
|
758
|
+
first = _capture_context(start, selectors)
|
|
759
|
+
second = _capture_context(start, selectors)
|
|
760
|
+
if first != second:
|
|
761
|
+
raise SnapshotError('SOURCE_CHANGED')
|
|
762
|
+
return first
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
def compare_context_snapshot(saved, start=None, *, boundary='join'):
|
|
766
|
+
"""Reacquire a complete snapshot; action additionally requires target preimages."""
|
|
767
|
+
if boundary not in ('join', 'action'):
|
|
768
|
+
raise SnapshotError('INVALID_BOUNDARY')
|
|
769
|
+
if type(saved) is not dict or set(saved) != {
|
|
770
|
+
'method', 'selectors', 'worktree', 'source', 'targets', 'allowed_effects'} or (
|
|
771
|
+
saved['method'] != SOURCE_SNAPSHOT_METHOD or type(saved['source']) is not dict or
|
|
772
|
+
saved['source'].get('digest_method') != SOURCE_SNAPSHOT_METHOD):
|
|
773
|
+
raise SnapshotError('INVALID_SNAPSHOT')
|
|
774
|
+
selectors = saved['selectors']
|
|
775
|
+
if type(selectors) is not dict or set(selectors) != {
|
|
776
|
+
'source_paths', 'source_scopes', 'membership', 'target_paths',
|
|
777
|
+
'allowed_effect_paths'}:
|
|
778
|
+
raise SnapshotError('INVALID_SNAPSHOT')
|
|
779
|
+
if (type(saved['worktree']) is not dict or set(saved['worktree']) != {
|
|
780
|
+
'root', 'git_dir', 'common_dir', 'head', 'branch', 'detached',
|
|
781
|
+
'upstream', 'fork'} or
|
|
782
|
+
type(saved['source']) is not dict or set(saved['source']) != {
|
|
783
|
+
'digest_method', 'digest', 'files', 'membership', 'git_state'} or
|
|
784
|
+
type(saved['targets']) is not dict or
|
|
785
|
+
type(saved['allowed_effects']) is not list or
|
|
786
|
+
saved['allowed_effects'] != selectors['allowed_effect_paths'] or
|
|
787
|
+
not _sha256_hex(saved['source']['digest'])):
|
|
788
|
+
raise SnapshotError('INVALID_SNAPSHOT')
|
|
789
|
+
if (set(saved['targets']) != set(selectors['target_paths']) or
|
|
790
|
+
any(type(value) is not dict or set(value) != {
|
|
791
|
+
'status', 'digest_method', 'digest'} or
|
|
792
|
+
(value['status'] == 'absent' and (
|
|
793
|
+
value['digest_method'] is not None or value['digest'] is not None)) or
|
|
794
|
+
(value['status'] == 'file' and (
|
|
795
|
+
value['digest_method'] != DIGEST_METHOD or
|
|
796
|
+
not _sha256_hex(value['digest']))) or
|
|
797
|
+
value['status'] not in ('absent', 'file')
|
|
798
|
+
for value in saved['targets'].values())):
|
|
799
|
+
raise SnapshotError('INVALID_SNAPSHOT')
|
|
800
|
+
if (type(saved['source']['files']) is not dict or
|
|
801
|
+
any(type(value) is not dict or set(value) != {'path', 'digest_method', 'digest'} or
|
|
802
|
+
value['path'] != path or value['digest_method'] != DIGEST_METHOD or
|
|
803
|
+
not _sha256_hex(value['digest'])
|
|
804
|
+
for path, value in saved['source']['files'].items()) or
|
|
805
|
+
type(saved['source']['membership']) is not list or
|
|
806
|
+
any(type(value) is not dict or value.get('digest_method') != MEMBERSHIP_METHOD or
|
|
807
|
+
value.get('status') != 'complete' or not _sha256_hex(value.get('digest'))
|
|
808
|
+
for value in saved['source']['membership']) or
|
|
809
|
+
type(saved['source']['git_state']) is not dict or
|
|
810
|
+
set(saved['source']['git_state']) != {
|
|
811
|
+
'staged', 'untracked', 'raw_dirty', 'index', 'head_tree'}):
|
|
812
|
+
raise SnapshotError('INVALID_SNAPSHOT')
|
|
813
|
+
if (type(saved['worktree']['upstream']) is not dict or
|
|
814
|
+
set(saved['worktree']['upstream']) != {
|
|
815
|
+
'status', 'ref', 'relation', 'freshness'} or
|
|
816
|
+
saved['worktree']['upstream']['freshness'] != 'local_only' or
|
|
817
|
+
saved['worktree']['fork'] != {
|
|
818
|
+
'status': 'unknown', 'basis': 'local_refs_only'}):
|
|
819
|
+
raise SnapshotError('INVALID_SNAPSHOT')
|
|
820
|
+
try:
|
|
821
|
+
saved_root = saved['worktree']['root']
|
|
822
|
+
if type(saved_root) is not str:
|
|
823
|
+
raise SnapshotError('INVALID_SNAPSHOT')
|
|
824
|
+
except (KeyError, TypeError):
|
|
825
|
+
raise SnapshotError('INVALID_SNAPSHOT') from None
|
|
826
|
+
current = context_snapshot(start if start is not None else saved['worktree']['root'],
|
|
827
|
+
source_paths=selectors['source_paths'],
|
|
828
|
+
source_scopes=selectors['source_scopes'],
|
|
829
|
+
membership=selectors['membership'],
|
|
830
|
+
target_paths=selectors['target_paths'],
|
|
831
|
+
allowed_effect_paths=selectors['allowed_effect_paths'])
|
|
832
|
+
source_current = (saved['selectors'] == current['selectors'] and
|
|
833
|
+
saved['worktree'] == current['worktree'] and
|
|
834
|
+
saved['source'] == current['source'] and
|
|
835
|
+
saved['allowed_effects'] == current['allowed_effects'])
|
|
836
|
+
targets_current = saved['targets'] == current['targets']
|
|
837
|
+
return {'boundary': boundary, 'admitted': source_current and (
|
|
838
|
+
boundary == 'join' or targets_current), 'source_current': source_current,
|
|
839
|
+
'targets_current': targets_current}
|
|
840
|
+
|
|
841
|
+
|
|
842
|
+
def compare_context_dependencies(saved, *, source_paths=(), membership=(),
|
|
843
|
+
target_paths=(), start=None):
|
|
844
|
+
"""Recheck only declared input and target identities for a context claim.
|
|
845
|
+
|
|
846
|
+
This is for incremental context assessment. It never substitutes for the
|
|
847
|
+
strict whole-snapshot action/join admission above. The physical worktree
|
|
848
|
+
and Git directory remain bound, while unrelated HEAD and source changes
|
|
849
|
+
cannot make a separate claim stale.
|
|
850
|
+
"""
|
|
851
|
+
if type(saved) is not dict or saved.get('method') != SOURCE_SNAPSHOT_METHOD:
|
|
852
|
+
raise SnapshotError('INVALID_SNAPSHOT')
|
|
853
|
+
try:
|
|
854
|
+
selectors = saved['selectors']
|
|
855
|
+
original = saved['source']
|
|
856
|
+
worktree = saved['worktree']
|
|
857
|
+
targets = saved['targets']
|
|
858
|
+
files = original['files']
|
|
859
|
+
members = original['membership']
|
|
860
|
+
scopes = selectors['source_scopes']
|
|
861
|
+
effects = selectors['allowed_effect_paths']
|
|
862
|
+
paths = _snapshot_paths(source_paths)
|
|
863
|
+
selected_members = _snapshot_membership(membership)
|
|
864
|
+
selected_targets = _snapshot_paths(target_paths)
|
|
865
|
+
signed_source = dict(original)
|
|
866
|
+
signed_source.pop('digest', None)
|
|
867
|
+
payload = {'worktree': worktree, 'selectors': selectors, 'source': signed_source}
|
|
868
|
+
expected_digest = hashlib.sha256(json.dumps(
|
|
869
|
+
payload, sort_keys=True, separators=(',', ':'),
|
|
870
|
+
ensure_ascii=True).encode('utf-8')).hexdigest()
|
|
871
|
+
if (original['digest_method'] != SOURCE_SNAPSHOT_METHOD or
|
|
872
|
+
original['digest'] != expected_digest or
|
|
873
|
+
not set(paths) <= set(files) or
|
|
874
|
+
not set(selected_targets) <= set(targets) or
|
|
875
|
+
not {(item[0], tuple(item[1])) for item in selected_members} <=
|
|
876
|
+
{(item['predicate'], tuple(item['scope_paths'])) for item in members}):
|
|
877
|
+
raise SnapshotError('INVALID_SNAPSHOT')
|
|
878
|
+
root = start if start is not None else worktree['root']
|
|
879
|
+
actual = _worktree_identity(root)
|
|
880
|
+
if any(actual[key] != worktree[key]
|
|
881
|
+
for key in ('root', 'git_dir', 'common_dir')):
|
|
882
|
+
raise SnapshotError('WORKTREE_CHANGED')
|
|
883
|
+
current = context_snapshot(root, source_paths=paths, source_scopes=scopes,
|
|
884
|
+
membership=selected_members,
|
|
885
|
+
target_paths=selected_targets,
|
|
886
|
+
allowed_effect_paths=effects)
|
|
887
|
+
original_members = {(item['predicate'], tuple(item['scope_paths'])): item
|
|
888
|
+
for item in members}
|
|
889
|
+
current_members = {(item['predicate'], tuple(item['scope_paths'])): item
|
|
890
|
+
for item in current['source']['membership']}
|
|
891
|
+
source_current = (all(files[path] == current['source']['files'].get(path)
|
|
892
|
+
for path in paths) and
|
|
893
|
+
all(original_members[(item[0], tuple(item[1]))] ==
|
|
894
|
+
current_members.get((item[0], tuple(item[1])))
|
|
895
|
+
for item in selected_members))
|
|
896
|
+
targets_current = all(targets[path] == current['targets'].get(path)
|
|
897
|
+
for path in selected_targets)
|
|
898
|
+
return {'source_current': source_current, 'targets_current': targets_current,
|
|
899
|
+
'admitted': source_current and targets_current}
|
|
900
|
+
except (KeyError, TypeError, ValueError, AttributeError):
|
|
901
|
+
raise SnapshotError('INVALID_SNAPSHOT') from None
|
|
902
|
+
|
|
903
|
+
|
|
904
|
+
def _sha256_hex(value):
|
|
905
|
+
return type(value) is str and len(value) == 64 and all(
|
|
906
|
+
character in '0123456789abcdef' for character in value)
|
|
907
|
+
|
|
908
|
+
|
|
909
|
+
def capture_context_effects(saved):
|
|
910
|
+
"""Read declared output preimages and mtimes for a bounded no-op check.
|
|
911
|
+
|
|
912
|
+
This is an observation of files at one instant, not a writer or a proof
|
|
913
|
+
that no transient write occurred between observations.
|
|
914
|
+
"""
|
|
915
|
+
try:
|
|
916
|
+
if type(saved) is not dict or saved.get('method') != SOURCE_SNAPSHOT_METHOD:
|
|
917
|
+
raise SnapshotError('INVALID_SNAPSHOT')
|
|
918
|
+
selectors = saved['selectors']
|
|
919
|
+
worktree = saved['worktree']
|
|
920
|
+
paths = _snapshot_paths(selectors['target_paths'] +
|
|
921
|
+
selectors['allowed_effect_paths'])
|
|
922
|
+
if (type(worktree) is not dict or type(worktree.get('root')) is not str or
|
|
923
|
+
not paths):
|
|
924
|
+
raise SnapshotError('INVALID_SNAPSHOT')
|
|
925
|
+
actual = _worktree_identity(worktree['root'])
|
|
926
|
+
if any(actual[key] != worktree[key] for key in
|
|
927
|
+
('root', 'git_dir', 'common_dir')):
|
|
928
|
+
raise SnapshotError('WORKTREE_CHANGED')
|
|
929
|
+
root = Path(worktree['root'])
|
|
930
|
+
files = {}
|
|
931
|
+
for path in paths:
|
|
932
|
+
candidate = root.joinpath(*path.split('/'))
|
|
933
|
+
try:
|
|
934
|
+
before = candidate.lstat()
|
|
935
|
+
except FileNotFoundError:
|
|
936
|
+
before = None
|
|
937
|
+
except OSError:
|
|
938
|
+
raise SnapshotError('UNREADABLE_TARGET') from None
|
|
939
|
+
preimage = _preimage(root, path)
|
|
940
|
+
try:
|
|
941
|
+
after = candidate.lstat()
|
|
942
|
+
except FileNotFoundError:
|
|
943
|
+
after = None
|
|
944
|
+
except OSError:
|
|
945
|
+
raise SnapshotError('UNREADABLE_TARGET') from None
|
|
946
|
+
if (before is None) != (after is None) or (before is not None and
|
|
947
|
+
_identity(before, content=True) != _identity(after, content=True)):
|
|
948
|
+
raise SnapshotError('TARGET_CHANGED')
|
|
949
|
+
if preimage['status'] == 'absent':
|
|
950
|
+
if after is not None:
|
|
951
|
+
raise SnapshotError('TARGET_CHANGED')
|
|
952
|
+
files[path] = {'status': 'absent', 'digest': None,
|
|
953
|
+
'size': None, 'mtime_ns': None}
|
|
954
|
+
else:
|
|
955
|
+
if after is None or not stat.S_ISREG(after.st_mode):
|
|
956
|
+
raise SnapshotError('TARGET_CHANGED')
|
|
957
|
+
files[path] = {'status': 'file', 'digest': preimage['digest'],
|
|
958
|
+
'size': after.st_size, 'mtime_ns': after.st_mtime_ns}
|
|
959
|
+
return {'root': str(root), 'files': files}
|
|
960
|
+
except SnapshotError:
|
|
961
|
+
raise
|
|
962
|
+
except (KeyError, TypeError, ValueError, AttributeError):
|
|
963
|
+
raise SnapshotError('INVALID_SNAPSHOT') from None
|
|
964
|
+
|
|
965
|
+
|
|
966
|
+
def compare_context_effects(before, after):
|
|
967
|
+
"""Classify final byte and timestamp differences without inferring writes."""
|
|
968
|
+
if (type(before) is not dict or type(after) is not dict or
|
|
969
|
+
set(before) != {'root', 'files'} or set(after) != {'root', 'files'} or
|
|
970
|
+
before['root'] != after['root'] or type(before['files']) is not dict or
|
|
971
|
+
type(after['files']) is not dict or
|
|
972
|
+
set(before['files']) != set(after['files'])):
|
|
973
|
+
raise SnapshotError('EFFECT_CONTEXT_MISMATCH')
|
|
974
|
+
bytes_changed, timestamp_only = [], []
|
|
975
|
+
for path in sorted(before['files']):
|
|
976
|
+
_snapshot_paths((path,))
|
|
977
|
+
old, new = before['files'][path], after['files'][path]
|
|
978
|
+
if (type(old) is not dict or type(new) is not dict or
|
|
979
|
+
set(old) != {'status', 'digest', 'size', 'mtime_ns'} or
|
|
980
|
+
set(new) != {'status', 'digest', 'size', 'mtime_ns'}):
|
|
981
|
+
raise SnapshotError('INVALID_EFFECTS')
|
|
982
|
+
for item in (old, new):
|
|
983
|
+
if item['status'] == 'absent':
|
|
984
|
+
if any(item[key] is not None for key in ('digest','size','mtime_ns')):
|
|
985
|
+
raise SnapshotError('INVALID_EFFECTS')
|
|
986
|
+
elif item['status'] == 'file':
|
|
987
|
+
if (not _sha256_hex(item['digest']) or
|
|
988
|
+
type(item['size']) is not int or item['size'] < 0 or
|
|
989
|
+
type(item['mtime_ns']) is not int or item['mtime_ns'] < 0):
|
|
990
|
+
raise SnapshotError('INVALID_EFFECTS')
|
|
991
|
+
else:
|
|
992
|
+
raise SnapshotError('INVALID_EFFECTS')
|
|
993
|
+
if any(old[key] != new[key] for key in ('status', 'digest', 'size')):
|
|
994
|
+
bytes_changed.append(path)
|
|
995
|
+
elif old['mtime_ns'] != new['mtime_ns']:
|
|
996
|
+
timestamp_only.append(path)
|
|
997
|
+
return {'no_op': not bytes_changed and not timestamp_only,
|
|
998
|
+
'byte_changes': bytes_changed,
|
|
999
|
+
'timestamp_only_changes': timestamp_only}
|