|
| 1 | +// SPDX-FileCopyrightText: © 2025 Sysand contributors <opensource@sensmetry.com> |
| 2 | +// SPDX-License-Identifier: MIT OR Apache-2.0 |
| 3 | + |
| 4 | +use std::sync::Arc; |
| 5 | + |
| 6 | +use bytes::Bytes; |
| 7 | +use camino::Utf8Path; |
| 8 | +use thiserror::Error; |
| 9 | +use url::Url; |
| 10 | + |
| 11 | +use crate::{ |
| 12 | + auth::HTTPAuthentication, |
| 13 | + project::{ProjectRead, local_kpar::LocalKParProject}, |
| 14 | +}; |
| 15 | + |
| 16 | +// Publish-only canonicalization rules for modern project IDs. |
| 17 | +// If additional surfaces need this behavior, extract to a shared module. |
| 18 | +fn is_ascii_alnum(byte: u8) -> bool { |
| 19 | + byte.is_ascii_alphanumeric() |
| 20 | +} |
| 21 | + |
| 22 | +fn is_canonicalizable_field_with_allowed_separators(s: &str, allow_dot: bool) -> bool { |
| 23 | + let bytes = s.as_bytes(); |
| 24 | + if !(3..=50).contains(&bytes.len()) { |
| 25 | + return false; |
| 26 | + } |
| 27 | + |
| 28 | + if !is_ascii_alnum(bytes[0]) || !is_ascii_alnum(bytes[bytes.len() - 1]) { |
| 29 | + return false; |
| 30 | + } |
| 31 | + |
| 32 | + for i in 1..(bytes.len() - 1) { |
| 33 | + let b = bytes[i]; |
| 34 | + if is_ascii_alnum(b) { |
| 35 | + continue; |
| 36 | + } |
| 37 | + |
| 38 | + let is_separator = b == b'-' || b == b' ' || (allow_dot && b == b'.'); |
| 39 | + if !is_separator { |
| 40 | + return false; |
| 41 | + } |
| 42 | + |
| 43 | + if !is_ascii_alnum(bytes[i - 1]) || !is_ascii_alnum(bytes[i + 1]) { |
| 44 | + return false; |
| 45 | + } |
| 46 | + } |
| 47 | + |
| 48 | + true |
| 49 | +} |
| 50 | + |
| 51 | +fn is_canonicalizable_publisher_field_value(s: &str) -> bool { |
| 52 | + is_canonicalizable_field_with_allowed_separators(s, false) |
| 53 | +} |
| 54 | + |
| 55 | +fn is_canonicalizable_name_field_value(s: &str) -> bool { |
| 56 | + is_canonicalizable_field_with_allowed_separators(s, true) |
| 57 | +} |
| 58 | + |
| 59 | +fn canonicalize_modern_project_id_component(s: &str) -> String { |
| 60 | + s.to_ascii_lowercase().replace(' ', "-") |
| 61 | +} |
| 62 | + |
| 63 | +#[derive(Error, Debug)] |
| 64 | +pub enum PublishError { |
| 65 | + #[error("failed to read kpar file at `{0}`: {1}")] |
| 66 | + KparRead(Box<str>, std::io::Error), |
| 67 | + |
| 68 | + #[error("failed to open kpar project at `{0}`: {1}")] |
| 69 | + KparOpen(Box<str>, String), |
| 70 | + |
| 71 | + #[error("missing project info in kpar")] |
| 72 | + MissingInfo, |
| 73 | + |
| 74 | + #[error("missing project metadata in kpar")] |
| 75 | + MissingMeta, |
| 76 | + |
| 77 | + #[error("missing publisher in project info (required for publishing)")] |
| 78 | + MissingPublisher, |
| 79 | + |
| 80 | + #[error( |
| 81 | + "publisher field `{0}` is invalid for modern project IDs: must be 3-50 characters, use only letters and numbers, may include single spaces or hyphens between words, and must start and end with a letter or number" |
| 82 | + )] |
| 83 | + NonCanonicalizablePublisher(Box<str>), |
| 84 | + |
| 85 | + #[error( |
| 86 | + "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" |
| 87 | + )] |
| 88 | + NonCanonicalizableName(Box<str>), |
| 89 | + |
| 90 | + #[error( |
| 91 | + "version field `{version}` is invalid for publishing: must be a valid Semantic Versioning 2.0 version ({source})" |
| 92 | + )] |
| 93 | + InvalidVersion { |
| 94 | + version: Box<str>, |
| 95 | + source: semver::Error, |
| 96 | + }, |
| 97 | + |
| 98 | + #[error("invalid index URL `{url}` for publish endpoint: {reason}")] |
| 99 | + InvalidIndexUrl { url: Box<str>, reason: String }, |
| 100 | + |
| 101 | + #[error("HTTP request failed: {0}")] |
| 102 | + Http(#[from] reqwest_middleware::Error), |
| 103 | + |
| 104 | + #[error("server error ({0}): {1}")] |
| 105 | + ServerError(u16, String), |
| 106 | + |
| 107 | + #[error("authentication failed: {0}")] |
| 108 | + AuthError(String), |
| 109 | + |
| 110 | + #[error("conflict: package version already exists: {0}")] |
| 111 | + Conflict(String), |
| 112 | + |
| 113 | + #[error("bad request: {0}")] |
| 114 | + BadRequest(String), |
| 115 | +} |
| 116 | + |
| 117 | +#[derive(Debug)] |
| 118 | +pub struct PublishResponse { |
| 119 | + pub status: u16, |
| 120 | + pub message: String, |
| 121 | + pub is_new_project: bool, |
| 122 | +} |
| 123 | + |
| 124 | +fn build_upload_url(index_url: &str) -> Result<Url, PublishError> { |
| 125 | + let mut upload_url = Url::parse(index_url).map_err(|e| PublishError::InvalidIndexUrl { |
| 126 | + url: index_url.into(), |
| 127 | + reason: e.to_string(), |
| 128 | + })?; |
| 129 | + |
| 130 | + { |
| 131 | + let mut segments = |
| 132 | + upload_url |
| 133 | + .path_segments_mut() |
| 134 | + .map_err(|_| PublishError::InvalidIndexUrl { |
| 135 | + url: index_url.into(), |
| 136 | + reason: "URL cannot be used as a hierarchical base URL".to_string(), |
| 137 | + })?; |
| 138 | + segments.pop_if_empty(); |
| 139 | + segments.extend(["api", "v1", "upload"]); |
| 140 | + } |
| 141 | + |
| 142 | + // Keep publish endpoint path stable even if user provided query/fragment in base URL. |
| 143 | + upload_url.set_query(None); |
| 144 | + upload_url.set_fragment(None); |
| 145 | + |
| 146 | + Ok(upload_url) |
| 147 | +} |
| 148 | + |
| 149 | +pub fn do_publish_kpar<P: AsRef<Utf8Path>, Policy: HTTPAuthentication>( |
| 150 | + kpar_path: P, |
| 151 | + index_url: &str, |
| 152 | + auth_policy: Arc<Policy>, |
| 153 | + client: reqwest_middleware::ClientWithMiddleware, |
| 154 | + runtime: Arc<tokio::runtime::Runtime>, |
| 155 | +) -> Result<PublishResponse, PublishError> { |
| 156 | + let kpar_path = kpar_path.as_ref(); |
| 157 | + let header = crate::style::get_style_config().header; |
| 158 | + |
| 159 | + // Open and validate kpar |
| 160 | + let kpar_project = LocalKParProject::new_guess_root(kpar_path) |
| 161 | + .map_err(|e| PublishError::KparOpen(kpar_path.as_str().into(), e.to_string()))?; |
| 162 | + |
| 163 | + let (info, meta) = kpar_project |
| 164 | + .get_project() |
| 165 | + .map_err(|e| PublishError::KparOpen(kpar_path.as_str().into(), e.to_string()))?; |
| 166 | + |
| 167 | + let info = info.ok_or(PublishError::MissingInfo)?; |
| 168 | + let _meta = meta.ok_or(PublishError::MissingMeta)?; |
| 169 | + |
| 170 | + let publisher = info |
| 171 | + .publisher |
| 172 | + .as_deref() |
| 173 | + .ok_or(PublishError::MissingPublisher)?; |
| 174 | + let name = &info.name; |
| 175 | + let version = &info.version; |
| 176 | + if !is_canonicalizable_publisher_field_value(publisher) { |
| 177 | + return Err(PublishError::NonCanonicalizablePublisher( |
| 178 | + publisher.to_owned().into_boxed_str(), |
| 179 | + )); |
| 180 | + } |
| 181 | + if !is_canonicalizable_name_field_value(name) { |
| 182 | + return Err(PublishError::NonCanonicalizableName( |
| 183 | + name.to_owned().into_boxed_str(), |
| 184 | + )); |
| 185 | + } |
| 186 | + semver::Version::parse(version).map_err(|source| PublishError::InvalidVersion { |
| 187 | + version: version.to_owned().into_boxed_str(), |
| 188 | + source, |
| 189 | + })?; |
| 190 | + let normalized_publisher = canonicalize_modern_project_id_component(publisher); |
| 191 | + let normalized_name = canonicalize_modern_project_id_component(name); |
| 192 | + let purl = format!("pkg:sysand/{normalized_publisher}/{normalized_name}@{version}"); |
| 193 | + |
| 194 | + let publishing = "Publishing"; |
| 195 | + log::info!("{header}{publishing:>12}{header:#} `{name}` {version} to {index_url}"); |
| 196 | + |
| 197 | + let file_name = kpar_path |
| 198 | + .file_name() |
| 199 | + .unwrap_or(kpar_path.as_str()) |
| 200 | + .to_string(); |
| 201 | + |
| 202 | + // Read kpar file bytes |
| 203 | + let file_bytes = std::fs::read(kpar_path) |
| 204 | + .map_err(|e| PublishError::KparRead(kpar_path.as_str().into(), e))?; |
| 205 | + |
| 206 | + let upload_url = build_upload_url(index_url)?; |
| 207 | + |
| 208 | + // Keep upload payload in `Bytes` so request retries clone cheaply. |
| 209 | + let file_bytes = Bytes::from(file_bytes); |
| 210 | + |
| 211 | + let request_builder = move |c: &reqwest_middleware::ClientWithMiddleware| { |
| 212 | + let file_part = reqwest::multipart::Part::stream(file_bytes.clone()) |
| 213 | + .file_name(file_name.clone()) |
| 214 | + .mime_str("application/octet-stream") |
| 215 | + .expect("valid mime type"); |
| 216 | + |
| 217 | + let form = reqwest::multipart::Form::new() |
| 218 | + .text("purl", purl.clone()) |
| 219 | + .part("file", file_part); |
| 220 | + |
| 221 | + c.post(upload_url.as_str()).multipart(form) |
| 222 | + }; |
| 223 | + |
| 224 | + let response = runtime.block_on(async { |
| 225 | + auth_policy |
| 226 | + .with_authentication(&client, &request_builder) |
| 227 | + .await |
| 228 | + })?; |
| 229 | + |
| 230 | + let status = response.status().as_u16(); |
| 231 | + let body = runtime.block_on(response.text()).unwrap_or_default(); |
| 232 | + |
| 233 | + match status { |
| 234 | + 200 => Ok(PublishResponse { |
| 235 | + status, |
| 236 | + message: body, |
| 237 | + is_new_project: false, |
| 238 | + }), |
| 239 | + 201 => Ok(PublishResponse { |
| 240 | + status, |
| 241 | + message: body, |
| 242 | + is_new_project: true, |
| 243 | + }), |
| 244 | + 401 | 403 => Err(PublishError::AuthError(body)), |
| 245 | + 409 => Err(PublishError::Conflict(body)), |
| 246 | + 400 | 404 => Err(PublishError::BadRequest(body)), |
| 247 | + _ => Err(PublishError::ServerError(status, body)), |
| 248 | + } |
| 249 | +} |
| 250 | + |
| 251 | +#[cfg(test)] |
| 252 | +mod tests { |
| 253 | + use super::{ |
| 254 | + PublishError, build_upload_url, canonicalize_modern_project_id_component, |
| 255 | + is_canonicalizable_name_field_value, is_canonicalizable_publisher_field_value, |
| 256 | + }; |
| 257 | + |
| 258 | + #[test] |
| 259 | + fn publisher_field_canonicalizability() { |
| 260 | + assert!(is_canonicalizable_publisher_field_value("Acme Labs")); |
| 261 | + assert!(is_canonicalizable_publisher_field_value("ACME-LABS-42")); |
| 262 | + assert!(!is_canonicalizable_publisher_field_value("ab")); |
| 263 | + assert!(!is_canonicalizable_publisher_field_value("Acme Labs")); |
| 264 | + assert!(!is_canonicalizable_publisher_field_value("Acme__Labs")); |
| 265 | + assert!(!is_canonicalizable_publisher_field_value("Acme.")); |
| 266 | + } |
| 267 | + |
| 268 | + #[test] |
| 269 | + fn name_field_canonicalizability() { |
| 270 | + assert!(is_canonicalizable_name_field_value("My.Project Alpha")); |
| 271 | + assert!(is_canonicalizable_name_field_value("Alpha-2")); |
| 272 | + assert!(!is_canonicalizable_name_field_value("ab")); |
| 273 | + assert!(!is_canonicalizable_name_field_value("My..Project")); |
| 274 | + assert!(!is_canonicalizable_name_field_value("My__Project")); |
| 275 | + assert!(!is_canonicalizable_name_field_value(".Project")); |
| 276 | + } |
| 277 | + |
| 278 | + #[test] |
| 279 | + fn canonicalize_modern_project_id_component_preserves_dot() { |
| 280 | + assert_eq!( |
| 281 | + canonicalize_modern_project_id_component("My.Project Alpha"), |
| 282 | + "my.project-alpha" |
| 283 | + ); |
| 284 | + assert_eq!( |
| 285 | + canonicalize_modern_project_id_component("ACME LABS"), |
| 286 | + "acme-labs" |
| 287 | + ); |
| 288 | + } |
| 289 | + |
| 290 | + #[test] |
| 291 | + fn build_upload_url_appends_endpoint_path() { |
| 292 | + assert_eq!( |
| 293 | + build_upload_url("https://example.org").unwrap().as_str(), |
| 294 | + "https://example.org/api/v1/upload" |
| 295 | + ); |
| 296 | + assert_eq!( |
| 297 | + build_upload_url("https://example.org/").unwrap().as_str(), |
| 298 | + "https://example.org/api/v1/upload" |
| 299 | + ); |
| 300 | + assert_eq!( |
| 301 | + build_upload_url("https://example.org/index") |
| 302 | + .unwrap() |
| 303 | + .as_str(), |
| 304 | + "https://example.org/index/api/v1/upload" |
| 305 | + ); |
| 306 | + assert_eq!( |
| 307 | + build_upload_url("https://example.org/index/") |
| 308 | + .unwrap() |
| 309 | + .as_str(), |
| 310 | + "https://example.org/index/api/v1/upload" |
| 311 | + ); |
| 312 | + } |
| 313 | + |
| 314 | + #[test] |
| 315 | + fn build_upload_url_strips_query_and_fragment() { |
| 316 | + assert_eq!( |
| 317 | + build_upload_url("https://example.org/index?x=1#frag") |
| 318 | + .unwrap() |
| 319 | + .as_str(), |
| 320 | + "https://example.org/index/api/v1/upload" |
| 321 | + ); |
| 322 | + } |
| 323 | + |
| 324 | + #[test] |
| 325 | + fn build_upload_url_rejects_non_hierarchical_url() { |
| 326 | + let err = build_upload_url("mailto:test@example.org").unwrap_err(); |
| 327 | + assert!(matches!(err, PublishError::InvalidIndexUrl { .. })); |
| 328 | + } |
| 329 | +} |
0 commit comments