fix: kopieer met shutil.copy i.p.v. copy2 (EPERM op exfat voor niet-eigenaar)

copy2 preserveert timestamps via os.utime; de exfat-SSD draait met
uid=1000,allow_utime=0022, waardoor een niet-eigenaar (service-gebruiker
aiaiaiaiagent sinds 2026-10-13, voorheen root) geen willekeurige
tijdsstempels mag zetten -> EPERM op elke kopie. Mode is op exfat altijd
0777 en het opnamemoment staat al in de bestandsnaam.
This commit is contained in:
Eddy de Vink 2026-10-15 20:09:55 +02:00
commit dd46ad6d3c
3 changed files with 18 additions and 8 deletions

View file

@ -48,6 +48,11 @@ 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`;
op de exfat-SSD (`uid=1000,allow_utime=0022`) weigert dat voor niet-eigenaars
(EPERM). Sinds de service als `aiaiaiaiagent` draait (werd voorheen als root
gedraaid) faalde daardoor elke kopie. Mode is op exfat altijd 0777 en het
opnamemoment staat al in de bestandsnaam — metadata-preserve voegt 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,7 +153,12 @@ 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:
shutil.copy2(src, dst) # copy (niet copy2): copy2 preserveert timestamps via os.utime, en exfat
# mountuid=1000 + allow_utime=0022 weigeren dat voor niet-eigenaars —
# daarmee faalde elke kopie toen de service als aiaiaiaiagent ging
# draaien (EPERM). Op exfat is mode altijd 0777 en staat het
# opnamemoment al in de bestandsnaam (YYYYMMDDHHMMSS).
shutil.copy(src, dst)
return return
except OSError as e: except OSError as e:
last_error = e last_error = e

View file

@ -85,22 +85,22 @@ 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_copy2 = dashcam.shutil.copy2 real_copy = dashcam.shutil.copy
calls = {"n": 0} calls = {"n": 0}
def flaky(s, d): def flaky(s, d):
calls["n"] += 1 calls["n"] += 1
if calls["n"] < 3: if calls["n"] < 3:
raise OSError(errno.EIO, "I/O error") raise OSError(errno.EIO, "I/O error")
real_copy2(s, d) real_copy(s, d)
with mock.patch.object(dashcam.shutil, "copy2", side_effect=flaky): with mock.patch.object(dashcam.shutil, "copy", 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, "copy2", side_effect=OSError(errno.EIO, "I/O error")): with mock.patch.object(dashcam.shutil, "copy", 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)
@ -155,11 +155,11 @@ class TransferFilesTest(unittest.TestCase):
def test_verification_mismatch_counts_as_error_and_keeps_source(self): def test_verification_mismatch_counts_as_error_and_keeps_source(self):
files = [self._make("20260622050346_0000001F.MP4", 100)] files = [self._make("20260622050346_0000001F.MP4", 100)]
def bad_copy2(s, d): def bad_copy(s, d):
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, "copy2", side_effect=bad_copy2), mock.patch.object( with mock.patch.object(dashcam.shutil, "copy", 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, "copy2", side_effect=OSError(errno.EIO, "I/O error") dashcam.shutil, "copy", 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)