Skip to content

Commit 60db825

Browse files
committed
fix: resolve exact artifact versions
1 parent b9fba48 commit 60db825

9 files changed

Lines changed: 181 additions & 50 deletions

File tree

README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,9 @@ Run `uvx foretop-lading scan --help` for every option.
2929
- Optional artifacts from the local Hugging Face cache or an existing CycloneDX 1.6 JSON SBOM.
3030
- Attribution, redistribution, acceptable-use, field-of-use, and share-alike obligations.
3131

32-
Unresolved licences remain `unknown`; they are never treated as permissive.
32+
When discovery supplies a package version or Hugging Face revision, Lading resolves metadata
33+
for that exact release rather than the registry's latest state. Unresolved licences remain
34+
`unknown`; they are never treated as permissive.
3335

3436
## Output and CI gating
3537

@@ -41,7 +43,7 @@ failed, while `2` means the scan itself failed. Warnings alone never fail the co
4143
## GitHub Action
4244

4345
```yaml
44-
- uses: foretop-dev/lading@v0.1.0
46+
- uses: foretop-dev/lading@v0.1.1
4547
with:
4648
policy: permissive-only
4749
```

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "foretop-lading"
3-
version = "0.1.0"
3+
version = "0.1.1"
44
description = "Traces licence obligations across code dependencies and model weights as one dependency graph — resolves each artifact's licence via Hugging Face / PyPI and prints a flat, honest table (unknown rather than guessed)."
55
readme = "README.md"
66
requires-python = ">=3.12"

src/lading/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "0.1.0"
1+
__version__ = "0.1.1"

src/lading/cli.py

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -94,24 +94,40 @@ def _curate_all(
9494
package_overrides: dict[str, PackageOverride],
9595
) -> tuple[tuple[DiscoveredArtifact, CuratedResolution], ...]:
9696
"""Curates each discovered artifact, deduplicated in-memory within this one run — the
97-
same model id or package name referenced from two call sites is only resolved once. No
98-
persistent cache yet (specs/lading.md §6's 7-day cache-expiry rule is real but not this
99-
slice's job — a documented gap, not a silent one)."""
100-
cache: dict[tuple[ArtifactKind, str], CuratedResolution] = {}
97+
same model id or package name at the same version/revision, referenced from two call sites,
98+
is resolved once. Two versions of one canonical id remain separate because registry
99+
metadata and its licence can differ. No persistent cache yet (specs/lading.md §6's 7-day
100+
cache-expiry rule is real but not this slice's job — a documented gap, not a silent one)."""
101+
cache: dict[tuple[ArtifactKind, str, str | None], CuratedResolution] = {}
101102
rows: list[tuple[DiscoveredArtifact, CuratedResolution]] = []
102103
for artifact in artifacts:
103-
key = (artifact.kind, artifact.identifier)
104+
key = (artifact.kind, artifact.identifier, artifact.version)
104105
if key not in cache:
105106
# ADAPTER shares MODEL's own resolution path: a vendored adapter's identifier is
106107
# a local directory path, not a real HF id, so curate_model's own id-shape check
107108
# already resolves it honestly to unresolved — no separate adapter resolver needed.
108109
if artifact.kind is ArtifactKind.MODEL or artifact.kind is ArtifactKind.ADAPTER:
109-
cache[key] = curate_model(client, artifact.identifier, overrides, licence_index)
110+
cache[key] = curate_model(
111+
client,
112+
artifact.identifier,
113+
overrides,
114+
licence_index,
115+
revision=artifact.version,
116+
)
110117
elif artifact.kind is ArtifactKind.DATASET:
111-
cache[key] = curate_dataset(client, artifact.identifier, licence_index)
118+
cache[key] = curate_dataset(
119+
client,
120+
artifact.identifier,
121+
licence_index,
122+
revision=artifact.version,
123+
)
112124
else:
113125
cache[key] = curate_package(
114-
client, artifact.identifier, licence_index, package_overrides
126+
client,
127+
artifact.identifier,
128+
licence_index,
129+
package_overrides,
130+
version=artifact.version,
115131
)
116132
rows.append((artifact, cache[key]))
117133
return tuple(rows)

src/lading/curate.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ def curate_model(
5959
model_id: str,
6060
overrides: dict[str, ModelOverride],
6161
licence_index: dict[str, LicenceEntry],
62+
*,
63+
revision: str | None = None,
6264
) -> CuratedResolution:
6365
"""DEC-02's hybrid: a curated override (real research done once, ahead of time) always
6466
wins and never touches the network — resolve_model is only called when no override
@@ -68,7 +70,7 @@ def curate_model(
6870
if override is not None:
6971
return _decorate(override.licence_id, override.source_url, licence_index)
7072

71-
resolution = resolve_model(client, model_id)
73+
resolution = resolve_model(client, model_id, revision=revision)
7274
if not resolution.resolved or resolution.licence is None:
7375
return _UNRESOLVED
7476
return _decorate(resolution.licence, resolution.source_url, licence_index)
@@ -79,6 +81,8 @@ def curate_package(
7981
name: str,
8082
licence_index: dict[str, LicenceEntry],
8183
package_overrides: dict[str, PackageOverride] | None = None,
84+
*,
85+
version: str | None = None,
8286
) -> CuratedResolution:
8387
"""Same DEC-02 hybrid `curate_model` uses: a curated override always wins and never
8488
touches the network. Package overrides matter more here than model overrides do for
@@ -93,19 +97,23 @@ def curate_package(
9397
if override is not None:
9498
return _decorate(override.licence_id, override.source_url, licence_index)
9599

96-
resolution = resolve_package(client, name)
100+
resolution = resolve_package(client, name, version=version)
97101
if not resolution.resolved or resolution.licence is None:
98102
return _UNRESOLVED
99103
return _decorate(resolution.licence, resolution.source_url, licence_index)
100104

101105

102106
def curate_dataset(
103-
client: httpx.Client, dataset_id: str, licence_index: dict[str, LicenceEntry]
107+
client: httpx.Client,
108+
dataset_id: str,
109+
licence_index: dict[str, LicenceEntry],
110+
*,
111+
revision: str | None = None,
104112
) -> CuratedResolution:
105113
"""No override layer for datasets this slice — data/model_overrides.yaml stays
106114
model-only; a real dataset override KB is future work, not named in Slice 3a's own
107115
scope."""
108-
resolution = resolve_dataset(client, dataset_id)
116+
resolution = resolve_dataset(client, dataset_id, revision=revision)
109117
if not resolution.resolved or resolution.licence is None:
110118
return _UNRESOLVED
111119
return _decorate(resolution.licence, resolution.source_url, licence_index)

src/lading/resolve.py

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import re
22
from typing import Any
3+
from urllib.parse import quote
34

45
import httpx
56

@@ -12,10 +13,17 @@
1213
# could reach an unintended API path.
1314
_MODEL_ID_RE = re.compile(r"^[\w][\w.-]*/[\w][\w.-]*$")
1415
_PACKAGE_NAME_RE = re.compile(r"^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$")
16+
_PACKAGE_VERSION_RE = re.compile(r"^[A-Za-z0-9](?:[A-Za-z0-9.!+_-]*[A-Za-z0-9])?$")
1517

1618
_UNRESOLVED = Resolution(licence=None, resolved=False, source_url=None)
1719

1820

21+
def _valid_hf_revision(revision: str) -> bool:
22+
if any(ord(character) < 32 or ord(character) == 127 for character in revision):
23+
return False
24+
return all(segment not in {"", ".", ".."} for segment in revision.split("/"))
25+
26+
1927
def _extract_hf_licence(data: dict[str, Any]) -> str | None:
2028
"""Verified live this session against real HF API responses: `cardData.license` can be a
2129
plain string (e.g. a gated Llama model's own non-SPDX slug, "llama3.2") or a list of
@@ -35,7 +43,13 @@ def _extract_hf_licence(data: dict[str, Any]) -> str | None:
3543
return None
3644

3745

38-
def _resolve_hf_repo(client: httpx.Client, repo_id: str, *, api_segment: str) -> Resolution:
46+
def _resolve_hf_repo(
47+
client: httpx.Client,
48+
repo_id: str,
49+
*,
50+
api_segment: str,
51+
revision: str | None = None,
52+
) -> Resolution:
3953
"""Shared by resolve_model/resolve_dataset — both endpoints share the exact same
4054
`cardData.license` shape (confirmed live this session against real datasets too:
4155
openai/gsm8k, Salesforce/wikitext), so this is genuinely identical logic, not merely
@@ -44,6 +58,13 @@ def _resolve_hf_repo(client: httpx.Client, repo_id: str, *, api_segment: str) ->
4458
return _UNRESOLVED
4559

4660
url = f"https://huggingface.co/api/{api_segment}/{repo_id}"
61+
if revision is not None:
62+
if not _valid_hf_revision(revision):
63+
return _UNRESOLVED
64+
# This is the exact revision route and quoting rule used by the current official
65+
# huggingface_hub HfApi model_info/dataset_info clients. A slash in a branch such as
66+
# refs/pr/7 is one revision value, never additional URL structure.
67+
url = f"{url}/revision/{quote(revision, safe='')}"
4768
try:
4869
response = client.get(url)
4970
except httpx.HTTPError:
@@ -62,19 +83,29 @@ def _resolve_hf_repo(client: httpx.Client, repo_id: str, *, api_segment: str) ->
6283
return Resolution(licence=licence, resolved=True, source_url=url)
6384

6485

65-
def resolve_model(client: httpx.Client, model_id: str) -> Resolution:
66-
return _resolve_hf_repo(client, model_id, api_segment="models")
86+
def resolve_model(
87+
client: httpx.Client, model_id: str, *, revision: str | None = None
88+
) -> Resolution:
89+
return _resolve_hf_repo(client, model_id, api_segment="models", revision=revision)
6790

6891

69-
def resolve_dataset(client: httpx.Client, dataset_id: str) -> Resolution:
70-
return _resolve_hf_repo(client, dataset_id, api_segment="datasets")
92+
def resolve_dataset(
93+
client: httpx.Client, dataset_id: str, *, revision: str | None = None
94+
) -> Resolution:
95+
return _resolve_hf_repo(client, dataset_id, api_segment="datasets", revision=revision)
7196

7297

73-
def resolve_package(client: httpx.Client, name: str) -> Resolution:
98+
def resolve_package(client: httpx.Client, name: str, *, version: str | None = None) -> Resolution:
7499
if not _PACKAGE_NAME_RE.match(name):
75100
return _UNRESOLVED
76101

77102
url = f"https://pypi.org/pypi/{name}/json"
103+
if version is not None:
104+
if not _PACKAGE_VERSION_RE.match(version):
105+
return _UNRESOLVED
106+
# PyPI documents this release-specific route separately from the project route,
107+
# whose info block describes only the latest release.
108+
url = f"https://pypi.org/pypi/{name}/{quote(version, safe='')}/json"
78109
try:
79110
response = client.get(url)
80111
except httpx.HTTPError:

tests/test_acceptance.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import httpx
2+
3+
from lading.cli import _curate_all
4+
from lading.models import ArtifactKind, DiscoveredArtifact
5+
6+
7+
def test_versions_and_revisions_reach_their_own_registry_metadata() -> None:
8+
artifacts = (
9+
DiscoveredArtifact(
10+
kind=ArtifactKind.PYTHON_PACKAGE,
11+
identifier="demo-package",
12+
version="1.0.0",
13+
evidence="pkg:pypi/demo-package@1.0.0",
14+
),
15+
DiscoveredArtifact(
16+
kind=ArtifactKind.PYTHON_PACKAGE,
17+
identifier="demo-package",
18+
version="2.0.0",
19+
evidence="pkg:pypi/demo-package@2.0.0",
20+
),
21+
DiscoveredArtifact(
22+
kind=ArtifactKind.MODEL,
23+
identifier="org/model",
24+
version="refs/pr/7",
25+
evidence="pkg:huggingface/org/model@refs%2Fpr%2F7",
26+
),
27+
DiscoveredArtifact(
28+
kind=ArtifactKind.MODEL,
29+
identifier="org/model",
30+
version="abcdef123456",
31+
evidence="pkg:huggingface/org/model@abcdef123456",
32+
),
33+
DiscoveredArtifact(
34+
kind=ArtifactKind.DATASET,
35+
identifier="org/dataset",
36+
version="dataset-revision",
37+
evidence="dataset fixture",
38+
),
39+
)
40+
licences_by_path = {
41+
"/pypi/demo-package/1.0.0/json": "licence-package-v1",
42+
"/pypi/demo-package/2.0.0/json": "licence-package-v2",
43+
"/api/models/org/model/revision/refs%2Fpr%2F7": "licence-model-pr",
44+
"/api/models/org/model/revision/abcdef123456": "licence-model-sha",
45+
"/api/datasets/org/dataset/revision/dataset-revision": "licence-dataset-revision",
46+
}
47+
requested_paths: list[str] = []
48+
49+
def handler(request: httpx.Request) -> httpx.Response:
50+
path = request.url.raw_path.decode("ascii")
51+
requested_paths.append(path)
52+
licence = licences_by_path.get(path, "wrong-latest-metadata")
53+
if request.url.host == "pypi.org":
54+
return httpx.Response(200, json={"info": {"license": licence}})
55+
return httpx.Response(200, json={"cardData": {"license": licence}})
56+
57+
with httpx.Client(transport=httpx.MockTransport(handler)) as client:
58+
curated = _curate_all(client, artifacts, {}, {}, {})
59+
60+
assert [resolution.licence for _artifact, resolution in curated] == list(
61+
licences_by_path.values()
62+
)
63+
assert requested_paths == list(licences_by_path)

tests/unit/test_resolve.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,17 @@ def handler(request: httpx.Request) -> httpx.Response:
8080
assert resolution.source_url is None
8181

8282

83+
def test_resolve_model_rejects_a_path_traversal_revision_before_building_a_url() -> None:
84+
def handler(request: httpx.Request) -> httpx.Response:
85+
raise AssertionError("must never reach the transport for a malformed revision")
86+
87+
with make_client(handler) as client:
88+
resolution = resolve_model(client, "org/model", revision="../../main")
89+
90+
assert resolution.resolved is False
91+
assert resolution.source_url is None
92+
93+
8394
def test_resolve_package_reads_pypi_license(monkeypatch: pytest.MonkeyPatch) -> None:
8495
def handler(request: httpx.Request) -> httpx.Response:
8596
assert request.url.path == "/pypi/requests/json"

0 commit comments

Comments
 (0)