enseal 0.15.1

Secure, ephemeral secret sharing for developers
Documentation
use anyhow::{bail, Context, Result};
use clap::Args;

use crate::cli::input::PayloadFormat;
use crate::crypto::envelope::Envelope;
use crate::crypto::signing::SignedEnvelope;
use crate::env;
use crate::keys;
use crate::transfer;
use crate::ui::display;

#[derive(Args)]
pub struct ReceiveArgs {
    /// Wormhole share code or path to .env.age file
    pub code: String,

    /// Write to specific file (overrides format-based default)
    #[arg(long)]
    pub output: Option<String>,

    /// Copy received value to clipboard instead of stdout/file
    #[arg(long)]
    pub clipboard: bool,

    /// Print to stdout even for .env payloads (don't write file)
    #[arg(long)]
    pub no_write: bool,

    /// Use specific relay server
    #[arg(long, env = "ENSEAL_RELAY")]
    pub relay: Option<String>,

    /// Overwrite existing files without prompting
    #[arg(long)]
    pub force: bool,

    /// Minimal output
    #[arg(long, short)]
    pub quiet: bool,
}

pub async fn run(args: ReceiveArgs) -> Result<()> {
    // Load .enseal.toml from CWD (silently ignore if absent)
    let manifest = crate::config::Manifest::load(None)?;
    let effective_relay: Option<String> = args.relay.clone().or(manifest.defaults.relay.clone());

    // Detect mode: file drop (.env.age file) vs wormhole code
    let is_file = std::path::Path::new(&args.code).exists() && args.code.ends_with(".age");

    let envelope = if is_file {
        receive_filedrop(&args)?
    } else {
        receive_wormhole(&args, effective_relay.as_deref()).await?
    };

    output_envelope(&args, &envelope)
}

async fn receive_wormhole(args: &ReceiveArgs, relay_url: Option<&str>) -> Result<Envelope> {
    // When a relay is configured, use the enseal relay transport instead of wormhole.
    // The share command mirrors this: --relay in anonymous mode sends via enseal relay too.
    if let Some(relay_url) = relay_url {
        let data = transfer::relay::receive(relay_url, &args.code).await?;
        // Try identity mode first (signed envelope), fall back to plain envelope
        let store = keys::store::KeyStore::open()?;
        if store.is_initialized() {
            if let Ok(signed) = SignedEnvelope::from_bytes(&data) {
                let own_identity = keys::identity::EnsealIdentity::load(&store)?;
                let sender_sign_pubkey = signed.sender_sign_pubkey.clone();
                let trusted_sender = keys::find_trusted_sender(&store, &signed);
                let inner_bytes = signed.open(&own_identity, trusted_sender.as_ref())?;
                let envelope = Envelope::from_bytes(&inner_bytes)?;
                envelope.check_age(300)?;
                if !args.quiet {
                    if let Some(ref trusted) = trusted_sender {
                        display::info("From:", &trusted.identity);
                    } else {
                        display::warning(&format!(
                            "received from unknown sender (signing key: {}...)",
                            &sender_sign_pubkey[..20.min(sender_sign_pubkey.len())]
                        ));
                    }
                    display::ok("signature verified");
                }
                return Ok(envelope);
            }
        }
        // Anonymous relay payload
        if !args.quiet {
            display::warning(
                "received unsigned (anonymous) payload -- sender identity not verified",
            );
        }
        let envelope = Envelope::from_bytes(&data)?;
        envelope.check_age(300)?;
        return Ok(envelope);
    }

    // No relay: use magic-wormhole
    let data = transfer::wormhole::receive_raw(&args.code, None).await?;

    let store = keys::store::KeyStore::open()?;

    // Try identity mode: parse as SignedEnvelope, verify, and decrypt
    if store.is_initialized() {
        if let Ok(signed) = SignedEnvelope::from_bytes(&data) {
            let own_identity = keys::identity::EnsealIdentity::load(&store)?;
            let sender_sign_pubkey = signed.sender_sign_pubkey.clone();

            // Look up sender in trusted keys to verify identity
            let trusted_sender = keys::find_trusted_sender(&store, &signed);

            let inner_bytes = signed.open(&own_identity, trusted_sender.as_ref())?;
            let envelope = Envelope::from_bytes(&inner_bytes)?;
            envelope.check_age(300)?;

            if !args.quiet {
                if let Some(ref trusted) = trusted_sender {
                    display::info("From:", &trusted.identity);
                } else {
                    display::warning(&format!(
                        "received from unknown sender (signing key: {}...)",
                        &sender_sign_pubkey[..20.min(sender_sign_pubkey.len())]
                    ));
                }
                display::ok("signature verified");
            }
            return Ok(envelope);
        }
    }

    // Anonymous mode: parse as plain Envelope
    if !args.quiet {
        display::warning("received unsigned (anonymous) payload -- sender identity not verified");
    }
    let envelope = Envelope::from_bytes(&data)?;
    envelope.check_age(300)?;
    Ok(envelope)
}

fn receive_filedrop(args: &ReceiveArgs) -> Result<Envelope> {
    let store = keys::store::KeyStore::open()?;
    let own_identity = keys::identity::EnsealIdentity::load(&store)?;

    let path = std::path::Path::new(&args.code);

    // Check file size before reading into memory
    let metadata = std::fs::metadata(path)
        .with_context(|| format!("failed to read file: {}", path.display()))?;
    if metadata.len() > 16 * 1024 * 1024 {
        bail!(
            "file too large ({} bytes, max 16 MiB): {}",
            metadata.len(),
            path.display()
        );
    }
    // Read the signed envelope to check sender trust before full decryption
    let data =
        std::fs::read(path).with_context(|| format!("failed to read file: {}", path.display()))?;
    let signed = SignedEnvelope::from_bytes(&data)?;
    let trusted_sender = keys::find_trusted_sender(&store, &signed);

    let (envelope, sender_pubkey) =
        transfer::filedrop::read_from_bytes(&data, &own_identity, trusted_sender.as_ref())?;

    if !args.quiet {
        if let Some(ref trusted) = trusted_sender {
            display::info("From:", &trusted.identity);
        } else {
            display::warning(&format!(
                "received from unknown sender (signing key: {}...)",
                &sender_pubkey[..20.min(sender_pubkey.len())]
            ));
        }
        display::ok("signature verified, file decrypted");
    }

    Ok(envelope)
}

fn output_envelope(args: &ReceiveArgs, envelope: &Envelope) -> Result<()> {
    let payload = &envelope.payload;

    // Show metadata
    if !args.quiet {
        if let Some(count) = envelope.metadata.var_count {
            display::info("Secrets:", &format!("{} variables", count));
        }
        if let Some(ref label) = envelope.metadata.label {
            display::info("Label:", label);
        }
    }

    // Handle clipboard
    if args.clipboard {
        let mut clipboard = arboard::Clipboard::new()
            .context("clipboard not available (are you in a graphical environment?)")?;
        clipboard.set_text(payload)?;
        if let Some(ref label) = envelope.metadata.label {
            display::ok(&format!("copied to clipboard (label: \"{}\")", label));
        } else {
            display::ok("copied to clipboard");
        }
        return Ok(());
    }

    // Schema validation on receive (non-blocking warnings)
    if matches!(envelope.format, PayloadFormat::Env) {
        validate_against_schema(payload, args.quiet);
    }

    // Route output based on format
    match envelope.format {
        PayloadFormat::Env => {
            if args.no_write {
                print!("{}", payload);
            } else {
                let path = args.output.as_deref().unwrap_or(".env");
                check_overwrite(path, args.force)?;
                write_secret_file(path, payload)?;
                let count = envelope.metadata.var_count.unwrap_or(0);
                display::ok(&format!("{} secrets written to {}", count, path));
            }
        }
        PayloadFormat::Raw => {
            if let Some(ref path) = args.output {
                check_overwrite(path, args.force)?;
                write_secret_file(path, payload)?;
                display::ok(&format!("written to {}", path));
            } else {
                print!("{}", payload);
            }
        }
        PayloadFormat::Kv => {
            if let Some(ref path) = args.output {
                check_overwrite(path, args.force)?;
                write_secret_file(path, payload)?;
                display::ok(&format!("written to {}", path));
            } else {
                println!("{}", payload);
            }
        }
    }

    Ok(())
}

/// Write a file containing secrets with restrictive permissions (0600 on Unix).
/// Uses atomic mode setting to avoid a TOCTOU window where the file is world-readable.
fn write_secret_file(path: &str, content: &str) -> Result<()> {
    #[cfg(unix)]
    {
        use std::fs::OpenOptions;
        use std::io::Write;
        use std::os::unix::fs::OpenOptionsExt;
        let mut file = OpenOptions::new()
            .write(true)
            .create(true)
            .truncate(true)
            .mode(0o600)
            .open(path)?;
        file.write_all(content.as_bytes())?;
    }
    #[cfg(not(unix))]
    {
        std::fs::write(path, content)?;
    }
    Ok(())
}

/// Check if the target file exists and handle overwrite confirmation.
fn check_overwrite(path: &str, force: bool) -> Result<()> {
    if !std::path::Path::new(path).exists() {
        return Ok(());
    }
    if force {
        return Ok(());
    }
    if !is_terminal::is_terminal(std::io::stdin()) {
        bail!(
            "'{}' already exists. Use --force to overwrite in non-interactive mode",
            path
        );
    }
    let confirm = dialoguer::Confirm::new()
        .with_prompt(format!("'{}' already exists. Overwrite?", path))
        .default(false)
        .interact()?;
    if !confirm {
        bail!("aborted: not overwriting '{}'", path);
    }
    Ok(())
}

/// Run schema validation against received .env payload.
/// Emits warnings but never blocks the receive.
fn validate_against_schema(payload: &str, quiet: bool) {
    if quiet {
        return;
    }

    let schema = match env::schema::load_schema(None) {
        Ok(Some(s)) => s,
        _ => return, // No schema or error loading — skip silently
    };

    let env_file = match env::parser::parse(payload) {
        Ok(f) => f,
        Err(_) => return,
    };

    let errors = env::schema::validate(&env_file, &schema);
    if !errors.is_empty() {
        display::warning("received .env has schema validation issues:");
        for err in &errors {
            display::warning(&format!("  {}", err));
        }
    }
}