Skip to main content

xianyun_backends/kustomize/
remote.rs

1//! Remote kustomize target helpers.
2//!
3//! Ported from `internal/backends/kustomize/remote.go`. Not yet
4//! implemented.
5
6/// Ported from `IsRemoteTarget`. Reports whether `path` is a kustomize
7/// remote target: a URL whose scheme kustomize can resolve
8/// (`http`/`https`/`ssh`/`file`). Everything else is a local path.
9#[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/// Ported from `URLScheme`. Returns the URL scheme of `path`, or empty
18/// when `path` has none or does not parse as a URL. Only paths
19/// containing "://" count as URLs, so a local path with a colon in its
20/// first segment (`overlays:v2/dev`, `c:/x`) stays a local path.
21#[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/// Ported from `RedactCredentials`. Returns `target` safe for logging:
34/// credentials embedded in a URL-form remote target's userinfo are
35/// removed, leaving scheme, host, path, and query. The whole userinfo
36/// goes, not just the password, because tokens are commonly passed as a
37/// bare username. Local paths are returned unchanged.
38#[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        // Opaque, e.g. `git::https://...`: could still embed credentials
50        // in a form the parser cannot expose, so fail closed rather than
51        // echo it.
52        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/// Ported from `HasPinnedRef`. Reports whether a remote target carries
63/// an explicit git ref (`?ref=` or its synonym `?version=`). An unpinned
64/// target builds whatever the repository's default branch points at,
65/// which is not reproducible.
66#[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    // --- Ported from remote_test.go: TestIsRemoteTarget ---
81
82    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    // --- Ported from remote_test.go: TestURLScheme ---
169
170    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    // --- Ported from remote_test.go: TestRedactCredentials ---
222
223    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    // --- Ported from remote_test.go: TestHasPinnedRef ---
270
271    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    // --- Ported from remote_integration_test.go: TestClient_Build_remoteTarget ---
318    //
319    // `Client::build` (`mod.rs`) now shells out to the real `kustomize`
320    // binary for every target. For a remote target (`is_remote_target`
321    // reports `true`), it passes the raw target string straight through
322    // to `kustomize build`, never touching the injected `FileSystem` at
323    // all -- kustomize's own machinery resolves the shallow git clone
324    // and the `?ref=` pin itself, the same as Go's `krusty` library
325    // does internally. `UntouchedFileSystem` below panics if `build`
326    // ever calls it, proving the remote-target path really does bypass
327    // `FileSystem`/staging.
328
329    /// A [`crate::kustomize::FileSystem`] that panics on every call, to
330    /// prove a remote-target build never touches it.
331    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        // Ported from the Go test's `GIT_DIR`/etc cleanup: these
358        // redirection variables (an inherited `GIT_INDEX_FILE` in
359        // particular, which `pre-commit` exports) interfere with
360        // kustomize's own internal clone, making it write the fixture
361        // repository's file list into the *outer* repository's index
362        // instead of its own.
363        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            // SAFETY: this test function isn't run concurrently with any
375            // other test that reads/writes these GIT_* variables (none
376            // elsewhere in this crate does); restored unconditionally
377            // below before the function returns.
378            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            // SAFETY: restoring the GIT_* variables this same function
436            // cleared above, on the same thread, before returning.
437            match value {
438                Some(value) => unsafe { std::env::set_var(name, value) },
439                None => unsafe { std::env::remove_var(name) },
440            }
441        }
442    }
443}