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.
Files changed (28) hide show
  1. {vexpath-0.1.2 → vexpath-0.1.4}/PKG-INFO +2 -2
  2. {vexpath-0.1.2 → vexpath-0.1.4}/pyproject.toml +2 -2
  3. {vexpath-0.1.2 → vexpath-0.1.4}/src/vexpath/commands/advisory.py +42 -0
  4. vexpath-0.1.4/src/vexpath/corpus.py +180 -0
  5. {vexpath-0.1.2 → vexpath-0.1.4}/src/vexpath/sources.py +25 -7
  6. vexpath-0.1.4/tests/test_corpus.py +191 -0
  7. {vexpath-0.1.2 → vexpath-0.1.4}/.gitignore +0 -0
  8. {vexpath-0.1.2 → vexpath-0.1.4}/LICENSE +0 -0
  9. {vexpath-0.1.2 → vexpath-0.1.4}/README.md +0 -0
  10. {vexpath-0.1.2 → vexpath-0.1.4}/src/vexpath/__init__.py +0 -0
  11. {vexpath-0.1.2 → vexpath-0.1.4}/src/vexpath/commands/__init__.py +0 -0
  12. {vexpath-0.1.2 → vexpath-0.1.4}/src/vexpath/commands/attest.py +0 -0
  13. {vexpath-0.1.2 → vexpath-0.1.4}/src/vexpath/commands/auth.py +0 -0
  14. {vexpath-0.1.2 → vexpath-0.1.4}/src/vexpath/commands/policy.py +0 -0
  15. {vexpath-0.1.2 → vexpath-0.1.4}/src/vexpath/commands/reach.py +0 -0
  16. {vexpath-0.1.2 → vexpath-0.1.4}/src/vexpath/commands/report.py +0 -0
  17. {vexpath-0.1.2 → vexpath-0.1.4}/src/vexpath/commands/sbom.py +0 -0
  18. {vexpath-0.1.2 → vexpath-0.1.4}/src/vexpath/commands/scan.py +0 -0
  19. {vexpath-0.1.2 → vexpath-0.1.4}/src/vexpath/commands/upload.py +0 -0
  20. {vexpath-0.1.2 → vexpath-0.1.4}/src/vexpath/commands/vex.py +0 -0
  21. {vexpath-0.1.2 → vexpath-0.1.4}/src/vexpath/credentials.py +0 -0
  22. {vexpath-0.1.2 → vexpath-0.1.4}/src/vexpath/exit_codes.py +0 -0
  23. {vexpath-0.1.2 → vexpath-0.1.4}/src/vexpath/main.py +0 -0
  24. {vexpath-0.1.2 → vexpath-0.1.4}/src/vexpath/output.py +0 -0
  25. {vexpath-0.1.2 → vexpath-0.1.4}/src/vexpath/py.typed +0 -0
  26. {vexpath-0.1.2 → vexpath-0.1.4}/tests/test_auth_and_upload.py +0 -0
  27. {vexpath-0.1.2 → vexpath-0.1.4}/tests/test_cli.py +0 -0
  28. {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.2
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.2
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.2"
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.2",
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, from the flag or the environment."""
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 not raw:
32
- return None
33
- candidate = Path(raw)
34
- if not candidate.is_dir():
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
- return candidate
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