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.
@@ -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