tsafe-cli 1.0.21

tsafe CLI — local secret and credential manager (replaces .env files)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
//! Non-core external vault pull command handlers.
//!
//! Implements optional `tsafe vault-pull` (HashiCorp Vault KV v2) and `tsafe op-pull`
//! (1Password CLI) — both fetch secrets from a remote store and write them
//! into the local tsafe vault.
//!
//! # Authentication (task E2.4)
//!
//! HashiCorp Vault supports two auth methods:
//!
//! - **Token** (legacy): static `VAULT_TOKEN` env var or `--token` CLI flag.
//! - **AppRole**: `VAULT_ROLE_ID` + `VAULT_SECRET_ID` env vars (or YAML config).
//!   The role_id + secret_id are exchanged for a short-lived client token at the
//!   start of the command.  The token is never persisted to disk.
//!
//! # Namespace support (task E2.4)
//!
//! HCP Vault Enterprise namespaces are addressed via the `X-Vault-Namespace`
//! header, sent on every request (AppRole login + KV v2 LIST + GET).
//! Set via `VAULT_NAMESPACE` env var or `vault_namespace` YAML field.

use std::collections::HashMap;

use anyhow::{Context, Result};
use colored::Colorize;
use tsafe_core::{audit::AuditEntry, events::emit_event};

use crate::helpers::*;
#[cfg(feature = "cloud-pull-vault")]
use tsafe_cli::tsafe_hcp::auth::VaultAuth;

#[cfg_attr(not(feature = "cloud-pull-vault"), allow(dead_code))]
pub(crate) fn cmd_vault_pull(
    profile: &str,
    addr: Option<&str>,
    token: Option<&str>,
    mount: Option<&str>,
    prefix: Option<&str>,
    overwrite: bool,
) -> Result<()> {
    cmd_vault_pull_ns(profile, addr, token, mount, prefix, overwrite, None)
}

/// Inner implementation that supports an optional namespace prefix (ADR-012).
///
/// When `ns` is `Some("prod")`, every key imported from HashiCorp Vault is
/// stored as `prod.KEY_NAME` in the local vault.
///
/// `vault_auth` and `vault_namespace` are supplied from the pull manifest
/// (task E2.4).  When `None`, auth falls back to `VAULT_TOKEN` (token auth),
/// and no `X-Vault-Namespace` header is sent.
#[cfg_attr(not(feature = "cloud-pull-vault"), allow(dead_code))]
pub(crate) fn cmd_vault_pull_ns(
    profile: &str,
    addr: Option<&str>,
    token: Option<&str>,
    mount: Option<&str>,
    prefix: Option<&str>,
    overwrite: bool,
    ns: Option<&str>,
) -> Result<()> {
    cmd_vault_pull_full(
        profile, addr, token, mount, prefix, overwrite, ns, None, None,
    )
}

/// Full implementation with AppRole auth and namespace support (task E2.4).
///
/// Parameters:
/// - `vault_auth_config`: pre-parsed `VaultAuthConfig` from the pull manifest.
///   When `None`, auth falls back to env vars (AppRole if both `VAULT_ROLE_ID`
///   and `VAULT_SECRET_ID` are set, else `VAULT_TOKEN`).
/// - `vault_namespace`: HCP Vault Enterprise namespace string.  Sent as the
///   `X-Vault-Namespace` header on every Vault HTTP request.  When `None`,
///   falls back to the `VAULT_NAMESPACE` env var.
#[cfg(feature = "cloud-pull-vault")]
#[allow(clippy::too_many_arguments)]
pub(crate) fn cmd_vault_pull_full(
    profile: &str,
    addr: Option<&str>,
    token: Option<&str>,
    mount: Option<&str>,
    prefix: Option<&str>,
    overwrite: bool,
    ns: Option<&str>,
    vault_auth_config: Option<&tsafe_core::pullconfig::VaultAuthConfig>,
    vault_namespace: Option<&str>,
) -> Result<()> {
    if token.is_some() {
        eprintln!(
            "Warning: --token passes the Vault token via CLI argument, which is visible \
             in the process table. Store your Vault token in tsafe and inject it securely:\n\
             \n  tsafe exec -- tsafe vault-pull\n\
             \ntsafe sets VAULT_TOKEN from your vault before spawning the child process."
        );
    }

    let base = addr
        .map(|s| s.to_string())
        .or_else(|| std::env::var("TSAFE_HCP_URL").ok())
        .unwrap_or_else(|| "http://127.0.0.1:8200".into());
    let base = base.trim_end_matches('/');

    // Allow plain HTTP only for localhost (local dev); require HTTPS otherwise.
    if !base.starts_with("https://")
        && !base.starts_with("http://127.0.0.1")
        && !base.starts_with("http://localhost")
    {
        anyhow::bail!(
            "vault-pull address must use https:// for remote servers \
             (plain HTTP is only allowed for localhost)"
        );
    }

    let mount = mount.unwrap_or("secret");

    // Resolve the HCP Vault Enterprise namespace.
    // Priority: argument > YAML config > VAULT_NAMESPACE env var.
    let resolved_namespace: Option<String> = vault_namespace.map(|s| s.to_string()).or_else(|| {
        std::env::var("VAULT_NAMESPACE")
            .ok()
            .filter(|s| !s.is_empty())
    });

    let agent = build_http_agent();

    // Resolve auth.  Priority: --token CLI flag > YAML auth config > env vars.
    let vault_auth: VaultAuth = if let Some(t) = token {
        VaultAuth::Token(t.to_string())
    } else if let Some(auth_cfg) = vault_auth_config {
        use tsafe_core::pullconfig::VaultAuthConfig;
        match auth_cfg {
            VaultAuthConfig::Token { token: Some(t) } => VaultAuth::Token(t.clone()),
            VaultAuthConfig::Token { token: None } => {
                // Fall back to env var resolution.
                VaultAuth::from_env().map_err(|e| anyhow::anyhow!("{e}"))?
            }
            VaultAuthConfig::Approle { role_id, secret_id } => VaultAuth::AppRole {
                role_id: role_id.clone(),
                secret_id: secret_id.clone(),
            },
        }
    } else {
        VaultAuth::from_env().map_err(|e| anyhow::anyhow!("{e}"))?
    };

    // Acquire a client token (AppRole login or static token passthrough).
    let client_token = vault_auth
        .acquire_token(base, resolved_namespace.as_deref(), &agent)
        .map_err(|e| anyhow::anyhow!("{e}"))?;

    // List keys under mount/prefix using KV v2 metadata/list endpoint.
    let list_path = match prefix {
        Some(p) => format!("{base}/v1/{mount}/metadata/{}", p.trim_matches('/')),
        None => format!("{base}/v1/{mount}/metadata/"),
    };

    let mut list_req = agent
        .request("LIST", &list_path)
        .set("X-Vault-Token", &client_token);
    if let Some(ref ns_val) = resolved_namespace {
        list_req = list_req.set("X-Vault-Namespace", ns_val);
    }

    let list_resp: serde_json::Value = list_req
        .call()
        .map_err(|e| {
            anyhow::anyhow!(
                "HashiCorp Vault list failed — check TSAFE_HCP_URL and Vault credentials: {e}"
            )
        })?
        .into_json()?;

    let keys: Vec<String> = list_resp["data"]["keys"]
        .as_array()
        .ok_or_else(|| anyhow::anyhow!("unexpected Vault response: no 'data.keys' field"))?
        .iter()
        .filter_map(|v| v.as_str())
        .filter(|k| !k.ends_with('/')) // skip sub-directories
        .map(|k| k.to_string())
        .collect();

    if keys.is_empty() {
        println!("{} No secrets found at path", "i".blue());
        return Ok(());
    }

    let mut vault = open_vault(profile)?;
    let mut imported = 0usize;
    let mut skipped = 0usize;

    for key_name in &keys {
        let secret_path = match prefix {
            Some(p) => format!("{base}/v1/{mount}/data/{}/{key_name}", p.trim_matches('/')),
            None => format!("{base}/v1/{mount}/data/{key_name}"),
        };

        let mut get_req = agent.get(&secret_path).set("X-Vault-Token", &client_token);
        if let Some(ref ns_val) = resolved_namespace {
            get_req = get_req.set("X-Vault-Namespace", ns_val);
        }

        let resp: serde_json::Value = get_req
            .call()
            .with_context(|| format!("failed to fetch '{key_name}' from Vault"))?
            .into_json()?;

        let data = resp["data"]["data"]
            .as_object()
            .ok_or_else(|| anyhow::anyhow!("unexpected Vault KV v2 response for '{key_name}'"))?;

        for (field, val) in data {
            let raw_key = normalize_vault_key(key_name, field);
            // ADR-012: apply per-source namespace prefix when declared in the manifest.
            let local_key = match ns {
                Some(ns_prefix) => format!("{ns_prefix}.{raw_key}"),
                None => raw_key,
            };
            let value = match val.as_str() {
                Some(s) => s,
                None => {
                    eprintln!(
                        "{} Skipping '{local_key}' — value is not a string (got {})",
                        "warn:".yellow().bold(),
                        if val.is_number() {
                            "number"
                        } else if val.is_boolean() {
                            "boolean"
                        } else {
                            "non-string"
                        }
                    );
                    continue;
                }
            };
            if !overwrite && vault.list().contains(&local_key.as_str()) {
                skipped += 1;
                continue;
            }
            vault.set(&local_key, value, HashMap::new())?;
            imported += 1;
        }
    }

    audit(profile)
        .append(&AuditEntry::success(profile, "vault-pull", None))
        .ok();
    emit_event(profile, "vault-pull", None);
    println!(
        "{} Imported {imported} secret(s) from HashiCorp Vault '{base}'{}",
        "".green(),
        if skipped > 0 {
            format!(" ({skipped} skipped — use --overwrite to replace)")
        } else {
            String::new()
        }
    );
    Ok(())
}

/// Non-feature-gated fallback for `cmd_vault_pull_full` so the compiler can
/// resolve the call site in `cmd_vault_pull_ns` even when `cloud-pull-vault`
/// is not enabled.  This path is unreachable in practice because
/// `cmd_vault_pull_ns` is itself gated on `cloud-pull-vault`.
#[cfg(not(feature = "cloud-pull-vault"))]
#[allow(clippy::too_many_arguments, dead_code)]
pub(crate) fn cmd_vault_pull_full(
    _profile: &str,
    _addr: Option<&str>,
    _token: Option<&str>,
    _mount: Option<&str>,
    _prefix: Option<&str>,
    _overwrite: bool,
    _ns: Option<&str>,
    _vault_auth_config: Option<&tsafe_core::pullconfig::VaultAuthConfig>,
    _vault_namespace: Option<&str>,
) -> Result<()> {
    anyhow::bail!("HashiCorp Vault pull requires the cloud-pull-vault feature")
}

/// Normalise a Vault KV key name to a local vault key.
/// The field value is uppercased; hyphens in the key name are replaced with
/// underscores and the name is uppercased before combining with the field.
///
/// Examples:
///   key_name="my-secret", field="value"  → `MY_SECRET_VALUE`
///   key_name="db-config",  field="HOST"  → `DB_CONFIG_HOST`
#[cfg_attr(not(feature = "cloud-pull-vault"), allow(dead_code))]
pub(crate) fn normalize_vault_key(key_name: &str, field: &str) -> String {
    format!(
        "{}_{}",
        key_name.replace('-', "_").to_uppercase(),
        field.to_uppercase()
    )
}

/// Normalise a 1Password field label to an env-style key name.
///
/// Delegates to the always-compiled `tsafe_cli::op_mapping::op_field_label_to_key`
/// so that integration tests can verify the same logic without a feature gate.
#[cfg(feature = "cloud-pull-1password")]
fn op_field_label_to_key(label: &str) -> String {
    tsafe_cli::op_mapping::op_field_label_to_key(label)
}

#[cfg(feature = "cloud-pull-1password")]
pub(crate) fn cmd_op_pull(
    profile: &str,
    item: &str,
    op_vault: Option<&str>,
    overwrite: bool,
) -> Result<()> {
    cmd_op_pull_ns(profile, item, op_vault, overwrite, None)
}

/// Inner implementation that supports an optional namespace prefix (ADR-012).
///
/// When `ns` is `Some("prod")`, every key imported from 1Password is
/// stored as `prod.KEY_NAME` in the local vault.
///
/// Activation order:
/// 1. When both `OP_CONNECT_URL` and `OP_CONNECT_TOKEN` are set, the native
///    HTTP client path is used (no `op` binary required).
/// 2. Otherwise falls back to the `op` CLI subprocess (unchanged).
#[cfg(feature = "cloud-pull-1password")]
pub(crate) fn cmd_op_pull_ns(
    profile: &str,
    item: &str,
    op_vault: Option<&str>,
    overwrite: bool,
    ns: Option<&str>,
) -> Result<()> {
    // Connect path: activated when both OP_CONNECT_URL and OP_CONNECT_TOKEN
    // are present and the URL passes the HTTPS posture check.
    use tsafe_cli::tsafe_op::config::OpConnectConfig;
    if let Ok(connect_cfg) = OpConnectConfig::from_env() {
        return cmd_op_pull_ns_connect(&connect_cfg, profile, item, op_vault, overwrite, ns);
    }

    // Build the `op item get` command.
    let mut cmd = std::process::Command::new("op");
    cmd.args(["item", "get", item, "--format", "json"]);
    if let Some(v) = op_vault {
        cmd.args(["--vault", v]);
    }
    let output = cmd
        .output()
        .context("could not run 'op' — install the 1Password CLI and sign in first")?;

    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        anyhow::bail!("op exited with status {}: {stderr}", output.status);
    }

    let item_json: serde_json::Value = serde_json::from_slice(&output.stdout)
        .context("could not parse 'op item get' JSON output")?;

    let fields = item_json["fields"]
        .as_array()
        .ok_or_else(|| anyhow::anyhow!("unexpected 'op' JSON: no 'fields' array"))?;

    // Collect non-empty fields and detect intra-item key collisions before
    // touching the vault. Two fields normalising to the same key name is an
    // error unless --overwrite is set (last-listed-wins).
    let candidates: Vec<(String, &str)> = fields
        .iter()
        .filter_map(|field| {
            let label = field["label"].as_str().unwrap_or_default();
            let value = field["value"].as_str().unwrap_or_default();
            if value.is_empty() {
                None
            } else {
                Some((op_field_label_to_key(label), value))
            }
        })
        .collect();

    if !overwrite {
        // Detect collisions: two different labels that normalise to the same key.
        let mut seen: HashMap<&str, Vec<usize>> = HashMap::new();
        for (idx, (key, _)) in candidates.iter().enumerate() {
            seen.entry(key.as_str()).or_default().push(idx);
        }
        let colliding: Vec<&str> = seen
            .iter()
            .filter(|(_, indices)| indices.len() > 1)
            .map(|(key, _)| *key)
            .collect();
        if !colliding.is_empty() {
            let mut sorted = colliding.clone();
            sorted.sort_unstable();
            anyhow::bail!(
                "1Password item '{item}' has fields that normalise to the same key name; \
                 this would cause a silent collision.\n\
                 Colliding key(s): {}\n\
                 Fix: rename the fields in 1Password, or pass --overwrite to use last-listed-wins.",
                sorted.join(", ")
            );
        }
    }

    let mut vault = open_vault(profile)?;
    let mut imported = 0usize;
    let mut skipped = 0usize;

    for (raw_key, value) in &candidates {
        // ADR-012: apply per-source namespace prefix when declared in the manifest.
        let local_key = match ns {
            Some(ns_prefix) => format!("{ns_prefix}.{raw_key}"),
            None => raw_key.clone(),
        };
        if !overwrite && vault.list().contains(&local_key.as_str()) {
            skipped += 1;
            continue;
        }
        vault.set(&local_key, value, HashMap::new())?;
        imported += 1;
    }

    audit(profile)
        .append(&AuditEntry::success(profile, "op-pull", Some(item)))
        .ok();
    emit_event(profile, "op-pull", Some(item));
    println!(
        "{} Imported {imported} field(s) from 1Password item '{item}'{}",
        "".green(),
        if skipped > 0 {
            format!(" ({skipped} skipped — use --overwrite to replace)")
        } else {
            String::new()
        }
    );
    Ok(())
}

/// 1Password Connect Server pull path.
///
/// Replaces the `op` CLI subprocess when both `OP_CONNECT_URL` and
/// `OP_CONNECT_TOKEN` are present.  Logic mirrors `cmd_op_pull_ns` exactly:
/// same field-label mapping (ADR-013 §Part 1), same collision detection
/// (§Part 2), same namespace prefix application (ADR-012), same vault write.
///
/// # Vault name resolution
///
/// `GET /v1/vaults` is called to map the human-readable `vault_name` to a
/// vault UUID.  Resolution is exact-match on the `name` field:
/// - Zero matches → `bail!` (vault not found)
/// - Exactly one match → proceed
/// - Two or more matches → `bail!` (ambiguous; operator must disambiguate by
///   renaming the vault in 1Password)
#[cfg(feature = "cloud-pull-1password")]
fn cmd_op_pull_ns_connect(
    connect_cfg: &tsafe_cli::tsafe_op::config::OpConnectConfig,
    profile: &str,
    item: &str,
    vault_name: Option<&str>,
    overwrite: bool,
    ns: Option<&str>,
) -> Result<()> {
    use tsafe_cli::tsafe_op::client;

    // Step 1: resolve vault UUID from name.
    let vaults = client::list_vaults(connect_cfg)
        .map_err(|e| anyhow::anyhow!("1Password Connect vault list failed: {e}"))?;

    let vault_uuid: String = if let Some(name) = vault_name {
        let matches: Vec<_> = vaults.iter().filter(|v| v.name == name).collect();
        match matches.len() {
            0 => anyhow::bail!(
                "1Password Connect: no vault named '{}' is visible to this token.\n\
                 Available vaults: {}",
                name,
                vaults
                    .iter()
                    .map(|v| v.name.as_str())
                    .collect::<Vec<_>>()
                    .join(", ")
            ),
            1 => matches[0].id.clone(),
            _ => anyhow::bail!(
                "1Password Connect: vault name '{}' matches {} vaults — names must be unique.\n\
                 Rename one vault in 1Password so the name is unambiguous, then retry.",
                name,
                matches.len()
            ),
        }
    } else {
        // No vault specified: use the first vault visible to the token.
        vaults.into_iter().next().map(|v| v.id).ok_or_else(|| {
            anyhow::anyhow!(
                "1Password Connect: no vaults are visible to this token — \
                     check OP_CONNECT_TOKEN permissions"
            )
        })?
    };

    // Step 2: list items and find by exact title match.
    let items = client::list_items(connect_cfg, &vault_uuid)
        .map_err(|e| anyhow::anyhow!("1Password Connect item list failed: {e}"))?;

    let item_matches: Vec<_> = items.iter().filter(|i| i.title == item).collect();
    let item_uuid = match item_matches.len() {
        0 => anyhow::bail!("1Password Connect: no item titled '{item}' found in vault"),
        1 => item_matches[0].id.clone(),
        _ => anyhow::bail!(
            "1Password Connect: item title '{}' matches {} items — titles must be unique",
            item,
            item_matches.len()
        ),
    };

    // Step 3: fetch the full item with fields.
    let op_item = client::get_item(connect_cfg, &vault_uuid, &item_uuid)
        .map_err(|e| anyhow::anyhow!("1Password Connect item fetch failed: {e}"))?;

    // Step 4: apply field-label mapping (ADR-013 §Part 1: label-only, no prefix).
    let candidates: Vec<(String, String)> = op_item
        .fields
        .iter()
        .filter(|f| !f.value.is_empty())
        .map(|f| (op_field_label_to_key(&f.label), f.value.clone()))
        .collect();

    // Step 5: intra-item collision detection (ADR-013 §Part 2).
    if !overwrite {
        let mut seen: HashMap<&str, Vec<usize>> = HashMap::new();
        for (idx, (key, _)) in candidates.iter().enumerate() {
            seen.entry(key.as_str()).or_default().push(idx);
        }
        let mut colliding: Vec<&str> = seen
            .iter()
            .filter(|(_, indices)| indices.len() > 1)
            .map(|(key, _)| *key)
            .collect();
        if !colliding.is_empty() {
            colliding.sort_unstable();
            anyhow::bail!(
                "1Password item '{item}' has fields that normalise to the same key name; \
                 this would cause a silent collision.\n\
                 Colliding key(s): {}\n\
                 Fix: rename the fields in 1Password, or pass --overwrite to use last-listed-wins.",
                colliding.join(", ")
            );
        }
    }

    // Step 6: write to vault.
    let mut vault = open_vault(profile)?;
    let mut imported = 0usize;
    let mut skipped = 0usize;

    for (raw_key, value) in &candidates {
        let local_key = match ns {
            Some(ns_prefix) => format!("{ns_prefix}.{raw_key}"),
            None => raw_key.clone(),
        };
        if !overwrite && vault.list().contains(&local_key.as_str()) {
            skipped += 1;
            continue;
        }
        vault.set(&local_key, value, HashMap::new())?;
        imported += 1;
    }

    audit(profile)
        .append(&AuditEntry::success(profile, "op-pull", Some(item)))
        .ok();
    emit_event(profile, "op-pull", Some(item));
    println!(
        "{} Imported {imported} field(s) from 1Password item '{item}' via Connect{}",
        "".green(),
        if skipped > 0 {
            format!(" ({skipped} skipped — use --overwrite to replace)")
        } else {
            String::new()
        }
    );
    Ok(())
}

// ── tests ─────────────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;

    // ── pure-logic tests ──────────────────────────────────────────────────────

    #[test]
    fn vault_key_normalization_hyphens_to_underscores() {
        assert_eq!(normalize_vault_key("my-secret", "value"), "MY_SECRET_VALUE");
    }

    #[test]
    fn vault_key_normalization_already_uppercase() {
        assert_eq!(normalize_vault_key("DB_CONFIG", "HOST"), "DB_CONFIG_HOST");
    }

    #[test]
    fn vault_key_normalization_mixed_case_field() {
        assert_eq!(
            normalize_vault_key("db-config", "Password"),
            "DB_CONFIG_PASSWORD"
        );
    }

    #[test]
    fn vault_key_normalization_compound_key_name() {
        assert_eq!(
            normalize_vault_key("my-app-api-key", "secret"),
            "MY_APP_API_KEY_SECRET"
        );
    }

    // ── HTTP/mock-server tests ────────────────────────────────────────────────
    //
    // These tests exercise the Vault KV v2 HTTP response classification logic
    // using a mock HTTP server.  They verify that the expected Vault API
    // responses map correctly to success, not-found, access-denied, and
    // sealed-vault semantics without requiring a real Vault instance.

    /// Helper: build a minimal ureq agent with short timeouts for tests.
    fn test_agent() -> ureq::Agent {
        ureq::AgentBuilder::new()
            .timeout_connect(std::time::Duration::from_secs(5))
            .timeout(std::time::Duration::from_secs(10))
            .build()
    }

    /// Vault KV v2 success: LIST returns keys, GET returns data.data with a
    /// string field.  Verifies the happy-path JSON shape is accepted.
    #[test]
    fn vault_kv_list_and_get_happy_path() {
        let mut server = mockito::Server::new();
        let token = "test-vault-token";

        let _list = server
            .mock("LIST", "/v1/secret/metadata/app")
            .match_header("X-Vault-Token", token)
            .with_status(200)
            .with_header("Content-Type", "application/json")
            .with_body(r#"{"data":{"keys":["my-api-key"]}}"#)
            .create();

        let _get = server
            .mock("GET", "/v1/secret/data/app/my-api-key")
            .match_header("X-Vault-Token", token)
            .with_status(200)
            .with_header("Content-Type", "application/json")
            .with_body(r#"{"data":{"data":{"value":"s3cr3t","version":1}}}"#)
            .create();

        let agent = test_agent();

        // LIST
        let list_resp: serde_json::Value = agent
            .request("LIST", &format!("{}/v1/secret/metadata/app", server.url()))
            .set("X-Vault-Token", token)
            .call()
            .expect("LIST should succeed")
            .into_json()
            .expect("LIST response should be valid JSON");

        let keys: Vec<&str> = list_resp["data"]["keys"]
            .as_array()
            .unwrap()
            .iter()
            .filter_map(|v| v.as_str())
            .filter(|k| !k.ends_with('/'))
            .collect();

        assert_eq!(keys, vec!["my-api-key"]);

        // GET
        let get_resp: serde_json::Value = agent
            .get(&format!("{}/v1/secret/data/app/{}", server.url(), keys[0]))
            .set("X-Vault-Token", token)
            .call()
            .expect("GET should succeed")
            .into_json()
            .expect("GET response should be valid JSON");

        let data = get_resp["data"]["data"].as_object().unwrap();
        assert!(data.contains_key("value"));
        assert_eq!(data["value"].as_str().unwrap(), "s3cr3t");

        // Verify key normalization
        let local_key = normalize_vault_key(keys[0], "value");
        assert_eq!(local_key, "MY_API_KEY_VALUE");
    }

    /// Vault KV v2: LIST on a path that does not exist returns 404.
    /// The vault-pull command must surface this as a clear error.
    #[test]
    fn vault_kv_list_path_not_found_returns_404() {
        let mut server = mockito::Server::new();
        let token = "test-vault-token";

        let _m = server
            .mock("LIST", "/v1/secret/metadata/nonexistent")
            .match_header("X-Vault-Token", token)
            .with_status(404)
            .with_header("Content-Type", "application/json")
            .with_body(r#"{"errors":[]}"#)
            .create();

        let agent = test_agent();
        let result = agent
            .request(
                "LIST",
                &format!("{}/v1/secret/metadata/nonexistent", server.url()),
            )
            .set("X-Vault-Token", token)
            .call();

        assert!(
            matches!(result, Err(ureq::Error::Status(404, _))),
            "expected 404 for nonexistent path, got: {:?}",
            result.map(|_| ())
        );
    }

    /// Vault KV v2: LIST with an expired or invalid token returns 403.
    /// The vault-pull command must surface this as an authentication error.
    #[test]
    fn vault_kv_list_expired_token_returns_403() {
        let mut server = mockito::Server::new();

        let _m = server
            .mock("LIST", "/v1/secret/metadata/")
            .with_status(403)
            .with_header("Content-Type", "application/json")
            .with_body(r#"{"errors":["1 error occurred:\n\t* permission denied\n\n"]}"#)
            .create();

        let agent = test_agent();
        let result = agent
            .request("LIST", &format!("{}/v1/secret/metadata/", server.url()))
            .set("X-Vault-Token", "expired-or-invalid-token")
            .call();

        assert!(
            matches!(result, Err(ureq::Error::Status(403, _))),
            "expected 403 for expired token, got: {:?}",
            result.map(|_| ())
        );
    }

    /// Vault sealed: any API call returns 503 with a JSON body indicating
    /// the vault is sealed.  vault-pull must not panic on this response.
    #[test]
    fn vault_kv_sealed_vault_returns_503() {
        let mut server = mockito::Server::new();

        let _m = server
            .mock("LIST", "/v1/secret/metadata/")
            .with_status(503)
            .with_header("Content-Type", "application/json")
            .with_body(r#"{"errors":["error performing token check: Vault is sealed"]}"#)
            .create();

        let agent = test_agent();
        let result = agent
            .request("LIST", &format!("{}/v1/secret/metadata/", server.url()))
            .set("X-Vault-Token", "any-token")
            .call();

        assert!(
            matches!(result, Err(ureq::Error::Status(503, _))),
            "expected 503 for sealed vault, got: {:?}",
            result.map(|_| ())
        );
    }

    /// Vault KV v2: GET for a specific secret version.  Verifies that a
    /// version-pinned path (`/v1/{mount}/data/{secret}?version=2`) returns
    /// the correct `data.data` payload.
    #[test]
    fn vault_kv_get_specific_version_returns_versioned_data() {
        let mut server = mockito::Server::new();
        let token = "test-vault-token";

        let _m = server
            .mock("GET", "/v1/secret/data/my-secret?version=2")
            .match_header("X-Vault-Token", token)
            .with_status(200)
            .with_header("Content-Type", "application/json")
            .with_body(
                r#"{"data":{"data":{"api_key":"v2-value"},"metadata":{"version":2,"destroyed":false}}}"#,
            )
            .create();

        let agent = test_agent();
        let resp: serde_json::Value = agent
            .get(&format!(
                "{}/v1/secret/data/my-secret?version=2",
                server.url()
            ))
            .set("X-Vault-Token", token)
            .call()
            .expect("GET with version should succeed")
            .into_json()
            .expect("response should be valid JSON");

        let data = resp["data"]["data"].as_object().unwrap();
        assert_eq!(data["api_key"].as_str().unwrap(), "v2-value");

        let local_key = normalize_vault_key("my-secret", "api_key");
        assert_eq!(local_key, "MY_SECRET_API_KEY");
    }

    /// Vault KV v2: GET response with `data.data` missing raises an error.
    /// Exercises the `ok_or_else` guard in the vault-pull data extraction path.
    #[test]
    fn vault_kv_get_missing_data_field_is_rejected() {
        let mut server = mockito::Server::new();
        let token = "test-vault-token";

        let _m = server
            .mock("GET", "/v1/secret/data/bad-secret")
            .match_header("X-Vault-Token", token)
            .with_status(200)
            .with_header("Content-Type", "application/json")
            // Malformed: has `data` wrapper but no inner `data` key
            .with_body(r#"{"data":{"metadata":{"version":1}}}"#)
            .create();

        let agent = test_agent();
        let resp: serde_json::Value = agent
            .get(&format!("{}/v1/secret/data/bad-secret", server.url()))
            .set("X-Vault-Token", token)
            .call()
            .expect("GET should return 200")
            .into_json()
            .expect("response should be valid JSON");

        // The data.data field must be an object — if absent, the pull command
        // returns an error.  Here we verify that `as_object()` returns None
        // for the malformed response, which is the guard the command uses.
        assert!(
            resp["data"]["data"].as_object().is_none(),
            "expected missing data.data to return None from as_object()"
        );
    }

    /// Vault KV v2: sub-directory entries (keys ending with `/`) are skipped
    /// by the vault-pull list step.  Verifies the filter logic.
    #[test]
    fn vault_kv_list_skips_subdirectory_entries() {
        let keys_raw = ["api-key", "nested/", "db-password"];
        let filtered: Vec<&str> = keys_raw
            .iter()
            .copied()
            .filter(|k| !k.ends_with('/'))
            .collect();

        assert_eq!(filtered, vec!["api-key", "db-password"]);
        assert!(!filtered.contains(&"nested/"));
    }

    /// Vault KV v2: a non-string value in `data.data` (e.g. a number) must be
    /// skipped rather than panicking.  Verifies the `as_str()` guard logic.
    #[test]
    fn vault_kv_non_string_field_value_is_skipped() {
        // Simulate what `val.as_str()` returns for various JSON types.
        let json_num = serde_json::json!(42);
        let json_bool = serde_json::json!(true);
        let json_str = serde_json::json!("hello");

        assert!(json_num.as_str().is_none(), "number should not be a string");
        assert!(json_bool.as_str().is_none(), "bool should not be a string");
        assert_eq!(json_str.as_str(), Some("hello"));
    }

    // ── Namespace header tests ─────────────────────────────────────────────────

    /// KV LIST request carries `X-Vault-Namespace` when namespace is configured.
    #[test]
    fn namespace_header_is_present_on_kv_list() {
        let mut server = mockito::Server::new();
        let token = "test-vault-token";

        let _m = server
            .mock("LIST", "/v1/secret/metadata/")
            .match_header("X-Vault-Token", token)
            .match_header("X-Vault-Namespace", "team-alpha")
            .with_status(200)
            .with_header("Content-Type", "application/json")
            .with_body(r#"{"data":{"keys":[]}}"#)
            .create();

        let agent = test_agent();
        let result = agent
            .request("LIST", &format!("{}/v1/secret/metadata/", server.url()))
            .set("X-Vault-Token", token)
            .set("X-Vault-Namespace", "team-alpha")
            .call();

        // 200 with empty keys — just verifying the header was matched.
        assert!(
            result.is_ok(),
            "expected 200 with namespace header, got: {result:?}"
        );
    }

    // ── 1Password field-label normalisation tests ─────────────────────────────

    #[cfg(feature = "cloud-pull-1password")]
    #[test]
    fn op_field_label_spaces_to_screaming_snake() {
        assert_eq!(op_field_label_to_key("My Secret"), "MY_SECRET");
    }

    #[cfg(feature = "cloud-pull-1password")]
    #[test]
    fn op_field_label_hyphens_to_screaming_snake() {
        assert_eq!(op_field_label_to_key("db-password"), "DB_PASSWORD");
    }

    #[cfg(feature = "cloud-pull-1password")]
    #[test]
    fn op_field_label_already_upper_passthrough() {
        assert_eq!(op_field_label_to_key("API_KEY"), "API_KEY");
    }

    #[cfg(feature = "cloud-pull-1password")]
    #[test]
    fn op_field_label_mixed_spaces_and_hyphens() {
        assert_eq!(op_field_label_to_key("my-API key"), "MY_API_KEY");
    }
}