From d216172d0b27b9d31cc89bc3cc06e8645b051aaa Mon Sep 17 00:00:00 2001 From: danimtb Date: Wed, 12 Aug 2026 12:31:20 +0200 Subject: [PATCH 1/2] Fix ``conan cache restore`` crashing with ``PermissionError`` when the cache contains read-only files --- conan/api/subapi/cache.py | 10 +++- .../command/cache/test_cache_save_restore.py | 56 +++++++++++++++++++ 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/conan/api/subapi/cache.py b/conan/api/subapi/cache.py index c95796ce4f0..5a8bebf3762 100644 --- a/conan/api/subapi/cache.py +++ b/conan/api/subapi/cache.py @@ -1,6 +1,7 @@ import json import os import shutil +import stat import tarfile import tempfile @@ -396,6 +397,11 @@ def restore(self, path) -> PackagesList: fileobj = the_tar.extractfile("pkglist.json") pkglist = fileobj.read() the_tar.extraction_filter = (lambda member, _: member) # fully_trusted (Py 3.14) + for member in the_tar.getmembers(): + # Extraction opens the destination for writing, read-only files would fail + dest = os.path.join(cache_folder, member.name) + if member.isfile() and os.path.isfile(dest) and not os.access(dest, os.W_OK): + os.chmod(dest, os.stat(dest).st_mode | stat.S_IWRITE) the_tar.extractall(path=cache_folder) the_tar.close() @@ -434,7 +440,7 @@ def restore(self, path) -> PackagesList: if db_pkg_folder != unzipped_pkg_folder: # If a previous package exists, like a previous restore, then remove it if os.path.exists(pkg_layout.package()): - shutil.rmtree(pkg_layout.package()) + rmdir(pkg_layout.package()) shutil.move(os.path.join(cache_folder, unzipped_pkg_folder), pkg_layout.package()) pref_bundle["package_folder"] = db_pkg_folder @@ -447,7 +453,7 @@ def restore(self, path) -> PackagesList: if db_metadata_folder != unzipped_metadata_folder: # We need to put the package in the final location in the cache if os.path.exists(pkg_layout.metadata()): - shutil.rmtree(pkg_layout.metadata()) + rmdir(pkg_layout.metadata()) shutil.move(os.path.join(cache_folder, unzipped_metadata_folder), pkg_layout.metadata()) pref_bundle["metadata_folder"] = db_metadata_folder diff --git a/test/integration/command/cache/test_cache_save_restore.py b/test/integration/command/cache/test_cache_save_restore.py index 6f301daa87f..d267fe32977 100644 --- a/test/integration/command/cache/test_cache_save_restore.py +++ b/test/integration/command/cache/test_cache_save_restore.py @@ -4,6 +4,7 @@ import shutil import sys import tarfile +import textwrap import time import pytest @@ -68,6 +69,61 @@ def test_cache_save_restore_with_package_file(): assert tree2 == tree +_READ_ONLY_CONANFILE = textwrap.dedent(""" + import os, stat + from conan import ConanFile + from conan.tools.files import save + + class Pkg(ConanFile): + name = "pkg" + version = "1.0" + def package(self): + f = os.path.join(self.package_folder, "bin", "readonly.txt") + save(self, f, "content!!") + os.chmod(f, stat.S_IREAD) + """) + + +def _check_restored_read_only(client): + client.run("list *:*#*") + assert "pkg/1.0" in client.out + ref_layout = client.get_latest_ref_layout(RecipeReference.loads("pkg/1.0")) + pkg_layout = client.get_latest_pkg_layout(PkgReference(ref_layout.reference, + NO_SETTINGS_PACKAGE_ID)) + assert load(os.path.join(pkg_layout.package(), "bin", "readonly.txt")) == "content!!" + + +def test_cache_restore_read_only_files_in_place(): + """ restoring over a cache that already contains those same read-only files, the extraction + happens in place, over the existing read-only files + https://github.com/conan-io/conan/issues/20241 + """ + c = TestClient() + c.save({"conanfile.py": _READ_ONLY_CONANFILE}) + c.run("create .") + c.run("cache save *:*") + c.run("cache restore conan_cache_save.tgz") + _check_restored_read_only(c) + + +def test_cache_save_restore_read_only_files(): + """ restoring in a different cache, the package folder is relocated, so a previously + restored package folder with read-only files has to be removed + https://github.com/conan-io/conan/issues/20241 + """ + c = TestClient() + c.save({"conanfile.py": _READ_ONLY_CONANFILE}) + c.run("create .") + c.run("cache save *:*") + cache_path = os.path.join(c.current_folder, "conan_cache_save.tgz") + + c2 = TestClient() + c2.run(f'cache restore "{cache_path}"') + # The restored files are read-only, restoring again over them must still work + c2.run(f'cache restore "{cache_path}"') + _check_restored_read_only(c2) + + def test_cache_save_downloaded_restore(): """ what happens if we save packages downloaded from server, not created From 4f87b3e4100da372f4de4a6533f82becf854a4d9 Mon Sep 17 00:00:00 2001 From: danimtb Date: Fri, 14 Aug 2026 23:16:28 +0200 Subject: [PATCH 2/2] harden tests --- .../command/cache/test_cache_save_restore.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/test/integration/command/cache/test_cache_save_restore.py b/test/integration/command/cache/test_cache_save_restore.py index d267fe32977..8a52f23c7f4 100644 --- a/test/integration/command/cache/test_cache_save_restore.py +++ b/test/integration/command/cache/test_cache_save_restore.py @@ -2,6 +2,7 @@ import os import platform import shutil +import stat import sys import tarfile import textwrap @@ -84,13 +85,16 @@ def package(self): """) -def _check_restored_read_only(client): - client.run("list *:*#*") - assert "pkg/1.0" in client.out +def _assert_read_only_file(client): + """ Check the packaged file and return its permission mode. """ ref_layout = client.get_latest_ref_layout(RecipeReference.loads("pkg/1.0")) pkg_layout = client.get_latest_pkg_layout(PkgReference(ref_layout.reference, NO_SETTINGS_PACKAGE_ID)) - assert load(os.path.join(pkg_layout.package(), "bin", "readonly.txt")) == "content!!" + f = os.path.join(pkg_layout.package(), "bin", "readonly.txt") + assert load(f) == "content!!" + mode = stat.S_IMODE(os.stat(f).st_mode) + assert (mode & stat.S_IWRITE) == 0 + return mode def test_cache_restore_read_only_files_in_place(): @@ -101,9 +105,11 @@ def test_cache_restore_read_only_files_in_place(): c = TestClient() c.save({"conanfile.py": _READ_ONLY_CONANFILE}) c.run("create .") + mode = _assert_read_only_file(c) c.run("cache save *:*") c.run("cache restore conan_cache_save.tgz") - _check_restored_read_only(c) + # Files are made writable just to overwrite them, extraction restores the archived mode + assert _assert_read_only_file(c) == mode def test_cache_save_restore_read_only_files(): @@ -114,6 +120,7 @@ def test_cache_save_restore_read_only_files(): c = TestClient() c.save({"conanfile.py": _READ_ONLY_CONANFILE}) c.run("create .") + mode = _assert_read_only_file(c) c.run("cache save *:*") cache_path = os.path.join(c.current_folder, "conan_cache_save.tgz") @@ -121,7 +128,7 @@ def test_cache_save_restore_read_only_files(): c2.run(f'cache restore "{cache_path}"') # The restored files are read-only, restoring again over them must still work c2.run(f'cache restore "{cache_path}"') - _check_restored_read_only(c2) + assert _assert_read_only_file(c2) == mode def test_cache_save_downloaded_restore():