fix: kopieer met shutil.copyfile (data-only) i.p.v. copy/copy2

copy doet na copyfile ook copymode (os.chmod); op exfat met mountuid=1000
zijn alle bestanden eigendom van uid 1000 en wordt chmod/utime voor de
niet-eigenaar service-gebruiker geweigerd (EPERM op elke kopie, ook na de
copy2-fix). copyfile schrijft alleen data; verificatie gebeurt op grootte
en mode/opnametijd voegen op exfat niets toe.
This commit is contained in:
Eddy de Vink 2026-10-15 20:17:09 +02:00
commit 69d08ba8ba
3 changed files with 19 additions and 16 deletions

View file

@ -48,11 +48,13 @@ SD-kaartlezer die midden in de transfer offline klapt, `I/O error`, `device offl
verwijderd, wordt eerst een bestand van de kaart gelezen. Een onleesbare kaart verwijderd, wordt eerst een bestand van de kaart gelezen. Een onleesbare kaart
resulteert in een duidelijke Matrix-"GESTOPT" en **niets** wordt gewist. resulteert in een duidelijke Matrix-"GESTOPT" en **niets** wordt gewist.
- **Retry per bestand** — 3 pogingen met 2s pauze; vangt transiënte USB-glitches. - **Retry per bestand** — 3 pogingen met 2s pauze; vangt transiënte USB-glitches.
- **`shutil.copy` i.p.v. `copy2`** — `copy2` preserveert tijdsstempels via `os.utime`; - **`shutil.copyfile` i.p.v. `copy`/`copy2`** — `copy`/`copy2` doen na het kopiëren
op de exfat-SSD (`uid=1000,allow_utime=0022`) weigert dat voor niet-eigenaars ook metadata-operaties op het doel (`chmod`, `utime`). Op de exfat-SSD
(EPERM). Sinds de service als `aiaiaiaiagent` draait (werd voorheen als root (`uid=1000` → álle bestanden zijn eigendom van uid 1000, `allow_utime=0022`)
gedraaid) faalde daardoor elke kopie. Mode is op exfat altijd 0777 en het worden die geweigerd voor een niet-eigenaar (EPERM); sinds de service als
opnamemoment staat al in de bestandsnaam — metadata-preserve voegt niets toe. `aiaiaiaiagent` draait (voorheen root) faalde daardoor elke kopie. `copyfile`
schrijft alleen data; mode is op exfat altijd 0777 en het opnamemoment staat al
in de bestandsnaam — metadata-operaties voegen niets toe.
- **Early-abort** — bij 5 opeenvolgende fouten stopt het script en stuurt het een - **Early-abort** — bij 5 opeenvolgende fouten stopt het script en stuurt het een
Matrix-bericht met de laatste fout. Bronbestanden worden nooit verwijderd zonder Matrix-bericht met de laatste fout. Bronbestanden worden nooit verwijderd zonder
geverifieerde kopie. geverifieerde kopie.

View file

@ -153,12 +153,13 @@ def copy_file(src, dst, attempts=MAX_RETRIES, delay=RETRY_DELAY):
last_error = None last_error = None
for attempt in range(1, attempts + 1): for attempt in range(1, attempts + 1):
try: try:
# copy (niet copy2): copy2 preserveert timestamps via os.utime, en exfat # copyfile (niet copy/copy2): die doen ook metadata-operaties op het
# mountuid=1000 + allow_utime=0022 weigeren dat voor niet-eigenaars — # doel — chmod en utime. Op exfat met mountuid=1000 zijn álle bestanden
# daarmee faalde elke kopie toen de service als aiaiaiaiagent ging # eigendom van uid 1000, en allow_utime=0022 + chmod-toestand weigeren
# draaien (EPERM). Op exfat is mode altijd 0777 en staat het # dat voor de niet-eigenaar service-gebruiker (EPERM op elke kopie).
# opnamemoment al in de bestandsnaam (YYYYMMDDHHMMSS). # copyfile schrijft alleen data; mode is op exfat altijd 0777 en het
shutil.copy(src, dst) # opnamemoment staat al in de bestandsnaam (YYYYMMDDHHMMSS).
shutil.copyfile(src, dst)
return return
except OSError as e: except OSError as e:
last_error = e last_error = e

View file

@ -85,7 +85,7 @@ class CopyFileTest(unittest.TestCase):
self.assertEqual(os.path.getsize(self.dst), 100) self.assertEqual(os.path.getsize(self.dst), 100)
def test_retries_then_succeeds(self): def test_retries_then_succeeds(self):
real_copy = dashcam.shutil.copy real_copy = dashcam.shutil.copyfile
calls = {"n": 0} calls = {"n": 0}
def flaky(s, d): def flaky(s, d):
@ -94,13 +94,13 @@ class CopyFileTest(unittest.TestCase):
raise OSError(errno.EIO, "I/O error") raise OSError(errno.EIO, "I/O error")
real_copy(s, d) real_copy(s, d)
with mock.patch.object(dashcam.shutil, "copy", side_effect=flaky): with mock.patch.object(dashcam.shutil, "copyfile", side_effect=flaky):
dashcam.copy_file(self.src, self.dst, attempts=3, delay=0) dashcam.copy_file(self.src, self.dst, attempts=3, delay=0)
self.assertEqual(calls["n"], 3) self.assertEqual(calls["n"], 3)
self.assertEqual(os.path.getsize(self.dst), 100) self.assertEqual(os.path.getsize(self.dst), 100)
def test_raises_after_exhausting_attempts(self): def test_raises_after_exhausting_attempts(self):
with mock.patch.object(dashcam.shutil, "copy", side_effect=OSError(errno.EIO, "I/O error")): with mock.patch.object(dashcam.shutil, "copyfile", side_effect=OSError(errno.EIO, "I/O error")):
with self.assertRaises(OSError): with self.assertRaises(OSError):
dashcam.copy_file(self.src, self.dst, attempts=2, delay=0) dashcam.copy_file(self.src, self.dst, attempts=2, delay=0)
@ -159,7 +159,7 @@ class TransferFilesTest(unittest.TestCase):
with open(d, "wb") as f: with open(d, "wb") as f:
f.write(b"0" * 50) f.write(b"0" * 50)
with mock.patch.object(dashcam.shutil, "copy", side_effect=bad_copy), mock.patch.object( with mock.patch.object(dashcam.shutil, "copyfile", side_effect=bad_copy), mock.patch.object(
dashcam.time, "sleep" dashcam.time, "sleep"
): ):
ok, errors = dashcam.transfer_files(files, self.src, self.base) ok, errors = dashcam.transfer_files(files, self.src, self.base)
@ -172,7 +172,7 @@ class TransferFilesTest(unittest.TestCase):
for i in range(dashcam.MAX_CONSECUTIVE_ERRORS + 3) for i in range(dashcam.MAX_CONSECUTIVE_ERRORS + 3)
] ]
with mock.patch.object( with mock.patch.object(
dashcam.shutil, "copy", side_effect=OSError(errno.EIO, "I/O error") dashcam.shutil, "copyfile", side_effect=OSError(errno.EIO, "I/O error")
), mock.patch.object(dashcam.time, "sleep"): ), mock.patch.object(dashcam.time, "sleep"):
with self.assertRaises(dashcam.TransferAborted) as ctx: with self.assertRaises(dashcam.TransferAborted) as ctx:
dashcam.transfer_files(files, self.src, self.base) dashcam.transfer_files(files, self.src, self.base)