From bf8fd3b1f5a740dc39b77ba4e69a2af826ea5901 Mon Sep 17 00:00:00 2001 From: kert Date: Wed, 11 Nov 2026 14:27:47 -0500 Subject: [PATCH] fix(bib): host-side Zotero sync skips copies it cannot read Zotero's storage files belong to the container user; on the host they stat but do not open, and comparing a same-name file crashed the nightly bib-refresh-iom sync. An unreadable copy now counts as unchanged (nothing is written); the in-container nightly sync compares and hoards. --- src/bib/sync.py | 17 ++++++++++++++-- tests/bib/test_sync_deeper.py | 38 +++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/src/bib/sync.py b/src/bib/sync.py index 51a1eb9..f8c859b 100644 --- a/src/bib/sync.py +++ b/src/bib/sync.py @@ -19,6 +19,7 @@ Usage:: from __future__ import annotations import json +import logging import shutil from datetime import date from pathlib import Path @@ -28,6 +29,8 @@ from bib.item import Item from zot.db import TYPE_MAP as ZOT_TYPE_MAP from zot.db import Db, generate_key, is_valid_key, now_iso +log = logging.getLogger(__name__) + if TYPE_CHECKING: from bib.store import Store @@ -596,13 +599,23 @@ def _sync_attachments( def _same_file(a: Path, b: Path) -> bool: - """Byte-identical? Size + mtime short-circuit (copy2 keeps mtime).""" + """Byte-identical? Size + mtime short-circuit (copy2 keeps mtime). + + On the host, Zotero's storage files belong to the container user: + they stat but do not open. An unreadable copy counts as the same — + nothing is written; the nightly in-container sync can compare it.""" import filecmp sa, sb = a.stat(), b.stat() if sa.st_size != sb.st_size: return False - return sa.st_mtime_ns == sb.st_mtime_ns or filecmp.cmp(a, b, shallow=False) + if sa.st_mtime_ns == sb.st_mtime_ns: + return True + try: + return filecmp.cmp(a, b, shallow=False) + except PermissionError: + log.debug("cannot read %s to compare; leaving it", b) + return True def _sha8(p: Path) -> str: diff --git a/tests/bib/test_sync_deeper.py b/tests/bib/test_sync_deeper.py index 5286502..f6221fc 100644 --- a/tests/bib/test_sync_deeper.py +++ b/tests/bib/test_sync_deeper.py @@ -128,6 +128,44 @@ class TestSyncAttachments: blobs = sorted(p.read_bytes() for p in storage.rglob("*.pdf")) assert blobs == [b"%PDF new chapter, revised", b"%PDF old chapter"] + def test_unreadable_zotero_copy_is_left_alone(self, tmp_path, monkeypatch): + # On the host, Zotero's storage files belong to the container user: + # stat works, open does not. Sync must skip, not crash or write. + import filecmp + + path = _setup(tmp_path) + storage = tmp_path / "storage" + storage.mkdir() + src_file = tmp_path / "source" / "chapter.pdf" + src_file.parent.mkdir() + src_file.write_bytes(b"%PDF a") + row = { + "filename": "chapter.pdf", + "content_type": "application/pdf", + "storage_path": str(src_file), + "created_at": "2026-04-15T00:00:00Z", + } + store = MagicMock() + store._con.return_value.execute.return_value.fetchall.return_value = [row] + with Db(path) as db: + parent_id = db.create_item(TYPE_MAP["document"]) + db.commit() + assert ( + _sync_attachments(db, store, MagicMock(key="K1"), parent_id, storage) + == 1 + ) + db.commit() + src_file.write_bytes(b"%PDF b") # same size, new mtime + + def denied(*a, **k): + raise PermissionError(13, "Permission denied") + + monkeypatch.setattr(filecmp, "cmp", denied) + assert ( + _sync_attachments(db, store, MagicMock(key="K1"), parent_id, storage) + == 0 + ) + def test_missing_file(self, tmp_path): path = _setup(tmp_path) store = MagicMock()