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(
19441944
     from rich.console import Console
19451945
 
19461946
     from dlm.doc.errors import DlmParseError
1947
-    from dlm.pack.errors import BaseLicenseRefusedError
1947
+    from dlm.pack.errors import BaseLicenseRefusedError, PackError
19481948
     from dlm.pack.packer import pack
19491949
 
19501950
     console = Console(stderr=True)
@@ -1961,6 +1961,9 @@ def pack_cmd(
19611961
     except BaseLicenseRefusedError as exc:
19621962
         console.print(f"[red]pack:[/red] {exc}")
19631963
         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
19641967
     except DlmParseError as exc:
19651968
         console.print(f"[red]parse:[/red] {exc}")
19661969
         raise typer.Exit(code=1) from exc
src/dlm/pack/__init__.pymodified
@@ -21,6 +21,7 @@ from __future__ import annotations
2121
 from dlm.pack.errors import (
2222
     BaseLicenseRefusedError,
2323
     PackError,
24
+    PackExecutableFileError,
2425
     PackFormatVersionError,
2526
     PackIntegrityError,
2627
     PackLayoutError,
@@ -45,6 +46,7 @@ __all__ = [
4546
     "HEADER_FILENAME",
4647
     "MANIFEST_FILENAME",
4748
     "PackError",
49
+    "PackExecutableFileError",
4850
     "PackFormatVersionError",
4951
     "PackHeader",
5052
     "PackIntegrityError",
src/dlm/pack/errors.pymodified
@@ -74,3 +74,19 @@ class BaseLicenseRefusedError(PackError):
7474
         )
7575
         self.base_key = base_key
7676
         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
4141
 from pathlib import Path
4242
 from typing import TYPE_CHECKING, Any
4343
 
44
-from dlm.pack.errors import BaseLicenseRefusedError
44
+from dlm.pack.errors import BaseLicenseRefusedError, PackExecutableFileError
4545
 from dlm.pack.format import (
4646
     CURRENT_PACK_FORMAT_VERSION,
4747
     ContentType,
@@ -264,6 +264,7 @@ def _stage_tree(
264264
         if relpath == store.lock.name:
265265
             # The lockfile itself is state-of-this-process only.
266266
             continue
267
+        _assert_no_executable_files(child, store_root=store.root)
267268
         dest = store_dst / relpath
268269
         if child.is_dir():
269270
             shutil.copytree(child, dest, symlinks=False)
@@ -271,6 +272,15 @@ def _stage_tree(
271272
             shutil.copy2(child, dest)
272273
 
273274
 
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
+
274284
 def _normalize_tarinfo(info: tarfile.TarInfo) -> tarfile.TarInfo:
275285
     """Strip host-specific metadata so two packs of identical content match byte-for-byte.
276286
 
tests/unit/pack/test_packer.pymodified
@@ -12,7 +12,7 @@ import pytest
1212
 from typer.testing import CliRunner
1313
 
1414
 from dlm.cli.app import app
15
-from dlm.pack.errors import BaseLicenseRefusedError
15
+from dlm.pack.errors import BaseLicenseRefusedError, PackExecutableFileError
1616
 from dlm.pack.packer import _platform_hint, pack
1717
 
1818
 
@@ -224,6 +224,23 @@ class TestStoreLock:
224224
         assert observed.get("holder_pid") == _os.getpid()
225225
 
226226
 
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
+
227244
 class TestPlatformHint:
228245
     def test_import_error_falls_back_to_sys_platform(
229246
         self, caplog: pytest.LogCaptureFixture