use std::path::PathBuf;
use anyhow::{Context, Result, bail};
use clap::{Args, Subcommand};
use kanade_shared::kv::OBJECT_SCRIPTS;
use tracing::info;
use super::object_http::{self, Bucket};
use super::validate_segment;
const MAX_SCRIPT_BYTES: u64 = 4 * 1024 * 1024;
const BUCKET: Bucket = Bucket {
route: "api/script-objects",
store: OBJECT_SCRIPTS,
empty_msg: "(no script objects)",
limit_note: "script bodies are limited to 4 MB for the whole upload request \
(multipart framing included)",
};
#[derive(Args, Debug)]
pub struct ScriptArgs {
#[command(subcommand)]
pub sub: ScriptSub,
}
#[derive(Subcommand, Debug)]
pub enum ScriptSub {
Publish {
name: String,
version: String,
file: PathBuf,
},
List,
Delete { name: String, version: String },
}
pub async fn execute(backend_url: &str, args: ScriptArgs) -> Result<()> {
let base = backend_url.trim_end_matches('/');
match args.sub {
ScriptSub::Publish {
name,
version,
file,
} => publish(base, name, version, file).await,
ScriptSub::List => {
object_http::list(&crate::http_client::authed_client()?, base, &BUCKET).await
}
ScriptSub::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(base: &str, name: String, version: String, file: PathBuf) -> Result<()> {
validate_segment("name", &name)?;
validate_segment("version", &version)?;
let len = tokio::fs::metadata(&file)
.await
.with_context(|| format!("stat {file:?}"))?
.len();
if len > MAX_SCRIPT_BYTES {
bail!(
"{file:?} is {len} bytes, over the {MAX_SCRIPT_BYTES}-byte (4 MB) script object limit"
);
}
info!(name, version, "uploading script object");
let client = crate::http_client::authed_client()?;
let published = object_http::upload(&client, base, &BUCKET, &name, &version, &file).await?;
let key = format!("{name}/{version}");
info!(name, version, size = published.size, digest = ?published.digest, "script object uploaded");
object_http::print_published(&BUCKET, &key, &published);
println!();
println!("Reference from a manifest with:");
println!(" execute:");
println!(" shell: powershell");
println!(" script_object: {key}");
println!(" timeout: 600s");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_http::{TEST_TOKEN, export_test_token, fake_backend, seen};
fn pubargs(path: &std::path::Path) -> ScriptArgs {
ScriptArgs {
sub: ScriptSub::Publish {
name: "inv".into(),
version: "2".into(),
file: path.to_path_buf(),
},
}
}
fn file_with(bytes: &[u8]) -> (tempfile::TempDir, PathBuf) {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("run.ps1");
std::fs::write(&p, bytes).unwrap();
(dir, p)
}
#[tokio::test]
async fn publish_posts_the_file_as_a_multipart_field() {
export_test_token();
let body = b"Write-Host '\xe3\x81\x82'\r\n\xff\xfe";
let (base, log) = fake_backend(vec![(
200,
r#"{"name":"inv","version":"2","size":20,"digest":"SHA-256=x"}"#,
)])
.await;
let (_dir, path) = file_with(body);
execute(&format!("{base}/"), pubargs(&path)).await.unwrap();
let got = seen(&log);
assert_eq!(got.len(), 1);
assert_eq!(got[0].method, "POST");
assert_eq!(got[0].target, "/api/script-objects/inv/2");
assert!(
got[0]
.headers
.contains(&format!("authorization: bearer {TEST_TOKEN}"))
);
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"));
assert!(
String::from_utf8_lossy(&got[0].raw).contains(r#"name="file""#),
"{}",
got[0].body
);
assert!(got[0].raw.windows(body.len()).any(|w| w == body));
}
#[tokio::test]
async fn publish_rejections_carry_status_body_and_hints() {
export_test_token();
for (code, body, hint) in [
(413, "too large", "4 MB"),
(403, "operator role required", "KANADE_AUTH_TOKEN"),
(500, "boom", ""),
] {
let (base, _log) = fake_backend(vec![(code, body)]).await;
let (_dir, path) = file_with(b"x");
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() {
let (base, _log) = fake_backend(vec![(200, "<html>")]).await;
let (_dir, path) = file_with(b"x");
assert!(execute(&base, pubargs(&path)).await.is_err());
}
#[tokio::test]
async fn a_file_over_the_limit_is_refused_locally() {
let (base, log) = fake_backend(vec![]).await;
let (_dir, path) = file_with(b"");
std::fs::File::options()
.write(true)
.open(&path)
.unwrap()
.set_len(MAX_SCRIPT_BYTES + 1)
.unwrap();
let err = execute(&base, pubargs(&path))
.await
.unwrap_err()
.to_string();
assert!(err.contains("4 MB"), "{err}");
assert!(seen(&log).is_empty());
}
#[tokio::test]
async fn a_file_at_the_limit_is_left_to_the_backend() {
let (base, log) = fake_backend(vec![(413, "limit")]).await;
let (_dir, path) = file_with(b"");
std::fs::File::options()
.write(true)
.open(&path)
.unwrap()
.set_len(MAX_SCRIPT_BYTES)
.unwrap();
let err = format!("{:#}", execute(&base, pubargs(&path)).await.unwrap_err());
assert!(err.contains("413"), "{err}");
assert_eq!(seen(&log).len(), 1);
}
#[tokio::test]
async fn invalid_segments_are_rejected_before_any_request() {
let (base, log) = fake_backend(vec![]).await;
let (_dir, path) = file_with(b"x");
for (name, version) in [("a/b", "1"), ("a", ""), ("..", "1")] {
let args = ScriptArgs {
sub: ScriptSub::Publish {
name: name.into(),
version: version.into(),
file: path.clone(),
},
};
assert!(execute(&base, args).await.is_err(), "{name} {version}");
}
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, "[]")]).await;
execute(
&base,
ScriptArgs {
sub: ScriptSub::List,
},
)
.await
.unwrap();
let got = seen(&log);
assert_eq!(got[0].method, "GET");
assert_eq!(got[0].target, "/api/script-objects");
assert!(got[0].headers.contains("authorization: bearer"));
assert!(got[0].headers.contains("x-kanade-source: cli"));
}
#[tokio::test]
async fn list_errors_are_reported() {
for (code, body) in [(403, "nope"), (500, "boom"), (200, "{")] {
let (base, _log) = fake_backend(vec![(code, body)]).await;
assert!(
execute(
&base,
ScriptArgs {
sub: ScriptSub::List
}
)
.await
.is_err(),
"{code}"
);
}
}
fn delargs() -> ScriptArgs {
ScriptArgs {
sub: ScriptSub::Delete {
name: "inv".into(),
version: "2".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/script-objects/inv/2");
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}");
}
}
}