mirror of
https://github.com/rust-kotlin/ashell.git
synced 2026-09-22 00:00:59 +00:00
Merge branch 'pr-47'
This commit is contained in:
Generated
+122
@@ -265,6 +265,18 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "argon2"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"blake2",
|
||||
"cpufeatures",
|
||||
"password-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arrayref"
|
||||
version = "0.3.9"
|
||||
@@ -307,7 +319,10 @@ version = "0.4.6"
|
||||
dependencies = [
|
||||
"alacritty_terminal",
|
||||
"anyhow",
|
||||
"argon2",
|
||||
"async-trait",
|
||||
"base64",
|
||||
"chacha20poly1305",
|
||||
"chrono",
|
||||
"directories",
|
||||
"flate2",
|
||||
@@ -316,11 +331,15 @@ dependencies = [
|
||||
"gpui-component",
|
||||
"gpui-component-assets",
|
||||
"gpui_platform",
|
||||
"hex",
|
||||
"hmac",
|
||||
"image",
|
||||
"menu",
|
||||
"notify 6.1.1",
|
||||
"open",
|
||||
"portable-pty",
|
||||
"rand 0.8.6",
|
||||
"reqwest",
|
||||
"rfd",
|
||||
"russh",
|
||||
"russh-keys",
|
||||
@@ -328,6 +347,7 @@ dependencies = [
|
||||
"rust-i18n 3.1.5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"ssh-key",
|
||||
"sys-locale",
|
||||
"sysinfo 0.33.1",
|
||||
@@ -842,6 +862,15 @@ dependencies = [
|
||||
"no_std_io2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blake2"
|
||||
version = "0.10.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
|
||||
dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block"
|
||||
version = "0.1.6"
|
||||
@@ -1091,6 +1120,19 @@ dependencies = [
|
||||
"cpufeatures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chacha20poly1305"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
|
||||
dependencies = [
|
||||
"aead",
|
||||
"chacha20",
|
||||
"cipher",
|
||||
"poly1305",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.45"
|
||||
@@ -3334,6 +3376,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3342,13 +3385,16 @@ version = "0.1.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"hyper",
|
||||
"ipnet",
|
||||
"libc",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"tokio",
|
||||
@@ -5012,6 +5058,17 @@ dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "password-hash"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"rand_core 0.6.4",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "paste"
|
||||
version = "1.0.15"
|
||||
@@ -5899,6 +5956,44 @@ version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832"
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.12.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tower-service",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "resvg"
|
||||
version = "0.45.1"
|
||||
@@ -7852,6 +7947,24 @@ dependencies = [
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower-http"
|
||||
version = "0.6.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
|
||||
dependencies = [
|
||||
"bitflags 2.12.1",
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"pin-project-lite",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower-layer"
|
||||
version = "0.3.3"
|
||||
@@ -8613,6 +8726,15 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "weezl"
|
||||
version = "0.1.12"
|
||||
|
||||
@@ -44,6 +44,14 @@ notify = "6.1.1"
|
||||
open = "5.1"
|
||||
time = { version = "0.3.45", features = ["formatting", "local-offset"] }
|
||||
tracing-appender = "0.2.5"
|
||||
argon2 = "0.5"
|
||||
base64 = "0.22"
|
||||
chacha20poly1305 = "0.10"
|
||||
hmac = "0.12"
|
||||
hex = "0.4"
|
||||
rand = "0.8"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
|
||||
sha2 = "0.10"
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
|
||||
@@ -158,3 +158,24 @@ title_bar_style: "Title Bar Style"
|
||||
title_bar_native: "Native"
|
||||
title_bar_integrated: "Integrated"
|
||||
restart_hint: " (Restart required)"
|
||||
settings_sync: "Configuration Sync"
|
||||
sync_endpoint: "WebDAV Endpoint"
|
||||
sync_username: "WebDAV Username"
|
||||
sync_webdav_password: "WebDAV Password"
|
||||
sync_encryption_password: "Encryption Password (8+ characters)"
|
||||
sync_security_hint: "Only connection parameters are stored locally. WebDAV passwords, S3 secrets, and encryption passwords remain in memory for this process. Uploaded data is end-to-end encrypted. Downloading replaces locally saved SSH sessions."
|
||||
sync_s3_endpoint: "S3 Endpoint (optional for AWS S3)"
|
||||
sync_s3_region: "S3 Region"
|
||||
sync_s3_bucket: "S3 Bucket"
|
||||
sync_s3_object_key: "Object Key"
|
||||
sync_s3_access_key: "Access Key ID"
|
||||
sync_s3_secret_key: "Secret Access Key"
|
||||
sync_s3_session_token: "Session Token (optional)"
|
||||
sync_download: "Download Remote"
|
||||
sync_upload: "Upload Remote"
|
||||
sync_not_run: "Not synchronized yet"
|
||||
sync_uploading: "Encrypting and uploading configuration..."
|
||||
sync_downloading: "Downloading and decrypting configuration..."
|
||||
sync_upload_complete: "Configuration uploaded"
|
||||
sync_download_complete: "Configuration downloaded"
|
||||
sync_failed: "Synchronization failed"
|
||||
|
||||
@@ -160,3 +160,24 @@ title_bar_style: "标题栏样式"
|
||||
title_bar_native: "原生"
|
||||
title_bar_integrated: "融合"
|
||||
restart_hint: " (修改后重启生效)"
|
||||
settings_sync: "配置同步"
|
||||
sync_endpoint: "WebDAV 地址"
|
||||
sync_username: "WebDAV 用户名"
|
||||
sync_webdav_password: "WebDAV 密码"
|
||||
sync_encryption_password: "配置加密口令(至少 8 位)"
|
||||
sync_security_hint: "仅连接参数会保存到本机。WebDAV 密码、S3 密钥和加密口令只保留在当前进程内;上传内容使用端到端加密。下载会替换本机保存的 SSH 会话。"
|
||||
sync_s3_endpoint: "S3 Endpoint(AWS S3 可留空)"
|
||||
sync_s3_region: "S3 Region"
|
||||
sync_s3_bucket: "S3 Bucket"
|
||||
sync_s3_object_key: "对象路径"
|
||||
sync_s3_access_key: "Access Key ID"
|
||||
sync_s3_secret_key: "Secret Access Key"
|
||||
sync_s3_session_token: "Session Token(可选)"
|
||||
sync_download: "从远程下载"
|
||||
sync_upload: "上传到远程"
|
||||
sync_not_run: "尚未同步"
|
||||
sync_uploading: "正在加密并上传配置..."
|
||||
sync_downloading: "正在下载并解密配置..."
|
||||
sync_upload_complete: "配置上传完成"
|
||||
sync_download_complete: "配置下载完成"
|
||||
sync_failed: "同步失败"
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
use gpui::{Context, Entity, SharedString};
|
||||
use gpui_component::input::InputState;
|
||||
use rust_i18n::t;
|
||||
|
||||
use crate::{
|
||||
Ashell,
|
||||
sync::{self, SyncBackendCredentials, SyncCredentials, SyncPayload, SyncResult},
|
||||
terminal::BackendEvent,
|
||||
};
|
||||
|
||||
impl Ashell {
|
||||
fn sync_input_value(input: &Entity<InputState>, cx: &Context<Self>) -> String {
|
||||
input.read(cx).value().trim().to_string()
|
||||
}
|
||||
|
||||
fn sync_credentials(&self, cx: &Context<Self>) -> SyncCredentials {
|
||||
let backend = if self.config.sync_backend() == "s3" {
|
||||
SyncBackendCredentials::S3 {
|
||||
endpoint: Self::sync_input_value(&self.sync_s3_endpoint_input, cx),
|
||||
region: Self::sync_input_value(&self.sync_s3_region_input, cx),
|
||||
bucket: Self::sync_input_value(&self.sync_s3_bucket_input, cx),
|
||||
object_key: Self::sync_input_value(&self.sync_s3_object_key_input, cx),
|
||||
access_key: Self::sync_input_value(&self.sync_s3_access_key_input, cx),
|
||||
secret_key: self.sync_s3_secret_key_input.read(cx).value().to_string(),
|
||||
session_token: self
|
||||
.sync_s3_session_token_input
|
||||
.read(cx)
|
||||
.value()
|
||||
.to_string(),
|
||||
}
|
||||
} else {
|
||||
SyncBackendCredentials::WebDav {
|
||||
endpoint: Self::sync_input_value(&self.sync_endpoint_input, cx),
|
||||
username: Self::sync_input_value(&self.sync_username_input, cx),
|
||||
password: self.sync_webdav_password_input.read(cx).value().to_string(),
|
||||
}
|
||||
};
|
||||
SyncCredentials {
|
||||
backend,
|
||||
encryption_password: self
|
||||
.sync_encryption_password_input
|
||||
.read(cx)
|
||||
.value()
|
||||
.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn begin_sync(
|
||||
&mut self,
|
||||
status: SharedString,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Option<SyncCredentials> {
|
||||
if self.sync_in_progress {
|
||||
return None;
|
||||
}
|
||||
let credentials = self.sync_credentials(cx);
|
||||
match &credentials.backend {
|
||||
SyncBackendCredentials::WebDav {
|
||||
endpoint, username, ..
|
||||
} => {
|
||||
self.config
|
||||
.set_sync_connection(endpoint.clone(), username.clone());
|
||||
}
|
||||
SyncBackendCredentials::S3 {
|
||||
endpoint,
|
||||
region,
|
||||
bucket,
|
||||
object_key,
|
||||
..
|
||||
} => {
|
||||
self.config.set_sync_s3_connection(
|
||||
endpoint.clone(),
|
||||
region.clone(),
|
||||
bucket.clone(),
|
||||
object_key.clone(),
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Err(err) = self.config.save() {
|
||||
self.sync_status = format!("{}: {err:#}", t!("sync_failed")).into();
|
||||
cx.notify();
|
||||
return None;
|
||||
}
|
||||
self.sync_in_progress = true;
|
||||
self.sync_status = status;
|
||||
cx.notify();
|
||||
Some(credentials)
|
||||
}
|
||||
|
||||
pub(crate) fn set_sync_backend(&mut self, backend: &str, cx: &mut Context<Self>) {
|
||||
self.config.set_sync_backend(backend);
|
||||
let _ = self.config.save();
|
||||
self.sync_status = t!("sync_not_run").into();
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn upload_sync_config(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(credentials) = self.begin_sync(t!("sync_uploading").into(), cx) else {
|
||||
return;
|
||||
};
|
||||
let payload = SyncPayload::new(
|
||||
self.config.sync_device_id().to_string(),
|
||||
self.config.sessions().to_vec(),
|
||||
);
|
||||
let expected_etag = self.config.sync_etag().map(str::to_string);
|
||||
let events = self.events_tx.clone();
|
||||
self.runtime.spawn(async move {
|
||||
let result = match sync::upload(credentials, payload, expected_etag).await {
|
||||
Ok(etag) => SyncResult::Uploaded { etag },
|
||||
Err(err) => SyncResult::Failed(format!("{err:#}")),
|
||||
};
|
||||
let _ = events.send(BackendEvent::SyncFinished(result));
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn download_sync_config(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(credentials) = self.begin_sync(t!("sync_downloading").into(), cx) else {
|
||||
return;
|
||||
};
|
||||
let events = self.events_tx.clone();
|
||||
self.runtime.spawn(async move {
|
||||
let result = match sync::download(credentials).await {
|
||||
Ok((payload, etag)) => SyncResult::Downloaded { payload, etag },
|
||||
Err(err) => SyncResult::Failed(format!("{err:#}")),
|
||||
};
|
||||
let _ = events.send(BackendEvent::SyncFinished(result));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1094,6 +1094,17 @@ impl Ashell {
|
||||
use gpui::IntoElement;
|
||||
let version = env!("CARGO_PKG_VERSION");
|
||||
let view_clone_for_general = view.clone();
|
||||
let sync_endpoint_input = view.read(cx).sync_endpoint_input.clone();
|
||||
let sync_username_input = view.read(cx).sync_username_input.clone();
|
||||
let sync_webdav_password_input = view.read(cx).sync_webdav_password_input.clone();
|
||||
let sync_s3_endpoint_input = view.read(cx).sync_s3_endpoint_input.clone();
|
||||
let sync_s3_region_input = view.read(cx).sync_s3_region_input.clone();
|
||||
let sync_s3_bucket_input = view.read(cx).sync_s3_bucket_input.clone();
|
||||
let sync_s3_object_key_input = view.read(cx).sync_s3_object_key_input.clone();
|
||||
let sync_s3_access_key_input = view.read(cx).sync_s3_access_key_input.clone();
|
||||
let sync_s3_secret_key_input = view.read(cx).sync_s3_secret_key_input.clone();
|
||||
let sync_s3_session_token_input = view.read(cx).sync_s3_session_token_input.clone();
|
||||
let sync_encryption_password_input = view.read(cx).sync_encryption_password_input.clone();
|
||||
|
||||
let focus_handle = view.read(cx).focus_handle.clone();
|
||||
|
||||
@@ -1648,6 +1659,76 @@ impl Ashell {
|
||||
)
|
||||
)
|
||||
)
|
||||
.page(
|
||||
SettingPage::new(t!("settings_sync").to_string())
|
||||
.icon(IconName::Globe)
|
||||
.group(
|
||||
SettingGroup::new()
|
||||
.title(t!("settings_sync").to_string())
|
||||
.item(SettingItem::render({
|
||||
let view = view.clone();
|
||||
let endpoint = sync_endpoint_input.clone();
|
||||
let username = sync_username_input.clone();
|
||||
let webdav_password = sync_webdav_password_input.clone();
|
||||
let s3_endpoint = sync_s3_endpoint_input.clone();
|
||||
let s3_region = sync_s3_region_input.clone();
|
||||
let s3_bucket = sync_s3_bucket_input.clone();
|
||||
let s3_object_key = sync_s3_object_key_input.clone();
|
||||
let s3_access_key = sync_s3_access_key_input.clone();
|
||||
let s3_secret_key = sync_s3_secret_key_input.clone();
|
||||
let s3_session_token = sync_s3_session_token_input.clone();
|
||||
let encryption_password = sync_encryption_password_input.clone();
|
||||
move |_, window, cx| {
|
||||
let in_progress = view.read(cx).sync_in_progress;
|
||||
let status = view.read(cx).sync_status.clone();
|
||||
let is_s3 = view.read(cx).config.sync_backend() == "s3";
|
||||
v_flex()
|
||||
.w_full()
|
||||
.gap_3()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
Button::new("sync-backend-webdav")
|
||||
.small()
|
||||
.label("WebDAV")
|
||||
.when(!is_s3, |button| button.primary())
|
||||
.on_click(window.listener_for(&view, |this, _, _, cx| this.set_sync_backend("webdav", cx)))
|
||||
)
|
||||
.child(
|
||||
Button::new("sync-backend-s3")
|
||||
.small()
|
||||
.label("S3")
|
||||
.when(is_s3, |button| button.primary())
|
||||
.on_click(window.listener_for(&view, |this, _, _, cx| this.set_sync_backend("s3", cx)))
|
||||
)
|
||||
)
|
||||
.when(!is_s3, |this| this
|
||||
.child(v_flex().gap_1().child(div().text_sm().child(t!("sync_endpoint").to_string())).child(Input::new(&endpoint).w_full()))
|
||||
.child(v_flex().gap_1().child(div().text_sm().child(t!("sync_username").to_string())).child(Input::new(&username).w_full()))
|
||||
.child(v_flex().gap_1().child(div().text_sm().child(t!("sync_webdav_password").to_string())).child(Input::new(&webdav_password).w_full())))
|
||||
.when(is_s3, |this| this
|
||||
.child(v_flex().gap_1().child(div().text_sm().child(t!("sync_s3_endpoint").to_string())).child(Input::new(&s3_endpoint).w_full()))
|
||||
.child(h_flex().gap_2()
|
||||
.child(v_flex().flex_1().gap_1().child(div().text_sm().child(t!("sync_s3_region").to_string())).child(Input::new(&s3_region).w_full()))
|
||||
.child(v_flex().flex_1().gap_1().child(div().text_sm().child(t!("sync_s3_bucket").to_string())).child(Input::new(&s3_bucket).w_full())))
|
||||
.child(v_flex().gap_1().child(div().text_sm().child(t!("sync_s3_object_key").to_string())).child(Input::new(&s3_object_key).w_full()))
|
||||
.child(v_flex().gap_1().child(div().text_sm().child(t!("sync_s3_access_key").to_string())).child(Input::new(&s3_access_key).w_full()))
|
||||
.child(v_flex().gap_1().child(div().text_sm().child(t!("sync_s3_secret_key").to_string())).child(Input::new(&s3_secret_key).w_full()))
|
||||
.child(v_flex().gap_1().child(div().text_sm().child(t!("sync_s3_session_token").to_string())).child(Input::new(&s3_session_token).w_full())))
|
||||
.child(v_flex().gap_1().child(div().text_sm().child(t!("sync_encryption_password").to_string())).child(Input::new(&encryption_password).w_full()))
|
||||
.child(div().text_sm().text_color(cx.theme().muted_foreground).child(t!("sync_security_hint").to_string()))
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.child(Button::new("sync-download").small().disabled(in_progress).label(t!("sync_download").to_string()).on_click(window.listener_for(&view, |this, _, _, cx| this.download_sync_config(cx))))
|
||||
.child(Button::new("sync-upload").small().disabled(in_progress).label(t!("sync_upload").to_string()).on_click(window.listener_for(&view, |this, _, _, cx| this.upload_sync_config(cx)))),
|
||||
)
|
||||
.child(div().text_sm().text_color(cx.theme().muted_foreground).child(status))
|
||||
}
|
||||
}))
|
||||
)
|
||||
)
|
||||
.page({
|
||||
let mut page = SettingPage::new(t!("settings_key_bindings").to_string())
|
||||
.icon(IconName::SquareTerminal)
|
||||
|
||||
+125
-4
@@ -1,3 +1,4 @@
|
||||
pub mod config_sync;
|
||||
pub mod constants;
|
||||
pub mod dialogs;
|
||||
pub mod keybinding_recorder;
|
||||
@@ -208,6 +209,19 @@ pub(crate) struct Ashell {
|
||||
pub(crate) key_path_input: Entity<InputState>,
|
||||
pub(crate) key_inline_input: Entity<InputState>,
|
||||
pub(crate) passphrase_input: Entity<InputState>,
|
||||
pub(crate) sync_endpoint_input: Entity<InputState>,
|
||||
pub(crate) sync_username_input: Entity<InputState>,
|
||||
pub(crate) sync_webdav_password_input: Entity<InputState>,
|
||||
pub(crate) sync_s3_endpoint_input: Entity<InputState>,
|
||||
pub(crate) sync_s3_region_input: Entity<InputState>,
|
||||
pub(crate) sync_s3_bucket_input: Entity<InputState>,
|
||||
pub(crate) sync_s3_object_key_input: Entity<InputState>,
|
||||
pub(crate) sync_s3_access_key_input: Entity<InputState>,
|
||||
pub(crate) sync_s3_secret_key_input: Entity<InputState>,
|
||||
pub(crate) sync_s3_session_token_input: Entity<InputState>,
|
||||
pub(crate) sync_encryption_password_input: Entity<InputState>,
|
||||
pub(crate) sync_in_progress: bool,
|
||||
pub(crate) sync_status: SharedString,
|
||||
pub(crate) sftp_path_input: Entity<InputState>,
|
||||
pub(crate) ssh_auth_method: AuthMethod,
|
||||
pub(crate) editing_session_id: Option<String>,
|
||||
@@ -353,6 +367,63 @@ impl Ashell {
|
||||
let sftp_path_input = cx.new(|cx| InputState::new(window, cx).default_value("/"));
|
||||
let sftp_new_folder_input =
|
||||
cx.new(|cx| InputState::new(window, cx).placeholder(t!("new_folder").to_string()));
|
||||
let config = ConfigStore::load().unwrap_or_else(|err| {
|
||||
tracing::warn!("failed to load config: {err:#}");
|
||||
ConfigStore::in_memory()
|
||||
});
|
||||
let sync_endpoint_input = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.placeholder("https://dav.example.com/ashell/")
|
||||
.default_value(config.sync_endpoint())
|
||||
});
|
||||
let sync_username_input = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.placeholder(t!("sync_username").to_string())
|
||||
.default_value(config.sync_username())
|
||||
});
|
||||
let sync_webdav_password_input = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.placeholder(t!("sync_webdav_password").to_string())
|
||||
.masked(true)
|
||||
});
|
||||
let sync_s3_endpoint_input = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.placeholder("https://s3.example.com")
|
||||
.default_value(config.sync_s3_endpoint())
|
||||
});
|
||||
let sync_s3_region_input = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.placeholder("us-east-1")
|
||||
.default_value(config.sync_s3_region())
|
||||
});
|
||||
let sync_s3_bucket_input = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.placeholder(t!("sync_s3_bucket").to_string())
|
||||
.default_value(config.sync_s3_bucket())
|
||||
});
|
||||
let sync_s3_object_key_input = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.placeholder("ashell-sync.json")
|
||||
.default_value(config.sync_s3_object_key())
|
||||
});
|
||||
let sync_s3_access_key_input = cx.new(|cx| {
|
||||
InputState::new(window, cx).placeholder(t!("sync_s3_access_key").to_string())
|
||||
});
|
||||
let sync_s3_secret_key_input = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.placeholder(t!("sync_s3_secret_key").to_string())
|
||||
.masked(true)
|
||||
});
|
||||
let sync_s3_session_token_input = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.placeholder(t!("sync_s3_session_token").to_string())
|
||||
.masked(true)
|
||||
});
|
||||
let sync_encryption_password_input = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.placeholder(t!("sync_encryption_password").to_string())
|
||||
.masked(true)
|
||||
});
|
||||
|
||||
let _subscriptions = vec![
|
||||
cx.subscribe_in(&host_input, window, Self::on_input_event),
|
||||
@@ -365,6 +436,21 @@ impl Ashell {
|
||||
cx.subscribe_in(&passphrase_input, window, Self::on_input_event),
|
||||
cx.subscribe_in(&sftp_path_input, window, Self::on_input_event),
|
||||
cx.subscribe_in(&sftp_new_folder_input, window, Self::on_input_event),
|
||||
cx.subscribe_in(&sync_endpoint_input, window, Self::on_input_event),
|
||||
cx.subscribe_in(&sync_username_input, window, Self::on_input_event),
|
||||
cx.subscribe_in(&sync_webdav_password_input, window, Self::on_input_event),
|
||||
cx.subscribe_in(&sync_s3_endpoint_input, window, Self::on_input_event),
|
||||
cx.subscribe_in(&sync_s3_region_input, window, Self::on_input_event),
|
||||
cx.subscribe_in(&sync_s3_bucket_input, window, Self::on_input_event),
|
||||
cx.subscribe_in(&sync_s3_object_key_input, window, Self::on_input_event),
|
||||
cx.subscribe_in(&sync_s3_access_key_input, window, Self::on_input_event),
|
||||
cx.subscribe_in(&sync_s3_secret_key_input, window, Self::on_input_event),
|
||||
cx.subscribe_in(&sync_s3_session_token_input, window, Self::on_input_event),
|
||||
cx.subscribe_in(
|
||||
&sync_encryption_password_input,
|
||||
window,
|
||||
Self::on_input_event,
|
||||
),
|
||||
];
|
||||
|
||||
let (events_tx, events_rx) = mpsc::channel();
|
||||
@@ -374,10 +460,6 @@ impl Ashell {
|
||||
let system = system_sampler.sample();
|
||||
let default_light_theme_name = ThemeRegistry::global(cx).default_light_theme().name.clone();
|
||||
let default_dark_theme_name = ThemeRegistry::global(cx).default_dark_theme().name.clone();
|
||||
let config = ConfigStore::load().unwrap_or_else(|err| {
|
||||
tracing::warn!("failed to load config: {err:#}");
|
||||
ConfigStore::in_memory()
|
||||
});
|
||||
let follow_system_theme = config.follow_system_theme();
|
||||
|
||||
let theme_mode = match config.theme_mode() {
|
||||
@@ -421,6 +503,19 @@ impl Ashell {
|
||||
key_path_input,
|
||||
key_inline_input,
|
||||
passphrase_input,
|
||||
sync_endpoint_input,
|
||||
sync_username_input,
|
||||
sync_webdav_password_input,
|
||||
sync_s3_endpoint_input,
|
||||
sync_s3_region_input,
|
||||
sync_s3_bucket_input,
|
||||
sync_s3_object_key_input,
|
||||
sync_s3_access_key_input,
|
||||
sync_s3_secret_key_input,
|
||||
sync_s3_session_token_input,
|
||||
sync_encryption_password_input,
|
||||
sync_in_progress: false,
|
||||
sync_status: t!("sync_not_run").into(),
|
||||
sftp_path_input,
|
||||
ssh_auth_method: AuthMethod::Password,
|
||||
editing_session_id: None,
|
||||
@@ -799,6 +894,32 @@ impl Ashell {
|
||||
tab.title = title.clone();
|
||||
}
|
||||
}
|
||||
BackendEvent::SyncFinished(result) => {
|
||||
self.sync_in_progress = false;
|
||||
match result {
|
||||
crate::sync::SyncResult::Uploaded { etag } => {
|
||||
if etag.is_some() {
|
||||
self.config.set_sync_etag(etag);
|
||||
}
|
||||
self.sync_status = t!("sync_upload_complete").into();
|
||||
let _ = self.config.save();
|
||||
}
|
||||
crate::sync::SyncResult::Downloaded { payload, etag } => {
|
||||
self.config.replace_sessions(payload.sessions);
|
||||
self.config.set_sync_etag(etag);
|
||||
match self.config.save() {
|
||||
Ok(()) => self.sync_status = t!("sync_download_complete").into(),
|
||||
Err(err) => {
|
||||
self.sync_status =
|
||||
format!("{}: {err:#}", t!("sync_failed")).into()
|
||||
}
|
||||
}
|
||||
}
|
||||
crate::sync::SyncResult::Failed(error) => {
|
||||
self.sync_status = format!("{}: {error}", t!("sync_failed")).into();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if transfers_changed {
|
||||
|
||||
@@ -7,6 +7,7 @@ mod app;
|
||||
mod backend;
|
||||
mod session;
|
||||
mod sftp;
|
||||
mod sync;
|
||||
mod system;
|
||||
mod terminal;
|
||||
|
||||
|
||||
+110
-2
@@ -150,12 +150,40 @@ pub struct ConfigFile {
|
||||
pub sftp_panel_minimized: bool,
|
||||
#[serde(default)]
|
||||
pub key_bindings: std::collections::HashMap<String, String>,
|
||||
#[serde(default)]
|
||||
pub sync_endpoint: String,
|
||||
#[serde(default)]
|
||||
pub sync_username: String,
|
||||
#[serde(default)]
|
||||
pub sync_etag: Option<String>,
|
||||
#[serde(default)]
|
||||
pub sync_device_id: String,
|
||||
#[serde(default)]
|
||||
pub sync_backend: String,
|
||||
#[serde(default)]
|
||||
pub sync_etag_backend: String,
|
||||
#[serde(default)]
|
||||
pub sync_s3_endpoint: String,
|
||||
#[serde(default = "default_s3_region")]
|
||||
pub sync_s3_region: String,
|
||||
#[serde(default)]
|
||||
pub sync_s3_bucket: String,
|
||||
#[serde(default = "default_s3_object_key")]
|
||||
pub sync_s3_object_key: String,
|
||||
}
|
||||
|
||||
fn default_monitoring_position() -> String {
|
||||
"Sidebar".to_string()
|
||||
}
|
||||
|
||||
fn default_s3_region() -> String {
|
||||
"us-east-1".to_string()
|
||||
}
|
||||
|
||||
fn default_s3_object_key() -> String {
|
||||
"ashell-sync.json".to_string()
|
||||
}
|
||||
|
||||
fn default_follow_system_theme() -> bool {
|
||||
true
|
||||
}
|
||||
@@ -208,7 +236,7 @@ impl ConfigStore {
|
||||
let _ = fs::create_dir_all(&tmp_dir);
|
||||
}
|
||||
|
||||
let cache = if path.exists() {
|
||||
let mut cache = if path.exists() {
|
||||
let raw = fs::read_to_string(&path)
|
||||
.with_context(|| format!("failed to read {}", path.display()))?;
|
||||
match serde_json::from_str::<ConfigFile>(&raw) {
|
||||
@@ -235,13 +263,20 @@ impl ConfigStore {
|
||||
ConfigFile::default()
|
||||
};
|
||||
|
||||
if cache.sync_device_id.is_empty() {
|
||||
cache.sync_device_id = Uuid::new_v4().to_string();
|
||||
}
|
||||
Ok(Self { path, cache })
|
||||
}
|
||||
|
||||
pub fn in_memory() -> Self {
|
||||
let cache = ConfigFile {
|
||||
sync_device_id: Uuid::new_v4().to_string(),
|
||||
..ConfigFile::default()
|
||||
};
|
||||
Self {
|
||||
path: PathBuf::new(),
|
||||
cache: ConfigFile::default(),
|
||||
cache,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,6 +293,79 @@ impl ConfigStore {
|
||||
&self.cache.sessions
|
||||
}
|
||||
|
||||
pub fn replace_sessions(&mut self, sessions: Vec<Session>) {
|
||||
self.cache.sessions = sessions;
|
||||
}
|
||||
|
||||
pub fn sync_endpoint(&self) -> &str {
|
||||
&self.cache.sync_endpoint
|
||||
}
|
||||
|
||||
pub fn sync_username(&self) -> &str {
|
||||
&self.cache.sync_username
|
||||
}
|
||||
|
||||
pub fn sync_etag(&self) -> Option<&str> {
|
||||
(self.cache.sync_etag_backend == self.sync_backend())
|
||||
.then_some(self.cache.sync_etag.as_deref())
|
||||
.flatten()
|
||||
}
|
||||
|
||||
pub fn sync_device_id(&self) -> &str {
|
||||
&self.cache.sync_device_id
|
||||
}
|
||||
|
||||
pub fn sync_backend(&self) -> &str {
|
||||
if self.cache.sync_backend == "s3" {
|
||||
"s3"
|
||||
} else {
|
||||
"webdav"
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_sync_backend(&mut self, backend: &str) {
|
||||
self.cache.sync_backend = if backend == "s3" { "s3" } else { "webdav" }.to_string();
|
||||
}
|
||||
|
||||
pub fn sync_s3_endpoint(&self) -> &str {
|
||||
&self.cache.sync_s3_endpoint
|
||||
}
|
||||
|
||||
pub fn sync_s3_region(&self) -> &str {
|
||||
if self.cache.sync_s3_region.is_empty() { "us-east-1" } else { &self.cache.sync_s3_region }
|
||||
}
|
||||
|
||||
pub fn sync_s3_bucket(&self) -> &str {
|
||||
&self.cache.sync_s3_bucket
|
||||
}
|
||||
|
||||
pub fn sync_s3_object_key(&self) -> &str {
|
||||
if self.cache.sync_s3_object_key.is_empty() { "ashell-sync.json" } else { &self.cache.sync_s3_object_key }
|
||||
}
|
||||
|
||||
pub fn set_sync_connection(&mut self, endpoint: String, username: String) {
|
||||
self.cache.sync_endpoint = endpoint;
|
||||
self.cache.sync_username = username;
|
||||
}
|
||||
|
||||
pub fn set_sync_s3_connection(
|
||||
&mut self,
|
||||
endpoint: String,
|
||||
region: String,
|
||||
bucket: String,
|
||||
object_key: String,
|
||||
) {
|
||||
self.cache.sync_s3_endpoint = endpoint;
|
||||
self.cache.sync_s3_region = region;
|
||||
self.cache.sync_s3_bucket = bucket;
|
||||
self.cache.sync_s3_object_key = object_key;
|
||||
}
|
||||
|
||||
pub fn set_sync_etag(&mut self, etag: Option<String>) {
|
||||
self.cache.sync_etag = etag;
|
||||
self.cache.sync_etag_backend = self.sync_backend().to_string();
|
||||
}
|
||||
|
||||
pub fn tmp_dir(&self) -> Option<PathBuf> {
|
||||
self.path.parent().map(|p| p.join("tmp"))
|
||||
}
|
||||
|
||||
+577
@@ -0,0 +1,577 @@
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use argon2::Argon2;
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
||||
use chacha20poly1305::{
|
||||
XChaCha20Poly1305, XNonce,
|
||||
aead::{Aead, KeyInit},
|
||||
};
|
||||
use hmac::{Hmac, Mac};
|
||||
use rand::{RngCore, rngs::OsRng};
|
||||
use reqwest::{Client, StatusCode, header};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::session::config::Session;
|
||||
|
||||
const SYNC_FILE_NAME: &str = "ashell-sync.json";
|
||||
const FORMAT_VERSION: u32 = 1;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SyncPayload {
|
||||
pub schema_version: u32,
|
||||
pub revision: String,
|
||||
pub updated_at: String,
|
||||
pub device_id: String,
|
||||
pub sessions: Vec<Session>,
|
||||
}
|
||||
|
||||
impl SyncPayload {
|
||||
pub fn new(device_id: String, sessions: Vec<Session>) -> Self {
|
||||
Self {
|
||||
schema_version: FORMAT_VERSION,
|
||||
revision: Uuid::new_v4().to_string(),
|
||||
updated_at: chrono::Utc::now().to_rfc3339(),
|
||||
device_id,
|
||||
sessions,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct EncryptedEnvelope {
|
||||
format_version: u32,
|
||||
kdf: String,
|
||||
cipher: String,
|
||||
salt: String,
|
||||
nonce: String,
|
||||
payload: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SyncCredentials {
|
||||
pub backend: SyncBackendCredentials,
|
||||
pub encryption_password: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum SyncBackendCredentials {
|
||||
WebDav {
|
||||
endpoint: String,
|
||||
username: String,
|
||||
password: String,
|
||||
},
|
||||
S3 {
|
||||
endpoint: String,
|
||||
region: String,
|
||||
bucket: String,
|
||||
object_key: String,
|
||||
access_key: String,
|
||||
secret_key: String,
|
||||
session_token: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SyncResult {
|
||||
Uploaded {
|
||||
etag: Option<String>,
|
||||
},
|
||||
Downloaded {
|
||||
payload: SyncPayload,
|
||||
etag: Option<String>,
|
||||
},
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
pub async fn upload(
|
||||
credentials: SyncCredentials,
|
||||
payload: SyncPayload,
|
||||
expected_etag: Option<String>,
|
||||
) -> Result<Option<String>> {
|
||||
validate_credentials(&credentials)?;
|
||||
let body = encrypt_payload(&payload, &credentials.encryption_password)?;
|
||||
match credentials.backend {
|
||||
SyncBackendCredentials::WebDav {
|
||||
endpoint,
|
||||
username,
|
||||
password,
|
||||
} => upload_webdav(&endpoint, &username, &password, body, expected_etag).await,
|
||||
SyncBackendCredentials::S3 {
|
||||
endpoint,
|
||||
region,
|
||||
bucket,
|
||||
object_key,
|
||||
access_key,
|
||||
secret_key,
|
||||
session_token,
|
||||
} => {
|
||||
let config = S3Config {
|
||||
endpoint,
|
||||
region,
|
||||
bucket,
|
||||
object_key,
|
||||
access_key,
|
||||
secret_key,
|
||||
session_token,
|
||||
};
|
||||
upload_s3(&config, body, expected_etag).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn upload_webdav(
|
||||
endpoint: &str,
|
||||
username: &str,
|
||||
password: &str,
|
||||
body: Vec<u8>,
|
||||
expected_etag: Option<String>,
|
||||
) -> Result<Option<String>> {
|
||||
let client = Client::new();
|
||||
let mut request = client
|
||||
.put(sync_url(endpoint))
|
||||
.basic_auth(username, Some(password))
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(body);
|
||||
request = if let Some(etag) = expected_etag {
|
||||
request.header(header::IF_MATCH, etag)
|
||||
} else {
|
||||
// An uninitialized client may only create a new remote file. This keeps
|
||||
// it from silently replacing configuration uploaded by another device.
|
||||
request.header(header::IF_NONE_MATCH, "*")
|
||||
};
|
||||
let response = request.send().await.context("send WebDAV upload")?;
|
||||
if response.status() == StatusCode::PRECONDITION_FAILED
|
||||
|| response.status() == StatusCode::CONFLICT
|
||||
{
|
||||
return Err(anyhow!(
|
||||
"remote configuration changed; download it before uploading"
|
||||
));
|
||||
}
|
||||
if !response.status().is_success() {
|
||||
return Err(anyhow!("WebDAV upload failed: HTTP {}", response.status()));
|
||||
}
|
||||
Ok(response
|
||||
.headers()
|
||||
.get(header::ETAG)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::to_string))
|
||||
}
|
||||
|
||||
pub async fn download(credentials: SyncCredentials) -> Result<(SyncPayload, Option<String>)> {
|
||||
validate_credentials(&credentials)?;
|
||||
let encryption_password = credentials.encryption_password;
|
||||
let (body, etag) = match credentials.backend {
|
||||
SyncBackendCredentials::WebDav {
|
||||
endpoint,
|
||||
username,
|
||||
password,
|
||||
} => download_webdav(&endpoint, &username, &password).await?,
|
||||
SyncBackendCredentials::S3 {
|
||||
endpoint,
|
||||
region,
|
||||
bucket,
|
||||
object_key,
|
||||
access_key,
|
||||
secret_key,
|
||||
session_token,
|
||||
} => {
|
||||
let config = S3Config {
|
||||
endpoint,
|
||||
region,
|
||||
bucket,
|
||||
object_key,
|
||||
access_key,
|
||||
secret_key,
|
||||
session_token,
|
||||
};
|
||||
download_s3(&config).await?
|
||||
}
|
||||
};
|
||||
let payload = decrypt_payload(&body, &encryption_password)?;
|
||||
Ok((payload, etag))
|
||||
}
|
||||
|
||||
async fn download_webdav(
|
||||
endpoint: &str,
|
||||
username: &str,
|
||||
password: &str,
|
||||
) -> Result<(Vec<u8>, Option<String>)> {
|
||||
let response = Client::new()
|
||||
.get(sync_url(endpoint))
|
||||
.basic_auth(username, Some(password))
|
||||
.send()
|
||||
.await
|
||||
.context("send WebDAV download")?;
|
||||
if response.status() == StatusCode::NOT_FOUND {
|
||||
return Err(anyhow!("no remote configuration exists yet"));
|
||||
}
|
||||
if !response.status().is_success() {
|
||||
return Err(anyhow!(
|
||||
"WebDAV download failed: HTTP {}",
|
||||
response.status()
|
||||
));
|
||||
}
|
||||
let etag = response
|
||||
.headers()
|
||||
.get(header::ETAG)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::to_string);
|
||||
let body = response
|
||||
.bytes()
|
||||
.await
|
||||
.context("read WebDAV response")?
|
||||
.to_vec();
|
||||
Ok((body, etag))
|
||||
}
|
||||
|
||||
fn validate_credentials(credentials: &SyncCredentials) -> Result<()> {
|
||||
if credentials.encryption_password.len() < 8 {
|
||||
return Err(anyhow!(
|
||||
"encryption password must contain at least 8 characters"
|
||||
));
|
||||
}
|
||||
match &credentials.backend {
|
||||
SyncBackendCredentials::WebDav { endpoint, .. } if endpoint.trim().is_empty() => {
|
||||
Err(anyhow!("WebDAV endpoint is required"))
|
||||
}
|
||||
SyncBackendCredentials::S3 {
|
||||
region,
|
||||
bucket,
|
||||
access_key,
|
||||
secret_key,
|
||||
..
|
||||
} if region.trim().is_empty()
|
||||
|| bucket.trim().is_empty()
|
||||
|| access_key.trim().is_empty()
|
||||
|| secret_key.is_empty() =>
|
||||
{
|
||||
Err(anyhow!(
|
||||
"S3 region, bucket, access key and secret key are required"
|
||||
))
|
||||
}
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
struct S3Config {
|
||||
endpoint: String,
|
||||
region: String,
|
||||
bucket: String,
|
||||
object_key: String,
|
||||
access_key: String,
|
||||
secret_key: String,
|
||||
session_token: String,
|
||||
}
|
||||
|
||||
async fn upload_s3(
|
||||
config: &S3Config,
|
||||
body: Vec<u8>,
|
||||
expected_etag: Option<String>,
|
||||
) -> Result<Option<String>> {
|
||||
let url = s3_url(config)?;
|
||||
let mut headers = signed_s3_headers("PUT", &url, &body, config)?;
|
||||
headers.insert(
|
||||
header::CONTENT_TYPE,
|
||||
header::HeaderValue::from_static("application/json"),
|
||||
);
|
||||
if let Some(etag) = expected_etag {
|
||||
headers.insert(header::IF_MATCH, header_value(&etag, "S3 ETag")?);
|
||||
} else {
|
||||
headers.insert(header::IF_NONE_MATCH, header::HeaderValue::from_static("*"));
|
||||
}
|
||||
let response = Client::new()
|
||||
.put(url)
|
||||
.headers(headers)
|
||||
.body(body)
|
||||
.send()
|
||||
.await
|
||||
.context("send S3 upload")?;
|
||||
if response.status() == StatusCode::PRECONDITION_FAILED
|
||||
|| response.status() == StatusCode::CONFLICT
|
||||
{
|
||||
return Err(anyhow!(
|
||||
"remote configuration changed; download it before uploading"
|
||||
));
|
||||
}
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let detail = response.text().await.unwrap_or_default();
|
||||
return Err(anyhow!("S3 upload failed: HTTP {status}: {detail}"));
|
||||
}
|
||||
Ok(response
|
||||
.headers()
|
||||
.get(header::ETAG)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::to_string))
|
||||
}
|
||||
|
||||
async fn download_s3(config: &S3Config) -> Result<(Vec<u8>, Option<String>)> {
|
||||
let url = s3_url(config)?;
|
||||
let headers = signed_s3_headers("GET", &url, &[], config)?;
|
||||
let response = Client::new()
|
||||
.get(url)
|
||||
.headers(headers)
|
||||
.send()
|
||||
.await
|
||||
.context("send S3 download")?;
|
||||
if response.status() == StatusCode::NOT_FOUND {
|
||||
return Err(anyhow!("no remote configuration exists yet"));
|
||||
}
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let detail = response.text().await.unwrap_or_default();
|
||||
return Err(anyhow!("S3 download failed: HTTP {status}: {detail}"));
|
||||
}
|
||||
let etag = response
|
||||
.headers()
|
||||
.get(header::ETAG)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::to_string);
|
||||
let body = response.bytes().await.context("read S3 response")?.to_vec();
|
||||
Ok((body, etag))
|
||||
}
|
||||
|
||||
fn s3_url(config: &S3Config) -> Result<reqwest::Url> {
|
||||
let endpoint = if config.endpoint.trim().is_empty() {
|
||||
format!("https://s3.{}.amazonaws.com", config.region.trim())
|
||||
} else {
|
||||
config.endpoint.trim().trim_end_matches('/').to_string()
|
||||
};
|
||||
let key = if config.object_key.trim().is_empty() {
|
||||
SYNC_FILE_NAME
|
||||
} else {
|
||||
config.object_key.trim().trim_start_matches('/')
|
||||
};
|
||||
let url = format!(
|
||||
"{}/{}/{}",
|
||||
endpoint,
|
||||
aws_uri_encode(config.bucket.trim(), true),
|
||||
aws_uri_encode(key, false)
|
||||
);
|
||||
reqwest::Url::parse(&url).context("parse S3 object URL")
|
||||
}
|
||||
|
||||
fn signed_s3_headers(
|
||||
method: &str,
|
||||
url: &reqwest::Url,
|
||||
body: &[u8],
|
||||
config: &S3Config,
|
||||
) -> Result<header::HeaderMap> {
|
||||
let now = chrono::Utc::now();
|
||||
let amz_date = now.format("%Y%m%dT%H%M%SZ").to_string();
|
||||
let date = now.format("%Y%m%d").to_string();
|
||||
let host = url
|
||||
.host_str()
|
||||
.ok_or_else(|| anyhow!("S3 endpoint has no host"))?;
|
||||
let host = match url.port() {
|
||||
Some(port) => format!("{host}:{port}"),
|
||||
None => host.to_string(),
|
||||
};
|
||||
let payload_hash = hex_sha256(body);
|
||||
let mut canonical_headers =
|
||||
format!("host:{host}\nx-amz-content-sha256:{payload_hash}\nx-amz-date:{amz_date}\n");
|
||||
let mut signed_headers = "host;x-amz-content-sha256;x-amz-date".to_string();
|
||||
if !config.session_token.is_empty() {
|
||||
canonical_headers.push_str(&format!(
|
||||
"x-amz-security-token:{}\n",
|
||||
config.session_token.trim()
|
||||
));
|
||||
signed_headers.push_str(";x-amz-security-token");
|
||||
}
|
||||
let canonical_request = format!(
|
||||
"{method}\n{}\n\n{canonical_headers}\n{signed_headers}\n{payload_hash}",
|
||||
url.path()
|
||||
);
|
||||
let scope = format!("{date}/{}/s3/aws4_request", config.region.trim());
|
||||
let string_to_sign = format!(
|
||||
"AWS4-HMAC-SHA256\n{amz_date}\n{scope}\n{}",
|
||||
hex_sha256(canonical_request.as_bytes())
|
||||
);
|
||||
let date_key = hmac_sha256(
|
||||
format!("AWS4{}", config.secret_key).as_bytes(),
|
||||
date.as_bytes(),
|
||||
)?;
|
||||
let region_key = hmac_sha256(&date_key, config.region.trim().as_bytes())?;
|
||||
let service_key = hmac_sha256(®ion_key, b"s3")?;
|
||||
let signing_key = hmac_sha256(&service_key, b"aws4_request")?;
|
||||
let signature = hex::encode(hmac_sha256(&signing_key, string_to_sign.as_bytes())?);
|
||||
let authorization = format!(
|
||||
"AWS4-HMAC-SHA256 Credential={}/{scope}, SignedHeaders={signed_headers}, Signature={signature}",
|
||||
config.access_key.trim()
|
||||
);
|
||||
let mut headers = header::HeaderMap::new();
|
||||
headers.insert("x-amz-date", header_value(&amz_date, "S3 date")?);
|
||||
headers.insert(
|
||||
"x-amz-content-sha256",
|
||||
header_value(&payload_hash, "S3 payload hash")?,
|
||||
);
|
||||
headers.insert(
|
||||
header::AUTHORIZATION,
|
||||
header_value(&authorization, "S3 authorization")?,
|
||||
);
|
||||
if !config.session_token.is_empty() {
|
||||
headers.insert(
|
||||
"x-amz-security-token",
|
||||
header_value(config.session_token.trim(), "S3 session token")?,
|
||||
);
|
||||
}
|
||||
Ok(headers)
|
||||
}
|
||||
|
||||
fn header_value(value: &str, name: &str) -> Result<header::HeaderValue> {
|
||||
header::HeaderValue::from_str(value).with_context(|| format!("invalid {name}"))
|
||||
}
|
||||
|
||||
fn hmac_sha256(key: &[u8], value: &[u8]) -> Result<Vec<u8>> {
|
||||
let mut mac =
|
||||
<Hmac<Sha256> as Mac>::new_from_slice(key).map_err(|_| anyhow!("initialize S3 signer"))?;
|
||||
mac.update(value);
|
||||
Ok(mac.finalize().into_bytes().to_vec())
|
||||
}
|
||||
|
||||
fn hex_sha256(value: &[u8]) -> String {
|
||||
hex::encode(Sha256::digest(value))
|
||||
}
|
||||
|
||||
fn aws_uri_encode(value: &str, encode_slash: bool) -> String {
|
||||
let mut encoded = String::new();
|
||||
for byte in value.bytes() {
|
||||
if byte.is_ascii_alphanumeric()
|
||||
|| matches!(byte, b'-' | b'_' | b'.' | b'~')
|
||||
|| (!encode_slash && byte == b'/')
|
||||
{
|
||||
encoded.push(byte as char);
|
||||
} else {
|
||||
encoded.push_str(&format!("%{byte:02X}"));
|
||||
}
|
||||
}
|
||||
encoded
|
||||
}
|
||||
|
||||
fn sync_url(endpoint: &str) -> String {
|
||||
let endpoint = endpoint.trim();
|
||||
if endpoint.ends_with('/') {
|
||||
format!("{endpoint}{SYNC_FILE_NAME}")
|
||||
} else if endpoint.ends_with(".json") {
|
||||
endpoint.to_string()
|
||||
} else {
|
||||
format!("{endpoint}/{SYNC_FILE_NAME}")
|
||||
}
|
||||
}
|
||||
|
||||
fn encrypt_payload(payload: &SyncPayload, password: &str) -> Result<Vec<u8>> {
|
||||
let mut salt = [0u8; 16];
|
||||
let mut nonce = [0u8; 24];
|
||||
OsRng.fill_bytes(&mut salt);
|
||||
OsRng.fill_bytes(&mut nonce);
|
||||
let key = derive_key(password, &salt)?;
|
||||
let plaintext = serde_json::to_vec(payload).context("serialize sync payload")?;
|
||||
let ciphertext = XChaCha20Poly1305::new((&key).into())
|
||||
.encrypt(XNonce::from_slice(&nonce), plaintext.as_ref())
|
||||
.map_err(|_| anyhow!("encrypt sync payload"))?;
|
||||
serde_json::to_vec_pretty(&EncryptedEnvelope {
|
||||
format_version: FORMAT_VERSION,
|
||||
kdf: "argon2id".to_string(),
|
||||
cipher: "xchacha20poly1305".to_string(),
|
||||
salt: STANDARD.encode(salt),
|
||||
nonce: STANDARD.encode(nonce),
|
||||
payload: STANDARD.encode(ciphertext),
|
||||
})
|
||||
.context("serialize encrypted sync envelope")
|
||||
}
|
||||
|
||||
fn decrypt_payload(raw: &[u8], password: &str) -> Result<SyncPayload> {
|
||||
let envelope: EncryptedEnvelope =
|
||||
serde_json::from_slice(raw).context("parse encrypted sync envelope")?;
|
||||
if envelope.format_version != FORMAT_VERSION
|
||||
|| envelope.kdf != "argon2id"
|
||||
|| envelope.cipher != "xchacha20poly1305"
|
||||
{
|
||||
return Err(anyhow!("unsupported remote sync format"));
|
||||
}
|
||||
let salt = STANDARD.decode(envelope.salt).context("decode sync salt")?;
|
||||
let nonce = STANDARD
|
||||
.decode(envelope.nonce)
|
||||
.context("decode sync nonce")?;
|
||||
if nonce.len() != 24 {
|
||||
return Err(anyhow!("invalid sync nonce"));
|
||||
}
|
||||
let ciphertext = STANDARD
|
||||
.decode(envelope.payload)
|
||||
.context("decode encrypted sync payload")?;
|
||||
let key = derive_key(password, &salt)?;
|
||||
let plaintext = XChaCha20Poly1305::new((&key).into())
|
||||
.decrypt(XNonce::from_slice(&nonce), ciphertext.as_ref())
|
||||
.map_err(|_| anyhow!("cannot decrypt remote configuration; check the password"))?;
|
||||
let payload: SyncPayload =
|
||||
serde_json::from_slice(&plaintext).context("parse decrypted sync payload")?;
|
||||
if payload.schema_version != FORMAT_VERSION {
|
||||
return Err(anyhow!("unsupported synchronized configuration version"));
|
||||
}
|
||||
Ok(payload)
|
||||
}
|
||||
|
||||
fn derive_key(password: &str, salt: &[u8]) -> Result<[u8; 32]> {
|
||||
let mut key = [0u8; 32];
|
||||
Argon2::default()
|
||||
.hash_password_into(password.as_bytes(), salt, &mut key)
|
||||
.map_err(|err| anyhow!("derive encryption key: {err}"))?;
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn encrypted_payload_round_trip() {
|
||||
let payload = SyncPayload::new("test-device".into(), Vec::new());
|
||||
let encrypted = encrypt_payload(&payload, "correct horse battery staple").unwrap();
|
||||
assert!(!String::from_utf8_lossy(&encrypted).contains("test-device"));
|
||||
let decrypted = decrypt_payload(&encrypted, "correct horse battery staple").unwrap();
|
||||
assert_eq!(decrypted.revision, payload.revision);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_password_is_rejected() {
|
||||
let payload = SyncPayload::new("test-device".into(), Vec::new());
|
||||
let encrypted = encrypt_payload(&payload, "correct horse battery staple").unwrap();
|
||||
assert!(decrypt_payload(&encrypted, "incorrect password").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endpoint_can_be_a_collection_or_file() {
|
||||
assert_eq!(
|
||||
sync_url("https://example.test/dav/"),
|
||||
"https://example.test/dav/ashell-sync.json"
|
||||
);
|
||||
assert_eq!(
|
||||
sync_url("https://example.test/config.json"),
|
||||
"https://example.test/config.json"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn s3_url_uses_path_style_and_encodes_object_key() {
|
||||
let config = S3Config {
|
||||
endpoint: "https://s3.example.test".into(),
|
||||
region: "us-east-1".into(),
|
||||
bucket: "my-bucket".into(),
|
||||
object_key: "configs/my file.json".into(),
|
||||
access_key: "access".into(),
|
||||
secret_key: "secret".into(),
|
||||
session_token: String::new(),
|
||||
};
|
||||
assert_eq!(
|
||||
s3_url(&config).unwrap().as_str(),
|
||||
"https://s3.example.test/my-bucket/configs/my%20file.json"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aws_uri_encoding_preserves_only_object_key_slashes() {
|
||||
assert_eq!(aws_uri_encode("a b/c", false), "a%20b/c");
|
||||
assert_eq!(aws_uri_encode("a/b", true), "a%2Fb");
|
||||
}
|
||||
}
|
||||
@@ -90,6 +90,7 @@ pub enum BackendEvent {
|
||||
tab_id: String,
|
||||
title: String,
|
||||
},
|
||||
SyncFinished(crate::sync::SyncResult),
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
|
||||
Reference in New Issue
Block a user