use std::path::PathBuf;
use eyre::{Result, bail};
use super::layout::{Located, Roots};
use crate::system::history::{
journal,
manifest::Manifest,
shadow::HistoryRepo,
tracked::{ExcludeSet, TrackedEntry, TrackedSet, governing_key, mode_from},
};
#[derive(Clone, Debug)]
pub(super) struct Step {
pub path: PathBuf,
before: Option<(u64, u64, u32)>,
desired: u32,
written: Option<(u64, u64, u32)>,
}
#[cfg(unix)]
fn observe(path: &std::path::Path) -> Result<Option<(u64, u64, u32)>> {
use std::os::unix::fs::MetadataExt;
match std::fs::symlink_metadata(path) {
Ok(meta) if meta.is_dir() => Ok(Some((meta.dev(), meta.ino(), meta.mode() & 0o777))),
Ok(_) => bail!(
"{} is not a directory; resolve its type before pulling",
crate::file::display_path(path)
),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(err) => Err(err.into()),
}
}
#[cfg(not(unix))]
fn observe(_path: &std::path::Path) -> Result<Option<(u64, u64, u32)>> {
Ok(None)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
enum Claim {
Enrolled,
Containing,
}
fn claim(
roots: &Roots,
tracked: &TrackedSet,
exclude: &ExcludeSet,
branch_path: &str,
) -> Option<Claim> {
if super::run::eligible(roots, tracked, exclude, branch_path) {
return Some(Claim::Enrolled);
}
let path = match roots.locate(branch_path) {
Located::Tracked {
path,
variant: None,
}
| Located::Config(path) => path,
Located::Tracked { .. } | Located::Marker | Located::Unmapped => return None,
};
(path != roots.home
&& path != roots.config_dir
&& tracked
.entries
.iter()
.any(|entry| entry.path.starts_with(&path) && entry.path != path))
.then_some(Claim::Containing)
}
fn present(
repo: &HistoryRepo,
tree: &str,
roots: &Roots,
entries: &[TrackedEntry],
path: &std::path::Path,
) -> Result<bool> {
let is_directory = |candidate: &str| -> Result<Option<bool>> {
Ok(repo
.object_at(tree, candidate)?
.map(|(mode, _)| mode == "040000"))
};
let Some(governing) = governing_key(roots, entries, path) else {
return Ok(false);
};
let covered = entries.iter().any(|entry| path.starts_with(&entry.path));
if covered && let Some(is_directory) = is_directory(&governing)? {
return Ok(is_directory);
}
let mut candidates = vec![governing];
for entry in entries {
if entry.path.starts_with(path)
&& entry.path != path
&& let Ok(stream) = entry.tree_path(path)
&& !candidates.contains(&stream)
{
candidates.push(stream);
}
}
let mut found = false;
for candidate in &candidates {
match is_directory(candidate)? {
Some(true) => found = true,
Some(false) => return Ok(false),
None => {}
}
}
Ok(found)
}
pub(super) fn plan(repo: &HistoryRepo, tracked: &TrackedSet, tree: &str) -> Result<Vec<Step>> {
if !cfg!(unix) {
return Ok(vec![]);
}
let local = repo.ref_oid(HistoryRepo::HISTORY_REF)?;
let saved = local
.as_deref()
.map(|head| Manifest::read(repo, head))
.transpose()?
.flatten()
.unwrap_or_default();
let roots = Roots::current();
let exclude = tracked.exclude_set()?;
let mut steps = vec![];
let paths: std::collections::BTreeSet<_> = tracked
.manifest
.permissions
.keys()
.chain(saved.permissions.keys())
.collect();
let mut directories: std::collections::BTreeSet<PathBuf> = Default::default();
for portable in paths {
if claim(&roots, tracked, &exclude, portable).is_none() {
continue;
}
let path = roots.locate(portable).path().unwrap().to_path_buf();
if present(repo, tree, &roots, &tracked.entries, &path)? {
directories.insert(path);
}
}
let saved_entries = saved.selected_entries();
for path in directories {
let before = observe(&path)?;
let desired = mode_from(
&roots,
&tracked.entries,
&tracked.manifest.permissions,
&path,
)
.unwrap_or(0o755);
if before.map(|(_, _, bits)| bits) == Some(desired) {
continue;
}
let Some((_, _, live)) = before else {
steps.push(Step {
path,
before,
desired,
written: None,
});
continue;
};
let baseline = mode_from(&roots, &saved_entries, &saved.permissions, &path);
if baseline.is_some_and(|bits| bits != live) {
bail!(
"{} has unsaved directory permission changes; save them before pulling. Sharing is paused",
crate::file::display_path(&path)
);
}
steps.push(Step {
path,
before,
desired,
written: None,
});
}
steps.sort_by_key(|step| step.path.components().count());
Ok(steps)
}
impl Step {
pub(super) fn validate(&self) -> Result<()> {
if observe(&self.path)? != self.before {
bail!(
"{} changed after permission planning; retry pull",
crate::file::display_path(&self.path)
);
}
Ok(())
}
pub(super) fn apply(&mut self) -> Result<()> {
self.validate()?;
let pending = journal::begin_changes_with(
"history",
"directory permissions",
[(self.path.clone(), journal::Capture::Shallow)],
)?;
if self.before.is_none() {
crate::file::create_dir_all(&self.path)?;
}
set_mode(&self.path, self.desired)?;
self.written = observe(&self.path)?;
journal::commit_changes(pending);
Ok(())
}
pub(super) fn verify_written(&self) -> Result<()> {
if observe(&self.path)? != self.written {
bail!(
"{} changed during application; left untouched",
crate::file::display_path(&self.path)
);
}
Ok(())
}
pub(super) fn recover(&self) -> Result<()> {
if self.written.is_none() {
return Ok(());
}
self.verify_written()?;
match self.before {
Some((_, _, mode)) => set_mode(&self.path, mode),
None => std::fs::remove_dir(&self.path).map_err(Into::into),
}
}
}
fn set_mode(path: &std::path::Path, bits: u32) -> Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(bits))?;
}
#[cfg(not(unix))]
let _ = (path, bits);
Ok(())
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
#[test]
fn directories_containing_tracked_files_are_eligible() {
use crate::system::files::{FileMode, FilePolicy};
use crate::system::history::tracked::TrackedEntry;
let roots = Roots {
home: "/home/u".into(),
config_dir: "/config/mise".into(),
};
let policy = FilePolicy::for_mode(FileMode::Track);
let mut variant = TrackedEntry::new("/home/u/.ssh/config".into(), "track", policy);
variant.variant = Some("linux".into());
let tracked = TrackedSet {
entries: vec![
TrackedEntry::new("/home/u/.claude/settings.json".into(), "track", policy),
TrackedEntry::new("/config/mise/tasks/private/build".into(), "track", policy),
variant,
],
..Default::default()
};
let exclude = tracked.exclude_set().unwrap();
for path in [
"home/.claude",
"home/.claude/settings.json",
"config/tasks",
"config/tasks/private",
"home/.ssh",
] {
assert!(claim(&roots, &tracked, &exclude, path).is_some(), "{path}");
}
assert_eq!(
claim(&roots, &tracked, &exclude, "home/.claude/settings.json"),
Some(Claim::Enrolled)
);
assert_eq!(
claim(&roots, &tracked, &exclude, "home/.ssh"),
Some(Claim::Containing)
);
for path in [
"home",
"config",
"home/.claudia",
"home/.claude/other",
"home@linux/.claude",
"home@linux/.ssh",
"config@linux/tasks",
"fs/etc",
] {
assert!(claim(&roots, &tracked, &exclude, path).is_none(), "{path}");
}
}
#[test]
fn one_step_per_directory_across_claims() -> Result<()> {
use crate::system::files::{FileMode, FilePolicy};
use crate::system::history::manifest::Enrollment;
use crate::system::history::shadow::Overlay;
use crate::system::history::tracked::{TrackedEntry, normalize};
let state = tempfile::tempdir()?;
let Some(repo) = HistoryRepo::open_or_init_in(state.path())? else {
return Ok(());
};
let roots = Roots::current();
let scratch = tempfile::Builder::new()
.prefix(".history-claims-")
.tempdir_in(&roots.home)?;
let private = normalize(scratch.path()).join("private");
let policy = FilePolicy::for_mode(FileMode::Track);
let mut directory = TrackedEntry::new(private.clone(), "track", policy);
directory.variant = Some("linux".into());
let file = TrackedEntry::new(private.join("settings.json"), "track", policy);
let own = directory.tree_path(&private)?;
let containing = roots.branch_path(&private, None).unwrap();
let blob = repo.hash_blob(b"{}")?;
let files = repo.compose(
&repo.empty_object("tree")?,
&[
Overlay {
path: directory.tree_path(&private.join("notes"))?,
object: Some(("100644".into(), blob.clone())),
},
Overlay {
path: file.tree_path(&file.path)?,
object: Some(("100644".into(), blob)),
},
],
)?;
let variant = |os: &str| crate::system::history::select::Variant {
os: vec![os.into()],
..Default::default()
};
let mut manifest = Manifest {
enrollment: vec![
Enrollment {
path: containing.clone(),
autosave: true,
encrypt: false,
variants: vec![variant("linux"), variant("macos")],
exclude: None,
include: None,
},
Enrollment {
path: file.tree_path(&file.path)?,
autosave: true,
encrypt: false,
variants: vec![],
exclude: None,
include: None,
},
],
permissions: std::collections::BTreeMap::from([
(own.clone(), 0o700),
(containing.clone(), 0o750),
]),
..Default::default()
};
let tree = manifest.write(&repo, &files)?;
let tracked = TrackedSet {
entries: vec![directory, file],
manifest: manifest.clone(),
..Default::default()
};
let steps = plan(&repo, &tracked, &tree)?;
assert_eq!(steps.len(), 1);
assert_eq!(steps[0].path, private);
assert_eq!(steps[0].desired, 0o700);
assert!(steps[0].before.is_none());
manifest.permissions.remove(&own);
let tree = manifest.write(&repo, &files)?;
let tracked = TrackedSet {
manifest: manifest.clone(),
..tracked
};
let steps = plan(&repo, &tracked, &tree)?;
assert_eq!(steps.len(), 1);
assert_eq!(steps[0].desired, 0o755);
use crate::system::history::checkpoint::test_checkpoint;
use std::os::unix::fs::PermissionsExt;
std::fs::create_dir(&private)?;
std::fs::set_permissions(&private, std::fs::Permissions::from_mode(0o700))?;
let mut local = manifest.clone();
local.permissions.insert(own.clone(), 0o700);
local
.permissions
.insert(own.replace("@linux/", "@macos/"), 0o700);
let head = local.write(&repo, &files)?;
repo.write_checkpoint(Some(&head), &test_checkpoint("local", Some(&head)))?;
let steps = plan(&repo, &tracked, &tree)?;
assert_eq!(steps.len(), 1);
assert_eq!(steps[0].path, private);
assert_eq!(steps[0].desired, 0o755);
Ok(())
}
#[test]
fn a_newly_enrolled_parent_keeps_its_containing_baseline() -> Result<()> {
use crate::system::files::{FileMode, FilePolicy};
use crate::system::history::checkpoint::test_checkpoint;
use crate::system::history::manifest::Enrollment;
use crate::system::history::shadow::Overlay;
use crate::system::history::tracked::{TrackedEntry, normalize};
use std::os::unix::fs::PermissionsExt;
let state = tempfile::tempdir()?;
let Some(repo) = HistoryRepo::open_or_init_in(state.path())? else {
return Ok(());
};
let roots = Roots::current();
let scratch = tempfile::Builder::new()
.prefix(".history-newly-enrolled-")
.tempdir_in(&roots.home)?;
let private = normalize(scratch.path()).join("private");
std::fs::create_dir(&private)?;
std::fs::set_permissions(&private, std::fs::Permissions::from_mode(0o700))?;
let policy = FilePolicy::for_mode(FileMode::Track);
let mut file = TrackedEntry::new(private.join("settings.json"), "track", policy);
file.variant = Some("linux".into());
let mut directory = TrackedEntry::new(private.clone(), "track", policy);
directory.variant = Some("linux".into());
let containing = roots.branch_path(&private, None).unwrap();
let own = directory.tree_path(&private)?;
let linux = || crate::system::history::select::Variant {
os: vec!["linux".into()],
..Default::default()
};
let files = repo.compose(
&repo.empty_object("tree")?,
&[Overlay {
path: file.tree_path(&file.path)?,
object: Some(("100644".into(), repo.hash_blob(b"{}")?)),
}],
)?;
let local = Manifest {
enrollment: vec![Enrollment {
path: roots.branch_path(&file.path, None).unwrap(),
autosave: true,
encrypt: false,
variants: vec![
linux(),
crate::system::history::select::Variant {
os: vec!["macos".into()],
..Default::default()
},
],
exclude: None,
include: None,
}],
permissions: std::collections::BTreeMap::from([(containing.clone(), 0o700)]),
..Default::default()
};
let head = local.write(&repo, &files)?;
repo.write_checkpoint(Some(&head), &test_checkpoint("local", Some(&head)))?;
let incoming = Manifest {
enrollment: vec![Enrollment {
path: containing.clone(),
autosave: true,
encrypt: false,
variants: vec![linux()],
exclude: None,
include: None,
}],
permissions: std::collections::BTreeMap::from([(own.clone(), 0o750)]),
..Default::default()
};
let tree = incoming.write(&repo, &files)?;
let tracked = TrackedSet {
entries: vec![directory],
manifest: incoming,
..Default::default()
};
let steps = plan(&repo, &tracked, &tree)?;
assert_eq!(steps.len(), 1);
assert_eq!(steps[0].path, private);
assert_eq!(steps[0].desired, 0o750);
std::fs::set_permissions(&private, std::fs::Permissions::from_mode(0o711))?;
let err = plan(&repo, &tracked, &tree).unwrap_err();
assert!(
err.to_string().contains("unsaved directory permission"),
"{err}"
);
Ok(())
}
#[test]
fn a_new_private_subdirectory_is_created_not_guarded() -> Result<()> {
use crate::system::files::{FileMode, FilePolicy};
use crate::system::history::checkpoint::test_checkpoint;
use crate::system::history::manifest::Enrollment;
use crate::system::history::shadow::Overlay;
use crate::system::history::tracked::{TrackedEntry, normalize};
let state = tempfile::tempdir()?;
let Some(repo) = HistoryRepo::open_or_init_in(state.path())? else {
return Ok(());
};
let roots = Roots::current();
let scratch = tempfile::Builder::new()
.prefix(".history-new-subdir-")
.tempdir_in(&roots.home)?;
let configs = normalize(scratch.path()).join("configs");
std::fs::create_dir(&configs)?;
let private = configs.join("private");
let entry = TrackedEntry::new(
configs.clone(),
"track",
FilePolicy::for_mode(FileMode::Track),
);
let blob = repo.hash_blob(b"{}")?;
let saved_tree = repo.compose(
&repo.empty_object("tree")?,
&[Overlay {
path: entry.tree_path(&configs.join("app.toml"))?,
object: Some(("100644".into(), blob.clone())),
}],
)?;
let mut manifest = Manifest {
enrollment: vec![Enrollment {
path: entry.tree_path(&configs)?,
autosave: true,
encrypt: false,
variants: vec![],
exclude: None,
include: None,
}],
..Default::default()
};
let head = manifest.write(&repo, &saved_tree)?;
repo.write_checkpoint(Some(&head), &test_checkpoint("local", Some(&head)))?;
let incoming_tree = repo.compose(
&saved_tree,
&[Overlay {
path: entry.tree_path(&private.join("settings.json"))?,
object: Some(("100644".into(), blob)),
}],
)?;
let key = entry.tree_path(&private)?;
manifest.permissions.insert(key, 0o700);
let tree = manifest.write(&repo, &incoming_tree)?;
let tracked = TrackedSet {
entries: vec![entry],
manifest,
..Default::default()
};
let steps = plan(&repo, &tracked, &tree)?;
assert_eq!(steps.len(), 1);
assert_eq!(steps[0].path, private);
assert_eq!(steps[0].desired, 0o700);
assert!(steps[0].before.is_none());
Ok(())
}
#[test]
fn an_inactive_nested_enrollment_does_not_hide_the_governing_parent() -> Result<()> {
use crate::system::files::{FileMode, FilePolicy};
use crate::system::history::checkpoint::test_checkpoint;
use crate::system::history::manifest::Enrollment;
use crate::system::history::shadow::Overlay;
use crate::system::history::tracked::{TrackedEntry, normalize};
use std::os::unix::fs::PermissionsExt;
let state = tempfile::tempdir()?;
let Some(repo) = HistoryRepo::open_or_init_in(state.path())? else {
return Ok(());
};
let roots = Roots::current();
let scratch = tempfile::Builder::new()
.prefix(".history-nested-inactive-")
.tempdir_in(&roots.home)?;
let configs = normalize(scratch.path()).join("configs");
let private = configs.join("private");
std::fs::create_dir_all(&private)?;
std::fs::set_permissions(&private, std::fs::Permissions::from_mode(0o700))?;
let entry = TrackedEntry::new(
configs.clone(),
"track",
FilePolicy::for_mode(FileMode::Track),
);
let blob = repo.hash_blob(b"{}")?;
let files = repo.compose(
&repo.empty_object("tree")?,
&[
Overlay {
path: entry.tree_path(&configs.join("app.toml"))?,
object: Some(("100644".into(), blob.clone())),
},
Overlay {
path: entry.tree_path(&private.join("settings.json"))?,
object: Some(("100644".into(), blob)),
},
],
)?;
let key = entry.tree_path(&private)?;
let mut manifest = Manifest {
enrollment: vec![
Enrollment {
path: entry.tree_path(&configs)?,
autosave: true,
encrypt: false,
variants: vec![],
exclude: None,
include: None,
},
Enrollment {
path: key.clone(),
autosave: true,
encrypt: false,
variants: vec![crate::system::history::select::Variant {
os: vec!["windows".into()],
..Default::default()
}],
exclude: None,
include: None,
},
],
..Default::default()
};
let head = manifest.write(&repo, &files)?;
repo.write_checkpoint(Some(&head), &test_checkpoint("local", Some(&head)))?;
manifest.permissions.insert(key, 0o750);
let tree = manifest.write(&repo, &files)?;
let tracked = TrackedSet {
entries: vec![entry],
manifest,
..Default::default()
};
let err = plan(&repo, &tracked, &tree).unwrap_err();
assert!(
err.to_string().contains("unsaved directory permission"),
"{err}"
);
std::fs::set_permissions(&private, std::fs::Permissions::from_mode(0o755))?;
let steps = plan(&repo, &tracked, &tree)?;
assert_eq!(steps.len(), 1);
assert_eq!(steps[0].desired, 0o750);
Ok(())
}
#[test]
fn a_file_in_the_governing_stream_is_not_made_a_directory() -> Result<()> {
use crate::system::files::{FileMode, FilePolicy};
use crate::system::history::manifest::Enrollment;
use crate::system::history::shadow::Overlay;
use crate::system::history::tracked::{TrackedEntry, normalize};
let state = tempfile::tempdir()?;
let Some(repo) = HistoryRepo::open_or_init_in(state.path())? else {
return Ok(());
};
let roots = Roots::current();
let scratch = tempfile::Builder::new()
.prefix(".history-mixed-types-")
.tempdir_in(&roots.home)?;
let configs = normalize(scratch.path()).join("configs");
let platform = configs.join("platform");
let policy = FilePolicy::for_mode(FileMode::Track);
let outer = TrackedEntry::new(configs.clone(), "track", policy);
let mut inner = TrackedEntry::new(platform.clone(), "track", policy);
inner.variant = Some("linux".into());
let blob = repo.hash_blob(b"{}")?;
let files = repo.compose(
&repo.empty_object("tree")?,
&[
Overlay {
path: inner.tree_path(&platform)?,
object: Some(("100644".into(), blob.clone())),
},
Overlay {
path: outer.tree_path(&platform.join("settings.json"))?,
object: Some(("100644".into(), blob)),
},
],
)?;
let variant = |os: &str| crate::system::history::select::Variant {
os: vec![os.into()],
..Default::default()
};
let manifest = Manifest {
enrollment: vec![
Enrollment {
path: outer.tree_path(&configs)?,
autosave: true,
encrypt: false,
variants: vec![],
exclude: None,
include: None,
},
Enrollment {
path: outer.tree_path(&platform)?,
autosave: true,
encrypt: false,
variants: vec![variant("linux"), variant("macos")],
exclude: None,
include: None,
},
],
permissions: std::collections::BTreeMap::from([
(inner.tree_path(&platform)?, 0o600),
(outer.tree_path(&platform)?, 0o700),
]),
..Default::default()
};
let tree = manifest.write(&repo, &files)?;
let tracked = TrackedSet {
entries: vec![outer, inner],
manifest,
..Default::default()
};
let steps = plan(&repo, &tracked, &tree)?;
assert!(steps.iter().all(|step| step.path != platform), "{steps:?}");
Ok(())
}
#[test]
fn a_covered_directory_present_only_in_an_inner_stream_is_planned() -> Result<()> {
use crate::system::files::{FileMode, FilePolicy};
use crate::system::history::manifest::Enrollment;
use crate::system::history::shadow::Overlay;
use crate::system::history::tracked::{TrackedEntry, normalize};
let state = tempfile::tempdir()?;
let Some(repo) = HistoryRepo::open_or_init_in(state.path())? else {
return Ok(());
};
let roots = Roots::current();
let scratch = tempfile::Builder::new()
.prefix(".history-inner-stream-")
.tempdir_in(&roots.home)?;
let configs = normalize(scratch.path()).join("configs");
let private = configs.join("private");
let policy = FilePolicy::for_mode(FileMode::Track);
let outer = TrackedEntry::new(configs.clone(), "track", policy);
let mut settings = TrackedEntry::new(private.join("settings.json"), "track", policy);
settings.variant = Some("linux".into());
let files = repo.compose(
&repo.empty_object("tree")?,
&[Overlay {
path: settings.tree_path(&settings.path)?,
object: Some(("100644".into(), repo.hash_blob(b"{}")?)),
}],
)?;
assert!(
repo.object_at(&files, &outer.tree_path(&private)?)?
.is_none()
);
let variant = |os: &str| crate::system::history::select::Variant {
os: vec![os.into()],
..Default::default()
};
let manifest = Manifest {
enrollment: vec![
Enrollment {
path: outer.tree_path(&configs)?,
autosave: true,
encrypt: false,
variants: vec![],
exclude: None,
include: None,
},
Enrollment {
path: outer.tree_path(&settings.path)?,
autosave: true,
encrypt: false,
variants: vec![variant("linux"), variant("macos")],
exclude: None,
include: None,
},
],
permissions: std::collections::BTreeMap::from([(outer.tree_path(&private)?, 0o700)]),
..Default::default()
};
let tree = manifest.write(&repo, &files)?;
let tracked = TrackedSet {
entries: vec![outer, settings],
manifest,
..Default::default()
};
let steps = plan(&repo, &tracked, &tree)?;
assert_eq!(steps.len(), 1);
assert_eq!(steps[0].path, private);
assert_eq!(steps[0].desired, 0o700);
assert!(steps[0].before.is_none());
Ok(())
}
#[test]
fn adoption_and_the_next_pull_agree_on_the_baseline() -> Result<()> {
use crate::system::files::{FileMode, FilePolicy};
use crate::system::history::checkpoint::test_checkpoint;
use crate::system::history::manifest::Enrollment;
use crate::system::history::shadow::Overlay;
use crate::system::history::tracked::{TrackedEntry, normalize};
use std::os::unix::fs::PermissionsExt;
let state = tempfile::tempdir()?;
let Some(repo) = HistoryRepo::open_or_init_in(state.path())? else {
return Ok(());
};
let roots = Roots::current();
let scratch = tempfile::Builder::new()
.prefix(".history-adopt-agree-")
.tempdir_in(&roots.home)?;
let private = normalize(scratch.path()).join("private");
let policy = FilePolicy::for_mode(FileMode::Track);
let file = TrackedEntry::new(private.join("settings.json"), "track", policy);
let mut directory = TrackedEntry::new(private.clone(), "track", policy);
directory.variant = Some("linux".into());
let containing = roots.branch_path(&private, None).unwrap();
let variant = |os: &str| crate::system::history::select::Variant {
os: vec![os.into()],
..Default::default()
};
let blob = repo.hash_blob(b"{}")?;
let files = repo.compose(
&repo.empty_object("tree")?,
&[
Overlay {
path: file.tree_path(&file.path)?,
object: Some(("100644".into(), blob.clone())),
},
Overlay {
path: directory.tree_path(&private.join("notes"))?,
object: Some(("100644".into(), blob)),
},
],
)?;
let enrollment = |path: String, variants| Enrollment {
path,
autosave: true,
encrypt: false,
variants,
exclude: None,
include: None,
};
let adopted = Manifest {
enrollment: vec![
enrollment(file.tree_path(&file.path)?, vec![]),
enrollment(containing.clone(), vec![variant("linux"), variant("macos")]),
],
permissions: std::collections::BTreeMap::from([(containing.clone(), 0o750)]),
..Default::default()
};
let tree = adopted.write(&repo, &files)?;
let tracked = TrackedSet {
entries: vec![file.clone(), directory.clone()],
manifest: adopted.clone(),
..Default::default()
};
let steps = plan(&repo, &tracked, &tree)?;
assert_eq!(steps.len(), 1);
let applied = steps[0].desired;
std::fs::create_dir(&private)?;
std::fs::set_permissions(&private, std::fs::Permissions::from_mode(applied))?;
repo.write_checkpoint(Some(&tree), &test_checkpoint("adopted", Some(&tree)))?;
let mut incoming = adopted.clone();
incoming.permissions.insert(containing.clone(), 0o700);
let tree = incoming.write(&repo, &files)?;
let tracked = TrackedSet {
manifest: incoming,
..tracked
};
let steps = plan(&repo, &tracked, &tree)?;
assert!(
steps.iter().all(|step| step.path != private) || steps[0].desired == applied,
"an enrolled directory ignores the containing record: {steps:?}"
);
let scratch = tempfile::Builder::new()
.prefix(".history-adopt-agree-containing-")
.tempdir_in(&roots.home)?;
let private = normalize(scratch.path()).join("private");
let file = TrackedEntry::new(private.join("settings.json"), "track", policy);
let containing = roots.branch_path(&private, None).unwrap();
let files = repo.compose(
&repo.empty_object("tree")?,
&[Overlay {
path: file.tree_path(&file.path)?,
object: Some(("100644".into(), repo.hash_blob(b"{}")?)),
}],
)?;
let adopted = Manifest {
enrollment: vec![enrollment(file.tree_path(&file.path)?, vec![])],
permissions: std::collections::BTreeMap::from([(containing.clone(), 0o750)]),
..Default::default()
};
let tree = adopted.write(&repo, &files)?;
let tracked = TrackedSet {
entries: vec![file],
manifest: adopted.clone(),
..Default::default()
};
let steps = plan(&repo, &tracked, &tree)?;
assert_eq!(steps.len(), 1);
assert_eq!(steps[0].desired, 0o750);
std::fs::create_dir(&private)?;
std::fs::set_permissions(&private, std::fs::Permissions::from_mode(0o750))?;
let head = repo.ref_oid(HistoryRepo::HISTORY_REF)?.unwrap();
repo.write_checkpoint(
Some(&tree),
&test_checkpoint("adopted-containing", Some(&tree)),
)?;
assert_ne!(repo.ref_oid(HistoryRepo::HISTORY_REF)?.unwrap(), head);
let mut incoming = adopted;
incoming.permissions.insert(containing, 0o700);
let tree = incoming.write(&repo, &files)?;
let tracked = TrackedSet {
manifest: incoming,
..tracked
};
let steps = plan(&repo, &tracked, &tree)?;
assert_eq!(steps.len(), 1);
assert_eq!(steps[0].path, private);
assert_eq!(steps[0].desired, 0o700);
Ok(())
}
#[test]
fn a_removed_variant_enrollment_keeps_its_baseline() -> Result<()> {
use crate::system::files::{FileMode, FilePolicy};
use crate::system::history::checkpoint::test_checkpoint;
use crate::system::history::manifest::Enrollment;
use crate::system::history::shadow::Overlay;
use crate::system::history::tracked::{TrackedEntry, normalize};
use std::os::unix::fs::PermissionsExt;
let state = tempfile::tempdir()?;
let Some(repo) = HistoryRepo::open_or_init_in(state.path())? else {
return Ok(());
};
let roots = Roots::current();
let scratch = tempfile::Builder::new()
.prefix(".history-removed-variant-")
.tempdir_in(&roots.home)?;
let private = normalize(scratch.path()).join("private");
std::fs::create_dir(&private)?;
std::fs::set_permissions(&private, std::fs::Permissions::from_mode(0o700))?;
let policy = FilePolicy::for_mode(FileMode::Track);
let file = TrackedEntry::new(private.join("settings.json"), "track", policy);
let containing = roots.branch_path(&private, None).unwrap();
let variant = |os: &str| crate::system::history::select::Variant {
os: vec![os.into()],
..Default::default()
};
let files = repo.compose(
&repo.empty_object("tree")?,
&[Overlay {
path: file.tree_path(&file.path)?,
object: Some(("100644".into(), repo.hash_blob(b"{}")?)),
}],
)?;
let local = Manifest {
enrollment: vec![Enrollment {
path: containing.clone(),
autosave: true,
encrypt: false,
variants: vec![variant("linux"), variant("macos")],
exclude: None,
include: None,
}],
permissions: std::collections::BTreeMap::from([
(containing.replacen("home/", "home@linux/", 1), 0o700),
(containing.replacen("home/", "home@macos/", 1), 0o700),
]),
..Default::default()
};
let head = local.write(&repo, &files)?;
repo.write_checkpoint(Some(&head), &test_checkpoint("local", Some(&head)))?;
let incoming = Manifest {
enrollment: vec![Enrollment {
path: file.tree_path(&file.path)?,
autosave: true,
encrypt: false,
variants: vec![],
exclude: None,
include: None,
}],
permissions: std::collections::BTreeMap::from([(containing.clone(), 0o750)]),
..Default::default()
};
let tree = incoming.write(&repo, &files)?;
let tracked = TrackedSet {
entries: vec![file],
manifest: incoming,
..Default::default()
};
let steps = plan(&repo, &tracked, &tree)?;
assert_eq!(steps.len(), 1);
assert_eq!(steps[0].desired, 0o750);
std::fs::set_permissions(&private, std::fs::Permissions::from_mode(0o711))?;
let err = plan(&repo, &tracked, &tree).unwrap_err();
assert!(
err.to_string().contains("unsaved directory permission"),
"{err}"
);
Ok(())
}
#[test]
fn a_dropped_enrolled_record_means_the_default_locally() -> Result<()> {
use crate::system::files::{FileMode, FilePolicy};
use crate::system::history::checkpoint::test_checkpoint;
use crate::system::history::manifest::Enrollment;
use crate::system::history::shadow::Overlay;
use crate::system::history::tracked::{TrackedEntry, normalize};
use std::os::unix::fs::PermissionsExt;
let state = tempfile::tempdir()?;
let Some(repo) = HistoryRepo::open_or_init_in(state.path())? else {
return Ok(());
};
let roots = Roots::current();
let scratch = tempfile::Builder::new()
.prefix(".history-dropped-")
.tempdir_in(&roots.home)?;
let private = normalize(scratch.path()).join("private");
std::fs::create_dir(&private)?;
std::fs::set_permissions(&private, std::fs::Permissions::from_mode(0o755))?;
let policy = FilePolicy::for_mode(FileMode::Track);
let file = TrackedEntry::new(private.join("settings.json"), "track", policy);
let mut directory = TrackedEntry::new(private.clone(), "track", policy);
directory.variant = Some("linux".into());
let containing = roots.branch_path(&private, None).unwrap();
let own = directory.tree_path(&private)?;
let blob = repo.hash_blob(b"{}")?;
let files = repo.compose(
&repo.empty_object("tree")?,
&[
Overlay {
path: file.tree_path(&file.path)?,
object: Some(("100644".into(), blob.clone())),
},
Overlay {
path: directory.tree_path(&private.join("notes"))?,
object: Some(("100644".into(), blob)),
},
],
)?;
let mut manifest = Manifest {
enrollment: vec![
Enrollment {
path: file.tree_path(&file.path)?,
autosave: true,
encrypt: false,
variants: vec![],
exclude: None,
include: None,
},
Enrollment {
path: containing.clone(),
autosave: true,
encrypt: false,
variants: ["linux", "macos"]
.map(|os| crate::system::history::select::Variant {
os: vec![os.into()],
..Default::default()
})
.to_vec(),
exclude: None,
include: None,
},
],
permissions: std::collections::BTreeMap::from([(containing.clone(), 0o700)]),
..Default::default()
};
let head = manifest.write(&repo, &files)?;
repo.write_checkpoint(Some(&head), &test_checkpoint("local", Some(&head)))?;
manifest.permissions.insert(own.clone(), 0o750);
let tree = manifest.write(&repo, &files)?;
let tracked = TrackedSet {
entries: vec![file, directory],
manifest,
..Default::default()
};
let steps = plan(&repo, &tracked, &tree)?;
assert_eq!(steps.len(), 1);
assert_eq!(steps[0].path, private);
assert_eq!(steps[0].desired, 0o750);
std::fs::set_permissions(&private, std::fs::Permissions::from_mode(0o711))?;
let err = plan(&repo, &tracked, &tree).unwrap_err();
assert!(
err.to_string().contains("unsaved directory permission"),
"{err}"
);
Ok(())
}
#[test]
fn a_superseded_key_still_guards_an_unsaved_local_change() -> Result<()> {
use crate::system::files::{FileMode, FilePolicy};
use crate::system::history::checkpoint::test_checkpoint;
use crate::system::history::manifest::Enrollment;
use crate::system::history::shadow::Overlay;
use crate::system::history::tracked::{TrackedEntry, normalize};
use std::os::unix::fs::PermissionsExt;
let state = tempfile::tempdir()?;
let Some(repo) = HistoryRepo::open_or_init_in(state.path())? else {
return Ok(());
};
let roots = Roots::current();
let scratch = tempfile::Builder::new()
.prefix(".history-superseded-")
.tempdir_in(&roots.home)?;
let private = normalize(scratch.path()).join("private");
std::fs::create_dir(&private)?;
std::fs::set_permissions(&private, std::fs::Permissions::from_mode(0o700))?;
let policy = FilePolicy::for_mode(FileMode::Track);
let file = TrackedEntry::new(private.join("settings.json"), "track", policy);
let mut directory = TrackedEntry::new(private.clone(), "track", policy);
directory.variant = Some("linux".into());
let containing = roots.branch_path(&private, None).unwrap();
let own = directory.tree_path(&private)?;
let blob = repo.hash_blob(b"{}")?;
let plain_only = repo.compose(
&repo.empty_object("tree")?,
&[Overlay {
path: file.tree_path(&file.path)?,
object: Some(("100644".into(), blob.clone())),
}],
)?;
let enrollment = |path: String, variants| Enrollment {
path,
autosave: true,
encrypt: false,
variants,
exclude: None,
include: None,
};
let local = Manifest {
enrollment: vec![enrollment(file.tree_path(&file.path)?, vec![])],
permissions: std::collections::BTreeMap::from([(containing.clone(), 0o700)]),
..Default::default()
};
let head = local.write(&repo, &plain_only)?;
repo.write_checkpoint(Some(&head), &test_checkpoint("local", Some(&head)))?;
let both = repo.compose(
&plain_only,
&[Overlay {
path: directory.tree_path(&private.join("notes"))?,
object: Some(("100644".into(), blob)),
}],
)?;
let incoming = Manifest {
enrollment: vec![
enrollment(file.tree_path(&file.path)?, vec![]),
enrollment(
containing.clone(),
vec![crate::system::history::select::Variant {
os: vec!["linux".into()],
..Default::default()
}],
),
],
permissions: std::collections::BTreeMap::from([
(containing.clone(), 0o700),
(own.clone(), 0o750),
]),
..Default::default()
};
let tree = incoming.write(&repo, &both)?;
let tracked = TrackedSet {
entries: vec![file, directory],
manifest: incoming,
..Default::default()
};
let steps = plan(&repo, &tracked, &tree)?;
assert_eq!(steps.len(), 1);
assert_eq!(steps[0].desired, 0o750);
std::fs::set_permissions(&private, std::fs::Permissions::from_mode(0o711))?;
let err = plan(&repo, &tracked, &tree).unwrap_err();
assert!(
err.to_string().contains("unsaved directory permission"),
"{err}"
);
Ok(())
}
#[test]
fn a_parent_present_only_in_a_variant_stream_is_planned() -> Result<()> {
use crate::system::files::{FileMode, FilePolicy};
use crate::system::history::manifest::Enrollment;
use crate::system::history::shadow::Overlay;
use crate::system::history::tracked::{TrackedEntry, normalize};
let state = tempfile::tempdir()?;
let Some(repo) = HistoryRepo::open_or_init_in(state.path())? else {
return Ok(());
};
let roots = Roots::current();
let scratch = tempfile::Builder::new()
.prefix(".history-variant-parent-")
.tempdir_in(&roots.home)?;
let private = normalize(scratch.path()).join("private");
let mut file = TrackedEntry::new(
private.join("settings.json"),
"track",
FilePolicy::for_mode(FileMode::Track),
);
file.variant = Some("linux".into());
let stream_path = file.tree_path(&file.path)?;
assert!(stream_path.starts_with("home@linux/"));
let containing = roots.branch_path(&private, None).unwrap();
let files = repo.compose(
&repo.empty_object("tree")?,
&[Overlay {
path: stream_path,
object: Some(("100644".into(), repo.hash_blob(b"{}")?)),
}],
)?;
assert!(repo.object_at(&files, &containing)?.is_none());
let manifest = Manifest {
enrollment: vec![Enrollment {
path: roots.branch_path(&file.path, None).unwrap(),
autosave: true,
encrypt: false,
variants: ["linux", "macos"]
.map(|os| crate::system::history::select::Variant {
os: vec![os.into()],
..Default::default()
})
.to_vec(),
exclude: None,
include: None,
}],
permissions: std::collections::BTreeMap::from([(containing.clone(), 0o700)]),
..Default::default()
};
let tree = manifest.write(&repo, &files)?;
let tracked = TrackedSet {
entries: vec![file],
manifest,
..Default::default()
};
let steps = plan(&repo, &tracked, &tree)?;
assert_eq!(steps.len(), 1);
assert_eq!(steps[0].path, private);
assert_eq!(steps[0].desired, 0o700);
assert!(steps[0].before.is_none());
use crate::system::history::checkpoint::test_checkpoint;
use std::os::unix::fs::PermissionsExt;
std::fs::create_dir(&private)?;
std::fs::set_permissions(&private, std::fs::Permissions::from_mode(0o700))?;
repo.write_checkpoint(Some(&tree), &test_checkpoint("local", Some(&tree)))?;
let mut incoming = tracked.manifest.clone();
incoming.permissions.insert(containing.clone(), 0o750);
let tree = incoming.write(&repo, &files)?;
let tracked = TrackedSet {
manifest: incoming,
..tracked
};
let steps = plan(&repo, &tracked, &tree)?;
assert_eq!(steps.len(), 1);
assert_eq!(steps[0].desired, 0o750);
std::fs::set_permissions(&private, std::fs::Permissions::from_mode(0o711))?;
let err = plan(&repo, &tracked, &tree).unwrap_err();
assert!(
err.to_string().contains("unsaved directory permission"),
"{err}"
);
Ok(())
}
#[test]
fn matching_parent_without_a_local_record_is_not_unsaved() -> Result<()> {
use crate::system::files::{FileMode, FilePolicy};
use crate::system::history::checkpoint::test_checkpoint;
use crate::system::history::manifest::Enrollment;
use crate::system::history::shadow::Overlay;
use crate::system::history::tracked::{TrackedEntry, normalize};
use std::os::unix::fs::PermissionsExt;
let state = tempfile::tempdir()?;
let Some(repo) = HistoryRepo::open_or_init_in(state.path())? else {
return Ok(());
};
let roots = Roots::current();
let parent = tempfile::Builder::new()
.prefix(".history-parent-")
.tempdir_in(&roots.home)?;
let parent_path = normalize(parent.path());
let settings = parent_path.join("settings.json");
std::fs::write(&settings, "{}")?;
std::fs::set_permissions(&parent_path, std::fs::Permissions::from_mode(0o700))?;
let policy = FilePolicy::for_mode(FileMode::Track);
let entry = TrackedEntry::new(settings.clone(), "track", policy);
let file = entry.tree_path(&settings)?;
let portable = roots.branch_path(&parent_path, None).unwrap();
let blob = repo.hash_blob(b"{}")?;
let files = repo.compose(
&repo.empty_object("tree")?,
&[Overlay {
path: file.clone(),
object: Some(("100644".into(), blob)),
}],
)?;
let mut manifest = Manifest {
enrollment: vec![Enrollment {
path: file,
autosave: true,
encrypt: false,
variants: vec![],
exclude: None,
include: None,
}],
..Default::default()
};
let local = manifest.write(&repo, &files)?;
repo.write_checkpoint(Some(&local), &test_checkpoint("local", Some(&local)))?;
manifest.permissions.insert(portable.clone(), 0o700);
let incoming = manifest.write(&repo, &files)?;
let tracked = TrackedSet {
entries: vec![entry],
manifest,
..Default::default()
};
assert!(plan(&repo, &tracked, &incoming)?.is_empty());
std::fs::set_permissions(&parent_path, std::fs::Permissions::from_mode(0o710))?;
let err = plan(&repo, &tracked, &incoming).unwrap_err();
assert!(
err.to_string().contains("unsaved directory permission"),
"{err}"
);
std::fs::set_permissions(&parent_path, std::fs::Permissions::from_mode(0o755))?;
let steps = plan(&repo, &tracked, &incoming)?;
assert_eq!(steps.len(), 1);
assert_eq!(steps[0].path, parent_path);
assert_eq!(steps[0].desired, 0o700);
Ok(())
}
#[test]
fn permission_transaction_preserves_concurrent_changes() -> Result<()> {
let temp = tempfile::tempdir()?;
let path = temp.path().join("private");
std::fs::create_dir(&path)?;
set_mode(&path, 0o755)?;
let mut step = Step {
path: path.clone(),
before: observe(&path)?,
desired: 0o700,
written: None,
};
step.apply()?;
std::fs::write(path.join("user-edit"), "keep")?;
step.recover()?;
assert_eq!(observe(&path)?.unwrap().2, 0o755);
assert_eq!(std::fs::read_to_string(path.join("user-edit"))?, "keep");
step.before = observe(&path)?;
step.apply()?;
set_mode(&path, 0o750)?;
assert!(step.recover().is_err());
assert_eq!(observe(&path)?.unwrap().2, 0o750);
Ok(())
}
}