#![forbid(unsafe_code)]
#![deny(missing_docs)]
use std::path::PathBuf;
use std::sync::Mutex;
use std::time::Duration;
use dynamic_config::{Error, Fetched, Format, RemoteSource, Watching};
use dynamic_config_store_core::documents::{self, Overlap};
use dynamic_config_store_core::guarded;
pub mod auth;
mod fetch;
pub mod tls;
mod url;
pub mod working;
pub use auth::{Auth, Credential, SshAuth};
pub use dynamic_config_store_core::tls::TlsConfig;
use auth::Session;
use fetch::Failure;
use url::redacted;
use working::Working;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
const DEFAULT_MAX_BYTES: u64 = 1024 * 1024;
const DEFAULT_BRANCH: &str = "main";
const LOCAL_REF: &str = "refs/dynamic-config/head";
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum Reference {
Branch(String),
Tag(String),
Commit(String),
}
impl Reference {
fn refspec(&self) -> String {
let source = match self {
Self::Branch(name) => format!("refs/heads/{name}"),
Self::Tag(name) => format!("refs/tags/{name}"),
Self::Commit(sha) => sha.clone(),
};
format!("+{source}:{LOCAL_REF}")
}
fn advertised(&self) -> Option<String> {
match self {
Self::Branch(name) => Some(format!("refs/heads/{name}")),
Self::Tag(name) => Some(format!("refs/tags/{name}")),
Self::Commit(_) => None,
}
}
fn resolve(
&self,
ref_map: &gix::remote::fetch::RefMap,
url: &str,
) -> Result<gix::ObjectId, Failure> {
let wanted = self.advertised();
let found = ref_map
.mappings
.iter()
.find(|mapping| match &mapping.remote {
gix::remote::fetch::refmap::Source::ObjectId(_) => wanted.is_none(),
gix::remote::fetch::refmap::Source::Ref(remote) => wanted
.as_deref()
.is_some_and(|wanted| remote.unpack().0 == wanted),
});
found
.and_then(|mapping| mapping.remote.as_id())
.map(gix::hash::oid::to_owned)
.ok_or_else(|| {
Failure::Other(Error::remote(format!(
"git {}: there is no {self} on that remote",
redacted(url)
)))
})
}
}
impl std::fmt::Display for Reference {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Branch(name) => write!(f, "branch {name}"),
Self::Tag(name) => write!(f, "tag {name}"),
Self::Commit(sha) => write!(f, "commit {sha}"),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Keys {
One(String),
Several(Vec<String>),
Prefix(String),
}
impl Keys {
#[must_use]
pub fn one(path: impl Into<String>) -> Self {
Self::One(path.into())
}
#[must_use]
pub fn several<I, S>(paths: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
Self::Several(paths.into_iter().map(Into::into).collect())
}
#[must_use]
pub fn prefix(directory: impl Into<String>) -> Self {
Self::Prefix(directory.into())
}
fn named(&self) -> &[String] {
match self {
Self::One(path) => std::slice::from_ref(path),
Self::Several(paths) => paths,
Self::Prefix(_) => &[],
}
}
pub(crate) fn describe(&self) -> String {
match self {
Self::One(path) => path.clone(),
Self::Several(paths) => format!("paths {}", paths.join(", ")),
Self::Prefix(directory) => format!("everything under {directory:?}"),
}
}
fn overlap(&self) -> Overlap {
match self {
Self::One(_) | Self::Several(_) => Overlap::LaterWins,
Self::Prefix(_) => Overlap::Refused,
}
}
}
impl From<&str> for Keys {
fn from(path: &str) -> Self {
Self::one(path)
}
}
impl From<String> for Keys {
fn from(path: String) -> Self {
Self::One(path)
}
}
impl From<&String> for Keys {
fn from(path: &String) -> Self {
Self::one(path)
}
}
pub struct GitSource {
url: String,
reference: Reference,
keys: Keys,
format: Format,
credential: Session,
tls: TlsConfig,
working: Working,
timeout: Duration,
max_bytes: u64,
compact_after: u32,
last: Mutex<Option<gix::ObjectId>>,
fetching: Mutex<()>,
}
impl GitSource {
pub fn builder(url: impl Into<String>) -> Builder {
Builder {
url: url.into(),
reference: Reference::Branch(DEFAULT_BRANCH.to_owned()),
path: None,
format: None,
credential: Credential::anonymous(),
tls: TlsConfig::new(),
cache_dir: None,
timeout: DEFAULT_TIMEOUT,
max_bytes: DEFAULT_MAX_BYTES,
compact_after: working::AFTER,
}
}
pub fn watch<F>(
&self,
watching: &Watching,
interval: Duration,
mut on_change: F,
) -> Result<(), Error>
where
F: FnMut(Fetched) -> Result<(), Error>,
{
let mut seen: Option<gix::ObjectId> = None;
while watching.keep_going() {
match self.attempt(false) {
Ok(current) if seen.is_none() => seen = Some(current.commit),
Ok(current) if seen != Some(current.commit) => {
if let Ok((document, commit)) = self.read() {
seen = Some(commit);
guarded(&mut on_change, document, &self.describe())?;
}
}
Err(error)
if error.kind() == dynamic_config::ErrorKind::Auth
&& !self.credential.is_replaceable() =>
{
return Err(error)
}
_ => {}
}
watching.sleep_for(interval);
}
Ok(())
}
fn read(&self) -> Result<(Fetched, gix::ObjectId), Error> {
let found = self.attempt(true)?;
*self.last() = Some(found.commit);
let document = documents::merged(
&found.documents,
self.format,
self.keys.overlap(),
&self.describe(),
)?;
Ok((document, found.commit))
}
fn attempt(&self, want_document: bool) -> Result<Found, Error> {
match self.once(want_document) {
Err(Failure::Refused(_)) if self.credential.is_replaceable() => {
self.credential.invalidate();
self.once(want_document).map_err(Failure::into_error)
}
outcome => outcome.map_err(Failure::into_error),
}
}
fn once(&self, want_document: bool) -> Result<Found, Failure> {
let auth = self.credential.current().map_err(Failure::Other)?;
let _fetching = self
.fetching
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let directory = self.working.path().map_err(Failure::Other)?;
working::compact(directory, self.compact_after).map_err(Failure::Other)?;
let repository = fetch::open(directory, auth.ssh_command())?;
let commit = fetch::fetch(
&repository,
&fetch::Plan {
url: &self.url,
reference: &self.reference,
auth: &auth,
tls: &self.tls,
timeout: self.timeout,
described: &self.describe(),
},
want_document,
)?;
let documents = if want_document {
fetch::read_documents(&repository, commit, &self.keys, self.max_bytes, &self.url)?
} else {
Vec::new()
};
Ok(Found { commit, documents })
}
fn last(&self) -> std::sync::MutexGuard<'_, Option<gix::ObjectId>> {
self.last
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
}
struct Found {
commit: gix::ObjectId,
documents: Vec<(String, String)>,
}
impl RemoteSource for GitSource {
fn fetch(&self) -> Result<Fetched, Error> {
self.read().map(|(document, _commit)| document)
}
fn describe(&self) -> String {
match *self.last() {
Some(commit) => format!(
"git {}@{}:{}",
redacted(&self.url),
commit.to_hex_with_len(12),
self.keys.describe()
),
None => format!(
"git {} {}:{}",
redacted(&self.url),
self.reference,
self.keys.describe()
),
}
}
}
impl std::fmt::Debug for GitSource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("GitSource")
.field("url", &redacted(&self.url))
.field("reference", &self.reference)
.field("keys", &self.keys)
.field("format", &self.format)
.field("credential", &self.credential)
.field("tls", &self.tls)
.field("working", &self.working)
.field("timeout", &self.timeout)
.finish_non_exhaustive()
}
}
#[must_use]
pub struct Builder {
url: String,
reference: Reference,
path: Option<Keys>,
format: Option<Format>,
credential: Credential,
tls: TlsConfig,
cache_dir: Option<PathBuf>,
timeout: Duration,
max_bytes: u64,
compact_after: u32,
}
impl std::fmt::Debug for Builder {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Builder")
.field("url", &redacted(&self.url))
.field("reference", &self.reference)
.field("path", &self.path)
.field("format", &self.format)
.field("credential", &self.credential)
.field("tls", &self.tls)
.field("cache_dir", &self.cache_dir)
.field("timeout", &self.timeout)
.field("max_bytes", &self.max_bytes)
.field("compact_after", &self.compact_after)
.finish()
}
}
impl Builder {
pub fn branch(mut self, name: impl Into<String>) -> Self {
self.reference = Reference::Branch(name.into());
self
}
pub fn tag(mut self, name: impl Into<String>) -> Self {
self.reference = Reference::Tag(name.into());
self
}
pub fn commit(mut self, sha: impl Into<String>) -> Self {
self.reference = Reference::Commit(sha.into());
self
}
pub fn reference(mut self, reference: Reference) -> Self {
self.reference = reference;
self
}
pub fn path(mut self, path: impl Into<Keys>) -> Self {
self.path = Some(path.into());
self
}
pub fn format(mut self, format: Format) -> Self {
self.format = Some(format);
self
}
pub fn credential(mut self, credential: Credential) -> Self {
self.credential = credential;
self
}
pub fn tls(mut self, tls: TlsConfig) -> Self {
self.tls = tls;
self
}
pub fn cache_dir(mut self, path: impl Into<PathBuf>) -> Self {
self.cache_dir = Some(path.into());
self
}
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
pub fn max_bytes(mut self, bytes: u64) -> Self {
self.max_bytes = bytes;
self
}
pub fn compact_after(mut self, transfers: u32) -> Self {
self.compact_after = transfers;
self
}
pub fn build(self) -> Result<GitSource, Error> {
let keys = self
.path
.ok_or_else(|| Error::remote("git: no path; call `path` with the file to read"))?;
check_keys(&keys)?;
check_reference(&self.reference)?;
tls::check_scheme(&self.url, &self.tls)?;
let format = match self.format {
Some(format) => format,
None => infer_format(&keys)?,
};
let working = match self.cache_dir {
Some(directory) => Working::Named(working::Claimed::new(directory)?),
None => Working::Temporary(working::Temporary::new()?),
};
Ok(GitSource {
url: self.url,
reference: self.reference,
keys,
format,
credential: Session::new(self.credential),
tls: self.tls,
working,
timeout: self.timeout,
max_bytes: self.max_bytes,
compact_after: self.compact_after,
last: Mutex::new(None),
fetching: Mutex::new(()),
})
}
}
fn check_keys(keys: &Keys) -> Result<(), Error> {
if let Keys::Several(paths) = keys {
if paths.is_empty() {
return Err(Error::remote(
"git: `Keys::several` with no paths in it; name at least one \
file, or use `Keys::prefix` for a directory",
));
}
}
if let Keys::Prefix(directory) = keys {
let root = directory.trim_end_matches('/');
return if root.is_empty() {
Ok(())
} else {
check_path(root)
};
}
keys.named().iter().try_for_each(|path| check_path(path))
}
fn infer_format(keys: &Keys) -> Result<Format, Error> {
match documents::agreed_format(keys.named()) {
Err(complaint) => Err(Error::remote(format!("git: {complaint}"))),
Ok(Some(format)) => Ok(format),
Ok(None) => Err(Error::remote(format!(
"git: cannot tell what format {} is; call `format`",
keys.describe()
))),
}
}
pub(crate) fn check_path(path: &str) -> Result<(), Error> {
let refuse = |why: &str| {
Err(Error::remote(format!(
"git: {path:?} is not a file inside the repository: {why}"
)))
};
if path.is_empty() {
return refuse("it is empty");
}
if path.starts_with('/') {
return refuse("it is absolute; paths are relative to the repository root");
}
for component in path.split('/') {
match component {
"" => return refuse("it has an empty component"),
"." | ".." => {
return refuse(
"it has a `.` or `..` component; this source reads one blob out of \
one tree and cannot leave the repository",
)
}
_ => {}
}
}
Ok(())
}
fn check_reference(reference: &Reference) -> Result<(), Error> {
let Reference::Commit(sha) = reference else {
return Ok(());
};
let usable = matches!(sha.len(), 40 | 64) && sha.bytes().all(|byte| byte.is_ascii_hexdigit());
if usable {
Ok(())
} else {
Err(Error::remote(format!(
"git: {sha:?} is not a full commit id; give all {} characters, or \
name a branch or a tag",
if sha.len() > 40 { 64 } else { 40 }
)))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_path_that_tries_to_leave_the_repository_is_refused() {
for path in [
"../../etc/shadow",
"services/../../../etc/shadow",
"/etc/shadow",
"services//config.yaml",
"./config.yaml",
"",
] {
let error = GitSource::builder("https://github.com/acme/config.git")
.path(path)
.format(Format::Yaml)
.build()
.expect_err("{path} must not be accepted");
assert!(
error
.to_string()
.contains("not a file inside the repository")
|| error.to_string().contains("no path"),
"{path}: {error}"
);
}
}
#[test]
fn an_ordinary_path_is_accepted_and_its_format_inferred() {
let source = GitSource::builder("https://github.com/acme/config.git")
.path("services/api/config.yaml")
.build()
.expect("a yaml file needs no format");
assert_eq!(source.format, Format::Yaml);
assert_eq!(source.reference, Reference::Branch("main".to_owned()));
}
#[test]
fn a_file_whose_name_says_nothing_needs_a_format() {
let error = GitSource::builder("https://github.com/acme/config.git")
.path("services/api/settings")
.build()
.expect_err("nothing can infer a format from that");
assert!(
error.to_string().contains("cannot tell what format"),
"{error}"
);
GitSource::builder("https://github.com/acme/config.git")
.path("services/api/settings")
.format(Format::Toml)
.build()
.expect("saying so is all it takes");
}
#[test]
fn an_abbreviated_commit_is_refused_where_it_was_written() {
let error = GitSource::builder("https://github.com/acme/config.git")
.path("config.yaml")
.commit("deadbee")
.build()
.expect_err("the protocol cannot ask for an abbreviation");
assert!(
error.to_string().contains("not a full commit id"),
"{error}"
);
GitSource::builder("https://github.com/acme/config.git")
.path("config.yaml")
.commit("da39a3ee5e6b4b0d3255bfef95601890afd80709")
.build()
.expect("a full sha is fine");
}
#[test]
fn each_reference_asks_for_the_ref_it_names() {
assert_eq!(
Reference::Branch("main".to_owned()).refspec(),
"+refs/heads/main:refs/dynamic-config/head"
);
assert_eq!(
Reference::Tag("v1.2.0".to_owned()).refspec(),
"+refs/tags/v1.2.0:refs/dynamic-config/head"
);
assert_eq!(
Reference::Commit("da39a3ee5e6b4b0d3255bfef95601890afd80709".to_owned()).refspec(),
"+da39a3ee5e6b4b0d3255bfef95601890afd80709:refs/dynamic-config/head"
);
}
#[test]
fn a_token_in_the_url_reaches_neither_debug_nor_describe() {
let source =
GitSource::builder("https://x-access-token:ghs_hunter2@github.com/acme/config.git")
.path("config.yaml")
.credential(Credential::token("ghs_hunter2-as-well"))
.build()
.unwrap();
let printed = format!("{source:?} {}", source.describe());
assert!(!printed.contains("hunter2"), "{printed}");
assert!(printed.contains("github.com/acme/config.git"), "{printed}");
assert!(printed.contains("x-access-token"), "{printed}");
}
#[test]
fn a_token_in_the_url_does_not_reach_the_builders_debug_either() {
let builder =
GitSource::builder("https://x-access-token:ghs_hunter2@github.com/acme/config.git")
.path("config.yaml")
.credential(Credential::token("ghs_hunter2-as-well"));
let printed = format!("{builder:?}");
assert!(!printed.contains("hunter2"), "{printed}");
assert!(printed.contains("github.com/acme/config.git"), "{printed}");
}
}