修复素材导出缺少敏感文件拒绝与自我覆盖保护
修复素材导出缺少敏感文件拒绝与自我覆盖保护(PR #316 review) - asset_export.rs:27-30 [security · high] 已被修复:`resolve_export_source_file` 现在与通用读取路径同一口径,normalize 后先 `reject_agent_runtime_private_control_path` 再 `reject_sensitive_project_file_read`,`.env` / 本地凭据配置与 `.agent/runtime|checkpoints|workbench` 控制面都不能被复制到项目外。 - asset_export.rs:31-33 [security · medium] 已被修复:源路径改用 `resolve_local_project_path` 逐段复核符号链接与 Windows reparse point,`assets` 本身是链接/junction 时不再把项目外文件当成素材导出。 - asset_export.rs:76-78 [bug · high] 已被修复:新增 `reject_export_destination_matching_source`,在创建写句柄之前按「路径等价(大小写不敏感平台忽略大小写)」+「文件身份(含硬链接,退化身份不参与判定)」拒绝保存目标与源素材同一份文件;否则 `File::create` 先截断源文件、复制读到 0 字节还会报成功。 - asset_export.rs:93-95 [other · low] 已被修复:复制改为同目录临时文件 + `sync_all` + `rename` 原子替换,失败时清理临时文件;不再先截断既有目标、也不再留下半截文件。 - 文档:命令 docstring 不再声称「只导出 manifest 已登记素材」——DirectProject 导入的附件同样要能另存,而它们不是 manifest 资产,所以这里明确写成与通用读取同一套路径门禁。 - 测试:新增 5 条用例覆盖敏感/控制面源拒绝、中间目录符号链接逃逸、目标即源文件、硬链接目标、原子替换不留临时文件;7 条变异验证全部 CAUGHT。
This commit is contained in:
@@ -2,10 +2,16 @@ use super::*;
|
||||
use std::fs::File;
|
||||
use std::io::{Read, Write};
|
||||
|
||||
/// 显式保存:把项目内已登记的素材文件复制到用户选定的目标路径。
|
||||
/// 显式保存:把项目内的素材文件复制到用户选定的目标路径。
|
||||
///
|
||||
/// 这里刻意不做"浏览器下载"——AGC 是 Tauri/WebView2 宿主,未注册 `on_download`
|
||||
/// 时 `<a download>` 能否落盘不可靠,所以保存路径由原生对话框给出,复制由 Rust 完成。
|
||||
///
|
||||
/// 允许导出的范围 = "项目根内真实存在的普通文件"减去敏感配置与 Runtime 控制面
|
||||
/// (见 [`resolve_export_source_file`])。**不额外要求文件已在 manifest 里登记**:
|
||||
/// 资源画布里的 DirectProject 附件(`attachment:<localPath>`)是客户端导入的项目内文件,
|
||||
/// 用户同样要能把它们另存出来,但它们不是 manifest 资产;按登记与否放行会把这条
|
||||
/// 正常路径一并拒掉。素材保存因此与通用项目读取共用同一套路径门禁,而不是更宽的门禁。
|
||||
const ASSET_EXPORT_COPY_CHUNK_BYTES: usize = 64 * 1024;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
@@ -25,10 +31,14 @@ pub(crate) struct SaveLocalProjectAssetFileResult {
|
||||
|
||||
fn resolve_export_source_file(root: &Path, relative_path: &str) -> Result<PathBuf, String> {
|
||||
let normalized = normalize_relative_path(relative_path.trim())?;
|
||||
if normalized.is_empty() {
|
||||
return Err("待保存的素材路径不能为空".to_string());
|
||||
}
|
||||
let source = root.join(&normalized);
|
||||
// 导出同样是"把项目里的文件交出去",必须与通用项目读取路径同一套拒绝口径:
|
||||
// 敏感配置(`.env` 等)与 `.agent/runtime|checkpoints|workbench` 控制面都不能被复制到项目外。
|
||||
reject_agent_runtime_private_control_path(&normalized)?;
|
||||
reject_sensitive_project_file_read(&normalized)?;
|
||||
// 逐段复核符号链接与 Windows reparse point,而不是只看最后一段:`assets` 本身是链接
|
||||
// 或 junction 时,`root.join(...)` 仍会解析到项目外,导出就变成任意文件读取。
|
||||
// 这里与改名路径共用 `resolve_local_project_path`,保证是同一条边界判据。
|
||||
let source = resolve_local_project_path(root, &normalized)?;
|
||||
// 只允许保存项目根内真实存在的普通文件:符号链接与目录都要拒绝。
|
||||
let metadata = std::fs::symlink_metadata(&source)
|
||||
.map_err(|error| format!("素材文件不存在或不可读:{error}"))?;
|
||||
@@ -63,19 +73,83 @@ fn resolve_export_destination_file(destination_path: &str) -> Result<PathBuf, St
|
||||
Ok(destination)
|
||||
}
|
||||
|
||||
pub(crate) fn save_local_project_asset_file_at(
|
||||
input: SaveLocalProjectAssetFileInput,
|
||||
) -> Result<SaveLocalProjectAssetFileResult, String> {
|
||||
let root = Path::new(input.project_path.trim());
|
||||
validate_project_root(root)?;
|
||||
enforce_project_auto_permission_policy(root, "file.read")?;
|
||||
/// 已存在路径的文件身份;读不到身份、或身份是退化常量时返回 `None`。
|
||||
///
|
||||
/// FAT/exFAT 与部分网络卷上 `file index` 恒为 0,那不是一个能区分文件的身份,
|
||||
/// 拿它比较只会把正常导出误判成"自我覆盖",所以这种值一律当成"没有身份"。
|
||||
fn export_path_file_identity(path: &Path) -> Option<(u64, u64)> {
|
||||
let identity = File::open(path)
|
||||
.ok()
|
||||
.and_then(|file| open_file_identity_key(&file).ok())?;
|
||||
if identity.1 == 0 {
|
||||
None
|
||||
} else {
|
||||
Some(identity)
|
||||
}
|
||||
}
|
||||
|
||||
let source = resolve_export_source_file(root, &input.relative_path)?;
|
||||
let destination = resolve_export_destination_file(&input.destination_path)?;
|
||||
/// 路径等价判据:Windows / macOS 的文件系统默认大小写不敏感,同一份文件可能只差大小写。
|
||||
fn export_paths_equivalent(left: &Path, right: &Path) -> bool {
|
||||
if cfg!(any(windows, target_os = "macos")) {
|
||||
left.to_string_lossy()
|
||||
.eq_ignore_ascii_case(&right.to_string_lossy())
|
||||
} else {
|
||||
left == right
|
||||
}
|
||||
}
|
||||
|
||||
let mut reader = File::open(&source).map_err(|error| format!("打开素材文件失败:{error}"))?;
|
||||
/// 保存目标不能与源素材是同一个文件。
|
||||
///
|
||||
/// 前端用的是原生保存对话框,用户完全可以导航进项目素材目录并选中源文件本身;`File::create`
|
||||
/// 会先把目标截断,随后的复制只能读到 0 字节,于是"复制成功"(`byte_len = 0`)返回,而源素材
|
||||
/// 已经被就地毁掉。路径等价、以及符号链接 / 硬链接指向同一份文件,都要在创建写句柄之前拦住。
|
||||
fn reject_export_destination_matching_source(
|
||||
source: &Path,
|
||||
destination: &Path,
|
||||
) -> Result<(), String> {
|
||||
if !destination.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
let same_file = match (
|
||||
export_path_file_identity(source),
|
||||
export_path_file_identity(destination),
|
||||
) {
|
||||
(Some(source_identity), Some(destination_identity)) => {
|
||||
source_identity == destination_identity
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
let same_path = matches!(
|
||||
(source.canonicalize(), destination.canonicalize()),
|
||||
(Ok(source_canonical), Ok(destination_canonical))
|
||||
if export_paths_equivalent(&source_canonical, &destination_canonical)
|
||||
);
|
||||
if same_file || same_path {
|
||||
return Err("保存目标不能与素材源文件相同".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 复制目标文件名用的临时兄弟路径:必须与目标同目录,`rename` 才是同卷原子替换。
|
||||
fn export_destination_temp_path(destination: &Path) -> PathBuf {
|
||||
let file_name = destination
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or("asset-export");
|
||||
destination.with_file_name(format!(
|
||||
".{file_name}.tmp.{}.{}",
|
||||
std::process::id(),
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos()
|
||||
))
|
||||
}
|
||||
|
||||
fn copy_export_source_bytes_to(source: &Path, temp_path: &Path) -> Result<u64, String> {
|
||||
let mut reader = File::open(source).map_err(|error| format!("打开素材文件失败:{error}"))?;
|
||||
let mut writer =
|
||||
File::create(&destination).map_err(|error| format!("创建保存目标失败:{error}"))?;
|
||||
File::create(temp_path).map_err(|error| format!("创建保存目标失败:{error}"))?;
|
||||
let mut buffer = vec![0_u8; ASSET_EXPORT_COPY_CHUNK_BYTES];
|
||||
let mut byte_len = 0_u64;
|
||||
loop {
|
||||
@@ -93,6 +167,42 @@ pub(crate) fn save_local_project_asset_file_at(
|
||||
writer
|
||||
.flush()
|
||||
.map_err(|error| format!("刷新保存目标失败:{error}"))?;
|
||||
// 替换前必须落盘:只 `flush` 的话,崩溃窗口里换过去的可能是还没写完的内容。
|
||||
writer
|
||||
.sync_all()
|
||||
.map_err(|error| format!("落盘保存目标失败:{error}"))?;
|
||||
Ok(byte_len)
|
||||
}
|
||||
|
||||
/// 先整份复制到目标同目录的临时文件,再原子替换目标。
|
||||
///
|
||||
/// 直接往最终目标写会在 `File::create` 那一刻截断既有文件:复制中途失败就同时留下半截文件
|
||||
/// 和"原目标已被毁掉"两个后果。manifest 写入(`write_manifest_locked`)用的是同一套手法。
|
||||
fn copy_export_source_to_destination(source: &Path, destination: &Path) -> Result<u64, String> {
|
||||
let temp_path = export_destination_temp_path(destination);
|
||||
let result = copy_export_source_bytes_to(source, &temp_path).and_then(|byte_len| {
|
||||
fs::rename(&temp_path, destination)
|
||||
.map_err(|error| format!("替换保存目标失败:{}: {error}", destination.display()))?;
|
||||
Ok(byte_len)
|
||||
});
|
||||
if result.is_err() {
|
||||
let _ = fs::remove_file(&temp_path);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
pub(crate) fn save_local_project_asset_file_at(
|
||||
input: SaveLocalProjectAssetFileInput,
|
||||
) -> Result<SaveLocalProjectAssetFileResult, String> {
|
||||
let root = Path::new(input.project_path.trim());
|
||||
validate_project_root(root)?;
|
||||
enforce_project_auto_permission_policy(root, "file.read")?;
|
||||
|
||||
let source = resolve_export_source_file(root, &input.relative_path)?;
|
||||
let destination = resolve_export_destination_file(&input.destination_path)?;
|
||||
reject_export_destination_matching_source(&source, &destination)?;
|
||||
|
||||
let byte_len = copy_export_source_to_destination(&source, &destination)?;
|
||||
|
||||
Ok(SaveLocalProjectAssetFileResult {
|
||||
destination_path: destination.to_string_lossy().into_owned(),
|
||||
@@ -246,4 +356,165 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let _ = std::fs::remove_dir_all(&destination_directory);
|
||||
}
|
||||
|
||||
/// 敏感配置与 Runtime 控制面文件都不能被导出到项目外:导出走的是同一条
|
||||
/// `file.read` 拒绝口径,而不是只做路径拼接。
|
||||
#[test]
|
||||
fn rejects_sensitive_and_agent_control_files_as_source() {
|
||||
let (root, assets) = create_export_project();
|
||||
std::fs::write(root.join(".env"), "SECRET=1").expect("write .env");
|
||||
std::fs::write(assets.join("hero.png"), b"payload").expect("write source asset");
|
||||
let agent_runtime = root.join(".agent/runtime");
|
||||
std::fs::create_dir_all(&agent_runtime).expect("create agent runtime dir");
|
||||
std::fs::write(agent_runtime.join("run.json"), "{}").expect("write control file");
|
||||
let destination_directory = unique_asset_export_directory("destination");
|
||||
std::fs::create_dir_all(&destination_directory).expect("create destination directory");
|
||||
|
||||
for relative_path in [".env", ".agent/runtime/run.json"] {
|
||||
let error = save_local_project_asset_file_at(SaveLocalProjectAssetFileInput {
|
||||
project_path: root.to_string_lossy().into_owned(),
|
||||
relative_path: relative_path.to_string(),
|
||||
destination_path: destination_directory
|
||||
.join("copy.bin")
|
||||
.to_string_lossy()
|
||||
.into_owned(),
|
||||
})
|
||||
.expect_err("sensitive or control-plane source must be rejected");
|
||||
assert!(
|
||||
error.contains("拒绝读取敏感配置文件") || error.contains("私有控制面"),
|
||||
"意外错误:{error}"
|
||||
);
|
||||
}
|
||||
assert!(!destination_directory.join("copy.bin").exists());
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let _ = std::fs::remove_dir_all(&destination_directory);
|
||||
}
|
||||
|
||||
/// 中间目录是符号链接时不能把项目外的文件当素材导出:只查最后一段会漏掉这条逃逸路径。
|
||||
#[test]
|
||||
fn rejects_source_reached_through_an_intermediate_symlink() {
|
||||
let (root, assets) = create_export_project();
|
||||
let outside = unique_asset_export_directory("outside");
|
||||
std::fs::create_dir_all(&outside).expect("create outside directory");
|
||||
std::fs::write(outside.join("secret.png"), b"outside-secret").expect("write outside file");
|
||||
std::fs::remove_dir_all(&assets).expect("remove real assets dir");
|
||||
// Windows 上建目录符号链接需要开发者模式或特权;拿不到权限就跳过这条断言,
|
||||
// 不让环境能力决定测试结论。Unix 上恒可用。
|
||||
#[cfg(windows)]
|
||||
let symlink_created = std::os::windows::fs::symlink_dir(&outside, &assets).is_ok();
|
||||
#[cfg(not(windows))]
|
||||
let symlink_created = std::os::unix::fs::symlink(&outside, &assets).is_ok();
|
||||
if !symlink_created || !assets.join("secret.png").is_file() {
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let _ = std::fs::remove_dir_all(&outside);
|
||||
return;
|
||||
}
|
||||
let destination_directory = unique_asset_export_directory("destination");
|
||||
std::fs::create_dir_all(&destination_directory).expect("create destination directory");
|
||||
|
||||
let error = save_local_project_asset_file_at(SaveLocalProjectAssetFileInput {
|
||||
project_path: root.to_string_lossy().into_owned(),
|
||||
relative_path: "assets/secret.png".to_string(),
|
||||
destination_path: destination_directory
|
||||
.join("leak.png")
|
||||
.to_string_lossy()
|
||||
.into_owned(),
|
||||
})
|
||||
.expect_err("source behind an intermediate symlink must be rejected");
|
||||
assert!(error.contains("符号链接"), "意外错误:{error}");
|
||||
assert!(!destination_directory.join("leak.png").exists());
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let _ = std::fs::remove_dir_all(&outside);
|
||||
let _ = std::fs::remove_dir_all(&destination_directory);
|
||||
}
|
||||
|
||||
/// 保存目标就是源文件时必须拒绝,并且源素材一个字节都不能变。
|
||||
#[test]
|
||||
fn rejects_destination_that_is_the_source_file() {
|
||||
let (root, assets) = create_export_project();
|
||||
let source_bytes = b"genarrative-asset-export-payload".to_vec();
|
||||
std::fs::write(assets.join("hero.png"), &source_bytes).expect("write source asset");
|
||||
let source = assets.join("hero.png");
|
||||
|
||||
let error = save_local_project_asset_file_at(SaveLocalProjectAssetFileInput {
|
||||
project_path: root.to_string_lossy().into_owned(),
|
||||
relative_path: "assets/hero.png".to_string(),
|
||||
destination_path: source.to_string_lossy().into_owned(),
|
||||
})
|
||||
.expect_err("destination equal to the source must be rejected");
|
||||
|
||||
assert!(error.contains("不能与素材源文件相同"), "{error}");
|
||||
assert_eq!(
|
||||
std::fs::read(&source).expect("read source asset"),
|
||||
source_bytes,
|
||||
"拒绝自我覆盖时源素材必须原样保留"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// 硬链接指向同一份文件时同样拒绝:路径不同但内容是同一份,覆盖等于毁掉源素材。
|
||||
#[test]
|
||||
fn rejects_hard_linked_destination_pointing_at_the_source() {
|
||||
let (root, assets) = create_export_project();
|
||||
let source_bytes = b"genarrative-asset-export-payload".to_vec();
|
||||
std::fs::write(assets.join("hero.png"), &source_bytes).expect("write source asset");
|
||||
let destination_directory = unique_asset_export_directory("destination");
|
||||
std::fs::create_dir_all(&destination_directory).expect("create destination directory");
|
||||
let destination = destination_directory.join("hero-alias.png");
|
||||
if std::fs::hard_link(assets.join("hero.png"), &destination).is_err() {
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let _ = std::fs::remove_dir_all(&destination_directory);
|
||||
return;
|
||||
}
|
||||
|
||||
let error = save_local_project_asset_file_at(SaveLocalProjectAssetFileInput {
|
||||
project_path: root.to_string_lossy().into_owned(),
|
||||
relative_path: "assets/hero.png".to_string(),
|
||||
destination_path: destination.to_string_lossy().into_owned(),
|
||||
})
|
||||
.expect_err("hard-linked destination must be rejected");
|
||||
assert!(error.contains("不能与素材源文件相同"), "{error}");
|
||||
assert_eq!(
|
||||
std::fs::read(assets.join("hero.png")).expect("read source asset"),
|
||||
source_bytes
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let _ = std::fs::remove_dir_all(&destination_directory);
|
||||
}
|
||||
|
||||
/// 覆盖既有目标走临时文件 + 原子替换:内容整体换新,且不留临时文件残渣。
|
||||
#[test]
|
||||
fn replaces_an_existing_destination_without_leaving_temp_files() {
|
||||
let (root, assets) = create_export_project();
|
||||
let source_bytes = b"genarrative-asset-export-payload".to_vec();
|
||||
std::fs::write(assets.join("hero.png"), &source_bytes).expect("write source asset");
|
||||
let destination_directory = unique_asset_export_directory("destination");
|
||||
std::fs::create_dir_all(&destination_directory).expect("create destination directory");
|
||||
let destination = destination_directory.join("hero-copy.png");
|
||||
std::fs::write(&destination, b"stale-and-longer-content").expect("write stale destination");
|
||||
|
||||
let result = save_local_project_asset_file_at(SaveLocalProjectAssetFileInput {
|
||||
project_path: root.to_string_lossy().into_owned(),
|
||||
relative_path: "assets/hero.png".to_string(),
|
||||
destination_path: destination.to_string_lossy().into_owned(),
|
||||
})
|
||||
.expect("save asset file");
|
||||
|
||||
assert_eq!(result.byte_len, source_bytes.len() as u64);
|
||||
assert_eq!(
|
||||
std::fs::read(&destination).expect("read copied asset"),
|
||||
source_bytes
|
||||
);
|
||||
let entries = std::fs::read_dir(&destination_directory)
|
||||
.expect("read destination directory")
|
||||
.count();
|
||||
assert_eq!(entries, 1, "原子替换不得留下临时文件");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let _ = std::fs::remove_dir_all(&destination_directory);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user