From e1c1385355778f6ffc7c046bf2399430222785d2 Mon Sep 17 00:00:00 2001 From: Peter Rekdal Khan-Sunde Date: Sat, 11 Apr 2026 12:57:47 +0200 Subject: [PATCH] feat: enforce strict SemVer 2.0 across Rust and .NET --- Cargo.lock | 5 +- Cargo.toml | 1 + crates/surge-cli/src/commands/demote.rs | 2 + crates/surge-cli/src/commands/pack.rs | 8 +- .../surge-cli/src/commands/pack/installers.rs | 1 + crates/surge-cli/src/commands/promote.rs | 4 +- crates/surge-cli/src/commands/push.rs | 80 +- crates/surge-cli/src/commands/setup.rs | 1 + crates/surge-core/Cargo.toml | 1 + crates/surge-core/src/config/installer.rs | 8 + crates/surge-core/src/install/activation.rs | 4 +- .../src/install/runtime_manifest.rs | 5 +- crates/surge-core/src/pack/builder.rs | 4 +- crates/surge-core/src/releases/channel.rs | 3 + crates/surge-core/src/releases/manifest.rs | 26 +- crates/surge-core/src/releases/restore.rs | 2 +- crates/surge-core/src/releases/version.rs | 270 +-- crates/surge-core/src/supervisor/stub.rs | 9 +- crates/surge-core/src/update/manager.rs | 16 +- crates/surge-ffi/src/update.rs | 15 +- crates/surge-installer-ui/src/main.rs | 1 + dotnet/Surge.NET.Tests/SurgeTests.cs | 100 +- dotnet/Surge.NET/SemanticVersions.cs | 43 + dotnet/Surge.NET/Semver.LICENSE.txt | 19 + .../Semver/Comparers/ISemVersionComparer.cs | 20 + .../Semver/Comparers/PrecedenceComparer.cs | 83 + .../Semver/Comparers/SortOrderComparer.cs | 69 + dotnet/Surge.NET/Semver/MetadataIdentifier.cs | 233 +++ .../Semver/Parsing/PrereleaseIdentifiers.cs | 13 + .../Semver/Parsing/SemVersionParser.cs | 459 +++++ .../Parsing/SemVersionParsingOptions.cs | 52 + .../Semver/Parsing/WildcardVersion.cs | 19 + .../Parsing/WildcardVersionExtensions.cs | 25 + .../Surge.NET/Semver/PrereleaseIdentifier.cs | 373 ++++ dotnet/Surge.NET/Semver/SemVersion.cs | 1703 +++++++++++++++++ .../Surge.NET/Semver/SemVersionDocParts.xml | 89 + dotnet/Surge.NET/Semver/SemVersionStyles.cs | 82 + .../Semver/SemVersionStylesExtensions.cs | 42 + .../Semver/Utility/CharExtensions.cs | 22 + .../Semver/Utility/CombinedHashCode.cs | 116 ++ .../Surge.NET/Semver/Utility/DebugChecks.cs | 104 + .../Semver/Utility/EnumerableExtensions.cs | 21 + .../ExcludeFromCodeCoverageAttribute.cs | 17 + .../Semver/Utility/IdentifierString.cs | 23 + .../Surge.NET/Semver/Utility/IntExtensions.cs | 35 + .../Semver/Utility/ListExtensions.cs | 17 + .../Surge.NET/Semver/Utility/ReadOnlyList.cs | 14 + .../Semver/Utility/StringExtensions.cs | 109 ++ .../Surge.NET/Semver/Utility/StringSegment.cs | 225 +++ .../Surge.NET/Semver/Utility/Unreachable.cs | 18 + .../Semver/Utility/UnsafeOverload.cs | 13 + .../Semver/Utility/VersionParsing.cs | 19 + dotnet/Surge.NET/Surge.NET.csproj | 6 + dotnet/Surge.NET/SurgeApp.cs | 68 +- dotnet/Surge.NET/SurgeAppInfo.cs | 6 +- dotnet/Surge.NET/SurgeRelease.cs | 6 +- dotnet/Surge.NET/SurgeUpdateManager.cs | 29 +- scripts/set-release-version.sh | 4 +- 58 files changed, 4491 insertions(+), 271 deletions(-) create mode 100644 dotnet/Surge.NET/SemanticVersions.cs create mode 100644 dotnet/Surge.NET/Semver.LICENSE.txt create mode 100644 dotnet/Surge.NET/Semver/Comparers/ISemVersionComparer.cs create mode 100644 dotnet/Surge.NET/Semver/Comparers/PrecedenceComparer.cs create mode 100644 dotnet/Surge.NET/Semver/Comparers/SortOrderComparer.cs create mode 100644 dotnet/Surge.NET/Semver/MetadataIdentifier.cs create mode 100644 dotnet/Surge.NET/Semver/Parsing/PrereleaseIdentifiers.cs create mode 100644 dotnet/Surge.NET/Semver/Parsing/SemVersionParser.cs create mode 100644 dotnet/Surge.NET/Semver/Parsing/SemVersionParsingOptions.cs create mode 100644 dotnet/Surge.NET/Semver/Parsing/WildcardVersion.cs create mode 100644 dotnet/Surge.NET/Semver/Parsing/WildcardVersionExtensions.cs create mode 100644 dotnet/Surge.NET/Semver/PrereleaseIdentifier.cs create mode 100644 dotnet/Surge.NET/Semver/SemVersion.cs create mode 100644 dotnet/Surge.NET/Semver/SemVersionDocParts.xml create mode 100644 dotnet/Surge.NET/Semver/SemVersionStyles.cs create mode 100644 dotnet/Surge.NET/Semver/SemVersionStylesExtensions.cs create mode 100644 dotnet/Surge.NET/Semver/Utility/CharExtensions.cs create mode 100644 dotnet/Surge.NET/Semver/Utility/CombinedHashCode.cs create mode 100644 dotnet/Surge.NET/Semver/Utility/DebugChecks.cs create mode 100644 dotnet/Surge.NET/Semver/Utility/EnumerableExtensions.cs create mode 100644 dotnet/Surge.NET/Semver/Utility/ExcludeFromCodeCoverageAttribute.cs create mode 100644 dotnet/Surge.NET/Semver/Utility/IdentifierString.cs create mode 100644 dotnet/Surge.NET/Semver/Utility/IntExtensions.cs create mode 100644 dotnet/Surge.NET/Semver/Utility/ListExtensions.cs create mode 100644 dotnet/Surge.NET/Semver/Utility/ReadOnlyList.cs create mode 100644 dotnet/Surge.NET/Semver/Utility/StringExtensions.cs create mode 100644 dotnet/Surge.NET/Semver/Utility/StringSegment.cs create mode 100644 dotnet/Surge.NET/Semver/Utility/Unreachable.cs create mode 100644 dotnet/Surge.NET/Semver/Utility/UnsafeOverload.cs create mode 100644 dotnet/Surge.NET/Semver/Utility/VersionParsing.cs diff --git a/Cargo.lock b/Cargo.lock index d71920e..8b168d9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2826,9 +2826,9 @@ checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" [[package]] name = "semver" -version = "1.0.27" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" @@ -3157,6 +3157,7 @@ dependencies = [ "percent-encoding", "quick-xml 0.39.2", "reqwest", + "semver", "serde", "serde_json", "serde_yaml", diff --git a/Cargo.toml b/Cargo.toml index 364b64c..828ae95 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ surge-core = { path = "crates/surge-core", version = "1.0.0" } serde = { version = "1.0", features = ["derive"] } serde_yaml = "0.9" serde_json = "1.0" +semver = "1.0.28" sha2 = "0.11" hex = "0.4" reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream", "json"] } diff --git a/crates/surge-cli/src/commands/demote.rs b/crates/surge-cli/src/commands/demote.rs index 1760530..5037502 100644 --- a/crates/surge-cli/src/commands/demote.rs +++ b/crates/surge-cli/src/commands/demote.rs @@ -8,6 +8,7 @@ use surge_core::config::constants::{DEFAULT_ZSTD_LEVEL, RELEASES_FILE_COMPRESSED use surge_core::config::manifest::SurgeManifest; use surge_core::error::{Result, SurgeError}; use surge_core::releases::manifest::{compress_release_index, decompress_release_index}; +use surge_core::releases::version::canonicalize_version; use surge_core::storage::{self, StorageBackend}; /// Demote (remove) a release version from a channel. @@ -19,6 +20,7 @@ pub async fn execute( channel: &str, ) -> Result<()> { const TOTAL_STAGES: usize = 4; + let version = canonicalize_version(version, "release version")?; let theme = UiTheme::global(); let started = Instant::now(); diff --git a/crates/surge-cli/src/commands/pack.rs b/crates/surge-cli/src/commands/pack.rs index d270935..4184b38 100644 --- a/crates/surge-cli/src/commands/pack.rs +++ b/crates/surge-cli/src/commands/pack.rs @@ -35,6 +35,7 @@ use surge_core::releases::artifact_cache::{CacheFetchOutcome, fetch_or_reuse_fil use surge_core::releases::restore::{ RestoreOptions, plan_full_archive_restore, restore_full_archive_for_version_with_options, }; +use surge_core::releases::version::canonicalize_version; use surge_core::storage_config::build_storage_config; /// Build release packages (full + delta) for a given app version and RID. @@ -47,6 +48,7 @@ pub async fn execute( output_dir: &Path, ) -> Result<()> { const TOTAL_STAGES: usize = 5; + let version = canonicalize_version(version, "release version")?; let theme = UiTheme::global(); let started = Instant::now(); @@ -62,7 +64,7 @@ pub async fn execute( print_stage(theme, 2, TOTAL_STAGES, "Validating artifacts and output directories"); let artifacts_dir = artifacts_dir.map_or_else( - || default_artifacts_dir(manifest_path, &app_id, &rid, version), + || default_artifacts_dir(manifest_path, &app_id, &rid, &version), PathBuf::from, ); if !artifacts_dir.is_dir() { @@ -96,7 +98,7 @@ pub async fn execute( )) })?; - let mut builder = PackBuilder::new(ctx, manifest_path_s, &app_id, &rid, version, artifacts_dir_s)?; + let mut builder = PackBuilder::new(ctx, manifest_path_s, &app_id, &rid, &version, artifacts_dir_s)?; let build_started = Instant::now(); let build_running = Arc::new(AtomicBool::new(true)); let build_step = Arc::new(AtomicI32::new(0)); @@ -168,7 +170,7 @@ pub async fn execute( &target, &app_id, &rid, - version, + &version, &self::resolution::default_channel_for_app(&manifest, app), manifest_path.parent().unwrap_or_else(|| Path::new(".")), artifacts_dir.as_path(), diff --git a/crates/surge-cli/src/commands/pack/installers.rs b/crates/surge-cli/src/commands/pack/installers.rs index 645d063..9c1b792 100644 --- a/crates/surge-cli/src/commands/pack/installers.rs +++ b/crates/surge-cli/src/commands/pack/installers.rs @@ -193,6 +193,7 @@ pub(super) fn build_installers_with_launcher( environment: target.environment.clone(), }, }; + manifest_payload.validate()?; let manifest_yaml = serde_yaml::to_string(&manifest_payload)?; std::fs::write(staging.join("installer.yml"), manifest_yaml.as_bytes())?; diff --git a/crates/surge-cli/src/commands/promote.rs b/crates/surge-cli/src/commands/promote.rs index 2567f98..28c0a4a 100644 --- a/crates/surge-cli/src/commands/promote.rs +++ b/crates/surge-cli/src/commands/promote.rs @@ -8,6 +8,7 @@ use surge_core::config::constants::{DEFAULT_ZSTD_LEVEL, RELEASES_FILE_COMPRESSED use surge_core::config::manifest::SurgeManifest; use surge_core::error::{Result, SurgeError}; use surge_core::releases::manifest::compress_release_index; +use surge_core::releases::version::canonicalize_version; use surge_core::storage; /// Promote a release version to a target channel. @@ -19,6 +20,7 @@ pub async fn execute( channel: &str, ) -> Result<()> { const TOTAL_STAGES: usize = 5; + let version = canonicalize_version(version, "release version")?; let theme = UiTheme::global(); let started = Instant::now(); @@ -71,7 +73,7 @@ pub async fn execute( } print_stage(theme, 3, TOTAL_STAGES, "Ensuring release full artifact exists"); - let full_materialized = super::ensure_release_full_artifact(&*backend, &index, &rid, version).await?; + let full_materialized = super::ensure_release_full_artifact(&*backend, &index, &rid, &version).await?; print_stage_done( theme, 3, diff --git a/crates/surge-cli/src/commands/push.rs b/crates/surge-cli/src/commands/push.rs index 8d3dfdb..16980d8 100644 --- a/crates/surge-cli/src/commands/push.rs +++ b/crates/surge-cli/src/commands/push.rs @@ -27,7 +27,9 @@ use surge_core::releases::manifest::{ DeltaArtifact, PATCH_FORMAT_BSDIFF4, PATCH_FORMAT_CHUNKED_BSDIFF_V1, PATCH_FORMAT_SPARSE_FILE_OPS_V1, ReleaseEntry, ReleaseIndex, compress_release_index, decompress_release_index, }; +use surge_core::releases::restore::find_previous_release_for_rid; use surge_core::releases::restore::required_artifacts_for_index; +use surge_core::releases::version::canonicalize_version; use surge_core::releases::version::compare_versions; use surge_core::storage::{self, StorageBackend}; @@ -41,6 +43,7 @@ pub async fn execute( packages_dir: &Path, ) -> Result<()> { const TOTAL_STAGES: usize = 5; + let version = canonicalize_version(version, "release version")?; let theme = UiTheme::global(); let started = Instant::now(); @@ -159,7 +162,7 @@ pub async fn execute( let pruned = update_release_index( &*backend, &app_id, - version, + &version, &rid, channel, full_filename, @@ -290,6 +293,19 @@ async fn update_release_index( .releases .iter() .any(|release| release.rid == rid || release.rid.is_empty()); + let delta_from_version = if delta_filename.trim().is_empty() { + None + } else { + Some( + find_previous_release_for_rid(&index, rid, version) + .map(|release| release.version.clone()) + .ok_or_else(|| { + SurgeError::Config(format!( + "Cannot publish delta artifact for {app_id}/{rid} v{version} without a previous release baseline" + )) + })?, + ) + }; index .releases @@ -318,41 +334,43 @@ async fn update_release_index( installers, environment, }; - let primary_delta = if delta_filename.trim().is_empty() { - None - } else if delta_patch_format.eq_ignore_ascii_case(PATCH_FORMAT_SPARSE_FILE_OPS_V1) { - Some(DeltaArtifact::sparse_file_ops_zstd( - "primary", - "", - &delta_filename, - delta_size, - &delta_sha256, - )) - } else if delta_patch_format.eq_ignore_ascii_case(PATCH_FORMAT_CHUNKED_BSDIFF_V1) { - Some(DeltaArtifact::chunked_bsdiff_zstd( - "primary", - "", - &delta_filename, - delta_size, - &delta_sha256, - )) - } else if delta_patch_format.eq_ignore_ascii_case(PATCH_FORMAT_BSDIFF4) { - Some(DeltaArtifact::bsdiff_zstd( - "primary", - "", - &delta_filename, - delta_size, - &delta_sha256, - )) - } else { - Some(DeltaArtifact::with_patch_format( + let primary_delta = match delta_from_version.as_deref() { + None => None, + Some(delta_from_version) if delta_patch_format.eq_ignore_ascii_case(PATCH_FORMAT_SPARSE_FILE_OPS_V1) => { + Some(DeltaArtifact::sparse_file_ops_zstd( + "primary", + delta_from_version, + &delta_filename, + delta_size, + &delta_sha256, + )) + } + Some(delta_from_version) if delta_patch_format.eq_ignore_ascii_case(PATCH_FORMAT_CHUNKED_BSDIFF_V1) => { + Some(DeltaArtifact::chunked_bsdiff_zstd( + "primary", + delta_from_version, + &delta_filename, + delta_size, + &delta_sha256, + )) + } + Some(delta_from_version) if delta_patch_format.eq_ignore_ascii_case(PATCH_FORMAT_BSDIFF4) => { + Some(DeltaArtifact::bsdiff_zstd( + "primary", + delta_from_version, + &delta_filename, + delta_size, + &delta_sha256, + )) + } + Some(delta_from_version) => Some(DeltaArtifact::with_patch_format( "primary", - "", + delta_from_version, &delta_patch_format, &delta_filename, delta_size, &delta_sha256, - )) + )), }; entry.set_primary_delta(primary_delta); index.releases.push(entry); diff --git a/crates/surge-cli/src/commands/setup.rs b/crates/surge-cli/src/commands/setup.rs index 7c72619..880e860 100644 --- a/crates/surge-cli/src/commands/setup.rs +++ b/crates/surge-cli/src/commands/setup.rs @@ -120,6 +120,7 @@ pub async fn execute(dir: &Path, no_start: bool, stage: bool) -> Result<()> { let manifest_bytes = std::fs::read(&manifest_path)?; let manifest: InstallerManifest = serde_yaml::from_slice(&manifest_bytes)?; + manifest.validate()?; logline::info(&format!( "Setting up {} v{} ({}/{})", diff --git a/crates/surge-core/Cargo.toml b/crates/surge-core/Cargo.toml index 5c352b3..3fc4982 100644 --- a/crates/surge-core/Cargo.toml +++ b/crates/surge-core/Cargo.toml @@ -36,6 +36,7 @@ quick-xml = { workspace = true } async-trait = { workspace = true } futures-util = { workspace = true } tempfile = { workspace = true } +semver = { workspace = true } [target.'cfg(unix)'.dependencies] nix = { version = "0.31", features = ["signal", "process", "fs"] } diff --git a/crates/surge-core/src/config/installer.rs b/crates/surge-core/src/config/installer.rs index 392377e..5740aae 100644 --- a/crates/surge-core/src/config/installer.rs +++ b/crates/surge-core/src/config/installer.rs @@ -3,6 +3,8 @@ use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; use crate::config::manifest::ShortcutLocation; +use crate::error::Result; +use crate::releases::version::validate_version_string; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -76,3 +78,9 @@ pub struct InstallerRuntime { #[serde(default)] pub environment: BTreeMap, } + +impl InstallerManifest { + pub fn validate(&self) -> Result<()> { + validate_version_string(&self.version, "installer manifest version") + } +} diff --git a/crates/surge-core/src/install/activation.rs b/crates/surge-core/src/install/activation.rs index 99b21e3..efa103b 100644 --- a/crates/surge-core/src/install/activation.rs +++ b/crates/surge-core/src/install/activation.rs @@ -7,7 +7,7 @@ use crate::error::{Result, SurgeError}; use crate::platform::fs::list_directories; use crate::platform::paths::default_install_root; use crate::platform::shortcuts::install_shortcuts; -use crate::releases::version::compare_versions; +use crate::releases::version::{compare_versions, is_valid_version_string}; use crate::supervisor::stub::find_latest_app_dir; use super::persistent_assets::copy_persistent_assets; @@ -211,7 +211,7 @@ pub fn install_package_locally_at_root_with_progress( fn app_snapshot_version(dir_name: &str) -> Option<&str> { let version = dir_name.strip_prefix("app-")?; - if version.is_empty() || !version.chars().next().is_some_and(|c| c.is_ascii_digit()) { + if !is_valid_version_string(version) { return None; } Some(version) diff --git a/crates/surge-core/src/install/runtime_manifest.rs b/crates/surge-core/src/install/runtime_manifest.rs index 346e9f8..d9d1eb5 100644 --- a/crates/surge-core/src/install/runtime_manifest.rs +++ b/crates/surge-core/src/install/runtime_manifest.rs @@ -4,6 +4,7 @@ use serde::Serialize; use crate::context::StorageProvider; use crate::error::{Result, SurgeError}; +use crate::releases::version::canonicalize_version; use super::InstallProfile; @@ -125,9 +126,11 @@ pub fn write_runtime_manifest( profile: &InstallProfile<'_>, metadata: &RuntimeManifestMetadata<'_>, ) -> Result { + let version = canonicalize_version(metadata.version, "runtime manifest version")?; + let manifest = RuntimeManifestFile { id: profile.app_id.trim(), - version: metadata.version.trim(), + version: &version, channel: metadata.channel.trim(), install_directory: profile.install_directory.trim(), supervisor_id: profile.supervisor_id.trim(), diff --git a/crates/surge-core/src/pack/builder.rs b/crates/surge-core/src/pack/builder.rs index 523e287..c676b48 100644 --- a/crates/surge-core/src/pack/builder.rs +++ b/crates/surge-core/src/pack/builder.rs @@ -17,6 +17,7 @@ use crate::config::manifest::{PackPolicy, ShortcutLocation, SurgeManifest}; use crate::context::Context; use crate::error::{Result, SurgeError}; use crate::platform::fs::write_file_atomic; +use crate::releases::version::canonicalize_version; use crate::storage::{StorageBackend, create_storage_backend}; pub(crate) use self::staging::build_canonical_archive_from_directory; @@ -114,6 +115,7 @@ impl PackBuilder { version: &str, artifacts_dir: &str, ) -> Result { + let version = canonicalize_version(version, "release version")?; let manifest = SurgeManifest::from_file(Path::new(manifest_path))?; let pack_policy = manifest.effective_pack_policy(); let (app, target) = manifest @@ -157,7 +159,7 @@ impl PackBuilder { ctx, app_id: app_id.to_string(), rid: rid.to_string(), - version: version.to_string(), + version, name: app.effective_name(), main_exe, install_directory: app.effective_install_directory(), diff --git a/crates/surge-core/src/releases/channel.rs b/crates/surge-core/src/releases/channel.rs index 9340ee5..077a32d 100644 --- a/crates/surge-core/src/releases/channel.rs +++ b/crates/surge-core/src/releases/channel.rs @@ -6,6 +6,7 @@ use crate::config::constants::RELEASES_FILE_COMPRESSED; use crate::context::Context; use crate::error::{Result, SurgeError}; use crate::releases::manifest::{ReleaseEntry, ReleaseIndex, compress_release_index, decompress_release_index}; +use crate::releases::version::canonicalize_version; /// Manages release channels: fetching, saving, promoting, and demoting releases. pub struct ChannelManager { @@ -46,6 +47,7 @@ impl ChannelManager { /// not on the source channel. pub async fn promote(&self, version: &str, source_channel: &str, target_channel: &str) -> Result<()> { self.ctx.check_cancelled()?; + let version = canonicalize_version(version, "release version")?; let mut index = self.fetch_index().await?; let mut found = false; @@ -82,6 +84,7 @@ impl ChannelManager { /// if the version is not found or not on the channel. pub async fn demote(&self, version: &str, channel: &str) -> Result<()> { self.ctx.check_cancelled()?; + let version = canonicalize_version(version, "release version")?; let mut index = self.fetch_index().await?; let mut found = false; diff --git a/crates/surge-core/src/releases/manifest.rs b/crates/surge-core/src/releases/manifest.rs index 53137f9..4f6d7ad 100644 --- a/crates/surge-core/src/releases/manifest.rs +++ b/crates/surge-core/src/releases/manifest.rs @@ -5,7 +5,7 @@ use std::collections::BTreeMap; use crate::config::manifest::ShortcutLocation; use crate::error::{Result, SurgeError}; -use crate::releases::version::compare_versions; +use crate::releases::version::{compare_versions, validate_version_string}; pub const DIFF_ALGORITHM_BSDIFF: &str = "bsdiff"; pub const DIFF_ALGORITHM_FILE_OPS: &str = "file-ops"; @@ -186,6 +186,16 @@ pub struct ReleaseEntry { } impl ReleaseEntry { + fn validate_versions(&self) -> Result<()> { + validate_version_string(&self.version, "release version")?; + + for delta in &self.deltas { + validate_version_string(&delta.from_version, "delta base version")?; + } + + Ok(()) + } + /// Returns the best display name, falling back through `name → main_exe → app_id`. #[must_use] pub fn display_name<'a>(&'a self, app_id: &'a str) -> &'a str { @@ -280,14 +290,26 @@ impl Default for ReleaseIndex { } } +impl ReleaseIndex { + fn validate_versions(&self) -> Result<()> { + for release in &self.releases { + release.validate_versions()?; + } + + Ok(()) + } +} + /// Parse a release index from YAML bytes. pub fn parse_release_index(data: &[u8]) -> Result { let index: ReleaseIndex = serde_yaml::from_slice(data)?; + index.validate_versions()?; Ok(index) } /// Serialize a release index to YAML bytes. pub fn serialize_release_index(index: &ReleaseIndex) -> Result> { + index.validate_versions()?; let yaml = serde_yaml::to_string(index)?; Ok(yaml.into_bytes()) } @@ -386,7 +408,7 @@ mod tests { let delta = if has_delta { Some(DeltaArtifact::bsdiff_zstd( "primary", - "", + "0.0.0", &format!("app-{version}-delta.tar.zst"), 200, "def456", diff --git a/crates/surge-core/src/releases/restore.rs b/crates/surge-core/src/releases/restore.rs index d3310d4..52d4022 100644 --- a/crates/surge-core/src/releases/restore.rs +++ b/crates/surge-core/src/releases/restore.rs @@ -62,7 +62,7 @@ mod tests { full_sha256: String::new(), deltas: vec![DeltaArtifact::bsdiff_zstd( "primary", - "", + "0.0.0", &format!("demo-{version}-linux-x64-delta.tar.zst"), 0, "", diff --git a/crates/surge-core/src/releases/version.rs b/crates/surge-core/src/releases/version.rs index 639828d..638397b 100644 --- a/crates/surge-core/src/releases/version.rs +++ b/crates/surge-core/src/releases/version.rs @@ -1,130 +1,57 @@ -//! Semantic version comparison for dotted-integer version strings. +//! Strict Semantic Versioning 2.0 parsing and comparison helpers. use std::cmp::Ordering; -#[derive(Debug, Clone, PartialEq, Eq)] -enum PrereleaseIdentifier<'a> { - Numeric(u64), - Text(&'a str), -} +use semver::Version; -#[derive(Debug, Clone, PartialEq, Eq)] -struct ParsedVersion<'a> { - core: Vec, - prerelease: Option>>, -} +use crate::error::{Result, SurgeError}; -fn parse_core_version(version: &str) -> Vec { - let parts: Vec = version - .split('.') - .filter_map(|p| p.trim().parse::().ok()) - .collect(); +fn parse_semver(version: &str, label: &str) -> Result { + if version.is_empty() { + return Err(SurgeError::Config(format!("{label} cannot be empty"))); + } - let mut result = parts; - while result.len() < 3 { - result.push(0); + if version.trim() != version { + return Err(SurgeError::Config(format!( + "{label} must not contain leading or trailing whitespace: '{version}'" + ))); } - result + + Version::parse(version) + .map_err(|error| SurgeError::Config(format!("Invalid {label} semantic version '{version}': {error}"))) } -fn parse_prerelease(version: &str) -> Option>> { - let (_, raw_prerelease) = version.split_once('-')?; - let identifiers = raw_prerelease - .split('.') - .filter(|segment| !segment.is_empty()) - .map(|segment| { - segment - .parse::() - .map_or(PrereleaseIdentifier::Text(segment), PrereleaseIdentifier::Numeric) - }) - .collect::>(); - - if identifiers.is_empty() { - None - } else { - Some(identifiers) +fn parse_semver_for_compare(version: &str) -> Version { + match parse_semver(version, "version") { + Ok(version) => version, + Err(error) => panic!("internal semantic-version invariant violated: {error}"), } } -fn parse_version(version: &str) -> ParsedVersion<'_> { - let without_build_metadata = version.split_once('+').map_or(version, |(base, _)| base); - let core = without_build_metadata - .split_once('-') - .map_or(without_build_metadata, |(base, _)| base); - - ParsedVersion { - core: parse_core_version(core), - prerelease: parse_prerelease(without_build_metadata), - } +/// Parse and validate a strict Semantic Versioning 2.0 version string. +pub fn validate_version_string(version: &str, label: &str) -> Result<()> { + parse_semver(version, label).map(|_| ()) } -fn compare_prerelease_identifiers(a: &[PrereleaseIdentifier<'_>], b: &[PrereleaseIdentifier<'_>]) -> Ordering { - let max_len = a.len().max(b.len()); - - for i in 0..max_len { - match (a.get(i), b.get(i)) { - (Some(PrereleaseIdentifier::Numeric(lhs)), Some(PrereleaseIdentifier::Numeric(rhs))) => { - let ordering = lhs.cmp(rhs); - if ordering != Ordering::Equal { - return ordering; - } - } - (Some(PrereleaseIdentifier::Text(lhs)), Some(PrereleaseIdentifier::Text(rhs))) => { - let ordering = lhs.cmp(rhs); - if ordering != Ordering::Equal { - return ordering; - } - } - (Some(PrereleaseIdentifier::Numeric(_)), Some(PrereleaseIdentifier::Text(_))) | (None, Some(_)) => { - return Ordering::Less; - } - (Some(PrereleaseIdentifier::Text(_)), Some(PrereleaseIdentifier::Numeric(_))) => { - return Ordering::Greater; - } - (Some(_), None) => return Ordering::Greater, - (None, None) => return Ordering::Equal, - } - } +/// Parse and canonicalize a strict Semantic Versioning 2.0 version string. +pub fn canonicalize_version(version: &str, label: &str) -> Result { + parse_semver(version, label).map(|parsed| parsed.to_string()) +} - Ordering::Equal +/// Return whether the provided string is a valid strict Semantic Versioning 2.0 value. +#[must_use] +pub fn is_valid_version_string(version: &str) -> bool { + parse_semver(version, "version").is_ok() } -/// Compare two dotted-integer version strings. -/// -/// Parses "major.minor.patch" format, filling missing parts with 0. -/// Supports versions with more than 3 parts (e.g., "1.2.3.4"). -/// -/// # Examples +/// Compare two validated semantic versions by precedence. /// -/// ``` -/// use surge_core::releases::version::compare_versions; -/// use std::cmp::Ordering; -/// -/// assert_eq!(compare_versions("1.2.3", "1.2.3"), Ordering::Equal); -/// assert_eq!(compare_versions("2.0.0", "1.9.9"), Ordering::Greater); -/// assert_eq!(compare_versions("1.0", "1.0.0"), Ordering::Equal); -/// ``` +/// This ignores build metadata as required by SemVer 2.0 precedence rules. #[must_use] pub fn compare_versions(a: &str, b: &str) -> Ordering { - let parts_a = parse_version(a); - let parts_b = parse_version(b); - - let max_len = parts_a.core.len().max(parts_b.core.len()); - - for i in 0..max_len { - let va = parts_a.core.get(i).copied().unwrap_or(0); - let vb = parts_b.core.get(i).copied().unwrap_or(0); - if va != vb { - return va.cmp(&vb); - } - } - - match (&parts_a.prerelease, &parts_b.prerelease) { - (None, None) => Ordering::Equal, - (None, Some(_)) => Ordering::Greater, - (Some(_), None) => Ordering::Less, - (Some(lhs), Some(rhs)) => compare_prerelease_identifiers(lhs, rhs), - } + let left = parse_semver_for_compare(a); + let right = parse_semver_for_compare(b); + left.cmp_precedence(&right) } #[cfg(test)] @@ -132,91 +59,88 @@ mod tests { use super::*; #[test] - fn test_equal_versions() { - assert_eq!(compare_versions("1.0.0", "1.0.0"), Ordering::Equal); - assert_eq!(compare_versions("0.0.0", "0.0.0"), Ordering::Equal); - assert_eq!(compare_versions("10.20.30", "10.20.30"), Ordering::Equal); - } - - #[test] - fn test_greater_major() { - assert_eq!(compare_versions("2.0.0", "1.0.0"), Ordering::Greater); - assert_eq!(compare_versions("10.0.0", "9.0.0"), Ordering::Greater); - } - - #[test] - fn test_greater_minor() { - assert_eq!(compare_versions("1.2.0", "1.1.0"), Ordering::Greater); - assert_eq!(compare_versions("1.10.0", "1.9.0"), Ordering::Greater); - } - - #[test] - fn test_greater_patch() { - assert_eq!(compare_versions("1.0.2", "1.0.1"), Ordering::Greater); - assert_eq!(compare_versions("1.0.10", "1.0.9"), Ordering::Greater); - } - - #[test] - fn test_less_than() { - assert_eq!(compare_versions("1.0.0", "2.0.0"), Ordering::Less); - assert_eq!(compare_versions("1.0.0", "1.1.0"), Ordering::Less); - assert_eq!(compare_versions("1.0.0", "1.0.1"), Ordering::Less); - } - - #[test] - fn test_missing_parts_filled_with_zero() { - assert_eq!(compare_versions("1", "1.0.0"), Ordering::Equal); - assert_eq!(compare_versions("1.2", "1.2.0"), Ordering::Equal); - assert_eq!(compare_versions("1", "1.0.1"), Ordering::Less); + fn compare_versions_follows_semver_precedence_examples() { + let ordered = [ + "1.0.0-alpha", + "1.0.0-alpha.1", + "1.0.0-alpha.beta", + "1.0.0-beta", + "1.0.0-beta.2", + "1.0.0-beta.11", + "1.0.0-rc.1", + "1.0.0", + ]; + + for pair in ordered.windows(2) { + assert_eq!(compare_versions(pair[0], pair[1]), Ordering::Less); + } } #[test] - fn test_four_part_versions() { - assert_eq!(compare_versions("1.0.0.1", "1.0.0.0"), Ordering::Greater); - assert_eq!(compare_versions("1.0.0", "1.0.0.0"), Ordering::Equal); - assert_eq!(compare_versions("1.0.0.1", "1.0.0.2"), Ordering::Less); + fn compare_versions_ignores_build_metadata() { + assert_eq!(compare_versions("1.2.3+build.1", "1.2.3+build.9"), Ordering::Equal); + assert_eq!( + compare_versions("1.2.3-beta.1+build.1", "1.2.3-beta.1"), + Ordering::Equal + ); } #[test] - fn test_complex_comparisons() { - assert_eq!(compare_versions("2.0.0", "1.9.9"), Ordering::Greater); - assert_eq!(compare_versions("1.0.0", "0.99.99"), Ordering::Greater); - assert_eq!(compare_versions("0.0.1", "0.0.0"), Ordering::Greater); + fn compare_versions_treats_release_as_newer_than_matching_prerelease() { + assert_eq!( + compare_versions("2859.0.0", "2859.0.0-prerelease.56"), + Ordering::Greater + ); + assert_eq!(compare_versions("2859.0.0-prerelease.56", "2859.0.0"), Ordering::Less); } #[test] - fn test_large_version_numbers() { - assert_eq!(compare_versions("100.200.300", "100.200.300"), Ordering::Equal); - assert_eq!(compare_versions("100.200.301", "100.200.300"), Ordering::Greater); + fn validate_version_string_accepts_strict_semver_values() { + for version in [ + "0.0.0", + "1.2.3", + "10.20.30", + "1.0.0-alpha", + "1.0.0-alpha.1", + "1.0.0-0A.is.legal", + "1.0.0+build.1", + "1.0.0-alpha+build.1", + ] { + validate_version_string(version, "version").unwrap(); + } } #[test] - fn test_stable_release_is_newer_than_matching_prerelease() { - assert_eq!( - compare_versions("2859.0.0", "2859.0.0-prerelease.56"), - Ordering::Greater - ); - assert_eq!(compare_versions("2859.0.0-prerelease.56", "2859.0.0"), Ordering::Less); + fn validate_version_string_rejects_non_compliant_inputs() { + for version in [ + "", + "1", + "1.2", + "1.2.3.4", + "01.2.3", + "1.02.3", + "1.2.03", + "1.2.3-01", + "1.2.3-alpha..1", + "1.2.3+meta+meta", + " 1.2.3", + "1.2.3 ", + ] { + assert!(validate_version_string(version, "version").is_err(), "{version}"); + } } #[test] - fn test_prerelease_versions_compare_by_numeric_suffix() { + fn canonicalize_version_preserves_valid_semver_shape() { assert_eq!( - compare_versions("2859.0.0-prerelease.56", "2859.0.0-prerelease.55"), - Ordering::Greater - ); - assert_eq!( - compare_versions("2859.0.0-prerelease.54", "2859.0.0-prerelease.56"), - Ordering::Less + canonicalize_version("1.2.3-rc.1+build.5", "version").unwrap(), + "1.2.3-rc.1+build.5" ); } #[test] - fn test_build_metadata_does_not_affect_ordering() { - assert_eq!(compare_versions("1.2.3+build.1", "1.2.3+build.9"), Ordering::Equal); - assert_eq!( - compare_versions("1.2.3-beta.1+build.1", "1.2.3-beta.1"), - Ordering::Equal - ); + fn is_valid_version_string_reports_strict_semver_status() { + assert!(is_valid_version_string("1.2.3")); + assert!(!is_valid_version_string("1.2")); } } diff --git a/crates/surge-core/src/supervisor/stub.rs b/crates/surge-core/src/supervisor/stub.rs index e68333a..3fb8e66 100644 --- a/crates/surge-core/src/supervisor/stub.rs +++ b/crates/surge-core/src/supervisor/stub.rs @@ -4,7 +4,7 @@ use std::path::{Path, PathBuf}; use crate::error::{Result, SurgeError}; use crate::platform::fs::list_directories; -use crate::releases::version::compare_versions; +use crate::releases::version::{compare_versions, is_valid_version_string}; /// Resolve the active app directory under `install_dir`. /// @@ -24,12 +24,7 @@ pub fn find_latest_app_dir(install_dir: &Path) -> Result { for dir_name in &dirs { if let Some(version) = dir_name.strip_prefix("app-") { - if version.is_empty() { - continue; - } - - // Validate it looks like a version (starts with a digit) - if !version.chars().next().is_some_and(|c| c.is_ascii_digit()) { + if !is_valid_version_string(version) { continue; } diff --git a/crates/surge-core/src/update/manager.rs b/crates/surge-core/src/update/manager.rs index 8c851f2..279c881 100644 --- a/crates/surge-core/src/update/manager.rs +++ b/crates/surge-core/src/update/manager.rs @@ -23,6 +23,7 @@ use crate::platform::shortcuts::install_shortcuts; use crate::releases::artifact_cache::prune_cached_artifacts; use crate::releases::manifest::{ReleaseEntry, ReleaseIndex, decompress_release_index}; use crate::releases::restore::{local_checkpoint_artifacts_for_index, required_artifacts_for_index}; +use crate::releases::version::canonicalize_version; use crate::storage::{StorageBackend, create_storage_backend}; use crate::supervisor::state::supervisor_pid_file; @@ -91,6 +92,7 @@ impl UpdateManager { channel: &str, install_dir: &str, ) -> Result { + let current_version = canonicalize_version(current_version, "current version")?; let storage_cfg = ctx.storage_config(); if !storage_cfg.access_key.trim().is_empty() || !storage_cfg.secret_key.trim().is_empty() { return Err(SurgeError::Config( @@ -102,7 +104,7 @@ impl UpdateManager { Ok(Self { ctx, app_id: app_id.to_string(), - current_version: current_version.to_string(), + current_version, channel: channel.to_string(), release_retention_limit: DEFAULT_RELEASE_RETENTION_LIMIT, install_dir: PathBuf::from(install_dir), @@ -144,11 +146,7 @@ impl UpdateManager { /// Update the local version baseline used for update checks. pub fn set_current_version(&mut self, version: &str) -> Result<()> { - let normalized = version.trim(); - if normalized.is_empty() { - return Err(SurgeError::Config("Current version cannot be empty".to_string())); - } - self.current_version = normalized.to_string(); + self.current_version = canonicalize_version(version, "current version")?; self.cached_index = None; Ok(()) } @@ -501,7 +499,7 @@ mod tests { full_sha256: String::new(), deltas: vec![DeltaArtifact::bsdiff_zstd( "primary", - "", + "0.0.0", &format!("{version}-delta.tar.zst"), 100, "", @@ -630,7 +628,9 @@ mod tests { let err = manager.set_channel(" ").unwrap_err(); assert!(err.to_string().contains("channel cannot be empty")); let err = manager.set_current_version("").unwrap_err(); - assert!(err.to_string().contains("Current version cannot be empty")); + assert!(err.to_string().contains("current version cannot be empty")); + let err = manager.set_current_version("1.0").unwrap_err(); + assert!(err.to_string().contains("Invalid current version semantic version")); } #[test] diff --git a/crates/surge-ffi/src/update.rs b/crates/surge-ffi/src/update.rs index 5c2b4e5..5f2bf92 100644 --- a/crates/surge-ffi/src/update.rs +++ b/crates/surge-ffi/src/update.rs @@ -1,6 +1,7 @@ use std::ffi::{c_char, c_int, c_void}; use std::ptr; +use surge_core::releases::version::canonicalize_version; use surge_core::update::manager::{ProgressInfo, UpdateManager}; use crate::handles::{ReleaseEntryFfi, SurgeReleasesInfoHandle, SurgeUpdateManagerHandle}; @@ -45,6 +46,14 @@ pub unsafe extern "C" fn surge_update_manager_create( return ptr::null_mut(); } + let version_s = match canonicalize_version(&version_s, "current version") { + Ok(version) => version, + Err(error) => { + set_ctx_error(handle, &error); + return ptr::null_mut(); + } + }; + let mgr = Box::new(SurgeUpdateManagerHandle { ctx: handle.ctx.clone(), runtime: handle.runtime.clone(), @@ -118,13 +127,15 @@ pub unsafe extern "C" fn surge_update_manager_set_current_version( // SAFETY: `current_version` follows the nullable C string contract. let version_s = unsafe { cstr_to_string(current_version) }; - let version_s = version_s.trim().to_string(); if version_s.is_empty() { let e = surge_core::error::SurgeError::Config("current_version is required".into()); return set_shared_error(&mgr_ref.ctx, &mgr_ref.last_error, &e); } - mgr_ref.current_version = version_s; + mgr_ref.current_version = match canonicalize_version(&version_s, "current version") { + Ok(version) => version, + Err(error) => return set_shared_error(&mgr_ref.ctx, &mgr_ref.last_error, &error), + }; SURGE_OK })) } diff --git a/crates/surge-installer-ui/src/main.rs b/crates/surge-installer-ui/src/main.rs index b2943a2..6e1278a 100644 --- a/crates/surge-installer-ui/src/main.rs +++ b/crates/surge-installer-ui/src/main.rs @@ -53,6 +53,7 @@ fn run() -> Result<()> { })?; let manifest: InstallerManifest = serde_yaml::from_slice(&manifest_bytes) .map_err(|e| SurgeError::Config(format!("Failed to parse installer.yml: {e}")))?; + manifest.validate()?; if headless || !has_display() { return run_headless(&manifest, extracted.path(), simulator); diff --git a/dotnet/Surge.NET.Tests/SurgeTests.cs b/dotnet/Surge.NET.Tests/SurgeTests.cs index ff0e75f..fd055e8 100644 --- a/dotnet/Surge.NET.Tests/SurgeTests.cs +++ b/dotnet/Surge.NET.Tests/SurgeTests.cs @@ -1,8 +1,14 @@ using System; +using Semver; using Xunit; namespace Surge.Tests { + internal static class TestSemVersions + { + internal static SemVersion Parse(string value) => SemVersion.Parse(value, SemVersionStyles.Strict); + } + public class SurgeAppTests { private static readonly string[] FallbackCommandLineArgs = { "/opt/fallback" }; @@ -10,7 +16,7 @@ public class SurgeAppTests [Fact] public void Version_ReturnsExpectedVersion() { - Assert.Equal("0.1.0", SurgeApp.Version); + Assert.Equal("0.1.0", SurgeApp.Version.ToString()); } [Fact] @@ -101,7 +107,7 @@ public void DefaultValues_AreEmpty() { var info = new SurgeAppInfo(); Assert.Equal("", info.Id); - Assert.Equal("", info.Version); + Assert.Equal("0.0.0", info.Version.ToString()); Assert.Equal("", info.Channel); Assert.Equal("", info.InstallDirectory); Assert.Equal("", info.SupervisorId); @@ -118,7 +124,7 @@ public void Properties_CanBeSet() var info = new SurgeAppInfo { Id = "myapp", - Version = "1.2.3", + Version = TestSemVersions.Parse("1.2.3"), Channel = "stable", InstallDirectory = "/opt/myapp", SupervisorId = "myapp-supervisor", @@ -129,7 +135,7 @@ public void Properties_CanBeSet() }; Assert.Equal("myapp", info.Id); - Assert.Equal("1.2.3", info.Version); + Assert.Equal("1.2.3", info.Version.ToString()); Assert.Equal("stable", info.Channel); Assert.Equal("/opt/myapp", info.InstallDirectory); Assert.Equal("myapp-supervisor", info.SupervisorId); @@ -238,7 +244,7 @@ public class SurgeReleaseTests public void DefaultValues_AreEmpty() { var release = new SurgeRelease(); - Assert.Equal("", release.Version); + Assert.Equal("0.0.0", release.Version.ToString()); Assert.Equal("", release.Channel); Assert.Equal(0L, release.FullSize); Assert.Equal(0L, release.DeltaSize); @@ -251,7 +257,7 @@ public void Properties_CanBeSet() { var release = new SurgeRelease { - Version = "2.0.0", + Version = TestSemVersions.Parse("2.0.0"), Channel = "beta", FullSize = 1024 * 1024, DeltaSize = 256 * 1024, @@ -259,7 +265,7 @@ public void Properties_CanBeSet() IsGenesis = true }; - Assert.Equal("2.0.0", release.Version); + Assert.Equal("2.0.0", release.Version.ToString()); Assert.Equal("beta", release.Channel); Assert.Equal(1024 * 1024, release.FullSize); Assert.Equal(256 * 1024, release.DeltaSize); @@ -338,15 +344,89 @@ public void WithReleases_ReturnsLatest() { var list = new System.Collections.Generic.List { - new SurgeRelease { Version = "1.0.0", Channel = "stable" }, - new SurgeRelease { Version = "2.0.0", Channel = "stable" } + new SurgeRelease { Version = TestSemVersions.Parse("1.0.0"), Channel = "stable" }, + new SurgeRelease { Version = TestSemVersions.Parse("2.0.0"), Channel = "stable" } }; var releases = new SurgeChannelReleases("stable", list); Assert.Equal("stable", releases.Channel); Assert.Equal(2, releases.Count); Assert.NotNull(releases.Latest); - Assert.Equal("2.0.0", releases.Latest!.Version); + Assert.Equal("2.0.0", releases.Latest!.Version.ToString()); + } + } + + public class SemVersionIntegrationTests + { + [Theory] + [InlineData("0.0.0")] + [InlineData("1.2.3")] + [InlineData("1.0.0-alpha")] + [InlineData("1.0.0-alpha.1")] + [InlineData("1.0.0-0A.is.legal")] + [InlineData("1.0.0+build.5")] + [InlineData("1.0.0-rc.1+build.9")] + public void StrictParse_Accepts_CompliantSemVer(string value) + { + var version = SemVersion.Parse(value, SemVersionStyles.Strict); + Assert.Equal(value, version.ToString()); + } + + [Theory] + [InlineData("")] + [InlineData("1")] + [InlineData("1.2")] + [InlineData("1.2.3.4")] + [InlineData("01.2.3")] + [InlineData("1.02.3")] + [InlineData("1.2.03")] + [InlineData("1.2.3-01")] + [InlineData("1.2.3-alpha..1")] + [InlineData("1.2.3 ")] + [InlineData(" 1.2.3")] + public void StrictParse_Rejects_InvalidSemVer(string value) + { + Assert.False(SemVersion.TryParse(value, SemVersionStyles.Strict, out _)); + } + + [Fact] + public void ComparePrecedence_Follows_SemVerExamples() + { + string[] ordered = + { + "1.0.0-alpha", + "1.0.0-alpha.1", + "1.0.0-alpha.beta", + "1.0.0-beta", + "1.0.0-beta.2", + "1.0.0-beta.11", + "1.0.0-rc.1", + "1.0.0" + }; + + for (int i = 0; i < ordered.Length - 1; i++) + { + var left = SemVersion.Parse(ordered[i], SemVersionStyles.Strict); + var right = SemVersion.Parse(ordered[i + 1], SemVersionStyles.Strict); + Assert.True(left.ComparePrecedenceTo(right) < 0, $"{left} should precede {right}"); + } + } + + [Fact] + public void ComparePrecedence_Ignores_BuildMetadata() + { + var left = SemVersion.Parse("1.2.3-beta.1+build.1", SemVersionStyles.Strict); + var right = SemVersion.Parse("1.2.3-beta.1+build.9", SemVersionStyles.Strict); + + Assert.Equal(0, left.ComparePrecedenceTo(right)); + Assert.True(left.CompareSortOrderTo(right) < 0); + } + + [Fact] + public void ParseArgument_Rejects_InvalidPublicInput() + { + var ex = Assert.Throws(() => SemanticVersions.ParseArgument("1.2", "version")); + Assert.Contains("Semantic Versioning 2.0", ex.Message); } } } diff --git a/dotnet/Surge.NET/SemanticVersions.cs b/dotnet/Surge.NET/SemanticVersions.cs new file mode 100644 index 0000000..e7dbcba --- /dev/null +++ b/dotnet/Surge.NET/SemanticVersions.cs @@ -0,0 +1,43 @@ +using System; +using Semver; + +namespace Surge +{ + internal static class SemanticVersions + { + internal static readonly SemVersion Zero = new SemVersion(0, 0, 0); + internal static readonly SemVersion LibraryVersion = ParseArgument("0.1.0", "libraryVersion"); + + internal static SemVersion ParseArgument(string value, string paramName) + { + if (value == null) + throw new ArgumentNullException(paramName); + + if (!SemVersion.TryParse(value, SemVersionStyles.Strict, out var version)) + throw new ArgumentException( + $"Version must be a valid Semantic Versioning 2.0 value: '{value}'.", + paramName); + + return version; + } + + internal static SemVersion ParseRuntimeValue(string value, string source) + { + if (string.IsNullOrWhiteSpace(value)) + throw new InvalidOperationException($"{source} is missing."); + + if (!SemVersion.TryParse(value, SemVersionStyles.Strict, out var version)) + throw new InvalidOperationException( + $"{source} is not a valid Semantic Versioning 2.0 value: '{value}'."); + + return version; + } + + internal static bool TryParseRuntimeValue(string value, out SemVersion version) + { + version = Zero; + return !string.IsNullOrWhiteSpace(value) + && SemVersion.TryParse(value, SemVersionStyles.Strict, out version); + } + } +} diff --git a/dotnet/Surge.NET/Semver.LICENSE.txt b/dotnet/Surge.NET/Semver.LICENSE.txt new file mode 100644 index 0000000..cda0875 --- /dev/null +++ b/dotnet/Surge.NET/Semver.LICENSE.txt @@ -0,0 +1,19 @@ +Copyright (c) 2013-2024 Jeff Walker, Max Hauser + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/dotnet/Surge.NET/Semver/Comparers/ISemVersionComparer.cs b/dotnet/Surge.NET/Semver/Comparers/ISemVersionComparer.cs new file mode 100644 index 0000000..9d9a351 --- /dev/null +++ b/dotnet/Surge.NET/Semver/Comparers/ISemVersionComparer.cs @@ -0,0 +1,20 @@ +#nullable disable +#pragma warning disable +using System.Collections; +using System.Collections.Generic; + +namespace Semver.Comparers +{ + /// + /// An interface that combines equality and order comparison for the + /// class. + /// + /// + /// This interface provides a type for the and + /// so that separate properties aren't needed for the + /// and of . + /// + public interface ISemVersionComparer : IEqualityComparer, IComparer, IComparer + { + } +} diff --git a/dotnet/Surge.NET/Semver/Comparers/PrecedenceComparer.cs b/dotnet/Surge.NET/Semver/Comparers/PrecedenceComparer.cs new file mode 100644 index 0000000..50f8ac3 --- /dev/null +++ b/dotnet/Surge.NET/Semver/Comparers/PrecedenceComparer.cs @@ -0,0 +1,83 @@ +#nullable disable +#pragma warning disable +using System; +using System.Collections.Generic; +using Semver.Utility; + +namespace Semver.Comparers +{ + internal sealed class PrecedenceComparer : Comparer, ISemVersionComparer + { + #region Singleton + public static readonly ISemVersionComparer Instance = new PrecedenceComparer(); + + private PrecedenceComparer() { } + #endregion + + public bool Equals(SemVersion x, SemVersion y) + { + if (ReferenceEquals(x, y)) return true; + if (x is null || y is null) return false; + + return x.Major == y.Major && x.Minor == y.Minor && x.Patch == y.Patch + && Equals(x.PrereleaseIdentifiers, y.PrereleaseIdentifiers); + } + + private static bool Equals( + IReadOnlyList xIdentifiers, + IReadOnlyList yIdentifiers) + { + if (xIdentifiers.Count != yIdentifiers.Count) return false; + + for (int i = 0; i < xIdentifiers.Count; i++) + if (xIdentifiers[i] != yIdentifiers[i]) + return false; + + return true; + } + + public int GetHashCode(SemVersion v) + { + var hash = CombinedHashCode.Create(v.Major, v.Minor, v.Patch); + foreach (var identifier in v.PrereleaseIdentifiers) + hash.Add(identifier); + + return hash; + } + + public override int Compare(SemVersion x, SemVersion y) + { + if (ReferenceEquals(x, y)) return 0; // covers both null case + if (x is null) return -1; + if (y is null) return 1; + + var comparison = x.Major.CompareTo(y.Major); + if (comparison != 0) return comparison; + + comparison = x.Minor.CompareTo(y.Minor); + if (comparison != 0) return comparison; + + comparison = x.Patch.CompareTo(y.Patch); + if (comparison != 0) return comparison; + + // Release are higher precedence than prerelease + var xIsRelease = x.IsRelease; + var yIsRelease = y.IsRelease; + if (xIsRelease && yIsRelease) return 0; + if (xIsRelease) return 1; + if (yIsRelease) return -1; + + var xPrereleaseIdentifiers = x.PrereleaseIdentifiers; + var yPrereleaseIdentifiers = y.PrereleaseIdentifiers; + + var minLength = Math.Min(xPrereleaseIdentifiers.Count, yPrereleaseIdentifiers.Count); + for (int i = 0; i < minLength; i++) + { + comparison = xPrereleaseIdentifiers[i].CompareTo(yPrereleaseIdentifiers[i]); + if (comparison != 0) return comparison; + } + + return xPrereleaseIdentifiers.Count.CompareTo(yPrereleaseIdentifiers.Count); + } + } +} diff --git a/dotnet/Surge.NET/Semver/Comparers/SortOrderComparer.cs b/dotnet/Surge.NET/Semver/Comparers/SortOrderComparer.cs new file mode 100644 index 0000000..3e512c2 --- /dev/null +++ b/dotnet/Surge.NET/Semver/Comparers/SortOrderComparer.cs @@ -0,0 +1,69 @@ +#nullable disable +#pragma warning disable +using System; +using System.Collections.Generic; +using Semver.Utility; + +namespace Semver.Comparers +{ + internal sealed class SortOrderComparer : Comparer, ISemVersionComparer + { + #region Singleton + public static readonly ISemVersionComparer Instance = new SortOrderComparer(); + + private SortOrderComparer() { } + #endregion + + public bool Equals(SemVersion x, SemVersion y) + { + if (ReferenceEquals(x, y)) return true; + if (x is null || y is null) return false; + + return x.Major == y.Major && x.Minor == y.Minor && x.Patch == y.Patch + // Compare prerelease identifiers by their string value so that "01" != "1" + && string.Equals(x.Prerelease, y.Prerelease, StringComparison.Ordinal) + && string.Equals(x.Metadata, y.Metadata, StringComparison.Ordinal); + } + + public int GetHashCode(SemVersion v) + // Using v.Prerelease handles leading zero so "01" != "1" + => CombinedHashCode.Create(v.Major, v.Minor, v.Patch, v.Prerelease, v.Metadata); + + public override int Compare(SemVersion x, SemVersion y) + { + if (ReferenceEquals(x, y)) return 0; // covers both null case + + var comparison = PrecedenceComparer.Instance.Compare(x, y); + if (comparison != 0) return comparison; + + var xMetadataIdentifiers = x.MetadataIdentifiers; + var yMetadataIdentifiers = y.MetadataIdentifiers; + var minLength = Math.Min(xMetadataIdentifiers.Count, yMetadataIdentifiers.Count); + for (int i = 0; i < minLength; i++) + { + comparison = xMetadataIdentifiers[i].CompareTo(yMetadataIdentifiers[i]); + if (comparison != 0) return comparison; + } + + comparison = xMetadataIdentifiers.Count.CompareTo(yMetadataIdentifiers.Count); + if (comparison != 0) return comparison; + + // TODO remove the next section in v3.0.0 when prerelease identifiers can't have leading zeros + // Now must sort based on leading zeros in prerelease identifiers. + // Doing this after metadata so that 1.0.0-1+a < 1.0.0-01+a.b (i.e. leading zeros are the + // least significant difference to sort on). + var xPrereleaseIdentifiers = x.PrereleaseIdentifiers; + var yPrereleaseIdentifiers = y.PrereleaseIdentifiers; + minLength = Math.Min(xPrereleaseIdentifiers.Count, yPrereleaseIdentifiers.Count); + for (int i = 0; i < minLength; i++) + if (xPrereleaseIdentifiers[i].NumericValue != null) // Skip alphanumeric identifiers + { + comparison = IdentifierString.Compare( + xPrereleaseIdentifiers[i].Value, yPrereleaseIdentifiers[i].Value); + if (comparison != 0) return comparison; + } + + return 0; + } + } +} diff --git a/dotnet/Surge.NET/Semver/MetadataIdentifier.cs b/dotnet/Surge.NET/Semver/MetadataIdentifier.cs new file mode 100644 index 0000000..18df12d --- /dev/null +++ b/dotnet/Surge.NET/Semver/MetadataIdentifier.cs @@ -0,0 +1,233 @@ +#nullable disable +#pragma warning disable +using System; +using System.ComponentModel; +using System.Runtime.CompilerServices; +using Semver.Utility; + +namespace Semver +{ + /// + /// An individual metadata identifier for a semantic version. + /// + /// + /// The metadata for a semantic version is composed of dot ('.') separated identifiers. + /// A valid identifier is a non-empty string of ASCII alphanumeric and hyphen characters + /// ([0-9A-Za-z-]). Metadata identifiers are compared lexically in ASCII sort order. + /// + /// Because is a struct, the default value is a + /// with a value. However, the + /// namespace types do not accept and will not return such a + /// . + /// + /// Invalid metadata identifiers including arbitrary Unicode characters and empty string can + /// currently be produced by the + /// constructor. Such identifiers are compared via an ordinal string comparision. + /// + public readonly struct MetadataIdentifier : IEquatable, IComparable, IComparable + { + /// + /// The string value of the metadata identifier. + /// + /// The string value of this metadata identifier or if this is + /// a default . + public string Value { get; } + + /// + /// Construct a potentially invalid . + /// + /// The parameter is . + /// This should only be used by the constructor that + /// still accepts illegal values. + [EditorBrowsable(EditorBrowsableState.Never), Obsolete] + internal static MetadataIdentifier CreateLoose(string value) + { + DebugChecks.IsNotNull(value, nameof(value)); + + return new MetadataIdentifier(value, UnsafeOverload.Marker); + } + + /// + /// Constructs a without checking that any of the invariants + /// hold. Used by the parser for performance. + /// + /// This is a create method rather than a constructor to clearly indicate uses + /// of it. The other constructors have not been hidden behind create methods because only + /// constructors are visible to the package users. So they see a class consistently + /// using constructors without any create methods. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static MetadataIdentifier CreateUnsafe(string value) + { + DebugChecks.IsNotNull(value, nameof(value)); +#if DEBUG + if (value.Length == 0) throw new ArgumentException("DEBUG: Metadata identifier cannot be empty.", nameof(value)); + if (!value.IsAlphanumericOrHyphens()) + throw new ArgumentException($"DEBUG: A metadata identifier can contain only ASCII alphanumeric characters and hyphens '{value}'.", nameof(value)); +#endif + return new MetadataIdentifier(value, UnsafeOverload.Marker); + } + + /// + /// Private constructor used by . + /// + /// The value for the identifier. Not validated. + /// Unused parameter that differentiates this from the + /// constructor that performs validation. + private MetadataIdentifier(string value, UnsafeOverload _) + { + Value = value; + } + + /// + /// Constructs a valid . + /// + /// The is . + /// The is empty or contains invalid characters + /// (i.e. characters that are not ASCII alphanumerics or hyphens). + public MetadataIdentifier(string value) + : this(value, nameof(value)) + { + } + + /// + /// Constructs a valid . + /// + /// + /// Internal constructor allows changing the parameter name to enable methods using this + /// as part of their metadata identifier validation to match the parameter name to their + /// parameter name. + /// + internal MetadataIdentifier(string value, string paramName) + { + if (value is null) + throw new ArgumentNullException(paramName); + if (value.Length == 0) + throw new ArgumentException("Metadata identifier cannot be empty.", paramName); + if (!value.IsAlphanumericOrHyphens()) + throw new ArgumentException($"A metadata identifier can contain only ASCII alphanumeric characters and hyphens '{value}'.", paramName); + + Value = value; + } + + #region Equality + /// + /// Determines whether two identifiers are equal. + /// + /// if is equal to the this identifier; + /// otherwise . + public bool Equals(MetadataIdentifier value) => Value == value.Value; + + /// Determines whether the given object is equal to this identifier. + /// if is equal to the this identifier; + /// otherwise . + public override bool Equals(object value) + => value is MetadataIdentifier other && Equals(other); + + /// Gets a hash code for this identifier. + /// A hash code for this identifier. + public override int GetHashCode() => CombinedHashCode.Create(Value); + + /// + /// Determines whether two identifiers are equal. + /// + /// if the value of is the same as + /// the value of ; otherwise . + public static bool operator ==(MetadataIdentifier left, MetadataIdentifier right) + => left.Value == right.Value; + + /// + /// Determines whether two identifiers are not equal. + /// + /// if the value of is different + /// from the value of ; otherwise . + public static bool operator !=(MetadataIdentifier left, MetadataIdentifier right) + => left.Value != right.Value; + #endregion + + #region Comparison + /// + /// Compares two identifiers and indicates whether this instance precedes, follows, or is + /// equal to the other in sort order. + /// + /// + /// An integer that indicates whether this instance precedes, follows, or is equal to + /// in sort order. + /// + /// + /// Value + /// Condition + /// + /// + /// -1 + /// This instance precedes . + /// + /// + /// 0 + /// This instance is equal to . + /// + /// + /// 1 + /// This instance follows . + /// + /// + /// + /// Identifiers are compared lexically in ASCII sort order. Invalid identifiers are + /// compared via an ordinal string comparision. + public int CompareTo(MetadataIdentifier value) + => IdentifierString.Compare(Value, value.Value); + + /// + /// Compares this identifier to an and indicates whether this instance + /// precedes, follows, or is equal to the object in sort order. + /// + /// + /// An integer that indicates whether this instance precedes, follows, or is equal to + /// in sort order. + /// + /// + /// Value + /// Condition + /// + /// + /// -1 + /// This instance precedes . + /// + /// + /// 0 + /// This instance is equal to . + /// + /// + /// 1 + /// This instance follows or + /// is . + /// + /// + /// + /// is not a . + /// Identifiers are compared lexically in ASCII sort order. Invalid identifiers are + /// compared via an ordinal string comparision. + public int CompareTo(object value) + { + if (value is null) return 1; + return value is MetadataIdentifier other + ? IdentifierString.Compare(Value, other.Value) + : throw new ArgumentException($"Object must be of type {nameof(MetadataIdentifier)}.", nameof(value)); + } + #endregion + + /// + /// Converts this identifier into an equivalent string value. + /// + /// The string value of this identifier or if this is + /// a default . + public static implicit operator string(MetadataIdentifier metadataIdentifier) + => metadataIdentifier.Value; + + /// + /// Converts this identifier into an equivalent string value. + /// + /// The string value of this identifier or if this is + /// a default + public override string ToString() => Value; + } +} diff --git a/dotnet/Surge.NET/Semver/Parsing/PrereleaseIdentifiers.cs b/dotnet/Surge.NET/Semver/Parsing/PrereleaseIdentifiers.cs new file mode 100644 index 0000000..18004aa --- /dev/null +++ b/dotnet/Surge.NET/Semver/Parsing/PrereleaseIdentifiers.cs @@ -0,0 +1,13 @@ +#nullable disable +#pragma warning disable +using System.Collections.Generic; +using Semver.Utility; + +namespace Semver.Parsing +{ + internal static class PrereleaseIdentifiers + { + public static readonly IReadOnlyList Zero + = new List(1) { PrereleaseIdentifier.Zero }.AsReadOnly(); + } +} diff --git a/dotnet/Surge.NET/Semver/Parsing/SemVersionParser.cs b/dotnet/Surge.NET/Semver/Parsing/SemVersionParser.cs new file mode 100644 index 0000000..151dc8a --- /dev/null +++ b/dotnet/Surge.NET/Semver/Parsing/SemVersionParser.cs @@ -0,0 +1,459 @@ +#nullable disable +#pragma warning disable +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Runtime.CompilerServices; +using Semver.Utility; + +namespace Semver.Parsing +{ + /// + /// Parsing for + /// + /// The new parsing code was complex enough that is made sense to break out into its + /// own class. + internal static class SemVersionParser + { + private const string LeadingWhitespaceMessage = "Version '{0}' has leading whitespace."; + private const string TrailingWhitespaceMessage = "Version '{0}' has trailing whitespace."; + private const string EmptyVersionMessage = "Empty string is not a valid version."; + private const string TooLongVersionMessage = "Exceeded maximum length of {1} for '{0}'."; + private const string AllWhitespaceVersionMessage = "Whitespace is not a valid version."; + private const string LeadingLowerVMessage = "Leading 'v' in '{0}'."; + private const string LeadingUpperVMessage = "Leading 'V' in '{0}'."; + private const string LeadingZeroInMajorMinorOrPatchMessage = "{1} version has leading zero in '{0}'."; + private const string EmptyMajorMinorOrPatchMessage = "{1} version missing in '{0}'."; + private const string MajorMinorOrPatchOverflowMessage = "{1} version '{2}' was too large for Int32 in '{0}'."; + private const string FourthVersionNumberMessage = "Fourth version number in '{0}'."; + private const string PrereleasePrefixedByDotMessage = "The prerelease identfiers should be prefixed by '-' instead of '.' in '{0}'."; + private const string MissingPrereleaseIdentifierMessage = "Missing prerelease identifier in '{0}'."; + private const string LeadingZeroInPrereleaseMessage = "Leading zero in prerelease identifier in version '{0}'."; + private const string PrereleaseOverflowMessage = "Prerelease identifier '{1}' was too large for Int32 in version '{0}'."; + private const string InvalidCharacterInPrereleaseMessage = "Invalid character '{1}' in prerelease identifier in '{0}'."; + private const string MissingMetadataIdentifierMessage = "Missing metadata identifier in '{0}'."; + private const string InvalidCharacterInMajorMinorOrPatchMessage = "{1} version contains invalid character '{2}' in '{0}'."; + private const string InvalidCharacterInMetadataMessage = "Invalid character '{1}' in metadata identifier in '{0}'."; + private const string InvalidWildcardInMajorMinorOrPatchMessage = "{1} version is a wildcard and should contain only 1 character in '{0}'."; + private const string MinorOrPatchMustBeWildcardVersionMessage = "{1} version should be a wildcard because the preceding version is a wildcard in '{0}'."; + private const string InvalidWildcardInPrereleaseMessage = "Prerelease version is a wildcard and should contain only 1 character in '{0}'."; + private const string PrereleaseWildcardMustBeLast = "Prerelease identifier follows wildcard prerelease identifier in '{0}'."; + + /// + /// The internal method that all parsing is based on. Because this is called by both + /// and + /// + /// it does not throw exceptions, but instead returns the exception that should be thrown + /// by the parse method. For performance when used from try parse, all exception construction + /// and message formatting can be avoided by passing in an exception which will be returned + /// when parsing fails. + /// + /// This does not validate the or + /// parameter values. That must be done in the calling method. + public static Exception Parse( + string version, + SemVersionStyles style, + Exception ex, + int maxLength, + out SemVersion semver) + { + DebugChecks.IsValid(style, nameof(style)); + // TODO include in v3.0.0 for issue #72 + // DebugChecks.IsValidMaxLength(maxLength, nameof(maxLength)); + + if (version != null) + return Parse(version, style, SemVersionParsingOptions.None, ex, maxLength, out semver, out _); + semver = null; + return ex ?? new ArgumentNullException(nameof(version)); + } + + /// + /// An internal method that is used when parsing versions from ranges. Because this is + /// called by both + /// and + /// + /// it does not throw exceptions, but instead returns the exception that should be thrown + /// by the parse method. For performance when used from try parse, all exception construction + /// and message formatting can be avoided by passing in an exception which will be returned + /// when parsing fails. + /// + /// This does not validate the or + /// parameter values. That must be done in the calling method. + public static Exception Parse( + StringSegment version, + SemVersionStyles style, + SemVersionParsingOptions options, + Exception ex, + int maxLength, + out SemVersion semver, + out WildcardVersion wildcardVersion) + { + DebugChecks.IsValid(style, nameof(style)); + // TODO include in v3.0.0 for issue #72 + // DebugChecks.IsValidMaxLength(maxLength, nameof(maxLength)); + + // Assign once so it doesn't have to be done any time parse fails + semver = null; + wildcardVersion = WildcardVersion.None; + + // Note: this method relies on the fact that the null coalescing operator `??` + // is short circuiting to avoid constructing exceptions and exception messages + // when a non-null exception is passed in. + + if (version.Length == 0) return ex ?? new FormatException(EmptyVersionMessage); + + if (version.Length > maxLength) + return ex ?? NewFormatException(TooLongVersionMessage, version.ToStringLimitLength(), maxLength); + + // This code does two things to help provide good error messages: + // 1. It breaks the version number into segments and then parses those segments + // 2. It parses an element first, then checks the flags for whether it should be allowed + + var mainSegment = version; + + var parseEx = ParseLeadingWhitespace(version, ref mainSegment, style, ex); + if (parseEx != null) return parseEx; + + // Take of trailing whitespace and remember that there was trailing whitespace + var lengthWithTrailingWhitespace = mainSegment.Length; + mainSegment = mainSegment.TrimEndWhitespace(); + var hasTrailingWhitespace = lengthWithTrailingWhitespace > mainSegment.Length; + + // Now break the version number down into segments. + mainSegment.SplitBeforeFirst('+', out mainSegment, out var metadataSegment); + mainSegment.SplitBeforeFirst('-', out var majorMinorPatchSegment, out var prereleaseSegment); + + parseEx = ParseLeadingV(version, ref majorMinorPatchSegment, style, ex); + if (parseEx != null) return parseEx; + + // Are leading zeros allowed + var allowLeadingZeros = style.HasStyle(SemVersionStyles.AllowLeadingZeros); + + int major, minor, patch; + using (var versionNumbers = majorMinorPatchSegment.Split('.').GetEnumerator()) + { + const bool majorIsOptional = false; + const bool majorIsWildcardRequired = false; + parseEx = ParseVersionNumber("Major", version, versionNumbers, allowLeadingZeros, + majorIsOptional, majorIsWildcardRequired, options, ex, out major, out var majorIsWildcard); + if (parseEx != null) return parseEx; + if (majorIsWildcard) wildcardVersion |= WildcardVersion.MajorMinorPatchWildcard; + + var minorIsOptional = style.HasStyle(SemVersionStyles.OptionalMinorPatch) || majorIsWildcard; + parseEx = ParseVersionNumber("Minor", version, versionNumbers, allowLeadingZeros, + minorIsOptional, majorIsWildcard, options, ex, out minor, out var minorIsWildcard); + if (parseEx != null) return parseEx; + if (minorIsWildcard) wildcardVersion |= WildcardVersion.MinorPatchWildcard; + + var patchIsOptional = style.HasStyle(SemVersionStyles.OptionalPatch) || majorIsWildcard || minorIsWildcard; + parseEx = ParseVersionNumber("Patch", version, versionNumbers, allowLeadingZeros, + patchIsOptional, minorIsWildcard, options, ex, out patch, out var patchIsWildcard); + if (parseEx != null) return parseEx; + if (patchIsWildcard) wildcardVersion |= WildcardVersion.PatchWildcard; + + // Handle fourth version number + if (versionNumbers.MoveNext()) + { + var fourthSegment = versionNumbers.Current; + // If it is ".\d" then we'll assume they were trying to have a fourth version number + if (fourthSegment.Length > 0 && fourthSegment[0].IsDigit()) + return ex ?? NewFormatException(FourthVersionNumberMessage, version.ToStringLimitLength()); + + // Otherwise, assume they used "." instead of "-" to start the prerelease + return ex ?? NewFormatException(PrereleasePrefixedByDotMessage, version.ToStringLimitLength()); + } + } + + // Parse prerelease version + string prerelease; + IReadOnlyList prereleaseIdentifiers; + if (prereleaseSegment.Length > 0) + { + prereleaseSegment = prereleaseSegment.Subsegment(1); + parseEx = ParsePrerelease(version, prereleaseSegment, allowLeadingZeros, options, ex, + out prerelease, out prereleaseIdentifiers, out var prereleaseIsWildcard); + if (parseEx != null) return parseEx; + if (prereleaseIsWildcard) wildcardVersion |= WildcardVersion.PrereleaseWildcard; + } + else + { + prerelease = ""; + prereleaseIdentifiers = ReadOnlyList.Empty; + } + + // Parse metadata + string metadata; + IReadOnlyList metadataIdentifiers; + if (metadataSegment.Length > 0) + { + metadataSegment = metadataSegment.Subsegment(1); + parseEx = ParseMetadata(version, metadataSegment, ex, out metadata, out metadataIdentifiers); + if (parseEx != null) return parseEx; + } + else + { + metadata = ""; + metadataIdentifiers = ReadOnlyList.Empty; + } + + // Error if trailing whitespace not allowed + if (hasTrailingWhitespace && !style.HasStyle(SemVersionStyles.AllowTrailingWhitespace)) + return ex ?? NewFormatException(TrailingWhitespaceMessage, version.ToStringLimitLength()); + + semver = new SemVersion(major, minor, patch, + prerelease, prereleaseIdentifiers, metadata, metadataIdentifiers); + return null; + } + + private static Exception ParseLeadingWhitespace( + StringSegment version, + ref StringSegment segment, + SemVersionStyles style, + Exception ex) + { + var oldLength = segment.Length; + + // Skip leading whitespace + segment = segment.TrimStartWhitespace(); + + // Error if all whitespace + if (segment.Length == 0) + return ex ?? new FormatException(AllWhitespaceVersionMessage); + + // Error if leading whitespace not allowed + if (oldLength > segment.Length && !style.HasStyle(SemVersionStyles.AllowLeadingWhitespace)) + return ex ?? NewFormatException(LeadingWhitespaceMessage, version.ToStringLimitLength()); + + return null; + } + + private static Exception ParseLeadingV( + StringSegment version, + ref StringSegment segment, + SemVersionStyles style, + Exception ex) + { + // This is safe because the check for all whitespace ensures there is at least one more char + var leadChar = segment[0]; + switch (leadChar) + { + case 'v' when style.HasStyle(SemVersionStyles.AllowLowerV): + segment = segment.Subsegment(1); + break; + case 'v': + return ex ?? NewFormatException(LeadingLowerVMessage, version.ToStringLimitLength()); + case 'V' when style.HasStyle(SemVersionStyles.AllowUpperV): + segment = segment.Subsegment(1); + break; + case 'V': + return ex ?? NewFormatException(LeadingUpperVMessage, version.ToStringLimitLength()); + } + + return null; + } + + private static Exception ParseVersionNumber( + string kind, // i.e. Major, Minor, or Patch + StringSegment version, + IEnumerator versionNumbers, + bool allowLeadingZeros, + bool optional, + bool wildcardRequired, + SemVersionParsingOptions options, + Exception ex, + out int number, + out bool isWildcard) + { + if (versionNumbers.MoveNext()) + return ParseVersionNumber(kind, version, versionNumbers.Current, allowLeadingZeros, + wildcardRequired, options, ex, out number, out isWildcard); + + number = 0; + isWildcard = options.MissingVersionsAreWildcards; + if (!optional) + return ex ?? NewFormatException(EmptyMajorMinorOrPatchMessage, version.ToStringLimitLength(), kind); + + return null; + } + + private static Exception ParseVersionNumber( + string kind, // i.e. Major, Minor, or Patch + StringSegment version, + StringSegment segment, + bool allowLeadingZeros, + bool wildcardRequired, + SemVersionParsingOptions options, + Exception ex, + out int number, + out bool isWildcard) + { + // Assign once so it doesn't have to be done any time parse fails + number = 0; + + if (segment.Length == 0) + { + isWildcard = false; + return ex ?? NewFormatException(EmptyMajorMinorOrPatchMessage, version.ToStringLimitLength(), kind); + } + + if (options.AllowWildcardMajorMinorPatch && segment.Length > 0 && options.IsWildcard(segment[0])) + { + isWildcard = true; + if (segment.Length > 1) + return ex ?? NewFormatException(InvalidWildcardInMajorMinorOrPatchMessage, + version.ToStringLimitLength(), kind); + + return null; + } + + isWildcard = false; + + if (wildcardRequired) + return ex ?? NewFormatException(MinorOrPatchMustBeWildcardVersionMessage, + version.ToStringLimitLength(), kind); + + var lengthWithLeadingZeros = segment.Length; + + // Skip leading zeros + segment = segment.TrimLeadingZeros(); + + // Scan for digits + var i = 0; + while (i < segment.Length && segment[i].IsDigit()) i += 1; + + // If there are unprocessed characters, then it is an invalid char for this segment + if (i < segment.Length) + return ex ?? NewFormatException(InvalidCharacterInMajorMinorOrPatchMessage, + version.ToStringLimitLength(), + kind, segment[i]); + + if (!allowLeadingZeros && lengthWithLeadingZeros > segment.Length) + return ex ?? NewFormatException(LeadingZeroInMajorMinorOrPatchMessage, + version.ToStringLimitLength(), kind); + + var numberString = segment.ToString(); + if (!int.TryParse(numberString, NumberStyles.None, CultureInfo.InvariantCulture, out number)) + // Parsing validated this as a string of digits possibly proceeded by zero so the only + // possible issue is a numeric overflow for `int` + return ex ?? new OverflowException(string.Format(CultureInfo.InvariantCulture, + MajorMinorOrPatchOverflowMessage, version.ToStringLimitLength(), kind, numberString)); + + return null; + } + + private static Exception ParsePrerelease( + StringSegment version, + StringSegment segment, + bool allowLeadingZero, + SemVersionParsingOptions options, + Exception ex, + out string prerelease, + out IReadOnlyList prereleaseIdentifiers, + out bool isWildcard) + { + prerelease = null; + var identifiers = new List(segment.SplitCount('.')); + prereleaseIdentifiers = identifiers.AsReadOnly(); + isWildcard = false; + + bool hasLeadingZeros = false; + foreach (var identifier in segment.Split('.')) + { + // Identifier after wildcard + if (isWildcard) + return ex ?? NewFormatException(PrereleaseWildcardMustBeLast, version.ToStringLimitLength()); + + // Empty identifiers not allowed + if (identifier.Length == 0) + return ex ?? NewFormatException(MissingPrereleaseIdentifierMessage, version.ToStringLimitLength()); + + var isNumeric = true; + + for (int i = 0; i < identifier.Length; i++) + { + var c = identifier[i]; + if (c.IsAlphaOrHyphen()) + isNumeric = false; + else if (options.AllowWildcardPrerelease && options.IsWildcard(c)) + isWildcard = true; + else if (!c.IsDigit()) + return ex ?? NewFormatException(InvalidCharacterInPrereleaseMessage, + version.ToStringLimitLength(), c); + } + + if (isWildcard) + { + if (identifier.Length > 1) return ex ?? NewFormatException(InvalidWildcardInPrereleaseMessage, version.ToStringLimitLength()); + isWildcard = true; + continue; // continue to make sure there aren't more identifiers + } + + if (!isNumeric) + identifiers.Add(PrereleaseIdentifier.CreateUnsafe(identifier.ToString(), null)); + else + { + string identifierString; + if (identifier[0] == '0' && identifier.Length > 1) + { + if (!allowLeadingZero) + return ex ?? NewFormatException(LeadingZeroInPrereleaseMessage, + version.ToStringLimitLength()); + hasLeadingZeros = true; + identifierString = identifier.TrimLeadingZeros().ToString(); + } + else + identifierString = identifier.ToString(); + + if (!int.TryParse(identifierString, NumberStyles.None, null, out var numericValue)) + // Parsing validated this as a string of digits possibly proceeded by zero so the only + // possible issue is a numeric overflow for `int` + return ex ?? new OverflowException(string.Format(CultureInfo.InvariantCulture, + PrereleaseOverflowMessage, version.ToStringLimitLength(), identifier)); + + identifiers.Add(PrereleaseIdentifier.CreateUnsafe(identifierString, numericValue)); + } + } + + // If there are leading zeros or a wildcard, reconstruct the string from the identifiers, + // otherwise just take a substring. + prerelease = hasLeadingZeros || isWildcard + ? string.Join(".", identifiers) : segment.ToString(); + + return null; + } + + private static Exception ParseMetadata( + StringSegment version, + StringSegment segment, + Exception ex, + out string metadata, + out IReadOnlyList metadataIdentifiers) + { + metadata = segment.ToString(); + var identifiers = new List(segment.SplitCount('.')); + metadataIdentifiers = identifiers.AsReadOnly(); + foreach (var identifier in segment.Split('.')) + { + // Empty identifiers not allowed + if (identifier.Length == 0) + return ex ?? NewFormatException(MissingMetadataIdentifierMessage, version.ToStringLimitLength()); + + for (int i = 0; i < identifier.Length; i++) + { + var c = identifier[i]; + if (!c.IsAlphaOrHyphen() && !c.IsDigit()) + return ex ?? NewFormatException(InvalidCharacterInMetadataMessage, + version.ToStringLimitLength(), c); + } + + identifiers.Add(MetadataIdentifier.CreateUnsafe(identifier.ToString())); + } + + return null; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static FormatException NewFormatException(string messageTemplate, params object[] args) + => new FormatException(string.Format(CultureInfo.InvariantCulture, messageTemplate, args)); + } +} diff --git a/dotnet/Surge.NET/Semver/Parsing/SemVersionParsingOptions.cs b/dotnet/Surge.NET/Semver/Parsing/SemVersionParsingOptions.cs new file mode 100644 index 0000000..fad247c --- /dev/null +++ b/dotnet/Surge.NET/Semver/Parsing/SemVersionParsingOptions.cs @@ -0,0 +1,52 @@ +#nullable disable +#pragma warning disable +using System; + +namespace Semver.Parsing +{ + /// + /// Options beyond the for version parsing used by range parsing. + /// + internal class SemVersionParsingOptions + { + /// + /// No special parsing options. Used when parsing versions outside of ranges. + /// + public static SemVersionParsingOptions None + = new SemVersionParsingOptions(false, false, false, _ => false); + + public SemVersionParsingOptions( + bool allowWildcardMajorMinorPatch, + bool allowWildcardPrerelease, + bool missingVersionsAreWildcards, + Predicate isWildcard) + { + AllowWildcardMajorMinorPatch = allowWildcardMajorMinorPatch; + AllowWildcardPrerelease = allowWildcardPrerelease; + MissingVersionsAreWildcards = missingVersionsAreWildcards; + IsWildcard = isWildcard; + } + + /// + /// Allow wildcards as defined by in the major, minor, and patch + /// version numbers. + /// + public bool AllowWildcardMajorMinorPatch { get; } + + /// + /// Allow a wildcard as defined by as the final prerelease identifier. + /// + public bool AllowWildcardPrerelease { get; } + + /// + /// Whether missing minor and patch version numbers allowed by the optional minor and patch + /// options count as being wildcard version numbers. + /// + public bool MissingVersionsAreWildcards { get; } + + /// + /// Determines whether any given character is a wildcard character. + /// + public Predicate IsWildcard { get; } + } +} diff --git a/dotnet/Surge.NET/Semver/Parsing/WildcardVersion.cs b/dotnet/Surge.NET/Semver/Parsing/WildcardVersion.cs new file mode 100644 index 0000000..95da258 --- /dev/null +++ b/dotnet/Surge.NET/Semver/Parsing/WildcardVersion.cs @@ -0,0 +1,19 @@ +#nullable disable +#pragma warning disable +using System; + +namespace Semver.Parsing +{ + [Flags] + internal enum WildcardVersion : byte + { + None = 0, + MajorWildcard = 1 << 3, + MinorWildcard = 1 << 2, + PatchWildcard = 1 << 1, + PrereleaseWildcard = 1 << 0, + + MinorPatchWildcard = MinorWildcard | PatchWildcard, + MajorMinorPatchWildcard = MajorWildcard | MinorPatchWildcard, + } +} diff --git a/dotnet/Surge.NET/Semver/Parsing/WildcardVersionExtensions.cs b/dotnet/Surge.NET/Semver/Parsing/WildcardVersionExtensions.cs new file mode 100644 index 0000000..d5b7f90 --- /dev/null +++ b/dotnet/Surge.NET/Semver/Parsing/WildcardVersionExtensions.cs @@ -0,0 +1,25 @@ +#nullable disable +#pragma warning disable +using System; +using System.Runtime.CompilerServices; + +namespace Semver.Parsing +{ + internal static class WildcardVersionExtensions + { + /// + /// The method is surprisingly slow. This provides + /// a fast alternative for the enum. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool HasOption(this WildcardVersion wildcards, WildcardVersion flag) + => (wildcards & flag) == flag; + + /// + /// Remove a flag from a . + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void RemoveOption(this ref WildcardVersion wildcards, WildcardVersion flag) + => wildcards &= ~flag; + } +} diff --git a/dotnet/Surge.NET/Semver/PrereleaseIdentifier.cs b/dotnet/Surge.NET/Semver/PrereleaseIdentifier.cs new file mode 100644 index 0000000..39d30ee --- /dev/null +++ b/dotnet/Surge.NET/Semver/PrereleaseIdentifier.cs @@ -0,0 +1,373 @@ +#nullable disable +#pragma warning disable +using System; +using System.ComponentModel; +using System.Globalization; +using System.Runtime.CompilerServices; +using Semver.Utility; + +namespace Semver +{ + /// + /// An individual prerelease identifier for a semantic version. + /// + /// + /// The prerelease portion of a semantic version is composed of dot ('.') separated identifiers. + /// A prerelease identifier is either an alphanumeric or numeric identifier. A valid numeric + /// identifier is composed of ASCII digits ([0-9]) without leading zeros. A valid + /// alphanumeric identifier is a non-empty string of ASCII alphanumeric and hyphen characters + /// ([0-9A-Za-z-]) with at least one non-digit character. Prerelease identifiers are + /// compared first by whether they are numeric or alphanumeric. Numeric identifiers have lower + /// precedence than alphanumeric identifiers. Numeric identifiers are compared to each other + /// numerically. Alphanumeric identifiers are compared to each other lexically in ASCII sort + /// order. + /// + /// Because is a struct, the default value is a + /// with a value. However, the + /// namespace types do not accept and will not return such a + /// . + /// + /// Invalid prerelease identifiers including arbitrary Unicode characters, empty string, + /// and numeric identifiers with leading zero can currently be produced by the + /// constructor and the obsolete + /// and + /// methods. Such alphanumeric + /// identifiers are compared via an ordinal string comparision. Numeric identifiers with + /// leading zeros are considered equal (e.g. '15' is equal to '015'). + /// + /// + public readonly struct PrereleaseIdentifier : IEquatable, IComparable, IComparable + { + internal static readonly PrereleaseIdentifier Zero = CreateUnsafe("0", 0); + internal static readonly PrereleaseIdentifier Hyphen = CreateUnsafe("-", null); + + /// + /// The string value of the prerelease identifier even if it is a numeric identifier. + /// + /// The string value of this prerelease identifier even if it is a numeric identifier + /// or if this is a default . + /// Invalid numeric prerelease identifiers with leading zeros will have a string + /// value including the leading zeros. This can be used to distinguish invalid numeric + /// identifiers with different numbers of leading zeros. + public string Value { get; } + + /// + /// The numeric value of the prerelease identifier if it is a numeric identifier, otherwise + /// . + /// + /// The numeric value of the prerelease identifier if it is a numeric identifier, + /// otherwise . + /// The numeric value of a prerelease identifier will never be negative. + public int? NumericValue { get; } + + /// + /// Construct a potentially invalid . + /// + /// The parameter is . + /// This should be used only by the constructor that + /// still accepts illegal values. + [EditorBrowsable(EditorBrowsableState.Never), Obsolete] + internal static PrereleaseIdentifier CreateLoose(string value) + { + DebugChecks.IsNotNull(value, nameof(value)); + + // Avoid parsing some non-ASCII digits as a number by checking that they are all digits + if (value.IsDigits() && int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out var numericValue)) + return new PrereleaseIdentifier(value, numericValue); + + return new PrereleaseIdentifier(value, null); + } + + /// + /// Construct a without checking that any of the invariants + /// hold. Used by the parser for performance. + /// + /// This is a create method rather than a constructor to clearly indicate uses + /// of it. The other constructors have not been hidden behind create methods because only + /// constructors are visible to the package users. So they see a class consistently + /// using constructors without any create methods. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static PrereleaseIdentifier CreateUnsafe(string value, int? numericValue) + { + DebugChecks.IsNotNull(value, nameof(value)); +#if DEBUG + PrereleaseIdentifier expected; + try + { + // Use the standard constructor as a way of validating the input + expected = new PrereleaseIdentifier(value); + } + catch (ArgumentException ex) + { + throw new ArgumentException("DEBUG: " + ex.Message, ex.ParamName, ex); + } + catch (OverflowException ex) + { + throw new OverflowException("DEBUG: " + ex.Message, ex); + } + if (expected.Value != value) + throw new ArgumentException("DEBUG: String value has leading zeros.", nameof(value)); + if (expected.NumericValue != numericValue) + throw new ArgumentException($"DEBUG: Numeric value {numericValue} doesn't match string value.", nameof(numericValue)); +#endif + return new PrereleaseIdentifier(value, numericValue); + } + + /// + /// Private constructor used by . + /// + private PrereleaseIdentifier(string value, int? numericValue) + { + Value = value; + NumericValue = numericValue; + } + + /// + /// Constructs a valid . + /// + /// The string value of this prerelease identifier. + /// Whether to allow leading zeros in the + /// parameter. If , leading zeros will be allowed on numeric identifiers + /// but will be removed. + /// The is . + /// The is empty or contains invalid characters + /// (i.e. characters that are not ASCII alphanumerics or hyphens) or has leading zeros for + /// a numeric identifier when is . + /// The numeric identifier value is too large for . + /// Because a valid numeric identifier does not have leading zeros, this constructor + /// will never create a with leading zeros even if + /// is . Any leading zeros will + /// be removed. + public PrereleaseIdentifier(string value, bool allowLeadingZeros = false) + : this(value, allowLeadingZeros, nameof(value)) + { + } + + /// + /// Constructs a valid . + /// + /// + /// Internal constructor allows changing the parameter name to enable methods using this + /// as part of their prerelease identifier validation to match the parameter name to their + /// parameter name. + /// + internal PrereleaseIdentifier(string value, bool allowLeadingZeros, string paramName) + { + if (value is null) + throw new ArgumentNullException(paramName); + if (value.Length == 0) + throw new ArgumentException("Prerelease identifier cannot be empty.", paramName); + if (value.IsDigits()) + { + if (value.Length > 1 && value[0] == '0') + { + if (allowLeadingZeros) + value = value.TrimLeadingZeros(); + else + throw new ArgumentException($"Leading zeros are not allowed on numeric prerelease identifiers '{value}'.", paramName); + } + + try + { + NumericValue = int.Parse(value, NumberStyles.None, CultureInfo.InvariantCulture); + } + catch (OverflowException) + { + // Remake the overflow exception to give better message + throw new OverflowException($"Prerelease identifier '{value}' was too large for {nameof(Int32)}."); + } + } + else + { + if (!value.IsAlphanumericOrHyphens()) + throw new ArgumentException($"A prerelease identifier can contain only ASCII alphanumeric characters and hyphens '{value}'.", paramName); + NumericValue = null; + } + + Value = value; + } + + /// + /// Construct a valid numeric from an integer value. + /// + /// The is negative. + /// The non-negative value of this identifier. + public PrereleaseIdentifier(int value) + { + if (value < 0) + throw new ArgumentOutOfRangeException(nameof(value), $"Numeric prerelease identifiers can't be negative: {value}."); + Value = value.ToString(CultureInfo.InvariantCulture); + NumericValue = value; + } + + #region Equality + /// + /// Determines whether two identifiers are equal. + /// + /// if is equal to the this identifier; + /// otherwise . + /// Numeric identifiers with leading zeros are considered equal (e.g. '15' + /// is equal to '015'). + public bool Equals(PrereleaseIdentifier value) + { + if (NumericValue is int numericValue) return numericValue == value.NumericValue; + return Value == value.Value; + } + + /// Determines whether the given object is equal to this identifier. + /// if is equal to the this identifier; + /// otherwise . + /// Numeric identifiers with leading zeros are considered equal (e.g. '15' + /// is equal to '015'). + public override bool Equals(object value) + => value is PrereleaseIdentifier other && Equals(other); + + /// Gets a hash code for this identifier. + /// A hash code for this identifier. + /// Numeric identifiers with leading zeros are have the same hash code (e.g. + /// '15' has the same hash code as '015'). + public override int GetHashCode() + { + if (NumericValue is int numericValue) return CombinedHashCode.Create(numericValue); + return CombinedHashCode.Create(Value); + } + + /// + /// Determines whether two identifiers are equal. + /// + /// if the value of is the same as + /// the value of ; otherwise . + /// Numeric identifiers with leading zeros are considered equal (e.g. '15' + /// is equal to '015'). + public static bool operator ==(PrereleaseIdentifier left, PrereleaseIdentifier right) + => left.Equals(right); + + /// + /// Determines whether two identifiers are not equal. + /// + /// if the value of is different + /// from the value of ; otherwise . + /// Numeric identifiers with leading zeros are considered equal (e.g. '15' + /// is equal to '015'). + public static bool operator !=(PrereleaseIdentifier left, PrereleaseIdentifier right) + => !left.Equals(right); + #endregion + + #region Comparison + /// + /// Compares two identifiers and indicates whether this instance precedes, follows, or is + /// equal to the other in precedence order. + /// + /// + /// An integer that indicates whether this instance precedes, follows, or is equal to + /// in precedence order. + /// + /// + /// Value + /// Condition + /// + /// + /// -1 + /// This instance precedes . + /// + /// + /// 0 + /// This instance is equal to . + /// + /// + /// 1 + /// This instance follows . + /// + /// + /// + /// Numeric identifiers have lower precedence than alphanumeric identifiers. + /// Numeric identifiers are compared numerically. Numeric identifiers with leading zeros are + /// considered equal (e.g. '15' is equal to '015'). Alphanumeric identifiers are + /// compared lexically in ASCII sort order. Invalid alphanumeric identifiers are + /// compared via an ordinal string comparision. + public int CompareTo(PrereleaseIdentifier value) + { + // Handle the fact that numeric identifiers are always less than alphanumeric + // and numeric identifiers are compared equal even with leading zeros. + if (NumericValue is int numericValue) + { + if (value.NumericValue is int otherNumericValue) + return numericValue.CompareTo(otherNumericValue); + + return -1; + } + + if (value.NumericValue != null) + return 1; + + return IdentifierString.Compare(Value, value.Value); + } + + /// + /// Compares this identifier to an and indicates whether this instance + /// precedes, follows, or is equal to the object in precedence order. + /// + /// + /// An integer that indicates whether this instance precedes, follows, or is equal to + /// in precedence order. + /// + /// + /// Value + /// Condition + /// + /// + /// -1 + /// This instance precedes . + /// + /// + /// 0 + /// This instance is equal to . + /// + /// + /// 1 + /// This instance follows or + /// is . + /// + /// + /// + /// is not a . + /// Numeric identifiers have lower precedence than alphanumeric identifiers. + /// Numeric identifiers are compared numerically. Numeric identifiers with leading zeros are + /// considered equal (e.g. '15' is equal to '015'). Alphanumeric identifiers are + /// compared lexically in ASCII sort order. Invalid alphanumeric identifiers are + /// compared via an ordinal string comparision. + public int CompareTo(object value) + { + if (value is null) return 1; + return value is PrereleaseIdentifier other + ? CompareTo(other) + : throw new ArgumentException($"Object must be of type {nameof(PrereleaseIdentifier)}.", nameof(value)); + } + #endregion + + /// + /// Converts this identifier into an equivalent string value. + /// + /// The string value of this identifier or if this is + /// a default + public static implicit operator string(PrereleaseIdentifier prereleaseIdentifier) + => prereleaseIdentifier.Value; + + /// + /// Converts this identifier into an equivalent string value. + /// + /// The string value of this identifier or if this is + /// a default + public override string ToString() => Value; + + internal PrereleaseIdentifier NextIdentifier() + { + if (NumericValue is int numericValue) + return numericValue == int.MaxValue + ? Hyphen + : new PrereleaseIdentifier(numericValue + 1); + + return new PrereleaseIdentifier(Value + "-"); + } + } +} diff --git a/dotnet/Surge.NET/Semver/SemVersion.cs b/dotnet/Surge.NET/Semver/SemVersion.cs new file mode 100644 index 0000000..7e4d75f --- /dev/null +++ b/dotnet/Surge.NET/Semver/SemVersion.cs @@ -0,0 +1,1703 @@ +#nullable disable +#pragma warning disable +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Globalization; +using System.Linq; +using System.Text; +#if SERIALIZABLE +using System.Runtime.Serialization; +using System.Security.Permissions; +#endif +using System.Text.RegularExpressions; +using Semver.Comparers; +using Semver.Parsing; +using Semver.Utility; + +namespace Semver +{ + /// + /// A semantic version number. Conforms with v2.0.0 of semantic versioning + /// (semver.org). + /// +#if SERIALIZABLE + [Serializable] + public sealed class SemVersion : IComparable, IComparable, IEquatable, ISerializable +#else + public sealed class SemVersion : IComparable, IComparable, IEquatable +#endif + { + internal static readonly SemVersion Min = new SemVersion(0, 0, 0, new[] { new PrereleaseIdentifier(0) }); + internal static readonly SemVersion MinRelease = new SemVersion(0, 0, 0); + internal static readonly SemVersion Max = new SemVersion(int.MaxValue, int.MaxValue, int.MaxValue); + + internal const string InvalidSemVersionStylesMessage = "An invalid SemVersionStyles value was used."; + private const string InvalidMajorVersionMessage = "Major version must be greater than or equal to zero."; + private const string InvalidMinorVersionMessage = "Minor version must be greater than or equal to zero."; + private const string InvalidPatchVersionMessage = "Patch version must be greater than or equal to zero."; + private const string PrereleaseIdentifierIsDefaultMessage = "Prerelease identifier cannot be default/null."; + private const string MetadataIdentifierIsDefaultMessage = "Metadata identifier cannot be default/null."; + // TODO include in v3.0.0 for issue #72 + //internal const string InvalidMaxLengthMessage = "Must not be negative."; + internal const int MaxVersionLength = 1024; + + private static readonly Regex ParseRegex = + new Regex(@"^(?\d+)" + + @"(?>\.(?\d+))?" + + @"(?>\.(?\d+))?" + + @"(?>\-(?
[0-9A-Za-z\-\.]+))?" +
+                @"(?>\+(?[0-9A-Za-z\-\.]+))?$",
+#if COMPILED_REGEX
+                RegexOptions.CultureInvariant | RegexOptions.Compiled | RegexOptions.ExplicitCapture,
+#else
+                RegexOptions.CultureInvariant | RegexOptions.ExplicitCapture,
+#endif
+                TimeSpan.FromSeconds(0.5));
+
+#if SERIALIZABLE
+        /// 
+        /// Deserialize a .
+        /// 
+        /// The  parameter is null.
+        private SemVersion(SerializationInfo info, StreamingContext context)
+        {
+            if (info == null) throw new ArgumentNullException(nameof(info));
+#pragma warning disable CS0618 // Type or member is obsolete
+            var semVersion = Parse(info.GetString("SemVersion"), true);
+#pragma warning restore CS0618 // Type or member is obsolete
+            Major = semVersion.Major;
+            Minor = semVersion.Minor;
+            Patch = semVersion.Patch;
+            Prerelease = semVersion.Prerelease;
+            PrereleaseIdentifiers = semVersion.PrereleaseIdentifiers;
+            Metadata = semVersion.Metadata;
+            MetadataIdentifiers = semVersion.MetadataIdentifiers;
+        }
+
+        /// 
+        /// Populates a  with the data needed to serialize the target object.
+        /// 
+        /// The  to populate with data.
+        /// The destination (see ) for this serialization.
+        [SecurityPermission(SecurityAction.Demand, SerializationFormatter = true)]
+        public void GetObjectData(SerializationInfo info, StreamingContext context)
+        {
+            if (info == null) throw new ArgumentNullException(nameof(info));
+            info.AddValue("SemVersion", ToString());
+        }
+#endif
+
+        /// 
+        /// Constructs a new instance of the  class.
+        /// 
+        /// The major version number.
+        // Constructor needed to resolve ambiguity between other overloads with default parameters.
+        public SemVersion(int major)
+        {
+            Major = major;
+            Minor = 0;
+            Patch = 0;
+            Prerelease = "";
+            PrereleaseIdentifiers = ReadOnlyList.Empty;
+            Metadata = "";
+            MetadataIdentifiers = ReadOnlyList.Empty;
+        }
+
+        /// 
+        /// Constructs a new instance of the  class.
+        /// 
+        /// The major version number.
+        /// The minor version number.
+        // Constructor needed to resolve ambiguity between other overloads with default parameters.
+        public SemVersion(int major, int minor)
+        {
+            Major = major;
+            Minor = minor;
+            Patch = 0;
+            Prerelease = "";
+            PrereleaseIdentifiers = ReadOnlyList.Empty;
+            Metadata = "";
+            MetadataIdentifiers = ReadOnlyList.Empty;
+        }
+
+        /// 
+        /// Constructs a new instance of the  class.
+        /// 
+        /// The major version number.
+        /// The minor version number.
+        /// The patch version number.
+        // Constructor needed to resolve ambiguity between other overloads with default parameters.
+        public SemVersion(int major, int minor, int patch)
+        {
+            Major = major;
+            Minor = minor;
+            Patch = patch;
+            Prerelease = "";
+            PrereleaseIdentifiers = ReadOnlyList.Empty;
+            Metadata = "";
+            MetadataIdentifiers = ReadOnlyList.Empty;
+        }
+
+        /// 
+        /// Constructs a new instance of the  class.
+        /// 
+        /// The major version number.
+        /// The minor version number.
+        /// The patch version number.
+        /// The prerelease portion (e.g. "alpha.5").
+        /// The build metadata (e.g. "nightly.232").
+        [EditorBrowsable(EditorBrowsableState.Never), Obsolete("This constructor is obsolete. Use another constructor or SemVersion.ParsedFrom() instead.")]
+        public SemVersion(int major, int minor = 0, int patch = 0, string prerelease = "", string build = "")
+        {
+            Major = major;
+            Minor = minor;
+            Patch = patch;
+
+            prerelease = prerelease ?? "";
+            Prerelease = prerelease;
+            PrereleaseIdentifiers = prerelease.SplitAndMapToReadOnlyList('.', PrereleaseIdentifier.CreateLoose);
+
+            build = build ?? "";
+            Metadata = build;
+            MetadataIdentifiers = build.SplitAndMapToReadOnlyList('.', MetadataIdentifier.CreateLoose);
+        }
+
+        /// 
+        /// Constructs a new instance of the  class.
+        /// 
+        /// The major version number.
+        /// The minor version number.
+        /// The patch version number.
+        /// The prerelease identifiers.
+        /// The build metadata identifiers.
+        /// A ,
+        /// , or  version number is negative.
+        /// A prerelease or metadata identifier has the default value.
+        public SemVersion(int major, int minor = 0, int patch = 0,
+            IEnumerable prerelease = null,
+            IEnumerable metadata = null)
+        {
+            if (major < 0) throw new ArgumentOutOfRangeException(nameof(major), InvalidMajorVersionMessage);
+            if (minor < 0) throw new ArgumentOutOfRangeException(nameof(minor), InvalidMinorVersionMessage);
+            if (patch < 0) throw new ArgumentOutOfRangeException(nameof(patch), InvalidPatchVersionMessage);
+            IReadOnlyList prereleaseIdentifiers;
+            if (prerelease is null)
+                prereleaseIdentifiers = null;
+            else
+            {
+                prereleaseIdentifiers = prerelease.ToReadOnlyList();
+                if (prereleaseIdentifiers.Any(i => i == default))
+                    throw new ArgumentException(PrereleaseIdentifierIsDefaultMessage, nameof(prerelease));
+            }
+
+            IReadOnlyList metadataIdentifiers;
+            if (metadata is null)
+                metadataIdentifiers = null;
+            else
+            {
+                metadataIdentifiers = metadata.ToReadOnlyList();
+                if (metadataIdentifiers.Any(i => i == default))
+                    throw new ArgumentException(MetadataIdentifierIsDefaultMessage, nameof(metadata));
+            }
+
+            Major = major;
+            Minor = minor;
+            Patch = patch;
+
+            if (prereleaseIdentifiers is null || prereleaseIdentifiers.Count == 0)
+            {
+                Prerelease = "";
+                PrereleaseIdentifiers = ReadOnlyList.Empty;
+            }
+            else
+            {
+                Prerelease = string.Join(".", prereleaseIdentifiers);
+                PrereleaseIdentifiers = prereleaseIdentifiers;
+            }
+
+            if (metadataIdentifiers is null || metadataIdentifiers.Count == 0)
+            {
+                Metadata = "";
+                MetadataIdentifiers = ReadOnlyList.Empty;
+            }
+            else
+            {
+                Metadata = string.Join(".", metadataIdentifiers);
+                MetadataIdentifiers = metadataIdentifiers;
+            }
+        }
+
+        /// 
+        /// Constructs a new instance of the  class.
+        /// 
+        /// The major version number.
+        /// The minor version number.
+        /// The patch version number.
+        /// The prerelease identifiers.
+        /// The build metadata identifiers.
+        /// A ,
+        /// , or  version number is negative.
+        /// One of the prerelease or metadata identifiers is .
+        /// A prerelease identifier is empty or contains invalid
+        /// characters (i.e. characters that are not ASCII alphanumerics or hyphens) or has leading
+        /// zeros for a numeric identifier. Or, a metadata identifier is empty or contains invalid
+        /// characters (i.e. characters that are not ASCII alphanumerics or hyphens).
+        /// A numeric prerelease identifier value is too large
+        /// for .
+        public SemVersion(int major, int minor = 0, int patch = 0,
+            IEnumerable prerelease = null,
+            IEnumerable metadata = null)
+        {
+            if (major < 0) throw new ArgumentOutOfRangeException(nameof(major), InvalidMajorVersionMessage);
+            if (minor < 0) throw new ArgumentOutOfRangeException(nameof(minor), InvalidMinorVersionMessage);
+            if (patch < 0) throw new ArgumentOutOfRangeException(nameof(patch), InvalidPatchVersionMessage);
+            var prereleaseIdentifiers = prerelease?
+                                        .Select(i => new PrereleaseIdentifier(i, allowLeadingZeros: false, nameof(prerelease)))
+                                        .ToReadOnlyList();
+
+            var metadataIdentifiers = metadata?
+                                      .Select(i => new MetadataIdentifier(i, nameof(metadata)))
+                                      .ToReadOnlyList();
+
+            Major = major;
+            Minor = minor;
+            Patch = patch;
+
+            if (prereleaseIdentifiers is null || prereleaseIdentifiers.Count == 0)
+            {
+                Prerelease = "";
+                PrereleaseIdentifiers = ReadOnlyList.Empty;
+            }
+            else
+            {
+                Prerelease = string.Join(".", prereleaseIdentifiers);
+                PrereleaseIdentifiers = prereleaseIdentifiers;
+            }
+
+            if (metadataIdentifiers is null || metadataIdentifiers.Count == 0)
+            {
+                Metadata = "";
+                MetadataIdentifiers = ReadOnlyList.Empty;
+            }
+            else
+            {
+                Metadata = string.Join(".", metadataIdentifiers);
+                MetadataIdentifiers = metadataIdentifiers;
+            }
+        }
+
+        /// 
+        /// Create a new instance of the  class. Parses prerelease
+        /// and metadata identifiers from dot separated strings. If parsing is not needed, use a
+        /// constructor instead.
+        /// 
+        /// The major version number.
+        /// The minor version number.
+        /// The patch version number.
+        /// The prerelease portion (e.g. "alpha.5").
+        /// The build metadata (e.g. "nightly.232").
+        /// Allow leading zeros in numeric prerelease identifiers. Leading
+        /// zeros will be removed.
+        /// A ,
+        /// , or  version number is negative.
+        /// A prerelease identifier is empty or contains invalid
+        /// characters (i.e. characters that are not ASCII alphanumerics or hyphens) or has leading
+        /// zeros for a numeric identifier when  is
+        /// . Or, a metadata identifier is empty or contains invalid
+        /// characters (i.e. characters that are not ASCII alphanumerics or hyphens).
+        /// A numeric prerelease identifier value is too large
+        /// for .
+        public static SemVersion ParsedFrom(int major, int minor = 0, int patch = 0,
+            string prerelease = "", string metadata = "", bool allowLeadingZeros = false)
+        {
+            if (major < 0) throw new ArgumentOutOfRangeException(nameof(major), InvalidMajorVersionMessage);
+            if (minor < 0) throw new ArgumentOutOfRangeException(nameof(minor), InvalidMinorVersionMessage);
+            if (patch < 0) throw new ArgumentOutOfRangeException(nameof(patch), InvalidPatchVersionMessage);
+
+            if (prerelease is null) throw new ArgumentNullException(nameof(prerelease));
+            var prereleaseIdentifiers = prerelease.Length == 0
+                ? ReadOnlyList.Empty
+                : prerelease.SplitAndMapToReadOnlyList('.',
+                    i => new PrereleaseIdentifier(i, allowLeadingZeros, nameof(prerelease)));
+            if (allowLeadingZeros)
+                // Leading zeros may have been removed, need to reconstruct the prerelease string
+                prerelease = string.Join(".", prereleaseIdentifiers);
+
+            if (metadata is null) throw new ArgumentNullException(nameof(metadata));
+            var metadataIdentifiers = metadata.Length == 0
+                ? ReadOnlyList.Empty
+                : metadata.SplitAndMapToReadOnlyList('.', i => new MetadataIdentifier(i, nameof(metadata)));
+
+            return new SemVersion(major, minor, patch,
+                prerelease, prereleaseIdentifiers, metadata, metadataIdentifiers);
+        }
+
+        /// 
+        /// Constructs a new instance of the  class from
+        /// a .
+        /// 
+        ///  used to initialize
+        /// the major, minor, and patch version numbers and the build metadata.
+        /// The  is null.
+        /// Constructs a  with the same major and
+        /// minor version numbers. The patch version number will be the fourth component
+        /// of the . The build meta data will contain the third component
+        /// of the  if it is greater than zero.
+        [EditorBrowsable(EditorBrowsableState.Never), Obsolete("This constructor is obsolete. Use SemVersion.FromVersion() instead.")]
+        public SemVersion(Version version)
+        {
+            if (version == null)
+                throw new ArgumentNullException(nameof(version));
+
+            Major = version.Major;
+            Minor = version.Minor;
+
+            if (version.Revision >= 0)
+                Patch = version.Revision;
+
+            Prerelease = "";
+            PrereleaseIdentifiers = ReadOnlyList.Empty;
+
+            if (version.Build > 0)
+            {
+                Metadata = version.Build.ToString(CultureInfo.InvariantCulture);
+                MetadataIdentifiers = new List(1) { MetadataIdentifier.CreateUnsafe(Metadata) }.AsReadOnly();
+            }
+            else
+            {
+                Metadata = "";
+                MetadataIdentifiers = ReadOnlyList.Empty;
+            }
+        }
+
+        /// 
+        /// Construct a  from its proper parts.
+        /// 
+        /// Parameter validation is not performed. The ,
+        /// , and  version numbers must not be
+        /// negative. The  and
+        ///  must not be  or
+        /// contain invalid values and must be immutable. The 
+        /// and  must not be null and must be equal to the
+        /// corresponding identifiers.
+        internal SemVersion(int major, int minor, int patch,
+            string prerelease, IReadOnlyList prereleaseIdentifiers,
+            string metadata, IReadOnlyList metadataIdentifiers)
+        {
+            DebugChecks.IsValidVersionNumber(major, "Major", nameof(major));
+            DebugChecks.IsValidVersionNumber(minor, "Minor", nameof(minor));
+            DebugChecks.IsValidVersionNumber(patch, "Patch", nameof(patch));
+            DebugChecks.IsNotNull(prerelease, nameof(prerelease));
+            DebugChecks.IsNotNull(prerelease, nameof(prereleaseIdentifiers));
+            DebugChecks.ContainsNoDefaultValues(prereleaseIdentifiers, "Prerelease", nameof(prereleaseIdentifiers));
+            DebugChecks.AreEqualWhenJoinedWithDots(prerelease, nameof(prerelease),
+                prereleaseIdentifiers, nameof(prereleaseIdentifiers));
+            DebugChecks.IsNotNull(metadata, nameof(metadata));
+            DebugChecks.IsNotNull(metadataIdentifiers, nameof(metadataIdentifiers));
+            DebugChecks.ContainsNoDefaultValues(metadataIdentifiers, "Metadata", nameof(metadataIdentifiers));
+            DebugChecks.AreEqualWhenJoinedWithDots(metadata, nameof(metadata),
+                metadataIdentifiers, nameof(metadataIdentifiers));
+
+            Major = major;
+            Minor = minor;
+            Patch = patch;
+            Prerelease = prerelease;
+            PrereleaseIdentifiers = prereleaseIdentifiers;
+            Metadata = metadata;
+            MetadataIdentifiers = metadataIdentifiers;
+        }
+
+        #region System.Version
+        /// 
+        /// Converts a  into the equivalent semantic version.
+        /// 
+        /// The version to be converted to a semantic version.
+        /// The equivalent semantic version.
+        ///  is .
+        ///  has a revision number greater than zero.
+        /// 
+        ///  numbers have the form major.minor[.build[.revision]]
+        /// where square brackets ('[' and ']')  indicate optional components. The first three parts
+        /// are converted to the major, minor, and patch version numbers of a semantic version. If the
+        /// build component is not defined (-1), the patch number is assumed to be zero.
+        ///  numbers with a revision greater than zero cannot be converted to
+        /// semantic versions. An  is thrown when this method is called
+        /// with such a .
+        /// 
+        public static SemVersion FromVersion(Version version)
+        {
+            if (version is null) throw new ArgumentNullException(nameof(version));
+            if (version.Revision > 0) throw new ArgumentException("Version with Revision number can't be converted to SemVer.", nameof(version));
+            var patch = version.Build > 0 ? version.Build : 0;
+            return new SemVersion(version.Major, version.Minor, patch);
+        }
+
+        /// 
+        /// Converts this semantic version to a .
+        /// 
+        /// The equivalent .
+        /// The semantic version is a prerelease version
+        /// or has build metadata or has a negative major, minor, or patch version number.
+        /// 
+        /// A semantic version of the form major.minor.patch
+        /// is converted to a  of the form
+        /// major.minor.build where the build number is the
+        /// patch version of the semantic version. Prerelease versions and build metadata
+        /// are not representable in a . This method throws
+        /// an  if the semantic version is a
+        /// prerelease version or has build metadata.
+        /// 
+        public Version ToVersion()
+        {
+            if (Major < 0 || Minor < 0 || Patch < 0) throw new InvalidOperationException("Negative version numbers can't be converted to System.Version.");
+            if (IsPrerelease) throw new InvalidOperationException("Prerelease version can't be converted to System.Version.");
+            if (Metadata.Length != 0) throw new InvalidOperationException("Version with build metadata can't be converted to System.Version.");
+
+            return new Version(Major, Minor, Patch);
+        }
+        #endregion
+
+        /// 
+        /// Converts the string representation of a semantic version to its  equivalent.
+        /// 
+        /// The version string.
+        /// A bitwise combination of enumeration values that indicates the style
+        /// elements that can be present in . The preferred value to use
+        /// is .
+        /// The maximum length of  that should be
+        /// parsed. This prevents attacks using very long version strings.
+        ///  is not a valid
+        ///  value.
+        ///  is .
+        /// The  is invalid or not in a
+        /// format compliant with .
+        /// A numeric part of  is too
+        /// large for an .
+        public static SemVersion Parse(string version, SemVersionStyles style, int maxLength = MaxVersionLength)
+        {
+            if (!style.IsValid()) throw new ArgumentException(InvalidSemVersionStylesMessage, nameof(style));
+            // TODO include in v3.0.0 for issue #72
+            //if (maxLength < 0) throw new ArgumentOutOfRangeException(InvalidMaxLengthMessage, nameof(maxLength));
+            var ex = SemVersionParser.Parse(version, style, null, maxLength, out var semver);
+
+            return ex is null ? semver : throw ex;
+        }
+
+        /// 
+        /// Converts the string representation of a semantic version to its  equivalent.
+        /// 
+        /// The version string.
+        /// If set to , minor and patch version are required;
+        /// otherwise they are optional.
+        /// The  is .
+        /// The  has an invalid format.
+        /// The  is missing minor
+        /// or patch version numbers when  is .
+        /// The major, minor, or patch version number is larger
+        /// than .
+        [EditorBrowsable(EditorBrowsableState.Never), Obsolete("Method is obsolete. Use Parse() overload with SemVersionStyles instead.")]
+        public static SemVersion Parse(string version, bool strict = false)
+        {
+            var match = ParseRegex.Match(version);
+            if (!match.Success)
+                throw new ArgumentException($"Invalid version '{version}'.", nameof(version));
+
+            var major = int.Parse(match.Groups["major"].Value, CultureInfo.InvariantCulture);
+
+            var minorMatch = match.Groups["minor"];
+            int minor = 0;
+            if (minorMatch.Success)
+                minor = int.Parse(minorMatch.Value, CultureInfo.InvariantCulture);
+            else if (strict)
+                throw new InvalidOperationException("Invalid version (no minor version given in strict mode)");
+
+            var patchMatch = match.Groups["patch"];
+            int patch = 0;
+            if (patchMatch.Success)
+                patch = int.Parse(patchMatch.Value, CultureInfo.InvariantCulture);
+            else if (strict)
+                throw new InvalidOperationException("Invalid version (no patch version given in strict mode)");
+
+            var prerelease = match.Groups["pre"].Value;
+            var metadata = match.Groups["metadata"].Value;
+
+            return new SemVersion(major, minor, patch, prerelease, metadata);
+        }
+
+        /// 
+        /// Converts the string representation of a semantic version to its 
+        /// equivalent. The return value indicates whether the conversion succeeded.
+        /// 
+        /// The version string.
+        /// A bitwise combination of enumeration values that indicates the style
+        /// elements that can be present in . The preferred value to use
+        /// is .
+        /// When this method returns, contains a  instance equivalent
+        /// to the version string passed in, if the version string was valid, or  if the
+        /// version string was invalid.
+        /// The maximum length of  that should be
+        /// parsed. This prevents attacks using very long version strings.
+        ///  when an invalid version string is passed, otherwise .
+        ///  is not a valid
+        ///  value.
+        public static bool TryParse(string version, SemVersionStyles style,
+            out SemVersion semver, int maxLength = MaxVersionLength)
+        {
+            if (!style.IsValid()) throw new ArgumentException(InvalidSemVersionStylesMessage, nameof(style));
+            // TODO include in v3.0.0 for issue #72
+            //if (maxLength < 0) throw new ArgumentOutOfRangeException(InvalidMaxLengthMessage, nameof(maxLength));
+            var exception = SemVersionParser.Parse(version, style, VersionParsing.FailedException, maxLength, out semver);
+
+#if DEBUG
+            // This check ensures that SemVersionParser.Parse doesn't construct an exception, but always returns ParseFailedException
+            if (exception != null && exception != VersionParsing.FailedException)
+                throw new InvalidOperationException($"DEBUG: {nameof(SemVersionParser)}.{nameof(SemVersionParser.Parse)} returned exception other than {nameof(VersionParsing.FailedException)}", exception);
+#endif
+
+            return exception is null;
+        }
+
+        /// 
+        /// Converts the string representation of a semantic version to its 
+        /// equivalent. The return value indicates whether the conversion succeeded.
+        /// 
+        /// The version string.
+        /// When this method returns, contains a  instance equivalent
+        /// to the version string passed in, if the version string was valid, or  if the
+        /// version string was invalid.
+        /// If set to , minor and patch version numbers are required;
+        /// otherwise they are optional.
+        ///  when an invalid version string is passed, otherwise .
+        [EditorBrowsable(EditorBrowsableState.Never), Obsolete("Method is obsolete. Use TryParse() overload with SemVersionStyles instead.")]
+        public static bool TryParse(string version, out SemVersion semver, bool strict = false)
+        {
+            semver = null;
+            if (version is null) return false;
+
+            var match = ParseRegex.Match(version);
+            if (!match.Success) return false;
+
+            if (!int.TryParse(match.Groups["major"].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var major))
+                return false;
+
+            var minorMatch = match.Groups["minor"];
+            int minor = 0;
+            if (minorMatch.Success)
+            {
+                if (!int.TryParse(minorMatch.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out minor))
+                    return false;
+            }
+            else if (strict) return false;
+
+            var patchMatch = match.Groups["patch"];
+            int patch = 0;
+            if (patchMatch.Success)
+            {
+                if (!int.TryParse(patchMatch.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out patch))
+                    return false;
+            }
+            else if (strict) return false;
+
+            var prerelease = match.Groups["pre"].Value;
+            var metadata = match.Groups["metadata"].Value;
+
+            semver = new SemVersion(major, minor, patch, prerelease, metadata);
+            return true;
+        }
+
+        /// 
+        /// Compares two versions and indicates whether the first precedes, follows, or is
+        /// equal to the other in the sort order. Note that sort order is more specific than precedence order.
+        /// 
+        /// 
+        /// An integer that indicates whether  precedes, follows, or
+        /// is equal to  in the sort order.
+        /// 
+        ///     
+        ///         Value
+        ///         Condition
+        ///     
+        ///     
+        ///         Less than zero
+        ///          precedes  in the sort order.
+        ///     
+        ///     
+        ///         Zero
+        ///          is equal to .
+        ///     
+        ///     
+        ///         Greater than zero
+        ///         
+        ///              follows  in the sort order
+        ///             or  is .
+        ///         
+        ///     
+        /// 
+        /// 
+        /// 
+        [EditorBrowsable(EditorBrowsableState.Never), Obsolete("Method is obsolete. Use CompareSortOrder() or ComparePrecedence() instead.")]
+        public static int Compare(SemVersion versionA, SemVersion versionB)
+        {
+            if (ReferenceEquals(versionA, versionB)) return 0;
+            if (versionA is null) return -1;
+            if (versionB is null) return 1;
+            return versionA.CompareTo(versionB);
+        }
+
+        /// 
+        /// Make a copy of the current instance with changed properties.
+        /// 
+        /// The value to replace the major version number or
+        ///  to leave it unchanged.
+        /// The value to replace the minor version number or
+        ///  to leave it unchanged.
+        /// The value to replace the patch version number or
+        ///  to leave it unchanged.
+        /// The value to replace the prerelease portion
+        /// or  to leave it unchanged.
+        /// The value to replace the build metadata or 
+        /// to leave it unchanged.
+        /// The new version with changed properties.
+        /// 
+        /// The change method is intended to be called using named argument syntax, passing only
+        /// those fields to be changed.
+        /// 
+        /// 
+        /// To change only the patch version:
+        /// var changedVersion = version.Change(patch: 4);
+        /// 
+        [EditorBrowsable(EditorBrowsableState.Never), Obsolete("Method is obsolete. Use With() or With...() method instead.")]
+        public SemVersion Change(int? major = null, int? minor = null, int? patch = null,
+            string prerelease = null, string build = null)
+        {
+            return new SemVersion(
+                major ?? Major,
+                minor ?? Minor,
+                patch ?? Patch,
+                prerelease ?? Prerelease,
+                build ?? Metadata);
+        }
+
+        /// 
+        /// Creates a copy of the current instance with multiple changed properties. If changing only
+        /// one property use one of the more specific WithX() methods.
+        /// 
+        /// The value to replace the major version number or  to leave it unchanged.
+        /// The value to replace the minor version number or  to leave it unchanged.
+        /// The value to replace the patch version number or  to leave it unchanged.
+        /// The value to replace the prerelease identifiers or  to leave it unchanged.
+        /// The value to replace the build metadata identifiers or  to leave it unchanged.
+        /// The new version with changed properties.
+        /// A ,
+        /// , or  version number is negative.
+        /// A prerelease or metadata identifier has the default value.
+        /// A numeric prerelease identifier value is too large
+        /// for .
+        /// 
+        /// The  method is intended to be called using named argument syntax, passing only
+        /// those fields to be changed.
+        /// 
+        /// 
+        /// To change the minor and patch versions:
+        /// var modifiedVersion = version.With(minor: 2, patch: 4);
+        /// 
+        public SemVersion With(
+            int? major = null,
+            int? minor = null,
+            int? patch = null,
+            IEnumerable prerelease = null,
+            IEnumerable metadata = null)
+        {
+            // Note: It is tempting to null coalesce first, but then this method would report invalid
+            // arguments on invalid SemVersion instances.
+            if (major is int majorInt && majorInt < 0)
+                throw new ArgumentOutOfRangeException(nameof(major), InvalidMajorVersionMessage);
+            if (minor is int minorInt && minorInt < 0)
+                throw new ArgumentOutOfRangeException(nameof(minor), InvalidMinorVersionMessage);
+            if (patch is int patchInt && patchInt < 0)
+                throw new ArgumentOutOfRangeException(nameof(patch), InvalidPatchVersionMessage);
+
+            IReadOnlyList prereleaseIdentifiers = null;
+            string prereleaseString = null;
+            if (prerelease != null)
+            {
+                prereleaseIdentifiers = prerelease.ToReadOnlyList();
+                if (prereleaseIdentifiers.Count == 0)
+                {
+                    prereleaseIdentifiers = ReadOnlyList.Empty;
+                    prereleaseString = "";
+                }
+                else if (prereleaseIdentifiers.Any(i => i == default))
+                    throw new ArgumentException(PrereleaseIdentifierIsDefaultMessage, nameof(prerelease));
+                else
+                    prereleaseString = string.Join(".", prereleaseIdentifiers);
+            }
+
+            IReadOnlyList metadataIdentifiers = null;
+            string metadataString = null;
+            if (metadata != null)
+            {
+                metadataIdentifiers = metadata.ToReadOnlyList();
+                if (metadataIdentifiers.Count == 0)
+                {
+                    metadataIdentifiers = ReadOnlyList.Empty;
+                    metadataString = "";
+                }
+                else if (metadataIdentifiers.Any(i => i == default))
+                    throw new ArgumentException(MetadataIdentifierIsDefaultMessage, nameof(metadata));
+                else
+                    metadataString = string.Join(".", metadataIdentifiers);
+            }
+
+            return new SemVersion(
+                major ?? Major,
+                minor ?? Minor,
+                patch ?? Patch,
+                prereleaseString ?? Prerelease,
+                prereleaseIdentifiers ?? PrereleaseIdentifiers,
+                metadataString ?? Metadata,
+                metadataIdentifiers ?? MetadataIdentifiers);
+        }
+
+        /// 
+        /// Creates a copy of the current instance with multiple changed properties. Parses prerelease
+        /// and metadata identifiers from dot separated strings. Use  instead if
+        /// parsing is not needed. If changing only one property use one of the more specific
+        /// WithX() methods.
+        /// 
+        /// The value to replace the major version number or  to leave it unchanged.
+        /// The value to replace the minor version number or  to leave it unchanged.
+        /// The value to replace the patch version number or  to leave it unchanged.
+        /// The value to replace the prerelease identifiers or  to leave it unchanged.
+        /// The value to replace the build metadata identifiers or  to leave it unchanged.
+        /// Allow leading zeros in numeric prerelease identifiers. Leading
+        /// zeros will be removed.
+        /// The new version with changed properties.
+        /// A ,
+        /// , or  version number is negative.
+        /// A prerelease identifier is empty or contains invalid
+        /// characters (i.e. characters that are not ASCII alphanumerics or hyphens) or has leading
+        /// zeros for a numeric identifier when  is
+        /// . Or, a metadata identifier is empty or contains invalid
+        /// characters (i.e. characters that are not ASCII alphanumerics or hyphens).
+        /// A numeric prerelease identifier value is too large
+        /// for .
+        /// 
+        /// The  method is intended to be called using named argument
+        /// syntax, passing only those fields to be changed.
+        /// 
+        /// 
+        /// To change the patch version and prerelease identifiers version:
+        /// var modifiedVersion = version.WithParsedFrom(patch: 4, prerelease: "alpha.5");
+        /// 
+        public SemVersion WithParsedFrom(
+            int? major = null,
+            int? minor = null,
+            int? patch = null,
+            string prerelease = null,
+            string metadata = null,
+            bool allowLeadingZeros = false)
+        {
+            // Note: It is tempting to null coalesce first, but then this method would report invalid
+            // arguments on invalid SemVersion instances.
+            if (major is int majorInt && majorInt < 0)
+                throw new ArgumentOutOfRangeException(nameof(major), InvalidMajorVersionMessage);
+            if (minor is int minorInt && minorInt < 0)
+                throw new ArgumentOutOfRangeException(nameof(minor), InvalidMinorVersionMessage);
+            if (patch is int patchInt && patchInt < 0)
+                throw new ArgumentOutOfRangeException(nameof(patch), InvalidPatchVersionMessage);
+
+            var prereleaseIdentifiers = prerelease?.SplitAndMapToReadOnlyList('.',
+                i => new PrereleaseIdentifier(i, allowLeadingZeros, nameof(prerelease)));
+            var metadataIdentifiers = metadata?.SplitAndMapToReadOnlyList('.',
+                i => new MetadataIdentifier(i, nameof(metadata)));
+
+            if (allowLeadingZeros && prerelease != null)
+                // Leading zeros may have been removed, need to reconstruct the prerelease string
+                prerelease = string.Join(".", prereleaseIdentifiers);
+
+            return new SemVersion(
+                major ?? Major,
+                minor ?? Minor,
+                patch ?? Patch,
+                prerelease ?? Prerelease,
+                prereleaseIdentifiers ?? PrereleaseIdentifiers,
+                metadata ?? Metadata,
+                metadataIdentifiers ?? MetadataIdentifiers);
+        }
+
+        #region With... Methods
+        /// 
+        /// Creates a copy of the current instance with a different major version number.
+        /// 
+        /// The value to replace the major version number.
+        /// The new version with the different major version number.
+        ///  is negative.
+        public SemVersion WithMajor(int major)
+        {
+            if (major < 0) throw new ArgumentOutOfRangeException(nameof(major), InvalidMajorVersionMessage);
+            if (Major == major) return this;
+            return new SemVersion(major, Minor, Patch,
+                Prerelease, PrereleaseIdentifiers, Metadata, MetadataIdentifiers);
+        }
+
+        /// 
+        /// Creates a copy of the current instance with a different minor version number.
+        /// 
+        /// The value to replace the minor version number.
+        /// The new version with the different minor version number.
+        ///  is negative.
+        public SemVersion WithMinor(int minor)
+        {
+            if (minor < 0) throw new ArgumentOutOfRangeException(nameof(minor), InvalidMinorVersionMessage);
+            if (Minor == minor) return this;
+            return new SemVersion(Major, minor, Patch,
+                Prerelease, PrereleaseIdentifiers, Metadata, MetadataIdentifiers);
+        }
+
+        /// 
+        /// Creates a copy of the current instance with a different patch version number.
+        /// 
+        /// The value to replace the patch version number.
+        /// The new version with the different patch version number.
+        ///  is negative.
+        public SemVersion WithPatch(int patch)
+        {
+            if (patch < 0) throw new ArgumentOutOfRangeException(nameof(patch), InvalidPatchVersionMessage);
+            if (Patch == patch) return this;
+            return new SemVersion(Major, Minor, patch,
+                Prerelease, PrereleaseIdentifiers, Metadata, MetadataIdentifiers);
+        }
+
+        /// 
+        /// Creates a copy of the current instance with a different prerelease portion.
+        /// 
+        /// The value to replace the prerelease portion.
+        /// Whether to allow leading zeros in the prerelease identifiers.
+        /// If , leading zeros will be allowed on numeric identifiers
+        /// but will be removed.
+        /// The new version with the different prerelease identifiers.
+        ///  is .
+        /// A prerelease identifier is empty or contains invalid
+        /// characters (i.e. characters that are not ASCII alphanumerics or hyphens) or has leading
+        /// zeros for a numeric identifier when  is .
+        /// A numeric prerelease identifier value is too large
+        /// for .
+        /// Because a valid numeric identifier does not have leading zeros, this constructor
+        /// will never create a  with leading zeros even if
+        ///  is . Any leading zeros will
+        /// be removed.
+        public SemVersion WithPrereleaseParsedFrom(string prerelease, bool allowLeadingZeros = false)
+        {
+            if (prerelease is null) throw new ArgumentNullException(nameof(prerelease));
+            if (prerelease.Length == 0) return WithoutPrerelease();
+            var identifiers = prerelease.SplitAndMapToReadOnlyList('.',
+                i => new PrereleaseIdentifier(i, allowLeadingZeros, nameof(prerelease)));
+            if (allowLeadingZeros)
+                // Leading zeros may have been removed, need to reconstruct the prerelease string
+                prerelease = string.Join(".", identifiers);
+            return new SemVersion(Major, Minor, Patch,
+                prerelease, identifiers, Metadata, MetadataIdentifiers);
+        }
+
+        /// 
+        /// Creates a copy of the current instance with different prerelease identifiers.
+        /// 
+        /// The first identifier to replace the existing
+        /// prerelease identifiers.
+        /// The rest of the identifiers to replace the
+        /// existing prerelease identifiers.
+        /// The new version with the different prerelease identifiers.
+        ///  or
+        ///  is  or one of the
+        /// prerelease identifiers is .
+        /// A prerelease identifier is empty or contains invalid
+        /// characters (i.e. characters that are not ASCII alphanumerics or hyphens) or has leading
+        /// zeros for a numeric identifier.
+        /// A numeric prerelease identifier value is too large
+        /// for .
+        public SemVersion WithPrerelease(string prereleaseIdentifier, params string[] prereleaseIdentifiers)
+        {
+            if (prereleaseIdentifier is null) throw new ArgumentNullException(nameof(prereleaseIdentifier));
+            if (prereleaseIdentifiers is null) throw new ArgumentNullException(nameof(prereleaseIdentifiers));
+            var identifiers = prereleaseIdentifiers
+                              .Prepend(prereleaseIdentifier)
+                              .Select(i => new PrereleaseIdentifier(i, allowLeadingZeros: false, nameof(prereleaseIdentifiers)))
+                              .ToReadOnlyList();
+            return new SemVersion(Major, Minor, Patch,
+                string.Join(".", identifiers), identifiers, Metadata, MetadataIdentifiers);
+        }
+
+        /// 
+        /// Creates a copy of the current instance with different prerelease identifiers.
+        /// 
+        /// The identifiers to replace the prerelease identifiers.
+        /// The new version with the different prerelease identifiers.
+        ///  is
+        ///  or one of the prerelease identifiers is .
+        /// A prerelease identifier is empty or contains invalid
+        /// characters (i.e. characters that are not ASCII alphanumerics or hyphens) or has leading
+        /// zeros for a numeric identifier.
+        /// A numeric prerelease identifier value is too large
+        /// for .
+        public SemVersion WithPrerelease(IEnumerable prereleaseIdentifiers)
+        {
+            if (prereleaseIdentifiers is null) throw new ArgumentNullException(nameof(prereleaseIdentifiers));
+            var identifiers = prereleaseIdentifiers
+                              .Select(i => new PrereleaseIdentifier(i, allowLeadingZeros: false, nameof(prereleaseIdentifiers)))
+                              .ToReadOnlyList();
+            if (identifiers.Count == 0) return WithoutPrerelease();
+            return new SemVersion(Major, Minor, Patch,
+                string.Join(".", identifiers), identifiers, Metadata, MetadataIdentifiers);
+        }
+
+        /// 
+        /// Creates a copy of the current instance with different prerelease identifiers.
+        /// 
+        /// The first identifier to replace the existing
+        /// prerelease identifiers.
+        /// The rest of the identifiers to replace the
+        /// existing prerelease identifiers.
+        /// The new version with the different prerelease identifiers.
+        ///  is
+        /// .
+        /// A prerelease identifier has the default value.
+        public SemVersion WithPrerelease(
+                    PrereleaseIdentifier prereleaseIdentifier,
+                    params PrereleaseIdentifier[] prereleaseIdentifiers)
+        {
+            if (prereleaseIdentifiers is null) throw new ArgumentNullException(nameof(prereleaseIdentifiers));
+            var identifiers = prereleaseIdentifiers.Prepend(prereleaseIdentifier).ToReadOnlyList();
+            if (identifiers.Any(i => i == default)) throw new ArgumentException(PrereleaseIdentifierIsDefaultMessage, nameof(prereleaseIdentifiers));
+            return new SemVersion(Major, Minor, Patch,
+                string.Join(".", identifiers), identifiers, Metadata, MetadataIdentifiers);
+        }
+
+        /// 
+        /// Creates a copy of the current instance with different prerelease identifiers.
+        /// 
+        /// The identifiers to replace the prerelease identifiers.
+        /// The new version with the different prerelease identifiers.
+        ///  is .
+        /// A prerelease identifier has the default value.
+        public SemVersion WithPrerelease(IEnumerable prereleaseIdentifiers)
+        {
+            if (prereleaseIdentifiers is null) throw new ArgumentNullException(nameof(prereleaseIdentifiers));
+            var identifiers = prereleaseIdentifiers.ToReadOnlyList();
+            if (identifiers.Count == 0) return WithoutPrerelease();
+            if (identifiers.Any(i => i == default)) throw new ArgumentException(PrereleaseIdentifierIsDefaultMessage, nameof(prereleaseIdentifiers));
+            return new SemVersion(Major, Minor, Patch,
+                string.Join(".", identifiers), identifiers, Metadata, MetadataIdentifiers);
+        }
+
+        /// 
+        /// Creates a copy of the current instance without prerelease identifiers.
+        /// 
+        /// The new version without prerelease identifiers.
+        public SemVersion WithoutPrerelease()
+        {
+            if (!IsPrerelease) return this;
+            return new SemVersion(Major, Minor, Patch,
+                "", ReadOnlyList.Empty, Metadata, MetadataIdentifiers);
+        }
+
+        /// 
+        /// Creates a copy of the current instance with different build metadata.
+        /// 
+        /// The value to replace the build metadata.
+        /// The new version with the different build metadata.
+        ///  is .
+        /// A metadata identifier is empty or contains invalid
+        /// characters (i.e. characters that are not ASCII alphanumerics or hyphens).
+        public SemVersion WithMetadataParsedFrom(string metadata)
+        {
+            if (metadata is null) throw new ArgumentNullException(nameof(metadata));
+            if (metadata.Length == 0) return WithoutMetadata();
+            var identifiers = metadata.SplitAndMapToReadOnlyList('.',
+                i => new MetadataIdentifier(i, nameof(metadata)));
+            return new SemVersion(Major, Minor, Patch,
+                Prerelease, PrereleaseIdentifiers, metadata, identifiers);
+        }
+
+        /// 
+        /// Creates a copy of the current instance with different build metadata identifiers.
+        /// 
+        /// The first identifier to replace the existing
+        /// build metadata identifiers.
+        /// The rest of the build metadata identifiers to replace the
+        /// existing build metadata identifiers.
+        /// The new version with the different build metadata identifiers.
+        ///  or
+        ///  is  or one of the metadata
+        /// identifiers is .
+        /// A metadata identifier is empty or contains invalid
+        /// characters (i.e. characters that are not ASCII alphanumerics or hyphens).
+        public SemVersion WithMetadata(string metadataIdentifier, params string[] metadataIdentifiers)
+        {
+            if (metadataIdentifier is null) throw new ArgumentNullException(nameof(metadataIdentifiers));
+            if (metadataIdentifiers is null) throw new ArgumentNullException(nameof(metadataIdentifiers));
+            var identifiers = metadataIdentifiers
+                              .Prepend(metadataIdentifier)
+                              .Select(i => new MetadataIdentifier(i, nameof(metadataIdentifiers)))
+                              .ToReadOnlyList();
+            return new SemVersion(Major, Minor, Patch,
+                Prerelease, PrereleaseIdentifiers, string.Join(".", identifiers), identifiers);
+        }
+
+        /// 
+        /// Creates a copy of the current instance with different build metadata identifiers.
+        /// 
+        /// The identifiers to replace the build metadata identifiers.
+        /// The new version with the different build metadata identifiers.
+        ///  is
+        ///  or one of the metadata identifiers is .
+        /// A metadata identifier is empty or contains invalid
+        /// characters (i.e. characters that are not ASCII alphanumerics or hyphens).
+        public SemVersion WithMetadata(IEnumerable metadataIdentifiers)
+        {
+            if (metadataIdentifiers is null) throw new ArgumentNullException(nameof(metadataIdentifiers));
+            var identifiers = metadataIdentifiers
+                              .Select(i => new MetadataIdentifier(i, nameof(metadataIdentifiers)))
+                              .ToReadOnlyList();
+            if (identifiers.Count == 0) return WithoutMetadata();
+            return new SemVersion(Major, Minor, Patch,
+                Prerelease, PrereleaseIdentifiers, string.Join(".", identifiers), identifiers);
+        }
+
+        /// 
+        /// Creates a copy of the current instance with different build metadata identifiers.
+        /// 
+        /// The first identifier to replace the existing
+        /// build metadata identifiers.
+        /// The rest of the identifiers to replace the
+        /// existing build metadata identifiers.
+        ///  is
+        /// .
+        /// A metadata identifier has the default value.
+        public SemVersion WithMetadata(
+            MetadataIdentifier metadataIdentifier,
+            params MetadataIdentifier[] metadataIdentifiers)
+        {
+            if (metadataIdentifiers is null) throw new ArgumentNullException(nameof(metadataIdentifiers));
+            var identifiers = metadataIdentifiers.Prepend(metadataIdentifier).ToReadOnlyList();
+            if (identifiers.Any(i => i == default))
+                throw new ArgumentException(MetadataIdentifierIsDefaultMessage, nameof(metadataIdentifiers));
+            return new SemVersion(Major, Minor, Patch,
+                Prerelease, PrereleaseIdentifiers, string.Join(".", identifiers), identifiers);
+        }
+
+        /// 
+        /// Creates a copy of the current instance with different build metadata identifiers.
+        /// 
+        /// The identifiers to replace the build metadata identifiers.
+        /// The new version with the different build metadata identifiers.
+        ///  is
+        /// .
+        /// A metadata identifier has the default value.
+        public SemVersion WithMetadata(IEnumerable metadataIdentifiers)
+        {
+            if (metadataIdentifiers is null) throw new ArgumentNullException(nameof(metadataIdentifiers));
+            var identifiers = metadataIdentifiers.ToReadOnlyList();
+            if (identifiers.Count == 0) return WithoutMetadata();
+            if (identifiers.Any(i => i == default))
+                throw new ArgumentException(MetadataIdentifierIsDefaultMessage, nameof(metadataIdentifiers));
+            return new SemVersion(Major, Minor, Patch,
+                Prerelease, PrereleaseIdentifiers, string.Join(".", identifiers), identifiers);
+        }
+
+        /// 
+        /// Creates a copy of the current instance without build metadata.
+        /// 
+        /// The new version without build metadata.
+        public SemVersion WithoutMetadata()
+        {
+            if (MetadataIdentifiers.Count == 0) return this;
+            return new SemVersion(Major, Minor, Patch,
+                Prerelease, PrereleaseIdentifiers, "", ReadOnlyList.Empty);
+        }
+
+        /// 
+        /// Creates a copy of the current instance without prerelease identifiers or build metadata.
+        /// 
+        /// The new version without prerelease identifiers or build metadata.
+        public SemVersion WithoutPrereleaseOrMetadata()
+        {
+            if (!IsPrerelease && MetadataIdentifiers.Count == 0) return this;
+            return new SemVersion(Major, Minor, Patch,
+                "", ReadOnlyList.Empty, "", ReadOnlyList.Empty);
+        }
+        #endregion
+
+        /// The major version number.
+        /// The major version number.
+        /// An increase in the major version number indicates a backwards
+        /// incompatible change.
+        public int Major { get; }
+
+        /// The minor version number.
+        /// The minor version number.
+        /// An increase in the minor version number indicates backwards
+        /// compatible changes.
+        public int Minor { get; }
+
+        /// The patch version number.
+        /// The patch version number.
+        /// An increase in the patch version number indicates backwards
+        /// compatible bug fixes.
+        public int Patch { get; }
+
+        /// 
+        /// The prerelease identifiers for this version.
+        /// 
+        /// 
+        /// The prerelease identifiers for this version or empty string if this is a release version.
+        /// 
+        /// 
+        // TODO v3.0.0 this should be null when there is no prerelease identifiers
+        public string Prerelease { get; }
+
+        /// 
+        /// The prerelease identifiers for this version.
+        /// 
+        /// 
+        /// The prerelease identifiers for this version or empty if this is a release version.
+        /// 
+        /// 
+        public IReadOnlyList PrereleaseIdentifiers { get; }
+
+        /// 
+        /// Whether this is a prerelease version where the prerelease version is zero (i.e. "-0").
+        /// 
+        internal bool PrereleaseIsZero
+            => PrereleaseIdentifiers.Count == 1
+               && PrereleaseIdentifiers[0] == PrereleaseIdentifier.Zero;
+
+        /// Whether this is a prerelease version.
+        /// Whether this is a prerelease version. A semantic version with
+        /// prerelease identifiers is a prerelease version.
+        /// When this is , the 
+        /// and  properties are non-empty. When
+        /// this is , the  property
+        /// will be an empty string and the  will
+        /// be an empty collection.
+        public bool IsPrerelease => Prerelease.Length != 0;
+
+        /// Whether this is a release version.
+        /// Whether this is a release version. A semantic version without
+        /// prerelease identifiers is a release version.
+        /// When this is , the 
+        /// property will be an empty string and the 
+        /// will be an empty collection. When this is ,
+        /// the  and 
+        /// properties are non-empty.
+        public bool IsRelease => Prerelease.Length == 0;
+
+        /// The build metadata for this version.
+        /// 
+        /// The build metadata for this version or empty string if there is no build metadata.
+        /// 
+        /// 
+        [EditorBrowsable(EditorBrowsableState.Never), Obsolete("This property is obsolete. Use Metadata instead.")]
+        public string Build => Metadata;
+
+        /// The build metadata for this version.
+        /// The build metadata for this version or empty string if there
+        /// is no metadata.
+        /// 
+        // TODO v3.0.0 this should be null when there is no metadata
+        public string Metadata { get; }
+
+        /// The build metadata identifiers for this version.
+        /// The build metadata identifiers for this version or empty if there
+        /// is no metadata.
+        /// 
+        public IReadOnlyList MetadataIdentifiers { get; }
+
+        /// 
+        /// Converts this version to an equivalent string value.
+        /// 
+        /// 
+        /// The  equivalent of this version.
+        /// 
+        public override string ToString()
+        {
+            // Assume all separators ("..-+"), at most 2 extra chars
+            var estimatedLength = 4 + Major.DecimalDigits()
+                                    + Minor.DecimalDigits()
+                                    + Patch.DecimalDigits()
+                                    + Prerelease.Length + Metadata.Length;
+            var version = new StringBuilder(estimatedLength);
+            version.Append(Major);
+            version.Append('.');
+            version.Append(Minor);
+            version.Append('.');
+            version.Append(Patch);
+            if (Prerelease.Length > 0)
+            {
+                version.Append('-');
+                version.Append(Prerelease);
+            }
+            if (Metadata.Length > 0)
+            {
+                version.Append('+');
+                version.Append(Metadata);
+            }
+            return version.ToString();
+        }
+
+        #region Equality
+        /// 
+        /// Determines whether two semantic versions are equal.
+        /// 
+        ///  if the two versions are equal, otherwise .
+        /// Two versions are equal if every part of the version numbers are equal. Thus two
+        /// versions with the same precedence may not be equal.
+        // TODO v3.0.0 rename parameters to `left` and `right` to be consistent with ComparePrecedence etc.
+        public static bool Equals(SemVersion versionA, SemVersion versionB)
+        {
+            if (ReferenceEquals(versionA, versionB)) return true;
+            if (versionA is null || versionB is null) return false;
+            return versionA.Equals(versionB);
+        }
+
+        /// Determines whether the given object is equal to this version.
+        ///  if  is equal to the this version;
+        /// otherwise .
+        /// Two versions are equal if every part of the version numbers are equal. Thus two
+        /// versions with the same precedence may not be equal.
+        public override bool Equals(object obj)
+            => obj is SemVersion version && Equals(version);
+
+        /// 
+        /// Determines whether two semantic versions are equal.
+        /// 
+        ///  if  is equal to the this version;
+        /// otherwise .
+        /// Two versions are equal if every part of the version numbers are equal. Thus two
+        /// versions with the same precedence may not be equal.
+        public bool Equals(SemVersion other)
+        {
+            if (other is null)
+                return false;
+
+            if (ReferenceEquals(this, other))
+                return true;
+
+            return Major == other.Major
+                && Minor == other.Minor
+                && Patch == other.Patch
+                && string.Equals(Prerelease, other.Prerelease, StringComparison.Ordinal)
+                && string.Equals(Metadata, other.Metadata, StringComparison.Ordinal);
+        }
+
+        /// 
+        /// Determines whether two semantic versions have the same precedence. Versions that differ
+        /// only by build metadata have the same precedence.
+        /// 
+        /// The semantic version to compare to.
+        ///  if the version precedences are equal, otherwise
+        /// .
+        public bool PrecedenceEquals(SemVersion other)
+            => PrecedenceComparer.Compare(this, other) == 0;
+
+        /// 
+        /// Determines whether two semantic versions have the same precedence. Versions that differ
+        /// only by build metadata have the same precedence.
+        /// 
+        ///  if the version precedences are equal, otherwise
+        /// .
+        public static bool PrecedenceEquals(SemVersion left, SemVersion right)
+            => PrecedenceComparer.Compare(left, right) == 0;
+
+        internal bool MajorMinorPatchEquals(SemVersion other)
+        {
+            if (other is null) return false;
+
+            if (ReferenceEquals(this, other)) return true;
+
+            return Major == other.Major
+                   && Minor == other.Minor
+                   && Patch == other.Patch;
+        }
+
+        /// 
+        /// Determines whether two semantic versions have the same precedence. Versions
+        /// that differ only by build metadata have the same precedence.
+        /// 
+        /// The semantic version to compare to.
+        ///  if the version precedences are equal.
+        [EditorBrowsable(EditorBrowsableState.Never), Obsolete("Method is obsolete. Use PrecedenceEquals() instead.")]
+        public bool PrecedenceMatches(SemVersion other) => CompareByPrecedence(other) == 0;
+
+        /// 
+        /// Gets a hash code for this instance.
+        /// 
+        /// 
+        /// A hash code for this instance, suitable for use in hashing algorithms
+        /// and data structures like a hash table.
+        /// 
+        /// Two versions are equal if every part of the version numbers are equal. Thus two
+        /// versions with the same precedence may not have the same hash code.
+        public override int GetHashCode()
+            => CombinedHashCode.Create(Major, Minor, Patch, Prerelease, Metadata);
+
+        /// 
+        /// Determines whether two semantic versions are equal.
+        /// 
+        ///  if the two versions are equal, otherwise .
+        /// Two versions are equal if every part of the version numbers are equal. Thus two
+        /// versions with the same precedence may not be equal.
+        public static bool operator ==(SemVersion left, SemVersion right) => Equals(left, right);
+
+        /// 
+        /// Determines whether two semantic versions are not equal.
+        /// 
+        ///  if the two versions are not equal, otherwise .
+        /// Two versions are equal if every part of the version numbers are equal. Thus two
+        /// versions with the same precedence may not be equal.
+        public static bool operator !=(SemVersion left, SemVersion right) => !Equals(left, right);
+        #endregion
+
+        #region Comparison
+        /// 
+        /// An  and 
+        /// that compares  by precedence. This can be used for sorting,
+        /// binary search, and using  as a dictionary key.
+        /// 
+        /// A precedence comparer that implements  and
+        ///  for .
+        /// 
+        public static ISemVersionComparer PrecedenceComparer { get; } = Comparers.PrecedenceComparer.Instance;
+
+        /// 
+        /// An  and 
+        /// that compares  by sort order. This can be used for sorting,
+        /// binary search, and using  as a dictionary key.
+        /// 
+        /// A sort order comparer that implements  and
+        ///  for .
+        /// 
+        public static ISemVersionComparer SortOrderComparer { get; } = Comparers.SortOrderComparer.Instance;
+
+        /// 
+        /// Compares two versions and indicates whether this instance precedes, follows, or is in the same
+        /// position as the other in the precedence order. Versions that differ only by build metadata
+        /// have the same precedence.
+        /// 
+        /// 
+        /// An integer that indicates whether this instance precedes, follows, or is in the same
+        /// position as  in the precedence order.
+        /// 
+        ///     
+        ///         Value
+        ///         Condition
+        ///     
+        ///     
+        ///         -1
+        ///         This instance precedes  in the precedence order.
+        ///     
+        ///     
+        ///         0
+        ///         This instance has the same precedence as .
+        ///     
+        ///     
+        ///         1
+        ///         
+        ///             This instance follows  in the precedence order
+        ///             or  is .
+        ///         
+        ///     
+        /// 
+        /// 
+        /// 
+        public int ComparePrecedenceTo(SemVersion other) => PrecedenceComparer.Compare(this, other);
+
+        /// 
+        /// Compares two versions and indicates whether this instance precedes, follows, or is equal
+        /// to the other in the sort order. Note that sort order is more specific than precedence order.
+        /// 
+        /// 
+        /// An integer that indicates whether this instance precedes, follows, or is equal to the
+        /// other in the sort order.
+        /// 
+        /// 	
+        /// 		Value
+        /// 		Condition
+        /// 	
+        /// 	
+        /// 		-1
+        /// 		This instance precedes the other in the sort order.
+        /// 	
+        /// 	
+        /// 		0
+        /// 		This instance is equal to the other.
+        /// 	
+        /// 	
+        /// 		1
+        /// 		
+        /// 			This instance follows the other in the sort order
+        /// 			or the other is .
+        /// 		
+        /// 	
+        /// 
+        /// 
+        /// 
+        public int CompareSortOrderTo(SemVersion other) => SortOrderComparer.Compare(this, other);
+
+        /// 
+        /// Compares two versions and indicates whether the first precedes, follows, or is in the same
+        /// position as the second in the precedence order. Versions that differ only by build metadata
+        /// have the same precedence.
+        /// 
+        /// 
+        /// An integer that indicates whether  precedes, follows, or is in the same
+        /// position as  in the precedence order.
+        /// 
+        ///     
+        ///         Value
+        ///         Condition
+        ///     
+        ///     
+        ///         -1
+        ///         
+        ///              precedes  in the precedence
+        ///             order or  is .
+        ///     
+        ///     
+        ///         0
+        ///          has the same precedence as .
+        ///     
+        ///     
+        ///         1
+        ///         
+        ///              follows  in the precedence order
+        ///             or  is .
+        ///         
+        ///     
+        /// 
+        /// 
+        /// 
+        public static int ComparePrecedence(SemVersion left, SemVersion right)
+            => PrecedenceComparer.Compare(left, right);
+
+        /// 
+        /// Compares two versions and indicates whether the first precedes, follows, or is equal to
+        /// the second in the sort order. Note that sort order is more specific than precedence order.
+        /// 
+        /// 
+        /// An integer that indicates whether  precedes, follows, or is equal
+        /// to  in the sort order.
+        /// 
+        ///     
+        ///         Value
+        ///         Condition
+        ///     
+        ///     
+        ///         -1
+        ///         
+        ///              precedes  in the sort
+        ///             order or  is .
+        ///     
+        ///     
+        ///         0
+        ///          is equal to .
+        ///     
+        ///     
+        ///         1
+        ///         
+        ///              follows  in the sort order
+        ///             or  is .
+        ///         
+        ///     
+        /// 
+        /// 
+        /// 
+        public static int CompareSortOrder(SemVersion left, SemVersion right)
+            => SortOrderComparer.Compare(left, right);
+
+        /// 
+        /// Compares this version to an  and indicates whether this instance
+        /// precedes, follows, or is equal to the object in the sort order. Note that sort order
+        /// is more specific than precedence order.
+        /// 
+        /// 
+        /// The  is not a .
+        /// 
+        [EditorBrowsable(EditorBrowsableState.Never), Obsolete("Method is obsolete. Use CompareSortOrderTo() or ComparePrecedenceTo() instead.")]
+        public int CompareTo(object obj) => CompareTo((SemVersion)obj);
+
+        /// 
+        /// Compares two versions and indicates whether this instance precedes, follows, or is
+        /// equal to the other in the sort order. Note that sort order is more specific than precedence order.
+        /// 
+        /// 
+        /// 
+        [EditorBrowsable(EditorBrowsableState.Never), Obsolete("Method is obsolete. Use CompareSortOrderTo() or ComparePrecedenceTo() instead.")]
+        public int CompareTo(SemVersion other)
+        {
+            var r = CompareByPrecedence(other);
+            if (r != 0) return r;
+
+            // If other is null, CompareByPrecedence() returns 1
+            return CompareComponents(Metadata, other.Metadata);
+        }
+
+        /// 
+        /// Compares two versions and indicates whether this instance precedes, follows, or is in the same
+        /// position as the other in the precedence order. Versions that differ only by build metadata
+        /// have the same precedence.
+        /// 
+        /// 
+        /// An integer that indicates whether this instance precedes, follows, or is in the same
+        /// position as  in the precedence order.
+        /// 
+        ///     
+        ///         Value
+        ///         Condition
+        ///     
+        ///     
+        ///         Less than zero
+        ///         This instance precedes  in the precedence order.
+        ///     
+        ///     
+        ///         Zero
+        ///         This instance has the same precedence as .
+        ///     
+        ///     
+        ///         Greater than zero
+        ///         
+        ///             This instance follows  in the precedence order
+        ///             or  is .
+        ///         
+        ///     
+        /// 
+        /// 
+        /// 
+        /// Precedence order is determined by comparing the major, minor, patch, and prerelease
+        /// portion in order from left to right. Versions that differ only by build metadata have the
+        /// same precedence. The major, minor, and patch version numbers are compared numerically. A
+        /// prerelease version precedes a release version.
+        ///
+        /// The prerelease portion is compared by comparing each prerelease identifier from
+        /// left to right. Numeric prerelease identifiers precede alphanumeric identifiers. Numeric
+        /// identifiers are compared numerically. Alphanumeric identifiers are compared lexically
+        /// in ASCII sort order. A longer series of prerelease identifiers follows a shorter series
+        /// if all the preceding identifiers are equal.
+        /// 
+        [EditorBrowsable(EditorBrowsableState.Never), Obsolete("Method is obsolete. Use ComparePrecedenceTo() or CompareSortOrderTo() instead.")]
+        public int CompareByPrecedence(SemVersion other)
+        {
+            if (other is null)
+                return 1;
+
+            var r = Major.CompareTo(other.Major);
+            if (r != 0) return r;
+
+            r = Minor.CompareTo(other.Minor);
+            if (r != 0) return r;
+
+            r = Patch.CompareTo(other.Patch);
+            if (r != 0) return r;
+
+            return CompareComponents(Prerelease, other.Prerelease, true);
+        }
+
+        [EditorBrowsable(EditorBrowsableState.Never), Obsolete]
+        private static int CompareComponents(string a, string b, bool nonEmptyIsLower = false)
+        {
+            var aEmpty = string.IsNullOrEmpty(a);
+            var bEmpty = string.IsNullOrEmpty(b);
+            if (aEmpty && bEmpty)
+                return 0;
+
+            if (aEmpty)
+                return nonEmptyIsLower ? 1 : -1;
+            if (bEmpty)
+                return nonEmptyIsLower ? -1 : 1;
+
+            var aComps = a.Split('.');
+            var bComps = b.Split('.');
+
+            var minLen = Math.Min(aComps.Length, bComps.Length);
+            for (int i = 0; i < minLen; i++)
+            {
+                var ac = aComps[i];
+                var bc = bComps[i];
+                var aIsNum = int.TryParse(ac, out var aNum);
+                var bIsNum = int.TryParse(bc, out var bNum);
+                int r;
+                if (aIsNum && bIsNum)
+                {
+                    r = aNum.CompareTo(bNum);
+                    if (r != 0) return r;
+                }
+                else
+                {
+                    if (aIsNum)
+                        return -1;
+                    if (bIsNum)
+                        return 1;
+                    r = string.CompareOrdinal(ac, bc);
+                    if (r != 0)
+                        return r;
+                }
+            }
+
+            return aComps.Length.CompareTo(bComps.Length);
+        }
+
+        /// 
+        /// Compares two versions by sort order. Note that sort order is more specific than precedence order.
+        /// 
+        ///  if  follows 
+        /// in the sort order; otherwise .
+        /// 
+        [EditorBrowsable(EditorBrowsableState.Never), Obsolete("Operator is obsolete. Use CompareSortOrder() or ComparePrecedence() instead.")]
+        public static bool operator >(SemVersion left, SemVersion right)
+            => Compare(left, right) > 0;
+
+        /// 
+        /// Compares two versions by sort order. Note that sort order is more specific than precedence order.
+        /// 
+        ///  if  follows or is equal to
+        ///  in the sort order; otherwise .
+        /// 
+        [EditorBrowsable(EditorBrowsableState.Never), Obsolete("Operator is obsolete. Use CompareSortOrder() or ComparePrecedence() instead.")]
+        public static bool operator >=(SemVersion left, SemVersion right)
+            => Equals(left, right) || Compare(left, right) > 0;
+
+        /// 
+        /// Compares two versions by sort order. Note that sort order is more specific than precedence order.
+        /// 
+        ///  if  precedes 
+        /// in the sort order; otherwise .
+        /// 
+        [EditorBrowsable(EditorBrowsableState.Never), Obsolete("Operator is obsolete. Use CompareSortOrder() or ComparePrecedence() instead.")]
+        public static bool operator <(SemVersion left, SemVersion right)
+            => Compare(left, right) < 0;
+
+        /// 
+        /// Compares two versions by sort order. Note that sort order is more specific than precedence order.
+        /// 
+        ///  if  precedes or is equal to
+        ///  in the sort order; otherwise .
+        /// 
+        [EditorBrowsable(EditorBrowsableState.Never), Obsolete("Operator is obsolete. Use CompareSortOrder() or ComparePrecedence() instead.")]
+        public static bool operator <=(SemVersion left, SemVersion right)
+            => Equals(left, right) || Compare(left, right) < 0;
+        #endregion
+
+        /// 
+        /// Implicit conversion from  to .
+        /// 
+        /// The semantic version.
+        /// The  object.
+        /// The  is .
+        /// The version number has an invalid format.
+        /// The major, minor, or patch version number is larger than .
+        [EditorBrowsable(EditorBrowsableState.Never), Obsolete("Implicit conversion from string is obsolete. Use Parse() or TryParse() method instead.")]
+        public static implicit operator SemVersion(string version)
+            => Parse(version);
+    }
+}
diff --git a/dotnet/Surge.NET/Semver/SemVersionDocParts.xml b/dotnet/Surge.NET/Semver/SemVersionDocParts.xml
new file mode 100644
index 0000000..b1e2573
--- /dev/null
+++ b/dotnet/Surge.NET/Semver/SemVersionDocParts.xml
@@ -0,0 +1,89 @@
+
+
+    
+        
+            An integer that indicates whether this instance precedes, follows, or is equal to
+            the other in the sort order.
+            
+                
+                    Value
+                    Condition
+                
+                
+                    Less than zero
+                    This instance precedes the other in the sort order.
+                
+                
+                    Zero
+                    This instance is equal to the other.
+                
+                
+                    Greater than zero
+                    
+                        This instance follows the other in the sort order
+                        or the other is .
+                    
+                
+            
+        
+    
+    
+        
+            The build metadata is a series of dot separated identifiers separated from the
+            rest of the version number with a plus sign ('+'). Valid metadata identifiers are
+            non-empty and consist of ASCII alphanumeric characters and hyphens ([0-9A-Za-z-]).
+
+            The metadata does not affect precedence. Two version numbers differing only in
+            build metadata have the same precedence. However, metadata does affect sort order. An
+            otherwise identical version without metadata sorts before one that has metadata.
+        
+    
+    
+        
+            A prerelease version is denoted by appending a hyphen ('-') and a series
+            of dot separated identifiers immediately following the patch version number. Each
+            prerelease identifier may be numeric or alphanumeric. Valid numeric identifiers consist
+            of ASCII digits ([0-9]) without leading zeros. Valid alphanumeric identifiers are
+            non-empty strings of ASCII alphanumeric and hyphen characters ([0-9A-Za-z-]) with
+            at least one non-digit character.
+
+            Prerelease versions have lower precedence than release versions. Prerelease
+            version precedence is determined by comparing each prerelease identifier in order from
+            left to right. Numeric identifiers have lower precedence than alphanumeric identifiers.
+            Numeric identifiers are compared numerically. Alphanumeric identifiers are compared
+            lexically in ASCII sort order.
+        
+    
+    
+        
+            Precedence order is determined by comparing the major, minor, patch, and prerelease
+            portion in order from left to right. Versions that differ only by build metadata have the
+            same precedence. The major, minor, and patch version numbers are compared numerically. A
+            prerelease version precedes a release version.
+
+            The prerelease portion is compared by comparing each prerelease identifier from
+            left to right. Numeric prerelease identifiers precede alphanumeric identifiers. Numeric
+            identifiers are compared numerically. Alphanumeric identifiers are compared lexically
+            in ASCII sort order. A longer series of prerelease identifiers follows a shorter series
+            if all the preceding identifiers are equal.
+        
+    
+    
+        
+            Sort order is consistent with precedence order, but provides an order for versions
+            with the same precedence. Sort order is determined by comparing the major, minor,
+            patch, prerelease portion, and build metadata in order from left to right. The major,
+            minor, and patch version numbers are compared numerically. A prerelease version precedes
+            a release version.
+            The prerelease portion is compared by comparing each prerelease identifier from
+            left to right. Numeric prerelease identifiers precede alphanumeric identifiers. Numeric
+            identifiers are compared numerically. Alphanumeric identifiers are compared lexically
+            in ASCII sort order. A longer series of prerelease identifiers follows a shorter series
+            if all the preceding identifiers are equal.
+            Otherwise equal versions without build metadata precede those with metadata. The
+            build metadata is compared by comparing each metadata identifier. Identifiers are
+            compared lexically in ASCII sort order. A longer series of metadata identifiers follows
+            a shorter series if all the preceding identifiers are equal.
+        
+    
+
\ No newline at end of file
diff --git a/dotnet/Surge.NET/Semver/SemVersionStyles.cs b/dotnet/Surge.NET/Semver/SemVersionStyles.cs
new file mode 100644
index 0000000..d7f6aa6
--- /dev/null
+++ b/dotnet/Surge.NET/Semver/SemVersionStyles.cs
@@ -0,0 +1,82 @@
+#nullable disable
+#pragma warning disable
+using System;
+
+namespace Semver
+{
+    /// 
+    /// Determines the styles that are allowed in version strings passed to the
+    ///  and
+    /// 
+    /// methods. These styles only affect which strings are accepted when parsing. The
+    /// constructed version numbers are valid semantic versions without any of the
+    /// optional features in the original string.
+    ///
+    /// This enumeration supports a bitwise combination of its member values (e.g.
+    /// SemVersionStyles.AllowWhitespace | SemVersionStyles.AllowV).
+    /// 
+    [Flags]
+    public enum SemVersionStyles
+    {
+        /// 
+        /// Accept version strings strictly conforming to the SemVer 2.0 spec.
+        /// 
+        Strict = 0,
+
+        /// 
+        /// Allow leading zeros on major, minor, patch, and prerelease version numbers.
+        ///
+        /// Leading zeros will be removed from the constructed version number.
+        /// 
+        AllowLeadingZeros = 1,
+
+        /// 
+        /// Allow leading whitespace. When combined with leading "v", the whitespace
+        /// must come before the "v".
+        /// 
+        AllowLeadingWhitespace = 1 << 1,
+
+        /// 
+        /// Allow trailing whitespace.
+        /// 
+        AllowTrailingWhitespace = 1 << 2,
+
+        /// 
+        /// Allow leading and/or trailing whitespace. When combined with leading "v",
+        /// the leading whitespace must come before the "v".
+        /// 
+        AllowWhitespace = AllowLeadingWhitespace | AllowTrailingWhitespace,
+
+        /// 
+        /// Allow a leading lowercase "v".
+        /// 
+        AllowLowerV = 1 << 3,
+
+        /// 
+        /// Allow a leading uppercase "V".
+        /// 
+        AllowUpperV = 1 << 4,
+
+        /// 
+        /// Allow a leading "v" or "V".
+        /// 
+        AllowV = AllowLowerV | AllowUpperV,
+
+        /// 
+        /// Patch version number is optional.
+        /// 
+        OptionalPatch = 1 << 5,
+
+        /// 
+        /// Minor and patch version numbers are optional.
+        /// 
+        OptionalMinorPatch = 1 << 6 | OptionalPatch,
+
+        /// 
+        /// Accept any version string format supported.
+        ///
+        /// The formats accepted by this style will change if/when more formats are supported.
+        /// 
+        Any = unchecked((int)0xFFFF_FFFF),
+    }
+}
diff --git a/dotnet/Surge.NET/Semver/SemVersionStylesExtensions.cs b/dotnet/Surge.NET/Semver/SemVersionStylesExtensions.cs
new file mode 100644
index 0000000..d94d313
--- /dev/null
+++ b/dotnet/Surge.NET/Semver/SemVersionStylesExtensions.cs
@@ -0,0 +1,42 @@
+#nullable disable
+#pragma warning disable
+using System;
+using System.Runtime.CompilerServices;
+using static Semver.SemVersionStyles;
+
+namespace Semver
+{
+    internal static class SemVersionStylesExtensions
+    {
+        internal const SemVersionStyles AllowAll = AllowLeadingZeros
+                                                 | AllowLeadingWhitespace
+                                                 | AllowTrailingWhitespace
+                                                 | AllowWhitespace
+                                                 | AllowLowerV
+                                                 | AllowUpperV
+                                                 | AllowV
+                                                 | OptionalPatch
+                                                 | OptionalMinorPatch;
+        private const SemVersionStyles OptionalMinorWithoutPatch = OptionalMinorPatch & ~OptionalPatch;
+
+        [MethodImpl(MethodImplOptions.AggressiveInlining)]
+        public static bool IsValid(this SemVersionStyles styles)
+        {
+            // Either it is the Any style
+            if (styles == Any) return true;
+
+            // Or it is some combination of the flags
+            return (styles & AllowAll) == styles
+                // Except for a flag for optional minor without optional patch
+                && (styles & OptionalMinorPatch) != OptionalMinorWithoutPatch;
+        }
+
+        /// 
+        /// The  method is surprisingly slow. This provides
+        /// a fast alternative for the  enum.
+        /// 
+        [MethodImpl(MethodImplOptions.AggressiveInlining)]
+        public static bool HasStyle(this SemVersionStyles styles, SemVersionStyles flag)
+            => (styles & flag) == flag;
+    }
+}
diff --git a/dotnet/Surge.NET/Semver/Utility/CharExtensions.cs b/dotnet/Surge.NET/Semver/Utility/CharExtensions.cs
new file mode 100644
index 0000000..197d706
--- /dev/null
+++ b/dotnet/Surge.NET/Semver/Utility/CharExtensions.cs
@@ -0,0 +1,22 @@
+#nullable disable
+#pragma warning disable
+using System.Runtime.CompilerServices;
+
+namespace Semver.Utility
+{
+    internal static class CharExtensions
+    {
+        /// 
+        /// Is this character an ASCII digit '0' through '9'
+        /// 
+        [MethodImpl(MethodImplOptions.AggressiveInlining)]
+        public static bool IsDigit(this char c) => c >= '0' && c <= '9';
+
+        /// 
+        /// Is this character and ASCII alphabetic character or hyphen [A-Za-z-]
+        /// 
+        [MethodImpl(MethodImplOptions.AggressiveInlining)]
+        public static bool IsAlphaOrHyphen(this char c)
+            => (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || c == '-';
+    }
+}
diff --git a/dotnet/Surge.NET/Semver/Utility/CombinedHashCode.cs b/dotnet/Surge.NET/Semver/Utility/CombinedHashCode.cs
new file mode 100644
index 0000000..55335e4
--- /dev/null
+++ b/dotnet/Surge.NET/Semver/Utility/CombinedHashCode.cs
@@ -0,0 +1,116 @@
+#nullable disable
+#pragma warning disable
+using System;
+using System.Collections.Generic;
+using System.ComponentModel;
+using System.Runtime.CompilerServices;
+using System.Runtime.InteropServices;
+
+namespace Semver.Utility
+{
+    /// 
+    /// Combine hash codes in a good way since System.HashCode isn't available.
+    /// 
+    /// Algorithm based on HashHelpers previously used in the core CLR.
+    /// https://github.com/dotnet/coreclr/blob/456afea9fbe721e57986a21eb3b4bb1c9c7e4c56/src/System.Private.CoreLib/shared/System/Numerics/Hashing/HashHelpers.cs
+    /// 
+    [StructLayout(LayoutKind.Auto)]
+    internal struct CombinedHashCode
+    {
+        private static readonly int RandomSeed = new Random().Next(int.MinValue, int.MaxValue);
+
+        #region Create Methods
+        public static CombinedHashCode Create(T1 value1)
+            => new CombinedHashCode(CombineValue(RandomSeed, value1));
+
+        public static CombinedHashCode Create(T1 value1, T2 value2)
+        {
+            var hash = RandomSeed;
+            hash = CombineValue(hash, value1);
+            hash = CombineValue(hash, value2);
+            return new CombinedHashCode(hash);
+        }
+
+        public static CombinedHashCode Create(T1 value1, T2 value2, T3 value3)
+        {
+            var hash = RandomSeed;
+            hash = CombineValue(hash, value1);
+            hash = CombineValue(hash, value2);
+            hash = CombineValue(hash, value3);
+            return new CombinedHashCode(hash);
+        }
+
+        public static CombinedHashCode Create(T1 value1, T2 value2, T3 value3, T4 value4)
+        {
+            var hash = RandomSeed;
+            hash = CombineValue(hash, value1);
+            hash = CombineValue(hash, value2);
+            hash = CombineValue(hash, value3);
+            hash = CombineValue(hash, value4);
+            return new CombinedHashCode(hash);
+        }
+
+        public static CombinedHashCode Create(T1 value1, T2 value2, T3 value3, T4 value4, T5 value5)
+        {
+            var hash = RandomSeed;
+            hash = CombineValue(hash, value1);
+            hash = CombineValue(hash, value2);
+            hash = CombineValue(hash, value3);
+            hash = CombineValue(hash, value4);
+            hash = CombineValue(hash, value5);
+            return new CombinedHashCode(hash);
+        }
+
+        public static CombinedHashCode CreateForItems(IEnumerable values)
+        {
+            var hash = RandomSeed;
+            foreach (var value in values)
+                hash = CombineValue(hash, value);
+
+            return new CombinedHashCode(hash);
+        }
+        #endregion
+
+#if DEBUG
+        private static readonly string UninitializedMessage = $"DEBUG: Uninitiated {nameof(CombinedHashCode)}.";
+        private readonly bool initialized;
+#endif
+        private int hash;
+
+        private CombinedHashCode(int hash)
+        {
+#if DEBUG
+            initialized = true;
+#endif
+            this.hash = hash;
+        }
+
+        public void Add(T value)
+        {
+#if DEBUG
+            if (!initialized) throw new InvalidOperationException(UninitializedMessage);
+#endif
+            hash = CombineValue(hash, value);
+        }
+
+        public static implicit operator int(CombinedHashCode hashCode)
+        {
+#if DEBUG
+            if (!hashCode.initialized) throw new InvalidOperationException(UninitializedMessage);
+#endif
+            return hashCode.hash;
+        }
+
+        [EditorBrowsable(EditorBrowsableState.Never), Obsolete("HashCode is a mutable struct and should not be compared with other HashCodes. Use ToHashCode to retrieve the computed hash code.", true)]
+#pragma warning disable CS0809 // Obsolete member overrides non-obsolete member
+        public override int GetHashCode() => throw new NotSupportedException();
+#pragma warning restore CS0809 // Obsolete member overrides non-obsolete member
+
+        [MethodImpl(MethodImplOptions.AggressiveInlining)]
+        private static int CombineValue(int hash1, T value)
+        {
+            uint rotateLeft5 = ((uint)hash1 << 5) | ((uint)hash1 >> 27);
+            return ((int)rotateLeft5 + hash1) ^ (value?.GetHashCode() ?? 0);
+        }
+    }
+}
diff --git a/dotnet/Surge.NET/Semver/Utility/DebugChecks.cs b/dotnet/Surge.NET/Semver/Utility/DebugChecks.cs
new file mode 100644
index 0000000..e55d46e
--- /dev/null
+++ b/dotnet/Surge.NET/Semver/Utility/DebugChecks.cs
@@ -0,0 +1,104 @@
+#nullable disable
+#pragma warning disable
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
+using System.Linq;
+using Semver.Parsing;
+
+namespace Semver.Utility
+{
+    /// 
+    /// The  class allows for the various conditional checks done only in
+    /// debug builds to not count against the code coverage metrics.
+    /// 
+    /// When using a preprocessor conditional block, the contained lines are not covered by
+    /// the unit tests (see example below). This is expected because the conditions should not be
+    /// reachable. But it makes it difficult to evaluate at a glance whether full code coverage has
+    /// been reached.
+    /// 
+    /// #if DEBUG
+    ///     if(condition) throw new Exception("...");
+    /// #endif
+    /// 
+    /// 
+    [ExcludeFromCodeCoverage]
+    internal static class DebugChecks
+    {
+        [Conditional("DEBUG")]
+        public static void IsValid(SemVersionStyles style, string paramName)
+        {
+            if (!style.IsValid())
+                throw new ArgumentException("DEBUG: " + SemVersion.InvalidSemVersionStylesMessage, paramName);
+        }
+
+        [Conditional("DEBUG")]
+        public static void IsValidMaxLength(int maxLength, string paramName)
+        {
+            if (maxLength < 0)
+                throw new ArgumentOutOfRangeException(paramName, "DEBUG: Must not be negative.");
+        }
+
+        [Conditional("DEBUG")]
+        public static void IsNotWildcardVersionWithPrerelease(WildcardVersion wildcardVersion, SemVersion semver)
+        {
+            if (wildcardVersion != WildcardVersion.None && semver.IsPrerelease)
+                throw new InvalidOperationException("DEBUG: prerelease not allowed with wildcard");
+        }
+
+        [Conditional("DEBUG")]
+        public static void IsNotEmpty(StringSegment segment, string paramName)
+        {
+            if (segment.IsEmpty)
+                throw new ArgumentException("DEBUG: Cannot be empty", paramName);
+        }
+
+        [Conditional("DEBUG")]
+        public static void IsNotNull(T value, string paramName)
+        {
+            if (value == null)
+                throw new ArgumentNullException(paramName, "DEBUG: Value cannot be null.");
+        }
+
+        /// 
+        /// This check ensures that an exception hasn't been constructed, but rather something always
+        /// returns .
+        /// 
+        [Conditional("DEBUG")]
+        public static void IsNotFailedException(Exception exception, string className, string methodName)
+        {
+            if (exception != null && exception != VersionParsing.FailedException)
+                throw new InvalidOperationException($"DEBUG: {className}.{methodName} returned exception other than {nameof(VersionParsing.FailedException)}", exception);
+        }
+
+        [Conditional("DEBUG")]
+        public static void NoMetadata(SemVersion version, string paramName)
+        {
+            if (version?.MetadataIdentifiers.Any() ?? false)
+                throw new ArgumentException("DEBUG: Cannot have metadata.", paramName);
+        }
+
+        [Conditional("DEBUG")]
+        public static void IsValidVersionNumber(int versionNumber, string kind, string paramName)
+        {
+            if (versionNumber < 0)
+                throw new ArgumentException($"DEBUG: {kind} version must be greater than or equal to zero.", paramName);
+        }
+
+        [Conditional("DEBUG")]
+        public static void ContainsNoDefaultValues(IEnumerable values, string kind, string paramName)
+            where T : struct
+        {
+            if (values.Any(i => EqualityComparer.Default.Equals(i, default)))
+                throw new ArgumentException($"DEBUG: {kind} identifier cannot be default/null.", paramName);
+        }
+
+        [Conditional("DEBUG")]
+        public static void AreEqualWhenJoinedWithDots(string value, string param1Name, IReadOnlyList values, string param2Name)
+        {
+            if (value != string.Join(".", values))
+                throw new ArgumentException($"DEBUG: must be equal to {param2Name} when joined with dots.", param1Name);
+        }
+    }
+}
diff --git a/dotnet/Surge.NET/Semver/Utility/EnumerableExtensions.cs b/dotnet/Surge.NET/Semver/Utility/EnumerableExtensions.cs
new file mode 100644
index 0000000..dc11a5c
--- /dev/null
+++ b/dotnet/Surge.NET/Semver/Utility/EnumerableExtensions.cs
@@ -0,0 +1,21 @@
+#nullable disable
+#pragma warning disable
+using System.Collections.Generic;
+using System.Linq;
+using System.Runtime.CompilerServices;
+
+namespace Semver.Utility
+{
+    internal static class EnumerableExtensions
+    {
+        [MethodImpl(MethodImplOptions.AggressiveInlining)]
+        public static IReadOnlyList ToReadOnlyList(this IEnumerable values)
+            => values.ToList().AsReadOnly();
+
+#if !NETSTANDARD1_6_OR_GREATER && !NET471_OR_GREATER && !NETCOREAPP
+        [MethodImpl(MethodImplOptions.AggressiveInlining)]
+        public static IEnumerable Prepend(this IEnumerable source, TSource element)
+            => new[] { element }.Concat(source);
+#endif
+    }
+}
diff --git a/dotnet/Surge.NET/Semver/Utility/ExcludeFromCodeCoverageAttribute.cs b/dotnet/Surge.NET/Semver/Utility/ExcludeFromCodeCoverageAttribute.cs
new file mode 100644
index 0000000..16c8ace
--- /dev/null
+++ b/dotnet/Surge.NET/Semver/Utility/ExcludeFromCodeCoverageAttribute.cs
@@ -0,0 +1,17 @@
+#nullable disable
+#pragma warning disable
+using System;
+
+#if NETSTANDARD1_1
+namespace Semver.Utility
+{
+    /// 
+    /// Specifies that the attributed code should be excluded from code coverage information.
+    /// 
+    [AttributeUsage(
+        AttributeTargets.Assembly | AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Constructor
+        | AttributeTargets.Method | AttributeTargets.Property | AttributeTargets.Event,
+        Inherited = false)]
+    internal sealed class ExcludeFromCodeCoverageAttribute : Attribute { }
+}
+#endif
diff --git a/dotnet/Surge.NET/Semver/Utility/IdentifierString.cs b/dotnet/Surge.NET/Semver/Utility/IdentifierString.cs
new file mode 100644
index 0000000..ddf1304
--- /dev/null
+++ b/dotnet/Surge.NET/Semver/Utility/IdentifierString.cs
@@ -0,0 +1,23 @@
+#nullable disable
+#pragma warning disable
+using System;
+using System.Runtime.CompilerServices;
+
+namespace Semver.Utility
+{
+    /// 
+    /// Methods for working with the strings that make up identifiers
+    /// 
+    internal static class IdentifierString
+    {
+        /// 
+        /// Compare two strings as they should be compared as identifiers.
+        /// 
+        /// This enforces ordinal comparision. It also fixes a technically
+        /// correct but odd thing where the comparision result can be a number
+        /// other than -1, 0, or 1.
+        [MethodImpl(MethodImplOptions.AggressiveInlining)]
+        public static int Compare(string left, string right)
+            => Math.Sign(string.CompareOrdinal(left, right));
+    }
+}
diff --git a/dotnet/Surge.NET/Semver/Utility/IntExtensions.cs b/dotnet/Surge.NET/Semver/Utility/IntExtensions.cs
new file mode 100644
index 0000000..c46e7e6
--- /dev/null
+++ b/dotnet/Surge.NET/Semver/Utility/IntExtensions.cs
@@ -0,0 +1,35 @@
+#nullable disable
+#pragma warning disable
+using System.Text;
+
+namespace Semver.Utility
+{
+    internal static class IntExtensions
+    {
+        /// 
+        /// The number of digits in a non-negative number. Returns 1 for all
+        /// negative numbers. That is ok because we are using it to calculate
+        /// string length for a  for numbers that
+        /// aren't supposed to be negative, but when they are it is just a little
+        /// slower.
+        /// 
+        /// 
+        /// This approach is based on https://stackoverflow.com/a/51099524/268898
+        /// where the poster offers performance benchmarks showing this is the
+        /// fastest way to get a number of digits.
+        /// 
+        public static int DecimalDigits(this int n)
+        {
+            if (n < 10) return 1;
+            if (n < 100) return 2;
+            if (n < 1_000) return 3;
+            if (n < 10_000) return 4;
+            if (n < 100_000) return 5;
+            if (n < 1_000_000) return 6;
+            if (n < 10_000_000) return 7;
+            if (n < 100_000_000) return 8;
+            if (n < 1_000_000_000) return 9;
+            return 10;
+        }
+    }
+}
diff --git a/dotnet/Surge.NET/Semver/Utility/ListExtensions.cs b/dotnet/Surge.NET/Semver/Utility/ListExtensions.cs
new file mode 100644
index 0000000..16e1cd1
--- /dev/null
+++ b/dotnet/Surge.NET/Semver/Utility/ListExtensions.cs
@@ -0,0 +1,17 @@
+#nullable disable
+#pragma warning disable
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Runtime.CompilerServices;
+
+namespace Semver.Utility
+{
+#if NETSTANDARD1_1
+    internal static class ListExtensions
+    {
+        [MethodImpl(MethodImplOptions.AggressiveInlining)]
+        public static ReadOnlyCollection AsReadOnly(this List list)
+            => new ReadOnlyCollection(list);
+    }
+#endif
+}
diff --git a/dotnet/Surge.NET/Semver/Utility/ReadOnlyList.cs b/dotnet/Surge.NET/Semver/Utility/ReadOnlyList.cs
new file mode 100644
index 0000000..17b876e
--- /dev/null
+++ b/dotnet/Surge.NET/Semver/Utility/ReadOnlyList.cs
@@ -0,0 +1,14 @@
+#nullable disable
+#pragma warning disable
+using System.Collections.Generic;
+
+namespace Semver.Utility
+{
+    /// 
+    /// Internal helper for efficiently creating empty read only lists
+    /// 
+    internal static class ReadOnlyList
+    {
+        public static readonly IReadOnlyList Empty = new List().AsReadOnly();
+    }
+}
diff --git a/dotnet/Surge.NET/Semver/Utility/StringExtensions.cs b/dotnet/Surge.NET/Semver/Utility/StringExtensions.cs
new file mode 100644
index 0000000..6218899
--- /dev/null
+++ b/dotnet/Surge.NET/Semver/Utility/StringExtensions.cs
@@ -0,0 +1,109 @@
+#nullable disable
+#pragma warning disable
+using System;
+using System.Collections.Generic;
+using System.Runtime.CompilerServices;
+
+namespace Semver.Utility
+{
+    internal static class StringExtensions
+    {
+        /// 
+        /// Is this string composed entirely of ASCII digits '0' to '9'?
+        /// 
+        public static bool IsDigits(this string value)
+        {
+            foreach (var c in value)
+                if (!c.IsDigit())
+                    return false;
+
+            return true;
+        }
+
+        /// 
+        /// Is this string composed entirely of ASCII alphanumeric characters and hyphens?
+        /// 
+        public static bool IsAlphanumericOrHyphens(this string value)
+        {
+            foreach (var c in value)
+                if (!c.IsAlphaOrHyphen() && !c.IsDigit())
+                    return false;
+
+            return true;
+        }
+
+        /// 
+        /// Split a string, map the parts, and return a read only list of the values.
+        /// 
+        /// Splitting a string, mapping the result and storing into a 
+        /// is a common operation in this package. This method optimizes that. It avoids the
+        /// performance overhead of:
+        /// 
+        ///   Constructing the params array for 
+        ///   Constructing the intermediate  returned by 
+        ///   
+        ///   Not allocating list capacity based on the size
+        /// 
+        /// Benchmarking shows this to be 30%+ faster and that may not reflect the whole benefit
+        /// since it doesn't fully account for reduced allocations.
+        /// 
+        public static IReadOnlyList SplitAndMapToReadOnlyList(
+            this string value,
+            char splitOn,
+            Func func)
+        {
+            if (value.Length == 0) return ReadOnlyList.Empty;
+
+            // Figure out how many items the resulting list will have
+            int count = 1; // Always one more item than there are separators
+            for (int i = 0; i < value.Length; i++)
+                if (value[i] == splitOn)
+                    count++;
+
+            // Allocate enough capacity for the items
+            var items = new List(count);
+            int start = 0;
+            for (int i = 0; i < value.Length; i++)
+                if (value[i] == splitOn)
+                {
+                    items.Add(func(value.Substring(start, i - start)));
+                    start = i + 1;
+                }
+            // Add the final items from the last separator to the end of the string
+            items.Add(func(value.Substring(start, value.Length - start)));
+
+            return items.AsReadOnly();
+        }
+
+        /// 
+        /// Trim leading zeros from a numeric string. If the string consists of all zeros, return
+        /// "0".
+        /// 
+        /// The standard  method handles all zeros
+        /// by returning "". This efficiently handles the kind of trimming needed.
+        public static string TrimLeadingZeros(this string value)
+        {
+            int start;
+            var searchUpTo = value.Length - 1;
+            for (start = 0; start < searchUpTo; start++)
+                if (value[start] != '0')
+                    break;
+
+            return value.Substring(start);
+        }
+
+        [MethodImpl(MethodImplOptions.AggressiveInlining)]
+        public static StringSegment Slice(this string value, int offset, int length)
+            => new StringSegment(value, offset, length);
+
+        public static string LimitLength(this string value)
+        {
+            if (value.Length > DisplayLimit)
+                return value.Substring(0, DisplayLimit - 3) + "...";
+
+            return value;
+        }
+
+        private const int DisplayLimit = 100;
+    }
+}
diff --git a/dotnet/Surge.NET/Semver/Utility/StringSegment.cs b/dotnet/Surge.NET/Semver/Utility/StringSegment.cs
new file mode 100644
index 0000000..1bbe9f0
--- /dev/null
+++ b/dotnet/Surge.NET/Semver/Utility/StringSegment.cs
@@ -0,0 +1,225 @@
+#nullable disable
+#pragma warning disable
+using System;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.Runtime.CompilerServices;
+using System.Runtime.InteropServices;
+
+namespace Semver.Utility
+{
+    /// 
+    /// An efficient representation of a section of a string
+    /// 
+    [StructLayout(LayoutKind.Auto)]
+    internal readonly struct StringSegment
+    {
+        public StringSegment(string source, int offset, int length)
+        {
+#if DEBUG
+            if (source is null) throw new ArgumentNullException(nameof(source), "DEBUG: Value cannot be null.");
+            if (offset < 0) throw new ArgumentOutOfRangeException(nameof(offset), offset, "DEBUG: Cannot be negative.");
+            if (offset > source.Length)
+                throw new ArgumentOutOfRangeException(nameof(offset), offset,
+                    $"DEBUG: Must be <= length of {length}. String:\r\n{source}");
+
+            if (length < 0) throw new ArgumentOutOfRangeException(nameof(length), length, "DEBUG: Cannot be negative.");
+            if (offset + length > source.Length)
+                throw new ArgumentOutOfRangeException(nameof(length), length,
+                    $"DEBUG: When added to offset of {offset}, must be <= length of {length}. String:\r\n{source}");
+#endif
+            Source = source;
+            Offset = offset;
+            Length = length;
+        }
+
+        public readonly string Source;
+        public readonly int Offset;
+        public readonly int Length;
+
+        public bool IsEmpty => Length == 0;
+
+        public char this[int i]
+        {
+            get
+            {
+#if DEBUG
+                ValidateIndex(i, nameof(i));
+#endif
+                return Source[Offset + i];
+            }
+        }
+
+        public StringSegment TrimStartSpaces()
+        {
+            var start = Offset;
+            var end = start + Length - 1;
+
+            while (start <= end && Source[start] == ' ') start++;
+
+            return new StringSegment(Source, start, end + 1 - start);
+        }
+
+        public StringSegment TrimStartWhitespace()
+        {
+            var start = Offset;
+            var end = start + Length - 1;
+
+            while (start <= end && char.IsWhiteSpace(Source[start])) start++;
+
+            return new StringSegment(Source, start, end + 1 - start);
+        }
+
+        public StringSegment TrimEndWhitespace()
+        {
+            var end = Offset + Length - 1;
+
+            while (Offset <= end && char.IsWhiteSpace(Source[end])) end--;
+
+            return new StringSegment(Source, Offset, end + 1 - Offset);
+        }
+
+        /// 
+        /// Trim leading zeros from a numeric string segment. If the segment consists of all zeros,
+        /// return "0".
+        /// 
+        /// The standard  method handles all zeros
+        /// by returning "". This efficiently handles the kind of trimming needed.
+        public StringSegment TrimLeadingZeros()
+        {
+            int start = Offset;
+            var end = start + Length - 1;
+            for (; start < end; start++)
+                if (Source[start] != '0')
+                    break;
+
+            return new StringSegment(Source, start, end + 1 - start);
+        }
+
+        public StringSegment Subsegment(int start, int length)
+        {
+#if DEBUG
+            ValidateIndex(start, nameof(start));
+            ValidateLength(start, length, nameof(length));
+#endif
+            return new StringSegment(Source, Offset + start, length);
+        }
+
+        public StringSegment Subsegment(int start)
+        {
+#if DEBUG
+            ValidateIndex(start, nameof(start));
+#endif
+            return new StringSegment(Source, Offset + start, Length - start);
+        }
+
+        [MethodImpl(MethodImplOptions.AggressiveInlining)]
+        public StringSegment EmptySubsegment() => new StringSegment(Source, Offset, 0);
+
+        [MethodImpl(MethodImplOptions.AggressiveInlining)]
+        public int IndexOf(char value)
+        {
+            var i = Source.IndexOf(value, Offset, Length);
+            return i < 0 ? i : i - Offset;
+        }
+
+        [MethodImpl(MethodImplOptions.AggressiveInlining)]
+        public int IndexOf(char value, int startIndex)
+        {
+#if DEBUG
+            ValidateIndex(startIndex, nameof(startIndex));
+#endif
+            var i = Source.IndexOf(value, Offset + startIndex, Length - startIndex);
+            return i < 0 ? i : i - Offset;
+        }
+
+        [MethodImpl(MethodImplOptions.AggressiveInlining)]
+        public int IndexOf(char value, int startIndex, int count)
+        {
+#if DEBUG
+            ValidateIndex(startIndex, nameof(startIndex));
+            ValidateLength(startIndex, count, nameof(count));
+#endif
+            var i = Source.IndexOf(value, Offset + startIndex, count);
+            return i < 0 ? i : i - Offset;
+        }
+
+        public int SplitCount(char c)
+        {
+            int count = 1; // Always one more item than there are separators
+            var end = Offset + Length;
+            // Use `for` instead of `foreach` to ensure performance
+            for (int i = Offset; i < end; i++)
+                if (Source[i] == c)
+                    count++;
+
+            return count;
+        }
+
+        public IEnumerable Split(char c)
+        {
+            var start = Offset;
+            var end = start + Length;
+            // Use `for` instead of `foreach` to ensure performance
+            for (int i = start; i < end; i++)
+                if (Source[i] == c)
+                {
+                    yield return Subsegment(start - Offset, i - start);
+                    start = i + 1;
+                }
+
+            // The final segment from the last separator to the end of the string
+            yield return Subsegment(start - Offset);
+        }
+
+        [MethodImpl(MethodImplOptions.AggressiveInlining)]
+        public void SplitBeforeFirst(char c, out StringSegment left, out StringSegment right)
+        {
+            var index = IndexOf(c);
+            var self = this; // make a copy of this in case assigning to left or right modifies it
+            if (index >= 0)
+            {
+                left = self.Subsegment(0, index);
+                right = self.Subsegment(index);
+            }
+            else
+            {
+                left = self;
+                right = self.EmptySubsegment();
+            }
+        }
+
+        [MethodImpl(MethodImplOptions.AggressiveInlining)]
+        public static implicit operator StringSegment(string value)
+            => new StringSegment(value, 0, value.Length);
+
+        [MethodImpl(MethodImplOptions.AggressiveInlining)]
+        public override string ToString() => Source.Substring(Offset, Length);
+
+        public string ToStringLimitLength()
+        {
+            if (Length > DisplayLimit) return Subsegment(0, DisplayLimit - 3) + "...";
+
+            return ToString();
+        }
+
+        private const int DisplayLimit = 100;
+
+#if DEBUG
+        [ExcludeFromCodeCoverage]
+        private void ValidateIndex(int i, string paramName)
+        {
+            if (i < 0) throw new ArgumentOutOfRangeException(paramName, i, "DEBUG: Cannot be negative.");
+            if (i > Length) throw new ArgumentOutOfRangeException(paramName, i, $"DEBUG: Cannot be > length {Length}.");
+        }
+
+        [ExcludeFromCodeCoverage]
+        private void ValidateLength(int start, int length, string paramName)
+        {
+            if (length < 0) throw new ArgumentOutOfRangeException(paramName, length, "DEBUG: Cannot be negative.");
+            if (start + length > Length) throw new ArgumentOutOfRangeException(paramName, length,
+                $"DEBUG: When added to offset of {start}, must be <= length of {Length}.");
+        }
+#endif
+    }
+}
diff --git a/dotnet/Surge.NET/Semver/Utility/Unreachable.cs b/dotnet/Surge.NET/Semver/Utility/Unreachable.cs
new file mode 100644
index 0000000..12bad64
--- /dev/null
+++ b/dotnet/Surge.NET/Semver/Utility/Unreachable.cs
@@ -0,0 +1,18 @@
+#nullable disable
+#pragma warning disable
+using System;
+using System.Diagnostics.CodeAnalysis;
+using Semver.Parsing;
+
+namespace Semver.Utility
+{
+    /// 
+    /// Used to clearly mark when a case should be unreachable and helps properly manage code coverage.
+    /// 
+    [ExcludeFromCodeCoverage]
+    internal static class Unreachable
+    {
+        public static ArgumentException InvalidEnum(WildcardVersion wildcardVersion)
+            => new ArgumentException($"DEBUG: Invalid {nameof(WildcardVersion)} value {wildcardVersion}.");
+    }
+}
diff --git a/dotnet/Surge.NET/Semver/Utility/UnsafeOverload.cs b/dotnet/Surge.NET/Semver/Utility/UnsafeOverload.cs
new file mode 100644
index 0000000..2132e25
--- /dev/null
+++ b/dotnet/Surge.NET/Semver/Utility/UnsafeOverload.cs
@@ -0,0 +1,13 @@
+#nullable disable
+#pragma warning disable
+namespace Semver.Utility
+{
+    /// 
+    /// Struct used as a marker to differentiate constructor overloads that would
+    /// otherwise be the same as safe overloads.
+    /// 
+    internal readonly struct UnsafeOverload
+    {
+        public static readonly UnsafeOverload Marker = default;
+    }
+}
diff --git a/dotnet/Surge.NET/Semver/Utility/VersionParsing.cs b/dotnet/Surge.NET/Semver/Utility/VersionParsing.cs
new file mode 100644
index 0000000..22cf0bc
--- /dev/null
+++ b/dotnet/Surge.NET/Semver/Utility/VersionParsing.cs
@@ -0,0 +1,19 @@
+#nullable disable
+#pragma warning disable
+using System;
+using Semver.Parsing;
+
+namespace Semver.Utility
+{
+    internal static class VersionParsing
+    {
+        /// 
+        /// This exception is used with the
+        /// 
+        /// method to indicate parse failure without constructing a new exception.
+        /// This exception should never be thrown or exposed outside of this
+        /// package.
+        /// 
+        public static readonly Exception FailedException = new Exception("Parse Failed");
+    }
+}
diff --git a/dotnet/Surge.NET/Surge.NET.csproj b/dotnet/Surge.NET/Surge.NET.csproj
index 4e06123..8366d05 100644
--- a/dotnet/Surge.NET/Surge.NET.csproj
+++ b/dotnet/Surge.NET/Surge.NET.csproj
@@ -14,8 +14,14 @@
   
   
     
+    
   
   
     
   
+  
+    
+      disable
+    
+  
 
diff --git a/dotnet/Surge.NET/SurgeApp.cs b/dotnet/Surge.NET/SurgeApp.cs
index 0ebd4a1..8144d91 100644
--- a/dotnet/Surge.NET/SurgeApp.cs
+++ b/dotnet/Surge.NET/SurgeApp.cs
@@ -2,7 +2,9 @@
 using System.Collections.Generic;
 using System.Diagnostics;
 using System.IO;
+using System.Runtime.ExceptionServices;
 using System.Runtime.InteropServices;
+using Semver;
 
 namespace Surge
 {
@@ -53,7 +55,7 @@ public static SurgeAppInfo? Current
         /// 
         /// The Surge library version.
         /// 
-        public static string Version => "0.1.0";
+        public static SemVersion Version => SemanticVersions.LibraryVersion;
 
         /// 
         /// Process application lifecycle events. Should be called early in the
@@ -66,13 +68,14 @@ public static SurgeAppInfo? Current
         /// True if a lifecycle event was handled.
         public static bool ProcessEvents(
             string[] args,
-            Action? onFirstRun = null,
-            Action? onInstalled = null,
-            Action? onUpdated = null)
+            Action? onFirstRun = null,
+            Action? onInstalled = null,
+            Action? onUpdated = null)
         {
             var lifecycleAction = DetermineLifecycleAction(args);
             var argPtrs = new IntPtr[args.Length];
             var pinnedHandles = new GCHandle[args.Length];
+            Exception? callbackException = null;
 
             try
             {
@@ -95,8 +98,20 @@ public static bool ProcessEvents(
                     {
                         firstRunCb = (versionPtr, _) =>
                         {
-                            var version = MarshalUtf8(versionPtr);
-                            onFirstRun(version);
+                            if (callbackException != null)
+                                return;
+
+                            try
+                            {
+                                var version = SemanticVersions.ParseRuntimeValue(
+                                    MarshalUtf8(versionPtr),
+                                    "Surge first-run version");
+                                onFirstRun(version);
+                            }
+                            catch (Exception ex)
+                            {
+                                callbackException = ex;
+                            }
                         };
                     }
 
@@ -104,8 +119,20 @@ public static bool ProcessEvents(
                     {
                         installedCb = (versionPtr, _) =>
                         {
-                            var version = MarshalUtf8(versionPtr);
-                            onInstalled(version);
+                            if (callbackException != null)
+                                return;
+
+                            try
+                            {
+                                var version = SemanticVersions.ParseRuntimeValue(
+                                    MarshalUtf8(versionPtr),
+                                    "Surge installed version");
+                                onInstalled(version);
+                            }
+                            catch (Exception ex)
+                            {
+                                callbackException = ex;
+                            }
                         };
                     }
 
@@ -113,8 +140,20 @@ public static bool ProcessEvents(
                     {
                         updatedCb = (versionPtr, _) =>
                         {
-                            var version = MarshalUtf8(versionPtr);
-                            onUpdated(version);
+                            if (callbackException != null)
+                                return;
+
+                            try
+                            {
+                                var version = SemanticVersions.ParseRuntimeValue(
+                                    MarshalUtf8(versionPtr),
+                                    "Surge updated version");
+                                onUpdated(version);
+                            }
+                            catch (Exception ex)
+                            {
+                                callbackException = ex;
+                            }
                         };
                     }
 
@@ -126,6 +165,9 @@ public static bool ProcessEvents(
                         updatedCb,
                         IntPtr.Zero);
 
+                    if (callbackException != null)
+                        ExceptionDispatchInfo.Capture(callbackException).Throw();
+
                     if (result != 0 || lifecycleAction == LifecycleAction.None)
                     {
                         return false;
@@ -376,10 +418,14 @@ internal static void PersistCurrentChannel(string channel)
                     return null;
 
                 var resolvedAppId = appId!.Trim();
+                var resolvedVersion = version ?? "0.0.0";
+                if (!SemanticVersions.TryParseRuntimeValue(resolvedVersion, out var parsedVersion))
+                    return null;
+
                 return new SurgeAppInfo
                 {
                     Id = resolvedAppId,
-                    Version = version ?? "0.0.0",
+                    Version = parsedVersion,
                     Channel = channel ?? "stable",
                     InstallDirectory = ResolveInstallDirectory(resolvedAppId, installDir, assemblyDir),
                     SupervisorId = supervisorId ?? "",
diff --git a/dotnet/Surge.NET/SurgeAppInfo.cs b/dotnet/Surge.NET/SurgeAppInfo.cs
index 61aa520..86fc243 100644
--- a/dotnet/Surge.NET/SurgeAppInfo.cs
+++ b/dotnet/Surge.NET/SurgeAppInfo.cs
@@ -1,3 +1,5 @@
+using Semver;
+
 namespace Surge
 {
     /// 
@@ -11,9 +13,9 @@ public sealed class SurgeAppInfo
         public string Id { get; init; } = "";
 
         /// 
-        /// Currently installed version string (semver).
+        /// Currently installed semantic version.
         /// 
-        public string Version { get; init; } = "";
+        public SemVersion Version { get; init; } = SemanticVersions.Zero;
 
         /// 
         /// Release channel this installation is tracking.
diff --git a/dotnet/Surge.NET/SurgeRelease.cs b/dotnet/Surge.NET/SurgeRelease.cs
index f22150b..fca56f0 100644
--- a/dotnet/Surge.NET/SurgeRelease.cs
+++ b/dotnet/Surge.NET/SurgeRelease.cs
@@ -1,3 +1,5 @@
+using Semver;
+
 namespace Surge
 {
     /// 
@@ -6,9 +8,9 @@ namespace Surge
     public sealed class SurgeRelease
     {
         /// 
-        /// Version string (semver) of this release.
+        /// Semantic version of this release.
         /// 
-        public string Version { get; init; } = "";
+        public SemVersion Version { get; init; } = SemanticVersions.Zero;
 
         /// 
         /// Release channel this release belongs to.
diff --git a/dotnet/Surge.NET/SurgeUpdateManager.cs b/dotnet/Surge.NET/SurgeUpdateManager.cs
index b063390..5ecfd1b 100644
--- a/dotnet/Surge.NET/SurgeUpdateManager.cs
+++ b/dotnet/Surge.NET/SurgeUpdateManager.cs
@@ -3,6 +3,7 @@
 using System.Runtime.InteropServices;
 using System.Threading;
 using System.Threading.Tasks;
+using Semver;
 
 namespace Surge
 {
@@ -15,7 +16,7 @@ public sealed class SurgeUpdateManager : IDisposable
         private IntPtr _nativeMgr;
         private bool _disposed;
         private string _channel = "stable";
-        private string _currentVersion = "0.0.0";
+        private SemVersion _currentVersion = SemanticVersions.Zero;
         private int _releaseRetentionLimit = 1;
 
         /// 
@@ -71,7 +72,7 @@ public SurgeUpdateManager()
             _nativeMgr = NativeMethods.UpdateManagerCreate(
                 _nativeCtx,
                 appInfo.Id,
-                appInfo.Version,
+                appInfo.Version.ToString(),
                 appInfo.Channel,
                 appInfo.InstallDirectory);
 
@@ -83,7 +84,7 @@ public SurgeUpdateManager()
             }
 
             _channel = string.IsNullOrWhiteSpace(appInfo.Channel) ? "stable" : appInfo.Channel;
-            _currentVersion = string.IsNullOrWhiteSpace(appInfo.Version) ? "0.0.0" : appInfo.Version;
+            _currentVersion = appInfo.Version ?? SemanticVersions.Zero;
         }
 
         /// 
@@ -94,7 +95,7 @@ public SurgeUpdateManager()
         /// 
         /// The current installed version baseline used for update checks.
         /// 
-        public string CurrentVersion => _currentVersion;
+        public SemVersion CurrentVersion => _currentVersion;
 
         /// 
         /// Switch update channel at runtime (for example, from production to test).
@@ -141,8 +142,8 @@ public void SetChannel(string channel)
         /// 
         /// Update the current version baseline used for future update checks.
         /// 
-        /// Installed version string.
-        public void SetCurrentVersion(string version)
+        /// Installed semantic version.
+        public void SetCurrentVersion(SemVersion version)
         {
             ThrowIfDisposed();
             SetCurrentVersionInternal(version);
@@ -219,7 +220,9 @@ public void SetCurrentVersion(string version)
 
                             releases.Add(new SurgeRelease
                             {
-                                Version = MarshalUtf8(versionPtr),
+                                Version = SemanticVersions.ParseRuntimeValue(
+                                    MarshalUtf8(versionPtr),
+                                    "Native release version"),
                                 Channel = MarshalUtf8(channelPtr),
                                 FullSize = fullSize,
                                 IsGenesis = isGenesis
@@ -359,12 +362,16 @@ private static int ParseStorageProvider(string provider)
             return string.IsNullOrWhiteSpace(value) ? null : value;
         }
 
-        private void SetCurrentVersionInternal(string version)
+        private void SetCurrentVersionInternal(SemVersion version)
         {
-            if (string.IsNullOrWhiteSpace(version))
-                throw new ArgumentException("Version cannot be empty.", nameof(version));
+#if NET6_0_OR_GREATER
+            ArgumentNullException.ThrowIfNull(version);
+#else
+            if (version == null)
+                throw new ArgumentNullException(nameof(version));
+#endif
 
-            int result = NativeMethods.UpdateManagerSetCurrentVersion(_nativeMgr, version);
+            int result = NativeMethods.UpdateManagerSetCurrentVersion(_nativeMgr, version.ToString());
             if (result != 0)
             {
                 var errorMsg = GetLastError();
diff --git a/scripts/set-release-version.sh b/scripts/set-release-version.sh
index 9c6a463..6366875 100755
--- a/scripts/set-release-version.sh
+++ b/scripts/set-release-version.sh
@@ -11,7 +11,9 @@ if [[ -z "$version" ]]; then
   exit 1
 fi
 
-if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-(alpha|beta)\.[0-9]+)?$ ]]; then
+semver_regex='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*))*))?(\+([0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*))?$'
+
+if [[ ! "$version" =~ $semver_regex ]]; then
   echo "Unsupported release version: $version" >&2
   exit 1
 fi