vendorcheck 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,110 @@
1
+ Metadata-Version: 2.4
2
+ Name: vendorcheck
3
+ Version: 0.2.0
4
+ Summary: Check whether embedded/vendored Python packages are identical to the official PyPI release.
5
+ License: MIT
6
+ Project-URL: Homepage, https://github.com/Ferdi-krbk/vendorcheck
7
+ Keywords: supply-chain,security,vendoring,pypi,audit
8
+ Classifier: License :: OSI Approved :: MIT License
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Topic :: Security
11
+ Requires-Python: >=3.9
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Dynamic: license-file
15
+
16
+ # vendorcheck
17
+
18
+ [![CI](https://github.com/Ferdi-krbk/vendorcheck/actions/workflows/ci.yml/badge.svg)](https://github.com/Ferdi-krbk/vendorcheck/actions/workflows/ci.yml)
19
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
20
+
21
+ **Is the Python library bundled inside your app, add-on or extension byte-for-byte the official PyPI release?**
22
+
23
+ Point it at a folder or an archive (`.zip`, `.xpi`, `.vsix`, `.fda`, `.whl`, any app bundle that is a zip). It finds the embedded Python packages, downloads the official release from PyPI, verifies the download against PyPI's published SHA-256, and compares **file by file**.
24
+
25
+ ```console
26
+ $ vendorcheck lib/ -v # idna with one file edited and one file added
27
+ idna 3.7: 8 files identical, 1 modified, 1 extra.
28
+ [modified] idna/core.py
29
+ [extra] idna/evil.py
30
+ ```
31
+
32
+ Real run on a clean `pip install --target` of yt-dlp:
33
+
34
+ ```console
35
+ $ vendorcheck lib/
36
+ yt_dlp 2026.8.19: 1049 files identical, 0 modified, 0 extra.
37
+ ```
38
+
39
+ ## Why
40
+
41
+ Software supply-chain attacks often hide in *copies* of popular libraries: a vendored package that was quietly patched, or has an extra file dropped in. Lockfiles, `pip-audit` and signature checks look at what PyPI serves, not at the copy shipped inside your artifact. `vendorcheck` closes that gap.
42
+
43
+ ## Install
44
+
45
+ ```bash
46
+ pip install git+https://github.com/Ferdi-krbk/vendorcheck
47
+ # or just download vendorcheck.py - it is a single file, stdlib only, Python 3.9+
48
+ ```
49
+
50
+ ## Usage
51
+
52
+ ```bash
53
+ vendorcheck PATH [-v | -vv] [--json] [--lang en|tr] [--only NAME ...] [--ignore GLOB ...]
54
+ [--no-nested] [--cache DIR] [--allow-empty]
55
+ ```
56
+
57
+ | Option | Meaning |
58
+ |---|---|
59
+ | `-v` | list every modified / extra / missing file |
60
+ | `-vv` | additionally print a unified diff (official → embedded) for modified text files |
61
+ | `--json` | machine-readable output (includes diffs with `-vv`) |
62
+ | `--only NAME ...` | check just these packages |
63
+ | `--ignore GLOB` | skip files matching the glob (relative to the package dir, e.g. `yt_dlp/version.py`); repeatable. For intentional local patches |
64
+ | `--no-nested` | don't open archives found inside the input |
65
+ | `--cache DIR` | reuse downloaded official releases |
66
+ | `--allow-empty` | exit 0 if no embedded package is found (default: exit 2, so CI can't pass by checking nothing) |
67
+ | `--lang tr` | Turkish output |
68
+
69
+ Archives nested inside the input (`.zip .whl .egg .xpi .vsix .fda .pyz`, up to 2 levels, 2 GiB guard) are opened automatically and reported as `pkg 1.0 [inner.xpi!/]`.
70
+
71
+ **Exit codes:** `0` identical · `1` differences found · `2` error / nothing checked.
72
+
73
+ In GitHub Actions the result is also written to the job's **step summary** as a table.
74
+
75
+ ## GitHub Action
76
+
77
+ ```yaml
78
+ - uses: actions/checkout@v4
79
+ - uses: Ferdi-krbk/vendorcheck@v1
80
+ with:
81
+ path: build/addon # folder or .xpi/.vsix/.zip
82
+ # only: "yt-dlp requests"
83
+ ```
84
+
85
+ The job fails when embedded code differs from the official release. The repo's own [CI](.github/workflows/ci.yml) proves it: a clean vendored tree must pass and a tampered one must fail.
86
+
87
+ ## How it works
88
+
89
+ 1. **Detect** packages through `*.dist-info/METADATA`, `*.egg-info/PKG-INFO`, or `<pkg>/version.py` (`__version__ = "..."`).
90
+ 2. **Fetch** `https://pypi.org/pypi/<name>/<version>/json`, prefer a pure `py3-none-any` wheel, fall back to the sdist, and verify its SHA-256 against PyPI's digest.
91
+ 3. **Compare** each file's SHA-256. Line-ending-only differences (CRLF vs LF) are tolerated; `__pycache__` and `.pyc` are ignored.
92
+ 4. **Report** identical / modified / extra (and missing) files.
93
+
94
+ Archives are extracted with zip-slip protection; nothing from the audited artifact is ever executed.
95
+
96
+ ## Limits (v1)
97
+
98
+ - Only packages with version metadata (see above). Guessing name/version for bare copies is out of scope.
99
+ - If PyPI only has platform wheels or an sdist, generated or compiled files show up as "extra".
100
+ - Archives nested deeper than 2 levels are not opened.
101
+ - It proves your copy equals what PyPI serves, not that the PyPI release itself is trustworthy. Pair it with `pip-audit` and provenance tools like `trustcheck`.
102
+
103
+ ## Related
104
+
105
+ - [trustcheck](https://pypi.org/project/trustcheck/) verifies provenance/signatures of PyPI releases, not embedded code.
106
+ - [vendoring](https://pypi.org/project/vendoring/) automates *creating* vendored trees; `vendorcheck` *audits* them.
107
+
108
+ ## License
109
+
110
+ MIT
@@ -0,0 +1,7 @@
1
+ vendorcheck.py,sha256=qf9N3oMQKLwHqxU_v9JqtSKX5g_Xure1HMEpNCxKJ5g,20016
2
+ vendorcheck-0.2.0.dist-info/licenses/LICENSE,sha256=oOiPsdl8Swgn4z0xK_XVGe8K6eHGD0VCj-BwfjAfydQ,1067
3
+ vendorcheck-0.2.0.dist-info/METADATA,sha256=tQVhFkHooLWKFdMhAIrS0tYUvtCXNZcr0v3ztUys2oQ,5010
4
+ vendorcheck-0.2.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
5
+ vendorcheck-0.2.0.dist-info/entry_points.txt,sha256=Ci7A9Xj3oX182wrtxn_oOR9cYELs7l1PG4ZQG_BGegI,49
6
+ vendorcheck-0.2.0.dist-info/top_level.txt,sha256=9BE3v_PYIaXAY1qP0y_MPaee9zU71j83bHfOgm4Jcwc,12
7
+ vendorcheck-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ vendorcheck = vendorcheck:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ferdi-krbk
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ vendorcheck
vendorcheck.py ADDED
@@ -0,0 +1,494 @@
1
+ #!/usr/bin/env python3
2
+ """vendorcheck: is the embedded Python library identical to the official PyPI release?
3
+
4
+ Usage:
5
+ python vendorcheck.py PATH [--json] [-v] [--lang en|tr] [--cache DIR]
6
+ [--only NAME ...] [--allow-empty]
7
+
8
+ PATH is a directory or a zip-like archive (.zip, .fda, .xpi, .vsix, .whl, ...).
9
+ v1 scope: only packages whose name+version can be read from metadata
10
+ (*.dist-info/METADATA, *.egg-info/PKG-INFO) or from <pkg>/version.py.
11
+
12
+ Exit code: 0 = everything identical, 1 = differences found,
13
+ 2 = error or nothing to check.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import difflib
19
+ import fnmatch
20
+ import hashlib
21
+ import io
22
+ import json
23
+ import os
24
+ import re
25
+ import shutil
26
+ import sys
27
+ import tarfile
28
+ import tempfile
29
+ import time
30
+ import urllib.error
31
+ import urllib.request
32
+ import zipfile
33
+ from dataclasses import dataclass, field
34
+ from pathlib import Path
35
+
36
+ __version__ = "0.2.0"
37
+ IGNORED_DIRS = {"__pycache__"}
38
+ IGNORED_SUFFIXES = (".pyc", ".pyo")
39
+ UA = {"User-Agent": f"vendorcheck/{__version__} (+https://github.com/Ferdi-krbk/vendorcheck)"}
40
+
41
+
42
+ # ----------------------------------------------------------------- helpers
43
+ def norm_name(name: str) -> str:
44
+ return re.sub(r"[-_.]+", "-", name).lower()
45
+
46
+
47
+ def sha256_bytes(data: bytes) -> str:
48
+ return hashlib.sha256(data).hexdigest()
49
+
50
+
51
+ def sha256_file(path: Path) -> str:
52
+ h = hashlib.sha256()
53
+ with open(path, "rb") as f:
54
+ for chunk in iter(lambda: f.read(1 << 20), b""):
55
+ h.update(chunk)
56
+ return h.hexdigest()
57
+
58
+
59
+ def safe_extract_zip(zf: zipfile.ZipFile, dest: Path) -> None:
60
+ dest = dest.resolve()
61
+ for info in zf.infolist():
62
+ target = (dest / info.filename).resolve()
63
+ if dest != target and dest not in target.parents:
64
+ raise RuntimeError(f"unsafe path in archive: {info.filename}")
65
+ zf.extractall(dest)
66
+
67
+
68
+ def safe_extract_tar(tf: tarfile.TarFile, dest: Path) -> None:
69
+ dest = dest.resolve()
70
+ members = []
71
+ for m in tf.getmembers():
72
+ target = (dest / m.name).resolve()
73
+ if dest != target and dest not in target.parents:
74
+ raise RuntimeError(f"unsafe path in archive: {m.name}")
75
+ if m.isfile() or m.isdir(): # skip symlinks/devices
76
+ members.append(m)
77
+ tf.extractall(dest, members=members)
78
+
79
+
80
+ def is_ignored(rel: Path) -> bool:
81
+ return any(p in IGNORED_DIRS for p in rel.parts) or rel.name.endswith(IGNORED_SUFFIXES)
82
+
83
+
84
+ def read_text(p: Path) -> str:
85
+ return p.read_text(encoding="utf-8", errors="replace")
86
+
87
+
88
+ # --------------------------------------------------------------- discovery
89
+ @dataclass
90
+ class Embedded:
91
+ name: str # distribution name (best guess for version.py case)
92
+ version: str
93
+ container: Path # directory that holds the package dirs
94
+ tops: list[str] # top-level package dir names / module names
95
+ source: str # "dist-info" | "egg-info" | "version.py"
96
+
97
+
98
+ def parse_metadata(text: str) -> tuple[str | None, str | None]:
99
+ name = version = None
100
+ for line in text.splitlines():
101
+ if not line.strip():
102
+ break # end of headers
103
+ if line.startswith("Name:") and name is None:
104
+ name = line.split(":", 1)[1].strip()
105
+ elif line.startswith("Version:") and version is None:
106
+ version = line.split(":", 1)[1].strip()
107
+ return name, version
108
+
109
+
110
+ def tops_from_info(info_dir: Path, fallback: str) -> list[str]:
111
+ container = info_dir.parent
112
+ tl = info_dir / "top_level.txt"
113
+ cands: list[str] = []
114
+ if tl.is_file():
115
+ cands = [l.strip() for l in read_text(tl).splitlines() if l.strip()]
116
+ elif (info_dir / "RECORD").is_file():
117
+ for line in read_text(info_dir / "RECORD").splitlines():
118
+ first = line.split(",", 1)[0].replace("\\", "/").split("/")[0]
119
+ if first and first not in ("..", ) and not first.endswith((".dist-info", ".data")):
120
+ if first.endswith(".py"):
121
+ first = first[:-3]
122
+ if first not in cands:
123
+ cands.append(first)
124
+ if not cands:
125
+ cands = [fallback.replace("-", "_")]
126
+ # keep only those that exist here
127
+ return [c for c in cands if (container / c).is_dir() or (container / f"{c}.py").is_file()]
128
+
129
+
130
+ def discover(root: Path) -> list[Embedded]:
131
+ found: list[Embedded] = []
132
+ covered: set[Path] = set()
133
+ for dirpath, dirnames, _ in os.walk(root):
134
+ d = Path(dirpath)
135
+ for sub in list(dirnames):
136
+ if sub.endswith((".dist-info", ".egg-info")):
137
+ info = d / sub
138
+ meta = info / ("METADATA" if sub.endswith(".dist-info") else "PKG-INFO")
139
+ if not meta.is_file():
140
+ continue
141
+ name, version = parse_metadata(read_text(meta))
142
+ if not name or not version:
143
+ continue
144
+ tops = tops_from_info(info, name)
145
+ if not tops:
146
+ continue
147
+ found.append(Embedded(name, version, d, tops,
148
+ "dist-info" if sub.endswith(".dist-info") else "egg-info"))
149
+ for t in tops:
150
+ covered.add((d / t).resolve())
151
+ # version.py fallback: top-level packages only
152
+ for dirpath, dirnames, filenames in os.walk(root):
153
+ d = Path(dirpath)
154
+ if "version.py" in filenames and "__init__.py" in filenames:
155
+ if (d.parent / "__init__.py").exists() or d.resolve() in covered:
156
+ continue
157
+ if any(c in covered for c in [d.resolve(), *d.resolve().parents]):
158
+ continue
159
+ m = re.search(r"""^__version__\s*=\s*['"]([^'"]+)['"]""",
160
+ read_text(d / "version.py"), re.M)
161
+ if m:
162
+ found.append(Embedded(d.name.replace("_", "-"), m.group(1), d.parent,
163
+ [d.name], "version.py"))
164
+ return found
165
+
166
+
167
+ # -------------------------------------------------------------------- PyPI
168
+ def http_get(url: str, attempts: int = 3) -> bytes:
169
+ """GET with retry + exponential backoff on 429/5xx and connection errors."""
170
+ for i in range(attempts):
171
+ try:
172
+ req = urllib.request.Request(url, headers=UA)
173
+ with urllib.request.urlopen(req, timeout=60) as r:
174
+ return r.read()
175
+ except urllib.error.HTTPError as ex:
176
+ if ex.code not in (429, 500, 502, 503, 504) or i == attempts - 1:
177
+ raise
178
+ except (urllib.error.URLError, TimeoutError, ConnectionError):
179
+ if i == attempts - 1:
180
+ raise
181
+ time.sleep(2 ** i)
182
+ raise RuntimeError("unreachable")
183
+
184
+
185
+ def pick_artifact(urls: list[dict]) -> dict | None:
186
+ wheels = [u for u in urls if u["packagetype"] == "bdist_wheel"
187
+ and u["filename"].endswith("-none-any.whl")]
188
+ if wheels:
189
+ return wheels[0]
190
+ sd = [u for u in urls if u["packagetype"] == "sdist"]
191
+ return sd[0] if sd else None
192
+
193
+
194
+ def _descend_sdist(out: Path, filename: str) -> Path:
195
+ """An sdist unpacks into a single top directory; wheels do not."""
196
+ if filename.endswith((".whl", ".zip")):
197
+ return out
198
+ subs = [p for p in out.iterdir() if p.is_dir()]
199
+ return subs[0] if len(subs) == 1 else out
200
+
201
+
202
+ def fetch_official(name: str, version: str, cache: Path) -> tuple[Path, str]:
203
+ """Download + hash-verify official artifact, return (extracted dir, artifact filename)."""
204
+ meta = json.loads(http_get(f"https://pypi.org/pypi/{norm_name(name)}/{version}/json"))
205
+ art = pick_artifact(meta["urls"])
206
+ if not art:
207
+ raise RuntimeError("no pure wheel or sdist on PyPI")
208
+ out = cache / f"{norm_name(name)}-{version}"
209
+ if (out / ".ok").exists():
210
+ return _descend_sdist(out, art["filename"]), art["filename"]
211
+ if out.exists():
212
+ shutil.rmtree(out)
213
+ out.mkdir(parents=True)
214
+ data = http_get(art["url"])
215
+ if sha256_bytes(data) != art["digests"]["sha256"]:
216
+ raise RuntimeError("downloaded artifact sha256 does not match PyPI-published digest")
217
+ if art["filename"].endswith((".whl", ".zip")):
218
+ with zipfile.ZipFile(io.BytesIO(data)) as zf:
219
+ safe_extract_zip(zf, out)
220
+ else:
221
+ with tarfile.open(fileobj=io.BytesIO(data)) as tf:
222
+ safe_extract_tar(tf, out)
223
+ (out / ".ok").touch()
224
+ return _descend_sdist(out, art["filename"]), art["filename"]
225
+
226
+
227
+ # ----------------------------------------------------------------- compare
228
+ def collect(container: Path, tops: list[str], ignore: tuple[str, ...] = ()) -> dict[str, Path]:
229
+ files: dict[str, Path] = {}
230
+ for t in tops:
231
+ for root in (container / t, container / f"{t}.py"):
232
+ if root.is_file():
233
+ candidates = [root]
234
+ elif root.is_dir():
235
+ candidates = [p for p in root.rglob("*") if p.is_file()]
236
+ else:
237
+ continue
238
+ for p in candidates:
239
+ rel = p.relative_to(container)
240
+ if is_ignored(rel) or matches_ignore(rel.as_posix(), ignore):
241
+ continue
242
+ files[rel.as_posix()] = p
243
+ return files
244
+
245
+
246
+ def matches_ignore(rel: str, patterns: tuple[str, ...]) -> bool:
247
+ """Glob match on the posix path relative to the package container (e.g. 'foo/*.orig')."""
248
+ return any(fnmatch.fnmatchcase(rel, p) for p in patterns)
249
+
250
+
251
+ def same_content(a: Path, b: Path) -> bool:
252
+ if sha256_file(a) == sha256_file(b):
253
+ return True
254
+ # tolerate CRLF/LF differences only (git autocrlf etc.)
255
+ try:
256
+ da, db = a.read_bytes(), b.read_bytes()
257
+ except OSError:
258
+ return False
259
+ if b"\0" in da[:8192] or b"\0" in db[:8192]:
260
+ return False
261
+ return da.replace(b"\r\n", b"\n") == db.replace(b"\r\n", b"\n")
262
+
263
+
264
+ def make_diff(official: Path, embedded: Path, rel: str, limit: int = 200) -> str:
265
+ """Unified diff official -> embedded; empty string for binary files."""
266
+ try:
267
+ a, b = official.read_bytes(), embedded.read_bytes()
268
+ except OSError:
269
+ return ""
270
+ if b"\0" in a[:8192] or b"\0" in b[:8192]:
271
+ return ""
272
+ lines = list(difflib.unified_diff(
273
+ a.decode("utf-8", "replace").splitlines(), b.decode("utf-8", "replace").splitlines(),
274
+ f"official/{rel}", f"embedded/{rel}", lineterm=""))
275
+ if len(lines) > limit:
276
+ lines = lines[:limit] + [f"... ({len(lines) - limit} more diff lines)"]
277
+ return "\n".join(lines)
278
+
279
+
280
+ @dataclass
281
+ class Result:
282
+ name: str
283
+ version: str
284
+ source: str
285
+ location: str
286
+ status: str = "ok" # ok | error
287
+ error: str = ""
288
+ artifact: str = ""
289
+ same: int = 0
290
+ modified: list[str] = field(default_factory=list)
291
+ extra: list[str] = field(default_factory=list)
292
+ missing: list[str] = field(default_factory=list)
293
+ diffs: dict[str, str] = field(default_factory=dict)
294
+
295
+ @property
296
+ def clean(self) -> bool:
297
+ return self.status == "ok" and not (self.modified or self.extra or self.missing)
298
+
299
+
300
+ def find_official_container(off: Path, tops: list[str]) -> Path:
301
+ for cand in (off, off / "src", off / "lib"):
302
+ if any((cand / t).is_dir() or (cand / f"{t}.py").is_file() for t in tops):
303
+ return cand
304
+ return off
305
+
306
+
307
+ def check_one(e: Embedded, cache: Path, root: Path, ignore: tuple[str, ...] = (),
308
+ want_diff: bool = False, prefix: str = "") -> Result:
309
+ loc = (e.container.relative_to(root).as_posix() or ".")
310
+ res = Result(e.name, e.version, e.source, f"{prefix}{loc}" if prefix else loc)
311
+ try:
312
+ off, res.artifact = fetch_official(e.name, e.version, cache)
313
+ except urllib.error.HTTPError as ex:
314
+ res.status, res.error = "error", f"PyPI HTTP {ex.code} (not on PyPI under this name/version?)"
315
+ return res
316
+ except Exception as ex: # noqa: BLE001
317
+ res.status, res.error = "error", str(ex)
318
+ return res
319
+ off_container = find_official_container(off, e.tops)
320
+ mine = collect(e.container, e.tops, ignore)
321
+ theirs = collect(off_container, e.tops, ignore)
322
+ for rel, p in sorted(mine.items()):
323
+ if rel not in theirs:
324
+ res.extra.append(rel)
325
+ elif same_content(p, theirs[rel]):
326
+ res.same += 1
327
+ else:
328
+ res.modified.append(rel)
329
+ if want_diff:
330
+ d = make_diff(theirs[rel], p, rel)
331
+ if d:
332
+ res.diffs[rel] = d
333
+ res.missing = sorted(set(theirs) - set(mine))
334
+ return res
335
+
336
+
337
+
338
+ NESTED_EXTS = (".zip", ".whl", ".egg", ".xpi", ".vsix", ".fda", ".pyz")
339
+ MAX_NESTED_BYTES = 2 * 1024**3 # zip-bomb guard for nested archive expansion
340
+
341
+
342
+ def expand_nested(root: Path, tmp: Path, max_depth: int = 2) -> list[tuple[Path, str]]:
343
+ """Return [(dir, label_prefix)] for root plus any zip-like archives found inside it."""
344
+ roots: list[tuple[Path, str]] = [(root, "")]
345
+ frontier = [(root, "")]
346
+ total, counter = 0, 0
347
+ for _ in range(max_depth):
348
+ nxt: list[tuple[Path, str]] = []
349
+ for base, prefix in frontier:
350
+ for p in sorted(base.rglob("*")):
351
+ if not (p.is_file() and p.suffix.lower() in NESTED_EXTS and zipfile.is_zipfile(p)):
352
+ continue
353
+ with zipfile.ZipFile(p) as zf:
354
+ total += sum(i.file_size for i in zf.infolist())
355
+ if total > MAX_NESTED_BYTES:
356
+ raise RuntimeError("nested archives too large (zip-bomb guard)")
357
+ counter += 1
358
+ dest = tmp / "nested" / str(counter)
359
+ safe_extract_zip(zf, dest)
360
+ label = f"{prefix}{p.relative_to(base).as_posix()}!/"
361
+ roots.append((dest, label))
362
+ nxt.append((dest, label))
363
+ frontier = nxt
364
+ return roots
365
+
366
+
367
+ def write_step_summary(results: list[Result]) -> None:
368
+ """Append a markdown table to $GITHUB_STEP_SUMMARY when running in GitHub Actions."""
369
+ path = os.environ.get("GITHUB_STEP_SUMMARY")
370
+ if not path:
371
+ return
372
+ rows = ["### vendorcheck", "", "| Package | Version | Identical | Modified | Extra | Missing | Status |",
373
+ "|---|---|---:|---:|---:|---:|---|"]
374
+ for r in results:
375
+ status = "error" if r.status == "error" else ("clean" if r.clean else "DIFFERS")
376
+ rows.append(f"| `{r.name}` | {r.version} | {r.same} | {len(r.modified)} | {len(r.extra)} "
377
+ f"| {len(r.missing)} | {status} |")
378
+ for r in results:
379
+ for title, items in (("modified", r.modified), ("extra", r.extra), ("missing", r.missing)):
380
+ if items:
381
+ rows += ["", f"<details><summary>{r.name} {r.version}: {title} ({len(items)})</summary>", ""]
382
+ rows += [f"- `{i}`" for i in items[:100]] + ["", "</details>"]
383
+ with open(path, "a", encoding="utf-8") as f:
384
+ f.write("\n".join(rows) + "\n")
385
+
386
+
387
+ STRINGS = {
388
+ "en": {"err": "COULD NOT CHECK", "same": "files identical", "same1": "file identical",
389
+ "mod": "modified", "extra": "extra", "miss": "missing",
390
+ "tags": ("modified", "extra", "missing"),
391
+ "none": "No embedded packages found (v1 needs .dist-info / .egg-info / version.py)."},
392
+ "tr": {"err": "KONTROL EDİLEMEDİ", "same": "dosya aynı", "same1": "dosya aynı",
393
+ "mod": "değişmiş", "extra": "fazladan", "miss": "eksik",
394
+ "tags": ("değişmiş", "fazladan", "eksik"),
395
+ "none": "Gömülü paket bulunamadı (v1: .dist-info / .egg-info / version.py gerekir)."},
396
+ }
397
+
398
+
399
+ def summarize(r: Result, lang: str = "en") -> str:
400
+ s = STRINGS[lang]
401
+ label = f"{r.name.replace('-', '_')} {r.version}"
402
+ if "!/" in r.location:
403
+ label += f" [{r.location}]"
404
+ if r.status == "error":
405
+ return f"{label}: {s['err']} ({r.error})"
406
+ same_word = s["same1"] if r.same == 1 else s["same"]
407
+ parts = [f"{r.same} {same_word}", f"{len(r.modified)} {s['mod']}", f"{len(r.extra)} {s['extra']}"]
408
+ if r.missing:
409
+ parts.append(f"{len(r.missing)} {s['miss']}")
410
+ return f"{label}: " + ", ".join(parts) + "."
411
+
412
+
413
+ # -------------------------------------------------------------------- main
414
+ def main(argv: list[str] | None = None) -> int:
415
+ ap = argparse.ArgumentParser(prog="vendorcheck", description=__doc__.split("\n\n")[0])
416
+ ap.add_argument("path", help="directory or archive (.zip/.fda/.xpi/.vsix/...)")
417
+ ap.add_argument("--version", action="version", version=f"vendorcheck {__version__}")
418
+ ap.add_argument("--json", action="store_true", help="machine readable output")
419
+ ap.add_argument("-v", "--verbose", action="count", default=0,
420
+ help="-v list differing files, -vv also print unified diffs")
421
+ ap.add_argument("--cache", help="download cache dir (default: temp)")
422
+ ap.add_argument("--only", nargs="*", help="check only these package names")
423
+ ap.add_argument("--ignore", action="append", default=[], metavar="GLOB",
424
+ help="ignore files matching GLOB (relative to the package dir, e.g. "
425
+ "'yt_dlp/version.py'); repeatable. For intentional local patches.")
426
+ ap.add_argument("--no-nested", action="store_true", help="do not open archives inside the input")
427
+ ap.add_argument("--lang", choices=sorted(STRINGS), default="en", help="output language")
428
+ ap.add_argument("--allow-empty", action="store_true",
429
+ help="exit 0 even if no embedded packages were found")
430
+ args = ap.parse_args(argv)
431
+ if hasattr(sys.stdout, "reconfigure"):
432
+ sys.stdout.reconfigure(encoding="utf-8")
433
+
434
+ src = Path(args.path)
435
+ if not src.exists():
436
+ print(f"not found: {src}", file=sys.stderr)
437
+ return 2
438
+
439
+ tmp = Path(tempfile.mkdtemp(prefix="vendorcheck-"))
440
+ try:
441
+ if src.is_dir():
442
+ root = src.resolve()
443
+ else:
444
+ if not zipfile.is_zipfile(src):
445
+ print("not a directory or zip-compatible archive", file=sys.stderr)
446
+ return 2
447
+ root = tmp / "pkg"
448
+ with zipfile.ZipFile(src) as zf:
449
+ safe_extract_zip(zf, root)
450
+ cache = Path(args.cache) if args.cache else tmp / "cache"
451
+ cache.mkdir(parents=True, exist_ok=True)
452
+
453
+ roots = [(root, "")] if args.no_nested else expand_nested(root, tmp)
454
+ wanted = {norm_name(n) for n in args.only} if args.only else None
455
+ ignore = tuple(args.ignore)
456
+ results: list[Result] = []
457
+ seen: set = set()
458
+ for rdir, prefix in roots:
459
+ for e in discover(rdir):
460
+ if wanted and norm_name(e.name) not in wanted:
461
+ continue
462
+ key = (norm_name(e.name), e.version, e.container)
463
+ if key in seen:
464
+ continue
465
+ seen.add(key)
466
+ results.append(check_one(e, cache, rdir, ignore, args.verbose >= 2, prefix))
467
+
468
+ if args.json:
469
+ print(json.dumps([r.__dict__ | {"clean": r.clean} for r in results],
470
+ indent=2, ensure_ascii=False))
471
+ else:
472
+ if not results:
473
+ print(STRINGS[args.lang]["none"])
474
+ for r in results:
475
+ print(summarize(r, args.lang))
476
+ if args.verbose:
477
+ tags = STRINGS[args.lang]["tags"]
478
+ for tag, items in zip(tags, (r.modified, r.extra, r.missing)):
479
+ for it in items:
480
+ print(f" [{tag}] {it}")
481
+ if tag == tags[0] and it in r.diffs:
482
+ print("\n".join(" " + l for l in r.diffs[it].splitlines()))
483
+ write_step_summary(results)
484
+ if not results:
485
+ return 0 if args.allow_empty else 2
486
+ if any(r.status == "error" for r in results):
487
+ return 2 if not any(not r.clean for r in results if r.status == "ok") else 1
488
+ return 0 if all(r.clean for r in results) else 1
489
+ finally:
490
+ shutil.rmtree(tmp, ignore_errors=True)
491
+
492
+
493
+ if __name__ == "__main__":
494
+ sys.exit(main())