diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 3979aed2..9b83a1f2 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -43,6 +43,8 @@ jobs: go-version: '1.20.1' - name: Install pnpm run: npm install -g pnpm + - name: Install uv + uses: astral-sh/setup-uv@v6 - name: get Python location id: python-location run: | diff --git a/README.md b/README.md index 9e8eb998..8c6f8e9d 100644 --- a/README.md +++ b/README.md @@ -182,7 +182,7 @@ public class TrustifyExample {
  • Java - Maven
  • JavaScript - Npm
  • Golang - Go Modules
  • -
  • Python - pip Installer (requirements.txt, pyproject.toml with PEP 621 format). Note: Poetry-style dependencies ([tool.poetry.dependencies]) are not supported.
  • +
  • Python - pip Installer / uv (requirements.txt, pyproject.toml with PEP 621 format). Note: Poetry-style dependencies ([tool.poetry.dependencies]) are not supported.
  • Gradle - Gradle Installation
  • Rust - Cargo
  • @@ -393,6 +393,7 @@ System.setProperty("TRUSTIFY_DA_PYTHON3_PATH", "/path/to/python3"); System.setProperty("TRUSTIFY_DA_PIP3_PATH", "/path/to/pip3"); System.setProperty("TRUSTIFY_DA_PYTHON_PATH", "/path/to/python"); System.setProperty("TRUSTIFY_DA_PIP_PATH", "/path/to/pip"); +System.setProperty("TRUSTIFY_DA_UV_PATH", "/path/to/custom/uv"); // Configure proxy for all requests System.setProperty("TRUSTIFY_DA_PROXY_URL", "http://proxy.example.com:8080"); // Configure Maven settings and repository @@ -504,6 +505,11 @@ following keys for setting custom paths for the said executables. TRUSTIFY_DA_PIP_PATH +uv Package Manager +uv +TRUSTIFY_DA_UV_PATH + + Cargo Package Manager cargo TRUSTIFY_DA_CARGO_PATH @@ -600,6 +606,14 @@ TRUSTIFY_DA_GO_MVS_LOGIC_ENABLED=false Python support works with both `requirements.txt` and `pyproject.toml` manifest files. The `pyproject.toml` provider scans production dependencies from PEP 621 `[project.dependencies]` and Poetry `[tool.poetry.dependencies]` sections. Optional dependencies (`[project.optional-dependencies]`) and Poetry group dependencies (`[tool.poetry.group.*.dependencies]`) are intentionally excluded to focus on runtime dependencies. +##### uv Support + +When a `uv.lock` file is present alongside `pyproject.toml`, the client automatically uses the [uv](https://docs.astral.sh/uv/) package manager for dependency resolution instead of pip. Dependency data is collected via `uv export --format requirements.txt --frozen --no-hashes --no-dev`. No additional configuration is required — the provider is selected automatically based on lock file detection. + +To use a custom uv binary, set `TRUSTIFY_DA_UV_PATH` to the path of your uv executable. + +##### pip Support + By default, Python support assumes that the package is installed using the pip/pip3 binary on the system PATH, or of the customized Binaries passed to environment variables. If the package is not installed , then an error will be thrown. @@ -683,6 +697,12 @@ Options: - `--output ` - Write SBOM JSON to the specified file - (default) - Print SBOM JSON to stdout +**SBOM for uv-managed Python project** +```shell +# When uv.lock is present alongside pyproject.toml, the uv provider is used automatically +java -jar trustify-da-java-client-cli.jar sbom /path/to/uv-project/pyproject.toml +``` + **Image Analysis** ```shell java -jar trustify-da-java-client-cli.jar image [...] [--summary|--html] diff --git a/src/main/java/io/github/guacsec/trustifyda/providers/PythonProviderFactory.java b/src/main/java/io/github/guacsec/trustifyda/providers/PythonProviderFactory.java new file mode 100644 index 00000000..8055a582 --- /dev/null +++ b/src/main/java/io/github/guacsec/trustifyda/providers/PythonProviderFactory.java @@ -0,0 +1,121 @@ +/* + * Copyright 2023-2025 Trustify Dependency Analytics Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.guacsec.trustifyda.providers; + +import io.github.guacsec.trustifyda.tools.Operations; +import io.github.guacsec.trustifyda.utils.Environment; +import io.github.guacsec.trustifyda.utils.PyprojectTomlUtils; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import java.util.function.Function; + +/** + * Factory for creating the appropriate {@link PythonProvider} based on lock file presence. Follows + * the same pattern as {@link JavaScriptProviderFactory}. + */ +public final class PythonProviderFactory { + + private static final Map> PYTHON_PROVIDERS = + Map.of(PythonUvProvider.LOCK_FILE, PythonUvProvider::new); + + /** + * Creates a Python provider for {@code pyproject.toml} manifests by checking for known lock files + * in the manifest directory and parent directories (for workspace members). When {@code uv.lock} + * is present, returns a {@link PythonUvProvider}; otherwise falls back to {@link + * PythonPyprojectProvider} (pip-based). + * + * @param manifestPath the path to the pyproject.toml manifest + * @return the matching Python provider + */ + public static PythonProvider create(final Path manifestPath) { + var manifestDir = manifestPath.getParent(); + + // Check manifest directory first (fast path) + for (var entry : PYTHON_PROVIDERS.entrySet()) { + if (Files.isRegularFile(manifestDir.resolve(entry.getKey()))) { + return entry.getValue().apply(manifestPath); + } + } + + // Walk up parent directories to find lock file at workspace root + Path lockFileDir = findLockFileDirInParents(manifestDir); + if (lockFileDir != null) { + for (var entry : PYTHON_PROVIDERS.entrySet()) { + if (Files.isRegularFile(lockFileDir.resolve(entry.getKey()))) { + return entry.getValue().apply(manifestPath); + } + } + } + + // Unlike JavaScript, pip fallback is valid — no lock file required + return new PythonPyprojectProvider(manifestPath); + } + + /** + * Walks up from the given directory to find a parent containing a known Python lock file. + * Respects {@code TRUSTIFY_DA_WORKSPACE_DIR} override, stops at uv workspace root boundaries and + * the git root. + * + * @param startDir the directory to start searching from (typically the manifest directory) + * @return the directory containing the lock file, or {@code null} if not found + */ + static Path findLockFileDirInParents(Path startDir) { + // Environment override takes precedence + String workspaceDirOverride = Environment.get("TRUSTIFY_DA_WORKSPACE_DIR"); + if (workspaceDirOverride != null && !workspaceDirOverride.isBlank()) { + Path overrideDir = Path.of(workspaceDirOverride); + for (String lockFile : PYTHON_PROVIDERS.keySet()) { + if (Files.isRegularFile(overrideDir.resolve(lockFile))) { + return overrideDir; + } + } + return null; + } + + // If startDir itself is a workspace root, don't walk up to avoid escaping into a parent + // workspace + if (PyprojectTomlUtils.isUvWorkspaceRoot(startDir)) { + return null; + } + + String gitRoot = Operations.getGitRootDir(startDir.toString()).orElse(null); + Path boundary = gitRoot != null ? Path.of(gitRoot) : startDir.toAbsolutePath().getRoot(); + + Path current = startDir.toAbsolutePath().normalize().getParent(); + while (current != null) { + for (String lockFile : PYTHON_PROVIDERS.keySet()) { + if (Files.isRegularFile(current.resolve(lockFile))) { + return current; + } + } + + // Stop at uv workspace root boundary + if (PyprojectTomlUtils.isUvWorkspaceRoot(current)) { + return null; + } + + if (current.equals(boundary.toAbsolutePath().normalize())) { + break; + } + + current = current.getParent(); + } + + return null; + } +} diff --git a/src/main/java/io/github/guacsec/trustifyda/providers/PythonPyprojectProvider.java b/src/main/java/io/github/guacsec/trustifyda/providers/PythonPyprojectProvider.java index 561b5c92..39f70442 100644 --- a/src/main/java/io/github/guacsec/trustifyda/providers/PythonPyprojectProvider.java +++ b/src/main/java/io/github/guacsec/trustifyda/providers/PythonPyprojectProvider.java @@ -25,6 +25,7 @@ import io.github.guacsec.trustifyda.sbom.SbomFactory; import io.github.guacsec.trustifyda.tools.Operations; import io.github.guacsec.trustifyda.utils.Environment; +import io.github.guacsec.trustifyda.utils.PyprojectTomlUtils; import io.github.guacsec.trustifyda.utils.PythonControllerBase; import java.io.IOException; import java.nio.charset.StandardCharsets; @@ -42,10 +43,8 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Collectors; -import org.tomlj.Toml; import org.tomlj.TomlArray; import org.tomlj.TomlParseResult; -import org.tomlj.TomlTable; /** * Provider for Python projects using {@code pyproject.toml} with ())); } @@ -299,7 +298,7 @@ PipReportData parsePipReport(String reportJson) throws IOException { if (name == null) { continue; } - String key = canonicalize(name); + String key = PyprojectTomlUtils.canonicalize(name); PipPackage pipPkg = graph.get(key); if (pipPkg == null) { continue; @@ -317,7 +316,7 @@ PipReportData parsePipReport(String reportJson) throws IOException { if (depName == null) { continue; } - String depKey = canonicalize(depName); + String depKey = PyprojectTomlUtils.canonicalize(depName); if (graph.containsKey(depKey)) { pipPkg.children.add(depKey); } @@ -338,18 +337,9 @@ static String extractDepName(String req) { return m.find() ? m.group(1) : null; } - static String canonicalize(String name) { - return name.toLowerCase().replaceAll("[-_.]+", "-"); - } - private TomlParseResult getToml() throws IOException { if (cachedToml == null) { - TomlParseResult parsed = Toml.parse(manifestPath); - if (parsed.hasErrors()) { - throw new IOException( - "Invalid pyproject.toml format: " + parsed.errors().get(0).getMessage()); - } - cachedToml = parsed; + cachedToml = PyprojectTomlUtils.parseToml(manifestPath); } return cachedToml; } @@ -357,9 +347,8 @@ private TomlParseResult getToml() throws IOException { @Override protected String getRootComponentName() { try { - TomlParseResult toml = getToml(); - String name = toml.getString("project.name"); - if (name != null && !name.isBlank()) { + String name = PyprojectTomlUtils.getProjectName(getToml()); + if (name != null) { return name; } } catch (IOException e) { @@ -371,9 +360,8 @@ protected String getRootComponentName() { @Override protected String getRootComponentVersion() { try { - TomlParseResult toml = getToml(); - String version = toml.getString("project.version"); - if (version != null && !version.isBlank()) { + String version = PyprojectTomlUtils.getProjectVersion(getToml()); + if (version != null) { return version; } } catch (IOException e) { @@ -385,16 +373,10 @@ protected String getRootComponentVersion() { @Override public String readLicenseFromManifest() { try { - TomlParseResult toml = getToml(); - String license = toml.getString("project.license"); - if (license != null && !license.isBlank()) { + String license = PyprojectTomlUtils.getLicense(getToml()); + if (license != null) { return license; } - // PEP 639: license may be in project.license.text - String licenseText = toml.getString("project.license.text"); - if (licenseText != null && !licenseText.isBlank()) { - return licenseText; - } } catch (IOException e) { log.fine("Failed to parse pyproject.toml for license: " + e.getMessage()); } @@ -416,9 +398,7 @@ protected Set getIgnoredDependencies(String manifestContent) { } private void rejectPoetryDependencies() throws IOException { - TomlParseResult toml = getToml(); - TomlTable poetryDeps = toml.getTable("tool.poetry.dependencies"); - if (poetryDeps != null) { + if (PyprojectTomlUtils.hasPoetryDependencies(getToml())) { throw new IllegalStateException( "Poetry dependencies in pyproject.toml are not supported." + " Please use PEP 621 [project.dependencies] format instead."); @@ -426,18 +406,7 @@ private void rejectPoetryDependencies() throws IOException { } private void collectIgnoredDeps() throws IOException { - TomlParseResult toml = getToml(); - List rawLines = Files.readAllLines(manifestPath); - collectedIgnoredDeps = new HashSet<>(); - - // [project.dependencies] - PEP 621 - TomlArray projectDeps = toml.getArray("project.dependencies"); - if (projectDeps != null) { - for (int i = 0; i < projectDeps.size(); i++) { - String dep = projectDeps.getString(i); - checkIgnored(rawLines, dep, dep); - } - } + collectedIgnoredDeps = PyprojectTomlUtils.collectIgnoredDeps(manifestPath, getToml()); } List parseDependencyStrings() throws IOException { @@ -455,15 +424,6 @@ List parseDependencyStrings() throws IOException { return deps; } - private void checkIgnored(List rawLines, String searchToken, String depIdentifier) { - for (String line : rawLines) { - if (line.contains(searchToken) && containsIgnorePattern(line)) { - collectedIgnoredDeps.add(depIdentifier); - break; - } - } - } - static final class PipPackage { final String name; final String version; diff --git a/src/main/java/io/github/guacsec/trustifyda/providers/PythonUvProvider.java b/src/main/java/io/github/guacsec/trustifyda/providers/PythonUvProvider.java new file mode 100644 index 00000000..60311a70 --- /dev/null +++ b/src/main/java/io/github/guacsec/trustifyda/providers/PythonUvProvider.java @@ -0,0 +1,412 @@ +/* + * Copyright 2023-2025 Trustify Dependency Analytics Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.guacsec.trustifyda.providers; + +import com.github.packageurl.PackageURL; +import io.github.guacsec.trustifyda.Api; +import io.github.guacsec.trustifyda.license.LicenseUtils; +import io.github.guacsec.trustifyda.logging.LoggersFactory; +import io.github.guacsec.trustifyda.sbom.Sbom; +import io.github.guacsec.trustifyda.sbom.SbomFactory; +import io.github.guacsec.trustifyda.tools.Operations; +import io.github.guacsec.trustifyda.utils.Environment; +import io.github.guacsec.trustifyda.utils.PyprojectTomlUtils; +import io.github.guacsec.trustifyda.utils.PythonControllerBase; +import java.io.IOException; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.logging.Logger; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import org.tomlj.TomlParseResult; + +/** + * Provider for Python projects using {@code pyproject.toml} with the uv package manager. + * + *

    Dependency resolution is performed via {@code uv export --format requirements.txt --frozen + * --no-hashes --no-dev --no-emit-project}, which produces a requirements.txt with {@code # via} + * comments encoding the full dependency graph. The provider is selected by {@link + * PythonProviderFactory} when {@code uv.lock} is present alongside the manifest. + */ +public final class PythonUvProvider extends PythonProvider { + + private static final Logger log = LoggersFactory.getLogger(PythonUvProvider.class.getName()); + + public static final String LOCK_FILE = "uv.lock"; + static final String PROP_TRUSTIFY_DA_UV_EXPORT = "TRUSTIFY_DA_UV_EXPORT"; + + private final String uvExecutable; + private Set collectedIgnoredDeps; + private TomlParseResult cachedToml; + + public PythonUvProvider(Path manifest) { + super(manifest); + this.uvExecutable = Operations.getExecutable("uv", "--version"); + } + + @Override + public void validateLockFile(Path lockFileDir) { + // Check manifest directory first, then walk up to workspace root + if (Files.isRegularFile(lockFileDir.resolve(LOCK_FILE))) { + return; + } + Path parentDir = PythonProviderFactory.findLockFileDirInParents(lockFileDir); + if (parentDir != null && Files.isRegularFile(parentDir.resolve(LOCK_FILE))) { + return; + } + throw new IllegalStateException( + "uv.lock does not exist. Ensure the project is managed by uv" + + " and run 'uv lock' to generate it."); + } + + @Override + public Content provideStack() throws IOException { + rejectPoetryDependencies(); + collectIgnoredDeps(); + Path manifestDir = manifestPath.toAbsolutePath().getParent(); + String exportOutput = getUvExportOutput(manifestDir); + UvDependencyData data = parseUvExport(exportOutput); + + Sbom sbom = SbomFactory.newInstance(Sbom.BelongingCondition.PURL, "sensitive"); + sbom.addRoot( + toPurl(getRootComponentName(), getRootComponentVersion()), readLicenseFromManifest()); + + for (String directKey : data.directDeps()) { + UvPackage pkg = data.graph().get(directKey); + if (pkg != null) { + addDependencyTree(sbom.getRoot(), pkg, data.graph(), sbom, new HashSet<>()); + } + } + + String manifestContent = Files.readString(manifestPath); + handleIgnoredDependencies(manifestContent, sbom); + return new Content( + sbom.getAsJsonString().getBytes(StandardCharsets.UTF_8), Api.CYCLONEDX_MEDIA_TYPE); + } + + @Override + public Content provideComponent() throws IOException { + rejectPoetryDependencies(); + collectIgnoredDeps(); + Path manifestDir = manifestPath.toAbsolutePath().getParent(); + String exportOutput = getUvExportOutput(manifestDir); + UvDependencyData data = parseUvExport(exportOutput); + + Sbom sbom = SbomFactory.newInstance(); + sbom.addRoot( + toPurl(getRootComponentName(), getRootComponentVersion()), readLicenseFromManifest()); + + for (String key : data.directDeps()) { + UvPackage pkg = data.graph().get(key); + if (pkg != null) { + sbom.addDependency(sbom.getRoot(), toPurl(pkg.name(), pkg.version()), null); + } + } + + String manifestContent = Files.readString(manifestPath); + handleIgnoredDependencies(manifestContent, sbom); + return new Content( + sbom.getAsJsonString().getBytes(StandardCharsets.UTF_8), Api.CYCLONEDX_MEDIA_TYPE); + } + + private void addDependencyTree( + PackageURL source, + UvPackage pkg, + Map graph, + Sbom sbom, + Set visited) { + PackageURL packageURL = toPurl(pkg.name(), pkg.version()); + sbom.addDependency(source, packageURL, null); + + String key = PyprojectTomlUtils.canonicalize(pkg.name()); + if (!visited.add(key)) { + return; + } + + for (String childKey : pkg.children()) { + UvPackage child = graph.get(childKey); + if (child != null) { + addDependencyTree(packageURL, child, graph, sbom, visited); + } + } + } + + /** + * Parses the output of {@code uv export --format requirements.txt} into a dependency graph. + * + *

    Each package line has the form {@code name==version} optionally followed by environment + * markers. Dependency relationships are encoded in {@code # via} comments that follow each + * package. A package whose {@code # via} parent is the project name is a direct dependency. + */ + UvDependencyData parseUvExport(String exportOutput) throws IOException { + String projectName = PyprojectTomlUtils.canonicalize(getRootComponentName()); + Map packages = new HashMap<>(); + List directDeps = new ArrayList<>(); + List parentChildPairs = new ArrayList<>(); + + String currentKey = null; + boolean inViaBlock = false; + + for (String line : exportOutput.split("\\r?\\n")) { + String trimmed = line.trim(); + + if (trimmed.isEmpty()) { + inViaBlock = false; + continue; + } + + // Skip top-level comments (header lines) + if (line.startsWith("#")) { + continue; + } + + // Editable installs are workspace members — resolve name/version from their pyproject.toml + if (line.startsWith("-e ")) { + inViaBlock = false; + currentKey = parseEditableInstall(line, packages, projectName); + continue; + } + + // Package line: name==version [; env-marker] + if (!line.startsWith(" ") && !trimmed.startsWith("#")) { + inViaBlock = false; + if (!trimmed.contains("==")) { + throw new IOException("uv export: package '" + trimmed + "' has no pinned version"); + } + String spec = trimmed.split(";")[0].trim(); + String[] parts = spec.split("==", 2); + String name = parts[0].split("\\[")[0].trim(); // strip extras like [bar] + String version = parts[1].trim(); + currentKey = PyprojectTomlUtils.canonicalize(name); + packages.put(currentKey, new UvPackage(name, version, new ArrayList<>())); + continue; + } + + // Indented "# via ..." line + if (trimmed.startsWith("# via") && currentKey != null) { + inViaBlock = true; + String rest = trimmed.substring("# via".length()).trim(); + if (!rest.isEmpty()) { + recordViaParent(rest, currentKey, projectName, directDeps, parentChildPairs); + } + continue; + } + + // Multi-line via continuation: "# parent-name" + if (inViaBlock && trimmed.startsWith("#") && currentKey != null) { + String parent = trimmed.substring(1).trim(); + if (!parent.isEmpty()) { + recordViaParent(parent, currentKey, projectName, directDeps, parentChildPairs); + } + } + } + + // Resolve parent→child relationships + for (String[] pair : parentChildPairs) { + UvPackage parent = packages.get(pair[0]); + if (parent != null && !parent.children().contains(pair[1])) { + parent.children().add(pair[1]); + } + } + + return new UvDependencyData(directDeps, packages); + } + + /** + * Parses an editable install line ({@code -e file:///path/to/member}) by reading the member's + * {@code pyproject.toml} to extract name and version. Skips the root project itself and packages + * missing either name or version, matching the JS client behavior. + * + * @return the canonicalized package key, or {@code null} if the member could not be resolved + */ + private static String parseEditableInstall( + String line, Map packages, String projectName) { + String uri = line.substring("-e ".length()).trim(); + try { + Path memberDir = Path.of(URI.create(uri)); + Path memberToml = memberDir.resolve("pyproject.toml"); + if (!Files.isRegularFile(memberToml)) { + log.fine("Editable install pyproject.toml not found: " + memberToml); + return null; + } + TomlParseResult toml = PyprojectTomlUtils.parseToml(memberToml); + String name = PyprojectTomlUtils.getProjectName(toml); + if (name == null) { + // Fall back to Poetry name + name = PyprojectTomlUtils.getPoetryProjectName(toml); + } + if (name == null) { + log.fine("Editable install has no project.name: " + memberToml); + return null; + } + String version = PyprojectTomlUtils.getProjectVersion(toml); + if (version == null) { + // Fall back to Poetry version + version = PyprojectTomlUtils.getPoetryProjectVersion(toml); + } + if (version == null) { + log.fine("Editable install has no project.version: " + memberToml); + return null; + } + String key = PyprojectTomlUtils.canonicalize(name); + // Skip the root project itself + if (key.equals(projectName)) { + return null; + } + packages.put(key, new UvPackage(name, version, new ArrayList<>())); + return key; + } catch (Exception e) { + log.fine("Failed to resolve editable install '" + uri + "': " + e.getMessage()); + return null; + } + } + + private static final Pattern BARE_PACKAGE_NAME = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]*"); + + private static void recordViaParent( + String parentName, + String childKey, + String projectName, + List directDeps, + List parentChildPairs) { + if (!BARE_PACKAGE_NAME.matcher(parentName).matches()) { + return; + } + String parentKey = PyprojectTomlUtils.canonicalize(parentName); + if (parentKey.equals(projectName)) { + if (!directDeps.contains(childKey)) { + directDeps.add(childKey); + } + } else { + parentChildPairs.add(new String[] {parentKey, childKey}); + } + } + + String getUvExportOutput(Path manifestDir) { + String envValue = Environment.get(PROP_TRUSTIFY_DA_UV_EXPORT); + if (envValue != null && !envValue.isBlank()) { + return envValue; + } + + String[] cmd = { + uvExecutable, + "export", + "--format", + "requirements.txt", + "--frozen", + "--no-hashes", + "--no-dev", + "--no-emit-project" + }; + Operations.ProcessExecOutput result = + Operations.runProcessGetFullOutput(manifestDir, cmd, null); + if (result.getExitCode() != 0) { + throw new RuntimeException( + String.format( + "uv export command failed with exit code %d: %s", + result.getExitCode(), result.getError())); + } + return result.getOutput(); + } + + // --- TOML parsing (shared with PythonPyprojectProvider) --- + + private TomlParseResult getToml() throws IOException { + if (cachedToml == null) { + cachedToml = PyprojectTomlUtils.parseToml(manifestPath); + } + return cachedToml; + } + + @Override + protected String getRootComponentName() { + try { + String name = PyprojectTomlUtils.getProjectName(getToml()); + if (name != null) { + return name; + } + } catch (IOException e) { + log.fine("Failed to parse pyproject.toml for root component name: " + e.getMessage()); + } + return super.getRootComponentName(); + } + + @Override + protected String getRootComponentVersion() { + try { + String version = PyprojectTomlUtils.getProjectVersion(getToml()); + if (version != null) { + return version; + } + } catch (IOException e) { + log.fine("Failed to parse pyproject.toml for root component version: " + e.getMessage()); + } + return super.getRootComponentVersion(); + } + + @Override + public String readLicenseFromManifest() { + try { + String license = PyprojectTomlUtils.getLicense(getToml()); + if (license != null) { + return license; + } + } catch (IOException e) { + log.fine("Failed to parse pyproject.toml for license: " + e.getMessage()); + } + return LicenseUtils.readLicenseFile(manifestPath); + } + + @Override + protected Set getIgnoredDependencies(String manifestContent) { + if (collectedIgnoredDeps == null) { + return Set.of(); + } + return collectedIgnoredDeps.stream() + .map( + dep -> { + String name = PythonControllerBase.getDependencyName(dep); + return toPurl(name, "*"); + }) + .collect(Collectors.toSet()); + } + + private void rejectPoetryDependencies() throws IOException { + if (PyprojectTomlUtils.hasPoetryDependencies(getToml())) { + throw new IllegalStateException( + "Poetry dependencies in pyproject.toml are not supported." + + " Please use PEP 621 [project.dependencies] format instead."); + } + } + + private void collectIgnoredDeps() throws IOException { + collectedIgnoredDeps = PyprojectTomlUtils.collectIgnoredDeps(manifestPath, getToml()); + } + + record UvPackage(String name, String version, List children) {} + + record UvDependencyData(List directDeps, Map graph) {} +} diff --git a/src/main/java/io/github/guacsec/trustifyda/tools/Ecosystem.java b/src/main/java/io/github/guacsec/trustifyda/tools/Ecosystem.java index 16650f79..3f6844fc 100644 --- a/src/main/java/io/github/guacsec/trustifyda/tools/Ecosystem.java +++ b/src/main/java/io/github/guacsec/trustifyda/tools/Ecosystem.java @@ -23,7 +23,7 @@ import io.github.guacsec.trustifyda.providers.JavaMavenProvider; import io.github.guacsec.trustifyda.providers.JavaScriptProviderFactory; import io.github.guacsec.trustifyda.providers.PythonPipProvider; -import io.github.guacsec.trustifyda.providers.PythonPyprojectProvider; +import io.github.guacsec.trustifyda.providers.PythonProviderFactory; import java.nio.file.Path; /** Utility class used for instantiating providers. * */ @@ -86,7 +86,7 @@ private static Provider resolveProvider(final Path manifestPath) { case "package.json" -> JavaScriptProviderFactory.create(manifestPath); case "go.mod" -> new GoModulesProvider(manifestPath); case "requirements.txt" -> new PythonPipProvider(manifestPath); - case "pyproject.toml" -> new PythonPyprojectProvider(manifestPath); + case "pyproject.toml" -> PythonProviderFactory.create(manifestPath); case "build.gradle", "build.gradle.kts" -> new GradleProvider(manifestPath); case "Cargo.toml" -> new CargoProvider(manifestPath); default -> diff --git a/src/main/java/io/github/guacsec/trustifyda/utils/PyprojectTomlUtils.java b/src/main/java/io/github/guacsec/trustifyda/utils/PyprojectTomlUtils.java new file mode 100644 index 00000000..edd2b2bc --- /dev/null +++ b/src/main/java/io/github/guacsec/trustifyda/utils/PyprojectTomlUtils.java @@ -0,0 +1,144 @@ +/* + * Copyright 2023-2025 Trustify Dependency Analytics Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.guacsec.trustifyda.utils; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import org.tomlj.Toml; +import org.tomlj.TomlArray; +import org.tomlj.TomlParseResult; + +/** + * Shared utilities for Python providers that use {@code pyproject.toml} manifests. Provides TOML + * parsing, ignore-pattern collection, and metadata extraction used by both {@code + * PythonPyprojectProvider} and {@code PythonUvProvider}. + */ +public final class PyprojectTomlUtils { + + private PyprojectTomlUtils() {} + + /** Parses a {@code pyproject.toml} file and returns the result. */ + public static TomlParseResult parseToml(Path manifest) throws IOException { + TomlParseResult parsed = Toml.parse(manifest); + if (parsed.hasErrors()) { + throw new IOException( + "Invalid pyproject.toml format: " + parsed.errors().get(0).getMessage()); + } + return parsed; + } + + /** + * Collects dependency identifiers marked with ignore patterns ({@code #exhortignore} or {@code + * #trustify-da-ignore}) in the {@code [project.dependencies]} section. + * + * @param manifest the path to the pyproject.toml file + * @param toml the parsed TOML result + * @return set of raw dependency strings that are marked as ignored + */ + public static Set collectIgnoredDeps(Path manifest, TomlParseResult toml) + throws IOException { + List rawLines = Files.readAllLines(manifest); + Set ignored = new HashSet<>(); + + TomlArray projectDeps = toml.getArray("project.dependencies"); + if (projectDeps != null) { + for (int i = 0; i < projectDeps.size(); i++) { + String dep = projectDeps.getString(i); + for (String line : rawLines) { + if (line.contains(dep) && IgnorePatternDetector.containsIgnorePattern(line)) { + ignored.add(dep); + break; + } + } + } + } + return ignored; + } + + /** Reads {@code project.name} from a parsed pyproject.toml, or {@code null} if absent. */ + public static String getProjectName(TomlParseResult toml) { + String name = toml.getString("project.name"); + return (name != null && !name.isBlank()) ? name : null; + } + + /** Reads {@code project.version} from a parsed pyproject.toml, or {@code null} if absent. */ + public static String getProjectVersion(TomlParseResult toml) { + String version = toml.getString("project.version"); + return (version != null && !version.isBlank()) ? version : null; + } + + /** + * Reads the license from a parsed pyproject.toml. Checks {@code project.license} first, then + * {@code project.license.text} (PEP 639). + * + * @return the license string, or {@code null} if not found + */ + public static String getLicense(TomlParseResult toml) { + String license = toml.getString("project.license"); + if (license != null && !license.isBlank()) { + return license; + } + String licenseText = toml.getString("project.license.text"); + return (licenseText != null && !licenseText.isBlank()) ? licenseText : null; + } + + /** Returns {@code true} if the manifest contains {@code [tool.poetry.dependencies]}. */ + public static boolean hasPoetryDependencies(TomlParseResult toml) { + return toml.getTable("tool.poetry.dependencies") != null; + } + + /** Reads {@code tool.poetry.name} from a parsed pyproject.toml, or {@code null} if absent. */ + public static String getPoetryProjectName(TomlParseResult toml) { + String name = toml.getString("tool.poetry.name"); + return (name != null && !name.isBlank()) ? name : null; + } + + /** Reads {@code tool.poetry.version} from a parsed pyproject.toml, or {@code null} if absent. */ + public static String getPoetryProjectVersion(TomlParseResult toml) { + String version = toml.getString("tool.poetry.version"); + return (version != null && !version.isBlank()) ? version : null; + } + + /** + * Canonicalizes a Python package name by lower-casing it and collapsing runs of hyphens, + * underscores, and dots into a single hyphen, per PEP 503. + */ + public static String canonicalize(String name) { + return name.toLowerCase().replaceAll("[-_.]+", "-"); + } + + /** + * Returns {@code true} if the directory contains a {@code pyproject.toml} with a {@code + * [tool.uv.workspace]} section, indicating a uv workspace root. + */ + public static boolean isUvWorkspaceRoot(Path dir) { + Path pyprojectPath = dir.resolve("pyproject.toml"); + if (!Files.isRegularFile(pyprojectPath)) { + return false; + } + try { + TomlParseResult toml = Toml.parse(pyprojectPath); + return toml.getTable("tool.uv.workspace") != null; + } catch (Exception e) { + return false; + } + } +} diff --git a/src/main/java/io/github/guacsec/trustifyda/utils/PythonControllerBase.java b/src/main/java/io/github/guacsec/trustifyda/utils/PythonControllerBase.java index b08c204e..cc367bc1 100644 --- a/src/main/java/io/github/guacsec/trustifyda/utils/PythonControllerBase.java +++ b/src/main/java/io/github/guacsec/trustifyda/utils/PythonControllerBase.java @@ -380,6 +380,14 @@ protected String getDependencyNameShow(String pipShowOutput) { public static String getDependencyName(String dep) { int markerSeparator = dep.indexOf(";"); String requirement = markerSeparator == -1 ? dep : dep.substring(0, markerSeparator); + // Strip PEP 508 extras (e.g. "requests[security]" -> "requests") + int extrasStart = requirement.indexOf("["); + if (extrasStart != -1) { + int extrasEnd = requirement.indexOf("]", extrasStart); + if (extrasEnd != -1) { + requirement = requirement.substring(0, extrasStart) + requirement.substring(extrasEnd + 1); + } + } int rightTriangleBracket = requirement.indexOf(">"); int leftTriangleBracket = requirement.indexOf("<"); int equalsSign = requirement.indexOf("="); diff --git a/src/test/java/io/github/guacsec/trustifyda/providers/PythonProviderFactoryLockFileTest.java b/src/test/java/io/github/guacsec/trustifyda/providers/PythonProviderFactoryLockFileTest.java new file mode 100644 index 00000000..0deeca37 --- /dev/null +++ b/src/test/java/io/github/guacsec/trustifyda/providers/PythonProviderFactoryLockFileTest.java @@ -0,0 +1,174 @@ +/* + * Copyright 2023-2025 Trustify Dependency Analytics Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.guacsec.trustifyda.providers; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junitpioneer.jupiter.ClearSystemProperty; + +/** + * Tests for PythonProviderFactory lock file walk-up. Verifies that the factory can find uv.lock in + * parent directories for workspace member packages. + */ +class PythonProviderFactoryLockFileTest { + + private static final String PYPROJECT_TOML = + "[project]\nname = \"my-lib\"\nversion = \"1.0.0\"\ndependencies = []\n"; + + /** Lock file in manifest directory (existing behavior — fast path). */ + @Test + void testLockFileInManifestDir(@TempDir Path tempDir) throws IOException { + Files.writeString(tempDir.resolve("pyproject.toml"), PYPROJECT_TOML); + Files.writeString(tempDir.resolve("uv.lock"), "version = 1"); + + var provider = PythonProviderFactory.create(tempDir.resolve("pyproject.toml")); + assertThat(provider).isInstanceOf(PythonUvProvider.class); + } + + /** No lock file — falls back to pip provider. */ + @Test + void testNoLockFileFallsToPip(@TempDir Path tempDir) throws IOException { + Files.writeString(tempDir.resolve("pyproject.toml"), PYPROJECT_TOML); + + var provider = PythonProviderFactory.create(tempDir.resolve("pyproject.toml")); + assertThat(provider).isInstanceOf(PythonPyprojectProvider.class); + } + + /** Lock file at workspace root, member pyproject.toml in subdirectory. */ + @Test + void testLockFileFoundInParentDir(@TempDir Path tempDir) throws IOException { + // Workspace root with uv workspace config and lock file + Files.writeString( + tempDir.resolve("pyproject.toml"), + "[project]\nname = \"workspace\"\nversion = \"1.0.0\"\n\n" + + "[tool.uv.workspace]\nmembers = [\"packages/*\"]\n"); + Files.writeString(tempDir.resolve("uv.lock"), "version = 1"); + + // Member package without its own lock file + Path memberDir = tempDir.resolve("packages/my-lib"); + Files.createDirectories(memberDir); + Files.writeString(memberDir.resolve("pyproject.toml"), PYPROJECT_TOML); + + var provider = PythonProviderFactory.create(memberDir.resolve("pyproject.toml")); + assertThat(provider).isInstanceOf(PythonUvProvider.class); + } + + /** TRUSTIFY_DA_WORKSPACE_DIR overrides walk-up. */ + @Test + @ClearSystemProperty(key = "TRUSTIFY_DA_WORKSPACE_DIR") + void testWorkspaceDirOverride(@TempDir Path tempDir) throws IOException { + // Custom dir with uv lock file + Path customDir = tempDir.resolve("custom-root"); + Files.createDirectories(customDir); + Files.writeString(customDir.resolve("uv.lock"), "version = 1"); + + // Member without lock file + Path memberDir = tempDir.resolve("packages/svc"); + Files.createDirectories(memberDir); + Files.writeString(memberDir.resolve("pyproject.toml"), PYPROJECT_TOML); + + System.setProperty("TRUSTIFY_DA_WORKSPACE_DIR", customDir.toString()); + try { + var provider = PythonProviderFactory.create(memberDir.resolve("pyproject.toml")); + assertThat(provider).isInstanceOf(PythonUvProvider.class); + } finally { + System.clearProperty("TRUSTIFY_DA_WORKSPACE_DIR"); + } + } + + /** Walk-up stops at uv workspace root boundary without lock file — falls back to pip. */ + @Test + void testStopsAtUvWorkspaceRootBoundary(@TempDir Path tempDir) throws IOException { + // Workspace root with uv workspace config but NO lock file + Files.writeString( + tempDir.resolve("pyproject.toml"), + "[project]\nname = \"workspace\"\nversion = \"1.0.0\"\n\n" + + "[tool.uv.workspace]\nmembers = [\"packages/*\"]\n"); + + // Member package + Path memberDir = tempDir.resolve("packages/lib"); + Files.createDirectories(memberDir); + Files.writeString(memberDir.resolve("pyproject.toml"), PYPROJECT_TOML); + + // Should fall back to pip, not keep walking up + var provider = PythonProviderFactory.create(memberDir.resolve("pyproject.toml")); + assertThat(provider).isInstanceOf(PythonPyprojectProvider.class); + } + + /** When manifestDir itself is a workspace root without uv.lock, don't walk up to parent. */ + @Test + void testStartDirIsWorkspaceRootDoesNotWalkUp(@TempDir Path tempDir) throws IOException { + // Parent has uv.lock (unrelated workspace) + Files.writeString(tempDir.resolve("uv.lock"), "version = 1"); + Files.writeString( + tempDir.resolve("pyproject.toml"), + "[project]\nname = \"parent-ws\"\nversion = \"1.0.0\"\n\n" + + "[tool.uv.workspace]\nmembers = [\"child-ws\"]\n"); + + // Child is itself a workspace root, but has no uv.lock + Path childWs = tempDir.resolve("child-ws"); + Files.createDirectories(childWs); + Files.writeString( + childWs.resolve("pyproject.toml"), + "[project]\nname = \"child-ws\"\nversion = \"1.0.0\"\n\n" + + "[tool.uv.workspace]\nmembers = [\"packages/*\"]\n"); + + // Should NOT pick up the parent's uv.lock — should fall back to pip + var provider = PythonProviderFactory.create(childWs.resolve("pyproject.toml")); + assertThat(provider).isInstanceOf(PythonPyprojectProvider.class); + } + + /** validateLockFile passes for workspace member when uv.lock is in parent directory. */ + @Test + void testValidateLockFilePassesWithLockInParent(@TempDir Path tempDir) throws IOException { + // Workspace root with uv.lock + Files.writeString( + tempDir.resolve("pyproject.toml"), + "[project]\nname = \"workspace\"\nversion = \"1.0.0\"\n\n" + + "[tool.uv.workspace]\nmembers = [\"packages/*\"]\n"); + Files.writeString(tempDir.resolve("uv.lock"), "version = 1"); + + // Member package without its own lock file + Path memberDir = tempDir.resolve("packages/my-lib"); + Files.createDirectories(memberDir); + Files.writeString(memberDir.resolve("pyproject.toml"), PYPROJECT_TOML); + + var provider = new PythonUvProvider(memberDir.resolve("pyproject.toml")); + // Ecosystem.getProvider() calls validateLockFile(manifestPath.getParent()) + assertThatCode(() -> provider.validateLockFile(memberDir)).doesNotThrowAnyException(); + } + + /** validateLockFile throws when uv.lock is not found anywhere. */ + @Test + void testValidateLockFileThrowsWhenNotFound(@TempDir Path tempDir) throws IOException { + Path memberDir = tempDir.resolve("packages/my-lib"); + Files.createDirectories(memberDir); + Files.writeString(memberDir.resolve("pyproject.toml"), PYPROJECT_TOML); + + var provider = new PythonUvProvider(memberDir.resolve("pyproject.toml")); + assertThatThrownBy(() -> provider.validateLockFile(memberDir)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("uv.lock does not exist"); + } +} diff --git a/src/test/java/io/github/guacsec/trustifyda/providers/Python_Pyproject_Provider_Test.java b/src/test/java/io/github/guacsec/trustifyda/providers/Python_Pyproject_Provider_Test.java index 51cce7b8..00216395 100644 --- a/src/test/java/io/github/guacsec/trustifyda/providers/Python_Pyproject_Provider_Test.java +++ b/src/test/java/io/github/guacsec/trustifyda/providers/Python_Pyproject_Provider_Test.java @@ -22,6 +22,7 @@ import io.github.guacsec.trustifyda.Api; import io.github.guacsec.trustifyda.ExhortTest; import io.github.guacsec.trustifyda.tools.Ecosystem; +import io.github.guacsec.trustifyda.utils.PyprojectTomlUtils; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; @@ -247,12 +248,11 @@ void test_extractDepName() { @Test void test_canonicalize() { - assertThat(PythonPyprojectProvider.canonicalize("charset_normalizer")) + assertThat(PyprojectTomlUtils.canonicalize("charset_normalizer")) .isEqualTo("charset-normalizer"); - assertThat(PythonPyprojectProvider.canonicalize("Jinja2")).isEqualTo("jinja2"); - assertThat(PythonPyprojectProvider.canonicalize("MarkupSafe")).isEqualTo("markupsafe"); - assertThat(PythonPyprojectProvider.canonicalize("my.package_name")) - .isEqualTo("my-package-name"); + assertThat(PyprojectTomlUtils.canonicalize("Jinja2")).isEqualTo("jinja2"); + assertThat(PyprojectTomlUtils.canonicalize("MarkupSafe")).isEqualTo("markupsafe"); + assertThat(PyprojectTomlUtils.canonicalize("my.package_name")).isEqualTo("my-package-name"); } @Test diff --git a/src/test/java/io/github/guacsec/trustifyda/providers/Python_Uv_Provider_Test.java b/src/test/java/io/github/guacsec/trustifyda/providers/Python_Uv_Provider_Test.java new file mode 100644 index 00000000..7fda0b84 --- /dev/null +++ b/src/test/java/io/github/guacsec/trustifyda/providers/Python_Uv_Provider_Test.java @@ -0,0 +1,415 @@ +/* + * Copyright 2023-2025 Trustify Dependency Analytics Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.guacsec.trustifyda.providers; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import io.github.guacsec.trustifyda.Api; +import io.github.guacsec.trustifyda.ExhortTest; +import io.github.guacsec.trustifyda.tools.Ecosystem; +import io.github.guacsec.trustifyda.utils.PyprojectTomlUtils; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class Python_Uv_Provider_Test extends ExhortTest { + + private static final String UV_FIXTURE = + "src/test/resources/tst_manifests/pip/pip_pyproject_toml_uv"; + private static final String UV_IGNORE_FIXTURE = + "src/test/resources/tst_manifests/pip/pip_pyproject_toml_uv_ignore"; + + @Test + void test_ecosystem_resolves_pyproject_toml_with_uv_lock() throws IOException { + var tempDir = + new TempDirFromResources() + .addFile("pyproject.toml") + .fromResources("tst_manifests/pip/pip_pyproject_toml_uv/pyproject.toml") + .addFile("uv.lock") + .fromResources("tst_manifests/pip/pip_pyproject_toml_uv/uv.lock"); + Path pyprojectPath = tempDir.getTempDir().resolve("pyproject.toml"); + var provider = Ecosystem.getProvider(pyprojectPath); + assertThat(provider).isInstanceOf(PythonUvProvider.class); + assertThat(provider.ecosystem).isEqualTo(Ecosystem.Type.PYTHON); + } + + @Test + void test_ecosystem_resolves_pyproject_toml_without_uv_lock() { + var provider = + Ecosystem.getProvider( + Path.of( + "src/test/resources/tst_manifests/pip/pip_pyproject_toml_no_ignore/pyproject.toml")); + assertThat(provider).isInstanceOf(PythonPyprojectProvider.class); + } + + @Test + void test_factory_selects_uv_provider_with_lock() throws IOException { + var tempDir = + new TempDirFromResources() + .addFile("pyproject.toml") + .fromResources("tst_manifests/pip/pip_pyproject_toml_uv/pyproject.toml") + .addFile("uv.lock") + .fromResources("tst_manifests/pip/pip_pyproject_toml_uv/uv.lock"); + Path pyprojectPath = tempDir.getTempDir().resolve("pyproject.toml"); + var provider = PythonProviderFactory.create(pyprojectPath); + assertThat(provider).isInstanceOf(PythonUvProvider.class); + } + + @Test + void test_factory_falls_back_to_pyproject_without_lock() { + Path pyprojectPath = + Path.of("src/test/resources/tst_manifests/pip/pip_pyproject_toml_no_ignore/pyproject.toml"); + var provider = PythonProviderFactory.create(pyprojectPath); + assertThat(provider).isInstanceOf(PythonPyprojectProvider.class); + } + + @Test + void test_validate_lock_file_passes_with_uv_lock() throws IOException { + var tempDir = + new TempDirFromResources() + .addFile("pyproject.toml") + .fromResources("tst_manifests/pip/pip_pyproject_toml_uv/pyproject.toml") + .addFile("uv.lock") + .fromResources("tst_manifests/pip/pip_pyproject_toml_uv/uv.lock"); + Path pyprojectPath = tempDir.getTempDir().resolve("pyproject.toml"); + var provider = new PythonUvProvider(pyprojectPath); + provider.validateLockFile(tempDir.getTempDir()); + } + + @Test + void test_validate_lock_file_throws_without_uv_lock() throws IOException { + var tempDir = + new TempDirFromResources() + .addFile("pyproject.toml") + .fromResources("tst_manifests/pip/pip_pyproject_toml_no_ignore/pyproject.toml"); + Path pyprojectPath = tempDir.getTempDir().resolve("pyproject.toml"); + var provider = new PythonUvProvider(pyprojectPath); + assertThatIllegalStateException() + .isThrownBy(() -> provider.validateLockFile(tempDir.getTempDir())) + .withMessageContaining("uv.lock does not exist"); + } + + @Test + void test_parseUvExport_parses_packages() throws IOException { + Path exportPath = Path.of(UV_FIXTURE, "uv_export.txt"); + Path pyprojectPath = Path.of(UV_FIXTURE, "pyproject.toml"); + var provider = new PythonUvProvider(pyprojectPath); + String exportOutput = Files.readString(exportPath); + var data = provider.parseUvExport(exportOutput); + assertThat(data.graph()).containsKeys("anyio", "flask", "requests", "idna", "sniffio"); + assertThat(data.graph().get("anyio").version()).isEqualTo("3.6.2"); + assertThat(data.graph().get("flask").version()).isEqualTo("2.0.3"); + } + + @Test + void test_parseUvExport_builds_children() throws IOException { + Path exportPath = Path.of(UV_FIXTURE, "uv_export.txt"); + Path pyprojectPath = Path.of(UV_FIXTURE, "pyproject.toml"); + var provider = new PythonUvProvider(pyprojectPath); + String exportOutput = Files.readString(exportPath); + var data = provider.parseUvExport(exportOutput); + + var requestsPkg = data.graph().get("requests"); + assertThat(requestsPkg).isNotNull(); + assertThat(requestsPkg.children()) + .containsExactlyInAnyOrder("charset-normalizer", "idna", "urllib3", "certifi"); + + var anyioPkg = data.graph().get("anyio"); + assertThat(anyioPkg).isNotNull(); + assertThat(anyioPkg.children()).containsExactlyInAnyOrder("idna", "sniffio"); + + var flaskPkg = data.graph().get("flask"); + assertThat(flaskPkg).isNotNull(); + assertThat(flaskPkg.children()) + .containsExactlyInAnyOrder("werkzeug", "jinja2", "itsdangerous", "click"); + + var jinja2Pkg = data.graph().get("jinja2"); + assertThat(jinja2Pkg).isNotNull(); + assertThat(jinja2Pkg.children()).containsExactly("markupsafe"); + } + + @Test + void test_parseUvExport_identifies_direct_deps() throws IOException { + Path exportPath = Path.of(UV_FIXTURE, "uv_export.txt"); + Path pyprojectPath = Path.of(UV_FIXTURE, "pyproject.toml"); + var provider = new PythonUvProvider(pyprojectPath); + String exportOutput = Files.readString(exportPath); + var data = provider.parseUvExport(exportOutput); + assertThat(data.directDeps()).containsExactlyInAnyOrder("anyio", "flask", "requests"); + } + + @Test + void test_getRootComponentName_reads_pep621_name() { + Path pyprojectPath = Path.of(UV_FIXTURE, "pyproject.toml"); + var provider = new PythonUvProvider(pyprojectPath); + assertThat(provider.getRootComponentName()).isEqualTo("test-project"); + } + + @Test + void test_getRootComponentVersion_reads_pep621_version() { + Path pyprojectPath = Path.of(UV_FIXTURE, "pyproject.toml"); + var provider = new PythonUvProvider(pyprojectPath); + assertThat(provider.getRootComponentVersion()).isEqualTo("0.1.0"); + } + + @Test + void test_provideStack_with_uv_export() throws IOException { + Path pyprojectPath = Path.of(UV_FIXTURE, "pyproject.toml"); + String exportOutput = Files.readString(Path.of(UV_FIXTURE, "uv_export.txt")); + + System.setProperty(PythonUvProvider.PROP_TRUSTIFY_DA_UV_EXPORT, exportOutput); + try { + var provider = new PythonUvProvider(pyprojectPath); + var content = provider.provideStack(); + assertThat(content.type).isEqualTo(Api.CYCLONEDX_MEDIA_TYPE); + String sbomJson = new String(content.buffer); + assertThat(sbomJson).contains("CycloneDX"); + assertThat(sbomJson).contains("pkg:pypi/"); + assertThat(sbomJson).contains("pkg:pypi/anyio@3.6.2"); + assertThat(sbomJson).contains("pkg:pypi/flask@2.0.3"); + assertThat(sbomJson).contains("pkg:pypi/requests@2.25.1"); + assertThat(sbomJson).contains("pkg:pypi/idna@3.4"); + assertThat(sbomJson).contains("pkg:pypi/sniffio@1.3.0"); + assertThat(sbomJson).contains("pkg:pypi/certifi@2023.5.7"); + assertThat(sbomJson).contains("pkg:pypi/markupsafe@2.1.2"); + } catch (RuntimeException | NoClassDefFoundError e) { + Assumptions.assumeTrue(false, "Skipping: SBOM serialization unavailable - " + e.getMessage()); + } finally { + System.clearProperty(PythonUvProvider.PROP_TRUSTIFY_DA_UV_EXPORT); + } + } + + @Test + void test_provideComponent_with_uv_export() throws IOException { + Path pyprojectPath = Path.of(UV_FIXTURE, "pyproject.toml"); + String exportOutput = Files.readString(Path.of(UV_FIXTURE, "uv_export.txt")); + + System.setProperty(PythonUvProvider.PROP_TRUSTIFY_DA_UV_EXPORT, exportOutput); + try { + var provider = new PythonUvProvider(pyprojectPath); + var content = provider.provideComponent(); + assertThat(content.type).isEqualTo(Api.CYCLONEDX_MEDIA_TYPE); + String sbomJson = new String(content.buffer); + assertThat(sbomJson).contains("CycloneDX"); + assertThat(sbomJson).contains("pkg:pypi/anyio@3.6.2"); + assertThat(sbomJson).contains("pkg:pypi/flask@2.0.3"); + assertThat(sbomJson).contains("pkg:pypi/requests@2.25.1"); + } catch (RuntimeException | NoClassDefFoundError e) { + Assumptions.assumeTrue(false, "Skipping: SBOM serialization unavailable - " + e.getMessage()); + } finally { + System.clearProperty(PythonUvProvider.PROP_TRUSTIFY_DA_UV_EXPORT); + } + } + + @Test + void test_ignored_dependencies_in_uv_project() throws IOException { + Path pyprojectPath = Path.of(UV_IGNORE_FIXTURE, "pyproject.toml"); + String exportOutput = Files.readString(Path.of(UV_IGNORE_FIXTURE, "uv_export.txt")); + + System.setProperty(PythonUvProvider.PROP_TRUSTIFY_DA_UV_EXPORT, exportOutput); + try { + var provider = new PythonUvProvider(pyprojectPath); + var content = provider.provideStack(); + String sbomJson = new String(content.buffer); + assertThat(sbomJson).doesNotContain("pkg:pypi/flask@"); + assertThat(sbomJson).contains("pkg:pypi/anyio@3.6.2"); + assertThat(sbomJson).contains("pkg:pypi/requests@2.25.1"); + } catch (RuntimeException | NoClassDefFoundError e) { + Assumptions.assumeTrue(false, "Skipping: SBOM serialization unavailable - " + e.getMessage()); + } finally { + System.clearProperty(PythonUvProvider.PROP_TRUSTIFY_DA_UV_EXPORT); + } + } + + @Test + void test_parseUvExport_includes_editable_installs(@TempDir Path tempDir) throws IOException { + // Create a workspace member with its own pyproject.toml + Path memberDir = tempDir.resolve("packages").resolve("my-lib"); + Files.createDirectories(memberDir); + Files.writeString( + memberDir.resolve("pyproject.toml"), + "[project]\nname = \"my-lib\"\nversion = \"2.0.0\"\ndependencies = []\n"); + + Path pyprojectPath = Path.of(UV_FIXTURE, "pyproject.toml"); + var provider = new PythonUvProvider(pyprojectPath); + + // Simulate uv export output with an editable install + String exportOutput = + "# This file was autogenerated by uv\n" + + "-e file://" + + memberDir.toUri().getPath() + + "\n" + + " # via test-project\n" + + "anyio==3.6.2\n" + + " # via my-lib\n"; + + var data = provider.parseUvExport(exportOutput); + + // The editable install should be in the graph with name/version from pyproject.toml + assertThat(data.graph()).containsKey("my-lib"); + assertThat(data.graph().get("my-lib").name()).isEqualTo("my-lib"); + assertThat(data.graph().get("my-lib").version()).isEqualTo("2.0.0"); + + // It should be identified as a direct dependency (via test-project) + assertThat(data.directDeps()).contains("my-lib"); + + // anyio should be a child of my-lib + assertThat(data.graph().get("my-lib").children()).contains("anyio"); + } + + @Test + void test_parseUvExport_editable_skips_self_reference(@TempDir Path tempDir) throws IOException { + // Create a member whose name matches the root project (test-project) + Path memberDir = tempDir.resolve("packages").resolve("self"); + Files.createDirectories(memberDir); + Files.writeString( + memberDir.resolve("pyproject.toml"), + "[project]\nname = \"test-project\"\nversion = \"0.1.0\"\ndependencies = []\n"); + + Path pyprojectPath = Path.of(UV_FIXTURE, "pyproject.toml"); + var provider = new PythonUvProvider(pyprojectPath); + + String exportOutput = + "# This file was autogenerated by uv\n" + + "-e file://" + + memberDir.toUri().getPath() + + "\n" + + "anyio==3.6.2\n" + + " # via test-project\n"; + + var data = provider.parseUvExport(exportOutput); + + // The root project should NOT appear in the graph as its own dependency + assertThat(data.graph()).doesNotContainKey("test-project"); + // anyio should still be a direct dep + assertThat(data.directDeps()).contains("anyio"); + } + + @Test + void test_parseUvExport_editable_skips_missing_version(@TempDir Path tempDir) throws IOException { + // Create a member with no version + Path memberDir = tempDir.resolve("packages").resolve("no-version"); + Files.createDirectories(memberDir); + Files.writeString( + memberDir.resolve("pyproject.toml"), + "[project]\nname = \"no-version-lib\"\ndependencies = []\n"); + + Path pyprojectPath = Path.of(UV_FIXTURE, "pyproject.toml"); + var provider = new PythonUvProvider(pyprojectPath); + + String exportOutput = + "# This file was autogenerated by uv\n" + + "-e file://" + + memberDir.toUri().getPath() + + "\n" + + " # via test-project\n" + + "anyio==3.6.2\n" + + " # via test-project\n"; + + var data = provider.parseUvExport(exportOutput); + + // Package with no version should be skipped + assertThat(data.graph()).doesNotContainKey("no-version-lib"); + // anyio should still be parsed + assertThat(data.graph()).containsKey("anyio"); + } + + @Test + void test_parseUvExport_editable_falls_back_to_poetry_name(@TempDir Path tempDir) + throws IOException { + // Create a member with Poetry-style name/version only + Path memberDir = tempDir.resolve("packages").resolve("poetry-lib"); + Files.createDirectories(memberDir); + Files.writeString( + memberDir.resolve("pyproject.toml"), + "[tool.poetry]\nname = \"poetry-lib\"\nversion = \"1.5.0\"\n"); + + Path pyprojectPath = Path.of(UV_FIXTURE, "pyproject.toml"); + var provider = new PythonUvProvider(pyprojectPath); + + String exportOutput = + "# This file was autogenerated by uv\n" + + "-e file://" + + memberDir.toUri().getPath() + + "\n" + + " # via test-project\n" + + "anyio==3.6.2\n" + + " # via poetry-lib\n"; + + var data = provider.parseUvExport(exportOutput); + + // Poetry name/version should be used as fallback + assertThat(data.graph()).containsKey("poetry-lib"); + assertThat(data.graph().get("poetry-lib").name()).isEqualTo("poetry-lib"); + assertThat(data.graph().get("poetry-lib").version()).isEqualTo("1.5.0"); + + // It should be a direct dep and anyio should be its child + assertThat(data.directDeps()).contains("poetry-lib"); + assertThat(data.graph().get("poetry-lib").children()).contains("anyio"); + } + + @Test + void test_parseUvExport_via_skips_non_bare_package_names() throws IOException { + Path pyprojectPath = Path.of(UV_FIXTURE, "pyproject.toml"); + var provider = new PythonUvProvider(pyprojectPath); + + String exportOutput = + "# This file was autogenerated by uv\n" + + "anyio==3.6.2\n" + + " # via test-project\n" + + "idna==3.4\n" + + " # via foo (>=1.0)\n" + + "sniffio==1.3.0\n" + + " # via foo[extra]\n"; + + var data = provider.parseUvExport(exportOutput); + + // anyio is direct (via test-project) + assertThat(data.directDeps()).contains("anyio"); + // idna and sniffio should be in the graph but with no parent edges + assertThat(data.graph()).containsKey("idna"); + assertThat(data.graph()).containsKey("sniffio"); + // No package should have children since the via names were invalid + assertThat(data.graph().values().stream().allMatch(p -> p.children().isEmpty())).isTrue(); + } + + @Test + void test_parseUvExport_throws_on_unpinned_version() { + Path pyprojectPath = Path.of(UV_FIXTURE, "pyproject.toml"); + var provider = new PythonUvProvider(pyprojectPath); + + String exportOutput = + "# This file was autogenerated by uv\n" + "anyio>=3.6.2\n" + " # via test-project\n"; + + assertThatThrownBy(() -> provider.parseUvExport(exportOutput)) + .isInstanceOf(IOException.class) + .hasMessageContaining("has no pinned version"); + } + + @Test + void test_canonicalize() { + assertThat(PyprojectTomlUtils.canonicalize("charset_normalizer")) + .isEqualTo("charset-normalizer"); + assertThat(PyprojectTomlUtils.canonicalize("Jinja2")).isEqualTo("jinja2"); + assertThat(PyprojectTomlUtils.canonicalize("MarkupSafe")).isEqualTo("markupsafe"); + } +} diff --git a/src/test/java/io/github/guacsec/trustifyda/utils/PythonControllerRealEnvTest.java b/src/test/java/io/github/guacsec/trustifyda/utils/PythonControllerRealEnvTest.java index 2b382110..ee63d5d1 100644 --- a/src/test/java/io/github/guacsec/trustifyda/utils/PythonControllerRealEnvTest.java +++ b/src/test/java/io/github/guacsec/trustifyda/utils/PythonControllerRealEnvTest.java @@ -301,6 +301,18 @@ void get_Dependency_Name_with_markers() { PythonControllerRealEnv.getDependencyName("certifi==2023.7.22 ; python_version >= \"3\"")); } + /** Verifies getDependencyName strips PEP 508 extras from requirements. */ + @Test + void get_Dependency_Name_with_extras() { + assertEquals("requests", PythonControllerRealEnv.getDependencyName("requests[security]>=2.0")); + assertEquals("requests", PythonControllerRealEnv.getDependencyName("requests[security]")); + assertEquals("uvicorn", PythonControllerRealEnv.getDependencyName("uvicorn[standard]>=0.15.0")); + assertEquals( + "package", + PythonControllerRealEnv.getDependencyName( + "package[extra1]>=1.0 ; python_version >= \"3.8\"")); + } + @Test void automaticallyInstallPackageOnEnvironment() { assertFalse(pythonControllerRealEnv.automaticallyInstallPackageOnEnvironment()); diff --git a/src/test/resources/tst_manifests/pip/pip_pyproject_toml_uv/pyproject.toml b/src/test/resources/tst_manifests/pip/pip_pyproject_toml_uv/pyproject.toml new file mode 100644 index 00000000..3f875d01 --- /dev/null +++ b/src/test/resources/tst_manifests/pip/pip_pyproject_toml_uv/pyproject.toml @@ -0,0 +1,18 @@ +[project] +name = "test-project" +version = "0.1.0" +dependencies = [ + "anyio==3.6.2", + "flask==2.0.3", + "requests==2.25.1", +] + +[project.optional-dependencies] +dev = [ + "click==8.0.4", +] + +[tool.uv] +dev-dependencies = [ + "pytest>=7.0", +] diff --git a/src/test/resources/tst_manifests/pip/pip_pyproject_toml_uv/uv.lock b/src/test/resources/tst_manifests/pip/pip_pyproject_toml_uv/uv.lock new file mode 100644 index 00000000..b0dd0b31 --- /dev/null +++ b/src/test/resources/tst_manifests/pip/pip_pyproject_toml_uv/uv.lock @@ -0,0 +1,96 @@ +version = 1 +requires-python = ">=3.9" + +[[package]] +name = "test-project" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "anyio" }, + { name = "flask" }, + { name = "requests" }, +] + +[[package]] +name = "anyio" +version = "3.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "sniffio" }, +] + +[[package]] +name = "flask" +version = "2.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "werkzeug" }, + { name = "jinja2" }, + { name = "itsdangerous" }, + { name = "click" }, +] + +[[package]] +name = "requests" +version = "2.25.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, + { name = "certifi" }, +] + +[[package]] +name = "idna" +version = "3.4" +source = { registry = "https://pypi.org/simple" } + +[[package]] +name = "sniffio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } + +[[package]] +name = "werkzeug" +version = "2.2.3" +source = { registry = "https://pypi.org/simple" } + +[[package]] +name = "jinja2" +version = "3.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] + +[[package]] +name = "itsdangerous" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } + +[[package]] +name = "click" +version = "8.1.3" +source = { registry = "https://pypi.org/simple" } + +[[package]] +name = "charset-normalizer" +version = "3.1.0" +source = { registry = "https://pypi.org/simple" } + +[[package]] +name = "urllib3" +version = "1.26.15" +source = { registry = "https://pypi.org/simple" } + +[[package]] +name = "certifi" +version = "2023.5.7" +source = { registry = "https://pypi.org/simple" } + +[[package]] +name = "markupsafe" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } diff --git a/src/test/resources/tst_manifests/pip/pip_pyproject_toml_uv/uv_export.txt b/src/test/resources/tst_manifests/pip/pip_pyproject_toml_uv/uv_export.txt new file mode 100644 index 00000000..30125dd0 --- /dev/null +++ b/src/test/resources/tst_manifests/pip/pip_pyproject_toml_uv/uv_export.txt @@ -0,0 +1,30 @@ +# This file was autogenerated by uv via the following command: +# uv export --format requirements.txt --frozen --no-hashes --no-dev +anyio==3.6.2 + # via test-project +certifi==2023.5.7 + # via requests +charset-normalizer==3.1.0 + # via requests +click==8.1.3 + # via flask +flask==2.0.3 + # via test-project +idna==3.4 + # via + # anyio + # requests +itsdangerous==2.1.2 + # via flask +jinja2==3.1.2 + # via flask +markupsafe==2.1.2 + # via jinja2 +requests==2.25.1 + # via test-project +sniffio==1.3.0 + # via anyio +urllib3==1.26.15 + # via requests +werkzeug==2.2.3 + # via flask diff --git a/src/test/resources/tst_manifests/pip/pip_pyproject_toml_uv_ignore/pyproject.toml b/src/test/resources/tst_manifests/pip/pip_pyproject_toml_uv_ignore/pyproject.toml new file mode 100644 index 00000000..30c9d2be --- /dev/null +++ b/src/test/resources/tst_manifests/pip/pip_pyproject_toml_uv_ignore/pyproject.toml @@ -0,0 +1,18 @@ +[project] +name = "test-project" +version = "0.1.0" +dependencies = [ + "anyio==3.6.2", + "flask==2.0.3", # exhortignore + "requests==2.25.1", +] + +[project.optional-dependencies] +dev = [ + "click==8.0.4", +] + +[tool.uv] +dev-dependencies = [ + "pytest>=7.0", +] diff --git a/src/test/resources/tst_manifests/pip/pip_pyproject_toml_uv_ignore/uv.lock b/src/test/resources/tst_manifests/pip/pip_pyproject_toml_uv_ignore/uv.lock new file mode 100644 index 00000000..b0dd0b31 --- /dev/null +++ b/src/test/resources/tst_manifests/pip/pip_pyproject_toml_uv_ignore/uv.lock @@ -0,0 +1,96 @@ +version = 1 +requires-python = ">=3.9" + +[[package]] +name = "test-project" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "anyio" }, + { name = "flask" }, + { name = "requests" }, +] + +[[package]] +name = "anyio" +version = "3.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "sniffio" }, +] + +[[package]] +name = "flask" +version = "2.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "werkzeug" }, + { name = "jinja2" }, + { name = "itsdangerous" }, + { name = "click" }, +] + +[[package]] +name = "requests" +version = "2.25.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, + { name = "certifi" }, +] + +[[package]] +name = "idna" +version = "3.4" +source = { registry = "https://pypi.org/simple" } + +[[package]] +name = "sniffio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } + +[[package]] +name = "werkzeug" +version = "2.2.3" +source = { registry = "https://pypi.org/simple" } + +[[package]] +name = "jinja2" +version = "3.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] + +[[package]] +name = "itsdangerous" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } + +[[package]] +name = "click" +version = "8.1.3" +source = { registry = "https://pypi.org/simple" } + +[[package]] +name = "charset-normalizer" +version = "3.1.0" +source = { registry = "https://pypi.org/simple" } + +[[package]] +name = "urllib3" +version = "1.26.15" +source = { registry = "https://pypi.org/simple" } + +[[package]] +name = "certifi" +version = "2023.5.7" +source = { registry = "https://pypi.org/simple" } + +[[package]] +name = "markupsafe" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } diff --git a/src/test/resources/tst_manifests/pip/pip_pyproject_toml_uv_ignore/uv_export.txt b/src/test/resources/tst_manifests/pip/pip_pyproject_toml_uv_ignore/uv_export.txt new file mode 100644 index 00000000..30125dd0 --- /dev/null +++ b/src/test/resources/tst_manifests/pip/pip_pyproject_toml_uv_ignore/uv_export.txt @@ -0,0 +1,30 @@ +# This file was autogenerated by uv via the following command: +# uv export --format requirements.txt --frozen --no-hashes --no-dev +anyio==3.6.2 + # via test-project +certifi==2023.5.7 + # via requests +charset-normalizer==3.1.0 + # via requests +click==8.1.3 + # via flask +flask==2.0.3 + # via test-project +idna==3.4 + # via + # anyio + # requests +itsdangerous==2.1.2 + # via flask +jinja2==3.1.2 + # via flask +markupsafe==2.1.2 + # via jinja2 +requests==2.25.1 + # via test-project +sniffio==1.3.0 + # via anyio +urllib3==1.26.15 + # via requests +werkzeug==2.2.3 + # via flask