feat(esp32): project library manifest scopes ESP-IDF resolution (P2.0)

When a compile supplies a 'libraries' manifest, _resolve_library_components
merges a USER-installed library only if it's declared in that set. A sketch
therefore never picks up an unrelated library from the shared dir (another
user's install, or a same-named clash) — the manifest is the resolution scope.

- _resolve_library_components(allowed_libraries): gate user-lib merges on
  manifest membership; match by folder name OR library.properties name=,
  normalised (display name vs on-disk folder differ by separators/case).
  Core/bundled libs are never gated. None = legacy scan-all (unchanged).
- Threaded through compile() -> _compile_in_dir.
- compile.py: CompileRequest.libraries; folded into the async dedup _job_key
  so a different manifest doesn't dedup to a job built with another.

Opt-in: omitting 'libraries' preserves current behaviour exactly.
Regression: test/backend/unit/test_espidf_core_first.py::TestManifestScope
This commit is contained in:
David Montero 2026-06-06 08:46:05 +02:00
parent 6b281c9d6d
commit 47e220b72f
3 changed files with 120 additions and 1 deletions

View File

@ -55,6 +55,7 @@ def _job_key(
board_fqbn: str,
board_options: dict | None = None,
spiffs_files: list[dict] | None = None,
libraries: list[str] | None = None,
) -> str:
"""Stable content hash of (files, board, options, spiffs) used as the
deduplication key.
@ -84,6 +85,12 @@ def _job_key(
h.update(b"\0")
h.update(f["content_b64"].encode())
h.update(b"\0")
if libraries:
# Manifest changes the resolved library set → different binary, so it
# must not dedup to a job built with a different manifest.
for name in sorted(libraries):
h.update(name.encode())
h.update(b"\0")
return h.hexdigest()
@ -136,6 +143,11 @@ class CompileRequest(BaseModel):
# User-uploaded files to bake into the SPIFFS partition (#162). Empty /
# None means the SPIFFS region stays blank (current behaviour).
spiffs_files: list[SpiffsFileBody] | None = None
# P2 — project library manifest (declared library names). When provided,
# ESP-IDF library resolution is SCOPED to this set: a user-installed lib is
# merged only if it's declared here, so a sketch never picks up an unrelated
# library from the shared dir. None / omitted = legacy scan-all (unchanged).
libraries: list[str] | None = None
class CompileResponse(BaseModel):
@ -202,6 +214,7 @@ async def _run_compile(
progress_callback=progress_callback,
board_options=request.board_options,
spiffs_files=spiffs_dicts,
allowed_libraries=set(request.libraries) if request.libraries is not None else None,
)
return CompileResponse(
success=result["success"],
@ -470,7 +483,7 @@ async def compile_start(
spiffs_dicts = (
[f.model_dump() for f in request.spiffs_files] if request.spiffs_files else None
)
key = _job_key(files, request.board_fqbn, request.board_options, spiffs_dicts)
key = _job_key(files, request.board_fqbn, request.board_options, spiffs_dicts, request.libraries)
existing_id = JOB_BY_KEY.get(key)
if existing_id is not None:
existing = COMPILE_JOBS.get(existing_id)

View File

@ -703,6 +703,23 @@ class ESPIDFCompiler:
arches = {a.strip().lower() for a in arch.split(',') if a.strip()}
return '*' in arches or self._ESP32_LIB_ARCH in arches
@staticmethod
def _norm_lib_name(name: str) -> str:
"""Normalise a library name for manifest matching: lowercased, only
alphanumerics. So "Adafruit GFX Library", "Adafruit_GFX_Library" and
"adafruitgfxlibrary" all compare equal the Library Manager display
name and the on-disk folder name differ only by separators/case."""
return ''.join(ch for ch in (name or '').lower() if ch.isalnum())
def _library_in_manifest(self, lib_root: Path, allowed_norm: set[str]) -> bool:
"""True if this library is in the project's declared manifest.
Matches on the on-disk folder name OR the library.properties `name=`
(the Library Manager display name), both normalised."""
if self._norm_lib_name(lib_root.name) in allowed_norm:
return True
props_name = self._parse_library_properties(lib_root).get('name', '')
return bool(props_name) and self._norm_lib_name(props_name) in allowed_norm
def _resolve_library_components(
self,
ext_headers: list[str],
@ -710,11 +727,22 @@ class ESPIDFCompiler:
esp32_libs: Path | None,
arduino_comp_name: str,
user_libs_dir: Path,
allowed_libraries: set[str] | None = None,
) -> tuple[list[str], dict[str, str]]:
"""
BFS over ext_headers (and transitive includes) to discover all external
Arduino libraries and merge them into a single 'user_libs_all' IDF component.
`allowed_libraries` (P2 project library manifest / scope): when not
None, a USER-installed library (from arduino_libs) is merged only if its
name is in this set. This makes the project's declared manifest the
resolution SCOPE the compiler never picks up an unrelated library from
the shared dir (another user's install, or a same-named clash). When
None (no manifest supplied) the behaviour is the legacy scan-all, so
existing callers and un-migrated projects are unaffected. Core
arduino-esp32 libs and bundled esp32_libs are always allowed (they are
platform-provided, not user installs).
All library files are copied flat into one directory, so every header is
visible to every other header and source file without any cross-component
REQUIRES propagation which is unreliable in ESP-IDF 4.x for deeply
@ -734,6 +762,14 @@ class ESPIDFCompiler:
logger.info(f'[espidf] arduino_libs: {arduino_libs}')
logger.info(f'[espidf] esp32_libs: {esp32_libs}')
# P2 manifest scope: normalise the allowed set once (None = scan-all).
allowed_norm: set[str] | None = (
{self._norm_lib_name(a) for a in allowed_libraries}
if allowed_libraries is not None else None
)
if allowed_norm is not None:
logger.info(f'[espidf] library manifest scope active: {sorted(allowed_libraries)}')
comp_dir = user_libs_dir / 'user_libs_all'
comp_dir.mkdir(exist_ok=True)
@ -784,6 +820,20 @@ class ESPIDFCompiler:
)
src_root = None
# P2 manifest scope. A user-installed library is merged only if it's
# in the project's declared manifest. Anything else from the shared
# dir is out of scope — drop it (the header then falls through to the
# core check, or to the "not found" path so install-on-missing can
# offer to add it to the manifest). Core/bundled libs aren't gated.
if src_root is not None and allowed_norm is not None:
_lr = src_root.parent if src_root.name == 'src' else src_root
if not self._library_in_manifest(_lr, allowed_norm):
logger.info(
f'[espidf] <{header}> resolves to "{_lr.name}" but it is not in '
f'the project library manifest — not merging (scope)'
)
src_root = None
# Tracks the "resolved to a core lib that's already compiled into
# the arduino-esp32 component" case, so we don't fall through to
# the scary "not found — build may fail" warning below for a
@ -1574,6 +1624,7 @@ class ESPIDFCompiler:
progress_callback: Optional[ProgressCallback] = None,
board_options: dict | None = None,
spiffs_files: list[dict] | None = None,
allowed_libraries: set[str] | None = None,
) -> dict:
"""
Compile Arduino sketch using ESP-IDF.
@ -1641,6 +1692,7 @@ class ESPIDFCompiler:
return await self._compile_in_dir(
project_dir, files, idf_target, is_c3,
progress_callback, normalized_opts, spiffs_files,
allowed_libraries=allowed_libraries,
)
with tempfile.TemporaryDirectory(prefix='espidf_') as temp_dir:
@ -1650,6 +1702,7 @@ class ESPIDFCompiler:
return await self._compile_in_dir(
project_dir, files, idf_target, is_c3,
progress_callback, normalized_opts, spiffs_files,
allowed_libraries=allowed_libraries,
)
async def _compile_in_dir(
@ -1661,6 +1714,7 @@ class ESPIDFCompiler:
progress_callback: Optional[ProgressCallback] = None,
board_options: dict | None = None,
spiffs_files: list[dict] | None = None,
allowed_libraries: set[str] | None = None,
) -> dict:
"""Inner compile body: writes sketch + libs into `project_dir`,
runs cmake + ninja, merges binaries. Caller is responsible for
@ -1774,6 +1828,7 @@ class ESPIDFCompiler:
component_names, _ = self._resolve_library_components(
ext_headers, arduino_libs, esp32_libs,
arduino_comp_name, user_libs_dir,
allowed_libraries=allowed_libraries,
)
# Patch main/CMakeLists.txt — REQUIRES and INCLUDE_DIRS for user_libs_all.

View File

@ -88,5 +88,56 @@ class TestCoreFirstResolution(unittest.TestCase):
self.assertNotIn("Foo.h", hdr2comp)
class TestManifestScope(unittest.TestCase):
"""P2: when a project declares a library manifest, only declared libraries
are merged a user-installed lib outside the manifest is never picked up,
even if its header is included. None manifest = legacy scan-all (unchanged)."""
def setUp(self) -> None:
self.tmp = Path(tempfile.mkdtemp())
self.ulibs = self.tmp / "Arduino" / "libraries"
# In-manifest lib, declared by Library Manager display name; on-disk
# folder name differs by separators/case (the realistic shape).
_mk(self.ulibs / "DHT_sensor_library" / "library.properties",
"name=DHT sensor library\n")
_mk(self.ulibs / "DHT_sensor_library" / "DHT.h")
_mk(self.ulibs / "DHT_sensor_library" / "DHT.cpp")
# Out-of-manifest lib (e.g. another user's install / a clash).
_mk(self.ulibs / "RandomOtherLib" / "Foo.h")
_mk(self.ulibs / "RandomOtherLib" / "Foo.cpp")
self.c = ESPIDFCompiler()
self.c.arduino_path = ""
self.c._core_headers_cache = None
self.out = self.tmp / "project" / "user_libs"
self.out.mkdir(parents=True)
def tearDown(self) -> None:
shutil.rmtree(self.tmp, ignore_errors=True)
def _resolve(self, headers, allowed):
return self.c._resolve_library_components(
headers, arduino_libs=self.ulibs, esp32_libs=None,
arduino_comp_name="arduino-esp32", user_libs_dir=self.out,
allowed_libraries=allowed,
)
def test_only_manifest_libs_merge(self):
# Manifest declares DHT (by display name) but not Foo's lib.
_, hdr2comp = self._resolve(["DHT.h", "Foo.h"], {"DHT sensor library"})
self.assertEqual(hdr2comp.get("DHT.h"), "user_libs_all") # declared → merged
self.assertNotIn("Foo.h", hdr2comp) # undeclared → dropped
def test_none_manifest_is_scan_all(self):
# No manifest → legacy behaviour: both resolve.
_, hdr2comp = self._resolve(["DHT.h", "Foo.h"], None)
self.assertEqual(hdr2comp.get("DHT.h"), "user_libs_all")
self.assertEqual(hdr2comp.get("Foo.h"), "user_libs_all")
def test_match_by_folder_name(self):
# Manifest may also reference the on-disk folder name directly.
_, hdr2comp = self._resolve(["Foo.h"], {"RandomOtherLib"})
self.assertEqual(hdr2comp.get("Foo.h"), "user_libs_all")
if __name__ == "__main__":
unittest.main()