tenseleyflow/documentlanguagemodel / 54e7e6a

Browse files

Refuse executable files in packs

Authored by espadonne
SHA
54e7e6a7f78fa9afcf45db06aa9c5e133c34d675
Parents
1fdf194
Tree
9d43d1e

5 changed files

StatusFile+-
M src/dlm/cli/commands.py 4 1
M src/dlm/pack/__init__.py 2 0
M src/dlm/pack/errors.py 16 0
M src/dlm/pack/packer.py 11 1
M tests/unit/pack/test_packer.py 18 1
src/dlm/cli/commands.pymodified
@@ -1944,7 +1944,7 @@ def pack_cmd(
1944
     from rich.console import Console
1944
     from rich.console import Console
1945
 
1945
 
1946
     from dlm.doc.errors import DlmParseError
1946
     from dlm.doc.errors import DlmParseError
1947
-    from dlm.pack.errors import BaseLicenseRefusedError
1947
+    from dlm.pack.errors import BaseLicenseRefusedError, PackError
1948
     from dlm.pack.packer import pack
1948
     from dlm.pack.packer import pack
1949
 
1949
 
1950
     console = Console(stderr=True)
1950
     console = Console(stderr=True)
@@ -1961,6 +1961,9 @@ def pack_cmd(
1961
     except BaseLicenseRefusedError as exc:
1961
     except BaseLicenseRefusedError as exc:
1962
         console.print(f"[red]pack:[/red] {exc}")
1962
         console.print(f"[red]pack:[/red] {exc}")
1963
         raise typer.Exit(code=1) from exc
1963
         raise typer.Exit(code=1) from exc
1964
+    except PackError as exc:
1965
+        console.print(f"[red]pack:[/red] {exc}")
1966
+        raise typer.Exit(code=1) from exc
1964
     except DlmParseError as exc:
1967
     except DlmParseError as exc:
1965
         console.print(f"[red]parse:[/red] {exc}")
1968
         console.print(f"[red]parse:[/red] {exc}")
1966
         raise typer.Exit(code=1) from exc
1969
         raise typer.Exit(code=1) from exc
src/dlm/pack/__init__.pymodified
@@ -21,6 +21,7 @@ from __future__ import annotations
21
 from dlm.pack.errors import (
21
 from dlm.pack.errors import (
22
     BaseLicenseRefusedError,
22
     BaseLicenseRefusedError,
23
     PackError,
23
     PackError,
24
+    PackExecutableFileError,
24
     PackFormatVersionError,
25
     PackFormatVersionError,
25
     PackIntegrityError,
26
     PackIntegrityError,
26
     PackLayoutError,
27
     PackLayoutError,
@@ -45,6 +46,7 @@ __all__ = [
45
     "HEADER_FILENAME",
46
     "HEADER_FILENAME",
46
     "MANIFEST_FILENAME",
47
     "MANIFEST_FILENAME",
47
     "PackError",
48
     "PackError",
49
+    "PackExecutableFileError",
48
     "PackFormatVersionError",
50
     "PackFormatVersionError",
49
     "PackHeader",
51
     "PackHeader",
50
     "PackIntegrityError",
52
     "PackIntegrityError",
src/dlm/pack/errors.pymodified
@@ -74,3 +74,19 @@ class BaseLicenseRefusedError(PackError):
74
         )
74
         )
75
         self.base_key = base_key
75
         self.base_key = base_key
76
         self.license_url = license_url
76
         self.license_url = license_url
77
+
78
+
79
+class PackExecutableFileError(PackError):
80
+    """Pack refused because a would-be-packed store file has execute bits set.
81
+
82
+    The pack format normalizes file modes to deterministic 0o644 / 0o755
83
+    headers. Refusing executable files keeps that normalization honest:
84
+    we never silently strip an x-bit from user data.
85
+    """
86
+
87
+    def __init__(self, relpath: str) -> None:
88
+        super().__init__(
89
+            f"store file {relpath!r} is executable; pack refuses to silently strip "
90
+            "the x-bit. Clear the executable bit or move the file out of the store."
91
+        )
92
+        self.relpath = relpath
src/dlm/pack/packer.pymodified
@@ -41,7 +41,7 @@ from datetime import UTC, datetime
41
 from pathlib import Path
41
 from pathlib import Path
42
 from typing import TYPE_CHECKING, Any
42
 from typing import TYPE_CHECKING, Any
43
 
43
 
44
-from dlm.pack.errors import BaseLicenseRefusedError
44
+from dlm.pack.errors import BaseLicenseRefusedError, PackExecutableFileError
45
 from dlm.pack.format import (
45
 from dlm.pack.format import (
46
     CURRENT_PACK_FORMAT_VERSION,
46
     CURRENT_PACK_FORMAT_VERSION,
47
     ContentType,
47
     ContentType,
@@ -264,6 +264,7 @@ def _stage_tree(
264
         if relpath == store.lock.name:
264
         if relpath == store.lock.name:
265
             # The lockfile itself is state-of-this-process only.
265
             # The lockfile itself is state-of-this-process only.
266
             continue
266
             continue
267
+        _assert_no_executable_files(child, store_root=store.root)
267
         dest = store_dst / relpath
268
         dest = store_dst / relpath
268
         if child.is_dir():
269
         if child.is_dir():
269
             shutil.copytree(child, dest, symlinks=False)
270
             shutil.copytree(child, dest, symlinks=False)
@@ -271,6 +272,15 @@ def _stage_tree(
271
             shutil.copy2(child, dest)
272
             shutil.copy2(child, dest)
272
 
273
 
273
 
274
 
275
+def _assert_no_executable_files(path: Path, *, store_root: Path) -> None:
276
+    """Refuse pack inputs that would lose executable bits in tar normalization."""
277
+
278
+    candidates = [path] if path.is_file() else sorted(p for p in path.rglob("*") if p.is_file())
279
+    for candidate in candidates:
280
+        if candidate.stat().st_mode & 0o111:
281
+            raise PackExecutableFileError(candidate.relative_to(store_root).as_posix())
282
+
283
+
274
 def _normalize_tarinfo(info: tarfile.TarInfo) -> tarfile.TarInfo:
284
 def _normalize_tarinfo(info: tarfile.TarInfo) -> tarfile.TarInfo:
275
     """Strip host-specific metadata so two packs of identical content match byte-for-byte.
285
     """Strip host-specific metadata so two packs of identical content match byte-for-byte.
276
 
286
 
tests/unit/pack/test_packer.pymodified
@@ -12,7 +12,7 @@ import pytest
12
 from typer.testing import CliRunner
12
 from typer.testing import CliRunner
13
 
13
 
14
 from dlm.cli.app import app
14
 from dlm.cli.app import app
15
-from dlm.pack.errors import BaseLicenseRefusedError
15
+from dlm.pack.errors import BaseLicenseRefusedError, PackExecutableFileError
16
 from dlm.pack.packer import _platform_hint, pack
16
 from dlm.pack.packer import _platform_hint, pack
17
 
17
 
18
 
18
 
@@ -224,6 +224,23 @@ class TestStoreLock:
224
         assert observed.get("holder_pid") == _os.getpid()
224
         assert observed.get("holder_pid") == _os.getpid()
225
 
225
 
226
 
226
 
227
+class TestExecutableBitRefusal:
228
+    def test_refuses_executable_file_in_store_tree(self, tmp_path: Path) -> None:
229
+        doc = _scaffold_doc_and_store(tmp_path)
230
+
231
+        from dlm.doc.parser import parse_file
232
+        from dlm.store.paths import for_dlm
233
+
234
+        parsed = parse_file(doc)
235
+        store = for_dlm(parsed.frontmatter.dlm_id)
236
+        hook = store.root / "resume.sh"
237
+        hook.write_text("#!/bin/sh\necho nope\n", encoding="utf-8")
238
+        hook.chmod(0o755)
239
+
240
+        with pytest.raises(PackExecutableFileError, match="resume\\.sh"):
241
+            pack(doc)
242
+
243
+
227
 class TestPlatformHint:
244
 class TestPlatformHint:
228
     def test_import_error_falls_back_to_sys_platform(
245
     def test_import_error_falls_back_to_sys_platform(
229
         self, caplog: pytest.LogCaptureFixture
246
         self, caplog: pytest.LogCaptureFixture