diff --git a/Cargo.lock b/Cargo.lock index 4084dd1ab4..31c0374995 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2196,6 +2196,16 @@ version = "0.0.0" [[package]] name = "gix-submodule" version = "0.0.0" +dependencies = [ + "bstr", + "gix-config", + "gix-features 0.32.1", + "gix-path 0.8.4", + "gix-refspec", + "gix-testtools", + "gix-url", + "thiserror", +] [[package]] name = "gix-tempfile" @@ -4073,18 +4083,18 @@ dependencies = [ [[package]] name = "thiserror" -version = "1.0.43" +version = "1.0.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a35fc5b8971143ca348fa6df4f024d4d55264f3468c71ad1c2f365b0a4d58c42" +checksum = "611040a08a0439f8248d1990b111c95baa9c704c805fa1f62104b39655fd7f90" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.43" +version = "1.0.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "463fe12d7993d3b327787537ce8dd4dfa058de32fc2b195ef3cde03dc4771e8f" +checksum = "090198534930841fab3a5d1bb637cde49e339654e606195f8d9c76eeb081dc96" dependencies = [ "proc-macro2", "quote", diff --git a/crate-status.md b/crate-status.md index 30a2673eb7..dc27f038b0 100644 --- a/crate-status.md +++ b/crate-status.md @@ -486,6 +486,7 @@ Make it the best-performing implementation and the most convenient one. * [x] primitives to help with graph traversal, along with commit-graph acceleration. ### gix-submodule +* [ ] read `.gitmodule` files, access all their fields, and apply overrides * CRUD for submodules * try to handle with all the nifty interactions and be a little more comfortable than what git offers, lay a foundation for smarter git submodules. @@ -721,7 +722,10 @@ See its [README.md](https://github.com/Byron/gitoxide/blob/main/gix-lock/README. * [ ] Use _Commit Graph_ to speed up certain queries * [ ] subtree * [ ] interactive rebase status/manipulation - * submodules + * **submodules** + * [ ] handle 'old' form for reading + * [ ] list + * [ ] traverse recursively * [ ] API documentation * [ ] Some examples @@ -754,6 +758,7 @@ See its [README.md](https://github.com/Byron/gitoxide/blob/main/gix-lock/README. ### gix-validate * [x] validate ref names +* [x] validate submodule names * [x] [validate][tagname-validation] tag names ### gix-ref diff --git a/gix-config/src/file/access/mutate.rs b/gix-config/src/file/access/mutate.rs index 56451a89e3..49de579fa8 100644 --- a/gix-config/src/file/access/mutate.rs +++ b/gix-config/src/file/access/mutate.rs @@ -272,17 +272,13 @@ impl<'event> File<'event> { self.sections.remove(&id) } - /// Adds the provided section to the config, returning a mutable reference - /// to it for immediate editing. + /// Adds the provided `section` to the config, returning a mutable reference to it for immediate editing. /// Note that its meta-data will remain as is. - pub fn push_section( - &mut self, - section: file::Section<'event>, - ) -> Result, section::header::Error> { + pub fn push_section(&mut self, section: file::Section<'event>) -> SectionMut<'_, 'event> { let id = self.push_section_internal(section); let nl = self.detect_newline_style_smallvec(); let section = self.sections.get_mut(&id).expect("each id yields a section").to_mut(nl); - Ok(section) + section } /// Renames the section with `name` and `subsection_name`, modifying the last matching section diff --git a/gix-ref/src/fullname.rs b/gix-ref/src/fullname.rs index 257bbe0602..d1533e1900 100644 --- a/gix-ref/src/fullname.rs +++ b/gix-ref/src/fullname.rs @@ -5,44 +5,46 @@ use gix_object::bstr::{BStr, BString, ByteSlice}; use crate::{bstr::ByteVec, name::is_pseudo_ref, Category, FullName, FullNameRef, Namespace, PartialNameRef}; impl TryFrom<&str> for FullName { - type Error = gix_validate::refname::Error; + type Error = gix_validate::reference::name::Error; fn try_from(value: &str) -> Result { - Ok(FullName(gix_validate::refname(value.as_bytes().as_bstr())?.into())) + Ok(FullName( + gix_validate::reference::name(value.as_bytes().as_bstr())?.into(), + )) } } impl TryFrom for FullName { - type Error = gix_validate::refname::Error; + type Error = gix_validate::reference::name::Error; fn try_from(value: String) -> Result { - gix_validate::refname(value.as_bytes().as_bstr())?; + gix_validate::reference::name(value.as_bytes().as_bstr())?; Ok(FullName(value.into())) } } impl TryFrom<&BStr> for FullName { - type Error = gix_validate::refname::Error; + type Error = gix_validate::reference::name::Error; fn try_from(value: &BStr) -> Result { - Ok(FullName(gix_validate::refname(value)?.into())) + Ok(FullName(gix_validate::reference::name(value)?.into())) } } impl TryFrom for FullName { - type Error = gix_validate::refname::Error; + type Error = gix_validate::reference::name::Error; fn try_from(value: BString) -> Result { - gix_validate::refname(value.as_ref())?; + gix_validate::reference::name(value.as_ref())?; Ok(FullName(value)) } } impl TryFrom<&BString> for FullName { - type Error = gix_validate::refname::Error; + type Error = gix_validate::reference::name::Error; fn try_from(value: &BString) -> Result { - gix_validate::refname(value.as_ref())?; + gix_validate::reference::name(value.as_ref())?; Ok(FullName(value.clone())) } } diff --git a/gix-ref/src/namespace.rs b/gix-ref/src/namespace.rs index 2723052ec2..ff0e80624c 100644 --- a/gix-ref/src/namespace.rs +++ b/gix-ref/src/namespace.rs @@ -36,10 +36,10 @@ impl Namespace { /// Given a `namespace` 'foo we output 'refs/namespaces/foo', and given 'foo/bar' we output 'refs/namespaces/foo/refs/namespaces/bar'. /// /// For more information, consult the [git namespace documentation](https://git-scm.com/docs/gitnamespaces). -pub fn expand<'a, Name, E>(namespace: Name) -> Result +pub fn expand<'a, Name, E>(namespace: Name) -> Result where Name: TryInto<&'a PartialNameRef, Error = E>, - gix_validate::refname::Error: From, + gix_validate::reference::name::Error: From, { let namespace = &namespace.try_into()?.0; let mut out = BString::default(); diff --git a/gix-ref/src/store/file/loose/reference/decode.rs b/gix-ref/src/store/file/loose/reference/decode.rs index 9bf2f7c29e..ece14bb484 100644 --- a/gix-ref/src/store/file/loose/reference/decode.rs +++ b/gix-ref/src/store/file/loose/reference/decode.rs @@ -39,15 +39,17 @@ impl TryFrom for Target { fn try_from(v: MaybeUnsafeState) -> Result { Ok(match v { MaybeUnsafeState::Id(id) => Target::Peeled(id), - MaybeUnsafeState::UnvalidatedPath(name) => Target::Symbolic(match gix_validate::refname(name.as_ref()) { - Ok(_) => FullName(name), - Err(err) => { - return Err(Error::RefnameValidation { - source: err, - path: name, - }) - } - }), + MaybeUnsafeState::UnvalidatedPath(name) => { + Target::Symbolic(match gix_validate::reference::name(name.as_ref()) { + Ok(_) => FullName(name), + Err(err) => { + return Err(Error::RefnameValidation { + source: err, + path: name, + }) + } + }) + } }) } } diff --git a/gix-ref/tests/namespace/mod.rs b/gix-ref/tests/namespace/mod.rs index 649af64bb2..dde96d0869 100644 --- a/gix-ref/tests/namespace/mod.rs +++ b/gix-ref/tests/namespace/mod.rs @@ -31,7 +31,7 @@ mod expand { fn backslashes_are_no_component_separators_and_invalid() { assert!(matches!( gix_ref::namespace::expand("foo\\bar").expect_err("empty invalid"), - gix_validate::refname::Error::Tag( + gix_validate::reference::name::Error::Tag( gix_validate::tag::name::Error::InvalidByte{byte} ) if byte == "\\" )); @@ -41,7 +41,7 @@ mod expand { fn trailing_slashes_are_not_allowed() { assert!(matches!( gix_ref::namespace::expand("foo/").expect_err("empty invalid"), - gix_validate::refname::Error::Tag(gix_validate::tag::name::Error::EndsWithSlash) + gix_validate::reference::name::Error::Tag(gix_validate::tag::name::Error::EndsWithSlash) )); } @@ -49,21 +49,21 @@ mod expand { fn empty_namespaces_are_not_allowed() { assert!(matches!( gix_ref::namespace::expand("").expect_err("empty invalid"), - gix_validate::refname::Error::Tag(gix_validate::tag::name::Error::Empty) + gix_validate::reference::name::Error::Tag(gix_validate::tag::name::Error::Empty) )); } #[test] fn bare_slashes_are_not_allowed() { assert!(matches!( gix_ref::namespace::expand("/").expect_err("empty invalid"), - gix_validate::refname::Error::Tag(gix_validate::tag::name::Error::EndsWithSlash) + gix_validate::reference::name::Error::Tag(gix_validate::tag::name::Error::EndsWithSlash) )); } #[test] fn repeated_slashes_are_invalid() { assert!(matches!( gix_ref::namespace::expand("foo//bar").expect_err("empty invalid"), - gix_validate::refname::Error::RepeatedSlash + gix_validate::reference::name::Error::RepeatedSlash )); } } diff --git a/gix-refspec/src/parse.rs b/gix-refspec/src/parse.rs index 0e8ca852c2..bba5c69ad9 100644 --- a/gix-refspec/src/parse.rs +++ b/gix-refspec/src/parse.rs @@ -25,7 +25,7 @@ pub enum Error { #[error("Both sides of the specification need a pattern, like 'a/*:b/*'")] PatternUnbalanced, #[error(transparent)] - ReferenceName(#[from] gix_validate::refname::Error), + ReferenceName(#[from] gix_validate::reference::name::Error), #[error(transparent)] RevSpec(#[from] gix_revision::spec::parse::Error), } diff --git a/gix-refspec/tests/parse/invalid.rs b/gix-refspec/tests/parse/invalid.rs index 0a7c1ba5aa..fec09aa96e 100644 --- a/gix-refspec/tests/parse/invalid.rs +++ b/gix-refspec/tests/parse/invalid.rs @@ -11,7 +11,7 @@ fn empty() { fn empty_component() { assert!(matches!( try_parse("refs/heads/test:refs/remotes//test", Operation::Fetch).unwrap_err(), - Error::ReferenceName(gix_validate::refname::Error::RepeatedSlash) + Error::ReferenceName(gix_validate::reference::name::Error::RepeatedSlash) )); } diff --git a/gix-submodule/Cargo.toml b/gix-submodule/Cargo.toml index 78e946256c..437c465f26 100644 --- a/gix-submodule/Cargo.toml +++ b/gix-submodule/Cargo.toml @@ -12,3 +12,15 @@ rust-version = "1.65" doctest = false [dependencies] +gix-refspec = { version = "^0.14.1", path = "../gix-refspec" } +gix-config = { version = "^0.26.2", path = "../gix-config" } +gix-path = { version = "^0.8.4", path = "../gix-path" } +gix-url = { version = "^0.21.1", path = "../gix-url" } + +bstr = { version = "1.5.0", default-features = false } +thiserror = "1.0.44" + +[dev-dependencies] +gix-testtools = { path = "../tests/tools"} +gix-features = { path = "../gix-features", features = ["walkdir"] } + diff --git a/gix-submodule/src/access.rs b/gix-submodule/src/access.rs new file mode 100644 index 0000000000..5464219ecc --- /dev/null +++ b/gix-submodule/src/access.rs @@ -0,0 +1,184 @@ +use crate::config::{Branch, FetchRecurse, Ignore, Update}; +use crate::{config, File}; +use bstr::BStr; +use std::borrow::Cow; +use std::path::Path; + +/// Access +/// +/// Note that all methods perform validation of the requested value and report issues right away. +/// If a bypass is needed, use [`config()`](File::config()) for direct access. +impl File { + /// Return the underlying configuration file. + /// + /// Note that it might have been merged with values from another configuration file and may + /// thus not be accurately reflecting that state of a `.gitmodules` file anymore. + pub fn config(&self) -> &gix_config::File<'static> { + &self.config + } + + /// Return the path at which the `.gitmodules` file lives, if it is known. + pub fn config_path(&self) -> Option<&Path> { + self.config.sections().filter_map(|s| s.meta().path.as_deref()).next() + } + + /// Return the unvalidated names of the submodules for which configuration is present. + /// + /// Note that these exact names have to be used for querying submodule values. + pub fn names(&self) -> impl Iterator { + self.config + .sections_by_name("submodule") + .into_iter() + .flatten() + .filter_map(|s| s.header().subsection_name()) + } + + /// Given the `relative_path` (as seen from the root of the worktree) of a submodule with possibly platform-specific + /// component separators, find the submodule's name associated with this path, or `None` if none was found. + /// + /// Note that this does a linear search and compares `relative_path` in a normalized form to the same form of the path + /// associated with the submodule. + pub fn name_by_path(&self, relative_path: &BStr) -> Option<&BStr> { + self.names() + .filter_map(|n| self.path(n).ok().map(|p| (n, p))) + .find_map(|(n, p)| (p == relative_path).then_some(n)) + } + + /// Return the path relative to the root directory of the working tree at which the submodule is expected to be checked out. + /// It's an error if the path doesn't exist as it's the only way to associate a path in the index with additional submodule + /// information, like the URL to fetch from. + /// + /// ### Deviation + /// + /// Git currently allows absolute paths to be used when adding submodules, but fails later as it can't find the submodule by + /// relative path anymore. Let's play it safe here. + pub fn path(&self, name: &BStr) -> Result, config::path::Error> { + let path_bstr = + self.config + .string("submodule", Some(name), "path") + .ok_or_else(|| config::path::Error::Missing { + submodule: name.to_owned(), + })?; + if path_bstr.is_empty() { + return Err(config::path::Error::Missing { + submodule: name.to_owned(), + }); + } + let path = gix_path::from_bstr(path_bstr.as_ref()); + if path.is_absolute() { + return Err(config::path::Error::Absolute { + submodule: name.to_owned(), + actual: path_bstr.into_owned(), + }); + } + if gix_path::normalize(path, "").is_none() { + return Err(config::path::Error::OutsideOfWorktree { + submodule: name.to_owned(), + actual: path_bstr.into_owned(), + }); + } + Ok(path_bstr) + } + + /// Retrieve the `url` field of the submodule named `name`. It's an error if it doesn't exist or is empty. + pub fn url(&self, name: &BStr) -> Result { + let url = self + .config + .string("submodule", Some(name), "url") + .ok_or_else(|| config::url::Error::Missing { + submodule: name.to_owned(), + })?; + + if url.is_empty() { + return Err(config::url::Error::Missing { + submodule: name.to_owned(), + }); + } + gix_url::Url::from_bytes(url.as_ref()).map_err(|err| config::url::Error::Parse { + submodule: name.to_owned(), + source: err, + }) + } + + /// Retrieve the `update` field of the submodule named `name`, if present. + pub fn update(&self, name: &BStr) -> Result, config::update::Error> { + let value: Update = match self.config.string("submodule", Some(name), "update") { + Some(v) => v.as_ref().try_into().map_err(|()| config::update::Error::Invalid { + submodule: name.to_owned(), + actual: v.into_owned(), + })?, + None => return Ok(None), + }; + + if let Update::Command(cmd) = &value { + let ours = self.config.meta(); + let has_value_from_foreign_section = self + .config + .sections_by_name("submodule") + .into_iter() + .flatten() + .any(|s| (s.header().subsection_name() == Some(name) && s.meta() as *const _ != ours as *const _)); + if !has_value_from_foreign_section { + return Err(config::update::Error::CommandForbiddenInModulesConfiguration { + submodule: name.to_owned(), + actual: cmd.to_owned(), + }); + } + } + Ok(Some(value)) + } + + /// Retrieve the `branch` field of the submodule named `name`, or `None` if unset. + /// + /// Note that `Default` is implemented for [`Branch`]. + pub fn branch(&self, name: &BStr) -> Result, config::branch::Error> { + let branch = match self.config.string("submodule", Some(name), "branch") { + Some(v) => v, + None => return Ok(None), + }; + + Branch::try_from(branch.as_ref()) + .map(Some) + .map_err(|err| config::branch::Error { + submodule: name.to_owned(), + actual: branch.into_owned(), + source: err, + }) + } + + /// Retrieve the `fetchRecurseSubmodules` field of the submodule named `name`, or `None` if unset. + /// + /// Note that if it's unset, it should be retrieved from `fetch.recurseSubmodules` in the configuration. + pub fn fetch_recurse(&self, name: &BStr) -> Result, config::Error> { + self.config + .boolean("submodule", Some(name), "fetchRecurseSubmodules") + .map(FetchRecurse::new) + .transpose() + .map_err(|value| config::Error { + field: "fetchRecurseSubmodules", + submodule: name.to_owned(), + actual: value, + }) + } + + /// Retrieve the `ignore` field of the submodule named `name`, or `None` if unset. + pub fn ignore(&self, name: &BStr) -> Result, config::Error> { + self.config + .string("submodule", Some(name), "ignore") + .map(|value| { + Ignore::try_from(value.as_ref()).map_err(|()| config::Error { + field: "ignore", + submodule: name.to_owned(), + actual: value.into_owned(), + }) + }) + .transpose() + } + + /// Retrieve the `shallow` field of the submodule named `name`, or `None` if unset. + /// + /// If `true`, the submodule will be checked out with `depth = 1`. If unset, `false` is assumed. + pub fn shallow(&self, name: &BStr) -> Result, gix_config::value::Error> { + self.config.boolean("submodule", Some(name), "shallow").transpose() + } +} diff --git a/gix-submodule/src/config.rs b/gix-submodule/src/config.rs new file mode 100644 index 0000000000..2026966917 --- /dev/null +++ b/gix-submodule/src/config.rs @@ -0,0 +1,216 @@ +use bstr::{BStr, BString, ByteSlice}; + +/// Determine how the submodule participates in `git status` queries. This setting also affects `git diff`. +#[derive(Default, Debug, Clone, Copy, Ord, PartialOrd, Eq, PartialEq, Hash)] +pub enum Ignore { + /// Submodule changes won't be considered at all, which is the fastest option. + /// + /// Note that changes to the submodule hash in the superproject will still be observable. + All, + /// Ignore any changes to the submodule working tree, only show committed differences between the `HEAD` of the submodule + /// compared to the recorded commit in the superproject. + Dirty, + /// Only ignore untracked files in the submodule, but show modifications to the submodule working tree as well as differences + /// between the recorded commit in the superproject and the checked-out commit in the submodule. + Untracked, + /// No modifications to the submodule are ignored, which shows untracked files, modified files in the submodule worktree as well as + /// differences between the recorded commit in the superproject and the checked-out commit in the submodule. + #[default] + None, +} + +impl TryFrom<&BStr> for Ignore { + type Error = (); + + fn try_from(value: &BStr) -> Result { + Ok(match value.as_bytes() { + b"all" => Ignore::All, + b"dirty" => Ignore::Dirty, + b"untracked" => Ignore::Untracked, + b"none" => Ignore::None, + _ => return Err(()), + }) + } +} + +/// Determine how to recurse into this module from the superproject when fetching. +/// +/// Generally, a fetch is only performed if the submodule commit referenced by the superproject isn't already +/// present in the submodule repository. +/// +/// Note that when unspecified, the `fetch.recurseSubmodules` configuration variable should be used instead. +#[derive(Default, Debug, Clone, Copy, Ord, PartialOrd, Eq, PartialEq, Hash)] +pub enum FetchRecurse { + /// Fetch only changed submodules. + #[default] + OnDemand, + /// Fetch all populated submodules, changed or not. + /// + /// This skips the work needed to determine whether a submodule has changed in the first place, but may work + /// more as some fetches might not be necessary. + Always, + /// Submodules are never fetched. + Never, +} + +impl FetchRecurse { + /// Check if `boolean` is set and translate it the respective variant, or check the underlying string + /// value for non-boolean options. + /// On error, it returns the obtained string value which would be the invalid value. + pub fn new(boolean: Result) -> Result { + Ok(match boolean { + Ok(value) => { + if value { + FetchRecurse::Always + } else { + FetchRecurse::Never + } + } + Err(err) => { + if err.input != "on-demand" { + return Err(err.input); + } + FetchRecurse::OnDemand + } + }) + } +} + +/// Describes the branch that should be tracked on the remote. +#[derive(Debug, Clone, Ord, PartialOrd, Eq, PartialEq, Hash)] +pub enum Branch { + /// The name of the remote branch should be the same as the one currently checked out in the superproject. + CurrentInSuperproject, + /// The validated remote-only branch that could be used for fetching. + Name(BString), +} + +impl Default for Branch { + fn default() -> Self { + Branch::Name("HEAD".into()) + } +} + +impl TryFrom<&BStr> for Branch { + type Error = gix_refspec::parse::Error; + + fn try_from(value: &BStr) -> Result { + if value == "." { + return Ok(Branch::CurrentInSuperproject); + } + + gix_refspec::parse(value, gix_refspec::parse::Operation::Fetch) + .map(|spec| Branch::Name(spec.source().expect("no object").to_owned())) + } +} + +/// Determine how `git submodule update` should deal with this submodule to bring it up-to-date with the +/// super-project's expectations. +#[derive(Default, Debug, Clone, Hash, PartialOrd, PartialEq, Ord, Eq)] +pub enum Update { + /// The commit recorded in the superproject should be checked out on a detached `HEAD`. + #[default] + Checkout, + /// The current branch in the submodule will be rebased onto the commit recorded in the superproject. + Rebase, + /// The commit recorded in the superproject will merged into the current branch of the submodule. + Merge, + /// A custom command to be called like ` hash-of-submodule-commit` that is to be executed to + /// perform the submodule update. + /// + /// Note that this variant is only allowed if the value is coming from an override. Thus it's not allowed to distribute + /// arbitrary commands via `.gitmodules` for security reasons. + Command(BString), + /// The submodule update is not performed at all. + None, +} + +impl TryFrom<&BStr> for Update { + type Error = (); + + fn try_from(value: &BStr) -> Result { + Ok(match value.as_bstr().as_bytes() { + b"checkout" => Update::Checkout, + b"rebase" => Update::Rebase, + b"merge" => Update::Merge, + b"none" => Update::None, + command if command.first() == Some(&b'!') => Update::Command(command[1..].to_owned().into()), + _ => return Err(()), + }) + } +} + +/// The error returned by [File::fetch_recurse()](crate::File::fetch_recurse) and [File::ignore()](crate::File::ignore). +#[derive(Debug, thiserror::Error)] +#[allow(missing_docs)] +#[error("The '{field}' field of submodule '{submodule}' was invalid: '{actual}'")] +pub struct Error { + pub field: &'static str, + pub submodule: BString, + pub actual: BString, +} + +/// +pub mod branch { + use bstr::BString; + + /// The error returned by [File::branch()](crate::File::branch). + #[derive(Debug, thiserror::Error)] + #[allow(missing_docs)] + #[error("The value '{actual}' of the 'branch' field of submodule '{submodule}' couldn't be turned into a valid fetch refspec")] + pub struct Error { + pub submodule: BString, + pub actual: BString, + pub source: gix_refspec::parse::Error, + } +} + +/// +pub mod update { + use bstr::BString; + + /// The error returned by [File::update()](crate::File::update). + #[derive(Debug, thiserror::Error)] + #[allow(missing_docs)] + pub enum Error { + #[error("The 'update' field of submodule '{submodule}' tried to set command '{actual}' to be shared")] + CommandForbiddenInModulesConfiguration { submodule: BString, actual: BString }, + #[error("The 'update' field of submodule '{submodule}' was invalid: '{actual}'")] + Invalid { submodule: BString, actual: BString }, + } +} + +/// +pub mod url { + use bstr::BString; + + /// The error returned by [File::url()](crate::File::url). + #[derive(Debug, thiserror::Error)] + #[allow(missing_docs)] + pub enum Error { + #[error("The url of submodule '{submodule}' could not be parsed")] + Parse { + submodule: BString, + source: gix_url::parse::Error, + }, + #[error("The submodule '{submodule}' was missing its 'url' field or it was empty")] + Missing { submodule: BString }, + } +} + +/// +pub mod path { + use bstr::BString; + + /// The error returned by [File::path()](crate::File::path). + #[derive(Debug, thiserror::Error)] + #[allow(missing_docs)] + pub enum Error { + #[error("The path '{actual}' of submodule '{submodule}' needs to be relative")] + Absolute { actual: BString, submodule: BString }, + #[error("The submodule '{submodule}' was missing its 'path' field or it was empty")] + Missing { submodule: BString }, + #[error("The path '{actual}' would lead outside of the repository worktree")] + OutsideOfWorktree { actual: BString, submodule: BString }, + } +} diff --git a/gix-submodule/src/lib.rs b/gix-submodule/src/lib.rs index 3a6cd994a5..dc4f340b03 100644 --- a/gix-submodule/src/lib.rs +++ b/gix-submodule/src/lib.rs @@ -1,2 +1,124 @@ +#![allow(missing_docs)] #![deny(rust_2018_idioms)] #![forbid(unsafe_code)] + +use bstr::BStr; +use std::borrow::Cow; +use std::collections::BTreeMap; + +/// All relevant information about a git module, typically from `.gitmodules` files. +/// +/// Note that overrides from other configuration might be relevant, which is why this type +/// can be used to take these into consideration when presented with other configuration +/// from the superproject. +#[derive(Clone)] +pub struct File { + config: gix_config::File<'static>, +} + +mod access; + +/// +pub mod config; + +/// Mutation +impl File { + /// This can be used to let `config` override some values we know about submodules, namely… + /// + /// * `url` + /// * `fetchRecurseSubmodules` + /// * `ignore` + /// * `update` + /// * `branch` + /// + /// These values aren't validated yet, which will happen upon query. + pub fn append_submodule_overrides(&mut self, config: &gix_config::File<'_>) -> &mut Self { + let mut values = BTreeMap::<_, Vec<_>>::new(); + for (module_name, section) in config + .sections_by_name("submodule") + .into_iter() + .flatten() + .filter_map(|s| s.header().subsection_name().map(|n| (n, s))) + { + for field in ["url", "fetchRecurseSubmodules", "ignore", "update", "branch"] { + if let Some(value) = section.value(field) { + values.entry((module_name, field)).or_default().push(value); + } + } + } + + let values = { + let mut v: Vec<_> = values.into_iter().collect(); + v.sort_by_key(|a| a.0 .0); + v + }; + + let mut config_to_append = gix_config::File::new(config.meta_owned()); + let mut prev_name = None; + let mut section = None; + for ((module_name, field), values) in values { + if prev_name.map_or(true, |pn: &BStr| pn != module_name) { + section.take(); + section = Some( + config_to_append + .new_section("submodule", Cow::Owned(module_name.to_owned())) + .expect("all names come from valid configuration, so remain valid"), + ); + prev_name = Some(module_name); + } + let section = section.as_mut().expect("always set at this point"); + section.push( + field.try_into().expect("statically known key"), + Some(values.last().expect("at least one value or we wouldn't be here")), + ); + } + + self.config.append(config_to_append); + self + } +} + +/// +mod init { + use crate::File; + use std::path::PathBuf; + + impl std::fmt::Debug for File { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("File") + .field("config_path", &self.config_path()) + .field("config", &format_args!("r#\"{}\"#", self.config)) + .finish() + } + } + + /// Lifecycle + impl File { + /// Parse `bytes` as git configuration, typically from `.gitmodules`, without doing any further validation. + /// `path` can be provided to keep track of where the file was read from in the underlying [`config`](Self::config()) + /// instance. + /// + /// Future access to the module information is lazy and configuration errors are exposed there on a per-value basis. + /// + /// ### Security Considerations + /// + /// The information itself should be used with care as it can direct the caller to fetch from remotes. It is, however, + /// on the caller to assure the input data can be trusted. + pub fn from_bytes(bytes: &[u8], path: impl Into>) -> Result { + let metadata = path.into().map_or_else(gix_config::file::Metadata::api, |path| { + gix_config::file::Metadata::from(gix_config::Source::Worktree).at(path) + }); + let config = gix_config::File::from_parse_events_no_includes( + gix_config::parse::Events::from_bytes_owned(bytes, None)?, + metadata, + ); + + Ok(Self { config }) + } + + /// Turn ourselves into the underlying parsed configuration file. + pub fn into_config(self) -> gix_config::File<'static> { + self.config + } + } +} diff --git a/gix-submodule/tests/file/baseline.rs b/gix-submodule/tests/file/baseline.rs new file mode 100644 index 0000000000..cc3c1050bf --- /dev/null +++ b/gix-submodule/tests/file/baseline.rs @@ -0,0 +1,77 @@ +use bstr::ByteSlice; +use gix_features::fs::walkdir::Parallelism; +use std::ffi::OsStr; +use std::path::PathBuf; + +#[test] +fn common_values_and_names_by_path() -> crate::Result { + let modules = module_files() + .map(|(path, stripped)| gix_submodule::File::from_bytes(&std::fs::read(path).unwrap(), stripped)) + .collect::, _>>()?; + + assert_eq!( + modules + .iter() + .map(|m| m.config_path().expect("present").to_owned()) + .collect::>(), + [ + "empty-clone/.gitmodules", + "multiple/.gitmodules", + "recursive-clone/.gitmodules", + "recursive-clone/submodule/.gitmodules", + "relative-clone/.gitmodules", + "relative-clone/submodule/.gitmodules", + "super/.gitmodules", + "super/submodule/.gitmodules", + "super-clone/.gitmodules", + "super-clone/submodule/.gitmodules", + "top-only-clone/.gitmodules" + ] + .into_iter() + .map(PathBuf::from) + .collect::>(), + "config_path() yields the path provided when instantiating (for .gitmodules), and not the path of a submodule." + ); + + assert_eq!( + { + let mut v = modules.iter().flat_map(gix_submodule::File::names).collect::>(); + v.sort(); + v.dedup(); + v + }, + [".a/..c", "a/b", "a/d\\", "a\\e", "submodule"] + .into_iter() + .map(|n| n.as_bytes().as_bstr()) + .collect::>(), + "names can be iterated" + ); + + for module in &modules { + for name in module.names() { + let path = module.path(name)?; + assert_eq!(module.name_by_path(path.as_ref()).expect("found"), name); + } + } + Ok(()) +} + +fn module_files() -> impl Iterator { + let dir = gix_testtools::scripted_fixture_read_only("basic.sh").expect("valid fixture"); + gix_features::fs::walkdir_sorted_new(&dir, Parallelism::Serial) + .follow_links(false) + .into_iter() + .filter_map(move |entry| { + let entry = entry.unwrap(); + (entry.file_name() == OsStr::new(".gitmodules")).then(|| { + ( + entry.path().to_owned(), + entry + .path() + .strip_prefix(&dir) + .expect("can only provide sub-dirs") + .to_owned(), + ) + }) + }) +} diff --git a/gix-submodule/tests/file/mod.rs b/gix-submodule/tests/file/mod.rs new file mode 100644 index 0000000000..5f04a7fe62 --- /dev/null +++ b/gix-submodule/tests/file/mod.rs @@ -0,0 +1,276 @@ +fn submodule(bytes: &str) -> gix_submodule::File { + gix_submodule::File::from_bytes(bytes.as_bytes(), None).expect("valid module") +} + +mod path { + use crate::file::submodule; + use gix_submodule::config::path::Error; + + fn submodule_path(value: &str) -> Error { + let module = submodule(&format!("[submodule.a]\npath = {value}")); + module.path("a".into()).unwrap_err() + } + + #[test] + fn valid() -> crate::Result { + let module = submodule("[submodule.a]\n path = relative/path/submodule"); + assert_eq!(module.path("a".into())?.as_ref(), "relative/path/submodule"); + Ok(()) + } + + #[test] + fn validate_upon_retrieval() { + assert!(matches!( + submodule_path(if cfg!(windows) { + "c:\\\\hello" + } else { + "/definitely/absolute\\\\" + }), + Error::Absolute { .. } + )); + assert!(matches!(submodule_path(""), Error::Missing { .. })); + assert!(matches!(submodule_path("../attack"), Error::OutsideOfWorktree { .. })); + + { + let module = submodule("[submodule.a]\n path"); + assert!(matches!(module.path("a".into()).unwrap_err(), Error::Missing { .. })); + } + + { + let module = submodule("[submodule.a]\n"); + assert!(matches!(module.path("a".into()).unwrap_err(), Error::Missing { .. })); + } + } +} + +mod url { + use crate::file::submodule; + use gix_submodule::config::url::Error; + + fn submodule_url(value: &str) -> Error { + let module = submodule(&format!("[submodule.a]\nurl = {value}")); + module.url("a".into()).unwrap_err() + } + + #[test] + fn valid() -> crate::Result { + let module = submodule("[submodule.a]\n url = path-to-repo"); + assert_eq!(module.url("a".into())?.to_bstring(), "path-to-repo"); + Ok(()) + } + + #[test] + fn validate_upon_retrieval() { + assert!(matches!(submodule_url(""), Error::Missing { .. })); + { + let module = submodule("[submodule.a]\n url"); + assert!(matches!(module.url("a".into()).unwrap_err(), Error::Missing { .. })); + } + + { + let module = submodule("[submodule.a]\n"); + assert!(matches!(module.url("a".into()).unwrap_err(), Error::Missing { .. })); + } + + assert!(matches!(submodule_url("file://"), Error::Parse { .. })); + } +} + +mod update { + use crate::file::submodule; + use gix_submodule::config::update::Error; + use gix_submodule::config::Update; + use std::str::FromStr; + + fn submodule_update(value: &str) -> Error { + let module = submodule(&format!("[submodule.a]\nupdate = {value}")); + module.update("a".into()).unwrap_err() + } + + #[test] + fn default() { + assert_eq!(Update::default(), Update::Checkout, "as defined in the docs"); + } + + #[test] + fn valid() -> crate::Result { + for (valid, expected) in [ + ("checkout", Update::Checkout), + ("rebase", Update::Rebase), + ("merge", Update::Merge), + ("none", Update::None), + ] { + let module = submodule(&format!("[submodule.a]\n update = {valid}")); + assert_eq!(module.update("a".into())?.expect("present"), expected); + } + Ok(()) + } + + #[test] + fn valid_in_overrides() -> crate::Result { + let mut module = submodule("[submodule.a]\n update = merge"); + let repo_config = gix_config::File::from_str("[submodule.a]\n update = !dangerous")?; + module.append_submodule_overrides(&repo_config); + + assert_eq!( + module.update("a".into())?.expect("present"), + Update::Command("dangerous".into()), + "overridden values are picked up and make commands possible - these are local" + ); + Ok(()) + } + + #[test] + fn validate_upon_retrieval() { + assert!(matches!(submodule_update(""), Error::Invalid { .. })); + assert!(matches!(submodule_update("bogus"), Error::Invalid { .. })); + assert!( + matches!( + submodule_update("!dangerous"), + Error::CommandForbiddenInModulesConfiguration { .. } + ), + "forbidden unless it's an override" + ); + } +} + +mod fetch_recurse { + use crate::file::submodule; + use gix_submodule::config::FetchRecurse; + + #[test] + fn default() { + assert_eq!( + FetchRecurse::default(), + FetchRecurse::OnDemand, + "as defined in git codebase actually" + ); + } + + #[test] + fn valid() -> crate::Result { + for (valid, expected) in [ + ("yes", FetchRecurse::Always), + ("true", FetchRecurse::Always), + ("", FetchRecurse::Never), + ("no", FetchRecurse::Never), + ("false", FetchRecurse::Never), + ("on-demand", FetchRecurse::OnDemand), + ] { + let module = submodule(&format!("[submodule.a]\n fetchRecurseSubmodules = {valid}")); + assert_eq!(module.fetch_recurse("a".into())?.expect("present"), expected); + } + let module = submodule("[submodule.a]\n fetchRecurseSubmodules"); + assert_eq!( + module.fetch_recurse("a".into())?.expect("present"), + FetchRecurse::Always, + "no value means true, which means to always recurse" + ); + Ok(()) + } + + #[test] + fn validate_upon_retrieval() -> crate::Result { + for invalid in ["foo", "ney", "On-demand"] { + let module = submodule(&format!("[submodule.a]\n fetchRecurseSubmodules = \"{invalid}\"")); + assert!(module.fetch_recurse("a".into()).is_err()); + } + Ok(()) + } +} + +mod ignore { + use crate::file::submodule; + use gix_submodule::config::Ignore; + + #[test] + fn default() { + assert_eq!(Ignore::default(), Ignore::None, "as defined in the docs"); + } + + #[test] + fn valid() -> crate::Result { + for (valid, expected) in [ + ("all", Ignore::All), + ("dirty", Ignore::Dirty), + ("untracked", Ignore::Untracked), + ("none", Ignore::None), + ] { + let module = submodule(&format!("[submodule.a]\n ignore = {valid}")); + assert_eq!(module.ignore("a".into())?.expect("present"), expected); + } + let module = submodule("[submodule.a]\n ignore"); + assert!( + module.ignore("a".into())?.is_none(), + "no value is interpreted as non-existing string, hence the caller will see None" + ); + Ok(()) + } + + #[test] + fn validate_upon_retrieval() -> crate::Result { + for invalid in ["All", ""] { + let module = submodule(&format!("[submodule.a]\n ignore = \"{invalid}\"")); + assert!(module.ignore("a".into()).is_err()); + } + Ok(()) + } +} + +mod branch { + use crate::file::submodule; + use gix_submodule::config::Branch; + + #[test] + fn valid() -> crate::Result { + for (valid, expected) in [ + (".", Branch::CurrentInSuperproject), + ("", Branch::Name("HEAD".into())), + ("master", Branch::Name("master".into())), + ("feature/a", Branch::Name("feature/a".into())), + ] { + let module = submodule(&format!("[submodule.a]\n branch = {valid}")); + assert_eq!(module.branch("a".into())?.expect("present"), expected); + } + let module = submodule("[submodule.a]\n branch"); + assert!( + module.branch("a".into())?.is_none(), + "no value implies it's not set, but the caller will then default" + ); + Ok(()) + } + + #[test] + fn validate_upon_retrieval() -> crate::Result { + let module = submodule("[submodule.a]\n branch = /invalid"); + assert!(module.branch("a".into()).is_err()); + Ok(()) + } +} + +#[test] +fn shallow() -> crate::Result { + let module = submodule("[submodule.a]\n shallow"); + assert_eq!( + module.shallow("a".into())?, + Some(true), + "shallow is a simple boolean without anything special (yet)" + ); + Ok(()) +} + +mod append_submodule_overrides { + use crate::file::submodule; + use std::str::FromStr; + + #[test] + fn last_of_multiple_values_wins() -> crate::Result { + let mut module = submodule("[submodule.a] url = from-module"); + let repo_config = + gix_config::File::from_str("[submodule.a]\n url = a\n url = b\n ignore = x\n [submodule.a]\n url = c\n[submodule.b] url = not-relevant")?; + module.append_submodule_overrides(&repo_config); + Ok(()) + } +} + +mod baseline; diff --git a/gix-submodule/tests/fixtures/basic.sh b/gix-submodule/tests/fixtures/basic.sh new file mode 100755 index 0000000000..d8568b8081 --- /dev/null +++ b/gix-submodule/tests/fixtures/basic.sh @@ -0,0 +1,39 @@ +#!/bin/bash +set -eu -o pipefail + +set -x +git init +touch empty && git add empty +git commit -m upstream +git clone . super +git clone super multiple +(cd multiple + git submodule add ../multiple submodule + git submodule add ../multiple a/b + git submodule add --name .a/..c ../multiple a\\c + git submodule add --name a/d\\ ../multiple a/d\\ + git submodule add --name a\\e ../multiple a/e/ + git commit -m "subsubmodule-a" +) + +(cd super + git submodule add ../multiple submodule + git commit -m "submodule" +) +git clone super super-clone +(cd super-clone + git submodule update --init --recursive +) +git clone super empty-clone +(cd empty-clone + git submodule init +) +git clone super top-only-clone +git clone super relative-clone +(cd relative-clone + git submodule update --init --recursive +) +git clone super recursive-clone +(cd recursive-clone + git submodule update --init --recursive +) diff --git a/gix-submodule/tests/fixtures/generated-archives/basic.tar.xz b/gix-submodule/tests/fixtures/generated-archives/basic.tar.xz new file mode 100644 index 0000000000..1074559a2b --- /dev/null +++ b/gix-submodule/tests/fixtures/generated-archives/basic.tar.xz @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c961fe67eb7af352064aeae412cabf8b8260782db003ddea7ca251c491c4963e +size 31404 diff --git a/gix-submodule/tests/submodule.rs b/gix-submodule/tests/submodule.rs new file mode 100644 index 0000000000..75144b10cb --- /dev/null +++ b/gix-submodule/tests/submodule.rs @@ -0,0 +1,3 @@ +use gix_testtools::Result; + +mod file; diff --git a/gix-validate/src/lib.rs b/gix-validate/src/lib.rs index fd603aeb8b..f7b6cc4aa7 100644 --- a/gix-validate/src/lib.rs +++ b/gix-validate/src/lib.rs @@ -4,8 +4,9 @@ /// pub mod reference; -pub use reference::name as refname; /// pub mod tag; -pub use tag::name as tagname; + +/// +pub mod submodule; diff --git a/gix-validate/src/reference.rs b/gix-validate/src/reference.rs index 0f32d00388..fff87e3b40 100644 --- a/gix-validate/src/reference.rs +++ b/gix-validate/src/reference.rs @@ -45,7 +45,7 @@ enum Mode { } fn validate(path: &BStr, mode: Mode) -> Result<&BStr, name::Error> { - crate::tagname(path)?; + crate::tag::name(path)?; if path[0] == b'/' { return Err(name::Error::StartsWithSlash); } diff --git a/gix-validate/src/submodule.rs b/gix-validate/src/submodule.rs new file mode 100644 index 0000000000..6811f4ff2e --- /dev/null +++ b/gix-validate/src/submodule.rs @@ -0,0 +1,32 @@ +use bstr::{BStr, ByteSlice}; + +/// +pub mod name { + /// The error used in [name()](super::name()). + #[derive(Debug, thiserror::Error)] + #[allow(missing_docs)] + pub enum Error { + #[error("Submodule names cannot be empty")] + Empty, + #[error("Submodules names must not contains '..'")] + ParentComponent, + } +} + +/// Return the original `name` if it is valid, or the respective error indicating what was wrong with it. +pub fn name(name: &BStr) -> Result<&BStr, name::Error> { + if name.is_empty() { + return Err(name::Error::Empty); + } + match name.find(b"..") { + Some(pos) => { + let &b = name.get(pos + 2).ok_or(name::Error::ParentComponent)?; + if b == b'/' || b == b'\\' { + Err(name::Error::ParentComponent) + } else { + Ok(name) + } + } + None => Ok(name), + } +} diff --git a/gix-validate/tests/all.rs b/gix-validate/tests/all.rs deleted file mode 100644 index 1b0bdf8378..0000000000 --- a/gix-validate/tests/all.rs +++ /dev/null @@ -1 +0,0 @@ -mod validate; diff --git a/gix-validate/tests/validate/reference.rs b/gix-validate/tests/reference/mod.rs similarity index 98% rename from gix-validate/tests/validate/reference.rs rename to gix-validate/tests/reference/mod.rs index f039a83129..9afbc1602f 100644 --- a/gix-validate/tests/validate/reference.rs +++ b/gix-validate/tests/reference/mod.rs @@ -90,7 +90,7 @@ mod name { ($name:ident, $input:expr) => { #[test] fn $name() { - assert!(gix_validate::refname($input.as_bstr()).is_ok()) + assert!(gix_validate::reference::name($input.as_bstr()).is_ok()) } }; } diff --git a/gix-validate/tests/submodule/mod.rs b/gix-validate/tests/submodule/mod.rs new file mode 100644 index 0000000000..1532f0458c --- /dev/null +++ b/gix-validate/tests/submodule/mod.rs @@ -0,0 +1,37 @@ +use gix_validate::submodule::name::Error; + +#[test] +fn valid() { + fn validate(name: &str) -> Result<(), Error> { + gix_validate::submodule::name(name.into()).map(|_| ()) + } + + for valid_name in ["a/./b/..[", "..a/./b/", "..a\\./b\\", "你好"] { + validate(valid_name).unwrap_or_else(|err| panic!("{valid_name} should be valid: {err:?}")); + } +} + +mod invalid { + use bstr::ByteSlice; + + macro_rules! mktest { + ($name:ident, $input:literal, $expected:ident) => { + #[test] + fn $name() { + match gix_validate::submodule::name($input.as_bstr()) { + Err(gix_validate::submodule::name::Error::$expected) => {} + got => panic!("Wanted {}, got {:?}", stringify!($expected), got), + } + } + }; + } + + mktest!(empty, b"", Empty); + mktest!(starts_with_parent_component, b"../", ParentComponent); + mktest!(parent_component_in_middle, b"hi/../ho", ParentComponent); + mktest!(ends_with_parent_component, b"hi/ho/..", ParentComponent); + mktest!(only_parent_component, b"..", ParentComponent); + mktest!(starts_with_parent_component_backslash, b"..\\", ParentComponent); + mktest!(parent_component_in_middle_backslash, b"hi\\..\\ho", ParentComponent); + mktest!(ends_with_parent_component_backslash, b"hi\\ho\\..", ParentComponent); +} diff --git a/gix-validate/tests/tag/mod.rs b/gix-validate/tests/tag/mod.rs new file mode 100644 index 0000000000..95dc2be506 --- /dev/null +++ b/gix-validate/tests/tag/mod.rs @@ -0,0 +1,81 @@ +mod name { + mod valid { + use bstr::ByteSlice; + + macro_rules! mktest { + ($name:ident, $input:expr) => { + #[test] + fn $name() { + assert!(gix_validate::tag::name($input.as_bstr()).is_ok()) + } + }; + } + + mktest!(an_at_sign, b"@"); + mktest!(chinese_utf8, "你好吗".as_bytes()); + mktest!(non_text, "😅🙌".as_bytes()); + mktest!(contains_an_at, b"hello@foo"); + mktest!(contains_dot_lock, b"file.lock.ext"); + mktest!(contains_brackets, b"this_{is-fine}_too"); + mktest!(contains_brackets_and_at, b"this_{@is-fine@}_too"); + mktest!(dot_in_the_middle, b"token.other"); + mktest!(dot_at_the_end, b"hello."); + mktest!(slash_inbetween, b"hello/world"); + } + + mod invalid { + use bstr::ByteSlice; + + macro_rules! mktest { + ($name:ident, $input:literal, $expected:ident) => { + #[test] + fn $name() { + match gix_validate::tag::name($input.as_bstr()) { + Err(gix_validate::tag::name::Error::$expected) => {} + got => panic!("Wanted {}, got {:?}", stringify!($expected), got), + } + } + }; + } + macro_rules! mktestb { + ($name:ident, $input:literal) => { + #[test] + fn $name() { + match gix_validate::tag::name($input.as_bstr()) { + Err(gix_validate::tag::name::Error::InvalidByte { .. }) => {} + got => panic!("Wanted {}, got {:?}", stringify!($expected), got), + } + } + }; + } + mktest!(contains_ref_log_portion, b"this_looks_like_a_@{reflog}", ReflogPortion); + mktest!(suffix_is_dot_lock, b"prefix.lock", LockFileSuffix); + mktest!(ends_with_slash, b"prefix/", EndsWithSlash); + mktest!(is_dot_lock, b".lock", StartsWithDot); + mktest!(contains_double_dot, b"with..double-dot", DoubleDot); + mktest!(starts_with_double_dot, b"..with-double-dot", DoubleDot); + mktest!(ends_with_double_dot, b"with-double-dot..", DoubleDot); + mktest!(starts_with_asterisk, b"*suffix", Asterisk); + mktest!(ends_with_asterisk, b"prefix*", Asterisk); + mktest!(contains_asterisk, b"prefix*suffix", Asterisk); + mktestb!(contains_null, b"prefix\0suffix"); + mktestb!(contains_bell, b"prefix\x07suffix"); + mktestb!(contains_backspace, b"prefix\x08suffix"); + mktestb!(contains_vertical_tab, b"prefix\x0bsuffix"); + mktestb!(contains_form_feed, b"prefix\x0csuffix"); + mktestb!(contains_ctrl_z, b"prefix\x1asuffix"); + mktestb!(contains_esc, b"prefix\x1bsuffix"); + mktestb!(contains_colon, b"prefix:suffix"); + mktestb!(contains_questionmark, b"prefix?suffix"); + mktestb!(contains_open_bracket, b"prefix[suffix"); + mktestb!(contains_backslash, b"prefix\\suffix"); + mktestb!(contains_circumflex, b"prefix^suffix"); + mktestb!(contains_tilde, b"prefix~suffix"); + mktestb!(contains_space, b"prefix suffix"); + mktestb!(contains_tab, b"prefix\tsuffix"); + mktestb!(contains_newline, b"prefix\nsuffix"); + mktestb!(contains_carriage_return, b"prefix\rsuffix"); + mktest!(starts_with_dot, b".with-dot", StartsWithDot); + mktest!(empty, b"", Empty); + } +} diff --git a/gix-validate/tests/validate.rs b/gix-validate/tests/validate.rs new file mode 100644 index 0000000000..db45c4aac5 --- /dev/null +++ b/gix-validate/tests/validate.rs @@ -0,0 +1,3 @@ +mod reference; +mod submodule; +mod tag; diff --git a/gix-validate/tests/validate/mod.rs b/gix-validate/tests/validate/mod.rs deleted file mode 100644 index a75d2a0768..0000000000 --- a/gix-validate/tests/validate/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -mod reference; -mod tagname; diff --git a/gix-validate/tests/validate/tagname.rs b/gix-validate/tests/validate/tagname.rs deleted file mode 100644 index 12553c5370..0000000000 --- a/gix-validate/tests/validate/tagname.rs +++ /dev/null @@ -1,79 +0,0 @@ -mod valid { - use bstr::ByteSlice; - - macro_rules! mktest { - ($name:ident, $input:expr) => { - #[test] - fn $name() { - assert!(gix_validate::tag::name($input.as_bstr()).is_ok()) - } - }; - } - - mktest!(an_at_sign, b"@"); - mktest!(chinese_utf8, "你好吗".as_bytes()); - mktest!(non_text, "😅🙌".as_bytes()); - mktest!(contains_an_at, b"hello@foo"); - mktest!(contains_dot_lock, b"file.lock.ext"); - mktest!(contains_brackets, b"this_{is-fine}_too"); - mktest!(contains_brackets_and_at, b"this_{@is-fine@}_too"); - mktest!(dot_in_the_middle, b"token.other"); - mktest!(dot_at_the_end, b"hello."); - mktest!(slash_inbetween, b"hello/world"); -} - -mod invalid { - use bstr::ByteSlice; - - macro_rules! mktest { - ($name:ident, $input:literal, $expected:ident) => { - #[test] - fn $name() { - match gix_validate::tag::name($input.as_bstr()) { - Err(gix_validate::tag::name::Error::$expected) => {} - got => panic!("Wanted {}, got {:?}", stringify!($expected), got), - } - } - }; - } - macro_rules! mktestb { - ($name:ident, $input:literal) => { - #[test] - fn $name() { - match gix_validate::tag::name($input.as_bstr()) { - Err(gix_validate::tag::name::Error::InvalidByte { .. }) => {} - got => panic!("Wanted {}, got {:?}", stringify!($expected), got), - } - } - }; - } - mktest!(contains_ref_log_portion, b"this_looks_like_a_@{reflog}", ReflogPortion); - mktest!(suffix_is_dot_lock, b"prefix.lock", LockFileSuffix); - mktest!(ends_with_slash, b"prefix/", EndsWithSlash); - mktest!(is_dot_lock, b".lock", StartsWithDot); - mktest!(contains_double_dot, b"with..double-dot", DoubleDot); - mktest!(starts_with_double_dot, b"..with-double-dot", DoubleDot); - mktest!(ends_with_double_dot, b"with-double-dot..", DoubleDot); - mktest!(starts_with_asterisk, b"*suffix", Asterisk); - mktest!(ends_with_asterisk, b"prefix*", Asterisk); - mktest!(contains_asterisk, b"prefix*suffix", Asterisk); - mktestb!(contains_null, b"prefix\0suffix"); - mktestb!(contains_bell, b"prefix\x07suffix"); - mktestb!(contains_backspace, b"prefix\x08suffix"); - mktestb!(contains_vertical_tab, b"prefix\x0bsuffix"); - mktestb!(contains_form_feed, b"prefix\x0csuffix"); - mktestb!(contains_ctrl_z, b"prefix\x1asuffix"); - mktestb!(contains_esc, b"prefix\x1bsuffix"); - mktestb!(contains_colon, b"prefix:suffix"); - mktestb!(contains_questionmark, b"prefix?suffix"); - mktestb!(contains_open_bracket, b"prefix[suffix"); - mktestb!(contains_backslash, b"prefix\\suffix"); - mktestb!(contains_circumflex, b"prefix^suffix"); - mktestb!(contains_tilde, b"prefix~suffix"); - mktestb!(contains_space, b"prefix suffix"); - mktestb!(contains_tab, b"prefix\tsuffix"); - mktestb!(contains_newline, b"prefix\nsuffix"); - mktestb!(contains_carriage_return, b"prefix\rsuffix"); - mktest!(starts_with_dot, b".with-dot", StartsWithDot); - mktest!(empty, b"", Empty); -} diff --git a/gix/src/clone/fetch/mod.rs b/gix/src/clone/fetch/mod.rs index e20cc96cb4..227281eb46 100644 --- a/gix/src/clone/fetch/mod.rs +++ b/gix/src/clone/fetch/mod.rs @@ -26,7 +26,7 @@ pub enum Error { SaveConfigIo(#[from] std::io::Error), #[error("The remote HEAD points to a reference named {head_ref_name:?} which is invalid.")] InvalidHeadRef { - source: gix_validate::refname::Error, + source: gix_validate::reference::name::Error, head_ref_name: crate::bstr::BString, }, #[error("Failed to update HEAD with values from remote")] diff --git a/gix/src/config/cache/util.rs b/gix/src/config/cache/util.rs index fa144d0716..a7c72a7713 100644 --- a/gix/src/config/cache/util.rs +++ b/gix/src/config/cache/util.rs @@ -51,7 +51,7 @@ pub(crate) fn query_refupdates( ) -> Result, Error> { let key = "core.logAllRefUpdates"; Core::LOG_ALL_REF_UPDATES - .try_into_ref_updates(config.boolean_by_key(key), || config.string_by_key(key)) + .try_into_ref_updates(config.boolean_by_key(key)) .with_leniency(lenient_config) .map_err(Into::into) } diff --git a/gix/src/config/tree/sections/core.rs b/gix/src/config/tree/sections/core.rs index b450346a08..f6903737c5 100644 --- a/gix/src/config/tree/sections/core.rs +++ b/gix/src/config/tree/sections/core.rs @@ -301,30 +301,27 @@ mod disambiguate { } mod log_all_ref_updates { - use std::borrow::Cow; - - use crate::{bstr::BStr, config, config::tree::core::LogAllRefUpdates}; + use crate::{config, config::tree::core::LogAllRefUpdates}; impl LogAllRefUpdates { - /// Returns the mode for ref-updates as parsed from `value`. If `value` is not a boolean, `string_on_failure` will be called - /// to obtain the key `core.logAllRefUpdates` as string instead. For correctness, this two step process is necessary as + /// Returns the mode for ref-updates as parsed from `value`. If `value` is not a boolean, we try + /// to interpret the string value instead. For correctness, this two step process is necessary as /// the interpretation of booleans in special in `gix-config`, i.e. we can't just treat it as string. - pub fn try_into_ref_updates<'a>( + pub fn try_into_ref_updates( &'static self, value: Option>, - string_on_failure: impl FnOnce() -> Option>, ) -> Result, config::key::GenericErrorWithValue> { - match value.transpose().ok().flatten() { - Some(bool) => Ok(Some(if bool { + match value { + Some(Ok(bool)) => Ok(Some(if bool { gix_ref::store::WriteReflog::Normal } else { gix_ref::store::WriteReflog::Disable })), - None => match string_on_failure() { - Some(val) if val.eq_ignore_ascii_case(b"always") => Ok(Some(gix_ref::store::WriteReflog::Always)), - Some(val) => Err(config::key::GenericErrorWithValue::from_value(self, val.into_owned())), - None => Ok(None), + Some(Err(err)) => match err.input { + val if val.eq_ignore_ascii_case(b"always") => Ok(Some(gix_ref::store::WriteReflog::Always)), + val => Err(config::key::GenericErrorWithValue::from_value(self, val)), }, + None => Ok(None), } } } @@ -438,9 +435,7 @@ mod validate { impl keys::Validate for LogAllRefUpdates { fn validate(&self, value: &BStr) -> Result<(), Box> { super::Core::LOG_ALL_REF_UPDATES - .try_into_ref_updates(Some(gix_config::Boolean::try_from(value).map(|b| b.0)), || { - Some(value.into()) - })?; + .try_into_ref_updates(Some(gix_config::Boolean::try_from(value).map(|b| b.0)))?; Ok(()) } } diff --git a/gix/src/config/tree/sections/diff.rs b/gix/src/config/tree/sections/diff.rs index 103bb7001f..7c467b8f52 100644 --- a/gix/src/config/tree/sections/diff.rs +++ b/gix/src/config/tree/sections/diff.rs @@ -68,10 +68,8 @@ mod algorithm { } mod renames { - use std::borrow::Cow; - use crate::{ - bstr::{BStr, ByteSlice}, + bstr::ByteSlice, config::{ key::GenericError, tree::{keys, sections::diff::Renames, Section}, @@ -84,21 +82,20 @@ mod renames { pub const fn new_renames(name: &'static str, section: &'static dyn Section) -> Self { keys::Any::new_with_validate(name, section, super::validate::Renames) } - /// Try to convert the configuration into a valid rename tracking variant. Use `value` and if it's an error, call `value_string` - /// to try and interpret the key as string. - pub fn try_into_renames<'a>( + /// Try to convert the configuration into a valid rename tracking variant. Use `value` and if it's an error, interpret + /// the boolean as string + pub fn try_into_renames( &'static self, value: Result, - value_string: impl FnOnce() -> Option>, ) -> Result { Ok(match value { Ok(true) => Tracking::Renames, Ok(false) => Tracking::Disabled, Err(err) => { - let value = value_string().ok_or_else(|| GenericError::from(self))?; - match value.as_ref().as_bytes() { + let value = &err.input; + match value.as_bytes() { b"copy" | b"copies" => Tracking::RenamesAndCopies, - _ => return Err(GenericError::from_value(self, value.into_owned()).with_source(err)), + _ => return Err(GenericError::from_value(self, value.clone()).with_source(err)), } } }) @@ -107,8 +104,6 @@ mod renames { } mod validate { - use std::borrow::Cow; - use crate::{ bstr::BStr, config::tree::{keys, Diff}, @@ -126,7 +121,7 @@ mod validate { impl keys::Validate for Renames { fn validate(&self, value: &BStr) -> Result<(), Box> { let boolean = gix_config::Boolean::try_from(value).map(|b| b.0); - Diff::RENAMES.try_into_renames(boolean, || Some(Cow::Borrowed(value)))?; + Diff::RENAMES.try_into_renames(boolean)?; Ok(()) } } diff --git a/gix/src/filter.rs b/gix/src/filter.rs index 3ebdc52740..073ea6328b 100644 --- a/gix/src/filter.rs +++ b/gix/src/filter.rs @@ -199,30 +199,31 @@ impl<'repo> Pipeline<'repo> { /// Obtain a list of all configured driver, but ignore those in sections that we don't trust enough. fn extract_drivers(repo: &Repository) -> Result, pipeline::options::Error> { - Ok(match repo.config.resolved.sections_by_name("filter") { - None => Vec::new(), - Some(sections) => sections - .filter(|s| repo.filter_config_section()(s.meta())) - .filter_map(|s| { - s.header().subsection_name().map(|name| { - Ok(gix_filter::Driver { - name: name.to_owned(), - clean: s.value("clean").map(Cow::into_owned), - smudge: s.value("smudge").map(Cow::into_owned), - process: s.value("process").map(Cow::into_owned), - required: s - .value("required") - .map(|value| gix_config::Boolean::try_from(value.as_ref())) - .transpose() - .map_err(|err| pipeline::options::Error::Driver { - name: name.to_owned(), - source: err, - })? - .unwrap_or_default() - .into(), - }) + repo.config + .resolved + .sections_by_name("filter") + .into_iter() + .flatten() + .filter(|s| repo.filter_config_section()(s.meta())) + .filter_map(|s| { + s.header().subsection_name().map(|name| { + Ok(gix_filter::Driver { + name: name.to_owned(), + clean: s.value("clean").map(Cow::into_owned), + smudge: s.value("smudge").map(Cow::into_owned), + process: s.value("process").map(Cow::into_owned), + required: s + .value("required") + .map(|value| gix_config::Boolean::try_from(value.as_ref())) + .transpose() + .map_err(|err| pipeline::options::Error::Driver { + name: name.to_owned(), + source: err, + })? + .unwrap_or_default() + .into(), }) }) - .collect::, pipeline::options::Error>>()?, - }) + }) + .collect::, pipeline::options::Error>>() } diff --git a/gix/src/init.rs b/gix/src/init.rs index d04de08067..bffd5fc5b8 100644 --- a/gix/src/init.rs +++ b/gix/src/init.rs @@ -29,7 +29,7 @@ pub enum Error { #[error("Invalid default branch name: {name:?}")] InvalidBranchName { name: BString, - source: gix_validate::refname::Error, + source: gix_validate::reference::name::Error, }, #[error("Could not edit HEAD reference with new default name")] EditHeadForDefaultBranch(#[from] crate::reference::edit::Error), diff --git a/gix/src/object/tree/diff/rewrites.rs b/gix/src/object/tree/diff/rewrites.rs index 1502048ece..e434726d9e 100644 --- a/gix/src/object/tree/diff/rewrites.rs +++ b/gix/src/object/tree/diff/rewrites.rs @@ -80,7 +80,7 @@ impl Rewrites { let key = "diff.renames"; let copies = match config .boolean_by_key(key) - .map(|value| Diff::RENAMES.try_into_renames(value, || config.string_by_key(key))) + .map(|value| Diff::RENAMES.try_into_renames(value)) .transpose() .with_leniency(lenient)? { diff --git a/gix/src/remote/connection/fetch/update_refs/update.rs b/gix/src/remote/connection/fetch/update_refs/update.rs index 7e31effab6..41ed3753d6 100644 --- a/gix/src/remote/connection/fetch/update_refs/update.rs +++ b/gix/src/remote/connection/fetch/update_refs/update.rs @@ -10,7 +10,7 @@ mod error { #[error(transparent)] FindReference(#[from] crate::reference::find::Error), #[error("A remote reference had a name that wasn't considered valid. Corrupt remote repo or insufficient checks on remote?")] - InvalidRefName(#[from] gix_validate::refname::Error), + InvalidRefName(#[from] gix_validate::reference::name::Error), #[error("Failed to update references to their new position to match their remote locations")] EditReferences(#[from] crate::reference::edit::Error), #[error("Failed to read or iterate worktree dir")] diff --git a/gix/src/repository/reference.rs b/gix/src/repository/reference.rs index 7d37f4f9c0..d34c790879 100644 --- a/gix/src/repository/reference.rs +++ b/gix/src/repository/reference.rs @@ -60,10 +60,10 @@ impl crate::Repository { pub fn set_namespace<'a, Name, E>( &mut self, namespace: Name, - ) -> Result, gix_validate::refname::Error> + ) -> Result, gix_validate::reference::name::Error> where Name: TryInto<&'a PartialNameRef, Error = E>, - gix_validate::refname::Error: From, + gix_validate::reference::name::Error: From, { let namespace = gix_ref::namespace::expand(namespace)?; Ok(self.refs.namespace.replace(namespace)) diff --git a/gix/tests/config/tree.rs b/gix/tests/config/tree.rs index 661ee46d67..867d3ba672 100644 --- a/gix/tests/config/tree.rs +++ b/gix/tests/config/tree.rs @@ -189,32 +189,24 @@ mod diff { #[test] fn renames() -> crate::Result { - assert_eq!( - Diff::RENAMES.try_into_renames(Ok(true), || unreachable!())?, - Tracking::Renames - ); + assert_eq!(Diff::RENAMES.try_into_renames(Ok(true))?, Tracking::Renames); assert!(Diff::RENAMES.validate("1".into()).is_ok()); - assert_eq!( - Diff::RENAMES.try_into_renames(Ok(false), || unreachable!())?, - Tracking::Disabled - ); + assert_eq!(Diff::RENAMES.try_into_renames(Ok(false))?, Tracking::Disabled); assert!(Diff::RENAMES.validate("0".into()).is_ok()); assert_eq!( - Diff::RENAMES.try_into_renames(Err(gix_config::value::Error::new("err", "err")), || Some(bcow("copy")))?, + Diff::RENAMES.try_into_renames(Err(gix_config::value::Error::new("err", "copy")))?, Tracking::RenamesAndCopies ); assert!(Diff::RENAMES.validate("copy".into()).is_ok()); assert_eq!( - Diff::RENAMES.try_into_renames(Err(gix_config::value::Error::new("err", "err")), || Some(bcow( - "copies" - )))?, + Diff::RENAMES.try_into_renames(Err(gix_config::value::Error::new("err", "copies")))?, Tracking::RenamesAndCopies ); assert!(Diff::RENAMES.validate("copies".into()).is_ok()); assert_eq!( Diff::RENAMES - .try_into_renames(Err(gix_config::value::Error::new("err", "err")), || Some(bcow("foo"))) + .try_into_renames(Err(gix_config::value::Error::new("err", "foo"))) .unwrap_err() .to_string(), "The value of key \"diff.renames=foo\" was invalid" @@ -322,23 +314,28 @@ mod core { #[test] fn log_all_ref_updates() -> crate::Result { assert_eq!( - Core::LOG_ALL_REF_UPDATES.try_into_ref_updates(Some(Ok(true)), || None)?, + Core::LOG_ALL_REF_UPDATES.try_into_ref_updates(Some(Ok(true)),)?, Some(gix_ref::store::WriteReflog::Normal) ); assert!(Core::LOG_ALL_REF_UPDATES.validate("true".into()).is_ok()); assert_eq!( - Core::LOG_ALL_REF_UPDATES.try_into_ref_updates(Some(Ok(false)), || None)?, + Core::LOG_ALL_REF_UPDATES.try_into_ref_updates(Some(Ok(false)),)?, Some(gix_ref::store::WriteReflog::Disable) ); assert!(Core::LOG_ALL_REF_UPDATES.validate("0".into()).is_ok()); + let boolean = |value| { + gix_config::Boolean::try_from(bcow(value)) + .map(|b| Some(b.0)) + .transpose() + }; assert_eq!( - Core::LOG_ALL_REF_UPDATES.try_into_ref_updates(None, || Some(bcow("always")))?, + Core::LOG_ALL_REF_UPDATES.try_into_ref_updates(boolean("always"))?, Some(gix_ref::store::WriteReflog::Always) ); assert!(Core::LOG_ALL_REF_UPDATES.validate("always".into()).is_ok()); assert_eq!( Core::LOG_ALL_REF_UPDATES - .try_into_ref_updates(None, || Some(bcow("invalid"))) + .try_into_ref_updates(boolean("invalid")) .unwrap_err() .to_string(), "The key \"core.logAllRefUpdates=invalid\" was invalid" diff --git a/src/plumbing/progress.rs b/src/plumbing/progress.rs index 0d9df2e853..08c3e70f60 100644 --- a/src/plumbing/progress.rs +++ b/src/plumbing/progress.rs @@ -210,7 +210,23 @@ static GIT_CONFIG: &[Record] = &[ }, Record { config: "submodule.recurse", - usage: Planned {note: Some("very relevant for doing the right thing during checkouts")}, + usage: Planned {note: Some("very relevant for doing the right thing during checkouts. Note that 'clone' isnt' affected by it, even though we could make it so for good measure.")}, + }, + Record { + config: "submodule.propagateBranches", + usage: NotPlanned {reason: "it is experimental, let's see how it pans out"} + }, + Record { + config: "submodule.alternateLocation", + usage: NotPlanned {reason: "not currently supported when we clone either"} + }, + Record { + config: "submodule.alternateErrorStrategy", + usage: NotPlanned {reason: "not currently supported when we clone either"} + }, + Record { + config: "submodule.fetchJobs", + usage: Planned {note: Some("relevant for fetching")}, }, Record { config: "branch.autoSetupRebase",