use std::path::PathBuf;
use anyhow::{Context, Result, bail};
use clap::{Args, Subcommand};
use kanade_shared::kv::OBJECT_APP_PACKAGES;
use tracing::info;
use super::object_http::{self, Bucket};
use super::validate_segment;
const BUCKET: Bucket = Bucket {
route: "api/app-packages",
store: OBJECT_APP_PACKAGES,
empty_msg: "(no app packages)",
limit_note: "the backend refused the upload size; app packages are capped at the bucket's \
256 MB ceiling",
};
#[derive(Args, Debug)]
pub struct AppArgs {
#[command(subcommand)]
pub sub: AppSub,
}
#[derive(Subcommand, Debug)]
pub enum AppSub {
PublishArtifact {
#[arg(long)]
identity: PathBuf,
binary: PathBuf,
},
Publish {
name: String,
binary: PathBuf,
#[arg(long)]
version: Option<String>,
},
List,
Delete { name: String, version: String },
}
pub async fn execute(backend_url: &str, args: AppArgs) -> Result<()> {
let base = backend_url.trim_end_matches('/');
match args.sub {
AppSub::PublishArtifact { identity, binary } => {
publish_artifact(base, identity, binary).await
}
AppSub::Publish {
name,
binary,
version,
} => publish(base, name, binary, version).await,
AppSub::List => {
object_http::list(&crate::http_client::authed_client()?, base, &BUCKET).await
}
AppSub::Delete { name, version } => {
validate_segment("name", &name)?;
validate_segment("version", &version)?;
let client = crate::http_client::authed_client()?;
object_http::delete(&client, base, &BUCKET, &name, &version).await
}
}
}
async fn publish_artifact(base: &str, identity_path: PathBuf, binary: PathBuf) -> Result<()> {
use kanade_shared::artifact::{ArtifactIdentity, ArtifactIntegrity, ArtifactSha256};
use sha2::{Digest, Sha256};
use tokio::io::AsyncReadExt;
let identity: ArtifactIdentity = serde_json::from_slice(&tokio::fs::read(identity_path).await?)
.context("parse explicit artifact identity")?;
let key = identity.app_package_key()?;
let mut file = tokio::fs::File::open(&binary).await?;
let mut hash = Sha256::new();
let mut size = 0u64;
let mut buffer = vec![0; 128 * 1024];
loop {
let n = file.read(&mut buffer).await?;
if n == 0 {
break;
}
size = size
.checked_add(n as u64)
.context("artifact size overflow")?;
hash.update(&buffer[..n]);
}
if size == 0 {
bail!("artifact file is empty");
}
let integrity = ArtifactIntegrity {
sha256: ArtifactSha256::from_bytes(hash.finalize().into()),
size_bytes: size,
};
let metadata = serde_json::json!({ "identity": identity, "integrity": integrity });
let client = crate::http_client::authed_client()?;
let url = reqwest::Url::parse(&format!("{base}/api/app-artifacts"))?;
let response = client
.post(url.clone())
.multipart(
reqwest::multipart::Form::new()
.text("metadata", serde_json::to_string(&metadata)?)
.part("file", object_http::file_part(&binary).await?),
)
.send()
.await?;
if !response.status().is_success() {
bail!(
"artifact publication failed ({}): {}",
response.status(),
response.text().await?
);
}
let accepted: serde_json::Value = response.json().await?;
if accepted != metadata {
bail!("artifact publication metadata mismatch");
}
let mut download = url;
download
.path_segments_mut()
.map_err(|_| anyhow::anyhow!("invalid backend URL"))?
.extend(key.split('/'));
let mut lookup = download.clone();
lookup
.path_segments_mut()
.map_err(|_| anyhow::anyhow!("invalid backend URL"))?
.push("metadata");
let stored: serde_json::Value = client
.get(lookup)
.send()
.await?
.error_for_status()?
.json()
.await?;
if stored != metadata {
bail!("artifact read-back metadata mismatch");
}
super::publish_verify::verify_http_readback(
&client,
&download,
&key,
Some(&integrity.sha256.to_object_store_digest()),
integrity.size_bytes,
)
.await?;
println!("Published integrity-checked artifact {key} (publisher trust not verified)");
Ok(())
}
async fn publish(base: &str, name: String, binary: PathBuf, version: Option<String>) -> Result<()> {
validate_segment("name", &name)?;
let resolved_version = match version {
Some(v) => v,
None => {
let bytes = tokio::fs::read(&binary)
.await
.with_context(|| format!("read {binary:?}"))?;
match kanade_shared::exe_version::extract_pe_version(&bytes) {
Some(v) => v,
None => match super::prompt_version_if_interactive(binary.clone()).await? {
Some(v) => v,
None => bail!(
"no --version given and couldn't extract VERSIONINFO from {binary:?} \
(Windows PE built with `winres`? otherwise pass `--version <label>`)"
),
},
}
}
};
validate_segment("version", &resolved_version)?;
let version = resolved_version;
info!(name, version, "uploading app package");
let client = crate::http_client::authed_client()?;
let published = object_http::upload(&client, base, &BUCKET, &name, &version, &binary).await?;
let key = format!("{name}/{version}");
info!(name, version, size = published.size, digest = ?published.digest, "app package uploaded");
let url = object_http::object_url(base, BUCKET.route, &name, &version)?;
super::publish_verify::verify_http_readback(
&client,
&url,
&key,
published.digest.as_deref(),
published.size,
)
.await
.context("publish read-back verify")?;
object_http::print_published(&BUCKET, &key, &published);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_http::{TEST_TOKEN, export_test_token, fake_backend, fake_backend_bytes, seen};
use base64::Engine;
use sha2::{Digest, Sha256};
fn digest_of(bytes: &[u8]) -> String {
let b64 = base64::engine::general_purpose::URL_SAFE.encode(Sha256::digest(bytes));
format!("SHA-256={b64}")
}
fn payload() -> Vec<u8> {
(0..=255u8).cycle().take(200_000).collect()
}
fn published_json(size: usize, digest: Option<&str>) -> Vec<u8> {
serde_json::json!({"name":"webex","version":"1.0","size":size,"digest":digest})
.to_string()
.into_bytes()
}
fn pubargs(path: &std::path::Path) -> AppArgs {
AppArgs {
sub: AppSub::Publish {
name: "webex".into(),
binary: path.to_path_buf(),
version: Some("1.0".into()),
},
}
}
fn file_with(bytes: &[u8]) -> (tempfile::TempDir, PathBuf) {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("installer.msi");
std::fs::write(&p, bytes).unwrap();
(dir, p)
}
#[test]
fn artifact_publication_future_does_not_embed_the_transfer_buffer() {
let future = publish_artifact("", PathBuf::new(), PathBuf::new());
assert!(
std::mem::size_of_val(&future) < 64 * 1024,
"publication future must fit comfortably within the Windows CLI stack (got {} bytes)",
std::mem::size_of_val(&future),
);
}
#[tokio::test]
async fn artifact_publication_uses_explicit_identity_and_verifies_metadata_and_bytes() {
export_test_token();
let bytes = b"artifact";
let identity: kanade_shared::artifact::ArtifactIdentity = serde_json::from_str(r#"{"role":"cli","version":"1.2.3+build","os":"linux","arch":"aarch64","distro_abi":"musl","format":"tar_gz"}"#).unwrap();
let metadata = serde_json::json!({"identity": identity, "integrity": kanade_shared::artifact::ArtifactIntegrity::of(bytes)});
let (base, log) = fake_backend_bytes(vec![
(200, serde_json::to_vec(&metadata).unwrap()),
(200, serde_json::to_vec(&metadata).unwrap()),
(200, bytes.to_vec()),
])
.await;
let (_dir, binary) = file_with(bytes);
let identity_path = binary.with_extension("json");
std::fs::write(&identity_path, serde_json::to_vec(&identity).unwrap()).unwrap();
publish_artifact(&base, identity_path, binary)
.await
.unwrap();
let requests = seen(&log);
assert_eq!(requests.len(), 3);
assert_eq!(requests[0].target, "/api/app-artifacts");
let body = String::from_utf8_lossy(&requests[0].raw);
assert!(body.find("name=\"metadata\"").unwrap() < body.find("name=\"file\"").unwrap());
assert_eq!(
requests[1].target,
"/api/app-artifacts/cli/1.2.3+build/linux-aarch64-musl.tar_gz/metadata"
);
assert_eq!(
requests[2].target,
"/api/app-artifacts/cli/1.2.3+build/linux-aarch64-musl.tar_gz"
);
}
#[tokio::test]
async fn publish_streams_a_multipart_file_field_and_reads_it_back() {
export_test_token();
let data = payload();
let d = digest_of(&data);
let (base, log) = fake_backend_bytes(vec![
(200, published_json(data.len(), Some(&d))),
(200, data.clone()),
])
.await;
let (_dir, path) = file_with(&data);
execute(&format!("{base}/"), pubargs(&path)).await.unwrap();
let got = seen(&log);
assert_eq!(got.len(), 2);
assert_eq!(got[0].method, "POST");
assert_eq!(got[0].target, "/api/app-packages/webex/1.0");
assert!(
got[0]
.headers
.contains(&format!("authorization: bearer {TEST_TOKEN}")),
"{}",
got[0].headers
);
assert!(got[0].headers.contains("x-kanade-source: cli"));
assert!(got[0].headers.contains("content-type: multipart/form-data"));
assert!(
!got[0].headers.contains("transfer-encoding"),
"length must be declared, not chunked"
);
let head = String::from_utf8_lossy(&got[0].raw[..300.min(got[0].raw.len())]).to_string();
assert!(head.contains(r#"name="file""#), "{head}");
let needle = &data[..];
assert!(
got[0].raw.windows(needle.len()).any(|w| w == needle),
"exact file bytes must be in the multipart body"
);
assert_eq!(got[1].method, "GET");
assert_eq!(got[1].target, "/api/app-packages/webex/1.0");
assert!(got[1].headers.contains("authorization: bearer"));
}
#[tokio::test]
async fn publish_without_a_reported_digest_checks_size_only() {
export_test_token();
let data = payload();
let (base, _log) = fake_backend_bytes(vec![
(200, published_json(data.len(), None)),
(200, data.clone()),
])
.await;
let (_dir, path) = file_with(&data);
execute(&base, pubargs(&path)).await.unwrap();
}
#[tokio::test]
async fn publish_retries_a_stale_readback() {
export_test_token();
let data = payload();
let d = digest_of(&data);
let (base, log) = fake_backend_bytes(vec![
(200, published_json(data.len(), Some(&d))),
(200, data[..10].to_vec()),
(404, b"not yet".to_vec()),
(200, data.clone()),
])
.await;
let (_dir, path) = file_with(&data);
execute(&base, pubargs(&path)).await.unwrap();
assert_eq!(seen(&log).len(), 4);
}
#[tokio::test]
async fn publish_fails_when_the_readback_never_matches() {
export_test_token();
let data = payload();
let d = digest_of(&data);
let mut replies = vec![(200, published_json(data.len(), Some(&d)))];
replies.extend((0..5).map(|_| (200, vec![0u8; data.len()])));
let (base, _log) = fake_backend_bytes(replies).await;
let (_dir, path) = file_with(&data);
let err = format!("{:#}", execute(&base, pubargs(&path)).await.unwrap_err());
assert!(err.contains("read-back"), "{err}");
}
#[tokio::test]
async fn publish_rejections_carry_status_body_and_hints() {
export_test_token();
for (code, body, hint) in [
(413, "too big", "256 MB"),
(403, "operator role required", "KANADE_AUTH_TOKEN"),
(401, "no token", "KANADE_AUTH_TOKEN"),
(400, "'file' field is empty", ""),
(500, "boom", ""),
] {
let (base, _log) = fake_backend(vec![(code, body)]).await;
let (_dir, path) = file_with(b"abc");
let err = format!("{:#}", execute(&base, pubargs(&path)).await.unwrap_err());
assert!(err.contains("publish failed"), "{err}");
assert!(err.contains(&code.to_string()), "{err}");
assert!(err.contains(body), "{err}");
assert!(err.contains(hint), "{err}");
}
}
#[tokio::test]
async fn publish_with_an_unparseable_reply_errors() {
export_test_token();
let (base, _log) = fake_backend(vec![(200, "not json")]).await;
let (_dir, path) = file_with(b"abc");
assert!(execute(&base, pubargs(&path)).await.is_err());
}
#[tokio::test]
async fn publish_validates_name_and_version_before_any_request() {
let (base, log) = fake_backend(vec![]).await;
let (_dir, path) = file_with(b"abc");
for (name, version) in [("bad/name", "1.0"), ("webex", "a/b"), ("webex", "..")] {
let args = AppArgs {
sub: AppSub::Publish {
name: name.into(),
binary: path.clone(),
version: Some(version.into()),
},
};
assert!(execute(&base, args).await.is_err(), "{name} {version}");
}
assert!(seen(&log).is_empty());
}
#[tokio::test]
async fn publish_of_a_missing_file_errors_without_a_request() {
let (base, log) = fake_backend(vec![]).await;
let err = execute(&base, pubargs(std::path::Path::new("/nonexistent/x.msi")))
.await
.unwrap_err();
assert!(format!("{err:#}").contains("open"), "{err:#}");
assert!(seen(&log).is_empty());
}
#[tokio::test]
async fn list_gets_the_collection_with_auth() {
export_test_token();
let (base, log) = fake_backend(vec![(
200,
r#"[{"name":"b","version":"1","size":3,"digest":null,"modified":null}]"#,
)])
.await;
execute(&base, AppArgs { sub: AppSub::List }).await.unwrap();
let got = seen(&log);
assert_eq!(got[0].method, "GET");
assert_eq!(got[0].target, "/api/app-packages");
assert!(got[0].headers.contains("authorization: bearer"));
assert!(got[0].headers.contains("x-kanade-source: cli"));
}
#[tokio::test]
async fn list_errors_report_status_and_body() {
for (code, body) in [(403, "nope"), (500, "boom"), (200, "{")] {
let (base, _log) = fake_backend(vec![(code, body)]).await;
assert!(
execute(&base, AppArgs { sub: AppSub::List }).await.is_err(),
"{code}"
);
}
}
fn delargs() -> AppArgs {
AppArgs {
sub: AppSub::Delete {
name: "webex".into(),
version: "1.0".into(),
},
}
}
#[tokio::test]
async fn delete_sends_delete_and_treats_404_as_a_no_op() {
export_test_token();
for code in [204, 404] {
let (base, log) = fake_backend(vec![(code, "")]).await;
execute(&base, delargs()).await.unwrap();
let got = seen(&log);
assert_eq!(got[0].method, "DELETE");
assert_eq!(got[0].target, "/api/app-packages/webex/1.0");
assert!(got[0].headers.contains("authorization: bearer"));
assert!(got[0].headers.contains("x-kanade-source: cli"));
}
}
#[tokio::test]
async fn delete_rejections_are_errors() {
for (code, body) in [(403, "operator role required"), (500, "boom")] {
let (base, _log) = fake_backend(vec![(code, body)]).await;
let err = format!("{:#}", execute(&base, delargs()).await.unwrap_err());
assert!(err.contains("delete failed"), "{err}");
assert!(err.contains(body), "{err}");
}
}
}