agent2learn 0.1.2__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.
- agent2learn/__init__.py +3 -0
- agent2learn/_release.py +19 -0
- agent2learn/aipolicy.py +182 -0
- agent2learn/api.py +590 -0
- agent2learn/audit.py +358 -0
- agent2learn/auth/__init__.py +282 -0
- agent2learn/auth/cdp.py +1067 -0
- agent2learn/auth/paste.py +378 -0
- agent2learn/calendar.py +525 -0
- agent2learn/calibrate.py +347 -0
- agent2learn/check.py +1091 -0
- agent2learn/cli.py +2039 -0
- agent2learn/clock.py +39 -0
- agent2learn/config.py +205 -0
- agent2learn/console.py +229 -0
- agent2learn/convert.py +1223 -0
- agent2learn/doctor.py +1167 -0
- agent2learn/errors.py +32 -0
- agent2learn/ground.py +735 -0
- agent2learn/index.py +614 -0
- agent2learn/ingest.py +3229 -0
- agent2learn/locations.py +247 -0
- agent2learn/outlines.py +754 -0
- agent2learn/paths.py +683 -0
- agent2learn/pipeline.py +392 -0
- agent2learn/privacy.py +1123 -0
- agent2learn/schools/__init__.py +29 -0
- agent2learn/schools/_base.py +194 -0
- agent2learn/schools/generic.py +78 -0
- agent2learn/schools/uwaterloo.py +66 -0
- agent2learn/session.py +373 -0
- agent2learn/skills.py +1081 -0
- agent2learn/snapshot.py +399 -0
- agent2learn/submit.py +1047 -0
- agent2learn/transactions.py +157 -0
- agent2learn/upgrade.py +288 -0
- agent2learn/vault.py +1134 -0
- agent2learn-0.1.2.data/data/a2l-coursework/SKILL.md +52 -0
- agent2learn-0.1.2.data/data/a2l-setup/SKILL.md +27 -0
- agent2learn-0.1.2.data/data/a2l-study/SKILL.md +27 -0
- agent2learn-0.1.2.data/data/a2l-sync/SKILL.md +30 -0
- agent2learn-0.1.2.dist-info/METADATA +186 -0
- agent2learn-0.1.2.dist-info/RECORD +46 -0
- agent2learn-0.1.2.dist-info/WHEEL +4 -0
- agent2learn-0.1.2.dist-info/entry_points.txt +3 -0
- agent2learn-0.1.2.dist-info/licenses/LICENSE +202 -0
agent2learn/privacy.py
ADDED
|
@@ -0,0 +1,1123 @@
|
|
|
1
|
+
"""Preview-first, allowlisted removal of locally retained sensitive categories.
|
|
2
|
+
|
|
3
|
+
Privacy operations are intentionally more conservative than ordinary vault maintenance. This
|
|
4
|
+
module discovers exact managed files or exact structured records, validates the complete target
|
|
5
|
+
inventory, and only then permits a fresh interactive confirmation to install the change. It
|
|
6
|
+
never interprets arbitrary course text as permission to delete anything and it never delegates a
|
|
7
|
+
privacy purge to a recursive tree-removal helper.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
from collections.abc import Iterable, Mapping
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from pathlib import Path, PurePosixPath
|
|
19
|
+
from typing import Any, Literal, cast
|
|
20
|
+
|
|
21
|
+
from agent2learn import config, console, paths
|
|
22
|
+
from agent2learn.errors import A2LError
|
|
23
|
+
from agent2learn.vault import Vault
|
|
24
|
+
|
|
25
|
+
Category = Literal["grades", "discussions", "logs"]
|
|
26
|
+
_CATEGORIES = frozenset({"grades", "discussions", "logs"})
|
|
27
|
+
_BACKUP_NAME = re.compile(r"^\.a2l-backup-v\d+(?:-\d+)?$")
|
|
28
|
+
_WINDOWS_ABSOLUTE = re.compile(r"^[A-Za-z]:[\\/]")
|
|
29
|
+
_GRADE_KEYS = frozenset({"grade", "grades", "my_grade", "my_grades", "grade_fields"})
|
|
30
|
+
_DISCUSSION_KEYS = frozenset({"discussion", "discussions", "forum", "forums"})
|
|
31
|
+
_KNOWN_LOG_NAMES = ("a2l.log", "a2l.log.1", "a2l.log.2", "a2l.log.3", "a2l.log.4")
|
|
32
|
+
_REVISION_FILENAME = "revision.json"
|
|
33
|
+
_INVENTORY_NAMES = ("privacy.json", "category-inventory.json")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class PrivacyStatus:
|
|
38
|
+
"""Redacted sensitive-category state suitable for terminal display."""
|
|
39
|
+
|
|
40
|
+
grades_enabled: bool
|
|
41
|
+
discussions_enabled: bool
|
|
42
|
+
grades_present: bool
|
|
43
|
+
discussions_present: bool
|
|
44
|
+
logs_present: bool
|
|
45
|
+
grade_location: str = "<vault>/<Term>/<Course>/_meta/my_grades.json and <vault>/.a2l/snapshots/"
|
|
46
|
+
discussion_location: str = "<vault>/<Term>/<Course>/discussions/ and <vault>/.a2l/private/"
|
|
47
|
+
log_location: str = "<configured log directory>/a2l.log[.1-.4]"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class PurgeTarget:
|
|
52
|
+
"""One exact planned file, record, or managed revision operation."""
|
|
53
|
+
|
|
54
|
+
path: Path
|
|
55
|
+
display: str
|
|
56
|
+
action: str
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class PurgePlan:
|
|
61
|
+
"""A preview inventory bound to the exact bytes observed at discovery time."""
|
|
62
|
+
|
|
63
|
+
category: Category
|
|
64
|
+
targets: tuple[PurgeTarget, ...]
|
|
65
|
+
fingerprint: str
|
|
66
|
+
log_directory: Path | None = field(default=None, repr=False, compare=False)
|
|
67
|
+
_operations: tuple[_Operation, ...] = field(default=(), repr=False, compare=False)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class PurgeResult:
|
|
72
|
+
"""Mutation summary without echoing sensitive paths or values."""
|
|
73
|
+
|
|
74
|
+
category: Category
|
|
75
|
+
target_count: int
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True)
|
|
79
|
+
class _StateRoot:
|
|
80
|
+
path: Path
|
|
81
|
+
display: str
|
|
82
|
+
trusted_root: Path
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(frozen=True)
|
|
86
|
+
class _Operation:
|
|
87
|
+
path: Path
|
|
88
|
+
action: Literal["unlink", "rewrite", "remove_tree", "rmdir"]
|
|
89
|
+
display: str
|
|
90
|
+
detail: str
|
|
91
|
+
payload: object | None = None
|
|
92
|
+
trusted_root: Path | None = None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def status(
|
|
96
|
+
vault: Vault,
|
|
97
|
+
cfg: config.Config,
|
|
98
|
+
*,
|
|
99
|
+
log_directory: Path | None = None,
|
|
100
|
+
) -> PrivacyStatus:
|
|
101
|
+
"""Return category flags and redacted retained-data locations without printing contents."""
|
|
102
|
+
|
|
103
|
+
if not paths.long_path(vault.root).is_dir():
|
|
104
|
+
return PrivacyStatus(
|
|
105
|
+
grades_enabled=cfg.include_grades,
|
|
106
|
+
discussions_enabled=cfg.include_discussions,
|
|
107
|
+
grades_present=False,
|
|
108
|
+
discussions_present=False,
|
|
109
|
+
logs_present=False,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
grades = plan_purge(vault, "grades", log_directory=log_directory)
|
|
113
|
+
discussions = plan_purge(vault, "discussions", log_directory=log_directory)
|
|
114
|
+
logs = plan_purge(vault, "logs", log_directory=log_directory)
|
|
115
|
+
return PrivacyStatus(
|
|
116
|
+
grades_enabled=cfg.include_grades,
|
|
117
|
+
discussions_enabled=cfg.include_discussions,
|
|
118
|
+
grades_present=bool(grades.targets),
|
|
119
|
+
discussions_present=bool(discussions.targets),
|
|
120
|
+
logs_present=bool(logs.targets),
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def render_status(value: PrivacyStatus) -> str:
|
|
125
|
+
"""Render only policy state and structural redacted locations."""
|
|
126
|
+
|
|
127
|
+
def state(enabled: bool, present: bool) -> str:
|
|
128
|
+
collection = "enabled" if enabled else "disabled"
|
|
129
|
+
retained = "retained locally" if present else "not detected locally"
|
|
130
|
+
return f"{collection}; {retained}"
|
|
131
|
+
|
|
132
|
+
return "\n".join(
|
|
133
|
+
[
|
|
134
|
+
"Privacy status",
|
|
135
|
+
f"- grades: {state(value.grades_enabled, value.grades_present)}",
|
|
136
|
+
f" location: {value.grade_location}",
|
|
137
|
+
f"- discussions: {state(value.discussions_enabled, value.discussions_present)}",
|
|
138
|
+
f" location: {value.discussion_location}",
|
|
139
|
+
f"- logs: {'present' if value.logs_present else 'not detected'}",
|
|
140
|
+
f" location: {value.log_location}",
|
|
141
|
+
"",
|
|
142
|
+
]
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def plan_purge(
|
|
147
|
+
vault: Vault,
|
|
148
|
+
category: str,
|
|
149
|
+
*,
|
|
150
|
+
log_directory: Path | None = None,
|
|
151
|
+
) -> PurgePlan:
|
|
152
|
+
"""Discover and validate an exact allowlisted purge inventory without mutating anything."""
|
|
153
|
+
|
|
154
|
+
selected = _category(category)
|
|
155
|
+
_validate_vault_root(vault)
|
|
156
|
+
operations: list[_Operation] = []
|
|
157
|
+
if selected == "logs":
|
|
158
|
+
_discover_logs(operations, log_directory)
|
|
159
|
+
else:
|
|
160
|
+
states = _state_roots(vault)
|
|
161
|
+
_discover_course_artifacts(vault, selected, operations)
|
|
162
|
+
for state in states:
|
|
163
|
+
_discover_state_artifacts(vault, state, selected, operations)
|
|
164
|
+
operations = _deduplicate(operations)
|
|
165
|
+
return _make_plan(vault, selected, operations, log_directory=log_directory)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def execute_purge(
|
|
169
|
+
vault: Vault,
|
|
170
|
+
plan: PurgePlan,
|
|
171
|
+
*,
|
|
172
|
+
phrase: str,
|
|
173
|
+
interactive: bool,
|
|
174
|
+
log_directory: Path | None = None,
|
|
175
|
+
) -> PurgeResult:
|
|
176
|
+
"""Apply a preview only after an exact, action-specific interactive confirmation.
|
|
177
|
+
|
|
178
|
+
The plan is rediscovered after confirmation. Any changed target, including a changed JSON
|
|
179
|
+
file or a newly appearing allowlisted file, invalidates the confirmation instead of widening
|
|
180
|
+
the operation silently.
|
|
181
|
+
"""
|
|
182
|
+
|
|
183
|
+
if not interactive:
|
|
184
|
+
raise A2LError("privacy purge requires an interactive terminal")
|
|
185
|
+
if not isinstance(phrase, str) or phrase != f"PURGE {plan.category.upper()}":
|
|
186
|
+
raise A2LError(f"privacy purge confirmation must be exactly PURGE {plan.category.upper()}")
|
|
187
|
+
|
|
188
|
+
refreshed = plan_purge(
|
|
189
|
+
vault,
|
|
190
|
+
plan.category,
|
|
191
|
+
log_directory=log_directory if log_directory is not None else plan.log_directory,
|
|
192
|
+
)
|
|
193
|
+
if refreshed.fingerprint != plan.fingerprint:
|
|
194
|
+
raise A2LError("privacy purge preview is stale; review a new preview")
|
|
195
|
+
|
|
196
|
+
if plan.category == "logs":
|
|
197
|
+
# Close only Agent2Learn's marked rotating handlers. In particular, do not configure a
|
|
198
|
+
# replacement handler after deleting the files: that would recreate a log immediately.
|
|
199
|
+
console.close_owned_handlers()
|
|
200
|
+
_apply(refreshed._operations)
|
|
201
|
+
return PurgeResult(plan.category, len(refreshed.targets))
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def purge(
|
|
205
|
+
vault: Vault,
|
|
206
|
+
category: str,
|
|
207
|
+
*,
|
|
208
|
+
phrase: str | None = None,
|
|
209
|
+
interactive: bool = False,
|
|
210
|
+
log_directory: Path | None = None,
|
|
211
|
+
) -> PurgePlan | PurgeResult:
|
|
212
|
+
"""Return a preview by default, or execute it with the exact fresh confirmation phrase."""
|
|
213
|
+
|
|
214
|
+
plan = plan_purge(vault, category, log_directory=log_directory)
|
|
215
|
+
if phrase is None:
|
|
216
|
+
return plan
|
|
217
|
+
return execute_purge(
|
|
218
|
+
vault,
|
|
219
|
+
plan,
|
|
220
|
+
phrase=phrase,
|
|
221
|
+
interactive=interactive,
|
|
222
|
+
log_directory=log_directory,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def render_plan(plan: PurgePlan) -> str:
|
|
227
|
+
"""Render an exact preview without reading or displaying target contents."""
|
|
228
|
+
|
|
229
|
+
lines = [f"Privacy purge preview · {plan.category}"]
|
|
230
|
+
if not plan.targets:
|
|
231
|
+
lines.append("- No retained files or records match this category.")
|
|
232
|
+
else:
|
|
233
|
+
lines.extend(f"- {target.display} — {target.action}" for target in plan.targets)
|
|
234
|
+
lines.extend(
|
|
235
|
+
[
|
|
236
|
+
"",
|
|
237
|
+
(
|
|
238
|
+
"Logical deletion only: filesystem snapshots, backups, and external copies "
|
|
239
|
+
"may retain data."
|
|
240
|
+
),
|
|
241
|
+
f"To continue in a controlling terminal, type: PURGE {plan.category.upper()}",
|
|
242
|
+
]
|
|
243
|
+
)
|
|
244
|
+
return "\n".join(lines) + "\n"
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _category(value: str) -> Category:
|
|
248
|
+
if not isinstance(value, str) or value not in _CATEGORIES:
|
|
249
|
+
raise A2LError("privacy category must be exactly grades, discussions, or logs")
|
|
250
|
+
return cast(Category, value)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _validate_vault_root(vault: Vault) -> None:
|
|
254
|
+
root = vault.root
|
|
255
|
+
if paths.is_link(root):
|
|
256
|
+
raise A2LError("privacy purge refuses a symlinked vault root")
|
|
257
|
+
if not paths.long_path(root).is_dir():
|
|
258
|
+
raise A2LError("vault is unavailable; run: a2l init")
|
|
259
|
+
state = root / ".a2l"
|
|
260
|
+
if paths.is_link(state):
|
|
261
|
+
raise A2LError("privacy purge refuses a symlinked vault state directory")
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _state_roots(vault: Vault) -> tuple[_StateRoot, ...]:
|
|
265
|
+
result: list[_StateRoot] = []
|
|
266
|
+
current = vault.root / ".a2l"
|
|
267
|
+
if paths.long_path(current).is_dir():
|
|
268
|
+
_safe_path(current, vault.root)
|
|
269
|
+
result.append(_StateRoot(current, "<vault>/.a2l", vault.root))
|
|
270
|
+
|
|
271
|
+
result.extend(_backup_state_roots(vault.root, vault.root))
|
|
272
|
+
return tuple(result)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _backup_state_roots(directory: Path, trusted_root: Path) -> list[_StateRoot]:
|
|
276
|
+
children = _directory_children(directory, "schema backup inventory")
|
|
277
|
+
result: list[_StateRoot] = []
|
|
278
|
+
for child in children:
|
|
279
|
+
if not _BACKUP_NAME.fullmatch(child.name):
|
|
280
|
+
continue
|
|
281
|
+
if paths.is_link(child):
|
|
282
|
+
raise A2LError("privacy purge refuses a symlinked schema backup")
|
|
283
|
+
if not paths.long_path(child).is_dir():
|
|
284
|
+
raise A2LError("schema backup path is not a directory")
|
|
285
|
+
state = child / ".a2l" if paths.long_path(child / ".a2l").is_dir() else child
|
|
286
|
+
_safe_path(child, trusted_root)
|
|
287
|
+
_safe_path(state, child)
|
|
288
|
+
display = "<schema backup>/.a2l" if state.name == ".a2l" else "<schema backup>"
|
|
289
|
+
result.append(_StateRoot(state, display, child))
|
|
290
|
+
return result
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _discover_course_artifacts(
|
|
294
|
+
vault: Vault,
|
|
295
|
+
category: Category,
|
|
296
|
+
operations: list[_Operation],
|
|
297
|
+
) -> None:
|
|
298
|
+
root = vault.root
|
|
299
|
+
content_maps: list[Path] = []
|
|
300
|
+
for candidate in _named_paths(root, "content_map.json"):
|
|
301
|
+
if ".a2l" in candidate.parts:
|
|
302
|
+
continue
|
|
303
|
+
_safe_path(candidate, root)
|
|
304
|
+
content_maps.append(candidate)
|
|
305
|
+
course_dir = candidate.parent.parent
|
|
306
|
+
if category == "grades":
|
|
307
|
+
_add_unlink(
|
|
308
|
+
vault,
|
|
309
|
+
operations,
|
|
310
|
+
course_dir / "_meta" / "my_grades.json",
|
|
311
|
+
"remove grade projection",
|
|
312
|
+
)
|
|
313
|
+
else:
|
|
314
|
+
_add_unlink(
|
|
315
|
+
vault,
|
|
316
|
+
operations,
|
|
317
|
+
course_dir / "_meta" / "discussions.json",
|
|
318
|
+
"remove discussion projection",
|
|
319
|
+
)
|
|
320
|
+
discussion_dir = course_dir / "discussions"
|
|
321
|
+
if paths.is_link(discussion_dir):
|
|
322
|
+
raise A2LError("privacy purge refuses a symlinked discussions directory")
|
|
323
|
+
if paths.long_path(discussion_dir).is_dir():
|
|
324
|
+
_add_unlink(
|
|
325
|
+
vault,
|
|
326
|
+
operations,
|
|
327
|
+
discussion_dir / "discussions.md",
|
|
328
|
+
"remove generated discussion markdown",
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
if category == "discussions":
|
|
332
|
+
for discussion_dir in _discussion_directories(root):
|
|
333
|
+
if paths.is_link(discussion_dir):
|
|
334
|
+
raise A2LError("privacy purge refuses a symlinked discussions directory")
|
|
335
|
+
_add_discussion_paths_from_map(vault, operations, discussion_dir, content_maps)
|
|
336
|
+
_add_empty_directory_cleanup(vault, operations, discussion_dir)
|
|
337
|
+
|
|
338
|
+
for content_map in content_maps:
|
|
339
|
+
raw = _read_json(content_map, "content map")
|
|
340
|
+
topics = raw.get("topics")
|
|
341
|
+
if not isinstance(topics, list):
|
|
342
|
+
raise A2LError("content_map.json topics must be an array")
|
|
343
|
+
if category == "discussions":
|
|
344
|
+
kept = [topic for topic in topics if not _owned_row(topic, "discussions")]
|
|
345
|
+
if len(kept) != len(topics):
|
|
346
|
+
payload = dict(raw)
|
|
347
|
+
payload["topics"] = kept
|
|
348
|
+
_add_rewrite(
|
|
349
|
+
vault,
|
|
350
|
+
operations,
|
|
351
|
+
content_map,
|
|
352
|
+
payload,
|
|
353
|
+
"remove discussion content-map records",
|
|
354
|
+
)
|
|
355
|
+
elif any(_owned_row(topic, "grades") for topic in topics):
|
|
356
|
+
kept = [topic for topic in topics if not _owned_row(topic, "grades")]
|
|
357
|
+
payload = dict(raw)
|
|
358
|
+
payload["topics"] = kept
|
|
359
|
+
_add_rewrite(
|
|
360
|
+
vault, operations, content_map, payload, "remove grade content-map records"
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
# These projections are allowlisted by their structural location, not inferred from the
|
|
364
|
+
# presence of a content map. A damaged or hand-created course can still retain sensitive
|
|
365
|
+
# data, and privacy status/purge must not miss it merely because navigation metadata is absent.
|
|
366
|
+
projection_name = "my_grades.json" if category == "grades" else "discussions.json"
|
|
367
|
+
for projection in _named_paths(root, projection_name):
|
|
368
|
+
if ".a2l" in projection.parts or projection.parent.name != "_meta":
|
|
369
|
+
continue
|
|
370
|
+
_safe_path(projection, root)
|
|
371
|
+
_add_unlink(
|
|
372
|
+
vault,
|
|
373
|
+
operations,
|
|
374
|
+
projection,
|
|
375
|
+
"remove grade projection" if category == "grades" else "remove discussion projection",
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
for index_path in _named_paths(root, "INDEX.md"):
|
|
379
|
+
if ".a2l" in index_path.parts:
|
|
380
|
+
continue
|
|
381
|
+
_safe_path(index_path, root)
|
|
382
|
+
original = _read_text(index_path, "course index")
|
|
383
|
+
scrubbed = _scrub_index(original, category)
|
|
384
|
+
if scrubbed != original:
|
|
385
|
+
_add_rewrite(vault, operations, index_path, scrubbed, f"remove {category} index block")
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _discover_state_artifacts(
|
|
389
|
+
vault: Vault,
|
|
390
|
+
state: _StateRoot,
|
|
391
|
+
category: Category,
|
|
392
|
+
operations: list[_Operation],
|
|
393
|
+
) -> None:
|
|
394
|
+
state_path = state.path
|
|
395
|
+
if paths.is_link(state_path):
|
|
396
|
+
raise A2LError("privacy purge refuses a symlinked managed state directory")
|
|
397
|
+
if not paths.long_path(state_path).is_dir():
|
|
398
|
+
return
|
|
399
|
+
|
|
400
|
+
if category == "grades":
|
|
401
|
+
_discover_snapshot_scrubs(vault, state, operations)
|
|
402
|
+
else:
|
|
403
|
+
_discover_discussion_private_files(vault, state, operations)
|
|
404
|
+
|
|
405
|
+
manifest = state_path / "manifest.json"
|
|
406
|
+
if paths.long_path(manifest).exists() or paths.is_link(manifest):
|
|
407
|
+
_safe_path(manifest, state.trusted_root)
|
|
408
|
+
raw = _read_json(manifest, "manifest")
|
|
409
|
+
_discover_manifest(vault, state, manifest, raw, category, operations)
|
|
410
|
+
|
|
411
|
+
for inventory_name in _INVENTORY_NAMES:
|
|
412
|
+
inventory = state_path / "private" / inventory_name
|
|
413
|
+
if paths.long_path(inventory).exists() or paths.is_link(inventory):
|
|
414
|
+
_safe_path(inventory, state.trusted_root)
|
|
415
|
+
raw = _read_json(inventory, "privacy inventory")
|
|
416
|
+
scrubbed = _scrub_inventory(raw, category)
|
|
417
|
+
if scrubbed != raw:
|
|
418
|
+
_add_rewrite(
|
|
419
|
+
vault,
|
|
420
|
+
operations,
|
|
421
|
+
inventory,
|
|
422
|
+
scrubbed,
|
|
423
|
+
f"remove {category} inventory",
|
|
424
|
+
trusted_root=state.trusted_root,
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
history = state_path / "history"
|
|
428
|
+
if paths.is_link(history):
|
|
429
|
+
raise A2LError("privacy purge refuses a symlinked managed history directory")
|
|
430
|
+
if paths.long_path(history).is_dir():
|
|
431
|
+
_reject_links_in_managed_directory(history, "history")
|
|
432
|
+
for revision in _named_paths(history, _REVISION_FILENAME):
|
|
433
|
+
_safe_path(revision, state.trusted_root)
|
|
434
|
+
raw = _read_json(revision, "revision metadata")
|
|
435
|
+
if _owned_record(raw, None, category):
|
|
436
|
+
_add_remove_tree(
|
|
437
|
+
vault,
|
|
438
|
+
operations,
|
|
439
|
+
revision.parent,
|
|
440
|
+
state.trusted_root,
|
|
441
|
+
f"remove {category} managed revision",
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _discover_snapshot_scrubs(
|
|
446
|
+
vault: Vault,
|
|
447
|
+
state: _StateRoot,
|
|
448
|
+
operations: list[_Operation],
|
|
449
|
+
) -> None:
|
|
450
|
+
snapshots = state.path / "snapshots"
|
|
451
|
+
if paths.is_link(snapshots):
|
|
452
|
+
raise A2LError("privacy purge refuses a symlinked snapshot directory")
|
|
453
|
+
if not paths.long_path(snapshots).is_dir():
|
|
454
|
+
return
|
|
455
|
+
for snapshot_path in _json_files(snapshots):
|
|
456
|
+
_safe_path(snapshot_path, state.trusted_root)
|
|
457
|
+
raw = _read_json(snapshot_path, "snapshot")
|
|
458
|
+
scrubbed, changed = _scrub_snapshot(raw)
|
|
459
|
+
if changed:
|
|
460
|
+
_add_rewrite(
|
|
461
|
+
vault,
|
|
462
|
+
operations,
|
|
463
|
+
snapshot_path,
|
|
464
|
+
scrubbed,
|
|
465
|
+
"remove grades field",
|
|
466
|
+
trusted_root=state.trusted_root,
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _discover_discussion_private_files(
|
|
471
|
+
vault: Vault,
|
|
472
|
+
state: _StateRoot,
|
|
473
|
+
operations: list[_Operation],
|
|
474
|
+
) -> None:
|
|
475
|
+
private = state.path / "private"
|
|
476
|
+
if paths.is_link(private):
|
|
477
|
+
raise A2LError("privacy purge refuses a symlinked private state directory")
|
|
478
|
+
key = private / "discussion-hmac.key"
|
|
479
|
+
if paths.long_path(key).exists() or paths.is_link(key):
|
|
480
|
+
_safe_path(key, state.trusted_root)
|
|
481
|
+
_add_unlink(
|
|
482
|
+
vault,
|
|
483
|
+
operations,
|
|
484
|
+
key,
|
|
485
|
+
"remove discussion pseudonym key",
|
|
486
|
+
trusted_root=state.trusted_root,
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _discover_manifest(
|
|
491
|
+
vault: Vault,
|
|
492
|
+
state: _StateRoot,
|
|
493
|
+
manifest: Path,
|
|
494
|
+
raw: dict[str, object],
|
|
495
|
+
category: Category,
|
|
496
|
+
operations: list[_Operation],
|
|
497
|
+
) -> None:
|
|
498
|
+
entries = raw.get("entries")
|
|
499
|
+
if not isinstance(entries, dict):
|
|
500
|
+
raise A2LError("manifest entries must be an object")
|
|
501
|
+
kept: dict[str, object] = {}
|
|
502
|
+
changed = False
|
|
503
|
+
for key, entry in entries.items():
|
|
504
|
+
if not isinstance(key, str):
|
|
505
|
+
raise A2LError("manifest entry keys must be strings")
|
|
506
|
+
if _owned_record(entry, key, category):
|
|
507
|
+
changed = True
|
|
508
|
+
if isinstance(entry, Mapping):
|
|
509
|
+
for relative in _entry_paths(entry):
|
|
510
|
+
target = _relative_target(vault.root, relative)
|
|
511
|
+
if paths.long_path(target).exists() or paths.is_link(target):
|
|
512
|
+
_add_unlink(
|
|
513
|
+
vault,
|
|
514
|
+
operations,
|
|
515
|
+
target,
|
|
516
|
+
f"remove {category} manifest artifact",
|
|
517
|
+
trusted_root=vault.root,
|
|
518
|
+
)
|
|
519
|
+
continue
|
|
520
|
+
kept[key] = entry
|
|
521
|
+
if changed:
|
|
522
|
+
payload = dict(raw)
|
|
523
|
+
payload["entries"] = kept
|
|
524
|
+
_add_rewrite(
|
|
525
|
+
vault,
|
|
526
|
+
operations,
|
|
527
|
+
manifest,
|
|
528
|
+
payload,
|
|
529
|
+
f"remove {category} manifest records",
|
|
530
|
+
trusted_root=state.trusted_root,
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def _discover_logs(operations: list[_Operation], log_directory: Path | None) -> None:
|
|
535
|
+
directory = (
|
|
536
|
+
Path(log_directory).expanduser()
|
|
537
|
+
if log_directory is not None
|
|
538
|
+
else Path(config.DIRS.user_log_path)
|
|
539
|
+
)
|
|
540
|
+
if paths.is_link(directory) or paths.has_link_component(directory):
|
|
541
|
+
raise A2LError("privacy log purge refuses a symlinked log directory")
|
|
542
|
+
if not paths.long_path(directory).is_dir():
|
|
543
|
+
return
|
|
544
|
+
for name in _KNOWN_LOG_NAMES:
|
|
545
|
+
candidate = directory / name
|
|
546
|
+
if paths.is_link(candidate):
|
|
547
|
+
raise A2LError("privacy log purge refuses a symlinked log file")
|
|
548
|
+
if paths.long_path(candidate).exists():
|
|
549
|
+
if not paths.long_path(candidate).is_file():
|
|
550
|
+
raise A2LError("privacy log purge found a non-file rotating log target")
|
|
551
|
+
_add_operation(
|
|
552
|
+
operations,
|
|
553
|
+
_Operation(
|
|
554
|
+
candidate,
|
|
555
|
+
"unlink",
|
|
556
|
+
f"<configured log directory>/{name}",
|
|
557
|
+
"remove rotating log",
|
|
558
|
+
trusted_root=directory,
|
|
559
|
+
),
|
|
560
|
+
)
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def _discussion_directories(root: Path) -> tuple[Path, ...]:
|
|
564
|
+
result: list[Path] = []
|
|
565
|
+
for candidate in _named_paths(root, "discussions"):
|
|
566
|
+
if paths.is_link(candidate):
|
|
567
|
+
raise A2LError("privacy purge refuses a symlinked discussions directory")
|
|
568
|
+
if candidate.parent.name == ".a2l":
|
|
569
|
+
continue
|
|
570
|
+
meta = candidate.parent / "_meta"
|
|
571
|
+
if paths.long_path(meta).is_dir() or paths.long_path(meta / "content_map.json").exists():
|
|
572
|
+
result.append(candidate)
|
|
573
|
+
return tuple(result)
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def _add_discussion_paths_from_map(
|
|
577
|
+
vault: Vault,
|
|
578
|
+
operations: list[_Operation],
|
|
579
|
+
discussion_dir: Path,
|
|
580
|
+
content_maps: Iterable[Path],
|
|
581
|
+
) -> None:
|
|
582
|
+
for content_map in content_maps:
|
|
583
|
+
if content_map.parent.parent != discussion_dir.parent:
|
|
584
|
+
continue
|
|
585
|
+
raw = _read_json(content_map, "content map")
|
|
586
|
+
topics = raw.get("topics")
|
|
587
|
+
if not isinstance(topics, list):
|
|
588
|
+
raise A2LError("content_map.json topics must be an array")
|
|
589
|
+
for topic in topics:
|
|
590
|
+
if not _owned_row(topic, "discussions") or not isinstance(topic, Mapping):
|
|
591
|
+
continue
|
|
592
|
+
for value in _entry_paths(topic):
|
|
593
|
+
if value:
|
|
594
|
+
target = _relative_target(vault.root, value)
|
|
595
|
+
_add_unlink(vault, operations, target, "remove discussion source or twin")
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def _add_empty_directory_cleanup(
|
|
599
|
+
vault: Vault,
|
|
600
|
+
operations: list[_Operation],
|
|
601
|
+
directory: Path,
|
|
602
|
+
) -> None:
|
|
603
|
+
_safe_path(directory, vault.root)
|
|
604
|
+
if not paths.long_path(directory).is_dir():
|
|
605
|
+
return
|
|
606
|
+
candidates = list(paths.walk(directory))
|
|
607
|
+
for candidate in candidates:
|
|
608
|
+
if paths.is_link(candidate):
|
|
609
|
+
raise A2LError("privacy purge refuses a symlinked discussions entry")
|
|
610
|
+
|
|
611
|
+
managed_files = {
|
|
612
|
+
operation.path
|
|
613
|
+
for operation in operations
|
|
614
|
+
if operation.action == "unlink" and operation.path.is_relative_to(directory)
|
|
615
|
+
}
|
|
616
|
+
removable: set[Path] = set()
|
|
617
|
+
directories = sorted(
|
|
618
|
+
(candidate for candidate in candidates if paths.long_path(candidate).is_dir()),
|
|
619
|
+
key=lambda value: len(value.relative_to(directory).parts),
|
|
620
|
+
reverse=True,
|
|
621
|
+
)
|
|
622
|
+
directories.append(directory)
|
|
623
|
+
for candidate in directories:
|
|
624
|
+
try:
|
|
625
|
+
children = _directory_children(candidate, "privacy discussions directory")
|
|
626
|
+
except OSError as exc:
|
|
627
|
+
raise A2LError("privacy discussions directory is unreadable") from exc
|
|
628
|
+
if all(child in managed_files or child in removable for child in children):
|
|
629
|
+
removable.add(candidate)
|
|
630
|
+
_add_operation(
|
|
631
|
+
operations,
|
|
632
|
+
_Operation(
|
|
633
|
+
candidate,
|
|
634
|
+
"rmdir",
|
|
635
|
+
_vault_display(vault, candidate),
|
|
636
|
+
"remove empty generated directory",
|
|
637
|
+
trusted_root=vault.root,
|
|
638
|
+
),
|
|
639
|
+
)
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
def _scrub_snapshot(raw: dict[str, object]) -> tuple[dict[str, object], bool]:
|
|
643
|
+
courses = raw.get("courses")
|
|
644
|
+
if not isinstance(courses, list):
|
|
645
|
+
raise A2LError("snapshot courses must be a list")
|
|
646
|
+
payload = dict(raw)
|
|
647
|
+
new_courses: list[object] = []
|
|
648
|
+
changed = False
|
|
649
|
+
for course in courses:
|
|
650
|
+
if not isinstance(course, Mapping):
|
|
651
|
+
raise A2LError("snapshot courses must contain objects")
|
|
652
|
+
value = dict(course)
|
|
653
|
+
if "grades" in value:
|
|
654
|
+
value.pop("grades")
|
|
655
|
+
changed = True
|
|
656
|
+
new_courses.append(value)
|
|
657
|
+
if changed:
|
|
658
|
+
payload["courses"] = new_courses
|
|
659
|
+
return payload, changed
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def _scrub_inventory(raw: dict[str, object], category: Category) -> dict[str, object]:
|
|
663
|
+
payload = dict(raw)
|
|
664
|
+
categories = payload.get("categories")
|
|
665
|
+
if isinstance(categories, list):
|
|
666
|
+
payload["categories"] = [
|
|
667
|
+
value for value in categories if not (_owned_record(value, None, category))
|
|
668
|
+
]
|
|
669
|
+
elif isinstance(categories, dict):
|
|
670
|
+
payload["categories"] = {
|
|
671
|
+
key: value
|
|
672
|
+
for key, value in categories.items()
|
|
673
|
+
if not _owned_record(value, key, category)
|
|
674
|
+
}
|
|
675
|
+
return payload
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def _scrub_index(text: str, category: Category) -> str:
|
|
679
|
+
lines = text.splitlines(keepends=True)
|
|
680
|
+
result: list[str] = []
|
|
681
|
+
inside = False
|
|
682
|
+
start_marker = f"<!-- a2l:{category}:start -->"
|
|
683
|
+
end_marker = f"<!-- a2l:{category}:end -->"
|
|
684
|
+
for line in lines:
|
|
685
|
+
lowered = line.casefold()
|
|
686
|
+
if start_marker in lowered:
|
|
687
|
+
inside = True
|
|
688
|
+
continue
|
|
689
|
+
if inside:
|
|
690
|
+
if end_marker in lowered:
|
|
691
|
+
inside = False
|
|
692
|
+
continue
|
|
693
|
+
if category == "grades" and re.match(r"^\s*[-*]\s+grades?\s*:", line, re.IGNORECASE):
|
|
694
|
+
continue
|
|
695
|
+
if category == "discussions" and (
|
|
696
|
+
"discussions/" in lowered
|
|
697
|
+
or re.match(r"^\s*#{1,3}\s+discussions?\s*$", line, re.IGNORECASE)
|
|
698
|
+
):
|
|
699
|
+
continue
|
|
700
|
+
result.append(line)
|
|
701
|
+
return "".join(result)
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
def _owned_row(value: object, category: Category) -> bool:
|
|
705
|
+
if not isinstance(value, Mapping):
|
|
706
|
+
return False
|
|
707
|
+
return _owned_record(value, cast(str | None, value.get("source_key")), category)
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
def _owned_record(value: object, key: str | None, category: Category) -> bool:
|
|
711
|
+
if not isinstance(value, Mapping):
|
|
712
|
+
return False
|
|
713
|
+
markers: list[str] = []
|
|
714
|
+
for marker_key in ("category", "privacy_category", "kind", "source_key", "canonical_key"):
|
|
715
|
+
marker = value.get(marker_key)
|
|
716
|
+
if isinstance(marker, str):
|
|
717
|
+
markers.append(marker.casefold())
|
|
718
|
+
if key:
|
|
719
|
+
markers.append(key.casefold())
|
|
720
|
+
wanted = "grade" if category == "grades" else "discussion"
|
|
721
|
+
explicit_names = _GRADE_KEYS if category == "grades" else _DISCUSSION_KEYS
|
|
722
|
+
for marker in markers:
|
|
723
|
+
if marker in explicit_names or f":{wanted}:" in marker or f":{wanted}s:" in marker:
|
|
724
|
+
return True
|
|
725
|
+
path_values = _entry_paths(value)
|
|
726
|
+
if category == "grades":
|
|
727
|
+
return any(
|
|
728
|
+
PurePosixPath(path).name.casefold() in {"my_grades.json", "grades.json"}
|
|
729
|
+
for path in path_values
|
|
730
|
+
)
|
|
731
|
+
return any(_is_discussion_path(path) for path in path_values)
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
def _entry_paths(value: Mapping[str, object]) -> tuple[str, ...]:
|
|
735
|
+
result: list[str] = []
|
|
736
|
+
for key in ("path", "source_path", "stub_path"):
|
|
737
|
+
path = value.get(key)
|
|
738
|
+
if isinstance(path, str):
|
|
739
|
+
result.append(path)
|
|
740
|
+
derived = value.get("derived")
|
|
741
|
+
if isinstance(derived, Mapping):
|
|
742
|
+
for item in derived.values():
|
|
743
|
+
if isinstance(item, Mapping) and isinstance(item.get("path"), str):
|
|
744
|
+
result.append(cast(str, item["path"]))
|
|
745
|
+
return tuple(result)
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
def _is_discussion_path(value: str) -> bool:
|
|
749
|
+
try:
|
|
750
|
+
parts = PurePosixPath(value).parts
|
|
751
|
+
except (TypeError, ValueError):
|
|
752
|
+
return False
|
|
753
|
+
return "discussions" in {part.casefold() for part in parts}
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
def _relative_target(root: Path, value: str) -> Path:
|
|
757
|
+
if (
|
|
758
|
+
not isinstance(value, str)
|
|
759
|
+
or not value
|
|
760
|
+
or "\\" in value
|
|
761
|
+
or _WINDOWS_ABSOLUTE.match(value) is not None
|
|
762
|
+
):
|
|
763
|
+
raise A2LError("privacy target path must be a relative POSIX path")
|
|
764
|
+
candidate = PurePosixPath(value)
|
|
765
|
+
if candidate.is_absolute() or ".." in candidate.parts or "." in candidate.parts:
|
|
766
|
+
raise A2LError("privacy target path escapes the vault")
|
|
767
|
+
path = root.joinpath(*candidate.parts)
|
|
768
|
+
_safe_path(path, root)
|
|
769
|
+
return path
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
def _named_paths(root: Path, name: str) -> tuple[Path, ...]:
|
|
773
|
+
if not paths.long_path(root).is_dir():
|
|
774
|
+
return ()
|
|
775
|
+
try:
|
|
776
|
+
return tuple(
|
|
777
|
+
sorted(
|
|
778
|
+
(candidate for candidate in paths.walk(root) if candidate.name == name),
|
|
779
|
+
key=os.fspath,
|
|
780
|
+
)
|
|
781
|
+
)
|
|
782
|
+
except OSError as exc:
|
|
783
|
+
raise A2LError("privacy target inventory is unreadable") from exc
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
def _json_files(root: Path) -> tuple[Path, ...]:
|
|
787
|
+
try:
|
|
788
|
+
candidates = list(paths.walk(root))
|
|
789
|
+
for candidate in candidates:
|
|
790
|
+
if paths.is_link(candidate):
|
|
791
|
+
raise A2LError("privacy purge refuses a symlinked JSON target")
|
|
792
|
+
return tuple(
|
|
793
|
+
sorted(
|
|
794
|
+
(
|
|
795
|
+
candidate
|
|
796
|
+
for candidate in candidates
|
|
797
|
+
if candidate.suffix.casefold() == ".json"
|
|
798
|
+
and not paths.long_path(candidate).is_dir()
|
|
799
|
+
),
|
|
800
|
+
key=os.fspath,
|
|
801
|
+
)
|
|
802
|
+
)
|
|
803
|
+
except OSError as exc:
|
|
804
|
+
raise A2LError("privacy JSON inventory is unreadable") from exc
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
def _add_unlink(
|
|
808
|
+
vault: Vault,
|
|
809
|
+
operations: list[_Operation],
|
|
810
|
+
path: Path,
|
|
811
|
+
detail: str,
|
|
812
|
+
*,
|
|
813
|
+
trusted_root: Path | None = None,
|
|
814
|
+
) -> None:
|
|
815
|
+
if not paths.long_path(path).exists() and not paths.is_link(path):
|
|
816
|
+
return
|
|
817
|
+
root = vault.root if trusted_root is None else trusted_root
|
|
818
|
+
_safe_path(path, root)
|
|
819
|
+
if not paths.long_path(path).is_file():
|
|
820
|
+
raise A2LError("privacy purge target is not a regular file")
|
|
821
|
+
_add_operation(
|
|
822
|
+
operations,
|
|
823
|
+
_Operation(path, "unlink", _display_path(vault, path, root), detail, trusted_root=root),
|
|
824
|
+
)
|
|
825
|
+
|
|
826
|
+
|
|
827
|
+
def _add_rewrite(
|
|
828
|
+
vault: Vault,
|
|
829
|
+
operations: list[_Operation],
|
|
830
|
+
path: Path,
|
|
831
|
+
payload: object,
|
|
832
|
+
detail: str,
|
|
833
|
+
*,
|
|
834
|
+
trusted_root: Path | None = None,
|
|
835
|
+
) -> None:
|
|
836
|
+
root = vault.root if trusted_root is None else trusted_root
|
|
837
|
+
_safe_path(path, root)
|
|
838
|
+
if not paths.long_path(path).is_file():
|
|
839
|
+
raise A2LError("privacy JSON target is not a regular file")
|
|
840
|
+
_add_operation(
|
|
841
|
+
operations,
|
|
842
|
+
_Operation(
|
|
843
|
+
path,
|
|
844
|
+
"rewrite",
|
|
845
|
+
_display_path(vault, path, root),
|
|
846
|
+
detail,
|
|
847
|
+
payload,
|
|
848
|
+
root,
|
|
849
|
+
),
|
|
850
|
+
)
|
|
851
|
+
|
|
852
|
+
|
|
853
|
+
def _add_remove_tree(
|
|
854
|
+
vault: Vault,
|
|
855
|
+
operations: list[_Operation],
|
|
856
|
+
path: Path,
|
|
857
|
+
trusted_root: Path,
|
|
858
|
+
detail: str,
|
|
859
|
+
) -> None:
|
|
860
|
+
if not paths.long_path(path).is_dir():
|
|
861
|
+
raise A2LError("privacy managed revision is not a directory")
|
|
862
|
+
_validate_explicit_tree(path, trusted_root)
|
|
863
|
+
display = _display_path(vault, path, trusted_root)
|
|
864
|
+
_add_operation(
|
|
865
|
+
operations,
|
|
866
|
+
_Operation(path, "remove_tree", display, detail, trusted_root=trusted_root),
|
|
867
|
+
)
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
def _add_operation(operations: list[_Operation], operation: _Operation) -> None:
|
|
871
|
+
operations.append(operation)
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
def _deduplicate(operations: list[_Operation]) -> list[_Operation]:
|
|
875
|
+
result: list[_Operation] = []
|
|
876
|
+
seen: set[tuple[str, str]] = set()
|
|
877
|
+
for operation in operations:
|
|
878
|
+
identity = (os.fspath(operation.path), operation.action)
|
|
879
|
+
if identity in seen:
|
|
880
|
+
continue
|
|
881
|
+
seen.add(identity)
|
|
882
|
+
result.append(operation)
|
|
883
|
+
return sorted(result, key=lambda item: (item.action, os.fspath(item.path), item.detail))
|
|
884
|
+
|
|
885
|
+
|
|
886
|
+
def _make_plan(
|
|
887
|
+
vault: Vault,
|
|
888
|
+
category: Category,
|
|
889
|
+
operations: Iterable[_Operation],
|
|
890
|
+
*,
|
|
891
|
+
log_directory: Path | None,
|
|
892
|
+
) -> PurgePlan:
|
|
893
|
+
selected = tuple(operations)
|
|
894
|
+
targets: list[PurgeTarget] = []
|
|
895
|
+
digest = hashlib.sha256()
|
|
896
|
+
for operation in selected:
|
|
897
|
+
trusted_root = operation.trusted_root
|
|
898
|
+
if trusted_root is None:
|
|
899
|
+
trusted_root = (
|
|
900
|
+
Path(log_directory).expanduser()
|
|
901
|
+
if category == "logs" and log_directory is not None
|
|
902
|
+
else Path(config.DIRS.user_log_path)
|
|
903
|
+
if category == "logs"
|
|
904
|
+
else vault.root
|
|
905
|
+
)
|
|
906
|
+
_safe_path(operation.path, trusted_root)
|
|
907
|
+
state = _object_fingerprint(operation.path)
|
|
908
|
+
digest.update(os.fspath(operation.path).encode("utf-8"))
|
|
909
|
+
digest.update(b"\0")
|
|
910
|
+
digest.update(operation.action.encode("utf-8"))
|
|
911
|
+
digest.update(b"\0")
|
|
912
|
+
digest.update(operation.detail.encode("utf-8"))
|
|
913
|
+
digest.update(b"\0")
|
|
914
|
+
digest.update(state.encode("ascii"))
|
|
915
|
+
digest.update(b"\0")
|
|
916
|
+
targets.append(PurgeTarget(operation.path, operation.display, operation.detail))
|
|
917
|
+
resolved_log_directory = (
|
|
918
|
+
Path(log_directory).expanduser()
|
|
919
|
+
if log_directory is not None
|
|
920
|
+
else Path(config.DIRS.user_log_path)
|
|
921
|
+
if category == "logs"
|
|
922
|
+
else None
|
|
923
|
+
)
|
|
924
|
+
return PurgePlan(
|
|
925
|
+
category,
|
|
926
|
+
tuple(targets),
|
|
927
|
+
digest.hexdigest(),
|
|
928
|
+
resolved_log_directory,
|
|
929
|
+
selected,
|
|
930
|
+
)
|
|
931
|
+
|
|
932
|
+
|
|
933
|
+
def _apply(operations: Iterable[_Operation]) -> None:
|
|
934
|
+
selected = tuple(operations)
|
|
935
|
+
rewrites = [operation for operation in selected if operation.action == "rewrite"]
|
|
936
|
+
for operation in rewrites:
|
|
937
|
+
trusted_root = operation.trusted_root or operation.path.parent
|
|
938
|
+
_safe_path(operation.path, trusted_root)
|
|
939
|
+
if not paths.long_path(operation.path).is_file():
|
|
940
|
+
raise A2LError("privacy JSON target changed before rewrite")
|
|
941
|
+
if isinstance(operation.payload, str):
|
|
942
|
+
text = operation.payload
|
|
943
|
+
else:
|
|
944
|
+
text = _canonical_json(operation.payload)
|
|
945
|
+
paths.atomic_write_text(operation.path, text, root=trusted_root)
|
|
946
|
+
|
|
947
|
+
for operation in sorted(
|
|
948
|
+
(item for item in selected if item.action == "remove_tree"),
|
|
949
|
+
key=lambda item: (len(item.path.parts), os.fspath(item.path)),
|
|
950
|
+
reverse=True,
|
|
951
|
+
):
|
|
952
|
+
_remove_explicit_tree(operation.path, operation.trusted_root or operation.path.parent)
|
|
953
|
+
for operation in selected:
|
|
954
|
+
if operation.action == "unlink":
|
|
955
|
+
_unlink_exact(operation.path, operation.trusted_root or operation.path.parent)
|
|
956
|
+
for operation in sorted(
|
|
957
|
+
(item for item in selected if item.action == "rmdir"),
|
|
958
|
+
key=lambda item: (len(item.path.parts), os.fspath(item.path)),
|
|
959
|
+
reverse=True,
|
|
960
|
+
):
|
|
961
|
+
_remove_empty_directory(operation.path, operation.trusted_root or operation.path.parent)
|
|
962
|
+
|
|
963
|
+
|
|
964
|
+
def _unlink_exact(path: Path, trusted_root: Path) -> None:
|
|
965
|
+
_safe_path(path, trusted_root)
|
|
966
|
+
if not paths.long_path(path).is_file():
|
|
967
|
+
raise A2LError("privacy purge target changed before removal")
|
|
968
|
+
os.unlink(os.fspath(paths.long_path(path)))
|
|
969
|
+
|
|
970
|
+
|
|
971
|
+
def _remove_empty_directory(path: Path, trusted_root: Path) -> None:
|
|
972
|
+
_safe_path(path, trusted_root)
|
|
973
|
+
if not paths.long_path(path).is_dir():
|
|
974
|
+
raise A2LError("privacy purge directory target changed before removal")
|
|
975
|
+
try:
|
|
976
|
+
os.rmdir(os.fspath(paths.long_path(path)))
|
|
977
|
+
except OSError as exc:
|
|
978
|
+
raise A2LError("privacy purge left an unexpected file in a managed directory") from exc
|
|
979
|
+
|
|
980
|
+
|
|
981
|
+
def _validate_explicit_tree(path: Path, trusted_root: Path) -> None:
|
|
982
|
+
_safe_path(path, trusted_root)
|
|
983
|
+
for candidate in paths.walk(path):
|
|
984
|
+
_safe_path(candidate, trusted_root)
|
|
985
|
+
if paths.is_link(candidate):
|
|
986
|
+
raise A2LError("privacy purge refuses a symlinked managed revision")
|
|
987
|
+
if not paths.long_path(candidate).is_file() and not paths.long_path(candidate).is_dir():
|
|
988
|
+
raise A2LError("privacy purge found an unsupported managed revision entry")
|
|
989
|
+
|
|
990
|
+
|
|
991
|
+
def _remove_explicit_tree(path: Path, trusted_root: Path) -> None:
|
|
992
|
+
_validate_explicit_tree(path, trusted_root)
|
|
993
|
+
candidates = list(paths.walk(path))
|
|
994
|
+
for candidate in sorted(
|
|
995
|
+
(item for item in candidates if paths.long_path(item).is_file()),
|
|
996
|
+
key=lambda item: len(item.relative_to(path).parts),
|
|
997
|
+
reverse=True,
|
|
998
|
+
):
|
|
999
|
+
_unlink_exact(candidate, trusted_root)
|
|
1000
|
+
for candidate in sorted(
|
|
1001
|
+
(item for item in candidates if paths.long_path(item).is_dir()),
|
|
1002
|
+
key=lambda item: len(item.relative_to(path).parts),
|
|
1003
|
+
reverse=True,
|
|
1004
|
+
):
|
|
1005
|
+
_remove_empty_directory(candidate, trusted_root)
|
|
1006
|
+
_remove_empty_directory(path, trusted_root)
|
|
1007
|
+
|
|
1008
|
+
|
|
1009
|
+
def _safe_path(path: Path, root: Path) -> None:
|
|
1010
|
+
if paths.is_link(path) or paths.has_link_component(path, root=root):
|
|
1011
|
+
raise A2LError("privacy purge refuses a symlinked target")
|
|
1012
|
+
absolute = Path(os.path.abspath(os.fspath(path)))
|
|
1013
|
+
trusted = Path(os.path.abspath(os.fspath(root)))
|
|
1014
|
+
try:
|
|
1015
|
+
absolute.relative_to(trusted)
|
|
1016
|
+
except ValueError as exc:
|
|
1017
|
+
raise A2LError("privacy target path escapes its trusted root") from exc
|
|
1018
|
+
|
|
1019
|
+
|
|
1020
|
+
def _object_fingerprint(path: Path) -> str:
|
|
1021
|
+
digest = hashlib.sha256()
|
|
1022
|
+
if paths.is_link(path):
|
|
1023
|
+
raise A2LError("privacy purge refuses a symlinked target")
|
|
1024
|
+
if paths.long_path(path).is_file():
|
|
1025
|
+
digest.update(b"file\0")
|
|
1026
|
+
with open(os.fspath(paths.long_path(path)), "rb") as handle:
|
|
1027
|
+
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
1028
|
+
digest.update(chunk)
|
|
1029
|
+
return digest.hexdigest()
|
|
1030
|
+
if paths.long_path(path).is_dir():
|
|
1031
|
+
digest.update(b"directory\0")
|
|
1032
|
+
for candidate in sorted(paths.walk(path), key=os.fspath):
|
|
1033
|
+
if paths.is_link(candidate):
|
|
1034
|
+
raise A2LError("privacy purge refuses a symlinked target")
|
|
1035
|
+
relative = candidate.relative_to(path).as_posix()
|
|
1036
|
+
digest.update(relative.encode("utf-8"))
|
|
1037
|
+
digest.update(b"\0")
|
|
1038
|
+
if paths.long_path(candidate).is_file():
|
|
1039
|
+
with open(os.fspath(paths.long_path(candidate)), "rb") as handle:
|
|
1040
|
+
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
1041
|
+
digest.update(chunk)
|
|
1042
|
+
digest.update(b"\0")
|
|
1043
|
+
return digest.hexdigest()
|
|
1044
|
+
raise A2LError("privacy purge target changed before preview")
|
|
1045
|
+
|
|
1046
|
+
|
|
1047
|
+
def _reject_links_in_managed_directory(directory: Path, label: str) -> None:
|
|
1048
|
+
try:
|
|
1049
|
+
for candidate in paths.walk(directory):
|
|
1050
|
+
if paths.is_link(candidate):
|
|
1051
|
+
raise A2LError(f"privacy purge refuses a symlinked managed {label} entry")
|
|
1052
|
+
except OSError as exc:
|
|
1053
|
+
raise A2LError(f"privacy {label} inventory is unreadable") from exc
|
|
1054
|
+
|
|
1055
|
+
|
|
1056
|
+
def _read_json(path: Path, label: str) -> dict[str, object]:
|
|
1057
|
+
try:
|
|
1058
|
+
with open(os.fspath(paths.long_path(path)), encoding="utf-8", newline="") as handle:
|
|
1059
|
+
raw: Any = json.load(handle)
|
|
1060
|
+
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
|
1061
|
+
raise A2LError(f"{label} is unreadable") from exc
|
|
1062
|
+
if not isinstance(raw, dict):
|
|
1063
|
+
raise A2LError(f"{label} must contain an object")
|
|
1064
|
+
return cast(dict[str, object], raw)
|
|
1065
|
+
|
|
1066
|
+
|
|
1067
|
+
def _read_text(path: Path, label: str) -> str:
|
|
1068
|
+
try:
|
|
1069
|
+
with open(os.fspath(paths.long_path(path)), encoding="utf-8", newline="") as handle:
|
|
1070
|
+
return handle.read()
|
|
1071
|
+
except (OSError, UnicodeError) as exc:
|
|
1072
|
+
raise A2LError(f"{label} is unreadable") from exc
|
|
1073
|
+
|
|
1074
|
+
|
|
1075
|
+
def _directory_children(directory: Path, label: str) -> list[Path]:
|
|
1076
|
+
try:
|
|
1077
|
+
with os.scandir(os.fspath(paths.long_path(directory))) as iterator:
|
|
1078
|
+
return sorted(
|
|
1079
|
+
(directory / entry.name for entry in iterator), key=lambda item: item.name
|
|
1080
|
+
)
|
|
1081
|
+
except OSError as exc:
|
|
1082
|
+
raise A2LError(f"{label} is unreadable") from exc
|
|
1083
|
+
|
|
1084
|
+
|
|
1085
|
+
def _canonical_json(value: object) -> str:
|
|
1086
|
+
return (
|
|
1087
|
+
json.dumps(value, ensure_ascii=False, sort_keys=True, indent=2, separators=(",", ": "))
|
|
1088
|
+
+ "\n"
|
|
1089
|
+
)
|
|
1090
|
+
|
|
1091
|
+
|
|
1092
|
+
def _vault_display(vault: Vault, path: Path) -> str:
|
|
1093
|
+
try:
|
|
1094
|
+
relative = path.relative_to(vault.root).as_posix()
|
|
1095
|
+
except ValueError:
|
|
1096
|
+
return "<schema backup>/managed state"
|
|
1097
|
+
return f"<vault>/{relative}"
|
|
1098
|
+
|
|
1099
|
+
|
|
1100
|
+
def _display_path(vault: Vault, path: Path, trusted_root: Path) -> str:
|
|
1101
|
+
if trusted_root == vault.root:
|
|
1102
|
+
return _vault_display(vault, path)
|
|
1103
|
+
if trusted_root == Path(config.DIRS.user_log_path):
|
|
1104
|
+
return f"<configured log directory>/{path.name}"
|
|
1105
|
+
try:
|
|
1106
|
+
relative = path.relative_to(trusted_root).as_posix()
|
|
1107
|
+
except ValueError:
|
|
1108
|
+
relative = path.name
|
|
1109
|
+
return f"<schema backup>/{relative}"
|
|
1110
|
+
|
|
1111
|
+
|
|
1112
|
+
__all__ = [
|
|
1113
|
+
"PrivacyStatus",
|
|
1114
|
+
"PurgePlan",
|
|
1115
|
+
"PurgeResult",
|
|
1116
|
+
"PurgeTarget",
|
|
1117
|
+
"execute_purge",
|
|
1118
|
+
"plan_purge",
|
|
1119
|
+
"purge",
|
|
1120
|
+
"render_plan",
|
|
1121
|
+
"render_status",
|
|
1122
|
+
"status",
|
|
1123
|
+
]
|