Skip to content

Commit 5fe287c

Browse files
committed
fix: symlink loop safety, remove unused fields, release 0.4.0
1 parent a1a4ad1 commit 5fe287c

12 files changed

Lines changed: 225 additions & 42 deletions

File tree

.github/workflows/publish.yml

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ on:
66
workflow_dispatch:
77
inputs:
88
tag:
9-
description: "Git tag to publish, for example v0.3.1"
9+
description: "Git tag to publish, for example v0.4.0"
1010
required: true
1111
type: string
1212

@@ -18,7 +18,7 @@ env:
1818

1919
jobs:
2020
build:
21-
name: Test and build package
21+
name: Quality checks, tests, and build
2222
runs-on: ubuntu-latest
2323
permissions:
2424
contents: read
@@ -38,6 +38,15 @@ jobs:
3838
python -m pip install --upgrade pip
3939
pip install -e ".[dev]"
4040
41+
- name: Ruff lint
42+
run: ruff check .
43+
44+
- name: Ruff format check
45+
run: ruff format --check .
46+
47+
- name: Pyright
48+
run: pyright
49+
4150
- name: Run tests
4251
run: pytest
4352

@@ -68,6 +77,9 @@ jobs:
6877
- name: Check package metadata
6978
run: python -m twine check dist/*
7079

80+
- name: Verify py.typed is in wheel
81+
run: python -m zipfile -l dist/*.whl | grep py.typed
82+
7183
- name: Upload package distributions
7284
uses: actions/upload-artifact@v4
7385
with:

CHANGELOG.md

Lines changed: 28 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -9,61 +9,66 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
No unreleased changes.
1111

12-
## [0.3.1] — 2026-06-05
12+
## [0.4.0] — 2026-06-05
1313

1414
### Fixed
1515

16-
- SQLite cache files are now correctly excluded when the scan root is accessed through a symbolic link. Path comparison now uses `resolve(strict=False)` on both sides so symlinked and real paths map to the same canonical key.
17-
- `-wal`, `-shm`, and `-journal` SQLite auxiliary files are excluded alongside the main cache database.
16+
- Root symbolic links are rejected when symbolic link traversal is disabled (`follow_symlinks=False`), including directory and file symlinks used as the scan root.
17+
- SQLite cache database and all auxiliary files (`-wal`, `-shm`, `-journal`) are excluded from scans.
18+
- Cache exclusion works correctly when the scan root is accessed through a symbolic link, using `resolve(strict=False)` on both sides of the comparison.
19+
- Internal path comparison during cache exclusion no longer raises `OSError` or `RuntimeError` on symlink loops or broken symlinks.
1820
- PyPI publishing workflow (`publish.yml`) rewritten as valid YAML — the previous file contained Markdown code fences that made it unparseable by GitHub Actions.
21+
- Public type annotations in `ScanOptions`, `ScanEvent`, and `ScanProgress` now resolve correctly on all supported Python versions via `get_type_hints()`.
1922

2023
### Changed
2124

25+
- Cancelled CLI scans return exit status `3`. Exit code `3` takes priority over `--fail-on-duplicates`.
2226
- Minimum supported Python version raised from 3.9 to 3.10. The codebase uses `X | Y` union syntax broadly; requiring 3.10 makes this consistent and allows `get_type_hints()` to resolve annotations correctly at runtime.
23-
- `ProgressPhase` values corrected to `"discovery"`, `"hashing"`, and `"done"` — the `"grouping"` phase mentioned in the previous changelog was never emitted.
27+
- `ProgressPhase` values restricted to the phases actually emitted: `"discovery"`, `"hashing"`, and `"done"`.
28+
29+
### Removed
30+
31+
- `ScanEvent.from_cache` and `ScanEvent.bytes_read` fields removed. These fields were added in 0.3.0 but were never populated — they always remained at their default values (`False` and `0`). Removing them eliminates misleading API surface.
2432

2533
### Tooling
2634

27-
- CI restructured into three jobs: `quality` (Ruff lint + format check + Pyright), `tests` (matrix across Ubuntu/Windows/macOS and Python 3.10–3.13), and `package` (build, Twine check, `py.typed` verification, wheel smoke test).
35+
- CI restructured into three jobs: `quality` (Ruff lint + format check + Pyright on Ubuntu), `tests` (matrix across Ubuntu, Windows, and macOS with Python 3.10–3.13), and `package` (build, Twine check, `py.typed` verification, wheel smoke test).
36+
- Publishing workflow (`publish.yml`) now runs Ruff, Pyright, and full quality checks before building, so manual releases cannot skip linting.
2837
- `pyyaml` added to development dependencies to enable workflow YAML validation in tests.
29-
- New regression tests: symlink policy (directory and file roots, broken symlinks), cache exclusion through symlinked roots, WAL/SHM/journal exclusion, typing contract, cancellation exit codes, version consistency, `py.typed` marker, workflow YAML validation.
38+
- Regression tests added: symlink policy, cache exclusion (including symlinked roots, symlink loops, broken symlinks), typing contracts, cancellation exit codes, version consistency, `py.typed` marker, workflow YAML validation.
3039

3140
## [0.3.0] — 2026-06-04
3241

3342
### Added
3443

3544
- `DupeFinder` class — integration-ready scan engine with typed events, cancellation, and optional hash cache.
3645
- `ScanEvent` frozen dataclass — typed events emitted during each scan phase (`scan_started`, `file_discovered`, `file_hashed`, `duplicate_group_found`, `issue`, `scan_completed`, `scan_cancelled`).
37-
- `ScanProgress` frozen dataclass — simplified progress snapshots delivered to the new `on_progress` callback. Fields: `root`, `phase` (`"discovery"`, `"hashing"`, `"done"`), `scanned_files`, `hashed_files`, `total_candidates`, `duplicate_groups`, `elapsed_seconds`, `cancelled`.
46+
- `ScanEvent.from_cache` and `ScanEvent.bytes_read` fields (reserved; removed in 0.4.0).
47+
- `ScanProgress` frozen dataclass — simplified progress snapshots delivered to the new `on_progress` callback. Fields: `root`, `phase`, `scanned_files`, `hashed_files`, `total_candidates`, `duplicate_groups`, `elapsed_seconds`, `cancelled`.
3848
- `DupeFinder.on_progress` parameter — optional callback that receives a `ScanProgress` snapshot after each file discovered or hashed, and once more at the end with `phase="done"`.
3949
- `ScanOptions.max_files` — stop file discovery after N files.
4050
- `ScanOptions.max_depth` — limit directory recursion depth (`0` = root only).
4151
- `ScanOptions.timeout_seconds` — automatically cancel scan after N seconds.
4252
- `ScanReport.cancelled``True` when the scan was cancelled early.
4353
- `ScanReport.elapsed_seconds` — wall-clock scan duration.
44-
- `ScanReport.total_bytes_read` — total bytes read during hashing (always populated; zero when no files were hashed).
45-
- `ScanReport.to_dict()` and `ScanReport.to_json()` — convenience methods for serialization.
46-
- `DuplicateGroup.to_dict()` — serialize a single group to a plain dictionary.
47-
- `ScanIssue.to_dict()` — serialize a single issue to a plain dictionary.
54+
- `ScanReport.total_bytes_read` — total bytes read during hashing.
55+
- `ScanReport.to_dict()` and `ScanReport.to_json()` — convenience serialization methods.
56+
- `DuplicateGroup.to_dict()` and `ScanIssue.to_dict()` — serialize to plain dictionary.
4857
- `HashCache` protocol — interface for pluggable hash caches.
4958
- `SQLiteHashCache` — SQLite-backed persistent hash cache; entries validated by file size and mtime.
50-
- `ScanEvent.from_cache` and `ScanEvent.bytes_read` fields.
51-
- `grouping.candidate_files()` — public helper that filters files sharing a size with at least one other file.
52-
- `grouping.groups_from_hash_map()` — public helper that builds and sorts `DuplicateGroup` objects from a `(size, digest) → paths` mapping.
53-
- `_ScanCancelled` internal exception in `errors.py` — raised between chunks when `should_cancel` returns `True`; caught at the engine boundary.
54-
- `hash_file()` accepts `should_cancel` and `on_bytes_read` keyword-only callbacks.
55-
- `hash_files()` accepts `should_cancel` and `on_bytes_read` and forwards them to `hash_file()`.
56-
- `SCHEMA_VERSION = "1.1"` constant; `schema_version` and `total_bytes_read` fields in all JSON/dict output.
59+
- `grouping.candidate_files()` and `grouping.groups_from_hash_map()` — public helpers.
60+
- `_ScanCancelled` internal exception in `errors.py`.
61+
- `hash_file()` and `hash_files()` accept `should_cancel` and `on_bytes_read` callbacks.
62+
- `SCHEMA_VERSION = "1.1"` constant; `schema_version` and `total_bytes_read` in all JSON/dict output.
5763
- CLI flags: `--max-files`, `--max-depth`, `--timeout`, `--cache`, `--progress`.
5864
- `--progress` flag prints live discovery/hashing progress to stderr.
59-
- Cancelled CLI scans exit with status code `3`.
6065

6166
### Fixed
6267

6368
- Engine now emits `type="issue"` events for all issues collected during discovery and hashing.
64-
- Cache error handling now catches `sqlite3.Error` in addition to `OSError`.
69+
- Cache error handling catches `sqlite3.Error` in addition to `OSError`.
6570
- CLI cache creation moved inside the `try` block so errors on cache open are caught and reported cleanly with exit code 1.
66-
- Engine no longer duplicates the size-grouping and hash-grouping logic from `grouping.py`.
71+
- Engine no longer duplicates size-grouping and hash-grouping logic from `grouping.py`.
6772

6873
### Changed
6974

@@ -89,7 +94,7 @@ No unreleased changes.
8994
- Unit tests for all modules using only the Python standard library.
9095
- Zero runtime dependencies — standard library only.
9196

92-
[Unreleased]: https://github.com/igors93/dupefinder/compare/v0.3.1...HEAD
93-
[0.3.1]: https://github.com/igors93/dupefinder/compare/v0.3.0...v0.3.1
97+
[Unreleased]: https://github.com/igors93/dupefinder/compare/v0.4.0...HEAD
98+
[0.4.0]: https://github.com/igors93/dupefinder/compare/v0.3.0...v0.4.0
9499
[0.3.0]: https://github.com/igors93/dupefinder/compare/v0.1.0...v0.3.0
95100
[0.1.0]: https://github.com/igors93/dupefinder/releases/tag/v0.1.0

README.md

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -194,10 +194,15 @@ Run `dupefinder --help` to see all options.
194194

195195
| Code | Meaning |
196196
|------|---------|
197-
| `0` | Scan completed normally, no errors |
198-
| `1` | Scan failed (invalid path, permission error with `--strict`, etc.) |
199-
| `2` | Scan completed and duplicates were found (only with `--fail-on-duplicates`) |
200-
| `3` | Scan was cancelled (timeout or signal) |
197+
| `0` | Scan completed. Non-fatal issues may still be present in the report. |
198+
| `1` | Scan failed because of an invalid option, invalid path, cache error, or strict-mode error. |
199+
| `2` | Scan completed and duplicates were found while `--fail-on-duplicates` was enabled. |
200+
| `3` | Scan was cancelled or stopped by the configured timeout. |
201+
202+
Notes:
203+
- `--strict` turns otherwise non-fatal file access errors into exit code `1`.
204+
- JSON output can contain an `issues` list even when the exit code is `0`.
205+
- Exit code `3` takes priority over `--fail-on-duplicates`.
201206

202207
## CLI reference
203208

@@ -236,7 +241,7 @@ Run `dupefinder --help` to see all options.
236241
| `DuplicateGroup` | One group of files with identical content |
237242
| `FileInfo` | Path and size of a single file |
238243
| `ScanIssue` | A non-fatal error recorded during a scan |
239-
| `SQLiteHashCache` | SQLite-backed hash cache for repeated scans |
244+
| `SQLiteHashCache` | SQLite-backed hash cache — import from `dupefinder.cache` |
240245

241246
### JSON schema
242247

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "dupefinder"
7-
version = "0.3.1"
7+
version = "0.4.0"
88
description = "A simple, safe, zero-dependency duplicate file finder for Python."
99
readme = "README.md"
1010
requires-python = ">=3.10"

src/dupefinder/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from dupefinder.events import ScanEvent, ScanProgress
66
from dupefinder.models import DuplicateGroup, FileInfo, ScanIssue, ScanOptions, ScanReport
77

8-
__version__ = "0.3.1"
8+
__version__ = "0.4.0"
99

1010
__all__ = [
1111
"find_duplicates",

src/dupefinder/events.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,6 @@ class ScanEvent:
3535
message: str | None = None
3636
issue: ScanIssue | None = None
3737
group: DuplicateGroup | None = None
38-
from_cache: bool = False
39-
bytes_read: int = 0
4038

4139

4240
@dataclass(frozen=True)

src/dupefinder/scanner.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,10 @@ def _walk_directory(
9595
def _is_excluded(path: Path, excluded_paths: frozenset[Path]) -> bool:
9696
if not excluded_paths:
9797
return False
98-
return normalize_internal_path(path) in excluded_paths
98+
try:
99+
return normalize_internal_path(path) in excluded_paths
100+
except (OSError, RuntimeError):
101+
return False
99102

100103

101104
def _handle_scan_error(

tests/test_hardening.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,48 @@ def test_cache_excluded_through_symlinked_root(self) -> None:
252252
self.assertEqual(report.scanned_files, 2)
253253
self.assertEqual(report.total_groups, 1)
254254

255+
def test_circular_symlink_inside_root_does_not_crash_with_cache(self) -> None:
256+
with tempfile.TemporaryDirectory() as tmp:
257+
root = Path(tmp)
258+
(root / "a.txt").write_text("same", encoding="utf-8")
259+
(root / "b.txt").write_text("same", encoding="utf-8")
260+
loop = root / "loop"
261+
try:
262+
loop.symlink_to(loop)
263+
except (OSError, NotImplementedError) as exc:
264+
self.skipTest(f"Symlinks unavailable: {exc}")
265+
cache_path = root / "cache.sqlite"
266+
267+
with SQLiteHashCache(cache_path) as cache:
268+
report = DupeFinder(
269+
options=ScanOptions(ignore_hidden=False),
270+
cache=cache,
271+
).scan(root)
272+
273+
self.assertFalse(report.cancelled)
274+
self.assertEqual(report.scanned_files, 2)
275+
276+
def test_broken_symlink_inside_root_does_not_crash_with_cache(self) -> None:
277+
with tempfile.TemporaryDirectory() as tmp:
278+
root = Path(tmp)
279+
(root / "a.txt").write_text("same", encoding="utf-8")
280+
(root / "b.txt").write_text("same", encoding="utf-8")
281+
broken = root / "broken"
282+
try:
283+
broken.symlink_to(root / "nonexistent_target")
284+
except (OSError, NotImplementedError) as exc:
285+
self.skipTest(f"Symlinks unavailable: {exc}")
286+
cache_path = root / "cache.sqlite"
287+
288+
with SQLiteHashCache(cache_path) as cache:
289+
report = DupeFinder(
290+
options=ScanOptions(ignore_hidden=False),
291+
cache=cache,
292+
).scan(root)
293+
294+
self.assertFalse(report.cancelled)
295+
self.assertEqual(report.scanned_files, 2)
296+
255297

256298
class CancellationExitCodeTests(unittest.TestCase):
257299
def _make_report(self, **kwargs: object) -> ScanReport:

tests/test_models.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ def test_to_dict_contains_expected_keys(self):
131131
def test_to_dict_values_are_correct(self):
132132
issue = self._make_issue()
133133
result = issue.to_dict()
134-
self.assertEqual(result["path"], "/some/file.txt")
134+
self.assertEqual(result["path"], str(Path("/some/file.txt")))
135135
self.assertEqual(result["message"], "Permission denied")
136136
self.assertEqual(result["phase"], "scan")
137137

tests/test_packaging.py

Lines changed: 79 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,11 @@
77
import unittest
88
from pathlib import Path
99

10+
ROOT = Path(__file__).parent.parent
11+
1012

1113
def _read_pyproject_version() -> str:
12-
pyproject = Path(__file__).parent.parent / "pyproject.toml"
13-
content = pyproject.read_text(encoding="utf-8")
14+
content = (ROOT / "pyproject.toml").read_text(encoding="utf-8")
1415
match = re.search(r'^version\s*=\s*"([^"]+)"', content, re.MULTILINE)
1516
if match is None:
1617
raise AssertionError("version not found in pyproject.toml")
@@ -42,11 +43,11 @@ def test_version_is_non_empty(self) -> None:
4243

4344
class PyTypedTests(unittest.TestCase):
4445
def test_py_typed_marker_exists(self) -> None:
45-
src = Path(__file__).parent.parent / "src" / "dupefinder" / "py.typed"
46+
src = ROOT / "src" / "dupefinder" / "py.typed"
4647
self.assertTrue(src.exists(), f"py.typed not found at {src}")
4748

4849
def test_py_typed_is_empty(self) -> None:
49-
src = Path(__file__).parent.parent / "src" / "dupefinder" / "py.typed"
50+
src = ROOT / "src" / "dupefinder" / "py.typed"
5051
if src.exists():
5152
self.assertEqual(src.read_text(), "", "py.typed should be an empty marker file")
5253

@@ -65,6 +66,11 @@ def test_public_api_is_importable(self) -> None:
6566
self.assertTrue(callable(DupeFinder))
6667
self.assertTrue(callable(ScanOptions))
6768

69+
def test_sqlite_hash_cache_importable_from_module(self) -> None:
70+
from dupefinder.cache import SQLiteHashCache
71+
72+
self.assertTrue(callable(SQLiteHashCache))
73+
6874
def test_python_version_meets_minimum(self) -> None:
6975
self.assertGreaterEqual(
7076
sys.version_info[:2],
@@ -73,5 +79,74 @@ def test_python_version_meets_minimum(self) -> None:
7379
)
7480

7581

82+
class READMEDocumentationTests(unittest.TestCase):
83+
_readme: str = ""
84+
85+
@classmethod
86+
def setUpClass(cls) -> None:
87+
cls._readme = (ROOT / "README.md").read_text(encoding="utf-8")
88+
89+
def test_readme_documents_exit_code_zero(self) -> None:
90+
self.assertIn("| `0`", self._readme, "README must document exit code 0")
91+
92+
def test_readme_documents_exit_code_one(self) -> None:
93+
self.assertIn("| `1`", self._readme, "README must document exit code 1")
94+
95+
def test_readme_documents_exit_code_two(self) -> None:
96+
self.assertIn("| `2`", self._readme, "README must document exit code 2")
97+
98+
def test_readme_documents_exit_code_three(self) -> None:
99+
self.assertIn("| `3`", self._readme, "README must document exit code 3")
100+
101+
def test_readme_notes_exit_code_3_priority(self) -> None:
102+
self.assertIn(
103+
"priority",
104+
self._readme.lower(),
105+
"README must clarify that exit code 3 takes priority over --fail-on-duplicates",
106+
)
107+
108+
109+
class ChangelogStructureTests(unittest.TestCase):
110+
_changelog: str = ""
111+
112+
@classmethod
113+
def setUpClass(cls) -> None:
114+
cls._changelog = (ROOT / "CHANGELOG.md").read_text(encoding="utf-8")
115+
116+
def test_changelog_has_unreleased_section_first(self) -> None:
117+
lines = self._changelog.splitlines()
118+
headers = [line.strip() for line in lines if line.startswith("## ")]
119+
self.assertTrue(headers, "No ## sections found in CHANGELOG")
120+
self.assertIn("[Unreleased]", headers[0], "First ## section must be [Unreleased]")
121+
122+
def test_changelog_current_version_exists(self) -> None:
123+
import dupefinder
124+
125+
self.assertIn(
126+
dupefinder.__version__,
127+
self._changelog,
128+
f"CHANGELOG must contain a section for version {dupefinder.__version__}",
129+
)
130+
131+
def test_changelog_exit_code_3_not_in_030_section(self) -> None:
132+
# Exit code 3 was added after v0.3.0; it must not be listed under [0.3.0].
133+
changelog = self._changelog
134+
idx_030 = changelog.find("## [0.3.0]")
135+
idx_010 = changelog.find("## [0.1.0]")
136+
if idx_030 == -1 or idx_010 == -1:
137+
return
138+
section_030 = changelog[idx_030:idx_010]
139+
self.assertNotIn(
140+
"exit status `3`",
141+
section_030,
142+
"Exit code 3 must not be listed under [0.3.0]; it was introduced later",
143+
)
144+
self.assertNotIn(
145+
"exit code `3`",
146+
section_030,
147+
"Exit code 3 must not be listed under [0.3.0]; it was introduced later",
148+
)
149+
150+
76151
if __name__ == "__main__":
77152
unittest.main()

0 commit comments

Comments
 (0)