obfuscidian 1.0.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.
- obfuscidian/__init__.py +8 -0
- obfuscidian/__main__.py +15 -0
- obfuscidian/_windows.py +106 -0
- obfuscidian/backup.py +324 -0
- obfuscidian/cli.py +895 -0
- obfuscidian/config.py +113 -0
- obfuscidian/constants.py +112 -0
- obfuscidian/crypto.py +87 -0
- obfuscidian/errors.py +22 -0
- obfuscidian/git_restore.py +696 -0
- obfuscidian/inventory.py +397 -0
- obfuscidian/keys.py +273 -0
- obfuscidian/manifest.py +522 -0
- obfuscidian/output.py +212 -0
- obfuscidian/paths.py +385 -0
- obfuscidian/restore.py +243 -0
- obfuscidian/transactions.py +912 -0
- obfuscidian/updates.py +96 -0
- obfuscidian/verification.py +64 -0
- obfuscidian-1.0.0.dist-info/METADATA +195 -0
- obfuscidian-1.0.0.dist-info/RECORD +24 -0
- obfuscidian-1.0.0.dist-info/WHEEL +4 -0
- obfuscidian-1.0.0.dist-info/entry_points.txt +3 -0
- obfuscidian-1.0.0.dist-info/licenses/LICENSE +201 -0
obfuscidian/__init__.py
ADDED
obfuscidian/__main__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
:Module: obfuscidian.__main__
|
|
4
|
+
:Synopsis: The ``__main__`` module for obfuscidian
|
|
5
|
+
:Created By: Jeff Shurtliff
|
|
6
|
+
:Last Modified: Jeff Shurtliff
|
|
7
|
+
:Modified Date: 05 Oct 2026
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from .cli import cli
|
|
13
|
+
|
|
14
|
+
if __name__ == '__main__':
|
|
15
|
+
cli(prog_name='obfuscidian')
|
obfuscidian/_windows.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
:Module: obfuscidian._windows
|
|
4
|
+
:Synopsis: Internal Windows exclusive key creation with a private DACL
|
|
5
|
+
:Created By: Jeff Shurtliff
|
|
6
|
+
:Last Modified: Jeff Shurtliff (via GPT-6.1 Sol)
|
|
7
|
+
:Modified Date: 05 Oct 2026
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import ctypes
|
|
13
|
+
import os
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from obfuscidian import constants as const
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _check_private_directory(path: Path) -> None:
|
|
20
|
+
"""Refuse volumes without persistent ACLs before creating any key entry."""
|
|
21
|
+
from ctypes import wintypes
|
|
22
|
+
|
|
23
|
+
kernel = ctypes.WinDLL('kernel32', use_last_error=True)
|
|
24
|
+
volume_path = kernel.GetVolumePathNameW
|
|
25
|
+
volume_path.argtypes = [wintypes.LPCWSTR, wintypes.LPWSTR, wintypes.DWORD]
|
|
26
|
+
volume_path.restype = wintypes.BOOL
|
|
27
|
+
information = kernel.GetVolumeInformationW
|
|
28
|
+
information.argtypes = [
|
|
29
|
+
wintypes.LPCWSTR,
|
|
30
|
+
wintypes.LPWSTR,
|
|
31
|
+
wintypes.DWORD,
|
|
32
|
+
ctypes.POINTER(wintypes.DWORD),
|
|
33
|
+
ctypes.POINTER(wintypes.DWORD),
|
|
34
|
+
ctypes.POINTER(wintypes.DWORD),
|
|
35
|
+
wintypes.LPWSTR,
|
|
36
|
+
wintypes.DWORD,
|
|
37
|
+
]
|
|
38
|
+
information.restype = wintypes.BOOL
|
|
39
|
+
volume = ctypes.create_unicode_buffer(32768)
|
|
40
|
+
flags = wintypes.DWORD()
|
|
41
|
+
if not volume_path(str(path), volume, len(volume)) or not information(
|
|
42
|
+
volume.value, None, 0, None, None, ctypes.byref(flags), None, 0
|
|
43
|
+
):
|
|
44
|
+
raise OSError('Cannot assess Windows volume access controls; no key was created.')
|
|
45
|
+
if not flags.value & const.WINDOWS_PERSISTENT_ACLS:
|
|
46
|
+
raise OSError('Key directory requires a Windows filesystem with persistent ACL support.')
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _create_private_key(path: Path) -> int:
|
|
50
|
+
"""Create a new binary key handle with a protected owner-only access list.
|
|
51
|
+
|
|
52
|
+
Uses Windows CREATE_NEW, applying the DACL at creation rather than after
|
|
53
|
+
writing. Privileged administrators/backup operators and filesystems without
|
|
54
|
+
ACL enforcement remain outside this access restriction. Tested on Windows
|
|
55
|
+
only when platform validation is available; no POSIX ACL claim is made.
|
|
56
|
+
"""
|
|
57
|
+
# All callers, including logs, require enforcement before CREATE_NEW.
|
|
58
|
+
_check_private_directory(path.parent)
|
|
59
|
+
|
|
60
|
+
import msvcrt
|
|
61
|
+
from ctypes import wintypes
|
|
62
|
+
|
|
63
|
+
class _SecurityAttributes(ctypes.Structure):
|
|
64
|
+
_fields_ = [('length', wintypes.DWORD), ('descriptor', wintypes.LPVOID), ('inherit', wintypes.BOOL)]
|
|
65
|
+
|
|
66
|
+
advapi = ctypes.WinDLL('advapi32', use_last_error=True)
|
|
67
|
+
kernel = ctypes.WinDLL('kernel32', use_last_error=True)
|
|
68
|
+
convert = advapi.ConvertStringSecurityDescriptorToSecurityDescriptorW
|
|
69
|
+
convert.argtypes = [wintypes.LPCWSTR, wintypes.DWORD, ctypes.POINTER(wintypes.LPVOID), ctypes.POINTER(wintypes.DWORD)]
|
|
70
|
+
convert.restype = wintypes.BOOL
|
|
71
|
+
create = kernel.CreateFileW
|
|
72
|
+
create.argtypes = [
|
|
73
|
+
wintypes.LPCWSTR,
|
|
74
|
+
wintypes.DWORD,
|
|
75
|
+
wintypes.DWORD,
|
|
76
|
+
ctypes.POINTER(_SecurityAttributes),
|
|
77
|
+
wintypes.DWORD,
|
|
78
|
+
wintypes.DWORD,
|
|
79
|
+
wintypes.HANDLE,
|
|
80
|
+
]
|
|
81
|
+
create.restype = wintypes.HANDLE
|
|
82
|
+
free = kernel.LocalFree
|
|
83
|
+
free.argtypes = [wintypes.LPVOID]
|
|
84
|
+
free.restype = wintypes.LPVOID
|
|
85
|
+
close = kernel.CloseHandle
|
|
86
|
+
close.argtypes = [wintypes.HANDLE]
|
|
87
|
+
close.restype = wintypes.BOOL
|
|
88
|
+
security = wintypes.LPVOID()
|
|
89
|
+
# Protected DACL: generic-all access for OWNER RIGHTS, no inherited grants.
|
|
90
|
+
if not convert(const.WINDOWS_KEY_DACL, 1, ctypes.byref(security), None):
|
|
91
|
+
raise OSError('Cannot establish private key access permissions.')
|
|
92
|
+
try:
|
|
93
|
+
attributes = _SecurityAttributes(ctypes.sizeof(_SecurityAttributes), security, False)
|
|
94
|
+
handle = create(str(path), 0x40000000, 0, ctypes.byref(attributes), 1, 0x00200080, None)
|
|
95
|
+
if handle == wintypes.HANDLE(-1).value:
|
|
96
|
+
error = ctypes.get_last_error()
|
|
97
|
+
if error in (80, 183):
|
|
98
|
+
raise FileExistsError('Key target already exists.')
|
|
99
|
+
raise OSError('Cannot create a private key file.')
|
|
100
|
+
try:
|
|
101
|
+
return msvcrt.open_osfhandle(handle, os.O_WRONLY | os.O_BINARY)
|
|
102
|
+
except BaseException:
|
|
103
|
+
close(handle)
|
|
104
|
+
raise
|
|
105
|
+
finally:
|
|
106
|
+
free(security)
|
obfuscidian/backup.py
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
:Module: obfuscidian.backup
|
|
4
|
+
:Synopsis: Internal read-only fresh/merge planning and encrypted publication
|
|
5
|
+
:Created By: Jeff Shurtliff
|
|
6
|
+
:Last Modified: Jeff Shurtliff (via GPT-6.1 Sol)
|
|
7
|
+
:Modified Date: 05 Oct 2026
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
from dataclasses import dataclass, field, replace
|
|
15
|
+
from datetime import UTC, datetime
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from cryptography.fernet import Fernet
|
|
19
|
+
|
|
20
|
+
from obfuscidian import constants as const
|
|
21
|
+
from obfuscidian import crypto, inventory, keys, manifest, paths, transactions
|
|
22
|
+
from obfuscidian.errors import _ConfigurationError, _OperationalError
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True, repr=False)
|
|
26
|
+
class _BackupPlan:
|
|
27
|
+
"""Keep a private, bounded logical proposal without staged bytes or randomness."""
|
|
28
|
+
|
|
29
|
+
locations: paths._VaultPaths
|
|
30
|
+
source: inventory._Inventory
|
|
31
|
+
previous: manifest._VerifiedMirror | None
|
|
32
|
+
proposal: manifest._Manifest
|
|
33
|
+
transaction: transactions._TransactionPlan
|
|
34
|
+
fernet: Fernet = field(repr=False)
|
|
35
|
+
warnings: tuple[str, ...]
|
|
36
|
+
no_op: bool
|
|
37
|
+
confirmation: bool
|
|
38
|
+
merge: bool
|
|
39
|
+
changes: _Changes
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class _Changes:
|
|
44
|
+
"""Classify current files and retained absent/excluded files without exposing names."""
|
|
45
|
+
|
|
46
|
+
new: int = 0
|
|
47
|
+
changed: int = 0
|
|
48
|
+
metadata_only: int = 0
|
|
49
|
+
unchanged: int = 0
|
|
50
|
+
retained: int = 0
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _mirror_rules(parent: Path) -> paths._TargetRules:
|
|
54
|
+
"""Restrict managed ASCII names and coordinate even case/Unicode path aliases.
|
|
55
|
+
|
|
56
|
+
Backup writes only fixed lowercase ASCII names and opaque lowercase hex IDs,
|
|
57
|
+
so their comparisons agree on case-sensitive and insensitive filesystems.
|
|
58
|
+
Conservative lock comparison also serializes case/Unicode aliases of the
|
|
59
|
+
destination without a writable probe. Source names live only in encrypted
|
|
60
|
+
metadata; this does not impose restore naming rules on original paths.
|
|
61
|
+
POSIX length limits are read from the actual destination filesystem.
|
|
62
|
+
"""
|
|
63
|
+
if os.name == 'nt':
|
|
64
|
+
return paths._TargetRules(case_sensitive=False, normalization='NFC', windows=True, component_limit=255)
|
|
65
|
+
try:
|
|
66
|
+
name_max = os.pathconf(parent, 'PC_NAME_MAX')
|
|
67
|
+
path_max = os.pathconf(parent, 'PC_PATH_MAX')
|
|
68
|
+
return paths._TargetRules(
|
|
69
|
+
case_sensitive=False,
|
|
70
|
+
normalization='NFC',
|
|
71
|
+
component_limit=name_max if name_max > 0 else None,
|
|
72
|
+
path_limit=path_max if path_max > 0 else None,
|
|
73
|
+
)
|
|
74
|
+
except (OSError, ValueError):
|
|
75
|
+
raise _OperationalError('Cannot establish destination naming limits read-only.') from None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _plan_fresh(origin: Path, mirror: Path, key: Path, *, exclusions: tuple[str, ...] = ()) -> _BackupPlan:
|
|
79
|
+
"""Plan the current included inventory, dropping absent/excluded historic paths."""
|
|
80
|
+
return _plan_backup(origin, mirror, key, exclusions=exclusions, merge=False)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _plan_merge(origin: Path, mirror: Path, key: Path, *, exclusions: tuple[str, ...] = ()) -> _BackupPlan:
|
|
84
|
+
"""Plan the additive union, retaining absent/excluded historic files and directories."""
|
|
85
|
+
return _plan_backup(origin, mirror, key, exclusions=exclusions, merge=True)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _plan_backup(origin: Path, mirror: Path, key: Path, *, exclusions: tuple[str, ...], merge: bool) -> _BackupPlan:
|
|
89
|
+
"""Authenticate, hash one source file at a time, and plan without writes.
|
|
90
|
+
|
|
91
|
+
Placeholder IDs/hashes/timestamps have the same serialized widths as the
|
|
92
|
+
eventual values. No-op planning never generates IDs, timestamps or tokens.
|
|
93
|
+
Existing object IDs, content bindings and lineage come only from complete
|
|
94
|
+
authenticated mirror validation. Merge retains old absent/excluded records;
|
|
95
|
+
fresh drops them. Neither infers renames from matching content.
|
|
96
|
+
|
|
97
|
+
:param origin: Existing read-only source.
|
|
98
|
+
:param mirror: Existing mirror or missing final component.
|
|
99
|
+
:param key: Selected existing external key.
|
|
100
|
+
:param exclusions: Portable original-relative component globs.
|
|
101
|
+
:param merge: Retain absent/excluded historic paths instead of dropping them.
|
|
102
|
+
:returns: Private proposal, exact ciphertext estimate and consent requirement.
|
|
103
|
+
:raises _ConfigurationError: Unsafe locations, content, or exclusions.
|
|
104
|
+
:raises _OperationalError: Integrity, stability, resource or I/O failure.
|
|
105
|
+
"""
|
|
106
|
+
locations = paths._preflight_vault_paths(origin, mirror, key)
|
|
107
|
+
fernet, warnings = keys._load_key(key)
|
|
108
|
+
# Observe pending ownership before attempting to interpret a partial mirror.
|
|
109
|
+
rules = _mirror_rules(mirror.parent)
|
|
110
|
+
initial = transactions._plan_transaction(origin, mirror, mirror=True, fernet=fernet, target_rules=rules)
|
|
111
|
+
source = inventory._inventory_vault(locations, exclusions=exclusions)
|
|
112
|
+
previous = manifest._verify_mirror(mirror, fernet) if const.MANAGED_DIRECTORY in initial.before else None
|
|
113
|
+
old_files = {record.path: record for record in previous.manifest.files} if previous else {}
|
|
114
|
+
old_directories = {record.path: record for record in previous.manifest.directories} if previous else {}
|
|
115
|
+
current_files = {entry.path for entry in source.entries if entry.kind == 'file'}
|
|
116
|
+
current_directories = {entry.path for entry in source.entries if entry.kind == 'directory'}
|
|
117
|
+
if merge and (current_files & old_directories.keys() or current_directories & old_files.keys()):
|
|
118
|
+
raise _ConfigurationError('Merge cannot discard a retained file/directory type conflict; use shroud fresh.')
|
|
119
|
+
retained = tuple(record for name, record in old_files.items() if name not in current_files) if merge else ()
|
|
120
|
+
counts = {'new': 0, 'changed': 0, 'metadata_only': 0, 'unchanged': 0, 'retained': len(retained)}
|
|
121
|
+
reserved = {record.object_id for record in old_files.values()}
|
|
122
|
+
records = []
|
|
123
|
+
placeholder = 0
|
|
124
|
+
for entry in source.entries:
|
|
125
|
+
if entry.kind != 'file':
|
|
126
|
+
continue
|
|
127
|
+
data = inventory._read_file(source, entry)
|
|
128
|
+
digest = crypto._sha256(data)
|
|
129
|
+
del data
|
|
130
|
+
old = old_files.get(entry.path)
|
|
131
|
+
if old is None:
|
|
132
|
+
while f'{placeholder:032x}' in reserved:
|
|
133
|
+
placeholder += 1
|
|
134
|
+
object_id = f'{placeholder:032x}'
|
|
135
|
+
reserved.add(object_id)
|
|
136
|
+
else:
|
|
137
|
+
object_id = old.object_id
|
|
138
|
+
same = old is not None and old.size == entry.size and old.plaintext_sha256 == digest
|
|
139
|
+
if old is None:
|
|
140
|
+
category = 'new'
|
|
141
|
+
elif not same:
|
|
142
|
+
category = 'changed'
|
|
143
|
+
elif old.mtime_ns != entry.mtime_ns:
|
|
144
|
+
category = 'metadata_only'
|
|
145
|
+
else:
|
|
146
|
+
category = 'unchanged'
|
|
147
|
+
counts[category] += 1
|
|
148
|
+
records.append(
|
|
149
|
+
manifest._FileRecord(
|
|
150
|
+
entry.path, object_id, entry.size, entry.mtime_ns, digest, old.ciphertext_sha256 if same else '0' * 64
|
|
151
|
+
)
|
|
152
|
+
)
|
|
153
|
+
directories = tuple(
|
|
154
|
+
manifest._DirectoryRecord(entry.path, entry.mtime_ns) for entry in source.entries if entry.kind == 'directory'
|
|
155
|
+
)
|
|
156
|
+
if merge:
|
|
157
|
+
records.extend(retained)
|
|
158
|
+
directory_union = dict(old_directories)
|
|
159
|
+
directory_union.update({record.path: record for record in directories})
|
|
160
|
+
directories = tuple(sorted(directory_union.values(), key=lambda record: record.path))
|
|
161
|
+
records.sort(key=lambda record: record.path)
|
|
162
|
+
proposal = manifest._Manifest(
|
|
163
|
+
const.FORMAT_VERSION,
|
|
164
|
+
previous.manifest.vault_id if previous else '0' * 32,
|
|
165
|
+
'0' * 32,
|
|
166
|
+
'2000-01-01T00:00:00.000000Z',
|
|
167
|
+
directories,
|
|
168
|
+
tuple(records),
|
|
169
|
+
)
|
|
170
|
+
no_op = previous is not None and directories == previous.manifest.directories and proposal.files == previous.manifest.files
|
|
171
|
+
current = {record.path: record for record in records}
|
|
172
|
+
removed_directories = previous is not None and bool(
|
|
173
|
+
{record.path for record in previous.manifest.directories} - {record.path for record in directories}
|
|
174
|
+
)
|
|
175
|
+
confirmation = removed_directories or any(
|
|
176
|
+
name not in current or (record.size, record.plaintext_sha256) != (current[name].size, current[name].plaintext_sha256)
|
|
177
|
+
for name, record in old_files.items()
|
|
178
|
+
)
|
|
179
|
+
serialized = manifest._serialize_manifest(proposal)
|
|
180
|
+
retained_bytes = sum(inventory._fernet_size(record.size) for record in retained)
|
|
181
|
+
estimate = inventory._estimate_resources(source, manifest_plaintext_bytes=len(serialized), retained_bytes=retained_bytes)
|
|
182
|
+
transaction = transactions._plan_transaction(
|
|
183
|
+
origin,
|
|
184
|
+
mirror,
|
|
185
|
+
mirror=True,
|
|
186
|
+
fernet=fernet,
|
|
187
|
+
required_bytes=0 if no_op else estimate.required_free_bytes,
|
|
188
|
+
target_rules=rules,
|
|
189
|
+
)
|
|
190
|
+
inventory._verify_inventory(source)
|
|
191
|
+
locations._recheck()
|
|
192
|
+
if previous is not None:
|
|
193
|
+
manifest._recheck_mirror(previous)
|
|
194
|
+
return _BackupPlan(
|
|
195
|
+
locations, source, previous, proposal, transaction, fernet, warnings, no_op, confirmation, merge, _Changes(**counts)
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _write_token(parent: Path, name: str, token: bytes) -> None:
|
|
200
|
+
"""Create one private staged file exclusively under an anchored directory."""
|
|
201
|
+
with paths._directory_handle(paths._inspect_path(parent)) as handle:
|
|
202
|
+
descriptor = os.open(name, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600, dir_fd=handle)
|
|
203
|
+
try:
|
|
204
|
+
stream = os.fdopen(descriptor, 'wb')
|
|
205
|
+
except (OSError, MemoryError):
|
|
206
|
+
os.close(descriptor)
|
|
207
|
+
raise
|
|
208
|
+
with stream:
|
|
209
|
+
if stream.write(token) != len(token):
|
|
210
|
+
raise _OperationalError('Incomplete staged token write; no partial snapshot is accepted.')
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _build_backup(plan: _BackupPlan, stage: Path) -> None:
|
|
214
|
+
"""Build either mode from stable current bytes and authenticated retained ciphertext."""
|
|
215
|
+
managed = stage / const.MANAGED_DIRECTORY
|
|
216
|
+
objects = managed / const.OBJECTS_DIRECTORY
|
|
217
|
+
for parent, child in ((stage, managed), (managed, objects)):
|
|
218
|
+
with paths._directory_handle(paths._inspect_path(parent)) as handle:
|
|
219
|
+
os.mkdir(child.name, 0o700, dir_fd=handle)
|
|
220
|
+
old_files = {record.path: record for record in plan.previous.manifest.files} if plan.previous else {}
|
|
221
|
+
entries = {entry.path: entry for entry in plan.source.entries if entry.kind == 'file'}
|
|
222
|
+
reserved = {record.object_id for record in old_files.values()}
|
|
223
|
+
records = []
|
|
224
|
+
for proposed in plan.proposal.files:
|
|
225
|
+
entry = entries.get(proposed.path)
|
|
226
|
+
if entry is None:
|
|
227
|
+
# Only additive merge proposals can contain a historic, absent path.
|
|
228
|
+
token, data = manifest._validated_token(plan.previous, proposed, plan.fernet)
|
|
229
|
+
del data
|
|
230
|
+
_write_token(objects, f'{proposed.object_id}.obf', token)
|
|
231
|
+
del token
|
|
232
|
+
records.append(proposed)
|
|
233
|
+
continue
|
|
234
|
+
data = inventory._read_file(plan.source, entry)
|
|
235
|
+
if len(data) != proposed.size or crypto._sha256(data) != proposed.plaintext_sha256:
|
|
236
|
+
raise _OperationalError('Source content changed after planning; no snapshot was published.')
|
|
237
|
+
old = old_files.get(entry.path)
|
|
238
|
+
if old is not None and (old.size, old.plaintext_sha256) == (proposed.size, proposed.plaintext_sha256):
|
|
239
|
+
token = manifest._reuse_object(plan.previous, old, data, plan.fernet)
|
|
240
|
+
record = replace(old, mtime_ns=entry.mtime_ns)
|
|
241
|
+
else:
|
|
242
|
+
object_id = old.object_id if old is not None else crypto._new_id(reserved)
|
|
243
|
+
reserved.add(object_id)
|
|
244
|
+
record, token = manifest._encode_file(entry.path, data, entry.mtime_ns, object_id, plan.fernet)
|
|
245
|
+
del data
|
|
246
|
+
_write_token(objects, f'{record.object_id}.obf', token)
|
|
247
|
+
del token
|
|
248
|
+
records.append(record)
|
|
249
|
+
final = replace(
|
|
250
|
+
plan.proposal,
|
|
251
|
+
vault_id=plan.previous.manifest.vault_id if plan.previous else crypto._new_id(),
|
|
252
|
+
snapshot_id=crypto._new_id({plan.previous.manifest.snapshot_id} if plan.previous else ()),
|
|
253
|
+
created_at=datetime.now(UTC).isoformat(timespec='microseconds').replace('+00:00', 'Z'),
|
|
254
|
+
files=tuple(records),
|
|
255
|
+
)
|
|
256
|
+
_write_token(managed, const.MANIFEST_FILENAME, manifest._encrypt_manifest(final, plan.fernet))
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _recheck_source(plan: _BackupPlan) -> None:
|
|
260
|
+
"""Re-inventory all included names/content marks and recheck selected key custody."""
|
|
261
|
+
inventory._verify_inventory(plan.source)
|
|
262
|
+
paths._recheck_path(plan.locations.key, content=True)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _publish_backup(
|
|
266
|
+
plan: _BackupPlan,
|
|
267
|
+
*,
|
|
268
|
+
yes: bool = False,
|
|
269
|
+
non_interactive: bool = False,
|
|
270
|
+
prompt: Callable[[], bool] | None = None,
|
|
271
|
+
dry_run: bool = False,
|
|
272
|
+
) -> transactions._TransactionResult | None:
|
|
273
|
+
"""Publish a changed verified backup in either mode, or perform no writes.
|
|
274
|
+
|
|
275
|
+
:param plan: Complete read-only proposal.
|
|
276
|
+
:param yes: Explicit consent for replacement.
|
|
277
|
+
:param non_interactive: Prohibit consent prompts.
|
|
278
|
+
:param prompt: Terminal-aware replacement prompt.
|
|
279
|
+
:param dry_run: Validate only, without consent, randomness, or artifacts.
|
|
280
|
+
:returns: Retained private locations for a write; no result for no-op/dry run.
|
|
281
|
+
:raises _OperationalError: Consent, stability, transaction or integrity failure.
|
|
282
|
+
:raises KeyboardInterrupt: Interruption after conservative recovery was attempted.
|
|
283
|
+
"""
|
|
284
|
+
_recheck_source(plan)
|
|
285
|
+
plan.locations._recheck()
|
|
286
|
+
if plan.previous is not None:
|
|
287
|
+
manifest._recheck_mirror(plan.previous)
|
|
288
|
+
read_only = dry_run or plan.no_op
|
|
289
|
+
return transactions._execute_transaction(
|
|
290
|
+
plan.transaction,
|
|
291
|
+
lambda stage: _build_backup(plan, stage),
|
|
292
|
+
lambda stage: _recheck_source(plan),
|
|
293
|
+
yes=yes or not plan.confirmation,
|
|
294
|
+
non_interactive=non_interactive,
|
|
295
|
+
prompt=prompt,
|
|
296
|
+
dry_run=read_only,
|
|
297
|
+
prepublish=lambda: _recheck_source(plan),
|
|
298
|
+
retain_rollback=not plan.merge,
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _recover_backup(
|
|
303
|
+
origin: Path,
|
|
304
|
+
mirror: Path,
|
|
305
|
+
key: Path,
|
|
306
|
+
*,
|
|
307
|
+
yes: bool = False,
|
|
308
|
+
non_interactive: bool = False,
|
|
309
|
+
prompt: Callable[[], bool] | None = None,
|
|
310
|
+
exclusions: tuple[str, ...] = (),
|
|
311
|
+
) -> transactions._TransactionResult:
|
|
312
|
+
"""Validate custody and explicitly recover old state before the CLI replans.
|
|
313
|
+
|
|
314
|
+
Recovery cannot bypass selected-key authentication of complete mirror
|
|
315
|
+
payloads. Incomplete unapproved staging remains retained. The origin is
|
|
316
|
+
inventoried before recovery to refuse unsafe types and hard-linked keys.
|
|
317
|
+
"""
|
|
318
|
+
locations = paths._preflight_vault_paths(origin, mirror, key)
|
|
319
|
+
inventory._inventory_vault(locations, exclusions=exclusions)
|
|
320
|
+
fernet, _warnings = keys._load_key(key)
|
|
321
|
+
plan = transactions._plan_transaction(
|
|
322
|
+
origin, mirror, mirror=True, fernet=fernet, allow_pending=True, target_rules=_mirror_rules(mirror.parent)
|
|
323
|
+
)
|
|
324
|
+
return transactions._recover_transaction(plan, yes=yes, non_interactive=non_interactive, prompt=prompt)
|