1use std::io::{Cursor, Read, Write};
42use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
43use std::path::{Component, Path, PathBuf};
44use std::process::{Command, Stdio};
45use std::time::{Duration, Instant};
46
47use url::{Host, Url};
48
49use crate::error::ApiError;
50
51pub const MAX_UPLOAD_BYTES: usize = 64 * 1024 * 1024;
53
54pub const MAX_TREE_BYTES: u64 = 512 * 1024 * 1024;
58
59pub const MAX_ARCHIVE_ENTRIES: usize = 100_000;
62
63pub const CLONE_TIMEOUT_SECS: u64 = 120;
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75pub struct IngestCaps {
76 pub max_upload_bytes: usize,
78 pub max_tree_bytes: u64,
80 pub max_archive_entries: usize,
82 pub clone_timeout: Duration,
84}
85
86impl IngestCaps {
87 pub const HOSTED: Self = IngestCaps {
89 max_upload_bytes: MAX_UPLOAD_BYTES,
90 max_tree_bytes: MAX_TREE_BYTES,
91 max_archive_entries: MAX_ARCHIVE_ENTRIES,
92 clone_timeout: Duration::from_secs(CLONE_TIMEOUT_SECS),
93 };
94
95 pub const UNLIMITED: Self = IngestCaps {
99 max_upload_bytes: usize::MAX,
100 max_tree_bytes: u64::MAX,
101 max_archive_entries: usize::MAX,
102 clone_timeout: Duration::MAX,
103 };
104}
105
106pub(crate) fn parse_repo_url(raw: &str) -> Result<Url, ApiError> {
119 let url = Url::parse(raw.trim())
120 .map_err(|_| ApiError::invalid_repo_url("not a valid URL"))?;
121 if url.scheme() != "https" {
122 return Err(ApiError::invalid_repo_url(
123 "only https:// URLs are accepted",
124 ));
125 }
126 if !url.username().is_empty() || url.password().is_some() {
127 return Err(ApiError::invalid_repo_url(
128 "URLs with embedded credentials are not accepted",
129 ));
130 }
131 if url.host().is_none() {
132 return Err(ApiError::invalid_repo_url("the URL has no host"));
133 }
134 if let Some(port) = url.port() {
135 if port != 443 {
136 return Err(ApiError::invalid_repo_url(
137 "only port 443 (the HTTPS default) is accepted",
138 ));
139 }
140 }
141 Ok(url)
142}
143
144#[derive(Debug, Clone, PartialEq, Eq)]
148pub struct ResolvePin {
149 pub host: String,
151 pub address: IpAddr,
153}
154
155#[derive(Debug, Clone)]
160pub struct ValidatedRepoUrl {
161 url: Url,
162 pins: Vec<ResolvePin>,
163}
164
165impl ValidatedRepoUrl {
166 pub fn url(&self) -> &Url {
168 &self.url
169 }
170
171 pub fn as_str(&self) -> &str {
173 self.url.as_str()
174 }
175
176 pub fn pins(&self) -> &[ResolvePin] {
181 &self.pins
182 }
183
184 pub fn package_name(&self) -> String {
186 package_name_for_url(&self.url)
187 }
188}
189
190pub async fn validate_repo_url(raw: &str) -> Result<ValidatedRepoUrl, ApiError> {
214 let url = parse_repo_url(raw)?;
215 let pins = match url.host() {
216 Some(Host::Ipv4(ip)) => {
217 require_public(IpAddr::V4(ip), url.host_str().unwrap_or(""))?;
218 Vec::new()
219 }
220 Some(Host::Ipv6(ip)) => {
221 require_public(IpAddr::V6(ip), url.host_str().unwrap_or(""))?;
222 Vec::new()
223 }
224 Some(Host::Domain(domain)) => {
225 let domain = domain.strip_suffix('.').unwrap_or(domain);
226 if domain.eq_ignore_ascii_case("localhost") {
227 return Err(non_public_host(domain));
228 }
229 let addresses = tokio::net::lookup_host((domain, 443))
230 .await
231 .map_err(|_| ApiError::invalid_repo_url("the host does not resolve"))?;
232 let url_host = url.host_str().unwrap_or(domain).to_string();
237 let mut pins: Vec<ResolvePin> = Vec::new();
238 for address in addresses {
239 let ip = address.ip();
240 require_public(ip, domain)?;
241 if !pins.iter().any(|pin| pin.address == ip) {
242 pins.push(ResolvePin {
243 host: url_host.clone(),
244 address: ip,
245 });
246 }
247 }
248 if pins.is_empty() {
249 return Err(ApiError::invalid_repo_url("the host does not resolve"));
250 }
251 pins
252 }
253 None => return Err(ApiError::invalid_repo_url("the URL has no host")),
254 };
255 Ok(ValidatedRepoUrl { url, pins })
256}
257
258fn require_public(ip: IpAddr, host: &str) -> Result<(), ApiError> {
259 if is_public_ip(ip) {
260 Ok(())
261 } else {
262 Err(non_public_host(host))
263 }
264}
265
266fn non_public_host(host: &str) -> ApiError {
267 ApiError::invalid_repo_url(&format!(
268 "host '{host}' resolves to a non-public address; only public repositories are ingested"
269 ))
270}
271
272pub(crate) fn is_public_ip(ip: IpAddr) -> bool {
277 match ip {
278 IpAddr::V4(ip) => is_public_ipv4(ip),
279 IpAddr::V6(ip) => is_public_ipv6(ip),
280 }
281}
282
283fn is_public_ipv4(ip: Ipv4Addr) -> bool {
284 let octets = ip.octets();
285 !(ip.is_loopback()
286 || ip.is_private()
287 || ip.is_link_local()
288 || ip.is_multicast()
289 || ip.is_unspecified()
290 || ip.is_broadcast()
291 || ip.is_documentation()
292 || octets[0] == 0 || (octets[0] == 100 && (octets[1] & 0xC0) == 0x40) || (octets[0] == 192 && octets[1] == 0 && octets[2] == 0) || (octets[0] == 198 && (octets[1] & 0xFE) == 18) || octets[0] >= 240) }
298
299fn is_public_ipv6(ip: Ipv6Addr) -> bool {
300 if let Some(mapped) = ip.to_ipv4_mapped() {
303 return is_public_ipv4(mapped);
304 }
305 let segments = ip.segments();
306 !(ip.is_loopback()
307 || ip.is_multicast()
308 || ip.is_unspecified()
309 || (segments[0] & 0xFE00) == 0xFC00 || (segments[0] & 0xFFC0) == 0xFE80 || (segments[0] == 0x2001 && segments[1] == 0x0DB8)) }
313
314pub(crate) fn package_name_for_url(url: &Url) -> String {
317 let segment = url
318 .path_segments()
319 .and_then(|mut segments| segments.rfind(|s| !s.is_empty()))
320 .unwrap_or("");
321 let name = segment.strip_suffix(".git").unwrap_or(segment);
322 if name.is_empty() {
323 "repo".to_string()
324 } else {
325 name.to_string()
326 }
327}
328
329pub fn clone_into(
334 validated: &ValidatedRepoUrl,
335 workdir: &Path,
336 caps: IngestCaps,
337) -> Result<(), ApiError> {
338 let result = clone_into_inner(validated, workdir, caps);
339 if result.is_err() {
340 let _ = std::fs::remove_dir_all(workdir);
341 }
342 result
343}
344
345fn clone_into_inner(
346 validated: &ValidatedRepoUrl,
347 workdir: &Path,
348 caps: IngestCaps,
349) -> Result<(), ApiError> {
350 std::fs::create_dir_all(workdir).map_err(|err| {
351 ApiError::internal(format!(
352 "cannot create ingest workdir {}: {err}",
353 workdir.display()
354 ))
355 })?;
356 run_git_clone(
357 validated.as_str(),
358 workdir,
359 caps.clone_timeout,
360 validated.pins(),
361 )?;
362 let bytes = tree_bytes(workdir, caps.max_tree_bytes);
363 if bytes > caps.max_tree_bytes {
364 return Err(ApiError::invalid_repo_url(&format!(
365 "the cloned tree is {bytes} bytes, past the {}-byte ingest limit",
366 caps.max_tree_bytes
367 )));
368 }
369 Ok(())
370}
371
372fn clone_args(url: &str, dest: &str, pins: &[ResolvePin]) -> Vec<String> {
378 let mut args: Vec<String> = vec![
379 "-c".to_string(),
380 "http.followRedirects=false".to_string(),
381 "-c".to_string(),
382 "protocol.ext.allow=never".to_string(),
383 ];
384 for pin in pins {
385 args.push("-c".to_string());
386 args.push(format!(
387 "http.curloptResolve={}:443:{}",
388 pin.host, pin.address
389 ));
390 }
391 for arg in [
392 "clone",
393 "--depth",
394 "1",
395 "--single-branch",
396 "--no-recurse-submodules",
397 "--",
398 url,
399 dest,
400 ] {
401 args.push(arg.to_string());
402 }
403 args
404}
405
406pub(crate) fn run_git_clone(
421 url: &str,
422 dest: &Path,
423 timeout: Duration,
424 pins: &[ResolvePin],
425) -> Result<(), ApiError> {
426 let dest_arg = dest
427 .to_str()
428 .ok_or_else(|| ApiError::internal("ingest workdir path is not valid UTF-8"))?;
429 let mut child = Command::new("git")
430 .args(clone_args(url, dest_arg, pins))
431 .env("GIT_TERMINAL_PROMPT", "0")
432 .env("GIT_LFS_SKIP_SMUDGE", "1")
433 .stdin(Stdio::null())
434 .stdout(Stdio::null())
435 .stderr(Stdio::piped())
436 .spawn()
437 .map_err(|err| ApiError::internal(format!("cannot run git: {err}")))?;
438
439 let stderr_pipe = child.stderr.take();
443 let drain = std::thread::spawn(move || {
444 let mut buf = Vec::new();
445 if let Some(mut pipe) = stderr_pipe {
446 let _ = pipe.read_to_end(&mut buf);
447 }
448 buf
449 });
450
451 let started = Instant::now();
452 let status = loop {
453 match child.try_wait() {
454 Ok(Some(status)) => break status,
455 Ok(None) => {
456 if started.elapsed() >= timeout {
457 let _ = child.kill();
458 let _ = child.wait();
459 let _ = drain.join();
460 return Err(ApiError::invalid_repo_url(&format!(
461 "git clone did not finish within {}s",
462 timeout.as_secs()
463 )));
464 }
465 std::thread::sleep(Duration::from_millis(50));
466 }
467 Err(err) => {
468 let _ = drain.join();
469 return Err(ApiError::internal(format!("cannot wait on git clone: {err}")));
470 }
471 }
472 };
473 let stderr = drain.join().unwrap_or_default();
474 if status.success() {
475 return Ok(());
476 }
477 let text = String::from_utf8_lossy(&stderr);
478 let last_line = text
479 .lines()
480 .rev()
481 .find(|line| !line.trim().is_empty())
482 .unwrap_or("no error output");
483 let last_line: String = last_line.chars().take(300).collect();
484 Err(ApiError::invalid_repo_url(&format!(
485 "git clone failed: {last_line}"
486 )))
487}
488
489fn skipped_dir_name(name: &str) -> bool {
497 matches!(name, ".git" | "target" | "node_modules" | ".corpus") || name.starts_with('.')
498}
499
500pub fn count_tree_files(root: &Path, stop_after: u64) -> u64 {
508 let mut count: u64 = 0;
509 let mut stack = vec![root.to_path_buf()];
510 while let Some(dir) = stack.pop() {
511 let Ok(entries) = std::fs::read_dir(&dir) else {
512 continue;
513 };
514 for entry in entries.flatten() {
515 let Ok(file_type) = entry.file_type() else {
516 continue;
517 };
518 if file_type.is_dir() {
519 if !skipped_dir_name(&entry.file_name().to_string_lossy()) {
520 stack.push(entry.path());
521 }
522 } else if file_type.is_file() {
523 count += 1;
524 if count >= stop_after {
525 return count;
526 }
527 }
528 }
529 }
530 count
531}
532
533fn tree_bytes(root: &Path, cap: u64) -> u64 {
537 let mut total: u64 = 0;
538 let mut stack = vec![root.to_path_buf()];
539 while let Some(dir) = stack.pop() {
540 let Ok(entries) = std::fs::read_dir(&dir) else {
541 continue;
542 };
543 for entry in entries.flatten() {
544 let Ok(file_type) = entry.file_type() else {
545 continue;
546 };
547 if file_type.is_dir() {
548 stack.push(entry.path());
549 } else if file_type.is_file() {
550 total += entry.metadata().map(|meta| meta.len()).unwrap_or(0);
551 if total > cap {
552 return total;
553 }
554 }
555 }
556 }
557 total
558}
559
560#[derive(Debug, Clone, Copy, PartialEq, Eq)]
566pub enum ArchiveKind {
567 Zip,
569 TarGz,
571}
572
573pub fn archive_kind(content_type: Option<&str>, bytes: &[u8]) -> Option<ArchiveKind> {
578 match content_type {
579 Some("application/zip") | Some("application/x-zip-compressed") => Some(ArchiveKind::Zip),
580 Some("application/gzip") | Some("application/x-gzip") => Some(ArchiveKind::TarGz),
581 _ => {
582 if bytes.starts_with(b"PK\x03\x04") || bytes.starts_with(b"PK\x05\x06") {
583 Some(ArchiveKind::Zip)
584 } else if bytes.starts_with(b"\x1f\x8b") {
585 Some(ArchiveKind::TarGz)
586 } else {
587 None
588 }
589 }
590 }
591}
592
593pub fn extract_into(
602 kind: ArchiveKind,
603 bytes: &[u8],
604 workdir: &Path,
605 caps: IngestCaps,
606) -> Result<PathBuf, ApiError> {
607 let result = extract_into_inner(kind, bytes, workdir, caps);
608 if result.is_err() {
609 let _ = std::fs::remove_dir_all(workdir);
610 }
611 result
612}
613
614fn extract_into_inner(
615 kind: ArchiveKind,
616 bytes: &[u8],
617 workdir: &Path,
618 caps: IngestCaps,
619) -> Result<PathBuf, ApiError> {
620 std::fs::create_dir_all(workdir).map_err(|err| {
621 ApiError::internal(format!(
622 "cannot create ingest workdir {}: {err}",
623 workdir.display()
624 ))
625 })?;
626 match kind {
627 ArchiveKind::Zip => extract_zip(bytes, workdir, caps.max_archive_entries, caps.max_tree_bytes),
628 ArchiveKind::TarGz => {
629 extract_tar_gz(bytes, workdir, caps.max_archive_entries, caps.max_tree_bytes)
630 }
631 }?;
632 Ok(index_root(workdir))
633}
634
635fn index_root(workdir: &Path) -> PathBuf {
637 let Ok(entries) = std::fs::read_dir(workdir) else {
638 return workdir.to_path_buf();
639 };
640 let entries: Vec<PathBuf> = entries.flatten().map(|entry| entry.path()).collect();
641 if entries.len() == 1 && entries[0].is_dir() {
642 return entries.into_iter().next().expect("one entry");
643 }
644 workdir.to_path_buf()
645}
646
647fn enclosed_relative(path: &Path) -> Option<PathBuf> {
652 let mut out = PathBuf::new();
653 for component in path.components() {
654 match component {
655 Component::Normal(part) => out.push(part),
656 Component::CurDir => {}
657 Component::ParentDir | Component::RootDir | Component::Prefix(_) => return None,
658 }
659 }
660 Some(out)
661}
662
663fn copy_capped(
669 reader: &mut impl Read,
670 file: &mut std::fs::File,
671 budget: u64,
672 limit: u64,
673) -> Result<u64, ApiError> {
674 let mut written: u64 = 0;
675 let mut buffer = [0u8; 64 * 1024];
676 loop {
677 let read = reader
678 .read(&mut buffer)
679 .map_err(|err| ApiError::invalid_body(&format!("cannot read archive entry: {err}")))?;
680 if read == 0 {
681 return Ok(written);
682 }
683 written += read as u64;
684 if written > budget {
685 return Err(ApiError::invalid_body(&format!(
686 "archive expands past the {limit}-byte ingest limit"
687 )));
688 }
689 file.write_all(&buffer[..read])
690 .map_err(|err| ApiError::internal(format!("cannot write extracted file: {err}")))?;
691 }
692}
693
694fn escape_error() -> ApiError {
695 ApiError::invalid_body("archive entry path escapes the extraction root")
696}
697
698fn extract_zip(
699 bytes: &[u8],
700 dest: &Path,
701 max_entries: usize,
702 max_bytes: u64,
703) -> Result<(), ApiError> {
704 let mut archive = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|err| {
705 ApiError::invalid_body(&format!("not a readable zip archive: {err}"))
706 })?;
707 if archive.len() > max_entries {
708 return Err(ApiError::invalid_body(&format!(
709 "archive carries {} entries, past the {max_entries}-entry ingest limit",
710 archive.len()
711 )));
712 }
713 let mut total: u64 = 0;
714 for i in 0..archive.len() {
715 let mut entry = archive
716 .by_index(i)
717 .map_err(|err| ApiError::invalid_body(&format!("cannot read archive entry: {err}")))?;
718 let enclosed = entry.enclosed_name().ok_or_else(escape_error)?;
722 let rel = enclosed_relative(&enclosed).ok_or_else(escape_error)?;
723 if rel.as_os_str().is_empty() {
724 continue;
725 }
726 let target = dest.join(&rel);
727 if entry.is_dir() {
728 std::fs::create_dir_all(&target).map_err(|err| {
729 ApiError::internal(format!("cannot create extracted directory: {err}"))
730 })?;
731 continue;
732 }
733 if entry.is_symlink() {
734 return Err(ApiError::invalid_body(
735 "archive contains a symbolic link, which ingest does not accept",
736 ));
737 }
738 if let Some(parent) = target.parent() {
739 std::fs::create_dir_all(parent).map_err(|err| {
740 ApiError::internal(format!("cannot create extracted directory: {err}"))
741 })?;
742 }
743 let mut file = std::fs::File::create(&target).map_err(|err| {
744 ApiError::internal(format!("cannot write extracted file: {err}"))
745 })?;
746 total += copy_capped(&mut entry, &mut file, max_bytes - total, max_bytes)?;
747 }
748 Ok(())
749}
750
751fn extract_tar_gz(
752 bytes: &[u8],
753 dest: &Path,
754 max_entries: usize,
755 max_bytes: u64,
756) -> Result<(), ApiError> {
757 let decoder = flate2::read::GzDecoder::new(Cursor::new(bytes));
758 let mut archive = tar::Archive::new(decoder);
759 let entries = archive
760 .entries()
761 .map_err(|err| ApiError::invalid_body(&format!("not a readable tar.gz archive: {err}")))?;
762 let mut total: u64 = 0;
763 let mut count: usize = 0;
764 for entry in entries {
765 let mut entry = entry
766 .map_err(|err| ApiError::invalid_body(&format!("cannot read archive entry: {err}")))?;
767 count += 1;
768 if count > max_entries {
769 return Err(ApiError::invalid_body(&format!(
770 "archive carries more than {max_entries} entries, the ingest limit"
771 )));
772 }
773 let entry_path = entry
774 .path()
775 .map_err(|err| ApiError::invalid_body(&format!("cannot read archive entry path: {err}")))?
776 .into_owned();
777 let rel = enclosed_relative(&entry_path).ok_or_else(escape_error)?;
778 if rel.as_os_str().is_empty() {
779 continue;
780 }
781 let target = dest.join(&rel);
782 match entry.header().entry_type() {
783 tar::EntryType::Directory => {
784 std::fs::create_dir_all(&target).map_err(|err| {
785 ApiError::internal(format!("cannot create extracted directory: {err}"))
786 })?;
787 }
788 tar::EntryType::Regular | tar::EntryType::Continuous => {
789 if let Some(parent) = target.parent() {
790 std::fs::create_dir_all(parent).map_err(|err| {
791 ApiError::internal(format!("cannot create extracted directory: {err}"))
792 })?;
793 }
794 let mut file = std::fs::File::create(&target).map_err(|err| {
795 ApiError::internal(format!("cannot write extracted file: {err}"))
796 })?;
797 total += copy_capped(&mut entry, &mut file, max_bytes - total, max_bytes)?;
798 }
799 _ => {
803 return Err(ApiError::invalid_body(
804 "archive contains a link or special entry, which ingest does not accept",
805 ));
806 }
807 }
808 }
809 Ok(())
810}
811
812#[cfg(test)]
813mod tests {
814 use super::*;
815
816 #[test]
817 fn public_ip_classes() {
818 for public in ["8.8.8.8", "1.1.1.1", "93.184.216.34", "2606:4700:4700::1111"] {
819 assert!(is_public_ip(public.parse().expect("ip")), "{public} is public");
820 }
821 for non_public in [
822 "127.0.0.1", "10.0.0.5", "172.16.0.9", "192.168.1.1", "169.254.1.1", "100.64.0.1", "0.0.0.0", "0.1.2.3", "224.0.0.1", "192.0.2.1", "198.18.0.1", "240.0.0.1", "255.255.255.255", "::1", "::", "fe80::1", "fc00::1", "fd12:3456::1", "ff02::1", "2001:db8::1", "::ffff:127.0.0.1", "::ffff:10.0.0.1", ] {
845 assert!(
846 !is_public_ip(non_public.parse().expect("ip")),
847 "{non_public} is not public"
848 );
849 }
850 assert!(
851 is_public_ip("::ffff:8.8.8.8".parse().expect("ip")),
852 "a v4-mapped public address stays public"
853 );
854 }
855
856 #[test]
857 fn parse_rejects_non_https_and_credentialed_urls() {
858 for (raw, why) in [
859 ("http://github.com/a/b", "plain http"),
860 ("ssh://git@github.com/a/b", "ssh scheme"),
861 ("git://github.com/a/b", "git scheme"),
862 ("file:///srv/repo", "file scheme"),
863 ("ext::sh -c id", "ext transport"),
864 ("https://user:secret@github.com/a/b", "embedded credentials"),
865 ("https://user@github.com/a/b", "embedded user"),
866 ("https://github.com:8443/a/b", "non-443 port"),
867 ("not a url", "unparseable"),
868 ("https://", "no host"),
869 ] {
870 let err = parse_repo_url(raw).expect_err(why);
871 assert_eq!(err.code(), "invalid_repo_url", "{raw}: {why}");
872 }
873 }
874
875 #[test]
876 fn parse_accepts_plain_https_urls() {
877 for raw in [
878 "https://github.com/nrupala/codetopo",
879 "https://github.com/nrupala/codetopo.git",
880 "https://github.com:443/nrupala/codetopo",
881 "https://git.example.com:443/group/repo.git",
882 ] {
883 assert!(parse_repo_url(raw).is_ok(), "{raw}");
884 }
885 }
886
887 #[tokio::test]
890 async fn validate_refuses_non_public_literal_hosts() {
891 for raw in [
892 "https://127.0.0.1/repo.git",
893 "https://10.1.2.3/repo.git",
894 "https://[::1]/repo.git",
895 "https://localhost/repo.git",
896 "https://localhost./repo.git",
897 ] {
898 let err = validate_repo_url(raw)
899 .await
900 .expect_err("non-public host refused");
901 assert_eq!(err.code(), "invalid_repo_url", "{raw}");
902 }
903 }
904
905 #[test]
906 fn package_name_comes_from_the_url_tail() {
907 let cases = [
908 ("https://github.com/nrupala/codetopo", "codetopo"),
909 ("https://github.com/nrupala/codetopo.git", "codetopo"),
910 ("https://github.com/nrupala/codetopo/", "codetopo"),
911 ("https://example.com/", "repo"),
912 ("https://example.com/.git", "repo"),
913 ];
914 for (raw, expected) in cases {
915 let url = parse_repo_url(raw).expect("url parses");
916 assert_eq!(package_name_for_url(&url), expected, "{raw}");
917 }
918 }
919
920 #[test]
921 fn archive_kind_reads_type_then_magic() {
922 assert_eq!(
923 archive_kind(Some("application/zip"), b"anything"),
924 Some(ArchiveKind::Zip)
925 );
926 assert_eq!(
927 archive_kind(Some("application/gzip"), b"anything"),
928 Some(ArchiveKind::TarGz)
929 );
930 assert_eq!(
931 archive_kind(Some("application/octet-stream"), b"PK\x03\x04rest"),
932 Some(ArchiveKind::Zip)
933 );
934 assert_eq!(
935 archive_kind(None, b"\x1f\x8brest"),
936 Some(ArchiveKind::TarGz)
937 );
938 assert_eq!(archive_kind(None, b"{\"json\":true}"), None);
939 assert_eq!(archive_kind(Some("text/plain"), b"hello"), None);
940 }
941
942 #[test]
943 fn clone_args_pin_each_validated_address() {
944 let pins = vec![
945 ResolvePin {
946 host: "github.com".to_string(),
947 address: "140.82.121.3".parse().expect("ip"),
948 },
949 ResolvePin {
950 host: "github.com".to_string(),
951 address: "2606:50c0:8000::153".parse().expect("ip"),
952 },
953 ];
954 let args = clone_args("https://github.com/a/b", "/tmp/dest", &pins);
955 let resolve: Vec<&str> = args
956 .iter()
957 .map(String::as_str)
958 .filter(|arg| arg.starts_with("http.curloptResolve="))
959 .collect();
960 assert_eq!(
961 resolve,
962 [
963 "http.curloptResolve=github.com:443:140.82.121.3",
964 "http.curloptResolve=github.com:443:2606:50c0:8000::153",
965 ],
966 "one pin per validated address, host:443:address"
967 );
968 let dashdash = args.iter().position(|arg| arg == "--").expect("-- present");
971 assert_eq!(args[dashdash + 1], "https://github.com/a/b");
972
973 let bare = clone_args("git://127.0.0.1:9418/served", "/tmp/dest", &[]);
974 assert!(
975 !bare.iter().any(|arg| arg.contains("curloptResolve")),
976 "no pins, no resolve config"
977 );
978 }
979
980 #[tokio::test]
986 async fn validated_literal_hosts_carry_no_pins() {
987 let err = validate_repo_url("https://127.0.0.1/repo.git")
988 .await
989 .expect_err("loopback literal refused before pins matter");
990 assert_eq!(err.code(), "invalid_repo_url");
991 }
992
993 #[test]
994 fn count_tree_files_counts_what_the_indexer_would_read() {
995 let dir = tempfile::tempdir().expect("temp dir");
996 let root = dir.path();
997 let write = |rel: &str| {
998 let path = root.join(rel);
999 std::fs::create_dir_all(path.parent().expect("parent")).expect("dirs");
1000 std::fs::write(path, b"x").expect("write");
1001 };
1002 write("src/lib.rs");
1003 write("src/main.rs");
1004 write("README.md");
1005 write(".git/objects/pack/pack-1.idx");
1006 write("node_modules/dep/index.js");
1007 write("target/debug/build/output");
1008 write(".hidden/secret.rs");
1009 write(".github/workflows/ci.yml");
1010
1011 assert_eq!(
1012 count_tree_files(root, u64::MAX),
1013 3,
1014 "only the three files outside the skipped directories count"
1015 );
1016 assert_eq!(
1017 count_tree_files(root, 2),
1018 2,
1019 "the walk stops at stop_after instead of counting a refused tree exactly"
1020 );
1021 assert_eq!(count_tree_files(root, 1), 1);
1022 }
1023
1024 fn zip_bytes(entries: &[(&str, &[u8])]) -> Vec<u8> {
1027 let mut writer = zip::ZipWriter::new(Cursor::new(Vec::new()));
1028 let options = zip::write::SimpleFileOptions::default()
1029 .compression_method(zip::CompressionMethod::Stored);
1030 for (name, contents) in entries {
1031 writer.start_file(*name, options).expect("start file");
1032 writer.write_all(contents).expect("write file");
1033 }
1034 writer.finish().expect("finish zip").into_inner()
1035 }
1036
1037 fn tar_gz_bytes(entries: &[(&str, &[u8])]) -> Vec<u8> {
1038 let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
1039 {
1040 let mut builder = tar::Builder::new(&mut encoder);
1041 for (name, contents) in entries {
1042 let mut header = tar::Header::new_gnu();
1043 header.set_size(contents.len() as u64);
1044 header.set_mode(0o644);
1045 header.set_cksum();
1046 builder
1047 .append_data(&mut header, name, *contents)
1048 .expect("append entry");
1049 }
1050 builder.finish().expect("finish tar");
1051 }
1052 encoder.finish().expect("finish gzip")
1053 }
1054
1055 fn extract(kind: ArchiveKind, bytes: &[u8]) -> (tempfile::TempDir, Result<PathBuf, ApiError>) {
1056 let dir = tempfile::tempdir().expect("temp dir");
1057 let workdir = dir.path().join("work");
1058 let result = extract_into(kind, bytes, &workdir, IngestCaps::HOSTED);
1059 (dir, result)
1060 }
1061
1062 #[test]
1063 fn zip_extracts_and_unwraps_a_single_top_directory() {
1064 let bytes = zip_bytes(&[
1065 ("repo-main/src/lib.rs", b"pub fn f() {}"),
1066 ("repo-main/README.md", b"# repo"),
1067 ]);
1068 let (_dir, result) = extract(ArchiveKind::Zip, &bytes);
1069 let root = result.expect("extract");
1070 assert_eq!(root.file_name().expect("name"), "repo-main");
1071 assert!(root.join("src/lib.rs").is_file());
1072 assert!(root.join("README.md").is_file());
1073 }
1074
1075 #[test]
1076 fn tar_gz_extracts_files_at_the_root() {
1077 let bytes = tar_gz_bytes(&[("src/main.rs", b"fn main() {}"), ("Cargo.toml", b"[package]")]);
1078 let (dir, result) = extract(ArchiveKind::TarGz, &bytes);
1079 let root = result.expect("extract");
1080 assert_eq!(root, dir.path().join("work"), "no unwrap without a single top dir");
1081 assert!(root.join("src/main.rs").is_file());
1082 }
1083
1084 #[test]
1085 fn zip_traversal_is_refused_and_leaves_nothing() {
1086 let bytes = zip_bytes(&[("../evil.rs", b"pub fn evil() {}")]);
1087 let (dir, result) = extract(ArchiveKind::Zip, &bytes);
1088 let err = result.expect_err("traversal refused");
1089 assert_eq!(err.code(), "invalid_body");
1090 assert!(
1091 !dir.path().join("work").exists(),
1092 "a failed ingest removes its workdir"
1093 );
1094 assert!(
1095 !dir.path().join("evil.rs").exists(),
1096 "nothing was written outside the workdir"
1097 );
1098 }
1099
1100 fn raw_tar_gz_bytes(name: &str, contents: &[u8]) -> Vec<u8> {
1106 let mut header = [0u8; 512];
1107 header[..name.len()].copy_from_slice(name.as_bytes());
1108 header[100..108].copy_from_slice(b"0000644\0");
1109 header[108..116].copy_from_slice(b"0000000\0");
1110 header[116..124].copy_from_slice(b"0000000\0");
1111 header[124..136].copy_from_slice(format!("{:011o}\0", contents.len()).as_bytes());
1112 header[136..148].copy_from_slice(b"00000000000\0");
1113 header[156] = b'0';
1114 header[257..263].copy_from_slice(b"ustar\0");
1115 header[263..265].copy_from_slice(b"00");
1116 for byte in &mut header[148..156] {
1117 *byte = b' ';
1118 }
1119 let checksum: u32 = header.iter().map(|byte| *byte as u32).sum();
1120 header[148..156].copy_from_slice(format!("{checksum:06o}\0 ").as_bytes());
1121
1122 let mut tar = Vec::new();
1123 tar.extend_from_slice(&header);
1124 tar.extend_from_slice(contents);
1125 tar.resize(tar.len() + (512 - contents.len() % 512) % 512, 0);
1126 tar.resize(tar.len() + 1024, 0);
1127
1128 let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
1129 encoder.write_all(&tar).expect("gzip write");
1130 encoder.finish().expect("finish gzip")
1131 }
1132
1133 #[test]
1134 fn tar_traversal_is_refused() {
1135 let bytes = raw_tar_gz_bytes("ok/../../evil.rs", b"x");
1136 let (dir, result) = extract(ArchiveKind::TarGz, &bytes);
1137 assert_eq!(result.expect_err("traversal refused").code(), "invalid_body");
1138 assert!(
1139 !dir.path().join("evil.rs").exists(),
1140 "nothing was written outside the workdir"
1141 );
1142 }
1143
1144 #[test]
1145 fn tar_symlink_is_refused() {
1146 let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
1147 {
1148 let mut builder = tar::Builder::new(&mut encoder);
1149 let mut header = tar::Header::new_gnu();
1150 header.set_size(0);
1151 header.set_entry_type(tar::EntryType::Symlink);
1152 header.set_cksum();
1153 builder
1154 .append_link(&mut header, "link", "/etc/passwd")
1155 .expect("append link");
1156 builder.finish().expect("finish tar");
1157 }
1158 let bytes = encoder.finish().expect("finish gzip");
1159 let (_dir, result) = extract(ArchiveKind::TarGz, &bytes);
1160 assert_eq!(result.expect_err("link refused").code(), "invalid_body");
1161 }
1162
1163 #[test]
1164 fn extraction_counts_actual_bytes_against_the_cap() {
1165 let bytes = zip_bytes(&[("a.rs", b"aaaa"), ("b.rs", b"bbbb")]);
1168 let dir = tempfile::tempdir().expect("temp dir");
1169 let dest = dir.path().join("work");
1170 std::fs::create_dir_all(&dest).expect("workdir");
1171 let err = extract_zip(&bytes, &dest, MAX_ARCHIVE_ENTRIES, 5).expect_err("cap enforced");
1172 assert_eq!(err.code(), "invalid_body");
1173 }
1174
1175 #[test]
1176 fn entry_count_cap_is_enforced() {
1177 let bytes = zip_bytes(&[("a.rs", b"a"), ("b.rs", b"b"), ("c.rs", b"c")]);
1178 let dir = tempfile::tempdir().expect("temp dir");
1179 let dest = dir.path().join("work");
1180 std::fs::create_dir_all(&dest).expect("workdir");
1181 let err = extract_zip(&bytes, &dest, 2, MAX_TREE_BYTES).expect_err("cap enforced");
1182 assert_eq!(err.code(), "invalid_body");
1183 }
1184
1185 #[test]
1186 fn garbage_is_not_an_archive() {
1187 let (_dir, result) = extract(ArchiveKind::Zip, b"definitely not a zip");
1188 assert_eq!(result.expect_err("refused").code(), "invalid_body");
1189 let (_dir, result) = extract(ArchiveKind::TarGz, b"definitely not a tarball");
1190 assert_eq!(result.expect_err("refused").code(), "invalid_body");
1191 }
1192
1193 struct DaemonRepo {
1201 _dir: tempfile::TempDir,
1202 url: String,
1203 child: std::process::Child,
1204 }
1205
1206 impl Drop for DaemonRepo {
1207 fn drop(&mut self) {
1208 let _ = self.child.kill();
1209 let _ = self.child.wait();
1210 }
1211 }
1212
1213 fn daemon_repo() -> DaemonRepo {
1214 let dir = tempfile::tempdir().expect("temp dir");
1215 let source = dir.path().join("served");
1216 std::fs::create_dir_all(source.join("src")).expect("source dirs");
1217 std::fs::write(source.join("src/lib.rs"), "pub fn served() {}\n").expect("write source");
1218 let run = |args: &[&str]| {
1219 let status = Command::new("git")
1220 .args(args)
1221 .current_dir(&source)
1222 .env("GIT_AUTHOR_NAME", "ingest test")
1223 .env("GIT_AUTHOR_EMAIL", "ingest@example.com")
1224 .env("GIT_COMMITTER_NAME", "ingest test")
1225 .env("GIT_COMMITTER_EMAIL", "ingest@example.com")
1226 .status()
1227 .expect("git runs");
1228 assert!(status.success(), "git {args:?} failed");
1229 };
1230 run(&["init", "--quiet", "."]);
1231 run(&["add", "."]);
1232 run(&["commit", "--quiet", "-m", "fixture"]);
1233
1234 let port = std::net::TcpListener::bind("127.0.0.1:0")
1238 .expect("bind")
1239 .local_addr()
1240 .expect("addr")
1241 .port();
1242 let child = Command::new("git")
1243 .args([
1244 "daemon",
1245 "--export-all",
1246 "--reuseaddr",
1247 &format!("--base-path={}", dir.path().display()),
1248 &format!("--port={port}"),
1249 "--listen=127.0.0.1",
1250 ])
1251 .stdin(Stdio::null())
1252 .stdout(Stdio::null())
1253 .stderr(Stdio::null())
1254 .spawn()
1255 .expect("git daemon spawns");
1256 let url = format!("git://127.0.0.1:{port}/served");
1257 let probe = dir.path().join("probe");
1260 let mut ready = false;
1261 for _ in 0..50 {
1262 if run_git_clone(&url, &probe, Duration::from_secs(5), &[]).is_ok() {
1263 let _ = std::fs::remove_dir_all(&probe);
1264 ready = true;
1265 break;
1266 }
1267 let _ = std::fs::remove_dir_all(&probe);
1268 std::thread::sleep(Duration::from_millis(100));
1269 }
1270 assert!(ready, "git daemon never answered a probe clone");
1271 DaemonRepo {
1272 _dir: dir,
1273 url,
1274 child,
1275 }
1276 }
1277
1278 #[test]
1279 fn clone_plumbing_lands_the_tree() {
1280 let daemon = daemon_repo();
1281 let dir = tempfile::tempdir().expect("temp dir");
1282 let dest = dir.path().join("clone");
1283 run_git_clone(&daemon.url, &dest, Duration::from_secs(30), &[]).expect("clone succeeds");
1284 assert!(dest.join("src/lib.rs").is_file(), "the tree landed");
1285 let bytes = tree_bytes(&dest, MAX_TREE_BYTES);
1286 assert!(bytes > 0 && bytes < MAX_TREE_BYTES);
1287 }
1288
1289 #[test]
1290 fn clone_of_a_missing_repo_is_an_error_not_a_hang() {
1291 let daemon = daemon_repo();
1292 let dir = tempfile::tempdir().expect("temp dir");
1293 let dest = dir.path().join("clone");
1294 let err = run_git_clone(
1295 &daemon.url.replace("/served", "/absent"),
1296 &dest,
1297 Duration::from_secs(10),
1298 &[],
1299 )
1300 .expect_err("missing repo refused");
1301 assert_eq!(err.code(), "invalid_repo_url");
1302 }
1303
1304 #[test]
1305 fn clone_timeout_kills_the_child() {
1306 let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
1309 let port = listener.local_addr().expect("addr").port();
1310 let held = std::thread::spawn(move || {
1311 let _held_streams: Vec<std::net::TcpStream> = listener
1312 .incoming()
1313 .filter_map(Result::ok)
1314 .take(1)
1315 .collect();
1316 std::thread::sleep(Duration::from_secs(10));
1317 });
1318 let dir = tempfile::tempdir().expect("temp dir");
1319 let dest = dir.path().join("clone");
1320 let started = Instant::now();
1321 let err = run_git_clone(
1322 &format!("git://127.0.0.1:{port}/served"),
1323 &dest,
1324 Duration::from_secs(1),
1325 &[],
1326 )
1327 .expect_err("a silent peer times out");
1328 assert_eq!(err.code(), "invalid_repo_url");
1329 assert!(
1330 started.elapsed() < Duration::from_secs(8),
1331 "the clone was killed, not waited out: {:?}",
1332 started.elapsed()
1333 );
1334 drop(held);
1335 }
1336}