filekeeper-cli 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.
- filekeeper/__init__.py +35 -0
- filekeeper/cli/__init__.py +5 -0
- filekeeper/cli/app.py +866 -0
- filekeeper/cli/exit_codes.py +84 -0
- filekeeper/cli/output.py +244 -0
- filekeeper/core/__init__.py +51 -0
- filekeeper/core/config.py +133 -0
- filekeeper/core/database.py +226 -0
- filekeeper/core/diffing.py +127 -0
- filekeeper/core/errors.py +278 -0
- filekeeper/core/history/__init__.py +20 -0
- filekeeper/core/history/blobs.py +92 -0
- filekeeper/core/history/journal.py +430 -0
- filekeeper/core/history/repository.py +152 -0
- filekeeper/core/history/undo.py +358 -0
- filekeeper/core/models.py +293 -0
- filekeeper/core/operations/__init__.py +9 -0
- filekeeper/core/operations/directory.py +475 -0
- filekeeper/core/operations/edit.py +1239 -0
- filekeeper/core/operations/read.py +370 -0
- filekeeper/core/operations/search.py +198 -0
- filekeeper/core/operations/write.py +763 -0
- filekeeper/core/paths.py +236 -0
- filekeeper/core/safety/__init__.py +5 -0
- filekeeper/core/safety/atomic.py +129 -0
- filekeeper/core/safety/hashing.py +73 -0
- filekeeper/core/safety/sensitive.py +65 -0
- filekeeper/core/safety/textdata.py +142 -0
- filekeeper/core/safety/vcs.py +51 -0
- filekeeper/core/service.py +706 -0
- filekeeper/core/workspace.py +300 -0
- filekeeper_cli-0.1.0.dist-info/METADATA +498 -0
- filekeeper_cli-0.1.0.dist-info/RECORD +36 -0
- filekeeper_cli-0.1.0.dist-info/WHEEL +4 -0
- filekeeper_cli-0.1.0.dist-info/entry_points.txt +3 -0
- filekeeper_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,763 @@
|
|
|
1
|
+
"""Whole-file mutations.
|
|
2
|
+
|
|
3
|
+
Every write goes through :func:`atomic_write_bytes`, so each single-file
|
|
4
|
+
replacement is atomic: after a failure the file holds either its old bytes or
|
|
5
|
+
its new bytes, never a truncated mix.
|
|
6
|
+
|
|
7
|
+
Each mutation is also recorded in the durable history before the filesystem is
|
|
8
|
+
touched: blobs first, then the pending operation, then the atomic replacement,
|
|
9
|
+
then the commit mark. A failure before the replacement marks the operation
|
|
10
|
+
failed; a failure after it leaves the operation pending for startup
|
|
11
|
+
reconciliation, which never discards the recoverable copy.
|
|
12
|
+
|
|
13
|
+
Copy and move touch two paths. Each individual step is a complete atomic
|
|
14
|
+
replacement or rename, but the pair is **not** atomic across both paths: it is
|
|
15
|
+
recoverable through history, never all-or-nothing.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import codecs
|
|
21
|
+
import os
|
|
22
|
+
import stat
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
from ..errors import (
|
|
26
|
+
AlreadyExistsError,
|
|
27
|
+
ContentConflictError,
|
|
28
|
+
FilekeeperError,
|
|
29
|
+
LimitExceededError,
|
|
30
|
+
NotAFileError,
|
|
31
|
+
SensitivePathRefusedError,
|
|
32
|
+
StorageError,
|
|
33
|
+
SymlinkWriteRefusedError,
|
|
34
|
+
ValidationError,
|
|
35
|
+
storage_failure,
|
|
36
|
+
)
|
|
37
|
+
from ..history.journal import OperationJournal, PendingItem
|
|
38
|
+
from ..models import MutationResult, OperationItemResult
|
|
39
|
+
from ..paths import ResolvedPath, ensure_parent_directory, resolve_path
|
|
40
|
+
from ..safety.atomic import atomic_write_bytes, fsync_directory
|
|
41
|
+
from ..safety.hashing import format_digest, hash_bytes, hash_file, hashes_match, normalize_hash
|
|
42
|
+
from ..safety.sensitive import sensitive_reason
|
|
43
|
+
from ..safety.textdata import decode_bytes, encode_text, looks_binary
|
|
44
|
+
from ..safety.vcs import ignore_warning
|
|
45
|
+
from ..workspace import WorkspaceHandle
|
|
46
|
+
|
|
47
|
+
SECRET_FLAG_HINT = (
|
|
48
|
+
"pass allow_sensitive_write=True in Core, or the CLI flag "
|
|
49
|
+
"--i-accept-secret-write-risk, if you really mean to write credential material"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _default_file_mode() -> int:
|
|
54
|
+
current = os.umask(0)
|
|
55
|
+
os.umask(current)
|
|
56
|
+
return 0o666 & ~current
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _snapshot(
|
|
60
|
+
path: Path, cap: int, *, display_path: str
|
|
61
|
+
) -> tuple[str | None, bytes | None, bool]:
|
|
62
|
+
"""Return ``(hash, bytes, exists)`` for a file, without reading oversize content.
|
|
63
|
+
|
|
64
|
+
``bytes`` is ``None`` for an oversize file: the hash is still recorded, the
|
|
65
|
+
content is not. That is what makes the history cap visible instead of silent.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
info = path.stat()
|
|
70
|
+
except FileNotFoundError:
|
|
71
|
+
return None, None, False
|
|
72
|
+
if info.st_size > cap:
|
|
73
|
+
return hash_file(path, display_path=display_path), None, True
|
|
74
|
+
try:
|
|
75
|
+
data = path.read_bytes()
|
|
76
|
+
except OSError as exc:
|
|
77
|
+
raise storage_failure("read", display_path, exc) from exc
|
|
78
|
+
return hash_bytes(data), data, True
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _check_size(handle: WorkspaceHandle, data: bytes, path: str) -> None:
|
|
82
|
+
if len(data) > handle.config.max_write_bytes:
|
|
83
|
+
raise LimitExceededError(
|
|
84
|
+
"content exceeds the configured write size limit",
|
|
85
|
+
limit=handle.config.max_write_bytes,
|
|
86
|
+
actual=len(data),
|
|
87
|
+
details={"path": path},
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _guard_sensitive(
|
|
92
|
+
handle: WorkspaceHandle, resolved: ResolvedPath, *, allow_sensitive_write: bool
|
|
93
|
+
) -> tuple[str, ...]:
|
|
94
|
+
reason = sensitive_reason(resolved.relative)
|
|
95
|
+
if reason is not None and not allow_sensitive_write:
|
|
96
|
+
raise SensitivePathRefusedError(
|
|
97
|
+
f"refusing to write {resolved.relative}: it looks like credential or key "
|
|
98
|
+
f"material ({reason}); {SECRET_FLAG_HINT}",
|
|
99
|
+
details={"path": resolved.relative, "reason": reason},
|
|
100
|
+
)
|
|
101
|
+
warnings: list[str] = []
|
|
102
|
+
warning = ignore_warning(handle.root, resolved.absolute, resolved.relative)
|
|
103
|
+
if warning is not None:
|
|
104
|
+
warnings.append(warning)
|
|
105
|
+
if reason is not None:
|
|
106
|
+
warnings.append(f"sensitive-path refusal was overridden for {resolved.relative} ({reason})")
|
|
107
|
+
return tuple(warnings)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _target_mode(resolved: ResolvedPath) -> int | None:
|
|
111
|
+
try:
|
|
112
|
+
return stat.S_IMODE(resolved.absolute.stat().st_mode)
|
|
113
|
+
except FileNotFoundError:
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _encoding_for_existing(target: Path, *, encoding: str | None) -> tuple[str, bool]:
|
|
118
|
+
"""Return ``(encoding, has_bom)`` for rewriting an existing file."""
|
|
119
|
+
|
|
120
|
+
try:
|
|
121
|
+
raw = target.read_bytes()
|
|
122
|
+
except OSError:
|
|
123
|
+
return (encoding or "utf-8", False)
|
|
124
|
+
try:
|
|
125
|
+
_, profile = decode_bytes(raw, encoding=encoding, path=str(target.name))
|
|
126
|
+
except FilekeeperError:
|
|
127
|
+
if encoding is None:
|
|
128
|
+
raise
|
|
129
|
+
return (encoding, encoding == "utf-8" and raw.startswith(codecs.BOM_UTF8))
|
|
130
|
+
return (profile.encoding, profile.has_bom)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _after_write(
|
|
134
|
+
handle: WorkspaceHandle,
|
|
135
|
+
resolved: ResolvedPath,
|
|
136
|
+
data: bytes,
|
|
137
|
+
*,
|
|
138
|
+
operation_type: str,
|
|
139
|
+
change: str,
|
|
140
|
+
before_hash: str | None,
|
|
141
|
+
before_bytes: bytes | None,
|
|
142
|
+
before_exists: bool,
|
|
143
|
+
before_mode: int | None,
|
|
144
|
+
dry_run: bool,
|
|
145
|
+
warnings: tuple[str, ...],
|
|
146
|
+
past_verb: str,
|
|
147
|
+
dry_verb: str,
|
|
148
|
+
journal: OperationJournal | None,
|
|
149
|
+
actor: str | None,
|
|
150
|
+
task_id: str | None,
|
|
151
|
+
) -> MutationResult:
|
|
152
|
+
after_hash = hash_bytes(data)
|
|
153
|
+
operation_id: str | None = None
|
|
154
|
+
skipped: tuple[str, ...] = ()
|
|
155
|
+
undo_available = False
|
|
156
|
+
|
|
157
|
+
if not dry_run:
|
|
158
|
+
pending = PendingItem(
|
|
159
|
+
relative_path=resolved.relative,
|
|
160
|
+
item_type="file",
|
|
161
|
+
before_exists=before_exists,
|
|
162
|
+
after_exists=True,
|
|
163
|
+
before_bytes=before_bytes,
|
|
164
|
+
after_bytes=data,
|
|
165
|
+
before_hash=before_hash,
|
|
166
|
+
after_hash=after_hash,
|
|
167
|
+
before_mode=before_mode,
|
|
168
|
+
after_mode=_target_mode(resolved) or _default_file_mode(),
|
|
169
|
+
)
|
|
170
|
+
if journal is not None:
|
|
171
|
+
begun = journal.begin(
|
|
172
|
+
operation_type=operation_type,
|
|
173
|
+
target_path=resolved.relative,
|
|
174
|
+
summary=f"{past_verb} {resolved.relative}.",
|
|
175
|
+
items=[pending],
|
|
176
|
+
actor=actor,
|
|
177
|
+
task_id=task_id,
|
|
178
|
+
before_hash=before_hash,
|
|
179
|
+
after_hash=after_hash,
|
|
180
|
+
)
|
|
181
|
+
operation_id = begun.operation_id
|
|
182
|
+
skipped = begun.skipped
|
|
183
|
+
warnings = (*warnings, *begun.warnings)
|
|
184
|
+
try:
|
|
185
|
+
after_hash = atomic_write_bytes(
|
|
186
|
+
resolved.absolute,
|
|
187
|
+
data,
|
|
188
|
+
mode=_target_mode(resolved),
|
|
189
|
+
fsync=handle.config.fsync,
|
|
190
|
+
display_path=resolved.relative,
|
|
191
|
+
)
|
|
192
|
+
except FilekeeperError:
|
|
193
|
+
if journal is not None and operation_id is not None:
|
|
194
|
+
journal.abort(operation_id)
|
|
195
|
+
raise
|
|
196
|
+
if journal is not None and operation_id is not None:
|
|
197
|
+
journal.commit(operation_id)
|
|
198
|
+
undo_available = not skipped
|
|
199
|
+
|
|
200
|
+
return MutationResult(
|
|
201
|
+
workspace=handle.name,
|
|
202
|
+
path=resolved.relative,
|
|
203
|
+
changed=True,
|
|
204
|
+
before_hash=before_hash,
|
|
205
|
+
after_hash=after_hash,
|
|
206
|
+
summary=(
|
|
207
|
+
f"{dry_verb} {resolved.relative}." if dry_run else f"{past_verb} {resolved.relative}."
|
|
208
|
+
),
|
|
209
|
+
operation_id=operation_id,
|
|
210
|
+
undo_available=undo_available,
|
|
211
|
+
dry_run=dry_run,
|
|
212
|
+
warnings=warnings,
|
|
213
|
+
items=(
|
|
214
|
+
OperationItemResult(
|
|
215
|
+
path=resolved.relative,
|
|
216
|
+
change=change, # type: ignore[arg-type]
|
|
217
|
+
before_hash=before_hash,
|
|
218
|
+
after_hash=after_hash,
|
|
219
|
+
size_bytes=len(data),
|
|
220
|
+
),
|
|
221
|
+
),
|
|
222
|
+
affected_count=1,
|
|
223
|
+
history_skipped=skipped,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def create_file(
|
|
228
|
+
handle: WorkspaceHandle,
|
|
229
|
+
path: str,
|
|
230
|
+
content: str,
|
|
231
|
+
*,
|
|
232
|
+
expected_absent: bool = True,
|
|
233
|
+
encoding: str = "utf-8",
|
|
234
|
+
allow_sensitive_write: bool = False,
|
|
235
|
+
dry_run: bool = False,
|
|
236
|
+
journal: OperationJournal | None = None,
|
|
237
|
+
actor: str | None = None,
|
|
238
|
+
task_id: str | None = None,
|
|
239
|
+
) -> MutationResult:
|
|
240
|
+
"""Create a file. Refuses an existing target unless ``expected_absent`` is False."""
|
|
241
|
+
|
|
242
|
+
resolved = resolve_path(handle.root, path, for_write=True, state_dir=handle.state_dir)
|
|
243
|
+
if resolved.exists and resolved.absolute.is_dir():
|
|
244
|
+
raise NotAFileError("path is a directory", details={"path": resolved.relative})
|
|
245
|
+
if resolved.exists and expected_absent:
|
|
246
|
+
raise AlreadyExistsError(
|
|
247
|
+
"file already exists; pass expected_absent=False to overwrite",
|
|
248
|
+
details={"path": resolved.relative},
|
|
249
|
+
)
|
|
250
|
+
warnings = _guard_sensitive(handle, resolved, allow_sensitive_write=allow_sensitive_write)
|
|
251
|
+
ensure_parent_directory(resolved)
|
|
252
|
+
|
|
253
|
+
before_hash, before_bytes, before_exists = _snapshot(
|
|
254
|
+
resolved.absolute,
|
|
255
|
+
handle.config.max_history_blob_bytes,
|
|
256
|
+
display_path=resolved.relative,
|
|
257
|
+
)
|
|
258
|
+
before_mode = _target_mode(resolved) if before_exists else None
|
|
259
|
+
data = encode_text(content, encoding=encoding, has_bom=False)
|
|
260
|
+
_check_size(handle, data, resolved.relative)
|
|
261
|
+
|
|
262
|
+
return _after_write(
|
|
263
|
+
handle,
|
|
264
|
+
resolved,
|
|
265
|
+
data,
|
|
266
|
+
operation_type="create_file",
|
|
267
|
+
change="modified" if before_exists else "created",
|
|
268
|
+
before_hash=before_hash,
|
|
269
|
+
before_bytes=before_bytes,
|
|
270
|
+
before_exists=before_exists,
|
|
271
|
+
before_mode=before_mode,
|
|
272
|
+
dry_run=dry_run,
|
|
273
|
+
warnings=warnings,
|
|
274
|
+
past_verb="Created" if not before_exists else "Overwrote",
|
|
275
|
+
dry_verb="Would create" if not before_exists else "Would overwrite",
|
|
276
|
+
journal=journal,
|
|
277
|
+
actor=actor,
|
|
278
|
+
task_id=task_id,
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def write_file(
|
|
283
|
+
handle: WorkspaceHandle,
|
|
284
|
+
path: str,
|
|
285
|
+
content: str,
|
|
286
|
+
*,
|
|
287
|
+
expected_hash: str | None = None,
|
|
288
|
+
encoding: str | None = None,
|
|
289
|
+
allow_sensitive_write: bool = False,
|
|
290
|
+
dry_run: bool = False,
|
|
291
|
+
journal: OperationJournal | None = None,
|
|
292
|
+
actor: str | None = None,
|
|
293
|
+
task_id: str | None = None,
|
|
294
|
+
) -> MutationResult:
|
|
295
|
+
"""Replace a file's complete content, optionally guarded by its current hash.
|
|
296
|
+
|
|
297
|
+
The existing file's encoding and byte-order mark are preserved unless an
|
|
298
|
+
encoding is named explicitly. Line endings inside ``content`` are written
|
|
299
|
+
exactly as given: they are never normalised.
|
|
300
|
+
"""
|
|
301
|
+
|
|
302
|
+
resolved = resolve_path(handle.root, path, for_write=True, state_dir=handle.state_dir)
|
|
303
|
+
if resolved.exists and resolved.absolute.is_dir():
|
|
304
|
+
raise NotAFileError("path is a directory", details={"path": resolved.relative})
|
|
305
|
+
warnings = _guard_sensitive(handle, resolved, allow_sensitive_write=allow_sensitive_write)
|
|
306
|
+
ensure_parent_directory(resolved)
|
|
307
|
+
|
|
308
|
+
before_hash, before_bytes, before_exists = _snapshot(
|
|
309
|
+
resolved.absolute,
|
|
310
|
+
handle.config.max_history_blob_bytes,
|
|
311
|
+
display_path=resolved.relative,
|
|
312
|
+
)
|
|
313
|
+
before_mode = _target_mode(resolved) if before_exists else None
|
|
314
|
+
if expected_hash is not None:
|
|
315
|
+
wanted = normalize_hash(expected_hash)
|
|
316
|
+
if before_hash is None:
|
|
317
|
+
raise ContentConflictError(
|
|
318
|
+
"file does not exist, so it cannot match the expected hash",
|
|
319
|
+
details={"path": resolved.relative, "expected_hash": wanted},
|
|
320
|
+
)
|
|
321
|
+
if not hashes_match(wanted, before_hash):
|
|
322
|
+
raise ContentConflictError(
|
|
323
|
+
"file content changed since it was read; refusing to overwrite",
|
|
324
|
+
details={
|
|
325
|
+
"path": resolved.relative,
|
|
326
|
+
"expected_hash": wanted,
|
|
327
|
+
"actual_hash": before_hash,
|
|
328
|
+
},
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
if before_exists:
|
|
332
|
+
effective_encoding, has_bom = _encoding_for_existing(resolved.absolute, encoding=encoding)
|
|
333
|
+
else:
|
|
334
|
+
effective_encoding, has_bom = encoding or "utf-8", False
|
|
335
|
+
|
|
336
|
+
data = encode_text(content, encoding=effective_encoding, has_bom=has_bom)
|
|
337
|
+
_check_size(handle, data, resolved.relative)
|
|
338
|
+
|
|
339
|
+
return _after_write(
|
|
340
|
+
handle,
|
|
341
|
+
resolved,
|
|
342
|
+
data,
|
|
343
|
+
operation_type="write_file",
|
|
344
|
+
change="modified" if before_exists else "created",
|
|
345
|
+
before_hash=before_hash,
|
|
346
|
+
before_bytes=before_bytes,
|
|
347
|
+
before_exists=before_exists,
|
|
348
|
+
before_mode=before_mode,
|
|
349
|
+
dry_run=dry_run,
|
|
350
|
+
warnings=warnings,
|
|
351
|
+
past_verb="Wrote",
|
|
352
|
+
dry_verb="Would write",
|
|
353
|
+
journal=journal,
|
|
354
|
+
actor=actor,
|
|
355
|
+
task_id=task_id,
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def copy_file(
|
|
360
|
+
handle: WorkspaceHandle,
|
|
361
|
+
source: str,
|
|
362
|
+
destination: str,
|
|
363
|
+
*,
|
|
364
|
+
expected_destination_hash: str | None = None,
|
|
365
|
+
allow_sensitive_write: bool = False,
|
|
366
|
+
dry_run: bool = False,
|
|
367
|
+
journal: OperationJournal | None = None,
|
|
368
|
+
actor: str | None = None,
|
|
369
|
+
task_id: str | None = None,
|
|
370
|
+
) -> MutationResult:
|
|
371
|
+
"""Copy a file's bytes to a new path. Never overwrites an unguarded destination."""
|
|
372
|
+
|
|
373
|
+
source_resolved = resolve_path(
|
|
374
|
+
handle.root,
|
|
375
|
+
source,
|
|
376
|
+
must_exist=True,
|
|
377
|
+
expect="file",
|
|
378
|
+
state_dir=handle.state_dir,
|
|
379
|
+
)
|
|
380
|
+
if source_resolved.absolute.is_dir():
|
|
381
|
+
raise NotAFileError("source is a directory", details={"path": source_resolved.relative})
|
|
382
|
+
|
|
383
|
+
destination_resolved = resolve_path(
|
|
384
|
+
handle.root, destination, for_write=True, state_dir=handle.state_dir
|
|
385
|
+
)
|
|
386
|
+
if destination_resolved.absolute == source_resolved.absolute:
|
|
387
|
+
raise ValidationError("source and destination are the same path")
|
|
388
|
+
|
|
389
|
+
destination_before: str | None = None
|
|
390
|
+
destination_bytes: bytes | None = None
|
|
391
|
+
destination_exists = False
|
|
392
|
+
if destination_resolved.exists:
|
|
393
|
+
if destination_resolved.absolute.is_dir():
|
|
394
|
+
raise NotAFileError(
|
|
395
|
+
"destination is a directory", details={"path": destination_resolved.relative}
|
|
396
|
+
)
|
|
397
|
+
destination_before, destination_bytes, destination_exists = _snapshot(
|
|
398
|
+
destination_resolved.absolute,
|
|
399
|
+
handle.config.max_history_blob_bytes,
|
|
400
|
+
display_path=destination_resolved.relative,
|
|
401
|
+
)
|
|
402
|
+
if expected_destination_hash is None:
|
|
403
|
+
raise AlreadyExistsError(
|
|
404
|
+
"destination already exists; pass expected_destination_hash to "
|
|
405
|
+
"replace it deliberately",
|
|
406
|
+
details={"path": destination_resolved.relative},
|
|
407
|
+
)
|
|
408
|
+
wanted = normalize_hash(expected_destination_hash)
|
|
409
|
+
if not hashes_match(wanted, destination_before or ""):
|
|
410
|
+
raise ContentConflictError(
|
|
411
|
+
"destination changed since it was read; refusing to overwrite",
|
|
412
|
+
details={
|
|
413
|
+
"path": destination_resolved.relative,
|
|
414
|
+
"expected_hash": wanted,
|
|
415
|
+
"actual_hash": destination_before,
|
|
416
|
+
},
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
warnings = _guard_sensitive(
|
|
420
|
+
handle,
|
|
421
|
+
destination_resolved,
|
|
422
|
+
allow_sensitive_write=allow_sensitive_write,
|
|
423
|
+
)
|
|
424
|
+
ensure_parent_directory(destination_resolved)
|
|
425
|
+
|
|
426
|
+
try:
|
|
427
|
+
data = source_resolved.absolute.read_bytes()
|
|
428
|
+
except OSError as exc:
|
|
429
|
+
raise storage_failure("read", source_resolved.relative, exc) from exc
|
|
430
|
+
_check_size(handle, data, destination_resolved.relative)
|
|
431
|
+
|
|
432
|
+
result = _after_write(
|
|
433
|
+
handle,
|
|
434
|
+
destination_resolved,
|
|
435
|
+
data,
|
|
436
|
+
operation_type="copy_file",
|
|
437
|
+
change="modified" if destination_exists else "created",
|
|
438
|
+
before_hash=destination_before,
|
|
439
|
+
before_bytes=destination_bytes,
|
|
440
|
+
before_exists=destination_exists,
|
|
441
|
+
before_mode=_target_mode(destination_resolved) if destination_exists else None,
|
|
442
|
+
dry_run=dry_run,
|
|
443
|
+
warnings=warnings,
|
|
444
|
+
past_verb="Copied",
|
|
445
|
+
dry_verb="Would copy",
|
|
446
|
+
journal=journal,
|
|
447
|
+
actor=actor,
|
|
448
|
+
task_id=task_id,
|
|
449
|
+
)
|
|
450
|
+
item = OperationItemResult(
|
|
451
|
+
path=source_resolved.relative,
|
|
452
|
+
change="unchanged",
|
|
453
|
+
before_hash=hash_bytes(data),
|
|
454
|
+
after_hash=hash_bytes(data),
|
|
455
|
+
size_bytes=len(data),
|
|
456
|
+
)
|
|
457
|
+
return MutationResult(
|
|
458
|
+
workspace=result.workspace,
|
|
459
|
+
path=result.path,
|
|
460
|
+
changed=True,
|
|
461
|
+
before_hash=result.before_hash,
|
|
462
|
+
after_hash=result.after_hash,
|
|
463
|
+
summary=(
|
|
464
|
+
f"{'Would copy' if dry_run else 'Copied'} {source_resolved.relative} "
|
|
465
|
+
f"to {destination_resolved.relative}."
|
|
466
|
+
),
|
|
467
|
+
operation_id=result.operation_id,
|
|
468
|
+
undo_available=result.undo_available,
|
|
469
|
+
dry_run=dry_run,
|
|
470
|
+
warnings=result.warnings,
|
|
471
|
+
items=(*result.items, item),
|
|
472
|
+
affected_count=1,
|
|
473
|
+
history_skipped=result.history_skipped,
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def move_file(
|
|
478
|
+
handle: WorkspaceHandle,
|
|
479
|
+
source: str,
|
|
480
|
+
destination: str,
|
|
481
|
+
*,
|
|
482
|
+
overwrite: bool = False,
|
|
483
|
+
allow_sensitive_write: bool = False,
|
|
484
|
+
dry_run: bool = False,
|
|
485
|
+
journal: OperationJournal | None = None,
|
|
486
|
+
actor: str | None = None,
|
|
487
|
+
task_id: str | None = None,
|
|
488
|
+
) -> MutationResult:
|
|
489
|
+
"""Rename a file. Refuses an existing destination unless ``overwrite`` is set."""
|
|
490
|
+
|
|
491
|
+
source_resolved = resolve_path(
|
|
492
|
+
handle.root,
|
|
493
|
+
source,
|
|
494
|
+
must_exist=True,
|
|
495
|
+
expect="file",
|
|
496
|
+
state_dir=handle.state_dir,
|
|
497
|
+
)
|
|
498
|
+
if source_resolved.absolute.is_dir():
|
|
499
|
+
raise ValidationError(
|
|
500
|
+
"source is a directory; use the directory move operation",
|
|
501
|
+
details={"path": source_resolved.relative},
|
|
502
|
+
)
|
|
503
|
+
source_hash = hash_file(source_resolved.absolute, display_path=source_resolved.relative)
|
|
504
|
+
source_mode = _target_mode(source_resolved)
|
|
505
|
+
cap = handle.config.max_history_blob_bytes
|
|
506
|
+
source_bytes: bytes | None
|
|
507
|
+
try:
|
|
508
|
+
source_bytes = (
|
|
509
|
+
source_resolved.absolute.read_bytes()
|
|
510
|
+
if source_resolved.absolute.stat().st_size <= cap
|
|
511
|
+
else None
|
|
512
|
+
)
|
|
513
|
+
except OSError as exc:
|
|
514
|
+
raise storage_failure("read", source_resolved.relative, exc) from exc
|
|
515
|
+
|
|
516
|
+
destination_resolved = resolve_path(
|
|
517
|
+
handle.root, destination, for_write=True, state_dir=handle.state_dir
|
|
518
|
+
)
|
|
519
|
+
if destination_resolved.absolute == source_resolved.absolute:
|
|
520
|
+
raise ValidationError("source and destination are the same path")
|
|
521
|
+
if destination_resolved.exists and destination_resolved.absolute.is_dir():
|
|
522
|
+
raise NotAFileError(
|
|
523
|
+
"destination is a directory", details={"path": destination_resolved.relative}
|
|
524
|
+
)
|
|
525
|
+
if destination_resolved.exists and not overwrite:
|
|
526
|
+
raise AlreadyExistsError(
|
|
527
|
+
"destination already exists; pass overwrite=True to replace it",
|
|
528
|
+
details={"path": destination_resolved.relative},
|
|
529
|
+
)
|
|
530
|
+
warnings = _guard_sensitive(
|
|
531
|
+
handle,
|
|
532
|
+
destination_resolved,
|
|
533
|
+
allow_sensitive_write=allow_sensitive_write,
|
|
534
|
+
)
|
|
535
|
+
ensure_parent_directory(destination_resolved)
|
|
536
|
+
destination_before, destination_bytes, destination_exists = _snapshot(
|
|
537
|
+
destination_resolved.absolute,
|
|
538
|
+
cap,
|
|
539
|
+
display_path=destination_resolved.relative,
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
operation_id: str | None = None
|
|
543
|
+
skipped: tuple[str, ...] = ()
|
|
544
|
+
if not dry_run:
|
|
545
|
+
if journal is not None:
|
|
546
|
+
begun = journal.begin(
|
|
547
|
+
operation_type="move_file",
|
|
548
|
+
target_path=destination_resolved.relative,
|
|
549
|
+
summary=(
|
|
550
|
+
f"Moved {source_resolved.relative} to {destination_resolved.relative}."
|
|
551
|
+
),
|
|
552
|
+
items=[
|
|
553
|
+
PendingItem(
|
|
554
|
+
relative_path=source_resolved.relative,
|
|
555
|
+
item_type="file",
|
|
556
|
+
before_exists=True,
|
|
557
|
+
after_exists=False,
|
|
558
|
+
before_bytes=source_bytes,
|
|
559
|
+
before_hash=source_hash,
|
|
560
|
+
before_mode=source_mode,
|
|
561
|
+
),
|
|
562
|
+
PendingItem(
|
|
563
|
+
relative_path=destination_resolved.relative,
|
|
564
|
+
item_type="file",
|
|
565
|
+
before_exists=destination_exists,
|
|
566
|
+
after_exists=True,
|
|
567
|
+
before_bytes=destination_bytes,
|
|
568
|
+
before_hash=destination_before,
|
|
569
|
+
after_bytes=source_bytes,
|
|
570
|
+
after_hash=source_hash,
|
|
571
|
+
before_mode=_target_mode(destination_resolved)
|
|
572
|
+
if destination_exists
|
|
573
|
+
else None,
|
|
574
|
+
after_mode=source_mode,
|
|
575
|
+
),
|
|
576
|
+
],
|
|
577
|
+
actor=actor,
|
|
578
|
+
task_id=task_id,
|
|
579
|
+
before_hash=destination_before,
|
|
580
|
+
after_hash=source_hash,
|
|
581
|
+
)
|
|
582
|
+
operation_id = begun.operation_id
|
|
583
|
+
skipped = begun.skipped
|
|
584
|
+
warnings = (*warnings, *begun.warnings)
|
|
585
|
+
try:
|
|
586
|
+
os.replace(source_resolved.absolute, destination_resolved.absolute)
|
|
587
|
+
except OSError as exc:
|
|
588
|
+
if journal is not None and operation_id is not None:
|
|
589
|
+
journal.abort(operation_id)
|
|
590
|
+
raise StorageError(
|
|
591
|
+
f"cannot move file: {exc.strerror or type(exc).__name__}; moving across "
|
|
592
|
+
"filesystems is not supported",
|
|
593
|
+
details={
|
|
594
|
+
"source": source_resolved.relative,
|
|
595
|
+
"destination": destination_resolved.relative,
|
|
596
|
+
},
|
|
597
|
+
) from exc
|
|
598
|
+
fsync_directory(source_resolved.absolute.parent)
|
|
599
|
+
fsync_directory(destination_resolved.absolute.parent)
|
|
600
|
+
after_hash = hash_file(
|
|
601
|
+
destination_resolved.absolute, display_path=destination_resolved.relative
|
|
602
|
+
)
|
|
603
|
+
if after_hash != source_hash:
|
|
604
|
+
if journal is not None and operation_id is not None:
|
|
605
|
+
journal.abort(operation_id)
|
|
606
|
+
raise StorageError(
|
|
607
|
+
"moved file content does not match its source",
|
|
608
|
+
details={"path": destination_resolved.relative},
|
|
609
|
+
)
|
|
610
|
+
if journal is not None and operation_id is not None:
|
|
611
|
+
journal.commit(operation_id)
|
|
612
|
+
else:
|
|
613
|
+
after_hash = source_hash
|
|
614
|
+
|
|
615
|
+
return MutationResult(
|
|
616
|
+
workspace=handle.name,
|
|
617
|
+
path=destination_resolved.relative,
|
|
618
|
+
changed=True,
|
|
619
|
+
before_hash=source_hash,
|
|
620
|
+
after_hash=after_hash,
|
|
621
|
+
summary=(
|
|
622
|
+
f"{'Would move' if dry_run else 'Moved'} {source_resolved.relative} "
|
|
623
|
+
f"to {destination_resolved.relative}."
|
|
624
|
+
),
|
|
625
|
+
operation_id=operation_id,
|
|
626
|
+
undo_available=not dry_run and operation_id is not None and not skipped,
|
|
627
|
+
dry_run=dry_run,
|
|
628
|
+
warnings=warnings,
|
|
629
|
+
items=(
|
|
630
|
+
OperationItemResult(
|
|
631
|
+
path=source_resolved.relative,
|
|
632
|
+
change="moved",
|
|
633
|
+
before_hash=source_hash,
|
|
634
|
+
after_hash=None,
|
|
635
|
+
),
|
|
636
|
+
OperationItemResult(
|
|
637
|
+
path=destination_resolved.relative,
|
|
638
|
+
change="created",
|
|
639
|
+
before_hash=destination_before,
|
|
640
|
+
after_hash=after_hash,
|
|
641
|
+
),
|
|
642
|
+
),
|
|
643
|
+
affected_count=1,
|
|
644
|
+
history_skipped=skipped,
|
|
645
|
+
)
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def delete_file(
|
|
649
|
+
handle: WorkspaceHandle,
|
|
650
|
+
path: str,
|
|
651
|
+
*,
|
|
652
|
+
expected_hash: str | None = None,
|
|
653
|
+
dry_run: bool = False,
|
|
654
|
+
journal: OperationJournal | None = None,
|
|
655
|
+
actor: str | None = None,
|
|
656
|
+
task_id: str | None = None,
|
|
657
|
+
) -> MutationResult:
|
|
658
|
+
"""Delete a file, optionally guarded by its current hash."""
|
|
659
|
+
|
|
660
|
+
resolved = resolve_path(
|
|
661
|
+
handle.root,
|
|
662
|
+
path,
|
|
663
|
+
must_exist=True,
|
|
664
|
+
expect="file",
|
|
665
|
+
state_dir=handle.state_dir,
|
|
666
|
+
)
|
|
667
|
+
if resolved.absolute.is_dir():
|
|
668
|
+
raise NotAFileError(
|
|
669
|
+
"path is a directory; use the directory delete operation",
|
|
670
|
+
details={"path": resolved.relative},
|
|
671
|
+
)
|
|
672
|
+
before_hash, before_bytes, _ = _snapshot(
|
|
673
|
+
resolved.absolute,
|
|
674
|
+
handle.config.max_history_blob_bytes,
|
|
675
|
+
display_path=resolved.relative,
|
|
676
|
+
)
|
|
677
|
+
if expected_hash is not None:
|
|
678
|
+
wanted = normalize_hash(expected_hash)
|
|
679
|
+
if not hashes_match(wanted, before_hash or ""):
|
|
680
|
+
raise ContentConflictError(
|
|
681
|
+
"file content changed since it was read; refusing to delete",
|
|
682
|
+
details={
|
|
683
|
+
"path": resolved.relative,
|
|
684
|
+
"expected_hash": wanted,
|
|
685
|
+
"actual_hash": before_hash,
|
|
686
|
+
},
|
|
687
|
+
)
|
|
688
|
+
|
|
689
|
+
operation_id: str | None = None
|
|
690
|
+
skipped: tuple[str, ...] = ()
|
|
691
|
+
if not dry_run:
|
|
692
|
+
if journal is not None:
|
|
693
|
+
begun = journal.begin(
|
|
694
|
+
operation_type="delete_file",
|
|
695
|
+
target_path=resolved.relative,
|
|
696
|
+
summary=f"Deleted {resolved.relative}.",
|
|
697
|
+
items=[
|
|
698
|
+
PendingItem(
|
|
699
|
+
relative_path=resolved.relative,
|
|
700
|
+
item_type="file",
|
|
701
|
+
before_exists=True,
|
|
702
|
+
after_exists=False,
|
|
703
|
+
before_bytes=before_bytes,
|
|
704
|
+
before_hash=before_hash,
|
|
705
|
+
before_mode=_target_mode(resolved),
|
|
706
|
+
)
|
|
707
|
+
],
|
|
708
|
+
actor=actor,
|
|
709
|
+
task_id=task_id,
|
|
710
|
+
before_hash=before_hash,
|
|
711
|
+
)
|
|
712
|
+
operation_id = begun.operation_id
|
|
713
|
+
skipped = begun.skipped
|
|
714
|
+
try:
|
|
715
|
+
resolved.absolute.unlink()
|
|
716
|
+
except OSError as exc:
|
|
717
|
+
if journal is not None and operation_id is not None:
|
|
718
|
+
journal.abort(operation_id)
|
|
719
|
+
raise StorageError(
|
|
720
|
+
f"cannot delete file {resolved.relative}: "
|
|
721
|
+
f"{exc.strerror or type(exc).__name__}",
|
|
722
|
+
details={"path": resolved.relative},
|
|
723
|
+
) from exc
|
|
724
|
+
fsync_directory(resolved.absolute.parent)
|
|
725
|
+
if journal is not None and operation_id is not None:
|
|
726
|
+
journal.commit(operation_id)
|
|
727
|
+
|
|
728
|
+
return MutationResult(
|
|
729
|
+
workspace=handle.name,
|
|
730
|
+
path=resolved.relative,
|
|
731
|
+
changed=True,
|
|
732
|
+
before_hash=before_hash,
|
|
733
|
+
after_hash=None,
|
|
734
|
+
summary=f"{'Would delete' if dry_run else 'Deleted'} {resolved.relative}.",
|
|
735
|
+
operation_id=operation_id,
|
|
736
|
+
undo_available=not dry_run and operation_id is not None and not skipped,
|
|
737
|
+
dry_run=dry_run,
|
|
738
|
+
items=(
|
|
739
|
+
OperationItemResult(
|
|
740
|
+
path=resolved.relative,
|
|
741
|
+
change="deleted",
|
|
742
|
+
before_hash=before_hash,
|
|
743
|
+
after_hash=None,
|
|
744
|
+
),
|
|
745
|
+
),
|
|
746
|
+
affected_count=1,
|
|
747
|
+
history_skipped=skipped,
|
|
748
|
+
)
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
# Re-exported for adapters that need to format a hash without importing safety.
|
|
752
|
+
__all__ = [
|
|
753
|
+
"SECRET_FLAG_HINT",
|
|
754
|
+
"copy_file",
|
|
755
|
+
"create_file",
|
|
756
|
+
"delete_file",
|
|
757
|
+
"format_digest",
|
|
758
|
+
"move_file",
|
|
759
|
+
"write_file",
|
|
760
|
+
]
|
|
761
|
+
|
|
762
|
+
# Keep static analyzers honest about intentionally public helpers.
|
|
763
|
+
_ = (looks_binary, SymlinkWriteRefusedError, stat.S_IMODE)
|