Skip to main content

dove_core/backend/
self_hosted.rs

1//! The self-hosted backend: your own S3 bucket (and, full tier, your own
2//! DynamoDB table + access-gate). Config-only at construction — no secrets
3//! load, no network call — so building a `SelfHosted` can never fail on I/O;
4//! only the lazily-built [`Store`] touches credentials or the network.
5
6use 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
21/// The full-tier gate's on/off state, as reported by `dove gate status`.
22pub struct GateState {
23    pub enabled: bool,
24}
25
26#[derive(Debug)]
27pub struct SelfHosted {
28    cfg: SelfHostedConfig,
29}
30
31impl SelfHosted {
32    /// Build from a registry backend. I/O-free: this only deserializes the
33    /// backend's config table into [`SelfHostedConfig`] — the same
34    /// `toml::Value::Table(...).try_into()` path [`Backend`]'s registry
35    /// helpers use. The [`Store`] (which loads secrets) is built lazily, on
36    /// first use, by [`Self::store`].
37    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    /// A backend-agnostic instance for operations that don't need a
45    /// configured backend. Today that's only `get`: a share link carries its
46    /// own host, and the decryption key rides the URL fragment — fetching one
47    /// doesn't depend on which backend (if any) is active on this machine.
48    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    /// Full tier: always encrypt, register a download policy in DynamoDB, and
64    /// hand out a gate link (`<gate>/d/<id>#<secret>`). The gate enforces the
65    /// budget; the key **and** the filename + trust metadata ride the
66    /// fragment, so the server sees neither the content nor the real
67    /// filename.
68    #[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        // A MAC'd share id: the gate rejects any id it didn't mint before
82        // touching the database, so forged / random-id floods die at a cheap
83        // check.
84        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        // The fragment always carries a random secret. Without a PIN it *is*
96        // the content key. With a PIN, the content key is PBKDF2(PIN, secret)
97        // — the PIN (already resolved by the caller, delivered out of band)
98        // is the second factor, and the gate also verifies it.
99        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(); // name-free: the filename is E2E, in the fragment
126        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        // The filename + trust (sender name, message) are encrypted with the
131        // secret and stored on the server as opaque ciphertext — the server
132        // can't read them (same as the file). Kept *off* the URL so links
133        // stay short and constant regardless of filename/message length; the
134        // page/`get` fetch + decrypt it.
135        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        // Keep a local id → filename record so `dove ls` can show it (the
160        // server, holding only a name-free key, can't). Best-effort; never
161        // fails the share.
162        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    /// Write the share's policy row to DynamoDB using the **scoped IAM key**
190    /// (which `provision` grants `dynamodb:PutItem`) — never the operator
191    /// profile, so `dove share` needs no elevated credentials.
192    #[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        // `size` is stored so /meta reads it from DynamoDB instead of a
209        // per-request S3 HeadObject. `meta` is the encrypted filename+trust
210        // blob — opaque to the server, decrypted client-side with the
211        // fragment secret.
212        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            // pin_attempts starts at 0; the gate increments on each wrong
224            // guess and locks the share once it hits the ceiling.
225            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    /// Mint a `dove request`: the PIN-gated ask for someone else to upload a
256    /// file to you. Mirrors `share_full` run backwards — a MAC'd id, a fresh
257    /// fragment secret, an encrypted trust blob — but the row this writes
258    /// tracks an upload budget instead of a download budget, and there's no
259    /// content to encrypt yet (the other side supplies that later, via the
260    /// gate's `/up` + `/done`). Full tier only: a request needs the gate +
261    /// table the simple tier doesn't have.
262    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        // The fragment secret is the content key for whatever gets uploaded,
282        // and the key for both meta blobs — never the PIN, which is only the
283        // gate's upload-authorization factor (see the wire contract). It
284        // never reaches the gate; it rides the URL fragment, and the local
285        // ledger below is the only other place it's kept.
286        let fragment_secret = crypto::gen_key();
287        let pin_hash = req.pin.as_deref().map(|pin| crypto::pin_hash(&id, pin));
288
289        // The trust blob: who's asking, their message, and what they're
290        // asking for. Encrypted with the fragment secret, so the gate holds
291        // it as opaque ciphertext (same as a share's meta blob) — the
292        // upload page decrypts it to show the uploader who they're trusting.
293        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        // A local record of {id, fragment, description} is the only way
316        // `dove requests`/`collect` can later decrypt whatever comes in —
317        // the gate never sees the fragment, and (unlike a share) it's never
318        // handed to anyone else either: this ledger row is the requester's
319        // ONLY durable copy of the decryption key. Best-effort like the
320        // share ledger — a write failure never fails the request itself,
321        // since the link below still carries the fragment — but silent is
322        // wrong here, so a failure surfaces as a progress warning instead of
323        // being swallowed.
324        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    /// Live status of a request, straight from the gate's `/rmeta/<id>` — no
351    /// local fulfilment state exists (the gate is the only side that knows
352    /// whether anything's been uploaded).
353    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    /// Collect what was uploaded: confirm the gate says `received`, decrypt
366    /// the filename from `/rmeta`'s `upload_meta`, then download the object
367    /// directly (the scoped key already has `s3:GetObject` — no gate
368    /// endpoint is involved in the download itself) and decrypt it with the
369    /// fragment secret. Mirrors `get()`'s download+decrypt path.
370    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        // The filename rides `upload_meta` (`name_meta` in the /rmeta JSON),
394        // encrypted by the browser with the same fragment secret. Best-effort:
395        // a missing/undecryptable blob falls back to a generic name rather
396        // than failing the whole collect.
397        let name = decrypt_meta_field(&v, "name_meta", &fragment_secret, "name")
398            .unwrap_or_else(|| "upload".to_string());
399        // The create-time trust blob rides `meta`, same secret.
400        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    /// Write the request's row to DynamoDB using the same scoped-key pattern
447    /// as [`Self::put_policy_item`] — the operator's `dove request` never
448    /// needs elevated credentials either.
449    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    /// Disable the full-tier gate: reserved concurrency 0, so every request
490    /// fails fast at no cost — the same lever the cost breaker pulls
491    /// automatically on a flood. Non-interactive; no terminal output (the
492    /// caller renders the result).
493    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    /// Re-enable the gate (remove the reserved-concurrency override).
509    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    /// Whether the gate is currently enabled (reserved concurrency isn't 0).
523    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    /// The gate Lambda's `(profile, function name)` from the config + AWS
542    /// identity — `dove-gate-<account>`, the name `provision full` gives it.
543    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        // Simple tier: presigned links, capped at 7 days, optional --encrypt.
611        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        // The simple tier has no server-side id of its own; the object key's
660        // random prefix is what `dove ls`/`revoke` key off of.
661        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        // The fragment is the secret (older links may append ".<meta>" —
681        // ignore it). Without a PIN the secret *is* the key; with one, the
682        // key is PBKDF2(PIN, secret).
683        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        // Filename + trust come from the gate's /meta blob, decrypted with
691        // the secret (the server holds it as opaque ciphertext).
692        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        // Trust: surface who it's from (and their message) *before* pulling
707        // the file, not after — the whole point of the callout is deciding
708        // whether to download at all.
709        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        // A full-tier link is the browser page URL (`…/d/<id>/<name>`); the
723        // gate's download endpoint is `…/dl/<id>` (which decrements + 302s).
724        // A simple-tier presigned URL is fetched as-is. The PIN rides a query
725        // param the gate checks (only on a gate link — never on a signed
726        // presigned URL).
727        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    /// The shares currently in the bucket. Full-tier shares are listed by id
770    /// only: their filenames are end-to-end encrypted in the link, so the
771    /// server (and therefore this) genuinely doesn't have them — the local
772    /// ledger fills in what it can.
773    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    /// Delete a share early, so its link 404s (it would have been reaped by
785    /// the lifecycle rule anyway). Handles both name-free full-tier keys
786    /// (`<id>`) and simple-tier keys (`<id>/<name>`).
787    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
822/// A unique temp path for the ciphertext written before upload.
823fn 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
830/// Map one S3 listing key (matched against the local ledger) to a
831/// `ShareInfo` — the pure core of `list()`, split out so it's testable
832/// without touching S3. A simple-tier key carries its name in the key itself
833/// (`<id>/<name>`); a full-tier key is name-free, so the name (and expiry), if
834/// known, come from the local ledger entry `share_full` recorded at share time.
835fn 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
853/// A share object key: a random prefix so filenames neither collide nor
854/// expose a guessable listing — `<8 hex>/<filename>`.
855fn 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
864/// Build the request row's DynamoDB put-item JSON (typed-attribute form),
865/// exactly as the wire contract specifies: `kind="request"`,
866/// `uploads_remaining`/`uploads_total` both seeded from `uploads`,
867/// `upload_attempts=0`, and — only when a PIN is set — `pin_hash` alongside
868/// `pin_attempts=0`. The pure core of [`SelfHosted::put_request_item`], split
869/// out so the exact attribute shape is testable without shelling out to
870/// `aws`.
871fn 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
894/// The request link: `{gate}/r/{id}#{fragment}` — the request-page analogue
895/// of `share_full`'s `{gate}/d/{id}#{fragment}`. Factored so the exact
896/// assembly is testable without minting a real id/fragment.
897fn request_link(gate: &str, id: &str, fragment: &str) -> String {
898    format!("{gate}/r/{id}#{fragment}")
899}
900
901/// GET the gate's `/rmeta/<id>` and return the raw JSON body. Shared by
902/// `request_status` and `collect_request`; a missing id (404) maps to
903/// `Error::NotFound` since neither caller has a meaningful `RequestStatus`
904/// for "the gate has never heard of this id".
905fn 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
922/// Map the gate's `/rmeta/<id>` JSON body to a [`RequestStatus`], decrypting
923/// the uploaded filename (`name_meta`) with `fragment_secret` when the
924/// status is `received`. Pure function — no network — so it's unit-testable
925/// from a fixed JSON string. Malformed JSON (the gate should never send
926/// this, but a proxy/CDN could mangle a body) maps to `Failed` rather than
927/// panicking or silently reading as `Waiting`.
928fn 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
951/// Decrypt one string field out of a JSON body's named blob attribute
952/// (`meta` or `name_meta`): base64url-decode + AES-GCM-decrypt the blob at
953/// `v[blob_field]` with `fragment_secret`, then pull `json_key` out of the
954/// resulting plaintext JSON. `None` on any failure along the way (missing
955/// field, bad blob, wrong key, absent key in the plaintext) — every caller
956/// treats this as best-effort and supplies its own fallback.
957fn 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
969/// Fetch the gate's `/meta`, decrypt its `meta` blob with the secret, and
970/// return `(filename, from, message)`. Best-effort: any failure (not a gate
971/// link, no blob, decrypt error) yields `None` and `get` falls back to the
972/// URL filename.
973fn 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
984/// Turn a gate page URL (`scheme://host/d/<id>`) into its `/meta/<id>`
985/// endpoint. Returns `None` for a non-gate URL (e.g. a simple-tier presigned
986/// URL).
987fn 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
997/// Derive the output filename from the URL's last path segment
998/// (percent-decoded), ignoring the query string. Fallback when a link
999/// carries no metadata.
1000fn 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
1011/// Turn a full-tier page URL (`scheme://host/d/<id>/<name>`) into the gate's
1012/// download endpoint (`scheme://host/dl/<id>`). Any other URL (e.g. a
1013/// simple-tier presigned URL) is returned unchanged.
1014fn 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
1029/// Minimal percent-decoding for a URL path segment (`%20` → space, etc.).
1030fn 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
1048/// Turn a gate error status (+ its JSON body) into an [`Error`] a recipient
1049/// can act on: needs a PIN, wrong PIN with tries left, locked out, or gone.
1050fn 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
1075/// Run an `aws` subcommand for its side effect, erroring unless it exits 0.
1076fn 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
1088/// Shell out to the `aws` CLI (with `--profile`, if one is configured),
1089/// returning its raw output for the caller to interpret.
1090fn 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
1100/// Whether the gate is enabled, given the raw JSON stdout of `aws lambda
1101/// get-function-concurrency`. Reserved concurrency of exactly 0 means
1102/// disabled; anything else — including output that doesn't parse — reads as
1103/// enabled, the same fallback the CLI used before this was extracted.
1104fn 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
1111/// A short transport-error string that never echoes the (signed) request URL.
1112fn 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
1119/// Wraps the response body to drive the download progress bar.
1120struct 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        // Building a SelfHosted must never touch the filesystem or network —
1173        // only Store::new() (built lazily by `store()`) loads secrets. This
1174        // guards against a regression that adds I/O to the constructor.
1175        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        // Randomised: two keys for the same name differ.
1202        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        // A simple-tier presigned URL is untouched.
1212        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        // 410 matches the fixed Error::Gone message exactly.
1243        assert_eq!(
1244            Error::Gone.to_string(),
1245            "this share has expired or reached its download limit"
1246        );
1247    }
1248
1249    /// The gate's recoverable codes must map to the typed variants a GUI can
1250    /// match on, while keeping the exact message text the CLI has always
1251    /// printed (`to_string()` unchanged pre- vs post-refactor).
1252    #[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        // Unparseable/empty output falls back to "enabled", same as before extraction.
1321        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        // Missing "status" also reads as waiting (the gate's default).
1380        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        // Wrong key → decrypt fails → None.
1460        assert_eq!(decrypt_meta_field(&v, "meta", &[9u8; 32], "from"), None);
1461        // Missing field → None.
1462        assert_eq!(decrypt_meta_field(&v, "name_meta", &secret, "name"), None);
1463        // Absent JSON key inside a validly-decrypted blob → None.
1464        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, // not full tier
1476            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, // not full tier
1506            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}