1use crate::config::{Backend, SelfHostedConfig};
7use crate::error::{Error, Result};
8use crate::progress::Progress;
9use crate::request::{CreateRequest, NewRequest, RequestStatus};
10use crate::request_ledger::{self, RequestRecord};
11use crate::s3::Store;
12use crate::transfer::*;
13use crate::{crypto, duration as dur, ledger};
14use std::collections::HashMap;
15use std::fs::File;
16use std::io::{BufWriter, Read};
17use std::path::{Path, PathBuf};
18use std::process::Command;
19use std::time::{Duration, SystemTime, UNIX_EPOCH};
20
21pub struct GateState {
23 pub enabled: bool,
24}
25
26#[derive(Debug)]
27pub struct SelfHosted {
28 cfg: SelfHostedConfig,
29}
30
31impl SelfHosted {
32 pub fn from_backend(b: &Backend) -> Result<Self> {
38 let cfg: SelfHostedConfig = toml::Value::Table(b.config.clone())
39 .try_into()
40 .map_err(|e| Error::Config(format!("backend {}: {e}", b.name)))?;
41 Ok(Self { cfg })
42 }
43
44 pub fn adhoc() -> Self {
49 Self {
50 cfg: SelfHostedConfig::default(),
51 }
52 }
53
54 fn store(&self) -> Result<Store> {
55 Store::new(
56 &self.cfg.bucket,
57 &self.cfg.region,
58 self.cfg.endpoint.as_deref(),
59 )
60 .map_err(|e| Error::Aws(e.to_string()))
61 }
62
63 #[allow(clippy::too_many_arguments)]
69 fn share_full(
70 &self,
71 store: &Store,
72 source: &Path,
73 name: &str,
74 ttl: Duration,
75 downloads: u32,
76 pin: Option<String>,
77 from: Option<String>,
78 message: Option<String>,
79 progress: &dyn Progress,
80 ) -> Result<Share> {
81 let gate_secret = crate::secrets::Secrets::load()
85 .map_err(|e| Error::Config(e.to_string()))?
86 .gate_secret
87 .ok_or_else(|| {
88 Error::Config(
89 "no gate secret in secrets.toml — re-run `dove provision full`".into(),
90 )
91 })?;
92 let share_id =
93 crypto::new_share_id(&gate_secret).map_err(|e| Error::Other(e.to_string()))?;
94
95 let fragment_secret = crypto::gen_key();
100 let (content_key, pin_hash) = match &pin {
101 Some(p) => (
102 crypto::derive_key(p, &fragment_secret),
103 Some(crypto::pin_hash(&share_id, p)),
104 ),
105 None => (fragment_secret, None),
106 };
107
108 let ct = temp_ct_path();
109 progress.step("encrypting");
110 let encrypted = (|| -> Result<()> {
111 let reader = File::open(source)
112 .map_err(|e| Error::Other(format!("opening {}: {e}", source.display())))?;
113 let writer = BufWriter::new(
114 File::create(&ct)
115 .map_err(|e| Error::Other(format!("creating {}: {e}", ct.display())))?,
116 );
117 crypto::encrypt(&content_key, crypto::DEFAULT_CHUNK, reader, writer)
118 .map_err(|e| Error::Other(e.to_string()))
119 })();
120 if encrypted.is_ok() {
121 progress.done("encrypting");
122 }
123 encrypted?;
124
125 let object_key = share_id.clone(); let uploaded = store.put_file(&object_key, &ct, progress);
127 let _ = std::fs::remove_file(&ct);
128 let size = uploaded.map_err(|e| Error::Aws(e.to_string()))?;
129
130 let meta_json = serde_json::json!({
136 "name": name,
137 "from": from.as_deref().unwrap_or(""),
138 "msg": message.as_deref().unwrap_or(""),
139 })
140 .to_string();
141 let meta_blob = crypto::encrypt_meta(&fragment_secret, meta_json.as_bytes());
142
143 let expires_at = now_epoch() + ttl.as_secs();
144 progress.step("registering policy");
145 let registered = self.put_policy_item(
146 &share_id,
147 &object_key,
148 downloads,
149 expires_at,
150 size,
151 &meta_blob,
152 pin_hash.as_deref(),
153 );
154 if registered.is_ok() {
155 progress.done("registering policy");
156 }
157 registered?;
158
159 let _ = ledger::record(ledger::ShareRecord {
163 id: share_id.clone(),
164 name: name.to_string(),
165 from: from.clone(),
166 created_at: now_epoch(),
167 expires_at,
168 downloads,
169 });
170
171 let gate = self
172 .cfg
173 .gate_url
174 .as_ref()
175 .ok_or_else(|| Error::Config("no gate URL in config".into()))?;
176 let link = format!(
177 "{gate}/d/{share_id}#{}",
178 crypto::key_to_fragment(&fragment_secret)
179 );
180
181 Ok(Share {
182 id: share_id,
183 link,
184 size,
185 expires_at,
186 })
187 }
188
189 #[allow(clippy::too_many_arguments)]
193 fn put_policy_item(
194 &self,
195 id: &str,
196 s3_key: &str,
197 downloads: u32,
198 expires_at: u64,
199 size: u64,
200 meta_blob: &str,
201 pin_hash: Option<&str>,
202 ) -> Result<()> {
203 let table = self
204 .cfg
205 .table
206 .as_ref()
207 .ok_or_else(|| Error::Config("no table in config".into()))?;
208 let mut item = serde_json::json!({
213 "id": {"S": id},
214 "s3_key": {"S": s3_key},
215 "downloads_remaining": {"N": downloads.to_string()},
216 "downloads_total": {"N": downloads.to_string()},
217 "expires_at": {"N": expires_at.to_string()},
218 "created_at": {"N": now_epoch().to_string()},
219 "size": {"N": size.to_string()},
220 "meta": {"S": meta_blob},
221 });
222 if let Some(hash) = pin_hash {
223 item["pin_hash"] = serde_json::json!({"S": hash});
226 item["pin_attempts"] = serde_json::json!({"N": "0"});
227 }
228 let item = item.to_string();
229 let secrets = crate::secrets::Secrets::load()
230 .map_err(|e| Error::Config(format!("loading scoped credentials: {e}")))?;
231 let mut cmd = Command::new("aws");
232 cmd.env("AWS_ACCESS_KEY_ID", &secrets.access_key_id)
233 .env("AWS_SECRET_ACCESS_KEY", &secrets.secret_access_key)
234 .env("AWS_DEFAULT_REGION", &self.cfg.region);
235 cmd.args([
236 "dynamodb",
237 "put-item",
238 "--table-name",
239 table,
240 "--item",
241 &item,
242 ]);
243 let out = cmd
244 .output()
245 .map_err(|e| Error::Aws(format!("running aws dynamodb put-item: {e}")))?;
246 if !out.status.success() {
247 return Err(Error::Aws(format!(
248 "registering the share policy failed: {}",
249 String::from_utf8_lossy(&out.stderr).trim()
250 )));
251 }
252 Ok(())
253 }
254
255 pub fn create_request(&self, req: CreateRequest, p: &dyn Progress) -> Result<NewRequest> {
263 if !self.cfg.is_full() {
264 return Err(Error::Config(
265 "dove request needs the full tier (a gate + DynamoDB table) — provision it \
266 with `dove provision full`."
267 .into(),
268 ));
269 }
270
271 let gate_secret = crate::secrets::Secrets::load()
272 .map_err(|e| Error::Config(e.to_string()))?
273 .gate_secret
274 .ok_or_else(|| {
275 Error::Config(
276 "no gate secret in secrets.toml — re-run `dove provision full`".into(),
277 )
278 })?;
279 let id = crypto::new_share_id(&gate_secret).map_err(|e| Error::Other(e.to_string()))?;
280
281 let fragment_secret = crypto::gen_key();
287 let pin_hash = req.pin.as_deref().map(|pin| crypto::pin_hash(&id, pin));
288
289 let meta_json = serde_json::json!({
294 "from": req.from.as_deref().unwrap_or(""),
295 "msg": req.message.as_deref().unwrap_or(""),
296 "desc": req.description,
297 })
298 .to_string();
299 let meta_blob = crypto::encrypt_meta(&fragment_secret, meta_json.as_bytes());
300
301 let expires_at = now_epoch() + req.expires.as_secs();
302 p.step("registering request");
303 let registered = self.put_request_item(
304 &id,
305 req.uploads,
306 expires_at,
307 pin_hash.as_deref(),
308 &meta_blob,
309 );
310 if registered.is_ok() {
311 p.done("registering request");
312 }
313 registered?;
314
315 let fragment = crypto::key_to_fragment(&fragment_secret);
325 if let Err(e) = request_ledger::record(RequestRecord {
326 id: id.clone(),
327 fragment: fragment.clone(),
328 description: req.description.clone(),
329 created_at: now_epoch(),
330 }) {
331 p.field(
332 "warning",
333 &format!(
334 "couldn't save this request locally ({e}) — keep the printed link; \
335 it carries your only decryption key"
336 ),
337 );
338 }
339
340 let gate = self
341 .cfg
342 .gate_url
343 .as_ref()
344 .ok_or_else(|| Error::Config("no gate URL in config".into()))?;
345 let link = request_link(gate, &id, &fragment);
346
347 Ok(NewRequest { id, link })
348 }
349
350 pub fn request_status(&self, rec: &RequestRecord) -> Result<RequestStatus> {
354 let gate = self
355 .cfg
356 .gate_url
357 .as_ref()
358 .ok_or_else(|| Error::Config("no gate URL in config".into()))?;
359 let body = fetch_rmeta(gate, &rec.id)?;
360 let fragment_secret =
361 crypto::key_from_fragment(&rec.fragment).map_err(|e| Error::Other(e.to_string()))?;
362 Ok(rmeta_to_status(&body, &fragment_secret))
363 }
364
365 pub fn collect_request(
371 &self,
372 rec: &RequestRecord,
373 out: Option<PathBuf>,
374 p: &dyn Progress,
375 ) -> Result<Fetched> {
376 let gate = self
377 .cfg
378 .gate_url
379 .as_ref()
380 .ok_or_else(|| Error::Config("no gate URL in config".into()))?;
381 let body = fetch_rmeta(gate, &rec.id)?;
382 let v: serde_json::Value = serde_json::from_str(&body)
383 .map_err(|e| Error::Other(format!("parsing the gate's response: {e}")))?;
384 if v["status"].as_str() != Some("received") {
385 return Err(Error::Other(
386 "this request hasn't been fulfilled yet".into(),
387 ));
388 }
389
390 let fragment_secret =
391 crypto::key_from_fragment(&rec.fragment).map_err(|e| Error::Other(e.to_string()))?;
392
393 let name = decrypt_meta_field(&v, "name_meta", &fragment_secret, "name")
398 .unwrap_or_else(|| "upload".to_string());
399 let from =
401 decrypt_meta_field(&v, "meta", &fragment_secret, "from").filter(|s| !s.is_empty());
402 let message =
403 decrypt_meta_field(&v, "meta", &fragment_secret, "msg").filter(|s| !s.is_empty());
404
405 let out_path = out.unwrap_or_else(|| PathBuf::from(&name));
406
407 let store = self.store()?;
408 let dl_url = store.presign_get(&format!("req/{}", rec.id), Duration::from_secs(300));
409 let resp = match ureq::get(&dl_url).call() {
410 Ok(r) => r,
411 Err(ureq::Error::Status(code, _)) => {
412 return Err(Error::Aws(format!(
413 "downloading the upload failed: HTTP {code}"
414 )))
415 }
416 Err(e @ ureq::Error::Transport(_)) => {
417 return Err(Error::Network(format!(
418 "downloading the upload failed: {}",
419 transport_err(e)
420 )))
421 }
422 };
423 let total: u64 = resp
424 .header("Content-Length")
425 .and_then(|s| s.parse().ok())
426 .unwrap_or(0);
427 let reader = CountingReader {
428 inner: resp.into_reader(),
429 seen: 0,
430 total,
431 progress: p,
432 };
433 let file = BufWriter::new(
434 File::create(&out_path)
435 .map_err(|e| Error::Other(format!("creating {}: {e}", out_path.display())))?,
436 );
437 crypto::decrypt(&fragment_secret, reader, file).map_err(|_| Error::Integrity)?;
438
439 Ok(Fetched {
440 path: out_path,
441 from,
442 message,
443 })
444 }
445
446 fn put_request_item(
450 &self,
451 id: &str,
452 uploads: u32,
453 expires_at: u64,
454 pin_hash: Option<&str>,
455 meta_blob: &str,
456 ) -> Result<()> {
457 let table = self
458 .cfg
459 .table
460 .as_ref()
461 .ok_or_else(|| Error::Config("no table in config".into()))?;
462 let item = request_item_json(id, uploads, expires_at, pin_hash, meta_blob).to_string();
463 let secrets = crate::secrets::Secrets::load()
464 .map_err(|e| Error::Config(format!("loading scoped credentials: {e}")))?;
465 let mut cmd = Command::new("aws");
466 cmd.env("AWS_ACCESS_KEY_ID", &secrets.access_key_id)
467 .env("AWS_SECRET_ACCESS_KEY", &secrets.secret_access_key)
468 .env("AWS_DEFAULT_REGION", &self.cfg.region);
469 cmd.args([
470 "dynamodb",
471 "put-item",
472 "--table-name",
473 table,
474 "--item",
475 &item,
476 ]);
477 let out = cmd
478 .output()
479 .map_err(|e| Error::Aws(format!("running aws dynamodb put-item: {e}")))?;
480 if !out.status.success() {
481 return Err(Error::Aws(format!(
482 "registering the request failed: {}",
483 String::from_utf8_lossy(&out.stderr).trim()
484 )));
485 }
486 Ok(())
487 }
488
489 pub fn gate_disable(&self) -> Result<()> {
494 let (profile, function) = self.gate_function()?;
495 run_aws(
496 profile.as_deref(),
497 &[
498 "lambda",
499 "put-function-concurrency",
500 "--function-name",
501 &function,
502 "--reserved-concurrent-executions",
503 "0",
504 ],
505 )
506 }
507
508 pub fn gate_enable(&self) -> Result<()> {
510 let (profile, function) = self.gate_function()?;
511 run_aws(
512 profile.as_deref(),
513 &[
514 "lambda",
515 "delete-function-concurrency",
516 "--function-name",
517 &function,
518 ],
519 )
520 }
521
522 pub fn gate_status(&self) -> Result<GateState> {
524 let (profile, function) = self.gate_function()?;
525 let out = aws_cmd(
526 profile.as_deref(),
527 &[
528 "lambda",
529 "get-function-concurrency",
530 "--function-name",
531 &function,
532 "--output",
533 "json",
534 ],
535 )?;
536 Ok(GateState {
537 enabled: gate_enabled(&out.stdout),
538 })
539 }
540
541 fn gate_function(&self) -> Result<(Option<String>, String)> {
544 if !self.cfg.is_full() {
545 return Err(Error::Other(
546 "this config has no gate — it isn't full tier (`dove provision full`)".into(),
547 ));
548 }
549 let out = aws_cmd(
550 self.cfg.profile.as_deref(),
551 &[
552 "sts",
553 "get-caller-identity",
554 "--query",
555 "Account",
556 "--output",
557 "text",
558 ],
559 )?;
560 if !out.status.success() {
561 return Err(Error::Aws(format!(
562 "resolving the AWS account: {}",
563 String::from_utf8_lossy(&out.stderr).trim()
564 )));
565 }
566 let account = String::from_utf8_lossy(&out.stdout).trim().to_string();
567 Ok((self.cfg.profile.clone(), format!("dove-gate-{account}")))
568 }
569}
570
571impl Transfer for SelfHosted {
572 fn share(&self, req: ShareRequest, progress: &dyn Progress) -> Result<Share> {
573 if req.pin.is_some() && !self.cfg.is_full() {
574 return Err(Error::Config(
575 "--pin is a full-tier feature: it's checked at the gate, which the simple tier \
576 doesn't have. Provision it with `dove provision full`."
577 .into(),
578 ));
579 }
580 if (req.from.is_some() || req.message.is_some()) && !self.cfg.is_full() {
581 return Err(Error::Config(
582 "--from/--message ride an encrypted metadata blob in the full-tier link. \
583 Provision it with `dove provision full`."
584 .into(),
585 ));
586 }
587
588 let name = req
589 .path
590 .file_name()
591 .and_then(|n| n.to_str())
592 .ok_or_else(|| Error::Other(format!("{} has no usable filename", req.path.display())))?
593 .to_string();
594 let store = self.store()?;
595
596 if self.cfg.is_full() {
597 return self.share_full(
598 &store,
599 &req.path,
600 &name,
601 req.expires,
602 req.downloads.unwrap_or(100),
603 req.pin,
604 req.from,
605 req.message,
606 progress,
607 );
608 }
609
610 if !dur::within_presign_limit(req.expires) {
612 return Err(Error::Other(format!(
613 "a share expiry of {} is over the 7-day limit for the simple tier's presigned \
614 links. Use 7d or less. Longer-lived shares and download limits are the full \
615 tier — provision it with `dove provision full`.",
616 dur::human(req.expires)
617 )));
618 }
619 let (upload_path, ct_temp, fragment) = if req.encrypt {
620 let content_key = crypto::gen_key();
621 let ct = temp_ct_path();
622 progress.step("encrypting");
623 let encrypted = (|| -> Result<()> {
624 let reader = File::open(&req.path)
625 .map_err(|e| Error::Other(format!("opening {}: {e}", req.path.display())))?;
626 let writer = BufWriter::new(
627 File::create(&ct)
628 .map_err(|e| Error::Other(format!("creating {}: {e}", ct.display())))?,
629 );
630 crypto::encrypt(&content_key, crypto::DEFAULT_CHUNK, reader, writer)
631 .map_err(|e| Error::Other(e.to_string()))
632 })();
633 if encrypted.is_ok() {
634 progress.done("encrypting");
635 }
636 encrypted?;
637 (
638 ct.clone(),
639 Some(ct),
640 Some(crypto::key_to_fragment(&content_key)),
641 )
642 } else {
643 (req.path.clone(), None, None)
644 };
645
646 let object_key = share_key(&name);
647 let uploaded = store.put_file(&object_key, &upload_path, progress);
648 if let Some(t) = ct_temp {
649 let _ = std::fs::remove_file(t);
650 }
651 let size = uploaded.map_err(|e| Error::Aws(e.to_string()))?;
652
653 let mut link = store.presign_get(&object_key, req.expires);
654 if let Some(frag) = fragment {
655 link.push('#');
656 link.push_str(&frag);
657 }
658 let expires_at = now_epoch() + req.expires.as_secs();
659 let id = object_key
662 .split_once('/')
663 .map(|(id, _)| id.to_string())
664 .unwrap_or(object_key);
665
666 Ok(Share {
667 id,
668 link,
669 size,
670 expires_at,
671 })
672 }
673
674 fn get(&self, req: GetRequest, progress: &dyn Progress) -> Result<Fetched> {
675 let (base, fragment) = req.url.rsplit_once('#').ok_or_else(|| {
676 Error::Other(
677 "this link has no key — it isn't a dove-encrypted share (nothing after `#`)".into(),
678 )
679 })?;
680 let secret = crypto::key_from_fragment(fragment.split('.').next().unwrap_or(fragment))
684 .map_err(|e| Error::Other(e.to_string()))?;
685 let key = match &req.pin {
686 Some(p) => crypto::derive_key(p, &secret),
687 None => secret,
688 };
689
690 let meta = fetch_meta(base, &secret);
693 let from = meta
694 .as_ref()
695 .map(|(_, from, _)| from.clone())
696 .filter(|s| !s.is_empty());
697 let message = meta
698 .as_ref()
699 .map(|(_, _, msg)| msg.clone())
700 .filter(|s| !s.is_empty());
701 let meta_name = meta
702 .as_ref()
703 .map(|(n, _, _)| n.clone())
704 .filter(|n| !n.is_empty());
705
706 if let Some(f) = &from {
710 progress.field("from", f);
711 }
712 if let Some(m) = &message {
713 progress.field("message", m);
714 }
715
716 let out_path = req
717 .out
718 .clone()
719 .or_else(|| meta_name.map(PathBuf::from))
720 .unwrap_or_else(|| PathBuf::from(filename_from_url(base)));
721
722 let mut fetch_url = to_download_url(base);
728 if let Some(p) = &req.pin {
729 if fetch_url.contains("/dl/") {
730 fetch_url.push_str(&format!("?pin={p}"));
731 }
732 }
733 let resp = match ureq::get(&fetch_url).call() {
734 Ok(r) => r,
735 Err(ureq::Error::Status(code, resp)) => {
736 return Err(gate_error(code, resp, req.pin.is_some()))
737 }
738 Err(e @ ureq::Error::Transport(_)) => {
739 return Err(Error::Network(format!(
740 "fetching the share failed: {}",
741 transport_err(e)
742 )))
743 }
744 };
745 let total: u64 = resp
746 .header("Content-Length")
747 .and_then(|s| s.parse().ok())
748 .unwrap_or(0);
749
750 let reader = CountingReader {
751 inner: resp.into_reader(),
752 seen: 0,
753 total,
754 progress,
755 };
756 let file = BufWriter::new(
757 File::create(&out_path)
758 .map_err(|e| Error::Other(format!("creating {}: {e}", out_path.display())))?,
759 );
760 crypto::decrypt(&key, reader, file).map_err(|_| Error::Integrity)?;
761
762 Ok(Fetched {
763 path: out_path,
764 from,
765 message,
766 })
767 }
768
769 fn list(&self) -> Result<Vec<ShareInfo>> {
774 let store = self.store()?;
775 let keys = store.list("").map_err(|e| Error::Aws(e.to_string()))?;
776 let records: HashMap<String, ledger::ShareRecord> = ledger::load()
777 .unwrap_or_default()
778 .into_iter()
779 .map(|r| (r.id.clone(), r))
780 .collect();
781 Ok(keys.iter().map(|k| share_info(k, &records)).collect())
782 }
783
784 fn revoke(&self, id: &str) -> Result<()> {
788 let store = self.store()?;
789 let keys = store.list(id).map_err(|e| Error::Aws(e.to_string()))?;
790 let key = keys
791 .first()
792 .ok_or_else(|| Error::Other(format!("no share with id {id}")))?;
793 store
794 .delete_object(key)
795 .map_err(|e| Error::Aws(e.to_string()))?;
796 let _ = ledger::remove(id);
797 Ok(())
798 }
799
800 fn status(&self) -> Result<BackendStatus> {
801 let mut summary = vec![
802 ("bucket".to_string(), self.cfg.bucket.clone()),
803 ("region".to_string(), self.cfg.region.clone()),
804 ];
805 if let Some(t) = &self.cfg.table {
806 summary.push(("table".into(), t.clone()));
807 }
808 if let Some(g) = &self.cfg.gate_url {
809 summary.push(("gate".into(), g.clone()));
810 }
811 Ok(BackendStatus { summary })
812 }
813}
814
815fn now_epoch() -> u64 {
816 SystemTime::now()
817 .duration_since(UNIX_EPOCH)
818 .map(|d| d.as_secs())
819 .unwrap_or(0)
820}
821
822fn temp_ct_path() -> PathBuf {
824 let mut b = [0u8; 8];
825 getrandom::getrandom(&mut b).expect("OS RNG unavailable");
826 let hex: String = b.iter().map(|x| format!("{x:02x}")).collect();
827 std::env::temp_dir().join(format!("dove-{hex}.zip"))
828}
829
830fn share_info(key: &str, records: &HashMap<String, ledger::ShareRecord>) -> ShareInfo {
836 match key.split_once('/') {
837 Some((id, name)) => ShareInfo {
838 id: id.to_string(),
839 filename: Some(name.to_string()),
840 expires_at: records.get(id).map(|r| r.expires_at).unwrap_or(0),
841 },
842 None => {
843 let rec = records.get(key);
844 ShareInfo {
845 id: key.to_string(),
846 filename: rec.map(|r| r.name.clone()),
847 expires_at: rec.map(|r| r.expires_at).unwrap_or(0),
848 }
849 }
850 }
851}
852
853fn share_key(filename: &str) -> String {
856 let mut b = [0u8; 4];
857 getrandom::getrandom(&mut b).expect("OS RNG unavailable");
858 format!(
859 "{:02x}{:02x}{:02x}{:02x}/{filename}",
860 b[0], b[1], b[2], b[3]
861 )
862}
863
864fn request_item_json(
872 id: &str,
873 uploads: u32,
874 expires_at: u64,
875 pin_hash: Option<&str>,
876 meta_blob: &str,
877) -> serde_json::Value {
878 let mut item = serde_json::json!({
879 "id": {"S": id},
880 "kind": {"S": "request"},
881 "uploads_remaining": {"N": uploads.to_string()},
882 "uploads_total": {"N": uploads.to_string()},
883 "expires_at": {"N": expires_at.to_string()},
884 "upload_attempts": {"N": "0"},
885 "meta": {"S": meta_blob},
886 });
887 if let Some(hash) = pin_hash {
888 item["pin_hash"] = serde_json::json!({"S": hash});
889 item["pin_attempts"] = serde_json::json!({"N": "0"});
890 }
891 item
892}
893
894fn request_link(gate: &str, id: &str, fragment: &str) -> String {
898 format!("{gate}/r/{id}#{fragment}")
899}
900
901fn fetch_rmeta(gate: &str, id: &str) -> Result<String> {
906 let url = format!("{gate}/rmeta/{id}");
907 match ureq::get(&url).call() {
908 Ok(r) => r
909 .into_string()
910 .map_err(|e| Error::Network(format!("reading the gate's response: {e}"))),
911 Err(ureq::Error::Status(404, _)) => Err(Error::NotFound),
912 Err(ureq::Error::Status(code, _)) => Err(Error::Other(format!(
913 "checking request status failed: HTTP {code}"
914 ))),
915 Err(e @ ureq::Error::Transport(_)) => Err(Error::Network(format!(
916 "checking request status failed: {}",
917 transport_err(e)
918 ))),
919 }
920}
921
922fn rmeta_to_status(body: &str, fragment_secret: &[u8; 32]) -> RequestStatus {
929 let v: serde_json::Value = match serde_json::from_str(body) {
930 Ok(v) => v,
931 Err(_) => {
932 return RequestStatus::Failed {
933 reason: "malformed response from the gate".into(),
934 }
935 }
936 };
937 match v["status"].as_str().unwrap_or("waiting") {
938 "received" => {
939 let size = v["size"].as_u64().unwrap_or(0);
940 let name = decrypt_meta_field(&v, "name_meta", fragment_secret, "name")
941 .unwrap_or_else(|| "(encrypted)".to_string());
942 RequestStatus::Received { name, size }
943 }
944 "failed" => RequestStatus::Failed {
945 reason: v["reason"].as_str().unwrap_or("failed").to_string(),
946 },
947 _ => RequestStatus::Waiting,
948 }
949}
950
951fn decrypt_meta_field(
958 v: &serde_json::Value,
959 blob_field: &str,
960 fragment_secret: &[u8; 32],
961 json_key: &str,
962) -> Option<String> {
963 let blob = v[blob_field].as_str()?;
964 let plain = crypto::decrypt_meta(fragment_secret, blob).ok()?;
965 let j: serde_json::Value = serde_json::from_slice(&plain).ok()?;
966 j[json_key].as_str().map(|s| s.to_string())
967}
968
969fn fetch_meta(base: &str, secret: &[u8; 32]) -> Option<(String, String, String)> {
974 let meta_url = to_meta_url(base)?;
975 let body = ureq::get(&meta_url).call().ok()?.into_string().ok()?;
976 let v: serde_json::Value = serde_json::from_str(&body).ok()?;
977 let blob = v["meta"].as_str().filter(|s| !s.is_empty())?;
978 let plain = crypto::decrypt_meta(secret, blob).ok()?;
979 let j: serde_json::Value = serde_json::from_slice(&plain).ok()?;
980 let s = |k: &str| j[k].as_str().unwrap_or("").to_string();
981 Some((s("name"), s("from"), s("msg")))
982}
983
984fn to_meta_url(base: &str) -> Option<String> {
988 let scheme_end = base.find("://")?;
989 let after = &base[scheme_end + 3..];
990 let slash = after.find('/')?;
991 let host = &base[..scheme_end + 3 + slash];
992 let path = after[slash..].split('?').next().unwrap_or("");
993 let segs: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
994 (segs.len() >= 2 && segs[0] == "d").then(|| format!("{host}/meta/{}", segs[1]))
995}
996
997fn filename_from_url(base: &str) -> String {
1001 let path = base.split('?').next().unwrap_or(base);
1002 let name = path.rsplit('/').next().unwrap_or("download");
1003 let decoded = percent_decode(name);
1004 if decoded.is_empty() {
1005 "download".to_string()
1006 } else {
1007 decoded
1008 }
1009}
1010
1011fn to_download_url(base: &str) -> String {
1015 if let Some(scheme_end) = base.find("://") {
1016 let after = &base[scheme_end + 3..];
1017 if let Some(slash) = after.find('/') {
1018 let host = &base[..scheme_end + 3 + slash];
1019 let path = after[slash..].split('?').next().unwrap_or("");
1020 let segs: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
1021 if segs.len() >= 2 && segs[0] == "d" {
1022 return format!("{host}/dl/{}", segs[1]);
1023 }
1024 }
1025 }
1026 base.to_string()
1027}
1028
1029fn percent_decode(s: &str) -> String {
1031 let bytes = s.as_bytes();
1032 let mut out = Vec::with_capacity(bytes.len());
1033 let mut i = 0;
1034 while i < bytes.len() {
1035 if bytes[i] == b'%' && i + 2 < bytes.len() {
1036 if let Ok(b) = u8::from_str_radix(&s[i + 1..i + 3], 16) {
1037 out.push(b);
1038 i += 3;
1039 continue;
1040 }
1041 }
1042 out.push(bytes[i]);
1043 i += 1;
1044 }
1045 String::from_utf8_lossy(&out).into_owned()
1046}
1047
1048fn gate_error(code: u16, resp: ureq::Response, had_pin: bool) -> Error {
1051 let body = resp.into_string().unwrap_or_default();
1052 let json: serde_json::Value = serde_json::from_str(&body).unwrap_or(serde_json::Value::Null);
1053 let attempts = json.get("attempts_remaining").and_then(|v| v.as_u64());
1054 match code {
1055 401 if !had_pin => Error::PinRequired(
1056 "this share is PIN-locked — pass --pin <PIN> (the sender sent it separately)".into(),
1057 ),
1058 401 => Error::PinRequired(match attempts {
1059 Some(n) => format!(
1060 "wrong PIN — {n} attempt{} left",
1061 if n == 1 { "" } else { "s" }
1062 ),
1063 None => "wrong PIN".into(),
1064 }),
1065 423 => Error::Locked(
1066 "this share is locked — too many wrong PINs. Ask the sender to re-share.".into(),
1067 ),
1068 410 => Error::Gone,
1069 _ => Error::Other(format!(
1070 "the share link returned HTTP {code} — it may have expired or been revoked"
1071 )),
1072 }
1073}
1074
1075fn run_aws(profile: Option<&str>, args: &[&str]) -> Result<()> {
1077 let out = aws_cmd(profile, args)?;
1078 if out.status.success() {
1079 return Ok(());
1080 }
1081 Err(Error::Aws(format!(
1082 "aws {} failed: {}",
1083 args.join(" "),
1084 String::from_utf8_lossy(&out.stderr).trim()
1085 )))
1086}
1087
1088fn aws_cmd(profile: Option<&str>, args: &[&str]) -> Result<std::process::Output> {
1091 let mut cmd = Command::new("aws");
1092 if let Some(p) = profile {
1093 cmd.args(["--profile", p]);
1094 }
1095 cmd.args(args)
1096 .output()
1097 .map_err(|e| Error::Aws(format!("running aws {}: {e}", args.join(" "))))
1098}
1099
1100fn gate_enabled(stdout: &[u8]) -> bool {
1105 let reserved = serde_json::from_slice::<serde_json::Value>(stdout)
1106 .ok()
1107 .and_then(|v| v["ReservedConcurrentExecutions"].as_i64());
1108 reserved != Some(0)
1109}
1110
1111fn transport_err(e: ureq::Error) -> String {
1113 match e {
1114 ureq::Error::Status(code, _) => format!("HTTP {code}"),
1115 ureq::Error::Transport(t) => t.kind().to_string(),
1116 }
1117}
1118
1119struct CountingReader<'a, R> {
1121 inner: R,
1122 seen: u64,
1123 total: u64,
1124 progress: &'a dyn Progress,
1125}
1126
1127impl<R: Read> Read for CountingReader<'_, R> {
1128 fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
1129 let k = self.inner.read(buf)?;
1130 self.seen += k as u64;
1131 self.progress.bytes(self.seen, self.total);
1132 Ok(k)
1133 }
1134}
1135
1136#[cfg(test)]
1137mod tests {
1138 use super::*;
1139
1140 #[test]
1141 fn status_summarizes_bucket_region_table_and_gate() {
1142 let cfg = SelfHostedConfig {
1143 bucket: "dove-shares-example".into(),
1144 region: "us-east-1".into(),
1145 profile: None,
1146 endpoint: None,
1147 table: Some("dove-shares-example".into()),
1148 gate_url: Some("https://share.example.com".into()),
1149 distribution_id: None,
1150 };
1151 let backend = Backend::self_hosted("default", &cfg).unwrap();
1152
1153 let sh = SelfHosted::from_backend(&backend).unwrap();
1154 let status = sh.status().unwrap();
1155
1156 assert!(status
1157 .summary
1158 .contains(&("bucket".to_string(), "dove-shares-example".to_string())));
1159 assert!(status
1160 .summary
1161 .contains(&("region".to_string(), "us-east-1".to_string())));
1162 assert!(status
1163 .summary
1164 .contains(&("table".to_string(), "dove-shares-example".to_string())));
1165 assert!(status
1166 .summary
1167 .contains(&("gate".to_string(), "https://share.example.com".to_string())));
1168 }
1169
1170 #[test]
1171 fn from_backend_is_io_free_even_with_no_secrets_present() {
1172 let cfg = SelfHostedConfig {
1176 bucket: "b".into(),
1177 region: "us-east-1".into(),
1178 profile: None,
1179 endpoint: None,
1180 table: None,
1181 gate_url: None,
1182 distribution_id: None,
1183 };
1184 let backend = Backend::self_hosted("default", &cfg).unwrap();
1185 assert!(SelfHosted::from_backend(&backend).is_ok());
1186 }
1187
1188 #[test]
1189 fn adhoc_is_io_free_and_not_full() {
1190 let sh = SelfHosted::adhoc();
1191 assert!(!sh.cfg.is_full());
1192 }
1193
1194 #[test]
1195 fn share_key_has_random_prefix_and_keeps_the_name() {
1196 let k = share_key("report.pdf");
1197 assert!(k.ends_with("/report.pdf"), "{k}");
1198 let prefix = k.split('/').next().unwrap();
1199 assert_eq!(prefix.len(), 8);
1200 assert!(prefix.chars().all(|c| c.is_ascii_hexdigit()));
1201 assert_ne!(share_key("report.pdf"), share_key("report.pdf"));
1203 }
1204
1205 #[test]
1206 fn to_download_url_maps_gate_page_to_dl_endpoint() {
1207 assert_eq!(
1208 to_download_url("https://abc.lambda-url.us-east-1.on.aws/d/8f3a/report.pdf"),
1209 "https://abc.lambda-url.us-east-1.on.aws/dl/8f3a"
1210 );
1211 let presigned = "https://b.s3.amazonaws.com/ab12/report.pdf?X-Amz-Sig=x";
1213 assert_eq!(to_download_url(presigned), presigned);
1214 }
1215
1216 #[test]
1217 fn filename_from_url_takes_last_segment_and_decodes() {
1218 assert_eq!(
1219 filename_from_url("https://b.s3.amazonaws.com/ab12/report.pdf?X-Amz-Sig=x"),
1220 "report.pdf"
1221 );
1222 assert_eq!(
1223 filename_from_url("https://b/ab12/quarterly%20report.pdf?q=1"),
1224 "quarterly report.pdf"
1225 );
1226 }
1227
1228 #[test]
1229 fn to_meta_url_only_matches_gate_page_urls() {
1230 assert_eq!(
1231 to_meta_url("https://share.example.com/d/8f3a/report.pdf"),
1232 Some("https://share.example.com/meta/8f3a".to_string())
1233 );
1234 assert_eq!(
1235 to_meta_url("https://b.s3.amazonaws.com/ab12/report.pdf?X-Amz-Sig=x"),
1236 None
1237 );
1238 }
1239
1240 #[test]
1241 fn gate_error_maps_known_codes() {
1242 assert_eq!(
1244 Error::Gone.to_string(),
1245 "this share has expired or reached its download limit"
1246 );
1247 }
1248
1249 #[test]
1253 fn gate_error_maps_recoverable_codes_to_typed_variants() {
1254 let no_pin = gate_error(
1255 401,
1256 ureq::Response::new(401, "Unauthorized", "").unwrap(),
1257 false,
1258 );
1259 assert!(matches!(no_pin, Error::PinRequired(_)));
1260 assert_eq!(
1261 no_pin.to_string(),
1262 "this share is PIN-locked — pass --pin <PIN> (the sender sent it separately)"
1263 );
1264
1265 let wrong_pin = gate_error(
1266 401,
1267 ureq::Response::new(401, "Unauthorized", r#"{"attempts_remaining":2}"#).unwrap(),
1268 true,
1269 );
1270 assert!(matches!(wrong_pin, Error::PinRequired(_)));
1271 assert_eq!(wrong_pin.to_string(), "wrong PIN — 2 attempts left");
1272
1273 let locked = gate_error(423, ureq::Response::new(423, "Locked", "").unwrap(), true);
1274 assert!(matches!(locked, Error::Locked(_)));
1275 assert_eq!(
1276 locked.to_string(),
1277 "this share is locked — too many wrong PINs. Ask the sender to re-share."
1278 );
1279 }
1280
1281 #[test]
1282 fn share_info_simple_tier_key_carries_its_own_name() {
1283 let info = share_info("ab12cd34/report.pdf", &HashMap::new());
1284 assert_eq!(info.id, "ab12cd34");
1285 assert_eq!(info.filename.as_deref(), Some("report.pdf"));
1286 }
1287
1288 #[test]
1289 fn share_info_full_tier_key_uses_the_ledger() {
1290 let mut records = HashMap::new();
1291 records.insert(
1292 "890ad620f2c0b442".to_string(),
1293 ledger::ShareRecord {
1294 id: "890ad620f2c0b442".into(),
1295 name: "vault.txt".into(),
1296 from: None,
1297 created_at: 0,
1298 expires_at: 1_700_000_000,
1299 downloads: 1,
1300 },
1301 );
1302 let info = share_info("890ad620f2c0b442", &records);
1303 assert_eq!(info.id, "890ad620f2c0b442");
1304 assert_eq!(info.filename.as_deref(), Some("vault.txt"));
1305 assert_eq!(info.expires_at, 1_700_000_000);
1306 }
1307
1308 #[test]
1309 fn share_info_unknown_full_tier_key_has_no_filename() {
1310 let info = share_info("deadbeefdeadbeef", &HashMap::new());
1311 assert_eq!(info.id, "deadbeefdeadbeef");
1312 assert_eq!(info.filename, None);
1313 assert_eq!(info.expires_at, 0);
1314 }
1315
1316 #[test]
1317 fn gate_enabled_reads_reserved_concurrency() {
1318 assert!(!gate_enabled(br#"{"ReservedConcurrentExecutions": 0}"#));
1319 assert!(gate_enabled(br#"{"ReservedConcurrentExecutions": 5}"#));
1320 assert!(gate_enabled(b""));
1322 }
1323
1324 #[test]
1325 fn request_item_json_without_pin_matches_the_wire_contract() {
1326 let item = request_item_json("abc123", 1, 1_700_000_000, None, "encrypted-trust-blob");
1327 assert_eq!(
1328 item,
1329 serde_json::json!({
1330 "id": {"S": "abc123"},
1331 "kind": {"S": "request"},
1332 "uploads_remaining": {"N": "1"},
1333 "uploads_total": {"N": "1"},
1334 "expires_at": {"N": "1700000000"},
1335 "upload_attempts": {"N": "0"},
1336 "meta": {"S": "encrypted-trust-blob"},
1337 })
1338 );
1339 }
1340
1341 #[test]
1342 fn request_item_json_with_pin_adds_pin_hash_and_pin_attempts() {
1343 let item = request_item_json(
1344 "abc123",
1345 3,
1346 1_700_000_000,
1347 Some("deadbeefpinhash"),
1348 "encrypted-trust-blob",
1349 );
1350 assert_eq!(
1351 item,
1352 serde_json::json!({
1353 "id": {"S": "abc123"},
1354 "kind": {"S": "request"},
1355 "uploads_remaining": {"N": "3"},
1356 "uploads_total": {"N": "3"},
1357 "expires_at": {"N": "1700000000"},
1358 "upload_attempts": {"N": "0"},
1359 "meta": {"S": "encrypted-trust-blob"},
1360 "pin_hash": {"S": "deadbeefpinhash"},
1361 "pin_attempts": {"N": "0"},
1362 })
1363 );
1364 }
1365
1366 #[test]
1367 fn request_link_format_is_locked() {
1368 let link = request_link("https://share.example.com", "abc123", "AAECAwQFBg");
1369 assert_eq!(link, "https://share.example.com/r/abc123#AAECAwQFBg");
1370 }
1371
1372 #[test]
1373 fn rmeta_to_status_waiting_when_status_absent_or_waiting() {
1374 let secret = [1u8; 32];
1375 assert!(matches!(
1376 rmeta_to_status(r#"{"status":"waiting"}"#, &secret),
1377 RequestStatus::Waiting
1378 ));
1379 assert!(matches!(
1381 rmeta_to_status("{}", &secret),
1382 RequestStatus::Waiting
1383 ));
1384 }
1385
1386 #[test]
1387 fn rmeta_to_status_failed_carries_the_reason() {
1388 let secret = [1u8; 32];
1389 let status = rmeta_to_status(r#"{"status":"failed","reason":"expired"}"#, &secret);
1390 assert!(matches!(status, RequestStatus::Failed { reason } if reason == "expired"));
1391 }
1392
1393 #[test]
1394 fn rmeta_to_status_received_decrypts_the_filename() {
1395 let secret = [2u8; 32];
1396 let name_meta = crypto::encrypt_meta(&secret, br#"{"name":"invoice.pdf"}"#);
1397 let body = serde_json::json!({
1398 "status": "received",
1399 "size": 4096,
1400 "name_meta": name_meta,
1401 })
1402 .to_string();
1403
1404 let status = rmeta_to_status(&body, &secret);
1405 match status {
1406 RequestStatus::Received { name, size } => {
1407 assert_eq!(name, "invoice.pdf");
1408 assert_eq!(size, 4096);
1409 }
1410 other => panic!("expected Received, got {other:?}"),
1411 }
1412 }
1413
1414 #[test]
1415 fn rmeta_to_status_received_falls_back_when_name_meta_is_undecryptable() {
1416 let secret = [3u8; 32];
1417 let wrong_secret = [4u8; 32];
1418 let name_meta = crypto::encrypt_meta(&wrong_secret, br#"{"name":"invoice.pdf"}"#);
1419 let body = serde_json::json!({
1420 "status": "received",
1421 "size": 10,
1422 "name_meta": name_meta,
1423 })
1424 .to_string();
1425
1426 let status = rmeta_to_status(&body, &secret);
1427 match status {
1428 RequestStatus::Received { name, size } => {
1429 assert_eq!(name, "(encrypted)");
1430 assert_eq!(size, 10);
1431 }
1432 other => panic!("expected Received, got {other:?}"),
1433 }
1434 }
1435
1436 #[test]
1437 fn rmeta_to_status_malformed_json_is_failed_not_a_panic() {
1438 let secret = [1u8; 32];
1439 let status = rmeta_to_status("not json", &secret);
1440 assert!(
1441 matches!(status, RequestStatus::Failed { reason } if reason == "malformed response from the gate")
1442 );
1443 }
1444
1445 #[test]
1446 fn decrypt_meta_field_round_trips_and_is_none_on_failure() {
1447 let secret = [5u8; 32];
1448 let blob = crypto::encrypt_meta(&secret, br#"{"from":"Alex","msg":"the codes"}"#);
1449 let v = serde_json::json!({ "meta": blob });
1450
1451 assert_eq!(
1452 decrypt_meta_field(&v, "meta", &secret, "from").as_deref(),
1453 Some("Alex")
1454 );
1455 assert_eq!(
1456 decrypt_meta_field(&v, "meta", &secret, "msg").as_deref(),
1457 Some("the codes")
1458 );
1459 assert_eq!(decrypt_meta_field(&v, "meta", &[9u8; 32], "from"), None);
1461 assert_eq!(decrypt_meta_field(&v, "name_meta", &secret, "name"), None);
1463 assert_eq!(decrypt_meta_field(&v, "meta", &secret, "desc"), None);
1465 }
1466
1467 #[test]
1468 fn create_request_requires_full_tier() {
1469 let cfg = SelfHostedConfig {
1470 bucket: "b".into(),
1471 region: "us-east-1".into(),
1472 profile: None,
1473 endpoint: None,
1474 table: None,
1475 gate_url: None, distribution_id: None,
1477 };
1478 let backend = Backend::self_hosted("default", &cfg).unwrap();
1479 let sh = SelfHosted::from_backend(&backend).unwrap();
1480 let err = sh
1481 .create_request(
1482 CreateRequest {
1483 description: "invoice".into(),
1484 from: None,
1485 message: None,
1486 pin: None,
1487 expires: Duration::from_secs(86_400),
1488 uploads: 1,
1489 },
1490 &crate::progress::Silent,
1491 )
1492 .unwrap_err();
1493 assert!(matches!(err, Error::Config(_)));
1494 assert!(err.to_string().contains("full tier"), "{err}");
1495 }
1496
1497 #[test]
1498 fn gate_function_rejects_a_config_with_no_gate() {
1499 let cfg = SelfHostedConfig {
1500 bucket: "b".into(),
1501 region: "us-east-1".into(),
1502 profile: None,
1503 endpoint: None,
1504 table: None,
1505 gate_url: None, distribution_id: None,
1507 };
1508 let backend = Backend::self_hosted("default", &cfg).unwrap();
1509 let sh = SelfHosted::from_backend(&backend).unwrap();
1510 let err = sh.gate_disable().unwrap_err();
1511 assert_eq!(
1512 err.to_string(),
1513 "this config has no gate — it isn't full tier (`dove provision full`)"
1514 );
1515 }
1516}