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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions bin/twcore/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,9 @@ enum SyncCmd {
#[arg(long)]
purge: bool,
},
/// After the sync location was emptied or lost its keyring: upload this device's
/// configuration again (and put back the keyring this device keeps)
Republish,
}

#[derive(Subcommand)]
Expand Down Expand Up @@ -289,6 +292,7 @@ fn main() -> Result<()> {
SyncCmd::Now => sync_cmd::now(&path),
SyncCmd::Enable => sync_cmd::enable(&path),
SyncCmd::Disable { purge } => sync_cmd::disable(&path, purge),
SyncCmd::Republish => sync_cmd::republish(&path),
},
Command::Upgrade {
check,
Expand Down
22 changes: 21 additions & 1 deletion bin/twcore/src/sync_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,19 @@ fn print_view(v: &tw_api::SyncView) {
if !v.excluded.is_empty() {
println!("not synced: {}", v.excluded.join(", "));
}
if v.skipped > 0 {
println!(
"skipped: {} file(s) at the sync location could not be read",
v.skipped
);
}
match v.state {
tw_api::SyncState::Emptied | tw_api::SyncState::KeyringMissing => println!(
"to go on: `twcore sync republish` uploads this device's configuration again, \
`twcore sync disable` stops syncing"
),
_ => {}
}
for d in &v.devices {
let mut notes = Vec::new();
if d.this {
Expand Down Expand Up @@ -139,11 +152,18 @@ pub fn now(config: &Path) -> Result<()> {
Ok(())
}

/// 同步位置空了、keyring 不见了之后:以本机配置重新上传(keyring 放回本机记着的那一份)
pub fn republish(config: &Path) -> Result<()> {
let v: tw_api::SyncView = send(config, "POST", "/sync/republish", None)?;
print_view(&v);
Ok(())
}

pub fn disable(config: &Path, purge: bool) -> Result<()> {
if purge
&& !yes(
"Delete every version, device record and the keyring at the sync location? Other \
devices stop syncing",
devices are told the location is empty",
false,
)?
{
Expand Down
11 changes: 10 additions & 1 deletion crates/tw-api/msg-codes.txt
Original file line number Diff line number Diff line change
Expand Up @@ -555,20 +555,25 @@ sync.config.device_name
sync.config.folder_relative
sync.config.multiline
sync.config.passphrase_missing
sync.config.plain_http_proxy
sync.config.prefix
sync.config.unknown_proxy
sync.emptied
sync.folder.denied
sync.folder.io
sync.folder.missing
sync.folder.task
sync.http_client
sync.join_undecided
sync.keyring_corrupt
sync.keyring_missing
sync.keyring_rolled_back
sync.keyring_too_new
sync.local
sync.local.io
sync.location_empty
sync.location_taken
sync.locked_new
sync.looks_empty
sync.merge_invalid
sync.not_arrived
sync.not_encrypted
Expand All @@ -586,12 +591,16 @@ sync.store.bad_path
sync.store.denied
sync.store.exists
sync.store.server_error
sync.store.too_large
sync.store.unexpected
sync.store.unreachable
sync.take_not_allowed
sync.too_new
sync.too_new_unknown
sync.version_gone
sync.webdav.bad_listing
sync.webdav.bad_url
sync.webdav.root_missing
sync.write_section
t.auth test
t.boom test
Expand Down
4 changes: 4 additions & 0 deletions crates/tw-api/src/ep.rs
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,10 @@ endpoints! {
SyncUnlock: POST "/sync/unlock", api::SyncUnlock => api::SyncView;
/// 停止同步;`purge` 时连同步位置上的数据一起删掉
SyncDisable: POST "/sync/disable", api::SyncDisable => ();
/// 同步位置空了(`emptied`)、keyring 不见了(`keyring_missing`)之后由人选的「重新上传本机
/// 配置」:keyring 放回这台设备记着的那一份(没有就新建),没有读得出来的版本时以本机配置
/// 上传一个,然后同步一轮
SyncRepublish: POST "/sync/republish", () => api::SyncView;

// ─────────────────────────────────────────────── 账号登录
StartChatgptLogin: POST "/chatgpt/login", api::ChatgptLoginStart => api::ChatgptLogin;
Expand Down
5 changes: 4 additions & 1 deletion crates/tw-api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -990,7 +990,10 @@ pub const MSG_CODES: &str = include_str!("../msg-codes.txt");
/// `PUT /sync/settings`([`SyncSettingsSave`])、`POST /sync/now`、`GET` / `POST /sync/conflicts`
/// ([`SyncConflict`]、[`SyncResolve`]:采用本机、另一边或两者都保留)、`POST /sync/encryption`
/// ([`SyncEncryption`]:开启、关闭、更改口令)、`POST /sync/unlock`([`SyncUnlock`])、
/// `POST /sync/disable`([`SyncDisable`])。状态变了发 [`Event::SyncStatus`];应用别的设备的
/// `POST /sync/disable`([`SyncDisable`])、`POST /sync/republish`(同步位置空了、keyring 不见了
/// 之后由人选的「重新上传本机配置」,→ [`SyncView`])。[`SyncState`] 里要人处理的有
/// `keyring_missing`、`emptied`、`misconfigured`,[`SyncView`] 的 `skipped` 是同步位置上跳过了的
/// 文件数,[`SyncConflict`] 的 `options` 是能选的处理。状态变了发 [`Event::SyncStatus`];应用别的设备的
/// 版本时配置的来源是 `sync`,改了网关密钥时发 `GatewayKeysChanged`。[`ProviderView`] 多了
/// `sync_excluded`(不同步的上游)和 `sync_url_changed`(地址经同步变更,改之前的地址)。
/// 同步的码是 `sync.*`。安全模式下不自动同步(`paused`)
Expand Down
21 changes: 20 additions & 1 deletion crates/tw-api/src/sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,17 @@ slug_enum! {
TooNew = "too_new",
/// 安全模式:不自动同步
Paused = "paused",
/// 同步位置上的 keyring 不见了、被换成了更早的、一直读不出来。**不会自己关掉加密**:
/// 由人选「重新上传本机配置」(`POST /sync/republish`:放回这台设备记着的 keyring)或者
/// 停止同步。原因在 `error`
KeyringMissing = "keyring_missing",
/// 同步位置空了(连着几轮、过了一段时间都是空的):其他设备删除了同步数据,或者它已经
/// 不是原来那个位置。同步设置不动,由人选「重新上传本机配置」(`POST /sync/republish`)
/// 或者停止同步
Emptied = "emptied",
/// 同步设置用不了(比如出站代理被删了、不加密的 `http://` 地址设了代理):改设置。
/// 原因在 `error`
Misconfigured = "misconfigured",
}
}

Expand Down Expand Up @@ -122,6 +133,9 @@ pub struct SyncView {
pub excluded: Vec<String>,
/// 随之不同步的项目:引用了不同步的上游的路由、别名、分组
pub excluded_cascade: Vec<ItemRef>,
/// 同步位置上跳过了的文件:坏了的、不是这把密钥加密的、加密的位置上没加密的、等了
/// 30 分钟还没传完的。不是 0 时界面提一句
pub skipped: u32,
}

/// `POST /sync/test`、`POST /sync/enable/preview`:连上去看看。
Expand Down Expand Up @@ -256,9 +270,14 @@ pub struct SyncConflict {
pub remote: SyncConflictSide,
/// 两边不同的地方,逐个单位:点对象名时显示的差异
pub diff: Vec<SyncConflictDiff>,
/// 能不能「两者都保留」:两边都还有这个对象,而且它有名字
/// 能不能「两者都保留」:两边都还有这个对象,而且它有名字(和 `options` 里有没有 `both`
/// 是一回事)
pub both_allowed: bool,
pub reason: Option<SyncConflictReason>,
/// 这处冲突能选哪几种处理,界面只给这几个按钮:`local` 总在;`remote` 在合并结果过不了
/// 校验(`invalid`)时没有;`both` 同 `both_allowed`。网关密钥的「两者都保留」新增的那一把
/// 是一个新的值
pub options: Vec<SyncTake>,
}

slug_enum! {
Expand Down
78 changes: 77 additions & 1 deletion crates/tw-config/src/sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,12 @@ pub enum SyncError {
BlankExclude,
#[error("{}", self.msg())]
MultiLine { field: &'static str },
/// S3 前缀里有 `..`、`.` 这样的段,或者以 `/` 开头
#[error("{}", self.msg())]
Prefix { prefix: String },
/// `http://` 的内网地址经出站代理:同步位置的密码会明文交给代理
#[error("{}", self.msg())]
PlainHttpProxy { proxy: String },
}

impl SyncError {
Expand Down Expand Up @@ -278,6 +284,16 @@ impl SyncError {
"sync.config.multiline", field = field =>
"sync.{field} has to be on one line"
),
SyncError::Prefix { prefix } => msg!(
"sync.config.prefix", prefix = prefix =>
"{prefix} is not a folder inside the bucket: no leading /, and no . or .. parts"
),
SyncError::PlainHttpProxy { proxy } => msg!(
"sync.config.plain_http_proxy", proxy = proxy =>
"an http:// sync location on the local network cannot go through proxy \
`{proxy}`: its password would reach the proxy in the clear. Use direct, or an \
https:// address"
),
}
}
}
Expand Down Expand Up @@ -366,6 +382,11 @@ impl Target {
if prefix.contains(['\n', '\r']) {
return Err(SyncError::MultiLine { field: "prefix" });
}
if !prefix_ok(prefix) {
return Err(SyncError::Prefix {
prefix: prefix.clone(),
});
}
if !address_ok(endpoint) {
return Err(SyncError::Address {
field: "endpoint",
Expand All @@ -383,9 +404,36 @@ impl Target {
}
}

/// 代理名对得上吗:`direct`、`system`,或者 `proxies` 里有的那一个
/// S3 前缀:桶里的一个文件夹。不以 `/` 开头,没有 `.`、`..` 段和反斜杠(结尾的 `/` 无所谓)
fn prefix_ok(p: &str) -> bool {
let p = p.trim();
!p.starts_with('/')
&& !p.contains('\\')
&& p.trim_end_matches('/')
.split('/')
.filter(|s| !p.is_empty() || !s.is_empty())
.all(|seg| p.is_empty() || (!seg.is_empty() && seg != "." && seg != ".."))
}

/// 地址是不是 `http://`(只给内网的那种)
fn plain_http(t: &Target) -> bool {
let url = match t {
Target::Webdav { url, .. } => url,
Target::S3 { endpoint, .. } => endpoint,
Target::Folder { .. } => return false,
};
url.trim().to_ascii_lowercase().starts_with("http://")
}

/// 代理名对得上吗:`direct`、`system`,或者 `proxies` 里有的那一个。`http://` 的内网地址
/// 只能直连
pub fn check_proxy(target: &Target, proxies: &[crate::Proxy]) -> Result<(), SyncError> {
let p = target.proxy();
if p != DIRECT && plain_http(target) {
return Err(SyncError::PlainHttpProxy {
proxy: p.to_string(),
});
}
if p == DIRECT || p == crate::SYSTEM || proxies.iter().any(|x| x.name == p) {
return Ok(());
}
Expand Down Expand Up @@ -613,6 +661,34 @@ mod tests {
assert_ne!(generate_device_id(), generate_device_id());
}

#[test]
fn an_s3_prefix_stays_inside_the_bucket() {
for ok in ["", "me", "team/tw", "team/tw/"] {
assert!(prefix_ok(ok), "{ok:?}");
}
for bad in ["/abs", "a/../b", "..", "./x", "a//b", "a\\b"] {
assert!(!prefix_ok(bad), "{bad:?}");
}
}

#[test]
fn a_plain_http_location_never_goes_through_a_proxy() {
let mut t = webdav("http://192.168.1.2:5005/dav").target;
check_proxy(&t, &[]).unwrap();
if let Target::Webdav { proxy, .. } = &mut t {
*proxy = "system".into();
}
assert!(matches!(
check_proxy(&t, &[]),
Err(SyncError::PlainHttpProxy { .. })
));
let mut https = webdav("https://dav.example.com/").target;
if let Target::Webdav { proxy, .. } = &mut https {
*proxy = "system".into();
}
check_proxy(&https, &[]).unwrap();
}

#[test]
fn folder_paths_have_to_be_absolute_on_either_platform() {
for ok in [
Expand Down
12 changes: 6 additions & 6 deletions crates/tw-config/tests/manual/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -126,8 +126,8 @@ fn target_types() -> Vec<&'static str> {
}

const PROXY_DOC: T2 = t(
"Where requests to the sync location go out: `direct`, `system` (the system proxy), or the name of a proxy under `proxies`.",
"访问同步位置的请求从哪里出去:`direct`(直连)、`system`(跟随系统代理),或者 `proxies` 里一个代理的名字。",
"Where requests to the sync location go out: `direct`, `system` (the system proxy), or the name of a proxy under `proxies`. An `http://` address on this machine or the local network has to be `direct`.",
"访问同步位置的请求从哪里出去:`direct`(直连)、`system`(跟随系统代理),或者 `proxies` 里一个代理的名字。本机、内网的 `http://` 地址只能是 `direct`。",
);

const RULE_ID: T2 = t("built-in rule id", "内置规则 id");
Expand Down Expand Up @@ -1921,8 +1921,8 @@ pub fn sections() -> Vec<Section> {
Kind::Str,
Def::Unset,
t(
"The sync passphrase; required when `encrypt` is on. Kept here in the clear, like the other keys in this file.",
"同步口令,`encrypt` 开着时必填。和本文件里的其他密钥一样以明文保存。",
"The sync passphrase; required when `encrypt` is on, and kept after encryption is turned off: the keyring that says it is off is checked with it. Kept here in the clear, like the other keys in this file.",
"同步口令,`encrypt` 开着时必填;关闭加密之后也留着:同步位置上写着「已关闭」的 keyring 要用它核对。和本文件里的其他密钥一样以明文保存。",
),
),
row(
Expand Down Expand Up @@ -2066,8 +2066,8 @@ pub fn sections() -> Vec<Section> {
Kind::Str,
Def::Is("''"),
t(
"A folder inside the bucket.",
"存储桶里的一个前缀(文件夹)。",
"A folder inside the bucket. No `..` parts, and not starting with `/`.",
"存储桶里的一个前缀(文件夹)。不能有 `..`,不以 `/` 开头。",
),
),
row(
Expand Down
Loading
Loading