llmt-cli 0.2.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.
- llmt/__init__.py +7 -0
- llmt/api.py +138 -0
- llmt/cli.py +562 -0
- llmt/clients.py +1316 -0
- llmt/data/manifest-pubkey.json +7 -0
- llmt/doctor.py +970 -0
- llmt/doctorcmd.py +142 -0
- llmt/download.py +321 -0
- llmt/errors.py +132 -0
- llmt/layout.py +676 -0
- llmt/manifest.py +314 -0
- llmt/matcher.py +358 -0
- llmt/models.py +130 -0
- llmt/output.py +51 -0
- llmt/permissions.py +154 -0
- llmt/scancmd.py +556 -0
- llmt/scanner.py +328 -0
- llmt/telemetry/__init__.py +15 -0
- llmt/telemetry/artifacts.py +217 -0
- llmt/telemetry/beat.py +221 -0
- llmt/telemetry/cmd.py +358 -0
- llmt/telemetry/collect.py +252 -0
- llmt/telemetry/config.py +237 -0
- llmt/telemetry/consent.py +57 -0
- llmt/telemetry/errors.py +41 -0
- llmt/telemetry/wire.py +194 -0
- llmt/verify.py +129 -0
- llmt_cli-0.2.0.dist-info/METADATA +307 -0
- llmt_cli-0.2.0.dist-info/RECORD +33 -0
- llmt_cli-0.2.0.dist-info/WHEEL +5 -0
- llmt_cli-0.2.0.dist-info/entry_points.txt +2 -0
- llmt_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
- llmt_cli-0.2.0.dist-info/top_level.txt +1 -0
llmt/scanner.py
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
"""Local model-file scanner for ``llmt scan`` (WP-14.4-S).
|
|
2
|
+
|
|
3
|
+
Two-phase design:
|
|
4
|
+
|
|
5
|
+
1. **Candidate gathering** — walk a set of scan roots, fingerprinting known
|
|
6
|
+
model layouts, applying a *size + extension prefilter* so we never hash 4 TB
|
|
7
|
+
of unrelated data. Gathering is cheap (stat only).
|
|
8
|
+
2. **Hashing** — full-file streaming SHA-256 over the (much smaller) candidate
|
|
9
|
+
set, with progress. The full-file hash is the ONLY seedability signal the
|
|
10
|
+
downstream matcher trusts: a truncated / wrong-size / corrupt file simply
|
|
11
|
+
produces a different digest and can never be "matched".
|
|
12
|
+
|
|
13
|
+
Hostile-filesystem hardening (red-team axis):
|
|
14
|
+
* Symlinks are never followed — neither symlinked directories (no traversal
|
|
15
|
+
out of the scan roots, no symlink loops) nor symlinked files (we skip them
|
|
16
|
+
entirely rather than hash whatever they point at).
|
|
17
|
+
* Only regular files are ever opened: device nodes, FIFOs, sockets are
|
|
18
|
+
skipped (``entry.is_file(follow_symlinks=False)``).
|
|
19
|
+
* Recursion depth is bounded (``max_depth``) and visited directories are
|
|
20
|
+
de-duplicated by ``(st_dev, st_ino)`` to survive bind-mount / hardlink loops.
|
|
21
|
+
* Every candidate's real path is re-checked to still live under the root's
|
|
22
|
+
real path (defence in depth on top of the symlink skip).
|
|
23
|
+
|
|
24
|
+
Layout honesty:
|
|
25
|
+
* ``ollama`` — we genuinely parse the manifest tree to map blob digests
|
|
26
|
+
(blobs are named ``sha256-<digest>``, NOT ``*.gguf``) to a friendly
|
|
27
|
+
``model:tag`` label. Blobs are matched by digest.
|
|
28
|
+
* ``lmstudio`` / ``text-generation-webui`` / ``generic`` — we detect the root
|
|
29
|
+
but scan it *generically* by model file-extension; we do not parse their
|
|
30
|
+
per-tool metadata. This is documented as best-effort.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import hashlib
|
|
36
|
+
import json
|
|
37
|
+
import os
|
|
38
|
+
import re
|
|
39
|
+
import stat
|
|
40
|
+
from dataclasses import dataclass
|
|
41
|
+
from typing import Callable, Iterator, Optional
|
|
42
|
+
|
|
43
|
+
_CHUNK = 64 * 1024 # streaming SHA-256 block size — the trust core
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _safe_sha256(path: str) -> str:
|
|
47
|
+
"""Full-file SHA-256 with an OPEN-TIME re-check that closes the TOCTOU gap
|
|
48
|
+
between the candidate walk (which vets each entry as a regular file) and the
|
|
49
|
+
hashing pass (which re-opens by path). A hostile writer in the scan root
|
|
50
|
+
could, in that window, swap a vetted regular file for a symlink (-> we would
|
|
51
|
+
hash and leak the digest of whatever it points at) or a FIFO (-> a blocking
|
|
52
|
+
read would hang the scan forever). We defeat both at open() time:
|
|
53
|
+
|
|
54
|
+
* ``O_NOFOLLOW`` — a symlink swapped in is refused (ELOOP), never followed.
|
|
55
|
+
* ``O_NONBLOCK`` — opening a FIFO with no writer returns immediately
|
|
56
|
+
instead of blocking; we then reject it by type.
|
|
57
|
+
* ``fstat`` + ``S_ISREG`` on the OPEN fd — only a genuine regular file is
|
|
58
|
+
ever read; anything else raises OSError (the caller skips it).
|
|
59
|
+
"""
|
|
60
|
+
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0)
|
|
61
|
+
fd = os.open(path, flags)
|
|
62
|
+
try:
|
|
63
|
+
st = os.fstat(fd)
|
|
64
|
+
if not stat.S_ISREG(st.st_mode):
|
|
65
|
+
raise OSError(f"not a regular file at hash time: {path!r}")
|
|
66
|
+
h = hashlib.sha256()
|
|
67
|
+
while True:
|
|
68
|
+
chunk = os.read(fd, _CHUNK)
|
|
69
|
+
if not chunk:
|
|
70
|
+
break
|
|
71
|
+
h.update(chunk)
|
|
72
|
+
return h.hexdigest()
|
|
73
|
+
finally:
|
|
74
|
+
os.close(fd)
|
|
75
|
+
|
|
76
|
+
DEFAULT_MIN_SIZE = 1 * 1024 * 1024 # 1 MiB: below this it is not a model shard
|
|
77
|
+
DEFAULT_MAX_DEPTH = 40
|
|
78
|
+
|
|
79
|
+
KNOWN_MODEL_EXTS = {
|
|
80
|
+
".gguf", ".ggml", ".safetensors", ".bin", ".pt", ".pth",
|
|
81
|
+
".onnx", ".gptq", ".awq", ".npz", ".mlmodel",
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
_OLLAMA_BLOB_RE = re.compile(r"^sha256[-:][0-9a-f]{64}$")
|
|
85
|
+
_HEX64_RE = re.compile(r"^[0-9a-f]{64}$")
|
|
86
|
+
|
|
87
|
+
LAYOUT_GENERIC = "generic"
|
|
88
|
+
LAYOUT_OLLAMA = "ollama"
|
|
89
|
+
LAYOUT_LMSTUDIO = "lmstudio"
|
|
90
|
+
LAYOUT_TEXTGEN = "text-generation-webui"
|
|
91
|
+
LAYOUT_PATH = "path"
|
|
92
|
+
|
|
93
|
+
SkipFn = Optional[Callable[[str, str], None]]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass
|
|
97
|
+
class Candidate:
|
|
98
|
+
"""A file that passed the cheap prefilter and is worth hashing."""
|
|
99
|
+
path: str
|
|
100
|
+
size: int
|
|
101
|
+
layout: str
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass
|
|
105
|
+
class HashedFile:
|
|
106
|
+
"""A candidate with its computed full-file SHA-256."""
|
|
107
|
+
path: str
|
|
108
|
+
size: int
|
|
109
|
+
sha256: str
|
|
110
|
+
layout: str
|
|
111
|
+
label: Optional[str] = None # friendly name (e.g. Ollama model:tag), if known
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# --------------------------------------------------------------------------- #
|
|
115
|
+
# Root discovery
|
|
116
|
+
# --------------------------------------------------------------------------- #
|
|
117
|
+
|
|
118
|
+
def default_roots() -> list[tuple[str, str]]:
|
|
119
|
+
"""Known model-store roots that currently exist, tagged with their layout.
|
|
120
|
+
|
|
121
|
+
We only return directories that exist so the scan does not invent paths.
|
|
122
|
+
Order is stable but not significant (candidates are de-duplicated by real
|
|
123
|
+
path downstream via the (dev, ino) visited-set on directories).
|
|
124
|
+
"""
|
|
125
|
+
home = os.path.expanduser("~")
|
|
126
|
+
candidates = [
|
|
127
|
+
(os.path.join(home, ".ollama", "models"), LAYOUT_OLLAMA),
|
|
128
|
+
("/usr/share/ollama/.ollama/models", LAYOUT_OLLAMA),
|
|
129
|
+
("/var/lib/ollama/.ollama/models", LAYOUT_OLLAMA),
|
|
130
|
+
(os.path.join(home, ".cache", "lm-studio", "models"), LAYOUT_LMSTUDIO),
|
|
131
|
+
(os.path.join(home, ".lmstudio", "models"), LAYOUT_LMSTUDIO),
|
|
132
|
+
(os.path.join(home, "text-generation-webui", "models"), LAYOUT_TEXTGEN),
|
|
133
|
+
(os.path.join(home, "models"), LAYOUT_GENERIC),
|
|
134
|
+
("/models", LAYOUT_GENERIC),
|
|
135
|
+
]
|
|
136
|
+
out: list[tuple[str, str]] = []
|
|
137
|
+
for path, layout in candidates:
|
|
138
|
+
if os.path.isdir(path):
|
|
139
|
+
out.append((path, layout))
|
|
140
|
+
return out
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# --------------------------------------------------------------------------- #
|
|
144
|
+
# Ollama manifest parsing (the one layout we truly parse)
|
|
145
|
+
# --------------------------------------------------------------------------- #
|
|
146
|
+
|
|
147
|
+
def ollama_labels(models_root: str) -> dict[str, str]:
|
|
148
|
+
"""Map ``blob-digest -> "model:tag"`` by parsing Ollama's manifest tree.
|
|
149
|
+
|
|
150
|
+
Best-effort and fully defensive: any unreadable/oversized/non-JSON manifest
|
|
151
|
+
is skipped, never fatal. The returned digests are lowercase 64-hex; they are
|
|
152
|
+
used only to attach a friendly label to a blob we still hash ourselves.
|
|
153
|
+
"""
|
|
154
|
+
labels: dict[str, str] = {}
|
|
155
|
+
man_root = os.path.join(models_root, "manifests")
|
|
156
|
+
if not os.path.isdir(man_root):
|
|
157
|
+
return labels
|
|
158
|
+
for dirpath, _dirnames, filenames in os.walk(man_root, followlinks=False):
|
|
159
|
+
for fn in filenames:
|
|
160
|
+
fp = os.path.join(dirpath, fn)
|
|
161
|
+
try:
|
|
162
|
+
if os.path.islink(fp) or not os.path.isfile(fp):
|
|
163
|
+
continue
|
|
164
|
+
if os.path.getsize(fp) > 1_000_000: # a manifest is tiny JSON
|
|
165
|
+
continue
|
|
166
|
+
with open(fp, "rb") as fh:
|
|
167
|
+
data = json.loads(fh.read().decode("utf-8", "replace"))
|
|
168
|
+
except (OSError, ValueError):
|
|
169
|
+
continue
|
|
170
|
+
if not isinstance(data, dict):
|
|
171
|
+
continue
|
|
172
|
+
model = os.path.basename(dirpath)
|
|
173
|
+
label = f"{model}:{fn}"
|
|
174
|
+
layers = data.get("layers")
|
|
175
|
+
if not isinstance(layers, list):
|
|
176
|
+
continue
|
|
177
|
+
for layer in layers:
|
|
178
|
+
if not isinstance(layer, dict):
|
|
179
|
+
continue
|
|
180
|
+
dig = layer.get("digest")
|
|
181
|
+
mtype = str(layer.get("mediaType") or "")
|
|
182
|
+
if isinstance(dig, str) and "model" in mtype:
|
|
183
|
+
d = dig.split(":", 1)[-1].lower()
|
|
184
|
+
if _HEX64_RE.match(d):
|
|
185
|
+
labels[d] = label
|
|
186
|
+
return labels
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# --------------------------------------------------------------------------- #
|
|
190
|
+
# Candidate walk (hostile-fs hardened)
|
|
191
|
+
# --------------------------------------------------------------------------- #
|
|
192
|
+
|
|
193
|
+
def _ext_ok(name: str, exts: set[str]) -> bool:
|
|
194
|
+
dot = name.rfind(".")
|
|
195
|
+
if dot < 0:
|
|
196
|
+
return False
|
|
197
|
+
return name[dot:].lower() in exts
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _within(base_real: str, path_real: str) -> bool:
|
|
201
|
+
try:
|
|
202
|
+
common = os.path.commonpath([base_real, path_real])
|
|
203
|
+
except ValueError:
|
|
204
|
+
return False
|
|
205
|
+
return os.path.normcase(common) == os.path.normcase(base_real)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _skip(on_skip: SkipFn, path: str, reason: str) -> None:
|
|
209
|
+
if on_skip is not None:
|
|
210
|
+
on_skip(path, reason)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def iter_candidates(
|
|
214
|
+
roots: list[tuple[str, str]],
|
|
215
|
+
*,
|
|
216
|
+
min_size: int = DEFAULT_MIN_SIZE,
|
|
217
|
+
exts: Optional[set[str]] = None,
|
|
218
|
+
all_files: bool = False,
|
|
219
|
+
max_depth: int = DEFAULT_MAX_DEPTH,
|
|
220
|
+
on_skip: SkipFn = None,
|
|
221
|
+
) -> Iterator[Candidate]:
|
|
222
|
+
"""Yield prefiltered candidate files under ``roots`` without ever following
|
|
223
|
+
a symlink out of a root or opening a non-regular file."""
|
|
224
|
+
exts = KNOWN_MODEL_EXTS if exts is None else exts
|
|
225
|
+
seen_dirs: set[tuple[int, int]] = set()
|
|
226
|
+
for root, layout in roots:
|
|
227
|
+
root_abs = os.path.abspath(os.path.expanduser(root))
|
|
228
|
+
if not os.path.isdir(root_abs):
|
|
229
|
+
_skip(on_skip, root_abs, "root-missing")
|
|
230
|
+
continue
|
|
231
|
+
# The boundary is the root's REAL path; a candidate whose realpath
|
|
232
|
+
# leaves it is rejected even though symlinks are already skipped.
|
|
233
|
+
base_real = os.path.realpath(root_abs)
|
|
234
|
+
yield from _scan_dir(
|
|
235
|
+
root_abs, layout, base_real, 0,
|
|
236
|
+
min_size, exts, all_files, max_depth, seen_dirs, on_skip,
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _scan_dir(
|
|
241
|
+
dirpath: str, layout: str, base_real: str, depth: int,
|
|
242
|
+
min_size: int, exts: set[str], all_files: bool, max_depth: int,
|
|
243
|
+
seen: set[tuple[int, int]], on_skip: SkipFn,
|
|
244
|
+
) -> Iterator[Candidate]:
|
|
245
|
+
if depth > max_depth:
|
|
246
|
+
_skip(on_skip, dirpath, "max-depth")
|
|
247
|
+
return
|
|
248
|
+
try:
|
|
249
|
+
dstat = os.stat(dirpath) # dir we chose to descend into (never a symlink)
|
|
250
|
+
except OSError as exc:
|
|
251
|
+
_skip(on_skip, dirpath, f"stat-dir: {exc}")
|
|
252
|
+
return
|
|
253
|
+
key = (dstat.st_dev, dstat.st_ino)
|
|
254
|
+
if key in seen:
|
|
255
|
+
_skip(on_skip, dirpath, "loop")
|
|
256
|
+
return
|
|
257
|
+
seen.add(key)
|
|
258
|
+
|
|
259
|
+
try:
|
|
260
|
+
scan_it = os.scandir(dirpath)
|
|
261
|
+
except OSError as exc:
|
|
262
|
+
_skip(on_skip, dirpath, f"opendir: {exc}")
|
|
263
|
+
return
|
|
264
|
+
|
|
265
|
+
with scan_it:
|
|
266
|
+
for entry in scan_it:
|
|
267
|
+
try:
|
|
268
|
+
if entry.is_symlink():
|
|
269
|
+
_skip(on_skip, entry.path, "symlink")
|
|
270
|
+
continue
|
|
271
|
+
if entry.is_dir(follow_symlinks=False):
|
|
272
|
+
yield from _scan_dir(
|
|
273
|
+
entry.path, layout, base_real, depth + 1,
|
|
274
|
+
min_size, exts, all_files, max_depth, seen, on_skip,
|
|
275
|
+
)
|
|
276
|
+
continue
|
|
277
|
+
if not entry.is_file(follow_symlinks=False):
|
|
278
|
+
# device node / FIFO / socket / other — never open it.
|
|
279
|
+
_skip(on_skip, entry.path, "not-regular")
|
|
280
|
+
continue
|
|
281
|
+
est = entry.stat(follow_symlinks=False)
|
|
282
|
+
size = est.st_size
|
|
283
|
+
if size < min_size:
|
|
284
|
+
continue
|
|
285
|
+
name = entry.name
|
|
286
|
+
if not (all_files or _ext_ok(name, exts)
|
|
287
|
+
or _OLLAMA_BLOB_RE.match(name)):
|
|
288
|
+
continue
|
|
289
|
+
if not _within(base_real, os.path.realpath(entry.path)):
|
|
290
|
+
_skip(on_skip, entry.path, "escapes-root")
|
|
291
|
+
continue
|
|
292
|
+
yield Candidate(path=entry.path, size=size, layout=layout)
|
|
293
|
+
except OSError as exc:
|
|
294
|
+
_skip(on_skip, entry.path, f"stat: {exc}")
|
|
295
|
+
continue
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
# --------------------------------------------------------------------------- #
|
|
299
|
+
# Hashing
|
|
300
|
+
# --------------------------------------------------------------------------- #
|
|
301
|
+
|
|
302
|
+
def hash_candidates(
|
|
303
|
+
candidates: list[Candidate],
|
|
304
|
+
*,
|
|
305
|
+
labels: Optional[dict[str, str]] = None,
|
|
306
|
+
progress: Optional[Callable[[int, int, str], None]] = None,
|
|
307
|
+
) -> list[HashedFile]:
|
|
308
|
+
"""Full-file SHA-256 every candidate; attach a label by digest when known.
|
|
309
|
+
|
|
310
|
+
Unreadable files (races, permission flips) are skipped rather than fatal.
|
|
311
|
+
"""
|
|
312
|
+
labels = labels or {}
|
|
313
|
+
out: list[HashedFile] = []
|
|
314
|
+
total = len(candidates)
|
|
315
|
+
for i, cand in enumerate(candidates, 1):
|
|
316
|
+
if progress is not None:
|
|
317
|
+
progress(i, total, cand.path)
|
|
318
|
+
try:
|
|
319
|
+
digest = _safe_sha256(cand.path)
|
|
320
|
+
except OSError:
|
|
321
|
+
# symlink/FIFO/device swapped in after the walk, race, perm flip:
|
|
322
|
+
# skip rather than hash something untrusted or block forever.
|
|
323
|
+
continue
|
|
324
|
+
out.append(HashedFile(
|
|
325
|
+
path=cand.path, size=cand.size, sha256=digest,
|
|
326
|
+
layout=cand.layout, label=labels.get(digest),
|
|
327
|
+
))
|
|
328
|
+
return out
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""llmt telemetry — the OPT-IN agent write-API client (WP-14.4-T, BUILD2 14B).
|
|
2
|
+
|
|
3
|
+
Telemetry is a SEPARATE, explicit, OFF-BY-DEFAULT, revocable opt-in (DR-27).
|
|
4
|
+
Nothing in this package touches the network unless the user has run
|
|
5
|
+
``llmt telemetry enable`` and a token is present. Scan data and local file
|
|
6
|
+
PATHS never leave the machine: only torrent infohashes/names/states/progress,
|
|
7
|
+
artifact SHA-256s, and challenge-range hashes are ever sent. See
|
|
8
|
+
:mod:`llmt.telemetry.consent` for the exact, user-visible statement.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
AGENT_API_PATH = "/agent/v1"
|
|
14
|
+
|
|
15
|
+
__all__ = ["AGENT_API_PATH"]
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Artifact-level possession challenges (H2) + local artifact location.
|
|
2
|
+
|
|
3
|
+
A challenge references an artifact by ``sha256`` and asks for
|
|
4
|
+
``sha256(salt_bytes || file_bytes[offset : offset+length])``. We locate the
|
|
5
|
+
LOCAL file whose FULL sha256 equals the challenged digest (via the same
|
|
6
|
+
open-time-hardened full-file hash the scanner uses), then hash the requested
|
|
7
|
+
byte range. If the file is absent, or too short to cover the range, we answer
|
|
8
|
+
NOTHING for it (honest failure) — we NEVER fabricate a hash.
|
|
9
|
+
|
|
10
|
+
Full-file digests are cached by (realpath, size, mtime_ns) under the llmt home
|
|
11
|
+
so repeated beats do not re-hash unchanged files.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import hashlib
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import re
|
|
20
|
+
import stat
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from typing import Optional
|
|
23
|
+
|
|
24
|
+
from ..errors import ApiError
|
|
25
|
+
from ..scanner import _safe_sha256
|
|
26
|
+
from . import config as tcfg
|
|
27
|
+
|
|
28
|
+
_HEX64_RE = re.compile(r"^[0-9a-f]{64}$")
|
|
29
|
+
_MAX_CACHE_ENTRIES = 8192
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# --------------------------------------------------------------------------- #
|
|
33
|
+
# Range hashing (the possession proof)
|
|
34
|
+
# --------------------------------------------------------------------------- #
|
|
35
|
+
|
|
36
|
+
def decode_salt(salt_hex: object) -> bytes:
|
|
37
|
+
"""Decode the server-provided hex salt to raw bytes (contract (b)2)."""
|
|
38
|
+
if not isinstance(salt_hex, str):
|
|
39
|
+
raise ApiError("challenge salt is not a string.")
|
|
40
|
+
s = salt_hex.strip()
|
|
41
|
+
if s == "":
|
|
42
|
+
return b""
|
|
43
|
+
try:
|
|
44
|
+
return bytes.fromhex(s)
|
|
45
|
+
except ValueError as exc:
|
|
46
|
+
raise ApiError("challenge salt is not valid hex.") from exc
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def hash_range(path: str, salt: bytes, offset: int, length: int,
|
|
50
|
+
chunk_size: int = 1024 * 1024) -> Optional[str]:
|
|
51
|
+
"""Return ``sha256(salt || file[offset:offset+length])`` as hex, or None
|
|
52
|
+
if the file is missing / not a regular file / too short for the range.
|
|
53
|
+
|
|
54
|
+
Symlinks are refused at open time (O_NOFOLLOW); only a genuine regular
|
|
55
|
+
file is ever read. Reads are streamed so a large range never loads wholly
|
|
56
|
+
into memory.
|
|
57
|
+
"""
|
|
58
|
+
if offset < 0 or length < 0:
|
|
59
|
+
return None
|
|
60
|
+
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
|
|
61
|
+
try:
|
|
62
|
+
fd = os.open(path, flags)
|
|
63
|
+
except OSError:
|
|
64
|
+
return None
|
|
65
|
+
try:
|
|
66
|
+
st = os.fstat(fd)
|
|
67
|
+
if not stat.S_ISREG(st.st_mode):
|
|
68
|
+
return None
|
|
69
|
+
if st.st_size < offset + length:
|
|
70
|
+
return None # range not fully present -> fail honestly
|
|
71
|
+
os.lseek(fd, offset, os.SEEK_SET)
|
|
72
|
+
h = hashlib.sha256()
|
|
73
|
+
h.update(salt)
|
|
74
|
+
remaining = length
|
|
75
|
+
while remaining > 0:
|
|
76
|
+
chunk = os.read(fd, min(chunk_size, remaining))
|
|
77
|
+
if not chunk:
|
|
78
|
+
return None # truncated under us -> honest failure
|
|
79
|
+
h.update(chunk)
|
|
80
|
+
remaining -= len(chunk)
|
|
81
|
+
return h.hexdigest()
|
|
82
|
+
except OSError:
|
|
83
|
+
return None
|
|
84
|
+
finally:
|
|
85
|
+
os.close(fd)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# --------------------------------------------------------------------------- #
|
|
89
|
+
# Local artifact location (full-file sha256, cached)
|
|
90
|
+
# --------------------------------------------------------------------------- #
|
|
91
|
+
|
|
92
|
+
class ArtifactLocator:
|
|
93
|
+
"""Find the local file for a given full-file SHA-256 among candidate paths,
|
|
94
|
+
caching digests by (realpath, size, mtime_ns)."""
|
|
95
|
+
|
|
96
|
+
def __init__(self, cache_path: Optional[str] = None):
|
|
97
|
+
self.cache_path = cache_path or os.path.join(
|
|
98
|
+
tcfg.llmt_home(), "hash-cache.json")
|
|
99
|
+
self._cache: dict = {}
|
|
100
|
+
self._dirty = False
|
|
101
|
+
self._load()
|
|
102
|
+
|
|
103
|
+
def _load(self) -> None:
|
|
104
|
+
try:
|
|
105
|
+
with open(self.cache_path, "r", encoding="utf-8") as fh:
|
|
106
|
+
data = json.load(fh)
|
|
107
|
+
if isinstance(data, dict):
|
|
108
|
+
self._cache = {k: v for k, v in data.items()
|
|
109
|
+
if isinstance(k, str) and isinstance(v, str)
|
|
110
|
+
and _HEX64_RE.match(v)}
|
|
111
|
+
except (OSError, ValueError):
|
|
112
|
+
self._cache = {}
|
|
113
|
+
|
|
114
|
+
@staticmethod
|
|
115
|
+
def _key(path: str) -> Optional[str]:
|
|
116
|
+
try:
|
|
117
|
+
st = os.stat(path)
|
|
118
|
+
except OSError:
|
|
119
|
+
return None
|
|
120
|
+
rp = os.path.realpath(path)
|
|
121
|
+
return f"{rp}\x00{st.st_size}\x00{st.st_mtime_ns}"
|
|
122
|
+
|
|
123
|
+
def sha256_of(self, path: str) -> Optional[str]:
|
|
124
|
+
key = self._key(path)
|
|
125
|
+
if key is not None:
|
|
126
|
+
cached = self._cache.get(key)
|
|
127
|
+
if cached is not None:
|
|
128
|
+
return cached
|
|
129
|
+
try:
|
|
130
|
+
digest = _safe_sha256(path)
|
|
131
|
+
except OSError:
|
|
132
|
+
return None
|
|
133
|
+
if key is not None and len(self._cache) < _MAX_CACHE_ENTRIES:
|
|
134
|
+
self._cache[key] = digest
|
|
135
|
+
self._dirty = True
|
|
136
|
+
return digest
|
|
137
|
+
|
|
138
|
+
def locate(self, target_sha256: str, candidate_paths) -> Optional[str]:
|
|
139
|
+
"""Return the first candidate whose full sha256 == target, else None.
|
|
140
|
+
Short-circuits on the first match so a whole tree need not be hashed."""
|
|
141
|
+
target = (target_sha256 or "").strip().lower()
|
|
142
|
+
if not _HEX64_RE.match(target):
|
|
143
|
+
return None
|
|
144
|
+
for path in candidate_paths:
|
|
145
|
+
if self.sha256_of(path) == target:
|
|
146
|
+
return path
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
def save(self) -> None:
|
|
150
|
+
if not self._dirty:
|
|
151
|
+
return
|
|
152
|
+
try:
|
|
153
|
+
os.makedirs(os.path.dirname(self.cache_path), exist_ok=True)
|
|
154
|
+
tmp = self.cache_path + ".tmp"
|
|
155
|
+
with open(tmp, "w", encoding="utf-8") as fh:
|
|
156
|
+
json.dump(self._cache, fh)
|
|
157
|
+
os.replace(tmp, self.cache_path)
|
|
158
|
+
try:
|
|
159
|
+
os.chmod(self.cache_path, 0o600)
|
|
160
|
+
except OSError:
|
|
161
|
+
pass
|
|
162
|
+
self._dirty = False
|
|
163
|
+
except OSError:
|
|
164
|
+
pass
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# --------------------------------------------------------------------------- #
|
|
168
|
+
# Challenge parsing + answering
|
|
169
|
+
# --------------------------------------------------------------------------- #
|
|
170
|
+
|
|
171
|
+
@dataclass
|
|
172
|
+
class Challenge:
|
|
173
|
+
nonce: str
|
|
174
|
+
artifact_sha256: str
|
|
175
|
+
offset: int
|
|
176
|
+
length: int
|
|
177
|
+
salt: str
|
|
178
|
+
|
|
179
|
+
@classmethod
|
|
180
|
+
def parse(cls, raw: object) -> Optional["Challenge"]:
|
|
181
|
+
if not isinstance(raw, dict):
|
|
182
|
+
return None
|
|
183
|
+
nonce = raw.get("nonce")
|
|
184
|
+
sha = raw.get("artifact_sha256")
|
|
185
|
+
offset = raw.get("offset")
|
|
186
|
+
length = raw.get("length")
|
|
187
|
+
salt = raw.get("salt")
|
|
188
|
+
if not isinstance(nonce, str) or not nonce:
|
|
189
|
+
return None
|
|
190
|
+
if not isinstance(sha, str) or not _HEX64_RE.match(sha.strip().lower()):
|
|
191
|
+
return None
|
|
192
|
+
if isinstance(offset, bool) or not isinstance(offset, int) or offset < 0:
|
|
193
|
+
return None
|
|
194
|
+
if isinstance(length, bool) or not isinstance(length, int) or length < 0:
|
|
195
|
+
return None
|
|
196
|
+
if not isinstance(salt, str):
|
|
197
|
+
return None
|
|
198
|
+
return cls(nonce=nonce, artifact_sha256=sha.strip().lower(),
|
|
199
|
+
offset=offset, length=length, salt=salt)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def answer_challenge(challenge: Challenge, candidate_paths, locator,
|
|
203
|
+
chunk_size: int) -> Optional[str]:
|
|
204
|
+
"""Resolve the local file for ``challenge.artifact_sha256`` and return the
|
|
205
|
+
range hash, or None when we cannot honestly answer (file absent/incomplete).
|
|
206
|
+
"""
|
|
207
|
+
path = locator.locate(challenge.artifact_sha256, candidate_paths)
|
|
208
|
+
if path is None:
|
|
209
|
+
return None
|
|
210
|
+
try:
|
|
211
|
+
salt = decode_salt(challenge.salt)
|
|
212
|
+
except ApiError:
|
|
213
|
+
# A hostile / malformed salt is a per-challenge honest failure, never
|
|
214
|
+
# a reason to abort the whole beat (which would retry forever --loop).
|
|
215
|
+
return None
|
|
216
|
+
return hash_range(path, salt, challenge.offset, challenge.length,
|
|
217
|
+
chunk_size=chunk_size)
|