projectaria-debug-sharing 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- projectaria_debug_sharing/__init__.py +22 -0
- projectaria_debug_sharing/debug_sharing/__init__.py +15 -0
- projectaria_debug_sharing/debug_sharing/_internal/__init__.py +13 -0
- projectaria_debug_sharing/debug_sharing/_internal/delivery.py +252 -0
- projectaria_debug_sharing/debug_sharing/_internal/file_access.py +130 -0
- projectaria_debug_sharing/debug_sharing/_internal/manifest.py +133 -0
- projectaria_debug_sharing/debug_sharing/_internal/plain_files.py +208 -0
- projectaria_debug_sharing/debug_sharing/_internal/report_command.py +560 -0
- projectaria_debug_sharing/debug_sharing/_internal/spool.py +673 -0
- projectaria_debug_sharing/debug_sharing/cli.py +183 -0
- projectaria_debug_sharing/debug_sharing/collectors.py +472 -0
- projectaria_debug_sharing/debug_sharing/command.py +36 -0
- projectaria_debug_sharing/debug_sharing/facade.py +435 -0
- projectaria_debug_sharing/debug_sharing/flytrap.py +327 -0
- projectaria_debug_sharing/debug_sharing/submitter.py +103 -0
- projectaria_debug_sharing/debug_sharing/types.py +172 -0
- projectaria_debug_sharing-0.1.0.dist-info/METADATA +30 -0
- projectaria_debug_sharing-0.1.0.dist-info/RECORD +22 -0
- projectaria_debug_sharing-0.1.0.dist-info/WHEEL +5 -0
- projectaria_debug_sharing-0.1.0.dist-info/entry_points.txt +2 -0
- projectaria_debug_sharing-0.1.0.dist-info/licenses/LICENSE +201 -0
- projectaria_debug_sharing-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,673 @@
|
|
|
1
|
+
# Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
"""
|
|
16
|
+
Reports kept on disk between assembling one and sending it. Package-internal:
|
|
17
|
+
nothing outside this package imports it.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import logging
|
|
23
|
+
import shutil
|
|
24
|
+
import time
|
|
25
|
+
import uuid
|
|
26
|
+
from collections.abc import Collection, Iterable
|
|
27
|
+
from dataclasses import astuple, dataclass
|
|
28
|
+
from pathlib import Path, PurePosixPath
|
|
29
|
+
from typing import Final
|
|
30
|
+
|
|
31
|
+
from projectaria_commons.commons.constants import CONFIG_DIR
|
|
32
|
+
from projectaria_debug_sharing.debug_sharing._internal.file_access import (
|
|
33
|
+
is_private_dir,
|
|
34
|
+
is_private_file,
|
|
35
|
+
leaf_name,
|
|
36
|
+
make_private_dir,
|
|
37
|
+
PRIVATE_DIR_MODE,
|
|
38
|
+
refuse_symlink,
|
|
39
|
+
write_private,
|
|
40
|
+
)
|
|
41
|
+
from projectaria_debug_sharing.debug_sharing._internal.manifest import (
|
|
42
|
+
Manifest,
|
|
43
|
+
MANIFEST_NAME,
|
|
44
|
+
)
|
|
45
|
+
from projectaria_debug_sharing.debug_sharing.types import (
|
|
46
|
+
Attachment,
|
|
47
|
+
DebugReport,
|
|
48
|
+
SpooledReportError,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
logger: logging.Logger = logging.getLogger(__name__)
|
|
52
|
+
|
|
53
|
+
DEFAULT_SPOOL_DIR: Final[Path] = CONFIG_DIR / "debug-reports"
|
|
54
|
+
|
|
55
|
+
FAILED_MARKER: Final[str] = ".failed"
|
|
56
|
+
|
|
57
|
+
_RESERVED_NAMES: Final[frozenset[str]] = frozenset({MANIFEST_NAME, FAILED_MARKER})
|
|
58
|
+
|
|
59
|
+
_STAGING_SUFFIX: Final[str] = ".tmp"
|
|
60
|
+
_PREVIOUS_SUFFIX: Final[str] = ".old"
|
|
61
|
+
_CLAIM_SUFFIX: Final[str] = ".sending"
|
|
62
|
+
_FILED_SUFFIX: Final[str] = ".filed"
|
|
63
|
+
|
|
64
|
+
_STAGING_MAX_AGE_SEC: Final[float] = 3600.0
|
|
65
|
+
"""A save takes well under a second; a staging directory older than this was
|
|
66
|
+
left by one that crashed."""
|
|
67
|
+
|
|
68
|
+
_CLAIM_MAX_AGE_SEC: Final[float] = 3600.0
|
|
69
|
+
"""A send takes seconds; a claim older than this was left by a drain that died.
|
|
70
|
+
Nothing renews a live claim, so a send must end well within this. The FlyTrap
|
|
71
|
+
submitter's `TOTAL_TIMEOUT_SEC` bounds its upload, but not the login refresh
|
|
72
|
+
before it."""
|
|
73
|
+
|
|
74
|
+
_DAY_SEC: Final[float] = 86400.0
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True)
|
|
78
|
+
class Retention:
|
|
79
|
+
"""How long a report stays on disk, counted from when it was saved."""
|
|
80
|
+
|
|
81
|
+
refused_duration_sec: float = 14 * _DAY_SEC
|
|
82
|
+
|
|
83
|
+
speculative_duration_sec: float = 3 * _DAY_SEC
|
|
84
|
+
"""For a report collected on the user's behalf rather than at their request."""
|
|
85
|
+
|
|
86
|
+
max_duration_sec: float = 30 * _DAY_SEC
|
|
87
|
+
"""Nothing outlives this, sent or not."""
|
|
88
|
+
|
|
89
|
+
def __post_init__(self) -> None:
|
|
90
|
+
if min(astuple(self)) < 0:
|
|
91
|
+
raise ValueError(f"retention durations must be non-negative, got {self}")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class ReportSpool:
|
|
95
|
+
"""
|
|
96
|
+
Reports kept on disk, readable by their owner only, until they are sent.
|
|
97
|
+
|
|
98
|
+
Each report is a directory named after its id, holding its attachments and
|
|
99
|
+
a manifest. A drain claims a report by renaming it, so two drains never
|
|
100
|
+
send the same one, and releases it if the send fails.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
def __init__(
|
|
104
|
+
self, root: Path | None = None, retention: Retention | None = None
|
|
105
|
+
) -> None:
|
|
106
|
+
"""
|
|
107
|
+
Args:
|
|
108
|
+
root: Where reports are kept, `DEFAULT_SPOOL_DIR` unless given.
|
|
109
|
+
Created owner-only on the first save. Never used if it is a
|
|
110
|
+
symlink.
|
|
111
|
+
retention: How long reports are kept. Defaults to `Retention()`.
|
|
112
|
+
"""
|
|
113
|
+
self._root: Path = root or DEFAULT_SPOOL_DIR
|
|
114
|
+
self._retention: Retention = retention or Retention()
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def root(self) -> Path:
|
|
118
|
+
"""The directory reports are kept in."""
|
|
119
|
+
return self._root
|
|
120
|
+
|
|
121
|
+
def save(self, report: DebugReport, *, requested: bool = True) -> Path:
|
|
122
|
+
"""
|
|
123
|
+
Saving an id that is already spooled replaces it, markers included: a
|
|
124
|
+
fresh save is a fresh attempt, and a stale `.failed` would stop it ever
|
|
125
|
+
being sent.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
report: The report to keep.
|
|
129
|
+
requested: False for a report collected on the user's behalf rather
|
|
130
|
+
than at their request. It is kept for less time, and unattended
|
|
131
|
+
drains skip it.
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
The report's directory.
|
|
135
|
+
|
|
136
|
+
Raises:
|
|
137
|
+
ValueError: The report id is not usable as a directory name.
|
|
138
|
+
OSError: The report could not be written, or the spool is a
|
|
139
|
+
symlink. A previous version of it is left in place.
|
|
140
|
+
"""
|
|
141
|
+
report_dir = self._report_dir_for(report.report_id)
|
|
142
|
+
make_private_dir(self._root)
|
|
143
|
+
tag = uuid.uuid4().hex[:8]
|
|
144
|
+
staging = self._root / f".{report.report_id}.{tag}{_STAGING_SUFFIX}"
|
|
145
|
+
previous = self._root / f".{report.report_id}.{tag}{_PREVIOUS_SUFFIX}"
|
|
146
|
+
staging.mkdir(mode=PRIVATE_DIR_MODE)
|
|
147
|
+
try:
|
|
148
|
+
self._write_report(staging, report, requested)
|
|
149
|
+
self._swap_in(staging, report_dir, previous)
|
|
150
|
+
except BaseException:
|
|
151
|
+
shutil.rmtree(staging, ignore_errors=True)
|
|
152
|
+
raise
|
|
153
|
+
self._drop_previous(previous)
|
|
154
|
+
return report_dir
|
|
155
|
+
|
|
156
|
+
def load(self, report_dir: Path) -> DebugReport:
|
|
157
|
+
"""
|
|
158
|
+
Reads a spooled report back. An attachment that is missing, unreadable
|
|
159
|
+
or changeable by another user is left out rather than failing the
|
|
160
|
+
report, which would lose all of it.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
report_dir: The report's directory, as `pending`, `find` or `claim`
|
|
164
|
+
returned it.
|
|
165
|
+
|
|
166
|
+
Returns:
|
|
167
|
+
The report, under the id it was saved with.
|
|
168
|
+
|
|
169
|
+
Raises:
|
|
170
|
+
SpooledReportError: The report could have been changed by another
|
|
171
|
+
user, or is unreadable, or the spool is a symlink. A drain marks
|
|
172
|
+
it refused.
|
|
173
|
+
"""
|
|
174
|
+
if self._root.is_symlink() or not is_private_dir(report_dir):
|
|
175
|
+
raise SpooledReportError(
|
|
176
|
+
f"{report_dir} is not a report directory that only you can change."
|
|
177
|
+
)
|
|
178
|
+
return self._load_report(report_dir, Manifest.read(report_dir))
|
|
179
|
+
|
|
180
|
+
def find(self, report_id: str) -> Path | None:
|
|
181
|
+
"""
|
|
182
|
+
Args:
|
|
183
|
+
report_id: The id the report was saved under.
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
The directory the report is kept in, refused or not; None when no
|
|
187
|
+
report with that id is kept, the id could never be saved, or the
|
|
188
|
+
spool is a symlink.
|
|
189
|
+
"""
|
|
190
|
+
try:
|
|
191
|
+
report_dir = self._report_dir_for(report_id)
|
|
192
|
+
except ValueError:
|
|
193
|
+
return None
|
|
194
|
+
if self._root.is_symlink():
|
|
195
|
+
return None
|
|
196
|
+
return report_dir if is_private_dir(report_dir) else None
|
|
197
|
+
|
|
198
|
+
def pending(self, *, requested_only: bool = False) -> list[Path]:
|
|
199
|
+
"""
|
|
200
|
+
Args:
|
|
201
|
+
requested_only: Also leave out reports collected on the user's
|
|
202
|
+
behalf. For an unattended drain, which may send what the user
|
|
203
|
+
asked for but nothing else.
|
|
204
|
+
|
|
205
|
+
Returns:
|
|
206
|
+
The directories of reports waiting to be sent, oldest save first.
|
|
207
|
+
Refused reports are left out. Empty when the spool does not exist.
|
|
208
|
+
|
|
209
|
+
Raises:
|
|
210
|
+
OSError: The spool cannot be listed, or is a symlink. Its reports
|
|
211
|
+
may still be waiting, so this is not reported as none.
|
|
212
|
+
"""
|
|
213
|
+
unsent = [
|
|
214
|
+
report_dir
|
|
215
|
+
for report_dir in self._visible_dirs(self._children())
|
|
216
|
+
if is_private_dir(report_dir)
|
|
217
|
+
and not (report_dir / FAILED_MARKER).exists()
|
|
218
|
+
and (not requested_only or self.was_requested(report_dir))
|
|
219
|
+
]
|
|
220
|
+
return sorted(unsent, key=self._saved_time_sec_for_sorting)
|
|
221
|
+
|
|
222
|
+
def was_requested(self, report_dir: Path) -> bool:
|
|
223
|
+
"""
|
|
224
|
+
Args:
|
|
225
|
+
report_dir: The report's directory.
|
|
226
|
+
|
|
227
|
+
Returns:
|
|
228
|
+
Whether the user asked for this report to be sent. False for an
|
|
229
|
+
unreadable one: it can never be sent, and an unattended drain would
|
|
230
|
+
otherwise retry it forever.
|
|
231
|
+
"""
|
|
232
|
+
try:
|
|
233
|
+
return Manifest.read(report_dir).requested
|
|
234
|
+
except SpooledReportError:
|
|
235
|
+
return False
|
|
236
|
+
|
|
237
|
+
def claim(self, report_dir: Path) -> Path | None:
|
|
238
|
+
"""
|
|
239
|
+
Takes a report out of `pending` for sending.
|
|
240
|
+
|
|
241
|
+
Args:
|
|
242
|
+
report_dir: The report's directory, from `pending` or `find`.
|
|
243
|
+
|
|
244
|
+
Returns:
|
|
245
|
+
The claimed directory, or None if another drain claimed it first.
|
|
246
|
+
|
|
247
|
+
Raises:
|
|
248
|
+
OSError: The report is still there but could not be claimed, or the
|
|
249
|
+
spool is a symlink.
|
|
250
|
+
"""
|
|
251
|
+
refuse_symlink(self._root)
|
|
252
|
+
claimed = report_dir.with_name(
|
|
253
|
+
f".{report_dir.name}.{int(time.time())}{_CLAIM_SUFFIX}"
|
|
254
|
+
)
|
|
255
|
+
try:
|
|
256
|
+
return report_dir.rename(claimed)
|
|
257
|
+
except FileNotFoundError:
|
|
258
|
+
return None
|
|
259
|
+
|
|
260
|
+
def release(self, claimed: Path, report_dir: Path) -> None:
|
|
261
|
+
"""
|
|
262
|
+
Puts back a claimed report that was not filed. If its id was saved
|
|
263
|
+
again while it was claimed, the fresh save wins and the claimed copy is
|
|
264
|
+
deleted, just as saving an id always replaces it. A failure is logged,
|
|
265
|
+
and `prune` puts the claim back once it is old enough.
|
|
266
|
+
|
|
267
|
+
Args:
|
|
268
|
+
claimed: The directory `claim` returned.
|
|
269
|
+
report_dir: The directory the report was claimed from.
|
|
270
|
+
"""
|
|
271
|
+
try:
|
|
272
|
+
if report_dir.exists():
|
|
273
|
+
shutil.rmtree(claimed)
|
|
274
|
+
else:
|
|
275
|
+
claimed.rename(report_dir)
|
|
276
|
+
except OSError as e:
|
|
277
|
+
logger.warning("Could not release %s: %s", report_dir.name, e.strerror or e)
|
|
278
|
+
|
|
279
|
+
def discard(self, report_dir: Path) -> bool:
|
|
280
|
+
"""
|
|
281
|
+
Deletes a filed report. One that cannot be deleted is renamed out of
|
|
282
|
+
`pending` instead, for `prune` to remove later.
|
|
283
|
+
|
|
284
|
+
Args:
|
|
285
|
+
report_dir: The report's directory, claimed or not.
|
|
286
|
+
|
|
287
|
+
Returns:
|
|
288
|
+
False, logged, if the report is still where a drain would send it
|
|
289
|
+
again, or the spool is a symlink and nothing was touched.
|
|
290
|
+
"""
|
|
291
|
+
if self._root.is_symlink():
|
|
292
|
+
logger.warning("Not discarding %s: the spool is a symlink", report_dir.name)
|
|
293
|
+
return False
|
|
294
|
+
return self._remove_tree(report_dir) or self._retire(report_dir)
|
|
295
|
+
|
|
296
|
+
def mark_failed(self, report_dir: Path, reason: str) -> bool:
|
|
297
|
+
"""
|
|
298
|
+
Records that a report was refused, so drains stop sending it.
|
|
299
|
+
|
|
300
|
+
Args:
|
|
301
|
+
report_dir: The report's directory, claimed or not.
|
|
302
|
+
reason: Why it was refused, kept in the marker.
|
|
303
|
+
|
|
304
|
+
Returns:
|
|
305
|
+
False, logged, if the marker could not be written, or the spool is a
|
|
306
|
+
symlink.
|
|
307
|
+
"""
|
|
308
|
+
if self._root.is_symlink():
|
|
309
|
+
logger.warning(
|
|
310
|
+
"Not marking %s refused: the spool is a symlink", report_dir.name
|
|
311
|
+
)
|
|
312
|
+
return False
|
|
313
|
+
try:
|
|
314
|
+
write_private(report_dir / FAILED_MARKER, f"{reason}\n".encode("utf-8"))
|
|
315
|
+
except OSError as e:
|
|
316
|
+
logger.warning(
|
|
317
|
+
"Could not mark %s refused: %s", report_dir.name, e.strerror or e
|
|
318
|
+
)
|
|
319
|
+
return False
|
|
320
|
+
return True
|
|
321
|
+
|
|
322
|
+
def secure(self) -> None:
|
|
323
|
+
"""
|
|
324
|
+
Makes the spool and each report directory in it owner-only, in case
|
|
325
|
+
something created them readable by others. Directory modes are the
|
|
326
|
+
protection: a file inside an owner-only directory is out of other
|
|
327
|
+
users' reach whatever its own mode, so files are left as they are.
|
|
328
|
+
Failures are logged rather than raised.
|
|
329
|
+
|
|
330
|
+
A report directory others could write to is left as it is, so it is
|
|
331
|
+
never trusted and `prune` removes it: tightening it would vouch for
|
|
332
|
+
contents that may already have been changed. A spool that is a symlink
|
|
333
|
+
is left alone: changing it would change wherever it points.
|
|
334
|
+
"""
|
|
335
|
+
if self._root.is_symlink():
|
|
336
|
+
logger.warning("Not securing %s: it is a symlink", self._root)
|
|
337
|
+
return
|
|
338
|
+
if not self._root.is_dir():
|
|
339
|
+
return
|
|
340
|
+
visible = self._visible_dirs(self._children_or_empty())
|
|
341
|
+
for directory in visible:
|
|
342
|
+
if not is_private_dir(directory):
|
|
343
|
+
logger.warning(
|
|
344
|
+
"Not sending %s: other users could have changed it",
|
|
345
|
+
directory.name,
|
|
346
|
+
)
|
|
347
|
+
trusted = [d for d in visible if is_private_dir(d)]
|
|
348
|
+
for directory in [self._root, *trusted]:
|
|
349
|
+
try:
|
|
350
|
+
directory.chmod(PRIVATE_DIR_MODE)
|
|
351
|
+
except OSError as e:
|
|
352
|
+
logger.warning("Could not restrict %s: %s", directory, e.strerror or e)
|
|
353
|
+
|
|
354
|
+
def prune(self) -> int:
|
|
355
|
+
"""
|
|
356
|
+
Deletes reports past their `Retention` window, and tidies up after saves
|
|
357
|
+
and sends that crashed. Failures are logged rather than raised.
|
|
358
|
+
|
|
359
|
+
A report the user asked to send is kept while it might still be sent,
|
|
360
|
+
up to `max_duration_sec`. One collected on their behalf goes after
|
|
361
|
+
`speculative_duration_sec`, and a refused one after
|
|
362
|
+
`refused_duration_sec`. Ages count from the save, so being refused late
|
|
363
|
+
does not extend a report's life.
|
|
364
|
+
|
|
365
|
+
Returns:
|
|
366
|
+
How many directories were removed.
|
|
367
|
+
"""
|
|
368
|
+
children = self._children_or_empty()
|
|
369
|
+
if not children:
|
|
370
|
+
return 0
|
|
371
|
+
now_time_sec = time.time()
|
|
372
|
+
removed = self._recover(self._hidden_dirs(children), now_time_sec)
|
|
373
|
+
for report_dir in self._visible_dirs(self._children_or_empty()):
|
|
374
|
+
if self._is_expired(report_dir, now_time_sec):
|
|
375
|
+
removed += self._remove_expired(report_dir)
|
|
376
|
+
if removed:
|
|
377
|
+
logger.info("Removed %d expired or leftover directories", removed)
|
|
378
|
+
return removed
|
|
379
|
+
|
|
380
|
+
def _report_dir_for(self, report_id: str) -> Path:
|
|
381
|
+
"""
|
|
382
|
+
Raises:
|
|
383
|
+
ValueError: `report_id` is not a plain name, so as a directory it
|
|
384
|
+
could escape the spool, or it starts with "." like the spool's
|
|
385
|
+
own staging and claimed directories.
|
|
386
|
+
"""
|
|
387
|
+
if (
|
|
388
|
+
not report_id
|
|
389
|
+
or report_id.startswith(".")
|
|
390
|
+
or leaf_name(report_id) != report_id
|
|
391
|
+
):
|
|
392
|
+
raise ValueError(
|
|
393
|
+
f"report_id {report_id!r} is not usable as a directory name"
|
|
394
|
+
)
|
|
395
|
+
return self._root / report_id
|
|
396
|
+
|
|
397
|
+
def _children(self) -> list[Path]:
|
|
398
|
+
"""
|
|
399
|
+
Empty when the spool is missing, which a running drain can meet if the
|
|
400
|
+
spool is removed under it.
|
|
401
|
+
|
|
402
|
+
Raises:
|
|
403
|
+
OSError: The spool cannot be listed, or is a symlink, which could
|
|
404
|
+
point anywhere.
|
|
405
|
+
"""
|
|
406
|
+
refuse_symlink(self._root)
|
|
407
|
+
try:
|
|
408
|
+
return list(self._root.iterdir())
|
|
409
|
+
except FileNotFoundError:
|
|
410
|
+
return []
|
|
411
|
+
|
|
412
|
+
def _children_or_empty(self) -> list[Path]:
|
|
413
|
+
"""`_children`, or empty, logged: tidying up can wait for the next drain."""
|
|
414
|
+
try:
|
|
415
|
+
return self._children()
|
|
416
|
+
except OSError as e:
|
|
417
|
+
logger.warning("Could not list the spool: %s", e.strerror or e)
|
|
418
|
+
return []
|
|
419
|
+
|
|
420
|
+
@staticmethod
|
|
421
|
+
def _visible_dirs(children: Iterable[Path]) -> list[Path]:
|
|
422
|
+
"""Directories that are not the spool's hidden staging, claims or retirees."""
|
|
423
|
+
return [
|
|
424
|
+
d
|
|
425
|
+
for d in children
|
|
426
|
+
if not d.name.startswith(".") and d.is_dir() and not d.is_symlink()
|
|
427
|
+
]
|
|
428
|
+
|
|
429
|
+
@staticmethod
|
|
430
|
+
def _hidden_dirs(children: Iterable[Path]) -> list[Path]:
|
|
431
|
+
return [d for d in children if d.name.startswith(".") and is_private_dir(d)]
|
|
432
|
+
|
|
433
|
+
def _is_expired(self, report_dir: Path, now_time_sec: float) -> bool:
|
|
434
|
+
"""False when the report's age cannot be read: unknown is not old."""
|
|
435
|
+
saved_time_sec = self._saved_time_sec(report_dir)
|
|
436
|
+
if saved_time_sec is None:
|
|
437
|
+
return False
|
|
438
|
+
return now_time_sec - saved_time_sec >= self._window_duration_sec(report_dir)
|
|
439
|
+
|
|
440
|
+
def _window_duration_sec(self, report_dir: Path) -> float:
|
|
441
|
+
retention = self._retention
|
|
442
|
+
if (report_dir / FAILED_MARKER).exists():
|
|
443
|
+
return min(retention.refused_duration_sec, retention.max_duration_sec)
|
|
444
|
+
if not self.was_requested(report_dir):
|
|
445
|
+
return min(retention.speculative_duration_sec, retention.max_duration_sec)
|
|
446
|
+
return retention.max_duration_sec
|
|
447
|
+
|
|
448
|
+
def _remove_expired(self, report_dir: Path) -> int:
|
|
449
|
+
"""
|
|
450
|
+
Logs the removal of a report the user asked to send, since that
|
|
451
|
+
discards their evidence unsent.
|
|
452
|
+
"""
|
|
453
|
+
if not (report_dir / FAILED_MARKER).exists() and self.was_requested(report_dir):
|
|
454
|
+
logger.warning(
|
|
455
|
+
"Discarding %s: never delivered, and older than %g days",
|
|
456
|
+
report_dir.name,
|
|
457
|
+
self._retention.max_duration_sec / _DAY_SEC,
|
|
458
|
+
)
|
|
459
|
+
return 1 if self._remove_tree(report_dir) else 0
|
|
460
|
+
|
|
461
|
+
def _recover(self, hidden_dirs: Iterable[Path], now_time_sec: float) -> int:
|
|
462
|
+
"""
|
|
463
|
+
Tidies up after saves and sends that crashed, returning how many
|
|
464
|
+
directories went. A version a save moved aside, or a claim a dead drain
|
|
465
|
+
abandoned, is put back unless a newer save replaced it, in which case it
|
|
466
|
+
goes. So does a staging directory older than any save takes, and a filed
|
|
467
|
+
report `discard` could only rename.
|
|
468
|
+
"""
|
|
469
|
+
removed = 0
|
|
470
|
+
for hidden in hidden_dirs:
|
|
471
|
+
if self._is_set_aside(hidden, now_time_sec):
|
|
472
|
+
target = self._root / self._report_id_of(hidden)
|
|
473
|
+
if not target.exists():
|
|
474
|
+
self._restore(hidden, target)
|
|
475
|
+
continue
|
|
476
|
+
elif not self._is_leftover(hidden, now_time_sec):
|
|
477
|
+
continue
|
|
478
|
+
removed += 1 if self._remove_tree(hidden) else 0
|
|
479
|
+
return removed
|
|
480
|
+
|
|
481
|
+
@classmethod
|
|
482
|
+
def _write_report(
|
|
483
|
+
cls, report_dir: Path, report: DebugReport, requested: bool
|
|
484
|
+
) -> None:
|
|
485
|
+
stored: dict[str, str] = {}
|
|
486
|
+
content_types: dict[str, str] = {}
|
|
487
|
+
for attachment in report.attachments:
|
|
488
|
+
file_name = cls._stored_file_name(attachment, taken=stored.keys())
|
|
489
|
+
if file_name:
|
|
490
|
+
write_private(report_dir / file_name, attachment.data)
|
|
491
|
+
stored[file_name] = attachment.name
|
|
492
|
+
content_types[file_name] = attachment.content_type
|
|
493
|
+
Manifest(
|
|
494
|
+
report_id=report.report_id,
|
|
495
|
+
description=report.description,
|
|
496
|
+
source_app=report.source_app,
|
|
497
|
+
created_time_sec=time.time(),
|
|
498
|
+
requested=requested,
|
|
499
|
+
metadata=dict(report.metadata),
|
|
500
|
+
attachments=stored,
|
|
501
|
+
content_types=content_types,
|
|
502
|
+
).write(report_dir)
|
|
503
|
+
|
|
504
|
+
@staticmethod
|
|
505
|
+
def _stored_file_name(attachment: Attachment, *, taken: Collection[str]) -> str:
|
|
506
|
+
"""
|
|
507
|
+
The file an attachment is stored as: its file name, made unique and kept
|
|
508
|
+
off the spool's own files, or "" if it has no usable name.
|
|
509
|
+
"""
|
|
510
|
+
name = leaf_name(attachment.filename or attachment.name)
|
|
511
|
+
if not name:
|
|
512
|
+
logger.warning(
|
|
513
|
+
"Dropping attachment %r: no usable file name", attachment.name
|
|
514
|
+
)
|
|
515
|
+
return ""
|
|
516
|
+
path = PurePosixPath(name)
|
|
517
|
+
candidate = name
|
|
518
|
+
suffix = 1
|
|
519
|
+
while candidate in _RESERVED_NAMES or candidate in taken:
|
|
520
|
+
suffix += 1
|
|
521
|
+
candidate = f"{path.stem}_{suffix}{path.suffix}"
|
|
522
|
+
if candidate != name:
|
|
523
|
+
logger.warning("Storing attachment %s as %s", name, candidate)
|
|
524
|
+
return candidate
|
|
525
|
+
|
|
526
|
+
@classmethod
|
|
527
|
+
def _drop_previous(cls, previous: Path) -> None:
|
|
528
|
+
"""
|
|
529
|
+
Deletes the version a save replaced. One that cannot be deleted is
|
|
530
|
+
retired for `prune` to remove, rather than left looking like a crash's
|
|
531
|
+
leftover, which `prune` would put back.
|
|
532
|
+
"""
|
|
533
|
+
if previous.exists() and not cls._remove_tree(previous):
|
|
534
|
+
cls._retire(previous)
|
|
535
|
+
|
|
536
|
+
@staticmethod
|
|
537
|
+
def _swap_in(staging: Path, report_dir: Path, previous: Path) -> None:
|
|
538
|
+
"""
|
|
539
|
+
Moves any existing version aside rather than deleting it, so a crash
|
|
540
|
+
between the two renames leaves it for `prune` to put back.
|
|
541
|
+
"""
|
|
542
|
+
if report_dir.exists():
|
|
543
|
+
report_dir.replace(previous)
|
|
544
|
+
staging.replace(report_dir)
|
|
545
|
+
|
|
546
|
+
@classmethod
|
|
547
|
+
def _load_report(cls, report_dir: Path, manifest: Manifest) -> DebugReport:
|
|
548
|
+
attachments = (
|
|
549
|
+
cls._load_attachment(
|
|
550
|
+
report_dir, file_name, name, manifest.content_types[file_name]
|
|
551
|
+
)
|
|
552
|
+
for file_name, name in manifest.attachments.items()
|
|
553
|
+
)
|
|
554
|
+
return manifest.to_report(tuple(a for a in attachments if a is not None))
|
|
555
|
+
|
|
556
|
+
@staticmethod
|
|
557
|
+
def _load_attachment(
|
|
558
|
+
report_dir: Path, file_name: str, name: str, content_type: str
|
|
559
|
+
) -> Attachment | None:
|
|
560
|
+
"""None, logged, for a file that is not safe to read or cannot be read."""
|
|
561
|
+
if leaf_name(file_name) != file_name or file_name in _RESERVED_NAMES:
|
|
562
|
+
logger.warning("Skipping %r: not an attachment file name", file_name)
|
|
563
|
+
return None
|
|
564
|
+
path = report_dir / file_name
|
|
565
|
+
if not is_private_file(path):
|
|
566
|
+
logger.warning(
|
|
567
|
+
"Skipping %s: missing, a link, or changeable by another user",
|
|
568
|
+
file_name,
|
|
569
|
+
)
|
|
570
|
+
return None
|
|
571
|
+
try:
|
|
572
|
+
data = path.read_bytes()
|
|
573
|
+
except OSError as e:
|
|
574
|
+
logger.warning("Skipping %s: unreadable: %s", file_name, e.strerror or e)
|
|
575
|
+
return None
|
|
576
|
+
return Attachment(
|
|
577
|
+
name=name, data=data, content_type=content_type, filename=file_name
|
|
578
|
+
)
|
|
579
|
+
|
|
580
|
+
@classmethod
|
|
581
|
+
def _saved_time_sec(cls, report_dir: Path) -> float | None:
|
|
582
|
+
"""
|
|
583
|
+
When the report was saved. For one whose manifest is unreadable, when
|
|
584
|
+
its directory last changed, so it still ages out. None if neither can be
|
|
585
|
+
read.
|
|
586
|
+
"""
|
|
587
|
+
try:
|
|
588
|
+
return Manifest.read(report_dir).created_time_sec
|
|
589
|
+
except SpooledReportError:
|
|
590
|
+
return cls._modified_time_sec(report_dir)
|
|
591
|
+
|
|
592
|
+
@classmethod
|
|
593
|
+
def _saved_time_sec_for_sorting(cls, report_dir: Path) -> float:
|
|
594
|
+
"""A report that vanished mid-scan, so has no age, sorts first."""
|
|
595
|
+
saved_time_sec = cls._saved_time_sec(report_dir)
|
|
596
|
+
return 0.0 if saved_time_sec is None else saved_time_sec
|
|
597
|
+
|
|
598
|
+
@staticmethod
|
|
599
|
+
def _modified_time_sec(path: Path) -> float | None:
|
|
600
|
+
try:
|
|
601
|
+
return path.stat().st_mtime
|
|
602
|
+
except OSError:
|
|
603
|
+
return None
|
|
604
|
+
|
|
605
|
+
@staticmethod
|
|
606
|
+
def _remove_tree(path: Path) -> bool:
|
|
607
|
+
"""Deletes `path`. False, logged, if it is still there."""
|
|
608
|
+
try:
|
|
609
|
+
shutil.rmtree(path)
|
|
610
|
+
except OSError as e:
|
|
611
|
+
logger.warning("Could not remove %s: %s", path.name, e.strerror or e)
|
|
612
|
+
return False
|
|
613
|
+
return not path.exists()
|
|
614
|
+
|
|
615
|
+
@staticmethod
|
|
616
|
+
def _retire(report_dir: Path) -> bool:
|
|
617
|
+
retired = report_dir.with_name(f".{report_dir.name.lstrip('.')}{_FILED_SUFFIX}")
|
|
618
|
+
try:
|
|
619
|
+
report_dir.rename(retired)
|
|
620
|
+
except OSError as e:
|
|
621
|
+
logger.warning("Could not retire %s: %s", report_dir.name, e.strerror or e)
|
|
622
|
+
return False
|
|
623
|
+
return True
|
|
624
|
+
|
|
625
|
+
@staticmethod
|
|
626
|
+
def _report_id_of(hidden: Path) -> str:
|
|
627
|
+
return hidden.name[1:].rsplit(".", 2)[0]
|
|
628
|
+
|
|
629
|
+
@classmethod
|
|
630
|
+
def _is_set_aside(cls, hidden: Path, now_time_sec: float) -> bool:
|
|
631
|
+
"""
|
|
632
|
+
A version a crashed save moved aside, or a claim abandoned by a drain
|
|
633
|
+
that died. A younger moved-aside version belongs to a save still in
|
|
634
|
+
progress.
|
|
635
|
+
"""
|
|
636
|
+
if hidden.name.endswith(_PREVIOUS_SUFFIX):
|
|
637
|
+
return cls._is_older_than(hidden, now_time_sec, _STAGING_MAX_AGE_SEC)
|
|
638
|
+
if not hidden.name.endswith(_CLAIM_SUFFIX):
|
|
639
|
+
return False
|
|
640
|
+
try:
|
|
641
|
+
claimed_time_sec = int(hidden.name.rsplit(".", 2)[1])
|
|
642
|
+
except (IndexError, ValueError):
|
|
643
|
+
return False
|
|
644
|
+
return claimed_time_sec < now_time_sec - _CLAIM_MAX_AGE_SEC
|
|
645
|
+
|
|
646
|
+
@classmethod
|
|
647
|
+
def _is_leftover(cls, hidden: Path, now_time_sec: float) -> bool:
|
|
648
|
+
"""A filed report `discard` could only rename, or a crashed save's staging."""
|
|
649
|
+
if hidden.name.endswith(_FILED_SUFFIX):
|
|
650
|
+
return True
|
|
651
|
+
if not hidden.name.endswith(_STAGING_SUFFIX):
|
|
652
|
+
return False
|
|
653
|
+
return cls._is_older_than(hidden, now_time_sec, _STAGING_MAX_AGE_SEC)
|
|
654
|
+
|
|
655
|
+
@classmethod
|
|
656
|
+
def _is_older_than(
|
|
657
|
+
cls, path: Path, now_time_sec: float, age_duration_sec: float
|
|
658
|
+
) -> bool:
|
|
659
|
+
"""False when the age cannot be read: unknown is not old."""
|
|
660
|
+
modified_time_sec = cls._modified_time_sec(path)
|
|
661
|
+
return (
|
|
662
|
+
modified_time_sec is not None
|
|
663
|
+
and modified_time_sec <= now_time_sec - age_duration_sec
|
|
664
|
+
)
|
|
665
|
+
|
|
666
|
+
@staticmethod
|
|
667
|
+
def _restore(moved: Path, target: Path) -> None:
|
|
668
|
+
try:
|
|
669
|
+
moved.replace(target)
|
|
670
|
+
except OSError as e:
|
|
671
|
+
logger.warning("Could not restore %s: %s", target.name, e.strerror or e)
|
|
672
|
+
return
|
|
673
|
+
logger.warning("Restored %s after an interrupted save or send", target.name)
|