rustfs-cli 0.1.16

A Rust S3 CLI client for S3-compatible object storage
Documentation
#![cfg(not(windows))]

use std::net::TcpListener;
use std::path::PathBuf;
use std::process::Command;

fn rc_binary() -> PathBuf {
    if let Ok(path) = std::env::var("CARGO_BIN_EXE_rc") {
        return PathBuf::from(path);
    }

    let workspace_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .parent()
        .expect("cli crate has parent directory")
        .parent()
        .expect("workspace root exists")
        .to_path_buf();

    let debug_binary = workspace_root.join("target/debug/rc");
    if debug_binary.exists() {
        return debug_binary;
    }

    workspace_root.join("target/release/rc")
}

fn rc_command() -> Command {
    let mut command = Command::new(rc_binary());

    for (key, _) in std::env::vars_os() {
        if key.to_string_lossy().starts_with("RC_HOST_") {
            command.env_remove(key);
        }
    }

    command
}

fn unused_local_endpoint() -> String {
    let listener = TcpListener::bind("127.0.0.1:0").expect("bind local endpoint");
    let address = listener.local_addr().expect("local endpoint address");
    drop(listener);
    format!("http://{address}")
}

#[test]
fn alias_list_includes_rc_host_alias_without_credentials() {
    let config_dir = tempfile::tempdir().expect("create config dir");

    let output = rc_command()
        .args(["alias", "list", "--json"])
        .env("RC_CONFIG_DIR", config_dir.path())
        .env(
            "RC_HOST_myalias",
            "https://ACCESS_KEY:SECRET_KEY@rustfs.local:9000",
        )
        .output()
        .expect("run rc command");

    assert!(
        output.status.success(),
        "stderr: {}",
        String::from_utf8_lossy(&output.stderr)
    );

    let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8");
    assert!(!stdout.contains("SECRET_KEY"));

    let payload: serde_json::Value = serde_json::from_str(&stdout).expect("JSON output");
    assert_eq!(payload["aliases"][0]["name"], "myalias");
    assert_eq!(
        payload["aliases"][0]["endpoint"],
        "https://rustfs.local:9000"
    );
}

#[test]
fn ls_resolves_rc_host_alias_from_environment() {
    let config_dir = tempfile::tempdir().expect("create config dir");
    let endpoint = unused_local_endpoint();
    let (_, endpoint_authority) = endpoint.split_once("://").expect("endpoint has scheme");
    let env_alias = format!("http://ACCESS_KEY:SECRET_KEY@{endpoint_authority}");

    let output = rc_command()
        .args(["--json", "ls", "myalias"])
        .env("RC_CONFIG_DIR", config_dir.path())
        .env("RC_HOST_myalias", env_alias)
        .output()
        .expect("run rc command");

    assert_eq!(
        output.status.code(),
        Some(3),
        "stderr: {}",
        String::from_utf8_lossy(&output.stderr)
    );

    let stderr = String::from_utf8(output.stderr).expect("stderr should be UTF-8");
    let payload: serde_json::Value = serde_json::from_str(&stderr).expect("JSON error output");
    assert!(
        payload["error"]
            .as_str()
            .expect("error message")
            .contains("Failed to list buckets"),
        "payload: {payload}"
    );
    assert_eq!(payload["details"]["type"], "network_error");
}

#[test]
fn alias_list_rejects_invalid_rc_host_percent_encoding_without_credentials() {
    let config_dir = tempfile::tempdir().expect("create config dir");

    let output = rc_command()
        .args(["alias", "list", "--json"])
        .env("RC_CONFIG_DIR", config_dir.path())
        .env(
            "RC_HOST_badalias",
            "https://ACCESS_KEY:SECRET%ZZ@rustfs.local:9000",
        )
        .output()
        .expect("run rc command");

    assert_eq!(
        output.status.code(),
        Some(2),
        "stdout: {}\nstderr: {}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );

    let stderr = String::from_utf8(output.stderr).expect("stderr should be UTF-8");
    assert!(!stderr.contains("ACCESS_KEY"));
    assert!(!stderr.contains("SECRET"));

    let payload: serde_json::Value = serde_json::from_str(&stderr).expect("JSON error output");
    assert!(
        payload["error"]
            .as_str()
            .expect("error message")
            .contains("invalid percent-encoding in secret key"),
        "payload: {payload}"
    );
    assert_eq!(payload["code"], 2);
    assert_eq!(payload["details"]["type"], "usage_error");
}

#[test]
fn alias_list_rejects_invalid_rc_host_access_key_percent_encoding_without_credentials() {
    let config_dir = tempfile::tempdir().expect("create config dir");

    let output = rc_command()
        .args(["alias", "list", "--json"])
        .env("RC_CONFIG_DIR", config_dir.path())
        .env(
            "RC_HOST_badalias",
            "https://ACCESS%ZZKEY:SECRET_KEY@rustfs.local:9000",
        )
        .output()
        .expect("run rc command");

    assert_eq!(
        output.status.code(),
        Some(2),
        "stdout: {}\nstderr: {}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );

    let stderr = String::from_utf8(output.stderr).expect("stderr should be UTF-8");
    assert!(!stderr.contains("ACCESS"));
    assert!(!stderr.contains("SECRET_KEY"));

    let payload: serde_json::Value = serde_json::from_str(&stderr).expect("JSON error output");
    assert!(
        payload["error"]
            .as_str()
            .expect("error message")
            .contains("invalid percent-encoding in access key"),
        "payload: {payload}"
    );
    assert_eq!(payload["code"], 2);
    assert_eq!(payload["details"]["type"], "usage_error");
}

#[test]
fn alias_list_rejects_empty_rc_host_alias_name_as_usage_error() {
    let config_dir = tempfile::tempdir().expect("create config dir");

    let output = rc_command()
        .args(["alias", "list", "--json"])
        .env("RC_CONFIG_DIR", config_dir.path())
        .env(
            "RC_HOST_",
            "https://ACCESS_KEY:SECRET_KEY@rustfs.local:9000",
        )
        .output()
        .expect("run rc command");

    assert_eq!(
        output.status.code(),
        Some(2),
        "stdout: {}\nstderr: {}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );
    assert!(
        output.stdout.is_empty(),
        "usage JSON errors should be emitted on stderr"
    );

    let stderr = String::from_utf8(output.stderr).expect("stderr should be UTF-8");
    assert!(!stderr.contains("ACCESS_KEY"));
    assert!(!stderr.contains("SECRET_KEY"));

    let payload: serde_json::Value = serde_json::from_str(&stderr).expect("JSON error output");
    assert!(
        payload["error"]
            .as_str()
            .expect("error message")
            .contains("RC_HOST_ must include an alias name"),
        "payload: {payload}"
    );
    assert_eq!(payload["code"], 2);
    assert_eq!(payload["details"]["type"], "usage_error");
}

#[test]
fn alias_list_rejects_rc_host_missing_secret_key_as_usage_error() {
    let config_dir = tempfile::tempdir().expect("create config dir");

    let output = rc_command()
        .args(["alias", "list", "--json"])
        .env("RC_CONFIG_DIR", config_dir.path())
        .env("RC_HOST_badalias", "https://ACCESS_KEY@rustfs.local:9000")
        .output()
        .expect("run rc command");

    assert_eq!(
        output.status.code(),
        Some(2),
        "stdout: {}\nstderr: {}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );
    assert!(
        output.stdout.is_empty(),
        "usage JSON errors should be emitted on stderr"
    );

    let stderr = String::from_utf8(output.stderr).expect("stderr should be UTF-8");
    assert!(!stderr.contains("ACCESS_KEY"));

    let payload: serde_json::Value = serde_json::from_str(&stderr).expect("JSON error output");
    assert!(
        payload["error"]
            .as_str()
            .expect("error message")
            .contains("must include access key and secret key credentials"),
        "payload: {payload}"
    );
    assert_eq!(payload["code"], 2);
    assert_eq!(payload["details"]["type"], "usage_error");
}

#[test]
fn alias_list_rejects_invalid_rc_host_scheme_without_credentials() {
    let config_dir = tempfile::tempdir().expect("create config dir");

    let output = rc_command()
        .args(["alias", "list", "--json"])
        .env("RC_CONFIG_DIR", config_dir.path())
        .env(
            "RC_HOST_badalias",
            "ftp://ACCESS_KEY:SECRET_KEY@rustfs.local:9000",
        )
        .output()
        .expect("run rc command");

    assert_eq!(
        output.status.code(),
        Some(2),
        "stdout: {}\nstderr: {}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );

    let stderr = String::from_utf8(output.stderr).expect("stderr should be UTF-8");
    assert!(!stderr.contains("ACCESS_KEY"));
    assert!(!stderr.contains("SECRET_KEY"));

    let payload: serde_json::Value = serde_json::from_str(&stderr).expect("JSON error output");
    assert!(
        payload["error"]
            .as_str()
            .expect("error message")
            .contains("must use an http or https URL"),
        "payload: {payload}"
    );
    assert_eq!(payload["code"], 2);
    assert_eq!(payload["details"]["type"], "usage_error");
}