untar 0.1.0__tar.gz

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,78 @@
1
+ name: ci
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ strategy:
11
+ fail-fast: false
12
+ matrix:
13
+ os: [ubuntu-latest]
14
+ python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14"]
15
+ include:
16
+ - os: macos-latest
17
+ python-version: "3.14"
18
+ - os: windows-latest
19
+ python-version: "3.14"
20
+ runs-on: ${{ matrix.os }}
21
+ steps:
22
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
23
+ - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
24
+ with:
25
+ python-version: ${{ matrix.python-version }}
26
+ - run: pip install -e '.[dev]'
27
+ - run: python tests/vendor/fetch_cpython_testdata.py cpython-testdata
28
+ - run: pytest -rs
29
+ env:
30
+ UNTAR_CPYTHON_TESTDATA: cpython-testdata
31
+
32
+ wheel:
33
+ runs-on: ubuntu-latest
34
+ steps:
35
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
36
+ - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
37
+ with:
38
+ python-version: "3.14"
39
+ - run: pip install build
40
+ - run: python -m build --wheel
41
+ - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
42
+ with:
43
+ name: wheel
44
+ path: dist/
45
+
46
+ # Spack still supports Python 3.6, for which hatchling and hypothesis are too new: test the
47
+ # wheel built above, which also checks that old pip installs it.
48
+ test-old:
49
+ needs: wheel
50
+ strategy:
51
+ fail-fast: false
52
+ matrix:
53
+ python-version: ["3.6", "3.7"]
54
+ runs-on: ubuntu-latest
55
+ container: python:${{ matrix.python-version }}
56
+ steps:
57
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
58
+ - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
59
+ with:
60
+ name: wheel
61
+ path: dist/
62
+ - run: pip install pytest dist/*.whl
63
+ - run: python tests/vendor/fetch_cpython_testdata.py cpython-testdata
64
+ - run: rm untar.py && pytest -rs
65
+ env:
66
+ UNTAR_CPYTHON_TESTDATA: cpython-testdata
67
+
68
+ lint:
69
+ runs-on: ubuntu-latest
70
+ steps:
71
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
72
+ - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
73
+ with:
74
+ python-version: "3.14"
75
+ - run: pip install -e '.[dev]'
76
+ - run: ruff check
77
+ - run: ruff format --diff
78
+ - run: mypy
@@ -0,0 +1,36 @@
1
+ name: release
2
+
3
+ on:
4
+ push:
5
+ tags: ["v*"]
6
+
7
+ jobs:
8
+ build:
9
+ runs-on: ubuntu-latest
10
+ steps:
11
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
12
+ - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
13
+ with:
14
+ python-version: "3.14"
15
+ - run: pip install build twine
16
+ - run: python -m build
17
+ - run: twine check --strict dist/*
18
+ - name: The tag matches the version
19
+ run: test "${GITHUB_REF_NAME}" = "v$(python -c 'import untar; print(untar.__version__)')"
20
+ - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
21
+ with:
22
+ name: dist
23
+ path: dist/
24
+
25
+ publish:
26
+ needs: build
27
+ runs-on: ubuntu-latest
28
+ environment: pypi
29
+ permissions:
30
+ id-token: write
31
+ steps:
32
+ - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
33
+ with:
34
+ name: dist
35
+ path: dist/
36
+ - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
untar-0.1.0/.gitignore ADDED
@@ -0,0 +1,9 @@
1
+ __pycache__/
2
+ .ruff_cache/
3
+ .mypy_cache/
4
+ .pytest_cache/
5
+ .hypothesis/
6
+ *.egg-info/
7
+ dist/
8
+ .venv/
9
+ cpython-testdata/
untar-0.1.0/LICENSE ADDED
@@ -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-0.1.0/PKG-INFO ADDED
@@ -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.
untar-0.1.0/README.md ADDED
@@ -0,0 +1,59 @@
1
+ # untar
2
+
3
+ Fast, safe, streaming extraction of tar archives that distribute software, in a single pure
4
+ Python module without dependencies, for Python 3.6 and later.
5
+
6
+ ```python
7
+ import untar
8
+
9
+ # Extract an archive, from a path or any readable binary stream
10
+ untar.extract("pkg.tar.gz", "/opt/pkg")
11
+
12
+ # Extract the dir that contains `.meta`, wherever it is in the archive
13
+ root = untar.extract("pkg.tar.gz", "/opt/pkg", root_marker=".meta")
14
+ ```
15
+
16
+ ## Scope
17
+
18
+ untar is for installing software from archives, not for restoring backups. Owners, groups,
19
+ timestamps and permissions recorded in an archive describe the machine it was made on; on
20
+ another machine they are meaningless at best and a liability at worst. So untar does not
21
+ restore them: files are created with `file_mode` (without executable bits where the archive
22
+ has none for the owner), dirs with `dir_mode`, optionally with group `gid`, and only regular
23
+ files, dirs, symlinks and hardlinks are extracted.
24
+
25
+ ## Features
26
+
27
+ - **One pass.** The archive is read front to back once, without seeking, from a file, pipe or
28
+ network stream. Gzip is detected from the first bytes.
29
+ - **Less work per member.** Only the header fields that extraction needs are parsed (ustar,
30
+ pax and GNU long names), and a file costs an `open`, `write`s and a `close`.
31
+ - **A root only known from the contents.** With `root_marker`, the parent of the shallowest
32
+ member with that name is extracted, like `tar --strip-components` with a count that is only
33
+ known after the last member.
34
+ - **No path length limits.** Entries are created relative to open dir file descriptors, so no
35
+ path longer than one name is passed to the operating system.
36
+
37
+ ## Safety
38
+
39
+ Nothing is written outside the destination, whatever the archive contains. Absolute names and
40
+ `..` are rejected, dirs are opened with `O_NOFOLLOW` so nothing is written through a symlink,
41
+ and files are created with `O_EXCL | O_NOFOLLOW` so existing entries are never replaced.
42
+ Symlinks may point anywhere, but untar never follows them. On Windows, which has no dir file
43
+ descriptors, these checks are path based and not safe against concurrent changes.
44
+
45
+ Archives that tar implementations read differently are rejected, so that no member can hide
46
+ from one of them: numbers must be plain digits, a member's name and size come from one header
47
+ only, and global pax headers cannot set them. The gzip checksum is always verified.
48
+
49
+ ## Development
50
+
51
+ ```console
52
+ $ pip install -e '.[dev]'
53
+ $ python tests/vendor/fetch_cpython_testdata.py cpython-testdata
54
+ $ UNTAR_CPYTHON_TESTDATA=cpython-testdata pytest
55
+ $ ruff check && ruff format --diff && mypy
56
+ ```
57
+
58
+ `tests/vendor/test_cpython.py` is adapted from CPython's `test_tarfile.py` and is under the PSF
59
+ License Agreement included in it. The rest of untar is under the MIT license.
@@ -0,0 +1,49 @@
1
+ [project]
2
+ name = "untar"
3
+ description = "Fast, safe, streaming extraction of tar archives that distribute software"
4
+ readme = "README.md"
5
+ requires-python = ">=3.6"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ authors = [{ name = "Harmen Stoppels", email = "me@harmenstoppels.nl" }]
9
+ keywords = ["tar", "archive", "extract"]
10
+ classifiers = [
11
+ "Programming Language :: Python :: 3",
12
+ "Programming Language :: Python :: 3 :: Only",
13
+ "Topic :: System :: Archiving",
14
+ ]
15
+ dependencies = []
16
+ dynamic = ["version"]
17
+
18
+ [project.urls]
19
+ Homepage = "https://github.com/haampie/untar"
20
+ Repository = "https://github.com/haampie/untar"
21
+
22
+ [project.optional-dependencies]
23
+ dev = ["pytest", "hypothesis", "ruff", "mypy"]
24
+
25
+ [build-system]
26
+ requires = ["hatchling"]
27
+ build-backend = "hatchling.build"
28
+
29
+ [tool.hatch.version]
30
+ path = "untar.py"
31
+
32
+ [tool.hatch.build.targets.wheel]
33
+ only-include = ["untar.py"]
34
+
35
+ # The tests adapted from CPython are under the PSF license; keep distributions MIT only
36
+ [tool.hatch.build.targets.sdist]
37
+ exclude = ["tests/vendor"]
38
+
39
+ [tool.mypy]
40
+ files = ["untar.py"]
41
+
42
+ [tool.pytest.ini_options]
43
+ testpaths = ["tests"]
44
+
45
+ [tool.ruff]
46
+ # The oldest target ruff supports; requires-python is lower
47
+ target-version = "py37"
48
+ # from __future__ import annotations needs Python 3.7
49
+ lint.ignore = ["FA100"]
@@ -0,0 +1,217 @@
1
+ """Structure-aware fuzzing: one header field at a time gets an odd value, and the checksum is
2
+ recomputed, so that mutations get past the checksum. Whatever the archive, reading must make
3
+ progress and raise only untar.Error, and extracting must raise only untar.Error or OSError and
4
+ change nothing outside the destination."""
5
+
6
+ import errno
7
+ import gzip
8
+ import io
9
+ import itertools
10
+ import os
11
+ import random
12
+ import shutil
13
+ import tarfile
14
+
15
+ import pytest
16
+ from test_untar import SAMPLE, make_tar, needs_symlinks, snapshot
17
+
18
+ import untar
19
+
20
+ FIELDS = {
21
+ "name": (0, 100),
22
+ "mode": (100, 108),
23
+ "size": (124, 136),
24
+ "type": (156, 157),
25
+ "linkname": (157, 257),
26
+ "magic": (257, 265),
27
+ "prefix": (345, 500),
28
+ }
29
+
30
+ NUMBERS = [
31
+ b"-1777",
32
+ b"+17",
33
+ b"0o17",
34
+ b"0_17",
35
+ b"1 7",
36
+ b"",
37
+ b" " * 11,
38
+ b"0",
39
+ b"1",
40
+ b"777",
41
+ b"77777777777",
42
+ b"\x80" + bytes(10) + b"\x10",
43
+ b"\x80" + b"\xff" * 11,
44
+ b"\xff" * 12,
45
+ b"\xd9\xa3",
46
+ ]
47
+
48
+ VALUES = {
49
+ "name": [
50
+ b"",
51
+ b".",
52
+ b"..",
53
+ b"/abs",
54
+ b"a/../b",
55
+ b"a//b",
56
+ b"./a",
57
+ b"a/",
58
+ b"a/.",
59
+ b"\xff\xfe",
60
+ ],
61
+ "mode": NUMBERS,
62
+ "size": NUMBERS,
63
+ "type": [bytes([t]) for t in b"012345675xgLKSXZ\0"],
64
+ "linkname": [b"", b".", b"..", b"/etc", b"pkg", b"../pkg", b"pkg/bin/app", b"\0x"],
65
+ "magic": [b"ustar\x0000", b"ustar \0", b"ustar\0\0\0", bytes(8)],
66
+ "prefix": [b"", b"pkg", b"/", b"..", b"a/b"],
67
+ }
68
+
69
+ RECORDS = [
70
+ b"5 abc\n",
71
+ b"6 =abc\n",
72
+ b"10 size=0\n",
73
+ b"13 size=-512\n",
74
+ b"11 size=1_0\n",
75
+ b"13 path=a/..\n",
76
+ b"12 path=a\0b\n",
77
+ b"9 path=\n",
78
+ b"16 linkpath=/etc\n",
79
+ b"+9 path=x\n",
80
+ b"99 path=x\n",
81
+ b"25 GNU.sparse.size=1000\n",
82
+ ]
83
+
84
+
85
+ def archives():
86
+ gnu = make_tar(SAMPLE, tarfile.GNU_FORMAT)
87
+ pax = make_tar(SAMPLE, tarfile.PAX_FORMAT)
88
+ out = io.BytesIO()
89
+ with tarfile.open(fileobj=out, mode="w", format=tarfile.PAX_FORMAT) as tar:
90
+ tar.pax_headers = {"comment": "global"}
91
+ for name in ("a", "b/c"):
92
+ info = tarfile.TarInfo(name)
93
+ info.size = 3
94
+ tar.addfile(info, io.BytesIO(b"abc"))
95
+ return [gnu, pax, out.getvalue()]
96
+
97
+
98
+ def headers(archive: bytes):
99
+ """Offsets of the blocks that are headers."""
100
+ offsets = []
101
+ for offset in range(0, len(archive), 512):
102
+ block = archive[offset : offset + 512]
103
+ try:
104
+ if block != bytes(512) and untar._checksum_ok(block):
105
+ offsets.append(offset)
106
+ except untar.Error:
107
+ pass
108
+ return offsets
109
+
110
+
111
+ def checksum(block: bytearray) -> None:
112
+ block[148:156] = b" " * 8
113
+ block[148:156] = b"%06o\0 " % sum(block)
114
+
115
+
116
+ def mutate(archive: bytes, rng: random.Random) -> bytes:
117
+ data = bytearray(archive)
118
+ for _ in range(rng.choice([1, 1, 2, 3])):
119
+ offset = rng.choice(headers(archive))
120
+ if data[offset + 156 : offset + 157] in (b"x", b"g") and rng.random() < 0.3:
121
+ # Replace the pax records, keeping the header's size
122
+ records = b"".join(rng.sample(RECORDS, rng.randint(1, 3)))
123
+ try:
124
+ size = untar._int(bytes(data[offset + 124 : offset + 136]))
125
+ except untar.Error:
126
+ continue
127
+ data[offset + 512 : offset + 512 + size] = records[:size].ljust(size, b"\0")
128
+ continue
129
+ field = rng.choice(list(FIELDS))
130
+ start, end = FIELDS[field]
131
+ value = rng.choice(VALUES[field])
132
+ if rng.random() < 0.1:
133
+ value = bytes(rng.randrange(256) for _ in range(end - start))
134
+ data[offset + start : offset + end] = value[: end - start].ljust(
135
+ end - start, b"\0"
136
+ )
137
+ block = data[offset : offset + 512]
138
+ checksum(block)
139
+ data[offset : offset + 512] = block
140
+ return bytes(data)
141
+
142
+
143
+ def check_reader(archive: bytes) -> None:
144
+ """The reader ends within the number of blocks, and raises only untar.Error."""
145
+ limit = len(archive) // 512 + 1
146
+ reader = untar.TarReader(io.BytesIO(archive))
147
+ try:
148
+ count = 0
149
+ for _ in itertools.islice(reader, limit + 1):
150
+ for _ in reader.chunks():
151
+ pass
152
+ count += 1
153
+ assert count <= limit, "the reader does not make progress"
154
+ except untar.Error:
155
+ pass
156
+
157
+
158
+ def check_extract(archive: bytes, outer) -> None:
159
+ """Extracting raises only untar.Error or OSError, and changes nothing outside dest."""
160
+ flag = outer / "flag"
161
+ dest = outer / "dest"
162
+ try:
163
+ untar.extract(io.BytesIO(archive), str(dest))
164
+ except (untar.Error, OSError):
165
+ pass
166
+ assert sorted(os.listdir(outer)) == ["dest", "flag"]
167
+ assert flag.read_bytes() == b"flag"
168
+ assert not [n for n in os.listdir(dest) if n.startswith(".untar-")]
169
+ shutil.rmtree(dest)
170
+
171
+
172
+ @needs_symlinks
173
+ @pytest.mark.parametrize("links", ["supported", "unsupported"])
174
+ def test_fuzz_fields(tmp_path, monkeypatch, links):
175
+ if links == "unsupported":
176
+
177
+ def fail(*args, **kwargs):
178
+ raise OSError(errno.EPERM, "links are not supported")
179
+
180
+ monkeypatch.setattr(os, "symlink", fail)
181
+ monkeypatch.setattr(os, "link", fail)
182
+ outer = tmp_path / "outer"
183
+ outer.mkdir()
184
+ (outer / "flag").write_bytes(b"flag")
185
+ rng = random.Random(f"fields-{links}")
186
+ bases = archives()
187
+ for _ in range(int(os.environ.get("UNTAR_FUZZ", "600"))):
188
+ archive = mutate(rng.choice(bases), rng)
189
+ check_reader(archive)
190
+ check_extract(archive, outer)
191
+
192
+
193
+ @needs_symlinks
194
+ def test_fuzz_gzip(tmp_path):
195
+ """Truncated or corrupted gzip fails, or extracts exactly the original tree."""
196
+ plain = make_tar(SAMPLE)
197
+ untar.extract(io.BytesIO(plain), str(tmp_path / "expected"))
198
+ expected = snapshot(tmp_path / "expected")
199
+ compressed = gzip.compress(plain)
200
+ rng = random.Random("gzip")
201
+ outcomes = {"error": 0, "same": 0}
202
+ for i in range(300):
203
+ data = bytearray(compressed)
204
+ if rng.random() < 0.3:
205
+ data = data[: rng.randrange(len(data))]
206
+ else:
207
+ for _ in range(rng.randint(1, 4)):
208
+ data[rng.randrange(len(data))] ^= 1 << rng.randrange(8)
209
+ dest = tmp_path / str(i)
210
+ try:
211
+ untar.extract(io.BytesIO(bytes(data)), str(dest))
212
+ except untar.Error:
213
+ outcomes["error"] += 1
214
+ continue
215
+ assert snapshot(dest) == expected
216
+ outcomes["same"] += 1
217
+ assert outcomes["error"] > 0
@@ -0,0 +1,79 @@
1
+ """Property test against tarfile, which needs hypothesis."""
2
+
3
+ import io
4
+ import os
5
+ import tarfile
6
+
7
+ import pytest
8
+ from test_untar import make_tar, needs_symlinks, snapshot, tarfile_extract
9
+
10
+ import untar
11
+
12
+ hypothesis = pytest.importorskip("hypothesis")
13
+ st = hypothesis.strategies
14
+
15
+ names = st.sampled_from(["a", "b", "lib", ".meta", "x" * 120, "ü", "-x"])
16
+
17
+
18
+ @st.composite
19
+ def archives(draw):
20
+ """Random valid archives: a tree in random order, possibly without some dir entries."""
21
+ paths = draw(
22
+ st.lists(st.lists(names, min_size=1, max_size=4), min_size=1, max_size=12)
23
+ )
24
+ members, files, seen = [], [], set()
25
+ for parts in paths:
26
+ for i in range(1, len(parts)):
27
+ seen.add("/".join(parts[:i]))
28
+ name = "/".join(parts)
29
+ if name in seen:
30
+ continue
31
+ seen.add(name)
32
+ kind = draw(st.sampled_from("fffsh" if files else "ffs"))
33
+ if kind == "f":
34
+ members.append(
35
+ (
36
+ name,
37
+ "f",
38
+ draw(st.binary(max_size=2000)),
39
+ draw(st.sampled_from([0o644, 0o755])),
40
+ )
41
+ )
42
+ files.append(name)
43
+ elif kind == "s":
44
+ members.append(
45
+ (name, "s", draw(st.sampled_from(["a", "../b", "/abs", "x/y"])), 0o777)
46
+ )
47
+ else:
48
+ # like tar, a hardlink records the mode of the inode it shares
49
+ target = draw(st.sampled_from(files))
50
+ mode = next(m[3] for m in members if m[0] == target)
51
+ members.append((name, "h", target, mode))
52
+ dirs = sorted(seen - {m[0] for m in members})
53
+ members += [(d, "d", None, 0o755) for d in dirs if draw(st.booleans())]
54
+ # no member below a non-dir, and hardlinks after their targets
55
+ nondirs = {m[0] for m in members if m[1] != "d"}
56
+ members = [m for m in members if not any(m[0].startswith(n + "/") for n in nondirs)]
57
+ placed, result = set(), []
58
+ for m in draw(st.permutations(members)):
59
+ if m[1] == "h" and m[2] not in placed:
60
+ continue
61
+ placed.add(m[0])
62
+ result.append(m)
63
+ return result, draw(st.sampled_from([tarfile.GNU_FORMAT, tarfile.PAX_FORMAT]))
64
+
65
+
66
+ @needs_symlinks
67
+ @hypothesis.settings(
68
+ max_examples=int(os.environ.get("UNTAR_EXAMPLES", "300")),
69
+ deadline=None,
70
+ suppress_health_check=list(hypothesis.HealthCheck),
71
+ )
72
+ @hypothesis.given(archive=archives())
73
+ def test_property_same_tree_as_tarfile(tmp_path_factory, archive):
74
+ members, format = archive
75
+ tmp = tmp_path_factory.mktemp("p")
76
+ data = make_tar(members, format)
77
+ tarfile_extract(data, tmp / "expected")
78
+ untar.extract(io.BytesIO(data), str(tmp / "actual"))
79
+ assert snapshot(tmp / "actual") == snapshot(tmp / "expected")