@arbiterforge/ca-pi 0.6.0
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/LICENSE +661 -0
- package/README.md +558 -0
- package/package.json +35 -0
- package/plugins/ca-pi/CHANGELOG.md +1030 -0
- package/plugins/ca-pi/COMMANDS.md +90 -0
- package/plugins/ca-pi/ORCHESTRATOR.md +159 -0
- package/plugins/ca-pi/SKILLS.md +47 -0
- package/plugins/ca-pi/SPRINT.md +142 -0
- package/plugins/ca-pi/agents/INDEX.md +31 -0
- package/plugins/ca-pi/agents/architecture-drift-reviewer.md +86 -0
- package/plugins/ca-pi/agents/auth-crypto-reviewer.md +60 -0
- package/plugins/ca-pi/agents/backend-author.md +60 -0
- package/plugins/ca-pi/agents/checkpoint-aggregator.md +111 -0
- package/plugins/ca-pi/agents/coverage-auditor.md +71 -0
- package/plugins/ca-pi/agents/decision-challenger.md +116 -0
- package/plugins/ca-pi/agents/dependency-reviewer.md +79 -0
- package/plugins/ca-pi/agents/design-quality-reviewer.md +80 -0
- package/plugins/ca-pi/agents/finding-triage.md +86 -0
- package/plugins/ca-pi/agents/frontend-author.md +64 -0
- package/plugins/ca-pi/agents/grader.md +173 -0
- package/plugins/ca-pi/agents/infra-author.md +64 -0
- package/plugins/ca-pi/agents/map-deps.md +35 -0
- package/plugins/ca-pi/agents/map-structure.md +37 -0
- package/plugins/ca-pi/agents/migration-reviewer.md +65 -0
- package/plugins/ca-pi/agents/scout.md +127 -0
- package/plugins/ca-pi/agents/security-reviewer.md +72 -0
- package/plugins/ca-pi/agents/tribunal-lens-reviewer.md +65 -0
- package/plugins/ca-pi/extensions/codearbiter-child.js +1885 -0
- package/plugins/ca-pi/extensions/codearbiter.js +9802 -0
- package/plugins/ca-pi/generated/command-catalog.json +197 -0
- package/plugins/ca-pi/generated/roles.json +213 -0
- package/plugins/ca-pi/helpers/windows-supervisor.js +205 -0
- package/plugins/ca-pi/hooks/_activationlib.py +196 -0
- package/plugins/ca-pi/hooks/_arbiterstatelib.py +208 -0
- package/plugins/ca-pi/hooks/_babysitlib.py +76 -0
- package/plugins/ca-pi/hooks/_bashguardlib.py +1667 -0
- package/plugins/ca-pi/hooks/_boxlib.py +131 -0
- package/plugins/ca-pi/hooks/_colorlib.py +304 -0
- package/plugins/ca-pi/hooks/_durabilitylib.py +186 -0
- package/plugins/ca-pi/hooks/_entrylib.py +41 -0
- package/plugins/ca-pi/hooks/_fmtlib.py +161 -0
- package/plugins/ca-pi/hooks/_gitexec.py +45 -0
- package/plugins/ca-pi/hooks/_githooks.py +920 -0
- package/plugins/ca-pi/hooks/_gitlib.py +110 -0
- package/plugins/ca-pi/hooks/_hooklib.py +595 -0
- package/plugins/ca-pi/hooks/_host.py +115 -0
- package/plugins/ca-pi/hooks/_intentlib.py +242 -0
- package/plugins/ca-pi/hooks/_ledgerlib.py +1035 -0
- package/plugins/ca-pi/hooks/_metricslib.py +709 -0
- package/plugins/ca-pi/hooks/_pathnorm.py +74 -0
- package/plugins/ca-pi/hooks/_planfilelib.py +664 -0
- package/plugins/ca-pi/hooks/_previewlib.py +193 -0
- package/plugins/ca-pi/hooks/_protectedlib.py +312 -0
- package/plugins/ca-pi/hooks/_protectedstatelib.py +411 -0
- package/plugins/ca-pi/hooks/_provenancelib.py +971 -0
- package/plugins/ca-pi/hooks/_prunelib.py +1398 -0
- package/plugins/ca-pi/hooks/_prunepolicy.py +235 -0
- package/plugins/ca-pi/hooks/_readinjectlib.py +1080 -0
- package/plugins/ca-pi/hooks/_releaselib.py +2657 -0
- package/plugins/ca-pi/hooks/_scopelib.py +262 -0
- package/plugins/ca-pi/hooks/_segmentslib.py +278 -0
- package/plugins/ca-pi/hooks/_sensitivelib.py +270 -0
- package/plugins/ca-pi/hooks/_sessionlib.py +78 -0
- package/plugins/ca-pi/hooks/_sloplib.py +244 -0
- package/plugins/ca-pi/hooks/_standuplib.py +214 -0
- package/plugins/ca-pi/hooks/_subagentslib.py +219 -0
- package/plugins/ca-pi/hooks/_taskboardlib.py +1088 -0
- package/plugins/ca-pi/hooks/_updatelib.py +278 -0
- package/plugins/ca-pi/hooks/babysit.py +47 -0
- package/plugins/ca-pi/hooks/boardsync.py +129 -0
- package/plugins/ca-pi/hooks/doctor.py +420 -0
- package/plugins/ca-pi/hooks/git-enforce.py +325 -0
- package/plugins/ca-pi/hooks/hostapi.py +460 -0
- package/plugins/ca-pi/hooks/init-codearbiter.py +225 -0
- package/plugins/ca-pi/hooks/metrics.py +62 -0
- package/plugins/ca-pi/hooks/migration-pass.py +129 -0
- package/plugins/ca-pi/hooks/pi-bridge.py +543 -0
- package/plugins/ca-pi/hooks/post-write-edit.py +231 -0
- package/plugins/ca-pi/hooks/pre-bash.py +90 -0
- package/plugins/ca-pi/hooks/pre-edit.py +284 -0
- package/plugins/ca-pi/hooks/pre-read.py +81 -0
- package/plugins/ca-pi/hooks/pre-write.py +217 -0
- package/plugins/ca-pi/hooks/preview.py +69 -0
- package/plugins/ca-pi/hooks/prune-transcript.py +232 -0
- package/plugins/ca-pi/hooks/releasehash.py +216 -0
- package/plugins/ca-pi/hooks/security-pass.py +139 -0
- package/plugins/ca-pi/hooks/session-start.py +1218 -0
- package/plugins/ca-pi/hooks/statusline.py +736 -0
- package/plugins/ca-pi/hooks/taskwrite.py +351 -0
- package/plugins/ca-pi/hooks/update-refresh.py +51 -0
- package/plugins/ca-pi/hooks/wire-statusline.py +435 -0
- package/plugins/ca-pi/includes/anti-slop-design/INDEX.md +55 -0
- package/plugins/ca-pi/includes/anti-slop-design/color.md +43 -0
- package/plugins/ca-pi/includes/anti-slop-design/core.md +244 -0
- package/plugins/ca-pi/includes/anti-slop-design/images.md +32 -0
- package/plugins/ca-pi/includes/anti-slop-design/layout.md +45 -0
- package/plugins/ca-pi/includes/anti-slop-design/medium-cli.md +39 -0
- package/plugins/ca-pi/includes/anti-slop-design/medium-dataviz.md +43 -0
- package/plugins/ca-pi/includes/anti-slop-design/medium-diagram.md +35 -0
- package/plugins/ca-pi/includes/anti-slop-design/medium-documents.md +70 -0
- package/plugins/ca-pi/includes/anti-slop-design/medium-slides.md +30 -0
- package/plugins/ca-pi/includes/anti-slop-design/medium-web.md +39 -0
- package/plugins/ca-pi/includes/anti-slop-design/typography.md +51 -0
- package/plugins/ca-pi/includes/author-tdd-workflow.md +14 -0
- package/plugins/ca-pi/includes/compaction-charter.md +16 -0
- package/plugins/ca-pi/includes/cut-docs.md +16 -0
- package/plugins/ca-pi/includes/dev-mode.md +30 -0
- package/plugins/ca-pi/includes/farm.md +237 -0
- package/plugins/ca-pi/includes/fresh-verification.md +14 -0
- package/plugins/ca-pi/includes/harvest.md +69 -0
- package/plugins/ca-pi/includes/maturity-coverage.md +102 -0
- package/plugins/ca-pi/includes/pi-host-notes.md +69 -0
- package/plugins/ca-pi/includes/redirect.md +69 -0
- package/plugins/ca-pi/includes/reference-map.md +22 -0
- package/plugins/ca-pi/includes/review-matrix.md +14 -0
- package/plugins/ca-pi/includes/reviewer-contract.md +53 -0
- package/plugins/ca-pi/includes/routing-table.md +47 -0
- package/plugins/ca-pi/includes/security-gate-record.md +22 -0
- package/plugins/ca-pi/includes/smarts/core.md +90 -0
- package/plugins/ca-pi/includes/smarts/decision-log-format.md +56 -0
- package/plugins/ca-pi/routines/INDEX.md +32 -0
- package/plugins/ca-pi/routines/brainstorming/SKILL.md +122 -0
- package/plugins/ca-pi/routines/commit-gate/SKILL.md +151 -0
- package/plugins/ca-pi/routines/context-check/SKILL.md +85 -0
- package/plugins/ca-pi/routines/context-creation/SKILL.md +171 -0
- package/plugins/ca-pi/routines/crypto-compliance/SKILL.md +41 -0
- package/plugins/ca-pi/routines/debug/SKILL.md +99 -0
- package/plugins/ca-pi/routines/decision-lifecycle/SKILL.md +104 -0
- package/plugins/ca-pi/routines/decision-lifecycle/references/adr-template.md +74 -0
- package/plugins/ca-pi/routines/decision-variance/SKILL.md +147 -0
- package/plugins/ca-pi/routines/decompose/SKILL.md +168 -0
- package/plugins/ca-pi/routines/dispatching-parallel-agents/SKILL.md +76 -0
- package/plugins/ca-pi/routines/executing-plans/SKILL.md +83 -0
- package/plugins/ca-pi/routines/finishing-a-development-branch/SKILL.md +91 -0
- package/plugins/ca-pi/routines/post-merge-cleanup/SKILL.md +233 -0
- package/plugins/ca-pi/routines/refactor/SKILL.md +91 -0
- package/plugins/ca-pi/routines/release/SKILL.md +315 -0
- package/plugins/ca-pi/routines/secret-handling/SKILL.md +67 -0
- package/plugins/ca-pi/routines/security-architecture/SKILL.md +63 -0
- package/plugins/ca-pi/routines/skill-author/SKILL.md +108 -0
- package/plugins/ca-pi/routines/skill-author/references/skill-template.md +58 -0
- package/plugins/ca-pi/routines/subagent-driven-development/SKILL.md +149 -0
- package/plugins/ca-pi/routines/subagent-driven-development/references/farm-dispatch.md +145 -0
- package/plugins/ca-pi/routines/tdd/SKILL.md +139 -0
- package/plugins/ca-pi/routines/tribunal/SKILL.md +109 -0
- package/plugins/ca-pi/routines/tribunal/references/ai-markers.md +29 -0
- package/plugins/ca-pi/routines/tribunal/references/cost-and-models.md +64 -0
- package/plugins/ca-pi/routines/tribunal/references/finding-record.md +27 -0
- package/plugins/ca-pi/routines/tribunal/references/issue-filing.md +47 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/appsec.md +22 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/architecture.md +23 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/coverage.md +20 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/infra.md +24 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/migration.md +22 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/observability.md +21 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/performance.md +22 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/reliability.md +23 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/secrets-supply.md +22 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/test-fidelity.md +24 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/typesafety.md +21 -0
- package/plugins/ca-pi/routines/tribunal/references/report.md +19 -0
- package/plugins/ca-pi/routines/tribunal/references/schemas.md +58 -0
- package/plugins/ca-pi/routines/tribunal/references/telemetry.md +28 -0
- package/plugins/ca-pi/routines/tribunal/references/triage.md +53 -0
- package/plugins/ca-pi/routines/using-git-worktrees/SKILL.md +85 -0
- package/plugins/ca-pi/routines/writing-plans/SKILL.md +129 -0
- package/plugins/ca-pi/routines/writing-plans/references/farm-plan.md +50 -0
- package/plugins/ca-pi/skills/ca-add-dep/SKILL.md +88 -0
- package/plugins/ca-pi/skills/ca-adr/SKILL.md +30 -0
- package/plugins/ca-pi/skills/ca-adr-status/SKILL.md +30 -0
- package/plugins/ca-pi/skills/ca-arbiter/SKILL.md +36 -0
- package/plugins/ca-pi/skills/ca-audit/SKILL.md +51 -0
- package/plugins/ca-pi/skills/ca-btw/SKILL.md +23 -0
- package/plugins/ca-pi/skills/ca-checkpoint/SKILL.md +50 -0
- package/plugins/ca-pi/skills/ca-chore/SKILL.md +58 -0
- package/plugins/ca-pi/skills/ca-cleanup/SKILL.md +55 -0
- package/plugins/ca-pi/skills/ca-commands/SKILL.md +21 -0
- package/plugins/ca-pi/skills/ca-commit/SKILL.md +27 -0
- package/plugins/ca-pi/skills/ca-conflict/SKILL.md +61 -0
- package/plugins/ca-pi/skills/ca-context-check/SKILL.md +32 -0
- package/plugins/ca-pi/skills/ca-create-context/SKILL.md +32 -0
- package/plugins/ca-pi/skills/ca-debug/SKILL.md +42 -0
- package/plugins/ca-pi/skills/ca-decompose/SKILL.md +30 -0
- package/plugins/ca-pi/skills/ca-dev/SKILL.md +42 -0
- package/plugins/ca-pi/skills/ca-doctor/SKILL.md +44 -0
- package/plugins/ca-pi/skills/ca-feature/SKILL.md +105 -0
- package/plugins/ca-pi/skills/ca-fix/SKILL.md +42 -0
- package/plugins/ca-pi/skills/ca-init/SKILL.md +56 -0
- package/plugins/ca-pi/skills/ca-metrics/SKILL.md +80 -0
- package/plugins/ca-pi/skills/ca-new-skill/SKILL.md +34 -0
- package/plugins/ca-pi/skills/ca-override/SKILL.md +72 -0
- package/plugins/ca-pi/skills/ca-pr/SKILL.md +61 -0
- package/plugins/ca-pi/skills/ca-preview/SKILL.md +86 -0
- package/plugins/ca-pi/skills/ca-prune/SKILL.md +100 -0
- package/plugins/ca-pi/skills/ca-reconcile/SKILL.md +43 -0
- package/plugins/ca-pi/skills/ca-refactor/SKILL.md +43 -0
- package/plugins/ca-pi/skills/ca-release/SKILL.md +57 -0
- package/plugins/ca-pi/skills/ca-review/SKILL.md +63 -0
- package/plugins/ca-pi/skills/ca-spike/SKILL.md +41 -0
- package/plugins/ca-pi/skills/ca-sprint/SKILL.md +44 -0
- package/plugins/ca-pi/skills/ca-standup/SKILL.md +112 -0
- package/plugins/ca-pi/skills/ca-status/SKILL.md +56 -0
- package/plugins/ca-pi/skills/ca-task/SKILL.md +61 -0
- package/plugins/ca-pi/skills/ca-threat-model/SKILL.md +45 -0
- package/plugins/ca-pi/skills/ca-tribunal/SKILL.md +44 -0
- package/plugins/ca-pi/skills/ca-watch/SKILL.md +78 -0
|
@@ -0,0 +1,664 @@
|
|
|
1
|
+
"""Canonical, cooperatively locked planning-file CAS publication."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import ctypes
|
|
7
|
+
import hashlib
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import secrets
|
|
11
|
+
import stat
|
|
12
|
+
import tempfile
|
|
13
|
+
import time
|
|
14
|
+
|
|
15
|
+
MAX_CONTENT_BYTES = 92_160
|
|
16
|
+
LOCK_WAIT_SECONDS = 2.0
|
|
17
|
+
SLUG_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,126}[a-z0-9])?$")
|
|
18
|
+
HASH_RE = re.compile(r"^[a-f0-9]{64}$")
|
|
19
|
+
KINDS = {"spec": "specs", "plan": "plans"}
|
|
20
|
+
_REPARSE = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)
|
|
21
|
+
_WINDOWS_PARENT_HANDLES = {}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class PlanFileError(RuntimeError):
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _is_reparse(info):
|
|
29
|
+
return stat.S_ISLNK(info.st_mode) or bool(getattr(info, "st_file_attributes", 0) & _REPARSE)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _identity(info):
|
|
33
|
+
return info.st_dev, info.st_ino
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _version(info):
|
|
37
|
+
return (_identity(info), info.st_size, getattr(info, "st_mtime_ns", 0),
|
|
38
|
+
getattr(info, "st_ctime_ns", 0), info.st_nlink, info.st_mode)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _regular_single(info):
|
|
42
|
+
return stat.S_ISREG(info.st_mode) and not _is_reparse(info) and info.st_nlink == 1
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _directory(info):
|
|
46
|
+
return stat.S_ISDIR(info.st_mode) and not _is_reparse(info)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _aliases(directory, name):
|
|
50
|
+
names = os.listdir(directory)
|
|
51
|
+
return [item for item in names if item.casefold() == name.casefold()]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _exact_absolute_child(parent, name):
|
|
55
|
+
if _aliases(parent, name) != [name]:
|
|
56
|
+
raise PlanFileError("path_alias")
|
|
57
|
+
path = os.path.join(parent, name)
|
|
58
|
+
info = os.lstat(path)
|
|
59
|
+
if not _directory(info) or os.path.realpath(path) != os.path.abspath(path):
|
|
60
|
+
raise PlanFileError("path_type")
|
|
61
|
+
return path, info
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _request_path(root, slug, kind):
|
|
65
|
+
if not isinstance(root, str) or not os.path.isabs(root) \
|
|
66
|
+
or os.path.realpath(root) != os.path.abspath(root):
|
|
67
|
+
raise PlanFileError("root")
|
|
68
|
+
if not isinstance(slug, str) or not SLUG_RE.fullmatch(slug) or kind not in KINDS:
|
|
69
|
+
raise PlanFileError("request")
|
|
70
|
+
return os.path.join(root, ".codearbiter", KINDS[kind], f"{slug}.md")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _windows_handle_identity(handle):
|
|
74
|
+
from ctypes import wintypes
|
|
75
|
+
|
|
76
|
+
class ByHandleInfo(ctypes.Structure):
|
|
77
|
+
_fields_ = [
|
|
78
|
+
("FileAttributes", wintypes.DWORD), ("CreationTime", wintypes.FILETIME),
|
|
79
|
+
("LastAccessTime", wintypes.FILETIME), ("LastWriteTime", wintypes.FILETIME),
|
|
80
|
+
("VolumeSerialNumber", wintypes.DWORD), ("FileSizeHigh", wintypes.DWORD),
|
|
81
|
+
("FileSizeLow", wintypes.DWORD), ("NumberOfLinks", wintypes.DWORD),
|
|
82
|
+
("FileIndexHigh", wintypes.DWORD), ("FileIndexLow", wintypes.DWORD),
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
info = ByHandleInfo()
|
|
86
|
+
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
87
|
+
if not kernel32.GetFileInformationByHandle(handle, ctypes.byref(info)):
|
|
88
|
+
raise PlanFileError("ancestor_handle")
|
|
89
|
+
return (info.VolumeSerialNumber, (info.FileIndexHigh << 32) | info.FileIndexLow,
|
|
90
|
+
info.FileAttributes)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _windows_open_directory(path, deny_delete):
|
|
94
|
+
from ctypes import wintypes
|
|
95
|
+
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
96
|
+
create = kernel32.CreateFileW
|
|
97
|
+
create.argtypes = [wintypes.LPCWSTR, wintypes.DWORD, wintypes.DWORD, wintypes.LPVOID,
|
|
98
|
+
wintypes.DWORD, wintypes.DWORD, wintypes.HANDLE]
|
|
99
|
+
create.restype = wintypes.HANDLE
|
|
100
|
+
sharing = 0x00000001 | 0x00000002
|
|
101
|
+
if not deny_delete:
|
|
102
|
+
sharing |= 0x00000004
|
|
103
|
+
handle = create(path, 0x0080 | 0x00100000, sharing, None, 3,
|
|
104
|
+
0x02000000 | 0x00200000, None)
|
|
105
|
+
if handle == ctypes.c_void_p(-1).value:
|
|
106
|
+
raise PlanFileError("ancestor_handle")
|
|
107
|
+
return handle
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _windows_close(handle):
|
|
111
|
+
ctypes.WinDLL("kernel32", use_last_error=True).CloseHandle(handle)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _windows_snapshot(path):
|
|
115
|
+
handle = _windows_open_directory(path, False)
|
|
116
|
+
try:
|
|
117
|
+
return _windows_handle_identity(handle)
|
|
118
|
+
finally:
|
|
119
|
+
_windows_close(handle)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _validated_windows_path(root, slug, kind):
|
|
123
|
+
target = _request_path(root, slug, kind)
|
|
124
|
+
root_info = os.lstat(root)
|
|
125
|
+
if not _directory(root_info):
|
|
126
|
+
raise PlanFileError("root")
|
|
127
|
+
state_path, state_info = _exact_absolute_child(root, ".codearbiter")
|
|
128
|
+
parent_path, parent_info = _exact_absolute_child(state_path, KINDS[kind])
|
|
129
|
+
leaf = f"{slug}.md"
|
|
130
|
+
aliases = _aliases(parent_path, leaf)
|
|
131
|
+
if aliases not in ([], [leaf]):
|
|
132
|
+
raise PlanFileError("path_alias")
|
|
133
|
+
paths = (root, state_path, parent_path)
|
|
134
|
+
infos = (root_info, state_info, parent_info)
|
|
135
|
+
snapshots = tuple(_windows_snapshot(item) for item in paths)
|
|
136
|
+
for info, snapshot in zip(infos, snapshots):
|
|
137
|
+
if info.st_ino != snapshot[1] or snapshot[2] & _REPARSE or not snapshot[2] & 0x10:
|
|
138
|
+
raise PlanFileError("ancestor_identity")
|
|
139
|
+
return {"root": root, "state": state_path, "parent": parent_path, "target": target,
|
|
140
|
+
"leaf": leaf, "kind": kind, "identities": snapshots, "parent_fd": None}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _open_posix_directory(name, parent_fd=None, expected=None):
|
|
144
|
+
flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0)
|
|
145
|
+
fd = os.open(name, flags, dir_fd=parent_fd)
|
|
146
|
+
opened = os.fstat(fd)
|
|
147
|
+
if not _directory(opened) or expected is not None and _identity(opened) != _identity(expected):
|
|
148
|
+
os.close(fd)
|
|
149
|
+
raise PlanFileError("ancestor_identity")
|
|
150
|
+
return fd, opened
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _exact_fd_child(parent_fd, name):
|
|
154
|
+
if _aliases(parent_fd, name) != [name]:
|
|
155
|
+
raise PlanFileError("path_alias")
|
|
156
|
+
info = os.stat(name, dir_fd=parent_fd, follow_symlinks=False)
|
|
157
|
+
if not _directory(info):
|
|
158
|
+
raise PlanFileError("path_type")
|
|
159
|
+
fd, opened = _open_posix_directory(name, parent_fd, info)
|
|
160
|
+
return fd, opened
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@contextlib.contextmanager
|
|
164
|
+
def _held_path(root, slug, kind, fault=None):
|
|
165
|
+
if os.name == "nt":
|
|
166
|
+
path = _validated_windows_path(root, slug, kind)
|
|
167
|
+
if fault:
|
|
168
|
+
fault("after_validate", path)
|
|
169
|
+
handles = []
|
|
170
|
+
try:
|
|
171
|
+
for directory, expected in zip((path["root"], path["state"], path["parent"]),
|
|
172
|
+
path["identities"]):
|
|
173
|
+
handle = _windows_open_directory(directory, True)
|
|
174
|
+
actual = _windows_handle_identity(handle)
|
|
175
|
+
if actual != expected:
|
|
176
|
+
_windows_close(handle)
|
|
177
|
+
raise PlanFileError("ancestor_identity")
|
|
178
|
+
handles.append(handle)
|
|
179
|
+
_WINDOWS_PARENT_HANDLES[path["parent"]] = handles[-1]
|
|
180
|
+
yield path
|
|
181
|
+
finally:
|
|
182
|
+
_WINDOWS_PARENT_HANDLES.pop(path["parent"], None)
|
|
183
|
+
for handle in reversed(handles):
|
|
184
|
+
with contextlib.suppress(Exception):
|
|
185
|
+
_windows_close(handle)
|
|
186
|
+
return
|
|
187
|
+
|
|
188
|
+
_request_path(root, slug, kind)
|
|
189
|
+
root_before = os.lstat(root)
|
|
190
|
+
if not _directory(root_before):
|
|
191
|
+
raise PlanFileError("root")
|
|
192
|
+
root_fd = state_fd = parent_fd = None
|
|
193
|
+
try:
|
|
194
|
+
root_fd, root_info = _open_posix_directory(root, expected=root_before)
|
|
195
|
+
state_fd, state_info = _exact_fd_child(root_fd, ".codearbiter")
|
|
196
|
+
parent_fd, parent_info = _exact_fd_child(state_fd, KINDS[kind])
|
|
197
|
+
leaf = f"{slug}.md"
|
|
198
|
+
aliases = _aliases(parent_fd, leaf)
|
|
199
|
+
if aliases not in ([], [leaf]):
|
|
200
|
+
raise PlanFileError("path_alias")
|
|
201
|
+
path = {"root": root, "state": os.path.join(root, ".codearbiter"),
|
|
202
|
+
"parent": os.path.join(root, ".codearbiter", KINDS[kind]),
|
|
203
|
+
"target": os.path.join(root, ".codearbiter", KINDS[kind], leaf),
|
|
204
|
+
"leaf": leaf, "kind": kind, "root_fd": root_fd, "state_fd": state_fd,
|
|
205
|
+
"parent_fd": parent_fd,
|
|
206
|
+
"identities": (_identity(root_info), _identity(state_info), _identity(parent_info))}
|
|
207
|
+
if fault:
|
|
208
|
+
fault("after_validate", path)
|
|
209
|
+
yield path
|
|
210
|
+
finally:
|
|
211
|
+
for fd in (parent_fd, state_fd, root_fd):
|
|
212
|
+
if fd is not None:
|
|
213
|
+
with contextlib.suppress(Exception):
|
|
214
|
+
os.close(fd)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _chain_current(path):
|
|
218
|
+
if os.name == "nt":
|
|
219
|
+
return True
|
|
220
|
+
try:
|
|
221
|
+
state = os.stat(".codearbiter", dir_fd=path["root_fd"], follow_symlinks=False)
|
|
222
|
+
parent = os.stat(KINDS[path["kind"]], dir_fd=path["state_fd"], follow_symlinks=False)
|
|
223
|
+
return _identity(state) == path["identities"][1] and _identity(parent) == path["identities"][2]
|
|
224
|
+
except (OSError, ValueError):
|
|
225
|
+
return False
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _lock_name(target):
|
|
229
|
+
return hashlib.sha256(os.path.normcase(os.path.abspath(target)).encode("utf-8")).hexdigest() + ".lock"
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
@contextlib.contextmanager
|
|
233
|
+
def _windows_mutex(target):
|
|
234
|
+
name = "Local\\CodeArbiterPiPlan-" + _lock_name(target)[:-5]
|
|
235
|
+
handle = _windows_create_mutex(name)
|
|
236
|
+
if not handle:
|
|
237
|
+
raise PlanFileError("lock_create")
|
|
238
|
+
acquired = False
|
|
239
|
+
try:
|
|
240
|
+
outcome = _windows_wait_mutex(handle, int(LOCK_WAIT_SECONDS * 1000))
|
|
241
|
+
if outcome not in (0x00000000, 0x00000080):
|
|
242
|
+
raise PlanFileError("lock_timeout" if outcome == 0x00000102 else "lock_wait")
|
|
243
|
+
acquired = True # WAIT_ABANDONED is ownership, not failure.
|
|
244
|
+
yield
|
|
245
|
+
finally:
|
|
246
|
+
if acquired:
|
|
247
|
+
with contextlib.suppress(Exception):
|
|
248
|
+
_windows_release_mutex(handle)
|
|
249
|
+
with contextlib.suppress(Exception):
|
|
250
|
+
_windows_close(handle)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _windows_create_mutex(name):
|
|
254
|
+
from ctypes import wintypes
|
|
255
|
+
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
256
|
+
create = kernel32.CreateMutexW
|
|
257
|
+
create.argtypes = [wintypes.LPVOID, wintypes.BOOL, wintypes.LPCWSTR]
|
|
258
|
+
create.restype = wintypes.HANDLE
|
|
259
|
+
return create(None, False, name) # Default user DACL; no replaceable lock pathname.
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _windows_wait_mutex(handle, timeout_ms):
|
|
263
|
+
return ctypes.WinDLL("kernel32", use_last_error=True).WaitForSingleObject(handle, timeout_ms)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _windows_release_mutex(handle):
|
|
267
|
+
if not ctypes.WinDLL("kernel32", use_last_error=True).ReleaseMutex(handle):
|
|
268
|
+
raise OSError(ctypes.get_last_error(), "ReleaseMutex")
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
@contextlib.contextmanager
|
|
272
|
+
def _posix_lock(target, lock_root):
|
|
273
|
+
root = lock_root or os.path.join(tempfile.gettempdir(), "codearbiter-pi-plan-locks")
|
|
274
|
+
try:
|
|
275
|
+
os.mkdir(root, 0o700)
|
|
276
|
+
except FileExistsError:
|
|
277
|
+
pass
|
|
278
|
+
before = os.lstat(root)
|
|
279
|
+
if not _directory(before) or stat.S_IMODE(before.st_mode) != 0o700 \
|
|
280
|
+
or hasattr(os, "getuid") and before.st_uid != os.getuid():
|
|
281
|
+
raise PlanFileError("lock_root")
|
|
282
|
+
root_fd, opened_root = _open_posix_directory(root, expected=before)
|
|
283
|
+
lock_fd = None
|
|
284
|
+
try:
|
|
285
|
+
name = _lock_name(target)
|
|
286
|
+
flags = os.O_RDWR | getattr(os, "O_NOFOLLOW", 0)
|
|
287
|
+
try:
|
|
288
|
+
leaf_before = os.stat(name, dir_fd=root_fd, follow_symlinks=False)
|
|
289
|
+
lock_fd = os.open(name, flags, dir_fd=root_fd)
|
|
290
|
+
except FileNotFoundError:
|
|
291
|
+
lock_fd = os.open(name, flags | os.O_CREAT | os.O_EXCL, 0o600, dir_fd=root_fd)
|
|
292
|
+
leaf_before = os.stat(name, dir_fd=root_fd, follow_symlinks=False)
|
|
293
|
+
opened = os.fstat(lock_fd)
|
|
294
|
+
if not _regular_single(opened) or _identity(opened) != _identity(leaf_before) \
|
|
295
|
+
or stat.S_IMODE(opened.st_mode) != 0o600 \
|
|
296
|
+
or hasattr(os, "getuid") and opened.st_uid != os.getuid():
|
|
297
|
+
raise PlanFileError("lock_path")
|
|
298
|
+
import fcntl
|
|
299
|
+
deadline = time.monotonic() + LOCK_WAIT_SECONDS
|
|
300
|
+
while True:
|
|
301
|
+
try:
|
|
302
|
+
fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
303
|
+
break
|
|
304
|
+
except (OSError, BlockingIOError):
|
|
305
|
+
if time.monotonic() >= deadline:
|
|
306
|
+
raise PlanFileError("lock_timeout")
|
|
307
|
+
time.sleep(0.005)
|
|
308
|
+
current = os.stat(name, dir_fd=root_fd, follow_symlinks=False)
|
|
309
|
+
if _identity(current) != _identity(opened) or _version(current) != _version(opened):
|
|
310
|
+
raise PlanFileError("lock_race")
|
|
311
|
+
yield
|
|
312
|
+
finally:
|
|
313
|
+
if lock_fd is not None:
|
|
314
|
+
with contextlib.suppress(Exception):
|
|
315
|
+
import fcntl
|
|
316
|
+
fcntl.flock(lock_fd, fcntl.LOCK_UN)
|
|
317
|
+
with contextlib.suppress(Exception):
|
|
318
|
+
os.close(lock_fd)
|
|
319
|
+
with contextlib.suppress(Exception):
|
|
320
|
+
os.close(root_fd)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _lock_for(target, lock_root=None):
|
|
324
|
+
return _windows_mutex(target) if os.name == "nt" else _posix_lock(target, lock_root)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _target_stat(path):
|
|
328
|
+
aliases = _aliases(path["parent_fd"] if os.name != "nt" else path["parent"], path["leaf"])
|
|
329
|
+
if aliases == []:
|
|
330
|
+
return None
|
|
331
|
+
if aliases != [path["leaf"]]:
|
|
332
|
+
raise PlanFileError("path_alias")
|
|
333
|
+
return (os.stat(path["leaf"], dir_fd=path["parent_fd"], follow_symlinks=False)
|
|
334
|
+
if os.name != "nt" else os.lstat(path["target"]))
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _read_pass(fd):
|
|
338
|
+
os.lseek(fd, 0, os.SEEK_SET)
|
|
339
|
+
chunks, total = [], 0
|
|
340
|
+
while True:
|
|
341
|
+
chunk = os.read(fd, min(65_536, MAX_CONTENT_BYTES + 1 - total))
|
|
342
|
+
if not chunk:
|
|
343
|
+
break
|
|
344
|
+
chunks.append(chunk)
|
|
345
|
+
total += len(chunk)
|
|
346
|
+
if total > MAX_CONTENT_BYTES:
|
|
347
|
+
raise PlanFileError("content_overflow")
|
|
348
|
+
return b"".join(chunks)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _open_temp(path, temp_name):
|
|
352
|
+
if os.name != "nt":
|
|
353
|
+
flags = os.O_RDWR | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0)
|
|
354
|
+
return os.open(temp_name, flags, 0o600, dir_fd=path["parent_fd"])
|
|
355
|
+
from ctypes import wintypes
|
|
356
|
+
import msvcrt
|
|
357
|
+
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
358
|
+
create = kernel32.CreateFileW
|
|
359
|
+
create.argtypes = [wintypes.LPCWSTR, wintypes.DWORD, wintypes.DWORD, wintypes.LPVOID,
|
|
360
|
+
wintypes.DWORD, wintypes.DWORD, wintypes.HANDLE]
|
|
361
|
+
create.restype = wintypes.HANDLE
|
|
362
|
+
handle = create(os.path.join(path["parent"], temp_name),
|
|
363
|
+
0x80000000 | 0x40000000 | 0x00010000 | 0x00100000,
|
|
364
|
+
0x00000001 | 0x00000002 | 0x00000004, None, 1,
|
|
365
|
+
0x00000080 | 0x00200000, None)
|
|
366
|
+
if handle == ctypes.c_void_p(-1).value:
|
|
367
|
+
raise PlanFileError("temp_create")
|
|
368
|
+
try:
|
|
369
|
+
return msvcrt.open_osfhandle(handle, os.O_RDWR | getattr(os, "O_BINARY", 0))
|
|
370
|
+
except Exception:
|
|
371
|
+
_windows_close(handle)
|
|
372
|
+
raise
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _verify_owned_temp(path, temp_name, fd, owned_identity, data):
|
|
376
|
+
opened_before = os.fstat(fd)
|
|
377
|
+
first = _read_pass(fd)
|
|
378
|
+
opened_middle = os.fstat(fd)
|
|
379
|
+
second = _read_pass(fd)
|
|
380
|
+
opened_after = os.fstat(fd)
|
|
381
|
+
current = (os.stat(temp_name, dir_fd=path["parent_fd"], follow_symlinks=False)
|
|
382
|
+
if os.name != "nt" else os.lstat(os.path.join(path["parent"], temp_name)))
|
|
383
|
+
if not _regular_single(opened_after) or _identity(opened_after) != owned_identity \
|
|
384
|
+
or _identity(current) != owned_identity \
|
|
385
|
+
or not (_version(opened_before) == _version(opened_middle) == _version(opened_after)) \
|
|
386
|
+
or current.st_size != opened_after.st_size or current.st_mtime_ns != opened_after.st_mtime_ns \
|
|
387
|
+
or first != second or first != data:
|
|
388
|
+
raise PlanFileError("temp_race")
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _read_target(path, fault=None, allow_links=1):
|
|
392
|
+
before = _target_stat(path)
|
|
393
|
+
if before is None:
|
|
394
|
+
return None
|
|
395
|
+
if not stat.S_ISREG(before.st_mode) or _is_reparse(before) or before.st_nlink != allow_links:
|
|
396
|
+
raise PlanFileError("target_type")
|
|
397
|
+
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
|
|
398
|
+
fd = (os.open(path["leaf"], flags, dir_fd=path["parent_fd"])
|
|
399
|
+
if os.name != "nt" else os.open(path["target"], flags))
|
|
400
|
+
try:
|
|
401
|
+
opened = os.fstat(fd)
|
|
402
|
+
if not stat.S_ISREG(opened.st_mode) or _is_reparse(opened) or opened.st_nlink != allow_links \
|
|
403
|
+
or _identity(opened) != _identity(before):
|
|
404
|
+
raise PlanFileError("target_race")
|
|
405
|
+
first_before = _version(opened)
|
|
406
|
+
first = _read_pass(fd)
|
|
407
|
+
first_after = _version(os.fstat(fd))
|
|
408
|
+
if fault:
|
|
409
|
+
fault("read_between", path)
|
|
410
|
+
second_before = _version(os.fstat(fd))
|
|
411
|
+
second = _read_pass(fd)
|
|
412
|
+
second_after = _version(os.fstat(fd))
|
|
413
|
+
current = _target_stat(path)
|
|
414
|
+
path_stable = current is not None and _identity(current) == _identity(opened) \
|
|
415
|
+
and current.st_size == opened.st_size and current.st_nlink == opened.st_nlink \
|
|
416
|
+
and current.st_mtime_ns == opened.st_mtime_ns
|
|
417
|
+
if not path_stable or not _chain_current(path) \
|
|
418
|
+
or not (first_before == first_after == second_before == second_after) \
|
|
419
|
+
or first != second:
|
|
420
|
+
raise PlanFileError("target_race")
|
|
421
|
+
text = first.decode("utf-8", "strict")
|
|
422
|
+
return {"content": text, "hash": hashlib.sha256(first).hexdigest(),
|
|
423
|
+
"identity": _identity(opened), "version": first_before}
|
|
424
|
+
finally:
|
|
425
|
+
os.close(fd)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _write_all(fd, data):
|
|
429
|
+
offset = 0
|
|
430
|
+
while offset < len(data):
|
|
431
|
+
written = os.write(fd, data[offset:])
|
|
432
|
+
if written <= 0:
|
|
433
|
+
raise PlanFileError("temp_write")
|
|
434
|
+
offset += written
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _sync_temp(fd):
|
|
438
|
+
os.fsync(fd)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _close_temp(fd):
|
|
442
|
+
os.close(fd)
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _sync_parent(path):
|
|
446
|
+
if os.name == "nt":
|
|
447
|
+
return False
|
|
448
|
+
os.fsync(path["parent_fd"])
|
|
449
|
+
return True
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _windows_rename_status(handle, parent_handle, leaf, replace):
|
|
453
|
+
from ctypes import wintypes
|
|
454
|
+
|
|
455
|
+
class RenameInfo(ctypes.Structure):
|
|
456
|
+
_fields_ = [("ReplaceIfExists", wintypes.BOOLEAN), ("RootDirectory", wintypes.HANDLE),
|
|
457
|
+
("FileNameLength", wintypes.DWORD), ("FileName", wintypes.WCHAR * 1)]
|
|
458
|
+
|
|
459
|
+
class IoStatusBlock(ctypes.Structure):
|
|
460
|
+
_fields_ = [("Status", ctypes.c_void_p), ("Information", ctypes.c_size_t)]
|
|
461
|
+
|
|
462
|
+
encoded = leaf.encode("utf-16-le")
|
|
463
|
+
size = ctypes.sizeof(RenameInfo) + len(encoded)
|
|
464
|
+
buffer = ctypes.create_string_buffer(size)
|
|
465
|
+
info = ctypes.cast(buffer, ctypes.POINTER(RenameInfo)).contents
|
|
466
|
+
info.ReplaceIfExists = replace
|
|
467
|
+
info.RootDirectory = parent_handle
|
|
468
|
+
info.FileNameLength = len(encoded)
|
|
469
|
+
ctypes.memmove(ctypes.addressof(buffer) + RenameInfo.FileName.offset, encoded, len(encoded))
|
|
470
|
+
iosb = IoStatusBlock()
|
|
471
|
+
ntdll = ctypes.WinDLL("ntdll")
|
|
472
|
+
function = ntdll.NtSetInformationFile
|
|
473
|
+
function.argtypes = [wintypes.HANDLE, ctypes.POINTER(IoStatusBlock), wintypes.LPVOID,
|
|
474
|
+
wintypes.ULONG, ctypes.c_int]
|
|
475
|
+
function.restype = wintypes.LONG
|
|
476
|
+
return int(function(handle, ctypes.byref(iosb), buffer, size, 10))
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _windows_publish(path, temp_fd, replace):
|
|
480
|
+
import msvcrt
|
|
481
|
+
parent_handle = _WINDOWS_PARENT_HANDLES.get(path["parent"])
|
|
482
|
+
if parent_handle is None:
|
|
483
|
+
raise PlanFileError("ancestor_handle")
|
|
484
|
+
handle = msvcrt.get_osfhandle(temp_fd)
|
|
485
|
+
status = _windows_rename_status(handle, parent_handle, path["leaf"], replace)
|
|
486
|
+
if status < 0: # NT_SUCCESS includes success and informational statuses.
|
|
487
|
+
if not replace and (status & 0xFFFFFFFF) in (0xC0000035, 0xC00000BA):
|
|
488
|
+
raise FileExistsError(path["leaf"])
|
|
489
|
+
raise PlanFileError("publish")
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _posix_publish(path, temp_name, replace):
|
|
493
|
+
if replace:
|
|
494
|
+
os.replace(temp_name, path["leaf"], src_dir_fd=path["parent_fd"],
|
|
495
|
+
dst_dir_fd=path["parent_fd"])
|
|
496
|
+
return
|
|
497
|
+
os.link(temp_name, path["leaf"], src_dir_fd=path["parent_fd"],
|
|
498
|
+
dst_dir_fd=path["parent_fd"], follow_symlinks=False)
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _publish(path, temp_name, temp_fd, replace):
|
|
502
|
+
if os.name == "nt":
|
|
503
|
+
_windows_publish(path, temp_fd, replace)
|
|
504
|
+
else:
|
|
505
|
+
_posix_publish(path, temp_name, replace)
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def _unlink_owned(path, name, owned_identity):
|
|
509
|
+
try:
|
|
510
|
+
current = (os.stat(name, dir_fd=path["parent_fd"], follow_symlinks=False)
|
|
511
|
+
if os.name != "nt" else os.lstat(os.path.join(path["parent"], name)))
|
|
512
|
+
if _identity(current) != owned_identity:
|
|
513
|
+
return
|
|
514
|
+
if os.name == "nt":
|
|
515
|
+
os.unlink(os.path.join(path["parent"], name))
|
|
516
|
+
else:
|
|
517
|
+
os.unlink(name, dir_fd=path["parent_fd"])
|
|
518
|
+
except FileNotFoundError:
|
|
519
|
+
pass
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def _cleanup_committed_temp(path, name, owned_identity):
|
|
523
|
+
"""Best-effort postcommit cleanup; never lets fallible name work escape."""
|
|
524
|
+
try:
|
|
525
|
+
_unlink_owned(path, name, owned_identity)
|
|
526
|
+
try:
|
|
527
|
+
current = (os.stat(name, dir_fd=path["parent_fd"], follow_symlinks=False)
|
|
528
|
+
if os.name != "nt" else os.lstat(os.path.join(path["parent"], name)))
|
|
529
|
+
except FileNotFoundError:
|
|
530
|
+
return True
|
|
531
|
+
return _identity(current) != owned_identity
|
|
532
|
+
except Exception:
|
|
533
|
+
return False
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def _same_snapshot(left, right):
|
|
537
|
+
if left is None or right is None:
|
|
538
|
+
return left is right
|
|
539
|
+
return left["identity"] == right["identity"] and left["version"] == right["version"] \
|
|
540
|
+
and left["hash"] == right["hash"]
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def _result(status, **fields):
|
|
544
|
+
return {"status": status, **fields}
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def _committed_result(path, data, content, fault=None, initial_diagnostic=None):
|
|
548
|
+
durable = False
|
|
549
|
+
diagnostic = initial_diagnostic
|
|
550
|
+
if fault:
|
|
551
|
+
with contextlib.suppress(Exception):
|
|
552
|
+
fault("after_publish", path)
|
|
553
|
+
try:
|
|
554
|
+
durable = _sync_parent(path) and diagnostic is None
|
|
555
|
+
if not durable and diagnostic is None:
|
|
556
|
+
diagnostic = diagnostic or "directory_durability_unavailable"
|
|
557
|
+
except Exception:
|
|
558
|
+
diagnostic = diagnostic or "directory_sync_failed"
|
|
559
|
+
try:
|
|
560
|
+
try:
|
|
561
|
+
final = _read_target(path)
|
|
562
|
+
except PlanFileError:
|
|
563
|
+
if initial_diagnostic != "postcommit_cleanup_failed":
|
|
564
|
+
raise
|
|
565
|
+
final = _read_target(path, allow_links=2)
|
|
566
|
+
wanted_hash = hashlib.sha256(data).hexdigest()
|
|
567
|
+
exists = final is not None
|
|
568
|
+
observed_hash = final["hash"] if final is not None else None
|
|
569
|
+
observed_content = final["content"] if final is not None else ""
|
|
570
|
+
if not exists or observed_hash != wanted_hash or observed_content != content:
|
|
571
|
+
diagnostic = "postcommit_changed"
|
|
572
|
+
durable = False
|
|
573
|
+
except Exception:
|
|
574
|
+
return _result("committed", observed=False, exists=None, hash=None, content=None,
|
|
575
|
+
directoryDurable=False, postCommitDiagnostic="postcommit_unobserved")
|
|
576
|
+
result = _result("committed", observed=True, exists=exists, hash=observed_hash,
|
|
577
|
+
content=observed_content, directoryDurable=durable)
|
|
578
|
+
if diagnostic:
|
|
579
|
+
result["postCommitDiagnostic"] = diagnostic
|
|
580
|
+
return result
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def plan_file_operation(root, request, *, lock_root=None, fault=None):
|
|
584
|
+
"""Run a bounded CAS. The lock serializes cooperating codeArbiter Pi writers only."""
|
|
585
|
+
try:
|
|
586
|
+
if not isinstance(request, dict) or set(request) - {"slug", "kind", "action", "expectedHash", "content"}:
|
|
587
|
+
raise PlanFileError("request")
|
|
588
|
+
action = request.get("action")
|
|
589
|
+
if action == "read" and set(request) != {"slug", "kind", "action"}:
|
|
590
|
+
raise PlanFileError("request")
|
|
591
|
+
if action == "replace" and set(request) != {"slug", "kind", "action", "expectedHash", "content"}:
|
|
592
|
+
raise PlanFileError("request")
|
|
593
|
+
if action not in ("read", "replace"):
|
|
594
|
+
raise PlanFileError("request")
|
|
595
|
+
target = _request_path(root, request.get("slug"), request.get("kind"))
|
|
596
|
+
with _lock_for(target, lock_root), _held_path(root, request["slug"], request["kind"], fault) as path:
|
|
597
|
+
chosen = _read_target(path, fault)
|
|
598
|
+
if action == "read":
|
|
599
|
+
if chosen is None:
|
|
600
|
+
return _result("unchanged", exists=False, hash=None, content="")
|
|
601
|
+
return _result("unchanged", exists=True, hash=chosen["hash"], content=chosen["content"])
|
|
602
|
+
expected = request["expectedHash"]
|
|
603
|
+
if expected is not None and (not isinstance(expected, str) or not HASH_RE.fullmatch(expected)):
|
|
604
|
+
raise PlanFileError("request")
|
|
605
|
+
if (chosen is None) != (expected is None) or chosen is not None and chosen["hash"] != expected:
|
|
606
|
+
return _result("conflict")
|
|
607
|
+
content = request["content"]
|
|
608
|
+
if not isinstance(content, str):
|
|
609
|
+
raise PlanFileError("request")
|
|
610
|
+
data = content.encode("utf-8", "strict")
|
|
611
|
+
if len(data) > MAX_CONTENT_BYTES:
|
|
612
|
+
raise PlanFileError("content_overflow")
|
|
613
|
+
temp_name = f".{path['leaf']}.ca-plan-tmp-{secrets.token_hex(16)}"
|
|
614
|
+
fd = None
|
|
615
|
+
temp_identity = None
|
|
616
|
+
committed = False
|
|
617
|
+
try:
|
|
618
|
+
fd = _open_temp(path, temp_name)
|
|
619
|
+
temp_info = os.fstat(fd)
|
|
620
|
+
if not _regular_single(temp_info):
|
|
621
|
+
raise PlanFileError("temp_type")
|
|
622
|
+
temp_identity = _identity(temp_info)
|
|
623
|
+
_write_all(fd, data)
|
|
624
|
+
_sync_temp(fd)
|
|
625
|
+
for phase in ("before_publish", "publish"):
|
|
626
|
+
if fault:
|
|
627
|
+
fault(phase, path)
|
|
628
|
+
try:
|
|
629
|
+
latest = _read_target(path)
|
|
630
|
+
except Exception:
|
|
631
|
+
return _result("conflict")
|
|
632
|
+
if not _same_snapshot(chosen, latest) or not _chain_current(path):
|
|
633
|
+
return _result("conflict")
|
|
634
|
+
_verify_owned_temp(path, temp_name, fd, temp_identity, data)
|
|
635
|
+
try:
|
|
636
|
+
_publish(path, temp_name, fd, chosen is not None)
|
|
637
|
+
except FileExistsError:
|
|
638
|
+
return _result("conflict")
|
|
639
|
+
committed = True # The successful atomic rename is the irreversible commit point.
|
|
640
|
+
if os.name == "nt" or chosen is not None:
|
|
641
|
+
temp_identity = None
|
|
642
|
+
else:
|
|
643
|
+
cleanup_ok = _cleanup_committed_temp(path, temp_name, temp_identity)
|
|
644
|
+
if cleanup_ok:
|
|
645
|
+
temp_identity = None
|
|
646
|
+
cleanup_diagnostic = None if cleanup_ok else "postcommit_cleanup_failed"
|
|
647
|
+
if os.name == "nt" or chosen is not None:
|
|
648
|
+
cleanup_diagnostic = None
|
|
649
|
+
published_fd = fd
|
|
650
|
+
fd = None
|
|
651
|
+
with contextlib.suppress(Exception):
|
|
652
|
+
_close_temp(published_fd)
|
|
653
|
+
return _committed_result(path, data, content, fault, cleanup_diagnostic)
|
|
654
|
+
finally:
|
|
655
|
+
if fd is not None:
|
|
656
|
+
with contextlib.suppress(Exception):
|
|
657
|
+
_close_temp(fd)
|
|
658
|
+
if temp_identity is not None:
|
|
659
|
+
with contextlib.suppress(Exception):
|
|
660
|
+
_unlink_owned(path, temp_name, temp_identity)
|
|
661
|
+
except PlanFileError as exc:
|
|
662
|
+
return _result("error", code=str(exc) if str(exc) else "operation")
|
|
663
|
+
except Exception:
|
|
664
|
+
return _result("error", code="operation")
|