Skip to content

Commit 05798a9

Browse files
Copilotmnriem
andauthored
Skip PAX/GNU metadata members in safe_extract_tarball; use standard mock imports in workflow tests
Agent-Logs-Url: https://github.com/github/spec-kit/sessions/c1fcc1ff-8766-4d97-90a5-368447980acf Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>
1 parent bd04937 commit 05798a9

2 files changed

Lines changed: 30 additions & 28 deletions

File tree

src/specify_cli/extensions.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,15 @@ def safe_extract_tarball(
170170
error_class: If any member is unsafe or the archive cannot be read.
171171
"""
172172
dest_resolved = dest_dir.resolve()
173+
# Tar metadata member types to skip during validation — they carry no
174+
# extractable payload and are generated automatically by many common
175+
# archiving tools (e.g. PAX headers, GNU longname/longlink entries).
176+
_TAR_METADATA_TYPES = (
177+
tarfile.XHDTYPE, # PAX extended header
178+
tarfile.XGLTYPE, # PAX global extended header
179+
tarfile.SOLARIS_XHDTYPE, # Solaris PAX extended header
180+
*tarfile.GNU_TYPES, # GNU longname / longlink / sparse
181+
)
173182

174183
try:
175184
with tarfile.open(archive_path, "r:gz") as tf:
@@ -195,13 +204,17 @@ def safe_extract_tarball(
195204
f"Unsafe path in tar archive: {member.name} (potential path traversal)"
196205
)
197206

207+
# Skip tar metadata members — they carry no extractable payload.
208+
if member.type in _TAR_METADATA_TYPES:
209+
continue
210+
198211
# Reject symlinks and hard links.
199212
if member.issym() or member.islnk():
200213
raise error_class(
201214
f"Symlinks are not allowed in archive: {member.name}"
202215
)
203216

204-
# Only allow regular files and directories.
217+
# Reject devices, FIFOs and other special file types.
205218
if not (member.isreg() or member.isdir()):
206219
raise error_class(
207220
f"Non-regular file in archive: {member.name}"

tests/test_workflows.py

Lines changed: 16 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1882,15 +1882,14 @@ def _runner_and_app(self):
18821882
def test_workflow_add_local_zip_flat(self, project_dir):
18831883
"""workflow add installs from a local ZIP with workflow.yml at root."""
18841884
import zipfile
1885+
from unittest.mock import patch
18851886
runner, app = self._runner_and_app()
18861887

18871888
archive = project_dir / "workflow.zip"
18881889
with zipfile.ZipFile(archive, "w") as zf:
18891890
zf.writestr("workflow.yml", MINIMAL_WORKFLOW_YAML)
18901891

1891-
with __import__("unittest.mock", fromlist=["patch"]).patch.object(
1892-
__import__("pathlib", fromlist=["Path"]).Path, "cwd", return_value=project_dir
1893-
):
1892+
with patch.object(Path, "cwd", return_value=project_dir):
18941893
result = runner.invoke(app, ["workflow", "add", str(archive)], catch_exceptions=False)
18951894

18961895
assert result.exit_code == 0, result.output
@@ -1901,15 +1900,14 @@ def test_workflow_add_local_zip_flat(self, project_dir):
19011900
def test_workflow_add_local_zip_nested(self, project_dir):
19021901
"""workflow add installs from a local ZIP with workflow.yml in a subdirectory."""
19031902
import zipfile
1903+
from unittest.mock import patch
19041904
runner, app = self._runner_and_app()
19051905

19061906
archive = project_dir / "workflow.zip"
19071907
with zipfile.ZipFile(archive, "w") as zf:
19081908
zf.writestr("repo-1.0/workflow.yml", MINIMAL_WORKFLOW_YAML)
19091909

1910-
with __import__("unittest.mock", fromlist=["patch"]).patch.object(
1911-
__import__("pathlib", fromlist=["Path"]).Path, "cwd", return_value=project_dir
1912-
):
1910+
with patch.object(Path, "cwd", return_value=project_dir):
19131911
result = runner.invoke(app, ["workflow", "add", str(archive)], catch_exceptions=False)
19141912

19151913
assert result.exit_code == 0, result.output
@@ -1918,15 +1916,14 @@ def test_workflow_add_local_zip_nested(self, project_dir):
19181916
def test_workflow_add_local_zip_missing_workflow_yml(self, project_dir):
19191917
"""workflow add exits with an error when the ZIP has no workflow.yml."""
19201918
import zipfile
1919+
from unittest.mock import patch
19211920
runner, app = self._runner_and_app()
19221921

19231922
archive = project_dir / "empty.zip"
19241923
with zipfile.ZipFile(archive, "w") as zf:
19251924
zf.writestr("README.md", "nothing here")
19261925

1927-
with __import__("unittest.mock", fromlist=["patch"]).patch.object(
1928-
__import__("pathlib", fromlist=["Path"]).Path, "cwd", return_value=project_dir
1929-
):
1926+
with patch.object(Path, "cwd", return_value=project_dir):
19301927
result = runner.invoke(app, ["workflow", "add", str(archive)], catch_exceptions=True)
19311928

19321929
assert result.exit_code != 0
@@ -1937,6 +1934,7 @@ def test_workflow_add_local_zip_missing_workflow_yml(self, project_dir):
19371934
def test_workflow_add_local_tar_gz_flat(self, project_dir):
19381935
"""workflow add installs from a local .tar.gz with workflow.yml at root."""
19391936
import tarfile, io
1937+
from unittest.mock import patch
19401938
runner, app = self._runner_and_app()
19411939

19421940
archive = project_dir / "workflow.tar.gz"
@@ -1946,9 +1944,7 @@ def test_workflow_add_local_tar_gz_flat(self, project_dir):
19461944
info.size = len(data)
19471945
tf.addfile(info, io.BytesIO(data))
19481946

1949-
with __import__("unittest.mock", fromlist=["patch"]).patch.object(
1950-
__import__("pathlib", fromlist=["Path"]).Path, "cwd", return_value=project_dir
1951-
):
1947+
with patch.object(Path, "cwd", return_value=project_dir):
19521948
result = runner.invoke(app, ["workflow", "add", str(archive)], catch_exceptions=False)
19531949

19541950
assert result.exit_code == 0, result.output
@@ -1959,6 +1955,7 @@ def test_workflow_add_local_tar_gz_flat(self, project_dir):
19591955
def test_workflow_add_local_tar_gz_nested(self, project_dir):
19601956
"""workflow add installs from a local .tar.gz with workflow.yml in a subdirectory."""
19611957
import tarfile, io
1958+
from unittest.mock import patch
19621959
runner, app = self._runner_and_app()
19631960

19641961
archive = project_dir / "workflow.tar.gz"
@@ -1968,9 +1965,7 @@ def test_workflow_add_local_tar_gz_nested(self, project_dir):
19681965
info.size = len(data)
19691966
tf.addfile(info, io.BytesIO(data))
19701967

1971-
with __import__("unittest.mock", fromlist=["patch"]).patch.object(
1972-
__import__("pathlib", fromlist=["Path"]).Path, "cwd", return_value=project_dir
1973-
):
1968+
with patch.object(Path, "cwd", return_value=project_dir):
19741969
result = runner.invoke(app, ["workflow", "add", str(archive)], catch_exceptions=False)
19751970

19761971
assert result.exit_code == 0, result.output
@@ -1979,6 +1974,7 @@ def test_workflow_add_local_tar_gz_nested(self, project_dir):
19791974
def test_workflow_add_local_tgz_flat(self, project_dir):
19801975
"""workflow add recognises the .tgz extension as a gzipped tarball."""
19811976
import tarfile, io
1977+
from unittest.mock import patch
19821978
runner, app = self._runner_and_app()
19831979

19841980
archive = project_dir / "workflow.tgz"
@@ -1988,9 +1984,7 @@ def test_workflow_add_local_tgz_flat(self, project_dir):
19881984
info.size = len(data)
19891985
tf.addfile(info, io.BytesIO(data))
19901986

1991-
with __import__("unittest.mock", fromlist=["patch"]).patch.object(
1992-
__import__("pathlib", fromlist=["Path"]).Path, "cwd", return_value=project_dir
1993-
):
1987+
with patch.object(Path, "cwd", return_value=project_dir):
19941988
result = runner.invoke(app, ["workflow", "add", str(archive)], catch_exceptions=False)
19951989

19961990
assert result.exit_code == 0, result.output
@@ -1999,6 +1993,7 @@ def test_workflow_add_local_tgz_flat(self, project_dir):
19991993
def test_workflow_add_local_tar_gz_missing_workflow_yml(self, project_dir):
20001994
"""workflow add exits with an error when the .tar.gz has no workflow.yml."""
20011995
import tarfile, io
1996+
from unittest.mock import patch
20021997
runner, app = self._runner_and_app()
20031998

20041999
archive = project_dir / "empty.tar.gz"
@@ -2008,9 +2003,7 @@ def test_workflow_add_local_tar_gz_missing_workflow_yml(self, project_dir):
20082003
info.size = len(data)
20092004
tf.addfile(info, io.BytesIO(data))
20102005

2011-
with __import__("unittest.mock", fromlist=["patch"]).patch.object(
2012-
__import__("pathlib", fromlist=["Path"]).Path, "cwd", return_value=project_dir
2013-
):
2006+
with patch.object(Path, "cwd", return_value=project_dir):
20142007
result = runner.invoke(app, ["workflow", "add", str(archive)], catch_exceptions=True)
20152008

20162009
assert result.exit_code != 0
@@ -2041,9 +2034,7 @@ def test_workflow_add_url_tar_gz(self, project_dir):
20412034
mock_resp.__exit__ = MagicMock(return_value=False)
20422035

20432036
with patch("urllib.request.urlopen", return_value=mock_resp), \
2044-
patch.object(
2045-
__import__("pathlib", fromlist=["Path"]).Path, "cwd", return_value=project_dir
2046-
):
2037+
patch.object(Path, "cwd", return_value=project_dir):
20472038
result = runner.invoke(
20482039
app, ["workflow", "add", "https://example.com/workflow.tar.gz"],
20492040
catch_exceptions=False,
@@ -2071,9 +2062,7 @@ def test_workflow_add_url_zip(self, project_dir):
20712062
mock_resp.__exit__ = MagicMock(return_value=False)
20722063

20732064
with patch("urllib.request.urlopen", return_value=mock_resp), \
2074-
patch.object(
2075-
__import__("pathlib", fromlist=["Path"]).Path, "cwd", return_value=project_dir
2076-
):
2065+
patch.object(Path, "cwd", return_value=project_dir):
20772066
result = runner.invoke(
20782067
app, ["workflow", "add", "https://example.com/workflow.zip"],
20792068
catch_exceptions=False,

0 commit comments

Comments
 (0)