1use std::path::Path;
29
30use crate::cert_authority::{parse_known_hosts, CertAuthority, KnownHostsFile, RevokedEntry};
31use crate::error::AnvilError;
32use crate::ssh_config::lexer::wildcard_match;
33
34pub const DEFAULT_GITHUB_HOST: &str = "github.com";
38
39pub const GITHUB_FALLBACK_HOST: &str = "ssh.github.com";
43
44pub const DEFAULT_GITLAB_HOST: &str = "gitlab.com";
46
47pub const GITLAB_FALLBACK_HOST: &str = "altssh.gitlab.com";
51
52pub const DEFAULT_CODEBERG_HOST: &str = "codeberg.org";
54
55pub const DEFAULT_PORT: u16 = 22;
61
62pub const FALLBACK_PORT: u16 = 443;
64
65#[deprecated(since = "0.2.0", note = "use GITHUB_FALLBACK_HOST instead")]
70pub const FALLBACK_HOST: &str = GITHUB_FALLBACK_HOST;
71
72pub const GITHUB_FINGERPRINTS: &[&str] = &[
83 "SHA256:+DiY3wvvV6TuJJhbpZisF/zLDA0zPMSvHdkr4UvCOqU", "SHA256:p2QAMXNIC1TJYWeIOttrVc98/R1BUFWu3/LiyKgUfQM", "SHA256:uNiVztksCsDhcc0u9e8BujQXVUpKZIDTMczCvj3tD2s", ];
87
88pub const GITLAB_FINGERPRINTS: &[&str] = &[
97 "SHA256:eUXGGm1YGsMAS7vkcx6JOJdOGHPem5gQp4taiCfCLB8", "SHA256:HbW3g8zUjNSksFbqTiUWPWg2Bq1x8xdGUrliXFzSnUw", "SHA256:ROQFvPThGrW4RuWLoL9tq9I9zJ42fK4XywyRtbOz/EQ", ];
101
102pub const CODEBERG_FINGERPRINTS: &[&str] = &[
111 "SHA256:mIlxA9k46MmM6qdJOdMnAQpzGxF4WIVVL+fj+wZbw0g", "SHA256:T9FYDEHELhVkulEKKwge5aVhVTbqCW0MIRwAfpARs/E", "SHA256:6QQmYi4ppFS4/+zSZ5S4IU+4sa6rwvQ4PbhCtPEBekQ", ];
115
116fn fingerprints_from_known_hosts(path: &Path, hostname: &str) -> Result<Vec<String>, AnvilError> {
127 let content = std::fs::read_to_string(path)?;
128 let mut fps = Vec::new();
129
130 for line in content.lines() {
131 let line = line.trim();
132 if line.is_empty() || line.starts_with('#') {
133 continue;
134 }
135 let mut parts = line.splitn(2, ' ');
136 let Some(host_part) = parts.next() else {
137 continue;
138 };
139 let Some(fp_part) = parts.next() else {
140 continue;
141 };
142 if host_part == hostname {
143 fps.push(fp_part.trim().to_owned());
144 }
145 }
146
147 Ok(fps)
148}
149
150fn default_known_hosts_path() -> Option<std::path::PathBuf> {
152 dirs::config_dir().map(|d| d.join("gitway").join("known_hosts"))
153}
154
155pub fn fingerprints_for_host(
169 host: &str,
170 custom_path: &Option<std::path::PathBuf>,
171) -> Result<Vec<String>, AnvilError> {
172 let mut fps: Vec<String> = match host {
174 "github.com" | "ssh.github.com" => {
175 GITHUB_FINGERPRINTS.iter().map(|&s| s.to_owned()).collect()
176 }
177 "gitlab.com" | "altssh.gitlab.com" => {
178 GITLAB_FINGERPRINTS.iter().map(|&s| s.to_owned()).collect()
179 }
180 "codeberg.org" => CODEBERG_FINGERPRINTS
181 .iter()
182 .map(|&s| s.to_owned())
183 .collect(),
184 _ => Vec::new(),
185 };
186
187 let known_hosts_path = custom_path.clone().or_else(default_known_hosts_path);
191
192 if let Some(ref path) = known_hosts_path {
193 if path.exists() {
194 let extras = fingerprints_from_known_hosts(path, host)?;
195 fps.extend(extras);
196 }
197 }
198
199 if fps.is_empty() {
201 return Err(
202 AnvilError::invalid_config(format!("no fingerprints known for host '{host}'"))
203 .with_hint(format!(
204 "Gitway refuses to connect to hosts whose SSH fingerprint it can't \
205 verify (no trust-on-first-use). Either you typed the hostname \
206 wrong, or this is a self-hosted server and you need to pin its \
207 fingerprint: fetch it from the provider's docs (GitHub, GitLab, \
208 Codeberg publish them) and append one line to \
209 ~/.config/gitway/known_hosts:\n\
210 \n\
211 {host} SHA256:<base64-fingerprint>\n\
212 \n\
213 As a last resort, re-run with --insecure-skip-host-check (not \
214 recommended — this disables MITM protection)."
215 )),
216 );
217 }
218
219 Ok(fps)
220}
221
222#[derive(Debug, Clone, Default, PartialEq, Eq)]
243pub struct HostKeyTrust {
244 pub fingerprints: Vec<String>,
245 pub cert_authorities: Vec<CertAuthority>,
246 pub revoked: Vec<RevokedEntry>,
247}
248
249pub fn host_key_trust(
264 host: &str,
265 custom_path: &Option<std::path::PathBuf>,
266) -> Result<HostKeyTrust, AnvilError> {
267 let mut trust = HostKeyTrust {
268 fingerprints: embedded_fingerprints(host),
269 cert_authorities: Vec::new(),
270 revoked: Vec::new(),
271 };
272
273 let known_hosts_path = custom_path.clone().or_else(default_known_hosts_path);
274 let Some(path) = known_hosts_path else {
275 return Ok(trust);
276 };
277 if !path.exists() {
278 return Ok(trust);
279 }
280
281 let content = std::fs::read_to_string(&path).map_err(|e| {
282 AnvilError::invalid_config(format!(
283 "could not read known_hosts {}: {e}",
284 path.display(),
285 ))
286 })?;
287 let parsed: KnownHostsFile = parse_known_hosts(&content)?;
288
289 for direct in parsed.direct {
290 if wildcard_match(&direct.host_pattern, host) {
291 trust.fingerprints.push(direct.fingerprint);
292 }
293 }
294 for ca in parsed.cert_authorities {
295 if wildcard_match(&ca.host_pattern, host) {
296 trust.cert_authorities.push(ca);
297 }
298 }
299 for rev in parsed.revoked {
300 if wildcard_match(&rev.host_pattern, host) {
301 trust.revoked.push(rev);
302 }
303 }
304
305 Ok(trust)
306}
307
308fn embedded_fingerprints(host: &str) -> Vec<String> {
312 match host {
313 "github.com" | "ssh.github.com" => {
314 GITHUB_FINGERPRINTS.iter().map(|&s| s.to_owned()).collect()
315 }
316 "gitlab.com" | "altssh.gitlab.com" => {
317 GITLAB_FINGERPRINTS.iter().map(|&s| s.to_owned()).collect()
318 }
319 "codeberg.org" => CODEBERG_FINGERPRINTS
320 .iter()
321 .map(|&s| s.to_owned())
322 .collect(),
323 _ => Vec::new(),
324 }
325}
326
327pub(crate) fn append_known_host(
341 path: &Path,
342 host: &str,
343 fingerprint: &str,
344) -> Result<(), AnvilError> {
345 use std::io::Write;
346
347 if let Some(parent) = path.parent() {
348 if !parent.as_os_str().is_empty() {
349 std::fs::create_dir_all(parent).map_err(|e| {
350 AnvilError::invalid_config(format!(
351 "could not create known_hosts parent {}: {e}",
352 parent.display(),
353 ))
354 })?;
355 }
356 }
357
358 let line = format!("{host} {fingerprint}\n");
359 let mut file = std::fs::OpenOptions::new()
360 .append(true)
361 .create(true)
362 .open(path)
363 .map_err(|e| {
364 AnvilError::invalid_config(format!(
365 "could not open known_hosts {} for append: {e}",
366 path.display(),
367 ))
368 })?;
369 file.write_all(line.as_bytes()).map_err(|e| {
370 AnvilError::invalid_config(format!(
371 "could not write to known_hosts {}: {e}",
372 path.display(),
373 ))
374 })?;
375
376 Ok(())
377}
378
379#[cfg(test)]
382mod tests {
383 use super::*;
384
385 #[test]
386 fn github_com_returns_three_fingerprints() {
387 let fps = fingerprints_for_host("github.com", &None).unwrap();
388 assert_eq!(fps.len(), 3);
389 }
390
391 #[test]
392 fn ssh_github_com_returns_same_fingerprints() {
393 let fps = fingerprints_for_host("ssh.github.com", &None).unwrap();
394 assert_eq!(fps.len(), 3);
395 }
396
397 #[test]
398 fn gitlab_com_returns_three_fingerprints() {
399 let fps = fingerprints_for_host("gitlab.com", &None).unwrap();
400 assert_eq!(fps.len(), 3);
401 }
402
403 #[test]
404 fn altssh_gitlab_com_returns_same_fingerprints_as_gitlab() {
405 let primary = fingerprints_for_host("gitlab.com", &None).unwrap();
406 let fallback = fingerprints_for_host("altssh.gitlab.com", &None).unwrap();
407 assert_eq!(primary, fallback);
408 }
409
410 #[test]
411 fn codeberg_org_returns_three_fingerprints() {
412 let fps = fingerprints_for_host("codeberg.org", &None).unwrap();
413 assert_eq!(fps.len(), 3);
414 }
415
416 #[test]
417 fn all_github_fingerprints_start_with_sha256_prefix() {
418 for fp in GITHUB_FINGERPRINTS {
419 assert!(fp.starts_with("SHA256:"), "malformed fingerprint: {fp}");
420 }
421 }
422
423 #[test]
424 fn all_gitlab_fingerprints_start_with_sha256_prefix() {
425 for fp in GITLAB_FINGERPRINTS {
426 assert!(fp.starts_with("SHA256:"), "malformed fingerprint: {fp}");
427 }
428 }
429
430 #[test]
431 fn all_codeberg_fingerprints_start_with_sha256_prefix() {
432 for fp in CODEBERG_FINGERPRINTS {
433 assert!(fp.starts_with("SHA256:"), "malformed fingerprint: {fp}");
434 }
435 }
436
437 #[test]
438 fn unknown_host_without_known_hosts_is_error() {
439 let result = fingerprints_for_host("git.example.com", &None);
440 assert!(result.is_err());
441 let err = result.unwrap_err();
442 assert!(err.to_string().contains("git.example.com"));
443 }
444
445 fn write_known_hosts(content: &str) -> (tempfile::TempDir, std::path::PathBuf) {
449 let dir = tempfile::tempdir().expect("tempdir");
450 let path = dir.path().join("known_hosts");
451 std::fs::write(&path, content).expect("write");
452 (dir, path)
453 }
454
455 #[test]
456 fn host_key_trust_embeds_well_known_fingerprints() {
457 let trust = host_key_trust("github.com", &None).expect("trust");
458 assert_eq!(trust.fingerprints.len(), 3);
459 assert!(trust.cert_authorities.is_empty());
460 assert!(trust.revoked.is_empty());
461 }
462
463 #[test]
464 fn host_key_trust_pattern_matches_cert_authority() {
465 let (_g, path) = write_known_hosts(
466 "@cert-authority *.example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILM+rvN+ot98qgEN796jTiQfZfG1KaT0PtFDJ/XFSqti ca\n",
467 );
468 let trust = host_key_trust("foo.example.com", &Some(path)).expect("trust");
469 assert_eq!(trust.cert_authorities.len(), 1);
470 assert_eq!(trust.cert_authorities[0].host_pattern, "*.example.com");
471 }
472
473 #[test]
474 fn host_key_trust_pattern_excludes_non_match() {
475 let (_g, path) = write_known_hosts(
476 "@cert-authority *.example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILM+rvN+ot98qgEN796jTiQfZfG1KaT0PtFDJ/XFSqti ca\n",
477 );
478 let trust = host_key_trust("other.org", &Some(path)).expect("trust");
479 assert!(trust.cert_authorities.is_empty());
480 }
481
482 #[test]
483 fn host_key_trust_revoked_pattern_matches() {
484 let (_g, path) = write_known_hosts(
485 "@revoked *.example.com SHA256:revokedfp\n\
486 @revoked unrelated.com SHA256:other\n",
487 );
488 let trust = host_key_trust("foo.example.com", &Some(path)).expect("trust");
489 assert_eq!(trust.revoked.len(), 1);
490 assert_eq!(trust.revoked[0].fingerprint, "SHA256:revokedfp");
491 }
492
493 #[test]
494 fn host_key_trust_combines_direct_and_embedded() {
495 let (_g, path) = write_known_hosts("github.com SHA256:extra-pin\n");
496 let trust = host_key_trust("github.com", &Some(path)).expect("trust");
497 assert_eq!(trust.fingerprints.len(), 4);
499 assert!(trust.fingerprints.contains(&"SHA256:extra-pin".to_owned()));
500 }
501
502 #[test]
503 fn host_key_trust_missing_file_returns_embedded_only() {
504 let trust = host_key_trust(
505 "github.com",
506 &Some(std::path::PathBuf::from("/this/path/does/not/exist")),
507 )
508 .expect("trust");
509 assert_eq!(trust.fingerprints.len(), 3);
510 assert!(trust.cert_authorities.is_empty());
511 assert!(trust.revoked.is_empty());
512 }
513
514 #[test]
515 fn host_key_trust_empty_for_unknown_host_no_file() {
516 let trust = host_key_trust("git.example.com", &None).expect("trust");
520 assert!(trust.fingerprints.is_empty());
521 assert!(trust.cert_authorities.is_empty());
522 assert!(trust.revoked.is_empty());
523 }
524}