vexpath 0.1.2__tar.gz → 0.1.4__tar.gz
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.
- {vexpath-0.1.2 → vexpath-0.1.4}/PKG-INFO +2 -2
- {vexpath-0.1.2 → vexpath-0.1.4}/pyproject.toml +2 -2
- {vexpath-0.1.2 → vexpath-0.1.4}/src/vexpath/commands/advisory.py +42 -0
- vexpath-0.1.4/src/vexpath/corpus.py +180 -0
- {vexpath-0.1.2 → vexpath-0.1.4}/src/vexpath/sources.py +25 -7
- vexpath-0.1.4/tests/test_corpus.py +191 -0
- {vexpath-0.1.2 → vexpath-0.1.4}/.gitignore +0 -0
- {vexpath-0.1.2 → vexpath-0.1.4}/LICENSE +0 -0
- {vexpath-0.1.2 → vexpath-0.1.4}/README.md +0 -0
- {vexpath-0.1.2 → vexpath-0.1.4}/src/vexpath/__init__.py +0 -0
- {vexpath-0.1.2 → vexpath-0.1.4}/src/vexpath/commands/__init__.py +0 -0
- {vexpath-0.1.2 → vexpath-0.1.4}/src/vexpath/commands/attest.py +0 -0
- {vexpath-0.1.2 → vexpath-0.1.4}/src/vexpath/commands/auth.py +0 -0
- {vexpath-0.1.2 → vexpath-0.1.4}/src/vexpath/commands/policy.py +0 -0
- {vexpath-0.1.2 → vexpath-0.1.4}/src/vexpath/commands/reach.py +0 -0
- {vexpath-0.1.2 → vexpath-0.1.4}/src/vexpath/commands/report.py +0 -0
- {vexpath-0.1.2 → vexpath-0.1.4}/src/vexpath/commands/sbom.py +0 -0
- {vexpath-0.1.2 → vexpath-0.1.4}/src/vexpath/commands/scan.py +0 -0
- {vexpath-0.1.2 → vexpath-0.1.4}/src/vexpath/commands/upload.py +0 -0
- {vexpath-0.1.2 → vexpath-0.1.4}/src/vexpath/commands/vex.py +0 -0
- {vexpath-0.1.2 → vexpath-0.1.4}/src/vexpath/credentials.py +0 -0
- {vexpath-0.1.2 → vexpath-0.1.4}/src/vexpath/exit_codes.py +0 -0
- {vexpath-0.1.2 → vexpath-0.1.4}/src/vexpath/main.py +0 -0
- {vexpath-0.1.2 → vexpath-0.1.4}/src/vexpath/output.py +0 -0
- {vexpath-0.1.2 → vexpath-0.1.4}/src/vexpath/py.typed +0 -0
- {vexpath-0.1.2 → vexpath-0.1.4}/tests/test_auth_and_upload.py +0 -0
- {vexpath-0.1.2 → vexpath-0.1.4}/tests/test_cli.py +0 -0
- {vexpath-0.1.2 → vexpath-0.1.4}/tests/test_report_command.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: vexpath
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.4
|
|
4
4
|
Summary: Command-line tool: generate SBOMs, match advisories, analyse reachability and emit signed VEX.
|
|
5
5
|
Project-URL: Homepage, https://github.com/Unknown122-coder/vexpath
|
|
6
6
|
Project-URL: Documentation, https://github.com/Unknown122-coder/vexpath/tree/main/docs
|
|
@@ -27,7 +27,7 @@ Classifier: Typing :: Typed
|
|
|
27
27
|
Requires-Python: >=3.12
|
|
28
28
|
Requires-Dist: rich>=13.9
|
|
29
29
|
Requires-Dist: typer>=0.15
|
|
30
|
-
Requires-Dist: vexpath-core==0.1.
|
|
30
|
+
Requires-Dist: vexpath-core==0.1.4
|
|
31
31
|
Description-Content-Type: text/markdown
|
|
32
32
|
|
|
33
33
|
# vexpath
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "vexpath"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.4"
|
|
4
4
|
description = "Command-line tool: generate SBOMs, match advisories, analyse reachability and emit signed VEX."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.12"
|
|
@@ -45,7 +45,7 @@ classifiers = [
|
|
|
45
45
|
]
|
|
46
46
|
|
|
47
47
|
dependencies = [
|
|
48
|
-
"vexpath-core==0.1.
|
|
48
|
+
"vexpath-core==0.1.4",
|
|
49
49
|
"typer>=0.15",
|
|
50
50
|
"rich>=13.9",
|
|
51
51
|
]
|
|
@@ -8,6 +8,7 @@ from typing import Annotated
|
|
|
8
8
|
|
|
9
9
|
import typer
|
|
10
10
|
|
|
11
|
+
from vexpath import corpus
|
|
11
12
|
from vexpath.output import OutputFormat, emit, emit_json, fail, note
|
|
12
13
|
from vexpath.sources import SNAPSHOT_ENV
|
|
13
14
|
from vexpath_core.advisory.coverage import FixReferenceTier, measure
|
|
@@ -19,6 +20,47 @@ from vexpath_core.snapshot import SnapshotIntegrityError, read_snapshot
|
|
|
19
20
|
app = typer.Typer(help="Download and inspect the advisory corpus.", no_args_is_help=True)
|
|
20
21
|
|
|
21
22
|
|
|
23
|
+
@app.command("pull")
|
|
24
|
+
def pull_corpus(
|
|
25
|
+
directory: Annotated[
|
|
26
|
+
Path | None,
|
|
27
|
+
typer.Argument(
|
|
28
|
+
help="Where to put it. Defaults to the per-user cache, where every command finds it."
|
|
29
|
+
),
|
|
30
|
+
] = None,
|
|
31
|
+
url: Annotated[
|
|
32
|
+
str | None,
|
|
33
|
+
typer.Option("--url", help="Fetch from somewhere else: a fork, a mirror, your own build."),
|
|
34
|
+
] = None,
|
|
35
|
+
) -> None:
|
|
36
|
+
"""Download the published advisory corpus, with its vulnerable symbols.
|
|
37
|
+
|
|
38
|
+
The corpus is what makes a finding clearable. OSV says a package is
|
|
39
|
+
affected; it does not say which functions the fix changed, and without
|
|
40
|
+
those there is nothing for a reachability search to look for -- so a scan
|
|
41
|
+
with no corpus reports almost everything as ``symbols_unknown``, which is
|
|
42
|
+
honest and useless.
|
|
43
|
+
|
|
44
|
+
Building one means diffing the fix commit of every advisory, which is
|
|
45
|
+
hours of cloning upstream repositories and produces the same answer for
|
|
46
|
+
everybody. So it is built once and published, and this collects it.
|
|
47
|
+
|
|
48
|
+
Verified on arrival: the content hash is recomputed over the records and
|
|
49
|
+
checked against what the snapshot claims. A corpus that fails never
|
|
50
|
+
replaces one that works.
|
|
51
|
+
"""
|
|
52
|
+
try:
|
|
53
|
+
destination, advisories, symbols = corpus.pull(directory, base_url=url)
|
|
54
|
+
except corpus.CorpusError as exc:
|
|
55
|
+
fail(str(exc))
|
|
56
|
+
|
|
57
|
+
note(
|
|
58
|
+
f"{advisories} advisories, {symbols} vulnerable symbols -> {destination}\n"
|
|
59
|
+
f"Every command uses it from now on; override with --snapshot or "
|
|
60
|
+
f"${SNAPSHOT_ENV}."
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
22
64
|
@app.command("sync")
|
|
23
65
|
def sync(
|
|
24
66
|
directory: Annotated[
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Fetching the published advisory corpus.
|
|
2
|
+
|
|
3
|
+
Building the corpus is the expensive half of this tool and nobody should have
|
|
4
|
+
to repeat it. Downloading advisories from OSV takes twenty seconds; extracting
|
|
5
|
+
which functions each fix changed takes hours of cloning and diffing upstream
|
|
6
|
+
repositories, and the result is identical for everyone. So it is built once and
|
|
7
|
+
published, and this is the half that collects it.
|
|
8
|
+
|
|
9
|
+
Without it a new user gets the honest but useless answer: almost every finding
|
|
10
|
+
comes back ``symbols_unknown`` -- the advisory never said which functions its
|
|
11
|
+
fix changed, so nothing can be searched for -- and the tool that promises to
|
|
12
|
+
tell you what is reachable tells you it could not look.
|
|
13
|
+
|
|
14
|
+
**Published to a GitHub release rather than served from the platform.** Release
|
|
15
|
+
assets cost nothing to serve and are already covered by the release's
|
|
16
|
+
provenance; the platform is one small machine that sleeps when idle, and
|
|
17
|
+
sending every user ten megabytes from it would be the first thing to fall over.
|
|
18
|
+
|
|
19
|
+
The download is verified rather than trusted: ``read_snapshot`` recomputes the
|
|
20
|
+
content hash over the normalised records and refuses a corpus whose contents do
|
|
21
|
+
not match what the snapshot claims. A corrupted or substituted corpus changes
|
|
22
|
+
verdicts, so loading one is not an acceptable degraded mode.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import os
|
|
28
|
+
import shutil
|
|
29
|
+
import tempfile
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
import httpx
|
|
33
|
+
|
|
34
|
+
from vexpath_core.snapshot import (
|
|
35
|
+
ADVISORIES_FILE_GZ,
|
|
36
|
+
SNAPSHOT_METADATA_FILE,
|
|
37
|
+
SnapshotIntegrityError,
|
|
38
|
+
read_snapshot,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
#: Where a published corpus is fetched from when nothing says otherwise.
|
|
42
|
+
#:
|
|
43
|
+
#: ``releases/latest/download`` is GitHub's redirect to whatever the newest
|
|
44
|
+
#: release is, so this URL does not have to name a version and does not go
|
|
45
|
+
#: stale.
|
|
46
|
+
DEFAULT_CORPUS_URL = "https://github.com/Unknown122-coder/vexpath/releases/latest/download/corpus"
|
|
47
|
+
|
|
48
|
+
#: Override, for a fork, a mirror, or a corpus built in-house.
|
|
49
|
+
CORPUS_URL_ENVIRONMENT_VARIABLE = "VEXPATH_CORPUS_URL"
|
|
50
|
+
|
|
51
|
+
#: How long to wait for the corpus. It is around ten megabytes compressed, and
|
|
52
|
+
#: a slow connection is not a failure.
|
|
53
|
+
DOWNLOAD_TIMEOUT = 300.0
|
|
54
|
+
|
|
55
|
+
#: Refuse anything larger. The published corpus is about ten megabytes; a
|
|
56
|
+
#: response far above that is a redirect to something else, a captive portal,
|
|
57
|
+
#: or a mistake, and streaming it to disk unchecked would fill the disk.
|
|
58
|
+
MAX_CORPUS_BYTES = 200 * 1024 * 1024
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class CorpusError(Exception):
|
|
62
|
+
"""Raised when a corpus cannot be fetched or cannot be trusted."""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def default_corpus_directory() -> Path:
|
|
66
|
+
"""Where a downloaded corpus lives.
|
|
67
|
+
|
|
68
|
+
Beside the call-graph cache and following the same platform convention,
|
|
69
|
+
because both are rebuildable data rather than configuration: losing either
|
|
70
|
+
costs time, not information.
|
|
71
|
+
"""
|
|
72
|
+
override = os.environ.get("VEXPATH_CACHE_DIR")
|
|
73
|
+
if override:
|
|
74
|
+
return Path(override) / "corpus"
|
|
75
|
+
if os.name == "nt":
|
|
76
|
+
base = os.environ.get("LOCALAPPDATA") or str(Path.home() / "AppData" / "Local")
|
|
77
|
+
return Path(base) / "vexpath" / "corpus"
|
|
78
|
+
base = os.environ.get("XDG_CACHE_HOME") or str(Path.home() / ".cache")
|
|
79
|
+
return Path(base) / "vexpath" / "corpus"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def corpus_url() -> str:
|
|
83
|
+
"""The base URL to fetch from, honouring the override."""
|
|
84
|
+
return os.environ.get(CORPUS_URL_ENVIRONMENT_VARIABLE, "").strip() or DEFAULT_CORPUS_URL
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def pull(
|
|
88
|
+
destination: Path | None = None,
|
|
89
|
+
*,
|
|
90
|
+
base_url: str | None = None,
|
|
91
|
+
client: httpx.Client | None = None,
|
|
92
|
+
) -> tuple[Path, int, int]:
|
|
93
|
+
"""Download and verify a published corpus.
|
|
94
|
+
|
|
95
|
+
Returns the directory, the number of advisories and the number of symbols.
|
|
96
|
+
|
|
97
|
+
Downloaded into a temporary directory and moved into place only once it
|
|
98
|
+
verifies, so an interrupted or corrupted download cannot replace a corpus
|
|
99
|
+
that was working. The client is injectable so the tests exercise the real
|
|
100
|
+
code against recorded bytes rather than the network.
|
|
101
|
+
|
|
102
|
+
Raises:
|
|
103
|
+
CorpusError: if the corpus cannot be fetched, is implausibly large, or
|
|
104
|
+
does not match its own content hash.
|
|
105
|
+
"""
|
|
106
|
+
target = destination or default_corpus_directory()
|
|
107
|
+
root = (base_url or corpus_url()).rstrip("/")
|
|
108
|
+
|
|
109
|
+
owns_client = client is None
|
|
110
|
+
http = client or httpx.Client(timeout=DOWNLOAD_TIMEOUT, follow_redirects=True)
|
|
111
|
+
try:
|
|
112
|
+
with tempfile.TemporaryDirectory() as directory:
|
|
113
|
+
staging = Path(directory)
|
|
114
|
+
_download(http, f"{root}.{SNAPSHOT_METADATA_FILE}", staging / SNAPSHOT_METADATA_FILE)
|
|
115
|
+
_download(http, f"{root}.{ADVISORIES_FILE_GZ}", staging / ADVISORIES_FILE_GZ)
|
|
116
|
+
|
|
117
|
+
# Verifies the content hash, which is the point of doing it here
|
|
118
|
+
# rather than after the move: a corpus that fails this never
|
|
119
|
+
# replaces one that works.
|
|
120
|
+
metadata, records = read_snapshot(staging)
|
|
121
|
+
symbols = sum(len(record.symbols) for record in records)
|
|
122
|
+
|
|
123
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
124
|
+
if target.exists():
|
|
125
|
+
shutil.rmtree(target)
|
|
126
|
+
shutil.move(str(staging), str(target))
|
|
127
|
+
except httpx.HTTPError as exc:
|
|
128
|
+
msg = f"the corpus could not be downloaded from {root}: {exc}"
|
|
129
|
+
raise CorpusError(msg) from exc
|
|
130
|
+
except SnapshotIntegrityError as exc:
|
|
131
|
+
# The check that makes downloading a corpus acceptable at all. A
|
|
132
|
+
# substituted one changes verdicts -- drop an advisory's symbols and
|
|
133
|
+
# every project using that package is quietly cleared -- so this is
|
|
134
|
+
# fatal, and the message says to try again rather than how to skip it.
|
|
135
|
+
msg = f"the corpus from {root} does not match its own content hash and was discarded: {exc}"
|
|
136
|
+
raise CorpusError(msg) from exc
|
|
137
|
+
except (OSError, ValueError) as exc:
|
|
138
|
+
msg = f"the corpus from {root} could not be read: {exc}"
|
|
139
|
+
raise CorpusError(msg) from exc
|
|
140
|
+
finally:
|
|
141
|
+
if owns_client:
|
|
142
|
+
http.close()
|
|
143
|
+
|
|
144
|
+
del metadata
|
|
145
|
+
return target, len(records), symbols
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _download(client: httpx.Client, url: str, destination: Path) -> None:
|
|
149
|
+
"""Stream one file to disk, refusing an implausible one."""
|
|
150
|
+
with client.stream("GET", url) as response:
|
|
151
|
+
if response.status_code == 404:
|
|
152
|
+
msg = (
|
|
153
|
+
f"{url} does not exist. No corpus has been published for this "
|
|
154
|
+
f"release yet; 'vexpath advisory sync' builds one locally instead."
|
|
155
|
+
)
|
|
156
|
+
raise CorpusError(msg)
|
|
157
|
+
response.raise_for_status()
|
|
158
|
+
|
|
159
|
+
written = 0
|
|
160
|
+
with destination.open("wb") as handle:
|
|
161
|
+
for chunk in response.iter_bytes():
|
|
162
|
+
written += len(chunk)
|
|
163
|
+
if written > MAX_CORPUS_BYTES:
|
|
164
|
+
msg = (
|
|
165
|
+
f"{url} is larger than the {MAX_CORPUS_BYTES} byte limit, "
|
|
166
|
+
f"which a published corpus is not"
|
|
167
|
+
)
|
|
168
|
+
raise CorpusError(msg)
|
|
169
|
+
handle.write(chunk)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
__all__ = [
|
|
173
|
+
"CORPUS_URL_ENVIRONMENT_VARIABLE",
|
|
174
|
+
"DEFAULT_CORPUS_URL",
|
|
175
|
+
"MAX_CORPUS_BYTES",
|
|
176
|
+
"CorpusError",
|
|
177
|
+
"corpus_url",
|
|
178
|
+
"default_corpus_directory",
|
|
179
|
+
"pull",
|
|
180
|
+
]
|
|
@@ -11,10 +11,11 @@ from __future__ import annotations
|
|
|
11
11
|
import os
|
|
12
12
|
from pathlib import Path
|
|
13
13
|
|
|
14
|
+
from vexpath.corpus import default_corpus_directory
|
|
14
15
|
from vexpath.output import fail, note, warn
|
|
15
16
|
from vexpath_core.advisory import OsvApiSource, SnapshotSource
|
|
16
17
|
from vexpath_core.protocols import AdvisorySource
|
|
17
|
-
from vexpath_core.snapshot import SnapshotIntegrityError
|
|
18
|
+
from vexpath_core.snapshot import SNAPSHOT_METADATA_FILE, SnapshotIntegrityError
|
|
18
19
|
|
|
19
20
|
#: Environment variable naming a local advisory snapshot. Set it and commands
|
|
20
21
|
#: become reproducible and offline by default.
|
|
@@ -24,17 +25,34 @@ OSV_BASE_ENV = "VEXPATH_OSV_API_BASE"
|
|
|
24
25
|
|
|
25
26
|
|
|
26
27
|
def snapshot_directory(explicit: Path | None = None) -> Path | None:
|
|
27
|
-
"""Resolve the snapshot to read
|
|
28
|
+
"""Resolve the snapshot to read: the flag, the environment, or the cache.
|
|
29
|
+
|
|
30
|
+
The last of those is what makes this tool useful to somebody who has not
|
|
31
|
+
built a corpus. ``vexpath advisory pull`` downloads the published one, and
|
|
32
|
+
every command finds it from then on without being told -- which matters
|
|
33
|
+
because the alternative is a live OSV query, and OSV does not publish
|
|
34
|
+
which functions a fix changed. Without a corpus nearly every finding comes
|
|
35
|
+
back ``symbols_unknown``: an honest answer, and not a useful one.
|
|
36
|
+
|
|
37
|
+
Order is deliberate. An explicit flag beats the environment, the
|
|
38
|
+
environment beats the cache, and a cache that is merely present never
|
|
39
|
+
overrides something the caller asked for.
|
|
40
|
+
"""
|
|
28
41
|
if explicit is not None:
|
|
29
42
|
return explicit
|
|
43
|
+
|
|
30
44
|
raw = os.environ.get(SNAPSHOT_ENV, "").strip()
|
|
31
|
-
if
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
45
|
+
if raw:
|
|
46
|
+
candidate = Path(raw)
|
|
47
|
+
if candidate.is_dir():
|
|
48
|
+
return candidate
|
|
35
49
|
warn(f"${SNAPSHOT_ENV} points at {candidate}, which is not a directory; ignoring it.")
|
|
36
50
|
return None
|
|
37
|
-
|
|
51
|
+
|
|
52
|
+
downloaded = default_corpus_directory()
|
|
53
|
+
if (downloaded / SNAPSHOT_METADATA_FILE).is_file():
|
|
54
|
+
return downloaded
|
|
55
|
+
return None
|
|
38
56
|
|
|
39
57
|
|
|
40
58
|
def advisory_source(snapshot: Path | None, *, result_noun: str = "scan") -> AdvisorySource:
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Fetching the published corpus.
|
|
2
|
+
|
|
3
|
+
The corpus is what makes a finding clearable: OSV says a package is affected,
|
|
4
|
+
it does not say which functions the fix changed. Without one, nearly every
|
|
5
|
+
finding comes back ``symbols_unknown`` -- honest, and useless. Building one is
|
|
6
|
+
hours of cloning upstream repositories and produces the same answer for
|
|
7
|
+
everybody, so it is published and collected rather than rebuilt.
|
|
8
|
+
|
|
9
|
+
These exercise the real download code against recorded bytes. The client is
|
|
10
|
+
injectable for exactly that reason: a test that mocks the function under test
|
|
11
|
+
proves nothing about it.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import gzip
|
|
17
|
+
import json
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
import httpx
|
|
21
|
+
import pytest
|
|
22
|
+
|
|
23
|
+
from vexpath import corpus
|
|
24
|
+
from vexpath.sources import snapshot_directory
|
|
25
|
+
from vexpath_core.snapshot import ADVISORIES_FILE, SNAPSHOT_METADATA_FILE
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _built_corpus(tmp_path: Path) -> tuple[bytes, bytes]:
|
|
29
|
+
"""A real snapshot, built by the real writer, as published bytes."""
|
|
30
|
+
from datetime import UTC, datetime
|
|
31
|
+
|
|
32
|
+
from vexpath_core.enums import (
|
|
33
|
+
Confidence,
|
|
34
|
+
Severity,
|
|
35
|
+
SymbolKind,
|
|
36
|
+
SymbolSource,
|
|
37
|
+
SymbolsStatus,
|
|
38
|
+
)
|
|
39
|
+
from vexpath_core.models.advisory import Advisory, AdvisorySnapshot, VulnerableSymbol
|
|
40
|
+
from vexpath_core.snapshot import write_snapshot
|
|
41
|
+
|
|
42
|
+
advisory = Advisory(
|
|
43
|
+
id="CVE-2026-0001",
|
|
44
|
+
summary="a problem",
|
|
45
|
+
severity=Severity.HIGH,
|
|
46
|
+
symbols_status=SymbolsStatus.EXTRACTED,
|
|
47
|
+
affected=(),
|
|
48
|
+
symbols=(
|
|
49
|
+
VulnerableSymbol(
|
|
50
|
+
advisory_id="CVE-2026-0001",
|
|
51
|
+
ecosystem="PyPI",
|
|
52
|
+
package_name="lib",
|
|
53
|
+
symbol="lib.danger",
|
|
54
|
+
kind=SymbolKind.FUNCTION,
|
|
55
|
+
source=SymbolSource.FIX_COMMIT,
|
|
56
|
+
confidence=Confidence.HIGH,
|
|
57
|
+
),
|
|
58
|
+
),
|
|
59
|
+
)
|
|
60
|
+
source = tmp_path / "built"
|
|
61
|
+
# The writer fills in the content hash and the counts, which is what makes
|
|
62
|
+
# this a real snapshot rather than a file shaped like one.
|
|
63
|
+
write_snapshot(
|
|
64
|
+
source,
|
|
65
|
+
AdvisorySnapshot(
|
|
66
|
+
id="published-1",
|
|
67
|
+
created_at=datetime(2026, 11, 13, tzinfo=UTC),
|
|
68
|
+
content_sha256="0" * 64,
|
|
69
|
+
core_version="0.0.0",
|
|
70
|
+
ecosystems=["PyPI"],
|
|
71
|
+
counts={},
|
|
72
|
+
),
|
|
73
|
+
[advisory],
|
|
74
|
+
)
|
|
75
|
+
metadata = (source / SNAPSHOT_METADATA_FILE).read_bytes()
|
|
76
|
+
advisories = gzip.compress((source / ADVISORIES_FILE).read_bytes())
|
|
77
|
+
return metadata, advisories
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _client(metadata: bytes, advisories: bytes, *, status: int = 200) -> httpx.Client:
|
|
81
|
+
def handle(request: httpx.Request) -> httpx.Response:
|
|
82
|
+
body = metadata if request.url.path.endswith("snapshot.json") else advisories
|
|
83
|
+
return httpx.Response(status, content=body)
|
|
84
|
+
|
|
85
|
+
return httpx.Client(transport=httpx.MockTransport(handle))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_a_published_corpus_is_downloaded_and_verified(tmp_path: Path) -> None:
|
|
89
|
+
metadata, advisories = _built_corpus(tmp_path)
|
|
90
|
+
destination = tmp_path / "cache"
|
|
91
|
+
|
|
92
|
+
with _client(metadata, advisories) as client:
|
|
93
|
+
where, count, symbols = corpus.pull(
|
|
94
|
+
destination, base_url="https://example.test/corpus", client=client
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
assert where == destination
|
|
98
|
+
assert count == 1
|
|
99
|
+
assert symbols == 1
|
|
100
|
+
assert (destination / SNAPSHOT_METADATA_FILE).is_file()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_a_corpus_that_does_not_match_its_hash_is_refused(tmp_path: Path) -> None:
|
|
104
|
+
"""The check that makes downloading one acceptable at all.
|
|
105
|
+
|
|
106
|
+
A substituted corpus changes verdicts: drop the symbols for an advisory
|
|
107
|
+
and every project using that package is quietly cleared. So the hash is
|
|
108
|
+
recomputed over the records and compared with what the snapshot claims,
|
|
109
|
+
and a mismatch is fatal rather than a degraded mode.
|
|
110
|
+
"""
|
|
111
|
+
metadata, advisories = _built_corpus(tmp_path)
|
|
112
|
+
tampered = json.loads(metadata)
|
|
113
|
+
tampered["content_sha256"] = "0" * 64
|
|
114
|
+
|
|
115
|
+
with (
|
|
116
|
+
_client(json.dumps(tampered).encode(), advisories) as client,
|
|
117
|
+
pytest.raises(corpus.CorpusError),
|
|
118
|
+
):
|
|
119
|
+
corpus.pull(tmp_path / "cache", base_url="https://example.test/corpus", client=client)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_a_failed_download_does_not_replace_a_working_corpus(tmp_path: Path) -> None:
|
|
123
|
+
"""Downloaded to a temporary directory and moved only once it verifies.
|
|
124
|
+
|
|
125
|
+
Somebody running `advisory pull` on a flaky connection must not end up
|
|
126
|
+
worse off than before they ran it.
|
|
127
|
+
"""
|
|
128
|
+
metadata, advisories = _built_corpus(tmp_path)
|
|
129
|
+
destination = tmp_path / "cache"
|
|
130
|
+
|
|
131
|
+
with _client(metadata, advisories) as client:
|
|
132
|
+
corpus.pull(destination, base_url="https://example.test/corpus", client=client)
|
|
133
|
+
existing = (destination / SNAPSHOT_METADATA_FILE).read_bytes()
|
|
134
|
+
|
|
135
|
+
tampered = json.loads(metadata)
|
|
136
|
+
tampered["content_sha256"] = "0" * 64
|
|
137
|
+
with (
|
|
138
|
+
_client(json.dumps(tampered).encode(), advisories) as client,
|
|
139
|
+
pytest.raises(corpus.CorpusError),
|
|
140
|
+
):
|
|
141
|
+
corpus.pull(destination, base_url="https://example.test/corpus", client=client)
|
|
142
|
+
|
|
143
|
+
assert (destination / SNAPSHOT_METADATA_FILE).read_bytes() == existing
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_a_missing_corpus_says_what_to_do_instead(tmp_path: Path) -> None:
|
|
147
|
+
metadata, advisories = _built_corpus(tmp_path)
|
|
148
|
+
with (
|
|
149
|
+
_client(metadata, advisories, status=404) as client,
|
|
150
|
+
pytest.raises(corpus.CorpusError, match="advisory sync"),
|
|
151
|
+
):
|
|
152
|
+
corpus.pull(tmp_path / "cache", base_url="https://example.test/corpus", client=client)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def test_a_downloaded_corpus_is_found_without_being_named(
|
|
156
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
157
|
+
) -> None:
|
|
158
|
+
"""The point of the whole feature: it works with no flag and no variable.
|
|
159
|
+
|
|
160
|
+
A new user runs `advisory pull` once and every command afterwards is
|
|
161
|
+
reproducible and offline, without being told where anything is.
|
|
162
|
+
"""
|
|
163
|
+
metadata, advisories = _built_corpus(tmp_path)
|
|
164
|
+
cache = tmp_path / "home"
|
|
165
|
+
monkeypatch.setenv("VEXPATH_CACHE_DIR", str(cache))
|
|
166
|
+
monkeypatch.delenv("VEXPATH_ADVISORY_SNAPSHOT_DIR", raising=False)
|
|
167
|
+
|
|
168
|
+
assert snapshot_directory() is None
|
|
169
|
+
|
|
170
|
+
with _client(metadata, advisories) as client:
|
|
171
|
+
corpus.pull(base_url="https://example.test/corpus", client=client)
|
|
172
|
+
|
|
173
|
+
assert snapshot_directory() == cache / "corpus"
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def test_what_the_caller_asked_for_beats_the_cache(
|
|
177
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
178
|
+
) -> None:
|
|
179
|
+
"""A cache that is merely present never overrides an explicit choice."""
|
|
180
|
+
metadata, advisories = _built_corpus(tmp_path)
|
|
181
|
+
cache = tmp_path / "home"
|
|
182
|
+
monkeypatch.setenv("VEXPATH_CACHE_DIR", str(cache))
|
|
183
|
+
monkeypatch.delenv("VEXPATH_ADVISORY_SNAPSHOT_DIR", raising=False)
|
|
184
|
+
with _client(metadata, advisories) as client:
|
|
185
|
+
corpus.pull(base_url="https://example.test/corpus", client=client)
|
|
186
|
+
|
|
187
|
+
explicit = tmp_path / "elsewhere"
|
|
188
|
+
assert snapshot_directory(explicit) == explicit
|
|
189
|
+
|
|
190
|
+
monkeypatch.setenv("VEXPATH_ADVISORY_SNAPSHOT_DIR", str(tmp_path / "built"))
|
|
191
|
+
assert snapshot_directory() == tmp_path / "built"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|