use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine as _;
use chacha20poly1305::aead::{Aead as _, KeyInit as _, OsRng as ChaChaOsRng};
use chacha20poly1305::XChaCha20Poly1305;
use serde_json::json;
use crate::client::Client;
use crate::cmd::peer::open_or_create_channel;
use crate::state::{load_identity, load_party_keys, load_server, save_party_keys};
pub async fn send(home: &Path, recipient: &str, path: &Path) -> Result<()> {
let identity = load_identity(home)?;
let server = load_server(home)?;
if server.server_url.is_empty() {
anyhow::bail!("server not configured. Run `parley register --server URL`.");
}
let party = load_party_keys(home, &identity)?;
let client = Client::new(&server, &identity)?;
let plaintext = std::fs::read(path).with_context(|| format!("read {}", path.display()))?;
let plaintext_size =
u64::try_from(plaintext.len()).map_err(|_| anyhow::anyhow!("file too large"))?;
let filename = path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("file")
.to_owned();
let sha_plain = {
use sha2::{Digest as _, Sha256};
let mut h = Sha256::new();
h.update(&plaintext);
let d = h.finalize();
let mut out = [0u8; 32];
out.copy_from_slice(&d);
out
};
let key_bytes: [u8; 32] = {
use rand::RngCore as _;
let mut k = [0u8; 32];
rand::thread_rng().fill_bytes(&mut k);
k
};
let nonce_bytes: [u8; 24] = {
use rand::RngCore as _;
let mut n = [0u8; 24];
rand::thread_rng().fill_bytes(&mut n);
n
};
let _ = ChaChaOsRng; let cipher = XChaCha20Poly1305::new((&key_bytes).into());
let ciphertext_inner = cipher
.encrypt((&nonce_bytes).into(), plaintext.as_slice())
.map_err(|e| anyhow::anyhow!("encrypt: {e}"))?;
let mut sealed = Vec::with_capacity(24 + ciphertext_inner.len());
sealed.extend_from_slice(&nonce_bytes);
sealed.extend_from_slice(&ciphertext_inner);
let ciphertext_size =
u64::try_from(sealed.len()).map_err(|_| anyhow::anyhow!("ciphertext too large"))?;
println!(
"encrypted: plaintext={} bytes → ciphertext={} bytes",
plaintext.len(),
sealed.len()
);
let mut opened = open_or_create_channel(home, &party, &client, recipient).await?;
let create = client.create_blob(ciphertext_size, None).await?;
let blob_id = create
.get("blob_id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("create_blob response missing blob_id"))?
.to_owned();
let upload_url = create
.get("upload_url")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("create_blob response missing upload_url"))?
.to_owned();
println!("blob_id: {blob_id}");
print!("uploading...");
use std::io::Write as _;
let _ = std::io::stdout().flush();
client.put_raw_to_presigned(&upload_url, sealed).await?;
println!(" ok");
let _ = client.complete_blob(&blob_id).await?;
let manifest = json!({
"kind": "file",
"blob_id": blob_id,
"key": URL_SAFE_NO_PAD.encode(key_bytes),
"name": filename,
"size": plaintext_size,
"sha256_plain": URL_SAFE_NO_PAD.encode(sha_plain),
"content_type": "application/octet-stream",
});
let manifest_bytes = serde_json::to_vec(&manifest)?;
let mls_ct = opened
.group
.encrypt_application(&party, &manifest_bytes)
.map_err(|e| anyhow::anyhow!("encrypt manifest: {e}"))?;
let _ = client
.post_mls_application(&opened.channel_id, &mls_ct)
.await?;
save_party_keys(home, &party)?;
let _ = crate::history::append(
home,
&opened.channel_id,
&crate::history::LogEntry {
seq: None,
ts: crate::history::now_unix(),
direction: "out".into(),
counterparty_pubkey: opened.recipient_pk_b64.clone(),
counterparty_handle: Some(recipient.to_string()),
kind: "file".into(),
body: filename.clone(),
size: Some(plaintext_size),
saved_to: Some(path.display().to_string()),
},
);
println!(
"sent file '{filename}' ({plaintext_size} bytes) to {recipient} (channel {})",
opened.channel_id
);
Ok(())
}
pub async fn fetch_and_decrypt(
client: &Client,
files_dir: &Path,
manifest: &serde_json::Value,
) -> Result<PathBuf> {
let blob_id = manifest
.get("blob_id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("manifest missing blob_id"))?;
let key_b64 = manifest
.get("key")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("manifest missing key"))?;
let name = manifest
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("manifest missing name"))?;
let plaintext_size = manifest
.get("size")
.and_then(|v| v.as_u64())
.ok_or_else(|| anyhow::anyhow!("manifest missing size"))?;
let sha_plain_b64 = manifest
.get("sha256_plain")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("manifest missing sha256_plain"))?;
let key_bytes = URL_SAFE_NO_PAD.decode(key_b64).context("key b64")?;
if key_bytes.len() != 32 {
anyhow::bail!("key wrong length: {}", key_bytes.len());
}
let expected_sha = URL_SAFE_NO_PAD.decode(sha_plain_b64).context("sha b64")?;
let download = client.download_blob(blob_id).await?;
let url = download
.get("download_url")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("download response missing download_url"))?;
let sealed = client.get_raw_from_presigned(url).await?;
if sealed.len() < 24 + 16 {
anyhow::bail!("ciphertext too short: {}", sealed.len());
}
let nonce = &sealed[..24];
let ct = &sealed[24..];
let cipher = XChaCha20Poly1305::new(key_bytes.as_slice().into());
let plaintext = cipher
.decrypt(nonce.into(), ct)
.map_err(|e| anyhow::anyhow!("decrypt: {e}"))?;
if u64::try_from(plaintext.len()).unwrap_or(0) != plaintext_size {
anyhow::bail!(
"size mismatch: declared {plaintext_size}, decrypted {}",
plaintext.len()
);
}
let actual_sha = {
use sha2::{Digest as _, Sha256};
let mut h = Sha256::new();
h.update(&plaintext);
h.finalize().to_vec()
};
if actual_sha != expected_sha {
anyhow::bail!("sha256_plain mismatch — file is corrupted or manifest was tampered with");
}
std::fs::create_dir_all(files_dir).with_context(|| format!("mkdir {}", files_dir.display()))?;
let safe_name = sanitize_filename(name);
let dest = files_dir.join(safe_name);
std::fs::write(&dest, &plaintext).with_context(|| format!("write {}", dest.display()))?;
Ok(dest)
}
fn sanitize_filename(name: &str) -> String {
let stem = Path::new(name)
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("file");
if stem.is_empty() || stem == "." || stem == ".." {
return "file".to_owned();
}
stem.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_' {
c
} else {
'_'
}
})
.collect()
}