From 742ae3d2b5da405b8a8f1d51c4293919eab609f8 Mon Sep 17 00:00:00 2001 From: Seongmin Lee Date: Sun, 9 Aug 2026 01:35:22 +0300 Subject: [PATCH] knot2: `git.keepCommit` pretty rough implementation since we will replace everything with git-push later. Signed-off-by: Seongmin Lee --- Cargo.lock | 3 + knot2/crates/knot-git/src/repo.rs | 11 ++ knot2/crates/knot-xrpc/Cargo.toml | 3 + knot2/crates/knot-xrpc/src/git.rs | 171 ++++++++++++++++++++++++++++++ knot2/crates/knot-xrpc/src/lib.rs | 2 + 5 files changed, 190 insertions(+) create mode 100644 knot2/crates/knot-xrpc/src/git.rs diff --git a/Cargo.lock b/Cargo.lock index 843f8f46b..d00411356 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5437,6 +5437,8 @@ dependencies = [ "http", "http-body", "httpdate", + "jacquard-axum", + "jacquard-common", "k256", "knot-acl", "knot-atproto", @@ -5459,6 +5461,7 @@ dependencies = [ "knot-runtime", "knot-secrets", "knot-types", + "lexicons", "serde", "serde_json", "sha2 0.11.0", diff --git a/knot2/crates/knot-git/src/repo.rs b/knot2/crates/knot-git/src/repo.rs index 2a47a2854..1ff94c405 100644 --- a/knot2/crates/knot-git/src/repo.rs +++ b/knot2/crates/knot-git/src/repo.rs @@ -507,6 +507,17 @@ impl Repo { self.git.git_dir().join("objects") } + pub fn set_reference(&self, name: &RefName, target: Oid) -> Result<(), GitError> { + self.git.reference( + name.as_str(), + target, + PreviousValue::Any, + "update ref to target commit", + ) + .map_err(|error| GitError::Backend(error.to_string()))?; + Ok(()) + } + pub fn references(&self) -> Result, GitError> { self.git .references() diff --git a/knot2/crates/knot-xrpc/Cargo.toml b/knot2/crates/knot-xrpc/Cargo.toml index 0d5ad684e..430b81ad0 100644 --- a/knot2/crates/knot-xrpc/Cargo.toml +++ b/knot2/crates/knot-xrpc/Cargo.toml @@ -25,6 +25,9 @@ knot-postreceive = { workspace = true } knot-messages = { workspace = true } knot-receive = { workspace = true } knot-maintenance = { workspace = true } +lexicons = { workspace = true } +jacquard-axum = { workspace = true } +jacquard-common = { workspace = true } tracing = { workspace = true } axum = { workspace = true, features = ["ws"] } tower = { workspace = true } diff --git a/knot2/crates/knot-xrpc/src/git.rs b/knot2/crates/knot-xrpc/src/git.rs new file mode 100644 index 000000000..e8d9882e7 --- /dev/null +++ b/knot2/crates/knot-xrpc/src/git.rs @@ -0,0 +1,171 @@ +#![allow(unused)] +use std::sync::Arc; + +use axum::extract::State; +use http::{HeaderMap, StatusCode}; +use jacquard_axum::{ExtractXrpc, XrpcResponse}; +use jacquard_common::types::ident::AtIdentifier; +use knot_runtime::{Clock, HttpTransport}; +use knot_types::{Oid, RefName, RepoDid}; +use lexicons::sh_tangled::git::keep_commit::{self, KeepCommitSource}; + +use crate::error::XrpcError; +use crate::{run_blocking, XrpcState}; + +pub(crate) const KEEP_COMMIT_ROUTE: &str = "/xrpc/sh.tangled.git.keepCommit"; + +const KEEP_COMMIT_DENIED: &str = "only repository owner or a collaborator may execute keepCommit"; + +fn encode_ref_component(raw: &str) -> String { + let mut out = String::with_capacity(raw.len()); + for c in raw.chars() { + match c { + '%' => out.push_str("%25"), + ':' => out.push_str("%3A"), + '~' => out.push_str("%7E"), + other => out.push(other), + } + } + out +} + +pub(crate) async fn keep_commit( + State(state): State>>, + headers: HeaderMap, + method: crate::Method, + ExtractXrpc(input): ExtractXrpc, +) -> Result, XrpcError> { + let actor = state.authenticate(&headers, &method).await?; + let repo_did = RepoDid::new(input.repo.as_str()).map_err(|_e| { + XrpcError::from_status(StatusCode::BAD_REQUEST, "repo should be valid DID") + })?; + + let (AtIdentifier::Did(record_did), Some(record_collection), Some(record_rkey)) = ( + input.record.authority(), + input.record.collection(), + input.record.rkey(), + ) else { + return Err(XrpcError::from_status( + StatusCode::BAD_REQUEST, + "record should be an AT-URI pointing to a record", + )); + }; + + crate::authorize_push(&state, &actor, &repo_did, KEEP_COMMIT_DENIED).await?; + + let commit = match input.source { + KeepCommitSource::Commit(commit) => { + let commit_id = Oid::from_hex(commit.oid.as_str()).map_err(|_e| { + XrpcError::from_status(StatusCode::BAD_REQUEST, "commit.oid must be valid OID") + })?; + if input.repo == commit.repo { + // no-op + } else { + // TODO: fetch `{commit.oid}` from `{commit.repo}` + return Err(XrpcError::from_status( + StatusCode::BAD_REQUEST, + "importing source from external repo is not supported yet.", + )); + } + + Ok(commit_id) + } + _ => Err(XrpcError::from_status( + StatusCode::BAD_REQUEST, + "only 'commit' source is supported yet.", + )), + }?; + + // set `refs/tngl/keep/{did}/{collection}/{rkey}/{oid}` to `{oid}` + let layout = state.layout.clone(); + let opened = repo_did.clone(); + let ref_name = RefName::new(format!( + "refs/tngl/keep/{}/{}/{}/{}", + encode_ref_component(record_did.as_str()), + record_collection.as_str(), + encode_ref_component(record_rkey.as_str()), + commit, + )) + .map_err(|_e| { + XrpcError::from_status( + StatusCode::BAD_REQUEST, + "record does not map to a usable ref name", + ) + })?; + run_blocking(move || { + let repo = layout.open(&opened)?; + repo.set_reference(&ref_name, commit)?; + Ok(()) + }) + .await?; + + Ok(XrpcResponse(keep_commit::KeepCommitOutput { + commit: commit.to_hex().into(), + ..Default::default() + })) +} + +#[cfg(test)] +mod tests { + use super::encode_ref_component; + use knot_types::RefName; + + const OID: &str = "81158e200ff7e520a20b5e7505960cee4f35e4b3"; + + fn keep_ref(did: &str, collection: &str, rkey: &str) -> String { + format!( + "refs/tngl/keep/{}/{}/{}/{OID}", + encode_ref_component(did), + collection, + encode_ref_component(rkey), + ) + } + + #[test] + fn keep_ref_for_a_did_and_tid_rkey_is_a_valid_ref_name() { + let name = keep_ref( + "did:plc:lpbyv7yk5vnegj63jmdwlxzx", + "sh.tangled.repo.pull", + "3ms6rk5d6gk22", + ); + assert!( + name.contains("did%3Aplc%3Alpbyv7yk5vnegj63jmdwlxzx"), + "the did's colons must be encoded, got {name}" + ); + assert!(RefName::new(&name).is_ok(), "{name} must be a valid ref"); + } + + #[test] + fn keep_ref_for_a_vouch_whose_rkey_is_itself_a_did_is_valid() { + let name = keep_ref( + "did:plc:lpbyv7yk5vnegj63jmdwlxzx", + "sh.tangled.graph.vouch", + "did:plc:zgazxbjxy2dkmgqovbvbnxwx", + ); + assert!(RefName::new(&name).is_ok(), "{name} must be a valid ref"); + assert!(!name.contains(':'), "no raw colon may survive, got {name}"); + } + + #[test] + fn tilde_in_an_rkey_is_encoded() { + let name = keep_ref("did:plc:alice", "sh.tangled.string", "a~b"); + assert!(name.contains("a%7Eb"), "got {name}"); + assert!(RefName::new(&name).is_ok(), "{name} must be a valid ref"); + } + + #[test] + fn encoding_is_injective() { + assert_ne!( + encode_ref_component("did:web:foo:bar"), + encode_ref_component("did:web:foo-bar") + ); + assert_ne!(encode_ref_component("a%3Ab"), encode_ref_component("a:b")); + assert_eq!(encode_ref_component("a%3Ab"), "a%253Ab"); + } + + #[test] + fn structurally_invalid_rkey_is_rejected_rather_than_reaching_git() { + let name = keep_ref("did:plc:alice", "sh.tangled.string", "a..b"); + assert!(RefName::new(&name).is_err(), "{name} must be rejected"); + } +} diff --git a/knot2/crates/knot-xrpc/src/lib.rs b/knot2/crates/knot-xrpc/src/lib.rs index 70c785812..36d2332d8 100644 --- a/knot2/crates/knot-xrpc/src/lib.rs +++ b/knot2/crates/knot-xrpc/src/lib.rs @@ -6,6 +6,7 @@ mod collaborators; mod error; mod events; mod forks; +mod git; pub mod legacy_admin; mod lfs; mod lists; @@ -298,6 +299,7 @@ pub fn router(state: Arc>) -> Router branches::DELETE_ROUTE, post(branches::delete_branch::), ) + .route(git::KEEP_COMMIT_ROUTE, post(git::keep_commit::)) .route(forks::SYNC_ROUTE, post(forks::fork_sync::)) .route(forks::HIDDEN_REF_ROUTE, post(forks::hidden_ref::)) .route(reads::TREE_ROUTE, get(reads::repo_tree::)) -- 2.51.2