Better support existing files with CreateFile and CreateorInsertIntoFile (#239)

* Better support existing files with CreateFile and CreateorInsertIntoFile

* Skip instead of complete if found

* Fixup some permissions

* Fixup nix version used

* Use unix module instead of linux one

* Use unix module instead of linux one in other spot

* Also cover CreateDirectory

* Add some more tests

* Fixup messaging

* Corect some logic inversions
This commit is contained in:
Ana Hobden 2023-02-10 10:56:22 -08:00 committed by GitHub
parent ce28eedf2a
commit 20b054b50c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 317 additions and 38 deletions

View file

@ -1,4 +1,4 @@
use std::os::unix::prelude::PermissionsExt; use std::os::unix::fs::{MetadataExt, PermissionsExt};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use nix::unistd::{chown, Group, User}; use nix::unistd::{chown, Group, User};
@ -32,25 +32,53 @@ impl CreateDirectory {
mode: impl Into<Option<u32>>, mode: impl Into<Option<u32>>,
force_prune_on_revert: bool, force_prune_on_revert: bool,
) -> Result<StatefulAction<Self>, ActionError> { ) -> Result<StatefulAction<Self>, ActionError> {
let path = path.as_ref(); let path = path.as_ref().to_path_buf();
let user = user.into(); let user = user.into();
let group = group.into(); let group = group.into();
let mode = mode.into(); let mode = mode.into();
let action_state = if path.exists() { let action_state = if path.exists() {
let metadata = tokio::fs::metadata(path) let metadata = tokio::fs::metadata(&path)
.await .await
.map_err(|e| ActionError::GettingMetadata(path.to_path_buf(), e))?; .map_err(|e| ActionError::GettingMetadata(path.clone(), e))?;
if metadata.is_dir() { if !metadata.is_dir() {
tracing::debug!(
"Creating directory `{}` already complete, skipping",
path.display(),
);
// TODO: Validate owner/group...
ActionState::Skipped
} else {
return Err(ActionError::Exists(path.to_owned())); return Err(ActionError::Exists(path.to_owned()));
} }
// Does it have the right user/group?
if let Some(user) = &user {
// If the file exists, the user must also exist to be correct.
let expected_uid = User::from_name(user.as_str())
.map_err(|e| ActionError::GettingUserId(user.clone(), e))?
.ok_or_else(|| ActionError::NoUser(user.clone()))?
.uid;
let found_uid = metadata.uid();
if found_uid != expected_uid.as_raw() {
return Err(ActionError::PathUserMismatch(
path.clone(),
found_uid,
expected_uid.as_raw(),
));
}
}
if let Some(group) = &group {
// If the file exists, the group must also exist to be correct.
let expected_gid = Group::from_name(group.as_str())
.map_err(|e| ActionError::GettingGroupId(group.clone(), e))?
.ok_or_else(|| ActionError::NoUser(group.clone()))?
.gid;
let found_gid = metadata.gid();
if found_gid != expected_gid.as_raw() {
return Err(ActionError::PathGroupMismatch(
path.clone(),
found_gid,
expected_gid.as_raw(),
));
}
}
tracing::debug!("Creating directory `{}` already complete", path.display(),);
ActionState::Skipped
} else { } else {
ActionState::Uncompleted ActionState::Uncompleted
}; };

View file

@ -1,10 +1,13 @@
use nix::unistd::{chown, Group, User}; use nix::unistd::{chown, Group, User};
use tracing::{span, Span}; use tracing::{span, Span};
use std::path::{Path, PathBuf}; use std::{
os::{unix::fs::MetadataExt, unix::fs::PermissionsExt},
path::{Path, PathBuf},
};
use tokio::{ use tokio::{
fs::{remove_file, OpenOptions}, fs::{remove_file, File, OpenOptions},
io::AsyncWriteExt, io::{AsyncReadExt, AsyncWriteExt},
}; };
use crate::action::{Action, ActionDescription, ActionError, StatefulAction}; use crate::action::{Action, ActionDescription, ActionError, StatefulAction};
@ -36,20 +39,87 @@ impl CreateFile {
force: bool, force: bool,
) -> Result<StatefulAction<Self>, ActionError> { ) -> Result<StatefulAction<Self>, ActionError> {
let path = path.as_ref().to_path_buf(); let path = path.as_ref().to_path_buf();
let mode = mode.into();
if path.exists() && !force { let user = user.into();
return Err(ActionError::Exists(path.to_path_buf())); let group = group.into();
} let this = Self {
Ok(Self {
path, path,
user: user.into(), user,
group: group.into(), group,
mode: mode.into(), mode,
buf, buf,
force, force,
};
if this.path.exists() {
// If the path exists, perhaps we can just skip this
let mut file = File::open(&this.path)
.await
.map_err(|e| ActionError::Open(this.path.clone(), e))?;
let metadata = file
.metadata()
.await
.map_err(|e| ActionError::GettingMetadata(this.path.clone(), e))?;
if let Some(mode) = mode {
// Does the file have the right permissions?
let discovered_mode = metadata.permissions().mode();
if discovered_mode != mode {
return Err(ActionError::PathModeMismatch(
this.path.clone(),
discovered_mode,
mode,
));
} }
.into()) }
// Does it have the right user/group?
if let Some(user) = &this.user {
// If the file exists, the user must also exist to be correct.
let expected_uid = User::from_name(user.as_str())
.map_err(|e| ActionError::GettingUserId(user.clone(), e))?
.ok_or_else(|| ActionError::NoUser(user.clone()))?
.uid;
let found_uid = metadata.uid();
if found_uid != expected_uid.as_raw() {
return Err(ActionError::PathUserMismatch(
this.path.clone(),
found_uid,
expected_uid.as_raw(),
));
}
}
if let Some(group) = &this.group {
// If the file exists, the group must also exist to be correct.
let expected_gid = Group::from_name(group.as_str())
.map_err(|e| ActionError::GettingGroupId(group.clone(), e))?
.ok_or_else(|| ActionError::NoUser(group.clone()))?
.gid;
let found_gid = metadata.gid();
if found_gid != expected_gid.as_raw() {
return Err(ActionError::PathGroupMismatch(
this.path.clone(),
found_gid,
expected_gid.as_raw(),
));
}
}
// Does it have the right content?
let mut discovered_buf = String::new();
file.read_to_string(&mut discovered_buf)
.await
.map_err(|e| ActionError::Read(this.path.clone(), e))?;
if discovered_buf != this.buf {
return Err(ActionError::Exists(this.path.clone()));
}
tracing::debug!("Creating file `{}` already complete", this.path.display());
return Ok(StatefulAction::completed(this));
}
Ok(StatefulAction::uncompleted(this))
} }
} }
@ -178,6 +248,8 @@ impl Action for CreateFile {
#[cfg(test)] #[cfg(test)]
mod test { mod test {
use super::*; use super::*;
use eyre::eyre;
use tokio::fs::write;
#[tokio::test] #[tokio::test]
async fn creates_and_deletes_file() -> eyre::Result<()> { async fn creates_and_deletes_file() -> eyre::Result<()> {
@ -206,7 +278,7 @@ mod test {
action.try_execute().await?; action.try_execute().await?;
tokio::fs::write(test_file.as_path(), "More content").await?; write(test_file.as_path(), "More content").await?;
action.try_revert().await?; action.try_revert().await?;
@ -214,4 +286,61 @@ mod test {
Ok(()) Ok(())
} }
#[tokio::test]
async fn recognizes_existing_exact_files_and_reverts_them() -> eyre::Result<()> {
let temp_dir = tempdir::TempDir::new("nix_installer_tests_create_file")?;
let test_file = temp_dir
.path()
.join("recognizes_existing_exact_files_and_reverts_them");
let test_content = "Some content";
write(test_file.as_path(), test_content).await?;
let mut action = CreateFile::plan(
test_file.clone(),
None,
None,
None,
test_content.into(),
false,
)
.await?;
action.try_execute().await?;
action.try_revert().await?;
assert!(!test_file.exists(), "File should have been deleted");
Ok(())
}
#[tokio::test]
async fn recognizes_existing_different_files_and_errors() -> eyre::Result<()> {
let temp_dir = tempdir::TempDir::new("nix_installer_tests_create_file")?;
let test_file = temp_dir
.path()
.join("recognizes_existing_different_files_and_errors");
write(test_file.as_path(), "Some content").await?;
match CreateFile::plan(
test_file.clone(),
None,
None,
None,
"Some different content".into(),
false,
)
.await
{
Err(ActionError::Exists(path)) => assert_eq!(path, test_file.as_path()),
_ => return Err(eyre!("Should have returned an ActionError::Exists error")),
}
assert!(test_file.exists(), "File should have not been deleted");
Ok(())
}
} }

View file

@ -4,11 +4,11 @@ use crate::action::{Action, ActionDescription, ActionError, StatefulAction};
use rand::Rng; use rand::Rng;
use std::{ use std::{
io::SeekFrom, io::SeekFrom,
os::unix::prelude::PermissionsExt, os::{unix::fs::MetadataExt, unix::prelude::PermissionsExt},
path::{Path, PathBuf}, path::{Path, PathBuf},
}; };
use tokio::{ use tokio::{
fs::{remove_file, OpenOptions}, fs::{remove_file, File, OpenOptions},
io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt}, io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt},
}; };
use tracing::{span, Span}; use tracing::{span, Span};
@ -46,16 +46,86 @@ impl CreateOrInsertIntoFile {
position: Position, position: Position,
) -> Result<StatefulAction<Self>, ActionError> { ) -> Result<StatefulAction<Self>, ActionError> {
let path = path.as_ref().to_path_buf(); let path = path.as_ref().to_path_buf();
let mode = mode.into();
Ok(Self { let user = user.into();
let group = group.into();
let this = Self {
path, path,
user: user.into(), user,
group: group.into(), group,
mode: mode.into(), mode,
buf, buf,
position, position,
};
if this.path.exists() {
// If the path exists, perhaps we can just skip this
let mut file = File::open(&this.path)
.await
.map_err(|e| ActionError::Open(this.path.clone(), e))?;
let metadata = file
.metadata()
.await
.map_err(|e| ActionError::GettingMetadata(this.path.clone(), e))?;
if let Some(mode) = mode {
// Does the file have the right permissions?
let discovered_mode = metadata.permissions().mode();
if discovered_mode != mode {
return Err(ActionError::PathModeMismatch(
this.path.clone(),
discovered_mode,
mode,
));
} }
.into()) }
// Does it have the right user/group?
if let Some(user) = &this.user {
// If the file exists, the user must also exist to be correct.
let expected_uid = User::from_name(user.as_str())
.map_err(|e| ActionError::GettingUserId(user.clone(), e))?
.ok_or_else(|| ActionError::NoUser(user.clone()))?
.uid;
let found_uid = metadata.uid();
if found_uid != expected_uid.as_raw() {
return Err(ActionError::PathUserMismatch(
this.path.clone(),
found_uid,
expected_uid.as_raw(),
));
}
}
if let Some(group) = &this.group {
// If the file exists, the group must also exist to be correct.
let expected_gid = Group::from_name(group.as_str())
.map_err(|e| ActionError::GettingGroupId(group.clone(), e))?
.ok_or_else(|| ActionError::NoUser(group.clone()))?
.gid;
let found_gid = metadata.gid();
if found_gid != expected_gid.as_raw() {
return Err(ActionError::PathGroupMismatch(
this.path.clone(),
found_gid,
expected_gid.as_raw(),
));
}
}
// Does it have the right content?
let mut discovered_buf = String::new();
file.read_to_string(&mut discovered_buf)
.await
.map_err(|e| ActionError::Read(this.path.clone(), e))?;
if discovered_buf.contains(&this.buf) {
tracing::debug!("Inserting into `{}` already complete", this.path.display(),);
return Ok(StatefulAction::completed(this));
}
// If not, we can't skip this, so we still do it
}
Ok(StatefulAction::uncompleted(this))
} }
} }
@ -278,6 +348,7 @@ impl Action for CreateOrInsertIntoFile {
#[cfg(test)] #[cfg(test)]
mod test { mod test {
use super::*; use super::*;
use tokio::fs::{read_to_string, write};
#[tokio::test] #[tokio::test]
async fn creates_and_deletes_file() -> eyre::Result<()> { async fn creates_and_deletes_file() -> eyre::Result<()> {
@ -336,4 +407,42 @@ mod test {
Ok(()) Ok(())
} }
#[tokio::test]
async fn recognizes_existing_containing_exact_contents_and_reverts_it() -> eyre::Result<()> {
let temp_dir = tempdir::TempDir::new("nix_installer_create_or_insert_into_file")?;
let test_file = temp_dir
.path()
.join("recognizes_existing_containing_exact_contents_and_reverts_it");
let expected_content = "Some expected content";
write(test_file.as_path(), expected_content).await?;
let added_content = "\nSome more expected content";
write(test_file.as_path(), added_content).await?;
// We test all `Position` options
let positions = [Position::Beginning, Position::End];
for position in positions {
let mut action = CreateOrInsertIntoFile::plan(
test_file.clone(),
None,
None,
None,
expected_content.into(),
position,
)
.await?;
action.try_execute().await?;
action.try_revert().await?;
assert!(test_file.exists(), "File should have not been deleted");
let after_revert_content = read_to_string(&test_file).await?;
assert_eq!(after_revert_content, added_content);
}
Ok(())
}
} }

View file

@ -70,7 +70,7 @@ impl ConfigureShellProfile {
profile_target_path, profile_target_path,
None, None,
None, None,
0o0755, None,
shell_buf.to_string(), shell_buf.to_string(),
create_or_insert_into_file::Position::Beginning, create_or_insert_into_file::Position::Beginning,
) )

View file

@ -262,8 +262,16 @@ pub enum ActionError {
}).collect::<Vec<_>>().join(" & "))] }).collect::<Vec<_>>().join(" & "))]
Children(Vec<Box<ActionError>>), Children(Vec<Box<ActionError>>),
/// The path already exists /// The path already exists
#[error("Path exists `{0}`")] #[error(
"`{0}` exists with different content than planned, consider removing it with `rm {0}`"
)]
Exists(std::path::PathBuf), Exists(std::path::PathBuf),
#[error("`{0}` exists with a different uid ({1}) than planned ({2}), consider removing it with `rm {0}`")]
PathUserMismatch(std::path::PathBuf, u32, u32),
#[error("`{0}` exists with a different gid ({1}) than planned ({2}), consider removing it with `rm {0}`")]
PathGroupMismatch(std::path::PathBuf, u32, u32),
#[error("`{0}` exists with a different mode ({1:o}) than planned ({2:o}), consider removing it with `rm {0}`")]
PathModeMismatch(std::path::PathBuf, u32, u32),
#[error("Getting metadata for {0}`")] #[error("Getting metadata for {0}`")]
GettingMetadata(std::path::PathBuf, #[source] std::io::Error), GettingMetadata(std::path::PathBuf, #[source] std::io::Error),
#[error("Creating directory `{0}`")] #[error("Creating directory `{0}`")]
@ -339,7 +347,12 @@ pub enum ActionError {
} }
impl HasExpectedErrors for ActionError { impl HasExpectedErrors for ActionError {
fn expected(&self) -> Option<Box<dyn std::error::Error>> { fn expected<'a>(&'a self) -> Option<Box<dyn std::error::Error + 'a>> {
None match self {
Self::PathUserMismatch(_, _, _)
| Self::PathGroupMismatch(_, _, _)
| Self::PathModeMismatch(_, _, _) => Some(Box::new(self)),
_ => None,
}
} }
} }