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.
- untar-0.1.0/.github/workflows/ci.yml +78 -0
- untar-0.1.0/.github/workflows/release.yml +36 -0
- untar-0.1.0/.gitignore +9 -0
- untar-0.1.0/LICENSE +21 -0
- untar-0.1.0/PKG-INFO +80 -0
- untar-0.1.0/README.md +59 -0
- untar-0.1.0/pyproject.toml +49 -0
- untar-0.1.0/tests/test_fuzz.py +217 -0
- untar-0.1.0/tests/test_property.py +79 -0
- untar-0.1.0/tests/test_strict.py +320 -0
- untar-0.1.0/tests/test_untar.py +501 -0
- untar-0.1.0/untar.py +798 -0
|
@@ -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
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")
|