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
#![cfg(e2e_test)]
#[cfg(test)]
mod common;
#[cfg(test)]
mod tests {
use super::*;
use common::*;
use std::process::{Command, Stdio};
// Process exit codes are defined in src/bin/s3util/cli/mod.rs:
// EXIT_CODE_SUCCESS = 0
// EXIT_CODE_ERROR = 1
// EXIT_CODE_WARNING = 3
// EXIT_CODE_CANCELLED = 130 (SIGINT/ctrl-c, covered in e2e_cancel_test.rs)
//
// These tests invoke the actual binary as a subprocess and assert the
// process-level exit code. They are the only tests that exercise
// src/bin/s3util/main.rs's exit-code mapping end to end.
const EXIT_CODE_SUCCESS: i32 = 0;
const EXIT_CODE_ERROR: i32 = 1;
const EXIT_CODE_WARNING: i32 = 3;
/// Exit code produced by clap when argument parsing fails.
///
/// This is not an exit code we set ourselves — it comes from clap's
/// `Error::exit` implementation. As of clap 4.x, every `ErrorKind`
/// variant except `DisplayHelp` / `DisplayVersion` /
/// `DisplayHelpOnMissingArgumentOrSubcommand` (which exit 0) is mapped
/// to exit code 2. This covers: unknown argument, invalid value,
/// missing required argument, value validation, subcommand errors, etc.
///
/// Two tests below assert this convention against two different
/// `ErrorKind` variants (value validation and unknown argument). If
/// both fail, clap has changed the exit-code convention globally —
/// update this constant and re-read clap's current error semantics.
/// If only one fails, the regression is in our own arg definition or
/// value parser rather than clap.
const EXIT_CODE_CLAP_ARG_ERROR: i32 = 2;
/// Successful local→S3 cp must exit 0.
#[tokio::test]
async fn exit_code_success_on_normal_cp() {
TestHelper::init_dummy_tracing_subscriber();
let helper = TestHelper::new().await;
let bucket = TestHelper::generate_bucket_name();
helper.create_bucket(&bucket, REGION).await;
let local_dir = TestHelper::create_temp_dir();
let test_file = TestHelper::create_sized_file(&local_dir, "ok.bin", 1024);
let target = format!("s3://{}/ok.bin", bucket);
let status = std::process::Command::new("cargo")
.args([
"run",
"--quiet",
"--",
"cp",
"--target-profile",
"s3util-e2e-test",
test_file.to_str().unwrap(),
&target,
])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.unwrap();
assert_eq!(
status.code(),
Some(EXIT_CODE_SUCCESS),
"successful cp must exit 0, got: {status}"
);
helper.delete_bucket_with_cascade(&bucket).await;
let _ = std::fs::remove_dir_all(&local_dir);
}
/// cp against a nonexistent bucket must exit 1 (run_cp returns Err).
#[tokio::test]
async fn exit_code_error_on_missing_bucket() {
TestHelper::init_dummy_tracing_subscriber();
// No bucket creation — the target bucket is intentionally absent. Use a
// unique name so we don't collide with an existing bucket.
let bucket = format!("nonexistent-{}", uuid::Uuid::new_v4());
let local_dir = TestHelper::create_temp_dir();
let test_file = TestHelper::create_sized_file(&local_dir, "err.bin", 1024);
let target = format!("s3://{}/err.bin", bucket);
let status = std::process::Command::new("cargo")
.args([
"run",
"--quiet",
"--",
"cp",
"--target-profile",
"s3util-e2e-test",
test_file.to_str().unwrap(),
&target,
])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.unwrap();
assert_eq!(
status.code(),
Some(EXIT_CODE_ERROR),
"cp to nonexistent bucket must exit 1, got: {status}"
);
let _ = std::fs::remove_dir_all(&local_dir);
}
// ---------------------------------------------------------------
// CLI paths in src/bin/s3util/main.rs that don't reach run_cp.
// These cover the early-return and validation branches that aren't
// exercised by lib unit tests (they need the actual binary).
// ---------------------------------------------------------------
/// `--auto-complete-shell bash` short-circuits before Config::try_from,
/// generates a shell completion script to stdout, and exits 0.
/// Covers the early-return branch in main.rs at the `auto_complete_shell`
/// check.
#[tokio::test]
async fn auto_complete_shell_emits_script_and_exits_zero() {
let output = std::process::Command::new("cargo")
.args([
"run",
"--quiet",
"--",
"cp",
"--auto-complete-shell",
"bash",
])
.stderr(std::process::Stdio::null())
.output()
.unwrap();
assert_eq!(
output.status.code(),
Some(EXIT_CODE_SUCCESS),
"--auto-complete-shell must exit 0, got: {}",
output.status
);
let stdout = String::from_utf8_lossy(&output.stdout);
// bash completion scripts contain `complete -F <funcname> s3util`.
assert!(
stdout.contains("s3util"),
"expected bash completion output mentioning 's3util', got first 200 chars: {}",
&stdout.chars().take(200).collect::<String>()
);
}
/// `--auto-complete-shell zsh` generates a zsh completion script to stdout
/// and exits 0. Asserts on the stable `#compdef s3util` anchor that
/// `clap_complete`'s zsh generator emits at the top of its script.
#[tokio::test]
async fn auto_complete_shell_zsh() {
let output = Command::new(env!("CARGO_BIN_EXE_s3util"))
.args(["cp", "--auto-complete-shell", "zsh"])
.stdout(Stdio::piped())
.stderr(Stdio::null())
.stdin(Stdio::null())
.output()
.unwrap();
assert_eq!(
output.status.code(),
Some(EXIT_CODE_SUCCESS),
"--auto-complete-shell zsh must exit 0, got: {}",
output.status
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("#compdef s3util"),
"expected zsh completion output containing '#compdef s3util', got first 200 chars: {}",
&stdout.chars().take(200).collect::<String>()
);
}
/// `--auto-complete-shell fish` generates a fish completion script to
/// stdout and exits 0. Asserts on fish's `complete -c <program>` line
/// convention.
#[tokio::test]
async fn auto_complete_shell_fish() {
let output = Command::new(env!("CARGO_BIN_EXE_s3util"))
.args(["cp", "--auto-complete-shell", "fish"])
.stdout(Stdio::piped())
.stderr(Stdio::null())
.stdin(Stdio::null())
.output()
.unwrap();
assert_eq!(
output.status.code(),
Some(EXIT_CODE_SUCCESS),
"--auto-complete-shell fish must exit 0, got: {}",
output.status
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("complete -c s3util"),
"expected fish completion output containing 'complete -c s3util', got first 200 chars: {}",
&stdout.chars().take(200).collect::<String>()
);
}
/// Invalid `--multipart-threshold` value (below the 5 MiB minimum) is
/// rejected by our value parser, which raises a clap error and exits
/// via `clap::Error::exit`. Exercises clap's `ValueValidation` branch.
///
/// Asserts exactly `EXIT_CODE_CLAP_ARG_ERROR` (2) so that any drift
/// in clap's exit-code convention surfaces as a test failure. Paired
/// with `unknown_flag_exits_with_clap_arg_error` below, which hits a
/// different `ErrorKind` — see the `EXIT_CODE_CLAP_ARG_ERROR` doc
/// comment for how to interpret single vs. paired failures.
#[tokio::test]
async fn invalid_multipart_threshold_exits_with_clap_error() {
let local_dir = TestHelper::create_temp_dir();
let test_file = TestHelper::create_sized_file(&local_dir, "x.bin", 64);
let status = std::process::Command::new("cargo")
.args([
"run",
"--quiet",
"--",
"cp",
// 1KiB is below the documented 5 MiB minimum → value parser rejects.
"--multipart-threshold",
"1KiB",
test_file.to_str().unwrap(),
"s3://any-bucket/key",
])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.unwrap();
assert_eq!(
status.code(),
Some(EXIT_CODE_CLAP_ARG_ERROR),
"invalid --multipart-threshold must exit with clap's arg-error code ({EXIT_CODE_CLAP_ARG_ERROR}), got: {status}"
);
let _ = std::fs::remove_dir_all(&local_dir);
}
/// An unknown CLI flag triggers clap's `UnknownArgument` branch,
/// which calls `clap::Error::exit` and terminates the process.
///
/// Asserts exactly `EXIT_CODE_CLAP_ARG_ERROR` (2). Together with
/// `invalid_multipart_threshold_exits_with_clap_error` above, this
/// triangulates clap's convention from two different `ErrorKind`
/// variants — see the `EXIT_CODE_CLAP_ARG_ERROR` doc comment.
#[tokio::test]
async fn unknown_flag_exits_with_clap_arg_error() {
let status = std::process::Command::new("cargo")
.args([
"run",
"--quiet",
"--",
"cp",
"--this-flag-does-not-exist",
"local.txt",
"s3://any-bucket/key",
])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.unwrap();
assert_eq!(
status.code(),
Some(EXIT_CODE_CLAP_ARG_ERROR),
"unknown flag must exit with clap's arg-error code ({EXIT_CODE_CLAP_ARG_ERROR}), got: {status}"
);
}
/// A cp that produces a warning (no errors) must exit 3.
///
/// Mirrors the trigger used by `local_to_s3_multipart_e_tag_ng` /
/// `s3_to_local_multipart_e_tag_ng` in `tests/e2e_integrity_check.rs`:
/// upload a 9 MiB file with `--multipart-chunksize=5MiB`, then download
/// without specifying chunksize — the local recompute uses the default
/// 8 MiB and the resulting ETag won't match the source's stored ETag,
/// causing the cp to emit a sync_warning and exit 3.
#[tokio::test]
async fn exit_code_warning_on_etag_mismatch_after_chunksize_change() {
TestHelper::init_dummy_tracing_subscriber();
let helper = TestHelper::new().await;
let bucket = TestHelper::generate_bucket_name();
helper.create_bucket(&bucket, REGION).await;
let local_dir = TestHelper::create_temp_dir();
let upload_file = TestHelper::create_sized_file(&local_dir, "warn.bin", 9 * 1024 * 1024);
let s3_path = format!("s3://{}/warn.bin", bucket);
// Step 1: upload with non-default chunksize so the stored ETag is built
// from 5 MiB parts.
let upload_status = std::process::Command::new("cargo")
.args([
"run",
"--quiet",
"--",
"cp",
"--target-profile",
"s3util-e2e-test",
"--multipart-threshold",
"5MiB",
"--multipart-chunksize",
"5MiB",
upload_file.to_str().unwrap(),
&s3_path,
])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.unwrap();
assert_eq!(
upload_status.code(),
Some(EXIT_CODE_SUCCESS),
"warning-test setup upload must succeed first, got: {upload_status}"
);
// Step 2: download without chunksize override. Local ETag recompute will
// use defaults and won't match the stored multipart ETag → warning.
let dl_file = local_dir.join("warn_dl.bin");
let dl_status = std::process::Command::new("cargo")
.args([
"run",
"--quiet",
"--",
"cp",
"--source-profile",
"s3util-e2e-test",
&s3_path,
dl_file.to_str().unwrap(),
])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.unwrap();
assert_eq!(
dl_status.code(),
Some(EXIT_CODE_WARNING),
"ETag mismatch from chunksize change must exit 3, got: {dl_status}"
);
helper.delete_bucket_with_cascade(&bucket).await;
let _ = std::fs::remove_dir_all(&local_dir);
}
}