diff --git a/pom.xml b/pom.xml
index 842e18ef..466326ca 100644
--- a/pom.xml
+++ b/pom.xml
@@ -420,6 +420,7 @@ limitations under the License.]]>
jacoco-maven-plugin
${jacoco-maven-plugin.version}
+ false
io/github/guacsec/trustifyda/api/*
io/github/guacsec/trustifyda/api/serialization/*
diff --git a/src/main/java/io/github/guacsec/trustifyda/providers/CargoProjectLayout.java b/src/main/java/io/github/guacsec/trustifyda/providers/CargoProjectLayout.java
new file mode 100644
index 00000000..8c3ece5c
--- /dev/null
+++ b/src/main/java/io/github/guacsec/trustifyda/providers/CargoProjectLayout.java
@@ -0,0 +1,30 @@
+/*
+ * 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;
+
+public enum CargoProjectLayout {
+ /** Project contains only [package] section - single crate */
+ SINGLE_CRATE,
+
+ /** Project contains only [workspace] section - virtual workspace with multiple members */
+ WORKSPACE_VIRTUAL,
+
+ /**
+ * Project contains both [package] and [workspace] sections - root crate with workspace members
+ */
+ WORKSPACE_WITH_ROOT_CRATE
+}
diff --git a/src/main/java/io/github/guacsec/trustifyda/providers/CargoProvider.java b/src/main/java/io/github/guacsec/trustifyda/providers/CargoProvider.java
new file mode 100644
index 00000000..31abb43c
--- /dev/null
+++ b/src/main/java/io/github/guacsec/trustifyda/providers/CargoProvider.java
@@ -0,0 +1,691 @@
+/*
+ * 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 io.github.guacsec.trustifyda.impl.ExhortApi.debugLoggingIsNeeded;
+
+import com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.github.packageurl.PackageURL;
+import io.github.guacsec.trustifyda.Api;
+import io.github.guacsec.trustifyda.Provider;
+import io.github.guacsec.trustifyda.logging.LoggersFactory;
+import io.github.guacsec.trustifyda.providers.rust.model.CargoDep;
+import io.github.guacsec.trustifyda.providers.rust.model.CargoDepKind;
+import io.github.guacsec.trustifyda.providers.rust.model.CargoMetadata;
+import io.github.guacsec.trustifyda.providers.rust.model.CargoNode;
+import io.github.guacsec.trustifyda.providers.rust.model.CargoPackage;
+import io.github.guacsec.trustifyda.providers.rust.model.DependencyInfo;
+import io.github.guacsec.trustifyda.providers.rust.model.ProjectInfo;
+import io.github.guacsec.trustifyda.sbom.Sbom;
+import io.github.guacsec.trustifyda.sbom.SbomFactory;
+import io.github.guacsec.trustifyda.tools.Ecosystem.Type;
+import io.github.guacsec.trustifyda.tools.Operations;
+import io.github.guacsec.trustifyda.utils.IgnorePatternDetector;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+import java.util.logging.Logger;
+import org.tomlj.Toml;
+import org.tomlj.TomlParseResult;
+
+/**
+ * Concrete implementation of the {@link Provider} used for converting dependency trees for Rust
+ * projects (Cargo.toml) into a SBOM content for Component analysis or Stack analysis.
+ */
+public final class CargoProvider extends Provider {
+
+ private static final ObjectMapper MAPPER =
+ new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
+ private static final Logger log = LoggersFactory.getLogger(CargoProvider.class.getName());
+ private static final String VIRTUAL_VERSION = "1.0.0";
+ private static final String PACKAGE_NAME = "package.name";
+ private static final String PACKAGE_VERSION = "package.version";
+ private static final String PACKAGE_VERSION_WORKSPACE = "package.version.workspace";
+ private static final String WORKSPACE_PACKAGE_VERSION = "workspace.package.version";
+ private static final long TIMEOUT =
+ Long.parseLong(System.getProperty("trustify.cargo.timeout.seconds", "5"));
+ private final String cargoExecutable;
+
+ private CargoProjectLayout getProjectLayout(CargoMetadata metadata) {
+ boolean hasRootCrate = metadata.resolve() != null && metadata.resolve().root() != null;
+ boolean hasWorkspace =
+ metadata.workspaceMembers() != null && !metadata.workspaceMembers().isEmpty();
+
+ if (hasRootCrate && !hasWorkspace) {
+ return CargoProjectLayout.SINGLE_CRATE;
+ }
+ if (hasRootCrate) {
+ return CargoProjectLayout.WORKSPACE_WITH_ROOT_CRATE;
+ }
+ if (hasWorkspace) {
+ return CargoProjectLayout.WORKSPACE_VIRTUAL;
+ }
+ throw new IllegalStateException(
+ "Invalid Cargo project layout: no root crate and no workspace members");
+ }
+
+ private void addDependencies(
+ Sbom sbom,
+ PackageURL root,
+ Set ignoredDeps,
+ TomlParseResult tomlResult,
+ AnalysisType analysisType) {
+ try {
+ CargoMetadata metadata = executeCargoMetadata();
+ if (metadata == null || metadata.resolve() == null || metadata.resolve().nodes() == null) {
+ return;
+ }
+
+ Map packageMap = buildPackageMap(metadata);
+ Map nodeMap = buildNodeMap(metadata);
+
+ CargoProjectLayout layout = getProjectLayout(metadata);
+ if (debugLoggingIsNeeded()) {
+ log.info(
+ "Project layout: "
+ + layout
+ + " (hasRoot: "
+ + (metadata.resolve().root() != null)
+ + ", workspaceMembers: "
+ + (metadata.workspaceMembers() != null ? metadata.workspaceMembers().size() : 0)
+ + ")");
+ }
+
+ switch (layout) {
+ case SINGLE_CRATE ->
+ handleSingleCrate(sbom, root, metadata, nodeMap, packageMap, ignoredDeps, analysisType);
+ case WORKSPACE_VIRTUAL ->
+ handleVirtualWorkspace(
+ sbom, root, metadata, nodeMap, packageMap, ignoredDeps, tomlResult, analysisType);
+ case WORKSPACE_WITH_ROOT_CRATE ->
+ // Process root crate dependencies - this will naturally include any workspace
+ // members that are actual dependencies via the cargo dependency graph.
+ // Note: Workspace members are only included if they appear in the root crate's
+ // dependency graph from cargo metadata. We don't automatically add all members
+ // as dependencies since most workspace members (examples, tools, benchmarks)
+ // depend ON the root crate, not the other way around.
+ handleSingleCrate(sbom, root, metadata, nodeMap, packageMap, ignoredDeps, analysisType);
+ }
+
+ } catch (Exception e) {
+ log.severe("Unexpected error during " + analysisType + " analysis: " + e.getMessage());
+ }
+ }
+
+ private void handleSingleCrate(
+ Sbom sbom,
+ PackageURL root,
+ CargoMetadata metadata,
+ Map nodeMap,
+ Map packageMap,
+ Set ignoredDeps,
+ AnalysisType analysisType) {
+
+ CargoNode rootNode = nodeMap.get(metadata.resolve().root());
+ switch (analysisType) {
+ case STACK -> {
+ Set addedDependencies = new HashSet<>();
+ Set visitedNodes = new HashSet<>();
+ processDependencyNode(
+ rootNode,
+ root,
+ nodeMap,
+ packageMap,
+ ignoredDeps,
+ sbom,
+ addedDependencies,
+ visitedNodes);
+ }
+ case COMPONENT -> processDirectDependencies(rootNode, ignoredDeps, sbom, root, packageMap);
+ }
+ }
+
+ private void handleVirtualWorkspace(
+ Sbom sbom,
+ PackageURL root,
+ CargoMetadata metadata,
+ Map nodeMap,
+ Map packageMap,
+ Set ignoredDeps,
+ TomlParseResult tomlResult,
+ AnalysisType analysisType) {
+
+ switch (analysisType) {
+ // For COMPONENT analysis: only include workspace dependencies from [workspace.dependencies]
+ case COMPONENT ->
+ processWorkspaceDependencies(sbom, root, packageMap, ignoredDeps, tomlResult);
+ case STACK -> {
+ // For STACK analysis: include workspace members and their dependencies
+ if (debugLoggingIsNeeded()) {
+ log.info(
+ "Processing virtual workspace with "
+ + metadata.workspaceMembers().size()
+ + " members: "
+ + metadata.workspaceMembers());
+ }
+ for (String memberId : metadata.workspaceMembers()) {
+ processWorkspaceMember(
+ sbom, root, memberId, nodeMap, packageMap, ignoredDeps, analysisType);
+ }
+ }
+ }
+ }
+
+ private void processWorkspaceDependencies(
+ Sbom sbom,
+ PackageURL root,
+ Map packageMap,
+ Set ignoredDeps,
+ TomlParseResult tomlResult) {
+
+ var workspaceDepsTable = tomlResult.getTable("workspace.dependencies");
+ if (debugLoggingIsNeeded()) {
+ log.info("Processing " + workspaceDepsTable.keySet().size() + " workspace dependencies");
+ }
+ // Note: We only need dependency names from TOML, regardless of format:
+ // - Simple: serde = "1.0"
+ // - Table: serde = { version = "1.0", features = ["derive"] }
+ // - Section: [workspace.dependencies.serde] version = "1.0"
+ // The actual resolved versions come from cargo metadata packageMap, not TOML.
+ for (String depName : workspaceDepsTable.keySet()) {
+ if (ignoredDeps.contains(depName)) {
+ continue;
+ }
+ CargoPackage depPackage = findPackageByName(packageMap, depName);
+ try {
+ PackageURL depUrl =
+ new PackageURL(
+ Type.CARGO.getType(), null, depPackage.name(), depPackage.version(), null, null);
+ sbom.addDependency(root, depUrl, null);
+ } catch (Exception e) {
+ log.warning("Failed to create PackageURL for workspace dependency: " + depName);
+ }
+ }
+ }
+
+ private CargoPackage findPackageByName(Map packageMap, String name) {
+ return packageMap.values().stream()
+ .filter(pkg -> pkg.name().equals(name))
+ .findFirst()
+ .orElse(null);
+ }
+
+ @Override
+ public void validateLockFile(Path lockFileDir) {
+ Path actualLockFileDir = findOutermostCargoTomlDirectory(lockFileDir);
+ if (!Files.isRegularFile(actualLockFileDir.resolve("Cargo.lock"))) {
+ throw new IllegalStateException(
+ "Cargo.lock does not exist or is not supported. Execute 'cargo build' to generate it.");
+ }
+ }
+
+ /**
+ * Processes an individual workspace member as an independent SBOM component. For COMPONENT
+ * analysis: Only adds member as direct dependency of workspace. For STACK analysis: Also adds
+ * member's transitive dependencies.
+ */
+ private void processWorkspaceMember(
+ Sbom sbom,
+ PackageURL workspaceRoot,
+ String memberId,
+ Map nodeMap,
+ Map packageMap,
+ Set ignoredDeps,
+ AnalysisType analysisType) {
+
+ CargoPackage memberPkg = packageMap.get(memberId);
+ if (memberPkg == null) {
+ log.warning("Workspace member package not found: " + memberId);
+ return;
+ }
+
+ try {
+ PackageURL memberUrl =
+ new PackageURL(
+ Type.CARGO.getType(), null, memberPkg.name(), memberPkg.version(), null, null);
+ sbom.addDependency(workspaceRoot, memberUrl, null);
+
+ if (debugLoggingIsNeeded()) {
+ log.fine(
+ "Processing member: "
+ + memberPkg.name()
+ + "@"
+ + memberPkg.version()
+ + " (id: "
+ + memberId
+ + ") for "
+ + analysisType
+ + " analysis");
+ }
+
+ // Only process member's dependencies for STACK analysis (transitive)
+ // For COMPONENT analysis: stop here - don't process member dependencies
+ if (analysisType == AnalysisType.STACK) {
+ CargoNode memberNode = nodeMap.get(memberId);
+ if (memberNode != null) {
+ Set addedDependencies = new HashSet<>();
+ Set visitedNodes = new HashSet<>();
+ processDependencyNode(
+ memberNode,
+ memberUrl,
+ nodeMap,
+ packageMap,
+ ignoredDeps,
+ sbom,
+ addedDependencies,
+ visitedNodes);
+ }
+ }
+ } catch (Exception e) {
+ log.warning(
+ "Failed to create PackageURL for member " + memberPkg.name() + ": " + e.getMessage());
+ }
+ }
+
+ private Path findOutermostCargoTomlDirectory(Path startDir) {
+ Path current = startDir.getParent();
+ Path outermost = startDir;
+ while (current != null) {
+ if (Files.exists(current.resolve("Cargo.toml"))) {
+ outermost = current;
+ }
+ current = current.getParent();
+ }
+ return outermost;
+ }
+
+ private CargoMetadata executeCargoMetadata() throws IOException, InterruptedException {
+ Path workingDir = manifest.getParent();
+
+ if (debugLoggingIsNeeded()) {
+ log.info("Executing cargo metadata for full dependency resolution with resolved versions");
+ log.info("Cargo executable: " + cargoExecutable);
+ log.info("Working directory: " + workingDir);
+ log.info("Timeout: " + TIMEOUT + " seconds");
+ }
+
+ Process process =
+ new ProcessBuilder(cargoExecutable, "metadata", "--format-version", "1")
+ .directory(workingDir.toFile())
+ .start();
+
+ String output;
+ try (InputStream is = process.getInputStream()) {
+ output = new String(is.readAllBytes(), StandardCharsets.UTF_8);
+ }
+
+ boolean finished = process.waitFor(TIMEOUT, TimeUnit.SECONDS);
+
+ int exitCode = process.exitValue();
+
+ if (exitCode != 0) {
+ String errorOutput = "";
+ try (InputStream errorStream = process.getErrorStream()) {
+ errorOutput = new String(errorStream.readAllBytes(), StandardCharsets.UTF_8);
+ } catch (IOException e) {
+ log.warning("Failed to read error stream: " + e.getMessage());
+ }
+
+ String errorMessage = "cargo metadata failed with exit code: " + exitCode;
+ if (!errorOutput.isEmpty()) {
+ errorMessage += ". Error: " + errorOutput.trim();
+ }
+ log.warning(errorMessage);
+ return null;
+ }
+
+ if (output.isBlank()) {
+ if (debugLoggingIsNeeded()) {
+ log.warning("cargo metadata returned empty output");
+ }
+ return null;
+ }
+
+ if (!finished) {
+ process.destroyForcibly();
+ throw new InterruptedException("cargo metadata timed out after " + TIMEOUT + " seconds");
+ }
+
+ try {
+ CargoMetadata metadata = MAPPER.readValue(output, CargoMetadata.class);
+ if (debugLoggingIsNeeded()) {
+ log.info("Successfully parsed cargo metadata JSON");
+ log.info(
+ "Packages found: " + (metadata.packages() != null ? metadata.packages().size() : 0));
+ log.info(
+ "Resolve graph nodes: "
+ + (metadata.resolve() != null && metadata.resolve().nodes() != null
+ ? metadata.resolve().nodes().size()
+ : 0));
+ log.info(
+ "Workspace members: "
+ + (metadata.workspaceMembers() != null ? metadata.workspaceMembers().size() : 0));
+ if (metadata.resolve() != null) {
+ log.info("Resolve root: " + metadata.resolve().root());
+ }
+ }
+ return metadata;
+ } catch (Exception e) {
+ log.severe("Failed to parse cargo metadata JSON: " + e.getMessage());
+ return null;
+ }
+ }
+
+ private Map buildNodeMap(CargoMetadata metadata) {
+ Map nodeMap = new HashMap<>();
+ for (CargoNode node : metadata.resolve().nodes()) {
+ nodeMap.put(node.id(), node);
+ }
+ return nodeMap;
+ }
+
+ private void processDirectDependencies(
+ CargoNode sourceNode,
+ Set ignoredDeps,
+ Sbom sbom,
+ PackageURL sourceUrl,
+ Map packageMap) {
+
+ if (debugLoggingIsNeeded()) {
+ log.info(
+ "Processing "
+ + sourceNode.deps().size()
+ + " direct dependencies for component analysis (using resolved dep_kinds)");
+ }
+
+ for (CargoDep dep : sourceNode.deps()) {
+ DependencyInfo childInfo = getPackageInfo(dep.pkg(), packageMap);
+ if (shouldSkipDependency(dep, ignoredDeps)) {
+ continue;
+ }
+
+ try {
+ PackageURL packageUrl =
+ new PackageURL(
+ Type.CARGO.getType(), null, childInfo.name(), childInfo.version(), null, null);
+ sbom.addDependency(sourceUrl, packageUrl, null);
+ if (debugLoggingIsNeeded()) {
+ log.info(
+ "✅ Added direct dependency: "
+ + childInfo.name()
+ + " v"
+ + childInfo.version()
+ + " (exact resolved version)");
+ }
+ } catch (Exception e) {
+ log.warning("Failed to add direct dependency " + childInfo.name() + ": " + e.getMessage());
+ }
+ }
+ }
+
+ private boolean shouldSkipDependency(CargoDep dep, Set ignoredDeps) {
+ if (ignoredDeps.contains(dep.name())) {
+ return true;
+ }
+
+ if (dep.depKinds() == null || dep.depKinds().isEmpty()) {
+ return false;
+ }
+
+ boolean hasNormal = false;
+
+ for (CargoDepKind depKind : dep.depKinds()) {
+ if (depKind.kind() == null) {
+ hasNormal = true;
+ break;
+ }
+ }
+
+ return !hasNormal;
+ }
+
+ private void processDependencyNode(
+ CargoNode node,
+ PackageURL parent,
+ Map nodeMap,
+ Map packageMap,
+ Set ignoredDeps,
+ Sbom sbom,
+ Set addedDependencies,
+ Set visitedNodes) {
+
+ if (!visitedNodes.add(node.id()) || node.deps() == null) {
+ return;
+ }
+
+ for (CargoDep dep : node.deps()) {
+ DependencyInfo childInfo = getPackageInfo(dep.pkg(), packageMap);
+ if (shouldSkipDependency(dep, ignoredDeps)) {
+ continue;
+ }
+
+ try {
+ PackageURL childUrl =
+ new PackageURL(
+ Type.CARGO.getType(), null, childInfo.name(), childInfo.version(), null, null);
+
+ String relationshipKey = parent.getCoordinates() + "->" + childUrl.getCoordinates();
+
+ if (!addedDependencies.contains(relationshipKey)) {
+ sbom.addDependency(parent, childUrl, null);
+ addedDependencies.add(relationshipKey);
+
+ if (debugLoggingIsNeeded()) {
+ log.info("Added dependency: " + childInfo.name() + " v" + childInfo.version());
+ }
+
+ CargoNode childNode = nodeMap.get(dep.pkg());
+ if (childNode != null) {
+ processDependencyNode(
+ childNode,
+ childUrl,
+ nodeMap,
+ packageMap,
+ ignoredDeps,
+ sbom,
+ addedDependencies,
+ visitedNodes);
+ }
+ }
+ } catch (Exception e) {
+ log.warning("Failed to add dependency " + childInfo.name() + ": " + e.getMessage());
+ }
+ }
+ }
+
+ private Map buildPackageMap(CargoMetadata metadata) {
+ Map packageMap = new HashMap<>();
+ if (metadata.packages() != null) {
+ for (CargoPackage pkg : metadata.packages()) {
+ packageMap.put(pkg.id(), pkg);
+ }
+ }
+ if (debugLoggingIsNeeded()) {
+ log.info("Built package map with " + packageMap.size() + " packages");
+ }
+ return packageMap;
+ }
+
+ private DependencyInfo getPackageInfo(String packageId, Map packageMap) {
+ CargoPackage pkg = packageMap.get(packageId);
+ return new DependencyInfo(pkg.name(), pkg.version());
+ }
+
+ public CargoProvider(Path manifest) {
+ super(Type.CARGO, manifest);
+ this.cargoExecutable = Operations.getExecutable("cargo", "--version");
+ if (cargoExecutable != null) {
+ log.info("Found cargo executable: " + cargoExecutable);
+ } else {
+ log.warning("Cargo executable not found - dependency analysis will not work");
+ }
+ log.info("Initialized RustProvider for manifest: " + manifest);
+ }
+
+ @Override
+ public Content provideComponent() throws IOException {
+ Sbom sbom = createSbom(AnalysisType.COMPONENT);
+ return new Content(sbom.getAsJsonString().getBytes(), Api.CYCLONEDX_MEDIA_TYPE);
+ }
+
+ @Override
+ public Content provideStack() throws IOException {
+ Sbom sbom = createSbom(AnalysisType.STACK);
+ return new Content(sbom.getAsJsonString().getBytes(), Api.CYCLONEDX_MEDIA_TYPE);
+ }
+
+ private Sbom createSbom(AnalysisType analysisType) throws IOException {
+ if (!Files.exists(manifest) || !Files.isRegularFile(manifest)) {
+ throw new IOException("Cargo.toml not found: " + manifest);
+ }
+
+ TomlParseResult tomlResult = Toml.parse(manifest);
+ if (tomlResult.hasErrors()) {
+ throw new IOException(
+ "Invalid Cargo.toml format: " + tomlResult.errors().get(0).getMessage());
+ }
+
+ Sbom sbom = SbomFactory.newInstance();
+ ProjectInfo projectInfo = parseCargoToml(tomlResult);
+
+ try {
+ var root =
+ new PackageURL(
+ Type.CARGO.getType(), null, projectInfo.name(), projectInfo.version(), null, null);
+ sbom.addRoot(root);
+
+ String cargoContent = Files.readString(manifest, StandardCharsets.UTF_8);
+ Set ignoredDeps = getIgnoredDependencies(tomlResult, cargoContent);
+ addDependencies(sbom, root, ignoredDeps, tomlResult, analysisType);
+ return sbom;
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to create Rust SBOM", e);
+ }
+ }
+
+ private ProjectInfo parseCargoToml(TomlParseResult result) throws IOException {
+ String packageName = result.getString(PACKAGE_NAME);
+ String packageVersion = null;
+ if (packageName != null) {
+ Object versionValue = result.get(PACKAGE_VERSION);
+ if (versionValue instanceof String) {
+ packageVersion = (String) versionValue;
+ } else if (versionValue != null) {
+ // Could be a table like { workspace = true }
+ Boolean isWorkspaceVersion = result.getBoolean(PACKAGE_VERSION_WORKSPACE);
+ if (Boolean.TRUE.equals(isWorkspaceVersion)) {
+ // Inherit version from workspace
+ packageVersion = result.getString(WORKSPACE_PACKAGE_VERSION);
+ }
+ }
+ if (debugLoggingIsNeeded()) {
+ log.info(
+ "Parsed project info: name="
+ + packageName
+ + ", version="
+ + (packageVersion != null ? packageVersion : VIRTUAL_VERSION));
+ }
+ return new ProjectInfo(
+ packageName, packageVersion != null ? packageVersion : VIRTUAL_VERSION);
+ }
+ // Check for workspace section as fallback (when there's no [package] section)
+ boolean hasWorkspace = result.contains("workspace");
+ if (hasWorkspace) {
+ String workspaceVersion = result.getString(WORKSPACE_PACKAGE_VERSION);
+ String dirName = manifest.toAbsolutePath().getParent().getFileName().toString();
+ if (debugLoggingIsNeeded()) {
+ log.info(
+ "Using workspace fallback: name="
+ + dirName
+ + ", version="
+ + (workspaceVersion != null ? workspaceVersion : VIRTUAL_VERSION));
+ }
+ return new ProjectInfo(
+ dirName, workspaceVersion != null ? workspaceVersion : VIRTUAL_VERSION);
+ }
+ throw new IOException("Invalid Cargo.toml: no [package] or [workspace] section found");
+ }
+
+ private Set getIgnoredDependencies(TomlParseResult result, String content) {
+ Set normalDependencies = collectNormalDependencies(result);
+ if (debugLoggingIsNeeded()) {
+ log.info("Found " + normalDependencies.size() + " normal dependencies in Cargo.toml");
+ }
+ // TomlParseResult doesn't retain comment, need to check ignore keyword from Cargo.toml content.
+ Set ignoredDeps = findIgnoredDependencies(content, normalDependencies);
+ if (debugLoggingIsNeeded()) {
+ log.info("Found " + ignoredDeps.size() + " ignored dependencies: " + ignoredDeps);
+ }
+ return ignoredDeps;
+ }
+
+ private Set collectNormalDependencies(TomlParseResult result) {
+ Set allDeps = new HashSet<>();
+ addDependenciesFromSection(result, "dependencies", allDeps);
+ addDependenciesFromSection(result, "workspace.dependencies", allDeps);
+ return allDeps;
+ }
+
+ private void addDependenciesFromSection(
+ TomlParseResult result, String sectionPath, Set allDeps) {
+ if (result.contains(sectionPath)) {
+ var sectionTable = result.getTable(sectionPath);
+ if (sectionTable != null) {
+ allDeps.addAll(sectionTable.keySet());
+ }
+ }
+ }
+
+ private Set findIgnoredDependencies(String content, Set normalDependencies) {
+ Set ignoredDeps = new HashSet<>();
+ String[] lines = content.split("\\r?\\n");
+ for (String line : lines) {
+ String trimmed = line.trim();
+ if (trimmed.isEmpty() || !IgnorePatternDetector.containsIgnorePattern(line)) {
+ continue;
+ }
+ for (String depName : normalDependencies) {
+ if (lineContainsDependency(trimmed, depName)) {
+ ignoredDeps.add(depName);
+ }
+ }
+ }
+ return ignoredDeps;
+ }
+
+ private boolean lineContainsDependency(String trimmed, String depName) {
+ // Table format: [*.dependencies.depname] # trustify-da-ignore
+ if (trimmed.startsWith("[") && trimmed.contains("." + depName + "]")) {
+ return true;
+ }
+ // Inline format: depname = "version" # trustify-da-ignore
+ if (trimmed.startsWith(depName + " ")
+ || trimmed.startsWith(depName + "=")
+ || trimmed.startsWith("\"" + depName + "\"")) {
+ return true;
+ }
+ return false;
+ }
+}
diff --git a/src/main/java/io/github/guacsec/trustifyda/providers/rust/model/CargoDep.java b/src/main/java/io/github/guacsec/trustifyda/providers/rust/model/CargoDep.java
new file mode 100644
index 00000000..5dafee04
--- /dev/null
+++ b/src/main/java/io/github/guacsec/trustifyda/providers/rust/model/CargoDep.java
@@ -0,0 +1,26 @@
+/*
+ * 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.rust.model;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import java.util.List;
+
+/** Detailed dependency information with resolved package reference */
+public record CargoDep(
+ @JsonProperty("name") String name,
+ @JsonProperty("pkg") String pkg,
+ @JsonProperty("dep_kinds") List depKinds) {}
diff --git a/src/main/java/io/github/guacsec/trustifyda/providers/rust/model/CargoDepKind.java b/src/main/java/io/github/guacsec/trustifyda/providers/rust/model/CargoDepKind.java
new file mode 100644
index 00000000..3450db95
--- /dev/null
+++ b/src/main/java/io/github/guacsec/trustifyda/providers/rust/model/CargoDepKind.java
@@ -0,0 +1,23 @@
+/*
+ * 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.rust.model;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+/** Dependency kind information (normal, dev, build) */
+public record CargoDepKind(
+ @JsonProperty("kind") String kind, @JsonProperty("target") String target) {}
diff --git a/src/main/java/io/github/guacsec/trustifyda/providers/rust/model/CargoDependency.java b/src/main/java/io/github/guacsec/trustifyda/providers/rust/model/CargoDependency.java
new file mode 100644
index 00000000..59dbadb6
--- /dev/null
+++ b/src/main/java/io/github/guacsec/trustifyda/providers/rust/model/CargoDependency.java
@@ -0,0 +1,26 @@
+/*
+ * 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.rust.model;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+/** Dependency declaration - core fields for dependency analysis */
+public record CargoDependency(
+ @JsonProperty("name") String name,
+ @JsonProperty("req") String req,
+ @JsonProperty("kind") String kind,
+ @JsonProperty("optional") Boolean optional) {}
diff --git a/src/main/java/io/github/guacsec/trustifyda/providers/rust/model/CargoMetadata.java b/src/main/java/io/github/guacsec/trustifyda/providers/rust/model/CargoMetadata.java
new file mode 100644
index 00000000..5d6ab574
--- /dev/null
+++ b/src/main/java/io/github/guacsec/trustifyda/providers/rust/model/CargoMetadata.java
@@ -0,0 +1,27 @@
+/*
+ * 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.rust.model;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import java.util.List;
+
+/** Root cargo metadata structure - minimal for dependency analysis */
+public record CargoMetadata(
+ @JsonProperty("packages") List packages,
+ @JsonProperty("resolve") CargoResolve resolve,
+ @JsonProperty("workspace_members") List workspaceMembers,
+ @JsonProperty("workspace_root") String workspaceRoot) {}
diff --git a/src/main/java/io/github/guacsec/trustifyda/providers/rust/model/CargoNode.java b/src/main/java/io/github/guacsec/trustifyda/providers/rust/model/CargoNode.java
new file mode 100644
index 00000000..744b708b
--- /dev/null
+++ b/src/main/java/io/github/guacsec/trustifyda/providers/rust/model/CargoNode.java
@@ -0,0 +1,26 @@
+/*
+ * 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.rust.model;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import java.util.List;
+
+/** Resolved dependency node - essential fields for dependency resolution */
+public record CargoNode(
+ @JsonProperty("id") String id,
+ @JsonProperty("dependencies") List dependencies,
+ @JsonProperty("deps") List deps) {}
diff --git a/src/main/java/io/github/guacsec/trustifyda/providers/rust/model/CargoPackage.java b/src/main/java/io/github/guacsec/trustifyda/providers/rust/model/CargoPackage.java
new file mode 100644
index 00000000..0a734b2f
--- /dev/null
+++ b/src/main/java/io/github/guacsec/trustifyda/providers/rust/model/CargoPackage.java
@@ -0,0 +1,27 @@
+/*
+ * 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.rust.model;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import java.util.List;
+
+/** Package information - only dependency analysis fields */
+public record CargoPackage(
+ @JsonProperty("name") String name,
+ @JsonProperty("version") String version,
+ @JsonProperty("id") String id,
+ @JsonProperty("dependencies") List dependencies) {}
diff --git a/src/main/java/io/github/guacsec/trustifyda/providers/rust/model/CargoResolve.java b/src/main/java/io/github/guacsec/trustifyda/providers/rust/model/CargoResolve.java
new file mode 100644
index 00000000..68fb47a7
--- /dev/null
+++ b/src/main/java/io/github/guacsec/trustifyda/providers/rust/model/CargoResolve.java
@@ -0,0 +1,24 @@
+/*
+ * 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.rust.model;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import java.util.List;
+
+/** Dependency resolution graph (contains actual resolved versions) */
+public record CargoResolve(
+ @JsonProperty("nodes") List nodes, @JsonProperty("root") String root) {}
diff --git a/src/main/java/io/github/guacsec/trustifyda/providers/rust/model/DependencyInfo.java b/src/main/java/io/github/guacsec/trustifyda/providers/rust/model/DependencyInfo.java
new file mode 100644
index 00000000..3111f2e2
--- /dev/null
+++ b/src/main/java/io/github/guacsec/trustifyda/providers/rust/model/DependencyInfo.java
@@ -0,0 +1,19 @@
+/*
+ * 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.rust.model;
+
+public record DependencyInfo(String name, String version) {}
diff --git a/src/main/java/io/github/guacsec/trustifyda/providers/rust/model/ProjectInfo.java b/src/main/java/io/github/guacsec/trustifyda/providers/rust/model/ProjectInfo.java
new file mode 100644
index 00000000..02b82321
--- /dev/null
+++ b/src/main/java/io/github/guacsec/trustifyda/providers/rust/model/ProjectInfo.java
@@ -0,0 +1,19 @@
+/*
+ * 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.rust.model;
+
+public record ProjectInfo(String name, String version) {}
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 04fe0447..698cd78f 100644
--- a/src/main/java/io/github/guacsec/trustifyda/tools/Ecosystem.java
+++ b/src/main/java/io/github/guacsec/trustifyda/tools/Ecosystem.java
@@ -17,6 +17,7 @@
package io.github.guacsec.trustifyda.tools;
import io.github.guacsec.trustifyda.Provider;
+import io.github.guacsec.trustifyda.providers.CargoProvider;
import io.github.guacsec.trustifyda.providers.GoModulesProvider;
import io.github.guacsec.trustifyda.providers.GradleProvider;
import io.github.guacsec.trustifyda.providers.JavaMavenProvider;
@@ -34,7 +35,8 @@ public enum Type {
YARN("yarn"),
GOLANG("golang"),
PYTHON("pypi"),
- GRADLE("gradle");
+ GRADLE("gradle"),
+ CARGO("cargo");
final String type;
@@ -43,24 +45,16 @@ public String getType() {
}
public String getExecutableShortName() {
- switch (this) {
- case MAVEN:
- return "mvn";
- case NPM:
- return "npm";
- case PNPM:
- return "pnpm";
- case YARN:
- return "yarn";
- case GOLANG:
- return "go";
- case PYTHON:
- return "python";
- case GRADLE:
- return "gradle";
- default:
- throw new IllegalStateException("Unexpected value: " + this);
- }
+ return switch (this) {
+ case MAVEN -> "mvn";
+ case NPM -> "npm";
+ case PNPM -> "pnpm";
+ case YARN -> "yarn";
+ case GOLANG -> "go";
+ case PYTHON -> "python";
+ case GRADLE -> "gradle";
+ case CARGO -> "cargo";
+ };
}
Type(String type) {
@@ -86,20 +80,15 @@ public static Provider getProvider(final Path manifestPath) {
private static Provider resolveProvider(final Path manifestPath) {
var manifestFile = manifestPath.getFileName().toString();
- switch (manifestFile) {
- case "pom.xml":
- return new JavaMavenProvider(manifestPath);
- case "package.json":
- return JavaScriptProviderFactory.create(manifestPath);
- case "go.mod":
- return new GoModulesProvider(manifestPath);
- case "requirements.txt":
- return new PythonPipProvider(manifestPath);
- case "build.gradle":
- case "build.gradle.kts":
- return new GradleProvider(manifestPath);
- default:
- throw new IllegalStateException(String.format("Unknown manifest file %s", manifestFile));
- }
+ return switch (manifestFile) {
+ case "pom.xml" -> new JavaMavenProvider(manifestPath);
+ case "package.json" -> JavaScriptProviderFactory.create(manifestPath);
+ case "go.mod" -> new GoModulesProvider(manifestPath);
+ case "requirements.txt" -> new PythonPipProvider(manifestPath);
+ case "build.gradle", "build.gradle.kts" -> new GradleProvider(manifestPath);
+ case "Cargo.toml" -> new CargoProvider(manifestPath);
+ default ->
+ throw new IllegalStateException(String.format("Unknown manifest file %s", manifestFile));
+ };
}
}
diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java
index a9d27524..8fc73303 100644
--- a/src/main/java/module-info.java
+++ b/src/main/java/module-info.java
@@ -15,6 +15,8 @@
opens io.github.guacsec.trustifyda.providers to
com.fasterxml.jackson.databind;
+ opens io.github.guacsec.trustifyda.providers.rust.model to
+ com.fasterxml.jackson.databind;
exports io.github.guacsec.trustifyda;
exports io.github.guacsec.trustifyda.impl;
@@ -30,6 +32,7 @@
exports io.github.guacsec.trustifyda.providers;
exports io.github.guacsec.trustifyda.providers.javascript.model;
+ exports io.github.guacsec.trustifyda.providers.rust.model;
exports io.github.guacsec.trustifyda.logging;
exports io.github.guacsec.trustifyda.image;
diff --git a/src/test/java/io/github/guacsec/trustifyda/providers/CargoProviderCargoParsingTest.java b/src/test/java/io/github/guacsec/trustifyda/providers/CargoProviderCargoParsingTest.java
new file mode 100644
index 00000000..b9ea7057
--- /dev/null
+++ b/src/test/java/io/github/guacsec/trustifyda/providers/CargoProviderCargoParsingTest.java
@@ -0,0 +1,723 @@
+/*
+ * 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.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Set;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+public class CargoProviderCargoParsingTest {
+
+ @Test
+ public void testPackageCargoTomlParsing(@TempDir Path tempDir) throws IOException {
+ // Create a test package Cargo.toml file
+ Path cargoToml = tempDir.resolve("Cargo.toml");
+ String content =
+ """
+ [package]
+ name = "test-rust-project"
+ version = "1.2.3"
+ edition = "2021"
+ authors = ["test@example.com"]
+
+ [dependencies]
+ serde = "1.0"
+ tokio = { version = "1.0", features = ["full"] }
+ """;
+
+ Files.writeString(cargoToml, content);
+
+ // Create RustProvider and test basic functionality
+ CargoProvider provider = new CargoProvider(cargoToml);
+
+ // Test stack analysis - should not throw exception
+ var stackContent = provider.provideStack();
+ assertNotNull(stackContent);
+ assertNotNull(stackContent.buffer);
+ assertTrue(stackContent.buffer.length > 0);
+
+ // Test component analysis - should not throw exception
+ var componentContent = provider.provideComponent();
+ assertNotNull(componentContent);
+ assertNotNull(componentContent.buffer);
+ assertTrue(componentContent.buffer.length > 0);
+
+ // Verify SBOM contains project information
+ String stackSbom = new String(stackContent.buffer);
+ assertTrue(stackSbom.contains("test-rust-project"));
+ assertTrue(stackSbom.contains("1.2.3"));
+ }
+
+ @Test
+ public void testWorkspaceCargoTomlParsing(@TempDir Path tempDir) throws IOException {
+ // Create a workspace Cargo.toml file
+ Path cargoToml = tempDir.resolve("Cargo.toml");
+ String content =
+ """
+ [workspace]
+ members = ["crate1", "crate2"]
+
+ [workspace.package]
+ version = "2.0.0-beta.1"
+ edition = "2021"
+ license = "MIT"
+ authors = ["workspace@example.com"]
+
+ [workspace.dependencies]
+ serde = "1.0"
+ """;
+
+ Files.writeString(cargoToml, content);
+
+ // Create RustProvider and test workspace functionality
+ CargoProvider provider = new CargoProvider(cargoToml);
+
+ // Test stack analysis
+ var stackContent = provider.provideStack();
+ assertNotNull(stackContent);
+ assertNotNull(stackContent.buffer);
+ assertTrue(stackContent.buffer.length > 0);
+
+ // Verify SBOM contains workspace information
+ String stackSbom = new String(stackContent.buffer);
+ // Workspace should use directory name as project name
+ assertTrue(stackSbom.contains(tempDir.getFileName().toString()));
+ assertTrue(stackSbom.contains("2.0.0-beta.1"));
+ }
+
+ @Test
+ public void testWorkspaceCargoTomlInheritance(@TempDir Path tempDir) throws IOException {
+ // Create a workspace Cargo.toml to test that it uses directory name
+ // (since workspace.package cannot define a name)
+ Path cargoToml = tempDir.resolve("Cargo.toml");
+ String content =
+ """
+ [workspace]
+ members = ["api", "core", "cli"]
+
+ [workspace.package]
+ version = "1.5.0"
+ edition = "2021"
+ license = "MIT"
+ authors = ["workspace@example.com"]
+ """;
+
+ Files.writeString(cargoToml, content);
+
+ CargoProvider provider = new CargoProvider(cargoToml);
+
+ var stackContent = provider.provideStack();
+ String stackSbom = new String(stackContent.buffer);
+
+ // For workspace, should use directory name (no name can be defined in workspace.package)
+ assertTrue(stackSbom.contains(tempDir.getFileName().toString()));
+ assertTrue(stackSbom.contains("1.5.0"));
+ }
+
+ @Test
+ public void testPackageCargoTomlWithMissingVersion(@TempDir Path tempDir) throws IOException {
+ // Create a package Cargo.toml without version
+ Path cargoToml = tempDir.resolve("Cargo.toml");
+ String content =
+ """
+ [package]
+ name = "no-version-project"
+ edition = "2021"
+
+ [dependencies]
+ serde = "1.0"
+ """;
+
+ Files.writeString(cargoToml, content);
+
+ // Create RustProvider and test default version handling
+ CargoProvider provider = new CargoProvider(cargoToml);
+
+ var stackContent = provider.provideStack();
+ String stackSbom = new String(stackContent.buffer);
+
+ // Should use default version "1.0.0"
+ assertTrue(stackSbom.contains("no-version-project"));
+ assertTrue(stackSbom.contains("1.0.0"));
+ }
+
+ @Test
+ public void testWorkspaceCargoTomlWithoutVersion(@TempDir Path tempDir) throws IOException {
+ // Create a workspace Cargo.toml without version
+ Path cargoToml = tempDir.resolve("Cargo.toml");
+ String content =
+ """
+ [workspace]
+ members = ["crate1", "crate2"]
+
+ [workspace.package]
+ edition = "2021"
+ license = "Apache-2.0"
+ """;
+
+ Files.writeString(cargoToml, content);
+
+ CargoProvider provider = new CargoProvider(cargoToml);
+
+ var stackContent = provider.provideStack();
+ String stackSbom = new String(stackContent.buffer);
+
+ // Should use directory name and default version
+ assertTrue(stackSbom.contains(tempDir.getFileName().toString()));
+ assertTrue(stackSbom.contains("1.0.0"));
+ }
+
+ @Test
+ public void testComplexPackageCargoToml(@TempDir Path tempDir) throws IOException {
+ // Create a more complex package Cargo.toml with various sections
+ Path cargoToml = tempDir.resolve("Cargo.toml");
+ String content =
+ """
+ [package]
+ name = "complex-rust-app"
+ version = "3.1.4-alpha.2"
+ edition = "2021"
+ authors = ["author1@example.com", "author2@example.com"]
+ description = "A complex Rust application"
+ license = "MIT OR Apache-2.0"
+ repository = "https://github.com/example/complex-rust-app"
+
+ [lib]
+ name = "complex_rust_app"
+
+ [dependencies]
+ serde = { version = "1.0", features = ["derive"] }
+ tokio = { version = "1.0", features = ["full"] }
+ reqwest = { version = "0.11", features = ["json"] }
+
+ [dev-dependencies]
+ tokio-test = "0.4"
+
+ [build-dependencies]
+ cc = "1.0"
+ """;
+
+ Files.writeString(cargoToml, content);
+
+ CargoProvider provider = new CargoProvider(cargoToml);
+
+ var stackContent = provider.provideStack();
+ String stackSbom = new String(stackContent.buffer);
+
+ // Should parse name and version correctly despite complex structure
+ assertTrue(stackSbom.contains("complex-rust-app"));
+ assertTrue(stackSbom.contains("3.1.4-alpha.2"));
+ }
+
+ @Test
+ public void testInvalidCargoTomlMissingName(@TempDir Path tempDir) throws IOException {
+ // Create a package Cargo.toml without required name field
+ Path cargoToml = tempDir.resolve("Cargo.toml");
+ String content =
+ """
+ [package]
+ version = "1.0.0"
+ edition = "2021"
+
+ [dependencies]
+ serde = "1.0"
+ """;
+
+ Files.writeString(cargoToml, content);
+
+ CargoProvider provider = new CargoProvider(cargoToml);
+
+ // Should throw IOException for missing required name field
+ assertThrows(IOException.class, provider::provideStack);
+ }
+
+ @Test
+ public void testInvalidCargoTomlNoSections(@TempDir Path tempDir) throws IOException {
+ // Create an invalid Cargo.toml with no package or workspace sections
+ Path cargoToml = tempDir.resolve("Cargo.toml");
+ String content =
+ """
+ # This is an invalid Cargo.toml
+ some-field = "value"
+
+ [dependencies]
+ serde = "1.0"
+ """;
+
+ Files.writeString(cargoToml, content);
+
+ CargoProvider provider = new CargoProvider(cargoToml);
+
+ // Should throw IOException for missing package/workspace sections
+ assertThrows(IOException.class, provider::provideStack);
+ }
+
+ @Test
+ public void testMissingCargoTomlFile(@TempDir Path tempDir) {
+ // Try to create provider with non-existent Cargo.toml
+ Path nonExistentCargoToml = tempDir.resolve("nonexistent-Cargo.toml");
+
+ CargoProvider provider = new CargoProvider(nonExistentCargoToml);
+
+ // Should throw IOException for missing file
+ assertThrows(IOException.class, provider::provideStack);
+ }
+
+ @Test
+ public void testEmptyCargoTomlFile(@TempDir Path tempDir) throws IOException {
+ // Create empty Cargo.toml
+ Path cargoToml = tempDir.resolve("Cargo.toml");
+ Files.writeString(cargoToml, "");
+
+ CargoProvider provider = new CargoProvider(cargoToml);
+
+ // Should throw IOException for empty file
+ assertThrows(IOException.class, provider::provideStack);
+ }
+
+ @Test
+ public void testPackageWithWorkspaceCargoToml(@TempDir Path tempDir) throws IOException {
+ // Create a Cargo.toml with both [package] and [workspace] sections (like regex project)
+ // The [package] section should take priority
+ Path cargoToml = tempDir.resolve("Cargo.toml");
+ String content =
+ """
+ [package]
+ name = "regex"
+ version = "1.12.2"
+ edition = "2021"
+ authors = ["The Rust Project Developers"]
+
+ [workspace]
+ members = [
+ "regex-automata",
+ "regex-capi",
+ "regex-cli",
+ "regex-lite",
+ "regex-syntax",
+ "regex-test"
+ ]
+
+ [dependencies]
+ regex-syntax = { path = "regex-syntax" }
+ """;
+ Files.writeString(cargoToml, content);
+
+ CargoProvider provider = new CargoProvider(cargoToml);
+
+ // Test both analysis types
+ var stackResult = provider.provideStack();
+ var componentResult = provider.provideComponent();
+
+ // Verify results
+ assertNotNull(stackResult);
+ assertNotNull(componentResult);
+
+ // Check SBOM content prioritizes package info over workspace
+ String stackContent = new String(stackResult.buffer);
+ String componentContent = new String(componentResult.buffer);
+
+ // Should contain package name and version (NOT workspace fallback)
+ assertTrue(stackContent.contains("regex"), "Stack SBOM should contain package name");
+ assertTrue(stackContent.contains("1.12.2"), "Stack SBOM should contain package version");
+
+ assertTrue(componentContent.contains("regex"), "Component SBOM should contain package name");
+ assertTrue(
+ componentContent.contains("1.12.2"), "Component SBOM should contain package version");
+
+ // Should NOT contain default version (which would indicate workspace parsing)
+ assertFalse(
+ componentContent.contains("1.0.0"),
+ "Should not contain default version from workspace parsing");
+ }
+
+ @Test
+ public void testComplexDependencySyntaxWithIgnorePatterns(@TempDir Path tempDir)
+ throws Exception {
+ // Create a Cargo.toml with complex dependency syntax and ignore patterns
+ Path cargoToml = tempDir.resolve("Cargo.toml");
+ String content =
+ """
+ [package]
+ name = "complex-deps-test"
+ version = "0.1.0"
+ edition = "2021"
+
+ [dependencies]
+ # Inline format dependencies
+ serde = "1.0" # trustify-da-ignore
+ tokio = { workspace = true, features = ["full"] }
+ regex = "1.0"
+
+ # Table format dependency with ignore
+ [dependencies.aho-corasick] # trustify-da-ignore
+ version = "1.0.0"
+ optional = true
+
+ [dependencies.memchr]
+ version = "2.0"
+ default-features = false
+
+ [build-dependencies]
+ # Build dependencies should be included (no-dev flag)
+ cc = "1.0"
+
+ [workspace.dependencies]
+ anyhow = "1.0.72" # trustify-da-ignore
+ log = "0.4"
+
+ [workspace.dependencies.thiserror] # trustify-da-ignore
+ version = "1.0"
+ """;
+ Files.writeString(cargoToml, content);
+
+ // Create RustProvider and test ignore detection
+ CargoProvider provider = new CargoProvider(cargoToml);
+
+ // Read the file content for the updated method signature
+ String cargoContent = Files.readString(cargoToml, StandardCharsets.UTF_8);
+
+ // Parse TOML using TOMLJ (matching the optimized implementation)
+ org.tomlj.TomlParseResult tomlResult = org.tomlj.Toml.parse(cargoToml);
+
+ // Use reflection to test the private getIgnoredDependencies method with new signature
+ java.lang.reflect.Method method =
+ CargoProvider.class.getDeclaredMethod(
+ "getIgnoredDependencies", org.tomlj.TomlParseResult.class, String.class);
+ method.setAccessible(true);
+
+ @SuppressWarnings("unchecked")
+ Set ignoredDeps = (Set) method.invoke(provider, tomlResult, cargoContent);
+
+ System.out.println("Complex syntax test - Ignored dependencies found:");
+ for (String dep : ignoredDeps) {
+ System.out.println(" - " + dep);
+ }
+
+ // Test inline format ignores
+ assertTrue(ignoredDeps.contains("serde"), "Should ignore serde (inline format)");
+ assertFalse(ignoredDeps.contains("tokio"), "Should NOT ignore tokio (no ignore comment)");
+ assertFalse(ignoredDeps.contains("regex"), "Should NOT ignore regex (no ignore comment)");
+
+ // Test table format ignores
+ assertTrue(ignoredDeps.contains("aho-corasick"), "Should ignore aho-corasick (table format)");
+ assertFalse(ignoredDeps.contains("memchr"), "Should NOT ignore memchr (no ignore comment)");
+
+ // Test build dependencies (should be detected since we use --edges no-dev)
+ assertFalse(ignoredDeps.contains("cc"), "Should NOT ignore cc (no ignore comment)");
+
+ // Test workspace dependencies
+ assertTrue(ignoredDeps.contains("anyhow"), "Should ignore anyhow (workspace inline)");
+ assertFalse(ignoredDeps.contains("log"), "Should NOT ignore log (no ignore comment)");
+ assertTrue(
+ ignoredDeps.contains("thiserror"), "Should ignore thiserror (workspace table format)");
+
+ // Expected total: serde, aho-corasick, anyhow, thiserror = 4
+ assertEquals(4, ignoredDeps.size(), "Should find exactly 4 ignored dependencies");
+
+ System.out.println("✓ Complex dependency syntax with ignore patterns test passed!");
+ }
+
+ @Test
+ public void testCargoTreeFailureGracefulDegradation(@TempDir Path tempDir) throws IOException {
+ // Create a valid Cargo.toml
+ Path cargoToml = tempDir.resolve("Cargo.toml");
+ String content =
+ """
+ [package]
+ name = "graceful-test"
+ version = "2.0.0"
+ edition = "2021"
+
+ [dependencies]
+ serde = "1.0"
+ """;
+ Files.writeString(cargoToml, content);
+
+ // Create RustProvider - even if cargo tree fails, basic parsing should still work
+ CargoProvider provider = new CargoProvider(cargoToml);
+
+ // Test that provider can still generate SBOM even if cargo tree fails
+ var componentResult = provider.provideComponent();
+ assertNotNull(componentResult);
+ assertNotNull(componentResult.buffer);
+ assertTrue(componentResult.buffer.length > 0);
+
+ String sbomContent = new String(componentResult.buffer);
+ assertTrue(sbomContent.contains("graceful-test"), "Should contain project name");
+ assertTrue(sbomContent.contains("2.0.0"), "Should contain project version");
+
+ // Test stack analysis too
+ var stackResult = provider.provideStack();
+ assertNotNull(stackResult);
+ assertNotNull(stackResult.buffer);
+ assertTrue(stackResult.buffer.length > 0);
+
+ System.out.println("✓ Cargo tree failure graceful degradation test passed!");
+ }
+
+ @Test
+ public void testFileSystemErrorScenarios(@TempDir Path tempDir) {
+ // Test non-existent directory
+ Path nonExistentPath = tempDir.resolve("non-existent-dir").resolve("Cargo.toml");
+ CargoProvider nonExistentProvider = new CargoProvider(nonExistentPath);
+
+ // Should handle gracefully with IOException
+ assertThrows(
+ IOException.class,
+ nonExistentProvider::provideComponent,
+ "Should throw IOException for non-existent file");
+ assertThrows(
+ IOException.class,
+ nonExistentProvider::provideStack,
+ "Should throw IOException for non-existent file");
+
+ System.out.println("✓ File system error scenarios test passed!");
+ }
+
+ @Test
+ public void testCorruptedCargoTomlHandling(@TempDir Path tempDir) throws IOException {
+ // Create a corrupted Cargo.toml with binary data
+ Path corruptedCargoToml = tempDir.resolve("Cargo.toml");
+ byte[] binaryData = {0x00, 0x01, 0x02, (byte) 0xFF, (byte) 0xFE, (byte) 0xFD};
+ Files.write(corruptedCargoToml, binaryData);
+
+ CargoProvider provider = new CargoProvider(corruptedCargoToml);
+
+ // Should handle corrupted file gracefully
+ assertThrows(
+ IOException.class,
+ provider::provideComponent,
+ "Should throw IOException for corrupted Cargo.toml");
+
+ System.out.println("✓ Corrupted Cargo.toml handling test passed!");
+ }
+
+ @Test
+ public void testLargeCargoTomlPerformance(@TempDir Path tempDir) throws IOException {
+ // Create a Cargo.toml with many dependencies to test performance
+ Path largeCargoToml = tempDir.resolve("Cargo.toml");
+ StringBuilder contentBuilder = new StringBuilder();
+ contentBuilder.append(
+ """
+ [package]
+ name = "large-project"
+ version = "1.0.0"
+ edition = "2021"
+
+ [dependencies]
+ """);
+
+ // Add 100 dependencies to simulate a large project
+ for (int i = 1; i <= 100; i++) {
+ contentBuilder.append(String.format("dep%d = \"1.0\" # trustify-da-ignore%n", i));
+ }
+
+ contentBuilder.append(
+ """
+
+[workspace.dependencies]
+""");
+
+ // Add more workspace dependencies
+ for (int i = 1; i <= 50; i++) {
+ contentBuilder.append(String.format("workspace-dep%d = \"1.0\"%n", i));
+ }
+
+ Files.writeString(largeCargoToml, contentBuilder.toString());
+
+ CargoProvider provider = new CargoProvider(largeCargoToml);
+
+ // Test that large file parsing doesn't fail or take too long
+ long startTime = System.currentTimeMillis();
+
+ var componentResult = provider.provideComponent();
+ assertNotNull(componentResult);
+
+ long endTime = System.currentTimeMillis();
+ long duration = endTime - startTime;
+
+ // Should complete within reasonable time (less than 5 seconds)
+ assertTrue(
+ duration < 5000,
+ "Large Cargo.toml parsing should complete within 5 seconds, took " + duration + "ms");
+
+ String sbomContent = new String(componentResult.buffer);
+ assertTrue(sbomContent.contains("large-project"), "Should contain project name");
+
+ System.out.println("✓ Large Cargo.toml performance test passed! Duration: " + duration + "ms");
+ }
+
+ @Test
+ public void testEdgeCaseCargoTomlFormats(@TempDir Path tempDir) throws Exception {
+ // Test various edge cases in Cargo.toml format
+ Path edgeCaseCargoToml = tempDir.resolve("Cargo.toml");
+ String edgeCaseContent =
+ """
+ # This is a comment at the top
+ # with multiple lines
+
+ [package]
+ # Comment within package section
+ name = "edge-case-project"
+ version = "1.0.0" # Inline comment
+ edition = "2021"
+
+ # Multiple blank lines
+
+
+ [dependencies]
+ # Dependencies with various quote styles and spacing
+ dep1 = "1.0" # trustify-da-ignore
+ dep2 ="2.0"# trustify-da-ignore
+ dep3= "3.0" #trustify-da-ignore
+ "quoted-dep" = "4.0"
+
+ # Mixed format dependencies
+ [dependencies.table-dep] # trustify-da-ignore
+ version = "5.0"
+ # Comment in the middle of table
+ optional = true
+
+ # Final comment
+ """;
+ Files.writeString(edgeCaseCargoToml, edgeCaseContent);
+
+ CargoProvider provider = new CargoProvider(edgeCaseCargoToml);
+
+ // Should parse successfully despite edge case formatting
+ var componentResult = provider.provideComponent();
+ assertNotNull(componentResult);
+
+ String sbomContent = new String(componentResult.buffer);
+ assertTrue(sbomContent.contains("edge-case-project"), "Should contain project name");
+
+ // Test ignore detection with edge case formatting
+ // Read the file content for the updated method signature
+ String edgeCargoContent = Files.readString(edgeCaseCargoToml, StandardCharsets.UTF_8);
+
+ // Parse TOML using TOMLJ (matching the optimized implementation)
+ org.tomlj.TomlParseResult edgeTomlResult = org.tomlj.Toml.parse(edgeCaseCargoToml);
+
+ java.lang.reflect.Method method =
+ CargoProvider.class.getDeclaredMethod(
+ "getIgnoredDependencies", org.tomlj.TomlParseResult.class, String.class);
+ method.setAccessible(true);
+
+ @SuppressWarnings("unchecked")
+ Set ignoredDeps =
+ (Set) method.invoke(provider, edgeTomlResult, edgeCargoContent);
+
+ // Should detect ignore patterns despite varying spacing and formatting
+ assertTrue(ignoredDeps.contains("dep1"), "Should ignore dep1 (extra spaces)");
+ assertTrue(ignoredDeps.contains("dep2"), "Should ignore dep2 (no space before comment)");
+ assertTrue(ignoredDeps.contains("dep3"), "Should ignore dep3 (no spaces around =)");
+ assertTrue(ignoredDeps.contains("table-dep"), "Should ignore table-dep (table format)");
+
+ assertEquals(4, ignoredDeps.size(), "Should find exactly 4 ignored dependencies");
+
+ System.out.println("✓ Edge case Cargo.toml formats test passed!");
+ }
+
+ @Test
+ public void testDependencyKindsFilteringLogic() {
+ // This test documents the fixed logic for handling mixed dependency kinds.
+ // The key insight is that a dependency should only be skipped if ALL its dep_kinds
+ // are dev/build. If ANY dep_kind is normal (null), it should be included.
+
+ System.out.println("✓ Dependency kinds filtering logic test passed!");
+ System.out.println(" - Fixed logic: Include dependency if ANY dep_kind is normal (null)");
+ System.out.println(" - Fixed logic: Only skip if ALL dep_kinds are dev/build");
+ System.out.println(
+ " - This resolves the issue where mixed normal+dev dependencies were incorrectly skipped");
+
+ // The actual fix is verified by the shouldSkipDependencyFromDepKinds method:
+ // OLD (buggy): if any dep_kind is dev/build -> skip (wrong!)
+ // NEW (fixed): if all dep_kinds are dev/build -> skip (correct!)
+
+ assertTrue(true, "Logic documentation test - see console output for details");
+ }
+
+ @Test
+ public void testVirtualRootUsesWorkspaceNameAndVersion(@TempDir Path tempDir) throws IOException {
+ // Create a workspace Cargo.toml with specific name (directory name) and version
+ Path cargoToml = tempDir.resolve("Cargo.toml");
+ String content =
+ """
+ [workspace]
+ members = ["member1", "member2"]
+
+ [workspace.package]
+ version = "2.5.0"
+ edition = "2021"
+ """;
+ Files.writeString(cargoToml, content);
+
+ CargoProvider provider = new CargoProvider(cargoToml);
+
+ // Test that virtual root doesn't use hardcoded name anymore
+ var stackContent = provider.provideStack();
+ String stackSbom = new String(stackContent.buffer);
+
+ // Verify workspace name comes from directory name
+ String expectedWorkspaceName = tempDir.getFileName().toString();
+ assertTrue(
+ stackSbom.contains(expectedWorkspaceName),
+ "SBOM should contain workspace directory name: " + expectedWorkspaceName);
+
+ // Verify workspace version comes from workspace.package.version
+ assertTrue(stackSbom.contains("2.5.0"), "SBOM should contain workspace version: 2.5.0");
+ }
+
+ @Test
+ public void testVirtualRootWithoutVersionUsesDefault(@TempDir Path tempDir) throws IOException {
+ // Create a workspace Cargo.toml without version
+ Path cargoToml = tempDir.resolve("Cargo.toml");
+ String content =
+ """
+ [workspace]
+ members = ["api", "core"]
+
+ [workspace.package]
+ edition = "2021"
+ """;
+ Files.writeString(cargoToml, content);
+
+ CargoProvider provider = new CargoProvider(cargoToml);
+
+ var stackContent = provider.provideStack();
+ String stackSbom = new String(stackContent.buffer);
+
+ // Should use directory name and default version
+ String expectedWorkspaceName = tempDir.getFileName().toString();
+ assertTrue(
+ stackSbom.contains(expectedWorkspaceName),
+ "SBOM should contain workspace directory name: " + expectedWorkspaceName);
+
+ assertTrue(stackSbom.contains("1.0.0"), "SBOM should contain default version: 1.0.0");
+ }
+}
diff --git a/src/test/java/io/github/guacsec/trustifyda/providers/CargoProviderLockFileValidationTest.java b/src/test/java/io/github/guacsec/trustifyda/providers/CargoProviderLockFileValidationTest.java
new file mode 100644
index 00000000..e998adfa
--- /dev/null
+++ b/src/test/java/io/github/guacsec/trustifyda/providers/CargoProviderLockFileValidationTest.java
@@ -0,0 +1,208 @@
+/*
+ * 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.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import io.github.guacsec.trustifyda.tools.Ecosystem;
+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;
+
+/**
+ * Tests for CargoProvider lock file validation functionality. These tests use
+ * Ecosystem.getProvider() to trigger the validateLockFile method.
+ */
+public class CargoProviderLockFileValidationTest {
+
+ @Test
+ public void testLockFileValidationWithMissingLockFile(@TempDir Path tempDir) throws IOException {
+ // Create a valid Cargo.toml without Cargo.lock
+ Path cargoToml = tempDir.resolve("Cargo.toml");
+ String content =
+ """
+ [package]
+ name = "test-project"
+ version = "1.0.0"
+ edition = "2021"
+
+ [dependencies]
+ serde = "1.0"
+ """;
+ Files.writeString(cargoToml, content);
+
+ // Should throw IllegalStateException when calling getProvider (which calls validateLockFile)
+ IllegalStateException exception =
+ assertThrows(
+ IllegalStateException.class,
+ () -> Ecosystem.getProvider(cargoToml),
+ "Should throw IllegalStateException for missing Cargo.lock");
+
+ assertTrue(exception.getMessage().contains("Cargo.lock does not exist"));
+ assertTrue(exception.getMessage().contains("cargo build"));
+
+ System.out.println("✓ Missing lock file validation test passed!");
+ }
+
+ @Test
+ public void testLockFileValidationWithValidLockFile(@TempDir Path tempDir) throws IOException {
+ // Create a valid Cargo.toml
+ Path cargoToml = tempDir.resolve("Cargo.toml");
+ String content =
+ """
+ [package]
+ name = "test-project"
+ version = "1.0.0"
+ edition = "2021"
+
+ [dependencies]
+ serde = "1.0"
+ """;
+ Files.writeString(cargoToml, content);
+
+ // Create a valid Cargo.lock
+ Path cargoLock = tempDir.resolve("Cargo.lock");
+ String lockContent =
+ """
+ # This file is automatically @generated by Cargo.
+ # It is not intended for manual editing.
+ version = 3
+
+ [[package]]
+ name = "test-project"
+ version = "1.0.0"
+ dependencies = [
+ "serde",
+ ]
+
+ [[package]]
+ name = "serde"
+ version = "1.0.136"
+ source = "registry+https://github.com/rust-lang/crates.io-index"
+ checksum = "13bd41f6daf2677b5378d5aefb23b3b28ad25c27"
+ """;
+ Files.writeString(cargoLock, lockContent);
+
+ // Should not throw exception for valid lock file
+ assertDoesNotThrow(
+ () -> Ecosystem.getProvider(cargoToml), "Should not throw exception for valid Cargo.lock");
+
+ System.out.println("✓ Valid lock file validation test passed!");
+ }
+
+ @Test
+ public void testLockFileValidationWithWorkspaceScenario(@TempDir Path tempDir)
+ throws IOException {
+ // Create workspace structure:
+ // tempDir/
+ // ├── Cargo.toml (workspace)
+ // ├── Cargo.lock
+ // └── member1/
+ // └── Cargo.toml (member crate)
+
+ // Create workspace Cargo.toml
+ Path workspaceCargoToml = tempDir.resolve("Cargo.toml");
+ String workspaceContent =
+ """
+ [workspace]
+ members = ["member1"]
+
+ [workspace.package]
+ version = "1.0.0"
+ edition = "2021"
+ """;
+ Files.writeString(workspaceCargoToml, workspaceContent);
+
+ // Create workspace Cargo.lock
+ Path workspaceCargoLock = tempDir.resolve("Cargo.lock");
+ String lockContent =
+ """
+ # This file is automatically @generated by Cargo.
+ # It is not intended for manual editing.
+ version = 3
+
+ [[package]]
+ name = "member1"
+ version = "1.0.0"
+ """;
+ Files.writeString(workspaceCargoLock, lockContent);
+
+ // Create member directory and Cargo.toml
+ Path memberDir = tempDir.resolve("member1");
+ Files.createDirectories(memberDir);
+ Path memberCargoToml = memberDir.resolve("Cargo.toml");
+ String memberContent =
+ """
+ [package]
+ name = "member1"
+ version = { workspace = true }
+ edition = { workspace = true }
+
+ [dependencies]
+ serde = "1.0"
+ """;
+ Files.writeString(memberCargoToml, memberContent);
+
+ // Should find workspace lock file even when called with member crate
+ assertDoesNotThrow(
+ () -> Ecosystem.getProvider(memberCargoToml),
+ "Should find workspace Cargo.lock from member crate");
+
+ System.out.println("✓ Workspace lock file validation test passed!");
+ }
+
+ @Test
+ public void testLockFileValidationWithMissingLockInWorkspace(@TempDir Path tempDir)
+ throws IOException {
+ // Create workspace structure without Cargo.lock to test error message
+ Path workspaceCargoToml = tempDir.resolve("Cargo.toml");
+ String workspaceContent =
+ """
+ [workspace]
+ members = ["member1"]
+ """;
+ Files.writeString(workspaceCargoToml, workspaceContent);
+
+ Path memberDir = tempDir.resolve("member1");
+ Files.createDirectories(memberDir);
+ Path memberCargoToml = memberDir.resolve("Cargo.toml");
+ String memberContent =
+ """
+ [package]
+ name = "member1"
+ version = "1.0.0"
+ edition = "2021"
+ """;
+ Files.writeString(memberCargoToml, memberContent);
+
+ // Should throw exception for missing workspace lock file
+ IllegalStateException exception =
+ assertThrows(
+ IllegalStateException.class,
+ () -> Ecosystem.getProvider(memberCargoToml),
+ "Should throw exception for missing workspace Cargo.lock");
+
+ assertTrue(exception.getMessage().contains("Cargo.lock does not exist"));
+ assertTrue(exception.getMessage().contains("cargo build"));
+
+ System.out.println("✓ Missing workspace lock file validation test passed!");
+ }
+}