5c3b8ec267
新增受确认的 project.patchset 事务式多文件修改与回滚机制 补充 checkpoint 内容差异和文件内容哈希约束 加强隔离 Agent 路径范围与仓库指纹复核 扩展共享命令契约、真实 LLM E2E 与 Runtime 文档
1890 lines
65 KiB
Rust
1890 lines
65 KiB
Rust
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(¤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::<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(¤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<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(¤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<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());
|
||
}
|
||
}
|