From ea0424e2f4d13ae0e35161e1d4af13c0b792b97c Mon Sep 17 00:00:00 2001 From: Chip Senkbeil Date: Sat, 27 May 2023 02:02:59 -0500 Subject: [PATCH] Feat: set permissions support (#184) --- CHANGELOG.md | 7 + Cargo.lock | 10 + Cargo.toml | 1 + distant-core/src/api.rs | 32 +- distant-core/src/api/local.rs | 570 ++++++++++++++++++++++- distant-core/src/client/ext.rs | 29 +- distant-core/src/protocol.rs | 19 + distant-core/src/protocol/permissions.rs | 294 ++++++++++++ distant-ssh2/src/api.rs | 145 +++++- distant-ssh2/tests/ssh2/client.rs | 476 ++++++++++++++++++- distant-ssh2/tests/ssh2/ssh.rs | 3 + src/cli/commands/client.rs | 70 ++- src/options.rs | 37 ++ tests/cli/client/capabilities.rs | 2 + 14 files changed, 1682 insertions(+), 13 deletions(-) create mode 100644 distant-core/src/protocol/permissions.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 0643b00..ea8acfe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- New `SetPermissions` enum variant on protocol request +- New `set_permissions` method available `DistantApi` and implemented by local + server (ssh unavailable due to https://github.com/wez/wezterm/issues/3784) +- Implementation of `DistantChannelExt::set_permissions` + ## [0.20.0-alpha.6] ### Changed diff --git a/Cargo.lock b/Cargo.lock index c1360dc..aef47cb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -822,6 +822,7 @@ dependencies = [ "distant-core", "distant-ssh2", "env_logger", + "file-mode", "flexi_logger", "fork", "indoc", @@ -1098,6 +1099,15 @@ dependencies = [ "subtle", ] +[[package]] +name = "file-mode" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773ea145485772b8d354624b32adbe20e776353d3e48c7b03ef44e3455e9815c" +dependencies = [ + "libc", +] + [[package]] name = "filedescriptor" version = "0.8.2" diff --git a/Cargo.toml b/Cargo.toml index 8e660c4..8d25bb2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,6 +34,7 @@ derive_more = { version = "0.99.17", default-features = false, features = ["disp dialoguer = { version = "0.10.3", default-features = false } distant-core = { version = "=0.20.0-alpha.7", path = "distant-core", features = ["schemars"] } directories = "5.0.0" +file-mode = "0.1.2" flexi_logger = "0.25.3" indoc = "2.0.1" log = "0.4.17" diff --git a/distant-core/src/api.rs b/distant-core/src/api.rs index 3be21ab..3c9e25b 100644 --- a/distant-core/src/api.rs +++ b/distant-core/src/api.rs @@ -8,8 +8,8 @@ use distant_net::server::{ConnectionCtx, Reply, ServerCtx, ServerHandler}; use log::*; use crate::protocol::{ - self, Capabilities, ChangeKind, DirEntry, Environment, Error, Metadata, ProcessId, PtySize, - SearchId, SearchQuery, SystemInfo, + self, Capabilities, ChangeKind, DirEntry, Environment, Error, Metadata, Permissions, ProcessId, + PtySize, SearchId, SearchQuery, SetPermissionsOptions, SystemInfo, }; mod local; @@ -316,6 +316,24 @@ pub trait DistantApi { unsupported("metadata") } + /// Sets permissions for a file, directory, or symlink. + /// + /// * `path` - the path to the file, directory, or symlink + /// * `resolve_symlink` - if true, will resolve the path to the underlying file/directory + /// * `permissions` - the new permissions to apply + /// + /// *Override this, otherwise it will return "unsupported" as an error.* + #[allow(unused_variables)] + async fn set_permissions( + &self, + ctx: DistantCtx, + path: PathBuf, + permissions: Permissions, + options: SetPermissionsOptions, + ) -> io::Result<()> { + unsupported("set_permissions") + } + /// Searches files for matches based on a query. /// /// * `query` - the specific query to perform @@ -632,6 +650,16 @@ where .await .map(protocol::Response::Metadata) .unwrap_or_else(protocol::Response::from), + protocol::Request::SetPermissions { + path, + permissions, + options, + } => server + .api + .set_permissions(ctx, path, permissions, options) + .await + .map(|_| protocol::Response::Ok) + .unwrap_or_else(protocol::Response::from), protocol::Request::Search { query } => server .api .search(ctx, query) diff --git a/distant-core/src/api/local.rs b/distant-core/src/api/local.rs index f6849d6..3eab60e 100644 --- a/distant-core/src/api/local.rs +++ b/distant-core/src/api/local.rs @@ -2,13 +2,14 @@ use std::io; use std::path::{Path, PathBuf}; use async_trait::async_trait; +use ignore::{DirEntry as WalkDirEntry, WalkBuilder}; use log::*; use tokio::io::AsyncWriteExt; use walkdir::WalkDir; use crate::protocol::{ - Capabilities, ChangeKind, ChangeKindSet, DirEntry, Environment, FileType, Metadata, ProcessId, - PtySize, SearchId, SearchQuery, SystemInfo, + Capabilities, ChangeKind, ChangeKindSet, DirEntry, Environment, FileType, Metadata, + Permissions, ProcessId, PtySize, SearchId, SearchQuery, SetPermissionsOptions, SystemInfo, }; use crate::{DistantApi, DistantCtx}; @@ -411,6 +412,135 @@ impl DistantApi for LocalDistantApi { Metadata::read(path, canonicalize, resolve_file_type).await } + async fn set_permissions( + &self, + _ctx: DistantCtx, + path: PathBuf, + permissions: Permissions, + options: SetPermissionsOptions, + ) -> io::Result<()> { + /// Builds permissions from the metadata of `entry`, failing if metadata was unavailable. + fn build_permissions( + entry: &WalkDirEntry, + permissions: &Permissions, + ) -> io::Result { + // Load up our std permissions so we can modify them + let mut std_permissions = entry + .metadata() + .map_err(|x| match x.io_error() { + Some(x) => io::Error::new(x.kind(), format!("(Read permissions failed) {x}")), + None => io::Error::new( + io::ErrorKind::Other, + format!("(Read permissions failed) {x}"), + ), + })? + .permissions(); + + // Apply the readonly flag for all platforms + if let Some(readonly) = permissions.is_readonly() { + std_permissions.set_readonly(readonly); + } + + // On Unix platforms, we can apply a bitset change + #[cfg(unix)] + { + use std::os::unix::prelude::*; + let mut current = Permissions::from(std_permissions.clone()); + current.apply_from(permissions); + std_permissions.set_mode(current.to_unix_mode()); + } + + Ok(std_permissions) + } + + async fn set_permissions_impl( + entry: &WalkDirEntry, + permissions: &Permissions, + ) -> io::Result<()> { + let permissions = match permissions.is_complete() { + // If we are on a Unix platform and we have a full permission set, we do not need + // to retrieve the permissions to modify them and can instead produce a new + // permission set purely from the permissions + #[cfg(unix)] + true => std::fs::Permissions::from(*permissions), + + // Otherwise, we have to load in the permissions from metadata and merge with our + // changes + _ => build_permissions(entry, permissions)?, + }; + + if log_enabled!(Level::Trace) { + let mut output = String::new(); + output.push_str("readonly = "); + output.push_str(if permissions.readonly() { + "true" + } else { + "false" + }); + + #[cfg(unix)] + { + use std::os::unix::prelude::*; + output.push_str(&format!(", mode = {:#o}", permissions.mode())); + } + + trace!("Setting {:?} permissions to ({})", entry.path(), output); + } + + tokio::fs::set_permissions(entry.path(), permissions) + .await + .map_err(|x| io::Error::new(x.kind(), format!("(Set permissions failed) {x}"))) + } + + // NOTE: On Unix platforms, setting permissions would automatically resolve the symlink, + // but on Windows this is not the case. So, on Windows, we need to resolve our path by + // following the symlink prior to feeding it to the walk builder because it does not appear + // to resolve the symlink itself. + // + // We do this by canonicalizing the path if following symlinks is enabled. + let path = if options.follow_symlinks { + tokio::fs::canonicalize(path).await? + } else { + path + }; + + let walk = WalkBuilder::new(path) + .follow_links(options.follow_symlinks) + .max_depth(if options.recursive { None } else { Some(0) }) + .standard_filters(false) + .skip_stdout(true) + .build(); + + // Process as much as possible and then fail with an error + let mut errors = Vec::new(); + for entry in walk { + match entry { + Ok(entry) if entry.path_is_symlink() && options.exclude_symlinks => {} + Ok(entry) => { + if let Err(x) = set_permissions_impl(&entry, &permissions).await { + errors.push(format!("{:?}: {x}", entry.path())); + } + } + Err(x) => { + errors.push(x.to_string()); + } + } + } + + if errors.is_empty() { + Ok(()) + } else { + Err(io::Error::new( + io::ErrorKind::PermissionDenied, + errors + .into_iter() + .map(|x| format!("* {x}")) + .collect::>() + .join("\n"), + )) + } + } + async fn search( &self, ctx: DistantCtx, @@ -1810,6 +1940,442 @@ mod tests { ); } + #[test(tokio::test)] + async fn set_permissions_should_set_readonly_flag_if_specified() { + let (api, ctx, _rx) = setup(1).await; + let temp = assert_fs::TempDir::new().unwrap(); + let file = temp.child("file"); + file.write_str("some text").unwrap(); + + // Verify that not readonly by default + let permissions = tokio::fs::symlink_metadata(file.path()) + .await + .unwrap() + .permissions(); + assert!(!permissions.readonly(), "File is already set to readonly"); + + // Change the file permissions + api.set_permissions( + ctx, + file.path().to_path_buf(), + Permissions::readonly(), + Default::default(), + ) + .await + .unwrap(); + + // Retrieve permissions to verify set + let permissions = tokio::fs::symlink_metadata(file.path()) + .await + .unwrap() + .permissions(); + assert!(permissions.readonly(), "File not set to readonly"); + } + + #[test(tokio::test)] + #[cfg_attr(not(unix), ignore)] + async fn set_permissions_should_set_unix_permissions_if_on_unix_platform() { + #[cfg(unix)] + { + use std::os::unix::prelude::*; + + let (api, ctx, _rx) = setup(1).await; + let temp = assert_fs::TempDir::new().unwrap(); + let file = temp.child("file"); + file.write_str("some text").unwrap(); + + // Verify that permissions do not match our readonly state + let permissions = tokio::fs::symlink_metadata(file.path()) + .await + .unwrap() + .permissions(); + let mode = permissions.mode() & 0o777; + assert_ne!(mode, 0o400, "File is already set to 0o400"); + + // Change the file permissions + api.set_permissions( + ctx, + file.path().to_path_buf(), + Permissions::from_unix_mode(0o400), + Default::default(), + ) + .await + .unwrap(); + + // Retrieve file permissions to verify set + let permissions = tokio::fs::symlink_metadata(file.path()) + .await + .unwrap() + .permissions(); + + // Drop the upper bits that mode can have (only care about read/write/exec) + let mode = permissions.mode() & 0o777; + + assert_eq!(mode, 0o400, "Wrong permissions on file: {:o}", mode); + } + #[cfg(not(unix))] + { + unreachable!(); + } + } + + #[test(tokio::test)] + #[cfg_attr(unix, ignore)] + async fn set_permissions_should_set_readonly_flag_if_not_on_unix_platform() { + let (api, ctx, _rx) = setup(1).await; + let temp = assert_fs::TempDir::new().unwrap(); + let file = temp.child("file"); + file.write_str("some text").unwrap(); + + // Verify that not readonly by default + let permissions = tokio::fs::symlink_metadata(file.path()) + .await + .unwrap() + .permissions(); + assert!(!permissions.readonly(), "File is already set to readonly"); + + // Change the file permissions to be readonly (in general) + api.set_permissions( + ctx, + file.path().to_path_buf(), + Permissions::from_unix_mode(0o400), + Default::default(), + ) + .await + .unwrap(); + + #[cfg(not(unix))] + { + // Retrieve file permissions to verify set + let permissions = tokio::fs::symlink_metadata(file.path()) + .await + .unwrap() + .permissions(); + + assert!(permissions.readonly(), "File not marked as readonly"); + } + #[cfg(unix)] + { + unreachable!(); + } + } + + #[test(tokio::test)] + async fn set_permissions_should_not_recurse_if_option_false() { + let (api, ctx, _rx) = setup(1).await; + let temp = assert_fs::TempDir::new().unwrap(); + let file = temp.child("file"); + file.write_str("some text").unwrap(); + + let symlink = temp.child("link"); + symlink.symlink_to_file(file.path()).unwrap(); + + // Verify that dir is not readonly by default + let permissions = tokio::fs::symlink_metadata(temp.path()) + .await + .unwrap() + .permissions(); + assert!( + !permissions.readonly(), + "Temp dir is already set to readonly" + ); + + // Verify that file is not readonly by default + let permissions = tokio::fs::symlink_metadata(file.path()) + .await + .unwrap() + .permissions(); + assert!(!permissions.readonly(), "File is already set to readonly"); + + // Verify that symlink is not readonly by default + let permissions = tokio::fs::symlink_metadata(symlink.path()) + .await + .unwrap() + .permissions(); + assert!( + !permissions.readonly(), + "Symlink is already set to readonly" + ); + + // Change the permissions of the directory and not the contents underneath + api.set_permissions( + ctx, + temp.path().to_path_buf(), + Permissions::readonly(), + SetPermissionsOptions { + recursive: false, + ..Default::default() + }, + ) + .await + .unwrap(); + + // Retrieve permissions of the file, symlink, and directory to verify set + let permissions = tokio::fs::symlink_metadata(temp.path()) + .await + .unwrap() + .permissions(); + assert!(permissions.readonly(), "Temp directory not set to readonly"); + + let permissions = tokio::fs::symlink_metadata(file.path()) + .await + .unwrap() + .permissions(); + assert!(!permissions.readonly(), "File unexpectedly set to readonly"); + + let permissions = tokio::fs::symlink_metadata(symlink.path()) + .await + .unwrap() + .permissions(); + assert!( + !permissions.readonly(), + "Symlink unexpectedly set to readonly" + ); + } + + #[test(tokio::test)] + async fn set_permissions_should_traverse_symlinks_while_recursing_if_following_symlinks_enabled( + ) { + let (api, ctx, _rx) = setup(1).await; + let temp = assert_fs::TempDir::new().unwrap(); + let file = temp.child("file"); + file.write_str("some text").unwrap(); + + let temp2 = assert_fs::TempDir::new().unwrap(); + let file2 = temp2.child("file"); + file2.write_str("some text").unwrap(); + + let symlink = temp.child("link"); + symlink.symlink_to_dir(temp2.path()).unwrap(); + + // Verify that symlink is not readonly by default + let permissions = tokio::fs::symlink_metadata(file2.path()) + .await + .unwrap() + .permissions(); + assert!(!permissions.readonly(), "File2 is already set to readonly"); + + // Change the main directory permissions + api.set_permissions( + ctx, + temp.path().to_path_buf(), + Permissions::readonly(), + SetPermissionsOptions { + follow_symlinks: true, + recursive: true, + ..Default::default() + }, + ) + .await + .unwrap(); + + // Retrieve permissions referenced by another directory + let permissions = tokio::fs::symlink_metadata(file2.path()) + .await + .unwrap() + .permissions(); + assert!(permissions.readonly(), "File2 not set to readonly"); + } + + #[test(tokio::test)] + async fn set_permissions_should_not_traverse_symlinks_while_recursing_if_following_symlinks_disabled( + ) { + let (api, ctx, _rx) = setup(1).await; + let temp = assert_fs::TempDir::new().unwrap(); + let file = temp.child("file"); + file.write_str("some text").unwrap(); + + let temp2 = assert_fs::TempDir::new().unwrap(); + let file2 = temp2.child("file"); + file2.write_str("some text").unwrap(); + + let symlink = temp.child("link"); + symlink.symlink_to_dir(temp2.path()).unwrap(); + + // Verify that symlink is not readonly by default + let permissions = tokio::fs::symlink_metadata(file2.path()) + .await + .unwrap() + .permissions(); + assert!(!permissions.readonly(), "File2 is already set to readonly"); + + // Change the main directory permissions + api.set_permissions( + ctx, + temp.path().to_path_buf(), + Permissions::readonly(), + SetPermissionsOptions { + follow_symlinks: false, + recursive: true, + ..Default::default() + }, + ) + .await + .unwrap(); + + // Retrieve permissions referenced by another directory + let permissions = tokio::fs::symlink_metadata(file2.path()) + .await + .unwrap() + .permissions(); + assert!( + !permissions.readonly(), + "File2 unexpectedly set to readonly" + ); + } + + #[test(tokio::test)] + async fn set_permissions_should_skip_symlinks_if_exclude_symlinks_enabled() { + let (api, ctx, _rx) = setup(1).await; + let temp = assert_fs::TempDir::new().unwrap(); + let file = temp.child("file"); + file.write_str("some text").unwrap(); + + let symlink = temp.child("link"); + symlink.symlink_to_file(file.path()).unwrap(); + + // Verify that symlink is not readonly by default + let permissions = tokio::fs::symlink_metadata(symlink.path()) + .await + .unwrap() + .permissions(); + assert!( + !permissions.readonly(), + "Symlink is already set to readonly" + ); + + // Change the symlink permissions + api.set_permissions( + ctx, + symlink.path().to_path_buf(), + Permissions::readonly(), + SetPermissionsOptions { + exclude_symlinks: true, + ..Default::default() + }, + ) + .await + .unwrap(); + + // Retrieve permissions to verify not set + let permissions = tokio::fs::symlink_metadata(symlink.path()) + .await + .unwrap() + .permissions(); + assert!( + !permissions.readonly(), + "Symlink (or file underneath) set to readonly" + ); + } + + #[test(tokio::test)] + async fn set_permissions_should_support_recursive_if_option_specified() { + let (api, ctx, _rx) = setup(1).await; + let temp = assert_fs::TempDir::new().unwrap(); + let file = temp.child("file"); + file.write_str("some text").unwrap(); + + // Verify that dir is not readonly by default + let permissions = tokio::fs::symlink_metadata(temp.path()) + .await + .unwrap() + .permissions(); + assert!( + !permissions.readonly(), + "Temp dir is already set to readonly" + ); + + // Verify that file is not readonly by default + let permissions = tokio::fs::symlink_metadata(file.path()) + .await + .unwrap() + .permissions(); + assert!(!permissions.readonly(), "File is already set to readonly"); + + // Change the permissions of the file pointed to by the symlink + api.set_permissions( + ctx, + temp.path().to_path_buf(), + Permissions::readonly(), + SetPermissionsOptions { + recursive: true, + ..Default::default() + }, + ) + .await + .unwrap(); + + // Retrieve permissions of the file, symlink, and directory to verify set + let permissions = tokio::fs::symlink_metadata(temp.path()) + .await + .unwrap() + .permissions(); + assert!(permissions.readonly(), "Temp directory not set to readonly"); + + let permissions = tokio::fs::symlink_metadata(file.path()) + .await + .unwrap() + .permissions(); + assert!(permissions.readonly(), "File not set to readonly"); + } + + #[test(tokio::test)] + async fn set_permissions_should_support_following_explicit_symlink_if_option_specified() { + let (api, ctx, _rx) = setup(1).await; + let temp = assert_fs::TempDir::new().unwrap(); + let file = temp.child("file"); + file.write_str("some text").unwrap(); + + let symlink = temp.child("link"); + symlink.symlink_to_file(file.path()).unwrap(); + + // Verify that file is not readonly by default + let permissions = tokio::fs::symlink_metadata(file.path()) + .await + .unwrap() + .permissions(); + assert!(!permissions.readonly(), "File is already set to readonly"); + + // Verify that symlink is not readonly by default + let permissions = tokio::fs::symlink_metadata(symlink.path()) + .await + .unwrap() + .permissions(); + assert!( + !permissions.readonly(), + "Symlink is already set to readonly" + ); + + // Change the permissions of the file pointed to by the symlink + api.set_permissions( + ctx, + symlink.path().to_path_buf(), + Permissions::readonly(), + SetPermissionsOptions { + follow_symlinks: true, + ..Default::default() + }, + ) + .await + .unwrap(); + + // Retrieve permissions of the file and symlink to verify set + let permissions = tokio::fs::symlink_metadata(file.path()) + .await + .unwrap() + .permissions(); + assert!(permissions.readonly(), "File not set to readonly"); + + let permissions = tokio::fs::symlink_metadata(symlink.path()) + .await + .unwrap() + .permissions(); + assert!( + !permissions.readonly(), + "Symlink unexpectedly set to readonly" + ); + } + // NOTE: Ignoring on windows because it's using WSL which wants a Linux path // with / but thinks it's on windows and is providing \ #[test(tokio::test)] diff --git a/distant-core/src/client/ext.rs b/distant-core/src/client/ext.rs index 9449f8c..8361d40 100644 --- a/distant-core/src/client/ext.rs +++ b/distant-core/src/client/ext.rs @@ -11,8 +11,8 @@ use crate::client::{ Watcher, }; use crate::protocol::{ - self, Capabilities, ChangeKindSet, DirEntry, Environment, Error as Failure, Metadata, PtySize, - SearchId, SearchQuery, SystemInfo, + self, Capabilities, ChangeKindSet, DirEntry, Environment, Error as Failure, Metadata, + Permissions, PtySize, SearchId, SearchQuery, SetPermissionsOptions, SystemInfo, }; pub type AsyncReturn<'a, T, E = io::Error> = @@ -57,6 +57,14 @@ pub trait DistantChannelExt { resolve_file_type: bool, ) -> AsyncReturn<'_, Metadata>; + /// Sets permissions for a path on a remote machine + fn set_permissions( + &mut self, + path: impl Into, + permissions: Permissions, + options: SetPermissionsOptions, + ) -> AsyncReturn<'_, ()>; + /// Perform a search fn search(&mut self, query: impl Into) -> AsyncReturn<'_, Searcher>; @@ -257,6 +265,23 @@ impl DistantChannelExt ) } + fn set_permissions( + &mut self, + path: impl Into, + permissions: Permissions, + options: SetPermissionsOptions, + ) -> AsyncReturn<'_, ()> { + make_body!( + self, + protocol::Request::SetPermissions { + path: path.into(), + permissions, + options, + }, + @ok + ) + } + fn search(&mut self, query: impl Into) -> AsyncReturn<'_, Searcher> { let query = query.into(); Box::pin(async move { Searcher::search(self.clone(), query).await }) diff --git a/distant-core/src/protocol.rs b/distant-core/src/protocol.rs index 29ab870..02d44b0 100644 --- a/distant-core/src/protocol.rs +++ b/distant-core/src/protocol.rs @@ -23,6 +23,9 @@ pub use filesystem::*; mod metadata; pub use metadata::*; +mod permissions; +pub use permissions::*; + mod pty; pub use pty::*; @@ -344,6 +347,22 @@ pub enum Request { resolve_file_type: bool, }, + /// Sets permissions on a file, directory, or symlink on the remote machine + #[strum_discriminants(strum( + message = "Supports setting permissions on a file, directory, or symlink" + ))] + SetPermissions { + /// The path to the file, directory, or symlink on the remote machine + path: PathBuf, + + /// New permissions to apply to the file, directory, or symlink + permissions: Permissions, + + /// Additional options to supply when setting permissions + #[serde(default)] + options: SetPermissionsOptions, + }, + /// Searches filesystem using the provided query #[strum_discriminants(strum(message = "Supports searching filesystem using queries"))] Search { diff --git a/distant-core/src/protocol/permissions.rs b/distant-core/src/protocol/permissions.rs new file mode 100644 index 0000000..4968e1a --- /dev/null +++ b/distant-core/src/protocol/permissions.rs @@ -0,0 +1,294 @@ +use bitflags::bitflags; +use serde::{Deserialize, Serialize}; + +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[serde(default, deny_unknown_fields, rename_all = "snake_case")] +pub struct SetPermissionsOptions { + /// Whether or not to exclude symlinks from traversal entirely, meaning that permissions will + /// not be set on symlinks (usually resolving the symlink and setting the permission of the + /// referenced file or directory) that are explicitly provided or show up during recursion. + pub exclude_symlinks: bool, + + /// Whether or not to traverse symlinks when recursively setting permissions. Note that this + /// does NOT influence setting permissions when encountering a symlink as most platforms will + /// resolve the symlink before setting permissions. + pub follow_symlinks: bool, + + /// Whether or not to set the permissions of the file hierarchies rooted in the paths, instead + /// of just the paths themselves. + pub recursive: bool, +} + +#[cfg(feature = "schemars")] +impl SetPermissionsOptions { + pub fn root_schema() -> schemars::schema::RootSchema { + schemars::schema_for!(SetPermissionsOptions) + } +} + +/// Represents permissions to apply to some path on a remote machine +/// +/// When used to set permissions on a file, directory, or symlink, +/// only fields that are set (not `None`) will be applied. +/// +/// On `Unix` platforms, this translates directly into the mode that +/// you would find with `chmod`. On all other platforms, this uses the +/// write flags to determine whether or not to set the readonly status. +#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +pub struct Permissions { + /// Represents whether or not owner can read from the file + pub owner_read: Option, + + /// Represents whether or not owner can write to the file + pub owner_write: Option, + + /// Represents whether or not owner can execute the file + pub owner_exec: Option, + + /// Represents whether or not associated group can read from the file + pub group_read: Option, + + /// Represents whether or not associated group can write to the file + pub group_write: Option, + + /// Represents whether or not associated group can execute the file + pub group_exec: Option, + + /// Represents whether or not other can read from the file + pub other_read: Option, + + /// Represents whether or not other can write to the file + pub other_write: Option, + + /// Represents whether or not other can execute the file + pub other_exec: Option, +} + +impl Permissions { + /// Creates a set of [`Permissions`] that indicate readonly status. + /// + /// ``` + /// use distant_core::protocol::Permissions; + /// + /// let permissions = Permissions::readonly(); + /// assert_eq!(permissions.is_readonly(), Some(true)); + /// assert_eq!(permissions.is_writable(), Some(false)); + /// ``` + pub fn readonly() -> Self { + Self { + owner_write: Some(false), + group_write: Some(false), + other_write: Some(false), + + owner_read: Some(true), + group_read: Some(true), + other_read: Some(true), + + owner_exec: None, + group_exec: None, + other_exec: None, + } + } + /// Creates a set of [`Permissions`] that indicate globally writable status. + /// + /// ``` + /// use distant_core::protocol::Permissions; + /// + /// let permissions = Permissions::writable(); + /// assert_eq!(permissions.is_readonly(), Some(false)); + /// assert_eq!(permissions.is_writable(), Some(true)); + /// ``` + pub fn writable() -> Self { + Self { + owner_write: Some(true), + group_write: Some(true), + other_write: Some(true), + + owner_read: Some(true), + group_read: Some(true), + other_read: Some(true), + + owner_exec: None, + group_exec: None, + other_exec: None, + } + } + + /// Returns true if the permission set has a value specified for each permission (no `None` + /// settings). + pub fn is_complete(&self) -> bool { + self.owner_read.is_some() + && self.owner_write.is_some() + && self.owner_exec.is_some() + && self.group_read.is_some() + && self.group_write.is_some() + && self.group_exec.is_some() + && self.other_read.is_some() + && self.other_write.is_some() + && self.other_exec.is_some() + } + + /// Returns `true` if permissions represent readonly, `false` if permissions represent + /// writable, and `None` if no permissions have been set to indicate either status. + #[inline] + pub fn is_readonly(&self) -> Option { + // Negate the writable status to indicate whether or not readonly + self.is_writable().map(|x| !x) + } + + /// Returns `true` if permissions represent ability to write, `false` if permissions represent + /// inability to write, and `None` if no permissions have been set to indicate either status. + #[inline] + pub fn is_writable(&self) -> Option { + self.owner_write + .zip(self.group_write) + .zip(self.other_write) + .map(|((owner, group), other)| owner || group || other) + } + + /// Applies `other` settings to `self`, overwriting any of the permissions in `self` with `other`. + #[inline] + pub fn apply_from(&mut self, other: &Self) { + macro_rules! apply { + ($key:ident) => {{ + if let Some(value) = other.$key { + self.$key = Some(value); + } + }}; + } + + apply!(owner_read); + apply!(owner_write); + apply!(owner_exec); + apply!(group_read); + apply!(group_write); + apply!(group_exec); + apply!(other_read); + apply!(other_write); + apply!(other_exec); + } + + /// Applies `self` settings to `other`, overwriting any of the permissions in `other` with + /// `self`. + #[inline] + pub fn apply_to(&self, other: &mut Self) { + Self::apply_from(other, self) + } + + /// Converts a Unix `mode` into the permission set. + pub fn from_unix_mode(mode: u32) -> Self { + let flags = UnixFilePermissionFlags::from_bits_truncate(mode); + Self { + owner_read: Some(flags.contains(UnixFilePermissionFlags::OWNER_READ)), + owner_write: Some(flags.contains(UnixFilePermissionFlags::OWNER_WRITE)), + owner_exec: Some(flags.contains(UnixFilePermissionFlags::OWNER_EXEC)), + group_read: Some(flags.contains(UnixFilePermissionFlags::GROUP_READ)), + group_write: Some(flags.contains(UnixFilePermissionFlags::GROUP_WRITE)), + group_exec: Some(flags.contains(UnixFilePermissionFlags::GROUP_EXEC)), + other_read: Some(flags.contains(UnixFilePermissionFlags::OTHER_READ)), + other_write: Some(flags.contains(UnixFilePermissionFlags::OTHER_WRITE)), + other_exec: Some(flags.contains(UnixFilePermissionFlags::OTHER_EXEC)), + } + } + + /// Converts to a Unix `mode` from a permission set. For any missing setting, a 0 bit is used. + pub fn to_unix_mode(&self) -> u32 { + let mut flags = UnixFilePermissionFlags::empty(); + + macro_rules! is_true { + ($opt:expr) => {{ + $opt.is_some() && $opt.unwrap() + }}; + } + + if is_true!(self.owner_read) { + flags.insert(UnixFilePermissionFlags::OWNER_READ); + } + if is_true!(self.owner_write) { + flags.insert(UnixFilePermissionFlags::OWNER_WRITE); + } + if is_true!(self.owner_exec) { + flags.insert(UnixFilePermissionFlags::OWNER_EXEC); + } + + if is_true!(self.group_read) { + flags.insert(UnixFilePermissionFlags::GROUP_READ); + } + if is_true!(self.group_write) { + flags.insert(UnixFilePermissionFlags::GROUP_WRITE); + } + if is_true!(self.group_exec) { + flags.insert(UnixFilePermissionFlags::GROUP_EXEC); + } + + if is_true!(self.other_read) { + flags.insert(UnixFilePermissionFlags::OTHER_READ); + } + if is_true!(self.other_write) { + flags.insert(UnixFilePermissionFlags::OTHER_WRITE); + } + if is_true!(self.other_exec) { + flags.insert(UnixFilePermissionFlags::OTHER_EXEC); + } + + flags.bits() + } +} + +#[cfg(feature = "schemars")] +impl Permissions { + pub fn root_schema() -> schemars::schema::RootSchema { + schemars::schema_for!(Permissions) + } +} + +#[cfg(unix)] +impl From for Permissions { + /// Converts [`std::fs::Permissions`] into [`Permissions`] using + /// [`std::os::unix::fs::PermissionsExt::mode`] to supply the bitset. + fn from(permissions: std::fs::Permissions) -> Self { + use std::os::unix::prelude::*; + Self::from_unix_mode(permissions.mode()) + } +} + +#[cfg(not(unix))] +impl From for Permissions { + /// Converts [`std::fs::Permissions`] into [`Permissions`] using the `readonly` flag. + /// + /// This will not set executable flags, but will set all read and write flags with write flags + /// being `false` if `readonly`, otherwise set to `true`. + fn from(permissions: std::fs::Permissions) -> Self { + if permissions.readonly() { + Self::readonly() + } else { + Self::writable() + } + } +} + +#[cfg(unix)] +impl From for std::fs::Permissions { + /// Converts [`Permissions`] into [`std::fs::Permissions`] using + /// [`std::os::unix::fs::PermissionsExt::from_mode`]. + fn from(permissions: Permissions) -> Self { + use std::os::unix::prelude::*; + std::fs::Permissions::from_mode(permissions.to_unix_mode()) + } +} + +bitflags! { + struct UnixFilePermissionFlags: u32 { + const OWNER_READ = 0o400; + const OWNER_WRITE = 0o200; + const OWNER_EXEC = 0o100; + const GROUP_READ = 0o40; + const GROUP_WRITE = 0o20; + const GROUP_EXEC = 0o10; + const OTHER_READ = 0o4; + const OTHER_WRITE = 0o2; + const OTHER_EXEC = 0o1; + } +} diff --git a/distant-ssh2/src/api.rs b/distant-ssh2/src/api.rs index bace98a..f960f3a 100644 --- a/distant-ssh2/src/api.rs +++ b/distant-ssh2/src/api.rs @@ -1,4 +1,4 @@ -use std::collections::{HashMap, HashSet}; +use std::collections::{HashMap, HashSet, VecDeque}; use std::io; use std::path::PathBuf; use std::sync::{Arc, Weak}; @@ -9,13 +9,15 @@ use async_once_cell::OnceCell; use async_trait::async_trait; use distant_core::net::server::ConnectionCtx; use distant_core::protocol::{ - Capabilities, CapabilityKind, DirEntry, Environment, FileType, Metadata, ProcessId, PtySize, - SystemInfo, UnixMetadata, + Capabilities, CapabilityKind, DirEntry, Environment, FileType, Metadata, Permissions, + ProcessId, PtySize, SetPermissionsOptions, SystemInfo, UnixMetadata, }; use distant_core::{DistantApi, DistantCtx}; use log::*; use tokio::sync::{mpsc, RwLock}; -use wezterm_ssh::{FilePermissions, OpenFileType, OpenOptions, Session as WezSession, WriteMode}; +use wezterm_ssh::{ + FilePermissions, OpenFileType, OpenOptions, Session as WezSession, Utf8PathBuf, WriteMode, +}; use crate::process::{spawn_pty, spawn_simple, SpawnResult}; use crate::utils::{self, to_other_error}; @@ -87,6 +89,9 @@ impl DistantApi for SshDistantApi { capabilities.take(CapabilityKind::Search); capabilities.take(CapabilityKind::CancelSearch); + // Broken via wezterm-ssh, so not supported right now + capabilities.take(CapabilityKind::SetPermissions); + Ok(capabilities) } @@ -664,7 +669,7 @@ impl DistantApi for SshDistantApi { // Check that owner, group, or other has write permission (if not, then readonly) readonly: metadata .permissions - .map(FilePermissions::is_readonly) + .map(|x| !x.owner_write && !x.group_write && !x.other_write) .unwrap_or(true), accessed: metadata.accessed.map(u128::from), modified: metadata.modified.map(u128::from), @@ -684,6 +689,136 @@ impl DistantApi for SshDistantApi { }) } + #[allow(unreachable_code)] + async fn set_permissions( + &self, + ctx: DistantCtx, + path: PathBuf, + permissions: Permissions, + options: SetPermissionsOptions, + ) -> io::Result<()> { + debug!( + "[Conn {}] Setting permissions for {:?} {{permissions: {:?}, options: {:?}}}", + ctx.connection_id, path, permissions, options + ); + + // Unsupported until issue resolved: https://github.com/wez/wezterm/issues/3784 + return Err(io::Error::new( + io::ErrorKind::Unsupported, + "Unsupported until issue resolved: https://github.com/wez/wezterm/issues/3784", + )); + + let sftp = self.session.sftp(); + + macro_rules! set_permissions { + ($path:ident, $metadata:ident) => {{ + let mut current = Permissions::from_unix_mode( + $metadata + .permissions + .ok_or_else(|| to_other_error("Unable to read file permissions"))? + .to_unix_mode(), + ); + + current.apply_from(&permissions); + + $metadata.permissions = + Some(FilePermissions::from_unix_mode(current.to_unix_mode())); + + println!("set_metadata for {:?}", $path.as_path()); + sftp.set_metadata($path.as_path(), $metadata) + .compat() + .await + .map_err(to_other_error)?; + + if $metadata.is_dir() { + Some($path) + } else { + None + } + }}; + ($path:ident) => {{ + let mut path = Utf8PathBuf::try_from($path).map_err(to_other_error)?; + + // Query metadata to determine if we are working with a symlink + println!("symlink_metadata for {:?}", path); + let mut metadata = sftp + .symlink_metadata(&path) + .compat() + .await + .map_err(to_other_error)?; + + // If we are excluding symlinks and this is a symlink, then we're done + if options.exclude_symlinks && metadata.is_symlink() { + None + } else { + // If we are following symlinks and this is a symlink, then get the real path + // and destination metadata + if options.follow_symlinks && metadata.is_symlink() { + println!("read_link for {:?}", path); + path = sftp + .read_link(path) + .compat() + .await + .map_err(to_other_error)?; + + println!("metadata for {:?}", path); + metadata = sftp + .metadata(&path) + .compat() + .await + .map_err(to_other_error)?; + } + + set_permissions!(path, metadata) + } + }}; + } + + let mut paths = VecDeque::new(); + + // Queue up our path if it is a directory + if let Some(path) = set_permissions!(path) { + paths.push_back(path); + } + + if options.recursive { + while let Some(path) = paths.pop_front() { + println!("read_dir for {:?}", path); + let paths_and_metadata = + sftp.read_dir(path).compat().await.map_err(to_other_error)?; + for (mut path, mut metadata) in paths_and_metadata { + if options.exclude_symlinks && metadata.is_symlink() { + println!("skipping symlink for {:?}", path); + continue; + } + + // If we are following symlinks, then adjust our path and metadata + if options.follow_symlinks && metadata.is_symlink() { + println!("read_link for {:?}", path); + path = sftp + .read_link(path) + .compat() + .await + .map_err(to_other_error)?; + + println!("metadata for {:?}", path); + metadata = sftp + .metadata(&path) + .compat() + .await + .map_err(to_other_error)?; + } + + if let Some(path) = set_permissions!(path, metadata) { + paths.push_back(path); + } + } + } + } + + Ok(()) + } + async fn proc_spawn( &self, ctx: DistantCtx, diff --git a/distant-ssh2/tests/ssh2/client.rs b/distant-ssh2/tests/ssh2/client.rs index c1acc99..c4fd3df 100644 --- a/distant-ssh2/tests/ssh2/client.rs +++ b/distant-ssh2/tests/ssh2/client.rs @@ -4,7 +4,9 @@ use std::time::Duration; use assert_fs::prelude::*; use assert_fs::TempDir; -use distant_core::protocol::{ChangeKindSet, Environment, FileType, Metadata}; +use distant_core::protocol::{ + ChangeKindSet, Environment, FileType, Metadata, Permissions, SetPermissionsOptions, +}; use distant_core::{DistantChannelExt, DistantClient}; use once_cell::sync::Lazy; use predicates::prelude::*; @@ -1207,6 +1209,478 @@ async fn metadata_should_resolve_file_type_of_symlink_if_flag_specified( ); } +#[rstest] +#[test(tokio::test)] +#[ignore] +async fn set_permissions_should_set_readonly_flag_if_specified( + #[future] client: Ctx, +) { + let mut client = client.await; + let temp = assert_fs::TempDir::new().unwrap(); + let file = temp.child("file"); + file.write_str("some text").unwrap(); + + // Verify that not readonly by default + let permissions = tokio::fs::symlink_metadata(file.path()) + .await + .unwrap() + .permissions(); + assert!(!permissions.readonly(), "File is already set to readonly"); + + // Change the file permissions + client + .set_permissions( + file.path().to_path_buf(), + Permissions::readonly(), + Default::default(), + ) + .await + .unwrap(); + + // Retrieve permissions to verify set + let permissions = tokio::fs::symlink_metadata(file.path()) + .await + .unwrap() + .permissions(); + assert!(permissions.readonly(), "File not set to readonly"); +} + +#[allow(unused_attributes)] +#[rstest] +#[test(tokio::test)] +#[cfg_attr(not(unix), ignore)] +#[ignore] +async fn set_permissions_should_set_unix_permissions_if_on_unix_platform( + #[future] client: Ctx, +) { + #[allow(unused_mut, unused_variables)] + let mut client = client.await; + + #[cfg(unix)] + { + use std::os::unix::prelude::*; + + let temp = assert_fs::TempDir::new().unwrap(); + let file = temp.child("file"); + file.write_str("some text").unwrap(); + + // Verify that permissions do not match our readonly state + let permissions = tokio::fs::symlink_metadata(file.path()) + .await + .unwrap() + .permissions(); + let mode = permissions.mode() & 0o777; + assert_ne!(mode, 0o400, "File is already set to 0o400"); + + // Change the file permissions + client + .set_permissions( + file.path().to_path_buf(), + Permissions::from_unix_mode(0o400), + Default::default(), + ) + .await + .unwrap(); + + // Retrieve file permissions to verify set + let permissions = tokio::fs::symlink_metadata(file.path()) + .await + .unwrap() + .permissions(); + + // Drop the upper bits that mode can have (only care about read/write/exec) + let mode = permissions.mode() & 0o777; + + assert_eq!(mode, 0o400, "Wrong permissions on file: {:o}", mode); + } + #[cfg(not(unix))] + { + unreachable!(); + } +} + +#[allow(unused_attributes)] +#[rstest] +#[test(tokio::test)] +#[cfg_attr(unix, ignore)] +#[ignore] +async fn set_permissions_should_set_readonly_flag_if_not_on_unix_platform( + #[future] client: Ctx, +) { + let mut client = client.await; + let temp = assert_fs::TempDir::new().unwrap(); + let file = temp.child("file"); + file.write_str("some text").unwrap(); + + // Verify that not readonly by default + let permissions = tokio::fs::symlink_metadata(file.path()) + .await + .unwrap() + .permissions(); + assert!(!permissions.readonly(), "File is already set to readonly"); + + // Change the file permissions to be readonly (in general) + client + .set_permissions( + file.path().to_path_buf(), + Permissions::from_unix_mode(0o400), + Default::default(), + ) + .await + .unwrap(); + + #[cfg(not(unix))] + { + // Retrieve file permissions to verify set + let permissions = tokio::fs::symlink_metadata(file.path()) + .await + .unwrap() + .permissions(); + + assert!(permissions.readonly(), "File not marked as readonly"); + } + #[cfg(unix)] + { + unreachable!(); + } +} + +#[rstest] +#[test(tokio::test)] +#[ignore] +async fn set_permissions_should_not_recurse_if_option_false(#[future] client: Ctx) { + let mut client = client.await; + let temp = assert_fs::TempDir::new().unwrap(); + let file = temp.child("file"); + file.write_str("some text").unwrap(); + + let symlink = temp.child("link"); + symlink.symlink_to_file(file.path()).unwrap(); + + // Verify that dir is not readonly by default + let permissions = tokio::fs::symlink_metadata(temp.path()) + .await + .unwrap() + .permissions(); + assert!( + !permissions.readonly(), + "Temp dir is already set to readonly" + ); + + // Verify that file is not readonly by default + let permissions = tokio::fs::symlink_metadata(file.path()) + .await + .unwrap() + .permissions(); + assert!(!permissions.readonly(), "File is already set to readonly"); + + // Verify that symlink is not readonly by default + let permissions = tokio::fs::symlink_metadata(symlink.path()) + .await + .unwrap() + .permissions(); + assert!( + !permissions.readonly(), + "Symlink is already set to readonly" + ); + + // Change the permissions of the directory and not the contents underneath + client + .set_permissions( + temp.path().to_path_buf(), + Permissions::readonly(), + SetPermissionsOptions { + recursive: false, + ..Default::default() + }, + ) + .await + .unwrap(); + + // Retrieve permissions of the file, symlink, and directory to verify set + let permissions = tokio::fs::symlink_metadata(temp.path()) + .await + .unwrap() + .permissions(); + assert!(permissions.readonly(), "Temp directory not set to readonly"); + + let permissions = tokio::fs::symlink_metadata(file.path()) + .await + .unwrap() + .permissions(); + assert!(!permissions.readonly(), "File unexpectedly set to readonly"); + + let permissions = tokio::fs::symlink_metadata(symlink.path()) + .await + .unwrap() + .permissions(); + assert!( + !permissions.readonly(), + "Symlink unexpectedly set to readonly" + ); +} + +#[rstest] +#[test(tokio::test)] +#[ignore] +async fn set_permissions_should_traverse_symlinks_while_recursing_if_following_symlinks_enabled( + #[future] client: Ctx, +) { + let mut client = client.await; + let temp = assert_fs::TempDir::new().unwrap(); + let file = temp.child("file"); + file.write_str("some text").unwrap(); + + let temp2 = assert_fs::TempDir::new().unwrap(); + let file2 = temp2.child("file"); + file2.write_str("some text").unwrap(); + + let symlink = temp.child("link"); + symlink.symlink_to_dir(temp2.path()).unwrap(); + + // Verify that symlink is not readonly by default + let permissions = tokio::fs::symlink_metadata(file2.path()) + .await + .unwrap() + .permissions(); + assert!(!permissions.readonly(), "File2 is already set to readonly"); + + // Change the main directory permissions + client + .set_permissions( + temp.path().to_path_buf(), + Permissions::readonly(), + SetPermissionsOptions { + follow_symlinks: true, + recursive: true, + ..Default::default() + }, + ) + .await + .unwrap(); + + // Retrieve permissions referenced by another directory + let permissions = tokio::fs::symlink_metadata(file2.path()) + .await + .unwrap() + .permissions(); + assert!(permissions.readonly(), "File2 not set to readonly"); +} + +#[rstest] +#[test(tokio::test)] +#[ignore] +async fn set_permissions_should_not_traverse_symlinks_while_recursing_if_following_symlinks_disabled( + #[future] client: Ctx, +) { + let mut client = client.await; + let temp = assert_fs::TempDir::new().unwrap(); + let file = temp.child("file"); + file.write_str("some text").unwrap(); + + let temp2 = assert_fs::TempDir::new().unwrap(); + let file2 = temp2.child("file"); + file2.write_str("some text").unwrap(); + + let symlink = temp.child("link"); + symlink.symlink_to_dir(temp2.path()).unwrap(); + + // Verify that symlink is not readonly by default + let permissions = tokio::fs::symlink_metadata(file2.path()) + .await + .unwrap() + .permissions(); + assert!(!permissions.readonly(), "File2 is already set to readonly"); + + // Change the main directory permissions + client + .set_permissions( + temp.path().to_path_buf(), + Permissions::readonly(), + SetPermissionsOptions { + follow_symlinks: false, + recursive: true, + ..Default::default() + }, + ) + .await + .unwrap(); + + // Retrieve permissions referenced by another directory + let permissions = tokio::fs::symlink_metadata(file2.path()) + .await + .unwrap() + .permissions(); + assert!( + !permissions.readonly(), + "File2 unexpectedly set to readonly" + ); +} + +#[rstest] +#[test(tokio::test)] +#[ignore] +async fn set_permissions_should_skip_symlinks_if_exclude_symlinks_enabled( + #[future] client: Ctx, +) { + let mut client = client.await; + let temp = assert_fs::TempDir::new().unwrap(); + let file = temp.child("file"); + file.write_str("some text").unwrap(); + + let symlink = temp.child("link"); + symlink.symlink_to_file(file.path()).unwrap(); + + // Verify that symlink is not readonly by default + let permissions = tokio::fs::symlink_metadata(symlink.path()) + .await + .unwrap() + .permissions(); + assert!( + !permissions.readonly(), + "Symlink is already set to readonly" + ); + + // Change the symlink permissions + client + .set_permissions( + symlink.path().to_path_buf(), + Permissions::readonly(), + SetPermissionsOptions { + exclude_symlinks: true, + ..Default::default() + }, + ) + .await + .unwrap(); + + // Retrieve permissions to verify not set + let permissions = tokio::fs::symlink_metadata(symlink.path()) + .await + .unwrap() + .permissions(); + assert!( + !permissions.readonly(), + "Symlink (or file underneath) set to readonly" + ); +} + +#[rstest] +#[test(tokio::test)] +#[ignore] +async fn set_permissions_should_support_recursive_if_option_specified( + #[future] client: Ctx, +) { + let mut client = client.await; + let temp = assert_fs::TempDir::new().unwrap(); + let file = temp.child("file"); + file.write_str("some text").unwrap(); + + // Verify that dir is not readonly by default + let permissions = tokio::fs::symlink_metadata(temp.path()) + .await + .unwrap() + .permissions(); + assert!( + !permissions.readonly(), + "Temp dir is already set to readonly" + ); + + // Verify that file is not readonly by default + let permissions = tokio::fs::symlink_metadata(file.path()) + .await + .unwrap() + .permissions(); + assert!(!permissions.readonly(), "File is already set to readonly"); + + // Change the permissions of the file pointed to by the symlink + client + .set_permissions( + temp.path().to_path_buf(), + Permissions::readonly(), + SetPermissionsOptions { + recursive: true, + ..Default::default() + }, + ) + .await + .unwrap(); + + // Retrieve permissions of the file, symlink, and directory to verify set + let permissions = tokio::fs::symlink_metadata(temp.path()) + .await + .unwrap() + .permissions(); + assert!(permissions.readonly(), "Temp directory not set to readonly"); + + let permissions = tokio::fs::symlink_metadata(file.path()) + .await + .unwrap() + .permissions(); + assert!(permissions.readonly(), "File not set to readonly"); +} + +#[rstest] +#[test(tokio::test)] +#[ignore] +async fn set_permissions_should_support_following_symlinks_if_option_specified( + #[future] client: Ctx, +) { + let mut client = client.await; + let temp = assert_fs::TempDir::new().unwrap(); + let file = temp.child("file"); + file.write_str("some text").unwrap(); + + let symlink = temp.child("link"); + symlink.symlink_to_file(file.path()).unwrap(); + + // Verify that file is not readonly by default + let permissions = tokio::fs::symlink_metadata(file.path()) + .await + .unwrap() + .permissions(); + assert!(!permissions.readonly(), "File is already set to readonly"); + + // Verify that symlink is not readonly by default + let permissions = tokio::fs::symlink_metadata(symlink.path()) + .await + .unwrap() + .permissions(); + assert!( + !permissions.readonly(), + "Symlink is already set to readonly" + ); + + // Change the permissions of the file pointed to by the symlink + client + .set_permissions( + symlink.path().to_path_buf(), + Permissions::readonly(), + SetPermissionsOptions { + follow_symlinks: true, + ..Default::default() + }, + ) + .await + .unwrap(); + + // Retrieve permissions of the file and symlink to verify set + let permissions = tokio::fs::symlink_metadata(file.path()) + .await + .unwrap() + .permissions(); + assert!(permissions.readonly(), "File not set to readonly"); + + let permissions = tokio::fs::symlink_metadata(symlink.path()) + .await + .unwrap() + .permissions(); + assert!( + !permissions.readonly(), + "Symlink unexpectedly set to readonly" + ); +} + #[rstest] #[test(tokio::test)] async fn proc_spawn_should_not_fail_even_if_process_not_found( diff --git a/distant-ssh2/tests/ssh2/ssh.rs b/distant-ssh2/tests/ssh2/ssh.rs index 62006a6..8cf8084 100644 --- a/distant-ssh2/tests/ssh2/ssh.rs +++ b/distant-ssh2/tests/ssh2/ssh.rs @@ -9,6 +9,9 @@ use crate::sshd::*; async fn detect_family_should_return_windows_if_sshd_on_windows(#[future] ssh: Ctx) { let ssh = ssh.await; let family = ssh.detect_family().await.expect("Failed to detect family"); + + // NOTE: We are testing against the local machine, so if Rust was compiled for Windows, then we + // are also on a Windows machine remotely for this test! assert_eq!( family, if cfg!(windows) { diff --git a/src/cli/commands/client.rs b/src/cli/commands/client.rs index 4589bb9..39f0873 100644 --- a/src/cli/commands/client.rs +++ b/src/cli/commands/client.rs @@ -6,7 +6,9 @@ use std::time::Duration; use anyhow::Context; use distant_core::net::common::{ConnectionId, Host, Map, Request, Response}; use distant_core::net::manager::ManagerClient; -use distant_core::protocol::{self, ChangeKindSet, FileType, SearchQuery, SystemInfo}; +use distant_core::protocol::{ + self, ChangeKindSet, FileType, Permissions, SearchQuery, SetPermissionsOptions, SystemInfo, +}; use distant_core::{DistantChannel, DistantChannelExt, RemoteCommand, Searcher, Watcher}; use log::*; use serde_json::json; @@ -1007,6 +1009,72 @@ async fn async_run(cmd: ClientSubcommand) -> CliResult { formatter.print(res).context("Failed to print match")?; } } + ClientSubcommand::FileSystem(ClientFileSystemSubcommand::SetPermissions { + cache, + connection, + network, + follow_symlinks, + recursive, + mode, + path, + }) => { + debug!("Parsing {mode:?} into a proper set of permissions"); + let permissions = { + if mode.trim().eq_ignore_ascii_case("readonly") { + Permissions::readonly() + } else if mode.trim().eq_ignore_ascii_case("notreadonly") { + Permissions::writable() + } else { + // Attempt to parse an octal number (chmod absolute), falling back to + // parsing the mode string similar to chmod's symbolic mode + let mode = match u32::from_str_radix(&mode, 8) { + Ok(absolute) => file_mode::Mode::from(absolute), + Err(_) => { + let mut new_mode = file_mode::Mode::empty(); + new_mode + .set_str(&mode) + .context("Failed to parse mode string")?; + new_mode + } + }; + Permissions::from_unix_mode(mode.mode()) + } + }; + + debug!("Connecting to manager"); + let mut client = Client::new(network) + .using_prompt_auth_handler() + .connect() + .await + .context("Failed to connect to manager")?; + + let mut cache = read_cache(&cache).await; + let connection_id = + use_or_lookup_connection_id(&mut cache, connection, &mut client).await?; + + debug!("Opening channel to connection {}", connection_id); + let channel = client + .open_raw_channel(connection_id) + .await + .with_context(|| format!("Failed to open channel to connection {connection_id}"))?; + + let options = SetPermissionsOptions { + recursive, + follow_symlinks, + exclude_symlinks: false, + }; + debug!("Setting permissions for {path:?} as (permissions = {permissions:?}, options = {options:?})"); + channel + .into_client() + .into_channel() + .set_permissions(path.as_path(), permissions, options) + .await + .with_context(|| { + format!( + "Failed to set permissions for {path:?} using connection {connection_id}" + ) + })?; + } ClientSubcommand::FileSystem(ClientFileSystemSubcommand::Watch { cache, connection, diff --git a/src/options.rs b/src/options.rs index 052b1d1..b1de9cd 100644 --- a/src/options.rs +++ b/src/options.rs @@ -121,6 +121,7 @@ impl Options { | ClientFileSystemSubcommand::Remove { network, .. } | ClientFileSystemSubcommand::Rename { network, .. } | ClientFileSystemSubcommand::Search { network, .. } + | ClientFileSystemSubcommand::SetPermissions { network, .. } | ClientFileSystemSubcommand::Watch { network, .. } | ClientFileSystemSubcommand::Write { network, .. }, ) => { @@ -740,6 +741,40 @@ pub enum ClientFileSystemSubcommand { paths: Vec, }, + /// Sets permissions for the specified path on the remote machine + SetPermissions { + /// Location to store cached data + #[clap( + long, + value_hint = ValueHint::FilePath, + value_parser, + default_value = CACHE_FILE_PATH_STR.as_str() + )] + cache: PathBuf, + + /// Specify a connection being managed + #[clap(long)] + connection: Option, + + #[clap(flatten)] + network: NetworkSettings, + + /// Recursively set permissions of files/directories/symlinks + #[clap(short = 'R', long)] + recursive: bool, + + /// Follow symlinks, which means that they will be unaffected + #[clap(short = 'L', long)] + follow_symlinks: bool, + + /// Mode string following `chmod` format (or set readonly flag if `readonly` or + /// `notreadonly` is specified) + mode: String, + + /// The path to the file, directory, or symlink on the remote machine + path: PathBuf, + }, + /// Watch a path for changes on the remote machine Watch { /// Location to store cached data @@ -828,6 +863,7 @@ impl ClientFileSystemSubcommand { Self::Remove { cache, .. } => cache.as_path(), Self::Rename { cache, .. } => cache.as_path(), Self::Search { cache, .. } => cache.as_path(), + Self::SetPermissions { cache, .. } => cache.as_path(), Self::Watch { cache, .. } => cache.as_path(), Self::Write { cache, .. } => cache.as_path(), } @@ -843,6 +879,7 @@ impl ClientFileSystemSubcommand { Self::Remove { network, .. } => network, Self::Rename { network, .. } => network, Self::Search { network, .. } => network, + Self::SetPermissions { network, .. } => network, Self::Watch { network, .. } => network, Self::Write { network, .. } => network, } diff --git a/tests/cli/client/capabilities.rs b/tests/cli/client/capabilities.rs index c12adff..f21c7f5 100644 --- a/tests/cli/client/capabilities.rs +++ b/tests/cli/client/capabilities.rs @@ -47,6 +47,8 @@ const EXPECTED_TABLE: &str = indoc! {" +------------------+------------------------------------------------------------------+ | search | Supports searching filesystem using queries | +------------------+------------------------------------------------------------------+ +| set_permissions | Supports setting permissions on a file, directory, or symlink | ++------------------+------------------------------------------------------------------+ | system_info | Supports retrieving system information | +------------------+------------------------------------------------------------------+ | unwatch | Supports unwatching filesystem for changes |