From 8f1693b848a93f11e160dd66f5738ed372872778 Mon Sep 17 00:00:00 2001 From: Sergio Sisternes Date: Tue, 15 Sep 2026 09:34:38 +0100 Subject: [PATCH 1/4] fix(cache): pin core.autocrlf=false on GitCache checkouts (closes #2971) Git-subpath materialization went through GitCache without the CRLF pin that bare_cache already set, so Windows hosts with system core.autocrlf=true recorded non-portable content_hash values. Add a -c pin that outranks env-frozen host config, persist it on the checkout, and rematerialize unpinned shards. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 1 + .../content/docs/reference/lockfile-spec.md | 2 +- .../docs/troubleshooting/common-errors.md | 2 +- src/apm_cli/cache/git_cache.py | 69 +++++++- tests/integration/test_git_cache_hermetic.py | 13 +- tests/unit/cache/test_git_cache.py | 4 + tests/unit/cache/test_git_cache_autocrlf.py | 155 ++++++++++++++++++ tests/unit/cache/test_git_cache_hardening.py | 2 + tests/unit/cache/test_git_cache_recency.py | 1 + 9 files changed, 242 insertions(+), 7 deletions(-) create mode 100644 tests/unit/cache/test_git_cache_autocrlf.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 192b36818d..eda3a25633 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Git-subpath `GitCache` checkouts pin `core.autocrlf=false` so a Windows host with the Git for Windows default produces the same package `content_hash` as Linux/macOS for LF-committed content; older unpinned cache shards rematerialize on the next install. (closes #2971) - GitLab `path:` dependencies now preserve the selected SSH transport, username, and port instead of silently using HTTPS; REST fallback requires an executed same-origin HTTPS attempt admitted by the transport policy. (#2938) ## [0.30.0] - 2026-09-07 diff --git a/docs/src/content/docs/reference/lockfile-spec.md b/docs/src/content/docs/reference/lockfile-spec.md index 08da217069..5cf4765e75 100644 --- a/docs/src/content/docs/reference/lockfile-spec.md +++ b/docs/src/content/docs/reference/lockfile-spec.md @@ -217,7 +217,7 @@ Each item in `dependencies` describes one resolved package. | `resolved_url` | string | registry only | Fully-qualified download URL used to re-fetch registry archives. | | `resolved_hash` | string | registry only | SHA-256 digest of the registry archive bytes, verified on every install. | | `local_path` | string | no | Original path from `apm.yml` for local deps, relative to project root. | -| `content_hash` | string | no | SHA-256 of the materialized package tree, computed from sorted relative paths and raw file bytes. For remote dependencies it verifies that downloaded or cached content still matches the lock; for local path dependencies it detects source-tree changes. | +| `content_hash` | string | no | SHA-256 of the materialized package tree, computed from sorted relative paths and raw file bytes. Git checkouts pin `core.autocrlf=false` so LF-committed content hashes the same on every OS; bytes that are committed as CRLF stay CRLF. For remote dependencies it verifies that downloaded or cached content still matches the lock; for local path dependencies it detects source-tree changes. | | `is_dev` | bool | no | `true` when the dep was declared under `devDependencies`. | | `discovered_via` | string | no | Marketplace name that surfaced this package (provenance). | | `marketplace_plugin_name` | string | no | Plugin name as listed in that marketplace. | diff --git a/docs/src/content/docs/troubleshooting/common-errors.md b/docs/src/content/docs/troubleshooting/common-errors.md index 417393525d..92f95a5827 100644 --- a/docs/src/content/docs/troubleshooting/common-errors.md +++ b/docs/src/content/docs/troubleshooting/common-errors.md @@ -85,7 +85,7 @@ indicate a supply-chain attack. Use 'apm install --update' to accept new content and update the lockfile. ``` -Cause: the bytes APM downloaded for a locked dependency do not match the `content_hash` recorded in `apm.lock.yaml`. Usually a force-pushed tag, a mutated branch, or a tampered mirror. +Cause: the bytes APM downloaded for a locked dependency do not match the `content_hash` recorded in `apm.lock.yaml`. Usually a force-pushed tag, a mutated branch, or a tampered mirror. A Windows host with Git's default `core.autocrlf=true` used to record CRLF hashes for git-subpath packages; current APM pins `core.autocrlf=false` on GitCache checkouts and rematerializes older unpinned shards, so that host setting is not a mismatch cause. Fix: investigate the upstream package before accepting. If the change is legitimate, run `apm install --update` to re-pin and commit the new hash. diff --git a/src/apm_cli/cache/git_cache.py b/src/apm_cli/cache/git_cache.py index bd0857de38..2890ad6e0d 100644 --- a/src/apm_cli/cache/git_cache.py +++ b/src/apm_cli/cache/git_cache.py @@ -66,6 +66,11 @@ def _safe_git_args() -> list[str]: malicious upstream might ship, so clone and checkout stay inert. - ``submodule.recurse=false`` prevents any subcommand from recursing into attacker-controlled submodule URLs. + - ``core.autocrlf=false`` keeps working-tree bytes identical to the + committed blob for a pinned SHA. ``-c`` outranks host system / + global config and ``GIT_CONFIG_KEY_n`` snapshots from + ``git_network_env``, which otherwise win over a repo-local pin + (apm#2971). These flags are scoped per-invocation via ``-c`` and never mutate the user's gitconfig. The cache layer is the single source of @@ -79,9 +84,28 @@ def _safe_git_args() -> list[str]: *git_no_hooks_args(), "-c", "submodule.recurse=false", + "-c", + "core.autocrlf=false", ] +def _checkout_pins_autocrlf_false(checkout_dir: Path) -> bool: + """Return whether the checkout's local gitconfig pins ``core.autocrlf=false``. + + Pre-fix shards materialized under host ``core.autocrlf=true`` omit this + pin and may contain CRLF working-tree bytes. Cache hits rematerialize + those shards so existing Windows caches heal without ``apm cache clean``. + """ + config = checkout_dir / ".git" / "config" + if not config.is_file(): + return False + try: + text = config.read_text(encoding="utf-8") + except OSError: + return False + return "autocrlf = false" in text or "autocrlf=false" in text + + # Partial bare-cache flavor suffix (perf #1433 follow-up). # When a caller requests sparse_paths, we use a separate bare keyed at # ``__p`` cloned with ``--filter=blob:none``. The partial bare @@ -201,12 +225,22 @@ def get_checkout( # Cache hit path (skip if refresh requested) if not self._refresh and checkout_dir.is_dir(): - if verify_checkout_sha(checkout_dir, sha): + sha_ok = verify_checkout_sha(checkout_dir, sha) + if sha_ok and _checkout_pins_autocrlf_false(checkout_dir): _log.debug("Cache HIT: %s @ %s [%s]", _sanitize_url(url), sha[:12], variant) with shard_lock(checkout_dir): return self._record_checkout_access( self._finalize_sparse_checkout(url, checkout_dir, sparse_paths, env=env) ) + if sha_ok: + _log.info( + "[*] Rematerializing git checkout missing core.autocrlf=false pin: " + "%s @ %s [%s]", + _sanitize_url(url), + sha[:12], + variant, + ) + self._evict_checkout(checkout_dir) else: # Integrity failure -- evict _log.warning( @@ -614,7 +648,8 @@ def _create_checkout( # this shard while we were waiting. Verify integrity to # rule out a poisoned half-write (atomic_land guards # against that, but we re-check defensively). - if final_dir.is_dir() and verify_checkout_sha(final_dir, sha): + existing_ok = final_dir.is_dir() and verify_checkout_sha(final_dir, sha) + if existing_ok and _checkout_pins_autocrlf_false(final_dir): _log.debug( "Write-dedup HIT under lock: %s @ %s [%s]", _sanitize_url(url), @@ -624,6 +659,15 @@ def _create_checkout( return self._record_checkout_access( self._finalize_sparse_checkout(url, final_dir, sparse_paths, env=env) ) + if existing_ok: + _log.info( + "[*] Rematerializing git checkout missing core.autocrlf=false pin: " + "%s @ %s [%s]", + _sanitize_url(url), + sha[:12], + variant, + ) + self._evict_checkout(final_dir) staged = stage_path(final_dir) ensure_path_within(staged, self._checkouts_root) @@ -670,6 +714,27 @@ def _create_checkout( stdin=subprocess.DEVNULL, check=True, ) + # Persist the pin so cache hits can recognize post-fix shards. + # Checkout itself still needs ``-c core.autocrlf=false`` from + # ``_safe_git_args`` because env-frozen host config outranks + # this local value. + subprocess.run( + [ + git_exe, + *_safe_git_args(), + "-C", + str(staged), + "config", + "core.autocrlf", + "false", + ], + capture_output=True, + text=True, + timeout=10, + env=subprocess_env, + stdin=subprocess.DEVNULL, + check=True, + ) if promisor_url: # Point origin at the real upstream (clone set it to the # local bare). Single config call; the other two promisor diff --git a/tests/integration/test_git_cache_hermetic.py b/tests/integration/test_git_cache_hermetic.py index 3b3ad97065..97b4930762 100644 --- a/tests/integration/test_git_cache_hermetic.py +++ b/tests/integration/test_git_cache_hermetic.py @@ -84,6 +84,10 @@ def test_cache_hit_returns_existing_checkout(self, cache: GitCache) -> None: sha = "a" * 40 checkout_dir = cache._checkouts_root / cache_shard_key(url) / sha / "full" checkout_dir.mkdir(parents=True) + (checkout_dir / ".git").mkdir() + (checkout_dir / ".git" / "config").write_text( + "[core]\n\tautocrlf = false\n", encoding="ascii" + ) with ( patch.object(cache, "_resolve_sha", return_value=sha), @@ -494,6 +498,8 @@ def test_write_dedup_hit_under_lock_returns_existing_checkout(self, cache: GitCa shard_key = cache_shard_key(url) final_dir = cache._checkouts_root / shard_key / ("a" * 40) / "full" final_dir.mkdir(parents=True) + (final_dir / ".git").mkdir() + (final_dir / ".git" / "config").write_text("[core]\n\tautocrlf = false\n", encoding="ascii") with ( patch("apm_cli.cache.git_cache.shard_lock", return_value=nullcontext()), @@ -533,7 +539,7 @@ def _land(staged: Path, final: Path, _lock: object) -> bool: result = cache._create_checkout(url, shard_key, "b" * 40) assert result == final_dir - assert mock_run.call_count == 2 + assert mock_run.call_count == 3 def test_clone_failure_cleans_staged_checkout(self, cache: GitCache) -> None: url = "https://example.com/repo.git" @@ -562,10 +568,11 @@ def test_checkout_failure_cleans_staged_checkout(self, cache: GitCache) -> None: (cache._db_root / shard_key).mkdir(parents=True) clone_result = _proc() + config_result = _proc() checkout_error = subprocess.CalledProcessError(1, "git", stderr="checkout failed") with ( patch("apm_cli.cache.git_cache.shard_lock", return_value=nullcontext()), - patch("subprocess.run", side_effect=[clone_result, checkout_error]), + patch("subprocess.run", side_effect=[clone_result, config_result, checkout_error]), patch("apm_cli.utils.git_env.get_git_executable", return_value="git"), patch("apm_cli.utils.git_env.git_subprocess_env", return_value={}), patch("apm_cli.cache.git_cache.os.chmod"), @@ -596,7 +603,7 @@ def test_atomic_land_false_accepts_valid_winner(self, cache: GitCache) -> None: result = cache._create_checkout(url, shard_key, "e" * 40) assert result == final_dir - assert mock_run.call_count == 2 + assert mock_run.call_count == 3 def test_atomic_land_false_with_invalid_winner_evicts_and_raises(self, cache: GitCache) -> None: url = "https://example.com/repo.git" diff --git a/tests/unit/cache/test_git_cache.py b/tests/unit/cache/test_git_cache.py index 5b8941678f..532e0419c6 100644 --- a/tests/unit/cache/test_git_cache.py +++ b/tests/unit/cache/test_git_cache.py @@ -95,6 +95,9 @@ def test_cache_hit_with_integrity_pass(self, mock_run: MagicMock, tmp_path: Path checkout_dir = tmp_path / "git" / "checkouts_v1" / real_shard / sha / "full" checkout_dir.mkdir(parents=True) (checkout_dir / ".git").mkdir() + (checkout_dir / ".git" / "config").write_text( + "[core]\n\tautocrlf = false\n", encoding="ascii" + ) # Mock git rev-parse HEAD to return the expected SHA mock_run.return_value = MagicMock( @@ -325,6 +328,7 @@ def test_short_circuits_when_final_exists_under_lock( final_dir = tmp_path / "git" / "checkouts_v1" / shard / sha / "full" final_dir.mkdir(parents=True) (final_dir / ".git").mkdir() + (final_dir / ".git" / "config").write_text("[core]\n\tautocrlf = false\n", encoding="ascii") with ( patch("subprocess.run") as mock_run, diff --git a/tests/unit/cache/test_git_cache_autocrlf.py b/tests/unit/cache/test_git_cache_autocrlf.py new file mode 100644 index 0000000000..be6d58744e --- /dev/null +++ b/tests/unit/cache/test_git_cache_autocrlf.py @@ -0,0 +1,155 @@ +"""Real-git regression for git-subpath content_hash CRLF invariance (apm#2971). + +GitCache is the default materialization path for ``owner/repo/#ref`` +dependencies. Host ``core.autocrlf=true`` (Git for Windows default) must not +change working-tree bytes or the raw package hash of LF-committed content. +""" + +from __future__ import annotations + +import os +import subprocess +from pathlib import Path + +import pytest + +from apm_cli.cache.git_cache import GitCache, _safe_git_args +from apm_cli.utils.content_hash import compute_package_hash + +_LF_BODY = b"---\nname: demo\n---\nhello\nworld\n" + + +def _git( + args: list[str], *, cwd: Path | None = None, env: dict[str, str] +) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["git", *args], + cwd=cwd, + env=env, + capture_output=True, + text=True, + check=True, + ) + + +def _neutral_git_env() -> dict[str, str]: + env = os.environ.copy() + env["GIT_CONFIG_GLOBAL"] = os.devnull + env["GIT_CONFIG_SYSTEM"] = os.devnull + env.pop("GIT_CONFIG_COUNT", None) + for key in list(env): + if key.startswith(("GIT_CONFIG_KEY_", "GIT_CONFIG_VALUE_")): + env.pop(key, None) + return env + + +def _lf_origin(tmp_path: Path) -> tuple[Path, str]: + """Commit LF skill bytes and return (origin path, sha).""" + src = tmp_path / "origin" + skill = src / "skills" / "demo" + skill.mkdir(parents=True) + (skill / "SKILL.md").write_bytes(_LF_BODY) + env = _neutral_git_env() + _git(["init", "-b", "main", str(src)], env=env) + _git(["-C", str(src), "config", "user.email", "test@example.com"], env=env) + _git(["-C", str(src), "config", "user.name", "APM Test"], env=env) + _git(["-C", str(src), "config", "core.autocrlf", "false"], env=env) + _git(["-C", str(src), "add", "."], env=env) + _git(["-C", str(src), "commit", "-q", "-m", "lf fixture"], env=env) + sha = _git(["-C", str(src), "rev-parse", "HEAD"], env=env).stdout.strip() + return src, sha + + +def _host_autocrlf_true_env(tmp_path: Path) -> dict[str, str]: + system_cfg = tmp_path / "system.gitconfig" + system_cfg.write_text( + "[core]\n\tautocrlf = true\n[safe]\n\tbareRepository = all\n", + encoding="ascii", + ) + env = _neutral_git_env() + env["GIT_CONFIG_SYSTEM"] = str(system_cfg) + return env + + +@pytest.mark.windows_compat +def test_safe_git_args_pin_autocrlf_false() -> None: + args = _safe_git_args() + assert "core.autocrlf=false" in args + + +@pytest.mark.windows_compat +def test_full_checkout_keeps_lf_under_system_autocrlf_true( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + origin, sha = _lf_origin(tmp_path) + host_env = _host_autocrlf_true_env(tmp_path) + for key, value in host_env.items(): + monkeypatch.setenv(key, value) + for key in list(os.environ): + if key.startswith(("GIT_CONFIG_KEY_", "GIT_CONFIG_VALUE_")) or key == "GIT_CONFIG_COUNT": + monkeypatch.delenv(key, raising=False) + + checkout = GitCache(tmp_path / "cache").get_checkout(str(origin), sha, locked_sha=sha) + skill = checkout / "skills" / "demo" / "SKILL.md" + assert skill.read_bytes() == _LF_BODY + config = (checkout / ".git" / "config").read_text(encoding="utf-8") + assert "autocrlf = false" in config or "autocrlf=false" in config + assert compute_package_hash(checkout / "skills" / "demo") == compute_package_hash( + origin / "skills" / "demo" + ) + + +@pytest.mark.windows_compat +def test_sparse_checkout_keeps_lf_when_env_freezes_autocrlf_true( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """git_network_env freezes host autocrlf into GIT_CONFIG_KEY_n; only -c outranks it.""" + origin, sha = _lf_origin(tmp_path) + host_env = _host_autocrlf_true_env(tmp_path) + for key, value in host_env.items(): + monkeypatch.setenv(key, value) + monkeypatch.delenv("GIT_CONFIG_COUNT", raising=False) + for key in list(os.environ): + if key.startswith(("GIT_CONFIG_KEY_", "GIT_CONFIG_VALUE_")): + monkeypatch.delenv(key, raising=False) + + checkout = GitCache(tmp_path / "cache").get_checkout( + str(origin), + sha, + locked_sha=sha, + sparse_paths=["skills"], + ) + skill = checkout / "skills" / "demo" / "SKILL.md" + assert skill.read_bytes() == _LF_BODY + assert b"\r\n" not in skill.read_bytes() + + +@pytest.mark.windows_compat +def test_cache_hit_rematerializes_unpinned_crlf_shard( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + origin, sha = _lf_origin(tmp_path) + host_env = _host_autocrlf_true_env(tmp_path) + for key, value in host_env.items(): + monkeypatch.setenv(key, value) + monkeypatch.delenv("GIT_CONFIG_COUNT", raising=False) + for key in list(os.environ): + if key.startswith(("GIT_CONFIG_KEY_", "GIT_CONFIG_VALUE_")): + monkeypatch.delenv(key, raising=False) + + cache = GitCache(tmp_path / "cache") + poisoned = cache.get_checkout(str(origin), sha, locked_sha=sha) + skill = poisoned / "skills" / "demo" / "SKILL.md" + skill.write_bytes(b"---\r\nname: demo\r\n---\r\nhello\r\nworld\r\n") + git_config = poisoned / ".git" / "config" + text = git_config.read_text(encoding="utf-8") + text = text.replace("autocrlf = false", "autocrlf = true").replace( + "autocrlf=false", "autocrlf=true" + ) + if "autocrlf" not in text: + text += "\n[core]\n\tautocrlf = true\n" + git_config.write_text(text, encoding="utf-8") + + reused = cache.get_checkout(str(origin), sha, locked_sha=sha) + assert reused.exists() + assert (reused / "skills" / "demo" / "SKILL.md").read_bytes() == _LF_BODY diff --git a/tests/unit/cache/test_git_cache_hardening.py b/tests/unit/cache/test_git_cache_hardening.py index 2cf2671f63..72256fe895 100644 --- a/tests/unit/cache/test_git_cache_hardening.py +++ b/tests/unit/cache/test_git_cache_hardening.py @@ -80,6 +80,8 @@ def test_cache_hit_log_omits_ssh_userinfo(self, caplog, tmp_path: Path) -> None: shard = "cache-shard" checkout = cache._checkouts_root / shard / sha / "full" checkout.mkdir(parents=True) + (checkout / ".git").mkdir() + (checkout / ".git" / "config").write_text("[core]\n\tautocrlf = false\n", encoding="ascii") with ( patch.object(cache, "_resolve_sha", return_value=sha), diff --git a/tests/unit/cache/test_git_cache_recency.py b/tests/unit/cache/test_git_cache_recency.py index 005343726c..4be12f5d00 100644 --- a/tests/unit/cache/test_git_cache_recency.py +++ b/tests/unit/cache/test_git_cache_recency.py @@ -124,6 +124,7 @@ def test_failed_sparse_validation_does_not_refresh_access( checkout = cache._checkouts_root / cache_shard_key(_REMOTE) / sha / _variant_key(["skills"]) (checkout / ".git").mkdir(parents=True) (checkout / ".git/HEAD").write_text(sha, encoding="ascii") + (checkout / ".git" / "config").write_text("[core]\n\tautocrlf = false\n", encoding="ascii") def reject_sparse(*args: object, **kwargs: object) -> Path: raise error_type("Invalid sparse symlink") From 67765fc5a2c6a0074831ddf7ae29874fc526060f Mon Sep 17 00:00:00 2001 From: Sergio Sisternes Date: Tue, 15 Sep 2026 09:38:26 +0100 Subject: [PATCH 2/4] docs(changelog): cite #2982 for GitCache autocrlf pin Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eda3a25633..6f23f8bbf0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,7 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- Git-subpath `GitCache` checkouts pin `core.autocrlf=false` so a Windows host with the Git for Windows default produces the same package `content_hash` as Linux/macOS for LF-committed content; older unpinned cache shards rematerialize on the next install. (closes #2971) +- Git-subpath `GitCache` checkouts pin `core.autocrlf=false` so a Windows host with the Git for Windows default produces the same package `content_hash` as Linux/macOS for LF-committed content; older unpinned cache shards rematerialize on the next install. (#2982, closes #2971) - GitLab `path:` dependencies now preserve the selected SSH transport, username, and port instead of silently using HTTPS; REST fallback requires an executed same-origin HTTPS attempt admitted by the transport policy. (#2938) ## [0.30.0] - 2026-09-07 From 9a5fa1caa68077a8012b216bbfe2c7efb46aa075 Mon Sep 17 00:00:00 2001 From: Sergio Sisternes Date: Tue, 15 Sep 2026 10:10:54 +0100 Subject: [PATCH 3/4] fix(cache): heal GitCache autocrlf pin under shard lock Defer unpinned SHA-valid eviction until _create_checkout holds the shard lock, parse the local core.autocrlf key, fail closed on land races, and document legacy lockfile regeneration. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 2 +- .../content/docs/reference/lockfile-spec.md | 2 +- .../docs/troubleshooting/common-errors.md | 4 +- src/apm_cli/cache/git_cache.py | 47 ++++++++--- tests/integration/test_git_cache_hermetic.py | 2 + tests/unit/cache/test_git_cache.py | 2 + tests/unit/cache/test_git_cache_autocrlf.py | 83 +++++++++++++++---- tests/unit/cache/test_proxy_compat.py | 1 + 8 files changed, 111 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f23f8bbf0..16a6722bee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,7 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- Git-subpath `GitCache` checkouts pin `core.autocrlf=false` so a Windows host with the Git for Windows default produces the same package `content_hash` as Linux/macOS for LF-committed content; older unpinned cache shards rematerialize on the next install. (#2982, closes #2971) +- Git-subpath `GitCache` checkouts pin `core.autocrlf=false` so a Windows host with the Git for Windows default produces the same package `content_hash` as Linux/macOS for LF-committed content; older unpinned cache shards rematerialize on the next install. (closes #2971) (#2982) - GitLab `path:` dependencies now preserve the selected SSH transport, username, and port instead of silently using HTTPS; REST fallback requires an executed same-origin HTTPS attempt admitted by the transport policy. (#2938) ## [0.30.0] - 2026-09-07 diff --git a/docs/src/content/docs/reference/lockfile-spec.md b/docs/src/content/docs/reference/lockfile-spec.md index 5cf4765e75..f45dd25719 100644 --- a/docs/src/content/docs/reference/lockfile-spec.md +++ b/docs/src/content/docs/reference/lockfile-spec.md @@ -217,7 +217,7 @@ Each item in `dependencies` describes one resolved package. | `resolved_url` | string | registry only | Fully-qualified download URL used to re-fetch registry archives. | | `resolved_hash` | string | registry only | SHA-256 digest of the registry archive bytes, verified on every install. | | `local_path` | string | no | Original path from `apm.yml` for local deps, relative to project root. | -| `content_hash` | string | no | SHA-256 of the materialized package tree, computed from sorted relative paths and raw file bytes. Git checkouts pin `core.autocrlf=false` so LF-committed content hashes the same on every OS; bytes that are committed as CRLF stay CRLF. For remote dependencies it verifies that downloaded or cached content still matches the lock; for local path dependencies it detects source-tree changes. | +| `content_hash` | string | no | SHA-256 of the materialized package tree, computed from sorted relative paths and raw file bytes. GitCache-backed git-subpath checkouts pin `core.autocrlf=false` so LF-committed content is not rewritten as CRLF on checkout; this pin does not override `.gitattributes` or `core.eol`. Bytes that are committed as CRLF stay CRLF. For remote dependencies it verifies that downloaded or cached content still matches the lock; for local path dependencies it detects source-tree changes. | | `is_dev` | bool | no | `true` when the dep was declared under `devDependencies`. | | `discovered_via` | string | no | Marketplace name that surfaced this package (provenance). | | `marketplace_plugin_name` | string | no | Plugin name as listed in that marketplace. | diff --git a/docs/src/content/docs/troubleshooting/common-errors.md b/docs/src/content/docs/troubleshooting/common-errors.md index 92f95a5827..755bc5c9bd 100644 --- a/docs/src/content/docs/troubleshooting/common-errors.md +++ b/docs/src/content/docs/troubleshooting/common-errors.md @@ -85,9 +85,9 @@ indicate a supply-chain attack. Use 'apm install --update' to accept new content and update the lockfile. ``` -Cause: the bytes APM downloaded for a locked dependency do not match the `content_hash` recorded in `apm.lock.yaml`. Usually a force-pushed tag, a mutated branch, or a tampered mirror. A Windows host with Git's default `core.autocrlf=true` used to record CRLF hashes for git-subpath packages; current APM pins `core.autocrlf=false` on GitCache checkouts and rematerializes older unpinned shards, so that host setting is not a mismatch cause. +Cause: the bytes APM downloaded for a locked dependency do not match the `content_hash` recorded in `apm.lock.yaml`. Usually a force-pushed tag, a mutated branch, or a tampered mirror. A Windows host with Git's default `core.autocrlf=true` used to record CRLF hashes for git-subpath packages. Current APM pins `core.autocrlf=false` on GitCache checkouts and rematerializes older unpinned cache shards, but `--frozen` cannot rewrite a lockfile that already stored that CRLF hash. -Fix: investigate the upstream package before accepting. If the change is legitimate, run `apm install --update` to re-pin and commit the new hash. +Fix: investigate the upstream package before accepting. If the change is legitimate, run `apm install --update` to re-pin and commit the new hash. If the lockfile was generated by a pre-fix Windows CLI, regenerate it with a current APM (`apm lock` or `apm install --update`) and commit the LF hash. See also: [Install failures](../install-failures/), [Lockfile specification](../../reference/lockfile-spec/) diff --git a/src/apm_cli/cache/git_cache.py b/src/apm_cli/cache/git_cache.py index 2890ad6e0d..1b48c00195 100644 --- a/src/apm_cli/cache/git_cache.py +++ b/src/apm_cli/cache/git_cache.py @@ -66,11 +66,13 @@ def _safe_git_args() -> list[str]: malicious upstream might ship, so clone and checkout stay inert. - ``submodule.recurse=false`` prevents any subcommand from recursing into attacker-controlled submodule URLs. - - ``core.autocrlf=false`` keeps working-tree bytes identical to the - committed blob for a pinned SHA. ``-c`` outranks host system / - global config and ``GIT_CONFIG_KEY_n`` snapshots from - ``git_network_env``, which otherwise win over a repo-local pin - (apm#2971). + - ``core.autocrlf=false`` disables host autocrlf conversion so + LF-committed blobs are not rewritten as CRLF on checkout. + ``-c`` outranks host system / global config and + ``GIT_CONFIG_KEY_n`` snapshots from ``git_network_env``, which + otherwise win over a repo-local pin (apm#2971). This pin does + not override ``core.eol`` or ``.gitattributes`` ``eol=crlf`` / + ``text=auto`` requests. These flags are scoped per-invocation via ``-c`` and never mutate the user's gitconfig. The cache layer is the single source of @@ -101,9 +103,25 @@ def _checkout_pins_autocrlf_false(checkout_dir: Path) -> bool: return False try: text = config.read_text(encoding="utf-8") - except OSError: + except (OSError, UnicodeError): return False - return "autocrlf = false" in text or "autocrlf=false" in text + section = None + for raw in text.splitlines(): + line = raw.split(";", 1)[0].split("#", 1)[0].strip() + if not line: + continue + if line.startswith("[") and line.endswith("]"): + inner = line[1:-1].strip() + section = inner.split(" ", 1)[0].strip().lower() + continue + if section != "core" or "=" not in line: + continue + key, _, value = line.partition("=") + if key.strip().lower() != "autocrlf": + continue + normalized = value.strip().strip("\"'").lower() + return normalized in {"false", "0", "no", "off"} + return False # Partial bare-cache flavor suffix (perf #1433 follow-up). @@ -240,7 +258,9 @@ def get_checkout( sha[:12], variant, ) - self._evict_checkout(checkout_dir) + # Leave the SHA-valid tree in place until ``_create_checkout`` + # holds ``shard_lock``. A concurrent consumer may still be + # reading it; the locked re-probe evicts and rebuilds. else: # Integrity failure -- evict _log.warning( @@ -668,6 +688,11 @@ def _create_checkout( variant, ) self._evict_checkout(final_dir) + if final_dir.exists(): + raise RuntimeError( + "Failed to rematerialize unpinned git checkout " + f"for {_sanitize_url(url)} @ {sha[:12]}" + ) staged = stage_path(final_dir) ensure_path_within(staged, self._checkouts_root) @@ -820,8 +845,10 @@ def _create_checkout( if not atomic_land(staged, final_dir, lock): # Another process landed first between our re-probe and # the rename (only possible if our lock dropped, which - # it didn't); verify integrity defensively. - if not verify_checkout_sha(final_dir, sha): + # it didn't); verify integrity and the autocrlf pin. + if not ( + verify_checkout_sha(final_dir, sha) and _checkout_pins_autocrlf_false(final_dir) + ): self._evict_checkout(final_dir) raise RuntimeError( f"Race condition: concurrent checkout failed integrity " diff --git a/tests/integration/test_git_cache_hermetic.py b/tests/integration/test_git_cache_hermetic.py index 97b4930762..3e4c1dc783 100644 --- a/tests/integration/test_git_cache_hermetic.py +++ b/tests/integration/test_git_cache_hermetic.py @@ -588,6 +588,8 @@ def test_atomic_land_false_accepts_valid_winner(self, cache: GitCache) -> None: shard_key = cache_shard_key(url) final_dir = cache._checkouts_root / shard_key / ("e" * 40) / "full" final_dir.mkdir(parents=True) + (final_dir / ".git").mkdir() + (final_dir / ".git" / "config").write_text("[core]\n\tautocrlf = false\n", encoding="ascii") (cache._db_root / shard_key).mkdir(parents=True) verify_results = [False, True] diff --git a/tests/unit/cache/test_git_cache.py b/tests/unit/cache/test_git_cache.py index 532e0419c6..82dd7c9972 100644 --- a/tests/unit/cache/test_git_cache.py +++ b/tests/unit/cache/test_git_cache.py @@ -392,6 +392,8 @@ def test_short_circuits_on_integrity_pass_only(self, tmp_path: Path) -> None: # Populate final_dir BUT integrity will report failure. final_dir = tmp_path / "git" / "checkouts_v1" / shard / sha / "full" final_dir.mkdir(parents=True) + (final_dir / ".git").mkdir() + (final_dir / ".git" / "config").write_text("[core]\n\tautocrlf = false\n", encoding="ascii") (tmp_path / "git" / "db_v1" / shard).mkdir(parents=True) def _populate(*args, **kwargs): diff --git a/tests/unit/cache/test_git_cache_autocrlf.py b/tests/unit/cache/test_git_cache_autocrlf.py index be6d58744e..088e1c28c0 100644 --- a/tests/unit/cache/test_git_cache_autocrlf.py +++ b/tests/unit/cache/test_git_cache_autocrlf.py @@ -13,8 +13,12 @@ import pytest -from apm_cli.cache.git_cache import GitCache, _safe_git_args +from apm_cli.cache.git_cache import GitCache, _checkout_pins_autocrlf_false, _safe_git_args +from apm_cli.cache.url_normalize import cache_shard_key from apm_cli.utils.content_hash import compute_package_hash +from apm_cli.utils.git_env import get_git_executable + +pytestmark = [pytest.mark.component, pytest.mark.windows_compat] _LF_BODY = b"---\nname: demo\n---\nhello\nworld\n" @@ -23,7 +27,7 @@ def _git( args: list[str], *, cwd: Path | None = None, env: dict[str, str] ) -> subprocess.CompletedProcess[str]: return subprocess.run( - ["git", *args], + [get_git_executable(), *args], cwd=cwd, env=env, capture_output=True, @@ -37,6 +41,8 @@ def _neutral_git_env() -> dict[str, str]: env["GIT_CONFIG_GLOBAL"] = os.devnull env["GIT_CONFIG_SYSTEM"] = os.devnull env.pop("GIT_CONFIG_COUNT", None) + env.pop("GIT_CONFIG_NOSYSTEM", None) + env.pop("GIT_CONFIG_PARAMETERS", None) for key in list(env): if key.startswith(("GIT_CONFIG_KEY_", "GIT_CONFIG_VALUE_")): env.pop(key, None) @@ -71,13 +77,11 @@ def _host_autocrlf_true_env(tmp_path: Path) -> dict[str, str]: return env -@pytest.mark.windows_compat def test_safe_git_args_pin_autocrlf_false() -> None: args = _safe_git_args() assert "core.autocrlf=false" in args -@pytest.mark.windows_compat def test_full_checkout_keeps_lf_under_system_autocrlf_true( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -92,14 +96,12 @@ def test_full_checkout_keeps_lf_under_system_autocrlf_true( checkout = GitCache(tmp_path / "cache").get_checkout(str(origin), sha, locked_sha=sha) skill = checkout / "skills" / "demo" / "SKILL.md" assert skill.read_bytes() == _LF_BODY - config = (checkout / ".git" / "config").read_text(encoding="utf-8") - assert "autocrlf = false" in config or "autocrlf=false" in config + assert _checkout_pins_autocrlf_false(checkout) assert compute_package_hash(checkout / "skills" / "demo") == compute_package_hash( origin / "skills" / "demo" ) -@pytest.mark.windows_compat def test_sparse_checkout_keeps_lf_when_env_freezes_autocrlf_true( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -124,7 +126,19 @@ def test_sparse_checkout_keeps_lf_when_env_freezes_autocrlf_true( assert b"\r\n" not in skill.read_bytes() -@pytest.mark.windows_compat +def _poison_autocrlf_pin(checkout: Path) -> None: + skill = checkout / "skills" / "demo" / "SKILL.md" + skill.write_bytes(b"---\r\nname: demo\r\n---\r\nhello\r\nworld\r\n") + git_config = checkout / ".git" / "config" + text = git_config.read_text(encoding="utf-8") + text = text.replace("autocrlf = false", "autocrlf = true").replace( + "autocrlf=false", "autocrlf=true" + ) + if "autocrlf" not in text: + text += "\n[core]\n\tautocrlf = true\n" + git_config.write_text(text, encoding="utf-8") + + def test_cache_hit_rematerializes_unpinned_crlf_shard( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -139,17 +153,50 @@ def test_cache_hit_rematerializes_unpinned_crlf_shard( cache = GitCache(tmp_path / "cache") poisoned = cache.get_checkout(str(origin), sha, locked_sha=sha) - skill = poisoned / "skills" / "demo" / "SKILL.md" - skill.write_bytes(b"---\r\nname: demo\r\n---\r\nhello\r\nworld\r\n") - git_config = poisoned / ".git" / "config" - text = git_config.read_text(encoding="utf-8") - text = text.replace("autocrlf = false", "autocrlf = true").replace( - "autocrlf=false", "autocrlf=true" - ) - if "autocrlf" not in text: - text += "\n[core]\n\tautocrlf = true\n" - git_config.write_text(text, encoding="utf-8") + _poison_autocrlf_pin(poisoned) reused = cache.get_checkout(str(origin), sha, locked_sha=sha) assert reused.exists() assert (reused / "skills" / "demo" / "SKILL.md").read_bytes() == _LF_BODY + assert _checkout_pins_autocrlf_false(reused) + + +def test_create_checkout_rematerializes_unpinned_final_dir( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + origin, sha = _lf_origin(tmp_path) + host_env = _host_autocrlf_true_env(tmp_path) + for key, value in host_env.items(): + monkeypatch.setenv(key, value) + monkeypatch.delenv("GIT_CONFIG_COUNT", raising=False) + for key in list(os.environ): + if key.startswith(("GIT_CONFIG_KEY_", "GIT_CONFIG_VALUE_")): + monkeypatch.delenv(key, raising=False) + + cache = GitCache(tmp_path / "cache") + poisoned = cache.get_checkout(str(origin), sha, locked_sha=sha) + _poison_autocrlf_pin(poisoned) + assert not _checkout_pins_autocrlf_false(poisoned) + + rebuilt = cache._create_checkout(str(origin), cache_shard_key(str(origin)), sha) + assert (rebuilt / "skills" / "demo" / "SKILL.md").read_bytes() == _LF_BODY + assert _checkout_pins_autocrlf_false(rebuilt) + + +def test_pin_reads_core_section_not_url_substring(tmp_path: Path) -> None: + checkout = tmp_path / "checkout" + git_dir = checkout / ".git" + git_dir.mkdir(parents=True) + (git_dir / "config").write_text( + '[remote "origin"]\n\turl = https://example.com/autocrlf=false.git\n', + encoding="utf-8", + ) + assert not _checkout_pins_autocrlf_false(checkout) + + +def test_pin_treats_non_utf8_config_as_missing(tmp_path: Path) -> None: + checkout = tmp_path / "checkout" + git_dir = checkout / ".git" + git_dir.mkdir(parents=True) + (git_dir / "config").write_bytes(b"\xff\xfe[core]\n\tautocrlf = false\n") + assert not _checkout_pins_autocrlf_false(checkout) diff --git a/tests/unit/cache/test_proxy_compat.py b/tests/unit/cache/test_proxy_compat.py index 2093e052a7..7bf194cdef 100644 --- a/tests/unit/cache/test_proxy_compat.py +++ b/tests/unit/cache/test_proxy_compat.py @@ -79,6 +79,7 @@ def test_second_install_hits_cache(self, mock_run: MagicMock, tmp_path: Path) -> git_dir = checkout_dir / ".git" git_dir.mkdir() (git_dir / "HEAD").write_text(f"{sha}\n", encoding="utf-8") + (git_dir / "config").write_text("[core]\n\tautocrlf = false\n", encoding="ascii") # Second install -- should hit cache with ZERO subprocess calls result = cache.get_checkout(url, "main", locked_sha=sha) From 78f42b3fb526bbb5eabc19156f1c3b6d8b60a9bf Mon Sep 17 00:00:00 2001 From: Sergio Sisternes Date: Tue, 15 Sep 2026 15:11:49 +0100 Subject: [PATCH 4/4] test(cache): isolate autocrlf host fixture and fail closed Drop inherited GIT_CONFIG_NOSYSTEM/PARAMETERS before the hostile-host regression, assert system autocrlf=true, document lock --update recovery, and reject unremovable unpinned shards. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 2 +- .../docs/troubleshooting/common-errors.md | 2 +- src/apm_cli/cache/git_cache.py | 16 ++--- tests/unit/cache/test_git_cache_autocrlf.py | 64 +++++++++++-------- 4 files changed, 43 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 16a6722bee..01d0446257 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,7 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- Git-subpath `GitCache` checkouts pin `core.autocrlf=false` so a Windows host with the Git for Windows default produces the same package `content_hash` as Linux/macOS for LF-committed content; older unpinned cache shards rematerialize on the next install. (closes #2971) (#2982) +- Git-subpath `GitCache` checkouts pin `core.autocrlf=false` so host autocrlf settings do not rewrite LF-committed package content; older unpinned cache shards rematerialize on the next install. (closes #2971) (#2982) - GitLab `path:` dependencies now preserve the selected SSH transport, username, and port instead of silently using HTTPS; REST fallback requires an executed same-origin HTTPS attempt admitted by the transport policy. (#2938) ## [0.30.0] - 2026-09-07 diff --git a/docs/src/content/docs/troubleshooting/common-errors.md b/docs/src/content/docs/troubleshooting/common-errors.md index 755bc5c9bd..15df5433ea 100644 --- a/docs/src/content/docs/troubleshooting/common-errors.md +++ b/docs/src/content/docs/troubleshooting/common-errors.md @@ -87,7 +87,7 @@ new content and update the lockfile. Cause: the bytes APM downloaded for a locked dependency do not match the `content_hash` recorded in `apm.lock.yaml`. Usually a force-pushed tag, a mutated branch, or a tampered mirror. A Windows host with Git's default `core.autocrlf=true` used to record CRLF hashes for git-subpath packages. Current APM pins `core.autocrlf=false` on GitCache checkouts and rematerializes older unpinned cache shards, but `--frozen` cannot rewrite a lockfile that already stored that CRLF hash. -Fix: investigate the upstream package before accepting. If the change is legitimate, run `apm install --update` to re-pin and commit the new hash. If the lockfile was generated by a pre-fix Windows CLI, regenerate it with a current APM (`apm lock` or `apm install --update`) and commit the LF hash. +Fix: investigate the upstream package before accepting. If the change is legitimate, run `apm install --update` to re-pin and commit the new hash. `--update` can advance mutable refs, so review the SHA and `content_hash` diff before committing. If the lockfile was generated by a pre-fix Windows CLI, rematerialize the affected package from a fresh consumer workspace (or delete the cached/installed copy) and regenerate with `apm install --update` or `apm lock --update`. Bare `apm lock` does not rewrite an existing CRLF hash. See also: [Install failures](../install-failures/), [Lockfile specification](../../reference/lockfile-spec/) diff --git a/src/apm_cli/cache/git_cache.py b/src/apm_cli/cache/git_cache.py index 1b48c00195..35a290dc35 100644 --- a/src/apm_cli/cache/git_cache.py +++ b/src/apm_cli/cache/git_cache.py @@ -250,18 +250,7 @@ def get_checkout( return self._record_checkout_access( self._finalize_sparse_checkout(url, checkout_dir, sparse_paths, env=env) ) - if sha_ok: - _log.info( - "[*] Rematerializing git checkout missing core.autocrlf=false pin: " - "%s @ %s [%s]", - _sanitize_url(url), - sha[:12], - variant, - ) - # Leave the SHA-valid tree in place until ``_create_checkout`` - # holds ``shard_lock``. A concurrent consumer may still be - # reading it; the locked re-probe evicts and rebuilds. - else: + elif not sha_ok: # Integrity failure -- evict _log.warning( "[!] Evicting corrupt cache entry: %s @ %s [%s]", @@ -270,6 +259,9 @@ def get_checkout( variant, ) self._evict_checkout(checkout_dir) + # SHA-valid unpinned trees stay until ``_create_checkout`` holds + # ``shard_lock`` and emits the rematerialize log. A concurrent + # consumer may still be reading the old checkout. # Cache miss: ensure we have the bare repo, then create checkout. # Sparse callers use a partial bare (blob:none) + promisor consumer diff --git a/tests/unit/cache/test_git_cache_autocrlf.py b/tests/unit/cache/test_git_cache_autocrlf.py index 088e1c28c0..1fbd43dc18 100644 --- a/tests/unit/cache/test_git_cache_autocrlf.py +++ b/tests/unit/cache/test_git_cache_autocrlf.py @@ -10,6 +10,7 @@ import os import subprocess from pathlib import Path +from unittest.mock import patch import pytest @@ -77,6 +78,21 @@ def _host_autocrlf_true_env(tmp_path: Path) -> dict[str, str]: return env +def _apply_hostile_host_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Install system autocrlf=true and drop inherited gitconfig overrides.""" + monkeypatch.delenv("GIT_CONFIG_NOSYSTEM", raising=False) + monkeypatch.delenv("GIT_CONFIG_PARAMETERS", raising=False) + monkeypatch.delenv("GIT_CONFIG_COUNT", raising=False) + for key in list(os.environ): + if key.startswith(("GIT_CONFIG_KEY_", "GIT_CONFIG_VALUE_")): + monkeypatch.delenv(key, raising=False) + host_env = _host_autocrlf_true_env(tmp_path) + for key, value in host_env.items(): + monkeypatch.setenv(key, value) + observed = _git(["config", "--system", "--get", "core.autocrlf"], env=dict(os.environ)) + assert observed.stdout.strip().lower() == "true" + + def test_safe_git_args_pin_autocrlf_false() -> None: args = _safe_git_args() assert "core.autocrlf=false" in args @@ -86,12 +102,7 @@ def test_full_checkout_keeps_lf_under_system_autocrlf_true( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: origin, sha = _lf_origin(tmp_path) - host_env = _host_autocrlf_true_env(tmp_path) - for key, value in host_env.items(): - monkeypatch.setenv(key, value) - for key in list(os.environ): - if key.startswith(("GIT_CONFIG_KEY_", "GIT_CONFIG_VALUE_")) or key == "GIT_CONFIG_COUNT": - monkeypatch.delenv(key, raising=False) + _apply_hostile_host_env(tmp_path, monkeypatch) checkout = GitCache(tmp_path / "cache").get_checkout(str(origin), sha, locked_sha=sha) skill = checkout / "skills" / "demo" / "SKILL.md" @@ -107,13 +118,7 @@ def test_sparse_checkout_keeps_lf_when_env_freezes_autocrlf_true( ) -> None: """git_network_env freezes host autocrlf into GIT_CONFIG_KEY_n; only -c outranks it.""" origin, sha = _lf_origin(tmp_path) - host_env = _host_autocrlf_true_env(tmp_path) - for key, value in host_env.items(): - monkeypatch.setenv(key, value) - monkeypatch.delenv("GIT_CONFIG_COUNT", raising=False) - for key in list(os.environ): - if key.startswith(("GIT_CONFIG_KEY_", "GIT_CONFIG_VALUE_")): - monkeypatch.delenv(key, raising=False) + _apply_hostile_host_env(tmp_path, monkeypatch) checkout = GitCache(tmp_path / "cache").get_checkout( str(origin), @@ -143,13 +148,7 @@ def test_cache_hit_rematerializes_unpinned_crlf_shard( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: origin, sha = _lf_origin(tmp_path) - host_env = _host_autocrlf_true_env(tmp_path) - for key, value in host_env.items(): - monkeypatch.setenv(key, value) - monkeypatch.delenv("GIT_CONFIG_COUNT", raising=False) - for key in list(os.environ): - if key.startswith(("GIT_CONFIG_KEY_", "GIT_CONFIG_VALUE_")): - monkeypatch.delenv(key, raising=False) + _apply_hostile_host_env(tmp_path, monkeypatch) cache = GitCache(tmp_path / "cache") poisoned = cache.get_checkout(str(origin), sha, locked_sha=sha) @@ -165,13 +164,7 @@ def test_create_checkout_rematerializes_unpinned_final_dir( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: origin, sha = _lf_origin(tmp_path) - host_env = _host_autocrlf_true_env(tmp_path) - for key, value in host_env.items(): - monkeypatch.setenv(key, value) - monkeypatch.delenv("GIT_CONFIG_COUNT", raising=False) - for key in list(os.environ): - if key.startswith(("GIT_CONFIG_KEY_", "GIT_CONFIG_VALUE_")): - monkeypatch.delenv(key, raising=False) + _apply_hostile_host_env(tmp_path, monkeypatch) cache = GitCache(tmp_path / "cache") poisoned = cache.get_checkout(str(origin), sha, locked_sha=sha) @@ -183,6 +176,23 @@ def test_create_checkout_rematerializes_unpinned_final_dir( assert _checkout_pins_autocrlf_false(rebuilt) +def test_unremovable_unpinned_checkout_is_not_returned( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + origin, sha = _lf_origin(tmp_path) + _apply_hostile_host_env(tmp_path, monkeypatch) + + cache = GitCache(tmp_path / "cache") + poisoned = cache.get_checkout(str(origin), sha, locked_sha=sha) + _poison_autocrlf_pin(poisoned) + + with ( + patch.object(cache, "_evict_checkout"), + pytest.raises(RuntimeError, match=r"Failed to rematerialize unpinned git checkout"), + ): + cache.get_checkout(str(origin), sha, locked_sha=sha) + + def test_pin_reads_core_section_not_url_substring(tmp_path: Path) -> None: checkout = tmp_path / "checkout" git_dir = checkout / ".git"