untar 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,80 @@
1
+ Metadata-Version: 2.5
2
+ Name: untar
3
+ Version: 0.1.0
4
+ Summary: Fast, safe, streaming extraction of tar archives that distribute software
5
+ Project-URL: Homepage, https://github.com/haampie/untar
6
+ Project-URL: Repository, https://github.com/haampie/untar
7
+ Author-email: Harmen Stoppels <me@harmenstoppels.nl>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: archive,extract,tar
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3 :: Only
13
+ Classifier: Topic :: System :: Archiving
14
+ Requires-Python: >=3.6
15
+ Provides-Extra: dev
16
+ Requires-Dist: hypothesis; extra == 'dev'
17
+ Requires-Dist: mypy; extra == 'dev'
18
+ Requires-Dist: pytest; extra == 'dev'
19
+ Requires-Dist: ruff; extra == 'dev'
20
+ Description-Content-Type: text/markdown
21
+
22
+ # untar
23
+
24
+ Fast, safe, streaming extraction of tar archives that distribute software, in a single pure
25
+ Python module without dependencies, for Python 3.6 and later.
26
+
27
+ ```python
28
+ import untar
29
+
30
+ # Extract an archive, from a path or any readable binary stream
31
+ untar.extract("pkg.tar.gz", "/opt/pkg")
32
+
33
+ # Extract the dir that contains `.meta`, wherever it is in the archive
34
+ root = untar.extract("pkg.tar.gz", "/opt/pkg", root_marker=".meta")
35
+ ```
36
+
37
+ ## Scope
38
+
39
+ untar is for installing software from archives, not for restoring backups. Owners, groups,
40
+ timestamps and permissions recorded in an archive describe the machine it was made on; on
41
+ another machine they are meaningless at best and a liability at worst. So untar does not
42
+ restore them: files are created with `file_mode` (without executable bits where the archive
43
+ has none for the owner), dirs with `dir_mode`, optionally with group `gid`, and only regular
44
+ files, dirs, symlinks and hardlinks are extracted.
45
+
46
+ ## Features
47
+
48
+ - **One pass.** The archive is read front to back once, without seeking, from a file, pipe or
49
+ network stream. Gzip is detected from the first bytes.
50
+ - **Less work per member.** Only the header fields that extraction needs are parsed (ustar,
51
+ pax and GNU long names), and a file costs an `open`, `write`s and a `close`.
52
+ - **A root only known from the contents.** With `root_marker`, the parent of the shallowest
53
+ member with that name is extracted, like `tar --strip-components` with a count that is only
54
+ known after the last member.
55
+ - **No path length limits.** Entries are created relative to open dir file descriptors, so no
56
+ path longer than one name is passed to the operating system.
57
+
58
+ ## Safety
59
+
60
+ Nothing is written outside the destination, whatever the archive contains. Absolute names and
61
+ `..` are rejected, dirs are opened with `O_NOFOLLOW` so nothing is written through a symlink,
62
+ and files are created with `O_EXCL | O_NOFOLLOW` so existing entries are never replaced.
63
+ Symlinks may point anywhere, but untar never follows them. On Windows, which has no dir file
64
+ descriptors, these checks are path based and not safe against concurrent changes.
65
+
66
+ Archives that tar implementations read differently are rejected, so that no member can hide
67
+ from one of them: numbers must be plain digits, a member's name and size come from one header
68
+ only, and global pax headers cannot set them. The gzip checksum is always verified.
69
+
70
+ ## Development
71
+
72
+ ```console
73
+ $ pip install -e '.[dev]'
74
+ $ python tests/vendor/fetch_cpython_testdata.py cpython-testdata
75
+ $ UNTAR_CPYTHON_TESTDATA=cpython-testdata pytest
76
+ $ ruff check && ruff format --diff && mypy
77
+ ```
78
+
79
+ `tests/vendor/test_cpython.py` is adapted from CPython's `test_tarfile.py` and is under the PSF
80
+ License Agreement included in it. The rest of untar is under the MIT license.
@@ -0,0 +1,5 @@
1
+ untar.py,sha256=E5nnt2e4RFdujpJqDWSoJl5HWCcTTdKM2pG4fxkZMRA,30646
2
+ untar-0.1.0.dist-info/METADATA,sha256=En4b4-GjBjdjfHZfYuJ7gZUue7A3r0swePkCcbthDLA,3544
3
+ untar-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
4
+ untar-0.1.0.dist-info/licenses/LICENSE,sha256=hst9cHVsW_voYEtCBs6y7_8ionKLq4cvL3u7rMtvoqc,1072
5
+ untar-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Harmen Stoppels
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
untar.py ADDED
@@ -0,0 +1,798 @@
1
+ """Fast, safe, streaming extraction of tar archives that distribute software.
2
+
3
+ Archives are read front to back exactly once, from a file or any readable binary stream, and
4
+ only the header fields that extraction needs are parsed. ``extract`` can strip a leading
5
+ directory that is only known from the archive's contents: the parent of the shallowest member
6
+ with a given name. Owners, groups, timestamps and permissions in the archive are not restored:
7
+ files are created with the permissions given by the caller.
8
+ """
9
+
10
+ import contextlib
11
+ import errno
12
+ import gzip
13
+ import os
14
+ import shutil
15
+ import stat
16
+ import struct
17
+ import sys
18
+ import zlib
19
+ from typing import IO, Dict, Iterable, Iterator, List, Optional, Tuple, Union
20
+
21
+ __version__ = "0.1.0"
22
+ __all__ = ["Error", "Member", "TarReader", "extract"]
23
+
24
+ REGTYPE = b"0"
25
+ AREGTYPE = b"\0"
26
+ LNKTYPE = b"1"
27
+ SYMTYPE = b"2"
28
+ DIRTYPE = b"5"
29
+ CONTTYPE = b"7"
30
+ _PAX_HEADER = b"x"
31
+ _SOLARIS_PAX_HEADER = b"X"
32
+ _PAX_GLOBAL_HEADER = b"g"
33
+ _GNU_LONGNAME = b"L"
34
+ _GNU_LONGLINK = b"K"
35
+ _EXTENDED = (
36
+ _PAX_HEADER,
37
+ _SOLARIS_PAX_HEADER,
38
+ _PAX_GLOBAL_HEADER,
39
+ _GNU_LONGNAME,
40
+ _GNU_LONGLINK,
41
+ )
42
+ _DATA_TYPES = (REGTYPE, AREGTYPE, CONTTYPE)
43
+ #: Like tarfile, members of other types than these have data, also unknown ones
44
+ _NO_DATA_TYPES = (LNKTYPE, SYMTYPE, DIRTYPE, b"3", b"4", b"6")
45
+
46
+ _WINDOWS = sys.platform == "win32"
47
+ _BLOCK = 512
48
+ _EOF_BLOCK = bytes(_BLOCK)
49
+ _CHUNK = 64 * 1024
50
+ _BUF = 1024 * 1024
51
+ #: Pax and GNU long name headers are read into memory: limit their size per member
52
+ _MAX_EXTENDED = 1024 * 1024
53
+ _BadGzipFile = getattr(gzip, "BadGzipFile", OSError)
54
+
55
+
56
+ class Error(ValueError):
57
+ """The archive is invalid, or cannot be extracted safely."""
58
+
59
+
60
+ class Member:
61
+ """A member of a tar archive, with only the fields that extraction needs."""
62
+
63
+ __slots__ = ("linkname", "mode", "name", "size", "type")
64
+
65
+ def __init__(
66
+ self, name: str, type: bytes, mode: int, size: int, linkname: str
67
+ ) -> None:
68
+ self.name = name
69
+ self.type = type
70
+ self.mode = mode
71
+ self.size = size
72
+ self.linkname = linkname
73
+
74
+ def isreg(self) -> bool:
75
+ return self.type in _DATA_TYPES
76
+
77
+ def isdir(self) -> bool:
78
+ return self.type == DIRTYPE
79
+
80
+ def issym(self) -> bool:
81
+ return self.type == SYMTYPE
82
+
83
+ def islnk(self) -> bool:
84
+ return self.type == LNKTYPE
85
+
86
+ def __repr__(self) -> str:
87
+ return f"Member({self.name!r}, type={self.type!r}, mode={oct(self.mode)}, size={self.size})"
88
+
89
+
90
+ def _str(field: bytes) -> str:
91
+ return field.split(b"\0", 1)[0].decode("utf-8", "surrogateescape")
92
+
93
+
94
+ def _int(field: bytes) -> int:
95
+ """A numeric header field: NUL or space terminated octal, or base-256 (GNU)."""
96
+ if field[0] == 0o200:
97
+ return int.from_bytes(field[1:], "big")
98
+ elif field[0] == 0o377:
99
+ raise Error("Archive contains a negative number")
100
+ digits = field.split(b"\0", 1)[0].strip()
101
+ # Only octal digits, like tar
102
+ if digits.strip(b"01234567"):
103
+ raise Error(f"Archive contains an invalid number {digits!r}")
104
+ return int(digits, 8) if digits else 0
105
+
106
+
107
+ def _decimal(value: str, key: str) -> int:
108
+ """A pax decimal number, of ASCII digits only."""
109
+ if not value or value.strip("0123456789"):
110
+ raise Error(f"Archive contains an invalid pax {key} {value!r}")
111
+ return int(value)
112
+
113
+
114
+ def _checksum_ok(header: bytes) -> bool:
115
+ expected = _int(header[148:156])
116
+ # adler32's low half is the byte sum mod 65521, which is exact for 256 bytes
117
+ total = (zlib.adler32(header[:256], 0) & 0xFFFF) + (
118
+ zlib.adler32(header[256:], 0) & 0xFFFF
119
+ )
120
+ if expected == total - sum(header[148:156]) + 256:
121
+ return True
122
+ # Some old tar implementations summed signed chars
123
+ signed = struct.unpack("148b8x356b", header)
124
+ return expected == sum(signed) + 256
125
+
126
+
127
+ def _pax_records(data: bytes) -> Dict[str, str]:
128
+ """Parse the ``<length> <key>=<value>\\n`` records of a pax extended header."""
129
+ records = {}
130
+ pos = 0
131
+ while pos < len(data) and data[pos] != 0:
132
+ space = data.find(b" ", pos)
133
+ length = data[pos:space]
134
+ if space < 0 or not length or length.strip(b"0123456789"):
135
+ raise Error("Archive contains an invalid pax header")
136
+ end = pos + int(length)
137
+ if end <= space or end > len(data) or data[end - 1] != 0x0A:
138
+ raise Error("Archive contains an invalid pax header")
139
+ key, equals, value = data[space + 1 : end - 1].partition(b"=")
140
+ if not key or not equals:
141
+ raise Error("Archive contains an invalid pax record")
142
+ records[key.decode("utf-8", "surrogateescape")] = value.decode(
143
+ "utf-8", "surrogateescape"
144
+ )
145
+ pos = end
146
+ return records
147
+
148
+
149
+ def _fill(stream: IO[bytes], size: int) -> bytes:
150
+ """Read size bytes, or fewer at the end of the stream."""
151
+ data = stream.read(size)
152
+ while 0 < len(data) < size:
153
+ more = stream.read(size - len(data))
154
+ if not more:
155
+ break
156
+ data += more
157
+ return data
158
+
159
+
160
+ class TarReader:
161
+ """Reads the members of a tar archive front to back from a binary stream, without seeking.
162
+
163
+ Parses ustar headers, pax extended headers and GNU long names, and verifies header
164
+ checksums. Iterate for members, and read the data of the current regular file with
165
+ ``chunks()``; data that is not read is skipped."""
166
+
167
+ def __init__(self, stream: IO[bytes]) -> None:
168
+ self.stream = stream
169
+ #: Unread data of the current member
170
+ self.remaining = 0
171
+ self.buf = b""
172
+ self.pos = 0
173
+
174
+ def _more(self, size: int) -> None:
175
+ """Make sure size bytes are buffered, or fewer at the end of the stream."""
176
+ parts = [self.buf[self.pos :]]
177
+ have = len(parts[0])
178
+ while have < size:
179
+ more = self.stream.read(max(_BUF, size - have))
180
+ if not more:
181
+ break
182
+ parts.append(more)
183
+ have += len(more)
184
+ self.buf = b"".join(parts)
185
+ self.pos = 0
186
+
187
+ def _read(self, size: int) -> bytes:
188
+ if self.pos + size > len(self.buf):
189
+ self._more(size)
190
+ if size > len(self.buf):
191
+ raise Error("Archive is truncated")
192
+ pos = self.pos
193
+ self.pos = pos + size
194
+ return self.buf[pos : pos + size]
195
+
196
+ def _header(self) -> Optional[bytes]:
197
+ if self.pos + _BLOCK > len(self.buf):
198
+ self._more(_BLOCK)
199
+ if not self.buf:
200
+ return None
201
+ if len(self.buf) < _BLOCK:
202
+ raise Error("Archive is truncated")
203
+ pos = self.pos
204
+ self.pos = pos + _BLOCK
205
+ header = self.buf[pos : pos + _BLOCK]
206
+ if header == _EOF_BLOCK:
207
+ return None
208
+ if not _checksum_ok(header):
209
+ raise Error("Archive contains a header with an invalid checksum")
210
+ return header
211
+
212
+ def chunks(self, size: int = _CHUNK) -> Iterator[memoryview]:
213
+ """Yield the unread data of the current member, as views that stay valid."""
214
+ while self.remaining:
215
+ if self.pos >= len(self.buf):
216
+ self._more(1)
217
+ if not self.buf:
218
+ raise Error("Archive is truncated")
219
+ pos = self.pos
220
+ n = min(size, self.remaining, len(self.buf) - pos)
221
+ self.pos = pos + n
222
+ self.remaining -= n
223
+ yield memoryview(self.buf)[pos : pos + n]
224
+
225
+ def __iter__(self) -> Iterator[Member]:
226
+ padding = 0
227
+ while True:
228
+ skip = self.remaining + padding
229
+ self.remaining = 0
230
+ if self.pos + skip <= len(self.buf):
231
+ self.pos += skip
232
+ else:
233
+ skip -= len(self.buf) - self.pos
234
+ self.buf, self.pos = b"", 0
235
+ while skip:
236
+ more = self.stream.read(min(skip, _BUF))
237
+ if not more:
238
+ raise Error("Archive is truncated")
239
+ skip -= len(more)
240
+
241
+ # Extended headers apply to the next member, and may give its name in one way only,
242
+ # so that readers that resolve conflicts differently cannot see different members.
243
+ pax: Dict[str, str] = {}
244
+ long_name: Optional[str] = None
245
+ long_link: Optional[str] = None
246
+ seen = b""
247
+ budget = _MAX_EXTENDED
248
+ while True:
249
+ header = self._header()
250
+ if header is None:
251
+ if seen:
252
+ raise Error("Archive ends after an extended header")
253
+ return
254
+ type = header[156:157]
255
+ if type not in _EXTENDED:
256
+ break
257
+ size = _int(header[124:136])
258
+ budget -= size
259
+ if budget < 0:
260
+ raise Error("Archive contains extended headers of more than 1 MiB")
261
+ data = self._read(size)
262
+ self._read(-size % _BLOCK)
263
+ if type == _PAX_GLOBAL_HEADER:
264
+ # Readers disagree on whether these apply to later members: refuse them.
265
+ if {"path", "linkpath", "size"} & _pax_records(data).keys():
266
+ raise Error(
267
+ "Archive contains a global pax path, linkpath or size"
268
+ )
269
+ continue
270
+ kind = _PAX_HEADER if type == _SOLARIS_PAX_HEADER else type
271
+ if kind in seen:
272
+ raise Error(f"Archive contains two {kind!r} headers for one member")
273
+ seen += kind
274
+ if kind == _PAX_HEADER:
275
+ pax = _pax_records(data)
276
+ elif kind == _GNU_LONGNAME:
277
+ long_name = _str(data)
278
+ else:
279
+ long_link = _str(data)
280
+ if kind != _PAX_HEADER and not _str(data):
281
+ raise Error("Archive contains an empty GNU long name")
282
+ if long_name is not None and "path" in pax:
283
+ raise Error("Archive contains both a pax path and a GNU long name")
284
+ if long_link is not None and "linkpath" in pax:
285
+ raise Error("Archive contains both a pax linkpath and a GNU long link")
286
+ for key in ("path", "linkpath"):
287
+ if key in pax and (not pax[key] or "\0" in pax[key]):
288
+ raise Error(f"Archive contains an invalid pax {key} {pax[key]!r}")
289
+
290
+ name = _str(header[0:100])
291
+ # Like GNU tar, only POSIX ustar headers (version 00) have a name prefix
292
+ if header[257:265] == b"ustar\x0000":
293
+ prefix = _str(header[345:500])
294
+ if prefix:
295
+ name = f"{prefix}/{name}"
296
+ if type == AREGTYPE and name.endswith("/"):
297
+ type = DIRTYPE
298
+ if type == b"S" or any(key.startswith("GNU.sparse.") for key in pax):
299
+ raise Error(
300
+ f"Archive contains a sparse file {name}, which is not supported"
301
+ )
302
+
303
+ name = long_name if long_name is not None else pax.get("path", name)
304
+ if type == DIRTYPE:
305
+ name = name.rstrip("/") or name
306
+ member = Member(
307
+ name,
308
+ type,
309
+ _int(header[100:108]),
310
+ _decimal(pax["size"], "size")
311
+ if "size" in pax
312
+ else _int(header[124:136]),
313
+ long_link
314
+ if long_link is not None
315
+ else pax.get("linkpath", _str(header[157:257])),
316
+ )
317
+
318
+ self.remaining = 0 if type in _NO_DATA_TYPES else member.size
319
+ padding = -self.remaining % _BLOCK
320
+ yield member
321
+
322
+
323
+ class _Prepend:
324
+ """A stream that first returns ``head`` and then reads from ``stream``."""
325
+
326
+ def __init__(self, head: bytes, stream: IO[bytes]) -> None:
327
+ self.head = head
328
+ self.stream = stream
329
+
330
+ def read(self, size: int = -1) -> bytes:
331
+ if not self.head:
332
+ return self.stream.read(size)
333
+ if size < 0:
334
+ data, self.head = self.head + self.stream.read(), b""
335
+ else:
336
+ data, self.head = self.head[:size], self.head[size:]
337
+ if len(data) < size:
338
+ data += self.stream.read(size - len(data))
339
+ return data
340
+
341
+
342
+ class _Gzip:
343
+ """A gzip stream whose errors are untar errors."""
344
+
345
+ def __init__(self, stream: _Prepend) -> None:
346
+ self.gzip = gzip.GzipFile(fileobj=stream, mode="rb") # type: ignore[call-overload]
347
+
348
+ def read(self, size: int = -1) -> bytes:
349
+ try:
350
+ return self.gzip.read(size)
351
+ except EOFError:
352
+ raise Error("Archive is truncated") from None
353
+ except (zlib.error, _BadGzipFile) as e:
354
+ raise Error(f"Archive is not valid gzip: {e}") from e
355
+
356
+
357
+ @contextlib.contextmanager
358
+ def _open_archive(
359
+ src: Union[str, "os.PathLike[str]", IO[bytes]],
360
+ ) -> Iterator[IO[bytes]]:
361
+ """Open a path or a binary file object as a stream of uncompressed tar data, which is
362
+ detected from the first bytes. Use as a context manager; file objects are not closed."""
363
+ file = open(src, "rb") if isinstance(src, (str, os.PathLike)) else None # noqa: SIM115
364
+ try:
365
+ stream = file or src
366
+ magic = _fill(stream, 2) # type: ignore[arg-type]
367
+ head = _Prepend(magic, stream) # type: ignore[arg-type]
368
+ yield _Gzip(head) if magic == b"\x1f\x8b" else head # type: ignore
369
+ finally:
370
+ if file is not None:
371
+ file.close()
372
+
373
+
374
+ def _common(a: List[str], b: List[str]) -> int:
375
+ """The number of leading names a and b have in common."""
376
+ n = 0
377
+ for x, y in zip(a, b):
378
+ if x != y:
379
+ break
380
+ n += 1
381
+ return n
382
+
383
+
384
+ def _parts(name: str) -> List[str]:
385
+ """Split a member name into path components, rejecting names that escape the destination."""
386
+ parts = [p for p in name.split("/") if p and p != "."]
387
+ if (
388
+ name.startswith("/")
389
+ or ".." in parts
390
+ or _WINDOWS
391
+ and any("\\" in p or ":" in p for p in parts)
392
+ ):
393
+ raise Error(f"Archive contains unsafe path {name}")
394
+ return parts
395
+
396
+
397
+ def _resolve(parts: List[str], linkname: str) -> Optional[List[str]]:
398
+ """The member a relative symlink at parts points to, or None if it points outside."""
399
+ if linkname.startswith("/"):
400
+ return None
401
+ target = parts[:-1]
402
+ for part in linkname.split("/"):
403
+ if part == "..":
404
+ if not target:
405
+ return None
406
+ target = target[:-1]
407
+ elif part and part != ".":
408
+ target = target + [part]
409
+ return target
410
+
411
+
412
+ #: Whether entries are created relative to open dir file descriptors. Otherwise, as on Windows,
413
+ #: they are created by (extended-length) path.
414
+ _DIR_FD = {os.open, os.mkdir, os.symlink, os.link, os.rename} <= os.supports_dir_fd
415
+ _O_NOFOLLOW = getattr(os, "O_NOFOLLOW", 0)
416
+ _O_BINARY = getattr(os, "O_BINARY", 0)
417
+ # os.open makes file descriptors non-inheritable, so O_CLOEXEC is implied
418
+ _DIR_FLAGS = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | _O_NOFOLLOW
419
+ _FILE_FLAGS = os.O_WRONLY | os.O_CREAT | os.O_EXCL | _O_NOFOLLOW | _O_BINARY
420
+
421
+ #: An open dir: a file descriptor, or a path where dir file descriptors are not supported
422
+ Dir = Union[int, str]
423
+
424
+
425
+ def _at(parent: Dir, name: str) -> Tuple[str, Optional[int]]:
426
+ """The path and dir_fd arguments for name in parent."""
427
+ if isinstance(parent, int):
428
+ return name, parent
429
+ return os.path.join(parent, name), None
430
+
431
+
432
+ def _open_dir(parent: Dir, name: str) -> Dir:
433
+ """Open the dir name in parent, without following symlinks."""
434
+ if isinstance(parent, int):
435
+ try:
436
+ return os.open(name, _DIR_FLAGS, dir_fd=parent)
437
+ except OSError as e:
438
+ if e.errno in (errno.ELOOP, errno.ENOTDIR):
439
+ raise Error(
440
+ f"Archive writes through {name}, which is not a directory"
441
+ ) from e
442
+ raise
443
+ path = os.path.join(parent, name)
444
+ if not stat.S_ISDIR(os.lstat(path).st_mode):
445
+ raise Error(f"Archive writes through {name}, which is not a directory")
446
+ return path
447
+
448
+
449
+ def _isreg(parent: Dir, name: str) -> bool:
450
+ path, dir_fd = _at(parent, name)
451
+ try:
452
+ return stat.S_ISREG(os.stat(path, dir_fd=dir_fd, follow_symlinks=False).st_mode)
453
+ except FileNotFoundError:
454
+ return False
455
+
456
+
457
+ def _close(d: Dir) -> None:
458
+ if isinstance(d, int):
459
+ os.close(d)
460
+
461
+
462
+ #: Errors of creating a link where the platform or filesystem does not support it
463
+ _UNSUPPORTED = {
464
+ errno.EPERM,
465
+ errno.ENOSYS,
466
+ errno.EOPNOTSUPP,
467
+ getattr(errno, "ENOTSUP", 0),
468
+ }
469
+
470
+
471
+ def _unsupported(e: OSError) -> bool:
472
+ """Whether a link could not be created because links are not supported."""
473
+ # Windows requires a privilege for symlinks, and some filesystems have no hardlinks
474
+ return e.errno in _UNSUPPORTED or _WINDOWS and not isinstance(e, FileExistsError)
475
+
476
+
477
+ class _Extractor:
478
+ """Extracts members in a single pass, each to its full archive name in a temporary dir.
479
+
480
+ Entries are created relative to open dir file descriptors, so paths are never longer than
481
+ a single name, and nothing is written through a symlink, even one swapped in concurrently.
482
+ The open dirs are a stack of the ancestors of the current member, which for the depth-first
483
+ order of tar archives changes little from one member to the next.
484
+
485
+ The archive name of the final root is only known after the last member. Validation only
486
+ needs ``root``, the deepest dir containing every member that is not a dir leading up to
487
+ it. State is bounded by the depth of the archive."""
488
+
489
+ def __init__(
490
+ self,
491
+ dest: str,
492
+ root_marker: Optional[str],
493
+ file_mode: int,
494
+ dir_mode: int,
495
+ gid: Optional[int],
496
+ ) -> None:
497
+ self.file_mode = file_mode
498
+ self.dir_mode = dir_mode
499
+ self.gid = gid
500
+ self.root: List[str] = []
501
+ self.descending = True
502
+ self.marker_name = root_marker
503
+ #: The shallowest member with the marker name
504
+ self.marker: Optional[List[str]] = None
505
+ #: Symlinks that could not be created, to be copies of targets extracted later
506
+ self.links: List[Tuple[List[str], List[str]]] = []
507
+ #: Open dirs below tmp, and their names
508
+ self.names: List[str] = []
509
+ self.dirs: List[Dir] = []
510
+
511
+ dest = os.path.abspath(dest)
512
+ if _WINDOWS and not dest.startswith("\\\\?\\"):
513
+ # an extended-length path, which may exceed MAX_PATH
514
+ dest = "\\\\?\\" + dest
515
+ self.dest: Dir = os.open(dest, _DIR_FLAGS) if _DIR_FD else dest
516
+ while True:
517
+ tmp_name = f".untar-{os.urandom(4).hex()}"
518
+ path, dir_fd = _at(self.dest, tmp_name)
519
+ try:
520
+ os.mkdir(path, 0o700, dir_fd=dir_fd)
521
+ break
522
+ except FileExistsError:
523
+ pass
524
+ self.tmp_path = os.path.join(dest, tmp_name)
525
+ self.tmp = _open_dir(self.dest, tmp_name)
526
+
527
+ # Find out once what the filesystem does to new entries: mode bits the umask or default
528
+ # ACLs strip, special bits it does not set, and the group they get.
529
+ path, dir_fd = _at(self.tmp, "probe")
530
+ os.close(os.open(path, _FILE_FLAGS, file_mode, dir_fd=dir_fd))
531
+ s = os.stat(path, dir_fd=dir_fd, follow_symlinks=False)
532
+ os.unlink(path, dir_fd=dir_fd)
533
+ self.chown = gid is not None and s.st_gid != gid
534
+ self.chmod_files = not _WINDOWS and stat.S_IMODE(s.st_mode) != file_mode
535
+ os.mkdir(path, dir_mode, dir_fd=dir_fd)
536
+ s = os.stat(path, dir_fd=dir_fd, follow_symlinks=False)
537
+ os.rmdir(path, dir_fd=dir_fd)
538
+ self.chmod_dirs = not _WINDOWS and stat.S_IMODE(s.st_mode) != dir_mode
539
+
540
+ def close(self) -> None:
541
+ for d in (*self.dirs, self.tmp, self.dest):
542
+ _close(d)
543
+ # rmtree walks with dir file descriptors where supported, so depth is no problem
544
+ shutil.rmtree(self.tmp_path, ignore_errors=True)
545
+
546
+ def _attrs(self, fd: Dir, mode: int, chmod: bool) -> None:
547
+ """Set the group and mode of an open file or dir, which never follows a symlink."""
548
+ # chown before chmod, since chown may clear set-user-ID and set-group-ID bits
549
+ if self.chown:
550
+ os.fchown(fd, -1, self.gid) # type: ignore
551
+ if chmod:
552
+ os.fchmod(fd, mode) # type: ignore
553
+
554
+ def _dir(self, parts: List[str]) -> Dir:
555
+ """Open tmp/parts, creating missing dirs, and keep its ancestors open."""
556
+ common = _common(self.names, parts)
557
+ for d in self.dirs[common:]:
558
+ _close(d)
559
+ del self.names[common:], self.dirs[common:]
560
+ for name in parts[common:]:
561
+ parent = self.dirs[-1] if self.dirs else self.tmp
562
+ try:
563
+ d = _open_dir(parent, name)
564
+ except FileNotFoundError:
565
+ path, dir_fd = _at(parent, name)
566
+ os.mkdir(path, self.dir_mode, dir_fd=dir_fd)
567
+ d = _open_dir(parent, name)
568
+ self._attrs(d, self.dir_mode, self.chmod_dirs)
569
+ self.names.append(name)
570
+ self.dirs.append(d)
571
+ return self.dirs[-1] if self.dirs else self.tmp
572
+
573
+ def _file(
574
+ self,
575
+ parent: Dir,
576
+ name: str,
577
+ mode: int,
578
+ chunks: Iterable[Union[bytes, memoryview]],
579
+ ) -> None:
580
+ path, dir_fd = _at(parent, name)
581
+ fd = os.open(path, _FILE_FLAGS, mode, dir_fd=dir_fd)
582
+ try:
583
+ for data in chunks:
584
+ view = memoryview(data)
585
+ while view:
586
+ view = view[os.write(fd, view) :]
587
+ self._attrs(fd, mode, self.chmod_files)
588
+ finally:
589
+ os.close(fd)
590
+
591
+ def _track(self, member: Member, parts: List[str]) -> None:
592
+ """Update root, which validation needs, and marker."""
593
+ if parts[-1:] == [self.marker_name] and (
594
+ self.marker is None or len(parts) < len(self.marker)
595
+ ):
596
+ self.marker = parts
597
+ common = _common(parts, self.root)
598
+ if common == len(parts):
599
+ if not member.isdir():
600
+ raise Error(
601
+ f"Archive contains non-directory {member.name} where a directory is"
602
+ )
603
+ elif common < len(self.root):
604
+ self.root = self.root[:common]
605
+ self.descending = False
606
+ elif self.descending:
607
+ # Nothing but dirs so far: descend to the deepest dir containing this member.
608
+ self.root = parts if member.isdir() else parts[:-1]
609
+ self.descending = member.isdir()
610
+
611
+ def add(self, member: Member, reader: TarReader) -> None:
612
+ parts = _parts(member.name)
613
+ self._track(member, parts)
614
+ if not parts:
615
+ return
616
+ if not member.isdir() and member.name.rstrip(".").endswith("/"):
617
+ raise Error(
618
+ f"Archive contains non-directory {member.name} named like a directory"
619
+ )
620
+ if member.isdir():
621
+ parent = self._dir(parts[:-1])
622
+ path, dir_fd = _at(parent, parts[-1])
623
+ try:
624
+ os.mkdir(path, self.dir_mode, dir_fd=dir_fd)
625
+ except FileExistsError:
626
+ self._dir(parts)
627
+ return
628
+ d = _open_dir(parent, parts[-1])
629
+ self._attrs(d, self.dir_mode, self.chmod_dirs)
630
+ self.names.append(parts[-1])
631
+ self.dirs.append(d)
632
+ return
633
+ parent = self._dir(parts[:-1])
634
+ path, dir_fd = _at(parent, parts[-1])
635
+ if member.isreg():
636
+ # Like chmod +X: only executable if the member is executable by its owner
637
+ mode = self.file_mode if member.mode & 0o100 else self.file_mode & ~0o111
638
+ self._file(parent, parts[-1], mode, reader.chunks())
639
+ elif member.issym():
640
+ if not member.linkname:
641
+ raise Error(f"Archive contains symlink {member.name} without a target")
642
+ try:
643
+ os.symlink(member.linkname, path, dir_fd=dir_fd)
644
+ except OSError as e:
645
+ if not _unsupported(e):
646
+ raise
647
+ # Like tarfile, copy the target instead where symlinks are not supported.
648
+ target = _resolve(parts, member.linkname)
649
+ if target is not None and not self._copy(target, parts):
650
+ self.links.append((target, parts))
651
+ return
652
+ if self.chown:
653
+ os.chown(path, -1, self.gid, dir_fd=dir_fd, follow_symlinks=False) # type: ignore
654
+ elif member.islnk():
655
+ target = _parts(member.linkname)
656
+ if target == parts and _isreg(parent, parts[-1]):
657
+ return # tar archives a file listed twice as a hardlink to itself
658
+ if (
659
+ not target
660
+ or target == parts
661
+ or not self._copy(target, parts, link=True)
662
+ ):
663
+ raise Error(
664
+ f"Archive hardlinks {member.linkname}, which is not extracted"
665
+ )
666
+ else:
667
+ raise Error(
668
+ f"Archive contains {member.name} of unsupported type {member.type!r}"
669
+ )
670
+
671
+ def _open_chain(self, parts: List[str]) -> Dir:
672
+ """Open tmp/parts without creating anything, independently of the stack."""
673
+ d = os.dup(self.tmp) if isinstance(self.tmp, int) else self.tmp
674
+ for name in parts:
675
+ try:
676
+ child = _open_dir(d, name)
677
+ finally:
678
+ _close(d)
679
+ d = child
680
+ return d
681
+
682
+ def _copy(self, source: List[str], dest: List[str], link: bool = False) -> bool:
683
+ """Hardlink or copy an extracted entry to dest, and return whether it was extracted."""
684
+ if not source:
685
+ return False
686
+ try:
687
+ source_parent = self._open_chain(source[:-1])
688
+ except (FileNotFoundError, Error):
689
+ return False
690
+ try:
691
+ path, dir_fd = _at(source_parent, source[-1])
692
+ try:
693
+ s = os.stat(path, dir_fd=dir_fd, follow_symlinks=False)
694
+ except FileNotFoundError:
695
+ return False
696
+ if stat.S_ISDIR(s.st_mode):
697
+ if link:
698
+ raise Error(
699
+ f"Archive hardlinks {'/'.join(source)}, which is a directory"
700
+ )
701
+ self._dir(dest)
702
+ return True
703
+ parent = self._dir(dest[:-1])
704
+ if link:
705
+ dest_path, dest_fd = _at(parent, dest[-1])
706
+ try:
707
+ os.link(
708
+ path,
709
+ dest_path,
710
+ src_dir_fd=dir_fd,
711
+ dst_dir_fd=dest_fd,
712
+ follow_symlinks=False,
713
+ )
714
+ return True
715
+ except OSError as e:
716
+ if not _unsupported(e):
717
+ raise
718
+ # Like tarfile, copy where hardlinks are not supported
719
+ if not stat.S_ISREG(s.st_mode):
720
+ return False
721
+ src = os.open(path, os.O_RDONLY | _O_BINARY, dir_fd=dir_fd)
722
+ try:
723
+ chunks = iter(lambda: os.read(src, _CHUNK), b"")
724
+ self._file(parent, dest[-1], stat.S_IMODE(s.st_mode), chunks)
725
+ finally:
726
+ os.close(src)
727
+ return True
728
+ finally:
729
+ _close(source_parent)
730
+
731
+ def finish(self) -> str:
732
+ """Move the contents of the final root into dest, and return its archive name."""
733
+ root: List[str] = []
734
+ if self.marker_name is not None:
735
+ if self.marker is None:
736
+ raise Error(f"Archive contains no member named {self.marker_name}")
737
+ root = self.marker[:-1]
738
+ if self.root[: len(root)] != root:
739
+ raise Error(f"Archive contains members outside of {'/'.join(root) or '.'}")
740
+
741
+ for source, dest in self.links:
742
+ self._copy(source, dest)
743
+
744
+ src = self._dir(root)
745
+ names = os.listdir(src)
746
+ for name in names:
747
+ new_path, new_dir_fd = _at(self.dest, name)
748
+ try:
749
+ os.stat(new_path, dir_fd=new_dir_fd, follow_symlinks=False)
750
+ except FileNotFoundError:
751
+ continue
752
+ raise Error(f"{name} exists in the destination")
753
+ for name in names:
754
+ path, dir_fd = _at(src, name)
755
+ new_path, new_dir_fd = _at(self.dest, name)
756
+ os.rename(path, new_path, src_dir_fd=dir_fd, dst_dir_fd=new_dir_fd)
757
+ return "/".join(root)
758
+
759
+
760
+ def extract(
761
+ src: Union[str, "os.PathLike[str]", IO[bytes]],
762
+ dest: str,
763
+ *,
764
+ root_marker: Optional[str] = None,
765
+ file_mode: int = 0o755,
766
+ dir_mode: int = 0o755,
767
+ gid: Optional[int] = None,
768
+ ) -> str:
769
+ """Extract the tar archive ``src``, a path or a binary file object that is read front to
770
+ back once, into ``dest``, which is created if it does not exist. Entries already in
771
+ ``dest`` are not replaced: an archive with one of the same name is an error.
772
+
773
+ With ``root_marker``, only the contents of the parent of the shallowest member with that
774
+ name are extracted, and the archive name of that parent is returned. Other members must be
775
+ in it, except for the dirs leading up to it. Without, everything is extracted and ``""`` is
776
+ returned. No path longer than a single name is passed to the operating system, and nothing
777
+ is written through a symlink.
778
+
779
+ Files are created with ``file_mode``, without executable bits if the member is not
780
+ executable by its owner, and dirs with ``dir_mode``; with ``gid``, both get that group.
781
+ Timestamps and ownership in the archive are not restored. Members must be regular files,
782
+ dirs, symlinks or hardlinks, and cannot be absolute or contain ``..``. Where links cannot
783
+ be created, their targets are copied."""
784
+ if gid is not None and _WINDOWS:
785
+ raise ValueError("gid is not supported on Windows")
786
+ os.makedirs(dest, exist_ok=True)
787
+ extractor = _Extractor(dest, root_marker, file_mode, dir_mode, gid)
788
+ try:
789
+ with _open_archive(src) as stream:
790
+ reader = TarReader(stream)
791
+ for member in reader:
792
+ extractor.add(member, reader)
793
+ # Read the rest of the stream, so that a gzip checksum is verified
794
+ while stream.read(_BUF):
795
+ pass
796
+ return extractor.finish()
797
+ finally:
798
+ extractor.close()