swarm-scout 0.6.1

scout — a light, no-node client for reading content from Swarm: point at a gateway (or any Bee) and cat / get / bytes by reference.
Documentation
//! scout CLI — read content from Swarm without a node; upload with one.

use std::io::Write;
use std::path::PathBuf;

use clap::{Parser, Subcommand};
use scout::LiteClient;

mod registry;

#[derive(Parser)]
#[command(name = "scout", version, about = "A light Swarm client: read via a gateway, write via a stamped node")]
struct Cli {
    /// Gateway/Bee endpoint to read from (default: the public Swarm gateway).
    #[arg(long, env = "BEE_GATEWAY", default_value = "https://download.gateway.ethswarm.org", global = true)]
    gateway: String,
    /// Bee node to upload to (writes). Must hold a postage batch.
    #[arg(long, env = "BEE_NODE", default_value = "http://localhost:1633", global = true)]
    node: String,
    /// Postage batch id (hex) for uploads.
    #[arg(long, env = "BEE_STAMP", global = true)]
    stamp: Option<String>,
    #[command(subcommand)]
    cmd: Cmd,
}

#[derive(Subcommand)]
enum Cmd {
    /// Print content at a reference to stdout (manifest-aware, /bzz).
    Cat { reference: String, path: Option<String> },
    /// Download content at a reference to a file.
    Get { reference: String, out: PathBuf, path: Option<String> },
    /// Print the raw bytes behind a reference (/bytes).
    Bytes { reference: String },
    /// Upload a file to Swarm (manifest-wrapped, /bzz). Needs --stamp.
    Up {
        file: PathBuf,
        /// Name stored in the manifest (default: the file's name).
        #[arg(long)]
        name: Option<String>,
        #[arg(long, default_value = "application/octet-stream")]
        content_type: String,
    },
    /// Upload raw bytes to Swarm (/bytes). Needs --stamp.
    UpBytes { file: PathBuf },
    /// Upload a directory as a browsable collection (/bzz). Needs --stamp.
    UpDir {
        folder: PathBuf,
        /// Default document served at the manifest root.
        #[arg(long, default_value = "index.html")]
        index: String,
    },
    /// Point a feed at a reference; prints a stable feed-manifest ref you
    /// can `scout cat` (re-publishing updates what it serves). Needs
    /// --key + --stamp.
    Publish {
        reference: String,
        #[arg(long, env = "SCOUT_KEY")]
        key: String,
        #[arg(long, short)]
        topic: String,
    },
    /// Generate a new identity (private key, owner address, pubkey).
    Keygen,
    /// Share a file under ACT, granting compressed pubkeys. Needs --stamp.
    Share {
        file: PathBuf,
        /// Recipient compressed pubkey (repeatable). See `scout keygen`.
        #[arg(long = "to")]
        to: Vec<String>,
        #[arg(long, default_value = "application/octet-stream")]
        content_type: String,
    },
    /// List your recorded shares.
    Shares,
    /// Revoke grantee(s) from one of your shares (by id). Needs --stamp.
    Revoke {
        id: String,
        /// Pubkey to revoke (repeatable).
        #[arg(long = "grantee")]
        grantee: Vec<String>,
    },
    /// List the live grantees of one of your shares (by id).
    Grantees { id: String },
    /// Download ACT-protected content (decrypts via the node's identity).
    Fetch {
        file_ref: String,
        #[arg(long)]
        publisher: String,
        #[arg(long)]
        history: String,
        out: Option<PathBuf>,
    },
}

fn client(cli: &Cli) -> anyhow::Result<LiteClient> {
    let mut c = LiteClient::read(&cli.gateway)?;
    if let Some(stamp) = &cli.stamp {
        c = c.with_write(&cli.node, stamp)?;
    }
    Ok(c)
}

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let cli = Cli::parse();
    let scout = client(&cli)?;

    match &cli.cmd {
        Cmd::Cat { reference, path } => {
            let data = fetch(&scout, reference, path.as_deref()).await?;
            std::io::stdout().write_all(&data)?;
        }
        Cmd::Get { reference, out, path } => {
            let data = fetch(&scout, reference, path.as_deref()).await?;
            std::fs::write(out, &data)?;
            eprintln!("wrote {} bytes to {}", data.len(), out.display());
        }
        Cmd::Bytes { reference } => {
            let data = scout.bytes(reference).await?;
            std::io::stdout().write_all(&data)?;
        }
        Cmd::Up { file, name, content_type } => {
            let data = std::fs::read(file)?;
            let n = name.clone().unwrap_or_else(|| {
                file.file_name().and_then(|s| s.to_str()).unwrap_or("file").to_string()
            });
            let reference = scout.up_file(&n, content_type, data).await?;
            println!("{reference}");
        }
        Cmd::UpBytes { file } => {
            let data = std::fs::read(file)?;
            let reference = scout.up_bytes(data).await?;
            println!("{reference}");
        }
        Cmd::UpDir { folder, index } => {
            let path = folder.to_str().ok_or_else(|| anyhow::anyhow!("non-UTF-8 folder path"))?;
            let reference = scout.up_dir(path, Some(index)).await?;
            println!("{reference}");
        }
        Cmd::Publish { reference, key, topic } => {
            let manifest = scout.publish(key, topic, reference).await?;
            eprintln!("feed manifest (read latest with `scout cat <ref>`):");
            println!("{manifest}");
        }
        Cmd::Keygen => {
            let (key, owner, pubkey) = scout::generate_key()?;
            println!("key:    {key}");
            println!("owner:  {owner}");
            println!("pubkey: {pubkey}");
        }
        Cmd::Share { file, to, content_type } => {
            anyhow::ensure!(!to.is_empty(), "at least one --to <pubkey> is required");
            let data = std::fs::read(file)?;
            let name = file.file_name().and_then(|s| s.to_str()).unwrap_or("share").to_string();
            let info = scout.share(&name, content_type, data, to).await?;
            let id = format!("{:08x}", registry::now_secs() as u32);
            let mut reg = registry::load();
            reg.shares.push(registry::Share {
                id: id.clone(),
                file: name,
                file_ref: info.file_ref.clone(),
                history: info.history.clone(),
                grantee_ref: info.grantee_ref.clone(),
                grantee_history: info.grantee_history.clone(),
                publisher: info.publisher.clone(),
                grantees: to.clone(),
                ts: registry::now_secs(),
            });
            registry::save(&reg)?;
            println!("id:              {id}");
            println!("file_ref:        {}", info.file_ref);
            println!("history:         {}", info.history);
            println!("publisher:       {}", info.publisher);
            eprintln!("\nmanage:    scout grantees {id}  |  scout revoke {id} --grantee <pk>");
            eprintln!("recipient: scout fetch {} --publisher {} --history {}", info.file_ref, info.publisher, info.history);
        }
        Cmd::Shares => {
            let reg = registry::load();
            if reg.shares.is_empty() {
                println!("(no shares yet)");
            } else {
                for s in &reg.shares {
                    println!("{}  {:<20}  {} grantee(s)  {}", s.id, s.file, s.grantees.len(), s.file_ref);
                }
            }
        }
        Cmd::Revoke { id, grantee } => {
            anyhow::ensure!(!grantee.is_empty(), "at least one --grantee <pubkey> is required");
            let mut reg = registry::load();
            let s = reg
                .find(id)
                .ok_or_else(|| anyhow::anyhow!("no share with id {id} (see `scout shares`)"))?
                .clone();
            let (gr, gh) = scout.revoke(&s.grantee_ref, &s.history, grantee).await?;
            if let Some(m) = reg.find_mut(id) {
                m.grantee_ref = gr.clone();
                m.grantee_history = gh;
                m.grantees.retain(|g| !grantee.contains(g));
            }
            registry::save(&reg)?;
            println!("revoked {} key(s) from {id}; new grantee_ref: {gr}", grantee.len());
        }
        Cmd::Grantees { id } => {
            let reg = registry::load();
            let s = reg
                .find(id)
                .ok_or_else(|| anyhow::anyhow!("no share with id {id} (see `scout shares`)"))?;
            let live = scout.grantees(&s.grantee_ref).await?;
            println!("share {id} ({}): {} live grantee(s)", s.file, live.len());
            for g in &live {
                println!("  {g}");
            }
        }
        Cmd::Fetch { file_ref, publisher, history, out } => {
            let data = scout.fetch_act(file_ref, publisher, history).await?;
            match out {
                Some(p) => {
                    std::fs::write(p, &data)?;
                    eprintln!("wrote {} bytes to {}", data.len(), p.display());
                }
                None => std::io::stdout().write_all(&data)?,
            }
        }
    }
    Ok(())
}

async fn fetch(scout: &LiteClient, reference: &str, path: Option<&str>) -> anyhow::Result<Vec<u8>> {
    match path {
        Some(p) => scout.cat_path(reference, p).await,
        None => scout.cat(reference).await,
    }
}