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, pub(crate) after_sha256: Option, 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, summaries: Vec, } 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, } 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) -> 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, permissions: Permissions, } #[derive(Clone, Debug)] struct PreparedProjectPatchsetChange { operation: ProjectPatchsetOperation, path: String, before: Option, after: Option>, } #[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 { 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, } 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 { 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 { apply_prepared_project_patchset_at_with_hook(root, prepared, |_, _| Ok(())) } fn apply_prepared_project_patchset_at_with_hook( root: &Path, prepared: &PreparedProjectPatchset, mut before_change: F, ) -> Result 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, 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 { 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, 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, 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, ) -> Result, 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 { 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::() } fn prepare_project_patchset_change( root: &Path, change: ParsedProjectPatchsetChange, ) -> Result { 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 { 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 { 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 { 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 { 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, 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 { 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::>(); 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(¤t, &relative_parts.join("/"))?; reject_directory_case_collision(¤t, component, relative_path)?; current.push(component); relative_parts.push((*component).to_string()); match fs::symlink_metadata(¤t) { 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(¤t) { 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(¤t).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::>(); 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(¤t, &relative_parts.join("/"))?; reject_directory_case_collision(¤t, component, relative_path)?; current.push(component); relative_parts.push((*component).to_string()); let metadata = fs::symlink_metadata(¤t).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 { 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, 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 { 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, String> { let mut current = root.to_path_buf(); let components = relative_path.split('/').collect::>(); for (index, component) in components.iter().enumerate() { let parent_label = components[..index].join("/"); validate_directory_metadata(¤t, &parent_label)?; reject_directory_case_collision(¤t, component, relative_path)?; current.push(component); match fs::symlink_metadata(¤t) { 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 { 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::>(); 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::>(); 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()); } }