320 lines
12 KiB
Rust
320 lines
12 KiB
Rust
// file: crates/knowledge-git-api/src/write.rs
|
|
// version: 1
|
|
|
|
/// Invalid value supplied to the provider-neutral Git write contract.
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub enum GitWriteInputError {
|
|
/// A mutation path is empty or contains only whitespace.
|
|
EmptyMutationPath,
|
|
/// A mutation path is absolute or uses a platform-specific separator.
|
|
AbsoluteMutationPath,
|
|
/// A mutation path contains an empty, current-directory or parent-directory component.
|
|
UnnormalizedMutationPath,
|
|
/// A mutation path addresses Git's private `.git` area.
|
|
ProtectedGitPath,
|
|
/// A commit identity name is empty or contains a forbidden line break or NUL byte.
|
|
InvalidCommitIdentityName,
|
|
/// A commit identity email is empty or contains a forbidden line break or NUL byte.
|
|
InvalidCommitIdentityEmail,
|
|
/// A commit message is empty or contains a NUL byte.
|
|
InvalidCommitMessage,
|
|
/// A write request does not contain any file mutation.
|
|
NoMutations,
|
|
/// More than one mutation targets the same normalized path.
|
|
DuplicateMutationPath,
|
|
/// A target branch is not a valid full `refs/heads/...` Git reference.
|
|
InvalidBranch,
|
|
}
|
|
|
|
impl std::fmt::Display for crate::GitWriteInputError {
|
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
crate::GitWriteInputError::EmptyMutationPath => return formatter.write_str("Git mutation path must not be empty"),
|
|
crate::GitWriteInputError::AbsoluteMutationPath => return formatter.write_str("Git mutation path must be relative and use '/' separators"),
|
|
crate::GitWriteInputError::UnnormalizedMutationPath => return formatter.write_str("Git mutation path must be normalized"),
|
|
crate::GitWriteInputError::ProtectedGitPath => return formatter.write_str("Git mutation path must not address .git"),
|
|
crate::GitWriteInputError::InvalidCommitIdentityName => return formatter.write_str("Git commit identity name is invalid"),
|
|
crate::GitWriteInputError::InvalidCommitIdentityEmail => return formatter.write_str("Git commit identity email is invalid"),
|
|
crate::GitWriteInputError::InvalidCommitMessage => return formatter.write_str("Git commit message is invalid"),
|
|
crate::GitWriteInputError::NoMutations => return formatter.write_str("Git write request must contain at least one mutation"),
|
|
crate::GitWriteInputError::DuplicateMutationPath => return formatter.write_str("Git write request must not mutate one path more than once"),
|
|
crate::GitWriteInputError::InvalidBranch => return formatter.write_str("Git target branch must be a valid refs/heads reference"),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for crate::GitWriteInputError {}
|
|
|
|
/// Normalized relative path that may be mutated inside an isolated Git write worktree.
|
|
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
|
pub struct GitMutationPath(String);
|
|
|
|
impl crate::GitMutationPath {
|
|
/// Validate and build a provider-neutral repository-relative mutation path.
|
|
pub fn new(value: &str) -> Result<crate::GitMutationPath, crate::GitWriteInputError> {
|
|
if value.trim().is_empty() {
|
|
return Err(crate::GitWriteInputError::EmptyMutationPath);
|
|
}
|
|
if value.starts_with('/') || value.contains('\\') || crate::write::has_windows_drive_prefix(value) {
|
|
return Err(crate::GitWriteInputError::AbsoluteMutationPath);
|
|
}
|
|
if value.contains('\0') {
|
|
return Err(crate::GitWriteInputError::UnnormalizedMutationPath);
|
|
}
|
|
for component in value.split('/') {
|
|
if component.is_empty() || component == "." || component == ".." {
|
|
return Err(crate::GitWriteInputError::UnnormalizedMutationPath);
|
|
}
|
|
if component.eq_ignore_ascii_case(".git") {
|
|
return Err(crate::GitWriteInputError::ProtectedGitPath);
|
|
}
|
|
}
|
|
return Ok(Self(value.to_owned()));
|
|
}
|
|
|
|
/// Return the normalized repository-relative path.
|
|
pub fn as_str(&self) -> &str {
|
|
return self.0.as_str();
|
|
}
|
|
}
|
|
|
|
/// One file mutation requested for a candidate Git commit.
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub enum GitFileMutation {
|
|
/// Create or replace one file with the provided bytes.
|
|
Write {
|
|
/// Validated repository-relative file path.
|
|
path: crate::GitMutationPath,
|
|
/// Complete bytes that must become the file content.
|
|
content: Vec<u8>,
|
|
},
|
|
/// Delete one existing path from the candidate tree.
|
|
Delete {
|
|
/// Validated repository-relative file path.
|
|
path: crate::GitMutationPath,
|
|
},
|
|
}
|
|
|
|
impl crate::GitFileMutation {
|
|
/// Return the validated path targeted by this mutation.
|
|
pub fn path(&self) -> &crate::GitMutationPath {
|
|
match self {
|
|
crate::GitFileMutation::Write { path, .. } => return path,
|
|
crate::GitFileMutation::Delete { path } => return path,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Explicit author and committer identity used to create a candidate commit.
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub struct GitCommitIdentity {
|
|
name: String,
|
|
email: String,
|
|
}
|
|
|
|
impl crate::GitCommitIdentity {
|
|
/// Validate and build an explicit Git commit identity.
|
|
pub fn new(name: &str, email: &str) -> Result<crate::GitCommitIdentity, crate::GitWriteInputError> {
|
|
if crate::write::invalid_identity_component(name) {
|
|
return Err(crate::GitWriteInputError::InvalidCommitIdentityName);
|
|
}
|
|
if crate::write::invalid_identity_component(email) {
|
|
return Err(crate::GitWriteInputError::InvalidCommitIdentityEmail);
|
|
}
|
|
return Ok(Self { name: name.to_owned(), email: email.to_owned() });
|
|
}
|
|
|
|
/// Return the explicit identity name.
|
|
pub fn name(&self) -> &str {
|
|
return self.name.as_str();
|
|
}
|
|
|
|
/// Return the explicit identity email.
|
|
pub fn email(&self) -> &str {
|
|
return self.email.as_str();
|
|
}
|
|
}
|
|
|
|
/// Validated full Git branch reference accepted as a remote write target.
|
|
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
|
pub struct GitBranch(String);
|
|
|
|
impl crate::GitBranch {
|
|
/// Validate a full `refs/heads/...` branch reference.
|
|
pub fn new(value: &str) -> Result<crate::GitBranch, crate::GitWriteInputError> {
|
|
if !crate::write::valid_branch_reference(value) {
|
|
return Err(crate::GitWriteInputError::InvalidBranch);
|
|
}
|
|
return Ok(Self(value.to_owned()));
|
|
}
|
|
|
|
/// Return the validated full branch reference.
|
|
pub fn as_str(&self) -> &str {
|
|
return self.0.as_str();
|
|
}
|
|
}
|
|
|
|
/// Expected or observed state of one remote branch.
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub enum GitRemoteState {
|
|
/// The branch does not exist.
|
|
Absent,
|
|
/// The branch resolves to the provided revision.
|
|
Revision(crate::GitRevision),
|
|
}
|
|
|
|
/// Request to build one candidate commit from an explicit base revision.
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub struct GitWriteRequest {
|
|
base_revision: crate::GitRevision,
|
|
mutations: Vec<crate::GitFileMutation>,
|
|
identity: crate::GitCommitIdentity,
|
|
message: String,
|
|
}
|
|
|
|
impl crate::GitWriteRequest {
|
|
/// Validate and build a candidate-commit request.
|
|
pub fn new(
|
|
base_revision: crate::GitRevision,
|
|
mutations: Vec<crate::GitFileMutation>,
|
|
identity: crate::GitCommitIdentity,
|
|
message: &str,
|
|
) -> Result<crate::GitWriteRequest, crate::GitWriteInputError> {
|
|
if mutations.is_empty() {
|
|
return Err(crate::GitWriteInputError::NoMutations);
|
|
}
|
|
if message.trim().is_empty() || message.contains('\0') {
|
|
return Err(crate::GitWriteInputError::InvalidCommitMessage);
|
|
}
|
|
let mut paths = std::collections::HashSet::new();
|
|
for mutation in &mutations {
|
|
if !paths.insert(mutation.path().as_str()) {
|
|
return Err(crate::GitWriteInputError::DuplicateMutationPath);
|
|
}
|
|
}
|
|
return Ok(Self { base_revision, mutations, identity, message: message.to_owned() });
|
|
}
|
|
|
|
/// Return the resolved base revision from which the candidate must be created.
|
|
pub fn base_revision(&self) -> &crate::GitRevision {
|
|
return &self.base_revision;
|
|
}
|
|
|
|
/// Return the ordered mutations to apply in the isolated write worktree.
|
|
pub fn mutations(&self) -> &[crate::GitFileMutation] {
|
|
return self.mutations.as_slice();
|
|
}
|
|
|
|
/// Return the explicit author/committer identity.
|
|
pub fn identity(&self) -> &crate::GitCommitIdentity {
|
|
return &self.identity;
|
|
}
|
|
|
|
/// Return the candidate commit message.
|
|
pub fn message(&self) -> &str {
|
|
return self.message.as_str();
|
|
}
|
|
}
|
|
|
|
/// Request to compare-and-exchange one remote branch to a candidate revision.
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub struct GitPushRequest {
|
|
target_branch: crate::GitBranch,
|
|
expected_remote: crate::GitRemoteState,
|
|
candidate_revision: crate::GitRevision,
|
|
}
|
|
|
|
impl crate::GitPushRequest {
|
|
/// Build a guarded remote update request from already validated values.
|
|
pub fn new(target_branch: crate::GitBranch, expected_remote: crate::GitRemoteState, candidate_revision: crate::GitRevision) -> crate::GitPushRequest {
|
|
return Self { target_branch, expected_remote, candidate_revision };
|
|
}
|
|
|
|
/// Return the target remote branch.
|
|
pub fn target_branch(&self) -> &crate::GitBranch {
|
|
return &self.target_branch;
|
|
}
|
|
|
|
/// Return the remote state that must still hold for the update to succeed.
|
|
pub fn expected_remote(&self) -> &crate::GitRemoteState {
|
|
return &self.expected_remote;
|
|
}
|
|
|
|
/// Return the candidate revision to publish.
|
|
pub fn candidate_revision(&self) -> &crate::GitRevision {
|
|
return &self.candidate_revision;
|
|
}
|
|
}
|
|
|
|
/// Result of one guarded remote branch update.
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub enum GitPushOutcome {
|
|
/// The target branch was updated to the candidate revision.
|
|
Updated(
|
|
/// Revision now expected at the updated target branch.
|
|
crate::GitRevision,
|
|
),
|
|
/// The target branch no longer matched the expected state.
|
|
Conflict {
|
|
/// Actual remote state observed when the guarded update was rejected.
|
|
actual: crate::GitRemoteState,
|
|
},
|
|
}
|
|
|
|
/// Provider-neutral write operations required from a Git repository backend.
|
|
///
|
|
/// This capability is intentionally separate from [`crate::GitRepository`] so read-only consumers
|
|
/// and fakes do not need to implement write methods. A concrete backend may implement this trait
|
|
/// independently from the read contract.
|
|
pub trait GitRepositoryWriter {
|
|
/// Implementation-specific recoverable error.
|
|
type Error;
|
|
|
|
/// Create a candidate commit without updating a remote branch.
|
|
fn create_candidate(&mut self, source_id: &knowledge_domain_lib::SourceId, request: &crate::GitWriteRequest) -> Result<crate::GitRevision, Self::Error>;
|
|
|
|
/// Attempt one guarded remote branch update for a previously created candidate revision.
|
|
fn push_candidate(&mut self, source_id: &knowledge_domain_lib::SourceId, request: &crate::GitPushRequest) -> Result<crate::GitPushOutcome, Self::Error>;
|
|
}
|
|
|
|
fn has_windows_drive_prefix(value: &str) -> bool {
|
|
let bytes = value.as_bytes();
|
|
if bytes.len() < 2 {
|
|
return false;
|
|
}
|
|
return bytes[0].is_ascii_alphabetic() && bytes[1] == b':';
|
|
}
|
|
|
|
fn invalid_identity_component(value: &str) -> bool {
|
|
return value.trim().is_empty() || value.contains('\0') || value.contains('\n') || value.contains('\r');
|
|
}
|
|
|
|
fn valid_branch_reference(value: &str) -> bool {
|
|
const PREFIX: &str = "refs/heads/";
|
|
let branch = match value.strip_prefix(PREFIX) {
|
|
Some(value) => value,
|
|
None => return false,
|
|
};
|
|
if branch.is_empty()
|
|
|| branch.starts_with('/')
|
|
|| branch.ends_with('/')
|
|
|| branch.ends_with('.')
|
|
|| branch.contains("..")
|
|
|| branch.contains("@{")
|
|
|| branch.contains("//")
|
|
{
|
|
return false;
|
|
}
|
|
for byte in branch.bytes() {
|
|
if byte <= 0x20 || byte == 0x7f || matches!(byte, b'~' | b'^' | b':' | b'?' | b'*' | b'[' | b'\\') {
|
|
return false;
|
|
}
|
|
}
|
|
for component in branch.split('/') {
|
|
if component.is_empty() || component.starts_with('.') || component.ends_with(".lock") {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|