/**
* 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