raidlock 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- raidlock/__init__.py +11 -0
- raidlock/__main__.py +5 -0
- raidlock/_python.py +58 -0
- raidlock/achievements.py +156 -0
- raidlock/approvals.py +237 -0
- raidlock/bench.py +286 -0
- raidlock/bridge/__init__.py +186 -0
- raidlock/budgets.py +178 -0
- raidlock/cli.py +2153 -0
- raidlock/commands_install.py +114 -0
- raidlock/core/__init__.py +26 -0
- raidlock/core/advisories.py +16 -0
- raidlock/core/billing.py +54 -0
- raidlock/core/board.py +2251 -0
- raidlock/core/brief.py +111 -0
- raidlock/core/clock.py +40 -0
- raidlock/core/cost.py +198 -0
- raidlock/core/db.py +608 -0
- raidlock/core/errors.py +52 -0
- raidlock/core/exe.py +6 -0
- raidlock/core/hints.py +32 -0
- raidlock/core/identity.py +285 -0
- raidlock/core/lead.py +758 -0
- raidlock/core/lead_env.py +23 -0
- raidlock/core/lead_hook.py +42 -0
- raidlock/core/leases.py +19 -0
- raidlock/core/lock.py +99 -0
- raidlock/core/messages.py +98 -0
- raidlock/core/migrate.py +579 -0
- raidlock/core/model.py +126 -0
- raidlock/core/models.py +109 -0
- raidlock/core/paths.py +61 -0
- raidlock/core/portable.py +53 -0
- raidlock/core/receipts.py +73 -0
- raidlock/core/registry.py +39 -0
- raidlock/core/repos.py +250 -0
- raidlock/core/safeio.py +201 -0
- raidlock/core/status.py +52 -0
- raidlock/core/store.py +107 -0
- raidlock/core/tokens.py +77 -0
- raidlock/core/transcript.py +208 -0
- raidlock/duel.py +371 -0
- raidlock/foresight.py +191 -0
- raidlock/frames.py +162 -0
- raidlock/gitguard.py +753 -0
- raidlock/guard.py +239 -0
- raidlock/hooks.py +1526 -0
- raidlock/infer.py +205 -0
- raidlock/limits.py +685 -0
- raidlock/lore/__init__.py +22 -0
- raidlock/lore/approval.py +145 -0
- raidlock/lore/capture.py +146 -0
- raidlock/lore/cli.py +109 -0
- raidlock/lore/distill.py +17 -0
- raidlock/lore/frontmatter.py +56 -0
- raidlock/lore/inject.py +127 -0
- raidlock/lore/vault.py +264 -0
- raidlock/mcp/__init__.py +34 -0
- raidlock/mcp/resources.py +72 -0
- raidlock/mcp/server.py +184 -0
- raidlock/mcp/tools.py +178 -0
- raidlock/notify.py +144 -0
- raidlock/party.py +239 -0
- raidlock/pr.py +575 -0
- raidlock/prsync.py +318 -0
- raidlock/recipes/README.md +34 -0
- raidlock/recipes/__init__.py +105 -0
- raidlock/recipes/docs-refresh.md +15 -0
- raidlock/recipes/multi-repo-refactor.md +15 -0
- raidlock/recipes/security-audit.md +27 -0
- raidlock/recipes/upgrade-deps.md +15 -0
- raidlock/reputation.py +194 -0
- raidlock/resume.py +329 -0
- raidlock/resurrect.py +419 -0
- raidlock/routing.py +419 -0
- raidlock/runners/__init__.py +16 -0
- raidlock/runners/base.py +341 -0
- raidlock/runners/brief.py +176 -0
- raidlock/runners/claude_code.py +121 -0
- raidlock/runners/cli.py +110 -0
- raidlock/runners/codex.py +114 -0
- raidlock/runners/gemini.py +64 -0
- raidlock/runners/isolation.py +391 -0
- raidlock/runners/policy.py +123 -0
- raidlock/runners/runner.py +921 -0
- raidlock/runners/shell.py +35 -0
- raidlock/runners/verifycache.py +93 -0
- raidlock/runners/workspace.py +376 -0
- raidlock/scan.py +357 -0
- raidlock/snapshot.py +151 -0
- raidlock/treasury.py +210 -0
- raidlock/tui.py +223 -0
- raidlock/web/__init__.py +5 -0
- raidlock/web/achievements/view.css +19 -0
- raidlock/web/achievements/view.js +58 -0
- raidlock/web/bosses/view.css +19 -0
- raidlock/web/bosses/view.js +46 -0
- raidlock/web/cli.py +90 -0
- raidlock/web/data.py +687 -0
- raidlock/web/export.py +153 -0
- raidlock/web/frames/view.css +25 -0
- raidlock/web/frames/view.js +63 -0
- raidlock/web/graph/__init__.py +142 -0
- raidlock/web/graph/view.css +33 -0
- raidlock/web/graph/view.js +136 -0
- raidlock/web/raidmap/__init__.py +205 -0
- raidlock/web/raidmap/view.css +15 -0
- raidlock/web/raidmap/view.js +47 -0
- raidlock/web/replay/__init__.py +258 -0
- raidlock/web/replay/view.css +13 -0
- raidlock/web/replay/view.js +127 -0
- raidlock/web/server.py +292 -0
- raidlock/web/static/app.css +851 -0
- raidlock/web/static/app.js +2102 -0
- raidlock/web/static/fonts/OFL.txt +93 -0
- raidlock/web/static/fonts/cinzel-600.woff2 +0 -0
- raidlock/web/static/fonts/cinzel-700.woff2 +0 -0
- raidlock/web/static/icon-32.png +0 -0
- raidlock/web/static/icon-raid.svg +1 -0
- raidlock/web/static/icon.svg +1 -0
- raidlock/web/static/index.html +122 -0
- raidlock-0.1.0.dist-info/METADATA +186 -0
- raidlock-0.1.0.dist-info/RECORD +126 -0
- raidlock-0.1.0.dist-info/WHEEL +4 -0
- raidlock-0.1.0.dist-info/entry_points.txt +34 -0
- raidlock-0.1.0.dist-info/licenses/LICENSE +21 -0
raidlock/core/model.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
from .errors import Precondition
|
|
4
|
+
|
|
5
|
+
LANES = ("todo", "doing", "review", "blocked", "done", "cancelled")
|
|
6
|
+
HOLD_LANES = ("doing", "review", "blocked")
|
|
7
|
+
ACTIVE_LANES = ("doing", "review", "blocked")
|
|
8
|
+
A2A_STATE = {
|
|
9
|
+
"todo": "submitted",
|
|
10
|
+
"doing": "working",
|
|
11
|
+
"blocked": "input-required",
|
|
12
|
+
"done": "completed",
|
|
13
|
+
"cancelled": "canceled",
|
|
14
|
+
"review": "review",
|
|
15
|
+
}
|
|
16
|
+
VERDICTS = ("approve", "changes", "comment")
|
|
17
|
+
ROLES = ("lead", "worker", "reviewer")
|
|
18
|
+
CARD_ID = re.compile(r"^[a-z0-9][a-z0-9-]{0,63}$")
|
|
19
|
+
AGENT_NAME = re.compile(r"^[a-z0-9][a-z0-9-]{2,39}$")
|
|
20
|
+
TEAM_NAME = re.compile(r"^[a-z0-9][a-z0-9-]{0,39}$")
|
|
21
|
+
_LINE_CONTROL = re.compile(r"[\x00-\x1f\x7f-\x9f
]")
|
|
22
|
+
_BLOCK_CONTROL = re.compile(r"[\x00-\x08\x0b-\x1f\x7f-\x9f
]")
|
|
23
|
+
TEXT_FIELDS = ("title", "scope", "ticket", "verify", "output")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def line_text(value, field, limit=2000):
|
|
27
|
+
"""Validate a single-line text field: no control characters, bounded length."""
|
|
28
|
+
text = "" if value is None else str(value)
|
|
29
|
+
if _LINE_CONTROL.search(text):
|
|
30
|
+
raise Precondition(f"{field} must not contain control characters or newlines", field=field)
|
|
31
|
+
if len(text) > limit:
|
|
32
|
+
raise Precondition(f"{field} is longer than {limit} characters", field=field)
|
|
33
|
+
return text.strip()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def block_text(value, field, limit=32768):
|
|
37
|
+
"""Validate multi-line text: newlines and tabs allowed, other control characters rejected."""
|
|
38
|
+
text = "" if value is None else str(value)
|
|
39
|
+
if _BLOCK_CONTROL.search(text):
|
|
40
|
+
raise Precondition(f"{field} must not contain control characters", field=field)
|
|
41
|
+
if len(text.encode("utf-8")) > limit:
|
|
42
|
+
raise Precondition(f"{field} is larger than {limit} bytes", field=field, limit=limit)
|
|
43
|
+
return text
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def card_id(value):
|
|
47
|
+
if not value or not isinstance(value, str) or not CARD_ID.fullmatch(value):
|
|
48
|
+
raise Precondition(f"invalid card id {value!r}: use [a-z0-9-], max 64", id=value)
|
|
49
|
+
return value
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def slug(title):
|
|
53
|
+
value = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-")
|
|
54
|
+
if len(value) > 40:
|
|
55
|
+
boundary = value.rfind("-", 0, 41)
|
|
56
|
+
value = value[:boundary] if boundary >= 0 else ""
|
|
57
|
+
return value or "card"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def agent_name(value):
|
|
61
|
+
if not value or not isinstance(value, str) or not AGENT_NAME.fullmatch(value):
|
|
62
|
+
raise Precondition(f"invalid agent name {value!r}: use [a-z0-9-], 3-40 characters", agent=value)
|
|
63
|
+
return value
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def team_name(value):
|
|
67
|
+
if not value or not TEAM_NAME.match(value):
|
|
68
|
+
raise Precondition(f"invalid team name {value!r}: use [a-z0-9-], max 40", team=value)
|
|
69
|
+
return value
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def lane(value):
|
|
73
|
+
if value not in LANES:
|
|
74
|
+
raise Precondition(f"lane must be one of {', '.join(LANES)}", lane=value)
|
|
75
|
+
return value
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def new_card(cid, title, created, **fields):
|
|
79
|
+
card = {
|
|
80
|
+
"id": card_id(cid),
|
|
81
|
+
"title": line_text(title, "title", 300),
|
|
82
|
+
"lane": "todo",
|
|
83
|
+
"owner": "",
|
|
84
|
+
"team": fields.get("team") or "",
|
|
85
|
+
"ticket": line_text(fields.get("ticket"), "ticket", 200),
|
|
86
|
+
"scope": line_text(fields.get("scope"), "scope"),
|
|
87
|
+
"output": line_text(fields.get("output"), "output"),
|
|
88
|
+
"verify": line_text(fields.get("verify"), "verify"),
|
|
89
|
+
"verify_cmd": line_text(fields.get("verify_cmd"), "verify_cmd"),
|
|
90
|
+
"locks": list(fields.get("locks") or []),
|
|
91
|
+
"repo": fields.get("repo") or "",
|
|
92
|
+
"allow_protected": list(fields.get("allow_protected") or []),
|
|
93
|
+
"depends": list(fields.get("depends") or []),
|
|
94
|
+
"reviewers": list(fields.get("reviewers") or []),
|
|
95
|
+
"fence": 0,
|
|
96
|
+
"rev": 0,
|
|
97
|
+
"released": False,
|
|
98
|
+
"created": created,
|
|
99
|
+
"updated": created,
|
|
100
|
+
"notes": [],
|
|
101
|
+
"reviews": [],
|
|
102
|
+
"verifications": [],
|
|
103
|
+
"costs": [],
|
|
104
|
+
}
|
|
105
|
+
if not card["title"]:
|
|
106
|
+
raise Precondition("title is required")
|
|
107
|
+
return card
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def a2a_state(card):
|
|
111
|
+
if card["lane"] == "todo" and card.get("released"):
|
|
112
|
+
return "canceled"
|
|
113
|
+
return A2A_STATE[card["lane"]]
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def approvals(card):
|
|
117
|
+
latest = {}
|
|
118
|
+
for r in card.get("reviews", []):
|
|
119
|
+
if r["verdict"] != "comment":
|
|
120
|
+
latest[r["by"]] = r["verdict"]
|
|
121
|
+
return sorted(a for a, v in latest.items() if v == "approve" and a != card.get("owner"))
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def last_verification(card):
|
|
125
|
+
runs = card.get("verifications") or []
|
|
126
|
+
return runs[-1] if runs else None
|
raidlock/core/models.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
from . import store
|
|
6
|
+
|
|
7
|
+
DEFAULT_DISPLAY = {
|
|
8
|
+
"claude-opus-5-5": "Claude Opus 5.5",
|
|
9
|
+
"claude-sonnet-5-5": "Claude Sonnet 5.5",
|
|
10
|
+
"claude-haiku-5-5": "Claude Haiku 5.5",
|
|
11
|
+
"gpt-6-astra": "GPT-6 Astra",
|
|
12
|
+
}
|
|
13
|
+
_TOML_MODEL = re.compile(r'^\s*model\s*=\s*"([^"]+)"\s*$')
|
|
14
|
+
_TOML_EFFORT = re.compile(r'^\s*model_reasoning_effort\s*=\s*"([^"]+)"\s*$')
|
|
15
|
+
_VERSION = re.compile(r"(?<![0-9])(\d{1,2})(?:[-.](\d{1,2}))?(?![0-9])")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def display_map(home):
|
|
19
|
+
path = os.path.join(home, "models.json")
|
|
20
|
+
data = store.read_json(path)
|
|
21
|
+
if data is None:
|
|
22
|
+
store.write_doc(path, "models", DEFAULT_DISPLAY)
|
|
23
|
+
return dict(DEFAULT_DISPLAY)
|
|
24
|
+
store.check_schema(data, path)
|
|
25
|
+
return {**DEFAULT_DISPLAY, **data.get("models", {})}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def display(model, home):
|
|
29
|
+
if not model:
|
|
30
|
+
return "unknown model"
|
|
31
|
+
return display_map(home).get(model, model)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def version(model):
|
|
35
|
+
"""Best-effort version from a model id: claude-opus-5-5 -> 5.5, gpt-6-astra -> 6."""
|
|
36
|
+
m = _VERSION.search(model or "")
|
|
37
|
+
if not m:
|
|
38
|
+
return ""
|
|
39
|
+
return m.group(1) + ("." + m.group(2) if m.group(2) else "")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _codex_config(env):
|
|
43
|
+
base = env.get("CODEX_HOME") or os.path.join(os.path.expanduser("~"), ".codex")
|
|
44
|
+
model = effort = ""
|
|
45
|
+
try:
|
|
46
|
+
with open(os.path.join(base, "config.toml"), encoding="utf-8") as f:
|
|
47
|
+
for line in f:
|
|
48
|
+
if line.lstrip().startswith("["):
|
|
49
|
+
break
|
|
50
|
+
m, e = _TOML_MODEL.match(line), _TOML_EFFORT.match(line)
|
|
51
|
+
model = m.group(1) if m else model
|
|
52
|
+
effort = e.group(1) if e else effort
|
|
53
|
+
except OSError:
|
|
54
|
+
pass
|
|
55
|
+
return model, effort
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _json_model(path, *keys):
|
|
59
|
+
try:
|
|
60
|
+
with open(path, encoding="utf-8") as f:
|
|
61
|
+
data = json.load(f)
|
|
62
|
+
except (OSError, ValueError):
|
|
63
|
+
return ""
|
|
64
|
+
for k in keys:
|
|
65
|
+
data = data.get(k) if isinstance(data, dict) else None
|
|
66
|
+
return data if isinstance(data, str) else ""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _claude_config(env):
|
|
70
|
+
base = env.get("CLAUDE_CONFIG_DIR") or os.path.join(os.path.expanduser("~"), ".claude")
|
|
71
|
+
return _json_model(os.path.join(base, "settings.json"), "model")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _gemini_config(env):
|
|
75
|
+
base = env.get("GEMINI_CONFIG_DIR") or os.path.join(os.path.expanduser("~"), ".gemini")
|
|
76
|
+
path = os.path.join(base, "settings.json")
|
|
77
|
+
return _json_model(path, "model", "name") or _json_model(path, "model")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _runtime_sources(runtime, env):
|
|
81
|
+
if runtime == "codex-cli":
|
|
82
|
+
model, effort = _codex_config(env)
|
|
83
|
+
return [(model, "config", effort)]
|
|
84
|
+
if runtime == "claude-code":
|
|
85
|
+
return [(env.get("ANTHROPIC_MODEL", ""), "env", ""), (_claude_config(env), "config", "")]
|
|
86
|
+
if runtime == "gemini-cli":
|
|
87
|
+
return [(env.get("GEMINI_MODEL", ""), "env", ""), (_gemini_config(env), "config", "")]
|
|
88
|
+
return []
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def detect(runtime, declared=None, flag=None, reported=None, effort=None, env=None):
|
|
92
|
+
"""Resolve an agent's model: declared, RAIDLOCK_MODEL, runner flag, runtime-reported, runtime env, runtime
|
|
93
|
+
config."""
|
|
94
|
+
env = os.environ if env is None else env
|
|
95
|
+
sources = [
|
|
96
|
+
(declared, "declared", ""),
|
|
97
|
+
(env.get("RAIDLOCK_MODEL", ""), "env", ""),
|
|
98
|
+
(flag, "flag", ""),
|
|
99
|
+
(reported, "reported", ""),
|
|
100
|
+
] + _runtime_sources(runtime, env)
|
|
101
|
+
for model, source, found_effort in sources:
|
|
102
|
+
if model:
|
|
103
|
+
return {
|
|
104
|
+
"model": model,
|
|
105
|
+
"model_version": version(model),
|
|
106
|
+
"effort": effort or found_effort or "",
|
|
107
|
+
"model_source": source,
|
|
108
|
+
}
|
|
109
|
+
return {"model": "", "model_version": "", "effort": effort or "", "model_source": "unknown"}
|
raidlock/core/paths.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import posixpath
|
|
2
|
+
import re
|
|
3
|
+
import unicodedata
|
|
4
|
+
|
|
5
|
+
from .errors import Precondition
|
|
6
|
+
|
|
7
|
+
_DRIVE = re.compile(r"^[A-Za-z]:")
|
|
8
|
+
_CONTROL = re.compile(r"[\x00-\x1f\x7f-\x9f
]")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def canonical(raw, casefold=False):
|
|
12
|
+
"""Return the canonical form of a lock path or raise Precondition."""
|
|
13
|
+
if not isinstance(raw, str):
|
|
14
|
+
raise Precondition("lock path must be text", path=repr(raw))
|
|
15
|
+
text = unicodedata.normalize("NFC", raw.strip())
|
|
16
|
+
if not text:
|
|
17
|
+
raise Precondition("empty lock path", path=raw)
|
|
18
|
+
if _CONTROL.search(text):
|
|
19
|
+
raise Precondition("control character in lock path", path=raw)
|
|
20
|
+
text = text.replace("\\", "/")
|
|
21
|
+
if text.startswith("/") or text.startswith("~") or _DRIVE.match(text):
|
|
22
|
+
raise Precondition(f"lock path must be relative to the workspace: {raw}", path=raw)
|
|
23
|
+
if ".." in text.split("/"):
|
|
24
|
+
raise Precondition(f"lock path must not contain '..': {raw}", path=raw)
|
|
25
|
+
text = posixpath.normpath(text).strip("/")
|
|
26
|
+
if text in ("", "."):
|
|
27
|
+
raise Precondition(f"lock path resolves to the workspace root: {raw}", path=raw)
|
|
28
|
+
return text.casefold() if casefold else text
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def split(text):
|
|
32
|
+
return [p for p in text.split("/") if p]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def parse_list(value):
|
|
36
|
+
if value is None:
|
|
37
|
+
return []
|
|
38
|
+
items = value if isinstance(value, (list, tuple)) else re.split(r"[,\s]+", value)
|
|
39
|
+
out = []
|
|
40
|
+
for item in items:
|
|
41
|
+
if item and item.strip():
|
|
42
|
+
p = canonical(item)
|
|
43
|
+
if p not in out:
|
|
44
|
+
out.append(p)
|
|
45
|
+
return out
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _parts(path, fold):
|
|
49
|
+
parts = split(path)
|
|
50
|
+
return [p.casefold() for p in parts] if fold else parts
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def contains(outer, inner, casefold=False):
|
|
54
|
+
"""True when `inner` equals `outer` or lies under it on a `/` boundary."""
|
|
55
|
+
a, b = _parts(outer, casefold), _parts(inner, casefold)
|
|
56
|
+
return len(a) <= len(b) and b[: len(a)] == a
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def overlaps(a, b, casefold=False):
|
|
60
|
+
"""True when one path contains the other."""
|
|
61
|
+
return contains(a, b, casefold) or contains(b, a, casefold)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import errno
|
|
2
|
+
import os
|
|
3
|
+
import shutil
|
|
4
|
+
import stat
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
NOFOLLOW = getattr(os, "O_NOFOLLOW", 0)
|
|
8
|
+
NONBLOCK = getattr(os, "O_NONBLOCK", 0)
|
|
9
|
+
BINARY = getattr(os, "O_BINARY", 0)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _redirected(path):
|
|
13
|
+
junction = getattr(os.path, "isjunction", None)
|
|
14
|
+
return os.path.islink(path) or bool(junction and junction(path))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def open_nofollow(path, flags, mode=0o600):
|
|
18
|
+
"""Open a path without following a final symlink, with an lstat check where O_NOFOLLOW is missing."""
|
|
19
|
+
if NOFOLLOW:
|
|
20
|
+
return os.open(path, flags | NOFOLLOW | BINARY, mode)
|
|
21
|
+
if _redirected(path):
|
|
22
|
+
raise OSError(errno.ELOOP, "refusing to follow a symlink", path)
|
|
23
|
+
fd = os.open(path, flags | BINARY, mode)
|
|
24
|
+
try:
|
|
25
|
+
opened = os.fstat(fd)
|
|
26
|
+
named = os.lstat(path)
|
|
27
|
+
if _redirected(path) or (opened.st_dev, opened.st_ino) != (named.st_dev, named.st_ino):
|
|
28
|
+
raise OSError(errno.ELOOP, "refusing to follow a symlink", path)
|
|
29
|
+
except BaseException:
|
|
30
|
+
os.close(fd)
|
|
31
|
+
raise
|
|
32
|
+
return fd
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _writable_retry(function, path, exc):
|
|
36
|
+
if isinstance(exc, tuple):
|
|
37
|
+
exc = exc[1]
|
|
38
|
+
if os.path.islink(path) or not isinstance(exc, PermissionError):
|
|
39
|
+
raise exc
|
|
40
|
+
os.chmod(path, stat.S_IWRITE | stat.S_IREAD)
|
|
41
|
+
function(path)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def remove_tree(path, ignore_errors=False):
|
|
45
|
+
"""shutil.rmtree that also removes read-only files, such as git objects on Windows."""
|
|
46
|
+
try:
|
|
47
|
+
if sys.version_info >= (3, 12):
|
|
48
|
+
shutil.rmtree(path, onexc=_writable_retry)
|
|
49
|
+
else:
|
|
50
|
+
shutil.rmtree(path, onerror=_writable_retry)
|
|
51
|
+
except OSError:
|
|
52
|
+
if not ignore_errors:
|
|
53
|
+
raise
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import hmac
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import secrets
|
|
6
|
+
import stat
|
|
7
|
+
|
|
8
|
+
from .errors import Precondition
|
|
9
|
+
from .portable import BINARY, NONBLOCK, open_nofollow
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def command_hash(command):
|
|
13
|
+
return hashlib.sha256(command.encode()).hexdigest()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _key(board, create=False):
|
|
17
|
+
from .identity import token_path
|
|
18
|
+
|
|
19
|
+
path = os.path.join(os.path.dirname(token_path(board.home, board.uid, board.lead)), "verify.key")
|
|
20
|
+
if os.path.commonpath((os.path.realpath(path), os.path.realpath(board.workspace))) == os.path.realpath(
|
|
21
|
+
board.workspace
|
|
22
|
+
):
|
|
23
|
+
raise Precondition("verification key must live outside the workspace; move RAIDLOCK_HOME")
|
|
24
|
+
if create:
|
|
25
|
+
os.makedirs(os.path.dirname(path), mode=0o700, exist_ok=True)
|
|
26
|
+
try:
|
|
27
|
+
if create:
|
|
28
|
+
try:
|
|
29
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | BINARY, 0o600)
|
|
30
|
+
except FileExistsError:
|
|
31
|
+
pass
|
|
32
|
+
else:
|
|
33
|
+
with os.fdopen(fd, "wb") as stream:
|
|
34
|
+
stream.write(secrets.token_bytes(32))
|
|
35
|
+
if os.path.islink(path):
|
|
36
|
+
raise OSError("symlink")
|
|
37
|
+
fd = open_nofollow(path, os.O_RDONLY | NONBLOCK)
|
|
38
|
+
with os.fdopen(fd, "rb") as stream:
|
|
39
|
+
info = os.fstat(stream.fileno())
|
|
40
|
+
if (
|
|
41
|
+
not stat.S_ISREG(info.st_mode)
|
|
42
|
+
or info.st_nlink != 1
|
|
43
|
+
or (os.name == "posix" and stat.S_IMODE(info.st_mode) != 0o600)
|
|
44
|
+
):
|
|
45
|
+
raise OSError("not a private regular file")
|
|
46
|
+
key = stream.read(33)
|
|
47
|
+
if len(key) != 32:
|
|
48
|
+
raise OSError("invalid key")
|
|
49
|
+
return key
|
|
50
|
+
except OSError as exc:
|
|
51
|
+
raise Precondition("missing or unsafe board verification key") from exc
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _payload(receipt):
|
|
55
|
+
return json.dumps(
|
|
56
|
+
{k: v for k, v in receipt.items() if k != "signature"}, sort_keys=True, separators=(",", ":"), allow_nan=False
|
|
57
|
+
).encode()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def sign(board, receipt):
|
|
61
|
+
return {**receipt, "signature": hmac.new(_key(board, create=True), _payload(receipt), hashlib.sha256).hexdigest()}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def valid(board, card, receipt):
|
|
65
|
+
try:
|
|
66
|
+
if receipt.get("card") != card["id"] or receipt.get("board") != board.uid:
|
|
67
|
+
return False
|
|
68
|
+
if receipt.get("command_hash") != command_hash(card.get("verify_cmd", "")):
|
|
69
|
+
return False
|
|
70
|
+
expected = hmac.new(_key(board), _payload(receipt), hashlib.sha256).hexdigest()
|
|
71
|
+
return hmac.compare_digest(expected, receipt.get("signature", ""))
|
|
72
|
+
except (Precondition, TypeError, ValueError, AttributeError):
|
|
73
|
+
return False
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def home():
|
|
5
|
+
"""Global raidlock home: $RAIDLOCK_HOME or ~/.raidlock."""
|
|
6
|
+
return os.path.abspath(os.environ.get("RAIDLOCK_HOME") or os.path.join(os.path.expanduser("~"), ".raidlock"))
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
_SYNC = (
|
|
10
|
+
("/library/mobile documents/", "iCloud"),
|
|
11
|
+
("/icloud drive/", "iCloud"),
|
|
12
|
+
("/dropbox/", "Dropbox"),
|
|
13
|
+
("/onedrive", "OneDrive"),
|
|
14
|
+
("/google drive/", "Google Drive"),
|
|
15
|
+
("/googledrive/", "Google Drive"),
|
|
16
|
+
("/my drive/", "Google Drive"),
|
|
17
|
+
("/library/cloudstorage/", "a cloud storage provider"),
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def synced_folder(folder):
|
|
22
|
+
"""Name of the sync provider when `folder` sits in a cloud-synced location, else ''."""
|
|
23
|
+
real = (os.path.realpath(folder).replace("\\", "/") + "/").lower()
|
|
24
|
+
return next((name for marker, name in _SYNC if marker in real), "")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def register(index, uid, board_dir):
|
|
28
|
+
"""Add or refresh a board in the global registry (raidlock.db); returns its workspace name."""
|
|
29
|
+
stem = os.path.basename(os.path.dirname(os.path.realpath(board_dir))) or "board"
|
|
30
|
+
return index.register(uid, board_dir, stem)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def workspaces(index):
|
|
34
|
+
"""Registered workspaces: [{uid, name, board, last_seen, version}]."""
|
|
35
|
+
return index.workspaces()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def remove(index, names):
|
|
39
|
+
return index.unregister(names)
|