Skip to content

Commit 7c628a4

Browse files
committed
publish: smaller changes based on feedback
Signed-off-by: Erik Sundell <erik.sundell+2025@sensmetry.com>
1 parent 3ae45be commit 7c628a4

4 files changed

Lines changed: 40 additions & 57 deletions

File tree

core/src/commands/publish.rs

Lines changed: 33 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ use std::sync::Arc;
55

66
use bytes::Bytes;
77
use camino::Utf8Path;
8+
use serde::Deserialize;
89
use sha2::Digest;
910
use thiserror::Error;
1011
use url::Url;
@@ -16,8 +17,8 @@ use crate::{
1617

1718
/// Defensive upper bound on kpar file size (100 MiB) to catch unexpected uploads by mistake.
1819
const MAX_KPAR_PUBLISH_SIZE: u64 = 100 * 1024 * 1024;
19-
/// Path segments appended to the index URL to form the upload endpoint.
20-
const UPLOAD_ENDPOINT_SEGMENTS: [&str; 3] = ["api", "v1", "upload"];
20+
/// Path appended to the index URL to form the upload endpoint.
21+
const UPLOAD_ENDPOINT_PATH: &str = "/api/v1/upload";
2122

2223
pub fn do_publish<P: AsRef<Utf8Path>>(
2324
path: P,
@@ -30,14 +31,12 @@ pub fn do_publish<P: AsRef<Utf8Path>>(
3031
let header = crate::style::get_style_config().header;
3132
let upload_url = build_upload_url(&index)?;
3233
let PublishPreparation {
33-
name,
34-
version,
35-
file_name,
36-
file_bytes,
34+
purl_versioned,
3735
metadata,
36+
kpar_bytes,
3837
} = prepare_publish_payload(path)?;
3938
log::info!(
40-
"{header}{:>12}{header:#} `{name}` {version} to {index}",
39+
"{header}{:>12}{header:#} `{purl_versioned}` to {index}",
4140
"Publishing",
4241
);
4342

@@ -47,17 +46,16 @@ pub fn do_publish<P: AsRef<Utf8Path>>(
4746
let upload_url_for_log = upload_url.to_string();
4847

4948
let build_request = move |c: &reqwest_middleware::ClientWithMiddleware| {
50-
let file_part = reqwest::multipart::Part::stream(file_bytes.clone())
51-
.file_name(file_name.clone())
52-
.mime_str("application/zip")
53-
.expect("hard-coded content type must be a valid MIME");
5449
let metadata_part = reqwest::multipart::Part::text(metadata.clone())
5550
.mime_str("application/json")
5651
.expect("hard-coded content type must be a valid MIME");
52+
let kpar_part = reqwest::multipart::Part::stream(kpar_bytes.clone())
53+
.mime_str("application/zip")
54+
.expect("hard-coded content type must be a valid MIME");
5755

5856
let form = reqwest::multipart::Form::new()
5957
.part("metadata", metadata_part)
60-
.part("kpar", file_part);
58+
.part("kpar", kpar_part);
6159

6260
c.post(upload_url.clone()).multipart(form)
6361
};
@@ -104,19 +102,14 @@ pub fn build_upload_url(index: &Url) -> Result<Url, PublishError> {
104102

105103
let mut upload_url = index.to_owned();
106104
{
107-
let mut segments = upload_url
108-
.path_segments_mut()
109-
.expect("http(s) URLs are hierarchical and must support mutable path segments");
105+
// Guaranteed for validated http(s) URLs.
106+
let mut segments = upload_url.path_segments_mut().unwrap();
110107
// Normalize both `https://host` and `https://host/`.
111108
segments.pop_if_empty();
112109
}
113110

114111
// After normalization, reject URLs that already end with the upload path.
115-
let path_segments: Vec<_> = upload_url
116-
.path_segments()
117-
.expect("http(s) URLs are hierarchical and must support path segments")
118-
.collect();
119-
if path_segments.ends_with(&UPLOAD_ENDPOINT_SEGMENTS) {
112+
if upload_url.path().ends_with(UPLOAD_ENDPOINT_PATH) {
120113
return Err(PublishError::InvalidIndexUrl {
121114
url: index.as_str().into(),
122115
reason: "URL must point to the index root; do not include `/api/v1/upload`".to_string(),
@@ -125,7 +118,7 @@ pub fn build_upload_url(index: &Url) -> Result<Url, PublishError> {
125118

126119
{
127120
let mut segments = upload_url.path_segments_mut().unwrap();
128-
for segment in UPLOAD_ENDPOINT_SEGMENTS {
121+
for segment in UPLOAD_ENDPOINT_PATH.trim_start_matches('/').split('/') {
129122
segments.push(segment);
130123
}
131124
}
@@ -219,11 +212,9 @@ pub enum PublishError {
219212
// --- Private helpers ---
220213

221214
struct PublishPreparation {
222-
name: String,
223-
version: String,
224-
file_name: String,
215+
purl_versioned: String,
225216
// Keep upload payload in `Bytes` so request retries clone cheaply.
226-
file_bytes: Bytes,
217+
kpar_bytes: Bytes,
227218
metadata: String,
228219
}
229220

@@ -267,9 +258,7 @@ fn prepare_publish_payload(path: &Utf8Path) -> Result<PublishPreparation, Publis
267258
})?;
268259
let normalized_publisher = normalize_field(publisher);
269260
let normalized_name = normalize_field(name);
270-
let purl = format!("pkg:sysand/{normalized_publisher}/{normalized_name}@{version}");
271-
272-
let file_name = path.file_name().unwrap_or(path.as_str()).to_string();
261+
let purl_versioned = format!("pkg:sysand/{normalized_publisher}/{normalized_name}@{version}");
273262

274263
let file_size = std::fs::metadata(path)
275264
.map_err(|e| PublishError::KparRead(path.as_str().into(), e))?
@@ -281,21 +270,19 @@ fn prepare_publish_payload(path: &Utf8Path) -> Result<PublishPreparation, Publis
281270
});
282271
}
283272

284-
let file_bytes =
273+
let kpar_bytes =
285274
std::fs::read(path).map_err(|e| PublishError::KparRead(path.as_str().into(), e))?;
286-
let sha256_digest = format!("{:x}", sha2::Sha256::digest(&file_bytes));
275+
let sha256_digest = format!("{:x}", sha2::Sha256::digest(&kpar_bytes));
287276
let metadata = serde_json::json!({
288-
"purl": purl,
277+
"purl": purl_versioned,
289278
"sha256_digest": sha256_digest,
290279
})
291280
.to_string();
292281

293282
Ok(PublishPreparation {
294-
name: name.clone(),
295-
version: version.clone(),
296-
file_name,
297-
file_bytes: Bytes::from(file_bytes),
283+
purl_versioned,
298284
metadata,
285+
kpar_bytes: Bytes::from(kpar_bytes),
299286
})
300287
}
301288

@@ -388,18 +375,22 @@ fn normalize_field(s: &str) -> String {
388375
s.to_ascii_lowercase().replace(' ', "-")
389376
}
390377

391-
fn error_body_to_string(body_bytes: &[u8]) -> String {
392-
if body_bytes.is_empty() {
393-
return "empty response body".to_string();
394-
}
378+
#[derive(Deserialize)]
379+
struct ErrorResponse {
380+
error: String,
381+
}
395382

383+
fn error_body_to_string(body_bytes: &[u8]) -> String {
396384
let text = String::from_utf8_lossy(body_bytes);
397385
let trimmed = text.trim();
386+
398387
if trimmed.is_empty() {
399-
"empty response body".to_string()
400-
} else {
401-
trimmed.to_string()
388+
return "no error details provided".to_string();
402389
}
390+
391+
serde_json::from_str::<ErrorResponse>(trimmed)
392+
.map(|error| error.error)
393+
.unwrap_or_else(|_| trimmed.to_string())
403394
}
404395

405396
#[cfg(test)]

core/src/commands/publish_tests.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -119,14 +119,14 @@ fn error_body_to_string_trims_text_content() {
119119
}
120120

121121
#[test]
122-
fn error_body_to_string_preserves_json_as_text() {
122+
fn error_body_to_string_extracts_error_from_json() {
123123
assert_eq!(
124-
error_body_to_string(br#"{"error":"Invalid token","detail":"Token not found or invalid"}"#),
125-
r#"{"error":"Invalid token","detail":"Token not found or invalid"}"#
124+
error_body_to_string(br#"{"error":"Invalid token"}"#),
125+
"Invalid token"
126126
);
127127
}
128128

129129
#[test]
130130
fn error_body_to_string_reports_empty_body() {
131-
assert_eq!(error_body_to_string(b" \n\t "), "empty response body");
131+
assert_eq!(error_body_to_string(b" \n\t "), "no error details provided");
132132
}

core/src/config/mod.rs

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -101,9 +101,6 @@ impl Config {
101101
#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
102102
pub struct Index {
103103
pub name: Option<String>,
104-
// url is declared a String, but would ideally be declared an Url. However,
105-
// that would come with challenges as Url provides no Default impl, which
106-
// makes it impossible to use #[derive(Default)] on this struct.
107104
pub url: String,
108105
// pub explicit: Option<bool>,
109106
pub default: Option<bool>,

sysand/tests/cli_publish.rs

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,6 @@ fn test_publish_with_explicit_index_succeeds() -> TestResult {
221221
Matcher::Regex(r#"Content-Type: application/json"#.to_string()),
222222
Matcher::Regex(r#""sha256_digest":"[0-9a-f]{64}""#.to_string()),
223223
Matcher::Regex(r#"name="kpar""#.to_string()),
224-
Matcher::Regex(r#"filename=".*\.kpar""#.to_string()),
225224
Matcher::Regex(r#"Content-Type: application/zip"#.to_string()),
226225
]))
227226
.with_status(201)
@@ -416,7 +415,6 @@ fn test_publish_canonicalizes_modern_project_id() -> TestResult {
416415
),
417416
Matcher::Regex(r#""sha256_digest":"[0-9a-f]{64}""#.to_string()),
418417
Matcher::Regex(r#"name="kpar""#.to_string()),
419-
Matcher::Regex(r#"filename="artifact\.kpar""#.to_string()),
420418
Matcher::Regex(r#"Content-Type: application/zip"#.to_string()),
421419
]))
422420
.with_status(201)
@@ -592,15 +590,12 @@ fn test_publish_409_maps_to_conflict_error() -> TestResult {
592590
}
593591

594592
#[test]
595-
fn test_publish_500_json_error_body_is_rendered_as_text() -> TestResult {
593+
fn test_publish_500_json_error_body_extracts_error_message() -> TestResult {
596594
assert_publish_error_status(
597595
"publish-server-error",
598596
500,
599-
r#"{"error":"Invalid token","detail":"Token not found or invalid"}"#,
597+
r#"{"error":"Invalid token"}"#,
600598
Some("application/json"),
601-
&[
602-
"server error (500)",
603-
r#"{"error":"Invalid token","detail":"Token not found or invalid"}"#,
604-
],
599+
&["server error (500)", "Invalid token"],
605600
)
606601
}

0 commit comments

Comments
 (0)