Skip to main content

bw/tools/
receive.rs

1//! `bw receive` / `bw send receive` — access a Bitwarden Send from a url.
2//!
3//! This is the only Send flow that runs **without a logged-in user**: the send's content is
4//! decrypted with a key derived purely from the URL fragment
5//! ([`bitwarden_send::SendAccessKey`]), never from the account key store. Because the link can
6//! point at any deployment, the command builds its own [`PasswordManagerClient`] per invocation
7//! from URLs derived off the link itself (see [`resolve_urls`]) rather than reusing the session
8//! client — a self-hosted receive link must not have its password hash sent to Bitwarden cloud
9//! identity, and vice versa.
10//!
11//! Both `bw receive <url>` and `bw send receive <url>` are the same command; their arg structs
12//! are field-identical and both funnel into [`run_receive`].
13//!
14//! **PM-40120:** the legacy CLI's version of this bug mints against its configured environment
15//! but can leak the resulting real token to an attacker-controlled host on a follow-up fetch.
16//! The decided fix (2026-08-17) is that a Send access token must never be requested from
17//! anywhere but the server named in the Send link itself. [`resolve_urls`] already does this
18//! unconditionally for both the API and identity origin, so the mint and fetch here always
19//! target the same resolved host and a token can never cross the kind of domain boundary
20//! PM-40120 tracked. A link naming an attacker-controlled host still sends that host a password
21//! hash if the user proceeds — but per the decided fix, that's the token's only valid target,
22//! not a leak to a second, different host.
23
24use bitwarden_auth::send_access::{
25    SendAccessCredentials, SendAccessTokenError, SendAccessTokenRequest, SendAccessTokenResponse,
26    SendEmailCredentials, SendEmailOtpCredentials, SendPasswordCredentials,
27    api::{
28        SendAccessTokenApiErrorResponse, SendAccessTokenInvalidGrantError,
29        SendAccessTokenInvalidRequestError,
30    },
31};
32use bitwarden_core::get_host_platform_info;
33use bitwarden_pm::PasswordManagerClient;
34use bitwarden_send::{SendAccessKey, SendAccessView, SendType};
35use color_eyre::eyre::{Context as _, Result, bail, eyre};
36use inquire::{Password, Text, validator::Validation};
37use url::Url;
38
39use super::send_access_token_cache;
40use crate::{
41    platform::{ConfigFile, read_config_json},
42    render::{CommandOutput, CommandResult},
43    tools::file_output::{default_send_file_name, reject_path_traversal, save_file},
44};
45
46/// The Bitwarden cloud deployments whose Send links do not carry their API host.
47///
48/// `bw` has no region-metadata service (the same gap documented in the `web_vault_url` TODO in
49/// [`super::send`]), so the mapping is a small table here. Hosts are matched **exactly**, never
50/// by suffix: a suffix match would let `send.bitwarden.com.evil.tld` route a Send password hash
51/// to real Bitwarden cloud identity.
52const CLOUD_HOSTS: &[CloudRegion] = &[
53    CloudRegion {
54        hosts: &["send.bitwarden.com", "vault.bitwarden.com"],
55        api_url: "https://api.bitwarden.com",
56        identity_url: "https://identity.bitwarden.com",
57    },
58    CloudRegion {
59        hosts: &["send.bitwarden.eu", "vault.bitwarden.eu"],
60        api_url: "https://api.bitwarden.eu",
61        identity_url: "https://identity.bitwarden.eu",
62    },
63];
64
65struct CloudRegion {
66    hosts: &'static [&'static str],
67    api_url: &'static str,
68    identity_url: &'static str,
69}
70
71/// The flags `bw receive` and `bw send receive` share, normalized into one struct so the two
72/// entry points cannot drift apart.
73pub(crate) struct ReceiveInputs {
74    /// The Send url, including the `#`-fragment that carries the send id and key.
75    pub url: String,
76    /// `--password`
77    pub password: Option<String>,
78    /// `--passwordenv`
79    pub passwordenv: Option<String>,
80    /// `--passwordfile`
81    pub passwordfile: Option<String>,
82    /// `--output <path>`: where to save a file-type Send. Matches the flag name (and internal
83    /// field name, to avoid colliding with the top-level `Cli::output` render-format field)
84    /// already established by `bw send get --output` — legacy spells this the same way.
85    /// `--fullObject` (not legacy's boolean `--obj`) is the JSON-dump flag, matching the
86    /// existing convention on `bw send`/`bw send create`.
87    pub output_path: Option<String>,
88    /// `--fullObject`: dump the decrypted Send as JSON instead of emitting its content.
89    pub full_object: bool,
90}
91
92/// Entry point for [`super::ReceiveArgs`]'s `BwCommand` impl, reached both from the top-level
93/// `bw receive` command and from `SendCommands::Receive` (`bw send receive`), which reuses the
94/// same arg struct rather than a hand-synced copy.
95pub(crate) async fn run_receive(inputs: ReceiveInputs) -> CommandResult {
96    let url = Url::parse(&inputs.url).wrap_err("Failed to parse the provided Send url")?;
97    let (send_id, key_b64) = parse_send_url(&url)?;
98
99    // A structurally valid URL whose fragment key isn't a 16-byte url-safe-base64 blob is not a
100    // Send url; report it the same way as a missing fragment segment rather than leaking the
101    // crypto-layer error text (which would be about key lengths, not about the url).
102    let access_key = SendAccessKey::from_url_b64(&key_b64)
103        .map_err(|_| eyre!("Failed to parse url, the url provided is not a valid Send url"))?;
104
105    let (api_url, identity_url, trusted) =
106        resolve_urls(&url, read_config_json().ok().flatten().as_ref());
107    // The cache key is the resolved host, never a separately configured environment — see
108    // `send_access_token_cache`'s module doc for why that distinction matters. Only engaged for
109    // a trusted host at all; see `resolve_urls`'s doc for why untrusted hosts are excluded
110    // entirely rather than relying on the key alone.
111    let cache_host = format!("{api_url}|{identity_url}");
112    let client = PasswordManagerClient::new(Some(
113        get_host_platform_info().to_client_settings(api_url, identity_url),
114    ));
115
116    if trusted && let Some(token) = send_access_token_cache::get(&cache_host, &send_id) {
117        return match render_access(&client, &access_key, token, &inputs).await {
118            Ok(output) => Ok(output),
119            Err(err) => {
120                // The cache believed this token was still valid but the server disagreed
121                // (clock skew, or revocation before natural expiry) — don't leave a bad entry
122                // behind for the next invocation to hit the same way.
123                send_access_token_cache::evict(&cache_host, &send_id);
124                Err(err)
125            }
126        };
127    }
128
129    let token_response = attempt_access(&client, &send_id, &access_key, &inputs).await?;
130    if trusted {
131        send_access_token_cache::set(
132            &cache_host,
133            &send_id,
134            &token_response.token,
135            token_response.expires_at,
136        );
137    }
138    render_access(&client, &access_key, token_response.token, &inputs).await
139}
140
141/// Extract `(send_id, url_b64_key)` from the last two `#`-fragment segments.
142///
143/// Mirrors the legacy CLI's `getIdAndKey` (`url.hash.slice(1).split("/").slice(-2)`), which is
144/// why one parser handles both link shapes: the web-vault route
145/// (`https://vault.example.com/#/send/<id>/<key>`) and the cloud Send vanity host
146/// (`https://send.bitwarden.com/#<id>/<key>`) — only the trailing two segments matter.
147fn parse_send_url(url: &Url) -> Result<(String, String)> {
148    let fragment = url.fragment().unwrap_or_default();
149    let mut trailing = fragment.rsplit('/');
150    let key = trailing.next().unwrap_or_default();
151    let id = trailing.next().unwrap_or_default();
152
153    if id.trim().is_empty() || key.trim().is_empty() {
154        return Err(eyre!(
155            "Failed to parse url, the url provided is not a valid Send url"
156        ));
157    }
158
159    Ok((id.to_string(), key.to_string()))
160}
161
162/// Resolve the API and identity base URLs to talk to for a given Send link, plus whether that
163/// host is trusted.
164///
165/// Both URLs are needed: the send-access token is minted at `{identity}/connect/token` and the
166/// send itself is read from `{api}`. Precedence:
167///
168/// 1. A known Bitwarden cloud host (exact match — see [`CLOUD_HOSTS`]). Trusted.
169/// 2. The locally configured deployment (`bw config server`) when the link's origin matches it —
170///    explicit `api`/`identity` overrides win, otherwise the base is suffixed. Trusted.
171/// 3. Otherwise `<origin>/api` + `<origin>/identity`, the single-domain self-host convention and
172///    the legacy CLI's final fallback. Not trusted.
173///
174/// The trust flag gates the send-access-token cache ([`send_access_token_cache`]): only a
175/// trusted host's token gets persisted to disk at all, matching the corresponding TS client fix
176/// (which caches only for configured and trusted domains). This is a stricter rule than
177/// correctness alone requires — [`send_access_token_cache`]'s `(resolved_host, send_id)` key
178/// already prevents a token from being looked up under a different host than the one it was
179/// minted against — but it keeps this CLI from ever writing a persistent, on-disk credential for
180/// an arbitrary, untrusted Send-link host, not just from misusing one it already wrote.
181///
182/// Pure so the precedence can be unit-tested without a client or a config file on disk.
183///
184/// See the module-level PM-40120 note: this unconditional host resolution — always the link's
185/// own host, never a separately configured environment.
186fn resolve_urls(url: &Url, config: Option<&ConfigFile>) -> (String, String, bool) {
187    if let Some(host) = url.host_str()
188        && let Some(region) = CLOUD_HOSTS
189            .iter()
190            .find(|region| region.hosts.contains(&host))
191    {
192        return (
193            region.api_url.to_string(),
194            region.identity_url.to_string(),
195            true,
196        );
197    }
198
199    let origin = url.origin().ascii_serialization();
200
201    if let Some(config) = config
202        && let Some(base) = [config.web_vault.as_deref(), config.server.as_deref()]
203            .into_iter()
204            .flatten()
205            .find(|configured| same_origin(configured, &origin))
206    {
207        let base = base.trim_end_matches('/');
208        let api = trimmed(config.api.as_deref()).unwrap_or_else(|| format!("{base}/api"));
209        let identity =
210            trimmed(config.identity.as_deref()).unwrap_or_else(|| format!("{base}/identity"));
211        return (api, identity, true);
212    }
213
214    (format!("{origin}/api"), format!("{origin}/identity"), false)
215}
216
217/// `true` when `configured` denotes the same origin as `origin` (already an ASCII origin
218/// serialization). Falls back to a string compare for values that aren't parseable URLs, so a
219/// hand-edited `config.json` still matches.
220fn same_origin(configured: &str, origin: &str) -> bool {
221    match Url::parse(configured) {
222        Ok(parsed) => parsed.origin().ascii_serialization() == origin,
223        Err(_) => configured.trim_end_matches('/') == origin,
224    }
225}
226
227fn trimmed(value: Option<&str>) -> Option<String> {
228    value
229        .map(|v| v.trim_end_matches('/'))
230        .filter(|v| !v.is_empty())
231        .map(str::to_string)
232}
233
234/// Negotiate a send-access token, prompting for whatever credential the server says the Send
235/// needs. Mirrors the legacy `attemptAccess`: ask with no credentials first and branch on the
236/// typed `send_access_error_type` the server returns.
237///
238/// Legacy wraps every token request in a 3-attempt retry loop (`getTokenWithRetry`) for a
239/// `{kind: "expired"}` case tied to its persistent send-access-token cache, itself keyed on
240/// `sendId` alone with no host component — part of what PM-40120 tracks. This port's own cache
241/// ([`send_access_token_cache`]) is keyed on `(resolved_host, send_id)` instead, and evicts
242/// rather than retries on a rejected cached token (see [`run_receive`]), so there's no
243/// multi-attempt loop to replicate here.
244async fn attempt_access(
245    client: &PasswordManagerClient,
246    send_id: &str,
247    access_key: &SendAccessKey,
248    inputs: &ReceiveInputs,
249) -> Result<SendAccessTokenResponse> {
250    match request_token(client, send_id, None).await {
251        Ok(token) => Ok(token),
252        Err(err) => match invalid_request_type(&err) {
253            Some(SendAccessTokenInvalidRequestError::PasswordHashB64Required) => {
254                access_with_password(client, send_id, access_key, inputs).await
255            }
256            Some(SendAccessTokenInvalidRequestError::EmailRequired) => {
257                access_with_email_otp(client, send_id).await
258            }
259            _ if invalid_grant_type(&err)
260                == Some(&SendAccessTokenInvalidGrantError::SendIdInvalid) =>
261            {
262                Err(eyre!("Not found."))
263            }
264            _ => Err(token_error(err)),
265        },
266    }
267}
268
269/// Password-protected Sends: resolve the password from flags/env/file/prompt, run it through the
270/// same PBKDF2 recipe `bw send create --password` used, and exchange it for a token.
271async fn access_with_password(
272    client: &PasswordManagerClient,
273    send_id: &str,
274    access_key: &SendAccessKey,
275    inputs: &ReceiveInputs,
276) -> Result<SendAccessTokenResponse> {
277    let password = resolve_password(inputs)?;
278    let credentials = SendAccessCredentials::Password(SendPasswordCredentials {
279        password_hash_b64: access_key.hash_password_b64(&password),
280    });
281
282    match request_token(client, send_id, Some(credentials)).await {
283        Ok(token) => Ok(token),
284        Err(err)
285            if invalid_grant_type(&err)
286                == Some(&SendAccessTokenInvalidGrantError::PasswordHashB64Invalid) =>
287        {
288            Err(eyre!("Invalid password"))
289        }
290        Err(err) => Err(token_error(err)),
291    }
292}
293
294/// Email-OTP-protected Sends: the email request is what makes the server send the code, so the
295/// expected outcome of the first call is an `email_and_otp_required` error, not a token.
296async fn access_with_email_otp(
297    client: &PasswordManagerClient,
298    send_id: &str,
299) -> Result<SendAccessTokenResponse> {
300    if !can_interact() {
301        return Err(eyre!(
302            "Email verification required. Run in interactive mode."
303        ));
304    }
305
306    let email = prompt_email()?;
307    let credentials = SendAccessCredentials::Email(SendEmailCredentials {
308        email: email.clone(),
309    });
310
311    let err = match request_token(client, send_id, Some(credentials)).await {
312        // A token here means the server stopped requiring the OTP it just mailed; treat it as a
313        // contract break rather than silently proceeding, matching legacy.
314        Ok(_) => return Err(eyre!("Unexpected server response")),
315        Err(err) => err,
316    };
317
318    if invalid_request_type(&err) != Some(&SendAccessTokenInvalidRequestError::EmailAndOtpRequired)
319    {
320        return Err(token_error(err));
321    }
322
323    let otp = prompt_otp()?;
324    let credentials = SendAccessCredentials::EmailOtp(SendEmailOtpCredentials { email, otp });
325
326    match request_token(client, send_id, Some(credentials)).await {
327        Ok(token) => Ok(token),
328        // The server deliberately doesn't say which of the two was wrong, so neither do we.
329        Err(err)
330            if invalid_request_type(&err)
331                == Some(&SendAccessTokenInvalidRequestError::EmailAndOtpRequired) =>
332        {
333            Err(eyre!("Invalid email or verification code"))
334        }
335        Err(err) => Err(token_error(err)),
336    }
337}
338
339async fn request_token(
340    client: &PasswordManagerClient,
341    send_id: &str,
342    credentials: Option<SendAccessCredentials>,
343) -> Result<SendAccessTokenResponse, SendAccessTokenError> {
344    client
345        .auth()
346        .send_access()
347        .request_send_access_token(SendAccessTokenRequest {
348            send_id: send_id.to_string(),
349            send_access_credentials: credentials,
350        })
351        .await
352}
353
354/// Extracts the typed `send_access_error_type` from an `invalid_request` response, or `None`
355/// if `err` isn't that shape. `attempt_access` and `access_with_email_otp` both need to branch
356/// on this one sub-field of a deeply nested error enum; centralizing the match here keeps
357/// those call sites down to a single `Some(...) => ...` comparison instead of repeating the
358/// full pattern.
359fn invalid_request_type(err: &SendAccessTokenError) -> Option<&SendAccessTokenInvalidRequestError> {
360    match err {
361        SendAccessTokenError::Expected(SendAccessTokenApiErrorResponse::InvalidRequest {
362            send_access_error_type,
363            ..
364        }) => send_access_error_type.as_ref(),
365        _ => None,
366    }
367}
368
369/// Same idea as [`invalid_request_type`], but for the `invalid_grant` response shape (used to
370/// detect an unknown Send id or an invalid password hash).
371fn invalid_grant_type(err: &SendAccessTokenError) -> Option<&SendAccessTokenInvalidGrantError> {
372    match err {
373        SendAccessTokenError::Expected(SendAccessTokenApiErrorResponse::InvalidGrant {
374            send_access_error_type,
375            ..
376        }) => send_access_error_type.as_ref(),
377        _ => None,
378    }
379}
380
381/// Surface a token-negotiation failure we have no specific message for. The error's `Debug`
382/// carries the server's `error_description`, which is diagnostic and never contains send content
383/// or credentials.
384fn token_error(err: SendAccessTokenError) -> color_eyre::eyre::Error {
385    match err {
386        SendAccessTokenError::Unexpected(inner) => eyre!("Server error: {inner:?}"),
387        SendAccessTokenError::Expected(inner) => eyre!("Error: {inner:?}"),
388    }
389}
390
391/// Fetch the Send with the negotiated token, decrypt it with the URL key, and render it.
392// `bw` builds a full per-receive client targeting the Send's instance, so the signed-in-instance
393// caveat behind the `SendClient` deprecation does not apply here.
394#[allow(deprecated)]
395async fn render_access(
396    client: &PasswordManagerClient,
397    access_key: &SendAccessKey,
398    token: String,
399    inputs: &ReceiveInputs,
400) -> CommandResult {
401    let response = client.sends().access_send(token.clone()).await?;
402    let view = access_key.decrypt_response(response)?;
403
404    if inputs.full_object {
405        return Ok(CommandOutput::Object(Box::new(view)));
406    }
407
408    match view.type_ {
409        // `render_result` uses `println!`, so this gains a trailing newline over legacy's raw
410        // `stdout.write`. Same pre-existing divergence as `bw send get --text`; kept consistent
411        // with the rest of this CLI rather than special-cased here.
412        Some(SendType::Text) => Ok(CommandOutput::Plain(
413            view.text.and_then(|text| text.text).unwrap_or_default(),
414        )),
415        Some(SendType::File) => {
416            save_file_send(
417                client,
418                access_key,
419                &token,
420                view,
421                inputs.output_path.as_deref(),
422            )
423            .await
424        }
425        Some(SendType::Item) => {
426            bail!("Accessing item Sends is not supported by the CLI.");
427        }
428        // Unknown or absent type: hand back everything we decrypted, as legacy does, rather
429        // than guessing at which content field to print.
430        None => Ok(CommandOutput::Object(Box::new(view))),
431    }
432}
433
434/// Download, decrypt, and save a file-type Send's blob.
435// `bw` builds a full per-receive client targeting the Send's instance, so the signed-in-instance
436// caveat behind the `SendClient` deprecation does not apply here.
437#[allow(deprecated)]
438async fn save_file_send(
439    client: &PasswordManagerClient,
440    access_key: &SendAccessKey,
441    token: &str,
442    view: SendAccessView,
443    output: Option<&str>,
444) -> CommandResult {
445    let file = view
446        .file
447        .ok_or_else(|| eyre!("The Send is a file Send but carries no file metadata."))?;
448    let file_id = file
449        .id
450        .ok_or_else(|| eyre!("The Send's file is missing an id; cannot download it."))?;
451
452    let download = client
453        .sends()
454        .get_file_download_data(token.to_string(), file_id)
455        .await?;
456    let download_url = download
457        .url
458        .ok_or_else(|| eyre!("The server did not return a download url for the Send's file."))?;
459
460    let encrypted = download_bytes(client, &download_url).await?;
461    let decrypted = access_key.decrypt_file_buffer(&encrypted)?;
462
463    let file_name = default_send_file_name(file.file_name.as_deref());
464    let path = save_file(output, &file_name, &decrypted)?;
465
466    Ok(CommandOutput::Plain(format!("Saved {}", path.display())))
467}
468
469/// GET a pre-signed blob URL with the client's shared HTTP stack (so proxy and TLS settings
470/// apply), mirroring legacy's `apiService.nativeFetch`.
471///
472/// The fetch lives here rather than in [`super::file_output`] because a shared helper would have
473/// to name `reqwest::Client` in its signature, which would mean adding `reqwest` as a direct
474/// dependency of `bw`. Worth doing once a second command needs it; not for one caller.
475async fn download_bytes(client: &PasswordManagerClient, url: &str) -> Result<Vec<u8>> {
476    let response = client
477        .0
478        .internal
479        .get_http_client()
480        .get(url)
481        // Spelled as a plain `&str` so this file never has to name `reqwest`'s header types.
482        .header("cache-control", "no-cache")
483        .send()
484        .await?;
485
486    if !response.status().is_success() {
487        return Err(eyre!(
488            "A {} error occurred while downloading the attachment.",
489            response.status().as_u16()
490        ));
491    }
492
493    Ok(response.bytes().await?.to_vec())
494}
495
496// ===== Password resolution =====
497
498/// Resolve the Send's password, prompting when it wasn't supplied and the session is
499/// interactive. Mirrors legacy's `handlePasswordAuth` precedence exactly.
500fn resolve_password(inputs: &ReceiveInputs) -> Result<String> {
501    let supplied = password_from_args(
502        inputs.password.as_deref(),
503        inputs.passwordfile.as_deref(),
504        inputs.passwordenv.as_deref(),
505    )?;
506
507    let password = match supplied {
508        Some(password) => password,
509        None if can_interact() => prompt_password()?,
510        None => return Err(eyre!("Password required")),
511    };
512
513    if password.is_empty() {
514        return Err(eyre!("Password required"));
515    }
516
517    Ok(password)
518}
519
520/// Non-interactive half of [`resolve_password`], split out so the precedence is unit-testable.
521///
522/// Order (legacy `handlePasswordAuth`): `--password`, then `--passwordfile`, then
523/// `--passwordenv`. An empty `--password ""` falls through, and `--passwordfile` short-circuits
524/// `--passwordenv` even when the file's first line is empty — both match legacy's `else if`
525/// chain.
526fn password_from_args(
527    password: Option<&str>,
528    passwordfile: Option<&str>,
529    passwordenv: Option<&str>,
530) -> Result<Option<String>> {
531    if let Some(password) = password.filter(|p| !p.is_empty()) {
532        return Ok(Some(password.to_string()));
533    }
534
535    if let Some(path) = passwordfile {
536        reject_path_traversal("--passwordfile", path)?;
537        let contents = std::fs::read_to_string(path)
538            .wrap_err_with(|| format!("Could not read --passwordfile {path}"))?;
539        // First line only, terminator stripped — legacy's `NodeUtils.readFirstLine`. Legacy
540        // hangs forever on an empty file (its readline never emits a `line` event); we treat
541        // that as "no password supplied" and let the caller prompt or error.
542        return Ok(contents.lines().next().map(str::to_string));
543    }
544
545    if let Some(name) = passwordenv {
546        return match std::env::var(name) {
547            Ok(value) => Ok(Some(value)),
548            // Legacy silently ignores an unset variable and falls through to the prompt, which
549            // reads as "the flag did nothing". Say so, on stderr, without echoing any value.
550            Err(_) => {
551                tracing::warn!("--passwordenv variable `{name}` is not set; ignoring it.");
552                Ok(None)
553            }
554        };
555    }
556
557    Ok(None)
558}
559
560// ===== Interactivity =====
561
562/// Whether we may prompt the user.
563///
564/// Reads `BW_NOINTERACTION` strictly (`== "true"`), matching the legacy CLI. The `--nointeraction`
565/// flag on [`crate::command::Cli`] is *not* honored here: it is never threaded into
566/// `ClientContext`, so no `BwCommand` can see it today. Plumbing it through touches every
567/// command's dispatch signature — out of scope for this command; needs its own ticket.
568fn can_interact() -> bool {
569    std::env::var("BW_NOINTERACTION").as_deref() != Ok("true")
570}
571
572/// Prompt for the Send's password. `inquire` renders to stderr, so
573/// `bw receive <url> > out.txt` still captures only the Send's content — the same reason legacy
574/// passes `output: process.stderr` to inquirer.
575fn prompt_password() -> Result<String> {
576    Ok(Password::new("Send password")
577        .without_confirmation()
578        .prompt()?)
579}
580
581fn prompt_email() -> Result<String> {
582    Ok(Text::new("Enter your email address:")
583        .with_validator(|input: &str| {
584            if input.contains('@') {
585                Ok(Validation::Valid)
586            } else {
587                Ok(Validation::Invalid(
588                    "Please enter a valid email address".into(),
589                ))
590            }
591        })
592        .prompt()?)
593}
594
595fn prompt_otp() -> Result<String> {
596    Ok(Text::new("Enter the verification code sent to your email:").prompt()?)
597}
598
599#[cfg(test)]
600mod tests {
601    use super::*;
602
603    fn parse(url: &str) -> Url {
604        Url::parse(url).expect("test url parses")
605    }
606
607    // ---- parse_send_url ----
608
609    #[test]
610    fn parses_the_web_vault_send_route() {
611        let (id, key) = parse_send_url(&parse(
612            "https://vault.bitwarden.com/#/send/access-id/Pgui0FK85cNhBGWHAlBHBw",
613        ))
614        .unwrap();
615        assert_eq!(id, "access-id");
616        assert_eq!(key, "Pgui0FK85cNhBGWHAlBHBw");
617    }
618
619    #[test]
620    fn parses_the_send_vanity_host_route() {
621        // `https://send.bitwarden.com/#<id>/<key>` — no `/send/` path segment in the fragment.
622        let (id, key) = parse_send_url(&parse(
623            "https://send.bitwarden.com/#access-id/Pgui0FK85cNhBGWHAlBHBw",
624        ))
625        .unwrap();
626        assert_eq!(id, "access-id");
627        assert_eq!(key, "Pgui0FK85cNhBGWHAlBHBw");
628    }
629
630    #[test]
631    fn parses_self_hosted_links_with_a_path_prefix() {
632        let (id, key) = parse_send_url(&parse(
633            "https://example.com/bitwarden/#/send/access-id/Pgui0FK85cNhBGWHAlBHBw",
634        ))
635        .unwrap();
636        assert_eq!(id, "access-id");
637        assert_eq!(key, "Pgui0FK85cNhBGWHAlBHBw");
638    }
639
640    #[test]
641    fn rejects_a_url_without_a_fragment() {
642        let err = parse_send_url(&parse("https://vault.bitwarden.com/")).unwrap_err();
643        assert!(
644            err.to_string().contains("not a valid Send url"),
645            "got: {err}"
646        );
647    }
648
649    #[test]
650    fn rejects_a_fragment_with_only_one_segment() {
651        let err = parse_send_url(&parse("https://send.bitwarden.com/#access-id")).unwrap_err();
652        assert!(
653            err.to_string().contains("not a valid Send url"),
654            "got: {err}"
655        );
656    }
657
658    #[test]
659    fn rejects_a_fragment_with_a_blank_segment() {
660        // `#/send//key` and `#/send/id/` both lose a required value.
661        assert!(
662            parse_send_url(&parse("https://vault.bitwarden.com/#/send//key")).is_err(),
663            "blank id must be rejected"
664        );
665        assert!(
666            parse_send_url(&parse("https://vault.bitwarden.com/#/send/id/")).is_err(),
667            "blank key must be rejected"
668        );
669    }
670
671    // ---- resolve_urls ----
672
673    #[test]
674    fn cloud_send_and_vault_hosts_map_to_their_region() {
675        for host in ["send.bitwarden.com", "vault.bitwarden.com"] {
676            let (api, identity, trusted) =
677                resolve_urls(&parse(&format!("https://{host}/#id/key")), None);
678            assert_eq!(api, "https://api.bitwarden.com");
679            assert_eq!(identity, "https://identity.bitwarden.com");
680            assert!(trusted, "a known cloud host must be trusted");
681        }
682        for host in ["send.bitwarden.eu", "vault.bitwarden.eu"] {
683            let (api, identity, trusted) =
684                resolve_urls(&parse(&format!("https://{host}/#id/key")), None);
685            assert_eq!(api, "https://api.bitwarden.eu");
686            assert_eq!(identity, "https://identity.bitwarden.eu");
687            assert!(trusted, "a known cloud host must be trusted");
688        }
689    }
690
691    /// The cloud table must match on the **exact** host. A suffix match would send a Send
692    /// password hash — derived from the recipient's password — to real Bitwarden cloud identity
693    /// on behalf of an attacker-controlled link.
694    #[test]
695    fn a_lookalike_host_is_never_treated_as_cloud() {
696        for host in [
697            "send.bitwarden.com.evil.tld",
698            "evil-send.bitwarden.com.attacker.example",
699            "notsend.bitwarden.com.evil.tld",
700        ] {
701            let (api, identity, trusted) =
702                resolve_urls(&parse(&format!("https://{host}/#id/key")), None);
703            assert_eq!(api, format!("https://{host}/api"));
704            assert_eq!(identity, format!("https://{host}/identity"));
705            assert!(!trusted, "a lookalike host must not be trusted");
706        }
707    }
708
709    #[test]
710    fn unknown_host_falls_back_to_the_single_domain_convention() {
711        let (api, identity, trusted) =
712            resolve_urls(&parse("https://vault.example.com/#/send/id/key"), None);
713        assert_eq!(api, "https://vault.example.com/api");
714        assert_eq!(identity, "https://vault.example.com/identity");
715        assert!(
716            !trusted,
717            "an unknown, unconfigured host must not be trusted"
718        );
719    }
720
721    #[test]
722    fn a_non_default_port_is_preserved_in_the_fallback() {
723        let (api, identity, trusted) =
724            resolve_urls(&parse("https://localhost:8080/#/send/id/key"), None);
725        assert_eq!(api, "https://localhost:8080/api");
726        assert_eq!(identity, "https://localhost:8080/identity");
727        assert!(!trusted);
728    }
729
730    #[test]
731    fn a_matching_configured_server_supplies_the_base() {
732        let config = ConfigFile {
733            server: Some("https://bw.example.com".to_string()),
734            ..Default::default()
735        };
736        let (api, identity, trusted) = resolve_urls(
737            &parse("https://bw.example.com/#/send/id/key"),
738            Some(&config),
739        );
740        assert_eq!(api, "https://bw.example.com/api");
741        assert_eq!(identity, "https://bw.example.com/identity");
742        assert!(trusted, "the locally configured deployment must be trusted");
743    }
744
745    #[test]
746    fn a_matching_configured_web_vault_supplies_the_base() {
747        let config = ConfigFile {
748            web_vault: Some("https://vault.example.com/".to_string()),
749            ..Default::default()
750        };
751        let (api, identity, trusted) = resolve_urls(
752            &parse("https://vault.example.com/#/send/id/key"),
753            Some(&config),
754        );
755        assert_eq!(api, "https://vault.example.com/api");
756        assert_eq!(identity, "https://vault.example.com/identity");
757        assert!(trusted);
758    }
759
760    /// A split-domain self-host configured via `bw config server --api/--identity` must use
761    /// those hosts, not `<web-vault>/api`.
762    #[test]
763    fn explicit_configured_api_and_identity_win_over_the_base() {
764        let config = ConfigFile {
765            web_vault: Some("https://vault.example.com".to_string()),
766            api: Some("https://api.example.com".to_string()),
767            identity: Some("https://identity.example.com/".to_string()),
768            ..Default::default()
769        };
770        let (api, identity, trusted) = resolve_urls(
771            &parse("https://vault.example.com/#/send/id/key"),
772            Some(&config),
773        );
774        assert_eq!(api, "https://api.example.com");
775        assert_eq!(identity, "https://identity.example.com");
776        assert!(trusted);
777    }
778
779    /// A configured deployment must not hijack a link that points somewhere else — receiving a
780    /// cloud Send while `bw config server` points at a self-host has to still hit cloud.
781    #[test]
782    fn a_config_for_a_different_origin_is_ignored() {
783        let config = ConfigFile {
784            server: Some("https://bw.example.com".to_string()),
785            api: Some("https://api.example.com".to_string()),
786            ..Default::default()
787        };
788        let (api, identity, trusted) =
789            resolve_urls(&parse("https://send.bitwarden.com/#id/key"), Some(&config));
790        assert_eq!(api, "https://api.bitwarden.com");
791        assert_eq!(identity, "https://identity.bitwarden.com");
792        assert!(trusted);
793
794        let (api, identity, trusted) = resolve_urls(
795            &parse("https://other.example.com/#/send/id/key"),
796            Some(&config),
797        );
798        assert_eq!(api, "https://other.example.com/api");
799        assert_eq!(identity, "https://other.example.com/identity");
800        assert!(
801            !trusted,
802            "an origin that matches neither the cloud table nor the configured deployment \
803             must not be trusted"
804        );
805    }
806
807    // ---- password_from_args ----
808
809    #[test]
810    fn password_flag_wins() {
811        let password = password_from_args(Some("hunter2"), None, None).unwrap();
812        assert_eq!(password.as_deref(), Some("hunter2"));
813    }
814
815    #[test]
816    fn empty_password_flag_falls_through() {
817        // Legacy treats `--password ""` as "not supplied".
818        assert_eq!(password_from_args(Some(""), None, None).unwrap(), None);
819    }
820
821    #[test]
822    fn password_file_supplies_its_first_line_only() {
823        let path = std::env::temp_dir().join(format!(
824            "bw-receive-passwordfile-{}",
825            chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default()
826        ));
827        std::fs::write(&path, "first-line\nsecond-line\n").unwrap();
828
829        let password = password_from_args(None, Some(&path.to_string_lossy()), None).unwrap();
830
831        assert_eq!(password.as_deref(), Some("first-line"));
832        std::fs::remove_file(&path).ok();
833    }
834
835    #[test]
836    fn password_file_strips_a_windows_line_terminator() {
837        let path = std::env::temp_dir().join(format!(
838            "bw-receive-passwordfile-crlf-{}",
839            chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default()
840        ));
841        std::fs::write(&path, "first-line\r\nsecond\r\n").unwrap();
842
843        let password = password_from_args(None, Some(&path.to_string_lossy()), None).unwrap();
844
845        assert_eq!(password.as_deref(), Some("first-line"));
846        std::fs::remove_file(&path).ok();
847    }
848
849    #[test]
850    fn password_file_rejects_traversal() {
851        let err = password_from_args(None, Some("../secrets/pw.txt"), None).unwrap_err();
852        assert!(
853            err.to_string().contains("--passwordfile"),
854            "expected a --passwordfile traversal error, got: {err}"
855        );
856    }
857
858    #[test]
859    fn missing_password_file_is_an_error_not_a_fallthrough() {
860        // A caller who pointed us at a file expects to hear that it isn't there, rather than
861        // being silently prompted.
862        assert!(password_from_args(None, Some("/nonexistent/bw-receive-pw.txt"), None).is_err());
863    }
864
865    /// `--passwordfile` short-circuits `--passwordenv` even when the file yields nothing —
866    /// legacy's `if/else if` chain never reaches the env branch once the file flag is set.
867    #[test]
868    fn password_file_short_circuits_password_env() {
869        let path = std::env::temp_dir().join(format!(
870            "bw-receive-passwordfile-empty-{}",
871            chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default()
872        ));
873        std::fs::write(&path, "").unwrap();
874
875        let password = password_from_args(
876            None,
877            Some(&path.to_string_lossy()),
878            // Deliberately a variable name that is never set: if the env branch were reached
879            // this test would still pass, so the assertion below is about the empty file
880            // producing `None` rather than about the env var itself.
881            Some("BW_RECEIVE_TEST_UNSET_VAR"),
882        )
883        .unwrap();
884
885        assert_eq!(password, None);
886        std::fs::remove_file(&path).ok();
887    }
888
889    #[test]
890    fn unset_password_env_yields_no_password() {
891        assert_eq!(
892            password_from_args(None, None, Some("BW_RECEIVE_TEST_DEFINITELY_UNSET")).unwrap(),
893            None
894        );
895    }
896
897    #[test]
898    fn no_inputs_yields_no_password() {
899        assert_eq!(password_from_args(None, None, None).unwrap(), None);
900    }
901}