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.
@@ -0,0 +1,8 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ :Module: obfuscidian.__init__
4
+ :Synopsis: The ``__init__`` module for obfuscidian
5
+ :Created By: Jeff Shurtliff
6
+ :Last Modified: Jeff Shurtliff
7
+ :Modified Date: 30 Sep 2026
8
+ """
@@ -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')
@@ -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)