/* * Copyright 2025 Bryson Steck * * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ use crate::common::{self, ExitCode, ReturnData}; use crate::config::{Config, ConfigFile}; use git2::build::RepoBuilder; use git2::string_array::StringArray; use git2::{CertificateCheckStatus, Cred, FetchOptions, PushOptions, RemoteCallbacks, Repository}; use git2::{Error, ErrorCode}; use hex; use sha2::{Digest, Sha256}; use std::env; use std::fs; use std::path::{Path, PathBuf, MAIN_SEPARATOR_STR}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::thread; use std::time; pub struct Refractr { pub docker: bool, pub pid: u32, pub strict: bool, pub unix: bool, pub verbose: u8, } struct OpenedRepository { repo: Repository, remotes: Vec, cfg: Config, ssh: bool, } impl Refractr { fn set_up_work_dir(&self, work_dir: PathBuf) -> Result { if let Err(e) = fs::create_dir_all(&work_dir) { return Err(format!( "could not create working directory: {}: {}", work_dir.to_string_lossy().to_string(), e )); } Ok(work_dir.to_string_lossy().to_string()) } fn set_up_ssh(&self, key_path: String, strict: bool) -> Result { let mut cb = RemoteCallbacks::new(); cb.credentials(move |_, _, _| Cred::ssh_key("git", None, Path::new(&key_path), None)); cb.certificate_check(move |cert, url| { let sha256 = hex::encode(cert.as_hostkey().unwrap().hash_sha256().unwrap().to_vec()); if strict { common::error( format!( "unknown host {} with sha256 host key {}, exiting", url, sha256 ), ExitCode::RemoteError, ); } else { common::warning(format!( "unknown host {} with sha256 host key {}, implicitly trusting", url, sha256 )); common::warning(format!( "to suppress this warning in the future, add this host to your known_hosts file" )); } Ok(CertificateCheckStatus::CertificateOk) }); Ok(cb) } fn get_refs(&self, branches: &Vec, tags: Option) -> Vec { let mut refs_branches = Vec::new(); for branch in branches { refs_branches.push(format!("refs/heads/{}", branch)); } if let Some(tags) = tags { for tag in &tags { refs_branches.push(format!("refs/tags/{}", tag.unwrap())) } } refs_branches } fn fast_forward(&self, repo: &Repository, branches: &Vec) -> Result<(), Error> { common::verbose(self.verbose, 2, format!("Pulling origin")); repo.find_remote("origin")?.fetch(&branches, None, None)?; for branch in branches { let refname = format!("refs/remotes/origin/{}", branch); let mut reference = repo.find_reference(&refname)?; reference.rename(format!("refs/heads/{}", branch).as_str(), true, "")?; } Ok(()) } fn fetch( &self, repo: &Repository, branches: &Vec, ssh: bool, ssh_key: &String, strict: bool, ) -> Result<(), Error> { let mut fo = FetchOptions::new(); if ssh { match self.set_up_ssh(ssh_key.clone(), strict.clone()) { Ok(cb) => { fo.remote_callbacks(cb); () }, Err(e) => common::error( format!("error setting up ssh: {}", e), ExitCode::ConfigError, ), }; } fo.download_tags(git2::AutotagOption::All); repo .find_remote("origin")? .fetch(&branches, Some(&mut fo), None)?; Ok(()) } fn set_up_refs(&self, repo: &Repository, branches: &Vec) -> Result<(), Error> { for branch in branches { let mut fetch_head = repo.find_reference(format!("refs/remotes/origin/{}", branch).as_str())?; fetch_head.rename(format!("refs/heads/{}", branch).as_str(), true, "")?; } Ok(()) } fn make_remotes<'a>( &self, repo: &'a Repository, cfg: &ConfigFile, ) -> Result, String> { // create remotes for each "to" repo let mut remote_list = Vec::new(); for to in &cfg.config.to { let mut hasher = Sha256::new(); hasher.update(to); let remote_id = format!("refractr-{}", &hex::encode(hasher.finalize())[..8]); common::verbose( self.verbose, 2, format!("Attempting to create remote {} for url {}", remote_id, to), ); match repo.remote(remote_id.as_str(), to) { Ok(_) => remote_list.push(remote_id), Err(e) => { if e.code() == ErrorCode::Exists { common::warning(format!("remote {} already exists, skipping", remote_id)); remote_list.push(remote_id) } else { return Err(format!("failed to create remote: {}", e)); } }, } } Ok(remote_list) } fn push_remotes( &self, cfg: &Config, repo: &Repository, remote_list: &Vec, ) -> Result<(), String> { for id in remote_list { let mut remote = repo.find_remote(&id).unwrap(); common::verbose( self.verbose, 1, format!("Pushing to remote: {}", remote.url().unwrap()), ); let mut po = PushOptions::new(); match self.set_up_ssh(cfg.git.ssh_identity_file.clone(), self.strict.clone()) { Ok(cb) => { po.remote_callbacks(cb); () }, Err(e) => common::error( format!("error setting up ssh: {}", e), ExitCode::ConfigError, ), }; let mut refs = Vec::new(); let mut refs_str = String::new(); let strings = self.get_refs( &cfg.branches, match cfg.push_tags { true => Some(repo.tag_names(None).unwrap()), false => None, }, ); for branch in &strings { refs.push(branch.as_str()); refs_str.push_str(format!("{} ", branch).as_str()); } common::verbose(self.verbose, 4, format!("ref list: {}", refs_str)); match remote.push::<&str>(&refs, Some(&mut po)) { Ok(_) => (), Err(e) => { if self.strict { return Err(format!( "failed to push to remote: {}: {}", remote.url().unwrap(), e )); } else { common::warning(format!( "failed to push to remote: {}: {}", remote.url().unwrap(), e )) } }, } } Ok(()) } fn looper(&self, repos: Vec) -> Result<(), ReturnData> { let mut current_ints = Vec::new(); let running = Arc::new(AtomicBool::new(true)); let r = running.clone(); let count = repos.len(); for i in 0..repos.len() { current_ints.push(i64::from( repos[i].cfg.schedule.interval.unwrap().unsigned_abs(), )); } let original_ints = current_ints.clone(); ctrlc::set_handler(move || { r.store(false, Ordering::SeqCst); }) .expect("Failed to set ^C handler"); common::verbose(self.verbose, 1, format!("Starting scheduled loop")); let mut do_break = false; while !do_break { do_break = true; let min = *current_ints.iter().min().unwrap(); let sleep_int = time::Duration::from_secs(min as u64); let now = time::Instant::now(); common::verbose( self.verbose, 2, format!("Sleeping for {} seconds", sleep_int.as_secs()), ); while running.load(Ordering::SeqCst) { thread::sleep(time::Duration::from_millis(200)); if now.elapsed().as_secs() >= sleep_int.as_secs() { common::verbose(self.verbose, 3, format!("Thread has awoken!")); for i in 0..count { current_ints[i] -= now.elapsed().as_secs() as i64; common::verbose( self.verbose, 4, format!("checking repo: {}", repos[i].cfg.from), ); if current_ints[i] <= 0 { common::verbose(self.verbose, 4, format!("repo is ready for push")); common::verbose( self.verbose, 2, format!("Interval for {} has arrived, pulling", repos[i].cfg.from), ); if let Err(e) = self.fetch( &repos[i].repo, &repos[i].cfg.branches, repos[i].ssh, &repos[i].cfg.git.ssh_identity_file, self.strict.clone(), ) { common::error( format!("failed to fetch repo {}: {}", repos[i].cfg.from, e), ExitCode::FetchError, ); } let _ = self.fast_forward(&repos[i].repo, &repos[i].cfg.branches); if let Err(e) = self.push_remotes(&repos[i].cfg, &repos[i].repo, &repos[i].remotes) { common::error(e, ExitCode::PushError) }; current_ints[i] = original_ints[i].clone(); } common::verbose( self.verbose, 4, format!("repo remaining time is now {}", current_ints[i]), ); } do_break = false; break; } } } common::verbose(self.verbose, 1, format!("Exited looper due to ^C")); Ok(()) } pub fn run(&self, cfgs: Vec) -> Result<(), ReturnData> { common::verbose(self.verbose, 3, format!("Starting main refractr loop")); let mut loop_repos = Vec::new(); for cfg in cfgs { // set up the working directory common::verbose(self.verbose, 3, format!("Loading config: {}", cfg.path)); let work_dir = self.set_up_work_dir(match &cfg.config.work_dir { None => { if cfg!(windows) { PathBuf::from(format!("{}\\refractr", env::var("TEMP").unwrap())) } else { PathBuf::from("/tmp/refractr") } }, Some(path) => PathBuf::from(path), }); let path_str = match work_dir { Ok(p) => p, Err(e) => { return Err(ReturnData { code: ExitCode::FilesystemError, msg: e, }) }, }; common::verbose( self.verbose, 2, format!("Created working directory: {}", &path_str), ); let repo_name = match &cfg.config.from.split("/").last() { Some(split) => split.to_string(), None => { return Err(ReturnData { code: ExitCode::ParseError, msg: format!("failed to parse repository name"), }) }, }; let ssh = cfg.config.from.starts_with("ssh://"); let mut builder = RepoBuilder::new(); let mut fo = FetchOptions::new(); // make initial clone if ssh { match self.set_up_ssh( cfg.config.git.ssh_identity_file.clone(), self.strict.clone(), ) { Ok(cb) => { fo.remote_callbacks(cb); () }, Err(e) => common::error( format!("error setting up ssh: {}", e), ExitCode::ConfigError, ), }; } fo.download_tags(git2::AutotagOption::All); builder.fetch_options(fo); let repo_dir = format!("{}{}{}", &path_str, MAIN_SEPARATOR_STR, repo_name); common::verbose( self.verbose, 1, format!("Cloning repository {} to {}", &cfg.config.from, &repo_dir), ); let repo = match builder.clone(&cfg.config.from, Path::new(&repo_dir)) { Ok(repo) => repo, Err(e) => { if e.code() != ErrorCode::Exists { common::error( format!("failed to clone repo to {}: {}", repo_dir, e), ExitCode::FilesystemError, ); } common::warning(format!( "found existing repo at {}, attempting to use", repo_dir )); match Repository::open(Path::new(&repo_dir)) { Ok(r) => match self.fast_forward(&r, &cfg.config.branches) { Ok(_) => r, Err(e) => { return Err(ReturnData { code: ExitCode::RepositoryError, msg: format!("failed to fast forward existing repo: {}", e), }); }, }, Err(e) => { return Err(ReturnData { code: ExitCode::RepositoryError, msg: format!("failed to obtain existing repo: {}", e), }) }, } }, }; if let Err(e) = self.set_up_refs(&repo, &cfg.config.branches) { common::error( format!("failed to set up refs: {}", e), ExitCode::RepositoryError, ); } if let Err(e) = self.fetch( &repo, &cfg.config.branches, ssh, &cfg.config.git.ssh_identity_file, self.strict.clone(), ) { common::error( format!("failed to fetch repo {}: {}", cfg.config.from, e), ExitCode::FetchError, ); } let remotes = match self.make_remotes(&repo, &cfg) { Ok(v) => v, Err(e) => { return Err(ReturnData { code: ExitCode::RemoteError, msg: e, }) }, }; if let Err(e) = self.push_remotes(&cfg.config, &repo, &remotes) { common::error(e, ExitCode::PushError); } if cfg.config.schedule.enabled { loop_repos.push(OpenedRepository { repo, remotes, cfg: cfg.config, ssh, }); } } if loop_repos.len() >= 1 { common::verbose( self.verbose, 2, format!( "{} configs have schedules enabled, setting up looper", loop_repos.len() ), ); return self.looper(loop_repos); } else { common::verbose( self.verbose, 2, format!("No scheduled configs found, exiting refractr"), ); } Ok(()) } }