upleb.uk

Public git repos — served from a NIP-34 GRASP relay at git.upleb.uk

summaryrefslogtreecommitdiff
path: root/src/git/handlers.rs
blob: 27bec7602aff0800e944120709b3f87123a8bac1 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
//! Git HTTP Protocol Handlers
//!
//! This module implements the HTTP handlers for Git Smart HTTP protocol.

use std::path::PathBuf;
use hyper::{body::Bytes, Response, StatusCode};
use http_body_util::Full;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tracing::{debug, error, info, warn};

use super::authorization::{
    AuthorizationContext, AuthorizationResult, parse_pushed_refs, validate_push_refs,
};
use super::protocol::{GitService, PktLine};
use super::subprocess::GitSubprocess;

/// Handle GET /info/refs?service=git-{upload,receive}-pack
///
/// This advertises the repository's refs to the client.
pub async fn handle_info_refs(
    repo_path: PathBuf,
    service: GitService,
) -> Result<Response<Full<Bytes>>, GitError> {
    debug!("Handling info/refs for {:?} with service {:?}", repo_path, service);

    // Check if repository exists
    if !repo_path.exists() {
        warn!("Repository not found: {:?}", repo_path);
        return Err(GitError::RepositoryNotFound);
    }

    // Spawn git with --advertise-refs
    let mut git = GitSubprocess::spawn(service, &repo_path, true)
        .map_err(|e| {
            error!("Failed to spawn git process: {}", e);
            GitError::ProcessSpawnFailed(e)
        })?;

    // Read the output from git
    let mut output = Vec::new();
    let mut stderr_output = Vec::new();
    
    if let Some(stdout) = git.take_stdout() {
        let mut stdout = stdout;
        stdout.read_to_end(&mut output).await
            .map_err(|e| {
                error!("Failed to read git output: {}", e);
                GitError::IoError(e)
            })?;
    }
    
    if let Some(stderr) = git.take_stderr() {
        let mut stderr = stderr;
        stderr.read_to_end(&mut stderr_output).await
            .map_err(|e| {
                error!("Failed to read git stderr: {}", e);
                GitError::IoError(e)
            })?;
    }

    // Wait for process to complete
    let status = git.wait().await
        .map_err(|e| {
            error!("Failed to wait for git process: {}", e);
            GitError::IoError(e)
        })?;

    if !status.success() {
        let stderr_str = String::from_utf8_lossy(&stderr_output);
        error!("Git process failed with status: {:?}, stderr: {}", status, stderr_str);
        return Err(GitError::GitFailed(status.code()));
    }

    // Build response with pkt-line header
    let mut response_body = Vec::new();
    
    // First line: service advertisement
    let service_line = format!("# service={}\n", service.as_str());
    response_body.extend_from_slice(&PktLine::data(service_line.as_bytes()).encode());
    response_body.extend_from_slice(&PktLine::flush().encode());
    
    // Then the git output
    response_body.extend_from_slice(&output);

    Ok(Response::builder()
        .status(StatusCode::OK)
        .header("content-type", service.advertisement_content_type())
        .header("cache-control", "no-cache")
        .body(Full::new(Bytes::from(response_body)))
        .unwrap())
}

/// Handle POST /git-upload-pack (clone/fetch)
pub async fn handle_upload_pack(
    repo_path: PathBuf,
    request_body: Bytes,
) -> Result<Response<Full<Bytes>>, GitError> {
    debug!("Handling upload-pack for {:?}", repo_path);

    if !repo_path.exists() {
        return Err(GitError::RepositoryNotFound);
    }

    // Spawn git upload-pack
    let mut git = GitSubprocess::spawn(GitService::UploadPack, &repo_path, false)
        .map_err(GitError::ProcessSpawnFailed)?;

    // Write request to git's stdin
    if let Some(mut stdin) = git.take_stdin() {
        stdin.write_all(&request_body).await
            .map_err(GitError::IoError)?;
        // Close stdin to signal end of input
        drop(stdin);
    }

    // Read response from git's stdout
    let mut output = Vec::new();
    let mut stderr_output = Vec::new();
    
    if let Some(stdout) = git.take_stdout() {
        let mut stdout = stdout;
        stdout.read_to_end(&mut output).await
            .map_err(GitError::IoError)?;
    }
    
    if let Some(stderr) = git.take_stderr() {
        let mut stderr = stderr;
        stderr.read_to_end(&mut stderr_output).await
            .map_err(GitError::IoError)?;
    }

    // Wait for process
    let status = git.wait().await
        .map_err(GitError::IoError)?;

    if !status.success() {
        let stderr_str = String::from_utf8_lossy(&stderr_output);
        error!("Git upload-pack failed: {}", stderr_str);
        return Err(GitError::GitFailed(status.code()));
    }

    Ok(Response::builder()
        .status(StatusCode::OK)
        .header("content-type", GitService::UploadPack.result_content_type())
        .header("cache-control", "no-cache")
        .body(Full::new(Bytes::from(output)))
        .unwrap())
}

/// Authorization parameters for push operations
#[derive(Debug, Clone)]
pub struct PushAuthParams {
    /// The relay URL for fetching events (e.g., "ws://localhost:8080")
    pub relay_url: String,
    /// The npub of the repository owner
    pub owner_npub: String,
    /// The repository identifier (d tag)
    pub identifier: String,
}

/// Handle POST /git-receive-pack (push)
///
/// This includes GRASP authorization validation according to GRASP-01:
/// "MUST accept pushes via this service that match the latest repo state announcement
/// on the relay, respecting the recursive maintainer set."
pub async fn handle_receive_pack(
    repo_path: PathBuf,
    request_body: Bytes,
    auth_params: Option<PushAuthParams>,
) -> Result<Response<Full<Bytes>>, GitError> {
    debug!("Handling receive-pack for {:?}", repo_path);

    if !repo_path.exists() {
        return Err(GitError::RepositoryNotFound);
    }

    // GRASP Authorization Check
    if let Some(params) = auth_params {
        info!(
            "Authorizing push for {}/{} via {}",
            params.owner_npub, params.identifier, params.relay_url
        );

        match authorize_push(&params, &request_body).await {
            Ok(auth_result) => {
                if !auth_result.authorized {
                    warn!(
                        "Push rejected for {}/{}: {}",
                        params.owner_npub, params.identifier, auth_result.reason
                    );
                    return Err(GitError::Unauthorized);
                }
                info!(
                    "Push authorized for {}/{} - {} maintainers",
                    params.owner_npub,
                    params.identifier,
                    auth_result.maintainers.len()
                );
            }
            Err(e) => {
                warn!(
                    "Authorization check failed for {}/{}: {}",
                    params.owner_npub, params.identifier, e
                );
                return Err(GitError::Unauthorized);
            }
        }
    } else {
        debug!("No authorization parameters provided - accepting push");
    }

    // Spawn git receive-pack
    let mut git = GitSubprocess::spawn(GitService::ReceivePack, &repo_path, false)
        .map_err(GitError::ProcessSpawnFailed)?;

    // Write request to git's stdin
    if let Some(mut stdin) = git.take_stdin() {
        stdin.write_all(&request_body).await
            .map_err(GitError::IoError)?;
        drop(stdin);
    }

    // Read response from git's stdout
    let mut output = Vec::new();
    let mut stderr_output = Vec::new();
    
    if let Some(stdout) = git.take_stdout() {
        let mut stdout = stdout;
        stdout.read_to_end(&mut output).await
            .map_err(GitError::IoError)?;
    }
    
    if let Some(stderr) = git.take_stderr() {
        let mut stderr = stderr;
        stderr.read_to_end(&mut stderr_output).await
            .map_err(GitError::IoError)?;
    }

    // Wait for process
    let status = git.wait().await
        .map_err(GitError::IoError)?;

    if !status.success() {
        let stderr_str = String::from_utf8_lossy(&stderr_output);
        error!("Git receive-pack failed: {}", stderr_str);
        return Err(GitError::GitFailed(status.code()));
    }

    Ok(Response::builder()
        .status(StatusCode::OK)
        .header("content-type", GitService::ReceivePack.result_content_type())
        .header("cache-control", "no-cache")
        .body(Full::new(Bytes::from(output)))
        .unwrap())
}

/// Perform GRASP authorization for a push operation
///
/// This function:
/// 1. Fetches announcement and state events from the relay
/// 2. Collects all authorized publishers from announcements
/// 3. Gets the latest authorized state
/// 4. Validates that pushed refs match the state
async fn authorize_push(
    params: &PushAuthParams,
    request_body: &Bytes,
) -> anyhow::Result<AuthorizationResult> {
    use nostr_sdk::ClientBuilder;
    use std::time::Duration;

    debug!(
        "Fetching events for identifier {} from relay {}",
        params.identifier, params.relay_url
    );

    // Create a Nostr client to fetch events
    let client = ClientBuilder::default().build();
    client.add_relay(&params.relay_url).await?;
    client.connect().await;

    // Create filter for repository events
    let filter = AuthorizationContext::create_filter(&params.identifier);

    // Fetch events with timeout
    let events = client.fetch_events(filter, Duration::from_secs(5))
        .await
        .map_err(|e| anyhow::anyhow!("Failed to fetch events: {}", e))?;

    let events: Vec<_> = events.into_iter().collect();
    debug!("Fetched {} events from relay", events.len());

    if events.is_empty() {
        return Ok(AuthorizationResult::denied(
            "No repository announcement or state events found on relay",
        ));
    }

    // Create authorization context
    let ctx = AuthorizationContext::new(events);

    // Get the authorized state (no owner_pubkey needed - self-contained check)
    let auth_result = ctx.get_authorized_state(&params.identifier)?;

    if !auth_result.authorized {
        return Ok(auth_result);
    }

    // Parse refs from the push request
    let pushed_refs = parse_pushed_refs(request_body);
    debug!("Parsed {} refs from push request", pushed_refs.len());
    for (old_oid, new_oid, ref_name) in &pushed_refs {
        debug!("  {} {} -> {}", ref_name, old_oid, new_oid);
    }

    // Validate refs against state
    if let Some(ref state) = auth_result.state {
        debug!("Validating against state with {} branches", state.branches.len());
        
        // If we have a state event but couldn't parse any refs, reject the push.
        // This protects against parsing failures allowing unauthorized pushes.
        if pushed_refs.is_empty() && !state.branches.is_empty() {
            warn!("No refs parsed from push request but state event has branches - rejecting");
            return Ok(AuthorizationResult::denied(
                "Failed to parse refs from push request - cannot validate against state"
            ));
        }
        
        if let Err(e) = validate_push_refs(state, &pushed_refs) {
            warn!("Ref validation failed: {}", e);
            return Ok(AuthorizationResult::denied(format!(
                "Ref validation failed: {}",
                e
            )));
        }
        debug!("Ref validation passed");
    } else {
        warn!("No state in auth_result - cannot validate refs");
    }

    Ok(auth_result)
}

/// Errors that can occur in Git handlers
#[derive(Debug)]
pub enum GitError {
    RepositoryNotFound,
    ProcessSpawnFailed(std::io::Error),
    IoError(std::io::Error),
    GitFailed(Option<i32>),
    Unauthorized,
}

impl std::fmt::Display for GitError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::RepositoryNotFound => write!(f, "repository not found"),
            Self::ProcessSpawnFailed(e) => write!(f, "failed to spawn git process: {}", e),
            Self::IoError(e) => write!(f, "IO error: {}", e),
            Self::GitFailed(code) => write!(f, "git process failed with code: {:?}", code),
            Self::Unauthorized => write!(f, "unauthorized"),
        }
    }
}

impl std::error::Error for GitError {}

impl GitError {
    /// Convert to HTTP status code
    pub fn status_code(&self) -> StatusCode {
        match self {
            Self::RepositoryNotFound => StatusCode::NOT_FOUND,
            Self::Unauthorized => StatusCode::FORBIDDEN,
            _ => StatusCode::INTERNAL_SERVER_ERROR,
        }
    }
}