Skip to content

Commit 8df53d4

Browse files
committed
[CLI] Add export workspace command
1 parent 3a0172b commit 8df53d4

4 files changed

Lines changed: 231 additions & 0 deletions

File tree

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
use std::path::PathBuf;
2+
3+
use libparsec::{FsPath, OpenOptions};
4+
use tokio::io::AsyncWriteExt;
5+
6+
use crate::utils::StartedClient;
7+
8+
const CHUNK_SIZE: usize = 4096;
9+
10+
crate::clap_parser_with_shared_opts_builder!(
11+
#[with = config_dir, device, password_stdin, workspace]
12+
pub struct Args {
13+
/// File to export (e.g. "myfile.txt")
14+
#[arg(value_hint = clap::ValueHint::FilePath)]
15+
pub(crate) src: FsPath,
16+
/// Destination path (e.g. "/path/to/myfile.txt")
17+
///
18+
/// The command will fail if the
19+
/// parent directories do not exist, unless the `parents` option is enabled.
20+
///
21+
/// If the destination file already exists, its content will be replaced.
22+
#[arg(value_hint = clap::ValueHint::DirPath)]
23+
pub(crate) dest: PathBuf,
24+
/// Control how existing files are updated.
25+
///
26+
/// (similar to `cp --update=...`)
27+
#[clap(long, value_enum, default_value_t)]
28+
update: UpdateMode,
29+
}
30+
);
31+
32+
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
33+
enum UpdateMode {
34+
/// Existing files in destination are replaced
35+
All,
36+
/// Existing files in destination are not replaced but raise an error instead.
37+
#[default]
38+
NoneFail,
39+
}
40+
41+
crate::build_main_with_client!(
42+
main,
43+
workspace_export,
44+
libparsec::ClientConfig {
45+
with_monitors: true,
46+
..Default::default()
47+
}
48+
.into()
49+
);
50+
51+
pub async fn workspace_export(
52+
_ui: crate::Ui,
53+
args: Args,
54+
client: &StartedClient,
55+
) -> anyhow::Result<()> {
56+
let Args {
57+
src,
58+
dest,
59+
workspace: wid,
60+
update,
61+
..
62+
} = args;
63+
64+
log::trace!(
65+
"workspace_export: {wid}:{src} -> {dst}",
66+
src = src,
67+
dst = dest.display()
68+
);
69+
70+
let workspace = client.start_workspace(wid).await?;
71+
72+
let file = workspace.open_file(src, OpenOptions::read_only()).await?;
73+
let stats = workspace.fd_stat(file).await?;
74+
let size = stats.size;
75+
76+
if matches!(update, UpdateMode::NoneFail) && tokio::fs::try_exists(&dest).await? {
77+
return Err(anyhow::anyhow!("File already exists."));
78+
}
79+
80+
let dest_file = tokio::fs::OpenOptions::new()
81+
.write(true)
82+
.create(true)
83+
.truncate(true)
84+
.open(&dest)
85+
.await?;
86+
87+
let mut read_buf = Vec::with_capacity(CHUNK_SIZE);
88+
let mut write_buf = tokio::io::BufWriter::new(dest_file);
89+
90+
let mut offset = 0;
91+
while offset < size {
92+
// read and decrypt chunk
93+
let bytes_read = workspace
94+
.fd_read(file, offset, CHUNK_SIZE as u64, &mut read_buf)
95+
.await?;
96+
97+
// write to dst file
98+
write_buf
99+
.write_all(&read_buf[..bytes_read.try_into()?])
100+
.await?;
101+
write_buf.flush().await?;
102+
offset += bytes_read
103+
}
104+
105+
Ok(())
106+
}

cli/src/commands/workspace/mod.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
pub mod archive;
22
pub mod create;
3+
pub mod export;
34
pub mod import;
45
pub mod list;
56
pub mod list_users;
@@ -19,6 +20,10 @@ pub enum Group {
1920
List(list::Args),
2021
/// Import a local file to a remote workspace
2122
Import(import::Args),
23+
/// Export a remote file to local storage
24+
/// /!\ This removes the encryption from the file,
25+
/// Proceed with caution.
26+
Export(export::Args),
2227
/// Share workspace
2328
Share(share::Args),
2429
/// Sync workspace data with the server
@@ -34,6 +39,7 @@ pub async fn dispatch_command(ui: crate::Ui, command: Group) -> anyhow::Result<(
3439
Group::Create(args) => create::main(ui, args).await,
3540
Group::List(args) => list::main(ui, args).await,
3641
Group::Import(args) => import::main(ui, args).await,
42+
Group::Export(args) => export::main(ui, args).await,
3743
Group::Share(args) => share::main(ui, args).await,
3844
Group::Sync(args) => sync::main(ui, args).await,
3945
Group::Mount(args) => mount::main(ui, args).await,
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
use libparsec::{tmp_path, FsPath, OpenOptions, TmpPath};
2+
use std::str::FromStr;
3+
use tokio::io::{AsyncReadExt, AsyncWriteExt};
4+
5+
use crate::{
6+
bootstrap_cli_test, test_ui,
7+
testenv_utils::{TestOrganization, DEFAULT_DEVICE_PASSWORD},
8+
};
9+
use parsec_cli::{ui::Ui, utils::start_client};
10+
11+
#[rstest::rstest]
12+
#[tokio::test]
13+
async fn workspace_export_file(
14+
tmp_path: TmpPath,
15+
test_ui: &Ui,
16+
#[values("all", "none-fail")] update: &str,
17+
) {
18+
let (_, TestOrganization { alice, .. }, _) =
19+
bootstrap_cli_test(test_ui, &tmp_path).await.unwrap();
20+
21+
let remote_path = "/hello.txt";
22+
let fs_path = FsPath::from_str(remote_path).unwrap();
23+
24+
let local_path = tmp_path.join("hello.txt");
25+
let content = b"Hello, world!";
26+
let old_content = b"Old old stuff";
27+
28+
// Create previous local file
29+
{
30+
let mut previous_file = tokio::fs::OpenOptions::new()
31+
.write(true)
32+
.create(true)
33+
.truncate(true)
34+
.open(&local_path)
35+
.await
36+
.unwrap();
37+
previous_file.write_all(old_content).await.unwrap();
38+
}
39+
40+
// Initialize workspace
41+
let wid = {
42+
let alice_client = start_client(alice.clone()).await.unwrap();
43+
44+
// create workspace
45+
let wid = alice_client
46+
.create_workspace("new-workspace".parse().unwrap())
47+
.await
48+
.unwrap();
49+
alice_client.ensure_workspaces_bootstrapped().await.unwrap();
50+
51+
// create file to export
52+
let workspace = alice_client.start_workspace(wid).await.unwrap();
53+
let _ = workspace.create_file(fs_path.clone()).await.unwrap();
54+
let fd = workspace
55+
.open_file(fs_path, OpenOptions::read_write())
56+
.await
57+
.unwrap();
58+
workspace.fd_write(fd, 0, content).await.unwrap();
59+
60+
alice_client.stop().await;
61+
62+
wid
63+
};
64+
65+
// Export the file
66+
67+
match update {
68+
"none-fail" => {
69+
crate::assert_cmd_failure!(
70+
with_password = DEFAULT_DEVICE_PASSWORD,
71+
"workspace",
72+
"export",
73+
"--device",
74+
&alice.device_id.hex(),
75+
"--workspace",
76+
&wid.hex(),
77+
&remote_path,
78+
&local_path,
79+
"--update",
80+
&update
81+
)
82+
.stderr(predicates::str::contains("Error: File already exists."));
83+
}
84+
"all" => {
85+
crate::assert_cmd_success!(
86+
with_password = DEFAULT_DEVICE_PASSWORD,
87+
"workspace",
88+
"export",
89+
"--device",
90+
&alice.device_id.hex(),
91+
"--workspace",
92+
&wid.hex(),
93+
&remote_path,
94+
&local_path,
95+
"--update",
96+
&update
97+
)
98+
.stdout(predicates::str::is_empty());
99+
}
100+
_ => unimplemented!(),
101+
}
102+
103+
let expected_content = match update {
104+
"all" => content,
105+
"none-fail" => old_content,
106+
_ => unimplemented!(),
107+
};
108+
109+
let mut fd = tokio::fs::OpenOptions::new()
110+
.read(true)
111+
.open(&local_path)
112+
.await
113+
.unwrap();
114+
let mut buf = Vec::with_capacity(expected_content.len());
115+
let out = fd.read_to_end(&mut buf).await.unwrap();
116+
assert_ne!(out, 0);
117+
assert_eq!(buf, expected_content)
118+
}

cli/tests/integration/workspace/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
mod archive;
22
mod create;
3+
mod export;
34
mod import;
45
mod list_users;
56
mod mount;

0 commit comments

Comments
 (0)