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
//! Integration coverage for the `mkit+s3` dispatch branch in
//! [`mkit_cli::remote_dispatch::open`].
//!
//! Two things are under test here:
//!
//! 1. `remote_dispatch::open` no longer returns `UnsupportedScheme` for
//! `mkit+s3://` URLs โ it delegates to [`S3Transport::connect`], which
//! parses the URL and reads credentials from the environment.
//! 2. Requests issued by the returned transport carry the `SigV4`
//! `Authorization`, `x-amz-date`, and `x-amz-content-sha256` headers
//! that the R2 endpoint expects.
//!
//! ## Why `with_parts` for the wire check
//!
//! `S3Transport::connect` rebuilds the URL as `https://<host>` because R2
//! only speaks HTTPS. `mockito` exposes a plain `http://127.0.0.1:<port>`
//! base, so we cannot point a URL-constructed transport at a mockito
//! server. Instead, we cover the scheme-dispatch branch with a smoke
//! test (`open()` succeeds), and cover the signed-request wire shape by
//! constructing the transport directly via the test-only
//! `S3Transport::with_parts` and asserting on the mockito-captured
//! headers.
#![allow(clippy::unwrap_used)] // unwrap is the assertion in test helpers
use std::process::Command;
use mkit_cli::remote_dispatch;
use mkit_core::protocol::{PackKey, Transport};
use mkit_transport_s3::S3Transport;
use mkit_transport_s3::sigv4::Credentials;
use mockito::{Matcher, Server};
fn mkit_bin() -> &'static str {
env!("CARGO_BIN_EXE_mkit")
}
fn run_in(cwd: &std::path::Path, args: &[&str]) -> std::process::Output {
Command::new(mkit_bin())
.args(args)
.current_dir(cwd)
.output()
.expect("spawn mkit")
}
#[test]
fn open_accepts_mkit_s3_url() {
// Previously this branch returned `UnsupportedScheme`. With the
// wiring in place, `open` should now succeed for a syntactically
// valid `mkit+s3://` URL โ even if the credentials env vars are
// unset. Construction does NOT hit the network.
let tx = remote_dispatch::open("mkit+s3://r2.example.com/mybucket")
.expect("mkit+s3:// must now dispatch to S3Transport");
drop(tx);
}
#[test]
fn open_accepts_mkit_s3_url_with_prefix() {
let tx = remote_dispatch::open("mkit+s3://r2.example.com/mybucket/myproject")
.expect("mkit+s3:// with prefix must dispatch");
drop(tx);
}
#[test]
fn open_rejects_malformed_mkit_s3_url() {
// Missing bucket component.
let Err(err) = remote_dispatch::open("mkit+s3://host-only") else {
panic!("expected error for mkit+s3 URL without bucket");
};
let msg = err.to_string();
assert!(
msg.contains("transport") || msg.contains("malformed") || msg.contains("bucket"),
"unexpected error for malformed mkit+s3 URL: {msg}"
);
}
#[test]
fn signed_put_request_carries_sigv4_headers() {
// Build a transport manually (bypassing the `https://` rewrite in
// `S3Transport::connect`) and issue an `upload_pack` against a
// mockito endpoint. Assert the captured request carries the
// SPEC-TRANSPORT ยง6 `SigV4` header set:
// - Authorization: AWS4-HMAC-SHA256 ...
// - x-amz-date
// - x-amz-content-sha256
let mut server = Server::new();
let _m = server
.mock(
"PUT",
Matcher::Regex(r"^/mybucket/packs/[0-9a-f]{64}$".to_string()),
)
.match_header(
"authorization",
Matcher::Regex(r"^AWS4-HMAC-SHA256 ".to_string()),
)
.match_header("x-amz-date", Matcher::Any)
.match_header("x-amz-content-sha256", Matcher::Any)
.with_status(200)
.create();
let creds = Credentials {
access_key_id: "TESTKEY".into(),
secret_access_key: "TESTSECRET".into(),
region: "auto".into(),
};
let tx = S3Transport::with_parts(server.url(), "mybucket", None, creds)
.expect("build S3Transport with mockito endpoint");
let key = PackKey::new([0xAB; 32]);
tx.upload_pack(b"hello-pack", &key)
.expect("signed PUT must succeed against mockito");
// mockito asserts mock.expect_at_least(1) on drop; an unsigned or
// malformed request would have failed the header matchers above.
}
#[test]
fn credential_env_vars_are_consulted_at_connect_time() {
// `S3Transport::connect` reads the `MKIT_R2_ACCESS_KEY_ID` and
// `MKIT_R2_SECRET_ACCESS_KEY` variables at construction. Absent vars
// default to empty strings โ no error at connect time. The first
// signed request then surfaces `AccessDenied` because the signer
// emits an unusable signature.
assert_eq!(mkit_transport_s3::ENV_ACCESS_KEY, "MKIT_R2_ACCESS_KEY_ID");
assert_eq!(
mkit_transport_s3::ENV_SECRET_KEY,
"MKIT_R2_SECRET_ACCESS_KEY"
);
// In-process `set_var`/`remove_var` is disallowed repo-wide (races
// other threads on POSIX), so the env consultation is proven in a
// child process instead: re-spawn this test binary targeting this
// very test with the creds set (or removed) ONLY in the child's
// environment, and have the child branch print what `connect`
// actually resolved.
if std::env::var_os("MKIT_S3_CRED_PROBE_CHILD").is_some() {
let tx = S3Transport::connect("mkit+s3://r2.example.com/mybucket")
.expect("connect must not error regardless of creds env");
let (access_key_id, secret_access_key, _region) = tx.credentials_for_test();
println!("probe:ak={access_key_id};sk={secret_access_key};");
return;
}
let spawn_probe = |set_creds: bool| -> String {
let mut cmd = Command::new(std::env::current_exe().unwrap());
cmd.args([
"--exact",
"credential_env_vars_are_consulted_at_connect_time",
"--nocapture",
])
.env("MKIT_S3_CRED_PROBE_CHILD", "1")
.env_remove(mkit_transport_s3::ENV_ACCESS_KEY)
.env_remove(mkit_transport_s3::ENV_SECRET_KEY);
if set_creds {
cmd.env(mkit_transport_s3::ENV_ACCESS_KEY, "test-access-key-id")
.env(mkit_transport_s3::ENV_SECRET_KEY, "test-secret-access-key");
}
let out = cmd.output().expect("spawn cred probe child");
assert!(
out.status.success(),
"cred probe child failed: {}",
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8(out.stdout).unwrap()
};
// Set in the child's env โ consulted at connect time.
let stdout = spawn_probe(true);
assert!(
stdout.contains("probe:ak=test-access-key-id;sk=test-secret-access-key;"),
"connect did not consult the creds env vars: {stdout}"
);
// Absent vars default to empty strings (the documented no-error-at-
// connect-time contract) rather than erroring or panicking.
let stdout = spawn_probe(false);
assert!(
stdout.contains("probe:ak=;sk=;"),
"connect with absent creds env should resolve empty strings: {stdout}"
);
// Subprocess smoke: `mkit remote add` + `mkit remote` round-trips a
// `mkit+s3://` URL through config without error, even with no creds
// set. This proves the CLI accepts the scheme.
let td = tempfile::tempdir().unwrap();
assert!(run_in(td.path(), &["init"]).status.success());
let out = run_in(
td.path(),
&["remote", "add", "mkit+s3://r2.example.com/bkt/proj"],
);
assert!(out.status.success(), "remote add failed: {out:?}");
// Bare `remote` lists names only (git-shaped); the URL is under `-v`.
let out = run_in(td.path(), &["remote", "-v"]);
let stdout = String::from_utf8(out.stdout).unwrap();
assert!(stdout.contains("mkit+s3://r2.example.com/bkt/proj"));
assert!(stdout.contains("(fetch)"));
}