use std::fs::{self, OpenOptions};
use std::io::{self, Write};
use std::os::unix::fs::OpenOptionsExt;
use std::path::PathBuf;
use std::process::Command;
use digest::Digest;
use git_workarea::{GitContext, GitError};
use itertools::Itertools;
use log::{error, info, warn};
use md5::Md5;
use sha2::{Sha256, Sha512};
use tempfile::TempDir;
use thiserror::Error;
use crate::host::Repo;
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum DataError {
#[error("failed to list data refs {} in {}: {}", remote, remote, output)]
ListDataRefs {
remote: String,
data_glob: String,
output: String,
},
#[error("failed to delete data refs \"{}\" from {}: {}", refnames.iter().format("\", \""), url, output)]
DeleteRemoteRef {
refnames: Vec<String>,
url: String,
output: String,
},
#[error("failed to create temporary directory under {}: {}", path.display(), source)]
CreateTempDirectory {
path: PathBuf,
#[source]
source: io::Error,
},
#[error("failed to create directory {}: {}", path.display(), source)]
CreateDirectory {
path: PathBuf,
#[source]
source: io::Error,
},
#[error("failed to create data file {}: {}", path.display(), source)]
CreateFile {
path: PathBuf,
#[source]
source: io::Error,
},
#[error("failed to write data file {}: {}", path.display(), source)]
WriteFile {
path: PathBuf,
#[source]
source: io::Error,
},
#[error("failed to construct sync command for {}: {}", command, source)]
SyncCommand {
command: &'static str,
#[source]
source: io::Error,
},
#[error(
"failed to rsync data to {} (status: {:?}): {}",
destination,
status,
output
)]
Rsync {
destination: String,
status: Option<i32>,
output: String,
},
#[error("failed to get the type of {}: {}", refname, output)]
ObjectType {
refname: String,
output: String,
},
#[error("failed to get the contents of {}: {}", refname, output)]
ObjectContents {
refname: String,
output: String,
},
#[error("unsupported data object type for {}: {}", refname, type_)]
UnsupportedObjectType {
refname: String,
type_: String,
},
#[error("failed to delete data ref {}: {}", refname, output)]
DeleteDataRef {
refname: String,
output: String,
},
#[error("git error: {}", source)]
Git {
#[from]
source: GitError,
},
}
impl DataError {
fn list_data_refs(remote: String, data_glob: String, output: &[u8]) -> Self {
DataError::ListDataRefs {
remote,
data_glob,
output: String::from_utf8_lossy(output).into(),
}
}
fn delete_remote_ref(refnames: Vec<&str>, url: String, output: &[u8]) -> Self {
DataError::DeleteRemoteRef {
refnames: refnames.into_iter().map(Into::into).collect(),
url,
output: String::from_utf8_lossy(output).into(),
}
}
fn create_temp_directory(path: PathBuf, source: io::Error) -> Self {
DataError::CreateTempDirectory {
path,
source,
}
}
fn create_directory(path: PathBuf, source: io::Error) -> Self {
DataError::CreateDirectory {
path,
source,
}
}
fn create_file(path: PathBuf, source: io::Error) -> Self {
DataError::CreateFile {
path,
source,
}
}
fn write_file(path: PathBuf, source: io::Error) -> Self {
DataError::WriteFile {
path,
source,
}
}
fn sync_command(command: &'static str, source: io::Error) -> Self {
DataError::SyncCommand {
command,
source,
}
}
fn rsync(destination: String, status: Option<i32>, output: &[u8]) -> Self {
DataError::Rsync {
destination,
status,
output: String::from_utf8_lossy(output).into(),
}
}
fn object_type(refname: String, output: &[u8]) -> Self {
DataError::ObjectType {
refname,
output: String::from_utf8_lossy(output).into(),
}
}
fn object_contents(refname: String, output: &[u8]) -> Self {
DataError::ObjectContents {
refname,
output: String::from_utf8_lossy(output).into(),
}
}
fn unsupported_object_type(refname: String, type_: String) -> Self {
DataError::UnsupportedObjectType {
refname,
type_,
}
}
fn delete_data_ref(refname: String, output: &[u8]) -> Self {
DataError::DeleteDataRef {
refname,
output: String::from_utf8_lossy(output).into(),
}
}
}
type DataResult<T> = Result<T, DataError>;
#[derive(Debug)]
pub struct Data {
ctx: GitContext,
destinations: Vec<String>,
ref_namespace: String,
keep_refs: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DataActionResult {
NoData,
NoDestinations,
DataPushed,
}
impl Data {
pub fn new(ctx: GitContext) -> Self {
Self {
ctx,
destinations: Vec::new(),
ref_namespace: "data".into(),
keep_refs: false,
}
}
pub fn add_destination<D>(&mut self, destination: D) -> &mut Self
where
D: Into<String>,
{
self.destinations.push(destination.into());
self
}
pub fn keep_refs(&mut self) -> &mut Self {
self.keep_refs = true;
self
}
pub fn ref_namespace<R>(&mut self, ref_namespace: R) -> &mut Self
where
R: Into<String>,
{
self.ref_namespace = ref_namespace.into();
self
}
pub fn fetch_data(&self, repo: &Repo) -> DataResult<DataActionResult> {
info!(
target: "ghostflow/data",
"checking for data in {}",
repo.url,
);
let data_ref_ns = format!("refs/{}/", self.ref_namespace);
let data_ref_glob = format!("{data_ref_ns}*");
let ls_remote = self
.ctx
.git()
.arg("ls-remote")
.arg("--quiet")
.arg("--exit-code")
.arg(&repo.url)
.arg(&data_ref_glob)
.output()
.map_err(|err| GitError::subcommand("ls-remote", err))?;
if !ls_remote.status.success() {
if let Some(2) = ls_remote.status.code() {
return Ok(DataActionResult::NoData);
} else {
return Err(DataError::list_data_refs(
repo.safe_url().into(),
data_ref_glob,
&ls_remote.stderr,
));
}
}
let remote_data_refs = String::from_utf8_lossy(&ls_remote.stdout);
let data_refs = remote_data_refs
.lines()
.filter_map(|line| line.split_once('\t').map(|t| t.1))
.collect::<Vec<_>>();
info!(
target: "ghostflow/data",
"fetching data from {}",
repo.url,
);
self.ctx
.force_fetch_into(&repo.url, &data_ref_glob, &data_ref_glob)?;
if self.destinations.is_empty() {
return Ok(DataActionResult::NoDestinations);
}
if !self.keep_refs {
let delete_refs = self
.ctx
.git()
.arg("push")
.arg("--atomic")
.arg("--porcelain")
.arg("--delete")
.arg(&repo.url)
.args(&data_refs)
.output()
.map_err(|err| GitError::subcommand("push", err))?;
if !delete_refs.status.success() {
return Err(DataError::delete_remote_ref(
data_refs,
repo.safe_url().into(),
&delete_refs.stderr,
));
}
}
let tempdir = TempDir::new_in(self.ctx.gitdir())
.map_err(|err| DataError::create_temp_directory(self.ctx.gitdir().into(), err))?;
let namespace_parts = 1 + self.ref_namespace.chars().filter(|&ch| ch == '/').count();
let mut valid_refs = Vec::new();
for data_ref in data_refs {
let ref_parts = data_ref
.splitn(3 + namespace_parts, '/')
.skip(1 + namespace_parts)
.tuples()
.next();
let (digest_str, expected_hash) = if let Some(bits) = ref_parts {
bits
} else {
warn!(
target: "ghostflow/data",
"unsupported refname {data_ref}",
);
self.delete_ref(data_ref)?;
continue;
};
let (contents, hash) = match digest_str {
"MD5" => self.hash_blob::<Md5>(data_ref)?,
"SHA256" => self.hash_blob::<Sha256>(data_ref)?,
"SHA512" => self.hash_blob::<Sha512>(data_ref)?,
_ => {
error!(
target: "ghostflow/data",
"unsupported digest algorithm {digest_str}; ignoring",
);
continue;
},
};
let hash_matches = expected_hash == hash;
if hash_matches {
let output_dir = tempdir.path().join(digest_str);
fs::create_dir_all(&output_dir)
.map_err(|err| DataError::create_directory(output_dir.clone(), err))?;
let output_path = output_dir.join(&hash);
{
let mut output_file = OpenOptions::new()
.mode(0o444)
.write(true)
.create_new(true)
.open(&output_path)
.map_err(|err| DataError::create_file(output_path.clone(), err))?;
output_file
.write_all(&contents)
.map_err(|err| DataError::write_file(output_path.clone(), err))?;
}
valid_refs.push(data_ref);
} else {
warn!(
target: "ghostflow/data",
"failed to verify {data_ref} hash; expected {expected_hash}, actually {hash}",
);
self.lenient_delete_ref(data_ref);
}
}
let mut source = tempdir.path().as_os_str().to_os_string();
source.push("/");
self.destinations
.iter()
.map(|destination| {
let rsync = Command::new("rsync")
.arg("--recursive")
.arg("--perms")
.arg("--times")
.arg("--verbose")
.arg(&source)
.arg(destination)
.output()
.map_err(|err| DataError::sync_command("rsync", err))?;
if !rsync.status.success() {
return Err(DataError::rsync(
destination.into(),
rsync.status.code(),
&rsync.stderr,
));
}
Ok(())
})
.collect::<Vec<DataResult<_>>>()
.into_iter()
.collect::<DataResult<Vec<_>>>()?;
if !self.keep_refs {
valid_refs
.into_iter()
.for_each(|refname| self.lenient_delete_ref(refname));
}
Ok(DataActionResult::DataPushed)
}
fn hash_blob<D>(&self, refname: &str) -> DataResult<(Vec<u8>, String)>
where
D: Digest,
digest::Output<D>: std::fmt::LowerHex,
{
let contents = self.blob_contents(refname)?;
let mut digest = D::new();
digest.update(&contents);
Ok((contents, format!("{:x}", digest.finalize())))
}
fn blob_contents(&self, refname: &str) -> DataResult<Vec<u8>> {
let cat_file = self
.ctx
.git()
.arg("cat-file")
.arg("-t")
.arg(refname)
.output()
.map_err(|err| GitError::subcommand("cat-file -t", err))?;
if !cat_file.status.success() {
return Err(DataError::object_type(refname.into(), &cat_file.stderr));
}
let object_type = String::from_utf8_lossy(&cat_file.stdout);
if object_type.trim() == "blob" {
let cat_file = self
.ctx
.git()
.arg("cat-file")
.arg("blob")
.arg(refname)
.output()
.map_err(|err| GitError::subcommand("cat-file blob", err))?;
if !cat_file.status.success() {
return Err(DataError::object_contents(refname.into(), &cat_file.stderr));
}
Ok(cat_file.stdout)
} else {
Err(DataError::unsupported_object_type(
refname.into(),
object_type.trim().into(),
))
}
}
fn delete_ref(&self, refname: &str) -> DataResult<()> {
let update_ref = self
.ctx
.git()
.arg("update-ref")
.arg("-d")
.arg(refname)
.output()
.map_err(|err| GitError::subcommand("update-ref -d", err))?;
if !update_ref.status.success() {
return Err(DataError::delete_data_ref(
refname.into(),
&update_ref.stderr,
));
}
Ok(())
}
fn lenient_delete_ref(&self, refname: &str) {
let _ = self.delete_ref(refname).map_err(|err| {
error!(
target: "ghostflow/data",
"failed to delete ref {refname}: {err:?}",
);
});
}
}