uv-sbom 2.0.1

SBOM generation tool for uv projects - Generate CycloneDX SBOMs from uv.lock files
Documentation
use crate::ports::outbound::{LockfileParseResult, LockfileReader, ProjectConfigReader};
use crate::sbom_generation::domain::Package;
use crate::shared::error::SbomError;
use crate::shared::security::{read_file_with_security, MAX_FILE_SIZE};
use crate::shared::Result;
use std::collections::HashMap;
use std::path::Path;

/// FileSystemReader adapter for reading files from the file system
///
/// This adapter implements both LockfileReader and ProjectConfigReader ports,
/// providing file system access for reading lockfiles and project configuration.
pub struct FileSystemReader;

impl FileSystemReader {
    pub fn new() -> Self {
        Self
    }
}

impl Default for FileSystemReader {
    fn default() -> Self {
        Self::new()
    }
}

impl FileSystemReader {
    /// Safely read a file with security checks.
    ///
    /// Delegates to the consolidated `read_file_with_security` function in `shared::security`,
    /// which provides:
    /// - Symlink rejection
    /// - File type validation
    /// - File size limits
    /// - TOCTOU mitigation
    fn safe_read_file(&self, path: &Path, file_type: &str) -> Result<String> {
        read_file_with_security(path, file_type, MAX_FILE_SIZE)
    }
}

impl LockfileReader for FileSystemReader {
    fn read_lockfile(&self, project_path: &Path) -> Result<String> {
        let lockfile_path = project_path.join("uv.lock");

        // Check if uv.lock file exists
        if !lockfile_path.exists() {
            return Err(SbomError::LockfileNotFound {
                path: lockfile_path.clone(),
                suggestion: format!(
                    "uv.lock file does not exist in project directory \"{}\".\n   \
                     Please run in the root directory of a uv project, or specify the correct path with the --path option.",
                    project_path.display()
                ),
            }
            .into());
        }

        // Read lockfile content with security checks
        self.safe_read_file(&lockfile_path, "uv.lock").map_err(|e| {
            SbomError::LockfileParseError {
                path: lockfile_path,
                details: e.to_string(),
            }
            .into()
        })
    }

    fn read_and_parse_lockfile(&self, project_path: &Path) -> Result<LockfileParseResult> {
        // Read the lockfile content
        let lockfile_content = self.read_lockfile(project_path)?;

        // Parse TOML content
        self.parse_lockfile_content(&lockfile_content, project_path)
    }
}

impl FileSystemReader {
    /// Parses lockfile content to extract packages and dependency map
    ///
    /// This method handles the TOML parsing logic which is an infrastructure concern.
    /// It was moved from the application layer to properly separate concerns.
    fn parse_lockfile_content(
        &self,
        content: &str,
        project_path: &Path,
    ) -> Result<LockfileParseResult> {
        use serde::Deserialize;

        #[derive(Debug, Deserialize)]
        struct UvLock {
            package: Vec<UvPackage>,
        }

        #[derive(Debug, Deserialize)]
        struct UvPackage {
            name: String,
            version: String,
            #[serde(default)]
            dependencies: Vec<UvDependency>,
            #[serde(default, rename = "dev-dependencies")]
            dev_dependencies: Option<DevDependencies>,
        }

        #[derive(Debug, Deserialize)]
        struct UvDependency {
            name: String,
        }

        #[derive(Debug, Deserialize)]
        struct DevDependencies {
            #[serde(default)]
            dev: Vec<UvDependency>,
        }

        let lockfile: UvLock =
            toml::from_str(content).map_err(|e| SbomError::LockfileParseError {
                path: project_path.join("uv.lock"),
                details: e.to_string(),
            })?;

        let mut packages = Vec::new();
        let mut dependency_map = HashMap::new();

        for pkg in lockfile.package {
            packages.push(Package::new(pkg.name.clone(), pkg.version.clone())?);

            // Build dependency map
            let mut deps = Vec::new();
            for dep in &pkg.dependencies {
                deps.push(dep.name.clone());
            }
            if let Some(dev_deps) = &pkg.dev_dependencies {
                for dep in &dev_deps.dev {
                    deps.push(dep.name.clone());
                }
            }
            dependency_map.insert(pkg.name, deps);
        }

        Ok((packages, dependency_map))
    }
}

impl ProjectConfigReader for FileSystemReader {
    fn read_project_name(&self, project_path: &Path) -> Result<String> {
        let pyproject_path = project_path.join("pyproject.toml");

        if !pyproject_path.exists() {
            anyhow::bail!("pyproject.toml not found in project directory");
        }

        // Read with security checks
        let pyproject_content = self.safe_read_file(&pyproject_path, "pyproject.toml")?;

        let pyproject: toml::Value = toml::from_str(&pyproject_content)
            .map_err(|e| anyhow::anyhow!("Failed to parse pyproject.toml: {}", e))?;

        let project_name = pyproject
            .get("project")
            .and_then(|p| p.get("name"))
            .and_then(|n| n.as_str())
            .ok_or_else(|| anyhow::anyhow!("Project name not found in pyproject.toml"))?;

        Ok(project_name.to_string())
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;
    use tempfile::TempDir;

    #[test]
    fn test_read_lockfile_success() {
        let temp_dir = TempDir::new().unwrap();
        let lockfile_path = temp_dir.path().join("uv.lock");
        fs::write(&lockfile_path, "test content").unwrap();

        let reader = FileSystemReader::new();
        let content = reader.read_lockfile(temp_dir.path()).unwrap();

        assert_eq!(content, "test content");
    }

    #[test]
    fn test_read_lockfile_not_found() {
        let temp_dir = TempDir::new().unwrap();

        let reader = FileSystemReader::new();
        let result = reader.read_lockfile(temp_dir.path());

        assert!(result.is_err());
        let err_string = format!("{}", result.unwrap_err());
        assert!(err_string.contains("uv.lock file does not exist"));
    }

    #[test]
    fn test_read_project_name_success() {
        let temp_dir = TempDir::new().unwrap();
        let pyproject_path = temp_dir.path().join("pyproject.toml");
        fs::write(
            &pyproject_path,
            r#"
[project]
name = "test-project"
version = "1.0.0"
"#,
        )
        .unwrap();

        let reader = FileSystemReader::new();
        let project_name = reader.read_project_name(temp_dir.path()).unwrap();

        assert_eq!(project_name, "test-project");
    }

    #[test]
    fn test_read_project_name_file_not_found() {
        let temp_dir = TempDir::new().unwrap();

        let reader = FileSystemReader::new();
        let result = reader.read_project_name(temp_dir.path());

        assert!(result.is_err());
        let err_string = format!("{}", result.unwrap_err());
        assert!(err_string.contains("pyproject.toml not found"));
    }

    #[test]
    fn test_read_project_name_invalid_toml() {
        let temp_dir = TempDir::new().unwrap();
        let pyproject_path = temp_dir.path().join("pyproject.toml");
        fs::write(&pyproject_path, "invalid toml [[[").unwrap();

        let reader = FileSystemReader::new();
        let result = reader.read_project_name(temp_dir.path());

        assert!(result.is_err());
        let err_string = format!("{}", result.unwrap_err());
        assert!(err_string.contains("Failed to parse pyproject.toml"));
    }

    #[test]
    fn test_read_project_name_missing_name_field() {
        let temp_dir = TempDir::new().unwrap();
        let pyproject_path = temp_dir.path().join("pyproject.toml");
        fs::write(
            &pyproject_path,
            r#"
[project]
version = "1.0.0"
"#,
        )
        .unwrap();

        let reader = FileSystemReader::new();
        let result = reader.read_project_name(temp_dir.path());

        assert!(result.is_err());
        let err_string = format!("{}", result.unwrap_err());
        assert!(err_string.contains("Project name not found"));
    }
}