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.

46 changes: 43 additions & 3 deletions bin/twcore/src/pack_cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -287,11 +287,19 @@ pub fn import(
continue;
}
Status::New => {
println!(" new {what}");
println!(" new {what}{}", flag_note(&r.item, &r.flags));
Action::Import
}
Status::Conflict => {
println!(" differs {what} ({})", r.differs.join(", "));
println!(
" differs {what} ({}){}",
r.differs.join(", "),
flag_note(&r.item, &r.flags)
);
let renamed = flag_note(&r.item, &r.rename_flags);
if !r.global && !renamed.is_empty() {
println!(" under another name{renamed}");
}
let choice = match on_conflict {
Some(c) => c,
None if dry_run => continue,
Expand All @@ -311,7 +319,10 @@ pub fn import(
};
match (choice, &r.suggested_name) {
(OnConflict::Overwrite, _) => Action::Overwrite,
(OnConflict::Rename, Some(n)) => Action::Rename(n.clone()),
(OnConflict::Rename, Some(n)) => {
println!(" → as `{n}`{renamed}");
Action::Rename(n.clone())
}
_ => Action::Skip,
}
}
Expand All @@ -336,6 +347,35 @@ pub fn import(
Ok(())
}

/// 一行导入带的标记说成一句:缺密钥、要登录的上游停用着进来,空的网关密钥换一个新值
fn flag_note(id: &ItemId, flags: &[tw_pack::import::Flag]) -> String {
use tw_pack::import::Flag;
let mut parts: Vec<&str> = Vec::new();
if flags.contains(&Flag::MissingKey) {
parts.push(match id.kind {
ItemKind::Key => "a new key value is generated",
ItemKind::Proxy => "its password is missing",
_ => "its key is missing",
});
}
if flags.contains(&Flag::NeedsLogin) {
parts.push("it needs signing in");
}
if id.kind == ItemKind::Provider
&& (flags.contains(&Flag::MissingKey) || flags.contains(&Flag::NeedsLogin))
{
parts.push("it comes in disabled");
}
if flags.contains(&Flag::PluginDisabled) {
parts.push("it comes in turned off");
}
if parts.is_empty() {
String::new()
} else {
format!(": {}", parts.join("; "))
}
}

// ─────────────────────────────────────────────────────────── backup

pub fn list(path: &Path) -> Result<()> {
Expand Down
2 changes: 2 additions & 0 deletions crates/tw-api/msg-codes.txt
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ config.aws_profile.unsupported
config.bad_allow_from
config.bad_base_url
config.bad_remote_allow_from
config.base_url_backslash
config.base_url_userinfo
config.blank_models_only
config.control_key_invalid
config.control_key_missing
Expand Down
6 changes: 4 additions & 2 deletions crates/tw-api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -976,8 +976,10 @@ pub const MSG_CODES: &str = include_str!("../msg-codes.txt");
/// 超出上限是 `backup.pinned.*`)报告接管的客户端在用哪几把密钥。恢复时留下的项目还引用着的、
/// 同步设置走的出站代理不删([`KeptReason`] 的 `referenced`),所选项目引用的、本机已没有的一起
/// 恢复;恢复同步位置上的版本时本机不同步的上游不列成删除。不带密钥的包导入时比较和覆盖都沿用
/// 本机的密钥,**只在目的地不变时**(协议、主机、端口、AWS 区域、OAuth 令牌端点、代理地址
/// 一样);改名导入的不沿用,缺着的标 `missing_key`(代理的密码也算)。导入覆盖、恢复改了或删了网关密钥时发
/// 本机的密钥,**只在目的地不变时**(协议、主机、端口按 URL 库规范化后一样,AWS 区域、OAuth
/// 令牌端点、所走的出站代理、代理地址一样);改名导入的不沿用,缺着的标 `missing_key`(代理的
/// 密码也算),[`ImportRow`] 的 `rename_flags` 说改名导入时会带的标记。上游地址里有反斜杠、
/// 主机前写了用户名或密码的不收(`config.base_url_backslash`、`config.base_url_userinfo`)。导入覆盖、恢复改了或删了网关密钥时发
/// [`Event::GatewayKeysChanged`]。[`ConfigOrigin`] 删了 `rollback`,多了 `sync`、`import`、
/// `restore`、`repair`(一键修复以前记成 `ui`)。备份相关的端点在安全模式下照样可用。配置包的
/// 码是 `pack.*`,导入的是 `import.*`,备份与恢复的是 `backup.*`。停用着的上游可以缺凭据
Expand Down
4 changes: 4 additions & 0 deletions crates/tw-api/src/pack.rs
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,10 @@ pub struct ImportRow {
pub differs: Vec<String>,
/// 改名导入时的缺省名字(`公司中转 (2)`)。冲突的、有名字的项目才有
pub suggested_name: Option<String>,
/// 改名导入时这一项会带的标记(`flags` 说的是导入、覆盖)。改名导入的是另一个项目,不沿用
/// 本机的密钥和登录:不带密钥的包里,改名导入的上游缺着密钥、停用着进来(`missing_key`),
/// ChatGPT 账号要重新登录(`needs_login`)。有 `suggested_name` 的行才有,别的行是空的
pub rename_flags: Vec<ImportFlag>,
pub error: Option<Msg>,
/// 它引用的、包里也有的项目。**被跳过的项目被谁引用着,谁就导入不了**(除非引用的那一项
/// 是改名导入):界面按它把那几行标成错误
Expand Down
76 changes: 76 additions & 0 deletions crates/tw-config/src/validate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,14 @@ pub enum ValidationError {
DuplicateClientKey(String, String, String),
#[error("{}", self.msg())]
BadBaseUrl { name: String, url: String },
/// 地址里有反斜杠:URL 库把它当成路径的分隔(`https://a\\@b/` 连的是 a),人读起来
/// 像是 b —— 两种读法对不上的地址一律不收
#[error("{}", self.msg())]
BaseUrlBackslash { name: String, url: String },
/// 地址的主机前面写了用户名或密码(`https://user:pass@host`):凭据写在 `key` 或
/// `headers` 里。消息里不带地址:那一段就是密码
#[error("{}", self.msg())]
BaseUrlUserinfo { name: String },
#[error("{}", self.msg())]
EmptyKey { name: String },
#[error("{}", self.msg())]
Expand Down Expand Up @@ -208,6 +216,16 @@ impl ValidationError {
"config.bad_base_url", upstream = name, url = url =>
"the endpoint of upstream `{upstream}` is neither http nor https: {url}"
),
BaseUrlBackslash { name, url } => msg!(
"config.base_url_backslash", upstream = name, url = url =>
"the endpoint of upstream `{upstream}` contains a backslash: {url}. Write the \
address with forward slashes only"
),
BaseUrlUserinfo { name } => msg!(
"config.base_url_userinfo", upstream = name =>
"the endpoint of upstream `{upstream}` has a user name or password before the \
host. Put the credential in `key` or `headers` instead"
),
EmptyKey { name } => msg!(
"config.empty_key", key = name =>
"the value of gateway key `{key}` is empty"
Expand Down Expand Up @@ -485,6 +503,19 @@ pub fn validate(cfg: &Config) -> Result<(), ValidationError> {
url: p.base_url.clone(),
});
}
if p.base_url.contains('\\') {
return Err(ValidationError::BaseUrlBackslash {
name: p.name.clone(),
url: p.base_url.clone(),
});
}
if let Ok(u) = url::Url::parse(p.base_url.trim())
&& (!u.username().is_empty() || u.password().is_some())
{
return Err(ValidationError::BaseUrlUserinfo {
name: p.name.clone(),
});
}
p.check_credential()
.map_err(|source| ValidationError::Credential {
name: p.name.clone(),
Expand Down Expand Up @@ -1222,6 +1253,46 @@ mod tests {
));
}

/// 两种读法对不上的地址不收:反斜杠(URL 库当成路径分隔,人读成主机前的一段)、主机前
/// 的用户名和密码
#[test]
fn a_base_url_that_reads_two_ways_is_refused() {
for url in [
"https://evil.example\\@relay.example/v1",
"https://relay.example\\v1",
] {
let e = validate(&cfg(vec![c("d", "tw-1")], vec![p("r", url)])).unwrap_err();
assert!(
matches!(e, ValidationError::BaseUrlBackslash { .. }),
"{url}: {e}"
);
assert_eq!(e.msg().code, "config.base_url_backslash");
}
for url in [
"https://user:pass@relay.example/v1",
"https://user@relay.example",
"http://:secret@10.0.0.1:3000",
] {
let e = validate(&cfg(vec![c("d", "tw-1")], vec![p("r", url)])).unwrap_err();
assert!(
matches!(e, ValidationError::BaseUrlUserinfo { .. }),
"{url}: {e}"
);
assert!(
!e.to_string().contains("user:pass") && !e.to_string().contains("secret"),
"{e}"
);
}
// `@` 在路径里没关系
assert!(
validate(&cfg(
vec![c("d", "tw-1")],
vec![p("r", "https://relay.example/v1/@team")]
))
.is_ok()
);
}

#[test]
fn names_that_start_with_two_underscores_are_reserved() {
// 「全部上游」在配置里叫 `__all__`。一个同名的上游或策略组会让
Expand Down Expand Up @@ -1863,6 +1934,11 @@ mod msg_codes {
name: "a".into(),
url: "ftp://x".into(),
},
BaseUrlBackslash {
name: "a".into(),
url: "https://a\\@b".into(),
},
BaseUrlUserinfo { name: "a".into() },
EmptyKey { name: "k".into() },
ZeroConcurrency { name: "k".into() },
ProviderConcurrency {
Expand Down
4 changes: 2 additions & 2 deletions crates/tw-config/tests/manual/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -587,8 +587,8 @@ pub fn sections() -> Vec<Section> {
Kind::Str,
Def::Required,
t(
"Endpoint, `http://` or `https://`, up to the version segment where the provider documents one (`https://api.anthropic.com`, `https://api.openai.com/v1`). For Bedrock, the region's runtime endpoint: `https://bedrock-runtime.<region>.amazonaws.com`.",
"接口地址,`http://` 或 `https://`,按服务商文档写到版本段为止(`https://api.anthropic.com`、`https://api.openai.com/v1`)。Bedrock 写所在区域的推理地址:`https://bedrock-runtime.<区域>.amazonaws.com`。",
"Endpoint, `http://` or `https://`, up to the version segment where the provider documents one (`https://api.anthropic.com`, `https://api.openai.com/v1`). For Bedrock, the region's runtime endpoint: `https://bedrock-runtime.<region>.amazonaws.com`. No backslashes, and no user name or password before the host (credentials go in `key` or `headers`).",
"接口地址,`http://` 或 `https://`,按服务商文档写到版本段为止(`https://api.anthropic.com`、`https://api.openai.com/v1`)。Bedrock 写所在区域的推理地址:`https://bedrock-runtime.<区域>.amazonaws.com`。地址中不能有反斜杠,主机前不能写用户名或密码(凭据写在 `key` 或 `headers` 中)。",
),
),
row(
Expand Down
22 changes: 12 additions & 10 deletions crates/tw-control/src/backups.rs
Original file line number Diff line number Diff line change
Expand Up @@ -496,8 +496,17 @@ fn import_fail(e: tw_pack::import::ImportError, pack: &Pack) -> Fail {
}
}

fn flag_view(f: tw_pack::import::Flag) -> tw_api::ImportFlag {
use tw_pack::import::Flag;
match f {
Flag::MissingKey => tw_api::ImportFlag::MissingKey,
Flag::NeedsLogin => tw_api::ImportFlag::NeedsLogin,
Flag::PluginDisabled => tw_api::ImportFlag::PluginDisabled,
}
}

pub(crate) fn row_view(r: tw_pack::import::Row) -> tw_api::ImportRow {
use tw_pack::import::{Flag, Status};
use tw_pack::import::Status;
tw_api::ImportRow {
item: item_ref(&r.item),
status: match r.status {
Expand All @@ -507,17 +516,10 @@ pub(crate) fn row_view(r: tw_pack::import::Row) -> tw_api::ImportRow {
Status::Error => tw_api::ImportStatus::Error,
},
global: r.global,
flags: r
.flags
.into_iter()
.map(|f| match f {
Flag::MissingKey => tw_api::ImportFlag::MissingKey,
Flag::NeedsLogin => tw_api::ImportFlag::NeedsLogin,
Flag::PluginDisabled => tw_api::ImportFlag::PluginDisabled,
})
.collect(),
flags: r.flags.into_iter().map(flag_view).collect(),
differs: r.differs,
suggested_name: r.suggested_name,
rename_flags: r.rename_flags.into_iter().map(flag_view).collect(),
error: r.error,
refs: r.refs.iter().map(item_ref).collect(),
}
Expand Down
28 changes: 28 additions & 0 deletions crates/tw-control/tests/backups.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1068,3 +1068,31 @@ async fn a_plugin_put_back_at_startup_runs_again() {
mgr.record_startup();
assert_eq!(tw_pack::backup::list(&p).unwrap().len(), before);
}

/// 导入预览的每一行带着改名导入时的标记:不带密钥的包里,改名导入的上游缺着密钥进来
#[tokio::test]
async fn an_import_row_says_what_a_rename_would_bring() {
let src = bed(&theirs());
let file = export_all(&src, false).await;
let dst = bed(&mine());
let (st, preview) = call(
&dst.app,
"POST",
"/config/import/preview",
serde_json::json!({ "data_b64": file["data_b64"] }),
)
.await;
assert_eq!(st, StatusCode::OK, "{preview}");
let rows = preview["rows"].as_array().unwrap();
let official = rows
.iter()
.find(|r| r["item"]["kind"] == "provider" && r["item"]["name"] == "官方")
.unwrap();
assert_eq!(official["status"], "conflict", "{official}");
// 只换了路径:覆盖沿用本机的密钥,改名导入不沿用
assert_eq!(official["flags"], serde_json::json!([]));
assert_eq!(official["rename_flags"], serde_json::json!(["missing_key"]));
let relay = rows.iter().find(|r| r["item"]["name"] == "relay").unwrap();
assert_eq!(relay["status"], "new");
assert_eq!(relay["rename_flags"], serde_json::json!([]));
}
2 changes: 2 additions & 0 deletions crates/tw-pack/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ blake3 = { workspace = true }
sha2 = { workspace = true }
age = { workspace = true }
zip = { workspace = true }
# 比较上游地址发去哪儿:和 reqwest 同一个解析器,两边对同一个地址的读法一样
url = { workspace = true }

[dev-dependencies]
tempfile = "3"
Loading
Loading