Skip to content

Commit b2f6819

Browse files
authored
perf(setup): unzip only manual html bake inputs (#42)
1 parent 6e89a80 commit b2f6819

5 files changed

Lines changed: 140 additions & 18 deletions

File tree

docs/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ This file contains the advanced, developer-oriented details that were removed fr
1111

1212
## Where files are stored / disk usage
1313
- Zip file: `data/unity/<version>/raw/UnityDocumentation.zip`
14-
- Unzipped HTML: `data/unity/<version>/raw/UnityDocumentation/`
14+
- Unzipped HTML: `data/unity/<version>/raw/UnityDocumentation/` (currently Manual HTML pages only)
1515
- Baked artifacts: `data/unity/<version>/baked/`
1616
- Index artifacts: `data/unity/<version>/index/`
1717

src/unity_docs_mcp/setup/ensure_artifacts.py

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@
1212
from unity_docs_mcp.setup.download import download_zip
1313
from unity_docs_mcp.setup.unzip import safe_unzip
1414

15+
_BAKE_INPUT_GLOBS = [
16+
"Documentation/en/Manual/*.html",
17+
"Documentation/en/Manual/**/*.html",
18+
]
19+
1520

1621
def _manifest_matches(path: Path, signature: str) -> bool:
1722
if not path.exists():
@@ -24,17 +29,23 @@ def _manifest_matches(path: Path, signature: str) -> bool:
2429

2530

2631
def _raw_docs_ready(raw_unzipped: Path) -> bool:
27-
return (raw_unzipped / "Documentation" / "en").is_dir()
32+
return (raw_unzipped / "Documentation" / "en" / "Manual" / "index.html").is_file()
2833

2934

30-
def _recover_unzip(download_url: str, raw_zip: Path, raw_unzipped: Path, error: Exception) -> None:
35+
def _recover_unzip(
36+
download_url: str,
37+
raw_zip: Path,
38+
raw_unzipped: Path,
39+
include_globs: list[str],
40+
error: Exception,
41+
) -> None:
3142
print(f"[setup] Unzip failed ({error}). Re-downloading zip and retrying once...")
3243
if raw_unzipped.exists():
3344
shutil.rmtree(raw_unzipped, ignore_errors=True)
3445
if raw_zip.exists():
3546
raw_zip.unlink()
3647
download_zip(download_url, raw_zip, overwrite=True)
37-
safe_unzip(raw_zip, raw_unzipped)
48+
safe_unzip(raw_zip, raw_unzipped, include_globs=include_globs)
3849

3950

4051
def ensure(config: Config) -> None:
@@ -54,9 +65,15 @@ def ensure(config: Config) -> None:
5465
if paths.raw_unzipped.exists():
5566
shutil.rmtree(paths.raw_unzipped, ignore_errors=True)
5667
try:
57-
safe_unzip(paths.raw_zip, paths.raw_unzipped)
68+
safe_unzip(paths.raw_zip, paths.raw_unzipped, include_globs=_BAKE_INPUT_GLOBS)
5869
except Exception as unzip_error:
59-
_recover_unzip(config.download_url, paths.raw_zip, paths.raw_unzipped, unzip_error)
70+
_recover_unzip(
71+
config.download_url,
72+
paths.raw_zip,
73+
paths.raw_unzipped,
74+
include_globs=_BAKE_INPUT_GLOBS,
75+
error=unzip_error,
76+
)
6077

6178
print("==> Baking docs (HTML -> cleaned text + chunks)...")
6279
bake(config)

src/unity_docs_mcp/setup/unzip.py

Lines changed: 50 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,73 @@
11
from __future__ import annotations
22

3+
import fnmatch
34
import zipfile
45
from pathlib import Path
6+
from typing import Iterable, Sequence
57

68
from tqdm import tqdm
79

810

9-
def safe_unzip(zip_path: Path, target_dir: Path) -> Path:
11+
def _normalize_patterns(patterns: Sequence[str] | None) -> list[str]:
12+
if not patterns:
13+
return []
14+
normalized: list[str] = []
15+
for pattern in patterns:
16+
value = str(pattern).strip().replace("\\", "/").lstrip("/")
17+
if value:
18+
normalized.append(value)
19+
return normalized
20+
21+
22+
def _glob_variants(pattern: str) -> Iterable[str]:
23+
# pathlib/fnmatch treat `/**/` as "one or more" directories; include a
24+
# zero-directory variant so `Manual/**/*.html` also matches `Manual/index.html`.
25+
yield pattern
26+
if "/**/" in pattern:
27+
yield pattern.replace("/**/", "/")
28+
29+
30+
def _member_selected(member_name: str, include_globs: Sequence[str] | None) -> bool:
31+
if not include_globs:
32+
return True
33+
normalized = member_name.replace("\\", "/").lstrip("/")
34+
if normalized.endswith("/"):
35+
return False
36+
for pattern in include_globs:
37+
for variant in _glob_variants(pattern):
38+
if fnmatch.fnmatch(normalized, variant):
39+
return True
40+
return False
41+
42+
43+
def safe_unzip(
44+
zip_path: Path,
45+
target_dir: Path,
46+
include_globs: Sequence[str] | None = None,
47+
) -> Path:
1048
"""
1149
Safely extract zip contents, preventing zip-slip by validating paths.
1250
"""
1351
if target_dir.exists() and any(target_dir.iterdir()):
1452
return target_dir
1553

16-
print(f"==> Unzipping {zip_path}...")
54+
selected_globs = _normalize_patterns(include_globs)
1755
target_dir.mkdir(parents=True, exist_ok=True)
1856
with zipfile.ZipFile(zip_path, "r") as zf:
1957
members = zf.infolist()
20-
for member in members:
58+
selected_members = [m for m in members if _member_selected(m.filename, selected_globs)]
59+
print(
60+
f"==> Unzipping {zip_path} "
61+
f"({len(selected_members)}/{len(members)} files selected)..."
62+
)
63+
target_root = target_dir.resolve()
64+
for member in selected_members:
2165
extracted_path = target_dir / member.filename
2266
resolved_path = extracted_path.resolve()
23-
if target_dir.resolve() not in resolved_path.parents and target_dir.resolve() != resolved_path:
67+
if target_root not in resolved_path.parents and target_root != resolved_path:
2468
raise ValueError(f"Unsafe path detected in zip: {member.filename}")
25-
bar = tqdm(total=len(members), unit="file", unit_scale=False)
26-
for member in members:
69+
bar = tqdm(total=len(selected_members), unit="file", unit_scale=False)
70+
for member in selected_members:
2771
zf.extract(member, target_dir)
2872
bar.update(1)
2973
bar.close()

tests/test_setup_recovery.py

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,16 @@ def fake_download(url: str, destination: Path, overwrite: bool = False, progress
3232
destination.write_bytes(b"fresh")
3333
return destination
3434

35-
def fake_unzip(zip_path: Path, target_dir: Path) -> Path:
35+
def fake_unzip(zip_path: Path, target_dir: Path, include_globs=None) -> Path:
3636
calls["unzip"] += 1
37+
assert include_globs == ensure_artifacts._BAKE_INPUT_GLOBS
3738
if calls["unzip"] == 1:
3839
target_dir.mkdir(parents=True, exist_ok=True)
3940
(target_dir / "partial.txt").write_text("partial", encoding="utf-8")
4041
raise RuntimeError("bad zip payload")
41-
(target_dir / "Documentation" / "en").mkdir(parents=True, exist_ok=True)
42+
manual_dir = target_dir / "Documentation" / "en" / "Manual"
43+
manual_dir.mkdir(parents=True, exist_ok=True)
44+
(manual_dir / "index.html").write_text("<html></html>", encoding="utf-8")
4245
return target_dir
4346

4447
monkeypatch.setattr(ensure_artifacts, "download_zip", fake_download)
@@ -50,7 +53,7 @@ def fake_unzip(zip_path: Path, target_dir: Path) -> Path:
5053

5154
assert calls["unzip"] == 2
5255
assert calls["download_overwrite"] == [True]
53-
assert (raw_unzipped / "Documentation" / "en").is_dir()
56+
assert (raw_unzipped / "Documentation" / "en" / "Manual" / "index.html").is_file()
5457
assert not (raw_unzipped / "partial.txt").exists()
5558

5659

@@ -65,10 +68,13 @@ def test_nonready_unzip_dir_is_cleared_before_unzip(monkeypatch, tmp_path: Path)
6568

6669
calls = {"unzip": 0}
6770

68-
def fake_unzip(zip_path: Path, target_dir: Path) -> Path:
71+
def fake_unzip(zip_path: Path, target_dir: Path, include_globs=None) -> Path:
6972
calls["unzip"] += 1
73+
assert include_globs == ensure_artifacts._BAKE_INPUT_GLOBS
7074
assert not (target_dir / "stale.tmp").exists()
71-
(target_dir / "Documentation" / "en").mkdir(parents=True, exist_ok=True)
75+
manual_dir = target_dir / "Documentation" / "en" / "Manual"
76+
manual_dir.mkdir(parents=True, exist_ok=True)
77+
(manual_dir / "index.html").write_text("<html></html>", encoding="utf-8")
7278
return target_dir
7379

7480
monkeypatch.setattr(ensure_artifacts, "safe_unzip", fake_unzip)
@@ -79,4 +85,4 @@ def fake_unzip(zip_path: Path, target_dir: Path) -> Path:
7985
ensure_artifacts.ensure(cfg)
8086

8187
assert calls["unzip"] == 1
82-
assert (raw_unzipped / "Documentation" / "en").is_dir()
88+
assert (raw_unzipped / "Documentation" / "en" / "Manual" / "index.html").is_file()

tests/test_unzip_filtering.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import zipfile
2+
from pathlib import Path
3+
4+
import pytest
5+
6+
from unity_docs_mcp.setup.unzip import safe_unzip
7+
8+
9+
def _write_zip(path: Path, members: dict[str, str]) -> None:
10+
path.parent.mkdir(parents=True, exist_ok=True)
11+
with zipfile.ZipFile(path, "w") as zf:
12+
for name, body in members.items():
13+
zf.writestr(name, body)
14+
15+
16+
def test_safe_unzip_extracts_only_selected_manual_html(tmp_path: Path):
17+
zip_path = tmp_path / "UnityDocumentation.zip"
18+
target_dir = tmp_path / "unzipped"
19+
_write_zip(
20+
zip_path,
21+
{
22+
"Documentation/en/Manual/index.html": "<html>manual index</html>",
23+
"Documentation/en/Manual/Sub/page.html": "<html>manual page</html>",
24+
"Documentation/en/Manual/readme.txt": "not html",
25+
"Documentation/en/ScriptReference/index.html": "<html>script ref</html>",
26+
},
27+
)
28+
29+
safe_unzip(
30+
zip_path,
31+
target_dir,
32+
include_globs=[
33+
"Documentation/en/Manual/*.html",
34+
"Documentation/en/Manual/**/*.html",
35+
],
36+
)
37+
38+
assert (target_dir / "Documentation" / "en" / "Manual" / "index.html").exists()
39+
assert (target_dir / "Documentation" / "en" / "Manual" / "Sub" / "page.html").exists()
40+
assert not (target_dir / "Documentation" / "en" / "Manual" / "readme.txt").exists()
41+
assert not (target_dir / "Documentation" / "en" / "ScriptReference" / "index.html").exists()
42+
43+
44+
def test_safe_unzip_keeps_zip_slip_protection(tmp_path: Path):
45+
zip_path = tmp_path / "UnityDocumentation.zip"
46+
target_dir = tmp_path / "unzipped"
47+
_write_zip(
48+
zip_path,
49+
{
50+
"../escape.txt": "bad",
51+
},
52+
)
53+
54+
with pytest.raises(ValueError, match="Unsafe path detected"):
55+
safe_unzip(zip_path, target_dir)

0 commit comments

Comments
 (0)