ithrl 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.
- ithrl/__init__.py +1 -0
- ithrl/__main__.py +6 -0
- ithrl/adapters/__init__.py +49 -0
- ithrl/adapters/_jsonl.py +266 -0
- ithrl/adapters/codex/__init__.py +5 -0
- ithrl/adapters/codex/context.py +204 -0
- ithrl/adapters/codex/decisions.py +224 -0
- ithrl/adapters/codex/discovery.py +60 -0
- ithrl/adapters/codex/parser.py +191 -0
- ithrl/adapters/contracts.py +106 -0
- ithrl/adapters/copilot/__init__.py +5 -0
- ithrl/adapters/copilot/context.py +182 -0
- ithrl/adapters/copilot/decisions.py +294 -0
- ithrl/adapters/copilot/discovery.py +28 -0
- ithrl/adapters/copilot/parser.py +283 -0
- ithrl/adapters/discovery.py +220 -0
- ithrl/adapters/errors.py +52 -0
- ithrl/adapters/registry.py +433 -0
- ithrl/behavior.py +138 -0
- ithrl/calibration.py +434 -0
- ithrl/case/__init__.py +33 -0
- ithrl/case/contracts.py +88 -0
- ithrl/case/evidence.py +169 -0
- ithrl/case/filesystem.py +340 -0
- ithrl/case/source.py +208 -0
- ithrl/case/source_context.py +119 -0
- ithrl/case/source_storage.py +234 -0
- ithrl/case/storage.py +377 -0
- ithrl/cli/__init__.py +968 -0
- ithrl/cli/extract.py +478 -0
- ithrl/cli/native.py +484 -0
- ithrl/cli/output.py +786 -0
- ithrl/cli/paths.py +24 -0
- ithrl/cli/sampled.py +485 -0
- ithrl/cli/sampled_output.py +472 -0
- ithrl/cli/sampled_reduction.py +1036 -0
- ithrl/cli/sampled_session.py +342 -0
- ithrl/cli/shrink.py +523 -0
- ithrl/context.py +465 -0
- ithrl/decisions.py +271 -0
- ithrl/examples/synthetic-trace.json +46 -0
- ithrl/execution/__init__.py +68 -0
- ithrl/execution/budget.py +146 -0
- ithrl/execution/contracts.py +426 -0
- ithrl/execution/preflight.py +346 -0
- ithrl/experiment.py +453 -0
- ithrl/extraction.py +206 -0
- ithrl/measurement.py +315 -0
- ithrl/observations.py +1148 -0
- ithrl/path_safety.py +100 -0
- ithrl/reduce/__init__.py +53 -0
- ithrl/reduce/contracts.py +508 -0
- ithrl/reduce/engine.py +901 -0
- ithrl/reduce/search.py +178 -0
- ithrl/results/__init__.py +49 -0
- ithrl/results/models.py +294 -0
- ithrl/results/storage.py +485 -0
- ithrl/results/summary.py +420 -0
- ithrl/results/validation.py +611 -0
- ithrl/run_state.py +1135 -0
- ithrl/runner/__init__.py +44 -0
- ithrl/runner/codex/__init__.py +17 -0
- ithrl/runner/codex/capability.py +708 -0
- ithrl/runner/codex/configuration.py +529 -0
- ithrl/runner/codex/isolation.py +396 -0
- ithrl/runner/codex/materialize.py +358 -0
- ithrl/runner/codex/policy.py +67 -0
- ithrl/runner/codex/protocol.py +649 -0
- ithrl/runner/codex/protocol_contracts.py +171 -0
- ithrl/runner/codex/runner.py +533 -0
- ithrl/runner/codex/runtime_configuration.py +264 -0
- ithrl/runner/codex/schema.py +527 -0
- ithrl/runner/codex/transcript.py +130 -0
- ithrl/runner/codex/worker.py +175 -0
- ithrl/runner/contracts.py +419 -0
- ithrl/runner/copilot/__init__.py +18 -0
- ithrl/runner/copilot/capability.py +825 -0
- ithrl/runner/copilot/configuration.py +201 -0
- ithrl/runner/copilot/isolation.py +374 -0
- ithrl/runner/copilot/materialize.py +484 -0
- ithrl/runner/copilot/policy.py +110 -0
- ithrl/runner/copilot/protocol/__init__.py +18 -0
- ithrl/runner/copilot/protocol/contracts.py +74 -0
- ithrl/runner/copilot/protocol/decoding.py +93 -0
- ithrl/runner/copilot/protocol/parser.py +418 -0
- ithrl/runner/copilot/protocol/state.py +130 -0
- ithrl/runner/copilot/runner.py +425 -0
- ithrl/runner/copilot/worker.py +351 -0
- ithrl/runner/evidence.py +345 -0
- ithrl/runner/fake.py +107 -0
- ithrl/runner/registry.py +90 -0
- ithrl/runner/runtime/__init__.py +50 -0
- ithrl/runner/runtime/contracts.py +157 -0
- ithrl/runner/runtime/output.py +184 -0
- ithrl/runner/runtime/process.py +954 -0
- ithrl/runner/runtime/protection.py +562 -0
- ithrl/runner/runtime/state.py +282 -0
- ithrl/runner/validation.py +376 -0
- ithrl/sampling.py +412 -0
- ithrl/statistics.py +276 -0
- ithrl/synthetic.py +511 -0
- ithrl/timestamps.py +26 -0
- ithrl/workflow.py +570 -0
- ithrl-0.1.0.dist-info/METADATA +118 -0
- ithrl-0.1.0.dist-info/RECORD +108 -0
- ithrl-0.1.0.dist-info/WHEEL +4 -0
- ithrl-0.1.0.dist-info/entry_points.txt +2 -0
- ithrl-0.1.0.dist-info/licenses/LICENSE +201 -0
ithrl/case/evidence.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Loaded case evidence and deterministic identity."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import copy
|
|
6
|
+
from collections.abc import Iterable
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from ithrl.case.contracts import CaseFormatError, CaseManifest
|
|
10
|
+
from ithrl.case.source import SourceArtifact, prepare_source_artifacts
|
|
11
|
+
from ithrl.case.source_context import validate_source_references
|
|
12
|
+
from ithrl.context import (
|
|
13
|
+
ContextGraph,
|
|
14
|
+
ContextNode,
|
|
15
|
+
JsonObject,
|
|
16
|
+
content_digest,
|
|
17
|
+
validate_json_limits,
|
|
18
|
+
)
|
|
19
|
+
from ithrl.decisions import DecisionEvidence
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def case_digest(
|
|
23
|
+
manifest: CaseManifest,
|
|
24
|
+
request: JsonObject | None,
|
|
25
|
+
nodes: Iterable[ContextNode],
|
|
26
|
+
source_artifacts: Iterable[SourceArtifact] = (),
|
|
27
|
+
decision: DecisionEvidence | None = None,
|
|
28
|
+
) -> str:
|
|
29
|
+
"""Return a deterministic identity for immutable case evidence."""
|
|
30
|
+
|
|
31
|
+
sources = prepare_source_artifacts(source_artifacts)
|
|
32
|
+
payload: dict[str, Any] = {
|
|
33
|
+
"manifest": manifest.model_dump(mode="json", exclude_none=True),
|
|
34
|
+
"context": [node.model_dump(mode="json") for node in nodes],
|
|
35
|
+
}
|
|
36
|
+
if request is not None:
|
|
37
|
+
payload["request"] = request
|
|
38
|
+
if sources:
|
|
39
|
+
payload["source"] = [artifact.identity.model_dump(mode="json") for artifact in sources]
|
|
40
|
+
if decision is not None:
|
|
41
|
+
payload["decision"] = decision.model_dump(mode="json")
|
|
42
|
+
return content_digest(payload)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class CaseEvidence:
|
|
46
|
+
"""Loaded immutable evidence with defensive request copies."""
|
|
47
|
+
|
|
48
|
+
__slots__ = (
|
|
49
|
+
"_context",
|
|
50
|
+
"_decision",
|
|
51
|
+
"_default_experiment",
|
|
52
|
+
"_digest",
|
|
53
|
+
"_manifest",
|
|
54
|
+
"_request",
|
|
55
|
+
"_source_artifacts",
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
def __init__(
|
|
59
|
+
self,
|
|
60
|
+
*,
|
|
61
|
+
manifest: CaseManifest,
|
|
62
|
+
request: JsonObject | None,
|
|
63
|
+
context: ContextGraph,
|
|
64
|
+
default_experiment: str,
|
|
65
|
+
source_artifacts: Iterable[SourceArtifact] = (),
|
|
66
|
+
decision: DecisionEvidence | None = None,
|
|
67
|
+
) -> None:
|
|
68
|
+
sources = prepare_source_artifacts(source_artifacts)
|
|
69
|
+
validate_case_inputs(
|
|
70
|
+
manifest=manifest,
|
|
71
|
+
request=request,
|
|
72
|
+
context=context,
|
|
73
|
+
source_artifacts=sources,
|
|
74
|
+
decision=decision,
|
|
75
|
+
)
|
|
76
|
+
self._manifest = manifest
|
|
77
|
+
self._request = copy.deepcopy(request) if request is not None else None
|
|
78
|
+
self._context = context
|
|
79
|
+
self._decision = decision
|
|
80
|
+
self._default_experiment = default_experiment
|
|
81
|
+
self._source_artifacts = sources
|
|
82
|
+
self._digest = case_digest(manifest, request, context.nodes, sources, decision)
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def manifest(self) -> CaseManifest:
|
|
86
|
+
return self._manifest
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def request(self) -> JsonObject:
|
|
90
|
+
if self._request is None:
|
|
91
|
+
raise CaseFormatError("case does not contain an exact provider request")
|
|
92
|
+
return copy.deepcopy(self._request)
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def has_request(self) -> bool:
|
|
96
|
+
return self._request is not None
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def decision(self) -> DecisionEvidence | None:
|
|
100
|
+
return self._decision
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def context(self) -> ContextGraph:
|
|
104
|
+
return self._context
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def default_experiment(self) -> str:
|
|
108
|
+
return self._default_experiment
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def source_artifacts(self) -> tuple[SourceArtifact, ...]:
|
|
112
|
+
return self._source_artifacts
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def digest(self) -> str:
|
|
116
|
+
return self._digest
|
|
117
|
+
|
|
118
|
+
def materialize(self, remove_ids: Iterable[str] = ()) -> JsonObject:
|
|
119
|
+
return self._context.materialize(self.request, remove_ids)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def validate_case_inputs(
|
|
123
|
+
*,
|
|
124
|
+
manifest: CaseManifest,
|
|
125
|
+
request: JsonObject | None,
|
|
126
|
+
context: ContextGraph,
|
|
127
|
+
source_artifacts: tuple[SourceArtifact, ...],
|
|
128
|
+
decision: DecisionEvidence | None,
|
|
129
|
+
) -> None:
|
|
130
|
+
"""Validate relationships between the immutable case inputs."""
|
|
131
|
+
|
|
132
|
+
_validate_capture_shape(manifest, request, decision)
|
|
133
|
+
if request is not None:
|
|
134
|
+
validate_json_limits(request)
|
|
135
|
+
context.validate_request(request)
|
|
136
|
+
elif any(node.path is not None for node in context.nodes):
|
|
137
|
+
raise CaseFormatError("request-backed context requires request.json")
|
|
138
|
+
if request is None and decision is None:
|
|
139
|
+
raise CaseFormatError("case evidence requires request.json or decision.json")
|
|
140
|
+
validate_source_references(context, decision, source_artifacts)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _validate_capture_shape(
|
|
144
|
+
manifest: CaseManifest,
|
|
145
|
+
request: JsonObject | None,
|
|
146
|
+
decision: DecisionEvidence | None,
|
|
147
|
+
) -> None:
|
|
148
|
+
capture_type = manifest.capture.request
|
|
149
|
+
if (request is not None) != (capture_type == "provider-request"):
|
|
150
|
+
raise CaseFormatError("capture classification does not match request.json")
|
|
151
|
+
if capture_type == "logical-session":
|
|
152
|
+
if decision is None:
|
|
153
|
+
raise CaseFormatError("logical-session capture requires decision.json")
|
|
154
|
+
if not all((manifest.harness, manifest.harness_version, manifest.source_schema)):
|
|
155
|
+
raise CaseFormatError("logical-session capture requires harness source metadata")
|
|
156
|
+
if manifest.source_schema != manifest.provenance.trace_schema:
|
|
157
|
+
raise CaseFormatError("logical-session source schema does not match provenance")
|
|
158
|
+
if manifest.capture.completeness != decision.completeness:
|
|
159
|
+
raise CaseFormatError("logical-session completeness does not match decision evidence")
|
|
160
|
+
if manifest.provenance.inference_call_id is not None:
|
|
161
|
+
raise CaseFormatError(
|
|
162
|
+
"logical-session capture cannot identify a provider inference call"
|
|
163
|
+
)
|
|
164
|
+
if decision is None:
|
|
165
|
+
return
|
|
166
|
+
if decision.selector.harness != manifest.harness:
|
|
167
|
+
raise CaseFormatError("decision harness does not match case metadata")
|
|
168
|
+
if decision.selector.source != manifest.provenance.source:
|
|
169
|
+
raise CaseFormatError("decision source does not match case provenance")
|
ithrl/case/filesystem.py
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
"""Safe filesystem primitives for private case workspaces."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import os
|
|
7
|
+
import stat
|
|
8
|
+
import tempfile
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from ithrl.case.contracts import (
|
|
13
|
+
CaseError,
|
|
14
|
+
CaseExistsError,
|
|
15
|
+
CaseFormatError,
|
|
16
|
+
UnsafeCasePathError,
|
|
17
|
+
)
|
|
18
|
+
from ithrl.path_safety import UnsafePathError, validate_directory_chain
|
|
19
|
+
|
|
20
|
+
type FileSignature = tuple[int, int, int, int, int]
|
|
21
|
+
type FileIdentity = tuple[int, int]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True, slots=True)
|
|
25
|
+
class PublishedFile:
|
|
26
|
+
"""Identity of a case file created during one publication operation."""
|
|
27
|
+
|
|
28
|
+
path: Path
|
|
29
|
+
signature: FileSignature
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def validate_target_path(path: Path) -> Path:
|
|
33
|
+
_validate_new_target(path)
|
|
34
|
+
parent = path.parent if path.parent != Path() else Path()
|
|
35
|
+
_validate_directory_control(parent)
|
|
36
|
+
reject_symlink_components(parent)
|
|
37
|
+
if not parent.is_dir():
|
|
38
|
+
raise UnsafeCasePathError(f"case parent directory does not exist: {parent}")
|
|
39
|
+
return path
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def prepare_target_path(path: Path) -> Path:
|
|
43
|
+
"""Create missing private parents after validating a new case path."""
|
|
44
|
+
|
|
45
|
+
_validate_new_target(path)
|
|
46
|
+
parent = path.parent if path.parent != Path() else Path()
|
|
47
|
+
_validate_directory_control(parent)
|
|
48
|
+
reject_symlink_components(parent)
|
|
49
|
+
_create_missing_private_directories(parent)
|
|
50
|
+
return validate_target_path(path)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def prepare_private_directory(path: Path) -> Path:
|
|
54
|
+
"""Create a private directory and any missing private parents."""
|
|
55
|
+
|
|
56
|
+
_validate_directory_control(path)
|
|
57
|
+
reject_symlink_components(path)
|
|
58
|
+
_create_missing_private_directories(path)
|
|
59
|
+
if not path.is_dir():
|
|
60
|
+
raise UnsafeCasePathError("private data path is not a directory")
|
|
61
|
+
set_private_mode(path, 0o700)
|
|
62
|
+
_validate_directory_control(path)
|
|
63
|
+
return path
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _create_missing_private_directories(path: Path) -> None:
|
|
67
|
+
missing: list[Path] = []
|
|
68
|
+
current = path
|
|
69
|
+
while not current.exists():
|
|
70
|
+
if os.path.lexists(current):
|
|
71
|
+
raise UnsafeCasePathError(f"case parent is unavailable: {current}")
|
|
72
|
+
missing.append(current)
|
|
73
|
+
ancestor = current.parent
|
|
74
|
+
if ancestor == current:
|
|
75
|
+
raise UnsafeCasePathError(f"case parent directory does not exist: {path}")
|
|
76
|
+
current = ancestor
|
|
77
|
+
if not current.is_dir():
|
|
78
|
+
raise UnsafeCasePathError(f"case parent directory does not exist: {current}")
|
|
79
|
+
for directory in reversed(missing):
|
|
80
|
+
create_private_directory(directory)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _validate_new_target(path: Path) -> None:
|
|
84
|
+
if ".." in path.parts:
|
|
85
|
+
raise UnsafeCasePathError("case path cannot contain '..'")
|
|
86
|
+
if os.path.lexists(path):
|
|
87
|
+
raise CaseExistsError(f"case path already exists: {path}")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def validate_existing_root(path: Path) -> Path:
|
|
91
|
+
if ".." in path.parts:
|
|
92
|
+
raise UnsafeCasePathError("case path cannot contain '..'")
|
|
93
|
+
_validate_directory_control(path)
|
|
94
|
+
reject_symlink_components(path)
|
|
95
|
+
try:
|
|
96
|
+
metadata = path.stat()
|
|
97
|
+
except OSError as error:
|
|
98
|
+
raise UnsafeCasePathError(f"case directory is unavailable: {path}") from error
|
|
99
|
+
if not stat.S_ISDIR(metadata.st_mode):
|
|
100
|
+
raise UnsafeCasePathError(f"case path is not a directory: {path}")
|
|
101
|
+
if os.name == "posix" and stat.S_IMODE(metadata.st_mode) != 0o700:
|
|
102
|
+
raise UnsafeCasePathError("case directory must have mode 0700")
|
|
103
|
+
return path
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def reject_symlink_components(path: Path) -> None:
|
|
107
|
+
current = Path(path.anchor) if path.is_absolute() else Path()
|
|
108
|
+
parts = path.parts[1:] if path.is_absolute() else path.parts
|
|
109
|
+
for part in parts:
|
|
110
|
+
current /= part
|
|
111
|
+
if os.path.lexists(current) and current.is_symlink():
|
|
112
|
+
raise UnsafeCasePathError(f"case path contains a symlink: {current}")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _validate_directory_control(path: Path) -> None:
|
|
116
|
+
try:
|
|
117
|
+
validate_directory_chain(path)
|
|
118
|
+
except UnsafePathError as error:
|
|
119
|
+
raise UnsafeCasePathError(str(error)) from error
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def create_private_directory(path: Path) -> Path:
|
|
123
|
+
try:
|
|
124
|
+
path.mkdir(mode=0o700)
|
|
125
|
+
except FileExistsError as error:
|
|
126
|
+
raise CaseExistsError("refusing to reuse a case directory") from error
|
|
127
|
+
except OSError as error:
|
|
128
|
+
raise CaseError("cannot create a case directory") from error
|
|
129
|
+
set_private_mode(path, 0o700)
|
|
130
|
+
return path
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def require_private_directory(path: Path, *, label: str) -> None:
|
|
134
|
+
reject_symlink_components(path.parent)
|
|
135
|
+
try:
|
|
136
|
+
metadata = path.lstat()
|
|
137
|
+
except OSError as error:
|
|
138
|
+
raise CaseFormatError(f"required case directory is unavailable: {label}") from error
|
|
139
|
+
if stat.S_ISLNK(metadata.st_mode):
|
|
140
|
+
raise UnsafeCasePathError(f"case entry must not be a symbolic link: {label}")
|
|
141
|
+
if not stat.S_ISDIR(metadata.st_mode):
|
|
142
|
+
raise UnsafeCasePathError(f"case entry must be a directory: {label}")
|
|
143
|
+
if os.name == "posix" and stat.S_IMODE(metadata.st_mode) != 0o700:
|
|
144
|
+
raise UnsafeCasePathError(f"case directory must have mode 0700: {label}")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def read_safe_text(path: Path, *, max_bytes: int, label: str | None = None) -> str:
|
|
148
|
+
file_label = label or path.name
|
|
149
|
+
content = read_safe_bytes(path, max_bytes=max_bytes, label=file_label)
|
|
150
|
+
try:
|
|
151
|
+
return content.decode("utf-8")
|
|
152
|
+
except UnicodeError as error:
|
|
153
|
+
raise CaseFormatError(f"cannot read case file: {file_label}") from error
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def read_safe_bytes(path: Path, *, max_bytes: int, label: str) -> bytes:
|
|
157
|
+
initial = _validate_file_before_open(path, max_bytes=max_bytes, label=label)
|
|
158
|
+
descriptor = _open_read_only(path, label=label)
|
|
159
|
+
try:
|
|
160
|
+
opened = os.fstat(descriptor)
|
|
161
|
+
_validate_opened_file(initial, opened, label=label)
|
|
162
|
+
read_descriptor = descriptor
|
|
163
|
+
descriptor = -1
|
|
164
|
+
content, finished = _read_descriptor(
|
|
165
|
+
read_descriptor,
|
|
166
|
+
expected_size=opened.st_size,
|
|
167
|
+
max_bytes=max_bytes,
|
|
168
|
+
label=label,
|
|
169
|
+
)
|
|
170
|
+
finally:
|
|
171
|
+
if descriptor >= 0:
|
|
172
|
+
os.close(descriptor)
|
|
173
|
+
if len(content) > max_bytes:
|
|
174
|
+
raise CaseFormatError(f"{label} exceeds the {max_bytes}-byte limit")
|
|
175
|
+
current = lstat_regular_file(path, label=label)
|
|
176
|
+
if file_signature(opened) != file_signature(finished) or file_signature(
|
|
177
|
+
opened
|
|
178
|
+
) != file_signature(current):
|
|
179
|
+
raise UnsafeCasePathError(f"case file changed while reading: {label}")
|
|
180
|
+
return content
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _validate_file_before_open(path: Path, *, max_bytes: int, label: str) -> os.stat_result:
|
|
184
|
+
reject_symlink_components(path.parent)
|
|
185
|
+
metadata = lstat_regular_file(path, label=label)
|
|
186
|
+
if metadata.st_size > max_bytes:
|
|
187
|
+
raise CaseFormatError(f"{label} exceeds the {max_bytes}-byte limit")
|
|
188
|
+
return metadata
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _open_read_only(path: Path, *, label: str) -> int:
|
|
192
|
+
flags = os.O_RDONLY
|
|
193
|
+
if hasattr(os, "O_CLOEXEC"):
|
|
194
|
+
flags |= os.O_CLOEXEC
|
|
195
|
+
if hasattr(os, "O_BINARY"):
|
|
196
|
+
flags |= os.O_BINARY
|
|
197
|
+
if hasattr(os, "O_NOFOLLOW"):
|
|
198
|
+
flags |= os.O_NOFOLLOW
|
|
199
|
+
if hasattr(os, "O_NONBLOCK"):
|
|
200
|
+
flags |= os.O_NONBLOCK
|
|
201
|
+
try:
|
|
202
|
+
return os.open(path, flags)
|
|
203
|
+
except OSError as error:
|
|
204
|
+
raise CaseFormatError(f"cannot read case file: {label}") from error
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _validate_opened_file(expected: os.stat_result, opened: os.stat_result, *, label: str) -> None:
|
|
208
|
+
if not stat.S_ISREG(opened.st_mode):
|
|
209
|
+
raise UnsafeCasePathError(f"case file changed while opening: {label}")
|
|
210
|
+
if file_signature(expected) != file_signature(opened):
|
|
211
|
+
raise UnsafeCasePathError(f"case file changed while opening: {label}")
|
|
212
|
+
if os.name == "posix" and stat.S_IMODE(opened.st_mode) != 0o600:
|
|
213
|
+
raise UnsafeCasePathError(f"case file must have mode 0600: {label}")
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _read_descriptor(
|
|
217
|
+
descriptor: int, *, expected_size: int, max_bytes: int, label: str
|
|
218
|
+
) -> tuple[bytes, os.stat_result]:
|
|
219
|
+
try:
|
|
220
|
+
handle = os.fdopen(descriptor, "rb")
|
|
221
|
+
except OSError as error:
|
|
222
|
+
with contextlib.suppress(OSError):
|
|
223
|
+
os.close(descriptor)
|
|
224
|
+
raise CaseFormatError(f"cannot read case file: {label}") from error
|
|
225
|
+
try:
|
|
226
|
+
with handle:
|
|
227
|
+
content = handle.read(min(expected_size, max_bytes) + 1)
|
|
228
|
+
return content, os.fstat(handle.fileno())
|
|
229
|
+
except OSError as error:
|
|
230
|
+
raise CaseFormatError(f"cannot read case file: {label}") from error
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def lstat_regular_file(path: Path, *, label: str) -> os.stat_result:
|
|
234
|
+
try:
|
|
235
|
+
metadata = path.lstat()
|
|
236
|
+
except OSError as error:
|
|
237
|
+
raise CaseFormatError(f"required case file is unavailable: {label}") from error
|
|
238
|
+
if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISREG(metadata.st_mode):
|
|
239
|
+
raise UnsafeCasePathError(f"case entry must be a regular file: {label}")
|
|
240
|
+
return metadata
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def file_signature(metadata: os.stat_result) -> FileSignature:
|
|
244
|
+
return (
|
|
245
|
+
metadata.st_dev,
|
|
246
|
+
metadata.st_ino,
|
|
247
|
+
metadata.st_size,
|
|
248
|
+
metadata.st_mtime_ns,
|
|
249
|
+
metadata.st_ctime_ns,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def atomic_write(path: Path, content: str) -> PublishedFile:
|
|
254
|
+
return atomic_write_bytes(path, content.encode("utf-8"))
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def atomic_write_bytes(path: Path, content: bytes) -> PublishedFile:
|
|
258
|
+
try:
|
|
259
|
+
descriptor, temporary_name = tempfile.mkstemp(
|
|
260
|
+
prefix=f".{path.name}.", suffix=".tmp", dir=path.parent
|
|
261
|
+
)
|
|
262
|
+
except OSError as error:
|
|
263
|
+
raise CaseError("cannot create temporary case file") from error
|
|
264
|
+
temporary = Path(temporary_name)
|
|
265
|
+
temporary_identity = file_identity(os.fstat(descriptor))
|
|
266
|
+
try:
|
|
267
|
+
_write_descriptor(descriptor, content)
|
|
268
|
+
set_private_mode(temporary, 0o600)
|
|
269
|
+
_link_without_overwrite(temporary, path)
|
|
270
|
+
_unlink_if_identity_matches(temporary, temporary_identity)
|
|
271
|
+
signature = _published_signature(path, temporary_identity)
|
|
272
|
+
return PublishedFile(path=path, signature=signature)
|
|
273
|
+
except CaseError:
|
|
274
|
+
raise
|
|
275
|
+
except OSError as error:
|
|
276
|
+
raise CaseError("cannot write case file") from error
|
|
277
|
+
finally:
|
|
278
|
+
_unlink_if_identity_matches(temporary, temporary_identity)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _write_descriptor(descriptor: int, content: bytes) -> None:
|
|
282
|
+
try:
|
|
283
|
+
handle = os.fdopen(descriptor, "wb")
|
|
284
|
+
except OSError:
|
|
285
|
+
os.close(descriptor)
|
|
286
|
+
raise
|
|
287
|
+
with handle:
|
|
288
|
+
handle.write(content)
|
|
289
|
+
handle.flush()
|
|
290
|
+
os.fsync(handle.fileno())
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _link_without_overwrite(temporary: Path, destination: Path) -> None:
|
|
294
|
+
try:
|
|
295
|
+
os.link(temporary, destination, follow_symlinks=False)
|
|
296
|
+
except FileExistsError as error:
|
|
297
|
+
raise CaseExistsError(f"refusing to overwrite case entry: {destination.name}") from error
|
|
298
|
+
except OSError as error:
|
|
299
|
+
raise CaseError(f"cannot create case file: {destination.name}") from error
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def remove_published_file(published: PublishedFile) -> None:
|
|
303
|
+
"""Remove a published file only when its identity is unchanged."""
|
|
304
|
+
|
|
305
|
+
try:
|
|
306
|
+
metadata = published.path.lstat()
|
|
307
|
+
except OSError:
|
|
308
|
+
return
|
|
309
|
+
if stat.S_ISREG(metadata.st_mode) and file_signature(metadata) == published.signature:
|
|
310
|
+
published.path.unlink()
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _published_signature(path: Path, expected: FileIdentity) -> FileSignature:
|
|
314
|
+
try:
|
|
315
|
+
metadata = path.lstat()
|
|
316
|
+
except OSError as error:
|
|
317
|
+
_unlink_if_identity_matches(path, expected)
|
|
318
|
+
raise CaseError("published case file is unavailable") from error
|
|
319
|
+
if not stat.S_ISREG(metadata.st_mode) or file_identity(metadata) != expected:
|
|
320
|
+
raise CaseError("published case file changed unexpectedly")
|
|
321
|
+
return file_signature(metadata)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _unlink_if_identity_matches(path: Path, identity: FileIdentity) -> None:
|
|
325
|
+
try:
|
|
326
|
+
metadata = path.lstat()
|
|
327
|
+
except OSError:
|
|
328
|
+
return
|
|
329
|
+
if file_identity(metadata) == identity:
|
|
330
|
+
with contextlib.suppress(OSError):
|
|
331
|
+
path.unlink()
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def set_private_mode(path: Path, mode: int) -> None:
|
|
335
|
+
if os.name == "posix":
|
|
336
|
+
path.chmod(mode)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def file_identity(metadata: os.stat_result) -> FileIdentity:
|
|
340
|
+
return metadata.st_dev, metadata.st_ino
|