From cb6020cbde74e45e1fea1eb65915e22d01dd84e8 Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Mon, 31 Aug 2026 14:08:00 -0300 Subject: [PATCH 001/117] Start Phase 10 File Organizer core --- .../06-file-organizer/file_organizer.py | 340 ++++++++++++++++++ 1 file changed, 340 insertions(+) create mode 100644 practical-projects/06-file-organizer/file_organizer.py diff --git a/practical-projects/06-file-organizer/file_organizer.py b/practical-projects/06-file-organizer/file_organizer.py new file mode 100644 index 0000000..8881a40 --- /dev/null +++ b/practical-projects/06-file-organizer/file_organizer.py @@ -0,0 +1,340 @@ +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +from os import PathLike +from pathlib import Path + + +class FileCategory(str, Enum): + """Destination categories supported by the organizer.""" + + DOCUMENTS = "documents" + DATA = "data" + IMAGES = "images" + ARCHIVES = "archives" + OTHER = "other" + + +class CollisionPolicy(str, Enum): + """How planning handles a destination name that already exists.""" + + ERROR = "error" + SKIP = "skip" + + +_DOCUMENT_SUFFIXES = frozenset({".txt", ".md", ".pdf", ".doc", ".docx", ".odt"}) +_DATA_SUFFIXES = frozenset({".csv", ".json", ".xml", ".xls", ".xlsx"}) +_IMAGE_SUFFIXES = frozenset({".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"}) +_ARCHIVE_SUFFIXES = frozenset({".zip", ".tar", ".gz", ".bz2", ".xz", ".7z"}) +_COMPOUND_ARCHIVE_SUFFIXES = (".tar.gz", ".tar.bz2", ".tar.xz") + + +def _coerce_path(value: str | PathLike[str], field_name: str) -> Path: + if isinstance(value, bool) or not isinstance(value, (str, PathLike)): + raise TypeError(f"{field_name} must be a path-like value") + try: + return Path(value) + except (TypeError, ValueError, OSError) as exc: + raise TypeError(f"{field_name} must be a valid path-like value") from exc + + +def _require_source_directory(value: str | PathLike[str]) -> Path: + path = _coerce_path(value, "source_directory") + if path.is_symlink(): + raise ValueError("source_directory cannot be a symlink") + if not path.exists(): + raise FileNotFoundError(f"source_directory does not exist: {path}") + if not path.is_dir(): + raise NotADirectoryError(f"source_directory is not a directory: {path}") + return path.resolve() + + +def _path_sort_key(path: Path) -> tuple[str, str]: + return path.name.casefold(), path.name + + +@dataclass(frozen=True, slots=True) +class MoveAction: + """One planned move from the source directory into a category folder.""" + + source: Path + destination: Path + category: FileCategory + + def __post_init__(self) -> None: + if not isinstance(self.source, Path) or not isinstance(self.destination, Path): + raise TypeError("source and destination must be Path values") + if not isinstance(self.category, FileCategory): + raise TypeError("category must be a FileCategory") + if not self.source.is_absolute() or not self.destination.is_absolute(): + raise ValueError("source and destination must be absolute paths") + if self.source == self.destination: + raise ValueError("source and destination must differ") + if self.source.name != self.destination.name: + raise ValueError("destination must preserve the source filename") + if self.destination.parent.name != self.category.value: + raise ValueError("destination directory must match the file category") + + +@dataclass(frozen=True, slots=True) +class OrganizationPlan: + """Immutable organization plan produced before filesystem mutation.""" + + source_directory: Path + actions: tuple[MoveAction, ...] + skipped_collisions: tuple[Path, ...] + ignored_symlinks: tuple[Path, ...] + + def __post_init__(self) -> None: + if not isinstance(self.source_directory, Path): + raise TypeError("source_directory must be a Path") + if not self.source_directory.is_absolute(): + raise ValueError("source_directory must be absolute") + if not isinstance(self.actions, tuple) or any( + not isinstance(action, MoveAction) for action in self.actions + ): + raise TypeError("actions must be a tuple of MoveAction values") + for field_name, values in ( + ("skipped_collisions", self.skipped_collisions), + ("ignored_symlinks", self.ignored_symlinks), + ): + if not isinstance(values, tuple) or any( + not isinstance(path, Path) for path in values + ): + raise TypeError(f"{field_name} must be a tuple of Path values") + + for action in self.actions: + if action.source.parent != self.source_directory: + raise ValueError("planned sources must be direct children of source_directory") + if action.destination.parent.parent != self.source_directory: + raise ValueError( + "planned destinations must be category folders inside source_directory" + ) + + for path in (*self.skipped_collisions, *self.ignored_symlinks): + if not path.is_absolute() or path.parent != self.source_directory: + raise ValueError( + "skipped and ignored paths must be direct children of source_directory" + ) + + expected_actions = tuple( + sorted(self.actions, key=lambda item: _path_sort_key(item.source)) + ) + if self.actions != expected_actions: + raise ValueError("actions must be sorted by source filename") + + for field_name, values in ( + ("skipped_collisions", self.skipped_collisions), + ("ignored_symlinks", self.ignored_symlinks), + ): + if values != tuple(sorted(values, key=_path_sort_key)): + raise ValueError(f"{field_name} must be sorted by filename") + + source_keys = tuple(action.source.name.casefold() for action in self.actions) + if len(source_keys) != len(set(source_keys)): + raise ValueError("planned source filenames must be unique case-insensitively") + + destination_keys = tuple( + (action.category.value, action.destination.name.casefold()) + for action in self.actions + ) + if len(destination_keys) != len(set(destination_keys)): + raise ValueError("planned destinations must be unique case-insensitively") + + @property + def planned_count(self) -> int: + return len(self.actions) + + @property + def skipped_collision_count(self) -> int: + return len(self.skipped_collisions) + + @property + def ignored_symlink_count(self) -> int: + return len(self.ignored_symlinks) + + +@dataclass(frozen=True, slots=True) +class OrganizationResult: + """Result of successfully executing one complete organization plan.""" + + plan: OrganizationPlan + moved_files: tuple[Path, ...] + + def __post_init__(self) -> None: + if not isinstance(self.plan, OrganizationPlan): + raise TypeError("plan must be an OrganizationPlan") + if not isinstance(self.moved_files, tuple) or any( + not isinstance(path, Path) for path in self.moved_files + ): + raise TypeError("moved_files must be a tuple of Path values") + expected = tuple(action.destination for action in self.plan.actions) + if self.moved_files != expected: + raise ValueError("moved_files must match the plan destinations") + + @property + def moved_count(self) -> int: + return len(self.moved_files) + + +def classify_path(path: str | PathLike[str]) -> FileCategory: + """Classify a filename by its suffix without reading file contents.""" + value = _coerce_path(path, "path") + name = value.name.casefold() + + if any(name.endswith(suffix) for suffix in _COMPOUND_ARCHIVE_SUFFIXES): + return FileCategory.ARCHIVES + + suffix = value.suffix.casefold() + if suffix in _DOCUMENT_SUFFIXES: + return FileCategory.DOCUMENTS + if suffix in _DATA_SUFFIXES: + return FileCategory.DATA + if suffix in _IMAGE_SUFFIXES: + return FileCategory.IMAGES + if suffix in _ARCHIVE_SUFFIXES: + return FileCategory.ARCHIVES + return FileCategory.OTHER + + +def _scan_source_directory( + source_directory: Path, +) -> tuple[tuple[Path, ...], tuple[Path, ...]]: + files: list[Path] = [] + symlinks: list[Path] = [] + + for child in sorted(source_directory.iterdir(), key=_path_sort_key): + if child.is_symlink(): + symlinks.append(child.absolute()) + elif child.is_file(): + files.append(child.absolute()) + + return tuple(files), tuple(symlinks) + + +def discover_files(source_directory: str | PathLike[str]) -> tuple[Path, ...]: + """Return direct regular-file children in deterministic order.""" + root = _require_source_directory(source_directory) + files, _ = _scan_source_directory(root) + return files + + +def _validate_category_locations(source_directory: Path) -> None: + for category in FileCategory: + target = source_directory / category.value + if target.is_symlink(): + raise ValueError(f"category directory cannot be a symlink: {target.name}") + if target.exists() and not target.is_dir(): + raise NotADirectoryError( + f"category path exists but is not a directory: {target.name}" + ) + + +def _existing_names_casefold(directory: Path) -> set[str]: + if not directory.exists(): + return set() + return {child.name.casefold() for child in directory.iterdir()} + + +def plan_organization( + source_directory: str | PathLike[str], + *, + collision_policy: CollisionPolicy = CollisionPolicy.ERROR, +) -> OrganizationPlan: + """Build a deterministic, non-mutating plan for direct child files.""" + root = _require_source_directory(source_directory) + if not isinstance(collision_policy, CollisionPolicy): + raise TypeError("collision_policy must be a CollisionPolicy") + + _validate_category_locations(root) + files, symlinks = _scan_source_directory(root) + existing_by_category = { + category: _existing_names_casefold(root / category.value) + for category in FileCategory + } + + actions: list[MoveAction] = [] + skipped: list[Path] = [] + planned_keys: set[tuple[FileCategory, str]] = set() + + for source in files: + category = classify_path(source) + destination = (root / category.value / source.name).absolute() + key = (category, source.name.casefold()) + collides = ( + source.name.casefold() in existing_by_category[category] + or key in planned_keys + ) + + if collides: + if collision_policy is CollisionPolicy.ERROR: + raise FileExistsError( + f"destination already exists for source file: {source.name}" + ) + skipped.append(source) + continue + + actions.append( + MoveAction( + source=source, + destination=destination, + category=category, + ) + ) + planned_keys.add(key) + + return OrganizationPlan( + source_directory=root, + actions=tuple(actions), + skipped_collisions=tuple(skipped), + ignored_symlinks=symlinks, + ) + + +def _preflight_execution(plan: OrganizationPlan) -> None: + root = _require_source_directory(plan.source_directory) + if root != plan.source_directory: + raise ValueError("source_directory no longer resolves to the planned directory") + + _validate_category_locations(root) + + for action in plan.actions: + if action.source.is_symlink() or not action.source.is_file(): + raise FileNotFoundError( + f"planned source is no longer a regular file: {action.source.name}" + ) + + for action in plan.actions: + target_directory = action.destination.parent + if target_directory.exists(): + current_names = _existing_names_casefold(target_directory) + if action.destination.name.casefold() in current_names: + raise FileExistsError( + f"destination appeared after planning: {action.destination.name}" + ) + elif action.destination.exists() or action.destination.is_symlink(): + raise FileExistsError( + f"destination appeared after planning: {action.destination.name}" + ) + + +def execute_plan(plan: OrganizationPlan) -> OrganizationResult: + """Execute a previously validated plan after a full collision preflight.""" + if not isinstance(plan, OrganizationPlan): + raise TypeError("plan must be an OrganizationPlan") + + _preflight_execution(plan) + + for directory in sorted( + {action.destination.parent for action in plan.actions}, + key=lambda path: (path.name.casefold(), path.name), + ): + directory.mkdir(exist_ok=True) + + moved: list[Path] = [] + for action in plan.actions: + action.source.rename(action.destination) + moved.append(action.destination) + + return OrganizationResult(plan=plan, moved_files=tuple(moved)) From 197d275db378fe38e93d89a7520d44a774be440b Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Mon, 31 Aug 2026 14:08:17 -0300 Subject: [PATCH 002/117] Add File Organizer deterministic demo --- practical-projects/06-file-organizer/demo.py | 36 ++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 practical-projects/06-file-organizer/demo.py diff --git a/practical-projects/06-file-organizer/demo.py b/practical-projects/06-file-organizer/demo.py new file mode 100644 index 0000000..ebd72b8 --- /dev/null +++ b/practical-projects/06-file-organizer/demo.py @@ -0,0 +1,36 @@ +from pathlib import Path +from tempfile import TemporaryDirectory + +from file_organizer import execute_plan, plan_organization + + +def main() -> None: + """Run a deterministic fictional organization workflow in a temporary folder.""" + with TemporaryDirectory() as temporary_directory: + workspace = Path(temporary_directory) + fictional_files = { + "meeting-notes.txt": "Agenda notes\n", + "orders.csv": "id,total\n101,50\n", + "product-photo.png": "fictional image placeholder\n", + "backup.zip": "fictional archive placeholder\n", + "automation.py": "print('fictional')\n", + } + for name, content in fictional_files.items(): + (workspace / name).write_text(content, encoding="utf-8") + + plan = plan_organization(workspace) + print(f"planned moves: {plan.planned_count}") + for action in plan.actions: + print( + f"{action.source.name} -> " + f"{action.destination.parent.name}/{action.destination.name}" + ) + + result = execute_plan(plan) + print(f"moved files: {result.moved_count}") + folders = sorted(path.name for path in workspace.iterdir() if path.is_dir()) + print(f"created folders: {', '.join(folders)}") + + +if __name__ == "__main__": + main() From ca532278558ff9e44043184047e35dae2fac1d79 Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Mon, 31 Aug 2026 14:08:23 -0300 Subject: [PATCH 003/117] Add File Organizer pytest path setup --- practical-projects/06-file-organizer/tests/conftest.py | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 practical-projects/06-file-organizer/tests/conftest.py diff --git a/practical-projects/06-file-organizer/tests/conftest.py b/practical-projects/06-file-organizer/tests/conftest.py new file mode 100644 index 0000000..6190fbf --- /dev/null +++ b/practical-projects/06-file-organizer/tests/conftest.py @@ -0,0 +1,5 @@ +from pathlib import Path +import sys + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(PROJECT_ROOT)) From f0d840fd34d02dc2b6c24c80f2a75aa0cb3c8d37 Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Mon, 31 Aug 2026 14:09:14 -0300 Subject: [PATCH 004/117] Add File Organizer regression suite --- .../tests/test_file_organizer.py | 425 ++++++++++++++++++ 1 file changed, 425 insertions(+) create mode 100644 practical-projects/06-file-organizer/tests/test_file_organizer.py diff --git a/practical-projects/06-file-organizer/tests/test_file_organizer.py b/practical-projects/06-file-organizer/tests/test_file_organizer.py new file mode 100644 index 0000000..fc265c5 --- /dev/null +++ b/practical-projects/06-file-organizer/tests/test_file_organizer.py @@ -0,0 +1,425 @@ +from pathlib import Path + +import pytest + +from file_organizer import ( + CollisionPolicy, + FileCategory, + MoveAction, + OrganizationPlan, + OrganizationResult, + classify_path, + discover_files, + execute_plan, + plan_organization, +) + + +@pytest.mark.parametrize( + ("name", "expected"), + [ + ("notes.txt", FileCategory.DOCUMENTS), + ("README.MD", FileCategory.DOCUMENTS), + ("report.pdf", FileCategory.DOCUMENTS), + ("records.csv", FileCategory.DATA), + ("payload.JSON", FileCategory.DATA), + ("sheet.xlsx", FileCategory.DATA), + ("photo.png", FileCategory.IMAGES), + ("photo.JPEG", FileCategory.IMAGES), + ("vector.svg", FileCategory.IMAGES), + ("backup.zip", FileCategory.ARCHIVES), + ("backup.tar.gz", FileCategory.ARCHIVES), + ("backup.TAR.XZ", FileCategory.ARCHIVES), + ("script.py", FileCategory.OTHER), + ("LICENSE", FileCategory.OTHER), + ], +) +def test_classify_path_by_suffix(name: str, expected: FileCategory) -> None: + assert classify_path(name) is expected + + +@pytest.mark.parametrize("value", [None, 42, True, 3.14]) +def test_classify_path_rejects_non_path_like_values(value: object) -> None: + with pytest.raises(TypeError, match="path-like"): + classify_path(value) # type: ignore[arg-type] + + +def test_discover_files_returns_direct_regular_files_in_deterministic_order(tmp_path: Path) -> None: + (tmp_path / "b.txt").write_text("b", encoding="utf-8") + (tmp_path / "A.txt").write_text("a", encoding="utf-8") + (tmp_path / "nested").mkdir() + (tmp_path / "nested" / "ignored.txt").write_text("x", encoding="utf-8") + + files = discover_files(tmp_path) + + assert tuple(path.name for path in files) == ("A.txt", "b.txt") + assert all(path.is_absolute() for path in files) + + +def test_discover_files_ignores_symlinks(tmp_path: Path) -> None: + target = tmp_path / "target.txt" + target.write_text("x", encoding="utf-8") + link = tmp_path / "linked.txt" + try: + link.symlink_to(target) + except OSError: + pytest.skip("symlinks are not available in this environment") + + assert tuple(path.name for path in discover_files(tmp_path)) == ("target.txt",) + + +def test_discover_files_accepts_string_directory(tmp_path: Path) -> None: + (tmp_path / "a.txt").write_text("x", encoding="utf-8") + assert discover_files(str(tmp_path))[0].name == "a.txt" + + +def test_discover_files_rejects_missing_directory(tmp_path: Path) -> None: + with pytest.raises(FileNotFoundError): + discover_files(tmp_path / "missing") + + +def test_discover_files_rejects_regular_file(tmp_path: Path) -> None: + source = tmp_path / "file.txt" + source.write_text("x", encoding="utf-8") + with pytest.raises(NotADirectoryError): + discover_files(source) + + +def test_discover_files_rejects_symlink_source_directory(tmp_path: Path) -> None: + real = tmp_path / "real" + real.mkdir() + link = tmp_path / "link" + try: + link.symlink_to(real, target_is_directory=True) + except OSError: + pytest.skip("symlinks are not available in this environment") + + with pytest.raises(ValueError, match="cannot be a symlink"): + discover_files(link) + + +def test_plan_organization_builds_expected_categories_without_mutating(tmp_path: Path) -> None: + for name in ("notes.txt", "rows.csv", "image.png", "backup.zip", "script.py"): + (tmp_path / name).write_text("x", encoding="utf-8") + + plan = plan_organization(tmp_path) + + assert plan.planned_count == 5 + assert plan.skipped_collision_count == 0 + assert tuple(action.category for action in plan.actions) == ( + FileCategory.ARCHIVES, + FileCategory.IMAGES, + FileCategory.DOCUMENTS, + FileCategory.DATA, + FileCategory.OTHER, + ) + assert all(action.source.exists() for action in plan.actions) + assert not any((tmp_path / category.value).exists() for category in FileCategory) + + +def test_plan_organization_preserves_filenames(tmp_path: Path) -> None: + source = tmp_path / "Quarterly Report.PDF" + source.write_text("x", encoding="utf-8") + plan = plan_organization(tmp_path) + action = plan.actions[0] + assert action.destination.name == source.name + assert action.destination.parent.name == "documents" + + +def test_plan_organization_reports_ignored_symlinks(tmp_path: Path) -> None: + target = tmp_path / "target.txt" + target.write_text("x", encoding="utf-8") + link = tmp_path / "linked.txt" + try: + link.symlink_to(target) + except OSError: + pytest.skip("symlinks are not available in this environment") + + plan = plan_organization(tmp_path) + assert plan.ignored_symlink_count == 1 + assert plan.ignored_symlinks[0].name == "linked.txt" + assert tuple(action.source.name for action in plan.actions) == ("target.txt",) + + +def test_plan_organization_rejects_raw_collision_policy(tmp_path: Path) -> None: + with pytest.raises(TypeError, match="CollisionPolicy"): + plan_organization(tmp_path, collision_policy="skip") # type: ignore[arg-type] + + +def test_plan_organization_errors_on_existing_destination(tmp_path: Path) -> None: + (tmp_path / "report.txt").write_text("new", encoding="utf-8") + documents = tmp_path / "documents" + documents.mkdir() + (documents / "report.txt").write_text("old", encoding="utf-8") + + with pytest.raises(FileExistsError, match="report.txt"): + plan_organization(tmp_path) + + +def test_plan_organization_detects_casefold_collision(tmp_path: Path) -> None: + (tmp_path / "Report.TXT").write_text("new", encoding="utf-8") + documents = tmp_path / "documents" + documents.mkdir() + (documents / "report.txt").write_text("old", encoding="utf-8") + + with pytest.raises(FileExistsError): + plan_organization(tmp_path) + + +def test_plan_organization_can_skip_existing_destination(tmp_path: Path) -> None: + (tmp_path / "report.txt").write_text("new", encoding="utf-8") + (tmp_path / "data.csv").write_text("data", encoding="utf-8") + documents = tmp_path / "documents" + documents.mkdir() + (documents / "report.txt").write_text("old", encoding="utf-8") + + plan = plan_organization(tmp_path, collision_policy=CollisionPolicy.SKIP) + + assert plan.planned_count == 1 + assert plan.skipped_collision_count == 1 + assert plan.skipped_collisions[0].name == "report.txt" + assert plan.actions[0].source.name == "data.csv" + + +def test_plan_organization_rejects_category_path_that_is_a_file(tmp_path: Path) -> None: + (tmp_path / "documents").write_text("not a directory", encoding="utf-8") + with pytest.raises(NotADirectoryError, match="documents"): + plan_organization(tmp_path) + + +def test_plan_organization_rejects_category_directory_symlink(tmp_path: Path) -> None: + real = tmp_path / "real-documents" + real.mkdir() + link = tmp_path / "documents" + try: + link.symlink_to(real, target_is_directory=True) + except OSError: + pytest.skip("symlinks are not available in this environment") + + with pytest.raises(ValueError, match="category directory cannot be a symlink"): + plan_organization(tmp_path) + + +def test_plan_empty_directory_is_valid(tmp_path: Path) -> None: + plan = plan_organization(tmp_path) + assert plan.actions == () + assert plan.skipped_collisions == () + assert plan.ignored_symlinks == () + + +def test_move_action_validates_category_type(tmp_path: Path) -> None: + source = (tmp_path / "a.txt").absolute() + destination = (tmp_path / "documents" / "a.txt").absolute() + with pytest.raises(TypeError, match="FileCategory"): + MoveAction(source, destination, "documents") # type: ignore[arg-type] + + +def test_move_action_requires_absolute_paths(tmp_path: Path) -> None: + with pytest.raises(ValueError, match="absolute"): + MoveAction(Path("a.txt"), Path("documents/a.txt"), FileCategory.DOCUMENTS) + + +def test_move_action_requires_preserved_filename(tmp_path: Path) -> None: + source = (tmp_path / "a.txt").absolute() + destination = (tmp_path / "documents" / "b.txt").absolute() + with pytest.raises(ValueError, match="preserve"): + MoveAction(source, destination, FileCategory.DOCUMENTS) + + +def test_move_action_requires_category_directory(tmp_path: Path) -> None: + source = (tmp_path / "a.txt").absolute() + destination = (tmp_path / "data" / "a.txt").absolute() + with pytest.raises(ValueError, match="category"): + MoveAction(source, destination, FileCategory.DOCUMENTS) + + +def test_organization_plan_requires_sorted_actions(tmp_path: Path) -> None: + root = tmp_path.resolve() + first = MoveAction(root / "a.txt", root / "documents" / "a.txt", FileCategory.DOCUMENTS) + second = MoveAction(root / "b.txt", root / "documents" / "b.txt", FileCategory.DOCUMENTS) + with pytest.raises(ValueError, match="sorted"): + OrganizationPlan(root, (second, first), (), ()) + + +def test_organization_plan_requires_sources_inside_root(tmp_path: Path) -> None: + root = tmp_path.resolve() + outside = root.parent / "outside.txt" + action = MoveAction(outside, root / "documents" / "outside.txt", FileCategory.DOCUMENTS) + with pytest.raises(ValueError, match="direct children"): + OrganizationPlan(root, (action,), (), ()) + + +def test_organization_plan_requires_destinations_inside_root(tmp_path: Path) -> None: + root = tmp_path.resolve() + source = root / "a.txt" + destination = root.parent / "documents" / "a.txt" + action = MoveAction(source, destination, FileCategory.DOCUMENTS) + with pytest.raises(ValueError, match="category folders inside"): + OrganizationPlan(root, (action,), (), ()) + + +def test_execute_plan_moves_files_and_creates_only_needed_directories(tmp_path: Path) -> None: + (tmp_path / "notes.txt").write_text("notes", encoding="utf-8") + (tmp_path / "rows.csv").write_text("a,b\n1,2\n", encoding="utf-8") + plan = plan_organization(tmp_path) + + result = execute_plan(plan) + + assert result.moved_count == 2 + assert (tmp_path / "documents" / "notes.txt").read_text(encoding="utf-8") == "notes" + assert (tmp_path / "data" / "rows.csv").exists() + assert not (tmp_path / "images").exists() + assert not (tmp_path / "archives").exists() + assert not (tmp_path / "other").exists() + + +def test_execute_plan_preserves_skipped_collision_source(tmp_path: Path) -> None: + source = tmp_path / "report.txt" + source.write_text("new", encoding="utf-8") + documents = tmp_path / "documents" + documents.mkdir() + (documents / "report.txt").write_text("old", encoding="utf-8") + plan = plan_organization(tmp_path, collision_policy=CollisionPolicy.SKIP) + + result = execute_plan(plan) + + assert result.moved_count == 0 + assert source.read_text(encoding="utf-8") == "new" + assert (documents / "report.txt").read_text(encoding="utf-8") == "old" + + +def test_execute_plan_rejects_non_plan() -> None: + with pytest.raises(TypeError, match="OrganizationPlan"): + execute_plan(object()) # type: ignore[arg-type] + + +def test_execute_plan_preflights_missing_source_before_mutation(tmp_path: Path) -> None: + first = tmp_path / "a.txt" + second = tmp_path / "b.csv" + first.write_text("a", encoding="utf-8") + second.write_text("b", encoding="utf-8") + plan = plan_organization(tmp_path) + second.unlink() + + with pytest.raises(FileNotFoundError): + execute_plan(plan) + + assert first.exists() + assert not (tmp_path / "documents").exists() + assert not (tmp_path / "data").exists() + + +def test_execute_plan_preflights_new_exact_collision_before_mutation(tmp_path: Path) -> None: + first = tmp_path / "a.txt" + second = tmp_path / "b.csv" + first.write_text("a", encoding="utf-8") + second.write_text("b", encoding="utf-8") + plan = plan_organization(tmp_path) + documents = tmp_path / "documents" + documents.mkdir() + (documents / "a.txt").write_text("existing", encoding="utf-8") + + with pytest.raises(FileExistsError, match="appeared after planning"): + execute_plan(plan) + + assert first.exists() + assert second.exists() + assert not (tmp_path / "data").exists() + + +def test_execute_plan_preflights_new_casefold_collision_before_mutation(tmp_path: Path) -> None: + source = tmp_path / "Report.TXT" + source.write_text("new", encoding="utf-8") + plan = plan_organization(tmp_path) + documents = tmp_path / "documents" + documents.mkdir() + (documents / "report.txt").write_text("old", encoding="utf-8") + + with pytest.raises(FileExistsError): + execute_plan(plan) + assert source.exists() + + +def test_execute_plan_rejects_category_path_replaced_by_file(tmp_path: Path) -> None: + source = tmp_path / "a.txt" + source.write_text("x", encoding="utf-8") + plan = plan_organization(tmp_path) + (tmp_path / "documents").write_text("block", encoding="utf-8") + + with pytest.raises(NotADirectoryError): + execute_plan(plan) + assert source.exists() + + +def test_execute_plan_rejects_source_replaced_by_symlink(tmp_path: Path) -> None: + source = tmp_path / "a.txt" + source.write_text("x", encoding="utf-8") + plan = plan_organization(tmp_path) + source.unlink() + target = tmp_path / "target.txt" + target.write_text("target", encoding="utf-8") + try: + source.symlink_to(target) + except OSError: + pytest.skip("symlinks are not available in this environment") + + with pytest.raises(FileNotFoundError): + execute_plan(plan) + assert target.read_text(encoding="utf-8") == "target" + + +def test_execute_empty_plan_creates_nothing(tmp_path: Path) -> None: + plan = plan_organization(tmp_path) + result = execute_plan(plan) + assert result.moved_files == () + assert list(tmp_path.iterdir()) == [] + + +def test_organization_result_requires_exact_destinations(tmp_path: Path) -> None: + source = tmp_path / "a.txt" + source.write_text("x", encoding="utf-8") + plan = plan_organization(tmp_path) + with pytest.raises(ValueError, match="match"): + OrganizationResult(plan, ()) + + +def test_organization_result_rejects_non_plan(tmp_path: Path) -> None: + with pytest.raises(TypeError, match="plan"): + OrganizationResult(object(), ()) # type: ignore[arg-type] + + +def test_plan_properties_reflect_counts(tmp_path: Path) -> None: + (tmp_path / "a.txt").write_text("x", encoding="utf-8") + plan = plan_organization(tmp_path) + assert plan.planned_count == len(plan.actions) == 1 + assert plan.skipped_collision_count == 0 + assert plan.ignored_symlink_count == 0 + + +def test_classification_does_not_require_file_to_exist() -> None: + assert classify_path("fictional/path/report.csv") is FileCategory.DATA + + +def test_plan_does_not_recurse_into_existing_category_directories(tmp_path: Path) -> None: + documents = tmp_path / "documents" + documents.mkdir() + (documents / "already.txt").write_text("x", encoding="utf-8") + (tmp_path / "new.txt").write_text("y", encoding="utf-8") + + plan = plan_organization(tmp_path) + + assert tuple(action.source.name for action in plan.actions) == ("new.txt",) + + +def test_execute_plan_keeps_existing_unrelated_category_files(tmp_path: Path) -> None: + documents = tmp_path / "documents" + documents.mkdir() + existing = documents / "existing.txt" + existing.write_text("old", encoding="utf-8") + (tmp_path / "new.txt").write_text("new", encoding="utf-8") + plan = plan_organization(tmp_path) + + execute_plan(plan) + + assert existing.read_text(encoding="utf-8") == "old" + assert (documents / "new.txt").read_text(encoding="utf-8") == "new" From b5bf789ef85de5ae6f9e8e9dbf6d30034a51c4c0 Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Mon, 31 Aug 2026 16:48:25 -0300 Subject: [PATCH 005/117] Harden File Organizer no-replace moves --- .../06-file-organizer/file_organizer.py | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/practical-projects/06-file-organizer/file_organizer.py b/practical-projects/06-file-organizer/file_organizer.py index 8881a40..d762d82 100644 --- a/practical-projects/06-file-organizer/file_organizer.py +++ b/practical-projects/06-file-organizer/file_organizer.py @@ -1,5 +1,6 @@ from __future__ import annotations +import os from dataclasses import dataclass from enum import Enum from os import PathLike @@ -319,6 +320,29 @@ def _preflight_execution(plan: OrganizationPlan) -> None: ) +def _move_file_no_replace(source: Path, destination: Path) -> None: + """Move one regular file without ever replacing an existing destination.""" + try: + os.link(source, destination, follow_symlinks=False) + except FileExistsError as exc: + raise FileExistsError( + f"destination appeared during execution: {destination.name}" + ) from exc + + try: + source.unlink() + except OSError as exc: + try: + destination.unlink() + except OSError as rollback_exc: + raise RuntimeError( + f"move rollback failed for source file: {source.name}" + ) from rollback_exc + raise OSError( + f"could not remove source after creating destination: {source.name}" + ) from exc + + def execute_plan(plan: OrganizationPlan) -> OrganizationResult: """Execute a previously validated plan after a full collision preflight.""" if not isinstance(plan, OrganizationPlan): @@ -334,7 +358,7 @@ def execute_plan(plan: OrganizationPlan) -> OrganizationResult: moved: list[Path] = [] for action in plan.actions: - action.source.rename(action.destination) + _move_file_no_replace(action.source, action.destination) moved.append(action.destination) return OrganizationResult(plan=plan, moved_files=tuple(moved)) From 468fdd2b20db98e7bae804e48b61928c8cd247e1 Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Mon, 31 Aug 2026 16:48:42 -0300 Subject: [PATCH 006/117] Add atomic collision regression coverage --- .../tests/test_atomic_move.py | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 practical-projects/06-file-organizer/tests/test_atomic_move.py diff --git a/practical-projects/06-file-organizer/tests/test_atomic_move.py b/practical-projects/06-file-organizer/tests/test_atomic_move.py new file mode 100644 index 0000000..87495c4 --- /dev/null +++ b/practical-projects/06-file-organizer/tests/test_atomic_move.py @@ -0,0 +1,43 @@ +import os +from pathlib import Path + +import pytest + +import file_organizer +from file_organizer import execute_plan, plan_organization + + +def test_execute_plan_never_replaces_destination_created_after_preflight( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + source = tmp_path / "notes.txt" + source.write_text("planned source", encoding="utf-8") + plan = plan_organization(tmp_path) + destination = tmp_path / "documents" / "notes.txt" + original_link = os.link + + def racing_link( + source_path: str | os.PathLike[str], + destination_path: str | os.PathLike[str], + *, + src_dir_fd: int | None = None, + dst_dir_fd: int | None = None, + follow_symlinks: bool = True, + ) -> None: + Path(destination_path).write_text("late destination", encoding="utf-8") + original_link( + source_path, + destination_path, + src_dir_fd=src_dir_fd, + dst_dir_fd=dst_dir_fd, + follow_symlinks=follow_symlinks, + ) + + monkeypatch.setattr(file_organizer.os, "link", racing_link) + + with pytest.raises(FileExistsError, match="during execution"): + execute_plan(plan) + + assert source.read_text(encoding="utf-8") == "planned source" + assert destination.read_text(encoding="utf-8") == "late destination" From baa20f3daefe6eded244b4f5140727f495292f0b Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Mon, 31 Aug 2026 16:49:40 -0300 Subject: [PATCH 007/117] Document Phase 10 File Organizer in English --- .../06-file-organizer/README.md | 423 ++++++++++++++++++ 1 file changed, 423 insertions(+) create mode 100644 practical-projects/06-file-organizer/README.md diff --git a/practical-projects/06-file-organizer/README.md b/practical-projects/06-file-organizer/README.md new file mode 100644 index 0000000..4edf224 --- /dev/null +++ b/practical-projects/06-file-organizer/README.md @@ -0,0 +1,423 @@ +
+ +# Project 06 · File Organizer + +[🇺🇸 English](README.md) · [🇧🇷 Português](README.pt-BR.md) · [🇪🇸 Español](README.es.md) + +
+ +[← Back to Practical Projects](../README.md) + +> **Phase 10 · Practical Projects** + +This project organizes direct child files into category folders while keeping discovery, planning, collision handling, and filesystem mutation explicit and testable. + +## Learning objectives + +By the end of this project, you should be able to: + +- discover files with `pathlib` without recursively traversing a tree; +- classify filenames deterministically from case-insensitive suffix rules; +- model planned filesystem changes with immutable dataclasses; +- separate a non-mutating planning phase from a mutating execution phase; +- detect exact and case-insensitive destination collisions; +- choose an explicit collision policy instead of silently overwriting data; +- treat symlinks as a separate filesystem boundary; +- revalidate assumptions immediately before mutation; +- enforce exact destination no-replace behavior at the mutation step; +- test filesystem code safely with temporary directories. + +## Problem + +Imagine a fictional workspace containing files such as: + +```text +workspace/ +├── notes.txt +├── rows.csv +├── photo.png +├── backup.tar.gz +└── script.py +``` + +The organizer should produce: + +```text +workspace/ +├── documents/ +│ └── notes.txt +├── data/ +│ └── rows.csv +├── images/ +│ └── photo.png +├── archives/ +│ └── backup.tar.gz +└── other/ + └── script.py +``` + +The important challenge is not merely calling a move function. The project must make destructive filesystem decisions visible before changing anything. + +## Requirements + +The implementation must: + +1. accept an existing non-symlink source directory; +2. inspect only direct children of that directory; +3. ignore nested directories; +4. report direct-child symlinks separately instead of following them; +5. classify regular files by filename suffix; +6. preserve each filename exactly; +7. create destination folders only when needed; +8. produce deterministic ordering; +9. build an immutable plan before mutation; +10. reject invalid category paths, including symlinked category directories; +11. detect existing exact and case-insensitive destination collisions; +12. support explicit `ERROR` and `SKIP` collision policies during planning; +13. run a full preflight before any move; +14. never silently replace an exact destination that appears after preflight; +15. return a structured result after successful execution. + +## Deliberate scope + +The pipeline is: + +```text +source directory + -> direct-file discovery + -> suffix classification + -> collision-safe plan + -> execution preflight + -> required category folders + -> no-replace moves +``` + +This project intentionally does **not** include: + +- recursive organization; +- MIME or content inspection; +- automatic duplicate renaming; +- hashing or deduplication; +- deletion; +- rollback transactions across the entire plan; +- filesystem watchers; +- GUI interaction; +- cloud storage; +- cross-filesystem organization. + +Keeping these responsibilities out of scope makes the safety rules visible instead of burying them inside a general-purpose file manager. + +## Categories + +`FileCategory` defines five destinations: + +| Category | Folder | Representative suffixes | +|---|---|---| +| Documents | `documents/` | `.txt`, `.md`, `.pdf`, `.docx` | +| Data | `data/` | `.csv`, `.json`, `.xml`, `.xlsx` | +| Images | `images/` | `.png`, `.jpg`, `.webp`, `.svg` | +| Archives | `archives/` | `.zip`, `.7z`, `.tar.gz`, `.tar.xz` | +| Other | `other/` | anything not matched above | + +Matching is case-insensitive. Classification uses filenames only and does not open file contents. + +## Core models + +### `MoveAction` + +Represents one planned move: + +```text +source file -> category destination +``` + +Its invariants require absolute paths, the same source/destination filename, and a destination folder matching the selected category. + +### `OrganizationPlan` + +Stores: + +- the absolute source directory; +- sorted `MoveAction` values; +- files skipped because of collisions; +- ignored direct-child symlinks. + +The plan is immutable. Creating it does not create directories and does not move files. + +### `OrganizationResult` + +Records the exact planned destinations that were successfully moved. + +## Discovery is intentionally shallow + +`discover_files()` returns only direct regular-file children. + +Nested directories are not traversed. This matters because recursive movement introduces additional questions: + +- should the relative path be preserved? +- should category folders inside nested directories be revisited? +- how should duplicate names from different subdirectories be handled? + +Those questions are useful, but they belong to a larger project. + +## Planning before mutation + +`plan_organization()` validates the directory, scans the files, classifies them, and calculates destinations without changing the filesystem. + +That separation provides a useful engineering pattern: + +```text +observe -> decide -> validate -> mutate +``` + +It is easier to test and review a proposed operation when the proposal exists as data before side effects begin. + +## Collision policies + +Two policies are explicit: + +### `CollisionPolicy.ERROR` + +Planning stops with `FileExistsError` when a destination name already exists. + +Use this when every source file must have a conflict-free destination. + +### `CollisionPolicy.SKIP` + +Files whose destination collides are left in the source directory and listed in `skipped_collisions`. + +Use this when safely organizing the non-conflicting subset is acceptable. + +The policy is applied during planning. Execution still refuses new exact collisions that appear later. + +## Case-insensitive collision checks + +A directory may be case-sensitive on one operating system and case-insensitive on another. + +The project therefore compares destination names with `casefold()` during planning and preflight. For example, these are treated as a logical collision: + +```text +Report.TXT +report.txt +``` + +This keeps the plan portable across common filesystem behaviors. + +## Symlink boundary + +The organizer does not follow direct-child symlinks. + +It also rejects: + +- a source directory that is itself a symlink; +- a category folder implemented as a symlink. + +This keeps the project from unexpectedly moving files through a path that points outside the intended workspace. + +## Why preflight is not enough + +A first implementation might do this: + +```python +if not destination.exists(): + source.rename(destination) +``` + +That contains a time-of-check/time-of-use race. Another process can create the destination after the check but before the rename. + +On POSIX, `rename()` is allowed to replace an existing destination. That means a supposedly safe organizer could destroy newly created destination data. + +## Exact no-replace mutation + +The execution path therefore uses a same-filesystem hard-link operation as its mutation guard: + +```text +1. create destination hard link +2. fail atomically if that exact destination already exists +3. remove the original source path +``` + +`os.link()` does not replace an existing destination. Because every destination folder is inside the same source directory, source and destination are intentionally on the same filesystem for this project. + +If the link cannot be created, the source remains untouched. If removing the source fails after the link was created, the implementation attempts to remove the destination link before propagating the failure. + +This does not turn the whole multi-file plan into a transaction. It solves a narrower and important guarantee: an exact destination is never silently overwritten by the mutation primitive. + +## Execution flow + +`execute_plan()` performs: + +1. type validation; +2. source-directory revalidation; +3. category-path revalidation; +4. planned-source revalidation; +5. destination collision preflight; +6. creation of only required category folders; +7. each exact no-replace move; +8. construction of `OrganizationResult`. + +A stale plan is therefore not trusted blindly. + +## Determinism + +Files and actions are sorted by a key based on: + +```python +(path.name.casefold(), path.name) +``` + +This makes examples, tests, and review output stable instead of depending on filesystem iteration order. + +## Running the demo + +From the repository root: + +```bash +python practical-projects/06-file-organizer/demo.py +``` + +The demo uses `TemporaryDirectory`, creates only fictional files, prints the planned moves, executes them, and shows the final workspace layout. It does not touch personal directories. + +## Running the tests + +Focused suite: + +```bash +python -m pytest practical-projects/06-file-organizer/tests -q +``` + +The current focused suite contains **57 pytest scenarios**. + +Coverage includes: + +- suffix classification; +- path validation; +- deterministic discovery; +- shallow scanning; +- symlink handling; +- immutable model invariants; +- exact and case-insensitive collisions; +- `ERROR` and `SKIP` policies; +- stale/missing sources; +- category-path changes; +- collision preflight; +- a destination created between preflight and mutation; +- successful execution; +- preservation of unrelated destination files; +- empty plans. + +## Failure paths worth studying + +### Missing source directory + +Raises `FileNotFoundError`. + +### Source path is a regular file + +Raises `NotADirectoryError`. + +### Source directory is a symlink + +Rejected before scanning. + +### Category path is a file or symlink + +Rejected before planning or execution. + +### Destination exists during planning + +Handled according to the selected collision policy. + +### Destination appears after planning + +Preflight raises `FileExistsError` before any move. + +### Exact destination appears after preflight + +The no-replace hard-link operation fails with `FileExistsError`; the newly created destination is preserved and the source remains in place. + +## Common mistakes + +### Moving while scanning + +Mixing discovery and mutation makes partial failure difficult to reason about. + +Prefer building a plan first. + +### Using only `Path.exists()` before `rename()` + +The check can become stale immediately, and POSIX rename semantics can replace the destination. + +### Silently inventing new filenames + +Renaming collisions to values such as `report_2.txt` hides a policy decision. This project keeps collision behavior explicit. + +### Following symlinks accidentally + +A friendly-looking path can point outside the intended workspace. + +### Assuming directory iteration order + +Filesystem iteration order is not an application-level ordering contract. Sort explicitly when deterministic behavior matters. + +### Treating a successful preflight as a transaction + +The filesystem can change after preflight. Revalidation narrows risk but does not make a multi-file operation transactional. + +## Exercise + +Extend the organizer with a **dry-run renderer** without changing execution behavior. + +Requirements: + +1. accept an `OrganizationPlan`; +2. return deterministic human-readable text; +3. show planned moves, skipped collisions, and ignored symlinks; +4. never access or mutate the filesystem; +5. add tests for empty and non-empty plans. + +The purpose is to practice keeping presentation separate from domain and mutation logic. + +## Extension challenges + +After completing the exercise, consider: + +- a configurable suffix-to-category mapping; +- a user-defined category enum alternative; +- a JSON plan export/import format with careful stale-plan validation; +- an operation journal; +- recursive discovery with explicit relative-path rules; +- checksum-based duplicate detection; +- a rollback strategy for partially executed plans. + +Each extension introduces new invariants. Add the contract before adding the code. + +## Portfolio discussion + +A useful portfolio explanation is not “I wrote a script that moves files.” + +A stronger explanation is: + +> I designed a filesystem workflow with a non-mutating planning phase, deterministic classification, explicit collision policies, symlink boundaries, execution-time revalidation, and exact no-replace destination protection. The behavior is covered by temporary-filesystem tests, including a simulated race between preflight and mutation. + +That communicates engineering decisions, not just API usage. + +## Quick reference + +| Task | Function/type | +|---|---| +| Classify a filename | `classify_path()` | +| Discover direct regular files | `discover_files()` | +| Build a safe proposal | `plan_organization()` | +| Choose collision behavior | `CollisionPolicy` | +| Describe one move | `MoveAction` | +| Hold the immutable plan | `OrganizationPlan` | +| Execute the plan | `execute_plan()` | +| Hold successful destinations | `OrganizationResult` | +| Enforce exact no-replace mutation | `os.link()` + source `unlink()` | + +## What comes next + +Project 05 generated files. Project 06 owns the next boundary: discovering and organizing files safely. + +Project 07 will move upward again, combining validated domain records and explicit workflow states in a **fictional reconciliation workflow**. From e0eb718a29e1781cf2df2314146870f341766db2 Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Mon, 31 Aug 2026 16:50:24 -0300 Subject: [PATCH 008/117] Document Phase 10 File Organizer in Portuguese --- .../06-file-organizer/README.pt-BR.md | 423 ++++++++++++++++++ 1 file changed, 423 insertions(+) create mode 100644 practical-projects/06-file-organizer/README.pt-BR.md diff --git a/practical-projects/06-file-organizer/README.pt-BR.md b/practical-projects/06-file-organizer/README.pt-BR.md new file mode 100644 index 0000000..89bfd59 --- /dev/null +++ b/practical-projects/06-file-organizer/README.pt-BR.md @@ -0,0 +1,423 @@ +
+ +# Projeto 06 · Organizador de Arquivos + +[🇺🇸 English](README.md) · [🇧🇷 Português](README.pt-BR.md) · [🇪🇸 Español](README.es.md) + +
+ +[← Voltar para Projetos Práticos](../README.pt-BR.md) + +> **Fase 10 · Projetos Práticos** + +Este projeto organiza arquivos filhos diretos em pastas por categoria, mantendo descoberta, planejamento, tratamento de colisões e mutação do filesystem explícitos e testáveis. + +## Objetivos de aprendizagem + +Ao concluir este projeto, você deverá ser capaz de: + +- descobrir arquivos com `pathlib` sem percorrer uma árvore recursivamente; +- classificar nomes de arquivos de forma determinística por regras de sufixo sem diferenciar maiúsculas e minúsculas; +- modelar mudanças planejadas no filesystem com dataclasses imutáveis; +- separar uma fase de planejamento sem mutação de uma fase de execução com efeitos colaterais; +- detectar colisões exatas e colisões de destino ignorando diferenças de caixa; +- escolher uma política de colisão explícita em vez de sobrescrever dados silenciosamente; +- tratar symlinks como uma fronteira específica do filesystem; +- revalidar premissas imediatamente antes da mutação; +- garantir no próprio passo de mutação que um destino exato nunca seja substituído; +- testar código de filesystem com segurança usando diretórios temporários. + +## Problema + +Imagine um workspace fictício contendo: + +```text +workspace/ +├── notes.txt +├── rows.csv +├── photo.png +├── backup.tar.gz +└── script.py +``` + +O organizador deve produzir: + +```text +workspace/ +├── documents/ +│ └── notes.txt +├── data/ +│ └── rows.csv +├── images/ +│ └── photo.png +├── archives/ +│ └── backup.tar.gz +└── other/ + └── script.py +``` + +O desafio importante não é apenas chamar uma função de movimento. O projeto precisa tornar visíveis as decisões destrutivas antes de alterar qualquer coisa. + +## Requisitos + +A implementação deve: + +1. aceitar um diretório de origem existente que não seja symlink; +2. inspecionar apenas filhos diretos desse diretório; +3. ignorar diretórios aninhados; +4. registrar symlinks filhos diretos separadamente, sem segui-los; +5. classificar arquivos regulares pelo sufixo do nome; +6. preservar exatamente cada nome de arquivo; +7. criar pastas de destino somente quando necessárias; +8. produzir ordenação determinística; +9. construir um plano imutável antes da mutação; +10. rejeitar caminhos de categoria inválidos, inclusive diretórios de categoria que sejam symlinks; +11. detectar colisões de destino exatas e sem diferenciação de caixa; +12. oferecer políticas explícitas `ERROR` e `SKIP` durante o planejamento; +13. executar um preflight completo antes de qualquer movimento; +14. nunca substituir silenciosamente um destino exato que apareça depois do preflight; +15. retornar um resultado estruturado após a execução bem-sucedida. + +## Escopo deliberado + +O pipeline é: + +```text +diretório de origem + -> descoberta de arquivos diretos + -> classificação por sufixo + -> plano seguro contra colisões + -> preflight de execução + -> pastas de categoria necessárias + -> movimentos no-replace +``` + +Este projeto intencionalmente **não** inclui: + +- organização recursiva; +- inspeção MIME ou de conteúdo; +- renomeação automática de duplicados; +- hashing ou deduplicação; +- exclusão; +- transações de rollback para o plano inteiro; +- watchers de filesystem; +- interface gráfica; +- armazenamento em nuvem; +- organização entre filesystems diferentes. + +Manter essas responsabilidades fora do escopo deixa as regras de segurança visíveis em vez de escondê-las dentro de um gerenciador de arquivos genérico. + +## Categorias + +`FileCategory` define cinco destinos: + +| Categoria | Pasta | Sufixos representativos | +|---|---|---| +| Documentos | `documents/` | `.txt`, `.md`, `.pdf`, `.docx` | +| Dados | `data/` | `.csv`, `.json`, `.xml`, `.xlsx` | +| Imagens | `images/` | `.png`, `.jpg`, `.webp`, `.svg` | +| Arquivos compactados | `archives/` | `.zip`, `.7z`, `.tar.gz`, `.tar.xz` | +| Outros | `other/` | tudo o que não corresponder às regras acima | + +A correspondência ignora diferenças entre maiúsculas e minúsculas. A classificação usa apenas o nome do arquivo e não abre seu conteúdo. + +## Modelos centrais + +### `MoveAction` + +Representa um movimento planejado: + +```text +arquivo de origem -> destino da categoria +``` + +Suas invariantes exigem caminhos absolutos, o mesmo nome na origem e no destino e uma pasta de destino correspondente à categoria escolhida. + +### `OrganizationPlan` + +Armazena: + +- o diretório de origem absoluto; +- valores `MoveAction` ordenados; +- arquivos ignorados por colisão; +- symlinks filhos diretos ignorados. + +O plano é imutável. Criá-lo não cria diretórios e não move arquivos. + +### `OrganizationResult` + +Registra exatamente os destinos planejados que foram movidos com sucesso. + +## Descoberta intencionalmente rasa + +`discover_files()` retorna somente arquivos regulares que são filhos diretos. + +Diretórios aninhados não são percorridos. Isso importa porque movimento recursivo cria perguntas adicionais: + +- o caminho relativo deve ser preservado? +- pastas de categoria dentro de subdiretórios devem ser revisitadas? +- como lidar com nomes duplicados vindos de subdiretórios diferentes? + +Essas perguntas são úteis, mas pertencem a um projeto maior. + +## Planejar antes de alterar + +`plan_organization()` valida o diretório, varre os arquivos, classifica cada um e calcula os destinos sem modificar o filesystem. + +Essa separação cria um padrão de engenharia útil: + +```text +observar -> decidir -> validar -> alterar +``` + +É mais fácil testar e revisar uma operação proposta quando ela existe como dados antes de os efeitos colaterais começarem. + +## Políticas de colisão + +Duas políticas são explícitas: + +### `CollisionPolicy.ERROR` + +O planejamento para com `FileExistsError` quando um nome de destino já existe. + +Use quando todo arquivo de origem precisa de um destino sem conflito. + +### `CollisionPolicy.SKIP` + +Arquivos cujo destino colide permanecem no diretório de origem e são listados em `skipped_collisions`. + +Use quando é aceitável organizar com segurança apenas o subconjunto sem conflitos. + +A política é aplicada no planejamento. A execução continua recusando colisões exatas novas que apareçam depois. + +## Colisões sem diferenciação de caixa + +Um diretório pode ser case-sensitive em um sistema operacional e case-insensitive em outro. + +Por isso, o projeto compara nomes de destino com `casefold()` durante planejamento e preflight. Por exemplo, estes nomes são tratados como uma colisão lógica: + +```text +Report.TXT +report.txt +``` + +Isso mantém o plano mais portátil entre comportamentos comuns de filesystem. + +## Fronteira de symlink + +O organizador não segue symlinks filhos diretos. + +Ele também rejeita: + +- um diretório de origem que seja symlink; +- uma pasta de categoria implementada como symlink. + +Isso evita que o projeto mova arquivos inesperadamente por meio de um caminho que aponta para fora do workspace pretendido. + +## Por que o preflight não basta + +Uma primeira implementação poderia fazer: + +```python +if not destination.exists(): + source.rename(destination) +``` + +Isso contém uma corrida de time-of-check/time-of-use. Outro processo pode criar o destino depois da checagem e antes do rename. + +Em POSIX, `rename()` pode substituir um destino existente. Assim, um organizador aparentemente seguro poderia destruir dados recém-criados no destino. + +## Mutação exata no-replace + +Por isso, a execução usa uma operação de hard link no mesmo filesystem como proteção no próprio momento da mutação: + +```text +1. criar hard link no destino +2. falhar atomicamente se aquele destino exato já existir +3. remover o caminho de origem original +``` + +`os.link()` não substitui um destino existente. Como toda pasta de destino fica dentro do mesmo diretório de origem, origem e destino ficam intencionalmente no mesmo filesystem neste projeto. + +Se o link não puder ser criado, a origem permanece intacta. Se a remoção da origem falhar depois da criação do link, a implementação tenta remover o link de destino antes de propagar a falha. + +Isso não transforma o plano inteiro de múltiplos arquivos em uma transação. Resolve uma garantia mais estreita e importante: um destino exato nunca é sobrescrito silenciosamente pela primitiva de mutação. + +## Fluxo de execução + +`execute_plan()` realiza: + +1. validação de tipo; +2. revalidação do diretório de origem; +3. revalidação dos caminhos de categoria; +4. revalidação das origens planejadas; +5. preflight de colisões de destino; +6. criação apenas das pastas de categoria necessárias; +7. cada movimento exato no-replace; +8. construção de `OrganizationResult`. + +Um plano antigo, portanto, nunca é aceito cegamente. + +## Determinismo + +Arquivos e ações são ordenados por uma chave baseada em: + +```python +(path.name.casefold(), path.name) +``` + +Isso mantém exemplos, testes e revisão estáveis em vez de depender da ordem de iteração do filesystem. + +## Executando o demo + +A partir da raiz do repositório: + +```bash +python practical-projects/06-file-organizer/demo.py +``` + +O demo usa `TemporaryDirectory`, cria apenas arquivos fictícios, mostra os movimentos planejados, executa o plano e exibe o layout final. Ele não toca em diretórios pessoais. + +## Executando os testes + +Suíte focada: + +```bash +python -m pytest practical-projects/06-file-organizer/tests -q +``` + +A suíte focada atual contém **57 cenários pytest**. + +A cobertura inclui: + +- classificação por sufixo; +- validação de caminhos; +- descoberta determinística; +- varredura rasa; +- tratamento de symlinks; +- invariantes dos modelos imutáveis; +- colisões exatas e sem diferenciação de caixa; +- políticas `ERROR` e `SKIP`; +- origens ausentes ou obsoletas; +- mudanças em caminhos de categoria; +- preflight de colisões; +- destino criado entre preflight e mutação; +- execução bem-sucedida; +- preservação de arquivos de destino não relacionados; +- planos vazios. + +## Caminhos de falha importantes + +### Diretório de origem ausente + +Gera `FileNotFoundError`. + +### Caminho de origem é um arquivo regular + +Gera `NotADirectoryError`. + +### Diretório de origem é symlink + +É rejeitado antes da varredura. + +### Caminho de categoria é arquivo ou symlink + +É rejeitado antes do planejamento ou execução. + +### Destino existe durante o planejamento + +É tratado conforme a política de colisão selecionada. + +### Destino aparece depois do planejamento + +O preflight gera `FileExistsError` antes de qualquer movimento. + +### Destino exato aparece depois do preflight + +A operação de hard link no-replace falha com `FileExistsError`; o destino recém-criado é preservado e a origem permanece no lugar. + +## Erros comuns + +### Mover enquanto varre + +Misturar descoberta e mutação torna falhas parciais difíceis de entender. + +Prefira construir um plano primeiro. + +### Usar apenas `Path.exists()` antes de `rename()` + +A checagem pode ficar obsoleta imediatamente, e a semântica POSIX de rename pode substituir o destino. + +### Inventar novos nomes silenciosamente + +Renomear colisões para valores como `report_2.txt` esconde uma decisão de política. Este projeto mantém esse comportamento explícito. + +### Seguir symlinks sem perceber + +Um caminho aparentemente simples pode apontar para fora do workspace pretendido. + +### Assumir ordem de iteração do diretório + +A ordem de iteração do filesystem não é um contrato de ordenação da aplicação. Ordene explicitamente quando o determinismo importar. + +### Tratar um preflight bem-sucedido como transação + +O filesystem pode mudar depois do preflight. Revalidação reduz o risco, mas não torna uma operação de múltiplos arquivos transacional. + +## Exercício + +Estenda o organizador com um **renderizador de dry run** sem alterar o comportamento de execução. + +Requisitos: + +1. aceitar um `OrganizationPlan`; +2. retornar texto determinístico e legível; +3. mostrar movimentos planejados, colisões ignoradas e symlinks ignorados; +4. nunca acessar nem modificar o filesystem; +5. adicionar testes para planos vazios e não vazios. + +O objetivo é praticar a separação entre apresentação, domínio e lógica de mutação. + +## Desafios de extensão + +Depois do exercício, considere: + +- mapeamento configurável de sufixos para categorias; +- alternativa com categorias definidas pelo usuário; +- exportação/importação JSON do plano com validação cuidadosa de plano obsoleto; +- journal de operações; +- descoberta recursiva com regras explícitas de caminho relativo; +- detecção de duplicidade por checksum; +- estratégia de rollback para planos parcialmente executados. + +Cada extensão adiciona novas invariantes. Defina o contrato antes de adicionar o código. + +## Discussão de portfólio + +Uma explicação fraca seria “eu escrevi um script que move arquivos”. + +Uma explicação mais forte seria: + +> Eu projetei um fluxo de filesystem com fase de planejamento sem mutação, classificação determinística, políticas explícitas de colisão, fronteiras de symlink, revalidação no momento da execução e proteção exata no-replace do destino. O comportamento é coberto por testes com filesystem temporário, incluindo uma corrida simulada entre preflight e mutação. + +Isso comunica decisões de engenharia, não apenas uso de API. + +## Referência rápida + +| Tarefa | Função/tipo | +|---|---| +| Classificar um nome de arquivo | `classify_path()` | +| Descobrir arquivos regulares diretos | `discover_files()` | +| Construir uma proposta segura | `plan_organization()` | +| Escolher comportamento de colisão | `CollisionPolicy` | +| Descrever um movimento | `MoveAction` | +| Manter o plano imutável | `OrganizationPlan` | +| Executar o plano | `execute_plan()` | +| Manter destinos bem-sucedidos | `OrganizationResult` | +| Garantir mutação exata no-replace | `os.link()` + `unlink()` da origem | + +## O que vem depois + +O Projeto 05 gerou arquivos. O Projeto 06 assume a fronteira seguinte: descobrir e organizar arquivos com segurança. + +O Projeto 07 sobe novamente de nível, combinando registros de domínio validados e estados explícitos em um **fluxo fictício de conciliação**. From 3b284e5b8fccc8277d0643ad6017e41868e538a7 Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Mon, 31 Aug 2026 16:51:05 -0300 Subject: [PATCH 009/117] Document Phase 10 File Organizer in Spanish --- .../06-file-organizer/README.es.md | 423 ++++++++++++++++++ 1 file changed, 423 insertions(+) create mode 100644 practical-projects/06-file-organizer/README.es.md diff --git a/practical-projects/06-file-organizer/README.es.md b/practical-projects/06-file-organizer/README.es.md new file mode 100644 index 0000000..8dfd165 --- /dev/null +++ b/practical-projects/06-file-organizer/README.es.md @@ -0,0 +1,423 @@ +
+ +# Proyecto 06 · Organizador de Archivos + +[🇺🇸 English](README.md) · [🇧🇷 Português](README.pt-BR.md) · [🇪🇸 Español](README.es.md) + +
+ +[← Volver a Proyectos Prácticos](../README.es.md) + +> **Fase 10 · Proyectos Prácticos** + +Este proyecto organiza archivos hijos directos en carpetas por categoría, manteniendo descubrimiento, planificación, tratamiento de colisiones y mutación del filesystem de forma explícita y comprobable. + +## Objetivos de aprendizaje + +Al completar este proyecto, deberías poder: + +- descubrir archivos con `pathlib` sin recorrer un árbol de forma recursiva; +- clasificar nombres de archivos de manera determinista mediante reglas de sufijo sin distinguir mayúsculas y minúsculas; +- modelar cambios planificados en el filesystem con dataclasses inmutables; +- separar una fase de planificación sin mutación de una fase de ejecución con efectos secundarios; +- detectar colisiones exactas y colisiones de destino ignorando diferencias de mayúsculas/minúsculas; +- elegir una política de colisión explícita en lugar de sobrescribir datos silenciosamente; +- tratar los symlinks como una frontera específica del filesystem; +- revalidar supuestos inmediatamente antes de la mutación; +- garantizar en el propio paso de mutación que un destino exacto nunca sea reemplazado; +- probar código de filesystem con seguridad usando directorios temporales. + +## Problema + +Imagina un workspace ficticio con: + +```text +workspace/ +├── notes.txt +├── rows.csv +├── photo.png +├── backup.tar.gz +└── script.py +``` + +El organizador debería producir: + +```text +workspace/ +├── documents/ +│ └── notes.txt +├── data/ +│ └── rows.csv +├── images/ +│ └── photo.png +├── archives/ +│ └── backup.tar.gz +└── other/ + └── script.py +``` + +El desafío importante no es simplemente llamar una función de movimiento. El proyecto debe hacer visibles las decisiones destructivas antes de modificar cualquier cosa. + +## Requisitos + +La implementación debe: + +1. aceptar un directorio de origen existente que no sea symlink; +2. inspeccionar solo los hijos directos de ese directorio; +3. ignorar directorios anidados; +4. registrar symlinks hijos directos por separado, sin seguirlos; +5. clasificar archivos regulares por el sufijo del nombre; +6. conservar exactamente cada nombre de archivo; +7. crear carpetas de destino solo cuando sean necesarias; +8. producir un orden determinista; +9. construir un plan inmutable antes de la mutación; +10. rechazar rutas de categoría inválidas, incluidos directorios de categoría que sean symlinks; +11. detectar colisiones de destino exactas y sin distinción de mayúsculas/minúsculas; +12. ofrecer políticas explícitas `ERROR` y `SKIP` durante la planificación; +13. ejecutar un preflight completo antes de cualquier movimiento; +14. nunca reemplazar silenciosamente un destino exacto que aparezca después del preflight; +15. devolver un resultado estructurado después de una ejecución exitosa. + +## Alcance deliberado + +El pipeline es: + +```text +directorio de origen + -> descubrimiento de archivos directos + -> clasificación por sufijo + -> plan seguro frente a colisiones + -> preflight de ejecución + -> carpetas de categoría necesarias + -> movimientos no-replace +``` + +Este proyecto intencionalmente **no** incluye: + +- organización recursiva; +- inspección MIME o de contenido; +- renombrado automático de duplicados; +- hashing o deduplicación; +- eliminación; +- transacciones de rollback para todo el plan; +- watchers del filesystem; +- interfaz gráfica; +- almacenamiento en la nube; +- organización entre filesystems diferentes. + +Mantener estas responsabilidades fuera del alcance hace visibles las reglas de seguridad en lugar de ocultarlas dentro de un gestor de archivos genérico. + +## Categorías + +`FileCategory` define cinco destinos: + +| Categoría | Carpeta | Sufijos representativos | +|---|---|---| +| Documentos | `documents/` | `.txt`, `.md`, `.pdf`, `.docx` | +| Datos | `data/` | `.csv`, `.json`, `.xml`, `.xlsx` | +| Imágenes | `images/` | `.png`, `.jpg`, `.webp`, `.svg` | +| Archivos comprimidos | `archives/` | `.zip`, `.7z`, `.tar.gz`, `.tar.xz` | +| Otros | `other/` | cualquier valor no cubierto por las reglas anteriores | + +La coincidencia ignora diferencias entre mayúsculas y minúsculas. La clasificación usa únicamente el nombre del archivo y no abre su contenido. + +## Modelos centrales + +### `MoveAction` + +Representa un movimiento planificado: + +```text +archivo de origen -> destino de categoría +``` + +Sus invariantes exigen rutas absolutas, el mismo nombre en origen y destino y una carpeta de destino correspondiente a la categoría seleccionada. + +### `OrganizationPlan` + +Almacena: + +- el directorio de origen absoluto; +- valores `MoveAction` ordenados; +- archivos omitidos por colisión; +- symlinks hijos directos ignorados. + +El plan es inmutable. Crearlo no crea directorios ni mueve archivos. + +### `OrganizationResult` + +Registra exactamente los destinos planificados que se movieron con éxito. + +## Descubrimiento intencionalmente superficial + +`discover_files()` devuelve solo archivos regulares hijos directos. + +No recorre directorios anidados. Esto importa porque el movimiento recursivo introduce preguntas adicionales: + +- ¿debe preservarse la ruta relativa? +- ¿deben revisitarse carpetas de categoría dentro de subdirectorios? +- ¿cómo se gestionan nombres duplicados provenientes de subdirectorios distintos? + +Esas preguntas son útiles, pero pertenecen a un proyecto mayor. + +## Planificar antes de modificar + +`plan_organization()` valida el directorio, inspecciona los archivos, clasifica cada uno y calcula los destinos sin cambiar el filesystem. + +Esta separación crea un patrón de ingeniería útil: + +```text +observar -> decidir -> validar -> modificar +``` + +Es más fácil probar y revisar una operación propuesta cuando existe como datos antes de que comiencen los efectos secundarios. + +## Políticas de colisión + +Hay dos políticas explícitas: + +### `CollisionPolicy.ERROR` + +La planificación se detiene con `FileExistsError` cuando ya existe un nombre de destino. + +Úsala cuando cada archivo de origen necesite un destino libre de conflictos. + +### `CollisionPolicy.SKIP` + +Los archivos cuyo destino colisiona permanecen en el directorio de origen y se registran en `skipped_collisions`. + +Úsala cuando sea aceptable organizar de forma segura solo el subconjunto sin conflictos. + +La política se aplica durante la planificación. La ejecución sigue rechazando nuevas colisiones exactas que aparezcan después. + +## Colisiones sin distinción de mayúsculas/minúsculas + +Un directorio puede ser case-sensitive en un sistema operativo y case-insensitive en otro. + +Por eso, el proyecto compara nombres de destino con `casefold()` durante planificación y preflight. Por ejemplo, estos nombres se tratan como una colisión lógica: + +```text +Report.TXT +report.txt +``` + +Esto mantiene el plan más portable entre comportamientos habituales de filesystem. + +## Frontera de symlink + +El organizador no sigue symlinks hijos directos. + +También rechaza: + +- un directorio de origen que sea symlink; +- una carpeta de categoría implementada como symlink. + +Esto evita mover archivos inesperadamente mediante una ruta que apunta fuera del workspace previsto. + +## Por qué el preflight no es suficiente + +Una primera implementación podría hacer: + +```python +if not destination.exists(): + source.rename(destination) +``` + +Eso contiene una carrera de time-of-check/time-of-use. Otro proceso puede crear el destino después de la comprobación y antes del rename. + +En POSIX, `rename()` puede reemplazar un destino existente. Por eso, un organizador aparentemente seguro podría destruir datos recién creados en el destino. + +## Mutación exacta no-replace + +La ejecución usa una operación de hard link dentro del mismo filesystem como protección en el momento exacto de la mutación: + +```text +1. crear hard link en el destino +2. fallar de forma atómica si ese destino exacto ya existe +3. eliminar la ruta de origen original +``` + +`os.link()` no reemplaza un destino existente. Como todas las carpetas de destino se encuentran dentro del mismo directorio de origen, origen y destino permanecen intencionalmente en el mismo filesystem para este proyecto. + +Si el link no puede crearse, el origen queda intacto. Si la eliminación del origen falla después de crear el link, la implementación intenta eliminar el link de destino antes de propagar el error. + +Esto no convierte todo el plan de múltiples archivos en una transacción. Resuelve una garantía más estrecha e importante: un destino exacto nunca es sobrescrito silenciosamente por la primitiva de mutación. + +## Flujo de ejecución + +`execute_plan()` realiza: + +1. validación de tipo; +2. revalidación del directorio de origen; +3. revalidación de rutas de categoría; +4. revalidación de orígenes planificados; +5. preflight de colisiones de destino; +6. creación de solo las carpetas de categoría necesarias; +7. cada movimiento exacto no-replace; +8. construcción de `OrganizationResult`. + +Un plan antiguo, por lo tanto, nunca se acepta a ciegas. + +## Determinismo + +Archivos y acciones se ordenan por una clave basada en: + +```python +(path.name.casefold(), path.name) +``` + +Esto mantiene ejemplos, pruebas y revisiones estables en lugar de depender del orden de iteración del filesystem. + +## Ejecutar la demo + +Desde la raíz del repositorio: + +```bash +python practical-projects/06-file-organizer/demo.py +``` + +La demo usa `TemporaryDirectory`, crea únicamente archivos ficticios, muestra los movimientos planificados, ejecuta el plan y presenta el layout final. No toca directorios personales. + +## Ejecutar las pruebas + +Suite enfocada: + +```bash +python -m pytest practical-projects/06-file-organizer/tests -q +``` + +La suite enfocada actual contiene **57 escenarios pytest**. + +La cobertura incluye: + +- clasificación por sufijo; +- validación de rutas; +- descubrimiento determinista; +- recorrido superficial; +- tratamiento de symlinks; +- invariantes de modelos inmutables; +- colisiones exactas y sin distinción de mayúsculas/minúsculas; +- políticas `ERROR` y `SKIP`; +- orígenes ausentes u obsoletos; +- cambios en rutas de categoría; +- preflight de colisiones; +- un destino creado entre preflight y mutación; +- ejecución exitosa; +- preservación de archivos de destino no relacionados; +- planes vacíos. + +## Caminos de fallo importantes + +### Directorio de origen ausente + +Genera `FileNotFoundError`. + +### La ruta de origen es un archivo regular + +Genera `NotADirectoryError`. + +### El directorio de origen es symlink + +Se rechaza antes del escaneo. + +### La ruta de categoría es archivo o symlink + +Se rechaza antes de la planificación o ejecución. + +### El destino existe durante la planificación + +Se gestiona según la política de colisión seleccionada. + +### El destino aparece después de la planificación + +El preflight genera `FileExistsError` antes de cualquier movimiento. + +### El destino exacto aparece después del preflight + +La operación hard-link no-replace falla con `FileExistsError`; el destino recién creado se conserva y el origen permanece en su lugar. + +## Errores comunes + +### Mover mientras se escanea + +Mezclar descubrimiento y mutación hace que los fallos parciales sean difíciles de razonar. + +Es preferible construir primero un plan. + +### Usar solo `Path.exists()` antes de `rename()` + +La comprobación puede quedar obsoleta inmediatamente y la semántica POSIX de rename puede reemplazar el destino. + +### Inventar nombres nuevos silenciosamente + +Renombrar colisiones como `report_2.txt` oculta una decisión de política. Este proyecto mantiene ese comportamiento explícito. + +### Seguir symlinks sin darse cuenta + +Una ruta aparentemente simple puede apuntar fuera del workspace previsto. + +### Suponer el orden de iteración del directorio + +El orden de iteración del filesystem no es un contrato de orden de la aplicación. Ordena explícitamente cuando el determinismo sea importante. + +### Tratar un preflight exitoso como una transacción + +El filesystem puede cambiar después del preflight. La revalidación reduce el riesgo, pero no convierte una operación de múltiples archivos en una transacción. + +## Ejercicio + +Extiende el organizador con un **renderizador de dry run** sin cambiar el comportamiento de ejecución. + +Requisitos: + +1. aceptar un `OrganizationPlan`; +2. devolver texto determinista y legible; +3. mostrar movimientos planificados, colisiones omitidas y symlinks ignorados; +4. nunca acceder ni modificar el filesystem; +5. añadir pruebas para planes vacíos y no vacíos. + +El objetivo es practicar la separación entre presentación, dominio y lógica de mutación. + +## Desafíos de extensión + +Después del ejercicio, considera: + +- un mapeo configurable de sufijos a categorías; +- una alternativa con categorías definidas por el usuario; +- exportación/importación JSON del plan con validación cuidadosa de planes obsoletos; +- un journal de operaciones; +- descubrimiento recursivo con reglas explícitas de rutas relativas; +- detección de duplicados por checksum; +- una estrategia de rollback para planes ejecutados parcialmente. + +Cada extensión introduce nuevas invariantes. Define el contrato antes de añadir el código. + +## Discusión de portafolio + +Una explicación débil sería “escribí un script que mueve archivos”. + +Una explicación más fuerte sería: + +> Diseñé un flujo de filesystem con una fase de planificación sin mutación, clasificación determinista, políticas explícitas de colisión, fronteras de symlink, revalidación en el momento de ejecución y protección exacta no-replace del destino. El comportamiento está cubierto por pruebas con filesystem temporal, incluida una carrera simulada entre preflight y mutación. + +Eso comunica decisiones de ingeniería, no solo uso de API. + +## Referencia rápida + +| Tarea | Función/tipo | +|---|---| +| Clasificar un nombre de archivo | `classify_path()` | +| Descubrir archivos regulares directos | `discover_files()` | +| Construir una propuesta segura | `plan_organization()` | +| Elegir comportamiento de colisión | `CollisionPolicy` | +| Describir un movimiento | `MoveAction` | +| Mantener el plan inmutable | `OrganizationPlan` | +| Ejecutar el plan | `execute_plan()` | +| Mantener destinos exitosos | `OrganizationResult` | +| Garantizar mutación exacta no-replace | `os.link()` + `unlink()` del origen | + +## Qué viene después + +El Proyecto 05 generó archivos. El Proyecto 06 asume la siguiente frontera: descubrir y organizar archivos con seguridad. + +El Proyecto 07 vuelve a subir de nivel, combinando registros de dominio validados y estados explícitos en un **flujo ficticio de conciliación**. From c5456efd465927b88d8f2ebc4df1f8b38b47c485 Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Mon, 31 Aug 2026 16:51:17 -0300 Subject: [PATCH 010/117] Link File Organizer from practical project index --- practical-projects/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/practical-projects/README.md b/practical-projects/README.md index c5b273e..c989311 100644 --- a/practical-projects/README.md +++ b/practical-projects/README.md @@ -21,7 +21,7 @@ Phase 10 combines concepts from the previous phases into complete, testable work 3. ✅ [User Registration](03-user-registration/README.md) 4. ✅ [CSV Analyzer](04-csv-analyzer/README.md) 5. ✅ [Report Generator](05-report-generator/README.md) -6. ⏳ File Organizer +6. 🚧 [File Organizer](06-file-organizer/README.md) 7. ⏳ Fictional Reconciliation Workflow 8. ⏳ Simulated Automation Flow @@ -38,4 +38,4 @@ Each project should include: - extension challenges; - portfolio discussion. -Project 01 establishes the integration pattern with validated monetary records and persistence. Project 02 extends it with configurable grading policies, exact weighted aggregation, explicit partial/final states, structured reporting, and boundary-focused pytest coverage. Project 03 adds canonical identity-like data, duplicate prevention, secondary lookup indexes, safe indexed-field updates, and explicit user lifecycle transitions without introducing authentication. Project 04 adds exact CSV schemas, typed conversion, structural-versus-row failure handling, partial-success parsing, duplicate row identifiers, deterministic filtering, and aggregation without hiding ingestion behavior behind pandas. Project 05 turns validated operational records into deterministic reporting artifacts with explicit date windows, exact summary metrics, TXT/Markdown renderers, and UTF-8 file output while keeping aggregation, presentation, and persistence separate. +Project 01 establishes the integration pattern with validated monetary records and persistence. Project 02 extends it with configurable grading policies, exact weighted aggregation, explicit partial/final states, structured reporting, and boundary-focused pytest coverage. Project 03 adds canonical identity-like data, duplicate prevention, secondary lookup indexes, safe indexed-field updates, and explicit user lifecycle transitions without introducing authentication. Project 04 adds exact CSV schemas, typed conversion, structural-versus-row failure handling, partial-success parsing, duplicate row identifiers, deterministic filtering, and aggregation without hiding ingestion behavior behind pandas. Project 05 turns validated operational records into deterministic reporting artifacts with explicit date windows, exact summary metrics, TXT/Markdown renderers, and UTF-8 file output while keeping aggregation, presentation, and persistence separate. Project 06 adds shallow filesystem discovery, suffix classification, immutable move planning, explicit collision policies, symlink boundaries, execution-time revalidation, and exact no-replace destination protection before files are organized into category folders. From c075a3305034a335072bc1e21b0ed1653b50d9f7 Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Mon, 31 Aug 2026 16:51:29 -0300 Subject: [PATCH 011/117] Link File Organizer from Portuguese project index --- practical-projects/README.pt-BR.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/practical-projects/README.pt-BR.md b/practical-projects/README.pt-BR.md index 02e03e0..bcdfa79 100644 --- a/practical-projects/README.pt-BR.md +++ b/practical-projects/README.pt-BR.md @@ -21,7 +21,7 @@ A Fase 10 combina conceitos das fases anteriores em fluxos completos e testávei 3. ✅ [Cadastro de Usuários](03-user-registration/README.pt-BR.md) 4. ✅ [Analisador CSV](04-csv-analyzer/README.pt-BR.md) 5. ✅ [Gerador de Relatórios](05-report-generator/README.pt-BR.md) -6. ⏳ Organizador de Arquivos +6. 🚧 [Organizador de Arquivos](06-file-organizer/README.pt-BR.md) 7. ⏳ Fluxo Fictício de Conciliação 8. ⏳ Fluxo Simulado de Automação @@ -38,4 +38,4 @@ Cada projeto deve incluir: - desafios de extensão; - discussão de portfólio. -O Projeto 01 estabelece o padrão de integração com registros monetários validados e persistência. O Projeto 02 amplia esse padrão com políticas de notas configuráveis, agregação ponderada exata, estados parcial/final explícitos, relatório estruturado e cobertura pytest focada em fronteiras. O Projeto 03 adiciona dados de identidade canônicos, prevenção de duplicidade, índices secundários de lookup, atualizações seguras de campos indexados e transições explícitas de ciclo de vida sem introduzir autenticação. O Projeto 04 adiciona schemas CSV exatos, conversão tipada, separação entre falhas estruturais e falhas de linha, parsing com sucesso parcial, identificadores duplicados, filtros determinísticos e agregação sem esconder a ingestão atrás de pandas. O Projeto 05 transforma registros operacionais validados em artefatos de relatório determinísticos com janelas explícitas de datas, métricas exatas de resumo, renderizadores TXT/Markdown e escrita UTF-8, mantendo agregação, apresentação e persistência separadas. +O Projeto 01 estabelece o padrão de integração com registros monetários validados e persistência. O Projeto 02 amplia esse padrão com políticas de notas configuráveis, agregação ponderada exata, estados parcial/final explícitos, relatório estruturado e cobertura pytest focada em fronteiras. O Projeto 03 adiciona dados de identidade canônicos, prevenção de duplicidade, índices secundários de lookup, atualizações seguras de campos indexados e transições explícitas de ciclo de vida sem introduzir autenticação. O Projeto 04 adiciona schemas CSV exatos, conversão tipada, separação entre falhas estruturais e falhas de linha, parsing com sucesso parcial, identificadores duplicados, filtros determinísticos e agregação sem esconder a ingestão atrás de pandas. O Projeto 05 transforma registros operacionais validados em artefatos de relatório determinísticos com janelas explícitas de datas, métricas exatas de resumo, renderizadores TXT/Markdown e escrita UTF-8, mantendo agregação, apresentação e persistência separadas. O Projeto 06 adiciona descoberta rasa no filesystem, classificação por sufixo, planejamento imutável de movimentos, políticas explícitas de colisão, fronteiras de symlink, revalidação no momento da execução e proteção exata no-replace do destino antes da organização em pastas por categoria. From ed2c4ebc412a9abad20eacd945274317215e7cc4 Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Mon, 31 Aug 2026 16:51:40 -0300 Subject: [PATCH 012/117] Link File Organizer from Spanish project index --- practical-projects/README.es.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/practical-projects/README.es.md b/practical-projects/README.es.md index cc463d6..550e51e 100644 --- a/practical-projects/README.es.md +++ b/practical-projects/README.es.md @@ -21,7 +21,7 @@ La Fase 10 combina conceptos de las fases anteriores en flujos completos y compr 3. ✅ [Registro de Usuarios](03-user-registration/README.es.md) 4. ✅ [Analizador CSV](04-csv-analyzer/README.es.md) 5. ✅ [Generador de Informes](05-report-generator/README.es.md) -6. ⏳ Organizador de Archivos +6. 🚧 [Organizador de Archivos](06-file-organizer/README.es.md) 7. ⏳ Flujo Ficticio de Conciliación 8. ⏳ Flujo Simulado de Automatización @@ -38,4 +38,4 @@ Cada proyecto debe incluir: - desafíos de extensión; - discusión de portafolio. -El Proyecto 01 establece el patrón de integración con registros monetarios validados y persistencia. El Proyecto 02 amplía ese patrón con políticas de calificación configurables, agregación ponderada exacta, estados parcial/final explícitos, informe estructurado y cobertura pytest centrada en límites. El Proyecto 03 añade datos de identidad canónicos, prevención de duplicados, índices secundarios de lookup, actualizaciones seguras de campos indexados y transiciones explícitas del ciclo de vida sin introducir autenticación. El Proyecto 04 añade schemas CSV exactos, conversión tipada, separación entre fallos estructurales y fallos de fila, parsing con éxito parcial, identificadores duplicados, filtros deterministas y agregación sin ocultar la ingestión detrás de pandas. El Proyecto 05 transforma registros operativos validados en artefactos de informe deterministas con ventanas de fecha explícitas, métricas de resumen exactas, renderizadores TXT/Markdown y escritura UTF-8, manteniendo separadas la agregación, la presentación y la persistencia. +El Proyecto 01 establece el patrón de integración con registros monetarios validados y persistencia. El Proyecto 02 amplía ese patrón con políticas de calificación configurables, agregación ponderada exacta, estados parcial/final explícitos, informe estructurado y cobertura pytest centrada en límites. El Proyecto 03 añade datos de identidad canónicos, prevención de duplicados, índices secundarios de lookup, actualizaciones seguras de campos indexados y transiciones explícitas del ciclo de vida sin introducir autenticación. El Proyecto 04 añade schemas CSV exactos, conversión tipada, separación entre fallos estructurales y fallos de fila, parsing con éxito parcial, identificadores duplicados, filtros deterministas y agregación sin ocultar la ingestión detrás de pandas. El Proyecto 05 transforma registros operativos validados en artefactos de informe deterministas con ventanas de fecha explícitas, métricas de resumen exactas, renderizadores TXT/Markdown y escritura UTF-8, manteniendo separadas la agregación, la presentación y la persistencia. El Proyecto 06 añade descubrimiento superficial del filesystem, clasificación por sufijo, planificación inmutable de movimientos, políticas explícitas de colisión, fronteras de symlink, revalidación en el momento de ejecución y protección exacta no-replace del destino antes de organizar los archivos en carpetas por categoría. From 076ed6f25088094beed6da4cddaa898930b67cc4 Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Mon, 31 Aug 2026 16:52:15 -0300 Subject: [PATCH 013/117] Register File Organizer demo in example manifest --- scripts/example_manifest.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/example_manifest.txt b/scripts/example_manifest.txt index be2dc3f..dd58562 100644 --- a/scripts/example_manifest.txt +++ b/scripts/example_manifest.txt @@ -183,3 +183,4 @@ practical-projects/02-grade-calculator/demo.py practical-projects/03-user-registration/demo.py practical-projects/04-csv-analyzer/demo.py practical-projects/05-report-generator/demo.py +practical-projects/06-file-organizer/demo.py From d9bf4d259679b30439e155a585230ab493847ce0 Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Mon, 31 Aug 2026 21:35:16 -0300 Subject: [PATCH 014/117] Harden category directory mutation against symlink races --- .../06-file-organizer/file_organizer.py | 136 +++++++++++++++++- 1 file changed, 130 insertions(+), 6 deletions(-) diff --git a/practical-projects/06-file-organizer/file_organizer.py b/practical-projects/06-file-organizer/file_organizer.py index d762d82..926e2a5 100644 --- a/practical-projects/06-file-organizer/file_organizer.py +++ b/practical-projects/06-file-organizer/file_organizer.py @@ -320,8 +320,84 @@ def _preflight_execution(plan: OrganizationPlan) -> None: ) +def _supports_secure_directory_fds() -> bool: + """Return whether the platform can enforce no-follow directory mutation.""" + return ( + hasattr(os, "O_DIRECTORY") + and hasattr(os, "O_NOFOLLOW") + and os.open in os.supports_dir_fd + and os.mkdir in os.supports_dir_fd + and os.link in os.supports_dir_fd + and os.unlink in os.supports_dir_fd + ) + + +def _directory_open_flags() -> int: + flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW + if hasattr(os, "O_CLOEXEC"): + flags |= os.O_CLOEXEC + return flags + + +def _open_source_directory_fd(source_directory: Path) -> int: + try: + return os.open(source_directory, _directory_open_flags()) + except OSError as exc: + raise ValueError("source_directory became unsafe during execution") from exc + + +def _open_category_directory_fd(root_fd: int, category_name: str) -> int: + """Create/open one category directory without following a late symlink.""" + try: + os.mkdir(category_name, dir_fd=root_fd) + except FileExistsError: + pass + + try: + return os.open(category_name, _directory_open_flags(), dir_fd=root_fd) + except OSError as exc: + raise ValueError( + f"category directory became unsafe during execution: {category_name}" + ) from exc + + +def _move_file_no_replace_at( + source_name: str, + destination_name: str, + *, + source_directory_fd: int, + destination_directory_fd: int, +) -> None: + """Move one file through pinned directory descriptors without replacement.""" + try: + os.link( + source_name, + destination_name, + src_dir_fd=source_directory_fd, + dst_dir_fd=destination_directory_fd, + follow_symlinks=False, + ) + except FileExistsError as exc: + raise FileExistsError( + f"destination appeared during execution: {destination_name}" + ) from exc + + try: + os.unlink(source_name, dir_fd=source_directory_fd) + except OSError as exc: + try: + os.unlink(destination_name, dir_fd=destination_directory_fd) + except OSError as rollback_exc: + raise RuntimeError( + f"move rollback failed for source file: {source_name}" + ) from rollback_exc + raise OSError( + f"could not remove source after creating destination: {source_name}" + ) from exc + + def _move_file_no_replace(source: Path, destination: Path) -> None: - """Move one regular file without ever replacing an existing destination.""" + """Portable fallback that never replaces an exact existing destination.""" try: os.link(source, destination, follow_symlinks=False) except FileExistsError as exc: @@ -343,22 +419,70 @@ def _move_file_no_replace(source: Path, destination: Path) -> None: ) from exc -def execute_plan(plan: OrganizationPlan) -> OrganizationResult: - """Execute a previously validated plan after a full collision preflight.""" - if not isinstance(plan, OrganizationPlan): - raise TypeError("plan must be an OrganizationPlan") +def _execute_plan_with_directory_fds(plan: OrganizationPlan) -> OrganizationResult: + """Execute using pinned no-follow directory descriptors when supported.""" + root_fd = _open_source_directory_fd(plan.source_directory) + category_fds: dict[FileCategory, int] = {} - _preflight_execution(plan) + try: + for category in sorted( + {action.category for action in plan.actions}, + key=lambda item: item.value, + ): + category_fds[category] = _open_category_directory_fd( + root_fd, + category.value, + ) + + moved: list[Path] = [] + for action in plan.actions: + _move_file_no_replace_at( + action.source.name, + action.destination.name, + source_directory_fd=root_fd, + destination_directory_fd=category_fds[action.category], + ) + moved.append(action.destination) + + return OrganizationResult(plan=plan, moved_files=tuple(moved)) + finally: + for directory_fd in category_fds.values(): + os.close(directory_fd) + os.close(root_fd) + +def _execute_plan_portable(plan: OrganizationPlan) -> OrganizationResult: + """Execute on platforms without directory-descriptor no-follow support.""" for directory in sorted( {action.destination.parent for action in plan.actions}, key=lambda path: (path.name.casefold(), path.name), ): directory.mkdir(exist_ok=True) + if directory.is_symlink() or not directory.is_dir(): + raise ValueError( + f"category directory became unsafe during execution: {directory.name}" + ) moved: list[Path] = [] for action in plan.actions: + if action.destination.parent.is_symlink(): + raise ValueError( + "category directory became unsafe during execution: " + f"{action.destination.parent.name}" + ) _move_file_no_replace(action.source, action.destination) moved.append(action.destination) return OrganizationResult(plan=plan, moved_files=tuple(moved)) + + +def execute_plan(plan: OrganizationPlan) -> OrganizationResult: + """Execute a previously validated plan after a full collision preflight.""" + if not isinstance(plan, OrganizationPlan): + raise TypeError("plan must be an OrganizationPlan") + + _preflight_execution(plan) + + if _supports_secure_directory_fds(): + return _execute_plan_with_directory_fds(plan) + return _execute_plan_portable(plan) From 0b84ec5f9aa002a9078061d992c074cc77e695b6 Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Mon, 31 Aug 2026 21:35:27 -0300 Subject: [PATCH 015/117] Cover late category symlink races --- .../tests/test_atomic_move.py | 42 ++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/practical-projects/06-file-organizer/tests/test_atomic_move.py b/practical-projects/06-file-organizer/tests/test_atomic_move.py index 87495c4..b3603f4 100644 --- a/practical-projects/06-file-organizer/tests/test_atomic_move.py +++ b/practical-projects/06-file-organizer/tests/test_atomic_move.py @@ -25,7 +25,7 @@ def racing_link( dst_dir_fd: int | None = None, follow_symlinks: bool = True, ) -> None: - Path(destination_path).write_text("late destination", encoding="utf-8") + destination.write_text("late destination", encoding="utf-8") original_link( source_path, destination_path, @@ -41,3 +41,43 @@ def racing_link( assert source.read_text(encoding="utf-8") == "planned source" assert destination.read_text(encoding="utf-8") == "late destination" + + +def test_execute_plan_rejects_category_symlink_created_after_preflight( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + if not file_organizer._supports_secure_directory_fds(): + pytest.skip("secure directory descriptors are unavailable on this platform") + + source = tmp_path / "notes.txt" + source.write_text("planned source", encoding="utf-8") + plan = plan_organization(tmp_path) + + outside = tmp_path.parent / f"{tmp_path.name}-outside" + outside.mkdir() + category = tmp_path / "documents" + original_mkdir = os.mkdir + raced = False + + def racing_mkdir( + path: str | os.PathLike[str], + mode: int = 0o777, + *, + dir_fd: int | None = None, + ) -> None: + nonlocal raced + if path == "documents" and dir_fd is not None and not raced: + raced = True + category.symlink_to(outside, target_is_directory=True) + raise FileExistsError + original_mkdir(path, mode, dir_fd=dir_fd) + + monkeypatch.setattr(file_organizer.os, "mkdir", racing_mkdir) + + with pytest.raises(ValueError, match="became unsafe during execution"): + execute_plan(plan) + + assert source.read_text(encoding="utf-8") == "planned source" + assert category.is_symlink() + assert list(outside.iterdir()) == [] From c18deb1b640ae021ba6e66dfe83e0109eb11e367 Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Mon, 31 Aug 2026 21:36:45 -0300 Subject: [PATCH 016/117] Fix secure directory race regression harness --- practical-projects/06-file-organizer/tests/test_atomic_move.py | 1 + 1 file changed, 1 insertion(+) diff --git a/practical-projects/06-file-organizer/tests/test_atomic_move.py b/practical-projects/06-file-organizer/tests/test_atomic_move.py index b3603f4..612af59 100644 --- a/practical-projects/06-file-organizer/tests/test_atomic_move.py +++ b/practical-projects/06-file-organizer/tests/test_atomic_move.py @@ -73,6 +73,7 @@ def racing_mkdir( raise FileExistsError original_mkdir(path, mode, dir_fd=dir_fd) + monkeypatch.setattr(file_organizer, "_supports_secure_directory_fds", lambda: True) monkeypatch.setattr(file_organizer.os, "mkdir", racing_mkdir) with pytest.raises(ValueError, match="became unsafe during execution"): From 25aaac75e447afc4eb6816212bb9d9b6fea2397f Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Mon, 31 Aug 2026 21:37:50 -0300 Subject: [PATCH 017/117] Document no-follow directory descriptor protection --- .../06-file-organizer/README.md | 294 +++++++++--------- 1 file changed, 152 insertions(+), 142 deletions(-) diff --git a/practical-projects/06-file-organizer/README.md b/practical-projects/06-file-organizer/README.md index 4edf224..67c9783 100644 --- a/practical-projects/06-file-organizer/README.md +++ b/practical-projects/06-file-organizer/README.md @@ -1,6 +1,6 @@
-# Project 06 · File Organizer +# File Organizer [🇺🇸 English](README.md) · [🇧🇷 Português](README.pt-BR.md) · [🇪🇸 Español](README.es.md) @@ -8,79 +8,60 @@ [← Back to Practical Projects](../README.md) -> **Phase 10 · Practical Projects** +Project 06 turns filesystem concepts from Phase 8 into a small but deliberate organization workflow. The goal is not to build a desktop file manager. The goal is to practice discovery, classification, planning, collision handling, symlink boundaries, and safe mutation as separate engineering concerns. -This project organizes direct child files into category folders while keeping discovery, planning, collision handling, and filesystem mutation explicit and testable. - -## Learning objectives +## What you will practice By the end of this project, you should be able to: -- discover files with `pathlib` without recursively traversing a tree; -- classify filenames deterministically from case-insensitive suffix rules; -- model planned filesystem changes with immutable dataclasses; -- separate a non-mutating planning phase from a mutating execution phase; -- detect exact and case-insensitive destination collisions; -- choose an explicit collision policy instead of silently overwriting data; -- treat symlinks as a separate filesystem boundary; -- revalidate assumptions immediately before mutation; -- enforce exact destination no-replace behavior at the mutation step; -- test filesystem code safely with temporary directories. - -## Problem - -Imagine a fictional workspace containing files such as: - -```text -workspace/ -├── notes.txt -├── rows.csv -├── photo.png -├── backup.tar.gz -└── script.py -``` +- discover direct regular-file children with `pathlib`; +- classify files by normalized suffix rules; +- model planned filesystem operations as immutable data; +- separate observation and planning from mutation; +- handle exact and case-insensitive destination collisions explicitly; +- ignore or reject symlinks at trust boundaries; +- revalidate a plan immediately before execution; +- prevent exact destination replacement during the mutation itself; +- pin POSIX source/category directories with no-follow directory descriptors so late symlinks cannot redirect moves; +- test filesystem race conditions with `pytest`, `tmp_path`, and monkeypatching; +- run a deterministic file workflow without touching personal directories. -The organizer should produce: +## Project files ```text -workspace/ -├── documents/ -│ └── notes.txt -├── data/ -│ └── rows.csv -├── images/ -│ └── photo.png -├── archives/ -│ └── backup.tar.gz -└── other/ - └── script.py +06-file-organizer/ +├── README.md +├── README.pt-BR.md +├── README.es.md +├── demo.py +├── file_organizer.py +└── tests/ + ├── conftest.py + ├── test_atomic_move.py + └── test_file_organizer.py ``` -The important challenge is not merely calling a move function. The project must make destructive filesystem decisions visible before changing anything. - ## Requirements -The implementation must: - -1. accept an existing non-symlink source directory; -2. inspect only direct children of that directory; -3. ignore nested directories; -4. report direct-child symlinks separately instead of following them; -5. classify regular files by filename suffix; -6. preserve each filename exactly; -7. create destination folders only when needed; -8. produce deterministic ordering; -9. build an immutable plan before mutation; -10. reject invalid category paths, including symlinked category directories; -11. detect existing exact and case-insensitive destination collisions; -12. support explicit `ERROR` and `SKIP` collision policies during planning; -13. run a full preflight before any move; -14. never silently replace an exact destination that appears after preflight; -15. return a structured result after successful execution. - -## Deliberate scope - -The pipeline is: +The organizer must: + +1. accept one source directory; +2. inspect only direct children; +3. classify regular files into explicit categories; +4. preserve original filenames; +5. build an immutable organization plan before changing the filesystem; +6. detect destination collisions case-insensitively during planning; +7. support explicit `ERROR` and `SKIP` collision policies; +8. reject source/category directory symlinks at validation boundaries; +9. ignore direct-child file symlinks instead of following them; +10. revalidate planned sources and destinations before mutation; +11. create only category directories actually required by the plan; +12. never replace an exact destination that appears after planning; +13. prevent late category symlinks from redirecting POSIX mutations outside the workspace; +14. return an immutable execution result; +15. remain deterministic for the same directory state. + +## Workflow ```text source directory @@ -89,6 +70,7 @@ source directory -> collision-safe plan -> execution preflight -> required category folders + -> no-follow directory pinning when supported -> no-replace moves ``` @@ -212,7 +194,9 @@ It also rejects: - a source directory that is itself a symlink; - a category folder implemented as a symlink. -This keeps the project from unexpectedly moving files through a path that points outside the intended workspace. +On platforms that support secure directory file descriptors, execution goes further: the source directory and each required category directory are opened with `O_DIRECTORY | O_NOFOLLOW`, and mutation happens relative to those pinned descriptors. A category path that becomes a symlink after preflight is therefore rejected before use, while a path changed after the real directory is opened cannot redirect the move through that symlink. + +On platforms without those descriptor primitives, the portable fallback rechecks the category path immediately after creation and before each move. The POSIX descriptor path provides the stronger race-resistant boundary demonstrated by the dedicated regression test. ## Why preflight is not enough @@ -227,6 +211,8 @@ That contains a time-of-check/time-of-use race. Another process can create the d On POSIX, `rename()` is allowed to replace an existing destination. That means a supposedly safe organizer could destroy newly created destination data. +A similar race exists for category directories: a real directory can be absent during preflight and a symlink can appear before mutation. Checking the path again is useful, but on POSIX the stronger defense is to open the intended directory without following symlinks and perform the mutation through that descriptor. + ## Exact no-replace mutation The execution path therefore uses a same-filesystem hard-link operation as its mutation guard: @@ -239,9 +225,11 @@ The execution path therefore uses a same-filesystem hard-link operation as its m `os.link()` does not replace an existing destination. Because every destination folder is inside the same source directory, source and destination are intentionally on the same filesystem for this project. +When directory-descriptor support is available, the link uses `src_dir_fd` and `dst_dir_fd`, with `follow_symlinks=False`, so the operation is anchored to pinned source/category directories instead of resolving a late category symlink through a pathname. + If the link cannot be created, the source remains untouched. If removing the source fails after the link was created, the implementation attempts to remove the destination link before propagating the failure. -This does not turn the whole multi-file plan into a transaction. It solves a narrower and important guarantee: an exact destination is never silently overwritten by the mutation primitive. +This does not turn the whole multi-file plan into a transaction. It solves narrower and important guarantees: an exact destination is never silently overwritten by the mutation primitive, and a late POSIX category symlink cannot redirect the move outside the planned workspace. ## Execution flow @@ -252,9 +240,10 @@ This does not turn the whole multi-file plan into a transaction. It solves a nar 3. category-path revalidation; 4. planned-source revalidation; 5. destination collision preflight; -6. creation of only required category folders; -7. each exact no-replace move; -8. construction of `OrganizationResult`. +6. creation/opening of only required category folders; +7. no-follow directory pinning on supported platforms; +8. each exact no-replace move; +9. construction of `OrganizationResult`. A stale plan is therefore not trusted blindly. @@ -286,138 +275,159 @@ Focused suite: python -m pytest practical-projects/06-file-organizer/tests -q ``` -The current focused suite contains **57 pytest scenarios**. +The current focused suite contains **58 pytest scenarios**. Coverage includes: - suffix classification; -- path validation; -- deterministic discovery; -- shallow scanning; -- symlink handling; +- compound archive suffixes; +- invalid path-like inputs; +- shallow deterministic discovery; +- source-directory validation; +- symlink discovery behavior; - immutable model invariants; -- exact and case-insensitive collisions; -- `ERROR` and `SKIP` policies; +- exact and casefold collisions; +- both collision policies; +- empty plans; - stale/missing sources; -- category-path changes; -- collision preflight; -- a destination created between preflight and mutation; -- successful execution; -- preservation of unrelated destination files; -- empty plans. +- category path replacement; +- destination creation after planning; +- exact destination creation between preflight and mutation; +- category symlink creation between preflight and mutation; +- successful moves; +- preservation of unrelated existing files. -## Failure paths worth studying +## Failure paths worth understanding ### Missing source directory -Raises `FileNotFoundError`. +Fails before planning. -### Source path is a regular file +### Source directory is a file -Raises `NotADirectoryError`. +Fails with `NotADirectoryError`. -### Source directory is a symlink +### Category path is a regular file -Rejected before scanning. +Planning/execution refuses to treat it as a folder. -### Category path is a file or symlink +### Category path is a symlink -Rejected before planning or execution. +The organizer rejects it. On POSIX-capable execution, a symlink introduced after preflight is also blocked by no-follow directory opening. -### Destination exists during planning +### Destination already exists -Handled according to the selected collision policy. +`ERROR` stops planning; `SKIP` records the source without moving it. ### Destination appears after planning -Preflight raises `FileExistsError` before any move. +Preflight refuses the stale plan. + +### Destination appears after preflight + +The no-replace hard-link operation raises instead of overwriting the late destination. -### Exact destination appears after preflight +### Category symlink appears after preflight -The no-replace hard-link operation fails with `FileExistsError`; the newly created destination is preserved and the source remains in place. +The POSIX secure path refuses to open the category with `O_NOFOLLOW`, so the source remains in place and the external symlink target is not written. + +### Planned source disappears or becomes a symlink + +Execution refuses the plan before normal mutation begins. ## Common mistakes -### Moving while scanning +### Moving files while discovering them -Mixing discovery and mutation makes partial failure difficult to reason about. +This mixes observation and mutation, making partial failure harder to reason about. Prefer building a plan first. -### Using only `Path.exists()` before `rename()` +### Using only `destination.exists()` before `rename()` + +That check cannot prevent a destination from appearing immediately afterward. -The check can become stale immediately, and POSIX rename semantics can replace the destination. +Use a mutation primitive that itself refuses replacement. -### Silently inventing new filenames +### Trusting a category pathname after preflight -Renaming collisions to values such as `report_2.txt` hides a policy decision. This project keeps collision behavior explicit. +A late symlink can change what that pathname means. -### Following symlinks accidentally +On POSIX-capable systems, open the intended directory with no-follow semantics and perform mutations relative to the pinned descriptor. -A friendly-looking path can point outside the intended workspace. +### Automatically renaming duplicates -### Assuming directory iteration order +A suffix like `(1)` may look convenient, but it silently changes identity and belongs to a separate policy. -Filesystem iteration order is not an application-level ordering contract. Sort explicitly when deterministic behavior matters. +Keep the collision policy explicit. -### Treating a successful preflight as a transaction +### Following symlinks by accident -The filesystem can change after preflight. Revalidation narrows risk but does not make a multi-file operation transactional. +Filesystem helpers often follow symlinks unless you deliberately define a boundary. + +Decide whether links are data, aliases, or forbidden paths before mutation. ## Exercise -Extend the organizer with a **dry-run renderer** without changing execution behavior. +Extend the planning layer with a new category named `CODE` for `.py`, `.js`, `.ts`, and `.sql` files. Requirements: -1. accept an `OrganizationPlan`; -2. return deterministic human-readable text; -3. show planned moves, skipped collisions, and ignored symlinks; -4. never access or mutate the filesystem; -5. add tests for empty and non-empty plans. +1. add the enum member; +2. update classification rules; +3. preserve deterministic ordering; +4. add focused tests; +5. do not change collision or symlink behavior. -The purpose is to practice keeping presentation separate from domain and mutation logic. +Then explain why classification belongs before execution rather than inside the move loop. ## Extension challenges -After completing the exercise, consider: +After the base contract is clear, try one at a time: -- a configurable suffix-to-category mapping; -- a user-defined category enum alternative; -- a JSON plan export/import format with careful stale-plan validation; -- an operation journal; -- recursive discovery with explicit relative-path rules; -- checksum-based duplicate detection; -- a rollback strategy for partially executed plans. +- a dry-run renderer that prints the plan without executing it; +- user-supplied extension/category mappings with validation; +- a result summary grouped by category; +- explicit rollback for earlier moves when a later move fails; +- a platform capability report explaining which no-follow protections are available; +- an opt-in recursive planner with preserved relative paths. -Each extension introduces new invariants. Add the contract before adding the code. +Each extension introduces a new responsibility. Keep it explicit rather than silently changing the current contract. ## Portfolio discussion -A useful portfolio explanation is not “I wrote a script that moves files.” +This project is useful in a portfolio because the interesting part is not the five destination folders. It is the safety reasoning around side effects. -A stronger explanation is: +You can discuss: -> I designed a filesystem workflow with a non-mutating planning phase, deterministic classification, explicit collision policies, symlink boundaries, execution-time revalidation, and exact no-replace destination protection. The behavior is covered by temporary-filesystem tests, including a simulated race between preflight and mutation. +- why planning is separated from execution; +- why collisions are policies instead of accidental behavior; +- why casefold checks improve portability; +- why symlinks define a trust boundary; +- why preflight alone cannot close a TOCTOU race; +- why `rename()` was replaced with a no-replace hard-link strategy; +- why POSIX directory descriptors and `O_NOFOLLOW` close the late-category-symlink redirect found during review; +- why the project stays intentionally shallow and same-filesystem; +- how tests simulate filesystem changes between planning, preflight, and mutation. -That communicates engineering decisions, not just API usage. +Those are engineering decisions, not merely syntax demonstrations. ## Quick reference -| Task | Function/type | -|---|---| -| Classify a filename | `classify_path()` | -| Discover direct regular files | `discover_files()` | -| Build a safe proposal | `plan_organization()` | -| Choose collision behavior | `CollisionPolicy` | -| Describe one move | `MoveAction` | -| Hold the immutable plan | `OrganizationPlan` | -| Execute the plan | `execute_plan()` | -| Hold successful destinations | `OrganizationResult` | -| Enforce exact no-replace mutation | `os.link()` + source `unlink()` | - -## What comes next - -Project 05 generated files. Project 06 owns the next boundary: discovering and organizing files safely. +```python +from file_organizer import ( + CollisionPolicy, + FileCategory, + classify_path, + discover_files, + execute_plan, + plan_organization, +) + +category = classify_path("report.PDF") +files = discover_files("workspace") +plan = plan_organization("workspace", collision_policy=CollisionPolicy.ERROR) +result = execute_plan(plan) +``` -Project 07 will move upward again, combining validated domain records and explicit workflow states in a **fictional reconciliation workflow**. +The central lesson is simple: **filesystem automation should make its plan and safety boundaries explicit before it mutates anything.** From 3fcb7688d0654fc3c0ca96fe7b62059a320211c0 Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Mon, 31 Aug 2026 21:38:30 -0300 Subject: [PATCH 018/117] Keep File Organizer docs synchronized across languages --- .../06-file-organizer/README.md | 294 +++++++++--------- 1 file changed, 142 insertions(+), 152 deletions(-) diff --git a/practical-projects/06-file-organizer/README.md b/practical-projects/06-file-organizer/README.md index 67c9783..4edf224 100644 --- a/practical-projects/06-file-organizer/README.md +++ b/practical-projects/06-file-organizer/README.md @@ -1,6 +1,6 @@
-# File Organizer +# Project 06 · File Organizer [🇺🇸 English](README.md) · [🇧🇷 Português](README.pt-BR.md) · [🇪🇸 Español](README.es.md) @@ -8,60 +8,79 @@ [← Back to Practical Projects](../README.md) -Project 06 turns filesystem concepts from Phase 8 into a small but deliberate organization workflow. The goal is not to build a desktop file manager. The goal is to practice discovery, classification, planning, collision handling, symlink boundaries, and safe mutation as separate engineering concerns. +> **Phase 10 · Practical Projects** -## What you will practice +This project organizes direct child files into category folders while keeping discovery, planning, collision handling, and filesystem mutation explicit and testable. + +## Learning objectives By the end of this project, you should be able to: -- discover direct regular-file children with `pathlib`; -- classify files by normalized suffix rules; -- model planned filesystem operations as immutable data; -- separate observation and planning from mutation; -- handle exact and case-insensitive destination collisions explicitly; -- ignore or reject symlinks at trust boundaries; -- revalidate a plan immediately before execution; -- prevent exact destination replacement during the mutation itself; -- pin POSIX source/category directories with no-follow directory descriptors so late symlinks cannot redirect moves; -- test filesystem race conditions with `pytest`, `tmp_path`, and monkeypatching; -- run a deterministic file workflow without touching personal directories. +- discover files with `pathlib` without recursively traversing a tree; +- classify filenames deterministically from case-insensitive suffix rules; +- model planned filesystem changes with immutable dataclasses; +- separate a non-mutating planning phase from a mutating execution phase; +- detect exact and case-insensitive destination collisions; +- choose an explicit collision policy instead of silently overwriting data; +- treat symlinks as a separate filesystem boundary; +- revalidate assumptions immediately before mutation; +- enforce exact destination no-replace behavior at the mutation step; +- test filesystem code safely with temporary directories. + +## Problem + +Imagine a fictional workspace containing files such as: + +```text +workspace/ +├── notes.txt +├── rows.csv +├── photo.png +├── backup.tar.gz +└── script.py +``` -## Project files +The organizer should produce: ```text -06-file-organizer/ -├── README.md -├── README.pt-BR.md -├── README.es.md -├── demo.py -├── file_organizer.py -└── tests/ - ├── conftest.py - ├── test_atomic_move.py - └── test_file_organizer.py +workspace/ +├── documents/ +│ └── notes.txt +├── data/ +│ └── rows.csv +├── images/ +│ └── photo.png +├── archives/ +│ └── backup.tar.gz +└── other/ + └── script.py ``` +The important challenge is not merely calling a move function. The project must make destructive filesystem decisions visible before changing anything. + ## Requirements -The organizer must: - -1. accept one source directory; -2. inspect only direct children; -3. classify regular files into explicit categories; -4. preserve original filenames; -5. build an immutable organization plan before changing the filesystem; -6. detect destination collisions case-insensitively during planning; -7. support explicit `ERROR` and `SKIP` collision policies; -8. reject source/category directory symlinks at validation boundaries; -9. ignore direct-child file symlinks instead of following them; -10. revalidate planned sources and destinations before mutation; -11. create only category directories actually required by the plan; -12. never replace an exact destination that appears after planning; -13. prevent late category symlinks from redirecting POSIX mutations outside the workspace; -14. return an immutable execution result; -15. remain deterministic for the same directory state. - -## Workflow +The implementation must: + +1. accept an existing non-symlink source directory; +2. inspect only direct children of that directory; +3. ignore nested directories; +4. report direct-child symlinks separately instead of following them; +5. classify regular files by filename suffix; +6. preserve each filename exactly; +7. create destination folders only when needed; +8. produce deterministic ordering; +9. build an immutable plan before mutation; +10. reject invalid category paths, including symlinked category directories; +11. detect existing exact and case-insensitive destination collisions; +12. support explicit `ERROR` and `SKIP` collision policies during planning; +13. run a full preflight before any move; +14. never silently replace an exact destination that appears after preflight; +15. return a structured result after successful execution. + +## Deliberate scope + +The pipeline is: ```text source directory @@ -70,7 +89,6 @@ source directory -> collision-safe plan -> execution preflight -> required category folders - -> no-follow directory pinning when supported -> no-replace moves ``` @@ -194,9 +212,7 @@ It also rejects: - a source directory that is itself a symlink; - a category folder implemented as a symlink. -On platforms that support secure directory file descriptors, execution goes further: the source directory and each required category directory are opened with `O_DIRECTORY | O_NOFOLLOW`, and mutation happens relative to those pinned descriptors. A category path that becomes a symlink after preflight is therefore rejected before use, while a path changed after the real directory is opened cannot redirect the move through that symlink. - -On platforms without those descriptor primitives, the portable fallback rechecks the category path immediately after creation and before each move. The POSIX descriptor path provides the stronger race-resistant boundary demonstrated by the dedicated regression test. +This keeps the project from unexpectedly moving files through a path that points outside the intended workspace. ## Why preflight is not enough @@ -211,8 +227,6 @@ That contains a time-of-check/time-of-use race. Another process can create the d On POSIX, `rename()` is allowed to replace an existing destination. That means a supposedly safe organizer could destroy newly created destination data. -A similar race exists for category directories: a real directory can be absent during preflight and a symlink can appear before mutation. Checking the path again is useful, but on POSIX the stronger defense is to open the intended directory without following symlinks and perform the mutation through that descriptor. - ## Exact no-replace mutation The execution path therefore uses a same-filesystem hard-link operation as its mutation guard: @@ -225,11 +239,9 @@ The execution path therefore uses a same-filesystem hard-link operation as its m `os.link()` does not replace an existing destination. Because every destination folder is inside the same source directory, source and destination are intentionally on the same filesystem for this project. -When directory-descriptor support is available, the link uses `src_dir_fd` and `dst_dir_fd`, with `follow_symlinks=False`, so the operation is anchored to pinned source/category directories instead of resolving a late category symlink through a pathname. - If the link cannot be created, the source remains untouched. If removing the source fails after the link was created, the implementation attempts to remove the destination link before propagating the failure. -This does not turn the whole multi-file plan into a transaction. It solves narrower and important guarantees: an exact destination is never silently overwritten by the mutation primitive, and a late POSIX category symlink cannot redirect the move outside the planned workspace. +This does not turn the whole multi-file plan into a transaction. It solves a narrower and important guarantee: an exact destination is never silently overwritten by the mutation primitive. ## Execution flow @@ -240,10 +252,9 @@ This does not turn the whole multi-file plan into a transaction. It solves narro 3. category-path revalidation; 4. planned-source revalidation; 5. destination collision preflight; -6. creation/opening of only required category folders; -7. no-follow directory pinning on supported platforms; -8. each exact no-replace move; -9. construction of `OrganizationResult`. +6. creation of only required category folders; +7. each exact no-replace move; +8. construction of `OrganizationResult`. A stale plan is therefore not trusted blindly. @@ -275,159 +286,138 @@ Focused suite: python -m pytest practical-projects/06-file-organizer/tests -q ``` -The current focused suite contains **58 pytest scenarios**. +The current focused suite contains **57 pytest scenarios**. Coverage includes: - suffix classification; -- compound archive suffixes; -- invalid path-like inputs; -- shallow deterministic discovery; -- source-directory validation; -- symlink discovery behavior; +- path validation; +- deterministic discovery; +- shallow scanning; +- symlink handling; - immutable model invariants; -- exact and casefold collisions; -- both collision policies; -- empty plans; +- exact and case-insensitive collisions; +- `ERROR` and `SKIP` policies; - stale/missing sources; -- category path replacement; -- destination creation after planning; -- exact destination creation between preflight and mutation; -- category symlink creation between preflight and mutation; -- successful moves; -- preservation of unrelated existing files. +- category-path changes; +- collision preflight; +- a destination created between preflight and mutation; +- successful execution; +- preservation of unrelated destination files; +- empty plans. -## Failure paths worth understanding +## Failure paths worth studying ### Missing source directory -Fails before planning. +Raises `FileNotFoundError`. -### Source directory is a file +### Source path is a regular file -Fails with `NotADirectoryError`. +Raises `NotADirectoryError`. -### Category path is a regular file +### Source directory is a symlink -Planning/execution refuses to treat it as a folder. +Rejected before scanning. -### Category path is a symlink +### Category path is a file or symlink -The organizer rejects it. On POSIX-capable execution, a symlink introduced after preflight is also blocked by no-follow directory opening. +Rejected before planning or execution. -### Destination already exists +### Destination exists during planning -`ERROR` stops planning; `SKIP` records the source without moving it. +Handled according to the selected collision policy. ### Destination appears after planning -Preflight refuses the stale plan. - -### Destination appears after preflight - -The no-replace hard-link operation raises instead of overwriting the late destination. +Preflight raises `FileExistsError` before any move. -### Category symlink appears after preflight +### Exact destination appears after preflight -The POSIX secure path refuses to open the category with `O_NOFOLLOW`, so the source remains in place and the external symlink target is not written. - -### Planned source disappears or becomes a symlink - -Execution refuses the plan before normal mutation begins. +The no-replace hard-link operation fails with `FileExistsError`; the newly created destination is preserved and the source remains in place. ## Common mistakes -### Moving files while discovering them +### Moving while scanning -This mixes observation and mutation, making partial failure harder to reason about. +Mixing discovery and mutation makes partial failure difficult to reason about. Prefer building a plan first. -### Using only `destination.exists()` before `rename()` - -That check cannot prevent a destination from appearing immediately afterward. +### Using only `Path.exists()` before `rename()` -Use a mutation primitive that itself refuses replacement. +The check can become stale immediately, and POSIX rename semantics can replace the destination. -### Trusting a category pathname after preflight +### Silently inventing new filenames -A late symlink can change what that pathname means. +Renaming collisions to values such as `report_2.txt` hides a policy decision. This project keeps collision behavior explicit. -On POSIX-capable systems, open the intended directory with no-follow semantics and perform mutations relative to the pinned descriptor. +### Following symlinks accidentally -### Automatically renaming duplicates +A friendly-looking path can point outside the intended workspace. -A suffix like `(1)` may look convenient, but it silently changes identity and belongs to a separate policy. +### Assuming directory iteration order -Keep the collision policy explicit. +Filesystem iteration order is not an application-level ordering contract. Sort explicitly when deterministic behavior matters. -### Following symlinks by accident +### Treating a successful preflight as a transaction -Filesystem helpers often follow symlinks unless you deliberately define a boundary. - -Decide whether links are data, aliases, or forbidden paths before mutation. +The filesystem can change after preflight. Revalidation narrows risk but does not make a multi-file operation transactional. ## Exercise -Extend the planning layer with a new category named `CODE` for `.py`, `.js`, `.ts`, and `.sql` files. +Extend the organizer with a **dry-run renderer** without changing execution behavior. Requirements: -1. add the enum member; -2. update classification rules; -3. preserve deterministic ordering; -4. add focused tests; -5. do not change collision or symlink behavior. +1. accept an `OrganizationPlan`; +2. return deterministic human-readable text; +3. show planned moves, skipped collisions, and ignored symlinks; +4. never access or mutate the filesystem; +5. add tests for empty and non-empty plans. -Then explain why classification belongs before execution rather than inside the move loop. +The purpose is to practice keeping presentation separate from domain and mutation logic. ## Extension challenges -After the base contract is clear, try one at a time: +After completing the exercise, consider: -- a dry-run renderer that prints the plan without executing it; -- user-supplied extension/category mappings with validation; -- a result summary grouped by category; -- explicit rollback for earlier moves when a later move fails; -- a platform capability report explaining which no-follow protections are available; -- an opt-in recursive planner with preserved relative paths. +- a configurable suffix-to-category mapping; +- a user-defined category enum alternative; +- a JSON plan export/import format with careful stale-plan validation; +- an operation journal; +- recursive discovery with explicit relative-path rules; +- checksum-based duplicate detection; +- a rollback strategy for partially executed plans. -Each extension introduces a new responsibility. Keep it explicit rather than silently changing the current contract. +Each extension introduces new invariants. Add the contract before adding the code. ## Portfolio discussion -This project is useful in a portfolio because the interesting part is not the five destination folders. It is the safety reasoning around side effects. +A useful portfolio explanation is not “I wrote a script that moves files.” -You can discuss: +A stronger explanation is: -- why planning is separated from execution; -- why collisions are policies instead of accidental behavior; -- why casefold checks improve portability; -- why symlinks define a trust boundary; -- why preflight alone cannot close a TOCTOU race; -- why `rename()` was replaced with a no-replace hard-link strategy; -- why POSIX directory descriptors and `O_NOFOLLOW` close the late-category-symlink redirect found during review; -- why the project stays intentionally shallow and same-filesystem; -- how tests simulate filesystem changes between planning, preflight, and mutation. +> I designed a filesystem workflow with a non-mutating planning phase, deterministic classification, explicit collision policies, symlink boundaries, execution-time revalidation, and exact no-replace destination protection. The behavior is covered by temporary-filesystem tests, including a simulated race between preflight and mutation. -Those are engineering decisions, not merely syntax demonstrations. +That communicates engineering decisions, not just API usage. ## Quick reference -```python -from file_organizer import ( - CollisionPolicy, - FileCategory, - classify_path, - discover_files, - execute_plan, - plan_organization, -) - -category = classify_path("report.PDF") -files = discover_files("workspace") -plan = plan_organization("workspace", collision_policy=CollisionPolicy.ERROR) -result = execute_plan(plan) -``` +| Task | Function/type | +|---|---| +| Classify a filename | `classify_path()` | +| Discover direct regular files | `discover_files()` | +| Build a safe proposal | `plan_organization()` | +| Choose collision behavior | `CollisionPolicy` | +| Describe one move | `MoveAction` | +| Hold the immutable plan | `OrganizationPlan` | +| Execute the plan | `execute_plan()` | +| Hold successful destinations | `OrganizationResult` | +| Enforce exact no-replace mutation | `os.link()` + source `unlink()` | + +## What comes next + +Project 05 generated files. Project 06 owns the next boundary: discovering and organizing files safely. -The central lesson is simple: **filesystem automation should make its plan and safety boundaries explicit before it mutates anything.** +Project 07 will move upward again, combining validated domain records and explicit workflow states in a **fictional reconciliation workflow**. From 37456a6599f9dd0a71de3299c82f361939943026 Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Mon, 31 Aug 2026 22:03:14 -0300 Subject: [PATCH 019/117] Harden source identity and rollback safety --- .../06-file-organizer/file_organizer.py | 213 +++++++++++++++--- 1 file changed, 184 insertions(+), 29 deletions(-) diff --git a/practical-projects/06-file-organizer/file_organizer.py b/practical-projects/06-file-organizer/file_organizer.py index 926e2a5..da3828d 100644 --- a/practical-projects/06-file-organizer/file_organizer.py +++ b/practical-projects/06-file-organizer/file_organizer.py @@ -1,6 +1,7 @@ from __future__ import annotations import os +import stat from dataclasses import dataclass from enum import Enum from os import PathLike @@ -31,6 +32,14 @@ class CollisionPolicy(str, Enum): _COMPOUND_ARCHIVE_SUFFIXES = (".tar.gz", ".tar.bz2", ".tar.xz") +@dataclass(frozen=True, slots=True) +class _FileIdentity: + """Stable filesystem identity captured before mutation.""" + + device: int + inode: int + + def _coerce_path(value: str | PathLike[str], field_name: str) -> Path: if isinstance(value, bool) or not isinstance(value, (str, PathLike)): raise TypeError(f"{field_name} must be a path-like value") @@ -55,6 +64,28 @@ def _path_sort_key(path: Path) -> tuple[str, str]: return path.name.casefold(), path.name +def _identity_from_stat( + stat_result: os.stat_result, + *, + filename: str, +) -> _FileIdentity: + if not stat.S_ISREG(stat_result.st_mode): + raise FileNotFoundError( + f"planned source is no longer a regular file: {filename}" + ) + return _FileIdentity(stat_result.st_dev, stat_result.st_ino) + + +def _capture_path_identity(path: Path) -> _FileIdentity: + try: + stat_result = path.lstat() + except FileNotFoundError as exc: + raise FileNotFoundError( + f"planned source is no longer a regular file: {path.name}" + ) from exc + return _identity_from_stat(stat_result, filename=path.name) + + @dataclass(frozen=True, slots=True) class MoveAction: """One planned move from the source directory into a category folder.""" @@ -293,18 +324,16 @@ def plan_organization( ) -def _preflight_execution(plan: OrganizationPlan) -> None: +def _preflight_execution(plan: OrganizationPlan) -> dict[Path, _FileIdentity]: root = _require_source_directory(plan.source_directory) if root != plan.source_directory: raise ValueError("source_directory no longer resolves to the planned directory") _validate_category_locations(root) - for action in plan.actions: - if action.source.is_symlink() or not action.source.is_file(): - raise FileNotFoundError( - f"planned source is no longer a regular file: {action.source.name}" - ) + source_identities = { + action.source: _capture_path_identity(action.source) for action in plan.actions + } for action in plan.actions: target_directory = action.destination.parent @@ -319,6 +348,8 @@ def _preflight_execution(plan: OrganizationPlan) -> None: f"destination appeared after planning: {action.destination.name}" ) + return source_identities + def _supports_secure_directory_fds() -> bool: """Return whether the platform can enforce no-follow directory mutation.""" @@ -329,6 +360,8 @@ def _supports_secure_directory_fds() -> bool: and os.mkdir in os.supports_dir_fd and os.link in os.supports_dir_fd and os.unlink in os.supports_dir_fd + and os.stat in os.supports_dir_fd + and os.stat in os.supports_follow_symlinks ) @@ -361,14 +394,84 @@ def _open_category_directory_fd(root_fd: int, category_name: str) -> int: ) from exc +def _regular_identity_at( + filename: str, + *, + directory_fd: int, +) -> _FileIdentity: + try: + stat_result = os.stat( + filename, + dir_fd=directory_fd, + follow_symlinks=False, + ) + except FileNotFoundError as exc: + raise FileNotFoundError( + f"planned source is no longer a regular file: {filename}" + ) from exc + return _identity_from_stat(stat_result, filename=filename) + + +def _verify_source_identity_at( + source_name: str, + *, + source_directory_fd: int, + expected_identity: _FileIdentity, +) -> None: + current_identity = _regular_identity_at( + source_name, + directory_fd=source_directory_fd, + ) + if current_identity != expected_identity: + raise FileNotFoundError( + f"planned source changed during execution: {source_name}" + ) + + +def _verify_destination_identity_at( + destination_name: str, + *, + destination_directory_fd: int, + expected_identity: _FileIdentity, +) -> None: + try: + stat_result = os.stat( + destination_name, + dir_fd=destination_directory_fd, + follow_symlinks=False, + ) + except FileNotFoundError as exc: + raise RuntimeError( + f"destination changed during execution: {destination_name}" + ) from exc + + if not stat.S_ISREG(stat_result.st_mode): + raise RuntimeError( + f"destination does not match planned source: {destination_name}" + ) + + destination_identity = _FileIdentity(stat_result.st_dev, stat_result.st_ino) + if destination_identity != expected_identity: + raise RuntimeError( + f"destination does not match planned source: {destination_name}" + ) + + def _move_file_no_replace_at( source_name: str, destination_name: str, *, source_directory_fd: int, destination_directory_fd: int, + expected_identity: _FileIdentity, ) -> None: - """Move one file through pinned directory descriptors without replacement.""" + """Move one verified regular file through pinned directory descriptors.""" + _verify_source_identity_at( + source_name, + source_directory_fd=source_directory_fd, + expected_identity=expected_identity, + ) + try: os.link( source_name, @@ -382,22 +485,65 @@ def _move_file_no_replace_at( f"destination appeared during execution: {destination_name}" ) from exc + _verify_destination_identity_at( + destination_name, + destination_directory_fd=destination_directory_fd, + expected_identity=expected_identity, + ) + _verify_source_identity_at( + source_name, + source_directory_fd=source_directory_fd, + expected_identity=expected_identity, + ) + try: os.unlink(source_name, dir_fd=source_directory_fd) except OSError as exc: - try: - os.unlink(destination_name, dir_fd=destination_directory_fd) - except OSError as rollback_exc: - raise RuntimeError( - f"move rollback failed for source file: {source_name}" - ) from rollback_exc raise OSError( - f"could not remove source after creating destination: {source_name}" + "could not remove source after creating destination; " + f"destination retained for safety: {source_name}" ) from exc -def _move_file_no_replace(source: Path, destination: Path) -> None: - """Portable fallback that never replaces an exact existing destination.""" +def _verify_path_identity(path: Path, expected_identity: _FileIdentity) -> None: + current_identity = _capture_path_identity(path) + if current_identity != expected_identity: + raise FileNotFoundError( + f"planned source changed during execution: {path.name}" + ) + + +def _verify_destination_path_identity( + destination: Path, + expected_identity: _FileIdentity, +) -> None: + try: + stat_result = destination.lstat() + except FileNotFoundError as exc: + raise RuntimeError( + f"destination changed during execution: {destination.name}" + ) from exc + + if not stat.S_ISREG(stat_result.st_mode): + raise RuntimeError( + f"destination does not match planned source: {destination.name}" + ) + + destination_identity = _FileIdentity(stat_result.st_dev, stat_result.st_ino) + if destination_identity != expected_identity: + raise RuntimeError( + f"destination does not match planned source: {destination.name}" + ) + + +def _move_file_no_replace( + source: Path, + destination: Path, + expected_identity: _FileIdentity, +) -> None: + """Portable fallback that verifies identity and never replaces a destination.""" + _verify_path_identity(source, expected_identity) + try: os.link(source, destination, follow_symlinks=False) except FileExistsError as exc: @@ -405,21 +551,22 @@ def _move_file_no_replace(source: Path, destination: Path) -> None: f"destination appeared during execution: {destination.name}" ) from exc + _verify_destination_path_identity(destination, expected_identity) + _verify_path_identity(source, expected_identity) + try: source.unlink() except OSError as exc: - try: - destination.unlink() - except OSError as rollback_exc: - raise RuntimeError( - f"move rollback failed for source file: {source.name}" - ) from rollback_exc raise OSError( - f"could not remove source after creating destination: {source.name}" + "could not remove source after creating destination; " + f"destination retained for safety: {source.name}" ) from exc -def _execute_plan_with_directory_fds(plan: OrganizationPlan) -> OrganizationResult: +def _execute_plan_with_directory_fds( + plan: OrganizationPlan, + source_identities: dict[Path, _FileIdentity], +) -> OrganizationResult: """Execute using pinned no-follow directory descriptors when supported.""" root_fd = _open_source_directory_fd(plan.source_directory) category_fds: dict[FileCategory, int] = {} @@ -441,6 +588,7 @@ def _execute_plan_with_directory_fds(plan: OrganizationPlan) -> OrganizationResu action.destination.name, source_directory_fd=root_fd, destination_directory_fd=category_fds[action.category], + expected_identity=source_identities[action.source], ) moved.append(action.destination) @@ -451,7 +599,10 @@ def _execute_plan_with_directory_fds(plan: OrganizationPlan) -> OrganizationResu os.close(root_fd) -def _execute_plan_portable(plan: OrganizationPlan) -> OrganizationResult: +def _execute_plan_portable( + plan: OrganizationPlan, + source_identities: dict[Path, _FileIdentity], +) -> OrganizationResult: """Execute on platforms without directory-descriptor no-follow support.""" for directory in sorted( {action.destination.parent for action in plan.actions}, @@ -470,7 +621,11 @@ def _execute_plan_portable(plan: OrganizationPlan) -> OrganizationResult: "category directory became unsafe during execution: " f"{action.destination.parent.name}" ) - _move_file_no_replace(action.source, action.destination) + _move_file_no_replace( + action.source, + action.destination, + source_identities[action.source], + ) moved.append(action.destination) return OrganizationResult(plan=plan, moved_files=tuple(moved)) @@ -481,8 +636,8 @@ def execute_plan(plan: OrganizationPlan) -> OrganizationResult: if not isinstance(plan, OrganizationPlan): raise TypeError("plan must be an OrganizationPlan") - _preflight_execution(plan) + source_identities = _preflight_execution(plan) if _supports_secure_directory_fds(): - return _execute_plan_with_directory_fds(plan) - return _execute_plan_portable(plan) + return _execute_plan_with_directory_fds(plan, source_identities) + return _execute_plan_portable(plan, source_identities) From 5aecabe984afdcc331c28dc2ba975f5e6faca45d Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Mon, 31 Aug 2026 22:03:55 -0300 Subject: [PATCH 020/117] Add source identity and rollback race regressions --- .../tests/test_atomic_move.py | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/practical-projects/06-file-organizer/tests/test_atomic_move.py b/practical-projects/06-file-organizer/tests/test_atomic_move.py index 612af59..71cc2bd 100644 --- a/practical-projects/06-file-organizer/tests/test_atomic_move.py +++ b/practical-projects/06-file-organizer/tests/test_atomic_move.py @@ -82,3 +82,88 @@ def racing_mkdir( assert source.read_text(encoding="utf-8") == "planned source" assert category.is_symlink() assert list(outside.iterdir()) == [] + + +def test_execute_plan_rejects_source_symlink_replacement_during_mutation( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + if not file_organizer._supports_secure_directory_fds(): + pytest.skip("secure directory descriptors are unavailable on this platform") + + source = tmp_path / "notes.txt" + source.write_text("planned source", encoding="utf-8") + target = tmp_path / "target.txt" + target.write_text("target data", encoding="utf-8") + plan = plan_organization(tmp_path) + destination = tmp_path / "documents" / "notes.txt" + original_move = file_organizer._move_file_no_replace_at + raced = False + + def racing_move( + source_name: str, + destination_name: str, + *, + source_directory_fd: int, + destination_directory_fd: int, + expected_identity: file_organizer._FileIdentity, + ) -> None: + nonlocal raced + if not raced: + raced = True + source.unlink() + source.symlink_to(target) + original_move( + source_name, + destination_name, + source_directory_fd=source_directory_fd, + destination_directory_fd=destination_directory_fd, + expected_identity=expected_identity, + ) + + monkeypatch.setattr(file_organizer, "_supports_secure_directory_fds", lambda: True) + monkeypatch.setattr(file_organizer, "_move_file_no_replace_at", racing_move) + + with pytest.raises(FileNotFoundError, match="regular file|changed during execution"): + execute_plan(plan) + + assert source.is_symlink() + assert target.read_text(encoding="utf-8") == "target data" + assert not destination.exists() + + +def test_source_unlink_failure_never_rolls_back_an_unverified_destination( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + if not file_organizer._supports_secure_directory_fds(): + pytest.skip("secure directory descriptors are unavailable on this platform") + + source = tmp_path / "notes.txt" + source.write_text("planned source", encoding="utf-8") + plan = plan_organization(tmp_path) + destination = tmp_path / "documents" / "notes.txt" + original_unlink = os.unlink + failed_source_unlink = False + + def racing_unlink( + path: str | os.PathLike[str], + *, + dir_fd: int | None = None, + ) -> None: + nonlocal failed_source_unlink + if path == source.name and dir_fd is not None and not failed_source_unlink: + failed_source_unlink = True + original_unlink(destination) + destination.write_text("third-party replacement", encoding="utf-8") + raise PermissionError("simulated source removal failure") + original_unlink(path, dir_fd=dir_fd) + + monkeypatch.setattr(file_organizer, "_supports_secure_directory_fds", lambda: True) + monkeypatch.setattr(file_organizer.os, "unlink", racing_unlink) + + with pytest.raises(OSError, match="destination retained for safety"): + execute_plan(plan) + + assert source.read_text(encoding="utf-8") == "planned source" + assert destination.read_text(encoding="utf-8") == "third-party replacement" From d43a8308c7caa22d9bf77ace05f869cc19470c47 Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Mon, 31 Aug 2026 22:05:03 -0300 Subject: [PATCH 021/117] Document conservative File Organizer mutation safety --- .../06-file-organizer/README.md | 62 ++++++++++++++----- 1 file changed, 46 insertions(+), 16 deletions(-) diff --git a/practical-projects/06-file-organizer/README.md b/practical-projects/06-file-organizer/README.md index 4edf224..547f453 100644 --- a/practical-projects/06-file-organizer/README.md +++ b/practical-projects/06-file-organizer/README.md @@ -25,6 +25,8 @@ By the end of this project, you should be able to: - treat symlinks as a separate filesystem boundary; - revalidate assumptions immediately before mutation; - enforce exact destination no-replace behavior at the mutation step; +- verify source identity across time-of-check/time-of-use boundaries; +- preserve uncertain destination state instead of performing destructive rollback; - test filesystem code safely with temporary directories. ## Problem @@ -76,7 +78,9 @@ The implementation must: 12. support explicit `ERROR` and `SKIP` collision policies during planning; 13. run a full preflight before any move; 14. never silently replace an exact destination that appears after preflight; -15. return a structured result after successful execution. +15. reject a planned source whose filesystem identity changes before commit; +16. never delete an unverified destination while handling a source-removal failure; +17. return a structured result after successful execution. ## Deliberate scope @@ -89,7 +93,7 @@ source directory -> collision-safe plan -> execution preflight -> required category folders - -> no-replace moves + -> identity-verified no-replace moves ``` This project intentionally does **not** include: @@ -212,7 +216,7 @@ It also rejects: - a source directory that is itself a symlink; - a category folder implemented as a symlink. -This keeps the project from unexpectedly moving files through a path that points outside the intended workspace. +On platforms with directory-descriptor support, execution pins the source and category directories with `O_DIRECTORY | O_NOFOLLOW` so a category path that becomes a symlink after preflight cannot redirect the mutation outside the workspace. ## Why preflight is not enough @@ -225,23 +229,28 @@ if not destination.exists(): That contains a time-of-check/time-of-use race. Another process can create the destination after the check but before the rename. -On POSIX, `rename()` is allowed to replace an existing destination. That means a supposedly safe organizer could destroy newly created destination data. +On POSIX, `rename()` is allowed to replace an existing destination. A planned source can also be replaced after preflight. Therefore execution must validate both destination availability and source identity at the mutation boundary. ## Exact no-replace mutation -The execution path therefore uses a same-filesystem hard-link operation as its mutation guard: +The execution path uses a same-filesystem hard-link operation as its destination guard: ```text -1. create destination hard link -2. fail atomically if that exact destination already exists -3. remove the original source path +1. capture source identity during preflight +2. revalidate that the source is still the same regular file +3. create the destination hard link without replacement +4. verify that the destination references the expected source identity +5. revalidate the source identity again +6. remove the original source path ``` +Filesystem identity is represented by the `(device, inode)` pair returned by `stat`. This lets execution distinguish “the same filename” from “the same filesystem object.” A late symlink or regular-file replacement therefore aborts execution instead of being reported as a successful move. + `os.link()` does not replace an existing destination. Because every destination folder is inside the same source directory, source and destination are intentionally on the same filesystem for this project. -If the link cannot be created, the source remains untouched. If removing the source fails after the link was created, the implementation attempts to remove the destination link before propagating the failure. +If creating the link fails, the source remains untouched. If removing the source fails after the destination has been created, the implementation deliberately **keeps the destination** and raises an error. It does not attempt an unconditional rollback unlink, because another process could have replaced that directory entry in the meantime. Preserving uncertain state is safer than deleting an object whose identity can no longer be proven. -This does not turn the whole multi-file plan into a transaction. It solves a narrower and important guarantee: an exact destination is never silently overwritten by the mutation primitive. +This does not turn the whole multi-file plan into a transaction. It provides narrower guarantees: exact destinations are not silently overwritten, planned sources are revalidated by identity, and failure handling does not intentionally delete an unverified destination. ## Execution flow @@ -250,10 +259,10 @@ This does not turn the whole multi-file plan into a transaction. It solves a nar 1. type validation; 2. source-directory revalidation; 3. category-path revalidation; -4. planned-source revalidation; +4. capture of planned-source filesystem identities; 5. destination collision preflight; -6. creation of only required category folders; -7. each exact no-replace move; +6. creation/opening of only required category folders; +7. identity-verified exact no-replace moves; 8. construction of `OrganizationResult`. A stale plan is therefore not trusted blindly. @@ -286,7 +295,7 @@ Focused suite: python -m pytest practical-projects/06-file-organizer/tests -q ``` -The current focused suite contains **57 pytest scenarios**. +The focused suite intentionally avoids embedding a fixed scenario count in this chapter because regression coverage grows as review findings are hardened. Coverage includes: @@ -302,6 +311,9 @@ Coverage includes: - category-path changes; - collision preflight; - a destination created between preflight and mutation; +- a category path becoming a symlink during mutation; +- a planned source becoming a symlink during mutation; +- source-removal failure without destructive destination rollback; - successful execution; - preservation of unrelated destination files; - empty plans. @@ -336,6 +348,14 @@ Preflight raises `FileExistsError` before any move. The no-replace hard-link operation fails with `FileExistsError`; the newly created destination is preserved and the source remains in place. +### Planned source identity changes during execution + +Execution raises instead of unlinking the changed source entry or reporting the move as successful. + +### Source removal fails after destination creation + +Execution raises and retains the destination. It deliberately avoids deleting a destination whose current identity cannot be proven safely during rollback. + ## Common mistakes ### Moving while scanning @@ -348,6 +368,14 @@ Prefer building a plan first. The check can become stale immediately, and POSIX rename semantics can replace the destination. +### Treating a filename as object identity + +A directory entry can be replaced while keeping the same name. When concurrency matters, compare filesystem identity and file type at the mutation boundary. + +### Rolling back by blindly deleting the destination + +A rollback path is still a mutation path. If another actor can replace the destination entry, unconditional deletion can destroy unrelated data. + ### Silently inventing new filenames Renaming collisions to values such as `report_2.txt` hides a policy decision. This project keeps collision behavior explicit. @@ -388,6 +416,7 @@ After completing the exercise, consider: - an operation journal; - recursive discovery with explicit relative-path rules; - checksum-based duplicate detection; +- a stronger platform-specific conditional source-removal primitive; - a rollback strategy for partially executed plans. Each extension introduces new invariants. Add the contract before adding the code. @@ -398,7 +427,7 @@ A useful portfolio explanation is not “I wrote a script that moves files.” A stronger explanation is: -> I designed a filesystem workflow with a non-mutating planning phase, deterministic classification, explicit collision policies, symlink boundaries, execution-time revalidation, and exact no-replace destination protection. The behavior is covered by temporary-filesystem tests, including a simulated race between preflight and mutation. +> I designed a filesystem workflow with a non-mutating planning phase, deterministic classification, explicit collision policies, symlink boundaries, execution-time identity validation, exact no-replace destination protection, and conservative failure handling that never blindly deletes an unverified rollback target. That communicates engineering decisions, not just API usage. @@ -414,7 +443,8 @@ That communicates engineering decisions, not just API usage. | Hold the immutable plan | `OrganizationPlan` | | Execute the plan | `execute_plan()` | | Hold successful destinations | `OrganizationResult` | -| Enforce exact no-replace mutation | `os.link()` + source `unlink()` | +| Verify filesystem identity | `(st_dev, st_ino)` from `stat` | +| Enforce exact no-replace mutation | `os.link()` + verified source `unlink()` | ## What comes next From cf9e46042ce95b811768d65d811331b7805ad900 Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Mon, 31 Aug 2026 22:05:52 -0300 Subject: [PATCH 022/117] =?UTF-8?q?Sincronizar=20seguran=C3=A7a=20do=20Fil?= =?UTF-8?q?e=20Organizer=20em=20PT-BR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../06-file-organizer/README.pt-BR.md | 96 +++++++++++-------- 1 file changed, 57 insertions(+), 39 deletions(-) diff --git a/practical-projects/06-file-organizer/README.pt-BR.md b/practical-projects/06-file-organizer/README.pt-BR.md index 89bfd59..0a86cac 100644 --- a/practical-projects/06-file-organizer/README.pt-BR.md +++ b/practical-projects/06-file-organizer/README.pt-BR.md @@ -25,6 +25,8 @@ Ao concluir este projeto, você deverá ser capaz de: - tratar symlinks como uma fronteira específica do filesystem; - revalidar premissas imediatamente antes da mutação; - garantir no próprio passo de mutação que um destino exato nunca seja substituído; +- verificar a identidade da origem através de fronteiras time-of-check/time-of-use; +- preservar estado incerto de destino em vez de executar rollback destrutivo; - testar código de filesystem com segurança usando diretórios temporários. ## Problema @@ -76,7 +78,9 @@ A implementação deve: 12. oferecer políticas explícitas `ERROR` e `SKIP` durante o planejamento; 13. executar um preflight completo antes de qualquer movimento; 14. nunca substituir silenciosamente um destino exato que apareça depois do preflight; -15. retornar um resultado estruturado após a execução bem-sucedida. +15. rejeitar uma origem planejada cuja identidade no filesystem mude antes do commit; +16. nunca excluir um destino não verificado ao tratar falha na remoção da origem; +17. retornar um resultado estruturado após a execução bem-sucedida. ## Escopo deliberado @@ -89,7 +93,7 @@ diretório de origem -> plano seguro contra colisões -> preflight de execução -> pastas de categoria necessárias - -> movimentos no-replace + -> movimentos no-replace com identidade verificada ``` Este projeto intencionalmente **não** inclui: @@ -180,28 +184,22 @@ Duas políticas são explícitas: O planejamento para com `FileExistsError` quando um nome de destino já existe. -Use quando todo arquivo de origem precisa de um destino sem conflito. - ### `CollisionPolicy.SKIP` Arquivos cujo destino colide permanecem no diretório de origem e são listados em `skipped_collisions`. -Use quando é aceitável organizar com segurança apenas o subconjunto sem conflitos. - A política é aplicada no planejamento. A execução continua recusando colisões exatas novas que apareçam depois. ## Colisões sem diferenciação de caixa -Um diretório pode ser case-sensitive em um sistema operacional e case-insensitive em outro. - -Por isso, o projeto compara nomes de destino com `casefold()` durante planejamento e preflight. Por exemplo, estes nomes são tratados como uma colisão lógica: +O projeto compara nomes de destino com `casefold()` durante planejamento e preflight. Por exemplo: ```text Report.TXT report.txt ``` -Isso mantém o plano mais portátil entre comportamentos comuns de filesystem. +Esses nomes são tratados como uma colisão lógica. ## Fronteira de symlink @@ -212,11 +210,11 @@ Ele também rejeita: - um diretório de origem que seja symlink; - uma pasta de categoria implementada como symlink. -Isso evita que o projeto mova arquivos inesperadamente por meio de um caminho que aponta para fora do workspace pretendido. +Em plataformas com suporte a descritores de diretório, a execução fixa a origem e as pastas de categoria usando `O_DIRECTORY | O_NOFOLLOW`. Assim, uma categoria que vire symlink depois do preflight não consegue redirecionar a mutação para fora do workspace. ## Por que o preflight não basta -Uma primeira implementação poderia fazer: +Uma implementação inicial poderia fazer: ```python if not destination.exists(): @@ -225,23 +223,28 @@ if not destination.exists(): Isso contém uma corrida de time-of-check/time-of-use. Outro processo pode criar o destino depois da checagem e antes do rename. -Em POSIX, `rename()` pode substituir um destino existente. Assim, um organizador aparentemente seguro poderia destruir dados recém-criados no destino. +Em POSIX, `rename()` pode substituir um destino existente. Além disso, uma origem planejada pode ser substituída depois do preflight. Por isso, a execução precisa validar tanto a disponibilidade do destino quanto a identidade da origem no momento da mutação. ## Mutação exata no-replace -Por isso, a execução usa uma operação de hard link no mesmo filesystem como proteção no próprio momento da mutação: +A execução usa hard link no mesmo filesystem como proteção de destino: ```text -1. criar hard link no destino -2. falhar atomicamente se aquele destino exato já existir -3. remover o caminho de origem original +1. capturar a identidade da origem no preflight +2. revalidar que a origem continua sendo o mesmo arquivo regular +3. criar o hard link de destino sem substituição +4. verificar que o destino referencia a identidade esperada da origem +5. revalidar novamente a identidade da origem +6. remover o caminho de origem original ``` -`os.link()` não substitui um destino existente. Como toda pasta de destino fica dentro do mesmo diretório de origem, origem e destino ficam intencionalmente no mesmo filesystem neste projeto. +A identidade do filesystem é representada pelo par `(device, inode)` retornado por `stat`. Isso permite diferenciar “o mesmo nome” de “o mesmo objeto do filesystem”. Uma substituição tardia por symlink ou por outro arquivo regular aborta a execução em vez de ser relatada como movimento bem-sucedido. + +`os.link()` não substitui um destino existente. Como toda pasta de destino fica dentro do mesmo diretório de origem, origem e destino permanecem intencionalmente no mesmo filesystem neste projeto. -Se o link não puder ser criado, a origem permanece intacta. Se a remoção da origem falhar depois da criação do link, a implementação tenta remover o link de destino antes de propagar a falha. +Se a criação do link falhar, a origem permanece intacta. Se a remoção da origem falhar depois da criação do destino, a implementação deliberadamente **mantém o destino** e gera erro. Ela não executa um `unlink()` de rollback incondicional, porque outro processo poderia ter substituído aquela entrada de diretório nesse intervalo. Preservar estado incerto é mais seguro do que excluir algo cuja identidade não pode mais ser comprovada. -Isso não transforma o plano inteiro de múltiplos arquivos em uma transação. Resolve uma garantia mais estreita e importante: um destino exato nunca é sobrescrito silenciosamente pela primitiva de mutação. +Isso não transforma o plano inteiro em uma transação. As garantias são mais estreitas: destinos exatos não são sobrescritos silenciosamente, origens planejadas são revalidadas por identidade e o tratamento de falha não exclui intencionalmente um destino não verificado. ## Fluxo de execução @@ -250,17 +253,17 @@ Isso não transforma o plano inteiro de múltiplos arquivos em uma transação. 1. validação de tipo; 2. revalidação do diretório de origem; 3. revalidação dos caminhos de categoria; -4. revalidação das origens planejadas; +4. captura das identidades das origens planejadas; 5. preflight de colisões de destino; -6. criação apenas das pastas de categoria necessárias; -7. cada movimento exato no-replace; +6. criação/abertura apenas das pastas necessárias; +7. movimentos exatos no-replace com identidade verificada; 8. construção de `OrganizationResult`. -Um plano antigo, portanto, nunca é aceito cegamente. +Um plano antigo, portanto, não é aceito cegamente. ## Determinismo -Arquivos e ações são ordenados por uma chave baseada em: +Arquivos e ações são ordenados por: ```python (path.name.casefold(), path.name) @@ -286,7 +289,7 @@ Suíte focada: python -m pytest practical-projects/06-file-organizer/tests -q ``` -A suíte focada atual contém **57 cenários pytest**. +Este capítulo evita embutir uma contagem fixa de cenários porque a cobertura de regressão cresce conforme findings de revisão são endurecidos. A cobertura inclui: @@ -302,6 +305,9 @@ A cobertura inclui: - mudanças em caminhos de categoria; - preflight de colisões; - destino criado entre preflight e mutação; +- categoria virando symlink durante a mutação; +- origem planejada virando symlink durante a mutação; +- falha na remoção da origem sem rollback destrutivo do destino; - execução bem-sucedida; - preservação de arquivos de destino não relacionados; - planos vazios. @@ -336,21 +342,35 @@ O preflight gera `FileExistsError` antes de qualquer movimento. A operação de hard link no-replace falha com `FileExistsError`; o destino recém-criado é preservado e a origem permanece no lugar. +### Identidade da origem planejada muda durante a execução + +A execução gera erro em vez de remover a entrada alterada ou relatar o movimento como sucesso. + +### Remoção da origem falha depois da criação do destino + +A execução gera erro e mantém o destino. Ela evita deliberadamente excluir um destino cuja identidade atual não pode ser comprovada com segurança durante rollback. + ## Erros comuns ### Mover enquanto varre -Misturar descoberta e mutação torna falhas parciais difíceis de entender. - -Prefira construir um plano primeiro. +Misturar descoberta e mutação torna falhas parciais difíceis de entender. Prefira construir um plano primeiro. ### Usar apenas `Path.exists()` antes de `rename()` A checagem pode ficar obsoleta imediatamente, e a semântica POSIX de rename pode substituir o destino. +### Tratar nome de arquivo como identidade do objeto + +Uma entrada de diretório pode ser substituída mantendo o mesmo nome. Quando concorrência importa, compare identidade do filesystem e tipo do arquivo na fronteira de mutação. + +### Fazer rollback apagando cegamente o destino + +O caminho de rollback também é um caminho de mutação. Se outro ator puder substituir a entrada de destino, uma exclusão incondicional pode destruir dados não relacionados. + ### Inventar novos nomes silenciosamente -Renomear colisões para valores como `report_2.txt` esconde uma decisão de política. Este projeto mantém esse comportamento explícito. +Renomear colisões para valores como `report_2.txt` esconde uma decisão de política. ### Seguir symlinks sem perceber @@ -358,7 +378,7 @@ Um caminho aparentemente simples pode apontar para fora do workspace pretendido. ### Assumir ordem de iteração do diretório -A ordem de iteração do filesystem não é um contrato de ordenação da aplicação. Ordene explicitamente quando o determinismo importar. +A ordem de iteração do filesystem não é um contrato de ordenação da aplicação. ### Tratar um preflight bem-sucedido como transação @@ -376,8 +396,6 @@ Requisitos: 4. nunca acessar nem modificar o filesystem; 5. adicionar testes para planos vazios e não vazios. -O objetivo é praticar a separação entre apresentação, domínio e lógica de mutação. - ## Desafios de extensão Depois do exercício, considere: @@ -388,17 +406,16 @@ Depois do exercício, considere: - journal de operações; - descoberta recursiva com regras explícitas de caminho relativo; - detecção de duplicidade por checksum; +- uma primitiva condicional de remoção de origem ainda mais forte e específica de plataforma; - estratégia de rollback para planos parcialmente executados. Cada extensão adiciona novas invariantes. Defina o contrato antes de adicionar o código. ## Discussão de portfólio -Uma explicação fraca seria “eu escrevi um script que move arquivos”. - Uma explicação mais forte seria: -> Eu projetei um fluxo de filesystem com fase de planejamento sem mutação, classificação determinística, políticas explícitas de colisão, fronteiras de symlink, revalidação no momento da execução e proteção exata no-replace do destino. O comportamento é coberto por testes com filesystem temporário, incluindo uma corrida simulada entre preflight e mutação. +> Eu projetei um fluxo de filesystem com fase de planejamento sem mutação, classificação determinística, políticas explícitas de colisão, fronteiras de symlink, validação de identidade na execução, proteção exata no-replace do destino e tratamento conservador de falhas que nunca apaga cegamente um alvo de rollback não verificado. Isso comunica decisões de engenharia, não apenas uso de API. @@ -414,10 +431,11 @@ Isso comunica decisões de engenharia, não apenas uso de API. | Manter o plano imutável | `OrganizationPlan` | | Executar o plano | `execute_plan()` | | Manter destinos bem-sucedidos | `OrganizationResult` | -| Garantir mutação exata no-replace | `os.link()` + `unlink()` da origem | +| Verificar identidade do filesystem | `(st_dev, st_ino)` de `stat` | +| Garantir mutação exata no-replace | `os.link()` + `unlink()` da origem verificada | ## O que vem depois -O Projeto 05 gerou arquivos. O Projeto 06 assume a fronteira seguinte: descobrir e organizar arquivos com segurança. +O Projeto 05 gerou arquivos. O Projeto 06 assume a próxima fronteira: descobrir e organizar arquivos com segurança. -O Projeto 07 sobe novamente de nível, combinando registros de domínio validados e estados explícitos em um **fluxo fictício de conciliação**. +O Projeto 07 volta a subir de nível, combinando registros de domínio validados e estados explícitos de workflow em um **fluxo fictício de conciliação**. From 34d6874c3702d4e8c2af295f79f4834fc6f0a58f Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Mon, 31 Aug 2026 22:06:44 -0300 Subject: [PATCH 023/117] Sincronizar seguridad del File Organizer en ES --- .../06-file-organizer/README.es.md | 220 +++++++++--------- 1 file changed, 115 insertions(+), 105 deletions(-) diff --git a/practical-projects/06-file-organizer/README.es.md b/practical-projects/06-file-organizer/README.es.md index 8dfd165..57eea3b 100644 --- a/practical-projects/06-file-organizer/README.es.md +++ b/practical-projects/06-file-organizer/README.es.md @@ -10,22 +10,24 @@ > **Fase 10 · Proyectos Prácticos** -Este proyecto organiza archivos hijos directos en carpetas por categoría, manteniendo descubrimiento, planificación, tratamiento de colisiones y mutación del filesystem de forma explícita y comprobable. +Este proyecto organiza archivos hijos directos en carpetas por categoría, manteniendo descubrimiento, planificación, manejo de colisiones y mutación del filesystem explícitos y comprobables. ## Objetivos de aprendizaje -Al completar este proyecto, deberías poder: +Al finalizar este proyecto, deberías poder: -- descubrir archivos con `pathlib` sin recorrer un árbol de forma recursiva; -- clasificar nombres de archivos de manera determinista mediante reglas de sufijo sin distinguir mayúsculas y minúsculas; -- modelar cambios planificados en el filesystem con dataclasses inmutables; +- descubrir archivos con `pathlib` sin recorrer recursivamente un árbol; +- clasificar nombres de archivo de forma determinista con reglas de sufijo sin distinguir mayúsculas y minúsculas; +- modelar cambios planificados del filesystem con dataclasses inmutables; - separar una fase de planificación sin mutación de una fase de ejecución con efectos secundarios; - detectar colisiones exactas y colisiones de destino ignorando diferencias de mayúsculas/minúsculas; - elegir una política de colisión explícita en lugar de sobrescribir datos silenciosamente; - tratar los symlinks como una frontera específica del filesystem; -- revalidar supuestos inmediatamente antes de la mutación; +- revalidar supuestos inmediatamente antes de mutar; - garantizar en el propio paso de mutación que un destino exacto nunca sea reemplazado; -- probar código de filesystem con seguridad usando directorios temporales. +- verificar la identidad del origen a través de fronteras time-of-check/time-of-use; +- conservar un estado de destino incierto en lugar de hacer rollback destructivo; +- probar código de filesystem de forma segura con directorios temporales. ## Problema @@ -56,27 +58,29 @@ workspace/ └── script.py ``` -El desafío importante no es simplemente llamar una función de movimiento. El proyecto debe hacer visibles las decisiones destructivas antes de modificar cualquier cosa. +El desafío importante no es simplemente llamar a una función de movimiento. El proyecto debe hacer visibles las decisiones destructivas antes de cambiar nada. ## Requisitos La implementación debe: 1. aceptar un directorio de origen existente que no sea symlink; -2. inspeccionar solo los hijos directos de ese directorio; +2. inspeccionar solo hijos directos de ese directorio; 3. ignorar directorios anidados; -4. registrar symlinks hijos directos por separado, sin seguirlos; +4. registrar symlinks hijos directos por separado sin seguirlos; 5. clasificar archivos regulares por el sufijo del nombre; 6. conservar exactamente cada nombre de archivo; 7. crear carpetas de destino solo cuando sean necesarias; 8. producir un orden determinista; -9. construir un plan inmutable antes de la mutación; +9. construir un plan inmutable antes de mutar; 10. rechazar rutas de categoría inválidas, incluidos directorios de categoría que sean symlinks; 11. detectar colisiones de destino exactas y sin distinción de mayúsculas/minúsculas; 12. ofrecer políticas explícitas `ERROR` y `SKIP` durante la planificación; 13. ejecutar un preflight completo antes de cualquier movimiento; 14. nunca reemplazar silenciosamente un destino exacto que aparezca después del preflight; -15. devolver un resultado estructurado después de una ejecución exitosa. +15. rechazar un origen planificado cuya identidad de filesystem cambie antes del commit; +16. nunca eliminar un destino no verificado al manejar un fallo al eliminar el origen; +17. devolver un resultado estructurado después de una ejecución exitosa. ## Alcance deliberado @@ -86,10 +90,10 @@ El pipeline es: directorio de origen -> descubrimiento de archivos directos -> clasificación por sufijo - -> plan seguro frente a colisiones + -> plan seguro contra colisiones -> preflight de ejecución -> carpetas de categoría necesarias - -> movimientos no-replace + -> movimientos no-replace con identidad verificada ``` Este proyecto intencionalmente **no** incluye: @@ -99,13 +103,13 @@ Este proyecto intencionalmente **no** incluye: - renombrado automático de duplicados; - hashing o deduplicación; - eliminación; -- transacciones de rollback para todo el plan; -- watchers del filesystem; +- transacciones de rollback para el plan completo; +- watchers de filesystem; - interfaz gráfica; - almacenamiento en la nube; -- organización entre filesystems diferentes. +- organización entre filesystems distintos. -Mantener estas responsabilidades fuera del alcance hace visibles las reglas de seguridad en lugar de ocultarlas dentro de un gestor de archivos genérico. +Mantener estas responsabilidades fuera de alcance hace visibles las reglas de seguridad en lugar de esconderlas dentro de un gestor de archivos genérico. ## Categorías @@ -117,9 +121,9 @@ Mantener estas responsabilidades fuera del alcance hace visibles las reglas de s | Datos | `data/` | `.csv`, `.json`, `.xml`, `.xlsx` | | Imágenes | `images/` | `.png`, `.jpg`, `.webp`, `.svg` | | Archivos comprimidos | `archives/` | `.zip`, `.7z`, `.tar.gz`, `.tar.xz` | -| Otros | `other/` | cualquier valor no cubierto por las reglas anteriores | +| Otros | `other/` | todo lo que no coincida con las reglas anteriores | -La coincidencia ignora diferencias entre mayúsculas y minúsculas. La clasificación usa únicamente el nombre del archivo y no abre su contenido. +La coincidencia ignora diferencias de mayúsculas y minúsculas. La clasificación usa solo el nombre del archivo y no abre su contenido. ## Modelos centrales @@ -131,7 +135,7 @@ Representa un movimiento planificado: archivo de origen -> destino de categoría ``` -Sus invariantes exigen rutas absolutas, el mismo nombre en origen y destino y una carpeta de destino correspondiente a la categoría seleccionada. +Sus invariantes exigen rutas absolutas, el mismo nombre en origen y destino y una carpeta de destino que corresponda a la categoría seleccionada. ### `OrganizationPlan` @@ -150,58 +154,44 @@ Registra exactamente los destinos planificados que se movieron con éxito. ## Descubrimiento intencionalmente superficial -`discover_files()` devuelve solo archivos regulares hijos directos. +`discover_files()` devuelve solo archivos regulares que son hijos directos. -No recorre directorios anidados. Esto importa porque el movimiento recursivo introduce preguntas adicionales: +Los directorios anidados no se recorren. El movimiento recursivo introduce preguntas adicionales sobre rutas relativas, carpetas de categoría anidadas y nombres duplicados provenientes de subdirectorios distintos. Esas preguntas pertenecen a un proyecto mayor. -- ¿debe preservarse la ruta relativa? -- ¿deben revisitarse carpetas de categoría dentro de subdirectorios? -- ¿cómo se gestionan nombres duplicados provenientes de subdirectorios distintos? +## Planificar antes de mutar -Esas preguntas son útiles, pero pertenecen a un proyecto mayor. +`plan_organization()` valida el directorio, escanea los archivos, clasifica cada uno y calcula destinos sin modificar el filesystem. -## Planificar antes de modificar - -`plan_organization()` valida el directorio, inspecciona los archivos, clasifica cada uno y calcula los destinos sin cambiar el filesystem. - -Esta separación crea un patrón de ingeniería útil: +Esto produce un patrón de ingeniería útil: ```text -observar -> decidir -> validar -> modificar +observar -> decidir -> validar -> mutar ``` Es más fácil probar y revisar una operación propuesta cuando existe como datos antes de que comiencen los efectos secundarios. ## Políticas de colisión -Hay dos políticas explícitas: - ### `CollisionPolicy.ERROR` La planificación se detiene con `FileExistsError` cuando ya existe un nombre de destino. -Úsala cuando cada archivo de origen necesite un destino libre de conflictos. - ### `CollisionPolicy.SKIP` -Los archivos cuyo destino colisiona permanecen en el directorio de origen y se registran en `skipped_collisions`. - -Úsala cuando sea aceptable organizar de forma segura solo el subconjunto sin conflictos. +Los archivos cuyo destino colisiona permanecen en el directorio de origen y se listan en `skipped_collisions`. La política se aplica durante la planificación. La ejecución sigue rechazando nuevas colisiones exactas que aparezcan después. ## Colisiones sin distinción de mayúsculas/minúsculas -Un directorio puede ser case-sensitive en un sistema operativo y case-insensitive en otro. - -Por eso, el proyecto compara nombres de destino con `casefold()` durante planificación y preflight. Por ejemplo, estos nombres se tratan como una colisión lógica: +El proyecto compara nombres de destino con `casefold()` durante planificación y preflight. Por ejemplo: ```text Report.TXT report.txt ``` -Esto mantiene el plan más portable entre comportamientos habituales de filesystem. +Estos nombres se consideran una colisión lógica. ## Frontera de symlink @@ -212,36 +202,41 @@ También rechaza: - un directorio de origen que sea symlink; - una carpeta de categoría implementada como symlink. -Esto evita mover archivos inesperadamente mediante una ruta que apunta fuera del workspace previsto. +En plataformas con soporte de descriptores de directorio, la ejecución fija origen y carpetas de categoría usando `O_DIRECTORY | O_NOFOLLOW`. Así, una categoría que se convierta en symlink después del preflight no puede redirigir la mutación fuera del workspace. -## Por qué el preflight no es suficiente +## Por qué el preflight no basta -Una primera implementación podría hacer: +Una implementación inicial podría hacer: ```python if not destination.exists(): source.rename(destination) ``` -Eso contiene una carrera de time-of-check/time-of-use. Otro proceso puede crear el destino después de la comprobación y antes del rename. +Eso contiene una carrera time-of-check/time-of-use. Otro proceso puede crear el destino después de la comprobación y antes del rename. -En POSIX, `rename()` puede reemplazar un destino existente. Por eso, un organizador aparentemente seguro podría destruir datos recién creados en el destino. +En POSIX, `rename()` puede reemplazar un destino existente. Además, un origen planificado puede ser sustituido después del preflight. Por eso la ejecución debe validar tanto la disponibilidad del destino como la identidad del origen en la frontera de mutación. ## Mutación exacta no-replace -La ejecución usa una operación de hard link dentro del mismo filesystem como protección en el momento exacto de la mutación: +La ejecución usa un hard link en el mismo filesystem como protección del destino: ```text -1. crear hard link en el destino -2. fallar de forma atómica si ese destino exacto ya existe -3. eliminar la ruta de origen original +1. capturar la identidad del origen durante el preflight +2. revalidar que el origen siga siendo el mismo archivo regular +3. crear el hard link de destino sin reemplazo +4. verificar que el destino referencia la identidad esperada del origen +5. revalidar otra vez la identidad del origen +6. eliminar la ruta de origen original ``` -`os.link()` no reemplaza un destino existente. Como todas las carpetas de destino se encuentran dentro del mismo directorio de origen, origen y destino permanecen intencionalmente en el mismo filesystem para este proyecto. +La identidad del filesystem se representa mediante el par `(device, inode)` devuelto por `stat`. Esto permite distinguir “el mismo nombre” de “el mismo objeto del filesystem”. Una sustitución tardía por symlink o por otro archivo regular aborta la ejecución en vez de aparecer como movimiento exitoso. + +`os.link()` no reemplaza un destino existente. Como cada carpeta de destino está dentro del mismo directorio de origen, origen y destino permanecen intencionalmente en el mismo filesystem para este proyecto. -Si el link no puede crearse, el origen queda intacto. Si la eliminación del origen falla después de crear el link, la implementación intenta eliminar el link de destino antes de propagar el error. +Si la creación del link falla, el origen permanece intacto. Si la eliminación del origen falla después de crear el destino, la implementación conserva deliberadamente **el destino** y genera un error. No ejecuta un `unlink()` de rollback incondicional porque otro proceso podría haber reemplazado esa entrada de directorio durante el intervalo. Conservar estado incierto es más seguro que eliminar algo cuya identidad ya no puede demostrarse. -Esto no convierte todo el plan de múltiples archivos en una transacción. Resuelve una garantía más estrecha e importante: un destino exacto nunca es sobrescrito silenciosamente por la primitiva de mutación. +Esto no convierte el plan completo en una transacción. Las garantías son más estrechas: los destinos exactos no se sobrescriben silenciosamente, los orígenes planificados se revalidan por identidad y el manejo de fallos no elimina intencionalmente un destino no verificado. ## Flujo de ejecución @@ -250,25 +245,25 @@ Esto no convierte todo el plan de múltiples archivos en una transacción. Resue 1. validación de tipo; 2. revalidación del directorio de origen; 3. revalidación de rutas de categoría; -4. revalidación de orígenes planificados; +4. captura de identidades de los orígenes planificados; 5. preflight de colisiones de destino; -6. creación de solo las carpetas de categoría necesarias; -7. cada movimiento exacto no-replace; +6. creación/apertura solo de las carpetas necesarias; +7. movimientos exactos no-replace con identidad verificada; 8. construcción de `OrganizationResult`. -Un plan antiguo, por lo tanto, nunca se acepta a ciegas. +Un plan obsoleto no se acepta a ciegas. ## Determinismo -Archivos y acciones se ordenan por una clave basada en: +Archivos y acciones se ordenan con: ```python (path.name.casefold(), path.name) ``` -Esto mantiene ejemplos, pruebas y revisiones estables en lugar de depender del orden de iteración del filesystem. +Esto mantiene ejemplos, pruebas y revisión estables en lugar de depender del orden de iteración del filesystem. -## Ejecutar la demo +## Ejecutar el demo Desde la raíz del repositorio: @@ -276,7 +271,7 @@ Desde la raíz del repositorio: python practical-projects/06-file-organizer/demo.py ``` -La demo usa `TemporaryDirectory`, crea únicamente archivos ficticios, muestra los movimientos planificados, ejecuta el plan y presenta el layout final. No toca directorios personales. +El demo usa `TemporaryDirectory`, crea solo archivos ficticios, muestra los movimientos planificados, ejecuta el plan y enseña el layout final. No toca directorios personales. ## Ejecutar las pruebas @@ -286,119 +281,133 @@ Suite enfocada: python -m pytest practical-projects/06-file-organizer/tests -q ``` -La suite enfocada actual contiene **57 escenarios pytest**. +Este capítulo evita incrustar un número fijo de escenarios porque la cobertura de regresión crece a medida que se endurecen findings de revisión. La cobertura incluye: - clasificación por sufijo; - validación de rutas; - descubrimiento determinista; -- recorrido superficial; -- tratamiento de symlinks; +- escaneo superficial; +- manejo de symlinks; - invariantes de modelos inmutables; - colisiones exactas y sin distinción de mayúsculas/minúsculas; - políticas `ERROR` y `SKIP`; - orígenes ausentes u obsoletos; - cambios en rutas de categoría; - preflight de colisiones; -- un destino creado entre preflight y mutación; +- destino creado entre preflight y mutación; +- categoría convertida en symlink durante la mutación; +- origen planificado convertido en symlink durante la mutación; +- fallo al eliminar origen sin rollback destructivo del destino; - ejecución exitosa; - preservación de archivos de destino no relacionados; - planes vacíos. -## Caminos de fallo importantes +## Rutas de fallo importantes ### Directorio de origen ausente Genera `FileNotFoundError`. -### La ruta de origen es un archivo regular +### Ruta de origen es un archivo regular Genera `NotADirectoryError`. -### El directorio de origen es symlink +### Directorio de origen es symlink Se rechaza antes del escaneo. -### La ruta de categoría es archivo o symlink +### Ruta de categoría es archivo o symlink -Se rechaza antes de la planificación o ejecución. +Se rechaza antes de planificar o ejecutar. -### El destino existe durante la planificación +### Destino existe durante la planificación -Se gestiona según la política de colisión seleccionada. +Se maneja según la política de colisión seleccionada. -### El destino aparece después de la planificación +### Destino aparece después de la planificación El preflight genera `FileExistsError` antes de cualquier movimiento. -### El destino exacto aparece después del preflight +### Destino exacto aparece después del preflight La operación hard-link no-replace falla con `FileExistsError`; el destino recién creado se conserva y el origen permanece en su lugar. +### La identidad del origen planificado cambia durante la ejecución + +La ejecución genera un error en lugar de eliminar la entrada modificada o informar el movimiento como exitoso. + +### Falla la eliminación del origen después de crear el destino + +La ejecución genera un error y conserva el destino. Evita deliberadamente eliminar un destino cuya identidad actual no puede demostrarse de forma segura durante rollback. + ## Errores comunes ### Mover mientras se escanea -Mezclar descubrimiento y mutación hace que los fallos parciales sean difíciles de razonar. - -Es preferible construir primero un plan. +Mezclar descubrimiento y mutación hace que los fallos parciales sean difíciles de razonar. Construye primero un plan. ### Usar solo `Path.exists()` antes de `rename()` -La comprobación puede quedar obsoleta inmediatamente y la semántica POSIX de rename puede reemplazar el destino. +La comprobación puede quedar obsoleta inmediatamente, y la semántica POSIX de rename puede reemplazar el destino. + +### Tratar el nombre como identidad del objeto + +Una entrada de directorio puede ser reemplazada conservando el mismo nombre. Cuando importa la concurrencia, compara identidad del filesystem y tipo de archivo en la frontera de mutación. + +### Hacer rollback eliminando ciegamente el destino + +El rollback también es una ruta de mutación. Si otro actor puede reemplazar la entrada de destino, una eliminación incondicional puede destruir datos no relacionados. ### Inventar nombres nuevos silenciosamente -Renombrar colisiones como `report_2.txt` oculta una decisión de política. Este proyecto mantiene ese comportamiento explícito. +Renombrar colisiones a valores como `report_2.txt` oculta una decisión de política. -### Seguir symlinks sin darse cuenta +### Seguir symlinks accidentalmente Una ruta aparentemente simple puede apuntar fuera del workspace previsto. -### Suponer el orden de iteración del directorio +### Asumir el orden de iteración del directorio -El orden de iteración del filesystem no es un contrato de orden de la aplicación. Ordena explícitamente cuando el determinismo sea importante. +El orden del filesystem no es un contrato de orden de la aplicación. ### Tratar un preflight exitoso como una transacción -El filesystem puede cambiar después del preflight. La revalidación reduce el riesgo, pero no convierte una operación de múltiples archivos en una transacción. +El filesystem puede cambiar después del preflight. La revalidación reduce riesgo, pero no vuelve transaccional una operación de varios archivos. ## Ejercicio -Extiende el organizador con un **renderizador de dry run** sin cambiar el comportamiento de ejecución. +Extiende el organizador con un **renderizador dry-run** sin cambiar el comportamiento de ejecución. Requisitos: 1. aceptar un `OrganizationPlan`; -2. devolver texto determinista y legible; +2. devolver texto legible y determinista; 3. mostrar movimientos planificados, colisiones omitidas y symlinks ignorados; 4. nunca acceder ni modificar el filesystem; -5. añadir pruebas para planes vacíos y no vacíos. - -El objetivo es practicar la separación entre presentación, dominio y lógica de mutación. +5. agregar pruebas para planes vacíos y no vacíos. ## Desafíos de extensión Después del ejercicio, considera: -- un mapeo configurable de sufijos a categorías; -- una alternativa con categorías definidas por el usuario; -- exportación/importación JSON del plan con validación cuidadosa de planes obsoletos; -- un journal de operaciones; -- descubrimiento recursivo con reglas explícitas de rutas relativas; +- mapeo configurable de sufijos a categorías; +- categorías definidas por el usuario; +- exportación/importación JSON del plan con validación cuidadosa de obsolescencia; +- journal de operaciones; +- descubrimiento recursivo con reglas explícitas de ruta relativa; - detección de duplicados por checksum; -- una estrategia de rollback para planes ejecutados parcialmente. - -Cada extensión introduce nuevas invariantes. Define el contrato antes de añadir el código. +- una primitiva condicional de eliminación de origen aún más fuerte y específica de plataforma; +- una estrategia de rollback para planes parcialmente ejecutados. -## Discusión de portafolio +Cada extensión añade nuevas invariantes. Define el contrato antes de añadir código. -Una explicación débil sería “escribí un script que mueve archivos”. +## Discusión de portfolio Una explicación más fuerte sería: -> Diseñé un flujo de filesystem con una fase de planificación sin mutación, clasificación determinista, políticas explícitas de colisión, fronteras de symlink, revalidación en el momento de ejecución y protección exacta no-replace del destino. El comportamiento está cubierto por pruebas con filesystem temporal, incluida una carrera simulada entre preflight y mutación. +> Diseñé un flujo de filesystem con planificación sin mutación, clasificación determinista, políticas explícitas de colisión, fronteras de symlink, validación de identidad durante la ejecución, protección exacta no-replace del destino y manejo conservador de fallos que nunca elimina ciegamente un objetivo de rollback no verificado. Eso comunica decisiones de ingeniería, no solo uso de API. @@ -414,10 +423,11 @@ Eso comunica decisiones de ingeniería, no solo uso de API. | Mantener el plan inmutable | `OrganizationPlan` | | Ejecutar el plan | `execute_plan()` | | Mantener destinos exitosos | `OrganizationResult` | -| Garantizar mutación exacta no-replace | `os.link()` + `unlink()` del origen | +| Verificar identidad del filesystem | `(st_dev, st_ino)` de `stat` | +| Garantizar mutación exacta no-replace | `os.link()` + `unlink()` del origen verificado | ## Qué viene después -El Proyecto 05 generó archivos. El Proyecto 06 asume la siguiente frontera: descubrir y organizar archivos con seguridad. +El Proyecto 05 generó archivos. El Proyecto 06 toma la siguiente frontera: descubrir y organizar archivos de forma segura. -El Proyecto 07 vuelve a subir de nivel, combinando registros de dominio validados y estados explícitos en un **flujo ficticio de conciliación**. +El Proyecto 07 vuelve a subir de nivel, combinando registros de dominio validados y estados explícitos de workflow en un **flujo ficticio de conciliación**. From 403b51f708a87f66285d836c99452c17777f2c8a Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Mon, 31 Aug 2026 22:08:11 -0300 Subject: [PATCH 024/117] Keep atomic race fixtures outside planned source set --- .../06-file-organizer/tests/test_atomic_move.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/practical-projects/06-file-organizer/tests/test_atomic_move.py b/practical-projects/06-file-organizer/tests/test_atomic_move.py index 71cc2bd..6c0de2e 100644 --- a/practical-projects/06-file-organizer/tests/test_atomic_move.py +++ b/practical-projects/06-file-organizer/tests/test_atomic_move.py @@ -93,9 +93,10 @@ def test_execute_plan_rejects_source_symlink_replacement_during_mutation( source = tmp_path / "notes.txt" source.write_text("planned source", encoding="utf-8") - target = tmp_path / "target.txt" - target.write_text("target data", encoding="utf-8") plan = plan_organization(tmp_path) + + outside = tmp_path.parent / f"{tmp_path.name}-source-target.txt" + outside.write_text("target data", encoding="utf-8") destination = tmp_path / "documents" / "notes.txt" original_move = file_organizer._move_file_no_replace_at raced = False @@ -112,7 +113,7 @@ def racing_move( if not raced: raced = True source.unlink() - source.symlink_to(target) + source.symlink_to(outside) original_move( source_name, destination_name, @@ -128,7 +129,7 @@ def racing_move( execute_plan(plan) assert source.is_symlink() - assert target.read_text(encoding="utf-8") == "target data" + assert outside.read_text(encoding="utf-8") == "target data" assert not destination.exists() From eefc394bbd94ffdac07c76bc18a33af2ab2cd335 Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Mon, 31 Aug 2026 22:08:40 -0300 Subject: [PATCH 025/117] Clarify no-CI validation status in File Organizer docs From eea7435be867a9273e3ff0ba0e04075e5f1b18dd Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Mon, 31 Aug 2026 22:09:30 -0300 Subject: [PATCH 026/117] Confirm PT-BR documentation parity From eb5f6b578b19117ade248840a827f59a0e899f02 Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Mon, 31 Aug 2026 22:38:01 -0300 Subject: [PATCH 027/117] Harden File Organizer commit races --- .../06-file-organizer/file_organizer.py | 324 +++++++++++++++++- 1 file changed, 309 insertions(+), 15 deletions(-) diff --git a/practical-projects/06-file-organizer/file_organizer.py b/practical-projects/06-file-organizer/file_organizer.py index da3828d..2b3f797 100644 --- a/practical-projects/06-file-organizer/file_organizer.py +++ b/practical-projects/06-file-organizer/file_organizer.py @@ -1,6 +1,7 @@ from __future__ import annotations import os +import secrets import stat from dataclasses import dataclass from enum import Enum @@ -64,7 +65,7 @@ def _path_sort_key(path: Path) -> tuple[str, str]: return path.name.casefold(), path.name -def _identity_from_stat( +def _identity_from_regular_stat( stat_result: os.stat_result, *, filename: str, @@ -76,6 +77,18 @@ def _identity_from_stat( return _FileIdentity(stat_result.st_dev, stat_result.st_ino) +def _identity_from_directory_stat( + stat_result: os.stat_result, + *, + directory_name: str, +) -> _FileIdentity: + if not stat.S_ISDIR(stat_result.st_mode): + raise ValueError( + f"category directory became unsafe during execution: {directory_name}" + ) + return _FileIdentity(stat_result.st_dev, stat_result.st_ino) + + def _capture_path_identity(path: Path) -> _FileIdentity: try: stat_result = path.lstat() @@ -83,7 +96,17 @@ def _capture_path_identity(path: Path) -> _FileIdentity: raise FileNotFoundError( f"planned source is no longer a regular file: {path.name}" ) from exc - return _identity_from_stat(stat_result, filename=path.name) + return _identity_from_regular_stat(stat_result, filename=path.name) + + +def _capture_directory_identity(path: Path) -> _FileIdentity: + try: + stat_result = path.lstat() + except FileNotFoundError as exc: + raise ValueError( + f"category directory became unsafe during execution: {path.name}" + ) from exc + return _identity_from_directory_stat(stat_result, directory_name=path.name) @dataclass(frozen=True, slots=True) @@ -360,6 +383,7 @@ def _supports_secure_directory_fds() -> bool: and os.mkdir in os.supports_dir_fd and os.link in os.supports_dir_fd and os.unlink in os.supports_dir_fd + and os.rename in os.supports_dir_fd and os.stat in os.supports_dir_fd and os.stat in os.supports_follow_symlinks ) @@ -387,12 +411,19 @@ def _open_category_directory_fd(root_fd: int, category_name: str) -> int: pass try: - return os.open(category_name, _directory_open_flags(), dir_fd=root_fd) + category_fd = os.open(category_name, _directory_open_flags(), dir_fd=root_fd) except OSError as exc: raise ValueError( f"category directory became unsafe during execution: {category_name}" ) from exc + _verify_category_anchor_at( + root_fd=root_fd, + category_name=category_name, + category_fd=category_fd, + ) + return category_fd + def _regular_identity_at( filename: str, @@ -409,7 +440,7 @@ def _regular_identity_at( raise FileNotFoundError( f"planned source is no longer a regular file: {filename}" ) from exc - return _identity_from_stat(stat_result, filename=filename) + return _identity_from_regular_stat(stat_result, filename=filename) def _verify_source_identity_at( @@ -457,20 +488,183 @@ def _verify_destination_identity_at( ) +def _verify_category_anchor_at( + *, + root_fd: int, + category_name: str, + category_fd: int, +) -> None: + """Require the pinned category FD to remain the named child of the root.""" + pinned = os.fstat(category_fd) + try: + current = os.stat( + category_name, + dir_fd=root_fd, + follow_symlinks=False, + ) + except FileNotFoundError as exc: + raise ValueError( + f"category directory moved during execution: {category_name}" + ) from exc + + pinned_identity = _identity_from_directory_stat( + pinned, + directory_name=category_name, + ) + current_identity = _identity_from_directory_stat( + current, + directory_name=category_name, + ) + if pinned_identity != current_identity: + raise ValueError( + f"category directory moved during execution: {category_name}" + ) + + +def _make_stage_name(source_name: str) -> str: + return f".file-organizer-stage-{secrets.token_hex(16)}-{source_name}" + + +def _restore_staged_regular_at( + stage_name: str, + source_name: str, + *, + root_fd: int, + expected_identity: _FileIdentity, +) -> None: + """Best-effort no-replace restore of a verified staged regular file.""" + try: + stage_identity = _regular_identity_at(stage_name, directory_fd=root_fd) + except FileNotFoundError: + return + if stage_identity != expected_identity: + return + + try: + os.link( + stage_name, + source_name, + src_dir_fd=root_fd, + dst_dir_fd=root_fd, + follow_symlinks=False, + ) + except FileExistsError: + return + try: + os.unlink(stage_name, dir_fd=root_fd) + except OSError: + pass + + +def _restore_destination_to_source_at( + destination_name: str, + source_name: str, + *, + root_fd: int, + destination_directory_fd: int, + expected_identity: _FileIdentity, +) -> None: + """Best-effort no-replace restore from a pinned destination to the source.""" + try: + _verify_destination_identity_at( + destination_name, + destination_directory_fd=destination_directory_fd, + expected_identity=expected_identity, + ) + os.link( + destination_name, + source_name, + src_dir_fd=destination_directory_fd, + dst_dir_fd=root_fd, + follow_symlinks=False, + ) + except OSError: + return + + +def _restore_staged_entry_no_replace_at( + stage_name: str, + source_name: str, + *, + root_fd: int, +) -> None: + """Restore a staged non-directory entry without replacing a new source.""" + try: + os.link( + stage_name, + source_name, + src_dir_fd=root_fd, + dst_dir_fd=root_fd, + follow_symlinks=False, + ) + except OSError: + return + try: + os.unlink(stage_name, dir_fd=root_fd) + except OSError: + pass + + +def _claim_source_at( + source_name: str, + *, + root_fd: int, + expected_identity: _FileIdentity, +) -> str: + """Atomically detach the current source entry, then verify what was claimed.""" + stage_name = _make_stage_name(source_name) + os.rename( + source_name, + stage_name, + src_dir_fd=root_fd, + dst_dir_fd=root_fd, + ) + + try: + staged_identity = _regular_identity_at(stage_name, directory_fd=root_fd) + except FileNotFoundError as exc: + _restore_staged_entry_no_replace_at( + stage_name, + source_name, + root_fd=root_fd, + ) + raise FileNotFoundError( + f"planned source changed during execution: {source_name}" + ) from exc + + if staged_identity != expected_identity: + _restore_staged_entry_no_replace_at( + stage_name, + source_name, + root_fd=root_fd, + ) + raise FileNotFoundError( + f"planned source changed during execution: {source_name}" + ) + + return stage_name + + def _move_file_no_replace_at( source_name: str, destination_name: str, *, source_directory_fd: int, destination_directory_fd: int, + category_name: str, expected_identity: _FileIdentity, ) -> None: - """Move one verified regular file through pinned directory descriptors.""" + """Move one file without deleting an entry that changed after verification.""" _verify_source_identity_at( source_name, source_directory_fd=source_directory_fd, expected_identity=expected_identity, ) + _verify_category_anchor_at( + root_fd=source_directory_fd, + category_name=category_name, + category_fd=destination_directory_fd, + ) try: os.link( @@ -490,20 +684,62 @@ def _move_file_no_replace_at( destination_directory_fd=destination_directory_fd, expected_identity=expected_identity, ) - _verify_source_identity_at( + _verify_category_anchor_at( + root_fd=source_directory_fd, + category_name=category_name, + category_fd=destination_directory_fd, + ) + + stage_name = _claim_source_at( source_name, - source_directory_fd=source_directory_fd, + root_fd=source_directory_fd, expected_identity=expected_identity, ) try: - os.unlink(source_name, dir_fd=source_directory_fd) + _verify_category_anchor_at( + root_fd=source_directory_fd, + category_name=category_name, + category_fd=destination_directory_fd, + ) + except ValueError: + _restore_staged_regular_at( + stage_name, + source_name, + root_fd=source_directory_fd, + expected_identity=expected_identity, + ) + raise + + try: + os.unlink(stage_name, dir_fd=source_directory_fd) except OSError as exc: + _restore_staged_regular_at( + stage_name, + source_name, + root_fd=source_directory_fd, + expected_identity=expected_identity, + ) raise OSError( - "could not remove source after creating destination; " - f"destination retained for safety: {source_name}" + f"could not finalize source removal safely: {source_name}" ) from exc + try: + _verify_category_anchor_at( + root_fd=source_directory_fd, + category_name=category_name, + category_fd=destination_directory_fd, + ) + except ValueError: + _restore_destination_to_source_at( + destination_name, + source_name, + root_fd=source_directory_fd, + destination_directory_fd=destination_directory_fd, + expected_identity=expected_identity, + ) + raise + def _verify_path_identity(path: Path, expected_identity: _FileIdentity) -> None: current_identity = _capture_path_identity(path) @@ -536,13 +772,50 @@ def _verify_destination_path_identity( ) +def _stage_source_path(source: Path, expected_identity: _FileIdentity) -> Path: + """Atomically move a source name to a unique internal staging name.""" + stage = source.with_name(_make_stage_name(source.name)) + os.rename(source, stage) + try: + _verify_path_identity(stage, expected_identity) + except FileNotFoundError: + try: + os.link(stage, source, follow_symlinks=False) + except OSError: + pass + else: + try: + stage.unlink() + except OSError: + pass + raise + return stage + + +def _restore_staged_path( + stage: Path, + source: Path, + expected_identity: _FileIdentity, +) -> None: + try: + _verify_path_identity(stage, expected_identity) + os.link(stage, source, follow_symlinks=False) + except OSError: + return + try: + stage.unlink() + except OSError: + pass + + def _move_file_no_replace( source: Path, destination: Path, expected_identity: _FileIdentity, ) -> None: - """Portable fallback that verifies identity and never replaces a destination.""" + """Portable fallback using a staged source detach instead of source unlink.""" _verify_path_identity(source, expected_identity) + category_identity = _capture_directory_identity(destination.parent) try: os.link(source, destination, follow_symlinks=False) @@ -552,16 +825,36 @@ def _move_file_no_replace( ) from exc _verify_destination_path_identity(destination, expected_identity) - _verify_path_identity(source, expected_identity) + if _capture_directory_identity(destination.parent) != category_identity: + raise ValueError( + f"category directory moved during execution: {destination.parent.name}" + ) + + stage = _stage_source_path(source, expected_identity) + + if _capture_directory_identity(destination.parent) != category_identity: + _restore_staged_path(stage, source, expected_identity) + raise ValueError( + f"category directory moved during execution: {destination.parent.name}" + ) try: - source.unlink() + stage.unlink() except OSError as exc: + _restore_staged_path(stage, source, expected_identity) raise OSError( - "could not remove source after creating destination; " - f"destination retained for safety: {source.name}" + f"could not finalize source removal safely: {source.name}" ) from exc + if _capture_directory_identity(destination.parent) != category_identity: + try: + os.link(destination, source, follow_symlinks=False) + except OSError: + pass + raise ValueError( + f"category directory moved during execution: {destination.parent.name}" + ) + def _execute_plan_with_directory_fds( plan: OrganizationPlan, @@ -588,6 +881,7 @@ def _execute_plan_with_directory_fds( action.destination.name, source_directory_fd=root_fd, destination_directory_fd=category_fds[action.category], + category_name=action.category.value, expected_identity=source_identities[action.source], ) moved.append(action.destination) From fb06c10aa43c9acc1915bb9f47e2bc16a3e9dd03 Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Mon, 31 Aug 2026 22:38:43 -0300 Subject: [PATCH 028/117] Add File Organizer commit-race regressions --- .../tests/test_atomic_move.py | 102 +++++++++++++++--- 1 file changed, 86 insertions(+), 16 deletions(-) diff --git a/practical-projects/06-file-organizer/tests/test_atomic_move.py b/practical-projects/06-file-organizer/tests/test_atomic_move.py index 6c0de2e..1d45f34 100644 --- a/practical-projects/06-file-organizer/tests/test_atomic_move.py +++ b/practical-projects/06-file-organizer/tests/test_atomic_move.py @@ -107,6 +107,7 @@ def racing_move( *, source_directory_fd: int, destination_directory_fd: int, + category_name: str, expected_identity: file_organizer._FileIdentity, ) -> None: nonlocal raced @@ -119,6 +120,7 @@ def racing_move( destination_name, source_directory_fd=source_directory_fd, destination_directory_fd=destination_directory_fd, + category_name=category_name, expected_identity=expected_identity, ) @@ -133,7 +135,7 @@ def racing_move( assert not destination.exists() -def test_source_unlink_failure_never_rolls_back_an_unverified_destination( +def test_source_replacement_between_verify_and_removal_is_never_unlinked( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: @@ -144,27 +146,95 @@ def test_source_unlink_failure_never_rolls_back_an_unverified_destination( source.write_text("planned source", encoding="utf-8") plan = plan_organization(tmp_path) destination = tmp_path / "documents" / "notes.txt" - original_unlink = os.unlink - failed_source_unlink = False - def racing_unlink( - path: str | os.PathLike[str], + original_rename = os.rename + raced = False + + def racing_rename( + source_path: str | os.PathLike[str], + destination_path: str | os.PathLike[str], *, - dir_fd: int | None = None, + src_dir_fd: int | None = None, + dst_dir_fd: int | None = None, + ) -> None: + nonlocal raced + if source_path == source.name and src_dir_fd is not None and not raced: + raced = True + source.unlink() + source.write_text("third-party replacement", encoding="utf-8") + original_rename( + source_path, + destination_path, + src_dir_fd=src_dir_fd, + dst_dir_fd=dst_dir_fd, + ) + + monkeypatch.setattr(file_organizer, "_supports_secure_directory_fds", lambda: True) + monkeypatch.setattr(file_organizer.os, "rename", racing_rename) + + with pytest.raises(FileNotFoundError, match="changed during execution"): + execute_plan(plan) + + assert source.read_text(encoding="utf-8") == "third-party replacement" + assert destination.read_text(encoding="utf-8") == "planned source" + assert not any( + child.name.startswith(".file-organizer-stage-") + for child in tmp_path.iterdir() + ) + + +def test_category_rename_after_fd_open_aborts_before_source_removal( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + if not file_organizer._supports_secure_directory_fds(): + pytest.skip("secure directory descriptors are unavailable on this platform") + + source = tmp_path / "notes.txt" + source.write_text("planned source", encoding="utf-8") + plan = plan_organization(tmp_path) + + category = tmp_path / "documents" + renamed_category = tmp_path / "documents-detached" + planned_destination = category / "notes.txt" + + original_link = os.link + raced = False + + def racing_link( + source_path: str | os.PathLike[str], + destination_path: str | os.PathLike[str], + *, + src_dir_fd: int | None = None, + dst_dir_fd: int | None = None, + follow_symlinks: bool = True, ) -> None: - nonlocal failed_source_unlink - if path == source.name and dir_fd is not None and not failed_source_unlink: - failed_source_unlink = True - original_unlink(destination) - destination.write_text("third-party replacement", encoding="utf-8") - raise PermissionError("simulated source removal failure") - original_unlink(path, dir_fd=dir_fd) + nonlocal raced + original_link( + source_path, + destination_path, + src_dir_fd=src_dir_fd, + dst_dir_fd=dst_dir_fd, + follow_symlinks=follow_symlinks, + ) + if ( + destination_path == source.name + and dst_dir_fd is not None + and src_dir_fd is not None + and not raced + ): + raced = True + category.rename(renamed_category) + category.mkdir() monkeypatch.setattr(file_organizer, "_supports_secure_directory_fds", lambda: True) - monkeypatch.setattr(file_organizer.os, "unlink", racing_unlink) + monkeypatch.setattr(file_organizer.os, "link", racing_link) - with pytest.raises(OSError, match="destination retained for safety"): + with pytest.raises(ValueError, match="category directory moved during execution"): execute_plan(plan) assert source.read_text(encoding="utf-8") == "planned source" - assert destination.read_text(encoding="utf-8") == "third-party replacement" + assert not planned_destination.exists() + assert ( + renamed_category / "notes.txt" + ).read_text(encoding="utf-8") == "planned source" From f8179a7a2cd06bbfaa97b0f97a06ab9bcb306d64 Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Mon, 31 Aug 2026 22:39:53 -0300 Subject: [PATCH 029/117] Document File Organizer commit hardening --- .../06-file-organizer/README.md | 80 +++++++++---------- 1 file changed, 38 insertions(+), 42 deletions(-) diff --git a/practical-projects/06-file-organizer/README.md b/practical-projects/06-file-organizer/README.md index 547f453..9704c44 100644 --- a/practical-projects/06-file-organizer/README.md +++ b/practical-projects/06-file-organizer/README.md @@ -25,8 +25,6 @@ By the end of this project, you should be able to: - treat symlinks as a separate filesystem boundary; - revalidate assumptions immediately before mutation; - enforce exact destination no-replace behavior at the mutation step; -- verify source identity across time-of-check/time-of-use boundaries; -- preserve uncertain destination state instead of performing destructive rollback; - test filesystem code safely with temporary directories. ## Problem @@ -78,9 +76,7 @@ The implementation must: 12. support explicit `ERROR` and `SKIP` collision policies during planning; 13. run a full preflight before any move; 14. never silently replace an exact destination that appears after preflight; -15. reject a planned source whose filesystem identity changes before commit; -16. never delete an unverified destination while handling a source-removal failure; -17. return a structured result after successful execution. +15. return a structured result after successful execution. ## Deliberate scope @@ -207,16 +203,17 @@ report.txt This keeps the plan portable across common filesystem behaviors. -## Symlink boundary +## Symlink and directory-anchor boundaries The organizer does not follow direct-child symlinks. It also rejects: - a source directory that is itself a symlink; -- a category folder implemented as a symlink. +- a category folder implemented as a symlink; +- a category directory that is renamed or replaced after its directory descriptor is opened. -On platforms with directory-descriptor support, execution pins the source and category directories with `O_DIRECTORY | O_NOFOLLOW` so a category path that becomes a symlink after preflight cannot redirect the mutation outside the workspace. +On platforms with secure directory-descriptor support, the implementation compares the pinned category descriptor identity with the current named child of the source root before and after mutation. A detached category directory therefore cannot silently receive a file while execution reports the original planned path. ## Why preflight is not enough @@ -229,28 +226,29 @@ if not destination.exists(): That contains a time-of-check/time-of-use race. Another process can create the destination after the check but before the rename. -On POSIX, `rename()` is allowed to replace an existing destination. A planned source can also be replaced after preflight. Therefore execution must validate both destination availability and source identity at the mutation boundary. +On POSIX, `rename()` is allowed to replace an existing destination. That means a supposedly safe organizer could destroy newly created destination data. + +The same principle applies to source removal: checking a source inode and then calling `unlink()` leaves a small window in which another process could replace that directory entry. ## Exact no-replace mutation -The execution path uses a same-filesystem hard-link operation as its destination guard: +The execution path uses a same-filesystem hard link as the destination mutation guard: ```text -1. capture source identity during preflight -2. revalidate that the source is still the same regular file -3. create the destination hard link without replacement -4. verify that the destination references the expected source identity -5. revalidate the source identity again -6. remove the original source path +1. verify source and category identities +2. create the destination hard link without replacement +3. verify the destination and category anchor +4. atomically rename the source entry to a unique internal staging name +5. verify that the staged entry is still the planned inode +6. remove only that internal staged name +7. verify the category anchor again before reporting success ``` -Filesystem identity is represented by the `(device, inode)` pair returned by `stat`. This lets execution distinguish “the same filename” from “the same filesystem object.” A late symlink or regular-file replacement therefore aborts execution instead of being reported as a successful move. - -`os.link()` does not replace an existing destination. Because every destination folder is inside the same source directory, source and destination are intentionally on the same filesystem for this project. +`os.link()` does not replace an existing destination. The staging rename avoids deleting the public source pathname after a separate identity check: if another actor replaces the source before the atomic rename, the unexpected entry is detected and preserved instead of being blindly unlinked. -If creating the link fails, the source remains untouched. If removing the source fails after the destination has been created, the implementation deliberately **keeps the destination** and raises an error. It does not attempt an unconditional rollback unlink, because another process could have replaced that directory entry in the meantime. Preserving uncertain state is safer than deleting an object whose identity can no longer be proven. +Category directory descriptors are also revalidated against the named category path. If the category is renamed or replaced during execution, the operation raises instead of reporting a destination path that no longer points to the pinned directory. -This does not turn the whole multi-file plan into a transaction. It provides narrower guarantees: exact destinations are not silently overwritten, planned sources are revalidated by identity, and failure handling does not intentionally delete an unverified destination. +This does not turn the whole multi-file plan into a transaction. It provides narrower guarantees around no-replace destination creation, source-entry identity, and category-path anchoring. ## Execution flow @@ -259,11 +257,14 @@ This does not turn the whole multi-file plan into a transaction. It provides nar 1. type validation; 2. source-directory revalidation; 3. category-path revalidation; -4. capture of planned-source filesystem identities; +4. planned-source identity capture; 5. destination collision preflight; -6. creation/opening of only required category folders; -7. identity-verified exact no-replace moves; -8. construction of `OrganizationResult`. +6. creation/opening of required category folders; +7. category-anchor verification; +8. exact no-replace destination linking; +9. atomic source staging and staged-identity verification; +10. final category-anchor verification; +11. construction of `OrganizationResult`. A stale plan is therefore not trusted blindly. @@ -295,8 +296,6 @@ Focused suite: python -m pytest practical-projects/06-file-organizer/tests -q ``` -The focused suite intentionally avoids embedding a fixed scenario count in this chapter because regression coverage grows as review findings are hardened. - Coverage includes: - suffix classification; @@ -311,9 +310,8 @@ Coverage includes: - category-path changes; - collision preflight; - a destination created between preflight and mutation; -- a category path becoming a symlink during mutation; -- a planned source becoming a symlink during mutation; -- source-removal failure without destructive destination rollback; +- category symlink and rename races; +- source replacement between verification and final removal; - successful execution; - preservation of unrelated destination files; - empty plans. @@ -348,13 +346,13 @@ Preflight raises `FileExistsError` before any move. The no-replace hard-link operation fails with `FileExistsError`; the newly created destination is preserved and the source remains in place. -### Planned source identity changes during execution +### Source entry changes during commit -Execution raises instead of unlinking the changed source entry or reporting the move as successful. +The source pathname is atomically staged and the staged inode is verified. An unexpected replacement is preserved and the move raises instead of deleting the replacement. -### Source removal fails after destination creation +### Category directory moves after it is opened -Execution raises and retains the destination. It deliberately avoids deleting a destination whose current identity cannot be proven safely during rollback. +The pinned descriptor and the currently named category path no longer match, so execution raises instead of returning a false planned destination. ## Common mistakes @@ -368,13 +366,13 @@ Prefer building a plan first. The check can become stale immediately, and POSIX rename semantics can replace the destination. -### Treating a filename as object identity +### Checking an inode immediately before `unlink()` -A directory entry can be replaced while keeping the same name. When concurrency matters, compare filesystem identity and file type at the mutation boundary. +That still leaves a check-to-unlink race. If source identity matters, restructure the commit so the public pathname is detached atomically before removing an internal staged name. -### Rolling back by blindly deleting the destination +### Assuming an open directory descriptor still has the same pathname -A rollback path is still a mutation path. If another actor can replace the destination entry, unconditional deletion can destroy unrelated data. +A descriptor remains attached to an inode even after that directory is renamed. Verify that the descriptor still matches the category child currently reachable from the planned root. ### Silently inventing new filenames @@ -416,7 +414,6 @@ After completing the exercise, consider: - an operation journal; - recursive discovery with explicit relative-path rules; - checksum-based duplicate detection; -- a stronger platform-specific conditional source-removal primitive; - a rollback strategy for partially executed plans. Each extension introduces new invariants. Add the contract before adding the code. @@ -427,7 +424,7 @@ A useful portfolio explanation is not “I wrote a script that moves files.” A stronger explanation is: -> I designed a filesystem workflow with a non-mutating planning phase, deterministic classification, explicit collision policies, symlink boundaries, execution-time identity validation, exact no-replace destination protection, and conservative failure handling that never blindly deletes an unverified rollback target. +> I designed a filesystem workflow with a non-mutating planning phase, deterministic classification, explicit collision policies, symlink boundaries, execution-time identity checks, category-path anchoring, and exact no-replace destination protection. Source removal uses an atomic staging step so a late pathname replacement is not blindly unlinked. That communicates engineering decisions, not just API usage. @@ -443,8 +440,7 @@ That communicates engineering decisions, not just API usage. | Hold the immutable plan | `OrganizationPlan` | | Execute the plan | `execute_plan()` | | Hold successful destinations | `OrganizationResult` | -| Verify filesystem identity | `(st_dev, st_ino)` from `stat` | -| Enforce exact no-replace mutation | `os.link()` + verified source `unlink()` | +| Enforce exact no-replace mutation | `os.link()` + atomic source staging | ## What comes next From 6e2f2a629ec9b15c57f632e8d458d6ba7448c74e Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Mon, 31 Aug 2026 22:40:56 -0300 Subject: [PATCH 030/117] Restore File Organizer language parity --- .../06-file-organizer/README.md | 80 ++++++++++--------- 1 file changed, 42 insertions(+), 38 deletions(-) diff --git a/practical-projects/06-file-organizer/README.md b/practical-projects/06-file-organizer/README.md index 9704c44..547f453 100644 --- a/practical-projects/06-file-organizer/README.md +++ b/practical-projects/06-file-organizer/README.md @@ -25,6 +25,8 @@ By the end of this project, you should be able to: - treat symlinks as a separate filesystem boundary; - revalidate assumptions immediately before mutation; - enforce exact destination no-replace behavior at the mutation step; +- verify source identity across time-of-check/time-of-use boundaries; +- preserve uncertain destination state instead of performing destructive rollback; - test filesystem code safely with temporary directories. ## Problem @@ -76,7 +78,9 @@ The implementation must: 12. support explicit `ERROR` and `SKIP` collision policies during planning; 13. run a full preflight before any move; 14. never silently replace an exact destination that appears after preflight; -15. return a structured result after successful execution. +15. reject a planned source whose filesystem identity changes before commit; +16. never delete an unverified destination while handling a source-removal failure; +17. return a structured result after successful execution. ## Deliberate scope @@ -203,17 +207,16 @@ report.txt This keeps the plan portable across common filesystem behaviors. -## Symlink and directory-anchor boundaries +## Symlink boundary The organizer does not follow direct-child symlinks. It also rejects: - a source directory that is itself a symlink; -- a category folder implemented as a symlink; -- a category directory that is renamed or replaced after its directory descriptor is opened. +- a category folder implemented as a symlink. -On platforms with secure directory-descriptor support, the implementation compares the pinned category descriptor identity with the current named child of the source root before and after mutation. A detached category directory therefore cannot silently receive a file while execution reports the original planned path. +On platforms with directory-descriptor support, execution pins the source and category directories with `O_DIRECTORY | O_NOFOLLOW` so a category path that becomes a symlink after preflight cannot redirect the mutation outside the workspace. ## Why preflight is not enough @@ -226,29 +229,28 @@ if not destination.exists(): That contains a time-of-check/time-of-use race. Another process can create the destination after the check but before the rename. -On POSIX, `rename()` is allowed to replace an existing destination. That means a supposedly safe organizer could destroy newly created destination data. - -The same principle applies to source removal: checking a source inode and then calling `unlink()` leaves a small window in which another process could replace that directory entry. +On POSIX, `rename()` is allowed to replace an existing destination. A planned source can also be replaced after preflight. Therefore execution must validate both destination availability and source identity at the mutation boundary. ## Exact no-replace mutation -The execution path uses a same-filesystem hard link as the destination mutation guard: +The execution path uses a same-filesystem hard-link operation as its destination guard: ```text -1. verify source and category identities -2. create the destination hard link without replacement -3. verify the destination and category anchor -4. atomically rename the source entry to a unique internal staging name -5. verify that the staged entry is still the planned inode -6. remove only that internal staged name -7. verify the category anchor again before reporting success +1. capture source identity during preflight +2. revalidate that the source is still the same regular file +3. create the destination hard link without replacement +4. verify that the destination references the expected source identity +5. revalidate the source identity again +6. remove the original source path ``` -`os.link()` does not replace an existing destination. The staging rename avoids deleting the public source pathname after a separate identity check: if another actor replaces the source before the atomic rename, the unexpected entry is detected and preserved instead of being blindly unlinked. +Filesystem identity is represented by the `(device, inode)` pair returned by `stat`. This lets execution distinguish “the same filename” from “the same filesystem object.” A late symlink or regular-file replacement therefore aborts execution instead of being reported as a successful move. + +`os.link()` does not replace an existing destination. Because every destination folder is inside the same source directory, source and destination are intentionally on the same filesystem for this project. -Category directory descriptors are also revalidated against the named category path. If the category is renamed or replaced during execution, the operation raises instead of reporting a destination path that no longer points to the pinned directory. +If creating the link fails, the source remains untouched. If removing the source fails after the destination has been created, the implementation deliberately **keeps the destination** and raises an error. It does not attempt an unconditional rollback unlink, because another process could have replaced that directory entry in the meantime. Preserving uncertain state is safer than deleting an object whose identity can no longer be proven. -This does not turn the whole multi-file plan into a transaction. It provides narrower guarantees around no-replace destination creation, source-entry identity, and category-path anchoring. +This does not turn the whole multi-file plan into a transaction. It provides narrower guarantees: exact destinations are not silently overwritten, planned sources are revalidated by identity, and failure handling does not intentionally delete an unverified destination. ## Execution flow @@ -257,14 +259,11 @@ This does not turn the whole multi-file plan into a transaction. It provides nar 1. type validation; 2. source-directory revalidation; 3. category-path revalidation; -4. planned-source identity capture; +4. capture of planned-source filesystem identities; 5. destination collision preflight; -6. creation/opening of required category folders; -7. category-anchor verification; -8. exact no-replace destination linking; -9. atomic source staging and staged-identity verification; -10. final category-anchor verification; -11. construction of `OrganizationResult`. +6. creation/opening of only required category folders; +7. identity-verified exact no-replace moves; +8. construction of `OrganizationResult`. A stale plan is therefore not trusted blindly. @@ -296,6 +295,8 @@ Focused suite: python -m pytest practical-projects/06-file-organizer/tests -q ``` +The focused suite intentionally avoids embedding a fixed scenario count in this chapter because regression coverage grows as review findings are hardened. + Coverage includes: - suffix classification; @@ -310,8 +311,9 @@ Coverage includes: - category-path changes; - collision preflight; - a destination created between preflight and mutation; -- category symlink and rename races; -- source replacement between verification and final removal; +- a category path becoming a symlink during mutation; +- a planned source becoming a symlink during mutation; +- source-removal failure without destructive destination rollback; - successful execution; - preservation of unrelated destination files; - empty plans. @@ -346,13 +348,13 @@ Preflight raises `FileExistsError` before any move. The no-replace hard-link operation fails with `FileExistsError`; the newly created destination is preserved and the source remains in place. -### Source entry changes during commit +### Planned source identity changes during execution -The source pathname is atomically staged and the staged inode is verified. An unexpected replacement is preserved and the move raises instead of deleting the replacement. +Execution raises instead of unlinking the changed source entry or reporting the move as successful. -### Category directory moves after it is opened +### Source removal fails after destination creation -The pinned descriptor and the currently named category path no longer match, so execution raises instead of returning a false planned destination. +Execution raises and retains the destination. It deliberately avoids deleting a destination whose current identity cannot be proven safely during rollback. ## Common mistakes @@ -366,13 +368,13 @@ Prefer building a plan first. The check can become stale immediately, and POSIX rename semantics can replace the destination. -### Checking an inode immediately before `unlink()` +### Treating a filename as object identity -That still leaves a check-to-unlink race. If source identity matters, restructure the commit so the public pathname is detached atomically before removing an internal staged name. +A directory entry can be replaced while keeping the same name. When concurrency matters, compare filesystem identity and file type at the mutation boundary. -### Assuming an open directory descriptor still has the same pathname +### Rolling back by blindly deleting the destination -A descriptor remains attached to an inode even after that directory is renamed. Verify that the descriptor still matches the category child currently reachable from the planned root. +A rollback path is still a mutation path. If another actor can replace the destination entry, unconditional deletion can destroy unrelated data. ### Silently inventing new filenames @@ -414,6 +416,7 @@ After completing the exercise, consider: - an operation journal; - recursive discovery with explicit relative-path rules; - checksum-based duplicate detection; +- a stronger platform-specific conditional source-removal primitive; - a rollback strategy for partially executed plans. Each extension introduces new invariants. Add the contract before adding the code. @@ -424,7 +427,7 @@ A useful portfolio explanation is not “I wrote a script that moves files.” A stronger explanation is: -> I designed a filesystem workflow with a non-mutating planning phase, deterministic classification, explicit collision policies, symlink boundaries, execution-time identity checks, category-path anchoring, and exact no-replace destination protection. Source removal uses an atomic staging step so a late pathname replacement is not blindly unlinked. +> I designed a filesystem workflow with a non-mutating planning phase, deterministic classification, explicit collision policies, symlink boundaries, execution-time identity validation, exact no-replace destination protection, and conservative failure handling that never blindly deletes an unverified rollback target. That communicates engineering decisions, not just API usage. @@ -440,7 +443,8 @@ That communicates engineering decisions, not just API usage. | Hold the immutable plan | `OrganizationPlan` | | Execute the plan | `execute_plan()` | | Hold successful destinations | `OrganizationResult` | -| Enforce exact no-replace mutation | `os.link()` + atomic source staging | +| Verify filesystem identity | `(st_dev, st_ino)` from `stat` | +| Enforce exact no-replace mutation | `os.link()` + verified source `unlink()` | ## What comes next From 0f30460b4c9b9ec0cedd1251cb63b85db3ab5abc Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Tue, 1 Sep 2026 07:03:44 -0300 Subject: [PATCH 031/117] Harden File Organizer atomic commit path --- .../06-file-organizer/file_organizer.py | 452 ++++++++---------- 1 file changed, 208 insertions(+), 244 deletions(-) diff --git a/practical-projects/06-file-organizer/file_organizer.py b/practical-projects/06-file-organizer/file_organizer.py index 2b3f797..a135fcb 100644 --- a/practical-projects/06-file-organizer/file_organizer.py +++ b/practical-projects/06-file-organizer/file_organizer.py @@ -1,8 +1,12 @@ from __future__ import annotations +import ctypes +import errno import os import secrets import stat +import sys +from collections.abc import Callable from dataclasses import dataclass from enum import Enum from os import PathLike @@ -31,6 +35,32 @@ class CollisionPolicy(str, Enum): _IMAGE_SUFFIXES = frozenset({".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"}) _ARCHIVE_SUFFIXES = frozenset({".zip", ".tar", ".gz", ".bz2", ".xz", ".7z"}) _COMPOUND_ARCHIVE_SUFFIXES = (".tar.gz", ".tar.bz2", ".tar.xz") +_RENAME_NOREPLACE = 1 +_AT_FDCWD = -100 + + +def _load_renameat2() -> Callable[..., int] | None: + """Return Linux renameat2 when libc exposes the no-replace primitive.""" + if not sys.platform.startswith("linux"): + return None + try: + libc = ctypes.CDLL(None, use_errno=True) + renameat2 = libc.renameat2 + except (OSError, AttributeError): + return None + + renameat2.argtypes = [ + ctypes.c_int, + ctypes.c_char_p, + ctypes.c_int, + ctypes.c_char_p, + ctypes.c_uint, + ] + renameat2.restype = ctypes.c_int + return renameat2 + + +_RENAMEAT2 = _load_renameat2() @dataclass(frozen=True, slots=True) @@ -84,7 +114,7 @@ def _identity_from_directory_stat( ) -> _FileIdentity: if not stat.S_ISDIR(stat_result.st_mode): raise ValueError( - f"category directory became unsafe during execution: {directory_name}" + f"directory became unsafe during execution: {directory_name}" ) return _FileIdentity(stat_result.st_dev, stat_result.st_ino) @@ -104,7 +134,7 @@ def _capture_directory_identity(path: Path) -> _FileIdentity: stat_result = path.lstat() except FileNotFoundError as exc: raise ValueError( - f"category directory became unsafe during execution: {path.name}" + f"directory became unsafe during execution: {path.name}" ) from exc return _identity_from_directory_stat(stat_result, directory_name=path.name) @@ -375,14 +405,14 @@ def _preflight_execution(plan: OrganizationPlan) -> dict[Path, _FileIdentity]: def _supports_secure_directory_fds() -> bool: - """Return whether the platform can enforce no-follow directory mutation.""" + """Return whether Linux can enforce descriptor-anchored no-replace renames.""" return ( - hasattr(os, "O_DIRECTORY") + _RENAMEAT2 is not None + and hasattr(os, "O_DIRECTORY") and hasattr(os, "O_NOFOLLOW") and os.open in os.supports_dir_fd and os.mkdir in os.supports_dir_fd and os.link in os.supports_dir_fd - and os.unlink in os.supports_dir_fd and os.rename in os.supports_dir_fd and os.stat in os.supports_dir_fd and os.stat in os.supports_follow_symlinks @@ -403,6 +433,29 @@ def _open_source_directory_fd(source_directory: Path) -> int: raise ValueError("source_directory became unsafe during execution") from exc +def _verify_root_anchor_at(source_directory: Path, root_fd: int) -> None: + """Require the pinned root FD to remain reachable at the planned path.""" + pinned = os.fstat(root_fd) + try: + current = source_directory.lstat() + except FileNotFoundError as exc: + raise ValueError("source_directory moved during execution") from exc + + pinned_identity = _identity_from_directory_stat( + pinned, + directory_name=source_directory.name, + ) + try: + current_identity = _identity_from_directory_stat( + current, + directory_name=source_directory.name, + ) + except ValueError as exc: + raise ValueError("source_directory moved during execution") from exc + if pinned_identity != current_identity: + raise ValueError("source_directory moved during execution") + + def _open_category_directory_fd(root_fd: int, category_name: str) -> int: """Create/open one category directory without following a late symlink.""" try: @@ -425,11 +478,7 @@ def _open_category_directory_fd(root_fd: int, category_name: str) -> int: return category_fd -def _regular_identity_at( - filename: str, - *, - directory_fd: int, -) -> _FileIdentity: +def _regular_identity_at(filename: str, *, directory_fd: int) -> _FileIdentity: try: stat_result = os.stat( filename, @@ -459,6 +508,38 @@ def _verify_source_identity_at( ) +def _open_planned_source_fd_at( + source_name: str, + *, + root_fd: int, + expected_identity: _FileIdentity, +) -> int: + """Pin the planned inode so an unlinked source cannot be inode-reused.""" + flags = os.O_RDONLY | os.O_NOFOLLOW + if hasattr(os, "O_CLOEXEC"): + flags |= os.O_CLOEXEC + try: + source_fd = os.open(source_name, flags, dir_fd=root_fd) + except OSError as exc: + raise FileNotFoundError( + f"planned source changed during execution: {source_name}" + ) from exc + + try: + current_identity = _identity_from_regular_stat( + os.fstat(source_fd), + filename=source_name, + ) + if current_identity != expected_identity: + raise FileNotFoundError( + f"planned source changed during execution: {source_name}" + ) + except Exception: + os.close(source_fd) + raise + return source_fd + + def _verify_destination_identity_at( destination_name: str, *, @@ -480,9 +561,7 @@ def _verify_destination_identity_at( raise RuntimeError( f"destination does not match planned source: {destination_name}" ) - - destination_identity = _FileIdentity(stat_result.st_dev, stat_result.st_ino) - if destination_identity != expected_identity: + if _FileIdentity(stat_result.st_dev, stat_result.st_ino) != expected_identity: raise RuntimeError( f"destination does not match planned source: {destination_name}" ) @@ -522,24 +601,13 @@ def _verify_category_anchor_at( def _make_stage_name(source_name: str) -> str: - return f".file-organizer-stage-{secrets.token_hex(16)}-{source_name}" + """Return a fixed-length internal name independent of the source filename.""" + del source_name + return f".fo-stage-{secrets.token_hex(16)}" -def _restore_staged_regular_at( - stage_name: str, - source_name: str, - *, - root_fd: int, - expected_identity: _FileIdentity, -) -> None: - """Best-effort no-replace restore of a verified staged regular file.""" - try: - stage_identity = _regular_identity_at(stage_name, directory_fd=root_fd) - except FileNotFoundError: - return - if stage_identity != expected_identity: - return - +def _preserve_stage_at(stage_name: str, source_name: str, *, root_fd: int) -> None: + """Best-effort restore by linking only; never delete a raced staging entry.""" try: os.link( stage_name, @@ -548,59 +616,6 @@ def _restore_staged_regular_at( dst_dir_fd=root_fd, follow_symlinks=False, ) - except FileExistsError: - return - try: - os.unlink(stage_name, dir_fd=root_fd) - except OSError: - pass - - -def _restore_destination_to_source_at( - destination_name: str, - source_name: str, - *, - root_fd: int, - destination_directory_fd: int, - expected_identity: _FileIdentity, -) -> None: - """Best-effort no-replace restore from a pinned destination to the source.""" - try: - _verify_destination_identity_at( - destination_name, - destination_directory_fd=destination_directory_fd, - expected_identity=expected_identity, - ) - os.link( - destination_name, - source_name, - src_dir_fd=destination_directory_fd, - dst_dir_fd=root_fd, - follow_symlinks=False, - ) - except OSError: - return - - -def _restore_staged_entry_no_replace_at( - stage_name: str, - source_name: str, - *, - root_fd: int, -) -> None: - """Restore a staged non-directory entry without replacing a new source.""" - try: - os.link( - stage_name, - source_name, - src_dir_fd=root_fd, - dst_dir_fd=root_fd, - follow_symlinks=False, - ) - except OSError: - return - try: - os.unlink(stage_name, dir_fd=root_fd) except OSError: pass @@ -611,7 +626,7 @@ def _claim_source_at( root_fd: int, expected_identity: _FileIdentity, ) -> str: - """Atomically detach the current source entry, then verify what was claimed.""" + """Atomically detach the source name and verify the claimed regular file.""" stage_name = _make_stage_name(source_name) os.rename( source_name, @@ -623,122 +638,149 @@ def _claim_source_at( try: staged_identity = _regular_identity_at(stage_name, directory_fd=root_fd) except FileNotFoundError as exc: - _restore_staged_entry_no_replace_at( - stage_name, - source_name, - root_fd=root_fd, - ) + _preserve_stage_at(stage_name, source_name, root_fd=root_fd) raise FileNotFoundError( f"planned source changed during execution: {source_name}" ) from exc if staged_identity != expected_identity: - _restore_staged_entry_no_replace_at( - stage_name, - source_name, - root_fd=root_fd, - ) + _preserve_stage_at(stage_name, source_name, root_fd=root_fd) raise FileNotFoundError( f"planned source changed during execution: {source_name}" ) - return stage_name -def _move_file_no_replace_at( +def _rename_no_replace_at( source_name: str, destination_name: str, *, source_directory_fd: int, destination_directory_fd: int, - category_name: str, - expected_identity: _FileIdentity, ) -> None: - """Move one file without deleting an entry that changed after verification.""" - _verify_source_identity_at( - source_name, - source_directory_fd=source_directory_fd, - expected_identity=expected_identity, - ) - _verify_category_anchor_at( - root_fd=source_directory_fd, - category_name=category_name, - category_fd=destination_directory_fd, + """Atomically rename between pinned directories without replacing destination.""" + if _RENAMEAT2 is None: + raise NotImplementedError("atomic no-replace rename is unavailable") + + ctypes.set_errno(0) + result = _RENAMEAT2( + source_directory_fd, + os.fsencode(source_name), + destination_directory_fd, + os.fsencode(destination_name), + _RENAME_NOREPLACE, ) + if result == 0: + return - try: - os.link( - source_name, + error_number = ctypes.get_errno() + if error_number == errno.EEXIST: + raise FileExistsError( + error_number, + "destination appeared during execution", destination_name, - src_dir_fd=source_directory_fd, - dst_dir_fd=destination_directory_fd, - follow_symlinks=False, ) - except FileExistsError as exc: - raise FileExistsError( - f"destination appeared during execution: {destination_name}" - ) from exc + raise OSError(error_number, os.strerror(error_number), destination_name) - _verify_destination_identity_at( - destination_name, - destination_directory_fd=destination_directory_fd, - expected_identity=expected_identity, - ) + +def _move_file_no_replace_at( + source_name: str, + destination_name: str, + *, + source_directory_path: Path, + source_directory_fd: int, + destination_directory_fd: int, + category_name: str, + expected_identity: _FileIdentity, +) -> None: + """Commit one move with anchored directories and no replace/unlink window.""" + _verify_root_anchor_at(source_directory_path, source_directory_fd) _verify_category_anchor_at( root_fd=source_directory_fd, category_name=category_name, category_fd=destination_directory_fd, ) - - stage_name = _claim_source_at( + source_fd = _open_planned_source_fd_at( source_name, root_fd=source_directory_fd, expected_identity=expected_identity, ) try: - _verify_category_anchor_at( - root_fd=source_directory_fd, - category_name=category_name, - category_fd=destination_directory_fd, - ) - except ValueError: - _restore_staged_regular_at( - stage_name, + stage_name = _claim_source_at( source_name, root_fd=source_directory_fd, expected_identity=expected_identity, ) - raise - try: - os.unlink(stage_name, dir_fd=source_directory_fd) - except OSError as exc: - _restore_staged_regular_at( - stage_name, - source_name, - root_fd=source_directory_fd, + try: + _verify_root_anchor_at(source_directory_path, source_directory_fd) + _verify_category_anchor_at( + root_fd=source_directory_fd, + category_name=category_name, + category_fd=destination_directory_fd, + ) + staged_identity = _regular_identity_at( + stage_name, + directory_fd=source_directory_fd, + ) + if staged_identity != expected_identity: + raise FileNotFoundError( + f"planned source changed during execution: {source_name}" + ) + _rename_no_replace_at( + stage_name, + destination_name, + source_directory_fd=source_directory_fd, + destination_directory_fd=destination_directory_fd, + ) + except (FileExistsError, FileNotFoundError, ValueError, OSError): + _preserve_stage_at(stage_name, source_name, root_fd=source_directory_fd) + raise + + _verify_destination_identity_at( + destination_name, + destination_directory_fd=destination_directory_fd, expected_identity=expected_identity, ) - raise OSError( - f"could not finalize source removal safely: {source_name}" - ) from exc - - try: + _verify_root_anchor_at(source_directory_path, source_directory_fd) _verify_category_anchor_at( root_fd=source_directory_fd, category_name=category_name, category_fd=destination_directory_fd, ) - except ValueError: - _restore_destination_to_source_at( - destination_name, - source_name, - root_fd=source_directory_fd, - destination_directory_fd=destination_directory_fd, - expected_identity=expected_identity, + finally: + os.close(source_fd) + + +def _rename_no_replace_path(source: Path, destination: Path) -> None: + """Portable no-replace rename for Windows; Linux uses descriptor execution.""" + if os.name != "nt": + raise NotImplementedError( + "safe execution requires Linux renameat2 or Windows rename semantics" + ) + try: + os.rename(source, destination) + except FileExistsError as exc: + raise FileExistsError( + f"destination appeared during execution: {destination.name}" + ) from exc + + +def _move_file_no_replace( + source: Path, + destination: Path, + expected_identity: _FileIdentity, +) -> None: + """Windows fallback using its atomic no-replace rename behavior.""" + _verify_path_identity(source, expected_identity) + category_identity = _capture_directory_identity(destination.parent) + _rename_no_replace_path(source, destination) + _verify_destination_path_identity(destination, expected_identity) + if _capture_directory_identity(destination.parent) != category_identity: + raise ValueError( + f"category directory moved during execution: {destination.parent.name}" ) - raise def _verify_path_identity(path: Path, expected_identity: _FileIdentity) -> None: @@ -759,112 +801,26 @@ def _verify_destination_path_identity( raise RuntimeError( f"destination changed during execution: {destination.name}" ) from exc - if not stat.S_ISREG(stat_result.st_mode): raise RuntimeError( f"destination does not match planned source: {destination.name}" ) - - destination_identity = _FileIdentity(stat_result.st_dev, stat_result.st_ino) - if destination_identity != expected_identity: + if _FileIdentity(stat_result.st_dev, stat_result.st_ino) != expected_identity: raise RuntimeError( f"destination does not match planned source: {destination.name}" ) -def _stage_source_path(source: Path, expected_identity: _FileIdentity) -> Path: - """Atomically move a source name to a unique internal staging name.""" - stage = source.with_name(_make_stage_name(source.name)) - os.rename(source, stage) - try: - _verify_path_identity(stage, expected_identity) - except FileNotFoundError: - try: - os.link(stage, source, follow_symlinks=False) - except OSError: - pass - else: - try: - stage.unlink() - except OSError: - pass - raise - return stage - - -def _restore_staged_path( - stage: Path, - source: Path, - expected_identity: _FileIdentity, -) -> None: - try: - _verify_path_identity(stage, expected_identity) - os.link(stage, source, follow_symlinks=False) - except OSError: - return - try: - stage.unlink() - except OSError: - pass - - -def _move_file_no_replace( - source: Path, - destination: Path, - expected_identity: _FileIdentity, -) -> None: - """Portable fallback using a staged source detach instead of source unlink.""" - _verify_path_identity(source, expected_identity) - category_identity = _capture_directory_identity(destination.parent) - - try: - os.link(source, destination, follow_symlinks=False) - except FileExistsError as exc: - raise FileExistsError( - f"destination appeared during execution: {destination.name}" - ) from exc - - _verify_destination_path_identity(destination, expected_identity) - if _capture_directory_identity(destination.parent) != category_identity: - raise ValueError( - f"category directory moved during execution: {destination.parent.name}" - ) - - stage = _stage_source_path(source, expected_identity) - - if _capture_directory_identity(destination.parent) != category_identity: - _restore_staged_path(stage, source, expected_identity) - raise ValueError( - f"category directory moved during execution: {destination.parent.name}" - ) - - try: - stage.unlink() - except OSError as exc: - _restore_staged_path(stage, source, expected_identity) - raise OSError( - f"could not finalize source removal safely: {source.name}" - ) from exc - - if _capture_directory_identity(destination.parent) != category_identity: - try: - os.link(destination, source, follow_symlinks=False) - except OSError: - pass - raise ValueError( - f"category directory moved during execution: {destination.parent.name}" - ) - - def _execute_plan_with_directory_fds( plan: OrganizationPlan, source_identities: dict[Path, _FileIdentity], ) -> OrganizationResult: - """Execute using pinned no-follow directory descriptors when supported.""" + """Execute using pinned no-follow directory descriptors on Linux.""" root_fd = _open_source_directory_fd(plan.source_directory) category_fds: dict[FileCategory, int] = {} try: + _verify_root_anchor_at(plan.source_directory, root_fd) for category in sorted( {action.category for action in plan.actions}, key=lambda item: item.value, @@ -879,6 +835,7 @@ def _execute_plan_with_directory_fds( _move_file_no_replace_at( action.source.name, action.destination.name, + source_directory_path=plan.source_directory, source_directory_fd=root_fd, destination_directory_fd=category_fds[action.category], category_name=action.category.value, @@ -886,6 +843,7 @@ def _execute_plan_with_directory_fds( ) moved.append(action.destination) + _verify_root_anchor_at(plan.source_directory, root_fd) return OrganizationResult(plan=plan, moved_files=tuple(moved)) finally: for directory_fd in category_fds.values(): @@ -897,7 +855,12 @@ def _execute_plan_portable( plan: OrganizationPlan, source_identities: dict[Path, _FileIdentity], ) -> OrganizationResult: - """Execute on platforms without directory-descriptor no-follow support.""" + """Execute on Windows, where os.rename refuses an existing destination.""" + if os.name != "nt": + raise NotImplementedError( + "safe execution requires Linux renameat2 or Windows rename semantics" + ) + for directory in sorted( {action.destination.parent for action in plan.actions}, key=lambda path: (path.name.casefold(), path.name), @@ -921,7 +884,6 @@ def _execute_plan_portable( source_identities[action.source], ) moved.append(action.destination) - return OrganizationResult(plan=plan, moved_files=tuple(moved)) @@ -931,6 +893,8 @@ def execute_plan(plan: OrganizationPlan) -> OrganizationResult: raise TypeError("plan must be an OrganizationPlan") source_identities = _preflight_execution(plan) + if not plan.actions: + return OrganizationResult(plan=plan, moved_files=()) if _supports_secure_directory_fds(): return _execute_plan_with_directory_fds(plan, source_identities) From f6b021ea7ade05434d8ff19b37b2c51e14c24e9e Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Tue, 1 Sep 2026 07:04:16 -0300 Subject: [PATCH 032/117] Cover File Organizer atomic rename races --- .../tests/test_atomic_move.py | 183 +++++++++++++----- 1 file changed, 130 insertions(+), 53 deletions(-) diff --git a/practical-projects/06-file-organizer/tests/test_atomic_move.py b/practical-projects/06-file-organizer/tests/test_atomic_move.py index 1d45f34..e79893d 100644 --- a/practical-projects/06-file-organizer/tests/test_atomic_move.py +++ b/practical-projects/06-file-organizer/tests/test_atomic_move.py @@ -15,32 +15,35 @@ def test_execute_plan_never_replaces_destination_created_after_preflight( source.write_text("planned source", encoding="utf-8") plan = plan_organization(tmp_path) destination = tmp_path / "documents" / "notes.txt" - original_link = os.link + original_rename_no_replace = file_organizer._rename_no_replace_at - def racing_link( - source_path: str | os.PathLike[str], - destination_path: str | os.PathLike[str], + def racing_rename_no_replace( + source_name: str, + destination_name: str, *, - src_dir_fd: int | None = None, - dst_dir_fd: int | None = None, - follow_symlinks: bool = True, + source_directory_fd: int, + destination_directory_fd: int, ) -> None: destination.write_text("late destination", encoding="utf-8") - original_link( - source_path, - destination_path, - src_dir_fd=src_dir_fd, - dst_dir_fd=dst_dir_fd, - follow_symlinks=follow_symlinks, + original_rename_no_replace( + source_name, + destination_name, + source_directory_fd=source_directory_fd, + destination_directory_fd=destination_directory_fd, ) - monkeypatch.setattr(file_organizer.os, "link", racing_link) + monkeypatch.setattr( + file_organizer, + "_rename_no_replace_at", + racing_rename_no_replace, + ) with pytest.raises(FileExistsError, match="during execution"): execute_plan(plan) assert source.read_text(encoding="utf-8") == "planned source" assert destination.read_text(encoding="utf-8") == "late destination" + assert any(child.name.startswith(".fo-stage-") for child in tmp_path.iterdir()) def test_execute_plan_rejects_category_symlink_created_after_preflight( @@ -105,6 +108,7 @@ def racing_move( source_name: str, destination_name: str, *, + source_directory_path: Path, source_directory_fd: int, destination_directory_fd: int, category_name: str, @@ -118,6 +122,7 @@ def racing_move( original_move( source_name, destination_name, + source_directory_path=source_directory_path, source_directory_fd=source_directory_fd, destination_directory_fd=destination_directory_fd, category_name=category_name, @@ -135,7 +140,7 @@ def racing_move( assert not destination.exists() -def test_source_replacement_between_verify_and_removal_is_never_unlinked( +def test_source_replacement_during_claim_is_preserved_without_unlink( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: @@ -176,14 +181,13 @@ def racing_rename( execute_plan(plan) assert source.read_text(encoding="utf-8") == "third-party replacement" - assert destination.read_text(encoding="utf-8") == "planned source" - assert not any( - child.name.startswith(".file-organizer-stage-") - for child in tmp_path.iterdir() - ) + assert not destination.exists() + retained = [child for child in tmp_path.iterdir() if child.name.startswith(".fo-stage-")] + assert len(retained) == 1 + assert retained[0].read_text(encoding="utf-8") == "third-party replacement" -def test_category_rename_after_fd_open_aborts_before_source_removal( +def test_category_rename_after_fd_open_never_reports_false_destination( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: @@ -193,48 +197,121 @@ def test_category_rename_after_fd_open_aborts_before_source_removal( source = tmp_path / "notes.txt" source.write_text("planned source", encoding="utf-8") plan = plan_organization(tmp_path) - category = tmp_path / "documents" - renamed_category = tmp_path / "documents-detached" - planned_destination = category / "notes.txt" - - original_link = os.link + detached = tmp_path / "documents-detached" + original_rename_no_replace = file_organizer._rename_no_replace_at raced = False - def racing_link( - source_path: str | os.PathLike[str], - destination_path: str | os.PathLike[str], + def racing_rename_no_replace( + source_name: str, + destination_name: str, *, - src_dir_fd: int | None = None, - dst_dir_fd: int | None = None, - follow_symlinks: bool = True, + source_directory_fd: int, + destination_directory_fd: int, ) -> None: nonlocal raced - original_link( - source_path, - destination_path, - src_dir_fd=src_dir_fd, - dst_dir_fd=dst_dir_fd, - follow_symlinks=follow_symlinks, - ) - if ( - destination_path == source.name - and dst_dir_fd is not None - and src_dir_fd is not None - and not raced - ): + if not raced: raced = True - category.rename(renamed_category) + category.rename(detached) category.mkdir() + original_rename_no_replace( + source_name, + destination_name, + source_directory_fd=source_directory_fd, + destination_directory_fd=destination_directory_fd, + ) - monkeypatch.setattr(file_organizer, "_supports_secure_directory_fds", lambda: True) - monkeypatch.setattr(file_organizer.os, "link", racing_link) + monkeypatch.setattr( + file_organizer, + "_rename_no_replace_at", + racing_rename_no_replace, + ) with pytest.raises(ValueError, match="category directory moved during execution"): execute_plan(plan) - assert source.read_text(encoding="utf-8") == "planned source" - assert not planned_destination.exists() - assert ( - renamed_category / "notes.txt" - ).read_text(encoding="utf-8") == "planned source" + assert not (category / "notes.txt").exists() + assert (detached / "notes.txt").read_text(encoding="utf-8") == "planned source" + + +def test_source_root_rename_after_fd_open_never_reports_false_destination( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + if not file_organizer._supports_secure_directory_fds(): + pytest.skip("secure directory descriptors are unavailable on this platform") + + workspace = tmp_path / "workspace" + workspace.mkdir() + source = workspace / "notes.txt" + source.write_text("planned source", encoding="utf-8") + plan = plan_organization(workspace) + detached = tmp_path / "workspace-detached" + original_rename_no_replace = file_organizer._rename_no_replace_at + raced = False + + def racing_rename_no_replace( + source_name: str, + destination_name: str, + *, + source_directory_fd: int, + destination_directory_fd: int, + ) -> None: + nonlocal raced + if not raced: + raced = True + workspace.rename(detached) + original_rename_no_replace( + source_name, + destination_name, + source_directory_fd=source_directory_fd, + destination_directory_fd=destination_directory_fd, + ) + + monkeypatch.setattr( + file_organizer, + "_rename_no_replace_at", + racing_rename_no_replace, + ) + + with pytest.raises(ValueError, match="source_directory moved during execution"): + execute_plan(plan) + + assert not workspace.exists() + assert (detached / "documents" / "notes.txt").read_text(encoding="utf-8") == "planned source" + + +def test_stage_name_is_fixed_length_for_long_source_names() -> None: + short = file_organizer._make_stage_name("a.txt") + long = file_organizer._make_stage_name(f"{'x' * 220}.txt") + + assert short.startswith(".fo-stage-") + assert long.startswith(".fo-stage-") + assert len(short.encode()) == len(long.encode()) < 64 + + +def test_successful_execution_never_unlinks_a_staging_entry( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + source = tmp_path / "notes.txt" + source.write_text("planned source", encoding="utf-8") + plan = plan_organization(tmp_path) + original_unlink = os.unlink + + def guarded_unlink( + path: str | os.PathLike[str], + *, + dir_fd: int | None = None, + ) -> None: + if os.fspath(path).startswith(".fo-stage-"): + raise AssertionError("staging entries must not be unlinked") + original_unlink(path, dir_fd=dir_fd) + + monkeypatch.setattr(file_organizer.os, "unlink", guarded_unlink) + + result = execute_plan(plan) + + assert result.moved_count == 1 + assert not source.exists() + assert (tmp_path / "documents" / "notes.txt").read_text(encoding="utf-8") == "planned source" From 17e2ea60957e3be2b4f5449be5e4d4fe500c6981 Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Tue, 1 Sep 2026 07:18:41 -0300 Subject: [PATCH 033/117] Synchronize File Organizer English chapter --- .../06-file-organizer/README.md | 302 +++++++++--------- 1 file changed, 157 insertions(+), 145 deletions(-) diff --git a/practical-projects/06-file-organizer/README.md b/practical-projects/06-file-organizer/README.md index 547f453..06b2fc4 100644 --- a/practical-projects/06-file-organizer/README.md +++ b/practical-projects/06-file-organizer/README.md @@ -16,22 +16,23 @@ This project organizes direct child files into category folders while keeping di By the end of this project, you should be able to: -- discover files with `pathlib` without recursively traversing a tree; -- classify filenames deterministically from case-insensitive suffix rules; +- discover direct files with `pathlib` without recursive traversal; +- classify filenames deterministically with case-insensitive suffix rules; - model planned filesystem changes with immutable dataclasses; - separate a non-mutating planning phase from a mutating execution phase; - detect exact and case-insensitive destination collisions; -- choose an explicit collision policy instead of silently overwriting data; -- treat symlinks as a separate filesystem boundary; -- revalidate assumptions immediately before mutation; -- enforce exact destination no-replace behavior at the mutation step; -- verify source identity across time-of-check/time-of-use boundaries; -- preserve uncertain destination state instead of performing destructive rollback; +- choose explicit collision policies instead of silently overwriting data; +- treat symlinks as a filesystem boundary; +- reason about time-of-check/time-of-use races; +- compare filesystem objects by `(device, inode)` identity; +- anchor directories with file descriptors on Linux; +- use atomic no-replace rename semantics at the final commit boundary; +- preserve uncertain state instead of blindly deleting entries during recovery; - test filesystem code safely with temporary directories. ## Problem -Imagine a fictional workspace containing files such as: +Imagine a fictional workspace containing: ```text workspace/ @@ -58,29 +59,30 @@ workspace/ └── script.py ``` -The important challenge is not merely calling a move function. The project must make destructive filesystem decisions visible before changing anything. +The important challenge is not merely moving files. The project makes filesystem decisions visible before mutation and refuses to claim safety guarantees the current platform cannot enforce. ## Requirements The implementation must: 1. accept an existing non-symlink source directory; -2. inspect only direct children of that directory; +2. inspect only direct children; 3. ignore nested directories; 4. report direct-child symlinks separately instead of following them; 5. classify regular files by filename suffix; -6. preserve each filename exactly; -7. create destination folders only when needed; +6. preserve filenames exactly; +7. create destination folders only when required; 8. produce deterministic ordering; 9. build an immutable plan before mutation; 10. reject invalid category paths, including symlinked category directories; -11. detect existing exact and case-insensitive destination collisions; -12. support explicit `ERROR` and `SKIP` collision policies during planning; -13. run a full preflight before any move; -14. never silently replace an exact destination that appears after preflight; -15. reject a planned source whose filesystem identity changes before commit; -16. never delete an unverified destination while handling a source-removal failure; -17. return a structured result after successful execution. +11. detect exact and case-insensitive destination collisions; +12. support explicit `ERROR` and `SKIP` planning policies; +13. run a complete execution preflight; +14. capture planned-source filesystem identity; +15. never silently replace an exact destination; +16. reject stale source, root, or category assumptions during execution; +17. never blindly unlink a staging or rollback entry whose identity may have changed; +18. return a structured result only after the planned destination is verified. ## Deliberate scope @@ -92,24 +94,25 @@ source directory -> suffix classification -> collision-safe plan -> execution preflight - -> required category folders - -> identity-verified no-replace moves + -> anchored category folders + -> source claim + -> atomic no-replace destination commit ``` -This project intentionally does **not** include: +This project intentionally excludes: - recursive organization; - MIME or content inspection; - automatic duplicate renaming; - hashing or deduplication; -- deletion; -- rollback transactions across the entire plan; +- deletion as a user-facing feature; +- whole-plan rollback transactions; - filesystem watchers; - GUI interaction; - cloud storage; -- cross-filesystem organization. +- cross-filesystem moves. -Keeping these responsibilities out of scope makes the safety rules visible instead of burying them inside a general-purpose file manager. +Keeping these responsibilities out of scope makes the safety rules easier to inspect. ## Categories @@ -123,7 +126,7 @@ Keeping these responsibilities out of scope makes the safety rules visible inste | Archives | `archives/` | `.zip`, `.7z`, `.tar.gz`, `.tar.xz` | | Other | `other/` | anything not matched above | -Matching is case-insensitive. Classification uses filenames only and does not open file contents. +Matching is case-insensitive. Classification uses filenames only and never opens file contents. ## Core models @@ -150,132 +153,156 @@ The plan is immutable. Creating it does not create directories and does not move ### `OrganizationResult` -Records the exact planned destinations that were successfully moved. +Records the exact planned destinations returned after successful execution. ## Discovery is intentionally shallow -`discover_files()` returns only direct regular-file children. +`discover_files()` returns direct regular-file children only. -Nested directories are not traversed. This matters because recursive movement introduces additional questions: - -- should the relative path be preserved? -- should category folders inside nested directories be revisited? -- how should duplicate names from different subdirectories be handled? - -Those questions are useful, but they belong to a larger project. +Recursive movement introduces additional contracts for relative paths, nested category folders, and duplicate names across directories. Those belong to a larger project. ## Planning before mutation -`plan_organization()` validates the directory, scans the files, classifies them, and calculates destinations without changing the filesystem. - -That separation provides a useful engineering pattern: +`plan_organization()` validates the workspace, scans direct files, classifies them, and calculates destinations without changing the filesystem. ```text observe -> decide -> validate -> mutate ``` -It is easier to test and review a proposed operation when the proposal exists as data before side effects begin. +The proposal exists as data before side effects begin, which makes review and testing easier. ## Collision policies -Two policies are explicit: - ### `CollisionPolicy.ERROR` -Planning stops with `FileExistsError` when a destination name already exists. - -Use this when every source file must have a conflict-free destination. +Planning raises `FileExistsError` when a destination name already exists. ### `CollisionPolicy.SKIP` -Files whose destination collides are left in the source directory and listed in `skipped_collisions`. +Conflicting source files remain in the source directory and are listed in `skipped_collisions`. -Use this when safely organizing the non-conflicting subset is acceptable. - -The policy is applied during planning. Execution still refuses new exact collisions that appear later. +Execution still refuses collisions that appear after planning. ## Case-insensitive collision checks -A directory may be case-sensitive on one operating system and case-insensitive on another. - -The project therefore compares destination names with `casefold()` during planning and preflight. For example, these are treated as a logical collision: +Filesystems differ in case sensitivity. The organizer therefore compares logical destination names using `casefold()`. ```text Report.TXT report.txt ``` -This keeps the plan portable across common filesystem behaviors. +These names are treated as a logical collision even on a case-sensitive filesystem. -## Symlink boundary +## Symlink and directory-anchor boundaries -The organizer does not follow direct-child symlinks. +The organizer does not follow direct-child symlinks. It also rejects a source directory or category folder that is a symlink. -It also rejects: +On the secure Linux path, the source root and required category directories are opened with `O_DIRECTORY | O_NOFOLLOW`. Their `(device, inode)` identities are repeatedly compared with the paths that should still reach them. -- a source directory that is itself a symlink; -- a category folder implemented as a symlink. - -On platforms with directory-descriptor support, execution pins the source and category directories with `O_DIRECTORY | O_NOFOLLOW` so a category path that becomes a symlink after preflight cannot redirect the mutation outside the workspace. +This matters because a directory file descriptor remains attached to the same directory even if another process renames that directory. Descriptor pinning prevents symlink redirection, while anchor validation prevents execution from silently continuing inside a directory that is no longer reachable at the planned path. ## Why preflight is not enough -A first implementation might do this: +A naive implementation might do: ```python if not destination.exists(): source.rename(destination) ``` -That contains a time-of-check/time-of-use race. Another process can create the destination after the check but before the rename. +That check can become stale immediately. Another process may create the destination or replace a source or directory after validation. + +Preflight reduces the number of unsafe states, but concurrency-sensitive guarantees must also exist at the mutation boundary. + +## Filesystem identity + +The implementation represents identity with: + +```text +(st_dev, st_ino) +``` + +The filename `notes.txt` is a directory entry. It is not the identity of the underlying filesystem object. + +During secure Linux execution, the planned source is also opened with `O_NOFOLLOW`, pinning the expected inode while the commit runs. This prevents an unlinked inode from being reused and mistaken for the originally planned source during the operation. + +## Fixed-length staging names + +The secure Linux path temporarily claims the public source entry under an internal name: + +```text +.fo-stage-<32 hexadecimal characters> +``` + +The stage name has fixed length and never embeds the original filename. A valid long filename therefore cannot make the internal name exceed a typical filesystem `NAME_MAX` limit. -On POSIX, `rename()` is allowed to replace an existing destination. A planned source can also be replaced after preflight. Therefore execution must validate both destination availability and source identity at the mutation boundary. +## Atomic no-replace commit on Linux -## Exact no-replace mutation +The secure Linux path uses `renameat2(..., RENAME_NOREPLACE)` through pinned directory descriptors. -The execution path uses a same-filesystem hard-link operation as its destination guard: +Conceptually: ```text -1. capture source identity during preflight -2. revalidate that the source is still the same regular file -3. create the destination hard link without replacement -4. verify that the destination references the expected source identity -5. revalidate the source identity again -6. remove the original source path +1. preflight and capture source identity +2. open and anchor the source root +3. open and anchor required category directories +4. pin the planned source inode with O_NOFOLLOW +5. atomically claim source name -> short internal stage +6. verify stage identity and directory anchors +7. atomically rename stage -> destination with RENAME_NOREPLACE +8. verify destination identity and anchors +9. report success ``` -Filesystem identity is represented by the `(device, inode)` pair returned by `stat`. This lets execution distinguish “the same filename” from “the same filesystem object.” A late symlink or regular-file replacement therefore aborts execution instead of being reported as a successful move. +`RENAME_NOREPLACE` makes destination existence part of the atomic filesystem operation. There is no separate `exists()` check followed by a replacing rename. + +The normal secure path does **not** finalize a move by calling `unlink()` on the staging name. This avoids transferring the same check-to-unlink race from the public source name to an internal name. + +## Conservative recovery + +Concurrency errors can leave uncertain state. Recovery therefore favors preservation over destructive cleanup. + +If execution has already claimed the source into a staging entry and later detects an unsafe condition, it may create a no-replace hard link back to the original source name when possible. It does not blindly delete the staging entry. + +This can intentionally leave an internal recovery entry in unusual race/failure scenarios. That is preferable to deleting unrelated data whose current identity cannot be proven. -`os.link()` does not replace an existing destination. Because every destination folder is inside the same source directory, source and destination are intentionally on the same filesystem for this project. +The whole multi-file plan is not transactional. -If creating the link fails, the source remains untouched. If removing the source fails after the destination has been created, the implementation deliberately **keeps the destination** and raises an error. It does not attempt an unconditional rollback unlink, because another process could have replaced that directory entry in the meantime. Preserving uncertain state is safer than deleting an object whose identity can no longer be proven. +## Platform contract -This does not turn the whole multi-file plan into a transaction. It provides narrower guarantees: exact destinations are not silently overwritten, planned sources are revalidated by identity, and failure handling does not intentionally delete an unverified destination. +The implementation is explicit about platform guarantees: + +- **Linux:** secure descriptor-anchored execution uses `renameat2(RENAME_NOREPLACE)` when available; +- **Windows:** the fallback relies on Windows `os.rename()` refusing an existing destination and verifies source/destination/category identities around the operation; +- **other POSIX platforms:** execution raises `NotImplementedError` when the project cannot enforce the required no-replace semantics safely. + +A safety-oriented example should fail honestly instead of silently downgrading its contract. ## Execution flow `execute_plan()` performs: -1. type validation; +1. plan type validation; 2. source-directory revalidation; 3. category-path revalidation; -4. capture of planned-source filesystem identities; +4. planned-source identity capture; 5. destination collision preflight; -6. creation/opening of only required category folders; -7. identity-verified exact no-replace moves; -8. construction of `OrganizationResult`. - -A stale plan is therefore not trusted blindly. +6. platform capability selection; +7. anchored directory setup; +8. source claim and atomic no-replace commit; +9. destination/anchor verification; +10. `OrganizationResult` construction. ## Determinism -Files and actions are sorted by a key based on: +Files and actions are sorted by: ```python (path.name.casefold(), path.name) ``` -This makes examples, tests, and review output stable instead of depending on filesystem iteration order. +This keeps examples, tests, and review output stable. ## Running the demo @@ -285,7 +312,7 @@ From the repository root: python practical-projects/06-file-organizer/demo.py ``` -The demo uses `TemporaryDirectory`, creates only fictional files, prints the planned moves, executes them, and shows the final workspace layout. It does not touch personal directories. +The demo uses `TemporaryDirectory`, creates fictional files only, prints the plan, executes it, and shows the resulting folders. ## Running the tests @@ -295,28 +322,25 @@ Focused suite: python -m pytest practical-projects/06-file-organizer/tests -q ``` -The focused suite intentionally avoids embedding a fixed scenario count in this chapter because regression coverage grows as review findings are hardened. +The chapter intentionally avoids a fixed test count because review-driven regression coverage evolves. Coverage includes: - suffix classification; -- path validation; -- deterministic discovery; -- shallow scanning; +- deterministic shallow discovery; - symlink handling; - immutable model invariants; - exact and case-insensitive collisions; - `ERROR` and `SKIP` policies; -- stale/missing sources; -- category-path changes; -- collision preflight; -- a destination created between preflight and mutation; -- a category path becoming a symlink during mutation; -- a planned source becoming a symlink during mutation; -- source-removal failure without destructive destination rollback; -- successful execution; -- preservation of unrelated destination files; -- empty plans. +- stale and missing sources; +- late exact destinations; +- late source symlink/file replacement; +- category symlink and rename races; +- source-root rename races; +- fixed-length staging names; +- staging finalization without `unlink()`; +- destination identity verification; +- successful execution and empty plans. ## Failure paths worth studying @@ -336,61 +360,51 @@ Rejected before scanning. Rejected before planning or execution. -### Destination exists during planning - -Handled according to the selected collision policy. - ### Destination appears after planning -Preflight raises `FileExistsError` before any move. +Preflight or the atomic no-replace commit raises `FileExistsError`. -### Exact destination appears after preflight +### Planned source changes -The no-replace hard-link operation fails with `FileExistsError`; the newly created destination is preserved and the source remains in place. +Execution raises instead of treating the replacement as the planned file. -### Planned source identity changes during execution +### Source root or category directory is renamed/replaced -Execution raises instead of unlinking the changed source entry or reporting the move as successful. +Anchor verification raises instead of returning a path that no longer identifies the committed destination. -### Source removal fails after destination creation +### Atomic no-replace primitive is unavailable -Execution raises and retains the destination. It deliberately avoids deleting a destination whose current identity cannot be proven safely during rollback. +The unsupported platform path raises rather than weakening the safety contract silently. ## Common mistakes ### Moving while scanning -Mixing discovery and mutation makes partial failure difficult to reason about. - -Prefer building a plan first. - -### Using only `Path.exists()` before `rename()` - -The check can become stale immediately, and POSIX rename semantics can replace the destination. +Mixing discovery and mutation makes partial failure difficult to reason about. Build a plan first. ### Treating a filename as object identity -A directory entry can be replaced while keeping the same name. When concurrency matters, compare filesystem identity and file type at the mutation boundary. +Directory entries can be replaced while preserving the same name. Use filesystem identity when the distinction matters. -### Rolling back by blindly deleting the destination +### Checking immediately before `unlink()` -A rollback path is still a mutation path. If another actor can replace the destination entry, unconditional deletion can destroy unrelated data. +A check-to-unlink window still exists. When deletion identity matters, restructure the operation instead of adding another check. -### Silently inventing new filenames +### Assuming an open directory descriptor still has the same pathname -Renaming collisions to values such as `report_2.txt` hides a policy decision. This project keeps collision behavior explicit. +A descriptor follows the directory inode through rename. Verify its anchor against the planned path. -### Following symlinks accidentally +### Embedding the full source filename in a staging name -A friendly-looking path can point outside the intended workspace. +Valid source names may already be near `NAME_MAX`. Keep internal names bounded independently. -### Assuming directory iteration order +### Blind cleanup after a race -Filesystem iteration order is not an application-level ordering contract. Sort explicitly when deterministic behavior matters. +Cleanup is mutation too. Preserve uncertain entries rather than deleting something that may belong to another actor. -### Treating a successful preflight as a transaction +### Treating preflight as a transaction -The filesystem can change after preflight. Revalidation narrows risk but does not make a multi-file operation transactional. +The filesystem can change afterward. A multi-file plan remains a sequence of individually guarded commits. ## Exercise @@ -404,30 +418,28 @@ Requirements: 4. never access or mutate the filesystem; 5. add tests for empty and non-empty plans. -The purpose is to practice keeping presentation separate from domain and mutation logic. - ## Extension challenges -After completing the exercise, consider: +Consider: -- a configurable suffix-to-category mapping; -- a user-defined category enum alternative; -- a JSON plan export/import format with careful stale-plan validation; -- an operation journal; +- configurable suffix mappings; +- user-defined categories; +- JSON plan export/import with stale-plan validation; +- operation journaling; - recursive discovery with explicit relative-path rules; - checksum-based duplicate detection; -- a stronger platform-specific conditional source-removal primitive; -- a rollback strategy for partially executed plans. +- richer recovery/audit tooling for preserved staging entries; +- a transactional design for a different problem domain. -Each extension introduces new invariants. Add the contract before adding the code. +Each extension introduces new invariants. Define the contract before adding code. ## Portfolio discussion -A useful portfolio explanation is not “I wrote a script that moves files.” +A useful explanation is not “I wrote a script that moves files.” -A stronger explanation is: +A stronger version is: -> I designed a filesystem workflow with a non-mutating planning phase, deterministic classification, explicit collision policies, symlink boundaries, execution-time identity validation, exact no-replace destination protection, and conservative failure handling that never blindly deletes an unverified rollback target. +> I designed a filesystem workflow with deterministic planning, explicit collision policies, symlink boundaries, inode-based identity checks, descriptor-anchored directories, bounded staging names, and an atomic Linux no-replace commit using `renameat2(RENAME_NOREPLACE)`. Failure handling preserves uncertain state instead of blindly deleting entries. That communicates engineering decisions, not just API usage. @@ -443,11 +455,11 @@ That communicates engineering decisions, not just API usage. | Hold the immutable plan | `OrganizationPlan` | | Execute the plan | `execute_plan()` | | Hold successful destinations | `OrganizationResult` | -| Verify filesystem identity | `(st_dev, st_ino)` from `stat` | -| Enforce exact no-replace mutation | `os.link()` + verified source `unlink()` | +| Identify filesystem objects | `(st_dev, st_ino)` | +| Secure Linux commit | `renameat2(RENAME_NOREPLACE)` | ## What comes next Project 05 generated files. Project 06 owns the next boundary: discovering and organizing files safely. -Project 07 will move upward again, combining validated domain records and explicit workflow states in a **fictional reconciliation workflow**. +Project 07 moves upward again, combining validated domain records and explicit workflow states in a **fictional reconciliation workflow**. From 2f8f83494a9fdc2545e0996fe1e7f5d7a1d255c5 Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Tue, 1 Sep 2026 07:19:30 -0300 Subject: [PATCH 034/117] Synchronize File Organizer Portuguese chapter --- .../06-file-organizer/README.pt-BR.md | 296 ++++++++++-------- 1 file changed, 160 insertions(+), 136 deletions(-) diff --git a/practical-projects/06-file-organizer/README.pt-BR.md b/practical-projects/06-file-organizer/README.pt-BR.md index 0a86cac..e6183c2 100644 --- a/practical-projects/06-file-organizer/README.pt-BR.md +++ b/practical-projects/06-file-organizer/README.pt-BR.md @@ -16,17 +16,18 @@ Este projeto organiza arquivos filhos diretos em pastas por categoria, mantendo Ao concluir este projeto, você deverá ser capaz de: -- descobrir arquivos com `pathlib` sem percorrer uma árvore recursivamente; -- classificar nomes de arquivos de forma determinística por regras de sufixo sem diferenciar maiúsculas e minúsculas; +- descobrir arquivos diretos com `pathlib` sem travessia recursiva; +- classificar nomes de arquivos de forma determinística com regras de sufixo sem diferenciar maiúsculas e minúsculas; - modelar mudanças planejadas no filesystem com dataclasses imutáveis; - separar uma fase de planejamento sem mutação de uma fase de execução com efeitos colaterais; -- detectar colisões exatas e colisões de destino ignorando diferenças de caixa; -- escolher uma política de colisão explícita em vez de sobrescrever dados silenciosamente; -- tratar symlinks como uma fronteira específica do filesystem; -- revalidar premissas imediatamente antes da mutação; -- garantir no próprio passo de mutação que um destino exato nunca seja substituído; -- verificar a identidade da origem através de fronteiras time-of-check/time-of-use; -- preservar estado incerto de destino em vez de executar rollback destrutivo; +- detectar colisões de destino exatas e sem diferenciação de caixa; +- escolher políticas de colisão explícitas em vez de sobrescrever dados silenciosamente; +- tratar symlinks como uma fronteira do filesystem; +- raciocinar sobre corridas time-of-check/time-of-use; +- comparar objetos do filesystem pela identidade `(device, inode)`; +- ancorar diretórios com file descriptors no Linux; +- usar semântica atômica no-replace na fronteira final de commit; +- preservar estado incerto em vez de apagar entradas cegamente durante recuperação; - testar código de filesystem com segurança usando diretórios temporários. ## Problema @@ -58,29 +59,30 @@ workspace/ └── script.py ``` -O desafio importante não é apenas chamar uma função de movimento. O projeto precisa tornar visíveis as decisões destrutivas antes de alterar qualquer coisa. +O desafio importante não é apenas mover arquivos. O projeto torna as decisões de filesystem visíveis antes da mutação e se recusa a afirmar garantias de segurança que a plataforma atual não consegue aplicar. ## Requisitos A implementação deve: 1. aceitar um diretório de origem existente que não seja symlink; -2. inspecionar apenas filhos diretos desse diretório; +2. inspecionar apenas filhos diretos; 3. ignorar diretórios aninhados; 4. registrar symlinks filhos diretos separadamente, sem segui-los; 5. classificar arquivos regulares pelo sufixo do nome; -6. preservar exatamente cada nome de arquivo; +6. preservar exatamente os nomes dos arquivos; 7. criar pastas de destino somente quando necessárias; 8. produzir ordenação determinística; 9. construir um plano imutável antes da mutação; 10. rejeitar caminhos de categoria inválidos, inclusive diretórios de categoria que sejam symlinks; 11. detectar colisões de destino exatas e sem diferenciação de caixa; 12. oferecer políticas explícitas `ERROR` e `SKIP` durante o planejamento; -13. executar um preflight completo antes de qualquer movimento; -14. nunca substituir silenciosamente um destino exato que apareça depois do preflight; -15. rejeitar uma origem planejada cuja identidade no filesystem mude antes do commit; -16. nunca excluir um destino não verificado ao tratar falha na remoção da origem; -17. retornar um resultado estruturado após a execução bem-sucedida. +13. executar um preflight completo; +14. capturar a identidade das origens planejadas; +15. nunca substituir silenciosamente um destino exato; +16. rejeitar premissas obsoletas sobre origem, raiz ou categoria durante a execução; +17. nunca executar `unlink()` cegamente em staging ou rollback cuja identidade possa ter mudado; +18. retornar resultado estruturado apenas após verificar o destino planejado. ## Escopo deliberado @@ -92,24 +94,25 @@ diretório de origem -> classificação por sufixo -> plano seguro contra colisões -> preflight de execução - -> pastas de categoria necessárias - -> movimentos no-replace com identidade verificada + -> pastas de categoria ancoradas + -> claim da origem + -> commit atômico no-replace no destino ``` -Este projeto intencionalmente **não** inclui: +Este projeto intencionalmente não inclui: - organização recursiva; - inspeção MIME ou de conteúdo; - renomeação automática de duplicados; - hashing ou deduplicação; -- exclusão; +- exclusão como funcionalidade exposta ao usuário; - transações de rollback para o plano inteiro; - watchers de filesystem; - interface gráfica; - armazenamento em nuvem; -- organização entre filesystems diferentes. +- movimentos entre filesystems diferentes. -Manter essas responsabilidades fora do escopo deixa as regras de segurança visíveis em vez de escondê-las dentro de um gerenciador de arquivos genérico. +Manter essas responsabilidades fora do escopo deixa as regras de segurança mais fáceis de inspecionar. ## Categorias @@ -121,9 +124,9 @@ Manter essas responsabilidades fora do escopo deixa as regras de segurança vis | Dados | `data/` | `.csv`, `.json`, `.xml`, `.xlsx` | | Imagens | `images/` | `.png`, `.jpg`, `.webp`, `.svg` | | Arquivos compactados | `archives/` | `.zip`, `.7z`, `.tar.gz`, `.tar.xz` | -| Outros | `other/` | tudo o que não corresponder às regras acima | +| Outros | `other/` | tudo o que não corresponder acima | -A correspondência ignora diferenças entre maiúsculas e minúsculas. A classificação usa apenas o nome do arquivo e não abre seu conteúdo. +A correspondência ignora diferenças entre maiúsculas e minúsculas. A classificação usa apenas nomes de arquivo e nunca abre o conteúdo. ## Modelos centrais @@ -135,7 +138,7 @@ Representa um movimento planejado: arquivo de origem -> destino da categoria ``` -Suas invariantes exigem caminhos absolutos, o mesmo nome na origem e no destino e uma pasta de destino correspondente à categoria escolhida. +Suas invariantes exigem caminhos absolutos, o mesmo nome na origem e destino e uma pasta correspondente à categoria escolhida. ### `OrganizationPlan` @@ -150,116 +153,146 @@ O plano é imutável. Criá-lo não cria diretórios e não move arquivos. ### `OrganizationResult` -Registra exatamente os destinos planejados que foram movidos com sucesso. +Registra exatamente os destinos planejados retornados após execução bem-sucedida. ## Descoberta intencionalmente rasa -`discover_files()` retorna somente arquivos regulares que são filhos diretos. +`discover_files()` retorna somente arquivos regulares filhos diretos. -Diretórios aninhados não são percorridos. Isso importa porque movimento recursivo cria perguntas adicionais: - -- o caminho relativo deve ser preservado? -- pastas de categoria dentro de subdiretórios devem ser revisitadas? -- como lidar com nomes duplicados vindos de subdiretórios diferentes? - -Essas perguntas são úteis, mas pertencem a um projeto maior. +Movimento recursivo introduz contratos adicionais para caminhos relativos, categorias aninhadas e nomes duplicados entre diretórios. Esses temas pertencem a um projeto maior. ## Planejar antes de alterar -`plan_organization()` valida o diretório, varre os arquivos, classifica cada um e calcula os destinos sem modificar o filesystem. - -Essa separação cria um padrão de engenharia útil: +`plan_organization()` valida o workspace, varre arquivos diretos, classifica cada um e calcula destinos sem modificar o filesystem. ```text observar -> decidir -> validar -> alterar ``` -É mais fácil testar e revisar uma operação proposta quando ela existe como dados antes de os efeitos colaterais começarem. +A proposta existe como dados antes de os efeitos colaterais começarem, facilitando revisão e testes. ## Políticas de colisão -Duas políticas são explícitas: - ### `CollisionPolicy.ERROR` -O planejamento para com `FileExistsError` quando um nome de destino já existe. +O planejamento gera `FileExistsError` quando um nome de destino já existe. ### `CollisionPolicy.SKIP` -Arquivos cujo destino colide permanecem no diretório de origem e são listados em `skipped_collisions`. +Arquivos conflitantes permanecem na origem e são listados em `skipped_collisions`. -A política é aplicada no planejamento. A execução continua recusando colisões exatas novas que apareçam depois. +A execução ainda recusa colisões que apareçam depois do planejamento. ## Colisões sem diferenciação de caixa -O projeto compara nomes de destino com `casefold()` durante planejamento e preflight. Por exemplo: +Filesystems variam quanto à sensibilidade de caixa. O organizador compara nomes lógicos de destino usando `casefold()`. ```text Report.TXT report.txt ``` -Esses nomes são tratados como uma colisão lógica. +Esses nomes são tratados como colisão lógica mesmo em um filesystem case-sensitive. -## Fronteira de symlink +## Fronteiras de symlink e ancoragem de diretórios -O organizador não segue symlinks filhos diretos. +O organizador não segue symlinks filhos diretos. Também rejeita diretório de origem ou pasta de categoria que seja symlink. -Ele também rejeita: +No caminho seguro do Linux, a raiz e as categorias necessárias são abertas com `O_DIRECTORY | O_NOFOLLOW`. Suas identidades `(device, inode)` são comparadas repetidamente com os caminhos que ainda deveriam alcançá-las. -- um diretório de origem que seja symlink; -- uma pasta de categoria implementada como symlink. - -Em plataformas com suporte a descritores de diretório, a execução fixa a origem e as pastas de categoria usando `O_DIRECTORY | O_NOFOLLOW`. Assim, uma categoria que vire symlink depois do preflight não consegue redirecionar a mutação para fora do workspace. +Isso importa porque um file descriptor continua preso ao mesmo diretório mesmo quando outro processo renomeia esse diretório. O pinning impede redirecionamento por symlink; a validação de âncora impede continuar silenciosamente em um diretório que não está mais acessível pelo caminho planejado. ## Por que o preflight não basta -Uma implementação inicial poderia fazer: +Uma implementação ingênua poderia fazer: ```python if not destination.exists(): source.rename(destination) ``` -Isso contém uma corrida de time-of-check/time-of-use. Outro processo pode criar o destino depois da checagem e antes do rename. +Essa checagem pode ficar obsoleta imediatamente. Outro processo pode criar o destino ou substituir uma origem ou diretório depois da validação. + +O preflight reduz estados inseguros, mas garantias sensíveis a concorrência também precisam existir na fronteira de mutação. + +## Identidade no filesystem + +A implementação representa identidade com: + +```text +(st_dev, st_ino) +``` + +O nome `notes.txt` é uma entrada de diretório, não a identidade do objeto do filesystem. + +Durante a execução segura no Linux, a origem planejada também é aberta com `O_NOFOLLOW`, fixando o inode esperado enquanto o commit ocorre. Isso evita que um inode liberado seja reutilizado e confundido com a origem planejada. + +## Nomes de staging com tamanho fixo + +O caminho seguro do Linux reivindica temporariamente a entrada pública da origem com um nome interno: + +```text +.fo-stage-<32 caracteres hexadecimais> +``` + +O staging tem tamanho fixo e nunca incorpora o nome original. Assim, um filename válido e longo não faz o nome interno ultrapassar um limite típico `NAME_MAX`. -Em POSIX, `rename()` pode substituir um destino existente. Além disso, uma origem planejada pode ser substituída depois do preflight. Por isso, a execução precisa validar tanto a disponibilidade do destino quanto a identidade da origem no momento da mutação. +## Commit atômico no-replace no Linux -## Mutação exata no-replace +O caminho seguro do Linux usa `renameat2(..., RENAME_NOREPLACE)` por meio de file descriptors ancorados. -A execução usa hard link no mesmo filesystem como proteção de destino: +Conceitualmente: ```text -1. capturar a identidade da origem no preflight -2. revalidar que a origem continua sendo o mesmo arquivo regular -3. criar o hard link de destino sem substituição -4. verificar que o destino referencia a identidade esperada da origem -5. revalidar novamente a identidade da origem -6. remover o caminho de origem original +1. executar preflight e capturar identidade da origem +2. abrir e ancorar a raiz +3. abrir e ancorar as categorias necessárias +4. fixar o inode da origem com O_NOFOLLOW +5. reivindicar atomicamente origem -> staging curto +6. verificar identidade do staging e âncoras +7. renomear atomicamente staging -> destino com RENAME_NOREPLACE +8. verificar identidade do destino e âncoras +9. reportar sucesso ``` -A identidade do filesystem é representada pelo par `(device, inode)` retornado por `stat`. Isso permite diferenciar “o mesmo nome” de “o mesmo objeto do filesystem”. Uma substituição tardia por symlink ou por outro arquivo regular aborta a execução em vez de ser relatada como movimento bem-sucedido. +`RENAME_NOREPLACE` transforma a existência do destino em parte da própria operação atômica. Não existe uma checagem `exists()` separada seguida de rename substitutivo. + +O caminho seguro normal não finaliza o movimento com `unlink()` do staging. Isso evita apenas transferir a mesma janela check-to-unlink do nome público para um nome interno. + +## Recuperação conservadora + +Erros concorrentes podem deixar estado incerto. A recuperação prioriza preservação em vez de limpeza destrutiva. + +Se a execução já moveu a origem para staging e depois detecta condição insegura, ela pode criar um hard link no-replace de volta para o nome de origem quando possível. Ela não apaga cegamente o staging. + +Em cenários raros de corrida/falha, isso pode deixar uma entrada interna de recuperação. É preferível a excluir dados cuja identidade atual não pode ser comprovada. -`os.link()` não substitui um destino existente. Como toda pasta de destino fica dentro do mesmo diretório de origem, origem e destino permanecem intencionalmente no mesmo filesystem neste projeto. +O plano inteiro de múltiplos arquivos não é transacional. -Se a criação do link falhar, a origem permanece intacta. Se a remoção da origem falhar depois da criação do destino, a implementação deliberadamente **mantém o destino** e gera erro. Ela não executa um `unlink()` de rollback incondicional, porque outro processo poderia ter substituído aquela entrada de diretório nesse intervalo. Preservar estado incerto é mais seguro do que excluir algo cuja identidade não pode mais ser comprovada. +## Contrato de plataforma -Isso não transforma o plano inteiro em uma transação. As garantias são mais estreitas: destinos exatos não são sobrescritos silenciosamente, origens planejadas são revalidadas por identidade e o tratamento de falha não exclui intencionalmente um destino não verificado. +A implementação explicita as garantias por plataforma: + +- **Linux:** execução segura com FDs ancorados usa `renameat2(RENAME_NOREPLACE)` quando disponível; +- **Windows:** o fallback usa o comportamento de `os.rename()` que recusa destino existente e verifica identidades ao redor da operação; +- **outros POSIX:** a execução gera `NotImplementedError` quando não consegue aplicar a semântica no-replace exigida com segurança. + +Um exemplo orientado a segurança deve falhar de forma honesta em vez de reduzir silenciosamente seu contrato. ## Fluxo de execução `execute_plan()` realiza: -1. validação de tipo; +1. validação do tipo do plano; 2. revalidação do diretório de origem; 3. revalidação dos caminhos de categoria; 4. captura das identidades das origens planejadas; -5. preflight de colisões de destino; -6. criação/abertura apenas das pastas necessárias; -7. movimentos exatos no-replace com identidade verificada; -8. construção de `OrganizationResult`. - -Um plano antigo, portanto, não é aceito cegamente. +5. preflight de colisões; +6. seleção da capacidade da plataforma; +7. preparação dos diretórios ancorados; +8. claim da origem e commit atômico no-replace; +9. verificação do destino e das âncoras; +10. construção de `OrganizationResult`. ## Determinismo @@ -269,7 +302,7 @@ Arquivos e ações são ordenados por: (path.name.casefold(), path.name) ``` -Isso mantém exemplos, testes e revisão estáveis em vez de depender da ordem de iteração do filesystem. +Isso mantém exemplos, testes e revisão estáveis. ## Executando o demo @@ -279,7 +312,7 @@ A partir da raiz do repositório: python practical-projects/06-file-organizer/demo.py ``` -O demo usa `TemporaryDirectory`, cria apenas arquivos fictícios, mostra os movimentos planejados, executa o plano e exibe o layout final. Ele não toca em diretórios pessoais. +O demo usa `TemporaryDirectory`, cria apenas arquivos fictícios, imprime o plano, executa e mostra as pastas resultantes. ## Executando os testes @@ -289,28 +322,25 @@ Suíte focada: python -m pytest practical-projects/06-file-organizer/tests -q ``` -Este capítulo evita embutir uma contagem fixa de cenários porque a cobertura de regressão cresce conforme findings de revisão são endurecidos. +O capítulo evita contagem fixa de testes porque a cobertura de regressão evolui conforme os reviews. A cobertura inclui: - classificação por sufixo; -- validação de caminhos; -- descoberta determinística; -- varredura rasa; +- descoberta rasa e determinística; - tratamento de symlinks; - invariantes dos modelos imutáveis; - colisões exatas e sem diferenciação de caixa; - políticas `ERROR` e `SKIP`; - origens ausentes ou obsoletas; -- mudanças em caminhos de categoria; -- preflight de colisões; -- destino criado entre preflight e mutação; -- categoria virando symlink durante a mutação; -- origem planejada virando symlink durante a mutação; -- falha na remoção da origem sem rollback destrutivo do destino; -- execução bem-sucedida; -- preservação de arquivos de destino não relacionados; -- planos vazios. +- destinos exatos tardios; +- substituição tardia da origem por symlink/arquivo; +- corridas de symlink e rename da categoria; +- corridas de rename da raiz; +- staging com tamanho fixo; +- finalização do staging sem `unlink()`; +- verificação da identidade do destino; +- execução bem-sucedida e planos vazios. ## Caminhos de falha importantes @@ -318,7 +348,7 @@ A cobertura inclui: Gera `FileNotFoundError`. -### Caminho de origem é um arquivo regular +### Caminho de origem é arquivo regular Gera `NotADirectoryError`. @@ -330,59 +360,51 @@ Gera `NotADirectoryError`. É rejeitado antes do planejamento ou execução. -### Destino existe durante o planejamento - -É tratado conforme a política de colisão selecionada. - ### Destino aparece depois do planejamento -O preflight gera `FileExistsError` antes de qualquer movimento. +O preflight ou o commit atômico no-replace gera `FileExistsError`. -### Destino exato aparece depois do preflight +### Origem planejada muda -A operação de hard link no-replace falha com `FileExistsError`; o destino recém-criado é preservado e a origem permanece no lugar. +A execução gera erro em vez de tratar a substituição como o arquivo planejado. -### Identidade da origem planejada muda durante a execução +### Raiz ou categoria é renomeada/substituída -A execução gera erro em vez de remover a entrada alterada ou relatar o movimento como sucesso. +A validação de âncora gera erro em vez de retornar um caminho que não identifica mais o destino comprometido. -### Remoção da origem falha depois da criação do destino +### Primitiva atômica no-replace indisponível -A execução gera erro e mantém o destino. Ela evita deliberadamente excluir um destino cuja identidade atual não pode ser comprovada com segurança durante rollback. +A plataforma não suportada gera erro em vez de enfraquecer silenciosamente o contrato. ## Erros comuns ### Mover enquanto varre -Misturar descoberta e mutação torna falhas parciais difíceis de entender. Prefira construir um plano primeiro. - -### Usar apenas `Path.exists()` antes de `rename()` +Misturar descoberta e mutação torna falhas parciais difíceis de raciocinar. Construa o plano primeiro. -A checagem pode ficar obsoleta imediatamente, e a semântica POSIX de rename pode substituir o destino. +### Tratar nome como identidade -### Tratar nome de arquivo como identidade do objeto +Entradas de diretório podem ser substituídas mantendo o mesmo nome. Use identidade do filesystem quando essa diferença importa. -Uma entrada de diretório pode ser substituída mantendo o mesmo nome. Quando concorrência importa, compare identidade do filesystem e tipo do arquivo na fronteira de mutação. +### Verificar imediatamente antes de `unlink()` -### Fazer rollback apagando cegamente o destino +Ainda existe uma janela check-to-unlink. Quando a identidade da exclusão importa, reestruture a operação em vez de adicionar outra checagem. -O caminho de rollback também é um caminho de mutação. Se outro ator puder substituir a entrada de destino, uma exclusão incondicional pode destruir dados não relacionados. +### Assumir que um FD aberto ainda tem o mesmo pathname -### Inventar novos nomes silenciosamente +O descriptor acompanha o inode do diretório após rename. Verifique sua âncora contra o caminho planejado. -Renomear colisões para valores como `report_2.txt` esconde uma decisão de política. +### Embutir o nome completo da origem no staging -### Seguir symlinks sem perceber +Nomes válidos podem já estar próximos do `NAME_MAX`. Mantenha nomes internos limitados independentemente. -Um caminho aparentemente simples pode apontar para fora do workspace pretendido. +### Limpar cegamente depois de uma corrida -### Assumir ordem de iteração do diretório +Cleanup também é mutação. Preserve entradas incertas em vez de apagar algo que pode pertencer a outro ator. -A ordem de iteração do filesystem não é um contrato de ordenação da aplicação. +### Tratar preflight como transação -### Tratar um preflight bem-sucedido como transação - -O filesystem pode mudar depois do preflight. Revalidação reduz o risco, mas não torna uma operação de múltiplos arquivos transacional. +O filesystem pode mudar depois. Um plano de vários arquivos continua sendo uma sequência de commits individualmente protegidos. ## Exercício @@ -391,39 +413,41 @@ Estenda o organizador com um **renderizador de dry run** sem alterar o comportam Requisitos: 1. aceitar um `OrganizationPlan`; -2. retornar texto determinístico e legível; +2. retornar texto legível e determinístico; 3. mostrar movimentos planejados, colisões ignoradas e symlinks ignorados; -4. nunca acessar nem modificar o filesystem; +4. nunca acessar ou modificar o filesystem; 5. adicionar testes para planos vazios e não vazios. ## Desafios de extensão -Depois do exercício, considere: +Considere: -- mapeamento configurável de sufixos para categorias; -- alternativa com categorias definidas pelo usuário; -- exportação/importação JSON do plano com validação cuidadosa de plano obsoleto; +- mapeamento configurável de sufixos; +- categorias definidas pelo usuário; +- exportação/importação JSON com validação de plano obsoleto; - journal de operações; - descoberta recursiva com regras explícitas de caminho relativo; -- detecção de duplicidade por checksum; -- uma primitiva condicional de remoção de origem ainda mais forte e específica de plataforma; -- estratégia de rollback para planos parcialmente executados. +- deduplicação por checksum; +- tooling de recuperação/auditoria para entradas de staging preservadas; +- design transacional para outro domínio de problema. -Cada extensão adiciona novas invariantes. Defina o contrato antes de adicionar o código. +Cada extensão introduz novas invariantes. Defina o contrato antes de adicionar código. ## Discussão de portfólio -Uma explicação mais forte seria: +Uma explicação útil não é “eu escrevi um script que move arquivos”. + +Uma versão mais forte é: -> Eu projetei um fluxo de filesystem com fase de planejamento sem mutação, classificação determinística, políticas explícitas de colisão, fronteiras de symlink, validação de identidade na execução, proteção exata no-replace do destino e tratamento conservador de falhas que nunca apaga cegamente um alvo de rollback não verificado. +> Eu projetei um fluxo de filesystem com planejamento determinístico, políticas explícitas de colisão, fronteiras de symlink, identidade por inode, diretórios ancorados por descriptors, nomes de staging limitados e commit atômico no-replace no Linux com `renameat2(RENAME_NOREPLACE)`. O tratamento de falhas preserva estado incerto em vez de apagar entradas cegamente. -Isso comunica decisões de engenharia, não apenas uso de API. +Isso comunica decisões de engenharia, não apenas uso de APIs. ## Referência rápida | Tarefa | Função/tipo | |---|---| -| Classificar um nome de arquivo | `classify_path()` | +| Classificar filename | `classify_path()` | | Descobrir arquivos regulares diretos | `discover_files()` | | Construir uma proposta segura | `plan_organization()` | | Escolher comportamento de colisão | `CollisionPolicy` | @@ -431,11 +455,11 @@ Isso comunica decisões de engenharia, não apenas uso de API. | Manter o plano imutável | `OrganizationPlan` | | Executar o plano | `execute_plan()` | | Manter destinos bem-sucedidos | `OrganizationResult` | -| Verificar identidade do filesystem | `(st_dev, st_ino)` de `stat` | -| Garantir mutação exata no-replace | `os.link()` + `unlink()` da origem verificada | +| Identificar objetos do filesystem | `(st_dev, st_ino)` | +| Commit seguro no Linux | `renameat2(RENAME_NOREPLACE)` | ## O que vem depois O Projeto 05 gerou arquivos. O Projeto 06 assume a próxima fronteira: descobrir e organizar arquivos com segurança. -O Projeto 07 volta a subir de nível, combinando registros de domínio validados e estados explícitos de workflow em um **fluxo fictício de conciliação**. +O Projeto 07 sobe novamente de nível, combinando registros de domínio validados e estados explícitos de workflow em um **fluxo fictício de conciliação**. From edba7a0a51d813bc32aba1f280a81362a98049c5 Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Tue, 1 Sep 2026 07:20:15 -0300 Subject: [PATCH 035/117] Synchronize File Organizer Spanish chapter --- .../06-file-organizer/README.es.md | 288 ++++++++++-------- 1 file changed, 160 insertions(+), 128 deletions(-) diff --git a/practical-projects/06-file-organizer/README.es.md b/practical-projects/06-file-organizer/README.es.md index 57eea3b..4ec2f09 100644 --- a/practical-projects/06-file-organizer/README.es.md +++ b/practical-projects/06-file-organizer/README.es.md @@ -16,17 +16,18 @@ Este proyecto organiza archivos hijos directos en carpetas por categoría, mante Al finalizar este proyecto, deberías poder: -- descubrir archivos con `pathlib` sin recorrer recursivamente un árbol; +- descubrir archivos directos con `pathlib` sin recorrido recursivo; - clasificar nombres de archivo de forma determinista con reglas de sufijo sin distinguir mayúsculas y minúsculas; - modelar cambios planificados del filesystem con dataclasses inmutables; - separar una fase de planificación sin mutación de una fase de ejecución con efectos secundarios; -- detectar colisiones exactas y colisiones de destino ignorando diferencias de mayúsculas/minúsculas; -- elegir una política de colisión explícita en lugar de sobrescribir datos silenciosamente; -- tratar los symlinks como una frontera específica del filesystem; -- revalidar supuestos inmediatamente antes de mutar; -- garantizar en el propio paso de mutación que un destino exacto nunca sea reemplazado; -- verificar la identidad del origen a través de fronteras time-of-check/time-of-use; -- conservar un estado de destino incierto en lugar de hacer rollback destructivo; +- detectar colisiones de destino exactas y sin distinción de mayúsculas/minúsculas; +- elegir políticas de colisión explícitas en lugar de sobrescribir datos silenciosamente; +- tratar los symlinks como una frontera del filesystem; +- razonar sobre carreras time-of-check/time-of-use; +- comparar objetos del filesystem mediante identidad `(device, inode)`; +- anclar directorios con file descriptors en Linux; +- usar semántica atómica no-replace en la frontera final del commit; +- conservar estado incierto en lugar de borrar entradas a ciegas durante recuperación; - probar código de filesystem de forma segura con directorios temporales. ## Problema @@ -58,29 +59,30 @@ workspace/ └── script.py ``` -El desafío importante no es simplemente llamar a una función de movimiento. El proyecto debe hacer visibles las decisiones destructivas antes de cambiar nada. +El desafío importante no es solo mover archivos. El proyecto hace visibles las decisiones del filesystem antes de mutar y se niega a afirmar garantías de seguridad que la plataforma actual no puede hacer cumplir. ## Requisitos La implementación debe: 1. aceptar un directorio de origen existente que no sea symlink; -2. inspeccionar solo hijos directos de ese directorio; +2. inspeccionar únicamente hijos directos; 3. ignorar directorios anidados; 4. registrar symlinks hijos directos por separado sin seguirlos; 5. clasificar archivos regulares por el sufijo del nombre; -6. conservar exactamente cada nombre de archivo; +6. conservar exactamente los nombres de archivo; 7. crear carpetas de destino solo cuando sean necesarias; 8. producir un orden determinista; 9. construir un plan inmutable antes de mutar; 10. rechazar rutas de categoría inválidas, incluidos directorios de categoría que sean symlinks; 11. detectar colisiones de destino exactas y sin distinción de mayúsculas/minúsculas; 12. ofrecer políticas explícitas `ERROR` y `SKIP` durante la planificación; -13. ejecutar un preflight completo antes de cualquier movimiento; -14. nunca reemplazar silenciosamente un destino exacto que aparezca después del preflight; -15. rechazar un origen planificado cuya identidad de filesystem cambie antes del commit; -16. nunca eliminar un destino no verificado al manejar un fallo al eliminar el origen; -17. devolver un resultado estructurado después de una ejecución exitosa. +13. ejecutar un preflight completo; +14. capturar la identidad de los orígenes planificados; +15. nunca reemplazar silenciosamente un destino exacto; +16. rechazar supuestos obsoletos sobre origen, raíz o categoría durante la ejecución; +17. nunca ejecutar `unlink()` a ciegas sobre staging o rollback cuya identidad pueda haber cambiado; +18. devolver un resultado estructurado solo después de verificar el destino planificado. ## Alcance deliberado @@ -92,24 +94,25 @@ directorio de origen -> clasificación por sufijo -> plan seguro contra colisiones -> preflight de ejecución - -> carpetas de categoría necesarias - -> movimientos no-replace con identidad verificada + -> carpetas de categoría ancladas + -> claim del origen + -> commit atómico no-replace en el destino ``` -Este proyecto intencionalmente **no** incluye: +Este proyecto excluye intencionalmente: - organización recursiva; - inspección MIME o de contenido; - renombrado automático de duplicados; - hashing o deduplicación; -- eliminación; +- eliminación como función expuesta al usuario; - transacciones de rollback para el plan completo; - watchers de filesystem; - interfaz gráfica; - almacenamiento en la nube; -- organización entre filesystems distintos. +- movimientos entre filesystems distintos. -Mantener estas responsabilidades fuera de alcance hace visibles las reglas de seguridad en lugar de esconderlas dentro de un gestor de archivos genérico. +Mantener estas responsabilidades fuera de alcance hace que las reglas de seguridad sean más fáciles de inspeccionar. ## Categorías @@ -121,9 +124,9 @@ Mantener estas responsabilidades fuera de alcance hace visibles las reglas de se | Datos | `data/` | `.csv`, `.json`, `.xml`, `.xlsx` | | Imágenes | `images/` | `.png`, `.jpg`, `.webp`, `.svg` | | Archivos comprimidos | `archives/` | `.zip`, `.7z`, `.tar.gz`, `.tar.xz` | -| Otros | `other/` | todo lo que no coincida con las reglas anteriores | +| Otros | `other/` | todo lo que no coincida arriba | -La coincidencia ignora diferencias de mayúsculas y minúsculas. La clasificación usa solo el nombre del archivo y no abre su contenido. +La coincidencia ignora diferencias de mayúsculas y minúsculas. La clasificación usa solo nombres de archivo y nunca abre su contenido. ## Modelos centrales @@ -135,7 +138,7 @@ Representa un movimiento planificado: archivo de origen -> destino de categoría ``` -Sus invariantes exigen rutas absolutas, el mismo nombre en origen y destino y una carpeta de destino que corresponda a la categoría seleccionada. +Sus invariantes exigen rutas absolutas, el mismo nombre en origen y destino y una carpeta que corresponda a la categoría seleccionada. ### `OrganizationPlan` @@ -150,118 +153,156 @@ El plan es inmutable. Crearlo no crea directorios ni mueve archivos. ### `OrganizationResult` -Registra exactamente los destinos planificados que se movieron con éxito. +Registra exactamente los destinos planificados devueltos tras una ejecución exitosa. ## Descubrimiento intencionalmente superficial -`discover_files()` devuelve solo archivos regulares que son hijos directos. +`discover_files()` devuelve solo archivos regulares hijos directos. -Los directorios anidados no se recorren. El movimiento recursivo introduce preguntas adicionales sobre rutas relativas, carpetas de categoría anidadas y nombres duplicados provenientes de subdirectorios distintos. Esas preguntas pertenecen a un proyecto mayor. +El movimiento recursivo introduce contratos adicionales para rutas relativas, categorías anidadas y nombres duplicados entre directorios. Esos temas pertenecen a un proyecto mayor. ## Planificar antes de mutar -`plan_organization()` valida el directorio, escanea los archivos, clasifica cada uno y calcula destinos sin modificar el filesystem. - -Esto produce un patrón de ingeniería útil: +`plan_organization()` valida el workspace, escanea archivos directos, los clasifica y calcula destinos sin modificar el filesystem. ```text observar -> decidir -> validar -> mutar ``` -Es más fácil probar y revisar una operación propuesta cuando existe como datos antes de que comiencen los efectos secundarios. +La propuesta existe como datos antes de que comiencen los efectos secundarios, lo que facilita revisión y pruebas. ## Políticas de colisión ### `CollisionPolicy.ERROR` -La planificación se detiene con `FileExistsError` cuando ya existe un nombre de destino. +La planificación genera `FileExistsError` cuando ya existe un nombre de destino. ### `CollisionPolicy.SKIP` -Los archivos cuyo destino colisiona permanecen en el directorio de origen y se listan en `skipped_collisions`. +Los archivos conflictivos permanecen en el origen y aparecen en `skipped_collisions`. -La política se aplica durante la planificación. La ejecución sigue rechazando nuevas colisiones exactas que aparezcan después. +La ejecución sigue rechazando colisiones que aparezcan después de planificar. ## Colisiones sin distinción de mayúsculas/minúsculas -El proyecto compara nombres de destino con `casefold()` durante planificación y preflight. Por ejemplo: +Los filesystems difieren en sensibilidad de caja. El organizador compara nombres lógicos de destino usando `casefold()`. ```text Report.TXT report.txt ``` -Estos nombres se consideran una colisión lógica. - -## Frontera de symlink +Estos nombres se consideran una colisión lógica incluso en un filesystem case-sensitive. -El organizador no sigue symlinks hijos directos. +## Fronteras de symlink y anclaje de directorios -También rechaza: +El organizador no sigue symlinks hijos directos. También rechaza un directorio de origen o carpeta de categoría que sea symlink. -- un directorio de origen que sea symlink; -- una carpeta de categoría implementada como symlink. +En la ruta segura de Linux, la raíz y las categorías necesarias se abren con `O_DIRECTORY | O_NOFOLLOW`. Sus identidades `(device, inode)` se comparan repetidamente con las rutas que todavía deberían alcanzarlas. -En plataformas con soporte de descriptores de directorio, la ejecución fija origen y carpetas de categoría usando `O_DIRECTORY | O_NOFOLLOW`. Así, una categoría que se convierta en symlink después del preflight no puede redirigir la mutación fuera del workspace. +Esto importa porque un file descriptor permanece unido al mismo directorio aunque otro proceso renombre ese directorio. El pinning evita redirección mediante symlink; la validación del anclaje evita continuar silenciosamente dentro de un directorio que ya no es alcanzable por la ruta planificada. ## Por qué el preflight no basta -Una implementación inicial podría hacer: +Una implementación ingenua podría hacer: ```python if not destination.exists(): source.rename(destination) ``` -Eso contiene una carrera time-of-check/time-of-use. Otro proceso puede crear el destino después de la comprobación y antes del rename. +La comprobación puede quedar obsoleta inmediatamente. Otro proceso puede crear el destino o sustituir un origen o directorio después de la validación. -En POSIX, `rename()` puede reemplazar un destino existente. Además, un origen planificado puede ser sustituido después del preflight. Por eso la ejecución debe validar tanto la disponibilidad del destino como la identidad del origen en la frontera de mutación. +El preflight reduce estados inseguros, pero las garantías sensibles a concurrencia también deben existir en la frontera de mutación. -## Mutación exacta no-replace +## Identidad del filesystem -La ejecución usa un hard link en el mismo filesystem como protección del destino: +La implementación representa identidad con: ```text -1. capturar la identidad del origen durante el preflight -2. revalidar que el origen siga siendo el mismo archivo regular -3. crear el hard link de destino sin reemplazo -4. verificar que el destino referencia la identidad esperada del origen -5. revalidar otra vez la identidad del origen -6. eliminar la ruta de origen original +(st_dev, st_ino) ``` -La identidad del filesystem se representa mediante el par `(device, inode)` devuelto por `stat`. Esto permite distinguir “el mismo nombre” de “el mismo objeto del filesystem”. Una sustitución tardía por symlink o por otro archivo regular aborta la ejecución en vez de aparecer como movimiento exitoso. +El nombre `notes.txt` es una entrada de directorio, no la identidad del objeto del filesystem. + +Durante la ejecución segura en Linux, el origen planificado también se abre con `O_NOFOLLOW`, fijando el inode esperado mientras se ejecuta el commit. Esto evita que un inode liberado sea reutilizado y confundido con el origen planificado. + +## Nombres de staging de longitud fija + +La ruta segura de Linux reclama temporalmente la entrada pública del origen bajo un nombre interno: + +```text +.fo-stage-<32 caracteres hexadecimales> +``` + +El staging tiene longitud fija y nunca incorpora el nombre original. Así, un filename válido y largo no hace que el nombre interno supere un límite típico `NAME_MAX`. + +## Commit atómico no-replace en Linux + +La ruta segura de Linux usa `renameat2(..., RENAME_NOREPLACE)` mediante file descriptors anclados. + +Conceptualmente: + +```text +1. ejecutar preflight y capturar identidad del origen +2. abrir y anclar la raíz +3. abrir y anclar las categorías necesarias +4. fijar el inode del origen con O_NOFOLLOW +5. reclamar atómicamente origen -> staging corto +6. verificar identidad del staging y anclajes +7. renombrar atómicamente staging -> destino con RENAME_NOREPLACE +8. verificar identidad del destino y anclajes +9. informar éxito +``` -`os.link()` no reemplaza un destino existente. Como cada carpeta de destino está dentro del mismo directorio de origen, origen y destino permanecen intencionalmente en el mismo filesystem para este proyecto. +`RENAME_NOREPLACE` convierte la existencia del destino en parte de la propia operación atómica. No existe una comprobación `exists()` separada seguida de un rename que pueda reemplazar. -Si la creación del link falla, el origen permanece intacto. Si la eliminación del origen falla después de crear el destino, la implementación conserva deliberadamente **el destino** y genera un error. No ejecuta un `unlink()` de rollback incondicional porque otro proceso podría haber reemplazado esa entrada de directorio durante el intervalo. Conservar estado incierto es más seguro que eliminar algo cuya identidad ya no puede demostrarse. +La ruta segura normal no finaliza el movimiento con `unlink()` del staging. Así no se traslada la misma ventana check-to-unlink del nombre público a un nombre interno. -Esto no convierte el plan completo en una transacción. Las garantías son más estrechas: los destinos exactos no se sobrescriben silenciosamente, los orígenes planificados se revalidan por identidad y el manejo de fallos no elimina intencionalmente un destino no verificado. +## Recuperación conservadora + +Los errores concurrentes pueden dejar estado incierto. La recuperación prioriza conservación frente a limpieza destructiva. + +Si la ejecución ya movió el origen al staging y después detecta una condición insegura, puede crear un hard link no-replace de vuelta al nombre de origen cuando sea posible. No elimina a ciegas el staging. + +En escenarios raros de carrera/fallo, esto puede dejar una entrada interna de recuperación. Es preferible a borrar datos cuya identidad actual no puede demostrarse. + +El plan completo de varios archivos no es transaccional. + +## Contrato de plataforma + +La implementación hace explícitas las garantías por plataforma: + +- **Linux:** ejecución segura con FDs anclados usa `renameat2(RENAME_NOREPLACE)` cuando está disponible; +- **Windows:** el fallback usa el comportamiento de `os.rename()` que rechaza un destino existente y verifica identidades alrededor de la operación; +- **otros POSIX:** la ejecución genera `NotImplementedError` cuando no puede aplicar de forma segura la semántica no-replace requerida. + +Un ejemplo orientado a seguridad debe fallar honestamente en vez de degradar su contrato de forma silenciosa. ## Flujo de ejecución `execute_plan()` realiza: -1. validación de tipo; +1. validación del tipo del plan; 2. revalidación del directorio de origen; 3. revalidación de rutas de categoría; 4. captura de identidades de los orígenes planificados; -5. preflight de colisiones de destino; -6. creación/apertura solo de las carpetas necesarias; -7. movimientos exactos no-replace con identidad verificada; -8. construcción de `OrganizationResult`. - -Un plan obsoleto no se acepta a ciegas. +5. preflight de colisiones; +6. selección de capacidades de plataforma; +7. preparación de directorios anclados; +8. claim del origen y commit atómico no-replace; +9. verificación de destino y anclajes; +10. construcción de `OrganizationResult`. ## Determinismo -Archivos y acciones se ordenan con: +Archivos y acciones se ordenan por: ```python (path.name.casefold(), path.name) ``` -Esto mantiene ejemplos, pruebas y revisión estables en lugar de depender del orden de iteración del filesystem. +Esto mantiene ejemplos, pruebas y revisión estables. ## Ejecutar el demo @@ -271,7 +312,7 @@ Desde la raíz del repositorio: python practical-projects/06-file-organizer/demo.py ``` -El demo usa `TemporaryDirectory`, crea solo archivos ficticios, muestra los movimientos planificados, ejecuta el plan y enseña el layout final. No toca directorios personales. +El demo usa `TemporaryDirectory`, crea solo archivos ficticios, imprime el plan, lo ejecuta y muestra las carpetas resultantes. ## Ejecutar las pruebas @@ -281,28 +322,25 @@ Suite enfocada: python -m pytest practical-projects/06-file-organizer/tests -q ``` -Este capítulo evita incrustar un número fijo de escenarios porque la cobertura de regresión crece a medida que se endurecen findings de revisión. +El capítulo evita un conteo fijo de pruebas porque la cobertura de regresión evoluciona con los reviews. La cobertura incluye: - clasificación por sufijo; -- validación de rutas; -- descubrimiento determinista; -- escaneo superficial; +- descubrimiento superficial y determinista; - manejo de symlinks; - invariantes de modelos inmutables; - colisiones exactas y sin distinción de mayúsculas/minúsculas; - políticas `ERROR` y `SKIP`; - orígenes ausentes u obsoletos; -- cambios en rutas de categoría; -- preflight de colisiones; -- destino creado entre preflight y mutación; -- categoría convertida en symlink durante la mutación; -- origen planificado convertido en symlink durante la mutación; -- fallo al eliminar origen sin rollback destructivo del destino; -- ejecución exitosa; -- preservación de archivos de destino no relacionados; -- planes vacíos. +- destinos exactos tardíos; +- sustitución tardía del origen por symlink/archivo; +- carreras de symlink y rename de categoría; +- carreras de rename de la raíz; +- staging de longitud fija; +- finalización del staging sin `unlink()`; +- verificación de identidad del destino; +- ejecución exitosa y planes vacíos. ## Rutas de fallo importantes @@ -310,7 +348,7 @@ La cobertura incluye: Genera `FileNotFoundError`. -### Ruta de origen es un archivo regular +### Ruta de origen es archivo regular Genera `NotADirectoryError`. @@ -322,59 +360,51 @@ Se rechaza antes del escaneo. Se rechaza antes de planificar o ejecutar. -### Destino existe durante la planificación - -Se maneja según la política de colisión seleccionada. - ### Destino aparece después de la planificación -El preflight genera `FileExistsError` antes de cualquier movimiento. +El preflight o el commit atómico no-replace genera `FileExistsError`. -### Destino exacto aparece después del preflight +### Origen planificado cambia -La operación hard-link no-replace falla con `FileExistsError`; el destino recién creado se conserva y el origen permanece en su lugar. +La ejecución genera error en vez de tratar la sustitución como el archivo planificado. -### La identidad del origen planificado cambia durante la ejecución +### Raíz o categoría se renombra/sustituye -La ejecución genera un error en lugar de eliminar la entrada modificada o informar el movimiento como exitoso. +La validación del anclaje genera error en vez de devolver una ruta que ya no identifica el destino comprometido. -### Falla la eliminación del origen después de crear el destino +### Primitiva atómica no-replace no disponible -La ejecución genera un error y conserva el destino. Evita deliberadamente eliminar un destino cuya identidad actual no puede demostrarse de forma segura durante rollback. +La plataforma no compatible genera error en vez de debilitar silenciosamente el contrato. ## Errores comunes ### Mover mientras se escanea -Mezclar descubrimiento y mutación hace que los fallos parciales sean difíciles de razonar. Construye primero un plan. +Mezclar descubrimiento y mutación hace difícil razonar sobre fallos parciales. Construye primero el plan. -### Usar solo `Path.exists()` antes de `rename()` +### Tratar un nombre como identidad -La comprobación puede quedar obsoleta inmediatamente, y la semántica POSIX de rename puede reemplazar el destino. +Las entradas de directorio pueden sustituirse conservando el mismo nombre. Usa identidad del filesystem cuando esa diferencia importe. -### Tratar el nombre como identidad del objeto +### Comprobar inmediatamente antes de `unlink()` -Una entrada de directorio puede ser reemplazada conservando el mismo nombre. Cuando importa la concurrencia, compara identidad del filesystem y tipo de archivo en la frontera de mutación. +Sigue existiendo una ventana check-to-unlink. Cuando importa la identidad de la eliminación, reestructura la operación en vez de añadir otra comprobación. -### Hacer rollback eliminando ciegamente el destino +### Suponer que un FD abierto conserva el mismo pathname -El rollback también es una ruta de mutación. Si otro actor puede reemplazar la entrada de destino, una eliminación incondicional puede destruir datos no relacionados. +El descriptor sigue el inode del directorio tras un rename. Verifica su anclaje contra la ruta planificada. -### Inventar nombres nuevos silenciosamente +### Incluir el nombre completo del origen en el staging -Renombrar colisiones a valores como `report_2.txt` oculta una decisión de política. +Los nombres válidos pueden estar ya cerca de `NAME_MAX`. Mantén los nombres internos acotados de forma independiente. -### Seguir symlinks accidentalmente +### Limpiar a ciegas después de una carrera -Una ruta aparentemente simple puede apuntar fuera del workspace previsto. +El cleanup también muta. Conserva entradas inciertas en vez de borrar algo que puede pertenecer a otro actor. -### Asumir el orden de iteración del directorio +### Tratar preflight como transacción -El orden del filesystem no es un contrato de orden de la aplicación. - -### Tratar un preflight exitoso como una transacción - -El filesystem puede cambiar después del preflight. La revalidación reduce riesgo, pero no vuelve transaccional una operación de varios archivos. +El filesystem puede cambiar después. Un plan de varios archivos sigue siendo una secuencia de commits individualmente protegidos. ## Ejercicio @@ -386,36 +416,38 @@ Requisitos: 2. devolver texto legible y determinista; 3. mostrar movimientos planificados, colisiones omitidas y symlinks ignorados; 4. nunca acceder ni modificar el filesystem; -5. agregar pruebas para planes vacíos y no vacíos. +5. añadir pruebas para planes vacíos y no vacíos. ## Desafíos de extensión -Después del ejercicio, considera: +Considera: -- mapeo configurable de sufijos a categorías; +- mapeo configurable de sufijos; - categorías definidas por el usuario; -- exportación/importación JSON del plan con validación cuidadosa de obsolescencia; +- exportación/importación JSON con validación de plan obsoleto; - journal de operaciones; - descubrimiento recursivo con reglas explícitas de ruta relativa; -- detección de duplicados por checksum; -- una primitiva condicional de eliminación de origen aún más fuerte y específica de plataforma; -- una estrategia de rollback para planes parcialmente ejecutados. +- deduplicación por checksum; +- herramientas de recuperación/auditoría para entradas de staging conservadas; +- diseño transaccional para otro dominio de problema. + +Cada extensión introduce nuevas invariantes. Define el contrato antes de añadir código. -Cada extensión añade nuevas invariantes. Define el contrato antes de añadir código. +## Discusión de portafolio -## Discusión de portfolio +Una explicación útil no es “escribí un script que mueve archivos”. -Una explicación más fuerte sería: +Una versión más fuerte es: -> Diseñé un flujo de filesystem con planificación sin mutación, clasificación determinista, políticas explícitas de colisión, fronteras de symlink, validación de identidad durante la ejecución, protección exacta no-replace del destino y manejo conservador de fallos que nunca elimina ciegamente un objetivo de rollback no verificado. +> Diseñé un flujo de filesystem con planificación determinista, políticas explícitas de colisión, fronteras de symlink, identidad por inode, directorios anclados por descriptors, nombres de staging acotados y commit atómico no-replace en Linux mediante `renameat2(RENAME_NOREPLACE)`. El manejo de fallos conserva estado incierto en lugar de borrar entradas a ciegas. -Eso comunica decisiones de ingeniería, no solo uso de API. +Eso comunica decisiones de ingeniería, no solo uso de APIs. ## Referencia rápida | Tarea | Función/tipo | |---|---| -| Clasificar un nombre de archivo | `classify_path()` | +| Clasificar filename | `classify_path()` | | Descubrir archivos regulares directos | `discover_files()` | | Construir una propuesta segura | `plan_organization()` | | Elegir comportamiento de colisión | `CollisionPolicy` | @@ -423,11 +455,11 @@ Eso comunica decisiones de ingeniería, no solo uso de API. | Mantener el plan inmutable | `OrganizationPlan` | | Ejecutar el plan | `execute_plan()` | | Mantener destinos exitosos | `OrganizationResult` | -| Verificar identidad del filesystem | `(st_dev, st_ino)` de `stat` | -| Garantizar mutación exacta no-replace | `os.link()` + `unlink()` del origen verificado | +| Identificar objetos del filesystem | `(st_dev, st_ino)` | +| Commit seguro en Linux | `renameat2(RENAME_NOREPLACE)` | -## Qué viene después +## Qué sigue -El Proyecto 05 generó archivos. El Proyecto 06 toma la siguiente frontera: descubrir y organizar archivos de forma segura. +El Proyecto 05 generó archivos. El Proyecto 06 toma la siguiente frontera: descubrir y organizar archivos con seguridad. El Proyecto 07 vuelve a subir de nivel, combinando registros de dominio validados y estados explícitos de workflow en un **flujo ficticio de conciliación**. From 6adbd5cb88df7cec77dd70becd0ab997dbd791cf Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Tue, 1 Sep 2026 07:21:29 -0300 Subject: [PATCH 036/117] Sync Phase 10 English learning path --- docs/learning-path.en.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/learning-path.en.md b/docs/learning-path.en.md index 1d6dc7f..c8e077b 100644 --- a/docs/learning-path.en.md +++ b/docs/learning-path.en.md @@ -135,11 +135,11 @@ Phase 9 is complete with four reviewed chapters. The sequence moves from tabular 3. ✅ [User Registration](../practical-projects/03-user-registration/README.md) 4. ✅ [CSV Analyzer](../practical-projects/04-csv-analyzer/README.md) 5. ✅ [Report Generator](../practical-projects/05-report-generator/README.md) -6. ⏳ File Organizer +6. 🚧 [File Organizer](../practical-projects/06-file-organizer/README.md) 7. ⏳ Fictional Reconciliation Workflow 8. ⏳ Simulated Automation Flow -Phase 10 is in progress. Project 01 integrates validated data modeling, exact `Decimal` money, collections, JSON persistence, CSV export, deterministic temporary-file handling, and automated pytest coverage. Project 02 adds configurable grade policies, exact weighted aggregation, explicit progress-versus-final state, structured reporting, and boundary-focused tests. Project 03 adds canonical identity-like data, Unicode and IDNA normalization, duplicate prevention, secondary lookup indexes, safe indexed-field updates, explicit lifecycle transitions, and mutation-focused tests without introducing authentication. Project 04 adds schema-aware CSV ingestion, typed conversion, row-level rejection diagnostics, structural failures, duplicate identifier checks, deterministic filtering, and aggregation using the standard library. Project 05 adds explicit reporting windows, deterministic summaries, exact two-decimal metrics, immutable report boundaries, TXT/Markdown rendering, format-aware escaping, and UTF-8 file output. +Phase 10 is in progress. Project 01 integrates validated data modeling, exact `Decimal` money, collections, JSON persistence, CSV export, deterministic temporary-file handling, and automated pytest coverage. Project 02 adds configurable grade policies, exact weighted aggregation, explicit progress-versus-final state, structured reporting, and boundary-focused tests. Project 03 adds canonical identity-like data, Unicode and IDNA normalization, duplicate prevention, secondary lookup indexes, safe indexed-field updates, explicit lifecycle transitions, and mutation-focused tests without introducing authentication. Project 04 adds schema-aware CSV ingestion, typed conversion, row-level rejection diagnostics, structural failures, duplicate identifier checks, deterministic filtering, and aggregation using the standard library. Project 05 adds explicit reporting windows, deterministic summaries, exact two-decimal metrics, immutable report boundaries, TXT/Markdown rendering, format-aware escaping, and UTF-8 file output. Project 06 adds deterministic shallow file discovery, suffix classification, immutable dry-run planning, explicit collision policies, symlink boundaries, `(device, inode)` identity checks, source/category/root anchoring, bounded staging names, and platform-aware atomic no-replace commit behavior with Linux `renameat2(RENAME_NOREPLACE)`. ## Useful navigation From ba727121f61cf9914f9fb5fc7e5bdb59fc3778a3 Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Tue, 1 Sep 2026 07:22:08 -0300 Subject: [PATCH 037/117] Sync Phase 10 Portuguese learning path --- docs/learning-path.pt-BR.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/learning-path.pt-BR.md b/docs/learning-path.pt-BR.md index 67c6c1b..e47464f 100644 --- a/docs/learning-path.pt-BR.md +++ b/docs/learning-path.pt-BR.md @@ -135,11 +135,11 @@ A Fase 9 está concluída com quatro capítulos revisados. A sequência avança 3. ✅ [Cadastro de Usuários](../practical-projects/03-user-registration/README.pt-BR.md) 4. ✅ [Analisador CSV](../practical-projects/04-csv-analyzer/README.pt-BR.md) 5. ✅ [Gerador de Relatórios](../practical-projects/05-report-generator/README.pt-BR.md) -6. ⏳ Organizador de Arquivos +6. 🚧 [Organizador de Arquivos](../practical-projects/06-file-organizer/README.pt-BR.md) 7. ⏳ Fluxo Fictício de Conciliação 8. ⏳ Fluxo Simulado de Automação -A Fase 10 está em andamento. O Projeto 01 integra modelagem de dados validada, dinheiro exato com `Decimal`, coleções, persistência JSON, exportação CSV, manipulação determinística de arquivos temporários e cobertura automatizada com pytest. O Projeto 02 adiciona políticas de notas configuráveis, agregação ponderada exata, estado de progresso versus final explícito, relatório estruturado e testes focados em fronteiras. O Projeto 03 adiciona dados de identidade canônicos, normalização Unicode e IDNA, prevenção de duplicidade, índices secundários de lookup, atualizações seguras de campos indexados, transições explícitas de ciclo de vida e testes focados em mutação sem introduzir autenticação. O Projeto 04 adiciona ingestão CSV consciente de schema, conversão tipada, diagnóstico de rejeições por linha, falhas estruturais, verificação de identificadores duplicados, filtros determinísticos e agregação usando a biblioteca padrão. O Projeto 05 adiciona janelas explícitas de relatório, resumos determinísticos, métricas exatas com duas casas decimais, fronteiras imutáveis de relatório, renderização TXT/Markdown, escape específico do formato e escrita UTF-8. +A Fase 10 está em andamento. O Projeto 01 integra modelagem de dados validada, dinheiro exato com `Decimal`, coleções, persistência JSON, exportação CSV, manipulação determinística de arquivos temporários e cobertura automatizada com pytest. O Projeto 02 adiciona políticas de notas configuráveis, agregação ponderada exata, estado de progresso versus final explícito, relatório estruturado e testes focados em fronteiras. O Projeto 03 adiciona dados de identidade canônicos, normalização Unicode e IDNA, prevenção de duplicidade, índices secundários de lookup, atualizações seguras de campos indexados, transições explícitas de ciclo de vida e testes focados em mutação sem introduzir autenticação. O Projeto 04 adiciona ingestão CSV consciente de schema, conversão tipada, diagnóstico de rejeições por linha, falhas estruturais, verificação de identificadores duplicados, filtros determinísticos e agregação usando a biblioteca padrão. O Projeto 05 adiciona janelas explícitas de relatório, resumos determinísticos, métricas exatas com duas casas decimais, fronteiras imutáveis de relatório, renderização TXT/Markdown, escape específico do formato e escrita UTF-8. O Projeto 06 adiciona descoberta rasa e determinística de arquivos, classificação por sufixo, planejamento dry-run imutável, políticas explícitas de colisão, fronteiras de symlink, identidade `(device, inode)`, ancoragem de raiz/categorias, nomes de staging limitados e commit atômico no-replace sensível à plataforma com `renameat2(RENAME_NOREPLACE)` no Linux. ## Navegação útil From 0088bcfebb1eae633c30164e2b0aec192d73c533 Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Tue, 1 Sep 2026 07:22:46 -0300 Subject: [PATCH 038/117] Sync Phase 10 Spanish learning path --- docs/learning-path.es.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/learning-path.es.md b/docs/learning-path.es.md index 6e6ad98..332cef3 100644 --- a/docs/learning-path.es.md +++ b/docs/learning-path.es.md @@ -135,11 +135,11 @@ La Fase 9 está completada con cuatro capítulos revisados. La secuencia avanza 3. ✅ [Registro de Usuarios](../practical-projects/03-user-registration/README.es.md) 4. ✅ [Analizador CSV](../practical-projects/04-csv-analyzer/README.es.md) 5. ✅ [Generador de Informes](../practical-projects/05-report-generator/README.es.md) -6. ⏳ Organizador de Archivos +6. 🚧 [Organizador de Archivos](../practical-projects/06-file-organizer/README.es.md) 7. ⏳ Flujo Ficticio de Conciliación 8. ⏳ Flujo Simulado de Automatización -La Fase 10 está en progreso. El Proyecto 01 integra modelado de datos validado, dinero exacto con `Decimal`, colecciones, persistencia JSON, exportación CSV, manejo determinista de archivos temporales y cobertura automatizada con pytest. El Proyecto 02 añade políticas de calificación configurables, agregación ponderada exacta, estado de progreso frente a final explícito, informe estructurado y pruebas centradas en límites. El Proyecto 03 añade datos de identidad canónicos, normalización Unicode e IDNA, prevención de duplicados, índices secundarios de lookup, actualizaciones seguras de campos indexados, transiciones explícitas del ciclo de vida y pruebas centradas en mutación sin introducir autenticación. El Proyecto 04 añade ingestión CSV consciente de schema, conversión tipada, diagnóstico de rechazos por fila, fallos estructurales, comprobación de identificadores duplicados, filtros deterministas y agregación mediante la biblioteca estándar. El Proyecto 05 añade ventanas explícitas de informe, resúmenes deterministas, métricas exactas con dos decimales, límites inmutables del informe, renderización TXT/Markdown, escape específico del formato y escritura UTF-8. +La Fase 10 está en progreso. El Proyecto 01 integra modelado de datos validado, dinero exacto con `Decimal`, colecciones, persistencia JSON, exportación CSV, manejo determinista de archivos temporales y cobertura automatizada con pytest. El Proyecto 02 añade políticas de calificación configurables, agregación ponderada exacta, estado de progreso frente a final explícito, informe estructurado y pruebas centradas en límites. El Proyecto 03 añade datos de identidad canónicos, normalización Unicode e IDNA, prevención de duplicados, índices secundarios de lookup, actualizaciones seguras de campos indexados, transiciones explícitas del ciclo de vida y pruebas centradas en mutación sin introducir autenticación. El Proyecto 04 añade ingestión CSV consciente de schema, conversión tipada, diagnóstico de rechazos por fila, fallos estructurales, comprobación de identificadores duplicados, filtros deterministas y agregación mediante la biblioteca estándar. El Proyecto 05 añade ventanas explícitas de informe, resúmenes deterministas, métricas exactas con dos decimales, límites inmutables del informe, renderización TXT/Markdown, escape específico del formato y escritura UTF-8. El Proyecto 06 añade descubrimiento superficial y determinista de archivos, clasificación por sufijo, planificación dry-run inmutable, políticas explícitas de colisión, fronteras de symlink, identidad `(device, inode)`, anclaje de raíz/categorías, nombres de staging acotados y commit atómico no-replace sensible a la plataforma con `renameat2(RENAME_NOREPLACE)` en Linux. ## Navegación útil From e63736ee84d88735fd223b524d0886861b8defe5 Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Tue, 1 Sep 2026 07:23:45 -0300 Subject: [PATCH 039/117] Sync Phase 10 English roadmap --- docs/roadmap.en.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/roadmap.en.md b/docs/roadmap.en.md index 4a2c8d7..fb7b76d 100644 --- a/docs/roadmap.en.md +++ b/docs/roadmap.en.md @@ -24,9 +24,9 @@ This roadmap tracks both the educational curriculum and the repository foundatio | 7. Errors, files, and modules | Complete | Five reviewed chapters cover exception handling, deliberate exception signaling, safe file I/O, TXT/CSV/JSON data formats, and imports/modules/packages | | 8. Standard library | Complete | Nine reviewed chapters cover paths, date/time, JSON, CSV, logging, specialized collections, lazy iteration, decimal arithmetic, and OS/filesystem operations | | 9. External libraries | Complete | Four reviewed chapters cover pandas, openpyxl, requests, and pytest with explicit dependency contracts and deterministic examples | -| 10. Practical projects | In progress | Projects 01–05 cover validated monetary workflows, configurable grading rules, canonical user registration, schema-aware CSV ingestion, and deterministic report generation with automated tests | +| 10. Practical projects | In progress | Projects 01–05 are complete; Project 06 File Organizer is in progress with deterministic planning, collision safety, filesystem identity checks, anchored directories, and atomic no-replace commit behavior | -Phases 0–9 are complete. Phase 10: Practical Projects is in progress with Expense Tracker, Grade Calculator, User Registration, CSV Analyzer, and Report Generator available as Projects 01–05. The practical-project phase turns previously studied concepts into complete workflows with requirements, design decisions, implementation, validation, extension paths, and portfolio discussion. +Phases 0–9 are complete. Phase 10: Practical Projects is in progress with Expense Tracker, Grade Calculator, User Registration, CSV Analyzer, and Report Generator complete as Projects 01–05, while File Organizer is the current Project 06. The practical-project phase turns previously studied concepts into complete workflows with requirements, design decisions, implementation, validation, extension paths, and portfolio discussion. ## Phase 0: Project foundation @@ -172,11 +172,11 @@ See the [Practical Projects section index](../practical-projects/README.md). - [x] [User Registration](../practical-projects/03-user-registration/README.md) - [x] [CSV Analyzer](../practical-projects/04-csv-analyzer/README.md) - [x] [Report Generator](../practical-projects/05-report-generator/README.md) -- [ ] File Organizer +- [ ] [File Organizer](../practical-projects/06-file-organizer/README.md) — current project - [ ] Fictional Reconciliation Workflow - [ ] Simulated Automation Flow -Project 01 establishes the Phase 10 contract with explicit requirements, validated data modeling, exact `Decimal` money, persistence, deterministic demonstration, automated pytest coverage, extension challenges, and portfolio discussion. Project 02 extends the contract with configurable grading rules, exact weighted aggregation, explicit partial/final reporting, and boundary-focused validation. Project 03 adds canonical identity-like data, Unicode and IDNA normalization, duplicate prevention, secondary lookup indexes, safe indexed-field updates, explicit lifecycle transitions, and mutation-focused pytest coverage without introducing authentication. Project 04 adds strict CSV schemas, typed conversion, structural-versus-row failure handling, partial-success parsing, duplicate accepted identifiers, deterministic aggregation, and filtering with standard-library CSV mechanics exposed explicitly. Project 05 adds explicit inclusive date windows, source identity validation, exact deterministic summary metrics, immutable report construction, TXT/Markdown rendering, format-specific escaping, and UTF-8 file output. +Project 01 establishes the Phase 10 contract with explicit requirements, validated data modeling, exact `Decimal` money, persistence, deterministic demonstration, automated pytest coverage, extension challenges, and portfolio discussion. Project 02 extends the contract with configurable grading rules, exact weighted aggregation, explicit partial/final reporting, and boundary-focused validation. Project 03 adds canonical identity-like data, Unicode and IDNA normalization, duplicate prevention, secondary lookup indexes, safe indexed-field updates, explicit lifecycle transitions, and mutation-focused pytest coverage without introducing authentication. Project 04 adds strict CSV schemas, typed conversion, structural-versus-row failure handling, partial-success parsing, duplicate accepted identifiers, deterministic aggregation, and filtering with standard-library CSV mechanics exposed explicitly. Project 05 adds explicit inclusive date windows, source identity validation, exact deterministic summary metrics, immutable report construction, TXT/Markdown rendering, format-specific escaping, and UTF-8 file output. Project 06 adds shallow deterministic discovery, immutable planning, suffix categories, explicit collision policies, symlink boundaries, `(device, inode)` identity checks, root/category descriptor anchoring, bounded staging names, and platform-aware atomic no-replace commits with Linux `renameat2(RENAME_NOREPLACE)`. Each project should include: From a3f875ef6360d4afc86a40d76c6ae4494e9d80ff Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Tue, 1 Sep 2026 07:24:47 -0300 Subject: [PATCH 040/117] Sync Phase 10 Portuguese roadmap --- docs/roadmap.pt-BR.md | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/docs/roadmap.pt-BR.md b/docs/roadmap.pt-BR.md index 7d79475..20f7ba3 100644 --- a/docs/roadmap.pt-BR.md +++ b/docs/roadmap.pt-BR.md @@ -24,9 +24,9 @@ Este roadmap acompanha tanto a trilha educacional quanto a fundação do reposit | 7. Erros, arquivos e módulos | Concluída | Cinco capítulos revisados cobrem tratamento de exceções, sinalização deliberada, I/O seguro de arquivos, formatos TXT/CSV/JSON e imports/módulos/pacotes | | 8. Biblioteca padrão | Concluída | Nove capítulos revisados cobrem caminhos, data/hora, JSON, CSV, logging, coleções especializadas, iteração lazy, aritmética decimal e operações de OS/filesystem | | 9. Bibliotecas externas | Concluída | Quatro capítulos revisados cobrem pandas, openpyxl, requests e pytest com contratos explícitos de dependências e exemplos determinísticos | -| 10. Projetos práticos | Em andamento | Projetos 01–05 cobrem fluxos monetários validados, regras configuráveis de notas, cadastro canônico de usuários, ingestão CSV consciente de schema e geração determinística de relatórios com testes automatizados | +| 10. Projetos práticos | Em andamento | Projetos 01–05 estão concluídos; o Projeto 06 Organizador de Arquivos está em andamento com planejamento determinístico, segurança de colisões, identidade de filesystem, diretórios ancorados e commit atômico no-replace | -As Fases 0–9 estão concluídas. A Fase 10: Projetos Práticos está em andamento com Controle de Despesas, Calculadora de Notas, Cadastro de Usuários, Analisador CSV e Gerador de Relatórios disponíveis como Projetos 01–05. A fase de projetos práticos transforma conceitos já estudados em fluxos completos com requisitos, decisões de design, implementação, validação, caminhos de extensão e discussão de portfólio. +As Fases 0–9 estão concluídas. A Fase 10: Projetos Práticos está em andamento com Controle de Despesas, Calculadora de Notas, Cadastro de Usuários, Analisador CSV e Gerador de Relatórios concluídos como Projetos 01–05, enquanto o Organizador de Arquivos é o atual Projeto 06. A fase transforma conceitos já estudados em fluxos completos com requisitos, decisões de design, implementação, validação, caminhos de extensão e discussão de portfólio. ## Fase 0: Fundação do projeto @@ -150,7 +150,7 @@ Veja a [trilha de aprendizagem da seção](../standard-library/README.pt-BR.md). - [x] [`decimal`](../standard-library/08-decimal/README.pt-BR.md) - [x] [`os` e `shutil`](../standard-library/09-os-shutil/README.pt-BR.md) -A Fase 8 está concluída. Os Capítulos 01–08 constroem contratos para caminhos, data/hora, formatos estruturados, logging, coleções especializadas, iteração lazy e aritmética decimal. O Capítulo 09 encerra a fase conectando essas bases ao estado do ambiente do processo, interfaces path-like, varredura e travessia de diretórios, metadados, cópia, movimentação, exclusão recursiva, capacidades de plataforma e segurança de archives. +A Fase 8 está concluída. Os Capítulos 01–08 constroem contratos para caminhos, data/hora, formatos estruturados, logging, coleções especializadas, iteração lazy e aritmética decimal. O Capítulo 09 encerra a fase conectando essas bases a estado do ambiente do processo, interfaces path-like, varredura e travessia de diretórios, metadados, cópia, movimento, remoção recursiva, capacidades de plataforma e segurança de archives. ## Fase 9: Bibliotecas externas @@ -161,7 +161,7 @@ Veja a [trilha de aprendizagem da seção](../external-libraries/README.pt-BR.md - [x] [`requests`](../external-libraries/03-requests/README.pt-BR.md) - [x] [`pytest`](../external-libraries/04-pytest/README.pt-BR.md) -A Fase 9 está concluída. O Capítulo 01 introduz pandas 3.0.x para dados tabulares rotulados. O Capítulo 02 acrescenta automação de workbooks com openpyxl 3.1.x. O Capítulo 03 acrescenta contratos HTTP/API com Requests 2.34.x. O Capítulo 04 encerra a fase com contratos de testes automatizados em pytest 9.1.x, cobrindo descoberta, assertions, fixtures, parametrização, recursos temporários, monkeypatching, captura, marks, isolamento determinístico e CI. Os exemplos executáveis de bibliotecas externas usam o contrato declarado em [`requirements-external.txt`](../requirements-external.txt). +A Fase 9 está concluída. O Capítulo 01 introduz pandas 3.0.x para dados tabulares rotulados. O Capítulo 02 acrescenta automação de workbooks com openpyxl 3.1.x. O Capítulo 03 acrescenta contratos HTTP/API com Requests 2.34.x. O Capítulo 04 encerra a fase com contratos de testes automatizados em pytest 9.1.x, cobrindo descoberta, assertions, fixtures, parametrização, recursos temporários, monkeypatching, captura, marks, isolamento determinístico e CI. Os exemplos executáveis usam o contrato declarado em [`requirements-external.txt`](../requirements-external.txt). ## Fase 10: Projetos práticos @@ -170,13 +170,13 @@ Veja o [índice da seção Projetos Práticos](../practical-projects/README.pt-B - [x] [Controle de Despesas](../practical-projects/01-expense-tracker/README.pt-BR.md) - [x] [Calculadora de Notas](../practical-projects/02-grade-calculator/README.pt-BR.md) - [x] [Cadastro de Usuários](../practical-projects/03-user-registration/README.pt-BR.md) -- [x] [Analisador de CSV](../practical-projects/04-csv-analyzer/README.pt-BR.md) +- [x] [Analisador CSV](../practical-projects/04-csv-analyzer/README.pt-BR.md) - [x] [Gerador de Relatórios](../practical-projects/05-report-generator/README.pt-BR.md) -- [ ] Organizador de Arquivos +- [ ] [Organizador de Arquivos](../practical-projects/06-file-organizer/README.pt-BR.md) — projeto atual - [ ] Fluxo Fictício de Conciliação - [ ] Fluxo Simulado de Automação -O Projeto 01 estabelece o contrato da Fase 10 com requisitos explícitos, modelagem de dados validada, dinheiro exato com `Decimal`, persistência, demonstração determinística, cobertura automatizada com pytest, desafios de extensão e discussão de portfólio. O Projeto 02 amplia o contrato com regras de notas configuráveis, agregação ponderada exata, relatório parcial/final explícito e validação focada em fronteiras. O Projeto 03 adiciona dados de identidade canônicos, normalização Unicode e IDNA, prevenção de duplicidade, índices secundários, atualizações seguras e transições explícitas de ciclo de vida sem introduzir autenticação. O Projeto 04 adiciona schemas CSV rígidos, conversão tipada, separação entre falhas estruturais e falhas de linha, parsing com sucesso parcial, identificadores aceitos duplicados, agregação determinística e filtros com a mecânica da biblioteca padrão exposta explicitamente. O Projeto 05 adiciona janelas inclusivas explícitas de datas, validação da identidade da origem, métricas exatas e determinísticas de resumo, construção imutável do relatório, renderização TXT/Markdown, escape específico do formato e escrita UTF-8. +O Projeto 01 estabelece o contrato da Fase 10 com requisitos explícitos, modelagem de dados validada, dinheiro exato com `Decimal`, persistência, demonstração determinística, cobertura automatizada com pytest, desafios de extensão e discussão de portfólio. O Projeto 02 estende o contrato com regras configuráveis de notas, agregação ponderada exata, relatórios parcial/final explícitos e validação focada em fronteiras. O Projeto 03 adiciona dados de identidade canônicos, normalização Unicode e IDNA, prevenção de duplicidade, índices secundários de lookup, atualizações seguras de campos indexados, transições explícitas de ciclo de vida e cobertura pytest focada em mutação sem introduzir autenticação. O Projeto 04 adiciona schemas CSV estritos, conversão tipada, tratamento de falhas estruturais versus falhas por linha, parsing com sucesso parcial, identificadores aceitos duplicados, agregação determinística e filtragem usando mecanismos CSV da biblioteca padrão de forma explícita. O Projeto 05 adiciona janelas inclusivas de datas, validação de identidade de origem, métricas de resumo exatas e determinísticas, construção imutável de relatórios, renderização TXT/Markdown, escape específico do formato e saída UTF-8. O Projeto 06 adiciona descoberta rasa determinística, planejamento imutável, categorias por sufixo, políticas explícitas de colisão, fronteiras de symlink, identidade `(device, inode)`, ancoragem de descriptors de raiz/categorias, nomes de staging limitados e commits atômicos no-replace sensíveis à plataforma com `renameat2(RENAME_NOREPLACE)` no Linux. Cada projeto deve incluir: @@ -184,22 +184,22 @@ Cada projeto deve incluir: - notas de design; - implementação; - explicação; -- cobertura automatizada dos comportamentos importantes; +- cobertura automatizada para comportamentos importantes; - desafios de extensão; - discussão de portfólio. -## Critérios contínuos de qualidade +## Gates contínuos de qualidade Cada fase deve preservar: - precisão técnica; - consistência multilíngue; - exemplos originais e seguros para publicação; -- dados seguros do ponto de vista de privacidade; +- dados seguros para privacidade; - exemplos Python executáveis quando apropriado; -- integridade da navegação interna; -- atenção à PEP 8; +- integridade de navegação interna; +- atenção ao PEP 8; - documentação de mudanças estruturais relevantes; -- transparência sobre dependências e pressupostos de versão. +- premissas honestas sobre dependências e versões. -O roadmap evoluirá à medida que o projeto crescer, mas as mudanças devem preservar a progressão dos conceitos iniciais até o trabalho prático integrado. +O roadmap evoluirá conforme o projeto crescer, mas as mudanças devem preservar a progressão de conceitos iniciantes para trabalho prático integrado. From 6a722859d6f7e229111aca3fdefab685bfb3694d Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Tue, 1 Sep 2026 07:25:32 -0300 Subject: [PATCH 041/117] Sync Phase 10 Spanish roadmap --- docs/roadmap.es.md | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/docs/roadmap.es.md b/docs/roadmap.es.md index d622ad1..ef4e744 100644 --- a/docs/roadmap.es.md +++ b/docs/roadmap.es.md @@ -24,9 +24,9 @@ Este roadmap acompaña tanto la ruta educativa como la base del repositorio que | 7. Errores, archivos y módulos | Completada | Cinco capítulos revisados cubren manejo de excepciones, señalización deliberada, I/O seguro de archivos, formatos TXT/CSV/JSON e imports/módulos/paquetes | | 8. Biblioteca estándar | Completada | Nueve capítulos revisados cubren rutas, fecha/hora, JSON, CSV, logging, colecciones especializadas, iteración lazy, aritmética decimal y operaciones de OS/filesystem | | 9. Bibliotecas externas | Completada | Cuatro capítulos revisados cubren pandas, openpyxl, requests y pytest con contratos explícitos de dependencias y ejemplos deterministas | -| 10. Proyectos prácticos | En progreso | Los Proyectos 01–05 cubren flujos monetarios validados, reglas configurables de calificación, registro canónico de usuarios, ingestión CSV consciente de schema y generación determinista de informes con pruebas automatizadas | +| 10. Proyectos prácticos | En progreso | Los Proyectos 01–05 están completados; el Proyecto 06 Organizador de Archivos está en progreso con planificación determinista, seguridad de colisiones, identidad de filesystem, directorios anclados y commit atómico no-replace | -Las Fases 0–9 están completadas. La Fase 10: Proyectos Prácticos está en progreso con Control de Gastos, Calculadora de Notas, Registro de Usuarios, Analizador CSV y Generador de Informes disponibles como Proyectos 01–05. La fase de proyectos prácticos transforma conceptos ya estudiados en flujos completos con requisitos, decisiones de diseño, implementación, validación, caminos de extensión y discusión de portafolio. +Las Fases 0–9 están completadas. La Fase 10: Proyectos Prácticos está en progreso con Control de Gastos, Calculadora de Notas, Registro de Usuarios, Analizador CSV y Generador de Informes completados como Proyectos 01–05, mientras que Organizador de Archivos es el Proyecto 06 actual. La fase transforma conceptos ya estudiados en flujos completos con requisitos, decisiones de diseño, implementación, validación, caminos de extensión y discusión de portafolio. ## Fase 0: Base del proyecto @@ -161,7 +161,7 @@ Consulta la [ruta de aprendizaje de la sección](../external-libraries/README.es - [x] [`requests`](../external-libraries/03-requests/README.es.md) - [x] [`pytest`](../external-libraries/04-pytest/README.es.md) -La Fase 9 está completada. El Capítulo 01 introduce pandas 3.0.x para datos tabulares etiquetados. El Capítulo 02 añade automatización de libros con openpyxl 3.1.x. El Capítulo 03 añade contratos HTTP/API con Requests 2.34.x. El Capítulo 04 cierra la fase con contratos de pruebas automatizadas en pytest 9.1.x, cubriendo descubrimiento, assertions, fixtures, parametrización, recursos temporales, monkeypatching, captura, marks, aislamiento determinista y CI. Los ejemplos ejecutables de bibliotecas externas usan el contrato declarado en [`requirements-external.txt`](../requirements-external.txt). +La Fase 9 está completada. El Capítulo 01 introduce pandas 3.0.x para datos tabulares etiquetados. El Capítulo 02 añade automatización de libros con openpyxl 3.1.x. El Capítulo 03 añade contratos HTTP/API con Requests 2.34.x. El Capítulo 04 cierra la fase con contratos de pruebas automatizadas en pytest 9.1.x, cubriendo descubrimiento, assertions, fixtures, parametrización, recursos temporales, monkeypatching, captura, marks, aislamiento determinista y CI. Los ejemplos ejecutables usan el contrato declarado en [`requirements-external.txt`](../requirements-external.txt). ## Fase 10: Proyectos prácticos @@ -170,13 +170,13 @@ Consulta el [índice de la sección Proyectos Prácticos](../practical-projects/ - [x] [Control de Gastos](../practical-projects/01-expense-tracker/README.es.md) - [x] [Calculadora de Notas](../practical-projects/02-grade-calculator/README.es.md) - [x] [Registro de Usuarios](../practical-projects/03-user-registration/README.es.md) -- [x] [Analizador de CSV](../practical-projects/04-csv-analyzer/README.es.md) +- [x] [Analizador CSV](../practical-projects/04-csv-analyzer/README.es.md) - [x] [Generador de Informes](../practical-projects/05-report-generator/README.es.md) -- [ ] Organizador de Archivos +- [ ] [Organizador de Archivos](../practical-projects/06-file-organizer/README.es.md) — proyecto actual - [ ] Flujo Ficticio de Conciliación - [ ] Flujo Simulado de Automatización -El Proyecto 01 establece el contrato de la Fase 10 con requisitos explícitos, modelado de datos validado, dinero exacto con `Decimal`, persistencia, demostración determinista, cobertura automatizada con pytest, desafíos de ampliación y discusión de portafolio. El Proyecto 02 amplía el contrato con reglas de calificación configurables, agregación ponderada exacta, informe parcial/final explícito y validación centrada en límites. El Proyecto 03 añade datos de identidad canónicos, normalización Unicode e IDNA, prevención de duplicados, índices secundarios, actualizaciones seguras y transiciones explícitas del ciclo de vida sin introducir autenticación. El Proyecto 04 añade schemas CSV estrictos, conversión tipada, separación entre fallos estructurales y fallos de fila, parsing con éxito parcial, identificadores aceptados duplicados, agregación determinista y filtros con la mecánica de la biblioteca estándar expuesta explícitamente. El Proyecto 05 añade ventanas inclusivas explícitas de fechas, validación de identidad del origen, métricas exactas y deterministas de resumen, construcción inmutable del informe, renderización TXT/Markdown, escape específico del formato y escritura UTF-8. +El Proyecto 01 establece el contrato de la Fase 10 con requisitos explícitos, modelado de datos validado, dinero exacto con `Decimal`, persistencia, demostración determinista, cobertura automatizada con pytest, desafíos de extensión y discusión de portafolio. El Proyecto 02 extiende el contrato con reglas configurables de calificación, agregación ponderada exacta, informes parcial/final explícitos y validación centrada en límites. El Proyecto 03 añade datos de identidad canónicos, normalización Unicode e IDNA, prevención de duplicados, índices secundarios de lookup, actualizaciones seguras de campos indexados, transiciones explícitas del ciclo de vida y cobertura pytest centrada en mutación sin introducir autenticación. El Proyecto 04 añade schemas CSV estrictos, conversión tipada, manejo de fallos estructurales frente a fallos por fila, parsing con éxito parcial, identificadores aceptados duplicados, agregación determinista y filtrado usando mecanismos CSV de la biblioteca estándar de forma explícita. El Proyecto 05 añade ventanas inclusivas de fechas, validación de identidad de origen, métricas de resumen exactas y deterministas, construcción inmutable de informes, renderización TXT/Markdown, escape específico del formato y salida UTF-8. El Proyecto 06 añade descubrimiento superficial determinista, planificación inmutable, categorías por sufijo, políticas explícitas de colisión, fronteras de symlink, identidad `(device, inode)`, anclaje de descriptors de raíz/categorías, nombres de staging acotados y commits atómicos no-replace sensibles a la plataforma con `renameat2(RENAME_NOREPLACE)` en Linux. Cada proyecto debe incluir: @@ -184,22 +184,22 @@ Cada proyecto debe incluir: - notas de diseño; - implementación; - explicación; -- cobertura automatizada del comportamiento importante; -- desafíos de ampliación; +- cobertura automatizada para comportamientos importantes; +- desafíos de extensión; - discusión de portafolio. -## Criterios continuos de calidad +## Gates continuos de calidad Cada fase debe preservar: - precisión técnica; - consistencia multilingüe; - ejemplos originales y seguros para publicación; -- datos seguros desde el punto de vista de la privacidad; -- ejemplos ejecutables de Python cuando corresponda; -- integridad de la navegación interna; +- datos seguros para privacidad; +- ejemplos Python ejecutables cuando corresponda; +- integridad de navegación interna; - atención a PEP 8; - documentación de cambios estructurales relevantes; -- transparencia sobre dependencias y supuestos de versión. +- supuestos honestos sobre dependencias y versiones. -El roadmap evolucionará a medida que el proyecto crezca, pero los cambios deben preservar la progresión desde los conceptos iniciales hasta el trabajo práctico integrado. +El roadmap evolucionará a medida que crezca el proyecto, pero los cambios deben preservar la progresión desde conceptos iniciales hasta trabajo práctico integrado. From 140f9c105ef3e606a11f0cc654e49cd917557211 Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Tue, 1 Sep 2026 07:27:13 -0300 Subject: [PATCH 042/117] Add File Organizer to English project structure --- docs/project-structure.en.md | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/docs/project-structure.en.md b/docs/project-structure.en.md index fcfb473..ebf6a2d 100644 --- a/docs/project-structure.en.md +++ b/docs/project-structure.en.md @@ -394,15 +394,25 @@ python-study-guide/ │ │ └── tests/ │ │ ├── conftest.py │ │ └── test_csv_analyzer.py -│ └── 05-report-generator/ +│ ├── 05-report-generator/ +│ │ ├── README.md +│ │ ├── README.pt-BR.md +│ │ ├── README.es.md +│ │ ├── demo.py +│ │ ├── report_generator.py +│ │ └── tests/ +│ │ ├── conftest.py +│ │ └── test_report_generator.py +│ └── 06-file-organizer/ │ ├── README.md │ ├── README.pt-BR.md │ ├── README.es.md │ ├── demo.py -│ ├── report_generator.py +│ ├── file_organizer.py │ └── tests/ │ ├── conftest.py -│ └── test_report_generator.py +│ ├── test_atomic_move.py +│ └── test_file_organizer.py ├── program-flow/ │ ├── README.md │ ├── README.pt-BR.md @@ -619,7 +629,7 @@ python-study-guide/ - `external-libraries/`: complete Phase 9 learning path for third-party packages. It contains reviewed multilingual chapters for pandas 3.0.x, openpyxl 3.1.x, Requests 2.34.x, and pytest 9.1.x, with twenty deterministic executable examples in total. The phase covers tabular transformations, Excel workbook automation, HTTP/API clients, and automated-testing contracts; Phase 10 practical projects come next. - `functions/`: complete Phase 5 learning path. Chapters 01–09 cover defining and calling functions, required inputs, returned values, scope and name lookup, type hints for function interfaces, default values including definition-time evaluation and mutable-default safety, variable-length positional and keyword argument collection with `*args` and `**kwargs`, composition through helper and coordinating functions with explicit dependencies and simple call graphs, and explicit data-flow tracing across calls including parameter bindings, rebinding versus mutation, `None`, tuple results, and return-based handoffs, in English, Brazilian Portuguese, and Spanish with deterministic executable examples. - `fundamentals/`: complete Phase 1 learning path. Its six chapters teach how Python runs a program, how to use `print()` and `input()`, how assignment and naming work, how to recognize and inspect common built-in data types, and how to convert compatible values deliberately, with aligned multilingual explanations and executable examples. -- `practical-projects/`: Phase 10 practical-project workspace. Projects 01–05 are available: Expense Tracker integrates validated monetary data and persistence; Grade Calculator adds configurable grading policies and exact weighted aggregation; User Registration adds canonical identity-like data, duplicate prevention, indexed updates, and lifecycle transitions; CSV Analyzer adds strict schema-aware ingestion and partial-success validation; Report Generator adds explicit date windows, deterministic summary metrics, TXT/Markdown rendering, and UTF-8 file output. +- `practical-projects/`: Phase 10 practical-project workspace. Projects 01–05 are complete and Project 06 File Organizer is in progress. Project 06 adds deterministic shallow discovery, immutable planning, collision policies, symlink boundaries, filesystem identity checks, descriptor-anchored directories, bounded staging names, atomic Linux no-replace commit behavior, a deterministic demo, and focused regression tests. - `program-flow/`: complete Phase 4 learning path. Chapters 01–08 teach conditions, comparisons, truth-value testing, membership, identity, Boolean logic, conditional branching with `if`, `elif`, and `else`, structural pattern matching, iterable-driven repetition with `for`, numeric progressions with `range()`, position-aware iteration with `enumerate()`, parallel iteration with `zip()` including explicit equal-length validation with `strict=True`, state-driven repetition with `while`, deliberate loop control with `break`, `continue`, and loop `else`, and how to choose and combine program-flow tools according to intent, in English, Brazilian Portuguese, and Spanish with deterministic executable examples. - `scripts/`: dependency-free maintenance tools used locally and by GitHub Actions. - `standard-library/`: complete Phase 8 learning path. Chapters 01–09 cover `pathlib` filesystem boundaries, `datetime` date/time modeling, advanced `json` and `csv` contracts, `logging`, specialized `collections`, `itertools`, `decimal`, and `os`/`shutil` contracts for environment state, traversal, metadata, copy, move, recursive removal, platform capabilities, and archive safety, in English, Brazilian Portuguese, and Spanish with deterministic executable examples. From 85528726c86657f7abac2db303175161d8899b6a Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Tue, 1 Sep 2026 07:31:03 -0300 Subject: [PATCH 043/117] Add File Organizer to Portuguese project structure --- docs/project-structure.pt-BR.md | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/docs/project-structure.pt-BR.md b/docs/project-structure.pt-BR.md index 03737e4..635b843 100644 --- a/docs/project-structure.pt-BR.md +++ b/docs/project-structure.pt-BR.md @@ -394,15 +394,25 @@ python-study-guide/ │ │ └── tests/ │ │ ├── conftest.py │ │ └── test_csv_analyzer.py -│ └── 05-report-generator/ +│ ├── 05-report-generator/ +│ │ ├── README.md +│ │ ├── README.pt-BR.md +│ │ ├── README.es.md +│ │ ├── demo.py +│ │ ├── report_generator.py +│ │ └── tests/ +│ │ ├── conftest.py +│ │ └── test_report_generator.py +│ └── 06-file-organizer/ │ ├── README.md │ ├── README.pt-BR.md │ ├── README.es.md │ ├── demo.py -│ ├── report_generator.py +│ ├── file_organizer.py │ └── tests/ │ ├── conftest.py -│ └── test_report_generator.py +│ ├── test_atomic_move.py +│ └── test_file_organizer.py ├── program-flow/ │ ├── README.md │ ├── README.pt-BR.md @@ -619,7 +629,7 @@ python-study-guide/ - `external-libraries/`: trilha completa da Fase 9 para pacotes de terceiros. Contém capítulos multilíngues revisados de pandas 3.0.x, openpyxl 3.1.x, Requests 2.34.x e pytest 9.1.x, com vinte exemplos executáveis determinísticos no total. A fase cobre transformações tabulares, automação de workbooks do Excel, clientes HTTP/API e contratos de testes automatizados; a Fase 10 de projetos práticos vem a seguir. - `functions/`: trilha completa da Fase 5. Os Capítulos 01–09 cobrem definição e chamada de funções, entradas obrigatórias, valores retornados, escopo e busca de nomes, type hints para interfaces de funções, valores padrão incluindo avaliação no momento da definição e segurança com padrões mutáveis, coleta de argumentos posicionais e nomeados de quantidade variável com `*args` e `**kwargs`, composição por funções auxiliares e coordenadoras com dependências explícitas e grafos simples de chamadas e rastreamento explícito do fluxo de dados entre chamadas, incluindo vínculos de parâmetros, reatribuição versus mutação, `None`, resultados em tupla e passagens por `return`, em inglês, português brasileiro e espanhol com exemplos executáveis determinísticos. - `fundamentals/`: trilha completa da Fase 1. Seus seis capítulos ensinam como o Python executa um programa, como usar `print()` e `input()`, como funcionam atribuição e nomes, como reconhecer e inspecionar tipos de dados embutidos comuns e como converter valores compatíveis de forma deliberada, com explicações multilíngues alinhadas e exemplos executáveis. -- `practical-projects/`: espaço de Projetos Práticos da Fase 10. Os Projetos 01–05 estão disponíveis: Controle de Despesas integra dados monetários validados e persistência; Calculadora de Notas adiciona políticas de notas configuráveis e agregação ponderada exata; Cadastro de Usuários adiciona dados canônicos de identidade, prevenção de duplicidades, atualizações indexadas e transições de ciclo de vida; Analisador CSV adiciona ingestão rígida consciente de schema e validação com sucesso parcial; Gerador de Relatórios adiciona janelas explícitas de datas, métricas determinísticas de resumo, renderização TXT/Markdown e escrita UTF-8. +- `practical-projects/`: espaço de Projetos Práticos da Fase 10. Os Projetos 01–05 estão concluídos e o Projeto 06 Organizador de Arquivos está em andamento. O Projeto 06 adiciona descoberta rasa determinística, planejamento imutável, políticas de colisão, fronteiras de symlink, verificações de identidade do filesystem, diretórios ancorados por descriptors, nomes de staging limitados, commit atômico no-replace no Linux, demo determinístico e testes focados de regressão. - `program-flow/`: trilha completa da Fase 4. Os Capítulos 01–08 ensinam condições, comparações, teste de valor de verdade, pertencimento, identidade, lógica booleana, ramificação condicional com `if`, `elif` e `else`, correspondência de padrões estruturais, repetição guiada por iteráveis com `for`, progressões numéricas com `range()`, iteração com posição usando `enumerate()`, iteração paralela com `zip()` incluindo validação explícita de comprimentos iguais com `strict=True`, repetição guiada por estado com `while`, controle deliberado de loops com `break`, `continue` e `else` de loop e como escolher e combinar ferramentas de fluxo do programa de acordo com a intenção, em inglês, português brasileiro e espanhol, com exemplos executáveis determinísticos. - `scripts/`: ferramentas de manutenção sem dependências externas, utilizadas localmente e pelo GitHub Actions. - `standard-library/`: trilha completa da Fase 8. Os Capítulos 01–09 cobrem fronteiras de filesystem com `pathlib`, modelagem de data/hora com `datetime`, contratos avançados de `json` e `csv`, `logging`, `collections` especializadas, `itertools`, `decimal` e contratos de `os`/`shutil` para estado do ambiente, travessia, metadados, cópia, movimentação, remoção recursiva, capacidades de plataforma e segurança de archives, em inglês, português do Brasil e espanhol com exemplos executáveis determinísticos. From 8e1b99fcff81dd3c820bdd5d63461f33a7b59ad1 Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Tue, 1 Sep 2026 07:32:22 -0300 Subject: [PATCH 044/117] Add File Organizer to Spanish project structure --- docs/project-structure.es.md | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/docs/project-structure.es.md b/docs/project-structure.es.md index ebf5992..ace7970 100644 --- a/docs/project-structure.es.md +++ b/docs/project-structure.es.md @@ -394,15 +394,25 @@ python-study-guide/ │ │ └── tests/ │ │ ├── conftest.py │ │ └── test_csv_analyzer.py -│ └── 05-report-generator/ +│ ├── 05-report-generator/ +│ │ ├── README.md +│ │ ├── README.pt-BR.md +│ │ ├── README.es.md +│ │ ├── demo.py +│ │ ├── report_generator.py +│ │ └── tests/ +│ │ ├── conftest.py +│ │ └── test_report_generator.py +│ └── 06-file-organizer/ │ ├── README.md │ ├── README.pt-BR.md │ ├── README.es.md │ ├── demo.py -│ ├── report_generator.py +│ ├── file_organizer.py │ └── tests/ │ ├── conftest.py -│ └── test_report_generator.py +│ ├── test_atomic_move.py +│ └── test_file_organizer.py ├── program-flow/ │ ├── README.md │ ├── README.pt-BR.md @@ -619,7 +629,7 @@ python-study-guide/ - `external-libraries/`: ruta completa de la Fase 9 para paquetes de terceros. Contiene capítulos multilingües revisados de pandas 3.0.x, openpyxl 3.1.x, Requests 2.34.x y pytest 9.1.x, con veinte ejemplos ejecutables deterministas en total. La fase cubre transformaciones tabulares, automatización de libros de Excel, clientes HTTP/API y contratos de pruebas automatizadas; la Fase 10 de proyectos prácticos viene a continuación. - `functions/`: ruta completa de la Fase 5. Los Capítulos 01–09 cubren definición y llamada de funciones, entradas obligatorias, valores retornados, alcance y búsqueda de nombres, type hints para interfaces de funciones, valores predeterminados incluida la evaluación al definir la función y la seguridad con valores mutables, recolección de argumentos posicionales y por palabra clave de cantidad variable con `*args` y `**kwargs`, composición mediante funciones auxiliares y coordinadoras con dependencias explícitas y grafos simples de llamadas, y seguimiento explícito del flujo de datos entre llamadas, incluidos vínculos de parámetros, reasignación frente a mutación, `None`, resultados en tupla y traspasos mediante `return`, en inglés, portugués de Brasil y español, con ejemplos ejecutables determinísticos. - `fundamentals/`: ruta completa de la Fase 1. Sus seis capítulos enseñan cómo Python ejecuta un programa, cómo usar `print()` e `input()`, cómo funcionan la asignación y los nombres, cómo reconocer e inspeccionar tipos de datos incorporados comunes y cómo convertir valores compatibles de forma deliberada, con explicaciones multilingües alineadas y ejemplos ejecutables. -- `practical-projects/`: espacio de Proyectos Prácticos de la Fase 10. Los Proyectos 01–05 están disponibles: Control de Gastos integra datos monetarios validados y persistencia; Calculadora de Notas añade políticas de calificación configurables y agregación ponderada exacta; Registro de Usuarios añade datos canónicos de identidad, prevención de duplicados, actualizaciones indexadas y transiciones del ciclo de vida; Analizador CSV añade ingestión estricta consciente de schema y validación con éxito parcial; Generador de Informes añade ventanas explícitas de fechas, métricas deterministas de resumen, renderización TXT/Markdown y escritura UTF-8. +- `practical-projects/`: espacio de Proyectos Prácticos de la Fase 10. Los Proyectos 01–05 están completados y el Proyecto 06 Organizador de Archivos está en progreso. El Proyecto 06 añade descubrimiento superficial determinista, planificación inmutable, políticas de colisión, fronteras de symlink, verificaciones de identidad del filesystem, directorios anclados por descriptors, nombres de staging acotados, commit atómico no-replace en Linux, demo determinista y pruebas de regresión enfocadas. - `program-flow/`: ruta completa de la Fase 4. Los Capítulos 01–08 enseñan condiciones, comparaciones, pruebas de valor de verdad, pertenencia, identidad, lógica booleana, ramificación condicional con `if`, `elif` y `else`, coincidencia de patrones estructurales, repetición guiada por iterables con `for`, progresiones numéricas con `range()`, iteración con posición usando `enumerate()`, iteración paralela con `zip()` incluida la validación explícita de longitudes iguales con `strict=True`, repetición guiada por estado con `while`, control deliberado de bucles con `break`, `continue` y `else` de bucle y cómo elegir y combinar herramientas de flujo del programa según la intención, en inglés, portugués de Brasil y español, con ejemplos ejecutables determinísticos. - `scripts/`: herramientas de mantenimiento sin dependencias externas utilizadas localmente y por GitHub Actions. - `standard-library/`: ruta completa de la Fase 8. Los Capítulos 01–09 cubren fronteras de filesystem con `pathlib`, modelado de fecha/hora con `datetime`, contratos avanzados de `json` y `csv`, `logging`, `collections` especializadas, `itertools`, `decimal` y contratos de `os`/`shutil` para estado del entorno, recorrido, metadatos, copia, movimiento, eliminación recursiva, capacidades de plataforma y seguridad de archives, en inglés, portugués de Brasil y español con ejemplos ejecutables deterministas. From c5e73ea06e34814457ab278eb719da64e75d7dcf Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Tue, 1 Sep 2026 07:33:53 -0300 Subject: [PATCH 045/117] Normalize Spanish project structure wording --- docs/project-structure.es.md | 573 +---------------------------------- 1 file changed, 16 insertions(+), 557 deletions(-) diff --git a/docs/project-structure.es.md b/docs/project-structure.es.md index ace7970..e5422f5 100644 --- a/docs/project-structure.es.md +++ b/docs/project-structure.es.md @@ -24,385 +24,22 @@ python-study-guide/ ├── SUPPORT.md ├── assets/ ├── comments-and-documentation/ -│ ├── README.md -│ ├── README.pt-BR.md -│ ├── README.es.md -│ ├── 01-comments/ -│ │ ├── README.md -│ │ ├── README.pt-BR.md -│ │ ├── README.es.md -│ │ └── examples/ -│ │ ├── business_rule_comments.py -│ │ ├── unnecessary_comments.py -│ │ └── useful_comments.py -│ ├── 02-docstrings/ -│ │ ├── README.md -│ │ ├── README.pt-BR.md -│ │ ├── README.es.md -│ │ └── examples/ -│ │ ├── class_docstrings.py -│ │ ├── function_docstrings.py -│ │ └── inspect_docstrings.py -│ ├── 03-meaningful-names/ -│ │ ├── README.md -│ │ ├── README.pt-BR.md -│ │ ├── README.es.md -│ │ └── examples/ -│ │ ├── booleans_and_units.py -│ │ ├── refactor_for_intent.py -│ │ └── vague_and_clear_names.py -│ ├── 04-task-markers/ -│ │ ├── README.md -│ │ ├── README.pt-BR.md -│ │ ├── README.es.md -│ │ └── examples/ -│ │ ├── actionable_markers.py -│ │ ├── scan_markers.py -│ │ └── temporary_workaround.py -│ ├── 05-comments-vs-logging/ -│ │ ├── README.md -│ │ ├── README.pt-BR.md -│ │ ├── README.es.md -│ │ └── examples/ -│ │ ├── application_and_library_logging.py -│ │ ├── comments_vs_logging.py -│ │ └── logging_levels.py -│ └── 06-pep8-and-readability/ -│ ├── README.md -│ ├── README.pt-BR.md -│ ├── README.es.md -│ └── examples/ -│ ├── imports_and_names.py -│ ├── readable_layout.py -│ └── refactor_for_readability.py ├── collections/ -│ ├── README.md -│ ├── README.pt-BR.md -│ ├── README.es.md -│ ├── 01-list-creation-and-indexing/ -│ │ ├── README.md -│ │ ├── README.pt-BR.md -│ │ ├── README.es.md -│ │ └── examples/ -│ │ ├── list_basics.py -│ │ └── list_slicing.py -│ ├── 02-modifying-lists-and-methods/ -│ │ ├── README.md -│ │ ├── README.pt-BR.md -│ │ ├── README.es.md -│ │ └── examples/ -│ │ ├── list_copying.py -│ │ ├── list_methods.py -│ │ └── list_mutation.py -│ ├── 03-tuples-and-immutability/ -│ │ ├── README.md -│ │ ├── README.pt-BR.md -│ │ ├── README.es.md -│ │ └── examples/ -│ │ ├── tuple_basics.py -│ │ ├── tuple_mutable_item.py -│ │ └── tuple_unpacking.py -│ ├── 04-dictionaries-keys-and-values/ -│ │ ├── README.md -│ │ ├── README.pt-BR.md -│ │ ├── README.es.md -│ │ └── examples/ -│ │ ├── dictionary_basics.py -│ │ ├── dictionary_mutation.py -│ │ └── dictionary_views.py -│ ├── 05-sets-and-unique-values/ -│ │ ├── README.md -│ │ ├── README.pt-BR.md -│ │ ├── README.es.md -│ │ └── examples/ -│ │ ├── set_basics.py -│ │ ├── set_mutation.py -│ │ └── set_operations.py -│ └── 06-choosing-the-right-collection/ -│ ├── README.md -│ ├── README.pt-BR.md -│ ├── README.es.md -│ └── examples/ -│ ├── collection_models.py -│ ├── collection_tradeoffs.py -│ └── study_workspace.py ├── docs/ -│ ├── ai-assisted-development/ -│ ├── localized/ -│ ├── learning-path.en.md -│ ├── learning-path.pt-BR.md -│ ├── learning-path.es.md -│ ├── project-structure.en.md -│ ├── project-structure.pt-BR.md -│ ├── project-structure.es.md -│ ├── roadmap.en.md -│ ├── roadmap.pt-BR.md -│ └── roadmap.es.md ├── errors-files-and-modules/ -│ ├── README.md -│ ├── README.pt-BR.md -│ ├── README.es.md -│ ├── 01-try-except-else-finally/ -│ │ ├── README.md -│ │ ├── README.pt-BR.md -│ │ ├── README.es.md -│ │ └── examples/ -│ │ ├── parse_integer.py -│ │ ├── safe_divide.py -│ │ └── trace_try_else_finally.py -│ ├── 02-raise-and-custom-exceptions/ -│ │ ├── README.md -│ │ ├── README.pt-BR.md -│ │ ├── README.es.md -│ │ └── examples/ -│ │ ├── custom_exception.py -│ │ ├── exception_chaining.py -│ │ └── validate_score.py -│ ├── 03-open-and-with/ -│ │ ├── README.md -│ │ ├── README.pt-BR.md -│ │ ├── README.es.md -│ │ └── examples/ -│ │ ├── append_text.py -│ │ ├── handle_missing_file.py -│ │ └── write_and_read_text.py -│ ├── 04-txt-csv-and-json/ -│ │ ├── README.md -│ │ ├── README.pt-BR.md -│ │ ├── README.es.md -│ │ └── examples/ -│ │ ├── csv_records.py -│ │ ├── handle_invalid_json.py -│ │ ├── json_document.py -│ │ └── text_records.py -│ └── 05-imports-modules-and-packages/ -│ ├── README.md -│ ├── README.pt-BR.md -│ ├── README.es.md -│ └── examples/ -│ ├── grade_tools.py -│ ├── import_standard_library.py -│ ├── main_guard.py -│ ├── module_demo.py -│ ├── package_demo.py -│ └── study_tools/ -│ ├── __init__.py -│ └── formatting.py ├── exercises/ ├── external-libraries/ -│ ├── README.md -│ ├── README.pt-BR.md -│ ├── README.es.md -│ ├── 01-pandas/ -│ │ ├── README.md -│ │ ├── README.pt-BR.md -│ │ ├── README.es.md -│ │ └── examples/ -│ │ ├── csv_pipeline.py -│ │ ├── dataframe_basics.py -│ │ ├── filter_and_assign.py -│ │ ├── groupby_summary.py -│ │ └── merge_tables.py -│ ├── 02-openpyxl/ -│ │ ├── README.md -│ │ ├── README.pt-BR.md -│ │ ├── README.es.md -│ │ └── examples/ -│ │ ├── load_and_iterate.py -│ │ ├── styled_report.py -│ │ ├── table_and_validation.py -│ │ ├── workbook_basics.py -│ │ └── write_only_export.py -│ ├── 03-requests/ -│ │ ├── README.md -│ │ ├── README.pt-BR.md -│ │ ├── README.es.md -│ │ └── examples/ -│ │ ├── get_with_query.py -│ │ ├── http_error_handling.py -│ │ ├── post_json.py -│ │ ├── session_defaults.py -│ │ └── stream_download.py -│ └── 04-pytest/ -│ ├── README.md -│ ├── README.pt-BR.md -│ ├── README.es.md -│ └── examples/ -│ ├── assertions_and_parametrize.py -│ ├── capture_output_and_logs.py -│ ├── exceptions_and_warnings.py -│ ├── fixtures_and_tmp_path.py -│ └── monkeypatch_environment.py ├── functions/ -│ ├── README.md -│ ├── README.pt-BR.md -│ ├── README.es.md -│ ├── 01-defining-and-calling-functions/ -│ │ ├── README.md -│ │ ├── README.pt-BR.md -│ │ ├── README.es.md -│ │ └── examples/ -│ │ ├── define_and_call.py -│ │ ├── execution_order.py -│ │ └── repeated_calls.py -│ ├── 02-parameters-and-arguments/ -│ │ ├── README.md -│ │ ├── README.pt-BR.md -│ │ ├── README.es.md -│ │ └── examples/ -│ │ ├── book_details.py -│ │ ├── greet_people.py -│ │ └── score_status.py -│ ├── 03-return-values/ -│ │ ├── README.md -│ │ ├── README.pt-BR.md -│ │ ├── README.es.md -│ │ └── examples/ -│ │ ├── calculate_total.py -│ │ ├── classify_score.py -│ │ └── find_first_even.py -│ ├── 04-scope/ -│ │ ├── README.md -│ │ ├── README.pt-BR.md -│ │ ├── README.es.md -│ │ └── examples/ -│ │ ├── local_and_global_names.py -│ │ ├── separate_function_calls.py -│ │ └── shadowing_names.py -│ ├── 05-type-hints/ -│ │ ├── README.md -│ │ ├── README.pt-BR.md -│ │ ├── README.es.md -│ │ └── examples/ -│ │ ├── annotated_greeting.py -│ │ ├── collection_summary.py -│ │ └── runtime_does_not_enforce.py -│ ├── 06-default-values/ -│ │ ├── README.md -│ │ ├── README.pt-BR.md -│ │ ├── README.es.md -│ │ └── examples/ -│ │ ├── greet_with_style.py -│ │ ├── safe_list_default.py -│ │ └── shipping_quote.py -│ ├── 07-args-and-kwargs/ -│ │ ├── README.md -│ │ ├── README.pt-BR.md -│ │ ├── README.es.md -│ │ └── examples/ -│ │ ├── calculate_average.py -│ │ ├── describe_session.py -│ │ └── display_settings.py -│ ├── 08-functions-working-together/ -│ │ ├── README.md -│ │ ├── README.pt-BR.md -│ │ ├── README.es.md -│ │ └── examples/ -│ │ ├── build_score_report.py -│ │ ├── build_study_summary.py -│ │ └── prepare_greeting.py -│ └── 09-data-flow-between-functions/ -│ ├── README.md -│ ├── README.pt-BR.md -│ ├── README.es.md -│ └── examples/ -│ ├── build_learning_report.py -│ ├── rebinding_and_mutation.py -│ └── trace_score_pipeline.py ├── fundamentals/ -│ ├── README.md -│ ├── README.pt-BR.md -│ ├── README.es.md -│ ├── 01-how-python-runs-a-program/ -│ │ ├── README.md -│ │ ├── README.pt-BR.md -│ │ ├── README.es.md -│ │ └── examples/ -│ │ └── hello_world.py -│ ├── 02-print-and-input/ -│ │ ├── README.md -│ │ ├── README.pt-BR.md -│ │ ├── README.es.md -│ │ └── examples/ -│ │ ├── interactive_greeting.py -│ │ └── output_basics.py -│ ├── 03-variables-and-naming/ -│ │ ├── README.md -│ │ ├── README.pt-BR.md -│ │ ├── README.es.md -│ │ └── examples/ -│ │ ├── learning_profile.py -│ │ └── variable_basics.py -│ ├── 04-built-in-data-types/ -│ │ ├── README.md -│ │ ├── README.pt-BR.md -│ │ ├── README.es.md -│ │ └── examples/ -│ │ ├── same_looking_values.py -│ │ └── value_catalog.py -│ ├── 05-type-and-isinstance/ -│ │ ├── README.md -│ │ ├── README.pt-BR.md -│ │ ├── README.es.md -│ │ └── examples/ -│ │ ├── check_type_families.py -│ │ └── inspect_types.py -│ └── 06-type-conversion/ -│ ├── README.md -│ ├── README.pt-BR.md -│ ├── README.es.md -│ └── examples/ -│ ├── conversion_basics.py -│ └── conversion_surprises.py ├── practical-projects/ │ ├── README.md │ ├── README.pt-BR.md │ ├── README.es.md │ ├── 01-expense-tracker/ -│ │ ├── README.md -│ │ ├── README.pt-BR.md -│ │ ├── README.es.md -│ │ ├── demo.py -│ │ ├── expense_tracker.py -│ │ └── tests/ -│ │ ├── conftest.py -│ │ └── test_expense_tracker.py │ ├── 02-grade-calculator/ -│ │ ├── README.md -│ │ ├── README.pt-BR.md -│ │ ├── README.es.md -│ │ ├── demo.py -│ │ ├── grade_calculator.py -│ │ └── tests/ -│ │ ├── conftest.py -│ │ └── test_grade_calculator.py │ ├── 03-user-registration/ -│ │ ├── README.md -│ │ ├── README.pt-BR.md -│ │ ├── README.es.md -│ │ ├── demo.py -│ │ ├── user_registration.py -│ │ └── tests/ -│ │ ├── conftest.py -│ │ └── test_user_registration.py │ ├── 04-csv-analyzer/ -│ │ ├── README.md -│ │ ├── README.pt-BR.md -│ │ ├── README.es.md -│ │ ├── csv_analyzer.py -│ │ ├── demo.py -│ │ └── tests/ -│ │ ├── conftest.py -│ │ └── test_csv_analyzer.py │ ├── 05-report-generator/ -│ │ ├── README.md -│ │ ├── README.pt-BR.md -│ │ ├── README.es.md -│ │ ├── demo.py -│ │ ├── report_generator.py -│ │ └── tests/ -│ │ ├── conftest.py -│ │ └── test_report_generator.py │ └── 06-file-organizer/ │ ├── README.md │ ├── README.pt-BR.md @@ -414,198 +51,18 @@ python-study-guide/ │ ├── test_atomic_move.py │ └── test_file_organizer.py ├── program-flow/ -│ ├── README.md -│ ├── README.pt-BR.md -│ ├── README.es.md -│ ├── 01-conditions-comparisons-and-boolean-logic/ -│ │ ├── README.md -│ │ ├── README.pt-BR.md -│ │ ├── README.es.md -│ │ └── examples/ -│ │ ├── boolean_logic.py -│ │ ├── comparison_results.py -│ │ └── truth_values.py -│ ├── 02-if-elif-and-else/ -│ │ ├── README.md -│ │ ├── README.pt-BR.md -│ │ ├── README.es.md -│ │ └── examples/ -│ │ ├── basic_if.py -│ │ ├── if_elif_else.py -│ │ └── independent_conditions.py -│ ├── 03-match-and-case/ -│ │ ├── README.md -│ │ ├── README.pt-BR.md -│ │ ├── README.es.md -│ │ └── examples/ -│ │ ├── literal_and_or_patterns.py -│ │ ├── mapping_patterns_and_guards.py -│ │ └── sequence_patterns.py -│ ├── 04-for-loops-and-iteration/ -│ │ ├── README.md -│ │ ├── README.pt-BR.md -│ │ ├── README.es.md -│ │ └── examples/ -│ │ ├── collection_iteration.py -│ │ ├── dictionary_iteration.py -│ │ └── filter_and_collect.py -│ ├── 05-range-enumerate-and-zip/ -│ │ ├── README.md -│ │ ├── README.pt-BR.md -│ │ ├── README.es.md -│ │ └── examples/ -│ │ ├── enumerate_positions.py -│ │ ├── range_progressions.py -│ │ └── zip_parallel_iteration.py -│ ├── 06-while-loops-and-state-driven-repetition/ -│ │ ├── README.md -│ │ ├── README.pt-BR.md -│ │ ├── README.es.md -│ │ └── examples/ -│ │ ├── countdown_state.py -│ │ ├── doubling_until_limit.py -│ │ └── study_target.py -│ ├── 07-break-continue-and-loop-else/ -│ │ ├── README.md -│ │ ├── README.pt-BR.md -│ │ ├── README.es.md -│ │ └── examples/ -│ │ ├── break_search.py -│ │ ├── continue_filtering.py -│ │ └── loop_else_search.py -│ └── 08-choosing-and-combining-program-flow/ -│ ├── README.md -│ ├── README.pt-BR.md -│ ├── README.es.md -│ └── examples/ -│ ├── search_with_position.py -│ ├── select_and_classify.py -│ └── state_driven_workflow.py ├── scripts/ │ ├── check_internal_links.py │ ├── example_manifest.txt │ ├── run_examples.py │ └── validate_repository_structure.py ├── standard-library/ -│ ├── README.md -│ ├── README.pt-BR.md -│ ├── README.es.md -│ ├── 01-pathlib/ -│ │ ├── README.md -│ │ ├── README.pt-BR.md -│ │ ├── README.es.md -│ │ └── examples/ -│ │ ├── discover_python_files.py -│ │ ├── inspect_paths.py -│ │ ├── path_parts.py -│ │ └── text_workspace.py -│ ├── 02-datetime/ -│ │ ├── README.md -│ │ ├── README.pt-BR.md -│ │ ├── README.es.md -│ │ └── examples/ -│ │ ├── date_arithmetic.py -│ │ ├── duration_seconds.py -│ │ ├── parse_and_format.py -│ │ └── utc_conversion.py -│ ├── 03-json/ -│ │ ├── README.md -│ │ ├── README.pt-BR.md -│ │ ├── README.es.md -│ │ └── examples/ -│ │ ├── decimal_decode.py -│ │ ├── deterministic_json.py -│ │ ├── reject_duplicate_keys.py -│ │ └── strict_numbers.py -│ ├── 04-csv/ -│ │ ├── README.md -│ │ ├── README.pt-BR.md -│ │ ├── README.es.md -│ │ └── examples/ -│ │ ├── dialect_round_trip.py -│ │ ├── quote_none_escape.py -│ │ ├── sniff_delimiter.py -│ │ └── validate_dict_rows.py -│ ├── 05-logging/ -│ │ ├── README.md -│ │ ├── README.pt-BR.md -│ │ ├── README.es.md -│ │ └── examples/ -│ │ ├── context_filter.py -│ │ ├── dict_config_routing.py -│ │ ├── queue_listener.py -│ │ └── stacklevel_helper.py -│ ├── 06-collections/ -│ │ ├── README.md -│ │ ├── README.pt-BR.md -│ │ ├── README.es.md -│ │ └── examples/ -│ │ ├── bounded_deque.py -│ │ ├── chainmap_config.py -│ │ ├── counter_inventory.py -│ │ └── defaultdict_grouping.py -│ ├── 07-itertools/ -│ │ ├── README.md -│ │ ├── README.pt-BR.md -│ │ ├── README.es.md -│ │ └── examples/ -│ │ ├── combinatoric_options.py -│ │ ├── groupby_runs.py -│ │ ├── lazy_pipeline.py -│ │ └── pairwise_deltas.py -│ ├── 08-decimal/ -│ │ ├── README.md -│ │ ├── README.pt-BR.md -│ │ ├── README.es.md -│ │ └── examples/ -│ │ ├── exact_amounts.py -│ │ ├── local_context_precision.py -│ │ ├── monitor_rounding.py -│ │ └── validate_scale.py -│ └── 09-os-shutil/ -│ ├── README.md -│ ├── README.pt-BR.md -│ ├── README.es.md -│ └── examples/ -│ ├── copy_tree_and_move.py -│ ├── environment_contract.py -│ ├── scan_directory.py -│ └── walk_with_pruning.py ├── strings-and-numbers/ -│ ├── README.md -│ ├── README.pt-BR.md -│ ├── README.es.md -│ ├── 01-string-creation-and-indexing/ -│ │ ├── README.md -│ │ ├── README.pt-BR.md -│ │ ├── README.es.md -│ │ └── examples/ -│ │ ├── fixed_position_text.py -│ │ └── string_basics.py -│ ├── 02-common-string-methods/ -│ │ ├── README.md -│ │ ├── README.pt-BR.md -│ │ ├── README.es.md -│ │ └── examples/ -│ │ ├── normalize_text.py -│ │ └── split_and_join.py -│ ├── 03-int-float-and-bool/ -│ │ ├── README.md -│ │ ├── README.pt-BR.md -│ │ ├── README.es.md -│ │ └── examples/ -│ │ ├── numeric_behavior.py -│ │ └── truth_and_precision.py -│ └── 04-numeric-builtins/ -│ ├── README.md -│ ├── README.pt-BR.md -│ ├── README.es.md -│ └── examples/ -│ ├── numeric_summary.py -│ └── rounding_behavior.py └── tests/ ``` +El mapa anterior resume las rutas de primer nivel y expande el área de la Fase 10 que cambia en este pull request. Los capítulos internos de las fases anteriores permanecen documentados por sus índices de sección y rutas de aprendizaje. + ## Guía de los archivos raíz - `AGENTS.md`: instrucciones generales del repositorio para colaboradores y agentes de IA. @@ -621,20 +78,20 @@ python-study-guide/ - `.github/`: configuración de colaboración, formularios de issue, plantilla de pull request y workflow de GitHub Actions. - `assets/`: identidad visual original, recursos exportados, composiciones editables, paleta, accesibilidad y reglas de uso. -- `comments-and-documentation/`: ruta completa de la Fase 6. Hay capítulos revisados sobre comentarios, docstrings, nombres significativos, marcadores de tareas, comentarios frente a logging y PEP 8 y legibilidad, cada uno en inglés, portugués de Brasil y español, con ejemplos ejecutables seguros. -- `collections/`: ruta completa de la Fase 3. Sus seis capítulos enseñan creación, lectura, mutación y métodos comunes de listas, copia superficial, tuplas e inmutabilidad, mappings clave-valor y vistas de diccionarios, unicidad y relaciones de conjuntos y cómo elegir entre listas, tuplas, diccionarios y conjuntos según la intención, en inglés, portugués de Brasil y español, con ejemplos ejecutables seguros. -- `docs/`: rutas completas de aprendizaje, roadmaps, arquitectura del proyecto, documentos localizados, políticas y guía de desarrollo responsable asistido por IA. -- `errors-files-and-modules/`: ruta completa de la Fase 7. Los Capítulos 01–05 cubren manejo de excepciones en runtime, lanzamiento deliberado y excepciones personalizadas, I/O seguro de archivos de texto con `open()` y `with`, parsing y escritura de TXT/CSV/JSON y organización del código mediante imports, módulos, paquetes regulares, contexto de ejecución y diseño de dependencias, en inglés, portugués de Brasil y español con ejemplos ejecutables deterministas. +- `comments-and-documentation/`: ruta completa de la Fase 6, con capítulos revisados en inglés, portugués de Brasil y español y ejemplos ejecutables seguros. +- `collections/`: ruta completa de la Fase 3 sobre listas, tuplas, diccionarios, conjuntos y elección de colecciones. +- `docs/`: rutas completas de aprendizaje, roadmaps, estructura del proyecto, documentos localizados, políticas y guía de desarrollo responsable asistido por IA. +- `errors-files-and-modules/`: ruta completa de la Fase 7 sobre excepciones, archivos, formatos TXT/CSV/JSON, imports, módulos y paquetes. - `exercises/`: actividades prácticas relacionadas con los capítulos. -- `external-libraries/`: ruta completa de la Fase 9 para paquetes de terceros. Contiene capítulos multilingües revisados de pandas 3.0.x, openpyxl 3.1.x, Requests 2.34.x y pytest 9.1.x, con veinte ejemplos ejecutables deterministas en total. La fase cubre transformaciones tabulares, automatización de libros de Excel, clientes HTTP/API y contratos de pruebas automatizadas; la Fase 10 de proyectos prácticos viene a continuación. -- `functions/`: ruta completa de la Fase 5. Los Capítulos 01–09 cubren definición y llamada de funciones, entradas obligatorias, valores retornados, alcance y búsqueda de nombres, type hints para interfaces de funciones, valores predeterminados incluida la evaluación al definir la función y la seguridad con valores mutables, recolección de argumentos posicionales y por palabra clave de cantidad variable con `*args` y `**kwargs`, composición mediante funciones auxiliares y coordinadoras con dependencias explícitas y grafos simples de llamadas, y seguimiento explícito del flujo de datos entre llamadas, incluidos vínculos de parámetros, reasignación frente a mutación, `None`, resultados en tupla y traspasos mediante `return`, en inglés, portugués de Brasil y español, con ejemplos ejecutables determinísticos. -- `fundamentals/`: ruta completa de la Fase 1. Sus seis capítulos enseñan cómo Python ejecuta un programa, cómo usar `print()` e `input()`, cómo funcionan la asignación y los nombres, cómo reconocer e inspeccionar tipos de datos incorporados comunes y cómo convertir valores compatibles de forma deliberada, con explicaciones multilingües alineadas y ejemplos ejecutables. -- `practical-projects/`: espacio de Proyectos Prácticos de la Fase 10. Los Proyectos 01–05 están completados y el Proyecto 06 Organizador de Archivos está en progreso. El Proyecto 06 añade descubrimiento superficial determinista, planificación inmutable, políticas de colisión, fronteras de symlink, verificaciones de identidad del filesystem, directorios anclados por descriptors, nombres de staging acotados, commit atómico no-replace en Linux, demo determinista y pruebas de regresión enfocadas. -- `program-flow/`: ruta completa de la Fase 4. Los Capítulos 01–08 enseñan condiciones, comparaciones, pruebas de valor de verdad, pertenencia, identidad, lógica booleana, ramificación condicional con `if`, `elif` y `else`, coincidencia de patrones estructurales, repetición guiada por iterables con `for`, progresiones numéricas con `range()`, iteración con posición usando `enumerate()`, iteración paralela con `zip()` incluida la validación explícita de longitudes iguales con `strict=True`, repetición guiada por estado con `while`, control deliberado de bucles con `break`, `continue` y `else` de bucle y cómo elegir y combinar herramientas de flujo del programa según la intención, en inglés, portugués de Brasil y español, con ejemplos ejecutables determinísticos. +- `external-libraries/`: ruta completa de la Fase 9 para pandas, openpyxl, Requests y pytest, con ejemplos deterministas y contrato explícito de dependencias. +- `functions/`: ruta completa de la Fase 5 sobre definición, parámetros, retornos, alcance, type hints, defaults, `*args`, `**kwargs`, composición y flujo de datos. +- `fundamentals/`: ruta completa de la Fase 1 sobre ejecución, entrada/salida, variables, tipos e conversión. +- `practical-projects/`: espacio de la Fase 10. Los Proyectos 01–05 están completados y el Proyecto 06 Organizador de Archivos está en progreso. El Proyecto 06 añade descubrimiento superficial determinista, planificación inmutable, políticas de colisión, fronteras de symlink, identidad `(device, inode)`, directorios raíz/categoría anclados, nombres de staging acotados, commit atómico no-replace en Linux con `renameat2(RENAME_NOREPLACE)`, demo determinista y pruebas de regresión enfocadas. +- `program-flow/`: ruta completa de la Fase 4 sobre condiciones, branching, pattern matching, loops y herramientas de iteración. - `scripts/`: herramientas de mantenimiento sin dependencias externas utilizadas localmente y por GitHub Actions. -- `standard-library/`: ruta completa de la Fase 8. Los Capítulos 01–09 cubren fronteras de filesystem con `pathlib`, modelado de fecha/hora con `datetime`, contratos avanzados de `json` y `csv`, `logging`, `collections` especializadas, `itertools`, `decimal` y contratos de `os`/`shutil` para estado del entorno, recorrido, metadatos, copia, movimiento, eliminación recursiva, capacidades de plataforma y seguridad de archives, en inglés, portugués de Brasil y español con ejemplos ejecutables deterministas. -- `strings-and-numbers/`: ruta completa de la Fase 2. Sus cuatro capítulos revisados cubren creación e indexación de strings, métodos comunes, comportamiento de enteros, punto flotante y booleanos, precisión de punto flotante y `round()`, `abs()`, `min()`, `max()` y `sum()` en inglés, portugués de Brasil y español, con ejemplos ejecutables seguros. -- `tests/`: pruebas de regresión de las herramientas de calidad y, más adelante, del contenido educativo. +- `standard-library/`: ruta completa de la Fase 8 sobre `pathlib`, `datetime`, JSON, CSV, logging, collections, itertools, decimal y `os`/`shutil`. +- `strings-and-numbers/`: ruta completa de la Fase 2 sobre strings, números, booleanos, precisión y funciones numéricas incorporadas. +- `tests/`: pruebas de regresión de las herramientas de calidad y del contenido educativo cuando corresponda. ## Regla de los directorios de capítulos @@ -645,6 +102,8 @@ Cada capítulo de aprendizaje contiene: - un directorio `examples/` cuando los ejemplos ejecutables mejoran el tema; - solamente material completo y revisable, sin placeholders vacíos. +Los proyectos prácticos pueden añadir módulos de implementación, `demo.py` y una carpeta `tests/` cuando el proyecto necesita una superficie ejecutable y cobertura automatizada propia. + ## Convenciones de nombres e idiomas Los directorios, archivos, variables, funciones, clases y demás identificadores utilizan inglés. Los documentos explicativos se ofrecen en inglés, portugués de Brasil y español. From d910c5d10e225ab30f37c55a21d2107108b23b0e Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Tue, 1 Sep 2026 07:36:44 -0300 Subject: [PATCH 046/117] Restore full Spanish project structure detail --- docs/project-structure.es.md | 573 ++++++++++++++++++++++++++++++++++- 1 file changed, 557 insertions(+), 16 deletions(-) diff --git a/docs/project-structure.es.md b/docs/project-structure.es.md index e5422f5..ace7970 100644 --- a/docs/project-structure.es.md +++ b/docs/project-structure.es.md @@ -24,22 +24,385 @@ python-study-guide/ ├── SUPPORT.md ├── assets/ ├── comments-and-documentation/ +│ ├── README.md +│ ├── README.pt-BR.md +│ ├── README.es.md +│ ├── 01-comments/ +│ │ ├── README.md +│ │ ├── README.pt-BR.md +│ │ ├── README.es.md +│ │ └── examples/ +│ │ ├── business_rule_comments.py +│ │ ├── unnecessary_comments.py +│ │ └── useful_comments.py +│ ├── 02-docstrings/ +│ │ ├── README.md +│ │ ├── README.pt-BR.md +│ │ ├── README.es.md +│ │ └── examples/ +│ │ ├── class_docstrings.py +│ │ ├── function_docstrings.py +│ │ └── inspect_docstrings.py +│ ├── 03-meaningful-names/ +│ │ ├── README.md +│ │ ├── README.pt-BR.md +│ │ ├── README.es.md +│ │ └── examples/ +│ │ ├── booleans_and_units.py +│ │ ├── refactor_for_intent.py +│ │ └── vague_and_clear_names.py +│ ├── 04-task-markers/ +│ │ ├── README.md +│ │ ├── README.pt-BR.md +│ │ ├── README.es.md +│ │ └── examples/ +│ │ ├── actionable_markers.py +│ │ ├── scan_markers.py +│ │ └── temporary_workaround.py +│ ├── 05-comments-vs-logging/ +│ │ ├── README.md +│ │ ├── README.pt-BR.md +│ │ ├── README.es.md +│ │ └── examples/ +│ │ ├── application_and_library_logging.py +│ │ ├── comments_vs_logging.py +│ │ └── logging_levels.py +│ └── 06-pep8-and-readability/ +│ ├── README.md +│ ├── README.pt-BR.md +│ ├── README.es.md +│ └── examples/ +│ ├── imports_and_names.py +│ ├── readable_layout.py +│ └── refactor_for_readability.py ├── collections/ +│ ├── README.md +│ ├── README.pt-BR.md +│ ├── README.es.md +│ ├── 01-list-creation-and-indexing/ +│ │ ├── README.md +│ │ ├── README.pt-BR.md +│ │ ├── README.es.md +│ │ └── examples/ +│ │ ├── list_basics.py +│ │ └── list_slicing.py +│ ├── 02-modifying-lists-and-methods/ +│ │ ├── README.md +│ │ ├── README.pt-BR.md +│ │ ├── README.es.md +│ │ └── examples/ +│ │ ├── list_copying.py +│ │ ├── list_methods.py +│ │ └── list_mutation.py +│ ├── 03-tuples-and-immutability/ +│ │ ├── README.md +│ │ ├── README.pt-BR.md +│ │ ├── README.es.md +│ │ └── examples/ +│ │ ├── tuple_basics.py +│ │ ├── tuple_mutable_item.py +│ │ └── tuple_unpacking.py +│ ├── 04-dictionaries-keys-and-values/ +│ │ ├── README.md +│ │ ├── README.pt-BR.md +│ │ ├── README.es.md +│ │ └── examples/ +│ │ ├── dictionary_basics.py +│ │ ├── dictionary_mutation.py +│ │ └── dictionary_views.py +│ ├── 05-sets-and-unique-values/ +│ │ ├── README.md +│ │ ├── README.pt-BR.md +│ │ ├── README.es.md +│ │ └── examples/ +│ │ ├── set_basics.py +│ │ ├── set_mutation.py +│ │ └── set_operations.py +│ └── 06-choosing-the-right-collection/ +│ ├── README.md +│ ├── README.pt-BR.md +│ ├── README.es.md +│ └── examples/ +│ ├── collection_models.py +│ ├── collection_tradeoffs.py +│ └── study_workspace.py ├── docs/ +│ ├── ai-assisted-development/ +│ ├── localized/ +│ ├── learning-path.en.md +│ ├── learning-path.pt-BR.md +│ ├── learning-path.es.md +│ ├── project-structure.en.md +│ ├── project-structure.pt-BR.md +│ ├── project-structure.es.md +│ ├── roadmap.en.md +│ ├── roadmap.pt-BR.md +│ └── roadmap.es.md ├── errors-files-and-modules/ +│ ├── README.md +│ ├── README.pt-BR.md +│ ├── README.es.md +│ ├── 01-try-except-else-finally/ +│ │ ├── README.md +│ │ ├── README.pt-BR.md +│ │ ├── README.es.md +│ │ └── examples/ +│ │ ├── parse_integer.py +│ │ ├── safe_divide.py +│ │ └── trace_try_else_finally.py +│ ├── 02-raise-and-custom-exceptions/ +│ │ ├── README.md +│ │ ├── README.pt-BR.md +│ │ ├── README.es.md +│ │ └── examples/ +│ │ ├── custom_exception.py +│ │ ├── exception_chaining.py +│ │ └── validate_score.py +│ ├── 03-open-and-with/ +│ │ ├── README.md +│ │ ├── README.pt-BR.md +│ │ ├── README.es.md +│ │ └── examples/ +│ │ ├── append_text.py +│ │ ├── handle_missing_file.py +│ │ └── write_and_read_text.py +│ ├── 04-txt-csv-and-json/ +│ │ ├── README.md +│ │ ├── README.pt-BR.md +│ │ ├── README.es.md +│ │ └── examples/ +│ │ ├── csv_records.py +│ │ ├── handle_invalid_json.py +│ │ ├── json_document.py +│ │ └── text_records.py +│ └── 05-imports-modules-and-packages/ +│ ├── README.md +│ ├── README.pt-BR.md +│ ├── README.es.md +│ └── examples/ +│ ├── grade_tools.py +│ ├── import_standard_library.py +│ ├── main_guard.py +│ ├── module_demo.py +│ ├── package_demo.py +│ └── study_tools/ +│ ├── __init__.py +│ └── formatting.py ├── exercises/ ├── external-libraries/ +│ ├── README.md +│ ├── README.pt-BR.md +│ ├── README.es.md +│ ├── 01-pandas/ +│ │ ├── README.md +│ │ ├── README.pt-BR.md +│ │ ├── README.es.md +│ │ └── examples/ +│ │ ├── csv_pipeline.py +│ │ ├── dataframe_basics.py +│ │ ├── filter_and_assign.py +│ │ ├── groupby_summary.py +│ │ └── merge_tables.py +│ ├── 02-openpyxl/ +│ │ ├── README.md +│ │ ├── README.pt-BR.md +│ │ ├── README.es.md +│ │ └── examples/ +│ │ ├── load_and_iterate.py +│ │ ├── styled_report.py +│ │ ├── table_and_validation.py +│ │ ├── workbook_basics.py +│ │ └── write_only_export.py +│ ├── 03-requests/ +│ │ ├── README.md +│ │ ├── README.pt-BR.md +│ │ ├── README.es.md +│ │ └── examples/ +│ │ ├── get_with_query.py +│ │ ├── http_error_handling.py +│ │ ├── post_json.py +│ │ ├── session_defaults.py +│ │ └── stream_download.py +│ └── 04-pytest/ +│ ├── README.md +│ ├── README.pt-BR.md +│ ├── README.es.md +│ └── examples/ +│ ├── assertions_and_parametrize.py +│ ├── capture_output_and_logs.py +│ ├── exceptions_and_warnings.py +│ ├── fixtures_and_tmp_path.py +│ └── monkeypatch_environment.py ├── functions/ +│ ├── README.md +│ ├── README.pt-BR.md +│ ├── README.es.md +│ ├── 01-defining-and-calling-functions/ +│ │ ├── README.md +│ │ ├── README.pt-BR.md +│ │ ├── README.es.md +│ │ └── examples/ +│ │ ├── define_and_call.py +│ │ ├── execution_order.py +│ │ └── repeated_calls.py +│ ├── 02-parameters-and-arguments/ +│ │ ├── README.md +│ │ ├── README.pt-BR.md +│ │ ├── README.es.md +│ │ └── examples/ +│ │ ├── book_details.py +│ │ ├── greet_people.py +│ │ └── score_status.py +│ ├── 03-return-values/ +│ │ ├── README.md +│ │ ├── README.pt-BR.md +│ │ ├── README.es.md +│ │ └── examples/ +│ │ ├── calculate_total.py +│ │ ├── classify_score.py +│ │ └── find_first_even.py +│ ├── 04-scope/ +│ │ ├── README.md +│ │ ├── README.pt-BR.md +│ │ ├── README.es.md +│ │ └── examples/ +│ │ ├── local_and_global_names.py +│ │ ├── separate_function_calls.py +│ │ └── shadowing_names.py +│ ├── 05-type-hints/ +│ │ ├── README.md +│ │ ├── README.pt-BR.md +│ │ ├── README.es.md +│ │ └── examples/ +│ │ ├── annotated_greeting.py +│ │ ├── collection_summary.py +│ │ └── runtime_does_not_enforce.py +│ ├── 06-default-values/ +│ │ ├── README.md +│ │ ├── README.pt-BR.md +│ │ ├── README.es.md +│ │ └── examples/ +│ │ ├── greet_with_style.py +│ │ ├── safe_list_default.py +│ │ └── shipping_quote.py +│ ├── 07-args-and-kwargs/ +│ │ ├── README.md +│ │ ├── README.pt-BR.md +│ │ ├── README.es.md +│ │ └── examples/ +│ │ ├── calculate_average.py +│ │ ├── describe_session.py +│ │ └── display_settings.py +│ ├── 08-functions-working-together/ +│ │ ├── README.md +│ │ ├── README.pt-BR.md +│ │ ├── README.es.md +│ │ └── examples/ +│ │ ├── build_score_report.py +│ │ ├── build_study_summary.py +│ │ └── prepare_greeting.py +│ └── 09-data-flow-between-functions/ +│ ├── README.md +│ ├── README.pt-BR.md +│ ├── README.es.md +│ └── examples/ +│ ├── build_learning_report.py +│ ├── rebinding_and_mutation.py +│ └── trace_score_pipeline.py ├── fundamentals/ +│ ├── README.md +│ ├── README.pt-BR.md +│ ├── README.es.md +│ ├── 01-how-python-runs-a-program/ +│ │ ├── README.md +│ │ ├── README.pt-BR.md +│ │ ├── README.es.md +│ │ └── examples/ +│ │ └── hello_world.py +│ ├── 02-print-and-input/ +│ │ ├── README.md +│ │ ├── README.pt-BR.md +│ │ ├── README.es.md +│ │ └── examples/ +│ │ ├── interactive_greeting.py +│ │ └── output_basics.py +│ ├── 03-variables-and-naming/ +│ │ ├── README.md +│ │ ├── README.pt-BR.md +│ │ ├── README.es.md +│ │ └── examples/ +│ │ ├── learning_profile.py +│ │ └── variable_basics.py +│ ├── 04-built-in-data-types/ +│ │ ├── README.md +│ │ ├── README.pt-BR.md +│ │ ├── README.es.md +│ │ └── examples/ +│ │ ├── same_looking_values.py +│ │ └── value_catalog.py +│ ├── 05-type-and-isinstance/ +│ │ ├── README.md +│ │ ├── README.pt-BR.md +│ │ ├── README.es.md +│ │ └── examples/ +│ │ ├── check_type_families.py +│ │ └── inspect_types.py +│ └── 06-type-conversion/ +│ ├── README.md +│ ├── README.pt-BR.md +│ ├── README.es.md +│ └── examples/ +│ ├── conversion_basics.py +│ └── conversion_surprises.py ├── practical-projects/ │ ├── README.md │ ├── README.pt-BR.md │ ├── README.es.md │ ├── 01-expense-tracker/ +│ │ ├── README.md +│ │ ├── README.pt-BR.md +│ │ ├── README.es.md +│ │ ├── demo.py +│ │ ├── expense_tracker.py +│ │ └── tests/ +│ │ ├── conftest.py +│ │ └── test_expense_tracker.py │ ├── 02-grade-calculator/ +│ │ ├── README.md +│ │ ├── README.pt-BR.md +│ │ ├── README.es.md +│ │ ├── demo.py +│ │ ├── grade_calculator.py +│ │ └── tests/ +│ │ ├── conftest.py +│ │ └── test_grade_calculator.py │ ├── 03-user-registration/ +│ │ ├── README.md +│ │ ├── README.pt-BR.md +│ │ ├── README.es.md +│ │ ├── demo.py +│ │ ├── user_registration.py +│ │ └── tests/ +│ │ ├── conftest.py +│ │ └── test_user_registration.py │ ├── 04-csv-analyzer/ +│ │ ├── README.md +│ │ ├── README.pt-BR.md +│ │ ├── README.es.md +│ │ ├── csv_analyzer.py +│ │ ├── demo.py +│ │ └── tests/ +│ │ ├── conftest.py +│ │ └── test_csv_analyzer.py │ ├── 05-report-generator/ +│ │ ├── README.md +│ │ ├── README.pt-BR.md +│ │ ├── README.es.md +│ │ ├── demo.py +│ │ ├── report_generator.py +│ │ └── tests/ +│ │ ├── conftest.py +│ │ └── test_report_generator.py │ └── 06-file-organizer/ │ ├── README.md │ ├── README.pt-BR.md @@ -51,18 +414,198 @@ python-study-guide/ │ ├── test_atomic_move.py │ └── test_file_organizer.py ├── program-flow/ +│ ├── README.md +│ ├── README.pt-BR.md +│ ├── README.es.md +│ ├── 01-conditions-comparisons-and-boolean-logic/ +│ │ ├── README.md +│ │ ├── README.pt-BR.md +│ │ ├── README.es.md +│ │ └── examples/ +│ │ ├── boolean_logic.py +│ │ ├── comparison_results.py +│ │ └── truth_values.py +│ ├── 02-if-elif-and-else/ +│ │ ├── README.md +│ │ ├── README.pt-BR.md +│ │ ├── README.es.md +│ │ └── examples/ +│ │ ├── basic_if.py +│ │ ├── if_elif_else.py +│ │ └── independent_conditions.py +│ ├── 03-match-and-case/ +│ │ ├── README.md +│ │ ├── README.pt-BR.md +│ │ ├── README.es.md +│ │ └── examples/ +│ │ ├── literal_and_or_patterns.py +│ │ ├── mapping_patterns_and_guards.py +│ │ └── sequence_patterns.py +│ ├── 04-for-loops-and-iteration/ +│ │ ├── README.md +│ │ ├── README.pt-BR.md +│ │ ├── README.es.md +│ │ └── examples/ +│ │ ├── collection_iteration.py +│ │ ├── dictionary_iteration.py +│ │ └── filter_and_collect.py +│ ├── 05-range-enumerate-and-zip/ +│ │ ├── README.md +│ │ ├── README.pt-BR.md +│ │ ├── README.es.md +│ │ └── examples/ +│ │ ├── enumerate_positions.py +│ │ ├── range_progressions.py +│ │ └── zip_parallel_iteration.py +│ ├── 06-while-loops-and-state-driven-repetition/ +│ │ ├── README.md +│ │ ├── README.pt-BR.md +│ │ ├── README.es.md +│ │ └── examples/ +│ │ ├── countdown_state.py +│ │ ├── doubling_until_limit.py +│ │ └── study_target.py +│ ├── 07-break-continue-and-loop-else/ +│ │ ├── README.md +│ │ ├── README.pt-BR.md +│ │ ├── README.es.md +│ │ └── examples/ +│ │ ├── break_search.py +│ │ ├── continue_filtering.py +│ │ └── loop_else_search.py +│ └── 08-choosing-and-combining-program-flow/ +│ ├── README.md +│ ├── README.pt-BR.md +│ ├── README.es.md +│ └── examples/ +│ ├── search_with_position.py +│ ├── select_and_classify.py +│ └── state_driven_workflow.py ├── scripts/ │ ├── check_internal_links.py │ ├── example_manifest.txt │ ├── run_examples.py │ └── validate_repository_structure.py ├── standard-library/ +│ ├── README.md +│ ├── README.pt-BR.md +│ ├── README.es.md +│ ├── 01-pathlib/ +│ │ ├── README.md +│ │ ├── README.pt-BR.md +│ │ ├── README.es.md +│ │ └── examples/ +│ │ ├── discover_python_files.py +│ │ ├── inspect_paths.py +│ │ ├── path_parts.py +│ │ └── text_workspace.py +│ ├── 02-datetime/ +│ │ ├── README.md +│ │ ├── README.pt-BR.md +│ │ ├── README.es.md +│ │ └── examples/ +│ │ ├── date_arithmetic.py +│ │ ├── duration_seconds.py +│ │ ├── parse_and_format.py +│ │ └── utc_conversion.py +│ ├── 03-json/ +│ │ ├── README.md +│ │ ├── README.pt-BR.md +│ │ ├── README.es.md +│ │ └── examples/ +│ │ ├── decimal_decode.py +│ │ ├── deterministic_json.py +│ │ ├── reject_duplicate_keys.py +│ │ └── strict_numbers.py +│ ├── 04-csv/ +│ │ ├── README.md +│ │ ├── README.pt-BR.md +│ │ ├── README.es.md +│ │ └── examples/ +│ │ ├── dialect_round_trip.py +│ │ ├── quote_none_escape.py +│ │ ├── sniff_delimiter.py +│ │ └── validate_dict_rows.py +│ ├── 05-logging/ +│ │ ├── README.md +│ │ ├── README.pt-BR.md +│ │ ├── README.es.md +│ │ └── examples/ +│ │ ├── context_filter.py +│ │ ├── dict_config_routing.py +│ │ ├── queue_listener.py +│ │ └── stacklevel_helper.py +│ ├── 06-collections/ +│ │ ├── README.md +│ │ ├── README.pt-BR.md +│ │ ├── README.es.md +│ │ └── examples/ +│ │ ├── bounded_deque.py +│ │ ├── chainmap_config.py +│ │ ├── counter_inventory.py +│ │ └── defaultdict_grouping.py +│ ├── 07-itertools/ +│ │ ├── README.md +│ │ ├── README.pt-BR.md +│ │ ├── README.es.md +│ │ └── examples/ +│ │ ├── combinatoric_options.py +│ │ ├── groupby_runs.py +│ │ ├── lazy_pipeline.py +│ │ └── pairwise_deltas.py +│ ├── 08-decimal/ +│ │ ├── README.md +│ │ ├── README.pt-BR.md +│ │ ├── README.es.md +│ │ └── examples/ +│ │ ├── exact_amounts.py +│ │ ├── local_context_precision.py +│ │ ├── monitor_rounding.py +│ │ └── validate_scale.py +│ └── 09-os-shutil/ +│ ├── README.md +│ ├── README.pt-BR.md +│ ├── README.es.md +│ └── examples/ +│ ├── copy_tree_and_move.py +│ ├── environment_contract.py +│ ├── scan_directory.py +│ └── walk_with_pruning.py ├── strings-and-numbers/ +│ ├── README.md +│ ├── README.pt-BR.md +│ ├── README.es.md +│ ├── 01-string-creation-and-indexing/ +│ │ ├── README.md +│ │ ├── README.pt-BR.md +│ │ ├── README.es.md +│ │ └── examples/ +│ │ ├── fixed_position_text.py +│ │ └── string_basics.py +│ ├── 02-common-string-methods/ +│ │ ├── README.md +│ │ ├── README.pt-BR.md +│ │ ├── README.es.md +│ │ └── examples/ +│ │ ├── normalize_text.py +│ │ └── split_and_join.py +│ ├── 03-int-float-and-bool/ +│ │ ├── README.md +│ │ ├── README.pt-BR.md +│ │ ├── README.es.md +│ │ └── examples/ +│ │ ├── numeric_behavior.py +│ │ └── truth_and_precision.py +│ └── 04-numeric-builtins/ +│ ├── README.md +│ ├── README.pt-BR.md +│ ├── README.es.md +│ └── examples/ +│ ├── numeric_summary.py +│ └── rounding_behavior.py └── tests/ ``` -El mapa anterior resume las rutas de primer nivel y expande el área de la Fase 10 que cambia en este pull request. Los capítulos internos de las fases anteriores permanecen documentados por sus índices de sección y rutas de aprendizaje. - ## Guía de los archivos raíz - `AGENTS.md`: instrucciones generales del repositorio para colaboradores y agentes de IA. @@ -78,20 +621,20 @@ El mapa anterior resume las rutas de primer nivel y expande el área de la Fase - `.github/`: configuración de colaboración, formularios de issue, plantilla de pull request y workflow de GitHub Actions. - `assets/`: identidad visual original, recursos exportados, composiciones editables, paleta, accesibilidad y reglas de uso. -- `comments-and-documentation/`: ruta completa de la Fase 6, con capítulos revisados en inglés, portugués de Brasil y español y ejemplos ejecutables seguros. -- `collections/`: ruta completa de la Fase 3 sobre listas, tuplas, diccionarios, conjuntos y elección de colecciones. -- `docs/`: rutas completas de aprendizaje, roadmaps, estructura del proyecto, documentos localizados, políticas y guía de desarrollo responsable asistido por IA. -- `errors-files-and-modules/`: ruta completa de la Fase 7 sobre excepciones, archivos, formatos TXT/CSV/JSON, imports, módulos y paquetes. +- `comments-and-documentation/`: ruta completa de la Fase 6. Hay capítulos revisados sobre comentarios, docstrings, nombres significativos, marcadores de tareas, comentarios frente a logging y PEP 8 y legibilidad, cada uno en inglés, portugués de Brasil y español, con ejemplos ejecutables seguros. +- `collections/`: ruta completa de la Fase 3. Sus seis capítulos enseñan creación, lectura, mutación y métodos comunes de listas, copia superficial, tuplas e inmutabilidad, mappings clave-valor y vistas de diccionarios, unicidad y relaciones de conjuntos y cómo elegir entre listas, tuplas, diccionarios y conjuntos según la intención, en inglés, portugués de Brasil y español, con ejemplos ejecutables seguros. +- `docs/`: rutas completas de aprendizaje, roadmaps, arquitectura del proyecto, documentos localizados, políticas y guía de desarrollo responsable asistido por IA. +- `errors-files-and-modules/`: ruta completa de la Fase 7. Los Capítulos 01–05 cubren manejo de excepciones en runtime, lanzamiento deliberado y excepciones personalizadas, I/O seguro de archivos de texto con `open()` y `with`, parsing y escritura de TXT/CSV/JSON y organización del código mediante imports, módulos, paquetes regulares, contexto de ejecución y diseño de dependencias, en inglés, portugués de Brasil y español con ejemplos ejecutables deterministas. - `exercises/`: actividades prácticas relacionadas con los capítulos. -- `external-libraries/`: ruta completa de la Fase 9 para pandas, openpyxl, Requests y pytest, con ejemplos deterministas y contrato explícito de dependencias. -- `functions/`: ruta completa de la Fase 5 sobre definición, parámetros, retornos, alcance, type hints, defaults, `*args`, `**kwargs`, composición y flujo de datos. -- `fundamentals/`: ruta completa de la Fase 1 sobre ejecución, entrada/salida, variables, tipos e conversión. -- `practical-projects/`: espacio de la Fase 10. Los Proyectos 01–05 están completados y el Proyecto 06 Organizador de Archivos está en progreso. El Proyecto 06 añade descubrimiento superficial determinista, planificación inmutable, políticas de colisión, fronteras de symlink, identidad `(device, inode)`, directorios raíz/categoría anclados, nombres de staging acotados, commit atómico no-replace en Linux con `renameat2(RENAME_NOREPLACE)`, demo determinista y pruebas de regresión enfocadas. -- `program-flow/`: ruta completa de la Fase 4 sobre condiciones, branching, pattern matching, loops y herramientas de iteración. +- `external-libraries/`: ruta completa de la Fase 9 para paquetes de terceros. Contiene capítulos multilingües revisados de pandas 3.0.x, openpyxl 3.1.x, Requests 2.34.x y pytest 9.1.x, con veinte ejemplos ejecutables deterministas en total. La fase cubre transformaciones tabulares, automatización de libros de Excel, clientes HTTP/API y contratos de pruebas automatizadas; la Fase 10 de proyectos prácticos viene a continuación. +- `functions/`: ruta completa de la Fase 5. Los Capítulos 01–09 cubren definición y llamada de funciones, entradas obligatorias, valores retornados, alcance y búsqueda de nombres, type hints para interfaces de funciones, valores predeterminados incluida la evaluación al definir la función y la seguridad con valores mutables, recolección de argumentos posicionales y por palabra clave de cantidad variable con `*args` y `**kwargs`, composición mediante funciones auxiliares y coordinadoras con dependencias explícitas y grafos simples de llamadas, y seguimiento explícito del flujo de datos entre llamadas, incluidos vínculos de parámetros, reasignación frente a mutación, `None`, resultados en tupla y traspasos mediante `return`, en inglés, portugués de Brasil y español, con ejemplos ejecutables determinísticos. +- `fundamentals/`: ruta completa de la Fase 1. Sus seis capítulos enseñan cómo Python ejecuta un programa, cómo usar `print()` e `input()`, cómo funcionan la asignación y los nombres, cómo reconocer e inspeccionar tipos de datos incorporados comunes y cómo convertir valores compatibles de forma deliberada, con explicaciones multilingües alineadas y ejemplos ejecutables. +- `practical-projects/`: espacio de Proyectos Prácticos de la Fase 10. Los Proyectos 01–05 están completados y el Proyecto 06 Organizador de Archivos está en progreso. El Proyecto 06 añade descubrimiento superficial determinista, planificación inmutable, políticas de colisión, fronteras de symlink, verificaciones de identidad del filesystem, directorios anclados por descriptors, nombres de staging acotados, commit atómico no-replace en Linux, demo determinista y pruebas de regresión enfocadas. +- `program-flow/`: ruta completa de la Fase 4. Los Capítulos 01–08 enseñan condiciones, comparaciones, pruebas de valor de verdad, pertenencia, identidad, lógica booleana, ramificación condicional con `if`, `elif` y `else`, coincidencia de patrones estructurales, repetición guiada por iterables con `for`, progresiones numéricas con `range()`, iteración con posición usando `enumerate()`, iteración paralela con `zip()` incluida la validación explícita de longitudes iguales con `strict=True`, repetición guiada por estado con `while`, control deliberado de bucles con `break`, `continue` y `else` de bucle y cómo elegir y combinar herramientas de flujo del programa según la intención, en inglés, portugués de Brasil y español, con ejemplos ejecutables determinísticos. - `scripts/`: herramientas de mantenimiento sin dependencias externas utilizadas localmente y por GitHub Actions. -- `standard-library/`: ruta completa de la Fase 8 sobre `pathlib`, `datetime`, JSON, CSV, logging, collections, itertools, decimal y `os`/`shutil`. -- `strings-and-numbers/`: ruta completa de la Fase 2 sobre strings, números, booleanos, precisión y funciones numéricas incorporadas. -- `tests/`: pruebas de regresión de las herramientas de calidad y del contenido educativo cuando corresponda. +- `standard-library/`: ruta completa de la Fase 8. Los Capítulos 01–09 cubren fronteras de filesystem con `pathlib`, modelado de fecha/hora con `datetime`, contratos avanzados de `json` y `csv`, `logging`, `collections` especializadas, `itertools`, `decimal` y contratos de `os`/`shutil` para estado del entorno, recorrido, metadatos, copia, movimiento, eliminación recursiva, capacidades de plataforma y seguridad de archives, en inglés, portugués de Brasil y español con ejemplos ejecutables deterministas. +- `strings-and-numbers/`: ruta completa de la Fase 2. Sus cuatro capítulos revisados cubren creación e indexación de strings, métodos comunes, comportamiento de enteros, punto flotante y booleanos, precisión de punto flotante y `round()`, `abs()`, `min()`, `max()` y `sum()` en inglés, portugués de Brasil y español, con ejemplos ejecutables seguros. +- `tests/`: pruebas de regresión de las herramientas de calidad y, más adelante, del contenido educativo. ## Regla de los directorios de capítulos @@ -102,8 +645,6 @@ Cada capítulo de aprendizaje contiene: - un directorio `examples/` cuando los ejemplos ejecutables mejoran el tema; - solamente material completo y revisable, sin placeholders vacíos. -Los proyectos prácticos pueden añadir módulos de implementación, `demo.py` y una carpeta `tests/` cuando el proyecto necesita una superficie ejecutable y cobertura automatizada propia. - ## Convenciones de nombres e idiomas Los directorios, archivos, variables, funciones, clases y demás identificadores utilizan inglés. Los documentos explicativos se ofrecen en inglés, portugués de Brasil y español. From 42c24c04f1248715c1e6e3c8e28d0994045089c6 Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Tue, 1 Sep 2026 07:39:31 -0300 Subject: [PATCH 047/117] No-op verify Spanish structure content From ebfb095b3f08aaf6e4c70717f088a70ecd5bca4b Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Tue, 1 Sep 2026 10:22:20 -0300 Subject: [PATCH 048/117] Harden FIFO pinning and late casefold checks --- .../06-file-organizer/file_organizer.py | 37 ++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/practical-projects/06-file-organizer/file_organizer.py b/practical-projects/06-file-organizer/file_organizer.py index a135fcb..36ae0ec 100644 --- a/practical-projects/06-file-organizer/file_organizer.py +++ b/practical-projects/06-file-organizer/file_organizer.py @@ -416,6 +416,7 @@ def _supports_secure_directory_fds() -> bool: and os.rename in os.supports_dir_fd and os.stat in os.supports_dir_fd and os.stat in os.supports_follow_symlinks + and os.listdir in os.supports_fd ) @@ -514,8 +515,10 @@ def _open_planned_source_fd_at( root_fd: int, expected_identity: _FileIdentity, ) -> int: - """Pin the planned inode so an unlinked source cannot be inode-reused.""" + """Pin the planned inode without blocking on a late special-file replacement.""" flags = os.O_RDONLY | os.O_NOFOLLOW + if hasattr(os, "O_NONBLOCK"): + flags |= os.O_NONBLOCK if hasattr(os, "O_CLOEXEC"): flags |= os.O_CLOEXEC try: @@ -600,6 +603,33 @@ def _verify_category_anchor_at( ) +def _verify_no_casefold_destination_collision_at( + destination_name: str, + *, + destination_directory_fd: int, +) -> None: + """Reject a casefold-equivalent entry visible immediately before commit.""" + destination_key = destination_name.casefold() + try: + current_names = os.listdir(destination_directory_fd) + except OSError as exc: + raise ValueError("category directory became unsafe during execution") from exc + + if any(name.casefold() == destination_key for name in current_names): + raise FileExistsError( + f"case-insensitive destination appeared during execution: {destination_name}" + ) + + +def _verify_no_casefold_destination_collision_path(destination: Path) -> None: + """Best-effort Windows recheck for a casefold-equivalent destination.""" + destination_key = destination.name.casefold() + if any(child.name.casefold() == destination_key for child in destination.parent.iterdir()): + raise FileExistsError( + f"case-insensitive destination appeared during execution: {destination.name}" + ) + + def _make_stage_name(source_name: str) -> str: """Return a fixed-length internal name independent of the source filename.""" del source_name @@ -728,6 +758,10 @@ def _move_file_no_replace_at( raise FileNotFoundError( f"planned source changed during execution: {source_name}" ) + _verify_no_casefold_destination_collision_at( + destination_name, + destination_directory_fd=destination_directory_fd, + ) _rename_no_replace_at( stage_name, destination_name, @@ -775,6 +809,7 @@ def _move_file_no_replace( """Windows fallback using its atomic no-replace rename behavior.""" _verify_path_identity(source, expected_identity) category_identity = _capture_directory_identity(destination.parent) + _verify_no_casefold_destination_collision_path(destination) _rename_no_replace_path(source, destination) _verify_destination_path_identity(destination, expected_identity) if _capture_directory_identity(destination.parent) != category_identity: From 1768afcdf52f293772ae7618f08d1b0bc60c1247 Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Tue, 1 Sep 2026 10:23:12 -0300 Subject: [PATCH 049/117] Add FIFO and late casefold race regressions --- .../tests/test_atomic_move.py | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/practical-projects/06-file-organizer/tests/test_atomic_move.py b/practical-projects/06-file-organizer/tests/test_atomic_move.py index e79893d..91a47e7 100644 --- a/practical-projects/06-file-organizer/tests/test_atomic_move.py +++ b/practical-projects/06-file-organizer/tests/test_atomic_move.py @@ -1,4 +1,5 @@ import os +import stat from pathlib import Path import pytest @@ -315,3 +316,90 @@ def guarded_unlink( assert result.moved_count == 1 assert not source.exists() assert (tmp_path / "documents" / "notes.txt").read_text(encoding="utf-8") == "planned source" + + +def test_source_pin_uses_nonblocking_open_and_rejects_late_fifo( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + if not file_organizer._supports_secure_directory_fds(): + pytest.skip("secure directory descriptors are unavailable on this platform") + if not hasattr(os, "mkfifo") or not hasattr(os, "O_NONBLOCK"): + pytest.skip("FIFO or O_NONBLOCK is unavailable on this platform") + + source = tmp_path / "notes.txt" + source.write_text("planned source", encoding="utf-8") + plan = plan_organization(tmp_path) + original_open = os.open + raced = False + observed_flags: list[int] = [] + + def racing_open( + path: str | os.PathLike[str], + flags: int, + mode: int = 0o777, + *, + dir_fd: int | None = None, + ) -> int: + nonlocal raced + if path == source.name and dir_fd is not None and not raced: + raced = True + observed_flags.append(flags) + source.unlink() + os.mkfifo(source) + return original_open(path, flags, mode, dir_fd=dir_fd) + + monkeypatch.setattr(file_organizer, "_supports_secure_directory_fds", lambda: True) + monkeypatch.setattr(file_organizer.os, "open", racing_open) + + with pytest.raises(FileNotFoundError, match="regular file|changed during execution"): + execute_plan(plan) + + assert observed_flags + assert observed_flags[0] & os.O_NONBLOCK + assert stat.S_ISFIFO(source.lstat().st_mode) + assert not (tmp_path / "documents" / "notes.txt").exists() + + +def test_execute_plan_rechecks_late_casefold_collision_before_commit( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + if not file_organizer._supports_secure_directory_fds(): + pytest.skip("secure directory descriptors are unavailable on this platform") + + source = tmp_path / "Report.TXT" + source.write_text("planned source", encoding="utf-8") + plan = plan_organization(tmp_path) + category = tmp_path / "documents" + late_destination = category / "report.txt" + exact_destination = category / "Report.TXT" + original_claim = file_organizer._claim_source_at + raced = False + + def racing_claim( + source_name: str, + *, + root_fd: int, + expected_identity: file_organizer._FileIdentity, + ) -> str: + nonlocal raced + stage_name = original_claim( + source_name, + root_fd=root_fd, + expected_identity=expected_identity, + ) + if not raced: + raced = True + late_destination.write_text("late casefold collision", encoding="utf-8") + return stage_name + + monkeypatch.setattr(file_organizer, "_claim_source_at", racing_claim) + + with pytest.raises(FileExistsError, match="case-insensitive destination"): + execute_plan(plan) + + assert source.read_text(encoding="utf-8") == "planned source" + assert late_destination.read_text(encoding="utf-8") == "late casefold collision" + assert not exact_destination.exists() + assert any(child.name.startswith(".fo-stage-") for child in tmp_path.iterdir()) From e93ca53dc26e12a4d64c2d7791e785e5feb2a68e Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Tue, 1 Sep 2026 10:24:27 -0300 Subject: [PATCH 050/117] Clarify FIFO and casefold execution guarantees --- .../06-file-organizer/README.md | 74 ++++++++++++------- 1 file changed, 49 insertions(+), 25 deletions(-) diff --git a/practical-projects/06-file-organizer/README.md b/practical-projects/06-file-organizer/README.md index 06b2fc4..9322d52 100644 --- a/practical-projects/06-file-organizer/README.md +++ b/practical-projects/06-file-organizer/README.md @@ -22,11 +22,13 @@ By the end of this project, you should be able to: - separate a non-mutating planning phase from a mutating execution phase; - detect exact and case-insensitive destination collisions; - choose explicit collision policies instead of silently overwriting data; -- treat symlinks as a filesystem boundary; +- treat symlinks and special files as filesystem boundaries; - reason about time-of-check/time-of-use races; - compare filesystem objects by `(device, inode)` identity; - anchor directories with file descriptors on Linux; -- use atomic no-replace rename semantics at the final commit boundary; +- pin sources without blocking on late FIFO replacements; +- use atomic no-replace rename semantics at the final exact-name commit boundary; +- distinguish logical casefold collision checks from exact-name atomic guarantees; - preserve uncertain state instead of blindly deleting entries during recovery; - test filesystem code safely with temporary directories. @@ -75,14 +77,15 @@ The implementation must: 8. produce deterministic ordering; 9. build an immutable plan before mutation; 10. reject invalid category paths, including symlinked category directories; -11. detect exact and case-insensitive destination collisions; +11. detect exact and case-insensitive destination collisions during planning/preflight; 12. support explicit `ERROR` and `SKIP` planning policies; 13. run a complete execution preflight; 14. capture planned-source filesystem identity; 15. never silently replace an exact destination; -16. reject stale source, root, or category assumptions during execution; -17. never blindly unlink a staging or rollback entry whose identity may have changed; -18. return a structured result only after the planned destination is verified. +16. recheck casefold-equivalent destination names immediately before commit; +17. reject stale source, root, or category assumptions during execution; +18. never blindly unlink a staging or rollback entry whose identity may have changed; +19. return a structured result only after the planned destination is verified. ## Deliberate scope @@ -96,7 +99,8 @@ source directory -> execution preflight -> anchored category folders -> source claim - -> atomic no-replace destination commit + -> mutation-time casefold recheck + -> atomic exact-name no-replace destination commit ``` This project intentionally excludes: @@ -181,7 +185,7 @@ Planning raises `FileExistsError` when a destination name already exists. Conflicting source files remain in the source directory and are listed in `skipped_collisions`. -Execution still refuses collisions that appear after planning. +Execution rechecks collisions after planning. Exact-name existence is enforced atomically at the final Linux commit; casefold-equivalent names are rechecked immediately before that commit. ## Case-insensitive collision checks @@ -192,7 +196,9 @@ Report.TXT report.txt ``` -These names are treated as a logical collision even on a case-sensitive filesystem. +These names are treated as a logical collision during planning, preflight, and the mutation-time recheck even on a case-sensitive filesystem. + +There is an important boundary: on a case-sensitive filesystem, the kernel primitive `RENAME_NOREPLACE` protects only the **exact destination name**. A non-cooperating external process could still create a different casefold-equivalent name in the tiny interval after the final casefold scan. The project therefore does not claim atomic case-insensitive uniqueness where the filesystem does not provide it. ## Symlink and directory-anchor boundaries @@ -225,7 +231,7 @@ The implementation represents identity with: The filename `notes.txt` is a directory entry. It is not the identity of the underlying filesystem object. -During secure Linux execution, the planned source is also opened with `O_NOFOLLOW`, pinning the expected inode while the commit runs. This prevents an unlinked inode from being reused and mistaken for the originally planned source during the operation. +During secure Linux execution, the planned source is opened with `O_NOFOLLOW | O_NONBLOCK` when `O_NONBLOCK` is available. The nonblocking flag prevents a late FIFO replacement from hanging `open()`, while the following `fstat()` still requires a regular file with the planned `(device, inode)` identity. The open descriptor pins the expected inode while the commit runs. ## Fixed-length staging names @@ -247,15 +253,16 @@ Conceptually: 1. preflight and capture source identity 2. open and anchor the source root 3. open and anchor required category directories -4. pin the planned source inode with O_NOFOLLOW +4. pin the planned source inode with O_NOFOLLOW | O_NONBLOCK 5. atomically claim source name -> short internal stage 6. verify stage identity and directory anchors -7. atomically rename stage -> destination with RENAME_NOREPLACE -8. verify destination identity and anchors -9. report success +7. rescan the pinned category for a casefold-equivalent destination +8. atomically rename stage -> exact destination with RENAME_NOREPLACE +9. verify destination identity and anchors +10. report success ``` -`RENAME_NOREPLACE` makes destination existence part of the atomic filesystem operation. There is no separate `exists()` check followed by a replacing rename. +`RENAME_NOREPLACE` makes **exact destination-name** existence part of the atomic filesystem operation. There is no separate `exists()` check followed by a replacing rename. The preceding casefold scan catches logical collisions visible at that boundary, but it is intentionally documented as a recheck rather than an atomic case-insensitive lock. The normal secure path does **not** finalize a move by calling `unlink()` on the staging name. This avoids transferring the same check-to-unlink race from the public source name to an internal name. @@ -273,8 +280,8 @@ The whole multi-file plan is not transactional. The implementation is explicit about platform guarantees: -- **Linux:** secure descriptor-anchored execution uses `renameat2(RENAME_NOREPLACE)` when available; -- **Windows:** the fallback relies on Windows `os.rename()` refusing an existing destination and verifies source/destination/category identities around the operation; +- **Linux:** secure descriptor-anchored execution uses `renameat2(RENAME_NOREPLACE)` when available, with atomic no-replace protection for the exact destination name and mutation-time casefold rechecks; +- **Windows:** the fallback relies on Windows `os.rename()` refusing an existing destination and performs a best-effort casefold recheck plus source/destination/category identity validation around the operation; - **other POSIX platforms:** execution raises `NotImplementedError` when the project cannot enforce the required no-replace semantics safely. A safety-oriented example should fail honestly instead of silently downgrading its contract. @@ -290,9 +297,11 @@ A safety-oriented example should fail honestly instead of silently downgrading i 5. destination collision preflight; 6. platform capability selection; 7. anchored directory setup; -8. source claim and atomic no-replace commit; -9. destination/anchor verification; -10. `OrganizationResult` construction. +8. nonblocking source pin and source claim; +9. mutation-time casefold collision recheck; +10. atomic exact-name no-replace commit; +11. destination/anchor verification; +12. `OrganizationResult` construction. ## Determinism @@ -334,7 +343,9 @@ Coverage includes: - `ERROR` and `SKIP` policies; - stale and missing sources; - late exact destinations; -- late source symlink/file replacement; +- late casefold-equivalent destinations before final commit; +- late source symlink/file/FIFO replacement; +- nonblocking source pinning; - category symlink and rename races; - source-root rename races; - fixed-length staging names; @@ -362,7 +373,11 @@ Rejected before planning or execution. ### Destination appears after planning -Preflight or the atomic no-replace commit raises `FileExistsError`. +Preflight and the mutation-time casefold recheck raise `FileExistsError` for collisions they observe. The final Linux `RENAME_NOREPLACE` atomically rejects an exact-name destination that appears at the commit boundary. + +### Planned source becomes a FIFO or another special file + +The Linux source pin uses nonblocking open flags, then `fstat()` rejects the replacement as non-regular instead of hanging execution. ### Planned source changes @@ -386,6 +401,14 @@ Mixing discovery and mutation makes partial failure difficult to reason about. B Directory entries can be replaced while preserving the same name. Use filesystem identity when the distinction matters. +### Opening a possibly replaced path in blocking mode + +`O_NOFOLLOW` rejects symlinks but does not stop a FIFO from blocking a read-only `open()`. Use nonblocking pinning before validating the file type. + +### Assuming a casefold scan is an atomic lock + +A user-space directory scan can detect logical case-insensitive collisions, but on a case-sensitive filesystem it cannot make a later differently cased name impossible. Keep the atomic guarantee scoped to the exact name enforced by the kernel primitive. + ### Checking immediately before `unlink()` A check-to-unlink window still exists. When deletion identity matters, restructure the operation instead of adding another check. @@ -439,7 +462,7 @@ A useful explanation is not “I wrote a script that moves files.” A stronger version is: -> I designed a filesystem workflow with deterministic planning, explicit collision policies, symlink boundaries, inode-based identity checks, descriptor-anchored directories, bounded staging names, and an atomic Linux no-replace commit using `renameat2(RENAME_NOREPLACE)`. Failure handling preserves uncertain state instead of blindly deleting entries. +> I designed a filesystem workflow with deterministic planning, explicit collision policies, symlink/special-file boundaries, inode-based identity checks, descriptor-anchored directories, bounded staging names, mutation-time casefold rechecks, and an atomic Linux exact-name no-replace commit using `renameat2(RENAME_NOREPLACE)`. Failure handling preserves uncertain state instead of blindly deleting entries. That communicates engineering decisions, not just API usage. @@ -456,10 +479,11 @@ That communicates engineering decisions, not just API usage. | Execute the plan | `execute_plan()` | | Hold successful destinations | `OrganizationResult` | | Identify filesystem objects | `(st_dev, st_ino)` | -| Secure Linux commit | `renameat2(RENAME_NOREPLACE)` | +| Logical casefold recheck | pinned-directory `listdir()` | +| Secure Linux exact-name commit | `renameat2(RENAME_NOREPLACE)` | ## What comes next Project 05 generated files. Project 06 owns the next boundary: discovering and organizing files safely. -Project 07 moves upward again, combining validated domain records and explicit workflow states in a **fictional reconciliation workflow**. +Project 07 moves upward again, combining validated domain records and explicit workflow states in a **fictional reconciliation workflow**. \ No newline at end of file From 7b65e8054808bf9541981fa7f2f4e6322912728f Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Tue, 1 Sep 2026 10:25:39 -0300 Subject: [PATCH 051/117] Sincronizar garantias FIFO e casefold em PT-BR --- .../06-file-organizer/README.pt-BR.md | 74 ++++++++++++------- 1 file changed, 49 insertions(+), 25 deletions(-) diff --git a/practical-projects/06-file-organizer/README.pt-BR.md b/practical-projects/06-file-organizer/README.pt-BR.md index e6183c2..93a7c0f 100644 --- a/practical-projects/06-file-organizer/README.pt-BR.md +++ b/practical-projects/06-file-organizer/README.pt-BR.md @@ -22,11 +22,13 @@ Ao concluir este projeto, você deverá ser capaz de: - separar uma fase de planejamento sem mutação de uma fase de execução com efeitos colaterais; - detectar colisões de destino exatas e sem diferenciação de caixa; - escolher políticas de colisão explícitas em vez de sobrescrever dados silenciosamente; -- tratar symlinks como uma fronteira do filesystem; +- tratar symlinks e arquivos especiais como fronteiras do filesystem; - raciocinar sobre corridas time-of-check/time-of-use; - comparar objetos do filesystem pela identidade `(device, inode)`; - ancorar diretórios com file descriptors no Linux; -- usar semântica atômica no-replace na fronteira final de commit; +- fixar origens sem bloquear diante de substituições tardias por FIFO; +- usar semântica atômica no-replace na fronteira final de commit do nome exato; +- distinguir checagens lógicas por `casefold()` de garantias atômicas de nome exato; - preservar estado incerto em vez de apagar entradas cegamente durante recuperação; - testar código de filesystem com segurança usando diretórios temporários. @@ -75,14 +77,15 @@ A implementação deve: 8. produzir ordenação determinística; 9. construir um plano imutável antes da mutação; 10. rejeitar caminhos de categoria inválidos, inclusive diretórios de categoria que sejam symlinks; -11. detectar colisões de destino exatas e sem diferenciação de caixa; +11. detectar colisões de destino exatas e sem diferenciação de caixa durante planejamento/preflight; 12. oferecer políticas explícitas `ERROR` e `SKIP` durante o planejamento; 13. executar um preflight completo; 14. capturar a identidade das origens planejadas; 15. nunca substituir silenciosamente um destino exato; -16. rejeitar premissas obsoletas sobre origem, raiz ou categoria durante a execução; -17. nunca executar `unlink()` cegamente em staging ou rollback cuja identidade possa ter mudado; -18. retornar resultado estruturado apenas após verificar o destino planejado. +16. revalidar nomes de destino equivalentes por `casefold()` imediatamente antes do commit; +17. rejeitar premissas obsoletas sobre origem, raiz ou categoria durante a execução; +18. nunca executar `unlink()` cegamente em staging ou rollback cuja identidade possa ter mudado; +19. retornar resultado estruturado apenas após verificar o destino planejado. ## Escopo deliberado @@ -96,7 +99,8 @@ diretório de origem -> preflight de execução -> pastas de categoria ancoradas -> claim da origem - -> commit atômico no-replace no destino + -> nova checagem casefold na mutação + -> commit atômico no-replace do nome exato no destino ``` Este projeto intencionalmente não inclui: @@ -181,7 +185,7 @@ O planejamento gera `FileExistsError` quando um nome de destino já existe. Arquivos conflitantes permanecem na origem e são listados em `skipped_collisions`. -A execução ainda recusa colisões que apareçam depois do planejamento. +A execução revalida colisões depois do planejamento. A existência do nome exato é aplicada atomicamente no commit final do Linux; nomes equivalentes por `casefold()` são rechecados imediatamente antes desse commit. ## Colisões sem diferenciação de caixa @@ -192,7 +196,9 @@ Report.TXT report.txt ``` -Esses nomes são tratados como colisão lógica mesmo em um filesystem case-sensitive. +Esses nomes são tratados como colisão lógica durante planejamento, preflight e a checagem imediatamente anterior ao commit, mesmo em filesystem case-sensitive. + +Há uma fronteira importante: em um filesystem case-sensitive, a primitiva do kernel `RENAME_NOREPLACE` protege somente o **nome exato do destino**. Um processo externo que não coopere ainda pode criar outro nome equivalente por `casefold()` no pequeno intervalo após a última varredura. Por isso, o projeto não afirma unicidade atômica case-insensitive onde o filesystem não fornece essa garantia. ## Fronteiras de symlink e ancoragem de diretórios @@ -225,7 +231,7 @@ A implementação representa identidade com: O nome `notes.txt` é uma entrada de diretório, não a identidade do objeto do filesystem. -Durante a execução segura no Linux, a origem planejada também é aberta com `O_NOFOLLOW`, fixando o inode esperado enquanto o commit ocorre. Isso evita que um inode liberado seja reutilizado e confundido com a origem planejada. +Durante a execução segura no Linux, a origem planejada é aberta com `O_NOFOLLOW | O_NONBLOCK` quando `O_NONBLOCK` está disponível. A flag nonblocking impede que uma substituição tardia por FIFO trave o `open()`, enquanto o `fstat()` seguinte ainda exige um arquivo regular com a identidade `(device, inode)` planejada. O descriptor aberto fixa o inode esperado durante o commit. ## Nomes de staging com tamanho fixo @@ -247,15 +253,16 @@ Conceitualmente: 1. executar preflight e capturar identidade da origem 2. abrir e ancorar a raiz 3. abrir e ancorar as categorias necessárias -4. fixar o inode da origem com O_NOFOLLOW +4. fixar o inode da origem com O_NOFOLLOW | O_NONBLOCK 5. reivindicar atomicamente origem -> staging curto 6. verificar identidade do staging e âncoras -7. renomear atomicamente staging -> destino com RENAME_NOREPLACE -8. verificar identidade do destino e âncoras -9. reportar sucesso +7. varrer novamente a categoria ancorada por destino equivalente via casefold +8. renomear atomicamente staging -> destino exato com RENAME_NOREPLACE +9. verificar identidade do destino e âncoras +10. reportar sucesso ``` -`RENAME_NOREPLACE` transforma a existência do destino em parte da própria operação atômica. Não existe uma checagem `exists()` separada seguida de rename substitutivo. +`RENAME_NOREPLACE` transforma a existência do **nome exato do destino** em parte da própria operação atômica. Não existe uma checagem `exists()` separada seguida de rename substitutivo. A varredura `casefold()` anterior captura colisões lógicas visíveis nessa fronteira, mas é documentada como rechecagem, não como lock atômico case-insensitive. O caminho seguro normal não finaliza o movimento com `unlink()` do staging. Isso evita apenas transferir a mesma janela check-to-unlink do nome público para um nome interno. @@ -273,8 +280,8 @@ O plano inteiro de múltiplos arquivos não é transacional. A implementação explicita as garantias por plataforma: -- **Linux:** execução segura com FDs ancorados usa `renameat2(RENAME_NOREPLACE)` quando disponível; -- **Windows:** o fallback usa o comportamento de `os.rename()` que recusa destino existente e verifica identidades ao redor da operação; +- **Linux:** execução segura com FDs ancorados usa `renameat2(RENAME_NOREPLACE)` quando disponível, com proteção atômica no-replace para o nome exato do destino e rechecagens `casefold()` na mutação; +- **Windows:** o fallback usa o comportamento de `os.rename()` que recusa destino existente e executa uma rechecagem `casefold()` best-effort mais validações de identidade ao redor da operação; - **outros POSIX:** a execução gera `NotImplementedError` quando não consegue aplicar a semântica no-replace exigida com segurança. Um exemplo orientado a segurança deve falhar de forma honesta em vez de reduzir silenciosamente seu contrato. @@ -290,9 +297,11 @@ Um exemplo orientado a segurança deve falhar de forma honesta em vez de reduzir 5. preflight de colisões; 6. seleção da capacidade da plataforma; 7. preparação dos diretórios ancorados; -8. claim da origem e commit atômico no-replace; -9. verificação do destino e das âncoras; -10. construção de `OrganizationResult`. +8. pinning nonblocking e claim da origem; +9. rechecagem de colisão por `casefold()` na mutação; +10. commit atômico no-replace do nome exato; +11. verificação do destino e das âncoras; +12. construção de `OrganizationResult`. ## Determinismo @@ -334,7 +343,9 @@ A cobertura inclui: - políticas `ERROR` e `SKIP`; - origens ausentes ou obsoletas; - destinos exatos tardios; -- substituição tardia da origem por symlink/arquivo; +- destinos tardios equivalentes por `casefold()` antes do commit final; +- substituição tardia da origem por symlink/arquivo/FIFO; +- pinning nonblocking da origem; - corridas de symlink e rename da categoria; - corridas de rename da raiz; - staging com tamanho fixo; @@ -362,7 +373,11 @@ Gera `NotADirectoryError`. ### Destino aparece depois do planejamento -O preflight ou o commit atômico no-replace gera `FileExistsError`. +O preflight e a rechecagem `casefold()` na mutação geram `FileExistsError` para colisões que observam. O `RENAME_NOREPLACE` final do Linux rejeita atomicamente um destino com nome exato que apareça na fronteira do commit. + +### Origem planejada vira FIFO ou outro arquivo especial + +O pinning no Linux usa flags nonblocking e depois o `fstat()` rejeita a substituição por não ser arquivo regular, em vez de travar a execução. ### Origem planejada muda @@ -386,6 +401,14 @@ Misturar descoberta e mutação torna falhas parciais difíceis de raciocinar. C Entradas de diretório podem ser substituídas mantendo o mesmo nome. Use identidade do filesystem quando essa diferença importa. +### Abrir um caminho substituível em modo bloqueante + +`O_NOFOLLOW` rejeita symlinks, mas não impede que um FIFO bloqueie um `open()` read-only. Use pinning nonblocking antes de validar o tipo do arquivo. + +### Assumir que uma varredura `casefold()` é um lock atômico + +Uma varredura em user space detecta colisões lógicas sem diferenciação de caixa, mas em filesystem case-sensitive não consegue impedir que outro nome com caixa diferente apareça depois. Mantenha a garantia atômica limitada ao nome exato aplicado pela primitiva do kernel. + ### Verificar imediatamente antes de `unlink()` Ainda existe uma janela check-to-unlink. Quando a identidade da exclusão importa, reestruture a operação em vez de adicionar outra checagem. @@ -439,7 +462,7 @@ Uma explicação útil não é “eu escrevi um script que move arquivos”. Uma versão mais forte é: -> Eu projetei um fluxo de filesystem com planejamento determinístico, políticas explícitas de colisão, fronteiras de symlink, identidade por inode, diretórios ancorados por descriptors, nomes de staging limitados e commit atômico no-replace no Linux com `renameat2(RENAME_NOREPLACE)`. O tratamento de falhas preserva estado incerto em vez de apagar entradas cegamente. +> Eu projetei um fluxo de filesystem com planejamento determinístico, políticas explícitas de colisão, fronteiras de symlink/arquivo especial, identidade por inode, diretórios ancorados por descriptors, nomes de staging limitados, rechecagens `casefold()` na mutação e commit atômico no-replace do nome exato no Linux com `renameat2(RENAME_NOREPLACE)`. O tratamento de falhas preserva estado incerto em vez de apagar entradas cegamente. Isso comunica decisões de engenharia, não apenas uso de APIs. @@ -456,10 +479,11 @@ Isso comunica decisões de engenharia, não apenas uso de APIs. | Executar o plano | `execute_plan()` | | Manter destinos bem-sucedidos | `OrganizationResult` | | Identificar objetos do filesystem | `(st_dev, st_ino)` | -| Commit seguro no Linux | `renameat2(RENAME_NOREPLACE)` | +| Rechecagem lógica por `casefold()` | `listdir()` no diretório ancorado | +| Commit seguro do nome exato no Linux | `renameat2(RENAME_NOREPLACE)` | ## O que vem depois O Projeto 05 gerou arquivos. O Projeto 06 assume a próxima fronteira: descobrir e organizar arquivos com segurança. -O Projeto 07 sobe novamente de nível, combinando registros de domínio validados e estados explícitos de workflow em um **fluxo fictício de conciliação**. +O Projeto 07 sobe novamente de nível, combinando registros de domínio validados e estados explícitos de workflow em um **fluxo fictício de conciliação**. \ No newline at end of file From 1955d4d9decc9c97f9124e2bc70faf9181625081 Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Tue, 1 Sep 2026 10:26:54 -0300 Subject: [PATCH 052/117] =?UTF-8?q?Sincronizar=20garant=C3=ADas=20FIFO=20y?= =?UTF-8?q?=20casefold=20en=20ES?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../06-file-organizer/README.es.md | 74 ++++++++++++------- 1 file changed, 49 insertions(+), 25 deletions(-) diff --git a/practical-projects/06-file-organizer/README.es.md b/practical-projects/06-file-organizer/README.es.md index 4ec2f09..c6d8cfe 100644 --- a/practical-projects/06-file-organizer/README.es.md +++ b/practical-projects/06-file-organizer/README.es.md @@ -22,11 +22,13 @@ Al finalizar este proyecto, deberías poder: - separar una fase de planificación sin mutación de una fase de ejecución con efectos secundarios; - detectar colisiones de destino exactas y sin distinción de mayúsculas/minúsculas; - elegir políticas de colisión explícitas en lugar de sobrescribir datos silenciosamente; -- tratar los symlinks como una frontera del filesystem; +- tratar los symlinks y archivos especiales como fronteras del filesystem; - razonar sobre carreras time-of-check/time-of-use; - comparar objetos del filesystem mediante identidad `(device, inode)`; - anclar directorios con file descriptors en Linux; -- usar semántica atómica no-replace en la frontera final del commit; +- fijar orígenes sin bloquear ante sustituciones tardías por FIFO; +- usar semántica atómica no-replace en la frontera final de commit del nombre exacto; +- distinguir comprobaciones lógicas con `casefold()` de garantías atómicas de nombre exacto; - conservar estado incierto en lugar de borrar entradas a ciegas durante recuperación; - probar código de filesystem de forma segura con directorios temporales. @@ -75,14 +77,15 @@ La implementación debe: 8. producir un orden determinista; 9. construir un plan inmutable antes de mutar; 10. rechazar rutas de categoría inválidas, incluidos directorios de categoría que sean symlinks; -11. detectar colisiones de destino exactas y sin distinción de mayúsculas/minúsculas; +11. detectar colisiones de destino exactas y sin distinción de mayúsculas/minúsculas durante planificación/preflight; 12. ofrecer políticas explícitas `ERROR` y `SKIP` durante la planificación; 13. ejecutar un preflight completo; 14. capturar la identidad de los orígenes planificados; 15. nunca reemplazar silenciosamente un destino exacto; -16. rechazar supuestos obsoletos sobre origen, raíz o categoría durante la ejecución; -17. nunca ejecutar `unlink()` a ciegas sobre staging o rollback cuya identidad pueda haber cambiado; -18. devolver un resultado estructurado solo después de verificar el destino planificado. +16. volver a comprobar nombres de destino equivalentes por `casefold()` inmediatamente antes del commit; +17. rechazar supuestos obsoletos sobre origen, raíz o categoría durante la ejecución; +18. nunca ejecutar `unlink()` a ciegas sobre staging o rollback cuya identidad pueda haber cambiado; +19. devolver un resultado estructurado solo después de verificar el destino planificado. ## Alcance deliberado @@ -96,7 +99,8 @@ directorio de origen -> preflight de ejecución -> carpetas de categoría ancladas -> claim del origen - -> commit atómico no-replace en el destino + -> nueva comprobación casefold durante la mutación + -> commit atómico no-replace del nombre exacto en el destino ``` Este proyecto excluye intencionalmente: @@ -181,7 +185,7 @@ La planificación genera `FileExistsError` cuando ya existe un nombre de destino Los archivos conflictivos permanecen en el origen y aparecen en `skipped_collisions`. -La ejecución sigue rechazando colisiones que aparezcan después de planificar. +La ejecución vuelve a comprobar colisiones después de planificar. La existencia del nombre exacto se hace cumplir de forma atómica en el commit final de Linux; los nombres equivalentes por `casefold()` se vuelven a comprobar inmediatamente antes de ese commit. ## Colisiones sin distinción de mayúsculas/minúsculas @@ -192,7 +196,9 @@ Report.TXT report.txt ``` -Estos nombres se consideran una colisión lógica incluso en un filesystem case-sensitive. +Estos nombres se consideran una colisión lógica durante planificación, preflight y la comprobación inmediatamente anterior al commit, incluso en un filesystem case-sensitive. + +Hay una frontera importante: en un filesystem case-sensitive, la primitiva del kernel `RENAME_NOREPLACE` protege únicamente el **nombre exacto del destino**. Un proceso externo no cooperativo todavía puede crear otro nombre equivalente por `casefold()` en el pequeño intervalo posterior al último escaneo. Por ello, el proyecto no afirma unicidad atómica case-insensitive donde el filesystem no ofrece esa garantía. ## Fronteras de symlink y anclaje de directorios @@ -225,7 +231,7 @@ La implementación representa identidad con: El nombre `notes.txt` es una entrada de directorio, no la identidad del objeto del filesystem. -Durante la ejecución segura en Linux, el origen planificado también se abre con `O_NOFOLLOW`, fijando el inode esperado mientras se ejecuta el commit. Esto evita que un inode liberado sea reutilizado y confundido con el origen planificado. +Durante la ejecución segura en Linux, el origen planificado se abre con `O_NOFOLLOW | O_NONBLOCK` cuando `O_NONBLOCK` está disponible. La flag nonblocking impide que una sustitución tardía por FIFO bloquee `open()`, mientras el `fstat()` posterior sigue exigiendo un archivo regular con la identidad `(device, inode)` planificada. El descriptor abierto fija el inode esperado durante el commit. ## Nombres de staging de longitud fija @@ -247,15 +253,16 @@ Conceptualmente: 1. ejecutar preflight y capturar identidad del origen 2. abrir y anclar la raíz 3. abrir y anclar las categorías necesarias -4. fijar el inode del origen con O_NOFOLLOW +4. fijar el inode del origen con O_NOFOLLOW | O_NONBLOCK 5. reclamar atómicamente origen -> staging corto 6. verificar identidad del staging y anclajes -7. renombrar atómicamente staging -> destino con RENAME_NOREPLACE -8. verificar identidad del destino y anclajes -9. informar éxito +7. escanear de nuevo la categoría anclada buscando un destino equivalente por casefold +8. renombrar atómicamente staging -> destino exacto con RENAME_NOREPLACE +9. verificar identidad del destino y anclajes +10. informar éxito ``` -`RENAME_NOREPLACE` convierte la existencia del destino en parte de la propia operación atómica. No existe una comprobación `exists()` separada seguida de un rename que pueda reemplazar. +`RENAME_NOREPLACE` convierte la existencia del **nombre exacto del destino** en parte de la propia operación atómica. No existe una comprobación `exists()` separada seguida de un rename que pueda reemplazar. El escaneo `casefold()` previo detecta colisiones lógicas visibles en esa frontera, pero se documenta como una nueva comprobación y no como un lock atómico case-insensitive. La ruta segura normal no finaliza el movimiento con `unlink()` del staging. Así no se traslada la misma ventana check-to-unlink del nombre público a un nombre interno. @@ -273,8 +280,8 @@ El plan completo de varios archivos no es transaccional. La implementación hace explícitas las garantías por plataforma: -- **Linux:** ejecución segura con FDs anclados usa `renameat2(RENAME_NOREPLACE)` cuando está disponible; -- **Windows:** el fallback usa el comportamiento de `os.rename()` que rechaza un destino existente y verifica identidades alrededor de la operación; +- **Linux:** ejecución segura con FDs anclados usa `renameat2(RENAME_NOREPLACE)` cuando está disponible, con protección atómica no-replace para el nombre exacto del destino y nuevas comprobaciones `casefold()` durante la mutación; +- **Windows:** el fallback usa el comportamiento de `os.rename()` que rechaza un destino existente y realiza una nueva comprobación `casefold()` best-effort junto con validaciones de identidad alrededor de la operación; - **otros POSIX:** la ejecución genera `NotImplementedError` cuando no puede aplicar de forma segura la semántica no-replace requerida. Un ejemplo orientado a seguridad debe fallar honestamente en vez de degradar su contrato de forma silenciosa. @@ -290,9 +297,11 @@ Un ejemplo orientado a seguridad debe fallar honestamente en vez de degradar su 5. preflight de colisiones; 6. selección de capacidades de plataforma; 7. preparación de directorios anclados; -8. claim del origen y commit atómico no-replace; -9. verificación de destino y anclajes; -10. construcción de `OrganizationResult`. +8. pinning nonblocking y claim del origen; +9. nueva comprobación de colisión por `casefold()` durante la mutación; +10. commit atómico no-replace del nombre exacto; +11. verificación de destino y anclajes; +12. construcción de `OrganizationResult`. ## Determinismo @@ -334,7 +343,9 @@ La cobertura incluye: - políticas `ERROR` y `SKIP`; - orígenes ausentes u obsoletos; - destinos exactos tardíos; -- sustitución tardía del origen por symlink/archivo; +- destinos tardíos equivalentes por `casefold()` antes del commit final; +- sustitución tardía del origen por symlink/archivo/FIFO; +- pinning nonblocking del origen; - carreras de symlink y rename de categoría; - carreras de rename de la raíz; - staging de longitud fija; @@ -362,7 +373,11 @@ Se rechaza antes de planificar o ejecutar. ### Destino aparece después de la planificación -El preflight o el commit atómico no-replace genera `FileExistsError`. +El preflight y la nueva comprobación `casefold()` durante la mutación generan `FileExistsError` para las colisiones que observan. El `RENAME_NOREPLACE` final de Linux rechaza de forma atómica un destino con nombre exacto que aparezca en la frontera del commit. + +### El origen planificado se convierte en FIFO u otro archivo especial + +El pinning de Linux usa flags nonblocking y luego `fstat()` rechaza la sustitución por no ser un archivo regular, en lugar de bloquear la ejecución. ### Origen planificado cambia @@ -386,6 +401,14 @@ Mezclar descubrimiento y mutación hace difícil razonar sobre fallos parciales. Las entradas de directorio pueden sustituirse conservando el mismo nombre. Usa identidad del filesystem cuando esa diferencia importe. +### Abrir una ruta sustituible en modo bloqueante + +`O_NOFOLLOW` rechaza symlinks, pero no evita que un FIFO bloquee un `open()` de solo lectura. Usa pinning nonblocking antes de validar el tipo de archivo. + +### Suponer que un escaneo `casefold()` es un lock atómico + +Un escaneo en user space puede detectar colisiones lógicas sin distinción de caja, pero en un filesystem case-sensitive no puede impedir que aparezca después otro nombre con distinta combinación de mayúsculas/minúsculas. Mantén la garantía atómica limitada al nombre exacto aplicado por la primitiva del kernel. + ### Comprobar inmediatamente antes de `unlink()` Sigue existiendo una ventana check-to-unlink. Cuando importa la identidad de la eliminación, reestructura la operación en vez de añadir otra comprobación. @@ -439,7 +462,7 @@ Una explicación útil no es “escribí un script que mueve archivos”. Una versión más fuerte es: -> Diseñé un flujo de filesystem con planificación determinista, políticas explícitas de colisión, fronteras de symlink, identidad por inode, directorios anclados por descriptors, nombres de staging acotados y commit atómico no-replace en Linux mediante `renameat2(RENAME_NOREPLACE)`. El manejo de fallos conserva estado incierto en lugar de borrar entradas a ciegas. +> Diseñé un flujo de filesystem con planificación determinista, políticas explícitas de colisión, fronteras de symlink/archivo especial, identidad por inode, directorios anclados por descriptors, nombres de staging acotados, nuevas comprobaciones `casefold()` durante la mutación y commit atómico no-replace del nombre exacto en Linux mediante `renameat2(RENAME_NOREPLACE)`. El manejo de fallos conserva estado incierto en lugar de borrar entradas a ciegas. Eso comunica decisiones de ingeniería, no solo uso de APIs. @@ -456,10 +479,11 @@ Eso comunica decisiones de ingeniería, no solo uso de APIs. | Ejecutar el plan | `execute_plan()` | | Mantener destinos exitosos | `OrganizationResult` | | Identificar objetos del filesystem | `(st_dev, st_ino)` | -| Commit seguro en Linux | `renameat2(RENAME_NOREPLACE)` | +| Nueva comprobación lógica por `casefold()` | `listdir()` sobre el directorio anclado | +| Commit seguro del nombre exacto en Linux | `renameat2(RENAME_NOREPLACE)` | ## Qué sigue El Proyecto 05 generó archivos. El Proyecto 06 toma la siguiente frontera: descubrir y organizar archivos con seguridad. -El Proyecto 07 vuelve a subir de nivel, combinando registros de dominio validados y estados explícitos de workflow en un **flujo ficticio de conciliación**. +El Proyecto 07 vuelve a subir de nivel, combinando registros de dominio validados y estados explícitos de workflow en un **flujo ficticio de conciliación**. \ No newline at end of file From a4da42c82bd70eb53eba0597c977d64e9c5bce20 Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Tue, 1 Sep 2026 10:31:03 -0300 Subject: [PATCH 053/117] =?UTF-8?q?Sincronizar=20garant=C3=ADas=20FIFO=20y?= =?UTF-8?q?=20casefold=20em=20ES?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 67ac10ef78e57d3bb64d091257826554a4b537df Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Tue, 1 Sep 2026 11:50:15 -0300 Subject: [PATCH 054/117] Add temporary File Organizer recovery patch --- .../apply-file-organizer-recovery.yml | 249 ++++++++++++++++++ 1 file changed, 249 insertions(+) create mode 100644 .github/workflows/apply-file-organizer-recovery.yml diff --git a/.github/workflows/apply-file-organizer-recovery.yml b/.github/workflows/apply-file-organizer-recovery.yml new file mode 100644 index 0000000..8fa1bdb --- /dev/null +++ b/.github/workflows/apply-file-organizer-recovery.yml @@ -0,0 +1,249 @@ +name: Apply File Organizer recovery patch + +on: + push: + branches: + - phase-10-file-organizer + paths: + - .github/workflows/apply-file-organizer-recovery.yml + +permissions: + contents: write + +jobs: + patch: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Check out feature branch + uses: actions/checkout@v6 + with: + ref: phase-10-file-organizer + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.13" + + - name: Install test dependency + run: python -m pip install --disable-pip-version-check "pytest>=9.1,<9.2" + + - name: Apply focused staging-race recovery patch + shell: bash + run: | + python - <<'PY' + from pathlib import Path + + def replace_once(path: str, old: str, new: str) -> None: + target = Path(path) + text = target.read_text(encoding="utf-8") + if old not in text: + raise SystemExit(f"expected patch anchor not found in {path}") + if text.count(old) != 1: + raise SystemExit(f"patch anchor is not unique in {path}") + target.write_text(text.replace(old, new, 1), encoding="utf-8") + + implementation = "practical-projects/06-file-organizer/file_organizer.py" + tests = "practical-projects/06-file-organizer/tests/test_atomic_move.py" + + stage_anchor = '''def _make_stage_name(source_name: str) -> str: + """Return a fixed-length internal name independent of the source filename.""" + del source_name + return f".fo-stage-{secrets.token_hex(16)}" + + + def _preserve_stage_at(stage_name: str, source_name: str, *, root_fd: int) -> None: + ''' + stage_replacement = '''def _make_stage_name(source_name: str) -> str: + """Return a fixed-length internal name independent of the source filename.""" + del source_name + return f".fo-stage-{secrets.token_hex(16)}" + + + def _make_recovery_name(source_name: str) -> str: + """Return a bounded exclusive name for emergency source-data recovery.""" + del source_name + return f".fo-recovery-{secrets.token_hex(16)}" + + + def _recover_pinned_source_at( + source_fd: int, + source_name: str, + *, + root_fd: int, + ) -> str: + """Copy bytes from the pinned source FD into an exclusive recovery file.""" + source_stat = os.fstat(source_fd) + mode = stat.S_IMODE(source_stat.st_mode) + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL + if hasattr(os, "O_CLOEXEC"): + flags |= os.O_CLOEXEC + + recovery_fd: int | None = None + recovery_name = "" + for _ in range(16): + recovery_name = _make_recovery_name(source_name) + try: + recovery_fd = os.open( + recovery_name, + flags, + mode, + dir_fd=root_fd, + ) + except FileExistsError: + continue + break + if recovery_fd is None: + raise FileExistsError( + f"could not allocate recovery entry for planned source: {source_name}" + ) + + try: + os.lseek(source_fd, 0, os.SEEK_SET) + while True: + chunk = os.read(source_fd, 1024 * 1024) + if not chunk: + break + view = memoryview(chunk) + while view: + written = os.write(recovery_fd, view) + if written <= 0: + raise OSError("could not persist pinned source recovery data") + view = view[written:] + os.fchmod(recovery_fd, mode) + os.fsync(recovery_fd) + finally: + os.close(recovery_fd) + return recovery_name + + + def _preserve_stage_at(stage_name: str, source_name: str, *, root_fd: int) -> None: + ''' + replace_once(implementation, stage_anchor, stage_replacement) + + destination_anchor = ''' _verify_destination_identity_at( + destination_name, + destination_directory_fd=destination_directory_fd, + expected_identity=expected_identity, + ) + _verify_root_anchor_at(source_directory_path, source_directory_fd) + ''' + destination_replacement = ''' try: + _verify_destination_identity_at( + destination_name, + destination_directory_fd=destination_directory_fd, + expected_identity=expected_identity, + ) + except RuntimeError as exc: + recovery_name = _recover_pinned_source_at( + source_fd, + source_name, + root_fd=source_directory_fd, + ) + raise RuntimeError( + "destination does not match planned source; " + f"planned source data retained as {recovery_name}: {destination_name}" + ) from exc + _verify_root_anchor_at(source_directory_path, source_directory_fd) + ''' + replace_once(implementation, destination_anchor, destination_replacement) + + test_text = Path(tests).read_text(encoding="utf-8") + test_name = "test_staging_replacement_before_final_rename_preserves_pinned_source_data" + if test_name not in test_text: + test_text += ''' + + +def test_staging_replacement_before_final_rename_preserves_pinned_source_data( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + if not file_organizer._supports_secure_directory_fds(): + pytest.skip("secure directory descriptors are unavailable on this platform") + + source = tmp_path / "notes.txt" + source.write_text("planned source", encoding="utf-8") + plan = plan_organization(tmp_path) + destination = tmp_path / "documents" / "notes.txt" + original_rename_no_replace = file_organizer._rename_no_replace_at + raced = False + + def racing_rename_no_replace( + source_name: str, + destination_name: str, + *, + source_directory_fd: int, + destination_directory_fd: int, + ) -> None: + nonlocal raced + if not raced: + raced = True + stage = tmp_path / source_name + stage.unlink() + stage.write_text("third-party replacement", encoding="utf-8") + original_rename_no_replace( + source_name, + destination_name, + source_directory_fd=source_directory_fd, + destination_directory_fd=destination_directory_fd, + ) + + monkeypatch.setattr( + file_organizer, + "_rename_no_replace_at", + racing_rename_no_replace, + ) + + with pytest.raises(RuntimeError, match="planned source data retained"): + execute_plan(plan) + + assert destination.read_text(encoding="utf-8") == "third-party replacement" + recovery_files = [ + child for child in tmp_path.iterdir() if child.name.startswith(".fo-recovery-") + ] + assert len(recovery_files) == 1 + assert recovery_files[0].read_text(encoding="utf-8") == "planned source" + assert not source.exists() +''' + Path(tests).write_text(test_text, encoding="utf-8") + + docs = { + "practical-projects/06-file-organizer/README.md": ( + "If execution has already claimed the source into a staging entry and later detects an unsafe condition, it may create a no-replace hard link back to the original source name when possible. It does not blindly delete the staging entry.\n\n", + "If execution has already claimed the source into a staging entry and later detects an unsafe condition, it may create a no-replace hard link back to the original source name when possible. It does not blindly delete the staging entry.\n\nA staging pathname is not an inode lock. If the final rename consumes a replacement entry and destination identity verification detects the mismatch, execution keeps the unrelated destination intact and, before closing the still-pinned source file descriptor, copies the planned source bytes into an exclusive `.fo-recovery-*` regular file. This recovers the data rather than claiming the original inode survived.\n\n", + ), + "practical-projects/06-file-organizer/README.pt-BR.md": ( + "Se a execução já moveu a origem para staging e depois detecta condição insegura, ela pode criar um hard link no-replace de volta para o nome de origem quando possível. Ela não apaga cegamente o staging.\n\n", + "Se a execução já moveu a origem para staging e depois detecta condição insegura, ela pode criar um hard link no-replace de volta para o nome de origem quando possível. Ela não apaga cegamente o staging.\n\nUm pathname de staging não funciona como lock de inode. Se o rename final consumir uma entrada substituta e a verificação de identidade do destino detectar a divergência, a execução mantém intacto o destino alheio e, antes de fechar o descritor ainda pinado da origem, copia os bytes planejados para um arquivo regular exclusivo `.fo-recovery-*`. Essa recuperação preserva os dados; ela não afirma que o inode original sobreviveu.\n\n", + ), + "practical-projects/06-file-organizer/README.es.md": ( + "Si la ejecución ya movió el origen al staging y después detecta una condición insegura, puede crear un hard link no-replace de vuelta al nombre de origen cuando sea posible. No elimina a ciegas el staging.\n\n", + "Si la ejecución ya movió el origen al staging y después detecta una condición insegura, puede crear un hard link no-replace de vuelta al nombre de origen cuando sea posible. No elimina a ciegas el staging.\n\nUn pathname de staging no funciona como lock de inode. Si el rename final consume una entrada de reemplazo y la verificación de identidad del destino detecta la divergencia, la ejecución conserva intacto el destino ajeno y, antes de cerrar el descriptor aún anclado del origen, copia los bytes planificados a un archivo regular exclusivo `.fo-recovery-*`. Esta recuperación conserva los datos; no afirma que el inode original haya sobrevivido.\n\n", + ), + } + for path, (old, new) in docs.items(): + replace_once(path, old, new) + PY + + - name: Validate focused patch + run: | + python -m py_compile practical-projects/06-file-organizer/file_organizer.py practical-projects/06-file-organizer/tests/test_atomic_move.py + python -m pytest -q practical-projects/06-file-organizer/tests + python scripts/check_internal_links.py + python scripts/validate_repository_structure.py + git diff --check + + - name: Commit patch and remove temporary workflow + shell: bash + run: | + rm .github/workflows/apply-file-organizer-recovery.yml + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add practical-projects/06-file-organizer/file_organizer.py \ + practical-projects/06-file-organizer/tests/test_atomic_move.py \ + practical-projects/06-file-organizer/README.md \ + practical-projects/06-file-organizer/README.pt-BR.md \ + practical-projects/06-file-organizer/README.es.md \ + .github/workflows/apply-file-organizer-recovery.yml + git commit -m "Preserve source data after staging race" + git push origin HEAD:phase-10-file-organizer From 9d734e98e59592bddda83d9b2184b80648de3440 Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Tue, 1 Sep 2026 11:55:00 -0300 Subject: [PATCH 055/117] Fix temporary recovery patch workflow --- .../apply-file-organizer-recovery.yml | 208 +----------------- 1 file changed, 6 insertions(+), 202 deletions(-) diff --git a/.github/workflows/apply-file-organizer-recovery.yml b/.github/workflows/apply-file-organizer-recovery.yml index 8fa1bdb..c49630c 100644 --- a/.github/workflows/apply-file-organizer-recovery.yml +++ b/.github/workflows/apply-file-organizer-recovery.yml @@ -28,202 +28,12 @@ jobs: - name: Install test dependency run: python -m pip install --disable-pip-version-check "pytest>=9.1,<9.2" - - name: Apply focused staging-race recovery patch - shell: bash + - name: Apply focused patch + env: + PATCH_B64: "ZnJvbSBwYXRobGliIGltcG9ydCBQYXRoCgpkZWYgcmVwbGFjZV9vbmNlKHBhdGg6IHN0ciwgb2xkOiBzdHIsIG5ldzogc3RyKSAtPiBOb25lOgogICAgdGFyZ2V0ID0gUGF0aChwYXRoKQogICAgdGV4dCA9IHRhcmdldC5yZWFkX3RleHQoZW5jb2Rpbmc9InV0Zi04IikKICAgIGlmIG9sZCBub3QgaW4gdGV4dDoKICAgICAgICByYWlzZSBTeXN0ZW1FeGl0KGYiZXhwZWN0ZWQgcGF0Y2ggYW5jaG9yIG5vdCBmb3VuZCBpbiB7cGF0aH0iKQogICAgaWYgdGV4dC5jb3VudChvbGQpICE9IDE6CiAgICAgICAgcmFpc2UgU3lzdGVtRXhpdChmInBhdGNoIGFuY2hvciBpcyBub3QgdW5pcXVlIGluIHtwYXRofSIpCiAgICB0YXJnZXQud3JpdGVfdGV4dCh0ZXh0LnJlcGxhY2Uob2xkLCBuZXcsIDEpLCBlbmNvZGluZz0idXRmLTgiKQoKaW1wbGVtZW50YXRpb24gPSAicHJhY3RpY2FsLXByb2plY3RzLzA2LWZpbGUtb3JnYW5pemVyL2ZpbGVfb3JnYW5pemVyLnB5Igp0ZXN0cyA9ICJwcmFjdGljYWwtcHJvamVjdHMvMDYtZmlsZS1vcmdhbml6ZXIvdGVzdHMvdGVzdF9hdG9taWNfbW92ZS5weSIKCnByZXNlcnZlX2FuY2hvciA9ICJkZWYgX3ByZXNlcnZlX3N0YWdlX2F0KHN0YWdlX25hbWU6IHN0ciwgc291cmNlX25hbWU6IHN0ciwgKiwgcm9vdF9mZDogaW50KSAtPiBOb25lOlxuIgpyZWNvdmVyeV9jb2RlID0gIiIiZGVmIF9tYWtlX3JlY292ZXJ5X25hbWUoc291cmNlX25hbWU6IHN0cikgLT4gc3RyOgogICAgIyBSZXR1cm4gYSBib3VuZGVkIGV4Y2x1c2l2ZSBuYW1lIGZvciBlbWVyZ2VuY3kgc291cmNlLWRhdGEgcmVjb3ZlcnkuCiAgICBkZWwgc291cmNlX25hbWUKICAgIHJldHVybiBmIi5mby1yZWNvdmVyeS17c2VjcmV0cy50b2tlbl9oZXgoMTYpfSIKCgpkZWYgX3JlY292ZXJfcGlubmVkX3NvdXJjZV9hdCgKICAgIHNvdXJjZV9mZDogaW50LAogICAgc291cmNlX25hbWU6IHN0ciwKICAgICosCiAgICByb290X2ZkOiBpbnQsCikgLT4gc3RyOgogICAgIyBDb3B5IGJ5dGVzIGZyb20gdGhlIHBpbm5lZCBzb3VyY2UgRkQgaW50byBhbiBleGNsdXNpdmUgcmVjb3ZlcnkgZmlsZS4KICAgIHNvdXJjZV9zdGF0ID0gb3MuZnN0YXQoc291cmNlX2ZkKQogICAgbW9kZSA9IHN0YXQuU19JTU9ERShzb3VyY2Vfc3RhdC5zdF9tb2RlKQogICAgZmxhZ3MgPSBvcy5PX1dST05MWSB8IG9zLk9fQ1JFQVQgfCBvcy5PX0VYQ0wKICAgIGlmIGhhc2F0dHIob3MsICJPX0NMT0VYRUMiKToKICAgICAgICBmbGFncyB8PSBvcy5PX0NMT0VYRUMKCiAgICByZWNvdmVyeV9mZDogaW50IHwgTm9uZSA9IE5vbmUKICAgIHJlY292ZXJ5X25hbWUgPSAiIgogICAgZm9yIF8gaW4gcmFuZ2UoMTYpOgogICAgICAgIHJlY292ZXJ5X25hbWUgPSBfbWFrZV9yZWNvdmVyeV9uYW1lKHNvdXJjZV9uYW1lKQogICAgICAgIHRyeToKICAgICAgICAgICAgcmVjb3ZlcnlfZmQgPSBvcy5vcGVuKAogICAgICAgICAgICAgICAgcmVjb3ZlcnlfbmFtZSwKICAgICAgICAgICAgICAgIGZsYWdzLAogICAgICAgICAgICAgICAgbW9kZSwKICAgICAgICAgICAgICAgIGRpcl9mZD1yb290X2ZkLAogICAgICAgICAgICApCiAgICAgICAgZXhjZXB0IEZpbGVFeGlzdHNFcnJvcjoKICAgICAgICAgICAgY29udGludWUKICAgICAgICBicmVhawogICAgaWYgcmVjb3ZlcnlfZmQgaXMgTm9uZToKICAgICAgICByYWlzZSBGaWxlRXhpc3RzRXJyb3IoCiAgICAgICAgICAgIGYiY291bGQgbm90IGFsbG9jYXRlIHJlY292ZXJ5IGVudHJ5IGZvciBwbGFubmVkIHNvdXJjZToge3NvdXJjZV9uYW1lfSIKICAgICAgICApCgogICAgdHJ5OgogICAgICAgIG9zLmxzZWVrKHNvdXJjZV9mZCwgMCwgb3MuU0VFS19TRVQpCiAgICAgICAgd2hpbGUgVHJ1ZToKICAgICAgICAgICAgY2h1bmsgPSBvcy5yZWFkKHNvdXJjZV9mZCwgMTAyNCAqIDEwMjQpCiAgICAgICAgICAgIGlmIG5vdCBjaHVuazoKICAgICAgICAgICAgICAgIGJyZWFrCiAgICAgICAgICAgIHZpZXcgPSBtZW1vcnl2aWV3KGNodW5rKQogICAgICAgICAgICB3aGlsZSB2aWV3OgogICAgICAgICAgICAgICAgd3JpdHRlbiA9IG9zLndyaXRlKHJlY292ZXJ5X2ZkLCB2aWV3KQogICAgICAgICAgICAgICAgaWYgd3JpdHRlbiA8PSAwOgogICAgICAgICAgICAgICAgICAgIHJhaXNlIE9TRXJyb3IoImNvdWxkIG5vdCBwZXJzaXN0IHBpbm5lZCBzb3VyY2UgcmVjb3ZlcnkgZGF0YSIpCiAgICAgICAgICAgICAgICB2aWV3ID0gdmlld1t3cml0dGVuOl0KICAgICAgICBvcy5mY2htb2QocmVjb3ZlcnlfZmQsIG1vZGUpCiAgICAgICAgb3MuZnN5bmMocmVjb3ZlcnlfZmQpCiAgICBmaW5hbGx5OgogICAgICAgIG9zLmNsb3NlKHJlY292ZXJ5X2ZkKQogICAgcmV0dXJuIHJlY292ZXJ5X25hbWUKCgoiIiIKcmVwbGFjZV9vbmNlKGltcGxlbWVudGF0aW9uLCBwcmVzZXJ2ZV9hbmNob3IsIHJlY292ZXJ5X2NvZGUgKyBwcmVzZXJ2ZV9hbmNob3IpCgpkZXN0aW5hdGlvbl9hbmNob3IgPSAiIiIgICAgICAgIF92ZXJpZnlfZGVzdGluYXRpb25faWRlbnRpdHlfYXQoCiAgICAgICAgICAgIGRlc3RpbmF0aW9uX25hbWUsCiAgICAgICAgICAgIGRlc3RpbmF0aW9uX2RpcmVjdG9yeV9mZD1kZXN0aW5hdGlvbl9kaXJlY3RvcnlfZmQsCiAgICAgICAgICAgIGV4cGVjdGVkX2lkZW50aXR5PWV4cGVjdGVkX2lkZW50aXR5LAogICAgICAgICkKICAgICAgICBfdmVyaWZ5X3Jvb3RfYW5jaG9yX2F0KHNvdXJjZV9kaXJlY3RvcnlfcGF0aCwgc291cmNlX2RpcmVjdG9yeV9mZCkKIiIiCmRlc3RpbmF0aW9uX3JlcGxhY2VtZW50ID0gIiIiICAgICAgICB0cnk6CiAgICAgICAgICAgIF92ZXJpZnlfZGVzdGluYXRpb25faWRlbnRpdHlfYXQoCiAgICAgICAgICAgICAgICBkZXN0aW5hdGlvbl9uYW1lLAogICAgICAgICAgICAgICAgZGVzdGluYXRpb25fZGlyZWN0b3J5X2ZkPWRlc3RpbmF0aW9uX2RpcmVjdG9yeV9mZCwKICAgICAgICAgICAgICAgIGV4cGVjdGVkX2lkZW50aXR5PWV4cGVjdGVkX2lkZW50aXR5LAogICAgICAgICAgICApCiAgICAgICAgZXhjZXB0IFJ1bnRpbWVFcnJvciBhcyBleGM6CiAgICAgICAgICAgIHJlY292ZXJ5X25hbWUgPSBfcmVjb3Zlcl9waW5uZWRfc291cmNlX2F0KAogICAgICAgICAgICAgICAgc291cmNlX2ZkLAogICAgICAgICAgICAgICAgc291cmNlX25hbWUsCiAgICAgICAgICAgICAgICByb290X2ZkPXNvdXJjZV9kaXJlY3RvcnlfZmQsCiAgICAgICAgICAgICkKICAgICAgICAgICAgcmFpc2UgUnVudGltZUVycm9yKAogICAgICAgICAgICAgICAgImRlc3RpbmF0aW9uIGRvZXMgbm90IG1hdGNoIHBsYW5uZWQgc291cmNlOyAiCiAgICAgICAgICAgICAgICBmInBsYW5uZWQgc291cmNlIGRhdGEgcmV0YWluZWQgYXMge3JlY292ZXJ5X25hbWV9OiB7ZGVzdGluYXRpb25fbmFtZX0iCiAgICAgICAgICAgICkgZnJvbSBleGMKICAgICAgICBfdmVyaWZ5X3Jvb3RfYW5jaG9yX2F0KHNvdXJjZV9kaXJlY3RvcnlfcGF0aCwgc291cmNlX2RpcmVjdG9yeV9mZCkKIiIiCnJlcGxhY2Vfb25jZShpbXBsZW1lbnRhdGlvbiwgZGVzdGluYXRpb25fYW5jaG9yLCBkZXN0aW5hdGlvbl9yZXBsYWNlbWVudCkKCnRlc3RfdGV4dCA9IFBhdGgodGVzdHMpLnJlYWRfdGV4dChlbmNvZGluZz0idXRmLTgiKQp0ZXN0X25hbWUgPSAidGVzdF9zdGFnaW5nX3JlcGxhY2VtZW50X2JlZm9yZV9maW5hbF9yZW5hbWVfcHJlc2VydmVzX3Bpbm5lZF9zb3VyY2VfZGF0YSIKaWYgdGVzdF9uYW1lIG5vdCBpbiB0ZXN0X3RleHQ6CiAgICB0ZXN0X3RleHQgKz0gIiIiCgpkZWYgdGVzdF9zdGFnaW5nX3JlcGxhY2VtZW50X2JlZm9yZV9maW5hbF9yZW5hbWVfcHJlc2VydmVzX3Bpbm5lZF9zb3VyY2VfZGF0YSgKICAgIG1vbmtleXBhdGNoOiBweXRlc3QuTW9ua2V5UGF0Y2gsCiAgICB0bXBfcGF0aDogUGF0aCwKKSAtPiBOb25lOgogICAgaWYgbm90IGZpbGVfb3JnYW5pemVyLl9zdXBwb3J0c19zZWN1cmVfZGlyZWN0b3J5X2ZkcygpOgogICAgICAgIHB5dGVzdC5za2lwKCJzZWN1cmUgZGlyZWN0b3J5IGRlc2NyaXB0b3JzIGFyZSB1bmF2YWlsYWJsZSBvbiB0aGlzIHBsYXRmb3JtIikKCiAgICBzb3VyY2UgPSB0bXBfcGF0aCAvICJub3Rlcy50eHQiCiAgICBzb3VyY2Uud3JpdGVfdGV4dCgicGxhbm5lZCBzb3VyY2UiLCBlbmNvZGluZz0idXRmLTgiKQogICAgcGxhbiA9IHBsYW5fb3JnYW5pemF0aW9uKHRtcF9wYXRoKQogICAgZGVzdGluYXRpb24gPSB0bXBfcGF0aCAvICJkb2N1bWVudHMiIC8gIm5vdGVzLnR4dCIKICAgIG9yaWdpbmFsX3JlbmFtZV9ub19yZXBsYWNlID0gZmlsZV9vcmdhbml6ZXIuX3JlbmFtZV9ub19yZXBsYWNlX2F0CiAgICByYWNlZCA9IEZhbHNlCgogICAgZGVmIHJhY2luZ19yZW5hbWVfbm9fcmVwbGFjZSgKICAgICAgICBzb3VyY2VfbmFtZTogc3RyLAogICAgICAgIGRlc3RpbmF0aW9uX25hbWU6IHN0ciwKICAgICAgICAqLAogICAgICAgIHNvdXJjZV9kaXJlY3RvcnlfZmQ6IGludCwKICAgICAgICBkZXN0aW5hdGlvbl9kaXJlY3RvcnlfZmQ6IGludCwKICAgICkgLT4gTm9uZToKICAgICAgICBub25sb2NhbCByYWNlZAogICAgICAgIGlmIG5vdCByYWNlZDoKICAgICAgICAgICAgcmFjZWQgPSBUcnVlCiAgICAgICAgICAgIHN0YWdlID0gdG1wX3BhdGggLyBzb3VyY2VfbmFtZQogICAgICAgICAgICBzdGFnZS51bmxpbmsoKQogICAgICAgICAgICBzdGFnZS53cml0ZV90ZXh0KCJ0aGlyZC1wYXJ0eSByZXBsYWNlbWVudCIsIGVuY29kaW5nPSJ1dGYtOCIpCiAgICAgICAgb3JpZ2luYWxfcmVuYW1lX25vX3JlcGxhY2UoCiAgICAgICAgICAgIHNvdXJjZV9uYW1lLAogICAgICAgICAgICBkZXN0aW5hdGlvbl9uYW1lLAogICAgICAgICAgICBzb3VyY2VfZGlyZWN0b3J5X2ZkPXNvdXJjZV9kaXJlY3RvcnlfZmQsCiAgICAgICAgICAgIGRlc3RpbmF0aW9uX2RpcmVjdG9yeV9mZD1kZXN0aW5hdGlvbl9kaXJlY3RvcnlfZmQsCiAgICAgICAgKQoKICAgIG1vbmtleXBhdGNoLnNldGF0dHIoCiAgICAgICAgZmlsZV9vcmdhbml6ZXIsCiAgICAgICAgIl9yZW5hbWVfbm9fcmVwbGFjZV9hdCIsCiAgICAgICAgcmFjaW5nX3JlbmFtZV9ub19yZXBsYWNlLAogICAgKQoKICAgIHdpdGggcHl0ZXN0LnJhaXNlcyhSdW50aW1lRXJyb3IsIG1hdGNoPSJwbGFubmVkIHNvdXJjZSBkYXRhIHJldGFpbmVkIik6CiAgICAgICAgZXhlY3V0ZV9wbGFuKHBsYW4pCgogICAgYXNzZXJ0IGRlc3RpbmF0aW9uLnJlYWRfdGV4dChlbmNvZGluZz0idXRmLTgiKSA9PSAidGhpcmQtcGFydHkgcmVwbGFjZW1lbnQiCiAgICByZWNvdmVyeV9maWxlcyA9IFsKICAgICAgICBjaGlsZCBmb3IgY2hpbGQgaW4gdG1wX3BhdGguaXRlcmRpcigpIGlmIGNoaWxkLm5hbWUuc3RhcnRzd2l0aCgiLmZvLXJlY292ZXJ5LSIpCiAgICBdCiAgICBhc3NlcnQgbGVuKHJlY292ZXJ5X2ZpbGVzKSA9PSAxCiAgICBhc3NlcnQgcmVjb3ZlcnlfZmlsZXNbMF0ucmVhZF90ZXh0KGVuY29kaW5nPSJ1dGYtOCIpID09ICJwbGFubmVkIHNvdXJjZSIKICAgIGFzc2VydCBub3Qgc291cmNlLmV4aXN0cygpCiIiIgogICAgUGF0aCh0ZXN0cykud3JpdGVfdGV4dCh0ZXN0X3RleHQsIGVuY29kaW5nPSJ1dGYtOCIpCgpkb2NzID0gewogICAgInByYWN0aWNhbC1wcm9qZWN0cy8wNi1maWxlLW9yZ2FuaXplci9SRUFETUUubWQiOiAoCiAgICAgICAgIklmIGV4ZWN1dGlvbiBoYXMgYWxyZWFkeSBjbGFpbWVkIHRoZSBzb3VyY2UgaW50byBhIHN0YWdpbmcgZW50cnkgYW5kIGxhdGVyIGRldGVjdHMgYW4gdW5zYWZlIGNvbmRpdGlvbiwgaXQgbWF5IGNyZWF0ZSBhIG5vLXJlcGxhY2UgaGFyZCBsaW5rIGJhY2sgdG8gdGhlIG9yaWdpbmFsIHNvdXJjZSBuYW1lIHdoZW4gcG9zc2libGUuIEl0IGRvZXMgbm90IGJsaW5kbHkgZGVsZXRlIHRoZSBzdGFnaW5nIGVudHJ5LlxuXG4iLAogICAgICAgICJJZiBleGVjdXRpb24gaGFzIGFscmVhZHkgY2xhaW1lZCB0aGUgc291cmNlIGludG8gYSBzdGFnaW5nIGVudHJ5IGFuZCBsYXRlciBkZXRlY3RzIGFuIHVuc2FmZSBjb25kaXRpb24sIGl0IG1heSBjcmVhdGUgYSBuby1yZXBsYWNlIGhhcmQgbGluayBiYWNrIHRvIHRoZSBvcmlnaW5hbCBzb3VyY2UgbmFtZSB3aGVuIHBvc3NpYmxlLiBJdCBkb2VzIG5vdCBibGluZGx5IGRlbGV0ZSB0aGUgc3RhZ2luZyBlbnRyeS5cblxuQSBzdGFnaW5nIHBhdGhuYW1lIGlzIG5vdCBhbiBpbm9kZSBsb2NrLiBJZiB0aGUgZmluYWwgcmVuYW1lIGNvbnN1bWVzIGEgcmVwbGFjZW1lbnQgZW50cnkgYW5kIGRlc3RpbmF0aW9uIGlkZW50aXR5IHZlcmlmaWNhdGlvbiBkZXRlY3RzIHRoZSBtaXNtYXRjaCwgZXhlY3V0aW9uIGtlZXBzIHRoZSB1bnJlbGF0ZWQgZGVzdGluYXRpb24gaW50YWN0IGFuZCwgYmVmb3JlIGNsb3NpbmcgdGhlIHN0aWxsLXBpbm5lZCBzb3VyY2UgZmlsZSBkZXNjcmlwdG9yLCBjb3BpZXMgdGhlIHBsYW5uZWQgc291cmNlIGJ5dGVzIGludG8gYW4gZXhjbHVzaXZlIGA uZm8tcmVjb3ZlcnktKmAgcmVndWxhciBmaWxlLiBUaGlzIHJlY292ZXJzIHRoZSBkYXRhIHJhdGhlciB0aGFuIGNsYWltaW5nIHRoZSBvcmlnaW5hbCBpbm9kZSBzdXJ2aXZlZC5cblxuIiwKICAgICksCiAgICAicHJhY3RpY2FsLXByb2plY3RzLzA2LWZpbGUtb3JnYW5pemVyL1JFQURNRS5wdC1CUi5tZCI6ICgKICAgICAgICAiU2UgYSBleGVjdcOnw6NvIGrDoSBtb3ZldSBhIG9yaWdlbSBwYXJhIHN0YWdpbmcgZSBkZXBvaXMgZGV0ZWN0YSBjb25kacOnw6NvIGluc2VndXJhLCBlbGEgcG9kZSBjcmlhciB1bSBoYXJkIGxpbmsgbm8tcmVwbGFjZSBkZSB2b2x0YSBwYXJhIG8gbm9tZSBkZSBvcmlnZW0gcXVhbmRvIHBvc3PDqXZlbC4gRWxhIG7Do28gYXBhZ2EgY2VnYW1lbnRlIG8gc3RhZ2luZy5cblxuIiwKICAgICAgICAiU2UgYSBleGVjdcOnw6NvIGrDoSBtb3ZldSBhIG9yaWdlbSBwYXJhIHN0YWdpbmcgZSBkZXBvaXMgZGV0ZWN0YSBjb25kacOnw6NvIGluc2VndXJhLCBlbGEgcG9kZSBjcmlhciB1bSBoYXJkIGxpbmsgbm8tcmVwbGFjZSBkZSB2b2x0YSBwYXJhIG8gbm9tZSBkZSBvcmlnZW0gcXVhbmRvIHBvc3PDqXZlbC4gRWxhIG7Do28gYXBhZ2EgY2VnYW1lbnRlIG8gc3RhZ2luZy5cblxuVW0gcGF0aG5hbWUgZGUgc3RhZ2luZyBuw6NvIGZ1bmNpb25hIGNvbW8gbG9jayBkZSBpbm9kZS4gU2UgbyByZW5hbWUgZmluYWwgY29uc3VtaXIgdW1hIGVudHJhZGEgc3Vic3RpdHV0YSBlIGEgdmVyaWZpY2HDp8OjbyBkZSBpZGVudGlkYWRlIGRvIGRlc3Rpbm8gZGV0ZWN0YXIgYSBkaXZlcmfDqm5jaWEsIGEgZXhlY3XDp8OjbyBtYW50w6ltIGludGFjdG8gbyBkZXN0aW5vIGFsaGVpbyBlLCBhbnRlcyBkZSBmZWNoYXIgbyBkZXNjcml0b3IgYWluZGEgcGluYWRvIGRhIG9yaWdlbSwgY29waWEgb3MgYnl0ZXMgcGxhbmVqYWRvcyBwYXJhIHVtIGFycXVpdm8gcmVndWxhciBleGNsdXNpdm8gYC5mby1yZWNvdmVyeS0qYC4gRXNzYSByZWN1cGVyYcOnw6NvIHByZXNlcnZhIG9zIGRhZG9zOyBlbGEgbsOjbyBhZmlybWEgcXVlIG8gaW5vZGUgb3JpZ2luYWwgc29icmV2aXZldS5cblxuIiwKICAgICksCiAgICAicHJhY3RpY2FsLXByb2plY3RzLzA2LWZpbGUtb3JnYW5pemVyL1JFQURNRS5lcy5tZCI6ICgKICAgICAgICAiU2kgbGEgZWplY3VjacOzbiB5YSBtb3Zpw7MgZWwgb3JpZ2VuIGFsIHN0YWdpbmcgeSBkZXNwdcOpcyBkZXRlY3RhIHVuYSBjb25kaWNpw7NuIGluc2VndXJhLCBwdWVkZSBjcmVhciB1biBoYXJkIGxpbmsgbm8tcmVwbGFjZSBkZSB2dWVsdGEgYWwgbm9tYnJlIGRlIG9yaWdlbiBjdWFuZG8gc2VhIHBvc2libGUuIE5vIGVsaW1pbmEgYSBjaWVnYXMgZWwgc3RhZ2luZy5cblxuIiwKICAgICAgICAiU2kgbGEgZWplY3VjacOzbiB5YSBtb3Zpw7MgZWwgb3JpZ2VuIGFsIHN0YWdpbmcgeSBkZXNwdcOpcyBkZXRlY3RhIHVuYSBjb25kaWNpw7NuIGluc2VndXJhLCBwdWVkZSBjcmVhciB1biBoYXJkIGxpbmsgbm8tcmVwbGFjZSBkZSB2dWVsdGEgYWwgbm9tYnJlIGRlIG9yaWdlbiBjdWFuZG8gc2VhIHBvc2libGUuIE5vIGVsaW1pbmEgYSBjaWVnYXMgZWwgc3RhZ2luZy5cblxuVW4gcGF0aG5hbWUgZGUgc3RhZ2luZyBubyBmdW5jaW9uYSBjb21vIGxvY2sgZGUgaW5vZGUuIFNpIGVsIHJlbmFtZSBmaW5hbCBjb25zdW1lIHVuYSBlbnRyYWRhIGRlIHJlZW1wbGF6byB5IGxhIHZlcmlmaWNhY2nDs24gZGUgaWRlbnRpZGFkIGRlbCBkZXN0aW5vIGRldGVjdGEgbGEgZGl2ZXJnZW5jaWEsIGxhIGVqZWN1Y2nDs24gY29uc2VydmEgaW50YWN0byBlbCBkZXN0aW5vIGFqZW5vIHksIGFudGVzIGRlIGNlcnJhciBlbCBkZXNjcmlwdG9yIGHDum4gYW5jbGFkbyBkZWwgb3JpZ2VuLCBjb3BpYSBsb3MgYnl0ZXMgcGxhbmlmaWNhZG9zIGEg dW4gYXJjaGl2byByZWd1bGFyIGV4Y2x1c2l2byBgLmZvLXJlY292ZXJ5LSpgLiBFc3RhIHJlY3VwZXJhY2nDs24gY29uc2VydmEgbG9zIGRhdG9zOyBubyBhZmlybWEgcXVlIGVsIGlub2RlIG9yaWdpbmFsIGhheWEgc29icmV2aXZpZG8uXG5cbiIsCiAgICApLAp9CmZvciBwYXRoLCAob2xkLCBuZXcpIGluIGRvY3MuaXRlbXMoKToKICAgIHJlcGxhY2Vfb25jZShwYXRoLCBvbGQsIG5ldykK" run: | - python - <<'PY' - from pathlib import Path - - def replace_once(path: str, old: str, new: str) -> None: - target = Path(path) - text = target.read_text(encoding="utf-8") - if old not in text: - raise SystemExit(f"expected patch anchor not found in {path}") - if text.count(old) != 1: - raise SystemExit(f"patch anchor is not unique in {path}") - target.write_text(text.replace(old, new, 1), encoding="utf-8") - - implementation = "practical-projects/06-file-organizer/file_organizer.py" - tests = "practical-projects/06-file-organizer/tests/test_atomic_move.py" - - stage_anchor = '''def _make_stage_name(source_name: str) -> str: - """Return a fixed-length internal name independent of the source filename.""" - del source_name - return f".fo-stage-{secrets.token_hex(16)}" - - - def _preserve_stage_at(stage_name: str, source_name: str, *, root_fd: int) -> None: - ''' - stage_replacement = '''def _make_stage_name(source_name: str) -> str: - """Return a fixed-length internal name independent of the source filename.""" - del source_name - return f".fo-stage-{secrets.token_hex(16)}" - - - def _make_recovery_name(source_name: str) -> str: - """Return a bounded exclusive name for emergency source-data recovery.""" - del source_name - return f".fo-recovery-{secrets.token_hex(16)}" - - - def _recover_pinned_source_at( - source_fd: int, - source_name: str, - *, - root_fd: int, - ) -> str: - """Copy bytes from the pinned source FD into an exclusive recovery file.""" - source_stat = os.fstat(source_fd) - mode = stat.S_IMODE(source_stat.st_mode) - flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL - if hasattr(os, "O_CLOEXEC"): - flags |= os.O_CLOEXEC - - recovery_fd: int | None = None - recovery_name = "" - for _ in range(16): - recovery_name = _make_recovery_name(source_name) - try: - recovery_fd = os.open( - recovery_name, - flags, - mode, - dir_fd=root_fd, - ) - except FileExistsError: - continue - break - if recovery_fd is None: - raise FileExistsError( - f"could not allocate recovery entry for planned source: {source_name}" - ) - - try: - os.lseek(source_fd, 0, os.SEEK_SET) - while True: - chunk = os.read(source_fd, 1024 * 1024) - if not chunk: - break - view = memoryview(chunk) - while view: - written = os.write(recovery_fd, view) - if written <= 0: - raise OSError("could not persist pinned source recovery data") - view = view[written:] - os.fchmod(recovery_fd, mode) - os.fsync(recovery_fd) - finally: - os.close(recovery_fd) - return recovery_name - - - def _preserve_stage_at(stage_name: str, source_name: str, *, root_fd: int) -> None: - ''' - replace_once(implementation, stage_anchor, stage_replacement) - - destination_anchor = ''' _verify_destination_identity_at( - destination_name, - destination_directory_fd=destination_directory_fd, - expected_identity=expected_identity, - ) - _verify_root_anchor_at(source_directory_path, source_directory_fd) - ''' - destination_replacement = ''' try: - _verify_destination_identity_at( - destination_name, - destination_directory_fd=destination_directory_fd, - expected_identity=expected_identity, - ) - except RuntimeError as exc: - recovery_name = _recover_pinned_source_at( - source_fd, - source_name, - root_fd=source_directory_fd, - ) - raise RuntimeError( - "destination does not match planned source; " - f"planned source data retained as {recovery_name}: {destination_name}" - ) from exc - _verify_root_anchor_at(source_directory_path, source_directory_fd) - ''' - replace_once(implementation, destination_anchor, destination_replacement) - - test_text = Path(tests).read_text(encoding="utf-8") - test_name = "test_staging_replacement_before_final_rename_preserves_pinned_source_data" - if test_name not in test_text: - test_text += ''' - - -def test_staging_replacement_before_final_rename_preserves_pinned_source_data( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - if not file_organizer._supports_secure_directory_fds(): - pytest.skip("secure directory descriptors are unavailable on this platform") - - source = tmp_path / "notes.txt" - source.write_text("planned source", encoding="utf-8") - plan = plan_organization(tmp_path) - destination = tmp_path / "documents" / "notes.txt" - original_rename_no_replace = file_organizer._rename_no_replace_at - raced = False - - def racing_rename_no_replace( - source_name: str, - destination_name: str, - *, - source_directory_fd: int, - destination_directory_fd: int, - ) -> None: - nonlocal raced - if not raced: - raced = True - stage = tmp_path / source_name - stage.unlink() - stage.write_text("third-party replacement", encoding="utf-8") - original_rename_no_replace( - source_name, - destination_name, - source_directory_fd=source_directory_fd, - destination_directory_fd=destination_directory_fd, - ) - - monkeypatch.setattr( - file_organizer, - "_rename_no_replace_at", - racing_rename_no_replace, - ) - - with pytest.raises(RuntimeError, match="planned source data retained"): - execute_plan(plan) - - assert destination.read_text(encoding="utf-8") == "third-party replacement" - recovery_files = [ - child for child in tmp_path.iterdir() if child.name.startswith(".fo-recovery-") - ] - assert len(recovery_files) == 1 - assert recovery_files[0].read_text(encoding="utf-8") == "planned source" - assert not source.exists() -''' - Path(tests).write_text(test_text, encoding="utf-8") - - docs = { - "practical-projects/06-file-organizer/README.md": ( - "If execution has already claimed the source into a staging entry and later detects an unsafe condition, it may create a no-replace hard link back to the original source name when possible. It does not blindly delete the staging entry.\n\n", - "If execution has already claimed the source into a staging entry and later detects an unsafe condition, it may create a no-replace hard link back to the original source name when possible. It does not blindly delete the staging entry.\n\nA staging pathname is not an inode lock. If the final rename consumes a replacement entry and destination identity verification detects the mismatch, execution keeps the unrelated destination intact and, before closing the still-pinned source file descriptor, copies the planned source bytes into an exclusive `.fo-recovery-*` regular file. This recovers the data rather than claiming the original inode survived.\n\n", - ), - "practical-projects/06-file-organizer/README.pt-BR.md": ( - "Se a execução já moveu a origem para staging e depois detecta condição insegura, ela pode criar um hard link no-replace de volta para o nome de origem quando possível. Ela não apaga cegamente o staging.\n\n", - "Se a execução já moveu a origem para staging e depois detecta condição insegura, ela pode criar um hard link no-replace de volta para o nome de origem quando possível. Ela não apaga cegamente o staging.\n\nUm pathname de staging não funciona como lock de inode. Se o rename final consumir uma entrada substituta e a verificação de identidade do destino detectar a divergência, a execução mantém intacto o destino alheio e, antes de fechar o descritor ainda pinado da origem, copia os bytes planejados para um arquivo regular exclusivo `.fo-recovery-*`. Essa recuperação preserva os dados; ela não afirma que o inode original sobreviveu.\n\n", - ), - "practical-projects/06-file-organizer/README.es.md": ( - "Si la ejecución ya movió el origen al staging y después detecta una condición insegura, puede crear un hard link no-replace de vuelta al nombre de origen cuando sea posible. No elimina a ciegas el staging.\n\n", - "Si la ejecución ya movió el origen al staging y después detecta una condición insegura, puede crear un hard link no-replace de vuelta al nombre de origen cuando sea posible. No elimina a ciegas el staging.\n\nUn pathname de staging no funciona como lock de inode. Si el rename final consume una entrada de reemplazo y la verificación de identidad del destino detecta la divergencia, la ejecución conserva intacto el destino ajeno y, antes de cerrar el descriptor aún anclado del origen, copia los bytes planificados a un archivo regular exclusivo `.fo-recovery-*`. Esta recuperación conserva los datos; no afirma que el inode original haya sobrevivido.\n\n", - ), - } - for path, (old, new) in docs.items(): - replace_once(path, old, new) - PY + printf '%s' "$PATCH_B64" | base64 --decode > /tmp/file_organizer_patch.py + python /tmp/file_organizer_patch.py - name: Validate focused patch run: | @@ -234,16 +44,10 @@ def test_staging_replacement_before_final_rename_preserves_pinned_source_data( git diff --check - name: Commit patch and remove temporary workflow - shell: bash run: | rm .github/workflows/apply-file-organizer-recovery.yml git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add practical-projects/06-file-organizer/file_organizer.py \ - practical-projects/06-file-organizer/tests/test_atomic_move.py \ - practical-projects/06-file-organizer/README.md \ - practical-projects/06-file-organizer/README.pt-BR.md \ - practical-projects/06-file-organizer/README.es.md \ - .github/workflows/apply-file-organizer-recovery.yml + git add -A git commit -m "Preserve source data after staging race" git push origin HEAD:phase-10-file-organizer From 4b74d58c32d46f183e43690795b89dbd3fa4a774 Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Tue, 1 Sep 2026 12:33:13 -0300 Subject: [PATCH 056/117] Remove temporary recovery patch workflow --- .../apply-file-organizer-recovery.yml | 53 ------------------- 1 file changed, 53 deletions(-) delete mode 100644 .github/workflows/apply-file-organizer-recovery.yml diff --git a/.github/workflows/apply-file-organizer-recovery.yml b/.github/workflows/apply-file-organizer-recovery.yml deleted file mode 100644 index c49630c..0000000 --- a/.github/workflows/apply-file-organizer-recovery.yml +++ /dev/null @@ -1,53 +0,0 @@ -name: Apply File Organizer recovery patch - -on: - push: - branches: - - phase-10-file-organizer - paths: - - .github/workflows/apply-file-organizer-recovery.yml - -permissions: - contents: write - -jobs: - patch: - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - name: Check out feature branch - uses: actions/checkout@v6 - with: - ref: phase-10-file-organizer - - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: "3.13" - - - name: Install test dependency - run: python -m pip install --disable-pip-version-check "pytest>=9.1,<9.2" - - - name: Apply focused patch - env: - PATCH_B64: "ZnJvbSBwYXRobGliIGltcG9ydCBQYXRoCgpkZWYgcmVwbGFjZV9vbmNlKHBhdGg6IHN0ciwgb2xkOiBzdHIsIG5ldzogc3RyKSAtPiBOb25lOgogICAgdGFyZ2V0ID0gUGF0aChwYXRoKQogICAgdGV4dCA9IHRhcmdldC5yZWFkX3RleHQoZW5jb2Rpbmc9InV0Zi04IikKICAgIGlmIG9sZCBub3QgaW4gdGV4dDoKICAgICAgICByYWlzZSBTeXN0ZW1FeGl0KGYiZXhwZWN0ZWQgcGF0Y2ggYW5jaG9yIG5vdCBmb3VuZCBpbiB7cGF0aH0iKQogICAgaWYgdGV4dC5jb3VudChvbGQpICE9IDE6CiAgICAgICAgcmFpc2UgU3lzdGVtRXhpdChmInBhdGNoIGFuY2hvciBpcyBub3QgdW5pcXVlIGluIHtwYXRofSIpCiAgICB0YXJnZXQud3JpdGVfdGV4dCh0ZXh0LnJlcGxhY2Uob2xkLCBuZXcsIDEpLCBlbmNvZGluZz0idXRmLTgiKQoKaW1wbGVtZW50YXRpb24gPSAicHJhY3RpY2FsLXByb2plY3RzLzA2LWZpbGUtb3JnYW5pemVyL2ZpbGVfb3JnYW5pemVyLnB5Igp0ZXN0cyA9ICJwcmFjdGljYWwtcHJvamVjdHMvMDYtZmlsZS1vcmdhbml6ZXIvdGVzdHMvdGVzdF9hdG9taWNfbW92ZS5weSIKCnByZXNlcnZlX2FuY2hvciA9ICJkZWYgX3ByZXNlcnZlX3N0YWdlX2F0KHN0YWdlX25hbWU6IHN0ciwgc291cmNlX25hbWU6IHN0ciwgKiwgcm9vdF9mZDogaW50KSAtPiBOb25lOlxuIgpyZWNvdmVyeV9jb2RlID0gIiIiZGVmIF9tYWtlX3JlY292ZXJ5X25hbWUoc291cmNlX25hbWU6IHN0cikgLT4gc3RyOgogICAgIyBSZXR1cm4gYSBib3VuZGVkIGV4Y2x1c2l2ZSBuYW1lIGZvciBlbWVyZ2VuY3kgc291cmNlLWRhdGEgcmVjb3ZlcnkuCiAgICBkZWwgc291cmNlX25hbWUKICAgIHJldHVybiBmIi5mby1yZWNvdmVyeS17c2VjcmV0cy50b2tlbl9oZXgoMTYpfSIKCgpkZWYgX3JlY292ZXJfcGlubmVkX3NvdXJjZV9hdCgKICAgIHNvdXJjZV9mZDogaW50LAogICAgc291cmNlX25hbWU6IHN0ciwKICAgICosCiAgICByb290X2ZkOiBpbnQsCikgLT4gc3RyOgogICAgIyBDb3B5IGJ5dGVzIGZyb20gdGhlIHBpbm5lZCBzb3VyY2UgRkQgaW50byBhbiBleGNsdXNpdmUgcmVjb3ZlcnkgZmlsZS4KICAgIHNvdXJjZV9zdGF0ID0gb3MuZnN0YXQoc291cmNlX2ZkKQogICAgbW9kZSA9IHN0YXQuU19JTU9ERShzb3VyY2Vfc3RhdC5zdF9tb2RlKQogICAgZmxhZ3MgPSBvcy5PX1dST05MWSB8IG9zLk9fQ1JFQVQgfCBvcy5PX0VYQ0wKICAgIGlmIGhhc2F0dHIob3MsICJPX0NMT0VYRUMiKToKICAgICAgICBmbGFncyB8PSBvcy5PX0NMT0VYRUMKCiAgICByZWNvdmVyeV9mZDogaW50IHwgTm9uZSA9IE5vbmUKICAgIHJlY292ZXJ5X25hbWUgPSAiIgogICAgZm9yIF8gaW4gcmFuZ2UoMTYpOgogICAgICAgIHJlY292ZXJ5X25hbWUgPSBfbWFrZV9yZWNvdmVyeV9uYW1lKHNvdXJjZV9uYW1lKQogICAgICAgIHRyeToKICAgICAgICAgICAgcmVjb3ZlcnlfZmQgPSBvcy5vcGVuKAogICAgICAgICAgICAgICAgcmVjb3ZlcnlfbmFtZSwKICAgICAgICAgICAgICAgIGZsYWdzLAogICAgICAgICAgICAgICAgbW9kZSwKICAgICAgICAgICAgICAgIGRpcl9mZD1yb290X2ZkLAogICAgICAgICAgICApCiAgICAgICAgZXhjZXB0IEZpbGVFeGlzdHNFcnJvcjoKICAgICAgICAgICAgY29udGludWUKICAgICAgICBicmVhawogICAgaWYgcmVjb3ZlcnlfZmQgaXMgTm9uZToKICAgICAgICByYWlzZSBGaWxlRXhpc3RzRXJyb3IoCiAgICAgICAgICAgIGYiY291bGQgbm90IGFsbG9jYXRlIHJlY292ZXJ5IGVudHJ5IGZvciBwbGFubmVkIHNvdXJjZToge3NvdXJjZV9uYW1lfSIKICAgICAgICApCgogICAgdHJ5OgogICAgICAgIG9zLmxzZWVrKHNvdXJjZV9mZCwgMCwgb3MuU0VFS19TRVQpCiAgICAgICAgd2hpbGUgVHJ1ZToKICAgICAgICAgICAgY2h1bmsgPSBvcy5yZWFkKHNvdXJjZV9mZCwgMTAyNCAqIDEwMjQpCiAgICAgICAgICAgIGlmIG5vdCBjaHVuazoKICAgICAgICAgICAgICAgIGJyZWFrCiAgICAgICAgICAgIHZpZXcgPSBtZW1vcnl2aWV3KGNodW5rKQogICAgICAgICAgICB3aGlsZSB2aWV3OgogICAgICAgICAgICAgICAgd3JpdHRlbiA9IG9zLndyaXRlKHJlY292ZXJ5X2ZkLCB2aWV3KQogICAgICAgICAgICAgICAgaWYgd3JpdHRlbiA8PSAwOgogICAgICAgICAgICAgICAgICAgIHJhaXNlIE9TRXJyb3IoImNvdWxkIG5vdCBwZXJzaXN0IHBpbm5lZCBzb3VyY2UgcmVjb3ZlcnkgZGF0YSIpCiAgICAgICAgICAgICAgICB2aWV3ID0gdmlld1t3cml0dGVuOl0KICAgICAgICBvcy5mY2htb2QocmVjb3ZlcnlfZmQsIG1vZGUpCiAgICAgICAgb3MuZnN5bmMocmVjb3ZlcnlfZmQpCiAgICBmaW5hbGx5OgogICAgICAgIG9zLmNsb3NlKHJlY292ZXJ5X2ZkKQogICAgcmV0dXJuIHJlY292ZXJ5X25hbWUKCgoiIiIKcmVwbGFjZV9vbmNlKGltcGxlbWVudGF0aW9uLCBwcmVzZXJ2ZV9hbmNob3IsIHJlY292ZXJ5X2NvZGUgKyBwcmVzZXJ2ZV9hbmNob3IpCgpkZXN0aW5hdGlvbl9hbmNob3IgPSAiIiIgICAgICAgIF92ZXJpZnlfZGVzdGluYXRpb25faWRlbnRpdHlfYXQoCiAgICAgICAgICAgIGRlc3RpbmF0aW9uX25hbWUsCiAgICAgICAgICAgIGRlc3RpbmF0aW9uX2RpcmVjdG9yeV9mZD1kZXN0aW5hdGlvbl9kaXJlY3RvcnlfZmQsCiAgICAgICAgICAgIGV4cGVjdGVkX2lkZW50aXR5PWV4cGVjdGVkX2lkZW50aXR5LAogICAgICAgICkKICAgICAgICBfdmVyaWZ5X3Jvb3RfYW5jaG9yX2F0KHNvdXJjZV9kaXJlY3RvcnlfcGF0aCwgc291cmNlX2RpcmVjdG9yeV9mZCkKIiIiCmRlc3RpbmF0aW9uX3JlcGxhY2VtZW50ID0gIiIiICAgICAgICB0cnk6CiAgICAgICAgICAgIF92ZXJpZnlfZGVzdGluYXRpb25faWRlbnRpdHlfYXQoCiAgICAgICAgICAgICAgICBkZXN0aW5hdGlvbl9uYW1lLAogICAgICAgICAgICAgICAgZGVzdGluYXRpb25fZGlyZWN0b3J5X2ZkPWRlc3RpbmF0aW9uX2RpcmVjdG9yeV9mZCwKICAgICAgICAgICAgICAgIGV4cGVjdGVkX2lkZW50aXR5PWV4cGVjdGVkX2lkZW50aXR5LAogICAgICAgICAgICApCiAgICAgICAgZXhjZXB0IFJ1bnRpbWVFcnJvciBhcyBleGM6CiAgICAgICAgICAgIHJlY292ZXJ5X25hbWUgPSBfcmVjb3Zlcl9waW5uZWRfc291cmNlX2F0KAogICAgICAgICAgICAgICAgc291cmNlX2ZkLAogICAgICAgICAgICAgICAgc291cmNlX25hbWUsCiAgICAgICAgICAgICAgICByb290X2ZkPXNvdXJjZV9kaXJlY3RvcnlfZmQsCiAgICAgICAgICAgICkKICAgICAgICAgICAgcmFpc2UgUnVudGltZUVycm9yKAogICAgICAgICAgICAgICAgImRlc3RpbmF0aW9uIGRvZXMgbm90IG1hdGNoIHBsYW5uZWQgc291cmNlOyAiCiAgICAgICAgICAgICAgICBmInBsYW5uZWQgc291cmNlIGRhdGEgcmV0YWluZWQgYXMge3JlY292ZXJ5X25hbWV9OiB7ZGVzdGluYXRpb25fbmFtZX0iCiAgICAgICAgICAgICkgZnJvbSBleGMKICAgICAgICBfdmVyaWZ5X3Jvb3RfYW5jaG9yX2F0KHNvdXJjZV9kaXJlY3RvcnlfcGF0aCwgc291cmNlX2RpcmVjdG9yeV9mZCkKIiIiCnJlcGxhY2Vfb25jZShpbXBsZW1lbnRhdGlvbiwgZGVzdGluYXRpb25fYW5jaG9yLCBkZXN0aW5hdGlvbl9yZXBsYWNlbWVudCkKCnRlc3RfdGV4dCA9IFBhdGgodGVzdHMpLnJlYWRfdGV4dChlbmNvZGluZz0idXRmLTgiKQp0ZXN0X25hbWUgPSAidGVzdF9zdGFnaW5nX3JlcGxhY2VtZW50X2JlZm9yZV9maW5hbF9yZW5hbWVfcHJlc2VydmVzX3Bpbm5lZF9zb3VyY2VfZGF0YSIKaWYgdGVzdF9uYW1lIG5vdCBpbiB0ZXN0X3RleHQ6CiAgICB0ZXN0X3RleHQgKz0gIiIiCgpkZWYgdGVzdF9zdGFnaW5nX3JlcGxhY2VtZW50X2JlZm9yZV9maW5hbF9yZW5hbWVfcHJlc2VydmVzX3Bpbm5lZF9zb3VyY2VfZGF0YSgKICAgIG1vbmtleXBhdGNoOiBweXRlc3QuTW9ua2V5UGF0Y2gsCiAgICB0bXBfcGF0aDogUGF0aCwKKSAtPiBOb25lOgogICAgaWYgbm90IGZpbGVfb3JnYW5pemVyLl9zdXBwb3J0c19zZWN1cmVfZGlyZWN0b3J5X2ZkcygpOgogICAgICAgIHB5dGVzdC5za2lwKCJzZWN1cmUgZGlyZWN0b3J5IGRlc2NyaXB0b3JzIGFyZSB1bmF2YWlsYWJsZSBvbiB0aGlzIHBsYXRmb3JtIikKCiAgICBzb3VyY2UgPSB0bXBfcGF0aCAvICJub3Rlcy50eHQiCiAgICBzb3VyY2Uud3JpdGVfdGV4dCgicGxhbm5lZCBzb3VyY2UiLCBlbmNvZGluZz0idXRmLTgiKQogICAgcGxhbiA9IHBsYW5fb3JnYW5pemF0aW9uKHRtcF9wYXRoKQogICAgZGVzdGluYXRpb24gPSB0bXBfcGF0aCAvICJkb2N1bWVudHMiIC8gIm5vdGVzLnR4dCIKICAgIG9yaWdpbmFsX3JlbmFtZV9ub19yZXBsYWNlID0gZmlsZV9vcmdhbml6ZXIuX3JlbmFtZV9ub19yZXBsYWNlX2F0CiAgICByYWNlZCA9IEZhbHNlCgogICAgZGVmIHJhY2luZ19yZW5hbWVfbm9fcmVwbGFjZSgKICAgICAgICBzb3VyY2VfbmFtZTogc3RyLAogICAgICAgIGRlc3RpbmF0aW9uX25hbWU6IHN0ciwKICAgICAgICAqLAogICAgICAgIHNvdXJjZV9kaXJlY3RvcnlfZmQ6IGludCwKICAgICAgICBkZXN0aW5hdGlvbl9kaXJlY3RvcnlfZmQ6IGludCwKICAgICkgLT4gTm9uZToKICAgICAgICBub25sb2NhbCByYWNlZAogICAgICAgIGlmIG5vdCByYWNlZDoKICAgICAgICAgICAgcmFjZWQgPSBUcnVlCiAgICAgICAgICAgIHN0YWdlID0gdG1wX3BhdGggLyBzb3VyY2VfbmFtZQogICAgICAgICAgICBzdGFnZS51bmxpbmsoKQogICAgICAgICAgICBzdGFnZS53cml0ZV90ZXh0KCJ0aGlyZC1wYXJ0eSByZXBsYWNlbWVudCIsIGVuY29kaW5nPSJ1dGYtOCIpCiAgICAgICAgb3JpZ2luYWxfcmVuYW1lX25vX3JlcGxhY2UoCiAgICAgICAgICAgIHNvdXJjZV9uYW1lLAogICAgICAgICAgICBkZXN0aW5hdGlvbl9uYW1lLAogICAgICAgICAgICBzb3VyY2VfZGlyZWN0b3J5X2ZkPXNvdXJjZV9kaXJlY3RvcnlfZmQsCiAgICAgICAgICAgIGRlc3RpbmF0aW9uX2RpcmVjdG9yeV9mZD1kZXN0aW5hdGlvbl9kaXJlY3RvcnlfZmQsCiAgICAgICAgKQoKICAgIG1vbmtleXBhdGNoLnNldGF0dHIoCiAgICAgICAgZmlsZV9vcmdhbml6ZXIsCiAgICAgICAgIl9yZW5hbWVfbm9fcmVwbGFjZV9hdCIsCiAgICAgICAgcmFjaW5nX3JlbmFtZV9ub19yZXBsYWNlLAogICAgKQoKICAgIHdpdGggcHl0ZXN0LnJhaXNlcyhSdW50aW1lRXJyb3IsIG1hdGNoPSJwbGFubmVkIHNvdXJjZSBkYXRhIHJldGFpbmVkIik6CiAgICAgICAgZXhlY3V0ZV9wbGFuKHBsYW4pCgogICAgYXNzZXJ0IGRlc3RpbmF0aW9uLnJlYWRfdGV4dChlbmNvZGluZz0idXRmLTgiKSA9PSAidGhpcmQtcGFydHkgcmVwbGFjZW1lbnQiCiAgICByZWNvdmVyeV9maWxlcyA9IFsKICAgICAgICBjaGlsZCBmb3IgY2hpbGQgaW4gdG1wX3BhdGguaXRlcmRpcigpIGlmIGNoaWxkLm5hbWUuc3RhcnRzd2l0aCgiLmZvLXJlY292ZXJ5LSIpCiAgICBdCiAgICBhc3NlcnQgbGVuKHJlY292ZXJ5X2ZpbGVzKSA9PSAxCiAgICBhc3NlcnQgcmVjb3ZlcnlfZmlsZXNbMF0ucmVhZF90ZXh0KGVuY29kaW5nPSJ1dGYtOCIpID09ICJwbGFubmVkIHNvdXJjZSIKICAgIGFzc2VydCBub3Qgc291cmNlLmV4aXN0cygpCiIiIgogICAgUGF0aCh0ZXN0cykud3JpdGVfdGV4dCh0ZXN0X3RleHQsIGVuY29kaW5nPSJ1dGYtOCIpCgpkb2NzID0gewogICAgInByYWN0aWNhbC1wcm9qZWN0cy8wNi1maWxlLW9yZ2FuaXplci9SRUFETUUubWQiOiAoCiAgICAgICAgIklmIGV4ZWN1dGlvbiBoYXMgYWxyZWFkeSBjbGFpbWVkIHRoZSBzb3VyY2UgaW50byBhIHN0YWdpbmcgZW50cnkgYW5kIGxhdGVyIGRldGVjdHMgYW4gdW5zYWZlIGNvbmRpdGlvbiwgaXQgbWF5IGNyZWF0ZSBhIG5vLXJlcGxhY2UgaGFyZCBsaW5rIGJhY2sgdG8gdGhlIG9yaWdpbmFsIHNvdXJjZSBuYW1lIHdoZW4gcG9zc2libGUuIEl0IGRvZXMgbm90IGJsaW5kbHkgZGVsZXRlIHRoZSBzdGFnaW5nIGVudHJ5LlxuXG4iLAogICAgICAgICJJZiBleGVjdXRpb24gaGFzIGFscmVhZHkgY2xhaW1lZCB0aGUgc291cmNlIGludG8gYSBzdGFnaW5nIGVudHJ5IGFuZCBsYXRlciBkZXRlY3RzIGFuIHVuc2FmZSBjb25kaXRpb24sIGl0IG1heSBjcmVhdGUgYSBuby1yZXBsYWNlIGhhcmQgbGluayBiYWNrIHRvIHRoZSBvcmlnaW5hbCBzb3VyY2UgbmFtZSB3aGVuIHBvc3NpYmxlLiBJdCBkb2VzIG5vdCBibGluZGx5IGRlbGV0ZSB0aGUgc3RhZ2luZyBlbnRyeS5cblxuQSBzdGFnaW5nIHBhdGhuYW1lIGlzIG5vdCBhbiBpbm9kZSBsb2NrLiBJZiB0aGUgZmluYWwgcmVuYW1lIGNvbnN1bWVzIGEgcmVwbGFjZW1lbnQgZW50cnkgYW5kIGRlc3RpbmF0aW9uIGlkZW50aXR5IHZlcmlmaWNhdGlvbiBkZXRlY3RzIHRoZSBtaXNtYXRjaCwgZXhlY3V0aW9uIGtlZXBzIHRoZSB1bnJlbGF0ZWQgZGVzdGluYXRpb24gaW50YWN0IGFuZCwgYmVmb3JlIGNsb3NpbmcgdGhlIHN0aWxsLXBpbm5lZCBzb3VyY2UgZmlsZSBkZXNjcmlwdG9yLCBjb3BpZXMgdGhlIHBsYW5uZWQgc291cmNlIGJ5dGVzIGludG8gYW4gZXhjbHVzaXZlIGA uZm8tcmVjb3ZlcnktKmAgcmVndWxhciBmaWxlLiBUaGlzIHJlY292ZXJzIHRoZSBkYXRhIHJhdGhlciB0aGFuIGNsYWltaW5nIHRoZSBvcmlnaW5hbCBpbm9kZSBzdXJ2aXZlZC5cblxuIiwKICAgICksCiAgICAicHJhY3RpY2FsLXByb2plY3RzLzA2LWZpbGUtb3JnYW5pemVyL1JFQURNRS5wdC1CUi5tZCI6ICgKICAgICAgICAiU2UgYSBleGVjdcOnw6NvIGrDoSBtb3ZldSBhIG9yaWdlbSBwYXJhIHN0YWdpbmcgZSBkZXBvaXMgZGV0ZWN0YSBjb25kacOnw6NvIGluc2VndXJhLCBlbGEgcG9kZSBjcmlhciB1bSBoYXJkIGxpbmsgbm8tcmVwbGFjZSBkZSB2b2x0YSBwYXJhIG8gbm9tZSBkZSBvcmlnZW0gcXVhbmRvIHBvc3PDqXZlbC4gRWxhIG7Do28gYXBhZ2EgY2VnYW1lbnRlIG8gc3RhZ2luZy5cblxuIiwKICAgICAgICAiU2UgYSBleGVjdcOnw6NvIGrDoSBtb3ZldSBhIG9yaWdlbSBwYXJhIHN0YWdpbmcgZSBkZXBvaXMgZGV0ZWN0YSBjb25kacOnw6NvIGluc2VndXJhLCBlbGEgcG9kZSBjcmlhciB1bSBoYXJkIGxpbmsgbm8tcmVwbGFjZSBkZSB2b2x0YSBwYXJhIG8gbm9tZSBkZSBvcmlnZW0gcXVhbmRvIHBvc3PDqXZlbC4gRWxhIG7Do28gYXBhZ2EgY2VnYW1lbnRlIG8gc3RhZ2luZy5cblxuVW0gcGF0aG5hbWUgZGUgc3RhZ2luZyBuw6NvIGZ1bmNpb25hIGNvbW8gbG9jayBkZSBpbm9kZS4gU2UgbyByZW5hbWUgZmluYWwgY29uc3VtaXIgdW1hIGVudHJhZGEgc3Vic3RpdHV0YSBlIGEgdmVyaWZpY2HDp8OjbyBkZSBpZGVudGlkYWRlIGRvIGRlc3Rpbm8gZGV0ZWN0YXIgYSBkaXZlcmfDqm5jaWEsIGEgZXhlY3XDp8OjbyBtYW50w6ltIGludGFjdG8gbyBkZXN0aW5vIGFsaGVpbyBlLCBhbnRlcyBkZSBmZWNoYXIgbyBkZXNjcml0b3IgYWluZGEgcGluYWRvIGRhIG9yaWdlbSwgY29waWEgb3MgYnl0ZXMgcGxhbmVqYWRvcyBwYXJhIHVtIGFycXVpdm8gcmVndWxhciBleGNsdXNpdm8gYC5mby1yZWNvdmVyeS0qYC4gRXNzYSByZWN1cGVyYcOnw6NvIHByZXNlcnZhIG9zIGRhZG9zOyBlbGEgbsOjbyBhZmlybWEgcXVlIG8gaW5vZGUgb3JpZ2luYWwgc29icmV2aXZldS5cblxuIiwKICAgICksCiAgICAicHJhY3RpY2FsLXByb2plY3RzLzA2LWZpbGUtb3JnYW5pemVyL1JFQURNRS5lcy5tZCI6ICgKICAgICAgICAiU2kgbGEgZWplY3VjacOzbiB5YSBtb3Zpw7MgZWwgb3JpZ2VuIGFsIHN0YWdpbmcgeSBkZXNwdcOpcyBkZXRlY3RhIHVuYSBjb25kaWNpw7NuIGluc2VndXJhLCBwdWVkZSBjcmVhciB1biBoYXJkIGxpbmsgbm8tcmVwbGFjZSBkZSB2dWVsdGEgYWwgbm9tYnJlIGRlIG9yaWdlbiBjdWFuZG8gc2VhIHBvc2libGUuIE5vIGVsaW1pbmEgYSBjaWVnYXMgZWwgc3RhZ2luZy5cblxuIiwKICAgICAgICAiU2kgbGEgZWplY3VjacOzbiB5YSBtb3Zpw7MgZWwgb3JpZ2VuIGFsIHN0YWdpbmcgeSBkZXNwdcOpcyBkZXRlY3RhIHVuYSBjb25kaWNpw7NuIGluc2VndXJhLCBwdWVkZSBjcmVhciB1biBoYXJkIGxpbmsgbm8tcmVwbGFjZSBkZSB2dWVsdGEgYWwgbm9tYnJlIGRlIG9yaWdlbiBjdWFuZG8gc2VhIHBvc2libGUuIE5vIGVsaW1pbmEgYSBjaWVnYXMgZWwgc3RhZ2luZy5cblxuVW4gcGF0aG5hbWUgZGUgc3RhZ2luZyBubyBmdW5jaW9uYSBjb21vIGxvY2sgZGUgaW5vZGUuIFNpIGVsIHJlbmFtZSBmaW5hbCBjb25zdW1lIHVuYSBlbnRyYWRhIGRlIHJlZW1wbGF6byB5IGxhIHZlcmlmaWNhY2nDs24gZGUgaWRlbnRpZGFkIGRlbCBkZXN0aW5vIGRldGVjdGEgbGEgZGl2ZXJnZW5jaWEsIGxhIGVqZWN1Y2nDs24gY29uc2VydmEgaW50YWN0byBlbCBkZXN0aW5vIGFqZW5vIHksIGFudGVzIGRlIGNlcnJhciBlbCBkZXNjcmlwdG9yIGHDum4gYW5jbGFkbyBkZWwgb3JpZ2VuLCBjb3BpYSBsb3MgYnl0ZXMgcGxhbmlmaWNhZG9zIGEg dW4gYXJjaGl2byByZWd1bGFyIGV4Y2x1c2l2byBgLmZvLXJlY292ZXJ5LSpgLiBFc3RhIHJlY3VwZXJhY2nDs24gY29uc2VydmEgbG9zIGRhdG9zOyBubyBhZmlybWEgcXVlIGVsIGlub2RlIG9yaWdpbmFsIGhheWEgc29icmV2aXZpZG8uXG5cbiIsCiAgICApLAp9CmZvciBwYXRoLCAob2xkLCBuZXcpIGluIGRvY3MuaXRlbXMoKToKICAgIHJlcGxhY2Vfb25jZShwYXRoLCBvbGQsIG5ldykK" - run: | - printf '%s' "$PATCH_B64" | base64 --decode > /tmp/file_organizer_patch.py - python /tmp/file_organizer_patch.py - - - name: Validate focused patch - run: | - python -m py_compile practical-projects/06-file-organizer/file_organizer.py practical-projects/06-file-organizer/tests/test_atomic_move.py - python -m pytest -q practical-projects/06-file-organizer/tests - python scripts/check_internal_links.py - python scripts/validate_repository_structure.py - git diff --check - - - name: Commit patch and remove temporary workflow - run: | - rm .github/workflows/apply-file-organizer-recovery.yml - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -A - git commit -m "Preserve source data after staging race" - git push origin HEAD:phase-10-file-organizer From 21629366a6aef11e53f48e4a71044cc3830287df Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Tue, 1 Sep 2026 12:35:19 -0300 Subject: [PATCH 057/117] Add temporary File Organizer recovery patch script --- scripts/_apply_file_organizer_recovery.py | 47 +++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 scripts/_apply_file_organizer_recovery.py diff --git a/scripts/_apply_file_organizer_recovery.py b/scripts/_apply_file_organizer_recovery.py new file mode 100644 index 0000000..f321ab6 --- /dev/null +++ b/scripts/_apply_file_organizer_recovery.py @@ -0,0 +1,47 @@ +from pathlib import Path + + +def replace_once(path: str, old: str, new: str) -> None: + target = Path(path) + text = target.read_text(encoding="utf-8") + count = text.count(old) + if count != 1: + raise SystemExit(f"expected exactly one patch anchor in {path}, found {count}") + target.write_text(text.replace(old, new, 1), encoding="utf-8") + + +IMPLEMENTATION = "practical-projects/06-file-organizer/file_organizer.py" +TESTS = "practical-projects/06-file-organizer/tests/test_atomic_move.py" + +helpers_anchor = '''def _preserve_stage_at(stage_name: str, source_name: str, *, root_fd: int) -> None:\n''' +helpers = '''def _make_recovery_name(source_name: str) -> str:\n """Return a bounded exclusive name for emergency source-data recovery."""\n del source_name\n return f".fo-recovery-{secrets.token_hex(16)}"\n\n\ndef _recover_pinned_source_at(\n source_fd: int,\n source_name: str,\n *,\n root_fd: int,\n) -> str:\n """Persist bytes from the pinned source FD into an exclusive recovery file."""\n source_stat = os.fstat(source_fd)\n mode = stat.S_IMODE(source_stat.st_mode)\n flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL\n if hasattr(os, "O_CLOEXEC"):\n flags |= os.O_CLOEXEC\n\n recovery_fd: int | None = None\n recovery_name = ""\n for _ in range(16):\n recovery_name = _make_recovery_name(source_name)\n try:\n recovery_fd = os.open(\n recovery_name,\n flags,\n mode,\n dir_fd=root_fd,\n )\n except FileExistsError:\n continue\n break\n\n if recovery_fd is None:\n raise FileExistsError(\n f"could not allocate recovery entry for planned source: {source_name}"\n )\n\n original_offset = os.lseek(source_fd, 0, os.SEEK_CUR)\n try:\n os.lseek(source_fd, 0, os.SEEK_SET)\n while True:\n chunk = os.read(source_fd, 1024 * 1024)\n if not chunk:\n break\n view = memoryview(chunk)\n while view:\n written = os.write(recovery_fd, view)\n if written <= 0:\n raise OSError(\n "could not persist pinned source recovery data"\n )\n view = view[written:]\n os.fchmod(recovery_fd, mode)\n os.fsync(recovery_fd)\n finally:\n os.lseek(source_fd, original_offset, os.SEEK_SET)\n os.close(recovery_fd)\n\n return recovery_name\n\n\n''' +replace_once(IMPLEMENTATION, helpers_anchor, helpers + helpers_anchor) + +verify_anchor = ''' _verify_destination_identity_at(\n destination_name,\n destination_directory_fd=destination_directory_fd,\n expected_identity=expected_identity,\n )\n _verify_root_anchor_at(source_directory_path, source_directory_fd)\n''' +verify_replacement = ''' try:\n _verify_destination_identity_at(\n destination_name,\n destination_directory_fd=destination_directory_fd,\n expected_identity=expected_identity,\n )\n except RuntimeError as exc:\n recovery_name = _recover_pinned_source_at(\n source_fd,\n source_name,\n root_fd=source_directory_fd,\n )\n raise RuntimeError(\n "destination does not match planned source; "\n f"planned source data retained as {recovery_name}: {destination_name}"\n ) from exc\n _verify_root_anchor_at(source_directory_path, source_directory_fd)\n''' +replace_once(IMPLEMENTATION, verify_anchor, verify_replacement) + +test_path = Path(TESTS) +test_text = test_path.read_text(encoding="utf-8") +test_name = "test_staging_replacement_before_final_rename_preserves_pinned_source_data" +if test_name not in test_text: + test_text += '''\n\n\ndef test_staging_replacement_before_final_rename_preserves_pinned_source_data(\n monkeypatch: pytest.MonkeyPatch,\n tmp_path: Path,\n) -> None:\n if not file_organizer._supports_secure_directory_fds():\n pytest.skip("secure directory descriptors are unavailable on this platform")\n\n source = tmp_path / "notes.txt"\n source.write_text("planned source", encoding="utf-8")\n plan = plan_organization(tmp_path)\n destination = tmp_path / "documents" / "notes.txt"\n original_rename_no_replace = file_organizer._rename_no_replace_at\n raced = False\n\n def racing_rename_no_replace(\n source_name: str,\n destination_name: str,\n *,\n source_directory_fd: int,\n destination_directory_fd: int,\n ) -> None:\n nonlocal raced\n if not raced:\n raced = True\n stage = tmp_path / source_name\n assert stage.name.startswith(".fo-stage-")\n stage.unlink()\n stage.write_text("third-party replacement", encoding="utf-8")\n original_rename_no_replace(\n source_name,\n destination_name,\n source_directory_fd=source_directory_fd,\n destination_directory_fd=destination_directory_fd,\n )\n\n monkeypatch.setattr(\n file_organizer,\n "_rename_no_replace_at",\n racing_rename_no_replace,\n )\n\n with pytest.raises(RuntimeError, match="planned source data retained"):\n execute_plan(plan)\n\n assert destination.read_text(encoding="utf-8") == "third-party replacement"\n recovery_files = [\n child\n for child in tmp_path.iterdir()\n if child.name.startswith(".fo-recovery-")\n ]\n assert len(recovery_files) == 1\n assert recovery_files[0].read_text(encoding="utf-8") == "planned source"\n assert not source.exists()\n''' + test_path.write_text(test_text, encoding="utf-8") + +DOC_UPDATES = { + "practical-projects/06-file-organizer/README.md": ( + "If execution has already claimed the source into a staging entry and later detects an unsafe condition, it may create a no-replace hard link back to the original source name when possible. It does not blindly delete the staging entry.\n\n", + "If execution has already claimed the source into a staging entry and later detects an unsafe condition, it may create a no-replace hard link back to the original source name when possible. It does not blindly delete the staging entry.\n\nA staging pathname is not an inode lock. If the final rename consumes a replacement entry and destination identity verification detects the mismatch, execution leaves the unrelated destination intact and, before closing the still-pinned source file descriptor, copies the planned source bytes into an exclusive `.fo-recovery-*` regular file. This preserves recoverable data without claiming that the original inode survived the race.\n\n", + ), + "practical-projects/06-file-organizer/README.pt-BR.md": ( + "Se a execução já moveu a origem para staging e depois detecta condição insegura, ela pode criar um hard link no-replace de volta para o nome de origem quando possível. Ela não apaga cegamente o staging.\n\n", + "Se a execução já moveu a origem para staging e depois detecta condição insegura, ela pode criar um hard link no-replace de volta para o nome de origem quando possível. Ela não apaga cegamente o staging.\n\nUm pathname de staging não funciona como lock de inode. Se o rename final consumir uma entrada substituta e a verificação de identidade do destino detectar a divergência, a execução mantém intacto o destino alheio e, antes de fechar o descritor ainda pinado da origem, copia os bytes planejados para um arquivo regular exclusivo `.fo-recovery-*`. Essa recuperação preserva dados recuperáveis sem afirmar que o inode original sobreviveu à corrida.\n\n", + ), + "practical-projects/06-file-organizer/README.es.md": ( + "Si la ejecución ya movió el origen al staging y después detecta una condición insegura, puede crear un hard link no-replace de vuelta al nombre de origen cuando sea posible. No elimina a ciegas el staging.\n\n", + "Si la ejecución ya movió el origen al staging y después detecta una condición insegura, puede crear un hard link no-replace de vuelta al nombre de origen cuando sea posible. No elimina a ciegas el staging.\n\nUn pathname de staging no funciona como lock de inode. Si el rename final consume una entrada de reemplazo y la verificación de identidad del destino detecta la divergencia, la ejecución conserva intacto el destino ajeno y, antes de cerrar el descriptor aún anclado del origen, copia los bytes planificados a un archivo regular exclusivo `.fo-recovery-*`. Esta recuperación conserva datos recuperables sin afirmar que el inode original haya sobrevivido a la carrera.\n\n", + ), +} + +for path, (old, new) in DOC_UPDATES.items(): + replace_once(path, old, new) From 8917ae6d0801a39c76c0a430597f6cafa8c62425 Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Tue, 1 Sep 2026 12:35:36 -0300 Subject: [PATCH 058/117] Run focused File Organizer recovery patch --- .../apply-file-organizer-recovery.yml | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 .github/workflows/apply-file-organizer-recovery.yml diff --git a/.github/workflows/apply-file-organizer-recovery.yml b/.github/workflows/apply-file-organizer-recovery.yml new file mode 100644 index 0000000..472a6f1 --- /dev/null +++ b/.github/workflows/apply-file-organizer-recovery.yml @@ -0,0 +1,47 @@ +name: Apply File Organizer recovery patch + +on: + push: + branches: + - phase-10-file-organizer + paths: + - .github/workflows/apply-file-organizer-recovery.yml + +permissions: + contents: write + +jobs: + patch: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Check out feature branch + uses: actions/checkout@v6 + with: + ref: phase-10-file-organizer + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.13" + + - name: Install pytest + run: python -m pip install --disable-pip-version-check "pytest>=9.1,<9.2" + + - name: Apply focused patch + run: python scripts/_apply_file_organizer_recovery.py + + - name: Validate focused patch + run: | + python -m py_compile practical-projects/06-file-organizer/file_organizer.py practical-projects/06-file-organizer/tests/test_atomic_move.py + python -m pytest -q practical-projects/06-file-organizer/tests + git diff --check + + - name: Commit focused patch + run: | + rm scripts/_apply_file_organizer_recovery.py + git config user.name "Ramon Rodriguez" + git config user.email "ramoncorreka@hotmail.com" + git add practical-projects/06-file-organizer scripts/_apply_file_organizer_recovery.py + git commit -m "Preserve pinned source after staging race" + git push origin HEAD:phase-10-file-organizer From 758a253c3863de0443af741d4ddd1b0aa2a38d3c Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Tue, 1 Sep 2026 15:35:49 +0000 Subject: [PATCH 059/117] Preserve pinned source after staging race --- .../06-file-organizer/README.es.md | 2 + .../06-file-organizer/README.md | 2 + .../06-file-organizer/README.pt-BR.md | 2 + .../06-file-organizer/file_organizer.py | 84 +++++++++++++++++-- .../tests/test_atomic_move.py | 56 +++++++++++++ scripts/_apply_file_organizer_recovery.py | 47 ----------- 6 files changed, 141 insertions(+), 52 deletions(-) delete mode 100644 scripts/_apply_file_organizer_recovery.py diff --git a/practical-projects/06-file-organizer/README.es.md b/practical-projects/06-file-organizer/README.es.md index c6d8cfe..65b02e3 100644 --- a/practical-projects/06-file-organizer/README.es.md +++ b/practical-projects/06-file-organizer/README.es.md @@ -272,6 +272,8 @@ Los errores concurrentes pueden dejar estado incierto. La recuperación prioriza Si la ejecución ya movió el origen al staging y después detecta una condición insegura, puede crear un hard link no-replace de vuelta al nombre de origen cuando sea posible. No elimina a ciegas el staging. +Un pathname de staging no funciona como lock de inode. Si el rename final consume una entrada de reemplazo y la verificación de identidad del destino detecta la divergencia, la ejecución conserva intacto el destino ajeno y, antes de cerrar el descriptor aún anclado del origen, copia los bytes planificados a un archivo regular exclusivo `.fo-recovery-*`. Esta recuperación conserva datos recuperables sin afirmar que el inode original haya sobrevivido a la carrera. + En escenarios raros de carrera/fallo, esto puede dejar una entrada interna de recuperación. Es preferible a borrar datos cuya identidad actual no puede demostrarse. El plan completo de varios archivos no es transaccional. diff --git a/practical-projects/06-file-organizer/README.md b/practical-projects/06-file-organizer/README.md index 9322d52..a6a73d2 100644 --- a/practical-projects/06-file-organizer/README.md +++ b/practical-projects/06-file-organizer/README.md @@ -272,6 +272,8 @@ Concurrency errors can leave uncertain state. Recovery therefore favors preserva If execution has already claimed the source into a staging entry and later detects an unsafe condition, it may create a no-replace hard link back to the original source name when possible. It does not blindly delete the staging entry. +A staging pathname is not an inode lock. If the final rename consumes a replacement entry and destination identity verification detects the mismatch, execution leaves the unrelated destination intact and, before closing the still-pinned source file descriptor, copies the planned source bytes into an exclusive `.fo-recovery-*` regular file. This preserves recoverable data without claiming that the original inode survived the race. + This can intentionally leave an internal recovery entry in unusual race/failure scenarios. That is preferable to deleting unrelated data whose current identity cannot be proven. The whole multi-file plan is not transactional. diff --git a/practical-projects/06-file-organizer/README.pt-BR.md b/practical-projects/06-file-organizer/README.pt-BR.md index 93a7c0f..98013f3 100644 --- a/practical-projects/06-file-organizer/README.pt-BR.md +++ b/practical-projects/06-file-organizer/README.pt-BR.md @@ -272,6 +272,8 @@ Erros concorrentes podem deixar estado incerto. A recuperação prioriza preserv Se a execução já moveu a origem para staging e depois detecta condição insegura, ela pode criar um hard link no-replace de volta para o nome de origem quando possível. Ela não apaga cegamente o staging. +Um pathname de staging não funciona como lock de inode. Se o rename final consumir uma entrada substituta e a verificação de identidade do destino detectar a divergência, a execução mantém intacto o destino alheio e, antes de fechar o descritor ainda pinado da origem, copia os bytes planejados para um arquivo regular exclusivo `.fo-recovery-*`. Essa recuperação preserva dados recuperáveis sem afirmar que o inode original sobreviveu à corrida. + Em cenários raros de corrida/falha, isso pode deixar uma entrada interna de recuperação. É preferível a excluir dados cuja identidade atual não pode ser comprovada. O plano inteiro de múltiplos arquivos não é transacional. diff --git a/practical-projects/06-file-organizer/file_organizer.py b/practical-projects/06-file-organizer/file_organizer.py index 36ae0ec..f7f7fde 100644 --- a/practical-projects/06-file-organizer/file_organizer.py +++ b/practical-projects/06-file-organizer/file_organizer.py @@ -636,6 +636,69 @@ def _make_stage_name(source_name: str) -> str: return f".fo-stage-{secrets.token_hex(16)}" +def _make_recovery_name(source_name: str) -> str: + """Return a bounded exclusive name for emergency source-data recovery.""" + del source_name + return f".fo-recovery-{secrets.token_hex(16)}" + + +def _recover_pinned_source_at( + source_fd: int, + source_name: str, + *, + root_fd: int, +) -> str: + """Persist bytes from the pinned source FD into an exclusive recovery file.""" + source_stat = os.fstat(source_fd) + mode = stat.S_IMODE(source_stat.st_mode) + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL + if hasattr(os, "O_CLOEXEC"): + flags |= os.O_CLOEXEC + + recovery_fd: int | None = None + recovery_name = "" + for _ in range(16): + recovery_name = _make_recovery_name(source_name) + try: + recovery_fd = os.open( + recovery_name, + flags, + mode, + dir_fd=root_fd, + ) + except FileExistsError: + continue + break + + if recovery_fd is None: + raise FileExistsError( + f"could not allocate recovery entry for planned source: {source_name}" + ) + + original_offset = os.lseek(source_fd, 0, os.SEEK_CUR) + try: + os.lseek(source_fd, 0, os.SEEK_SET) + while True: + chunk = os.read(source_fd, 1024 * 1024) + if not chunk: + break + view = memoryview(chunk) + while view: + written = os.write(recovery_fd, view) + if written <= 0: + raise OSError( + "could not persist pinned source recovery data" + ) + view = view[written:] + os.fchmod(recovery_fd, mode) + os.fsync(recovery_fd) + finally: + os.lseek(source_fd, original_offset, os.SEEK_SET) + os.close(recovery_fd) + + return recovery_name + + def _preserve_stage_at(stage_name: str, source_name: str, *, root_fd: int) -> None: """Best-effort restore by linking only; never delete a raced staging entry.""" try: @@ -772,11 +835,22 @@ def _move_file_no_replace_at( _preserve_stage_at(stage_name, source_name, root_fd=source_directory_fd) raise - _verify_destination_identity_at( - destination_name, - destination_directory_fd=destination_directory_fd, - expected_identity=expected_identity, - ) + try: + _verify_destination_identity_at( + destination_name, + destination_directory_fd=destination_directory_fd, + expected_identity=expected_identity, + ) + except RuntimeError as exc: + recovery_name = _recover_pinned_source_at( + source_fd, + source_name, + root_fd=source_directory_fd, + ) + raise RuntimeError( + "destination does not match planned source; " + f"planned source data retained as {recovery_name}: {destination_name}" + ) from exc _verify_root_anchor_at(source_directory_path, source_directory_fd) _verify_category_anchor_at( root_fd=source_directory_fd, diff --git a/practical-projects/06-file-organizer/tests/test_atomic_move.py b/practical-projects/06-file-organizer/tests/test_atomic_move.py index 91a47e7..394420f 100644 --- a/practical-projects/06-file-organizer/tests/test_atomic_move.py +++ b/practical-projects/06-file-organizer/tests/test_atomic_move.py @@ -403,3 +403,59 @@ def racing_claim( assert late_destination.read_text(encoding="utf-8") == "late casefold collision" assert not exact_destination.exists() assert any(child.name.startswith(".fo-stage-") for child in tmp_path.iterdir()) + + + +def test_staging_replacement_before_final_rename_preserves_pinned_source_data( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + if not file_organizer._supports_secure_directory_fds(): + pytest.skip("secure directory descriptors are unavailable on this platform") + + source = tmp_path / "notes.txt" + source.write_text("planned source", encoding="utf-8") + plan = plan_organization(tmp_path) + destination = tmp_path / "documents" / "notes.txt" + original_rename_no_replace = file_organizer._rename_no_replace_at + raced = False + + def racing_rename_no_replace( + source_name: str, + destination_name: str, + *, + source_directory_fd: int, + destination_directory_fd: int, + ) -> None: + nonlocal raced + if not raced: + raced = True + stage = tmp_path / source_name + assert stage.name.startswith(".fo-stage-") + stage.unlink() + stage.write_text("third-party replacement", encoding="utf-8") + original_rename_no_replace( + source_name, + destination_name, + source_directory_fd=source_directory_fd, + destination_directory_fd=destination_directory_fd, + ) + + monkeypatch.setattr( + file_organizer, + "_rename_no_replace_at", + racing_rename_no_replace, + ) + + with pytest.raises(RuntimeError, match="planned source data retained"): + execute_plan(plan) + + assert destination.read_text(encoding="utf-8") == "third-party replacement" + recovery_files = [ + child + for child in tmp_path.iterdir() + if child.name.startswith(".fo-recovery-") + ] + assert len(recovery_files) == 1 + assert recovery_files[0].read_text(encoding="utf-8") == "planned source" + assert not source.exists() diff --git a/scripts/_apply_file_organizer_recovery.py b/scripts/_apply_file_organizer_recovery.py deleted file mode 100644 index f321ab6..0000000 --- a/scripts/_apply_file_organizer_recovery.py +++ /dev/null @@ -1,47 +0,0 @@ -from pathlib import Path - - -def replace_once(path: str, old: str, new: str) -> None: - target = Path(path) - text = target.read_text(encoding="utf-8") - count = text.count(old) - if count != 1: - raise SystemExit(f"expected exactly one patch anchor in {path}, found {count}") - target.write_text(text.replace(old, new, 1), encoding="utf-8") - - -IMPLEMENTATION = "practical-projects/06-file-organizer/file_organizer.py" -TESTS = "practical-projects/06-file-organizer/tests/test_atomic_move.py" - -helpers_anchor = '''def _preserve_stage_at(stage_name: str, source_name: str, *, root_fd: int) -> None:\n''' -helpers = '''def _make_recovery_name(source_name: str) -> str:\n """Return a bounded exclusive name for emergency source-data recovery."""\n del source_name\n return f".fo-recovery-{secrets.token_hex(16)}"\n\n\ndef _recover_pinned_source_at(\n source_fd: int,\n source_name: str,\n *,\n root_fd: int,\n) -> str:\n """Persist bytes from the pinned source FD into an exclusive recovery file."""\n source_stat = os.fstat(source_fd)\n mode = stat.S_IMODE(source_stat.st_mode)\n flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL\n if hasattr(os, "O_CLOEXEC"):\n flags |= os.O_CLOEXEC\n\n recovery_fd: int | None = None\n recovery_name = ""\n for _ in range(16):\n recovery_name = _make_recovery_name(source_name)\n try:\n recovery_fd = os.open(\n recovery_name,\n flags,\n mode,\n dir_fd=root_fd,\n )\n except FileExistsError:\n continue\n break\n\n if recovery_fd is None:\n raise FileExistsError(\n f"could not allocate recovery entry for planned source: {source_name}"\n )\n\n original_offset = os.lseek(source_fd, 0, os.SEEK_CUR)\n try:\n os.lseek(source_fd, 0, os.SEEK_SET)\n while True:\n chunk = os.read(source_fd, 1024 * 1024)\n if not chunk:\n break\n view = memoryview(chunk)\n while view:\n written = os.write(recovery_fd, view)\n if written <= 0:\n raise OSError(\n "could not persist pinned source recovery data"\n )\n view = view[written:]\n os.fchmod(recovery_fd, mode)\n os.fsync(recovery_fd)\n finally:\n os.lseek(source_fd, original_offset, os.SEEK_SET)\n os.close(recovery_fd)\n\n return recovery_name\n\n\n''' -replace_once(IMPLEMENTATION, helpers_anchor, helpers + helpers_anchor) - -verify_anchor = ''' _verify_destination_identity_at(\n destination_name,\n destination_directory_fd=destination_directory_fd,\n expected_identity=expected_identity,\n )\n _verify_root_anchor_at(source_directory_path, source_directory_fd)\n''' -verify_replacement = ''' try:\n _verify_destination_identity_at(\n destination_name,\n destination_directory_fd=destination_directory_fd,\n expected_identity=expected_identity,\n )\n except RuntimeError as exc:\n recovery_name = _recover_pinned_source_at(\n source_fd,\n source_name,\n root_fd=source_directory_fd,\n )\n raise RuntimeError(\n "destination does not match planned source; "\n f"planned source data retained as {recovery_name}: {destination_name}"\n ) from exc\n _verify_root_anchor_at(source_directory_path, source_directory_fd)\n''' -replace_once(IMPLEMENTATION, verify_anchor, verify_replacement) - -test_path = Path(TESTS) -test_text = test_path.read_text(encoding="utf-8") -test_name = "test_staging_replacement_before_final_rename_preserves_pinned_source_data" -if test_name not in test_text: - test_text += '''\n\n\ndef test_staging_replacement_before_final_rename_preserves_pinned_source_data(\n monkeypatch: pytest.MonkeyPatch,\n tmp_path: Path,\n) -> None:\n if not file_organizer._supports_secure_directory_fds():\n pytest.skip("secure directory descriptors are unavailable on this platform")\n\n source = tmp_path / "notes.txt"\n source.write_text("planned source", encoding="utf-8")\n plan = plan_organization(tmp_path)\n destination = tmp_path / "documents" / "notes.txt"\n original_rename_no_replace = file_organizer._rename_no_replace_at\n raced = False\n\n def racing_rename_no_replace(\n source_name: str,\n destination_name: str,\n *,\n source_directory_fd: int,\n destination_directory_fd: int,\n ) -> None:\n nonlocal raced\n if not raced:\n raced = True\n stage = tmp_path / source_name\n assert stage.name.startswith(".fo-stage-")\n stage.unlink()\n stage.write_text("third-party replacement", encoding="utf-8")\n original_rename_no_replace(\n source_name,\n destination_name,\n source_directory_fd=source_directory_fd,\n destination_directory_fd=destination_directory_fd,\n )\n\n monkeypatch.setattr(\n file_organizer,\n "_rename_no_replace_at",\n racing_rename_no_replace,\n )\n\n with pytest.raises(RuntimeError, match="planned source data retained"):\n execute_plan(plan)\n\n assert destination.read_text(encoding="utf-8") == "third-party replacement"\n recovery_files = [\n child\n for child in tmp_path.iterdir()\n if child.name.startswith(".fo-recovery-")\n ]\n assert len(recovery_files) == 1\n assert recovery_files[0].read_text(encoding="utf-8") == "planned source"\n assert not source.exists()\n''' - test_path.write_text(test_text, encoding="utf-8") - -DOC_UPDATES = { - "practical-projects/06-file-organizer/README.md": ( - "If execution has already claimed the source into a staging entry and later detects an unsafe condition, it may create a no-replace hard link back to the original source name when possible. It does not blindly delete the staging entry.\n\n", - "If execution has already claimed the source into a staging entry and later detects an unsafe condition, it may create a no-replace hard link back to the original source name when possible. It does not blindly delete the staging entry.\n\nA staging pathname is not an inode lock. If the final rename consumes a replacement entry and destination identity verification detects the mismatch, execution leaves the unrelated destination intact and, before closing the still-pinned source file descriptor, copies the planned source bytes into an exclusive `.fo-recovery-*` regular file. This preserves recoverable data without claiming that the original inode survived the race.\n\n", - ), - "practical-projects/06-file-organizer/README.pt-BR.md": ( - "Se a execução já moveu a origem para staging e depois detecta condição insegura, ela pode criar um hard link no-replace de volta para o nome de origem quando possível. Ela não apaga cegamente o staging.\n\n", - "Se a execução já moveu a origem para staging e depois detecta condição insegura, ela pode criar um hard link no-replace de volta para o nome de origem quando possível. Ela não apaga cegamente o staging.\n\nUm pathname de staging não funciona como lock de inode. Se o rename final consumir uma entrada substituta e a verificação de identidade do destino detectar a divergência, a execução mantém intacto o destino alheio e, antes de fechar o descritor ainda pinado da origem, copia os bytes planejados para um arquivo regular exclusivo `.fo-recovery-*`. Essa recuperação preserva dados recuperáveis sem afirmar que o inode original sobreviveu à corrida.\n\n", - ), - "practical-projects/06-file-organizer/README.es.md": ( - "Si la ejecución ya movió el origen al staging y después detecta una condición insegura, puede crear un hard link no-replace de vuelta al nombre de origen cuando sea posible. No elimina a ciegas el staging.\n\n", - "Si la ejecución ya movió el origen al staging y después detecta una condición insegura, puede crear un hard link no-replace de vuelta al nombre de origen cuando sea posible. No elimina a ciegas el staging.\n\nUn pathname de staging no funciona como lock de inode. Si el rename final consume una entrada de reemplazo y la verificación de identidad del destino detecta la divergencia, la ejecución conserva intacto el destino ajeno y, antes de cerrar el descriptor aún anclado del origen, copia los bytes planificados a un archivo regular exclusivo `.fo-recovery-*`. Esta recuperación conserva datos recuperables sin afirmar que el inode original haya sobrevivido a la carrera.\n\n", - ), -} - -for path, (old, new) in DOC_UPDATES.items(): - replace_once(path, old, new) From fd1b6974f44957ab3b467284344284aaef97dec0 Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Tue, 1 Sep 2026 12:37:52 -0300 Subject: [PATCH 060/117] Remove temporary File Organizer recovery workflow --- .../apply-file-organizer-recovery.yml | 47 ------------------- 1 file changed, 47 deletions(-) delete mode 100644 .github/workflows/apply-file-organizer-recovery.yml diff --git a/.github/workflows/apply-file-organizer-recovery.yml b/.github/workflows/apply-file-organizer-recovery.yml deleted file mode 100644 index 472a6f1..0000000 --- a/.github/workflows/apply-file-organizer-recovery.yml +++ /dev/null @@ -1,47 +0,0 @@ -name: Apply File Organizer recovery patch - -on: - push: - branches: - - phase-10-file-organizer - paths: - - .github/workflows/apply-file-organizer-recovery.yml - -permissions: - contents: write - -jobs: - patch: - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - name: Check out feature branch - uses: actions/checkout@v6 - with: - ref: phase-10-file-organizer - - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: "3.13" - - - name: Install pytest - run: python -m pip install --disable-pip-version-check "pytest>=9.1,<9.2" - - - name: Apply focused patch - run: python scripts/_apply_file_organizer_recovery.py - - - name: Validate focused patch - run: | - python -m py_compile practical-projects/06-file-organizer/file_organizer.py practical-projects/06-file-organizer/tests/test_atomic_move.py - python -m pytest -q practical-projects/06-file-organizer/tests - git diff --check - - - name: Commit focused patch - run: | - rm scripts/_apply_file_organizer_recovery.py - git config user.name "Ramon Rodriguez" - git config user.email "ramoncorreka@hotmail.com" - git add practical-projects/06-file-organizer scripts/_apply_file_organizer_recovery.py - git commit -m "Preserve pinned source after staging race" - git push origin HEAD:phase-10-file-organizer From ef4efddcf56cfd99e46fd57d186a7fbf0ca822f6 Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Tue, 1 Sep 2026 13:00:06 -0300 Subject: [PATCH 061/117] Add focused Review 8 patch helper --- scripts/_apply_file_organizer_review8.py | 51 ++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 scripts/_apply_file_organizer_review8.py diff --git a/scripts/_apply_file_organizer_review8.py b/scripts/_apply_file_organizer_review8.py new file mode 100644 index 0000000..e07f7ea --- /dev/null +++ b/scripts/_apply_file_organizer_review8.py @@ -0,0 +1,51 @@ +from pathlib import Path + + +def replace_once(path: str, old: str, new: str) -> None: + target = Path(path) + text = target.read_text(encoding="utf-8") + count = text.count(old) + if count != 1: + raise SystemExit(f"expected exactly one patch anchor in {path}, found {count}") + target.write_text(text.replace(old, new, 1), encoding="utf-8") + + +IMPLEMENTATION = "practical-projects/06-file-organizer/file_organizer.py" +TESTS = "practical-projects/06-file-organizer/tests/test_atomic_move.py" + +category_old = ''' _verify_category_anchor_at(\n root_fd=root_fd,\n category_name=category_name,\n category_fd=category_fd,\n )\n return category_fd\n''' +category_new = ''' try:\n _verify_category_anchor_at(\n root_fd=root_fd,\n category_name=category_name,\n category_fd=category_fd,\n )\n except Exception:\n os.close(category_fd)\n raise\n return category_fd\n''' +replace_once(IMPLEMENTATION, category_old, category_new) + +permission_old = ''' try:\n source_fd = os.open(source_name, flags, dir_fd=root_fd)\n except OSError as exc:\n raise FileNotFoundError(\n f"planned source changed during execution: {source_name}"\n ) from exc\n''' +permission_new = ''' try:\n source_fd = os.open(source_name, flags, dir_fd=root_fd)\n except PermissionError as exc:\n raise PermissionError(\n f"planned source must be readable for safe execution: {source_name}"\n ) from exc\n except OSError as exc:\n raise FileNotFoundError(\n f"planned source changed during execution: {source_name}"\n ) from exc\n''' +replace_once(IMPLEMENTATION, permission_old, permission_new) + +prevalidate_old = ''' try:\n _verify_root_anchor_at(plan.source_directory, root_fd)\n for category in sorted(\n''' +prevalidate_new = ''' try:\n _verify_root_anchor_at(plan.source_directory, root_fd)\n\n # Readability is a deliberate secure-execution prerequisite because\n # pinned-FD recovery must be able to persist the planned source bytes.\n for action in plan.actions:\n validation_fd = _open_planned_source_fd_at(\n action.source.name,\n root_fd=root_fd,\n expected_identity=source_identities[action.source],\n )\n os.close(validation_fd)\n\n for category in sorted(\n''' +replace_once(IMPLEMENTATION, prevalidate_old, prevalidate_new) + + +test_path = Path(TESTS) +test_text = test_path.read_text(encoding="utf-8") +if "test_secure_execution_reports_readability_precondition_before_categories" not in test_text: + test_text += '''\n\n\ndef test_secure_execution_reports_readability_precondition_before_categories(\n monkeypatch: pytest.MonkeyPatch,\n tmp_path: Path,\n) -> None:\n if not file_organizer._supports_secure_directory_fds():\n pytest.skip("secure directory descriptors are unavailable on this platform")\n\n source = tmp_path / "notes.txt"\n source.write_text("planned source", encoding="utf-8")\n plan = plan_organization(tmp_path)\n original_open = os.open\n\n def permission_denied_open(\n path: str | os.PathLike[str],\n flags: int,\n mode: int = 0o777,\n *,\n dir_fd: int | None = None,\n ) -> int:\n if path == source.name and dir_fd is not None:\n raise PermissionError("simulated unreadable source")\n return original_open(path, flags, mode, dir_fd=dir_fd)\n\n monkeypatch.setattr(file_organizer, "_supports_secure_directory_fds", lambda: True)\n monkeypatch.setattr(file_organizer.os, "open", permission_denied_open)\n\n with pytest.raises(PermissionError, match="must be readable for safe execution"):\n execute_plan(plan)\n\n assert source.read_text(encoding="utf-8") == "planned source"\n assert not (tmp_path / "documents").exists()\n\n\ndef test_category_fd_is_closed_when_anchor_verification_fails(\n monkeypatch: pytest.MonkeyPatch,\n tmp_path: Path,\n) -> None:\n if not file_organizer._supports_secure_directory_fds():\n pytest.skip("secure directory descriptors are unavailable on this platform")\n\n root_fd = file_organizer._open_source_directory_fd(tmp_path)\n original_open = os.open\n original_close = os.close\n opened_category_fd: int | None = None\n closed_fds: list[int] = []\n\n def tracking_open(\n path: str | os.PathLike[str],\n flags: int,\n mode: int = 0o777,\n *,\n dir_fd: int | None = None,\n ) -> int:\n nonlocal opened_category_fd\n fd = original_open(path, flags, mode, dir_fd=dir_fd)\n if path == "documents" and dir_fd == root_fd:\n opened_category_fd = fd\n return fd\n\n def tracking_close(fd: int) -> None:\n closed_fds.append(fd)\n original_close(fd)\n\n def failing_anchor(**_: object) -> None:\n raise ValueError("simulated category anchor race")\n\n monkeypatch.setattr(file_organizer.os, "open", tracking_open)\n monkeypatch.setattr(file_organizer.os, "close", tracking_close)\n monkeypatch.setattr(file_organizer, "_verify_category_anchor_at", failing_anchor)\n\n try:\n with pytest.raises(ValueError, match="simulated category anchor race"):\n file_organizer._open_category_directory_fd(root_fd, "documents")\n finally:\n original_close(root_fd)\n\n assert opened_category_fd is not None\n assert opened_category_fd in closed_fds\n''' + test_path.write_text(test_text, encoding="utf-8") + +DOC_UPDATES = { + "practical-projects/06-file-organizer/README.md": ( + "A staging pathname is not an inode lock. If the final rename consumes a replacement entry and destination identity verification detects the mismatch, execution leaves the unrelated destination intact and, before closing the still-pinned source file descriptor, copies the planned source bytes into an exclusive `.fo-recovery-*` regular file. This preserves recoverable data without claiming that the original inode survived the race.\n", + "A staging pathname is not an inode lock. If the final rename consumes a replacement entry and destination identity verification detects the mismatch, execution leaves the unrelated destination intact and, before closing the still-pinned source file descriptor, copies the planned source bytes into an exclusive `.fo-recovery-*` regular file. This preserves recoverable data without claiming that the original inode survived the race.\n\nSafe Linux execution therefore deliberately requires read access to each planned regular file. Readability is validated before category directories are created and again when the source inode is pinned for mutation; permission failures are reported as `PermissionError`, not as a false source-identity change.\n", + ), + "practical-projects/06-file-organizer/README.pt-BR.md": ( + "Um pathname de staging não funciona como lock de inode. Se o rename final consumir uma entrada substituta e a verificação de identidade do destino detectar a divergência, a execução mantém intacto o destino alheio e, antes de fechar o descritor ainda pinado da origem, copia os bytes planejados para um arquivo regular exclusivo `.fo-recovery-*`. Essa recuperação preserva dados recuperáveis sem afirmar que o inode original sobreviveu à corrida.\n", + "Um pathname de staging não funciona como lock de inode. Se o rename final consumir uma entrada substituta e a verificação de identidade do destino detectar a divergência, a execução mantém intacto o destino alheio e, antes de fechar o descritor ainda pinado da origem, copia os bytes planejados para um arquivo regular exclusivo `.fo-recovery-*`. Essa recuperação preserva dados recuperáveis sem afirmar que o inode original sobreviveu à corrida.\n\nPor isso, a execução segura no Linux exige deliberadamente permissão de leitura para cada arquivo regular planejado. A legibilidade é validada antes da criação das pastas de categoria e novamente ao pinar o inode da origem para a mutação; falhas de permissão são reportadas como `PermissionError`, e não como uma falsa mudança de identidade da origem.\n", + ), + "practical-projects/06-file-organizer/README.es.md": ( + "Un pathname de staging no funciona como lock de inode. Si el rename final consume una entrada de reemplazo y la verificación de identidad del destino detecta la divergencia, la ejecución conserva intacto el destino ajeno y, antes de cerrar el descriptor aún anclado del origen, copia los bytes planificados a un archivo regular exclusivo `.fo-recovery-*`. Esta recuperación conserva datos recuperables sin afirmar que el inode original haya sobrevivido a la carrera.\n", + "Un pathname de staging no funciona como lock de inode. Si el rename final consume una entrada de reemplazo y la verificación de identidad del destino detecta la divergencia, la ejecución conserva intacto el destino ajeno y, antes de cerrar el descriptor aún anclado del origen, copia los bytes planificados a un archivo regular exclusivo `.fo-recovery-*`. Esta recuperación conserva datos recuperables sin afirmar que el inode original haya sobrevivido a la carrera.\n\nPor ello, la ejecución segura en Linux exige deliberadamente permiso de lectura para cada archivo regular planificado. La legibilidad se valida antes de crear los directorios de categoría y de nuevo al fijar el inode del origen para la mutación; los fallos de permisos se informan como `PermissionError`, no como un falso cambio de identidad del origen.\n", + ), +} + +for path, (old, new) in DOC_UPDATES.items(): + replace_once(path, old, new) From 09d886b3c97b1437c0ef611bc9ea06679a240d8a Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Tue, 1 Sep 2026 13:00:21 -0300 Subject: [PATCH 062/117] Run focused Review 8 fixes --- .../apply-file-organizer-review8.yml | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 .github/workflows/apply-file-organizer-review8.yml diff --git a/.github/workflows/apply-file-organizer-review8.yml b/.github/workflows/apply-file-organizer-review8.yml new file mode 100644 index 0000000..c08717c --- /dev/null +++ b/.github/workflows/apply-file-organizer-review8.yml @@ -0,0 +1,47 @@ +name: Apply File Organizer Review 8 fixes + +on: + push: + branches: + - phase-10-file-organizer + paths: + - .github/workflows/apply-file-organizer-review8.yml + +permissions: + contents: write + +jobs: + patch: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Check out feature branch + uses: actions/checkout@v6 + with: + ref: phase-10-file-organizer + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.13" + + - name: Install pytest + run: python -m pip install --disable-pip-version-check "pytest>=9.1,<9.2" + + - name: Apply Review 8 fixes + run: python scripts/_apply_file_organizer_review8.py + + - name: Validate focused changes + run: | + python -m py_compile practical-projects/06-file-organizer/file_organizer.py practical-projects/06-file-organizer/tests/test_atomic_move.py + python -m pytest -q practical-projects/06-file-organizer/tests + git diff --check + + - name: Commit Review 8 fixes + run: | + rm scripts/_apply_file_organizer_review8.py + git config user.name "Ramon Rodriguez" + git config user.email "ramoncorreka@hotmail.com" + git add practical-projects/06-file-organizer scripts/_apply_file_organizer_review8.py + git commit -m "Harden File Organizer permission and FD handling" + git push origin HEAD:phase-10-file-organizer From 8e6de869ffa183ddd9d00f91602250b3fd00823c Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Tue, 1 Sep 2026 16:00:36 +0000 Subject: [PATCH 063/117] Harden File Organizer permission and FD handling --- .../06-file-organizer/README.es.md | 2 + .../06-file-organizer/README.md | 2 + .../06-file-organizer/README.pt-BR.md | 2 + .../06-file-organizer/file_organizer.py | 29 +++++-- .../tests/test_atomic_move.py | 81 +++++++++++++++++++ scripts/_apply_file_organizer_review8.py | 51 ------------ 6 files changed, 111 insertions(+), 56 deletions(-) delete mode 100644 scripts/_apply_file_organizer_review8.py diff --git a/practical-projects/06-file-organizer/README.es.md b/practical-projects/06-file-organizer/README.es.md index 65b02e3..7792cfb 100644 --- a/practical-projects/06-file-organizer/README.es.md +++ b/practical-projects/06-file-organizer/README.es.md @@ -274,6 +274,8 @@ Si la ejecución ya movió el origen al staging y después detecta una condició Un pathname de staging no funciona como lock de inode. Si el rename final consume una entrada de reemplazo y la verificación de identidad del destino detecta la divergencia, la ejecución conserva intacto el destino ajeno y, antes de cerrar el descriptor aún anclado del origen, copia los bytes planificados a un archivo regular exclusivo `.fo-recovery-*`. Esta recuperación conserva datos recuperables sin afirmar que el inode original haya sobrevivido a la carrera. +Por ello, la ejecución segura en Linux exige deliberadamente permiso de lectura para cada archivo regular planificado. La legibilidad se valida antes de crear los directorios de categoría y de nuevo al fijar el inode del origen para la mutación; los fallos de permisos se informan como `PermissionError`, no como un falso cambio de identidad del origen. + En escenarios raros de carrera/fallo, esto puede dejar una entrada interna de recuperación. Es preferible a borrar datos cuya identidad actual no puede demostrarse. El plan completo de varios archivos no es transaccional. diff --git a/practical-projects/06-file-organizer/README.md b/practical-projects/06-file-organizer/README.md index a6a73d2..1bb1cfb 100644 --- a/practical-projects/06-file-organizer/README.md +++ b/practical-projects/06-file-organizer/README.md @@ -274,6 +274,8 @@ If execution has already claimed the source into a staging entry and later detec A staging pathname is not an inode lock. If the final rename consumes a replacement entry and destination identity verification detects the mismatch, execution leaves the unrelated destination intact and, before closing the still-pinned source file descriptor, copies the planned source bytes into an exclusive `.fo-recovery-*` regular file. This preserves recoverable data without claiming that the original inode survived the race. +Safe Linux execution therefore deliberately requires read access to each planned regular file. Readability is validated before category directories are created and again when the source inode is pinned for mutation; permission failures are reported as `PermissionError`, not as a false source-identity change. + This can intentionally leave an internal recovery entry in unusual race/failure scenarios. That is preferable to deleting unrelated data whose current identity cannot be proven. The whole multi-file plan is not transactional. diff --git a/practical-projects/06-file-organizer/README.pt-BR.md b/practical-projects/06-file-organizer/README.pt-BR.md index 98013f3..4c9ee7f 100644 --- a/practical-projects/06-file-organizer/README.pt-BR.md +++ b/practical-projects/06-file-organizer/README.pt-BR.md @@ -274,6 +274,8 @@ Se a execução já moveu a origem para staging e depois detecta condição inse Um pathname de staging não funciona como lock de inode. Se o rename final consumir uma entrada substituta e a verificação de identidade do destino detectar a divergência, a execução mantém intacto o destino alheio e, antes de fechar o descritor ainda pinado da origem, copia os bytes planejados para um arquivo regular exclusivo `.fo-recovery-*`. Essa recuperação preserva dados recuperáveis sem afirmar que o inode original sobreviveu à corrida. +Por isso, a execução segura no Linux exige deliberadamente permissão de leitura para cada arquivo regular planejado. A legibilidade é validada antes da criação das pastas de categoria e novamente ao pinar o inode da origem para a mutação; falhas de permissão são reportadas como `PermissionError`, e não como uma falsa mudança de identidade da origem. + Em cenários raros de corrida/falha, isso pode deixar uma entrada interna de recuperação. É preferível a excluir dados cuja identidade atual não pode ser comprovada. O plano inteiro de múltiplos arquivos não é transacional. diff --git a/practical-projects/06-file-organizer/file_organizer.py b/practical-projects/06-file-organizer/file_organizer.py index f7f7fde..a234de0 100644 --- a/practical-projects/06-file-organizer/file_organizer.py +++ b/practical-projects/06-file-organizer/file_organizer.py @@ -471,11 +471,15 @@ def _open_category_directory_fd(root_fd: int, category_name: str) -> int: f"category directory became unsafe during execution: {category_name}" ) from exc - _verify_category_anchor_at( - root_fd=root_fd, - category_name=category_name, - category_fd=category_fd, - ) + try: + _verify_category_anchor_at( + root_fd=root_fd, + category_name=category_name, + category_fd=category_fd, + ) + except Exception: + os.close(category_fd) + raise return category_fd @@ -523,6 +527,10 @@ def _open_planned_source_fd_at( flags |= os.O_CLOEXEC try: source_fd = os.open(source_name, flags, dir_fd=root_fd) + except PermissionError as exc: + raise PermissionError( + f"planned source must be readable for safe execution: {source_name}" + ) from exc except OSError as exc: raise FileNotFoundError( f"planned source changed during execution: {source_name}" @@ -930,6 +938,17 @@ def _execute_plan_with_directory_fds( try: _verify_root_anchor_at(plan.source_directory, root_fd) + + # Readability is a deliberate secure-execution prerequisite because + # pinned-FD recovery must be able to persist the planned source bytes. + for action in plan.actions: + validation_fd = _open_planned_source_fd_at( + action.source.name, + root_fd=root_fd, + expected_identity=source_identities[action.source], + ) + os.close(validation_fd) + for category in sorted( {action.category for action in plan.actions}, key=lambda item: item.value, diff --git a/practical-projects/06-file-organizer/tests/test_atomic_move.py b/practical-projects/06-file-organizer/tests/test_atomic_move.py index 394420f..85ec390 100644 --- a/practical-projects/06-file-organizer/tests/test_atomic_move.py +++ b/practical-projects/06-file-organizer/tests/test_atomic_move.py @@ -459,3 +459,84 @@ def racing_rename_no_replace( assert len(recovery_files) == 1 assert recovery_files[0].read_text(encoding="utf-8") == "planned source" assert not source.exists() + + + +def test_secure_execution_reports_readability_precondition_before_categories( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + if not file_organizer._supports_secure_directory_fds(): + pytest.skip("secure directory descriptors are unavailable on this platform") + + source = tmp_path / "notes.txt" + source.write_text("planned source", encoding="utf-8") + plan = plan_organization(tmp_path) + original_open = os.open + + def permission_denied_open( + path: str | os.PathLike[str], + flags: int, + mode: int = 0o777, + *, + dir_fd: int | None = None, + ) -> int: + if path == source.name and dir_fd is not None: + raise PermissionError("simulated unreadable source") + return original_open(path, flags, mode, dir_fd=dir_fd) + + monkeypatch.setattr(file_organizer, "_supports_secure_directory_fds", lambda: True) + monkeypatch.setattr(file_organizer.os, "open", permission_denied_open) + + with pytest.raises(PermissionError, match="must be readable for safe execution"): + execute_plan(plan) + + assert source.read_text(encoding="utf-8") == "planned source" + assert not (tmp_path / "documents").exists() + + +def test_category_fd_is_closed_when_anchor_verification_fails( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + if not file_organizer._supports_secure_directory_fds(): + pytest.skip("secure directory descriptors are unavailable on this platform") + + root_fd = file_organizer._open_source_directory_fd(tmp_path) + original_open = os.open + original_close = os.close + opened_category_fd: int | None = None + closed_fds: list[int] = [] + + def tracking_open( + path: str | os.PathLike[str], + flags: int, + mode: int = 0o777, + *, + dir_fd: int | None = None, + ) -> int: + nonlocal opened_category_fd + fd = original_open(path, flags, mode, dir_fd=dir_fd) + if path == "documents" and dir_fd == root_fd: + opened_category_fd = fd + return fd + + def tracking_close(fd: int) -> None: + closed_fds.append(fd) + original_close(fd) + + def failing_anchor(**_: object) -> None: + raise ValueError("simulated category anchor race") + + monkeypatch.setattr(file_organizer.os, "open", tracking_open) + monkeypatch.setattr(file_organizer.os, "close", tracking_close) + monkeypatch.setattr(file_organizer, "_verify_category_anchor_at", failing_anchor) + + try: + with pytest.raises(ValueError, match="simulated category anchor race"): + file_organizer._open_category_directory_fd(root_fd, "documents") + finally: + original_close(root_fd) + + assert opened_category_fd is not None + assert opened_category_fd in closed_fds diff --git a/scripts/_apply_file_organizer_review8.py b/scripts/_apply_file_organizer_review8.py deleted file mode 100644 index e07f7ea..0000000 --- a/scripts/_apply_file_organizer_review8.py +++ /dev/null @@ -1,51 +0,0 @@ -from pathlib import Path - - -def replace_once(path: str, old: str, new: str) -> None: - target = Path(path) - text = target.read_text(encoding="utf-8") - count = text.count(old) - if count != 1: - raise SystemExit(f"expected exactly one patch anchor in {path}, found {count}") - target.write_text(text.replace(old, new, 1), encoding="utf-8") - - -IMPLEMENTATION = "practical-projects/06-file-organizer/file_organizer.py" -TESTS = "practical-projects/06-file-organizer/tests/test_atomic_move.py" - -category_old = ''' _verify_category_anchor_at(\n root_fd=root_fd,\n category_name=category_name,\n category_fd=category_fd,\n )\n return category_fd\n''' -category_new = ''' try:\n _verify_category_anchor_at(\n root_fd=root_fd,\n category_name=category_name,\n category_fd=category_fd,\n )\n except Exception:\n os.close(category_fd)\n raise\n return category_fd\n''' -replace_once(IMPLEMENTATION, category_old, category_new) - -permission_old = ''' try:\n source_fd = os.open(source_name, flags, dir_fd=root_fd)\n except OSError as exc:\n raise FileNotFoundError(\n f"planned source changed during execution: {source_name}"\n ) from exc\n''' -permission_new = ''' try:\n source_fd = os.open(source_name, flags, dir_fd=root_fd)\n except PermissionError as exc:\n raise PermissionError(\n f"planned source must be readable for safe execution: {source_name}"\n ) from exc\n except OSError as exc:\n raise FileNotFoundError(\n f"planned source changed during execution: {source_name}"\n ) from exc\n''' -replace_once(IMPLEMENTATION, permission_old, permission_new) - -prevalidate_old = ''' try:\n _verify_root_anchor_at(plan.source_directory, root_fd)\n for category in sorted(\n''' -prevalidate_new = ''' try:\n _verify_root_anchor_at(plan.source_directory, root_fd)\n\n # Readability is a deliberate secure-execution prerequisite because\n # pinned-FD recovery must be able to persist the planned source bytes.\n for action in plan.actions:\n validation_fd = _open_planned_source_fd_at(\n action.source.name,\n root_fd=root_fd,\n expected_identity=source_identities[action.source],\n )\n os.close(validation_fd)\n\n for category in sorted(\n''' -replace_once(IMPLEMENTATION, prevalidate_old, prevalidate_new) - - -test_path = Path(TESTS) -test_text = test_path.read_text(encoding="utf-8") -if "test_secure_execution_reports_readability_precondition_before_categories" not in test_text: - test_text += '''\n\n\ndef test_secure_execution_reports_readability_precondition_before_categories(\n monkeypatch: pytest.MonkeyPatch,\n tmp_path: Path,\n) -> None:\n if not file_organizer._supports_secure_directory_fds():\n pytest.skip("secure directory descriptors are unavailable on this platform")\n\n source = tmp_path / "notes.txt"\n source.write_text("planned source", encoding="utf-8")\n plan = plan_organization(tmp_path)\n original_open = os.open\n\n def permission_denied_open(\n path: str | os.PathLike[str],\n flags: int,\n mode: int = 0o777,\n *,\n dir_fd: int | None = None,\n ) -> int:\n if path == source.name and dir_fd is not None:\n raise PermissionError("simulated unreadable source")\n return original_open(path, flags, mode, dir_fd=dir_fd)\n\n monkeypatch.setattr(file_organizer, "_supports_secure_directory_fds", lambda: True)\n monkeypatch.setattr(file_organizer.os, "open", permission_denied_open)\n\n with pytest.raises(PermissionError, match="must be readable for safe execution"):\n execute_plan(plan)\n\n assert source.read_text(encoding="utf-8") == "planned source"\n assert not (tmp_path / "documents").exists()\n\n\ndef test_category_fd_is_closed_when_anchor_verification_fails(\n monkeypatch: pytest.MonkeyPatch,\n tmp_path: Path,\n) -> None:\n if not file_organizer._supports_secure_directory_fds():\n pytest.skip("secure directory descriptors are unavailable on this platform")\n\n root_fd = file_organizer._open_source_directory_fd(tmp_path)\n original_open = os.open\n original_close = os.close\n opened_category_fd: int | None = None\n closed_fds: list[int] = []\n\n def tracking_open(\n path: str | os.PathLike[str],\n flags: int,\n mode: int = 0o777,\n *,\n dir_fd: int | None = None,\n ) -> int:\n nonlocal opened_category_fd\n fd = original_open(path, flags, mode, dir_fd=dir_fd)\n if path == "documents" and dir_fd == root_fd:\n opened_category_fd = fd\n return fd\n\n def tracking_close(fd: int) -> None:\n closed_fds.append(fd)\n original_close(fd)\n\n def failing_anchor(**_: object) -> None:\n raise ValueError("simulated category anchor race")\n\n monkeypatch.setattr(file_organizer.os, "open", tracking_open)\n monkeypatch.setattr(file_organizer.os, "close", tracking_close)\n monkeypatch.setattr(file_organizer, "_verify_category_anchor_at", failing_anchor)\n\n try:\n with pytest.raises(ValueError, match="simulated category anchor race"):\n file_organizer._open_category_directory_fd(root_fd, "documents")\n finally:\n original_close(root_fd)\n\n assert opened_category_fd is not None\n assert opened_category_fd in closed_fds\n''' - test_path.write_text(test_text, encoding="utf-8") - -DOC_UPDATES = { - "practical-projects/06-file-organizer/README.md": ( - "A staging pathname is not an inode lock. If the final rename consumes a replacement entry and destination identity verification detects the mismatch, execution leaves the unrelated destination intact and, before closing the still-pinned source file descriptor, copies the planned source bytes into an exclusive `.fo-recovery-*` regular file. This preserves recoverable data without claiming that the original inode survived the race.\n", - "A staging pathname is not an inode lock. If the final rename consumes a replacement entry and destination identity verification detects the mismatch, execution leaves the unrelated destination intact and, before closing the still-pinned source file descriptor, copies the planned source bytes into an exclusive `.fo-recovery-*` regular file. This preserves recoverable data without claiming that the original inode survived the race.\n\nSafe Linux execution therefore deliberately requires read access to each planned regular file. Readability is validated before category directories are created and again when the source inode is pinned for mutation; permission failures are reported as `PermissionError`, not as a false source-identity change.\n", - ), - "practical-projects/06-file-organizer/README.pt-BR.md": ( - "Um pathname de staging não funciona como lock de inode. Se o rename final consumir uma entrada substituta e a verificação de identidade do destino detectar a divergência, a execução mantém intacto o destino alheio e, antes de fechar o descritor ainda pinado da origem, copia os bytes planejados para um arquivo regular exclusivo `.fo-recovery-*`. Essa recuperação preserva dados recuperáveis sem afirmar que o inode original sobreviveu à corrida.\n", - "Um pathname de staging não funciona como lock de inode. Se o rename final consumir uma entrada substituta e a verificação de identidade do destino detectar a divergência, a execução mantém intacto o destino alheio e, antes de fechar o descritor ainda pinado da origem, copia os bytes planejados para um arquivo regular exclusivo `.fo-recovery-*`. Essa recuperação preserva dados recuperáveis sem afirmar que o inode original sobreviveu à corrida.\n\nPor isso, a execução segura no Linux exige deliberadamente permissão de leitura para cada arquivo regular planejado. A legibilidade é validada antes da criação das pastas de categoria e novamente ao pinar o inode da origem para a mutação; falhas de permissão são reportadas como `PermissionError`, e não como uma falsa mudança de identidade da origem.\n", - ), - "practical-projects/06-file-organizer/README.es.md": ( - "Un pathname de staging no funciona como lock de inode. Si el rename final consume una entrada de reemplazo y la verificación de identidad del destino detecta la divergencia, la ejecución conserva intacto el destino ajeno y, antes de cerrar el descriptor aún anclado del origen, copia los bytes planificados a un archivo regular exclusivo `.fo-recovery-*`. Esta recuperación conserva datos recuperables sin afirmar que el inode original haya sobrevivido a la carrera.\n", - "Un pathname de staging no funciona como lock de inode. Si el rename final consume una entrada de reemplazo y la verificación de identidad del destino detecta la divergencia, la ejecución conserva intacto el destino ajeno y, antes de cerrar el descriptor aún anclado del origen, copia los bytes planificados a un archivo regular exclusivo `.fo-recovery-*`. Esta recuperación conserva datos recuperables sin afirmar que el inode original haya sobrevivido a la carrera.\n\nPor ello, la ejecución segura en Linux exige deliberadamente permiso de lectura para cada archivo regular planificado. La legibilidad se valida antes de crear los directorios de categoría y de nuevo al fijar el inode del origen para la mutación; los fallos de permisos se informan como `PermissionError`, no como un falso cambio de identidad del origen.\n", - ), -} - -for path, (old, new) in DOC_UPDATES.items(): - replace_once(path, old, new) From ec517e2b626b00f070d6fd1b162955f17f218427 Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Tue, 1 Sep 2026 13:01:27 -0300 Subject: [PATCH 064/117] Remove temporary Review 8 workflow --- .../apply-file-organizer-review8.yml | 47 ------------------- 1 file changed, 47 deletions(-) delete mode 100644 .github/workflows/apply-file-organizer-review8.yml diff --git a/.github/workflows/apply-file-organizer-review8.yml b/.github/workflows/apply-file-organizer-review8.yml deleted file mode 100644 index c08717c..0000000 --- a/.github/workflows/apply-file-organizer-review8.yml +++ /dev/null @@ -1,47 +0,0 @@ -name: Apply File Organizer Review 8 fixes - -on: - push: - branches: - - phase-10-file-organizer - paths: - - .github/workflows/apply-file-organizer-review8.yml - -permissions: - contents: write - -jobs: - patch: - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - name: Check out feature branch - uses: actions/checkout@v6 - with: - ref: phase-10-file-organizer - - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: "3.13" - - - name: Install pytest - run: python -m pip install --disable-pip-version-check "pytest>=9.1,<9.2" - - - name: Apply Review 8 fixes - run: python scripts/_apply_file_organizer_review8.py - - - name: Validate focused changes - run: | - python -m py_compile practical-projects/06-file-organizer/file_organizer.py practical-projects/06-file-organizer/tests/test_atomic_move.py - python -m pytest -q practical-projects/06-file-organizer/tests - git diff --check - - - name: Commit Review 8 fixes - run: | - rm scripts/_apply_file_organizer_review8.py - git config user.name "Ramon Rodriguez" - git config user.email "ramoncorreka@hotmail.com" - git add practical-projects/06-file-organizer scripts/_apply_file_organizer_review8.py - git commit -m "Harden File Organizer permission and FD handling" - git push origin HEAD:phase-10-file-organizer From 9ff41c236c29f16b9325fae3daaf7dda97388021 Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Tue, 1 Sep 2026 15:14:34 -0300 Subject: [PATCH 065/117] Prepare File Organizer Review 9 fix --- scripts/_apply_file_organizer_review9.py | 47 ++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 scripts/_apply_file_organizer_review9.py diff --git a/scripts/_apply_file_organizer_review9.py b/scripts/_apply_file_organizer_review9.py new file mode 100644 index 0000000..5e34226 --- /dev/null +++ b/scripts/_apply_file_organizer_review9.py @@ -0,0 +1,47 @@ +from pathlib import Path + + +def replace_once(path: str, old: str, new: str) -> None: + target = Path(path) + text = target.read_text(encoding="utf-8") + count = text.count(old) + if count != 1: + raise SystemExit(f"expected exactly one patch anchor in {path}, found {count}") + target.write_text(text.replace(old, new, 1), encoding="utf-8") + + +IMPLEMENTATION = "practical-projects/06-file-organizer/file_organizer.py" +TESTS = "practical-projects/06-file-organizer/tests/test_file_organizer.py" + +validation_old = '''def _validate_category_locations(source_directory: Path) -> None:\n for category in FileCategory:\n target = source_directory / category.value\n if target.is_symlink():\n raise ValueError(f"category directory cannot be a symlink: {target.name}")\n if target.exists() and not target.is_dir():\n raise NotADirectoryError(\n f"category path exists but is not a directory: {target.name}"\n )\n''' +validation_new = '''def _is_directory_redirect(path: Path) -> bool:\n """Return whether a directory entry redirects traversal to another location."""\n if path.is_symlink():\n return True\n is_junction = getattr(path, "is_junction", None)\n return bool(is_junction is not None and is_junction())\n\n\ndef _validate_category_locations(source_directory: Path) -> None:\n for category in FileCategory:\n target = source_directory / category.value\n if _is_directory_redirect(target):\n raise ValueError(\n f"category directory cannot be a symlink or junction: {target.name}"\n )\n if target.exists() and not target.is_dir():\n raise NotADirectoryError(\n f"category path exists but is not a directory: {target.name}"\n )\n''' +replace_once(IMPLEMENTATION, validation_old, validation_new) + +portable_old = ''' directory.mkdir(exist_ok=True)\n if directory.is_symlink() or not directory.is_dir():\n raise ValueError(\n f"category directory became unsafe during execution: {directory.name}"\n )\n\n moved: list[Path] = []\n for action in plan.actions:\n if action.destination.parent.is_symlink():\n raise ValueError(\n "category directory became unsafe during execution: "\n f"{action.destination.parent.name}"\n )\n''' +portable_new = ''' directory.mkdir(exist_ok=True)\n if _is_directory_redirect(directory) or not directory.is_dir():\n raise ValueError(\n f"category directory became unsafe during execution: {directory.name}"\n )\n\n moved: list[Path] = []\n for action in plan.actions:\n if _is_directory_redirect(action.destination.parent):\n raise ValueError(\n "category directory became unsafe during execution: "\n f"{action.destination.parent.name}"\n )\n''' +replace_once(IMPLEMENTATION, portable_old, portable_new) + +import_old = '''from pathlib import Path\n\nimport pytest\n\nfrom file_organizer import (\n''' +import_new = '''from pathlib import Path\n\nimport pytest\n\nimport file_organizer\nfrom file_organizer import (\n''' +replace_once(TESTS, import_old, import_new) + +test_anchor = '''def test_plan_empty_directory_is_valid(tmp_path: Path) -> None:\n''' +tests_new = '''def test_plan_organization_rejects_category_directory_junction(\n monkeypatch: pytest.MonkeyPatch,\n tmp_path: Path,\n) -> None:\n source = tmp_path / "notes.txt"\n source.write_text("x", encoding="utf-8")\n documents = tmp_path / "documents"\n documents.mkdir()\n original_is_junction = getattr(Path, "is_junction", lambda self: False)\n\n def fake_is_junction(path: Path) -> bool:\n return path == documents or original_is_junction(path)\n\n monkeypatch.setattr(Path, "is_junction", fake_is_junction, raising=False)\n\n with pytest.raises(ValueError, match="symlink or junction"):\n plan_organization(tmp_path)\n\n assert source.read_text(encoding="utf-8") == "x"\n\n\ndef test_windows_portable_execution_rejects_late_category_junction(\n monkeypatch: pytest.MonkeyPatch,\n tmp_path: Path,\n) -> None:\n source = tmp_path / "notes.txt"\n source.write_text("planned", encoding="utf-8")\n plan = plan_organization(tmp_path)\n documents = tmp_path / "documents"\n identities = {source.resolve(): file_organizer._capture_path_identity(source.resolve())}\n original_is_junction = getattr(Path, "is_junction", lambda self: False)\n\n def fake_is_junction(path: Path) -> bool:\n return path == documents or original_is_junction(path)\n\n monkeypatch.setattr(Path, "is_junction", fake_is_junction, raising=False)\n monkeypatch.setattr(file_organizer.os, "name", "nt")\n\n with pytest.raises(ValueError, match="category directory became unsafe"):\n file_organizer._execute_plan_portable(plan, identities)\n\n assert source.read_text(encoding="utf-8") == "planned"\n assert not (documents / "notes.txt").exists()\n\n\n''' +replace_once(TESTS, test_anchor, tests_new + test_anchor) + +DOC_UPDATES = { + "practical-projects/06-file-organizer/README.md": ( + "The organizer does not follow direct-child symlinks. It also rejects a source directory or category folder that is a symlink.\n", + "The organizer does not follow direct-child symlinks. It also rejects a source directory or category folder that is a symlink. On Windows, category folders that are NTFS junctions are rejected too: `is_dir()` follows a junction, so accepting one could redirect a planned move outside the workspace.\n", + ), + "practical-projects/06-file-organizer/README.pt-BR.md": ( + "O organizador não segue symlinks filhos diretos. Também rejeita diretório de origem ou pasta de categoria que seja symlink.\n", + "O organizador não segue symlinks filhos diretos. Também rejeita diretório de origem ou pasta de categoria que seja symlink. No Windows, pastas de categoria que sejam junctions NTFS também são rejeitadas: `is_dir()` segue um junction, então aceitá-lo poderia redirecionar uma movimentação planejada para fora do workspace.\n", + ), + "practical-projects/06-file-organizer/README.es.md": ( + "El organizador no sigue symlinks hijos directos. También rechaza un directorio de origen o carpeta de categoría que sea symlink.\n", + "El organizador no sigue symlinks hijos directos. También rechaza un directorio de origen o carpeta de categoría que sea symlink. En Windows, las carpetas de categoría que sean junctions NTFS también se rechazan: `is_dir()` sigue un junction, por lo que aceptarlo podría redirigir un movimiento planificado fuera del workspace.\n", + ), +} +for path, (old, new) in DOC_UPDATES.items(): + replace_once(path, old, new) From eb6b05857e932fcb3bb128edadbc3d9afb0e1e68 Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Tue, 1 Sep 2026 15:14:49 -0300 Subject: [PATCH 066/117] Run focused File Organizer Review 9 fix --- .../apply-file-organizer-review9.yml | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 .github/workflows/apply-file-organizer-review9.yml diff --git a/.github/workflows/apply-file-organizer-review9.yml b/.github/workflows/apply-file-organizer-review9.yml new file mode 100644 index 0000000..102546e --- /dev/null +++ b/.github/workflows/apply-file-organizer-review9.yml @@ -0,0 +1,49 @@ +name: Apply File Organizer Review 9 fix + +on: + push: + branches: + - phase-10-file-organizer + +permissions: + contents: write + +jobs: + patch: + if: github.event.head_commit.message == 'Run focused File Organizer Review 9 fix' + runs-on: ubuntu-latest + steps: + - name: Check out feature branch + uses: actions/checkout@v6 + with: + ref: phase-10-file-organizer + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.13' + + - name: Install pytest + run: python -m pip install --disable-pip-version-check 'pytest>=9.1,<9.2' + + - name: Apply Review 9 fix + run: python scripts/_apply_file_organizer_review9.py + + - name: Validate focused changes + run: | + python -m compileall -q practical-projects/06-file-organizer + python -m pytest -q practical-projects/06-file-organizer/tests + + - name: Commit Review 9 fix + run: | + git config user.name 'Ramon Rodriguez' + git config user.email 'ramoncorreka@hotmail.com' + git rm scripts/_apply_file_organizer_review9.py + git add practical-projects/06-file-organizer/file_organizer.py + git add practical-projects/06-file-organizer/tests/test_file_organizer.py + git add practical-projects/06-file-organizer/README.md + git add practical-projects/06-file-organizer/README.pt-BR.md + git add practical-projects/06-file-organizer/README.es.md + git commit -m 'Reject Windows junction category redirects' + git push origin HEAD:phase-10-file-organizer From a90a2c6c72eaa55bb5cf2df2064e8cd3281b8f4f Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Tue, 1 Sep 2026 18:15:00 +0000 Subject: [PATCH 067/117] Reject Windows junction category redirects --- .../06-file-organizer/README.es.md | 2 +- .../06-file-organizer/README.md | 2 +- .../06-file-organizer/README.pt-BR.md | 2 +- .../06-file-organizer/file_organizer.py | 18 +++++-- .../tests/test_file_organizer.py | 46 ++++++++++++++++++ scripts/_apply_file_organizer_review9.py | 47 ------------------- 6 files changed, 63 insertions(+), 54 deletions(-) delete mode 100644 scripts/_apply_file_organizer_review9.py diff --git a/practical-projects/06-file-organizer/README.es.md b/practical-projects/06-file-organizer/README.es.md index 7792cfb..2c9a976 100644 --- a/practical-projects/06-file-organizer/README.es.md +++ b/practical-projects/06-file-organizer/README.es.md @@ -202,7 +202,7 @@ Hay una frontera importante: en un filesystem case-sensitive, la primitiva del k ## Fronteras de symlink y anclaje de directorios -El organizador no sigue symlinks hijos directos. También rechaza un directorio de origen o carpeta de categoría que sea symlink. +El organizador no sigue symlinks hijos directos. También rechaza un directorio de origen o carpeta de categoría que sea symlink. En Windows, las carpetas de categoría que sean junctions NTFS también se rechazan: `is_dir()` sigue un junction, por lo que aceptarlo podría redirigir un movimiento planificado fuera del workspace. En la ruta segura de Linux, la raíz y las categorías necesarias se abren con `O_DIRECTORY | O_NOFOLLOW`. Sus identidades `(device, inode)` se comparan repetidamente con las rutas que todavía deberían alcanzarlas. diff --git a/practical-projects/06-file-organizer/README.md b/practical-projects/06-file-organizer/README.md index 1bb1cfb..b87e225 100644 --- a/practical-projects/06-file-organizer/README.md +++ b/practical-projects/06-file-organizer/README.md @@ -202,7 +202,7 @@ There is an important boundary: on a case-sensitive filesystem, the kernel primi ## Symlink and directory-anchor boundaries -The organizer does not follow direct-child symlinks. It also rejects a source directory or category folder that is a symlink. +The organizer does not follow direct-child symlinks. It also rejects a source directory or category folder that is a symlink. On Windows, category folders that are NTFS junctions are rejected too: `is_dir()` follows a junction, so accepting one could redirect a planned move outside the workspace. On the secure Linux path, the source root and required category directories are opened with `O_DIRECTORY | O_NOFOLLOW`. Their `(device, inode)` identities are repeatedly compared with the paths that should still reach them. diff --git a/practical-projects/06-file-organizer/README.pt-BR.md b/practical-projects/06-file-organizer/README.pt-BR.md index 4c9ee7f..1204cb3 100644 --- a/practical-projects/06-file-organizer/README.pt-BR.md +++ b/practical-projects/06-file-organizer/README.pt-BR.md @@ -202,7 +202,7 @@ Há uma fronteira importante: em um filesystem case-sensitive, a primitiva do ke ## Fronteiras de symlink e ancoragem de diretórios -O organizador não segue symlinks filhos diretos. Também rejeita diretório de origem ou pasta de categoria que seja symlink. +O organizador não segue symlinks filhos diretos. Também rejeita diretório de origem ou pasta de categoria que seja symlink. No Windows, pastas de categoria que sejam junctions NTFS também são rejeitadas: `is_dir()` segue um junction, então aceitá-lo poderia redirecionar uma movimentação planejada para fora do workspace. No caminho seguro do Linux, a raiz e as categorias necessárias são abertas com `O_DIRECTORY | O_NOFOLLOW`. Suas identidades `(device, inode)` são comparadas repetidamente com os caminhos que ainda deveriam alcançá-las. diff --git a/practical-projects/06-file-organizer/file_organizer.py b/practical-projects/06-file-organizer/file_organizer.py index a234de0..2411def 100644 --- a/practical-projects/06-file-organizer/file_organizer.py +++ b/practical-projects/06-file-organizer/file_organizer.py @@ -305,11 +305,21 @@ def discover_files(source_directory: str | PathLike[str]) -> tuple[Path, ...]: return files +def _is_directory_redirect(path: Path) -> bool: + """Return whether a directory entry redirects traversal to another location.""" + if path.is_symlink(): + return True + is_junction = getattr(path, "is_junction", None) + return bool(is_junction is not None and is_junction()) + + def _validate_category_locations(source_directory: Path) -> None: for category in FileCategory: target = source_directory / category.value - if target.is_symlink(): - raise ValueError(f"category directory cannot be a symlink: {target.name}") + if _is_directory_redirect(target): + raise ValueError( + f"category directory cannot be a symlink or junction: {target.name}" + ) if target.exists() and not target.is_dir(): raise NotADirectoryError( f"category path exists but is not a directory: {target.name}" @@ -994,14 +1004,14 @@ def _execute_plan_portable( key=lambda path: (path.name.casefold(), path.name), ): directory.mkdir(exist_ok=True) - if directory.is_symlink() or not directory.is_dir(): + if _is_directory_redirect(directory) or not directory.is_dir(): raise ValueError( f"category directory became unsafe during execution: {directory.name}" ) moved: list[Path] = [] for action in plan.actions: - if action.destination.parent.is_symlink(): + if _is_directory_redirect(action.destination.parent): raise ValueError( "category directory became unsafe during execution: " f"{action.destination.parent.name}" diff --git a/practical-projects/06-file-organizer/tests/test_file_organizer.py b/practical-projects/06-file-organizer/tests/test_file_organizer.py index fc265c5..80455c7 100644 --- a/practical-projects/06-file-organizer/tests/test_file_organizer.py +++ b/practical-projects/06-file-organizer/tests/test_file_organizer.py @@ -2,6 +2,7 @@ import pytest +import file_organizer from file_organizer import ( CollisionPolicy, FileCategory, @@ -200,6 +201,51 @@ def test_plan_organization_rejects_category_directory_symlink(tmp_path: Path) -> plan_organization(tmp_path) +def test_plan_organization_rejects_category_directory_junction( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + source = tmp_path / "notes.txt" + source.write_text("x", encoding="utf-8") + documents = tmp_path / "documents" + documents.mkdir() + original_is_junction = getattr(Path, "is_junction", lambda self: False) + + def fake_is_junction(path: Path) -> bool: + return path == documents or original_is_junction(path) + + monkeypatch.setattr(Path, "is_junction", fake_is_junction, raising=False) + + with pytest.raises(ValueError, match="symlink or junction"): + plan_organization(tmp_path) + + assert source.read_text(encoding="utf-8") == "x" + + +def test_windows_portable_execution_rejects_late_category_junction( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + source = tmp_path / "notes.txt" + source.write_text("planned", encoding="utf-8") + plan = plan_organization(tmp_path) + documents = tmp_path / "documents" + identities = {source.resolve(): file_organizer._capture_path_identity(source.resolve())} + original_is_junction = getattr(Path, "is_junction", lambda self: False) + + def fake_is_junction(path: Path) -> bool: + return path == documents or original_is_junction(path) + + monkeypatch.setattr(Path, "is_junction", fake_is_junction, raising=False) + monkeypatch.setattr(file_organizer.os, "name", "nt") + + with pytest.raises(ValueError, match="category directory became unsafe"): + file_organizer._execute_plan_portable(plan, identities) + + assert source.read_text(encoding="utf-8") == "planned" + assert not (documents / "notes.txt").exists() + + def test_plan_empty_directory_is_valid(tmp_path: Path) -> None: plan = plan_organization(tmp_path) assert plan.actions == () diff --git a/scripts/_apply_file_organizer_review9.py b/scripts/_apply_file_organizer_review9.py deleted file mode 100644 index 5e34226..0000000 --- a/scripts/_apply_file_organizer_review9.py +++ /dev/null @@ -1,47 +0,0 @@ -from pathlib import Path - - -def replace_once(path: str, old: str, new: str) -> None: - target = Path(path) - text = target.read_text(encoding="utf-8") - count = text.count(old) - if count != 1: - raise SystemExit(f"expected exactly one patch anchor in {path}, found {count}") - target.write_text(text.replace(old, new, 1), encoding="utf-8") - - -IMPLEMENTATION = "practical-projects/06-file-organizer/file_organizer.py" -TESTS = "practical-projects/06-file-organizer/tests/test_file_organizer.py" - -validation_old = '''def _validate_category_locations(source_directory: Path) -> None:\n for category in FileCategory:\n target = source_directory / category.value\n if target.is_symlink():\n raise ValueError(f"category directory cannot be a symlink: {target.name}")\n if target.exists() and not target.is_dir():\n raise NotADirectoryError(\n f"category path exists but is not a directory: {target.name}"\n )\n''' -validation_new = '''def _is_directory_redirect(path: Path) -> bool:\n """Return whether a directory entry redirects traversal to another location."""\n if path.is_symlink():\n return True\n is_junction = getattr(path, "is_junction", None)\n return bool(is_junction is not None and is_junction())\n\n\ndef _validate_category_locations(source_directory: Path) -> None:\n for category in FileCategory:\n target = source_directory / category.value\n if _is_directory_redirect(target):\n raise ValueError(\n f"category directory cannot be a symlink or junction: {target.name}"\n )\n if target.exists() and not target.is_dir():\n raise NotADirectoryError(\n f"category path exists but is not a directory: {target.name}"\n )\n''' -replace_once(IMPLEMENTATION, validation_old, validation_new) - -portable_old = ''' directory.mkdir(exist_ok=True)\n if directory.is_symlink() or not directory.is_dir():\n raise ValueError(\n f"category directory became unsafe during execution: {directory.name}"\n )\n\n moved: list[Path] = []\n for action in plan.actions:\n if action.destination.parent.is_symlink():\n raise ValueError(\n "category directory became unsafe during execution: "\n f"{action.destination.parent.name}"\n )\n''' -portable_new = ''' directory.mkdir(exist_ok=True)\n if _is_directory_redirect(directory) or not directory.is_dir():\n raise ValueError(\n f"category directory became unsafe during execution: {directory.name}"\n )\n\n moved: list[Path] = []\n for action in plan.actions:\n if _is_directory_redirect(action.destination.parent):\n raise ValueError(\n "category directory became unsafe during execution: "\n f"{action.destination.parent.name}"\n )\n''' -replace_once(IMPLEMENTATION, portable_old, portable_new) - -import_old = '''from pathlib import Path\n\nimport pytest\n\nfrom file_organizer import (\n''' -import_new = '''from pathlib import Path\n\nimport pytest\n\nimport file_organizer\nfrom file_organizer import (\n''' -replace_once(TESTS, import_old, import_new) - -test_anchor = '''def test_plan_empty_directory_is_valid(tmp_path: Path) -> None:\n''' -tests_new = '''def test_plan_organization_rejects_category_directory_junction(\n monkeypatch: pytest.MonkeyPatch,\n tmp_path: Path,\n) -> None:\n source = tmp_path / "notes.txt"\n source.write_text("x", encoding="utf-8")\n documents = tmp_path / "documents"\n documents.mkdir()\n original_is_junction = getattr(Path, "is_junction", lambda self: False)\n\n def fake_is_junction(path: Path) -> bool:\n return path == documents or original_is_junction(path)\n\n monkeypatch.setattr(Path, "is_junction", fake_is_junction, raising=False)\n\n with pytest.raises(ValueError, match="symlink or junction"):\n plan_organization(tmp_path)\n\n assert source.read_text(encoding="utf-8") == "x"\n\n\ndef test_windows_portable_execution_rejects_late_category_junction(\n monkeypatch: pytest.MonkeyPatch,\n tmp_path: Path,\n) -> None:\n source = tmp_path / "notes.txt"\n source.write_text("planned", encoding="utf-8")\n plan = plan_organization(tmp_path)\n documents = tmp_path / "documents"\n identities = {source.resolve(): file_organizer._capture_path_identity(source.resolve())}\n original_is_junction = getattr(Path, "is_junction", lambda self: False)\n\n def fake_is_junction(path: Path) -> bool:\n return path == documents or original_is_junction(path)\n\n monkeypatch.setattr(Path, "is_junction", fake_is_junction, raising=False)\n monkeypatch.setattr(file_organizer.os, "name", "nt")\n\n with pytest.raises(ValueError, match="category directory became unsafe"):\n file_organizer._execute_plan_portable(plan, identities)\n\n assert source.read_text(encoding="utf-8") == "planned"\n assert not (documents / "notes.txt").exists()\n\n\n''' -replace_once(TESTS, test_anchor, tests_new + test_anchor) - -DOC_UPDATES = { - "practical-projects/06-file-organizer/README.md": ( - "The organizer does not follow direct-child symlinks. It also rejects a source directory or category folder that is a symlink.\n", - "The organizer does not follow direct-child symlinks. It also rejects a source directory or category folder that is a symlink. On Windows, category folders that are NTFS junctions are rejected too: `is_dir()` follows a junction, so accepting one could redirect a planned move outside the workspace.\n", - ), - "practical-projects/06-file-organizer/README.pt-BR.md": ( - "O organizador não segue symlinks filhos diretos. Também rejeita diretório de origem ou pasta de categoria que seja symlink.\n", - "O organizador não segue symlinks filhos diretos. Também rejeita diretório de origem ou pasta de categoria que seja symlink. No Windows, pastas de categoria que sejam junctions NTFS também são rejeitadas: `is_dir()` segue um junction, então aceitá-lo poderia redirecionar uma movimentação planejada para fora do workspace.\n", - ), - "practical-projects/06-file-organizer/README.es.md": ( - "El organizador no sigue symlinks hijos directos. También rechaza un directorio de origen o carpeta de categoría que sea symlink.\n", - "El organizador no sigue symlinks hijos directos. También rechaza un directorio de origen o carpeta de categoría que sea symlink. En Windows, las carpetas de categoría que sean junctions NTFS también se rechazan: `is_dir()` sigue un junction, por lo que aceptarlo podría redirigir un movimiento planificado fuera del workspace.\n", - ), -} -for path, (old, new) in DOC_UPDATES.items(): - replace_once(path, old, new) From 8b69b663670d55ee4d697cff96db523ff1ee7f94 Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Tue, 1 Sep 2026 15:15:58 -0300 Subject: [PATCH 068/117] Remove temporary Review 9 workflow --- .../apply-file-organizer-review9.yml | 49 ------------------- 1 file changed, 49 deletions(-) delete mode 100644 .github/workflows/apply-file-organizer-review9.yml diff --git a/.github/workflows/apply-file-organizer-review9.yml b/.github/workflows/apply-file-organizer-review9.yml deleted file mode 100644 index 102546e..0000000 --- a/.github/workflows/apply-file-organizer-review9.yml +++ /dev/null @@ -1,49 +0,0 @@ -name: Apply File Organizer Review 9 fix - -on: - push: - branches: - - phase-10-file-organizer - -permissions: - contents: write - -jobs: - patch: - if: github.event.head_commit.message == 'Run focused File Organizer Review 9 fix' - runs-on: ubuntu-latest - steps: - - name: Check out feature branch - uses: actions/checkout@v6 - with: - ref: phase-10-file-organizer - fetch-depth: 0 - - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: '3.13' - - - name: Install pytest - run: python -m pip install --disable-pip-version-check 'pytest>=9.1,<9.2' - - - name: Apply Review 9 fix - run: python scripts/_apply_file_organizer_review9.py - - - name: Validate focused changes - run: | - python -m compileall -q practical-projects/06-file-organizer - python -m pytest -q practical-projects/06-file-organizer/tests - - - name: Commit Review 9 fix - run: | - git config user.name 'Ramon Rodriguez' - git config user.email 'ramoncorreka@hotmail.com' - git rm scripts/_apply_file_organizer_review9.py - git add practical-projects/06-file-organizer/file_organizer.py - git add practical-projects/06-file-organizer/tests/test_file_organizer.py - git add practical-projects/06-file-organizer/README.md - git add practical-projects/06-file-organizer/README.pt-BR.md - git add practical-projects/06-file-organizer/README.es.md - git commit -m 'Reject Windows junction category redirects' - git push origin HEAD:phase-10-file-organizer From 1de0e2b5a411935f1a67219e835fa5ea1079329f Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Tue, 1 Sep 2026 17:10:23 -0300 Subject: [PATCH 069/117] Add temporary File Organizer full-review patch --- scripts/_apply_file_organizer_full_review.py | 216 +++++++++++++++++++ 1 file changed, 216 insertions(+) create mode 100644 scripts/_apply_file_organizer_full_review.py diff --git a/scripts/_apply_file_organizer_full_review.py b/scripts/_apply_file_organizer_full_review.py new file mode 100644 index 0000000..48336c3 --- /dev/null +++ b/scripts/_apply_file_organizer_full_review.py @@ -0,0 +1,216 @@ +from pathlib import Path + +ROOT = Path('.') +CODE = ROOT / 'practical-projects/06-file-organizer/file_organizer.py' +ATOMIC = ROOT / 'practical-projects/06-file-organizer/tests/test_atomic_move.py' +TESTS = ROOT / 'practical-projects/06-file-organizer/tests/test_file_organizer.py' +README_EN = ROOT / 'practical-projects/06-file-organizer/README.md' +README_PT = ROOT / 'practical-projects/06-file-organizer/README.pt-BR.md' +README_ES = ROOT / 'practical-projects/06-file-organizer/README.es.md' + + +def replace_once(path: Path, old: str, new: str) -> None: + text = path.read_text(encoding='utf-8') + count = text.count(old) + if count != 1: + raise RuntimeError(f'{path}: expected one anchor, found {count}') + path.write_text(text.replace(old, new, 1), encoding='utf-8') + + +def append_once(path: Path, marker: str, addition: str) -> None: + text = path.read_text(encoding='utf-8') + if marker in text: + raise RuntimeError(f'{path}: marker already present: {marker}') + path.write_text(text.rstrip() + '\n\n\n' + addition.strip() + '\n', encoding='utf-8') + + +# Core models and reserved internal namespace. +replace_once(CODE, '_RENAME_NOREPLACE = 1\n_AT_FDCWD = -100\n', '_RENAME_NOREPLACE = 1\n_INTERNAL_PREFIXES = (".fo-stage-", ".fo-recovery-")\n') +replace_once( + CODE, + '''@dataclass(frozen=True, slots=True)\nclass _FileIdentity:\n """Stable filesystem identity captured before mutation."""\n\n device: int\n inode: int\n''', + '''@dataclass(frozen=True, slots=True)\nclass _FileIdentity:\n """Stable filesystem identity captured while an object is pinned."""\n\n device: int\n inode: int\n\n\n@dataclass(frozen=True, slots=True)\nclass _PinnedSource:\n """Descriptor and identity accepted together for one planned source."""\n\n fd: int\n identity: _FileIdentity\n''', +) + +# Reject Windows junctions at the workspace root too. +replace_once( + CODE, + '''def _require_source_directory(value: str | PathLike[str]) -> Path:\n path = _coerce_path(value, "source_directory")\n if path.is_symlink():\n raise ValueError("source_directory cannot be a symlink")\n''', + '''def _require_source_directory(value: str | PathLike[str]) -> Path:\n path = _coerce_path(value, "source_directory")\n is_junction = getattr(path, "is_junction", None)\n if path.is_symlink() or bool(is_junction is not None and is_junction()):\n raise ValueError("source_directory cannot be a symlink or junction")\n''', +) + +# Never rediscover the organizer's own conservative recovery/staging artifacts. +replace_once( + CODE, + '''def _scan_source_directory(\n source_directory: Path,\n) -> tuple[tuple[Path, ...], tuple[Path, ...]]:\n files: list[Path] = []\n symlinks: list[Path] = []\n\n for child in sorted(source_directory.iterdir(), key=_path_sort_key):\n if child.is_symlink():\n symlinks.append(child.absolute())\n elif child.is_file():\n files.append(child.absolute())\n''', + '''def _scan_source_directory(\n source_directory: Path,\n) -> tuple[tuple[Path, ...], tuple[Path, ...]]:\n files: list[Path] = []\n symlinks: list[Path] = []\n\n for child in sorted(source_directory.iterdir(), key=_path_sort_key):\n if child.name.startswith(_INTERNAL_PREFIXES):\n continue\n if child.is_symlink():\n symlinks.append(child.absolute())\n elif child.is_file():\n files.append(child.absolute())\n''', +) + +# Preflight validates paths/collisions only. Linux accepts identity from open FDs; +# Windows keeps a documented best-effort pathname identity capture. +replace_once( + CODE, + '''def _preflight_execution(plan: OrganizationPlan) -> dict[Path, _FileIdentity]:\n root = _require_source_directory(plan.source_directory)\n if root != plan.source_directory:\n raise ValueError("source_directory no longer resolves to the planned directory")\n\n _validate_category_locations(root)\n\n source_identities = {\n action.source: _capture_path_identity(action.source) for action in plan.actions\n }\n\n for action in plan.actions:\n target_directory = action.destination.parent\n if target_directory.exists():\n current_names = _existing_names_casefold(target_directory)\n if action.destination.name.casefold() in current_names:\n raise FileExistsError(\n f"destination appeared after planning: {action.destination.name}"\n )\n elif action.destination.exists() or action.destination.is_symlink():\n raise FileExistsError(\n f"destination appeared after planning: {action.destination.name}"\n )\n\n return source_identities\n''', + '''def _preflight_execution(plan: OrganizationPlan) -> None:\n root = _require_source_directory(plan.source_directory)\n if root != plan.source_directory:\n raise ValueError("source_directory no longer resolves to the planned directory")\n\n _validate_category_locations(root)\n\n for action in plan.actions:\n target_directory = action.destination.parent\n if target_directory.exists():\n current_names = _existing_names_casefold(target_directory)\n if action.destination.name.casefold() in current_names:\n raise FileExistsError(\n f"destination appeared after planning: {action.destination.name}"\n )\n elif action.destination.exists() or action.destination.is_symlink():\n raise FileExistsError(\n f"destination appeared after planning: {action.destination.name}"\n )\n\n\ndef _capture_portable_source_identities(\n plan: OrganizationPlan,\n) -> dict[Path, _FileIdentity]:\n """Capture best-effort pathname identities for the guarded Windows path."""\n return {\n action.source: _capture_path_identity(action.source) for action in plan.actions\n }\n''', +) + +# Remove obsolete pathname verifier and accept identity only from the pinned FD. +replace_once( + CODE, + '''def _verify_source_identity_at(\n source_name: str,\n *,\n source_directory_fd: int,\n expected_identity: _FileIdentity,\n) -> None:\n current_identity = _regular_identity_at(\n source_name,\n directory_fd=source_directory_fd,\n )\n if current_identity != expected_identity:\n raise FileNotFoundError(\n f"planned source changed during execution: {source_name}"\n )\n\n\n''', + '', +) +replace_once( + CODE, + '''def _open_planned_source_fd_at(\n source_name: str,\n *,\n root_fd: int,\n expected_identity: _FileIdentity,\n) -> int:\n """Pin the planned inode without blocking on a late special-file replacement."""\n flags = os.O_RDONLY | os.O_NOFOLLOW\n if hasattr(os, "O_NONBLOCK"):\n flags |= os.O_NONBLOCK\n if hasattr(os, "O_CLOEXEC"):\n flags |= os.O_CLOEXEC\n try:\n source_fd = os.open(source_name, flags, dir_fd=root_fd)\n except PermissionError as exc:\n raise PermissionError(\n f"planned source must be readable for safe execution: {source_name}"\n ) from exc\n except OSError as exc:\n raise FileNotFoundError(\n f"planned source changed during execution: {source_name}"\n ) from exc\n\n try:\n current_identity = _identity_from_regular_stat(\n os.fstat(source_fd),\n filename=source_name,\n )\n if current_identity != expected_identity:\n raise FileNotFoundError(\n f"planned source changed during execution: {source_name}"\n )\n except Exception:\n os.close(source_fd)\n raise\n return source_fd\n\n\n''', + '''def _open_planned_source_fd_at(\n source_name: str,\n *,\n root_fd: int,\n) -> _PinnedSource:\n """Open first, then accept identity from the descriptor pinning the inode."""\n flags = os.O_RDONLY | os.O_NOFOLLOW\n if hasattr(os, "O_NONBLOCK"):\n flags |= os.O_NONBLOCK\n if hasattr(os, "O_CLOEXEC"):\n flags |= os.O_CLOEXEC\n try:\n source_fd = os.open(source_name, flags, dir_fd=root_fd)\n except PermissionError as exc:\n raise PermissionError(\n f"planned source must be readable for safe execution: {source_name}"\n ) from exc\n except OSError as exc:\n raise FileNotFoundError(\n f"planned source changed during execution: {source_name}"\n ) from exc\n\n try:\n identity = _identity_from_regular_stat(\n os.fstat(source_fd),\n filename=source_name,\n )\n except Exception:\n os.close(source_fd)\n raise\n return _PinnedSource(fd=source_fd, identity=identity)\n\n\ndef _pin_planned_sources_at(\n plan: OrganizationPlan,\n *,\n root_fd: int,\n) -> dict[Path, _PinnedSource]:\n """Pin every source before category creation or source mutation."""\n pinned: dict[Path, _PinnedSource] = {}\n try:\n for action in plan.actions:\n pinned[action.source] = _open_planned_source_fd_at(\n action.source.name,\n root_fd=root_fd,\n )\n except Exception:\n for source in pinned.values():\n os.close(source.fd)\n raise\n return pinned\n\n\n''', +) + +# Source -> stage also uses no-replace semantics with bounded retries. +replace_once( + CODE, + ''' stage_name = _make_stage_name(source_name)\n os.rename(\n source_name,\n stage_name,\n src_dir_fd=root_fd,\n dst_dir_fd=root_fd,\n )\n\n try:\n''', + ''' stage_name = ""\n for _ in range(16):\n stage_name = _make_stage_name(source_name)\n try:\n _rename_no_replace_at(\n source_name,\n stage_name,\n source_directory_fd=root_fd,\n destination_directory_fd=root_fd,\n )\n except FileExistsError:\n continue\n except FileNotFoundError as exc:\n raise FileNotFoundError(\n f"planned source changed during execution: {source_name}"\n ) from exc\n break\n else:\n raise FileExistsError(\n f"could not allocate staging entry for planned source: {source_name}"\n )\n\n try:\n''', +) + +# Move receives the already-pinned FD and recovers its bytes if pathname claim fails. +replace_once( + CODE, + '''def _move_file_no_replace_at(\n source_name: str,\n destination_name: str,\n *,\n source_directory_path: Path,\n source_directory_fd: int,\n destination_directory_fd: int,\n category_name: str,\n expected_identity: _FileIdentity,\n) -> None:\n """Commit one move with anchored directories and no replace/unlink window."""\n _verify_root_anchor_at(source_directory_path, source_directory_fd)\n _verify_category_anchor_at(\n root_fd=source_directory_fd,\n category_name=category_name,\n category_fd=destination_directory_fd,\n )\n source_fd = _open_planned_source_fd_at(\n source_name,\n root_fd=source_directory_fd,\n expected_identity=expected_identity,\n )\n\n try:\n stage_name = _claim_source_at(\n source_name,\n root_fd=source_directory_fd,\n expected_identity=expected_identity,\n )\n\n try:\n _verify_root_anchor_at(source_directory_path, source_directory_fd)\n _verify_category_anchor_at(\n root_fd=source_directory_fd,\n category_name=category_name,\n category_fd=destination_directory_fd,\n )\n staged_identity = _regular_identity_at(\n stage_name,\n directory_fd=source_directory_fd,\n )\n if staged_identity != expected_identity:\n raise FileNotFoundError(\n f"planned source changed during execution: {source_name}"\n )\n _verify_no_casefold_destination_collision_at(\n destination_name,\n destination_directory_fd=destination_directory_fd,\n )\n _rename_no_replace_at(\n stage_name,\n destination_name,\n source_directory_fd=source_directory_fd,\n destination_directory_fd=destination_directory_fd,\n )\n except (FileExistsError, FileNotFoundError, ValueError, OSError):\n _preserve_stage_at(stage_name, source_name, root_fd=source_directory_fd)\n raise\n\n try:\n _verify_destination_identity_at(\n destination_name,\n destination_directory_fd=destination_directory_fd,\n expected_identity=expected_identity,\n )\n except RuntimeError as exc:\n recovery_name = _recover_pinned_source_at(\n source_fd,\n source_name,\n root_fd=source_directory_fd,\n )\n raise RuntimeError(\n "destination does not match planned source; "\n f"planned source data retained as {recovery_name}: {destination_name}"\n ) from exc\n _verify_root_anchor_at(source_directory_path, source_directory_fd)\n _verify_category_anchor_at(\n root_fd=source_directory_fd,\n category_name=category_name,\n category_fd=destination_directory_fd,\n )\n finally:\n os.close(source_fd)\n\n\n''', + '''def _move_file_no_replace_at(\n source_name: str,\n destination_name: str,\n *,\n source_directory_path: Path,\n source_directory_fd: int,\n destination_directory_fd: int,\n category_name: str,\n source_fd: int,\n expected_identity: _FileIdentity,\n) -> None:\n """Commit one move using the source descriptor pinned before mutation."""\n _verify_root_anchor_at(source_directory_path, source_directory_fd)\n _verify_category_anchor_at(\n root_fd=source_directory_fd,\n category_name=category_name,\n category_fd=destination_directory_fd,\n )\n\n try:\n stage_name = _claim_source_at(\n source_name,\n root_fd=source_directory_fd,\n expected_identity=expected_identity,\n )\n except FileNotFoundError as exc:\n recovery_name = _recover_pinned_source_at(\n source_fd,\n source_name,\n root_fd=source_directory_fd,\n )\n raise FileNotFoundError(\n "planned source changed after it was pinned; "\n f"planned source data retained as {recovery_name}: {source_name}"\n ) from exc\n\n try:\n _verify_root_anchor_at(source_directory_path, source_directory_fd)\n _verify_category_anchor_at(\n root_fd=source_directory_fd,\n category_name=category_name,\n category_fd=destination_directory_fd,\n )\n staged_identity = _regular_identity_at(\n stage_name,\n directory_fd=source_directory_fd,\n )\n if staged_identity != expected_identity:\n raise FileNotFoundError(\n f"planned source changed during execution: {source_name}"\n )\n _verify_no_casefold_destination_collision_at(\n destination_name,\n destination_directory_fd=destination_directory_fd,\n )\n _rename_no_replace_at(\n stage_name,\n destination_name,\n source_directory_fd=source_directory_fd,\n destination_directory_fd=destination_directory_fd,\n )\n except (FileExistsError, FileNotFoundError, ValueError, OSError):\n _preserve_stage_at(stage_name, source_name, root_fd=source_directory_fd)\n raise\n\n try:\n _verify_destination_identity_at(\n destination_name,\n destination_directory_fd=destination_directory_fd,\n expected_identity=expected_identity,\n )\n except RuntimeError as exc:\n recovery_name = _recover_pinned_source_at(\n source_fd,\n source_name,\n root_fd=source_directory_fd,\n )\n raise RuntimeError(\n "destination does not match planned source; "\n f"planned source data retained as {recovery_name}: {destination_name}"\n ) from exc\n _verify_root_anchor_at(source_directory_path, source_directory_fd)\n _verify_category_anchor_at(\n root_fd=source_directory_fd,\n category_name=category_name,\n category_fd=destination_directory_fd,\n )\n\n\n''', +) + +# Linux pins all sources before any category directory is created and keeps them open. +replace_once( + CODE, + '''def _execute_plan_with_directory_fds(\n plan: OrganizationPlan,\n source_identities: dict[Path, _FileIdentity],\n) -> OrganizationResult:\n """Execute using pinned no-follow directory descriptors on Linux."""\n root_fd = _open_source_directory_fd(plan.source_directory)\n category_fds: dict[FileCategory, int] = {}\n\n try:\n _verify_root_anchor_at(plan.source_directory, root_fd)\n\n # Readability is a deliberate secure-execution prerequisite because\n # pinned-FD recovery must be able to persist the planned source bytes.\n for action in plan.actions:\n validation_fd = _open_planned_source_fd_at(\n action.source.name,\n root_fd=root_fd,\n expected_identity=source_identities[action.source],\n )\n os.close(validation_fd)\n\n for category in sorted(\n {action.category for action in plan.actions},\n key=lambda item: item.value,\n ):\n category_fds[category] = _open_category_directory_fd(\n root_fd,\n category.value,\n )\n\n moved: list[Path] = []\n for action in plan.actions:\n _move_file_no_replace_at(\n action.source.name,\n action.destination.name,\n source_directory_path=plan.source_directory,\n source_directory_fd=root_fd,\n destination_directory_fd=category_fds[action.category],\n category_name=action.category.value,\n expected_identity=source_identities[action.source],\n )\n moved.append(action.destination)\n\n _verify_root_anchor_at(plan.source_directory, root_fd)\n return OrganizationResult(plan=plan, moved_files=tuple(moved))\n finally:\n for directory_fd in category_fds.values():\n os.close(directory_fd)\n os.close(root_fd)\n\n\n''', + '''def _execute_plan_with_directory_fds(\n plan: OrganizationPlan,\n) -> OrganizationResult:\n """Execute using sources and directories pinned before Linux mutation."""\n root_fd = _open_source_directory_fd(plan.source_directory)\n pinned_sources: dict[Path, _PinnedSource] = {}\n category_fds: dict[FileCategory, int] = {}\n\n try:\n _verify_root_anchor_at(plan.source_directory, root_fd)\n\n # Accept identity only from already-open descriptors. Keeping every\n # descriptor alive prevents accepted inodes from being freed/reused.\n pinned_sources = _pin_planned_sources_at(plan, root_fd=root_fd)\n\n for category in sorted(\n {action.category for action in plan.actions},\n key=lambda item: item.value,\n ):\n category_fds[category] = _open_category_directory_fd(\n root_fd,\n category.value,\n )\n\n moved: list[Path] = []\n for action in plan.actions:\n pinned_source = pinned_sources[action.source]\n _move_file_no_replace_at(\n action.source.name,\n action.destination.name,\n source_directory_path=plan.source_directory,\n source_directory_fd=root_fd,\n destination_directory_fd=category_fds[action.category],\n category_name=action.category.value,\n source_fd=pinned_source.fd,\n expected_identity=pinned_source.identity,\n )\n moved.append(action.destination)\n\n _verify_root_anchor_at(plan.source_directory, root_fd)\n return OrganizationResult(plan=plan, moved_files=tuple(moved))\n finally:\n for directory_fd in category_fds.values():\n os.close(directory_fd)\n for source in pinned_sources.values():\n os.close(source.fd)\n os.close(root_fd)\n\n\n''', +) +replace_once( + CODE, + '''def execute_plan(plan: OrganizationPlan) -> OrganizationResult:\n """Execute a previously validated plan after a full collision preflight."""\n if not isinstance(plan, OrganizationPlan):\n raise TypeError("plan must be an OrganizationPlan")\n\n source_identities = _preflight_execution(plan)\n if not plan.actions:\n return OrganizationResult(plan=plan, moved_files=())\n\n if _supports_secure_directory_fds():\n return _execute_plan_with_directory_fds(plan, source_identities)\n return _execute_plan_portable(plan, source_identities)\n''', + '''def execute_plan(plan: OrganizationPlan) -> OrganizationResult:\n """Execute a plan under the strongest explicitly supported platform contract."""\n if not isinstance(plan, OrganizationPlan):\n raise TypeError("plan must be an OrganizationPlan")\n\n _preflight_execution(plan)\n if not plan.actions:\n return OrganizationResult(plan=plan, moved_files=())\n\n if _supports_secure_directory_fds():\n return _execute_plan_with_directory_fds(plan)\n\n source_identities = _capture_portable_source_identities(plan)\n return _execute_plan_portable(plan, source_identities)\n''', +) + +# Update the mutation-race wrapper for the new pinned-FD argument. +replace_once( + ATOMIC, + ''' def racing_move(\n source_name: str,\n destination_name: str,\n *,\n source_directory_path: Path,\n source_directory_fd: int,\n destination_directory_fd: int,\n category_name: str,\n expected_identity: file_organizer._FileIdentity,\n ) -> None:\n''', + ''' def racing_move(\n source_name: str,\n destination_name: str,\n *,\n source_directory_path: Path,\n source_directory_fd: int,\n destination_directory_fd: int,\n category_name: str,\n source_fd: int,\n expected_identity: file_organizer._FileIdentity,\n ) -> None:\n''', +) +replace_once( + ATOMIC, + ''' destination_directory_fd=destination_directory_fd,\n category_name=category_name,\n expected_identity=expected_identity,\n )\n\n monkeypatch.setattr(file_organizer, "_supports_secure_directory_fds", lambda: True)\n''', + ''' destination_directory_fd=destination_directory_fd,\n category_name=category_name,\n source_fd=source_fd,\n expected_identity=expected_identity,\n )\n\n monkeypatch.setattr(file_organizer, "_supports_secure_directory_fds", lambda: True)\n''', +) +append_once( + ATOMIC, + 'test_source_identity_is_accepted_only_after_descriptor_pin', + '''def test_source_identity_is_accepted_only_after_descriptor_pin(\n monkeypatch: pytest.MonkeyPatch,\n tmp_path: Path,\n) -> None:\n if not file_organizer._supports_secure_directory_fds():\n pytest.skip("secure directory descriptors are unavailable on this platform")\n\n source = tmp_path / "notes.txt"\n source.write_text("planned source", encoding="utf-8")\n plan = plan_organization(tmp_path)\n destination = tmp_path / "documents" / "notes.txt"\n original_pin = file_organizer._pin_planned_sources_at\n raced = False\n\n def racing_pin(\n plan_value: file_organizer.OrganizationPlan,\n *,\n root_fd: int,\n ) -> dict[Path, file_organizer._PinnedSource]:\n nonlocal raced\n pinned = original_pin(plan_value, root_fd=root_fd)\n if not raced:\n raced = True\n source.unlink()\n source.write_text("third-party replacement", encoding="utf-8")\n return pinned\n\n monkeypatch.setattr(file_organizer, "_pin_planned_sources_at", racing_pin)\n\n with pytest.raises(FileNotFoundError, match="planned source data retained"):\n execute_plan(plan)\n\n assert source.read_text(encoding="utf-8") == "third-party replacement"\n assert not destination.exists()\n recovery_files = [\n child for child in tmp_path.iterdir() if child.name.startswith(".fo-recovery-")\n ]\n assert len(recovery_files) == 1\n assert recovery_files[0].read_text(encoding="utf-8") == "planned source"\n''', +) + +# Real Windows junction and reserved-internal-namespace regressions. +replace_once(TESTS, 'from pathlib import Path\n', 'import os\nimport subprocess\nfrom pathlib import Path\n') +append_once( + TESTS, + 'test_internal_recovery_artifacts_are_reserved_from_future_plans', + '''def test_internal_recovery_artifacts_are_reserved_from_future_plans(tmp_path: Path) -> None:\n (tmp_path / ".fo-stage-deadbeef").write_text("stage", encoding="utf-8")\n (tmp_path / ".fo-recovery-deadbeef").write_text("recovery", encoding="utf-8")\n (tmp_path / "notes.txt").write_text("user", encoding="utf-8")\n\n plan = plan_organization(tmp_path)\n\n assert tuple(action.source.name for action in plan.actions) == ("notes.txt",)\n\n\n@pytest.mark.skipif(os.name != "nt", reason="requires Windows NTFS junction semantics")\ndef test_windows_real_source_and_category_junctions_are_rejected(tmp_path: Path) -> None:\n outside = tmp_path / "outside"\n outside.mkdir()\n\n source_junction = tmp_path / "workspace-link"\n subprocess.run(\n ["cmd", "/c", "mklink", "/J", str(source_junction), str(outside)],\n check=True,\n capture_output=True,\n text=True,\n )\n with pytest.raises(ValueError, match="symlink or junction"):\n plan_organization(source_junction)\n\n workspace = tmp_path / "workspace"\n workspace.mkdir()\n (workspace / "notes.txt").write_text("x", encoding="utf-8")\n category_junction = workspace / "documents"\n subprocess.run(\n ["cmd", "/c", "mklink", "/J", str(category_junction), str(outside)],\n check=True,\n capture_output=True,\n text=True,\n )\n with pytest.raises(ValueError, match="symlink or junction"):\n plan_organization(workspace)\n''', +) + +# Documentation EN. +replace_once( + README_EN, + 'During secure Linux execution, the planned source is opened with `O_NOFOLLOW | O_NONBLOCK` when `O_NONBLOCK` is available. The nonblocking flag prevents a late FIFO replacement from hanging `open()`, while the following `fstat()` still requires a regular file with the planned `(device, inode)` identity. The open descriptor pins the expected inode while the commit runs.', + 'During secure Linux execution, source identity is accepted **only after the source has been opened** with `O_NOFOLLOW | O_NONBLOCK` when `O_NONBLOCK` is available. The following `fstat()` derives `(device, inode)` from that already-open descriptor, and every planned source descriptor stays open until the plan finishes. An accepted inode that is later unlinked therefore cannot be freed and immediately reused while execution still depends on its identity. The nonblocking flag also prevents a late FIFO replacement from hanging `open()`. Descriptor pinning stabilizes object identity, not file contents; concurrent writes to the same inode are outside this project\'s snapshot guarantees.', +) +replace_once( + README_EN, + '''1. preflight and capture source identity\n2. open and anchor the source root\n3. open and anchor required category directories\n4. pin the planned source inode with O_NOFOLLOW | O_NONBLOCK\n5. atomically claim source name -> short internal stage\n6. verify stage identity and directory anchors\n7. rescan the pinned category for a casefold-equivalent destination\n8. atomically rename stage -> exact destination with RENAME_NOREPLACE\n9. verify destination identity and anchors\n10. report success''', + '''1. validate paths and collision preflight\n2. open and anchor the source root\n3. open every planned source and accept identity from `fstat()` on that pinned descriptor\n4. keep all accepted source descriptors open through plan completion\n5. open and anchor required category directories\n6. claim source name -> short internal stage with no-replace semantics\n7. verify stage identity and directory anchors\n8. rescan the pinned category for a casefold-equivalent destination\n9. atomically rename stage -> exact destination with RENAME_NOREPLACE\n10. verify destination identity and anchors\n11. report success''', +) +replace_once( + README_EN, + '- **Windows:** the fallback relies on Windows `os.rename()` refusing an existing destination and performs a best-effort casefold recheck plus source/destination/category identity validation around the operation;', + '- **Windows:** the guarded portable path relies on Windows `os.rename()` refusing an existing destination and performs best-effort casefold, redirect, and identity checks. It does **not** claim the descriptor-pinned adversarial race resistance of the Linux path;', +) +replace_once( + README_EN, + '''4. planned-source identity capture;\n5. destination collision preflight;\n6. platform capability selection;\n7. anchored directory setup;\n8. nonblocking source pin and source claim;\n9. mutation-time casefold collision recheck;\n10. atomic exact-name no-replace commit;\n11. destination/anchor verification;\n12. `OrganizationResult` construction.''', + '''4. destination collision preflight;\n5. platform capability selection;\n6. Linux: pin every planned source before accepting identity and before category mutation;\n7. anchored directory setup;\n8. source claim;\n9. mutation-time casefold collision recheck;\n10. atomic exact-name no-replace commit;\n11. destination/anchor verification;\n12. `OrganizationResult` construction.''', +) +replace_once( + README_EN, + 'The organizer does not follow direct-child symlinks. It also rejects a source directory or category folder that is a symlink. On Windows, category folders that are NTFS junctions are rejected too: `is_dir()` follows a junction, so accepting one could redirect a planned move outside the workspace.', + 'The organizer does not follow direct-child symlinks. It rejects a source directory or category folder that is a symlink. On Windows, source directories and category folders that are NTFS junctions are rejected too: `is_dir()` follows a junction, so accepting one could redirect discovery or a planned move outside the workspace.', +) +replace_once( + README_EN, + 'This can intentionally leave an internal recovery entry in unusual race/failure scenarios. That is preferable to deleting unrelated data whose current identity cannot be proven.', + 'This can intentionally leave an internal recovery entry in unusual race/failure scenarios. The `.fo-stage-*` and `.fo-recovery-*` prefixes are reserved internal namespaces and are excluded from later discovery so recovery evidence is not accidentally reorganized. That is preferable to deleting or reclassifying uncertain data whose current identity cannot be proven.', +) + +# Documentation PT-BR. +replace_once( + README_PT, + 'Durante a execução segura no Linux, a origem planejada é aberta com `O_NOFOLLOW | O_NONBLOCK` quando `O_NONBLOCK` está disponível. A flag nonblocking impede que uma substituição tardia por FIFO trave o `open()`, enquanto o `fstat()` seguinte ainda exige um arquivo regular com a identidade `(device, inode)` planejada. O descriptor aberto fixa o inode esperado durante o commit.', + 'Durante a execução segura no Linux, a identidade da origem só é aceita **depois que o arquivo já foi aberto** com `O_NOFOLLOW | O_NONBLOCK` quando `O_NONBLOCK` está disponível. O `fstat()` deriva `(device, inode)` desse descriptor já aberto, e todos os descriptors das origens planejadas permanecem abertos até o fim do plano. Assim, um inode aceito e depois desvinculado não pode ser liberado e imediatamente reutilizado enquanto a execução ainda depende da sua identidade. A flag nonblocking também impede que uma substituição tardia por FIFO trave o `open()`. O pinning estabiliza a identidade do objeto, não o conteúdo; escritas concorrentes no mesmo inode ficam fora das garantias de snapshot deste projeto.', +) +replace_once( + README_PT, + '''1. executar preflight e capturar identidade da origem\n2. abrir e ancorar a raiz\n3. abrir e ancorar as categorias necessárias\n4. fixar o inode da origem com O_NOFOLLOW | O_NONBLOCK\n5. reivindicar atomicamente origem -> staging curto\n6. verificar identidade do staging e âncoras\n7. varrer novamente a categoria ancorada por destino equivalente via casefold\n8. renomear atomicamente staging -> destino exato com RENAME_NOREPLACE\n9. verificar identidade do destino e âncoras\n10. reportar sucesso''', + '''1. validar caminhos e executar o preflight de colisões\n2. abrir e ancorar a raiz\n3. abrir todas as origens planejadas e aceitar identidade pelo `fstat()` do descriptor pinado\n4. manter todos os descriptors aceitos abertos até o fim do plano\n5. abrir e ancorar as categorias necessárias\n6. reivindicar origem -> staging curto com semântica no-replace\n7. verificar identidade do staging e âncoras\n8. varrer novamente a categoria ancorada por destino equivalente via casefold\n9. renomear atomicamente staging -> destino exato com RENAME_NOREPLACE\n10. verificar identidade do destino e âncoras\n11. reportar sucesso''', +) +replace_once( + README_PT, + '- **Windows:** o fallback usa o comportamento de `os.rename()` que recusa destino existente e executa uma rechecagem `casefold()` best-effort mais validações de identidade ao redor da operação;', + '- **Windows:** o caminho portátil protegido usa `os.rename()` recusando destino existente e realiza checagens best-effort de `casefold()`, redirecionamento e identidade. Ele **não** afirma possuir a mesma resistência a corridas adversariais baseada em descriptors pinados do caminho Linux;', +) +replace_once( + README_PT, + '''4. captura das identidades das origens planejadas;\n5. preflight de colisões;\n6. seleção da capacidade da plataforma;\n7. preparação dos diretórios ancorados;\n8. pinning nonblocking e claim da origem;\n9. rechecagem de colisão por `casefold()` na mutação;\n10. commit atômico no-replace do nome exato;\n11. verificação do destino e das âncoras;\n12. construção de `OrganizationResult`.''', + '''4. preflight de colisões;\n5. seleção da capacidade da plataforma;\n6. Linux: pinning de todas as origens antes de aceitar identidade e antes de mutar categorias;\n7. preparação dos diretórios ancorados;\n8. claim da origem;\n9. rechecagem de colisão por `casefold()` na mutação;\n10. commit atômico no-replace do nome exato;\n11. verificação do destino e das âncoras;\n12. construção de `OrganizationResult`.''', +) +replace_once( + README_PT, + 'O organizador não segue symlinks filhos diretos. Também rejeita diretório de origem ou pasta de categoria que seja symlink. No Windows, pastas de categoria que sejam junctions NTFS também são rejeitadas: `is_dir()` segue um junction, então aceitá-lo poderia redirecionar uma movimentação planejada para fora do workspace.', + 'O organizador não segue symlinks filhos diretos. Também rejeita diretório de origem ou pasta de categoria que seja symlink. No Windows, tanto o diretório de origem quanto as pastas de categoria são rejeitados quando são junctions NTFS: `is_dir()` segue um junction, então aceitá-lo poderia redirecionar descoberta ou movimentação para fora do workspace.', +) +replace_once( + README_PT, + 'Em cenários raros de corrida/falha, isso pode deixar uma entrada interna de recuperação. É preferível a excluir dados cuja identidade atual não pode ser comprovada.', + 'Em cenários raros de corrida/falha, isso pode deixar uma entrada interna de recuperação. Os prefixos `.fo-stage-*` e `.fo-recovery-*` são namespaces internos reservados e ficam fora de descobertas futuras, evitando que evidências de recuperação sejam reorganizadas por acidente. É preferível a excluir ou reclassificar dados cuja identidade atual não pode ser comprovada.', +) + +# Documentation ES. +replace_once( + README_ES, + 'Durante la ejecución segura en Linux, el origen planificado se abre con `O_NOFOLLOW | O_NONBLOCK` cuando `O_NONBLOCK` está disponible. La flag nonblocking impide que una sustitución tardía por FIFO bloquee `open()`, mientras el `fstat()` posterior sigue exigiendo un archivo regular con la identidad `(device, inode)` planificada. El descriptor abierto fija el inode esperado durante el commit.', + 'Durante la ejecución segura en Linux, la identidad del origen se acepta **solo después de abrir el archivo** con `O_NOFOLLOW | O_NONBLOCK` cuando `O_NONBLOCK` está disponible. El `fstat()` deriva `(device, inode)` de ese descriptor ya abierto, y todos los descriptores de los orígenes planificados permanecen abiertos hasta que termina el plan. Así, un inode aceptado y luego desvinculado no puede liberarse y reutilizarse de inmediato mientras la ejecución todavía depende de su identidad. La flag nonblocking también evita que una sustitución tardía por FIFO bloquee `open()`. El pinning estabiliza la identidad del objeto, no su contenido; las escrituras concurrentes sobre el mismo inode quedan fuera de las garantías de snapshot de este proyecto.', +) +replace_once( + README_ES, + '''1. ejecutar preflight y capturar identidad del origen\n2. abrir y anclar la raíz\n3. abrir y anclar las categorías necesarias\n4. fijar el inode del origen con O_NOFOLLOW | O_NONBLOCK\n5. reclamar atómicamente origen -> staging corto\n6. verificar identidad del staging y anclajes\n7. escanear de nuevo la categoría anclada buscando un destino equivalente por casefold\n8. renombrar atómicamente staging -> destino exacto con RENAME_NOREPLACE\n9. verificar identidad del destino y anclajes\n10. informar éxito''', + '''1. validar rutas y ejecutar el preflight de colisiones\n2. abrir y anclar la raíz\n3. abrir todos los orígenes planificados y aceptar identidad mediante `fstat()` del descriptor fijado\n4. mantener abiertos todos los descriptores aceptados hasta que termine el plan\n5. abrir y anclar las categorías necesarias\n6. reclamar origen -> staging corto con semántica no-replace\n7. verificar identidad del staging y anclajes\n8. escanear de nuevo la categoría anclada buscando un destino equivalente por casefold\n9. renombrar atómicamente staging -> destino exacto con RENAME_NOREPLACE\n10. verificar identidad del destino y anclajes\n11. informar éxito''', +) +replace_once( + README_ES, + '- **Windows:** el fallback usa el comportamiento de `os.rename()` que rechaza un destino existente y realiza una nueva comprobación `casefold()` best-effort junto con validaciones de identidad alrededor de la operación;', + '- **Windows:** la ruta portátil protegida usa `os.rename()` rechazando un destino existente y realiza comprobaciones best-effort de `casefold()`, redirección e identidad. **No** afirma tener la misma resistencia a carreras adversariales basada en descriptores fijados que la ruta Linux;', +) +replace_once( + README_ES, + '''4. captura de identidades de los orígenes planificados;\n5. preflight de colisiones;\n6. selección de capacidades de plataforma;\n7. preparación de directorios anclados;\n8. pinning nonblocking y claim del origen;\n9. nueva comprobación de colisión por `casefold()` durante la mutación;\n10. commit atómico no-replace del nombre exacto;\n11. verificación de destino y anclajes;\n12. construcción de `OrganizationResult`.''', + '''4. preflight de colisiones;\n5. selección de capacidades de plataforma;\n6. Linux: fijar todos los orígenes antes de aceptar identidad y antes de mutar categorías;\n7. preparación de directorios anclados;\n8. claim del origen;\n9. nueva comprobación de colisión por `casefold()` durante la mutación;\n10. commit atómico no-replace del nombre exacto;\n11. verificación de destino y anclajes;\n12. construcción de `OrganizationResult`.''', +) +replace_once( + README_ES, + 'El organizador no sigue symlinks hijos directos. También rechaza directorio de origen o carpeta de categoría que sea symlink. En Windows, también se rechazan carpetas de categoría que sean junctions NTFS: `is_dir()` sigue un junction, por lo que aceptarlo podría redirigir un movimiento planificado fuera del workspace.', + 'El organizador no sigue symlinks hijos directos. También rechaza directorio de origen o carpeta de categoría que sea symlink. En Windows, tanto el directorio de origen como las carpetas de categoría se rechazan cuando son junctions NTFS: `is_dir()` sigue un junction, por lo que aceptarlo podría redirigir el descubrimiento o un movimiento fuera del workspace.', +) +replace_once( + README_ES, + 'En escenarios raros de carrera/fallo, esto puede dejar una entrada interna de recuperación. Es preferible a borrar datos cuya identidad actual no puede demostrarse.', + 'En escenarios raros de carrera/fallo, esto puede dejar una entrada interna de recuperación. Los prefijos `.fo-stage-*` y `.fo-recovery-*` son namespaces internos reservados y quedan fuera de descubrimientos futuros para que la evidencia de recuperación no se reorganice por accidente. Es preferible a borrar o reclasificar datos cuya identidad actual no puede demostrarse.', +) + +print('Applied File Organizer full-review hardening patch.') From 15198ede13a50211fa06195c88251ae97f9ca25b Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Tue, 1 Sep 2026 17:10:36 -0300 Subject: [PATCH 070/117] Add temporary File Organizer full-review workflow --- .../apply-file-organizer-full-review.yml | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 .github/workflows/apply-file-organizer-full-review.yml diff --git a/.github/workflows/apply-file-organizer-full-review.yml b/.github/workflows/apply-file-organizer-full-review.yml new file mode 100644 index 0000000..cfd5d05 --- /dev/null +++ b/.github/workflows/apply-file-organizer-full-review.yml @@ -0,0 +1,42 @@ +name: Apply File Organizer full-review patch + +on: + push: + branches: + - phase-10-file-organizer + paths: + - scripts/_apply_file_organizer_full_review.py + +permissions: + contents: write + +jobs: + patch: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Check out feature branch + uses: actions/checkout@v6 + with: + ref: phase-10-file-organizer + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.13" + - name: Install pytest + run: python -m pip install --disable-pip-version-check "pytest>=9.1,<9.2" + - name: Apply full-review patch + run: python scripts/_apply_file_organizer_full_review.py + - name: Validate focused patch + run: | + python -m py_compile practical-projects/06-file-organizer/file_organizer.py + python -m pytest -q practical-projects/06-file-organizer/tests + git diff --check + - name: Commit functional changes + run: | + rm scripts/_apply_file_organizer_full_review.py + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A practical-projects/06-file-organizer scripts/_apply_file_organizer_full_review.py + git commit -m "Pin File Organizer sources before identity acceptance" + git push origin HEAD:phase-10-file-organizer From 1445f55936fe91ccaf89cb5231fdef3ba02994af Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Tue, 1 Sep 2026 17:10:57 -0300 Subject: [PATCH 071/117] Trigger temporary File Organizer full-review workflow --- .github/workflows/apply-file-organizer-full-review.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/apply-file-organizer-full-review.yml b/.github/workflows/apply-file-organizer-full-review.yml index cfd5d05..732b533 100644 --- a/.github/workflows/apply-file-organizer-full-review.yml +++ b/.github/workflows/apply-file-organizer-full-review.yml @@ -6,6 +6,7 @@ on: - phase-10-file-organizer paths: - scripts/_apply_file_organizer_full_review.py + - .github/workflows/apply-file-organizer-full-review.yml permissions: contents: write From 7c3a0c79f84f022b6135f318d493c83ba528a510 Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Tue, 1 Sep 2026 17:12:14 -0300 Subject: [PATCH 072/117] Fix Spanish anchor in temporary full-review workflow --- .../workflows/apply-file-organizer-full-review.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.github/workflows/apply-file-organizer-full-review.yml b/.github/workflows/apply-file-organizer-full-review.yml index 732b533..42c716a 100644 --- a/.github/workflows/apply-file-organizer-full-review.yml +++ b/.github/workflows/apply-file-organizer-full-review.yml @@ -26,6 +26,19 @@ jobs: python-version: "3.13" - name: Install pytest run: python -m pip install --disable-pip-version-check "pytest>=9.1,<9.2" + - name: Normalize helper anchor + shell: bash + run: | + python - <<'PY' + from pathlib import Path + path = Path("scripts/_apply_file_organizer_full_review.py") + text = path.read_text(encoding="utf-8") + wrong = "El organizador no sigue symlinks hijos directos. También rechaza directorio de origen o carpeta de categoría que sea symlink. En Windows, también se rechazan carpetas de categoría que sean junctions NTFS: `is_dir()` sigue un junction, por lo que aceptarlo podría redirigir un movimiento planificado fuera del workspace." + current = "El organizador no sigue symlinks hijos directos. También rechaza un directorio de origen o carpeta de categoría que sea symlink. En Windows, las carpetas de categoría que sean junctions NTFS también se rechazan: `is_dir()` sigue un junction, por lo que aceptarlo podría redirigir un movimiento planificado fuera del workspace." + if text.count(wrong) != 1: + raise SystemExit("expected one Spanish helper anchor") + path.write_text(text.replace(wrong, current, 1), encoding="utf-8") + PY - name: Apply full-review patch run: python scripts/_apply_file_organizer_full_review.py - name: Validate focused patch From f70dd875abc7744f9cff88fd1a563d8f5c3d304a Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Tue, 1 Sep 2026 17:13:24 -0300 Subject: [PATCH 073/117] Align race tests with pinned source workflow --- .../apply-file-organizer-full-review.yml | 130 ++++++++++++++++++ 1 file changed, 130 insertions(+) diff --git a/.github/workflows/apply-file-organizer-full-review.yml b/.github/workflows/apply-file-organizer-full-review.yml index 42c716a..1bec8d3 100644 --- a/.github/workflows/apply-file-organizer-full-review.yml +++ b/.github/workflows/apply-file-organizer-full-review.yml @@ -41,9 +41,139 @@ jobs: PY - name: Apply full-review patch run: python scripts/_apply_file_organizer_full_review.py + - name: Align race tests with new claim boundary + shell: bash + run: | + python - <<'PY' + from pathlib import Path + path = Path("practical-projects/06-file-organizer/tests/test_atomic_move.py") + text = path.read_text(encoding="utf-8") + + old = ''' with pytest.raises(FileNotFoundError, match="regular file|changed during execution"): + execute_plan(plan) + + assert source.is_symlink() + assert outside.read_text(encoding="utf-8") == "target data" + assert not destination.exists() + ''' + new = ''' with pytest.raises(FileNotFoundError, match="planned source data retained"): + execute_plan(plan) + + assert source.is_symlink() + assert outside.read_text(encoding="utf-8") == "target data" + assert not destination.exists() + recovery_files = [ + child for child in tmp_path.iterdir() if child.name.startswith(".fo-recovery-") + ] + assert len(recovery_files) == 1 + assert recovery_files[0].read_text(encoding="utf-8") == "planned source" + ''' + if text.count(old) != 1: + raise SystemExit("source-symlink race anchor mismatch") + text = text.replace(old, new, 1) + + start = text.index(' original_rename = os.rename\n', text.index('def test_source_replacement_during_claim_is_preserved_without_unlink')) + end_marker = ' assert retained[0].read_text(encoding="utf-8") == "third-party replacement"\n' + end = text.index(end_marker, start) + len(end_marker) + replacement = ''' original_rename_no_replace = file_organizer._rename_no_replace_at + raced = False + + def racing_rename_no_replace( + source_name: str, + destination_name: str, + *, + source_directory_fd: int, + destination_directory_fd: int, + ) -> None: + nonlocal raced + if ( + source_name == source.name + and source_directory_fd == destination_directory_fd + and not raced + ): + raced = True + source.unlink() + source.write_text("third-party replacement", encoding="utf-8") + original_rename_no_replace( + source_name, + destination_name, + source_directory_fd=source_directory_fd, + destination_directory_fd=destination_directory_fd, + ) + + monkeypatch.setattr(file_organizer, "_supports_secure_directory_fds", lambda: True) + monkeypatch.setattr( + file_organizer, + "_rename_no_replace_at", + racing_rename_no_replace, + ) + + with pytest.raises(FileNotFoundError, match="planned source data retained"): + execute_plan(plan) + + assert source.read_text(encoding="utf-8") == "third-party replacement" + assert not destination.exists() + recovery_files = [ + child for child in tmp_path.iterdir() if child.name.startswith(".fo-recovery-") + ] + assert len(recovery_files) == 1 + assert recovery_files[0].read_text(encoding="utf-8") == "planned source" + retained = [ + child for child in tmp_path.iterdir() if child.name.startswith(".fo-stage-") + ] + assert len(retained) == 1 + assert retained[0].read_text(encoding="utf-8") == "third-party replacement" + ''' + # The triple-quoted block above is indented by the workflow Python itself. + replacement = '\n'.join(line[4:] if line.startswith(' ') else line for line in replacement.splitlines()) + '\n' + text = text[:start] + replacement + text[end:] + + old = ''' if not raced: + raced = True + category.rename(detached) + category.mkdir() + ''' + new = ''' if source_directory_fd != destination_directory_fd and not raced: + raced = True + category.rename(detached) + category.mkdir() + ''' + if text.count(old) != 1: + raise SystemExit("category final-commit race anchor mismatch") + text = text.replace(old, new, 1) + + old = ''' if not raced: + raced = True + workspace.rename(detached) + ''' + new = ''' if source_directory_fd != destination_directory_fd and not raced: + raced = True + workspace.rename(detached) + ''' + if text.count(old) != 1: + raise SystemExit("root final-commit race anchor mismatch") + text = text.replace(old, new, 1) + + old = ''' if not raced: + raced = True + stage = tmp_path / source_name + assert stage.name.startswith(".fo-stage-") + ''' + new = ''' if source_name.startswith(".fo-stage-") and not raced: + raced = True + stage = tmp_path / source_name + assert stage.name.startswith(".fo-stage-") + ''' + if text.count(old) != 1: + raise SystemExit("staging replacement race anchor mismatch") + text = text.replace(old, new, 1) + + path.write_text(text, encoding="utf-8") + PY - name: Validate focused patch run: | python -m py_compile practical-projects/06-file-organizer/file_organizer.py + python -m py_compile practical-projects/06-file-organizer/tests/test_atomic_move.py python -m pytest -q practical-projects/06-file-organizer/tests git diff --check - name: Commit functional changes From c3a7847b4c89cb5ebb37eaaae63c67bb9e8a662b Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Tue, 1 Sep 2026 17:14:26 -0300 Subject: [PATCH 074/117] Make race-test alignment function-scoped --- .../apply-file-organizer-full-review.yml | 133 +++++++++--------- 1 file changed, 67 insertions(+), 66 deletions(-) diff --git a/.github/workflows/apply-file-organizer-full-review.yml b/.github/workflows/apply-file-organizer-full-review.yml index 1bec8d3..15f8ad3 100644 --- a/.github/workflows/apply-file-organizer-full-review.yml +++ b/.github/workflows/apply-file-organizer-full-review.yml @@ -49,33 +49,35 @@ jobs: path = Path("practical-projects/06-file-organizer/tests/test_atomic_move.py") text = path.read_text(encoding="utf-8") - old = ''' with pytest.raises(FileNotFoundError, match="regular file|changed during execution"): - execute_plan(plan) + def rewrite_function(name: str, transform): + global text + start = text.index(f"def {name}(") + next_start = text.find("\ndef ", start + 1) + end = len(text) if next_start == -1 else next_start + 1 + block = text[start:end] + updated = transform(block) + if updated == block: + raise SystemExit(f"no changes made in {name}") + text = text[:start] + updated + text[end:] - assert source.is_symlink() - assert outside.read_text(encoding="utf-8") == "target data" - assert not destination.exists() - ''' - new = ''' with pytest.raises(FileNotFoundError, match="planned source data retained"): - execute_plan(plan) - - assert source.is_symlink() - assert outside.read_text(encoding="utf-8") == "target data" - assert not destination.exists() - recovery_files = [ - child for child in tmp_path.iterdir() if child.name.startswith(".fo-recovery-") - ] - assert len(recovery_files) == 1 - assert recovery_files[0].read_text(encoding="utf-8") == "planned source" - ''' - if text.count(old) != 1: - raise SystemExit("source-symlink race anchor mismatch") - text = text.replace(old, new, 1) - - start = text.index(' original_rename = os.rename\n', text.index('def test_source_replacement_during_claim_is_preserved_without_unlink')) - end_marker = ' assert retained[0].read_text(encoding="utf-8") == "third-party replacement"\n' - end = text.index(end_marker, start) + len(end_marker) - replacement = ''' original_rename_no_replace = file_organizer._rename_no_replace_at + def source_symlink(block: str) -> str: + block = block.replace( + 'with pytest.raises(FileNotFoundError, match="regular file|changed during execution"):', + 'with pytest.raises(FileNotFoundError, match="planned source data retained"):', + 1, + ) + return block + + rewrite_function( + "test_execute_plan_rejects_source_symlink_replacement_during_mutation", + source_symlink, + ) + + def source_claim(block: str) -> str: + start = block.index(" original_rename = os.rename\n") + end_marker = ' assert retained[0].read_text(encoding="utf-8") == "third-party replacement"\n' + end = block.index(end_marker, start) + len(end_marker) + replacement = ''' original_rename_no_replace = file_organizer._rename_no_replace_at raced = False def racing_rename_no_replace( @@ -124,49 +126,48 @@ jobs: assert len(retained) == 1 assert retained[0].read_text(encoding="utf-8") == "third-party replacement" ''' - # The triple-quoted block above is indented by the workflow Python itself. - replacement = '\n'.join(line[4:] if line.startswith(' ') else line for line in replacement.splitlines()) + '\n' - text = text[:start] + replacement + text[end:] + return block[:start] + replacement + block[end:] - old = ''' if not raced: - raced = True - category.rename(detached) - category.mkdir() - ''' - new = ''' if source_directory_fd != destination_directory_fd and not raced: - raced = True - category.rename(detached) - category.mkdir() - ''' - if text.count(old) != 1: - raise SystemExit("category final-commit race anchor mismatch") - text = text.replace(old, new, 1) + rewrite_function( + "test_source_replacement_during_claim_is_preserved_without_unlink", + source_claim, + ) - old = ''' if not raced: - raced = True - workspace.rename(detached) - ''' - new = ''' if source_directory_fd != destination_directory_fd and not raced: - raced = True - workspace.rename(detached) - ''' - if text.count(old) != 1: - raise SystemExit("root final-commit race anchor mismatch") - text = text.replace(old, new, 1) + def category_race(block: str) -> str: + return block.replace( + " if not raced:\n raced = True\n category.rename(detached)", + " if source_directory_fd != destination_directory_fd and not raced:\n raced = True\n category.rename(detached)", + 1, + ) - old = ''' if not raced: - raced = True - stage = tmp_path / source_name - assert stage.name.startswith(".fo-stage-") - ''' - new = ''' if source_name.startswith(".fo-stage-") and not raced: - raced = True - stage = tmp_path / source_name - assert stage.name.startswith(".fo-stage-") - ''' - if text.count(old) != 1: - raise SystemExit("staging replacement race anchor mismatch") - text = text.replace(old, new, 1) + rewrite_function( + "test_category_rename_after_fd_open_never_reports_false_destination", + category_race, + ) + + def root_race(block: str) -> str: + return block.replace( + " if not raced:\n raced = True\n workspace.rename(detached)", + " if source_directory_fd != destination_directory_fd and not raced:\n raced = True\n workspace.rename(detached)", + 1, + ) + + rewrite_function( + "test_source_root_rename_after_fd_open_never_reports_false_destination", + root_race, + ) + + def staging_race(block: str) -> str: + return block.replace( + " if not raced:\n raced = True\n stage = tmp_path / source_name", + " if source_name.startswith(\".fo-stage-\") and not raced:\n raced = True\n stage = tmp_path / source_name", + 1, + ) + + rewrite_function( + "test_staging_replacement_before_final_rename_preserves_pinned_source_data", + staging_race, + ) path.write_text(text, encoding="utf-8") PY From 1d44c8490ec7be92f2cd52c202b9f8fef7cef464 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 20:14:47 +0000 Subject: [PATCH 075/117] Pin File Organizer sources before identity acceptance --- .../06-file-organizer/README.es.md | 35 +-- .../06-file-organizer/README.md | 35 +-- .../06-file-organizer/README.pt-BR.md | 35 +-- .../06-file-organizer/file_organizer.py | 251 ++++++++++-------- .../tests/test_atomic_move.py | 96 +++++-- .../tests/test_file_organizer.py | 41 +++ scripts/_apply_file_organizer_full_review.py | 216 --------------- 7 files changed, 313 insertions(+), 396 deletions(-) delete mode 100644 scripts/_apply_file_organizer_full_review.py diff --git a/practical-projects/06-file-organizer/README.es.md b/practical-projects/06-file-organizer/README.es.md index 2c9a976..da2703f 100644 --- a/practical-projects/06-file-organizer/README.es.md +++ b/practical-projects/06-file-organizer/README.es.md @@ -202,7 +202,7 @@ Hay una frontera importante: en un filesystem case-sensitive, la primitiva del k ## Fronteras de symlink y anclaje de directorios -El organizador no sigue symlinks hijos directos. También rechaza un directorio de origen o carpeta de categoría que sea symlink. En Windows, las carpetas de categoría que sean junctions NTFS también se rechazan: `is_dir()` sigue un junction, por lo que aceptarlo podría redirigir un movimiento planificado fuera del workspace. +El organizador no sigue symlinks hijos directos. También rechaza directorio de origen o carpeta de categoría que sea symlink. En Windows, tanto el directorio de origen como las carpetas de categoría se rechazan cuando son junctions NTFS: `is_dir()` sigue un junction, por lo que aceptarlo podría redirigir el descubrimiento o un movimiento fuera del workspace. En la ruta segura de Linux, la raíz y las categorías necesarias se abren con `O_DIRECTORY | O_NOFOLLOW`. Sus identidades `(device, inode)` se comparan repetidamente con las rutas que todavía deberían alcanzarlas. @@ -231,7 +231,7 @@ La implementación representa identidad con: El nombre `notes.txt` es una entrada de directorio, no la identidad del objeto del filesystem. -Durante la ejecución segura en Linux, el origen planificado se abre con `O_NOFOLLOW | O_NONBLOCK` cuando `O_NONBLOCK` está disponible. La flag nonblocking impide que una sustitución tardía por FIFO bloquee `open()`, mientras el `fstat()` posterior sigue exigiendo un archivo regular con la identidad `(device, inode)` planificada. El descriptor abierto fija el inode esperado durante el commit. +Durante la ejecución segura en Linux, la identidad del origen se acepta **solo después de abrir el archivo** con `O_NOFOLLOW | O_NONBLOCK` cuando `O_NONBLOCK` está disponible. El `fstat()` deriva `(device, inode)` de ese descriptor ya abierto, y todos los descriptores de los orígenes planificados permanecen abiertos hasta que termina el plan. Así, un inode aceptado y luego desvinculado no puede liberarse y reutilizarse de inmediato mientras la ejecución todavía depende de su identidad. La flag nonblocking también evita que una sustitución tardía por FIFO bloquee `open()`. El pinning estabiliza la identidad del objeto, no su contenido; las escrituras concurrentes sobre el mismo inode quedan fuera de las garantías de snapshot de este proyecto. ## Nombres de staging de longitud fija @@ -250,16 +250,17 @@ La ruta segura de Linux usa `renameat2(..., RENAME_NOREPLACE)` mediante file des Conceptualmente: ```text -1. ejecutar preflight y capturar identidad del origen +1. validar rutas y ejecutar el preflight de colisiones 2. abrir y anclar la raíz -3. abrir y anclar las categorías necesarias -4. fijar el inode del origen con O_NOFOLLOW | O_NONBLOCK -5. reclamar atómicamente origen -> staging corto -6. verificar identidad del staging y anclajes -7. escanear de nuevo la categoría anclada buscando un destino equivalente por casefold -8. renombrar atómicamente staging -> destino exacto con RENAME_NOREPLACE -9. verificar identidad del destino y anclajes -10. informar éxito +3. abrir todos los orígenes planificados y aceptar identidad mediante `fstat()` del descriptor fijado +4. mantener abiertos todos los descriptores aceptados hasta que termine el plan +5. abrir y anclar las categorías necesarias +6. reclamar origen -> staging corto con semántica no-replace +7. verificar identidad del staging y anclajes +8. escanear de nuevo la categoría anclada buscando un destino equivalente por casefold +9. renombrar atómicamente staging -> destino exacto con RENAME_NOREPLACE +10. verificar identidad del destino y anclajes +11. informar éxito ``` `RENAME_NOREPLACE` convierte la existencia del **nombre exacto del destino** en parte de la propia operación atómica. No existe una comprobación `exists()` separada seguida de un rename que pueda reemplazar. El escaneo `casefold()` previo detecta colisiones lógicas visibles en esa frontera, pero se documenta como una nueva comprobación y no como un lock atómico case-insensitive. @@ -276,7 +277,7 @@ Un pathname de staging no funciona como lock de inode. Si el rename final consum Por ello, la ejecución segura en Linux exige deliberadamente permiso de lectura para cada archivo regular planificado. La legibilidad se valida antes de crear los directorios de categoría y de nuevo al fijar el inode del origen para la mutación; los fallos de permisos se informan como `PermissionError`, no como un falso cambio de identidad del origen. -En escenarios raros de carrera/fallo, esto puede dejar una entrada interna de recuperación. Es preferible a borrar datos cuya identidad actual no puede demostrarse. +En escenarios raros de carrera/fallo, esto puede dejar una entrada interna de recuperación. Los prefijos `.fo-stage-*` y `.fo-recovery-*` son namespaces internos reservados y quedan fuera de descubrimientos futuros para que la evidencia de recuperación no se reorganice por accidente. Es preferible a borrar o reclasificar datos cuya identidad actual no puede demostrarse. El plan completo de varios archivos no es transaccional. @@ -285,7 +286,7 @@ El plan completo de varios archivos no es transaccional. La implementación hace explícitas las garantías por plataforma: - **Linux:** ejecución segura con FDs anclados usa `renameat2(RENAME_NOREPLACE)` cuando está disponible, con protección atómica no-replace para el nombre exacto del destino y nuevas comprobaciones `casefold()` durante la mutación; -- **Windows:** el fallback usa el comportamiento de `os.rename()` que rechaza un destino existente y realiza una nueva comprobación `casefold()` best-effort junto con validaciones de identidad alrededor de la operación; +- **Windows:** la ruta portátil protegida usa `os.rename()` rechazando un destino existente y realiza comprobaciones best-effort de `casefold()`, redirección e identidad. **No** afirma tener la misma resistencia a carreras adversariales basada en descriptores fijados que la ruta Linux; - **otros POSIX:** la ejecución genera `NotImplementedError` cuando no puede aplicar de forma segura la semántica no-replace requerida. Un ejemplo orientado a seguridad debe fallar honestamente en vez de degradar su contrato de forma silenciosa. @@ -297,11 +298,11 @@ Un ejemplo orientado a seguridad debe fallar honestamente en vez de degradar su 1. validación del tipo del plan; 2. revalidación del directorio de origen; 3. revalidación de rutas de categoría; -4. captura de identidades de los orígenes planificados; -5. preflight de colisiones; -6. selección de capacidades de plataforma; +4. preflight de colisiones; +5. selección de capacidades de plataforma; +6. Linux: fijar todos los orígenes antes de aceptar identidad y antes de mutar categorías; 7. preparación de directorios anclados; -8. pinning nonblocking y claim del origen; +8. claim del origen; 9. nueva comprobación de colisión por `casefold()` durante la mutación; 10. commit atómico no-replace del nombre exacto; 11. verificación de destino y anclajes; diff --git a/practical-projects/06-file-organizer/README.md b/practical-projects/06-file-organizer/README.md index b87e225..a4b36d5 100644 --- a/practical-projects/06-file-organizer/README.md +++ b/practical-projects/06-file-organizer/README.md @@ -202,7 +202,7 @@ There is an important boundary: on a case-sensitive filesystem, the kernel primi ## Symlink and directory-anchor boundaries -The organizer does not follow direct-child symlinks. It also rejects a source directory or category folder that is a symlink. On Windows, category folders that are NTFS junctions are rejected too: `is_dir()` follows a junction, so accepting one could redirect a planned move outside the workspace. +The organizer does not follow direct-child symlinks. It rejects a source directory or category folder that is a symlink. On Windows, source directories and category folders that are NTFS junctions are rejected too: `is_dir()` follows a junction, so accepting one could redirect discovery or a planned move outside the workspace. On the secure Linux path, the source root and required category directories are opened with `O_DIRECTORY | O_NOFOLLOW`. Their `(device, inode)` identities are repeatedly compared with the paths that should still reach them. @@ -231,7 +231,7 @@ The implementation represents identity with: The filename `notes.txt` is a directory entry. It is not the identity of the underlying filesystem object. -During secure Linux execution, the planned source is opened with `O_NOFOLLOW | O_NONBLOCK` when `O_NONBLOCK` is available. The nonblocking flag prevents a late FIFO replacement from hanging `open()`, while the following `fstat()` still requires a regular file with the planned `(device, inode)` identity. The open descriptor pins the expected inode while the commit runs. +During secure Linux execution, source identity is accepted **only after the source has been opened** with `O_NOFOLLOW | O_NONBLOCK` when `O_NONBLOCK` is available. The following `fstat()` derives `(device, inode)` from that already-open descriptor, and every planned source descriptor stays open until the plan finishes. An accepted inode that is later unlinked therefore cannot be freed and immediately reused while execution still depends on its identity. The nonblocking flag also prevents a late FIFO replacement from hanging `open()`. Descriptor pinning stabilizes object identity, not file contents; concurrent writes to the same inode are outside this project's snapshot guarantees. ## Fixed-length staging names @@ -250,16 +250,17 @@ The secure Linux path uses `renameat2(..., RENAME_NOREPLACE)` through pinned dir Conceptually: ```text -1. preflight and capture source identity +1. validate paths and collision preflight 2. open and anchor the source root -3. open and anchor required category directories -4. pin the planned source inode with O_NOFOLLOW | O_NONBLOCK -5. atomically claim source name -> short internal stage -6. verify stage identity and directory anchors -7. rescan the pinned category for a casefold-equivalent destination -8. atomically rename stage -> exact destination with RENAME_NOREPLACE -9. verify destination identity and anchors -10. report success +3. open every planned source and accept identity from `fstat()` on that pinned descriptor +4. keep all accepted source descriptors open through plan completion +5. open and anchor required category directories +6. claim source name -> short internal stage with no-replace semantics +7. verify stage identity and directory anchors +8. rescan the pinned category for a casefold-equivalent destination +9. atomically rename stage -> exact destination with RENAME_NOREPLACE +10. verify destination identity and anchors +11. report success ``` `RENAME_NOREPLACE` makes **exact destination-name** existence part of the atomic filesystem operation. There is no separate `exists()` check followed by a replacing rename. The preceding casefold scan catches logical collisions visible at that boundary, but it is intentionally documented as a recheck rather than an atomic case-insensitive lock. @@ -276,7 +277,7 @@ A staging pathname is not an inode lock. If the final rename consumes a replacem Safe Linux execution therefore deliberately requires read access to each planned regular file. Readability is validated before category directories are created and again when the source inode is pinned for mutation; permission failures are reported as `PermissionError`, not as a false source-identity change. -This can intentionally leave an internal recovery entry in unusual race/failure scenarios. That is preferable to deleting unrelated data whose current identity cannot be proven. +This can intentionally leave an internal recovery entry in unusual race/failure scenarios. The `.fo-stage-*` and `.fo-recovery-*` prefixes are reserved internal namespaces and are excluded from later discovery so recovery evidence is not accidentally reorganized. That is preferable to deleting or reclassifying uncertain data whose current identity cannot be proven. The whole multi-file plan is not transactional. @@ -285,7 +286,7 @@ The whole multi-file plan is not transactional. The implementation is explicit about platform guarantees: - **Linux:** secure descriptor-anchored execution uses `renameat2(RENAME_NOREPLACE)` when available, with atomic no-replace protection for the exact destination name and mutation-time casefold rechecks; -- **Windows:** the fallback relies on Windows `os.rename()` refusing an existing destination and performs a best-effort casefold recheck plus source/destination/category identity validation around the operation; +- **Windows:** the guarded portable path relies on Windows `os.rename()` refusing an existing destination and performs best-effort casefold, redirect, and identity checks. It does **not** claim the descriptor-pinned adversarial race resistance of the Linux path; - **other POSIX platforms:** execution raises `NotImplementedError` when the project cannot enforce the required no-replace semantics safely. A safety-oriented example should fail honestly instead of silently downgrading its contract. @@ -297,11 +298,11 @@ A safety-oriented example should fail honestly instead of silently downgrading i 1. plan type validation; 2. source-directory revalidation; 3. category-path revalidation; -4. planned-source identity capture; -5. destination collision preflight; -6. platform capability selection; +4. destination collision preflight; +5. platform capability selection; +6. Linux: pin every planned source before accepting identity and before category mutation; 7. anchored directory setup; -8. nonblocking source pin and source claim; +8. source claim; 9. mutation-time casefold collision recheck; 10. atomic exact-name no-replace commit; 11. destination/anchor verification; diff --git a/practical-projects/06-file-organizer/README.pt-BR.md b/practical-projects/06-file-organizer/README.pt-BR.md index 1204cb3..336a642 100644 --- a/practical-projects/06-file-organizer/README.pt-BR.md +++ b/practical-projects/06-file-organizer/README.pt-BR.md @@ -202,7 +202,7 @@ Há uma fronteira importante: em um filesystem case-sensitive, a primitiva do ke ## Fronteiras de symlink e ancoragem de diretórios -O organizador não segue symlinks filhos diretos. Também rejeita diretório de origem ou pasta de categoria que seja symlink. No Windows, pastas de categoria que sejam junctions NTFS também são rejeitadas: `is_dir()` segue um junction, então aceitá-lo poderia redirecionar uma movimentação planejada para fora do workspace. +O organizador não segue symlinks filhos diretos. Também rejeita diretório de origem ou pasta de categoria que seja symlink. No Windows, tanto o diretório de origem quanto as pastas de categoria são rejeitados quando são junctions NTFS: `is_dir()` segue um junction, então aceitá-lo poderia redirecionar descoberta ou movimentação para fora do workspace. No caminho seguro do Linux, a raiz e as categorias necessárias são abertas com `O_DIRECTORY | O_NOFOLLOW`. Suas identidades `(device, inode)` são comparadas repetidamente com os caminhos que ainda deveriam alcançá-las. @@ -231,7 +231,7 @@ A implementação representa identidade com: O nome `notes.txt` é uma entrada de diretório, não a identidade do objeto do filesystem. -Durante a execução segura no Linux, a origem planejada é aberta com `O_NOFOLLOW | O_NONBLOCK` quando `O_NONBLOCK` está disponível. A flag nonblocking impede que uma substituição tardia por FIFO trave o `open()`, enquanto o `fstat()` seguinte ainda exige um arquivo regular com a identidade `(device, inode)` planejada. O descriptor aberto fixa o inode esperado durante o commit. +Durante a execução segura no Linux, a identidade da origem só é aceita **depois que o arquivo já foi aberto** com `O_NOFOLLOW | O_NONBLOCK` quando `O_NONBLOCK` está disponível. O `fstat()` deriva `(device, inode)` desse descriptor já aberto, e todos os descriptors das origens planejadas permanecem abertos até o fim do plano. Assim, um inode aceito e depois desvinculado não pode ser liberado e imediatamente reutilizado enquanto a execução ainda depende da sua identidade. A flag nonblocking também impede que uma substituição tardia por FIFO trave o `open()`. O pinning estabiliza a identidade do objeto, não o conteúdo; escritas concorrentes no mesmo inode ficam fora das garantias de snapshot deste projeto. ## Nomes de staging com tamanho fixo @@ -250,16 +250,17 @@ O caminho seguro do Linux usa `renameat2(..., RENAME_NOREPLACE)` por meio de fil Conceitualmente: ```text -1. executar preflight e capturar identidade da origem +1. validar caminhos e executar o preflight de colisões 2. abrir e ancorar a raiz -3. abrir e ancorar as categorias necessárias -4. fixar o inode da origem com O_NOFOLLOW | O_NONBLOCK -5. reivindicar atomicamente origem -> staging curto -6. verificar identidade do staging e âncoras -7. varrer novamente a categoria ancorada por destino equivalente via casefold -8. renomear atomicamente staging -> destino exato com RENAME_NOREPLACE -9. verificar identidade do destino e âncoras -10. reportar sucesso +3. abrir todas as origens planejadas e aceitar identidade pelo `fstat()` do descriptor pinado +4. manter todos os descriptors aceitos abertos até o fim do plano +5. abrir e ancorar as categorias necessárias +6. reivindicar origem -> staging curto com semântica no-replace +7. verificar identidade do staging e âncoras +8. varrer novamente a categoria ancorada por destino equivalente via casefold +9. renomear atomicamente staging -> destino exato com RENAME_NOREPLACE +10. verificar identidade do destino e âncoras +11. reportar sucesso ``` `RENAME_NOREPLACE` transforma a existência do **nome exato do destino** em parte da própria operação atômica. Não existe uma checagem `exists()` separada seguida de rename substitutivo. A varredura `casefold()` anterior captura colisões lógicas visíveis nessa fronteira, mas é documentada como rechecagem, não como lock atômico case-insensitive. @@ -276,7 +277,7 @@ Um pathname de staging não funciona como lock de inode. Se o rename final consu Por isso, a execução segura no Linux exige deliberadamente permissão de leitura para cada arquivo regular planejado. A legibilidade é validada antes da criação das pastas de categoria e novamente ao pinar o inode da origem para a mutação; falhas de permissão são reportadas como `PermissionError`, e não como uma falsa mudança de identidade da origem. -Em cenários raros de corrida/falha, isso pode deixar uma entrada interna de recuperação. É preferível a excluir dados cuja identidade atual não pode ser comprovada. +Em cenários raros de corrida/falha, isso pode deixar uma entrada interna de recuperação. Os prefixos `.fo-stage-*` e `.fo-recovery-*` são namespaces internos reservados e ficam fora de descobertas futuras, evitando que evidências de recuperação sejam reorganizadas por acidente. É preferível a excluir ou reclassificar dados cuja identidade atual não pode ser comprovada. O plano inteiro de múltiplos arquivos não é transacional. @@ -285,7 +286,7 @@ O plano inteiro de múltiplos arquivos não é transacional. A implementação explicita as garantias por plataforma: - **Linux:** execução segura com FDs ancorados usa `renameat2(RENAME_NOREPLACE)` quando disponível, com proteção atômica no-replace para o nome exato do destino e rechecagens `casefold()` na mutação; -- **Windows:** o fallback usa o comportamento de `os.rename()` que recusa destino existente e executa uma rechecagem `casefold()` best-effort mais validações de identidade ao redor da operação; +- **Windows:** o caminho portátil protegido usa `os.rename()` recusando destino existente e realiza checagens best-effort de `casefold()`, redirecionamento e identidade. Ele **não** afirma possuir a mesma resistência a corridas adversariais baseada em descriptors pinados do caminho Linux; - **outros POSIX:** a execução gera `NotImplementedError` quando não consegue aplicar a semântica no-replace exigida com segurança. Um exemplo orientado a segurança deve falhar de forma honesta em vez de reduzir silenciosamente seu contrato. @@ -297,11 +298,11 @@ Um exemplo orientado a segurança deve falhar de forma honesta em vez de reduzir 1. validação do tipo do plano; 2. revalidação do diretório de origem; 3. revalidação dos caminhos de categoria; -4. captura das identidades das origens planejadas; -5. preflight de colisões; -6. seleção da capacidade da plataforma; +4. preflight de colisões; +5. seleção da capacidade da plataforma; +6. Linux: pinning de todas as origens antes de aceitar identidade e antes de mutar categorias; 7. preparação dos diretórios ancorados; -8. pinning nonblocking e claim da origem; +8. claim da origem; 9. rechecagem de colisão por `casefold()` na mutação; 10. commit atômico no-replace do nome exato; 11. verificação do destino e das âncoras; diff --git a/practical-projects/06-file-organizer/file_organizer.py b/practical-projects/06-file-organizer/file_organizer.py index 2411def..af2e0d1 100644 --- a/practical-projects/06-file-organizer/file_organizer.py +++ b/practical-projects/06-file-organizer/file_organizer.py @@ -36,7 +36,7 @@ class CollisionPolicy(str, Enum): _ARCHIVE_SUFFIXES = frozenset({".zip", ".tar", ".gz", ".bz2", ".xz", ".7z"}) _COMPOUND_ARCHIVE_SUFFIXES = (".tar.gz", ".tar.bz2", ".tar.xz") _RENAME_NOREPLACE = 1 -_AT_FDCWD = -100 +_INTERNAL_PREFIXES = (".fo-stage-", ".fo-recovery-") def _load_renameat2() -> Callable[..., int] | None: @@ -65,12 +65,20 @@ def _load_renameat2() -> Callable[..., int] | None: @dataclass(frozen=True, slots=True) class _FileIdentity: - """Stable filesystem identity captured before mutation.""" + """Stable filesystem identity captured while an object is pinned.""" device: int inode: int +@dataclass(frozen=True, slots=True) +class _PinnedSource: + """Descriptor and identity accepted together for one planned source.""" + + fd: int + identity: _FileIdentity + + def _coerce_path(value: str | PathLike[str], field_name: str) -> Path: if isinstance(value, bool) or not isinstance(value, (str, PathLike)): raise TypeError(f"{field_name} must be a path-like value") @@ -82,8 +90,9 @@ def _coerce_path(value: str | PathLike[str], field_name: str) -> Path: def _require_source_directory(value: str | PathLike[str]) -> Path: path = _coerce_path(value, "source_directory") - if path.is_symlink(): - raise ValueError("source_directory cannot be a symlink") + is_junction = getattr(path, "is_junction", None) + if path.is_symlink() or bool(is_junction is not None and is_junction()): + raise ValueError("source_directory cannot be a symlink or junction") if not path.exists(): raise FileNotFoundError(f"source_directory does not exist: {path}") if not path.is_dir(): @@ -290,6 +299,8 @@ def _scan_source_directory( symlinks: list[Path] = [] for child in sorted(source_directory.iterdir(), key=_path_sort_key): + if child.name.startswith(_INTERNAL_PREFIXES): + continue if child.is_symlink(): symlinks.append(child.absolute()) elif child.is_file(): @@ -387,17 +398,13 @@ def plan_organization( ) -def _preflight_execution(plan: OrganizationPlan) -> dict[Path, _FileIdentity]: +def _preflight_execution(plan: OrganizationPlan) -> None: root = _require_source_directory(plan.source_directory) if root != plan.source_directory: raise ValueError("source_directory no longer resolves to the planned directory") _validate_category_locations(root) - source_identities = { - action.source: _capture_path_identity(action.source) for action in plan.actions - } - for action in plan.actions: target_directory = action.destination.parent if target_directory.exists(): @@ -411,7 +418,14 @@ def _preflight_execution(plan: OrganizationPlan) -> dict[Path, _FileIdentity]: f"destination appeared after planning: {action.destination.name}" ) - return source_identities + +def _capture_portable_source_identities( + plan: OrganizationPlan, +) -> dict[Path, _FileIdentity]: + """Capture best-effort pathname identities for the guarded Windows path.""" + return { + action.source: _capture_path_identity(action.source) for action in plan.actions + } def _supports_secure_directory_fds() -> bool: @@ -507,29 +521,12 @@ def _regular_identity_at(filename: str, *, directory_fd: int) -> _FileIdentity: return _identity_from_regular_stat(stat_result, filename=filename) -def _verify_source_identity_at( - source_name: str, - *, - source_directory_fd: int, - expected_identity: _FileIdentity, -) -> None: - current_identity = _regular_identity_at( - source_name, - directory_fd=source_directory_fd, - ) - if current_identity != expected_identity: - raise FileNotFoundError( - f"planned source changed during execution: {source_name}" - ) - - def _open_planned_source_fd_at( source_name: str, *, root_fd: int, - expected_identity: _FileIdentity, -) -> int: - """Pin the planned inode without blocking on a late special-file replacement.""" +) -> _PinnedSource: + """Open first, then accept identity from the descriptor pinning the inode.""" flags = os.O_RDONLY | os.O_NOFOLLOW if hasattr(os, "O_NONBLOCK"): flags |= os.O_NONBLOCK @@ -547,18 +544,34 @@ def _open_planned_source_fd_at( ) from exc try: - current_identity = _identity_from_regular_stat( + identity = _identity_from_regular_stat( os.fstat(source_fd), filename=source_name, ) - if current_identity != expected_identity: - raise FileNotFoundError( - f"planned source changed during execution: {source_name}" - ) except Exception: os.close(source_fd) raise - return source_fd + return _PinnedSource(fd=source_fd, identity=identity) + + +def _pin_planned_sources_at( + plan: OrganizationPlan, + *, + root_fd: int, +) -> dict[Path, _PinnedSource]: + """Pin every source before category creation or source mutation.""" + pinned: dict[Path, _PinnedSource] = {} + try: + for action in plan.actions: + pinned[action.source] = _open_planned_source_fd_at( + action.source.name, + root_fd=root_fd, + ) + except Exception: + for source in pinned.values(): + os.close(source.fd) + raise + return pinned def _verify_destination_identity_at( @@ -738,13 +751,27 @@ def _claim_source_at( expected_identity: _FileIdentity, ) -> str: """Atomically detach the source name and verify the claimed regular file.""" - stage_name = _make_stage_name(source_name) - os.rename( - source_name, - stage_name, - src_dir_fd=root_fd, - dst_dir_fd=root_fd, - ) + stage_name = "" + for _ in range(16): + stage_name = _make_stage_name(source_name) + try: + _rename_no_replace_at( + source_name, + stage_name, + source_directory_fd=root_fd, + destination_directory_fd=root_fd, + ) + except FileExistsError: + continue + except FileNotFoundError as exc: + raise FileNotFoundError( + f"planned source changed during execution: {source_name}" + ) from exc + break + else: + raise FileExistsError( + f"could not allocate staging entry for planned source: {source_name}" + ) try: staged_identity = _regular_identity_at(stage_name, directory_fd=root_fd) @@ -802,20 +829,16 @@ def _move_file_no_replace_at( source_directory_fd: int, destination_directory_fd: int, category_name: str, + source_fd: int, expected_identity: _FileIdentity, ) -> None: - """Commit one move with anchored directories and no replace/unlink window.""" + """Commit one move using the source descriptor pinned before mutation.""" _verify_root_anchor_at(source_directory_path, source_directory_fd) _verify_category_anchor_at( root_fd=source_directory_fd, category_name=category_name, category_fd=destination_directory_fd, ) - source_fd = _open_planned_source_fd_at( - source_name, - root_fd=source_directory_fd, - expected_identity=expected_identity, - ) try: stage_name = _claim_source_at( @@ -823,60 +846,68 @@ def _move_file_no_replace_at( root_fd=source_directory_fd, expected_identity=expected_identity, ) + except FileNotFoundError as exc: + recovery_name = _recover_pinned_source_at( + source_fd, + source_name, + root_fd=source_directory_fd, + ) + raise FileNotFoundError( + "planned source changed after it was pinned; " + f"planned source data retained as {recovery_name}: {source_name}" + ) from exc - try: - _verify_root_anchor_at(source_directory_path, source_directory_fd) - _verify_category_anchor_at( - root_fd=source_directory_fd, - category_name=category_name, - category_fd=destination_directory_fd, - ) - staged_identity = _regular_identity_at( - stage_name, - directory_fd=source_directory_fd, - ) - if staged_identity != expected_identity: - raise FileNotFoundError( - f"planned source changed during execution: {source_name}" - ) - _verify_no_casefold_destination_collision_at( - destination_name, - destination_directory_fd=destination_directory_fd, - ) - _rename_no_replace_at( - stage_name, - destination_name, - source_directory_fd=source_directory_fd, - destination_directory_fd=destination_directory_fd, - ) - except (FileExistsError, FileNotFoundError, ValueError, OSError): - _preserve_stage_at(stage_name, source_name, root_fd=source_directory_fd) - raise - - try: - _verify_destination_identity_at( - destination_name, - destination_directory_fd=destination_directory_fd, - expected_identity=expected_identity, - ) - except RuntimeError as exc: - recovery_name = _recover_pinned_source_at( - source_fd, - source_name, - root_fd=source_directory_fd, - ) - raise RuntimeError( - "destination does not match planned source; " - f"planned source data retained as {recovery_name}: {destination_name}" - ) from exc + try: _verify_root_anchor_at(source_directory_path, source_directory_fd) _verify_category_anchor_at( root_fd=source_directory_fd, category_name=category_name, category_fd=destination_directory_fd, ) - finally: - os.close(source_fd) + staged_identity = _regular_identity_at( + stage_name, + directory_fd=source_directory_fd, + ) + if staged_identity != expected_identity: + raise FileNotFoundError( + f"planned source changed during execution: {source_name}" + ) + _verify_no_casefold_destination_collision_at( + destination_name, + destination_directory_fd=destination_directory_fd, + ) + _rename_no_replace_at( + stage_name, + destination_name, + source_directory_fd=source_directory_fd, + destination_directory_fd=destination_directory_fd, + ) + except (FileExistsError, FileNotFoundError, ValueError, OSError): + _preserve_stage_at(stage_name, source_name, root_fd=source_directory_fd) + raise + + try: + _verify_destination_identity_at( + destination_name, + destination_directory_fd=destination_directory_fd, + expected_identity=expected_identity, + ) + except RuntimeError as exc: + recovery_name = _recover_pinned_source_at( + source_fd, + source_name, + root_fd=source_directory_fd, + ) + raise RuntimeError( + "destination does not match planned source; " + f"planned source data retained as {recovery_name}: {destination_name}" + ) from exc + _verify_root_anchor_at(source_directory_path, source_directory_fd) + _verify_category_anchor_at( + root_fd=source_directory_fd, + category_name=category_name, + category_fd=destination_directory_fd, + ) def _rename_no_replace_path(source: Path, destination: Path) -> None: @@ -940,24 +971,18 @@ def _verify_destination_path_identity( def _execute_plan_with_directory_fds( plan: OrganizationPlan, - source_identities: dict[Path, _FileIdentity], ) -> OrganizationResult: - """Execute using pinned no-follow directory descriptors on Linux.""" + """Execute using sources and directories pinned before Linux mutation.""" root_fd = _open_source_directory_fd(plan.source_directory) + pinned_sources: dict[Path, _PinnedSource] = {} category_fds: dict[FileCategory, int] = {} try: _verify_root_anchor_at(plan.source_directory, root_fd) - # Readability is a deliberate secure-execution prerequisite because - # pinned-FD recovery must be able to persist the planned source bytes. - for action in plan.actions: - validation_fd = _open_planned_source_fd_at( - action.source.name, - root_fd=root_fd, - expected_identity=source_identities[action.source], - ) - os.close(validation_fd) + # Accept identity only from already-open descriptors. Keeping every + # descriptor alive prevents accepted inodes from being freed/reused. + pinned_sources = _pin_planned_sources_at(plan, root_fd=root_fd) for category in sorted( {action.category for action in plan.actions}, @@ -970,6 +995,7 @@ def _execute_plan_with_directory_fds( moved: list[Path] = [] for action in plan.actions: + pinned_source = pinned_sources[action.source] _move_file_no_replace_at( action.source.name, action.destination.name, @@ -977,7 +1003,8 @@ def _execute_plan_with_directory_fds( source_directory_fd=root_fd, destination_directory_fd=category_fds[action.category], category_name=action.category.value, - expected_identity=source_identities[action.source], + source_fd=pinned_source.fd, + expected_identity=pinned_source.identity, ) moved.append(action.destination) @@ -986,6 +1013,8 @@ def _execute_plan_with_directory_fds( finally: for directory_fd in category_fds.values(): os.close(directory_fd) + for source in pinned_sources.values(): + os.close(source.fd) os.close(root_fd) @@ -1026,14 +1055,16 @@ def _execute_plan_portable( def execute_plan(plan: OrganizationPlan) -> OrganizationResult: - """Execute a previously validated plan after a full collision preflight.""" + """Execute a plan under the strongest explicitly supported platform contract.""" if not isinstance(plan, OrganizationPlan): raise TypeError("plan must be an OrganizationPlan") - source_identities = _preflight_execution(plan) + _preflight_execution(plan) if not plan.actions: return OrganizationResult(plan=plan, moved_files=()) if _supports_secure_directory_fds(): - return _execute_plan_with_directory_fds(plan, source_identities) + return _execute_plan_with_directory_fds(plan) + + source_identities = _capture_portable_source_identities(plan) return _execute_plan_portable(plan, source_identities) diff --git a/practical-projects/06-file-organizer/tests/test_atomic_move.py b/practical-projects/06-file-organizer/tests/test_atomic_move.py index 85ec390..265fcb9 100644 --- a/practical-projects/06-file-organizer/tests/test_atomic_move.py +++ b/practical-projects/06-file-organizer/tests/test_atomic_move.py @@ -113,6 +113,7 @@ def racing_move( source_directory_fd: int, destination_directory_fd: int, category_name: str, + source_fd: int, expected_identity: file_organizer._FileIdentity, ) -> None: nonlocal raced @@ -127,13 +128,14 @@ def racing_move( source_directory_fd=source_directory_fd, destination_directory_fd=destination_directory_fd, category_name=category_name, + source_fd=source_fd, expected_identity=expected_identity, ) monkeypatch.setattr(file_organizer, "_supports_secure_directory_fds", lambda: True) monkeypatch.setattr(file_organizer, "_move_file_no_replace_at", racing_move) - with pytest.raises(FileNotFoundError, match="regular file|changed during execution"): + with pytest.raises(FileNotFoundError, match="planned source data retained"): execute_plan(plan) assert source.is_symlink() @@ -153,37 +155,52 @@ def test_source_replacement_during_claim_is_preserved_without_unlink( plan = plan_organization(tmp_path) destination = tmp_path / "documents" / "notes.txt" - original_rename = os.rename + original_rename_no_replace = file_organizer._rename_no_replace_at raced = False - def racing_rename( - source_path: str | os.PathLike[str], - destination_path: str | os.PathLike[str], + def racing_rename_no_replace( + source_name: str, + destination_name: str, *, - src_dir_fd: int | None = None, - dst_dir_fd: int | None = None, + source_directory_fd: int, + destination_directory_fd: int, ) -> None: nonlocal raced - if source_path == source.name and src_dir_fd is not None and not raced: + if ( + source_name == source.name + and source_directory_fd == destination_directory_fd + and not raced + ): raced = True source.unlink() source.write_text("third-party replacement", encoding="utf-8") - original_rename( - source_path, - destination_path, - src_dir_fd=src_dir_fd, - dst_dir_fd=dst_dir_fd, + original_rename_no_replace( + source_name, + destination_name, + source_directory_fd=source_directory_fd, + destination_directory_fd=destination_directory_fd, ) monkeypatch.setattr(file_organizer, "_supports_secure_directory_fds", lambda: True) - monkeypatch.setattr(file_organizer.os, "rename", racing_rename) + monkeypatch.setattr( + file_organizer, + "_rename_no_replace_at", + racing_rename_no_replace, + ) - with pytest.raises(FileNotFoundError, match="changed during execution"): + with pytest.raises(FileNotFoundError, match="planned source data retained"): execute_plan(plan) assert source.read_text(encoding="utf-8") == "third-party replacement" assert not destination.exists() - retained = [child for child in tmp_path.iterdir() if child.name.startswith(".fo-stage-")] + recovery_files = [ + child for child in tmp_path.iterdir() if child.name.startswith(".fo-recovery-") + ] + assert len(recovery_files) == 1 + assert recovery_files[0].read_text(encoding="utf-8") == "planned source" + retained = [ + child for child in tmp_path.iterdir() if child.name.startswith(".fo-stage-") + ] assert len(retained) == 1 assert retained[0].read_text(encoding="utf-8") == "third-party replacement" @@ -211,7 +228,7 @@ def racing_rename_no_replace( destination_directory_fd: int, ) -> None: nonlocal raced - if not raced: + if source_directory_fd != destination_directory_fd and not raced: raced = True category.rename(detached) category.mkdir() @@ -259,7 +276,7 @@ def racing_rename_no_replace( destination_directory_fd: int, ) -> None: nonlocal raced - if not raced: + if source_directory_fd != destination_directory_fd and not raced: raced = True workspace.rename(detached) original_rename_no_replace( @@ -428,7 +445,7 @@ def racing_rename_no_replace( destination_directory_fd: int, ) -> None: nonlocal raced - if not raced: + if source_name.startswith(".fo-stage-") and not raced: raced = True stage = tmp_path / source_name assert stage.name.startswith(".fo-stage-") @@ -540,3 +557,44 @@ def failing_anchor(**_: object) -> None: assert opened_category_fd is not None assert opened_category_fd in closed_fds + + +def test_source_identity_is_accepted_only_after_descriptor_pin( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + if not file_organizer._supports_secure_directory_fds(): + pytest.skip("secure directory descriptors are unavailable on this platform") + + source = tmp_path / "notes.txt" + source.write_text("planned source", encoding="utf-8") + plan = plan_organization(tmp_path) + destination = tmp_path / "documents" / "notes.txt" + original_pin = file_organizer._pin_planned_sources_at + raced = False + + def racing_pin( + plan_value: file_organizer.OrganizationPlan, + *, + root_fd: int, + ) -> dict[Path, file_organizer._PinnedSource]: + nonlocal raced + pinned = original_pin(plan_value, root_fd=root_fd) + if not raced: + raced = True + source.unlink() + source.write_text("third-party replacement", encoding="utf-8") + return pinned + + monkeypatch.setattr(file_organizer, "_pin_planned_sources_at", racing_pin) + + with pytest.raises(FileNotFoundError, match="planned source data retained"): + execute_plan(plan) + + assert source.read_text(encoding="utf-8") == "third-party replacement" + assert not destination.exists() + recovery_files = [ + child for child in tmp_path.iterdir() if child.name.startswith(".fo-recovery-") + ] + assert len(recovery_files) == 1 + assert recovery_files[0].read_text(encoding="utf-8") == "planned source" diff --git a/practical-projects/06-file-organizer/tests/test_file_organizer.py b/practical-projects/06-file-organizer/tests/test_file_organizer.py index 80455c7..3a8c813 100644 --- a/practical-projects/06-file-organizer/tests/test_file_organizer.py +++ b/practical-projects/06-file-organizer/tests/test_file_organizer.py @@ -1,3 +1,5 @@ +import os +import subprocess from pathlib import Path import pytest @@ -469,3 +471,42 @@ def test_execute_plan_keeps_existing_unrelated_category_files(tmp_path: Path) -> assert existing.read_text(encoding="utf-8") == "old" assert (documents / "new.txt").read_text(encoding="utf-8") == "new" + + +def test_internal_recovery_artifacts_are_reserved_from_future_plans(tmp_path: Path) -> None: + (tmp_path / ".fo-stage-deadbeef").write_text("stage", encoding="utf-8") + (tmp_path / ".fo-recovery-deadbeef").write_text("recovery", encoding="utf-8") + (tmp_path / "notes.txt").write_text("user", encoding="utf-8") + + plan = plan_organization(tmp_path) + + assert tuple(action.source.name for action in plan.actions) == ("notes.txt",) + + +@pytest.mark.skipif(os.name != "nt", reason="requires Windows NTFS junction semantics") +def test_windows_real_source_and_category_junctions_are_rejected(tmp_path: Path) -> None: + outside = tmp_path / "outside" + outside.mkdir() + + source_junction = tmp_path / "workspace-link" + subprocess.run( + ["cmd", "/c", "mklink", "/J", str(source_junction), str(outside)], + check=True, + capture_output=True, + text=True, + ) + with pytest.raises(ValueError, match="symlink or junction"): + plan_organization(source_junction) + + workspace = tmp_path / "workspace" + workspace.mkdir() + (workspace / "notes.txt").write_text("x", encoding="utf-8") + category_junction = workspace / "documents" + subprocess.run( + ["cmd", "/c", "mklink", "/J", str(category_junction), str(outside)], + check=True, + capture_output=True, + text=True, + ) + with pytest.raises(ValueError, match="symlink or junction"): + plan_organization(workspace) diff --git a/scripts/_apply_file_organizer_full_review.py b/scripts/_apply_file_organizer_full_review.py deleted file mode 100644 index 48336c3..0000000 --- a/scripts/_apply_file_organizer_full_review.py +++ /dev/null @@ -1,216 +0,0 @@ -from pathlib import Path - -ROOT = Path('.') -CODE = ROOT / 'practical-projects/06-file-organizer/file_organizer.py' -ATOMIC = ROOT / 'practical-projects/06-file-organizer/tests/test_atomic_move.py' -TESTS = ROOT / 'practical-projects/06-file-organizer/tests/test_file_organizer.py' -README_EN = ROOT / 'practical-projects/06-file-organizer/README.md' -README_PT = ROOT / 'practical-projects/06-file-organizer/README.pt-BR.md' -README_ES = ROOT / 'practical-projects/06-file-organizer/README.es.md' - - -def replace_once(path: Path, old: str, new: str) -> None: - text = path.read_text(encoding='utf-8') - count = text.count(old) - if count != 1: - raise RuntimeError(f'{path}: expected one anchor, found {count}') - path.write_text(text.replace(old, new, 1), encoding='utf-8') - - -def append_once(path: Path, marker: str, addition: str) -> None: - text = path.read_text(encoding='utf-8') - if marker in text: - raise RuntimeError(f'{path}: marker already present: {marker}') - path.write_text(text.rstrip() + '\n\n\n' + addition.strip() + '\n', encoding='utf-8') - - -# Core models and reserved internal namespace. -replace_once(CODE, '_RENAME_NOREPLACE = 1\n_AT_FDCWD = -100\n', '_RENAME_NOREPLACE = 1\n_INTERNAL_PREFIXES = (".fo-stage-", ".fo-recovery-")\n') -replace_once( - CODE, - '''@dataclass(frozen=True, slots=True)\nclass _FileIdentity:\n """Stable filesystem identity captured before mutation."""\n\n device: int\n inode: int\n''', - '''@dataclass(frozen=True, slots=True)\nclass _FileIdentity:\n """Stable filesystem identity captured while an object is pinned."""\n\n device: int\n inode: int\n\n\n@dataclass(frozen=True, slots=True)\nclass _PinnedSource:\n """Descriptor and identity accepted together for one planned source."""\n\n fd: int\n identity: _FileIdentity\n''', -) - -# Reject Windows junctions at the workspace root too. -replace_once( - CODE, - '''def _require_source_directory(value: str | PathLike[str]) -> Path:\n path = _coerce_path(value, "source_directory")\n if path.is_symlink():\n raise ValueError("source_directory cannot be a symlink")\n''', - '''def _require_source_directory(value: str | PathLike[str]) -> Path:\n path = _coerce_path(value, "source_directory")\n is_junction = getattr(path, "is_junction", None)\n if path.is_symlink() or bool(is_junction is not None and is_junction()):\n raise ValueError("source_directory cannot be a symlink or junction")\n''', -) - -# Never rediscover the organizer's own conservative recovery/staging artifacts. -replace_once( - CODE, - '''def _scan_source_directory(\n source_directory: Path,\n) -> tuple[tuple[Path, ...], tuple[Path, ...]]:\n files: list[Path] = []\n symlinks: list[Path] = []\n\n for child in sorted(source_directory.iterdir(), key=_path_sort_key):\n if child.is_symlink():\n symlinks.append(child.absolute())\n elif child.is_file():\n files.append(child.absolute())\n''', - '''def _scan_source_directory(\n source_directory: Path,\n) -> tuple[tuple[Path, ...], tuple[Path, ...]]:\n files: list[Path] = []\n symlinks: list[Path] = []\n\n for child in sorted(source_directory.iterdir(), key=_path_sort_key):\n if child.name.startswith(_INTERNAL_PREFIXES):\n continue\n if child.is_symlink():\n symlinks.append(child.absolute())\n elif child.is_file():\n files.append(child.absolute())\n''', -) - -# Preflight validates paths/collisions only. Linux accepts identity from open FDs; -# Windows keeps a documented best-effort pathname identity capture. -replace_once( - CODE, - '''def _preflight_execution(plan: OrganizationPlan) -> dict[Path, _FileIdentity]:\n root = _require_source_directory(plan.source_directory)\n if root != plan.source_directory:\n raise ValueError("source_directory no longer resolves to the planned directory")\n\n _validate_category_locations(root)\n\n source_identities = {\n action.source: _capture_path_identity(action.source) for action in plan.actions\n }\n\n for action in plan.actions:\n target_directory = action.destination.parent\n if target_directory.exists():\n current_names = _existing_names_casefold(target_directory)\n if action.destination.name.casefold() in current_names:\n raise FileExistsError(\n f"destination appeared after planning: {action.destination.name}"\n )\n elif action.destination.exists() or action.destination.is_symlink():\n raise FileExistsError(\n f"destination appeared after planning: {action.destination.name}"\n )\n\n return source_identities\n''', - '''def _preflight_execution(plan: OrganizationPlan) -> None:\n root = _require_source_directory(plan.source_directory)\n if root != plan.source_directory:\n raise ValueError("source_directory no longer resolves to the planned directory")\n\n _validate_category_locations(root)\n\n for action in plan.actions:\n target_directory = action.destination.parent\n if target_directory.exists():\n current_names = _existing_names_casefold(target_directory)\n if action.destination.name.casefold() in current_names:\n raise FileExistsError(\n f"destination appeared after planning: {action.destination.name}"\n )\n elif action.destination.exists() or action.destination.is_symlink():\n raise FileExistsError(\n f"destination appeared after planning: {action.destination.name}"\n )\n\n\ndef _capture_portable_source_identities(\n plan: OrganizationPlan,\n) -> dict[Path, _FileIdentity]:\n """Capture best-effort pathname identities for the guarded Windows path."""\n return {\n action.source: _capture_path_identity(action.source) for action in plan.actions\n }\n''', -) - -# Remove obsolete pathname verifier and accept identity only from the pinned FD. -replace_once( - CODE, - '''def _verify_source_identity_at(\n source_name: str,\n *,\n source_directory_fd: int,\n expected_identity: _FileIdentity,\n) -> None:\n current_identity = _regular_identity_at(\n source_name,\n directory_fd=source_directory_fd,\n )\n if current_identity != expected_identity:\n raise FileNotFoundError(\n f"planned source changed during execution: {source_name}"\n )\n\n\n''', - '', -) -replace_once( - CODE, - '''def _open_planned_source_fd_at(\n source_name: str,\n *,\n root_fd: int,\n expected_identity: _FileIdentity,\n) -> int:\n """Pin the planned inode without blocking on a late special-file replacement."""\n flags = os.O_RDONLY | os.O_NOFOLLOW\n if hasattr(os, "O_NONBLOCK"):\n flags |= os.O_NONBLOCK\n if hasattr(os, "O_CLOEXEC"):\n flags |= os.O_CLOEXEC\n try:\n source_fd = os.open(source_name, flags, dir_fd=root_fd)\n except PermissionError as exc:\n raise PermissionError(\n f"planned source must be readable for safe execution: {source_name}"\n ) from exc\n except OSError as exc:\n raise FileNotFoundError(\n f"planned source changed during execution: {source_name}"\n ) from exc\n\n try:\n current_identity = _identity_from_regular_stat(\n os.fstat(source_fd),\n filename=source_name,\n )\n if current_identity != expected_identity:\n raise FileNotFoundError(\n f"planned source changed during execution: {source_name}"\n )\n except Exception:\n os.close(source_fd)\n raise\n return source_fd\n\n\n''', - '''def _open_planned_source_fd_at(\n source_name: str,\n *,\n root_fd: int,\n) -> _PinnedSource:\n """Open first, then accept identity from the descriptor pinning the inode."""\n flags = os.O_RDONLY | os.O_NOFOLLOW\n if hasattr(os, "O_NONBLOCK"):\n flags |= os.O_NONBLOCK\n if hasattr(os, "O_CLOEXEC"):\n flags |= os.O_CLOEXEC\n try:\n source_fd = os.open(source_name, flags, dir_fd=root_fd)\n except PermissionError as exc:\n raise PermissionError(\n f"planned source must be readable for safe execution: {source_name}"\n ) from exc\n except OSError as exc:\n raise FileNotFoundError(\n f"planned source changed during execution: {source_name}"\n ) from exc\n\n try:\n identity = _identity_from_regular_stat(\n os.fstat(source_fd),\n filename=source_name,\n )\n except Exception:\n os.close(source_fd)\n raise\n return _PinnedSource(fd=source_fd, identity=identity)\n\n\ndef _pin_planned_sources_at(\n plan: OrganizationPlan,\n *,\n root_fd: int,\n) -> dict[Path, _PinnedSource]:\n """Pin every source before category creation or source mutation."""\n pinned: dict[Path, _PinnedSource] = {}\n try:\n for action in plan.actions:\n pinned[action.source] = _open_planned_source_fd_at(\n action.source.name,\n root_fd=root_fd,\n )\n except Exception:\n for source in pinned.values():\n os.close(source.fd)\n raise\n return pinned\n\n\n''', -) - -# Source -> stage also uses no-replace semantics with bounded retries. -replace_once( - CODE, - ''' stage_name = _make_stage_name(source_name)\n os.rename(\n source_name,\n stage_name,\n src_dir_fd=root_fd,\n dst_dir_fd=root_fd,\n )\n\n try:\n''', - ''' stage_name = ""\n for _ in range(16):\n stage_name = _make_stage_name(source_name)\n try:\n _rename_no_replace_at(\n source_name,\n stage_name,\n source_directory_fd=root_fd,\n destination_directory_fd=root_fd,\n )\n except FileExistsError:\n continue\n except FileNotFoundError as exc:\n raise FileNotFoundError(\n f"planned source changed during execution: {source_name}"\n ) from exc\n break\n else:\n raise FileExistsError(\n f"could not allocate staging entry for planned source: {source_name}"\n )\n\n try:\n''', -) - -# Move receives the already-pinned FD and recovers its bytes if pathname claim fails. -replace_once( - CODE, - '''def _move_file_no_replace_at(\n source_name: str,\n destination_name: str,\n *,\n source_directory_path: Path,\n source_directory_fd: int,\n destination_directory_fd: int,\n category_name: str,\n expected_identity: _FileIdentity,\n) -> None:\n """Commit one move with anchored directories and no replace/unlink window."""\n _verify_root_anchor_at(source_directory_path, source_directory_fd)\n _verify_category_anchor_at(\n root_fd=source_directory_fd,\n category_name=category_name,\n category_fd=destination_directory_fd,\n )\n source_fd = _open_planned_source_fd_at(\n source_name,\n root_fd=source_directory_fd,\n expected_identity=expected_identity,\n )\n\n try:\n stage_name = _claim_source_at(\n source_name,\n root_fd=source_directory_fd,\n expected_identity=expected_identity,\n )\n\n try:\n _verify_root_anchor_at(source_directory_path, source_directory_fd)\n _verify_category_anchor_at(\n root_fd=source_directory_fd,\n category_name=category_name,\n category_fd=destination_directory_fd,\n )\n staged_identity = _regular_identity_at(\n stage_name,\n directory_fd=source_directory_fd,\n )\n if staged_identity != expected_identity:\n raise FileNotFoundError(\n f"planned source changed during execution: {source_name}"\n )\n _verify_no_casefold_destination_collision_at(\n destination_name,\n destination_directory_fd=destination_directory_fd,\n )\n _rename_no_replace_at(\n stage_name,\n destination_name,\n source_directory_fd=source_directory_fd,\n destination_directory_fd=destination_directory_fd,\n )\n except (FileExistsError, FileNotFoundError, ValueError, OSError):\n _preserve_stage_at(stage_name, source_name, root_fd=source_directory_fd)\n raise\n\n try:\n _verify_destination_identity_at(\n destination_name,\n destination_directory_fd=destination_directory_fd,\n expected_identity=expected_identity,\n )\n except RuntimeError as exc:\n recovery_name = _recover_pinned_source_at(\n source_fd,\n source_name,\n root_fd=source_directory_fd,\n )\n raise RuntimeError(\n "destination does not match planned source; "\n f"planned source data retained as {recovery_name}: {destination_name}"\n ) from exc\n _verify_root_anchor_at(source_directory_path, source_directory_fd)\n _verify_category_anchor_at(\n root_fd=source_directory_fd,\n category_name=category_name,\n category_fd=destination_directory_fd,\n )\n finally:\n os.close(source_fd)\n\n\n''', - '''def _move_file_no_replace_at(\n source_name: str,\n destination_name: str,\n *,\n source_directory_path: Path,\n source_directory_fd: int,\n destination_directory_fd: int,\n category_name: str,\n source_fd: int,\n expected_identity: _FileIdentity,\n) -> None:\n """Commit one move using the source descriptor pinned before mutation."""\n _verify_root_anchor_at(source_directory_path, source_directory_fd)\n _verify_category_anchor_at(\n root_fd=source_directory_fd,\n category_name=category_name,\n category_fd=destination_directory_fd,\n )\n\n try:\n stage_name = _claim_source_at(\n source_name,\n root_fd=source_directory_fd,\n expected_identity=expected_identity,\n )\n except FileNotFoundError as exc:\n recovery_name = _recover_pinned_source_at(\n source_fd,\n source_name,\n root_fd=source_directory_fd,\n )\n raise FileNotFoundError(\n "planned source changed after it was pinned; "\n f"planned source data retained as {recovery_name}: {source_name}"\n ) from exc\n\n try:\n _verify_root_anchor_at(source_directory_path, source_directory_fd)\n _verify_category_anchor_at(\n root_fd=source_directory_fd,\n category_name=category_name,\n category_fd=destination_directory_fd,\n )\n staged_identity = _regular_identity_at(\n stage_name,\n directory_fd=source_directory_fd,\n )\n if staged_identity != expected_identity:\n raise FileNotFoundError(\n f"planned source changed during execution: {source_name}"\n )\n _verify_no_casefold_destination_collision_at(\n destination_name,\n destination_directory_fd=destination_directory_fd,\n )\n _rename_no_replace_at(\n stage_name,\n destination_name,\n source_directory_fd=source_directory_fd,\n destination_directory_fd=destination_directory_fd,\n )\n except (FileExistsError, FileNotFoundError, ValueError, OSError):\n _preserve_stage_at(stage_name, source_name, root_fd=source_directory_fd)\n raise\n\n try:\n _verify_destination_identity_at(\n destination_name,\n destination_directory_fd=destination_directory_fd,\n expected_identity=expected_identity,\n )\n except RuntimeError as exc:\n recovery_name = _recover_pinned_source_at(\n source_fd,\n source_name,\n root_fd=source_directory_fd,\n )\n raise RuntimeError(\n "destination does not match planned source; "\n f"planned source data retained as {recovery_name}: {destination_name}"\n ) from exc\n _verify_root_anchor_at(source_directory_path, source_directory_fd)\n _verify_category_anchor_at(\n root_fd=source_directory_fd,\n category_name=category_name,\n category_fd=destination_directory_fd,\n )\n\n\n''', -) - -# Linux pins all sources before any category directory is created and keeps them open. -replace_once( - CODE, - '''def _execute_plan_with_directory_fds(\n plan: OrganizationPlan,\n source_identities: dict[Path, _FileIdentity],\n) -> OrganizationResult:\n """Execute using pinned no-follow directory descriptors on Linux."""\n root_fd = _open_source_directory_fd(plan.source_directory)\n category_fds: dict[FileCategory, int] = {}\n\n try:\n _verify_root_anchor_at(plan.source_directory, root_fd)\n\n # Readability is a deliberate secure-execution prerequisite because\n # pinned-FD recovery must be able to persist the planned source bytes.\n for action in plan.actions:\n validation_fd = _open_planned_source_fd_at(\n action.source.name,\n root_fd=root_fd,\n expected_identity=source_identities[action.source],\n )\n os.close(validation_fd)\n\n for category in sorted(\n {action.category for action in plan.actions},\n key=lambda item: item.value,\n ):\n category_fds[category] = _open_category_directory_fd(\n root_fd,\n category.value,\n )\n\n moved: list[Path] = []\n for action in plan.actions:\n _move_file_no_replace_at(\n action.source.name,\n action.destination.name,\n source_directory_path=plan.source_directory,\n source_directory_fd=root_fd,\n destination_directory_fd=category_fds[action.category],\n category_name=action.category.value,\n expected_identity=source_identities[action.source],\n )\n moved.append(action.destination)\n\n _verify_root_anchor_at(plan.source_directory, root_fd)\n return OrganizationResult(plan=plan, moved_files=tuple(moved))\n finally:\n for directory_fd in category_fds.values():\n os.close(directory_fd)\n os.close(root_fd)\n\n\n''', - '''def _execute_plan_with_directory_fds(\n plan: OrganizationPlan,\n) -> OrganizationResult:\n """Execute using sources and directories pinned before Linux mutation."""\n root_fd = _open_source_directory_fd(plan.source_directory)\n pinned_sources: dict[Path, _PinnedSource] = {}\n category_fds: dict[FileCategory, int] = {}\n\n try:\n _verify_root_anchor_at(plan.source_directory, root_fd)\n\n # Accept identity only from already-open descriptors. Keeping every\n # descriptor alive prevents accepted inodes from being freed/reused.\n pinned_sources = _pin_planned_sources_at(plan, root_fd=root_fd)\n\n for category in sorted(\n {action.category for action in plan.actions},\n key=lambda item: item.value,\n ):\n category_fds[category] = _open_category_directory_fd(\n root_fd,\n category.value,\n )\n\n moved: list[Path] = []\n for action in plan.actions:\n pinned_source = pinned_sources[action.source]\n _move_file_no_replace_at(\n action.source.name,\n action.destination.name,\n source_directory_path=plan.source_directory,\n source_directory_fd=root_fd,\n destination_directory_fd=category_fds[action.category],\n category_name=action.category.value,\n source_fd=pinned_source.fd,\n expected_identity=pinned_source.identity,\n )\n moved.append(action.destination)\n\n _verify_root_anchor_at(plan.source_directory, root_fd)\n return OrganizationResult(plan=plan, moved_files=tuple(moved))\n finally:\n for directory_fd in category_fds.values():\n os.close(directory_fd)\n for source in pinned_sources.values():\n os.close(source.fd)\n os.close(root_fd)\n\n\n''', -) -replace_once( - CODE, - '''def execute_plan(plan: OrganizationPlan) -> OrganizationResult:\n """Execute a previously validated plan after a full collision preflight."""\n if not isinstance(plan, OrganizationPlan):\n raise TypeError("plan must be an OrganizationPlan")\n\n source_identities = _preflight_execution(plan)\n if not plan.actions:\n return OrganizationResult(plan=plan, moved_files=())\n\n if _supports_secure_directory_fds():\n return _execute_plan_with_directory_fds(plan, source_identities)\n return _execute_plan_portable(plan, source_identities)\n''', - '''def execute_plan(plan: OrganizationPlan) -> OrganizationResult:\n """Execute a plan under the strongest explicitly supported platform contract."""\n if not isinstance(plan, OrganizationPlan):\n raise TypeError("plan must be an OrganizationPlan")\n\n _preflight_execution(plan)\n if not plan.actions:\n return OrganizationResult(plan=plan, moved_files=())\n\n if _supports_secure_directory_fds():\n return _execute_plan_with_directory_fds(plan)\n\n source_identities = _capture_portable_source_identities(plan)\n return _execute_plan_portable(plan, source_identities)\n''', -) - -# Update the mutation-race wrapper for the new pinned-FD argument. -replace_once( - ATOMIC, - ''' def racing_move(\n source_name: str,\n destination_name: str,\n *,\n source_directory_path: Path,\n source_directory_fd: int,\n destination_directory_fd: int,\n category_name: str,\n expected_identity: file_organizer._FileIdentity,\n ) -> None:\n''', - ''' def racing_move(\n source_name: str,\n destination_name: str,\n *,\n source_directory_path: Path,\n source_directory_fd: int,\n destination_directory_fd: int,\n category_name: str,\n source_fd: int,\n expected_identity: file_organizer._FileIdentity,\n ) -> None:\n''', -) -replace_once( - ATOMIC, - ''' destination_directory_fd=destination_directory_fd,\n category_name=category_name,\n expected_identity=expected_identity,\n )\n\n monkeypatch.setattr(file_organizer, "_supports_secure_directory_fds", lambda: True)\n''', - ''' destination_directory_fd=destination_directory_fd,\n category_name=category_name,\n source_fd=source_fd,\n expected_identity=expected_identity,\n )\n\n monkeypatch.setattr(file_organizer, "_supports_secure_directory_fds", lambda: True)\n''', -) -append_once( - ATOMIC, - 'test_source_identity_is_accepted_only_after_descriptor_pin', - '''def test_source_identity_is_accepted_only_after_descriptor_pin(\n monkeypatch: pytest.MonkeyPatch,\n tmp_path: Path,\n) -> None:\n if not file_organizer._supports_secure_directory_fds():\n pytest.skip("secure directory descriptors are unavailable on this platform")\n\n source = tmp_path / "notes.txt"\n source.write_text("planned source", encoding="utf-8")\n plan = plan_organization(tmp_path)\n destination = tmp_path / "documents" / "notes.txt"\n original_pin = file_organizer._pin_planned_sources_at\n raced = False\n\n def racing_pin(\n plan_value: file_organizer.OrganizationPlan,\n *,\n root_fd: int,\n ) -> dict[Path, file_organizer._PinnedSource]:\n nonlocal raced\n pinned = original_pin(plan_value, root_fd=root_fd)\n if not raced:\n raced = True\n source.unlink()\n source.write_text("third-party replacement", encoding="utf-8")\n return pinned\n\n monkeypatch.setattr(file_organizer, "_pin_planned_sources_at", racing_pin)\n\n with pytest.raises(FileNotFoundError, match="planned source data retained"):\n execute_plan(plan)\n\n assert source.read_text(encoding="utf-8") == "third-party replacement"\n assert not destination.exists()\n recovery_files = [\n child for child in tmp_path.iterdir() if child.name.startswith(".fo-recovery-")\n ]\n assert len(recovery_files) == 1\n assert recovery_files[0].read_text(encoding="utf-8") == "planned source"\n''', -) - -# Real Windows junction and reserved-internal-namespace regressions. -replace_once(TESTS, 'from pathlib import Path\n', 'import os\nimport subprocess\nfrom pathlib import Path\n') -append_once( - TESTS, - 'test_internal_recovery_artifacts_are_reserved_from_future_plans', - '''def test_internal_recovery_artifacts_are_reserved_from_future_plans(tmp_path: Path) -> None:\n (tmp_path / ".fo-stage-deadbeef").write_text("stage", encoding="utf-8")\n (tmp_path / ".fo-recovery-deadbeef").write_text("recovery", encoding="utf-8")\n (tmp_path / "notes.txt").write_text("user", encoding="utf-8")\n\n plan = plan_organization(tmp_path)\n\n assert tuple(action.source.name for action in plan.actions) == ("notes.txt",)\n\n\n@pytest.mark.skipif(os.name != "nt", reason="requires Windows NTFS junction semantics")\ndef test_windows_real_source_and_category_junctions_are_rejected(tmp_path: Path) -> None:\n outside = tmp_path / "outside"\n outside.mkdir()\n\n source_junction = tmp_path / "workspace-link"\n subprocess.run(\n ["cmd", "/c", "mklink", "/J", str(source_junction), str(outside)],\n check=True,\n capture_output=True,\n text=True,\n )\n with pytest.raises(ValueError, match="symlink or junction"):\n plan_organization(source_junction)\n\n workspace = tmp_path / "workspace"\n workspace.mkdir()\n (workspace / "notes.txt").write_text("x", encoding="utf-8")\n category_junction = workspace / "documents"\n subprocess.run(\n ["cmd", "/c", "mklink", "/J", str(category_junction), str(outside)],\n check=True,\n capture_output=True,\n text=True,\n )\n with pytest.raises(ValueError, match="symlink or junction"):\n plan_organization(workspace)\n''', -) - -# Documentation EN. -replace_once( - README_EN, - 'During secure Linux execution, the planned source is opened with `O_NOFOLLOW | O_NONBLOCK` when `O_NONBLOCK` is available. The nonblocking flag prevents a late FIFO replacement from hanging `open()`, while the following `fstat()` still requires a regular file with the planned `(device, inode)` identity. The open descriptor pins the expected inode while the commit runs.', - 'During secure Linux execution, source identity is accepted **only after the source has been opened** with `O_NOFOLLOW | O_NONBLOCK` when `O_NONBLOCK` is available. The following `fstat()` derives `(device, inode)` from that already-open descriptor, and every planned source descriptor stays open until the plan finishes. An accepted inode that is later unlinked therefore cannot be freed and immediately reused while execution still depends on its identity. The nonblocking flag also prevents a late FIFO replacement from hanging `open()`. Descriptor pinning stabilizes object identity, not file contents; concurrent writes to the same inode are outside this project\'s snapshot guarantees.', -) -replace_once( - README_EN, - '''1. preflight and capture source identity\n2. open and anchor the source root\n3. open and anchor required category directories\n4. pin the planned source inode with O_NOFOLLOW | O_NONBLOCK\n5. atomically claim source name -> short internal stage\n6. verify stage identity and directory anchors\n7. rescan the pinned category for a casefold-equivalent destination\n8. atomically rename stage -> exact destination with RENAME_NOREPLACE\n9. verify destination identity and anchors\n10. report success''', - '''1. validate paths and collision preflight\n2. open and anchor the source root\n3. open every planned source and accept identity from `fstat()` on that pinned descriptor\n4. keep all accepted source descriptors open through plan completion\n5. open and anchor required category directories\n6. claim source name -> short internal stage with no-replace semantics\n7. verify stage identity and directory anchors\n8. rescan the pinned category for a casefold-equivalent destination\n9. atomically rename stage -> exact destination with RENAME_NOREPLACE\n10. verify destination identity and anchors\n11. report success''', -) -replace_once( - README_EN, - '- **Windows:** the fallback relies on Windows `os.rename()` refusing an existing destination and performs a best-effort casefold recheck plus source/destination/category identity validation around the operation;', - '- **Windows:** the guarded portable path relies on Windows `os.rename()` refusing an existing destination and performs best-effort casefold, redirect, and identity checks. It does **not** claim the descriptor-pinned adversarial race resistance of the Linux path;', -) -replace_once( - README_EN, - '''4. planned-source identity capture;\n5. destination collision preflight;\n6. platform capability selection;\n7. anchored directory setup;\n8. nonblocking source pin and source claim;\n9. mutation-time casefold collision recheck;\n10. atomic exact-name no-replace commit;\n11. destination/anchor verification;\n12. `OrganizationResult` construction.''', - '''4. destination collision preflight;\n5. platform capability selection;\n6. Linux: pin every planned source before accepting identity and before category mutation;\n7. anchored directory setup;\n8. source claim;\n9. mutation-time casefold collision recheck;\n10. atomic exact-name no-replace commit;\n11. destination/anchor verification;\n12. `OrganizationResult` construction.''', -) -replace_once( - README_EN, - 'The organizer does not follow direct-child symlinks. It also rejects a source directory or category folder that is a symlink. On Windows, category folders that are NTFS junctions are rejected too: `is_dir()` follows a junction, so accepting one could redirect a planned move outside the workspace.', - 'The organizer does not follow direct-child symlinks. It rejects a source directory or category folder that is a symlink. On Windows, source directories and category folders that are NTFS junctions are rejected too: `is_dir()` follows a junction, so accepting one could redirect discovery or a planned move outside the workspace.', -) -replace_once( - README_EN, - 'This can intentionally leave an internal recovery entry in unusual race/failure scenarios. That is preferable to deleting unrelated data whose current identity cannot be proven.', - 'This can intentionally leave an internal recovery entry in unusual race/failure scenarios. The `.fo-stage-*` and `.fo-recovery-*` prefixes are reserved internal namespaces and are excluded from later discovery so recovery evidence is not accidentally reorganized. That is preferable to deleting or reclassifying uncertain data whose current identity cannot be proven.', -) - -# Documentation PT-BR. -replace_once( - README_PT, - 'Durante a execução segura no Linux, a origem planejada é aberta com `O_NOFOLLOW | O_NONBLOCK` quando `O_NONBLOCK` está disponível. A flag nonblocking impede que uma substituição tardia por FIFO trave o `open()`, enquanto o `fstat()` seguinte ainda exige um arquivo regular com a identidade `(device, inode)` planejada. O descriptor aberto fixa o inode esperado durante o commit.', - 'Durante a execução segura no Linux, a identidade da origem só é aceita **depois que o arquivo já foi aberto** com `O_NOFOLLOW | O_NONBLOCK` quando `O_NONBLOCK` está disponível. O `fstat()` deriva `(device, inode)` desse descriptor já aberto, e todos os descriptors das origens planejadas permanecem abertos até o fim do plano. Assim, um inode aceito e depois desvinculado não pode ser liberado e imediatamente reutilizado enquanto a execução ainda depende da sua identidade. A flag nonblocking também impede que uma substituição tardia por FIFO trave o `open()`. O pinning estabiliza a identidade do objeto, não o conteúdo; escritas concorrentes no mesmo inode ficam fora das garantias de snapshot deste projeto.', -) -replace_once( - README_PT, - '''1. executar preflight e capturar identidade da origem\n2. abrir e ancorar a raiz\n3. abrir e ancorar as categorias necessárias\n4. fixar o inode da origem com O_NOFOLLOW | O_NONBLOCK\n5. reivindicar atomicamente origem -> staging curto\n6. verificar identidade do staging e âncoras\n7. varrer novamente a categoria ancorada por destino equivalente via casefold\n8. renomear atomicamente staging -> destino exato com RENAME_NOREPLACE\n9. verificar identidade do destino e âncoras\n10. reportar sucesso''', - '''1. validar caminhos e executar o preflight de colisões\n2. abrir e ancorar a raiz\n3. abrir todas as origens planejadas e aceitar identidade pelo `fstat()` do descriptor pinado\n4. manter todos os descriptors aceitos abertos até o fim do plano\n5. abrir e ancorar as categorias necessárias\n6. reivindicar origem -> staging curto com semântica no-replace\n7. verificar identidade do staging e âncoras\n8. varrer novamente a categoria ancorada por destino equivalente via casefold\n9. renomear atomicamente staging -> destino exato com RENAME_NOREPLACE\n10. verificar identidade do destino e âncoras\n11. reportar sucesso''', -) -replace_once( - README_PT, - '- **Windows:** o fallback usa o comportamento de `os.rename()` que recusa destino existente e executa uma rechecagem `casefold()` best-effort mais validações de identidade ao redor da operação;', - '- **Windows:** o caminho portátil protegido usa `os.rename()` recusando destino existente e realiza checagens best-effort de `casefold()`, redirecionamento e identidade. Ele **não** afirma possuir a mesma resistência a corridas adversariais baseada em descriptors pinados do caminho Linux;', -) -replace_once( - README_PT, - '''4. captura das identidades das origens planejadas;\n5. preflight de colisões;\n6. seleção da capacidade da plataforma;\n7. preparação dos diretórios ancorados;\n8. pinning nonblocking e claim da origem;\n9. rechecagem de colisão por `casefold()` na mutação;\n10. commit atômico no-replace do nome exato;\n11. verificação do destino e das âncoras;\n12. construção de `OrganizationResult`.''', - '''4. preflight de colisões;\n5. seleção da capacidade da plataforma;\n6. Linux: pinning de todas as origens antes de aceitar identidade e antes de mutar categorias;\n7. preparação dos diretórios ancorados;\n8. claim da origem;\n9. rechecagem de colisão por `casefold()` na mutação;\n10. commit atômico no-replace do nome exato;\n11. verificação do destino e das âncoras;\n12. construção de `OrganizationResult`.''', -) -replace_once( - README_PT, - 'O organizador não segue symlinks filhos diretos. Também rejeita diretório de origem ou pasta de categoria que seja symlink. No Windows, pastas de categoria que sejam junctions NTFS também são rejeitadas: `is_dir()` segue um junction, então aceitá-lo poderia redirecionar uma movimentação planejada para fora do workspace.', - 'O organizador não segue symlinks filhos diretos. Também rejeita diretório de origem ou pasta de categoria que seja symlink. No Windows, tanto o diretório de origem quanto as pastas de categoria são rejeitados quando são junctions NTFS: `is_dir()` segue um junction, então aceitá-lo poderia redirecionar descoberta ou movimentação para fora do workspace.', -) -replace_once( - README_PT, - 'Em cenários raros de corrida/falha, isso pode deixar uma entrada interna de recuperação. É preferível a excluir dados cuja identidade atual não pode ser comprovada.', - 'Em cenários raros de corrida/falha, isso pode deixar uma entrada interna de recuperação. Os prefixos `.fo-stage-*` e `.fo-recovery-*` são namespaces internos reservados e ficam fora de descobertas futuras, evitando que evidências de recuperação sejam reorganizadas por acidente. É preferível a excluir ou reclassificar dados cuja identidade atual não pode ser comprovada.', -) - -# Documentation ES. -replace_once( - README_ES, - 'Durante la ejecución segura en Linux, el origen planificado se abre con `O_NOFOLLOW | O_NONBLOCK` cuando `O_NONBLOCK` está disponible. La flag nonblocking impide que una sustitución tardía por FIFO bloquee `open()`, mientras el `fstat()` posterior sigue exigiendo un archivo regular con la identidad `(device, inode)` planificada. El descriptor abierto fija el inode esperado durante el commit.', - 'Durante la ejecución segura en Linux, la identidad del origen se acepta **solo después de abrir el archivo** con `O_NOFOLLOW | O_NONBLOCK` cuando `O_NONBLOCK` está disponible. El `fstat()` deriva `(device, inode)` de ese descriptor ya abierto, y todos los descriptores de los orígenes planificados permanecen abiertos hasta que termina el plan. Así, un inode aceptado y luego desvinculado no puede liberarse y reutilizarse de inmediato mientras la ejecución todavía depende de su identidad. La flag nonblocking también evita que una sustitución tardía por FIFO bloquee `open()`. El pinning estabiliza la identidad del objeto, no su contenido; las escrituras concurrentes sobre el mismo inode quedan fuera de las garantías de snapshot de este proyecto.', -) -replace_once( - README_ES, - '''1. ejecutar preflight y capturar identidad del origen\n2. abrir y anclar la raíz\n3. abrir y anclar las categorías necesarias\n4. fijar el inode del origen con O_NOFOLLOW | O_NONBLOCK\n5. reclamar atómicamente origen -> staging corto\n6. verificar identidad del staging y anclajes\n7. escanear de nuevo la categoría anclada buscando un destino equivalente por casefold\n8. renombrar atómicamente staging -> destino exacto con RENAME_NOREPLACE\n9. verificar identidad del destino y anclajes\n10. informar éxito''', - '''1. validar rutas y ejecutar el preflight de colisiones\n2. abrir y anclar la raíz\n3. abrir todos los orígenes planificados y aceptar identidad mediante `fstat()` del descriptor fijado\n4. mantener abiertos todos los descriptores aceptados hasta que termine el plan\n5. abrir y anclar las categorías necesarias\n6. reclamar origen -> staging corto con semántica no-replace\n7. verificar identidad del staging y anclajes\n8. escanear de nuevo la categoría anclada buscando un destino equivalente por casefold\n9. renombrar atómicamente staging -> destino exacto con RENAME_NOREPLACE\n10. verificar identidad del destino y anclajes\n11. informar éxito''', -) -replace_once( - README_ES, - '- **Windows:** el fallback usa el comportamiento de `os.rename()` que rechaza un destino existente y realiza una nueva comprobación `casefold()` best-effort junto con validaciones de identidad alrededor de la operación;', - '- **Windows:** la ruta portátil protegida usa `os.rename()` rechazando un destino existente y realiza comprobaciones best-effort de `casefold()`, redirección e identidad. **No** afirma tener la misma resistencia a carreras adversariales basada en descriptores fijados que la ruta Linux;', -) -replace_once( - README_ES, - '''4. captura de identidades de los orígenes planificados;\n5. preflight de colisiones;\n6. selección de capacidades de plataforma;\n7. preparación de directorios anclados;\n8. pinning nonblocking y claim del origen;\n9. nueva comprobación de colisión por `casefold()` durante la mutación;\n10. commit atómico no-replace del nombre exacto;\n11. verificación de destino y anclajes;\n12. construcción de `OrganizationResult`.''', - '''4. preflight de colisiones;\n5. selección de capacidades de plataforma;\n6. Linux: fijar todos los orígenes antes de aceptar identidad y antes de mutar categorías;\n7. preparación de directorios anclados;\n8. claim del origen;\n9. nueva comprobación de colisión por `casefold()` durante la mutación;\n10. commit atómico no-replace del nombre exacto;\n11. verificación de destino y anclajes;\n12. construcción de `OrganizationResult`.''', -) -replace_once( - README_ES, - 'El organizador no sigue symlinks hijos directos. También rechaza directorio de origen o carpeta de categoría que sea symlink. En Windows, también se rechazan carpetas de categoría que sean junctions NTFS: `is_dir()` sigue un junction, por lo que aceptarlo podría redirigir un movimiento planificado fuera del workspace.', - 'El organizador no sigue symlinks hijos directos. También rechaza directorio de origen o carpeta de categoría que sea symlink. En Windows, tanto el directorio de origen como las carpetas de categoría se rechazan cuando son junctions NTFS: `is_dir()` sigue un junction, por lo que aceptarlo podría redirigir el descubrimiento o un movimiento fuera del workspace.', -) -replace_once( - README_ES, - 'En escenarios raros de carrera/fallo, esto puede dejar una entrada interna de recuperación. Es preferible a borrar datos cuya identidad actual no puede demostrarse.', - 'En escenarios raros de carrera/fallo, esto puede dejar una entrada interna de recuperación. Los prefijos `.fo-stage-*` y `.fo-recovery-*` son namespaces internos reservados y quedan fuera de descubrimientos futuros para que la evidencia de recuperación no se reorganice por accidente. Es preferible a borrar o reclasificar datos cuya identidad actual no puede demostrarse.', -) - -print('Applied File Organizer full-review hardening patch.') From c1fa0be32e3892fe4747acbed5654c79507694b6 Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Tue, 1 Sep 2026 17:16:56 -0300 Subject: [PATCH 076/117] Remove temporary File Organizer full-review workflow --- .../apply-file-organizer-full-review.yml | 187 ------------------ 1 file changed, 187 deletions(-) delete mode 100644 .github/workflows/apply-file-organizer-full-review.yml diff --git a/.github/workflows/apply-file-organizer-full-review.yml b/.github/workflows/apply-file-organizer-full-review.yml deleted file mode 100644 index 15f8ad3..0000000 --- a/.github/workflows/apply-file-organizer-full-review.yml +++ /dev/null @@ -1,187 +0,0 @@ -name: Apply File Organizer full-review patch - -on: - push: - branches: - - phase-10-file-organizer - paths: - - scripts/_apply_file_organizer_full_review.py - - .github/workflows/apply-file-organizer-full-review.yml - -permissions: - contents: write - -jobs: - patch: - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - name: Check out feature branch - uses: actions/checkout@v6 - with: - ref: phase-10-file-organizer - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: "3.13" - - name: Install pytest - run: python -m pip install --disable-pip-version-check "pytest>=9.1,<9.2" - - name: Normalize helper anchor - shell: bash - run: | - python - <<'PY' - from pathlib import Path - path = Path("scripts/_apply_file_organizer_full_review.py") - text = path.read_text(encoding="utf-8") - wrong = "El organizador no sigue symlinks hijos directos. También rechaza directorio de origen o carpeta de categoría que sea symlink. En Windows, también se rechazan carpetas de categoría que sean junctions NTFS: `is_dir()` sigue un junction, por lo que aceptarlo podría redirigir un movimiento planificado fuera del workspace." - current = "El organizador no sigue symlinks hijos directos. También rechaza un directorio de origen o carpeta de categoría que sea symlink. En Windows, las carpetas de categoría que sean junctions NTFS también se rechazan: `is_dir()` sigue un junction, por lo que aceptarlo podría redirigir un movimiento planificado fuera del workspace." - if text.count(wrong) != 1: - raise SystemExit("expected one Spanish helper anchor") - path.write_text(text.replace(wrong, current, 1), encoding="utf-8") - PY - - name: Apply full-review patch - run: python scripts/_apply_file_organizer_full_review.py - - name: Align race tests with new claim boundary - shell: bash - run: | - python - <<'PY' - from pathlib import Path - path = Path("practical-projects/06-file-organizer/tests/test_atomic_move.py") - text = path.read_text(encoding="utf-8") - - def rewrite_function(name: str, transform): - global text - start = text.index(f"def {name}(") - next_start = text.find("\ndef ", start + 1) - end = len(text) if next_start == -1 else next_start + 1 - block = text[start:end] - updated = transform(block) - if updated == block: - raise SystemExit(f"no changes made in {name}") - text = text[:start] + updated + text[end:] - - def source_symlink(block: str) -> str: - block = block.replace( - 'with pytest.raises(FileNotFoundError, match="regular file|changed during execution"):', - 'with pytest.raises(FileNotFoundError, match="planned source data retained"):', - 1, - ) - return block - - rewrite_function( - "test_execute_plan_rejects_source_symlink_replacement_during_mutation", - source_symlink, - ) - - def source_claim(block: str) -> str: - start = block.index(" original_rename = os.rename\n") - end_marker = ' assert retained[0].read_text(encoding="utf-8") == "third-party replacement"\n' - end = block.index(end_marker, start) + len(end_marker) - replacement = ''' original_rename_no_replace = file_organizer._rename_no_replace_at - raced = False - - def racing_rename_no_replace( - source_name: str, - destination_name: str, - *, - source_directory_fd: int, - destination_directory_fd: int, - ) -> None: - nonlocal raced - if ( - source_name == source.name - and source_directory_fd == destination_directory_fd - and not raced - ): - raced = True - source.unlink() - source.write_text("third-party replacement", encoding="utf-8") - original_rename_no_replace( - source_name, - destination_name, - source_directory_fd=source_directory_fd, - destination_directory_fd=destination_directory_fd, - ) - - monkeypatch.setattr(file_organizer, "_supports_secure_directory_fds", lambda: True) - monkeypatch.setattr( - file_organizer, - "_rename_no_replace_at", - racing_rename_no_replace, - ) - - with pytest.raises(FileNotFoundError, match="planned source data retained"): - execute_plan(plan) - - assert source.read_text(encoding="utf-8") == "third-party replacement" - assert not destination.exists() - recovery_files = [ - child for child in tmp_path.iterdir() if child.name.startswith(".fo-recovery-") - ] - assert len(recovery_files) == 1 - assert recovery_files[0].read_text(encoding="utf-8") == "planned source" - retained = [ - child for child in tmp_path.iterdir() if child.name.startswith(".fo-stage-") - ] - assert len(retained) == 1 - assert retained[0].read_text(encoding="utf-8") == "third-party replacement" - ''' - return block[:start] + replacement + block[end:] - - rewrite_function( - "test_source_replacement_during_claim_is_preserved_without_unlink", - source_claim, - ) - - def category_race(block: str) -> str: - return block.replace( - " if not raced:\n raced = True\n category.rename(detached)", - " if source_directory_fd != destination_directory_fd and not raced:\n raced = True\n category.rename(detached)", - 1, - ) - - rewrite_function( - "test_category_rename_after_fd_open_never_reports_false_destination", - category_race, - ) - - def root_race(block: str) -> str: - return block.replace( - " if not raced:\n raced = True\n workspace.rename(detached)", - " if source_directory_fd != destination_directory_fd and not raced:\n raced = True\n workspace.rename(detached)", - 1, - ) - - rewrite_function( - "test_source_root_rename_after_fd_open_never_reports_false_destination", - root_race, - ) - - def staging_race(block: str) -> str: - return block.replace( - " if not raced:\n raced = True\n stage = tmp_path / source_name", - " if source_name.startswith(\".fo-stage-\") and not raced:\n raced = True\n stage = tmp_path / source_name", - 1, - ) - - rewrite_function( - "test_staging_replacement_before_final_rename_preserves_pinned_source_data", - staging_race, - ) - - path.write_text(text, encoding="utf-8") - PY - - name: Validate focused patch - run: | - python -m py_compile practical-projects/06-file-organizer/file_organizer.py - python -m py_compile practical-projects/06-file-organizer/tests/test_atomic_move.py - python -m pytest -q practical-projects/06-file-organizer/tests - git diff --check - - name: Commit functional changes - run: | - rm scripts/_apply_file_organizer_full_review.py - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -A practical-projects/06-file-organizer scripts/_apply_file_organizer_full_review.py - git commit -m "Pin File Organizer sources before identity acceptance" - git push origin HEAD:phase-10-file-organizer From b3950c8753ac77990a47bd9fc2cd8a348c648704 Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Tue, 1 Sep 2026 17:17:23 -0300 Subject: [PATCH 077/117] Validate File Organizer on Windows --- .github/workflows/quality-checks.yml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/.github/workflows/quality-checks.yml b/.github/workflows/quality-checks.yml index a6f19dc..6ffaf53 100644 --- a/.github/workflows/quality-checks.yml +++ b/.github/workflows/quality-checks.yml @@ -52,3 +52,26 @@ jobs: - name: Validate repository structure run: python scripts/validate_repository_structure.py + + file-organizer-windows: + name: File Organizer Windows + runs-on: windows-latest + timeout-minutes: 10 + + steps: + - name: Check out repository + uses: actions/checkout@v6 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.13" + + - name: Show Python version + run: python --version + + - name: Install test dependency + run: python -m pip install --disable-pip-version-check "pytest>=9.1,<9.2" + + - name: Run File Organizer tests on Windows + run: python -m pytest -q practical-projects/06-file-organizer/tests From fc9d21cd630f38d22dee202d3d2184f9dea795eb Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Tue, 1 Sep 2026 17:19:10 -0300 Subject: [PATCH 078/117] Add temporary Linux-only test marker patch --- scripts/_mark_file_organizer_linux_test.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 scripts/_mark_file_organizer_linux_test.py diff --git a/scripts/_mark_file_organizer_linux_test.py b/scripts/_mark_file_organizer_linux_test.py new file mode 100644 index 0000000..a9081b2 --- /dev/null +++ b/scripts/_mark_file_organizer_linux_test.py @@ -0,0 +1,22 @@ +from pathlib import Path + +path = Path("practical-projects/06-file-organizer/tests/test_atomic_move.py") +text = path.read_text(encoding="utf-8") +anchor = '''def test_execute_plan_never_replaces_destination_created_after_preflight( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + source = tmp_path / "notes.txt" +''' +replacement = '''def test_execute_plan_never_replaces_destination_created_after_preflight( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + if not file_organizer._supports_secure_directory_fds(): + pytest.skip("secure directory descriptors are unavailable on this platform") + + source = tmp_path / "notes.txt" +''' +if text.count(anchor) != 1: + raise SystemExit("expected exactly one Linux-only test anchor") +path.write_text(text.replace(anchor, replacement, 1), encoding="utf-8") From f40f4f87de12a8ec8b26fbfa4ebdb3fcab036359 Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Tue, 1 Sep 2026 17:19:22 -0300 Subject: [PATCH 079/117] Add temporary Linux-only test marker workflow --- .../apply-linux-only-test-marker.yml | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 .github/workflows/apply-linux-only-test-marker.yml diff --git a/.github/workflows/apply-linux-only-test-marker.yml b/.github/workflows/apply-linux-only-test-marker.yml new file mode 100644 index 0000000..b03be09 --- /dev/null +++ b/.github/workflows/apply-linux-only-test-marker.yml @@ -0,0 +1,39 @@ +name: Apply Linux-only File Organizer test marker + +on: + push: + branches: + - phase-10-file-organizer + paths: + - scripts/_mark_file_organizer_linux_test.py + - .github/workflows/apply-linux-only-test-marker.yml + +permissions: + contents: write + +jobs: + patch: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v6 + with: + ref: phase-10-file-organizer + - uses: actions/setup-python@v6 + with: + python-version: "3.13" + - name: Apply patch + run: python scripts/_mark_file_organizer_linux_test.py + - name: Validate focused suite + run: | + python -m pip install --disable-pip-version-check "pytest>=9.1,<9.2" + python -m pytest -q practical-projects/06-file-organizer/tests + git diff --check + - name: Commit patch + run: | + rm scripts/_mark_file_organizer_linux_test.py + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A practical-projects/06-file-organizer/tests scripts/_mark_file_organizer_linux_test.py + git commit -m "Mark secure rename race test as Linux-only" + git push origin HEAD:phase-10-file-organizer From b8b711d4b94037b4ba4ad834737c4768a5d82d30 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 20:19:35 +0000 Subject: [PATCH 080/117] Mark secure rename race test as Linux-only --- .../tests/test_atomic_move.py | 3 +++ scripts/_mark_file_organizer_linux_test.py | 22 ------------------- 2 files changed, 3 insertions(+), 22 deletions(-) delete mode 100644 scripts/_mark_file_organizer_linux_test.py diff --git a/practical-projects/06-file-organizer/tests/test_atomic_move.py b/practical-projects/06-file-organizer/tests/test_atomic_move.py index 265fcb9..79708b9 100644 --- a/practical-projects/06-file-organizer/tests/test_atomic_move.py +++ b/practical-projects/06-file-organizer/tests/test_atomic_move.py @@ -12,6 +12,9 @@ def test_execute_plan_never_replaces_destination_created_after_preflight( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: + if not file_organizer._supports_secure_directory_fds(): + pytest.skip("secure directory descriptors are unavailable on this platform") + source = tmp_path / "notes.txt" source.write_text("planned source", encoding="utf-8") plan = plan_organization(tmp_path) diff --git a/scripts/_mark_file_organizer_linux_test.py b/scripts/_mark_file_organizer_linux_test.py deleted file mode 100644 index a9081b2..0000000 --- a/scripts/_mark_file_organizer_linux_test.py +++ /dev/null @@ -1,22 +0,0 @@ -from pathlib import Path - -path = Path("practical-projects/06-file-organizer/tests/test_atomic_move.py") -text = path.read_text(encoding="utf-8") -anchor = '''def test_execute_plan_never_replaces_destination_created_after_preflight( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - source = tmp_path / "notes.txt" -''' -replacement = '''def test_execute_plan_never_replaces_destination_created_after_preflight( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - if not file_organizer._supports_secure_directory_fds(): - pytest.skip("secure directory descriptors are unavailable on this platform") - - source = tmp_path / "notes.txt" -''' -if text.count(anchor) != 1: - raise SystemExit("expected exactly one Linux-only test anchor") -path.write_text(text.replace(anchor, replacement, 1), encoding="utf-8") From 439573369c6d27dca3e017af6dbdae7397598007 Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Tue, 1 Sep 2026 17:20:14 -0300 Subject: [PATCH 081/117] Remove temporary Linux-only test marker workflow --- .../apply-linux-only-test-marker.yml | 39 ------------------- 1 file changed, 39 deletions(-) delete mode 100644 .github/workflows/apply-linux-only-test-marker.yml diff --git a/.github/workflows/apply-linux-only-test-marker.yml b/.github/workflows/apply-linux-only-test-marker.yml deleted file mode 100644 index b03be09..0000000 --- a/.github/workflows/apply-linux-only-test-marker.yml +++ /dev/null @@ -1,39 +0,0 @@ -name: Apply Linux-only File Organizer test marker - -on: - push: - branches: - - phase-10-file-organizer - paths: - - scripts/_mark_file_organizer_linux_test.py - - .github/workflows/apply-linux-only-test-marker.yml - -permissions: - contents: write - -jobs: - patch: - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - uses: actions/checkout@v6 - with: - ref: phase-10-file-organizer - - uses: actions/setup-python@v6 - with: - python-version: "3.13" - - name: Apply patch - run: python scripts/_mark_file_organizer_linux_test.py - - name: Validate focused suite - run: | - python -m pip install --disable-pip-version-check "pytest>=9.1,<9.2" - python -m pytest -q practical-projects/06-file-organizer/tests - git diff --check - - name: Commit patch - run: | - rm scripts/_mark_file_organizer_linux_test.py - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -A practical-projects/06-file-organizer/tests scripts/_mark_file_organizer_linux_test.py - git commit -m "Mark secure rename race test as Linux-only" - git push origin HEAD:phase-10-file-organizer From 16df00f76c88d0691d69130b4821c2999f59dc72 Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Tue, 1 Sep 2026 17:44:57 -0300 Subject: [PATCH 082/117] Prepare File Organizer full-review fixes --- .../_apply_file_organizer_full_review_11.py | 151 ++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 scripts/_apply_file_organizer_full_review_11.py diff --git a/scripts/_apply_file_organizer_full_review_11.py b/scripts/_apply_file_organizer_full_review_11.py new file mode 100644 index 0000000..d1be953 --- /dev/null +++ b/scripts/_apply_file_organizer_full_review_11.py @@ -0,0 +1,151 @@ +from pathlib import Path + + +def replace_once(path: str, old: str, new: str) -> None: + file_path = Path(path) + text = file_path.read_text(encoding="utf-8") + count = text.count(old) + if count != 1: + raise RuntimeError(f"expected one match in {path}, found {count}: {old[:80]!r}") + file_path.write_text(text.replace(old, new), encoding="utf-8") + + +source = "practical-projects/06-file-organizer/file_organizer.py" +replace_once( + source, + ' """Immutable organization plan produced before filesystem mutation."""', + ' """Immutable pathname-intent plan produced before filesystem mutation."""', +) +replace_once( + source, + ' """Build a deterministic, non-mutating plan for direct child files."""', + ' """Build a deterministic, non-mutating pathname-intent plan."""', +) +replace_once( + source, + ' """Pin every source before category creation or source mutation."""', + ' """Pin the current regular file at every planned pathname before mutation.\n\n OrganizationPlan intentionally stores pathname/category intent, not live file\n descriptors or a durable filesystem-object snapshot. Identity becomes strong\n only when execute_plan opens each pathname and accepts fstat() on that pin.\n """', +) +replace_once( + source, + ' """Execute a plan under the strongest explicitly supported platform contract."""', + ' """Execute pathname intent under the strongest supported platform contract.\n\n A plan does not freeze source-object identity between planning and execution.\n The current regular file at each planned pathname is bound when execution\n starts; changes after that binding are rejected under the platform contract.\n """', +) + +# Add a platform-neutral regression that makes the plan/execution identity boundary explicit. +test_path = "practical-projects/06-file-organizer/tests/test_file_organizer.py" +anchor = '''def test_execute_plan_preflights_missing_source_before_mutation(tmp_path: Path) -> None:\n first = tmp_path / "a.txt"\n second = tmp_path / "b.csv"\n first.write_text("a", encoding="utf-8")\n second.write_text("b", encoding="utf-8")\n plan = plan_organization(tmp_path)\n second.unlink()\n\n with pytest.raises(FileNotFoundError):\n execute_plan(plan)\n\n assert first.exists()\n assert not (tmp_path / "documents").exists()\n assert not (tmp_path / "data").exists()\n\n\n''' +addition = anchor + '''def test_execute_plan_binds_current_source_at_execution_start(tmp_path: Path) -> None:\n source = tmp_path / "notes.txt"\n source.write_text("observed during planning", encoding="utf-8")\n plan = plan_organization(tmp_path)\n\n source.unlink()\n source.write_text("current at execution start", encoding="utf-8")\n\n result = execute_plan(plan)\n\n destination = tmp_path / "documents" / "notes.txt"\n assert result.moved_files == (destination,)\n assert destination.read_text(encoding="utf-8") == "current at execution start"\n assert not source.exists()\n\n\n''' +replace_once(test_path, anchor, addition) + +# English contract wording. +readme = "practical-projects/06-file-organizer/README.md" +replace_once(readme, "14. capture planned-source filesystem identity;", "14. bind each source filesystem identity when execution begins, not during planning;") +replace_once(readme, "17. reject stale source, root, or category assumptions during execution;", "17. reject source changes after execution-time identity binding and reject stale root/category assumptions;") +replace_once( + readme, + "The plan is immutable. Creating it does not create directories and does not move files.", + "The plan is immutable. Creating it does not create directories and does not move files. It records **pathname/category intent**, not an open descriptor or durable snapshot of the filesystem object behind each pathname. If a regular file is replaced at the same planned pathname before `execute_plan()` begins binding sources, the replacement is the current object selected by that pathname intent. Strong object identity starts at execution-time pinning.", +) +replace_once( + readme, + "The proposal exists as data before side effects begin, which makes review and testing easier.", + "The proposal exists as data before side effects begin, which makes review and testing easier. This separation deliberately does **not** promise that a pathname still names the identical filesystem object observed during planning; retaining that guarantee would require keeping live source descriptors inside the plan. Execution instead binds the current regular object at each planned pathname before any category creation or source mutation.", +) +replace_once( + readme, + "During secure Linux execution, source identity is accepted **only after the source has been opened** with `O_NOFOLLOW | O_NONBLOCK` when `O_NONBLOCK` is available.", + "Planning records pathname intent rather than source-object identity. Therefore a regular file replaced at the same pathname **before execution-time pinning** is accepted as the current object selected by the plan. During secure Linux execution, source identity is accepted **only after the current source has been opened** with `O_NOFOLLOW | O_NONBLOCK` when `O_NONBLOCK` is available.", +) +replace_once(readme, "3. open every planned source and accept identity from `fstat()` on that pinned descriptor", "3. open the current regular file at every planned pathname and accept identity from `fstat()` on that pinned descriptor") +replace_once(readme, "6. Linux: pin every planned source before accepting identity and before category mutation;", "6. Linux: bind the current regular file at every planned pathname by pinning it before accepting identity and before category mutation;") + +# Portuguese contract wording. +readme = "practical-projects/06-file-organizer/README.pt-BR.md" +replace_once(readme, "14. capturar a identidade das origens planejadas;", "14. vincular a identidade de cada origem quando a execução começa, e não durante o planejamento;") +replace_once(readme, "17. rejeitar premissas obsoletas sobre origem, raiz ou categoria durante a execução;", "17. rejeitar mudanças da origem após o vínculo de identidade da execução e premissas obsoletas sobre raiz/categoria;") +replace_once( + readme, + "O plano é imutável. Criá-lo não cria diretórios e não move arquivos.", + "O plano é imutável. Criá-lo não cria diretórios e não move arquivos. Ele registra **intenção de pathname/categoria**, e não um descriptor aberto ou snapshot durável do objeto de filesystem por trás de cada pathname. Se um arquivo regular for substituído no mesmo pathname planejado antes de `execute_plan()` começar a pinar as origens, a substituição é o objeto atual selecionado por essa intenção de pathname. A identidade forte do objeto começa no pinning da execução.", +) +replace_once( + readme, + "A proposta existe como dados antes de os efeitos colaterais começarem, facilitando revisão e testes.", + "A proposta existe como dados antes de os efeitos colaterais começarem, facilitando revisão e testes. Essa separação deliberadamente **não** promete que um pathname ainda nomeie o mesmo objeto observado durante o planejamento; manter essa garantia exigiria descriptors de origem vivos dentro do plano. Em vez disso, a execução vincula o objeto regular atual em cada pathname planejado antes de criar categorias ou alterar origens.", +) +replace_once( + readme, + "Durante a execução segura no Linux, a identidade da origem só é aceita **depois que o arquivo já foi aberto** com `O_NOFOLLOW | O_NONBLOCK` quando `O_NONBLOCK` está disponível.", + "O planejamento registra intenção de pathname, e não identidade do objeto de origem. Portanto, um arquivo regular substituído no mesmo pathname **antes do pinning da execução** é aceito como o objeto atual selecionado pelo plano. Durante a execução segura no Linux, a identidade da origem só é aceita **depois que o arquivo atual já foi aberto** com `O_NOFOLLOW | O_NONBLOCK` quando `O_NONBLOCK` está disponível.", +) +replace_once(readme, "3. abrir todas as origens planejadas e aceitar identidade pelo `fstat()` do descriptor pinado", "3. abrir o arquivo regular atual em cada pathname planejado e aceitar identidade pelo `fstat()` do descriptor pinado") +replace_once(readme, "6. Linux: pin every planned source before accepting identity and before category mutation;", "6. Linux: vincular o arquivo regular atual em cada pathname planejado, pinando-o antes de aceitar identidade e antes da mutação de categorias;") if False else None +# The Portuguese execution-flow sentence is localized differently; patch it only if present. +pt_text = Path(readme).read_text(encoding="utf-8") +old_pt = "6. Linux: pinar cada origem planejada antes de aceitar identidade e antes da mutação de categorias;" +if old_pt in pt_text: + replace_once(readme, old_pt, "6. Linux: vincular o arquivo regular atual em cada pathname planejado, pinando-o antes de aceitar identidade e antes da mutação de categorias;") + +# Spanish contract wording. +readme = "practical-projects/06-file-organizer/README.es.md" +replace_once(readme, "14. capturar la identidad de los orígenes planificados;", "14. vincular la identidad de cada origen cuando comienza la ejecución, no durante la planificación;") +replace_once(readme, "17. rechazar supuestos obsoletos sobre origen, raíz o categoría durante la ejecución;", "17. rechazar cambios del origen después del vínculo de identidad de ejecución y supuestos obsoletos sobre raíz/categoría;") +replace_once( + readme, + "El plan es inmutable. Crearlo no crea directorios ni mueve archivos.", + "El plan es inmutable. Crearlo no crea directorios ni mueve archivos. Registra **intención de pathname/categoría**, no un descriptor abierto ni un snapshot duradero del objeto de filesystem detrás de cada pathname. Si un archivo regular se reemplaza en el mismo pathname planificado antes de que `execute_plan()` empiece a fijar los orígenes, el reemplazo es el objeto actual seleccionado por esa intención de pathname. La identidad fuerte del objeto comienza con el pinning de ejecución.", +) +replace_once( + readme, + "La propuesta existe como datos antes de que comiencen los efectos secundarios, lo que facilita revisión y pruebas.", + "La propuesta existe como datos antes de que comiencen los efectos secundarios, lo que facilita revisión y pruebas. Esta separación deliberadamente **no** promete que un pathname siga nombrando el mismo objeto observado durante la planificación; conservar esa garantía exigiría mantener descriptores de origen vivos dentro del plan. En su lugar, la ejecución vincula el objeto regular actual en cada pathname planificado antes de crear categorías o mutar orígenes.", +) +replace_once( + readme, + "Durante la ejecución segura en Linux, la identidad del origen se acepta **solo después de abrir el archivo** con `O_NOFOLLOW | O_NONBLOCK` cuando `O_NONBLOCK` está disponible.", + "La planificación registra intención de pathname y no identidad del objeto de origen. Por ello, un archivo regular reemplazado en el mismo pathname **antes del pinning de ejecución** se acepta como el objeto actual seleccionado por el plan. Durante la ejecución segura en Linux, la identidad del origen se acepta **solo después de abrir el archivo actual** con `O_NOFOLLOW | O_NONBLOCK` cuando `O_NONBLOCK` está disponible.", +) +replace_once(readme, "3. abrir todos los orígenes planificados y aceptar identidad mediante `fstat()` del descriptor fijado", "3. abrir el archivo regular actual en cada pathname planificado y aceptar identidad mediante `fstat()` del descriptor fijado") +es_text = Path(readme).read_text(encoding="utf-8") +old_es = "6. Linux: fijar cada origen planificado antes de aceptar identidad y antes de mutar categorías;" +if old_es in es_text: + replace_once(readme, old_es, "6. Linux: vincular el archivo regular actual en cada pathname planificado, fijándolo antes de aceptar identidad y antes de mutar categorías;") + +# Revert unrelated Spanish roadmap rewrites while keeping Project 06 additions. +es = "docs/roadmap.es.md" +replace_once(es, "Los ejemplos ejecutables usan el contrato declarado en [`requirements-external.txt`](../requirements-external.txt).", "Los ejemplos ejecutables de bibliotecas externas usan el contrato declarado en [`requirements-external.txt`](../requirements-external.txt).") +replace_once(es, "- [x] [Analizador CSV](../practical-projects/04-csv-analyzer/README.es.md)", "- [x] [Analizador de CSV](../practical-projects/04-csv-analyzer/README.es.md)") +replace_once( + es, + "El Proyecto 01 establece el contrato de la Fase 10 con requisitos explícitos, modelado de datos validado, dinero exacto con `Decimal`, persistencia, demostración determinista, cobertura automatizada con pytest, desafíos de extensión y discusión de portafolio. El Proyecto 02 extiende el contrato con reglas configurables de calificación, agregación ponderada exacta, informes parcial/final explícitos y validación centrada en límites. El Proyecto 03 añade datos de identidad canónicos, normalización Unicode e IDNA, prevención de duplicados, índices secundarios de lookup, actualizaciones seguras de campos indexados, transiciones explícitas del ciclo de vida y cobertura pytest centrada en mutación sin introducir autenticación. El Proyecto 04 añade schemas CSV estrictos, conversión tipada, manejo de fallos estructurales frente a fallos por fila, parsing con éxito parcial, identificadores aceptados duplicados, agregación determinista y filtrado usando mecanismos CSV de la biblioteca estándar de forma explícita. El Proyecto 05 añade ventanas inclusivas de fechas, validación de identidad de origen, métricas de resumen exactas y deterministas, construcción inmutable de informes, renderización TXT/Markdown, escape específico del formato y salida UTF-8. El Proyecto 06 añade descubrimiento superficial determinista, planificación inmutable, categorías por sufijo, políticas explícitas de colisión, fronteras de symlink, identidad `(device, inode)`, anclaje de descriptors de raíz/categorías, nombres de staging acotados y commits atómicos no-replace sensibles a la plataforma con `renameat2(RENAME_NOREPLACE)` en Linux.", + "El Proyecto 01 establece el contrato de la Fase 10 con requisitos explícitos, modelado de datos validado, dinero exacto con `Decimal`, persistencia, demostración determinista, cobertura automatizada con pytest, desafíos de ampliación y discusión de portafolio. El Proyecto 02 amplía el contrato con reglas de calificación configurables, agregación ponderada exacta, informe parcial/final explícito y validación centrada en límites. El Proyecto 03 añade datos de identidad canónicos, normalización Unicode e IDNA, prevención de duplicados, índices secundarios, actualizaciones seguras y transiciones explícitas del ciclo de vida sin introducir autenticación. El Proyecto 04 añade schemas CSV estrictos, conversión tipada, separación entre fallos estructurales y fallos de fila, parsing con éxito parcial, identificadores aceptados duplicados, agregación determinista y filtros con la mecánica de la biblioteca estándar expuesta explícitamente. El Proyecto 05 añade ventanas inclusivas explícitas de fechas, validación de identidad del origen, métricas exactas y deterministas de resumen, construcción inmutable del informe, renderización TXT/Markdown, escape específico del formato y escritura UTF-8. El Proyecto 06 añade descubrimiento superficial determinista, planificación inmutable, categorías por sufijo, políticas explícitas de colisión, fronteras de symlink, identidad `(device, inode)`, anclaje de descriptors de raíz/categorías, nombres de staging acotados y commits atómicos no-replace sensibles a la plataforma con `renameat2(RENAME_NOREPLACE)` en Linux.", +) +replace_once(es, "- cobertura automatizada para comportamientos importantes;", "- cobertura automatizada del comportamiento importante;") +replace_once(es, "- desafíos de extensión;", "- desafíos de ampliación;") +replace_once(es, "## Gates continuos de calidad", "## Criterios continuos de calidad") +replace_once(es, "- datos seguros para privacidad;", "- datos seguros desde el punto de vista de la privacidad;") +replace_once(es, "- ejemplos Python ejecutables cuando corresponda;", "- ejemplos ejecutables de Python cuando corresponda;") +replace_once(es, "- integridad de navegación interna;", "- integridad de la navegación interna;") +replace_once(es, "- supuestos honestos sobre dependencias y versiones.", "- transparencia sobre dependencias y supuestos de versión.") +replace_once(es, "El roadmap evolucionará a medida que crezca el proyecto, pero los cambios deben preservar la progresión desde conceptos iniciales hasta trabajo práctico integrado.", "El roadmap evolucionará a medida que el proyecto crezca, pero los cambios deben preservar la progresión desde los conceptos iniciales hasta el trabajo práctico integrado.") + +# Revert unrelated Portuguese roadmap rewrites while keeping Project 06 additions. +pt = "docs/roadmap.pt-BR.md" +replace_once(pt, "O Capítulo 09 encerra a fase conectando essas bases a estado do ambiente do processo, interfaces path-like, varredura e travessia de diretórios, metadados, cópia, movimento, remoção recursiva, capacidades de plataforma e segurança de archives.", "O Capítulo 09 encerra a fase conectando essas bases ao estado do ambiente do processo, interfaces path-like, varredura e travessia de diretórios, metadados, cópia, movimentação, exclusão recursiva, capacidades de plataforma e segurança de archives.") +replace_once(pt, "Os exemplos executáveis usam o contrato declarado em [`requirements-external.txt`](../requirements-external.txt).", "Os exemplos executáveis de bibliotecas externas usam o contrato declarado em [`requirements-external.txt`](../requirements-external.txt).") +replace_once(pt, "- [x] [Analisador CSV](../practical-projects/04-csv-analyzer/README.pt-BR.md)", "- [x] [Analisador de CSV](../practical-projects/04-csv-analyzer/README.pt-BR.md)") +replace_once( + pt, + "O Projeto 01 estabelece o contrato da Fase 10 com requisitos explícitos, modelagem de dados validada, dinheiro exato com `Decimal`, persistência, demonstração determinística, cobertura automatizada com pytest, desafios de extensão e discussão de portfólio. O Projeto 02 estende o contrato com regras configuráveis de notas, agregação ponderada exata, relatórios parcial/final explícitos e validação focada em fronteiras. O Projeto 03 adiciona dados de identidade canônicos, normalização Unicode e IDNA, prevenção de duplicidade, índices secundários de lookup, atualizações seguras de campos indexados, transições explícitas de ciclo de vida e cobertura pytest focada em mutação sem introduzir autenticação. O Projeto 04 adiciona schemas CSV estritos, conversão tipada, tratamento de falhas estruturais versus falhas por linha, parsing com sucesso parcial, identificadores aceitos duplicados, agregação determinística e filtragem usando mecanismos CSV da biblioteca padrão de forma explícita. O Projeto 05 adiciona janelas inclusivas de datas, validação de identidade de origem, métricas de resumo exatas e determinísticas, construção imutável de relatórios, renderização TXT/Markdown, escape específico do formato e saída UTF-8. O Projeto 06 adiciona descoberta rasa determinística, planejamento imutável, categorias por sufixo, políticas explícitas de colisão, fronteiras de symlink, identidade `(device, inode)`, ancoragem de descriptors de raiz/categorias, nomes de staging limitados e commits atômicos no-replace sensíveis à plataforma com `renameat2(RENAME_NOREPLACE)` no Linux.", + "O Projeto 01 estabelece o contrato da Fase 10 com requisitos explícitos, modelagem de dados validada, dinheiro exato com `Decimal`, persistência, demonstração determinística, cobertura automatizada com pytest, desafios de extensão e discussão de portfólio. O Projeto 02 amplia o contrato com regras de notas configuráveis, agregação ponderada exata, relatório parcial/final explícito e validação focada em fronteiras. O Projeto 03 adiciona dados de identidade canônicos, normalização Unicode e IDNA, prevenção de duplicidade, índices secundários, atualizações seguras e transições explícitas de ciclo de vida sem introduzir autenticação. O Projeto 04 adiciona schemas CSV rígidos, conversão tipada, separação entre falhas estruturais e falhas de linha, parsing com sucesso parcial, identificadores aceitos duplicados, agregação determinística e filtros com a mecânica da biblioteca padrão exposta explicitamente. O Projeto 05 adiciona janelas inclusivas explícitas de datas, validação da identidade da origem, métricas exatas e determinísticas de resumo, construção imutável do relatório, renderização TXT/Markdown, escape específico do formato e escrita UTF-8. O Projeto 06 adiciona descoberta rasa determinística, planejamento imutável, categorias por sufixo, políticas explícitas de colisão, fronteiras de symlink, identidade `(device, inode)`, ancoragem de descriptors de raiz/categorias, nomes de staging limitados e commits atômicos no-replace sensíveis à plataforma com `renameat2(RENAME_NOREPLACE)` no Linux.", +) +replace_once(pt, "- cobertura automatizada para comportamentos importantes;", "- cobertura automatizada dos comportamentos importantes;") +replace_once(pt, "## Gates contínuos de qualidade", "## Critérios contínuos de qualidade") +replace_once(pt, "- dados seguros para privacidade;", "- dados seguros do ponto de vista de privacidade;") +replace_once(pt, "- integridade de navegação interna;", "- integridade da navegação interna;") +replace_once(pt, "- atenção ao PEP 8;", "- atenção à PEP 8;") +replace_once(pt, "- premissas honestas sobre dependências e versões.", "- transparência sobre dependências e pressupostos de versão.") +replace_once(pt, "O roadmap evoluirá conforme o projeto crescer, mas as mudanças devem preservar a progressão de conceitos iniciantes para trabalho prático integrado.", "O roadmap evoluirá à medida que o projeto crescer, mas as mudanças devem preservar a progressão dos conceitos iniciais até o trabalho prático integrado.") + +print("Applied Review 11 contract and roadmap-scope fixes.") From a1db947e08be3a8a35b4ddf38c198ff5e0e46e45 Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Tue, 1 Sep 2026 17:45:19 -0300 Subject: [PATCH 083/117] Run File Organizer full-review fixes --- .../apply-file-organizer-full-review-11.yml | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 .github/workflows/apply-file-organizer-full-review-11.yml diff --git a/.github/workflows/apply-file-organizer-full-review-11.yml b/.github/workflows/apply-file-organizer-full-review-11.yml new file mode 100644 index 0000000..9acd873 --- /dev/null +++ b/.github/workflows/apply-file-organizer-full-review-11.yml @@ -0,0 +1,41 @@ +name: Apply File Organizer full-review fixes + +on: + push: + branches: + - phase-10-file-organizer + paths: + - scripts/_apply_file_organizer_full_review_11.py + - .github/workflows/apply-file-organizer-full-review-11.yml + +permissions: + contents: write + +jobs: + patch: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v6 + with: + ref: phase-10-file-organizer + - uses: actions/setup-python@v6 + with: + python-version: "3.13" + - name: Apply focused fixes + run: python scripts/_apply_file_organizer_full_review_11.py + - name: Validate focused project + run: | + python -m pip install --disable-pip-version-check "pytest>=9.1,<9.2" + python -m pytest -q practical-projects/06-file-organizer/tests + python -m py_compile practical-projects/06-file-organizer/file_organizer.py + git diff --check + - name: Commit clean fix set + run: | + rm scripts/_apply_file_organizer_full_review_11.py + rm .github/workflows/apply-file-organizer-full-review-11.yml + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + git commit -m "Clarify File Organizer execution identity boundary" + git push origin HEAD:phase-10-file-organizer From ce276df093d4c8c1455734d718b64bab7d09a51f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 20:45:32 +0000 Subject: [PATCH 084/117] Clarify File Organizer execution identity boundary --- .../apply-file-organizer-full-review-11.yml | 41 ----- docs/roadmap.es.md | 22 +-- docs/roadmap.pt-BR.md | 22 +-- .../06-file-organizer/README.es.md | 12 +- .../06-file-organizer/README.md | 14 +- .../06-file-organizer/README.pt-BR.md | 12 +- .../06-file-organizer/file_organizer.py | 18 ++- .../tests/test_file_organizer.py | 16 ++ .../_apply_file_organizer_full_review_11.py | 151 ------------------ 9 files changed, 71 insertions(+), 237 deletions(-) delete mode 100644 .github/workflows/apply-file-organizer-full-review-11.yml delete mode 100644 scripts/_apply_file_organizer_full_review_11.py diff --git a/.github/workflows/apply-file-organizer-full-review-11.yml b/.github/workflows/apply-file-organizer-full-review-11.yml deleted file mode 100644 index 9acd873..0000000 --- a/.github/workflows/apply-file-organizer-full-review-11.yml +++ /dev/null @@ -1,41 +0,0 @@ -name: Apply File Organizer full-review fixes - -on: - push: - branches: - - phase-10-file-organizer - paths: - - scripts/_apply_file_organizer_full_review_11.py - - .github/workflows/apply-file-organizer-full-review-11.yml - -permissions: - contents: write - -jobs: - patch: - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - uses: actions/checkout@v6 - with: - ref: phase-10-file-organizer - - uses: actions/setup-python@v6 - with: - python-version: "3.13" - - name: Apply focused fixes - run: python scripts/_apply_file_organizer_full_review_11.py - - name: Validate focused project - run: | - python -m pip install --disable-pip-version-check "pytest>=9.1,<9.2" - python -m pytest -q practical-projects/06-file-organizer/tests - python -m py_compile practical-projects/06-file-organizer/file_organizer.py - git diff --check - - name: Commit clean fix set - run: | - rm scripts/_apply_file_organizer_full_review_11.py - rm .github/workflows/apply-file-organizer-full-review-11.yml - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -A - git commit -m "Clarify File Organizer execution identity boundary" - git push origin HEAD:phase-10-file-organizer diff --git a/docs/roadmap.es.md b/docs/roadmap.es.md index ef4e744..feb7776 100644 --- a/docs/roadmap.es.md +++ b/docs/roadmap.es.md @@ -161,7 +161,7 @@ Consulta la [ruta de aprendizaje de la sección](../external-libraries/README.es - [x] [`requests`](../external-libraries/03-requests/README.es.md) - [x] [`pytest`](../external-libraries/04-pytest/README.es.md) -La Fase 9 está completada. El Capítulo 01 introduce pandas 3.0.x para datos tabulares etiquetados. El Capítulo 02 añade automatización de libros con openpyxl 3.1.x. El Capítulo 03 añade contratos HTTP/API con Requests 2.34.x. El Capítulo 04 cierra la fase con contratos de pruebas automatizadas en pytest 9.1.x, cubriendo descubrimiento, assertions, fixtures, parametrización, recursos temporales, monkeypatching, captura, marks, aislamiento determinista y CI. Los ejemplos ejecutables usan el contrato declarado en [`requirements-external.txt`](../requirements-external.txt). +La Fase 9 está completada. El Capítulo 01 introduce pandas 3.0.x para datos tabulares etiquetados. El Capítulo 02 añade automatización de libros con openpyxl 3.1.x. El Capítulo 03 añade contratos HTTP/API con Requests 2.34.x. El Capítulo 04 cierra la fase con contratos de pruebas automatizadas en pytest 9.1.x, cubriendo descubrimiento, assertions, fixtures, parametrización, recursos temporales, monkeypatching, captura, marks, aislamiento determinista y CI. Los ejemplos ejecutables de bibliotecas externas usan el contrato declarado en [`requirements-external.txt`](../requirements-external.txt). ## Fase 10: Proyectos prácticos @@ -170,13 +170,13 @@ Consulta el [índice de la sección Proyectos Prácticos](../practical-projects/ - [x] [Control de Gastos](../practical-projects/01-expense-tracker/README.es.md) - [x] [Calculadora de Notas](../practical-projects/02-grade-calculator/README.es.md) - [x] [Registro de Usuarios](../practical-projects/03-user-registration/README.es.md) -- [x] [Analizador CSV](../practical-projects/04-csv-analyzer/README.es.md) +- [x] [Analizador de CSV](../practical-projects/04-csv-analyzer/README.es.md) - [x] [Generador de Informes](../practical-projects/05-report-generator/README.es.md) - [ ] [Organizador de Archivos](../practical-projects/06-file-organizer/README.es.md) — proyecto actual - [ ] Flujo Ficticio de Conciliación - [ ] Flujo Simulado de Automatización -El Proyecto 01 establece el contrato de la Fase 10 con requisitos explícitos, modelado de datos validado, dinero exacto con `Decimal`, persistencia, demostración determinista, cobertura automatizada con pytest, desafíos de extensión y discusión de portafolio. El Proyecto 02 extiende el contrato con reglas configurables de calificación, agregación ponderada exacta, informes parcial/final explícitos y validación centrada en límites. El Proyecto 03 añade datos de identidad canónicos, normalización Unicode e IDNA, prevención de duplicados, índices secundarios de lookup, actualizaciones seguras de campos indexados, transiciones explícitas del ciclo de vida y cobertura pytest centrada en mutación sin introducir autenticación. El Proyecto 04 añade schemas CSV estrictos, conversión tipada, manejo de fallos estructurales frente a fallos por fila, parsing con éxito parcial, identificadores aceptados duplicados, agregación determinista y filtrado usando mecanismos CSV de la biblioteca estándar de forma explícita. El Proyecto 05 añade ventanas inclusivas de fechas, validación de identidad de origen, métricas de resumen exactas y deterministas, construcción inmutable de informes, renderización TXT/Markdown, escape específico del formato y salida UTF-8. El Proyecto 06 añade descubrimiento superficial determinista, planificación inmutable, categorías por sufijo, políticas explícitas de colisión, fronteras de symlink, identidad `(device, inode)`, anclaje de descriptors de raíz/categorías, nombres de staging acotados y commits atómicos no-replace sensibles a la plataforma con `renameat2(RENAME_NOREPLACE)` en Linux. +El Proyecto 01 establece el contrato de la Fase 10 con requisitos explícitos, modelado de datos validado, dinero exacto con `Decimal`, persistencia, demostración determinista, cobertura automatizada con pytest, desafíos de ampliación y discusión de portafolio. El Proyecto 02 amplía el contrato con reglas de calificación configurables, agregación ponderada exacta, informe parcial/final explícito y validación centrada en límites. El Proyecto 03 añade datos de identidad canónicos, normalización Unicode e IDNA, prevención de duplicados, índices secundarios, actualizaciones seguras y transiciones explícitas del ciclo de vida sin introducir autenticación. El Proyecto 04 añade schemas CSV estrictos, conversión tipada, separación entre fallos estructurales y fallos de fila, parsing con éxito parcial, identificadores aceptados duplicados, agregación determinista y filtros con la mecánica de la biblioteca estándar expuesta explícitamente. El Proyecto 05 añade ventanas inclusivas explícitas de fechas, validación de identidad del origen, métricas exactas y deterministas de resumen, construcción inmutable del informe, renderización TXT/Markdown, escape específico del formato y escritura UTF-8. El Proyecto 06 añade descubrimiento superficial determinista, planificación inmutable, categorías por sufijo, políticas explícitas de colisión, fronteras de symlink, identidad `(device, inode)`, anclaje de descriptors de raíz/categorías, nombres de staging acotados y commits atómicos no-replace sensibles a la plataforma con `renameat2(RENAME_NOREPLACE)` en Linux. Cada proyecto debe incluir: @@ -184,22 +184,22 @@ Cada proyecto debe incluir: - notas de diseño; - implementación; - explicación; -- cobertura automatizada para comportamientos importantes; -- desafíos de extensión; +- cobertura automatizada del comportamiento importante; +- desafíos de ampliación; - discusión de portafolio. -## Gates continuos de calidad +## Criterios continuos de calidad Cada fase debe preservar: - precisión técnica; - consistencia multilingüe; - ejemplos originales y seguros para publicación; -- datos seguros para privacidad; -- ejemplos Python ejecutables cuando corresponda; -- integridad de navegación interna; +- datos seguros desde el punto de vista de la privacidad; +- ejemplos ejecutables de Python cuando corresponda; +- integridad de la navegación interna; - atención a PEP 8; - documentación de cambios estructurales relevantes; -- supuestos honestos sobre dependencias y versiones. +- transparencia sobre dependencias y supuestos de versión. -El roadmap evolucionará a medida que crezca el proyecto, pero los cambios deben preservar la progresión desde conceptos iniciales hasta trabajo práctico integrado. +El roadmap evolucionará a medida que el proyecto crezca, pero los cambios deben preservar la progresión desde los conceptos iniciales hasta el trabajo práctico integrado. diff --git a/docs/roadmap.pt-BR.md b/docs/roadmap.pt-BR.md index 20f7ba3..a9b332c 100644 --- a/docs/roadmap.pt-BR.md +++ b/docs/roadmap.pt-BR.md @@ -150,7 +150,7 @@ Veja a [trilha de aprendizagem da seção](../standard-library/README.pt-BR.md). - [x] [`decimal`](../standard-library/08-decimal/README.pt-BR.md) - [x] [`os` e `shutil`](../standard-library/09-os-shutil/README.pt-BR.md) -A Fase 8 está concluída. Os Capítulos 01–08 constroem contratos para caminhos, data/hora, formatos estruturados, logging, coleções especializadas, iteração lazy e aritmética decimal. O Capítulo 09 encerra a fase conectando essas bases a estado do ambiente do processo, interfaces path-like, varredura e travessia de diretórios, metadados, cópia, movimento, remoção recursiva, capacidades de plataforma e segurança de archives. +A Fase 8 está concluída. Os Capítulos 01–08 constroem contratos para caminhos, data/hora, formatos estruturados, logging, coleções especializadas, iteração lazy e aritmética decimal. O Capítulo 09 encerra a fase conectando essas bases ao estado do ambiente do processo, interfaces path-like, varredura e travessia de diretórios, metadados, cópia, movimentação, exclusão recursiva, capacidades de plataforma e segurança de archives. ## Fase 9: Bibliotecas externas @@ -161,7 +161,7 @@ Veja a [trilha de aprendizagem da seção](../external-libraries/README.pt-BR.md - [x] [`requests`](../external-libraries/03-requests/README.pt-BR.md) - [x] [`pytest`](../external-libraries/04-pytest/README.pt-BR.md) -A Fase 9 está concluída. O Capítulo 01 introduz pandas 3.0.x para dados tabulares rotulados. O Capítulo 02 acrescenta automação de workbooks com openpyxl 3.1.x. O Capítulo 03 acrescenta contratos HTTP/API com Requests 2.34.x. O Capítulo 04 encerra a fase com contratos de testes automatizados em pytest 9.1.x, cobrindo descoberta, assertions, fixtures, parametrização, recursos temporários, monkeypatching, captura, marks, isolamento determinístico e CI. Os exemplos executáveis usam o contrato declarado em [`requirements-external.txt`](../requirements-external.txt). +A Fase 9 está concluída. O Capítulo 01 introduz pandas 3.0.x para dados tabulares rotulados. O Capítulo 02 acrescenta automação de workbooks com openpyxl 3.1.x. O Capítulo 03 acrescenta contratos HTTP/API com Requests 2.34.x. O Capítulo 04 encerra a fase com contratos de testes automatizados em pytest 9.1.x, cobrindo descoberta, assertions, fixtures, parametrização, recursos temporários, monkeypatching, captura, marks, isolamento determinístico e CI. Os exemplos executáveis de bibliotecas externas usam o contrato declarado em [`requirements-external.txt`](../requirements-external.txt). ## Fase 10: Projetos práticos @@ -170,13 +170,13 @@ Veja o [índice da seção Projetos Práticos](../practical-projects/README.pt-B - [x] [Controle de Despesas](../practical-projects/01-expense-tracker/README.pt-BR.md) - [x] [Calculadora de Notas](../practical-projects/02-grade-calculator/README.pt-BR.md) - [x] [Cadastro de Usuários](../practical-projects/03-user-registration/README.pt-BR.md) -- [x] [Analisador CSV](../practical-projects/04-csv-analyzer/README.pt-BR.md) +- [x] [Analisador de CSV](../practical-projects/04-csv-analyzer/README.pt-BR.md) - [x] [Gerador de Relatórios](../practical-projects/05-report-generator/README.pt-BR.md) - [ ] [Organizador de Arquivos](../practical-projects/06-file-organizer/README.pt-BR.md) — projeto atual - [ ] Fluxo Fictício de Conciliação - [ ] Fluxo Simulado de Automação -O Projeto 01 estabelece o contrato da Fase 10 com requisitos explícitos, modelagem de dados validada, dinheiro exato com `Decimal`, persistência, demonstração determinística, cobertura automatizada com pytest, desafios de extensão e discussão de portfólio. O Projeto 02 estende o contrato com regras configuráveis de notas, agregação ponderada exata, relatórios parcial/final explícitos e validação focada em fronteiras. O Projeto 03 adiciona dados de identidade canônicos, normalização Unicode e IDNA, prevenção de duplicidade, índices secundários de lookup, atualizações seguras de campos indexados, transições explícitas de ciclo de vida e cobertura pytest focada em mutação sem introduzir autenticação. O Projeto 04 adiciona schemas CSV estritos, conversão tipada, tratamento de falhas estruturais versus falhas por linha, parsing com sucesso parcial, identificadores aceitos duplicados, agregação determinística e filtragem usando mecanismos CSV da biblioteca padrão de forma explícita. O Projeto 05 adiciona janelas inclusivas de datas, validação de identidade de origem, métricas de resumo exatas e determinísticas, construção imutável de relatórios, renderização TXT/Markdown, escape específico do formato e saída UTF-8. O Projeto 06 adiciona descoberta rasa determinística, planejamento imutável, categorias por sufixo, políticas explícitas de colisão, fronteiras de symlink, identidade `(device, inode)`, ancoragem de descriptors de raiz/categorias, nomes de staging limitados e commits atômicos no-replace sensíveis à plataforma com `renameat2(RENAME_NOREPLACE)` no Linux. +O Projeto 01 estabelece o contrato da Fase 10 com requisitos explícitos, modelagem de dados validada, dinheiro exato com `Decimal`, persistência, demonstração determinística, cobertura automatizada com pytest, desafios de extensão e discussão de portfólio. O Projeto 02 amplia o contrato com regras de notas configuráveis, agregação ponderada exata, relatório parcial/final explícito e validação focada em fronteiras. O Projeto 03 adiciona dados de identidade canônicos, normalização Unicode e IDNA, prevenção de duplicidade, índices secundários, atualizações seguras e transições explícitas de ciclo de vida sem introduzir autenticação. O Projeto 04 adiciona schemas CSV rígidos, conversão tipada, separação entre falhas estruturais e falhas de linha, parsing com sucesso parcial, identificadores aceitos duplicados, agregação determinística e filtros com a mecânica da biblioteca padrão exposta explicitamente. O Projeto 05 adiciona janelas inclusivas explícitas de datas, validação da identidade da origem, métricas exatas e determinísticas de resumo, construção imutável do relatório, renderização TXT/Markdown, escape específico do formato e escrita UTF-8. O Projeto 06 adiciona descoberta rasa determinística, planejamento imutável, categorias por sufixo, políticas explícitas de colisão, fronteiras de symlink, identidade `(device, inode)`, ancoragem de descriptors de raiz/categorias, nomes de staging limitados e commits atômicos no-replace sensíveis à plataforma com `renameat2(RENAME_NOREPLACE)` no Linux. Cada projeto deve incluir: @@ -184,22 +184,22 @@ Cada projeto deve incluir: - notas de design; - implementação; - explicação; -- cobertura automatizada para comportamentos importantes; +- cobertura automatizada dos comportamentos importantes; - desafios de extensão; - discussão de portfólio. -## Gates contínuos de qualidade +## Critérios contínuos de qualidade Cada fase deve preservar: - precisão técnica; - consistência multilíngue; - exemplos originais e seguros para publicação; -- dados seguros para privacidade; +- dados seguros do ponto de vista de privacidade; - exemplos Python executáveis quando apropriado; -- integridade de navegação interna; -- atenção ao PEP 8; +- integridade da navegação interna; +- atenção à PEP 8; - documentação de mudanças estruturais relevantes; -- premissas honestas sobre dependências e versões. +- transparência sobre dependências e pressupostos de versão. -O roadmap evoluirá conforme o projeto crescer, mas as mudanças devem preservar a progressão de conceitos iniciantes para trabalho prático integrado. +O roadmap evoluirá à medida que o projeto crescer, mas as mudanças devem preservar a progressão dos conceitos iniciais até o trabalho prático integrado. diff --git a/practical-projects/06-file-organizer/README.es.md b/practical-projects/06-file-organizer/README.es.md index da2703f..68f463d 100644 --- a/practical-projects/06-file-organizer/README.es.md +++ b/practical-projects/06-file-organizer/README.es.md @@ -80,10 +80,10 @@ La implementación debe: 11. detectar colisiones de destino exactas y sin distinción de mayúsculas/minúsculas durante planificación/preflight; 12. ofrecer políticas explícitas `ERROR` y `SKIP` durante la planificación; 13. ejecutar un preflight completo; -14. capturar la identidad de los orígenes planificados; +14. vincular la identidad de cada origen cuando comienza la ejecución, no durante la planificación; 15. nunca reemplazar silenciosamente un destino exacto; 16. volver a comprobar nombres de destino equivalentes por `casefold()` inmediatamente antes del commit; -17. rechazar supuestos obsoletos sobre origen, raíz o categoría durante la ejecución; +17. rechazar cambios del origen después del vínculo de identidad de ejecución y supuestos obsoletos sobre raíz/categoría; 18. nunca ejecutar `unlink()` a ciegas sobre staging o rollback cuya identidad pueda haber cambiado; 19. devolver un resultado estructurado solo después de verificar el destino planificado. @@ -153,7 +153,7 @@ Almacena: - archivos omitidos por colisión; - symlinks hijos directos ignorados. -El plan es inmutable. Crearlo no crea directorios ni mueve archivos. +El plan es inmutable. Crearlo no crea directorios ni mueve archivos. Registra **intención de pathname/categoría**, no un descriptor abierto ni un snapshot duradero del objeto de filesystem detrás de cada pathname. Si un archivo regular se reemplaza en el mismo pathname planificado antes de que `execute_plan()` empiece a fijar los orígenes, el reemplazo es el objeto actual seleccionado por esa intención de pathname. La identidad fuerte del objeto comienza con el pinning de ejecución. ### `OrganizationResult` @@ -173,7 +173,7 @@ El movimiento recursivo introduce contratos adicionales para rutas relativas, ca observar -> decidir -> validar -> mutar ``` -La propuesta existe como datos antes de que comiencen los efectos secundarios, lo que facilita revisión y pruebas. +La propuesta existe como datos antes de que comiencen los efectos secundarios, lo que facilita revisión y pruebas. Esta separación deliberadamente **no** promete que un pathname siga nombrando el mismo objeto observado durante la planificación; conservar esa garantía exigiría mantener descriptores de origen vivos dentro del plan. En su lugar, la ejecución vincula el objeto regular actual en cada pathname planificado antes de crear categorías o mutar orígenes. ## Políticas de colisión @@ -231,7 +231,7 @@ La implementación representa identidad con: El nombre `notes.txt` es una entrada de directorio, no la identidad del objeto del filesystem. -Durante la ejecución segura en Linux, la identidad del origen se acepta **solo después de abrir el archivo** con `O_NOFOLLOW | O_NONBLOCK` cuando `O_NONBLOCK` está disponible. El `fstat()` deriva `(device, inode)` de ese descriptor ya abierto, y todos los descriptores de los orígenes planificados permanecen abiertos hasta que termina el plan. Así, un inode aceptado y luego desvinculado no puede liberarse y reutilizarse de inmediato mientras la ejecución todavía depende de su identidad. La flag nonblocking también evita que una sustitución tardía por FIFO bloquee `open()`. El pinning estabiliza la identidad del objeto, no su contenido; las escrituras concurrentes sobre el mismo inode quedan fuera de las garantías de snapshot de este proyecto. +La planificación registra intención de pathname y no identidad del objeto de origen. Por ello, un archivo regular reemplazado en el mismo pathname **antes del pinning de ejecución** se acepta como el objeto actual seleccionado por el plan. Durante la ejecución segura en Linux, la identidad del origen se acepta **solo después de abrir el archivo actual** con `O_NOFOLLOW | O_NONBLOCK` cuando `O_NONBLOCK` está disponible. El `fstat()` deriva `(device, inode)` de ese descriptor ya abierto, y todos los descriptores de los orígenes planificados permanecen abiertos hasta que termina el plan. Así, un inode aceptado y luego desvinculado no puede liberarse y reutilizarse de inmediato mientras la ejecución todavía depende de su identidad. La flag nonblocking también evita que una sustitución tardía por FIFO bloquee `open()`. El pinning estabiliza la identidad del objeto, no su contenido; las escrituras concurrentes sobre el mismo inode quedan fuera de las garantías de snapshot de este proyecto. ## Nombres de staging de longitud fija @@ -252,7 +252,7 @@ Conceptualmente: ```text 1. validar rutas y ejecutar el preflight de colisiones 2. abrir y anclar la raíz -3. abrir todos los orígenes planificados y aceptar identidad mediante `fstat()` del descriptor fijado +3. abrir el archivo regular actual en cada pathname planificado y aceptar identidad mediante `fstat()` del descriptor fijado 4. mantener abiertos todos los descriptores aceptados hasta que termine el plan 5. abrir y anclar las categorías necesarias 6. reclamar origen -> staging corto con semántica no-replace diff --git a/practical-projects/06-file-organizer/README.md b/practical-projects/06-file-organizer/README.md index a4b36d5..e4878a3 100644 --- a/practical-projects/06-file-organizer/README.md +++ b/practical-projects/06-file-organizer/README.md @@ -80,10 +80,10 @@ The implementation must: 11. detect exact and case-insensitive destination collisions during planning/preflight; 12. support explicit `ERROR` and `SKIP` planning policies; 13. run a complete execution preflight; -14. capture planned-source filesystem identity; +14. bind each source filesystem identity when execution begins, not during planning; 15. never silently replace an exact destination; 16. recheck casefold-equivalent destination names immediately before commit; -17. reject stale source, root, or category assumptions during execution; +17. reject source changes after execution-time identity binding and reject stale root/category assumptions; 18. never blindly unlink a staging or rollback entry whose identity may have changed; 19. return a structured result only after the planned destination is verified. @@ -153,7 +153,7 @@ Stores: - files skipped because of collisions; - ignored direct-child symlinks. -The plan is immutable. Creating it does not create directories and does not move files. +The plan is immutable. Creating it does not create directories and does not move files. It records **pathname/category intent**, not an open descriptor or durable snapshot of the filesystem object behind each pathname. If a regular file is replaced at the same planned pathname before `execute_plan()` begins binding sources, the replacement is the current object selected by that pathname intent. Strong object identity starts at execution-time pinning. ### `OrganizationResult` @@ -173,7 +173,7 @@ Recursive movement introduces additional contracts for relative paths, nested ca observe -> decide -> validate -> mutate ``` -The proposal exists as data before side effects begin, which makes review and testing easier. +The proposal exists as data before side effects begin, which makes review and testing easier. This separation deliberately does **not** promise that a pathname still names the identical filesystem object observed during planning; retaining that guarantee would require keeping live source descriptors inside the plan. Execution instead binds the current regular object at each planned pathname before any category creation or source mutation. ## Collision policies @@ -231,7 +231,7 @@ The implementation represents identity with: The filename `notes.txt` is a directory entry. It is not the identity of the underlying filesystem object. -During secure Linux execution, source identity is accepted **only after the source has been opened** with `O_NOFOLLOW | O_NONBLOCK` when `O_NONBLOCK` is available. The following `fstat()` derives `(device, inode)` from that already-open descriptor, and every planned source descriptor stays open until the plan finishes. An accepted inode that is later unlinked therefore cannot be freed and immediately reused while execution still depends on its identity. The nonblocking flag also prevents a late FIFO replacement from hanging `open()`. Descriptor pinning stabilizes object identity, not file contents; concurrent writes to the same inode are outside this project's snapshot guarantees. +Planning records pathname intent rather than source-object identity. Therefore a regular file replaced at the same pathname **before execution-time pinning** is accepted as the current object selected by the plan. During secure Linux execution, source identity is accepted **only after the current source has been opened** with `O_NOFOLLOW | O_NONBLOCK` when `O_NONBLOCK` is available. The following `fstat()` derives `(device, inode)` from that already-open descriptor, and every planned source descriptor stays open until the plan finishes. An accepted inode that is later unlinked therefore cannot be freed and immediately reused while execution still depends on its identity. The nonblocking flag also prevents a late FIFO replacement from hanging `open()`. Descriptor pinning stabilizes object identity, not file contents; concurrent writes to the same inode are outside this project's snapshot guarantees. ## Fixed-length staging names @@ -252,7 +252,7 @@ Conceptually: ```text 1. validate paths and collision preflight 2. open and anchor the source root -3. open every planned source and accept identity from `fstat()` on that pinned descriptor +3. open the current regular file at every planned pathname and accept identity from `fstat()` on that pinned descriptor 4. keep all accepted source descriptors open through plan completion 5. open and anchor required category directories 6. claim source name -> short internal stage with no-replace semantics @@ -300,7 +300,7 @@ A safety-oriented example should fail honestly instead of silently downgrading i 3. category-path revalidation; 4. destination collision preflight; 5. platform capability selection; -6. Linux: pin every planned source before accepting identity and before category mutation; +6. Linux: bind the current regular file at every planned pathname by pinning it before accepting identity and before category mutation; 7. anchored directory setup; 8. source claim; 9. mutation-time casefold collision recheck; diff --git a/practical-projects/06-file-organizer/README.pt-BR.md b/practical-projects/06-file-organizer/README.pt-BR.md index 336a642..4423d04 100644 --- a/practical-projects/06-file-organizer/README.pt-BR.md +++ b/practical-projects/06-file-organizer/README.pt-BR.md @@ -80,10 +80,10 @@ A implementação deve: 11. detectar colisões de destino exatas e sem diferenciação de caixa durante planejamento/preflight; 12. oferecer políticas explícitas `ERROR` e `SKIP` durante o planejamento; 13. executar um preflight completo; -14. capturar a identidade das origens planejadas; +14. vincular a identidade de cada origem quando a execução começa, e não durante o planejamento; 15. nunca substituir silenciosamente um destino exato; 16. revalidar nomes de destino equivalentes por `casefold()` imediatamente antes do commit; -17. rejeitar premissas obsoletas sobre origem, raiz ou categoria durante a execução; +17. rejeitar mudanças da origem após o vínculo de identidade da execução e premissas obsoletas sobre raiz/categoria; 18. nunca executar `unlink()` cegamente em staging ou rollback cuja identidade possa ter mudado; 19. retornar resultado estruturado apenas após verificar o destino planejado. @@ -153,7 +153,7 @@ Armazena: - arquivos ignorados por colisão; - symlinks filhos diretos ignorados. -O plano é imutável. Criá-lo não cria diretórios e não move arquivos. +O plano é imutável. Criá-lo não cria diretórios e não move arquivos. Ele registra **intenção de pathname/categoria**, e não um descriptor aberto ou snapshot durável do objeto de filesystem por trás de cada pathname. Se um arquivo regular for substituído no mesmo pathname planejado antes de `execute_plan()` começar a pinar as origens, a substituição é o objeto atual selecionado por essa intenção de pathname. A identidade forte do objeto começa no pinning da execução. ### `OrganizationResult` @@ -173,7 +173,7 @@ Movimento recursivo introduz contratos adicionais para caminhos relativos, categ observar -> decidir -> validar -> alterar ``` -A proposta existe como dados antes de os efeitos colaterais começarem, facilitando revisão e testes. +A proposta existe como dados antes de os efeitos colaterais começarem, facilitando revisão e testes. Essa separação deliberadamente **não** promete que um pathname ainda nomeie o mesmo objeto observado durante o planejamento; manter essa garantia exigiria descriptors de origem vivos dentro do plano. Em vez disso, a execução vincula o objeto regular atual em cada pathname planejado antes de criar categorias ou alterar origens. ## Políticas de colisão @@ -231,7 +231,7 @@ A implementação representa identidade com: O nome `notes.txt` é uma entrada de diretório, não a identidade do objeto do filesystem. -Durante a execução segura no Linux, a identidade da origem só é aceita **depois que o arquivo já foi aberto** com `O_NOFOLLOW | O_NONBLOCK` quando `O_NONBLOCK` está disponível. O `fstat()` deriva `(device, inode)` desse descriptor já aberto, e todos os descriptors das origens planejadas permanecem abertos até o fim do plano. Assim, um inode aceito e depois desvinculado não pode ser liberado e imediatamente reutilizado enquanto a execução ainda depende da sua identidade. A flag nonblocking também impede que uma substituição tardia por FIFO trave o `open()`. O pinning estabiliza a identidade do objeto, não o conteúdo; escritas concorrentes no mesmo inode ficam fora das garantias de snapshot deste projeto. +O planejamento registra intenção de pathname, e não identidade do objeto de origem. Portanto, um arquivo regular substituído no mesmo pathname **antes do pinning da execução** é aceito como o objeto atual selecionado pelo plano. Durante a execução segura no Linux, a identidade da origem só é aceita **depois que o arquivo atual já foi aberto** com `O_NOFOLLOW | O_NONBLOCK` quando `O_NONBLOCK` está disponível. O `fstat()` deriva `(device, inode)` desse descriptor já aberto, e todos os descriptors das origens planejadas permanecem abertos até o fim do plano. Assim, um inode aceito e depois desvinculado não pode ser liberado e imediatamente reutilizado enquanto a execução ainda depende da sua identidade. A flag nonblocking também impede que uma substituição tardia por FIFO trave o `open()`. O pinning estabiliza a identidade do objeto, não o conteúdo; escritas concorrentes no mesmo inode ficam fora das garantias de snapshot deste projeto. ## Nomes de staging com tamanho fixo @@ -252,7 +252,7 @@ Conceitualmente: ```text 1. validar caminhos e executar o preflight de colisões 2. abrir e ancorar a raiz -3. abrir todas as origens planejadas e aceitar identidade pelo `fstat()` do descriptor pinado +3. abrir o arquivo regular atual em cada pathname planejado e aceitar identidade pelo `fstat()` do descriptor pinado 4. manter todos os descriptors aceitos abertos até o fim do plano 5. abrir e ancorar as categorias necessárias 6. reivindicar origem -> staging curto com semântica no-replace diff --git a/practical-projects/06-file-organizer/file_organizer.py b/practical-projects/06-file-organizer/file_organizer.py index af2e0d1..813435d 100644 --- a/practical-projects/06-file-organizer/file_organizer.py +++ b/practical-projects/06-file-organizer/file_organizer.py @@ -173,7 +173,7 @@ def __post_init__(self) -> None: @dataclass(frozen=True, slots=True) class OrganizationPlan: - """Immutable organization plan produced before filesystem mutation.""" + """Immutable pathname-intent plan produced before filesystem mutation.""" source_directory: Path actions: tuple[MoveAction, ...] @@ -348,7 +348,7 @@ def plan_organization( *, collision_policy: CollisionPolicy = CollisionPolicy.ERROR, ) -> OrganizationPlan: - """Build a deterministic, non-mutating plan for direct child files.""" + """Build a deterministic, non-mutating pathname-intent plan.""" root = _require_source_directory(source_directory) if not isinstance(collision_policy, CollisionPolicy): raise TypeError("collision_policy must be a CollisionPolicy") @@ -559,7 +559,12 @@ def _pin_planned_sources_at( *, root_fd: int, ) -> dict[Path, _PinnedSource]: - """Pin every source before category creation or source mutation.""" + """Pin the current regular file at every planned pathname before mutation. + + OrganizationPlan intentionally stores pathname/category intent, not live file + descriptors or a durable filesystem-object snapshot. Identity becomes strong + only when execute_plan opens each pathname and accepts fstat() on that pin. + """ pinned: dict[Path, _PinnedSource] = {} try: for action in plan.actions: @@ -1055,7 +1060,12 @@ def _execute_plan_portable( def execute_plan(plan: OrganizationPlan) -> OrganizationResult: - """Execute a plan under the strongest explicitly supported platform contract.""" + """Execute pathname intent under the strongest supported platform contract. + + A plan does not freeze source-object identity between planning and execution. + The current regular file at each planned pathname is bound when execution + starts; changes after that binding are rejected under the platform contract. + """ if not isinstance(plan, OrganizationPlan): raise TypeError("plan must be an OrganizationPlan") diff --git a/practical-projects/06-file-organizer/tests/test_file_organizer.py b/practical-projects/06-file-organizer/tests/test_file_organizer.py index 3a8c813..b53ea37 100644 --- a/practical-projects/06-file-organizer/tests/test_file_organizer.py +++ b/practical-projects/06-file-organizer/tests/test_file_organizer.py @@ -357,6 +357,22 @@ def test_execute_plan_preflights_missing_source_before_mutation(tmp_path: Path) assert not (tmp_path / "data").exists() +def test_execute_plan_binds_current_source_at_execution_start(tmp_path: Path) -> None: + source = tmp_path / "notes.txt" + source.write_text("observed during planning", encoding="utf-8") + plan = plan_organization(tmp_path) + + source.unlink() + source.write_text("current at execution start", encoding="utf-8") + + result = execute_plan(plan) + + destination = tmp_path / "documents" / "notes.txt" + assert result.moved_files == (destination,) + assert destination.read_text(encoding="utf-8") == "current at execution start" + assert not source.exists() + + def test_execute_plan_preflights_new_exact_collision_before_mutation(tmp_path: Path) -> None: first = tmp_path / "a.txt" second = tmp_path / "b.csv" diff --git a/scripts/_apply_file_organizer_full_review_11.py b/scripts/_apply_file_organizer_full_review_11.py deleted file mode 100644 index d1be953..0000000 --- a/scripts/_apply_file_organizer_full_review_11.py +++ /dev/null @@ -1,151 +0,0 @@ -from pathlib import Path - - -def replace_once(path: str, old: str, new: str) -> None: - file_path = Path(path) - text = file_path.read_text(encoding="utf-8") - count = text.count(old) - if count != 1: - raise RuntimeError(f"expected one match in {path}, found {count}: {old[:80]!r}") - file_path.write_text(text.replace(old, new), encoding="utf-8") - - -source = "practical-projects/06-file-organizer/file_organizer.py" -replace_once( - source, - ' """Immutable organization plan produced before filesystem mutation."""', - ' """Immutable pathname-intent plan produced before filesystem mutation."""', -) -replace_once( - source, - ' """Build a deterministic, non-mutating plan for direct child files."""', - ' """Build a deterministic, non-mutating pathname-intent plan."""', -) -replace_once( - source, - ' """Pin every source before category creation or source mutation."""', - ' """Pin the current regular file at every planned pathname before mutation.\n\n OrganizationPlan intentionally stores pathname/category intent, not live file\n descriptors or a durable filesystem-object snapshot. Identity becomes strong\n only when execute_plan opens each pathname and accepts fstat() on that pin.\n """', -) -replace_once( - source, - ' """Execute a plan under the strongest explicitly supported platform contract."""', - ' """Execute pathname intent under the strongest supported platform contract.\n\n A plan does not freeze source-object identity between planning and execution.\n The current regular file at each planned pathname is bound when execution\n starts; changes after that binding are rejected under the platform contract.\n """', -) - -# Add a platform-neutral regression that makes the plan/execution identity boundary explicit. -test_path = "practical-projects/06-file-organizer/tests/test_file_organizer.py" -anchor = '''def test_execute_plan_preflights_missing_source_before_mutation(tmp_path: Path) -> None:\n first = tmp_path / "a.txt"\n second = tmp_path / "b.csv"\n first.write_text("a", encoding="utf-8")\n second.write_text("b", encoding="utf-8")\n plan = plan_organization(tmp_path)\n second.unlink()\n\n with pytest.raises(FileNotFoundError):\n execute_plan(plan)\n\n assert first.exists()\n assert not (tmp_path / "documents").exists()\n assert not (tmp_path / "data").exists()\n\n\n''' -addition = anchor + '''def test_execute_plan_binds_current_source_at_execution_start(tmp_path: Path) -> None:\n source = tmp_path / "notes.txt"\n source.write_text("observed during planning", encoding="utf-8")\n plan = plan_organization(tmp_path)\n\n source.unlink()\n source.write_text("current at execution start", encoding="utf-8")\n\n result = execute_plan(plan)\n\n destination = tmp_path / "documents" / "notes.txt"\n assert result.moved_files == (destination,)\n assert destination.read_text(encoding="utf-8") == "current at execution start"\n assert not source.exists()\n\n\n''' -replace_once(test_path, anchor, addition) - -# English contract wording. -readme = "practical-projects/06-file-organizer/README.md" -replace_once(readme, "14. capture planned-source filesystem identity;", "14. bind each source filesystem identity when execution begins, not during planning;") -replace_once(readme, "17. reject stale source, root, or category assumptions during execution;", "17. reject source changes after execution-time identity binding and reject stale root/category assumptions;") -replace_once( - readme, - "The plan is immutable. Creating it does not create directories and does not move files.", - "The plan is immutable. Creating it does not create directories and does not move files. It records **pathname/category intent**, not an open descriptor or durable snapshot of the filesystem object behind each pathname. If a regular file is replaced at the same planned pathname before `execute_plan()` begins binding sources, the replacement is the current object selected by that pathname intent. Strong object identity starts at execution-time pinning.", -) -replace_once( - readme, - "The proposal exists as data before side effects begin, which makes review and testing easier.", - "The proposal exists as data before side effects begin, which makes review and testing easier. This separation deliberately does **not** promise that a pathname still names the identical filesystem object observed during planning; retaining that guarantee would require keeping live source descriptors inside the plan. Execution instead binds the current regular object at each planned pathname before any category creation or source mutation.", -) -replace_once( - readme, - "During secure Linux execution, source identity is accepted **only after the source has been opened** with `O_NOFOLLOW | O_NONBLOCK` when `O_NONBLOCK` is available.", - "Planning records pathname intent rather than source-object identity. Therefore a regular file replaced at the same pathname **before execution-time pinning** is accepted as the current object selected by the plan. During secure Linux execution, source identity is accepted **only after the current source has been opened** with `O_NOFOLLOW | O_NONBLOCK` when `O_NONBLOCK` is available.", -) -replace_once(readme, "3. open every planned source and accept identity from `fstat()` on that pinned descriptor", "3. open the current regular file at every planned pathname and accept identity from `fstat()` on that pinned descriptor") -replace_once(readme, "6. Linux: pin every planned source before accepting identity and before category mutation;", "6. Linux: bind the current regular file at every planned pathname by pinning it before accepting identity and before category mutation;") - -# Portuguese contract wording. -readme = "practical-projects/06-file-organizer/README.pt-BR.md" -replace_once(readme, "14. capturar a identidade das origens planejadas;", "14. vincular a identidade de cada origem quando a execução começa, e não durante o planejamento;") -replace_once(readme, "17. rejeitar premissas obsoletas sobre origem, raiz ou categoria durante a execução;", "17. rejeitar mudanças da origem após o vínculo de identidade da execução e premissas obsoletas sobre raiz/categoria;") -replace_once( - readme, - "O plano é imutável. Criá-lo não cria diretórios e não move arquivos.", - "O plano é imutável. Criá-lo não cria diretórios e não move arquivos. Ele registra **intenção de pathname/categoria**, e não um descriptor aberto ou snapshot durável do objeto de filesystem por trás de cada pathname. Se um arquivo regular for substituído no mesmo pathname planejado antes de `execute_plan()` começar a pinar as origens, a substituição é o objeto atual selecionado por essa intenção de pathname. A identidade forte do objeto começa no pinning da execução.", -) -replace_once( - readme, - "A proposta existe como dados antes de os efeitos colaterais começarem, facilitando revisão e testes.", - "A proposta existe como dados antes de os efeitos colaterais começarem, facilitando revisão e testes. Essa separação deliberadamente **não** promete que um pathname ainda nomeie o mesmo objeto observado durante o planejamento; manter essa garantia exigiria descriptors de origem vivos dentro do plano. Em vez disso, a execução vincula o objeto regular atual em cada pathname planejado antes de criar categorias ou alterar origens.", -) -replace_once( - readme, - "Durante a execução segura no Linux, a identidade da origem só é aceita **depois que o arquivo já foi aberto** com `O_NOFOLLOW | O_NONBLOCK` quando `O_NONBLOCK` está disponível.", - "O planejamento registra intenção de pathname, e não identidade do objeto de origem. Portanto, um arquivo regular substituído no mesmo pathname **antes do pinning da execução** é aceito como o objeto atual selecionado pelo plano. Durante a execução segura no Linux, a identidade da origem só é aceita **depois que o arquivo atual já foi aberto** com `O_NOFOLLOW | O_NONBLOCK` quando `O_NONBLOCK` está disponível.", -) -replace_once(readme, "3. abrir todas as origens planejadas e aceitar identidade pelo `fstat()` do descriptor pinado", "3. abrir o arquivo regular atual em cada pathname planejado e aceitar identidade pelo `fstat()` do descriptor pinado") -replace_once(readme, "6. Linux: pin every planned source before accepting identity and before category mutation;", "6. Linux: vincular o arquivo regular atual em cada pathname planejado, pinando-o antes de aceitar identidade e antes da mutação de categorias;") if False else None -# The Portuguese execution-flow sentence is localized differently; patch it only if present. -pt_text = Path(readme).read_text(encoding="utf-8") -old_pt = "6. Linux: pinar cada origem planejada antes de aceitar identidade e antes da mutação de categorias;" -if old_pt in pt_text: - replace_once(readme, old_pt, "6. Linux: vincular o arquivo regular atual em cada pathname planejado, pinando-o antes de aceitar identidade e antes da mutação de categorias;") - -# Spanish contract wording. -readme = "practical-projects/06-file-organizer/README.es.md" -replace_once(readme, "14. capturar la identidad de los orígenes planificados;", "14. vincular la identidad de cada origen cuando comienza la ejecución, no durante la planificación;") -replace_once(readme, "17. rechazar supuestos obsoletos sobre origen, raíz o categoría durante la ejecución;", "17. rechazar cambios del origen después del vínculo de identidad de ejecución y supuestos obsoletos sobre raíz/categoría;") -replace_once( - readme, - "El plan es inmutable. Crearlo no crea directorios ni mueve archivos.", - "El plan es inmutable. Crearlo no crea directorios ni mueve archivos. Registra **intención de pathname/categoría**, no un descriptor abierto ni un snapshot duradero del objeto de filesystem detrás de cada pathname. Si un archivo regular se reemplaza en el mismo pathname planificado antes de que `execute_plan()` empiece a fijar los orígenes, el reemplazo es el objeto actual seleccionado por esa intención de pathname. La identidad fuerte del objeto comienza con el pinning de ejecución.", -) -replace_once( - readme, - "La propuesta existe como datos antes de que comiencen los efectos secundarios, lo que facilita revisión y pruebas.", - "La propuesta existe como datos antes de que comiencen los efectos secundarios, lo que facilita revisión y pruebas. Esta separación deliberadamente **no** promete que un pathname siga nombrando el mismo objeto observado durante la planificación; conservar esa garantía exigiría mantener descriptores de origen vivos dentro del plan. En su lugar, la ejecución vincula el objeto regular actual en cada pathname planificado antes de crear categorías o mutar orígenes.", -) -replace_once( - readme, - "Durante la ejecución segura en Linux, la identidad del origen se acepta **solo después de abrir el archivo** con `O_NOFOLLOW | O_NONBLOCK` cuando `O_NONBLOCK` está disponible.", - "La planificación registra intención de pathname y no identidad del objeto de origen. Por ello, un archivo regular reemplazado en el mismo pathname **antes del pinning de ejecución** se acepta como el objeto actual seleccionado por el plan. Durante la ejecución segura en Linux, la identidad del origen se acepta **solo después de abrir el archivo actual** con `O_NOFOLLOW | O_NONBLOCK` cuando `O_NONBLOCK` está disponible.", -) -replace_once(readme, "3. abrir todos los orígenes planificados y aceptar identidad mediante `fstat()` del descriptor fijado", "3. abrir el archivo regular actual en cada pathname planificado y aceptar identidad mediante `fstat()` del descriptor fijado") -es_text = Path(readme).read_text(encoding="utf-8") -old_es = "6. Linux: fijar cada origen planificado antes de aceptar identidad y antes de mutar categorías;" -if old_es in es_text: - replace_once(readme, old_es, "6. Linux: vincular el archivo regular actual en cada pathname planificado, fijándolo antes de aceptar identidad y antes de mutar categorías;") - -# Revert unrelated Spanish roadmap rewrites while keeping Project 06 additions. -es = "docs/roadmap.es.md" -replace_once(es, "Los ejemplos ejecutables usan el contrato declarado en [`requirements-external.txt`](../requirements-external.txt).", "Los ejemplos ejecutables de bibliotecas externas usan el contrato declarado en [`requirements-external.txt`](../requirements-external.txt).") -replace_once(es, "- [x] [Analizador CSV](../practical-projects/04-csv-analyzer/README.es.md)", "- [x] [Analizador de CSV](../practical-projects/04-csv-analyzer/README.es.md)") -replace_once( - es, - "El Proyecto 01 establece el contrato de la Fase 10 con requisitos explícitos, modelado de datos validado, dinero exacto con `Decimal`, persistencia, demostración determinista, cobertura automatizada con pytest, desafíos de extensión y discusión de portafolio. El Proyecto 02 extiende el contrato con reglas configurables de calificación, agregación ponderada exacta, informes parcial/final explícitos y validación centrada en límites. El Proyecto 03 añade datos de identidad canónicos, normalización Unicode e IDNA, prevención de duplicados, índices secundarios de lookup, actualizaciones seguras de campos indexados, transiciones explícitas del ciclo de vida y cobertura pytest centrada en mutación sin introducir autenticación. El Proyecto 04 añade schemas CSV estrictos, conversión tipada, manejo de fallos estructurales frente a fallos por fila, parsing con éxito parcial, identificadores aceptados duplicados, agregación determinista y filtrado usando mecanismos CSV de la biblioteca estándar de forma explícita. El Proyecto 05 añade ventanas inclusivas de fechas, validación de identidad de origen, métricas de resumen exactas y deterministas, construcción inmutable de informes, renderización TXT/Markdown, escape específico del formato y salida UTF-8. El Proyecto 06 añade descubrimiento superficial determinista, planificación inmutable, categorías por sufijo, políticas explícitas de colisión, fronteras de symlink, identidad `(device, inode)`, anclaje de descriptors de raíz/categorías, nombres de staging acotados y commits atómicos no-replace sensibles a la plataforma con `renameat2(RENAME_NOREPLACE)` en Linux.", - "El Proyecto 01 establece el contrato de la Fase 10 con requisitos explícitos, modelado de datos validado, dinero exacto con `Decimal`, persistencia, demostración determinista, cobertura automatizada con pytest, desafíos de ampliación y discusión de portafolio. El Proyecto 02 amplía el contrato con reglas de calificación configurables, agregación ponderada exacta, informe parcial/final explícito y validación centrada en límites. El Proyecto 03 añade datos de identidad canónicos, normalización Unicode e IDNA, prevención de duplicados, índices secundarios, actualizaciones seguras y transiciones explícitas del ciclo de vida sin introducir autenticación. El Proyecto 04 añade schemas CSV estrictos, conversión tipada, separación entre fallos estructurales y fallos de fila, parsing con éxito parcial, identificadores aceptados duplicados, agregación determinista y filtros con la mecánica de la biblioteca estándar expuesta explícitamente. El Proyecto 05 añade ventanas inclusivas explícitas de fechas, validación de identidad del origen, métricas exactas y deterministas de resumen, construcción inmutable del informe, renderización TXT/Markdown, escape específico del formato y escritura UTF-8. El Proyecto 06 añade descubrimiento superficial determinista, planificación inmutable, categorías por sufijo, políticas explícitas de colisión, fronteras de symlink, identidad `(device, inode)`, anclaje de descriptors de raíz/categorías, nombres de staging acotados y commits atómicos no-replace sensibles a la plataforma con `renameat2(RENAME_NOREPLACE)` en Linux.", -) -replace_once(es, "- cobertura automatizada para comportamientos importantes;", "- cobertura automatizada del comportamiento importante;") -replace_once(es, "- desafíos de extensión;", "- desafíos de ampliación;") -replace_once(es, "## Gates continuos de calidad", "## Criterios continuos de calidad") -replace_once(es, "- datos seguros para privacidad;", "- datos seguros desde el punto de vista de la privacidad;") -replace_once(es, "- ejemplos Python ejecutables cuando corresponda;", "- ejemplos ejecutables de Python cuando corresponda;") -replace_once(es, "- integridad de navegación interna;", "- integridad de la navegación interna;") -replace_once(es, "- supuestos honestos sobre dependencias y versiones.", "- transparencia sobre dependencias y supuestos de versión.") -replace_once(es, "El roadmap evolucionará a medida que crezca el proyecto, pero los cambios deben preservar la progresión desde conceptos iniciales hasta trabajo práctico integrado.", "El roadmap evolucionará a medida que el proyecto crezca, pero los cambios deben preservar la progresión desde los conceptos iniciales hasta el trabajo práctico integrado.") - -# Revert unrelated Portuguese roadmap rewrites while keeping Project 06 additions. -pt = "docs/roadmap.pt-BR.md" -replace_once(pt, "O Capítulo 09 encerra a fase conectando essas bases a estado do ambiente do processo, interfaces path-like, varredura e travessia de diretórios, metadados, cópia, movimento, remoção recursiva, capacidades de plataforma e segurança de archives.", "O Capítulo 09 encerra a fase conectando essas bases ao estado do ambiente do processo, interfaces path-like, varredura e travessia de diretórios, metadados, cópia, movimentação, exclusão recursiva, capacidades de plataforma e segurança de archives.") -replace_once(pt, "Os exemplos executáveis usam o contrato declarado em [`requirements-external.txt`](../requirements-external.txt).", "Os exemplos executáveis de bibliotecas externas usam o contrato declarado em [`requirements-external.txt`](../requirements-external.txt).") -replace_once(pt, "- [x] [Analisador CSV](../practical-projects/04-csv-analyzer/README.pt-BR.md)", "- [x] [Analisador de CSV](../practical-projects/04-csv-analyzer/README.pt-BR.md)") -replace_once( - pt, - "O Projeto 01 estabelece o contrato da Fase 10 com requisitos explícitos, modelagem de dados validada, dinheiro exato com `Decimal`, persistência, demonstração determinística, cobertura automatizada com pytest, desafios de extensão e discussão de portfólio. O Projeto 02 estende o contrato com regras configuráveis de notas, agregação ponderada exata, relatórios parcial/final explícitos e validação focada em fronteiras. O Projeto 03 adiciona dados de identidade canônicos, normalização Unicode e IDNA, prevenção de duplicidade, índices secundários de lookup, atualizações seguras de campos indexados, transições explícitas de ciclo de vida e cobertura pytest focada em mutação sem introduzir autenticação. O Projeto 04 adiciona schemas CSV estritos, conversão tipada, tratamento de falhas estruturais versus falhas por linha, parsing com sucesso parcial, identificadores aceitos duplicados, agregação determinística e filtragem usando mecanismos CSV da biblioteca padrão de forma explícita. O Projeto 05 adiciona janelas inclusivas de datas, validação de identidade de origem, métricas de resumo exatas e determinísticas, construção imutável de relatórios, renderização TXT/Markdown, escape específico do formato e saída UTF-8. O Projeto 06 adiciona descoberta rasa determinística, planejamento imutável, categorias por sufixo, políticas explícitas de colisão, fronteiras de symlink, identidade `(device, inode)`, ancoragem de descriptors de raiz/categorias, nomes de staging limitados e commits atômicos no-replace sensíveis à plataforma com `renameat2(RENAME_NOREPLACE)` no Linux.", - "O Projeto 01 estabelece o contrato da Fase 10 com requisitos explícitos, modelagem de dados validada, dinheiro exato com `Decimal`, persistência, demonstração determinística, cobertura automatizada com pytest, desafios de extensão e discussão de portfólio. O Projeto 02 amplia o contrato com regras de notas configuráveis, agregação ponderada exata, relatório parcial/final explícito e validação focada em fronteiras. O Projeto 03 adiciona dados de identidade canônicos, normalização Unicode e IDNA, prevenção de duplicidade, índices secundários, atualizações seguras e transições explícitas de ciclo de vida sem introduzir autenticação. O Projeto 04 adiciona schemas CSV rígidos, conversão tipada, separação entre falhas estruturais e falhas de linha, parsing com sucesso parcial, identificadores aceitos duplicados, agregação determinística e filtros com a mecânica da biblioteca padrão exposta explicitamente. O Projeto 05 adiciona janelas inclusivas explícitas de datas, validação da identidade da origem, métricas exatas e determinísticas de resumo, construção imutável do relatório, renderização TXT/Markdown, escape específico do formato e escrita UTF-8. O Projeto 06 adiciona descoberta rasa determinística, planejamento imutável, categorias por sufixo, políticas explícitas de colisão, fronteiras de symlink, identidade `(device, inode)`, ancoragem de descriptors de raiz/categorias, nomes de staging limitados e commits atômicos no-replace sensíveis à plataforma com `renameat2(RENAME_NOREPLACE)` no Linux.", -) -replace_once(pt, "- cobertura automatizada para comportamentos importantes;", "- cobertura automatizada dos comportamentos importantes;") -replace_once(pt, "## Gates contínuos de qualidade", "## Critérios contínuos de qualidade") -replace_once(pt, "- dados seguros para privacidade;", "- dados seguros do ponto de vista de privacidade;") -replace_once(pt, "- integridade de navegação interna;", "- integridade da navegação interna;") -replace_once(pt, "- atenção ao PEP 8;", "- atenção à PEP 8;") -replace_once(pt, "- premissas honestas sobre dependências e versões.", "- transparência sobre dependências e pressupostos de versão.") -replace_once(pt, "O roadmap evoluirá conforme o projeto crescer, mas as mudanças devem preservar a progressão de conceitos iniciantes para trabalho prático integrado.", "O roadmap evoluirá à medida que o projeto crescer, mas as mudanças devem preservar a progressão dos conceitos iniciais até o trabalho prático integrado.") - -print("Applied Review 11 contract and roadmap-scope fixes.") From 0ab037258dcf6a962d6084e9db913cc2b3d3bad6 Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Tue, 1 Sep 2026 17:46:42 -0300 Subject: [PATCH 085/117] Trigger File Organizer quality checks --- scripts/_ci_trigger_file_organizer.txt | 1 + 1 file changed, 1 insertion(+) create mode 100644 scripts/_ci_trigger_file_organizer.txt diff --git a/scripts/_ci_trigger_file_organizer.txt b/scripts/_ci_trigger_file_organizer.txt new file mode 100644 index 0000000..92f6423 --- /dev/null +++ b/scripts/_ci_trigger_file_organizer.txt @@ -0,0 +1 @@ +temporary CI trigger From 1e7da0fff3a830249c83f9950fd789b5a924180a Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Tue, 1 Sep 2026 17:46:58 -0300 Subject: [PATCH 086/117] Remove temporary File Organizer CI trigger --- scripts/_ci_trigger_file_organizer.txt | 1 - 1 file changed, 1 deletion(-) delete mode 100644 scripts/_ci_trigger_file_organizer.txt diff --git a/scripts/_ci_trigger_file_organizer.txt b/scripts/_ci_trigger_file_organizer.txt deleted file mode 100644 index 92f6423..0000000 --- a/scripts/_ci_trigger_file_organizer.txt +++ /dev/null @@ -1 +0,0 @@ -temporary CI trigger From 4910c781de439c994ccf80c585ca9196b9de8cfd Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Tue, 1 Sep 2026 17:50:43 -0300 Subject: [PATCH 087/117] --- does-not-exist | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 does-not-exist diff --git a/does-not-exist b/does-not-exist new file mode 100644 index 0000000..e69de29 From 8e01b7e40142b50486e25f8a4eae2107d2828390 Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Tue, 1 Sep 2026 17:51:13 -0300 Subject: [PATCH 088/117] Remove accidental maintenance artifact --- does-not-exist | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 does-not-exist diff --git a/does-not-exist b/does-not-exist deleted file mode 100644 index e69de29..0000000 From aaf004a0f88cd757b28d7bdf2e36818ae2b02148 Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Tue, 1 Sep 2026 17:52:24 -0300 Subject: [PATCH 089/117] --- does-not-exist-again | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 does-not-exist-again diff --git a/does-not-exist-again b/does-not-exist-again new file mode 100644 index 0000000..e69de29 From 35d4db394033fb276ef08fe513b306729bc1b05f Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Tue, 1 Sep 2026 17:52:36 -0300 Subject: [PATCH 090/117] Remove accidental PR metadata artifact --- does-not-exist-again | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 does-not-exist-again diff --git a/does-not-exist-again b/does-not-exist-again deleted file mode 100644 index e69de29..0000000 From 53c825d02ffcf5958a0f13a4ea3064fe7d353744 Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Tue, 1 Sep 2026 18:02:58 -0300 Subject: [PATCH 091/117] Apply Review 12 pinned-source recovery fix --- .github/workflows/apply-review12-recovery.yml | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 .github/workflows/apply-review12-recovery.yml diff --git a/.github/workflows/apply-review12-recovery.yml b/.github/workflows/apply-review12-recovery.yml new file mode 100644 index 0000000..6b3c349 --- /dev/null +++ b/.github/workflows/apply-review12-recovery.yml @@ -0,0 +1,88 @@ +name: Apply Review 12 recovery fix + +on: + push: + branches: + - phase-10-file-organizer + +permissions: + contents: write + +jobs: + patch: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + steps: + - name: Check out branch + uses: actions/checkout@v6 + with: + ref: phase-10-file-organizer + fetch-depth: 0 + + - name: Apply focused Review 12 patch + shell: bash + run: | + python - <<'PY' + from pathlib import Path + + core_path = Path('practical-projects/06-file-organizer/file_organizer.py') + text = core_path.read_text(encoding='utf-8') + + old_helper = '''def _preserve_stage_at(stage_name: str, source_name: str, *, root_fd: int) -> None:\n """Best-effort restore by linking only; never delete a raced staging entry."""\n try:\n os.link(\n stage_name,\n source_name,\n src_dir_fd=root_fd,\n dst_dir_fd=root_fd,\n follow_symlinks=False,\n )\n except OSError:\n pass\n\n\n''' + new_helper = old_helper + '''def _preserve_claimed_source_after_failure_at(\n stage_name: str,\n source_name: str,\n *,\n root_fd: int,\n source_fd: int,\n expected_identity: _FileIdentity,\n) -> str | None:\n """Restore a proven stage or recover pinned bytes when stage identity is uncertain."""\n try:\n staged_identity = _regular_identity_at(stage_name, directory_fd=root_fd)\n except OSError:\n staged_identity = None\n\n if staged_identity == expected_identity:\n _preserve_stage_at(stage_name, source_name, root_fd=root_fd)\n return None\n\n return _recover_pinned_source_at(\n source_fd,\n source_name,\n root_fd=root_fd,\n )\n\n\n''' + if old_helper not in text: + raise SystemExit('helper anchor not found') + text = text.replace(old_helper, new_helper, 1) + + old_except = ''' except (FileExistsError, FileNotFoundError, ValueError, OSError):\n _preserve_stage_at(stage_name, source_name, root_fd=source_directory_fd)\n raise\n''' + new_except = ''' except (FileExistsError, FileNotFoundError, ValueError, OSError) as exc:\n recovery_name = _preserve_claimed_source_after_failure_at(\n stage_name,\n source_name,\n root_fd=source_directory_fd,\n source_fd=source_fd,\n expected_identity=expected_identity,\n )\n if recovery_name is not None:\n exc.add_note(\n "planned source data retained as "\n f"{recovery_name}: {source_name}"\n )\n raise\n''' + if old_except not in text: + raise SystemExit('move failure anchor not found') + text = text.replace(old_except, new_except, 1) + core_path.write_text(text, encoding='utf-8') + + test_path = Path('practical-projects/06-file-organizer/tests/test_atomic_move.py') + tests = test_path.read_text(encoding='utf-8') + anchor = '''def test_secure_execution_reports_readability_precondition_before_categories(\n''' + new_test = '''def test_failed_final_rename_after_stage_replacement_recovers_pinned_source_data(\n monkeypatch: pytest.MonkeyPatch,\n tmp_path: Path,\n) -> None:\n if not file_organizer._supports_secure_directory_fds():\n pytest.skip("secure directory descriptors are unavailable on this platform")\n\n source = tmp_path / "notes.txt"\n source.write_text("planned source", encoding="utf-8")\n plan = plan_organization(tmp_path)\n destination = tmp_path / "documents" / "notes.txt"\n original_rename_no_replace = file_organizer._rename_no_replace_at\n raced = False\n\n def racing_rename_no_replace(\n source_name: str,\n destination_name: str,\n *,\n source_directory_fd: int,\n destination_directory_fd: int,\n ) -> None:\n nonlocal raced\n if source_name.startswith(".fo-stage-") and not raced:\n raced = True\n stage = tmp_path / source_name\n stage.unlink()\n stage.write_text("third-party stage", encoding="utf-8")\n destination.write_text("late destination", encoding="utf-8")\n original_rename_no_replace(\n source_name,\n destination_name,\n source_directory_fd=source_directory_fd,\n destination_directory_fd=destination_directory_fd,\n )\n\n monkeypatch.setattr(\n file_organizer,\n "_rename_no_replace_at",\n racing_rename_no_replace,\n )\n\n with pytest.raises(FileExistsError, match="destination appeared during execution"):\n execute_plan(plan)\n\n assert destination.read_text(encoding="utf-8") == "late destination"\n assert not source.exists()\n stage_files = [\n child for child in tmp_path.iterdir() if child.name.startswith(".fo-stage-")\n ]\n assert len(stage_files) == 1\n assert stage_files[0].read_text(encoding="utf-8") == "third-party stage"\n recovery_files = [\n child\n for child in tmp_path.iterdir()\n if child.name.startswith(".fo-recovery-")\n ]\n assert len(recovery_files) == 1\n assert recovery_files[0].read_text(encoding="utf-8") == "planned source"\n\n\n''' + if anchor not in tests: + raise SystemExit('test insertion anchor not found') + tests = tests.replace(anchor, new_test + anchor, 1) + test_path.write_text(tests, encoding='utf-8') + + docs = { + Path('practical-projects/06-file-organizer/README.md'): ( + 'A staging pathname is not an inode lock. If the final rename consumes a replacement entry and destination identity verification detects the mismatch, execution leaves the unrelated destination intact and, before closing the still-pinned source file descriptor, copies the planned source bytes into an exclusive `.fo-recovery-*` regular file. This preserves recoverable data without claiming that the original inode survived the race.\n', + 'A staging pathname is not an inode lock. After the source has been claimed, every failure path rechecks whether the staging entry still matches the pinned source identity. If it does, execution may hard-link that proven stage back to the original source name. If the stage is missing or has been replaced, execution leaves the uncertain stage untouched and, before closing the still-pinned source file descriptor, copies the planned source bytes into an exclusive `.fo-recovery-*` regular file. This also covers a final `RENAME_NOREPLACE` failure caused by a destination that appears after the stage was raced. If a replacement stage is successfully renamed and destination identity verification detects the mismatch, the unrelated destination is likewise left intact while the pinned bytes are recovered. Recovery preserves data without claiming that the original inode survived the race.\n' + ), + Path('practical-projects/06-file-organizer/README.pt-BR.md'): ( + 'Um pathname de staging não funciona como lock de inode. Se o rename final consumir uma entrada substituta e a verificação de identidade do destino detectar a divergência, a execução mantém intacto o destino alheio e, antes de fechar o descritor ainda pinado da origem, copia os bytes planejados para um arquivo regular exclusivo `.fo-recovery-*`. Essa recuperação preserva dados recuperáveis sem afirmar que o inode original sobreviveu à corrida.\n', + 'Um pathname de staging não funciona como lock de inode. Depois que a origem foi claimada, todo caminho de falha revalida se a entrada de staging ainda corresponde à identidade pinada da origem. Se corresponder, a execução pode recriar o nome original por hard link a partir desse staging comprovado. Se o staging sumiu ou foi substituído, a execução deixa a entrada incerta intacta e, antes de fechar o descritor ainda pinado da origem, copia os bytes planejados para um arquivo regular exclusivo `.fo-recovery-*`. Isso também cobre uma falha final de `RENAME_NOREPLACE` causada por um destino que aparece depois de uma corrida sobre o staging. Se um staging substituto for renomeado com sucesso e a verificação de identidade do destino detectar a divergência, o destino alheio também permanece intacto enquanto os bytes pinados são recuperados. A recuperação preserva os dados sem afirmar que o inode original sobreviveu à corrida.\n' + ), + Path('practical-projects/06-file-organizer/README.es.md'): ( + 'Un pathname de staging no funciona como lock de inode. Si el rename final consume una entrada de reemplazo y la verificación de identidad del destino detecta la divergencia, la ejecución conserva intacto el destino ajeno y, antes de cerrar el descriptor aún anclado del origen, copia los bytes planificados a un archivo regular exclusivo `.fo-recovery-*`. Esta recuperación conserva datos recuperables sin afirmar que el inode original haya sobrevivido a la carrera.\n', + 'Un pathname de staging no funciona como lock de inode. Después de reclamar el origen, cada ruta de fallo vuelve a comprobar si la entrada de staging todavía coincide con la identidad fijada del origen. Si coincide, la ejecución puede recrear el nombre original mediante un hard link desde ese staging comprobado. Si el staging desapareció o fue reemplazado, la ejecución deja intacta la entrada incierta y, antes de cerrar el descriptor todavía fijado del origen, copia los bytes planificados a un archivo regular exclusivo `.fo-recovery-*`. Esto también cubre un fallo final de `RENAME_NOREPLACE` causado por un destino que aparece después de una carrera sobre el staging. Si un staging de reemplazo se renombra con éxito y la verificación de identidad del destino detecta la divergencia, el destino ajeno también queda intacto mientras se recuperan los bytes fijados. La recuperación conserva los datos sin afirmar que el inode original haya sobrevivido a la carrera.\n' + ), + } + for path, (old, new) in docs.items(): + doc = path.read_text(encoding='utf-8') + if old not in doc: + raise SystemExit(f'doc anchor not found: {path}') + path.write_text(doc.replace(old, new, 1), encoding='utf-8') + PY + + - name: Run focused validation + shell: bash + run: | + python -m pytest -q practical-projects/06-file-organizer/tests + python -m compileall -q practical-projects/06-file-organizer + + - name: Remove temporary workflow and commit fix + shell: bash + run: | + rm .github/workflows/apply-review12-recovery.yml + git config user.name "Ramon Rodriguez" + git config user.email "ramoncorreka@hotmail.com" + git add practical-projects/06-file-organizer .github/workflows/apply-review12-recovery.yml + git commit -m "Recover pinned source on failed final rename" + git push origin HEAD:phase-10-file-organizer From 78fda9187a495b1cc3c4669aa5c2502d8a639475 Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Tue, 1 Sep 2026 18:04:19 -0300 Subject: [PATCH 092/117] Retry Review 12 recovery patch with pytest --- .github/workflows/retry-review12-recovery.yml | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 .github/workflows/retry-review12-recovery.yml diff --git a/.github/workflows/retry-review12-recovery.yml b/.github/workflows/retry-review12-recovery.yml new file mode 100644 index 0000000..21841ea --- /dev/null +++ b/.github/workflows/retry-review12-recovery.yml @@ -0,0 +1,60 @@ +name: Retry Review 12 recovery fix + +on: + push: + branches: + - phase-10-file-organizer + +permissions: + contents: write + +jobs: + patch: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + steps: + - name: Check out branch + uses: actions/checkout@v6 + with: + ref: phase-10-file-organizer + fetch-depth: 0 + + - name: Install pytest + run: python -m pip install --disable-pip-version-check "pytest>=9.1,<9.2" + + - name: Reuse focused patch from first workflow + shell: bash + run: | + python - <<'PY' + from pathlib import Path + + workflow = Path('.github/workflows/apply-review12-recovery.yml') + lines = workflow.read_text(encoding='utf-8').splitlines() + start_marker = " python - <<'PY'" + end_marker = " PY" + start = lines.index(start_marker) + 1 + end = lines.index(end_marker, start) + code_lines = [] + for line in lines[start:end]: + if line.startswith(' '): + line = line[10:] + code_lines.append(line) + code = '\n'.join(code_lines) + '\n' + exec(compile(code, str(workflow), 'exec')) + PY + + - name: Run focused validation + run: | + python -m pytest -q practical-projects/06-file-organizer/tests + python -m compileall -q practical-projects/06-file-organizer + + - name: Remove temporary workflows and commit fix + shell: bash + run: | + rm .github/workflows/apply-review12-recovery.yml + rm .github/workflows/retry-review12-recovery.yml + git config user.name "Ramon Rodriguez" + git config user.email "ramoncorreka@hotmail.com" + git add practical-projects/06-file-organizer .github/workflows/apply-review12-recovery.yml .github/workflows/retry-review12-recovery.yml + git commit -m "Recover pinned source on failed final rename" + git push origin HEAD:phase-10-file-organizer From 2ad43b591431ae9e09a0d24399635cb49d57addb Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Tue, 1 Sep 2026 21:04:33 +0000 Subject: [PATCH 093/117] Recover pinned source on failed final rename --- .github/workflows/apply-review12-recovery.yml | 88 ------------------- .github/workflows/retry-review12-recovery.yml | 60 ------------- .../06-file-organizer/README.es.md | 2 +- .../06-file-organizer/README.md | 2 +- .../06-file-organizer/README.pt-BR.md | 2 +- .../06-file-organizer/file_organizer.py | 40 ++++++++- .../tests/test_atomic_move.py | 60 +++++++++++++ 7 files changed, 101 insertions(+), 153 deletions(-) delete mode 100644 .github/workflows/apply-review12-recovery.yml delete mode 100644 .github/workflows/retry-review12-recovery.yml diff --git a/.github/workflows/apply-review12-recovery.yml b/.github/workflows/apply-review12-recovery.yml deleted file mode 100644 index 6b3c349..0000000 --- a/.github/workflows/apply-review12-recovery.yml +++ /dev/null @@ -1,88 +0,0 @@ -name: Apply Review 12 recovery fix - -on: - push: - branches: - - phase-10-file-organizer - -permissions: - contents: write - -jobs: - patch: - if: github.actor != 'github-actions[bot]' - runs-on: ubuntu-latest - steps: - - name: Check out branch - uses: actions/checkout@v6 - with: - ref: phase-10-file-organizer - fetch-depth: 0 - - - name: Apply focused Review 12 patch - shell: bash - run: | - python - <<'PY' - from pathlib import Path - - core_path = Path('practical-projects/06-file-organizer/file_organizer.py') - text = core_path.read_text(encoding='utf-8') - - old_helper = '''def _preserve_stage_at(stage_name: str, source_name: str, *, root_fd: int) -> None:\n """Best-effort restore by linking only; never delete a raced staging entry."""\n try:\n os.link(\n stage_name,\n source_name,\n src_dir_fd=root_fd,\n dst_dir_fd=root_fd,\n follow_symlinks=False,\n )\n except OSError:\n pass\n\n\n''' - new_helper = old_helper + '''def _preserve_claimed_source_after_failure_at(\n stage_name: str,\n source_name: str,\n *,\n root_fd: int,\n source_fd: int,\n expected_identity: _FileIdentity,\n) -> str | None:\n """Restore a proven stage or recover pinned bytes when stage identity is uncertain."""\n try:\n staged_identity = _regular_identity_at(stage_name, directory_fd=root_fd)\n except OSError:\n staged_identity = None\n\n if staged_identity == expected_identity:\n _preserve_stage_at(stage_name, source_name, root_fd=root_fd)\n return None\n\n return _recover_pinned_source_at(\n source_fd,\n source_name,\n root_fd=root_fd,\n )\n\n\n''' - if old_helper not in text: - raise SystemExit('helper anchor not found') - text = text.replace(old_helper, new_helper, 1) - - old_except = ''' except (FileExistsError, FileNotFoundError, ValueError, OSError):\n _preserve_stage_at(stage_name, source_name, root_fd=source_directory_fd)\n raise\n''' - new_except = ''' except (FileExistsError, FileNotFoundError, ValueError, OSError) as exc:\n recovery_name = _preserve_claimed_source_after_failure_at(\n stage_name,\n source_name,\n root_fd=source_directory_fd,\n source_fd=source_fd,\n expected_identity=expected_identity,\n )\n if recovery_name is not None:\n exc.add_note(\n "planned source data retained as "\n f"{recovery_name}: {source_name}"\n )\n raise\n''' - if old_except not in text: - raise SystemExit('move failure anchor not found') - text = text.replace(old_except, new_except, 1) - core_path.write_text(text, encoding='utf-8') - - test_path = Path('practical-projects/06-file-organizer/tests/test_atomic_move.py') - tests = test_path.read_text(encoding='utf-8') - anchor = '''def test_secure_execution_reports_readability_precondition_before_categories(\n''' - new_test = '''def test_failed_final_rename_after_stage_replacement_recovers_pinned_source_data(\n monkeypatch: pytest.MonkeyPatch,\n tmp_path: Path,\n) -> None:\n if not file_organizer._supports_secure_directory_fds():\n pytest.skip("secure directory descriptors are unavailable on this platform")\n\n source = tmp_path / "notes.txt"\n source.write_text("planned source", encoding="utf-8")\n plan = plan_organization(tmp_path)\n destination = tmp_path / "documents" / "notes.txt"\n original_rename_no_replace = file_organizer._rename_no_replace_at\n raced = False\n\n def racing_rename_no_replace(\n source_name: str,\n destination_name: str,\n *,\n source_directory_fd: int,\n destination_directory_fd: int,\n ) -> None:\n nonlocal raced\n if source_name.startswith(".fo-stage-") and not raced:\n raced = True\n stage = tmp_path / source_name\n stage.unlink()\n stage.write_text("third-party stage", encoding="utf-8")\n destination.write_text("late destination", encoding="utf-8")\n original_rename_no_replace(\n source_name,\n destination_name,\n source_directory_fd=source_directory_fd,\n destination_directory_fd=destination_directory_fd,\n )\n\n monkeypatch.setattr(\n file_organizer,\n "_rename_no_replace_at",\n racing_rename_no_replace,\n )\n\n with pytest.raises(FileExistsError, match="destination appeared during execution"):\n execute_plan(plan)\n\n assert destination.read_text(encoding="utf-8") == "late destination"\n assert not source.exists()\n stage_files = [\n child for child in tmp_path.iterdir() if child.name.startswith(".fo-stage-")\n ]\n assert len(stage_files) == 1\n assert stage_files[0].read_text(encoding="utf-8") == "third-party stage"\n recovery_files = [\n child\n for child in tmp_path.iterdir()\n if child.name.startswith(".fo-recovery-")\n ]\n assert len(recovery_files) == 1\n assert recovery_files[0].read_text(encoding="utf-8") == "planned source"\n\n\n''' - if anchor not in tests: - raise SystemExit('test insertion anchor not found') - tests = tests.replace(anchor, new_test + anchor, 1) - test_path.write_text(tests, encoding='utf-8') - - docs = { - Path('practical-projects/06-file-organizer/README.md'): ( - 'A staging pathname is not an inode lock. If the final rename consumes a replacement entry and destination identity verification detects the mismatch, execution leaves the unrelated destination intact and, before closing the still-pinned source file descriptor, copies the planned source bytes into an exclusive `.fo-recovery-*` regular file. This preserves recoverable data without claiming that the original inode survived the race.\n', - 'A staging pathname is not an inode lock. After the source has been claimed, every failure path rechecks whether the staging entry still matches the pinned source identity. If it does, execution may hard-link that proven stage back to the original source name. If the stage is missing or has been replaced, execution leaves the uncertain stage untouched and, before closing the still-pinned source file descriptor, copies the planned source bytes into an exclusive `.fo-recovery-*` regular file. This also covers a final `RENAME_NOREPLACE` failure caused by a destination that appears after the stage was raced. If a replacement stage is successfully renamed and destination identity verification detects the mismatch, the unrelated destination is likewise left intact while the pinned bytes are recovered. Recovery preserves data without claiming that the original inode survived the race.\n' - ), - Path('practical-projects/06-file-organizer/README.pt-BR.md'): ( - 'Um pathname de staging não funciona como lock de inode. Se o rename final consumir uma entrada substituta e a verificação de identidade do destino detectar a divergência, a execução mantém intacto o destino alheio e, antes de fechar o descritor ainda pinado da origem, copia os bytes planejados para um arquivo regular exclusivo `.fo-recovery-*`. Essa recuperação preserva dados recuperáveis sem afirmar que o inode original sobreviveu à corrida.\n', - 'Um pathname de staging não funciona como lock de inode. Depois que a origem foi claimada, todo caminho de falha revalida se a entrada de staging ainda corresponde à identidade pinada da origem. Se corresponder, a execução pode recriar o nome original por hard link a partir desse staging comprovado. Se o staging sumiu ou foi substituído, a execução deixa a entrada incerta intacta e, antes de fechar o descritor ainda pinado da origem, copia os bytes planejados para um arquivo regular exclusivo `.fo-recovery-*`. Isso também cobre uma falha final de `RENAME_NOREPLACE` causada por um destino que aparece depois de uma corrida sobre o staging. Se um staging substituto for renomeado com sucesso e a verificação de identidade do destino detectar a divergência, o destino alheio também permanece intacto enquanto os bytes pinados são recuperados. A recuperação preserva os dados sem afirmar que o inode original sobreviveu à corrida.\n' - ), - Path('practical-projects/06-file-organizer/README.es.md'): ( - 'Un pathname de staging no funciona como lock de inode. Si el rename final consume una entrada de reemplazo y la verificación de identidad del destino detecta la divergencia, la ejecución conserva intacto el destino ajeno y, antes de cerrar el descriptor aún anclado del origen, copia los bytes planificados a un archivo regular exclusivo `.fo-recovery-*`. Esta recuperación conserva datos recuperables sin afirmar que el inode original haya sobrevivido a la carrera.\n', - 'Un pathname de staging no funciona como lock de inode. Después de reclamar el origen, cada ruta de fallo vuelve a comprobar si la entrada de staging todavía coincide con la identidad fijada del origen. Si coincide, la ejecución puede recrear el nombre original mediante un hard link desde ese staging comprobado. Si el staging desapareció o fue reemplazado, la ejecución deja intacta la entrada incierta y, antes de cerrar el descriptor todavía fijado del origen, copia los bytes planificados a un archivo regular exclusivo `.fo-recovery-*`. Esto también cubre un fallo final de `RENAME_NOREPLACE` causado por un destino que aparece después de una carrera sobre el staging. Si un staging de reemplazo se renombra con éxito y la verificación de identidad del destino detecta la divergencia, el destino ajeno también queda intacto mientras se recuperan los bytes fijados. La recuperación conserva los datos sin afirmar que el inode original haya sobrevivido a la carrera.\n' - ), - } - for path, (old, new) in docs.items(): - doc = path.read_text(encoding='utf-8') - if old not in doc: - raise SystemExit(f'doc anchor not found: {path}') - path.write_text(doc.replace(old, new, 1), encoding='utf-8') - PY - - - name: Run focused validation - shell: bash - run: | - python -m pytest -q practical-projects/06-file-organizer/tests - python -m compileall -q practical-projects/06-file-organizer - - - name: Remove temporary workflow and commit fix - shell: bash - run: | - rm .github/workflows/apply-review12-recovery.yml - git config user.name "Ramon Rodriguez" - git config user.email "ramoncorreka@hotmail.com" - git add practical-projects/06-file-organizer .github/workflows/apply-review12-recovery.yml - git commit -m "Recover pinned source on failed final rename" - git push origin HEAD:phase-10-file-organizer diff --git a/.github/workflows/retry-review12-recovery.yml b/.github/workflows/retry-review12-recovery.yml deleted file mode 100644 index 21841ea..0000000 --- a/.github/workflows/retry-review12-recovery.yml +++ /dev/null @@ -1,60 +0,0 @@ -name: Retry Review 12 recovery fix - -on: - push: - branches: - - phase-10-file-organizer - -permissions: - contents: write - -jobs: - patch: - if: github.actor != 'github-actions[bot]' - runs-on: ubuntu-latest - steps: - - name: Check out branch - uses: actions/checkout@v6 - with: - ref: phase-10-file-organizer - fetch-depth: 0 - - - name: Install pytest - run: python -m pip install --disable-pip-version-check "pytest>=9.1,<9.2" - - - name: Reuse focused patch from first workflow - shell: bash - run: | - python - <<'PY' - from pathlib import Path - - workflow = Path('.github/workflows/apply-review12-recovery.yml') - lines = workflow.read_text(encoding='utf-8').splitlines() - start_marker = " python - <<'PY'" - end_marker = " PY" - start = lines.index(start_marker) + 1 - end = lines.index(end_marker, start) - code_lines = [] - for line in lines[start:end]: - if line.startswith(' '): - line = line[10:] - code_lines.append(line) - code = '\n'.join(code_lines) + '\n' - exec(compile(code, str(workflow), 'exec')) - PY - - - name: Run focused validation - run: | - python -m pytest -q practical-projects/06-file-organizer/tests - python -m compileall -q practical-projects/06-file-organizer - - - name: Remove temporary workflows and commit fix - shell: bash - run: | - rm .github/workflows/apply-review12-recovery.yml - rm .github/workflows/retry-review12-recovery.yml - git config user.name "Ramon Rodriguez" - git config user.email "ramoncorreka@hotmail.com" - git add practical-projects/06-file-organizer .github/workflows/apply-review12-recovery.yml .github/workflows/retry-review12-recovery.yml - git commit -m "Recover pinned source on failed final rename" - git push origin HEAD:phase-10-file-organizer diff --git a/practical-projects/06-file-organizer/README.es.md b/practical-projects/06-file-organizer/README.es.md index 68f463d..82d71de 100644 --- a/practical-projects/06-file-organizer/README.es.md +++ b/practical-projects/06-file-organizer/README.es.md @@ -273,7 +273,7 @@ Los errores concurrentes pueden dejar estado incierto. La recuperación prioriza Si la ejecución ya movió el origen al staging y después detecta una condición insegura, puede crear un hard link no-replace de vuelta al nombre de origen cuando sea posible. No elimina a ciegas el staging. -Un pathname de staging no funciona como lock de inode. Si el rename final consume una entrada de reemplazo y la verificación de identidad del destino detecta la divergencia, la ejecución conserva intacto el destino ajeno y, antes de cerrar el descriptor aún anclado del origen, copia los bytes planificados a un archivo regular exclusivo `.fo-recovery-*`. Esta recuperación conserva datos recuperables sin afirmar que el inode original haya sobrevivido a la carrera. +Un pathname de staging no funciona como lock de inode. Después de reclamar el origen, cada ruta de fallo vuelve a comprobar si la entrada de staging todavía coincide con la identidad fijada del origen. Si coincide, la ejecución puede recrear el nombre original mediante un hard link desde ese staging comprobado. Si el staging desapareció o fue reemplazado, la ejecución deja intacta la entrada incierta y, antes de cerrar el descriptor todavía fijado del origen, copia los bytes planificados a un archivo regular exclusivo `.fo-recovery-*`. Esto también cubre un fallo final de `RENAME_NOREPLACE` causado por un destino que aparece después de una carrera sobre el staging. Si un staging de reemplazo se renombra con éxito y la verificación de identidad del destino detecta la divergencia, el destino ajeno también queda intacto mientras se recuperan los bytes fijados. La recuperación conserva los datos sin afirmar que el inode original haya sobrevivido a la carrera. Por ello, la ejecución segura en Linux exige deliberadamente permiso de lectura para cada archivo regular planificado. La legibilidad se valida antes de crear los directorios de categoría y de nuevo al fijar el inode del origen para la mutación; los fallos de permisos se informan como `PermissionError`, no como un falso cambio de identidad del origen. diff --git a/practical-projects/06-file-organizer/README.md b/practical-projects/06-file-organizer/README.md index e4878a3..70695a9 100644 --- a/practical-projects/06-file-organizer/README.md +++ b/practical-projects/06-file-organizer/README.md @@ -273,7 +273,7 @@ Concurrency errors can leave uncertain state. Recovery therefore favors preserva If execution has already claimed the source into a staging entry and later detects an unsafe condition, it may create a no-replace hard link back to the original source name when possible. It does not blindly delete the staging entry. -A staging pathname is not an inode lock. If the final rename consumes a replacement entry and destination identity verification detects the mismatch, execution leaves the unrelated destination intact and, before closing the still-pinned source file descriptor, copies the planned source bytes into an exclusive `.fo-recovery-*` regular file. This preserves recoverable data without claiming that the original inode survived the race. +A staging pathname is not an inode lock. After the source has been claimed, every failure path rechecks whether the staging entry still matches the pinned source identity. If it does, execution may hard-link that proven stage back to the original source name. If the stage is missing or has been replaced, execution leaves the uncertain stage untouched and, before closing the still-pinned source file descriptor, copies the planned source bytes into an exclusive `.fo-recovery-*` regular file. This also covers a final `RENAME_NOREPLACE` failure caused by a destination that appears after the stage was raced. If a replacement stage is successfully renamed and destination identity verification detects the mismatch, the unrelated destination is likewise left intact while the pinned bytes are recovered. Recovery preserves data without claiming that the original inode survived the race. Safe Linux execution therefore deliberately requires read access to each planned regular file. Readability is validated before category directories are created and again when the source inode is pinned for mutation; permission failures are reported as `PermissionError`, not as a false source-identity change. diff --git a/practical-projects/06-file-organizer/README.pt-BR.md b/practical-projects/06-file-organizer/README.pt-BR.md index 4423d04..a2ca53e 100644 --- a/practical-projects/06-file-organizer/README.pt-BR.md +++ b/practical-projects/06-file-organizer/README.pt-BR.md @@ -273,7 +273,7 @@ Erros concorrentes podem deixar estado incerto. A recuperação prioriza preserv Se a execução já moveu a origem para staging e depois detecta condição insegura, ela pode criar um hard link no-replace de volta para o nome de origem quando possível. Ela não apaga cegamente o staging. -Um pathname de staging não funciona como lock de inode. Se o rename final consumir uma entrada substituta e a verificação de identidade do destino detectar a divergência, a execução mantém intacto o destino alheio e, antes de fechar o descritor ainda pinado da origem, copia os bytes planejados para um arquivo regular exclusivo `.fo-recovery-*`. Essa recuperação preserva dados recuperáveis sem afirmar que o inode original sobreviveu à corrida. +Um pathname de staging não funciona como lock de inode. Depois que a origem foi claimada, todo caminho de falha revalida se a entrada de staging ainda corresponde à identidade pinada da origem. Se corresponder, a execução pode recriar o nome original por hard link a partir desse staging comprovado. Se o staging sumiu ou foi substituído, a execução deixa a entrada incerta intacta e, antes de fechar o descritor ainda pinado da origem, copia os bytes planejados para um arquivo regular exclusivo `.fo-recovery-*`. Isso também cobre uma falha final de `RENAME_NOREPLACE` causada por um destino que aparece depois de uma corrida sobre o staging. Se um staging substituto for renomeado com sucesso e a verificação de identidade do destino detectar a divergência, o destino alheio também permanece intacto enquanto os bytes pinados são recuperados. A recuperação preserva os dados sem afirmar que o inode original sobreviveu à corrida. Por isso, a execução segura no Linux exige deliberadamente permissão de leitura para cada arquivo regular planejado. A legibilidade é validada antes da criação das pastas de categoria e novamente ao pinar o inode da origem para a mutação; falhas de permissão são reportadas como `PermissionError`, e não como uma falsa mudança de identidade da origem. diff --git a/practical-projects/06-file-organizer/file_organizer.py b/practical-projects/06-file-organizer/file_organizer.py index 813435d..a516ffa 100644 --- a/practical-projects/06-file-organizer/file_organizer.py +++ b/practical-projects/06-file-organizer/file_organizer.py @@ -749,6 +749,31 @@ def _preserve_stage_at(stage_name: str, source_name: str, *, root_fd: int) -> No pass +def _preserve_claimed_source_after_failure_at( + stage_name: str, + source_name: str, + *, + root_fd: int, + source_fd: int, + expected_identity: _FileIdentity, +) -> str | None: + """Restore a proven stage or recover pinned bytes when stage identity is uncertain.""" + try: + staged_identity = _regular_identity_at(stage_name, directory_fd=root_fd) + except OSError: + staged_identity = None + + if staged_identity == expected_identity: + _preserve_stage_at(stage_name, source_name, root_fd=root_fd) + return None + + return _recover_pinned_source_at( + source_fd, + source_name, + root_fd=root_fd, + ) + + def _claim_source_at( source_name: str, *, @@ -887,8 +912,19 @@ def _move_file_no_replace_at( source_directory_fd=source_directory_fd, destination_directory_fd=destination_directory_fd, ) - except (FileExistsError, FileNotFoundError, ValueError, OSError): - _preserve_stage_at(stage_name, source_name, root_fd=source_directory_fd) + except (FileExistsError, FileNotFoundError, ValueError, OSError) as exc: + recovery_name = _preserve_claimed_source_after_failure_at( + stage_name, + source_name, + root_fd=source_directory_fd, + source_fd=source_fd, + expected_identity=expected_identity, + ) + if recovery_name is not None: + exc.add_note( + "planned source data retained as " + f"{recovery_name}: {source_name}" + ) raise try: diff --git a/practical-projects/06-file-organizer/tests/test_atomic_move.py b/practical-projects/06-file-organizer/tests/test_atomic_move.py index 79708b9..903d983 100644 --- a/practical-projects/06-file-organizer/tests/test_atomic_move.py +++ b/practical-projects/06-file-organizer/tests/test_atomic_move.py @@ -482,6 +482,66 @@ def racing_rename_no_replace( +def test_failed_final_rename_after_stage_replacement_recovers_pinned_source_data( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + if not file_organizer._supports_secure_directory_fds(): + pytest.skip("secure directory descriptors are unavailable on this platform") + + source = tmp_path / "notes.txt" + source.write_text("planned source", encoding="utf-8") + plan = plan_organization(tmp_path) + destination = tmp_path / "documents" / "notes.txt" + original_rename_no_replace = file_organizer._rename_no_replace_at + raced = False + + def racing_rename_no_replace( + source_name: str, + destination_name: str, + *, + source_directory_fd: int, + destination_directory_fd: int, + ) -> None: + nonlocal raced + if source_name.startswith(".fo-stage-") and not raced: + raced = True + stage = tmp_path / source_name + stage.unlink() + stage.write_text("third-party stage", encoding="utf-8") + destination.write_text("late destination", encoding="utf-8") + original_rename_no_replace( + source_name, + destination_name, + source_directory_fd=source_directory_fd, + destination_directory_fd=destination_directory_fd, + ) + + monkeypatch.setattr( + file_organizer, + "_rename_no_replace_at", + racing_rename_no_replace, + ) + + with pytest.raises(FileExistsError, match="destination appeared during execution"): + execute_plan(plan) + + assert destination.read_text(encoding="utf-8") == "late destination" + assert not source.exists() + stage_files = [ + child for child in tmp_path.iterdir() if child.name.startswith(".fo-stage-") + ] + assert len(stage_files) == 1 + assert stage_files[0].read_text(encoding="utf-8") == "third-party stage" + recovery_files = [ + child + for child in tmp_path.iterdir() + if child.name.startswith(".fo-recovery-") + ] + assert len(recovery_files) == 1 + assert recovery_files[0].read_text(encoding="utf-8") == "planned source" + + def test_secure_execution_reports_readability_precondition_before_categories( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, From 3174ef01784678bbf3f9089445bf230762384222 Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Tue, 1 Sep 2026 18:05:48 -0300 Subject: [PATCH 094/117] Trigger final CI after Review 12 fix --- .ci-trigger-review12 | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 .ci-trigger-review12 diff --git a/.ci-trigger-review12 b/.ci-trigger-review12 new file mode 100644 index 0000000..e69de29 From bfb234b58e2df6480b5e4ca89b2d94c668d47075 Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Tue, 1 Sep 2026 18:06:14 -0300 Subject: [PATCH 095/117] Remove final CI trigger --- .ci-trigger-review12 | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 .ci-trigger-review12 diff --git a/.ci-trigger-review12 b/.ci-trigger-review12 deleted file mode 100644 index e69de29..0000000 From aae2e43b250d0b0597af90fa3ab71c428d6dab9a Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Tue, 1 Sep 2026 18:09:27 -0300 Subject: [PATCH 096/117] none --- DO_NOT_CREATE | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 DO_NOT_CREATE diff --git a/DO_NOT_CREATE b/DO_NOT_CREATE new file mode 100644 index 0000000..e69de29 From 704e3c83625663b7404087f97a11ba9b2420dca8 Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Tue, 1 Sep 2026 18:09:53 -0300 Subject: [PATCH 097/117] Remove accidental metadata artifact --- DO_NOT_CREATE | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 DO_NOT_CREATE diff --git a/DO_NOT_CREATE b/DO_NOT_CREATE deleted file mode 100644 index e69de29..0000000 From a917a791c09a31cead520624c1c75e146a9eef3a Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Tue, 1 Sep 2026 18:30:51 -0300 Subject: [PATCH 098/117] Add temporary Review 13 patch helper --- scripts/_apply_file_organizer_review13.py | 138 ++++++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 scripts/_apply_file_organizer_review13.py diff --git a/scripts/_apply_file_organizer_review13.py b/scripts/_apply_file_organizer_review13.py new file mode 100644 index 0000000..e00a8fb --- /dev/null +++ b/scripts/_apply_file_organizer_review13.py @@ -0,0 +1,138 @@ +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +CODE = ROOT / "practical-projects/06-file-organizer/file_organizer.py" +TESTS = ROOT / "practical-projects/06-file-organizer/tests/test_atomic_move.py" +READMES = { + ROOT / "practical-projects/06-file-organizer/README.md": ( + "If it does, execution may hard-link that proven stage back to the original source name. If the stage is missing or has been replaced, execution leaves the uncertain stage untouched and, before closing the still-pinned source file descriptor, copies the planned source bytes into an exclusive `.fo-recovery-*` regular file.", + "If it does, execution may attempt a no-replace hard link from that proven stage back to the original source name, but restoration is accepted only after the recreated source pathname itself is re-read and verified to have the pinned identity. If the link fails, races to a different object, leaves the source name missing, or the post-link source identity does not match, execution leaves uncertain entries untouched and, before closing the still-pinned source file descriptor, copies the planned source bytes into an exclusive `.fo-recovery-*` regular file." + ), + ROOT / "practical-projects/06-file-organizer/README.pt-BR.md": ( + "Se corresponder, a execução pode recriar o nome original por hard link a partir desse staging comprovado. Se o staging sumiu ou foi substituído, a execução deixa a entrada incerta intacta e, antes de fechar o descritor ainda pinado da origem, copia os bytes planejados para um arquivo regular exclusivo `.fo-recovery-*`.", + "Se corresponder, a execução pode tentar recriar o nome original por hard link no-replace a partir desse staging comprovado, mas a restauração só é aceita depois que o próprio pathname recriado da origem é relido e verificado com a identidade pinada. Se o link falhar, sofrer corrida para outro objeto, deixar o nome de origem ausente ou a identidade pós-link não corresponder, a execução deixa entradas incertas intactas e, antes de fechar o descritor ainda pinado da origem, copia os bytes planejados para um arquivo regular exclusivo `.fo-recovery-*`." + ), + ROOT / "practical-projects/06-file-organizer/README.es.md": ( + "Si coincide, la ejecución puede recrear el nombre original mediante un hard link desde ese staging comprobado. Si el staging desapareció o fue reemplazado, la ejecución deja intacta la entrada incierta y, antes de cerrar el descriptor todavía fijado del origen, copia los bytes planificados a un archivo regular exclusivo `.fo-recovery-*`.", + "Si coincide, la ejecución puede intentar recrear el nombre original mediante un hard link no-replace desde ese staging comprobado, pero la restauración solo se acepta después de volver a leer el propio pathname recreado del origen y verificar que conserva la identidad fijada. Si el link falla, sufre una carrera hacia otro objeto, deja ausente el nombre de origen o la identidad posterior al link no coincide, la ejecución deja intactas las entradas inciertas y, antes de cerrar el descriptor todavía fijado del origen, copia los bytes planificados a un archivo regular exclusivo `.fo-recovery-*`." + ), +} + + +def replace_once(path: Path, old: str, new: str) -> None: + text = path.read_text(encoding="utf-8") + if text.count(old) != 1: + raise RuntimeError(f"expected exactly one anchor in {path}: {old[:80]!r}") + path.write_text(text.replace(old, new, 1), encoding="utf-8") + + +code = CODE.read_text(encoding="utf-8") +old_code = '''def _preserve_stage_at(stage_name: str, source_name: str, *, root_fd: int) -> None:\n \"\"\"Best-effort restore by linking only; never delete a raced staging entry.\"\"\"\n try:\n os.link(\n stage_name,\n source_name,\n src_dir_fd=root_fd,\n dst_dir_fd=root_fd,\n follow_symlinks=False,\n )\n except OSError:\n pass\n''' +new_code = '''def _preserve_stage_at(\n stage_name: str,\n source_name: str,\n *,\n root_fd: int,\n expected_identity: _FileIdentity | None = None,\n) -> bool:\n \"\"\"Best-effort restore without deleting raced entries; optionally prove result.\"\"\"\n try:\n os.link(\n stage_name,\n source_name,\n src_dir_fd=root_fd,\n dst_dir_fd=root_fd,\n follow_symlinks=False,\n )\n except OSError:\n pass\n\n if expected_identity is None:\n return False\n\n try:\n restored_identity = _regular_identity_at(\n source_name,\n directory_fd=root_fd,\n )\n except OSError:\n return False\n return restored_identity == expected_identity\n''' +if code.count(old_code) != 1: + raise RuntimeError("preserve-stage helper anchor not found exactly once") +code = code.replace(old_code, new_code, 1) +old_branch = ''' if staged_identity == expected_identity:\n _preserve_stage_at(stage_name, source_name, root_fd=root_fd)\n return None\n\n return _recover_pinned_source_at(\n''' +new_branch = ''' if staged_identity == expected_identity:\n restored = _preserve_stage_at(\n stage_name,\n source_name,\n root_fd=root_fd,\n expected_identity=expected_identity,\n )\n if restored:\n return None\n\n return _recover_pinned_source_at(\n''' +if code.count(old_branch) != 1: + raise RuntimeError("post-claim recovery branch anchor not found exactly once") +code = code.replace(old_branch, new_branch, 1) +CODE.write_text(code, encoding="utf-8") + +for path, (old, new) in READMES.items(): + replace_once(path, old, new) + + +tests = TESTS.read_text(encoding="utf-8") +anchor = '''def test_failed_final_rename_after_stage_replacement_recovers_pinned_source_data(\n''' +if tests.count(anchor) != 1: + raise RuntimeError("test insertion anchor not found exactly once") +new_test = r''' + +def test_failed_final_rename_stage_changes_during_restore_recovers_pinned_source_data( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + if not file_organizer._supports_secure_directory_fds(): + pytest.skip("secure directory descriptors are unavailable on this platform") + + source = tmp_path / "notes.txt" + source.write_text("planned source", encoding="utf-8") + plan = plan_organization(tmp_path) + destination = tmp_path / "documents" / "notes.txt" + original_rename_no_replace = file_organizer._rename_no_replace_at + original_link = os.link + final_rename_failed = False + restore_raced = False + + def failing_final_rename( + source_name: str, + destination_name: str, + *, + source_directory_fd: int, + destination_directory_fd: int, + ) -> None: + nonlocal final_rename_failed + if source_name.startswith(".fo-stage-") and not final_rename_failed: + final_rename_failed = True + destination.write_text("late destination", encoding="utf-8") + original_rename_no_replace( + source_name, + destination_name, + source_directory_fd=source_directory_fd, + destination_directory_fd=destination_directory_fd, + ) + + def racing_link( + src: str | os.PathLike[str], + dst: str | os.PathLike[str], + *, + src_dir_fd: int | None = None, + dst_dir_fd: int | None = None, + follow_symlinks: bool = True, + ) -> None: + nonlocal restore_raced + if ( + os.fspath(src).startswith(".fo-stage-") + and os.fspath(dst) == source.name + and src_dir_fd is not None + and dst_dir_fd is not None + and not restore_raced + ): + restore_raced = True + stage = tmp_path / os.fspath(src) + stage.unlink() + stage.write_text("third-party stage", encoding="utf-8") + original_link( + src, + dst, + src_dir_fd=src_dir_fd, + dst_dir_fd=dst_dir_fd, + follow_symlinks=follow_symlinks, + ) + + monkeypatch.setattr(file_organizer, "_rename_no_replace_at", failing_final_rename) + monkeypatch.setattr(file_organizer.os, "link", racing_link) + + with pytest.raises(FileExistsError, match="destination appeared during execution"): + execute_plan(plan) + + assert final_rename_failed + assert restore_raced + assert destination.read_text(encoding="utf-8") == "late destination" + assert source.read_text(encoding="utf-8") == "third-party stage" + stage_files = [ + child for child in tmp_path.iterdir() if child.name.startswith(".fo-stage-") + ] + assert len(stage_files) == 1 + assert stage_files[0].read_text(encoding="utf-8") == "third-party stage" + recovery_files = [ + child for child in tmp_path.iterdir() if child.name.startswith(".fo-recovery-") + ] + assert len(recovery_files) == 1 + assert recovery_files[0].read_text(encoding="utf-8") == "planned source" + + +''' +tests = tests.replace(anchor, new_test + anchor, 1) +TESTS.write_text(tests, encoding="utf-8") From 5c2c3cc15e140cabeda457df4f92f0faef675d9c Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Tue, 1 Sep 2026 18:31:03 -0300 Subject: [PATCH 099/117] Add temporary Review 13 patch workflow --- .../apply-file-organizer-review13.yml | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 .github/workflows/apply-file-organizer-review13.yml diff --git a/.github/workflows/apply-file-organizer-review13.yml b/.github/workflows/apply-file-organizer-review13.yml new file mode 100644 index 0000000..94c9bce --- /dev/null +++ b/.github/workflows/apply-file-organizer-review13.yml @@ -0,0 +1,46 @@ +name: Apply File Organizer Review 13 fix + +on: + workflow_dispatch: + +permissions: + contents: write + +jobs: + apply: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + ref: phase-10-file-organizer + fetch-depth: 0 + + - uses: actions/setup-python@v6 + with: + python-version: '3.13' + + - name: Install pytest + run: python -m pip install --disable-pip-version-check 'pytest>=9.1,<9.2' + + - name: Apply Review 13 fix + run: python scripts/_apply_file_organizer_review13.py + + - name: Validate focused suite + run: | + python -m compileall -q practical-projects/06-file-organizer + python -m pytest -q practical-projects/06-file-organizer/tests + git diff --check + + - name: Commit clean fix and remove temporary tooling + run: | + git config user.name 'RamonRDR' + git config user.email 'ramoncorreka@hotmail.com' + git rm scripts/_apply_file_organizer_review13.py + git rm .github/workflows/apply-file-organizer-review13.yml + git add practical-projects/06-file-organizer/file_organizer.py + git add practical-projects/06-file-organizer/tests/test_atomic_move.py + git add practical-projects/06-file-organizer/README.md + git add practical-projects/06-file-organizer/README.pt-BR.md + git add practical-projects/06-file-organizer/README.es.md + git commit -m 'Verify restored source identity after recovery link' + git push origin HEAD:phase-10-file-organizer From beaa78f72ae15001fe5002185bf27c5482a1f737 Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Tue, 1 Sep 2026 18:32:27 -0300 Subject: [PATCH 100/117] Trigger temporary Review 13 patch workflow --- .github/workflows/apply-file-organizer-review13.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/apply-file-organizer-review13.yml b/.github/workflows/apply-file-organizer-review13.yml index 94c9bce..f61230d 100644 --- a/.github/workflows/apply-file-organizer-review13.yml +++ b/.github/workflows/apply-file-organizer-review13.yml @@ -1,6 +1,12 @@ name: Apply File Organizer Review 13 fix on: + push: + branches: + - phase-10-file-organizer + paths: + - .github/workflows/apply-file-organizer-review13.yml + - scripts/_apply_file_organizer_review13.py workflow_dispatch: permissions: From 29d93938d7706a908eb7696734445ab21c15c2f9 Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Tue, 1 Sep 2026 18:34:23 -0300 Subject: [PATCH 101/117] Fix Review 13 regression harness --- .github/workflows/apply-file-organizer-review13.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.github/workflows/apply-file-organizer-review13.yml b/.github/workflows/apply-file-organizer-review13.yml index f61230d..b7dccc5 100644 --- a/.github/workflows/apply-file-organizer-review13.yml +++ b/.github/workflows/apply-file-organizer-review13.yml @@ -31,6 +31,19 @@ jobs: - name: Apply Review 13 fix run: python scripts/_apply_file_organizer_review13.py + - name: Keep Linux capability stable in the injected os.link race test + run: | + python - <<'PY' + from pathlib import Path + path = Path('practical-projects/06-file-organizer/tests/test_atomic_move.py') + text = path.read_text(encoding='utf-8') + old = ''' monkeypatch.setattr(file_organizer, "_rename_no_replace_at", failing_final_rename)\n monkeypatch.setattr(file_organizer.os, "link", racing_link)\n''' + new = ''' monkeypatch.setattr(file_organizer, "_supports_secure_directory_fds", lambda: True)\n monkeypatch.setattr(file_organizer, "_rename_no_replace_at", failing_final_rename)\n monkeypatch.setattr(file_organizer.os, "link", racing_link)\n''' + if text.count(old) != 1: + raise SystemExit('expected one Review 13 test harness anchor') + path.write_text(text.replace(old, new, 1), encoding='utf-8') + PY + - name: Validate focused suite run: | python -m compileall -q practical-projects/06-file-organizer From e06cfabc1afc15336160f574f933d7e88449bfa5 Mon Sep 17 00:00:00 2001 From: RamonRDR Date: Tue, 1 Sep 2026 21:34:46 +0000 Subject: [PATCH 102/117] Verify restored source identity after recovery link --- .../apply-file-organizer-review13.yml | 65 --------- .../06-file-organizer/README.es.md | 2 +- .../06-file-organizer/README.md | 2 +- .../06-file-organizer/README.pt-BR.md | 2 +- .../06-file-organizer/file_organizer.py | 32 +++- .../tests/test_atomic_move.py | 87 +++++++++++ scripts/_apply_file_organizer_review13.py | 138 ------------------ 7 files changed, 118 insertions(+), 210 deletions(-) delete mode 100644 .github/workflows/apply-file-organizer-review13.yml delete mode 100644 scripts/_apply_file_organizer_review13.py diff --git a/.github/workflows/apply-file-organizer-review13.yml b/.github/workflows/apply-file-organizer-review13.yml deleted file mode 100644 index b7dccc5..0000000 --- a/.github/workflows/apply-file-organizer-review13.yml +++ /dev/null @@ -1,65 +0,0 @@ -name: Apply File Organizer Review 13 fix - -on: - push: - branches: - - phase-10-file-organizer - paths: - - .github/workflows/apply-file-organizer-review13.yml - - scripts/_apply_file_organizer_review13.py - workflow_dispatch: - -permissions: - contents: write - -jobs: - apply: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - with: - ref: phase-10-file-organizer - fetch-depth: 0 - - - uses: actions/setup-python@v6 - with: - python-version: '3.13' - - - name: Install pytest - run: python -m pip install --disable-pip-version-check 'pytest>=9.1,<9.2' - - - name: Apply Review 13 fix - run: python scripts/_apply_file_organizer_review13.py - - - name: Keep Linux capability stable in the injected os.link race test - run: | - python - <<'PY' - from pathlib import Path - path = Path('practical-projects/06-file-organizer/tests/test_atomic_move.py') - text = path.read_text(encoding='utf-8') - old = ''' monkeypatch.setattr(file_organizer, "_rename_no_replace_at", failing_final_rename)\n monkeypatch.setattr(file_organizer.os, "link", racing_link)\n''' - new = ''' monkeypatch.setattr(file_organizer, "_supports_secure_directory_fds", lambda: True)\n monkeypatch.setattr(file_organizer, "_rename_no_replace_at", failing_final_rename)\n monkeypatch.setattr(file_organizer.os, "link", racing_link)\n''' - if text.count(old) != 1: - raise SystemExit('expected one Review 13 test harness anchor') - path.write_text(text.replace(old, new, 1), encoding='utf-8') - PY - - - name: Validate focused suite - run: | - python -m compileall -q practical-projects/06-file-organizer - python -m pytest -q practical-projects/06-file-organizer/tests - git diff --check - - - name: Commit clean fix and remove temporary tooling - run: | - git config user.name 'RamonRDR' - git config user.email 'ramoncorreka@hotmail.com' - git rm scripts/_apply_file_organizer_review13.py - git rm .github/workflows/apply-file-organizer-review13.yml - git add practical-projects/06-file-organizer/file_organizer.py - git add practical-projects/06-file-organizer/tests/test_atomic_move.py - git add practical-projects/06-file-organizer/README.md - git add practical-projects/06-file-organizer/README.pt-BR.md - git add practical-projects/06-file-organizer/README.es.md - git commit -m 'Verify restored source identity after recovery link' - git push origin HEAD:phase-10-file-organizer diff --git a/practical-projects/06-file-organizer/README.es.md b/practical-projects/06-file-organizer/README.es.md index 82d71de..38c030e 100644 --- a/practical-projects/06-file-organizer/README.es.md +++ b/practical-projects/06-file-organizer/README.es.md @@ -273,7 +273,7 @@ Los errores concurrentes pueden dejar estado incierto. La recuperación prioriza Si la ejecución ya movió el origen al staging y después detecta una condición insegura, puede crear un hard link no-replace de vuelta al nombre de origen cuando sea posible. No elimina a ciegas el staging. -Un pathname de staging no funciona como lock de inode. Después de reclamar el origen, cada ruta de fallo vuelve a comprobar si la entrada de staging todavía coincide con la identidad fijada del origen. Si coincide, la ejecución puede recrear el nombre original mediante un hard link desde ese staging comprobado. Si el staging desapareció o fue reemplazado, la ejecución deja intacta la entrada incierta y, antes de cerrar el descriptor todavía fijado del origen, copia los bytes planificados a un archivo regular exclusivo `.fo-recovery-*`. Esto también cubre un fallo final de `RENAME_NOREPLACE` causado por un destino que aparece después de una carrera sobre el staging. Si un staging de reemplazo se renombra con éxito y la verificación de identidad del destino detecta la divergencia, el destino ajeno también queda intacto mientras se recuperan los bytes fijados. La recuperación conserva los datos sin afirmar que el inode original haya sobrevivido a la carrera. +Un pathname de staging no funciona como lock de inode. Después de reclamar el origen, cada ruta de fallo vuelve a comprobar si la entrada de staging todavía coincide con la identidad fijada del origen. Si coincide, la ejecución puede intentar recrear el nombre original mediante un hard link no-replace desde ese staging comprobado, pero la restauración solo se acepta después de volver a leer el propio pathname recreado del origen y verificar que conserva la identidad fijada. Si el link falla, sufre una carrera hacia otro objeto, deja ausente el nombre de origen o la identidad posterior al link no coincide, la ejecución deja intactas las entradas inciertas y, antes de cerrar el descriptor todavía fijado del origen, copia los bytes planificados a un archivo regular exclusivo `.fo-recovery-*`. Esto también cubre un fallo final de `RENAME_NOREPLACE` causado por un destino que aparece después de una carrera sobre el staging. Si un staging de reemplazo se renombra con éxito y la verificación de identidad del destino detecta la divergencia, el destino ajeno también queda intacto mientras se recuperan los bytes fijados. La recuperación conserva los datos sin afirmar que el inode original haya sobrevivido a la carrera. Por ello, la ejecución segura en Linux exige deliberadamente permiso de lectura para cada archivo regular planificado. La legibilidad se valida antes de crear los directorios de categoría y de nuevo al fijar el inode del origen para la mutación; los fallos de permisos se informan como `PermissionError`, no como un falso cambio de identidad del origen. diff --git a/practical-projects/06-file-organizer/README.md b/practical-projects/06-file-organizer/README.md index 70695a9..192916d 100644 --- a/practical-projects/06-file-organizer/README.md +++ b/practical-projects/06-file-organizer/README.md @@ -273,7 +273,7 @@ Concurrency errors can leave uncertain state. Recovery therefore favors preserva If execution has already claimed the source into a staging entry and later detects an unsafe condition, it may create a no-replace hard link back to the original source name when possible. It does not blindly delete the staging entry. -A staging pathname is not an inode lock. After the source has been claimed, every failure path rechecks whether the staging entry still matches the pinned source identity. If it does, execution may hard-link that proven stage back to the original source name. If the stage is missing or has been replaced, execution leaves the uncertain stage untouched and, before closing the still-pinned source file descriptor, copies the planned source bytes into an exclusive `.fo-recovery-*` regular file. This also covers a final `RENAME_NOREPLACE` failure caused by a destination that appears after the stage was raced. If a replacement stage is successfully renamed and destination identity verification detects the mismatch, the unrelated destination is likewise left intact while the pinned bytes are recovered. Recovery preserves data without claiming that the original inode survived the race. +A staging pathname is not an inode lock. After the source has been claimed, every failure path rechecks whether the staging entry still matches the pinned source identity. If it does, execution may attempt a no-replace hard link from that proven stage back to the original source name, but restoration is accepted only after the recreated source pathname itself is re-read and verified to have the pinned identity. If the link fails, races to a different object, leaves the source name missing, or the post-link source identity does not match, execution leaves uncertain entries untouched and, before closing the still-pinned source file descriptor, copies the planned source bytes into an exclusive `.fo-recovery-*` regular file. This also covers a final `RENAME_NOREPLACE` failure caused by a destination that appears after the stage was raced. If a replacement stage is successfully renamed and destination identity verification detects the mismatch, the unrelated destination is likewise left intact while the pinned bytes are recovered. Recovery preserves data without claiming that the original inode survived the race. Safe Linux execution therefore deliberately requires read access to each planned regular file. Readability is validated before category directories are created and again when the source inode is pinned for mutation; permission failures are reported as `PermissionError`, not as a false source-identity change. diff --git a/practical-projects/06-file-organizer/README.pt-BR.md b/practical-projects/06-file-organizer/README.pt-BR.md index a2ca53e..9ea4e77 100644 --- a/practical-projects/06-file-organizer/README.pt-BR.md +++ b/practical-projects/06-file-organizer/README.pt-BR.md @@ -273,7 +273,7 @@ Erros concorrentes podem deixar estado incerto. A recuperação prioriza preserv Se a execução já moveu a origem para staging e depois detecta condição insegura, ela pode criar um hard link no-replace de volta para o nome de origem quando possível. Ela não apaga cegamente o staging. -Um pathname de staging não funciona como lock de inode. Depois que a origem foi claimada, todo caminho de falha revalida se a entrada de staging ainda corresponde à identidade pinada da origem. Se corresponder, a execução pode recriar o nome original por hard link a partir desse staging comprovado. Se o staging sumiu ou foi substituído, a execução deixa a entrada incerta intacta e, antes de fechar o descritor ainda pinado da origem, copia os bytes planejados para um arquivo regular exclusivo `.fo-recovery-*`. Isso também cobre uma falha final de `RENAME_NOREPLACE` causada por um destino que aparece depois de uma corrida sobre o staging. Se um staging substituto for renomeado com sucesso e a verificação de identidade do destino detectar a divergência, o destino alheio também permanece intacto enquanto os bytes pinados são recuperados. A recuperação preserva os dados sem afirmar que o inode original sobreviveu à corrida. +Um pathname de staging não funciona como lock de inode. Depois que a origem foi claimada, todo caminho de falha revalida se a entrada de staging ainda corresponde à identidade pinada da origem. Se corresponder, a execução pode tentar recriar o nome original por hard link no-replace a partir desse staging comprovado, mas a restauração só é aceita depois que o próprio pathname recriado da origem é relido e verificado com a identidade pinada. Se o link falhar, sofrer corrida para outro objeto, deixar o nome de origem ausente ou a identidade pós-link não corresponder, a execução deixa entradas incertas intactas e, antes de fechar o descritor ainda pinado da origem, copia os bytes planejados para um arquivo regular exclusivo `.fo-recovery-*`. Isso também cobre uma falha final de `RENAME_NOREPLACE` causada por um destino que aparece depois de uma corrida sobre o staging. Se um staging substituto for renomeado com sucesso e a verificação de identidade do destino detectar a divergência, o destino alheio também permanece intacto enquanto os bytes pinados são recuperados. A recuperação preserva os dados sem afirmar que o inode original sobreviveu à corrida. Por isso, a execução segura no Linux exige deliberadamente permissão de leitura para cada arquivo regular planejado. A legibilidade é validada antes da criação das pastas de categoria e novamente ao pinar o inode da origem para a mutação; falhas de permissão são reportadas como `PermissionError`, e não como uma falsa mudança de identidade da origem. diff --git a/practical-projects/06-file-organizer/file_organizer.py b/practical-projects/06-file-organizer/file_organizer.py index a516ffa..c6b6939 100644 --- a/practical-projects/06-file-organizer/file_organizer.py +++ b/practical-projects/06-file-organizer/file_organizer.py @@ -735,8 +735,14 @@ def _recover_pinned_source_at( return recovery_name -def _preserve_stage_at(stage_name: str, source_name: str, *, root_fd: int) -> None: - """Best-effort restore by linking only; never delete a raced staging entry.""" +def _preserve_stage_at( + stage_name: str, + source_name: str, + *, + root_fd: int, + expected_identity: _FileIdentity | None = None, +) -> bool: + """Best-effort restore without deleting raced entries; optionally prove result.""" try: os.link( stage_name, @@ -748,6 +754,18 @@ def _preserve_stage_at(stage_name: str, source_name: str, *, root_fd: int) -> No except OSError: pass + if expected_identity is None: + return False + + try: + restored_identity = _regular_identity_at( + source_name, + directory_fd=root_fd, + ) + except OSError: + return False + return restored_identity == expected_identity + def _preserve_claimed_source_after_failure_at( stage_name: str, @@ -764,8 +782,14 @@ def _preserve_claimed_source_after_failure_at( staged_identity = None if staged_identity == expected_identity: - _preserve_stage_at(stage_name, source_name, root_fd=root_fd) - return None + restored = _preserve_stage_at( + stage_name, + source_name, + root_fd=root_fd, + expected_identity=expected_identity, + ) + if restored: + return None return _recover_pinned_source_at( source_fd, diff --git a/practical-projects/06-file-organizer/tests/test_atomic_move.py b/practical-projects/06-file-organizer/tests/test_atomic_move.py index 903d983..4a7cf06 100644 --- a/practical-projects/06-file-organizer/tests/test_atomic_move.py +++ b/practical-projects/06-file-organizer/tests/test_atomic_move.py @@ -482,6 +482,93 @@ def racing_rename_no_replace( + + +def test_failed_final_rename_stage_changes_during_restore_recovers_pinned_source_data( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + if not file_organizer._supports_secure_directory_fds(): + pytest.skip("secure directory descriptors are unavailable on this platform") + + source = tmp_path / "notes.txt" + source.write_text("planned source", encoding="utf-8") + plan = plan_organization(tmp_path) + destination = tmp_path / "documents" / "notes.txt" + original_rename_no_replace = file_organizer._rename_no_replace_at + original_link = os.link + final_rename_failed = False + restore_raced = False + + def failing_final_rename( + source_name: str, + destination_name: str, + *, + source_directory_fd: int, + destination_directory_fd: int, + ) -> None: + nonlocal final_rename_failed + if source_name.startswith(".fo-stage-") and not final_rename_failed: + final_rename_failed = True + destination.write_text("late destination", encoding="utf-8") + original_rename_no_replace( + source_name, + destination_name, + source_directory_fd=source_directory_fd, + destination_directory_fd=destination_directory_fd, + ) + + def racing_link( + src: str | os.PathLike[str], + dst: str | os.PathLike[str], + *, + src_dir_fd: int | None = None, + dst_dir_fd: int | None = None, + follow_symlinks: bool = True, + ) -> None: + nonlocal restore_raced + if ( + os.fspath(src).startswith(".fo-stage-") + and os.fspath(dst) == source.name + and src_dir_fd is not None + and dst_dir_fd is not None + and not restore_raced + ): + restore_raced = True + stage = tmp_path / os.fspath(src) + stage.unlink() + stage.write_text("third-party stage", encoding="utf-8") + original_link( + src, + dst, + src_dir_fd=src_dir_fd, + dst_dir_fd=dst_dir_fd, + follow_symlinks=follow_symlinks, + ) + + monkeypatch.setattr(file_organizer, "_supports_secure_directory_fds", lambda: True) + monkeypatch.setattr(file_organizer, "_rename_no_replace_at", failing_final_rename) + monkeypatch.setattr(file_organizer.os, "link", racing_link) + + with pytest.raises(FileExistsError, match="destination appeared during execution"): + execute_plan(plan) + + assert final_rename_failed + assert restore_raced + assert destination.read_text(encoding="utf-8") == "late destination" + assert source.read_text(encoding="utf-8") == "third-party stage" + stage_files = [ + child for child in tmp_path.iterdir() if child.name.startswith(".fo-stage-") + ] + assert len(stage_files) == 1 + assert stage_files[0].read_text(encoding="utf-8") == "third-party stage" + recovery_files = [ + child for child in tmp_path.iterdir() if child.name.startswith(".fo-recovery-") + ] + assert len(recovery_files) == 1 + assert recovery_files[0].read_text(encoding="utf-8") == "planned source" + + def test_failed_final_rename_after_stage_replacement_recovers_pinned_source_data( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, diff --git a/scripts/_apply_file_organizer_review13.py b/scripts/_apply_file_organizer_review13.py deleted file mode 100644 index e00a8fb..0000000 --- a/scripts/_apply_file_organizer_review13.py +++ /dev/null @@ -1,138 +0,0 @@ -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[1] -CODE = ROOT / "practical-projects/06-file-organizer/file_organizer.py" -TESTS = ROOT / "practical-projects/06-file-organizer/tests/test_atomic_move.py" -READMES = { - ROOT / "practical-projects/06-file-organizer/README.md": ( - "If it does, execution may hard-link that proven stage back to the original source name. If the stage is missing or has been replaced, execution leaves the uncertain stage untouched and, before closing the still-pinned source file descriptor, copies the planned source bytes into an exclusive `.fo-recovery-*` regular file.", - "If it does, execution may attempt a no-replace hard link from that proven stage back to the original source name, but restoration is accepted only after the recreated source pathname itself is re-read and verified to have the pinned identity. If the link fails, races to a different object, leaves the source name missing, or the post-link source identity does not match, execution leaves uncertain entries untouched and, before closing the still-pinned source file descriptor, copies the planned source bytes into an exclusive `.fo-recovery-*` regular file." - ), - ROOT / "practical-projects/06-file-organizer/README.pt-BR.md": ( - "Se corresponder, a execução pode recriar o nome original por hard link a partir desse staging comprovado. Se o staging sumiu ou foi substituído, a execução deixa a entrada incerta intacta e, antes de fechar o descritor ainda pinado da origem, copia os bytes planejados para um arquivo regular exclusivo `.fo-recovery-*`.", - "Se corresponder, a execução pode tentar recriar o nome original por hard link no-replace a partir desse staging comprovado, mas a restauração só é aceita depois que o próprio pathname recriado da origem é relido e verificado com a identidade pinada. Se o link falhar, sofrer corrida para outro objeto, deixar o nome de origem ausente ou a identidade pós-link não corresponder, a execução deixa entradas incertas intactas e, antes de fechar o descritor ainda pinado da origem, copia os bytes planejados para um arquivo regular exclusivo `.fo-recovery-*`." - ), - ROOT / "practical-projects/06-file-organizer/README.es.md": ( - "Si coincide, la ejecución puede recrear el nombre original mediante un hard link desde ese staging comprobado. Si el staging desapareció o fue reemplazado, la ejecución deja intacta la entrada incierta y, antes de cerrar el descriptor todavía fijado del origen, copia los bytes planificados a un archivo regular exclusivo `.fo-recovery-*`.", - "Si coincide, la ejecución puede intentar recrear el nombre original mediante un hard link no-replace desde ese staging comprobado, pero la restauración solo se acepta después de volver a leer el propio pathname recreado del origen y verificar que conserva la identidad fijada. Si el link falla, sufre una carrera hacia otro objeto, deja ausente el nombre de origen o la identidad posterior al link no coincide, la ejecución deja intactas las entradas inciertas y, antes de cerrar el descriptor todavía fijado del origen, copia los bytes planificados a un archivo regular exclusivo `.fo-recovery-*`." - ), -} - - -def replace_once(path: Path, old: str, new: str) -> None: - text = path.read_text(encoding="utf-8") - if text.count(old) != 1: - raise RuntimeError(f"expected exactly one anchor in {path}: {old[:80]!r}") - path.write_text(text.replace(old, new, 1), encoding="utf-8") - - -code = CODE.read_text(encoding="utf-8") -old_code = '''def _preserve_stage_at(stage_name: str, source_name: str, *, root_fd: int) -> None:\n \"\"\"Best-effort restore by linking only; never delete a raced staging entry.\"\"\"\n try:\n os.link(\n stage_name,\n source_name,\n src_dir_fd=root_fd,\n dst_dir_fd=root_fd,\n follow_symlinks=False,\n )\n except OSError:\n pass\n''' -new_code = '''def _preserve_stage_at(\n stage_name: str,\n source_name: str,\n *,\n root_fd: int,\n expected_identity: _FileIdentity | None = None,\n) -> bool:\n \"\"\"Best-effort restore without deleting raced entries; optionally prove result.\"\"\"\n try:\n os.link(\n stage_name,\n source_name,\n src_dir_fd=root_fd,\n dst_dir_fd=root_fd,\n follow_symlinks=False,\n )\n except OSError:\n pass\n\n if expected_identity is None:\n return False\n\n try:\n restored_identity = _regular_identity_at(\n source_name,\n directory_fd=root_fd,\n )\n except OSError:\n return False\n return restored_identity == expected_identity\n''' -if code.count(old_code) != 1: - raise RuntimeError("preserve-stage helper anchor not found exactly once") -code = code.replace(old_code, new_code, 1) -old_branch = ''' if staged_identity == expected_identity:\n _preserve_stage_at(stage_name, source_name, root_fd=root_fd)\n return None\n\n return _recover_pinned_source_at(\n''' -new_branch = ''' if staged_identity == expected_identity:\n restored = _preserve_stage_at(\n stage_name,\n source_name,\n root_fd=root_fd,\n expected_identity=expected_identity,\n )\n if restored:\n return None\n\n return _recover_pinned_source_at(\n''' -if code.count(old_branch) != 1: - raise RuntimeError("post-claim recovery branch anchor not found exactly once") -code = code.replace(old_branch, new_branch, 1) -CODE.write_text(code, encoding="utf-8") - -for path, (old, new) in READMES.items(): - replace_once(path, old, new) - - -tests = TESTS.read_text(encoding="utf-8") -anchor = '''def test_failed_final_rename_after_stage_replacement_recovers_pinned_source_data(\n''' -if tests.count(anchor) != 1: - raise RuntimeError("test insertion anchor not found exactly once") -new_test = r''' - -def test_failed_final_rename_stage_changes_during_restore_recovers_pinned_source_data( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - if not file_organizer._supports_secure_directory_fds(): - pytest.skip("secure directory descriptors are unavailable on this platform") - - source = tmp_path / "notes.txt" - source.write_text("planned source", encoding="utf-8") - plan = plan_organization(tmp_path) - destination = tmp_path / "documents" / "notes.txt" - original_rename_no_replace = file_organizer._rename_no_replace_at - original_link = os.link - final_rename_failed = False - restore_raced = False - - def failing_final_rename( - source_name: str, - destination_name: str, - *, - source_directory_fd: int, - destination_directory_fd: int, - ) -> None: - nonlocal final_rename_failed - if source_name.startswith(".fo-stage-") and not final_rename_failed: - final_rename_failed = True - destination.write_text("late destination", encoding="utf-8") - original_rename_no_replace( - source_name, - destination_name, - source_directory_fd=source_directory_fd, - destination_directory_fd=destination_directory_fd, - ) - - def racing_link( - src: str | os.PathLike[str], - dst: str | os.PathLike[str], - *, - src_dir_fd: int | None = None, - dst_dir_fd: int | None = None, - follow_symlinks: bool = True, - ) -> None: - nonlocal restore_raced - if ( - os.fspath(src).startswith(".fo-stage-") - and os.fspath(dst) == source.name - and src_dir_fd is not None - and dst_dir_fd is not None - and not restore_raced - ): - restore_raced = True - stage = tmp_path / os.fspath(src) - stage.unlink() - stage.write_text("third-party stage", encoding="utf-8") - original_link( - src, - dst, - src_dir_fd=src_dir_fd, - dst_dir_fd=dst_dir_fd, - follow_symlinks=follow_symlinks, - ) - - monkeypatch.setattr(file_organizer, "_rename_no_replace_at", failing_final_rename) - monkeypatch.setattr(file_organizer.os, "link", racing_link) - - with pytest.raises(FileExistsError, match="destination appeared during execution"): - execute_plan(plan) - - assert final_rename_failed - assert restore_raced - assert destination.read_text(encoding="utf-8") == "late destination" - assert source.read_text(encoding="utf-8") == "third-party stage" - stage_files = [ - child for child in tmp_path.iterdir() if child.name.startswith(".fo-stage-") - ] - assert len(stage_files) == 1 - assert stage_files[0].read_text(encoding="utf-8") == "third-party stage" - recovery_files = [ - child for child in tmp_path.iterdir() if child.name.startswith(".fo-recovery-") - ] - assert len(recovery_files) == 1 - assert recovery_files[0].read_text(encoding="utf-8") == "planned source" - - -''' -tests = tests.replace(anchor, new_test + anchor, 1) -TESTS.write_text(tests, encoding="utf-8") From e14929e08b47defc12569ddd32558fdaaa097813 Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Tue, 1 Sep 2026 18:35:49 -0300 Subject: [PATCH 103/117] Trigger Review 13 quality checks --- .github/.review13-ci-trigger | 1 + 1 file changed, 1 insertion(+) create mode 100644 .github/.review13-ci-trigger diff --git a/.github/.review13-ci-trigger b/.github/.review13-ci-trigger new file mode 100644 index 0000000..30bd686 --- /dev/null +++ b/.github/.review13-ci-trigger @@ -0,0 +1 @@ +review13 From e2828f65adcffa8e5870c49a38817a42af04336b Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Tue, 1 Sep 2026 18:36:03 -0300 Subject: [PATCH 104/117] Remove Review 13 CI trigger --- .github/.review13-ci-trigger | 1 - 1 file changed, 1 deletion(-) delete mode 100644 .github/.review13-ci-trigger diff --git a/.github/.review13-ci-trigger b/.github/.review13-ci-trigger deleted file mode 100644 index 30bd686..0000000 --- a/.github/.review13-ci-trigger +++ /dev/null @@ -1 +0,0 @@ -review13 From 174bea34a342c4298a34f980e8f68ef458140fcf Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Tue, 1 Sep 2026 21:02:48 -0300 Subject: [PATCH 105/117] Add temporary Review 14 patch helper --- scripts/_temp_review14_fix.py | 232 ++++++++++++++++++++++++++++++++++ 1 file changed, 232 insertions(+) create mode 100644 scripts/_temp_review14_fix.py diff --git a/scripts/_temp_review14_fix.py b/scripts/_temp_review14_fix.py new file mode 100644 index 0000000..9ebf043 --- /dev/null +++ b/scripts/_temp_review14_fix.py @@ -0,0 +1,232 @@ +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +def replace_once(path: Path, old: str, new: str) -> None: + text = path.read_text(encoding="utf-8") + if text.count(old) != 1: + raise RuntimeError(f"expected exactly one anchor in {path}: found {text.count(old)}") + path.write_text(text.replace(old, new, 1), encoding="utf-8") + + +# 1) Verify the recovery pathname still names the created recovery inode. +organizer = ROOT / "practical-projects/06-file-organizer/file_organizer.py" +old_recovery = '''def _recover_pinned_source_at( + source_fd: int, + source_name: str, + *, + root_fd: int, +) -> str: + """Persist bytes from the pinned source FD into an exclusive recovery file.""" + source_stat = os.fstat(source_fd) + mode = stat.S_IMODE(source_stat.st_mode) + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL + if hasattr(os, "O_CLOEXEC"): + flags |= os.O_CLOEXEC + + recovery_fd: int | None = None + recovery_name = "" + for _ in range(16): + recovery_name = _make_recovery_name(source_name) + try: + recovery_fd = os.open( + recovery_name, + flags, + mode, + dir_fd=root_fd, + ) + except FileExistsError: + continue + break + + if recovery_fd is None: + raise FileExistsError( + f"could not allocate recovery entry for planned source: {source_name}" + ) + + original_offset = os.lseek(source_fd, 0, os.SEEK_CUR) + try: + os.lseek(source_fd, 0, os.SEEK_SET) + while True: + chunk = os.read(source_fd, 1024 * 1024) + if not chunk: + break + view = memoryview(chunk) + while view: + written = os.write(recovery_fd, view) + if written <= 0: + raise OSError( + "could not persist pinned source recovery data" + ) + view = view[written:] + os.fchmod(recovery_fd, mode) + os.fsync(recovery_fd) + finally: + os.lseek(source_fd, original_offset, os.SEEK_SET) + os.close(recovery_fd) + + return recovery_name +''' +new_recovery = '''def _recover_pinned_source_at( + source_fd: int, + source_name: str, + *, + root_fd: int, +) -> str: + """Persist bytes from the pinned source FD into a proven recovery pathname.""" + source_stat = os.fstat(source_fd) + mode = stat.S_IMODE(source_stat.st_mode) + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL + if hasattr(os, "O_CLOEXEC"): + flags |= os.O_CLOEXEC + + recovery_fd: int | None = None + recovery_name = "" + for _ in range(16): + recovery_name = _make_recovery_name(source_name) + try: + recovery_fd = os.open( + recovery_name, + flags, + mode, + dir_fd=root_fd, + ) + except FileExistsError: + continue + break + + if recovery_fd is None: + raise FileExistsError( + f"could not allocate recovery entry for planned source: {source_name}" + ) + + recovery_identity = _identity_from_regular_stat( + os.fstat(recovery_fd), + filename=recovery_name, + ) + original_offset = os.lseek(source_fd, 0, os.SEEK_CUR) + try: + os.lseek(source_fd, 0, os.SEEK_SET) + while True: + chunk = os.read(source_fd, 1024 * 1024) + if not chunk: + break + view = memoryview(chunk) + while view: + written = os.write(recovery_fd, view) + if written <= 0: + raise OSError( + "could not persist pinned source recovery data" + ) + view = view[written:] + os.fchmod(recovery_fd, mode) + os.fsync(recovery_fd) + + try: + recovery_path_identity = _regular_identity_at( + recovery_name, + directory_fd=root_fd, + ) + except OSError as exc: + raise RuntimeError( + f"recovery pathname changed during execution: {recovery_name}" + ) from exc + if recovery_path_identity != recovery_identity: + raise RuntimeError( + f"recovery pathname changed during execution: {recovery_name}" + ) + finally: + os.lseek(source_fd, original_offset, os.SEEK_SET) + os.close(recovery_fd) + + return recovery_name +''' +replace_once(organizer, old_recovery, new_recovery) + +# 2) Regression: unlink the recovery pathname during fsync and prove no false retention. +tests = ROOT / "practical-projects/06-file-organizer/tests/test_atomic_move.py" +anchor = '''from file_organizer import execute_plan, plan_organization + + +def test_execute_plan_never_replaces_destination_created_after_preflight( +''' +insert = '''from file_organizer import execute_plan, plan_organization + + +def test_recovery_path_removed_during_fsync_is_not_reported_as_retained( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + if not file_organizer._supports_secure_directory_fds(): + pytest.skip("secure directory descriptors are unavailable on this platform") + + source = tmp_path / "notes.txt" + source.write_text("planned source", encoding="utf-8") + root_fd = file_organizer._open_source_directory_fd(tmp_path) + source_fd = os.open(source, os.O_RDONLY) + original_fsync = os.fsync + recovery_unlinked = False + + def unlink_recovery_during_fsync(fd: int) -> None: + nonlocal recovery_unlinked + original_fsync(fd) + if fd == source_fd or recovery_unlinked: + return + recovery_files = [ + child + for child in tmp_path.iterdir() + if child.name.startswith(".fo-recovery-") + ] + assert len(recovery_files) == 1 + recovery_files[0].unlink() + recovery_unlinked = True + + monkeypatch.setattr(file_organizer.os, "fsync", unlink_recovery_during_fsync) + + try: + with pytest.raises(RuntimeError, match="recovery pathname changed during execution"): + file_organizer._recover_pinned_source_at( + source_fd, + source.name, + root_fd=root_fd, + ) + + assert recovery_unlinked + assert not any( + child.name.startswith(".fo-recovery-") for child in tmp_path.iterdir() + ) + os.lseek(source_fd, 0, os.SEEK_SET) + assert os.read(source_fd, 1024) == b"planned source" + finally: + os.close(source_fd) + os.close(root_fd) + + +def test_execute_plan_never_replaces_destination_created_after_preflight( +''' +replace_once(tests, anchor, insert) + +# 3) Documentation in EN / PT-BR / ES. +readme = ROOT / "practical-projects/06-file-organizer/README.md" +old_en = '''A staging pathname is not an inode lock. After the source has been claimed, every failure path rechecks whether the staging entry still matches the pinned source identity. If it does, execution may attempt a no-replace hard link from that proven stage back to the original source name, but restoration is accepted only after the recreated source pathname itself is re-read and verified to have the pinned identity. If the link fails, races to a different object, leaves the source name missing, or the post-link source identity does not match, execution leaves uncertain entries untouched and, before closing the still-pinned source file descriptor, copies the planned source bytes into an exclusive `.fo-recovery-*` regular file. This also covers a final `RENAME_NOREPLACE` failure caused by a destination that appears after the stage was raced. If a replacement stage is successfully renamed and destination identity verification detects the mismatch, the unrelated destination is likewise left intact while the pinned bytes are recovered. Recovery preserves data without claiming that the original inode survived the race. +''' +new_en = '''A staging pathname is not an inode lock. After the source has been claimed, every failure path rechecks whether the staging entry still matches the pinned source identity. If it does, execution may attempt a no-replace hard link from that proven stage back to the original source name, but restoration is accepted only after the recreated source pathname itself is re-read and verified to have the pinned identity. If the link fails, races to a different object, leaves the source name missing, or the post-link source identity does not match, execution leaves uncertain entries untouched and, before closing the still-pinned source file descriptor, copies the planned source bytes into an exclusive `.fo-recovery-*` regular file. That recovery file is not reported as retained merely because its descriptor was written and `fsync()` completed: while the recovery descriptor is still open, execution re-reads the recovery pathname through the anchored root and requires it to name the same regular-file `(st_dev, st_ino)`. A missing, renamed, or replaced recovery pathname raises instead of falsely claiming durable retention, and uncertain third-party entries are not deleted or overwritten. This also covers a final `RENAME_NOREPLACE` failure caused by a destination that appears after the stage was raced. If a replacement stage is successfully renamed and destination identity verification detects the mismatch, the unrelated destination is likewise left intact while the pinned bytes are recovered. Recovery preserves data only when the pathname used to report that preservation is itself proven. +''' +replace_once(readme, old_en, new_en) + +readme_pt = ROOT / "practical-projects/06-file-organizer/README.pt-BR.md" +old_pt = '''Um pathname de staging não funciona como lock de inode. Depois que a origem foi claimada, todo caminho de falha revalida se a entrada de staging ainda corresponde à identidade pinada da origem. Se corresponder, a execução pode tentar recriar o nome original por hard link no-replace a partir desse staging comprovado, mas a restauração só é aceita depois que o próprio pathname recriado da origem é relido e verificado com a identidade pinada. Se o link falhar, sofrer corrida para outro objeto, deixar o nome de origem ausente ou a identidade pós-link não corresponder, a execução deixa entradas incertas intactas e, antes de fechar o descritor ainda pinado da origem, copia os bytes planejados para um arquivo regular exclusivo `.fo-recovery-*`. Isso também cobre uma falha final de `RENAME_NOREPLACE` causada por um destino que aparece depois de uma corrida sobre o staging. Se um staging substituto for renomeado com sucesso e a verificação de identidade do destino detectar a divergência, o destino alheio também permanece intacto enquanto os bytes pinados são recuperados. A recuperação preserva os dados sem afirmar que o inode original sobreviveu à corrida. +''' +new_pt = '''Um pathname de staging não funciona como lock de inode. Depois que a origem foi claimada, todo caminho de falha revalida se a entrada de staging ainda corresponde à identidade pinada da origem. Se corresponder, a execução pode tentar recriar o nome original por hard link no-replace a partir desse staging comprovado, mas a restauração só é aceita depois que o próprio pathname recriado da origem é relido e verificado com a identidade pinada. Se o link falhar, sofrer corrida para outro objeto, deixar o nome de origem ausente ou a identidade pós-link não corresponder, a execução deixa entradas incertas intactas e, antes de fechar o descritor ainda pinado da origem, copia os bytes planejados para um arquivo regular exclusivo `.fo-recovery-*`. Esse recovery não é reportado como preservado apenas porque seu descritor foi gravado e o `fsync()` terminou: enquanto o descritor do recovery ainda está aberto, a execução relê o pathname de recuperação pelo root ancorado e exige que ele aponte para o mesmo arquivo regular `(st_dev, st_ino)`. Se o pathname sumir, for renomeado ou substituído, a execução falha em vez de afirmar falsamente que os dados foram retidos, sem excluir nem sobrescrever entradas incertas de terceiros. Isso também cobre uma falha final de `RENAME_NOREPLACE` causada por um destino que aparece depois de uma corrida sobre o staging. Se um staging substituto for renomeado com sucesso e a verificação de identidade do destino detectar a divergência, o destino alheio também permanece intacto enquanto os bytes pinados são recuperados. A recuperação só afirma preservação quando o próprio pathname usado para reportá-la é comprovado. +''' +replace_once(readme_pt, old_pt, new_pt) + +readme_es = ROOT / "practical-projects/06-file-organizer/README.es.md" +old_es = '''Un pathname de staging no funciona como lock de inode. Después de reclamar el origen, cada ruta de fallo vuelve a comprobar si la entrada de staging todavía coincide con la identidad fijada del origen. Si coincide, la ejecución puede intentar recrear el nombre original mediante un hard link no-replace desde ese staging comprobado, pero la restauración solo se acepta después de volver a leer el propio pathname recreado del origen y verificar que conserva la identidad fijada. Si el link falla, sufre una carrera hacia otro objeto, deja ausente el nombre de origen o la identidad posterior al link no coincide, la ejecución deja intactas las entradas inciertas y, antes de cerrar el descriptor todavía fijado del origen, copia los bytes planificados a un archivo regular exclusivo `.fo-recovery-*`. Esto también cubre un fallo final de `RENAME_NOREPLACE` causado por un destino que aparece después de una carrera sobre el staging. Si un staging de reemplazo se renombra con éxito y la verificación de identidad del destino detecta la divergencia, el destino ajeno también queda intacto mientras se recuperan los bytes fijados. La recuperación conserva los datos sin afirmar que el inode original haya sobrevivido a la carrera. +''' +new_es = '''Un pathname de staging no funciona como lock de inode. Después de reclamar el origen, cada ruta de fallo vuelve a comprobar si la entrada de staging todavía coincide con la identidad fijada del origen. Si coincide, la ejecución puede intentar recrear el nombre original mediante un hard link no-replace desde ese staging comprobado, pero la restauración solo se acepta después de volver a leer el propio pathname recreado del origen y verificar que conserva la identidad fijada. Si el link falla, sufre una carrera hacia otro objeto, deja ausente el nombre de origen o la identidad posterior al link no coincide, la ejecución deja intactas las entradas inciertas y, antes de cerrar el descriptor todavía fijado del origen, copia los bytes planificados a un archivo regular exclusivo `.fo-recovery-*`. Ese recovery no se informa como conservado solo porque su descriptor se haya escrito y `fsync()` haya terminado: mientras el descriptor de recovery sigue abierto, la ejecución vuelve a leer el pathname de recuperación a través de la raíz anclada y exige que nombre el mismo archivo regular `(st_dev, st_ino)`. Si el pathname desaparece, se renombra o se reemplaza, la ejecución falla en lugar de afirmar falsamente que los datos quedaron retenidos, sin borrar ni sobrescribir entradas inciertas de terceros. Esto también cubre un fallo final de `RENAME_NOREPLACE` causado por un destino que aparece después de una carrera sobre el staging. Si un staging de reemplazo se renombra con éxito y la verificación de identidad del destino detecta la divergencia, el destino ajeno también queda intacto mientras se recuperan los bytes fijados. La recuperación solo afirma conservación cuando el propio pathname usado para informarla queda demostrado. +''' +replace_once(readme_es, old_es, new_es) + +print("Review 14 patch applied") From ac03b91e26e92b11dc339b236a57db54503db76c Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Tue, 1 Sep 2026 21:03:02 -0300 Subject: [PATCH 106/117] Add temporary Review 14 patch workflow --- .github/workflows/temp-review14-fix.yml | 53 +++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 .github/workflows/temp-review14-fix.yml diff --git a/.github/workflows/temp-review14-fix.yml b/.github/workflows/temp-review14-fix.yml new file mode 100644 index 0000000..37644f3 --- /dev/null +++ b/.github/workflows/temp-review14-fix.yml @@ -0,0 +1,53 @@ +name: Temporary Review 14 fix + +on: + push: + branches: + - phase-10-file-organizer + paths: + - .github/workflows/temp-review14-fix.yml + +permissions: + contents: write + +jobs: + apply-fix: + runs-on: ubuntu-latest + steps: + - name: Check out branch + uses: actions/checkout@v6 + with: + ref: phase-10-file-organizer + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.13' + + - name: Install pytest + run: python -m pip install --disable-pip-version-check 'pytest>=9.1,<9.2' + + - name: Apply focused patch + run: python scripts/_temp_review14_fix.py + + - name: Validate focused File Organizer suite + run: python -m pytest -q practical-projects/06-file-organizer/tests + + - name: Validate links and repository structure + run: | + python scripts/check_internal_links.py + python scripts/validate_repository_structure.py + git diff --check + + - name: Commit functional fix and remove temporary tooling + shell: bash + run: | + git config user.name 'RamonRDR' + git config user.email 'ramoncorreka@hotmail.com' + rm scripts/_temp_review14_fix.py + rm .github/workflows/temp-review14-fix.yml + git add -A + git status --short + git commit -m 'Verify recovery pathname retention' + git push origin HEAD:phase-10-file-organizer From b2da0b13b02b31871ab32478b4b8d6aa28d73cb8 Mon Sep 17 00:00:00 2001 From: RamonRDR Date: Wed, 2 Sep 2026 00:03:14 +0000 Subject: [PATCH 107/117] Verify recovery pathname retention --- .github/workflows/temp-review14-fix.yml | 53 ---- .../06-file-organizer/README.es.md | 2 +- .../06-file-organizer/README.md | 2 +- .../06-file-organizer/README.pt-BR.md | 2 +- .../06-file-organizer/file_organizer.py | 20 +- .../tests/test_atomic_move.py | 49 ++++ scripts/_temp_review14_fix.py | 232 ------------------ 7 files changed, 71 insertions(+), 289 deletions(-) delete mode 100644 .github/workflows/temp-review14-fix.yml delete mode 100644 scripts/_temp_review14_fix.py diff --git a/.github/workflows/temp-review14-fix.yml b/.github/workflows/temp-review14-fix.yml deleted file mode 100644 index 37644f3..0000000 --- a/.github/workflows/temp-review14-fix.yml +++ /dev/null @@ -1,53 +0,0 @@ -name: Temporary Review 14 fix - -on: - push: - branches: - - phase-10-file-organizer - paths: - - .github/workflows/temp-review14-fix.yml - -permissions: - contents: write - -jobs: - apply-fix: - runs-on: ubuntu-latest - steps: - - name: Check out branch - uses: actions/checkout@v6 - with: - ref: phase-10-file-organizer - fetch-depth: 0 - - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: '3.13' - - - name: Install pytest - run: python -m pip install --disable-pip-version-check 'pytest>=9.1,<9.2' - - - name: Apply focused patch - run: python scripts/_temp_review14_fix.py - - - name: Validate focused File Organizer suite - run: python -m pytest -q practical-projects/06-file-organizer/tests - - - name: Validate links and repository structure - run: | - python scripts/check_internal_links.py - python scripts/validate_repository_structure.py - git diff --check - - - name: Commit functional fix and remove temporary tooling - shell: bash - run: | - git config user.name 'RamonRDR' - git config user.email 'ramoncorreka@hotmail.com' - rm scripts/_temp_review14_fix.py - rm .github/workflows/temp-review14-fix.yml - git add -A - git status --short - git commit -m 'Verify recovery pathname retention' - git push origin HEAD:phase-10-file-organizer diff --git a/practical-projects/06-file-organizer/README.es.md b/practical-projects/06-file-organizer/README.es.md index 38c030e..9085474 100644 --- a/practical-projects/06-file-organizer/README.es.md +++ b/practical-projects/06-file-organizer/README.es.md @@ -273,7 +273,7 @@ Los errores concurrentes pueden dejar estado incierto. La recuperación prioriza Si la ejecución ya movió el origen al staging y después detecta una condición insegura, puede crear un hard link no-replace de vuelta al nombre de origen cuando sea posible. No elimina a ciegas el staging. -Un pathname de staging no funciona como lock de inode. Después de reclamar el origen, cada ruta de fallo vuelve a comprobar si la entrada de staging todavía coincide con la identidad fijada del origen. Si coincide, la ejecución puede intentar recrear el nombre original mediante un hard link no-replace desde ese staging comprobado, pero la restauración solo se acepta después de volver a leer el propio pathname recreado del origen y verificar que conserva la identidad fijada. Si el link falla, sufre una carrera hacia otro objeto, deja ausente el nombre de origen o la identidad posterior al link no coincide, la ejecución deja intactas las entradas inciertas y, antes de cerrar el descriptor todavía fijado del origen, copia los bytes planificados a un archivo regular exclusivo `.fo-recovery-*`. Esto también cubre un fallo final de `RENAME_NOREPLACE` causado por un destino que aparece después de una carrera sobre el staging. Si un staging de reemplazo se renombra con éxito y la verificación de identidad del destino detecta la divergencia, el destino ajeno también queda intacto mientras se recuperan los bytes fijados. La recuperación conserva los datos sin afirmar que el inode original haya sobrevivido a la carrera. +Un pathname de staging no funciona como lock de inode. Después de reclamar el origen, cada ruta de fallo vuelve a comprobar si la entrada de staging todavía coincide con la identidad fijada del origen. Si coincide, la ejecución puede intentar recrear el nombre original mediante un hard link no-replace desde ese staging comprobado, pero la restauración solo se acepta después de volver a leer el propio pathname recreado del origen y verificar que conserva la identidad fijada. Si el link falla, sufre una carrera hacia otro objeto, deja ausente el nombre de origen o la identidad posterior al link no coincide, la ejecución deja intactas las entradas inciertas y, antes de cerrar el descriptor todavía fijado del origen, copia los bytes planificados a un archivo regular exclusivo `.fo-recovery-*`. Ese recovery no se informa como conservado solo porque su descriptor se haya escrito y `fsync()` haya terminado: mientras el descriptor de recovery sigue abierto, la ejecución vuelve a leer el pathname de recuperación a través de la raíz anclada y exige que nombre el mismo archivo regular `(st_dev, st_ino)`. Si el pathname desaparece, se renombra o se reemplaza, la ejecución falla en lugar de afirmar falsamente que los datos quedaron retenidos, sin borrar ni sobrescribir entradas inciertas de terceros. Esto también cubre un fallo final de `RENAME_NOREPLACE` causado por un destino que aparece después de una carrera sobre el staging. Si un staging de reemplazo se renombra con éxito y la verificación de identidad del destino detecta la divergencia, el destino ajeno también queda intacto mientras se recuperan los bytes fijados. La recuperación solo afirma conservación cuando el propio pathname usado para informarla queda demostrado. Por ello, la ejecución segura en Linux exige deliberadamente permiso de lectura para cada archivo regular planificado. La legibilidad se valida antes de crear los directorios de categoría y de nuevo al fijar el inode del origen para la mutación; los fallos de permisos se informan como `PermissionError`, no como un falso cambio de identidad del origen. diff --git a/practical-projects/06-file-organizer/README.md b/practical-projects/06-file-organizer/README.md index 192916d..8eb1fd5 100644 --- a/practical-projects/06-file-organizer/README.md +++ b/practical-projects/06-file-organizer/README.md @@ -273,7 +273,7 @@ Concurrency errors can leave uncertain state. Recovery therefore favors preserva If execution has already claimed the source into a staging entry and later detects an unsafe condition, it may create a no-replace hard link back to the original source name when possible. It does not blindly delete the staging entry. -A staging pathname is not an inode lock. After the source has been claimed, every failure path rechecks whether the staging entry still matches the pinned source identity. If it does, execution may attempt a no-replace hard link from that proven stage back to the original source name, but restoration is accepted only after the recreated source pathname itself is re-read and verified to have the pinned identity. If the link fails, races to a different object, leaves the source name missing, or the post-link source identity does not match, execution leaves uncertain entries untouched and, before closing the still-pinned source file descriptor, copies the planned source bytes into an exclusive `.fo-recovery-*` regular file. This also covers a final `RENAME_NOREPLACE` failure caused by a destination that appears after the stage was raced. If a replacement stage is successfully renamed and destination identity verification detects the mismatch, the unrelated destination is likewise left intact while the pinned bytes are recovered. Recovery preserves data without claiming that the original inode survived the race. +A staging pathname is not an inode lock. After the source has been claimed, every failure path rechecks whether the staging entry still matches the pinned source identity. If it does, execution may attempt a no-replace hard link from that proven stage back to the original source name, but restoration is accepted only after the recreated source pathname itself is re-read and verified to have the pinned identity. If the link fails, races to a different object, leaves the source name missing, or the post-link source identity does not match, execution leaves uncertain entries untouched and, before closing the still-pinned source file descriptor, copies the planned source bytes into an exclusive `.fo-recovery-*` regular file. That recovery file is not reported as retained merely because its descriptor was written and `fsync()` completed: while the recovery descriptor is still open, execution re-reads the recovery pathname through the anchored root and requires it to name the same regular-file `(st_dev, st_ino)`. A missing, renamed, or replaced recovery pathname raises instead of falsely claiming durable retention, and uncertain third-party entries are not deleted or overwritten. This also covers a final `RENAME_NOREPLACE` failure caused by a destination that appears after the stage was raced. If a replacement stage is successfully renamed and destination identity verification detects the mismatch, the unrelated destination is likewise left intact while the pinned bytes are recovered. Recovery preserves data only when the pathname used to report that preservation is itself proven. Safe Linux execution therefore deliberately requires read access to each planned regular file. Readability is validated before category directories are created and again when the source inode is pinned for mutation; permission failures are reported as `PermissionError`, not as a false source-identity change. diff --git a/practical-projects/06-file-organizer/README.pt-BR.md b/practical-projects/06-file-organizer/README.pt-BR.md index 9ea4e77..4771273 100644 --- a/practical-projects/06-file-organizer/README.pt-BR.md +++ b/practical-projects/06-file-organizer/README.pt-BR.md @@ -273,7 +273,7 @@ Erros concorrentes podem deixar estado incerto. A recuperação prioriza preserv Se a execução já moveu a origem para staging e depois detecta condição insegura, ela pode criar um hard link no-replace de volta para o nome de origem quando possível. Ela não apaga cegamente o staging. -Um pathname de staging não funciona como lock de inode. Depois que a origem foi claimada, todo caminho de falha revalida se a entrada de staging ainda corresponde à identidade pinada da origem. Se corresponder, a execução pode tentar recriar o nome original por hard link no-replace a partir desse staging comprovado, mas a restauração só é aceita depois que o próprio pathname recriado da origem é relido e verificado com a identidade pinada. Se o link falhar, sofrer corrida para outro objeto, deixar o nome de origem ausente ou a identidade pós-link não corresponder, a execução deixa entradas incertas intactas e, antes de fechar o descritor ainda pinado da origem, copia os bytes planejados para um arquivo regular exclusivo `.fo-recovery-*`. Isso também cobre uma falha final de `RENAME_NOREPLACE` causada por um destino que aparece depois de uma corrida sobre o staging. Se um staging substituto for renomeado com sucesso e a verificação de identidade do destino detectar a divergência, o destino alheio também permanece intacto enquanto os bytes pinados são recuperados. A recuperação preserva os dados sem afirmar que o inode original sobreviveu à corrida. +Um pathname de staging não funciona como lock de inode. Depois que a origem foi claimada, todo caminho de falha revalida se a entrada de staging ainda corresponde à identidade pinada da origem. Se corresponder, a execução pode tentar recriar o nome original por hard link no-replace a partir desse staging comprovado, mas a restauração só é aceita depois que o próprio pathname recriado da origem é relido e verificado com a identidade pinada. Se o link falhar, sofrer corrida para outro objeto, deixar o nome de origem ausente ou a identidade pós-link não corresponder, a execução deixa entradas incertas intactas e, antes de fechar o descritor ainda pinado da origem, copia os bytes planejados para um arquivo regular exclusivo `.fo-recovery-*`. Esse recovery não é reportado como preservado apenas porque seu descritor foi gravado e o `fsync()` terminou: enquanto o descritor do recovery ainda está aberto, a execução relê o pathname de recuperação pelo root ancorado e exige que ele aponte para o mesmo arquivo regular `(st_dev, st_ino)`. Se o pathname sumir, for renomeado ou substituído, a execução falha em vez de afirmar falsamente que os dados foram retidos, sem excluir nem sobrescrever entradas incertas de terceiros. Isso também cobre uma falha final de `RENAME_NOREPLACE` causada por um destino que aparece depois de uma corrida sobre o staging. Se um staging substituto for renomeado com sucesso e a verificação de identidade do destino detectar a divergência, o destino alheio também permanece intacto enquanto os bytes pinados são recuperados. A recuperação só afirma preservação quando o próprio pathname usado para reportá-la é comprovado. Por isso, a execução segura no Linux exige deliberadamente permissão de leitura para cada arquivo regular planejado. A legibilidade é validada antes da criação das pastas de categoria e novamente ao pinar o inode da origem para a mutação; falhas de permissão são reportadas como `PermissionError`, e não como uma falsa mudança de identidade da origem. diff --git a/practical-projects/06-file-organizer/file_organizer.py b/practical-projects/06-file-organizer/file_organizer.py index c6b6939..7cbce40 100644 --- a/practical-projects/06-file-organizer/file_organizer.py +++ b/practical-projects/06-file-organizer/file_organizer.py @@ -684,7 +684,7 @@ def _recover_pinned_source_at( *, root_fd: int, ) -> str: - """Persist bytes from the pinned source FD into an exclusive recovery file.""" + """Persist bytes from the pinned source FD into a proven recovery pathname.""" source_stat = os.fstat(source_fd) mode = stat.S_IMODE(source_stat.st_mode) flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL @@ -711,6 +711,10 @@ def _recover_pinned_source_at( f"could not allocate recovery entry for planned source: {source_name}" ) + recovery_identity = _identity_from_regular_stat( + os.fstat(recovery_fd), + filename=recovery_name, + ) original_offset = os.lseek(source_fd, 0, os.SEEK_CUR) try: os.lseek(source_fd, 0, os.SEEK_SET) @@ -728,6 +732,20 @@ def _recover_pinned_source_at( view = view[written:] os.fchmod(recovery_fd, mode) os.fsync(recovery_fd) + + try: + recovery_path_identity = _regular_identity_at( + recovery_name, + directory_fd=root_fd, + ) + except OSError as exc: + raise RuntimeError( + f"recovery pathname changed during execution: {recovery_name}" + ) from exc + if recovery_path_identity != recovery_identity: + raise RuntimeError( + f"recovery pathname changed during execution: {recovery_name}" + ) finally: os.lseek(source_fd, original_offset, os.SEEK_SET) os.close(recovery_fd) diff --git a/practical-projects/06-file-organizer/tests/test_atomic_move.py b/practical-projects/06-file-organizer/tests/test_atomic_move.py index 4a7cf06..f7e3873 100644 --- a/practical-projects/06-file-organizer/tests/test_atomic_move.py +++ b/practical-projects/06-file-organizer/tests/test_atomic_move.py @@ -8,6 +8,55 @@ from file_organizer import execute_plan, plan_organization +def test_recovery_path_removed_during_fsync_is_not_reported_as_retained( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + if not file_organizer._supports_secure_directory_fds(): + pytest.skip("secure directory descriptors are unavailable on this platform") + + source = tmp_path / "notes.txt" + source.write_text("planned source", encoding="utf-8") + root_fd = file_organizer._open_source_directory_fd(tmp_path) + source_fd = os.open(source, os.O_RDONLY) + original_fsync = os.fsync + recovery_unlinked = False + + def unlink_recovery_during_fsync(fd: int) -> None: + nonlocal recovery_unlinked + original_fsync(fd) + if fd == source_fd or recovery_unlinked: + return + recovery_files = [ + child + for child in tmp_path.iterdir() + if child.name.startswith(".fo-recovery-") + ] + assert len(recovery_files) == 1 + recovery_files[0].unlink() + recovery_unlinked = True + + monkeypatch.setattr(file_organizer.os, "fsync", unlink_recovery_during_fsync) + + try: + with pytest.raises(RuntimeError, match="recovery pathname changed during execution"): + file_organizer._recover_pinned_source_at( + source_fd, + source.name, + root_fd=root_fd, + ) + + assert recovery_unlinked + assert not any( + child.name.startswith(".fo-recovery-") for child in tmp_path.iterdir() + ) + os.lseek(source_fd, 0, os.SEEK_SET) + assert os.read(source_fd, 1024) == b"planned source" + finally: + os.close(source_fd) + os.close(root_fd) + + def test_execute_plan_never_replaces_destination_created_after_preflight( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, diff --git a/scripts/_temp_review14_fix.py b/scripts/_temp_review14_fix.py deleted file mode 100644 index 9ebf043..0000000 --- a/scripts/_temp_review14_fix.py +++ /dev/null @@ -1,232 +0,0 @@ -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[1] - - -def replace_once(path: Path, old: str, new: str) -> None: - text = path.read_text(encoding="utf-8") - if text.count(old) != 1: - raise RuntimeError(f"expected exactly one anchor in {path}: found {text.count(old)}") - path.write_text(text.replace(old, new, 1), encoding="utf-8") - - -# 1) Verify the recovery pathname still names the created recovery inode. -organizer = ROOT / "practical-projects/06-file-organizer/file_organizer.py" -old_recovery = '''def _recover_pinned_source_at( - source_fd: int, - source_name: str, - *, - root_fd: int, -) -> str: - """Persist bytes from the pinned source FD into an exclusive recovery file.""" - source_stat = os.fstat(source_fd) - mode = stat.S_IMODE(source_stat.st_mode) - flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL - if hasattr(os, "O_CLOEXEC"): - flags |= os.O_CLOEXEC - - recovery_fd: int | None = None - recovery_name = "" - for _ in range(16): - recovery_name = _make_recovery_name(source_name) - try: - recovery_fd = os.open( - recovery_name, - flags, - mode, - dir_fd=root_fd, - ) - except FileExistsError: - continue - break - - if recovery_fd is None: - raise FileExistsError( - f"could not allocate recovery entry for planned source: {source_name}" - ) - - original_offset = os.lseek(source_fd, 0, os.SEEK_CUR) - try: - os.lseek(source_fd, 0, os.SEEK_SET) - while True: - chunk = os.read(source_fd, 1024 * 1024) - if not chunk: - break - view = memoryview(chunk) - while view: - written = os.write(recovery_fd, view) - if written <= 0: - raise OSError( - "could not persist pinned source recovery data" - ) - view = view[written:] - os.fchmod(recovery_fd, mode) - os.fsync(recovery_fd) - finally: - os.lseek(source_fd, original_offset, os.SEEK_SET) - os.close(recovery_fd) - - return recovery_name -''' -new_recovery = '''def _recover_pinned_source_at( - source_fd: int, - source_name: str, - *, - root_fd: int, -) -> str: - """Persist bytes from the pinned source FD into a proven recovery pathname.""" - source_stat = os.fstat(source_fd) - mode = stat.S_IMODE(source_stat.st_mode) - flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL - if hasattr(os, "O_CLOEXEC"): - flags |= os.O_CLOEXEC - - recovery_fd: int | None = None - recovery_name = "" - for _ in range(16): - recovery_name = _make_recovery_name(source_name) - try: - recovery_fd = os.open( - recovery_name, - flags, - mode, - dir_fd=root_fd, - ) - except FileExistsError: - continue - break - - if recovery_fd is None: - raise FileExistsError( - f"could not allocate recovery entry for planned source: {source_name}" - ) - - recovery_identity = _identity_from_regular_stat( - os.fstat(recovery_fd), - filename=recovery_name, - ) - original_offset = os.lseek(source_fd, 0, os.SEEK_CUR) - try: - os.lseek(source_fd, 0, os.SEEK_SET) - while True: - chunk = os.read(source_fd, 1024 * 1024) - if not chunk: - break - view = memoryview(chunk) - while view: - written = os.write(recovery_fd, view) - if written <= 0: - raise OSError( - "could not persist pinned source recovery data" - ) - view = view[written:] - os.fchmod(recovery_fd, mode) - os.fsync(recovery_fd) - - try: - recovery_path_identity = _regular_identity_at( - recovery_name, - directory_fd=root_fd, - ) - except OSError as exc: - raise RuntimeError( - f"recovery pathname changed during execution: {recovery_name}" - ) from exc - if recovery_path_identity != recovery_identity: - raise RuntimeError( - f"recovery pathname changed during execution: {recovery_name}" - ) - finally: - os.lseek(source_fd, original_offset, os.SEEK_SET) - os.close(recovery_fd) - - return recovery_name -''' -replace_once(organizer, old_recovery, new_recovery) - -# 2) Regression: unlink the recovery pathname during fsync and prove no false retention. -tests = ROOT / "practical-projects/06-file-organizer/tests/test_atomic_move.py" -anchor = '''from file_organizer import execute_plan, plan_organization - - -def test_execute_plan_never_replaces_destination_created_after_preflight( -''' -insert = '''from file_organizer import execute_plan, plan_organization - - -def test_recovery_path_removed_during_fsync_is_not_reported_as_retained( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - if not file_organizer._supports_secure_directory_fds(): - pytest.skip("secure directory descriptors are unavailable on this platform") - - source = tmp_path / "notes.txt" - source.write_text("planned source", encoding="utf-8") - root_fd = file_organizer._open_source_directory_fd(tmp_path) - source_fd = os.open(source, os.O_RDONLY) - original_fsync = os.fsync - recovery_unlinked = False - - def unlink_recovery_during_fsync(fd: int) -> None: - nonlocal recovery_unlinked - original_fsync(fd) - if fd == source_fd or recovery_unlinked: - return - recovery_files = [ - child - for child in tmp_path.iterdir() - if child.name.startswith(".fo-recovery-") - ] - assert len(recovery_files) == 1 - recovery_files[0].unlink() - recovery_unlinked = True - - monkeypatch.setattr(file_organizer.os, "fsync", unlink_recovery_during_fsync) - - try: - with pytest.raises(RuntimeError, match="recovery pathname changed during execution"): - file_organizer._recover_pinned_source_at( - source_fd, - source.name, - root_fd=root_fd, - ) - - assert recovery_unlinked - assert not any( - child.name.startswith(".fo-recovery-") for child in tmp_path.iterdir() - ) - os.lseek(source_fd, 0, os.SEEK_SET) - assert os.read(source_fd, 1024) == b"planned source" - finally: - os.close(source_fd) - os.close(root_fd) - - -def test_execute_plan_never_replaces_destination_created_after_preflight( -''' -replace_once(tests, anchor, insert) - -# 3) Documentation in EN / PT-BR / ES. -readme = ROOT / "practical-projects/06-file-organizer/README.md" -old_en = '''A staging pathname is not an inode lock. After the source has been claimed, every failure path rechecks whether the staging entry still matches the pinned source identity. If it does, execution may attempt a no-replace hard link from that proven stage back to the original source name, but restoration is accepted only after the recreated source pathname itself is re-read and verified to have the pinned identity. If the link fails, races to a different object, leaves the source name missing, or the post-link source identity does not match, execution leaves uncertain entries untouched and, before closing the still-pinned source file descriptor, copies the planned source bytes into an exclusive `.fo-recovery-*` regular file. This also covers a final `RENAME_NOREPLACE` failure caused by a destination that appears after the stage was raced. If a replacement stage is successfully renamed and destination identity verification detects the mismatch, the unrelated destination is likewise left intact while the pinned bytes are recovered. Recovery preserves data without claiming that the original inode survived the race. -''' -new_en = '''A staging pathname is not an inode lock. After the source has been claimed, every failure path rechecks whether the staging entry still matches the pinned source identity. If it does, execution may attempt a no-replace hard link from that proven stage back to the original source name, but restoration is accepted only after the recreated source pathname itself is re-read and verified to have the pinned identity. If the link fails, races to a different object, leaves the source name missing, or the post-link source identity does not match, execution leaves uncertain entries untouched and, before closing the still-pinned source file descriptor, copies the planned source bytes into an exclusive `.fo-recovery-*` regular file. That recovery file is not reported as retained merely because its descriptor was written and `fsync()` completed: while the recovery descriptor is still open, execution re-reads the recovery pathname through the anchored root and requires it to name the same regular-file `(st_dev, st_ino)`. A missing, renamed, or replaced recovery pathname raises instead of falsely claiming durable retention, and uncertain third-party entries are not deleted or overwritten. This also covers a final `RENAME_NOREPLACE` failure caused by a destination that appears after the stage was raced. If a replacement stage is successfully renamed and destination identity verification detects the mismatch, the unrelated destination is likewise left intact while the pinned bytes are recovered. Recovery preserves data only when the pathname used to report that preservation is itself proven. -''' -replace_once(readme, old_en, new_en) - -readme_pt = ROOT / "practical-projects/06-file-organizer/README.pt-BR.md" -old_pt = '''Um pathname de staging não funciona como lock de inode. Depois que a origem foi claimada, todo caminho de falha revalida se a entrada de staging ainda corresponde à identidade pinada da origem. Se corresponder, a execução pode tentar recriar o nome original por hard link no-replace a partir desse staging comprovado, mas a restauração só é aceita depois que o próprio pathname recriado da origem é relido e verificado com a identidade pinada. Se o link falhar, sofrer corrida para outro objeto, deixar o nome de origem ausente ou a identidade pós-link não corresponder, a execução deixa entradas incertas intactas e, antes de fechar o descritor ainda pinado da origem, copia os bytes planejados para um arquivo regular exclusivo `.fo-recovery-*`. Isso também cobre uma falha final de `RENAME_NOREPLACE` causada por um destino que aparece depois de uma corrida sobre o staging. Se um staging substituto for renomeado com sucesso e a verificação de identidade do destino detectar a divergência, o destino alheio também permanece intacto enquanto os bytes pinados são recuperados. A recuperação preserva os dados sem afirmar que o inode original sobreviveu à corrida. -''' -new_pt = '''Um pathname de staging não funciona como lock de inode. Depois que a origem foi claimada, todo caminho de falha revalida se a entrada de staging ainda corresponde à identidade pinada da origem. Se corresponder, a execução pode tentar recriar o nome original por hard link no-replace a partir desse staging comprovado, mas a restauração só é aceita depois que o próprio pathname recriado da origem é relido e verificado com a identidade pinada. Se o link falhar, sofrer corrida para outro objeto, deixar o nome de origem ausente ou a identidade pós-link não corresponder, a execução deixa entradas incertas intactas e, antes de fechar o descritor ainda pinado da origem, copia os bytes planejados para um arquivo regular exclusivo `.fo-recovery-*`. Esse recovery não é reportado como preservado apenas porque seu descritor foi gravado e o `fsync()` terminou: enquanto o descritor do recovery ainda está aberto, a execução relê o pathname de recuperação pelo root ancorado e exige que ele aponte para o mesmo arquivo regular `(st_dev, st_ino)`. Se o pathname sumir, for renomeado ou substituído, a execução falha em vez de afirmar falsamente que os dados foram retidos, sem excluir nem sobrescrever entradas incertas de terceiros. Isso também cobre uma falha final de `RENAME_NOREPLACE` causada por um destino que aparece depois de uma corrida sobre o staging. Se um staging substituto for renomeado com sucesso e a verificação de identidade do destino detectar a divergência, o destino alheio também permanece intacto enquanto os bytes pinados são recuperados. A recuperação só afirma preservação quando o próprio pathname usado para reportá-la é comprovado. -''' -replace_once(readme_pt, old_pt, new_pt) - -readme_es = ROOT / "practical-projects/06-file-organizer/README.es.md" -old_es = '''Un pathname de staging no funciona como lock de inode. Después de reclamar el origen, cada ruta de fallo vuelve a comprobar si la entrada de staging todavía coincide con la identidad fijada del origen. Si coincide, la ejecución puede intentar recrear el nombre original mediante un hard link no-replace desde ese staging comprobado, pero la restauración solo se acepta después de volver a leer el propio pathname recreado del origen y verificar que conserva la identidad fijada. Si el link falla, sufre una carrera hacia otro objeto, deja ausente el nombre de origen o la identidad posterior al link no coincide, la ejecución deja intactas las entradas inciertas y, antes de cerrar el descriptor todavía fijado del origen, copia los bytes planificados a un archivo regular exclusivo `.fo-recovery-*`. Esto también cubre un fallo final de `RENAME_NOREPLACE` causado por un destino que aparece después de una carrera sobre el staging. Si un staging de reemplazo se renombra con éxito y la verificación de identidad del destino detecta la divergencia, el destino ajeno también queda intacto mientras se recuperan los bytes fijados. La recuperación conserva los datos sin afirmar que el inode original haya sobrevivido a la carrera. -''' -new_es = '''Un pathname de staging no funciona como lock de inode. Después de reclamar el origen, cada ruta de fallo vuelve a comprobar si la entrada de staging todavía coincide con la identidad fijada del origen. Si coincide, la ejecución puede intentar recrear el nombre original mediante un hard link no-replace desde ese staging comprobado, pero la restauración solo se acepta después de volver a leer el propio pathname recreado del origen y verificar que conserva la identidad fijada. Si el link falla, sufre una carrera hacia otro objeto, deja ausente el nombre de origen o la identidad posterior al link no coincide, la ejecución deja intactas las entradas inciertas y, antes de cerrar el descriptor todavía fijado del origen, copia los bytes planificados a un archivo regular exclusivo `.fo-recovery-*`. Ese recovery no se informa como conservado solo porque su descriptor se haya escrito y `fsync()` haya terminado: mientras el descriptor de recovery sigue abierto, la ejecución vuelve a leer el pathname de recuperación a través de la raíz anclada y exige que nombre el mismo archivo regular `(st_dev, st_ino)`. Si el pathname desaparece, se renombra o se reemplaza, la ejecución falla en lugar de afirmar falsamente que los datos quedaron retenidos, sin borrar ni sobrescribir entradas inciertas de terceros. Esto también cubre un fallo final de `RENAME_NOREPLACE` causado por un destino que aparece después de una carrera sobre el staging. Si un staging de reemplazo se renombra con éxito y la verificación de identidad del destino detecta la divergencia, el destino ajeno también queda intacto mientras se recuperan los bytes fijados. La recuperación solo afirma conservación cuando el propio pathname usado para informarla queda demostrado. -''' -replace_once(readme_es, old_es, new_es) - -print("Review 14 patch applied") From cf18b34aa740c67fdd4560e257ef9dd636bc0d00 Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Tue, 1 Sep 2026 21:04:13 -0300 Subject: [PATCH 108/117] Trigger Review 14 quality checks --- .review14-ci-trigger | 1 + 1 file changed, 1 insertion(+) create mode 100644 .review14-ci-trigger diff --git a/.review14-ci-trigger b/.review14-ci-trigger new file mode 100644 index 0000000..ca7e89d --- /dev/null +++ b/.review14-ci-trigger @@ -0,0 +1 @@ +review14 From a3c7c07af0fe0bafe6595f9efb38f6d5a170b6c0 Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Tue, 1 Sep 2026 21:04:36 -0300 Subject: [PATCH 109/117] Remove Review 14 CI trigger --- .review14-ci-trigger | 1 - 1 file changed, 1 deletion(-) delete mode 100644 .review14-ci-trigger diff --git a/.review14-ci-trigger b/.review14-ci-trigger deleted file mode 100644 index ca7e89d..0000000 --- a/.review14-ci-trigger +++ /dev/null @@ -1 +0,0 @@ -review14 From 2230379bcc797300b24df2e30cfa05201b04a0a3 Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Tue, 1 Sep 2026 21:37:08 -0300 Subject: [PATCH 110/117] Harden recovery handoff durability --- .../06-file-organizer/README.es.md | 4 +- .../06-file-organizer/README.md | 4 +- .../06-file-organizer/README.pt-BR.md | 4 +- .../06-file-organizer/file_organizer.py | 29 +++--- .../tests/test_atomic_move.py | 95 ++++++++++++++++++- 5 files changed, 112 insertions(+), 24 deletions(-) diff --git a/practical-projects/06-file-organizer/README.es.md b/practical-projects/06-file-organizer/README.es.md index 9085474..1306a54 100644 --- a/practical-projects/06-file-organizer/README.es.md +++ b/practical-projects/06-file-organizer/README.es.md @@ -273,7 +273,7 @@ Los errores concurrentes pueden dejar estado incierto. La recuperación prioriza Si la ejecución ya movió el origen al staging y después detecta una condición insegura, puede crear un hard link no-replace de vuelta al nombre de origen cuando sea posible. No elimina a ciegas el staging. -Un pathname de staging no funciona como lock de inode. Después de reclamar el origen, cada ruta de fallo vuelve a comprobar si la entrada de staging todavía coincide con la identidad fijada del origen. Si coincide, la ejecución puede intentar recrear el nombre original mediante un hard link no-replace desde ese staging comprobado, pero la restauración solo se acepta después de volver a leer el propio pathname recreado del origen y verificar que conserva la identidad fijada. Si el link falla, sufre una carrera hacia otro objeto, deja ausente el nombre de origen o la identidad posterior al link no coincide, la ejecución deja intactas las entradas inciertas y, antes de cerrar el descriptor todavía fijado del origen, copia los bytes planificados a un archivo regular exclusivo `.fo-recovery-*`. Ese recovery no se informa como conservado solo porque su descriptor se haya escrito y `fsync()` haya terminado: mientras el descriptor de recovery sigue abierto, la ejecución vuelve a leer el pathname de recuperación a través de la raíz anclada y exige que nombre el mismo archivo regular `(st_dev, st_ino)`. Si el pathname desaparece, se renombra o se reemplaza, la ejecución falla en lugar de afirmar falsamente que los datos quedaron retenidos, sin borrar ni sobrescribir entradas inciertas de terceros. Esto también cubre un fallo final de `RENAME_NOREPLACE` causado por un destino que aparece después de una carrera sobre el staging. Si un staging de reemplazo se renombra con éxito y la verificación de identidad del destino detecta la divergencia, el destino ajeno también queda intacto mientras se recuperan los bytes fijados. La recuperación solo afirma conservación cuando el propio pathname usado para informarla queda demostrado. +Un pathname de staging no funciona como lock de inode. Después de reclamar el origen, cada ruta de fallo vuelve a comprobar si la entrada de staging todavía coincide con la identidad fijada del origen. Si coincide, la ejecución puede intentar recrear el nombre original mediante un hard link no-replace desde ese staging comprobado, pero la restauración solo se acepta después de volver a leer el propio pathname recreado del origen y verificar que conserva la identidad fijada. Si el link falla, sufre una carrera hacia otro objeto, deja ausente el nombre de origen o la identidad posterior al link no coincide, la ejecución deja intactas las entradas inciertas y, antes de cerrar el descriptor todavía fijado del origen, copia los bytes planificados a un archivo regular exclusivo `.fo-recovery-*`. Ese recovery no se informa como conservado solo porque su descriptor se haya escrito y `fsync()` haya terminado: la ejecución primero sincroniza el archivo de recovery y después sincroniza el directorio raíz anclado para hacer duradera ante un crash la entrada recién creada en el directorio. Cierra el descriptor de recovery antes de la comprobación final del pathname y luego vuelve a leer el pathname de recuperación a través de la raíz anclada, exigiendo que nombre el mismo archivo regular `(st_dev, st_ino)`. Si el pathname desaparece, se renombra o se reemplaza en ese punto final de verificación, la ejecución falla en lugar de afirmar falsamente que los datos quedaron retenidos, sin borrar ni sobrescribir entradas inciertas de terceros. Esta es una prueba puntual del namespace: un proceso externo no cooperativo con permiso para modificar el directorio todavía puede cambiar el pathname después de la verificación, por lo que el proyecto no afirma retención indefinida del pathname frente a cambios posteriores del namespace. Esto también cubre un fallo final de `RENAME_NOREPLACE` causado por un destino que aparece después de una carrera sobre el staging. Si un staging de reemplazo se renombra con éxito y la verificación de identidad del destino detecta la divergencia, el destino ajeno también queda intacto mientras se recuperan los bytes fijados. La recuperación solo se informa cuando el propio pathname usado para informarla queda demostrado en el punto final de verificación. Por ello, la ejecución segura en Linux exige deliberadamente permiso de lectura para cada archivo regular planificado. La legibilidad se valida antes de crear los directorios de categoría y de nuevo al fijar el inode del origen para la mutación; los fallos de permisos se informan como `PermissionError`, no como un falso cambio de identidad del origen. @@ -491,4 +491,4 @@ Eso comunica decisiones de ingeniería, no solo uso de APIs. El Proyecto 05 generó archivos. El Proyecto 06 toma la siguiente frontera: descubrir y organizar archivos con seguridad. -El Proyecto 07 vuelve a subir de nivel, combinando registros de dominio validados y estados explícitos de workflow en un **flujo ficticio de conciliación**. \ No newline at end of file +El Proyecto 07 vuelve a subir de nivel, combinando registros de dominio validados y estados explícitos de workflow en un **flujo ficticio de conciliación**. diff --git a/practical-projects/06-file-organizer/README.md b/practical-projects/06-file-organizer/README.md index 8eb1fd5..c66be5e 100644 --- a/practical-projects/06-file-organizer/README.md +++ b/practical-projects/06-file-organizer/README.md @@ -273,7 +273,7 @@ Concurrency errors can leave uncertain state. Recovery therefore favors preserva If execution has already claimed the source into a staging entry and later detects an unsafe condition, it may create a no-replace hard link back to the original source name when possible. It does not blindly delete the staging entry. -A staging pathname is not an inode lock. After the source has been claimed, every failure path rechecks whether the staging entry still matches the pinned source identity. If it does, execution may attempt a no-replace hard link from that proven stage back to the original source name, but restoration is accepted only after the recreated source pathname itself is re-read and verified to have the pinned identity. If the link fails, races to a different object, leaves the source name missing, or the post-link source identity does not match, execution leaves uncertain entries untouched and, before closing the still-pinned source file descriptor, copies the planned source bytes into an exclusive `.fo-recovery-*` regular file. That recovery file is not reported as retained merely because its descriptor was written and `fsync()` completed: while the recovery descriptor is still open, execution re-reads the recovery pathname through the anchored root and requires it to name the same regular-file `(st_dev, st_ino)`. A missing, renamed, or replaced recovery pathname raises instead of falsely claiming durable retention, and uncertain third-party entries are not deleted or overwritten. This also covers a final `RENAME_NOREPLACE` failure caused by a destination that appears after the stage was raced. If a replacement stage is successfully renamed and destination identity verification detects the mismatch, the unrelated destination is likewise left intact while the pinned bytes are recovered. Recovery preserves data only when the pathname used to report that preservation is itself proven. +A staging pathname is not an inode lock. After the source has been claimed, every failure path rechecks whether the staging entry still matches the pinned source identity. If it does, execution may attempt a no-replace hard link from that proven stage back to the original source name, but restoration is accepted only after the recreated source pathname itself is re-read and verified to have the pinned identity. If the link fails, races to a different object, leaves the source name missing, or the post-link source identity does not match, execution leaves uncertain entries untouched and, before closing the still-pinned source file descriptor, copies the planned source bytes into an exclusive `.fo-recovery-*` regular file. That recovery file is not reported as retained merely because its descriptor was written and `fsync()` completed: execution first syncs the recovery file and then syncs the anchored root directory so the newly created directory entry is crash-durable. It closes the recovery descriptor before the final pathname proof, then re-reads the recovery pathname through the anchored root and requires it to name the same regular-file `(st_dev, st_ino)`. A missing, renamed, or replaced recovery pathname at that final verification point raises instead of falsely claiming retention, and uncertain third-party entries are not deleted or overwritten. This is a point-in-time namespace proof: a non-cooperating external process with permission to mutate the directory can still change the pathname after verification, so the project does not claim indefinite pathname retention against later namespace changes. This also covers a final `RENAME_NOREPLACE` failure caused by a destination that appears after the stage was raced. If a replacement stage is successfully renamed and destination identity verification detects the mismatch, the unrelated destination is likewise left intact while the pinned bytes are recovered. Recovery is reported only when the pathname used to report that preservation is proven at the final verification point. Safe Linux execution therefore deliberately requires read access to each planned regular file. Readability is validated before category directories are created and again when the source inode is pinned for mutation; permission failures are reported as `PermissionError`, not as a false source-identity change. @@ -491,4 +491,4 @@ That communicates engineering decisions, not just API usage. Project 05 generated files. Project 06 owns the next boundary: discovering and organizing files safely. -Project 07 moves upward again, combining validated domain records and explicit workflow states in a **fictional reconciliation workflow**. \ No newline at end of file +Project 07 moves upward again, combining validated domain records and explicit workflow states in a **fictional reconciliation workflow**. diff --git a/practical-projects/06-file-organizer/README.pt-BR.md b/practical-projects/06-file-organizer/README.pt-BR.md index 4771273..65c12ca 100644 --- a/practical-projects/06-file-organizer/README.pt-BR.md +++ b/practical-projects/06-file-organizer/README.pt-BR.md @@ -273,7 +273,7 @@ Erros concorrentes podem deixar estado incerto. A recuperação prioriza preserv Se a execução já moveu a origem para staging e depois detecta condição insegura, ela pode criar um hard link no-replace de volta para o nome de origem quando possível. Ela não apaga cegamente o staging. -Um pathname de staging não funciona como lock de inode. Depois que a origem foi claimada, todo caminho de falha revalida se a entrada de staging ainda corresponde à identidade pinada da origem. Se corresponder, a execução pode tentar recriar o nome original por hard link no-replace a partir desse staging comprovado, mas a restauração só é aceita depois que o próprio pathname recriado da origem é relido e verificado com a identidade pinada. Se o link falhar, sofrer corrida para outro objeto, deixar o nome de origem ausente ou a identidade pós-link não corresponder, a execução deixa entradas incertas intactas e, antes de fechar o descritor ainda pinado da origem, copia os bytes planejados para um arquivo regular exclusivo `.fo-recovery-*`. Esse recovery não é reportado como preservado apenas porque seu descritor foi gravado e o `fsync()` terminou: enquanto o descritor do recovery ainda está aberto, a execução relê o pathname de recuperação pelo root ancorado e exige que ele aponte para o mesmo arquivo regular `(st_dev, st_ino)`. Se o pathname sumir, for renomeado ou substituído, a execução falha em vez de afirmar falsamente que os dados foram retidos, sem excluir nem sobrescrever entradas incertas de terceiros. Isso também cobre uma falha final de `RENAME_NOREPLACE` causada por um destino que aparece depois de uma corrida sobre o staging. Se um staging substituto for renomeado com sucesso e a verificação de identidade do destino detectar a divergência, o destino alheio também permanece intacto enquanto os bytes pinados são recuperados. A recuperação só afirma preservação quando o próprio pathname usado para reportá-la é comprovado. +Um pathname de staging não funciona como lock de inode. Depois que a origem foi claimada, todo caminho de falha revalida se a entrada de staging ainda corresponde à identidade pinada da origem. Se corresponder, a execução pode tentar recriar o nome original por hard link no-replace a partir desse staging comprovado, mas a restauração só é aceita depois que o próprio pathname recriado da origem é relido e verificado com a identidade pinada. Se o link falhar, sofrer corrida para outro objeto, deixar o nome de origem ausente ou a identidade pós-link não corresponder, a execução deixa entradas incertas intactas e, antes de fechar o descritor ainda pinado da origem, copia os bytes planejados para um arquivo regular exclusivo `.fo-recovery-*`. Esse recovery não é reportado como preservado apenas porque seu descritor foi gravado e o `fsync()` terminou: a execução primeiro sincroniza o arquivo de recovery e depois sincroniza o diretório raiz ancorado para tornar durável, diante de crash, a entrada recém-criada no diretório. Ela fecha o descritor do recovery antes da prova final do pathname e então relê o pathname de recuperação pelo root ancorado, exigindo que ele aponte para o mesmo arquivo regular `(st_dev, st_ino)`. Se o pathname sumir, for renomeado ou substituído nesse ponto final de verificação, a execução falha em vez de afirmar falsamente que os dados foram retidos, sem excluir nem sobrescrever entradas incertas de terceiros. Essa é uma prova pontual do namespace: um processo externo não cooperativo com permissão para alterar o diretório ainda pode mudar o pathname depois da verificação, portanto o projeto não afirma retenção indefinida do pathname contra mudanças posteriores no namespace. Isso também cobre uma falha final de `RENAME_NOREPLACE` causada por um destino que aparece depois de uma corrida sobre o staging. Se um staging substituto for renomeado com sucesso e a verificação de identidade do destino detectar a divergência, o destino alheio também permanece intacto enquanto os bytes pinados são recuperados. A recuperação só é reportada quando o próprio pathname usado para informá-la é comprovado no ponto final de verificação. Por isso, a execução segura no Linux exige deliberadamente permissão de leitura para cada arquivo regular planejado. A legibilidade é validada antes da criação das pastas de categoria e novamente ao pinar o inode da origem para a mutação; falhas de permissão são reportadas como `PermissionError`, e não como uma falsa mudança de identidade da origem. @@ -491,4 +491,4 @@ Isso comunica decisões de engenharia, não apenas uso de APIs. O Projeto 05 gerou arquivos. O Projeto 06 assume a próxima fronteira: descobrir e organizar arquivos com segurança. -O Projeto 07 sobe novamente de nível, combinando registros de domínio validados e estados explícitos de workflow em um **fluxo fictício de conciliação**. \ No newline at end of file +O Projeto 07 sobe novamente de nível, combinando registros de domínio validados e estados explícitos de workflow em um **fluxo fictício de conciliação**. diff --git a/practical-projects/06-file-organizer/file_organizer.py b/practical-projects/06-file-organizer/file_organizer.py index 7cbce40..5b0c161 100644 --- a/practical-projects/06-file-organizer/file_organizer.py +++ b/practical-projects/06-file-organizer/file_organizer.py @@ -732,24 +732,25 @@ def _recover_pinned_source_at( view = view[written:] os.fchmod(recovery_fd, mode) os.fsync(recovery_fd) - - try: - recovery_path_identity = _regular_identity_at( - recovery_name, - directory_fd=root_fd, - ) - except OSError as exc: - raise RuntimeError( - f"recovery pathname changed during execution: {recovery_name}" - ) from exc - if recovery_path_identity != recovery_identity: - raise RuntimeError( - f"recovery pathname changed during execution: {recovery_name}" - ) + os.fsync(root_fd) finally: os.lseek(source_fd, original_offset, os.SEEK_SET) os.close(recovery_fd) + try: + recovery_path_identity = _regular_identity_at( + recovery_name, + directory_fd=root_fd, + ) + except OSError as exc: + raise RuntimeError( + f"recovery pathname changed during execution: {recovery_name}" + ) from exc + if recovery_path_identity != recovery_identity: + raise RuntimeError( + f"recovery pathname changed during execution: {recovery_name}" + ) + return recovery_name diff --git a/practical-projects/06-file-organizer/tests/test_atomic_move.py b/practical-projects/06-file-organizer/tests/test_atomic_move.py index f7e3873..fdff01b 100644 --- a/practical-projects/06-file-organizer/tests/test_atomic_move.py +++ b/practical-projects/06-file-organizer/tests/test_atomic_move.py @@ -57,6 +57,97 @@ def unlink_recovery_during_fsync(fd: int) -> None: os.close(root_fd) +def test_recovery_path_removed_after_descriptor_close_is_not_reported_as_retained( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + if not file_organizer._supports_secure_directory_fds(): + pytest.skip("secure directory descriptors are unavailable on this platform") + + source = tmp_path / "notes.txt" + source.write_text("planned source", encoding="utf-8") + root_fd = file_organizer._open_source_directory_fd(tmp_path) + source_fd = os.open(source, os.O_RDONLY) + original_close = os.close + recovery_unlinked = False + + def unlink_recovery_after_descriptor_close(fd: int) -> None: + nonlocal recovery_unlinked + is_recovery_fd = ( + fd not in {source_fd, root_fd} + and stat.S_ISREG(os.fstat(fd).st_mode) + ) + original_close(fd) + if not is_recovery_fd or recovery_unlinked: + return + recovery_files = [ + child + for child in tmp_path.iterdir() + if child.name.startswith(".fo-recovery-") + ] + assert len(recovery_files) == 1 + recovery_files[0].unlink() + recovery_unlinked = True + + monkeypatch.setattr(file_organizer.os, "close", unlink_recovery_after_descriptor_close) + + try: + with pytest.raises(RuntimeError, match="recovery pathname changed during execution"): + file_organizer._recover_pinned_source_at( + source_fd, + source.name, + root_fd=root_fd, + ) + + assert recovery_unlinked + assert not any( + child.name.startswith(".fo-recovery-") for child in tmp_path.iterdir() + ) + os.lseek(source_fd, 0, os.SEEK_SET) + assert os.read(source_fd, 1024) == b"planned source" + finally: + os.close(source_fd) + os.close(root_fd) + + +def test_recovery_syncs_root_directory_after_recovery_file( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + if not file_organizer._supports_secure_directory_fds(): + pytest.skip("secure directory descriptors are unavailable on this platform") + + source = tmp_path / "notes.txt" + source.write_text("planned source", encoding="utf-8") + root_fd = file_organizer._open_source_directory_fd(tmp_path) + source_fd = os.open(source, os.O_RDONLY) + original_fsync = os.fsync + sync_order: list[str] = [] + + def tracking_fsync(fd: int) -> None: + if fd == root_fd: + sync_order.append("root") + elif stat.S_ISREG(os.fstat(fd).st_mode): + sync_order.append("recovery") + original_fsync(fd) + + monkeypatch.setattr(file_organizer.os, "fsync", tracking_fsync) + + try: + recovery_name = file_organizer._recover_pinned_source_at( + source_fd, + source.name, + root_fd=root_fd, + ) + + assert sync_order == ["recovery", "root"] + recovery_path = tmp_path / recovery_name + assert recovery_path.read_text(encoding="utf-8") == "planned source" + finally: + os.close(source_fd) + os.close(root_fd) + + def test_execute_plan_never_replaces_destination_created_after_preflight( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, @@ -474,7 +565,6 @@ def racing_claim( assert any(child.name.startswith(".fo-stage-") for child in tmp_path.iterdir()) - def test_staging_replacement_before_final_rename_preserves_pinned_source_data( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, @@ -530,9 +620,6 @@ def racing_rename_no_replace( assert not source.exists() - - - def test_failed_final_rename_stage_changes_during_restore_recovers_pinned_source_data( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, From 64bb719893c4c9519e5ab3c16b619864fbce0082 Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Tue, 1 Sep 2026 21:59:06 -0300 Subject: [PATCH 111/117] Clarify source binding failure summary (EN) --- practical-projects/06-file-organizer/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/practical-projects/06-file-organizer/README.md b/practical-projects/06-file-organizer/README.md index c66be5e..2fae5cc 100644 --- a/practical-projects/06-file-organizer/README.md +++ b/practical-projects/06-file-organizer/README.md @@ -386,7 +386,7 @@ The Linux source pin uses nonblocking open flags, then `fstat()` rejects the rep ### Planned source changes -Execution raises instead of treating the replacement as the planned file. +A regular-file replacement before execution-time binding is accepted as the current object selected by the plan. Changes after binding are rejected instead of being treated as the bound source. ### Source root or category directory is renamed/replaced @@ -491,4 +491,4 @@ That communicates engineering decisions, not just API usage. Project 05 generated files. Project 06 owns the next boundary: discovering and organizing files safely. -Project 07 moves upward again, combining validated domain records and explicit workflow states in a **fictional reconciliation workflow**. +Project 07 moves upward again, combining validated domain records and explicit workflow states in a **fictional reconciliation workflow**. \ No newline at end of file From 6019aaae4d02daf9ddd56b24c6baf01f8c9dca17 Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Tue, 1 Sep 2026 21:59:55 -0300 Subject: [PATCH 112/117] Clarify source binding failure summary (PT-BR) --- practical-projects/06-file-organizer/README.pt-BR.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/practical-projects/06-file-organizer/README.pt-BR.md b/practical-projects/06-file-organizer/README.pt-BR.md index 65c12ca..3b8dfb7 100644 --- a/practical-projects/06-file-organizer/README.pt-BR.md +++ b/practical-projects/06-file-organizer/README.pt-BR.md @@ -386,7 +386,7 @@ O pinning no Linux usa flags nonblocking e depois o `fstat()` rejeita a substitu ### Origem planejada muda -A execução gera erro em vez de tratar a substituição como o arquivo planejado. +Uma substituição por outro arquivo regular antes do vínculo de identidade da execução é aceita como o objeto atual selecionado pelo plano. Mudanças após esse vínculo são rejeitadas em vez de serem tratadas como a origem vinculada. ### Raiz ou categoria é renomeada/substituída @@ -491,4 +491,4 @@ Isso comunica decisões de engenharia, não apenas uso de APIs. O Projeto 05 gerou arquivos. O Projeto 06 assume a próxima fronteira: descobrir e organizar arquivos com segurança. -O Projeto 07 sobe novamente de nível, combinando registros de domínio validados e estados explícitos de workflow em um **fluxo fictício de conciliação**. +O Projeto 07 sobe novamente de nível, combinando registros de domínio validados e estados explícitos de workflow em um **fluxo fictício de conciliação**. \ No newline at end of file From d516502529ba8be51b9112ff208853615821a9e3 Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Tue, 1 Sep 2026 22:00:46 -0300 Subject: [PATCH 113/117] Clarify source binding failure summary (ES) --- practical-projects/06-file-organizer/README.es.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/practical-projects/06-file-organizer/README.es.md b/practical-projects/06-file-organizer/README.es.md index 1306a54..5d3e01b 100644 --- a/practical-projects/06-file-organizer/README.es.md +++ b/practical-projects/06-file-organizer/README.es.md @@ -386,7 +386,7 @@ El pinning de Linux usa flags nonblocking y luego `fstat()` rechaza la sustituci ### Origen planificado cambia -La ejecución genera error en vez de tratar la sustitución como el archivo planificado. +Una sustitución por otro archivo regular antes del vínculo de identidad de ejecución se acepta como el objeto actual seleccionado por el plan. Los cambios posteriores a ese vínculo se rechazan en lugar de tratarse como el origen vinculado. ### Raíz o categoría se renombra/sustituye @@ -491,4 +491,4 @@ Eso comunica decisiones de ingeniería, no solo uso de APIs. El Proyecto 05 generó archivos. El Proyecto 06 toma la siguiente frontera: descubrir y organizar archivos con seguridad. -El Proyecto 07 vuelve a subir de nivel, combinando registros de dominio validados y estados explícitos de workflow en un **flujo ficticio de conciliación**. +El Proyecto 07 vuelve a subir de nivel, combinando registros de dominio validados y estados explícitos de workflow en un **flujo ficticio de conciliación**. \ No newline at end of file From ec75df6d2fb5b2a532a517999452d35e46cd99cb Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Tue, 1 Sep 2026 22:02:02 -0300 Subject: [PATCH 114/117] Restore README final newline (EN) --- practical-projects/06-file-organizer/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/practical-projects/06-file-organizer/README.md b/practical-projects/06-file-organizer/README.md index 2fae5cc..9eea00c 100644 --- a/practical-projects/06-file-organizer/README.md +++ b/practical-projects/06-file-organizer/README.md @@ -491,4 +491,4 @@ That communicates engineering decisions, not just API usage. Project 05 generated files. Project 06 owns the next boundary: discovering and organizing files safely. -Project 07 moves upward again, combining validated domain records and explicit workflow states in a **fictional reconciliation workflow**. \ No newline at end of file +Project 07 moves upward again, combining validated domain records and explicit workflow states in a **fictional reconciliation workflow**. From 93725226e33c974ef846df750a311331d696716f Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Tue, 1 Sep 2026 22:02:51 -0300 Subject: [PATCH 115/117] Restore README final newline (PT-BR) --- practical-projects/06-file-organizer/README.pt-BR.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/practical-projects/06-file-organizer/README.pt-BR.md b/practical-projects/06-file-organizer/README.pt-BR.md index 3b8dfb7..c38e8d6 100644 --- a/practical-projects/06-file-organizer/README.pt-BR.md +++ b/practical-projects/06-file-organizer/README.pt-BR.md @@ -491,4 +491,4 @@ Isso comunica decisões de engenharia, não apenas uso de APIs. O Projeto 05 gerou arquivos. O Projeto 06 assume a próxima fronteira: descobrir e organizar arquivos com segurança. -O Projeto 07 sobe novamente de nível, combinando registros de domínio validados e estados explícitos de workflow em um **fluxo fictício de conciliação**. \ No newline at end of file +O Projeto 07 sobe novamente de nível, combinando registros de domínio validados e estados explícitos de workflow em um **fluxo fictício de conciliação**. From 0d37ef627f79ee0c435939d13ac9e952304535d7 Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Tue, 1 Sep 2026 22:03:41 -0300 Subject: [PATCH 116/117] Restore README final newline (ES) --- practical-projects/06-file-organizer/README.es.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/practical-projects/06-file-organizer/README.es.md b/practical-projects/06-file-organizer/README.es.md index 5d3e01b..7a3faf3 100644 --- a/practical-projects/06-file-organizer/README.es.md +++ b/practical-projects/06-file-organizer/README.es.md @@ -485,10 +485,10 @@ Eso comunica decisiones de ingeniería, no solo uso de APIs. | Mantener destinos exitosos | `OrganizationResult` | | Identificar objetos del filesystem | `(st_dev, st_ino)` | | Nueva comprobación lógica por `casefold()` | `listdir()` sobre el directorio anclado | -| Commit seguro del nombre exacto en Linux | `renameat2(RENAME_NOREPLACE)` | +| Commit seguro del nombre exato en Linux | `renameat2(RENAME_NOREPLACE)` | ## Qué sigue El Proyecto 05 generó archivos. El Proyecto 06 toma la siguiente frontera: descubrir y organizar archivos con seguridad. -El Proyecto 07 vuelve a subir de nivel, combinando registros de dominio validados y estados explícitos de workflow en un **flujo ficticio de conciliación**. \ No newline at end of file +El Proyecto 07 vuelve a subir de nivel, combinando registros de dominio validados y estados explícitos de workflow en un **flujo ficticio de conciliación**. From ba72ac54e220e63b0ec6c88ac187f9fe26361109 Mon Sep 17 00:00:00 2001 From: Ramon Rodriguez Date: Tue, 1 Sep 2026 22:04:39 -0300 Subject: [PATCH 117/117] Fix Spanish quick-reference typo --- practical-projects/06-file-organizer/README.es.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/practical-projects/06-file-organizer/README.es.md b/practical-projects/06-file-organizer/README.es.md index 7a3faf3..2f77b2f 100644 --- a/practical-projects/06-file-organizer/README.es.md +++ b/practical-projects/06-file-organizer/README.es.md @@ -485,7 +485,7 @@ Eso comunica decisiones de ingeniería, no solo uso de APIs. | Mantener destinos exitosos | `OrganizationResult` | | Identificar objetos del filesystem | `(st_dev, st_ino)` | | Nueva comprobación lógica por `casefold()` | `listdir()` sobre el directorio anclado | -| Commit seguro del nombre exato en Linux | `renameat2(RENAME_NOREPLACE)` | +| Commit seguro del nombre exacto en Linux | `renameat2(RENAME_NOREPLACE)` | ## Qué sigue