You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@tooling.apache.org by ar...@apache.org on 2026/04/03 14:16:08 UTC
(tooling-trusted-releases) branch arm updated (3abfd5e8 -> 4847ab29)
This is an automated email from the ASF dual-hosted git repository.
arm pushed a change to branch arm
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git
omit 3abfd5e8 #964 - implement constrained safe path type for containment guarantees
new 4847ab29 #964 - implement constrained safe path type for containment guarantees
This update added new revisions after undoing existing revisions.
That is to say, some revisions that were in the old version of the
branch are not in the new version. This situation occurs
when a user --force pushes a change and generates a repository
containing something like this:
* -- * -- B -- O -- O -- O (3abfd5e8)
\
N -- N -- N refs/heads/arm (4847ab29)
You should already have received notification emails for all of the O
revisions, and so the following emails describe only the N revisions
from the common base, B.
Any revisions marked "omit" are not gone; other references still
refer to them. Any revisions marked "discard" are gone forever.
The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
Summary of changes:
tests/unit/test_keys_writer.py | 14 ++++++++------
1 file changed, 8 insertions(+), 6 deletions(-)
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org
(tooling-trusted-releases) 01/01: #964 - implement constrained safe path type for containment guarantees
Posted by ar...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
arm pushed a commit to branch arm
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git
commit 4847ab295fed8e3fa5bee17a5636203f7f124a26
Author: Alastair McFarlane <ar...@apache.org>
AuthorDate: Thu Apr 2 15:46:41 2026 +0100
#964 - implement constrained safe path type for containment guarantees
---
atr/analysis.py | 4 -
atr/api/__init__.py | 9 +-
atr/archives.py | 5 +-
atr/attestable.py | 86 ++++++-----
atr/classify.py | 12 +-
atr/detection.py | 23 +--
atr/get/checks.py | 25 ++--
atr/get/compose.py | 2 +-
atr/get/distribution.py | 2 +-
atr/get/download.py | 10 +-
atr/get/file.py | 21 +--
atr/get/finish.py | 15 +-
atr/get/published.py | 6 +-
atr/get/revisions.py | 23 ++-
atr/get/test.py | 5 +-
atr/hashes.py | 5 +-
atr/models/safe.py | 93 +++++++++++-
atr/models/sql.py | 41 ++++-
atr/paths.py | 20 +--
atr/post/draft.py | 15 +-
atr/post/upload.py | 4 +-
atr/sbom/osv.py | 10 +-
atr/server.py | 18 +--
atr/shared/distribution.py | 2 +-
atr/ssh.py | 3 +-
atr/storage/readers/releases.py | 25 ++--
atr/storage/types.py | 10 +-
atr/storage/writers/announce.py | 6 +-
atr/storage/writers/keys.py | 11 +-
atr/storage/writers/release.py | 66 ++++-----
atr/storage/writers/revision.py | 44 +++---
atr/storage/writers/sbom.py | 20 +--
atr/tarzip.py | 3 +-
atr/tasks/__init__.py | 7 +-
atr/tasks/checks/__init__.py | 51 ++++---
atr/tasks/checks/compare.py | 17 ++-
atr/tasks/checks/hashing.py | 4 +-
atr/tasks/checks/license.py | 25 ++--
atr/tasks/checks/paths.py | 61 ++++----
atr/tasks/checks/rat.py | 4 +-
atr/tasks/checks/signature.py | 2 +-
atr/tasks/checks/targz.py | 12 +-
atr/tasks/checks/zipformat.py | 10 +-
atr/tasks/quarantine.py | 84 ++++++-----
atr/tasks/sbom.py | 38 ++---
atr/tasks/svn.py | 9 +-
atr/templates/check-selected-path-table.html | 2 +-
atr/util.py | 49 +++---
atr/validate.py | 6 +-
atr/worker.py | 4 +-
tests/unit/recorders.py | 5 +-
tests/unit/test_archive_root_variants.py | 39 ++---
tests/unit/test_attestable.py | 35 +++--
tests/unit/test_checks_compare.py | 206 ++++++++++++++------------
tests/unit/test_checks_license.py | 85 ++++++-----
tests/unit/test_classify.py | 52 ++++---
tests/unit/test_create_revision.py | 37 +++--
tests/unit/test_create_revision_quarantine.py | 89 +++++++----
tests/unit/test_keys_writer.py | 14 +-
tests/unit/test_paths.py | 5 +-
tests/unit/test_quarantine_backfill.py | 73 ++++-----
tests/unit/test_quarantine_task.py | 80 +++++-----
62 files changed, 982 insertions(+), 767 deletions(-)
diff --git a/atr/analysis.py b/atr/analysis.py
index 2ab5ad13..e824fb2b 100755
--- a/atr/analysis.py
+++ b/atr/analysis.py
@@ -61,10 +61,6 @@ ARTIFACT_SUFFIXES: Final[list[str]] = [
DISALLOWED_FILENAMES: Final[frozenset[str]] = frozenset(
{
- ".DS_Store",
- ".git",
- ".htaccess",
- ".htpasswd",
"desktop.ini",
"id_dsa",
"id_ecdsa",
diff --git a/atr/api/__init__.py b/atr/api/__init__.py
index 71756c40..7b2f4208 100644
--- a/atr/api/__init__.py
+++ b/atr/api/__init__.py
@@ -16,7 +16,6 @@
# under the License.
import datetime
import hashlib
-import pathlib
from typing import Any, Final, Literal
import aiofiles.os
@@ -1664,7 +1663,7 @@ def _jwt_asf_uid() -> str:
async def _match_committee_keys(
- key_committees: list[sql.Committee], finished_dir: pathlib.Path, data: models.api.SignatureProvenanceArgs
+ key_committees: list[sql.Committee], finished_dir: safe.StatePath, data: models.api.SignatureProvenanceArgs
) -> set[str]:
key_committee_keys = set(committee.key for committee in key_committees)
finished_dir = paths.get_finished_dir()
@@ -1674,7 +1673,7 @@ async def _match_committee_keys(
for key_committee_key in key_committee_keys:
key_committee_finished_dir = finished_dir / key_committee_key
async for rel_path in util.paths_recursive(key_committee_finished_dir):
- if rel_path.name == data.signature_file_name:
+ if rel_path.as_path().name == data.signature_file_name:
abs_path = finished_dir / rel_path
async with aiofiles.open(abs_path, "rb") as f:
rel_path_data = await f.read()
@@ -1699,9 +1698,9 @@ async def _match_committee_keys(
return matched_committee_keys
-async def _match_unfinished(release_directory: pathlib.Path, data: models.api.SignatureProvenanceArgs) -> bool:
+async def _match_unfinished(release_directory: safe.StatePath, data: models.api.SignatureProvenanceArgs) -> bool:
async for rel_path in util.paths_recursive(release_directory):
- if rel_path.name == data.signature_file_name:
+ if rel_path.as_path().name == data.signature_file_name:
abs_path = release_directory / rel_path
async with aiofiles.open(abs_path, "rb") as f:
rel_path_data = await f.read()
diff --git a/atr/archives.py b/atr/archives.py
index 6b71ae34..216e8512 100644
--- a/atr/archives.py
+++ b/atr/archives.py
@@ -21,6 +21,7 @@ import tarfile
import zipfile
import atr.log as log
+import atr.models.safe as safe
import atr.tarzip as tarzip
@@ -29,7 +30,7 @@ class ExtractionError(Exception):
def extract(
- archive_path: str,
+ archive_path: safe.StatePath,
extract_dir: str,
max_size: int,
chunk_size: int,
@@ -79,7 +80,7 @@ def extract(
return total_extracted, extracted_paths
-def total_size(tgz_path: str, chunk_size: int = 4096) -> int:
+def total_size(tgz_path: safe.StatePath, chunk_size: int = 4096) -> int:
with tarzip.open_archive(tgz_path) as archive:
match archive.specific():
case tarfile.TarFile():
diff --git a/atr/attestable.py b/atr/attestable.py
index a58c34ae..999e5c6b 100644
--- a/atr/attestable.py
+++ b/atr/attestable.py
@@ -18,8 +18,7 @@
from __future__ import annotations
import json
-import pathlib
-from typing import Any
+from typing import TYPE_CHECKING, Any
import aiofiles
import aiofiles.os
@@ -35,16 +34,19 @@ import atr.models.sql as sql
import atr.paths as paths
import atr.util as util
+if TYPE_CHECKING:
+ import pathlib
+
def attestable_checks_path(
project_key: safe.ProjectKey, version_key: safe.VersionKey, revision_number: safe.RevisionNumber
-) -> pathlib.Path:
+) -> safe.StatePath:
return paths.get_attestable_dir() / str(project_key) / str(version_key) / f"{revision_number!s}.checks.json"
def attestable_path(
project_key: safe.ProjectKey, version_key: safe.VersionKey, revision_number: safe.RevisionNumber
-) -> pathlib.Path:
+) -> safe.StatePath:
return paths.get_attestable_dir() / str(project_key) / str(version_key) / f"{revision_number!s}.json"
@@ -58,10 +60,10 @@ def can_write_file_state_rows(
def compute_classifications(
- path_to_hash: dict[str, str],
+ path_to_hash: dict[safe.RelPath, str],
release_policy: dict[str, Any] | None,
- base_path: pathlib.Path,
-) -> dict[str, str]:
+ base_path: safe.StatePath,
+) -> dict[safe.RelPath, str]:
policy = release_policy or {}
source_matcher, binary_matcher = classify.matchers_from_policy(
policy.get("source_artifact_paths", []),
@@ -69,7 +71,7 @@ def compute_classifications(
base_path,
)
return {
- path_key: classify.classify(pathlib.Path(path_key), base_path, source_matcher, binary_matcher).value
+ path_key: classify.classify(path_key, base_path, source_matcher, binary_matcher).value
for path_key in path_to_hash
}
@@ -77,8 +79,8 @@ def compute_classifications(
def compute_file_state_rows(
release_key: str,
since_revision_seq: int,
- path_to_hash: dict[str, str],
- classifications: dict[str, str],
+ path_to_hash: dict[safe.RelPath, str],
+ classifications: dict[safe.RelPath, str],
previous: models.Attestable | None,
) -> list[sql.ReleaseFileState]:
prev_hashes: dict[str, str] = {}
@@ -90,25 +92,26 @@ def compute_file_state_rows(
rows: list[sql.ReleaseFileState] = []
- for path_key in sorted(path_to_hash):
+ for path_key in sorted(path_to_hash, key=str):
+ path_str = str(path_key)
content_hash = path_to_hash[path_key]
classification = classifications[path_key]
# If all prior metadata properties are the same, we skip recording an event
- if (prev_hashes.get(path_key) == content_hash) and (prev_classifications.get(path_key) == classification):
+ if (prev_hashes.get(path_str) == content_hash) and (prev_classifications.get(path_str) == classification):
continue
rows.append(
sql.ReleaseFileState(
release_key=release_key,
- path=path_key,
+ path=path_str,
since_revision_seq=since_revision_seq,
present=True,
content_hash=content_hash,
classification=classification,
)
)
-
+ str_keys = {str(k) for k in path_to_hash}
for path_key in sorted(prev_hashes):
- if path_key not in path_to_hash:
+ if path_key not in str_keys:
rows.append(
sql.ReleaseFileState(
release_key=release_key,
@@ -125,7 +128,7 @@ def compute_file_state_rows(
def github_tp_payload_path(
project_key: safe.ProjectKey, version_key: safe.VersionKey, revision_number: safe.RevisionNumber
-) -> pathlib.Path:
+) -> safe.StatePath:
return paths.get_attestable_dir() / str(project_key) / str(version_key) / f"{revision_number!s}.github-tp.json"
@@ -166,7 +169,9 @@ async def github_tp_payload_write(
# Dump the workflow payload, excluding exp and nbf - which shouldn't have made it this far. If they do,
# it's safe to remove them as they've been validated by the model already, and we should never store
# stale dates
- await util.atomic_write_file(payload_path, json.dumps(github_payload.model_dump(exclude={"exp", "nbf"}), indent=2))
+ await util.atomic_write_file(
+ payload_path.path, json.dumps(github_payload.model_dump(exclude={"exp", "nbf"}), indent=2)
+ )
async def load(
@@ -228,17 +233,16 @@ def path_hashes(attestable: models.Attestable) -> dict[str, str]:
return dict(attestable.paths)
-async def paths_to_hashes_and_sizes(directory: pathlib.Path) -> tuple[dict[str, str], dict[str, int]]:
- path_to_hash: dict[str, str] = {}
- path_to_size: dict[str, int] = {}
+async def paths_to_hashes_and_sizes(directory: pathlib.Path) -> tuple[dict[safe.RelPath, str], dict[safe.RelPath, int]]:
+ path_to_hash: dict[safe.RelPath, str] = {}
+ path_to_size: dict[safe.RelPath, int] = {}
async for rel_path in util.paths_recursive(directory):
full_path = directory / rel_path
- path_key = str(rel_path)
- if "\\" in path_key:
+ if "\\" in str(rel_path):
# TODO: We should centralise this, and forbid some other characters too
- raise ValueError(f"Backslash in path is forbidden: {path_key}")
- path_to_hash[path_key] = await hashes.compute_file_hash(full_path)
- path_to_size[path_key] = (await aiofiles.os.stat(full_path)).st_size
+ raise ValueError(f"Backslash in path is forbidden: {rel_path!s}")
+ path_to_hash[rel_path] = await hashes.compute_file_hash(full_path)
+ path_to_size[rel_path] = (await aiofiles.os.stat(full_path)).st_size
return path_to_hash, path_to_size
@@ -263,7 +267,7 @@ async def write_checks_data(
result = models.AttestableChecksV2(checks=current)
return result.model_dump_json(indent=2)
- await util.atomic_modify_file(attestable_checks_path(project_key, version_key, revision_number), modify)
+ await util.atomic_modify_file(attestable_checks_path(project_key, version_key, revision_number).path, modify)
async def write_files_data(
@@ -273,10 +277,10 @@ async def write_files_data(
release_policy: dict[str, Any] | None,
uploader_uid: str,
previous: models.Attestable | None,
- path_to_hash: dict[str, str],
- path_to_size: dict[str, int],
- base_path: pathlib.Path,
- classifications: dict[str, str] | None = None,
+ path_to_hash: dict[safe.RelPath, str],
+ path_to_size: dict[safe.RelPath, int],
+ base_path: safe.StatePath,
+ classifications: dict[safe.RelPath, str] | None = None,
) -> None:
result = _generate_files_data(
path_to_hash,
@@ -289,16 +293,16 @@ async def write_files_data(
classifications=classifications,
)
file_path = attestable_path(project_key, version_key, revision_number)
- await util.atomic_write_file(file_path, result.model_dump_json(indent=2))
+ await util.atomic_write_file(file_path.path, result.model_dump_json(indent=2))
checks_file_path = attestable_checks_path(project_key, version_key, revision_number)
- if not checks_file_path.exists():
+ if not checks_file_path.path.exists():
async with aiofiles.open(checks_file_path, "w", encoding="utf-8") as f:
await f.write(models.AttestableChecksV2().model_dump_json(indent=2))
def _compute_hashes_with_attribution( # noqa: C901
- current_hash_to_paths: dict[str, set[str]],
- path_to_size: dict[str, int],
+ current_hash_to_paths: dict[str, set[safe.RelPath]],
+ path_to_size: dict[safe.RelPath, int],
previous: models.Attestable | None,
uploader_uid: str,
revision_number: safe.RevisionNumber,
@@ -317,7 +321,7 @@ def _compute_hashes_with_attribution( # noqa: C901
previous_paths = previous_hash_to_paths.get(hash_ref, set())
sample_path = next(iter(current_paths))
file_size = path_to_size[sample_path]
- current_basenames = {_path_basename(path_key) for path_key in current_paths}
+ current_basenames = {_path_basename(str(path_key)) for path_key in current_paths}
if hash_ref not in new_hashes:
new_hashes[hash_ref] = models.HashEntry(
@@ -342,16 +346,16 @@ def _compute_hashes_with_attribution( # noqa: C901
def _generate_files_data(
- path_to_hash: dict[str, str],
- path_to_size: dict[str, int],
+ path_to_hash: dict[safe.RelPath, str],
+ path_to_size: dict[safe.RelPath, int],
revision_number: safe.RevisionNumber,
release_policy: dict[str, Any] | None,
uploader_uid: str,
previous: models.Attestable | None,
- base_path: pathlib.Path,
- classifications: dict[str, str] | None = None,
+ base_path: safe.StatePath,
+ classifications: dict[safe.RelPath, str] | None = None,
) -> models.AttestableV2:
- current_hash_to_paths: dict[str, set[str]] = {}
+ current_hash_to_paths: dict[str, set[safe.RelPath]] = {}
for path_key, hash_ref in path_to_hash.items():
current_hash_to_paths.setdefault(hash_ref, set()).add(path_key)
@@ -364,7 +368,7 @@ def _generate_files_data(
return models.AttestableV2(
hashes=dict(new_hashes),
paths={
- path_key: models.PathEntryV2(content_hash=hash_ref, classification=classifications[path_key])
+ str(path_key): models.PathEntryV2(content_hash=hash_ref, classification=classifications[path_key])
for path_key, hash_ref in path_to_hash.items()
},
policy=release_policy or {},
diff --git a/atr/classify.py b/atr/classify.py
index 06cba511..e44499e3 100644
--- a/atr/classify.py
+++ b/atr/classify.py
@@ -133,13 +133,15 @@ def archive_marker_counts(stem: str, path: pathlib.PurePath) -> tuple[int, int,
def classify(
- path: pathlib.Path,
- base_path: pathlib.Path | None = None,
+ path: safe.RelPath,
+ base_path: safe.StatePath | None = None,
source_matcher: Callable[[str], bool] | None = None,
binary_matcher: Callable[[str], bool] | None = None,
_project_key: safe.ProjectKey | None = None,
) -> FileType:
- if (path.name in analysis.DISALLOWED_FILENAMES) or (path.suffix in analysis.DISALLOWED_SUFFIXES):
+ if (path.as_path().name in analysis.DISALLOWED_FILENAMES) or (
+ path.as_path().suffix in analysis.DISALLOWED_SUFFIXES
+ ):
return FileType.DISALLOWED
path_str = str(path)
@@ -162,7 +164,7 @@ def classify(
stem = path_str[: search.start()]
if not any(path_str.endswith(suffix) for suffix in detection.QUARANTINE_ARCHIVE_SUFFIXES):
return FileType.BINARY
- return classify_from_counts(*archive_marker_counts(stem, path))
+ return classify_from_counts(*archive_marker_counts(stem, path.as_path()))
def classify_from_counts(source_count: int, binary_count: int, docs_count: int) -> FileType:
@@ -178,7 +180,7 @@ def classify_from_counts(source_count: int, binary_count: int, docs_count: int)
def matchers_from_policy(
source_artifact_paths: list[str],
binary_artifact_paths: list[str],
- base_path: pathlib.Path,
+ base_path: safe.StatePath,
) -> tuple[Callable[[str], bool] | None, Callable[[str], bool] | None]:
# TODO: Arguably this should just go into classify(...)
# Then it could take a release policy or None
diff --git a/atr/detection.py b/atr/detection.py
index 22e73c5c..49bb95a5 100644
--- a/atr/detection.py
+++ b/atr/detection.py
@@ -23,6 +23,7 @@ from typing import Final
import puremagic
import atr.models.attestable as models
+import atr.models.safe as safe
import atr.tarzip as tarzip
# TODO: Widen the range of types checked here
@@ -66,7 +67,7 @@ _COMPOUND_SUFFIXES: Final = tuple(s for s in _EXPECTED if s.count(".") > 1)
_QUARANTINE_NORMALISED_SUFFIXES: Final[dict[str, str]] = {".tgz": ".tar.gz"}
-def check_archive_safety(archive_path: str) -> list[str]:
+def check_archive_safety(archive_path: safe.StatePath) -> list[str]:
errors: list[str] = []
try:
with tarzip.open_archive(archive_path) as archive:
@@ -85,26 +86,28 @@ def check_archive_safety(archive_path: str) -> list[str]:
return errors
-def deduplicate_quarantine_archives(archive_paths: list[str], path_to_hash: dict[str, str]) -> list[tuple[str, str]]:
- seen: dict[tuple[str, str], str] = {}
+def deduplicate_quarantine_archives(
+ archive_paths: list[safe.RelPath], path_to_hash: dict[safe.RelPath, str]
+) -> list[tuple[safe.RelPath, str]]:
+ seen: dict[tuple[str, str], safe.RelPath] = {}
for rel_path in archive_paths:
content_hash = path_to_hash[rel_path]
- basename = _path_basename(rel_path)
+ basename = _path_basename(str(rel_path))
suffix = _quarantine_archive_suffix(basename)
if suffix is None:
continue
key = (content_hash, suffix)
- if (key not in seen) or (rel_path < seen[key]):
+ if (key not in seen) or (str(rel_path) < str(seen[key])):
seen[key] = rel_path
- return [(rel_path, path_to_hash[rel_path]) for rel_path in sorted(seen.values())]
+ return [(rel_path, path_to_hash[rel_path]) for rel_path in sorted(seen.values(), key=str)]
def detect_archives_requiring_quarantine(
- path_to_hash: dict[str, str], previous_attestable: models.Attestable | None
-) -> list[str]:
- quarantine_paths: list[str] = []
+ path_to_hash: dict[safe.RelPath, str], previous_attestable: models.Attestable | None
+) -> list[safe.RelPath]:
+ quarantine_paths: list[safe.RelPath] = []
for path_key, hash_ref in path_to_hash.items():
- basename = _path_basename(path_key)
+ basename = _path_basename(str(path_key))
suffix = _quarantine_archive_suffix(basename)
if suffix is None:
continue
diff --git a/atr/get/checks.py b/atr/get/checks.py
index 261fb9af..a0f870f6 100644
--- a/atr/get/checks.py
+++ b/atr/get/checks.py
@@ -15,7 +15,6 @@
# specific language governing permissions and limitations
# under the License.
-import pathlib
from collections.abc import Callable
from typing import Literal, NamedTuple
@@ -219,10 +218,10 @@ async def selected_revision(
async def _compute_stats( # noqa: C901
release: sql.Release,
- paths: list[pathlib.Path],
+ paths: list[safe.RelPath],
match_ignore: Callable[[sql.CheckResult], bool],
-) -> tuple[dict[pathlib.Path, FileStats], FileStats]:
- per_file: dict[pathlib.Path, dict[str, int]] = {
+) -> tuple[dict[safe.RelPath, FileStats], FileStats]:
+ per_file: dict[safe.RelPath, dict[str, int]] = {
p: {
"file_pass_before": 0,
"file_warn_before": 0,
@@ -252,7 +251,7 @@ async def _compute_stats( # noqa: C901
if not cr.primary_rel_path:
continue
- file_path = pathlib.Path(cr.primary_rel_path)
+ file_path = safe.RelPath(cr.primary_rel_path)
if file_path not in per_file:
continue
@@ -298,8 +297,8 @@ async def _compute_stats( # noqa: C901
def _render_checks_table(
page: htm.Block,
release: sql.Release,
- paths: list[pathlib.Path],
- per_file_stats: dict[pathlib.Path, FileStats],
+ paths: list[safe.RelPath],
+ per_file_stats: dict[safe.RelPath, FileStats],
) -> None:
if not paths:
page.div(".alert.alert-info")["This release candidate does not have any files."]
@@ -331,8 +330,8 @@ def _render_checks_table(
def _render_debug_table(
page: htm.Block,
- paths: list[pathlib.Path],
- per_file_stats: dict[pathlib.Path, FileStats],
+ paths: list[safe.RelPath],
+ per_file_stats: dict[safe.RelPath, FileStats],
) -> None:
# Bootstrap does have striping, but that's for horizontal stripes
# These are vertical stripes, to make it easier to distinguish collections
@@ -410,7 +409,7 @@ def _render_debug_table(
def _render_file_row(
tbody: htm.Block,
release: sql.Release,
- path: pathlib.Path,
+ path: safe.RelPath,
stats: FileStats,
) -> None:
path_str = str(path)
@@ -484,7 +483,7 @@ def _render_file_row(
# <a href="{{ as_url(get.sbom.report, project=project_key, version=version_key, file_path=path) }}"
# class="btn btn-sm btn-outline-secondary">Show SBOM</a>
sbom_btn = None
- if path.suffixes[-2:] == [".cdx", ".json"]:
+ if path.as_path().suffixes[-2:] == [".cdx", ".json"]:
sbom_btn = htpy.a(".btn.btn-sm.btn-outline-secondary", href=sbom_url)["SBOM report"]
download_btn = htpy.a(".btn.btn-sm.btn-outline-secondary", href=download_url)["Download"]
@@ -533,8 +532,8 @@ def _render_ignores_section(page: htm.Block, release: sql.Release) -> None:
def _render_summary(
page: htm.Block,
totals: FileStats,
- paths: list[pathlib.Path],
- per_file_stats: dict[pathlib.Path, FileStats],
+ paths: list[safe.RelPath],
+ per_file_stats: dict[safe.RelPath, FileStats],
) -> None:
files_with_errors = sum(1 for s in per_file_stats.values() if s.file_err_after > 0)
files_with_warnings = sum(1 for s in per_file_stats.values() if (s.file_warn_after > 0) and (s.file_err_after == 0))
diff --git a/atr/get/compose.py b/atr/get/compose.py
index 906ba499..65b740ec 100644
--- a/atr/get/compose.py
+++ b/atr/get/compose.py
@@ -301,7 +301,7 @@ def _render_move_section(max_files_to_show: int = 10) -> htm.Element:
return section.collect()
-async def _sources_and_targets(latest_revision_dir: pathlib.Path) -> tuple[list[pathlib.Path], set[pathlib.Path]]:
+async def _sources_and_targets(latest_revision_dir: safe.StatePath) -> tuple[list[pathlib.Path], set[pathlib.Path]]:
source_items_rel: list[pathlib.Path] = []
target_dirs: set[pathlib.Path] = {pathlib.Path(".")}
diff --git a/atr/get/distribution.py b/atr/get/distribution.py
index 32b4d9bd..68135180 100644
--- a/atr/get/distribution.py
+++ b/atr/get/distribution.py
@@ -29,10 +29,10 @@ import atr.models.sql as sql
import atr.post as post
import atr.render as render
import atr.shared as shared
+import atr.tasks.gha as gha
import atr.template as template
import atr.util as util
import atr.web as web
-from atr.tasks import gha
@get.typed
diff --git a/atr/get/download.py b/atr/get/download.py
index 88915530..2ba06751 100644
--- a/atr/get/download.py
+++ b/atr/get/download.py
@@ -247,15 +247,15 @@ async def _generate_file_url_list(release: sql.Release) -> str:
async def _list(
- original_path: pathlib.Path, full_path: pathlib.Path, project_key: str, version_key: str, file_path: str
+ original_path: pathlib.Path, safe_path: safe.StatePath, project_key: str, version_key: str, file_path: str
) -> web.Response:
# Build a list of files in the directory
files: list[pathlib.Path] = []
- for file in await aiofiles.os.listdir(full_path):
+ for file in await aiofiles.os.listdir(safe_path):
file_in_dir = pathlib.Path(file)
# Include subdirectories in listing
- is_file = await aiofiles.os.path.isfile(full_path / file_in_dir)
- is_dir = await aiofiles.os.path.isdir(full_path / file_in_dir)
+ is_file = await aiofiles.os.path.isfile(safe_path / file_in_dir)
+ is_dir = await aiofiles.os.path.isdir(safe_path / file_in_dir)
if is_file or is_dir:
files.append(file_in_dir)
files.sort()
@@ -283,7 +283,7 @@ async def _list(
version_key=version_key,
file_path=relative_path_str,
)
- display_name = f"{item_in_dir}/" if await aiofiles.os.path.isdir(full_path / item_in_dir) else str(item_in_dir)
+ display_name = f"{item_in_dir}/" if await aiofiles.os.path.isdir(safe_path / item_in_dir) else str(item_in_dir)
div.a(href=link_url)[display_name]
html.body[div.collect(separator=htm.br)]
response_body = html.collect()
diff --git a/atr/get/file.py b/atr/get/file.py
index 75e3833d..ed6df294 100644
--- a/atr/get/file.py
+++ b/atr/get/file.py
@@ -46,21 +46,15 @@ async def selected(
"""
release = await session.release(project_key, version_key, phase=None)
- revision_number = release.latest_revision_number
+ revision_number = release.safe_latest_revision_number
file_stats = []
if release.phase == sql.ReleasePhase.RELEASE:
- file_stats = [
- stat async for stat in util.content_list(paths.get_finished_dir(), str(project_key), str(version_key))
- ]
- elif revision_number is not None:
+ file_stats = [stat async for stat in util.content_list(paths.get_finished_dir(), project_key, version_key)]
+ else:
file_stats = [
stat
- async for stat in util.content_list(
- paths.get_unfinished_dir(), str(project_key), str(version_key), revision_number
- )
+ async for stat in util.content_list(paths.get_unfinished_dir(), project_key, version_key, revision_number)
]
- else:
- raise ValueError("No revision number found for unfinished release")
file_stats.sort(key=lambda fs: fs.path)
block = htm.Block()
@@ -147,11 +141,10 @@ async def selected_path(
URL: /file/<project_key>/<version_key>/<path:file_path>
View the content of a specific file in a release (any phase).
"""
- validated_path = file_path.as_path()
release = await session.release(project_key, version_key, phase=None)
_max_view_size = 512 * 1024
- full_path = paths.release_directory(release) / validated_path
+ full_path = paths.release_directory(release) / file_path
content_listing = await util.archive_listing(full_path)
content, is_text, is_truncated, error_message = await util.read_file_for_viewer(full_path, _max_view_size)
@@ -162,7 +155,7 @@ async def selected_path(
block.a(href=back_url, class_="atr-back-link")[f"← Back to {phase_name} files"]
block.div(".p-3.mt-4.mb-4.bg-light.border.rounded")[
- htm.h2(".mt-0")[f"Viewing file: {validated_path}"],
+ htm.h2(".mt-0")[f"Viewing file: {file_path}"],
htm.p(".mb-0")[htm.strong["Release:"], " ", release.key],
]
@@ -187,7 +180,7 @@ async def selected_path(
block.div(".alert.alert-secondary")["No content available for this file."]
return await template.blank(
- f"View {release.project.short_display_name}/{release.version}/{validated_path}", content=block.collect()
+ f"View {release.project.short_display_name}/{release.version}/{file_path}", content=block.collect()
)
diff --git a/atr/get/finish.py b/atr/get/finish.py
index 831b7d42..1fe1a6c1 100644
--- a/atr/get/finish.py
+++ b/atr/get/finish.py
@@ -17,6 +17,7 @@
import dataclasses
+import os
import pathlib
from collections.abc import Sequence
from typing import Literal
@@ -108,7 +109,7 @@ async def selected(
)
-async def _analyse_rc_tags(latest_revision_dir: pathlib.Path) -> RCTagAnalysisResult:
+async def _analyse_rc_tags(latest_revision_dir: os.PathLike) -> RCTagAnalysisResult:
r = RCTagAnalysisResult(
affected_paths_preview=[],
affected_count=0,
@@ -134,9 +135,9 @@ async def _analyse_rc_tags(latest_revision_dir: pathlib.Path) -> RCTagAnalysisRe
async def _deletable_choices(
- latest_revision_dir: pathlib.Path, target_dirs: set[pathlib.Path]
+ latest_revision_dir: safe.StatePath, target_dirs: set[safe.RelPath]
) -> list[tuple[str, str]]:
- empty_deletable_dirs: list[pathlib.Path] = []
+ empty_deletable_dirs: list[safe.RelPath] = []
if await aiofiles.os.path.exists(latest_revision_dir):
for d_rel in target_dirs:
if d_rel == pathlib.Path("."):
@@ -497,16 +498,16 @@ def _render_task(task: sql.Task) -> htm.Element:
]
-async def _sources_and_targets(latest_revision_dir: pathlib.Path) -> tuple[list[pathlib.Path], set[pathlib.Path]]:
+async def _sources_and_targets(latest_revision_dir: safe.StatePath) -> tuple[list[pathlib.Path], set[safe.RelPath]]:
source_items_rel: list[pathlib.Path] = []
- target_dirs: set[pathlib.Path] = {pathlib.Path(".")}
+ target_dirs: set[safe.RelPath] = {safe.RelPath(".")}
async for item_rel_path in util.paths_recursive_all(latest_revision_dir):
current_parent = item_rel_path.parent
source_items_rel.append(item_rel_path)
while True:
- target_dirs.add(current_parent)
+ target_dirs.add(safe.RelPath.from_path(current_parent))
if current_parent == pathlib.Path("."):
break
current_parent = current_parent.parent
@@ -515,6 +516,6 @@ async def _sources_and_targets(latest_revision_dir: pathlib.Path) -> tuple[list[
if await aiofiles.os.path.isfile(item_abs_path):
pass
elif await aiofiles.os.path.isdir(item_abs_path):
- target_dirs.add(item_rel_path)
+ target_dirs.add(safe.RelPath.from_path(item_rel_path))
return source_items_rel, target_dirs
diff --git a/atr/get/published.py b/atr/get/published.py
index 151b658a..4108cc7e 100644
--- a/atr/get/published.py
+++ b/atr/get/published.py
@@ -56,7 +56,7 @@ async def root(session: web.Committer, _published: Literal["published/"]) -> web
return await _path(session, "")
-async def _directory_listing(full_path: pathlib.Path, current_path: str) -> web.ElementResponse:
+async def _directory_listing(full_path: safe.StatePath, current_path: str) -> web.ElementResponse:
html = htm.Block(htm.html)
html.title[f"Index of /{current_path}"]
html.style["body { margin: 1rem; }"]
@@ -67,7 +67,7 @@ async def _directory_listing(full_path: pathlib.Path, current_path: str) -> web.
return web.ElementResponse(html.collect())
-async def _directory_listing_pre(full_path: pathlib.Path, current_path: str, pre: htm.Block) -> None:
+async def _directory_listing_pre(full_path: safe.StatePath, current_path: str, pre: htm.Block) -> None:
if current_path:
parent_path = pathlib.Path(current_path).parent
parent_url_path = str(parent_path) if (str(parent_path) != ".") else ""
@@ -105,7 +105,7 @@ async def _directory_listing_pre(full_path: pathlib.Path, current_path: str, pre
pre.text("\n")
-async def _file_content(full_path: pathlib.Path) -> web.QuartResponse:
+async def _file_content(full_path: safe.StatePath) -> web.QuartResponse:
return await quart.send_file(
full_path,
as_attachment=True,
diff --git a/atr/get/revisions.py b/atr/get/revisions.py
index 2cae3223..86056cb4 100644
--- a/atr/get/revisions.py
+++ b/atr/get/revisions.py
@@ -16,7 +16,6 @@
# under the License.
import asyncio
-import pathlib
from typing import Literal
import aiofiles.os
@@ -44,9 +43,9 @@ import atr.web as web
class FilesDiff(schema.Strict):
- added: list[pathlib.Path]
- removed: list[pathlib.Path]
- modified: list[pathlib.Path]
+ added: list[safe.RelPath]
+ removed: list[safe.RelPath]
+ modified: list[safe.RelPath]
@get.typed
@@ -87,7 +86,7 @@ async def selected(
revisions_list: list[sql.Revision] = list(revisions_result.scalars().all())
revision_history = []
- loop_prev_revision_files: set[pathlib.Path] | None = None
+ loop_prev_revision_files: set[safe.RelPath] | None = None
loop_prev_revision_number: str | None = None
for current_db_revision in revisions_list:
current_files_for_diff, files_diff_for_current = await _revision_files_diff(
@@ -303,17 +302,17 @@ def _render_tag_form(body: htm.Block, revision: sql.Revision, project_key: str,
async def _revision_files_diff(
revision_number: str,
- release_dir: pathlib.Path,
- prev_revision_files: set[pathlib.Path] | None,
+ release_dir: safe.StatePath,
+ prev_revision_files: set[safe.RelPath] | None,
prev_revision_number: str | None,
-) -> tuple[set[pathlib.Path], FilesDiff]:
+) -> tuple[set[safe.RelPath], FilesDiff]:
"""Process a single revision and calculate its diff from the previous."""
latest_revision_dir = release_dir / revision_number
latest_revision_files = {path async for path in util.paths_recursive(latest_revision_dir)}
- added_files: set[pathlib.Path] = set()
- removed_files: set[pathlib.Path] = set()
- modified_files: set[pathlib.Path] = set()
+ added_files: set[safe.RelPath] = set()
+ removed_files: set[safe.RelPath] = set()
+ modified_files: set[safe.RelPath] = set()
if (prev_revision_files is not None) and (prev_revision_number is not None):
added_files = latest_revision_files - prev_revision_files
@@ -325,7 +324,7 @@ async def _revision_files_diff(
mtime_tasks = []
for common_file in common_files:
- async def check_mtime(file_path: pathlib.Path) -> tuple[pathlib.Path, bool]:
+ async def check_mtime(file_path: safe.RelPath) -> tuple[safe.RelPath, bool]:
try:
parent_mtime = await aiofiles.os.path.getmtime(parent_revision_dir / file_path)
latest_mtime = await aiofiles.os.path.getmtime(latest_revision_dir / file_path)
diff --git a/atr/get/test.py b/atr/get/test.py
index 822d150b..02951117 100644
--- a/atr/get/test.py
+++ b/atr/get/test.py
@@ -16,7 +16,6 @@
# under the License.
import json
-import pathlib
from typing import Literal
import aiofiles
@@ -132,14 +131,14 @@ async def test_merge(
async with storage.write(session) as write_n:
wacp_n = await write_n.as_project_committee_participant(project_key)
- async def modify_new(path_new: pathlib.Path, _old_rev_new: sql.Revision | None) -> None:
+ async def modify_new(path_new: safe.StatePath, _old_rev_new: sql.Revision | None) -> None:
async with aiofiles.open(path_new / "from_new.txt", "w") as f:
await f.write("new content")
async with storage.write(session) as write_p:
wacp_p = await write_p.as_project_committee_participant(project_key)
- async def modify_prior(path_prior: pathlib.Path, _old_rev_prior: sql.Revision | None) -> None:
+ async def modify_prior(path_prior: safe.StatePath, _old_rev_prior: sql.Revision | None) -> None:
async with aiofiles.open(path_prior / "from_prior.txt", "w") as f:
await f.write("prior content")
diff --git a/atr/hashes.py b/atr/hashes.py
index 8f3ef46e..6590ffe5 100644
--- a/atr/hashes.py
+++ b/atr/hashes.py
@@ -15,6 +15,7 @@
# specific language governing permissions and limitations
# under the License.
import hashlib
+import os
import pathlib
from typing import Any, Final
@@ -33,7 +34,7 @@ def compute_dict_hash(to_hash: dict[Any, Any]) -> str:
return f"blake3:{hasher.hexdigest()}"
-async def compute_file_hash(path: str | pathlib.Path) -> str:
+async def compute_file_hash(path: str | os.PathLike) -> str:
path = pathlib.Path(path)
hasher = blake3.blake3()
async with aiofiles.open(path, "rb") as f:
@@ -42,7 +43,7 @@ async def compute_file_hash(path: str | pathlib.Path) -> str:
return f"blake3:{hasher.hexdigest()}"
-def compute_file_hash_sync(path: str | pathlib.Path) -> str:
+def compute_file_hash_sync(path: str | os.PathLike) -> str:
path = pathlib.Path(path)
hasher = blake3.blake3()
with open(path, "rb") as f:
diff --git a/atr/models/safe.py b/atr/models/safe.py
index 60f54b33..3e86f3f8 100644
--- a/atr/models/safe.py
+++ b/atr/models/safe.py
@@ -89,14 +89,23 @@ class SafeType:
class StatePath:
- """An absolute path within the managed storage system."""
+ """An absolute path within the managed storage system.
- __slots__ = ("_path",)
+ Tracks the managed root directory and ensures all derived paths remain within it.
+ The initial path (from get_*_dir) is the root; paths created via / carry it forward.
+ """
- def __init__(self, path: pathlib.Path) -> None:
+ __slots__ = ("_path", "_root")
+
+ def __init__(self, path: pathlib.Path, root: pathlib.Path | None = None) -> None:
if not path.is_absolute():
raise ValueError("Path must be absolute")
+ resolved = path.resolve()
+ managed_root = (root or path).resolve()
+ if not resolved.is_relative_to(managed_root):
+ raise ValueError(f"Path {resolved} is not within managed root {managed_root}")
self._path = path
+ self._root = root or path
def __fspath__(self) -> str:
return str(self._path)
@@ -104,13 +113,63 @@ class StatePath:
def __str__(self) -> str:
return str(self._path)
- def __truediv__(self, other: str | pathlib.Path | SafeType) -> pathlib.Path:
- return self._path / str(other) if isinstance(other, SafeType) else self._path / other
+ def __truediv__(self, other: str | pathlib.Path | SafeType) -> StatePath:
+ validated = other if isinstance(other, SafeType) else RelPath(str(other))
+ return StatePath(self._path / validated, self._root)
+
+ def __eq__(self, other: object) -> bool:
+ if isinstance(other, self.__class__):
+ return self._path == other._path
+ return NotImplemented
+
+ @property
+ def parent(self) -> StatePath:
+ """Root-safe parent - cannot traverse outside the original root path"""
+ return StatePath(self._path.parent, self._root)
@property
def path(self) -> pathlib.Path:
return self._path
+ @property
+ def root(self) -> pathlib.Path:
+ return self._root
+
+ @property
+ def name(self) -> str:
+ return self._path.name
+
+ @classmethod
+ def __get_pydantic_core_schema__(cls, _source_type: Any, _handler: Any) -> Any:
+ import pydantic_core.core_schema as core_schema
+
+ def _validate(v: Any) -> Any:
+ if isinstance(v, str):
+ return cls(pathlib.Path(v))
+ if isinstance(v, dict) and v.get("__type__") == "StatePath":
+ return cls(pathlib.Path(v["path"]), pathlib.Path(v["root"]))
+ return v
+
+ def _serialize(v: Any) -> Any:
+ return {"__type__": "StatePath", "path": str(v.path), "root": str(v.root)}
+
+ return core_schema.no_info_plain_validator_function(
+ _validate,
+ serialization=core_schema.plain_serializer_function_ser_schema(_serialize),
+ )
+
+ @classmethod
+ def __get_pydantic_json_schema__(cls, _core_schema: Any, _handler: Any) -> dict[str, Any]:
+ return {
+ "type": "object",
+ "properties": {
+ "__type__": {"type": "string", "const": "StatePath"},
+ "path": {"type": "string", "format": "path"},
+ "root": {"type": "string", "format": "path"},
+ },
+ "required": ["__type__", "path", "root"],
+ }
+
class Alphanumeric(SafeType):
@classmethod
@@ -168,6 +227,10 @@ class RelPath(SafeType):
"""Return the validated path as a pathlib.Path."""
return pathlib.Path(self._value)
+ @classmethod
+ def from_path(cls, value: pathlib.Path) -> RelPath:
+ return cls(str(value))
+
def append(self, path: str | pathlib.Path) -> RelPath:
return RelPath(f"{self!s}/{path!s}")
@@ -177,6 +240,26 @@ class RelPath(SafeType):
def removeprefix(self, prefix: str):
return RelPath(self._value.removeprefix(prefix))
+ def __lt__(self, other):
+ if not isinstance(other, RelPath):
+ return NotImplemented
+ return self.as_path() < other.as_path()
+
+ def __le__(self, other):
+ if not isinstance(other, RelPath):
+ return NotImplemented
+ return self.as_path() <= other.as_path()
+
+ def __gt__(self, other):
+ if not isinstance(other, RelPath):
+ return NotImplemented
+ return self.as_path() > other.as_path()
+
+ def __ge__(self, other):
+ if not isinstance(other, RelPath):
+ return NotImplemented
+ return self.as_path() >= other.as_path()
+
class RevisionNumber(Numeric):
"""A revision number that has been validated for safety."""
diff --git a/atr/models/sql.py b/atr/models/sql.py
index 09c711fb..6346743a 100644
--- a/atr/models/sql.py
+++ b/atr/models/sql.py
@@ -305,11 +305,12 @@ class UTCDateTime(sqlalchemy.types.TypeDecorator):
class SafeJSON(sqlalchemy.types.TypeDecorator):
- """JSON column that serialises SafeType values to plain strings.
+ """JSON column that serialises SafeType and StatePath values.
Use instead of sqlalchemy.JSON whenever the stored value may contain
atr.models.safe.SafeType instances (which are not JSON-serialisable by
- the standard library encoder).
+ the standard library encoder) or atr.models.safe.StatePath instances
+ (which include a managed root that must survive the round-trip).
"""
impl = sqlalchemy.JSON
@@ -323,13 +324,17 @@ class SafeJSON(sqlalchemy.types.TypeDecorator):
return _safe_json_encode(value)
def process_result_value(self, value, dialect):
- return value
+ if value is None:
+ return None
+ return _safe_json_decode(value)
def _safe_json_encode(value: Any) -> Any:
- """Recursively convert SafeType instances to plain strings."""
+ """Recursively convert SafeType/StatePath instances to JSON-serialisable form."""
from . import safe
+ if isinstance(value, safe.StatePath):
+ return {"__type__": "StatePath", "path": str(value.path), "root": str(value.root)}
if isinstance(value, safe.SafeType):
return str(value)
if isinstance(value, dict):
@@ -342,6 +347,24 @@ def _safe_json_encode(value: Any) -> Any:
return value
+def _safe_json_decode(value: Any) -> Any:
+ """
+ Reconstruct StatePath instances from tagged dicts produced by _safe_json_encode.
+ Other types are handled cleanly by Pydantic so just return the value
+ """
+ import pathlib
+
+ from . import safe
+
+ if isinstance(value, dict):
+ if value.get("__type__") == "StatePath":
+ return safe.StatePath(pathlib.Path(value["path"]), pathlib.Path(value["root"]))
+ return {k: _safe_json_decode(v) for k, v in value.items()}
+ if isinstance(value, list):
+ return [_safe_json_decode(v) for v in value]
+ return value
+
+
class ResultsJSON(sqlalchemy.types.TypeDecorator):
impl = sqlalchemy.JSON
cache_ok = True
@@ -486,6 +509,11 @@ class Task(sqlmodel.SQLModel, table=True):
if isinstance(self.completed, str):
self.completed = datetime.datetime.fromisoformat(self.completed.rstrip("Z"))
+ @property
+ def safe_primary_rel_path(self) -> safe.RelPath | None:
+ """Get the typesafe validated relative path for the task, if set."""
+ return safe.RelPath(self.primary_rel_path) if self.primary_rel_path else None
+
# Create an index on status and added for efficient task claiming
__table_args__ = (
sqlalchemy.Index("ix_task_status_added", "status", "added"),
@@ -1058,6 +1086,11 @@ class CheckResult(sqlmodel.SQLModel, table=True):
inputs_hash: str | None = sqlmodel.Field(default=None, index=True, **example("blake3:7f83b1657ff1fc..."))
cached: bool = sqlmodel.Field(default=False, **example(False))
+ @property
+ def safe_primary_rel_path(self) -> safe.RelPath | None:
+ """Get the typesafe validated relative path for the check result, if set."""
+ return safe.RelPath(self.primary_rel_path) if self.primary_rel_path else None
+
class CheckResultIgnore(sqlmodel.SQLModel, table=True):
id: int = sqlmodel.Field(default=None, primary_key=True, **example(123))
diff --git a/atr/paths.py b/atr/paths.py
index 6314489e..2024b70b 100644
--- a/atr/paths.py
+++ b/atr/paths.py
@@ -24,7 +24,7 @@ import atr.models.sql as sql
def base_path_for_revision(
project_key: safe.ProjectKey, version_key: safe.VersionKey, revision: safe.RevisionNumber
-) -> pathlib.Path:
+) -> safe.StatePath:
return get_unfinished_dir() / project_key / version_key / revision
@@ -44,7 +44,7 @@ def get_finished_dir() -> safe.StatePath:
return safe.StatePath(pathlib.Path(config.get().FINISHED_STORAGE_DIR))
-def get_finished_dir_for(project_key: safe.ProjectKey, version_key: safe.VersionKey) -> pathlib.Path:
+def get_finished_dir_for(project_key: safe.ProjectKey, version_key: safe.VersionKey) -> safe.StatePath:
return get_finished_dir() / project_key / version_key
@@ -63,24 +63,24 @@ def get_unfinished_dir() -> safe.StatePath:
def get_unfinished_dir_for(
project_key: safe.ProjectKey, version_key: safe.VersionKey, revision: safe.RevisionNumber
-) -> pathlib.Path:
+) -> safe.StatePath:
return get_unfinished_dir() / project_key / version_key / revision
-def get_upload_staging_dir(session_token: str) -> pathlib.Path:
+def get_upload_staging_dir(session_token: str) -> safe.StatePath:
if not session_token.isalnum():
raise ValueError("Invalid session token")
return get_tmp_dir() / "upload-staging" / session_token
-def quarantine_directory(quarantined: sql.Quarantined) -> pathlib.Path:
+def quarantine_directory(quarantined: sql.Quarantined) -> safe.StatePath:
if not quarantined.token.isalnum():
raise ValueError("Invalid quarantine token")
release = quarantined.release
return get_quarantined_dir() / release.project_key / release.version / quarantined.token
-def release_directory(release: sql.Release) -> pathlib.Path:
+def release_directory(release: sql.Release) -> safe.StatePath:
"""Return the absolute path to the directory containing the active files for a given release phase."""
latest_revision_number = release.latest_revision_number
if (release.phase == sql.ReleasePhase.RELEASE) or (latest_revision_number is None):
@@ -88,7 +88,7 @@ def release_directory(release: sql.Release) -> pathlib.Path:
return release_directory_base(release) / latest_revision_number
-def release_directory_base(release: sql.Release) -> pathlib.Path:
+def release_directory_base(release: sql.Release) -> safe.StatePath:
"""Determine the filesystem directory for a given release based on its phase."""
phase = release.phase
project_key = release.project.key
@@ -108,7 +108,7 @@ def release_directory_base(release: sql.Release) -> pathlib.Path:
return base_dir / project_key / version_key
-def release_directory_revision(release: sql.Release) -> pathlib.Path | None:
+def release_directory_revision(release: sql.Release) -> safe.StatePath | None:
"""Return the path to the directory containing the active files for a given release phase."""
path_project = release.project.key
path_version = release.version
@@ -127,7 +127,7 @@ def release_directory_revision(release: sql.Release) -> pathlib.Path | None:
return path
-def release_directory_version(release: sql.Release) -> pathlib.Path:
+def release_directory_version(release: sql.Release) -> safe.StatePath:
"""Return the path to the directory containing the active files for a given release phase."""
path_project = release.project.key
path_version = release.version
@@ -146,5 +146,5 @@ def release_directory_version(release: sql.Release) -> pathlib.Path:
def revision_path_for_file(
project_key: safe.ProjectKey, version_key: safe.VersionKey, revision: safe.RevisionNumber, file_name: str
-) -> pathlib.Path:
+) -> safe.StatePath:
return base_path_for_revision(project_key, version_key, revision) / file_name
diff --git a/atr/post/draft.py b/atr/post/draft.py
index 05518ae9..00917f26 100644
--- a/atr/post/draft.py
+++ b/atr/post/draft.py
@@ -17,7 +17,7 @@
from __future__ import annotations
-from typing import TYPE_CHECKING, Literal
+from typing import Literal
import aiofiles.os
import asfquart.base as base
@@ -36,9 +36,6 @@ import atr.storage.types as types
import atr.util as util
import atr.web as web
-if TYPE_CHECKING:
- import pathlib
-
@post.typed
async def cache_reset(
@@ -273,7 +270,7 @@ async def sbomconvert(
async with storage.write(session) as write:
wacp = await write.as_project_committee_participant(project_key)
- async def modify(path: pathlib.Path, old_rev: sql.Revision | None) -> None:
+ async def modify(path: safe.StatePath, old_rev: sql.Revision | None) -> None:
path_in_new_revision = path / rel_path
sbom_path_rel = rel_path.with_suffix(".cdx.json").name
sbom_path_in_new_revision = path / rel_path.parent / sbom_path_rel
@@ -296,8 +293,8 @@ async def sbomconvert(
project_key,
version_key,
old_rev.safe_number,
- str(path_in_new_revision),
- str(sbom_path_in_new_revision),
+ path_in_new_revision,
+ sbom_path_in_new_revision,
)
success = await interaction.wait_for_task(sbom_task)
if not success:
@@ -359,7 +356,7 @@ async def sbomgen(
async with storage.write(session) as write:
wacp = await write.as_project_committee_participant(project_key)
- async def modify(path: pathlib.Path, old_rev: sql.Revision | None) -> None:
+ async def modify(path: safe.StatePath, old_rev: sql.Revision | None) -> None:
path_in_new_revision = path / rel_path
sbom_path_rel = rel_path.with_suffix(rel_path.suffix + ".cdx.json").name
sbom_path_in_new_revision = path / rel_path.parent / sbom_path_rel
@@ -381,7 +378,7 @@ async def sbomgen(
sbom_task = await wacp.sbom.generate_cyclonedx(
project_key,
version_key,
- old_rev.number,
+ old_rev.safe_number,
path_in_new_revision,
sbom_path_in_new_revision,
)
diff --git a/atr/post/upload.py b/atr/post/upload.py
index 3271106b..f6406116 100644
--- a/atr/post/upload.py
+++ b/atr/post/upload.py
@@ -77,11 +77,11 @@ async def finalise( # noqa: C901
number_of_files = len(staged_files)
description = f"Upload of {util.plural(number_of_files, 'file')} through web interface"
- async def modify(path: pathlib.Path, _old_rev: sql.Revision | None) -> None:
+ async def modify(path: safe.StatePath, _old_rev: sql.Revision | None) -> None:
for filename in staged_files:
src = staging_dir / filename
dst = path / filename
- await aioshutil.move(str(src), str(dst))
+ await aioshutil.move(src, dst)
result = await wacp.revision.create_revision_with_quarantine(
project_key,
diff --git a/atr/sbom/osv.py b/atr/sbom/osv.py
index a8f658f9..0a7dffa5 100644
--- a/atr/sbom/osv.py
+++ b/atr/sbom/osv.py
@@ -179,13 +179,11 @@ def _component_purl_with_version(component: Component) -> str | None:
if component.version is None:
return None
version = component.version.strip()
- if not version:
+ if (not version) or version == "UNKNOWN":
return None
- # Clone the purl so we don't affect the component definition
- purl = str(component.purl)
- new_purl = PackageURL.from_string(purl)
- new_purl.version = version
- return str(new_purl)
+ # Make a new purl so we don't affect the component definition
+ purl = PackageURL.from_string(str(component.purl))
+ return str(PackageURL(purl.type, purl.namespace, purl.name, version, purl.qualifiers, purl.subpath))
async def _fetch_vulnerabilities_for_batch(
diff --git a/atr/server.py b/atr/server.py
index 72981880..b724c511 100644
--- a/atr/server.py
+++ b/atr/server.py
@@ -181,15 +181,15 @@ def _app_dirs_setup(state_dir_str: str, hot_reload: bool) -> None:
pathlib.Path(state_dir_str) / "runtime",
pathlib.Path(state_dir_str) / "secrets" / "curated",
pathlib.Path(state_dir_str) / "secrets" / "generated",
- paths.get_archives_dir(),
- paths.get_downloads_dir(),
- paths.get_finished_dir(),
- paths.get_quarantined_dir(),
- paths.get_tmp_dir(),
- paths.get_unfinished_dir(),
+ pathlib.Path(paths.get_archives_dir()),
+ pathlib.Path(paths.get_downloads_dir()),
+ pathlib.Path(paths.get_finished_dir()),
+ pathlib.Path(paths.get_quarantined_dir()),
+ pathlib.Path(paths.get_tmp_dir()),
+ pathlib.Path(paths.get_unfinished_dir()),
]
- archives_dir = paths.get_archives_dir()
- unfinished_dir = paths.get_unfinished_dir()
+ archives_dir = pathlib.Path(paths.get_archives_dir())
+ unfinished_dir = pathlib.Path(paths.get_unfinished_dir())
for directory in directories_to_ensure:
directory.mkdir(parents=True, exist_ok=True)
# Some directories need custom permissions
@@ -1005,7 +1005,7 @@ async def _reset_request_log_context():
def _set_file_permissions_to_read_only() -> None:
"""Set permissions of all files in the unfinished and finished directories to read only."""
# TODO: After a migration period, incorrect permissions should be an error
- directories = [paths.get_unfinished_dir(), paths.get_finished_dir()]
+ directories = [pathlib.Path(paths.get_unfinished_dir()), pathlib.Path(paths.get_finished_dir())]
fixed_count = 0
for directory in directories:
if not directory.exists():
diff --git a/atr/shared/distribution.py b/atr/shared/distribution.py
index f8046a3d..15f6826c 100644
--- a/atr/shared/distribution.py
+++ b/atr/shared/distribution.py
@@ -31,8 +31,8 @@ import atr.models.basic as basic
import atr.models.distribution as distribution
import atr.models.safe as safe
import atr.models.sql as sql
+import atr.storage.outcome as outcome
import atr.util as util
-from atr.storage import outcome
# async def __json_from_maven_cdn(
# self, api_url: str, group_id: str, artifact_id: str, version: str
diff --git a/atr/ssh.py b/atr/ssh.py
index d011649b..228f6507 100644
--- a/atr/ssh.py
+++ b/atr/ssh.py
@@ -22,7 +22,6 @@ import asyncio.subprocess
import datetime
import glob
import os
-import pathlib
import stat
import string
import time
@@ -580,7 +579,7 @@ async def _step_07b_process_validated_rsync_write(
async with storage.write(asf_uid) as write:
wacp = await write.as_project_committee_participant(project_key)
- async def modify(path: pathlib.Path, old_rev: sql.Revision | None) -> None:
+ async def modify(path: safe.StatePath, old_rev: sql.Revision | None) -> None:
nonlocal exit_status
if old_rev is not None:
log.info(f"Using old revision {old_rev.number} and interim path {path}")
diff --git a/atr/storage/readers/releases.py b/atr/storage/readers/releases.py
index e24ca802..09af9e21 100644
--- a/atr/storage/readers/releases.py
+++ b/atr/storage/readers/releases.py
@@ -19,7 +19,6 @@
from __future__ import annotations
import dataclasses
-import pathlib
import atr.classify as classify
import atr.db as db
@@ -55,7 +54,7 @@ class GeneralPublic:
self.__data = data
self.__asf_uid = read.authorisation.asf_uid
- async def path_info(self, release: sql.Release, all_paths: list[pathlib.Path]) -> types.PathInfo | None:
+ async def path_info(self, release: sql.Release, all_paths: list[safe.RelPath]) -> types.PathInfo | None:
info = types.PathInfo()
latest_revision_number = release.latest_revision_number
if latest_revision_number is None:
@@ -84,8 +83,8 @@ class GeneralPublic:
def __accumulate_results(
self,
- results: dict[pathlib.Path, list[sql.CheckResult]],
- paths_set: set[pathlib.Path],
+ results: dict[safe.RelPath, list[sql.CheckResult]],
+ paths_set: set[safe.RelPath],
checker_data: dict[str, CheckerAccumulator],
kind: str,
) -> None:
@@ -107,7 +106,7 @@ class GeneralPublic:
acc.failure += 1
acc.failure_files[path_str] = acc.failure_files.get(path_str, 0) + 1
- def __compute_checker_stats(self, info: types.PathInfo, paths: list[pathlib.Path]) -> None:
+ def __compute_checker_stats(self, info: types.PathInfo, paths: list[safe.RelPath]) -> None:
paths_set = set(paths)
checker_data: dict[str, CheckerAccumulator] = {}
@@ -154,8 +153,8 @@ class GeneralPublic:
async def __blocker(self, cs: types.ChecksSubset) -> None:
blocker = [cr for cr in cs.checks if cr.status == sql.CheckResultStatus.BLOCKER]
for result in blocker:
- if primary_rel_path := result.primary_rel_path:
- cs.info.errors.setdefault(pathlib.Path(primary_rel_path), []).append(result)
+ if path := result.safe_primary_rel_path:
+ cs.info.errors.setdefault(path, []).append(result)
async def __errors(self, cs: types.ChecksSubset) -> None:
errors = [cr for cr in cs.checks if cr.status == sql.CheckResultStatus.FAILURE]
@@ -163,15 +162,15 @@ class GeneralPublic:
if cs.match_ignore(error):
cs.info.ignored_errors.append(error)
continue
- if primary_rel_path := error.primary_rel_path:
- cs.info.errors.setdefault(pathlib.Path(primary_rel_path), []).append(error)
+ if path := error.safe_primary_rel_path:
+ cs.info.errors.setdefault(path, []).append(error)
async def __successes(self, cs: types.ChecksSubset) -> None:
successes = [cr for cr in cs.checks if cr.status == sql.CheckResultStatus.SUCCESS]
for success in successes:
# Successes cannot be ignored
- if primary_rel_path := success.primary_rel_path:
- cs.info.successes.setdefault(pathlib.Path(primary_rel_path), []).append(success)
+ if path := success.safe_primary_rel_path:
+ cs.info.successes.setdefault(path, []).append(success)
async def __warnings(self, cs: types.ChecksSubset) -> None:
warnings = [cr for cr in cs.checks if cr.status == sql.CheckResultStatus.WARNING]
@@ -179,5 +178,5 @@ class GeneralPublic:
if cs.match_ignore(warning):
cs.info.ignored_warnings.append(warning)
continue
- if primary_rel_path := warning.primary_rel_path:
- cs.info.warnings.setdefault(pathlib.Path(primary_rel_path), []).append(warning)
+ if path := warning.safe_primary_rel_path:
+ cs.info.warnings.setdefault(path, []).append(warning)
diff --git a/atr/storage/types.py b/atr/storage/types.py
index 8dbb5e56..c44067d4 100644
--- a/atr/storage/types.py
+++ b/atr/storage/types.py
@@ -18,10 +18,10 @@
import dataclasses
import datetime
import enum
-import pathlib
from collections.abc import Callable
import atr.classify as classify
+import atr.models.safe as safe
import atr.models.schema as schema
import atr.models.sql as sql
import atr.storage.outcome as outcome
@@ -66,12 +66,12 @@ class LinkedCommittee:
class PathInfo(schema.Strict):
checker_stats: list[CheckerStats] = schema.factory(list)
- errors: dict[pathlib.Path, list[sql.CheckResult]] = schema.factory(dict)
- file_types: dict[pathlib.Path, classify.FileType] = schema.factory(dict)
+ errors: dict[safe.RelPath, list[sql.CheckResult]] = schema.factory(dict)
+ file_types: dict[safe.RelPath, classify.FileType] = schema.factory(dict)
ignored_errors: list[sql.CheckResult] = schema.factory(list)
ignored_warnings: list[sql.CheckResult] = schema.factory(list)
- successes: dict[pathlib.Path, list[sql.CheckResult]] = schema.factory(dict)
- warnings: dict[pathlib.Path, list[sql.CheckResult]] = schema.factory(dict)
+ successes: dict[safe.RelPath, list[sql.CheckResult]] = schema.factory(dict)
+ warnings: dict[safe.RelPath, list[sql.CheckResult]] = schema.factory(dict)
class PersonalAccessTokenSafe(schema.Strict):
diff --git a/atr/storage/writers/announce.py b/atr/storage/writers/announce.py
index 31345604..393e4f1e 100644
--- a/atr/storage/writers/announce.py
+++ b/atr/storage/writers/announce.py
@@ -21,7 +21,6 @@ from __future__ import annotations
import asyncio
import copy
import datetime
-from typing import TYPE_CHECKING
import aiofiles.os
import aioshutil
@@ -38,9 +37,6 @@ import atr.storage as storage
import atr.tasks.message as message
import atr.util as util
-if TYPE_CHECKING:
- import pathlib
-
class GeneralPublic:
def __init__(
@@ -265,7 +261,7 @@ class CommitteeMember(CommitteeParticipant):
async def __hard_link_downloads(
self,
committee: sql.Committee,
- unfinished_path: pathlib.Path,
+ unfinished_path: safe.StatePath,
download_path_suffix: safe.RelPath | None,
dry_run: bool = False,
preserve: bool = False,
diff --git a/atr/storage/writers/keys.py b/atr/storage/writers/keys.py
index c8d8c627..a2ec0973 100644
--- a/atr/storage/writers/keys.py
+++ b/atr/storage/writers/keys.py
@@ -25,10 +25,7 @@ import asyncio
import datetime
import tempfile
import textwrap
-from typing import TYPE_CHECKING, NoReturn
-
-if TYPE_CHECKING:
- import pathlib
+from typing import NoReturn
import aiofiles
import aiofiles.os
@@ -184,7 +181,7 @@ class FoundationCommitter(GeneralPublic):
key_blocks_str=key_blocks_str,
)
- def _committee_keys_path(self, committee: sql.Committee) -> pathlib.Path:
+ def _committee_keys_path(self, committee: sql.Committee) -> safe.StatePath:
base_downloads_dir = paths.get_downloads_dir()
if committee.is_podling:
return base_downloads_dir / "incubator" / committee.key / "KEYS"
@@ -209,7 +206,7 @@ class FoundationCommitter(GeneralPublic):
try:
await aiofiles.os.makedirs(committee_keys_dir, exist_ok=True)
await asyncio.to_thread(util.chmod_directories, committee_keys_dir, permissions=0o755)
- await asyncio.to_thread(committee_keys_path.write_text, full_keys_file_content, encoding="utf-8")
+ await asyncio.to_thread(committee_keys_path.path.write_text, full_keys_file_content, encoding="utf-8")
except OSError as e:
raise storage.AccessError(f"Failed to write KEYS file for committee {committee_key}: {e}") from e
except Exception as e:
@@ -564,7 +561,7 @@ class CommitteeParticipant(FoundationCommitter):
if (outcomes.result_count > 0) and (outcomes.error_count == 0):
description = "Removed KEYS file after successful import through web interface"
- async def modify(path: pathlib.Path, _old_rev: sql.Revision | None) -> None:
+ async def modify(path: safe.StatePath, _old_rev: sql.Revision | None) -> None:
path_in_new_revision = path / "KEYS"
await aiofiles.os.remove(path_in_new_revision)
diff --git a/atr/storage/writers/release.py b/atr/storage/writers/release.py
index b1402a55..d26245e4 100644
--- a/atr/storage/writers/release.py
+++ b/atr/storage/writers/release.py
@@ -171,10 +171,10 @@ class CommitteeParticipant(FoundationCommitter):
) -> str | None:
description = f"Delete empty directory {dir_to_delete_rel} via web interface"
- async def modify(path: pathlib.Path, _old_rev: sql.Revision | None) -> None:
+ async def modify(path: safe.StatePath, _old_rev: sql.Revision | None) -> None:
path_to_remove = path / dir_to_delete_rel
- resolved = await asyncio.to_thread(path_to_remove.resolve)
- resolved.relative_to(await asyncio.to_thread(path.resolve))
+ resolved = await asyncio.to_thread(path_to_remove.path.resolve)
+ resolved.relative_to(await asyncio.to_thread(path.path.resolve))
if not await aiofiles.os.path.isdir(path_to_remove):
raise types.FailedError(f"Path '{dir_to_delete_rel}' is not a directory.")
if await aiofiles.os.listdir(path_to_remove):
@@ -200,15 +200,15 @@ class CommitteeParticipant(FoundationCommitter):
metadata_files_deleted = 0
description = "File deletion through web interface"
- async def modify(path: pathlib.Path, _old_rev: sql.Revision | None) -> None:
+ async def modify(path: safe.StatePath, _old_rev: sql.Revision | None) -> None:
nonlocal metadata_files_deleted
# Uses new_revision_number for logging only
# Path to delete within the new revision directory
path_in_new_revision = path / rel_path_to_delete
# Make sure the requested path is relative to the actual path
- resolved = await asyncio.to_thread(path_in_new_revision.resolve)
- resolved.relative_to(await asyncio.to_thread(path.resolve))
+ resolved = await asyncio.to_thread(path_in_new_revision.path.resolve)
+ resolved.relative_to(await asyncio.to_thread(path.path.resolve))
# Check that the file exists in the new revision
if not await aiofiles.os.path.exists(path_in_new_revision):
@@ -217,12 +217,12 @@ class CommitteeParticipant(FoundationCommitter):
raise storage.AccessError("File to delete was not found in the new revision")
# Check whether the file is an artifact
- if analysis.is_artifact(path_in_new_revision):
+ if analysis.is_artifact(path_in_new_revision.path):
# If so, delete all associated metadata files in the new revision
async for p in util.paths_recursive(path_in_new_revision.parent):
# Construct full path within the new revision
metadata_path_obj = path / p
- if p.name.startswith(rel_path_to_delete.name + "."):
+ if p.as_path().name.startswith(rel_path_to_delete.name + "."):
await aiofiles.os.remove(metadata_path_obj)
metadata_files_deleted += 1
@@ -244,13 +244,13 @@ class CommitteeParticipant(FoundationCommitter):
) -> None:
description = "Hash generation through web interface"
- async def modify(path: pathlib.Path, _old_rev: sql.Revision | None) -> None:
+ async def modify(path: safe.StatePath, _old_rev: sql.Revision | None) -> None:
# Uses new_revision_number for logging only
path_in_new_revision = path / rel_path
# Make sure the requested path is relative to the actual path
- resolved = await asyncio.to_thread(path_in_new_revision.resolve)
- resolved.relative_to(await asyncio.to_thread(path.resolve))
+ resolved = await asyncio.to_thread(path_in_new_revision.path.resolve)
+ resolved.relative_to(await asyncio.to_thread(path.path.resolve))
hash_path_rel = rel_path.name + ".sha512"
hash_path_in_new_revision = path / rel_path.parent / hash_path_rel
@@ -325,7 +325,7 @@ class CommitteeParticipant(FoundationCommitter):
moved_files_names: list[str] = []
skipped_files_names: list[str] = []
- async def modify(path: pathlib.Path, _old_rev: sql.Revision | None) -> None:
+ async def modify(path: safe.StatePath, _old_rev: sql.Revision | None) -> None:
await self.__setup_revision(
source_files_rel,
target_dir_rel,
@@ -419,7 +419,7 @@ class CommitteeParticipant(FoundationCommitter):
error_messages: list[str] = []
renamed_count = 0
- async def modify(path: pathlib.Path, _old_rev: sql.Revision | None) -> None:
+ async def modify(path: safe.StatePath, _old_rev: sql.Revision | None) -> None:
nonlocal renamed_count
renamed_count = await self.__remove_rc_tags_revision(path, error_messages)
@@ -517,7 +517,7 @@ class CommitteeParticipant(FoundationCommitter):
validated_path = args.relpath.as_path()
description = f"Upload via API: {validated_path}"
- async def modify(path: pathlib.Path, _old_rev: sql.Revision | None) -> None:
+ async def modify(path: safe.StatePath, _old_rev: sql.Revision | None) -> None:
target_path = path / validated_path
await aiofiles.os.makedirs(target_path.parent, exist_ok=True)
if await aiofiles.os.path.exists(target_path):
@@ -552,14 +552,14 @@ class CommitteeParticipant(FoundationCommitter):
number_of_files = len(files)
description = f"Upload of {util.plural(number_of_files, 'file')} through web interface"
- async def modify(path: pathlib.Path, _old_rev: sql.Revision | None) -> None:
+ async def modify(path: safe.StatePath, _old_rev: sql.Revision | None) -> None:
for file in files:
if not file.filename:
raise storage.AccessError("No filename provided")
# Validate the filename from multipart upload and construct the new path
target_path = path / str(safe.RelPath(file.filename))
await aiofiles.os.makedirs(target_path.parent, exist_ok=True)
- await self.__save_file(file, target_path)
+ await self.__save_file(file, target_path.path)
try:
result = await self.__write_as.revision.create_revision_with_quarantine(
@@ -574,7 +574,7 @@ class CommitteeParticipant(FoundationCommitter):
return str(e), len(files), False
return None, len(files), isinstance(result, sql.Quarantined)
- async def __current_paths(self, interim_path: pathlib.Path) -> list[pathlib.Path]:
+ async def __current_paths(self, interim_path: safe.StatePath) -> list[pathlib.Path]:
all_current_paths_interim: list[pathlib.Path] = []
async for p_rel_interim in util.paths_recursive_all(interim_path):
all_current_paths_interim.append(p_rel_interim)
@@ -608,7 +608,7 @@ class CommitteeParticipant(FoundationCommitter):
continue
async def __delete_release_data_filesystem(
- self, release_dirs: Sequence[pathlib.Path], project_key: safe.ProjectKey, version: safe.VersionKey
+ self, release_dirs: Sequence[safe.StatePath], project_key: safe.ProjectKey, version: safe.VersionKey
) -> str | None:
delete_errors: list[str] = []
for release_dir in release_dirs:
@@ -644,7 +644,7 @@ class CommitteeParticipant(FoundationCommitter):
async def __remove_rc_tags_revision(
self,
- interim_path: pathlib.Path,
+ interim_path: safe.StatePath,
error_messages: list[str],
) -> int:
all_current_paths_interim = await self.__current_paths(interim_path)
@@ -658,7 +658,7 @@ class CommitteeParticipant(FoundationCommitter):
full_stripped_path = interim_path / path_rel_stripped_interim
skip, renamed_count_local = await self.__remove_rc_tags_revision_item(
- path_rel_original_interim,
+ safe.RelPath.from_path(path_rel_original_interim),
full_original_path,
full_stripped_path,
error_messages,
@@ -687,9 +687,9 @@ class CommitteeParticipant(FoundationCommitter):
async def __remove_rc_tags_revision_item(
self,
- path_rel_original_interim: pathlib.Path,
- full_original_path: pathlib.Path,
- full_stripped_path: pathlib.Path,
+ path_rel_original_interim: safe.RelPath,
+ full_original_path: safe.StatePath,
+ full_stripped_path: safe.StatePath,
error_messages: list[str],
renamed_count_local: int,
) -> tuple[bool, int]:
@@ -722,20 +722,20 @@ class CommitteeParticipant(FoundationCommitter):
self,
source_files_rel: list[safe.RelPath],
target_dir_rel: safe.RelPath,
- interim_path: pathlib.Path,
+ interim_path: safe.StatePath,
moved_files_names: list[str],
skipped_files_names: list[str],
) -> None:
- target_path = interim_path / target_dir_rel.as_path()
+ target_path = interim_path / target_dir_rel
try:
- resolved = await asyncio.to_thread(target_path.resolve)
- resolved.relative_to(await asyncio.to_thread(interim_path.resolve))
+ resolved = await asyncio.to_thread(target_path.path.resolve)
+ resolved.relative_to(await asyncio.to_thread(interim_path.path.resolve))
except ValueError:
# Path traversal detected
raise types.FailedError("Paths must be restricted to the release directory")
if not await aiofiles.os.path.exists(target_path):
- for part in target_path.parts:
+ for part in target_path.path.parts:
# TODO: This .prefix check could include some existing directory segment
# TODO: The second check probably isn't needed now since this came from a safe RelPath type.
if util.is_disallowed_dotfile(part):
@@ -759,10 +759,10 @@ class CommitteeParticipant(FoundationCommitter):
self,
source_file_rel: safe.RelPath,
target_dir_rel: safe.RelPath,
- interim_path: pathlib.Path,
+ interim_path: safe.StatePath,
moved_files_names: list[str],
skipped_files_names: list[str],
- target_path: pathlib.Path,
+ target_path: safe.StatePath,
) -> None:
source_path = source_file_rel.as_path()
target_dir_path = target_dir_rel.as_path()
@@ -770,11 +770,11 @@ class CommitteeParticipant(FoundationCommitter):
skipped_files_names.append(source_path.name)
return
- full_source_item_path = interim_path / source_path
+ full_source_item_path: safe.StatePath = interim_path / source_path
if await aiofiles.os.path.isdir(full_source_item_path):
- if (target_dir_rel == source_file_rel) or (interim_path / target_dir_path).resolve().is_relative_to(
- full_source_item_path.resolve()
+ if (target_dir_rel == source_file_rel) or (interim_path / target_dir_path).path.resolve().is_relative_to(
+ full_source_item_path.path.resolve()
):
raise types.FailedError("Cannot move a directory into itself or a subdirectory of itself")
diff --git a/atr/storage/writers/revision.py b/atr/storage/writers/revision.py
index 3cc352df..97184155 100644
--- a/atr/storage/writers/revision.py
+++ b/atr/storage/writers/revision.py
@@ -80,8 +80,8 @@ async def finalise_revision(
merge_enabled: bool,
n_inodes: dict[str, int],
old_revision: sql.Revision | None,
- path_to_hash: dict[str, str],
- path_to_size: dict[str, int],
+ path_to_hash: dict[safe.RelPath, str],
+ path_to_size: dict[safe.RelPath, int],
previous_attestable: atr.models.attestable.Attestable | None,
project_key: safe.ProjectKey,
release: sql.Release,
@@ -135,8 +135,8 @@ async def _commit_new_revision(
asf_uid: str,
description: str | None,
merge_base_revision_key: str | None,
- path_to_hash: dict[str, str],
- path_to_size: dict[str, int],
+ path_to_hash: dict[safe.RelPath, str],
+ path_to_size: dict[safe.RelPath, int],
previous_attestable: atr.models.attestable.Attestable | None,
project_key: safe.ProjectKey,
release: sql.Release,
@@ -189,7 +189,7 @@ async def _commit_new_revision(
# This must be done after the rename, otherwise the rename will fail
# The ".." entry in a directory is modified when it is moved between parents
# (Additionally, on macOS a 555 directory cannot be renamed within the same parent)
- await asyncio.to_thread(util.chmod_directories, new_revision_dir, 0o555)
+ await asyncio.to_thread(util.chmod_directories, new_revision_dir.path, 0o555)
policy = release.release_policy or release.project.release_policy
policy_dict = policy.model_dump() if policy else None
@@ -254,8 +254,8 @@ async def _lock_and_merge(
merge_enabled: bool,
n_inodes: dict[str, int],
old_revision: sql.Revision | None,
- path_to_hash: dict[str, str],
- path_to_size: dict[str, int],
+ path_to_hash: dict[safe.RelPath, str],
+ path_to_size: dict[safe.RelPath, int],
previous_attestable: atr.models.attestable.Attestable | None,
project_key: safe.ProjectKey,
release: sql.Release,
@@ -290,14 +290,14 @@ async def _lock_and_merge(
data,
base_inodes,
base_hashes,
- prior_dir,
+ prior_dir.path,
project_key,
version_key,
latest.seq,
temp_dir_path,
n_inodes,
- path_to_hash,
- path_to_size,
+ {str(k): v for k, v in path_to_hash.items()},
+ {str(k): v for k, v in path_to_size.items()},
)
previous_attestable = await attestable.load(project_key, version_key, prior_number)
@@ -378,7 +378,7 @@ class CommitteeParticipant(FoundationCommitter):
description: str | None = None,
set_local_cache: bool = False,
reset_to_global_cache: bool = False,
- modify: Callable[[pathlib.Path, sql.Revision | None], Awaitable[None]] | None = None,
+ modify: Callable[[safe.StatePath, sql.Revision | None], Awaitable[None]] | None = None,
clone_from: safe.RevisionNumber | None = None,
) -> sql.Revision | sql.Quarantined:
"""Create a new revision, quarantining archives that require validation."""
@@ -410,7 +410,7 @@ class CommitteeParticipant(FoundationCommitter):
# Use the tmp subdirectory of state, to ensure that it is on the same filesystem
prefix_token = secrets.token_hex(16)
temp_dir: str = await asyncio.to_thread(tempfile.mkdtemp, prefix=prefix_token + "-", dir=paths.get_tmp_dir())
- temp_dir_path = pathlib.Path(temp_dir)
+ temp_dir_path = safe.StatePath(pathlib.Path(temp_dir), paths.get_tmp_dir().path)
try:
# The directory was created by mkdtemp, but it's empty
@@ -427,27 +427,27 @@ class CommitteeParticipant(FoundationCommitter):
await aioshutil.rmtree(temp_dir)
raise
- validation_errors = await asyncio.to_thread(detection.validate_directory, temp_dir_path)
+ validation_errors = await asyncio.to_thread(detection.validate_directory, temp_dir_path.path)
if validation_errors:
await aioshutil.rmtree(temp_dir)
raise types.FailedError("File validation failed:\n" + "\n".join(validation_errors))
# Ensure that the permissions of every directory are 755
try:
- await asyncio.to_thread(util.chmod_directories, temp_dir_path)
+ await asyncio.to_thread(util.chmod_directories, temp_dir_path.path)
except Exception:
await aioshutil.rmtree(temp_dir)
raise
# Make files read only to prevent them from being modified through hard links
try:
- await asyncio.to_thread(util.chmod_files, temp_dir_path, 0o444)
+ await asyncio.to_thread(util.chmod_files, temp_dir_path.path, 0o444)
except Exception:
await aioshutil.rmtree(temp_dir)
raise
try:
- path_to_hash, path_to_size = await attestable.paths_to_hashes_and_sizes(temp_dir_path)
+ path_to_hash, path_to_size = await attestable.paths_to_hashes_and_sizes(temp_dir_path.path)
parent_revision_number = old_revision.safe_number if old_revision else None
previous_attestable = None
if parent_revision_number is not None:
@@ -456,9 +456,9 @@ class CommitteeParticipant(FoundationCommitter):
base_hashes: dict[str, str] = {}
if merge_enabled and (old_revision is not None):
base_dir = old_release_dir
- base_inodes = await asyncio.to_thread(util.paths_to_inodes, base_dir)
+ base_inodes = await asyncio.to_thread(util.paths_to_inodes, base_dir.path)
base_hashes = attestable.path_hashes(previous_attestable) if (previous_attestable is not None) else {}
- n_inodes = await asyncio.to_thread(util.paths_to_inodes, temp_dir_path)
+ n_inodes = await asyncio.to_thread(util.paths_to_inodes, temp_dir_path.path)
except Exception:
await aioshutil.rmtree(temp_dir)
raise
@@ -483,7 +483,7 @@ class CommitteeParticipant(FoundationCommitter):
project_key=project_key,
release=release,
_release_key=release.safe_key,
- temp_dir_path=temp_dir_path,
+ temp_dir_path=temp_dir_path.path,
version_key=version_key,
)
except Exception:
@@ -539,9 +539,9 @@ class CommitteeParticipant(FoundationCommitter):
data: db.Session,
*,
asf_uid: str,
- deduped_archives: list[tuple[str, str]],
+ deduped_archives: list[tuple[safe.RelPath, str]],
description: str | None,
- path_to_size: dict[str, int],
+ path_to_size: dict[safe.RelPath, int],
prior_revision_key: str | None,
project_key: safe.ProjectKey,
release_key: str,
@@ -550,7 +550,7 @@ class CommitteeParticipant(FoundationCommitter):
) -> sql.Quarantined:
file_metadata = [
sql.QuarantineFileEntryV1(
- rel_path=rel_path,
+ rel_path=str(rel_path),
size_bytes=path_to_size[rel_path],
content_hash=content_hash,
errors=[],
diff --git a/atr/storage/writers/sbom.py b/atr/storage/writers/sbom.py
index f204d824..f2605887 100644
--- a/atr/storage/writers/sbom.py
+++ b/atr/storage/writers/sbom.py
@@ -20,7 +20,6 @@ from __future__ import annotations
import asyncio
import datetime
-from typing import TYPE_CHECKING
import atr.db as db
import atr.models.safe as safe
@@ -29,9 +28,6 @@ import atr.storage as storage
import atr.tasks.sbom as sbom
import atr.util as util
-if TYPE_CHECKING:
- import pathlib
-
class GeneralPublic:
def __init__(
@@ -110,8 +106,8 @@ class CommitteeParticipant(FoundationCommitter):
project_key: safe.ProjectKey,
version_key: safe.VersionKey,
revision_number: safe.RevisionNumber,
- file_path: str,
- sbom_path: str,
+ file_path: safe.StatePath,
+ sbom_path: safe.StatePath,
) -> sql.Task:
sbom_task = sql.Task(
task_type=sql.TaskType.SBOM_CONVERT,
@@ -136,9 +132,9 @@ class CommitteeParticipant(FoundationCommitter):
self,
project_key: safe.ProjectKey,
version_key: safe.VersionKey,
- revision_number: str,
- path_in_new_revision: pathlib.Path,
- sbom_path_in_new_revision: pathlib.Path,
+ revision_number: safe.RevisionNumber,
+ path_in_new_revision: safe.StatePath,
+ sbom_path_in_new_revision: safe.StatePath,
) -> sql.Task:
# Create and queue the task, using paths within the new revision
# We still need release.name for the task metadata
@@ -155,7 +151,7 @@ class CommitteeParticipant(FoundationCommitter):
status=sql.TaskStatus.QUEUED,
project_key=str(project_key),
version_key=str(version_key),
- revision_number=revision_number,
+ revision_number=str(revision_number),
)
self.__data.add(sbom_task)
await self.__data.commit()
@@ -211,5 +207,5 @@ class CommitteeMember(CommitteeParticipant):
self.__committee_key = committee_key
-def _resolved_path_str(path: pathlib.Path) -> str:
- return str(path.resolve())
+def _resolved_path_str(path: safe.StatePath) -> safe.StatePath:
+ return safe.StatePath(path.path.resolve(), path.root)
diff --git a/atr/tarzip.py b/atr/tarzip.py
index df018710..11577e89 100644
--- a/atr/tarzip.py
+++ b/atr/tarzip.py
@@ -16,6 +16,7 @@
# under the License.
import contextlib
+import os
import tarfile
import zipfile
from collections.abc import Generator, Iterator
@@ -188,7 +189,7 @@ type Archive = TarArchive | ZipArchive
@contextlib.contextmanager
def open_archive(
- archive_path: str,
+ archive_path: os.PathLike,
max_members: int = MAX_ARCHIVE_MEMBERS,
) -> Generator[Archive]:
"""Open an archive file (tar or zip) and yield an ArchiveContext.
diff --git a/atr/tasks/__init__.py b/atr/tasks/__init__.py
index a2cc27a2..6c4327cf 100644
--- a/atr/tasks/__init__.py
+++ b/atr/tasks/__init__.py
@@ -17,7 +17,6 @@
import asyncio
import datetime
-import pathlib
import sqlite3
from collections.abc import Awaitable, Callable, Coroutine
from typing import Any, Final
@@ -569,7 +568,7 @@ async def _draft_file_checks(
asf_uid: str,
caller_data: db.Session | None,
data: db.Session,
- path: pathlib.Path,
+ path: safe.RelPath,
previous_version: sql.Release | None,
project_key: safe.ProjectKey,
release: sql.Release,
@@ -579,7 +578,7 @@ async def _draft_file_checks(
path_str = str(path)
task_function: Callable[[str, sql.Release, str, str, db.Session], Awaitable[list[sql.Task | None]]] | None = None
for suffix, func in TASK_FUNCTIONS.items():
- if path.name.endswith(suffix):
+ if path.as_path().name.endswith(suffix):
task_function = func
break
if task_function:
@@ -589,7 +588,7 @@ async def _draft_file_checks(
await _add_task(data, task)
# TODO: Should we check .json files for their content?
# Ideally we would not have to do that
- if path.name.endswith(".cdx.json"):
+ if path.as_path().name.endswith(".cdx.json"):
cdx_task = await queued(
asf_uid,
sql.TaskType.SBOM_TOOL_SCORE,
diff --git a/atr/tasks/checks/__init__.py b/atr/tasks/checks/__init__.py
index 24d3fce4..bc798920 100644
--- a/atr/tasks/checks/__init__.py
+++ b/atr/tasks/checks/__init__.py
@@ -20,7 +20,6 @@ from __future__ import annotations
import dataclasses
import datetime
import functools
-import pathlib
from typing import TYPE_CHECKING, Any, Final
import aiofiles
@@ -52,7 +51,7 @@ class FunctionArguments:
project_key: safe.ProjectKey
version_key: safe.VersionKey
revision_number: safe.RevisionNumber
- primary_rel_path: str | None
+ primary_rel_path: safe.RelPath | None
extra_args: dict[str, Any]
@@ -62,7 +61,7 @@ class Recorder:
release_key: safe.ReleaseKey
project_key: safe.ProjectKey
version_key: safe.VersionKey
- primary_rel_path: str | None
+ primary_rel_path: safe.RelPath | None
member_rel_path: str | None
revision_number: safe.RevisionNumber
afresh: bool
@@ -77,7 +76,7 @@ class Recorder:
project_key: safe.ProjectKey,
version_key: safe.VersionKey,
revision_number: safe.RevisionNumber,
- primary_rel_path: str | None = None,
+ primary_rel_path: safe.RelPath | None = None,
member_rel_path: str | None = None,
afresh: bool = True,
) -> None:
@@ -105,7 +104,7 @@ class Recorder:
project_key: safe.ProjectKey,
version_key: safe.VersionKey,
revision_number: safe.RevisionNumber,
- primary_rel_path: str | None = None,
+ primary_rel_path: safe.RelPath | None = None,
member_rel_path: str | None = None,
afresh: bool = True,
) -> Recorder:
@@ -122,7 +121,9 @@ class Recorder:
)
if afresh is True:
# Clear outer path whether it's specified or not
- await recorder.clear(primary_rel_path=primary_rel_path, member_rel_path=member_rel_path)
+ await recorder.clear(
+ primary_rel_path=str(primary_rel_path) if primary_rel_path else None, member_rel_path=member_rel_path
+ )
recorder.constructed = True
return recorder
@@ -131,7 +132,7 @@ class Recorder:
status: sql.CheckResultStatus,
message: str,
data: Any,
- primary_rel_path: str | None = None,
+ primary_rel_path: safe.RelPath | None = None,
member_rel_path: str | None = None,
) -> sql.CheckResult:
if self.constructed is False:
@@ -154,7 +155,9 @@ class Recorder:
revision_number=str(self.revision_number),
checker=self.checker,
checker_version=self.checker_version,
- primary_rel_path=primary_rel_path or self.primary_rel_path,
+ primary_rel_path=str(primary_rel_path or self.primary_rel_path)
+ if (primary_rel_path or self.primary_rel_path)
+ else None,
member_rel_path=member_rel_path,
created=datetime.datetime.now(datetime.UTC),
status=status,
@@ -172,10 +175,10 @@ class Recorder:
await session.commit()
return result
- async def abs_path(self, rel_path: str | None = None) -> pathlib.Path | None:
+ async def abs_path(self, rel_path: safe.RelPath | str | None = None) -> safe.StatePath | None:
"""Construct the absolute path using the required revision."""
# Determine the relative path part
- rel_path_part: str | None = None
+ rel_path_part: safe.RelPath | str | None = None
if rel_path is not None:
rel_path_part = rel_path
elif self.primary_rel_path is not None:
@@ -185,7 +188,7 @@ class Recorder:
return self.abs_path_base()
return self.abs_path_base() / rel_path_part
- def abs_path_base(self) -> pathlib.Path:
+ def abs_path_base(self) -> safe.StatePath:
return file_paths.base_path_for_revision(self.project_key, self.version_key, self.revision_number)
async def project(self) -> sql.Project:
@@ -212,7 +215,9 @@ class Recorder:
release_key = str(self.release_key)
revision_seq = int(str(self.revision_number))
async with db.session() as data:
- classification = await data.release_file_classification_at(release_key, self.primary_rel_path, revision_seq)
+ classification = await data.release_file_classification_at(
+ release_key, str(self.primary_rel_path), revision_seq
+ )
if classification is not None:
return classify.FileType(classification)
project = await self.project()
@@ -225,7 +230,7 @@ class Recorder:
base_path,
)
return classify.classify(
- pathlib.Path(self.primary_rel_path),
+ self.primary_rel_path,
base_path=base_path,
source_matcher=source_matcher,
binary_matcher=binary_matcher,
@@ -252,7 +257,7 @@ class Recorder:
self,
message: str,
data: Any,
- primary_rel_path: str | None = None,
+ primary_rel_path: safe.RelPath | None = None,
member_rel_path: str | None = None,
) -> sql.CheckResult:
result = await self._add(
@@ -268,7 +273,7 @@ class Recorder:
self,
message: str,
data: Any,
- primary_rel_path: str | None = None,
+ primary_rel_path: safe.RelPath | None = None,
member_rel_path: str | None = None,
) -> sql.CheckResult:
result = await self._add(
@@ -284,7 +289,7 @@ class Recorder:
self,
message: str,
data: Any,
- primary_rel_path: str | None = None,
+ primary_rel_path: safe.RelPath | None = None,
member_rel_path: str | None = None,
) -> sql.CheckResult:
result = await self._add(
@@ -300,7 +305,7 @@ class Recorder:
self,
message: str,
data: Any,
- primary_rel_path: str | None = None,
+ primary_rel_path: safe.RelPath | None = None,
member_rel_path: str | None = None,
) -> sql.CheckResult:
result = await self._add(
@@ -316,7 +321,7 @@ class Recorder:
self,
message: str,
data: Any,
- primary_rel_path: str | None = None,
+ primary_rel_path: safe.RelPath | None = None,
member_rel_path: str | None = None,
) -> sql.CheckResult:
result = await self._add(
@@ -333,17 +338,17 @@ def function_key(func: Callable[..., Any] | str) -> str:
return func.__module__ + "." + func.__name__ if callable(func) else func
-async def resolve_archive_dir(args: FunctionArguments) -> pathlib.Path | None:
+async def resolve_archive_dir(args: FunctionArguments) -> safe.StatePath | None:
"""Resolve the extracted archive directory for the primary archive."""
if args.primary_rel_path is None:
return None
release_key = sql.release_key(str(args.project_key), str(args.version_key))
revision_seq = int(str(args.revision_number))
async with db.session() as data:
- content_hash = await data.release_file_hash_at(release_key, args.primary_rel_path, revision_seq)
+ content_hash = await data.release_file_hash_at(release_key, str(args.primary_rel_path), revision_seq)
if content_hash is None:
abs_path = file_paths.revision_path_for_file(
- args.project_key, args.version_key, args.revision_number, args.primary_rel_path
+ args.project_key, args.version_key, args.revision_number, str(args.primary_rel_path)
)
if await aiofiles.os.path.isfile(abs_path):
content_hash = await hashes.compute_file_hash(abs_path)
@@ -364,7 +369,7 @@ async def resolve_cache_key( # noqa: C901
revision: safe.RevisionNumber,
args: dict[str, Any] | None = None,
file: str | None = None,
- path: pathlib.Path | None = None,
+ path: safe.StatePath | None = None,
ignore_path: bool = False,
) -> dict[str, Any] | None:
if not args:
@@ -488,7 +493,7 @@ async def _resolve_unsuffixed_file_hash(release: sql.Release, rel_path: str | No
abs_path = file_paths.revision_path_for_file(
release.safe_project_key, release.safe_version_key, release.safe_latest_revision_number, rel_path
)
- plain_path = abs_path.with_suffix("")
+ plain_path = abs_path.path.with_suffix("")
if await aiofiles.os.path.isfile(plain_path):
return await hashes.compute_file_hash(plain_path)
else:
diff --git a/atr/tasks/checks/compare.py b/atr/tasks/checks/compare.py
index d0b777ac..e7549a67 100644
--- a/atr/tasks/checks/compare.py
+++ b/atr/tasks/checks/compare.py
@@ -39,6 +39,7 @@ import atr.config as config
import atr.log as log
import atr.models.github as github_models
import atr.models.results as results
+import atr.models.safe as safe
import atr.paths as paths
import atr.tasks.checks as checks
import atr.util as util
@@ -108,7 +109,7 @@ async def source_trees(args: checks.FunctionArguments) -> results.Results | None
tmp_dir = paths.get_tmp_dir()
await aiofiles.os.makedirs(tmp_dir, exist_ok=True)
async with util.async_temporary_directory(prefix="trees-", dir=tmp_dir) as temp_dir:
- github_dir = temp_dir / "github"
+ github_dir = safe.StatePath(temp_dir) / "github"
await aiofiles.os.makedirs(github_dir, exist_ok=True)
checkout_dir = await _checkout_github_source(payload, github_dir)
if checkout_dir is None:
@@ -149,7 +150,7 @@ async def source_trees(args: checks.FunctionArguments) -> results.Results | None
required = _PERMITTED_ADDED_PATHS.get(path)
if required is None:
invalid_filtered.add(path)
- elif not all((archive_content_dir / r).is_file() for r in required):
+ elif not all((archive_content_dir / r).path.is_file() for r in required):
invalid_filtered.add(path)
if invalid_filtered:
invalid_list = sorted(invalid_filtered)
@@ -181,7 +182,7 @@ async def source_trees(args: checks.FunctionArguments) -> results.Results | None
async def _checkout_github_source(
- payload: github_models.TrustedPublisherPayload, checkout_dir: pathlib.Path
+ payload: github_models.TrustedPublisherPayload, checkout_dir: safe.StatePath
) -> str | None:
repo_url = f"https://github.com/{payload.repository}.git"
started_ns = time.perf_counter_ns()
@@ -215,7 +216,7 @@ async def _checkout_github_source(
return str(checkout_dir)
-def _clone_repo(repo_url: str, sha: str, checkout_dir: pathlib.Path) -> None:
+def _clone_repo(repo_url: str, sha: str, checkout_dir: safe.StatePath) -> None:
_ensure_clone_identity_env()
repo = dulwich.porcelain.init(str(checkout_dir))
git_client, path = dulwich.client.get_transport_and_path(repo_url, operation="pull")
@@ -235,11 +236,11 @@ def _clone_repo(repo_url: str, sha: str, checkout_dir: pathlib.Path) -> None:
shutil.rmtree(git_dir)
-async def _compare_trees(repo_dir: pathlib.Path, archive_dir: pathlib.Path) -> TreeComparisonResult:
+async def _compare_trees(repo_dir: safe.StatePath, archive_dir: safe.StatePath) -> TreeComparisonResult:
return await asyncio.to_thread(_compare_trees_rsync, repo_dir, archive_dir)
-def _compare_trees_rsync(repo_dir: pathlib.Path, archive_dir: pathlib.Path) -> TreeComparisonResult: # noqa: C901
+def _compare_trees_rsync(repo_dir: safe.StatePath, archive_dir: safe.StatePath) -> TreeComparisonResult: # noqa: C901
if shutil.which("rsync") is None:
raise RuntimeError("rsync is not available on PATH")
command = [
@@ -289,7 +290,7 @@ def _compare_trees_rsync(repo_dir: pathlib.Path, archive_dir: pathlib.Path) -> T
continue
full_repo = repo_dir / rel_path
full_archive = archive_dir / rel_path
- if full_repo.is_file() or full_archive.is_file():
+ if full_repo.path.is_file() or full_archive.path.is_file():
if is_repo_only:
repo_only.add(rel_path)
elif is_content_diff:
@@ -302,7 +303,7 @@ def _ensure_clone_identity_env() -> None:
os.environ["EMAIL"] = _DEFAULT_EMAIL
-async def _find_archive_root(archive_path: pathlib.Path, extract_dir: pathlib.Path) -> ArchiveRootResult:
+async def _find_archive_root(archive_path: safe.StatePath, extract_dir: safe.StatePath) -> ArchiveRootResult:
entries = await aiofiles.os.listdir(extract_dir)
directories: list[str] = []
for entry in entries:
diff --git a/atr/tasks/checks/hashing.py b/atr/tasks/checks/hashing.py
index 2082dcdc..7a060699 100644
--- a/atr/tasks/checks/hashing.py
+++ b/atr/tasks/checks/hashing.py
@@ -37,7 +37,7 @@ async def check(args: checks.FunctionArguments) -> results.Results | None:
if not (hash_abs_path := await recorder.abs_path()):
return None
- algorithm = hash_abs_path.suffix.lstrip(".")
+ algorithm = hash_abs_path.path.suffix.lstrip(".")
if algorithm not in {"sha256", "sha512"}:
await recorder.failure("Unsupported hash algorithm", {"algorithm": algorithm})
return None
@@ -48,7 +48,7 @@ async def check(args: checks.FunctionArguments) -> results.Results | None:
# PosixPath('a/b/c.d.e.f.x')
# >>> pathlib.Path("a/b/c.d.e.f.g").with_suffix("")
# PosixPath('a/b/c.d.e.f')
- artifact_abs_path = hash_abs_path.with_suffix("")
+ artifact_abs_path = hash_abs_path.path.with_suffix("")
log.info(
f"Checking hash ({algorithm}) for {artifact_abs_path} against {hash_abs_path} (rel: {args.primary_rel_path})"
diff --git a/atr/tasks/checks/license.py b/atr/tasks/checks/license.py
index baff7a83..71d6020c 100644
--- a/atr/tasks/checks/license.py
+++ b/atr/tasks/checks/license.py
@@ -27,6 +27,7 @@ from typing import Any, Final
import atr.constants as constants
import atr.log as log
import atr.models.results as results
+import atr.models.safe as safe
import atr.models.schema as schema
import atr.models.sql as sql
import atr.tasks.checks as checks
@@ -240,7 +241,7 @@ def headers_validate(content: bytes, _filename: str) -> tuple[bool, str | None]:
return False, "Could not find Apache License header"
-def _files_check_binary(root_path: pathlib.Path) -> Iterator[Result]:
+def _files_check_binary(root_path: safe.StatePath) -> Iterator[Result]:
license_paths: list[pathlib.Path] = []
notice_paths: list[pathlib.Path] = []
@@ -257,8 +258,8 @@ def _files_check_binary(root_path: pathlib.Path) -> Iterator[Result]:
if filename in _BINARY_NOTICE_FILENAMES:
notice_paths.append(pathlib.Path(dirpath) / filename)
- yield _files_check_binary_license(license_paths, root_path)
- yield _files_check_binary_notice(notice_paths, root_path)
+ yield _files_check_binary_license(license_paths, root_path.path)
+ yield _files_check_binary_notice(notice_paths, root_path.path)
def _files_check_binary_license(paths: list[pathlib.Path], root_path: pathlib.Path) -> ArtifactResult:
@@ -306,8 +307,8 @@ def _files_check_binary_notice(paths: list[pathlib.Path], root_path: pathlib.Pat
)
-def _files_check_core_logic(archive_dir: pathlib.Path, is_podling: bool, is_binary: bool) -> Iterator[Result]:
- if not archive_dir.is_dir():
+def _files_check_core_logic(archive_dir: safe.StatePath, is_podling: bool, is_binary: bool) -> Iterator[Result]:
+ if not archive_dir.path.is_dir():
# Already protected by the caller
# We add it here again to make unit testing cleaner
yield ArtifactResult(
@@ -319,7 +320,7 @@ def _files_check_core_logic(archive_dir: pathlib.Path, is_podling: bool, is_bina
# Check for license files in the root directory
top_entries = sorted(e for e in os.listdir(archive_dir) if not e.startswith("._"))
- root_dirs = [e for e in top_entries if (archive_dir / e).is_dir()]
+ root_dirs = [e for e in top_entries if (archive_dir / e).path.is_dir()]
if len(root_dirs) != 1:
yield ArtifactResult(
status=sql.CheckResultStatus.FAILURE,
@@ -396,7 +397,7 @@ def _files_check_core_logic_notice(file_path: pathlib.Path) -> tuple[bool, list[
return len(issues) == 0, issues, preamble
-def _files_check_source(root_path: pathlib.Path, is_podling: bool) -> Iterator[Result]:
+def _files_check_source(root_path: safe.StatePath, is_podling: bool) -> Iterator[Result]:
license_results: dict[str, str | None] = {}
notice_results: dict[str, tuple[bool, list[str], str]] = {}
disclaimer_found = False
@@ -405,7 +406,9 @@ def _files_check_source(root_path: pathlib.Path, is_podling: bool) -> Iterator[R
if entry.startswith("._"):
continue
- entry_path = root_path / entry
+ # We explicitly escape the StatePath safety here as archives *can* contain prohibited files
+ # TODO: Should they?
+ entry_path = root_path.path / entry
if not entry_path.is_file():
continue
@@ -439,7 +442,7 @@ def _get_file_extension(filename: str) -> str | None:
def _headers_check_core_logic( # noqa: C901
- archive_dir: pathlib.Path, artifact_basename: str, ignore_lines: list[str], excludes_source: str
+ archive_dir: safe.StatePath, artifact_basename: str, ignore_lines: list[str], excludes_source: str
) -> Iterator[Result]:
"""Verify Apache License headers in source files within an extracted cache tree."""
# We could modify @Lucas-C/pre-commit-hooks instead for this
@@ -457,7 +460,7 @@ def _headers_check_core_logic( # noqa: C901
# data=None,
# )
- if not archive_dir.is_dir():
+ if not archive_dir.path.is_dir():
yield ArtifactResult(
status=sql.CheckResultStatus.FAILURE,
message="Cache directory is not available",
@@ -579,7 +582,7 @@ def _headers_check_core_logic_should_check(filepath: str) -> bool:
async def _headers_core(
recorder: checks.Recorder,
- archive_dir: pathlib.Path,
+ archive_dir: safe.StatePath,
artifact_basename: str,
ignore_lines: list[str],
excludes_source: str,
diff --git a/atr/tasks/checks/paths.py b/atr/tasks/checks/paths.py
index f6425f99..fcc9f9da 100644
--- a/atr/tasks/checks/paths.py
+++ b/atr/tasks/checks/paths.py
@@ -25,6 +25,7 @@ import aiofiles.os
import atr.analysis as analysis
import atr.log as log
import atr.models.results as results
+import atr.models.safe as safe
import atr.tasks.checks as checks
import atr.user as user
import atr.util as util
@@ -111,8 +112,8 @@ async def check(args: checks.FunctionArguments) -> results.Results | None:
async def _check_artifact_rules(
- base_path: pathlib.Path,
- relative_path: pathlib.Path,
+ base_path: safe.StatePath,
+ relative_path: safe.RelPath,
relative_paths: set[str],
errors: list[str],
blockers: list[str],
@@ -122,13 +123,14 @@ async def _check_artifact_rules(
full_path = base_path / relative_path
# RDP says that .asc is required
- asc_path = full_path.with_suffix(full_path.suffix + ".asc")
+ asc_path = full_path.path.with_suffix(full_path.path.suffix + ".asc")
if not await aiofiles.os.path.exists(asc_path):
blockers.append(f"Missing corresponding signature file ({relative_path}.asc)")
# RDP requires one of .sha256 or .sha512
- relative_sha256_path = relative_path.with_suffix(relative_path.suffix + ".sha256")
- relative_sha512_path = relative_path.with_suffix(relative_path.suffix + ".sha512")
+ path = relative_path.as_path()
+ relative_sha256_path = path.with_suffix(path.suffix + ".sha256")
+ relative_sha512_path = path.with_suffix(path.suffix + ".sha512")
has_sha256 = str(relative_sha256_path) in relative_paths
has_sha512 = str(relative_sha512_path) in relative_paths
if not (has_sha256 or has_sha512):
@@ -137,13 +139,13 @@ async def _check_artifact_rules(
# IP requires "incubating" in the filename
if is_podling is True:
# TODO: Allow "incubator" too as #114 requests?
- if "incubating" not in full_path.name:
+ if "incubating" not in full_path.path.name:
blockers.append("Podling artifact filenames must include 'incubating'")
async def _check_metadata_rules(
- _base_path: pathlib.Path,
- relative_path: pathlib.Path,
+ _base_path: safe.StatePath,
+ relative_path: safe.RelPath,
relative_paths: set[str],
ext_metadata: str,
errors: list[str],
@@ -153,7 +155,7 @@ async def _check_metadata_rules(
is_standalone: bool = False,
) -> None:
"""Check rules specific to metadata files (.asc, .sha*, etc.)."""
- suffixes = set(relative_path.suffixes)
+ suffixes = set(relative_path.as_path().suffixes)
if ".md5" in suffixes:
# Forbidden by RCP, deprecated by RDP
@@ -189,8 +191,8 @@ async def _check_metadata_rules(
async def _check_path_process_single( # noqa: C901
asf_uid: str,
- base_path: pathlib.Path,
- relative_path: pathlib.Path,
+ base_path: safe.StatePath,
+ relative_path: safe.RelPath,
recorder_errors: checks.Recorder,
recorder_warnings: checks.Recorder,
recorder_success: checks.Recorder,
@@ -202,7 +204,7 @@ async def _check_path_process_single( # noqa: C901
relative_path_str = str(relative_path)
# For debugging and testing
- if (await user.is_admin_async(asf_uid)) and (full_path.name == "deliberately_slow_ATR_task_filename.txt"):
+ if (await user.is_admin_async(asf_uid)) and (full_path.path.name == "deliberately_slow_ATR_task_filename.txt"):
await asyncio.sleep(20)
errors: list[str] = []
@@ -211,31 +213,32 @@ async def _check_path_process_single( # noqa: C901
# The Release Distribution Policy specifically allows README and CHANGES, etc.
# We assume that LICENSE and NOTICE are permitted also
- if relative_path.name == "KEYS":
+ path = relative_path.as_path()
+ if path.name == "KEYS":
errors.append("The KEYS file should be uploaded via the 'Keys' section, not included in the artifact bundle")
- if relative_path.name in analysis.DISALLOWED_FILENAMES:
+ if path.name in analysis.DISALLOWED_FILENAMES:
await _record(
recorder_errors,
recorder_warnings,
recorder_success,
- relative_path_str,
+ relative_path,
errors,
- [f"Disallowed file: {relative_path.name}"],
+ [f"Disallowed file: {path.name}"],
warnings,
)
return
- elif relative_path.suffix in analysis.DISALLOWED_SUFFIXES:
+ elif path.suffix in analysis.DISALLOWED_SUFFIXES:
await _record(
recorder_errors,
recorder_warnings,
recorder_success,
- relative_path_str,
+ relative_path,
errors,
- [f"Disallowed file type: {relative_path.suffix}"],
+ [f"Disallowed file type: {path.suffix}"],
warnings,
)
return
- elif any(util.is_disallowed_dotfile(part) for part in relative_path.parts):
+ elif any(util.is_disallowed_dotfile(part) for part in path.parts):
# TODO: There is not a a policy for this
# We should enquire as to whether such a policy should be instituted
# We're forbidding dotfiles to catch accidental uploads of e.g. .git or .htaccess
@@ -272,14 +275,14 @@ async def _check_path_process_single( # noqa: C901
)
else:
log.info(f"Checking general rules for {full_path}")
- if (relative_path.parent == pathlib.Path(".")) and (relative_path.name not in _ALLOWED_TOP_LEVEL):
- errors.append(f"Unknown top level file: {relative_path.name}")
+ if (path.parent == pathlib.Path(".")) and (path.name not in _ALLOWED_TOP_LEVEL):
+ errors.append(f"Unknown top level file: {path.name}")
await _record(
recorder_errors,
recorder_warnings,
recorder_success,
- relative_path_str,
+ relative_path,
errors,
blockers,
warnings,
@@ -290,20 +293,20 @@ async def _record(
recorder_errors: checks.Recorder,
recorder_warnings: checks.Recorder,
recorder_success: checks.Recorder,
- relative_path_str: str,
+ relative_path: safe.RelPath,
errors: list[str],
blockers: list[str],
warnings: list[str],
) -> None:
for error in errors:
- await recorder_errors.failure(f"{relative_path_str}: {error}", {}, primary_rel_path=relative_path_str)
+ await recorder_errors.failure(f"{relative_path}: {error}", {}, primary_rel_path=relative_path)
for item in blockers:
- await recorder_errors.blocker(f"{relative_path_str}: {item}", {}, primary_rel_path=relative_path_str)
+ await recorder_errors.blocker(f"{relative_path}: {item}", {}, primary_rel_path=relative_path)
for warning in warnings:
- await recorder_warnings.warning(f"{relative_path_str}: {warning}", {}, primary_rel_path=relative_path_str)
+ await recorder_warnings.warning(f"{relative_path}: {warning}", {}, primary_rel_path=relative_path)
if not (errors or blockers or warnings):
await recorder_success.success(
- f"{relative_path_str}: Path structure and naming conventions conform to policy",
+ f"{relative_path}: Path structure and naming conventions conform to policy",
{},
- primary_rel_path=relative_path_str,
+ primary_rel_path=relative_path,
)
diff --git a/atr/tasks/checks/rat.py b/atr/tasks/checks/rat.py
index ca9648ba..5d9153d5 100644
--- a/atr/tasks/checks/rat.py
+++ b/atr/tasks/checks/rat.py
@@ -17,7 +17,6 @@
import asyncio
import os
-import pathlib
import shlex
import subprocess
import tempfile
@@ -30,6 +29,7 @@ import atr.constants as constants
import atr.log as log
import atr.models.checkdata as checkdata
import atr.models.results as results
+import atr.models.safe as safe
import atr.models.sql as sql
import atr.tasks.checks as checks
import atr.util as util
@@ -160,7 +160,7 @@ def _build_rat_command(
async def _check_core(
args: checks.FunctionArguments,
recorder: checks.Recorder,
- archive_dir: pathlib.Path,
+ archive_dir: safe.StatePath,
policy_excludes: list[str],
) -> None:
result = await asyncio.to_thread(
diff --git a/atr/tasks/checks/signature.py b/atr/tasks/checks/signature.py
index 6547c813..800b3b5d 100644
--- a/atr/tasks/checks/signature.py
+++ b/atr/tasks/checks/signature.py
@@ -46,7 +46,7 @@ async def check(args: checks.FunctionArguments) -> results.Results | None:
await recorder.exception("Primary relative path is required", {"primary_rel_path": primary_rel_path})
return None
- artifact_rel_path = primary_rel_path.removesuffix(".asc")
+ artifact_rel_path = str(primary_rel_path).removesuffix(".asc")
if not (artifact_abs_path := await recorder.abs_path(artifact_rel_path)):
return None
diff --git a/atr/tasks/checks/targz.py b/atr/tasks/checks/targz.py
index 38924c29..f7f9f9d5 100644
--- a/atr/tasks/checks/targz.py
+++ b/atr/tasks/checks/targz.py
@@ -18,12 +18,12 @@
import asyncio
import contextlib
import os
-import pathlib
import stat
from typing import Final
import atr.log as log
import atr.models.results as results
+import atr.models.safe as safe
import atr.tasks.checks as checks
import atr.util as util
@@ -39,7 +39,7 @@ class RootDirectoryError(Exception):
...
-def root_directory(archive_dir: pathlib.Path) -> tuple[str, bytes | None]:
+def root_directory(archive_dir: safe.StatePath) -> tuple[str, bytes | None]:
"""Find root directory and read package/package.json from the extracted tree."""
# The ._ prefix is a metadata convention
entries = sorted(e for e in os.listdir(archive_dir) if not e.startswith("._"))
@@ -52,7 +52,7 @@ def root_directory(archive_dir: pathlib.Path) -> tuple[str, bytes | None]:
root = entries[0]
root_path = archive_dir / root
try:
- root_stat = root_path.lstat()
+ root_stat = root_path.path.lstat()
except OSError as e:
raise RootDirectoryError(f"Unable to inspect root entry '{root}': {e}") from e
if not stat.S_ISDIR(root_stat.st_mode):
@@ -63,12 +63,12 @@ def root_directory(archive_dir: pathlib.Path) -> tuple[str, bytes | None]:
if root == "package":
package_json_path = archive_dir / "package" / "package.json"
with contextlib.suppress(FileNotFoundError, OSError):
- package_json_stat = package_json_path.lstat()
+ package_json_stat = package_json_path.path.lstat()
# We do this to avoid allowing package.json to be a symlink
if stat.S_ISREG(package_json_stat.st_mode):
size = package_json_stat.st_size
if (size > 0) and (size <= util.NPM_PACKAGE_JSON_MAX_SIZE):
- package_json = package_json_path.read_bytes()
+ package_json = package_json_path.path.read_bytes()
return root, package_json
@@ -89,7 +89,7 @@ async def structure(args: checks.FunctionArguments) -> results.Results | None:
)
return None
- filename = artifact_abs_path.name
+ filename = artifact_abs_path.path.name
basename_from_filename: Final[str] = (
filename.removesuffix(".tar.gz") if filename.endswith(".tar.gz") else filename.removesuffix(".tgz")
)
diff --git a/atr/tasks/checks/zipformat.py b/atr/tasks/checks/zipformat.py
index e18adedf..d9604441 100644
--- a/atr/tasks/checks/zipformat.py
+++ b/atr/tasks/checks/zipformat.py
@@ -17,12 +17,12 @@
import asyncio
import os
-import pathlib
import stat
from typing import Any, Final
import atr.log as log
import atr.models.results as results
+import atr.models.safe as safe
import atr.tasks.checks as checks
import atr.util as util
@@ -67,7 +67,7 @@ async def structure(args: checks.FunctionArguments) -> results.Results | None:
return None
-def _structure_check_core_logic(archive_dir: pathlib.Path, artifact_path: str) -> dict[str, Any]:
+def _structure_check_core_logic(archive_dir: safe.StatePath, artifact_path: str) -> dict[str, Any]:
"""Verify the internal structure of the zip archive."""
# The ._ prefix is a metadata convention
entries = sorted(e for e in os.listdir(archive_dir) if not e.startswith("._"))
@@ -81,7 +81,7 @@ def _structure_check_core_logic(archive_dir: pathlib.Path, artifact_path: str) -
root_dirs: list[str] = []
non_rooted_entries: list[str] = []
for entry in entries:
- entry_path = archive_dir / entry
+ entry_path = (archive_dir / entry).path
try:
entry_stat = entry_path.lstat()
except OSError as e:
@@ -117,9 +117,9 @@ def _structure_check_core_logic(archive_dir: pathlib.Path, artifact_path: str) -
def _structure_npm_result(
- archive_dir: pathlib.Path, basename_from_filename: str, actual_root: str, expected_roots: list[str]
+ archive_dir: safe.StatePath, basename_from_filename: str, actual_root: str, expected_roots: list[str]
) -> dict[str, Any] | None:
- package_json_path = archive_dir / "package" / "package.json"
+ package_json_path = (archive_dir / "package" / "package.json").path
try:
package_json_stat = package_json_path.lstat()
except (FileNotFoundError, OSError):
diff --git a/atr/tasks/quarantine.py b/atr/tasks/quarantine.py
index 72b47616..9137f11d 100644
--- a/atr/tasks/quarantine.py
+++ b/atr/tasks/quarantine.py
@@ -57,25 +57,28 @@ class QuarantineValidate(schema.Strict):
archives: list[QuarantineArchiveEntry]
-def backfill_archive_cache() -> list[tuple[str, pathlib.Path, float]]:
+def backfill_archive_cache() -> list[tuple[str, safe.StatePath, float]]:
done_file = _backfill_done_file()
- if done_file.exists():
+ done_file_path = done_file.path
+ if done_file_path.exists():
return []
unfinished_dir = paths.get_unfinished_dir()
- if not unfinished_dir.is_dir():
- done_file.parent.mkdir(parents=True, exist_ok=True)
- done_file.touch()
+ unfinished_dir_path = unfinished_dir.path
+ if not unfinished_dir_path.is_dir():
+ done_file_path.parent.mkdir(parents=True, exist_ok=True)
+ done_file_path.touch()
return []
archives_dir = paths.get_archives_dir()
staging_base = paths.get_tmp_dir()
- staging_base.mkdir(parents=True, exist_ok=True)
+ staging_base_path = staging_base.path
+ staging_base_path.mkdir(parents=True, exist_ok=True)
extraction_cfg = _extraction_config()
seen_archive_keys: set[str] = set()
- results_list: list[tuple[str, pathlib.Path, float]] = []
+ results_list: list[tuple[str, safe.StatePath, float]] = []
- for project_dir in sorted(unfinished_dir.iterdir()):
+ for project_dir in sorted(unfinished_dir_path.iterdir()):
if not project_dir.is_dir():
continue
for version_dir in sorted(project_dir.iterdir()):
@@ -85,7 +88,7 @@ def backfill_archive_cache() -> list[tuple[str, pathlib.Path, float]]:
if not revision_dir.is_dir():
continue
_backfill_revision(
- revision_dir,
+ safe.StatePath(revision_dir, unfinished_dir_path),
project_dir.name,
version_dir.name,
archives_dir,
@@ -95,8 +98,8 @@ def backfill_archive_cache() -> list[tuple[str, pathlib.Path, float]]:
results_list,
)
- done_file.parent.mkdir(parents=True, exist_ok=True)
- done_file.touch()
+ done_file_path.parent.mkdir(parents=True, exist_ok=True)
+ done_file_path.touch()
return results_list
@@ -130,7 +133,7 @@ async def validate(args: QuarantineValidate) -> results.Results | None:
return None
try:
- await _extract_archives(args.archives, quarantine_dir, str(project_key), str(version_key), file_entries)
+ await _extract_archives(args.archives, quarantine_dir, project_key, version_key, file_entries)
except Exception as exc:
await _mark_failed(quarantined, file_entries, f"Archive extraction failed: {exc}")
await aioshutil.rmtree(quarantine_dir)
@@ -140,16 +143,16 @@ async def validate(args: QuarantineValidate) -> results.Results | None:
return None
-def _backfill_done_file() -> pathlib.Path:
- return pathlib.Path(config.get().STATE_DIR) / "cache" / "archive-backfill.done"
+def _backfill_done_file() -> safe.StatePath:
+ return safe.StatePath(pathlib.Path(config.get().STATE_DIR)) / "cache" / "archive-backfill.done"
def _backfill_extract_archive(
- archive_path: pathlib.Path,
- archive_dir: pathlib.Path,
- staging_base: pathlib.Path,
+ archive_path: safe.StatePath,
+ archive_dir: safe.StatePath,
+ staging_base: safe.StatePath,
extraction_cfg: exarch.SecurityConfig,
- results_list: list[tuple[str, pathlib.Path, float]],
+ results_list: list[tuple[str, safe.StatePath, float]],
) -> None:
try:
elapsed = _extract_archive_to_dir(archive_path, archive_dir, staging_base, extraction_cfg)
@@ -159,17 +162,18 @@ def _backfill_extract_archive(
def _backfill_revision(
- revision_dir: pathlib.Path,
+ revision_dir: safe.StatePath,
project_key: str,
version_key: str,
- archives_dir: pathlib.Path,
- staging_base: pathlib.Path,
+ archives_dir: safe.StatePath,
+ staging_base: safe.StatePath,
extraction_cfg: exarch.SecurityConfig,
seen_archive_keys: set[str],
- results_list: list[tuple[str, pathlib.Path, float]],
+ results_list: list[tuple[str, safe.StatePath, float]],
) -> None:
+ revision_dir_path = revision_dir.path
archives_base = archives_dir / project_key / version_key
- for archive_path in sorted(revision_dir.rglob("*")):
+ for archive_path in sorted(revision_dir_path.rglob("*")):
if not archive_path.is_file():
continue
if not _is_archive_suffix(archive_path.name):
@@ -181,23 +185,31 @@ def _backfill_revision(
continue
seen_archive_keys.add(dedupe_key)
archive_dir = archives_base / archive_key
- if archive_dir.is_dir():
+ if archive_dir.path.is_dir():
continue
- _backfill_extract_archive(archive_path, archive_dir, staging_base, extraction_cfg, results_list)
+ _backfill_extract_archive(
+ safe.StatePath(archive_path, root=revision_dir_path),
+ archive_dir,
+ staging_base,
+ extraction_cfg,
+ results_list,
+ )
def _extract_archive_to_dir(
- archive_path: pathlib.Path,
- archive_dir: pathlib.Path,
- staging_base: pathlib.Path,
+ archive_path: safe.StatePath,
+ archive_dir: safe.StatePath,
+ staging_base: safe.StatePath,
extraction_cfg: exarch.SecurityConfig,
) -> float:
+ archive_dir_path = archive_dir.path
staging_dir = staging_base / f"archive-extract-{uuid.uuid4().hex}"
+ staging_dir_path = staging_dir.path
try:
- staging_dir.mkdir(parents=False, exist_ok=False)
+ staging_dir_path.mkdir(parents=False, exist_ok=False)
start = time.monotonic()
exarch.extract_archive(str(archive_path), str(staging_dir), extraction_cfg)
- archive_dir.parent.mkdir(parents=True, exist_ok=True)
+ archive_dir_path.parent.mkdir(parents=True, exist_ok=True)
try:
os.rename(staging_dir, archive_dir)
except OSError as err:
@@ -214,9 +226,9 @@ def _extract_archive_to_dir(
async def _extract_archives(
archives: list[QuarantineArchiveEntry],
- quarantine_dir: pathlib.Path,
- project_key: str,
- version_key: str,
+ quarantine_dir: safe.StatePath,
+ project_key: safe.ProjectKey,
+ version_key: safe.VersionKey,
file_entries: list[sql.QuarantineFileEntryV1],
) -> None:
archives_base = paths.get_archives_dir() / project_key / version_key
@@ -357,12 +369,12 @@ async def _promote(
async def _run_safety_checks(
- archives: list[QuarantineArchiveEntry], quarantine_dir: pathlib.Path
+ archives: list[QuarantineArchiveEntry], quarantine_dir: safe.StatePath
) -> tuple[list[sql.QuarantineFileEntryV1], bool]:
file_entries: list[sql.QuarantineFileEntryV1] = []
any_failed = False
for archive in archives:
- archive_path = str(quarantine_dir / archive.rel_path)
+ archive_path = quarantine_dir / archive.rel_path
stat = await aiofiles.os.stat(archive_path)
errors = await asyncio.to_thread(detection.check_archive_safety, archive_path)
entry = sql.QuarantineFileEntryV1(
@@ -377,6 +389,6 @@ async def _run_safety_checks(
return file_entries, any_failed
-def _set_archive_permissions(archive_dir: pathlib.Path) -> None:
+def _set_archive_permissions(archive_dir: os.PathLike) -> None:
util.chmod_files(archive_dir, 0o444)
util.chmod_directories(archive_dir, 0o555)
diff --git a/atr/tasks/sbom.py b/atr/tasks/sbom.py
index 79f4e502..07df0793 100644
--- a/atr/tasks/sbom.py
+++ b/atr/tasks/sbom.py
@@ -43,16 +43,16 @@ _CONFIG: Final = config.get()
class ConvertCycloneDX(schema.Strict):
"""Arguments for the task to generate a CycloneDX SBOM."""
- artifact_path: str = schema.description("Absolute path to the artifact")
- output_path: str = schema.description("Absolute path where the generated SBOM JSON should be written")
+ artifact_path: safe.StatePath = schema.description("Absolute path to the artifact")
+ output_path: safe.StatePath = schema.description("Absolute path where the generated SBOM JSON should be written")
revision: safe.RevisionNumber = schema.description("Revision number")
class GenerateCycloneDX(schema.Strict):
"""Arguments for the task to generate a CycloneDX SBOM."""
- artifact_path: str = schema.description("Absolute path to the artifact")
- output_path: str = schema.description("Absolute path where the generated SBOM JSON should be written")
+ artifact_path: safe.StatePath = schema.description("Absolute path to the artifact")
+ output_path: safe.StatePath = schema.description("Absolute path where the generated SBOM JSON should be written")
class SBOMConversionError(Exception):
@@ -110,7 +110,7 @@ async def augment(args: FileArgs) -> results.Results | None:
if not (full_path_str.endswith(".cdx.json") and await aiofiles.os.path.isfile(full_path)):
raise SBOMScoringError("SBOM file does not exist", {"file_path": path_str})
# Read from the old revision
- bundle = sbom.utilities.path_to_bundle(full_path)
+ bundle = sbom.utilities.path_to_bundle(full_path.path)
if not bundle:
raise SBOMScoringError("Could not load bundle")
patch_ops = await sbom.utilities.bundle_to_ntia_patch(bundle)
@@ -123,9 +123,9 @@ async def augment(args: FileArgs) -> results.Results | None:
async with storage.write(args.asf_uid) as write:
wacp = await write.as_project_committee_participant(args.project_key)
- async def modify(path: pathlib.Path, _old_rev: sql.Revision | None) -> None:
+ async def modify(path: safe.StatePath, _old_rev: sql.Revision | None) -> None:
nonlocal new_full_path, new_full_path_str
- new_full_path = path / path_str
+ new_full_path = (path / path_str).path
new_full_path_str = str(new_full_path)
# Write to the new revision
log.info(f"Writing augmented SBOM to {new_full_path_str}")
@@ -153,12 +153,14 @@ async def augment(args: FileArgs) -> results.Results | None:
async def convert_cyclonedx(args: ConvertCycloneDX) -> results.Results | None:
"""Generate a JSON CycloneDX SBOM from a given XML SBOM."""
try:
- result_data = await _convert_cyclonedx_core(args.artifact_path, args.output_path, str(args.revision))
+ result_data = await _convert_cyclonedx_core(args.artifact_path, args.output_path, args.revision)
log.info(f"Successfully converted CycloneDX SBOM for {args.artifact_path}")
msg = result_data["message"]
if not isinstance(msg, str):
raise SBOMConversionError(f"Invalid message type: {type(msg)}")
- return results.SBOMConvert(kind="sbom_convert", path=args.output_path, bom_version=result_data.get("version"))
+ return results.SBOMConvert(
+ kind="sbom_convert", path=str(args.output_path), bom_version=result_data.get("version")
+ )
except (archives.ExtractionError, SBOMGenerationError) as e:
log.error(f"SBOM conversion failed for {args.artifact_path}: {e}")
raise
@@ -194,7 +196,7 @@ async def osv_scan(args: FileArgs) -> results.Results | None:
full_path_str = str(full_path)
if not (full_path_str.endswith(".cdx.json") and await aiofiles.os.path.isfile(full_path)):
raise SBOMScanningError("SBOM file does not exist", {"file_path": path_str})
- bundle = sbom.utilities.path_to_bundle(full_path)
+ bundle = sbom.utilities.path_to_bundle(full_path.path)
if not bundle:
raise SBOMScanningError("Could not load bundle")
vulnerabilities, ignored = await sbom.osv.scan_bundle(bundle)
@@ -217,9 +219,9 @@ async def osv_scan(args: FileArgs) -> results.Results | None:
async with storage.write(args.asf_uid) as write:
wacp = await write.as_project_committee_participant(args.project_key)
- async def modify(path: pathlib.Path, _old_rev: sql.Revision | None) -> None:
+ async def modify(path: safe.StatePath, _old_rev: sql.Revision | None) -> None:
nonlocal new_full_path, new_full_path_str
- new_full_path = path / str(args.file_path)
+ new_full_path = (path / str(args.file_path)).path
new_full_path_str = str(new_full_path)
# Write to the new revision
log.info(f"Writing updated SBOM to {new_full_path_str}")
@@ -302,7 +304,7 @@ async def score_tool(args: ScoreArgs) -> results.Results | None:
full_path_str = str(full_path)
if not (full_path_str.endswith(".cdx.json") and await aiofiles.os.path.isfile(full_path)):
raise SBOMScoringError("SBOM file does not exist", {"file_path": path_str})
- bundle = sbom.utilities.path_to_bundle(full_path)
+ bundle = sbom.utilities.path_to_bundle(full_path.path)
if not bundle:
raise SBOMScoringError("Could not load bundle")
version, properties = sbom.utilities.get_props_from_bundle(bundle)
@@ -320,7 +322,7 @@ async def score_tool(args: ScoreArgs) -> results.Results | None:
if previous_base_dir is not None:
previous_full_path = previous_base_dir / path_str
try:
- previous_bundle = sbom.utilities.path_to_bundle(previous_full_path)
+ previous_bundle = sbom.utilities.path_to_bundle(previous_full_path.path)
except FileNotFoundError:
# Previous release didn't include this file
previous_bundle = None
@@ -353,7 +355,9 @@ async def score_tool(args: ScoreArgs) -> results.Results | None:
)
-async def _convert_cyclonedx_core(artifact_path: str, output_path: str, revision_str: str) -> dict[str, Any]:
+async def _convert_cyclonedx_core(
+ artifact_path: safe.StatePath, output_path: safe.StatePath, revision_str: safe.RevisionNumber
+) -> dict[str, Any]:
"""Core logic to convert XML CycloneDX SBOM to JSON."""
log.info(f"Generating CycloneDX JSON SBOM for {artifact_path} -> {output_path}")
@@ -361,7 +365,7 @@ async def _convert_cyclonedx_core(artifact_path: str, output_path: str, revision
bundle = sbom.utilities.path_to_bundle(pathlib.Path(artifact_path))
if not bundle:
raise SBOMConversionError("Could not load bundle")
- sbom.utilities.apply_patch("conversion to JSON", revision_str, bundle, [])
+ sbom.utilities.apply_patch("conversion to JSON", str(revision_str), bundle, [])
outputter = sbom.utilities.bundle_outputter(bundle)
text = outputter.output_as_string(indent=2)
@@ -399,7 +403,7 @@ def _extracted_dir(temp_dir: str) -> str | None:
return extract_dir
-async def _generate_cyclonedx_core(artifact_path: str, output_path: str) -> dict[str, Any]:
+async def _generate_cyclonedx_core(artifact_path: safe.StatePath, output_path: safe.StatePath) -> dict[str, Any]:
"""Core logic to generate CycloneDX SBOM on failure."""
log.info(f"Generating CycloneDX SBOM for {artifact_path} -> {output_path}")
diff --git a/atr/tasks/svn.py b/atr/tasks/svn.py
index 0f11f1ab..7dbad904 100644
--- a/atr/tasks/svn.py
+++ b/atr/tasks/svn.py
@@ -16,7 +16,6 @@
# under the License.
import asyncio
-import pathlib
from typing import Any, Final
import aiofiles.os
@@ -84,12 +83,12 @@ async def _import_files_core(args: SvnImport) -> str:
async with storage.write(args.asf_uid) as write:
wacp = await write.as_project_committee_participant(args.project_key)
- async def modify(path: pathlib.Path, _old_rev: sql.Revision | None) -> None:
+ async def modify(path: safe.StatePath, _old_rev: sql.Revision | None) -> None:
log.debug(f"Created revision directory: {path}")
- final_target_path = path
+ final_target_path = path.path
if args.target_subdirectory:
- final_target_path = path / args.target_subdirectory
+ final_target_path = (path / args.target_subdirectory).path
# Validate that final_target_path is a subdirectory of new_revision_dir
if not final_target_path.is_relative_to(path):
raise SvnImportError(
@@ -142,7 +141,7 @@ async def _import_files_core(args: SvnImport) -> str:
return f"Successfully imported files from SVN into revision {result.number}"
-async def _import_files_core_run_svn_export(svn_command: list[str], temp_export_path: pathlib.Path) -> None:
+async def _import_files_core_run_svn_export(svn_command: list[str], temp_export_path: safe.StatePath) -> None:
"""Execute the svn export command and handle errors."""
log.info(f"Executing SVN command: {' '.join(svn_command)}")
diff --git a/atr/templates/check-selected-path-table.html b/atr/templates/check-selected-path-table.html
index 11ca486b..67584a1c 100644
--- a/atr/templates/check-selected-path-table.html
+++ b/atr/templates/check-selected-path-table.html
@@ -69,7 +69,7 @@
<button type="submit" class="btn btn-sm btn-outline-primary">Import keys</button>
</form>
{% endif %}
- {% if path.suffixes[-2:] == [".cdx", ".json"] %}
+ {% if path.as_path().suffixes[-2:] == [".cdx", ".json"] %}
<a href="{{ as_url(get.sbom.report, project_key=project_key|string, version_key=version_key|string, file_path=path) }}"
class="btn btn-sm btn-outline-secondary">SBOM report</a>
{% endif %}
diff --git a/atr/util.py b/atr/util.py
index e8088cea..a8efe29b 100644
--- a/atr/util.py
+++ b/atr/util.py
@@ -52,6 +52,7 @@ import quart
import atr.config as config
import atr.ldap as ldap
import atr.log as log
+import atr.models.safe as safe
import atr.models.session
import atr.models.sql as sql
import atr.models.validation as validation
@@ -114,7 +115,7 @@ class FetchError(RuntimeError):
self.url = url
-async def archive_listing(file_path: pathlib.Path) -> list[str] | None:
+async def archive_listing(file_path: safe.StatePath) -> list[str] | None:
"""Attempt to list contents of supported archive files."""
if not await aiofiles.os.path.isfile(file_path):
return None
@@ -124,7 +125,7 @@ async def archive_listing(file_path: pathlib.Path) -> list[str] | None:
def _read_archive() -> list[str] | None:
with contextlib.suppress(tarfile.ReadError, zipfile.BadZipFile, EOFError, ValueError, OSError):
- with tarzip.open_archive(str(file_path)) as archive:
+ with tarzip.open_archive(file_path) as archive:
# TODO: Skip metadata files
return sorted(member.name for member in archive)
return None
@@ -184,7 +185,7 @@ async def asf_uid_from_uids(
@contextlib.asynccontextmanager
async def async_temporary_directory(
- suffix: str | None = None, prefix: str | None = None, dir: str | pathlib.Path | None = None
+ suffix: str | None = None, prefix: str | None = None, dir: str | os.PathLike | None = None
) -> AsyncGenerator[pathlib.Path]:
"""Create an async temporary directory similar to tempfile.TemporaryDirectory."""
temp_dir_path: str = await asyncio.to_thread(tempfile.mkdtemp, suffix=suffix, prefix=prefix, dir=dir)
@@ -234,16 +235,16 @@ async def atomic_write_file(file_path: pathlib.Path, content: str, encoding: str
raise
-def chmod_directories(path: pathlib.Path, permissions: int = DIRECTORY_PERMISSIONS) -> None:
+def chmod_directories(path: os.PathLike, permissions: int = DIRECTORY_PERMISSIONS) -> None:
os.chmod(path, permissions)
- for dir_path in path.rglob("*"):
+ for dir_path in pathlib.Path(path).rglob("*"):
if dir_path.is_dir():
os.chmod(dir_path, permissions)
-def chmod_files(path: pathlib.Path, permissions: int) -> None:
+def chmod_files(path: os.PathLike, permissions: int) -> None:
"""Set permissions on all files in a directory tree."""
- for file_path in path.rglob("*"):
+ for file_path in pathlib.Path(path).rglob("*"):
if file_path.is_file():
os.chmod(file_path, permissions)
@@ -267,7 +268,10 @@ def conjunction(items: Sequence[str], empty: str | None = None) -> str:
async def content_list(
- phase_subdir: pathlib.Path, project_key: str, version_key: str, revision_name: str | None = None
+ phase_subdir: safe.StatePath,
+ project_key: safe.ProjectKey,
+ version_key: safe.VersionKey,
+ revision_name: safe.RevisionNumber | None = None,
) -> AsyncGenerator[FileStat]:
"""List all the files in the given path."""
base_path = phase_subdir / project_key / version_key
@@ -312,8 +316,8 @@ def cookie_pmcs_or_session_pmcs(session_data: session.ClientSession) -> list[str
async def create_hard_link_clone(
- source_dir: pathlib.Path,
- dest_dir: pathlib.Path,
+ source_dir: safe.StatePath,
+ dest_dir: safe.StatePath,
do_not_create_dest_dir: bool = False,
exist_ok: bool = False,
dry_run: bool = False,
@@ -321,7 +325,7 @@ async def create_hard_link_clone(
"""Recursively create a clone of source_dir in dest_dir using hard links for files."""
await _create_hard_link_clone_checks(source_dir, dest_dir, do_not_create_dest_dir, exist_ok, dry_run)
- async def _clone_recursive(current_source: pathlib.Path, current_dest: pathlib.Path) -> None:
+ async def _clone_recursive(current_source: safe.StatePath, current_dest: safe.StatePath) -> None:
for entry in await aiofiles.os.scandir(current_source):
source_entry_path = current_source / entry.name
dest_entry_path = current_dest / entry.name
@@ -350,7 +354,7 @@ async def create_hard_link_clone(
def create_path_matcher(
- lines: Iterable[str], full_path: pathlib.Path | None, base_dir: pathlib.Path
+ lines: Iterable[str], full_path: pathlib.Path | None, base_dir: pathlib.Path | safe.StatePath
) -> Callable[[str], bool]:
rules = []
negation = False
@@ -389,7 +393,7 @@ def create_secure_ssl_context() -> ssl.SSLContext:
return ctx
-async def delete_immutable_directory(path: pathlib.Path, reason: str) -> None:
+async def delete_immutable_directory(path: safe.StatePath, reason: str) -> None:
if not reason:
raise ValueError("Reason is required to delete an immutable directory")
log.info(f"Deleting immutable directory {path} because {reason}")
@@ -594,9 +598,9 @@ def intersect_algs(policy: dict[str, Any], policy_key: str, supported: set[bytes
return [a for a in algs if isinstance(a, str) and (a.encode("ascii") in supported)]
-async def is_dir_resolve(path: pathlib.Path) -> pathlib.Path | None:
+async def is_dir_resolve(path: os.PathLike) -> pathlib.Path | None:
try:
- resolved_path = await asyncio.to_thread(path.resolve)
+ resolved_path = await asyncio.to_thread(pathlib.Path(path).resolve)
if not await aiofiles.os.path.isdir(resolved_path):
return None
except (FileNotFoundError, OSError):
@@ -777,7 +781,7 @@ def parse_npm_pack_info(raw: bytes, filename_basename: str | None = None) -> tup
return NpmPackInfo(name=name, version=version, filename_match=filename_match), None
-async def paths_recursive(base_path: pathlib.Path) -> AsyncGenerator[pathlib.Path]:
+async def paths_recursive(base_path: pathlib.Path | safe.StatePath) -> AsyncGenerator[safe.RelPath]:
"""Yield all file paths recursively within a base path, relative to the base path."""
if (resolved_base_path := await is_dir_resolve(base_path)) is None:
return
@@ -785,10 +789,10 @@ async def paths_recursive(base_path: pathlib.Path) -> AsyncGenerator[pathlib.Pat
abs_path_to_check = resolved_base_path / rel_path
with contextlib.suppress(FileNotFoundError, OSError):
if await aiofiles.os.path.isfile(abs_path_to_check):
- yield rel_path
+ yield safe.RelPath.from_path(rel_path)
-async def paths_recursive_all(base_path: pathlib.Path) -> AsyncGenerator[pathlib.Path]:
+async def paths_recursive_all(base_path: os.PathLike) -> AsyncGenerator[pathlib.Path]:
"""Yield all file and directory paths recursively within a base path, relative to the base path."""
if (resolved_base_path := await is_dir_resolve(base_path)) is None:
return
@@ -812,7 +816,8 @@ async def paths_recursive_all(base_path: pathlib.Path) -> AsyncGenerator[pathlib
queue.append(entry_abs_path)
-def paths_to_inodes(directory: pathlib.Path) -> dict[str, int]:
+def paths_to_inodes(directory: os.PathLike) -> dict[str, int]:
+ directory = pathlib.Path(directory)
result: dict[str, int] = {}
stack: list[pathlib.Path] = [directory]
while stack:
@@ -869,7 +874,7 @@ def plural(count: int, singular: str, plural_form: str | None = None, *, include
return word
-async def read_file_for_viewer(full_path: pathlib.Path, max_size: int) -> tuple[str | None, bool, bool, str | None]:
+async def read_file_for_viewer(full_path: safe.StatePath, max_size: int) -> tuple[str | None, bool, bool, str | None]:
"""Read file content for viewer."""
content: str | None = None
is_text = False
@@ -1285,8 +1290,8 @@ def write_quart_session_cookie(session_data: atr.models.session.CookieData) -> N
async def _create_hard_link_clone_checks(
- source_dir: pathlib.Path,
- dest_dir: pathlib.Path,
+ source_dir: safe.StatePath,
+ dest_dir: safe.StatePath,
do_not_create_dest_dir: bool = False,
exist_ok: bool = False,
dry_run: bool = False,
diff --git a/atr/validate.py b/atr/validate.py
index 0ff09065..d6c7bbd2 100644
--- a/atr/validate.py
+++ b/atr/validate.py
@@ -17,11 +17,11 @@
import asyncio
import datetime
-import pathlib
from collections.abc import AsyncGenerator, Callable, Generator, Iterable, Sequence
from typing import NamedTuple, TypeVar
import atr.db as db
+import atr.models.safe as safe
import atr.models.sql as sql
import atr.paths as paths
@@ -332,9 +332,9 @@ def release_on_disk(r: sql.Release) -> Divergences:
"""Check that the release is on disk."""
path = paths.release_directory(r)
- def okay(p: pathlib.Path) -> bool:
+ def okay(p: safe.StatePath) -> bool:
# The release directory must exist and contain at least one entry
- return p.exists() and any(p.iterdir())
+ return p.path.exists() and any(p.path.iterdir())
expected = "directory to exist and contain files"
yield from divergences_predicate(okay, expected, path)
diff --git a/atr/worker.py b/atr/worker.py
index 494cdabf..9a8ec813 100644
--- a/atr/worker.py
+++ b/atr/worker.py
@@ -130,7 +130,7 @@ async def _execute_check_task(
project_key=project_key,
version_key=version_key,
revision_number=revision_number,
- primary_rel_path=task_obj.primary_rel_path,
+ primary_rel_path=task_obj.safe_primary_rel_path,
)
function_arguments = checks.FunctionArguments(
@@ -139,7 +139,7 @@ async def _execute_check_task(
project_key=project_key,
version_key=version_key,
revision_number=revision_number,
- primary_rel_path=task_obj.primary_rel_path,
+ primary_rel_path=task_obj.safe_primary_rel_path,
extra_args=task_args,
)
log.debug(f"Calling {handler.__name__} with structured arguments: {function_arguments}")
diff --git a/tests/unit/recorders.py b/tests/unit/recorders.py
index aabb0b9d..215e30e1 100644
--- a/tests/unit/recorders.py
+++ b/tests/unit/recorders.py
@@ -16,7 +16,6 @@
# under the License.
import datetime
-import pathlib
from collections.abc import Awaitable, Callable
from typing import Any
@@ -26,7 +25,7 @@ import atr.tasks.checks as checks
class RecorderStub(checks.Recorder):
- def __init__(self, path: pathlib.Path, checker: str, checker_version: str | None = None) -> None:
+ def __init__(self, path: safe.StatePath, checker: str, checker_version: str | None = None) -> None:
super().__init__(
checker=checker,
checker_version=checker_version,
@@ -41,7 +40,7 @@ class RecorderStub(checks.Recorder):
self._path = path
self.messages: list[tuple[str, str, dict | None]] = []
- async def abs_path(self, rel_path: str | None = None) -> pathlib.Path | None:
+ async def abs_path(self, rel_path: str | None = None) -> safe.StatePath | None:
return self._path if (rel_path is None) else self._path / rel_path
async def primary_path_is_source(self) -> bool:
diff --git a/tests/unit/test_archive_root_variants.py b/tests/unit/test_archive_root_variants.py
index 1fae864b..55fec26d 100644
--- a/tests/unit/test_archive_root_variants.py
+++ b/tests/unit/test_archive_root_variants.py
@@ -40,7 +40,7 @@ async def test_targz_structure_accepts_npm_pack_root(tmp_path: pathlib.Path) ->
)
recorder, args = await _targz_structure_args(tmp_path, "example-1.2.3.tgz")
- with mock.patch.object(checks, "resolve_archive_dir", new=mock.AsyncMock(return_value=cache_dir)):
+ with mock.patch.object(checks, "resolve_archive_dir", new=mock.AsyncMock(return_value=safe.StatePath(cache_dir))):
await targz.structure(args)
assert any(status == sql.CheckResultStatus.SUCCESS.value for status, _, _ in recorder.messages)
@@ -52,7 +52,7 @@ async def test_targz_structure_accepts_source_suffix_variant(tmp_path: pathlib.P
cache_dir = _make_cache_tree(tmp_path, ["apache-example-1.2.3/README.txt"])
recorder, args = await _targz_structure_args(tmp_path, "apache-example-1.2.3-source.tar.gz")
- with mock.patch.object(checks, "resolve_archive_dir", new=mock.AsyncMock(return_value=cache_dir)):
+ with mock.patch.object(checks, "resolve_archive_dir", new=mock.AsyncMock(return_value=safe.StatePath(cache_dir))):
await targz.structure(args)
assert any(status == sql.CheckResultStatus.SUCCESS.value for status, _, _ in recorder.messages)
@@ -63,7 +63,7 @@ async def test_targz_structure_accepts_src_suffix_variant(tmp_path: pathlib.Path
cache_dir = _make_cache_tree(tmp_path, ["apache-example-1.2.3/README.txt"])
recorder, args = await _targz_structure_args(tmp_path, "apache-example-1.2.3-src.tar.gz")
- with mock.patch.object(checks, "resolve_archive_dir", new=mock.AsyncMock(return_value=cache_dir)):
+ with mock.patch.object(checks, "resolve_archive_dir", new=mock.AsyncMock(return_value=safe.StatePath(cache_dir))):
await targz.structure(args)
assert any(status == sql.CheckResultStatus.SUCCESS.value for status, _, _ in recorder.messages)
@@ -91,7 +91,7 @@ async def test_targz_structure_rejects_npm_pack_filename_mismatch(tmp_path: path
)
recorder, args = await _targz_structure_args(tmp_path, "example-1.2.3.tgz")
- with mock.patch.object(checks, "resolve_archive_dir", new=mock.AsyncMock(return_value=cache_dir)):
+ with mock.patch.object(checks, "resolve_archive_dir", new=mock.AsyncMock(return_value=safe.StatePath(cache_dir))):
await targz.structure(args)
assert any(status == sql.CheckResultStatus.FAILURE.value for status, _, _ in recorder.messages)
@@ -108,7 +108,7 @@ async def test_targz_structure_rejects_package_root_without_package_json(tmp_pat
)
recorder, args = await _targz_structure_args(tmp_path, "example-1.2.3.tgz")
- with mock.patch.object(checks, "resolve_archive_dir", new=mock.AsyncMock(return_value=cache_dir)):
+ with mock.patch.object(checks, "resolve_archive_dir", new=mock.AsyncMock(return_value=safe.StatePath(cache_dir))):
await targz.structure(args)
assert any(status == sql.CheckResultStatus.FAILURE.value for status, _, _ in recorder.messages)
@@ -119,7 +119,7 @@ async def test_targz_structure_rejects_source_root_when_filename_has_no_suffix(t
cache_dir = _make_cache_tree(tmp_path, ["apache-example-1.2.3-source/README.txt"])
recorder, args = await _targz_structure_args(tmp_path, "apache-example-1.2.3.tar.gz")
- with mock.patch.object(checks, "resolve_archive_dir", new=mock.AsyncMock(return_value=cache_dir)):
+ with mock.patch.object(checks, "resolve_archive_dir", new=mock.AsyncMock(return_value=safe.StatePath(cache_dir))):
await targz.structure(args)
assert any(status == sql.CheckResultStatus.FAILURE.value for status, _, _ in recorder.messages)
@@ -130,7 +130,7 @@ async def test_targz_structure_rejects_source_root_when_filename_has_src_suffix(
cache_dir = _make_cache_tree(tmp_path, ["apache-example-1.2.3-source/README.txt"])
recorder, args = await _targz_structure_args(tmp_path, "apache-example-1.2.3-src.tar.gz")
- with mock.patch.object(checks, "resolve_archive_dir", new=mock.AsyncMock(return_value=cache_dir)):
+ with mock.patch.object(checks, "resolve_archive_dir", new=mock.AsyncMock(return_value=safe.StatePath(cache_dir))):
await targz.structure(args)
assert any(status == sql.CheckResultStatus.FAILURE.value for status, _, _ in recorder.messages)
@@ -141,7 +141,7 @@ async def test_targz_structure_rejects_src_root_when_filename_has_no_suffix(tmp_
cache_dir = _make_cache_tree(tmp_path, ["apache-example-1.2.3-src/README.txt"])
recorder, args = await _targz_structure_args(tmp_path, "apache-example-1.2.3.tar.gz")
- with mock.patch.object(checks, "resolve_archive_dir", new=mock.AsyncMock(return_value=cache_dir)):
+ with mock.patch.object(checks, "resolve_archive_dir", new=mock.AsyncMock(return_value=safe.StatePath(cache_dir))):
await targz.structure(args)
assert any(status == sql.CheckResultStatus.FAILURE.value for status, _, _ in recorder.messages)
@@ -152,7 +152,7 @@ async def test_targz_structure_rejects_src_root_when_filename_has_source_suffix(
cache_dir = _make_cache_tree(tmp_path, ["apache-example-1.2.3-src/README.txt"])
recorder, args = await _targz_structure_args(tmp_path, "apache-example-1.2.3-source.tar.gz")
- with mock.patch.object(checks, "resolve_archive_dir", new=mock.AsyncMock(return_value=cache_dir)):
+ with mock.patch.object(checks, "resolve_archive_dir", new=mock.AsyncMock(return_value=safe.StatePath(cache_dir))):
await targz.structure(args)
assert any(status == sql.CheckResultStatus.FAILURE.value for status, _, _ in recorder.messages)
@@ -165,7 +165,7 @@ async def test_targz_structure_rejects_symlink_root(tmp_path: pathlib.Path) -> N
(cache_dir / "apache-example-1.2.3").symlink_to(cache_dir / "missing-target")
recorder, args = await _targz_structure_args(tmp_path, "apache-example-1.2.3.tar.gz")
- with mock.patch.object(checks, "resolve_archive_dir", new=mock.AsyncMock(return_value=cache_dir)):
+ with mock.patch.object(checks, "resolve_archive_dir", new=mock.AsyncMock(return_value=safe.StatePath(cache_dir))):
await targz.structure(args)
assert any(status == sql.CheckResultStatus.FAILURE.value for status, _, _ in recorder.messages)
@@ -183,7 +183,7 @@ async def test_targz_structure_rejects_symlinked_package_json(tmp_path: pathlib.
(cache_dir / "package" / "package.json").symlink_to(cache_dir / "metadata.json")
recorder, args = await _targz_structure_args(tmp_path, "example-1.2.3.tgz")
- with mock.patch.object(checks, "resolve_archive_dir", new=mock.AsyncMock(return_value=cache_dir)):
+ with mock.patch.object(checks, "resolve_archive_dir", new=mock.AsyncMock(return_value=safe.StatePath(cache_dir))):
await targz.structure(args)
assert any(status == sql.CheckResultStatus.FAILURE.value for status, _, _ in recorder.messages)
@@ -198,7 +198,7 @@ def test_zipformat_structure_accepts_npm_pack_root(tmp_path: pathlib.Path) -> No
},
)
- result = zipformat._structure_check_core_logic(cache_dir, "example-1.2.3.zip")
+ result = zipformat._structure_check_core_logic(safe.StatePath(cache_dir), "example-1.2.3.zip")
assert result.get("error") is None
assert result.get("root_dir") == "package"
@@ -207,7 +207,7 @@ def test_zipformat_structure_accepts_npm_pack_root(tmp_path: pathlib.Path) -> No
def test_zipformat_structure_accepts_src_suffix_variant(tmp_path: pathlib.Path) -> None:
cache_dir = _make_cache_tree(tmp_path, ["apache-example-1.2.3/README.txt"])
- result = zipformat._structure_check_core_logic(cache_dir, "apache-example-1.2.3-src.zip")
+ result = zipformat._structure_check_core_logic(safe.StatePath(cache_dir), "apache-example-1.2.3-src.zip")
assert result.get("error") is None
assert result.get("root_dir") == "apache-example-1.2.3"
@@ -216,7 +216,7 @@ def test_zipformat_structure_accepts_src_suffix_variant(tmp_path: pathlib.Path)
def test_zipformat_structure_rejects_dated_src_suffix(tmp_path: pathlib.Path) -> None:
cache_dir = _make_cache_tree(tmp_path, ["apache-example-1.2.3/README.txt"])
- result = zipformat._structure_check_core_logic(cache_dir, "apache-example-1.2.3-src-20251202.zip")
+ result = zipformat._structure_check_core_logic(safe.StatePath(cache_dir), "apache-example-1.2.3-src-20251202.zip")
assert "error" in result
assert "Root directory mismatch" in result["error"]
@@ -231,7 +231,7 @@ def test_zipformat_structure_rejects_npm_pack_filename_mismatch(tmp_path: pathli
},
)
- result = zipformat._structure_check_core_logic(cache_dir, "example-1.2.3.zip")
+ result = zipformat._structure_check_core_logic(safe.StatePath(cache_dir), "example-1.2.3.zip")
assert result.get("error") is not None
assert "npm pack layout detected" in result["error"]
@@ -246,7 +246,7 @@ def test_zipformat_structure_rejects_package_root_without_package_json(tmp_path:
},
)
- result = zipformat._structure_check_core_logic(cache_dir, "example-1.2.3.zip")
+ result = zipformat._structure_check_core_logic(safe.StatePath(cache_dir), "example-1.2.3.zip")
assert result.get("error") is not None
assert "Root directory mismatch" in result["error"]
@@ -256,7 +256,7 @@ def test_zipformat_structure_rejects_top_level_symlink(tmp_path: pathlib.Path) -
cache_dir = _make_cache_tree(tmp_path, ["example-1.2.3/README.txt"])
(cache_dir / "link").symlink_to(cache_dir / "missing-target")
- result = zipformat._structure_check_core_logic(cache_dir, "example-1.2.3.zip")
+ result = zipformat._structure_check_core_logic(safe.StatePath(cache_dir), "example-1.2.3.zip")
assert result.get("error") is not None
assert "Files found directly in root" in result["error"]
@@ -287,7 +287,8 @@ def _make_cache_tree_with_contents(base: pathlib.Path, members: dict[str, str])
async def _targz_structure_args(
tmp_path: pathlib.Path, archive_filename: str
) -> tuple[recorders.RecorderStub, checks.FunctionArguments]:
- archive_path = tmp_path / archive_filename
+ temp_dir = safe.StatePath(tmp_path)
+ archive_path = temp_dir / archive_filename
recorder = recorders.RecorderStub(archive_path, "tests.unit.test_archive_root_variants")
args = checks.FunctionArguments(
recorder=recorders.get_recorder(recorder),
@@ -295,7 +296,7 @@ async def _targz_structure_args(
project_key=safe.ProjectKey("test"),
version_key=safe.VersionKey("test"),
revision_number=safe.RevisionNumber("00001"),
- primary_rel_path=archive_filename,
+ primary_rel_path=safe.RelPath(archive_filename),
extra_args={},
)
return recorder, args
diff --git a/tests/unit/test_attestable.py b/tests/unit/test_attestable.py
index 0d8619c8..254e2db2 100644
--- a/tests/unit/test_attestable.py
+++ b/tests/unit/test_attestable.py
@@ -19,6 +19,7 @@ import pathlib
import atr.attestable as attestable
import atr.models.attestable as models
+import atr.models.safe as safe
def test_attestable_v2_round_trip():
@@ -43,13 +44,19 @@ def test_attestable_v2_round_trip():
def test_generate_files_data_returns_attestable_v2():
data = attestable._generate_files_data(
- path_to_hash={"apache-widget-1.0-src.tar.gz": "h1", "apache-widget-1.0-src.tar.gz.sha512": "h2"},
- path_to_size={"apache-widget-1.0-src.tar.gz": 100, "apache-widget-1.0-src.tar.gz.sha512": 64},
- revision_number="00001",
+ path_to_hash={
+ safe.RelPath("apache-widget-1.0-src.tar.gz"): "h1",
+ safe.RelPath("apache-widget-1.0-src.tar.gz.sha512"): "h2",
+ },
+ path_to_size={
+ safe.RelPath("apache-widget-1.0-src.tar.gz"): 100,
+ safe.RelPath("apache-widget-1.0-src.tar.gz.sha512"): 64,
+ },
+ revision_number=safe.RevisionNumber("00001"),
release_policy=None,
uploader_uid="alice",
previous=None,
- base_path=pathlib.Path("/test"),
+ base_path=safe.StatePath(pathlib.Path("/test")),
)
assert isinstance(data, models.AttestableV2)
@@ -86,26 +93,26 @@ def test_hash_metadata_basenames_are_cumulative_and_unique():
policy={},
)
path_to_hash = {
- "dist/apache-widget-1.0-src.tar.gz": "h1",
- "dist/apache-widget-1.0.zip": "h1",
- "other/apache-widget-1.0.zip": "h1",
- "docs/readme.txt": "h2",
+ safe.RelPath("dist/apache-widget-1.0-src.tar.gz"): "h1",
+ safe.RelPath("dist/apache-widget-1.0.zip"): "h1",
+ safe.RelPath("other/apache-widget-1.0.zip"): "h1",
+ safe.RelPath("docs/readme.txt"): "h2",
}
path_to_size = {
- "dist/apache-widget-1.0-src.tar.gz": 100,
- "dist/apache-widget-1.0.zip": 100,
- "other/apache-widget-1.0.zip": 100,
- "docs/readme.txt": 50,
+ safe.RelPath("dist/apache-widget-1.0-src.tar.gz"): 100,
+ safe.RelPath("dist/apache-widget-1.0.zip"): 100,
+ safe.RelPath("other/apache-widget-1.0.zip"): 100,
+ safe.RelPath("docs/readme.txt"): 50,
}
data = attestable._generate_files_data(
path_to_hash=path_to_hash,
path_to_size=path_to_size,
- revision_number="00002",
+ revision_number=safe.RevisionNumber("00002"),
release_policy=None,
uploader_uid="bob",
previous=previous,
- base_path=pathlib.Path("/test"),
+ base_path=safe.StatePath(pathlib.Path("/test")),
)
assert data.hashes["h1"].basenames == ["apache-widget-1.0-src.tar.gz", "apache-widget-1.0.zip"]
diff --git a/tests/unit/test_checks_compare.py b/tests/unit/test_checks_compare.py
index 338f331d..18105b79 100644
--- a/tests/unit/test_checks_compare.py
+++ b/tests/unit/test_checks_compare.py
@@ -29,6 +29,7 @@ import pytest
import atr.attestable
import atr.models.github
import atr.models.safe
+import atr.models.safe as safe
import atr.models.sql
import atr.tasks.checks
import atr.tasks.checks.compare
@@ -36,13 +37,13 @@ import atr.tasks.checks.compare
class CheckoutRecorder:
def __init__(self, return_value: str | None = None) -> None:
- self.checkout_dir: pathlib.Path | None = None
+ self.checkout_dir: safe.StatePath | None = None
self.return_value = return_value
async def __call__(
self,
payload: atr.models.github.TrustedPublisherPayload,
- checkout_dir: pathlib.Path,
+ checkout_dir: safe.StatePath,
) -> str | None:
self.checkout_dir = checkout_dir
assert await aiofiles.os.path.exists(checkout_dir)
@@ -82,7 +83,7 @@ class CompareRecorder:
class ArchiveDirResolver:
- def __init__(self, cache_dir: pathlib.Path | None) -> None:
+ def __init__(self, cache_dir: safe.StatePath | None) -> None:
self.cache_dir = cache_dir
async def __call__(self, args: object) -> pathlib.Path | None:
@@ -269,10 +270,10 @@ class RepoStub:
class ReturnValue:
- def __init__(self, value: pathlib.Path) -> None:
+ def __init__(self, value: safe.StatePath) -> None:
self.value = value
- def __call__(self) -> pathlib.Path:
+ def __call__(self) -> safe.StatePath:
return self.value
@@ -299,8 +300,9 @@ class WorktreeStub:
async def test_checkout_github_source_uses_provided_dir(
monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
) -> None:
+ temp_dir = safe.StatePath(tmp_path)
payload = _make_payload()
- checkout_dir = tmp_path / "checkout"
+ checkout_dir = temp_dir / "checkout"
clone_recorder = CloneRecorder()
monkeypatch.setattr(atr.tasks.checks.compare, "_clone_repo", clone_recorder)
@@ -316,8 +318,9 @@ async def test_checkout_github_source_uses_provided_dir(
def test_clone_repo_fetches_requested_sha(monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> None:
- checkout_dir = tmp_path / "checkout"
- git_dir = checkout_dir / ".git"
+ temp_dir = safe.StatePath(tmp_path)
+ checkout_dir = temp_dir / "checkout"
+ git_dir = pathlib.Path(checkout_dir) / ".git"
git_dir.mkdir(parents=True)
worktree = WorktreeStub()
repo = RepoStub(git_dir, worktree)
@@ -345,8 +348,9 @@ def test_clone_repo_fetches_requested_sha(monkeypatch: pytest.MonkeyPatch, tmp_p
def test_clone_repo_raises_when_commit_missing(monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> None:
- checkout_dir = tmp_path / "checkout"
- git_dir = checkout_dir / ".git"
+ temp_dir = safe.StatePath(tmp_path)
+ checkout_dir = temp_dir / "checkout"
+ git_dir = pathlib.Path(checkout_dir) / ".git"
git_dir.mkdir(parents=True)
worktree = WorktreeStub()
repo = RepoStub(git_dir, worktree)
@@ -372,10 +376,11 @@ def test_clone_repo_raises_when_commit_missing(monkeypatch: pytest.MonkeyPatch,
def test_compare_trees_rsync_archive_has_extra_file(monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> None:
- repo_dir = tmp_path / "repo"
- archive_dir = tmp_path / "archive"
- _make_tree(repo_dir, ["a.txt"])
- _make_tree(archive_dir, ["a.txt", "b.txt"])
+ temp_dir = safe.StatePath(tmp_path)
+ repo_dir = temp_dir / "repo"
+ archive_dir = temp_dir / "archive"
+ _make_tree(repo_dir.path, ["a.txt"])
+ _make_tree(archive_dir.path, ["a.txt", "b.txt"])
completed = subprocess.CompletedProcess(
args=["rsync"],
returncode=0,
@@ -394,10 +399,11 @@ def test_compare_trees_rsync_archive_has_extra_file(monkeypatch: pytest.MonkeyPa
def test_compare_trees_rsync_content_differs(monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> None:
- repo_dir = tmp_path / "repo"
- archive_dir = tmp_path / "archive"
- _make_tree(repo_dir, ["a.txt", "b.txt"])
- _make_tree(archive_dir, ["a.txt", "b.txt"])
+ temp_dir = safe.StatePath(tmp_path)
+ repo_dir = temp_dir / "repo"
+ archive_dir = temp_dir / "archive"
+ _make_tree(repo_dir.path, ["a.txt", "b.txt"])
+ _make_tree(archive_dir.path, ["a.txt", "b.txt"])
completed = subprocess.CompletedProcess(
args=["rsync"],
returncode=0,
@@ -416,10 +422,11 @@ def test_compare_trees_rsync_content_differs(monkeypatch: pytest.MonkeyPatch, tm
def test_compare_trees_rsync_distinct_files(monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> None:
- repo_dir = tmp_path / "repo"
- archive_dir = tmp_path / "archive"
- _make_tree(repo_dir, ["a.txt"])
- _make_tree(archive_dir, ["b.txt"])
+ temp_dir = safe.StatePath(tmp_path)
+ repo_dir = temp_dir / "repo"
+ archive_dir = temp_dir / "archive"
+ _make_tree(repo_dir.path, ["a.txt"])
+ _make_tree(archive_dir.path, ["b.txt"])
completed = subprocess.CompletedProcess(
args=["rsync"],
returncode=0,
@@ -438,10 +445,11 @@ def test_compare_trees_rsync_distinct_files(monkeypatch: pytest.MonkeyPatch, tmp
def test_compare_trees_rsync_ignores_timestamp_only(monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> None:
- repo_dir = tmp_path / "repo"
- archive_dir = tmp_path / "archive"
- _make_tree(repo_dir, ["a.txt"])
- _make_tree(archive_dir, ["a.txt"])
+ temp_dir = safe.StatePath(tmp_path)
+ repo_dir = temp_dir / "repo"
+ archive_dir = temp_dir / "archive"
+ _make_tree(repo_dir.path, ["a.txt"])
+ _make_tree(archive_dir.path, ["a.txt"])
completed = subprocess.CompletedProcess(
args=["rsync"],
returncode=0,
@@ -460,10 +468,11 @@ def test_compare_trees_rsync_ignores_timestamp_only(monkeypatch: pytest.MonkeyPa
def test_compare_trees_rsync_repo_has_extra_file(monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> None:
- repo_dir = tmp_path / "repo"
- archive_dir = tmp_path / "archive"
- _make_tree(repo_dir, ["a.txt", "b.txt"])
- _make_tree(archive_dir, ["a.txt"])
+ temp_dir = safe.StatePath(tmp_path)
+ repo_dir = temp_dir / "repo"
+ archive_dir = temp_dir / "archive"
+ _make_tree(repo_dir.path, ["a.txt", "b.txt"])
+ _make_tree(archive_dir.path, ["a.txt"])
completed = subprocess.CompletedProcess(
args=["rsync"],
returncode=0,
@@ -482,10 +491,11 @@ def test_compare_trees_rsync_repo_has_extra_file(monkeypatch: pytest.MonkeyPatch
def test_compare_trees_rsync_trees_match(monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> None:
- repo_dir = tmp_path / "repo"
- archive_dir = tmp_path / "archive"
- _make_tree(repo_dir, ["a.txt", "b.txt"])
- _make_tree(archive_dir, ["a.txt", "b.txt"])
+ temp_dir = safe.StatePath(tmp_path)
+ repo_dir = temp_dir / "repo"
+ archive_dir = temp_dir / "archive"
+ _make_tree(repo_dir.path, ["a.txt", "b.txt"])
+ _make_tree(archive_dir.path, ["a.txt", "b.txt"])
completed = subprocess.CompletedProcess(
args=["rsync"],
returncode=0,
@@ -505,11 +515,12 @@ def test_compare_trees_rsync_trees_match(monkeypatch: pytest.MonkeyPatch, tmp_pa
@pytest.mark.asyncio
async def test_find_archive_root_accepts_any_single_directory(tmp_path: pathlib.Path) -> None:
- archive_path = tmp_path / "my-project-1.0.0.tar.gz"
- extract_dir = tmp_path / "extracted"
- extract_dir.mkdir(parents=True)
+ temp_dir = safe.StatePath(tmp_path)
+ archive_path = temp_dir / "my-project-1.0.0.tar.gz"
+ extract_dir = temp_dir / "extracted"
+ extract_dir.path.mkdir(parents=True)
root_dir = extract_dir / "package"
- root_dir.mkdir()
+ root_dir.path.mkdir()
result = await atr.tasks.checks.compare._find_archive_root(archive_path, extract_dir)
@@ -519,12 +530,13 @@ async def test_find_archive_root_accepts_any_single_directory(tmp_path: pathlib.
@pytest.mark.asyncio
async def test_find_archive_root_detects_extra_file_entries(tmp_path: pathlib.Path) -> None:
- archive_path = tmp_path / "my-project-1.0.0.tar.gz"
- extract_dir = tmp_path / "extracted"
+ temp_dir = safe.StatePath(tmp_path)
+ archive_path = temp_dir / "my-project-1.0.0.tar.gz"
+ extract_dir = temp_dir / "extracted"
root_dir = extract_dir / "my-project-1.0.0"
- root_dir.mkdir(parents=True)
- (extract_dir / "extra.txt").write_text("extra")
- (extract_dir / "README").write_text("readme")
+ root_dir.path.mkdir(parents=True)
+ (extract_dir / "extra.txt").path.write_text("extra")
+ (extract_dir / "README").path.write_text("readme")
result = await atr.tasks.checks.compare._find_archive_root(archive_path, extract_dir)
@@ -534,10 +546,11 @@ async def test_find_archive_root_detects_extra_file_entries(tmp_path: pathlib.Pa
@pytest.mark.asyncio
async def test_find_archive_root_finds_expected_root(tmp_path: pathlib.Path) -> None:
- archive_path = tmp_path / "my-project-1.0.0.tar.gz"
- extract_dir = tmp_path / "extracted"
+ temp_dir = safe.StatePath(tmp_path)
+ archive_path = temp_dir / "my-project-1.0.0.tar.gz"
+ extract_dir = temp_dir / "extracted"
root_dir = extract_dir / "my-project-1.0.0"
- root_dir.mkdir(parents=True)
+ root_dir.path.mkdir(parents=True)
result = await atr.tasks.checks.compare._find_archive_root(archive_path, extract_dir)
@@ -547,10 +560,11 @@ async def test_find_archive_root_finds_expected_root(tmp_path: pathlib.Path) ->
@pytest.mark.asyncio
async def test_find_archive_root_finds_root_with_source_suffix(tmp_path: pathlib.Path) -> None:
- archive_path = tmp_path / "my-project-1.0.0-source.tar.gz"
- extract_dir = tmp_path / "extracted"
+ temp_dir = safe.StatePath(tmp_path)
+ archive_path = temp_dir / "my-project-1.0.0-source.tar.gz"
+ extract_dir = temp_dir / "extracted"
root_dir = extract_dir / "my-project-1.0.0-source"
- root_dir.mkdir(parents=True)
+ root_dir.path.mkdir(parents=True)
result = await atr.tasks.checks.compare._find_archive_root(archive_path, extract_dir)
@@ -560,10 +574,11 @@ async def test_find_archive_root_finds_root_with_source_suffix(tmp_path: pathlib
@pytest.mark.asyncio
async def test_find_archive_root_finds_root_without_source_suffix(tmp_path: pathlib.Path) -> None:
- archive_path = tmp_path / "my-project-1.0.0-source.tar.gz"
- extract_dir = tmp_path / "extracted"
+ temp_dir = safe.StatePath(tmp_path)
+ archive_path = temp_dir / "my-project-1.0.0-source.tar.gz"
+ extract_dir = temp_dir / "extracted"
root_dir = extract_dir / "my-project-1.0.0"
- root_dir.mkdir(parents=True)
+ root_dir.path.mkdir(parents=True)
result = await atr.tasks.checks.compare._find_archive_root(archive_path, extract_dir)
@@ -573,11 +588,12 @@ async def test_find_archive_root_finds_root_without_source_suffix(tmp_path: path
@pytest.mark.asyncio
async def test_find_archive_root_ignores_macos_metadata(tmp_path: pathlib.Path) -> None:
- archive_path = tmp_path / "my-project-1.0.0.tar.gz"
- extract_dir = tmp_path / "extracted"
+ temp_dir = safe.StatePath(tmp_path)
+ archive_path = temp_dir / "my-project-1.0.0.tar.gz"
+ extract_dir = temp_dir / "extracted"
root_dir = extract_dir / "my-project-1.0.0"
- root_dir.mkdir(parents=True)
- metadata_file = extract_dir / "._my-project-1.0.0"
+ root_dir.path.mkdir(parents=True)
+ metadata_file = pathlib.Path(extract_dir) / "._my-project-1.0.0"
metadata_file.write_text("metadata")
result = await atr.tasks.checks.compare._find_archive_root(archive_path, extract_dir)
@@ -588,11 +604,12 @@ async def test_find_archive_root_ignores_macos_metadata(tmp_path: pathlib.Path)
@pytest.mark.asyncio
async def test_find_archive_root_returns_none_when_multiple_directories(tmp_path: pathlib.Path) -> None:
- archive_path = tmp_path / "my-project-1.0.0.tar.gz"
- extract_dir = tmp_path / "extracted"
- extract_dir.mkdir(parents=True)
- (extract_dir / "dir1").mkdir()
- (extract_dir / "dir2").mkdir()
+ temp_dir = safe.StatePath(tmp_path)
+ archive_path = temp_dir / "my-project-1.0.0.tar.gz"
+ extract_dir = temp_dir / "extracted"
+ extract_dir.path.mkdir(parents=True)
+ (extract_dir / "dir1").path.mkdir()
+ (extract_dir / "dir2").path.mkdir()
result = await atr.tasks.checks.compare._find_archive_root(archive_path, extract_dir)
@@ -601,10 +618,11 @@ async def test_find_archive_root_returns_none_when_multiple_directories(tmp_path
@pytest.mark.asyncio
async def test_find_archive_root_returns_none_when_no_directories(tmp_path: pathlib.Path) -> None:
- archive_path = tmp_path / "my-project-1.0.0.tar.gz"
- extract_dir = tmp_path / "extracted"
- extract_dir.mkdir(parents=True)
- (extract_dir / "file.txt").write_text("content")
+ temp_dir = safe.StatePath(tmp_path)
+ archive_path = temp_dir / "my-project-1.0.0.tar.gz"
+ extract_dir = temp_dir / "extracted"
+ extract_dir.path.mkdir(parents=True)
+ (extract_dir / "file.txt").path.write_text("content")
result = await atr.tasks.checks.compare._find_archive_root(archive_path, extract_dir)
@@ -615,15 +633,16 @@ async def test_find_archive_root_returns_none_when_no_directories(tmp_path: path
async def test_source_trees_creates_temp_workspace_and_cleans_up(
monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
) -> None:
+ temp_dir = safe.StatePath(tmp_path)
recorder = RecorderStub(True)
args = _make_args(recorder)
payload = _make_payload()
checkout = CheckoutRecorder()
- cache_dir = tmp_path / "cache"
- cache_dir.mkdir()
+ cache_dir = temp_dir / "cache"
+ cache_dir.path.mkdir()
find_root = FindArchiveRootRecorder("artifact")
compare = CompareRecorder(repo_only={"extra1.txt", "extra2.txt"})
- tmp_root = tmp_path / "temporary-root"
+ tmp_root = temp_dir / "temporary-root"
monkeypatch.setattr(atr.attestable, "github_tp_payload_read", PayloadLoader(payload))
monkeypatch.setattr(atr.tasks.checks.compare, "_checkout_github_source", checkout)
@@ -636,9 +655,9 @@ async def test_source_trees_creates_temp_workspace_and_cleans_up(
assert checkout.checkout_dir is not None
checkout_dir = checkout.checkout_dir
- assert checkout_dir.name == "github"
- assert checkout_dir.parent.parent == tmp_root
- assert checkout_dir.parent.name.startswith("trees-")
+ assert checkout_dir.path.name == "github"
+ assert checkout_dir.parent.path.parent == tmp_root.path
+ assert checkout_dir.parent.path.name.startswith("trees-")
assert await aiofiles.os.path.exists(tmp_root)
assert not await aiofiles.os.path.exists(checkout_dir.parent)
assert len(recorder.success_calls) == 1
@@ -670,16 +689,17 @@ async def test_source_trees_payload_none_skips_temp_workspace(monkeypatch: pytes
async def test_source_trees_permits_pkg_info_when_pyproject_toml_exists(
monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
) -> None:
+ temp_dir = safe.StatePath(tmp_path)
recorder = RecorderStub(True)
args = _make_args(recorder)
payload = _make_payload()
checkout = CheckoutRecorder()
- cache_dir = tmp_path / "cache"
- (cache_dir / "artifact").mkdir(parents=True)
- (cache_dir / "artifact" / "pyproject.toml").write_text("[project]\nname = 'test'\n")
+ cache_dir = temp_dir / "cache"
+ (cache_dir / "artifact").path.mkdir(parents=True)
+ (cache_dir / "artifact" / "pyproject.toml").path.write_text("[project]\nname = 'test'\n")
find_root = FindArchiveRootRecorder("artifact")
compare = CompareRecorder(invalid={"PKG-INFO"})
- tmp_root = tmp_path / "temporary-root"
+ tmp_root = temp_dir / "temporary-root"
monkeypatch.setattr(atr.attestable, "github_tp_payload_read", PayloadLoader(payload))
monkeypatch.setattr(atr.tasks.checks.compare, "_checkout_github_source", checkout)
@@ -698,15 +718,16 @@ async def test_source_trees_permits_pkg_info_when_pyproject_toml_exists(
async def test_source_trees_records_failure_when_archive_has_invalid_files(
monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
) -> None:
+ temp_dir = safe.StatePath(tmp_path)
recorder = RecorderStub(True)
args = _make_args(recorder)
payload = _make_payload()
checkout = CheckoutRecorder()
- cache_dir = tmp_path / "cache"
- cache_dir.mkdir()
+ cache_dir = temp_dir / "cache"
+ cache_dir.path.mkdir()
find_root = FindArchiveRootRecorder("artifact")
compare = CompareRecorder(invalid={"bad1.txt", "bad2.txt"}, repo_only={"ok.txt"})
- tmp_root = tmp_path / "temporary-root"
+ tmp_root = temp_dir / "temporary-root"
monkeypatch.setattr(atr.attestable, "github_tp_payload_read", PayloadLoader(payload))
monkeypatch.setattr(atr.tasks.checks.compare, "_checkout_github_source", checkout)
@@ -730,14 +751,15 @@ async def test_source_trees_records_failure_when_archive_has_invalid_files(
async def test_source_trees_records_failure_when_archive_root_not_found(
monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
) -> None:
+ temp_dir = safe.StatePath(tmp_path)
recorder = RecorderStub(True)
args = _make_args(recorder)
payload = _make_payload()
checkout = CheckoutRecorder()
- cache_dir = tmp_path / "cache"
- cache_dir.mkdir()
+ cache_dir = temp_dir / "cache"
+ cache_dir.path.mkdir()
find_root = FindArchiveRootRecorder(root=None)
- tmp_root = tmp_path / "temporary-root"
+ tmp_root = temp_dir / "temporary-root"
monkeypatch.setattr(atr.attestable, "github_tp_payload_read", PayloadLoader(payload))
monkeypatch.setattr(atr.tasks.checks.compare, "_checkout_github_source", checkout)
@@ -782,14 +804,15 @@ async def test_source_trees_records_failure_when_cache_dir_unavailable(
async def test_source_trees_records_failure_when_extra_entries_in_archive(
monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
) -> None:
+ temp_dir = safe.StatePath(tmp_path)
recorder = RecorderStub(True)
args = _make_args(recorder)
payload = _make_payload()
checkout = CheckoutRecorder()
- cache_dir = tmp_path / "cache"
- cache_dir.mkdir()
+ cache_dir = temp_dir / "cache"
+ cache_dir.path.mkdir()
find_root = FindArchiveRootRecorder(root="artifact", extra_entries=["README.txt", "extra.txt"])
- tmp_root = tmp_path / "temporary-root"
+ tmp_root = temp_dir / "temporary-root"
monkeypatch.setattr(atr.attestable, "github_tp_payload_read", PayloadLoader(payload))
monkeypatch.setattr(atr.tasks.checks.compare, "_checkout_github_source", checkout)
@@ -811,16 +834,17 @@ async def test_source_trees_records_failure_when_extra_entries_in_archive(
async def test_source_trees_reports_repo_only_sample_limited_to_five(
monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
) -> None:
+ temp_dir = safe.StatePath(tmp_path)
recorder = RecorderStub(True)
args = _make_args(recorder)
payload = _make_payload()
checkout = CheckoutRecorder()
- cache_dir = tmp_path / "cache"
- cache_dir.mkdir()
+ cache_dir = temp_dir / "cache"
+ cache_dir.path.mkdir()
find_root = FindArchiveRootRecorder("artifact")
repo_only_files = {f"file{i}.txt" for i in range(10)}
compare = CompareRecorder(repo_only=repo_only_files)
- tmp_root = tmp_path / "temporary-root"
+ tmp_root = temp_dir / "temporary-root"
monkeypatch.setattr(atr.attestable, "github_tp_payload_read", PayloadLoader(payload))
monkeypatch.setattr(atr.tasks.checks.compare, "_checkout_github_source", checkout)
@@ -854,10 +878,10 @@ def _make_args(recorder: atr.tasks.checks.Recorder) -> atr.tasks.checks.Function
return atr.tasks.checks.FunctionArguments(
recorder=RecorderFactory(recorder),
asf_uid="test",
- project_key="project",
- version_key="version",
- revision_number="00001",
- primary_rel_path="artifact.tar.gz",
+ project_key=safe.ProjectKey("project"),
+ version_key=safe.VersionKey("version"),
+ revision_number=safe.RevisionNumber("00001"),
+ primary_rel_path=safe.RelPath("artifact.tar.gz"),
extra_args={},
)
diff --git a/tests/unit/test_checks_license.py b/tests/unit/test_checks_license.py
index d58b45c8..8a524113 100644
--- a/tests/unit/test_checks_license.py
+++ b/tests/unit/test_checks_license.py
@@ -19,6 +19,7 @@ import pathlib
import tarfile
import atr.constants as constants
+import atr.models.safe as safe
import atr.models.sql as sql
import atr.tasks.checks.license as license
@@ -38,9 +39,9 @@ def test_files_binary_license_notice_in_subdir(tmp_path):
cache_dir = _cache_with_root(tmp_path)
root = cache_dir / "apache-test-0.2"
meta_inf = root / "META-INF"
- meta_inf.mkdir()
- (meta_inf / "LICENSE").write_text(constants.APACHE_LICENSE_2_0)
- (meta_inf / "NOTICE").write_text(NOTICE_VALID)
+ meta_inf.path.mkdir()
+ (meta_inf / "LICENSE").path.write_text(constants.APACHE_LICENSE_2_0)
+ (meta_inf / "NOTICE").path.write_text(NOTICE_VALID)
results = list(license._files_check_core_logic(cache_dir, is_podling=False, is_binary=True))
artifact_results = [r for r in results if isinstance(r, license.ArtifactResult)]
assert all(r.status == sql.CheckResultStatus.SUCCESS for r in artifact_results)
@@ -50,9 +51,9 @@ def test_files_binary_license_txt_notice_txt_nested(tmp_path):
cache_dir = _cache_with_root(tmp_path)
root = cache_dir / "apache-test-0.2"
nested = root / "lib" / "inner"
- nested.mkdir(parents=True)
- (nested / "LICENSE.txt").write_text(constants.APACHE_LICENSE_2_0)
- (nested / "NOTICE.txt").write_text(NOTICE_VALID)
+ nested.path.mkdir(parents=True)
+ (nested / "LICENSE.txt").path.write_text(constants.APACHE_LICENSE_2_0)
+ (nested / "NOTICE.txt").path.write_text(NOTICE_VALID)
results = list(license._files_check_core_logic(cache_dir, is_podling=False, is_binary=True))
artifact_results = [r for r in results if isinstance(r, license.ArtifactResult)]
assert all(r.status == sql.CheckResultStatus.SUCCESS for r in artifact_results)
@@ -61,7 +62,7 @@ def test_files_binary_license_txt_notice_txt_nested(tmp_path):
def test_files_binary_missing_license(tmp_path):
cache_dir = _cache_with_root(tmp_path)
root = cache_dir / "apache-test-0.2"
- (root / "NOTICE").write_text(NOTICE_VALID)
+ (root / "NOTICE").path.write_text(NOTICE_VALID)
results = list(license._files_check_core_logic(cache_dir, is_podling=False, is_binary=True))
blockers = [
r for r in results if isinstance(r, license.ArtifactResult) and (r.status == sql.CheckResultStatus.BLOCKER)
@@ -72,7 +73,7 @@ def test_files_binary_missing_license(tmp_path):
def test_files_binary_missing_notice(tmp_path):
cache_dir = _cache_with_root(tmp_path)
root = cache_dir / "apache-test-0.2"
- (root / "LICENSE").write_text(constants.APACHE_LICENSE_2_0)
+ (root / "LICENSE").path.write_text(constants.APACHE_LICENSE_2_0)
results = list(license._files_check_core_logic(cache_dir, is_podling=False, is_binary=True))
blockers = [
r for r in results if isinstance(r, license.ArtifactResult) and (r.status == sql.CheckResultStatus.BLOCKER)
@@ -83,29 +84,32 @@ def test_files_binary_missing_notice(tmp_path):
def test_files_binary_multiple_license_no_failure(tmp_path):
cache_dir = _cache_with_root(tmp_path)
root = cache_dir / "apache-test-0.2"
- (root / "LICENSE").write_text(constants.APACHE_LICENSE_2_0)
- (root / "NOTICE").write_text(NOTICE_VALID)
+ (root / "LICENSE").path.write_text(constants.APACHE_LICENSE_2_0)
+ (root / "NOTICE").path.write_text(NOTICE_VALID)
meta_inf = root / "META-INF"
- meta_inf.mkdir()
- (meta_inf / "LICENSE").write_text(constants.APACHE_LICENSE_2_0)
- (meta_inf / "NOTICE").write_text(NOTICE_VALID)
+ meta_inf.path.mkdir()
+ (meta_inf / "LICENSE").path.write_text(constants.APACHE_LICENSE_2_0)
+ (meta_inf / "NOTICE").path.write_text(NOTICE_VALID)
results = list(license._files_check_core_logic(cache_dir, is_podling=False, is_binary=True))
artifact_results = [r for r in results if isinstance(r, license.ArtifactResult)]
assert all(r.status == sql.CheckResultStatus.SUCCESS for r in artifact_results)
def test_files_missing_cache_dir():
- results = list(license._files_check_core_logic(pathlib.Path("/nonexistent"), is_podling=False, is_binary=False))
+ results = list(
+ license._files_check_core_logic(safe.StatePath(pathlib.Path("/nonexistent")), is_podling=False, is_binary=False)
+ )
assert len(results) == 1
assert results[0].status == sql.CheckResultStatus.FAILURE
assert "not available" in results[0].message.lower()
def test_files_multiple_root_dirs(tmp_path):
- cache_dir = tmp_path / "cache"
- cache_dir.mkdir()
- (cache_dir / "root-a").mkdir()
- (cache_dir / "root-b").mkdir()
+ temp_dir = safe.StatePath(tmp_path)
+ cache_dir = temp_dir / "cache"
+ cache_dir.path.mkdir()
+ (cache_dir / "root-a").path.mkdir()
+ (cache_dir / "root-b").path.mkdir()
results = list(license._files_check_core_logic(cache_dir, is_podling=False, is_binary=False))
assert len(results) >= 1
assert results[0].status == sql.CheckResultStatus.FAILURE
@@ -113,9 +117,10 @@ def test_files_multiple_root_dirs(tmp_path):
def test_files_no_root_dirs(tmp_path):
- cache_dir = tmp_path / "cache"
- cache_dir.mkdir()
- (cache_dir / "LICENSE").write_text("stray file")
+ temp_dir = safe.StatePath(tmp_path)
+ cache_dir = temp_dir / "cache"
+ cache_dir.path.mkdir()
+ (cache_dir / "LICENSE").path.write_text("stray file")
results = list(license._files_check_core_logic(cache_dir, is_podling=False, is_binary=False))
assert len(results) >= 1
assert results[0].status == sql.CheckResultStatus.FAILURE
@@ -125,8 +130,8 @@ def test_files_no_root_dirs(tmp_path):
def test_files_podling_without_disclaimer(tmp_path):
cache_dir = _cache_with_root(tmp_path)
root = cache_dir / "apache-test-0.2"
- (root / "LICENSE").write_text(constants.APACHE_LICENSE_2_0)
- (root / "NOTICE").write_text(NOTICE_VALID)
+ (root / "LICENSE").path.write_text(constants.APACHE_LICENSE_2_0)
+ (root / "NOTICE").path.write_text(NOTICE_VALID)
results = list(license._files_check_core_logic(cache_dir, is_podling=True, is_binary=False))
assert any(isinstance(r, license.ArtifactResult) and (r.status == sql.CheckResultStatus.BLOCKER) for r in results)
@@ -134,10 +139,10 @@ def test_files_podling_without_disclaimer(tmp_path):
def test_files_single_root_with_stray_top_level_file(tmp_path):
cache_dir = _cache_with_root(tmp_path)
root = cache_dir / "apache-test-0.2"
- root.mkdir(exist_ok=True)
- (root / "LICENSE").write_text(constants.APACHE_LICENSE_2_0)
- (root / "NOTICE").write_text(NOTICE_VALID)
- (cache_dir / "stray.txt").write_text("ignored")
+ root.path.mkdir(exist_ok=True)
+ (root / "LICENSE").path.write_text(constants.APACHE_LICENSE_2_0)
+ (root / "NOTICE").path.write_text(NOTICE_VALID)
+ (cache_dir / "stray.txt").path.write_text("ignored")
results = list(license._files_check_core_logic(cache_dir, is_podling=False, is_binary=False))
statuses = [r.status for r in results if isinstance(r, license.ArtifactResult)]
assert sql.CheckResultStatus.SUCCESS in statuses
@@ -147,9 +152,9 @@ def test_files_source_nested_license_notice_ignored(tmp_path):
cache_dir = _cache_with_root(tmp_path)
root = cache_dir / "apache-test-0.2"
meta_inf = root / "META-INF"
- meta_inf.mkdir()
- (meta_inf / "LICENSE").write_text(constants.APACHE_LICENSE_2_0)
- (meta_inf / "NOTICE").write_text(NOTICE_VALID)
+ meta_inf.path.mkdir()
+ (meta_inf / "LICENSE").path.write_text(constants.APACHE_LICENSE_2_0)
+ (meta_inf / "NOTICE").path.write_text(NOTICE_VALID)
results = list(license._files_check_core_logic(cache_dir, is_podling=False, is_binary=False))
blockers = [
r for r in results if isinstance(r, license.ArtifactResult) and (r.status == sql.CheckResultStatus.BLOCKER)
@@ -161,8 +166,8 @@ def test_files_source_nested_license_notice_ignored(tmp_path):
def test_files_valid_license_and_notice(tmp_path):
cache_dir = _cache_with_root(tmp_path)
root = cache_dir / "apache-test-0.2"
- (root / "LICENSE").write_text(constants.APACHE_LICENSE_2_0)
- (root / "NOTICE").write_text(NOTICE_VALID)
+ (root / "LICENSE").path.write_text(constants.APACHE_LICENSE_2_0)
+ (root / "NOTICE").path.write_text(NOTICE_VALID)
results = list(license._files_check_core_logic(cache_dir, is_podling=False, is_binary=False))
artifact_results = [r for r in results if isinstance(r, license.ArtifactResult)]
assert all(r.status == sql.CheckResultStatus.SUCCESS for r in artifact_results)
@@ -213,17 +218,19 @@ def test_headers_check_includes_excludes_source_policy(tmp_path):
assert final_result.data["excludes_source"] == "policy"
-def _cache_with_root(tmp_path: pathlib.Path) -> pathlib.Path:
- cache_dir = tmp_path / "cache"
- cache_dir.mkdir()
+def _cache_with_root(tmp_path: pathlib.Path) -> safe.StatePath:
+ temp_dir = safe.StatePath(tmp_path)
+ cache_dir = temp_dir / "cache"
+ cache_dir.path.mkdir()
root = cache_dir / "apache-test-0.2"
- root.mkdir()
+ root.path.mkdir()
return cache_dir
-def _extract_test_archive(tmp_path: pathlib.Path) -> pathlib.Path:
- cache_dir = tmp_path / "headers_cache"
- cache_dir.mkdir()
+def _extract_test_archive(tmp_path: pathlib.Path) -> safe.StatePath:
+ temp_dir = safe.StatePath(tmp_path)
+ cache_dir = temp_dir / "headers_cache"
+ cache_dir.path.mkdir()
with tarfile.open(TEST_ARCHIVE) as tf:
tf.extractall(cache_dir, filter="data")
return cache_dir
diff --git a/tests/unit/test_classify.py b/tests/unit/test_classify.py
index f4bd563f..901eb5a2 100644
--- a/tests/unit/test_classify.py
+++ b/tests/unit/test_classify.py
@@ -15,46 +15,48 @@
# specific language governing permissions and limitations
# under the License.
-import pathlib
import atr.classify as classify
+import atr.models.safe as safe
def test_archive_defaults_to_source():
- path = pathlib.Path("apache-widget-1.0.tar.gz")
+ path = safe.RelPath("apache-widget-1.0.tar.gz")
assert classify.classify(path) == classify.FileType.SOURCE
def test_archive_tgz_defaults_to_source():
- path = pathlib.Path("apache-widget-1.0.tgz")
+ path = safe.RelPath("apache-widget-1.0.tgz")
assert classify.classify(path) == classify.FileType.SOURCE
def test_archive_zip_defaults_to_source():
- path = pathlib.Path("apache-widget-1.0.zip")
+ path = safe.RelPath("apache-widget-1.0.zip")
assert classify.classify(path) == classify.FileType.SOURCE
def test_binary_matcher_classifies_as_binary(tmp_path):
- path = pathlib.Path("maven-mvnd-1.0.4-windows-amd64.zip")
- result = classify.classify(path, base_path=tmp_path, binary_matcher=_matches_windows)
+ path = safe.RelPath("maven-mvnd-1.0.4-windows-amd64.zip")
+ result = classify.classify(path, base_path=safe.StatePath(tmp_path), binary_matcher=_matches_windows)
assert result == classify.FileType.BINARY
def test_binary_matcher_does_not_override_source_matcher(tmp_path):
- path = pathlib.Path("apache-widget-1.0.tar.gz")
- result = classify.classify(path, base_path=tmp_path, source_matcher=_always_true, binary_matcher=_always_true)
+ path = safe.RelPath("apache-widget-1.0.tar.gz")
+ result = classify.classify(
+ path, base_path=safe.StatePath(tmp_path), source_matcher=_always_true, binary_matcher=_always_true
+ )
assert result == classify.FileType.SOURCE
def test_binary_matcher_wins_over_stem_heuristic(tmp_path):
- path = pathlib.Path("apache-widget-1.0-src.tar.gz")
- result = classify.classify(path, base_path=tmp_path, binary_matcher=_always_true)
+ path = safe.RelPath("apache-widget-1.0-src.tar.gz")
+ result = classify.classify(path, base_path=safe.StatePath(tmp_path), binary_matcher=_always_true)
assert result == classify.FileType.BINARY
def test_binary_stem_heuristic():
- path = pathlib.Path("apache-widget-1.0-binary.zip")
+ path = safe.RelPath("apache-widget-1.0-binary.zip")
assert classify.classify(path) == classify.FileType.BINARY
@@ -71,61 +73,63 @@ def test_counts_docs_with_source():
def test_disallowed_files_detected():
- path = pathlib.Path(".DS_Store")
+ path = safe.RelPath("desktop.ini")
assert classify.classify(path) == classify.FileType.DISALLOWED
def test_docs_stem_heuristic():
- path = pathlib.Path("apache-widget-1.0-docs.tar.gz")
+ path = safe.RelPath("apache-widget-1.0-docs.tar.gz")
assert classify.classify(path) == classify.FileType.DOCS
def test_docs_stem_heuristic_javadoc():
- path = pathlib.Path("apache-widget-1.0-javadoc.zip")
+ path = safe.RelPath("apache-widget-1.0-javadoc.zip")
assert classify.classify(path) == classify.FileType.DOCS
def test_docs_stem_heuristic_site():
- path = pathlib.Path("apache-widget-1.0-site.tar.gz")
+ path = safe.RelPath("apache-widget-1.0-site.tar.gz")
assert classify.classify(path) == classify.FileType.DOCS
def test_jar_defaults_to_binary():
- path = pathlib.Path("apache-widget-1.0.jar")
+ path = safe.RelPath("apache-widget-1.0.jar")
assert classify.classify(path) == classify.FileType.BINARY
def test_matchers_from_policy_builds_both(tmp_path):
- source_matcher, binary_matcher = classify.matchers_from_policy(["*-src.*"], ["*-bin.*"], tmp_path)
+ source_matcher, binary_matcher = classify.matchers_from_policy(["*-src.*"], ["*-bin.*"], safe.StatePath(tmp_path))
assert source_matcher is not None
assert binary_matcher is not None
def test_matchers_from_policy_empty(tmp_path):
- source_matcher, binary_matcher = classify.matchers_from_policy([], [], tmp_path)
+ source_matcher, binary_matcher = classify.matchers_from_policy([], [], safe.StatePath(tmp_path))
assert source_matcher is None
assert binary_matcher is None
def test_metadata_suffix_detected():
- path = pathlib.Path("apache-widget-1.0.tar.gz.sha512")
+ path = safe.RelPath("apache-widget-1.0.tar.gz.sha512")
assert classify.classify(path) == classify.FileType.METADATA
def test_source_matcher_wins_over_stem_binary(tmp_path):
- path = pathlib.Path("apache-widget-1.0-bin.tar.gz")
- result = classify.classify(path, base_path=tmp_path, source_matcher=_always_true)
+ path = safe.RelPath("apache-widget-1.0-bin.tar.gz")
+ result = classify.classify(path, base_path=safe.StatePath(tmp_path), source_matcher=_always_true)
assert result == classify.FileType.SOURCE
def test_source_stem_heuristic():
- path = pathlib.Path("apache-widget-1.0-source.tar.gz")
+ path = safe.RelPath("apache-widget-1.0-source.tar.gz")
assert classify.classify(path) == classify.FileType.SOURCE
def test_stem_heuristics_are_fallback_after_policy(tmp_path):
- path = pathlib.Path("apache-widget-1.0-src.tar.gz")
- result = classify.classify(path, base_path=tmp_path, source_matcher=_always_false, binary_matcher=_always_false)
+ path = safe.RelPath("apache-widget-1.0-src.tar.gz")
+ result = classify.classify(
+ path, base_path=safe.StatePath(tmp_path), source_matcher=_always_false, binary_matcher=_always_false
+ )
assert result == classify.FileType.SOURCE
diff --git a/tests/unit/test_create_revision.py b/tests/unit/test_create_revision.py
index f852aadc..a033fe11 100644
--- a/tests/unit/test_create_revision.py
+++ b/tests/unit/test_create_revision.py
@@ -106,6 +106,7 @@ class MockSafeSession:
@pytest.mark.asyncio
async def test_clone_from_older_revision_skips_merge_without_intervening_change(tmp_path: pathlib.Path):
+ temp_dir = safe.StatePath(tmp_path)
release = mock.MagicMock()
release.phase = sql.ReleasePhase.RELEASE_PREVIEW
release.project = mock.MagicMock()
@@ -153,10 +154,10 @@ async def test_clone_from_older_revision_skips_merge_without_intervening_change(
mock.patch.object(
revision.util, "create_hard_link_clone", new_callable=mock.AsyncMock
) as create_hard_link_clone_mock,
- mock.patch.object(revision.paths, "get_tmp_dir", return_value=tmp_path),
+ mock.patch.object(revision.paths, "get_tmp_dir", return_value=temp_dir),
mock.patch.object(revision.util, "paths_to_inodes", return_value={}) as paths_to_inodes_mock,
- mock.patch.object(revision.paths, "release_directory", return_value=tmp_path / "releases" / "00006"),
- mock.patch.object(revision.paths, "release_directory_base", return_value=tmp_path / "releases"),
+ mock.patch.object(revision.paths, "release_directory", return_value=temp_dir / "releases" / "00006"),
+ mock.patch.object(revision.paths, "release_directory_base", return_value=temp_dir / "releases"),
):
await participant.create_revision_with_quarantine(
safe.ProjectKey("proj"),
@@ -186,18 +187,19 @@ async def test_clone_from_older_revision_skips_merge_without_intervening_change(
clone_destination = clone_await_args.args[1]
clone_kwargs = clone_await_args.kwargs
inodes_input = inodes_call_args.args[0]
- expected_clone_source = tmp_path / "releases" / "00002"
+ expected_clone_source = temp_dir / "releases" / "00002"
if clone_source != expected_clone_source:
raise AssertionError("Expected hard-link clone source to match clone_from revision directory")
if clone_kwargs.get("do_not_create_dest_dir") is not True:
raise AssertionError("Expected hard-link clone to use do_not_create_dest_dir=True")
- if clone_destination != inodes_input:
+ if clone_destination.path != inodes_input:
raise AssertionError("Expected inode scan to run only for the temporary working directory")
@pytest.mark.asyncio
async def test_intervening_revision_triggers_merge_and_uses_latest_parent(tmp_path: pathlib.Path):
+ temp_dir = safe.StatePath(tmp_path)
release = mock.MagicMock()
release.phase = sql.ReleasePhase.RELEASE_PREVIEW
release.project = mock.MagicMock()
@@ -249,10 +251,10 @@ async def test_intervening_revision_triggers_merge_and_uses_latest_parent(tmp_pa
mock.patch.object(revision.util, "chmod_directories"),
mock.patch.object(revision.util, "chmod_files"),
mock.patch.object(revision.util, "create_hard_link_clone", new_callable=mock.AsyncMock),
- mock.patch.object(revision.paths, "get_tmp_dir", return_value=tmp_path),
+ mock.patch.object(revision.paths, "get_tmp_dir", return_value=temp_dir),
mock.patch.object(revision.util, "paths_to_inodes", return_value={}),
- mock.patch.object(revision.paths, "release_directory", return_value=tmp_path / "releases" / "00007"),
- mock.patch.object(revision.paths, "release_directory_base", return_value=tmp_path / "releases"),
+ mock.patch.object(revision.paths, "release_directory", return_value=temp_dir / "releases" / "00007"),
+ mock.patch.object(revision.paths, "release_directory_base", return_value=temp_dir / "releases"),
):
created_revision = await participant.create_revision_with_quarantine(
safe.ProjectKey("proj"),
@@ -272,23 +274,27 @@ async def test_intervening_revision_triggers_merge_and_uses_latest_parent(tmp_pa
@pytest.mark.asyncio
async def test_modify_failed_error_propagates_and_cleans_up(tmp_path: pathlib.Path):
+ temp_dir = safe.StatePath(tmp_path)
received_args: dict[str, object] = {}
- async def modify(path: pathlib.Path, old_rev: object) -> None:
+ async def modify(path: safe.StatePath, old_rev: object) -> None:
received_args["path"] = path
received_args["old_rev"] = old_rev
- (path / "file.txt").write_text("Should be cleaned up.")
+ (path / "file.txt").path.write_text("Should be cleaned up.")
raise types.FailedError("Intentional error")
release = mock.MagicMock()
release.phase = sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT
+ release.project.key = "proj"
+ release.version = "1.0"
+ release.latest_revision_number = "00001"
mock_session = _mock_db_session(release)
participant = _make_participant()
with (
mock.patch.object(revision.db, "session", return_value=mock_session),
mock.patch.object(revision.interaction, "latest_revision", new_callable=mock.AsyncMock, return_value=None),
- mock.patch.object(revision.paths, "get_tmp_dir", return_value=tmp_path),
+ mock.patch.object(revision.paths, "get_tmp_dir", return_value=temp_dir),
):
with pytest.raises(types.FailedError, match="Intentional error"):
await participant.create_revision_with_quarantine(
@@ -299,13 +305,14 @@ async def test_modify_failed_error_propagates_and_cleans_up(tmp_path: pathlib.Pa
modify=modify,
)
- assert isinstance(received_args["path"], pathlib.Path)
+ assert isinstance(received_args["path"], safe.StatePath)
assert received_args["old_rev"] is None
assert not os.listdir(tmp_path)
@pytest.mark.asyncio
async def test_v1_previous_attestable_suppresses_file_state_rows(tmp_path: pathlib.Path):
+ temp_dir = safe.StatePath(tmp_path)
release = mock.MagicMock()
release.phase = sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT
release.project = mock.MagicMock()
@@ -353,10 +360,10 @@ async def test_v1_previous_attestable_suppresses_file_state_rows(tmp_path: pathl
mock.patch.object(revision.util, "chmod_directories"),
mock.patch.object(revision.util, "chmod_files"),
mock.patch.object(revision.util, "create_hard_link_clone", new_callable=mock.AsyncMock),
- mock.patch.object(revision.paths, "get_tmp_dir", return_value=tmp_path),
+ mock.patch.object(revision.paths, "get_tmp_dir", return_value=temp_dir),
mock.patch.object(revision.util, "paths_to_inodes", return_value={}),
- mock.patch.object(revision.paths, "release_directory", return_value=tmp_path / "releases" / "00002"),
- mock.patch.object(revision.paths, "release_directory_base", return_value=tmp_path / "releases"),
+ mock.patch.object(revision.paths, "release_directory", return_value=temp_dir / "releases" / "00002"),
+ mock.patch.object(revision.paths, "release_directory_base", return_value=temp_dir / "releases"),
]
with contextlib.ExitStack() as stack:
diff --git a/tests/unit/test_create_revision_quarantine.py b/tests/unit/test_create_revision_quarantine.py
index 04940179..af420f1f 100644
--- a/tests/unit/test_create_revision_quarantine.py
+++ b/tests/unit/test_create_revision_quarantine.py
@@ -22,6 +22,7 @@ from typing import Final
import pytest
+import atr.models.safe as safe
import atr.models.sql as sql
import atr.storage.types as types
import atr.storage.writers.revision as revision
@@ -111,12 +112,16 @@ def test_generate_quarantine_token_uniqueness():
@pytest.mark.asyncio
async def test_no_quarantine_returns_revision_when_no_archives(tmp_path: pathlib.Path):
+ temp_dir = safe.StatePath(tmp_path)
release = mock.MagicMock()
release.phase = sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT
release.project = mock.MagicMock()
release.project.release_policy = None
+ release.project.key = "proj"
+ release.version = "1.0"
release.release_policy = None
- release.key = sql.release_key("proj", "1.0")
+ release.key = sql.release_key(release.project.key, release.version)
+ release.latest_revision_number = "00001"
mock_session = _mock_db_session(release)
participant = _make_participant()
@@ -130,7 +135,7 @@ async def test_no_quarantine_returns_revision_when_no_archives(tmp_path: pathlib
revision.attestable,
"paths_to_hashes_and_sizes",
new_callable=mock.AsyncMock,
- return_value=({"README.md": "hash1"}, {"README.md": 100}),
+ return_value=({safe.RelPath("README.md"): "hash1"}, {safe.RelPath("README.md"): 100}),
),
mock.patch.object(revision.attestable, "write_files_data", new_callable=mock.AsyncMock),
mock.patch.object(revision.db, "session", return_value=mock_session),
@@ -142,7 +147,7 @@ async def test_no_quarantine_returns_revision_when_no_archives(tmp_path: pathlib
revision, "_lock_and_merge", new_callable=mock.AsyncMock, return_value=(None, None, None, release)
),
mock.patch.object(revision, "SafeSession", return_value=MockQuarantineSession(MockQuarantineData(None))),
- mock.patch.object(revision.paths, "get_tmp_dir", return_value=tmp_path),
+ mock.patch.object(revision.paths, "get_tmp_dir", return_value=temp_dir),
mock.patch.object(revision.util, "chmod_directories"),
mock.patch.object(revision.util, "chmod_files"),
mock.patch.object(revision.util, "paths_to_inodes", return_value={}),
@@ -152,8 +157,8 @@ async def test_no_quarantine_returns_revision_when_no_archives(tmp_path: pathlib
with contextlib.ExitStack() as stack:
_apply_patches(stack, patches)
result = await participant.create_revision_with_quarantine(
- "proj",
- "1.0",
+ safe.ProjectKey("proj"),
+ safe.VersionKey("1.0"),
"test",
allowed_phases=frozenset({sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT}),
)
@@ -163,12 +168,16 @@ async def test_no_quarantine_returns_revision_when_no_archives(tmp_path: pathlib
@pytest.mark.asyncio
async def test_phase_gate_allows_matching_phase(tmp_path: pathlib.Path):
+ temp_dir = safe.StatePath(tmp_path)
release = mock.MagicMock()
release.phase = sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT
release.project = mock.MagicMock()
release.project.release_policy = None
+ release.project.key = "proj"
+ release.version = "1.0"
release.release_policy = None
release.key = sql.release_key("proj", "1.0")
+ release.latest_revision_number = "00001"
mock_session = _mock_db_session(release)
participant = _make_participant()
@@ -193,7 +202,7 @@ async def test_phase_gate_allows_matching_phase(tmp_path: pathlib.Path):
revision, "_lock_and_merge", new_callable=mock.AsyncMock, return_value=(None, None, None, release)
),
mock.patch.object(revision, "SafeSession", return_value=MockQuarantineSession(MockQuarantineData(None))),
- mock.patch.object(revision.paths, "get_tmp_dir", return_value=tmp_path),
+ mock.patch.object(revision.paths, "get_tmp_dir", return_value=temp_dir),
mock.patch.object(revision.util, "chmod_directories"),
mock.patch.object(revision.util, "chmod_files"),
mock.patch.object(revision.util, "paths_to_inodes", return_value={}),
@@ -203,8 +212,8 @@ async def test_phase_gate_allows_matching_phase(tmp_path: pathlib.Path):
with contextlib.ExitStack() as stack:
_apply_patches(stack, patches)
result = await participant.create_revision_with_quarantine(
- "proj",
- "1.0",
+ safe.ProjectKey("proj"),
+ safe.VersionKey("1.0"),
"test",
allowed_phases=frozenset({sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT}),
)
@@ -235,16 +244,20 @@ async def test_phase_gate_rejects_mismatched_phase():
@pytest.mark.asyncio
async def test_quarantine_branch_returns_quarantined_when_archives_detected(tmp_path: pathlib.Path):
+ temp_dir = safe.StatePath(tmp_path)
release = mock.MagicMock()
release.phase = sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT
release.project = mock.MagicMock()
+ release.project.key = "proj"
+ release.version = "1.0"
release.key = sql.release_key("proj", "1.0")
+ release.latest_revision_number = "00001"
mock_session = _mock_db_session(release)
participant = _make_participant()
safe_data = MockQuarantineData(latest_revision_key=None)
- quarantine_dir = tmp_path / "quarantine" / "proj" / "1.0" / "testtoken"
+ quarantine_dir = temp_dir / "quarantine" / "proj" / "1.0" / "testtoken"
patches = [
mock.patch.object(revision.aiofiles.os, "makedirs", new_callable=mock.AsyncMock),
@@ -253,19 +266,22 @@ async def test_quarantine_branch_returns_quarantined_when_archives_detected(tmp_
revision.attestable,
"paths_to_hashes_and_sizes",
new_callable=mock.AsyncMock,
- return_value=({"dist/apache-test-1.0.tar.gz": "hash1"}, {"dist/apache-test-1.0.tar.gz": 1000}),
+ return_value=(
+ {safe.RelPath("dist/apache-test-1.0.tar.gz"): "hash1"},
+ {safe.RelPath("dist/apache-test-1.0.tar.gz"): 1000},
+ ),
),
mock.patch.object(revision.db, "session", return_value=mock_session),
mock.patch.object(revision.detection, "validate_directory", return_value=[]),
mock.patch.object(
revision.detection,
"detect_archives_requiring_quarantine",
- return_value=["dist/apache-test-1.0.tar.gz"],
+ return_value=[safe.RelPath("dist/apache-test-1.0.tar.gz")],
),
mock.patch.object(
revision.detection,
"deduplicate_quarantine_archives",
- return_value=[("dist/apache-test-1.0.tar.gz", "hash1")],
+ return_value=[(safe.RelPath("dist/apache-test-1.0.tar.gz"), "hash1")],
),
mock.patch.object(revision.interaction, "latest_revision", new_callable=mock.AsyncMock, return_value=None),
mock.patch.object(revision.sql, "Quarantined", side_effect=FakeQuarantined),
@@ -273,7 +289,7 @@ async def test_quarantine_branch_returns_quarantined_when_archives_detected(tmp_
mock.patch.object(revision, "SafeSession", return_value=MockQuarantineSession(safe_data)),
mock.patch.object(revision, "_generate_quarantine_token", return_value="aaaaaaaaaaaaaaaa"),
mock.patch.object(revision.paths, "quarantine_directory", return_value=quarantine_dir),
- mock.patch.object(revision.paths, "get_tmp_dir", return_value=tmp_path),
+ mock.patch.object(revision.paths, "get_tmp_dir", return_value=temp_dir),
mock.patch.object(revision.util, "chmod_directories"),
mock.patch.object(revision.util, "chmod_files"),
mock.patch.object(revision.util, "paths_to_inodes", return_value={}),
@@ -283,8 +299,8 @@ async def test_quarantine_branch_returns_quarantined_when_archives_detected(tmp_
with contextlib.ExitStack() as stack:
_apply_patches(stack, patches)
result = await participant.create_revision_with_quarantine(
- "proj",
- "1.0",
+ safe.ProjectKey("proj"),
+ safe.VersionKey("1.0"),
"test",
allowed_phases=frozenset({sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT}),
)
@@ -306,10 +322,14 @@ async def test_quarantine_branch_returns_quarantined_when_archives_detected(tmp_
@pytest.mark.asyncio
async def test_quarantine_dedup_applied_to_task_args(tmp_path: pathlib.Path):
+ temp_dir = safe.StatePath(tmp_path)
release = mock.MagicMock()
release.phase = sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT
release.project = mock.MagicMock()
+ release.project.key = "proj"
+ release.version = "1.0"
release.key = sql.release_key("proj", "1.0")
+ release.latest_revision_number = "00001"
mock_session = _mock_db_session(release)
participant = _make_participant()
@@ -325,8 +345,16 @@ async def test_quarantine_dedup_applied_to_task_args(tmp_path: pathlib.Path):
"paths_to_hashes_and_sizes",
new_callable=mock.AsyncMock,
return_value=(
- {"a/test.tar.gz": "h1", "b/test.tar.gz": "h1", "c/other.zip": "h2"},
- {"a/test.tar.gz": 100, "b/test.tar.gz": 100, "c/other.zip": 200},
+ {
+ safe.RelPath("a/test.tar.gz"): "h1",
+ safe.RelPath("b/test.tar.gz"): "h1",
+ safe.RelPath("c/other.zip"): "h2",
+ },
+ {
+ safe.RelPath("a/test.tar.gz"): 100,
+ safe.RelPath("b/test.tar.gz"): 100,
+ safe.RelPath("c/other.zip"): 200,
+ },
),
),
mock.patch.object(revision.db, "session", return_value=mock_session),
@@ -334,12 +362,12 @@ async def test_quarantine_dedup_applied_to_task_args(tmp_path: pathlib.Path):
mock.patch.object(
revision.detection,
"detect_archives_requiring_quarantine",
- return_value=["a/test.tar.gz", "b/test.tar.gz", "c/other.zip"],
+ return_value=[safe.RelPath("a/test.tar.gz"), safe.RelPath("b/test.tar.gz"), safe.RelPath("c/other.zip")],
),
mock.patch.object(
revision.detection,
"deduplicate_quarantine_archives",
- return_value=[("a/test.tar.gz", "h1"), ("c/other.zip", "h2")],
+ return_value=[(safe.RelPath("a/test.tar.gz"), "h1"), (safe.RelPath("c/other.zip"), "h2")],
),
mock.patch.object(revision.interaction, "latest_revision", new_callable=mock.AsyncMock, return_value=None),
mock.patch.object(revision.sql, "Quarantined", side_effect=FakeQuarantined),
@@ -347,7 +375,7 @@ async def test_quarantine_dedup_applied_to_task_args(tmp_path: pathlib.Path):
mock.patch.object(revision, "SafeSession", return_value=MockQuarantineSession(safe_data)),
mock.patch.object(revision, "_generate_quarantine_token", return_value="cccccccccccccccc"),
mock.patch.object(revision.paths, "quarantine_directory", return_value=quarantine_dir),
- mock.patch.object(revision.paths, "get_tmp_dir", return_value=tmp_path),
+ mock.patch.object(revision.paths, "get_tmp_dir", return_value=temp_dir),
mock.patch.object(revision.util, "chmod_directories"),
mock.patch.object(revision.util, "chmod_files"),
mock.patch.object(revision.util, "paths_to_inodes", return_value={}),
@@ -357,8 +385,8 @@ async def test_quarantine_dedup_applied_to_task_args(tmp_path: pathlib.Path):
with contextlib.ExitStack() as stack:
_apply_patches(stack, patches)
result = await participant.create_revision_with_quarantine(
- "proj",
- "1.0",
+ safe.ProjectKey("proj"),
+ safe.VersionKey("1.0"),
"test",
allowed_phases=frozenset({sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT}),
)
@@ -381,6 +409,7 @@ async def test_quarantine_dedup_applied_to_task_args(tmp_path: pathlib.Path):
@pytest.mark.asyncio
async def test_quarantine_stores_prior_revision_key_from_lock(tmp_path: pathlib.Path):
+ temp_dir = safe.StatePath(tmp_path)
release = mock.MagicMock()
release.phase = sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT
release.project = mock.MagicMock()
@@ -403,19 +432,19 @@ async def test_quarantine_stores_prior_revision_key_from_lock(tmp_path: pathlib.
revision.attestable,
"paths_to_hashes_and_sizes",
new_callable=mock.AsyncMock,
- return_value=({"dist/archive.tar.gz": "newhash"}, {"dist/archive.tar.gz": 500}),
+ return_value=({safe.RelPath("dist/archive.tar.gz"): "newhash"}, {safe.RelPath("dist/archive.tar.gz"): 500}),
),
mock.patch.object(revision.db, "session", return_value=mock_session),
mock.patch.object(revision.detection, "validate_directory", return_value=[]),
mock.patch.object(
revision.detection,
"detect_archives_requiring_quarantine",
- return_value=["dist/archive.tar.gz"],
+ return_value=[safe.RelPath("dist/archive.tar.gz")],
),
mock.patch.object(
revision.detection,
"deduplicate_quarantine_archives",
- return_value=[("dist/archive.tar.gz", "newhash")],
+ return_value=[(safe.RelPath("dist/archive.tar.gz"), "newhash")],
),
mock.patch.object(
revision.interaction,
@@ -428,7 +457,7 @@ async def test_quarantine_stores_prior_revision_key_from_lock(tmp_path: pathlib.
mock.patch.object(revision, "SafeSession", return_value=MockQuarantineSession(safe_data)),
mock.patch.object(revision, "_generate_quarantine_token", return_value="bbbbbbbbbbbbbbbb"),
mock.patch.object(revision.paths, "quarantine_directory", return_value=quarantine_dir),
- mock.patch.object(revision.paths, "get_tmp_dir", return_value=tmp_path),
+ mock.patch.object(revision.paths, "get_tmp_dir", return_value=temp_dir),
mock.patch.object(revision.util, "chmod_directories"),
mock.patch.object(revision.util, "chmod_files"),
mock.patch.object(revision.util, "create_hard_link_clone", new_callable=mock.AsyncMock),
@@ -436,15 +465,15 @@ async def test_quarantine_stores_prior_revision_key_from_lock(tmp_path: pathlib.
mock.patch.object(
revision.attestable, "load", new_callable=mock.AsyncMock, return_value=mock.MagicMock(paths={})
),
- mock.patch.object(revision.paths, "release_directory_base", return_value=tmp_path / "releases"),
- mock.patch.object(revision.paths, "release_directory", return_value=tmp_path / "releases" / "00003"),
+ mock.patch.object(revision.paths, "release_directory_base", return_value=temp_dir / "releases"),
+ mock.patch.object(revision.paths, "release_directory", return_value=temp_dir / "releases" / "00003"),
]
with contextlib.ExitStack() as stack:
_apply_patches(stack, patches)
result = await participant.create_revision_with_quarantine(
- "proj",
- "1.0",
+ safe.ProjectKey("proj"),
+ safe.VersionKey("1.0"),
"test",
allowed_phases=frozenset({sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT}),
)
diff --git a/tests/unit/test_keys_writer.py b/tests/unit/test_keys_writer.py
index 1c1da45d..9386477c 100644
--- a/tests/unit/test_keys_writer.py
+++ b/tests/unit/test_keys_writer.py
@@ -22,6 +22,7 @@ import pytest
import atr.storage.outcome as outcome
import atr.storage.writers.keys as keys_writer
+from atr.models import safe
class Query:
@@ -110,6 +111,7 @@ async def test_update_committee_associations_removal_deletes_empty_keys_file(tmp
@pytest.mark.asyncio
async def test_update_committee_associations_removal_rewrites_keys_file_with_remaining_keys(tmp_path):
+ temp_dir = safe.StatePath(tmp_path)
owned_key = SimpleNamespace(fingerprint="fp1", committees=[SimpleNamespace(key="alpha")])
remaining_key = _public_key("bbbbccccdddd1111")
data = MockData(
@@ -118,19 +120,19 @@ async def test_update_committee_associations_removal_rewrites_keys_file_with_rem
)
writer, write = _make_foundation_committer(data)
- keys_path = tmp_path / "alpha" / "KEYS"
- keys_path.parent.mkdir(parents=True)
- keys_path.write_text("stale content", encoding="utf-8")
+ keys_path = temp_dir / "alpha" / "KEYS"
+ keys_path.parent.path.mkdir(parents=True)
+ keys_path.path.write_text("stale content", encoding="utf-8")
with (
- mock.patch.object(keys_writer.paths, "get_downloads_dir", return_value=tmp_path),
+ mock.patch.object(keys_writer.paths, "get_downloads_dir", return_value=temp_dir),
mock.patch.object(keys_writer.util, "chmod_directories"),
):
affected = await writer.update_committee_associations("fp1", [])
assert affected == {"alpha"}
- assert keys_path.exists()
- content = keys_path.read_text(encoding="utf-8")
+ assert keys_path.path.exists()
+ content = keys_path.path.read_text(encoding="utf-8")
assert "stale content" not in content
assert remaining_key.fingerprint.upper() in content
assert "Signing keys for the alpha committee" in content
diff --git a/tests/unit/test_paths.py b/tests/unit/test_paths.py
index 8d65e36c..33f249bd 100644
--- a/tests/unit/test_paths.py
+++ b/tests/unit/test_paths.py
@@ -26,7 +26,7 @@ import atr.paths as paths
def test_get_quarantined_dir_uses_state_dir(monkeypatch, tmp_path: pathlib.Path):
mock_config = types.SimpleNamespace(STATE_DIR=str(tmp_path))
monkeypatch.setattr("atr.config.get", lambda: mock_config)
- assert paths.get_quarantined_dir() == tmp_path / "quarantined"
+ assert paths.get_quarantined_dir().path == tmp_path / "quarantined"
def test_quarantine_directory_builds_deterministic_path(monkeypatch, tmp_path: pathlib.Path):
@@ -35,7 +35,8 @@ def test_quarantine_directory_builds_deterministic_path(monkeypatch, tmp_path: p
mock_release = types.SimpleNamespace(project_key="example", version="1.2.3")
quarantined = types.SimpleNamespace(release=mock_release, token="0123456789abcdef")
assert (
- paths.quarantine_directory(quarantined) == tmp_path / "quarantined" / "example" / "1.2.3" / "0123456789abcdef"
+ paths.quarantine_directory(quarantined).path
+ == tmp_path / "quarantined" / "example" / "1.2.3" / "0123456789abcdef"
)
diff --git a/tests/unit/test_quarantine_backfill.py b/tests/unit/test_quarantine_backfill.py
index fe02d03f..82c6559a 100644
--- a/tests/unit/test_quarantine_backfill.py
+++ b/tests/unit/test_quarantine_backfill.py
@@ -23,6 +23,7 @@ import tarfile
import pytest
import atr.hashes as hashes
+import atr.models.safe as safe
import atr.tasks.quarantine as quarantine
@@ -31,14 +32,14 @@ def test_backfill_already_cached(monkeypatch: pytest.MonkeyPatch, tmp_path: path
_patch_paths(monkeypatch, tmp_path, unfinished_dir, cache_dir)
revision_dir = unfinished_dir / "proj" / "1.0" / "00001"
- revision_dir.mkdir(parents=True)
+ revision_dir.path.mkdir(parents=True)
archive_path = revision_dir / "artifact.tar.gz"
_create_tar_gz(archive_path)
content_hash = hashes.compute_file_hash_sync(archive_path)
cache_key = hashes.filesystem_archives_key(content_hash)
existing_cache = cache_dir / "proj" / "1.0" / cache_key
- existing_cache.mkdir(parents=True)
+ existing_cache.path.mkdir(parents=True)
result = quarantine.backfill_archive_cache()
@@ -50,8 +51,8 @@ def test_backfill_continues_after_extraction_failure(monkeypatch: pytest.MonkeyP
_patch_paths(monkeypatch, tmp_path, unfinished_dir, cache_dir)
revision_dir = unfinished_dir / "proj" / "1.0" / "00001"
- revision_dir.mkdir(parents=True)
- (revision_dir / "bad.tar.gz").write_bytes(b"not a valid archive")
+ revision_dir.path.mkdir(parents=True)
+ (revision_dir / "bad.tar.gz").path.write_bytes(b"not a valid archive")
_create_tar_gz(revision_dir / "good.tar.gz")
result = quarantine.backfill_archive_cache()
@@ -61,11 +62,11 @@ def test_backfill_continues_after_extraction_failure(monkeypatch: pytest.MonkeyP
good_hash = hashes.compute_file_hash_sync(revision_dir / "good.tar.gz")
good_cache = cache_dir / "proj" / "1.0" / hashes.filesystem_archives_key(good_hash)
- assert good_cache.is_dir()
+ assert good_cache.path.is_dir()
bad_hash = hashes.compute_file_hash_sync(revision_dir / "bad.tar.gz")
bad_cache = cache_dir / "proj" / "1.0" / hashes.filesystem_archives_key(bad_hash)
- assert not bad_cache.exists()
+ assert not bad_cache.path.exists()
def test_backfill_deduplicates_within_same_version(monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> None:
@@ -73,12 +74,12 @@ def test_backfill_deduplicates_within_same_version(monkeypatch: pytest.MonkeyPat
_patch_paths(monkeypatch, tmp_path, unfinished_dir, cache_dir)
revision_1 = unfinished_dir / "proj" / "1.0" / "00001"
- revision_1.mkdir(parents=True)
+ revision_1.path.mkdir(parents=True)
_create_tar_gz(revision_1 / "artifact.tar.gz")
revision_2 = unfinished_dir / "proj" / "1.0" / "00002"
- revision_2.mkdir(parents=True)
- (revision_2 / "artifact.tar.gz").write_bytes((revision_1 / "artifact.tar.gz").read_bytes())
+ revision_2.path.mkdir(parents=True)
+ (revision_2 / "artifact.tar.gz").path.write_bytes((revision_1 / "artifact.tar.gz").path.read_bytes())
result = quarantine.backfill_archive_cache()
@@ -102,12 +103,12 @@ def test_backfill_extracts_same_content_into_different_namespaces(
_patch_paths(monkeypatch, tmp_path, unfinished_dir, cache_dir)
revision_a = unfinished_dir / "projA" / "1.0" / "00001"
- revision_a.mkdir(parents=True)
+ revision_a.path.mkdir(parents=True)
_create_tar_gz(revision_a / "artifact.tar.gz")
revision_b = unfinished_dir / "projB" / "2.0" / "00001"
- revision_b.mkdir(parents=True)
- (revision_b / "artifact.tar.gz").write_bytes((revision_a / "artifact.tar.gz").read_bytes())
+ revision_b.path.mkdir(parents=True)
+ (revision_b / "artifact.tar.gz").path.write_bytes((revision_a / "artifact.tar.gz").path.read_bytes())
result = quarantine.backfill_archive_cache()
@@ -115,8 +116,8 @@ def test_backfill_extracts_same_content_into_different_namespaces(
content_hash = hashes.compute_file_hash_sync(revision_a / "artifact.tar.gz")
cache_key = hashes.filesystem_archives_key(content_hash)
- assert (cache_dir / "projA" / "1.0" / cache_key).is_dir()
- assert (cache_dir / "projB" / "2.0" / cache_key).is_dir()
+ assert (cache_dir / "projA" / "1.0" / cache_key).path.is_dir()
+ assert (cache_dir / "projB" / "2.0" / cache_key).path.is_dir()
def test_backfill_extracts_uncached_archive(monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> None:
@@ -124,22 +125,22 @@ def test_backfill_extracts_uncached_archive(monkeypatch: pytest.MonkeyPatch, tmp
_patch_paths(monkeypatch, tmp_path, unfinished_dir, cache_dir)
revision_dir = unfinished_dir / "proj" / "1.0" / "00001"
- revision_dir.mkdir(parents=True)
+ revision_dir.path.mkdir(parents=True)
archive_path = revision_dir / "artifact.tar.gz"
_create_tar_gz(archive_path)
- (revision_dir / "artifact.tar.gz.sha512").write_text("somehash artifact.tar.gz")
+ (revision_dir / "artifact.tar.gz.sha512").path.write_text("somehash artifact.tar.gz")
result = quarantine.backfill_archive_cache()
assert len(result) == 1
archive_path_str, result_cache_dir, duration = result[0]
assert archive_path_str == str(archive_path)
- assert result_cache_dir.is_dir()
- assert (result_cache_dir / "README.txt").read_text() == "Hello"
+ assert result_cache_dir.path.is_dir()
+ assert (result_cache_dir / "README.txt").path.read_text() == "Hello"
assert duration >= 0
assert (tmp_path / "cache" / "archive-backfill.done").is_file()
- assert stat.S_IMODE(result_cache_dir.stat().st_mode) == 0o555
- assert stat.S_IMODE((result_cache_dir / "README.txt").stat().st_mode) == 0o444
+ assert stat.S_IMODE(result_cache_dir.path.stat().st_mode) == 0o555
+ assert stat.S_IMODE((result_cache_dir / "README.txt").path.stat().st_mode) == 0o444
def test_backfill_skips_non_archive_files(monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> None:
@@ -147,9 +148,9 @@ def test_backfill_skips_non_archive_files(monkeypatch: pytest.MonkeyPatch, tmp_p
_patch_paths(monkeypatch, tmp_path, unfinished_dir, cache_dir)
revision_dir = unfinished_dir / "proj" / "1.0" / "00001"
- revision_dir.mkdir(parents=True)
- (revision_dir / "artifact.tar.gz.sha512").write_text("somehash artifact.tar.gz")
- (revision_dir / "artifact.tar.gz.asc").write_bytes(b"signature")
+ revision_dir.path.mkdir(parents=True)
+ (revision_dir / "artifact.tar.gz.sha512").path.write_text("somehash artifact.tar.gz")
+ (revision_dir / "artifact.tar.gz.asc").path.write_bytes(b"signature")
result = quarantine.backfill_archive_cache()
@@ -173,33 +174,35 @@ def test_backfill_skips_scan_when_done_file_exists(monkeypatch: pytest.MonkeyPat
assert result == []
-def _create_tar_gz(path: pathlib.Path) -> None:
+def _create_tar_gz(path: safe.StatePath) -> None:
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
info = tarfile.TarInfo(name="README.txt")
content = b"Hello"
info.size = len(content)
tar.addfile(info, io.BytesIO(content))
- path.write_bytes(buf.getvalue())
+ path.path.write_bytes(buf.getvalue())
def _patch_paths(
monkeypatch: pytest.MonkeyPatch,
tmp_path: pathlib.Path,
- unfinished_dir: pathlib.Path,
- archives_dir: pathlib.Path,
+ unfinished_dir: safe.StatePath,
+ archives_dir: safe.StatePath,
) -> None:
+ temp_dir = safe.StatePath(tmp_path)
monkeypatch.setattr(quarantine.paths, "get_unfinished_dir", lambda: unfinished_dir)
monkeypatch.setattr(quarantine.paths, "get_archives_dir", lambda: archives_dir)
- monkeypatch.setattr(quarantine.paths, "get_tmp_dir", lambda: tmp_path / "temporary")
- monkeypatch.setattr(quarantine, "_backfill_done_file", lambda: tmp_path / "cache" / "archive-backfill.done")
+ monkeypatch.setattr(quarantine.paths, "get_tmp_dir", lambda: temp_dir / "temporary")
+ monkeypatch.setattr(quarantine, "_backfill_done_file", lambda: temp_dir / "cache" / "archive-backfill.done")
-def _setup_dirs(tmp_path: pathlib.Path) -> tuple[pathlib.Path, pathlib.Path]:
- unfinished_dir = tmp_path / "unfinished"
- archives_dir = tmp_path / "archives"
- cache_dir = tmp_path / "cache"
- staging_dir = tmp_path / "temporary"
+def _setup_dirs(tmp_path: pathlib.Path) -> tuple[safe.StatePath, safe.StatePath]:
+ temp_dir = safe.StatePath(tmp_path)
+ unfinished_dir = temp_dir / "unfinished"
+ archives_dir = temp_dir / "archives"
+ cache_dir = temp_dir / "cache"
+ staging_dir = temp_dir / "temporary"
for d in [unfinished_dir, archives_dir, cache_dir, staging_dir]:
- d.mkdir(parents=True)
+ d.path.mkdir(parents=True)
return unfinished_dir, archives_dir
diff --git a/tests/unit/test_quarantine_task.py b/tests/unit/test_quarantine_task.py
index 73ffaadc..2eaf4efa 100644
--- a/tests/unit/test_quarantine_task.py
+++ b/tests/unit/test_quarantine_task.py
@@ -69,12 +69,13 @@ async def test_clear_quarantine_transitions_failed_to_acknowledged():
async def test_extract_archives_discards_staging_dir_on_enotempty_collision(
monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
) -> None:
- quarantine_dir = tmp_path / "quarantine"
- quarantine_dir.mkdir()
+ temp_dir = safe.StatePath(tmp_path)
+ quarantine_dir = temp_dir / "quarantine"
+ quarantine_dir.path.mkdir()
archive_rel_path = "artifact.tar.gz"
- (quarantine_dir / archive_rel_path).write_bytes(b"archive")
- cache_root = tmp_path / "cache"
- tmp_root = tmp_path / "temporary"
+ (quarantine_dir / archive_rel_path).path.write_bytes(b"archive")
+ cache_root = temp_dir / "cache"
+ tmp_root = temp_dir / "temporary"
recorded: dict[str, pathlib.Path] = {}
def extract_archive(_archive_path: str, extract_dir: str, _config: object) -> None:
@@ -98,15 +99,15 @@ async def test_extract_archives_discards_staging_dir_on_enotempty_collision(
await quarantine._extract_archives(
[quarantine.QuarantineArchiveEntry(rel_path=archive_rel_path, content_hash="blake3:ghi")],
quarantine_dir,
- "proj",
- "1.0",
+ safe.ProjectKey("proj"),
+ safe.VersionKey("1.0"),
entries,
)
cache_dir = cache_root / "proj" / "1.0" / quarantine.hashes.filesystem_archives_key("blake3:ghi")
- assert cache_dir.is_dir()
- assert (cache_dir / "winner.txt").read_text() == "winner"
+ assert cache_dir.path.is_dir()
+ assert (cache_dir / "winner.txt").path.read_text() == "winner"
assert not recorded["staging_dir"].exists()
@@ -114,12 +115,13 @@ async def test_extract_archives_discards_staging_dir_on_enotempty_collision(
async def test_extract_archives_discards_staging_dir_when_other_worker_wins(
monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
) -> None:
- quarantine_dir = tmp_path / "quarantine"
- quarantine_dir.mkdir()
+ temp_dir = safe.StatePath(tmp_path)
+ quarantine_dir = temp_dir / "quarantine"
+ quarantine_dir.path.mkdir()
archive_rel_path = "artifact.tar.gz"
- (quarantine_dir / archive_rel_path).write_bytes(b"archive")
- cache_root = tmp_path / "cache"
- tmp_root = tmp_path / "temporary"
+ (quarantine_dir / archive_rel_path).path.write_bytes(b"archive")
+ cache_root = temp_dir / "cache"
+ tmp_root = temp_dir / "temporary"
recorded: dict[str, pathlib.Path] = {}
def extract_archive(_archive_path: str, extract_dir: str, _config: object) -> None:
@@ -143,15 +145,15 @@ async def test_extract_archives_discards_staging_dir_when_other_worker_wins(
await quarantine._extract_archives(
[quarantine.QuarantineArchiveEntry(rel_path=archive_rel_path, content_hash="blake3:def")],
quarantine_dir,
- "proj",
- "1.0",
+ safe.ProjectKey("proj"),
+ safe.VersionKey("1.0"),
entries,
)
cache_dir = cache_root / "proj" / "1.0" / quarantine.hashes.filesystem_archives_key("blake3:def")
- assert cache_dir.is_dir()
- assert (cache_dir / "winner.txt").read_text() == "winner"
+ assert cache_dir.path.is_dir()
+ assert (cache_dir / "winner.txt").path.read_text() == "winner"
assert not recorded["staging_dir"].exists()
@@ -159,12 +161,13 @@ async def test_extract_archives_discards_staging_dir_when_other_worker_wins(
async def test_extract_archives_propagates_exarch_error_to_file_entry(
monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
) -> None:
- quarantine_dir = tmp_path / "quarantine"
- quarantine_dir.mkdir()
+ temp_dir = safe.StatePath(tmp_path)
+ quarantine_dir = temp_dir / "quarantine"
+ quarantine_dir.path.mkdir()
archive_rel_path = "artifact.tar.gz"
- (quarantine_dir / archive_rel_path).write_bytes(b"archive")
- cache_root = tmp_path / "cache"
- tmp_root = tmp_path / "temporary"
+ (quarantine_dir / archive_rel_path).path.write_bytes(b"archive")
+ cache_root = temp_dir / "cache"
+ tmp_root = temp_dir / "temporary"
def extract_archive(_archive_path: str, _extract_dir: str, _config: object) -> None:
raise RuntimeError("unsafe zip detected")
@@ -179,8 +182,8 @@ async def test_extract_archives_propagates_exarch_error_to_file_entry(
await quarantine._extract_archives(
[quarantine.QuarantineArchiveEntry(rel_path=archive_rel_path, content_hash="blake3:bad")],
quarantine_dir,
- "proj",
- "1.0",
+ safe.ProjectKey("proj"),
+ safe.VersionKey("1.0"),
entries,
)
@@ -192,12 +195,13 @@ async def test_extract_archives_propagates_exarch_error_to_file_entry(
async def test_extract_archives_stages_in_temporary_then_promotes(
monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
) -> None:
- quarantine_dir = tmp_path / "quarantine"
- quarantine_dir.mkdir()
+ temp_dir = safe.StatePath(tmp_path)
+ quarantine_dir = temp_dir / "quarantine"
+ quarantine_dir.path.mkdir()
archive_rel_path = "artifact.tar.gz"
- (quarantine_dir / archive_rel_path).write_bytes(b"archive")
- cache_root = tmp_path / "cache"
- tmp_root = tmp_path / "temporary"
+ (quarantine_dir / archive_rel_path).path.write_bytes(b"archive")
+ cache_root = temp_dir / "cache"
+ tmp_root = temp_dir / "temporary"
recorded: dict[str, str] = {}
def extract_archive(archive_path: str, extract_dir: str, _config: object) -> None:
@@ -215,8 +219,8 @@ async def test_extract_archives_stages_in_temporary_then_promotes(
await quarantine._extract_archives(
[quarantine.QuarantineArchiveEntry(rel_path=archive_rel_path, content_hash="blake3:abc")],
quarantine_dir,
- "proj",
- "1.0",
+ safe.ProjectKey("proj"),
+ safe.VersionKey("1.0"),
entries,
)
@@ -224,13 +228,13 @@ async def test_extract_archives_stages_in_temporary_then_promotes(
staging_base = tmp_root
assert recorded["archive_path"] == str(quarantine_dir / archive_rel_path)
- assert pathlib.Path(recorded["extract_dir"]).parent == staging_base
+ assert pathlib.Path(recorded["extract_dir"]).parent == staging_base.path
assert pathlib.Path(recorded["extract_dir"]) != cache_dir
- assert cache_dir.is_dir()
- assert (cache_dir / "content.txt").read_text() == "cached"
- assert list(staging_base.iterdir()) == []
- assert stat.S_IMODE(cache_dir.stat().st_mode) == 0o555
- assert stat.S_IMODE((cache_dir / "content.txt").stat().st_mode) == 0o444
+ assert cache_dir.path.is_dir()
+ assert (cache_dir / "content.txt").path.read_text() == "cached"
+ assert list(staging_base.path.iterdir()) == []
+ assert stat.S_IMODE(cache_dir.path.stat().st_mode) == 0o555
+ assert stat.S_IMODE((cache_dir / "content.txt").path.stat().st_mode) == 0o444
@pytest.mark.asyncio
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org