scripts/duplicate_file_report.py — Duplicate File and Photo Report

From version 0.1.0 of Duplicate File and Photo Report, shown publicly by its creator. 16.4 KiB · SHA-256 3e5fcb4bc0b8309b56312cd6bb9aac49d346e0dc571e841e61ab3bea93445940.

#!/usr/bin/env python3
"""Find duplicate local files without changing scanned content.

By Zero Width Lab (zerowidthlab.com)
"""

from __future__ import annotations

import argparse
import errno
import hashlib
import json
import os
import stat
import sys
import unicodedata
from pathlib import Path
from typing import Any, Callable, Sequence

__author__ = "Zero Width Lab"

SIGNATURE = "By Zero Width Lab (zerowidthlab.com)"
SF_DATALESS = 0x40000000
HASH_CHUNK = 64 * 1024
DIRECTORY_APIS = (os.open in os.supports_dir_fd and os.stat in os.supports_dir_fd
                  and os.scandir in os.supports_fd)
PACKAGE_SUFFIXES = (
    ".app", ".bundle", ".framework", ".library", ".photoslibrary",
    ".pkg", ".plugin", ".scptd", ".xcodeproj",
)


class ScanError(Exception):
    """Expected input or output error."""


class SkillArgumentParser(argparse.ArgumentParser):
    def error(self, message: str) -> None:
        raise ScanError(message)


def require_platform() -> None:
    if (os.name != "posix" or sys.platform not in ("darwin", "linux") or not hasattr(os, "O_DIRECTORY") or
            not hasattr(os, "O_NOFOLLOW") or not DIRECTORY_APIS):
        raise ScanError("Supported systems: macOS/Linux with POSIX directory descriptors and O_NOFOLLOW; Windows is unsupported")


def validated_path(path: str) -> str:
    if not path or "\0" in path or ".." in path.split("/"):
        raise ScanError("Path must not contain parent traversal (..) or NUL")
    return os.path.abspath(os.path.expanduser(path))


def identity(descriptor: int) -> tuple[int, int]:
    info = os.fstat(descriptor)
    return info.st_dev, info.st_ino


def open_directory(path: str, expected: dict[str, tuple[int, int]] | None = None) -> int:
    """Walk every component without following symlinks, checking pinned identities."""
    descriptor = os.open("/", os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
    current = "/"
    if expected is not None:
        actual = identity(descriptor)
        if "/" in expected and expected["/"] != actual:
            os.close(descriptor)
            raise ScanError("Root directory identity changed")
        expected["/"] = actual
    try:
        for name in Path(path).parts[1:]:
            try:
                child = os.open(name, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=descriptor)
            except OSError as exc:
                if exc.errno in {errno.ENOTDIR, errno.ELOOP}:
                    try:
                        component = os.stat(name, dir_fd=descriptor, follow_symlinks=False)
                    except OSError:
                        detail = "is a symlink or not a directory"
                    else:
                        detail = "is a symlink" if stat.S_ISLNK(component.st_mode) else "is not a directory"
                    hint = "; use the physical path (for example /private/tmp)" if "symlink" in detail else ""
                    raise ScanError(f"a path component {detail}{hint}") from exc
                raise
            os.close(descriptor)
            descriptor = child
            current = os.path.join(current, name)
            if expected is not None:
                actual = identity(descriptor)
                if current in expected and expected[current] != actual:
                    raise ScanError("Directory identity changed; refusing access")
                expected[current] = actual
        return descriptor
    except BaseException:
        os.close(descriptor)
        raise


def output_plan(path: str) -> dict[str, Any]:
    destination = validated_path(path)
    parent = os.path.dirname(destination)
    ancestors: dict[str, tuple[int, int]] = {}
    descriptor = open_directory(parent, ancestors)
    try:
        try:
            os.stat(os.path.basename(destination), dir_fd=descriptor, follow_symlinks=False)
        except FileNotFoundError:
            pass
        else:
            raise ScanError("Report destination already exists; choose a new path")
    finally:
        os.close(descriptor)
    return {"destination": destination, "resolved_destination": str(Path(destination).resolve()),
            "directory": ancestors.get(parent), "parent": ancestors.get(os.path.dirname(parent)),
            "ancestors": ancestors}


def display_text(value: object) -> str:
    """Keep untrusted values on one printable line."""
    pieces = []
    for character in str(value):
        point = ord(character)
        if character in ("\u2028", "\u2029") or unicodedata.category(character).startswith("C"):
            pieces.append(f"\\u{point:04x}" if point <= 0xFFFF else f"\\U{point:08x}")
        else:
            pieces.append(character)
    return "".join(pieces)


def file_hash(path: str, size: int, partial: bool, expected: dict[str, tuple[int, int]] | None = None) -> str:
    flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0)
    directory = open_directory(os.path.dirname(path), expected)
    try:
        descriptor = os.open(os.path.basename(path), flags, dir_fd=directory)
    finally:
        os.close(directory)
    try:
        with os.fdopen(descriptor, "rb") as stream:
            descriptor = -1
            before = os.fstat(stream.fileno())
            if not stat.S_ISREG(before.st_mode) or getattr(before, "st_flags", 0) & SF_DATALESS:
                raise OSError("file became special or not downloaded")
            if before.st_size != size:
                raise OSError("file changed during scan")
            digest = hashlib.sha256()
            if partial:
                digest.update(stream.read(HASH_CHUNK))
                if size > HASH_CHUNK:
                    stream.seek(size - HASH_CHUNK)
                    digest.update(stream.read(HASH_CHUNK))
            else:
                remaining = size
                while remaining:
                    block = stream.read(min(1024 * 1024, remaining))
                    if not block:
                        raise OSError("file shrank during scan")
                    digest.update(block)
                    remaining -= len(block)
                if stream.read(1):
                    raise OSError("file grew during scan")
            after = os.fstat(stream.fileno())
            if (before.st_size, before.st_mtime_ns, before.st_ino) != (
                after.st_size, after.st_mtime_ns, after.st_ino
            ):
                raise OSError("file changed during scan")
            return digest.hexdigest()
    finally:
        if descriptor >= 0:
            os.close(descriptor)


def scan_paths(
    roots: Sequence[str], min_size: int = 1, include_hidden: bool = False,
    include_packages: bool = False,
    stat_func: Callable[..., os.stat_result] | None = None,
) -> dict[str, Any]:
    require_platform()
    if min_size < 0:
        raise ScanError("--min-size must be nonnegative")
    normalized_roots = list(dict.fromkeys(validated_path(root) for root in roots))
    pinned: dict[str, tuple[int, int]] = {}
    result: dict[str, Any] = {
        "roots": normalized_roots, "min_size": min_size,
        "include_hidden": include_hidden, "include_packages": include_packages,
        "scanned_files": 0, "not_downloaded": 0, "unreadable": 0,
        "skipped": [], "incomplete": False, "groups": [], "reclaimable_bytes": 0,
    }
    by_size: dict[int, list[dict[str, Any]]] = {}
    visited_paths: set[str] = set()

    def skip(path: str, reason: str, incomplete: bool = False) -> None:
        result["skipped"].append({"path": path, "reason": reason})
        if reason == "not_downloaded":
            result["not_downloaded"] += 1
        elif reason == "unreadable":
            result["unreadable"] += 1
        if incomplete:
            result["incomplete"] = True

    stack = list(reversed(normalized_roots))
    while stack:
        path = stack.pop()
        if path in visited_paths:
            continue
        visited_paths.add(path)
        try:
            if stat_func is not None:
                info = stat_func(path, follow_symlinks=False)
            else:
                directory = open_directory(os.path.dirname(path), pinned)
                try:
                    info = os.stat(os.path.basename(path) or ".", dir_fd=directory, follow_symlinks=False)
                finally:
                    os.close(directory)
        except ScanError:
            if path in normalized_roots:
                raise
            skip(path, "unreadable", True)
            continue
        except OSError:
            skip(path, "unreadable", True)
            continue
        if stat.S_ISLNK(info.st_mode):
            skip(path, "symlink")
            continue
        if stat.S_ISDIR(info.st_mode):
            name = os.path.basename(path)
            if not include_hidden and name.startswith("."):
                skip(path, "hidden_directory")
                continue
            if not include_packages and name.lower().endswith(PACKAGE_SUFFIXES):
                skip(path, "package_directory")
                continue
            try:
                directory = open_directory(path, pinned)
                try:
                    with os.scandir(directory) as entries:
                        children = sorted((os.path.join(path, entry.name) for entry in entries), reverse=True)
                finally:
                    os.close(directory)
                stack.extend(children)
            except ScanError:
                if path in normalized_roots:
                    raise
                skip(path, "unreadable", True)
            except OSError:
                skip(path, "unreadable", True)
            continue
        if not stat.S_ISREG(info.st_mode):
            skip(path, "special_file")
            continue
        if not include_hidden and os.path.basename(path).startswith("."):
            skip(path, "hidden_file")
            continue
        if getattr(info, "st_flags", 0) & SF_DATALESS:
            skip(path, "not_downloaded", True)
            continue
        if info.st_size < min_size:
            continue
        result["scanned_files"] += 1
        by_size.setdefault(info.st_size, []).append({
            "path": path, "size": info.st_size, "mtime_ns": info.st_mtime_ns,
        })

    for size, candidates in sorted(by_size.items()):
        if len(candidates) < 2:
            continue
        partial_groups: dict[str, list[dict[str, Any]]] = {}
        for item in candidates:
            try:
                key = file_hash(item["path"], size, True, pinned)
            except (OSError, ScanError):
                skip(item["path"], "unreadable", True)
                continue
            partial_groups.setdefault(key, []).append(item)
        for partial_group in partial_groups.values():
            if len(partial_group) < 2:
                continue
            full_groups: dict[str, list[dict[str, Any]]] = {}
            for item in partial_group:
                try:
                    key = file_hash(item["path"], size, False, pinned)
                except (OSError, ScanError):
                    skip(item["path"], "unreadable", True)
                    continue
                full_groups.setdefault(key, []).append(item)
            for digest, files in full_groups.items():
                if len(files) < 2:
                    continue
                files.sort(key=lambda item: item["path"])
                reclaimed = size * (len(files) - 1)
                result["groups"].append({
                    "size": size, "sha256": digest,
                    "reclaimable_bytes": reclaimed, "files": files,
                })
                result["reclaimable_bytes"] += reclaimed
    result["groups"].sort(key=lambda group: (-group["reclaimable_bytes"], group["files"][0]["path"]))
    result["skipped"].sort(key=lambda item: (item["path"], item["reason"]))
    return result


def report_bytes(result: dict[str, Any]) -> bytes:
    return (json.dumps(result, ensure_ascii=True, indent=2, sort_keys=True) + "\n").encode("utf-8")


def approval_digest(path: str, content: bytes, plan: dict[str, Any] | None = None) -> str:
    plan = output_plan(path) if plan is None else plan
    digest = hashlib.sha256()
    digest.update(b"duplicate-file-and-photo-report-v2\0")
    digest.update(json.dumps(plan, sort_keys=True).encode("utf-8"))
    digest.update(b"\0")
    digest.update(content)
    return digest.hexdigest()


def create_report(path: str, content: bytes, plan: dict[str, Any] | None = None) -> None:
    require_platform()
    plan = output_plan(path) if plan is None else plan
    destination = validated_path(path)
    if destination != plan["destination"]:
        raise ScanError("Report destination changed")
    descriptor = open_directory(os.path.dirname(destination), dict(plan["ancestors"]))
    try:
        flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW
        output = os.open(os.path.basename(destination), flags, 0o600, dir_fd=descriptor)
        with os.fdopen(output, "wb") as stream:
            stream.write(content)
    finally:
        os.close(descriptor)


def build_parser() -> SkillArgumentParser:
    parser = SkillArgumentParser(
        description="Report duplicate local files without deleting or moving them.",
        epilog=SIGNATURE,
    )
    subcommands = parser.add_subparsers(dest="command", required=True)
    scan = subcommands.add_parser("scan", help="scan local roots", epilog=SIGNATURE)
    scan.add_argument("roots", metavar="ROOT", nargs="+")
    scan.add_argument("--min-size", type=int, default=1, metavar="BYTES",
                      help="minimum file size in bytes (default: 1; zero-byte files are skipped)")
    scan.add_argument("--include-hidden", action="store_true")
    scan.add_argument("--include-packages", action="store_true")
    scan.add_argument("--json", action="store_true")
    scan.add_argument("--report", metavar="PATH")
    scan.add_argument("--dry-run", action="store_true")
    scan.add_argument("--approve", metavar="DIGEST")
    scan.add_argument("--allow-partial", action="store_true")
    return parser


def main(argv: Sequence[str] | None = None) -> int:
    try:
        require_platform()
        args = build_parser().parse_args(argv)
        if args.report:
            validated_path(args.report)
        if args.dry_run and not args.report:
            raise ScanError("--dry-run requires --report")
        if args.approve and not args.report:
            raise ScanError("--approve requires --report")
        if args.dry_run and args.approve:
            raise ScanError("--dry-run and --approve cannot be combined")
        result = scan_paths(args.roots, args.min_size, args.include_hidden, args.include_packages)
        content = report_bytes(result)
        if args.report:
            plan = output_plan(args.report)
            digest = approval_digest(args.report, content, plan)
            if not args.dry_run:
                if result["incomplete"] and not args.allow_partial:
                    raise ScanError("incomplete scan; report create requires --allow-partial")
                if not args.approve or args.approve != digest:
                    raise ScanError("report approval missing or different; run --dry-run with the same options")
                create_report(args.report, content, plan)
        if args.json:
            sys.stdout.write(content.decode("utf-8"))
        else:
            print(f"Scanned files: {result['scanned_files']}; duplicate groups: {len(result['groups'])}; reclaimable bytes: {result['reclaimable_bytes']}")
            for group in result["groups"]:
                print(f"Group: {len(group['files'])} copies, {group['size']} bytes each, {group['reclaimable_bytes']} reclaimable bytes")
                for item in group["files"]:
                    print(f"  {display_text(item['path'])} (mtime_ns={item['mtime_ns']})")
            if result["incomplete"]:
                print(f"INCOMPLETE: {result['not_downloaded']} not downloaded; {result['unreadable']} unreadable. Skipped paths:")
                for item in result["skipped"]:
                    if item["reason"] in ("not_downloaded", "unreadable"):
                        print(f"  {display_text(item['path'])}: {item['reason']}")
        if args.dry_run:
            print(f"Report dry run; destination: {display_text(plan['resolved_destination'])}; no directories created; approval digest: {digest}", file=sys.stderr)
        if result["incomplete"] and not args.allow_partial:
            return 2
        return 1 if result["groups"] else 0
    except (ScanError, OSError, ValueError) as exc:
        print(f"Error: {display_text(exc)}", file=sys.stderr)
        return 2


if __name__ == "__main__":
    raise SystemExit(main())

License: MIT (read it).

MITMIT License

Copyright (c) 2026 Zero Width Lab

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

Back to the listing