Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@
> 只把该 commit 的 reachable tree/blob 导入此前不存在的 Maka-owned bare repository,并以确定性零父
> baseline commit 发布 `refs/maka/*`。caller 不能重新提交 source path、HEAD 或 tree identity。

后续的 exact-base successor/ref CAS 由
`gitoxide-successor-ref-cas-data-plane-v1.zh-CN.md` 单独证明;本切片不创建 projection,也不推进 SQLite canonical head。

## 2. Owner 与原子性边界

- repository admission authority 拥有 source path、commit 与 tree identity;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->

# Gitoxide successor/ref CAS 数据面 v1

状态:API-only Draft。该切片不接 Desktop/CLI,不实现 projection,也不宣称 Write/Edit 已经恢复闭环。

## 主要不变量

一个 owner-bound managed-repository capability 只能从它绑定的 exact base commit 构造确定性的单路径 successor;`refs/maka/*` 只有在当前值仍等于 exact base 时才可通过 CAS 前进。调用者不能重新提交 repository path、base commit 或 target ref。

## Owner 与原子性边界

- source-import authority 在成功导入后签发 opaque managed-repository capability,内部绑定 Maka-owned bare repository、accepted ref、base commit 与 base tree;
- 短生命周期 Gitoxide helper 只接受 SHA-1 repository、canonical UTF-8 `/` 路径和不超过 64 MiB 的文本内容;SHA-256 仍在 admission 阶段 fail closed;
- helper 从 immutable base tree 写入 blob、tree 与确定性单父 commit;这些对象在 ref 发布前都不是 accepted truth;
- 唯一线性化点是 `PreviousValue::MustExistAndMatch(base)` 的 ref transaction;CAS 失败不会移动 accepted ref;
- 若响应丢失,而 ref 已等于本次请求确定性计算出的 successor,精确重试返回相同 response,不会再生成一代 successor;
- 成功结果签发下一代 capability,旧 capability 只可用于同一请求的精确重试,不能基于过期 base 发布另一项修改。

## 失败状态与回滚

- ref 已由其他 successor 前进:返回 `base_commit_mismatch`,不覆盖当前 ref;
- helper/config/object/path/content 不满足协议:fail closed,不调用 system Git,不从 `PATH` fallback;
- CAS 前进程退出:新对象可能成为不可达对象,accepted ref 不变,可由后续 GC 回收;
- CAS 后响应丢失:相同请求通过确定性 successor identity 收敛;
- SQLite accepted-head、candidate receipt、projection 与 quarantine 不属于本切片,分别由重建后的 M2.1、M2.2/M2.4 和后续 projection owner 承担。

## 平台能力矩阵

| 平台 | v1 承诺 |
| --- | --- |
| Linux | 短生命周期 helper、exact-base CAS、精确重试;由三平台 workflow 验证 |
| macOS | 同 Linux;不依赖系统 Git 作为生产数据面 |
| Windows | 同 Linux;路径协议统一使用 canonical `/`,反斜杠输入在 helper 前拒绝 |

这里不承诺对同一用户恶意替换 Maka 私有 storage root 的安全隔离;storage-root ownership 与进程级锁由产品 composition 切片负责。

## 后续依赖

1. Gitoxide fresh projection materialization/observation;
2. M1.3 product composition 消费 admission/import/candidate/projection capabilities;
3. 数据面完成后,从最新 `main` 重建 M2.2 candidate durable owner 与 M2.4 Write/Edit 生产闭环。
2 changes: 1 addition & 1 deletion native/gitoxide-helper/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ name = "maka-gitoxide-helper"
path = "src/main.rs"

[dependencies]
gix = { version = "=0.86.0", default-features = false, features = ["sha1", "sha256"] }
gix = { version = "=0.86.0", default-features = false, features = ["sha1", "sha256", "tree-editor"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
unicode-normalization = "0.1"
239 changes: 238 additions & 1 deletion native/gitoxide-helper/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ use serde::{Deserialize, Serialize};
use unicode_normalization::UnicodeNormalization;

const PROTOCOL_VERSION: u8 = 1;
const MAX_REQUEST_BYTES: u64 = 64 * 1024;
const MAX_REQUEST_BYTES: u64 = MAX_IMPORT_FILE_BYTES + 64 * 1024;
const MAX_IMPORT_FILE_BYTES: u64 = 64 * 1024 * 1024;
const MAX_IMPORT_BYTES: u64 = 2 * 1024 * 1024 * 1024;
const MAX_IMPORT_FILES: u64 = 200_000;
Expand Down Expand Up @@ -64,6 +64,14 @@ enum Request {
destination_repository_path: PathBuf,
baseline_ref: String,
},
CreateSuccessor {
protocol_version: u8,
repository_path: PathBuf,
expected_base_commit_oid: String,
target_ref: String,
path: String,
content: String,
},
}

#[derive(Serialize)]
Expand Down Expand Up @@ -96,6 +104,26 @@ enum Response<'a> {
bytes_imported: u64,
},
#[serde(rename_all = "camelCase")]
SuccessorPublished {
protocol_version: u8,
object_format: &'static str,
base_commit_oid: String,
successor_commit_oid: String,
successor_tree_oid: String,
result_blob_oid: String,
target_ref: String,
path: String,
},
#[serde(rename_all = "camelCase")]
SuccessorRejected {
protocol_version: u8,
reason: &'static str,
object_format: &'static str,
expected_base_commit_oid: String,
actual_base_commit_oid: String,
target_ref: String,
},
#[serde(rename_all = "camelCase")]
HelperError {
protocol_version: u8,
reason: &'a str,
Expand Down Expand Up @@ -140,6 +168,23 @@ fn run() -> Result<ExitCode, &'static str> {
baseline_ref,
)
}
Request::CreateSuccessor {
protocol_version,
repository_path,
expected_base_commit_oid,
target_ref,
path,
content,
} => {
assert_protocol_version(protocol_version)?;
create_successor(
repository_path,
expected_base_commit_oid,
target_ref,
path,
content,
)
}
}
}

Expand Down Expand Up @@ -395,6 +440,198 @@ fn copy_source_tree(
Ok(())
}

fn create_successor(
repository_path: PathBuf,
expected_base_commit_oid: String,
target_ref: String,
path: String,
content: String,
) -> Result<ExitCode, &'static str> {
use gix::bstr::ByteSlice;

if !target_ref.starts_with("refs/maka/") {
return Err("target_ref_outside_maka_namespace");
}
if !is_canonical_successor_path(&path) {
return Err("invalid_successor_path");
}
if content.len() as u64 > MAX_IMPORT_FILE_BYTES {
return Err("successor_content_limit_exceeded");
}

let repository = open_repository(repository_path)?;
if repository.object_hash() != gix::hash::Kind::Sha1 {
return Err("unsupported_object_format");
}
let expected_base = gix::hash::ObjectId::from_hex(expected_base_commit_oid.as_bytes())
.map_err(|_| "invalid_base_commit_oid")?;
if expected_base.kind() != gix::hash::Kind::Sha1 {
return Err("invalid_base_commit_oid");
}
let base_tree = repository
.find_commit(expected_base)
.map_err(|_| "base_commit_unavailable")?
.tree_id()
.map_err(|_| "base_tree_unavailable")?
.detach();
let result_blob = repository
.write_blob(content.as_bytes())
.map_err(|_| "blob_write_failed")?
.detach();
let entry_kind = match repository
.find_tree(base_tree)
.map_err(|_| "base_tree_unavailable")?
.lookup_entry_by_path(path.as_str())
.map_err(|_| "base_path_lookup_failed")?
.map(|entry| entry.mode().kind())
{
Some(gix::objs::tree::EntryKind::BlobExecutable) => {
gix::objs::tree::EntryKind::BlobExecutable
}
Some(gix::objs::tree::EntryKind::Blob) | None => gix::objs::tree::EntryKind::Blob,
Some(_) => return Err("unsupported_base_path_kind"),
};
let mut editor = repository
.edit_tree(base_tree)
.map_err(|_| "tree_edit_failed")?;
editor
.upsert(path.as_str(), entry_kind, result_blob)
.map_err(|_| "tree_edit_failed")?;
let successor_tree = editor.write().map_err(|_| "tree_write_failed")?.detach();
validate_managed_tree(&repository, successor_tree, MANAGED_TREE_POLICY_V1)?;
let signature = gix::actor::SignatureRef {
name: b"Maka Workspace Service".as_bstr(),
email: b"workspace@maka.invalid".as_bstr(),
time: "946684800 +0000",
};
let successor_commit = repository
.new_commit_as(
signature,
signature,
"maka managed workspace successor v1",
successor_tree,
[expected_base],
)
.map_err(|_| "commit_write_failed")?
.id()
.detach();

let current = repository
.find_reference(target_ref.as_str())
.map_err(|_| "target_ref_unavailable")?
.into_fully_peeled_id()
.map_err(|_| "target_ref_unavailable")?
.detach();
if current != expected_base && current != successor_commit {
write_response(&Response::SuccessorRejected {
protocol_version: PROTOCOL_VERSION,
reason: "base_commit_mismatch",
object_format: "sha1",
expected_base_commit_oid: expected_base.to_string(),
actual_base_commit_oid: current.to_string(),
target_ref,
});
return Ok(ExitCode::from(3));
}
if current == expected_base {
repository
.reference(
target_ref.as_str(),
successor_commit,
gix::refs::transaction::PreviousValue::MustExistAndMatch(
gix::refs::Target::Object(expected_base),
),
"maka managed workspace successor",
)
.map_err(|_| "successor_publish_failed")?;
}

write_response(&Response::SuccessorPublished {
protocol_version: PROTOCOL_VERSION,
object_format: "sha1",
base_commit_oid: expected_base.to_string(),
successor_commit_oid: successor_commit.to_string(),
successor_tree_oid: successor_tree.to_string(),
result_blob_oid: result_blob.to_string(),
target_ref,
path,
});
Ok(ExitCode::SUCCESS)
}

fn validate_managed_tree(
repository: &gix::Repository,
tree_oid: gix::hash::ObjectId,
policy: ManagedTreePolicy,
) -> Result<ManagedTreeStats, &'static str> {
let mut stats = ManagedTreeStats::default();
validate_managed_tree_inner(repository, tree_oid, "", 0, policy, &mut stats)?;
Ok(stats)
}

fn validate_managed_tree_inner(
repository: &gix::Repository,
tree_oid: gix::hash::ObjectId,
prefix: &str,
depth: u64,
policy: ManagedTreePolicy,
stats: &mut ManagedTreeStats,
) -> Result<(), &'static str> {
stats.enter_tree(depth, policy)?;
let tree = repository
.find_tree(tree_oid)
.map_err(|_| "source_tree_unavailable")?;
for entry in tree.iter() {
let entry = entry.map_err(|_| "source_tree_invalid")?;
let component =
std::str::from_utf8(entry.filename()).map_err(|_| "unsupported_source_path")?;
if !is_supported_source_component(component)
|| component.len() as u64 > policy.max_component_bytes
{
return Err("unsupported_source_path");
}
let relative_path = if prefix.is_empty() {
component.to_owned()
} else {
format!("{prefix}/{component}")
};
stats.observe_entry(&relative_path, policy)?;
match entry.mode().kind() {
gix::objs::tree::EntryKind::Tree => validate_managed_tree_inner(
repository,
entry.object_id(),
&relative_path,
depth.checked_add(1).ok_or("source_tree_depth_exceeded")?,
policy,
stats,
)?,
gix::objs::tree::EntryKind::Blob | gix::objs::tree::EntryKind::BlobExecutable => {
let header = entry.id().header().map_err(|_| "source_blob_unavailable")?;
if header.kind() != gix::objs::Kind::Blob {
return Err("source_blob_invalid");
}
stats.observe_blob(header.size(), policy)?;
}
_ => return Err("unsupported_source_entry_kind"),
}
}
Ok(())
}

fn is_canonical_successor_path(path: &str) -> bool {
path.len() <= 4096
&& !path.is_empty()
&& !path.starts_with('/')
&& !path.contains('\\')
&& !path.contains('\0')
&& path.split('/').all(|component| {
!component.is_empty()
&& component != "."
&& component != ".."
&& !component.eq_ignore_ascii_case(".git")
})
}

fn is_supported_source_component(component: &str) -> bool {
!component.is_empty()
&& component != "."
Expand Down
Loading
Loading