Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 129 additions & 0 deletions cli/src/commands/workspace/export.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
use std::path::PathBuf;

use libparsec::{FsPath, OpenOptions};
use tokio::io::AsyncWriteExt;

use crate::utils::StartedClient;

const CHUNK_SIZE: usize = 4096;

crate::clap_parser_with_shared_opts_builder!(
#[with = config_dir, device, password_stdin, workspace]
pub struct Args {
/// File to export (e.g. "myfile.txt")
#[arg(value_hint = clap::ValueHint::FilePath)]
pub(crate) src: FsPath,
/// Destination path (e.g. "/path/to/myfile.txt")
///
/// The command will fail if the
/// parent directories do not exist, unless the `parents` option is enabled.
Comment thread
AureliaDolo marked this conversation as resolved.
///
/// If the destination file already exists, its content will be replaced.
#[arg(value_hint = clap::ValueHint::DirPath)]
pub(crate) dest: PathBuf,
/// If specified, create parent directories as needed
///
/// No error if parent directories already exist (similar to `mkdir -p`)
#[clap(long, short, action)]
pub(crate) parents: bool,
/// Control how existing files are updated.
///
/// (similar to `cp --update=...`)
#[clap(long, value_enum, default_value_t)]
update: UpdateMode,
}
);

#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
enum UpdateMode {
/// Existing files in destination are replaced
All,
/// Existing files in destination are not replaced but raise an error instead.
#[default]
NoneFail,
}

crate::build_main_with_client!(
main,
workspace_export,
libparsec::ClientConfig {
with_monitors: true,
..Default::default()
}
.into()
);

pub async fn workspace_export(
_ui: crate::Ui,
args: Args,
client: &StartedClient,
) -> anyhow::Result<()> {
let Args {
src,
dest,
workspace: wid,
update,
parents,
..
} = args;

log::trace!(
"workspace_export: {wid}:{src} -> {dst}",
src = src,
dst = dest.display()
);

let workspace = client.start_workspace(wid).await?;

let file = workspace
.open_file(src.clone(), OpenOptions::read_only())
.await
.map_err(|e| match e {
libparsec::WorkspaceOpenFileError::EntryNotFound => {
anyhow::anyhow!("File {src} not found in Parsec workspace.")
}
libparsec::WorkspaceOpenFileError::EntryNotAFile { .. } => {
anyhow::anyhow!("{src} is not a file.")
}
_ => e.into(),
})?;
let stats = workspace.fd_stat(file).await?;
let size = stats.size;

if (update == UpdateMode::NoneFail) && tokio::fs::try_exists(&dest).await? {
return Err(anyhow::anyhow!("File already exists."));
}

if parents {
if let Some(p) = dest.parent() {
tokio::fs::create_dir_all(p).await?
}
}
Comment thread
FirelightFlagboy marked this conversation as resolved.

let dest_file = tokio::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(&dest)
.await?;

let mut read_buf = Vec::with_capacity(CHUNK_SIZE);
let mut write_buf = tokio::io::BufWriter::new(dest_file);

let mut offset = 0;
while offset < size {
// read and decrypt chunk
let bytes_read = workspace
.fd_read(file, offset, CHUNK_SIZE as u64, &mut read_buf)
.await?;

// write to dst file
write_buf
.write_all(&read_buf[..bytes_read.try_into()?])
.await?;
offset += bytes_read
}
write_buf.flush().await?;

Ok(())
}
6 changes: 6 additions & 0 deletions cli/src/commands/workspace/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
pub mod archive;
pub mod create;
pub mod export;
pub mod import;
pub mod list;
pub mod list_users;
Expand All @@ -19,6 +20,10 @@ pub enum Group {
List(list::Args),
/// Import a local file to a remote workspace
Import(import::Args),
/// Export a remote file to local storage
/// /!\ This removes the encryption from the file,
/// Proceed with caution.
Export(export::Args),
/// Share workspace
Share(share::Args),
/// Sync workspace data with the server
Expand All @@ -34,6 +39,7 @@ pub async fn dispatch_command(ui: crate::Ui, command: Group) -> anyhow::Result<(
Group::Create(args) => create::main(ui, args).await,
Group::List(args) => list::main(ui, args).await,
Group::Import(args) => import::main(ui, args).await,
Group::Export(args) => export::main(ui, args).await,
Group::Share(args) => share::main(ui, args).await,
Group::Sync(args) => sync::main(ui, args).await,
Group::Mount(args) => mount::main(ui, args).await,
Expand Down
227 changes: 227 additions & 0 deletions cli/tests/integration/workspace/export.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
use libparsec::{tmp_path, FsPath, OpenOptions, TmpPath};
use std::str::FromStr;
use tokio::io::{AsyncReadExt, AsyncWriteExt};

use crate::{
bootstrap_cli_test, test_ui,
testenv_utils::{TestOrganization, DEFAULT_DEVICE_PASSWORD},
};
use parsec_cli::{ui::Ui, utils::start_client};

#[rstest::rstest]
#[tokio::test]
async fn workspace_export_file(
tmp_path: TmpPath,
test_ui: &Ui,
#[values("all", "none-fail")] update: &str,
) {
let (_, TestOrganization { alice, .. }, _) =
bootstrap_cli_test(test_ui, &tmp_path).await.unwrap();

let remote_path = "/hello.txt";
let fs_path = FsPath::from_str(remote_path).unwrap();

let local_path = tmp_path.join("hello.txt");
let content = b"Hello, world!";
let old_content = b"Old old stuff";

// Create previous local file
{
let mut previous_file = tokio::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(&local_path)
.await
.unwrap();
previous_file.write_all(old_content).await.unwrap();
}

// Initialize workspace
let wid = {
let alice_client = start_client(alice.clone()).await.unwrap();

// create workspace
let wid = alice_client
.create_workspace("new-workspace".parse().unwrap())
.await
.unwrap();
alice_client.ensure_workspaces_bootstrapped().await.unwrap();

// create file to export
let workspace = alice_client.start_workspace(wid).await.unwrap();
let _ = workspace.create_file(fs_path.clone()).await.unwrap();
let fd = workspace
.open_file(fs_path, OpenOptions::read_write())
.await
.unwrap();
workspace.fd_write(fd, 0, content).await.unwrap();

alice_client.stop().await;

wid
};

// Export the file
macro_rules! assert_export_cmd {
() => {
crate::assert_cmd!(
with_password = DEFAULT_DEVICE_PASSWORD,
"workspace",
"export",
"--device",
&alice.device_id.hex(),
"--workspace",
&wid.hex(),
&remote_path,
&local_path,
"--update",
&update
)
};
}
match update {
"none-fail" => {
assert_export_cmd!()
.assert()
.failure()
.stderr(predicates::str::contains("Error: File already exists."));
}
"all" => {
assert_export_cmd!()
.assert()
.success()
.stdout(predicates::str::is_empty());
}
_ => unimplemented!(),
}

let expected_content = match update {
"all" => content,
"none-fail" => old_content,
_ => unimplemented!(),
};

let mut fd = tokio::fs::OpenOptions::new()
.read(true)
.open(&local_path)
.await
.unwrap();
let mut buf = Vec::with_capacity(expected_content.len());
let out = fd.read_to_end(&mut buf).await.unwrap();
assert_ne!(out, 0);
assert_eq!(buf, expected_content)
}

#[rstest::rstest]
#[tokio::test]
async fn workspace_export_file_parents(
tmp_path: TmpPath,
test_ui: &Ui,
#[values("all", "none-fail")] update: &str,
) {
let (_, TestOrganization { alice, .. }, _) =
bootstrap_cli_test(test_ui, &tmp_path).await.unwrap();

let remote_path = "/hello.txt";
let fs_path = FsPath::from_str(remote_path).unwrap();

let local_path = tmp_path.join("not_existing_dir").join("hello.txt");
let content = b"Hello, world!";

// Initialize workspace
let wid = {
let alice_client = start_client(alice.clone()).await.unwrap();

// create workspace
let wid = alice_client
.create_workspace("new-workspace".parse().unwrap())
.await
.unwrap();
alice_client.ensure_workspaces_bootstrapped().await.unwrap();

// create file to export
let workspace = alice_client.start_workspace(wid).await.unwrap();
let _ = workspace.create_file(fs_path.clone()).await.unwrap();
let fd = workspace
.open_file(fs_path, OpenOptions::read_write())
.await
.unwrap();
workspace.fd_write(fd, 0, content).await.unwrap();

alice_client.stop().await;

wid
};

// Parent not existing
crate::assert_cmd_failure!(
with_password = DEFAULT_DEVICE_PASSWORD,
"workspace",
"export",
"--device",
&alice.device_id.hex(),
"--workspace",
&wid.hex(),
&remote_path,
&local_path,
"--update",
&update
)
.stderr(predicates::str::contains(
"Error: No such file or directory",
));

// --parents to create parents
crate::assert_cmd_success!(
with_password = DEFAULT_DEVICE_PASSWORD,
"workspace",
"export",
"--device",
&alice.device_id.hex(),
"--workspace",
&wid.hex(),
&remote_path,
&local_path,
"--update",
&update,
"--parents"
)
.stdout(predicates::str::is_empty());

// Export the file
macro_rules! assert_export_cmd {
() => {
crate::assert_cmd!(
with_password = DEFAULT_DEVICE_PASSWORD,
"workspace",
"export",
"--device",
&alice.device_id.hex(),
"--workspace",
&wid.hex(),
&remote_path,
&local_path,
"--update",
&update
)
};
}

// Parent exists so depends on update mode
match update {
"none-fail" => {
assert_export_cmd!()
.assert()
.failure()
.stderr(predicates::str::contains("Error: File already exists."));
}
"all" => {
assert_export_cmd!()
.assert()
.success()
.stdout(predicates::str::is_empty());
}
_ => unimplemented!(),
}
}
1 change: 1 addition & 0 deletions cli/tests/integration/workspace/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
mod archive;
mod create;
mod export;
mod import;
mod list_users;
mod mount;
Expand Down
Loading
Loading