1use std::io::Read;
16use std::path::{Path, PathBuf};
17use std::process::{Command, Stdio};
18use std::time::{Duration, Instant};
19
20use serde::{Deserialize, Serialize};
21
22use crate::error::{Error, Result};
23
24pub const GIT_TIMEOUT: Duration = Duration::from_secs(600);
26
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(deny_unknown_fields)]
30pub struct GitSource {
31 pub url: String,
34 #[serde(default = "default_ref", rename = "ref")]
36 pub reference: String,
37 #[serde(default, skip_serializing_if = "Option::is_none")]
39 pub subdir: Option<String>,
40 #[serde(default, skip_serializing_if = "GitAuth::is_none")]
41 pub auth: GitAuth,
42 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
44 pub submodules: bool,
45}
46
47fn default_ref() -> String {
48 "main".into()
49}
50
51#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
53#[serde(untagged)]
54pub enum GitAuth {
55 #[default]
56 None,
57 Token {
61 token_secret: String,
62 #[serde(default, skip_serializing_if = "Option::is_none")]
63 username: Option<String>,
64 },
65 SshKey { ssh_key_secret: String },
67}
68
69impl GitAuth {
70 pub fn is_none(&self) -> bool {
71 matches!(self, GitAuth::None)
72 }
73
74 pub fn secret(&self) -> Option<&str> {
75 match self {
76 GitAuth::None => None,
77 GitAuth::Token { token_secret, .. } => Some(token_secret),
78 GitAuth::SshKey { ssh_key_secret } => Some(ssh_key_secret),
79 }
80 }
81}
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85pub enum Transport {
86 Https,
87 Http,
89 Ssh,
90 Git,
92}
93
94impl GitSource {
95 pub fn validate(&self) -> Result<Transport> {
96 let t = transport(&self.url)?;
97 validate_ref(&self.reference)?;
98 if let Some(s) = &self.subdir {
99 validate_subdir(s)?;
100 }
101 match (&self.auth, t) {
102 (GitAuth::Token { .. }, Transport::Https) => {}
103 (GitAuth::Token { .. }, _) => {
104 return Err(Error::invalid(
105 "token auth needs an https:// URL (a token is never sent in clear)",
106 ));
107 }
108 (GitAuth::SshKey { .. }, Transport::Ssh) => {}
109 (GitAuth::SshKey { .. }, _) => {
110 return Err(Error::invalid(
111 "a deploy key needs an SSH URL (ssh://... or git@host:owner/repo)",
112 ));
113 }
114 (GitAuth::None, _) => {}
115 }
116 if let GitAuth::Token {
117 token_secret,
118 username,
119 } = &self.auth
120 {
121 crate::secrets::validate_name(token_secret)?;
122 if let Some(u) = username {
123 if u.is_empty() || u.contains([':', '\n', '\r']) {
124 return Err(Error::invalid(format!("git username {u:?}")));
125 }
126 }
127 }
128 if let GitAuth::SshKey { ssh_key_secret } = &self.auth {
129 crate::secrets::validate_name(ssh_key_secret)?;
130 }
131 Ok(t)
132 }
133
134 pub fn matches_push(&self, pushed: &str) -> bool {
138 let r = self.reference.as_str();
139 if is_sha(r) {
140 return false;
141 }
142 if r.starts_with("refs/") {
143 return pushed == r;
144 }
145 pushed == format!("refs/heads/{r}") || pushed == format!("refs/tags/{r}")
146 }
147}
148
149pub fn is_sha(r: &str) -> bool {
150 (r.len() == 40 || r.len() == 64) && r.bytes().all(|b| b.is_ascii_hexdigit())
151}
152
153pub fn transport(url: &str) -> Result<Transport> {
156 let bad = |why: &str| Err(Error::invalid(format!("git URL {url:?}: {why}")));
157 if url.is_empty() || url.len() > 2048 {
158 return bad("empty or too long");
159 }
160 if url.starts_with('-') || url.chars().any(|c| c.is_whitespace() || c.is_control()) {
161 return bad("must not start with '-' or contain whitespace");
162 }
163 if let Some((scheme, rest)) = url.split_once("://") {
164 let host = rest.split(['/', '?', '#']).next().unwrap_or("");
165 let host = host.rsplit('@').next().unwrap_or("");
166 if host.is_empty() || host.starts_with('-') {
167 return bad("no host");
168 }
169 if rest
170 .split(['/', '?', '#'])
171 .next()
172 .unwrap_or("")
173 .contains('@')
174 && matches!(scheme, "https" | "http")
175 {
176 return bad("credentials in the URL; store a token as an org secret instead");
178 }
179 return match scheme {
180 "https" => Ok(Transport::Https),
181 "http" => Ok(Transport::Http),
182 "ssh" | "git+ssh" | "ssh+git" => Ok(Transport::Ssh),
183 "git" => Ok(Transport::Git),
184 _ => bad("only https, http, ssh and git URLs"),
185 };
186 }
187 if url.contains("::") {
188 return bad("transport helpers (ext::, fd::) are not allowed");
189 }
190 match url.split_once(':') {
192 Some((host, path)) if !host.contains('/') && !host.is_empty() && !path.is_empty() => {
193 let h = host.rsplit('@').next().unwrap_or("");
194 if h.is_empty() || h.starts_with('-') {
195 return bad("no host");
196 }
197 Ok(Transport::Ssh)
198 }
199 _ => bad("a local path; only remote repositories can be fetched"),
200 }
201}
202
203pub fn validate_ref(r: &str) -> Result<()> {
206 let ok = !r.is_empty()
207 && r.len() <= 255
208 && !r.starts_with(['-', '/', '.'])
209 && !r.ends_with(['/', '.'])
210 && !r.ends_with(".lock")
211 && !r.contains("..")
212 && !r.contains("@{")
213 && !r.contains("//")
214 && r.bytes().all(|b| {
215 b > 0x20 && b != 0x7f && !matches!(b, b'~' | b'^' | b':' | b'?' | b'*' | b'[' | b'\\')
216 });
217 if ok {
218 Ok(())
219 } else {
220 Err(Error::invalid(format!("git ref {r:?}")))
221 }
222}
223
224pub fn validate_subdir(s: &str) -> Result<()> {
226 let p = Path::new(s);
227 let ok = !s.is_empty()
228 && !p.is_absolute()
229 && p.components()
230 .all(|c| matches!(c, std::path::Component::Normal(_)));
231 if ok {
232 Ok(())
233 } else {
234 Err(Error::invalid(format!(
235 "subdir {s:?}: a relative path inside the repository"
236 )))
237 }
238}
239
240fn origin(url: &str) -> Option<String> {
242 let rest = url.strip_prefix("https://")?;
243 let host = rest.split(['/', '?', '#']).next()?;
244 Some(format!("https://{host}/"))
245}
246
247#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
249pub struct Checkout {
250 pub sha: String,
251 pub message: String,
253 pub dir: PathBuf,
256}
257
258pub enum Credentials {
260 None,
261 Token { username: String, token: String },
262 SshKey { private_key: Vec<u8> },
263}
264
265impl Credentials {
266 fn env(&self, url: &str, tmp: &Path, known_hosts: &Path) -> Result<Vec<(String, String)>> {
269 let mut env = Vec::new();
270 match self {
271 Credentials::None => {}
272 Credentials::Token { username, token } => {
273 use base64::Engine;
274 let origin = origin(url)
275 .ok_or_else(|| Error::invalid("token auth needs an https:// URL"))?;
276 if token.contains(['\n', '\r']) {
277 return Err(Error::invalid("the git token holds a line break"));
278 }
279 let basic =
280 base64::engine::general_purpose::STANDARD.encode(format!("{username}:{token}"));
281 env.push(("GIT_CONFIG_COUNT".into(), "1".into()));
282 env.push((
283 "GIT_CONFIG_KEY_0".into(),
284 format!("http.{origin}.extraHeader"),
285 ));
286 env.push((
287 "GIT_CONFIG_VALUE_0".into(),
288 format!("Authorization: Basic {basic}"),
289 ));
290 }
291 Credentials::SshKey { private_key } => {
292 let key = tmp.join("key");
293 write_private(&key, private_key)?;
294 env.push(("GIT_SSH_COMMAND".into(), ssh_command(&key, known_hosts)));
295 }
296 }
297 Ok(env)
298 }
299}
300
301pub fn ssh_command(key: &Path, known_hosts: &Path) -> String {
303 format!(
304 "ssh -F /dev/null -i {} -o IdentitiesOnly=yes -o IdentityAgent=none -o BatchMode=yes \
305 -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile={} -o ConnectTimeout=30",
306 shell_quote(&key.to_string_lossy()),
307 shell_quote(&known_hosts.to_string_lossy())
308 )
309}
310
311fn shell_quote(s: &str) -> String {
312 format!("'{}'", s.replace('\'', r"'\''"))
313}
314
315fn write_private(p: &Path, data: &[u8]) -> Result<()> {
316 use std::io::Write;
317 use std::os::unix::fs::OpenOptionsExt;
318 let mut f = std::fs::OpenOptions::new()
319 .write(true)
320 .create_new(true)
321 .mode(0o600)
322 .open(p)?;
323 f.write_all(data)?;
324 if !data.ends_with(b"\n") {
326 f.write_all(b"\n")?;
327 }
328 f.sync_all()?;
329 Ok(())
330}
331
332pub(crate) struct TempDir(PathBuf);
334
335impl TempDir {
336 pub(crate) fn new(parent: &Path) -> Result<TempDir> {
337 std::fs::create_dir_all(parent)?;
338 for _ in 0..16 {
339 let p = parent.join(format!(".tmp-{}", random_hex(8)));
340 match std::fs::create_dir(&p) {
341 Ok(()) => {
342 use std::os::unix::fs::PermissionsExt;
343 std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o700))?;
344 return Ok(TempDir(p));
345 }
346 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
347 Err(e) => return Err(e.into()),
348 }
349 }
350 Err(Error::invalid("cannot make a temporary directory"))
351 }
352 pub(crate) fn path(&self) -> &Path {
353 &self.0
354 }
355}
356
357impl Drop for TempDir {
358 fn drop(&mut self) {
359 let _ = std::fs::remove_dir_all(&self.0);
360 }
361}
362
363pub(crate) fn random_hex(n: usize) -> String {
364 use ring::rand::SecureRandom;
365 let mut b = vec![0u8; n];
366 ring::rand::SystemRandom::new()
367 .fill(&mut b)
368 .expect("the OS random source failed");
369 b.iter().map(|x| format!("{x:02x}")).collect()
370}
371
372fn git_base() -> Vec<String> {
374 [
375 "-c",
376 "core.hooksPath=/dev/null",
377 "-c",
378 "protocol.file.allow=never",
379 "-c",
380 "protocol.ext.allow=never",
381 "-c",
383 "protocol.allow=never",
384 "-c",
385 "protocol.https.allow=always",
386 "-c",
387 "protocol.http.allow=always",
388 "-c",
389 "protocol.ssh.allow=always",
390 "-c",
391 "protocol.git.allow=always",
392 "-c",
393 "submodule.recurse=false",
394 "-c",
395 "core.fsmonitor=false",
396 "-c",
397 "credential.helper=",
398 "-c",
399 "advice.detachedHead=false",
400 ]
401 .iter()
402 .map(|s| s.to_string())
403 .collect()
404}
405
406fn git(
409 dir: &Path,
410 args: &[&str],
411 env: &[(String, String)],
412 home: &Path,
413 timeout: Duration,
414 log: &mut dyn FnMut(&str),
415) -> Result<String> {
416 let mut cmd = Command::new(std::env::var_os("ISB_GIT_BIN").unwrap_or_else(|| "git".into()));
417 cmd.args(git_base())
418 .args(args)
419 .current_dir(dir)
420 .env_clear()
421 .env("HOME", home)
422 .env("GIT_TERMINAL_PROMPT", "0")
423 .env("GIT_CONFIG_NOSYSTEM", "1")
424 .env("GIT_CONFIG_GLOBAL", "/dev/null")
425 .env("GIT_ASKPASS", "/bin/false")
426 .env("SSH_ASKPASS", "/bin/false")
427 .env("LC_ALL", "C")
428 .stdin(Stdio::null())
429 .stdout(Stdio::piped())
430 .stderr(Stdio::piped());
431 if let Some(p) = std::env::var_os("PATH") {
432 cmd.env("PATH", p);
433 }
434 for (k, v) in env {
435 cmd.env(k, v);
436 }
437 let mut child = cmd
438 .spawn()
439 .map_err(|e| Error::invalid(format!("cannot run git: {e}")))?;
440 let (mut out, mut err) = (child.stdout.take().unwrap(), child.stderr.take().unwrap());
441 let rd = std::thread::spawn(move || {
442 let mut b = Vec::new();
443 let _ = out.read_to_end(&mut b);
444 b
445 });
446 let ed = std::thread::spawn(move || {
447 let mut b = Vec::new();
448 let _ = err.read_to_end(&mut b);
449 b
450 });
451 let started = Instant::now();
452 let status = loop {
453 if let Some(s) = child.try_wait()? {
454 break s;
455 }
456 if started.elapsed() > timeout {
457 let _ = child.kill();
458 let _ = child.wait();
459 return Err(Error::invalid(format!(
460 "git {} took longer than {timeout:?}",
461 args.first().unwrap_or(&"")
462 )));
463 }
464 std::thread::sleep(Duration::from_millis(50));
465 };
466 let stdout = String::from_utf8_lossy(&rd.join().unwrap_or_default()).into_owned();
467 let stderr = String::from_utf8_lossy(&ed.join().unwrap_or_default()).into_owned();
468 for l in stderr.lines().filter(|l| !l.trim().is_empty()) {
469 log(&format!("git: {}", l.trim_end()));
470 }
471 if !status.success() {
472 let last = stderr.lines().rev().find(|l| !l.trim().is_empty());
473 return Err(Error::invalid(format!(
474 "git {} failed: {}",
475 args.first().unwrap_or(&""),
476 last.unwrap_or("no output").trim()
477 )));
478 }
479 Ok(stdout)
480}
481
482#[expect(
485 clippy::too_many_lines,
486 reason = "predates the lint ratchet; split it when next changed"
487)]
488pub fn fetch(
489 src: &GitSource,
490 creds: &Credentials,
491 dir: &Path,
492 log: &mut dyn FnMut(&str),
493) -> Result<Checkout> {
494 src.validate()?;
495 std::fs::create_dir_all(dir)?;
496 let repo = dir.join("repo");
497 let known_hosts = dir.join("known_hosts");
498 let tmp = TempDir::new(dir)?;
499 let home = tmp.path().join("home");
500 std::fs::create_dir(&home)?;
501 let env = creds.env(&src.url, tmp.path(), &known_hosts)?;
502 if !repo.join(".git").is_dir() {
503 let _ = std::fs::remove_dir_all(&repo);
504 std::fs::create_dir_all(&repo)?;
505 git(&repo, &["init", "-q"], &[], &home, GIT_TIMEOUT, log)?;
506 }
507 log(&format!("fetching {} {}", redact(&src.url), src.reference));
508 let refspec = &src.reference;
511 git(
512 &repo,
513 &[
514 "fetch",
515 "--quiet",
516 "--depth=1",
517 "--no-tags",
518 "--no-recurse-submodules",
519 "--no-write-fetch-head",
520 "--",
521 &src.url,
522 &format!("+{refspec}:refs/isb/source"),
523 ],
524 &env,
525 &home,
526 GIT_TIMEOUT,
527 log,
528 )
529 .or_else(|e| {
530 if is_sha(&src.reference) || src.reference.starts_with("refs/") {
531 return Err(e);
532 }
533 git(
535 &repo,
536 &[
537 "fetch",
538 "--quiet",
539 "--depth=1",
540 "--no-tags",
541 "--no-recurse-submodules",
542 "--no-write-fetch-head",
543 "--",
544 &src.url,
545 &format!("+refs/tags/{}:refs/isb/source", src.reference),
546 ],
547 &env,
548 &home,
549 GIT_TIMEOUT,
550 log,
551 )
552 .map_err(|_| e)
553 })?;
554 git(
555 &repo,
556 &[
557 "checkout",
558 "--quiet",
559 "--force",
560 "--detach",
561 "refs/isb/source",
562 ],
563 &[],
564 &home,
565 GIT_TIMEOUT,
566 log,
567 )?;
568 git(&repo, &["clean", "-ffdxq"], &[], &home, GIT_TIMEOUT, log)?;
569 if src.submodules {
570 log("updating submodules");
571 git(
572 &repo,
573 &[
574 "submodule",
575 "update",
576 "--init",
577 "--recursive",
578 "--depth=1",
579 "--force",
580 ],
581 &env,
582 &home,
583 GIT_TIMEOUT,
584 log,
585 )?;
586 }
587 let sha = git(&repo, &["rev-parse", "HEAD"], &[], &home, GIT_TIMEOUT, log)?
588 .trim()
589 .to_string();
590 if is_sha(&src.reference) && !sha.eq_ignore_ascii_case(&src.reference) {
591 return Err(Error::invalid(format!(
592 "fetched {sha}, not the pinned {}",
593 src.reference
594 )));
595 }
596 let message = git(
597 &repo,
598 &["log", "-1", "--format=%s", "HEAD"],
599 &[],
600 &home,
601 GIT_TIMEOUT,
602 log,
603 )?
604 .trim()
605 .to_string();
606 if let Some(sub) = &src.subdir {
607 let real = repo
609 .join(sub)
610 .canonicalize()
611 .map_err(|_| Error::invalid(format!("subdir {sub:?} is not in the repository")))?;
612 if !real.starts_with(repo.canonicalize()?) || !real.is_dir() {
613 return Err(Error::invalid(format!(
614 "subdir {sub:?} is not a directory in the repository"
615 )));
616 }
617 }
618 log(&format!("checked out {sha}: {message}"));
619 Ok(Checkout {
620 sha,
621 message,
622 dir: repo,
623 })
624}
625
626pub fn redact(url: &str) -> String {
628 match url.split_once("://") {
629 Some((s, rest)) => {
630 let (auth, tail) = match rest.find('/') {
631 Some(i) => rest.split_at(i),
632 None => (rest, ""),
633 };
634 match auth.rsplit_once('@') {
635 Some((_, host)) if s.starts_with("http") => format!("{s}://{host}{tail}"),
636 _ => url.to_string(),
637 }
638 }
639 None => url.to_string(),
640 }
641}
642
643pub fn generate_deploy_key(scratch: &Path, comment: &str) -> Result<(Vec<u8>, String)> {
646 let tmp = TempDir::new(scratch)?;
647 let key = tmp.path().join("key");
648 let out = Command::new("ssh-keygen")
649 .args(["-q", "-t", "ed25519", "-N", "", "-C", comment, "-f"])
650 .arg(&key)
651 .env_clear()
652 .stdin(Stdio::null())
653 .stdout(Stdio::null())
654 .stderr(Stdio::piped())
655 .output()
656 .map_err(|e| Error::invalid(format!("cannot run ssh-keygen: {e}")))?;
657 if !out.status.success() {
658 return Err(Error::invalid(format!(
659 "ssh-keygen: {}",
660 String::from_utf8_lossy(&out.stderr).trim()
661 )));
662 }
663 let private = std::fs::read(&key)?;
664 let public = std::fs::read_to_string(key.with_extension("pub"))?;
665 Ok((private, public.trim().to_string()))
666}
667
668#[cfg(test)]
669mod tests {
670 use super::*;
671
672 fn src(url: &str, auth: GitAuth) -> GitSource {
673 GitSource {
674 url: url.into(),
675 reference: "main".into(),
676 subdir: None,
677 auth,
678 submodules: false,
679 }
680 }
681
682 #[test]
683 fn transports() {
684 assert_eq!(
685 transport("https://github.com/o/r.git").unwrap(),
686 Transport::Https
687 );
688 assert_eq!(transport("http://h:3000/o/r").unwrap(), Transport::Http);
689 assert_eq!(transport("ssh://git@h:22/o/r").unwrap(), Transport::Ssh);
690 assert_eq!(transport("git@github.com:o/r.git").unwrap(), Transport::Ssh);
691 assert_eq!(transport("git://127.0.0.1:9418/r").unwrap(), Transport::Git);
692 for bad in [
693 "",
694 "file:///etc",
695 "/srv/repo.git",
696 "./repo",
697 "../x",
698 "repo",
699 "ext::sh -c touch% /tmp/x",
700 "fd::3",
701 "-uhttps://x/y",
702 "--upload-pack=touch /tmp/x",
703 "https://user:pass@github.com/o/r",
704 "https:///o/r",
705 "ssh://-oProxyCommand=x/r",
706 "-oProxyCommand=x:r",
707 "https://h/o/r with space",
708 "ftp://h/r",
709 ] {
710 assert!(transport(bad).is_err(), "{bad:?} was allowed");
711 }
712 }
713
714 #[test]
715 fn auth_must_fit_the_transport() {
716 let tok = GitAuth::Token {
717 token_secret: "gh_token".into(),
718 username: None,
719 };
720 let key = GitAuth::SshKey {
721 ssh_key_secret: "deploy.key".into(),
722 };
723 assert!(src("https://h/o/r", tok.clone()).validate().is_ok());
724 assert!(src("http://h/o/r", tok.clone()).validate().is_err());
725 assert!(src("git@h:o/r", tok.clone()).validate().is_err());
726 assert!(src("git@h:o/r", key.clone()).validate().is_ok());
727 assert!(src("https://h/o/r", key).validate().is_err());
728 let mut s = src("https://h/o/r", GitAuth::None);
729 s.reference = "--upload-pack=x".into();
730 assert!(s.validate().is_err());
731 s.reference = "feature/x".into();
732 assert!(s.validate().is_ok());
733 s.subdir = Some("../etc".into());
734 assert!(s.validate().is_err());
735 s.subdir = Some("apps/web".into());
736 assert!(s.validate().is_ok());
737 let a: GitAuth = serde_json::from_value(serde_json::json!({"token_secret": "t"})).unwrap();
739 assert!(matches!(a, GitAuth::Token { .. }));
740 let a: GitAuth =
741 serde_json::from_value(serde_json::json!({"ssh_key_secret": "k"})).unwrap();
742 assert_eq!(a.secret(), Some("k"));
743 }
744
745 #[test]
746 fn token_travels_in_env_scoped_to_origin() {
747 let dir = tempfile::tempdir().unwrap();
748 let c = Credentials::Token {
749 username: "x-access-token".into(),
750 token: "s3cr3t".into(),
751 };
752 let env = c
753 .env(
754 "https://git.example.com:8443/o/r.git",
755 dir.path(),
756 &dir.path().join("kh"),
757 )
758 .unwrap();
759 let m: std::collections::BTreeMap<_, _> = env.into_iter().collect();
760 assert_eq!(
761 m["GIT_CONFIG_KEY_0"],
762 "http.https://git.example.com:8443/.extraHeader"
763 );
764 assert!(m["GIT_CONFIG_VALUE_0"].starts_with("Authorization: Basic "));
765 assert!(!m["GIT_CONFIG_VALUE_0"].contains("s3cr3t"));
766 assert!(!git_base().iter().any(|a| a.contains("s3cr3t")));
768 }
769
770 #[test]
771 fn ssh_key_is_a_private_file() {
772 let dir = tempfile::tempdir().unwrap();
773 let c = Credentials::SshKey {
774 private_key:
775 b"-----BEGIN OPENSSH PRIVATE KEY-----\nx\n-----END OPENSSH PRIVATE KEY-----"
776 .to_vec(),
777 };
778 let kh = dir.path().join("known hosts");
779 let env = c.env("git@h:o/r", dir.path(), &kh).unwrap();
780 let cmd = &env[0].1;
781 assert!(cmd.contains("IdentitiesOnly=yes"));
782 assert!(cmd.contains("StrictHostKeyChecking=accept-new"));
783 assert!(cmd.contains("'"), "{cmd}");
784 use std::os::unix::fs::PermissionsExt;
785 let m = std::fs::metadata(dir.path().join("key")).unwrap();
786 assert_eq!(m.permissions().mode() & 0o777, 0o600);
787 assert!(
788 std::fs::read(dir.path().join("key"))
789 .unwrap()
790 .ends_with(b"\n")
791 );
792 }
793
794 #[test]
795 fn push_matching() {
796 let mut s = src("https://h/o/r", GitAuth::None);
797 assert!(s.matches_push("refs/heads/main"));
798 assert!(s.matches_push("refs/tags/main"));
799 assert!(!s.matches_push("refs/heads/dev"));
800 assert!(!s.matches_push("refs/heads/main2"));
801 s.reference = "a".repeat(40);
802 assert!(!s.matches_push("refs/heads/main"));
803 s.reference = "refs/heads/release".into();
804 assert!(s.matches_push("refs/heads/release"));
805 assert!(!s.matches_push("refs/tags/release"));
806 }
807
808 #[test]
809 fn redacts_userinfo() {
810 assert_eq!(redact("https://u:p@h/o/r"), "https://h/o/r");
811 assert_eq!(redact("git@h:o/r"), "git@h:o/r");
812 assert_eq!(redact("ssh://git@h/o/r"), "ssh://git@h/o/r");
813 }
814}