From 0ae5a4fcdc76a610b776fee5e200221003b92fa4 Mon Sep 17 00:00:00 2001 From: ByteHamster Date: Sun, 23 Aug 2026 10:13:02 +0200 Subject: [PATCH 1/3] Fix symlink handling around triple-t/fastlane metadata --- fdroidserver/update.py | 30 +++++++++++++++++++++++++-- tests/test_update.py | 46 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 2 deletions(-) diff --git a/fdroidserver/update.py b/fdroidserver/update.py index 5865332e..d5143534 100644 --- a/fdroidserver/update.py +++ b/fdroidserver/update.py @@ -1093,6 +1093,22 @@ def insert_funding_yml_donation_links(apps): break +def _filter_entries_within_checkout(root, names, checkout): + """Drop os.walk names whose symlink target escapes the app source checkout.""" + checkout = os.path.realpath(checkout) + kept = [] + for name in names: + target = os.path.realpath(os.path.join(root, name)) + if target == checkout or target.startswith(checkout + os.sep): + kept.append(name) + else: + logging.warning( + _('Ignoring "{path}": symlink points outside the app source') + .format(path=os.path.join(root, name)) + ) + return kept + + def copy_triple_t_store_metadata(apps): """Include store metadata from the app's source repo. @@ -1162,9 +1178,14 @@ def copy_triple_t_store_metadata(apps): # Flutter-style android subdir gradle_subdirs.update(glob.glob(os.path.join('build', packageName, 'android', 'app', 'src', '*', 'play'))) + checkout = os.path.join('build', packageName) for d in sorted(gradle_subdirs): logging.debug('Triple-T Gradle Play Publisher: ' + d) - for root, dirs, files in os.walk(d): + # follow symlinks, but only within the checkout so they can't + # leak files from the build host into the published index + for root, dirs, files in os.walk(d, followlinks=True): + dirs[:] = _filter_entries_within_checkout(root, dirs, checkout) + files = _filter_entries_within_checkout(root, files, checkout) segments = root.split('/') if segments[-2] == 'listings' or segments[-2] == 'release-notes': locale = segments[-1] @@ -1262,9 +1283,14 @@ def insert_localized_app_metadata(apps): for srcd in sorted(sourcedirs): if not os.path.isdir(srcd): continue - for root, dirs, files in os.walk(srcd): + # follow symlinks, but only within the checkout so they can't + # leak files from the build host into the published index + for root, dirs, files in os.walk(srcd, followlinks=True): segments = root.split('/') packageName = segments[1] + checkout = os.path.join(segments[0], packageName) + dirs[:] = _filter_entries_within_checkout(root, dirs, checkout) + files = _filter_entries_within_checkout(root, files, checkout) if packageName not in apps: logging.debug( packageName + ' does not have app metadata, skipping l18n scan.' diff --git a/tests/test_update.py b/tests/test_update.py index c39d770b..3d5e4fe0 100755 --- a/tests/test_update.py +++ b/tests/test_update.py @@ -653,6 +653,52 @@ class UpdateTest(SetUpTearDownMixin, unittest.TestCase): self.assertEqual(app['localized']['en-US']['name'], 'Goguma') self.assertEqual(app['localized']['en-US']['summary'], 'An IRC client for mobile devices') + def _triple_t_app(self, packageName): + """Write minimal metadata and return the Triple-T play dir.""" + os.chdir(self.testdir) + Path('metadata').mkdir(exist_ok=True) + Path('metadata', packageName + '.yml').write_text( + 'CurrentVersionCode: 1\n' + 'Builds:\n - versionCode: 1\n gradle:\n - yes\n' + ) + play = Path('build', packageName, 'src', 'main', 'play') + play.mkdir(parents=True) + return play + + def _copy_triple_t(self, packageName): + config = dict() + fdroidserver.common.fill_config_defaults(config) + fdroidserver.common.config = config + fdroidserver.update.config = config + apps = fdroidserver.metadata.read_metadata() + fdroidserver.update.copy_triple_t_store_metadata(apps) + return apps[packageName].get('localized', {}).get('en-US', {}) + + @unittest.skipUnless(hasattr(os, 'symlink'), 'requires symlink support') + def test_insert_triple_t_symlink_within_checkout(self): + """A listings dir symlinked inside the checkout is still read.""" + packageName = 'com.example.app' + play = self._triple_t_app(packageName) + target = Path('build', packageName, 'store-metadata', 'listings', 'en-US') + target.mkdir(parents=True) + (target / 'full-description.txt').write_text('good description') + os.symlink(os.path.relpath(target.parent, play), play / 'listings') + self.assertEqual(self._copy_triple_t(packageName).get('description'), + 'good description') + + @unittest.skipUnless(hasattr(os, 'symlink'), 'requires symlink support') + def test_insert_triple_t_symlink_outside_checkout(self): + """A listing symlinked outside the checkout must not be published.""" + packageName = 'com.example.app' + play = self._triple_t_app(packageName) + en_US = play / 'listings' / 'en-US' + en_US.mkdir(parents=True) + secret = Path('secret.txt') + secret.write_text('SECRET') + os.symlink(os.path.abspath(secret), en_US / 'full-description.txt') + self.assertNotIn('SECRET', + self._copy_triple_t(packageName).get('description', '')) + def testBadGetsig(self): """getsig() should still be able to fetch the fingerprint of bad signatures""" # config needed to use jarsigner and keytool From 10c3d03b6ab08ee2003e5204139cd71686d4d49d Mon Sep 17 00:00:00 2001 From: ByteHamster Date: Tue, 25 Aug 2026 23:35:04 +0200 Subject: [PATCH 2/3] Turn it into a utility function --- fdroidserver/update.py | 56 +++++++++++++++++++++++------------------- 1 file changed, 31 insertions(+), 25 deletions(-) diff --git a/fdroidserver/update.py b/fdroidserver/update.py index d5143534..fb549777 100644 --- a/fdroidserver/update.py +++ b/fdroidserver/update.py @@ -1093,20 +1093,32 @@ def insert_funding_yml_donation_links(apps): break -def _filter_entries_within_checkout(root, names, checkout): - """Drop os.walk names whose symlink target escapes the app source checkout.""" - checkout = os.path.realpath(checkout) - kept = [] - for name in names: - target = os.path.realpath(os.path.join(root, name)) - if target == checkout or target.startswith(checkout + os.sep): - kept.append(name) - else: - logging.warning( - _('Ignoring "{path}": symlink points outside the app source') - .format(path=os.path.join(root, name)) - ) - return kept +def safe_walk(top, followLinksTo): + """Run os.walk, only following symlinks that stay within followLinksTo. + + Entries whose symlink target escapes the followLinksTo directory + are dropped with a warning, so symlinks in an app's source repo + cannot leak files from the build host into the published index. + """ + followLinksTo = os.path.realpath(followLinksTo) + + def _filter(root, names): + kept = [] + for name in names: + target = os.path.realpath(os.path.join(root, name)) + if target == followLinksTo or target.startswith(followLinksTo + os.sep): + kept.append(name) + else: + logging.warning( + _('Ignoring "{path}": symlink points outside the app source') + .format(path=os.path.join(root, name)) + ) + return kept + + for root, dirs, files in os.walk(top, followlinks=True): + dirs[:] = _filter(root, dirs) + files[:] = _filter(root, files) + yield root, dirs, files def copy_triple_t_store_metadata(apps): @@ -1181,11 +1193,7 @@ def copy_triple_t_store_metadata(apps): checkout = os.path.join('build', packageName) for d in sorted(gradle_subdirs): logging.debug('Triple-T Gradle Play Publisher: ' + d) - # follow symlinks, but only within the checkout so they can't - # leak files from the build host into the published index - for root, dirs, files in os.walk(d, followlinks=True): - dirs[:] = _filter_entries_within_checkout(root, dirs, checkout) - files = _filter_entries_within_checkout(root, files, checkout) + for root, dirs, files in safe_walk(d, followLinksTo=checkout): segments = root.split('/') if segments[-2] == 'listings' or segments[-2] == 'release-notes': locale = segments[-1] @@ -1283,14 +1291,12 @@ def insert_localized_app_metadata(apps): for srcd in sorted(sourcedirs): if not os.path.isdir(srcd): continue - # follow symlinks, but only within the checkout so they can't - # leak files from the build host into the published index - for root, dirs, files in os.walk(srcd, followlinks=True): + topdir, packageName = srcd.split('/')[:2] + assert topdir in ('build', 'metadata') + checkout = os.path.join(topdir, packageName) + for root, dirs, files in safe_walk(srcd, followLinksTo=checkout): segments = root.split('/') packageName = segments[1] - checkout = os.path.join(segments[0], packageName) - dirs[:] = _filter_entries_within_checkout(root, dirs, checkout) - files = _filter_entries_within_checkout(root, files, checkout) if packageName not in apps: logging.debug( packageName + ' does not have app metadata, skipping l18n scan.' From 396bfec45772a732a47686803470bb7f5160189a Mon Sep 17 00:00:00 2001 From: ByteHamster Date: Tue, 25 Aug 2026 23:55:55 +0200 Subject: [PATCH 3/3] CI doesn't like asserts --- fdroidserver/update.py | 1 - 1 file changed, 1 deletion(-) diff --git a/fdroidserver/update.py b/fdroidserver/update.py index fb549777..795b212d 100644 --- a/fdroidserver/update.py +++ b/fdroidserver/update.py @@ -1292,7 +1292,6 @@ def insert_localized_app_metadata(apps): if not os.path.isdir(srcd): continue topdir, packageName = srcd.split('/')[:2] - assert topdir in ('build', 'metadata') checkout = os.path.join(topdir, packageName) for root, dirs, files in safe_walk(srcd, followLinksTo=checkout): segments = root.split('/')