use crate::config::{Backend, SelfHostedConfig};
use crate::error::{Error, Result};
use crate::progress::Progress;
use crate::request::{CreateRequest, NewRequest, RequestStatus};
use crate::request_ledger::{self, RequestRecord};
use crate::s3::Store;
use crate::transfer::*;
use crate::{crypto, duration as dur, ledger};
use std::collections::HashMap;
use std::fs::File;
use std::io::{BufWriter, Read};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
pub struct GateState {
pub enabled: bool,
}
#[derive(Debug)]
pub struct SelfHosted {
cfg: SelfHostedConfig,
}
impl SelfHosted {
pub fn from_backend(b: &Backend) -> Result<Self> {
let cfg: SelfHostedConfig = toml::Value::Table(b.config.clone())
.try_into()
.map_err(|e| Error::Config(format!("backend {}: {e}", b.name)))?;
Ok(Self { cfg })
}
pub fn adhoc() -> Self {
Self {
cfg: SelfHostedConfig::default(),
}
}
fn store(&self) -> Result<Store> {
Store::new(
&self.cfg.bucket,
&self.cfg.region,
self.cfg.endpoint.as_deref(),
)
.map_err(|e| Error::Aws(e.to_string()))
}
#[allow(clippy::too_many_arguments)]
fn share_full(
&self,
store: &Store,
source: &Path,
name: &str,
ttl: Duration,
downloads: u32,
pin: Option<String>,
from: Option<String>,
message: Option<String>,
progress: &dyn Progress,
) -> Result<Share> {
let gate_secret = crate::secrets::Secrets::load()
.map_err(|e| Error::Config(e.to_string()))?
.gate_secret
.ok_or_else(|| {
Error::Config(
"no gate secret in secrets.toml — re-run `dove provision full`".into(),
)
})?;
let share_id =
crypto::new_share_id(&gate_secret).map_err(|e| Error::Other(e.to_string()))?;
let fragment_secret = crypto::gen_key();
let (content_key, pin_hash) = match &pin {
Some(p) => (
crypto::derive_key(p, &fragment_secret),
Some(crypto::pin_hash(&share_id, p)),
),
None => (fragment_secret, None),
};
let ct = temp_ct_path();
progress.step("encrypting");
let encrypted = (|| -> Result<()> {
let reader = File::open(source)
.map_err(|e| Error::Other(format!("opening {}: {e}", source.display())))?;
let writer = BufWriter::new(
File::create(&ct)
.map_err(|e| Error::Other(format!("creating {}: {e}", ct.display())))?,
);
crypto::encrypt(&content_key, crypto::DEFAULT_CHUNK, reader, writer)
.map_err(|e| Error::Other(e.to_string()))
})();
if encrypted.is_ok() {
progress.done("encrypting");
}
encrypted?;
let object_key = share_id.clone(); let uploaded = store.put_file(&object_key, &ct, progress);
let _ = std::fs::remove_file(&ct);
let size = uploaded.map_err(|e| Error::Aws(e.to_string()))?;
let meta_json = serde_json::json!({
"name": name,
"from": from.as_deref().unwrap_or(""),
"msg": message.as_deref().unwrap_or(""),
})
.to_string();
let meta_blob = crypto::encrypt_meta(&fragment_secret, meta_json.as_bytes());
let expires_at = now_epoch() + ttl.as_secs();
progress.step("registering policy");
let registered = self.put_policy_item(
&share_id,
&object_key,
downloads,
expires_at,
size,
&meta_blob,
pin_hash.as_deref(),
);
if registered.is_ok() {
progress.done("registering policy");
}
registered?;
let _ = ledger::record(ledger::ShareRecord {
id: share_id.clone(),
name: name.to_string(),
from: from.clone(),
created_at: now_epoch(),
expires_at,
downloads,
});
let gate = self
.cfg
.gate_url
.as_ref()
.ok_or_else(|| Error::Config("no gate URL in config".into()))?;
let link = format!(
"{gate}/d/{share_id}#{}",
crypto::key_to_fragment(&fragment_secret)
);
Ok(Share {
id: share_id,
link,
size,
expires_at,
})
}
#[allow(clippy::too_many_arguments)]
fn put_policy_item(
&self,
id: &str,
s3_key: &str,
downloads: u32,
expires_at: u64,
size: u64,
meta_blob: &str,
pin_hash: Option<&str>,
) -> Result<()> {
let table = self
.cfg
.table
.as_ref()
.ok_or_else(|| Error::Config("no table in config".into()))?;
let mut item = serde_json::json!({
"id": {"S": id},
"s3_key": {"S": s3_key},
"downloads_remaining": {"N": downloads.to_string()},
"downloads_total": {"N": downloads.to_string()},
"expires_at": {"N": expires_at.to_string()},
"created_at": {"N": now_epoch().to_string()},
"size": {"N": size.to_string()},
"meta": {"S": meta_blob},
});
if let Some(hash) = pin_hash {
item["pin_hash"] = serde_json::json!({"S": hash});
item["pin_attempts"] = serde_json::json!({"N": "0"});
}
let item = item.to_string();
let secrets = crate::secrets::Secrets::load()
.map_err(|e| Error::Config(format!("loading scoped credentials: {e}")))?;
let mut cmd = Command::new("aws");
cmd.env("AWS_ACCESS_KEY_ID", &secrets.access_key_id)
.env("AWS_SECRET_ACCESS_KEY", &secrets.secret_access_key)
.env("AWS_DEFAULT_REGION", &self.cfg.region);
cmd.args([
"dynamodb",
"put-item",
"--table-name",
table,
"--item",
&item,
]);
let out = cmd
.output()
.map_err(|e| Error::Aws(format!("running aws dynamodb put-item: {e}")))?;
if !out.status.success() {
return Err(Error::Aws(format!(
"registering the share policy failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
)));
}
Ok(())
}
pub fn create_request(&self, req: CreateRequest, p: &dyn Progress) -> Result<NewRequest> {
if !self.cfg.is_full() {
return Err(Error::Config(
"dove request needs the full tier (a gate + DynamoDB table) — provision it \
with `dove provision full`."
.into(),
));
}
let gate_secret = crate::secrets::Secrets::load()
.map_err(|e| Error::Config(e.to_string()))?
.gate_secret
.ok_or_else(|| {
Error::Config(
"no gate secret in secrets.toml — re-run `dove provision full`".into(),
)
})?;
let id = crypto::new_share_id(&gate_secret).map_err(|e| Error::Other(e.to_string()))?;
let fragment_secret = crypto::gen_key();
let pin_hash = req.pin.as_deref().map(|pin| crypto::pin_hash(&id, pin));
let meta_json = serde_json::json!({
"from": req.from.as_deref().unwrap_or(""),
"msg": req.message.as_deref().unwrap_or(""),
"desc": req.description,
})
.to_string();
let meta_blob = crypto::encrypt_meta(&fragment_secret, meta_json.as_bytes());
let expires_at = now_epoch() + req.expires.as_secs();
p.step("registering request");
let registered = self.put_request_item(
&id,
req.uploads,
expires_at,
pin_hash.as_deref(),
&meta_blob,
);
if registered.is_ok() {
p.done("registering request");
}
registered?;
let fragment = crypto::key_to_fragment(&fragment_secret);
if let Err(e) = request_ledger::record(RequestRecord {
id: id.clone(),
fragment: fragment.clone(),
description: req.description.clone(),
created_at: now_epoch(),
}) {
p.field(
"warning",
&format!(
"couldn't save this request locally ({e}) — keep the printed link; \
it carries your only decryption key"
),
);
}
let gate = self
.cfg
.gate_url
.as_ref()
.ok_or_else(|| Error::Config("no gate URL in config".into()))?;
let link = request_link(gate, &id, &fragment);
Ok(NewRequest { id, link })
}
pub fn request_status(&self, rec: &RequestRecord) -> Result<RequestStatus> {
let gate = self
.cfg
.gate_url
.as_ref()
.ok_or_else(|| Error::Config("no gate URL in config".into()))?;
let body = fetch_rmeta(gate, &rec.id)?;
let fragment_secret =
crypto::key_from_fragment(&rec.fragment).map_err(|e| Error::Other(e.to_string()))?;
Ok(rmeta_to_status(&body, &fragment_secret))
}
pub fn collect_request(
&self,
rec: &RequestRecord,
out: Option<PathBuf>,
p: &dyn Progress,
) -> Result<Fetched> {
let gate = self
.cfg
.gate_url
.as_ref()
.ok_or_else(|| Error::Config("no gate URL in config".into()))?;
let body = fetch_rmeta(gate, &rec.id)?;
let v: serde_json::Value = serde_json::from_str(&body)
.map_err(|e| Error::Other(format!("parsing the gate's response: {e}")))?;
if v["status"].as_str() != Some("received") {
return Err(Error::Other(
"this request hasn't been fulfilled yet".into(),
));
}
let fragment_secret =
crypto::key_from_fragment(&rec.fragment).map_err(|e| Error::Other(e.to_string()))?;
let name = decrypt_meta_field(&v, "name_meta", &fragment_secret, "name")
.unwrap_or_else(|| "upload".to_string());
let from =
decrypt_meta_field(&v, "meta", &fragment_secret, "from").filter(|s| !s.is_empty());
let message =
decrypt_meta_field(&v, "meta", &fragment_secret, "msg").filter(|s| !s.is_empty());
let out_path = out.unwrap_or_else(|| PathBuf::from(&name));
let store = self.store()?;
let dl_url = store.presign_get(&format!("req/{}", rec.id), Duration::from_secs(300));
let resp = match ureq::get(&dl_url).call() {
Ok(r) => r,
Err(ureq::Error::Status(code, _)) => {
return Err(Error::Aws(format!(
"downloading the upload failed: HTTP {code}"
)))
}
Err(e @ ureq::Error::Transport(_)) => {
return Err(Error::Network(format!(
"downloading the upload failed: {}",
transport_err(e)
)))
}
};
let total: u64 = resp
.header("Content-Length")
.and_then(|s| s.parse().ok())
.unwrap_or(0);
let reader = CountingReader {
inner: resp.into_reader(),
seen: 0,
total,
progress: p,
};
let file = BufWriter::new(
File::create(&out_path)
.map_err(|e| Error::Other(format!("creating {}: {e}", out_path.display())))?,
);
crypto::decrypt(&fragment_secret, reader, file).map_err(|_| Error::Integrity)?;
Ok(Fetched {
path: out_path,
from,
message,
})
}
fn put_request_item(
&self,
id: &str,
uploads: u32,
expires_at: u64,
pin_hash: Option<&str>,
meta_blob: &str,
) -> Result<()> {
let table = self
.cfg
.table
.as_ref()
.ok_or_else(|| Error::Config("no table in config".into()))?;
let item = request_item_json(id, uploads, expires_at, pin_hash, meta_blob).to_string();
let secrets = crate::secrets::Secrets::load()
.map_err(|e| Error::Config(format!("loading scoped credentials: {e}")))?;
let mut cmd = Command::new("aws");
cmd.env("AWS_ACCESS_KEY_ID", &secrets.access_key_id)
.env("AWS_SECRET_ACCESS_KEY", &secrets.secret_access_key)
.env("AWS_DEFAULT_REGION", &self.cfg.region);
cmd.args([
"dynamodb",
"put-item",
"--table-name",
table,
"--item",
&item,
]);
let out = cmd
.output()
.map_err(|e| Error::Aws(format!("running aws dynamodb put-item: {e}")))?;
if !out.status.success() {
return Err(Error::Aws(format!(
"registering the request failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
)));
}
Ok(())
}
pub fn gate_disable(&self) -> Result<()> {
let (profile, function) = self.gate_function()?;
run_aws(
profile.as_deref(),
&[
"lambda",
"put-function-concurrency",
"--function-name",
&function,
"--reserved-concurrent-executions",
"0",
],
)
}
pub fn gate_enable(&self) -> Result<()> {
let (profile, function) = self.gate_function()?;
run_aws(
profile.as_deref(),
&[
"lambda",
"delete-function-concurrency",
"--function-name",
&function,
],
)
}
pub fn gate_status(&self) -> Result<GateState> {
let (profile, function) = self.gate_function()?;
let out = aws_cmd(
profile.as_deref(),
&[
"lambda",
"get-function-concurrency",
"--function-name",
&function,
"--output",
"json",
],
)?;
Ok(GateState {
enabled: gate_enabled(&out.stdout),
})
}
fn gate_function(&self) -> Result<(Option<String>, String)> {
if !self.cfg.is_full() {
return Err(Error::Other(
"this config has no gate — it isn't full tier (`dove provision full`)".into(),
));
}
let out = aws_cmd(
self.cfg.profile.as_deref(),
&[
"sts",
"get-caller-identity",
"--query",
"Account",
"--output",
"text",
],
)?;
if !out.status.success() {
return Err(Error::Aws(format!(
"resolving the AWS account: {}",
String::from_utf8_lossy(&out.stderr).trim()
)));
}
let account = String::from_utf8_lossy(&out.stdout).trim().to_string();
Ok((self.cfg.profile.clone(), format!("dove-gate-{account}")))
}
}
impl Transfer for SelfHosted {
fn share(&self, req: ShareRequest, progress: &dyn Progress) -> Result<Share> {
if req.pin.is_some() && !self.cfg.is_full() {
return Err(Error::Config(
"--pin is a full-tier feature: it's checked at the gate, which the simple tier \
doesn't have. Provision it with `dove provision full`."
.into(),
));
}
if (req.from.is_some() || req.message.is_some()) && !self.cfg.is_full() {
return Err(Error::Config(
"--from/--message ride an encrypted metadata blob in the full-tier link. \
Provision it with `dove provision full`."
.into(),
));
}
let name = req
.path
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| Error::Other(format!("{} has no usable filename", req.path.display())))?
.to_string();
let store = self.store()?;
if self.cfg.is_full() {
return self.share_full(
&store,
&req.path,
&name,
req.expires,
req.downloads.unwrap_or(100),
req.pin,
req.from,
req.message,
progress,
);
}
if !dur::within_presign_limit(req.expires) {
return Err(Error::Other(format!(
"a share expiry of {} is over the 7-day limit for the simple tier's presigned \
links. Use 7d or less. Longer-lived shares and download limits are the full \
tier — provision it with `dove provision full`.",
dur::human(req.expires)
)));
}
let (upload_path, ct_temp, fragment) = if req.encrypt {
let content_key = crypto::gen_key();
let ct = temp_ct_path();
progress.step("encrypting");
let encrypted = (|| -> Result<()> {
let reader = File::open(&req.path)
.map_err(|e| Error::Other(format!("opening {}: {e}", req.path.display())))?;
let writer = BufWriter::new(
File::create(&ct)
.map_err(|e| Error::Other(format!("creating {}: {e}", ct.display())))?,
);
crypto::encrypt(&content_key, crypto::DEFAULT_CHUNK, reader, writer)
.map_err(|e| Error::Other(e.to_string()))
})();
if encrypted.is_ok() {
progress.done("encrypting");
}
encrypted?;
(
ct.clone(),
Some(ct),
Some(crypto::key_to_fragment(&content_key)),
)
} else {
(req.path.clone(), None, None)
};
let object_key = share_key(&name);
let uploaded = store.put_file(&object_key, &upload_path, progress);
if let Some(t) = ct_temp {
let _ = std::fs::remove_file(t);
}
let size = uploaded.map_err(|e| Error::Aws(e.to_string()))?;
let mut link = store.presign_get(&object_key, req.expires);
if let Some(frag) = fragment {
link.push('#');
link.push_str(&frag);
}
let expires_at = now_epoch() + req.expires.as_secs();
let id = object_key
.split_once('/')
.map(|(id, _)| id.to_string())
.unwrap_or(object_key);
Ok(Share {
id,
link,
size,
expires_at,
})
}
fn get(&self, req: GetRequest, progress: &dyn Progress) -> Result<Fetched> {
let (base, fragment) = req.url.rsplit_once('#').ok_or_else(|| {
Error::Other(
"this link has no key — it isn't a dove-encrypted share (nothing after `#`)".into(),
)
})?;
let secret = crypto::key_from_fragment(fragment.split('.').next().unwrap_or(fragment))
.map_err(|e| Error::Other(e.to_string()))?;
let key = match &req.pin {
Some(p) => crypto::derive_key(p, &secret),
None => secret,
};
let meta = fetch_meta(base, &secret);
let from = meta
.as_ref()
.map(|(_, from, _)| from.clone())
.filter(|s| !s.is_empty());
let message = meta
.as_ref()
.map(|(_, _, msg)| msg.clone())
.filter(|s| !s.is_empty());
let meta_name = meta
.as_ref()
.map(|(n, _, _)| n.clone())
.filter(|n| !n.is_empty());
if let Some(f) = &from {
progress.field("from", f);
}
if let Some(m) = &message {
progress.field("message", m);
}
let out_path = req
.out
.clone()
.or_else(|| meta_name.map(PathBuf::from))
.unwrap_or_else(|| PathBuf::from(filename_from_url(base)));
let mut fetch_url = to_download_url(base);
if let Some(p) = &req.pin {
if fetch_url.contains("/dl/") {
fetch_url.push_str(&format!("?pin={p}"));
}
}
let resp = match ureq::get(&fetch_url).call() {
Ok(r) => r,
Err(ureq::Error::Status(code, resp)) => {
return Err(gate_error(code, resp, req.pin.is_some()))
}
Err(e @ ureq::Error::Transport(_)) => {
return Err(Error::Network(format!(
"fetching the share failed: {}",
transport_err(e)
)))
}
};
let total: u64 = resp
.header("Content-Length")
.and_then(|s| s.parse().ok())
.unwrap_or(0);
let reader = CountingReader {
inner: resp.into_reader(),
seen: 0,
total,
progress,
};
let file = BufWriter::new(
File::create(&out_path)
.map_err(|e| Error::Other(format!("creating {}: {e}", out_path.display())))?,
);
crypto::decrypt(&key, reader, file).map_err(|_| Error::Integrity)?;
Ok(Fetched {
path: out_path,
from,
message,
})
}
fn list(&self) -> Result<Vec<ShareInfo>> {
let store = self.store()?;
let keys = store.list("").map_err(|e| Error::Aws(e.to_string()))?;
let records: HashMap<String, ledger::ShareRecord> = ledger::load()
.unwrap_or_default()
.into_iter()
.map(|r| (r.id.clone(), r))
.collect();
Ok(keys.iter().map(|k| share_info(k, &records)).collect())
}
fn revoke(&self, id: &str) -> Result<()> {
let store = self.store()?;
let keys = store.list(id).map_err(|e| Error::Aws(e.to_string()))?;
let key = keys
.first()
.ok_or_else(|| Error::Other(format!("no share with id {id}")))?;
store
.delete_object(key)
.map_err(|e| Error::Aws(e.to_string()))?;
let _ = ledger::remove(id);
Ok(())
}
fn status(&self) -> Result<BackendStatus> {
let mut summary = vec![
("bucket".to_string(), self.cfg.bucket.clone()),
("region".to_string(), self.cfg.region.clone()),
];
if let Some(t) = &self.cfg.table {
summary.push(("table".into(), t.clone()));
}
if let Some(g) = &self.cfg.gate_url {
summary.push(("gate".into(), g.clone()));
}
Ok(BackendStatus { summary })
}
}
fn now_epoch() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
fn temp_ct_path() -> PathBuf {
let mut b = [0u8; 8];
getrandom::getrandom(&mut b).expect("OS RNG unavailable");
let hex: String = b.iter().map(|x| format!("{x:02x}")).collect();
std::env::temp_dir().join(format!("dove-{hex}.zip"))
}
fn share_info(key: &str, records: &HashMap<String, ledger::ShareRecord>) -> ShareInfo {
match key.split_once('/') {
Some((id, name)) => ShareInfo {
id: id.to_string(),
filename: Some(name.to_string()),
expires_at: records.get(id).map(|r| r.expires_at).unwrap_or(0),
},
None => {
let rec = records.get(key);
ShareInfo {
id: key.to_string(),
filename: rec.map(|r| r.name.clone()),
expires_at: rec.map(|r| r.expires_at).unwrap_or(0),
}
}
}
}
fn share_key(filename: &str) -> String {
let mut b = [0u8; 4];
getrandom::getrandom(&mut b).expect("OS RNG unavailable");
format!(
"{:02x}{:02x}{:02x}{:02x}/{filename}",
b[0], b[1], b[2], b[3]
)
}
fn request_item_json(
id: &str,
uploads: u32,
expires_at: u64,
pin_hash: Option<&str>,
meta_blob: &str,
) -> serde_json::Value {
let mut item = serde_json::json!({
"id": {"S": id},
"kind": {"S": "request"},
"uploads_remaining": {"N": uploads.to_string()},
"uploads_total": {"N": uploads.to_string()},
"expires_at": {"N": expires_at.to_string()},
"upload_attempts": {"N": "0"},
"meta": {"S": meta_blob},
});
if let Some(hash) = pin_hash {
item["pin_hash"] = serde_json::json!({"S": hash});
item["pin_attempts"] = serde_json::json!({"N": "0"});
}
item
}
fn request_link(gate: &str, id: &str, fragment: &str) -> String {
format!("{gate}/r/{id}#{fragment}")
}
fn fetch_rmeta(gate: &str, id: &str) -> Result<String> {
let url = format!("{gate}/rmeta/{id}");
match ureq::get(&url).call() {
Ok(r) => r
.into_string()
.map_err(|e| Error::Network(format!("reading the gate's response: {e}"))),
Err(ureq::Error::Status(404, _)) => Err(Error::NotFound),
Err(ureq::Error::Status(code, _)) => Err(Error::Other(format!(
"checking request status failed: HTTP {code}"
))),
Err(e @ ureq::Error::Transport(_)) => Err(Error::Network(format!(
"checking request status failed: {}",
transport_err(e)
))),
}
}
fn rmeta_to_status(body: &str, fragment_secret: &[u8; 32]) -> RequestStatus {
let v: serde_json::Value = match serde_json::from_str(body) {
Ok(v) => v,
Err(_) => {
return RequestStatus::Failed {
reason: "malformed response from the gate".into(),
}
}
};
match v["status"].as_str().unwrap_or("waiting") {
"received" => {
let size = v["size"].as_u64().unwrap_or(0);
let name = decrypt_meta_field(&v, "name_meta", fragment_secret, "name")
.unwrap_or_else(|| "(encrypted)".to_string());
RequestStatus::Received { name, size }
}
"failed" => RequestStatus::Failed {
reason: v["reason"].as_str().unwrap_or("failed").to_string(),
},
_ => RequestStatus::Waiting,
}
}
fn decrypt_meta_field(
v: &serde_json::Value,
blob_field: &str,
fragment_secret: &[u8; 32],
json_key: &str,
) -> Option<String> {
let blob = v[blob_field].as_str()?;
let plain = crypto::decrypt_meta(fragment_secret, blob).ok()?;
let j: serde_json::Value = serde_json::from_slice(&plain).ok()?;
j[json_key].as_str().map(|s| s.to_string())
}
fn fetch_meta(base: &str, secret: &[u8; 32]) -> Option<(String, String, String)> {
let meta_url = to_meta_url(base)?;
let body = ureq::get(&meta_url).call().ok()?.into_string().ok()?;
let v: serde_json::Value = serde_json::from_str(&body).ok()?;
let blob = v["meta"].as_str().filter(|s| !s.is_empty())?;
let plain = crypto::decrypt_meta(secret, blob).ok()?;
let j: serde_json::Value = serde_json::from_slice(&plain).ok()?;
let s = |k: &str| j[k].as_str().unwrap_or("").to_string();
Some((s("name"), s("from"), s("msg")))
}
fn to_meta_url(base: &str) -> Option<String> {
let scheme_end = base.find("://")?;
let after = &base[scheme_end + 3..];
let slash = after.find('/')?;
let host = &base[..scheme_end + 3 + slash];
let path = after[slash..].split('?').next().unwrap_or("");
let segs: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
(segs.len() >= 2 && segs[0] == "d").then(|| format!("{host}/meta/{}", segs[1]))
}
fn filename_from_url(base: &str) -> String {
let path = base.split('?').next().unwrap_or(base);
let name = path.rsplit('/').next().unwrap_or("download");
let decoded = percent_decode(name);
if decoded.is_empty() {
"download".to_string()
} else {
decoded
}
}
fn to_download_url(base: &str) -> String {
if let Some(scheme_end) = base.find("://") {
let after = &base[scheme_end + 3..];
if let Some(slash) = after.find('/') {
let host = &base[..scheme_end + 3 + slash];
let path = after[slash..].split('?').next().unwrap_or("");
let segs: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
if segs.len() >= 2 && segs[0] == "d" {
return format!("{host}/dl/{}", segs[1]);
}
}
}
base.to_string()
}
fn percent_decode(s: &str) -> String {
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
if let Ok(b) = u8::from_str_radix(&s[i + 1..i + 3], 16) {
out.push(b);
i += 3;
continue;
}
}
out.push(bytes[i]);
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
fn gate_error(code: u16, resp: ureq::Response, had_pin: bool) -> Error {
let body = resp.into_string().unwrap_or_default();
let json: serde_json::Value = serde_json::from_str(&body).unwrap_or(serde_json::Value::Null);
let attempts = json.get("attempts_remaining").and_then(|v| v.as_u64());
match code {
401 if !had_pin => Error::PinRequired(
"this share is PIN-locked — pass --pin <PIN> (the sender sent it separately)".into(),
),
401 => Error::PinRequired(match attempts {
Some(n) => format!(
"wrong PIN — {n} attempt{} left",
if n == 1 { "" } else { "s" }
),
None => "wrong PIN".into(),
}),
423 => Error::Locked(
"this share is locked — too many wrong PINs. Ask the sender to re-share.".into(),
),
410 => Error::Gone,
_ => Error::Other(format!(
"the share link returned HTTP {code} — it may have expired or been revoked"
)),
}
}
fn run_aws(profile: Option<&str>, args: &[&str]) -> Result<()> {
let out = aws_cmd(profile, args)?;
if out.status.success() {
return Ok(());
}
Err(Error::Aws(format!(
"aws {} failed: {}",
args.join(" "),
String::from_utf8_lossy(&out.stderr).trim()
)))
}
fn aws_cmd(profile: Option<&str>, args: &[&str]) -> Result<std::process::Output> {
let mut cmd = Command::new("aws");
if let Some(p) = profile {
cmd.args(["--profile", p]);
}
cmd.args(args)
.output()
.map_err(|e| Error::Aws(format!("running aws {}: {e}", args.join(" "))))
}
fn gate_enabled(stdout: &[u8]) -> bool {
let reserved = serde_json::from_slice::<serde_json::Value>(stdout)
.ok()
.and_then(|v| v["ReservedConcurrentExecutions"].as_i64());
reserved != Some(0)
}
fn transport_err(e: ureq::Error) -> String {
match e {
ureq::Error::Status(code, _) => format!("HTTP {code}"),
ureq::Error::Transport(t) => t.kind().to_string(),
}
}
struct CountingReader<'a, R> {
inner: R,
seen: u64,
total: u64,
progress: &'a dyn Progress,
}
impl<R: Read> Read for CountingReader<'_, R> {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
let k = self.inner.read(buf)?;
self.seen += k as u64;
self.progress.bytes(self.seen, self.total);
Ok(k)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn status_summarizes_bucket_region_table_and_gate() {
let cfg = SelfHostedConfig {
bucket: "dove-shares-example".into(),
region: "us-east-1".into(),
profile: None,
endpoint: None,
table: Some("dove-shares-example".into()),
gate_url: Some("https://share.example.com".into()),
distribution_id: None,
};
let backend = Backend::self_hosted("default", &cfg).unwrap();
let sh = SelfHosted::from_backend(&backend).unwrap();
let status = sh.status().unwrap();
assert!(status
.summary
.contains(&("bucket".to_string(), "dove-shares-example".to_string())));
assert!(status
.summary
.contains(&("region".to_string(), "us-east-1".to_string())));
assert!(status
.summary
.contains(&("table".to_string(), "dove-shares-example".to_string())));
assert!(status
.summary
.contains(&("gate".to_string(), "https://share.example.com".to_string())));
}
#[test]
fn from_backend_is_io_free_even_with_no_secrets_present() {
let cfg = SelfHostedConfig {
bucket: "b".into(),
region: "us-east-1".into(),
profile: None,
endpoint: None,
table: None,
gate_url: None,
distribution_id: None,
};
let backend = Backend::self_hosted("default", &cfg).unwrap();
assert!(SelfHosted::from_backend(&backend).is_ok());
}
#[test]
fn adhoc_is_io_free_and_not_full() {
let sh = SelfHosted::adhoc();
assert!(!sh.cfg.is_full());
}
#[test]
fn share_key_has_random_prefix_and_keeps_the_name() {
let k = share_key("report.pdf");
assert!(k.ends_with("/report.pdf"), "{k}");
let prefix = k.split('/').next().unwrap();
assert_eq!(prefix.len(), 8);
assert!(prefix.chars().all(|c| c.is_ascii_hexdigit()));
assert_ne!(share_key("report.pdf"), share_key("report.pdf"));
}
#[test]
fn to_download_url_maps_gate_page_to_dl_endpoint() {
assert_eq!(
to_download_url("https://abc.lambda-url.us-east-1.on.aws/d/8f3a/report.pdf"),
"https://abc.lambda-url.us-east-1.on.aws/dl/8f3a"
);
let presigned = "https://b.s3.amazonaws.com/ab12/report.pdf?X-Amz-Sig=x";
assert_eq!(to_download_url(presigned), presigned);
}
#[test]
fn filename_from_url_takes_last_segment_and_decodes() {
assert_eq!(
filename_from_url("https://b.s3.amazonaws.com/ab12/report.pdf?X-Amz-Sig=x"),
"report.pdf"
);
assert_eq!(
filename_from_url("https://b/ab12/quarterly%20report.pdf?q=1"),
"quarterly report.pdf"
);
}
#[test]
fn to_meta_url_only_matches_gate_page_urls() {
assert_eq!(
to_meta_url("https://share.example.com/d/8f3a/report.pdf"),
Some("https://share.example.com/meta/8f3a".to_string())
);
assert_eq!(
to_meta_url("https://b.s3.amazonaws.com/ab12/report.pdf?X-Amz-Sig=x"),
None
);
}
#[test]
fn gate_error_maps_known_codes() {
assert_eq!(
Error::Gone.to_string(),
"this share has expired or reached its download limit"
);
}
#[test]
fn gate_error_maps_recoverable_codes_to_typed_variants() {
let no_pin = gate_error(
401,
ureq::Response::new(401, "Unauthorized", "").unwrap(),
false,
);
assert!(matches!(no_pin, Error::PinRequired(_)));
assert_eq!(
no_pin.to_string(),
"this share is PIN-locked — pass --pin <PIN> (the sender sent it separately)"
);
let wrong_pin = gate_error(
401,
ureq::Response::new(401, "Unauthorized", r#"{"attempts_remaining":2}"#).unwrap(),
true,
);
assert!(matches!(wrong_pin, Error::PinRequired(_)));
assert_eq!(wrong_pin.to_string(), "wrong PIN — 2 attempts left");
let locked = gate_error(423, ureq::Response::new(423, "Locked", "").unwrap(), true);
assert!(matches!(locked, Error::Locked(_)));
assert_eq!(
locked.to_string(),
"this share is locked — too many wrong PINs. Ask the sender to re-share."
);
}
#[test]
fn share_info_simple_tier_key_carries_its_own_name() {
let info = share_info("ab12cd34/report.pdf", &HashMap::new());
assert_eq!(info.id, "ab12cd34");
assert_eq!(info.filename.as_deref(), Some("report.pdf"));
}
#[test]
fn share_info_full_tier_key_uses_the_ledger() {
let mut records = HashMap::new();
records.insert(
"890ad620f2c0b442".to_string(),
ledger::ShareRecord {
id: "890ad620f2c0b442".into(),
name: "vault.txt".into(),
from: None,
created_at: 0,
expires_at: 1_700_000_000,
downloads: 1,
},
);
let info = share_info("890ad620f2c0b442", &records);
assert_eq!(info.id, "890ad620f2c0b442");
assert_eq!(info.filename.as_deref(), Some("vault.txt"));
assert_eq!(info.expires_at, 1_700_000_000);
}
#[test]
fn share_info_unknown_full_tier_key_has_no_filename() {
let info = share_info("deadbeefdeadbeef", &HashMap::new());
assert_eq!(info.id, "deadbeefdeadbeef");
assert_eq!(info.filename, None);
assert_eq!(info.expires_at, 0);
}
#[test]
fn gate_enabled_reads_reserved_concurrency() {
assert!(!gate_enabled(br#"{"ReservedConcurrentExecutions": 0}"#));
assert!(gate_enabled(br#"{"ReservedConcurrentExecutions": 5}"#));
assert!(gate_enabled(b""));
}
#[test]
fn request_item_json_without_pin_matches_the_wire_contract() {
let item = request_item_json("abc123", 1, 1_700_000_000, None, "encrypted-trust-blob");
assert_eq!(
item,
serde_json::json!({
"id": {"S": "abc123"},
"kind": {"S": "request"},
"uploads_remaining": {"N": "1"},
"uploads_total": {"N": "1"},
"expires_at": {"N": "1700000000"},
"upload_attempts": {"N": "0"},
"meta": {"S": "encrypted-trust-blob"},
})
);
}
#[test]
fn request_item_json_with_pin_adds_pin_hash_and_pin_attempts() {
let item = request_item_json(
"abc123",
3,
1_700_000_000,
Some("deadbeefpinhash"),
"encrypted-trust-blob",
);
assert_eq!(
item,
serde_json::json!({
"id": {"S": "abc123"},
"kind": {"S": "request"},
"uploads_remaining": {"N": "3"},
"uploads_total": {"N": "3"},
"expires_at": {"N": "1700000000"},
"upload_attempts": {"N": "0"},
"meta": {"S": "encrypted-trust-blob"},
"pin_hash": {"S": "deadbeefpinhash"},
"pin_attempts": {"N": "0"},
})
);
}
#[test]
fn request_link_format_is_locked() {
let link = request_link("https://share.example.com", "abc123", "AAECAwQFBg");
assert_eq!(link, "https://share.example.com/r/abc123#AAECAwQFBg");
}
#[test]
fn rmeta_to_status_waiting_when_status_absent_or_waiting() {
let secret = [1u8; 32];
assert!(matches!(
rmeta_to_status(r#"{"status":"waiting"}"#, &secret),
RequestStatus::Waiting
));
assert!(matches!(
rmeta_to_status("{}", &secret),
RequestStatus::Waiting
));
}
#[test]
fn rmeta_to_status_failed_carries_the_reason() {
let secret = [1u8; 32];
let status = rmeta_to_status(r#"{"status":"failed","reason":"expired"}"#, &secret);
assert!(matches!(status, RequestStatus::Failed { reason } if reason == "expired"));
}
#[test]
fn rmeta_to_status_received_decrypts_the_filename() {
let secret = [2u8; 32];
let name_meta = crypto::encrypt_meta(&secret, br#"{"name":"invoice.pdf"}"#);
let body = serde_json::json!({
"status": "received",
"size": 4096,
"name_meta": name_meta,
})
.to_string();
let status = rmeta_to_status(&body, &secret);
match status {
RequestStatus::Received { name, size } => {
assert_eq!(name, "invoice.pdf");
assert_eq!(size, 4096);
}
other => panic!("expected Received, got {other:?}"),
}
}
#[test]
fn rmeta_to_status_received_falls_back_when_name_meta_is_undecryptable() {
let secret = [3u8; 32];
let wrong_secret = [4u8; 32];
let name_meta = crypto::encrypt_meta(&wrong_secret, br#"{"name":"invoice.pdf"}"#);
let body = serde_json::json!({
"status": "received",
"size": 10,
"name_meta": name_meta,
})
.to_string();
let status = rmeta_to_status(&body, &secret);
match status {
RequestStatus::Received { name, size } => {
assert_eq!(name, "(encrypted)");
assert_eq!(size, 10);
}
other => panic!("expected Received, got {other:?}"),
}
}
#[test]
fn rmeta_to_status_malformed_json_is_failed_not_a_panic() {
let secret = [1u8; 32];
let status = rmeta_to_status("not json", &secret);
assert!(
matches!(status, RequestStatus::Failed { reason } if reason == "malformed response from the gate")
);
}
#[test]
fn decrypt_meta_field_round_trips_and_is_none_on_failure() {
let secret = [5u8; 32];
let blob = crypto::encrypt_meta(&secret, br#"{"from":"Alex","msg":"the codes"}"#);
let v = serde_json::json!({ "meta": blob });
assert_eq!(
decrypt_meta_field(&v, "meta", &secret, "from").as_deref(),
Some("Alex")
);
assert_eq!(
decrypt_meta_field(&v, "meta", &secret, "msg").as_deref(),
Some("the codes")
);
assert_eq!(decrypt_meta_field(&v, "meta", &[9u8; 32], "from"), None);
assert_eq!(decrypt_meta_field(&v, "name_meta", &secret, "name"), None);
assert_eq!(decrypt_meta_field(&v, "meta", &secret, "desc"), None);
}
#[test]
fn create_request_requires_full_tier() {
let cfg = SelfHostedConfig {
bucket: "b".into(),
region: "us-east-1".into(),
profile: None,
endpoint: None,
table: None,
gate_url: None, distribution_id: None,
};
let backend = Backend::self_hosted("default", &cfg).unwrap();
let sh = SelfHosted::from_backend(&backend).unwrap();
let err = sh
.create_request(
CreateRequest {
description: "invoice".into(),
from: None,
message: None,
pin: None,
expires: Duration::from_secs(86_400),
uploads: 1,
},
&crate::progress::Silent,
)
.unwrap_err();
assert!(matches!(err, Error::Config(_)));
assert!(err.to_string().contains("full tier"), "{err}");
}
#[test]
fn gate_function_rejects_a_config_with_no_gate() {
let cfg = SelfHostedConfig {
bucket: "b".into(),
region: "us-east-1".into(),
profile: None,
endpoint: None,
table: None,
gate_url: None, distribution_id: None,
};
let backend = Backend::self_hosted("default", &cfg).unwrap();
let sh = SelfHosted::from_backend(&backend).unwrap();
let err = sh.gate_disable().unwrap_err();
assert_eq!(
err.to_string(),
"this config has no gate — it isn't full tier (`dove provision full`)"
);
}
}