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,22 @@
|
|
|
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
|
+
# pyre-strict
|
|
16
|
+
|
|
17
|
+
from projectaria_debug_sharing.debug_sharing.types import Attachment, DebugReport
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"Attachment",
|
|
21
|
+
"DebugReport",
|
|
22
|
+
]
|
|
@@ -0,0 +1,15 @@
|
|
|
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
|
+
# pyre-strict
|
|
@@ -0,0 +1,13 @@
|
|
|
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.
|
|
@@ -0,0 +1,252 @@
|
|
|
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
|
+
Getting a report to Meta: now if possible, later if not. Package-internal:
|
|
17
|
+
nothing outside this package imports it.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import logging
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
from projectaria_debug_sharing.debug_sharing._internal.spool import ReportSpool
|
|
27
|
+
from projectaria_debug_sharing.debug_sharing.submitter import (
|
|
28
|
+
Delivery,
|
|
29
|
+
DrainResult,
|
|
30
|
+
SubmissionError,
|
|
31
|
+
Submitter,
|
|
32
|
+
)
|
|
33
|
+
from projectaria_debug_sharing.debug_sharing.types import (
|
|
34
|
+
DebugReport,
|
|
35
|
+
SpooledReportError,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
logger: logging.Logger = logging.getLogger(__name__)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class _Attempt:
|
|
43
|
+
bug_id: str | None = None
|
|
44
|
+
will_retry: bool = False
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ReportDelivery:
|
|
48
|
+
"""
|
|
49
|
+
Sends reports now, keeps the ones that fail in a `ReportSpool`, and sends
|
|
50
|
+
those on a later `drain`.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(self, submitter: Submitter, spool: ReportSpool | None = None) -> None:
|
|
54
|
+
"""
|
|
55
|
+
Args:
|
|
56
|
+
submitter: Where reports are sent.
|
|
57
|
+
spool: Where failed reports are kept. Defaults to `ReportSpool()`.
|
|
58
|
+
"""
|
|
59
|
+
self._submitter = submitter
|
|
60
|
+
self._spool: ReportSpool = spool or ReportSpool()
|
|
61
|
+
|
|
62
|
+
async def deliver(self, report: DebugReport) -> Delivery:
|
|
63
|
+
"""
|
|
64
|
+
Sends a report, and keeps it for `drain` if that fails and a retry
|
|
65
|
+
could succeed. Sending a report now means the user asked for it, so it
|
|
66
|
+
is kept as requested. A refused report is not kept, and any copy of it
|
|
67
|
+
saved earlier is deleted, or marked refused if it cannot be, so it is
|
|
68
|
+
never sent again.
|
|
69
|
+
|
|
70
|
+
A copy of the report saved earlier, for example while the tool finished
|
|
71
|
+
something the report mentions, is claimed for the send, so no drain can
|
|
72
|
+
file it meanwhile. It is deleted once the report is filed, replaced if
|
|
73
|
+
a retryable send fails, and put back if the send is cancelled. If the
|
|
74
|
+
replacement cannot be saved, it stays kept in its place as it was
|
|
75
|
+
saved, requested or not. A drain that claimed it first may still file
|
|
76
|
+
it too.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
report: The report to send.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
A `Delivery`: the bug id when the report was filed; otherwise the
|
|
83
|
+
error, and where the report was kept for a later drain, if it was.
|
|
84
|
+
"""
|
|
85
|
+
held = self._claim_kept(report.report_id)
|
|
86
|
+
try:
|
|
87
|
+
bug_id = await self._submitter.submit(report)
|
|
88
|
+
except SubmissionError as e:
|
|
89
|
+
if not e.retryable:
|
|
90
|
+
self._discard_refused(held, report.report_id, e)
|
|
91
|
+
return Delivery(error=e)
|
|
92
|
+
self._put_back(held)
|
|
93
|
+
return Delivery(error=e, spooled=self._keep(report))
|
|
94
|
+
except BaseException:
|
|
95
|
+
self._put_back(held)
|
|
96
|
+
raise
|
|
97
|
+
self._discard_kept(held, report.report_id)
|
|
98
|
+
return Delivery(bug_id=bug_id)
|
|
99
|
+
|
|
100
|
+
async def drain(self, *, requested_only: bool = False) -> DrainResult:
|
|
101
|
+
"""
|
|
102
|
+
Sends every report still waiting, deleting each once it is filed.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
requested_only: Send only reports the user asked to send. For an
|
|
106
|
+
unattended drain.
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
A `DrainResult`: the bug ids filed, how many reports wait for the
|
|
110
|
+
next drain, and how many were rejected.
|
|
111
|
+
|
|
112
|
+
Raises:
|
|
113
|
+
OSError: The spool cannot be listed, so whether any report is
|
|
114
|
+
waiting is unknown.
|
|
115
|
+
"""
|
|
116
|
+
self._spool.secure()
|
|
117
|
+
self._spool.prune()
|
|
118
|
+
sent: list[str] = []
|
|
119
|
+
waiting = 0
|
|
120
|
+
rejected = 0
|
|
121
|
+
for report_dir in self._spool.pending(requested_only=requested_only):
|
|
122
|
+
try:
|
|
123
|
+
claimed = self._spool.claim(report_dir)
|
|
124
|
+
except OSError as e:
|
|
125
|
+
logger.warning(
|
|
126
|
+
"%s: could not claim it: %s", report_dir.name, e.strerror or e
|
|
127
|
+
)
|
|
128
|
+
waiting += 1
|
|
129
|
+
continue
|
|
130
|
+
if claimed is None:
|
|
131
|
+
continue
|
|
132
|
+
attempt = await self._send_claimed(claimed, report_dir)
|
|
133
|
+
if attempt.bug_id is not None:
|
|
134
|
+
sent.append(attempt.bug_id)
|
|
135
|
+
elif attempt.will_retry:
|
|
136
|
+
waiting += 1
|
|
137
|
+
else:
|
|
138
|
+
rejected += 1
|
|
139
|
+
return DrainResult(sent=tuple(sent), waiting=waiting, rejected=rejected)
|
|
140
|
+
|
|
141
|
+
def _keep(self, report: DebugReport) -> Path | None:
|
|
142
|
+
"""
|
|
143
|
+
The spooled report, the copy saved earlier if it could not be saved, or
|
|
144
|
+
None, logged, if neither is kept.
|
|
145
|
+
"""
|
|
146
|
+
try:
|
|
147
|
+
return self._spool.save(report)
|
|
148
|
+
except (OSError, ValueError) as e:
|
|
149
|
+
logger.error("Could not keep report %s: %s", report.report_id, e)
|
|
150
|
+
return self._keep_earlier_copy(report.report_id)
|
|
151
|
+
|
|
152
|
+
def _keep_earlier_copy(self, report_id: str) -> Path | None:
|
|
153
|
+
"""
|
|
154
|
+
The copy saved earlier, still in place after a fresh save failed, or
|
|
155
|
+
None. It is kept as it was saved: one collected on the user's behalf
|
|
156
|
+
stays so, and unattended drains still skip it.
|
|
157
|
+
"""
|
|
158
|
+
kept = self._spool.find(report_id)
|
|
159
|
+
if kept is not None:
|
|
160
|
+
logger.warning("%s: a copy saved earlier is kept instead", report_id)
|
|
161
|
+
return kept
|
|
162
|
+
|
|
163
|
+
def _discard_refused(
|
|
164
|
+
self, held: tuple[Path, Path] | None, report_id: str, error: SubmissionError
|
|
165
|
+
) -> None:
|
|
166
|
+
"""
|
|
167
|
+
Deletes any copy of a refused report saved earlier, claimed for the
|
|
168
|
+
send or not, so no drain sends it. One that cannot be deleted is marked
|
|
169
|
+
refused instead, and put back if it was claimed.
|
|
170
|
+
"""
|
|
171
|
+
kept = held[0] if held is not None else self._spool.find(report_id)
|
|
172
|
+
if kept is None or self._spool.discard(kept):
|
|
173
|
+
return
|
|
174
|
+
if self._spool.mark_failed(kept, str(error)):
|
|
175
|
+
self._put_back(held)
|
|
176
|
+
return
|
|
177
|
+
logger.warning(
|
|
178
|
+
"%s: refused, but a kept copy is still on disk; it may be sent again",
|
|
179
|
+
report_id,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
def _claim_kept(self, report_id: str) -> tuple[Path, Path] | None:
|
|
183
|
+
"""
|
|
184
|
+
The kept copy of a report, claimed, and where it is put back; None when
|
|
185
|
+
no copy is kept, a drain claimed it first, or it could not be claimed.
|
|
186
|
+
"""
|
|
187
|
+
report_dir = self._spool.find(report_id)
|
|
188
|
+
if report_dir is None:
|
|
189
|
+
return None
|
|
190
|
+
try:
|
|
191
|
+
claimed = self._spool.claim(report_dir)
|
|
192
|
+
except OSError as e:
|
|
193
|
+
logger.warning(
|
|
194
|
+
"%s: could not claim the kept copy: %s", report_id, e.strerror or e
|
|
195
|
+
)
|
|
196
|
+
return None
|
|
197
|
+
return None if claimed is None else (claimed, report_dir)
|
|
198
|
+
|
|
199
|
+
def _put_back(self, held: tuple[Path, Path] | None) -> None:
|
|
200
|
+
if held is not None:
|
|
201
|
+
self._spool.release(*held)
|
|
202
|
+
|
|
203
|
+
def _discard_kept(self, held: tuple[Path, Path] | None, report_id: str) -> None:
|
|
204
|
+
if held is not None and not self._spool.discard(held[0]):
|
|
205
|
+
logger.warning(
|
|
206
|
+
"%s: filed but a kept copy is still on disk; it may refile", report_id
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
async def _send_claimed(self, claimed: Path, report_dir: Path) -> _Attempt:
|
|
210
|
+
"""Releases the report on any failure, cancellation included."""
|
|
211
|
+
try:
|
|
212
|
+
bug_id = await self._submitter.submit(self._spool.load(claimed))
|
|
213
|
+
except SpooledReportError as e:
|
|
214
|
+
return self._refuse(claimed, report_dir, e)
|
|
215
|
+
except SubmissionError as e:
|
|
216
|
+
if e.retryable:
|
|
217
|
+
return self._release_for_retry(claimed, report_dir, e)
|
|
218
|
+
return self._refuse(claimed, report_dir, e)
|
|
219
|
+
except BaseException:
|
|
220
|
+
self._spool.release(claimed, report_dir)
|
|
221
|
+
raise
|
|
222
|
+
self._discard_filed(claimed, report_dir, bug_id)
|
|
223
|
+
return _Attempt(bug_id=bug_id)
|
|
224
|
+
|
|
225
|
+
def _release_for_retry(
|
|
226
|
+
self, claimed: Path, report_dir: Path, error: SubmissionError
|
|
227
|
+
) -> _Attempt:
|
|
228
|
+
logger.info("%s: will retry: %s", report_dir.name, error)
|
|
229
|
+
self._spool.release(claimed, report_dir)
|
|
230
|
+
return _Attempt(will_retry=True)
|
|
231
|
+
|
|
232
|
+
def _refuse(self, claimed: Path, report_dir: Path, error: Exception) -> _Attempt:
|
|
233
|
+
"""
|
|
234
|
+
Marks the report refused and puts it back. One that cannot be marked is
|
|
235
|
+
discarded instead: put back unmarked, the next drain would send it again.
|
|
236
|
+
"""
|
|
237
|
+
logger.warning("%s: not retrying: %s", report_dir.name, error)
|
|
238
|
+
if self._spool.mark_failed(claimed, str(error)):
|
|
239
|
+
self._spool.release(claimed, report_dir)
|
|
240
|
+
else:
|
|
241
|
+
logger.warning(
|
|
242
|
+
"%s: could not be marked refused, so it is discarded", report_dir.name
|
|
243
|
+
)
|
|
244
|
+
self._spool.discard(claimed)
|
|
245
|
+
return _Attempt()
|
|
246
|
+
|
|
247
|
+
def _discard_filed(self, claimed: Path, report_dir: Path, bug_id: str) -> None:
|
|
248
|
+
logger.info("%s: filed as bug %s", report_dir.name, bug_id)
|
|
249
|
+
if not self._spool.discard(claimed):
|
|
250
|
+
logger.warning(
|
|
251
|
+
"%s: filed but still on disk; it may refile", report_dir.name
|
|
252
|
+
)
|
|
@@ -0,0 +1,130 @@
|
|
|
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
|
+
"""Files and directories only their owner can read or change."""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import errno
|
|
20
|
+
import os
|
|
21
|
+
from pathlib import Path, PurePosixPath
|
|
22
|
+
from typing import Final
|
|
23
|
+
|
|
24
|
+
PRIVATE_DIR_MODE: Final[int] = 0o700
|
|
25
|
+
PRIVATE_FILE_MODE: Final[int] = 0o600
|
|
26
|
+
|
|
27
|
+
_WRITABLE_BY_OTHERS: Final[int] = 0o022
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def leaf_name(raw: str) -> str:
|
|
31
|
+
"""
|
|
32
|
+
The last component of `raw`, or "" when there is no usable one. Keeps
|
|
33
|
+
`../../.ssh/id_rsa`, or `..\\..\\etc\\passwd` and `D:evil.log` on
|
|
34
|
+
Windows, inside the directory it is joined to.
|
|
35
|
+
"""
|
|
36
|
+
name = PurePosixPath(raw.replace("\\", "/")).name
|
|
37
|
+
return "" if name in {".", ".."} or ":" in name else name
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def refuse_symlink(path: Path) -> None:
|
|
41
|
+
"""
|
|
42
|
+
Raises:
|
|
43
|
+
OSError: `path` is a symlink, which could point anywhere. Its `errno`
|
|
44
|
+
is `ELOOP`, as for a symlink `O_NOFOLLOW` refuses.
|
|
45
|
+
"""
|
|
46
|
+
if path.is_symlink():
|
|
47
|
+
raise OSError(errno.ELOOP, f"{path} is a symlink, not a directory of yours")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def make_private_dir(path: Path) -> None:
|
|
51
|
+
"""
|
|
52
|
+
Creates `path` and each missing parent owner-only, and tightens `path` if
|
|
53
|
+
it already existed. Existing parents are left alone: they are not ours.
|
|
54
|
+
|
|
55
|
+
Raises:
|
|
56
|
+
OSError: `path` could not be created or tightened. Among other reasons,
|
|
57
|
+
it is a symlink, which is never followed.
|
|
58
|
+
"""
|
|
59
|
+
refuse_symlink(path)
|
|
60
|
+
missing = [
|
|
61
|
+
directory for directory in (path, *path.parents) if not directory.exists()
|
|
62
|
+
]
|
|
63
|
+
for directory in reversed(missing):
|
|
64
|
+
directory.mkdir(mode=PRIVATE_DIR_MODE, exist_ok=True)
|
|
65
|
+
path.chmod(PRIVATE_DIR_MODE)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def write_private(path: Path, data: bytes) -> None:
|
|
69
|
+
"""
|
|
70
|
+
Writes `data` to `path`, leaving it owner-only even if it existed wider.
|
|
71
|
+
|
|
72
|
+
Raises:
|
|
73
|
+
OSError: Among other reasons, `path` is a symlink, which is never
|
|
74
|
+
followed.
|
|
75
|
+
"""
|
|
76
|
+
flags = (
|
|
77
|
+
os.O_WRONLY
|
|
78
|
+
| os.O_CREAT
|
|
79
|
+
| os.O_TRUNC
|
|
80
|
+
| getattr(os, "O_BINARY", 0)
|
|
81
|
+
| getattr(os, "O_NOFOLLOW", 0)
|
|
82
|
+
)
|
|
83
|
+
descriptor = os.open(path, flags, PRIVATE_FILE_MODE)
|
|
84
|
+
with os.fdopen(descriptor, "wb") as handle:
|
|
85
|
+
if hasattr(os, "fchmod"):
|
|
86
|
+
os.fchmod(descriptor, PRIVATE_FILE_MODE)
|
|
87
|
+
else:
|
|
88
|
+
path.chmod(PRIVATE_FILE_MODE)
|
|
89
|
+
handle.write(data)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def is_private_file(path: Path) -> bool:
|
|
93
|
+
"""
|
|
94
|
+
A regular file, not a link to one, that only this user can change. A file
|
|
95
|
+
with a second hard link is refused too: that name could be any file of
|
|
96
|
+
this user's.
|
|
97
|
+
"""
|
|
98
|
+
return (
|
|
99
|
+
path.is_file()
|
|
100
|
+
and not path.is_symlink()
|
|
101
|
+
and _only_user_can_write(path)
|
|
102
|
+
and _link_count(path) == 1
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def is_private_dir(path: Path) -> bool:
|
|
107
|
+
"""A directory, not a link to one, that only this user can change."""
|
|
108
|
+
return path.is_dir() and not path.is_symlink() and _only_user_can_write(path)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _only_user_can_write(path: Path) -> bool:
|
|
112
|
+
"""
|
|
113
|
+
`path` itself belongs to this user and no one else can write to it, so
|
|
114
|
+
what it holds is what this user put there. Always True on Windows, which
|
|
115
|
+
protects files with ACLs rather than mode bits.
|
|
116
|
+
"""
|
|
117
|
+
try:
|
|
118
|
+
status = path.lstat()
|
|
119
|
+
except OSError:
|
|
120
|
+
return False
|
|
121
|
+
if not hasattr(os, "getuid"):
|
|
122
|
+
return True
|
|
123
|
+
return status.st_uid == os.getuid() and not status.st_mode & _WRITABLE_BY_OTHERS
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _link_count(path: Path) -> int:
|
|
127
|
+
try:
|
|
128
|
+
return path.lstat().st_nlink
|
|
129
|
+
except OSError:
|
|
130
|
+
return 0
|
|
@@ -0,0 +1,133 @@
|
|
|
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
|
+
"""The `report.json` stored beside a spooled report's attachments."""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
from collections.abc import Mapping
|
|
21
|
+
from dataclasses import asdict, dataclass
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Final, TypeVar
|
|
24
|
+
|
|
25
|
+
from projectaria_debug_sharing.debug_sharing._internal.file_access import (
|
|
26
|
+
is_private_file,
|
|
27
|
+
write_private,
|
|
28
|
+
)
|
|
29
|
+
from projectaria_debug_sharing.debug_sharing.types import (
|
|
30
|
+
Attachment,
|
|
31
|
+
DebugReport,
|
|
32
|
+
SpooledReportError,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
MANIFEST_NAME: Final[str] = "report.json"
|
|
36
|
+
|
|
37
|
+
_T = TypeVar("_T")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class Manifest:
|
|
42
|
+
"""Everything about a spooled report except its attachments' bytes."""
|
|
43
|
+
|
|
44
|
+
report_id: str
|
|
45
|
+
description: str
|
|
46
|
+
source_app: str
|
|
47
|
+
created_time_sec: float
|
|
48
|
+
"""When the report was saved. Ages it: a file's mtime moves when it is touched."""
|
|
49
|
+
|
|
50
|
+
requested: bool
|
|
51
|
+
"""Whether the user asked for the report to be sent, rather than it being
|
|
52
|
+
collected on their behalf."""
|
|
53
|
+
|
|
54
|
+
metadata: dict[str, str]
|
|
55
|
+
attachments: dict[str, str]
|
|
56
|
+
"""Stored file name to attachment name. The name is the multipart field the
|
|
57
|
+
attachment is sent under, which the file name cannot always reproduce."""
|
|
58
|
+
|
|
59
|
+
content_types: dict[str, str]
|
|
60
|
+
"""Stored file name to the content type the attachment was given, which its
|
|
61
|
+
suffix cannot always reproduce."""
|
|
62
|
+
|
|
63
|
+
@classmethod
|
|
64
|
+
def read(cls, report_dir: Path) -> Manifest:
|
|
65
|
+
"""
|
|
66
|
+
Raises:
|
|
67
|
+
SpooledReportError: The manifest is missing, could have been changed
|
|
68
|
+
by another user, or is not one this library wrote.
|
|
69
|
+
"""
|
|
70
|
+
path = report_dir / MANIFEST_NAME
|
|
71
|
+
if not is_private_file(path):
|
|
72
|
+
raise SpooledReportError(
|
|
73
|
+
f"{path} is missing, or could have been changed by another user."
|
|
74
|
+
)
|
|
75
|
+
try:
|
|
76
|
+
return cls._from_json(json.loads(path.read_text(encoding="utf-8")))
|
|
77
|
+
except (OSError, ValueError) as e:
|
|
78
|
+
raise SpooledReportError(f"{path} is unreadable: {e}") from e
|
|
79
|
+
|
|
80
|
+
def write(self, report_dir: Path) -> None:
|
|
81
|
+
write_private(
|
|
82
|
+
report_dir / MANIFEST_NAME, json.dumps(asdict(self)).encode("utf-8")
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
def to_report(self, attachments: tuple[Attachment, ...]) -> DebugReport:
|
|
86
|
+
return DebugReport(
|
|
87
|
+
description=self.description,
|
|
88
|
+
source_app=self.source_app,
|
|
89
|
+
attachments=attachments,
|
|
90
|
+
metadata=self.metadata,
|
|
91
|
+
report_id=self.report_id,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
@classmethod
|
|
95
|
+
def _from_json(cls, data: object) -> Manifest:
|
|
96
|
+
"""
|
|
97
|
+
Raises:
|
|
98
|
+
ValueError: `data` is not a manifest object with every field typed
|
|
99
|
+
as this class writes it.
|
|
100
|
+
"""
|
|
101
|
+
if not isinstance(data, dict):
|
|
102
|
+
raise ValueError("not a JSON object")
|
|
103
|
+
report_id = cls._field(data, "report_id", str)
|
|
104
|
+
if not report_id:
|
|
105
|
+
raise ValueError("'report_id' is empty")
|
|
106
|
+
attachments = cls._string_map(data, "attachments")
|
|
107
|
+
content_types = cls._string_map(data, "content_types")
|
|
108
|
+
if content_types.keys() != attachments.keys():
|
|
109
|
+
raise ValueError("'content_types' does not match 'attachments'")
|
|
110
|
+
return cls(
|
|
111
|
+
report_id=report_id,
|
|
112
|
+
description=cls._field(data, "description", str),
|
|
113
|
+
source_app=cls._field(data, "source_app", str),
|
|
114
|
+
created_time_sec=cls._field(data, "created_time_sec", float),
|
|
115
|
+
requested=cls._field(data, "requested", bool),
|
|
116
|
+
metadata=cls._string_map(data, "metadata"),
|
|
117
|
+
attachments=attachments,
|
|
118
|
+
content_types=content_types,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
@staticmethod
|
|
122
|
+
def _field(data: Mapping[str, object], key: str, kind: type[_T]) -> _T:
|
|
123
|
+
value = data.get(key)
|
|
124
|
+
if not isinstance(value, kind):
|
|
125
|
+
raise ValueError(f"{key!r} is not a {kind.__name__}")
|
|
126
|
+
return value
|
|
127
|
+
|
|
128
|
+
@classmethod
|
|
129
|
+
def _string_map(cls, data: Mapping[str, object], key: str) -> dict[str, str]:
|
|
130
|
+
value = cls._field(data, key, dict)
|
|
131
|
+
if not all(isinstance(k, str) and isinstance(v, str) for k, v in value.items()):
|
|
132
|
+
raise ValueError(f"{key!r} is not a map of strings")
|
|
133
|
+
return value
|