Skip to content

Commit d56e015

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

13 files changed

Lines changed: 957 additions & 3 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: 329 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,329 @@
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+
}

core/src/project/gix_git_download.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,7 @@ mod tests {
161161
//use predicates::prelude::*;
162162

163163
/// Initializes a git repository at `path` with a pre-configured test user.
164+
#[cfg(feature = "alltests")]
164165
fn git_init(path: &std::path::Path) -> Result<(), Box<dyn std::error::Error>> {
165166
Command::new("git")
166167
.arg("init")

docs/src/SUMMARY.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
- [sysand include](commands/include.md)
1515
- [sysand exclude](commands/exclude.md)
1616
- [sysand build](commands/build.md)
17+
- [sysand publish](commands/publish.md)
1718
- [sysand lock](commands/lock.md)
1819
- [sysand env](commands/env.md)
1920
- [sysand env install](commands/env/install.md)

0 commit comments

Comments
 (0)