use std::{
fmt::Write,
fs,
path::{Path, PathBuf},
str,
time::Duration,
};
use gix::{bstr::ByteSlice, protocol::handshake::Ref};
use tame_index::utils::flock::LockOptions;
use super::{Commit, DEFAULT_URL};
use crate::error::{Error, ErrorKind};
const ADVISORY_DB_DIRECTORY: &str = "advisory-db";
const REF_SPEC: &str = "+HEAD:refs/remotes/origin/HEAD";
const DIR: gix::remote::Direction = gix::remote::Direction::Fetch;
const DEFAULT_LOCK_TIMEOUT: Duration = Duration::from_secs(5 * 60);
#[cfg_attr(docsrs, doc(cfg(feature = "git")))]
pub struct Repository {
pub(super) repo: gix::Repository,
}
impl Repository {
pub fn default_path() -> PathBuf {
home::cargo_home()
.unwrap_or_else(|err| {
panic!("Error locating Cargo home directory: {err}");
})
.join(ADVISORY_DB_DIRECTORY)
}
pub fn fetch_default_repo() -> Result<Self, Error> {
Self::fetch(
DEFAULT_URL,
Repository::default_path(),
true,
DEFAULT_LOCK_TIMEOUT,
)
}
pub fn fetch<P: Into<PathBuf>>(
url: &str,
into_path: P,
ensure_fresh: bool,
lock_timeout: Duration,
) -> Result<Self, Error> {
if !url.starts_with("https://") {
fail!(
ErrorKind::BadParam,
"expected {} to start with https://",
url
);
}
let path = into_path.into();
if let Some(parent) = path.parent() {
if !parent.is_dir() {
fs::create_dir_all(parent)?;
}
} else {
fail!(ErrorKind::BadParam, "invalid directory: {}", path.display())
}
if path.is_dir() && fs::read_dir(&path)?.next().is_none() {
fs::remove_dir(&path)?;
}
let lock_path = tame_index::Path::from_path(&path)
.ok_or_else(|| {
Error::new(
ErrorKind::BadParam,
"Path to the advisory DB directory is not valid UTF-8!",
)
})?
.with_extension(".lock");
let lock_opts = LockOptions::new(&lock_path).exclusive(false);
let _lock = if lock_timeout == Duration::from_secs(0) {
lock_opts.try_lock()
} else {
lock_opts.lock(|_| Some(lock_timeout))
}
.map_err(Error::from_tame)?;
let open_or_clone_repo = || -> Result<_, Error> {
let mut mapping = gix::sec::trust::Mapping::default();
let open_with_complete_config =
gix::open::Options::default().permissions(gix::open::Permissions {
config: gix::open::permissions::Config {
git_binary: true,
..Default::default()
},
..Default::default()
});
mapping.reduced = open_with_complete_config.clone();
mapping.full = open_with_complete_config.clone();
let repo = gix::ThreadSafeRepository::discover_opts(
&path,
gix::discover::upwards::Options::default().apply_environment(),
mapping,
)
.ok()
.map(|repo| repo.to_thread_local())
.filter(|repo| {
repo.find_remote("origin").is_ok_and(|remote| {
remote
.url(DIR)
.is_some_and(|remote_url| remote_url.to_bstring() == url)
})
})
.or_else(|| gix::open_opts(&path, open_with_complete_config).ok());
let res = if let Some(repo) = repo {
(repo, None)
} else {
let mut progress = gix::progress::Discard;
let should_interrupt = &gix::interrupt::IS_INTERRUPTED;
let (mut prep_checkout, out) = gix::prepare_clone(url, path)
.map_err(|err| {
Error::with_source(
ErrorKind::Repo,
"failed to prepare clone".to_owned(),
err,
)
})?
.with_remote_name("origin")
.map_err(|err| {
Error::with_source(ErrorKind::Repo, "invalid remote name".to_owned(), err)
})?
.configure_remote(|remote| Ok(remote.with_refspecs([REF_SPEC], DIR)?))
.fetch_then_checkout(&mut progress, should_interrupt)
.map_err(|err| Error::with_source(ErrorKind::Repo, err.to_string(), err))?;
let repo = prep_checkout
.main_worktree(&mut progress, should_interrupt)
.map_err(|err| {
Error::with_source(
ErrorKind::Repo,
"failed to checkout fresh clone".to_owned(),
err,
)
})?
.0;
(repo, Some(out))
};
Ok(res)
};
let (mut repo, fetch_outcome) = open_or_clone_repo()?;
if let Some(fetch_outcome) = fetch_outcome {
write_fetch_head(&repo, &fetch_outcome, &repo.find_remote("origin").unwrap())?;
} else {
Self::perform_fetch(&mut repo)?;
}
repo.object_cache_size_if_unset(OBJECT_CACHE_SIZE);
let repo = Self { repo };
let latest_commit = Commit::from_repo_head(&repo)?;
latest_commit.reset(&repo)?;
if ensure_fresh && !latest_commit.is_fresh() {
fail!(
ErrorKind::Repo,
"repository is stale (last commit: {:?})",
latest_commit.timestamp
);
}
Ok(repo)
}
pub fn open<P: Into<PathBuf>>(into_path: P) -> Result<Self, Error> {
let path = into_path.into();
let mut repo = gix::open(&path).map_err(|err| {
Error::with_source(
ErrorKind::Repo,
format!("failed to open repository at '{}'", path.display()),
err,
)
})?;
repo.object_cache_size_if_unset(OBJECT_CACHE_SIZE);
Ok(Self { repo })
}
pub fn latest_commit(&self) -> Result<Commit, Error> {
Commit::from_repo_head(self)
}
pub fn path(&self) -> &Path {
self.repo.workdir().unwrap()
}
pub fn has_relative_path(&self, path: &Path) -> bool {
let lookup = || {
self.repo
.head_commit()
.ok()?
.tree()
.ok()?
.lookup_entry_by_path(path)
.ok()
.map(|_e| true)
};
lookup().unwrap_or_default()
}
fn perform_fetch(repo: &mut gix::Repository) -> Result<(), Error> {
let mut config = repo.config_snapshot_mut();
config
.set_raw_value_by("committer", None, "name", "rustsec")
.map_err(|err| {
Error::with_source(
ErrorKind::Repo,
"failed to set `committer.name`".to_owned(),
err,
)
})?;
config
.set_raw_value_by("committer", None, "email", "")
.map_err(|err| {
Error::with_source(
ErrorKind::Repo,
"failed to set `committer.email`".to_owned(),
err,
)
})?;
let repo = config.commit_auto_rollback().map_err(|err| {
Error::with_source(ErrorKind::Repo, "failed to set `committer`".to_owned(), err)
})?;
let mut remote = repo.find_remote("origin").map_err(|err| {
Error::with_source(
ErrorKind::Repo,
"failed to find `origin` remote".to_owned(),
err,
)
})?;
remote
.replace_refspecs(Some(REF_SPEC), DIR)
.expect("valid statically known refspec");
let outcome = remote
.connect(DIR)
.map_err(|err| {
Error::with_source(
ErrorKind::Repo,
"failed to connect to remote".to_owned(),
err,
)
})?
.prepare_fetch(&mut gix::progress::Discard, Default::default())
.map_err(|err| {
Error::with_source(ErrorKind::Repo, "failed to prepare fetch".to_owned(), err)
})?
.receive(&mut gix::progress::Discard, &gix::interrupt::IS_INTERRUPTED)
.map_err(|err| {
Error::with_source(ErrorKind::Repo, "failed to fetch".to_owned(), err)
})?;
let remote_head_id = write_fetch_head(&repo, &outcome, &remote)?;
use gix::refs::{Target, transaction as tx};
use gix::head::Kind;
let edit = match repo
.head()
.map_err(|err| {
Error::with_source(ErrorKind::Repo, "unable to locate HEAD".to_owned(), err)
})?
.kind
{
Kind::Symbolic(sref) => {
if let Target::Symbolic(name) = sref.target {
Some(tx::RefEdit {
change: tx::Change::Update {
log: tx::LogChange {
mode: tx::RefLog::AndReference,
force_create_reflog: false,
message: "".into(),
},
expected: tx::PreviousValue::MustExist,
new: Target::Object(remote_head_id),
},
name,
deref: true,
})
} else {
None
}
}
Kind::Unborn(_) | Kind::Detached { .. } => None,
};
let edit = edit.unwrap_or_else(|| tx::RefEdit {
change: tx::Change::Update {
log: tx::LogChange {
mode: tx::RefLog::AndReference,
force_create_reflog: false,
message: "".into(),
},
expected: tx::PreviousValue::Any,
new: Target::Object(remote_head_id),
},
name: "HEAD".try_into().unwrap(),
deref: true,
});
repo.edit_reference(edit).map_err(|err| {
Error::with_source(
ErrorKind::Repo,
"failed to set update reflog".to_owned(),
err,
)
})?;
Ok(())
}
}
fn write_fetch_head(
repo: &gix::Repository,
fetch: &gix::remote::fetch::Outcome,
remote: &gix::Remote<'_>,
) -> Result<gix::ObjectId, Error> {
let (head_target_branch, oid) = fetch
.ref_map
.mappings
.iter()
.find_map(|mapping| {
let gix::remote::fetch::refmap::Source::Ref(rref) = &mapping.remote else {
return None;
};
let Ref::Symbolic {
full_ref_name,
target,
object,
..
} = rref
else {
return None;
};
(full_ref_name == "HEAD").then_some((target, object))
})
.ok_or_else(|| Error::new(ErrorKind::Repo, "unable to find remote HEAD"))?;
let remote_url = {
let ru = remote
.url(gix::remote::Direction::Fetch)
.expect("can't fetch without a fetch url");
let s = ru.to_bstring();
let v = s.into();
String::from_utf8(v).expect("remote url was not utf-8 :-/")
};
let fetch_head = {
let mut hex_id = [0u8; 40];
let gix::ObjectId::Sha1(sha1) = oid else {
return Err(Error::new(
ErrorKind::Repo,
"unsupported object id format in remote HEAD",
));
};
let commit_id = encode_hex(sha1, &mut hex_id);
let mut fetch_head = String::new();
let remote_name = remote
.name()
.and_then(|n| {
let gix::remote::Name::Symbol(name) = n else {
return None;
};
Some(name.as_ref())
})
.unwrap_or("origin");
if remote
.refspecs(gix::remote::Direction::Fetch)
.iter()
.any(|rspec| {
let rspec = rspec.to_ref();
if !rspec.remote().is_some_and(|r| r.ends_with(b"HEAD")) {
return false;
}
rspec.local().is_some_and(|l| {
l.to_str().ok().and_then(|l| {
l.strip_prefix("refs/remotes/")
.and_then(|l| l.strip_suffix("/HEAD"))
}) == Some(remote_name)
})
})
{
writeln!(&mut fetch_head, "{commit_id}\t\t{remote_url}").unwrap();
}
if let Some(branch_name) = head_target_branch
.to_str()
.ok()
.and_then(|s| s.strip_prefix("refs/heads/"))
{
writeln!(
&mut fetch_head,
"{commit_id}\t\tbranch '{branch_name}' of {remote_url}"
)
.unwrap();
}
fetch_head
};
if fetch_head.is_empty() {
return Err(Error::new(ErrorKind::Repo, "unable to find remote HEAD"));
}
let fetch_head_path = repo.path().join("FETCH_HEAD");
fs::write(&fetch_head_path, fetch_head).map_err(|err| {
Error::with_source(
ErrorKind::Io,
format!("failed to write {}", fetch_head_path.display()),
err,
)
})?;
Ok(*oid)
}
fn encode_hex<'out, const I: usize, const O: usize>(
input: &[u8; I],
output: &'out mut [u8; O],
) -> &'out str {
assert_eq!(I * 2, O);
const CHARS: &[u8] = b"0123456789abcdef";
for (i, &byte) in input.iter().enumerate() {
let i = i * 2;
output[i] = CHARS[(byte >> 4) as usize];
output[i + 1] = CHARS[(byte & 0xf) as usize];
}
str::from_utf8(output).expect("hex encoding produced invalid UTF-8")
}
const OBJECT_CACHE_SIZE: usize = 4 * 1024 * 1024;