Skip to content
Merged
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
160 changes: 143 additions & 17 deletions bin/twcore/src/sync_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -179,19 +179,94 @@ pub fn disable(config: &Path, purge: bool) -> Result<()> {
Ok(())
}

/// 一段机器名能不能当设备名:去掉首尾空白,不能有控制字符,太长的截短
fn usable_name(raw: &str) -> Option<String> {
let n = raw.trim();
if n.is_empty() || n.chars().any(char::is_control) {
return None;
}
Some(n.chars().take(tw_config::sync::DEVICE_NAME_MAX).collect())
}

/// 跑一个系统自带的命令,取它输出的第一行
#[cfg(unix)]
fn command_line(program: &str, args: &[&str]) -> Option<String> {
let out = std::process::Command::new(program)
.args(args)
.stdin(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.output()
.ok()?;
if !out.status.success() {
return None;
}
usable_name(String::from_utf8_lossy(&out.stdout).lines().next()?)
}

/// 这台计算机的名称,当设备名称的缺省值:macOS 是系统设置里的「电脑名称」(ComputerName,
/// 取不到用 LocalHostName),Linux 是主机名,Windows 是计算机名
fn host_name() -> String {
for var in ["COMPUTERNAME", "HOSTNAME"] {
if let Ok(v) = std::env::var(var)
&& !v.trim().is_empty()
{
return v.trim().to_string();
#[cfg(target_os = "macos")]
let found = command_line("/usr/sbin/scutil", &["--get", "ComputerName"])
.or_else(|| command_line("/usr/sbin/scutil", &["--get", "LocalHostName"]))
.or_else(|| command_line("/bin/hostname", &[]));
#[cfg(windows)]
let found = std::env::var("COMPUTERNAME")
.ok()
.and_then(|v| usable_name(&v));
#[cfg(all(unix, not(target_os = "macos")))]
let found = ["/etc/hostname", "/proc/sys/kernel/hostname"]
.iter()
.find_map(|p| {
std::fs::read_to_string(p)
.ok()
.and_then(|s| usable_name(&s))
})
.or_else(|| command_line("hostname", &[]))
.or_else(|| std::env::var("HOSTNAME").ok().and_then(|v| usable_name(&v)));
#[cfg(not(any(unix, windows)))]
let found: Option<String> = None;
found.unwrap_or_else(|| "server".into())
}

/// 合并加入时,同步数据里和本机不一样的一项怎么办。**缺省是采用同步数据**:留本机的那一份
/// 会随这次加入上传,把别的设备上的那一项也改成本机的 —— 那要明确选
fn decide(row: &tw_api::ImportRow) -> Result<serde_json::Value> {
let differs = if row.differs.is_empty() {
String::new()
} else {
format!(" ({})", row.differs.join(", "))
};
let what = match row.item.name.as_str() {
"" => row.item.kind.slug().to_string(),
name => format!("{} `{name}`", row.item.kind.slug()),
};
eprintln!("{what}{differs} differs between this device and the sync data:");
eprintln!(" u use the sync data (default)");
eprintln!(
" k keep this device's version; it is uploaded and replaces the other devices' one"
);
let rename = row.suggested_name.clone().filter(|_| !row.global);
if let Some(n) = &rename {
eprintln!(" r keep both: the sync data's one is added as {n}");
}
loop {
let a = ask("Choice", Some("u"))?;
let action = match a.to_ascii_lowercase().as_str() {
"u" => "overwrite",
"k" => "skip",
"r" if rename.is_some() => "rename",
_ => {
eprintln!("type u, k{}", if rename.is_some() { " or r" } else { "" });
continue;
}
};
let mut d = serde_json::json!({ "item": row.item, "action": action });
if action == "rename" {
d["new_name"] = rename.clone().into();
}
return Ok(d);
}
std::fs::read_to_string("/etc/hostname")
.map(|s| s.trim().to_string())
.ok()
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "server".into())
}

pub fn enable(config: &Path) -> Result<()> {
Expand Down Expand Up @@ -275,11 +350,23 @@ pub fn enable(config: &Path) -> Result<()> {
"/sync/enable/preview",
Some(serde_json::json!({ "target": target, "passphrase": body.get("passphrase") })),
)?;
for a in &preview.auto {
match a.reason {
tw_api::SyncJoinAutoReason::DefaultKey => eprintln!(
"The default gateway key ({}) takes the sync data's value; clients set up \
on this device are updated to it",
a.item.name
),
}
}
if !preview.initial {
let auto: Vec<&tw_api::ItemRef> = preview.auto.iter().map(|a| &a.item).collect();
let conflicts: Vec<&tw_api::ImportRow> = preview
.rows
.iter()
.filter(|r| r.status == tw_api::ImportStatus::Conflict)
.filter(|r| {
r.status == tw_api::ImportStatus::Conflict && !auto.contains(&&r.item)
})
.collect();
let new = preview
.rows
Expand All @@ -292,16 +379,17 @@ pub fn enable(config: &Path) -> Result<()> {
conflicts.len()
);
let replace = yes(
"Replace this device's configuration with the sync data (no keeps this \
device's version of every item that differs)",
"Replace this device's configuration with the sync data (no merges them; \
each item that differs is asked)",
false,
)?;
if !replace {
body["join"] = "merge".into();
body["decisions"] = conflicts
.iter()
.map(|r| serde_json::json!({ "item": r.item, "action": "skip" }))
.collect();
let mut decisions = Vec::new();
for r in &conflicts {
decisions.push(decide(r)?);
}
body["decisions"] = decisions.into();
}
}
}
Expand All @@ -311,3 +399,41 @@ pub fn enable(config: &Path) -> Result<()> {
print_view(&v);
Ok(())
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn a_machine_name_is_trimmed_and_cut_to_a_device_name() {
assert_eq!(usable_name(" Mac mini \n").as_deref(), Some("Mac mini"));
assert_eq!(usable_name(" "), None);
assert_eq!(usable_name("a\u{7}b"), None);
let long = "名".repeat(100);
assert_eq!(
usable_name(&long).unwrap().chars().count(),
tw_config::sync::DEVICE_NAME_MAX
);
}

/// macOS 上缺省是「电脑名称」(ComputerName),不是 `server`
#[cfg(target_os = "macos")]
#[test]
fn on_macos_the_default_device_name_is_the_computer_name() {
let want = command_line("/usr/sbin/scutil", &["--get", "ComputerName"])
.or_else(|| command_line("/usr/sbin/scutil", &["--get", "LocalHostName"]));
let got = host_name();
assert_ne!(got, "server");
if let Some(w) = want {
assert_eq!(got, w);
}
}

/// 别的 Unix 上是主机名
#[cfg(all(unix, not(target_os = "macos")))]
#[test]
fn elsewhere_the_default_device_name_is_the_host_name() {
let got = host_name();
assert!(!got.is_empty());
}
}
3 changes: 2 additions & 1 deletion crates/tw-api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -993,7 +993,8 @@ pub const MSG_CODES: &str = include_str!("../msg-codes.txt");
/// `POST /sync/disable`([`SyncDisable`])、`POST /sync/republish`(同步位置空了、keyring 不见了
/// 之后由人选的「重新上传本机配置」,→ [`SyncView`])。[`SyncState`] 里要人处理的有
/// `keyring_missing`、`emptied`、`misconfigured`,[`SyncView`] 的 `skipped` 是同步位置上跳过了的
/// 文件数,[`SyncConflict`] 的 `options` 是能选的处理。状态变了发 [`Event::SyncStatus`];应用别的设备的
/// 文件数,[`SyncConflict`] 的 `options` 是能选的处理,[`SyncJoinPreview`] 的 `auto` 是加入时
/// 不用选的项(两边的默认网关密钥同名、值不同时采用同步数据的值)。状态变了发 [`Event::SyncStatus`];应用别的设备的
/// 版本时配置的来源是 `sync`,改了网关密钥时发 `GatewayKeysChanged`。[`ProviderView`] 多了
/// `sync_excluded`(不同步的上游)和 `sync_url_changed`(地址经同步变更,改之前的地址)。
/// 同步的码是 `sync.*`。安全模式下不自动同步(`paused`)
Expand Down
25 changes: 24 additions & 1 deletion crates/tw-api/src/sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

use serde::{Deserialize, Serialize};

use crate::{ImportDecision, ImportRow, ItemRef, Msg};
use crate::{ImportAction, ImportDecision, ImportRow, ItemRef, Msg};

fn direct() -> String {
"direct".to_string()
Expand Down Expand Up @@ -179,6 +179,29 @@ pub struct SyncJoinPreview {
pub initial: bool,
/// 同步数据里的每个项目和本机比([`ImportRow`])。`initial` 时是空的
pub rows: Vec<ImportRow>,
/// core 替人定了、不用选的那几项(两种加入方式都照它办,`decisions` 里给了也不看)。它们
/// 在 `rows` 里照常列出,界面那一行显示成定下的处理和原因,不当作冲突
pub auto: Vec<SyncJoinAuto>,
}

/// 加入时不用选的一项([`SyncJoinPreview::auto`])
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
pub struct SyncJoinAuto {
pub item: ItemRef,
/// 怎么处理。现在只有 `overwrite`:采用同步数据
pub action: ImportAction,
pub reason: SyncJoinAutoReason,
}

slug_enum! {
/// 为什么不用选。
pub enum SyncJoinAutoReason {
/// 两边的默认网关密钥(`default_key` 指着的那一把)同名、值不同:每个安装都会自动生成
/// 一把,加入的设备采用同步数据的值。本机已接管的客户端随之自动更新
/// (`GatewayKeysChanged`)
DefaultKey = "default_key",
}
}

slug_enum! {
Expand Down
62 changes: 51 additions & 11 deletions crates/tw-control/src/sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1158,9 +1158,10 @@ fn is_initial(snap: &Snapshot) -> bool {
/// 加入时「合并」:和导入同一套规矩(`tw_pack::import`),本机打底,同步数据逐项并进来。
/// 新增的默认导入,冲突的必须选(覆盖、改名导入、跳过),没选是 `sync.join_undecided`。
///
/// **两处和导入不一样**:导入进来的插件按导入的规矩是停用的,加入同步时照同步数据里的开关
/// **三处和导入不一样**:导入进来的插件按导入的规矩是停用的,加入同步时照同步数据里的开关
/// (各设备一致,见方案 6.2;不加密时同步一轮里另有停用的规矩);导入预览里只有本机有的
/// 项目不出现,这里也一样 —— 它们会随这次加入上传
/// 项目不出现,这里也一样 —— 它们会随这次加入上传;两边的默认网关密钥同名、值不同时不用选,
/// 采用同步数据的值([`engine::adopted_default_key`],给了别的选择也不看)
fn merge_join(
local: &Pack,
remote: &Pack,
Expand All @@ -1172,8 +1173,18 @@ fn merge_join(
.iter()
.map(|d| (crate::backups::item_id(&d.item), d))
.collect();
let adopt = model::snapshot(local)
.ok()
.and_then(|l| adopted(&l, remote));
let mut out = Vec::new();
for r in &rows {
if adopt.as_ref() == Some(&r.item) {
out.push(tw_pack::import::Decision {
item: r.item.clone(),
action: tw_pack::import::Action::Overwrite,
});
continue;
}
let action = match decided.get(&r.item) {
Some(d) => match d.action {
tw_api::ImportAction::Import => tw_pack::import::Action::Import,
Expand Down Expand Up @@ -1340,17 +1351,13 @@ async fn join_preview(
let (target, store) = config_target(&s, &req.target)?;
let cur = current_pack(&s.cfg).map_err(|m| fail(StatusCode::INTERNAL_SERVER_ERROR, m))?;
let local = model::snapshot(&cur.pack).map_err(|e| fail(StatusCode::BAD_REQUEST, e.msg()))?;
if is_initial(&local) {
return Ok(Json(tw_api::SyncJoinPreview {
initial: true,
rows: Vec::new(),
}));
}
let initial = is_initial(&local);
let probe = Eng::probe(&store, req.passphrase.as_deref()).await;
if probe.existing.is_none() {
return Ok(Json(tw_api::SyncJoinPreview {
initial: false,
initial,
rows: Vec::new(),
auto: Vec::new(),
}));
}
let encrypted = probe.existing.as_ref().is_some_and(|e| e.encrypted);
Expand All @@ -1360,16 +1367,49 @@ async fn join_preview(
store,
&draft(target.clone(), encrypted, req.passphrase.clone(), "preview"),
);
let remote = e.latest().await.map_err(|e| round_fail(&e))?;
let remote = match e.latest().await {
Ok(r) => r,
// 初始配置不出现合并这一步:读不到同步数据(口令还没给)也照样答
Err(_) if initial => {
return Ok(Json(tw_api::SyncJoinPreview {
initial,
rows: Vec::new(),
auto: Vec::new(),
}));
}
Err(e) => return Err(round_fail(&e)),
};
let auto = adopted(&local, &remote)
.map(|id| tw_api::SyncJoinAuto {
item: item_ref(&id),
action: tw_api::ImportAction::Overwrite,
reason: tw_api::SyncJoinAutoReason::DefaultKey,
})
.into_iter()
.collect();
if initial {
return Ok(Json(tw_api::SyncJoinPreview {
initial,
rows: Vec::new(),
auto,
}));
}
// 和导入预览同一套规矩
let rows = tw_pack::import::preview(&cur.pack, &remote, &crate::backups::import_ctx())
.map_err(|e| import_fail(&e))?;
Ok(Json(tw_api::SyncJoinPreview {
initial: false,
initial,
rows: rows.into_iter().map(crate::backups::row_view).collect(),
auto,
}))
}

/// 加入时不用选、采用同步数据的那一把默认网关密钥([`engine::adopted_default_key`])
fn adopted(local: &Snapshot, remote: &Pack) -> Option<ItemId> {
let remote = model::snapshot(remote).ok()?;
engine::adopted_default_key(local, &remote)
}

async fn enable(
State(s): State<ControlState>,
Json(req): Json<tw_api::SyncEnable>,
Expand Down
10 changes: 3 additions & 7 deletions crates/tw-control/tests/sync_devices.rs
Original file line number Diff line number Diff line change
Expand Up @@ -355,8 +355,6 @@ async fn joining_with_a_configuration_merges_by_the_import_rules() {
"target": folder(loc.path()), "encrypt": false, "device_name": "B", "join": "merge",
"decisions": [
{ "item": { "kind": "provider", "name": "shared" }, "action": "rename", "new_name": "shared (A)" },
// 两边各自生成的默认密钥值不同:留本机的
{ "item": { "kind": "key", "name": "default" }, "action": "skip" },
],
}),
)
Expand All @@ -369,11 +367,9 @@ async fn joining_with_a_configuration_merges_by_the_import_rules() {
assert_eq!(names(&b), ["mine", "shared", "shared (A)"]);
a.ok("POST", "/sync/now", Value::Null).await;
assert_eq!(names(&a), ["mine", "shared", "shared (A)"], "{}", a.text());
assert_eq!(
a.config().clients[0].key,
"tw-default-b",
"选了留本机的那一把,A 跟着换"
);
// 两边各自生成的默认密钥值不同:不用选,B 采用同步数据的值,A 的不变
assert_eq!(b.config().clients[0].key, "tw-default-a");
assert_eq!(a.config().clients[0].key, "tw-default-a");
}

#[tokio::test]
Expand Down
Loading
Loading