Refuse executable files in packs
- SHA
54e7e6a7f78fa9afcf45db06aa9c5e133c34d675- Parents
-
1fdf194 - Tree
9d43d1e
54e7e6a
54e7e6a7f78fa9afcf45db06aa9c5e133c34d6751fdf194
9d43d1e| Status | File | + | - |
|---|---|---|---|
| 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 |