diff --git a/Cargo.lock b/Cargo.lock index 498354070..7cafcaab0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1140,6 +1140,7 @@ dependencies = [ "log", "memmap2", "nix", + "nom", "normalize-path", "pna", "rand", diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 63f40a518..a48449397 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -22,6 +22,7 @@ globset = "0.4.15" ignore = "0.4.23" itertools = "0.13.0" memmap2 = { version = "0.9.5", optional = true } +nom = "7.1.3" normalize-path = "0.2.1" pna = { version = "0.19.0", path = "../pna" } rayon = "1.10.0" diff --git a/cli/src/chunk/acl.rs b/cli/src/chunk/acl.rs index 9450bdc09..5d9a36429 100644 --- a/cli/src/chunk/acl.rs +++ b/cli/src/chunk/acl.rs @@ -140,7 +140,6 @@ pub struct Ace { } impl Ace { - #[cfg(feature = "acl")] #[inline] pub(crate) fn to_bytes(&self) -> Vec { self.to_string().into_bytes() diff --git a/cli/src/command.rs b/cli/src/command.rs index dcf3a44f8..d3cf10337 100644 --- a/cli/src/command.rs +++ b/cli/src/command.rs @@ -1,3 +1,4 @@ +mod acl; pub mod append; mod chmod; mod chown; diff --git a/cli/src/command/acl.rs b/cli/src/command/acl.rs new file mode 100644 index 000000000..2e801efe1 --- /dev/null +++ b/cli/src/command/acl.rs @@ -0,0 +1,441 @@ +use crate::{ + chunk::{Ace, AcePlatform, Flag, Identifier, OwnerType, Permission}, + cli::{PasswordArgs, SolidEntriesTransformStrategy, SolidEntriesTransformStrategyArgs}, + command::{ + ask_password, + commons::{ + run_entries, run_transform_entry, TransformStrategyKeepSolid, TransformStrategyUnSolid, + }, + Command, + }, + ext::NormalEntryExt, + utils::{GlobPatterns, PathPartExt}, +}; +use clap::{Parser, ValueHint}; +use nom::{ + branch::alt, + bytes::complete::{tag, take_while}, + character::complete::char, + combinator::{map, opt}, + sequence::tuple, +}; +use pna::{Chunk, NormalEntry, RawChunk}; +use std::io; +use std::path::PathBuf; +use std::str::FromStr; + +#[derive(Parser, Clone, Eq, PartialEq, Hash, Debug)] +#[command(args_conflicts_with_subcommands = true, arg_required_else_help = true)] +pub(crate) struct AclCommand { + #[command(subcommand)] + command: XattrCommands, +} + +impl Command for AclCommand { + #[inline] + fn execute(self) -> io::Result<()> { + match self.command { + XattrCommands::Get(cmd) => cmd.execute(), + XattrCommands::Set(cmd) => cmd.execute(), + } + } +} + +#[derive(Parser, Clone, Eq, PartialEq, Hash, Debug)] +pub(crate) enum XattrCommands { + #[command(about = "Get acl of entries")] + Get(GetAclCommand), + #[command(about = "Set acl of entries")] + Set(SetAclCommand), +} + +#[derive(Parser, Clone, Eq, PartialEq, Hash, Debug)] +pub(crate) struct GetAclCommand { + #[arg(value_hint = ValueHint::FilePath)] + archive: PathBuf, + #[arg(value_hint = ValueHint::AnyPath)] + files: Vec, + #[command(flatten)] + password: PasswordArgs, +} + +impl Command for GetAclCommand { + #[inline] + fn execute(self) -> io::Result<()> { + archive_get_acl(self) + } +} + +#[derive(Parser, Clone, Eq, PartialEq, Hash, Debug)] +pub(crate) struct SetAclCommand { + #[arg(value_hint = ValueHint::FilePath)] + archive: PathBuf, + #[arg(value_hint = ValueHint::AnyPath)] + files: Vec, + #[arg(short = 'm', help = "")] + modify: Option, + #[arg(short = 'x', help = "")] + remove: Option, + #[command(flatten)] + transform_strategy: SolidEntriesTransformStrategyArgs, + #[command(flatten)] + password: PasswordArgs, +} + +impl Command for SetAclCommand { + #[inline] + fn execute(self) -> io::Result<()> { + archive_set_acl(self) + } +} + +#[derive(Clone, Eq, PartialEq, Hash, Debug)] +pub(crate) struct AclEntries { + default: bool, + owner: OwnerType, + permissions: Option>, +} + +impl AclEntries { + fn is_match(&self, ace: &Ace) -> bool { + if self.default != ace.flags.contains(Flag::DEFAULT) { + return false; + } + if self.owner != ace.owner_type { + return false; + } + true + } + + fn to_ace(&self) -> Ace { + Ace { + platform: AcePlatform::General, + flags: if self.default { + Flag::DEFAULT + } else { + Flag::empty() + }, + owner_type: self.owner.clone(), + allow: true, + permission: if let Some(permissions) = &self.permissions { + let mut permission = Permission::empty(); + for (f, names) in Permission::PERMISSION_NAME_MAP { + if names.iter().any(|it| permissions.iter().any(|s| s == it)) { + permission.insert(*f); + } + } + permission + } else { + Permission::empty() + }, + } + } +} + +impl FromStr for AclEntries { + type Err = String; + + /// `"[d[efault]:] [u[ser]:]uid [:perms]"` + /// `"[d[efault]:] g[roup]:gid [:perms]"` + /// `"[d[efault]:] m[ask][:] [:perms]"` + /// `"[d[efault]:] o[ther][:] [:perms]"` + #[inline] + fn from_str(s: &str) -> Result { + fn kw_default(s: &str) -> nom::IResult<&str, (char, Option<&str>)> { + tuple((char('d'), opt(tag("efault"))))(s) + } + fn kw_user(s: &str) -> nom::IResult<&str, (char, Option<&str>)> { + tuple((char('u'), opt(tag("ser"))))(s) + } + fn kw_group(s: &str) -> nom::IResult<&str, (char, Option<&str>)> { + tuple((char('g'), opt(tag("roup"))))(s) + } + fn kw_other(s: &str) -> nom::IResult<&str, (char, Option<&str>)> { + tuple((char('o'), opt(tag("ther"))))(s) + } + fn kw_mask(s: &str) -> nom::IResult<&str, (char, Option<&str>)> { + tuple((char('m'), opt(tag("ask"))))(s) + } + let (p, v) = map( + tuple(( + opt(map(tuple((kw_default, char(':'))), |_| true)), + alt(( + map(tuple((kw_other, opt(char(':')))), |_| OwnerType::Other), + map(tuple((kw_mask, opt(char(':')))), |_| OwnerType::Mask), + map( + tuple((kw_group, char(':'), take_while(|c| c != ':'))), + |(_, _, gid)| { + if gid.is_empty() { + OwnerType::OwnerGroup + } else { + OwnerType::Group(Identifier(gid.into())) + } + }, + ), + map( + tuple((opt(tuple((kw_user, char(':')))), take_while(|c| c != ':'))), + |(_, uid)| { + if uid.is_empty() { + OwnerType::Owner + } else { + OwnerType::User(Identifier(uid.into())) + } + }, + ), + )), + opt(map( + tuple((char(':'), take_while(|_| true))), + |(_, c): (_, &str)| { + if c.is_empty() { + Vec::new() + } else { + c.split(',').map(|it| it.to_string()).collect() + } + }, + )), + )), + |(d, owner, permissions)| AclEntries { + default: d.unwrap_or_default(), + owner, + permissions, + }, + )(s) + .map_err(|it| it.to_string())?; + if !p.is_empty() { + return Err(format!("unexpected value: {}", p)); + } + Ok(v) + } +} + +fn archive_get_acl(args: GetAclCommand) -> io::Result<()> { + let password = ask_password(args.password)?; + if args.files.is_empty() { + return Ok(()); + } + let globs = GlobPatterns::new(args.files) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?; + + run_entries( + &args.archive, + || password.as_deref(), + |entry| { + let entry = entry?; + let name = entry.header().path(); + let permission = entry.metadata().permission(); + if globs.matches_any(name) { + println!("# file: {}", name); + println!( + "# owner: {}", + permission.map(|it| it.uname()).unwrap_or("-") + ); + println!( + "# group: {}", + permission.map(|it| it.gname()).unwrap_or("-") + ); + for ace in entry.acl()? { + println!("{}", ace); + } + } + Ok(()) + }, + )?; + Ok(()) +} + +fn archive_set_acl(args: SetAclCommand) -> io::Result<()> { + let password = ask_password(args.password)?; + if args.files.is_empty() { + return Ok(()); + } + let globs = GlobPatterns::new(args.files) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?; + + match args.transform_strategy.strategy() { + SolidEntriesTransformStrategy::UnSolid => run_transform_entry( + args.archive.remove_part().unwrap(), + &args.archive, + || password.as_deref(), + |entry| { + let entry = entry?; + if globs.matches_any(entry.header().path()) { + Ok(Some(transform_entry( + entry, + args.modify.as_ref(), + args.remove.as_ref(), + ))) + } else { + Ok(Some(entry)) + } + }, + TransformStrategyUnSolid, + ), + SolidEntriesTransformStrategy::KeepSolid => run_transform_entry( + args.archive.remove_part().unwrap(), + &args.archive, + || password.as_deref(), + |entry| { + let entry = entry?; + if globs.matches_any(entry.header().path()) { + Ok(Some(transform_entry( + entry, + args.modify.as_ref(), + args.remove.as_ref(), + ))) + } else { + Ok(Some(entry)) + } + }, + TransformStrategyKeepSolid, + ), + } +} + +#[inline] +fn transform_entry( + entry: NormalEntry, + modify: Option<&AclEntries>, + remove: Option<&AclEntries>, +) -> NormalEntry +where + T: Clone, + RawChunk: Chunk, + RawChunk: From, +{ + let mut acl = entry.acl().unwrap_or_default(); + let extra_without_known = entry + .extra_chunks() + .iter() + .filter(|it| it.ty() != crate::chunk::faCe) + .cloned(); + if let Some(modify) = modify { + let ace = modify.to_ace(); + let item = acl.iter_mut().find(|it| modify.is_match(it)); + if let Some(item) = item { + log::debug!("Modifying ace {} to {}", item, ace); + item.permission = ace.permission; + } else { + log::debug!("Adding ace {} ", ace); + acl.push(ace); + } + } + if let Some(remove) = remove { + log::debug!("Removing ace {}", remove.to_ace()); + acl.retain(|it| !remove.is_match(it)); + } + let extra_chunks = acl + .into_iter() + .map(|it| RawChunk::from_data(crate::chunk::faCe, it.to_bytes()).into()) + .chain(extra_without_known) + .collect::>(); + entry.with_extra_chunks(&extra_chunks) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_acl_user() { + assert_eq!( + AclEntries::from_str("uname").unwrap(), + AclEntries { + default: false, + owner: OwnerType::User(Identifier("uname".into())), + permissions: None, + } + ); + assert_eq!( + AclEntries::from_str("user:").unwrap(), + AclEntries { + default: false, + owner: OwnerType::Owner, + permissions: None, + } + ); + assert_eq!( + AclEntries::from_str("uname:").unwrap(), + AclEntries { + default: false, + owner: OwnerType::User(Identifier("uname".into())), + permissions: Some(Vec::new()), + } + ); + } + + #[test] + fn parse_acl_group() { + assert_eq!( + AclEntries::from_str("g:").unwrap(), + AclEntries { + default: false, + owner: OwnerType::OwnerGroup, + permissions: None, + } + ); + assert_eq!( + AclEntries::from_str("group:gname").unwrap(), + AclEntries { + default: false, + owner: OwnerType::Group(Identifier("gname".into())), + permissions: None, + } + ); + assert_eq!( + AclEntries::from_str("g::").unwrap(), + AclEntries { + default: false, + owner: OwnerType::OwnerGroup, + permissions: Some(Vec::new()), + } + ); + assert_eq!( + AclEntries::from_str("group:gname:").unwrap(), + AclEntries { + default: false, + owner: OwnerType::Group(Identifier("gname".into())), + permissions: Some(Vec::new()), + } + ); + } + + #[test] + fn parse_acl_mask() { + assert_eq!( + AclEntries::from_str("m:").unwrap(), + AclEntries { + default: false, + owner: OwnerType::Mask, + permissions: None, + } + ); + assert_eq!( + AclEntries::from_str("mask:").unwrap(), + AclEntries { + default: false, + owner: OwnerType::Mask, + permissions: None, + } + ); + } + + #[test] + fn parse_acl_other() { + assert_eq!( + AclEntries::from_str("o:").unwrap(), + AclEntries { + default: false, + owner: OwnerType::Other, + permissions: None, + } + ); + assert_eq!( + AclEntries::from_str("other:").unwrap(), + AclEntries { + default: false, + owner: OwnerType::Other, + permissions: None, + } + ); + } +} diff --git a/cli/src/command/experimental.rs b/cli/src/command/experimental.rs index 3a362d942..f6a540b55 100644 --- a/cli/src/command/experimental.rs +++ b/cli/src/command/experimental.rs @@ -19,6 +19,7 @@ impl Command for ExperimentalCommand { ExperimentalCommands::Chown(cmd) => cmd.execute(), ExperimentalCommands::Chmod(cmd) => cmd.execute(), ExperimentalCommands::Xattr(cmd) => cmd.execute(), + ExperimentalCommands::Acl(cmd) => cmd.execute(), } } } @@ -37,4 +38,6 @@ pub(crate) enum ExperimentalCommands { Chmod(command::chmod::ChmodCommand), #[command(about = "Manipulate extended attributes")] Xattr(command::xattr::XattrCommand), + #[command(about = "Manipulate ACLs of entries")] + Acl(command::acl::AclCommand), } diff --git a/cli/tests/acl.rs b/cli/tests/acl.rs new file mode 100644 index 000000000..044d0dd33 --- /dev/null +++ b/cli/tests/acl.rs @@ -0,0 +1,72 @@ +use clap::Parser; +use portable_network_archive::{cli, command}; + +#[test] +fn archive_acl_get_set() { + command::entry(cli::Cli::parse_from([ + "pna", + "--quiet", + "c", + &format!("{}/acl_get_set.pna", env!("CARGO_TARGET_TMPDIR")), + "--overwrite", + "-r", + "../resources/test/raw", + ])) + .unwrap(); + command::entry(cli::Cli::parse_from([ + "pna", + "--quiet", + "experimental", + "acl", + "set", + &format!("{}/acl_get_set.pna", env!("CARGO_TARGET_TMPDIR")), + "resources/test/raw/text.txt", + "-m", + "u:test:r,w,x", + ])) + .unwrap(); + command::entry(cli::Cli::parse_from([ + "pna", + "--quiet", + "experimental", + "acl", + "set", + &format!("{}/acl_get_set.pna", env!("CARGO_TARGET_TMPDIR")), + "resources/test/raw/text.txt", + "-m", + "g:test_group:r,w,x", + ])) + .unwrap(); + command::entry(cli::Cli::parse_from([ + "pna", + "--quiet", + "experimental", + "acl", + "set", + &format!("{}/acl_get_set.pna", env!("CARGO_TARGET_TMPDIR")), + "resources/test/raw/text.txt", + "-x", + "g:test_group", + ])) + .unwrap(); + command::entry(cli::Cli::parse_from([ + "pna", + "--quiet", + "experimental", + "acl", + "get", + &format!("{}/acl_get_set.pna", env!("CARGO_TARGET_TMPDIR")), + "resources/test/raw/text.txt", + ])) + .unwrap(); + command::entry(cli::Cli::parse_from([ + "pna", + "--quiet", + "x", + &format!("{}/acl_get_set.pna", env!("CARGO_TARGET_TMPDIR")), + "--overwrite", + "--out-dir", + &format!("{}/acl_get_set/", env!("CARGO_TARGET_TMPDIR")), + ])) + .unwrap(); +}