use std::{
fmt::Display,
path::{Path, PathBuf},
rc::Rc,
str::FromStr,
};
use regex::Regex;
use serde::{Deserialize, Serialize};
use serde_regex;
use serde_tuple::Deserialize_tuple;
use url::Url;
use crate::{
error::{Error as AppError, Result},
item::Operate,
};
#[derive(Clone, Debug, Default, Deserialize, PartialEq)]
pub struct GroupName(pub PathBuf);
impl Display for GroupName {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0.to_string_lossy())
}
}
impl GroupName {
pub fn main(&self) -> String {
let first_comp: PathBuf = self.0.components().take(1).collect();
first_comp.to_string_lossy().to_string()
}
pub fn validate(&self) -> Result<()> {
if self
.0
.components()
.any(|comp| comp.as_os_str().to_string_lossy() == "..")
{
Err(AppError::ConfigError(
"Group name should not contain relative component".to_owned(),
))
} else if self.0.starts_with("/") {
Err(AppError::ConfigError(
"Group name should not start with slash".to_owned(),
))
} else if self.0 == PathBuf::from_str("").unwrap() {
Err(AppError::ConfigError(
"Group name should not be empty".to_owned(),
))
} else {
Ok(())
}
}
pub fn with_subgroup_prefix(&self, subgroup_prefix: &str) -> PathBuf {
PathBuf::from(self.main()).join(
self.0
.iter()
.skip(1)
.map(|comp| subgroup_prefix.to_owned() + &comp.to_string_lossy())
.collect::<PathBuf>(),
)
}
}
#[derive(Clone, Debug, Deserialize, PartialEq)]
pub struct StagingPath(pub PathBuf);
impl Default for StagingPath {
fn default() -> Self {
if let Some(cache_dir) = dirs::data_dir() {
Self(cache_dir.join("dt").join("staging"))
} else {
panic!("Cannot infer default staging directory, set either XDG_DATA_HOME or HOME to solve this.");
}
}
}
#[derive(Clone, Copy, Debug, Deserialize, PartialEq)]
pub enum SyncMethod {
Copy,
Symlink,
}
impl Default for SyncMethod {
fn default() -> Self {
SyncMethod::Symlink
}
}
#[derive(Clone, Debug, Deserialize)]
pub struct SubgroupPrefix(pub String);
impl Default for SubgroupPrefix {
fn default() -> Self {
Self("#".to_owned())
}
}
#[derive(Clone, Copy, Debug, Deserialize)]
pub struct AllowOverwrite(pub bool);
#[allow(clippy::derivable_impls)]
impl Default for AllowOverwrite {
fn default() -> Self {
Self(false)
}
}
#[derive(Clone, Copy, Debug, Deserialize)]
pub struct IgnoreFailure(pub bool);
#[allow(clippy::derivable_impls)]
impl Default for IgnoreFailure {
fn default() -> Self {
Self(false)
}
}
#[derive(Clone, Copy, Debug, Deserialize)]
pub struct Renderable(pub bool);
#[allow(clippy::derivable_impls)]
impl Default for Renderable {
fn default() -> Self {
Self(true)
}
}
#[derive(Clone, Debug, Deserialize)]
pub struct HostnameSeparator(pub String);
impl Default for HostnameSeparator {
fn default() -> Self {
Self("@@".to_owned())
}
}
#[derive(Clone, Debug, Deserialize)]
pub struct RenamingRules(pub Vec<RenamingRule>);
#[allow(clippy::derivable_impls)]
impl Default for RenamingRules {
fn default() -> Self {
Self(Vec::new())
}
}
#[derive(Clone, Debug, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
pub enum DTScope {
General,
App,
Dropin,
}
impl Default for DTScope {
fn default() -> Self {
DTScope::General
}
}
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(default)]
pub struct DTConfig {
pub global: GlobalConfig,
pub context: ContextConfig,
pub local: Vec<LocalGroup>,
pub remote: Vec<RemoteGroup>,
}
impl FromStr for DTConfig {
type Err = AppError;
fn from_str(s: &str) -> Result<Self> {
toml::from_str::<Self>(s)?.expand_tilde().validate()
}
}
impl DTConfig {
pub fn from_path(path: impl AsRef<Path>) -> Result<Self> {
let path = path.as_ref();
if let Ok(confstr) = std::fs::read_to_string(path) {
Self::from_str(&confstr)
} else {
Err(AppError::ConfigError(format!(
"Could not load config from '{}'",
path.display(),
)))
}
}
pub fn filter_names(self, group_names: Vec<String>) -> Self {
Self {
global: self.global,
context: self.context,
local: self
.local
.iter()
.filter(|l| group_names.iter().any(|n| l.name.0.starts_with(n)))
.map(|l| l.to_owned())
.collect(),
remote: self
.remote
.iter()
.filter(|l| group_names.iter().any(|n| l.name.0.starts_with(n)))
.map(|l| l.to_owned())
.collect(),
}
}
fn validate(self) -> Result<Self> {
if !self.context.0.is_table() {
return Err(AppError::ConfigError(
"`context` is expected to be a table".to_owned(),
));
}
let global_ref = Rc::new(self.global.to_owned());
let context_ref = Rc::new(self.context.to_owned());
let mut ret: Self = self;
for group in &mut ret.local {
group.global = Rc::clone(&global_ref);
group.context = Rc::clone(&context_ref);
group.validate()?;
}
for group in &mut ret.remote {
group.global = Rc::clone(&global_ref);
group.context = Rc::clone(&context_ref);
group.validate()?;
}
Ok(ret)
}
fn expand_tilde(self) -> Self {
let mut ret = self;
let staging = &mut ret.global.staging;
*staging = if *staging == StagingPath("".into()) {
log::warn!("Empty staging path is replaced to '.'");
StagingPath(".".into())
} else {
StagingPath(
PathBuf::from_str(&shellexpand::tilde(&staging.0.to_string_lossy())).unwrap(),
)
};
for group in &mut ret.local {
group.base = if group.base == PathBuf::from_str("").unwrap() {
log::warn!("[{}]: Empty base is replaced to '.'", group.name);
".".into()
} else {
PathBuf::from_str(&shellexpand::tilde(&group.base.to_string_lossy())).unwrap()
};
group.target = if group.target == PathBuf::from_str("").unwrap() {
log::warn!("[{}]: Empty target is replaced to '.'", group.name,);
".".into()
} else {
PathBuf::from_str(&shellexpand::tilde(&group.target.to_string_lossy())).unwrap()
};
}
ret
}
}
#[derive(Clone, Debug, Deserialize_tuple)]
pub struct RenamingRule {
#[serde(deserialize_with = "serde_regex::deserialize")]
pub pattern: Regex,
pub substitution: String,
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct GlobalConfig {
#[serde(default)]
pub staging: StagingPath,
#[serde(default)]
pub method: SyncMethod,
#[serde(default)]
pub subgroup_prefix: SubgroupPrefix,
#[serde(default)]
pub allow_overwrite: AllowOverwrite,
#[serde(default)]
pub ignore_failure: IgnoreFailure,
#[serde(default)]
pub renderable: Renderable,
#[serde(default)]
pub hostname_sep: HostnameSeparator,
#[serde(default)]
pub rename: RenamingRules,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct ContextConfig(toml::Value);
impl Default for ContextConfig {
fn default() -> Self {
Self(toml::map::Map::new().into())
}
}
#[derive(Default, Clone, Deserialize, Debug)]
pub struct Group<T>
where
T: Operate,
{
#[serde(skip_deserializing)]
pub global: Rc<GlobalConfig>,
#[serde(skip_deserializing)]
pub context: Rc<ContextConfig>,
pub name: GroupName,
#[serde(default)]
pub scope: DTScope,
pub base: T,
pub sources: Vec<T>,
pub target: PathBuf,
pub ignored: Option<RenamingRules>,
pub hostname_sep: Option<HostnameSeparator>,
pub allow_overwrite: Option<AllowOverwrite>,
pub ignore_failure: Option<IgnoreFailure>,
#[serde(default)]
pub renderable: Option<Renderable>,
pub method: Option<SyncMethod>,
pub subgroup_prefix: Option<SubgroupPrefix>,
#[serde(default)]
pub rename: RenamingRules,
}
impl<T> Group<T>
where
T: Operate,
{
pub fn is_overwrite_allowed(&self) -> bool {
match self.allow_overwrite {
Some(AllowOverwrite(allow_overwrite)) => allow_overwrite,
_ => self.global.allow_overwrite.0,
}
}
pub fn is_failure_ignored(&self) -> bool {
match self.ignore_failure {
Some(IgnoreFailure(ignore_failure)) => ignore_failure,
_ => self.global.ignore_failure.0,
}
}
pub fn get_staging_dir(&self) -> PathBuf {
self.global
.staging
.0
.join(self.name.with_subgroup_prefix(&self.get_subgroup_prefix()))
}
pub fn get_method(&self) -> SyncMethod {
match self.method {
Some(method) => method,
_ => self.global.method,
}
}
pub fn get_subgroup_prefix(&self) -> String {
match &self.subgroup_prefix {
Some(prefix) => prefix.0.to_owned(),
_ => self.global.subgroup_prefix.0.to_owned(),
}
}
pub fn get_hostname_sep(&self) -> String {
match &self.hostname_sep {
Some(hostname_sep) => hostname_sep.0.to_owned(),
_ => self.global.hostname_sep.0.to_owned(),
}
}
pub fn get_renaming_rules(&self) -> Vec<RenamingRule> {
let mut ret: Vec<RenamingRule> = Vec::new();
for r in &self.global.rename.0 {
ret.push(r.to_owned());
}
for r in &self.rename.0 {
ret.push(r.to_owned());
}
ret
}
pub fn is_renderable(&self) -> bool {
match self.renderable {
Some(Renderable(renderable)) => renderable,
_ => self.global.renderable.0,
}
}
fn _validate_no_fs_query(&self) -> Result<()> {
self.name.validate()?;
if self.sources.iter().any(|s| s.is_twisted()) {
return Err(AppError::ConfigError(format!(
"source item references parent directory in group '{}'",
self.name,
)));
}
if self.ignored.is_some() {
todo!("`ignored` array works poorly and I decided to implement it in the future");
}
Ok(())
}
fn _validate_with_fs_query(&self) -> Result<()> {
if self.get_method() == SyncMethod::Symlink {
let staging_path: PathBuf = self.global.staging.0.to_owned();
if staging_path.exists() && !staging_path.is_dir() {
return Err(AppError::ConfigError(
"staging root path exists but is not a valid directory".to_owned(),
));
}
if !staging_path.exists() && staging_path.absolute()?.is_parent_readonly() {
return Err(AppError::ConfigError(
"staging root path cannot be created due to insufficient permissions"
.to_owned(),
));
}
}
if self.target.exists() && !self.target.is_dir() {
return Err(AppError::ConfigError(format!(
"target path exists but is not a valid directory in group '{}'",
self.name,
)));
}
if !self.target.exists() && self.target.to_owned().absolute()?.is_parent_readonly() {
return Err(AppError::ConfigError(format!(
"target path cannot be created due to insufficient permissions in group '{}'",
self.name,
)));
}
Ok(())
}
}
pub type LocalGroup = Group<PathBuf>;
impl LocalGroup {
pub fn validate(&self) -> Result<()> {
self._validate_no_fs_query()?;
if self.base == self.target {
return Err(AppError::ConfigError(format!(
"base directory and its target are the same in group '{}'",
self.name,
)));
}
let hostname_sep = self.get_hostname_sep();
if self.base.to_string_lossy().contains(&hostname_sep) {
return Err(AppError::ConfigError(format!(
"base directory contains hostname_sep ({}) in group '{}'",
hostname_sep, self.name,
)));
}
if self
.sources
.iter()
.any(|s| s.starts_with("/") || s.starts_with("~"))
{
return Err(AppError::ConfigError(format!(
"source array contains absolute path in group '{}'",
self.name,
)));
}
if self.sources.iter().any(|s| {
s.to_str()
.unwrap()
.split('/')
.any(|component| component == ".*")
}) {
log::error!(
"'.*' is prohibited for globbing sources because it also matches the parent directory.",
);
log::error!(
"If you want to match all items that starts with a dot, use ['.[!.]*', '..?*'] as sources.",
);
return Err(AppError::ConfigError("bad globbing pattern".to_owned()));
}
if self.sources.iter().any(|s| {
let s = s.to_string_lossy();
s.contains(&hostname_sep)
}) {
return Err(AppError::ConfigError(format!(
"a source item contains hostname_sep ({}) in group '{}'",
hostname_sep, self.name,
)));
}
self._validate_with_fs_query()?;
if self.base.exists() {
if let Err(e) = std::fs::read_dir(&self.base) {
log::error!("Could not read base '{}'", self.base.display());
return Err(e.into());
}
}
Ok(())
}
}
pub type RemoteGroup = Group<Url>;
impl RemoteGroup {
fn validate(&self) -> Result<()> {
self._validate_no_fs_query()?;
self._validate_with_fs_query()?;
Ok(())
}
}
#[cfg(test)]
mod overriding_global {
use std::str::FromStr;
use super::{DTConfig, SyncMethod};
use color_eyre::Report;
use pretty_assertions::assert_eq;
#[test]
fn allow_overwrite_no_global() -> Result<(), Report> {
let config = DTConfig::from_str(
r#"
[[local]]
name = "placeholder"
base = "~"
sources = ["*"]
target = "."
allow_overwrite = true"#,
)?;
for group in config.local {
assert_eq!(group.is_overwrite_allowed(), true);
}
Ok(())
}
#[test]
fn allow_overwrite_with_global() -> Result<(), Report> {
let config = DTConfig::from_str(
r#"
[global]
method = "Copy" # Default value because not testing this key
allow_overwrite = true
[[local]]
name = "placeholder"
base = "~"
sources = ["*"]
target = "."
allow_overwrite = false"#,
)?;
for group in config.local {
assert_eq!(group.is_overwrite_allowed(), false);
}
Ok(())
}
#[test]
fn both_allow_overwrite_and_method_no_global() -> Result<(), Report> {
let config = DTConfig::from_str(
r#"
[[local]]
name = "placeholder"
base = "~"
sources = ["*"]
target = "."
method = "Copy"
allow_overwrite = true"#,
)?;
for group in config.local {
assert_eq!(group.get_method(), SyncMethod::Copy);
assert_eq!(group.is_overwrite_allowed(), true);
}
Ok(())
}
#[test]
fn both_allow_overwrite_and_method_with_global() -> Result<(), Report> {
let config = DTConfig::from_str(
r#"
[global]
method = "Copy"
allow_overwrite = true
[[local]]
name = "placeholder"
base = "~"
sources = ["*"]
target = "."
method = "Symlink"
allow_overwrite = false"#,
)?;
for group in config.local {
assert_eq!(group.get_method(), SyncMethod::Symlink);
assert_eq!(group.is_overwrite_allowed(), false);
}
Ok(())
}
#[test]
fn hostname_sep_no_global() -> Result<(), Report> {
let config = DTConfig::from_str(
r#"
[[local]]
name = "hostname_sep no global test"
hostname_sep = "@-@"
base = "~"
sources = []
target = ".""#,
)?;
for group in config.local {
assert_eq!(group.get_hostname_sep(), "@-@");
}
Ok(())
}
#[test]
fn hostname_sep_with_global() -> Result<(), Report> {
let config = DTConfig::from_str(
r#"
[global]
hostname_sep = "@-@"
[[local]]
name = "hostname_sep fall back to global"
base = "~"
sources = []
target = ".""#,
)?;
for group in config.local {
assert_eq!(group.get_hostname_sep(), "@-@");
}
Ok(())
}
#[test]
fn method_no_global() -> Result<(), Report> {
let config = DTConfig::from_str(
r#"
[[local]]
name = "placeholder"
base = "~"
sources = ["*"]
target = "."
method = "Copy""#,
)?;
for group in config.local {
assert_eq!(group.get_method(), SyncMethod::Copy)
}
Ok(())
}
#[test]
fn method_with_global() -> Result<(), Report> {
let config = DTConfig::from_str(
r#"
[global]
method = "Copy"
allow_overwrite = false # Default value because not testing this key
[[local]]
name = "placeholder"
base = "~"
sources = ["*"]
target = "."
method = "Symlink""#,
)?;
for group in config.local {
assert_eq!(group.get_method(), SyncMethod::Symlink)
}
Ok(())
}
}
#[cfg(test)]
mod tilde_expansion {
use std::str::FromStr;
use color_eyre::Report;
use pretty_assertions::assert_eq;
use super::DTConfig;
#[test]
fn all() -> Result<(), Report> {
let config = DTConfig::from_str(
r#"
[global]
staging = "~"
method = "Symlink"
allow_overwrite = false
[[local]]
name = "expand tilde in base and target"
base = "~"
sources = []
target = "~/dt/target""#,
)?;
dbg!(&config.global.staging.0);
assert_eq!(Some(config.global.staging.0), dirs::home_dir());
config.local.iter().all(|group| {
dbg!(&group.base);
dbg!(&group.target);
assert_eq!(Some(group.to_owned().base), dirs::home_dir());
assert_eq!(
Some(group.to_owned().target),
dirs::home_dir()
.map(|p| p.join("dt"))
.map(|p| p.join("target")),
);
true
});
Ok(())
}
}
#[cfg(test)]
mod validation {
use std::str::FromStr;
use color_eyre::{eyre::eyre, Report};
use pretty_assertions::assert_eq;
use super::DTConfig;
use crate::error::Error as AppError;
#[test]
fn relative_component_in_group_name() -> Result<(), Report> {
if let Err(err) = DTConfig::from_str(
r#"
[[local]]
name = "a/../b"
base = "~"
sources = []
target = ".""#,
) {
assert_eq!(
err,
AppError::ConfigError(
"Group name should not contain relative component".to_owned(),
),
"{}",
err,
);
Ok(())
} else {
Err(eyre!("This config should not be loaded because a group's name contains relative component"))
}
}
#[test]
fn prefix_slash_in_group_name() -> Result<(), Report> {
if let Err(err) = DTConfig::from_str(
r#"
[[local]]
name = "/a/b/c/d"
base = "~"
sources = []
target = ".""#,
) {
assert_eq!(
err,
AppError::ConfigError("Group name should not start with slash".to_owned(),),
"{}",
err,
);
Ok(())
} else {
Err(eyre!(
"This config should not be loaded because a group's name starts with a slash"
))
}
}
#[test]
fn empty_group_name() -> Result<(), Report> {
if let Err(err) = DTConfig::from_str(
r#"
[[local]]
name = ""
base = "~"
sources = []
target = ".""#,
) {
assert_eq!(
err,
AppError::ConfigError("Group name should not be empty".to_owned(),),
"{}",
err,
);
Ok(())
} else {
Err(eyre!(
"This config should not be loaded because a group's name is empty"
))
}
}
#[test]
fn base_is_target() -> Result<(), Report> {
if let Err(err) = DTConfig::from_str(
r#"
[[local]]
name = "base is target"
base = "~"
sources = []
target = "~""#,
) {
assert_eq!(
err,
AppError::ConfigError(
"base directory and its target are the same in group 'base is target'"
.to_owned(),
),
"{}",
err,
);
Ok(())
} else {
Err(eyre!(
"This config should not be loaded because base and target are the same"
))
}
}
#[test]
fn base_contains_hostname_sep() -> Result<(), Report> {
if let Err(err) = DTConfig::from_str(
r#"
[[local]]
name = "base contains hostname_sep"
hostname_sep = "@@"
base = "~/.config/sytemd/user@@elbert"
sources = []
target = ".""#,
) {
assert_eq!(
err,
AppError::ConfigError(
"base directory contains hostname_sep (@@) in group 'base contains hostname_sep'"
.to_owned(),
),
"{}",
err,
);
Ok(())
} else {
Err(eyre!(
"This config should not be loaded because a base contains hostname_sep"
))
}
}
#[test]
fn source_item_referencing_parent() -> Result<(), Report> {
if let Err(err) = DTConfig::from_str(
r#"
[[local]]
name = "source item references parent dir"
base = "."
sources = ["../Cargo.toml"]
target = "target""#,
) {
assert_eq!(
err,
AppError::ConfigError(
"source item references parent directory in group 'source item references parent dir'"
.to_owned(),
),
"{}",
err,
);
Ok(())
} else {
Err(eyre!("This config should not be loaded because a source item references parent directory"))
}
}
#[test]
fn source_item_is_absolute() -> Result<(), Report> {
if let Err(err) = DTConfig::from_str(
r#"
[[local]]
name = "source item is absolute"
base = "~"
sources = ["/usr/share/gdb-dashboard/.gdbinit"]
target = "/tmp""#,
) {
assert_eq!(
err,
AppError::ConfigError(
"source array contains absolute path in group 'source item is absolute'"
.to_owned(),
),
"{}",
err,
);
Ok(())
} else {
Err(eyre!(
"This config should not be loaded because a source item is an absolute path"
))
}
}
#[test]
fn except_dot_asterisk_glob() -> Result<(), Report> {
if let Err(err) = DTConfig::from_str(
r#"
[[local]]
name = "placeholder"
base = "~"
sources = [".*"]
target = ".""#,
) {
assert_eq!(
err,
AppError::ConfigError("bad globbing pattern".to_owned()),
"{}",
err,
);
Ok(())
} else {
Err(eyre!(
"This config should not be loaded because it contains bad globs (.* and /.*)"
))
}
}
#[test]
fn source_item_contains_hostname_sep() -> Result<(), Report> {
if let Err(err) = DTConfig::from_str(
r#"
[[local]]
name = "@@ in source item"
base = "~/.config/nvim"
sources = ["init.vim@@elbert"]
target = ".""#,
) {
assert_eq!(
err,
AppError::ConfigError(
"a source item contains hostname_sep (@@) in group '@@ in source item'"
.to_owned()
),
"{}",
err,
);
Ok(())
} else {
Err(eyre!(
"This config should not be loaded because a source item contains hostname_sep"
))
}
}
}
#[cfg(test)]
mod validation_physical {
use std::str::FromStr;
use color_eyre::{eyre::eyre, Report};
use super::DTConfig;
use crate::error::Error as AppError;
use crate::utils::testing::{get_testroot, prepare_directory, prepare_file};
#[test]
fn non_existent_relative_staging_and_target() -> Result<(), Report> {
if let Err(err) = DTConfig::from_str(
r#"
[global]
staging = "staging-882b842397c5b44929b9c5f4e83130c9-dir"
[[local]]
name = "readable relative non-existent target"
base = "base-7f2f7ff8407a330751f13dc5ec86db1b-dir"
sources = ["b1db25c31c23950132a44f6faec2005c"]
target = "target-ce59cb1aea35e22e43195d4a444ff2e7-dir""#,
) {
Err(eyre!("Non-existent, relative but readable staging/target path should be loaded without error (got error :'{}')", err))
} else {
Ok(())
}
}
#[test]
fn staging_is_file() -> Result<(), Report> {
let staging_path = prepare_file(
get_testroot("validation_physical")
.join("staging_is_file")
.join("staging-but-file"),
0o644,
)?;
let base = prepare_directory(
get_testroot("validation_physical")
.join("staging_is_file")
.join("base"),
0o755,
)?;
let target = prepare_directory(
get_testroot("validation_physical")
.join("staging_is_file")
.join("target"),
0o755,
)?;
if let Err(err) = DTConfig::from_str(&format!(
r#"
[global]
staging = "{}"
[[local]]
name = "staging is file"
base = "{}"
sources = []
target = "{}""#,
staging_path.display(),
base.display(),
target.display(),
)) {
assert_eq!(
err,
AppError::ConfigError(
"staging root path exists but is not a valid directory".to_owned(),
),
"{}",
err,
);
Ok(())
} else {
Err(eyre!(
"This config should not be validated because staging is not a directory",
))
}
}
#[test]
fn staging_readonly() -> Result<(), Report> {
let staging_path = prepare_directory(
get_testroot("validation_physical")
.join("staging_readonly")
.join("staging-but-readonly"),
0o555,
)?;
let base = prepare_directory(
get_testroot("validation_physical")
.join("staging_readonly")
.join("base"),
0o755,
)?;
let target_path = prepare_directory(
get_testroot("validation_physical")
.join("staging_readonly")
.join("target"),
0o755,
)?;
if let Err(err) = DTConfig::from_str(&format!(
r#"
[global]
staging = "{}/actual_staging_dir"
[[local]]
name = "staging is readonly"
base = "{}"
sources = []
target = "{}""#,
staging_path.display(),
base.display(),
target_path.display(),
)) {
assert_eq!(
err,
AppError::ConfigError(
"staging root path cannot be created due to insufficient permissions"
.to_owned(),
),
"{}",
err,
);
Ok(())
} else {
Err(eyre!(
"This config should not be validated because staging path is readonly",
))
}
}
#[test]
fn target_is_file() -> Result<(), Report> {
let target_path = prepare_file(
get_testroot("validation_physical")
.join("target_is_file")
.join("target-but-file"),
0o755,
)?;
if let Err(err) = DTConfig::from_str(&format!(
r#"
[[local]]
name = "target path is absolute"
base = "."
sources = []
target = "{}""#,
target_path.display(),
)) {
assert_eq!(
err,
AppError::ConfigError(
"target path exists but is not a valid directory in group 'target path is absolute'"
.to_owned(),
),
"{}",
err,
);
Ok(())
} else {
Err(eyre!(
"This config should not be validated because target is not a directory",
))
}
}
#[test]
fn target_readonly() -> Result<(), Report> {
let base = prepare_directory(
get_testroot("validation_physical")
.join("target_readonly")
.join("base"),
0o755,
)?;
let target_path = prepare_directory(
get_testroot("validation_physical")
.join("target_readonly")
.join("target-but-readonly"),
0o555,
)?;
if let Err(err) = DTConfig::from_str(&format!(
r#"
[[local]]
name = "target is readonly"
base = "{}"
sources = []
target = "{}/actual_target_dir""#,
base.display(),
target_path.display(),
)) {
assert_eq!(
err,
AppError::ConfigError(
"target path cannot be created due to insufficient permissions in group 'target is readonly'"
.to_owned(),
),
"{}",
err,
);
Ok(())
} else {
Err(eyre!(
"This config should not be validated because target path is readonly",
))
}
}
#[test]
fn identical_configured_base_and_target_in_local() -> Result<(), Report> {
let base = prepare_directory(
get_testroot("validation_physical")
.join("local_group_has_same_base_and_target")
.join("base-and-target"),
0o755,
)?;
let target_path = base.clone();
if let Err(err) = DTConfig::from_str(&format!(
r#"
[[local]]
name = "same base and target"
base = "{}"
sources = []
target = "{}"
"#,
base.display(),
target_path.display(),
)) {
assert_eq!(
err,
AppError::ConfigError(
"base directory and its target are the same in group 'same base and target'"
.to_owned(),
),
"{}",
err,
);
Ok(())
} else {
Err(eyre!(
"This config should not be validated because a local group's base and target are identical"
))
}
}
#[test]
fn base_unreadable() -> Result<(), Report> {
let base = prepare_file(
get_testroot("validation_physical")
.join("base_unreadable")
.join("base-but-file"),
0o311,
)?;
if let Err(err) = DTConfig::from_str(&format!(
r#"
[[local]]
name = "base unreadable (not a directory)"
base = "{}"
sources = []
target = ".""#,
base.display(),
)) {
assert_eq!(
err,
AppError::IoError("Not a directory (os error 20)".to_owned(),),
"{}",
err,
);
} else {
return Err(eyre!(
"This config should not be loaded because base is not a directory",
));
}
let base = prepare_directory(
get_testroot("validation_physical")
.join("base_unreadable")
.join("base-unreadable"),
0o311,
)?;
if let Err(err) = DTConfig::from_str(&format!(
r#"
[[local]]
name = "base unreadable (permission denied)"
base = "{}"
sources = []
target = ".""#,
base.display(),
)) {
assert_eq!(
err,
AppError::IoError("Permission denied (os error 13)".to_owned(),),
"{}",
err,
);
} else {
return Err(eyre!(
"This config should not be loaded because insufficient permissions to base",
));
}
Ok(())
}
}