You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@allura.apache.org by br...@apache.org on 2025/08/18 15:23:52 UTC

(allura) 04/04: [#8520] make a pkg_file helper for shorter and safer file locating code

This is an automated email from the ASF dual-hosted git repository.

brondsem pushed a commit to branch db/8520
in repository https://gitbox.apache.org/repos/asf/allura.git

commit 1a233df85759b48e776705a7740abb922c114fff
Author: Dave Brondsema <db...@slashdotmedia.com>
AuthorDate: Fri Aug 15 18:16:25 2025 -0400

    [#8520] make a pkg_file helper for shorter and safer file locating code
---
 Allura/allura/app.py                               |  5 ++--
 Allura/allura/config/middleware.py                 |  4 +--
 Allura/allura/config/resources.py                  | 11 ++++---
 Allura/allura/ext/admin/admin_main.py              |  4 +--
 Allura/allura/ext/project_home/project_main.py     |  5 ++--
 Allura/allura/ext/search/search_main.py            |  4 +--
 Allura/allura/ext/user_profile/user_main.py        |  5 ++--
 Allura/allura/lib/app_globals.py                   |  5 ++--
 Allura/allura/lib/custom_middleware.py             |  7 ++---
 Allura/allura/lib/package_path_loader.py           | 11 +++----
 Allura/allura/lib/plugin.py                        |  5 ++--
 Allura/allura/lib/utils.py                         | 22 ++++++++++++++
 .../tests/functional/test_personal_dashboard.py    |  3 +-
 Allura/allura/tests/test_commands.py               |  6 ++--
 Allura/allura/tests/test_utils.py                  | 20 +++++++++++++
 .../allura/tests/unit/test_package_path_loader.py  |  4 +--
 ForgeActivity/forgeactivity/config/resources.py    |  4 +--
 .../forgediscussion/tests/functional/test_forum.py |  5 ++--
 ForgeFiles/forgefiles/files_main.py                |  2 +-
 .../forgegit/tests/functional/test_controllers.py  | 17 +++++------
 ForgeGit/forgegit/tests/model/test_repository.py   | 35 +++++++++++-----------
 .../forgesvn/tests/functional/test_controllers.py  |  6 ++--
 ForgeSVN/forgesvn/tests/model/test_repository.py   | 20 ++++++-------
 ForgeTracker/forgetracker/config/resources.py      |  6 ++--
 ForgeUserStats/forgeuserstats/tests/test_model.py  |  4 +--
 ForgeUserStats/forgeuserstats/tests/test_stats.py  |  5 ++--
 26 files changed, 133 insertions(+), 92 deletions(-)

diff --git a/Allura/allura/app.py b/Allura/allura/app.py
index 1d1723a7a..ac602fe50 100644
--- a/Allura/allura/app.py
+++ b/Allura/allura/app.py
@@ -23,7 +23,6 @@
 from xml.etree import ElementTree as ET
 from copy import copy
 
-import importlib.resources
 from markupsafe import Markup
 from tg import expose, redirect, flash, validate
 from tg.decorators import without_trailing_slash
@@ -45,7 +44,7 @@
 from allura import model
 from allura.controllers import BaseController
 from allura.lib.decorators import require_post, memoize
-from allura.lib.utils import permanent_redirect, ConfigProxy
+from allura.lib.utils import permanent_redirect, ConfigProxy, pkg_file
 from allura import model as M
 from allura.tasks import index_tasks
 import six
@@ -475,7 +474,7 @@ def has_resource(cls, resource_path):
         """
         for klass in [o for o in cls.__mro__ if issubclass(o, Application)]:
             package = klass.__module__.split(".")[0]
-            if importlib.resources.files(package).joinpath(resource_path).exists():
+            if os.path.exists(pkg_file(package, resource_path)):
                 return klass
 
     def has_access(self, user, topic):
diff --git a/Allura/allura/config/middleware.py b/Allura/allura/config/middleware.py
index 214579ed7..d1cd4cdff 100644
--- a/Allura/allura/config/middleware.py
+++ b/Allura/allura/config/middleware.py
@@ -65,7 +65,7 @@
 from allura.lib.custom_middleware import SetHeadersMiddleware
 from allura.lib.custom_middleware import StatusCodeRedirect
 from allura.lib import helpers as h
-from allura.lib.utils import configure_ming
+from allura.lib.utils import configure_ming, pkg_file
 
 __all__ = ['make_app']
 
@@ -108,7 +108,7 @@ def _make_core_app(root, global_conf: dict, **app_conf):
     under ``[app:main]``.
     """
     # Run all the initialization code here
-    mimetypes.init([str(importlib.resources.files('allura')), 'etc/mime.types'] + mimetypes.knownfiles)
+    mimetypes.init([pkg_file('allura', 'etc/mime.types')] + mimetypes.knownfiles)
 
     # Configure MongoDB
     configure_ming(app_conf)
diff --git a/Allura/allura/config/resources.py b/Allura/allura/config/resources.py
index 0693d1746..8fac055c2 100644
--- a/Allura/allura/config/resources.py
+++ b/Allura/allura/config/resources.py
@@ -18,20 +18,19 @@
 import os
 import logging
 
-import importlib.resources
-
 from allura.lib.helpers import iter_entry_points
+from allura.lib.utils import pkg_file
 
 log = logging.getLogger(__name__)
 
 
 def register_ew_resources(manager):
     manager.register_directory(
-        'js', str(importlib.resources.files('allura').joinpath('lib/widgets/resources/js')))
+        'js', pkg_file('allura', 'lib/widgets/resources/js'))
     manager.register_directory(
-        'css', str(importlib.resources.files('allura').joinpath('lib/widgets/resources/css')))
+        'css', pkg_file('allura', 'lib/widgets/resources/css'))
     manager.register_directory(
-        'allura', str(importlib.resources.files('allura').joinpath('public/nf')))
+        'allura', pkg_file('allura', 'public/nf'))
     for ep in iter_entry_points('allura'):
         try:
             app = ep.load()
@@ -41,7 +40,7 @@ def register_ew_resources(manager):
                 package = resource_cls.__module__.split(".")[0]
                 manager.register_directory(
                     'tool/%s' % ep.name.lower(),
-                    importlib.resources.files(package).joinpath(resource_path))
+                    pkg_file(package, resource_path))
         except ImportError:
             log.warning('Cannot import entry point %s', ep)
             raise
diff --git a/Allura/allura/ext/admin/admin_main.py b/Allura/allura/ext/admin/admin_main.py
index f055ba2c0..aea3085b9 100644
--- a/Allura/allura/ext/admin/admin_main.py
+++ b/Allura/allura/ext/admin/admin_main.py
@@ -23,7 +23,6 @@
 from urllib.parse import urlparse
 import json
 from operator import itemgetter, attrgetter
-import importlib.resources
 
 from tg import tmpl_context as c, app_globals as g
 from tg import request
@@ -47,6 +46,7 @@
 from allura.lib.decorators import require_post
 from allura.tasks import export_tasks
 from allura.lib.widgets.project_list import ProjectScreenshots
+from allura.lib.utils import pkg_file
 
 from . import widgets as aw
 import six
@@ -89,7 +89,7 @@ def __init__(self, project, config):
         self.root = ProjectAdminController()
         self.api_root = ProjectAdminRestController()
         self.admin = AdminAppAdminController(self)
-        self.templates = str(importlib.resources.files('allura.ext.admin')) + '/templates'
+        self.templates = pkg_file('allura.ext.admin', 'templates')
         self.sitemap = [SitemapEntry('Admin', '.')]
 
     def is_visible_to(self, user):
diff --git a/Allura/allura/ext/project_home/project_main.py b/Allura/allura/ext/project_home/project_main.py
index cfc135cf8..e015f254c 100644
--- a/Allura/allura/ext/project_home/project_main.py
+++ b/Allura/allura/ext/project_home/project_main.py
@@ -17,7 +17,6 @@
 
 import logging
 
-import importlib.resources
 from tg import tmpl_context as c
 from tg import expose, redirect
 from tg.decorators import with_trailing_slash
@@ -25,10 +24,10 @@
 from allura import version
 from allura.app import Application, SitemapEntry
 from allura.lib import helpers as h
+from allura.lib.utils import pkg_file
 from allura.controllers import BaseController
 from allura import model
 
-
 log = logging.getLogger(__name__)
 
 
@@ -46,7 +45,7 @@ class ProjectHomeApp(Application):
     def __init__(self, project, config):
         Application.__init__(self, project, config)
         self.root = ProjectHomeController()
-        self.templates = str(importlib.resources.files('allura.ext.project_home') / 'templates')
+        self.templates = pkg_file('allura.ext.project_home', 'templates')
 
     def is_visible_to(self, user):
         '''Whether the user can view the app.'''
diff --git a/Allura/allura/ext/search/search_main.py b/Allura/allura/ext/search/search_main.py
index baf5a8a71..72c0c58e0 100644
--- a/Allura/allura/ext/search/search_main.py
+++ b/Allura/allura/ext/search/search_main.py
@@ -17,7 +17,6 @@
 
 import logging
 
-import importlib.resources
 from tg import tmpl_context as c
 from tg import expose, validate
 from tg.decorators import with_trailing_slash
@@ -26,6 +25,7 @@
 from allura.app import Application
 from allura import version
 from allura.lib.search import search_app
+from allura.lib.utils import pkg_file
 from allura.lib.widgets.search import SearchResults, SearchHelp
 from allura.controllers import BaseController
 
@@ -46,7 +46,7 @@ class SearchApp(Application):
     def __init__(self, project, config):
         Application.__init__(self, project, config)
         self.root = SearchController()
-        self.templates = str(importlib.resources.files('allura.ext.search') / 'templates')
+        self.templates = pkg_file('allura.ext.search', 'templates')
 
     def main_menu(self):  # pragma no cover
         return []
diff --git a/Allura/allura/ext/user_profile/user_main.py b/Allura/allura/ext/user_profile/user_main.py
index 6ae5a8676..416436dfe 100644
--- a/Allura/allura/ext/user_profile/user_main.py
+++ b/Allura/allura/ext/user_profile/user_main.py
@@ -16,8 +16,6 @@
 #       under the License.
 
 import logging
-
-import importlib.resources
 from datetime import datetime
 
 import six
@@ -38,6 +36,7 @@
 from allura.lib.decorators import require_post
 from allura.lib.plugin import AuthenticationProvider
 from allura.lib.security import require_access
+from allura.lib.utils import pkg_file
 from allura.lib.widgets.user_profile import SendMessageForm, SectionsUtil, SectionBase, ProjectsSectionBase
 from allura.model import ACE, ProjectRole
 
@@ -67,7 +66,7 @@ class UserProfileApp(Application):
     def __init__(self, user, config):
         Application.__init__(self, user, config)
         self.root = UserProfileController()
-        self.templates = str(importlib.resources.files('allura.ext.user_profile') / 'templates')
+        self.templates = pkg_file('allura.ext.user_profile', 'templates')
         self.api_root = UserProfileRestController()
 
     @property
diff --git a/Allura/allura/lib/app_globals.py b/Allura/allura/lib/app_globals.py
index 47e4eded4..23c6fd494 100644
--- a/Allura/allura/lib/app_globals.py
+++ b/Allura/allura/lib/app_globals.py
@@ -25,7 +25,6 @@
 import os
 import time
 import traceback
-import importlib.resources
 
 import activitystream
 import markdown
@@ -38,6 +37,7 @@
 from tg import tmpl_context as c
 from paste.deploy.converters import asbool, asint, aslist
 from pypeline.markup import markup as pypeline_markup
+
 from ming.odm import session, MappedClass
 
 import ew as ew_core
@@ -53,6 +53,7 @@
     EmojiExtension,
     UserMentionExtension
 )
+from allura.lib.utils import pkg_file
 from allura.eventslistener import PostEvent
 
 from allura.lib import gravatar, plugin, utils
@@ -194,7 +195,7 @@ def __init__(self):
         self.__dict__ = self.__shared_state
         if self.__shared_state:
             return
-        self.allura_templates = str(importlib.resources.files('allura') / 'templates')
+        self.allura_templates = pkg_file('allura', 'templates')
         # Setup SOLR
         self.solr_server = aslist(config.get('solr.server'), ',')
         # skip empty strings in case of extra commas
diff --git a/Allura/allura/lib/custom_middleware.py b/Allura/allura/lib/custom_middleware.py
index 685b231a1..a2c672b9e 100644
--- a/Allura/allura/lib/custom_middleware.py
+++ b/Allura/allura/lib/custom_middleware.py
@@ -20,7 +20,6 @@
 import logging
 
 import tg
-import importlib.resources
 from paste import fileapp
 from paste.deploy.converters import aslist, asbool
 from tg import tmpl_context as c
@@ -31,7 +30,7 @@
 from ming.odm import session
 
 from allura.lib import helpers as h
-from allura.lib.utils import is_ajax
+from allura.lib.utils import is_ajax, pkg_file
 from allura import model as M
 import allura.model.repository
 from tg import tmpl_context as c
@@ -79,10 +78,10 @@ def get_app(self, environ):
                 resource_cls = ep.load().has_resource(resource_path)
                 if resource_cls:
                     package = resource_cls.__module__.split(".")[0]
-                    file_path = str(importlib.resources.files(package) / resource_path)
+                    file_path = pkg_file(package, resource_path)
                     return fileapp.FileApp(file_path, [('Access-Control-Allow-Origin', '*')])
         filename = environ['PATH_INFO'][len(self.script_name):]
-        file_path = str(importlib.resources.files('allura')) + '/public/nf/' + filename
+        file_path = pkg_file('allura', 'public/nf', filename)
         return fileapp.FileApp(file_path, [('Access-Control-Allow-Origin', '*')])
 
 
diff --git a/Allura/allura/lib/package_path_loader.py b/Allura/allura/lib/package_path_loader.py
index 6fe11d163..a9d63200a 100644
--- a/Allura/allura/lib/package_path_loader.py
+++ b/Allura/allura/lib/package_path_loader.py
@@ -122,12 +122,13 @@ class NewToolApp(Application):
 **TODO:** Support multiple partial themes
 
 """
-import importlib.resources
 import os
 
 import jinja2
 from tg import config
 from paste.deploy.converters import asbool
+
+from allura.lib.utils import pkg_file
 from ming.utils import LazyProperty
 
 from allura.lib.helpers import topological_sort, iter_entry_points
@@ -164,9 +165,9 @@ def _load_paths(self):
         """
         paths = self.default_paths[:]  # copy default_paths
         paths[-1:0] = [  # insert all eps just before last item, by default
-            [ep.name, str(
-                importlib.resources.files(ep.module.split(".")[0])  # first part gives a pkg, for importlib
-                / '/'.join(ep.module.split(".")[1:-1])  # append the rest (except last part) to match pkg_resources previous behavior.  A bit weird.
+            [ep.name, pkg_file(
+                ep.module.split(".")[0],  # first part gives a pkg, for importlib
+                *ep.module.split(".")[1:-1]  # append the rest (except last part) to match pkg_resources previous behavior.  A bit weird.
             )]
             for ep in iter_entry_points(self.override_entrypoint)
         ]
@@ -281,7 +282,7 @@ def get_source(self, environment, template):
 
         if ':' in template:
             package, path = template.split(':', 2)
-            filename = str(importlib.resources.files(package) / path)
+            filename = pkg_file(package, path)
             return self.fs_loader.get_source(environment, filename)
         else:
             return self.fs_loader.get_source(environment, template)
diff --git a/Allura/allura/lib/plugin.py b/Allura/allura/lib/plugin.py
index b327ac79c..0ee13d737 100644
--- a/Allura/allura/lib/plugin.py
+++ b/Allura/allura/lib/plugin.py
@@ -40,12 +40,13 @@
 import six
 from passlib.context import CryptContext
 
+from allura.lib.utils import pkg_file
+
 try:
     import ldap
     from ldap import modlist
 except ImportError:
     ldap = modlist = None
-import importlib.resources
 import tg
 from tg import config, request, redirect, response
 from tg import tmpl_context as c, app_globals as g
@@ -1471,7 +1472,7 @@ def require(self):
     def register_ew_resources(cls, manager, name):
         manager.register_directory(
             'theme/%s' % name,
-            str(importlib.resources.files('allura')) + '/nf/' + name)
+            pkg_file('allura', 'nf/' + name))
 
     def href(self, href, theme_name=None):
         '''
diff --git a/Allura/allura/lib/utils.py b/Allura/allura/lib/utils.py
index 0391f026c..6ebb95c80 100644
--- a/Allura/allura/lib/utils.py
+++ b/Allura/allura/lib/utils.py
@@ -17,6 +17,7 @@
 from __future__ import annotations
 
 import base64
+import importlib
 from collections.abc import Iterable, Mapping, MutableMapping
 from contextlib import contextmanager
 import time
@@ -28,6 +29,7 @@
 import random
 import mimetypes
 import re
+from pathlib import Path
 from typing import TypeVar
 import magic
 from itertools import groupby
@@ -820,3 +822,23 @@ def socket_default_timeout(timeout):
         yield
     finally:
         socket.setdefaulttimeout(orig_timeout)
+
+
+def join_paths_no_traversal(base: str | Path, *paths: str | Path) -> str:
+    for p in paths:
+        if Path(p).is_absolute():
+            raise ValueError(f"Absolute paths is not allowed: {p}")
+        if any(part == ".." for part in Path(p).parts):
+            raise ValueError(f"Parent directory traversal is not allowed: {p}")
+    base = Path(base)
+    joined = str(base.joinpath(*paths).resolve())
+    base_resolved = str(base.resolve())
+    if not joined.startswith(base_resolved):
+        raise ValueError("Resulting path escapes base directory")
+    if paths and str(paths[-1]).endswith('/'):
+        joined += '/'
+    return joined
+
+
+def pkg_file(pkg_name: str, *paths: str) -> str:
+    return join_paths_no_traversal(importlib.resources.files(pkg_name), *paths)
diff --git a/Allura/allura/tests/functional/test_personal_dashboard.py b/Allura/allura/tests/functional/test_personal_dashboard.py
index 59b8e44b5..5109477a9 100644
--- a/Allura/allura/tests/functional/test_personal_dashboard.py
+++ b/Allura/allura/tests/functional/test_personal_dashboard.py
@@ -19,6 +19,7 @@
 import mock
 import tg
 
+from allura.lib.utils import pkg_file
 from ming.odm import ThreadLocalODMSession, ThreadLocalODMSession
 from tg import tmpl_context as c
 
@@ -95,7 +96,7 @@ def setup_method(self, method):
     def setup_with_tools(self):
         setup_global_objects()
         h.set_context('test2', 'src-git', neighborhood='Projects')
-        repo_dir = str(importlib.resources.files('forgegit') / 'tests/data')
+        repo_dir = pkg_file('forgegit', 'tests/data')
         c.app.repo.fs_path = repo_dir
         c.app.repo.name = 'testgit.git'
         self.repo = c.app.repo
diff --git a/Allura/allura/tests/test_commands.py b/Allura/allura/tests/test_commands.py
index 52ef9aea8..112557e42 100644
--- a/Allura/allura/tests/test_commands.py
+++ b/Allura/allura/tests/test_commands.py
@@ -20,6 +20,7 @@
 
 from testfixtures import OutputCapture
 
+from allura.lib.utils import pkg_file
 from ming.base import Object
 from ming.odm import ThreadLocalODMSession
 from mock import Mock, call, patch
@@ -51,10 +52,9 @@ def setup_module():
 
 def test_script():
     cmd = script.ScriptCommand('script')
-    cmd.run(
-        [test_config, str(importlib.resources.files('allura') / 'tests/tscript.py')])
+    cmd.run([test_config, pkg_file('allura', 'tests/tscript.py')])
     with pytest.raises(ValueError):
-        cmd.run([test_config, str(importlib.resources.files('allura') / 'tests/tscript_error.py')])
+        cmd.run([test_config, pkg_file('allura', 'tests/tscript_error.py')])
 
 
 def test_set_neighborhood_max_projects():
diff --git a/Allura/allura/tests/test_utils.py b/Allura/allura/tests/test_utils.py
index 64d02c59b..a47950d53 100644
--- a/Allura/allura/tests/test_utils.py
+++ b/Allura/allura/tests/test_utils.py
@@ -19,6 +19,7 @@
 import time
 import datetime as dt
 from os import path
+from pathlib import Path
 
 import ming
 from ming.odm import session
@@ -409,3 +410,22 @@ def test_urlencode():
     # list of pairs - including unicode and bytes
     assert (utils.urlencode([('a', 1), ('b', 'ƒ'), ('c', 'ƒ'.encode())]) ==
             'a=1&b=%C6%92&c=%C6%92')
+
+
+@pytest.mark.parametrize('base, paths, expected', [
+    ('/foo', ['bar'], '/foo/bar'),
+    ('/foo', ['bar/'], '/foo/bar/'),
+    ('/foo', ['bar', 'baz'], '/foo/bar/baz'),
+    (Path('/foo'), [Path('bar')], '/foo/bar'),
+    ('/foo', ['/tmp'], ValueError),
+    ('/foo', ['x', '/tmp'], ValueError),
+    ('/foo', ['..'], ValueError),
+    ('/foo', ['bar/..'], ValueError),
+    ('/foo', ['x', 'bar/../asf'], ValueError),
+])
+def test_join_paths_no_traversal(base, paths, expected: str | type[Exception]):
+    if isinstance(expected, str):
+        assert utils.join_paths_no_traversal(base, *paths) == expected
+    else:
+        with pytest.raises(expected):
+            utils.join_paths_no_traversal(base, *paths)
diff --git a/Allura/allura/tests/unit/test_package_path_loader.py b/Allura/allura/tests/unit/test_package_path_loader.py
index bbff977fc..3d9383843 100644
--- a/Allura/allura/tests/unit/test_package_path_loader.py
+++ b/Allura/allura/tests/unit/test_package_path_loader.py
@@ -189,7 +189,7 @@ def test_get_source(self, fs_loader):
             jinja2.TemplateNotFound('test'), 'fs_load']
 
         with mock.patch('importlib.resources.files') as rf:
-            rf.return_value = PurePath('resource')
+            rf.return_value = PurePath('/resource')
             # no override, ':' in template
             output = ppl.get_source(
                 'env', 'allura.ext.admin:templates/audit.html')
@@ -197,7 +197,7 @@ def test_get_source(self, fs_loader):
 
         assert output == 'fs_load'
         assert fs_loader().get_source.call_count == 2
-        fs_loader().get_source.assert_called_with('env', 'resource/templates/audit.html')
+        fs_loader().get_source.assert_called_with('env', '/resource/templates/audit.html')
 
         fs_loader().get_source.reset_mock()
         fs_loader().get_source.side_effect = [
diff --git a/ForgeActivity/forgeactivity/config/resources.py b/ForgeActivity/forgeactivity/config/resources.py
index 303571713..6aa193936 100644
--- a/ForgeActivity/forgeactivity/config/resources.py
+++ b/ForgeActivity/forgeactivity/config/resources.py
@@ -15,9 +15,9 @@
 #       specific language governing permissions and limitations
 #       under the License.
 
-import importlib.resources
+from allura.lib.utils import pkg_file
 
 
 def register_ew_resources(manager):
     manager.register_directory(
-        'activity_js', importlib.resources.files('forgeactivity') / 'widgets/resources/js')
+        'activity_js', pkg_file('forgeactivity', 'widgets/resources/js'))
diff --git a/ForgeDiscussion/forgediscussion/tests/functional/test_forum.py b/ForgeDiscussion/forgediscussion/tests/functional/test_forum.py
index 78909240e..55dc9db5e 100644
--- a/ForgeDiscussion/forgediscussion/tests/functional/test_forum.py
+++ b/ForgeDiscussion/forgediscussion/tests/functional/test_forum.py
@@ -15,7 +15,6 @@
 #       specific language governing permissions and limitations
 #       under the License.
 
-import os
 import mock
 import pytest
 import random
@@ -24,7 +23,6 @@
 from email.mime.image import MIMEImage
 from email.mime.multipart import MIMEMultipart
 
-import importlib.resources
 import pymongo
 
 from ming.odm import ThreadLocalODMSession
@@ -38,6 +36,7 @@
 from allura.tasks import mail_tasks
 from alluratest.controller import TestController
 from allura.lib import helpers as h
+from allura.lib.utils import pkg_file
 from allura.tests import decorators as td
 
 from forgediscussion import model as FM
@@ -104,7 +103,7 @@ def test_html_email_with_images(self):
             ],
             policy=email_policy
         )
-        with open(str(importlib.resources.files('forgediscussion')) + '/tests/data/python-logo.png', 'rb') as fp:
+        with open(pkg_file('forgediscussion', 'tests/data/python-logo.png'), 'rb') as fp:
             img = MIMEImage(fp.read(), policy=email_policy)
             img.add_header('Content-Disposition', 'attachment',
                            filename='python-logo.png')
diff --git a/ForgeFiles/forgefiles/files_main.py b/ForgeFiles/forgefiles/files_main.py
index 9279014e1..21ad9b12b 100755
--- a/ForgeFiles/forgefiles/files_main.py
+++ b/ForgeFiles/forgefiles/files_main.py
@@ -368,7 +368,7 @@ def publish_folder(self, folder_id=None, remarks=None):
             try:
                 for i in user_ids:
                     user_object = M.User.query.get(_id=i)
-                    template_name = 'forgefiles:/templates/mail.html'
+                    template_name = 'forgefiles:templates/mail.html'
                     text = g.jinja2_env.get_template(template_name).render(dict(
                         base_url=config.get('base_url'), user_object=user_object, project=c.project,
                         remarks=remarks, folder_object=folder_object, project_owner=c.user,
diff --git a/ForgeGit/forgegit/tests/functional/test_controllers.py b/ForgeGit/forgegit/tests/functional/test_controllers.py
index 5505d2bb6..b0e74de78 100644
--- a/ForgeGit/forgegit/tests/functional/test_controllers.py
+++ b/ForgeGit/forgegit/tests/functional/test_controllers.py
@@ -14,7 +14,6 @@
 #       KIND, either express or implied.  See the License for the
 #       specific language governing permissions and limitations
 #       under the License.
-import importlib.resources
 import json
 import re
 import os
@@ -22,7 +21,6 @@
 import tempfile
 import textwrap
 
-import importlib.resources
 from alluratest.tools import assert_regexp_matches
 from tg import tmpl_context as c
 import tg
@@ -33,6 +31,7 @@
 from allura import model as M
 from allura.lib import helpers as h
 from allura.lib import macro
+from allura.lib.utils import pkg_file
 from alluratest.controller import TestController, TestRestApiBase
 from allura.tests.decorators import with_tool
 from allura.tests.test_globals import squish_spaces
@@ -49,7 +48,7 @@ def setup_method(self, method):
     @with_git
     def setup_with_tools(self):
         h.set_context('test', 'src-git', neighborhood='Projects')
-        repo_dir = str(importlib.resources.files('forgegit') / 'tests/data')
+        repo_dir = pkg_file('forgegit', 'tests/data')
         c.app.repo.fs_path = repo_dir
         c.app.repo.status = 'ready'
         c.app.repo.name = 'testgit.git'
@@ -65,7 +64,7 @@ def setup_with_tools(self):
     @with_tool('test', 'Git', 'testgit-index', 'Git', type='git')
     def setup_testgit_index_repo(self):
         h.set_context('test', 'testgit-index', neighborhood='Projects')
-        repo_dir = str(importlib.resources.files('forgegit') / 'tests/data')
+        repo_dir = pkg_file('forgegit', 'tests/data')
         c.app.repo.fs_path = repo_dir
         c.app.repo.status = 'ready'
         c.app.repo.name = 'testgit_index.git'
@@ -83,7 +82,7 @@ def setup_method(self, method):
     @with_git
     def setup_with_tools(self):
         h.set_context('test', 'src-git', neighborhood='Projects')
-        repo_dir = str(importlib.resources.files('forgegit') / 'tests/data')
+        repo_dir = pkg_file('forgegit', 'tests/data')
         c.app.repo.fs_path = repo_dir
         c.app.repo.name = 'testui2.git'
         c.app.repo.status = 'ready'
@@ -114,7 +113,7 @@ class TestRootController(_TestCase):
     @with_tool('test', 'Git', 'weird-chars', 'WeirdChars', type='git')
     def _setup_weird_chars_repo(self):
         h.set_context('test', 'weird-chars', neighborhood='Projects')
-        repo_dir = str(importlib.resources.files('forgegit') / 'tests/data')
+        repo_dir = pkg_file('forgegit', 'tests/data')
         c.app.repo.fs_path = repo_dir
         c.app.repo.status = 'ready'
         c.app.repo.name = 'weird-chars.git'
@@ -978,7 +977,7 @@ def setup_method(self, method):
     @with_git
     def setup_with_tools(self):
         h.set_context('test', 'src-git', neighborhood='Projects')
-        repo_dir = str(importlib.resources.files('forgegit') / 'tests/data')
+        repo_dir = pkg_file('forgegit', 'tests/data')
         c.app.repo.fs_path = repo_dir
         c.app.repo.status = 'ready'
         c.app.repo.name = 'testmime.git'
@@ -1007,7 +1006,7 @@ def setup_method(self, method):
     @with_git
     def setup_with_tools(self):
         h.set_context('test', 'src-git', neighborhood='Projects')
-        repo_dir = str(importlib.resources.files('forgegit') / 'tests/data')
+        repo_dir = pkg_file('forgegit', 'tests/data')
         c.app.repo.fs_path = repo_dir
         c.app.repo.status = 'ready'
         c.app.repo.name = 'testrename.git'
@@ -1075,7 +1074,7 @@ def setup_method(self, method):
     @with_git
     def setup_with_tools(self):
         h.set_context('test', 'src-git', neighborhood='Projects')
-        repo_dir = str(importlib.resources.files('forgegit') / 'tests/data')
+        repo_dir = pkg_file('forgegit', 'tests/data')
         c.app.repo.fs_path = repo_dir
         c.app.repo.status = 'ready'
         c.app.repo.name = 'test_branch.git'
diff --git a/ForgeGit/forgegit/tests/model/test_repository.py b/ForgeGit/forgegit/tests/model/test_repository.py
index 7d8628067..b4e57a051 100644
--- a/ForgeGit/forgegit/tests/model/test_repository.py
+++ b/ForgeGit/forgegit/tests/model/test_repository.py
@@ -18,13 +18,14 @@
 import os
 import shutil
 import stat
-import importlib.resources
 import datetime
 import email.iterators
 import pytest
 import mock
 from tg import tmpl_context as c, app_globals as g
 import tg
+
+from allura.lib.utils import pkg_file
 from ming.base import Object
 from ming.odm import ThreadLocalODMSession, session
 from testfixtures import TempDirectory
@@ -54,7 +55,7 @@ def setup_method(self, method):
     def setup_with_tools(self):
         setup_global_objects()
         h.set_context('test', 'src-git', neighborhood='Projects')
-        repo_dir = str(importlib.resources.files('forgegit') / 'tests/data')
+        repo_dir = pkg_file('forgegit', 'tests/data')
         c.app.repo.fs_path = repo_dir
         c.app.repo.name = 'testgit.git'
         self.repo = c.app.repo
@@ -130,7 +131,7 @@ def setup_method(self, method):
     def setup_with_tools(self):
         setup_global_objects()
         h.set_context('test', 'src-git', neighborhood='Projects')
-        repo_dir = str(importlib.resources.files('forgegit') / 'tests/data')
+        repo_dir = pkg_file('forgegit', 'tests/data')
         c.app.repo.fs_path = repo_dir
         c.app.repo.name = 'testgit.git'
         self.repo = c.app.repo
@@ -166,7 +167,7 @@ def test_fork(self):
             url_path='/test/',
             tool='git',
             status='creating')
-        repo_path = str(importlib.resources.files('forgegit') / 'tests/data/testgit.git')
+        repo_path = pkg_file('forgegit', 'tests/data/testgit.git')
         dirname = os.path.join(repo.fs_path, repo.name)
         if os.path.exists(dirname):
             shutil.rmtree(dirname)
@@ -188,7 +189,7 @@ def test_clone(self):
             url_path='/test/',
             tool='git',
             status='creating')
-        repo_path = str(importlib.resources.files('forgegit') / 'tests/data/testgit.git')
+        repo_path = pkg_file('forgegit', 'tests/data/testgit.git')
         dirname = os.path.join(repo.fs_path, repo.name)
         if os.path.exists(dirname):
             shutil.rmtree(dirname)
@@ -220,7 +221,7 @@ def test_hotcopy(self, clone_from):
                 tool='git',
                 status='creating')
             repo.app.config.options['hotcopy'] = True
-            repo_path = str(importlib.resources.files('forgegit') / 'tests/data/testgit.git')
+            repo_path = pkg_file('forgegit', 'tests/data/testgit.git')
             dirname = os.path.join(repo.fs_path, repo.name)
             if os.path.exists(dirname):
                 shutil.rmtree(dirname)
@@ -429,7 +430,7 @@ def test_notification_email_max_commits(self):
     @td.with_tool('test', 'Git', 'weird-chars', 'WeirdChars', type='git')
     def _setup_weird_chars_repo(self):
         h.set_context('test', 'weird-chars', neighborhood='Projects')
-        repo_dir = str(importlib.resources.files('forgegit') / 'tests/data')
+        repo_dir = pkg_file('forgegit', 'tests/data')
         c.app.repo.fs_path = repo_dir
         c.app.repo.status = 'ready'
         c.app.repo.name = 'weird-chars.git'
@@ -600,7 +601,7 @@ def test_default_branch_set(self):
         assert self.repo.get_default_branch(('main', 'master')) == 'zz'
 
     def test_update_default_branch(self):
-        repo_dir = str(importlib.resources.files('forgegit') / 'tests/data/testgit.git')
+        repo_dir = pkg_file('forgegit', 'tests/data/testgit.git')
         repo = mock.Mock(full_fs_path=repo_dir)
         repo.__ming__ = mock.Mock()
         impl = GM.git_repo.GitImplementation(repo)
@@ -793,7 +794,7 @@ def test_merge_raise_exception(self, git, shutil, tempfile):
     def test_paged_diffs(self):
         # setup
         h.set_context('test', 'src-weird', neighborhood='Projects')
-        repo_dir = str(importlib.resources.files('forgegit') / 'tests/data')
+        repo_dir = pkg_file('forgegit', 'tests/data')
         repo = GM.Repository(
             name='weird-chars.git',
             fs_path=repo_dir,
@@ -896,7 +897,7 @@ def test_paged_diffs(self):
     def test_paged_diffs_with_detect_copies(self):
         # setup
         h.set_context('test', 'src-weird', neighborhood='Projects')
-        repo_dir = str(importlib.resources.files('forgegit') / 'tests/data')
+        repo_dir = pkg_file('forgegit', 'tests/data')
         repo = GM.Repository(
             name='weird-chars.git',
             fs_path=repo_dir,
@@ -979,7 +980,7 @@ def test_cached_tags(self):
 class TestGitImplementation:
 
     def test_branches(self):
-        repo_dir = str(importlib.resources.files('forgegit') / 'tests/data/testgit.git')
+        repo_dir = pkg_file('forgegit', 'tests/data/testgit.git')
         repo = mock.Mock(full_fs_path=repo_dir)
         repo.__ming__ = mock.Mock()
         repo.cached_branches = []
@@ -992,7 +993,7 @@ def test_branches(self):
         ]
 
     def test_tags(self):
-        repo_dir = str(importlib.resources.files('forgegit') / 'tests/data/testgit.git')
+        repo_dir = pkg_file('forgegit', 'tests/data/testgit.git')
         repo = mock.Mock(full_fs_path=repo_dir)
         repo.__ming__ = mock.Mock()
         repo.cached_tags = []
@@ -1003,7 +1004,7 @@ def test_tags(self):
         ]
 
     def test_last_commit_ids(self):
-        repo_dir = str(importlib.resources.files('forgegit') / 'tests/data/testrename.git')
+        repo_dir = pkg_file('forgegit', 'tests/data/testrename.git')
         repo = mock.Mock(full_fs_path=repo_dir)
         impl = GM.git_repo.GitImplementation(repo)
         def lcd(c, p): return impl.last_commit_ids(mock.Mock(_id=c), p)
@@ -1022,7 +1023,7 @@ def test_last_commit_ids_threaded(self):
     @mock.patch('forgegit.model.git_repo.GitImplementation._git', new_callable=mock.PropertyMock)
     def test_last_commit_ids_threaded_error(self, _git):
         with h.push_config(tg.config, lcd_thread_chunk_size=1, lcd_timeout=2):
-            repo_dir = str(importlib.resources.files('forgegit') / 'tests/data/testrename.git')
+            repo_dir = pkg_file('forgegit', 'tests/data/testrename.git')
             repo = mock.Mock(full_fs_path=repo_dir)
             _git.side_effect = ValueError
             impl = GM.git_repo.GitImplementation(repo)
@@ -1041,7 +1042,7 @@ def setup_method(self, method):
     def setup_with_tools(self):
         setup_global_objects()
         h.set_context('test', 'src-git', neighborhood='Projects')
-        repo_dir = str(importlib.resources.files('forgegit') / 'tests/data')
+        repo_dir = pkg_file('forgegit', 'tests/data')
         c.app.repo.fs_path = repo_dir
         c.app.repo.name = 'testgit.git'
         self.repo = c.app.repo
@@ -1116,7 +1117,7 @@ def setup_method(self, method):
     def setup_with_tools(self):
         setup_global_objects()
         h.set_context('test', 'src-git', neighborhood='Projects')
-        repo_dir = str(importlib.resources.files('forgegit') / 'tests/data')
+        repo_dir = pkg_file('forgegit', 'tests/data')
         c.app.repo.fs_path = repo_dir
         c.app.repo.name = 'testmime.git'
         self.repo = c.app.repo
@@ -1146,7 +1147,7 @@ def setup_method(self, method):
     def setup_with_tools(self):
         setup_global_objects()
         h.set_context('test', 'src-git', neighborhood='Projects')
-        repo_dir = str(importlib.resources.files('forgegit') / 'tests/data')
+        repo_dir = pkg_file('forgegit', 'tests/data')
         c.app.repo.fs_path = repo_dir
         c.app.repo.name = 'testrename.git'
         self.repo = c.app.repo
diff --git a/ForgeSVN/forgesvn/tests/functional/test_controllers.py b/ForgeSVN/forgesvn/tests/functional/test_controllers.py
index b629029fa..c81972b55 100644
--- a/ForgeSVN/forgesvn/tests/functional/test_controllers.py
+++ b/ForgeSVN/forgesvn/tests/functional/test_controllers.py
@@ -23,6 +23,8 @@
 import tg
 import importlib.resources
 from tg import tmpl_context as c
+
+from allura.lib.utils import pkg_file
 from ming.odm import ThreadLocalODMSession
 from mock import patch
 
@@ -41,7 +43,7 @@ def setup_method(self, method):
 
     def _make_app(self, mount_point, name):
         h.set_context('test', mount_point, neighborhood='Projects')
-        repo_dir = str(importlib.resources.files('forgesvn')) + '/tests/data/'
+        repo_dir = pkg_file('forgesvn', 'tests/data/')
         c.app.repo.fs_path = repo_dir
         c.app.repo.status = 'ready'
         c.app.repo.name = name
@@ -354,7 +356,7 @@ def setup_method(self, method):
     @with_svn
     def setup_with_tools(self):
         h.set_context('test', 'src', neighborhood='Projects')
-        repo_dir = str(importlib.resources.files('forgesvn')) + '/tests/data/'
+        repo_dir = pkg_file('forgesvn', 'tests/data/')
         c.app.repo.fs_path = repo_dir
         c.app.repo.status = 'ready'
         c.app.repo.name = 'testsvn'
diff --git a/ForgeSVN/forgesvn/tests/model/test_repository.py b/ForgeSVN/forgesvn/tests/model/test_repository.py
index db9f75dc1..7d218a755 100644
--- a/ForgeSVN/forgesvn/tests/model/test_repository.py
+++ b/ForgeSVN/forgesvn/tests/model/test_repository.py
@@ -18,7 +18,6 @@
 import shutil
 from unittest import skipUnless
 
-import importlib.resources
 from itertools import count, product
 from datetime import datetime
 from zipfile import ZipFile
@@ -38,6 +37,7 @@
 from allura import model as M
 from allura.model.repo_refresh import send_notifications
 from allura.lib import helpers as h
+from allura.lib.utils import pkg_file
 from allura.webhooks import RepoPushWebhookSender
 from allura.tests.model.test_repo import RepoImplTestBase
 
@@ -57,7 +57,7 @@ def setup_method(self, method):
     def setup_with_tools(self):
         setup_global_objects()
         h.set_context('test', 'src', neighborhood='Projects')
-        repo_dir = str(importlib.resources.files('forgesvn')) + '/tests/data/'
+        repo_dir = pkg_file('forgesvn', 'tests/data/')
         c.app.repo.name = 'testsvn'
         c.app.repo.fs_path = repo_dir
         self.repo = c.app.repo
@@ -111,7 +111,7 @@ def setup_method(self, method):
     @with_tool('test', 'SVN', 'svn-tags', 'SVN with tags')
     def setup_with_tools(self):
         setup_global_objects()
-        repo_dir = str(importlib.resources.files('forgesvn')) + '/tests/data/'
+        repo_dir = pkg_file('forgesvn', 'tests/data/')
         with h.push_context('test', 'src', neighborhood='Projects'):
             c.app.repo.name = 'testsvn'
             c.app.repo.fs_path = repo_dir
@@ -148,7 +148,7 @@ def test_fork(self):
             url_path='/test/',
             tool='svn',
             status='creating')
-        repo_path = str(importlib.resources.files('forgesvn')) + '/tests/data/testsvn'
+        repo_path = pkg_file('forgesvn', 'tests/data/testsvn/')
         dirname = os.path.join(repo.fs_path, repo.name)
         if os.path.exists(dirname):
             shutil.rmtree(dirname)
@@ -198,7 +198,7 @@ def test_clone(self, post_event):
             url_path='/test/',
             tool='svn',
             status='creating')
-        repo_path = str(importlib.resources.files('forgesvn')) + '/tests/data/testsvn'
+        repo_path = pkg_file('forgesvn', 'tests/data/testsvn/')
         dirname = os.path.join(repo.fs_path, repo.name)
         if os.path.exists(dirname):
             shutil.rmtree(dirname)
@@ -408,7 +408,7 @@ def test_commit(self):
         assert entry.message
 
     def test_svn_path_exists(self):
-        repo_path = str(importlib.resources.files('forgesvn')) + '/tests/data/testsvn'
+        repo_path = pkg_file('forgesvn', 'tests/data/testsvn/')
         assert svn_path_exists("file://%s/a" % repo_path)
         assert svn_path_exists("file://%s" % repo_path)
         assert not svn_path_exists("file://%s/badpath" % repo_path)
@@ -571,7 +571,7 @@ def setup_method(self, method):
     def setup_with_tools(self):
         setup_global_objects()
         h.set_context('test', 'src', neighborhood='Projects')
-        repo_dir = str(importlib.resources.files('forgesvn')) + '/tests/data/'
+        repo_dir = pkg_file('forgesvn', 'tests/data/')
         c.app.repo.name = 'testsvn'
         c.app.repo.fs_path = repo_dir
         self.repo = c.app.repo
@@ -618,7 +618,7 @@ def test_log(self):
     def test_notification_email(self):
         setup_global_objects()
         h.set_context('test', 'src', neighborhood='Projects')
-        repo_dir = str(importlib.resources.files('forgesvn')) + '/tests/data/'
+        repo_dir = pkg_file('forgesvn', 'tests/data/')
         self.repo = SM.Repository(
             name='testsvn',
             fs_path=repo_dir,
@@ -1017,7 +1017,7 @@ def setup_method(self, method):
     def setup_with_tools(self):
         setup_global_objects()
         h.set_context('test', 'src', neighborhood='Projects')
-        repo_dir = str(importlib.resources.files('forgesvn')) + '/tests/data/'
+        repo_dir = pkg_file('forgesvn', 'tests/data/')
         c.app.repo.name = 'testsvn-rename'
         c.app.repo.fs_path = repo_dir
         self.repo = c.app.repo
@@ -1052,7 +1052,7 @@ def setup_method(self, method):
     def setup_with_tools(self):
         setup_global_objects()
         h.set_context('test', 'src', neighborhood='Projects')
-        repo_dir = str(importlib.resources.files('forgesvn')) + '/tests/data/'
+        repo_dir = pkg_file('forgesvn', 'tests/data/')
         c.app.repo.name = 'testsvn'
         c.app.repo.fs_path = repo_dir
         self.repo = c.app.repo
diff --git a/ForgeTracker/forgetracker/config/resources.py b/ForgeTracker/forgetracker/config/resources.py
index e50b89fde..d5049351a 100644
--- a/ForgeTracker/forgetracker/config/resources.py
+++ b/ForgeTracker/forgetracker/config/resources.py
@@ -15,11 +15,11 @@
 #       specific language governing permissions and limitations
 #       under the License.
 
-import importlib.resources
+from allura.lib.utils import pkg_file
 
 
 def register_ew_resources(manager):
     manager.register_directory(
-        'tracker_js', str(importlib.resources.files('forgetracker').joinpath('widgets/resources/js')))
+        'tracker_js', pkg_file('forgetracker', 'widgets/resources/js'))
     manager.register_directory(
-        'tracker_css', str(importlib.resources.files('forgetracker').joinpath('widgets/resources/css')))
+        'tracker_css', pkg_file('forgetracker', 'widgets/resources/css'))
diff --git a/ForgeUserStats/forgeuserstats/tests/test_model.py b/ForgeUserStats/forgeuserstats/tests/test_model.py
index 7296b1205..6fa47db9e 100644
--- a/ForgeUserStats/forgeuserstats/tests/test_model.py
+++ b/ForgeUserStats/forgeuserstats/tests/test_model.py
@@ -15,13 +15,13 @@
 #       specific language governing permissions and limitations
 #       under the License.
 
-import importlib.resources
 from datetime import datetime, timedelta
 
 from tg import tmpl_context as c
 from tg import config
 import mock
 
+from allura.lib.utils import pkg_file
 from alluratest.controller import setup_basic_test, setup_global_objects, setup_trove_categories
 from allura.tests import decorators as td
 from allura.model import User, Project, TroveCategory
@@ -373,7 +373,7 @@ def test_commit_stats(self):
         addr = M.EmailAddress.get(email='rcopeland@geek.net')
         addr.confirmed = True
 
-        repo_dir = str(importlib.resources.files('forgeuserstats') / 'tests/data')
+        repo_dir = pkg_file('forgeuserstats', 'tests/data')
 
         c.app.repo.fs_path = repo_dir
         c.app.repo.name = 'testgit.git'
diff --git a/ForgeUserStats/forgeuserstats/tests/test_stats.py b/ForgeUserStats/forgeuserstats/tests/test_stats.py
index 81a4d0af0..321a98337 100644
--- a/ForgeUserStats/forgeuserstats/tests/test_stats.py
+++ b/ForgeUserStats/forgeuserstats/tests/test_stats.py
@@ -15,10 +15,9 @@
 #       specific language governing permissions and limitations
 #       under the License.
 
-import importlib.resources
-
 from tg import tmpl_context as c
 
+from allura.lib.utils import pkg_file
 from alluratest.controller import TestController, setup_basic_test, setup_global_objects
 from allura.tests import decorators as td
 from allura.lib import helpers as h
@@ -200,7 +199,7 @@ def setup_method(self, method):
     def setup_with_tools(self):
         setup_global_objects()
         h.set_context('test', 'git-userstats-stats', neighborhood='Projects')
-        repo_dir = str(importlib.resources.files('forgeuserstats') /'tests/data')
+        repo_dir = pkg_file('forgeuserstats', 'tests/data')
         c.app.repo.fs_path = repo_dir
         c.app.repo.name = 'testgit.git'
         self.repo = c.app.repo