diff --git a/Cargo.lock b/Cargo.lock index ee1d5626..cabe0551 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2274,6 +2274,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "minicov" version = "0.3.8" @@ -2871,6 +2881,7 @@ dependencies = [ "js-sys", "log", "mime", + "mime_guess", "percent-encoding", "pin-project-lite", "quinn", diff --git a/core/Cargo.toml b/core/Cargo.toml index db781846..a8a31cae 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -45,7 +45,7 @@ log = { version = "0.4.29", default-features = false } pubgrub = { version = "0.3.0", default-features = false } # partialzip = { version = "5.0.0", default-features = false, optional = true } pyo3 = { version = "0.28.2", default-features = false, features = ["macros", "chrono", "indexmap"], optional = true } -reqwest-middleware = { version = "0.5.1" } +reqwest-middleware = { version = "0.5.1", features = ["multipart"] } semver = { version = "1.0.27", features = ["serde"] } serde = { version = "1.0.228", features = ["derive"] } serde_json = { version = "1.0.149", default-features = false, features = ["preserve_order"] } @@ -67,7 +67,7 @@ tokio = { version = "1.50.0", default-features = false, features = ["rt", "io-ut bytes = { version = "1.11.1", default-features = false } toml_edit = { version = "0.25.4", features = ["serde"] } globset = { version = "0.4.18", default-features = false } -reqwest = { version = "0.13.2", optional = true, features = ["rustls", "stream"] } +reqwest = { version = "0.13.2", optional = true, features = ["rustls", "stream", "multipart"] } dunce = "1.0.5" [dev-dependencies] diff --git a/core/src/auth.rs b/core/src/auth.rs index 56106b31..2dfe877d 100644 --- a/core/src/auth.rs +++ b/core/src/auth.rs @@ -416,6 +416,31 @@ pub type StandardHTTPAuthentication = RestrictAuthentication< Unauthenticated, >; +/// Publish-specific HTTP authentication policy where matching URL globs always use bearer auth. +pub type PublishHTTPAuthentication = RestrictAuthentication; + +impl StandardHTTPAuthentication { + pub fn into_publish_authentication(self) -> Result { + let mut partial = GlobMapBuilder::new(); + + for (key, sequence_auth) in self + .restricted + .keys + .into_iter() + .zip(self.restricted.values.into_iter()) + { + if let StandardInnerAuthentication::BearerAuth(inner) = sequence_auth.lower { + partial.add(key, inner); + } + } + + Ok(PublishHTTPAuthentication { + restricted: partial.build()?, + unrestricted: Unauthenticated {}, + }) + } +} + /// Utility to simplify construction of `StandardHTTPAuthentication` #[derive(Debug, Default, Clone)] pub struct StandardHTTPAuthenticationBuilder { diff --git a/core/src/commands/mod.rs b/core/src/commands/mod.rs index d3a93171..4ae0433c 100644 --- a/core/src/commands/mod.rs +++ b/core/src/commands/mod.rs @@ -10,6 +10,8 @@ pub mod include; pub mod info; pub mod init; pub mod lock; +#[cfg(all(feature = "filesystem", feature = "networking"))] +pub mod publish; pub mod remove; #[cfg(feature = "filesystem")] pub mod root; diff --git a/core/src/commands/publish.rs b/core/src/commands/publish.rs new file mode 100644 index 00000000..7d4d9fc5 --- /dev/null +++ b/core/src/commands/publish.rs @@ -0,0 +1,390 @@ +// SPDX-FileCopyrightText: © 2025 Sysand contributors +// SPDX-License-Identifier: MIT OR Apache-2.0 + +use std::sync::Arc; + +use bytes::Bytes; +use camino::Utf8Path; +use thiserror::Error; +use url::Url; + +use crate::{ + auth::{GlobMapResult, HTTPAuthentication, PublishHTTPAuthentication}, + project::{ProjectRead, local_kpar::LocalKParProject}, +}; + +// Publish-only canonicalization rules for modern project IDs. +// If additional surfaces need this behavior, extract to a shared module. +fn is_ascii_alnum(byte: u8) -> bool { + byte.is_ascii_alphanumeric() +} + +fn is_canonicalizable_field_with_allowed_separators(s: &str, allow_dot: bool) -> bool { + let bytes = s.as_bytes(); + if !(3..=50).contains(&bytes.len()) { + return false; + } + + if !is_ascii_alnum(bytes[0]) || !is_ascii_alnum(bytes[bytes.len() - 1]) { + return false; + } + + for i in 1..(bytes.len() - 1) { + let b = bytes[i]; + if is_ascii_alnum(b) { + continue; + } + + let is_separator = b == b'-' || b == b' ' || (allow_dot && b == b'.'); + if !is_separator { + return false; + } + + if !is_ascii_alnum(bytes[i - 1]) || !is_ascii_alnum(bytes[i + 1]) { + return false; + } + } + + true +} + +fn is_canonicalizable_publisher_field_value(s: &str) -> bool { + is_canonicalizable_field_with_allowed_separators(s, false) +} + +fn is_canonicalizable_name_field_value(s: &str) -> bool { + is_canonicalizable_field_with_allowed_separators(s, true) +} + +fn canonicalize_modern_project_id_component(s: &str) -> String { + s.to_ascii_lowercase().replace(' ', "-") +} + +#[derive(Error, Debug)] +pub enum PublishError { + #[error("failed to read kpar file at `{0}`: {1}")] + KparRead(Box, std::io::Error), + + #[error("failed to open kpar project at `{0}`: {1}")] + KparOpen(Box, String), + + #[error("missing project info in kpar")] + MissingInfo, + + #[error("missing project metadata in kpar")] + MissingMeta, + + #[error("missing publisher in project info (required for publishing)")] + MissingPublisher, + + #[error( + "publisher field `{0}` is invalid for modern project IDs: must be 3-50 characters, use only ASCII letters and numbers, may include single spaces or hyphens between words, and must start and end with a letter or number" + )] + NonCanonicalizablePublisher(Box), + + #[error( + "name field `{0}` is invalid for modern project IDs: must be 3-50 characters, use only letters and numbers, may include single spaces, hyphens, or dots between words, and must start and end with a letter or number" + )] + NonCanonicalizableName(Box), + + #[error( + "version field `{version}` is invalid for publishing: must be a valid Semantic Versioning 2.0 version ({source})" + )] + InvalidVersion { + version: Box, + source: semver::Error, + }, + + #[error("missing license in project info (required for publishing)")] + MissingLicense, + + #[error( + "license field `{license}` is invalid for publishing: must be a valid SPDX license expression ({source})" + )] + InvalidLicense { + license: Box, + source: spdx::error::ParseError, + }, + + #[error("invalid index URL `{url}` for publish endpoint: {reason}")] + InvalidIndexUrl { url: Box, reason: String }, + + #[error( + "no bearer token credentials configured for publish URL `{0}`; set SYSAND_CRED_ and SYSAND_CRED__BEARER_TOKEN with a matching URL pattern" + )] + MissingCredentials(Box), + + #[error("HTTP request failed: {0}")] + Http(#[from] reqwest_middleware::Error), + + #[error("server error ({0}): {1}")] + ServerError(u16, String), + + #[error("authentication failed: {0}")] + AuthError(String), + + #[error("conflict: package version already exists: {0}")] + Conflict(String), + + #[error("bad request: {0}")] + BadRequest(String), +} + +#[derive(Debug)] +pub struct PublishResponse { + pub status: u16, + pub message: String, + pub is_new_project: bool, +} + +fn build_upload_url(index_url: &Url) -> Result { + if !matches!(index_url.scheme(), "http" | "https") { + return Err(PublishError::InvalidIndexUrl { + url: index_url.as_str().into(), + reason: "URL scheme must be http or https".to_string(), + }); + } + + if index_url.query().is_some() { + return Err(PublishError::InvalidIndexUrl { + url: index_url.as_str().into(), + reason: "URL must not include a query component".to_string(), + }); + } + + if index_url.fragment().is_some() { + return Err(PublishError::InvalidIndexUrl { + url: index_url.as_str().into(), + reason: "URL must not include a fragment component".to_string(), + }); + } + + let mut upload_url = index_url.clone(); + { + let mut segments = upload_url.path_segments_mut().unwrap(); + segments.pop_if_empty(); + segments.extend(["api", "v1", "upload"]); + } + + Ok(upload_url) +} + +pub fn do_publish_kpar>( + kpar_path: P, + index_url: Url, + auth_policy: Arc, + client: reqwest_middleware::ClientWithMiddleware, + runtime: Arc, +) -> Result { + let kpar_path = kpar_path.as_ref(); + let header = crate::style::get_style_config().header; + + // Open and validate kpar + let kpar_project = LocalKParProject::new_guess_root(kpar_path) + .map_err(|e| PublishError::KparOpen(kpar_path.as_str().into(), e.to_string()))?; + + let (info, meta) = kpar_project + .get_project() + .map_err(|e| PublishError::KparOpen(kpar_path.as_str().into(), e.to_string()))?; + + let info = info.ok_or(PublishError::MissingInfo)?; + let _meta = meta.ok_or(PublishError::MissingMeta)?; + + let publisher = info + .publisher + .as_deref() + .ok_or(PublishError::MissingPublisher)?; + let name = &info.name; + let version = &info.version; + let license = info + .license + .as_deref() + .ok_or(PublishError::MissingLicense)?; + if !is_canonicalizable_publisher_field_value(publisher) { + return Err(PublishError::NonCanonicalizablePublisher(publisher.into())); + } + if !is_canonicalizable_name_field_value(name) { + return Err(PublishError::NonCanonicalizableName(name.as_str().into())); + } + semver::Version::parse(version).map_err(|source| PublishError::InvalidVersion { + version: version.as_str().into(), + source, + })?; + spdx::Expression::parse(license).map_err(|source| PublishError::InvalidLicense { + license: license.into(), + source, + })?; + let normalized_publisher = canonicalize_modern_project_id_component(publisher); + let normalized_name = canonicalize_modern_project_id_component(name); + let purl = format!("pkg:sysand/{normalized_publisher}/{normalized_name}@{version}"); + + let publishing = "Publishing"; + log::info!( + "{header}{publishing:>12}{header:#} `{name}` {version} to {}", + index_url + ); + + let file_name = kpar_path + .file_name() + .unwrap_or(kpar_path.as_str()) + .to_string(); + + // Read kpar file bytes + let file_bytes = std::fs::read(kpar_path) + .map_err(|e| PublishError::KparRead(kpar_path.as_str().into(), e))?; + + let upload_url = build_upload_url(&index_url)?; + + match auth_policy.restricted.lookup(upload_url.as_str()) { + GlobMapResult::NotFound => { + return Err(PublishError::MissingCredentials(upload_url.as_str().into())); + } + GlobMapResult::Found(_, _) | GlobMapResult::Ambiguous(_) => {} + } + + // Keep upload payload in `Bytes` so request retries clone cheaply. + let file_bytes = Bytes::from(file_bytes); + let upload_url_for_request = upload_url.clone(); + + let request_builder = move |c: &reqwest_middleware::ClientWithMiddleware| { + let file_part = reqwest::multipart::Part::stream(file_bytes.clone()) + .file_name(file_name.clone()) + .mime_str("application/octet-stream") + .unwrap(); + + let form = reqwest::multipart::Form::new() + .text("purl", purl.clone()) + .part("file", file_part); + + c.post(upload_url_for_request.clone()).multipart(form) + }; + + let response = runtime.block_on(async { + auth_policy + .with_authentication(&client, &request_builder) + .await + })?; + + let status = response.status().as_u16(); + let response_url = response.url().to_string(); + let body = runtime.block_on(response.text()).unwrap_or_default(); + log::debug!( + "publish response: request URL `{}`, final URL `{}`, status {}", + upload_url, + response_url, + status + ); + + match status { + 200 => Ok(PublishResponse { + status, + message: body, + is_new_project: false, + }), + 201 => Ok(PublishResponse { + status, + message: body, + is_new_project: true, + }), + 401 | 403 => Err(PublishError::AuthError(body)), + 409 => Err(PublishError::Conflict(body)), + 400 | 404 => Err(PublishError::BadRequest(body)), + _ => { + log::warn!( + "publish failed: request URL `{}`, final URL `{}`, status {}", + upload_url, + response_url, + status + ); + Err(PublishError::ServerError(status, body)) + } + } +} + +#[cfg(test)] +mod tests { + use super::{ + PublishError, build_upload_url, canonicalize_modern_project_id_component, + is_canonicalizable_name_field_value, is_canonicalizable_publisher_field_value, + }; + use url::Url; + + #[test] + fn publisher_field_canonicalizability() { + assert!(is_canonicalizable_publisher_field_value("Acme Labs")); + assert!(is_canonicalizable_publisher_field_value("ACME-LABS-42")); + assert!(!is_canonicalizable_publisher_field_value("ab")); + assert!(!is_canonicalizable_publisher_field_value("Acme Labs")); + assert!(!is_canonicalizable_publisher_field_value("Acme__Labs")); + assert!(!is_canonicalizable_publisher_field_value("Acme.")); + } + + #[test] + fn name_field_canonicalizability() { + assert!(is_canonicalizable_name_field_value("My.Project Alpha")); + assert!(is_canonicalizable_name_field_value("Alpha-2")); + assert!(!is_canonicalizable_name_field_value("ab")); + assert!(!is_canonicalizable_name_field_value("My..Project")); + assert!(!is_canonicalizable_name_field_value("My__Project")); + assert!(!is_canonicalizable_name_field_value(".Project")); + } + + #[test] + fn canonicalize_modern_project_id_component_preserves_dot() { + assert_eq!( + canonicalize_modern_project_id_component("My.Project Alpha"), + "my.project-alpha" + ); + assert_eq!( + canonicalize_modern_project_id_component("ACME LABS"), + "acme-labs" + ); + } + + #[test] + fn build_upload_url_appends_endpoint_path() { + assert_eq!( + build_upload_url(&Url::parse("https://example.org").unwrap()) + .unwrap() + .as_str(), + "https://example.org/api/v1/upload" + ); + assert_eq!( + build_upload_url(&Url::parse("https://example.org/").unwrap()) + .unwrap() + .as_str(), + "https://example.org/api/v1/upload" + ); + assert_eq!( + build_upload_url(&Url::parse("https://example.org/index").unwrap()) + .unwrap() + .as_str(), + "https://example.org/index/api/v1/upload" + ); + assert_eq!( + build_upload_url(&Url::parse("https://example.org/index/").unwrap()) + .unwrap() + .as_str(), + "https://example.org/index/api/v1/upload" + ); + } + + #[test] + fn build_upload_url_strips_query_and_fragment() { + let err = build_upload_url(&Url::parse("https://example.org/index?x=1#frag").unwrap()) + .unwrap_err(); + assert!(matches!(err, PublishError::InvalidIndexUrl { .. })); + } + + #[test] + fn build_upload_url_rejects_non_http_scheme() { + let err = build_upload_url(&Url::parse("ftp://example.org").unwrap()).unwrap_err(); + assert!(matches!(err, PublishError::InvalidIndexUrl { .. })); + } + + #[test] + fn build_upload_url_rejects_non_hierarchical_url() { + let err = build_upload_url(&Url::parse("mailto:test@example.org").unwrap()).unwrap_err(); + assert!(matches!(err, PublishError::InvalidIndexUrl { .. })); + } +} diff --git a/core/src/project/gix_git_download.rs b/core/src/project/gix_git_download.rs index 2b058d32..1fc7d64f 100644 --- a/core/src/project/gix_git_download.rs +++ b/core/src/project/gix_git_download.rs @@ -161,6 +161,7 @@ mod tests { //use predicates::prelude::*; /// Initializes a git repository at `path` with a pre-configured test user. + #[cfg(feature = "alltests")] fn git_init(path: &std::path::Path) -> Result<(), Box> { Command::new("git") .arg("init") diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 57e75320..0ed2d3bb 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -14,6 +14,7 @@ - [sysand include](commands/include.md) - [sysand exclude](commands/exclude.md) - [sysand build](commands/build.md) + - [sysand publish](commands/publish.md) - [sysand lock](commands/lock.md) - [sysand env](commands/env.md) - [sysand env install](commands/env/install.md) diff --git a/docs/src/commands/publish.md b/docs/src/commands/publish.md new file mode 100644 index 00000000..91353dbb --- /dev/null +++ b/docs/src/commands/publish.md @@ -0,0 +1,77 @@ +# `sysand publish` + +Publish a KPAR to the sysand package index. + +## Usage + +```sh +sysand publish [OPTIONS] [PATH] +``` + +## Description + +Publishes a `.kpar` file to a sysand-compatible package index. The project +must be built first using [`sysand build`](build.md). + +Authentication is required. See [Authentication](../authentication.md) for +how to configure credentials. +For `sysand publish`, only bearer token credentials +(`SYSAND_CRED__BEARER_TOKEN`) are used. +If no matching bearer token credentials are configured for the publish URL, +the command fails before making the upload request. + +The package identifier used during publish is derived from project metadata. +Before publishing, ensure `version`, `publisher`, and `name` follow these rules: + +- `version`: must be a valid Semantic Versioning 2.0 version. + +- `publisher`: 3-50 characters, letters and numbers only, with optional single + spaces or hyphens between words, and must start and end with a letter or + number. +- `name`: 3-50 characters, letters and numbers only, with optional single + spaces, hyphens, or dots between words, and must start and end with a letter + or number. + +`name` dots are preserved in the published identifier (they are not normalized +away). + +## Arguments + +- `[PATH]`: Path to the `.kpar` file to publish. If not provided, looks for + a KPAR in the output directory matching the current project's name and + version (e.g. `output/-.kpar`). + +## Options + +- `--index `: URL of the package index to publish to. Defaults to the + configured default index URL, otherwise the first configured index URL, or + `https://beta.sysand.org`. + +{{#include ./partials/global_opts.md}} + +## Examples + +Build and publish the current project: + +```sh +sysand build +sysand publish +``` + +Publish a specific KPAR file: + +```sh +sysand publish ./my-project-1.0.0.kpar +``` + +Publish to a custom index: + +```sh +sysand publish --index https://my-index.example.com +``` + +## See Also + +- [`sysand build`](build.md) — Build a KPAR from a project +- [Authentication](../authentication.md) — Configure credentials +- [Publishing a package](../publishing.md) — Publishing guide diff --git a/sysand/Cargo.toml b/sysand/Cargo.toml index 1f7e3fd7..f24c5ac0 100644 --- a/sysand/Cargo.toml +++ b/sysand/Cargo.toml @@ -42,7 +42,7 @@ url = { version = "2.5.8", default-features = false } pubgrub = { version = "0.3.0", default-features = false } indexmap = "2.13.0" tokio = { version = "1.50.0", default-features = false } -reqwest-middleware = { version = "0.5.1" } +reqwest-middleware = { version = "0.5.1", features = ["multipart"] } reqwest = { version = "0.13.2", features = ["rustls", "blocking"] } [dev-dependencies] diff --git a/sysand/src/cli.rs b/sysand/src/cli.rs index 1034a426..8057a4ec 100644 --- a/sysand/src/cli.rs +++ b/sysand/src/cli.rs @@ -12,6 +12,7 @@ use clap::{ValueEnum, builder::StyledStr, crate_authors}; use fluent_uri::Iri; use semver::VersionReq; use sysand_core::build::KparCompressionMethod; +use url::Url; use crate::env_vars; @@ -181,6 +182,20 @@ pub enum Command { #[arg(long, short, default_value_t = false, verbatim_doc_comment)] allow_path_usage: bool, }, + /// Publish a KPAR to a sysand package index + Publish { + /// Path to the KPAR file to publish. If not provided, will look + /// for a KPAR in the output directory with the current project's + /// name and version + #[clap(verbatim_doc_comment)] + path: Option, + + /// URL of the package index to publish to. Defaults to the + /// configured default index URL, otherwise the first configured + /// index URL, or https://beta.sysand.org + #[arg(long, verbatim_doc_comment)] + index: Option, + }, /// Create or update lockfile Lock { #[command(flatten)] diff --git a/sysand/src/commands/mod.rs b/sysand/src/commands/mod.rs index 4de8a420..d87694a2 100644 --- a/sysand/src/commands/mod.rs +++ b/sysand/src/commands/mod.rs @@ -11,6 +11,7 @@ pub mod info; pub mod init; pub mod lock; pub mod print_root; +pub mod publish; pub mod remove; pub mod sources; pub mod sync; diff --git a/sysand/src/commands/publish.rs b/sysand/src/commands/publish.rs new file mode 100644 index 00000000..31bccf1f --- /dev/null +++ b/sysand/src/commands/publish.rs @@ -0,0 +1,181 @@ +// SPDX-FileCopyrightText: © 2025 Sysand contributors +// SPDX-License-Identifier: MIT OR Apache-2.0 + +use std::sync::Arc; + +use anyhow::{Result, anyhow, bail}; +use camino::Utf8PathBuf; +use sysand_core::{ + auth::StandardHTTPAuthentication, build::default_kpar_file_name, + commands::publish::do_publish_kpar, config::Config, context::ProjectContext, +}; +use url::Url; + +use crate::{CliError, DEFAULT_INDEX_URL}; + +fn resolve_publish_kpar_path( + path: Option, + ctx: &ProjectContext, +) -> Result { + Ok(if let Some(path) = path { + path + } else { + let current_project = ctx + .current_project + .as_ref() + .ok_or(CliError::MissingProjectCurrentDir)?; + let mut output_dir = ctx + .current_workspace + .as_ref() + .map(|workspace| workspace.root_path()) + .unwrap_or(¤t_project.project_path) + .join("output"); + let name = default_kpar_file_name(current_project)?; + output_dir.push(name); + output_dir + }) +} + +fn resolve_publish_index_url(index: Option, config: &Config) -> Result { + if let Some(index_url) = index { + return Ok(index_url); + } + + if let Some(index_url) = config + .indexes + .iter() + .find(|index| index.default.unwrap_or(false)) + .map(|index| index.url.as_str()) + .or_else(|| config.indexes.first().map(|index| index.url.as_str())) + { + Url::parse(index_url).map_err(|e| anyhow!("invalid index URL in configuration: {e}")) + } else { + Ok(Url::parse(DEFAULT_INDEX_URL).expect("default publish index URL must be valid")) + } +} + +pub fn command_publish( + path: Option, + index: Option, + ctx: &ProjectContext, + config: &Config, + auth_policy: Arc, + client: reqwest_middleware::ClientWithMiddleware, + runtime: Arc, +) -> Result<()> { + let kpar_path = resolve_publish_kpar_path(path, ctx)?; + if !kpar_path.is_file() { + bail!("kpar file not found at `{kpar_path}`, run `sysand build` first"); + } + let index_url = resolve_publish_index_url(index, config)?; + let publish_auth_policy = + Arc::new(Arc::unwrap_or_clone(auth_policy).into_publish_authentication()?); + let response = do_publish_kpar(kpar_path, index_url, publish_auth_policy, client, runtime)?; + + let header = sysand_core::style::get_style_config().header; + let published = "Published"; + if response.is_new_project { + log::info!("{header}{published:>12}{header:#} new project successfully"); + } else { + log::info!("{header}{published:>12}{header:#} new release successfully"); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::resolve_publish_index_url; + use crate::DEFAULT_INDEX_URL; + use sysand_core::config::{Config, Index}; + use url::Url; + + #[test] + fn resolve_publish_index_url_prefers_explicit_flag() { + let config = Config { + indexes: vec![Index { + url: "https://config.example.com".to_string(), + default: Some(true), + ..Default::default() + }], + ..Default::default() + }; + + let url = resolve_publish_index_url( + Some(Url::parse("https://cli.example.com").unwrap()), + &config, + ) + .unwrap(); + + assert_eq!(url.as_str(), "https://cli.example.com/"); + } + + #[test] + fn resolve_publish_index_url_prefers_config_default() { + let config = Config { + indexes: vec![ + Index { + url: "https://non-default.example.com".to_string(), + default: Some(false), + ..Default::default() + }, + Index { + url: "https://default.example.com".to_string(), + default: Some(true), + ..Default::default() + }, + ], + ..Default::default() + }; + + let url = resolve_publish_index_url(None, &config).unwrap(); + + assert_eq!(url.as_str(), "https://default.example.com/"); + } + + #[test] + fn resolve_publish_index_url_falls_back_to_first_non_default() { + let config = Config { + indexes: vec![ + Index { + url: "https://first.example.com".to_string(), + ..Default::default() + }, + Index { + url: "https://second.example.com".to_string(), + ..Default::default() + }, + ], + ..Default::default() + }; + + let url = resolve_publish_index_url(None, &config).unwrap(); + + assert_eq!(url.as_str(), "https://first.example.com/"); + } + + #[test] + fn resolve_publish_index_url_falls_back_to_builtin_default() { + let config = Config::default(); + let url = resolve_publish_index_url(None, &config).unwrap(); + + assert_eq!(url.as_str(), format!("{DEFAULT_INDEX_URL}/")); + } + + #[test] + fn resolve_publish_index_url_reports_invalid_config_url() { + let config = Config { + indexes: vec![Index { + url: "not-a-url".to_string(), + default: Some(true), + ..Default::default() + }], + ..Default::default() + }; + + let error = resolve_publish_index_url(None, &config) + .unwrap_err() + .to_string(); + assert!(error.contains("invalid index URL in configuration")); + } +} diff --git a/sysand/src/lib.rs b/sysand/src/lib.rs index 2e47df5e..ebde3181 100644 --- a/sysand/src/lib.rs +++ b/sysand/src/lib.rs @@ -59,6 +59,7 @@ use crate::{ init::command_init, lock::command_lock, print_root::command_print_root, + publish::command_publish, remove::command_remove, sources::{command_sources_env, command_sources_project}, sync::command_sync, @@ -267,7 +268,7 @@ pub fn run_cli(args: cli::Args) -> Result<()> { } } } - let basic_auth_policy = Arc::new(auths_builder.build()?); + let basic_or_bearer_auth_policy = Arc::new(auths_builder.build()?); match args.command { Command::Init { @@ -309,7 +310,7 @@ pub fn run_cli(args: cli::Args) -> Result<()> { project_root, client, runtime, - basic_auth_policy, + basic_or_bearer_auth_policy, ctx, ) } else { @@ -322,7 +323,7 @@ pub fn run_cli(args: cli::Args) -> Result<()> { project_root, client, runtime, - basic_auth_policy, + basic_or_bearer_auth_policy, ctx, ) } @@ -369,7 +370,7 @@ pub fn run_cli(args: cli::Args) -> Result<()> { project_root, client, runtime, - basic_auth_policy, + basic_or_bearer_auth_policy, &ctx, ) .map(|_| ()) @@ -414,7 +415,7 @@ pub fn run_cli(args: cli::Args) -> Result<()> { &project_root, client.clone(), runtime.clone(), - basic_auth_policy.clone(), + basic_or_bearer_auth_policy.clone(), &ctx, )? } else { @@ -429,7 +430,7 @@ pub fn run_cli(args: cli::Args) -> Result<()> { client, &provided_iris, runtime, - basic_auth_policy, + basic_or_bearer_auth_policy, &ctx, ) } @@ -483,7 +484,7 @@ pub fn run_cli(args: cli::Args) -> Result<()> { &project_root, &client, runtime.clone(), - basic_auth_policy.clone(), + basic_or_bearer_auth_policy.clone(), )?; enum Location { @@ -568,7 +569,7 @@ pub fn run_cli(args: cli::Args) -> Result<()> { &excluded_iris, overrides, runtime, - basic_auth_policy, + basic_or_bearer_auth_policy, ), (Location::Iri(iri), Some(subcommand)) => { let numbered = subcommand.numbered(); @@ -581,7 +582,7 @@ pub fn run_cli(args: cli::Args) -> Result<()> { index_urls, overrides, runtime, - basic_auth_policy, + basic_or_bearer_auth_policy, ) } (Location::Path(path), None) => command_info_path(&path, &excluded_iris), @@ -614,7 +615,7 @@ pub fn run_cli(args: cli::Args) -> Result<()> { ctx, client, runtime, - basic_auth_policy, + basic_or_bearer_auth_policy, ) } Command::Remove { locator } => { @@ -681,6 +682,15 @@ pub fn run_cli(args: cli::Args) -> Result<()> { ) } } + cli::Command::Publish { path, index } => command_publish( + path, + index, + &ctx, + &config, + basic_or_bearer_auth_policy, + client, + runtime, + ), Command::Sources { sources_opts } => { let cli::SourcesOptions { no_deps, @@ -711,7 +721,7 @@ pub fn run_cli(args: cli::Args) -> Result<()> { &config, client, runtime, - basic_auth_policy, + basic_or_bearer_auth_policy, ), } } diff --git a/sysand/tests/cli_publish.rs b/sysand/tests/cli_publish.rs new file mode 100644 index 00000000..6320be91 --- /dev/null +++ b/sysand/tests/cli_publish.rs @@ -0,0 +1,391 @@ +// SPDX-FileCopyrightText: © 2025 Sysand contributors +// SPDX-License-Identifier: MIT OR Apache-2.0 + +use assert_cmd::prelude::*; +use camino::{Utf8Path, Utf8PathBuf}; +use camino_tempfile::Utf8TempDir; +use indexmap::IndexMap; +use mockito::{Matcher, Server}; +use predicates::prelude::*; + +// pub due to https://github.com/rust-lang/rust/issues/46379 +mod common; +pub use common::*; + +type TestResult = Result<(), Box>; + +fn init_project(name: &str) -> Result<(Utf8TempDir, Utf8PathBuf), Box> { + let (temp_dir, cwd, out) = run_sysand(["init", "--version", "1.0.0", "--name", name], None)?; + out.assert().success(); + Ok((temp_dir, cwd)) +} + +fn run_sysand_ok(cwd: &Utf8Path, args: &[&str], cfg: Option<&str>) -> TestResult { + let out = run_sysand_in(cwd, args.iter().copied(), cfg)?; + out.assert().success(); + Ok(()) +} + +fn include_basic_model(cwd: &Utf8Path) -> TestResult { + std::fs::write(cwd.join("test.sysml"), "package P;\n")?; + run_sysand_ok(cwd, &["include", "--no-index-symbols", "test.sysml"], None) +} + +fn build_default_kpar(cwd: &Utf8Path) -> TestResult { + run_sysand_ok(cwd, &["build"], None) +} + +fn build_kpar_at(cwd: &Utf8Path, kpar_path: &str) -> TestResult { + run_sysand_ok(cwd, &["build", kpar_path], None) +} + +fn setup_built_project( + name: &str, +) -> Result<(Utf8TempDir, Utf8PathBuf), Box> { + let (temp_dir, cwd) = init_project(name)?; + include_basic_model(&cwd)?; + build_default_kpar(&cwd)?; + Ok((temp_dir, cwd)) +} + +fn setup_built_project_at( + name: &str, + kpar_path: &str, +) -> Result<(Utf8TempDir, Utf8PathBuf), Box> { + let (temp_dir, cwd) = init_project(name)?; + include_basic_model(&cwd)?; + build_kpar_at(&cwd, kpar_path)?; + Ok((temp_dir, cwd)) +} + +fn set_project_field(cwd: &Utf8Path, field: &str, value: &str) -> TestResult { + run_sysand_ok(cwd, &["info", field, "--set", value], None) +} + +fn bearer_env_for_url(url: &str) -> IndexMap { + let mut env = IndexMap::new(); + env.insert("SYSAND_CRED_TEST".to_string(), format!("{url}/**")); + env.insert( + "SYSAND_CRED_TEST_BEARER_TOKEN".to_string(), + "test-token".to_string(), + ); + env +} + +#[test] +fn test_publish_missing_kpar() -> TestResult { + let (_temp_dir, cwd) = init_project("test-publish")?; + let out = run_sysand_in(&cwd, ["publish"], None)?; + + out.assert() + .failure() + .stderr(predicate::str::contains("kpar file not found")) + .stderr(predicate::str::contains("sysand build")); + + Ok(()) +} + +#[test] +fn test_publish_explicit_missing_kpar() -> TestResult { + let (_temp_dir, cwd) = init_project("test-publish")?; + let out = run_sysand_in(&cwd, ["publish", "nonexistent.kpar"], None)?; + + out.assert() + .failure() + .stderr(predicate::str::contains("kpar file not found")); + + Ok(()) +} + +#[test] +fn test_publish_network_error() -> TestResult { + let (_temp_dir, cwd) = setup_built_project("test-publish")?; + let env = bearer_env_for_url("http://localhost:1"); + let out = run_sysand_in_with( + &cwd, + ["publish", "--index", "http://localhost:1"], + None, + &env, + )?; + + out.assert() + .failure() + .stderr(predicate::str::contains("HTTP request failed")); + + Ok(()) +} + +#[test] +fn test_publish_uses_config_default_index() -> TestResult { + let (_temp_dir, cwd) = setup_built_project("test-publish")?; + + let mut server = Server::new(); + let publish_mock = server + .mock("POST", "/api/v1/upload") + .match_header( + "content-type", + Matcher::Regex("multipart/form-data; boundary=.*".to_string()), + ) + .match_header( + "content-length", + Matcher::Regex("^[1-9][0-9]{2,}$".to_string()), + ) + .match_body(Matcher::AllOf(vec![ + Matcher::Regex(r#"name="purl""#.to_string()), + Matcher::Regex(r#"name="file""#.to_string()), + Matcher::Regex(r#"filename=".*\.kpar""#.to_string()), + Matcher::Regex(r#"Content-Type: application/octet-stream"#.to_string()), + ])) + .with_status(201) + .with_body("created") + .expect(1) + .create(); + + let config_path = cwd.join("publish-test.toml"); + std::fs::write( + &config_path, + format!("[[index]]\nurl = \"{}\"\ndefault = true\n", server.url()), + )?; + + let env = bearer_env_for_url(server.url().as_str()); + let out = run_sysand_in_with(&cwd, ["publish"], Some(config_path.as_str()), &env)?; + out.assert().success(); + publish_mock.assert(); + + Ok(()) +} + +#[test] +fn test_publish_prefers_configured_default_index() -> TestResult { + let (_temp_dir, cwd) = setup_built_project("test-publish")?; + + let mut non_default_server = Server::new(); + let non_default_publish_mock = non_default_server + .mock("POST", "/api/v1/upload") + .expect(0) + .create(); + + let mut default_server = Server::new(); + let default_publish_mock = default_server + .mock("POST", "/api/v1/upload") + .match_header( + "content-type", + Matcher::Regex("multipart/form-data; boundary=.*".to_string()), + ) + .with_status(201) + .with_body("created") + .expect(1) + .create(); + + let config_path = cwd.join("publish-test.toml"); + std::fs::write( + &config_path, + format!( + "[[index]]\nurl = \"{}\"\n[[index]]\nurl = \"{}\"\ndefault = true\n", + non_default_server.url(), + default_server.url() + ), + )?; + + let env = bearer_env_for_url(default_server.url().as_str()); + let out = run_sysand_in_with(&cwd, ["publish"], Some(config_path.as_str()), &env)?; + out.assert().success(); + default_publish_mock.assert(); + non_default_publish_mock.assert(); + + Ok(()) +} + +#[test] +fn test_publish_explicit_path_outside_project_dir() -> TestResult { + let (_temp_dir, cwd) = setup_built_project_at("outside-publish", "artifact.kpar")?; + let kpar_path = cwd.join("artifact.kpar"); + + let (_outside_temp_dir, outside_cwd) = new_temp_cwd()?; + let env = bearer_env_for_url("http://localhost:1"); + let out = run_sysand_in_with( + &outside_cwd, + [ + "publish", + kpar_path.as_str(), + "--index", + "http://localhost:1", + ], + None, + &env, + )?; + + out.assert() + .failure() + .stderr(predicate::str::contains("unable to find interchange project").not()) + .stderr(predicate::str::contains("HTTP request failed")); + + Ok(()) +} + +#[test] +fn test_publish_invalid_index_url_errors_early() -> TestResult { + let (_temp_dir, cwd) = setup_built_project_at("invalid-index", "artifact.kpar")?; + let out = run_sysand_in( + &cwd, + ["publish", "artifact.kpar", "--index", "not-a-url"], + None, + )?; + + out.assert() + .failure() + .stderr(predicate::str::contains("invalid index URL")) + .stderr(predicate::str::contains("HTTP request failed").not()); + + Ok(()) +} + +#[test] +fn test_publish_rejects_invalid_semver_version() -> TestResult { + let (_temp_dir, cwd) = init_project("invalid-version")?; + + let project_file = cwd.join(".project.json"); + let project_json = std::fs::read_to_string(&project_file)?; + let project_json = + project_json.replace("\"version\": \"1.0.0\"", "\"version\": \"not-semver\""); + std::fs::write(project_file, project_json)?; + + include_basic_model(&cwd)?; + build_kpar_at(&cwd, "artifact.kpar")?; + + let out = run_sysand_in( + &cwd, + ["publish", "artifact.kpar", "--index", "http://localhost:1"], + None, + )?; + + out.assert() + .failure() + .stderr(predicate::str::contains("version field")) + .stderr(predicate::str::contains("Semantic Versioning 2.0 version")) + .stderr(predicate::str::contains("HTTP request failed").not()); + + Ok(()) +} + +#[test] +fn test_publish_rejects_noncanonicalizable_publisher() -> TestResult { + let (_temp_dir, cwd) = init_project("valid-publish-name")?; + set_project_field(&cwd, "publisher", "bad__publisher")?; + include_basic_model(&cwd)?; + build_kpar_at(&cwd, "artifact.kpar")?; + + let out = run_sysand_in( + &cwd, + ["publish", "artifact.kpar", "--index", "http://localhost:1"], + None, + )?; + + out.assert() + .failure() + .stderr(predicate::str::contains("publisher field")) + .stderr(predicate::str::contains("must be 3-50 characters")) + .stderr(predicate::str::contains("HTTP request failed").not()); + + Ok(()) +} + +#[test] +fn test_publish_rejects_noncanonicalizable_name() -> TestResult { + let (_temp_dir, cwd) = init_project("valid-publish-name")?; + set_project_field(&cwd, "name", "bad__name")?; + include_basic_model(&cwd)?; + build_kpar_at(&cwd, "artifact.kpar")?; + + let out = run_sysand_in( + &cwd, + ["publish", "artifact.kpar", "--index", "http://localhost:1"], + None, + )?; + + out.assert() + .failure() + .stderr(predicate::str::contains("name field")) + .stderr(predicate::str::contains("must be 3-50 characters")) + .stderr(predicate::str::contains("HTTP request failed").not()); + + Ok(()) +} + +#[test] +fn test_publish_canonicalizes_modern_project_id() -> TestResult { + let (_temp_dir, cwd) = init_project("seed-project")?; + set_project_field(&cwd, "publisher", "Acme Labs")?; + set_project_field(&cwd, "name", "My.Project Alpha")?; + include_basic_model(&cwd)?; + build_kpar_at(&cwd, "artifact.kpar")?; + + let mut server = Server::new(); + let publish_mock = server + .mock("POST", "/api/v1/upload") + .match_header( + "content-type", + Matcher::Regex("multipart/form-data; boundary=.*".to_string()), + ) + .match_header( + "content-length", + Matcher::Regex("^[1-9][0-9]{2,}$".to_string()), + ) + .match_body(Matcher::AllOf(vec![ + Matcher::Regex(r#"name="purl""#.to_string()), + Matcher::Regex("pkg:sysand/acme-labs/my\\.project-alpha@1\\.0\\.0".to_string()), + Matcher::Regex(r#"name="file""#.to_string()), + Matcher::Regex(r#"filename="artifact\.kpar""#.to_string()), + Matcher::Regex(r#"Content-Type: application/octet-stream"#.to_string()), + ])) + .with_status(201) + .with_body("created") + .expect(1) + .create(); + + let index_url = server.url(); + let env = bearer_env_for_url(index_url.as_str()); + let out = run_sysand_in_with( + &cwd, + ["publish", "artifact.kpar", "--index", index_url.as_str()], + None, + &env, + )?; + + out.assert().success(); + publish_mock.assert(); + + Ok(()) +} + +#[test] +fn test_publish_ignores_basic_auth_credentials() -> TestResult { + let (_temp_dir, cwd) = setup_built_project("publish-basic-auth-ignored")?; + + let mut server = Server::new(); + let publish_mock = server.mock("POST", "/api/v1/upload").expect(0).create(); + + let config_path = cwd.join("publish-test.toml"); + std::fs::write( + &config_path, + format!("[[index]]\nurl = \"{}\"\ndefault = true\n", server.url()), + )?; + + let pattern = format!("{}/**", server.url()); + let mut env = IndexMap::new(); + env.insert("SYSAND_CRED_TEST", pattern.as_str()); + env.insert("SYSAND_CRED_TEST_BASIC_USER", "user"); + env.insert("SYSAND_CRED_TEST_BASIC_PASS", "pass"); + + let out = run_sysand_in_with(&cwd, ["publish"], Some(config_path.as_str()), &env)?; + out.assert() + .failure() + .stderr(predicate::str::contains( + "no bearer token credentials configured for publish URL", + )) + .stderr(predicate::str::contains("HTTP request failed").not()); + + publish_mock.assert(); + + Ok(()) +}