use serde::{de, Deserialize, Deserializer, Serialize, Serializer};
use std::error::Error;
use std::fmt::{self, Debug};
use std::fs::Permissions;
use std::io;
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::{fs, iter};
use walkdir::WalkDir;
#[derive(Serialize, Deserialize)]
#[serde(tag = "version")]
pub(crate) enum ManifestVersioned {
#[serde(rename = "1")]
V1(ManifestHigh),
}
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub struct Manifest<FileAction: Default> {
pub files: Vec<FileManifest<FileAction>>,
}
pub type ManifestHigh = Manifest<FileActionHigh>;
pub type ManifestLow = Manifest<FileActionLow>;
pub type FileManifestHigh = FileManifest<FileActionHigh>;
pub type FileManifestLow = FileManifest<FileActionLow>;
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub struct FileManifest<FileAction> {
pub source: PathBuf,
pub target: PathBuf,
#[serde(default)]
pub action: FileAction,
#[serde(default)]
pub collision: Collision,
}
#[derive(Debug, Default, Clone, PartialEq, Deserialize, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum FileActionHigh {
#[default]
Symlink,
Copy {
#[serde(
default,
serialize_with = "serialize_mode",
deserialize_with = "deserialize_mode",
skip_serializing_if = "Option::is_none"
)]
mode: Option<fs::Permissions>,
},
RecursiveSymlink,
RecursiveCopy {
#[serde(
default,
serialize_with = "serialize_mode",
deserialize_with = "deserialize_mode",
skip_serializing_if = "Option::is_none"
)]
mode: Option<fs::Permissions>,
},
}
#[derive(Debug, Default, Clone, PartialEq, Deserialize, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum FileActionLow {
#[default]
Symlink,
Copy {
#[serde(
default,
serialize_with = "serialize_mode",
deserialize_with = "deserialize_mode",
skip_serializing_if = "Option::is_none"
)]
mode: Option<fs::Permissions>,
},
}
fn serialize_mode<S: Serializer>(
mode: &Option<fs::Permissions>,
serializer: S,
) -> Result<S::Ok, S::Error> {
match mode {
Some(mode) => serializer.serialize_str(&format!("{:o}", mode.mode())),
None => panic!("mode is none but is serialized"),
}
}
fn deserialize_mode<'de, D: Deserializer<'de>>(
deserializer: D,
) -> Result<Option<fs::Permissions>, D::Error> {
struct ModeVisitor;
impl de::Visitor<'_> for ModeVisitor {
type Value = Option<fs::Permissions>;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("a string representing an unix file mode")
}
fn visit_str<E: de::Error>(self, value: &str) -> Result<Self::Value, E> {
let invalid = || E::custom(format!("invalid unix mode: {value}"));
u32::from_str_radix(value, 8)
.map_err(|_| invalid())
.map(Permissions::from_mode)
.map(Some)
}
}
struct OptionModeVisitor;
impl<'de> de::Visitor<'de> for OptionModeVisitor {
type Value = Option<fs::Permissions>;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("an optional string representing an unix file mode")
}
fn visit_some<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
where
D: Deserializer<'de>,
{
deserializer.deserialize_str(ModeVisitor)
}
}
deserializer.deserialize_option(OptionModeVisitor)
}
#[derive(Debug, Default, Clone, PartialEq, Deserialize, Serialize)]
#[serde(tag = "resolution", rename_all = "snake_case")]
pub enum Collision {
#[default]
Abort,
Backup {
backup_path: PathBuf,
},
Force,
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum ManifestError {
#[error("error loading manifest from {0}")]
LoadFail(PathBuf, #[source] Box<dyn Error + Send + Sync>),
#[error("error reading manifest")]
ReadFail(#[source] Box<dyn Error + Send + Sync>),
#[error("source file path is not absolute: {0}")]
SourceNotAbsolute(PathBuf),
#[error("source file path without file name: {0}")]
SourceMissingFileName(PathBuf),
#[error("target file path is not absolute: {0}")]
TargetNotAbsolute(PathBuf),
#[error("target file path without file name: {0}")]
TargetMissingFileName(PathBuf),
#[error("target file path overlap: {0} and {1}")]
TargetOverlap(PathBuf, PathBuf),
#[error("duplicate target file path: {0}")]
DuplicateTarget(PathBuf),
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum LoweringError {}
impl ManifestHigh {
pub fn load<P: AsRef<Path>>(path: P) -> Result<Self, ManifestError> {
let path = path.as_ref();
let file = std::fs::File::open(path)
.map_err(|e| ManifestError::LoadFail(path.to_path_buf(), Box::new(e)))?;
let reader = io::BufReader::new(file);
Manifest::read(reader).map_err(|e| {
if let ManifestError::ReadFail(inner) = e {
ManifestError::LoadFail(path.to_path_buf(), inner)
} else {
e
}
})
}
fn read<R: io::Read>(reader: R) -> Result<Self, ManifestError> {
let manifest = serde_json::from_reader(reader)
.map_err(|e| ManifestError::ReadFail(Box::new(e)))
.map(|r| match r {
ManifestVersioned::V1(r) => r,
})?;
manifest.validate()
}
fn validate(self) -> Result<Self, ManifestError> {
for (i, file) in self.files.iter().enumerate() {
if !file.source.is_absolute() {
return Err(ManifestError::SourceNotAbsolute(file.source.clone()));
}
if file.source.file_name().is_none() {
return Err(ManifestError::SourceMissingFileName(file.source.clone()));
}
if !file.target.is_absolute() {
return Err(ManifestError::TargetNotAbsolute(file.target.clone()));
}
if file.target.file_name().is_none() {
return Err(ManifestError::TargetMissingFileName(file.target.clone()));
}
let is_leaf = |f: &FileManifestHigh| {
matches!(
f.action,
FileActionHigh::Symlink | FileActionHigh::Copy { .. }
)
};
let file_is_leaf = is_leaf(file);
for prev_file in &self.files[0..i] {
if file.target == prev_file.target {
return Err(ManifestError::DuplicateTarget(file.target.clone()));
}
if file_is_leaf && prev_file.target.starts_with(&file.target)
|| is_leaf(prev_file) && file.target.starts_with(&prev_file.target)
{
return Err(ManifestError::TargetOverlap(
prev_file.target.clone(),
file.target.clone(),
));
}
}
}
Ok(self)
}
pub fn into_manifest_low(self) -> Result<ManifestLow, LoweringError> {
expand_manifest(self)
}
}
fn expand_manifest(mut manifest: ManifestHigh) -> Result<ManifestLow, LoweringError> {
fn recurse(
file: FileManifestHigh,
action: FileActionLow,
) -> impl Iterator<Item = Result<FileManifestLow, LoweringError>> {
WalkDir::new(&file.source)
.sort_by_file_name()
.into_iter()
.filter(|entry| !entry.as_ref().unwrap().path().is_dir())
.map(move |entry| {
let entry = entry.unwrap();
let source = entry.path().to_path_buf();
let mut target = file.target.clone();
target.extend(
entry
.path()
.to_path_buf()
.strip_prefix(&file.source)
.unwrap(),
);
Ok(FileManifestLow {
source,
target,
action: action.clone(),
collision: file.collision.clone(),
})
})
}
fn expand_file(
file: FileManifestHigh,
) -> Box<dyn Iterator<Item = Result<FileManifestLow, LoweringError>>> {
match file.action {
FileActionHigh::Symlink => Box::new(iter::once(Ok(FileManifestLow {
source: file.source,
target: file.target,
action: FileActionLow::Symlink,
collision: file.collision,
}))),
FileActionHigh::Copy { mode } => Box::new(iter::once(Ok(FileManifestLow {
source: file.source,
target: file.target,
action: FileActionLow::Copy { mode },
collision: file.collision,
}))),
FileActionHigh::RecursiveSymlink => Box::new(recurse(file, FileActionLow::Symlink)),
FileActionHigh::RecursiveCopy { ref mode } => {
let mode = mode.clone();
Box::new(recurse(file, FileActionLow::Copy { mode }))
}
}
}
manifest.files.sort_by(|a, b| {
(b.target.as_os_str().len(), a.target.as_path())
.cmp(&(a.target.as_os_str().len(), b.target.as_path()))
});
let mut files: Vec<_> = manifest
.files
.into_iter()
.flat_map(expand_file)
.collect::<Result<_, _>>()?;
files.sort_by(|a, b| {
(b.target.as_os_str().len(), a.target.as_path())
.cmp(&(a.target.as_os_str().len(), b.target.as_path()))
});
files.dedup_by(|a, b| a.target == b.target);
Ok(ManifestLow { files })
}
#[cfg(test)]
mod tests {
use super::*;
use assert_fs::prelude::{FileWriteStr, PathChild};
use assert_matches::assert_matches;
#[allow(unused)]
fn roundtrip_via_json(manifest: &ManifestHigh) {
let manifest_v1 = ManifestVersioned::V1(manifest.clone());
let json = serde_json::to_string_pretty(&manifest_v1).unwrap();
let result = Manifest::read(json.as_bytes()).unwrap();
assert_eq!(&result, manifest);
}
#[test]
fn load_missing_from_json() -> anyhow::Result<()> {
let dir = assert_fs::TempDir::new()?;
let manifest_file = dir.child("manifest.json");
let result = Manifest::load(&manifest_file);
assert_matches!(result, Err(ManifestError::LoadFail(p, _)) => {
assert_eq!(p, manifest_file.path());
});
dir.close()?;
Ok(())
}
#[test]
fn load_version_1_from_json() -> anyhow::Result<()> {
let dir = assert_fs::TempDir::new()?;
let manifest_file = dir.child("manifest.json");
manifest_file.write_str(
r#"
{
"version": "1",
"files": [
{
"source": "/path/to/source",
"target": "/path/to/target"
}
]
}
"#,
)?;
let manifest = Manifest::load(manifest_file)?;
assert_matches!(manifest, Manifest { files: _ });
dir.close()?;
Ok(())
}
#[test]
fn load_unknown_version_from_json() -> anyhow::Result<()> {
let dir = assert_fs::TempDir::new()?;
let manifest_file = dir.child("manifest.json");
manifest_file.write_str(r#"{"version": "0"}"#)?;
let result = Manifest::load(&manifest_file);
assert_matches!(result, Err(ManifestError::LoadFail(p, _)) => {
assert_eq!(p, manifest_file.path());
});
Ok(())
}
#[test]
fn load_missing_version_from_json() -> anyhow::Result<()> {
let dir = assert_fs::TempDir::new()?;
let manifest_file = dir.child("manifest.json");
manifest_file.write_str(r#"{"foo": "bar"}"#)?;
let result = Manifest::load(&manifest_file);
assert_matches!(result, Err(ManifestError::LoadFail(p, _)) => {
assert_eq!(p, manifest_file.path());
});
Ok(())
}
#[test]
fn version_1_source_not_absolute() {
let json = r#"
{
"version": "1",
"files": [{ "source": "relative/path/to/source", "target": "/path/to/target" }
]
}
"#;
let result = Manifest::read(json.as_bytes());
assert_matches!(result, Err(ManifestError::SourceNotAbsolute(p)) => {
assert_eq!(p, PathBuf::from("relative/path/to/source"));
});
}
#[test]
fn version_1_source_no_file_name() {
let json = r#"
{
"version": "1",
"files": [{ "source": "/path/to/source/..", "target": "/path/to/target" }
]
}
"#;
let result = Manifest::read(json.as_bytes());
assert_matches!(result, Err(ManifestError::SourceMissingFileName(p)) => {
assert_eq!(p, PathBuf::from("/path/to/source/.."));
});
}
#[test]
fn version_1_target_not_absolute() {
let json = r#"
{
"version": "1",
"files": [{ "source": "/path/to/source", "target": "relative/path/to/target" }
]
}
"#;
let result = Manifest::read(json.as_bytes());
assert_matches!(result, Err(ManifestError::TargetNotAbsolute(p)) => {
assert_eq!(p, PathBuf::from("relative/path/to/target"));
});
}
#[test]
fn version_1_target_no_file_name() {
let json = r#"
{
"version": "1",
"files": [{ "source": "/path/to/source", "target": "/" }
]
}
"#;
let result = Manifest::read(json.as_bytes());
assert_matches!(result, Err(ManifestError::TargetMissingFileName(p)) => {
assert_eq!(p, PathBuf::from("/"));
});
}
#[test]
fn version_1_target_symlink_exact_overlap() {
let json = r#"
{
"version": "1",
"files": [
{ "source": "/path/to/source", "target": "/path/to/target" },
{ "source": "/path/to/source", "target": "/path/to/target" }
]
}
"#;
let result = Manifest::read(json.as_bytes());
assert_matches!(result, Err(ManifestError::DuplicateTarget(path)) => {
assert_eq!(path, PathBuf::from("/path/to/target"));
});
}
#[test]
fn version_1_target_symlink_recursive_exact_overlap() {
let json = r#"
{
"version": "1",
"files": [
{ "source": "/path/to/source", "target": "/path/to/target", "action": { "type": "recursive_symlink" } },
{ "source": "/path/to/source", "target": "/path/to/target" }
]
}
"#;
let result = Manifest::read(json.as_bytes());
assert_matches!(result, Err(ManifestError::DuplicateTarget(path)) => {
assert_eq!(path, PathBuf::from("/path/to/target"));
});
}
#[test]
fn version_1_target_symlink_prefix_overlap() {
let json = r#"
{
"version": "1",
"files": [
{ "source": "/path/to/source", "target": "/path/to/target/subtarget" },
{ "source": "/path/to/source", "target": "/path/to/target" }
]
}
"#;
let result = Manifest::read(json.as_bytes());
assert_matches!(result, Err(ManifestError::TargetOverlap(p1, p2)) => {
assert_eq!(p1, PathBuf::from("/path/to/target/subtarget"));
assert_eq!(p2, PathBuf::from("/path/to/target"));
});
}
#[test]
fn version_1_target_symlink_recursive_prefix_overlap() {
let json = r#"
{
"version": "1",
"files": [
{ "source": "/path/to/source", "target": "/path/to/target", "action": { "type": "recursive_symlink" } },
{ "source": "/path/to/source", "target": "/path/to/target/sub1", "action": { "type": "recursive_copy" } },
{ "source": "/path/to/source", "target": "/path/to/target/sub1/sub2" }
]
}
"#;
let manifest = Manifest::read(json.as_bytes()).unwrap();
assert_eq!(
manifest,
Manifest {
files: vec![
FileManifest {
source: PathBuf::from("/path/to/source"),
target: PathBuf::from("/path/to/target"),
action: FileActionHigh::RecursiveSymlink,
collision: Collision::Abort
},
FileManifest {
source: PathBuf::from("/path/to/source"),
target: PathBuf::from("/path/to/target/sub1"),
action: FileActionHigh::RecursiveCopy { mode: None },
collision: Collision::Abort
},
FileManifest {
source: PathBuf::from("/path/to/source"),
target: PathBuf::from("/path/to/target/sub1/sub2"),
action: FileActionHigh::Symlink,
collision: Collision::Abort
}
]
}
)
}
#[test]
fn version_1_copy_without_mode() {
let json = r#"
{
"version": "1",
"files": [
{
"source": "/path/to/source",
"target": "/path/to/target",
"action": { "type": "copy" }
}
]
}
"#;
let manifest = Manifest::read(json.as_bytes()).unwrap();
assert_eq!(
manifest,
Manifest {
files: vec![FileManifest {
source: PathBuf::from("/path/to/source"),
target: PathBuf::from("/path/to/target"),
action: FileActionHigh::Copy { mode: None },
collision: Collision::Abort
}]
}
);
roundtrip_via_json(&manifest);
}
#[test]
fn version_1_copy_with_null_mode() {
let json = r#"
{
"version": "1",
"files": [
{
"source": "/path/to/source",
"target": "/path/to/target",
"action": { "type": "copy", "mode": null }
}
]
}
"#;
let result = Manifest::read(json.as_bytes());
assert_matches!(result, Err(ManifestError::ReadFail(_)));
}
#[test]
fn version_1_copy_with_good_mode() {
let json = r#"
{
"version": "1",
"files": [
{
"source": "/path/to/source",
"target": "/path/to/target",
"action": { "type": "copy", "mode": "755" }
}
]
}
"#;
let manifest = Manifest::read(json.as_bytes()).unwrap();
assert_eq!(
manifest,
Manifest {
files: vec![FileManifest {
source: PathBuf::from("/path/to/source"),
target: PathBuf::from("/path/to/target"),
action: FileActionHigh::Copy {
mode: Some(Permissions::from_mode(0o755))
},
collision: Collision::Abort
}]
}
);
roundtrip_via_json(&manifest);
}
#[test]
fn version_1_copy_with_invalid_mode_octal() {
let json = r#"
{
"version": "1",
"files": [
{
"source": "/path/to/source",
"target": "/path/to/target",
"action": { "type": "copy", "mode": "999" }
}
]
}
"#;
let result = Manifest::read(json.as_bytes());
assert_matches!(result, Err(ManifestError::ReadFail(_)));
}
#[test]
fn version_1_copy_with_numeric_mode() {
let json = r#"
{
"version": "1",
"files": [
{
"source": "/path/to/source",
"target": "/path/to/target",
"action": { "type": "copy", "mode": 999 }
}
]
}
"#;
let result = Manifest::read(json.as_bytes());
assert_matches!(result, Err(ManifestError::ReadFail(_)));
}
mod into_manifest_low {
use assert_fs::{
prelude::{SymlinkToDir, SymlinkToFile},
TempDir,
};
use super::*;
#[test]
fn can_expand_basic_structure() -> anyhow::Result<()> {
let dir = TempDir::new()?;
let file_1 = dir.child("file_1");
let file_2 = dir.child("file_2");
let file_3 = dir.child("inner-dir").child("file_3");
file_1.write_str("content1")?;
file_2.write_str("content2")?;
file_3.write_str("content3")?;
let manifest = ManifestHigh {
files: vec![FileManifestHigh {
source: dir.to_path_buf(),
target: PathBuf::from("/dir"),
action: FileActionHigh::RecursiveSymlink,
collision: Collision::Abort,
}],
};
let actual = manifest.into_manifest_low()?;
let expected = ManifestLow {
files: vec![
FileManifestLow {
source: file_3.to_path_buf(),
target: PathBuf::from("/dir/inner-dir/file_3"),
action: FileActionLow::Symlink,
collision: Collision::Abort,
},
FileManifestLow {
source: file_1.to_path_buf(),
target: PathBuf::from("/dir/file_1"),
action: FileActionLow::Symlink,
collision: Collision::Abort,
},
FileManifestLow {
source: file_2.to_path_buf(),
target: PathBuf::from("/dir/file_2"),
action: FileActionLow::Symlink,
collision: Collision::Abort,
},
],
};
assert_eq!(actual, expected);
dir.close()?;
Ok(())
}
#[test]
fn can_handle_symlink_to_file() -> anyhow::Result<()> {
let dir = TempDir::new()?;
let file = dir.child("file");
let link = dir.child("link");
file.write_str("content")?;
link.symlink_to_file(&file)?;
let manifest = ManifestHigh {
files: vec![FileManifestHigh {
source: dir.to_path_buf(),
target: PathBuf::from("/dir"),
action: FileActionHigh::RecursiveSymlink,
collision: Collision::Abort,
}],
};
let actual = manifest.into_manifest_low()?;
let expected = ManifestLow {
files: vec![
FileManifestLow {
source: file.to_path_buf(),
target: PathBuf::from("/dir/file"),
action: FileActionLow::Symlink,
collision: Collision::Abort,
},
FileManifestLow {
source: link.to_path_buf(),
target: PathBuf::from("/dir/link"),
action: FileActionLow::Symlink,
collision: Collision::Abort,
},
],
};
assert_eq!(actual, expected);
dir.close()?;
Ok(())
}
#[test]
fn can_handle_symlink_to_dir() -> anyhow::Result<()> {
let dir = TempDir::new()?;
let inner_dir = dir.child("inner-dir");
let file = inner_dir.child("file");
let link = dir.child("link");
file.write_str("content")?;
link.symlink_to_dir(&inner_dir)?;
let manifest = ManifestHigh {
files: vec![FileManifestHigh {
source: dir.to_path_buf(),
target: PathBuf::from("/root"),
action: FileActionHigh::RecursiveSymlink,
collision: Collision::Abort,
}],
};
let actual = manifest.into_manifest_low()?;
let expected = ManifestLow {
files: vec![FileManifestLow {
source: file.to_path_buf(),
target: PathBuf::from("/root/inner-dir/file"),
action: FileActionLow::Symlink,
collision: Collision::Abort,
}],
};
assert_eq!(actual, expected);
dir.close()?;
Ok(())
}
#[test]
fn can_handle_file_override() -> anyhow::Result<()> {
let dir = TempDir::new()?;
let file1 = dir.child("file1");
let file2 = dir.child("file2");
let file3 = dir.child("file3");
file1.write_str("content")?;
file2.write_str("content")?;
file3.write_str("content")?;
let manifest = Manifest {
files: vec![
FileManifest {
source: file1.to_path_buf(),
target: PathBuf::from("/root/file1"),
action: FileActionHigh::Symlink,
collision: Collision::Force,
},
FileManifest {
source: dir.to_path_buf(),
target: PathBuf::from("/root/"),
action: FileActionHigh::RecursiveSymlink,
collision: Collision::Abort,
},
FileManifest {
source: file3.to_path_buf(),
target: PathBuf::from("/root/file3"),
action: FileActionHigh::Symlink,
collision: Collision::Force,
},
],
};
let actual = manifest.into_manifest_low()?;
let expected = Manifest {
files: vec![
FileManifest {
source: file1.to_path_buf(),
target: PathBuf::from("/root/file1"),
action: FileActionLow::Symlink,
collision: Collision::Force,
},
FileManifest {
source: file2.to_path_buf(),
target: PathBuf::from("/root/file2"),
action: FileActionLow::Symlink,
collision: Collision::Abort,
},
FileManifest {
source: file3.to_path_buf(),
target: PathBuf::from("/root/file3"),
action: FileActionLow::Symlink,
collision: Collision::Force,
},
],
};
assert_eq!(actual, expected);
dir.close()?;
Ok(())
}
}
}