@wlfi-agent/cli 1.4.12 → 1.4.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (289) hide show
  1. package/Cargo.lock +3968 -0
  2. package/Cargo.toml +50 -0
  3. package/README.md +426 -6
  4. package/crates/vault-cli-admin/Cargo.toml +26 -0
  5. package/crates/vault-cli-admin/src/io_utils.rs +500 -0
  6. package/crates/vault-cli-admin/src/main.rs +3990 -0
  7. package/crates/vault-cli-admin/src/shared_config.rs +624 -0
  8. package/crates/vault-cli-admin/src/tui/amounts.rs +180 -0
  9. package/crates/vault-cli-admin/src/tui/token_rpc.rs +250 -0
  10. package/crates/vault-cli-admin/src/tui/utils.rs +82 -0
  11. package/crates/vault-cli-admin/src/tui.rs +3410 -0
  12. package/crates/vault-cli-agent/Cargo.toml +24 -0
  13. package/crates/vault-cli-agent/src/io_utils.rs +576 -0
  14. package/crates/vault-cli-agent/src/main.rs +833 -0
  15. package/crates/vault-cli-daemon/Cargo.toml +28 -0
  16. package/crates/vault-cli-daemon/src/bin/wlfi-agent-system-keychain.rs +216 -0
  17. package/crates/vault-cli-daemon/src/main.rs +644 -0
  18. package/crates/vault-cli-daemon/src/relay_sync.rs +894 -0
  19. package/crates/vault-cli-daemon/tests/system_keychain_helper_acl.rs +167 -0
  20. package/crates/vault-daemon/Cargo.toml +32 -0
  21. package/crates/vault-daemon/src/daemon_parts/api_impl_and_utils.rs +1041 -0
  22. package/crates/vault-daemon/src/daemon_parts/core_helpers.rs +1256 -0
  23. package/crates/vault-daemon/src/daemon_parts/types_api_rpc.rs +622 -0
  24. package/crates/vault-daemon/src/lib.rs +54 -0
  25. package/crates/vault-daemon/src/persistence.rs +441 -0
  26. package/crates/vault-daemon/src/tests.rs +237 -0
  27. package/crates/vault-daemon/src/tests_parts/part1.rs +1224 -0
  28. package/crates/vault-daemon/src/tests_parts/part2.rs +1021 -0
  29. package/crates/vault-daemon/src/tests_parts/part3.rs +835 -0
  30. package/crates/vault-daemon/src/tests_parts/part4.rs +604 -0
  31. package/crates/vault-domain/Cargo.toml +20 -0
  32. package/crates/vault-domain/src/action.rs +849 -0
  33. package/crates/vault-domain/src/address.rs +51 -0
  34. package/crates/vault-domain/src/approval.rs +90 -0
  35. package/crates/vault-domain/src/constants.rs +4 -0
  36. package/crates/vault-domain/src/error.rs +54 -0
  37. package/crates/vault-domain/src/keys.rs +71 -0
  38. package/crates/vault-domain/src/lib.rs +42 -0
  39. package/crates/vault-domain/src/nonce.rs +102 -0
  40. package/crates/vault-domain/src/policy.rs +172 -0
  41. package/crates/vault-domain/src/request.rs +53 -0
  42. package/crates/vault-domain/src/scope.rs +24 -0
  43. package/crates/vault-domain/src/session.rs +50 -0
  44. package/crates/vault-domain/src/signature.rs +34 -0
  45. package/crates/vault-domain/src/tests.rs +651 -0
  46. package/crates/vault-domain/src/u128_as_decimal_string.rs +44 -0
  47. package/crates/vault-policy/Cargo.toml +17 -0
  48. package/crates/vault-policy/src/engine.rs +301 -0
  49. package/crates/vault-policy/src/error.rs +81 -0
  50. package/crates/vault-policy/src/lib.rs +17 -0
  51. package/crates/vault-policy/src/report.rs +34 -0
  52. package/crates/vault-policy/src/tests.rs +891 -0
  53. package/crates/vault-policy/src/tests_explain.rs +78 -0
  54. package/crates/vault-sdk-agent/Cargo.toml +21 -0
  55. package/crates/vault-sdk-agent/src/lib.rs +711 -0
  56. package/crates/vault-signer/Cargo.toml +25 -0
  57. package/crates/vault-signer/src/lib.rs +731 -0
  58. package/crates/vault-signer/tests/secure_enclave_acl.rs +54 -0
  59. package/crates/vault-transport-unix/Cargo.toml +24 -0
  60. package/crates/vault-transport-unix/src/lib.rs +1640 -0
  61. package/crates/vault-transport-xpc/Cargo.toml +25 -0
  62. package/crates/vault-transport-xpc/src/client_codec_api.rs +635 -0
  63. package/crates/vault-transport-xpc/src/lib.rs +680 -0
  64. package/crates/vault-transport-xpc/src/tests.rs +818 -0
  65. package/crates/vault-transport-xpc/tests/e2e_flow.rs +773 -0
  66. package/dist/cli.cjs +35088 -0
  67. package/dist/cli.cjs.map +1 -0
  68. package/package.json +49 -43
  69. package/packages/cache/.turbo/turbo-build.log +52 -0
  70. package/packages/cache/dist/chunk-2QFWMUXT.cjs +43 -0
  71. package/packages/cache/dist/chunk-2QFWMUXT.cjs.map +1 -0
  72. package/packages/cache/dist/chunk-4U63TZTQ.js +43 -0
  73. package/packages/cache/dist/chunk-4U63TZTQ.js.map +1 -0
  74. package/packages/cache/dist/chunk-ALQ6H7KG.cjs +404 -0
  75. package/packages/cache/dist/chunk-ALQ6H7KG.cjs.map +1 -0
  76. package/packages/cache/dist/chunk-FGJEEF5N.js +404 -0
  77. package/packages/cache/dist/chunk-FGJEEF5N.js.map +1 -0
  78. package/packages/cache/dist/chunk-UYNEHZHB.cjs +45 -0
  79. package/packages/cache/dist/chunk-UYNEHZHB.cjs.map +1 -0
  80. package/packages/cache/dist/chunk-VXVMPG3W.js +45 -0
  81. package/packages/cache/dist/chunk-VXVMPG3W.js.map +1 -0
  82. package/packages/cache/dist/client/index.cjs +11 -0
  83. package/packages/cache/dist/client/index.cjs.map +1 -0
  84. package/packages/cache/dist/client/index.d.cts +15 -0
  85. package/packages/cache/dist/client/index.d.ts +15 -0
  86. package/packages/cache/dist/client/index.js +11 -0
  87. package/packages/cache/dist/client/index.js.map +1 -0
  88. package/packages/cache/dist/errors/index.cjs +11 -0
  89. package/packages/cache/dist/errors/index.cjs.map +1 -0
  90. package/packages/cache/dist/errors/index.d.cts +26 -0
  91. package/packages/cache/dist/errors/index.d.ts +26 -0
  92. package/packages/cache/dist/errors/index.js +11 -0
  93. package/packages/cache/dist/errors/index.js.map +1 -0
  94. package/packages/cache/dist/index.cjs +29 -0
  95. package/packages/cache/dist/index.cjs.map +1 -0
  96. package/packages/cache/dist/index.d.cts +4 -0
  97. package/packages/cache/dist/index.d.ts +4 -0
  98. package/packages/cache/dist/index.js +29 -0
  99. package/packages/cache/dist/index.js.map +1 -0
  100. package/packages/cache/dist/service/index.cjs +15 -0
  101. package/packages/cache/dist/service/index.cjs.map +1 -0
  102. package/packages/cache/dist/service/index.d.cts +184 -0
  103. package/packages/cache/dist/service/index.d.ts +184 -0
  104. package/packages/cache/dist/service/index.js +15 -0
  105. package/packages/cache/dist/service/index.js.map +1 -0
  106. package/packages/cache/node_modules/.bin/jiti +17 -0
  107. package/packages/cache/node_modules/.bin/tsc +17 -0
  108. package/packages/cache/node_modules/.bin/tsserver +17 -0
  109. package/packages/cache/node_modules/.bin/tsup +17 -0
  110. package/packages/cache/node_modules/.bin/tsup-node +17 -0
  111. package/packages/cache/node_modules/.bin/tsx +17 -0
  112. package/packages/cache/node_modules/.bin/vitest +17 -0
  113. package/packages/cache/package.json +48 -0
  114. package/packages/cache/src/client/index.ts +56 -0
  115. package/packages/cache/src/errors/index.ts +53 -0
  116. package/packages/cache/src/index.ts +3 -0
  117. package/packages/cache/src/service/index.test.ts +263 -0
  118. package/packages/cache/src/service/index.ts +678 -0
  119. package/packages/cache/tsconfig.json +13 -0
  120. package/packages/cache/tsup.config.ts +13 -0
  121. package/packages/cache/vitest.config.ts +16 -0
  122. package/packages/config/.turbo/turbo-build.log +18 -0
  123. package/packages/config/dist/index.cjs +1037 -0
  124. package/packages/config/dist/index.cjs.map +1 -0
  125. package/packages/config/dist/index.d.ts +131 -0
  126. package/packages/config/node_modules/.bin/jiti +17 -0
  127. package/packages/config/node_modules/.bin/tsc +17 -0
  128. package/packages/config/node_modules/.bin/tsserver +17 -0
  129. package/packages/config/node_modules/.bin/tsup +17 -0
  130. package/packages/config/node_modules/.bin/tsup-node +17 -0
  131. package/packages/config/node_modules/.bin/tsx +17 -0
  132. package/packages/config/package.json +21 -0
  133. package/packages/config/src/index.js +1 -0
  134. package/packages/config/src/index.ts +1282 -0
  135. package/packages/config/tsconfig.json +4 -0
  136. package/packages/rpc/.turbo/turbo-build.log +32 -0
  137. package/packages/rpc/dist/_esm-BCLXDO2R.cjs +3660 -0
  138. package/packages/rpc/dist/_esm-BCLXDO2R.cjs.map +1 -0
  139. package/packages/rpc/dist/ccip-OWJLAW55.cjs +16 -0
  140. package/packages/rpc/dist/ccip-OWJLAW55.cjs.map +1 -0
  141. package/packages/rpc/dist/chunk-APQIFZ3B.cjs +6247 -0
  142. package/packages/rpc/dist/chunk-APQIFZ3B.cjs.map +1 -0
  143. package/packages/rpc/dist/chunk-CDO2GWRD.cjs +410 -0
  144. package/packages/rpc/dist/chunk-CDO2GWRD.cjs.map +1 -0
  145. package/packages/rpc/dist/chunk-QGTNTFJ7.cjs +2249 -0
  146. package/packages/rpc/dist/chunk-QGTNTFJ7.cjs.map +1 -0
  147. package/packages/rpc/dist/chunk-TZDTAHWR.cjs +44 -0
  148. package/packages/rpc/dist/chunk-TZDTAHWR.cjs.map +1 -0
  149. package/packages/rpc/dist/index.cjs +7342 -0
  150. package/packages/rpc/dist/index.cjs.map +1 -0
  151. package/packages/rpc/dist/index.d.ts +3857 -0
  152. package/packages/rpc/dist/secp256k1-WCNM675D.cjs +18 -0
  153. package/packages/rpc/dist/secp256k1-WCNM675D.cjs.map +1 -0
  154. package/packages/rpc/node_modules/.bin/jiti +17 -0
  155. package/packages/rpc/node_modules/.bin/tsc +17 -0
  156. package/packages/rpc/node_modules/.bin/tsserver +17 -0
  157. package/packages/rpc/node_modules/.bin/tsup +17 -0
  158. package/packages/rpc/node_modules/.bin/tsup-node +17 -0
  159. package/packages/rpc/node_modules/.bin/tsx +17 -0
  160. package/packages/rpc/package.json +25 -0
  161. package/packages/rpc/src/index.ts +206 -0
  162. package/packages/rpc/tsconfig.json +4 -0
  163. package/packages/typescript/base.json +36 -0
  164. package/packages/typescript/nextjs.json +17 -0
  165. package/packages/typescript/package.json +10 -0
  166. package/packages/ui/.turbo/turbo-build.log +44 -0
  167. package/packages/ui/dist/chunk-MOAFBKSA.js +11 -0
  168. package/packages/ui/dist/chunk-MOAFBKSA.js.map +1 -0
  169. package/packages/ui/dist/components/badge.d.ts +12 -0
  170. package/packages/ui/dist/components/badge.js +31 -0
  171. package/packages/ui/dist/components/badge.js.map +1 -0
  172. package/packages/ui/dist/components/button.d.ts +13 -0
  173. package/packages/ui/dist/components/button.js +40 -0
  174. package/packages/ui/dist/components/button.js.map +1 -0
  175. package/packages/ui/dist/components/card.d.ts +10 -0
  176. package/packages/ui/dist/components/card.js +39 -0
  177. package/packages/ui/dist/components/card.js.map +1 -0
  178. package/packages/ui/dist/components/input.d.ts +5 -0
  179. package/packages/ui/dist/components/input.js +28 -0
  180. package/packages/ui/dist/components/input.js.map +1 -0
  181. package/packages/ui/dist/components/label.d.ts +5 -0
  182. package/packages/ui/dist/components/label.js +13 -0
  183. package/packages/ui/dist/components/label.js.map +1 -0
  184. package/packages/ui/dist/components/separator.d.ts +5 -0
  185. package/packages/ui/dist/components/separator.js +13 -0
  186. package/packages/ui/dist/components/separator.js.map +1 -0
  187. package/packages/ui/dist/components/textarea.d.ts +5 -0
  188. package/packages/ui/dist/components/textarea.js +27 -0
  189. package/packages/ui/dist/components/textarea.js.map +1 -0
  190. package/packages/ui/dist/tailwind.d.ts +56 -0
  191. package/packages/ui/dist/tailwind.js +60 -0
  192. package/packages/ui/dist/tailwind.js.map +1 -0
  193. package/packages/ui/dist/utils/cn.d.ts +5 -0
  194. package/packages/ui/dist/utils/cn.js +7 -0
  195. package/packages/ui/dist/utils/cn.js.map +1 -0
  196. package/packages/ui/node_modules/.bin/jiti +17 -0
  197. package/packages/ui/node_modules/.bin/tsc +17 -0
  198. package/packages/ui/node_modules/.bin/tsserver +17 -0
  199. package/packages/ui/node_modules/.bin/tsup +17 -0
  200. package/packages/ui/node_modules/.bin/tsup-node +17 -0
  201. package/packages/ui/node_modules/.bin/tsx +17 -0
  202. package/packages/ui/package.json +69 -0
  203. package/packages/ui/src/components/badge.tsx +27 -0
  204. package/packages/ui/src/components/button.tsx +40 -0
  205. package/packages/ui/src/components/card.tsx +31 -0
  206. package/packages/ui/src/components/input.tsx +21 -0
  207. package/packages/ui/src/components/label.tsx +6 -0
  208. package/packages/ui/src/components/separator.tsx +6 -0
  209. package/packages/ui/src/components/textarea.tsx +20 -0
  210. package/packages/ui/src/globals.css +70 -0
  211. package/packages/ui/src/tailwind.ts +56 -0
  212. package/packages/ui/src/utils/cn.ts +6 -0
  213. package/packages/ui/tsconfig.json +20 -0
  214. package/packages/ui/tsup.config.ts +20 -0
  215. package/pnpm-workspace.yaml +4 -0
  216. package/scripts/install-rust-binaries.mjs +84 -0
  217. package/scripts/launchd/install-user-daemon.sh +358 -0
  218. package/scripts/launchd/run-vault-daemon.sh +5 -0
  219. package/scripts/launchd/run-wlfi-agent-daemon.sh +73 -0
  220. package/scripts/launchd/uninstall-user-daemon.sh +103 -0
  221. package/src/cli.ts +2121 -0
  222. package/src/lib/admin-guard.js +1 -0
  223. package/src/lib/admin-guard.ts +185 -0
  224. package/src/lib/admin-passthrough.ts +33 -0
  225. package/src/lib/admin-reset.ts +751 -0
  226. package/src/lib/admin-setup.ts +1612 -0
  227. package/src/lib/agent-auth-clear.js +1 -0
  228. package/src/lib/agent-auth-clear.ts +58 -0
  229. package/src/lib/agent-auth-forwarding.js +1 -0
  230. package/src/lib/agent-auth-forwarding.ts +149 -0
  231. package/src/lib/agent-auth-migrate.js +1 -0
  232. package/src/lib/agent-auth-migrate.ts +150 -0
  233. package/src/lib/agent-auth-revoke.ts +103 -0
  234. package/src/lib/agent-auth-rotate.ts +107 -0
  235. package/src/lib/agent-auth-token.js +1 -0
  236. package/src/lib/agent-auth-token.ts +25 -0
  237. package/src/lib/agent-auth.ts +89 -0
  238. package/src/lib/asset-broadcast.js +1 -0
  239. package/src/lib/asset-broadcast.ts +285 -0
  240. package/src/lib/bootstrap-artifacts.js +1 -0
  241. package/src/lib/bootstrap-artifacts.ts +205 -0
  242. package/src/lib/bootstrap-credentials.js +1 -0
  243. package/src/lib/bootstrap-credentials.ts +832 -0
  244. package/src/lib/config-amounts.js +1 -0
  245. package/src/lib/config-amounts.ts +189 -0
  246. package/src/lib/config-mutation.ts +27 -0
  247. package/src/lib/fs-trust.js +1 -0
  248. package/src/lib/fs-trust.ts +537 -0
  249. package/src/lib/keychain.js +1 -0
  250. package/src/lib/keychain.ts +225 -0
  251. package/src/lib/local-admin-access.ts +106 -0
  252. package/src/lib/network-selection.js +1 -0
  253. package/src/lib/network-selection.ts +71 -0
  254. package/src/lib/passthrough-security.js +1 -0
  255. package/src/lib/passthrough-security.ts +114 -0
  256. package/src/lib/rpc-guard.js +1 -0
  257. package/src/lib/rpc-guard.ts +7 -0
  258. package/src/lib/rust-spawn-options.js +1 -0
  259. package/src/lib/rust-spawn-options.ts +98 -0
  260. package/src/lib/rust.js +1 -0
  261. package/src/lib/rust.ts +143 -0
  262. package/src/lib/signed-tx.js +1 -0
  263. package/src/lib/signed-tx.ts +116 -0
  264. package/src/lib/status-repair-cli.ts +116 -0
  265. package/src/lib/sudo.js +1 -0
  266. package/src/lib/sudo.ts +172 -0
  267. package/src/lib/vault-password-forwarding.js +1 -0
  268. package/src/lib/vault-password-forwarding.ts +155 -0
  269. package/src/lib/wallet-profile.js +1 -0
  270. package/src/lib/wallet-profile.ts +332 -0
  271. package/src/lib/wallet-repair.js +1 -0
  272. package/src/lib/wallet-repair.ts +304 -0
  273. package/src/lib/wallet-setup.js +1 -0
  274. package/src/lib/wallet-setup.ts +1466 -0
  275. package/src/lib/wallet-status.js +1 -0
  276. package/src/lib/wallet-status.ts +640 -0
  277. package/tsconfig.base.json +17 -0
  278. package/tsconfig.json +10 -0
  279. package/tsup.config.ts +25 -0
  280. package/turbo.json +41 -0
  281. package/LICENSE.md +0 -1
  282. package/dist/wlfa/index.cjs +0 -250
  283. package/dist/wlfa/index.d.cts +0 -1
  284. package/dist/wlfa/index.d.ts +0 -1
  285. package/dist/wlfa/index.js +0 -250
  286. package/dist/wlfc/index.cjs +0 -1894
  287. package/dist/wlfc/index.d.cts +0 -1
  288. package/dist/wlfc/index.d.ts +0 -1
  289. package/dist/wlfc/index.js +0 -1894
@@ -0,0 +1,500 @@
1
+ use std::fs::OpenOptions;
2
+ use std::io::{Read, Write};
3
+ use std::path::{Path, PathBuf};
4
+
5
+ use anyhow::{anyhow, bail, Context, Result};
6
+ #[cfg(unix)]
7
+ use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
8
+ use zeroize::Zeroize;
9
+
10
+ use crate::{OutputFormat, OutputTarget};
11
+
12
+ const MAX_SECRET_STDIN_BYTES: u64 = 16 * 1024;
13
+ #[cfg(unix)]
14
+ const PRIVATE_DIR_MODE: u32 = 0o700;
15
+ #[cfg(unix)]
16
+ const GROUP_OTHER_WRITE_MODE_MASK: u32 = 0o022;
17
+ #[cfg(unix)]
18
+ const STICKY_BIT_MODE: u32 = 0o1000;
19
+
20
+ pub(crate) fn resolve_vault_password(from_stdin: bool, non_interactive: bool) -> Result<String> {
21
+ if from_stdin {
22
+ return read_secret_from_reader(std::io::stdin(), "vault password");
23
+ }
24
+
25
+ if non_interactive {
26
+ bail!("vault password is required in non-interactive mode; use --vault-password-stdin");
27
+ }
28
+
29
+ let prompted =
30
+ rpassword::prompt_password("Vault password: ").context("failed to read password input")?;
31
+ validate_password(prompted, "prompt")
32
+ }
33
+
34
+ pub(crate) fn validate_password(mut password: String, source: &str) -> Result<String> {
35
+ if password.as_bytes().len() > MAX_SECRET_STDIN_BYTES as usize {
36
+ password.zeroize();
37
+ bail!("vault password from {source} must not exceed {MAX_SECRET_STDIN_BYTES} bytes");
38
+ }
39
+ if password.trim().is_empty() {
40
+ password.zeroize();
41
+ bail!("vault password from {source} must not be empty or whitespace");
42
+ }
43
+ Ok(password)
44
+ }
45
+
46
+ fn read_secret_from_reader(mut reader: impl Read, label: &str) -> Result<String> {
47
+ let mut raw = String::new();
48
+ reader
49
+ .by_ref()
50
+ .take(MAX_SECRET_STDIN_BYTES + 1)
51
+ .read_to_string(&mut raw)
52
+ .with_context(|| format!("failed to read {label} from stdin"))?;
53
+ if raw.as_bytes().len() > MAX_SECRET_STDIN_BYTES as usize {
54
+ raw.zeroize();
55
+ bail!("{label} must not exceed {MAX_SECRET_STDIN_BYTES} bytes");
56
+ }
57
+ let secret = raw.trim_end_matches(['\r', '\n']).to_string();
58
+ raw.zeroize();
59
+ validate_password(secret, "stdin")
60
+ }
61
+
62
+ #[cfg(test)]
63
+ mod tests {
64
+ use super::{
65
+ ensure_output_parent, read_secret_from_reader, resolve_vault_password, validate_password,
66
+ write_output_file,
67
+ };
68
+ use std::fs;
69
+ use std::io::Cursor;
70
+ use std::path::PathBuf;
71
+ use std::time::{SystemTime, UNIX_EPOCH};
72
+
73
+ fn temp_path(prefix: &str) -> PathBuf {
74
+ std::env::temp_dir().join(format!(
75
+ "{prefix}-{}-{}",
76
+ std::process::id(),
77
+ SystemTime::now()
78
+ .duration_since(UNIX_EPOCH)
79
+ .expect("system time before unix epoch")
80
+ .as_nanos()
81
+ ))
82
+ }
83
+
84
+ #[test]
85
+ fn read_secret_from_reader_rejects_oversized_stdin() {
86
+ let oversized = Cursor::new(vec![b'a'; (16 * 1024) + 1]);
87
+ let err = read_secret_from_reader(oversized, "vault password").expect_err("must fail");
88
+ assert!(err.to_string().contains("must not exceed"));
89
+ }
90
+
91
+ #[test]
92
+ fn validate_password_rejects_oversized_non_stdin_secret() {
93
+ let err = validate_password("a".repeat((16 * 1024) + 1), "argument or environment")
94
+ .expect_err("must fail");
95
+ assert!(err.to_string().contains("must not exceed"));
96
+ }
97
+
98
+ #[test]
99
+ fn resolve_vault_password_requires_stdin_in_non_interactive_mode() {
100
+ let err = resolve_vault_password(false, true).expect_err("must fail");
101
+ assert!(err.to_string().contains("use --vault-password-stdin"));
102
+ }
103
+
104
+ #[test]
105
+ #[cfg(unix)]
106
+ fn ensure_output_parent_rejects_symlinked_parent_directory() {
107
+ let root = temp_path("vault-cli-admin-output-symlink");
108
+ let actual = root.join("actual");
109
+ let linked = root.join("linked");
110
+ fs::create_dir_all(&actual).expect("create actual directory");
111
+ std::os::unix::fs::symlink(&actual, &linked).expect("create symlink parent");
112
+
113
+ let err = ensure_output_parent(&linked.join("output.json")).expect_err("must reject");
114
+ assert!(err.to_string().contains("must not be a symlink"));
115
+
116
+ fs::remove_dir_all(&root).expect("cleanup temp tree");
117
+ }
118
+
119
+ #[test]
120
+ #[cfg(unix)]
121
+ fn ensure_output_parent_rejects_group_writable_parent_directory() {
122
+ use std::os::unix::fs::PermissionsExt;
123
+
124
+ let root = temp_path("vault-cli-admin-output-mode");
125
+ let insecure = root.join("shared");
126
+ fs::create_dir_all(&insecure).expect("create insecure directory");
127
+ fs::set_permissions(&insecure, fs::Permissions::from_mode(0o777))
128
+ .expect("set insecure permissions");
129
+
130
+ let err = ensure_output_parent(&insecure.join("output.json")).expect_err("must reject");
131
+ assert!(err
132
+ .to_string()
133
+ .contains("must not be writable by group/other"));
134
+
135
+ fs::set_permissions(&insecure, fs::Permissions::from_mode(0o700))
136
+ .expect("restore cleanup permissions");
137
+ fs::remove_dir_all(&root).expect("cleanup temp tree");
138
+ }
139
+
140
+ #[test]
141
+ #[cfg(unix)]
142
+ fn ensure_output_parent_rejects_group_writable_ancestor_directory() {
143
+ use std::os::unix::fs::PermissionsExt;
144
+
145
+ let root = temp_path("vault-cli-admin-output-ancestor-mode");
146
+ let insecure = root.join("shared");
147
+ let nested = insecure.join("nested");
148
+ fs::create_dir_all(&nested).expect("create nested directory");
149
+ fs::set_permissions(&insecure, fs::Permissions::from_mode(0o777))
150
+ .expect("set insecure ancestor permissions");
151
+ fs::set_permissions(&nested, fs::Permissions::from_mode(0o700))
152
+ .expect("set nested permissions");
153
+
154
+ let err = ensure_output_parent(&nested.join("output.json")).expect_err("must reject");
155
+ assert!(err
156
+ .to_string()
157
+ .contains("must not be writable by group/other"));
158
+
159
+ fs::set_permissions(&insecure, fs::Permissions::from_mode(0o700))
160
+ .expect("restore cleanup permissions");
161
+ fs::remove_dir_all(&root).expect("cleanup temp tree");
162
+ }
163
+
164
+ #[test]
165
+ #[cfg(unix)]
166
+ fn ensure_output_parent_creates_private_missing_directories() {
167
+ use std::os::unix::fs::PermissionsExt;
168
+
169
+ let root = temp_path("vault-cli-admin-output-create");
170
+ fs::create_dir_all(&root).expect("create root directory");
171
+
172
+ let nested_output = root.join("nested").join("deeper").join("output.json");
173
+ ensure_output_parent(&nested_output).expect("must create parent directories");
174
+
175
+ let nested_mode = fs::metadata(root.join("nested"))
176
+ .expect("nested metadata")
177
+ .permissions()
178
+ .mode()
179
+ & 0o777;
180
+ let deeper_mode = fs::metadata(root.join("nested").join("deeper"))
181
+ .expect("deeper metadata")
182
+ .permissions()
183
+ .mode()
184
+ & 0o777;
185
+ assert_eq!(nested_mode, 0o700);
186
+ assert_eq!(deeper_mode, 0o700);
187
+
188
+ fs::remove_dir_all(&root).expect("cleanup temp tree");
189
+ }
190
+
191
+ #[test]
192
+ #[cfg(unix)]
193
+ fn write_output_file_overwrite_replaces_existing_hard_link_instead_of_mutating_shared_inode() {
194
+ use std::os::unix::fs::PermissionsExt;
195
+
196
+ let root = temp_path("vault-cli-admin-output-overwrite-hardlink");
197
+ fs::create_dir_all(&root).expect("create root directory");
198
+ fs::set_permissions(&root, fs::Permissions::from_mode(0o700))
199
+ .expect("secure root directory permissions");
200
+
201
+ let output_path = root.join("output.json");
202
+ let alias_path = root.join("output-alias.json");
203
+ fs::write(&output_path, "old\n").expect("write original output file");
204
+ fs::set_permissions(&output_path, fs::Permissions::from_mode(0o600))
205
+ .expect("secure original output file permissions");
206
+ fs::hard_link(&output_path, &alias_path).expect("create hard link alias");
207
+
208
+ write_output_file(&output_path, "new", true).expect("overwrite output file");
209
+
210
+ assert_eq!(
211
+ fs::read_to_string(&output_path).expect("read replaced output"),
212
+ "new\n"
213
+ );
214
+ assert_eq!(
215
+ fs::read_to_string(&alias_path).expect("read hard link alias"),
216
+ "old\n"
217
+ );
218
+
219
+ fs::remove_dir_all(&root).expect("cleanup temp tree");
220
+ }
221
+ }
222
+
223
+ pub(crate) fn resolve_output_format(
224
+ format: Option<OutputFormat>,
225
+ json: bool,
226
+ ) -> Result<OutputFormat> {
227
+ if json {
228
+ if matches!(format, Some(OutputFormat::Text)) {
229
+ bail!("--json cannot be combined with --format text");
230
+ }
231
+ return Ok(OutputFormat::Json);
232
+ }
233
+ Ok(format.unwrap_or(OutputFormat::Text))
234
+ }
235
+
236
+ pub(crate) fn resolve_output_target(
237
+ target: Option<PathBuf>,
238
+ overwrite: bool,
239
+ ) -> Result<OutputTarget> {
240
+ match target {
241
+ Some(path) if path.as_os_str() == "-" => {
242
+ if overwrite {
243
+ bail!("--overwrite cannot be used with --output - (stdout)");
244
+ }
245
+ Ok(OutputTarget::Stdout)
246
+ }
247
+ Some(path) => Ok(OutputTarget::File { path, overwrite }),
248
+ None => Ok(OutputTarget::Stdout),
249
+ }
250
+ }
251
+
252
+ pub(crate) fn emit_output(output: &str, target: &OutputTarget) -> Result<()> {
253
+ match target {
254
+ OutputTarget::Stdout => {
255
+ println!("{output}");
256
+ Ok(())
257
+ }
258
+ OutputTarget::File { path, overwrite } => {
259
+ ensure_output_parent(path)?;
260
+ write_output_file(path, output, *overwrite)
261
+ }
262
+ }
263
+ }
264
+
265
+ pub(crate) fn ensure_output_parent(path: &Path) -> Result<()> {
266
+ if is_symlink_path(path)? {
267
+ bail!("output path '{}' must not be a symlink", path.display());
268
+ }
269
+ if path.is_dir() {
270
+ bail!(
271
+ "output path '{}' is a directory; provide a file path",
272
+ path.display()
273
+ );
274
+ }
275
+ if let Some(parent) = path.parent() {
276
+ if !parent.as_os_str().is_empty() {
277
+ ensure_secure_output_directory(parent)?;
278
+ }
279
+ }
280
+ Ok(())
281
+ }
282
+
283
+ fn ensure_secure_output_directory(path: &Path) -> Result<()> {
284
+ match std::fs::symlink_metadata(path) {
285
+ Ok(metadata) => {
286
+ assert_secure_output_directory(path, &metadata, false)?;
287
+ assert_secure_output_directory_ancestors(path)?;
288
+ Ok(())
289
+ }
290
+ Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
291
+ if let Some(parent) = path.parent() {
292
+ if !parent.as_os_str().is_empty() {
293
+ ensure_secure_output_directory(parent)?;
294
+ }
295
+ }
296
+
297
+ std::fs::create_dir(path)
298
+ .with_context(|| format!("failed to create output directory {}", path.display()))?;
299
+
300
+ #[cfg(unix)]
301
+ std::fs::set_permissions(path, std::fs::Permissions::from_mode(PRIVATE_DIR_MODE))
302
+ .with_context(|| {
303
+ format!(
304
+ "failed to set output directory permissions on {}",
305
+ path.display()
306
+ )
307
+ })?;
308
+
309
+ let metadata = std::fs::symlink_metadata(path).with_context(|| {
310
+ format!(
311
+ "failed to inspect output directory metadata {}",
312
+ path.display()
313
+ )
314
+ })?;
315
+ assert_secure_output_directory(path, &metadata, false)?;
316
+ assert_secure_output_directory_ancestors(path)
317
+ }
318
+ Err(err) => Err(err).with_context(|| {
319
+ format!(
320
+ "failed to inspect output directory metadata {}",
321
+ path.display()
322
+ )
323
+ }),
324
+ }
325
+ }
326
+
327
+ #[cfg(unix)]
328
+ fn assert_secure_output_directory_ancestors(path: &Path) -> Result<()> {
329
+ let canonical = std::fs::canonicalize(path)
330
+ .with_context(|| format!("failed to canonicalize output directory {}", path.display()))?;
331
+
332
+ for ancestor in canonical.ancestors().skip(1) {
333
+ let metadata = std::fs::symlink_metadata(ancestor).with_context(|| {
334
+ format!(
335
+ "failed to inspect ancestor output directory metadata {}",
336
+ ancestor.display()
337
+ )
338
+ })?;
339
+ assert_secure_output_directory(ancestor, &metadata, true)?;
340
+ }
341
+
342
+ Ok(())
343
+ }
344
+
345
+ #[cfg(not(unix))]
346
+ fn assert_secure_output_directory_ancestors(_path: &Path) -> Result<()> {
347
+ Ok(())
348
+ }
349
+
350
+ fn assert_secure_output_directory(
351
+ path: &Path,
352
+ metadata: &std::fs::Metadata,
353
+ #[cfg(unix)] allow_sticky_group_other_write: bool,
354
+ ) -> Result<()> {
355
+ if metadata.file_type().is_symlink() {
356
+ bail!(
357
+ "output directory '{}' must not be a symlink",
358
+ path.display()
359
+ );
360
+ }
361
+ if !metadata.is_dir() {
362
+ bail!("output directory '{}' must be a directory", path.display());
363
+ }
364
+
365
+ #[cfg(unix)]
366
+ if metadata.permissions().mode() & GROUP_OTHER_WRITE_MODE_MASK != 0 {
367
+ if allow_sticky_group_other_write && metadata.permissions().mode() & STICKY_BIT_MODE != 0 {
368
+ return Ok(());
369
+ }
370
+
371
+ bail!(
372
+ "output directory '{}' must not be writable by group/other",
373
+ path.display()
374
+ );
375
+ }
376
+
377
+ Ok(())
378
+ }
379
+
380
+ pub(crate) fn write_output_file(path: &Path, output: &str, overwrite: bool) -> Result<()> {
381
+ if overwrite {
382
+ return write_output_file_atomic_replace(path, output);
383
+ }
384
+
385
+ let mut options = OpenOptions::new();
386
+ options.write(true);
387
+ options.create_new(true);
388
+ #[cfg(unix)]
389
+ {
390
+ options.mode(0o600);
391
+ options.custom_flags(libc::O_NOFOLLOW);
392
+ }
393
+
394
+ let mut file = options.open(path).map_err(|err| {
395
+ if err.kind() == std::io::ErrorKind::AlreadyExists {
396
+ anyhow!(
397
+ "output path '{}' already exists; pass --overwrite to replace it",
398
+ path.display()
399
+ )
400
+ } else {
401
+ err.into()
402
+ }
403
+ })?;
404
+ file.write_all(output.as_bytes())
405
+ .with_context(|| format!("failed to write output to {}", path.display()))?;
406
+ file.write_all(b"\n")
407
+ .with_context(|| format!("failed to write output to {}", path.display()))?;
408
+ #[cfg(unix)]
409
+ {
410
+ file.set_permissions(std::fs::Permissions::from_mode(0o600))
411
+ .with_context(|| format!("failed to set output permissions on {}", path.display()))?;
412
+ }
413
+ Ok(())
414
+ }
415
+
416
+ fn write_output_file_atomic_replace(path: &Path, output: &str) -> Result<()> {
417
+ let temp_path = temporary_output_path(path);
418
+ let result = (|| -> Result<()> {
419
+ let mut options = OpenOptions::new();
420
+ options.write(true).create_new(true);
421
+ #[cfg(unix)]
422
+ {
423
+ options.mode(0o600);
424
+ options.custom_flags(libc::O_NOFOLLOW);
425
+ }
426
+
427
+ let mut file = options.open(&temp_path).with_context(|| {
428
+ format!(
429
+ "failed to create temporary output file {}",
430
+ temp_path.display()
431
+ )
432
+ })?;
433
+ file.write_all(output.as_bytes())
434
+ .with_context(|| format!("failed to write output to {}", temp_path.display()))?;
435
+ file.write_all(b"\n")
436
+ .with_context(|| format!("failed to write output to {}", temp_path.display()))?;
437
+ #[cfg(unix)]
438
+ {
439
+ file.set_permissions(std::fs::Permissions::from_mode(0o600))
440
+ .with_context(|| {
441
+ format!(
442
+ "failed to set output permissions on {}",
443
+ temp_path.display()
444
+ )
445
+ })?;
446
+ }
447
+ drop(file);
448
+
449
+ #[cfg(windows)]
450
+ if path.exists() {
451
+ std::fs::remove_file(path)
452
+ .with_context(|| format!("failed to remove output file {}", path.display()))?;
453
+ }
454
+
455
+ std::fs::rename(&temp_path, path)
456
+ .with_context(|| format!("failed to replace output file {}", path.display()))?;
457
+ Ok(())
458
+ })();
459
+
460
+ if result.is_err() {
461
+ let _ = std::fs::remove_file(&temp_path);
462
+ }
463
+
464
+ result
465
+ }
466
+
467
+ fn temporary_output_path(path: &Path) -> PathBuf {
468
+ let parent = path.parent().unwrap_or_else(|| Path::new("."));
469
+ let file_name = path
470
+ .file_name()
471
+ .and_then(|value| value.to_str())
472
+ .unwrap_or("output");
473
+ let timestamp = std::time::SystemTime::now()
474
+ .duration_since(std::time::UNIX_EPOCH)
475
+ .unwrap_or_default()
476
+ .as_nanos();
477
+ parent.join(format!(
478
+ ".{file_name}.tmp-{}-{timestamp}",
479
+ std::process::id()
480
+ ))
481
+ }
482
+
483
+ fn is_symlink_path(path: &Path) -> Result<bool> {
484
+ match std::fs::symlink_metadata(path) {
485
+ Ok(metadata) => Ok(metadata.file_type().is_symlink()),
486
+ Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(false),
487
+ Err(err) => Err(err)
488
+ .with_context(|| format!("failed to inspect output path metadata {}", path.display())),
489
+ }
490
+ }
491
+
492
+ pub(crate) fn should_print_status(format: OutputFormat, quiet: bool) -> bool {
493
+ format == OutputFormat::Text && !quiet
494
+ }
495
+
496
+ pub(crate) fn print_status(message: &str, format: OutputFormat, quiet: bool) {
497
+ if should_print_status(format, quiet) {
498
+ eprintln!("==> {message}");
499
+ }
500
+ }