|
14 | 14 | from _pytest import pathlib |
15 | 15 | from _pytest.config import Config |
16 | 16 | from _pytest.monkeypatch import MonkeyPatch |
| 17 | +from _pytest.pathlib import _chmod_rwx |
17 | 18 | from _pytest.pathlib import cleanup_numbered_dir |
18 | 19 | from _pytest.pathlib import create_cleanup_lock |
19 | 20 | from _pytest.pathlib import make_numbered_dir |
@@ -522,6 +523,10 @@ def test_removal_accepts_lock(self, tmp_path): |
522 | 523 | assert dir.is_dir() |
523 | 524 |
|
524 | 525 |
|
| 526 | +def _raise_oserror(*args: object, **kwargs: object) -> None: |
| 527 | + raise OSError("simulated failure") |
| 528 | + |
| 529 | + |
525 | 530 | class TestRmRf: |
526 | 531 | def test_rm_rf(self, tmp_path): |
527 | 532 | adir = tmp_path / "adir" |
@@ -566,6 +571,25 @@ def test_rm_rf_with_read_only_directory(self, tmp_path): |
566 | 571 |
|
567 | 572 | assert not adir.is_dir() |
568 | 573 |
|
| 574 | + @pytest.mark.skipif(not hasattr(os, "getuid"), reason="unix permissions") |
| 575 | + def test_rm_rf_with_no_exec_permission_directories(self, tmp_path): |
| 576 | + """Ensure rm_rf can remove directories without S_IXUSR (#7940). |
| 577 | +
|
| 578 | + This is the exact scenario from the original issue: nested directories |
| 579 | + and files with all permissions stripped. |
| 580 | + """ |
| 581 | + p = tmp_path / "foo" / "bar" / "baz" |
| 582 | + p.parent.mkdir(parents=True) |
| 583 | + p.touch(mode=0) |
| 584 | + for parent in p.parents: # pragma: no branch |
| 585 | + if parent == tmp_path: |
| 586 | + break |
| 587 | + parent.chmod(mode=0) |
| 588 | + |
| 589 | + rm_rf(tmp_path / "foo") |
| 590 | + |
| 591 | + assert not (tmp_path / "foo").exists() |
| 592 | + |
569 | 593 | def test_on_rm_rf_error(self, tmp_path: Path) -> None: |
570 | 594 | adir = tmp_path / "dir" |
571 | 595 | adir.mkdir() |
@@ -593,17 +617,146 @@ def test_on_rm_rf_error(self, tmp_path: Path) -> None: |
593 | 617 | on_rm_rf_error(None, str(fn), exc_info3, start_path=tmp_path) |
594 | 618 | assert fn.is_file() |
595 | 619 |
|
596 | | - # ignored function |
597 | | - with warnings.catch_warnings(record=True) as w: |
598 | | - exc_info4 = PermissionError() |
599 | | - on_rm_rf_error(os.open, str(fn), exc_info4, start_path=tmp_path) |
600 | | - assert fn.is_file() |
601 | | - assert not [x.message for x in w] |
602 | | - |
| 620 | + # os.unlink PermissionError is handled (chmod + retry) |
603 | 621 | exc_info5 = PermissionError() |
604 | 622 | on_rm_rf_error(os.unlink, str(fn), exc_info5, start_path=tmp_path) |
605 | 623 | assert not fn.is_file() |
606 | 624 |
|
| 625 | + def test_on_rm_rf_error_os_open_handles_file(self, tmp_path: Path) -> None: |
| 626 | + """os.open PermissionError on a file is handled by fixing |
| 627 | + permissions and removing it (#7940).""" |
| 628 | + adir = tmp_path / "dir" |
| 629 | + adir.mkdir() |
| 630 | + fn = adir / "foo.txt" |
| 631 | + fn.touch() |
| 632 | + self.chmod_r(fn) |
| 633 | + |
| 634 | + with warnings.catch_warnings(record=True) as w: |
| 635 | + exc_info = PermissionError() |
| 636 | + on_rm_rf_error(os.open, str(fn), exc_info, start_path=tmp_path) |
| 637 | + assert not fn.exists() |
| 638 | + assert not [x.message for x in w] |
| 639 | + |
| 640 | + @pytest.mark.skipif(not hasattr(os, "getuid"), reason="unix permissions") |
| 641 | + def test_on_rm_rf_error_os_open_handles_directory(self, tmp_path: Path) -> None: |
| 642 | + """os.open PermissionError on a directory is handled by fixing |
| 643 | + permissions and recursively removing it (#7940).""" |
| 644 | + adir = tmp_path / "dir" |
| 645 | + adir.mkdir() |
| 646 | + (adir / "child").mkdir() |
| 647 | + (adir / "child" / "file.txt").touch() |
| 648 | + os.chmod(str(adir), stat.S_IRUSR | stat.S_IWUSR) |
| 649 | + |
| 650 | + with warnings.catch_warnings(record=True) as w: |
| 651 | + exc_info = PermissionError() |
| 652 | + on_rm_rf_error(os.open, str(adir), exc_info, start_path=tmp_path) |
| 653 | + assert not adir.exists() |
| 654 | + assert not [x.message for x in w] |
| 655 | + |
| 656 | + @pytest.mark.skipif(not hasattr(os, "getuid"), reason="unix permissions") |
| 657 | + def test_on_rm_rf_error_os_open_parent_perms(self, tmp_path: Path) -> None: |
| 658 | + """When the PermissionError is caused by the *parent* directory lacking |
| 659 | + S_IXUSR, fixing the parent is sufficient even if the child already has |
| 660 | + correct permissions.""" |
| 661 | + parent = tmp_path / "parent" |
| 662 | + parent.mkdir() |
| 663 | + child = parent / "child" |
| 664 | + child.mkdir() |
| 665 | + (child / "file.txt").touch() |
| 666 | + # Child has full perms, but parent lacks execute -> os.open(child) fails. |
| 667 | + os.chmod(str(parent), stat.S_IRUSR | stat.S_IWUSR) |
| 668 | + |
| 669 | + with warnings.catch_warnings(record=True) as w: |
| 670 | + exc_info = PermissionError() |
| 671 | + result = on_rm_rf_error(os.open, str(child), exc_info, start_path=tmp_path) |
| 672 | + assert result is True |
| 673 | + assert not child.exists() |
| 674 | + assert not [x.message for x in w] |
| 675 | + |
| 676 | + def test_chmod_rwx_returns_false_on_nonexistent_path(self, tmp_path: Path) -> None: |
| 677 | + """_chmod_rwx returns False when the path doesn't exist (OSError).""" |
| 678 | + nonexistent = tmp_path / "does_not_exist" |
| 679 | + assert _chmod_rwx(str(nonexistent)) is False |
| 680 | + |
| 681 | + def test_chmod_rwx_returns_false_when_chmod_fails( |
| 682 | + self, tmp_path: Path, monkeypatch: MonkeyPatch |
| 683 | + ) -> None: |
| 684 | + """_chmod_rwx returns False when os.chmod raises OSError. |
| 685 | +
|
| 686 | + A real FS layout that makes chmod fail while the path remains |
| 687 | + (immutable bit, RO mount, foreign ownership) is not portable in CI, |
| 688 | + so pin this branch with a monkeypatch. |
| 689 | + """ |
| 690 | + fn = tmp_path / "file.txt" |
| 691 | + fn.touch(mode=0) # needs bits so we reach os.chmod |
| 692 | + monkeypatch.setattr(os, "chmod", _raise_oserror) |
| 693 | + assert _chmod_rwx(str(fn)) is False |
| 694 | + |
| 695 | + def test_chmod_rwx_returns_false_when_already_sufficient( |
| 696 | + self, tmp_path: Path |
| 697 | + ) -> None: |
| 698 | + """_chmod_rwx returns False when permissions are already sufficient.""" |
| 699 | + d = tmp_path / "dir" |
| 700 | + d.mkdir(mode=stat.S_IRWXU) |
| 701 | + assert _chmod_rwx(str(d)) is False |
| 702 | + |
| 703 | + f = tmp_path / "file" |
| 704 | + f.touch(mode=stat.S_IRUSR | stat.S_IWUSR) |
| 705 | + assert _chmod_rwx(str(f)) is False |
| 706 | + |
| 707 | + def test_on_rm_rf_error_os_open_returns_false_when_chmod_ineffective( |
| 708 | + self, tmp_path: Path |
| 709 | + ) -> None: |
| 710 | + """os.open handler returns False when neither parent nor path chmod |
| 711 | + changes anything (recursion guard).""" |
| 712 | + adir = tmp_path / "dir" |
| 713 | + adir.mkdir(mode=stat.S_IRWXU) |
| 714 | + exc_info = PermissionError() |
| 715 | + result = on_rm_rf_error(os.open, str(adir), exc_info, start_path=tmp_path) |
| 716 | + assert result is False |
| 717 | + assert adir.exists() |
| 718 | + |
| 719 | + def test_on_rm_rf_error_os_open_unlink_fails( |
| 720 | + self, tmp_path: Path, monkeypatch: MonkeyPatch |
| 721 | + ) -> None: |
| 722 | + """os.open handler returns False when chmod succeeds but os.unlink |
| 723 | + still raises OSError (e.g. sandbox or other mechanism).""" |
| 724 | + fn = tmp_path / "stubborn.txt" |
| 725 | + fn.touch(mode=0) |
| 726 | + |
| 727 | + monkeypatch.setattr(os, "unlink", _raise_oserror) |
| 728 | + |
| 729 | + exc_info = PermissionError() |
| 730 | + result = on_rm_rf_error(os.open, str(fn), exc_info, start_path=tmp_path) |
| 731 | + assert result is False |
| 732 | + assert fn.exists() |
| 733 | + |
| 734 | + @pytest.mark.skipif(not hasattr(os, "getuid"), reason="unix permissions") |
| 735 | + def test_on_rm_rf_error_chmod_retry_walks_parents(self, tmp_path: Path) -> None: |
| 736 | + """The os.rmdir/os.unlink handler walks up through multiple parent |
| 737 | + directories to fix permissions before retrying.""" |
| 738 | + deep = tmp_path / "a" / "b" / "c" |
| 739 | + deep.mkdir(parents=True) |
| 740 | + fn = deep / "file.txt" |
| 741 | + fn.touch() |
| 742 | + # Remove write from intermediate dirs (keep exec so traversal works, |
| 743 | + # but os.unlink needs write on the parent). |
| 744 | + for parent in fn.parents: # pragma: no branch |
| 745 | + if parent == tmp_path: |
| 746 | + break |
| 747 | + parent.chmod( |
| 748 | + stat.S_IRUSR |
| 749 | + | stat.S_IXUSR |
| 750 | + | stat.S_IRGRP |
| 751 | + | stat.S_IXGRP |
| 752 | + | stat.S_IROTH |
| 753 | + | stat.S_IXOTH |
| 754 | + ) |
| 755 | + |
| 756 | + exc_info = PermissionError() |
| 757 | + on_rm_rf_error(os.unlink, str(fn), exc_info, start_path=tmp_path) |
| 758 | + assert not fn.exists() |
| 759 | + |
607 | 760 |
|
608 | 761 | def attempt_symlink_to(path, to_path): |
609 | 762 | """Try to make a symlink from "path" to "to_path", skipping in case this platform |
|
0 commit comments