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