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
//! Proof tests for `tsafe plugin` failure semantics (ADR-026).
//!
//! Covers at least 5 of the 10 failure modes in the ADR-026 failure table.
//! All tests that require the `plugins` feature are gated behind
//! `#[cfg(feature = "plugins")]`.
//!
//! Failure modes covered:
//! F1 — unknown plugin name → exit 1, clear error
//! F4 — binary not found on PATH → exit 1, clear error
//! F5 — required key missing from vault → exit 1, error before spawn
//! F8 — binary exits non-zero → exit code propagated
//! F3 — empty/no command (static table guard) + structural: no ambient
//! env leak for declared plugin names (ADR-025 §D4 ambient-strip proof)
#[cfg(feature = "plugins")]
mod plugin_failure_contract {
use assert_cmd::Command;
use predicates::str::contains;
use std::path::Path;
use tempfile::tempdir;
fn tsafe() -> Command {
Command::cargo_bin("tsafe").unwrap()
}
fn init_vault(dir: &Path) {
tsafe()
.args(["init"])
.env("TSAFE_VAULT_DIR", dir)
.env("TSAFE_PASSWORD", "pw")
.assert()
.success();
}
// ── F1: Unknown plugin name → exit 1, clear error ────────────────────────
/// ADR-026 F1: requesting a plugin tool name that is not in the static
/// PLUGINS table must fail immediately with exit code 1 and a message that
/// names the unknown tool and hints at `tsafe plugin` for the list.
#[test]
fn f1_unknown_plugin_name_exits_1_with_clear_error() {
let dir = tempdir().unwrap();
init_vault(dir.path());
tsafe()
.args(["plugin", "nonexistent-tool-xyz"])
.env("TSAFE_VAULT_DIR", dir.path())
.env("TSAFE_PASSWORD", "pw")
.assert()
.failure()
.code(1)
.stderr(contains("unknown plugin 'nonexistent-tool-xyz'"))
.stderr(contains("tsafe plugin"));
}
// ── F4: Binary not found on PATH → exit 1, clear error ───────────────────
/// ADR-026 F4: when a plugin's binary cannot be found on PATH (or the OS
/// spawn fails), tsafe must exit 1 with an error naming the binary.
///
/// We use the `npm` plugin (binary: `npm`) which may or may not be installed
/// in the test environment. To ensure a controlled failure we point PATH at
/// an empty temp directory so no binaries can be resolved, then check that
/// tsafe exits 1 with a message about the binary or the spawn failure.
///
/// Note: the `npm` plugin has all optional keys, so a missing key won't
/// trigger F5 first — the vault can be empty.
#[test]
fn f4_binary_not_found_on_path_exits_1_with_spawn_error() {
let dir = tempdir().unwrap();
let empty_path_dir = tempdir().unwrap();
init_vault(dir.path());
// Point PATH at an empty dir so no binary can be found.
let empty_path = empty_path_dir.path().to_str().unwrap().to_string();
let assert = tsafe()
.args(["plugin", "npm"])
.env("TSAFE_VAULT_DIR", dir.path())
.env("TSAFE_PASSWORD", "pw")
.env("PATH", &empty_path)
.assert();
// Must fail with exit code 1.
assert
.failure()
.code(1)
// The error must mention the binary name.
.stderr(contains("npm"));
}
// ── F5: Required key missing from vault → exit 1, error before spawn ─────
/// ADR-026 F5: the `aws` plugin marks `AWS_ACCESS_KEY_ID` and
/// `AWS_SECRET_ACCESS_KEY` as `required: true`. If these keys are absent
/// from the vault, tsafe must abort with exit code 1 before spawning
/// the `aws` binary. The error message must name the missing keys.
#[test]
fn f5_required_key_missing_exits_1_before_spawn() {
let dir = tempdir().unwrap();
init_vault(dir.path());
// Vault is empty — required AWS keys are absent.
tsafe()
.args(["plugin", "aws", "s3", "ls"])
.env("TSAFE_VAULT_DIR", dir.path())
.env("TSAFE_PASSWORD", "pw")
.assert()
.failure()
.code(1)
.stderr(contains("requires vault keys that are missing"))
.stderr(contains("AWS_ACCESS_KEY_ID"));
}
/// ADR-026 F5 variant: confirming the error occurs before spawn by
/// ensuring no aws binary output appears in stdout/stderr and the error
/// message arrives on stderr (not via the tool itself).
#[test]
fn f5_required_key_missing_error_is_tsafe_owned_not_tool_owned() {
let dir = tempdir().unwrap();
// Create a sentinel vault dir that ensures no aws output could appear.
let empty_path_dir = tempdir().unwrap();
let empty_path = empty_path_dir.path().to_str().unwrap().to_string();
init_vault(dir.path());
// Even with no PATH, the error should appear because tsafe checks keys
// before attempting the spawn (pre-spawn check). This confirms the
// failure happens in tsafe's code, not in the binary.
tsafe()
.args(["plugin", "aws", "s3", "ls"])
.env("TSAFE_VAULT_DIR", dir.path())
.env("TSAFE_PASSWORD", "pw")
.env("PATH", &empty_path)
.assert()
.failure()
.code(1)
.stderr(contains("requires vault keys that are missing"));
}
// ── F8: Binary exits non-zero → exit code propagated ─────────────────────
/// ADR-026 F8: when the launched plugin binary exits with a non-zero
/// code, tsafe must propagate that exact code rather than collapsing it
/// to 1.
///
/// We use the `gh` plugin (all optional keys) with a PATH that points to a
/// fake `gh` script that exits with a specific code. We craft a tiny shell
/// script (Unix) or batch file (Windows) to act as the fake binary.
#[cfg(unix)]
#[test]
fn f8_plugin_binary_nonzero_exit_propagated() {
use std::os::unix::fs::PermissionsExt;
let dir = tempdir().unwrap();
let bin_dir = tempdir().unwrap();
init_vault(dir.path());
// Write a fake `gh` script that exits with code 42.
let fake_gh = bin_dir.path().join("gh");
std::fs::write(&fake_gh, "#!/bin/sh\nexit 42\n").unwrap();
std::fs::set_permissions(&fake_gh, std::fs::Permissions::from_mode(0o755)).unwrap();
tsafe()
.args(["plugin", "gh", "repo", "list"])
.env("TSAFE_VAULT_DIR", dir.path())
.env("TSAFE_PASSWORD", "pw")
.env("PATH", bin_dir.path())
.assert()
.failure()
.code(42);
}
// ── Plugin list command is not an error (no-arg behavior) ────────────────
/// ADR-026 F2 clarification: `tsafe plugin` with no tool name prints the
/// plugin list and exits 0. This is the intended no-arg behavior, not an
/// error case.
#[test]
fn no_arg_plugin_lists_available_plugins_and_exits_0() {
let dir = tempdir().unwrap();
init_vault(dir.path());
tsafe()
.args(["plugin"])
.env("TSAFE_VAULT_DIR", dir.path())
.env("TSAFE_PASSWORD", "pw")
.assert()
.success()
.stdout(contains("plugin launchers"))
.stdout(contains("gh"))
.stdout(contains("aws"));
}
/// `tsafe plugin list` is equivalent to `tsafe plugin` with no args.
#[test]
fn plugin_list_subcommand_exits_0_and_shows_plugins() {
let dir = tempdir().unwrap();
init_vault(dir.path());
tsafe()
.args(["plugin", "list"])
.env("TSAFE_VAULT_DIR", dir.path())
.env("TSAFE_PASSWORD", "pw")
.assert()
.success()
.stdout(contains("plugin launchers"));
}
// ── ADR-025 D4: ambient env-var strip proof ───────────────────────────────
/// ADR-025 §D4 proof: ambient env vars that share names with declared
/// plugin keys must NOT reach the subprocess if the vault does not
/// override them — they are stripped from the subprocess environment.
///
/// We use the `npm` plugin (single declared env: `NPM_TOKEN`) and set
/// `NPM_TOKEN` as an ambient env var. The subprocess should NOT see the
/// ambient value; it should either see nothing (key absent from vault)
/// or the vault value (key present in vault).
///
/// We use a fake `npm` binary (Unix) that prints its environment and exits 0,
/// then confirm the ambient value is absent from its output.
#[cfg(unix)]
#[test]
fn d4_ambient_env_stripped_for_declared_plugin_vars() {
use std::os::unix::fs::PermissionsExt;
let dir = tempdir().unwrap();
let bin_dir = tempdir().unwrap();
let capture = dir.path().join("env_output.txt");
init_vault(dir.path());
// Do NOT set NPM_TOKEN in the vault — it should be absent from the child.
// Write a fake `npm` that dumps its environment to a file then exits 0.
let capture_str = capture.to_str().unwrap();
let script = format!(
"#!/bin/sh\nprintenv > '{capture_str}'\nexit 0\n",
capture_str = capture_str,
);
let fake_npm = bin_dir.path().join("npm");
std::fs::write(&fake_npm, &script).unwrap();
std::fs::set_permissions(&fake_npm, std::fs::Permissions::from_mode(0o755)).unwrap();
tsafe()
.args(["plugin", "npm"])
.env("TSAFE_VAULT_DIR", dir.path())
.env("TSAFE_PASSWORD", "pw")
.env("PATH", bin_dir.path())
// Ambient NPM_TOKEN — must be stripped before the subprocess sees it.
.env("NPM_TOKEN", "ambient-secret-must-not-leak")
.assert()
.success();
let captured = std::fs::read_to_string(&capture).unwrap_or_default();
assert!(
!captured.contains("ambient-secret-must-not-leak"),
"ambient NPM_TOKEN value must not reach the plugin subprocess (ADR-025 D4)"
);
}
}