don't
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(knot): implement git operations

Signed-off-by: tjh <did:plc:65gha4t3avpfpzmvpbwovss7>

+599 -14
+12
Cargo.lock
··· 2256 2256 "thiserror", 2257 2257 "time", 2258 2258 "tokio", 2259 + "tokio-stream", 2259 2260 "tower", 2260 2261 "tower-http", 2261 2262 "tracing", ··· 3341 3340 checksum = "05f63835928ca123f1bef57abbcd23bb2ba0ac9ae1235f1e65bda0d06e7786bd" 3342 3341 dependencies = [ 3343 3342 "rustls", 3343 + "tokio", 3344 + ] 3345 + 3346 + [[package]] 3347 + name = "tokio-stream" 3348 + version = "0.1.17" 3349 + source = "registry+https://github.com/rust-lang/crates.io-index" 3350 + checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" 3351 + dependencies = [ 3352 + "futures-core", 3353 + "pin-project-lite", 3344 3354 "tokio", 3345 3355 ] 3346 3356
+1
crates/knot/Cargo.toml
··· 22 22 thiserror = "2.0.16" 23 23 time = { version = "0.3.43", features = ["formatting", "macros", "parsing", "serde"] } 24 24 tokio = { version = "1.47.1", features = ["io-util", "macros", "net", "process", "signal", "rt-multi-thread"] } 25 + tokio-stream = "0.1.17" 25 26 tower = "0.5.2" 26 27 tower-http = { version = "0.6.6", features = ["decompression-gzip", "request-id", "trace", "tracing"] } 27 28 tracing.workspace = true
+1 -1
crates/knot/src/main.rs
··· 106 106 } 107 107 108 108 if let Ok(Query(Repo { repo })) = Query::try_from_uri(uri) { 109 - span.record("repo", format!("{}/{}", repo.owner(), repo.name())); 109 + span.record("repo", format!("{}/{}", repo.owner, repo.name)); 110 110 } 111 111 112 112 span
+22 -1
crates/knot/src/model.rs
··· 3 3 convert::time_to_offsetdatetime, 4 4 model::errors::{HeadDetached, PathNotFound, RefNotFound, RepoEmpty, RepoError, RepoNotFound}, 5 5 public::xrpc::XrpcError, 6 + repoid::RepoParts as _, 6 7 types::sh::tangled::repo::{ 7 8 BlobEncoding, BlobParams, BlobResponse, Branch, BranchesParams, BranchesResponse, 8 9 DefaultBranchResponse, Diff, DiffParams, DiffResponse, GetDefaultBranchParams, JsonBlob, ··· 61 60 repository_cache: Mutex<FxHashMap<RepoId, ThreadSafeRepository>>, 62 61 } 63 62 64 - trait RepositoryManager { 63 + pub trait RepositoryManager { 65 64 fn open(&self, repo: &RepoId) -> Result<Repository, XrpcError>; 65 + 66 + fn reopen(&self, repo: &RepoId) -> Result<Repository, XrpcError>; 66 67 } 67 68 68 69 impl RepositoryManager for Knot { ··· 86 83 path.push(repo.owner()); 87 84 path.push(repo.name()); 88 85 86 + tracing::debug!(?path, "opening repository"); 89 87 let repository = Options::default() 90 88 .strict_config(true) 91 89 .open_path_as_is(true) ··· 103 99 Ok(repository.to_thread_local()) 104 100 } 105 101 } 102 + } 103 + 104 + fn reopen(&self, repo: &RepoId) -> Result<Repository, XrpcError> { 105 + { 106 + let mut cache = self 107 + .inner 108 + .repository_cache 109 + .lock() 110 + .unwrap_or_else(|mut poison| { 111 + poison.get_mut().clear(); 112 + poison.into_inner() 113 + }); 114 + 115 + cache.remove(repo); 116 + } 117 + 118 + self.open(repo) 106 119 } 107 120 } 108 121
+1
crates/knot/src/public.rs
··· 14 14 .without_v07_checks() 15 15 .route("/events", axum::routing::get(events_handler)) 16 16 .nest("/xrpc", xrpc::router()) 17 + .nest("/{owner}/{name}", git::router()) 17 18 } 18 19 19 20 pub async fn events_handler(ws: WebSocketUpgrade) -> axum::response::Response {
+432
crates/knot/src/public/git.rs
··· 1 + use crate::{ 2 + RepoId, 3 + model::{Knot, RepositoryManager as _}, 4 + public::git::{helpers::SetOptionEnv as _, protocol::GitProtocol}, 5 + repoid::RepoPath, 6 + }; 7 + use axum::{ 8 + Router, 9 + body::{Body, BodyDataStream, HttpBody as _}, 10 + extract::{Path, Query, State}, 11 + http::header::{CACHE_CONTROL, CONNECTION, CONTENT_TYPE}, 12 + response::{IntoResponse, Response}, 13 + }; 14 + use axum_extra::body::AsyncReadBody; 15 + use bytes::BytesMut; 16 + use gix::Repository; 17 + use reqwest::StatusCode; 18 + use serde::Deserialize; 19 + use std::{os::unix::fs::PermissionsExt, process::Stdio}; 20 + use tokio::{ 21 + io::{AsyncWrite, AsyncWriteExt}, 22 + net::unix::pipe::{Sender, pipe as async_pipe}, 23 + process::Command, 24 + }; 25 + use tokio_stream::StreamExt as _; 26 + 27 + mod helpers; 28 + mod protocol; 29 + 30 + #[derive(Debug, Deserialize)] 31 + #[serde(rename_all = "kebab-case")] 32 + pub enum GitService { 33 + GitUploadPack, 34 + GitReceivePack, 35 + } 36 + 37 + /// Query-string parameters for repository 'info/refs'. 38 + #[derive(serde::Deserialize)] 39 + pub struct InfoRefsQuery { 40 + pub service: GitService, 41 + } 42 + 43 + const MAX_REQUEST_BODY: usize = 2 * 1024 * 1024; 44 + 45 + const GIT_COMMAND: &str = "/usr/bin/git"; 46 + const NO_CACHE: &str = "no-cache, max-age=0, must-revalidate"; 47 + const KEEP_ALIVE: &str = "keep-alive"; 48 + 49 + const UPLOAD_PACK_ADVERTISEMENT: &str = "application/x-git-upload-pack-advertisement"; 50 + const UPLOAD_PACK_RESULT: &str = "application/x-git-upload-pack-result"; 51 + const RECEIVE_PACK_ADVERTISEMENT: &str = "application/x-git-receive-pack-advertisement"; 52 + const RECEIVE_PACK_RESULT: &str = "application/x-git-receive-pack-result"; 53 + 54 + pub struct Error { 55 + code: StatusCode, 56 + error: String, 57 + } 58 + 59 + impl Error { 60 + fn new<E: std::fmt::Display>(error: E) -> Self { 61 + Self { 62 + code: StatusCode::INTERNAL_SERVER_ERROR, 63 + error: error.to_string(), 64 + } 65 + } 66 + 67 + fn not_found() -> Self { 68 + Self { 69 + code: StatusCode::NOT_FOUND, 70 + error: String::new(), 71 + } 72 + } 73 + 74 + #[allow(unused)] 75 + fn forbidden<E: std::fmt::Display>(error: E) -> Self { 76 + Self { 77 + code: StatusCode::FORBIDDEN, 78 + error: error.to_string(), 79 + } 80 + } 81 + } 82 + 83 + impl IntoResponse for Error { 84 + fn into_response(self) -> Response { 85 + (self.code, self.error).into_response() 86 + } 87 + } 88 + 89 + impl From<std::io::Error> for Error { 90 + fn from(value: std::io::Error) -> Self { 91 + Self::new(value) 92 + } 93 + } 94 + 95 + impl From<tokio::task::JoinError> for Error { 96 + fn from(error: tokio::task::JoinError) -> Self { 97 + Self::new(error) 98 + } 99 + } 100 + 101 + impl From<anyhow::Error> for Error { 102 + fn from(error: anyhow::Error) -> Self { 103 + Self::new(error) 104 + } 105 + } 106 + 107 + impl From<axum::Error> for Error { 108 + fn from(error: axum::Error) -> Self { 109 + Self::new(error) 110 + } 111 + } 112 + 113 + type Result<T> = std::result::Result<T, Error>; 114 + 115 + pub fn router() -> Router<Knot> { 116 + use axum::routing::{get, post}; 117 + 118 + Router::new() 119 + .route("/info/refs", get(info_refs)) 120 + .route("/git-upload-pack", post(upload_pack)) 121 + .route("/git-receive-pack", post(receive_pack)) 122 + } 123 + 124 + #[tracing::instrument] 125 + pub async fn info_refs( 126 + State(knot): State<Knot>, 127 + Path(repo): Path<RepoPath>, 128 + Query(InfoRefsQuery { service }): Query<InfoRefsQuery>, 129 + protocol: Option<GitProtocol>, 130 + ) -> Result<Response> { 131 + let repo = repo.into(); 132 + match service { 133 + GitService::GitUploadPack => advertise_upload_pack(knot, repo, protocol).await, 134 + GitService::GitReceivePack => advertise_receive_pack(knot, repo, protocol).await, 135 + } 136 + } 137 + 138 + /// Ref advertisement phase of a `git clone`, `git fetch`, or `git pull`. 139 + #[tracing::instrument] 140 + pub async fn advertise_upload_pack( 141 + knot: Knot, 142 + repo: RepoId, 143 + protocol: Option<GitProtocol>, 144 + ) -> Result<Response> { 145 + let repo = knot.open(&repo).map_err(|_| Error::not_found())?; 146 + 147 + let (mut stdout_tx, stdout_rx) = async_pipe()?; 148 + 149 + // The service header is only required for git protocol version 1, but 150 + // protocol version 2 clients will ignore it. 151 + pack_line(&mut stdout_tx, "# service=git-upload-pack\n").await?; 152 + pack_flush(&mut stdout_tx).await?; 153 + 154 + let child = Command::new(GIT_COMMAND) 155 + .env_clear() 156 + .current_dir(repo.path()) 157 + .args([ 158 + "upload-pack", 159 + "--http-backend-info-refs", 160 + "--stateless-rpc", 161 + "--strict", 162 + "--timeout=10", 163 + ".", 164 + ]) 165 + .option_env("GIT_PROTOCOL", protocol.as_deref()) 166 + .stdin(Stdio::null()) 167 + .stdout(stdout_tx.into_blocking_fd()?) 168 + .stderr(Stdio::piped()) 169 + .spawn()?; 170 + 171 + tokio::spawn(wait_with_stderr(child)); 172 + 173 + let headers = [ 174 + (CONTENT_TYPE, UPLOAD_PACK_ADVERTISEMENT), 175 + (CONNECTION, KEEP_ALIVE), 176 + (CACHE_CONTROL, NO_CACHE), 177 + ]; 178 + let body = AsyncReadBody::new(stdout_rx); 179 + 180 + Ok((headers, body).into_response()) 181 + } 182 + 183 + /// Ref advertisement phase of a `git push`. 184 + #[tracing::instrument] 185 + pub async fn advertise_receive_pack( 186 + knot: Knot, 187 + repo: RepoId, 188 + protocol: Option<GitProtocol>, 189 + ) -> Result<Response> { 190 + let repo = knot.reopen(&repo).map_err(|_| Error::not_found())?; 191 + check_push_status(&repo)?; 192 + 193 + let (mut stdout_tx, stdout_rx) = async_pipe()?; 194 + 195 + // The service header is only required for git protocol version 1, but 196 + // protocol version 2 clients will ignore it. 197 + pack_line(&mut stdout_tx, "# service=git-receive-pack\n").await?; 198 + pack_flush(&mut stdout_tx).await?; 199 + 200 + let child = Command::new(GIT_COMMAND) 201 + .env_clear() 202 + .current_dir(repo.path()) 203 + .args([ 204 + "receive-pack", 205 + "--http-backend-info-refs", 206 + "--stateless-rpc", 207 + ".", 208 + ]) 209 + .option_env("GIT_PROTOCOL", protocol.as_deref()) 210 + .stdin(Stdio::null()) 211 + .stdout(stdout_tx.into_blocking_fd()?) 212 + .stderr(Stdio::piped()) 213 + .spawn()?; 214 + 215 + tokio::spawn(wait_with_stderr(child)); 216 + 217 + let headers = [ 218 + (CONTENT_TYPE, RECEIVE_PACK_ADVERTISEMENT), 219 + (CONNECTION, KEEP_ALIVE), 220 + (CACHE_CONTROL, NO_CACHE), 221 + ]; 222 + let body = AsyncReadBody::new(stdout_rx); 223 + 224 + Ok((headers, body).into_response()) 225 + } 226 + 227 + /// Transfer phase of a `git clone`, `git fetch`, or `git pull`. 228 + pub async fn upload_pack( 229 + State(knot): State<Knot>, 230 + Path(repo): Path<RepoPath>, 231 + protocol: Option<GitProtocol>, 232 + body: Body, 233 + ) -> Result<Response> { 234 + let repo = knot.open(&repo.into()).map_err(|_| Error::not_found())?; 235 + 236 + // The request body for git-upload-pack should be quite small, so just 237 + // buffer it. 238 + let mut request_body = axum::body::to_bytes(body, MAX_REQUEST_BODY) 239 + .await 240 + .inspect_err(|error| tracing::error!(?error))?; 241 + 242 + let mut child = Command::new(GIT_COMMAND) 243 + .env_clear() 244 + .current_dir(repo.path()) 245 + .args(["upload-pack", "--strict", "--stateless-rpc", "."]) 246 + .option_env("GIT_PROTOCOL", protocol.as_deref()) 247 + .stdin(Stdio::piped()) 248 + .stdout(Stdio::piped()) 249 + .stderr(Stdio::piped()) 250 + .spawn()?; 251 + 252 + let stdin = child 253 + .stdin 254 + .take() 255 + .ok_or(anyhow::anyhow!("failed to take stdin of child process"))?; 256 + 257 + let stdout = child 258 + .stdout 259 + .take() 260 + .ok_or(anyhow::anyhow!("failed to take stdout of child process"))?; 261 + 262 + let mut sender = Sender::from_owned_fd(stdin.into_owned_fd()?)?; 263 + tokio::spawn(async move { 264 + if let Err(error) = sender.write_all_buf(&mut request_body).await { 265 + tracing::error!(?error, "error writing to child stdin"); 266 + } 267 + }); 268 + 269 + tokio::spawn(wait_with_stderr(child)); 270 + 271 + let headers = [(CONTENT_TYPE, UPLOAD_PACK_RESULT)]; 272 + let body = AsyncReadBody::new(stdout); 273 + Ok((headers, body).into_response()) 274 + } 275 + 276 + /// Transfer phase of a `git push`. 277 + pub async fn receive_pack( 278 + State(knot): State<Knot>, 279 + Path(repo): Path<RepoPath>, 280 + protocol: Option<GitProtocol>, 281 + body: Body, 282 + ) -> Result<impl IntoResponse> { 283 + let repo = knot.reopen(&repo.into()).map_err(|_| Error::not_found())?; 284 + check_push_status(&repo)?; 285 + 286 + let mut child = Command::new(GIT_COMMAND) 287 + .env_clear() 288 + .current_dir(repo.path()) 289 + .args(["receive-pack", "--stateless-rpc", "."]) 290 + .option_env("GIT_PROTOCOL", protocol.as_deref()) 291 + .env("KNOT_SERVER_OWNER", knot.owner().as_str()) 292 + .stdin(Stdio::piped()) 293 + .stdout(Stdio::piped()) 294 + .stderr(Stdio::piped()) 295 + .spawn()?; 296 + 297 + let stdin = child 298 + .stdin 299 + .take() 300 + .ok_or(anyhow::anyhow!("failed to take stdin of child process"))?; 301 + 302 + tokio::task::spawn(stream_to_pipe( 303 + body.into_data_stream(), 304 + Sender::from_owned_fd(stdin.into_owned_fd()?)?, 305 + )); 306 + 307 + let stdout = child 308 + .stdout 309 + .take() 310 + .ok_or(anyhow::anyhow!("failed to take stdout of child process"))?; 311 + 312 + tokio::spawn(wait_with_stderr(child)); 313 + 314 + let headers = [(CONTENT_TYPE, RECEIVE_PACK_RESULT)]; 315 + let body = AsyncReadBody::new(stdout); 316 + 317 + Ok((headers, body).into_response()) 318 + } 319 + 320 + fn check_push_status(repo: &Repository) -> Result<()> { 321 + let config = repo.config_snapshot(); 322 + if !config 323 + .boolean("receive.advertisePushOptions") 324 + .unwrap_or_default() 325 + { 326 + return Err(Error::forbidden( 327 + "'receive.advertisePushOptions is not set or 'false'", 328 + )); 329 + } 330 + 331 + if config 332 + .string("receive.certNonceSeed") 333 + .unwrap_or_default() 334 + .is_empty() 335 + { 336 + return Err(Error::forbidden( 337 + "'receive.certNonceSeed is not set or empty", 338 + )); 339 + } 340 + 341 + // Verify a pre-receive hook is present. 342 + let pre_receive_path = repo.path().join("hooks/pre-receive"); 343 + let Ok(metadata) = std::fs::metadata(&pre_receive_path) else { 344 + return Err(Error::forbidden("Failed to stat 'hooks/pre-receive'")); 345 + }; 346 + 347 + if !(metadata.is_file() || metadata.is_symlink()) 348 + || (metadata.permissions().mode() & 0o111 == 0) 349 + { 350 + return Err(Error::forbidden( 351 + "'hooks/pre-receive' is not an executable file/symlink", 352 + )); 353 + } 354 + 355 + Ok(()) 356 + } 357 + 358 + #[tracing::instrument] 359 + pub async fn stream_to_pipe( 360 + mut body_stream: BodyDataStream, 361 + mut pipe: Sender, 362 + ) -> std::result::Result<(), anyhow::Error> { 363 + let mut buffer = BytesMut::new(); 364 + let mut bytes_len = 0; 365 + loop { 366 + if !buffer.is_empty() { 367 + pipe.writable().await?; 368 + pipe.write_buf(&mut buffer).await?; 369 + } 370 + 371 + match body_stream.next().await { 372 + None if !buffer.is_empty() => continue, 373 + None => break, 374 + Some(Ok(chunk)) => { 375 + bytes_len += chunk.len(); 376 + buffer.extend_from_slice(&chunk); 377 + } 378 + Some(Err(error)) => { 379 + tracing::error!(?error, "error chunk"); 380 + break; 381 + } 382 + } 383 + } 384 + 385 + tracing::trace!(end = ?body_stream.is_end_stream(), ?bytes_len, "finished reading request body"); 386 + Ok(()) 387 + } 388 + 389 + pub async fn wait_with_stderr(child: tokio::process::Child) { 390 + match child.wait_with_output().await { 391 + Ok(output) if output.status.success() => { 392 + if !output.stderr.is_empty() { 393 + let stderr = std::str::from_utf8(&output.stderr).unwrap_or("non-utf8 in stderr"); 394 + tracing::warn!("git child process completed with errors. stderr:\n{stderr}"); 395 + } 396 + } 397 + Ok(output) => { 398 + let status = output.status; 399 + match std::str::from_utf8(&output.stderr) { 400 + Ok(stderr) => tracing::error!( 401 + ?status, 402 + "error waiting for git child process. stderr:\n{stderr}" 403 + ), 404 + Err(_) => tracing::error!( 405 + ?status, 406 + "error waiting for git child process. stderr:\n{:?}\n{}", 407 + &output.stderr, 408 + String::from_utf8_lossy(&output.stderr) 409 + ), 410 + } 411 + } 412 + Err(error) => tracing::error!(?error), 413 + } 414 + } 415 + 416 + async fn pack_line(mut w: impl AsyncWrite + Unpin, s: &str) -> std::io::Result<()> { 417 + let buffer = format!("{:04x}{s}", s.len() + 4); 418 + w.write_all(buffer.as_bytes()).await?; 419 + Ok(()) 420 + } 421 + 422 + #[allow(unused)] 423 + async fn pack_message(mut w: impl AsyncWrite + Unpin, message: &str) -> std::io::Result<()> { 424 + let buffer = format!("{:04x}\u{2}{message}", message.len() + 5); 425 + w.write_all(buffer.as_bytes()).await?; 426 + Ok(()) 427 + } 428 + 429 + async fn pack_flush(mut w: impl AsyncWrite + Unpin) -> std::io::Result<()> { 430 + w.write_all(b"0000").await?; 431 + Ok(()) 432 + }
+47
crates/knot/src/public/git/helpers.rs
··· 1 + use std::ffi::OsStr; 2 + 3 + trait SetEnv { 4 + fn env<K, V>(&mut self, key: K, val: V) -> &mut Self 5 + where 6 + K: AsRef<OsStr>, 7 + V: AsRef<OsStr>; 8 + } 9 + 10 + macro_rules! impl_set_env { 11 + ($ty:ty) => { 12 + impl SetEnv for $ty { 13 + fn env<K, V>(&mut self, key: K, val: V) -> &mut Self 14 + where 15 + K: AsRef<OsStr>, 16 + V: AsRef<OsStr>, 17 + { 18 + Self::env(self, key, val) 19 + } 20 + } 21 + }; 22 + } 23 + 24 + impl_set_env!(std::process::Command); 25 + impl_set_env!(tokio::process::Command); 26 + 27 + pub trait SetOptionEnv { 28 + /// Inserts or updates an environment variable mapping if, and only if, 29 + /// `val` is [`Some`]. 30 + fn option_env<K, V>(&mut self, key: K, val: Option<V>) -> &mut Self 31 + where 32 + K: AsRef<OsStr>, 33 + V: AsRef<OsStr>; 34 + } 35 + 36 + impl<T: SetEnv> SetOptionEnv for T { 37 + fn option_env<K, V>(&mut self, key: K, val: Option<V>) -> &mut Self 38 + where 39 + K: AsRef<OsStr>, 40 + V: AsRef<OsStr>, 41 + { 42 + if let Some(val) = val { 43 + self.env(key, val); 44 + } 45 + self 46 + } 47 + }
+41
crates/knot/src/public/git/protocol.rs
··· 1 + use axum::extract::OptionalFromRequestParts; 2 + use std::convert::Infallible; 3 + 4 + /// Extract the "Git-Protocol" header from a request. 5 + #[derive(Debug)] 6 + pub struct GitProtocol(pub Box<str>); 7 + 8 + impl AsRef<str> for GitProtocol { 9 + #[inline] 10 + fn as_ref(&self) -> &str { 11 + &self.0 12 + } 13 + } 14 + 15 + impl std::ops::Deref for GitProtocol { 16 + type Target = str; 17 + 18 + #[inline] 19 + fn deref(&self) -> &Self::Target { 20 + &self.0 21 + } 22 + } 23 + 24 + impl<S> OptionalFromRequestParts<S> for GitProtocol 25 + where 26 + S: Sync, 27 + { 28 + type Rejection = Infallible; 29 + 30 + async fn from_request_parts( 31 + parts: &mut axum::http::request::Parts, 32 + _: &S, 33 + ) -> Result<Option<Self>, Self::Rejection> { 34 + let header_value = parts 35 + .headers 36 + .get("Git-Protocol") 37 + .and_then(|header| header.to_str().ok()); 38 + 39 + Ok(header_value.map(|value| Self(value.into()))) 40 + } 41 + }
+42 -12
crates/knot/src/repoid.rs
··· 1 - use identity::Did; 2 1 use serde::{ 3 2 Deserialize, Deserializer, 4 3 de::{Error, Visitor}, 5 4 }; 6 5 6 + pub trait RepoParts { 7 + fn owner(&self) -> &str; 8 + fn name(&self) -> &str; 9 + } 10 + 7 11 #[derive(Clone, Debug, PartialEq, Eq, Hash)] 8 12 pub struct RepoId { 9 13 /// DID of the repository owner 10 - pub owner: Did, 14 + pub owner: Box<str>, 11 15 12 16 /// Repository name 13 17 pub name: Box<str>, 14 18 } 15 19 16 - impl RepoId { 17 - #[inline] 18 - pub const fn owner(&self) -> &str { 19 - self.owner.as_str() 20 - } 20 + macro_rules! impl_repo_parts { 21 + ($typ:ty) => { 22 + impl RepoParts for $typ { 23 + fn owner(&self) -> &str { 24 + &self.owner 25 + } 21 26 22 - #[inline] 23 - pub const fn name(&self) -> &str { 24 - &self.name 27 + fn name(&self) -> &str { 28 + &self.name 29 + } 30 + } 31 + }; 32 + } 33 + 34 + impl_repo_parts!(RepoId); 35 + impl_repo_parts!(RepoPath); 36 + 37 + #[derive(Clone, Debug, PartialEq, Eq, Hash, Deserialize)] 38 + pub struct RepoPath { 39 + /// DID of the repository owner 40 + pub owner: Box<str>, 41 + 42 + /// Repository name 43 + pub name: Box<str>, 44 + } 45 + 46 + impl From<RepoId> for RepoPath { 47 + fn from(RepoId { owner, name }: RepoId) -> Self { 48 + Self { owner, name } 49 + } 50 + } 51 + 52 + impl From<RepoPath> for RepoId { 53 + fn from(RepoPath { owner, name }: RepoPath) -> Self { 54 + Self { owner, name } 25 55 } 26 56 } 27 57 ··· 79 49 )); 80 50 }; 81 51 82 - let owner = owner.parse().map_err(Error::custom)?; 52 + // @TODO validate owner and name! 83 53 84 54 Ok(Self::Value { 85 - owner, 55 + owner: owner.into(), 86 56 name: name.into(), 87 57 }) 88 58 }