guts_node/
compat_api.rs

1//! # Compatibility API
2//!
3//! This module provides GitHub-compatible API endpoints for:
4//!
5//! - **User Accounts**: User registration and profile management
6//! - **Personal Access Tokens**: Token-based authentication
7//! - **SSH Keys**: SSH key management
8//! - **Releases**: Release and asset management
9//! - **Contents**: Repository file browsing
10//! - **Archives**: Tarball and zipball downloads
11//! - **Rate Limits**: Rate limit status endpoint
12//!
13//! ## User Endpoints
14//!
15//! | Method | Path | Description |
16//! |--------|------|-------------|
17//! | POST | `/api/users` | Create user account |
18//! | GET | `/api/users` | List users |
19//! | GET | `/api/users/{username}` | Get user profile |
20//! | PATCH | `/api/users/{username}` | Update user profile |
21//! | GET | `/api/user` | Get authenticated user |
22//! | PATCH | `/api/user` | Update authenticated user |
23//!
24//! ## Token Endpoints
25//!
26//! | Method | Path | Description |
27//! |--------|------|-------------|
28//! | POST | `/api/user/tokens` | Create personal access token |
29//! | GET | `/api/user/tokens` | List tokens |
30//! | DELETE | `/api/user/tokens/{id}` | Revoke token |
31//!
32//! ## SSH Key Endpoints
33//!
34//! | Method | Path | Description |
35//! |--------|------|-------------|
36//! | POST | `/api/user/keys` | Add SSH key |
37//! | GET | `/api/user/keys` | List SSH keys |
38//! | GET | `/api/user/keys/{id}` | Get SSH key |
39//! | DELETE | `/api/user/keys/{id}` | Remove SSH key |
40//!
41//! ## Release Endpoints
42//!
43//! | Method | Path | Description |
44//! |--------|------|-------------|
45//! | POST | `/api/repos/{owner}/{name}/releases` | Create release |
46//! | GET | `/api/repos/{owner}/{name}/releases` | List releases |
47//! | GET | `/api/repos/{owner}/{name}/releases/latest` | Get latest release |
48//! | GET | `/api/repos/{owner}/{name}/releases/tags/{tag}` | Get by tag |
49//! | GET | `/api/repos/{owner}/{name}/releases/{id}` | Get release |
50//! | PATCH | `/api/repos/{owner}/{name}/releases/{id}` | Update release |
51//! | DELETE | `/api/repos/{owner}/{name}/releases/{id}` | Delete release |
52//!
53//! ## Contents Endpoints
54//!
55//! | Method | Path | Description |
56//! |--------|------|-------------|
57//! | GET | `/api/repos/{owner}/{name}/contents` | Get root contents |
58//! | GET | `/api/repos/{owner}/{name}/contents/*path` | Get file/directory |
59//! | GET | `/api/repos/{owner}/{name}/readme` | Get README |
60//!
61//! ## Archive Endpoints
62//!
63//! | Method | Path | Description |
64//! |--------|------|-------------|
65//! | GET | `/api/repos/{owner}/{name}/tarball/{ref}` | Download tarball |
66//! | GET | `/api/repos/{owner}/{name}/zipball/{ref}` | Download zipball |
67//!
68//! ## Rate Limit Endpoint
69//!
70//! | Method | Path | Description |
71//! |--------|------|-------------|
72//! | GET | `/api/rate_limit` | Get rate limit status |
73
74use axum::{
75    body::Body,
76    extract::{Path, Query, State},
77    http::{header, StatusCode},
78    response::{IntoResponse, Response},
79    routing::get,
80    Json, Router,
81};
82use guts_compat::{
83    base64_encode, create_archive, is_readme_file, paginate, AddSshKeyRequest, ArchiveEntry,
84    ArchiveFormat, CompatError, CompatStore, ContentEntry, ContentType, CreateReleaseRequest,
85    CreateTokenRequest, CreateUserRequest, PaginationParams, UpdateReleaseRequest,
86    UpdateUserRequest, User,
87};
88use guts_storage::{GitObject, ObjectId, ObjectType, Reference, Repository};
89use serde::{Deserialize, Serialize};
90
91use crate::api::AppState;
92
93/// Creates the compatibility API routes.
94pub fn compat_routes() -> Router<AppState> {
95    Router::new()
96        // User endpoints
97        .route("/api/users", get(list_users).post(create_user))
98        .route(
99            "/api/users/{username}",
100            get(get_user).patch(update_user_by_name),
101        )
102        .route(
103            "/api/user",
104            get(get_current_user).patch(update_current_user),
105        )
106        // Token endpoints
107        .route("/api/user/tokens", get(list_tokens).post(create_token))
108        .route("/api/user/tokens/{id}", get(get_token).delete(revoke_token))
109        // SSH key endpoints
110        .route("/api/user/keys", get(list_ssh_keys).post(add_ssh_key))
111        .route(
112            "/api/user/keys/{id}",
113            get(get_ssh_key).delete(remove_ssh_key),
114        )
115        // Release endpoints
116        .route(
117            "/api/repos/{owner}/{name}/releases",
118            get(list_releases).post(create_release),
119        )
120        .route(
121            "/api/repos/{owner}/{name}/releases/latest",
122            get(get_latest_release),
123        )
124        .route(
125            "/api/repos/{owner}/{name}/releases/tags/{tag}",
126            get(get_release_by_tag),
127        )
128        .route(
129            "/api/repos/{owner}/{name}/releases/{id}",
130            get(get_release)
131                .patch(update_release)
132                .delete(delete_release),
133        )
134        // Contents endpoints
135        .route("/api/repos/{owner}/{name}/contents", get(get_contents_root))
136        .route(
137            "/api/repos/{owner}/{name}/contents/{*path}",
138            get(get_contents),
139        )
140        .route("/api/repos/{owner}/{name}/readme", get(get_readme))
141        // Archive endpoints
142        .route("/api/repos/{owner}/{name}/tarball/{ref}", get(get_tarball))
143        .route("/api/repos/{owner}/{name}/zipball/{ref}", get(get_zipball))
144        // Rate limit endpoint
145        .route("/api/rate_limit", get(get_rate_limit))
146}
147
148// ==================== Error Handling ====================
149
150/// Wrapper for compat errors.
151struct CompatApiError(CompatError);
152
153impl From<CompatError> for CompatApiError {
154    fn from(err: CompatError) -> Self {
155        Self(err)
156    }
157}
158
159impl IntoResponse for CompatApiError {
160    fn into_response(self) -> Response {
161        let status =
162            StatusCode::from_u16(self.0.status_code()).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
163        let message = self.0.github_message();
164
165        (
166            status,
167            Json(ErrorResponse {
168                message: message.to_string(),
169                documentation_url: None,
170            }),
171        )
172            .into_response()
173    }
174}
175
176#[derive(Serialize)]
177struct ErrorResponse {
178    message: String,
179    #[serde(skip_serializing_if = "Option::is_none")]
180    documentation_url: Option<String>,
181}
182
183// ==================== Helper Functions ====================
184
185/// Extract user from X-Guts-Identity header (temporary until token auth is fully integrated).
186fn get_identity_from_header(headers: &axum::http::HeaderMap) -> Option<String> {
187    headers
188        .get("X-Guts-Identity")
189        .and_then(|v| v.to_str().ok())
190        .map(|s| s.to_string())
191}
192
193/// Get user ID from identity header.
194fn get_user_from_identity(compat: &CompatStore, identity: &str) -> Option<User> {
195    compat
196        .users
197        .get_by_username(identity)
198        .or_else(|| compat.users.get_by_public_key(identity))
199}
200
201// ==================== User Handlers ====================
202
203/// Lists all users.
204async fn list_users(
205    State(state): State<AppState>,
206    Query(params): Query<PaginationParams>,
207) -> impl IntoResponse {
208    let users = state.compat.users.list();
209    let profiles: Vec<_> = users.iter().map(|u| u.to_profile(0, 0, 0)).collect();
210    let response = paginate(&profiles, &params);
211    Json(response.items)
212}
213
214/// Creates a new user.
215async fn create_user(
216    State(state): State<AppState>,
217    Json(req): Json<CreateUserRequest>,
218) -> Result<impl IntoResponse, CompatApiError> {
219    let mut user = state.compat.users.create(req.username, req.public_key)?;
220
221    if let Some(email) = req.email {
222        user.email = Some(email);
223    }
224    if let Some(name) = req.name {
225        user.display_name = Some(name);
226    }
227    if user.email.is_some() || user.display_name.is_some() {
228        user = state.compat.users.update(user)?;
229    }
230
231    Ok((StatusCode::CREATED, Json(user.to_profile(0, 0, 0))))
232}
233
234/// Gets a user by username.
235async fn get_user(
236    State(state): State<AppState>,
237    Path(username): Path<String>,
238) -> Result<impl IntoResponse, CompatApiError> {
239    let user = state
240        .compat
241        .users
242        .get_by_username(&username)
243        .ok_or(CompatError::UserNotFound(username))?;
244
245    // Count repos owned by user
246    let repos = state.repos.list();
247    let repo_count = repos.iter().filter(|r| r.owner == user.username).count() as u64;
248
249    Ok(Json(user.to_profile(repo_count, 0, 0)))
250}
251
252/// Updates a user by username.
253async fn update_user_by_name(
254    State(state): State<AppState>,
255    Path(username): Path<String>,
256    Json(req): Json<UpdateUserRequest>,
257) -> Result<impl IntoResponse, CompatApiError> {
258    let mut user = state
259        .compat
260        .users
261        .get_by_username(&username)
262        .ok_or(CompatError::UserNotFound(username))?;
263
264    if let Some(name) = req.name {
265        user.display_name = Some(name);
266    }
267    if let Some(email) = req.email {
268        user.email = Some(email);
269    }
270    if let Some(bio) = req.bio {
271        user.bio = Some(bio);
272    }
273    if let Some(location) = req.location {
274        user.location = Some(location);
275    }
276    if let Some(blog) = req.blog {
277        user.website = Some(blog);
278    }
279    if let Some(email_public) = req.email_public {
280        user.email_public = email_public;
281    }
282
283    user.touch();
284    let updated = state.compat.users.update(user)?;
285
286    Ok(Json(updated.to_profile(0, 0, 0)))
287}
288
289/// Gets the current authenticated user.
290async fn get_current_user(
291    State(state): State<AppState>,
292    headers: axum::http::HeaderMap,
293) -> Result<impl IntoResponse, CompatApiError> {
294    let identity = get_identity_from_header(&headers).ok_or(CompatError::TokenNotFound)?;
295
296    let user = get_user_from_identity(&state.compat, &identity)
297        .ok_or(CompatError::UserNotFound(identity))?;
298
299    let repos = state.repos.list();
300    let repo_count = repos.iter().filter(|r| r.owner == user.username).count() as u64;
301
302    Ok(Json(user.to_profile(repo_count, 0, 0)))
303}
304
305/// Updates the current authenticated user.
306async fn update_current_user(
307    State(state): State<AppState>,
308    headers: axum::http::HeaderMap,
309    Json(req): Json<UpdateUserRequest>,
310) -> Result<impl IntoResponse, CompatApiError> {
311    let identity = get_identity_from_header(&headers).ok_or(CompatError::TokenNotFound)?;
312
313    let mut user = get_user_from_identity(&state.compat, &identity)
314        .ok_or(CompatError::UserNotFound(identity))?;
315
316    if let Some(name) = req.name {
317        user.display_name = Some(name);
318    }
319    if let Some(email) = req.email {
320        user.email = Some(email);
321    }
322    if let Some(bio) = req.bio {
323        user.bio = Some(bio);
324    }
325    if let Some(location) = req.location {
326        user.location = Some(location);
327    }
328    if let Some(blog) = req.blog {
329        user.website = Some(blog);
330    }
331    if let Some(email_public) = req.email_public {
332        user.email_public = email_public;
333    }
334
335    user.touch();
336    let updated = state.compat.users.update(user)?;
337
338    Ok(Json(updated.to_profile(0, 0, 0)))
339}
340
341// ==================== Token Handlers ====================
342
343/// Lists tokens for the authenticated user.
344async fn list_tokens(
345    State(state): State<AppState>,
346    headers: axum::http::HeaderMap,
347) -> Result<impl IntoResponse, CompatApiError> {
348    let identity = get_identity_from_header(&headers).ok_or(CompatError::TokenNotFound)?;
349
350    let user = get_user_from_identity(&state.compat, &identity)
351        .ok_or(CompatError::UserNotFound(identity))?;
352
353    let tokens = state.compat.tokens.list_for_user(user.id);
354    let responses: Vec<_> = tokens.iter().map(|t| t.to_response(None)).collect();
355
356    Ok(Json(responses))
357}
358
359/// Creates a new personal access token.
360async fn create_token(
361    State(state): State<AppState>,
362    headers: axum::http::HeaderMap,
363    Json(req): Json<CreateTokenRequest>,
364) -> Result<impl IntoResponse, CompatApiError> {
365    let identity = get_identity_from_header(&headers).ok_or(CompatError::TokenNotFound)?;
366
367    let user = get_user_from_identity(&state.compat, &identity)
368        .ok_or(CompatError::UserNotFound(identity))?;
369
370    let expires_at = req.expires_in_days.map(|days| {
371        std::time::SystemTime::now()
372            .duration_since(std::time::UNIX_EPOCH)
373            .unwrap()
374            .as_secs()
375            + (days as u64 * 86400)
376    });
377
378    let (token, plaintext) = state
379        .compat
380        .tokens
381        .create(user.id, req.name, req.scopes, expires_at)?;
382
383    Ok((
384        StatusCode::CREATED,
385        Json(token.to_response(Some(&plaintext))),
386    ))
387}
388
389/// Gets a token by ID.
390async fn get_token(
391    State(state): State<AppState>,
392    headers: axum::http::HeaderMap,
393    Path(id): Path<u64>,
394) -> Result<impl IntoResponse, CompatApiError> {
395    let identity = get_identity_from_header(&headers).ok_or(CompatError::TokenNotFound)?;
396
397    let user = get_user_from_identity(&state.compat, &identity)
398        .ok_or(CompatError::UserNotFound(identity))?;
399
400    let token = state
401        .compat
402        .tokens
403        .get(id)
404        .filter(|t| t.user_id == user.id)
405        .ok_or(CompatError::TokenNotFound)?;
406
407    Ok(Json(token.to_response(None)))
408}
409
410/// Revokes a token.
411async fn revoke_token(
412    State(state): State<AppState>,
413    headers: axum::http::HeaderMap,
414    Path(id): Path<u64>,
415) -> Result<impl IntoResponse, CompatApiError> {
416    let identity = get_identity_from_header(&headers).ok_or(CompatError::TokenNotFound)?;
417
418    let user = get_user_from_identity(&state.compat, &identity)
419        .ok_or(CompatError::UserNotFound(identity))?;
420
421    // Verify token belongs to user
422    let token = state
423        .compat
424        .tokens
425        .get(id)
426        .filter(|t| t.user_id == user.id)
427        .ok_or(CompatError::TokenNotFound)?;
428
429    state.compat.tokens.revoke(token.id)?;
430
431    Ok(StatusCode::NO_CONTENT)
432}
433
434// ==================== SSH Key Handlers ====================
435
436/// Lists SSH keys for the authenticated user.
437async fn list_ssh_keys(
438    State(state): State<AppState>,
439    headers: axum::http::HeaderMap,
440) -> Result<impl IntoResponse, CompatApiError> {
441    let identity = get_identity_from_header(&headers).ok_or(CompatError::TokenNotFound)?;
442
443    let user = get_user_from_identity(&state.compat, &identity)
444        .ok_or(CompatError::UserNotFound(identity))?;
445
446    let keys = state.compat.ssh_keys.list_for_user(user.id);
447    let responses: Vec<_> = keys.iter().map(|k| k.to_response()).collect();
448
449    Ok(Json(responses))
450}
451
452/// Adds an SSH key.
453async fn add_ssh_key(
454    State(state): State<AppState>,
455    headers: axum::http::HeaderMap,
456    Json(req): Json<AddSshKeyRequest>,
457) -> Result<impl IntoResponse, CompatApiError> {
458    let identity = get_identity_from_header(&headers).ok_or(CompatError::TokenNotFound)?;
459
460    let user = get_user_from_identity(&state.compat, &identity)
461        .ok_or(CompatError::UserNotFound(identity))?;
462
463    let key = state.compat.ssh_keys.add(user.id, req.title, req.key)?;
464
465    Ok((StatusCode::CREATED, Json(key.to_response())))
466}
467
468/// Gets an SSH key by ID.
469async fn get_ssh_key(
470    State(state): State<AppState>,
471    headers: axum::http::HeaderMap,
472    Path(id): Path<u64>,
473) -> Result<impl IntoResponse, CompatApiError> {
474    let identity = get_identity_from_header(&headers).ok_or(CompatError::TokenNotFound)?;
475
476    let user = get_user_from_identity(&state.compat, &identity)
477        .ok_or(CompatError::UserNotFound(identity))?;
478
479    let key = state
480        .compat
481        .ssh_keys
482        .get(id)
483        .filter(|k| k.user_id == user.id)
484        .ok_or(CompatError::SshKeyNotFound)?;
485
486    Ok(Json(key.to_response()))
487}
488
489/// Removes an SSH key.
490async fn remove_ssh_key(
491    State(state): State<AppState>,
492    headers: axum::http::HeaderMap,
493    Path(id): Path<u64>,
494) -> Result<impl IntoResponse, CompatApiError> {
495    let identity = get_identity_from_header(&headers).ok_or(CompatError::TokenNotFound)?;
496
497    let user = get_user_from_identity(&state.compat, &identity)
498        .ok_or(CompatError::UserNotFound(identity))?;
499
500    // Verify key belongs to user
501    let key = state
502        .compat
503        .ssh_keys
504        .get(id)
505        .filter(|k| k.user_id == user.id)
506        .ok_or(CompatError::SshKeyNotFound)?;
507
508    state.compat.ssh_keys.remove(key.id)?;
509
510    Ok(StatusCode::NO_CONTENT)
511}
512
513// ==================== Release Handlers ====================
514
515/// Lists releases for a repository.
516async fn list_releases(
517    State(state): State<AppState>,
518    Path((owner, name)): Path<(String, String)>,
519    Query(params): Query<PaginationParams>,
520) -> impl IntoResponse {
521    let repo_key = format!("{}/{}", owner, name);
522    let releases = state.compat.releases.list(&repo_key);
523    let responses: Vec<_> = releases.iter().map(|r| r.to_response()).collect();
524    let paginated = paginate(&responses, &params);
525    Json(paginated.items)
526}
527
528/// Creates a new release.
529async fn create_release(
530    State(state): State<AppState>,
531    Path((owner, name)): Path<(String, String)>,
532    headers: axum::http::HeaderMap,
533    Json(req): Json<CreateReleaseRequest>,
534) -> Result<impl IntoResponse, CompatApiError> {
535    let identity = get_identity_from_header(&headers).unwrap_or_else(|| "anonymous".to_string());
536
537    let repo_key = format!("{}/{}", owner, name);
538    let target = req.target_commitish.unwrap_or_else(|| "main".to_string());
539
540    let mut release = state
541        .compat
542        .releases
543        .create(repo_key, req.tag_name, target, identity)?;
544
545    release.name = req.name;
546    release.body = req.body;
547    release.draft = req.draft;
548    release.prerelease = req.prerelease;
549
550    if release.draft {
551        release.published_at = None;
552    }
553
554    let updated = state.compat.releases.update(release)?;
555
556    Ok((StatusCode::CREATED, Json(updated.to_response())))
557}
558
559/// Gets the latest release.
560async fn get_latest_release(
561    State(state): State<AppState>,
562    Path((owner, name)): Path<(String, String)>,
563) -> Result<impl IntoResponse, CompatApiError> {
564    let repo_key = format!("{}/{}", owner, name);
565    let release = state
566        .compat
567        .releases
568        .get_latest(&repo_key)
569        .ok_or_else(|| CompatError::ReleaseNotFound("latest".to_string()))?;
570
571    Ok(Json(release.to_response()))
572}
573
574/// Gets a release by tag.
575async fn get_release_by_tag(
576    State(state): State<AppState>,
577    Path((owner, name, tag)): Path<(String, String, String)>,
578) -> Result<impl IntoResponse, CompatApiError> {
579    let repo_key = format!("{}/{}", owner, name);
580    let release = state
581        .compat
582        .releases
583        .get_by_tag(&repo_key, &tag)
584        .ok_or(CompatError::ReleaseNotFound(tag))?;
585
586    Ok(Json(release.to_response()))
587}
588
589/// Gets a release by ID.
590async fn get_release(
591    State(state): State<AppState>,
592    Path((owner, name, id)): Path<(String, String, u64)>,
593) -> Result<impl IntoResponse, CompatApiError> {
594    let repo_key = format!("{}/{}", owner, name);
595    let release = state
596        .compat
597        .releases
598        .get(id)
599        .filter(|r| r.repo_key == repo_key)
600        .ok_or_else(|| CompatError::ReleaseNotFound(id.to_string()))?;
601
602    Ok(Json(release.to_response()))
603}
604
605/// Updates a release.
606async fn update_release(
607    State(state): State<AppState>,
608    Path((owner, name, id)): Path<(String, String, u64)>,
609    Json(req): Json<UpdateReleaseRequest>,
610) -> Result<impl IntoResponse, CompatApiError> {
611    let repo_key = format!("{}/{}", owner, name);
612    let mut release = state
613        .compat
614        .releases
615        .get(id)
616        .filter(|r| r.repo_key == repo_key)
617        .ok_or_else(|| CompatError::ReleaseNotFound(id.to_string()))?;
618
619    if let Some(tag) = req.tag_name {
620        release.tag_name = tag;
621    }
622    if let Some(target) = req.target_commitish {
623        release.target_commitish = target;
624    }
625    if let Some(name) = req.name {
626        release.name = Some(name);
627    }
628    if let Some(body) = req.body {
629        release.body = Some(body);
630    }
631    if let Some(draft) = req.draft {
632        release.draft = draft;
633        if !draft && release.published_at.is_none() {
634            release.publish();
635        }
636    }
637    if let Some(prerelease) = req.prerelease {
638        release.prerelease = prerelease;
639    }
640
641    let updated = state.compat.releases.update(release)?;
642
643    Ok(Json(updated.to_response()))
644}
645
646/// Deletes a release.
647async fn delete_release(
648    State(state): State<AppState>,
649    Path((owner, name, id)): Path<(String, String, u64)>,
650) -> Result<impl IntoResponse, CompatApiError> {
651    let repo_key = format!("{}/{}", owner, name);
652
653    // Verify release belongs to this repo
654    let _ = state
655        .compat
656        .releases
657        .get(id)
658        .filter(|r| r.repo_key == repo_key)
659        .ok_or_else(|| CompatError::ReleaseNotFound(id.to_string()))?;
660
661    state.compat.releases.delete(id)?;
662
663    Ok(StatusCode::NO_CONTENT)
664}
665
666// ==================== Contents Handlers ====================
667
668#[derive(Deserialize)]
669struct ContentsQuery {
670    #[serde(rename = "ref")]
671    git_ref: Option<String>,
672}
673
674/// Gets root contents of a repository.
675async fn get_contents_root(
676    State(state): State<AppState>,
677    Path((owner, name)): Path<(String, String)>,
678    Query(query): Query<ContentsQuery>,
679) -> Result<impl IntoResponse, CompatApiError> {
680    get_contents_internal(&state, &owner, &name, "", query.git_ref.as_deref()).await
681}
682
683/// Gets contents at a specific path.
684async fn get_contents(
685    State(state): State<AppState>,
686    Path((owner, name, path)): Path<(String, String, String)>,
687    Query(query): Query<ContentsQuery>,
688) -> Result<impl IntoResponse, CompatApiError> {
689    get_contents_internal(&state, &owner, &name, &path, query.git_ref.as_deref()).await
690}
691
692/// Internal function to get contents.
693async fn get_contents_internal(
694    state: &AppState,
695    owner: &str,
696    name: &str,
697    path: &str,
698    git_ref: Option<&str>,
699) -> Result<impl IntoResponse, CompatApiError> {
700    let repo = state
701        .repos
702        .get(owner, name)
703        .map_err(|_| CompatError::PathNotFound(format!("{}/{}", owner, name)))?;
704
705    // Resolve ref to commit
706    let ref_name = git_ref.unwrap_or("HEAD");
707    let commit_sha = resolve_ref(&repo, ref_name)
708        .ok_or_else(|| CompatError::InvalidRef(ref_name.to_string()))?;
709
710    // Get commit and tree
711    let commit = repo
712        .objects
713        .get(&commit_sha)
714        .map_err(|_| CompatError::InvalidRef(ref_name.to_string()))?;
715
716    let tree_sha =
717        parse_commit_tree(&commit).ok_or_else(|| CompatError::InvalidRef(ref_name.to_string()))?;
718
719    // Navigate to the requested path
720    let entries = if path.is_empty() {
721        // Root directory
722        let tree = get_tree(&repo, &tree_sha)
723            .ok_or_else(|| CompatError::PathNotFound("root".to_string()))?;
724
725        tree.iter()
726            .map(|e| {
727                let content_type = match e.mode {
728                    0o040000 => ContentType::Dir,
729                    0o120000 => ContentType::Symlink,
730                    0o160000 => ContentType::Submodule,
731                    _ => ContentType::File,
732                };
733
734                let mut entry = match content_type {
735                    ContentType::Dir => {
736                        ContentEntry::dir(e.name.clone(), e.name.clone(), e.oid.to_hex())
737                    }
738                    ContentType::File => {
739                        let size = get_blob(&repo, &e.oid).map(|b| b.len()).unwrap_or(0) as u64;
740                        ContentEntry::file(e.name.clone(), e.name.clone(), e.oid.to_hex(), size)
741                    }
742                    _ => ContentEntry::file(e.name.clone(), e.name.clone(), e.oid.to_hex(), 0),
743                };
744                entry.content_type = content_type;
745                entry
746            })
747            .collect()
748    } else {
749        // Navigate path
750        let parts: Vec<&str> = path.split('/').filter(|p| !p.is_empty()).collect();
751        let mut current_tree_sha = tree_sha;
752
753        for (i, part) in parts.iter().enumerate() {
754            let tree = get_tree(&repo, &current_tree_sha)
755                .ok_or_else(|| CompatError::PathNotFound(path.to_string()))?;
756
757            let entry = tree
758                .iter()
759                .find(|e| e.name == *part)
760                .ok_or_else(|| CompatError::PathNotFound(path.to_string()))?;
761
762            if i == parts.len() - 1 {
763                // Last part - could be file or directory
764                if entry.mode == 0o040000 {
765                    // Directory
766                    let tree = get_tree(&repo, &entry.oid)
767                        .ok_or_else(|| CompatError::PathNotFound(path.to_string()))?;
768
769                    let entries: Vec<ContentEntry> = tree
770                        .iter()
771                        .map(|e| {
772                            let full_path = format!("{}/{}", path, e.name);
773                            let content_type = match e.mode {
774                                0o040000 => ContentType::Dir,
775                                0o120000 => ContentType::Symlink,
776                                0o160000 => ContentType::Submodule,
777                                _ => ContentType::File,
778                            };
779
780                            let mut entry = match content_type {
781                                ContentType::Dir => {
782                                    ContentEntry::dir(e.name.clone(), full_path, e.oid.to_hex())
783                                }
784                                ContentType::File => {
785                                    let size = get_blob(&repo, &e.oid).map(|b| b.len()).unwrap_or(0)
786                                        as u64;
787                                    ContentEntry::file(
788                                        e.name.clone(),
789                                        full_path,
790                                        e.oid.to_hex(),
791                                        size,
792                                    )
793                                }
794                                _ => {
795                                    ContentEntry::file(e.name.clone(), full_path, e.oid.to_hex(), 0)
796                                }
797                            };
798                            entry.content_type = content_type;
799                            entry
800                        })
801                        .collect();
802
803                    return Ok(Json(serde_json::to_value(entries).unwrap()));
804                } else {
805                    // File
806                    let blob = get_blob(&repo, &entry.oid)
807                        .ok_or_else(|| CompatError::PathNotFound(path.to_string()))?;
808
809                    let content = base64_encode(&blob);
810                    let file_entry = ContentEntry::file(
811                        entry.name.clone(),
812                        path.to_string(),
813                        entry.oid.to_hex(),
814                        blob.len() as u64,
815                    )
816                    .with_content(content);
817
818                    return Ok(Json(serde_json::to_value(file_entry).unwrap()));
819                }
820            } else {
821                // Not last part - must be directory
822                if entry.mode != 0o040000 {
823                    return Err(CompatError::PathNotFound(path.to_string()).into());
824                }
825                current_tree_sha = entry.oid;
826            }
827        }
828
829        Vec::new()
830    };
831
832    Ok(Json(serde_json::to_value(entries).unwrap()))
833}
834
835/// Gets the README file.
836async fn get_readme(
837    State(state): State<AppState>,
838    Path((owner, name)): Path<(String, String)>,
839    Query(query): Query<ContentsQuery>,
840) -> Result<impl IntoResponse, CompatApiError> {
841    let repo = state
842        .repos
843        .get(&owner, &name)
844        .map_err(|_| CompatError::PathNotFound(format!("{}/{}", owner, name)))?;
845
846    // Resolve ref
847    let ref_name = query.git_ref.as_deref().unwrap_or("HEAD");
848    let commit_sha = resolve_ref(&repo, ref_name)
849        .ok_or_else(|| CompatError::InvalidRef(ref_name.to_string()))?;
850
851    let commit = repo
852        .objects
853        .get(&commit_sha)
854        .map_err(|_| CompatError::InvalidRef(ref_name.to_string()))?;
855
856    let tree_sha =
857        parse_commit_tree(&commit).ok_or_else(|| CompatError::InvalidRef(ref_name.to_string()))?;
858
859    let tree =
860        get_tree(&repo, &tree_sha).ok_or_else(|| CompatError::PathNotFound("root".to_string()))?;
861
862    // Find README file
863    let readme_entry = tree
864        .iter()
865        .find(|e| is_readme_file(&e.name))
866        .ok_or_else(|| CompatError::PathNotFound("README".to_string()))?;
867
868    let blob = get_blob(&repo, &readme_entry.oid)
869        .ok_or_else(|| CompatError::PathNotFound("README".to_string()))?;
870
871    let content = base64_encode(&blob);
872    let entry = ContentEntry::file(
873        readme_entry.name.clone(),
874        readme_entry.name.clone(),
875        readme_entry.oid.to_hex(),
876        blob.len() as u64,
877    )
878    .with_content(content);
879
880    Ok(Json(entry))
881}
882
883// ==================== Archive Handlers ====================
884
885/// Downloads a tarball.
886async fn get_tarball(
887    State(state): State<AppState>,
888    Path((owner, name, git_ref)): Path<(String, String, String)>,
889) -> Result<Response, CompatApiError> {
890    get_archive(&state, &owner, &name, &git_ref, ArchiveFormat::TarGz).await
891}
892
893/// Downloads a zipball.
894async fn get_zipball(
895    State(state): State<AppState>,
896    Path((owner, name, git_ref)): Path<(String, String, String)>,
897) -> Result<Response, CompatApiError> {
898    get_archive(&state, &owner, &name, &git_ref, ArchiveFormat::Zip).await
899}
900
901/// Internal function to generate an archive.
902async fn get_archive(
903    state: &AppState,
904    owner: &str,
905    name: &str,
906    git_ref: &str,
907    format: ArchiveFormat,
908) -> Result<Response, CompatApiError> {
909    let repo = state
910        .repos
911        .get(owner, name)
912        .map_err(|_| CompatError::PathNotFound(format!("{}/{}", owner, name)))?;
913
914    // Resolve ref
915    let commit_sha =
916        resolve_ref(&repo, git_ref).ok_or_else(|| CompatError::InvalidRef(git_ref.to_string()))?;
917
918    let commit = repo
919        .objects
920        .get(&commit_sha)
921        .map_err(|_| CompatError::InvalidRef(git_ref.to_string()))?;
922
923    let tree_sha =
924        parse_commit_tree(&commit).ok_or_else(|| CompatError::InvalidRef(git_ref.to_string()))?;
925
926    // Collect all files
927    let mut entries = Vec::new();
928    collect_tree_entries(&repo, tree_sha, "", &mut entries);
929
930    // Create archive
931    let prefix = format!("{}-{}", name, git_ref.replace('/', "-"));
932    let archive = create_archive(format, prefix.clone(), entries)
933        .map_err(|e| CompatError::ArchiveFailed(e.to_string()))?;
934
935    let filename = format.filename(name, git_ref);
936    let content_type = format.content_type();
937
938    Ok(Response::builder()
939        .status(StatusCode::OK)
940        .header(header::CONTENT_TYPE, content_type)
941        .header(
942            header::CONTENT_DISPOSITION,
943            format!("attachment; filename=\"{}\"", filename),
944        )
945        .body(Body::from(archive))
946        .unwrap())
947}
948
949/// Recursively collect tree entries for archive.
950fn collect_tree_entries(
951    repo: &Repository,
952    tree_sha: ObjectId,
953    prefix: &str,
954    entries: &mut Vec<ArchiveEntry>,
955) {
956    if let Some(tree) = get_tree(repo, &tree_sha) {
957        for entry in &tree {
958            let path = if prefix.is_empty() {
959                entry.name.clone()
960            } else {
961                format!("{}/{}", prefix, entry.name)
962            };
963
964            if entry.mode == 0o040000 {
965                // Directory - recurse
966                collect_tree_entries(repo, entry.oid, &path, entries);
967            } else if let Some(blob) = get_blob(repo, &entry.oid) {
968                // File
969                let archive_entry = if entry.mode == 0o100755 {
970                    ArchiveEntry::executable(path, blob)
971                } else {
972                    ArchiveEntry::file(path, blob)
973                };
974                entries.push(archive_entry);
975            }
976        }
977    }
978}
979
980// ==================== Rate Limit Handler ====================
981
982/// Gets the rate limit status.
983async fn get_rate_limit(
984    State(state): State<AppState>,
985    headers: axum::http::HeaderMap,
986) -> impl IntoResponse {
987    let identity = get_identity_from_header(&headers).unwrap_or_else(|| "anonymous".to_string());
988    let authenticated = state.compat.users.get_by_username(&identity).is_some();
989
990    let response = state
991        .compat
992        .rate_limiter
993        .get_response(&identity, authenticated);
994
995    Json(response)
996}
997
998// ==================== Git Object Parsing Helpers ====================
999
1000/// Parsed tree entry.
1001struct TreeEntry {
1002    name: String,
1003    mode: u32,
1004    oid: ObjectId,
1005}
1006
1007/// Parse tree ID from commit object.
1008fn parse_commit_tree(commit: &GitObject) -> Option<ObjectId> {
1009    if commit.object_type != ObjectType::Commit {
1010        return None;
1011    }
1012
1013    let content = String::from_utf8_lossy(&commit.data);
1014    for line in content.lines() {
1015        if let Some(tree_hex) = line.strip_prefix("tree ") {
1016            return ObjectId::from_hex(tree_hex.trim()).ok();
1017        }
1018    }
1019
1020    None
1021}
1022
1023/// Parse raw tree data into entries.
1024fn parse_tree_entries(data: &[u8]) -> Vec<TreeEntry> {
1025    let mut entries = Vec::new();
1026    let mut i = 0;
1027
1028    while i < data.len() {
1029        // Find the space after mode
1030        let space_pos = match data[i..].iter().position(|&b| b == b' ') {
1031            Some(pos) => pos,
1032            None => break,
1033        };
1034
1035        let mode_str = String::from_utf8_lossy(&data[i..i + space_pos]);
1036        let mode = u32::from_str_radix(&mode_str, 8).unwrap_or(0);
1037        i += space_pos + 1;
1038
1039        // Find the null byte after name
1040        let null_pos = match data[i..].iter().position(|&b| b == 0) {
1041            Some(pos) => pos,
1042            None => break,
1043        };
1044
1045        let name = String::from_utf8_lossy(&data[i..i + null_pos]).to_string();
1046        i += null_pos + 1;
1047
1048        // Read 20-byte SHA
1049        if i + 20 > data.len() {
1050            break;
1051        }
1052        let mut sha_bytes = [0u8; 20];
1053        sha_bytes.copy_from_slice(&data[i..i + 20]);
1054        let oid = ObjectId::from_bytes(sha_bytes);
1055        i += 20;
1056
1057        entries.push(TreeEntry { name, mode, oid });
1058    }
1059
1060    entries
1061}
1062
1063/// Get a tree object from the store.
1064fn get_tree(repo: &Repository, id: &ObjectId) -> Option<Vec<TreeEntry>> {
1065    let obj = repo.objects.get(id).ok()?;
1066    if obj.object_type != ObjectType::Tree {
1067        return None;
1068    }
1069    Some(parse_tree_entries(&obj.data))
1070}
1071
1072/// Get blob content from the store.
1073fn get_blob(repo: &Repository, id: &ObjectId) -> Option<Vec<u8>> {
1074    let obj = repo.objects.get(id).ok()?;
1075    if obj.object_type != ObjectType::Blob {
1076        return None;
1077    }
1078    Some(obj.data.to_vec())
1079}
1080
1081// ==================== Helper Functions ====================
1082
1083/// Resolve a ref to a commit SHA.
1084fn resolve_ref(repo: &Repository, ref_name: &str) -> Option<ObjectId> {
1085    // Try as direct ref
1086    if let Ok(reference) = repo.refs.get(ref_name) {
1087        return Some(resolve_reference(repo, reference));
1088    }
1089
1090    // Try as branch
1091    let branch_ref = format!("refs/heads/{}", ref_name);
1092    if let Ok(reference) = repo.refs.get(&branch_ref) {
1093        return Some(resolve_reference(repo, reference));
1094    }
1095
1096    // Try as tag
1097    let tag_ref = format!("refs/tags/{}", ref_name);
1098    if let Ok(reference) = repo.refs.get(&tag_ref) {
1099        return Some(resolve_reference(repo, reference));
1100    }
1101
1102    // Try as SHA
1103    if ref_name.len() >= 7 {
1104        // Find commit by prefix
1105        for sha in repo.objects.list_objects() {
1106            let sha_str = sha.to_hex();
1107            if sha_str.starts_with(ref_name) {
1108                return Some(sha);
1109            }
1110        }
1111    }
1112
1113    None
1114}
1115
1116/// Resolve a reference to a direct object ID.
1117fn resolve_reference(repo: &Repository, reference: Reference) -> ObjectId {
1118    match reference {
1119        Reference::Direct(oid) => oid,
1120        Reference::Symbolic(name) => {
1121            if let Ok(r) = repo.refs.get(&name) {
1122                resolve_reference(repo, r)
1123            } else {
1124                // Shouldn't happen, but return zero as fallback
1125                ObjectId::from_bytes([0u8; 20])
1126            }
1127        }
1128    }
1129}
1130
1131#[cfg(test)]
1132mod tests {
1133    use super::*;
1134
1135    #[test]
1136    fn test_error_response() {
1137        let err = CompatApiError(CompatError::UserNotFound("test".into()));
1138        let response = err.into_response();
1139        assert_eq!(response.status(), StatusCode::NOT_FOUND);
1140    }
1141}