diff --git a/Cargo.lock b/Cargo.lock index 7f74297d..3557739c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3900,6 +3900,7 @@ dependencies = [ "tw-config", "tw-engine", "tw-pricing", + "tw-secret", "tw-types", "tw-yaml", "zip", diff --git a/bin/twcore/src/pack_cli.rs b/bin/twcore/src/pack_cli.rs index dcc4c79e..8287bcf8 100644 --- a/bin/twcore/src/pack_cli.rs +++ b/bin/twcore/src/pack_cli.rs @@ -169,6 +169,22 @@ fn describe(id: &ItemId) -> String { } } +/// 较旧版本写的、读不进来的字段在内存里修过的那几处 +fn print_repairs(fixes: &[tw_config::repair::Fix]) { + if fixes.is_empty() { + return; + } + println!("written by an older version; repaired before reading:"); + for f in fixes { + let what = match f.kind { + tw_config::repair::FixKind::UnknownValue => "back to the default", + tw_config::repair::FixKind::UnknownField => "removed", + }; + let line = f.line.map(|l| format!(" (line {l})")).unwrap_or_default(); + println!(" {}{line}: {what}", f.field); + } +} + // ─────────────────────────────────────────────────────────── export pub fn export( @@ -248,6 +264,8 @@ pub fn import( ) -> Result<()> { use tw_pack::import::{Action, Decision, Status}; let incoming = read_pack(file)?; + let (incoming, repairs) = tw_pack::import::prepare(incoming).map_err(|e| anyhow!("{e}"))?; + print_repairs(&repairs); let (cur, local) = current(path, PackOrigin::Import)?; let ctx = tw_pack::import::Context { has_aws_profile: &|p| tw_config::aws_profile::credentials(p).is_ok(), @@ -357,6 +375,8 @@ pub fn restore(path: &Path, id: &str, dry_run: bool) -> Result<()> { use tw_pack::restore::{Change, Kept}; let b = tw_pack::backup::find(path, id).map_err(|e| anyhow!("{e}"))?; let backup = tw_pack::backup::open(&b).map_err(|e| anyhow!("{e}"))?; + let (backup, repairs) = tw_pack::import::prepare(backup).map_err(|e| anyhow!("{e}"))?; + print_repairs(&repairs); let (cur, local) = current(path, PackOrigin::Restore)?; let pinned = tw_pack::disk::read_pinned(path); let after = if tw_pack::model::snapshot(&local).is_err() { diff --git a/crates/tw-api/msg-codes.txt b/crates/tw-api/msg-codes.txt index ee9b9edd..b86e8b00 100644 --- a/crates/tw-api/msg-codes.txt +++ b/crates/tw-api/msg-codes.txt @@ -141,8 +141,8 @@ control.chatgpt_login.expired control.chatgpt_login.no_code control.chatgpt_login.page_error control.client_unknown -control.config_not_repairable control.config_stale +control.config_unrepairable control.control_key_locked control.default_key_cannot_delete control.default_key_cannot_disable diff --git a/crates/tw-api/src/lib.rs b/crates/tw-api/src/lib.rs index b1924591..90f1cc86 100644 --- a/crates/tw-api/src/lib.rs +++ b/crates/tw-api/src/lib.rs @@ -972,8 +972,11 @@ pub const MSG_CODES: &str = include_str!("../msg-codes.txt"); /// [`Event::GatewayKeysChanged`]。[`ConfigOrigin`] 删了 `rollback`,多了 `sync`、`import`、 /// `restore`、`repair`(一键修复以前记成 `ui`)。备份相关的端点在安全模式下照样可用。配置包的 /// 码是 `pack.*`,导入的是 `import.*`,备份与恢复的是 `backup.*`。停用着的上游可以缺凭据 -/// (密钥、OAuth、AWS 访问密钥空着,ChatGPT 账号没登录):导入时就是这样进来的。照 49 写的 -/// 界面调不到删掉的那两个端点。 +/// (密钥、OAuth、AWS 访问密钥空着,ChatGPT 账号没登录):导入时就是这样进来的。较旧版本 +/// 导出的包、升级之前留的版本里读不进来的字段按一键修复的规矩在内存里修好,[`ImportPreview`] +/// 和 [`RestorePreview`] 的 `repairs` 列出修了哪几处。OAuth 上游换发来的令牌(刷新令牌会轮换) +/// 和 ChatGPT 的登录一样留在本机,不随导出带走、导入覆盖时留着本机的。一键修复修不了时的码换成 +/// `control.config_unrepairable`(句子改说从备份恢复)。照 49 写的界面调不到删掉的那两个端点。 pub const CONTROL_API_VERSION: u32 = 50; /// `/live` 的速度看最近多久(见 [`LiveView::tokens_per_sec`]),毫秒 diff --git a/crates/tw-api/src/pack.rs b/crates/tw-api/src/pack.rs index b5b2b242..689c940a 100644 --- a/crates/tw-api/src/pack.rs +++ b/crates/tw-api/src/pack.rs @@ -180,6 +180,9 @@ pub struct ImportPreview { #[serde(default, skip_serializing_if = "Option::is_none")] #[cfg_attr(feature = "ts", ts(type = "unknown"))] pub client_prefs: Option, + /// 较旧版本导出的包里读不进来的字段,按一键修复的规矩(不认识的字段删掉、取值不合法的 + /// 回到默认值)**在内存里修过了**:预览和导入都按修好的算,包本身不变。没修过是空的 + pub repairs: Vec, } slug_enum! { @@ -320,6 +323,9 @@ pub struct RestorePreview { pub rows: Vec, /// 开着同步时,恢复的结果会同步到几台别的设备。没开同步是 0 pub sync_devices: u32, + /// 那一版里这一版 Core 读不进来的字段(升级之前留的版本),按一键修复的规矩在内存里修过了。 + /// 没修过是空的 + pub repairs: Vec, } slug_enum! { diff --git a/crates/tw-control/src/backups.rs b/crates/tw-control/src/backups.rs index 286a91a9..3bf7b060 100644 --- a/crates/tw-control/src/backups.rs +++ b/crates/tw-control/src/backups.rs @@ -191,8 +191,12 @@ fn bad_pack(e: PackError) -> Fail { /// 配置(本机的、包里的)拆不开。**由更新的版本写的**说成「太新」 fn model_fail(e: model::ModelError, pack: Option<&Pack>) -> Fail { + let newer = |p: &Pack| { + matches!(e, model::ModelError::TooNew { .. }) + || (e.is_too_new() && p.manifest.from_newer_core()) + }; if let Some(p) = pack - && e.is_too_new() + && newer(p) { return bad_pack(PackError::TooNew { format: p.manifest.format, @@ -273,6 +277,20 @@ fn file_of(pack: &Pack, pass: Option<&str>) -> Result<(String, String), Fail> { )) } +fn fix_view(f: tw_config::repair::Fix) -> tw_api::ConfigFix { + use tw_config::repair::FixKind; + tw_api::ConfigFix { + kind: match f.kind { + FixKind::UnknownValue => tw_api::ConfigFixKind::UnknownValue, + FixKind::UnknownField => tw_api::ConfigFixKind::UnknownField, + }, + field: f.field, + line: f.line, + value: f.value, + now: f.now, + } +} + // ─────────────────────────────────────────────────────────── 此刻的样子 /// 磁盘上此刻的配置(原文和版本)连同它的插件、通用偏好 @@ -430,6 +448,7 @@ fn import_fail(e: tw_pack::import::ImportError, pack: &Pack) -> Fail { E::Exists(_) | E::NameTaken(..) => fail(StatusCode::CONFLICT, e.msg()), E::NotInPack(_) => fail(StatusCode::NOT_FOUND, e.msg()), E::Item(..) => fail(StatusCode::UNPROCESSABLE_ENTITY, e.msg()), + E::TooNew(p) => bad_pack(p), } } @@ -464,7 +483,9 @@ async fn import_preview( State(s): State, Json(req): Json, ) -> Result, Fail> { - let incoming = open_pack(&req.data_b64, req.passphrase.as_deref())?; + let raw = open_pack(&req.data_b64, req.passphrase.as_deref())?; + let (incoming, repairs) = + tw_pack::import::prepare(raw.clone()).map_err(|e| import_fail(e, &raw))?; let (cur, local) = current(&s, PackOrigin::Import)?; let rows = tw_pack::import::preview(&local, &incoming, &import_ctx()) .map_err(|e| import_fail(e, &incoming))?; @@ -473,6 +494,7 @@ async fn import_preview( base_version: cur.version(), rows: rows.into_iter().map(row_view).collect(), client_prefs: incoming.client_prefs.clone(), + repairs: repairs.into_iter().map(fix_view).collect(), })) } @@ -480,7 +502,9 @@ async fn import( State(s): State, Json(req): Json, ) -> Result, Fail> { - let incoming = open_pack(&req.data_b64, req.passphrase.as_deref())?; + let raw = open_pack(&req.data_b64, req.passphrase.as_deref())?; + // 预览时修过的那几处,这里照样修一遍(同一份字节,修出来一样) + let (incoming, _) = tw_pack::import::prepare(raw.clone()).map_err(|e| import_fail(e, &raw))?; let (cur, local) = current(&s, PackOrigin::Import)?; if cur.version() != req.base_version { return Err(apply_fail(ApplyError::Stale { @@ -663,7 +687,10 @@ async fn restore_preview( State(s): State, Json(req): Json, ) -> Result, Fail> { - let backup = backup_pack(&s, &req.id).await?; + let raw = backup_pack(&s, &req.id).await?; + // 升级之前留的版本可能有这一版不认识的写法:和导入一样在内存里修好 + let (backup, repairs) = + tw_pack::import::prepare(raw.clone()).map_err(|e| import_fail(e, &raw))?; let (cur, local) = current(&s, PackOrigin::Restore)?; let sync_devices = match s.cfg.remote_backups() { Some(r) => r.other_devices().await, @@ -693,6 +720,7 @@ async fn restore_preview( base_version: cur.version(), rows, sync_devices, + repairs: repairs.into_iter().map(fix_view).collect(), })) } @@ -700,7 +728,8 @@ async fn restore( State(s): State, Json(req): Json, ) -> Result, Fail> { - let backup = backup_pack(&s, &req.id).await?; + let raw = backup_pack(&s, &req.id).await?; + let (backup, _) = tw_pack::import::prepare(raw.clone()).map_err(|e| import_fail(e, &raw))?; let (cur, local) = current(&s, PackOrigin::Restore)?; if cur.version() != req.base_version { return Err(apply_fail(ApplyError::Stale { diff --git a/crates/tw-control/src/lib.rs b/crates/tw-control/src/lib.rs index f6720f71..a5c3eaec 100644 --- a/crates/tw-control/src/lib.rs +++ b/crates/tw-control/src/lib.rs @@ -1549,9 +1549,9 @@ async fn repair_config( return Err(fail( StatusCode::BAD_REQUEST, msg!( - "control.config_not_repairable" => + "control.config_unrepairable" => "The configuration has no error that can be repaired automatically. Edit the \ - configuration file, or roll back to an earlier version" + configuration file, or restore an earlier version from the backups" ), )); }; diff --git a/crates/tw-control/tests/backups.rs b/crates/tw-control/tests/backups.rs index dfab9e05..69b8e6d3 100644 --- a/crates/tw-control/tests/backups.rs +++ b/crates/tw-control/tests/backups.rs @@ -564,3 +564,64 @@ async fn in_safe_mode_a_backup_comes_back_whole() { let text = std::fs::read_to_string(&p).unwrap(); assert_eq!(text, mine()); } + +/// 较旧版本导出的包:读不进来的字段在内存里修好,预览说修了哪几处;更新的版本写的说太新 +#[tokio::test] +async fn an_older_pack_is_previewed_repaired_and_a_newer_one_is_too_new() { + let dst = bed(&mine()); + let pack_b64 = |core: &str| { + let mut m = tw_pack::Manifest::new(tw_pack::PackKind::Full, tw_pack::PackOrigin::Export); + m.core_version = core.to_string(); + let p = tw_pack::Pack { + manifest: m, + config_yaml: "version: 1\nproviders:\n - name: old\n base_url: https://old.example\n retired_field: 1\nclient_probes:\n titling: passthrough\n".into(), + plugins: Default::default(), + client_prefs: None, + }; + base64::engine::general_purpose::STANDARD + .encode(tw_pack::write(&p, tw_pack::Seal::None).unwrap()) + }; + let old = pack_b64("0.1.0"); + let (st, preview) = call( + &dst.app, + "POST", + "/config/import/preview", + serde_json::json!({ "data_b64": old }), + ) + .await; + assert_eq!(st, StatusCode::OK, "{preview}"); + let fields: Vec<&str> = preview["repairs"] + .as_array() + .unwrap() + .iter() + .map(|f| f["field"].as_str().unwrap()) + .collect(); + assert_eq!( + fields, + ["providers[0].retired_field", "client_probes.titling"] + ); + assert_eq!(preview["repairs"][1]["kind"], "unknown_value"); + let (st, v) = call( + &dst.app, + "POST", + "/config/import", + serde_json::json!({ + "data_b64": old, + "base_version": preview["base_version"], + "decisions": [{ "item": { "kind": "provider", "name": "old" }, "action": "import" }], + }), + ) + .await; + assert_eq!(st, StatusCode::OK, "{v}"); + assert!(dst.file().contains("https://old.example")); + assert!(!dst.file().contains("retired_field")); + let (st, v) = call( + &dst.app, + "POST", + "/config/import/preview", + serde_json::json!({ "data_b64": pack_b64("999.0.0") }), + ) + .await; + assert_eq!(st, StatusCode::BAD_REQUEST, "{v}"); + assert_eq!(v["code"], "pack.too_new"); +} diff --git a/crates/tw-control/tests/safe_mode.rs b/crates/tw-control/tests/safe_mode.rs index 6c4f009c..fb2fd5af 100644 --- a/crates/tw-control/tests/safe_mode.rs +++ b/crates/tw-control/tests/safe_mode.rs @@ -165,7 +165,7 @@ async fn an_error_that_is_not_fixable_offers_nothing() { ) .await; assert_eq!(st, StatusCode::BAD_REQUEST, "{e}"); - assert_eq!(e["code"], "control.config_not_repairable", "{e}"); + assert_eq!(e["code"], "control.config_unrepairable", "{e}"); } #[tokio::test] diff --git a/crates/tw-pack/Cargo.toml b/crates/tw-pack/Cargo.toml index d842fd96..ba3b604b 100644 --- a/crates/tw-pack/Cargo.toml +++ b/crates/tw-pack/Cargo.toml @@ -18,6 +18,8 @@ tw-engine = { workspace = true } tw-pricing = { workspace = true } tw-types = { workspace = true } tw-yaml = { workspace = true } +# 修导入的旧配置包时,原值给人看之前脱敏 +tw-secret = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } serde_yaml_ng = { workspace = true } diff --git a/crates/tw-pack/src/export.rs b/crates/tw-pack/src/export.rs index 17d50c47..30d72f81 100644 --- a/crates/tw-pack/src/export.rs +++ b/crates/tw-pack/src/export.rs @@ -29,7 +29,7 @@ pub struct Exported { pub pack: Pack, /// 包里有哪些项目(随附的也在),顺序项目不算 pub included: Vec, - /// 登录没有随包带走的上游(ChatGPT 账号) + /// 登录没有随包带走的上游:ChatGPT 账号,和令牌留在本机的 OAuth 上游 pub omitted_logins: Vec, } @@ -81,7 +81,7 @@ pub fn export_with( let omitted_logins = cfg .providers .iter() - .filter(|p| picked.contains(p.name.as_str()) && model::is_chatgpt(p)) + .filter(|p| picked.contains(p.name.as_str()) && model::holds_login(p)) .map(|p| p.name.clone()) .collect(); let kind = if opts.scope.is_some() { diff --git a/crates/tw-pack/src/import.rs b/crates/tw-pack/src/import.rs index 646f8280..4621a094 100644 --- a/crates/tw-pack/src/import.rs +++ b/crates/tw-pack/src/import.rs @@ -103,6 +103,36 @@ pub enum ImportError { /// 导进来之后配置过不了校验 #[error("{}", self.msg())] Rejected(Box), + /// 由更新的版本写的 + #[error("{}", self.msg())] + TooNew(crate::PackError), +} + +/// 导入之前:包里的配置读得进来吗。由更新的版本写的是「太新」;较旧版本写的、有读不进来的 +/// 字段的,按一键修复的规矩在内存里修好([`crate::repair`]),连同修了哪几处一起交回去 +pub fn prepare(incoming: Pack) -> Result<(Pack, Vec), ImportError> { + let e = match model::snapshot(&incoming) { + Ok(_) => return Ok((incoming, Vec::new())), + Err(e) => e, + }; + let newer = matches!(e, ModelError::TooNew { .. }) + || (e.is_too_new() && incoming.manifest.from_newer_core()); + if newer { + return Err(ImportError::TooNew(crate::PackError::TooNew { + format: incoming.manifest.format, + core_version: incoming.manifest.core_version.clone(), + })); + } + match crate::repair::repair(&incoming.config_yaml) { + Some((text, fixes)) => Ok(( + Pack { + config_yaml: text, + ..incoming + }, + fixes, + )), + None => Err(ImportError::Model(e)), + } } impl From for ImportError { @@ -130,6 +160,7 @@ impl ImportError { "The configuration pack has no {kind} named `{name}`." ), ImportError::Rejected(r) => r.msg(), + ImportError::TooNew(e) => e.msg(), } } } @@ -249,6 +280,15 @@ fn flags(id: &ItemId, item: &Item, incoming: &Pack, l: &Snapshot) -> Vec { if !here { out.push(Flag::NeedsLogin); } + } else if id.kind == ItemKind::Provider + && let Some(endpoint) = oauth_endpoint(item) + { + // OAuth 的令牌不随包带(会轮换,见 `model`):本机同名的那一家用着同一个令牌端点时, + // 覆盖留着本机的令牌;否则要填上刷新令牌 + let here = l.items.get(id).and_then(oauth_endpoint) == Some(endpoint); + if !here && !out.contains(&Flag::MissingKey) { + out.push(Flag::MissingKey); + } } if id.kind == ItemKind::Plugin { out.push(Flag::PluginDisabled); @@ -256,6 +296,16 @@ fn flags(id: &ItemId, item: &Item, incoming: &Pack, l: &Snapshot) -> Vec { out } +/// 一个上游项目的 OAuth 令牌端点。不是 OAuth 的是 None +fn oauth_endpoint(item: &Item) -> Option { + item.units + .get(unit::CONN)? + .get("oauth")? + .get("endpoint")? + .as_str() + .map(str::to_string) +} + fn provider_is_chatgpt(id: &ItemId, item: &Item) -> bool { model::provider_of(id, item).is_ok_and(|p| model::is_chatgpt(&p)) } @@ -273,9 +323,10 @@ fn problem( ctx: &Context, codec: &dyn PluginCodec, ) -> Option { - // 引用了本机和包里都没有的 + // 引用了本机和包里都没有的。**插件不算**:它范围里写的上游这里没有,只是管不着什么, + // 不是导入不了 for r in &both.items[id].refs { - if !both.items.contains_key(r) && !builtin(r) { + if id.kind != ItemKind::Plugin && !both.items.contains_key(r) && !builtin(r) { return Some(missing_ref(id, r)); } } @@ -540,10 +591,11 @@ pub fn apply_with( } target.relink(); - // 导进来的项目引用的东西都得在:被跳过的那些不在 + // 导进来的项目引用的东西都得在:被跳过的那些不在。插件只看包里有、却被跳过的那些 for id in &taken { for r in &target.items[id].refs { - if !target.items.contains_key(r) && !builtin(r) { + let soft = id.kind == ItemKind::Plugin && !i.items.contains_key(r); + if !soft && !target.items.contains_key(r) && !builtin(r) { return Err(ImportError::Item(id.clone(), Box::new(skipped_ref(id, r)))); } } diff --git a/crates/tw-pack/src/import/tests.rs b/crates/tw-pack/src/import/tests.rs index f66a9396..242a2b5e 100644 --- a/crates/tw-pack/src/import/tests.rs +++ b/crates/tw-pack/src/import/tests.rs @@ -522,3 +522,168 @@ fn client_preferences_import_only_when_core_keeps_them() { .unwrap(); assert_eq!(applied.pack.client_prefs, Some(json!({"language": "en"}))); } + +/// 第一行是 `// data: ` 的插件源码:数据单独拆出来(同 `model` 的测试) +struct DataLine; + +impl PluginCodec for DataLine { + fn split(&self, source: &str) -> Option<(String, serde_json::Value)> { + let (first, rest) = source.split_once('\n')?; + let data = serde_json::from_str(first.strip_prefix("// data: ")?).ok()?; + Some((format!("// data: null\n{rest}"), data)) + } + fn join(&self, source: &str, data: &serde_json::Value) -> Option { + let (_, rest) = source.split_once('\n')?; + Some(format!("// data: {data}\n{rest}")) + } + fn shipped(&self, _: &str) -> Option { + None + } + fn needs_approval(&self, _: &str) -> bool { + false + } +} + +/// OAuth 上游的令牌不随包带:新来的停用着、标成缺密钥;覆盖本机同一个令牌端点的留着本机的令牌 +#[test] +fn an_oauth_upstream_arrives_without_its_tokens() { + let oauth = " - name: corp\n base_url: https://corp.example\n oauth:\n refresh: rt-theirs\n endpoint: https://auth.example/token\n"; + let incoming = pack_of( + &format!( + "version: 1\nproviders:\n{oauth} - name: corp2\n base_url: https://corp2.example\n oauth:\n refresh: rt-2\n endpoint: https://auth.example/token\n max_concurrent: 2\n" + ), + true, + ); + let local = pack_of( + &local_text("").replace( + "providers:\n", + "providers:\n - name: corp2\n base_url: https://corp2.example\n oauth:\n refresh: rt-mine\n endpoint: https://auth.example/token\n", + ), + true, + ); + let r = rows(&local, &incoming); + assert_eq!(r["provider `corp`"].flags, [Flag::MissingKey]); + assert_eq!(r["provider `corp2`"].flags, Vec::::new()); + assert_eq!(r["provider `corp2`"].status, Status::Conflict); + let applied = apply_with( + &local, + &incoming, + &[ + Decision { + item: ItemId::new(ItemKind::Provider, "corp"), + action: Action::Import, + }, + Decision { + item: ItemId::new(ItemKind::Provider, "corp2"), + action: Action::Overwrite, + }, + ], + &ctx(), + &WholeFile, + ) + .unwrap(); + let cfg = tw_config::try_parse(&applied.pack.config_yaml).unwrap(); + let p = |n: &str| cfg.providers.iter().find(|p| p.name == n).unwrap(); + assert!(p("corp").disabled); + assert_eq!(p("corp").oauth.as_ref().unwrap().refresh, ""); + assert!(!applied.pack.config_yaml.contains("rt-theirs")); + assert_eq!(p("corp2").oauth.as_ref().unwrap().refresh, "rt-mine"); + assert_eq!(p("corp2").max_concurrent, Some(2)); + assert!(!p("corp2").disabled); +} + +/// 插件范围里写明的上游:跳过它,插件就导入不了;本机和包里都没有的不碍事 +#[test] +fn skipping_an_upstream_a_plugin_names_refuses_the_plugin() { + let src = "// data: {\"match\":{\"upstreams\":[\"relay\",\"nowhere\"]}}\nscoped\n"; + let sha = model::sha256_hex(src.as_bytes()); + let mut incoming = pack_of( + &format!( + "version: 1\nproviders:\n - name: relay\n base_url: https://relay.example\nplugins:\n - id: scoped\n file: plugins/scoped.js\n sha256: {sha}\n" + ), + true, + ); + incoming.plugins.insert( + "scoped".into(), + PluginFiles { + script: src.as_bytes().to_vec(), + approved: Some(src.as_bytes().to_vec()), + }, + ); + let local = pack_of(&local_text(""), true); + let r: BTreeMap = preview_with(&local, &incoming, &ctx(), &DataLine) + .unwrap() + .into_iter() + .map(|r| (r.item.to_string(), r)) + .collect(); + let plugin = &r["plugin `scoped`"]; + assert_eq!(plugin.status, Status::New, "{plugin:?}"); + assert_eq!( + plugin + .refs + .iter() + .map(|r| r.to_string()) + .collect::>(), + ["provider `relay`"] + ); + let d = |kind, name: &str, action| Decision { + item: ItemId::new(kind, name), + action, + }; + let e = apply_with( + &local, + &incoming, + &[ + d(ItemKind::Plugin, "scoped", Action::Import), + d(ItemKind::Provider, "relay", Action::Skip), + ], + &ctx(), + &DataLine, + ) + .unwrap_err(); + assert_eq!(e.msg().code, "import.skipped_reference"); + // 一起导入就成;改名导入的上游,插件的范围跟着改 + let applied = apply_with( + &local, + &incoming, + &[ + d(ItemKind::Plugin, "scoped", Action::Import), + d( + ItemKind::Provider, + "relay", + Action::Rename("relay-cn".into()), + ), + ], + &ctx(), + &DataLine, + ) + .unwrap(); + let script = String::from_utf8(applied.pack.plugins["scoped"].script.clone()).unwrap(); + assert!(script.contains("relay-cn"), "{script}"); +} + +/// 较旧版本导出的、有这一版不认识的写法的包:修好再看;更新的版本写的说太新 +#[test] +fn an_older_pack_is_repaired_and_a_newer_one_is_too_new() { + let text = + "version: 1\nproviders:\n - name: a\n base_url: https://a.example\n retired: 1\n"; + let mut old = pack_of(text, true); + old.manifest.core_version = "0.1.0".into(); + let (fixed, fixes) = prepare(old.clone()).unwrap(); + assert_eq!(fixes.len(), 1); + assert_eq!(fixes[0].field, "providers[0].retired"); + assert!(!fixed.config_yaml.contains("retired")); + let r = rows(&pack_of(&local_text(""), true), &fixed); + assert_eq!(r["provider `a`"].status, Status::New); + // 读得进来的不动 + let (same, none) = prepare(pack_of("version: 1\n", true)).unwrap(); + assert!(none.is_empty()); + assert_eq!(same.config_yaml, "version: 1\n"); + // 更新的 Core 写的 + let mut new = old.clone(); + new.manifest.core_version = "999.0.0".into(); + assert_eq!(prepare(new).unwrap_err().msg().code, "pack.too_new"); + // 修不了的照旧是读不进来的那句话 + let broken = pack_of("version: 1\nproviders: [\n", true); + assert!(prepare(broken).is_err()); +} diff --git a/crates/tw-pack/src/lib.rs b/crates/tw-pack/src/lib.rs index 789b1400..3ede6064 100644 --- a/crates/tw-pack/src/lib.rs +++ b/crates/tw-pack/src/lib.rs @@ -38,6 +38,7 @@ pub mod disk; pub mod export; pub mod import; pub mod model; +pub mod repair; pub mod restore; /// 配置包格式的版本。**读到更大的就拒绝**([`PackError::TooNew`]):新版本的格式旧版本 diff --git a/crates/tw-pack/src/model/items.rs b/crates/tw-pack/src/model/items.rs index 3f200741..8fcb73ad 100644 --- a/crates/tw-pack/src/model/items.rs +++ b/crates/tw-pack/src/model/items.rs @@ -206,7 +206,19 @@ fn provider_unit(field: &str) -> &'static str { pub(crate) const PROVIDER_UNITS: &[&str] = &[unit::CONN, unit::MODELS, unit::BILLING, unit::OTHER]; -/// 去掉这台设备自己的部分:ChatGPT 的登录和账户 ID 请求头 +/// OAuth 里属于这台设备的那几项:换发来的令牌。**刷新令牌会轮换**:很多 OAuth 服务器每刷新 +/// 一次就换发一个新的、作废旧的(`tw_gateway::oauth` 收到新的就写回配置),同一个刷新令牌 +/// 在两台设备上用,先刷新的那台让另一台的失效。所以它和 ChatGPT 的登录一样留在本机, +/// 端点、客户端 ID 和密钥照常比较、带走 +pub(crate) const OAUTH_TOKENS: &[&str] = &["refresh", "access", "expires_at"]; + +/// 这一家的凭据里有属于这台设备的登录:ChatGPT 账号,或者 OAuth 的令牌 +pub fn holds_login(p: &Provider) -> bool { + is_chatgpt(p) || p.oauth.is_some() +} + +/// 去掉这台设备自己的部分:ChatGPT 的登录和账户 ID 请求头(OAuth 的令牌在 +/// [`provider_units`] 里去掉:`refresh` 是必填的,结构体里去不掉) pub(crate) fn shareable(p: &Provider) -> Provider { let mut p = p.clone(); if is_chatgpt(&p) { @@ -228,6 +240,11 @@ fn provider_units(p: &Provider) -> Result, String> { return Err("it does not serialize to an object".into()); }; obj.remove("name"); + if let Some(Json::Object(o)) = obj.get_mut("oauth") { + for k in OAUTH_TOKENS { + o.remove(*k); + } + } // **请求头按写的顺序**:JSON 对象的键是排过序的,所以写成一串 [名字, 值] if obj.contains_key("headers") { obj.insert( @@ -262,7 +279,8 @@ fn provider_units(p: &Provider) -> Result, String> { } /// 上游的单位拼回配置里的那一项。`local` 是本机同名的那一家:它是 ChatGPT 账号、拼出来 -/// 的也是的话,登录和账户 ID 请求头取它的 +/// 的也是的话,登录和账户 ID 请求头取它的;两边都是 OAuth、令牌端点一样的话,令牌取它的。 +/// 本机没有令牌可取的 OAuth 上游,刷新令牌是空的(停用着才过得了校验,见导入、恢复) pub(crate) fn provider_mapping( id: &ItemId, units: &BTreeMap, @@ -283,7 +301,21 @@ pub(crate) fn provider_mapping( m.insert(key(k), value); } } + if let Some(Yaml::Mapping(o)) = m.get_mut("oauth") + && !o.contains_key("refresh") + { + o.insert(key("refresh"), Yaml::String(String::new())); + } let (mut p, _) = typed_mapping::(Yaml::Mapping(m)).map_err(|e| shape(id, e))?; + let chatgpt = is_chatgpt(&p); + if let (Some(mine), Some(theirs)) = (local.and_then(|l| l.oauth.as_ref()), p.oauth.as_mut()) + && !chatgpt + && mine.endpoint == theirs.endpoint + { + theirs.refresh = mine.refresh.clone(); + theirs.access = mine.access.clone(); + theirs.expires_at = mine.expires_at.clone(); + } if let Some(local) = local && is_chatgpt(&p) && is_chatgpt(local) @@ -713,6 +745,22 @@ fn refs_of( } } } + ItemKind::Plugin => { + // 范围里写明的上游(`match.upstreams`)。**通配的不算**:`relay-*` 说的是一批名字, + // 不是哪一个上游 + let listed = units + .get(unit::SETTINGS) + .and_then(|d| d.get("match")) + .and_then(|m| m.get("upstreams")) + .and_then(Json::as_array) + .cloned() + .unwrap_or_default(); + for n in listed.iter().filter_map(Json::as_str).map(str::trim) { + if !n.is_empty() && !n.contains(['*', '?', '[']) { + out.insert(ItemId::new(ItemKind::Provider, n)); + } + } + } ItemKind::Global => { if let Some(r) = s(units.get(global::DEFAULT_ROUTE)) { out.insert(ItemId::new(ItemKind::Route, r)); diff --git a/crates/tw-pack/src/model/mod.rs b/crates/tw-pack/src/model/mod.rs index 28974ee1..1691795e 100644 --- a/crates/tw-pack/src/model/mod.rs +++ b/crates/tw-pack/src/model/mod.rs @@ -26,8 +26,9 @@ //! # 本机设置 //! //! 不进快照,[`materialize`] 时保留本机的值:`listen`、`retention`、`sync` 三节,ChatGPT -//! 上游的登录(`oauth`)和登录所得的 `ChatGPT-Account-Id` 请求头,Core 自带的默认插件的 -//! 脚本(它的设置和开关照常比较)。 +//! 上游的登录(`oauth`)和登录所得的 `ChatGPT-Account-Id` 请求头,别的 OAuth 上游换发来的 +//! 令牌(`oauth.refresh` / `access` / `expires_at`:刷新令牌会轮换,两台设备共用一个会互相 +//! 作废),Core 自带的默认插件的脚本(它的设置和开关照常比较)。 use std::collections::{BTreeMap, BTreeSet}; use std::sync::OnceLock; @@ -45,7 +46,7 @@ mod rename; mod write; pub use codec::{PluginCodec, WholeFile}; -pub use items::{is_chatgpt, sha256_hex}; +pub use items::{holds_login, is_chatgpt, sha256_hex}; pub use rename::{free_name, rename}; pub(crate) use write::materialize_for_export; pub use write::{materialize, materialize_with}; diff --git a/crates/tw-pack/src/model/rename.rs b/crates/tw-pack/src/model/rename.rs index 3a84ec3e..5e84e1cb 100644 --- a/crates/tw-pack/src/model/rename.rs +++ b/crates/tw-pack/src/model/rename.rs @@ -79,6 +79,18 @@ pub fn rename(snap: &mut Snapshot, id: &ItemId, new_name: &str) { } } } + (ItemKind::Provider, ItemKind::Plugin) => { + if let Some(Json::Array(list)) = item + .units + .get_mut(unit::SETTINGS) + .and_then(|d| d.get_mut("match")) + .and_then(|m| m.get_mut("upstreams")) + { + for n in list { + swap(Some(n), old, new); + } + } + } (ItemKind::Proxy, ItemKind::Provider) => { swap( item.units diff --git a/crates/tw-pack/src/model/tests.rs b/crates/tw-pack/src/model/tests.rs index 3bbedfde..79c0af6c 100644 --- a/crates/tw-pack/src/model/tests.rs +++ b/crates/tw-pack/src/model/tests.rs @@ -1018,3 +1018,102 @@ fn retain_trims_the_orders_too() { json!(["relay"]) ); } + +// ─────────────────────────────────────────────────────────── OAuth 的令牌、插件的范围 + +const OAUTH: &str = " oauth:\n refresh: rt-mine\n access: at-mine\n expires_at: '2026-10-19T00:00:00Z'\n endpoint: https://auth.example/token\n client_id: cid\n"; + +fn with_oauth(endpoint: &str) -> String { + format!( + "version: 1\nlisten:\n control:\n key: {KEY}\nclients:\n - name: default\n key: tw-a\nproviders:\n - name: corp\n base_url: https://corp.example\n{}", + OAUTH.replace("https://auth.example/token", endpoint) + ) +} + +/// 刷新令牌会轮换:令牌留在本机,端点和客户端 ID 照常比较 +#[test] +fn oauth_tokens_stay_on_this_device() { + let p = pack(&with_oauth("https://auth.example/token")); + let s = snap(&p); + let conn = &s.items[&id(ItemKind::Provider, "corp")].units["conn"]; + assert_eq!( + conn["oauth"], + json!({"endpoint": "https://auth.example/token", "client_id": "cid"}) + ); + // 轮换不算改动 + let rotated = pack(&with_oauth("https://auth.example/token").replace("rt-mine", "rt-rotated")); + assert_eq!(snap(&rotated), s); + assert_eq!(rotated.content_id(), p.content_id()); + // 别处来的(同一个端点)改了别的:令牌留着本机的 + let mut t = s.clone(); + t.items + .get_mut(&id(ItemKind::Provider, "corp")) + .unwrap() + .units + .insert("other".into(), json!({"max_concurrent": 2})); + let out = mat(&p, &t); + assert!( + out.config_yaml.contains("refresh: rt-mine"), + "{}", + out.config_yaml + ); + assert!( + out.config_yaml.contains("access: at-mine"), + "{}", + out.config_yaml + ); + // 换了令牌端点:本机的令牌不是那个服务器发的,不留 + let mut t = snap(&pack(&with_oauth("https://other.example/token"))); + t.items + .get_mut(&id(ItemKind::Provider, "corp")) + .unwrap() + .units + .insert("other".into(), json!({"disabled": true})); + let out = mat(&p, &t); + let cfg = tw_config::try_parse(&out.config_yaml).unwrap(); + let o = cfg.providers[0].oauth.as_ref().unwrap(); + assert_eq!(o.endpoint, "https://other.example/token"); + assert_eq!(o.refresh, ""); + // 落到一份空配置上(导出):令牌不在 + let out = mat(&pack("version: 1\n"), &s); + assert!(!out.config_yaml.contains("rt-mine"), "{}", out.config_yaml); +} + +fn with_scoped_plugin(upstreams: &str) -> Pack { + let src = format!("// data: {{\"match\":{{\"upstreams\":{upstreams}}}}}\nscoped code\n"); + let base = with_plugins(&[("scoped", &src, true)]); + Pack { + config_yaml: base.config_yaml.replace( + "plugins:\n", + "providers:\n - name: relay\n base_url: https://relay.example\n - name: other\n base_url: https://other.example\nplugins:\n", + ), + ..base + } +} + +/// 插件范围里写明的上游是它的引用:导出带上它,改名跟着改;通配的不算 +#[test] +fn a_plugin_refers_to_the_upstreams_its_scope_names() { + let p = with_scoped_plugin(r#"["relay", "rel*", " "]"#); + let s = snap(&p); + let refs: Vec = s.items[&id(ItemKind::Plugin, "scoped")] + .refs + .iter() + .map(|r| r.to_string()) + .collect(); + assert_eq!(refs, ["provider `relay`"]); + let picked = s.closure(&[id(ItemKind::Plugin, "scoped")]); + assert!(picked.contains(&id(ItemKind::Provider, "relay"))); + assert!(!picked.contains(&id(ItemKind::Provider, "other"))); + let mut s2 = s.clone(); + rename(&mut s2, &id(ItemKind::Provider, "relay"), "relay (2)"); + let settings = &s2.items[&id(ItemKind::Plugin, "scoped")].units["settings"]; + assert_eq!( + settings["match"]["upstreams"], + json!(["relay (2)", "rel*", " "]) + ); + // 改了名的落下去,插件文件里的范围跟着改 + let out = mat(&p, &s2); + let script = String::from_utf8(out.plugins["scoped"].script.clone()).unwrap(); + assert!(script.contains("relay (2)"), "{script}"); +} diff --git a/crates/tw-pack/src/repair.rs b/crates/tw-pack/src/repair.rs new file mode 100644 index 00000000..cdfad38b --- /dev/null +++ b/crates/tw-pack/src/repair.rs @@ -0,0 +1,204 @@ +//! 较旧版本导出的配置包:读不进来的字段按「一键修复」的规矩在内存里修好再导入。 +//! +//! 规矩和 `tw_config::repair` 一样,**只修两种、都只是删掉一个键**:取值不在可选范围里的 +//! (删掉就回到默认值)、不认识的字段(删掉)。不同的是这里只要求「读得进来」,不要求 +//! 「是一份完整、能用的配置」:一个配置包可以只有一个上游,没有网关密钥、没有控制面的钥匙, +//! 拿整份校验去要求它,什么都修不了。修完之后能不能用由导入那一步照常校验。 +//! +//! **更新的版本写的不修**:那时不认识的字段是旧版本不懂的新东西,删掉就丢了,要说的是 +//! 「请先更新」([`crate::Manifest::from_newer_core`])。 + +use tw_config::repair::{Fix, FixKind}; +use tw_yaml::Step; + +use crate::model::{self, ModelError}; + +/// 一份配置最多修几处。删一处冒一处的修不完 +const MAX_FIXES: usize = 20; + +/// 修好的原文和修了哪几处。读得进来(不用修)、或者修不了,都是 `None` +pub fn repair(text: &str) -> Option<(String, Vec)> { + let mut out = text.to_string(); + let mut fixes: Vec = Vec::new(); + for _ in 0..=MAX_FIXES { + let r = match model::parse(&out) { + Ok(cfg) => { + if fixes.is_empty() { + return None; + } + for f in fixes.iter_mut() { + if f.kind == FixKind::UnknownValue { + f.now = default_at(&cfg, &f.field); + } + } + return Some((out, fixes)); + } + Err(ModelError::Config(r)) => r, + Err(_) => return None, + }; + let kind = match r.message.code.as_str() { + "config.unknown_variant" => FixKind::UnknownValue, + "config.unknown_field" => FixKind::UnknownField, + _ => return None, + }; + let field = r.message.args.get("field")?.clone(); + let path = steps(&field)?; + // 行号和原值按**修之前的原文**找:前面删过行,`r.line` 已经对不上了 + let before = tw_yaml::find(text, &path).ok(); + let line = before + .as_ref() + .map(|f| text[..f.bytes.start].matches('\n').count() + 1) + .or(r.line); + let value = before.map(|f| masked(&path, &f.value)); + out = tw_yaml::remove_key(&out, &path).ok()?; + if fixes.len() == MAX_FIXES { + return None; + } + fixes.push(Fix { + kind, + field, + line, + value, + now: None, + }); + } + None +} + +/// 原值给人看之前脱敏:拼错名字的那个字段完全可能就是一把密钥。连着键名一起判断 +fn masked(path: &[Step], v: &str) -> String { + let name = match path.last() { + Some(Step::Key(k)) => k.as_str(), + _ => "value", + }; + let line = tw_secret::mask_line(&mask_hex_runs(&format!("{name}: {v}"))); + match line.split_once(": ") { + Some((_, v)) => v.to_string(), + None => line, + } +} + +/// 正好 64 个十六进制字符的一段(控制面的钥匙那样的)换成打码 +fn mask_hex_runs(s: &str) -> String { + let b = s.as_bytes(); + let (mut out, mut i, mut last) = (String::new(), 0, 0); + while i < b.len() { + if !b[i].is_ascii_hexdigit() { + i += 1; + continue; + } + let start = i; + while i < b.len() && b[i].is_ascii_hexdigit() { + i += 1; + } + if i - start == tw_api::control::KEY_HEX_LEN { + out.push_str(&s[last..start]); + out.push_str(tw_api::control::KEY_MASK); + last = i; + } + } + out.push_str(&s[last..]); + out +} + +/// 修完之后这个字段的值(也就是默认值),写成一个标量。写不成的是 None +fn default_at(cfg: &tw_config::Config, field: &str) -> Option { + use serde_yaml_ng::to_value; + let path = steps(field)?; + let (first, rest) = path.split_first()?; + let Step::Key(name) = first else { return None }; + // **一节单独序列化**:整份序列化时还是默认值的那几节整节不写,改回默认值的恰恰是它们 + let section = match name.as_str() { + "clients" => to_value(&cfg.clients), + "providers" => to_value(&cfg.providers), + "proxies" => to_value(&cfg.proxies), + "pricing" => to_value(&cfg.pricing), + "client_probes" => to_value(&cfg.client_probes), + "security" => to_value(&cfg.security), + "failover" => to_value(&cfg.failover), + "aliases" => to_value(&cfg.aliases), + "groups" => to_value(&cfg.groups), + "routes" => to_value(&cfg.routes), + "plugins" => to_value(&cfg.plugins), + _ => return None, + } + .ok()?; + let mut cur = §ion; + for s in rest { + cur = match s { + Step::Key(k) => cur.get(k.as_str())?, + Step::Index(i) => cur.get(*i)?, + }; + } + match cur { + serde_yaml_ng::Value::String(s) => Some(s.clone()), + serde_yaml_ng::Value::Bool(b) => Some(b.to_string()), + serde_yaml_ng::Value::Number(n) => Some(n.to_string()), + _ => None, + } +} + +/// `providers[0].protocol` → 键、下标、键。认不出(键里带点、方括号)就是 None:那一处 +/// 不修 —— 删错一个键比不修糟得多 +fn steps(field: &str) -> Option> { + let mut out = Vec::new(); + for part in field.split('.') { + let (key, rest) = match part.find('[') { + Some(i) => (&part[..i], &part[i..]), + None => (part, ""), + }; + if key.is_empty() || key.contains(']') { + return None; + } + out.push(Step::key(key)); + let mut rest = rest; + while !rest.is_empty() { + let inner = rest.strip_prefix('[')?; + let (n, after) = inner.split_once(']')?; + out.push(Step::Index(n.parse().ok()?)); + rest = after; + } + } + Some(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_partial_pack_with_an_old_field_and_an_old_value_is_repaired_in_memory() { + let text = "version: 1\nproviders:\n - name: a\n base_url: https://a.example\n balance: retired_kind\n gone_field: sk-ant-api03-secretsecretsecret\nclient_probes:\n titling: passthrough\n"; + let (out, fixes) = repair(text).expect("修得了"); + assert!(model::parse(&out).is_ok(), "{out}"); + let got: Vec<(FixKind, &str, Option)> = fixes + .iter() + .map(|f| (f.kind, f.field.as_str(), f.line)) + .collect(); + assert_eq!( + got, + vec![ + (FixKind::UnknownValue, "providers[0].balance", Some(5)), + (FixKind::UnknownField, "providers[0].gone_field", Some(6)), + (FixKind::UnknownValue, "client_probes.titling", Some(8)), + ] + ); + // 默认值不写出来的(余额来源缺省是 auto)说不出来 + assert_eq!(fixes[0].now, None); + assert_eq!(fixes[2].now.as_deref(), Some("forward")); + // 原值脱敏 + let v = fixes[1].value.as_deref().unwrap(); + assert!(!v.contains("secretsecret"), "{v}"); + // 别的原样留着 + assert!(out.contains(" base_url: https://a.example\n"), "{out}"); + } + + #[test] + fn what_reads_or_cannot_be_repaired_is_none() { + assert!(repair("version: 1\n").is_none()); + // 语法错不修 + assert!(repair("version: 1\nproviders: [\n").is_none()); + // 缺字段(没有默认值可回)不修 + assert!(repair("version: 1\nproviders:\n - name: a\n").is_none()); + } +} diff --git a/crates/tw-pack/src/restore.rs b/crates/tw-pack/src/restore.rs index 9ae33160..e53b7bb2 100644 --- a/crates/tw-pack/src/restore.rs +++ b/crates/tw-pack/src/restore.rs @@ -119,6 +119,8 @@ pub fn apply_with( let picked: Option> = items.map(|xs| xs.iter().collect()); let mut target = l.clone(); let mut kept = Vec::new(); + // 那一版有、现在没有的 OAuth 上游:令牌不在快照里,落下去之后从那一版里取回来 + let mut tokens_from_backup: Vec = Vec::new(); for row in rows(&l, &b, pinned, with_prefs) { if picked.as_ref().is_some_and(|p| !p.contains(&row.item)) { continue; @@ -130,13 +132,20 @@ pub fn apply_with( match b.items.get(&row.item) { Some(it) => { let mut it = it.clone(); - // 那一版有、现在没有的 ChatGPT 账号上游:没有登录,停用着回来 - if row.item.kind == ItemKind::Provider - && !l.items.contains_key(&row.item) - && model::provider_of(&row.item, &it).is_ok_and(|p| model::is_chatgpt(&p)) - && let Some(Json::Object(other)) = it.units.get_mut(unit::OTHER) - { - other.insert("disabled".into(), Json::Bool(true)); + // 那一版有、现在没有的 ChatGPT 账号上游:它的登录随删除吊销了,停用着回来。 + // 别的 OAuth 上游:令牌从那一版里取回来(落下去之后补上) + let back = !l.items.contains_key(&row.item); + let p = (row.item.kind == ItemKind::Provider) + .then(|| model::provider_of(&row.item, &it).ok()) + .flatten(); + if back && let Some(p) = &p { + if model::is_chatgpt(p) { + if let Some(Json::Object(other)) = it.units.get_mut(unit::OTHER) { + other.insert("disabled".into(), Json::Bool(true)); + } + } else if p.oauth.is_some() { + tokens_from_backup.push(row.item.name.clone()); + } } target.items.insert(row.item.clone(), it); } @@ -146,7 +155,10 @@ pub fn apply_with( } } target.relink(); - let pack = model::materialize_with(local, &target, codec)?; + let mut pack = model::materialize_with(local, &target, codec)?; + if !tokens_from_backup.is_empty() { + pack.config_yaml = tokens_back(&pack.config_yaml, backup, &tokens_from_backup)?; + } tw_config::try_parse(&pack.config_yaml).map_err(|r| ModelError::Config(Box::new(r)))?; Ok(Applied { changed_keys: import::changed_keys(local, &pack), @@ -155,6 +167,63 @@ pub fn apply_with( }) } +/// 把那一版里这几家 OAuth 上游的令牌写回 `text`。那一版里没有令牌(空的)的那家停用着回来 +fn tokens_back(text: &str, backup: &Pack, names: &[String]) -> Result { + let then = model::parse(&backup.config_yaml)?; + let mut out = text.to_string(); + for name in names { + let Some(o) = then + .providers + .iter() + .find(|p| &p.name == name) + .and_then(|p| p.oauth.as_ref()) + else { + continue; + }; + if o.refresh.trim().is_empty() { + out = tw_config::edit::set( + &out, + &provider_field(&out, name, "disabled")?, + Some(&serde_yaml_ng::Value::Bool(true)), + )?; + continue; + } + let tokens = tw_config::RenewedTokens { + access: o.access.as_deref().unwrap_or_default(), + expires_at: o.expires_at.as_deref(), + refresh: Some(&o.refresh), + }; + out = tw_config::patch_oauth_tokens(&out, name, tokens).map_err(|e| ModelError::Shape { + item: format!("provider `{name}`"), + detail: e.to_string(), + })?; + // 没有 access token 的:去掉写进去的空值,下一次用到时拿 refresh 换 + if o.access.is_none() { + let at = provider_field(&out, name, "oauth")?; + let mut p = at.clone(); + p.push(tw_yaml::Step::key("access")); + out = tw_config::edit::set(&out, &p, None)?; + } + } + Ok(out) +} + +/// 配置里上游 `name` 的某个字段的路径 +fn provider_field(text: &str, name: &str, field: &str) -> Result, ModelError> { + let doc = tw_config::edit::parse(text)?; + let i = tw_config::edit::PROVIDERS + .index_of(&doc, name) + .ok_or_else(|| ModelError::Shape { + item: format!("provider `{name}`"), + detail: "it is not in the configuration".into(), + })?; + Ok(vec![ + tw_yaml::Step::key("providers"), + tw_yaml::Step::Index(i), + tw_yaml::Step::key(field), + ]) +} + /// 配置读不进来(安全模式)时的恢复:整份换成那一版,**只有控制面的钥匙换成现在这一把** /// (`current_text` 是磁盘上现在那份读不进来的原文)。插件和通用偏好照那一版 pub fn whole(backup: &Pack, current_text: &str) -> Pack { diff --git a/crates/tw-pack/src/restore/tests.rs b/crates/tw-pack/src/restore/tests.rs index 3ecaeb0d..409f9076 100644 --- a/crates/tw-pack/src/restore/tests.rs +++ b/crates/tw-pack/src/restore/tests.rs @@ -103,3 +103,34 @@ fn in_safe_mode_the_whole_version_comes_back_with_todays_key() { assert!(!out.config_yaml.contains(tw_api::control::KEY_MASK)); assert!(out.config_yaml.contains("port: 8000")); } + +/// OAuth 上游:现在还在的留着现在的令牌;那一版有、现在没有的,令牌从那一版里取回来 +#[test] +fn oauth_tokens_come_from_now_or_from_the_version_it_comes_back_from() { + let oauth = |name: &str, rt: &str| { + format!( + " - name: {name}\n base_url: https://{name}.example\n oauth:\n refresh: {rt}\n access: at-{rt}\n endpoint: https://auth.example/token\n" + ) + }; + let head = format!( + "version: 1\nlisten:\n control:\n key: {KEY}\nclients:\n - name: default\n key: tw-a\nproviders:\n" + ); + let now = pack(&format!("{head}{}", oauth("kept", "rt-now"))); + let then = pack(&format!( + "{head}{}{}{}", + oauth("kept", "rt-then").replace("kept.example", "kept.example/v0"), + oauth("gone", "rt-gone"), + " - name: blank\n base_url: https://blank.example\n disabled: true\n oauth:\n refresh: ''\n endpoint: https://auth.example/token\n", + )); + let out = apply_with(&now, &then, None, &BTreeSet::new(), true, &WholeFile).unwrap(); + let cfg = tw_config::try_parse(&out.pack.config_yaml) + .unwrap_or_else(|e| panic!("{e}\n{}", out.pack.config_yaml)); + let p = |n: &str| cfg.providers.iter().find(|p| p.name == n).unwrap(); + assert_eq!(p("kept").base_url, "https://kept.example/v0"); + assert_eq!(p("kept").oauth.as_ref().unwrap().refresh, "rt-now"); + let gone = p("gone").oauth.as_ref().unwrap(); + assert_eq!(gone.refresh, "rt-gone"); + assert_eq!(gone.access.as_deref(), Some("at-rt-gone")); + assert!(!p("gone").disabled); + assert!(p("blank").disabled); +}