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.
- untar-0.1.0.dist-info/METADATA +80 -0
- untar-0.1.0.dist-info/RECORD +5 -0
- untar-0.1.0.dist-info/WHEEL +4 -0
- untar-0.1.0.dist-info/licenses/LICENSE +21 -0
- untar.py +798 -0
|
@@ -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,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()
|