Skip to content

Commit f845b6d

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

4 files changed

Lines changed: 40 additions & 56 deletions

File tree

core/src/commands/publish.rs

Lines changed: 34 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,9 @@ 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";
22+
const NO_ERROR_DETAILS: &str = "no error details provided";
2123

2224
pub fn do_publish<P: AsRef<Utf8Path>>(
2325
path: P,
@@ -30,14 +32,12 @@ pub fn do_publish<P: AsRef<Utf8Path>>(
3032
let header = crate::style::get_style_config().header;
3133
let upload_url = build_upload_url(&index)?;
3234
let PublishPreparation {
33-
name,
34-
version,
35-
file_name,
36-
file_bytes,
35+
purl_versioned,
3736
metadata,
37+
kpar_bytes,
3838
} = prepare_publish_payload(path)?;
3939
log::info!(
40-
"{header}{:>12}{header:#} `{name}` {version} to {index}",
40+
"{header}{:>12}{header:#} `{purl_versioned}` to {index}",
4141
"Publishing",
4242
);
4343

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

4949
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");
5450
let metadata_part = reqwest::multipart::Part::text(metadata.clone())
5551
.mime_str("application/json")
5652
.expect("hard-coded content type must be a valid MIME");
53+
let kpar_part = reqwest::multipart::Part::stream(kpar_bytes.clone())
54+
.mime_str("application/zip")
55+
.expect("hard-coded content type must be a valid MIME");
5756

5857
let form = reqwest::multipart::Form::new()
5958
.part("metadata", metadata_part)
60-
.part("kpar", file_part);
59+
.part("kpar", kpar_part);
6160

6261
c.post(upload_url.clone()).multipart(form)
6362
};
@@ -104,19 +103,14 @@ pub fn build_upload_url(index: &Url) -> Result<Url, PublishError> {
104103

105104
let mut upload_url = index.to_owned();
106105
{
107-
let mut segments = upload_url
108-
.path_segments_mut()
109-
.expect("http(s) URLs are hierarchical and must support mutable path segments");
106+
// Guaranteed for validated http(s) URLs.
107+
let mut segments = upload_url.path_segments_mut().unwrap();
110108
// Normalize both `https://host` and `https://host/`.
111109
segments.pop_if_empty();
112110
}
113111

114112
// 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) {
113+
if upload_url.path().ends_with(UPLOAD_ENDPOINT_PATH) {
120114
return Err(PublishError::InvalidIndexUrl {
121115
url: index.as_str().into(),
122116
reason: "URL must point to the index root; do not include `/api/v1/upload`".to_string(),
@@ -125,7 +119,7 @@ pub fn build_upload_url(index: &Url) -> Result<Url, PublishError> {
125119

126120
{
127121
let mut segments = upload_url.path_segments_mut().unwrap();
128-
for segment in UPLOAD_ENDPOINT_SEGMENTS {
122+
for segment in UPLOAD_ENDPOINT_PATH.trim_start_matches('/').split('/') {
129123
segments.push(segment);
130124
}
131125
}
@@ -219,11 +213,9 @@ pub enum PublishError {
219213
// --- Private helpers ---
220214

221215
struct PublishPreparation {
222-
name: String,
223-
version: String,
224-
file_name: String,
216+
purl_versioned: String,
225217
// Keep upload payload in `Bytes` so request retries clone cheaply.
226-
file_bytes: Bytes,
218+
kpar_bytes: Bytes,
227219
metadata: String,
228220
}
229221

@@ -267,9 +259,7 @@ fn prepare_publish_payload(path: &Utf8Path) -> Result<PublishPreparation, Publis
267259
})?;
268260
let normalized_publisher = normalize_field(publisher);
269261
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();
262+
let purl_versioned = format!("pkg:sysand/{normalized_publisher}/{normalized_name}@{version}");
273263

274264
let file_size = std::fs::metadata(path)
275265
.map_err(|e| PublishError::KparRead(path.as_str().into(), e))?
@@ -281,21 +271,19 @@ fn prepare_publish_payload(path: &Utf8Path) -> Result<PublishPreparation, Publis
281271
});
282272
}
283273

284-
let file_bytes =
274+
let kpar_bytes =
285275
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));
276+
let sha256_digest = format!("{:x}", sha2::Sha256::digest(&kpar_bytes));
287277
let metadata = serde_json::json!({
288-
"purl": purl,
278+
"purl": purl_versioned,
289279
"sha256_digest": sha256_digest,
290280
})
291281
.to_string();
292282

293283
Ok(PublishPreparation {
294-
name: name.clone(),
295-
version: version.clone(),
296-
file_name,
297-
file_bytes: Bytes::from(file_bytes),
284+
purl_versioned,
298285
metadata,
286+
kpar_bytes: Bytes::from(kpar_bytes),
299287
})
300288
}
301289

@@ -388,18 +376,22 @@ fn normalize_field(s: &str) -> String {
388376
s.to_ascii_lowercase().replace(' ', "-")
389377
}
390378

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

384+
fn error_body_to_string(body_bytes: &[u8]) -> String {
396385
let text = String::from_utf8_lossy(body_bytes);
397386
let trimmed = text.trim();
387+
398388
if trimmed.is_empty() {
399-
"empty response body".to_string()
400-
} else {
401-
trimmed.to_string()
389+
return NO_ERROR_DETAILS.to_string();
402390
}
391+
392+
serde_json::from_str::<ErrorResponse>(trimmed)
393+
.map(|error| error.error)
394+
.unwrap_or_else(|_| trimmed.to_string())
403395
}
404396

405397
#[cfg(test)]

core/src/commands/publish_tests.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -119,10 +119,10 @@ 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

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)