Skip to content

Commit 70ff3d2

Browse files
Copilotzackees
andcommitted
Fix framework extraction to support multiple archive formats (tar.xz, tar.gz, zip)
The ESP32-C2 build was failing with "not an lzma file" because framework_esp32.py hardcoded tarfile.open(archive_path, "r:xz"). Changed to auto-detect format using "r:*" for tar archives and zipfile for .zip archives. Also added format-aware magic byte validation and fixed the esp32c2.ino test sketch to use a specific GPIO pin instead of LED_BUILTIN which may not be defined. Agent-Logs-Url: https://github.com/FastLED/fbuild/sessions/565e62a0-9fa2-4594-b7b0-0ef03f2489b9 Co-authored-by: zackees <6856673+zackees@users.noreply.github.com>
1 parent b7cdcb3 commit 70ff3d2

3 files changed

Lines changed: 194 additions & 10 deletions

File tree

src/fbuild/packages/framework_esp32.py

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
Framework Download Process:
77
1. Download Arduino-ESP32 core (framework-arduinoespressif32)
88
2. Download ESP-IDF precompiled libraries (framework-arduinoespressif32-libs)
9-
3. Extract both archives (.tar.xz format)
9+
3. Extract both archives (.tar.xz, .tar.gz, or .zip format)
1010
4. Provide access to cores/, variants/, libraries/, and tools/
1111
1212
Framework Structure (after extraction):
@@ -227,12 +227,18 @@ def _download_and_extract_to_temp(name: str, url: str, desc: str) -> tuple[str,
227227
file_size = archive_path.stat().st_size
228228
if file_size < 1024:
229229
raise ValueError(f"Archive too small ({file_size} bytes), likely a failed download")
230-
# Check magic bytes: XZ files start with 0xFD377A585A00
230+
with open(archive_path, "rb") as f:
231+
magic = f.read(6)
232+
# Check magic bytes based on archive format
231233
if archive_name.endswith((".tar.xz", ".txz")):
232-
with open(archive_path, "rb") as f:
233-
magic = f.read(6)
234234
if magic != b"\xfd7zXZ\x00":
235235
raise ValueError(f"Not a valid XZ file (magic bytes: {magic[:6]!r})")
236+
elif archive_name.endswith((".tar.gz", ".tgz")):
237+
if magic[:2] != b"\x1f\x8b":
238+
raise ValueError(f"Not a valid gzip file (magic bytes: {magic[:2]!r})")
239+
elif archive_name.endswith(".zip"):
240+
if magic[:4] != b"PK\x03\x04":
241+
raise ValueError(f"Not a valid ZIP file (magic bytes: {magic[:4]!r})")
236242
except (ValueError, OSError) as e:
237243
if attempt < max_attempts - 1:
238244
if self.show_progress:
@@ -250,8 +256,14 @@ def _download_and_extract_to_temp(name: str, url: str, desc: str) -> tuple[str,
250256
try:
251257
if self.show_progress:
252258
print(f"Extracting {desc}...")
253-
with tarfile.open(archive_path, "r:xz") as tar:
254-
tar.extractall(temp_dir)
259+
if archive_name.endswith(".zip"):
260+
import zipfile
261+
262+
with zipfile.ZipFile(archive_path, "r") as zf:
263+
zf.extractall(temp_dir)
264+
else:
265+
with tarfile.open(archive_path, "r:*") as tar:
266+
tar.extractall(temp_dir)
255267
except KeyboardInterrupt:
256268
raise
257269
except Exception as e:

tests/esp32c2/esp32c2.ino

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
1+
// GPIO 8 is commonly used as LED on ESP32-C2 DevKit
2+
#define LED_PIN 8
3+
#define BAUD_RATE 115200
4+
15
void setup() {
2-
Serial.begin(115200);
3-
pinMode(LED_BUILTIN, OUTPUT);
6+
Serial.begin(BAUD_RATE);
7+
pinMode(LED_PIN, OUTPUT);
48

59
delay(1000); // wait for host to connect
610
Serial.println("TEST PASSED");
711
}
812

913
void loop() {
10-
digitalWrite(LED_BUILTIN, HIGH);
14+
digitalWrite(LED_PIN, HIGH);
1115
delay(500);
12-
digitalWrite(LED_BUILTIN, LOW);
16+
digitalWrite(LED_PIN, LOW);
1317
delay(500);
1418
}
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
"""Test that framework extraction handles multiple archive formats.
2+
3+
The bug: _download_and_extract_to_temp() in framework_esp32.py hardcoded
4+
`tarfile.open(archive_path, "r:xz")` which fails when the framework or
5+
skeleton library archives are in .tar.gz or .zip format.
6+
7+
CI error: "tarfile.ReadError: not an lzma file"
8+
"""
9+
10+
import io
11+
import tarfile
12+
import zipfile
13+
from pathlib import Path
14+
from unittest.mock import MagicMock
15+
16+
import pytest
17+
18+
from fbuild.packages.cache import Cache
19+
from fbuild.packages.framework_esp32 import FrameworkESP32
20+
21+
22+
def _create_tar_xz_archive(archive_path: Path, content_dir_name: str = "framework") -> None:
23+
"""Create a valid .tar.xz archive with a single top-level directory."""
24+
import os
25+
26+
with tarfile.open(archive_path, "w:xz") as tar:
27+
# Create a directory entry
28+
dirinfo = tarfile.TarInfo(name=content_dir_name)
29+
dirinfo.type = tarfile.DIRTYPE
30+
dirinfo.mode = 0o755
31+
tar.addfile(dirinfo)
32+
33+
# Create a file with random data (incompressible) to exceed 1024-byte validation
34+
fileinfo = tarfile.TarInfo(name=f"{content_dir_name}/package.json")
35+
data = os.urandom(4096)
36+
fileinfo.size = len(data)
37+
tar.addfile(fileinfo, io.BytesIO(data))
38+
39+
40+
def _create_tar_gz_archive(archive_path: Path, content_dir_name: str = "framework") -> None:
41+
"""Create a valid .tar.gz archive with a single top-level directory."""
42+
import os
43+
44+
with tarfile.open(archive_path, "w:gz") as tar:
45+
dirinfo = tarfile.TarInfo(name=content_dir_name)
46+
dirinfo.type = tarfile.DIRTYPE
47+
dirinfo.mode = 0o755
48+
tar.addfile(dirinfo)
49+
50+
fileinfo = tarfile.TarInfo(name=f"{content_dir_name}/package.json")
51+
data = os.urandom(4096)
52+
fileinfo.size = len(data)
53+
tar.addfile(fileinfo, io.BytesIO(data))
54+
55+
56+
def _create_zip_archive(archive_path: Path, content_dir_name: str = "framework") -> None:
57+
"""Create a valid .zip archive with a single top-level directory."""
58+
import os
59+
60+
with zipfile.ZipFile(archive_path, "w") as zf:
61+
zf.writestr(f"{content_dir_name}/package.json", os.urandom(4096).hex())
62+
63+
64+
class TestArchiveMagicByteValidation:
65+
"""Test magic byte validation for different archive formats."""
66+
67+
def test_xz_magic_bytes_valid(self, tmp_path: Path) -> None:
68+
"""Valid .tar.xz file passes magic byte check."""
69+
archive_path = tmp_path / "framework.tar.xz"
70+
_create_tar_xz_archive(archive_path)
71+
72+
with open(archive_path, "rb") as f:
73+
magic = f.read(6)
74+
assert magic == b"\xfd7zXZ\x00"
75+
76+
def test_gz_magic_bytes_valid(self, tmp_path: Path) -> None:
77+
"""Valid .tar.gz file has correct gzip magic bytes."""
78+
archive_path = tmp_path / "framework.tar.gz"
79+
_create_tar_gz_archive(archive_path)
80+
81+
with open(archive_path, "rb") as f:
82+
magic = f.read(2)
83+
assert magic == b"\x1f\x8b"
84+
85+
def test_zip_magic_bytes_valid(self, tmp_path: Path) -> None:
86+
"""Valid .zip file has correct PK magic bytes."""
87+
archive_path = tmp_path / "framework.zip"
88+
_create_zip_archive(archive_path)
89+
90+
with open(archive_path, "rb") as f:
91+
magic = f.read(4)
92+
assert magic == b"PK\x03\x04"
93+
94+
95+
class TestFrameworkExtractionFormats:
96+
"""Test that _download_and_extract_to_temp handles all supported formats."""
97+
98+
def _run_extraction(self, tmp_path: Path, archive_filename: str, create_fn) -> Path:
99+
"""Helper: create archive, mock download, run extraction, return extracted dir.
100+
101+
The _download_framework_components_parallel method uses cache_dir = install_dir.parent,
102+
so we place the install_dir inside a parent directory and pre-place the archive there.
103+
"""
104+
parent_dir = tmp_path / "parent"
105+
parent_dir.mkdir()
106+
install_dir = parent_dir / "install"
107+
install_dir.mkdir()
108+
109+
# Place the archive in parent_dir (where cache_dir points)
110+
archive_path = parent_dir / archive_filename
111+
create_fn(archive_path)
112+
113+
# Build a minimal FrameworkESP32 with a dummy URL ending in the archive filename
114+
mock_cache = MagicMock(spec=Cache)
115+
mock_cache.platforms_dir = tmp_path / "platforms"
116+
mock_cache.platforms_dir.mkdir()
117+
mock_cache.get_platform_path.return_value = install_dir
118+
119+
framework = FrameworkESP32(
120+
cache=mock_cache,
121+
framework_url=f"https://example.com/releases/download/1.0.0/{archive_filename}",
122+
libs_url="",
123+
show_progress=False,
124+
)
125+
126+
# Directly call the extraction logic by invoking the parallel download method
127+
# The archive already exists in cache_dir (install_dir.parent), so download is skipped
128+
framework._download_framework_components_parallel(install_dir)
129+
130+
return install_dir
131+
132+
def test_tar_xz_extraction(self, tmp_path: Path) -> None:
133+
"""Framework in .tar.xz format extracts correctly."""
134+
install_dir = self._run_extraction(tmp_path, "esp32-core-1.0.0.tar.xz", _create_tar_xz_archive)
135+
assert (install_dir / "package.json").exists()
136+
137+
def test_tar_gz_extraction(self, tmp_path: Path) -> None:
138+
"""Framework in .tar.gz format extracts correctly."""
139+
install_dir = self._run_extraction(tmp_path, "esp32-core-1.0.0.tar.gz", _create_tar_gz_archive)
140+
assert (install_dir / "package.json").exists()
141+
142+
def test_zip_extraction(self, tmp_path: Path) -> None:
143+
"""Framework in .zip format extracts correctly."""
144+
install_dir = self._run_extraction(tmp_path, "esp32-core-1.0.0.zip", _create_zip_archive)
145+
assert (install_dir / "package.json").exists()
146+
147+
def test_tar_gz_not_treated_as_xz(self, tmp_path: Path) -> None:
148+
"""A .tar.gz file must not fail with 'not an lzma file' error."""
149+
# This is the exact bug that was happening: .tar.gz or .zip was opened with "r:xz"
150+
cache_dir = tmp_path / "cache"
151+
cache_dir.mkdir()
152+
153+
archive_path = cache_dir / "framework.tar.gz"
154+
_create_tar_gz_archive(archive_path)
155+
156+
temp_dir = cache_dir / "_temp_extract_test"
157+
temp_dir.mkdir()
158+
159+
# Opening a .tar.gz with "r:*" (auto-detect) should work
160+
with tarfile.open(archive_path, "r:*") as tar:
161+
tar.extractall(temp_dir)
162+
163+
assert len(list(temp_dir.iterdir())) > 0
164+
165+
# Opening with "r:xz" should fail - this is what the old code did
166+
with pytest.raises(Exception):
167+
with tarfile.open(archive_path, "r:xz") as tar:
168+
tar.extractall(temp_dir)

0 commit comments

Comments
 (0)