use std::{
path::Path,
thread,
time::{Duration, Instant},
};
use serde::{Deserialize, Serialize};
use ureq::{Agent, Body, http::Response};
use zeroize::Zeroizing;
use crate::{args::PublicationVisibility, auth, manifest, output, python, source_package};
const CREATOR_CONSENTS_URL: &str = "https://developers.shimpz.com/api/v1/publication-consents";
const PUBLICATIONS_URL: &str = "https://developers.shimpz.com/api/v1/publications";
const SOURCE_MEDIA_TYPE: &str = "application/vnd.shimpz.source.v1+tar";
const REQUIRED_SCOPE: &str = "assistant:publish";
const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
const POLL_INTERVAL: Duration = Duration::from_secs(1);
const WAIT_TIMEOUT: Duration = Duration::from_mins(30);
const MAX_RESPONSE_BYTES: u64 = 32 * 1024;
pub(crate) fn run(project: &Path, visibility: PublicationVisibility) -> Result<String, String> {
let package = source_package::build(project)?;
python::Assistant::open(project)?.contract()?;
let identity = manifest::PublicationIdentity::parse(&package.manifest)?;
let credentials = auth::ensure_authenticated(REQUIRED_SCOPE)?;
let api = Api::new();
output::info("Recording exact Creator consent.");
output::detail(
"Assistant",
&format!("{} {}", identity.id, identity.version),
);
output::detail("Source", &package.digest);
output::detail("Creators", &identity.creators.join(", "));
api.consent(&credentials, &identity, &package.digest)?;
let publication = api.create(&credentials, &package, visibility)?;
output::info("Publication accepted.");
output::detail("Source", &package.digest);
wait_until_installable(&api, &credentials, &package.digest, visibility, publication)
}
fn wait_until_installable(
api: &Api,
credentials: &crate::credentials::Credentials,
expected_digest: &str,
expected_visibility: PublicationVisibility,
mut publication: Publication,
) -> Result<String, String> {
let deadline = Instant::now()
.checked_add(WAIT_TIMEOUT)
.ok_or_else(unavailable)?;
let mut observed_state = None;
loop {
publication.validate(expected_digest, expected_visibility.as_str())?;
if observed_state.as_deref() != Some(publication.build_state.as_str()) {
output::progress(&publication.progress_message());
observed_state = Some(publication.build_state.clone());
}
match publication.terminal_result() {
Some(result) => return result,
None if Instant::now() < deadline => thread::sleep(POLL_INTERVAL),
None => return Err("publication wait timed out; run `shimpz publish` again".into()),
}
publication = api.status(credentials, expected_digest, expected_visibility)?;
}
}
struct Api {
agent: Agent,
}
impl Api {
fn new() -> Self {
let config = Agent::config_builder()
.timeout_global(Some(REQUEST_TIMEOUT))
.max_redirects(0)
.http_status_as_error(false)
.build();
Self {
agent: config.into(),
}
}
fn consent(
&self,
credentials: &crate::credentials::Credentials,
identity: &manifest::PublicationIdentity,
source_digest: &str,
) -> Result<(), String> {
let authorization = Zeroizing::new(format!("Bearer {}", credentials.access_token()));
let request = CreatorConsentRequest {
assistant_id: &identity.id,
version: &identity.version,
source_digest,
};
let mut response = self
.agent
.post(CREATOR_CONSENTS_URL)
.header("Accept", "application/json")
.header("Authorization", authorization.as_str())
.send_json(&request)
.map_err(|_| unavailable())?;
match response.status().as_u16() {
200 | 201 => {
let consent = read_consent(&mut response)?;
consent.validate(identity, source_digest)
}
_ => Err(status_error(&mut response, "Creator consent was rejected")),
}
}
fn create(
&self,
credentials: &crate::credentials::Credentials,
package: &source_package::SourcePackage,
visibility: PublicationVisibility,
) -> Result<Publication, String> {
let authorization = Zeroizing::new(format!("Bearer {}", credentials.access_token()));
let mut response = self
.agent
.post(PUBLICATIONS_URL)
.header("Accept", "application/json")
.header("Authorization", authorization.as_str())
.header("Content-Type", SOURCE_MEDIA_TYPE)
.header("Content-Length", package.bytes.len().to_string())
.header("X-Shimpz-Source-Digest", &package.digest)
.header("X-Shimpz-Visibility", visibility.as_str())
.send(&package.bytes)
.map_err(|_| unavailable())?;
match response.status().as_u16() {
200 | 201 => read_publication(&mut response, &package.digest, visibility.as_str()),
_ => Err(status_error(&mut response, "publication was rejected")),
}
}
fn status(
&self,
credentials: &crate::credentials::Credentials,
source_digest: &str,
visibility: PublicationVisibility,
) -> Result<Publication, String> {
let authorization = Zeroizing::new(format!("Bearer {}", credentials.access_token()));
let url = format!("{PUBLICATIONS_URL}/{source_digest}");
let mut response = self
.agent
.get(&url)
.header("Accept", "application/json")
.header("Authorization", authorization.as_str())
.call()
.map_err(|_| unavailable())?;
match response.status().as_u16() {
200 => read_publication(&mut response, source_digest, visibility.as_str()),
_ => Err(status_error(
&mut response,
"publication status is unavailable",
)),
}
}
}
#[derive(Serialize)]
struct CreatorConsentRequest<'a> {
assistant_id: &'a str,
version: &'a str,
source_digest: &'a str,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct CreatorConsent {
assistant_id: String,
version: String,
source_digest: String,
creator_handle: String,
outcome: String,
consented_at: u64,
}
impl CreatorConsent {
fn validate(
&self,
identity: &manifest::PublicationIdentity,
expected_digest: &str,
) -> Result<(), String> {
if self.assistant_id != identity.id
|| self.version != identity.version
|| self.source_digest != expected_digest
|| !manifest::valid_creator(&self.creator_handle)
|| !identity.creators.contains(&self.creator_handle)
|| self.outcome != "consented"
{
return Err("Developers returned an invalid Creator consent response".into());
}
let _ = self.consented_at;
Ok(())
}
}
fn read_consent(response: &mut Response<Body>) -> Result<CreatorConsent, String> {
if response
.headers()
.get("Content-Type")
.and_then(|value| value.to_str().ok())
!= Some("application/json")
{
return Err("Developers returned an invalid Creator consent response".into());
}
response
.body_mut()
.with_config()
.limit(MAX_RESPONSE_BYTES)
.read_json()
.map_err(|_| "Developers returned an invalid Creator consent response".to_owned())
}
fn read_publication(
response: &mut Response<Body>,
expected_digest: &str,
expected_visibility: &str,
) -> Result<Publication, String> {
if response
.headers()
.get("Content-Type")
.and_then(|value| value.to_str().ok())
!= Some("application/json")
{
return Err("Developers returned an invalid publication response".into());
}
let publication: Publication = response
.body_mut()
.with_config()
.limit(MAX_RESPONSE_BYTES)
.read_json()
.map_err(|_| "Developers returned an invalid publication response".to_owned())?;
publication.validate(expected_digest, expected_visibility)?;
Ok(publication)
}
fn status_error(response: &mut Response<Body>, fallback: &'static str) -> String {
let retry_after = match validate_retry_after(
response.status().as_u16(),
response
.headers()
.get("Retry-After")
.and_then(|value| value.to_str().ok()),
) {
Ok(value) => value,
Err(message) => return message,
};
if response
.headers()
.get("Content-Type")
.and_then(|value| value.to_str().ok())
!= Some("application/json")
{
return fallback.into();
}
let message = response
.body_mut()
.with_config()
.limit(MAX_RESPONSE_BYTES)
.read_json::<ErrorEnvelope>()
.ok()
.filter(|envelope| envelope.error.valid())
.map_or_else(|| fallback.into(), |envelope| envelope.error.message);
retry_after.map_or(message.clone(), |seconds| {
format!("{message}; retry after {seconds} seconds")
})
}
fn validate_retry_after(status: u16, value: Option<&str>) -> Result<Option<u64>, String> {
if status != 429 {
return Ok(None);
}
value
.and_then(|value| value.parse::<u64>().ok())
.filter(|seconds| (1..=3600).contains(seconds))
.map(Some)
.ok_or_else(|| "Developers returned an invalid rate-limit response".into())
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct Publication {
assistant_id: String,
version: String,
source_digest: String,
build_state: String,
visibility: String,
review_state: String,
security_state: String,
blocked: bool,
safe_error_code: Option<String>,
safe_error_details: Option<Vec<AssistantTestFailure>>,
image_reference: Option<String>,
oci_digest: Option<String>,
manifest_digest: Option<String>,
machine_contract_digest: Option<String>,
signature_identity: Option<String>,
signature_reference: Option<String>,
provenance_reference: Option<String>,
sbom_digest: Option<String>,
scan_digest: Option<String>,
workflow_run_id: Option<u64>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct AssistantTestFailure {
test: String,
reason: String,
}
impl AssistantTestFailure {
fn valid(&self) -> bool {
valid_printable_ascii(&self.test, 256) && valid_printable_ascii(&self.reason, 512)
}
}
impl Publication {
fn validate(&self, expected_digest: &str, expected_visibility: &str) -> Result<(), String> {
if !manifest::valid_id(&self.assistant_id)
|| !manifest::valid_version(&self.version)
|| self.source_digest != expected_digest
|| self.visibility != expected_visibility
|| !matches!(
self.build_state.as_str(),
"queued"
| "resolving"
| "building"
| "scanning"
| "signing"
| "artifact_ready"
| "build_failed"
)
|| !matches!(
self.review_state.as_str(),
"pending" | "approved" | "catalog_rejected"
)
|| !matches!(self.security_state.as_str(), "clear" | "security_rejected")
|| (self.security_state == "security_rejected" && !self.blocked)
|| !self.valid_build_result()
{
return Err("Developers returned an invalid publication response".into());
}
Ok(())
}
fn valid_build_result(&self) -> bool {
let artifacts = [
self.image_reference.as_deref(),
self.oci_digest.as_deref(),
self.manifest_digest.as_deref(),
self.machine_contract_digest.as_deref(),
self.signature_identity.as_deref(),
self.signature_reference.as_deref(),
self.provenance_reference.as_deref(),
self.sbom_digest.as_deref(),
self.scan_digest.as_deref(),
];
match self.build_state.as_str() {
"artifact_ready" => {
self.safe_error_code.is_none()
&& self.safe_error_details.is_none()
&& artifacts.iter().all(Option::is_some)
&& self.workflow_run_id.is_some()
&& self.valid_ready_artifact()
}
"build_failed" => {
self.safe_error_code
.as_deref()
.is_some_and(valid_build_error)
&& self.valid_error_details()
&& artifacts.iter().all(Option::is_none)
&& self.valid_optional_workflow_run()
}
_ => {
self.safe_error_code.is_none()
&& self.safe_error_details.is_none()
&& artifacts.iter().all(Option::is_none)
&& self.valid_active_workflow_run()
}
}
}
fn valid_error_details(&self) -> bool {
match self.safe_error_code.as_deref() {
Some("assistant_tests_failed") => {
self.safe_error_details.as_deref().is_some_and(|details| {
(1..=20).contains(&details.len())
&& details.iter().all(AssistantTestFailure::valid)
})
}
Some(_) => self.safe_error_details.is_none(),
None => false,
}
}
fn valid_optional_workflow_run(&self) -> bool {
self.workflow_run_id.is_none_or(|value| value > 0)
}
fn valid_active_workflow_run(&self) -> bool {
match self.build_state.as_str() {
"queued" => self.workflow_run_id.is_none(),
"resolving" => self.valid_optional_workflow_run(),
"building" | "scanning" | "signing" => {
self.workflow_run_id.is_some_and(|value| value > 0)
}
_ => false,
}
}
fn valid_ready_artifact(&self) -> bool {
let Some(oci_digest) = self.oci_digest.as_deref() else {
return false;
};
if self.workflow_run_id.is_none_or(|value| value == 0) {
return false;
}
let expected_image = format!("ghcr.io/theshimpz/shimpz-assistant@{oci_digest}");
valid_digest(oci_digest)
&& self.image_reference.as_deref() == Some(expected_image.as_str())
&& self.manifest_digest.as_deref().is_some_and(valid_digest)
&& self
.machine_contract_digest
.as_deref()
.is_some_and(valid_digest)
&& self.signature_identity.as_deref()
== Some(
"https://github.com/TheShimpz/shimpz-developers/.github/workflows/build-assistant.yml@refs/heads/main",
)
&& self
.signature_reference
.as_deref()
.is_some_and(valid_trust_reference)
&& self
.provenance_reference
.as_deref()
.is_some_and(valid_trust_reference)
&& self.sbom_digest.as_deref().is_some_and(valid_digest)
&& self.scan_digest.as_deref().is_some_and(valid_digest)
}
fn terminal_result(&self) -> Option<Result<String, String>> {
if self.security_state == "security_rejected" {
return Some(Err("publication was rejected by security review".into()));
}
if self.blocked {
return Some(Err("publication is blocked".into()));
}
if self.build_state == "build_failed" {
let error = match self.safe_error_code.as_deref() {
Some("dependency_resolution_failed") => "publication dependency resolution failed",
Some("security_scan_failed") => "publication security scan failed",
Some("signing_failed") => "publication signing failed",
Some("non_reproducible_build") => "publication build was not reproducible",
Some("assistant_tests_failed") => "publication Assistant tests failed",
_ => "publication build failed",
};
let run = self
.workflow_url()
.map_or_else(String::new, |url| format!("\nBuild run: {url}"));
return Some(Err(format!("{error}{run}")));
}
(self.build_state == "artifact_ready").then(|| {
let image = self
.image_reference
.as_deref()
.expect("validated ready publication has an image");
let oci_digest = self
.oci_digest
.as_deref()
.expect("validated ready publication has an OCI digest");
let provenance = self
.provenance_reference
.as_deref()
.expect("validated ready publication has provenance");
let signature = self
.signature_reference
.as_deref()
.expect("validated ready publication has a signature");
let workflow = self
.workflow_url()
.expect("validated ready publication has a workflow run");
Ok(format!(
"Assistant published and installable.\nAssistant: {} {}\nSource: {}\nImage: {}\nOCI digest: {}\nReview: {}\nBuild run: {}\nSignature: {}\nProvenance: {}\nPortal: https://developers.shimpz.com/assistants/{}",
self.assistant_id,
self.version,
self.source_digest,
image,
oci_digest,
self.review_state,
workflow,
signature,
provenance,
self.assistant_id
))
})
}
fn progress_message(&self) -> String {
let message = match self.build_state.as_str() {
"queued" => "Queued — starting automatically.",
"resolving" => "Resolving and locking dependencies.",
"building" => "Building and publishing the immutable amd64/arm64 image.",
"scanning" => "Scanning the image and generating its SBOM.",
"signing" => "Signing the image and provenance.",
"artifact_ready" => "Signed artifact ready.",
"build_failed" => "Build failed.",
_ => unreachable!("validated publication has a closed build state"),
};
self.workflow_url().map_or_else(
|| format!("[{}] {message}", self.build_state),
|url| format!("[{}] {message}\nRun: {url}", self.build_state),
)
}
fn workflow_url(&self) -> Option<String> {
self.workflow_run_id.map(|run_id| {
format!("https://github.com/TheShimpz/shimpz-developers/actions/runs/{run_id}")
})
}
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct ErrorEnvelope {
error: ApiError,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct ApiError {
code: String,
message: String,
request_id: String,
}
impl ApiError {
fn valid(&self) -> bool {
valid_error_code(&self.code)
&& !self.message.is_empty()
&& self.message.len() <= 200
&& self
.message
.bytes()
.all(|byte| byte.is_ascii() && !byte.is_ascii_control())
&& self.request_id.len() == 32
&& self
.request_id
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
}
}
fn valid_error_code(value: &str) -> bool {
!value.is_empty()
&& value.len() <= 64
&& value
.bytes()
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_')
}
fn valid_build_error(value: &str) -> bool {
matches!(
value,
"dependency_resolution_failed"
| "build_failed"
| "security_scan_failed"
| "signing_failed"
| "non_reproducible_build"
| "assistant_tests_failed"
)
}
fn valid_printable_ascii(value: &str, max_length: usize) -> bool {
!value.is_empty()
&& value.len() <= max_length
&& value.bytes().all(|byte| (b' '..=b'~').contains(&byte))
}
fn valid_digest(value: &str) -> bool {
value.len() == 71
&& value.starts_with("sha256:")
&& value[7..]
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
}
fn valid_trust_reference(value: &str) -> bool {
value
.strip_prefix("ghcr.io/theshimpz/shimpz-assistant-trust@")
.is_some_and(valid_digest)
}
fn unavailable() -> String {
"Developers is unavailable; try again shortly".into()
}
#[cfg(test)]
mod tests {
use super::{AssistantTestFailure, CreatorConsent, Publication, validate_retry_after};
use crate::manifest::PublicationIdentity;
const DIGEST: &str = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
fn publication(build_state: &str) -> Publication {
Publication {
assistant_id: "hello-world".into(),
version: "0.1.0".into(),
source_digest: DIGEST.into(),
build_state: build_state.into(),
visibility: "public".into(),
review_state: "pending".into(),
security_state: "clear".into(),
blocked: false,
safe_error_code: None,
safe_error_details: None,
image_reference: None,
oci_digest: None,
manifest_digest: None,
machine_contract_digest: None,
signature_identity: None,
signature_reference: None,
provenance_reference: None,
sbom_digest: None,
scan_digest: None,
workflow_run_id: None,
}
}
fn identity() -> PublicationIdentity {
PublicationIdentity {
id: "hello-world".into(),
version: "0.1.0".into(),
creators: vec!["@creator-one".into()],
}
}
fn consent() -> CreatorConsent {
CreatorConsent {
assistant_id: "hello-world".into(),
version: "0.1.0".into(),
source_digest: DIGEST.into(),
creator_handle: "@creator-one".into(),
outcome: "consented".into(),
consented_at: 42,
}
}
fn ready_publication() -> Publication {
let mut ready = publication("artifact_ready");
let digest = "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
ready.image_reference = Some(format!("ghcr.io/theshimpz/shimpz-assistant@{digest}"));
ready.oci_digest = Some(digest.into());
ready.manifest_digest = Some(digest.into());
ready.machine_contract_digest = Some(digest.into());
ready.signature_identity = Some(
"https://github.com/TheShimpz/shimpz-developers/.github/workflows/build-assistant.yml@refs/heads/main".into(),
);
ready.signature_reference =
Some(format!("ghcr.io/theshimpz/shimpz-assistant-trust@{digest}"));
ready.provenance_reference =
Some(format!("ghcr.io/theshimpz/shimpz-assistant-trust@{digest}"));
ready.sbom_digest = Some(digest.into());
ready.scan_digest = Some(digest.into());
ready.workflow_run_id = Some(42);
ready
}
#[test]
fn accepts_only_closed_publication_states() {
for state in ["queued", "resolving"] {
assert!(publication(state).validate(DIGEST, "public").is_ok());
}
for state in ["building", "scanning", "signing"] {
let mut active = publication(state);
active.workflow_run_id = Some(42);
assert!(active.validate(DIGEST, "public").is_ok());
}
assert!(ready_publication().validate(DIGEST, "public").is_ok());
let mut failed = publication("build_failed");
failed.safe_error_code = Some("build_failed".into());
assert!(failed.validate(DIGEST, "public").is_ok());
let mut invalid = publication("ready");
assert!(invalid.validate(DIGEST, "public").is_err());
invalid = publication("queued");
invalid.source_digest = DIGEST.replace('a', "b");
assert!(invalid.validate(DIGEST, "public").is_err());
invalid = publication("queued");
invalid.visibility = "private".into();
assert!(invalid.validate(DIGEST, "public").is_err());
invalid = ready_publication();
invalid.image_reference = Some(
"ghcr.io/theshimpz/shimpz-assistants@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".into(),
);
assert!(invalid.validate(DIGEST, "public").is_err());
invalid = ready_publication();
invalid.signature_reference =
Some("ghcr.io/theshimpz/shimpz-assistants@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".into());
assert!(invalid.validate(DIGEST, "public").is_err());
invalid = ready_publication();
invalid.workflow_run_id = Some(0);
assert!(invalid.validate(DIGEST, "public").is_err());
let mut test_failure = publication("build_failed");
test_failure.safe_error_code = Some("assistant_tests_failed".into());
test_failure.safe_error_details = Some(vec![AssistantTestFailure {
test: "tests/test_zones.py::test_lists_zones".into(),
reason: "assertion failed".into(),
}]);
assert!(test_failure.validate(DIGEST, "public").is_ok());
test_failure.safe_error_details = None;
assert!(test_failure.validate(DIGEST, "public").is_err());
}
#[test]
fn only_installability_or_safe_failure_ends_the_wait() {
assert!(publication("queued").terminal_result().is_none());
assert!(ready_publication().terminal_result().unwrap().is_ok());
let mut failed = publication("build_failed");
failed.safe_error_code = Some("security_scan_failed".into());
assert!(failed.terminal_result().unwrap().is_err());
let mut blocked = ready_publication();
blocked.blocked = true;
assert!(blocked.terminal_result().unwrap().is_err());
let mut rejected = ready_publication();
rejected.security_state = "security_rejected".into();
rejected.blocked = true;
assert!(rejected.terminal_result().unwrap().is_err());
}
#[test]
fn renders_each_real_stage_and_the_exact_workflow() {
assert_eq!(
publication("queued").progress_message(),
"[queued] Queued — starting automatically."
);
let mut building = publication("building");
building.workflow_run_id = Some(42);
assert_eq!(
building.progress_message(),
"[building] Building and publishing the immutable amd64/arm64 image.\nRun: https://github.com/TheShimpz/shimpz-developers/actions/runs/42"
);
assert!(building.validate(DIGEST, "public").is_ok());
let mut failed = publication("build_failed");
failed.safe_error_code = Some("build_failed".into());
failed.workflow_run_id = Some(42);
assert!(failed.validate(DIGEST, "public").is_ok());
assert!(
failed
.terminal_result()
.unwrap()
.unwrap_err()
.contains("/actions/runs/42")
);
}
#[test]
fn accepts_only_the_exact_attributed_creator_consent() {
assert!(consent().validate(&identity(), DIGEST).is_ok());
let mut invalid = consent();
invalid.source_digest = DIGEST.replace('a', "b");
assert!(invalid.validate(&identity(), DIGEST).is_err());
invalid = consent();
invalid.creator_handle = "@another-creator".into();
assert!(invalid.validate(&identity(), DIGEST).is_err());
invalid = consent();
invalid.outcome = "pending".into();
assert!(invalid.validate(&identity(), DIGEST).is_err());
}
#[test]
fn accepts_only_bounded_delta_seconds_for_rate_limits() {
assert_eq!(validate_retry_after(429, Some("60")), Ok(Some(60)));
assert_eq!(validate_retry_after(503, None), Ok(None));
for value in [None, Some("0"), Some("3601"), Some("tomorrow")] {
assert!(validate_retry_after(429, value).is_err());
}
}
}