///|
/// The result of joining one requirement, one core-metadata document, one
/// PEP 691 index response and one PEP 751 lock file for a concrete target.
/// Strings are retained for reporting; no file is downloaded or executed.
pub(all) struct PackageAudit {
project : String
requirement : String
applicable : Bool
metadata_name : String
metadata_version : String
index_name : String
index_files : Int
ignored_index_files : Int
yanked_files : Int
candidates : Array[String]
selected_file : String?
selected_version : String?
lock_versions : Array[String]
issues : Array[AuditIssue]
} derive(Eq, @debug.Debug)
///|
pub extend PackageAudit with Eq::{equal, not_equal}
///|
pub extend PackageAudit with @debug.Debug::{to_repr}
///|
/// A stable machine-readable finding. `subject` identifies the project,
/// version or filename involved without embedding whole input documents.
pub(all) struct AuditIssue {
code : String
subject : String
} derive(Eq, @debug.Debug)
///|
pub extend AuditIssue with Eq::{equal, not_equal}
///|
pub extend AuditIssue with @debug.Debug::{to_repr}
///|
/// Whether the requested dependency needs no action, is reproducibly ready,
/// or is blocked by at least one cross-document inconsistency.
pub(all) enum AuditDisposition {
NotRequired
Ready
Blocked
} derive(Eq, @debug.Debug)
///|
pub extend AuditDisposition with Eq::{equal, not_equal}
///|
pub extend AuditDisposition with @debug.Debug::{to_repr}
///|
pub fn PackageAudit::disposition(self : PackageAudit) -> AuditDisposition {
if !self.applicable {
NotRequired
} else if self.issues.length() == 0 && self.selected_file is Some(_) {
Ready
} else {
Blocked
}
}
///|
/// A deterministic human-readable report suitable for CI logs. Documents and
/// URLs are not echoed; the report contains identities, counts and findings.
pub fn PackageAudit::render(self : PackageAudit) -> String {
let out = StringBuilder()
out.write_string("project: " + self.project + "\n")
out.write_string("requirement: " + self.requirement + "\n")
let disposition = match self.disposition() {
NotRequired => "not-required"
Ready => "ready"
Blocked => "blocked"
}
out.write_string("disposition: " + disposition + "\n")
out.write_string(
"metadata: " + self.metadata_name + " " + self.metadata_version + "\n",
)
out.write_string(
"index: " +
self.index_name +
", files=" +
self.index_files.to_string() +
", ignored=" +
self.ignored_index_files.to_string() +
", yanked=" +
self.yanked_files.to_string() +
"\n",
)
out.write_string("candidates: " + self.candidates.length().to_string() + "\n")
match self.selected_file {
Some(filename) => out.write_string("selected: " + filename + "\n")
None => out.write_string("selected: none\n")
}
out.write_string("lock-versions:")
if self.lock_versions.length() == 0 {
out.write_string(" none\n")
} else {
for version in self.lock_versions {
out.write_string(" " + version)
}
out.write_char('\n')
}
if self.issues.length() == 0 {
out.write_string("issues: none\n")
} else {
out.write_string("issues: " + self.issues.length().to_string() + "\n")
for issue in self.issues {
out.write_string("- " + issue.code + ": " + issue.subject + "\n")
}
}
out.to_string()
}
///|
fn audit_issue(
issues : Array[AuditIssue],
code : String,
subject : String,
) -> Unit {
for issue in issues {
if issue.code == code && issue.subject == subject {
return
}
}
issues.push({ code, subject, })
}
///|
fn requirement_applies(
requirement : Requirement,
environment : MarkerEnvironment,
) -> Bool raise {
match requirement.marker_text {
None => true
Some(text) => Marker::parse(text).evaluate(environment)
}
}
///|
fn metadata_supports_python(metadata : Metadata, python : Version) -> Bool {
match metadata.requires_python() {
None => true
Some(specifier) => specifier.contains(python)
}
}
///|
fn lock_package_supports_python(
entry : PylockPackage,
python : Version,
) -> Bool {
match entry.requires_python_set() {
None => true
Some(specifier) => specifier.contains(python)
}
}
///|
fn same_version(a : Version, b : Version) -> Bool {
Version::compare(a, b) == 0
}
///|
/// Audit a package decision across four Python packaging artifacts.
///
/// The function is deliberately a join, not a resolver or installer:
///
/// - `requirement_text` supplies the requested project, constraints and marker;
/// - `metadata_text` supplies the distribution identity and Requires-Python;
/// - `index_json` supplies downloadable filenames, yank state and hashes;
/// - `pylock_toml` supplies the versions locked for the target environment;
/// - `environment`, `python_text` and `tags` describe the target explicitly.
///
/// Parsing errors are raised by the existing typed parsers. Successfully parsed
/// but mutually inconsistent artifacts return stable `AuditIssue` entries.
/// Yanked files are excluded unless `allow_yanked` is true. The selected file
/// must advertise a sha256 digest, match a file and digest in the one applicable
/// lock entry, and agree with the supplied metadata version for `Ready`.
pub fn audit_package(
requirement_text : String,
metadata_text : String,
index_json : String,
pylock_toml : String,
environment : MarkerEnvironment,
python_text : String,
tags : Array[String],
allow_yanked? : Bool = false,
) -> PackageAudit raise {
let lock = Pylock::parse(pylock_toml)
audit_package_with_lock(
requirement_text, metadata_text, index_json, lock, environment, python_text,
tags, allow_yanked,
)
}
///|
fn audit_package_with_lock(
requirement_text : String,
metadata_text : String,
index_json : String,
lock : Pylock,
environment : MarkerEnvironment,
python_text : String,
tags : Array[String],
allow_yanked : Bool,
) -> PackageAudit raise {
let requirement = Requirement::parse(requirement_text)
let metadata = Metadata::parse(metadata_text)
let index = SimpleIndex::parse(index_json)
let python = Version::parse(python_text)
let project = canonicalize_name(requirement.name)
let issues : Array[AuditIssue] = []
let applicable = requirement_applies(requirement, environment)
if canonicalize_name(metadata.name) != project {
audit_issue(issues, "METADATA_NAME_MISMATCH", metadata.name)
}
if canonicalize_name(index.name()) != project {
audit_issue(issues, "INDEX_NAME_MISMATCH", index.name())
}
if !metadata_supports_python(metadata, python) {
audit_issue(issues, "METADATA_REQUIRES_PYTHON", python_text)
}
if !lock.supports_python(python) {
audit_issue(issues, "LOCK_REQUIRES_PYTHON", python_text)
}
if !lock.accepts_environment(environment) {
audit_issue(issues, "LOCK_ENVIRONMENT_MISMATCH", project)
}
if requirement.url is Some(_) {
audit_issue(issues, "DIRECT_URL_NOT_INDEXED", project)
}
let index_files : Array[IndexFile] = []
let retained_sources : Array[SimpleIndexFile] = []
let mut ignored_index_files = 0
let mut yanked_files = 0
for source in index.files() {
let excluded_yanked = source.yanked is Yanked(_) && !allow_yanked
if excluded_yanked {
yanked_files = yanked_files + 1
} else {
try {
index_files.push(source.to_index_file())
retained_sources.push(source)
} catch {
_ => ignored_index_files = ignored_index_files + 1
}
}
}
let candidates = if applicable && requirement.url is None {
resolve_candidates(index_files, requirement, tags, Some(python))
} else {
[]
}
let candidate_names : Array[String] = []
for candidate in candidates {
candidate_names.push(candidate.filename)
}
let applicable_locked : Array[PylockPackage] = []
for entry in lock.packages_named(project) {
if entry.is_applicable(environment) &&
lock_package_supports_python(entry, python) {
applicable_locked.push(entry)
}
}
// A lock is a selection, not permission to silently upgrade to the highest
// version in the index. Keep the resolver's order within the locked version.
let mut selected : IndexFile? = None
if candidates.length() > 0 {
if applicable_locked.length() == 1 {
match applicable_locked[0].version {
Some(locked_version) =>
for candidate in candidates {
if selected is None &&
same_version(candidate.version, locked_version) {
selected = Some(candidate)
}
}
None => ()
}
}
// Retain the best available candidate for diagnostics when the lock has
// no matching version; it still cannot make the audit Ready.
if selected is None {
selected = Some(candidates[0])
}
}
if applicable && selected is None {
audit_issue(issues, "CANDIDATE_MISSING", project)
}
let selected_file : String? = match selected {
None => None
Some(file) => Some(file.filename)
}
let selected_version : String? = match selected {
None => None
Some(file) => Some(file.version.to_string())
}
match selected {
Some(file) => {
let mut has_sha256 = false
for source in retained_sources {
if source.filename == file.filename &&
source.hash_for("sha256") is Some(_) {
has_sha256 = true
}
}
if !has_sha256 {
audit_issue(issues, "SELECTED_SHA256_MISSING", file.filename)
}
if !same_version(metadata.version, file.version) {
audit_issue(
issues,
"METADATA_SELECTION_MISMATCH",
metadata.version.to_string() + " != " + file.version.to_string(),
)
}
}
None => ()
}
let lock_versions : Array[String] = []
for entry in applicable_locked {
match entry.version {
Some(version) => {
lock_versions.push(version.to_string())
if !requirement.specifier.contains(version) {
audit_issue(
issues,
"LOCK_REQUIREMENT_MISMATCH",
entry.name + "==" + version.to_string(),
)
}
}
None => audit_issue(issues, "LOCK_VERSION_MISSING", entry.name)
}
}
if applicable && applicable_locked.length() == 0 {
audit_issue(issues, "LOCK_ENTRY_MISSING", project)
} else if applicable_locked.length() > 1 {
audit_issue(issues, "LOCK_ENTRY_AMBIGUOUS", project)
}
match selected {
Some(file) if applicable_locked.length() == 1 => {
let locked = applicable_locked[0]
match locked.version {
Some(version) =>
if !same_version(file.version, version) {
audit_issue(
issues,
"LOCK_SELECTION_MISMATCH",
version.to_string() + " != " + file.version.to_string(),
)
}
None => ()
}
let mut index_digest : String? = None
for source in retained_sources {
if source.filename == file.filename {
index_digest = source.hash_for("sha256")
}
}
let mut matched_file = false
for locked_file in locked.all_files() {
if locked_file.filename() == Some(file.filename) {
matched_file = true
match (index_digest, locked_file.hash_for("sha256")) {
(Some(index_hash), Some(lock_hash)) =>
if index_hash.to_lower() != lock_hash.to_lower() {
audit_issue(issues, "LOCK_SHA256_MISMATCH", file.filename)
}
(Some(_), None) =>
audit_issue(issues, "LOCK_SHA256_MISSING", file.filename)
_ => ()
}
}
}
if !matched_file {
audit_issue(issues, "LOCK_FILE_MISSING", file.filename)
}
}
_ => ()
}
{
project,
// A direct URL may contain credentials. The report must not echo it even
// when the input is rejected as outside the index/lock audit model.
requirement: if requirement.url is Some(_) {
project + " @ [redacted]"
} else {
requirement.to_string()
},
applicable,
metadata_name: metadata.name,
metadata_version: metadata.version.to_string(),
index_name: index.name(),
index_files: index.files().length(),
ignored_index_files,
yanked_files,
candidates: candidate_names,
selected_file,
selected_version,
lock_versions,
issues,
}
}
///|
/// One explicit project request and its two externally supplied artifacts.
/// The shared PEP 751 lock and target environment are passed to `audit_bundle`.
pub(all) struct AuditInput {
requirement_text : String
metadata_text : String
index_json : String
} derive(Eq, @debug.Debug)
///|
pub extend AuditInput with Eq::{equal, not_equal}
///|
pub extend AuditInput with @debug.Debug::{to_repr}
///|
/// An ordered set of package audits against one lock and one target. This is
/// intentionally not a dependency resolver: it audits exactly the inputs the
/// caller supplies and does not infer or fetch missing transitive projects.
pub(all) struct AuditBundle {
reports : Array[PackageAudit]
issues : Array[AuditIssue]
} derive(Eq, @debug.Debug)
///|
pub extend AuditBundle with Eq::{equal, not_equal}
///|
pub extend AuditBundle with @debug.Debug::{to_repr}
///|
pub fn AuditBundle::disposition(self : AuditBundle) -> AuditDisposition {
if self.issues.length() > 0 {
return Blocked
}
let mut has_ready = false
for report in self.reports {
match report.disposition() {
Blocked => return Blocked
Ready => has_ready = true
NotRequired => ()
}
}
if has_ready {
Ready
} else {
NotRequired
}
}
///|
pub fn AuditBundle::counts(self : AuditBundle) -> (Int, Int, Int) {
let mut ready = 0
let mut blocked = 0
let mut not_required = 0
for report in self.reports {
match report.disposition() {
Ready => ready = ready + 1
Blocked => blocked = blocked + 1
NotRequired => not_required = not_required + 1
}
}
(ready, blocked, not_required)
}
///|
pub fn AuditBundle::render(self : AuditBundle) -> String {
let (ready, blocked, not_required) = self.counts()
let out = StringBuilder()
out.write_string(
"bundle: " +
self.reports.length().to_string() +
" package(s), ready=" +
ready.to_string() +
", blocked=" +
blocked.to_string() +
", not-required=" +
not_required.to_string() +
"\n",
)
for issue in self.issues {
out.write_string(
"bundle-issue: " + issue.code + ": " + issue.subject + "\n",
)
}
for report in self.reports {
out.write_string("\n" + report.render())
}
out.to_string()
}
///|
/// Audit a caller-supplied bundle against one lock and one explicit target.
/// The lock is parsed once, then each input receives an independent package
/// report in caller order. Duplicate project inputs are reported at bundle
/// level, because two independently supplied index/metadata pairs for one
/// canonical project make the overall decision ambiguous.
///
/// With `require_lock_coverage=true`, every applicable, Python-compatible lock
/// entry must have an input. This is useful when the caller claims to provide a
/// complete offline mirror; leave it false for partial audits. No network I/O,
/// recursive dependency solving or content hashing occurs here.
pub fn audit_bundle(
inputs : Array[AuditInput],
pylock_toml : String,
environment : MarkerEnvironment,
python_text : String,
tags : Array[String],
allow_yanked? : Bool = false,
require_lock_coverage? : Bool = false,
) -> AuditBundle raise {
let lock = Pylock::parse(pylock_toml)
let python = Version::parse(python_text)
let reports : Array[PackageAudit] = []
let issues : Array[AuditIssue] = []
for input in inputs {
let report = audit_package_with_lock(
input.requirement_text,
input.metadata_text,
input.index_json,
lock,
environment,
python_text,
tags,
allow_yanked,
)
for previous in reports {
if previous.project == report.project {
audit_issue(issues, "DUPLICATE_PROJECT_INPUT", report.project)
}
}
reports.push(report)
}
if require_lock_coverage {
for entry in lock.applicable_packages(environment) {
if lock_package_supports_python(entry, python) {
let name = canonicalize_name(entry.name)
let mut found = false
for report in reports {
if report.project == name && report.applicable {
found = true
}
}
if !found {
audit_issue(issues, "LOCK_ENTRY_UNAUDITED", name)
}
}
}
}
{ reports, issues, }
}