OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-sync commits / 15e8819

Add optional push-authorization hook for git-receive-pack
git-upload-pack (read) stays open; git-receive-pack (push) is served only when an authorize({ service, repo, remotePublicKey }) hook approves it. Default (no hook) preserves the prior read-only behavior, so existing callers are unaffected. Enables ACL-gated push over the P2P swarm transport for pear-git-v3 (#37 item 2). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
15e88191f7f4fcbe3fab23d3a4a4b52fb6059e60
by plankatron <fleeky@gmail.com> · 2026-05-25T23:24:37.000Z

Changed files (2) +128 -3

index.js +26 -3
……@@ -19,6 +19,14 @@ module.exports = class HyperSync extends ReadyResource {
1919 this._ownSwarm = !this._swarm;
2020 this._keypair = opts.keypair || null;
2121
22 // Optional push authorization hook. git-upload-pack (read) is always
23 // allowed; any write service (git-receive-pack) is rejected UNLESS
24 // this hook approves it. Signature:
25 // async ({ service, repo, remotePublicKey }) => boolean
26 // When absent, behavior is read-only (back-compat — the prior code
27 // hard-rejected everything that wasn't git-upload-pack).
28 this._authorize = typeof opts.authorize === 'function' ? opts.authorize : null;
29
2230 this.repos = new Map();
2331 this.remotes = new Map();
2432 this._topics = new Map();
……@@ -389,13 +397,28 @@ module.exports = class HyperSync extends ReadyResource {
389397 return;
390398 }
391399
392 // Only allow git-upload-pack (read-only) for now
393 if (service !== 'git-upload-pack') {
400 // git-upload-pack (read) is always open — clone/fetch is unrestricted.
401 if (service === 'git-upload-pack') {
402 this._spawnGitService(conn, service, repoPath, remaining);
403 return;
404 }
405
406 // Any write service (git-receive-pack) requires the authorize hook
407 // to approve. Without a hook configured, stay read-only (back-compat).
408 if (!this._authorize) {
394409 conn.destroy();
395410 return;
396411 }
397412
398 this._spawnGitService(conn, service, repoPath, remaining);
413 // Pause until the (async) decision lands so no pack bytes are dropped
414 // in the gap; _spawnGitService resumes the conn once it attaches.
415 conn.pause();
416 Promise.resolve(this._authorize({ service, repo, remotePublicKey: conn.remotePublicKey }))
417 .then((ok) => {
418 if (ok) this._spawnGitService(conn, service, repoPath, remaining);
419 else conn.destroy();
420 })
421 .catch(() => conn.destroy());
399422 };
400423
401424 conn.on('data', headerHandler);
test/push-acl.test.js +102 -0
……@@ -0,0 +1,102 @@
1// Push ACL gating in the swarm connection handler (issue #37, item 2).
2//
3// git-upload-pack (read) is always open; git-receive-pack (push) is
4// rejected unless the `authorize` hook approves it. Drives the real
5// _onConnection logic with a fake conn + stubbed _spawnGitService so no
6// swarm or real git process is involved.
7
8const test = require('brittle');
9const fs = require('fs');
10const os = require('os');
11const path = require('path');
12const EventEmitter = require('events');
13
14const HyperSync = require('../');
15
16function makeReposDir () {
17 const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hs-acl-'));
18 fs.mkdirSync(path.join(dir, 'r.git'), { recursive: true }); // existsSync(repoPath) → true
19 return dir;
20}
21
22function fakeConn () {
23 const c = new EventEmitter();
24 c.destroyed = false;
25 c.paused = false;
26 c.remotePublicKey = Buffer.from('ab'.repeat(32), 'hex');
27 c.destroy = () => { c.destroyed = true; c.emit('close'); };
28 c.pause = () => { c.paused = true; };
29 c.resume = () => { c.paused = false; };
30 c.write = () => true;
31 return c;
32}
33
34// Minimal `this` with just what _onConnection touches — exercises the real
35// gating branch without constructing a swarm-backed HyperSync.
36function ctx (reposDir, authorize, spawned) {
37 return {
38 _reposDir: reposDir,
39 _topics: new Map([['r', {}]]),
40 _authorize: authorize,
41 emit () {},
42 _spawnGitService (conn, service) { spawned.push(service); }
43 };
44}
45
46function header (service, repo) {
47 return Buffer.from(service + '\0' + repo + '\0');
48}
49
50const tick = () => new Promise((r) => setTimeout(r, 30));
51
52test('upload-pack (read) is always served — no authorize hook needed', async (t) => {
53 const reposDir = makeReposDir();
54 const spawned = [];
55 const conn = fakeConn();
56 HyperSync.prototype._onConnection.call(ctx(reposDir, null, spawned), conn, {});
57 conn.emit('data', header('git-upload-pack', 'r'));
58 await tick();
59 t.alike(spawned, ['git-upload-pack'], 'upload-pack spawned');
60 t.absent(conn.destroyed, 'read connection not destroyed');
61});
62
63test('receive-pack (push) is rejected when no authorize hook is configured (back-compat)', async (t) => {
64 const reposDir = makeReposDir();
65 const spawned = [];
66 const conn = fakeConn();
67 HyperSync.prototype._onConnection.call(ctx(reposDir, null, spawned), conn, {});
68 conn.emit('data', header('git-receive-pack', 'r'));
69 await tick();
70 t.alike(spawned, [], 'nothing spawned');
71 t.ok(conn.destroyed, 'push connection destroyed (read-only default)');
72});
73
74test('receive-pack is served when authorize approves', async (t) => {
75 const reposDir = makeReposDir();
76 const spawned = [];
77 const seen = [];
78 const authorize = async (info) => { seen.push(info); return true; };
79 const conn = fakeConn();
80 HyperSync.prototype._onConnection.call(ctx(reposDir, authorize, spawned), conn, {});
81 conn.emit('data', header('git-receive-pack', 'r'));
82 await tick();
83 t.is(seen.length, 1, 'authorize was consulted');
84 t.is(seen[0].service, 'git-receive-pack', 'authorize got the service');
85 t.is(seen[0].repo, 'r', 'authorize got the repo');
86 t.ok(seen[0].remotePublicKey && seen[0].remotePublicKey.equals(conn.remotePublicKey),
87 'authorize got the peer noise key');
88 t.alike(spawned, ['git-receive-pack'], 'receive-pack spawned after approval');
89 t.absent(conn.destroyed, 'approved push not destroyed');
90});
91
92test('receive-pack is rejected when authorize denies', async (t) => {
93 const reposDir = makeReposDir();
94 const spawned = [];
95 const authorize = async () => false;
96 const conn = fakeConn();
97 HyperSync.prototype._onConnection.call(ctx(reposDir, authorize, spawned), conn, {});
98 conn.emit('data', header('git-receive-pack', 'r'));
99 await tick();
100 t.alike(spawned, [], 'nothing spawned on denial');
101 t.ok(conn.destroyed, 'denied push destroyed');
102});