update: use ctime/mtime to control _strip_and_copy_image runs

Oftentimes, the file that is copied is stripped, in which case, the file
size is different.  Using a file size check here means it will rerun the
strip and copy every time `fdroid update` is run for any image that needs
to be stripped.  If the source's ctime is newer than the destination, then
the process should run since it is a newly created file.  Even more so with
mtime, since the destination's mtime is reset based on the source's.
This commit is contained in:
Hans-Christoph Steiner 2025-06-11 15:47:01 +02:00
parent 3cb6078059
commit 59102fb07f
2 changed files with 65 additions and 10 deletions

View File

@ -800,14 +800,18 @@ def _strip_and_copy_image(in_file, outpath):
It is not used at all in the F-Droid ecosystem, so its much safer It is not used at all in the F-Droid ecosystem, so its much safer
just to remove it entirely. just to remove it entirely.
This uses size+mtime to check for a new file since this process This only uses ctime/mtime to check for a new file since this
actually modifies the resulting file to strip out the EXIF. process actually modifies the resulting file to strip out the EXIF.
Therefore, whenever the file needs to be stripped, it will have a
newer ctime and most likely a different size. The mtime is copied
from the source to the destination, so it can be the same.
outpath can be path to either a file or dir. The dir that outpath outpath can be path to either a file or dir. The dir that outpath
refers to must exist before calling this. refers to must exist before calling this.
Potential source of Python code to strip JPEGs without dependencies: Potential source of Python code to strip JPEGs without dependencies:
http://www.fetidcascade.com/public/minimal_exif_writer.py http://www.fetidcascade.com/public/minimal_exif_writer.py
""" """
logging.debug('copying %s %s', in_file, outpath) logging.debug('copying %s %s', in_file, outpath)
@ -823,12 +827,11 @@ def _strip_and_copy_image(in_file, outpath):
else: else:
out_file = outpath out_file = outpath
if os.path.exists(out_file): if os.path.exists(out_file) and (
in_stat = os.stat(in_file) os.path.getmtime(in_file) <= os.path.getmtime(out_file)
out_stat = os.stat(out_file) and os.path.getctime(in_file) <= os.path.getctime(out_file)
if in_stat.st_size == out_stat.st_size \ ):
and in_stat.st_mtime == out_stat.st_mtime: return
return
extension = common.get_extension(in_file)[1] extension = common.get_extension(in_file)[1]
if extension == 'png': if extension == 'png':

View File

@ -12,6 +12,7 @@ import shutil
import string import string
import subprocess import subprocess
import sys import sys
import time
import unittest import unittest
import yaml import yaml
import zipfile import zipfile
@ -1369,15 +1370,66 @@ class UpdateTest(unittest.TestCase):
def test_strip_and_copy_image(self): def test_strip_and_copy_image(self):
in_file = basedir / 'metadata/info.guardianproject.urzip/en-US/images/icon.png' in_file = basedir / 'metadata/info.guardianproject.urzip/en-US/images/icon.png'
out_file = os.path.join(self.testdir, 'icon.png') out_file = os.path.join(self.testdir, 'icon.png')
fdroidserver.update._strip_and_copy_image(in_file, out_file) with self.assertLogs(level=logging.DEBUG):
fdroidserver.update._strip_and_copy_image(in_file, out_file)
self.assertTrue(os.path.exists(out_file)) self.assertTrue(os.path.exists(out_file))
def test_strip_and_copy_image_bad_filename(self): def test_strip_and_copy_image_bad_filename(self):
in_file = basedir / 'corrupt-featureGraphic.png' in_file = basedir / 'corrupt-featureGraphic.png'
out_file = os.path.join(self.testdir, 'corrupt-featureGraphic.png') out_file = os.path.join(self.testdir, 'corrupt-featureGraphic.png')
fdroidserver.update._strip_and_copy_image(in_file, out_file) with self.assertLogs(level=logging.DEBUG):
fdroidserver.update._strip_and_copy_image(in_file, out_file)
self.assertFalse(os.path.exists(out_file)) self.assertFalse(os.path.exists(out_file))
def test_strip_and_copy_image_unchanged(self):
in_file = basedir / 'metadata/info.guardianproject.urzip/en-US/images/icon.png'
out_file = os.path.join(self.testdir, 'icon.png')
shutil.copy2(in_file, out_file)
ctime = os.path.getctime(out_file)
delta = 0.01
time.sleep(delta) # ensure reliable failure if file isn't preserved
with self.assertLogs(level=logging.DEBUG): # suppress log output
fdroidserver.update._strip_and_copy_image(in_file, out_file)
self.assertAlmostEqual(ctime, os.path.getctime(out_file), delta=delta)
def test_strip_and_copy_image_in_file_ctime_changed(self):
out_file = os.path.join(self.testdir, 'icon.png')
with open(out_file, 'w') as fp:
fp.write('to be replaced')
size = os.path.getsize(out_file)
delta = 0.01
time.sleep(delta) # ensure reliable failure when testing ctime
src_file = basedir / 'metadata/info.guardianproject.urzip/en-US/images/icon.png'
in_file = os.path.join(self.testdir, 'in-icon.png')
shutil.copy(src_file, in_file)
time.sleep(delta) # ensure reliable failure when testing ctime
with self.assertLogs(level=logging.DEBUG): # suppress log output
fdroidserver.update._strip_and_copy_image(in_file, out_file)
self.assertNotEqual(size, os.path.getsize(out_file))
self.assertNotAlmostEqual(
os.path.getctime(in_file), os.path.getctime(out_file), delta=delta
)
# _strip_and_copy_image syncs mtime from in_file to out_file
self.assertAlmostEqual(
os.path.getmtime(in_file), os.path.getmtime(out_file), delta=delta
)
def test_strip_and_copy_image_in_file_mtime_changed(self):
in_file = basedir / 'metadata/info.guardianproject.urzip/en-US/images/icon.png'
out_file = os.path.join(self.testdir, 'icon.png')
shutil.copy(in_file, out_file)
os.utime(out_file, (12345, 12345)) # set atime/mtime to something old
with self.assertLogs(level=logging.DEBUG): # suppress log output
fdroidserver.update._strip_and_copy_image(in_file, out_file)
delta = 0.01
self.assertNotAlmostEqual(
os.path.getctime(in_file), os.path.getctime(out_file), delta=delta
)
# _strip_and_copy_image syncs mtime from in_file to out_file
self.assertAlmostEqual(
os.path.getmtime(in_file), os.path.getmtime(out_file), delta=delta
)
def test_create_metadata_from_template_empty_keys(self): def test_create_metadata_from_template_empty_keys(self):
apk = {'packageName': 'rocks.janicerand'} apk = {'packageName': 'rocks.janicerand'}
with mkdtemp() as tmpdir, TmpCwd(tmpdir): with mkdtemp() as tmpdir, TmpCwd(tmpdir):