1#[must_use]
10pub fn is_remote_target(path: &str) -> bool {
11 matches!(
12 url_scheme(path).to_lowercase().as_str(),
13 "http" | "https" | "ssh" | "file"
14 )
15}
16
17#[must_use]
22pub fn url_scheme(path: &str) -> String {
23 if !path.contains("://") {
24 return String::new();
25 }
26
27 match url::Url::parse(path) {
28 Ok(parsed) => parsed.scheme().to_string(),
29 Err(_) => String::new(),
30 }
31}
32
33#[must_use]
39pub fn redact_credentials(target: &str) -> String {
40 if !target.contains("://") {
41 return target.to_string();
42 }
43
44 let Ok(mut parsed) = url::Url::parse(target) else {
45 return "<unparseable kustomize target>".to_string();
46 };
47
48 if parsed.cannot_be_a_base() {
49 return "<unparseable kustomize target>".to_string();
53 }
54
55 if parsed.set_password(None).is_err() || parsed.set_username("").is_err() {
56 return "<unparseable kustomize target>".to_string();
57 }
58
59 parsed.to_string()
60}
61
62#[must_use]
67pub fn has_pinned_ref(target: &str) -> bool {
68 let Some((_, query)) = target.split_once('?') else {
69 return false;
70 };
71
72 url::form_urlencoded::parse(query.as_bytes())
73 .any(|(key, value)| (key == "ref" || key == "version") && !value.is_empty())
74}
75
76#[cfg(test)]
77mod tests {
78 use super::*;
79
80 struct IsRemoteCase {
83 name: &'static str,
84 target: &'static str,
85 want: bool,
86 }
87
88 #[test]
89 fn is_remote_target_cases() {
90 let cases = [
91 IsRemoteCase {
92 name: "https URL with subdir delimiter",
93 target: "https://gitlab.com/group/subgroup/repo.git//overlays/dev?ref=v1.2.0",
94 want: true,
95 },
96 IsRemoteCase {
97 name: "http URL",
98 target: "http://example.com/org/repo",
99 want: true,
100 },
101 IsRemoteCase {
102 name: "ssh URL with port",
103 target: "ssh://git@gitlab.example.com:2222/org/repo.git//dir",
104 want: true,
105 },
106 IsRemoteCase {
107 name: "file URL is a git clone target",
108 target: "file:///abs/path/to/repo//overlay?ref=v1",
109 want: true,
110 },
111 IsRemoteCase {
112 name: "scheme is case-insensitive",
113 target: "HTTPS://gitlab.com/org/repo.git//dir?ref=v1",
114 want: true,
115 },
116 IsRemoteCase {
117 name: "scp-style is not a url",
118 target: "git@gitlab.com:group/repo.git//overlays/dev?ref=v1",
119 want: false,
120 },
121 IsRemoteCase {
122 name: "go-getter git prefix is not supported",
123 target: "git::https://example.com/org/repo",
124 want: false,
125 },
126 IsRemoteCase {
127 name: "bare github shorthand is not a url",
128 target: "github.com/owner/repo/overlays/dev?ref=v1.2.0",
129 want: false,
130 },
131 IsRemoteCase {
132 name: "unsupported scheme is not a remote target",
133 target: "s3://bucket/org/repo",
134 want: false,
135 },
136 IsRemoteCase {
137 name: "relative directory",
138 target: "./overlays/dev",
139 want: false,
140 },
141 IsRemoteCase {
142 name: "bare relative directory",
143 target: "overlays/dev",
144 want: false,
145 },
146 IsRemoteCase {
147 name: "absolute path",
148 target: "/abs/overlays/dev",
149 want: false,
150 },
151 IsRemoteCase {
152 name: "builtin overlay reference",
153 target: "builtin:dev-ca",
154 want: false,
155 },
156 IsRemoteCase {
157 name: "empty path",
158 target: "",
159 want: false,
160 },
161 ];
162
163 for case in cases {
164 assert_eq!(is_remote_target(case.target), case.want, "{}", case.name);
165 }
166 }
167
168 struct UrlSchemeCase {
171 name: &'static str,
172 target: &'static str,
173 want: &'static str,
174 }
175
176 #[test]
177 fn url_scheme_cases() {
178 let cases = [
179 UrlSchemeCase {
180 name: "supported scheme",
181 target: "https://gitlab.com/org/repo.git//dir?ref=v1",
182 want: "https",
183 },
184 UrlSchemeCase {
185 name: "unsupported scheme",
186 target: "s3://bucket/org/repo",
187 want: "s3",
188 },
189 UrlSchemeCase {
190 name: "go-getter git prefix parses as git scheme",
191 target: "git::https://example.com/org/repo",
192 want: "git",
193 },
194 UrlSchemeCase {
195 name: "relative path has no scheme",
196 target: "./overlays/dev",
197 want: "",
198 },
199 UrlSchemeCase {
200 name: "scp-style target has no scheme",
201 target: "git@gitlab.com:group/repo.git//dir",
202 want: "",
203 },
204 UrlSchemeCase {
205 name: "relative path with colon in first segment is not a url",
206 target: "overlays:v2/dev",
207 want: "",
208 },
209 UrlSchemeCase {
210 name: "windows-style drive path is not a url",
211 target: "c:/overlays/dev",
212 want: "",
213 },
214 ];
215
216 for case in cases {
217 assert_eq!(url_scheme(case.target), case.want, "{}", case.name);
218 }
219 }
220
221 struct RedactCase {
224 name: &'static str,
225 target: &'static str,
226 want: &'static str,
227 }
228
229 #[test]
230 fn redact_credentials_cases() {
231 let cases = [
232 RedactCase {
233 name: "user and password stripped",
234 target: "https://gitlab-ci-token:secret@gitlab.com/org/repo.git//dir?ref=v1",
235 want: "https://gitlab.com/org/repo.git//dir?ref=v1",
236 },
237 RedactCase {
238 name: "token as bare username stripped",
239 target: "https://glpat-secret@gitlab.com/org/repo.git//dir",
240 want: "https://gitlab.com/org/repo.git//dir",
241 },
242 RedactCase {
243 name: "url without credentials unchanged",
244 target: "https://gitlab.com/org/repo.git//dir?ref=v1",
245 want: "https://gitlab.com/org/repo.git//dir?ref=v1",
246 },
247 RedactCase {
248 name: "opaque url fails closed",
249 target: "git::https://user:secret@example.com/org/repo",
250 want: "<unparseable kustomize target>",
251 },
252 RedactCase {
253 name: "local path unchanged",
254 target: "./overlays/dev",
255 want: "./overlays/dev",
256 },
257 RedactCase {
258 name: "builtin reference unchanged",
259 target: "builtin:dev-ca",
260 want: "builtin:dev-ca",
261 },
262 ];
263
264 for case in cases {
265 assert_eq!(redact_credentials(case.target), case.want, "{}", case.name);
266 }
267 }
268
269 struct HasPinnedRefCase {
272 name: &'static str,
273 target: &'static str,
274 want: bool,
275 }
276
277 #[test]
278 fn has_pinned_ref_cases() {
279 let cases = [
280 HasPinnedRefCase {
281 name: "ref query param",
282 target: "https://gitlab.com/org/repo.git//dir?ref=v1.2.0",
283 want: true,
284 },
285 HasPinnedRefCase {
286 name: "version query param is a ref synonym",
287 target: "https://gitlab.com/org/repo.git//dir?version=v2.0.0",
288 want: true,
289 },
290 HasPinnedRefCase {
291 name: "ref alongside other params",
292 target: "https://gitlab.com/org/repo.git//dir?timeout=90&ref=main",
293 want: true,
294 },
295 HasPinnedRefCase {
296 name: "no query string",
297 target: "https://gitlab.com/org/repo.git//dir",
298 want: false,
299 },
300 HasPinnedRefCase {
301 name: "query without ref",
302 target: "https://gitlab.com/org/repo.git//dir?timeout=90&submodules=false",
303 want: false,
304 },
305 HasPinnedRefCase {
306 name: "empty ref",
307 target: "https://gitlab.com/org/repo.git//dir?ref=",
308 want: false,
309 },
310 ];
311
312 for case in cases {
313 assert_eq!(has_pinned_ref(case.target), case.want, "{}", case.name);
314 }
315 }
316
317 struct UntouchedFileSystem;
332
333 impl crate::kustomize::FileSystem for UntouchedFileSystem {
334 fn write_file(&self, _path: &str, _content: &[u8]) -> std::io::Result<()> {
335 panic!("a remote-target build must not touch FileSystem::write_file")
336 }
337
338 fn read_file(&self, _path: &str) -> std::io::Result<Vec<u8>> {
339 panic!("a remote-target build must not touch FileSystem::read_file")
340 }
341
342 fn walk(&self, _dir: &str) -> std::io::Result<Vec<String>> {
343 panic!("a remote-target build must not touch FileSystem::walk")
344 }
345 }
346
347 #[test]
348 fn client_build_remote_target() {
349 use std::process::Command;
350 use std::sync::Arc;
351
352 use crate::kustomize::fixtures::{
353 NAME_PREFIX_KUSTOMIZATION, SETTINGS_CONFIG_MAP, assert_dev_prefixed_config_map,
354 };
355 use crate::kustomize::{Backend, Client};
356
357 let saved_env: Vec<(&str, Option<String>)> = [
364 "GIT_DIR",
365 "GIT_INDEX_FILE",
366 "GIT_WORK_TREE",
367 "GIT_OBJECT_DIRECTORY",
368 "GIT_COMMON_DIR",
369 ]
370 .iter()
371 .map(|name| (*name, std::env::var(name).ok()))
372 .collect();
373 for (name, _) in &saved_env {
374 unsafe {
379 std::env::remove_var(name);
380 }
381 }
382
383 let repo = tempfile::tempdir().expect("create tempdir");
384 let run_git = |args: &[&str]| {
385 let output = Command::new("git")
386 .arg("-C")
387 .arg(repo.path())
388 .args(args)
389 .output()
390 .expect("git binary must be on PATH");
391 assert!(
392 output.status.success(),
393 "git {args:?} failed: {}",
394 String::from_utf8_lossy(&output.stderr)
395 );
396 };
397
398 let overlay = repo.path().join("overlay");
399 std::fs::create_dir_all(&overlay).expect("create overlay dir");
400 std::fs::write(
401 overlay.join("kustomization.yaml"),
402 NAME_PREFIX_KUSTOMIZATION,
403 )
404 .expect("write kustomization.yaml");
405 std::fs::write(overlay.join("configmap.yaml"), SETTINGS_CONFIG_MAP)
406 .expect("write configmap.yaml");
407
408 run_git(&["init", "-q"]);
409 run_git(&["add", "."]);
410 run_git(&[
411 "-c",
412 "user.email=test@example.com",
413 "-c",
414 "user.name=Test",
415 "commit",
416 "-q",
417 "-m",
418 "overlay",
419 ]);
420 run_git(&["tag", "v1.0.0"]);
421
422 let client = Client::with_fs(
423 Arc::new(UntouchedFileSystem),
424 Arc::new(xianyun_executor::exec::Client::new()),
425 );
426
427 let target = format!("file://{}//overlay?ref=v1.0.0", repo.path().display());
428 let got = client
429 .build(&target)
430 .expect("remote target must build successfully");
431
432 assert_dev_prefixed_config_map(&got, "the remote overlay's namePrefix must be applied");
433
434 for (name, value) in saved_env {
435 match value {
438 Some(value) => unsafe { std::env::set_var(name, value) },
439 None => unsafe { std::env::remove_var(name) },
440 }
441 }
442 }
443}