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
resulteert in een duidelijke Matrix-"GESTOPT" en **niets** wordt gewist.
- **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
Matrix-bericht met de laatste fout. Bronbestanden worden nooit verwijderd zonder
geverifieerde kopie.

View file

@ -153,7 +153,12 @@ def copy_file(src, dst, attempts=MAX_RETRIES, delay=RETRY_DELAY):
last_error = None
for attempt in range(1, attempts + 1):
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
except OSError as e:
last_error = e

View file

@ -85,22 +85,22 @@ class CopyFileTest(unittest.TestCase):
self.assertEqual(os.path.getsize(self.dst), 100)
def test_retries_then_succeeds(self):
real_copy2 = dashcam.shutil.copy2
real_copy = dashcam.shutil.copy
calls = {"n": 0}
def flaky(s, d):
calls["n"] += 1
if calls["n"] < 3:
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)
self.assertEqual(calls["n"], 3)
self.assertEqual(os.path.getsize(self.dst), 100)
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):
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):
files = [self._make("20260622050346_0000001F.MP4", 100)]
def bad_copy2(s, d):
def bad_copy(s, d):
with open(d, "wb") as f:
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"
):
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)
]
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"):
with self.assertRaises(dashcam.TransferAborted) as ctx:
dashcam.transfer_files(files, self.src, self.base)