blob: ac2326238ccb7ab5edfc6cc073f12c5f60473ec5 [file]
/*
* 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.
*/
use std::{
collections::HashSet,
fs,
io::{self, Read},
path::{Path, PathBuf},
process::ExitCode,
};
use caseless::Caseless;
use serde::{Deserialize, Serialize};
use unicode_normalization::UnicodeNormalization;
const PROTOCOL_VERSION: u8 = 1;
const MANAGED_TREE_POLICY_VERSION: u8 = 2;
const MAX_REQUEST_BYTES: u64 = 64 * 1024;
const MAX_REPOSITORY_METADATA_BYTES: u64 = 1024 * 1024;
const MAX_REPOSITORY_METADATA_ENTRIES: u64 = 16_384;
const MAX_REPOSITORY_METADATA_DEPTH: u64 = 64;
const MAX_REPOSITORY_PACK_DIRECTORY_ENTRIES: u64 = 1024;
const MAX_GITOXIDE_OBJECT_STORE_SLOTS: u16 = 1024;
const MAX_IMPORT_FILE_BYTES: u64 = 64 * 1024 * 1024;
const MAX_ATTRIBUTES_FILE_BYTES: u64 = 64 * 1024;
const MAX_GIT_ATTRIBUTES_LINE_BYTES_V2: usize = 2048;
const MAX_IMPORT_BYTES: u64 = 2 * 1024 * 1024 * 1024;
const MAX_IMPORT_FILES: u64 = 200_000;
const MAX_COMMIT_OBJECT_BYTES: u64 = 1024 * 1024;
const MAX_SINGLE_TREE_OBJECT_BYTES: u64 = 8 * 1024 * 1024;
const MAX_TOTAL_TREE_OBJECT_BYTES: u64 = 64 * 1024 * 1024;
const MAX_GITOXIDE_OBJECT_ALLOCATION_BYTES: &str = "gitoxide.objects.allocLimit=67108864";
const MANAGED_TREE_POLICY_V2: ManagedTreePolicy = ManagedTreePolicy {
max_depth: 64,
max_tree_visits: 250_000,
max_entries: 400_000,
max_total_path_bytes: 256 * 1024 * 1024,
max_total_folded_path_bytes: 256 * 1024 * 1024,
max_component_bytes: 255,
max_relative_path_bytes: 4096,
max_folded_relative_path_bytes: 4096,
max_files: MAX_IMPORT_FILES,
max_file_bytes: MAX_IMPORT_FILE_BYTES,
max_bytes: MAX_IMPORT_BYTES,
max_commit_object_bytes: MAX_COMMIT_OBJECT_BYTES,
max_single_tree_object_bytes: MAX_SINGLE_TREE_OBJECT_BYTES,
max_total_tree_object_bytes: MAX_TOTAL_TREE_OBJECT_BYTES,
};
const HELPER_ERROR_REASONS_V1: &[&str] = &[
"internal_error_reason_invalid",
"request_read_failed",
"request_too_large",
"invalid_request",
"unsupported_protocol_version",
"repository_metadata_limit_exceeded",
"repository_alternates_unsupported",
"repository_open_failed",
"head_commit_unavailable",
"head_commit_identity_mismatch",
"head_tree_unavailable",
"commit_object_limit_exceeded",
"baseline_commit_write_failed",
"baseline_publish_failed",
"baseline_ref_outside_maka_namespace",
"invalid_baseline_ref",
"import_destination_create_failed",
"import_destination_not_fresh",
"import_destination_object_format_mismatch",
"import_destination_parent_untrusted",
"invalid_source_head_commit_oid",
"source_blob_copy_failed",
"source_blob_identity_mismatch",
"source_blob_invalid",
"source_blob_unavailable",
"source_attributes_limit_exceeded",
"source_byte_limit_exceeded",
"source_file_limit_exceeded",
"source_folded_path_byte_limit_exceeded",
"source_folded_path_length_exceeded",
"source_head_commit_mismatch",
"source_head_commit_identity_mismatch",
"source_head_commit_unavailable",
"source_head_tree_unavailable",
"source_path_collision",
"source_path_byte_limit_exceeded",
"source_path_length_exceeded",
"source_tree_copy_failed",
"source_tree_depth_exceeded",
"source_tree_entry_limit_exceeded",
"source_tree_identity_mismatch",
"source_tree_invalid",
"source_tree_object_byte_limit_exceeded",
"source_tree_object_limit_exceeded",
"source_tree_noncanonical_mode",
"source_tree_not_sorted",
"source_tree_observation_mismatch",
"source_tree_unavailable",
"source_tree_visit_limit_exceeded",
"unsupported_source_entry_kind",
"unsupported_source_attributes",
"unsupported_source_path",
"unsupported_object_format",
"unsupported_managed_tree_policy",
];
#[derive(Deserialize)]
#[serde(
deny_unknown_fields,
tag = "operation",
rename_all = "snake_case",
rename_all_fields = "camelCase"
)]
enum Request {
InspectRepository {
protocol_version: u8,
repository_path: PathBuf,
},
ImportSourceHead {
protocol_version: u8,
source_repository_path: PathBuf,
expected_source_head_commit_oid: String,
destination_repository_path: PathBuf,
baseline_ref: String,
managed_tree_policy_version: u8,
},
}
#[derive(Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
enum Response<'a> {
#[serde(rename_all = "camelCase")]
RepositoryInspected {
protocol_version: u8,
object_format: &'static str,
head_commit_oid: String,
head_tree_oid: String,
},
#[serde(rename_all = "camelCase")]
RepositoryRejected {
protocol_version: u8,
reason: &'static str,
object_format: String,
supported_object_formats: [&'static str; 1],
},
#[serde(rename_all = "camelCase")]
SourceImported {
protocol_version: u8,
object_format: &'static str,
source_head_commit_oid: String,
source_tree_oid: String,
baseline_commit_oid: String,
baseline_tree_oid: String,
baseline_ref: String,
managed_tree_policy_version: u8,
files_imported: u64,
bytes_imported: u64,
},
#[serde(rename_all = "camelCase")]
HelperError {
protocol_version: u8,
reason: &'a str,
},
}
fn main() -> ExitCode {
match run() {
Ok(code) => code,
Err(reason) => {
let reason = if HELPER_ERROR_REASONS_V1.contains(&reason) {
reason
} else {
"internal_error_reason_invalid"
};
write_response(&Response::HelperError {
protocol_version: PROTOCOL_VERSION,
reason,
});
ExitCode::from(1)
}
}
}
fn run() -> Result<ExitCode, &'static str> {
let request = read_request()?;
match request {
Request::InspectRepository {
protocol_version,
repository_path,
} => {
assert_protocol_version(protocol_version)?;
inspect_repository(repository_path)
}
Request::ImportSourceHead {
protocol_version,
source_repository_path,
expected_source_head_commit_oid,
destination_repository_path,
baseline_ref,
managed_tree_policy_version,
} => {
assert_protocol_version(protocol_version)?;
import_source_head(
source_repository_path,
expected_source_head_commit_oid,
destination_repository_path,
baseline_ref,
managed_tree_policy_version,
)
}
}
}
fn assert_protocol_version(protocol_version: u8) -> Result<(), &'static str> {
if protocol_version != PROTOCOL_VERSION {
return Err("unsupported_protocol_version");
}
Ok(())
}
fn inspect_repository(repository_path: PathBuf) -> Result<ExitCode, &'static str> {
let mut metadata_budget = admit_repository_metadata(&repository_path)?;
let repository = match managed_open_options().open(repository_path) {
Ok(repository) => repository.to_thread_local(),
Err(gix::open::Error::Config(gix::config::Error::ConfigTypedString(error)))
if error.key.as_slice() == b"extensions.objectFormat" =>
{
return Ok(reject_unsupported_object_format("unknown".to_owned()));
}
Err(gix::open::Error::Config(gix::config::Error::UnsupportedObjectFormat { .. })) => {
return Ok(reject_unsupported_object_format("unknown".to_owned()));
}
Err(_) => return Err("repository_open_failed"),
};
metadata_budget.observe_opened_repository(&repository)?;
match repository.object_hash() {
gix::hash::Kind::Sha1 => {
let head_id = repository
.head_id()
.map_err(|_| "head_commit_unavailable")?
.detach();
let head = load_verified_object(
&repository,
head_id,
gix::objs::Kind::Commit,
MAX_COMMIT_OBJECT_BYTES,
"head_commit_unavailable",
"head_commit_unavailable",
"commit_object_limit_exceeded",
"head_commit_identity_mismatch",
)?
.try_into_commit()
.map_err(|_| "head_commit_unavailable")?;
let head_commit_oid = head.id().detach().to_string();
let head_tree_oid = head
.tree_id()
.map_err(|_| "head_tree_unavailable")?
.detach()
.to_string();
write_response(&Response::RepositoryInspected {
protocol_version: PROTOCOL_VERSION,
object_format: "sha1",
head_commit_oid,
head_tree_oid,
});
Ok(ExitCode::SUCCESS)
}
gix::hash::Kind::Sha256 => Ok(reject_unsupported_object_format("sha256".to_owned())),
_ => Ok(reject_unsupported_object_format("unknown".to_owned())),
}
}
fn open_repository(repository_path: PathBuf) -> Result<gix::Repository, &'static str> {
let mut metadata_budget = admit_repository_metadata(&repository_path)?;
let repository = managed_open_options()
.open(repository_path)
.map_err(|_| "repository_open_failed")?
.to_thread_local();
metadata_budget.observe_opened_repository(&repository)?;
Ok(repository)
}
fn managed_open_options() -> gix::open::Options {
gix::open::Options::isolated()
.lossy_config(true)
.strict_config(true)
.object_store_slots(gix::odb::store::init::Slots::Given(
MAX_GITOXIDE_OBJECT_STORE_SLOTS,
))
.config_overrides([MAX_GITOXIDE_OBJECT_ALLOCATION_BYTES])
}
fn admit_repository_metadata(
repository_path: &Path,
) -> Result<RepositoryMetadataBudget, &'static str> {
let mut budget = RepositoryMetadataBudget::default();
if repository_path
.file_name()
.is_none_or(|name| name != ".git")
{
let worktree_candidate = repository_path.join(".git");
budget.observe_repository_candidate(&worktree_candidate)?;
if gix::discover::is_git(&worktree_candidate).is_ok() {
return Ok(budget);
}
}
budget.observe_repository_candidate(repository_path)?;
Ok(budget)
}
#[derive(Default)]
struct RepositoryMetadataBudget {
observed_bytes: u64,
observed_entries: u64,
observed_paths: HashSet<PathBuf>,
}
impl RepositoryMetadataBudget {
fn observe_repository_candidate(&mut self, candidate: &Path) -> Result<(), &'static str> {
match fs::symlink_metadata(candidate) {
Ok(metadata) if metadata.file_type().is_symlink() => Err("repository_open_failed"),
Ok(metadata) if metadata.is_file() => {
self.observe_file_with_metadata(candidate, &metadata)?;
let git_dir = gix::discover::path::from_gitdir_file(candidate)
.map_err(|_| "repository_open_failed")?;
self.observe_git_dir(&git_dir)
}
Ok(metadata) if metadata.is_dir() => self.observe_git_dir(candidate),
Ok(_) => Err("repository_open_failed"),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
Err(_) => Err("repository_open_failed"),
}
}
fn observe_opened_repository(
&mut self,
repository: &gix::Repository,
) -> Result<(), &'static str> {
self.observe_git_dir(repository.git_dir())?;
self.observe_common_dir(repository.common_dir())
}
fn observe_git_dir(&mut self, git_dir: &Path) -> Result<(), &'static str> {
self.observe_optional_file(&git_dir.join("HEAD"))?;
self.observe_optional_file(&git_dir.join("gitdir"))?;
self.observe_optional_file(&git_dir.join("config.worktree"))?;
self.observe_optional_file(&git_dir.join("packed-refs"))?;
self.observe_optional_file(&git_dir.join("shallow"))?;
self.observe_tree(&git_dir.join("refs"), 0)?;
let common_dir_path_file = git_dir.join("commondir");
self.observe_optional_file(&common_dir_path_file)?;
let common_dir = match gix::discover::path::from_plain_file(&common_dir_path_file) {
Some(Ok(relative_or_absolute)) => git_dir.join(relative_or_absolute),
Some(Err(_)) => return Err("repository_open_failed"),
None => git_dir.to_path_buf(),
};
self.observe_common_dir(&common_dir)
}
fn observe_common_dir(&mut self, common_dir: &Path) -> Result<(), &'static str> {
self.observe_optional_file(&common_dir.join("config"))?;
self.observe_optional_file(&common_dir.join("packed-refs"))?;
self.observe_optional_file(&common_dir.join("shallow"))?;
self.reject_alternates(&common_dir.join("objects/info/alternates"))?;
self.reject_alternates(&common_dir.join("objects/info/http-alternates"))?;
self.observe_pack_directory(&common_dir.join("objects/pack"))?;
self.observe_tree(&common_dir.join("refs"), 0)
}
fn reject_alternates(&self, path: &Path) -> Result<(), &'static str> {
match fs::symlink_metadata(path) {
Ok(_) => Err("repository_alternates_unsupported"),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
Err(_) => Err("repository_open_failed"),
}
}
fn observe_pack_directory(&mut self, path: &Path) -> Result<(), &'static str> {
match fs::symlink_metadata(path) {
Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_dir() => {
return Err("repository_open_failed");
}
Ok(_) => self.observe_entry(path, 0)?,
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()),
Err(_) => return Err("repository_open_failed"),
}
let entries = fs::read_dir(path).map_err(|_| "repository_open_failed")?;
let mut pack_entries = 0u64;
for entry in entries {
let entry = entry.map_err(|_| "repository_open_failed")?;
pack_entries = pack_entries
.checked_add(1)
.filter(|entries| *entries <= MAX_REPOSITORY_PACK_DIRECTORY_ENTRIES)
.ok_or("repository_metadata_limit_exceeded")?;
let entry_path = entry.path();
let metadata =
fs::symlink_metadata(&entry_path).map_err(|_| "repository_open_failed")?;
if metadata.file_type().is_symlink() || !metadata.is_file() {
return Err("repository_open_failed");
}
let path_bytes = u64::try_from(entry.file_name().as_encoded_bytes().len())
.map_err(|_| "repository_metadata_limit_exceeded")?;
self.observe_entry(&entry_path, path_bytes)?;
}
Ok(())
}
fn observe_tree(&mut self, path: &Path, depth: u64) -> Result<(), &'static str> {
if depth > MAX_REPOSITORY_METADATA_DEPTH {
return Err("repository_metadata_limit_exceeded");
}
let entries = match fs::read_dir(path) {
Ok(entries) => entries,
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()),
Err(_) => return Err("repository_open_failed"),
};
for entry in entries {
let entry = entry.map_err(|_| "repository_open_failed")?;
let entry_path = entry.path();
let metadata =
fs::symlink_metadata(&entry_path).map_err(|_| "repository_open_failed")?;
if metadata.file_type().is_symlink() {
return Err("repository_open_failed");
}
if metadata.is_dir() {
self.observe_entry(&entry_path, 0)?;
self.observe_tree(&entry_path, depth + 1)?;
} else if metadata.is_file() {
self.observe_file_with_metadata(&entry_path, &metadata)?;
} else {
return Err("repository_open_failed");
}
}
Ok(())
}
fn observe_optional_file(&mut self, path: &Path) -> Result<(), &'static str> {
match fs::symlink_metadata(path) {
Ok(metadata) if metadata.file_type().is_symlink() => Err("repository_open_failed"),
Ok(metadata) if metadata.is_file() => self.observe_file_with_metadata(path, &metadata),
Ok(_) => Err("repository_open_failed"),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
Err(_) => Err("repository_open_failed"),
}
}
fn observe_file_with_metadata(
&mut self,
path: &Path,
metadata: &fs::Metadata,
) -> Result<(), &'static str> {
self.observe_entry(path, metadata.len())
}
fn observe_entry(&mut self, path: &Path, bytes: u64) -> Result<(), &'static str> {
if !self.observed_paths.insert(path.to_path_buf()) {
return Ok(());
}
self.observed_entries = self
.observed_entries
.checked_add(1)
.filter(|entries| *entries <= MAX_REPOSITORY_METADATA_ENTRIES)
.ok_or("repository_metadata_limit_exceeded")?;
self.observed_bytes = self
.observed_bytes
.checked_add(bytes)
.filter(|observed| *observed <= MAX_REPOSITORY_METADATA_BYTES)
.ok_or("repository_metadata_limit_exceeded")?;
Ok(())
}
}
fn import_source_head(
source_repository_path: PathBuf,
expected_source_head_commit_oid: String,
destination_repository_path: PathBuf,
baseline_ref: String,
managed_tree_policy_version: u8,
) -> Result<ExitCode, &'static str> {
use gix::bstr::ByteSlice;
if !baseline_ref.starts_with("refs/maka/") {
return Err("baseline_ref_outside_maka_namespace");
}
gix::refs::FullName::try_from(baseline_ref.as_str()).map_err(|_| "invalid_baseline_ref")?;
if managed_tree_policy_version != MANAGED_TREE_POLICY_VERSION {
return Err("unsupported_managed_tree_policy");
}
let source = open_repository(source_repository_path)?;
if source.object_hash() != gix::hash::Kind::Sha1 {
return Err("unsupported_object_format");
}
let expected_source_head =
gix::hash::ObjectId::from_hex(expected_source_head_commit_oid.as_bytes())
.map_err(|_| "invalid_source_head_commit_oid")?;
if expected_source_head.kind() != gix::hash::Kind::Sha1 {
return Err("invalid_source_head_commit_oid");
}
let source_head_id = source
.head_id()
.map_err(|_| "source_head_commit_unavailable")?;
if source_head_id.detach() != expected_source_head {
return Err("source_head_commit_mismatch");
}
let source_head = load_verified_object(
&source,
expected_source_head,
gix::objs::Kind::Commit,
MANAGED_TREE_POLICY_V2.max_commit_object_bytes,
"source_head_commit_unavailable",
"source_head_commit_unavailable",
"commit_object_limit_exceeded",
"source_head_commit_identity_mismatch",
)?
.try_into_commit()
.map_err(|_| "source_head_commit_unavailable")?;
let source_tree = source_head
.tree_id()
.map_err(|_| "source_head_tree_unavailable")?
.detach();
let mut stats = ManagedTreeStats::default();
walk_verified_source_tree(
&source,
None,
source_tree,
"",
0,
MANAGED_TREE_POLICY_V2,
&mut stats,
)?;
let expected_files = stats.files;
let expected_bytes = stats.bytes;
drop(stats);
assert_import_destination_parent(&destination_repository_path)?;
let destination = claim_fresh_import_destination(&destination_repository_path)?;
if destination.object_hash() != gix::hash::Kind::Sha1 {
return Err("import_destination_object_format_mismatch");
}
let mut copy_stats = ManagedTreeStats::default();
walk_verified_source_tree(
&source,
Some(&destination),
source_tree,
"",
0,
MANAGED_TREE_POLICY_V2,
&mut copy_stats,
)?;
if copy_stats.files != expected_files || copy_stats.bytes != expected_bytes {
return Err("source_tree_observation_mismatch");
}
let signature = gix::actor::SignatureRef {
name: b"Maka Workspace Service".as_bstr(),
email: b"workspace@maka.invalid".as_bstr(),
time: "946684800 +0000",
};
let baseline_commit = destination
.new_commit_as(
signature,
signature,
"maka managed workspace baseline v2",
source_tree,
std::iter::empty::<gix::hash::ObjectId>(),
)
.map_err(|_| "baseline_commit_write_failed")?
.id()
.detach();
destination
.reference(
baseline_ref.as_str(),
baseline_commit,
gix::refs::transaction::PreviousValue::MustNotExist,
"maka managed workspace baseline",
)
.map_err(|_| "baseline_publish_failed")?;
write_response(&Response::SourceImported {
protocol_version: PROTOCOL_VERSION,
object_format: "sha1",
source_head_commit_oid: expected_source_head.to_string(),
source_tree_oid: source_tree.to_string(),
baseline_commit_oid: baseline_commit.to_string(),
baseline_tree_oid: source_tree.to_string(),
baseline_ref,
managed_tree_policy_version: MANAGED_TREE_POLICY_VERSION,
files_imported: copy_stats.files,
bytes_imported: copy_stats.bytes,
});
Ok(ExitCode::SUCCESS)
}
fn claim_fresh_import_destination(path: &Path) -> Result<gix::Repository, &'static str> {
match fs::create_dir(path) {
Ok(()) => {}
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
return Err("import_destination_not_fresh");
}
Err(_) => return Err("import_destination_create_failed"),
}
gix::ThreadSafeRepository::init_opts(
path,
gix::create::Kind::Bare,
gix::create::Options {
destination_must_be_empty: Some(true),
object_hash: Some(gix::hash::Kind::Sha1),
..Default::default()
},
managed_open_options(),
)
.map_err(|_| "import_destination_create_failed")
.map(|repository| repository.to_thread_local())
}
fn load_verified_object<'repo>(
repository: &'repo gix::Repository,
object_id: gix::hash::ObjectId,
expected_kind: gix::objs::Kind,
max_bytes: u64,
unavailable_reason: &'static str,
invalid_reason: &'static str,
limit_reason: &'static str,
identity_reason: &'static str,
) -> Result<gix::Object<'repo>, &'static str> {
let header = repository
.find_header(object_id)
.map_err(|_| unavailable_reason)?;
if header.kind() != expected_kind {
return Err(invalid_reason);
}
if header.size() > max_bytes {
return Err(limit_reason);
}
let object = repository
.find_object(object_id)
.map_err(|_| unavailable_reason)?;
if object.kind != expected_kind {
return Err(invalid_reason);
}
if object.data.len() as u64 > max_bytes {
return Err(limit_reason);
}
gix::objs::Data::new(&object.data, object.kind, object.id.kind())
.verify_checksum(object_id.as_ref())
.map_err(|_| identity_reason)?;
Ok(object)
}
fn assert_import_destination_parent(destination: &Path) -> Result<(), &'static str> {
if !destination.is_absolute() {
return Err("import_destination_parent_untrusted");
}
let parent = destination
.parent()
.ok_or("import_destination_parent_untrusted")?;
let mut ancestors = parent.ancestors().collect::<Vec<_>>();
ancestors.reverse();
for ancestor in ancestors {
if ancestor.as_os_str().is_empty() {
continue;
}
let metadata =
fs::symlink_metadata(ancestor).map_err(|_| "import_destination_parent_untrusted")?;
if metadata.file_type().is_symlink() || is_windows_reparse_point(&metadata) {
return Err("import_destination_parent_untrusted");
}
}
Ok(())
}
#[cfg(windows)]
fn is_windows_reparse_point(metadata: &fs::Metadata) -> bool {
use std::os::windows::fs::MetadataExt;
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400;
metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
}
#[cfg(not(windows))]
fn is_windows_reparse_point(_metadata: &fs::Metadata) -> bool {
false
}
fn walk_verified_source_tree(
source: &gix::Repository,
destination: Option<&gix::Repository>,
tree_oid: gix::hash::ObjectId,
prefix: &str,
depth: u64,
policy: ManagedTreePolicy,
stats: &mut ManagedTreeStats,
) -> Result<(), &'static str> {
let tree = load_verified_object(
source,
tree_oid,
gix::objs::Kind::Tree,
policy.max_single_tree_object_bytes,
"source_tree_unavailable",
"source_tree_invalid",
"source_tree_object_limit_exceeded",
"source_tree_identity_mismatch",
)?
.try_into_tree()
.map_err(|_| "source_tree_invalid")?;
stats.enter_tree(depth, tree.data.len() as u64, policy)?;
assert_canonical_tree_modes(&tree.data)?;
let mut previous_entry: Option<(Vec<u8>, bool)> = None;
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 is_tree = matches!(entry.mode().kind(), gix::objs::tree::EntryKind::Tree);
if previous_entry.as_ref().is_some_and(|(name, tree)| {
compare_git_tree_entry_names(name, *tree, entry.filename(), is_tree)
!= std::cmp::Ordering::Less
}) {
return Err("source_tree_not_sorted");
}
previous_entry = Some((entry.filename().to_vec(), is_tree));
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 => {
walk_verified_source_tree(
source,
destination,
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 blob_oid = entry.object_id();
let is_attributes = component == ".gitattributes";
let blob = load_verified_object(
source,
blob_oid,
gix::objs::Kind::Blob,
if is_attributes {
MAX_ATTRIBUTES_FILE_BYTES
} else {
policy.max_file_bytes
},
"source_blob_unavailable",
"source_blob_invalid",
if is_attributes {
"source_attributes_limit_exceeded"
} else {
"source_file_limit_exceeded"
},
"source_blob_identity_mismatch",
)?
.try_into_blob()
.map_err(|_| "source_blob_invalid")?;
if is_attributes {
validate_managed_attributes_v2(&blob.data)?;
}
stats.observe_blob(blob.data.len() as u64, policy)?;
if let Some(destination) = destination {
let copied_blob = destination
.write_blob(&blob.data)
.map_err(|_| "source_blob_copy_failed")?
.detach();
if copied_blob != blob_oid {
return Err("source_blob_identity_mismatch");
}
}
}
_ => return Err("unsupported_source_entry_kind"),
}
}
if let Some(destination) = destination {
let copied_tree = destination
.write_object(tree.decode().map_err(|_| "source_tree_invalid")?)
.map_err(|_| "source_tree_copy_failed")?
.detach();
if copied_tree != tree_oid {
return Err("source_tree_identity_mismatch");
}
}
Ok(())
}
fn compare_git_tree_entry_names(
left_name: &[u8],
left_is_tree: bool,
right_name: &[u8],
right_is_tree: bool,
) -> std::cmp::Ordering {
let common = left_name.len().min(right_name.len());
match left_name[..common].cmp(&right_name[..common]) {
std::cmp::Ordering::Equal => {
let left_suffix =
left_name
.get(common)
.copied()
.unwrap_or(if left_is_tree { b'/' } else { 0 });
let right_suffix =
right_name
.get(common)
.copied()
.unwrap_or(if right_is_tree { b'/' } else { 0 });
left_suffix.cmp(&right_suffix)
}
ordering => ordering,
}
}
fn assert_canonical_tree_modes(mut data: &[u8]) -> Result<(), &'static str> {
const SHA1_OID_BYTES: usize = 20;
while !data.is_empty() {
let mode_end = data
.iter()
.position(|byte| *byte == b' ')
.ok_or("source_tree_invalid")?;
let mode = &data[..mode_end];
if mode != b"40000" && mode != b"100644" && mode != b"100755" {
return Err("source_tree_noncanonical_mode");
}
data = &data[mode_end + 1..];
let filename_end = data
.iter()
.position(|byte| *byte == 0)
.ok_or("source_tree_invalid")?;
data = &data[filename_end + 1..];
if data.len() < SHA1_OID_BYTES {
return Err("source_tree_invalid");
}
data = &data[SHA1_OID_BYTES..];
}
Ok(())
}
fn is_supported_source_component(component: &str) -> bool {
let folded_component = fold_managed_path_v2(component);
!component.is_empty()
&& component != "."
&& component != ".."
&& !component.contains('/')
&& !component.contains('\\')
&& !component.contains('\0')
&& !component
.chars()
.any(|character| character <= '\u{001f}' || "<>:\"|?*".contains(character))
&& !component.ends_with('.')
&& !component.ends_with(' ')
&& !is_windows_reserved_device_name(component)
&& folded_component != ".git"
&& (component == ".gitattributes" || folded_component != ".gitattributes")
}
fn fold_managed_path_v2(value: &str) -> String {
value.nfc().default_case_fold().nfc().collect()
}
fn validate_managed_attributes_v2(data: &[u8]) -> Result<(), &'static str> {
let content = std::str::from_utf8(data).map_err(|_| "unsupported_source_attributes")?;
for raw_line in content.split('\n') {
if raw_line.len() >= MAX_GIT_ATTRIBUTES_LINE_BYTES_V2 {
return Err("unsupported_source_attributes");
}
if raw_line.is_empty() {
continue;
}
if let Some(comment) = raw_line.strip_prefix('#') {
if comment.chars().any(char::is_control) {
return Err("unsupported_source_attributes");
}
continue;
}
if raw_line == "* text=auto eol=lf" {
continue;
}
if raw_line
.strip_suffix(" export-ignore")
.is_some_and(is_portable_export_ignore_pattern_v2)
{
continue;
}
return Err("unsupported_source_attributes");
}
Ok(())
}
fn is_portable_export_ignore_pattern_v2(pattern: &str) -> bool {
if !pattern.starts_with('/') || pattern.len() <= 1 || pattern.len() > 4096 {
return false;
}
pattern[1..]
.split('/')
.all(is_literal_attributes_component_v2)
}
fn is_literal_attributes_component_v2(component: &str) -> bool {
!component.is_empty()
&& is_supported_source_component(component)
&& !component.contains(['[', ']'])
&& !component
.chars()
.any(|character| character.is_whitespace() || character.is_control())
}
fn is_windows_reserved_device_name(component: &str) -> bool {
let stem = component.split('.').next().unwrap_or_default();
let folded = stem.to_ascii_uppercase();
matches!(
folded.as_str(),
"CON" | "PRN" | "AUX" | "NUL" | "CONIN$" | "CONOUT$"
) || folded
.strip_prefix("COM")
.is_some_and(|suffix| matches!(suffix, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"))
|| folded.strip_prefix("LPT").is_some_and(|suffix| {
matches!(suffix, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9")
})
|| matches!(
folded.as_str(),
"COM¹" | "COM²" | "COM³" | "LPT¹" | "LPT²" | "LPT³"
)
}
#[derive(Clone, Copy)]
struct ManagedTreePolicy {
max_depth: u64,
max_tree_visits: u64,
max_entries: u64,
max_total_path_bytes: u64,
max_total_folded_path_bytes: u64,
max_component_bytes: u64,
max_relative_path_bytes: u64,
max_folded_relative_path_bytes: u64,
max_files: u64,
max_file_bytes: u64,
max_bytes: u64,
max_commit_object_bytes: u64,
max_single_tree_object_bytes: u64,
max_total_tree_object_bytes: u64,
}
#[derive(Default)]
struct ManagedTreeStats {
tree_visits: u64,
entries: u64,
total_path_bytes: u64,
total_folded_path_bytes: u64,
files: u64,
bytes: u64,
tree_object_bytes: u64,
folded_paths: HashSet<String>,
}
impl ManagedTreeStats {
fn enter_tree(
&mut self,
depth: u64,
object_bytes: u64,
policy: ManagedTreePolicy,
) -> Result<(), &'static str> {
if depth > policy.max_depth {
return Err("source_tree_depth_exceeded");
}
self.tree_visits = self
.tree_visits
.checked_add(1)
.filter(|visits| *visits <= policy.max_tree_visits)
.ok_or("source_tree_visit_limit_exceeded")?;
self.tree_object_bytes = self
.tree_object_bytes
.checked_add(object_bytes)
.filter(|bytes| *bytes <= policy.max_total_tree_object_bytes)
.ok_or("source_tree_object_byte_limit_exceeded")?;
Ok(())
}
fn observe_entry(
&mut self,
relative_path: &str,
policy: ManagedTreePolicy,
) -> Result<(), &'static str> {
let path_bytes = relative_path.len() as u64;
if path_bytes > policy.max_relative_path_bytes {
return Err("source_path_length_exceeded");
}
self.entries = self
.entries
.checked_add(1)
.filter(|entries| *entries <= policy.max_entries)
.ok_or("source_tree_entry_limit_exceeded")?;
self.total_path_bytes = self
.total_path_bytes
.checked_add(path_bytes)
.filter(|bytes| *bytes <= policy.max_total_path_bytes)
.ok_or("source_path_byte_limit_exceeded")?;
let folded_path = fold_managed_path_v2(relative_path);
let folded_path_bytes = folded_path.len() as u64;
if folded_path_bytes > policy.max_folded_relative_path_bytes {
return Err("source_folded_path_length_exceeded");
}
self.total_folded_path_bytes = self
.total_folded_path_bytes
.checked_add(folded_path_bytes)
.filter(|bytes| *bytes <= policy.max_total_folded_path_bytes)
.ok_or("source_folded_path_byte_limit_exceeded")?;
if !self.folded_paths.insert(folded_path) {
return Err("source_path_collision");
}
Ok(())
}
fn observe_blob(&mut self, size: u64, policy: ManagedTreePolicy) -> Result<(), &'static str> {
if size > policy.max_file_bytes {
return Err("source_file_limit_exceeded");
}
self.files = self
.files
.checked_add(1)
.filter(|files| *files <= policy.max_files)
.ok_or("source_file_limit_exceeded")?;
self.bytes = self
.bytes
.checked_add(size)
.filter(|bytes| *bytes <= policy.max_bytes)
.ok_or("source_byte_limit_exceeded")?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn tiny_policy() -> ManagedTreePolicy {
ManagedTreePolicy {
max_depth: 1,
max_tree_visits: 2,
max_entries: 2,
max_total_path_bytes: 5,
max_total_folded_path_bytes: 5,
max_component_bytes: 3,
max_relative_path_bytes: 4,
max_folded_relative_path_bytes: 4,
max_files: 1,
max_file_bytes: 3,
max_bytes: 3,
max_commit_object_bytes: 3,
max_single_tree_object_bytes: 3,
max_total_tree_object_bytes: 5,
}
}
#[test]
fn managed_tree_budget_bounds_depth_visits_and_entries() {
let policy = tiny_policy();
let mut stats = ManagedTreeStats::default();
assert_eq!(stats.enter_tree(0, 2, policy), Ok(()));
assert_eq!(stats.enter_tree(1, 3, policy), Ok(()));
assert_eq!(
stats.enter_tree(1, 0, policy),
Err("source_tree_visit_limit_exceeded")
);
let mut stats = ManagedTreeStats::default();
assert_eq!(
stats.enter_tree(2, 0, policy),
Err("source_tree_depth_exceeded")
);
assert_eq!(stats.observe_entry("a", policy), Ok(()));
assert_eq!(stats.observe_entry("bb", policy), Ok(()));
assert_eq!(
stats.observe_entry("c", policy),
Err("source_tree_entry_limit_exceeded")
);
}
#[test]
fn managed_tree_budget_bounds_single_and_total_tree_object_bytes() {
let policy = tiny_policy();
let mut stats = ManagedTreeStats::default();
assert_eq!(stats.enter_tree(0, 3, policy), Ok(()));
assert_eq!(
stats.enter_tree(1, 3, policy),
Err("source_tree_object_byte_limit_exceeded")
);
}
#[test]
fn managed_tree_budget_bounds_paths_and_blob_bytes() {
let policy = tiny_policy();
let mut stats = ManagedTreeStats::default();
assert_eq!(
stats.observe_entry("abcde", policy),
Err("source_path_length_exceeded")
);
assert_eq!(stats.observe_entry("abc", policy), Ok(()));
assert_eq!(
stats.observe_entry("def", policy),
Err("source_path_byte_limit_exceeded")
);
let mut stats = ManagedTreeStats::default();
assert_eq!(
stats.observe_blob(4, policy),
Err("source_file_limit_exceeded")
);
assert_eq!(stats.observe_blob(3, policy), Ok(()));
assert_eq!(
stats.observe_blob(1, policy),
Err("source_file_limit_exceeded")
);
}
#[test]
fn managed_tree_policy_rejects_non_portable_components() {
for component in [
"CON",
"NUL.txt",
"COM1.log",
"LPT9",
"a:b",
"control\u{001f}",
"trailing.",
"trailing ",
".git.",
".git ",
"com¹",
"lpt².txt",
] {
assert!(
!is_supported_source_component(component),
"component must be rejected by portable policy: {component:?}"
);
}
}
#[test]
fn managed_tree_policy_uses_full_unicode_casefold_after_nfc() {
let policy = MANAGED_TREE_POLICY_V2;
for (first, second) in [
("Σ.txt", "ς.txt"),
("STRASSE.txt", "Straße.txt"),
("é.txt", "e\u{301}.txt"),
] {
let mut stats = ManagedTreeStats::default();
assert_eq!(stats.observe_entry(first, policy), Ok(()));
assert_eq!(
stats.observe_entry(second, policy),
Err("source_path_collision"),
"paths must collide under the versioned fold key: {first:?}, {second:?}"
);
}
}
#[test]
fn managed_tree_policy_bounds_folded_keys_independently() {
let policy = ManagedTreePolicy {
max_relative_path_bytes: 2,
max_folded_relative_path_bytes: 2,
..MANAGED_TREE_POLICY_V2
};
let mut stats = ManagedTreeStats::default();
assert_eq!(
stats.observe_entry("İ", policy),
Err("source_folded_path_length_exceeded")
);
let policy = ManagedTreePolicy {
max_total_path_bytes: 3,
max_total_folded_path_bytes: 3,
..MANAGED_TREE_POLICY_V2
};
let mut stats = ManagedTreeStats::default();
assert_eq!(stats.observe_entry("İ", policy), Ok(()));
assert_eq!(
stats.observe_entry("A", policy),
Err("source_folded_path_byte_limit_exceeded")
);
}
#[test]
fn managed_attributes_v2_accepts_only_the_deterministic_subset() {
assert_eq!(
validate_managed_attributes_v2(
b"# portable Maka attributes\n\n* text=auto eol=lf\n/docs export-ignore\n"
),
Ok(())
);
for unsupported in [
b"*.bin filter=lfs\n".as_slice(),
b"* working-tree-encoding=UTF-16\n".as_slice(),
b"* ident\n".as_slice(),
b"* text=auto eol=crlf\n".as_slice(),
b"/*.log export-ignore\n".as_slice(),
b"* text=auto eol=lf unknown\n".as_slice(),
&[0xff, b'\n'],
] {
assert_eq!(
validate_managed_attributes_v2(unsupported),
Err("unsupported_source_attributes")
);
}
}
#[test]
fn managed_attributes_v2_rejects_bracket_globs() {
for unsupported in [
b"/[ab].txt export-ignore\n".as_slice(),
b"/file[0-9].txt export-ignore\n".as_slice(),
] {
assert_eq!(
validate_managed_attributes_v2(unsupported),
Err("unsupported_source_attributes")
);
}
}
#[test]
fn managed_attributes_v2_rejects_non_git_blank_characters() {
for unsupported in [
"\u{00a0}* text=auto eol=lf\n".as_bytes(),
b"\x0b* text=auto eol=lf\n".as_slice(),
b"\x0c* text=auto eol=lf\n".as_slice(),
] {
assert_eq!(
validate_managed_attributes_v2(unsupported),
Err("unsupported_source_attributes")
);
}
}
#[test]
fn managed_attributes_v2_matches_gits_line_byte_limit() {
let accepted = format!("/{} export-ignore", "a".repeat(2032));
assert_eq!(accepted.len(), 2047);
assert_eq!(validate_managed_attributes_v2(accepted.as_bytes()), Ok(()));
let rejected = format!("/{} export-ignore", "a".repeat(2033));
assert_eq!(rejected.len(), 2048);
assert_eq!(
validate_managed_attributes_v2(rejected.as_bytes()),
Err("unsupported_source_attributes")
);
}
#[test]
fn managed_attributes_v2_rejects_noncanonical_serializations() {
for unsupported in [
b"* eol=lf text=auto\n".as_slice(),
b" * text=auto eol=lf\n".as_slice(),
b"* text=auto eol=lf\n".as_slice(),
b"* text=auto eol=lf \n".as_slice(),
b"* text=auto eol=lf\r\n".as_slice(),
b"/docs export-ignore\n".as_slice(),
b" /docs export-ignore\n".as_slice(),
] {
assert_eq!(
validate_managed_attributes_v2(unsupported),
Err("unsupported_source_attributes")
);
}
}
#[test]
fn canonical_tree_order_uses_gits_directory_suffix_rule() {
assert_eq!(
compare_git_tree_entry_names(b"foo", false, b"foo.bar", false),
std::cmp::Ordering::Less
);
assert_eq!(
compare_git_tree_entry_names(b"foo.bar", false, b"foo", true),
std::cmp::Ordering::Less
);
assert_eq!(
compare_git_tree_entry_names(b"foo", true, b"foo0", false),
std::cmp::Ordering::Less
);
assert_eq!(
compare_git_tree_entry_names(b"same", false, b"same", false),
std::cmp::Ordering::Equal
);
}
}
fn reject_unsupported_object_format(object_format: String) -> ExitCode {
write_response(&Response::RepositoryRejected {
protocol_version: PROTOCOL_VERSION,
reason: "unsupported_object_format",
object_format,
supported_object_formats: ["sha1"],
});
ExitCode::from(2)
}
fn read_request() -> Result<Request, &'static str> {
let mut bytes = Vec::new();
io::stdin()
.take(MAX_REQUEST_BYTES + 1)
.read_to_end(&mut bytes)
.map_err(|_| "request_read_failed")?;
if bytes.len() as u64 > MAX_REQUEST_BYTES {
return Err("request_too_large");
}
serde_json::from_slice(&bytes).map_err(|_| "invalid_request")
}
fn write_response(response: &Response<'_>) {
let encoded = serde_json::to_string(response).expect("closed response shape must serialize");
println!("{encoded}");
}