diff --git a/include/libcamera/internal/ipa_manager.h b/include/libcamera/internal/ipa_manager.h
index a0d448cf9862..6b0ba4b5477f 100644
--- a/include/libcamera/internal/ipa_manager.h
+++ b/include/libcamera/internal/ipa_manager.h
@@ -59,10 +59,6 @@ public:
 #endif
 
 private:
-	void parseDir(const char *libDir, unsigned int maxDepth,
-		      std::vector<std::string> &files);
-	unsigned int addDir(const char *libDir, unsigned int maxDepth = 0);
-
 	IPAModule *module(PipelineHandler *pipe, uint32_t minVersion,
 			  uint32_t maxVersion);
 
diff --git a/include/libcamera/internal/meson.build b/include/libcamera/internal/meson.build
index 5c80a28c4cbe..690f5c5ec9f6 100644
--- a/include/libcamera/internal/meson.build
+++ b/include/libcamera/internal/meson.build
@@ -41,6 +41,7 @@ libcamera_internal_headers = files([
     'shared_mem_object.h',
     'source_paths.h',
     'sysfs.h',
+    'utils.h',
     'v4l2_device.h',
     'v4l2_pixelformat.h',
     'v4l2_subdevice.h',
diff --git a/include/libcamera/internal/utils.h b/include/libcamera/internal/utils.h
new file mode 100644
index 000000000000..742657bebb28
--- /dev/null
+++ b/include/libcamera/internal/utils.h
@@ -0,0 +1,26 @@
+/* SPDX-License-Identifier: LGPL-2.1-or-later */
+/*
+ * Copyright (C) 2018, Google Inc.
+ *
+ * Miscellaneous utility functions
+ */
+
+#pragma once
+
+#include <functional>
+#include <string>
+#include <vector>
+
+namespace libcamera {
+
+namespace utils {
+
+void parseDir(const char *libDir, unsigned int maxDepth,
+	      std::vector<std::string> &files);
+
+unsigned int addDir(const char *libDir, unsigned int maxDepth,
+		    std::function<int(const std::string &)> func);
+
+} /* namespace utils */
+
+} /* namespace libcamera */
diff --git a/src/libcamera/ipa_manager.cpp b/src/libcamera/ipa_manager.cpp
index 830750dcc0fb..ecad4845a077 100644
--- a/src/libcamera/ipa_manager.cpp
+++ b/src/libcamera/ipa_manager.cpp
@@ -8,9 +8,8 @@
 #include "libcamera/internal/ipa_manager.h"
 
 #include <algorithm>
-#include <dirent.h>
+#include <functional>
 #include <string.h>
-#include <sys/types.h>
 
 #include <libcamera/base/file.h>
 #include <libcamera/base/log.h>
@@ -19,6 +18,7 @@
 #include "libcamera/internal/ipa_module.h"
 #include "libcamera/internal/ipa_proxy.h"
 #include "libcamera/internal/pipeline_handler.h"
+#include "libcamera/internal/utils.h"
 
 /**
  * \file ipa_manager.h
@@ -110,6 +110,20 @@ IPAManager::IPAManager()
 
 	unsigned int ipaCount = 0;
 
+	auto &modules = modules_;
+	std::function<int(const std::string &)> addDirHandler =
+	[&modules](const std::string &file) {
+		auto ipaModule = std::make_unique<IPAModule>(file);
+		if (!ipaModule->isValid())
+			return 0;
+
+		LOG(IPAManager, Debug) << "Loaded IPA module '" << file << "'";
+
+		modules.push_back(std::move(ipaModule));
+
+		return 1;
+	};
+
 	/* User-specified paths take precedence. */
 	const char *modulePaths = utils::secure_getenv("LIBCAMERA_IPA_MODULE_PATH");
 	if (modulePaths) {
@@ -117,7 +131,7 @@ IPAManager::IPAManager()
 			if (dir.empty())
 				continue;
 
-			ipaCount += addDir(dir.c_str());
+			ipaCount += utils::addDir(dir.c_str(), 0, addDirHandler);
 		}
 
 		if (!ipaCount)
@@ -138,11 +152,11 @@ IPAManager::IPAManager()
 			<< "libcamera is not installed. Adding '"
 			<< ipaBuildPath << "' to the IPA search path";
 
-		ipaCount += addDir(ipaBuildPath.c_str(), maxDepth);
+		ipaCount += utils::addDir(ipaBuildPath.c_str(), maxDepth, addDirHandler);
 	}
 
 	/* Finally try to load IPAs from the installed system path. */
-	ipaCount += addDir(IPA_MODULE_DIR);
+	ipaCount += utils::addDir(IPA_MODULE_DIR, 0, addDirHandler);
 
 	if (!ipaCount)
 		LOG(IPAManager, Warning)
@@ -151,90 +165,6 @@ IPAManager::IPAManager()
 
 IPAManager::~IPAManager() = default;
 
-/**
- * \brief Identify shared library objects within a directory
- * \param[in] libDir The directory to search for shared objects
- * \param[in] maxDepth The maximum depth of sub-directories to parse
- * \param[out] files A vector of paths to shared object library files
- *
- * Search a directory for .so files, allowing recursion down to sub-directories
- * no further than the depth specified by \a maxDepth.
- *
- * Discovered shared objects are added to the \a files vector.
- */
-void IPAManager::parseDir(const char *libDir, unsigned int maxDepth,
-			  std::vector<std::string> &files)
-{
-	struct dirent *ent;
-	DIR *dir;
-
-	dir = opendir(libDir);
-	if (!dir)
-		return;
-
-	while ((ent = readdir(dir)) != nullptr) {
-		if (ent->d_type == DT_DIR && maxDepth) {
-			if (strcmp(ent->d_name, ".") == 0 ||
-			    strcmp(ent->d_name, "..") == 0)
-				continue;
-
-			std::string subdir = std::string(libDir) + "/" + ent->d_name;
-
-			/* Recursion is limited to maxDepth. */
-			parseDir(subdir.c_str(), maxDepth - 1, files);
-
-			continue;
-		}
-
-		int offset = strlen(ent->d_name) - 3;
-		if (offset < 0)
-			continue;
-		if (strcmp(&ent->d_name[offset], ".so"))
-			continue;
-
-		files.push_back(std::string(libDir) + "/" + ent->d_name);
-	}
-
-	closedir(dir);
-}
-
-/**
- * \brief Load IPA modules from a directory
- * \param[in] libDir The directory to search for IPA modules
- * \param[in] maxDepth The maximum depth of sub-directories to search
- *
- * This function tries to create an IPAModule instance for every shared object
- * found in \a libDir, and skips invalid IPA modules.
- *
- * Sub-directories are searched up to a depth of \a maxDepth. A \a maxDepth
- * value of 0 only searches the directory specified in \a libDir.
- *
- * \return Number of modules loaded by this call
- */
-unsigned int IPAManager::addDir(const char *libDir, unsigned int maxDepth)
-{
-	std::vector<std::string> files;
-
-	parseDir(libDir, maxDepth, files);
-
-	/* Ensure a stable ordering of modules. */
-	std::sort(files.begin(), files.end());
-
-	unsigned int count = 0;
-	for (const std::string &file : files) {
-		auto ipaModule = std::make_unique<IPAModule>(file);
-		if (!ipaModule->isValid())
-			continue;
-
-		LOG(IPAManager, Debug) << "Loaded IPA module '" << file << "'";
-
-		modules_.push_back(std::move(ipaModule));
-		count++;
-	}
-
-	return count;
-}
-
 /**
  * \brief Retrieve an IPA module that matches a given pipeline handler
  * \param[in] pipe The pipeline handler
diff --git a/src/libcamera/meson.build b/src/libcamera/meson.build
index de1eb99b28fd..6a71b2903d27 100644
--- a/src/libcamera/meson.build
+++ b/src/libcamera/meson.build
@@ -51,6 +51,7 @@ libcamera_internal_sources = files([
     'shared_mem_object.cpp',
     'source_paths.cpp',
     'sysfs.cpp',
+    'utils.cpp',
     'v4l2_device.cpp',
     'v4l2_pixelformat.cpp',
     'v4l2_subdevice.cpp',
diff --git a/src/libcamera/utils.cpp b/src/libcamera/utils.cpp
new file mode 100644
index 000000000000..ef046ac3134e
--- /dev/null
+++ b/src/libcamera/utils.cpp
@@ -0,0 +1,107 @@
+/* SPDX-License-Identifier: LGPL-2.1-or-later */
+/*
+ * Copyright (C) 2019, Google Inc.
+ *
+ * Miscellaneous utility functions (internal)
+ */
+
+#include "libcamera/internal/utils.h"
+
+#include <algorithm>
+#include <dirent.h>
+#include <functional>
+#include <string.h>
+#include <string>
+#include <sys/types.h>
+#include <vector>
+
+/**
+ * \file internal/utils.h
+ * \brief Miscellaneous utility functions (internal)
+ */
+
+namespace libcamera {
+
+namespace utils {
+
+/**
+ * \brief Identify shared library objects within a directory
+ * \param[in] libDir The directory to search for shared objects
+ * \param[in] maxDepth The maximum depth of sub-directories to parse
+ * \param[out] files A vector of paths to shared object library files
+ *
+ * Search a directory for .so files, allowing recursion down to sub-directories
+ * no further than the depth specified by \a maxDepth.
+ *
+ * Discovered shared objects are added to the \a files vector.
+ */
+void parseDir(const char *libDir, unsigned int maxDepth,
+	      std::vector<std::string> &files)
+{
+	struct dirent *ent;
+	DIR *dir;
+
+	dir = opendir(libDir);
+	if (!dir)
+		return;
+
+	while ((ent = readdir(dir)) != nullptr) {
+		if (ent->d_type == DT_DIR && maxDepth) {
+			if (strcmp(ent->d_name, ".") == 0 ||
+			    strcmp(ent->d_name, "..") == 0)
+				continue;
+
+			std::string subdir = std::string(libDir) + "/" + ent->d_name;
+
+			/* Recursion is limited to maxDepth. */
+			parseDir(subdir.c_str(), maxDepth - 1, files);
+
+			continue;
+		}
+
+		int offset = strlen(ent->d_name) - 3;
+		if (offset < 0)
+			continue;
+		if (strcmp(&ent->d_name[offset], ".so"))
+			continue;
+
+		files.push_back(std::string(libDir) + "/" + ent->d_name);
+	}
+
+	closedir(dir);
+}
+
+/**
+ * \brief Execute some function on shared object files from a directory
+ * \param[in] libDir The directory to search for shared objects
+ * \param[in] maxDepth The maximum depth of sub-directories to search
+ * \param[in] func The function to execute on every shared object
+ *
+ * This function tries to execute the given function \a func for every shared
+ * object found in \a libDir.
+ *
+ * Sub-directories are searched up to a depth of \a maxDepth. A \a maxDepth
+ * value of 0 only searches the directory specified in \a libDir.
+ *
+ * \return Number of shared objects loaded by this call
+ */
+unsigned int addDir(const char *libDir, unsigned int maxDepth,
+		    std::function<int(const std::string &)> func)
+{
+	std::vector<std::string> files;
+
+	parseDir(libDir, maxDepth, files);
+
+	/* Ensure a stable ordering of modules. */
+	std::sort(files.begin(), files.end());
+
+	unsigned int count = 0;
+	for (const std::string &file : files)
+		count += func(file);
+
+	return count;
+}
+
+} /* namespace utils */
+
+} /* namespace libcamera */
