Skip to content

Commit 9ef6e96

Browse files
author
Erik Sundell
committed
Add publish command
Signed-off-by: Erik Sundell <erik.sundell@sensmetry.com>
1 parent 8fbda0c commit 9ef6e96

13 files changed

Lines changed: 1043 additions & 14 deletions

File tree

Cargo.lock

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

core/Cargo.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ log = { version = "0.4.29", default-features = false }
4545
pubgrub = { version = "0.3.0", default-features = false }
4646
# partialzip = { version = "5.0.0", default-features = false, optional = true }
4747
pyo3 = { version = "0.28.2", default-features = false, features = ["macros", "chrono", "indexmap"], optional = true }
48-
reqwest-middleware = { version = "0.5.1" }
48+
reqwest-middleware = { version = "0.5.1", features = ["multipart"] }
4949
semver = { version = "1.0.27", features = ["serde"] }
5050
serde = { version = "1.0.228", features = ["derive"] }
5151
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
6767
bytes = { version = "1.11.1", default-features = false }
6868
toml_edit = { version = "0.25.4", features = ["serde"] }
6969
globset = { version = "0.4.18", default-features = false }
70-
reqwest = { version = "0.13.2", optional = true, features = ["rustls", "stream"] }
70+
reqwest = { version = "0.13.2", optional = true, features = ["rustls", "stream", "multipart"] }
7171
dunce = "1.0.5"
7272

7373
[dev-dependencies]

core/src/commands/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ pub mod include;
1010
pub mod info;
1111
pub mod init;
1212
pub mod lock;
13+
#[cfg(all(feature = "filesystem", feature = "networking"))]
14+
pub mod publish;
1315
pub mod remove;
1416
#[cfg(feature = "filesystem")]
1517
pub mod root;

core/src/commands/publish.rs

Lines changed: 341 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,341 @@
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::{GlobMapResult, HTTPAuthentication, StandardHTTPAuthentication},
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(
102+
"no bearer token credentials configured for publish URL `{0}`; set SYSAND_CRED_<X> and SYSAND_CRED_<X>_BEARER_TOKEN with a matching URL pattern"
103+
)]
104+
MissingCredentials(Box<str>),
105+
106+
#[error("HTTP request failed: {0}")]
107+
Http(#[from] reqwest_middleware::Error),
108+
109+
#[error("server error ({0}): {1}")]
110+
ServerError(u16, String),
111+
112+
#[error("authentication failed: {0}")]
113+
AuthError(String),
114+
115+
#[error("conflict: package version already exists: {0}")]
116+
Conflict(String),
117+
118+
#[error("bad request: {0}")]
119+
BadRequest(String),
120+
}
121+
122+
#[derive(Debug)]
123+
pub struct PublishResponse {
124+
pub status: u16,
125+
pub message: String,
126+
pub is_new_project: bool,
127+
}
128+
129+
fn build_upload_url(index_url: &str) -> Result<Url, PublishError> {
130+
let mut upload_url = Url::parse(index_url).map_err(|e| PublishError::InvalidIndexUrl {
131+
url: index_url.into(),
132+
reason: e.to_string(),
133+
})?;
134+
135+
{
136+
let mut segments =
137+
upload_url
138+
.path_segments_mut()
139+
.map_err(|_| PublishError::InvalidIndexUrl {
140+
url: index_url.into(),
141+
reason: "URL cannot be used as a hierarchical base URL".to_string(),
142+
})?;
143+
segments.pop_if_empty();
144+
segments.extend(["api", "v1", "upload"]);
145+
}
146+
147+
// Keep publish endpoint path stable even if user provided query/fragment in base URL.
148+
upload_url.set_query(None);
149+
upload_url.set_fragment(None);
150+
151+
Ok(upload_url)
152+
}
153+
154+
pub fn do_publish_kpar<P: AsRef<Utf8Path>>(
155+
kpar_path: P,
156+
index_url: &str,
157+
auth_policy: Arc<StandardHTTPAuthentication>,
158+
client: reqwest_middleware::ClientWithMiddleware,
159+
runtime: Arc<tokio::runtime::Runtime>,
160+
) -> Result<PublishResponse, PublishError> {
161+
let kpar_path = kpar_path.as_ref();
162+
let header = crate::style::get_style_config().header;
163+
164+
// Open and validate kpar
165+
let kpar_project = LocalKParProject::new_guess_root(kpar_path)
166+
.map_err(|e| PublishError::KparOpen(kpar_path.as_str().into(), e.to_string()))?;
167+
168+
let (info, meta) = kpar_project
169+
.get_project()
170+
.map_err(|e| PublishError::KparOpen(kpar_path.as_str().into(), e.to_string()))?;
171+
172+
let info = info.ok_or(PublishError::MissingInfo)?;
173+
let _meta = meta.ok_or(PublishError::MissingMeta)?;
174+
175+
let publisher = info
176+
.publisher
177+
.as_deref()
178+
.ok_or(PublishError::MissingPublisher)?;
179+
let name = &info.name;
180+
let version = &info.version;
181+
if !is_canonicalizable_publisher_field_value(publisher) {
182+
return Err(PublishError::NonCanonicalizablePublisher(
183+
publisher.to_owned().into_boxed_str(),
184+
));
185+
}
186+
if !is_canonicalizable_name_field_value(name) {
187+
return Err(PublishError::NonCanonicalizableName(
188+
name.to_owned().into_boxed_str(),
189+
));
190+
}
191+
semver::Version::parse(version).map_err(|source| PublishError::InvalidVersion {
192+
version: version.to_owned().into_boxed_str(),
193+
source,
194+
})?;
195+
let normalized_publisher = canonicalize_modern_project_id_component(publisher);
196+
let normalized_name = canonicalize_modern_project_id_component(name);
197+
let purl = format!("pkg:sysand/{normalized_publisher}/{normalized_name}@{version}");
198+
199+
let publishing = "Publishing";
200+
log::info!("{header}{publishing:>12}{header:#} `{name}` {version} to {index_url}");
201+
202+
let file_name = kpar_path
203+
.file_name()
204+
.unwrap_or(kpar_path.as_str())
205+
.to_string();
206+
207+
// Read kpar file bytes
208+
let file_bytes = std::fs::read(kpar_path)
209+
.map_err(|e| PublishError::KparRead(kpar_path.as_str().into(), e))?;
210+
211+
let upload_url = build_upload_url(index_url)?;
212+
213+
match auth_policy.restricted.lookup(upload_url.as_str()) {
214+
GlobMapResult::NotFound => {
215+
return Err(PublishError::MissingCredentials(upload_url.as_str().into()));
216+
}
217+
GlobMapResult::Found(_, _) | GlobMapResult::Ambiguous(_) => {}
218+
}
219+
220+
// Keep upload payload in `Bytes` so request retries clone cheaply.
221+
let file_bytes = Bytes::from(file_bytes);
222+
223+
let request_builder = move |c: &reqwest_middleware::ClientWithMiddleware| {
224+
let file_part = reqwest::multipart::Part::stream(file_bytes.clone())
225+
.file_name(file_name.clone())
226+
.mime_str("application/octet-stream")
227+
.expect("valid mime type");
228+
229+
let form = reqwest::multipart::Form::new()
230+
.text("purl", purl.clone())
231+
.part("file", file_part);
232+
233+
c.post(upload_url.as_str()).multipart(form)
234+
};
235+
236+
let response = runtime.block_on(async {
237+
auth_policy
238+
.with_authentication(&client, &request_builder)
239+
.await
240+
})?;
241+
242+
let status = response.status().as_u16();
243+
let body = runtime.block_on(response.text()).unwrap_or_default();
244+
245+
match status {
246+
200 => Ok(PublishResponse {
247+
status,
248+
message: body,
249+
is_new_project: false,
250+
}),
251+
201 => Ok(PublishResponse {
252+
status,
253+
message: body,
254+
is_new_project: true,
255+
}),
256+
401 | 403 => Err(PublishError::AuthError(body)),
257+
409 => Err(PublishError::Conflict(body)),
258+
400 | 404 => Err(PublishError::BadRequest(body)),
259+
_ => Err(PublishError::ServerError(status, body)),
260+
}
261+
}
262+
263+
#[cfg(test)]
264+
mod tests {
265+
use super::{
266+
PublishError, build_upload_url, canonicalize_modern_project_id_component,
267+
is_canonicalizable_name_field_value, is_canonicalizable_publisher_field_value,
268+
};
269+
270+
#[test]
271+
fn publisher_field_canonicalizability() {
272+
assert!(is_canonicalizable_publisher_field_value("Acme Labs"));
273+
assert!(is_canonicalizable_publisher_field_value("ACME-LABS-42"));
274+
assert!(!is_canonicalizable_publisher_field_value("ab"));
275+
assert!(!is_canonicalizable_publisher_field_value("Acme Labs"));
276+
assert!(!is_canonicalizable_publisher_field_value("Acme__Labs"));
277+
assert!(!is_canonicalizable_publisher_field_value("Acme."));
278+
}
279+
280+
#[test]
281+
fn name_field_canonicalizability() {
282+
assert!(is_canonicalizable_name_field_value("My.Project Alpha"));
283+
assert!(is_canonicalizable_name_field_value("Alpha-2"));
284+
assert!(!is_canonicalizable_name_field_value("ab"));
285+
assert!(!is_canonicalizable_name_field_value("My..Project"));
286+
assert!(!is_canonicalizable_name_field_value("My__Project"));
287+
assert!(!is_canonicalizable_name_field_value(".Project"));
288+
}
289+
290+
#[test]
291+
fn canonicalize_modern_project_id_component_preserves_dot() {
292+
assert_eq!(
293+
canonicalize_modern_project_id_component("My.Project Alpha"),
294+
"my.project-alpha"
295+
);
296+
assert_eq!(
297+
canonicalize_modern_project_id_component("ACME LABS"),
298+
"acme-labs"
299+
);
300+
}
301+
302+
#[test]
303+
fn build_upload_url_appends_endpoint_path() {
304+
assert_eq!(
305+
build_upload_url("https://example.org").unwrap().as_str(),
306+
"https://example.org/api/v1/upload"
307+
);
308+
assert_eq!(
309+
build_upload_url("https://example.org/").unwrap().as_str(),
310+
"https://example.org/api/v1/upload"
311+
);
312+
assert_eq!(
313+
build_upload_url("https://example.org/index")
314+
.unwrap()
315+
.as_str(),
316+
"https://example.org/index/api/v1/upload"
317+
);
318+
assert_eq!(
319+
build_upload_url("https://example.org/index/")
320+
.unwrap()
321+
.as_str(),
322+
"https://example.org/index/api/v1/upload"
323+
);
324+
}
325+
326+
#[test]
327+
fn build_upload_url_strips_query_and_fragment() {
328+
assert_eq!(
329+
build_upload_url("https://example.org/index?x=1#frag")
330+
.unwrap()
331+
.as_str(),
332+
"https://example.org/index/api/v1/upload"
333+
);
334+
}
335+
336+
#[test]
337+
fn build_upload_url_rejects_non_hierarchical_url() {
338+
let err = build_upload_url("mailto:test@example.org").unwrap_err();
339+
assert!(matches!(err, PublishError::InvalidIndexUrl { .. }));
340+
}
341+
}

0 commit comments

Comments
 (0)