From a64a60a8a9d51e1a870dd6d37aff6922e91401a7 Mon Sep 17 00:00:00 2001 From: Helge Sverre Date: Fri, 31 Jul 2026 11:53:23 +0200 Subject: [PATCH] feat: validate Sema version requirements --- README.md | 14 ++++++++ src/api/packages.rs | 3 ++ src/github_sync.rs | 78 ++++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 90 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 5824567..bb59e64 100644 --- a/README.md +++ b/README.md @@ -204,6 +204,20 @@ Link a GitHub repository to automatically publish packages from semver tags. Git tags are mapped to package versions: `v1.0.0` → `1.0.0`. Tags that don't match semver (e.g., `nightly`, `latest`) are skipped. +### Sema Version Compatibility + +Packages may declare a SemVer requirement in `sema.toml`: + +```toml +[package] +sema_version_req = ">=1.34.0" +``` + +The registry validates and normalizes this field for both CLI uploads and +GitHub-linked releases. Clients use it to skip incompatible versions during +unversioned installs and to reject incompatible explicit or locked installs +before replacing local package files. + ### Source Locking A package is either **CLI-uploaded** or **GitHub-linked**, never both. Once a package is linked to a repo, it cannot be published via `sema publish`, and vice versa. diff --git a/src/api/packages.rs b/src/api/packages.rs index 5554bcc..b45efd5 100644 --- a/src/api/packages.rs +++ b/src/api/packages.rs @@ -156,6 +156,9 @@ pub async fn publish( ))); } } + metadata.sema_version_req = + crate::github_sync::validate_sema_version_req(metadata.sema_version_req.as_deref()) + .map_err(ApiError::bad_request)?; // Ownership / source checks (reads; the writes below run in one transaction) let existing = dal::packages::find_by_name(&state.db, &name) diff --git a/src/github_sync.rs b/src/github_sync.rs index 1094a98..07884ae 100644 --- a/src/github_sync.rs +++ b/src/github_sync.rs @@ -1,5 +1,17 @@ use crate::{crypto, db::Db}; +pub fn validate_sema_version_req(requirement: Option<&str>) -> Result, String> { + let Some(requirement) = requirement.map(str::trim).filter(|value| !value.is_empty()) else { + return Ok(None); + }; + if requirement.len() > 128 { + return Err("sema_version_req must be at most 128 characters".to_string()); + } + semver::VersionReq::parse(requirement) + .map_err(|error| format!("Invalid sema_version_req {requirement:?}: {error}"))?; + Ok(Some(requirement.to_string())) +} + /// Fetch the decrypted GitHub access token for a user. pub async fn get_github_token(db: &Db, user_id: i64, token_key: &str) -> Option { let row = crate::dal::oauth::find_active(db, user_id).await.ok()??; @@ -89,10 +101,13 @@ fn parse_manifest(content: &str) -> Result { .get("repository") .and_then(toml::Value::as_str) .map(str::to_string); - let sema_version_req = pkg - .get("sema_version_req") - .and_then(toml::Value::as_str) - .map(str::to_string); + let sema_version_req = match pkg.get("sema_version_req") { + None => None, + Some(value) => { + let requirement = value.as_str().ok_or("sema_version_req must be a string")?; + validate_sema_version_req(Some(requirement))? + } + }; Ok(RepoManifest { name: name.to_string(), description, @@ -166,6 +181,7 @@ pub async fn sync_tag( sema_version_req: Option<&str>, ) -> Result { let version_str = version.to_string(); + let sema_version_req = validate_sema_version_req(sema_version_req)?; // Check if version already exists let exists = crate::dal::versions::exists(db, package_id, &version_str) @@ -183,7 +199,7 @@ pub async fn sync_tag( package_id, &version_str, tarball_url, - sema_version_req.map(String::from), + sema_version_req, ) .await .map_err(|e| format!("Failed to insert version: {e}"))?; @@ -448,6 +464,58 @@ mod readme_tests { } } +#[cfg(test)] +mod manifest_tests { + use super::*; + + #[test] + fn manifest_rejects_invalid_sema_version_requirement() { + let error = parse_manifest( + r#" + [package] + name = "policies" + sema_version_req = "not a requirement" + "#, + ) + .unwrap_err(); + assert!(error.contains("Invalid sema_version_req")); + } + + #[test] + fn manifest_normalizes_valid_sema_version_requirement() { + let manifest = parse_manifest( + r#" + [package] + name = "policies" + sema_version_req = " >=1.34.0 " + "#, + ) + .unwrap(); + assert_eq!(manifest.sema_version_req.as_deref(), Some(">=1.34.0")); + } + + #[test] + fn manifest_rejects_non_string_sema_version_requirement() { + let error = parse_manifest( + r#" + [package] + name = "policies" + sema_version_req = 34 + "#, + ) + .unwrap_err(); + assert_eq!(error, "sema_version_req must be a string"); + } + + #[test] + fn validator_treats_blank_as_absent_and_limits_length() { + assert_eq!(validate_sema_version_req(Some(" ")).unwrap(), None); + let requirement = format!(">={}", "1".repeat(128)); + let error = validate_sema_version_req(Some(&requirement)).unwrap_err(); + assert!(error.contains("at most 128 characters")); + } +} + #[cfg(test)] mod theme_probe { use super::*;