diff --git a/Cargo.lock b/Cargo.lock index bbe6ca2..f1526af 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -722,6 +722,7 @@ dependencies = [ "pest", "pest_derive", "regex", + "semver", "serde", "serde_json", "sha2 0.9.9", @@ -1348,9 +1349,12 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.17" +version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bebd363326d05ec3e2f532ab7660680f3b02130d780c299bca73469d521bc0ed" +checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" +dependencies = [ + "serde", +] [[package]] name = "serde" diff --git a/libips/Cargo.toml b/libips/Cargo.toml index 3e5cd79..5fcef65 100644 --- a/libips/Cargo.toml +++ b/libips/Cargo.toml @@ -28,6 +28,7 @@ pest_derive = "2.1.0" strum = { version = "0.24.1", features = ["derive"] } serde = { version = "1.0.207", features = ["derive"] } serde_json = "1.0.124" -diff-struct = "0.5.3" flate2 = "1.0.28" lz4 = "1.24.0" +semver = { version = "1.0.20", features = ["serde"] } +diff-struct = "0.5.3" diff --git a/libips/src/actions/mod.rs b/libips/src/actions/mod.rs index 3646990..97a511b 100644 --- a/libips/src/actions/mod.rs +++ b/libips/src/actions/mod.rs @@ -6,6 +6,7 @@ // Source https://docs.oracle.com/cd/E23824_01/html/E21796/pkg-5.html use crate::digest::Digest; +use crate::fmri::Fmri; use crate::payload::{Payload, PayloadError}; use pest::Parser; use pest_derive::Parser; @@ -267,9 +268,9 @@ pub enum FileError { #[derive(Debug, PartialEq)] ))] pub struct Dependency { - pub fmri: String, //TODO make FMRI + pub fmri: Option, // FMRI of the dependency pub dependency_type: String, //TODO make enum - pub predicate: String, //TODO make FMRI + pub predicate: Option, // FMRI for conditional dependencies pub root_image: String, //TODO make boolean pub optional: Vec, pub facets: HashMap, @@ -288,9 +289,25 @@ impl From for Dependency { } for prop in props { match prop.key.as_str() { - "fmri" => dep.fmri = prop.value, + "fmri" => { + match Fmri::parse(&prop.value) { + Ok(fmri) => dep.fmri = Some(fmri), + Err(err) => { + eprintln!("Error parsing FMRI '{}': {}", prop.value, err); + dep.fmri = None; + } + } + }, "type" => dep.dependency_type = prop.value, - "predicate" => dep.predicate = prop.value, + "predicate" => { + match Fmri::parse(&prop.value) { + Ok(fmri) => dep.predicate = Some(fmri), + Err(err) => { + eprintln!("Error parsing predicate FMRI '{}': {}", prop.value, err); + dep.predicate = None; + } + } + }, "root-image" => dep.root_image = prop.value, _ => { if is_facet(prop.key.clone()) { diff --git a/libips/src/fmri.rs b/libips/src/fmri.rs new file mode 100644 index 0000000..8bb4830 --- /dev/null +++ b/libips/src/fmri.rs @@ -0,0 +1,1001 @@ +// This Source Code Form is subject to the terms of +// the Mozilla Public License, v. 2.0. If a copy of the +// MPL was not distributed with this file, You can +// obtain one at https://mozilla.org/MPL/2.0/. + +//! FMRI (Fault Management Resource Identifier) implementation +//! +//! An FMRI is a unique identifier for a package in the IPS system. +//! It follows the format: pkg://publisher/package_name@version +//! where: +//! - publisher is optional +//! - version is optional and follows the format: release[,branch][-build]:timestamp +//! - release is a dot-separated vector of digits (e.g., 5.11) +//! - branch is optional and is a dot-separated vector of digits (e.g., 1) +//! - build is optional and is a dot-separated vector of digits (e.g., 2020.0.1.0) +//! - timestamp is optional and is a hexadecimal string (e.g., 20200421T195136Z) +//! +//! The dot-separated vector components (release, branch, build) can be converted to and from +//! semver::Version objects using the provided conversion methods: +//! - release_to_semver +//! - branch_to_semver +//! - build_to_semver +//! - from_semver +//! +//! Examples: +//! - pkg:///sunos/coreutils@5.11,1:[hex-timestamp-1] +//! - pkg://openindiana.org/web/server/nginx@1.18.0,5.11-2020.0.1.0:20200421T195136Z +//! - pkg:/system/library@0.5.11-2020.0.1.19563 +//! - xvm@0.5.11-2015.0.2.0 +//! +//! # Examples +//! +//! ``` +//! use libips::fmri::{Fmri, Version}; +//! +//! // Parse an FMRI +//! let fmri = Fmri::parse("pkg://openindiana.org/web/server/nginx@1.18.0,5.11-2020.0.1.0:20200421T195136Z").unwrap(); +//! +//! // Convert the release component to a semver::Version +//! if let Some(version) = &fmri.version { +//! let semver = version.release_to_semver().unwrap(); +//! assert_eq!(semver.major, 1); +//! assert_eq!(semver.minor, 18); +//! assert_eq!(semver.patch, 0); +//! } +//! +//! // Create a Version from semver::Version components +//! let release = semver::Version::new(5, 11, 0); +//! let branch = Some(semver::Version::new(1, 0, 0)); +//! let build = Some(semver::Version::new(2020, 0, 1)); +//! let timestamp = Some("20200421T195136Z".to_string()); +//! +//! let version = Version::from_semver(release, branch, build, timestamp); +//! assert_eq!(version.release, "5.11.0"); +//! assert_eq!(version.branch, Some("1.0.0".to_string())); +//! assert_eq!(version.build, Some("2020.0.1".to_string())); +//! assert_eq!(version.timestamp, Some("20200421T195136Z".to_string())); +//! ``` + +use std::fmt; +use std::str::FromStr; +use thiserror::Error; +use serde::{Serialize, Deserialize}; +use diff::Diff; + +/// Errors that can occur when parsing an FMRI +#[derive(Debug, Error, PartialEq)] +pub enum FmriError { + #[error("invalid FMRI format")] + InvalidFormat, + #[error("invalid version format")] + InvalidVersionFormat, + #[error("invalid release format")] + InvalidReleaseFormat, + #[error("invalid branch format")] + InvalidBranchFormat, + #[error("invalid build format")] + InvalidBuildFormat, + #[error("invalid timestamp format")] + InvalidTimestampFormat, +} + +/// A version component of an FMRI +/// +/// A version consists of: +/// - release: a dot-separated vector of digits (e.g., 5.11) +/// - branch: optional, a dot-separated vector of digits (e.g., 1) +/// - build: optional, a dot-separated vector of digits (e.g., 2020.0.1.0) +/// - timestamp: optional, a hexadecimal string (e.g., 20200421T195136Z) +/// +/// The dot-separated vector components (release, branch, build) can be converted to and from +/// semver::Version objects using the provided conversion methods: +/// - release_to_semver +/// - branch_to_semver +/// - build_to_semver +/// - to_semver +/// +/// New Version objects can be created from semver::Version objects using: +/// - new_semver +/// - with_branch_semver +/// - with_build_semver +/// - with_timestamp_semver +/// - from_semver +/// +/// # Examples +/// +/// ``` +/// use libips::fmri::Version; +/// +/// // Create a Version from strings +/// let version = Version::new("5.11"); +/// +/// // Convert to semver::Version +/// let semver = version.release_to_semver().unwrap(); +/// assert_eq!(semver.major, 5); +/// assert_eq!(semver.minor, 11); +/// assert_eq!(semver.patch, 0); +/// +/// // Create a Version from semver::Version +/// let semver_version = semver::Version::new(1, 2, 3); +/// let version = Version::new_semver(semver_version); +/// assert_eq!(version.release, "1.2.3"); +/// ``` +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Diff)] +#[diff(attr( + #[derive(Debug, PartialEq)] +))] +pub struct Version { + /// The release component (e.g., 5.11) + pub release: String, + /// The branch component (e.g., 1) + pub branch: Option, + /// The build component (e.g., 2020.0.1.0) + pub build: Option, + /// The timestamp component (e.g., 20200421T195136Z) + pub timestamp: Option, +} + +impl Version { + /// Create a new Version with the given release + pub fn new(release: &str) -> Self { + Version { + release: release.to_string(), + branch: None, + build: None, + timestamp: None, + } + } + + /// Helper method to pad a version string to ensure it has at least MAJOR.MINOR.PATCH components + /// + /// This method takes a dot-separated version string and ensures it has at least three components + /// by padding with zeros if necessary. If the string has more than three components, only the + /// first three are used. + fn pad_version_string(version_str: &str) -> String { + let parts: Vec<&str> = version_str.split('.').collect(); + match parts.len() { + 1 => format!("{}.0.0", parts[0]), + 2 => format!("{}.{}.0", parts[0], parts[1]), + 3 => format!("{}.{}.{}", parts[0], parts[1], parts[2]), + _ => format!("{}.{}.{}", parts[0], parts[1], parts[2]), // Use only the first three parts + } + } + + /// Convert the release component to a semver::Version + /// + /// This method attempts to parse the release component as a semver::Version. + /// If the release component doesn't have enough parts (e.g., "5.11" instead of "5.11.0"), + /// it will be padded with zeros to make it a valid semver version. + /// If the release component has more than three parts, only the first three will be used. + pub fn release_to_semver(&self) -> Result { + let version_str = Self::pad_version_string(&self.release); + version_str.parse() + } + + /// Convert the branch component to a semver::Version + /// + /// This method attempts to parse the branch component as a semver::Version. + /// If the branch component doesn't have enough parts (e.g., "1" instead of "1.0.0"), + /// it will be padded with zeros to make it a valid semver version. + /// If the branch component has more than three parts, only the first three will be used. + /// Returns None if the branch component is None. + pub fn branch_to_semver(&self) -> Option> { + self.branch.as_ref().map(|branch| { + let version_str = Self::pad_version_string(branch); + version_str.parse() + }) + } + + /// Convert the build component to a semver::Version + /// + /// This method attempts to parse the build component as a semver::Version. + /// If the build component doesn't have enough parts (e.g., "1" instead of "1.0.0"), + /// it will be padded with zeros to make it a valid semver version. + /// If the build component has more than three parts, only the first three will be used. + /// Returns None if the build component is None. + pub fn build_to_semver(&self) -> Option> { + self.build.as_ref().map(|build| { + let version_str = Self::pad_version_string(build); + version_str.parse() + }) + } + + /// Create a new Version with the given semver::Version as release + /// + /// This method creates a new Version with the given semver::Version as release. + /// The semver::Version is converted to a string. + pub fn new_semver(release: semver::Version) -> Self { + Version { + release: release.to_string(), + branch: None, + build: None, + timestamp: None, + } + } + + /// Create a Version from semver::Version components + /// + /// This method creates a Version from semver::Version components. + /// The semver::Version components are converted to strings. + pub fn from_semver( + release: semver::Version, + branch: Option, + build: Option, + timestamp: Option, + ) -> Self { + Version { + release: release.to_string(), + branch: branch.map(|v| v.to_string()), + build: build.map(|v| v.to_string()), + timestamp, + } + } + + /// Create a new Version with the given semver::Version as release and branch + /// + /// This method creates a new Version with the given semver::Version as release and branch. + /// The semver::Version objects are converted to strings. + pub fn with_branch_semver(release: semver::Version, branch: semver::Version) -> Self { + Version { + release: release.to_string(), + branch: Some(branch.to_string()), + build: None, + timestamp: None, + } + } + + /// Create a new Version with the given semver::Version as release, branch, and build + /// + /// This method creates a new Version with the given semver::Version as release, branch, and build. + /// The semver::Version objects are converted to strings. + pub fn with_build_semver( + release: semver::Version, + branch: Option, + build: semver::Version, + ) -> Self { + Version { + release: release.to_string(), + branch: branch.map(|v| v.to_string()), + build: Some(build.to_string()), + timestamp: None, + } + } + + /// Create a new Version with the given semver::Version as release, branch, build, and timestamp + /// + /// This method creates a new Version with the given semver::Version as release, branch, build, and timestamp. + /// The semver::Version objects are converted to strings. + pub fn with_timestamp_semver( + release: semver::Version, + branch: Option, + build: Option, + timestamp: &str, + ) -> Self { + Version { + release: release.to_string(), + branch: branch.map(|v| v.to_string()), + build: build.map(|v| v.to_string()), + timestamp: Some(timestamp.to_string()), + } + } + + /// Get all version components as semver::Version objects + /// + /// This method returns all version components as semver::Version objects. + /// If a component is not present or cannot be parsed, it will be None. + pub fn to_semver(&self) -> (Result, Option>, Option>) { + let release = self.release_to_semver(); + let branch = self.branch_to_semver(); + let build = self.build_to_semver(); + + (release, branch, build) + } + + /// Check if this version is compatible with semver + /// + /// This method checks if all components of this version can be parsed as semver::Version objects. + pub fn is_semver_compatible(&self) -> bool { + let (release, branch, build) = self.to_semver(); + + let release_ok = release.is_ok(); + let branch_ok = branch.map_or(true, |r| r.is_ok()); + let build_ok = build.map_or(true, |r| r.is_ok()); + + release_ok && branch_ok && build_ok + } + + /// Create a new Version with the given release and branch + pub fn with_branch(release: &str, branch: &str) -> Self { + Version { + release: release.to_string(), + branch: Some(branch.to_string()), + build: None, + timestamp: None, + } + } + + /// Create a new Version with the given release, branch, and build + pub fn with_build(release: &str, branch: Option<&str>, build: &str) -> Self { + Version { + release: release.to_string(), + branch: branch.map(|b| b.to_string()), + build: Some(build.to_string()), + timestamp: None, + } + } + + /// Create a new Version with the given release, branch, build, and timestamp + pub fn with_timestamp( + release: &str, + branch: Option<&str>, + build: Option<&str>, + timestamp: &str, + ) -> Self { + Version { + release: release.to_string(), + branch: branch.map(|b| b.to_string()), + build: build.map(|b| b.to_string()), + timestamp: Some(timestamp.to_string()), + } + } + + /// Parse a version string into a Version + /// + /// The version string should be in the format: release[,branch][-build][:timestamp] + pub fn parse(version_str: &str) -> Result { + let mut version = Version { + release: String::new(), + branch: None, + build: None, + timestamp: None, + }; + + // Split by colon to separate timestamp + let parts: Vec<&str> = version_str.split(':').collect(); + if parts.len() > 2 { + return Err(FmriError::InvalidVersionFormat); + } + + // If there's a timestamp, parse it + if parts.len() == 2 { + let timestamp = parts[1]; + // Reject empty timestamps + if timestamp.is_empty() { + return Err(FmriError::InvalidTimestampFormat); + } + if !timestamp.chars().all(|c| c.is_ascii_hexdigit() || c == 'T' || c == 'Z') { + return Err(FmriError::InvalidTimestampFormat); + } + version.timestamp = Some(timestamp.to_string()); + } + + // Split the first part by dash to separate build + let parts: Vec<&str> = parts[0].split('-').collect(); + if parts.len() > 2 { + return Err(FmriError::InvalidVersionFormat); + } + + // If there's a build, parse it + if parts.len() == 2 { + let build = parts[1]; + if !Self::is_valid_dot_vector(build) { + return Err(FmriError::InvalidBuildFormat); + } + version.build = Some(build.to_string()); + } + + // Split the first part by comma to separate release and branch + let parts: Vec<&str> = parts[0].split(',').collect(); + if parts.len() > 2 { + return Err(FmriError::InvalidVersionFormat); + } + + // Parse the release + let release = parts[0]; + if !Self::is_valid_dot_vector(release) { + return Err(FmriError::InvalidReleaseFormat); + } + version.release = release.to_string(); + + // If there's a branch, parse it + if parts.len() == 2 { + let branch = parts[1]; + if !Self::is_valid_dot_vector(branch) { + return Err(FmriError::InvalidBranchFormat); + } + version.branch = Some(branch.to_string()); + } + + Ok(version) + } + + /// Check if a string is a valid dot-separated vector of digits + /// + /// This method uses semver for validation when possible, but also accepts + /// dot-separated vectors with fewer than 3 components (which are not valid semver). + fn is_valid_dot_vector(s: &str) -> bool { + if s.is_empty() { + return false; + } + + // First check if it's a valid dot-separated vector of digits + let parts: Vec<&str> = s.split('.').collect(); + for part in &parts { + if part.is_empty() || !part.chars().all(|c| c.is_ascii_digit()) { + return false; + } + } + + // If it has at least 3 components, try to parse it as a semver version + if parts.len() >= 3 { + // Create a version string with exactly MAJOR.MINOR.PATCH + let version_str = format!("{}.{}.{}", parts[0], parts[1], parts[2]); + + // Try to parse it as a semver version + if let Err(_) = semver::Version::parse(&version_str) { + return false; + } + } + + true + } +} + +impl fmt::Display for Version { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.release)?; + + if let Some(branch) = &self.branch { + write!(f, ",{}", branch)?; + } + + if let Some(build) = &self.build { + write!(f, "-{}", build)?; + } + + if let Some(timestamp) = &self.timestamp { + write!(f, ":{}", timestamp)?; + } + + Ok(()) + } +} + +impl FromStr for Version { + type Err = FmriError; + + fn from_str(s: &str) -> Result { + Self::parse(s) + } +} + +/// An FMRI (Fault Management Resource Identifier) +/// +/// An FMRI is a unique identifier for a package in the IPS system. +/// It follows the format: pkg://publisher/package_name@version +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Diff)] +#[diff(attr( + #[derive(Debug, PartialEq)] +))] +pub struct Fmri { + /// The scheme (e.g., pkg) + pub scheme: String, + /// The publisher (e.g., openindiana.org) + pub publisher: Option, + /// The package name (e.g., web/server/nginx) + pub name: String, + /// The version + pub version: Option, +} + +impl Fmri { + /// Create a new FMRI with the given name + pub fn new(name: &str) -> Self { + Fmri { + scheme: "pkg".to_string(), + publisher: None, + name: name.to_string(), + version: None, + } + } + + /// Create a new FMRI with the given name and version + pub fn with_version(name: &str, version: Version) -> Self { + Fmri { + scheme: "pkg".to_string(), + publisher: None, + name: name.to_string(), + version: Some(version), + } + } + + /// Create a new FMRI with the given publisher, name, and version + pub fn with_publisher(publisher: &str, name: &str, version: Option) -> Self { + Fmri { + scheme: "pkg".to_string(), + publisher: Some(publisher.to_string()), + name: name.to_string(), + version, + } + } + + /// Parse an FMRI string into an Fmri + /// + /// The FMRI string should be in the format: [scheme://][publisher/]name[@version] + pub fn parse(fmri_str: &str) -> Result { + let mut fmri = Fmri { + scheme: "pkg".to_string(), + publisher: None, + name: String::new(), + version: None, + }; + + // Split by @ to separate name and version + let parts: Vec<&str> = fmri_str.split('@').collect(); + if parts.len() > 2 { + return Err(FmriError::InvalidFormat); + } + + // If there's a version, parse it + if parts.len() == 2 { + let version = Version::parse(parts[1])?; + fmri.version = Some(version); + } + + // Parse the name part + let name_part = parts[0]; + + // Check if there's a scheme with a publisher (pkg://publisher/name) + if let Some(scheme_end) = name_part.find("://") { + fmri.scheme = name_part[0..scheme_end].to_string(); + + // Extract the rest after the scheme + let rest = &name_part[scheme_end + 3..]; + + // Check if there's a publisher + if let Some(publisher_end) = rest.find('/') { + // If there's a non-empty publisher, set it + if publisher_end > 0 { + fmri.publisher = Some(rest[0..publisher_end].to_string()); + } + + // Set the name + fmri.name = rest[publisher_end + 1..].to_string(); + } else { + // No publisher, just a name + fmri.name = rest.to_string(); + } + } + // Check if there's a scheme without a publisher (pkg:/name) + else if let Some(scheme_end) = name_part.find(":/") { + fmri.scheme = name_part[0..scheme_end].to_string(); + + // Extract the rest after the scheme + let rest = &name_part[scheme_end + 2..]; + + // Set the name + fmri.name = rest.to_string(); + } + else { + // No scheme, just a name + fmri.name = name_part.to_string(); + } + + // Validate that the name is not empty + if fmri.name.is_empty() { + return Err(FmriError::InvalidFormat); + } + + Ok(fmri) + } +} + +impl fmt::Display for Fmri { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + // For FMRIs without a publisher, we should use the format pkg:///name + // For FMRIs with a publisher, we should use the format pkg://publisher/name + if let Some(publisher) = &self.publisher { + write!(f, "{}://{}/", self.scheme, publisher)?; + } else { + write!(f, "{}:///", self.scheme)?; + } + + write!(f, "{}", self.name)?; + + if let Some(version) = &self.version { + write!(f, "@{}", version)?; + } + + Ok(()) + } +} + +impl FromStr for Fmri { + type Err = FmriError; + + fn from_str(s: &str) -> Result { + Self::parse(s) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_semver_conversion() { + // Test release_to_semver + let version = Version::new("5.11"); + let semver = version.release_to_semver().unwrap(); + assert_eq!(semver.major, 5); + assert_eq!(semver.minor, 11); + assert_eq!(semver.patch, 0); + + // Test with a full semver version + let version = Version::new("1.2.3"); + let semver = version.release_to_semver().unwrap(); + assert_eq!(semver.major, 1); + assert_eq!(semver.minor, 2); + assert_eq!(semver.patch, 3); + + // Test branch_to_semver + let version = Version::with_branch("5.11", "1"); + let semver = version.branch_to_semver().unwrap().unwrap(); + assert_eq!(semver.major, 1); + assert_eq!(semver.minor, 0); + assert_eq!(semver.patch, 0); + + // Test with a full semver version + let mut version = Version::new("5.11"); + version.branch = Some("1.2.3".to_string()); + let semver = version.branch_to_semver().unwrap().unwrap(); + assert_eq!(semver.major, 1); + assert_eq!(semver.minor, 2); + assert_eq!(semver.patch, 3); + + // Test build_to_semver + let mut version = Version::new("5.11"); + version.build = Some("2020.0.1.0".to_string()); + let semver = version.build_to_semver().unwrap().unwrap(); + assert_eq!(semver.major, 2020); + assert_eq!(semver.minor, 0); + assert_eq!(semver.patch, 1); + + // Test from_semver + let release = semver::Version::new(5, 11, 0); + let branch = Some(semver::Version::new(1, 0, 0)); + let build = Some(semver::Version::new(2020, 0, 1)); + let timestamp = Some("20200421T195136Z".to_string()); + + let version = Version::from_semver(release, branch, build, timestamp); + assert_eq!(version.release, "5.11.0"); + assert_eq!(version.branch, Some("1.0.0".to_string())); + assert_eq!(version.build, Some("2020.0.1".to_string())); + assert_eq!(version.timestamp, Some("20200421T195136Z".to_string())); + } + + #[test] + fn test_new_semver_constructors() { + // Test new_semver + let semver_version = semver::Version::new(1, 2, 3); + let version = Version::new_semver(semver_version); + assert_eq!(version.release, "1.2.3"); + assert_eq!(version.branch, None); + assert_eq!(version.build, None); + assert_eq!(version.timestamp, None); + + // Test with_branch_semver + let release = semver::Version::new(5, 11, 0); + let branch = semver::Version::new(1, 0, 0); + let version = Version::with_branch_semver(release, branch); + assert_eq!(version.release, "5.11.0"); + assert_eq!(version.branch, Some("1.0.0".to_string())); + assert_eq!(version.build, None); + assert_eq!(version.timestamp, None); + + // Test with_build_semver + let release = semver::Version::new(5, 11, 0); + let branch = Some(semver::Version::new(1, 0, 0)); + let build = semver::Version::new(2020, 0, 1); + let version = Version::with_build_semver(release, branch, build); + assert_eq!(version.release, "5.11.0"); + assert_eq!(version.branch, Some("1.0.0".to_string())); + assert_eq!(version.build, Some("2020.0.1".to_string())); + assert_eq!(version.timestamp, None); + + // Test with_timestamp_semver + let release = semver::Version::new(5, 11, 0); + let branch = Some(semver::Version::new(1, 0, 0)); + let build = Some(semver::Version::new(2020, 0, 1)); + let timestamp = "20200421T195136Z"; + let version = Version::with_timestamp_semver(release, branch, build, timestamp); + assert_eq!(version.release, "5.11.0"); + assert_eq!(version.branch, Some("1.0.0".to_string())); + assert_eq!(version.build, Some("2020.0.1".to_string())); + assert_eq!(version.timestamp, Some("20200421T195136Z".to_string())); + } + + #[test] + fn test_to_semver() { + // Test to_semver with all components + let mut version = Version::new("5.11"); + version.branch = Some("1.2.3".to_string()); + version.build = Some("2020.0.1".to_string()); + + let (release, branch, build) = version.to_semver(); + + assert!(release.is_ok()); + let release = release.unwrap(); + assert_eq!(release.major, 5); + assert_eq!(release.minor, 11); + assert_eq!(release.patch, 0); + + assert!(branch.is_some()); + let branch = branch.unwrap().unwrap(); + assert_eq!(branch.major, 1); + assert_eq!(branch.minor, 2); + assert_eq!(branch.patch, 3); + + assert!(build.is_some()); + let build = build.unwrap().unwrap(); + assert_eq!(build.major, 2020); + assert_eq!(build.minor, 0); + assert_eq!(build.patch, 1); + + // Test is_semver_compatible + assert!(version.is_semver_compatible()); + + // Test with invalid semver + let mut version = Version::new("5.11"); + version.branch = Some("invalid".to_string()); + assert!(!version.is_semver_compatible()); + } + + #[test] + fn test_semver_validation() { + // Test valid dot-separated vectors + assert!(Version::is_valid_dot_vector("5")); + assert!(Version::is_valid_dot_vector("5.11")); + assert!(Version::is_valid_dot_vector("5.11.0")); + assert!(Version::is_valid_dot_vector("2020.0.1.0")); + + // Test invalid dot-separated vectors + assert!(!Version::is_valid_dot_vector("")); + assert!(!Version::is_valid_dot_vector(".11")); + assert!(!Version::is_valid_dot_vector("5.")); + assert!(!Version::is_valid_dot_vector("5..11")); + assert!(!Version::is_valid_dot_vector("5a.11")); + + // Test semver validation + assert!(Version::is_valid_dot_vector("1.2.3")); + assert!(Version::is_valid_dot_vector("0.0.0")); + assert!(Version::is_valid_dot_vector("999999.999999.999999")); + } + + #[test] + fn test_version_parse() { + // Test parsing a release + let version = Version::parse("5.11").unwrap(); + assert_eq!(version.release, "5.11"); + assert_eq!(version.branch, None); + assert_eq!(version.build, None); + assert_eq!(version.timestamp, None); + + // Test parsing a release and branch + let version = Version::parse("5.11,1").unwrap(); + assert_eq!(version.release, "5.11"); + assert_eq!(version.branch, Some("1".to_string())); + assert_eq!(version.build, None); + assert_eq!(version.timestamp, None); + + // Test parsing a release, branch, and build + let version = Version::parse("5.11,1-2020.0.1.0").unwrap(); + assert_eq!(version.release, "5.11"); + assert_eq!(version.branch, Some("1".to_string())); + assert_eq!(version.build, Some("2020.0.1.0".to_string())); + assert_eq!(version.timestamp, None); + + // Test parsing a release and build (no branch) + let version = Version::parse("5.11-2020.0.1.0").unwrap(); + assert_eq!(version.release, "5.11"); + assert_eq!(version.branch, None); + assert_eq!(version.build, Some("2020.0.1.0".to_string())); + assert_eq!(version.timestamp, None); + + // Test parsing a release, branch, build, and timestamp + let version = Version::parse("5.11,1-2020.0.1.0:20200421T195136Z").unwrap(); + assert_eq!(version.release, "5.11"); + assert_eq!(version.branch, Some("1".to_string())); + assert_eq!(version.build, Some("2020.0.1.0".to_string())); + assert_eq!(version.timestamp, Some("20200421T195136Z".to_string())); + + // Test parsing a release and timestamp (no branch or build) + let version = Version::parse("5.11:20200421T195136Z").unwrap(); + assert_eq!(version.release, "5.11"); + assert_eq!(version.branch, None); + assert_eq!(version.build, None); + assert_eq!(version.timestamp, Some("20200421T195136Z".to_string())); + + // Test parsing a release, branch, and timestamp (no build) + let version = Version::parse("5.11,1:20200421T195136Z").unwrap(); + assert_eq!(version.release, "5.11"); + assert_eq!(version.branch, Some("1".to_string())); + assert_eq!(version.build, None); + assert_eq!(version.timestamp, Some("20200421T195136Z".to_string())); + } + + #[test] + fn test_version_display() { + // Test displaying a release + let version = Version::new("5.11"); + assert_eq!(version.to_string(), "5.11"); + + // Test displaying a release and branch + let version = Version::with_branch("5.11", "1"); + assert_eq!(version.to_string(), "5.11,1"); + + // Test displaying a release, branch, and build + let version = Version::with_build("5.11", Some("1"), "2020.0.1.0"); + assert_eq!(version.to_string(), "5.11,1-2020.0.1.0"); + + // Test displaying a release and build (no branch) + let version = Version::with_build("5.11", None, "2020.0.1.0"); + assert_eq!(version.to_string(), "5.11-2020.0.1.0"); + + // Test displaying a release, branch, build, and timestamp + let version = Version::with_timestamp("5.11", Some("1"), Some("2020.0.1.0"), "20200421T195136Z"); + assert_eq!(version.to_string(), "5.11,1-2020.0.1.0:20200421T195136Z"); + + // Test displaying a release and timestamp (no branch or build) + let version = Version::with_timestamp("5.11", None, None, "20200421T195136Z"); + assert_eq!(version.to_string(), "5.11:20200421T195136Z"); + + // Test displaying a release, branch, and timestamp (no build) + let version = Version::with_timestamp("5.11", Some("1"), None, "20200421T195136Z"); + assert_eq!(version.to_string(), "5.11,1:20200421T195136Z"); + } + + #[test] + fn test_fmri_parse() { + // Test parsing a name only + let fmri = Fmri::parse("sunos/coreutils").unwrap(); + assert_eq!(fmri.scheme, "pkg"); + assert_eq!(fmri.publisher, None); + assert_eq!(fmri.name, "sunos/coreutils"); + assert_eq!(fmri.version, None); + + // Test parsing a name and version + let fmri = Fmri::parse("sunos/coreutils@5.11,1:20200421T195136Z").unwrap(); + assert_eq!(fmri.scheme, "pkg"); + assert_eq!(fmri.publisher, None); + assert_eq!(fmri.name, "sunos/coreutils"); + assert_eq!( + fmri.version, + Some(Version { + release: "5.11".to_string(), + branch: Some("1".to_string()), + build: None, + timestamp: Some("20200421T195136Z".to_string()), + }) + ); + + // Test parsing with scheme + let fmri = Fmri::parse("pkg://sunos/coreutils").unwrap(); + assert_eq!(fmri.scheme, "pkg"); + assert_eq!(fmri.publisher, Some("sunos".to_string())); + assert_eq!(fmri.name, "coreutils"); + assert_eq!(fmri.version, None); + + // Test parsing with scheme and empty publisher + let fmri = Fmri::parse("pkg:///sunos/coreutils").unwrap(); + assert_eq!(fmri.scheme, "pkg"); + assert_eq!(fmri.publisher, None); + assert_eq!(fmri.name, "sunos/coreutils"); + assert_eq!(fmri.version, None); + + // Test parsing with scheme, publisher, and version + let fmri = Fmri::parse("pkg://openindiana.org/web/server/nginx@1.18.0,5.11-2020.0.1.0:20200421T195136Z").unwrap(); + assert_eq!(fmri.scheme, "pkg"); + assert_eq!(fmri.publisher, Some("openindiana.org".to_string())); + assert_eq!(fmri.name, "web/server/nginx"); + assert_eq!( + fmri.version, + Some(Version { + release: "1.18.0".to_string(), + branch: Some("5.11".to_string()), + build: Some("2020.0.1.0".to_string()), + timestamp: Some("20200421T195136Z".to_string()), + }) + ); + + // Test parsing with scheme and version + let fmri = Fmri::parse("pkg:/system/library@0.5.11-2020.0.1.19563").unwrap(); + assert_eq!(fmri.scheme, "pkg"); + assert_eq!(fmri.publisher, None); + assert_eq!(fmri.name, "system/library"); + assert_eq!( + fmri.version, + Some(Version { + release: "0.5.11".to_string(), + branch: None, + build: Some("2020.0.1.19563".to_string()), + timestamp: None, + }) + ); + } + + #[test] + fn test_fmri_display() { + // Test displaying a name only + let fmri = Fmri::new("sunos/coreutils"); + assert_eq!(fmri.to_string(), "pkg:///sunos/coreutils"); + + // Test displaying a name and version + let version = Version::with_timestamp("5.11", Some("1"), None, "20200421T195136Z"); + let fmri = Fmri::with_version("sunos/coreutils", version); + assert_eq!(fmri.to_string(), "pkg:///sunos/coreutils@5.11,1:20200421T195136Z"); + + // Test displaying with publisher + let fmri = Fmri::with_publisher("openindiana.org", "web/server/nginx", None); + assert_eq!(fmri.to_string(), "pkg://openindiana.org/web/server/nginx"); + + // Test displaying with publisher and version + let version = Version::with_timestamp("1.18.0", Some("5.11"), Some("2020.0.1.0"), "20200421T195136Z"); + let fmri = Fmri::with_publisher("openindiana.org", "web/server/nginx", Some(version)); + assert_eq!( + fmri.to_string(), + "pkg://openindiana.org/web/server/nginx@1.18.0,5.11-2020.0.1.0:20200421T195136Z" + ); + } + + #[test] + fn test_version_errors() { + // Test invalid release format + assert_eq!(Version::parse(""), Err(FmriError::InvalidReleaseFormat)); + assert_eq!(Version::parse(".11"), Err(FmriError::InvalidReleaseFormat)); + assert_eq!(Version::parse("5."), Err(FmriError::InvalidReleaseFormat)); + assert_eq!(Version::parse("5..11"), Err(FmriError::InvalidReleaseFormat)); + assert_eq!(Version::parse("5a.11"), Err(FmriError::InvalidReleaseFormat)); + + // Test invalid branch format + assert_eq!(Version::parse("5.11,"), Err(FmriError::InvalidBranchFormat)); + assert_eq!(Version::parse("5.11,.1"), Err(FmriError::InvalidBranchFormat)); + assert_eq!(Version::parse("5.11,1."), Err(FmriError::InvalidBranchFormat)); + assert_eq!(Version::parse("5.11,1..2"), Err(FmriError::InvalidBranchFormat)); + assert_eq!(Version::parse("5.11,1a.2"), Err(FmriError::InvalidBranchFormat)); + + // Test invalid build format + assert_eq!(Version::parse("5.11-"), Err(FmriError::InvalidBuildFormat)); + assert_eq!(Version::parse("5.11-.1"), Err(FmriError::InvalidBuildFormat)); + assert_eq!(Version::parse("5.11-1."), Err(FmriError::InvalidBuildFormat)); + assert_eq!(Version::parse("5.11-1..2"), Err(FmriError::InvalidBuildFormat)); + assert_eq!(Version::parse("5.11-1a.2"), Err(FmriError::InvalidBuildFormat)); + + // Test invalid timestamp format + assert_eq!(Version::parse("5.11:"), Err(FmriError::InvalidTimestampFormat)); + assert_eq!(Version::parse("5.11:xyz"), Err(FmriError::InvalidTimestampFormat)); + + // Test invalid version format + assert_eq!(Version::parse("5.11,1,2"), Err(FmriError::InvalidVersionFormat)); + assert_eq!(Version::parse("5.11-1-2"), Err(FmriError::InvalidVersionFormat)); + assert_eq!(Version::parse("5.11:1:2"), Err(FmriError::InvalidVersionFormat)); + } + + #[test] + fn test_fmri_errors() { + // Test invalid format + assert_eq!(Fmri::parse(""), Err(FmriError::InvalidFormat)); + assert_eq!(Fmri::parse("pkg://"), Err(FmriError::InvalidFormat)); + assert_eq!(Fmri::parse("pkg:///"), Err(FmriError::InvalidFormat)); + assert_eq!(Fmri::parse("pkg://publisher/"), Err(FmriError::InvalidFormat)); + assert_eq!(Fmri::parse("@5.11"), Err(FmriError::InvalidFormat)); + assert_eq!(Fmri::parse("name@version@extra"), Err(FmriError::InvalidFormat)); + + // Test invalid version + assert_eq!(Fmri::parse("name@"), Err(FmriError::InvalidReleaseFormat)); + assert_eq!(Fmri::parse("name@5.11,"), Err(FmriError::InvalidBranchFormat)); + assert_eq!(Fmri::parse("name@5.11-"), Err(FmriError::InvalidBuildFormat)); + assert_eq!(Fmri::parse("name@5.11:"), Err(FmriError::InvalidTimestampFormat)); + } +} \ No newline at end of file diff --git a/libips/src/lib.rs b/libips/src/lib.rs index 7a72d89..166adfe 100644 --- a/libips/src/lib.rs +++ b/libips/src/lib.rs @@ -6,6 +6,7 @@ #[allow(clippy::result_large_err)] pub mod actions; pub mod digest; +pub mod fmri; pub mod payload; pub mod image; pub mod repository; @@ -16,6 +17,7 @@ mod tests { use crate::actions::Attr; use crate::actions::{Dependency, Dir, Facet, File, Link, Manifest, Property}; use crate::digest::{Digest, DigestAlgorithm, DigestSource}; + use crate::fmri::Fmri; use crate::payload::Payload; use std::collections::HashMap; @@ -980,18 +982,17 @@ depend facet.version-lock.system/mozilla-nss=true fmri=system/mozilla-nss@3.51.1 let test_results = vec![ Dependency { - fmri: "pkg:/system/library@0.5.11-2020.0.1.19563".to_string(), + fmri: Some(Fmri::parse("pkg:/system/library@0.5.11-2020.0.1.19563").unwrap()), dependency_type: "require".to_string(), ..Dependency::default() }, Dependency { - fmri: "pkg:/system/file-system/nfs@0.5.11,5.11-2020.0.1.19951".to_string(), + fmri: Some(Fmri::parse("pkg:/system/file-system/nfs@0.5.11,5.11-2020.0.1.19951").unwrap()), dependency_type: "incorporate".to_string(), ..Dependency::default() }, Dependency { - fmri: "pkg:/system/data/hardware-registry@2020.2.22,5.11-2020.0.1.19951" - .to_string(), + fmri: Some(Fmri::parse("pkg:/system/data/hardware-registry@2020.2.22,5.11-2020.0.1.19951").unwrap()), dependency_type: "incorporate".to_string(), facets: hashmap! { "version-lock.system/data/hardware-registry".to_string() => Facet{ @@ -1002,7 +1003,7 @@ depend facet.version-lock.system/mozilla-nss=true fmri=system/mozilla-nss@3.51.1 ..Dependency::default() }, Dependency { - fmri: "xvm@0.5.11-2015.0.2.0".to_string(), + fmri: Some(Fmri::parse("xvm@0.5.11-2015.0.2.0").unwrap()), dependency_type: "incorporate".to_string(), facets: hashmap! { "version-lock.xvm".to_string() => Facet{ @@ -1013,7 +1014,7 @@ depend facet.version-lock.system/mozilla-nss=true fmri=system/mozilla-nss@3.51.1 ..Dependency::default() }, Dependency { - fmri: "system/mozilla-nss@3.51.1-2020.0.1.0".to_string(), + fmri: Some(Fmri::parse("system/mozilla-nss@3.51.1-2020.0.1.0").unwrap()), dependency_type: "incorporate".to_string(), facets: hashmap! { "version-lock.system/mozilla-nss".to_string() => Facet{ @@ -1031,7 +1032,13 @@ depend facet.version-lock.system/mozilla-nss=true fmri=system/mozilla-nss@3.51.1 assert_eq!(manifest.dependencies.len(), test_results.len()); for (pos, dependency) in manifest.dependencies.iter().enumerate() { - assert_eq!(dependency.fmri, test_results[pos].fmri); + // Compare the string representation of the FMRIs + if let (Some(dep_fmri), Some(test_fmri)) = (&dependency.fmri, &test_results[pos].fmri) { + assert_eq!(dep_fmri.to_string(), test_fmri.to_string()); + } else { + assert_eq!(dependency.fmri.is_none(), test_results[pos].fmri.is_none()); + } + assert_eq!( dependency.dependency_type, test_results[pos].dependency_type diff --git a/libips/src/repository/file_backend.rs b/libips/src/repository/file_backend.rs index 48d11ca..c34d8fa 100644 --- a/libips/src/repository/file_backend.rs +++ b/libips/src/repository/file_backend.rs @@ -18,6 +18,7 @@ use regex::Regex; use crate::actions::{Manifest, File as FileAction}; use crate::digest::Digest; +use crate::fmri::Fmri; use crate::payload::{Payload, PayloadCompressionAlgorithm}; use super::{Repository, RepositoryConfig, RepositoryVersion, REPOSITORY_CONFIG_FILENAME, PublisherInfo, RepositoryInfo, PackageInfo}; @@ -520,51 +521,51 @@ impl Repository for FileBackend { if attr.key == "pkg.fmri" && !attr.values.is_empty() { let fmri = &attr.values[0]; - // Parse the FMRI to extract package name and version - // Format: pkg://publisher/package_name@version - if let Some(pkg_part) = fmri.strip_prefix("pkg://") { - if let Some(at_pos) = pkg_part.find('@') { - let pkg_with_pub = &pkg_part[0..at_pos]; - let version = &pkg_part[at_pos+1..]; - - // Extract package name (may include publisher) - let pkg_name = if let Some(slash_pos) = pkg_with_pub.find('/') { - // Skip publisher part if present - let pub_end = slash_pos + 1; - &pkg_with_pub[pub_end..] - } else { - pkg_with_pub - }; - + // Parse the FMRI using our Fmri type + match Fmri::parse(fmri) { + Ok(parsed_fmri) => { // Filter by pattern if specified if let Some(pat) = pattern { // Try to compile the pattern as a regex match Regex::new(pat) { Ok(regex) => { // Use regex matching - if !regex.is_match(pkg_name) { + if !regex.is_match(&parsed_fmri.name) { continue; } }, Err(err) => { // Log the error but fall back to simple string contains eprintln!("Error compiling regex pattern '{}': {}", pat, err); - if !pkg_name.contains(pat) { + if !parsed_fmri.name.contains(pat) { continue; } } } } - // Create a PackageInfo struct and add it to the list - packages.push(PackageInfo { - name: pkg_name.to_string(), - version: version.to_string(), - publisher: pub_name.clone(), - }); + // If the publisher is not set in the FMRI, use the current publisher + if parsed_fmri.publisher.is_none() { + let mut fmri_with_publisher = parsed_fmri.clone(); + fmri_with_publisher.publisher = Some(pub_name.clone()); + + // Create a PackageInfo struct and add it to the list + packages.push(PackageInfo { + fmri: fmri_with_publisher, + }); + } else { + // Create a PackageInfo struct and add it to the list + packages.push(PackageInfo { + fmri: parsed_fmri.clone(), + }); + } // Found the package info, no need to check other attributes break; + }, + Err(err) => { + // Log the error but continue processing + eprintln!("Error parsing FMRI '{}': {}", fmri, err); } } } @@ -597,10 +598,17 @@ impl Repository for FileBackend { let mut contents = Vec::new(); for pkg_info in packages { + // Format the package identifier using the FMRI + let pkg_id = if let Some(version) = &pkg_info.fmri.version { + format!("{}@{}", pkg_info.fmri.name, version) + } else { + pkg_info.fmri.name.clone() + }; + // Example content data (package, path, type) let example_contents = vec![ - (format!("{}@{}", pkg_info.name, pkg_info.version), "/usr/bin/example".to_string(), "file".to_string()), - (format!("{}@{}", pkg_info.name, pkg_info.version), "/usr/share/doc/example".to_string(), "dir".to_string()), + (pkg_id.clone(), "/usr/bin/example".to_string(), "file".to_string()), + (pkg_id.clone(), "/usr/share/doc/example".to_string(), "dir".to_string()), ]; // Filter by action type if specified diff --git a/libips/src/repository/mod.rs b/libips/src/repository/mod.rs index e6752e9..3cf0c49 100644 --- a/libips/src/repository/mod.rs +++ b/libips/src/repository/mod.rs @@ -39,12 +39,8 @@ pub struct RepositoryInfo { /// Information about a package in a repository #[derive(Debug, Clone, PartialEq, Eq)] pub struct PackageInfo { - /// Name of the package - pub name: String, - /// Version of the package - pub version: String, - /// Publisher of the package - pub publisher: String, + /// FMRI (Fault Management Resource Identifier) of the package + pub fmri: crate::fmri::Fmri, } /// Repository version diff --git a/libips/src/repository/rest_backend.rs b/libips/src/repository/rest_backend.rs index 4a37fcb..39b8afa 100644 --- a/libips/src/repository/rest_backend.rs +++ b/libips/src/repository/rest_backend.rs @@ -211,10 +211,17 @@ impl Repository for RestBackend { for pkg_info in packages { // In a real implementation, we would get this information from the REST API + // Format the package identifier using the FMRI + let pkg_id = if let Some(version) = &pkg_info.fmri.version { + format!("{}@{}", pkg_info.fmri.name, version) + } else { + pkg_info.fmri.name.clone() + }; + // Example content data (package, path, type) let example_contents = vec![ - (format!("{}@{}", pkg_info.name, pkg_info.version), "/usr/bin/example".to_string(), "file".to_string()), - (format!("{}@{}", pkg_info.name, pkg_info.version), "/usr/share/doc/example".to_string(), "dir".to_string()), + (pkg_id.clone(), "/usr/bin/example".to_string(), "file".to_string()), + (pkg_id.clone(), "/usr/share/doc/example".to_string(), "dir".to_string()), ]; // Filter by action type if specified diff --git a/pkg6repo/src/main.rs b/pkg6repo/src/main.rs index 118fd59..4d3b9aa 100644 --- a/pkg6repo/src/main.rs +++ b/pkg6repo/src/main.rs @@ -378,7 +378,18 @@ fn main() -> Result<()> { // Print packages for pkg_info in packages { - println!("{:<30} {:<15} {:<10}", pkg_info.name, pkg_info.version, pkg_info.publisher); + // Format version and publisher, handling optional fields + let version_str = match &pkg_info.fmri.version { + Some(version) => version.to_string(), + None => String::new(), + }; + + let publisher_str = match &pkg_info.fmri.publisher { + Some(publisher) => publisher.clone(), + None => String::new(), + }; + + println!("{:<30} {:<15} {:<10}", pkg_info.fmri.name, version_str, publisher_str); } Ok(())