Skip to content

Add publish command#249

Open
consideRatio wants to merge 7 commits intosensmetry:mainfrom
consideRatio:publish
Open

Add publish command#249
consideRatio wants to merge 7 commits intosensmetry:mainfrom
consideRatio:publish

Conversation

@consideRatio
Copy link
Copy Markdown
Collaborator

@consideRatio consideRatio commented Apr 1, 2026

Summary

This PR introduces a new sysand publish command to upload .kpar artifacts to a sysand package index.

What’s Included

  • Adds CLI support: sysand publish --index <URL> [PATH]
  • Resolves [PATH] explicitly, or defaults to output/<name>-<version>.kpar for the current project
  • Adds core publish flow that uploads multipart payload to <index>/api/v1/upload:
    • metadata JSON with:
      • purl (pkg:sysand/<publisher>/<name>@<version>)
      • sha256_digest of the .kpar
    • file with .kpar data (application/zip)
  • Validates publish inputs before upload:
    • version must be valid SemVer 2.0
    • license must be valid SPDX expression
    • publisher/name must follow strict modern ID rules
  • Normalizes published ID fields (lowercase, spaces -> hyphens; dots in name are preserved)
  • Adds robust index URL validation:
    • only http/https
    • rejects query/fragment
    • rejects URLs that already include /api/v1/upload
  • Adds publish-specific auth behavior:
    • only bearer-token credentials are used
    • credential matching is performed against the resolved upload URL
    • clear failures for missing/ambiguous bearer credentials
  • Maps HTTP errors to user-friendly CLI errors (401/403/404/409 + generic server errors with summarized body)

Docs

  • Adds docs/src/commands/publish.md
  • Adds sysand publish entry to docs summary navigation

Tests

  • Adds dedicated core publish tests (URL handling, validation, error-body summarization)
  • Adds extensive CLI integration tests for success paths and failure modes
  • New tests added: 34 total (12 core + 22 CLI)

Dependency/Config Updates

  • Enables multipart features for reqwest / reqwest-middleware where needed for upload support

@consideRatio consideRatio marked this pull request as ready for review April 1, 2026 13:51
@andrius-puksta-sensmetry
Copy link
Copy Markdown
Collaborator

andrius-puksta-sensmetry commented Apr 2, 2026

Uploads multipart form (purl + file) to /api/v1/upload

Why not use something like Cargo publish protocol?

  • Reads and validates project metadata from the .kpar:
    • publisher present and canonicalizable
    • name canonicalizable
    • version valid SemVer 2.0

What about license? Should require it to be an SPDX expression. (EDIT: Agree! RESOLVED)

@andrius-puksta-sensmetry
Copy link
Copy Markdown
Collaborator

Uploads multipart form (purl + file) to /api/v1/upload

Why not use something like Cargo publish protocol?

Alaternatively, why not be even simpler: pass the PURL in some header (e.g. X-Sysand-publish-PURL) and the KPAR as the application/zip body?

@consideRatio consideRatio force-pushed the publish branch 4 times, most recently from 6d94e3f to d1f6b99 Compare April 9, 2026 12:20
Signed-off-by: Erik Sundell <erik.sundell@sensmetry.com>

Signed-off-by: Erik Sundell <erik.sundell+2025@sensmetry.com>
@consideRatio
Copy link
Copy Markdown
Collaborator Author

@andrius-puksta-sensmetry I've now updated this PR after rework and more self-review, and I consider it ready to be reviewed again.


Why not use something like Cargo publish protocol?

Alaternatively, why not be even simpler: pass the PURL in some header (e.g. X-Sysand-publish-PURL) and the KPAR as the application/zip body?

We could go for cargo's publish protocol, but there wasn't a clear why / why not for me here so I picked use of multipart form as it felt a bit more straightforward then using crates binary protocol. I think it is good if we can ensure that we can include misc metadata though, so just having a single application/zip POST form with headers seems too rigid. I for example added sha256_digest to the metadata now so that we can verify that the client submitted what was intended to be submitted for example.

Do you want me to switch to Cargos binary protocol to deliver the metadata and file (but with different metadata content)? It also does the trick.

@consideRatio consideRatio marked this pull request as ready for review April 9, 2026 13:35
Signed-off-by: Erik Sundell <erik.sundell+2025@sensmetry.com>
Signed-off-by: Erik Sundell <erik.sundell+2025@sensmetry.com>
Signed-off-by: Erik Sundell <erik.sundell+2025@sensmetry.com>
Signed-off-by: Erik Sundell <erik.sundell+2025@sensmetry.com>
Signed-off-by: Erik Sundell <erik.sundell+2025@sensmetry.com>
Signed-off-by: Erik Sundell <erik.sundell+2025@sensmetry.com>
#[error(
"publisher field `{0}` is invalid for modern project IDs: must be 3-50 characters, use only ASCII letters and numbers, may include single spaces or hyphens between words, and must start and end with a letter or number"
)]
InvalidPublisher(Box<str>),
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
InvalidPublisher(Box<str>),
InvalidPublisher(String),

Is there a reason not to just use String here?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To reduce the error size. Box<str> is 16 bytes, String is 24. Since errors are returned from many places and tend to be somewhat large, they dictate the size of the return types.

Copy link
Copy Markdown
Collaborator

@andrius-puksta-sensmetry andrius-puksta-sensmetry Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(to be fair, here specifically this has no effect on error size)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does it matter to us if the enum type is a few bytes bigger? It seems like a micro optimization to me.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is a micro optimization. Doesn't hurt though.

};

/// Defensive upper bound on kpar file size (100 MiB) to catch unexpected uploads by mistake.
const MAX_KPAR_PUBLISH_SIZE: u64 = 100 * 1024 * 1024;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently Sysand doesn't support jsons in KPARs (I think), but they will be supported. And the sysml model jsons are really big, at least with the current zip compression, and I wouldn't be surprised if even a simple project will be more than 100 MiB.

This should be fine for now though.


let info = info.ok_or(PublishError::MissingInfo)?;
// Validate that metadata exists; contents are not used during upload.
let _meta = meta.ok_or(PublishError::MissingMeta)?;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
let _meta = meta.ok_or(PublishError::MissingMeta)?;
_ = meta.ok_or(PublishError::MissingMeta)?;

);
Err(PublishError::ServerError {
status,
body: summarize_error_body(body_bytes),
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want to return these error details to the user in all cases?
The answer might be obvious, but I'm not familiar enough with with this to know for myself. Often the "500 internal server error" message is deliberately lacking in details to not accidentally expose sensitive information.

/// Must start and end with an alphanumeric character.
///
/// Publish-only; if additional surfaces need this, extract to a shared module.
fn is_valid_field(s: &str, allow_dot: bool) -> bool {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be nice to return a more precise error in case it is invalid. It would be better UX for people to immediately know what exactly sysand doesn't like about the name/publisher

(None, Some(detail)) => detail.to_string(),
(None, None) => String::new(),
};
if !message.is_empty() {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens if message is empty? The body was a valid json, so I feel like something should be returned from here either way, and not fall through to the non-json body handling

fn summarize_error_text(text: &str) -> String {
let trimmed = text.trim();
if trimmed.is_empty() {
return "empty response body".to_string();
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is "empty response body" an error we want to show to the user? Wouldn't it be better to just return a generic description based on the error code (e.g. "Not Found" for 404)?

/// Defensive upper bound on kpar file size (100 MiB) to catch unexpected uploads by mistake.
const MAX_KPAR_PUBLISH_SIZE: u64 = 100 * 1024 * 1024;
/// Maximum number of characters to include when summarizing an error response body.
const MAX_ERROR_BODY_CHARS: usize = 1024;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are there any known cases when the errors would be really long?

@andrius-puksta-sensmetry
Copy link
Copy Markdown
Collaborator

Do you want me to switch to Cargos binary protocol to deliver the metadata and file (but with different metadata content)? It also does the trick.

Your choice, I don't have a strong opinion either way. multipart/form-data imposes a slight performance penalty (for generating the field separator string), but is widely supported (which is IMO much more important).

Either way I think it would be better to have separate fields for publisher, name and version. It's also unclear to me if these are required to match the actual KPAR metadata (IMO they should be the same). If yes, combining them into a PURL can be done server side, as publisher/name/version will have to be checked anyway.

Comment on lines +41 to +44
log::info!(
"{header}{:>12}{header:#} `{name}` {version} to {index}",
"Publishing",
);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe mention publisher as well?


let build_request = move |c: &reqwest_middleware::ClientWithMiddleware| {
let file_part = reqwest::multipart::Part::stream(file_bytes.clone())
.file_name(file_name.clone())
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not use Part::file(<path>)? Also, file_name serves no purpose; name will already identify the part.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see, stream is to clone cheaply.


let form = reqwest::multipart::Form::new()
.part("metadata", metadata_part)
.part("file", file_part);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
.part("file", file_part);
.part("kpar", file_part);

More explicit.

});
}

let mut upload_url = index.clone();
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
let mut upload_url = index.clone();
let mut upload_url = index.to_owned();

{
let mut segments = upload_url
.path_segments_mut()
.expect("http(s) URLs are hierarchical and must support mutable path segments");
Copy link
Copy Markdown
Collaborator

@andrius-puksta-sensmetry andrius-puksta-sensmetry Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need for error messages, simple unwrap + comment is fine, this is guaranteed to not fail for http(s)

.path_segments()
.expect("http(s) URLs are hierarchical and must support path segments")
.collect();
if path_segments.ends_with(&UPLOAD_ENDPOINT_SEGMENTS) {
Copy link
Copy Markdown
Collaborator

@andrius-puksta-sensmetry andrius-puksta-sensmetry Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is too verbose and inefficient. Use .path() and unify UPLOAD_ENDPOINT_SEGMENTS into a single &str.

{
let mut segments = upload_url
.path_segments_mut()
.expect("http(s) URLs are hierarchical and must support mutable path segments");
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
.expect("http(s) URLs are hierarchical and must support mutable path segments");
.unwrap();

Since this is the same op as above, no need to explain.

Comment on lines +196 to +197
#[error("HTTP request failed: {0}")]
Http(#[from] reqwest_middleware::Error),
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
#[error("HTTP request failed: {0}")]
Http(#[from] reqwest_middleware::Error),
#[error("HTTP request failed: {0:#?}")]
Http(#[from] reqwest_middleware::Error),

Due to the way reqwest implements Error, crucial details get lost for reqwest_middleware::Error unless debug print is used.

Comment on lines +199 to +200
#[error("failed to read server response body: {0}")]
ResponseBody(#[source] reqwest::Error),
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here

Suggested change
#[error("failed to read server response body: {0}")]
ResponseBody(#[source] reqwest::Error),
#[error("failed to read server response body: {0:#?}")]
ResponseBody(#[source] reqwest::Error),

NotFound(String),

#[error(
"kpar file is unexpectedly large ({size} bytes, limit is {limit} bytes); verify you are publishing the correct file"
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
"kpar file is unexpectedly large ({size} bytes, limit is {limit} bytes); verify you are publishing the correct file"
"KPAR file is unexpectedly large ({size} bytes, limit is {limit} bytes); verify you are publishing the correct file"

KPAR is an abbreviation

Comment on lines +104 to 107
// url is declared a String, but would ideally be declared an Url. However,
// that would come with challenges as Url provides no Default impl, which
// makes it impossible to use #[derive(Default)] on this struct.
pub url: String,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Semantically doesn't make sense to implement Default for this. It only seems to be useful in a few tests. For convenience can expose function from_url that populates only url field.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants