Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/patchset.rs
T
AIGameCreator App 5c3b8ec267 完善Agent多文件变更集能力
新增受确认的 project.patchset 事务式多文件修改与回滚机制

补充 checkpoint 内容差异和文件内容哈希约束

加强隔离 Agent 路径范围与仓库指纹复核

扩展共享命令契约、真实 LLM E2E 与 Runtime 文档
2026-07-13 01:08:36 +08:00

1890 lines
65 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use serde::Serialize;
use serde_json::{Map, Value};
use sha2::{Digest, Sha256};
use std::fmt;
use std::fs::{self, File, Metadata, OpenOptions, Permissions};
use std::io::{Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use unicode_normalization::UnicodeNormalization;
use crate::project::{normalize_relative_path, validate_project_root};
const PROJECT_PATCHSET_MAX_CHANGES: usize = 12;
const PROJECT_PATCHSET_MAX_TOTAL_BODY_BYTES: usize = 256 * 1024;
const PROJECT_PATCHSET_MAX_FILE_BYTES: usize = 2 * 1024 * 1024;
#[cfg(windows)]
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
#[cfg(windows)]
const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ProjectPatchsetChangeSummary {
pub(crate) operation: String,
pub(crate) path: String,
pub(crate) before_sha256: Option<String>,
pub(crate) after_sha256: Option<String>,
pub(crate) before_bytes: u64,
pub(crate) after_bytes: u64,
}
impl ProjectPatchsetChangeSummary {
pub(crate) fn operation(&self) -> &str {
&self.operation
}
pub(crate) fn path(&self) -> &str {
&self.path
}
pub(crate) fn before_sha256(&self) -> Option<&str> {
self.before_sha256.as_deref()
}
pub(crate) fn after_sha256(&self) -> Option<&str> {
self.after_sha256.as_deref()
}
pub(crate) fn before_bytes(&self) -> u64 {
self.before_bytes
}
pub(crate) fn after_bytes(&self) -> u64 {
self.after_bytes
}
}
#[derive(Clone, Debug)]
pub(crate) struct PreparedProjectPatchset {
root: PathBuf,
changes: Vec<PreparedProjectPatchsetChange>,
summaries: Vec<ProjectPatchsetChangeSummary>,
}
impl PreparedProjectPatchset {
pub(crate) fn summaries(&self) -> &[ProjectPatchsetChangeSummary] {
&self.summaries
}
pub(crate) fn len(&self) -> usize {
self.changes.len()
}
pub(crate) fn is_empty(&self) -> bool {
self.changes.is_empty()
}
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct AppliedProjectPatchset {
pub(crate) changes: Vec<ProjectPatchsetChangeSummary>,
}
impl AppliedProjectPatchset {
pub(crate) fn summaries(&self) -> &[ProjectPatchsetChangeSummary] {
&self.changes
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct ProjectPatchsetApplyError {
pub(crate) message: String,
pub(crate) side_effect_applied: bool,
pub(crate) rollback_complete: bool,
}
impl ProjectPatchsetApplyError {
pub(crate) fn message(&self) -> &str {
&self.message
}
pub(crate) fn side_effect_applied(&self) -> bool {
self.side_effect_applied
}
pub(crate) fn rollback_complete(&self) -> bool {
self.rollback_complete
}
fn before_side_effect(message: impl Into<String>) -> Self {
Self {
message: message.into(),
side_effect_applied: false,
rollback_complete: true,
}
}
}
impl fmt::Display for ProjectPatchsetApplyError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.message)
}
}
impl std::error::Error for ProjectPatchsetApplyError {}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum ProjectPatchsetOperation {
Create,
Update,
Delete,
}
impl ProjectPatchsetOperation {
fn as_str(self) -> &'static str {
match self {
Self::Create => "create",
Self::Update => "update",
Self::Delete => "delete",
}
}
}
#[derive(Clone, Debug)]
struct ProjectPatchsetFileSnapshot {
content: Vec<u8>,
permissions: Permissions,
}
#[derive(Clone, Debug)]
struct PreparedProjectPatchsetChange {
operation: ProjectPatchsetOperation,
path: String,
before: Option<ProjectPatchsetFileSnapshot>,
after: Option<Vec<u8>>,
}
#[derive(Debug)]
enum ParsedProjectPatchsetChange {
Create {
path: String,
content: String,
},
Update {
path: String,
expected_sha256: String,
old_text: String,
new_text: String,
expected_replacements: usize,
},
Delete {
path: String,
expected_sha256: String,
},
}
impl ParsedProjectPatchsetChange {
fn path(&self) -> &str {
match self {
Self::Create { path, .. } | Self::Update { path, .. } | Self::Delete { path, .. } => {
path
}
}
}
fn body_bytes(&self) -> Result<usize, String> {
match self {
Self::Create { content, .. } => Ok(content.len()),
Self::Update {
old_text, new_text, ..
} => old_text
.len()
.checked_add(new_text.len())
.ok_or_else(|| "project.patchset 正文大小溢出".to_string()),
Self::Delete { .. } => Ok(0),
}
}
}
#[derive(Debug)]
struct AppliedMutationJournal {
change_index: usize,
target_touched: bool,
operation_completed: bool,
created_directories: Vec<CreatedProjectDirectory>,
}
impl AppliedMutationJournal {
fn new(change_index: usize) -> Self {
Self {
change_index,
target_touched: false,
operation_completed: false,
created_directories: Vec::new(),
}
}
fn has_side_effect(&self) -> bool {
self.target_touched || !self.created_directories.is_empty()
}
}
#[derive(Debug)]
struct CreatedProjectDirectory {
relative_path: String,
absolute_path: PathBuf,
}
#[derive(Debug)]
struct ProjectPatchsetMutationError {
message: String,
journal: AppliedMutationJournal,
}
pub(crate) fn prepare_project_patchset_at(
root: &Path,
input: &Value,
) -> Result<PreparedProjectPatchset, String> {
let root = validate_patchset_root(root)?;
let parsed_changes = parse_project_patchset_input(input)?;
let normalized_changes = normalize_and_validate_patchset_inputs(parsed_changes)?;
validate_patchset_path_collisions(&normalized_changes)?;
let mut changes = Vec::with_capacity(normalized_changes.len());
let mut summaries = Vec::with_capacity(normalized_changes.len());
for change in normalized_changes {
let prepared = prepare_project_patchset_change(&root, change)?;
summaries.push(project_patchset_change_summary(&prepared));
changes.push(prepared);
}
Ok(PreparedProjectPatchset {
root,
changes,
summaries,
})
}
pub(crate) fn apply_prepared_project_patchset_at(
root: &Path,
prepared: &PreparedProjectPatchset,
) -> Result<AppliedProjectPatchset, ProjectPatchsetApplyError> {
apply_prepared_project_patchset_at_with_hook(root, prepared, |_, _| Ok(()))
}
fn apply_prepared_project_patchset_at_with_hook<F>(
root: &Path,
prepared: &PreparedProjectPatchset,
mut before_change: F,
) -> Result<AppliedProjectPatchset, ProjectPatchsetApplyError>
where
F: FnMut(usize, &ProjectPatchsetChangeSummary) -> Result<(), String>,
{
let root =
validate_patchset_root(root).map_err(ProjectPatchsetApplyError::before_side_effect)?;
if root != prepared.root {
return Err(ProjectPatchsetApplyError::before_side_effect(
"project.patchset 的项目目录与预检目录不一致",
));
}
if prepared.changes.is_empty()
|| prepared.changes.len() > PROJECT_PATCHSET_MAX_CHANGES
|| prepared.changes.len() != prepared.summaries.len()
{
return Err(ProjectPatchsetApplyError::before_side_effect(
"project.patchset 预检结果无效",
));
}
for change in &prepared.changes {
validate_prepared_change_before_apply(&root, change)
.map_err(ProjectPatchsetApplyError::before_side_effect)?;
}
let mut journals = Vec::with_capacity(prepared.changes.len());
for (index, change) in prepared.changes.iter().enumerate() {
if let Err(error) = before_change(index, &prepared.summaries[index]) {
return Err(project_patchset_apply_failure(
&root,
prepared,
journals,
format!("project.patchset 应用前检查失败:{error}"),
));
}
if let Err(error) = validate_prepared_change_before_apply(&root, change) {
return Err(project_patchset_apply_failure(
&root, prepared, journals, error,
));
}
match apply_prepared_change(&root, change, index) {
Ok(journal) => journals.push(journal),
Err(error) => {
if error.journal.has_side_effect() {
journals.push(error.journal);
}
return Err(project_patchset_apply_failure(
&root,
prepared,
journals,
error.message,
));
}
}
}
for change in &prepared.changes {
if let Err(error) = validate_prepared_change_after_apply(&root, change) {
return Err(project_patchset_apply_failure(
&root, prepared, journals, error,
));
}
}
Ok(AppliedProjectPatchset {
changes: prepared.summaries.clone(),
})
}
fn parse_project_patchset_input(input: &Value) -> Result<Vec<ParsedProjectPatchsetChange>, String> {
let object = input
.as_object()
.ok_or_else(|| "project.patchset 输入必须是 JSON object".to_string())?;
require_exact_object_keys(object, &["changes"], "project.patchset 输入")?;
let changes = object
.get("changes")
.and_then(Value::as_array)
.ok_or_else(|| "project.patchset changes 必须是数组".to_string())?;
if changes.is_empty() {
return Err("project.patchset changes 不能为空".to_string());
}
if changes.len() > PROJECT_PATCHSET_MAX_CHANGES {
return Err(format!(
"project.patchset 一次最多包含 {PROJECT_PATCHSET_MAX_CHANGES} 项变更"
));
}
changes
.iter()
.enumerate()
.map(|(index, change)| parse_project_patchset_change(index, change))
.collect()
}
fn parse_project_patchset_change(
index: usize,
value: &Value,
) -> Result<ParsedProjectPatchsetChange, String> {
let label = format!("project.patchset changes[{}]", index + 1);
let object = value
.as_object()
.ok_or_else(|| format!("{label} 必须是 JSON object"))?;
let operation = required_string(object, "operation", &label)?;
match operation {
"create" => {
require_exact_object_keys(object, &["operation", "path", "content"], &label)?;
Ok(ParsedProjectPatchsetChange::Create {
path: required_string(object, "path", &label)?.to_string(),
content: required_string(object, "content", &label)?.to_string(),
})
}
"update" => {
require_exact_object_keys(
object,
&[
"operation",
"path",
"expectedSha256",
"oldText",
"newText",
"expectedReplacements",
],
&label,
)?;
let expected_replacements = object
.get("expectedReplacements")
.and_then(Value::as_u64)
.and_then(|value| usize::try_from(value).ok())
.ok_or_else(|| format!("{label}.expectedReplacements 必须是整数"))?;
Ok(ParsedProjectPatchsetChange::Update {
path: required_string(object, "path", &label)?.to_string(),
expected_sha256: required_string(object, "expectedSha256", &label)?.to_string(),
old_text: required_string(object, "oldText", &label)?.to_string(),
new_text: required_string(object, "newText", &label)?.to_string(),
expected_replacements,
})
}
"delete" => {
require_exact_object_keys(object, &["operation", "path", "expectedSha256"], &label)?;
Ok(ParsedProjectPatchsetChange::Delete {
path: required_string(object, "path", &label)?.to_string(),
expected_sha256: required_string(object, "expectedSha256", &label)?.to_string(),
})
}
_ => Err(format!("{label}.operation 只允许 create、update 或 delete")),
}
}
fn require_exact_object_keys(
object: &Map<String, Value>,
expected: &[&str],
label: &str,
) -> Result<(), String> {
for key in expected {
if !object.contains_key(*key) {
return Err(format!("{label} 缺少 {key}"));
}
}
if let Some(key) = object
.keys()
.find(|key| !expected.iter().any(|expected| key.as_str() == *expected))
{
return Err(format!("{label} 包含未知字段 {key}"));
}
Ok(())
}
fn required_string<'a>(
object: &'a Map<String, Value>,
field: &str,
label: &str,
) -> Result<&'a str, String> {
object
.get(field)
.and_then(Value::as_str)
.ok_or_else(|| format!("{label}.{field} 必须是字符串"))
}
fn normalize_and_validate_patchset_inputs(
changes: Vec<ParsedProjectPatchsetChange>,
) -> Result<Vec<ParsedProjectPatchsetChange>, String> {
let mut total_body_bytes = 0usize;
let mut normalized = Vec::with_capacity(changes.len());
for change in changes {
total_body_bytes = total_body_bytes
.checked_add(change.body_bytes()?)
.ok_or_else(|| "project.patchset 总正文大小溢出".to_string())?;
if total_body_bytes > PROJECT_PATCHSET_MAX_TOTAL_BODY_BYTES {
return Err(format!(
"project.patchset 总正文不能超过 {} KiB",
PROJECT_PATCHSET_MAX_TOTAL_BODY_BYTES / 1024
));
}
let path = normalize_relative_path(change.path())?;
reject_sensitive_patchset_path(&path)?;
let change = match change {
ParsedProjectPatchsetChange::Create { content, .. } => {
validate_input_text(&content, &path)?;
if content.len() > PROJECT_PATCHSET_MAX_FILE_BYTES {
return Err(patchset_file_size_error(&path));
}
ParsedProjectPatchsetChange::Create { path, content }
}
ParsedProjectPatchsetChange::Update {
expected_sha256,
old_text,
new_text,
expected_replacements,
..
} => {
if old_text.is_empty() {
return Err(format!("project.patchset update oldText 不能为空:{path}"));
}
if !(1..=100).contains(&expected_replacements) {
return Err(format!(
"project.patchset update expectedReplacements 必须在 1-100 之间:{path}"
));
}
validate_input_text(&old_text, &path)?;
validate_input_text(&new_text, &path)?;
ParsedProjectPatchsetChange::Update {
path,
expected_sha256: normalize_expected_sha256(&expected_sha256, "update")?,
old_text,
new_text,
expected_replacements,
}
}
ParsedProjectPatchsetChange::Delete {
expected_sha256, ..
} => ParsedProjectPatchsetChange::Delete {
path,
expected_sha256: normalize_expected_sha256(&expected_sha256, "delete")?,
},
};
normalized.push(change);
}
Ok(normalized)
}
fn normalize_expected_sha256(value: &str, operation: &str) -> Result<String, String> {
if value.len() != 64 || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) {
return Err(format!(
"project.patchset {operation} expectedSha256 必须是 64 位十六进制摘要"
));
}
Ok(value.to_ascii_lowercase())
}
fn validate_input_text(content: &str, path: &str) -> Result<(), String> {
if content.contains('\0') {
return Err(format!("project.patchset 文本不能包含 NUL{path}"));
}
Ok(())
}
fn validate_patchset_path_collisions(
changes: &[ParsedProjectPatchsetChange],
) -> Result<(), String> {
let mut paths: Vec<(String, String)> = Vec::with_capacity(changes.len());
for change in changes {
let path = change.path();
let key = portable_patchset_path_key(path);
if let Some((existing_path, existing_key)) = paths.iter().find(|(_, existing_key)| {
*existing_key == key
|| existing_key.starts_with(&format!("{key}/"))
|| key.starts_with(&format!("{existing_key}/"))
}) {
if existing_key == &key {
return Err(format!(
"project.patchset 路径重复或存在大小写碰撞:{existing_path} / {path}"
));
}
return Err(format!(
"project.patchset 路径存在父子层级冲突:{existing_path} / {path}"
));
}
paths.push((path.to_string(), key));
}
Ok(())
}
fn portable_patchset_path_key(value: &str) -> String {
value
.nfkc()
.flat_map(char::to_lowercase)
.collect::<String>()
}
fn prepare_project_patchset_change(
root: &Path,
change: ParsedProjectPatchsetChange,
) -> Result<PreparedProjectPatchsetChange, String> {
match change {
ParsedProjectPatchsetChange::Create { path, content } => {
validate_create_target(root, &path)?;
Ok(PreparedProjectPatchsetChange {
operation: ProjectPatchsetOperation::Create,
path,
before: None,
after: Some(content.into_bytes()),
})
}
ParsedProjectPatchsetChange::Update {
path,
expected_sha256,
old_text,
new_text,
expected_replacements,
} => {
let before = read_project_text_snapshot(root, &path)?;
let actual_sha256 = sha256_hex(&before.content);
if actual_sha256 != expected_sha256 {
return Err(format!(
"project.patchset update expectedSha256 与当前文件不一致:{path}"
));
}
let before_text = std::str::from_utf8(&before.content)
.map_err(|_| format!("project.patchset 目标不是 UTF-8 文本文件:{path}"))?;
let actual_replacements = before_text.matches(&old_text).count();
if actual_replacements != expected_replacements {
return Err(format!(
"project.patchset update 精确匹配数不符:{path},预期 {expected_replacements},实际 {actual_replacements}"
));
}
let after = before_text.replace(&old_text, &new_text).into_bytes();
if after.len() > PROJECT_PATCHSET_MAX_FILE_BYTES {
return Err(patchset_file_size_error(&path));
}
Ok(PreparedProjectPatchsetChange {
operation: ProjectPatchsetOperation::Update,
path,
before: Some(before),
after: Some(after),
})
}
ParsedProjectPatchsetChange::Delete {
path,
expected_sha256,
} => {
let before = read_project_text_snapshot(root, &path)?;
if sha256_hex(&before.content) != expected_sha256 {
return Err(format!(
"project.patchset delete expectedSha256 与当前文件不一致:{path}"
));
}
Ok(PreparedProjectPatchsetChange {
operation: ProjectPatchsetOperation::Delete,
path,
before: Some(before),
after: None,
})
}
}
}
fn project_patchset_change_summary(
change: &PreparedProjectPatchsetChange,
) -> ProjectPatchsetChangeSummary {
let before = change
.before
.as_ref()
.map(|snapshot| snapshot.content.as_slice());
let after = change.after.as_deref();
ProjectPatchsetChangeSummary {
operation: change.operation.as_str().to_string(),
path: change.path.clone(),
before_sha256: before.map(sha256_hex),
after_sha256: after.map(sha256_hex),
before_bytes: before.map_or(0, |content| content.len() as u64),
after_bytes: after.map_or(0, |content| content.len() as u64),
}
}
fn validate_prepared_change_before_apply(
root: &Path,
change: &PreparedProjectPatchsetChange,
) -> Result<(), String> {
reject_sensitive_patchset_path(&change.path)?;
match change.operation {
ProjectPatchsetOperation::Create => validate_create_target(root, &change.path),
ProjectPatchsetOperation::Update | ProjectPatchsetOperation::Delete => {
let expected = change
.before
.as_ref()
.ok_or_else(|| "project.patchset 预检结果缺少原始内容".to_string())?;
let current = read_project_text_snapshot(root, &change.path)?;
if current.content != expected.content {
return Err(format!(
"project.patchset 应用前 SHA-256 已漂移:{}",
change.path
));
}
Ok(())
}
}
}
fn validate_prepared_change_after_apply(
root: &Path,
change: &PreparedProjectPatchsetChange,
) -> Result<(), String> {
match change.operation {
ProjectPatchsetOperation::Create | ProjectPatchsetOperation::Update => {
let expected = change
.after
.as_deref()
.ok_or_else(|| "project.patchset 预检结果缺少修改后内容".to_string())?;
let current = read_project_text_snapshot(root, &change.path)?;
if current.content != expected {
return Err(format!(
"project.patchset 应用后内容校验失败:{}",
change.path
));
}
Ok(())
}
ProjectPatchsetOperation::Delete => match inspect_project_path(root, &change.path)? {
None => Ok(()),
Some(_) => Err(format!(
"project.patchset delete 应用后目标仍存在:{}",
change.path
)),
},
}
}
fn apply_prepared_change(
root: &Path,
change: &PreparedProjectPatchsetChange,
change_index: usize,
) -> Result<AppliedMutationJournal, ProjectPatchsetMutationError> {
match change.operation {
ProjectPatchsetOperation::Create => apply_create_change(root, change, change_index),
ProjectPatchsetOperation::Update => apply_update_change(root, change, change_index),
ProjectPatchsetOperation::Delete => apply_delete_change(root, change, change_index),
}
}
fn apply_create_change(
root: &Path,
change: &PreparedProjectPatchsetChange,
change_index: usize,
) -> Result<AppliedMutationJournal, ProjectPatchsetMutationError> {
let mut journal = AppliedMutationJournal::new(change_index);
if let Err(error) = create_missing_parent_directories(root, &change.path, &mut journal) {
return Err(ProjectPatchsetMutationError {
message: error,
journal,
});
}
if let Err(error) = validate_create_target(root, &change.path) {
return Err(ProjectPatchsetMutationError {
message: error,
journal,
});
}
let content = change.after.as_deref().unwrap_or_default();
let path = root.join(&change.path);
let mut file = match OpenOptions::new().create_new(true).write(true).open(&path) {
Ok(file) => file,
Err(error) => {
return Err(ProjectPatchsetMutationError {
message: format!(
"project.patchset create 创建文件失败:{}: {error}",
change.path
),
journal,
})
}
};
journal.target_touched = true;
if let Err(error) = write_and_sync_file(&mut file, content, false) {
return Err(ProjectPatchsetMutationError {
message: format!(
"project.patchset create 写入文件失败:{}: {error}",
change.path
),
journal,
});
}
journal.operation_completed = true;
Ok(journal)
}
fn apply_update_change(
root: &Path,
change: &PreparedProjectPatchsetChange,
change_index: usize,
) -> Result<AppliedMutationJournal, ProjectPatchsetMutationError> {
let mut journal = AppliedMutationJournal::new(change_index);
let expected = change.before.as_ref().expect("prepared update has before");
let content = change.after.as_deref().expect("prepared update has after");
let path = root.join(&change.path);
let mut file = match open_existing_regular_file_no_follow(&path, true) {
Ok(file) => file,
Err(error) => {
return Err(ProjectPatchsetMutationError {
message: format!(
"project.patchset update 打开文件失败:{}: {error}",
change.path
),
journal,
})
}
};
match read_text_from_open_file(&mut file, &change.path) {
Ok(current) if current == expected.content => {}
Ok(_) => {
return Err(ProjectPatchsetMutationError {
message: format!(
"project.patchset update 写入前 SHA-256 已漂移:{}",
change.path
),
journal,
})
}
Err(error) => {
return Err(ProjectPatchsetMutationError {
message: error,
journal,
})
}
}
if let Err(error) = file.set_len(0) {
return Err(ProjectPatchsetMutationError {
message: format!(
"project.patchset update 清空文件失败:{}: {error}",
change.path
),
journal,
});
}
journal.target_touched = true;
if let Err(error) = write_and_sync_file(&mut file, content, true) {
return Err(ProjectPatchsetMutationError {
message: format!(
"project.patchset update 写入文件失败:{}: {error}",
change.path
),
journal,
});
}
journal.operation_completed = true;
Ok(journal)
}
fn apply_delete_change(
root: &Path,
change: &PreparedProjectPatchsetChange,
change_index: usize,
) -> Result<AppliedMutationJournal, ProjectPatchsetMutationError> {
let mut journal = AppliedMutationJournal::new(change_index);
if let Err(error) = validate_prepared_change_before_apply(root, change) {
return Err(ProjectPatchsetMutationError {
message: error,
journal,
});
}
if let Err(error) = fs::remove_file(root.join(&change.path)) {
return Err(ProjectPatchsetMutationError {
message: format!(
"project.patchset delete 删除文件失败:{}: {error}",
change.path
),
journal,
});
}
journal.target_touched = true;
journal.operation_completed = true;
Ok(journal)
}
fn write_and_sync_file(
file: &mut File,
content: &[u8],
seek_to_start: bool,
) -> std::io::Result<()> {
if seek_to_start {
file.seek(SeekFrom::Start(0))?;
}
file.write_all(content)?;
file.flush()?;
file.sync_all()
}
fn project_patchset_apply_failure(
root: &Path,
prepared: &PreparedProjectPatchset,
journals: Vec<AppliedMutationJournal>,
message: String,
) -> ProjectPatchsetApplyError {
let side_effect_applied = journals.iter().any(AppliedMutationJournal::has_side_effect);
if !side_effect_applied {
return ProjectPatchsetApplyError::before_side_effect(message);
}
let rollback_errors = rollback_applied_changes(root, prepared, &journals);
let rollback_complete = rollback_errors.is_empty();
let message = if rollback_complete {
format!("{message};已完整回滚已应用项")
} else {
format!("{message};回滚不完整:{}", rollback_errors.join(""))
};
ProjectPatchsetApplyError {
message,
side_effect_applied,
rollback_complete,
}
}
fn rollback_applied_changes(
root: &Path,
prepared: &PreparedProjectPatchset,
journals: &[AppliedMutationJournal],
) -> Vec<String> {
let mut errors = Vec::new();
for journal in journals.iter().rev() {
let Some(change) = prepared.changes.get(journal.change_index) else {
errors.push("project.patchset 回滚记录引用了无效变更".to_string());
continue;
};
if let Err(error) = rollback_change_target(root, change, journal) {
errors.push(error);
}
for directory in journal.created_directories.iter().rev() {
match fs::symlink_metadata(&directory.absolute_path) {
Ok(metadata) if metadata_is_link_or_reparse(&metadata) || !metadata.is_dir() => {
errors.push(format!(
"project.patchset 回滚父目录时发现不安全路径:{}",
directory.relative_path
));
}
Ok(_) => {
if let Err(error) = fs::remove_dir(&directory.absolute_path) {
errors.push(format!(
"project.patchset 回滚父目录失败:{}: {error}",
directory.relative_path
));
}
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => errors.push(format!(
"project.patchset 检查回滚父目录失败:{}: {error}",
directory.relative_path
)),
}
}
}
errors
}
fn rollback_change_target(
root: &Path,
change: &PreparedProjectPatchsetChange,
journal: &AppliedMutationJournal,
) -> Result<(), String> {
if !journal.target_touched {
return Ok(());
}
match change.operation {
ProjectPatchsetOperation::Create => rollback_created_file(root, change, journal),
ProjectPatchsetOperation::Update => rollback_updated_file(root, change, journal),
ProjectPatchsetOperation::Delete => rollback_deleted_file(root, change, journal),
}
}
fn rollback_created_file(
root: &Path,
change: &PreparedProjectPatchsetChange,
journal: &AppliedMutationJournal,
) -> Result<(), String> {
let metadata = match inspect_project_path(root, &change.path)? {
Some(metadata) => metadata,
None => return Ok(()),
};
if metadata_is_link_or_reparse(&metadata) || !metadata.is_file() {
return Err(format!(
"project.patchset create 回滚目标不再是普通文件:{}",
change.path
));
}
if journal.operation_completed {
let current = read_project_text_snapshot(root, &change.path)?;
if current.content != change.after.as_deref().unwrap_or_default() {
return Err(format!(
"project.patchset create 回滚前目标已漂移:{}",
change.path
));
}
}
fs::remove_file(root.join(&change.path))
.map_err(|error| format!("project.patchset create 回滚失败:{}: {error}", change.path))
}
fn rollback_updated_file(
root: &Path,
change: &PreparedProjectPatchsetChange,
journal: &AppliedMutationJournal,
) -> Result<(), String> {
if journal.operation_completed {
let current = read_project_text_snapshot(root, &change.path)?;
if current.content != change.after.as_deref().unwrap_or_default() {
return Err(format!(
"project.patchset update 回滚前目标已漂移:{}",
change.path
));
}
} else if let Some(metadata) = inspect_project_path(root, &change.path)? {
if metadata_is_link_or_reparse(&metadata) || !metadata.is_file() {
return Err(format!(
"project.patchset update 回滚目标不再是普通文件:{}",
change.path
));
}
}
let before = change
.before
.as_ref()
.ok_or_else(|| "project.patchset update 回滚缺少原始内容".to_string())?;
restore_project_file(root, &change.path, before)
}
fn rollback_deleted_file(
root: &Path,
change: &PreparedProjectPatchsetChange,
_journal: &AppliedMutationJournal,
) -> Result<(), String> {
if inspect_project_path(root, &change.path)?.is_some() {
return Err(format!(
"project.patchset delete 回滚前目标已被重新创建:{}",
change.path
));
}
let before = change
.before
.as_ref()
.ok_or_else(|| "project.patchset delete 回滚缺少原始内容".to_string())?;
restore_project_file(root, &change.path, before)
}
fn restore_project_file(
root: &Path,
relative_path: &str,
snapshot: &ProjectPatchsetFileSnapshot,
) -> Result<(), String> {
validate_existing_parent_directories(root, relative_path)?;
let path = root.join(relative_path);
let mut file = match inspect_project_path(root, relative_path)? {
Some(metadata) => {
if metadata_is_link_or_reparse(&metadata) || !metadata.is_file() {
return Err(format!(
"project.patchset 回滚目标不是普通文件:{relative_path}"
));
}
open_existing_regular_file_no_follow(&path, true).map_err(|error| {
format!("project.patchset 打开回滚目标失败:{relative_path}: {error}")
})?
}
None => OpenOptions::new()
.create_new(true)
.write(true)
.open(&path)
.map_err(|error| {
format!("project.patchset 重建回滚目标失败:{relative_path}: {error}")
})?,
};
file.set_len(0)
.map_err(|error| format!("project.patchset 清空回滚目标失败:{relative_path}: {error}"))?;
write_and_sync_file(&mut file, &snapshot.content, true)
.map_err(|error| format!("project.patchset 写入回滚目标失败:{relative_path}: {error}"))?;
fs::set_permissions(&path, snapshot.permissions.clone())
.map_err(|error| format!("project.patchset 恢复文件权限失败:{relative_path}: {error}"))?;
Ok(())
}
fn create_missing_parent_directories(
root: &Path,
relative_path: &str,
journal: &mut AppliedMutationJournal,
) -> Result<(), String> {
let components = relative_path.split('/').collect::<Vec<_>>();
let mut current = root.to_path_buf();
let mut relative_parts = Vec::new();
for component in components.iter().take(components.len().saturating_sub(1)) {
validate_directory_metadata(&current, &relative_parts.join("/"))?;
reject_directory_case_collision(&current, component, relative_path)?;
current.push(component);
relative_parts.push((*component).to_string());
match fs::symlink_metadata(&current) {
Ok(metadata) => {
if metadata_is_link_or_reparse(&metadata) || !metadata.is_dir() {
return Err(format!(
"project.patchset 父路径不是安全普通目录:{}",
relative_parts.join("/")
));
}
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
match fs::create_dir(&current) {
Ok(()) => journal.created_directories.push(CreatedProjectDirectory {
relative_path: relative_parts.join("/"),
absolute_path: current.clone(),
}),
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
Err(error) => {
return Err(format!(
"project.patchset 创建父目录失败:{}: {error}",
relative_parts.join("/")
))
}
}
let metadata = fs::symlink_metadata(&current).map_err(|error| {
format!(
"project.patchset 检查新建父目录失败:{}: {error}",
relative_parts.join("/")
)
})?;
if metadata_is_link_or_reparse(&metadata) || !metadata.is_dir() {
return Err(format!(
"project.patchset 新建父目录不安全:{}",
relative_parts.join("/")
));
}
}
Err(error) => {
return Err(format!(
"project.patchset 读取父目录失败:{}: {error}",
relative_parts.join("/")
))
}
}
}
Ok(())
}
fn validate_existing_parent_directories(root: &Path, relative_path: &str) -> Result<(), String> {
let components = relative_path.split('/').collect::<Vec<_>>();
let mut current = root.to_path_buf();
let mut relative_parts = Vec::new();
for component in components.iter().take(components.len().saturating_sub(1)) {
validate_directory_metadata(&current, &relative_parts.join("/"))?;
reject_directory_case_collision(&current, component, relative_path)?;
current.push(component);
relative_parts.push((*component).to_string());
let metadata = fs::symlink_metadata(&current).map_err(|error| {
format!(
"project.patchset 回滚父目录不可用:{}: {error}",
relative_parts.join("/")
)
})?;
if metadata_is_link_or_reparse(&metadata) || !metadata.is_dir() {
return Err(format!(
"project.patchset 回滚父路径不是安全普通目录:{}",
relative_parts.join("/")
));
}
}
Ok(())
}
fn validate_create_target(root: &Path, relative_path: &str) -> Result<(), String> {
match inspect_project_path(root, relative_path)? {
None => Ok(()),
Some(_) => Err(format!(
"project.patchset create 要求目标不存在:{relative_path}"
)),
}
}
fn read_project_text_snapshot(
root: &Path,
relative_path: &str,
) -> Result<ProjectPatchsetFileSnapshot, String> {
let metadata = inspect_project_path(root, relative_path)?
.ok_or_else(|| format!("project.patchset 目标文件不存在:{relative_path}"))?;
if metadata_is_link_or_reparse(&metadata) || !metadata.is_file() {
return Err(format!(
"project.patchset 目标必须是普通文本文件:{relative_path}"
));
}
if metadata.len() > PROJECT_PATCHSET_MAX_FILE_BYTES as u64 {
return Err(patchset_file_size_error(relative_path));
}
let path = root.join(relative_path);
let mut file = open_existing_regular_file_no_follow(&path, false)
.map_err(|error| format!("project.patchset 打开目标文件失败:{relative_path}: {error}"))?;
let opened_metadata = file.metadata().map_err(|error| {
format!("project.patchset 读取目标元数据失败:{relative_path}: {error}")
})?;
if metadata_is_link_or_reparse(&opened_metadata) || !opened_metadata.is_file() {
return Err(format!(
"project.patchset 目标必须是普通文本文件:{relative_path}"
));
}
let content = read_text_from_open_file(&mut file, relative_path)?;
let final_metadata = file.metadata().map_err(|error| {
format!("project.patchset 复核目标元数据失败:{relative_path}: {error}")
})?;
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
if final_metadata.nlink() != 1 {
return Err(format!(
"project.patchset 目标不能是硬链接文件:{relative_path}"
));
}
}
#[cfg(windows)]
crate::runner::validate_windows_regular_file_handle(&file, "project.patchset 目标")?;
if opened_metadata.len() != final_metadata.len() || final_metadata.len() != content.len() as u64
{
return Err(format!(
"project.patchset 读取期间文件发生漂移:{relative_path}"
));
}
Ok(ProjectPatchsetFileSnapshot {
content,
permissions: opened_metadata.permissions(),
})
}
fn read_text_from_open_file(file: &mut File, relative_path: &str) -> Result<Vec<u8>, String> {
file.seek(SeekFrom::Start(0))
.map_err(|error| format!("project.patchset 定位目标文件失败:{relative_path}: {error}"))?;
let mut content = Vec::new();
file.take(PROJECT_PATCHSET_MAX_FILE_BYTES as u64 + 1)
.read_to_end(&mut content)
.map_err(|error| format!("project.patchset 读取目标文件失败:{relative_path}: {error}"))?;
if content.len() > PROJECT_PATCHSET_MAX_FILE_BYTES {
return Err(patchset_file_size_error(relative_path));
}
let text = std::str::from_utf8(&content)
.map_err(|_| format!("project.patchset 目标不是 UTF-8 文本文件:{relative_path}"))?;
if text.contains('\0') {
return Err(format!(
"project.patchset 目标不是普通文本文件:{relative_path}"
));
}
Ok(content)
}
fn open_existing_regular_file_no_follow(path: &Path, write: bool) -> std::io::Result<File> {
let mut options = OpenOptions::new();
options.read(true).write(write);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.custom_flags(libc::O_NOFOLLOW);
}
#[cfg(windows)]
{
use std::os::windows::fs::OpenOptionsExt;
options.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT);
}
let file = options.open(path)?;
let metadata = file.metadata()?;
if metadata_is_link_or_reparse(&metadata) || !metadata.is_file() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"target is not a regular non-link file",
));
}
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
if metadata.nlink() != 1 {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"target must not have hard links",
));
}
}
#[cfg(windows)]
crate::runner::validate_windows_regular_file_handle(&file, "project.patchset 目标")
.map_err(std::io::Error::other)?;
Ok(file)
}
fn inspect_project_path(root: &Path, relative_path: &str) -> Result<Option<Metadata>, String> {
let mut current = root.to_path_buf();
let components = relative_path.split('/').collect::<Vec<_>>();
for (index, component) in components.iter().enumerate() {
let parent_label = components[..index].join("/");
validate_directory_metadata(&current, &parent_label)?;
reject_directory_case_collision(&current, component, relative_path)?;
current.push(component);
match fs::symlink_metadata(&current) {
Ok(metadata) => {
if metadata_is_link_or_reparse(&metadata) {
return Err(format!(
"project.patchset 路径不能包含符号链接或 reparse point{relative_path}"
));
}
if index + 1 < components.len() && !metadata.is_dir() {
return Err(format!(
"project.patchset 父路径不是普通目录:{}",
components[..=index].join("/")
));
}
if index + 1 == components.len() {
return Ok(Some(metadata));
}
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => {
return Err(format!(
"project.patchset 读取项目路径失败:{relative_path}: {error}"
))
}
}
}
Ok(None)
}
fn validate_directory_metadata(path: &Path, relative_path: &str) -> Result<(), String> {
let metadata = fs::symlink_metadata(path).map_err(|error| {
let label = if relative_path.is_empty() {
"项目根目录"
} else {
relative_path
};
format!("project.patchset 读取父目录失败:{label}: {error}")
})?;
if metadata_is_link_or_reparse(&metadata) || !metadata.is_dir() {
let label = if relative_path.is_empty() {
"项目根目录"
} else {
relative_path
};
return Err(format!("project.patchset 父路径不是安全普通目录:{label}"));
}
Ok(())
}
fn reject_directory_case_collision(
directory: &Path,
requested_component: &str,
relative_path: &str,
) -> Result<(), String> {
let entries = fs::read_dir(directory).map_err(|error| {
format!("project.patchset 检查路径大小写失败:{relative_path}: {error}")
})?;
for entry in entries {
let entry = entry.map_err(|error| {
format!("project.patchset 检查路径大小写失败:{relative_path}: {error}")
})?;
let Some(existing_name) = entry.file_name().to_str().map(str::to_string) else {
continue;
};
if existing_name != requested_component
&& portable_patchset_path_key(&existing_name)
== portable_patchset_path_key(requested_component)
{
return Err(format!(
"project.patchset 路径与已有文件存在大小写碰撞:{relative_path}"
));
}
}
Ok(())
}
fn validate_patchset_root(root: &Path) -> Result<PathBuf, String> {
validate_project_root(root)?;
let metadata = fs::symlink_metadata(root)
.map_err(|error| format!("project.patchset 项目目录不可用:{error}"))?;
if metadata_is_link_or_reparse(&metadata) || !metadata.is_dir() {
return Err("project.patchset 项目目录必须是安全普通目录".to_string());
}
let canonical = fs::canonicalize(root)
.map_err(|error| format!("project.patchset 无法规范化项目目录:{error}"))?;
let canonical_metadata = fs::symlink_metadata(&canonical)
.map_err(|error| format!("project.patchset 无法复核项目目录:{error}"))?;
if metadata_is_link_or_reparse(&canonical_metadata) || !canonical_metadata.is_dir() {
return Err("project.patchset 项目目录必须是安全普通目录".to_string());
}
Ok(canonical)
}
fn metadata_is_link_or_reparse(metadata: &Metadata) -> bool {
if metadata.file_type().is_symlink() {
return true;
}
#[cfg(windows)]
{
use std::os::windows::fs::MetadataExt;
return metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0;
}
#[cfg(not(windows))]
{
false
}
}
fn reject_sensitive_patchset_path(relative_path: &str) -> Result<(), String> {
let components = relative_path
.split('/')
.map(str::to_ascii_lowercase)
.collect::<Vec<_>>();
if components.iter().any(|component| {
matches!(
component.as_str(),
".agent"
| ".git"
| ".hg"
| ".svn"
| ".ssh"
| ".aws"
| ".azure"
| ".gnupg"
| ".kube"
| ".docker"
| ".gcloud"
| ".terraform"
| ".password-store"
| ".secrets"
| "secrets"
| "credentials"
| "node_modules"
| "target"
| "dist"
| "build"
| ".next"
| "coverage"
| ".cache"
)
}) {
return Err(format!(
"project.patchset 禁止修改控制面、依赖、构建或敏感目录:{relative_path}"
));
}
let file_name = components
.last()
.ok_or_else(|| "project.patchset 文件路径不能为空".to_string())?;
let sensitive_suffixes = [
".pem",
".key",
".p12",
".pfx",
".ppk",
".jks",
".keystore",
".kdbx",
".db",
".db-wal",
".db-shm",
".sqlite",
".sqlite-wal",
".sqlite-shm",
".sqlite3",
".sqlite3-wal",
".sqlite3-shm",
".sql",
".sql.gz",
".sql.bz2",
".sql.xz",
".dump",
".dump.gz",
".dmp",
".bak",
".mdb",
".accdb",
".rdb",
".bson",
".pgdump",
".tfstate",
".tfstate.backup",
];
let structured_secret_suffixes = [".json", ".txt", ".toml", ".yaml", ".yml"];
let sensitive = file_name == ".env"
|| file_name.starts_with(".env.")
|| file_name.ends_with(".env")
|| file_name == ".envrc"
|| matches!(
file_name.as_str(),
".npmrc"
| ".pypirc"
| ".netrc"
| ".git-credentials"
| ".htpasswd"
| ".vault-token"
| ".bash_history"
| ".zsh_history"
| ".psql_history"
| ".mysql_history"
| "authorized_keys"
| "kubeconfig"
| "credentials"
| "credentials.json"
| "credentials.toml"
| "credentials.yaml"
| "credentials.yml"
| "auth.json"
| "auth.toml"
| "auth.yaml"
| "auth.yml"
| "secrets.json"
| "secrets.toml"
| "secrets.yaml"
| "secrets.yml"
| "client_secret.json"
| "client_secrets.json"
| "service-account.json"
| "service_account.json"
| "application_default_credentials.json"
| "cookies.txt"
| "cookies.json"
| "token"
| "token.txt"
| "token.json"
| "tokens.json"
| "game-creator.config.json"
| "game-creator.config.local.json"
)
|| file_name.starts_with("id_rsa")
|| file_name.starts_with("id_dsa")
|| file_name.starts_with("id_ecdsa")
|| file_name.starts_with("id_ed25519")
|| file_name.starts_with("id_xmss")
|| sensitive_suffixes
.iter()
.any(|suffix| file_name.ends_with(suffix))
|| ((file_name.contains("cookie") || file_name.contains("credential"))
&& structured_secret_suffixes
.iter()
.any(|suffix| file_name.ends_with(suffix)));
if sensitive {
return Err(format!(
"project.patchset 禁止修改敏感配置、密钥或数据库文件:{relative_path}"
));
}
Ok(())
}
fn patchset_file_size_error(relative_path: &str) -> String {
format!(
"project.patchset 文件不能超过 {} MiB{relative_path}",
PROJECT_PATCHSET_MAX_FILE_BYTES / (1024 * 1024)
)
}
fn sha256_hex(content: &[u8]) -> String {
format!("{:x}", Sha256::digest(content))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use tempfile::tempdir;
fn write_text(root: &Path, path: &str, content: &str) {
let target = root.join(path);
if let Some(parent) = target.parent() {
fs::create_dir_all(parent).expect("create test parent");
}
fs::write(target, content).expect("write test file");
}
#[test]
fn project_patchset_applies_create_update_and_delete() {
let temp = tempdir().expect("temp dir");
let root = temp.path();
write_text(root, "game/main.rs", "let value = 1;\nlet value = 1;\n");
write_text(root, "game/obsolete.txt", "remove me\n");
let main_before = fs::read(root.join("game/main.rs")).expect("read main");
let obsolete_before = fs::read(root.join("game/obsolete.txt")).expect("read obsolete");
let input = json!({
"changes": [
{
"operation": "create",
"path": "game/new.txt",
"content": "created\n"
},
{
"operation": "update",
"path": "game/main.rs",
"expectedSha256": sha256_hex(&main_before),
"oldText": "value = 1",
"newText": "value = 2",
"expectedReplacements": 2
},
{
"operation": "delete",
"path": "game/obsolete.txt",
"expectedSha256": sha256_hex(&obsolete_before)
}
]
});
let prepared = prepare_project_patchset_at(root, &input).expect("prepare patchset");
assert_eq!(prepared.len(), 3);
assert!(!prepared.is_empty());
assert_eq!(prepared.summaries()[0].operation(), "create");
assert_eq!(prepared.summaries()[1].path(), "game/main.rs");
assert_eq!(
prepared.summaries()[1].before_bytes(),
main_before.len() as u64
);
assert!(prepared.summaries()[1].before_sha256().is_some());
assert!(prepared.summaries()[1].after_sha256().is_some());
assert_eq!(prepared.summaries()[2].after_bytes(), 0);
let applied = apply_prepared_project_patchset_at(root, &prepared).expect("apply patchset");
assert_eq!(applied.summaries(), prepared.summaries());
assert_eq!(
fs::read_to_string(root.join("game/new.txt")).expect("new file"),
"created\n"
);
assert_eq!(
fs::read_to_string(root.join("game/main.rs")).expect("updated main"),
"let value = 2;\nlet value = 2;\n"
);
assert!(!root.join("game/obsolete.txt").exists());
}
#[test]
fn project_patchset_preflight_failure_writes_nothing() {
let temp = tempdir().expect("temp dir");
let root = temp.path();
write_text(root, "game/main.txt", "alpha\n");
let before = fs::read(root.join("game/main.txt")).expect("read main");
let input = json!({
"changes": [
{
"operation": "create",
"path": "game/new.txt",
"content": "must not be written"
},
{
"operation": "update",
"path": "game/main.txt",
"expectedSha256": sha256_hex(&before),
"oldText": "missing",
"newText": "replacement",
"expectedReplacements": 1
}
]
});
let error = prepare_project_patchset_at(root, &input).expect_err("preflight fails");
assert!(error.contains("精确匹配数不符"));
assert!(!root.join("game/new.txt").exists());
assert_eq!(
fs::read(root.join("game/main.txt")).expect("main unchanged"),
before
);
}
#[test]
fn project_patchset_rejects_sha_drift_before_any_side_effect() {
let temp = tempdir().expect("temp dir");
let root = temp.path();
write_text(root, "game/main.txt", "before\n");
let before = fs::read(root.join("game/main.txt")).expect("read main");
let input = json!({
"changes": [{
"operation": "update",
"path": "game/main.txt",
"expectedSha256": sha256_hex(&before),
"oldText": "before",
"newText": "after",
"expectedReplacements": 1
}]
});
let prepared = prepare_project_patchset_at(root, &input).expect("prepare patchset");
fs::write(root.join("game/main.txt"), "external drift\n").expect("drift file");
let error = apply_prepared_project_patchset_at(root, &prepared).expect_err("drift fails");
assert!(error.message().contains("漂移"));
assert!(!error.side_effect_applied());
assert!(error.rollback_complete());
assert_eq!(
fs::read_to_string(root.join("game/main.txt")).expect("drift preserved"),
"external drift\n"
);
}
#[test]
fn project_patchset_rejects_duplicate_case_collision_and_sensitive_paths() {
let temp = tempdir().expect("temp dir");
let root = temp.path();
let duplicate = json!({
"changes": [
{"operation": "create", "path": "src/a.txt", "content": "a"},
{"operation": "create", "path": "src/a.txt", "content": "b"}
]
});
assert!(prepare_project_patchset_at(root, &duplicate)
.expect_err("duplicate rejected")
.contains("重复"));
let case_collision = json!({
"changes": [
{"operation": "create", "path": "src/State.txt", "content": "a"},
{"operation": "create", "path": "src/state.txt", "content": "b"}
]
});
assert!(prepare_project_patchset_at(root, &case_collision)
.expect_err("case collision rejected")
.contains("大小写碰撞"));
for collision in [
json!({
"changes": [
{"operation": "create", "path": "src/Ä.txt", "content": "a"},
{"operation": "create", "path": "src/ä.txt", "content": "b"}
]
}),
json!({
"changes": [
{"operation": "create", "path": "src/café.txt", "content": "a"},
{"operation": "create", "path": "src/cafe\u{301}.txt", "content": "b"}
]
}),
] {
assert!(prepare_project_patchset_at(root, &collision)
.expect_err("portable Unicode collision rejected")
.contains("碰撞"));
}
for path in [
".agent/runtime/state.json",
".env.local",
"config/private.pem",
"data/runtime.sqlite",
] {
let sensitive = json!({
"changes": [{"operation": "create", "path": path, "content": "secret"}]
});
assert!(
prepare_project_patchset_at(root, &sensitive).is_err(),
"{path}"
);
}
}
#[cfg(unix)]
#[test]
fn project_patchset_rejects_symlinked_parent_without_touching_target() {
use std::os::unix::fs::symlink;
let temp = tempdir().expect("temp dir");
let outside = tempdir().expect("outside dir");
symlink(outside.path(), temp.path().join("linked")).expect("create symlink");
let input = json!({
"changes": [{
"operation": "create",
"path": "linked/escaped.txt",
"content": "must stay inside"
}]
});
let error = prepare_project_patchset_at(temp.path(), &input).expect_err("link rejected");
assert!(error.contains("符号链接") || error.contains("reparse"));
assert!(!outside.path().join("escaped.txt").exists());
}
#[cfg(unix)]
#[test]
fn project_patchset_rejects_hard_linked_targets_without_touching_outside_inode() {
let temp = tempdir().expect("temp dir");
let outside = tempdir().expect("outside dir");
fs::create_dir_all(temp.path().join("game")).expect("create game dir");
let outside_file = outside.path().join("outside.txt");
fs::write(&outside_file, "outside must survive\n").expect("write outside file");
let linked = temp.path().join("game/linked.txt");
fs::hard_link(&outside_file, &linked).expect("create hard link");
let input = json!({
"changes": [{
"operation": "update",
"path": "game/linked.txt",
"expectedSha256": sha256_hex(b"outside must survive\n"),
"oldText": "outside",
"newText": "changed",
"expectedReplacements": 1
}]
});
let error = prepare_project_patchset_at(temp.path(), &input)
.expect_err("hard-linked target rejected");
assert!(error.contains("hard link") || error.contains("硬链接"));
assert_eq!(
fs::read_to_string(&outside_file).expect("read outside file"),
"outside must survive\n"
);
}
#[test]
fn project_patchset_rolls_back_applied_items_when_later_state_drifts() {
let temp = tempdir().expect("temp dir");
let root = temp.path();
write_text(root, "game/first.txt", "first before\n");
write_text(root, "game/second.txt", "second before\n");
let first_before = fs::read(root.join("game/first.txt")).expect("first");
let second_before = fs::read(root.join("game/second.txt")).expect("second");
let input = json!({
"changes": [
{
"operation": "update",
"path": "game/first.txt",
"expectedSha256": sha256_hex(&first_before),
"oldText": "first before",
"newText": "first after",
"expectedReplacements": 1
},
{
"operation": "update",
"path": "game/second.txt",
"expectedSha256": sha256_hex(&second_before),
"oldText": "second before",
"newText": "second after",
"expectedReplacements": 1
}
]
});
let prepared = prepare_project_patchset_at(root, &input).expect("prepare patchset");
let error =
apply_prepared_project_patchset_at_with_hook(root, &prepared, |index, _summary| {
if index == 1 {
fs::write(root.join("game/second.txt"), "external drift\n")
.expect("inject drift");
}
Ok(())
})
.expect_err("second change must fail");
assert!(error.side_effect_applied());
assert!(error.rollback_complete());
assert_eq!(
fs::read(root.join("game/first.txt")).expect("first restored"),
first_before
);
assert_eq!(
fs::read_to_string(root.join("game/second.txt")).expect("drift kept"),
"external drift\n"
);
}
#[test]
fn project_patchset_reports_incomplete_rollback_separately() {
let temp = tempdir().expect("temp dir");
let root = temp.path();
let input = json!({
"changes": [
{"operation": "create", "path": "game/created.txt", "content": "created"},
{"operation": "create", "path": "game/later.txt", "content": "later"}
]
});
let prepared = prepare_project_patchset_at(root, &input).expect("prepare patchset");
let error =
apply_prepared_project_patchset_at_with_hook(root, &prepared, |index, _summary| {
if index == 1 {
fs::remove_file(root.join("game/created.txt")).expect("remove created file");
fs::create_dir(root.join("game/created.txt")).expect("replace with directory");
return Err("injected failure".to_string());
}
Ok(())
})
.expect_err("rollback cannot remove replacement directory");
assert!(error.side_effect_applied());
assert!(!error.rollback_complete());
assert!(error.message().contains("回滚不完整"));
assert!(root.join("game/created.txt").is_dir());
}
#[test]
fn project_patchset_enforces_change_body_and_file_limits() {
let temp = tempdir().expect("temp dir");
let root = temp.path();
let too_many = (0..=PROJECT_PATCHSET_MAX_CHANGES)
.map(|index| {
json!({
"operation": "create",
"path": format!("src/{index}.txt"),
"content": "x"
})
})
.collect::<Vec<_>>();
assert!(prepare_project_patchset_at(root, &json!({"changes": too_many})).is_err());
let oversized_body = "x".repeat(PROJECT_PATCHSET_MAX_TOTAL_BODY_BYTES + 1);
assert!(prepare_project_patchset_at(
root,
&json!({
"changes": [{
"operation": "create",
"path": "src/large.txt",
"content": oversized_body
}]
})
)
.is_err());
write_text(
root,
"src/existing.txt",
&"x".repeat(PROJECT_PATCHSET_MAX_FILE_BYTES + 1),
);
let existing = fs::read(root.join("src/existing.txt")).expect("large existing");
assert!(prepare_project_patchset_at(
root,
&json!({
"changes": [{
"operation": "delete",
"path": "src/existing.txt",
"expectedSha256": sha256_hex(&existing)
}]
})
)
.is_err());
}
}