OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git / packages / git-remote-pear / lib / git-p2p-handler.js

247 lines · javascript ref:
/**
 * Git P2P Protocol Handler for Pear-Git
 *
 * Handles git smart protocol over Hyperswarm connections:
 * - git-upload-pack (clone/fetch)
 * - git-receive-pack (push)
 *
 * Protocol:
 * 1. Client connects to topic (derived from server key + repo name)
 * 2. Client sends: service\0repoName\0 (e.g., "git-upload-pack\0myrepo\0")
 * 3. Server verifies access, spawns git process
 * 4. Bidirectional proxy: client <-> git process
 */

import { spawn } from 'bare-subprocess'
import path from 'bare-path'

export class GitP2PHandler {
  constructor(options = {}) {
    this.reposPath = options.reposPath
    this.authManager = options.authManager || null
    this.repoRegistry = options.repoRegistry || null
    this.activeConnections = new Map()
  }

  /**
   * Handle incoming P2P connection
   * @param {Stream} conn - Hyperswarm connection
   * @param {Object} peerInfo - Peer information
   */
  async handleConnection(conn, peerInfo) {
    const connId = peerInfo.publicKey.toString('hex').slice(0, 8)
    console.log(`[GitP2P] Connection from ${connId}`)

    let headerReceived = false
    let buffer = Buffer.alloc(0)

    // Header parser - will be removed after header is received
    const headerHandler = async (chunk) => {
      if (headerReceived) return // Safety check

      buffer = Buffer.concat([buffer, chunk])

      // Look for double null terminator (service\0repo\0)
      const firstNull = buffer.indexOf(0)
      if (firstNull === -1) return

      const secondNull = buffer.indexOf(0, firstNull + 1)
      if (secondNull === -1) return

      headerReceived = true

      // CRITICAL: Remove this listener before setting up pipes
      conn.off('data', headerHandler)

      const service = buffer.slice(0, firstNull).toString('utf-8')
      const repoName = buffer.slice(firstNull + 1, secondNull).toString('utf-8')
      const remaining = buffer.slice(secondNull + 1)

      console.log(`[GitP2P] ${connId}: ${service} ${repoName}`)

      try {
        await this._handleGitService(conn, service, repoName, remaining, peerInfo)
      } catch (err) {
        console.error(`[GitP2P] Error: ${err.message}`)
        this._sendError(conn, err.message)
        conn.destroy()
      }
    }

    conn.on('data', headerHandler)

    conn.on('error', (err) => {
      console.error(`[GitP2P] Connection error ${connId}: ${err.message}`)
    })

    conn.on('close', () => {
      console.log(`[GitP2P] Connection closed ${connId}`)
      this._cleanupConnection(connId)
    })

    this.activeConnections.set(connId, { conn, peerInfo })
  }

  /**
   * Handle a git service request
   */
  async _handleGitService(conn, service, repoName, initialData, peerInfo) {
    // Validate service
    const allowedServices = ['git-upload-pack', 'git-receive-pack']
    if (!allowedServices.includes(service)) {
      throw new Error(`Unknown service: ${service}`)
    }

    // Validate repo exists and is accessible
    const repoPath = await this._validateRepo(repoName, service, peerInfo)

    // Spawn git process
    console.log(`[GitP2P] Spawning: ${service} ${repoPath}`)
    const gitProcess = spawn(service, [repoPath], {
      stdio: ['pipe', 'pipe', 'pipe']
    })

    // Send any remaining data from initial packet
    if (initialData.length > 0) {
      console.log(`[GitP2P] Sending initial data: ${initialData.length} bytes`)
      gitProcess.stdin.write(initialData)
    }

    // Log data flowing through
    gitProcess.stdout.on('data', (chunk) => {
      console.log(`[GitP2P] git stdout: ${chunk.length} bytes`)
    })

    // Handle pipe errors to prevent uncaught exceptions
    gitProcess.stdin.on('error', (err) => {
      if (err.code !== 'EPIPE') {
        console.error(`[GitP2P] git stdin error: ${err.message}`)
      }
    })

    // Bidirectional proxy: conn <-> git
    conn.pipe(gitProcess.stdin)
    gitProcess.stdout.pipe(conn)

    gitProcess.stderr.on('data', (data) => {
      console.error(`[GitP2P] git stderr: ${data.toString()}`)
    })

    gitProcess.on('exit', (code) => {
      console.log(`[GitP2P] git process exited with code ${code}`)
      conn.end()
    })

    gitProcess.on('error', (err) => {
      console.error(`[GitP2P] git process error: ${err.message}`)
      conn.destroy()
    })
  }

  /**
   * Validate repository access
   */
  async _validateRepo(repoName, service, peerInfo) {
    // Check if repo exists in registry
    if (this.repoRegistry) {
      const repo = await this.repoRegistry.getRepo(repoName)
      if (!repo) {
        throw new Error(`Repository not found: ${repoName}`)
      }

      // For push (receive-pack), check write access
      if (service === 'git-receive-pack') {
        // TODO: Implement P2P authentication
        // For now, only allow upload-pack (read-only)
        throw new Error('Push over P2P not yet implemented')
      }

      // For clone/fetch (upload-pack), check visibility
      if (repo.visibility === 'private') {
        // TODO: Implement P2P authentication for private repos
        throw new Error('Private repos over P2P not yet implemented')
      }

      // Use path from registry if available
      if (repo.path) {
        console.log(`[GitP2P] Using repo path from registry: ${repo.path}`)
        return repo.path
      }
    }

    // Fallback: Construct repo path
    const repoPath = path.join(this.reposPath, repoName)
    if (!repoPath.endsWith('.git')) {
      return repoPath + '.git'
    }
    return repoPath
  }

  /**
   * Send error message to client
   */
  _sendError(conn, message) {
    // Git protocol error format
    const errorLine = `ERR ${message}\n`
    const length = (4 + errorLine.length).toString(16).padStart(4, '0')
    conn.write(length + errorLine)
  }

  /**
   * Clean up connection state
   */
  _cleanupConnection(connId) {
    this.activeConnections.delete(connId)
  }

  /**
   * Get active connection count
   */
  getConnectionCount() {
    return this.activeConnections.size
  }

  /**
   * Destroy all connections
   */
  async destroy() {
    for (const [id, { conn }] of this.activeConnections) {
      conn.destroy()
    }
    this.activeConnections.clear()
  }
}

/**
 * Client-side git P2P helper
 * Used by git-remote-pear
 */
export class GitP2PClient {
  constructor(options = {}) {
    this.petnames = options.petnames
  }

  /**
   * Initiate a git operation over P2P
   * @param {Stream} conn - Hyperswarm connection
   * @param {string} service - git-upload-pack or git-receive-pack
   * @param {string} repoName - Repository name
   */
  async initiate(conn, service, repoName) {
    // Send service header: service\0repo\0
    const header = Buffer.concat([
      Buffer.from(service, 'utf-8'),
      Buffer.from([0]),
      Buffer.from(repoName, 'utf-8'),
      Buffer.from([0])
    ])

    conn.write(header)

    // Return connection for bidirectional communication
    return conn
  }
}

export default GitP2PHandler