Skip to content

Commit 85f5054

Browse files
author
epriestley
committed
Support serving SVN repositories over SSH
Summary: Ref T2230. The SVN protocol has a sensible protocol format with a good spec here: http://svn.apache.org/repos/asf/subversion/trunk/subversion/libsvn_ra_svn/protocol Particularly, compare this statement to the clown show that is the Mercurial wire protocol: > It is possible to parse an item without knowing its type in advance. WHAT A REASONABLE STATEMENT TO BE ABLE TO MAKE ABOUT A WIRE PROTOCOL Although it makes substantially more sense than Mercurial, it's much heavier-weight than the Git or Mercurial protocols, since it isn't distributed. It's also not possible to figure out if a request is a write request (or even which repository it is against) without proxying some of the protocol frames. Finally, several protocol commands embed repository URLs, and we need to reach into the protocol and translate them. Test Plan: Ran various SVN commands over SSH (`svn log`, `svn up`, `svn commit`, etc). Reviewers: btrahan Reviewed By: btrahan CC: aran Maniphest Tasks: T2230 Differential Revision: https://secure.phabricator.com/D7556
1 parent 8840f60 commit 85f5054

11 files changed

+582
-29
lines changed

scripts/ssh/ssh-exec.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@
6161

6262
$workflows = array(
6363
new ConduitSSHWorkflow(),
64+
new DiffusionSSHSubversionServeWorkflow(),
6465
new DiffusionSSHMercurialServeWorkflow(),
6566
new DiffusionSSHGitUploadPackWorkflow(),
6667
new DiffusionSSHGitReceivePackWorkflow(),

src/__phutil_library_map__.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -547,10 +547,14 @@
547547
'DiffusionSSHMercurialWireClientProtocolChannel' => 'applications/diffusion/ssh/DiffusionSSHMercurialWireClientProtocolChannel.php',
548548
'DiffusionSSHMercurialWireTestCase' => 'applications/diffusion/ssh/__tests__/DiffusionSSHMercurialWireTestCase.php',
549549
'DiffusionSSHMercurialWorkflow' => 'applications/diffusion/ssh/DiffusionSSHMercurialWorkflow.php',
550+
'DiffusionSSHSubversionServeWorkflow' => 'applications/diffusion/ssh/DiffusionSSHSubversionServeWorkflow.php',
551+
'DiffusionSSHSubversionWorkflow' => 'applications/diffusion/ssh/DiffusionSSHSubversionWorkflow.php',
550552
'DiffusionSSHWorkflow' => 'applications/diffusion/ssh/DiffusionSSHWorkflow.php',
551553
'DiffusionServeController' => 'applications/diffusion/controller/DiffusionServeController.php',
552554
'DiffusionSetPasswordPanel' => 'applications/diffusion/panel/DiffusionSetPasswordPanel.php',
553555
'DiffusionSetupException' => 'applications/diffusion/exception/DiffusionSetupException.php',
556+
'DiffusionSubversionWireProtocol' => 'applications/diffusion/protocol/DiffusionSubversionWireProtocol.php',
557+
'DiffusionSubversionWireProtocolTestCase' => 'applications/diffusion/protocol/__tests__/DiffusionSubversionWireProtocolTestCase.php',
554558
'DiffusionSvnCommitParentsQuery' => 'applications/diffusion/query/parents/DiffusionSvnCommitParentsQuery.php',
555559
'DiffusionSvnFileContentQuery' => 'applications/diffusion/query/filecontent/DiffusionSvnFileContentQuery.php',
556560
'DiffusionSvnRawDiffQuery' => 'applications/diffusion/query/rawdiff/DiffusionSvnRawDiffQuery.php',
@@ -2816,10 +2820,14 @@
28162820
'DiffusionSSHMercurialWireClientProtocolChannel' => 'PhutilProtocolChannel',
28172821
'DiffusionSSHMercurialWireTestCase' => 'PhabricatorTestCase',
28182822
'DiffusionSSHMercurialWorkflow' => 'DiffusionSSHWorkflow',
2823+
'DiffusionSSHSubversionServeWorkflow' => 'DiffusionSSHSubversionWorkflow',
2824+
'DiffusionSSHSubversionWorkflow' => 'DiffusionSSHWorkflow',
28192825
'DiffusionSSHWorkflow' => 'PhabricatorSSHWorkflow',
28202826
'DiffusionServeController' => 'DiffusionController',
28212827
'DiffusionSetPasswordPanel' => 'PhabricatorSettingsPanel',
28222828
'DiffusionSetupException' => 'AphrontUsageException',
2829+
'DiffusionSubversionWireProtocol' => 'Phobject',
2830+
'DiffusionSubversionWireProtocolTestCase' => 'PhabricatorTestCase',
28232831
'DiffusionSvnCommitParentsQuery' => 'DiffusionCommitParentsQuery',
28242832
'DiffusionSvnFileContentQuery' => 'DiffusionFileContentQuery',
28252833
'DiffusionSvnRawDiffQuery' => 'DiffusionRawDiffQuery',
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
<?php
2+
3+
final class DiffusionSubversionWireProtocol extends Phobject {
4+
5+
private $buffer = '';
6+
private $state = 'item';
7+
private $expectBytes = 0;
8+
private $byteBuffer = '';
9+
private $stack = array();
10+
private $list = array();
11+
private $raw = '';
12+
13+
private function pushList() {
14+
$this->stack[] = $this->list;
15+
$this->list = array();
16+
}
17+
18+
private function popList() {
19+
$list = $this->list;
20+
$this->list = array_pop($this->stack);
21+
return $list;
22+
}
23+
24+
private function pushItem($item, $type) {
25+
$this->list[] = array(
26+
'type' => $type,
27+
'value' => $item,
28+
);
29+
}
30+
31+
public function writeData($data) {
32+
$this->buffer .= $data;
33+
34+
$messages = array();
35+
while (true) {
36+
if ($this->state == 'item') {
37+
$match = null;
38+
$result = null;
39+
$buf = $this->buffer;
40+
if (preg_match('/^([a-z][a-z0-9-]*)\s/i', $buf, $match)) {
41+
$this->pushItem($match[1], 'word');
42+
} else if (preg_match('/^(\d+)\s/', $buf, $match)) {
43+
$this->pushItem((int)$match[1], 'number');
44+
} else if (preg_match('/^(\d+):/', $buf, $match)) {
45+
// NOTE: The "+ 1" includes the space after the string.
46+
$this->expectBytes = (int)$match[1] + 1;
47+
$this->state = 'bytes';
48+
} else if (preg_match('/^(\\()\s/', $buf, $match)) {
49+
$this->pushList();
50+
} else if (preg_match('/^(\\))\s/', $buf, $match)) {
51+
$list = $this->popList();
52+
if ($this->stack) {
53+
$this->pushItem($list, 'list');
54+
} else {
55+
$result = $list;
56+
}
57+
} else {
58+
$match = false;
59+
}
60+
61+
if ($match !== false) {
62+
$this->raw .= substr($this->buffer, 0, strlen($match[0]));
63+
$this->buffer = substr($this->buffer, strlen($match[0]));
64+
65+
if ($result !== null) {
66+
$messages[] = array(
67+
'structure' => $list,
68+
'raw' => $this->raw,
69+
);
70+
$this->raw = '';
71+
}
72+
} else {
73+
// No matches yet, wait for more data.
74+
break;
75+
}
76+
} else if ($this->state == 'bytes') {
77+
$new_data = substr($this->buffer, 0, $this->expectBytes);
78+
$this->buffer = substr($this->buffer, strlen($new_data));
79+
80+
$this->expectBytes -= strlen($new_data);
81+
$this->raw .= $new_data;
82+
$this->byteBuffer .= $new_data;
83+
84+
if (!$this->expectBytes) {
85+
$this->state = 'byte-space';
86+
// Strip off the terminal space.
87+
$this->pushItem(substr($this->byteBuffer, 0, -1), 'string');
88+
$this->byteBuffer = '';
89+
$this->state = 'item';
90+
}
91+
} else {
92+
throw new Exception("Invalid state '{$this->state}'!");
93+
}
94+
}
95+
96+
return $messages;
97+
}
98+
99+
/**
100+
* Convert a parsed command struct into a wire protocol string.
101+
*/
102+
public function serializeStruct(array $struct) {
103+
$out = array();
104+
105+
$out[] = '( ';
106+
foreach ($struct as $item) {
107+
$value = $item['value'];
108+
$type = $item['type'];
109+
switch ($type) {
110+
case 'word':
111+
$out[] = $value;
112+
break;
113+
case 'number':
114+
$out[] = $value;
115+
break;
116+
case 'string':
117+
$out[] = strlen($value).':'.$value;
118+
break;
119+
case 'list':
120+
$out[] = self::serializeStruct($value);
121+
break;
122+
default:
123+
throw new Exception("Unknown SVN wire protocol structure '{$type}'!");
124+
}
125+
if ($type != 'list') {
126+
$out[] = ' ';
127+
}
128+
}
129+
$out[] = ') ';
130+
131+
return implode('', $out);
132+
}
133+
134+
public function isReadOnlyCommand(array $struct) {
135+
if (empty($struct[0]['type']) || ($struct[0]['type'] != 'word')) {
136+
// This isn't what we expect; fail defensively.
137+
throw new Exception(
138+
pht("Unexpected command structure, expected '( word ... )'."));
139+
}
140+
141+
switch ($struct[0]['value']) {
142+
// Authentication command set.
143+
case 'EXTERNAL':
144+
145+
// The "Main" command set. Some of the commands in this command set are
146+
// mutation commands, and are omitted from this list.
147+
case 'reparent':
148+
case 'get-latest-rev':
149+
case 'get-dated-rev':
150+
case 'rev-proplist':
151+
case 'rev-prop':
152+
case 'get-file':
153+
case 'get-dir':
154+
case 'check-path':
155+
case 'stat':
156+
case 'update':
157+
case 'get-mergeinfo':
158+
case 'switch':
159+
case 'status':
160+
case 'diff':
161+
case 'log':
162+
case 'get-file-revs':
163+
case 'get-locations':
164+
165+
// The "Report" command set. These are not actually mutation
166+
// operations, they just define a request for information.
167+
case 'set-path':
168+
case 'delete-path':
169+
case 'link-path':
170+
case 'finish-report':
171+
case 'abort-report':
172+
173+
// These are used to report command results.
174+
case 'success':
175+
case 'failure':
176+
177+
// If we get here, we've matched some known read-only command.
178+
return true;
179+
default:
180+
// Anything else isn't a known read-only command, so require write
181+
// access to use it.
182+
break;
183+
}
184+
185+
return false;
186+
}
187+
188+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
<?php
2+
3+
final class DiffusionSubversionWireProtocolTestCase
4+
extends PhabricatorTestCase {
5+
6+
public function testSubversionWireProtocolParser() {
7+
$this->assertSameSubversionMessages(
8+
'( ) ',
9+
array(
10+
array(
11+
),
12+
));
13+
14+
$this->assertSameSubversionMessages(
15+
'( duck 5:quack 42 ( item1 item2 ) ) ',
16+
array(
17+
array(
18+
array(
19+
'type' => 'word',
20+
'value' => 'duck',
21+
),
22+
array(
23+
'type' => 'string',
24+
'value' => 'quack',
25+
),
26+
array(
27+
'type' => 'number',
28+
'value' => 42,
29+
),
30+
array(
31+
'type' => 'list',
32+
'value' => array(
33+
array(
34+
'type' => 'word',
35+
'value' => 'item1',
36+
),
37+
array(
38+
'type' => 'word',
39+
'value' => 'item2',
40+
),
41+
),
42+
),
43+
),
44+
));
45+
46+
$this->assertSameSubversionMessages(
47+
'( msg1 ) ( msg2 ) ',
48+
array(
49+
array(
50+
array(
51+
'type' => 'word',
52+
'value' => 'msg1',
53+
),
54+
),
55+
array(
56+
array(
57+
'type' => 'word',
58+
'value' => 'msg2',
59+
),
60+
),
61+
));
62+
}
63+
64+
private function assertSameSubversionMessages($string, array $structs) {
65+
$proto = new DiffusionSubversionWireProtocol();
66+
67+
// Verify that the wire message parses into the structs.
68+
$messages = $proto->writeData($string);
69+
$messages = ipull($messages, 'structure');
70+
$this->assertEqual($structs, $messages, 'parse<'.$string.'>');
71+
72+
// Verify that the structs serialize into the wire message.
73+
$serial = array();
74+
foreach ($structs as $struct) {
75+
$serial[] = $proto->serializeStruct($struct);
76+
}
77+
$serial = implode('', $serial);
78+
$this->assertEqual($string, $serial, 'serialize<'.$string.'>');
79+
}
80+
}

src/applications/diffusion/ssh/DiffusionSSHGitReceivePackWorkflow.php

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,20 +14,18 @@ public function didConstruct() {
1414
));
1515
}
1616

17-
public function getRequestPath() {
17+
protected function executeRepositoryOperations() {
1818
$args = $this->getArgs();
19-
return head($args->getArg('dir'));
20-
}
21-
22-
protected function executeRepositoryOperations(
23-
PhabricatorRepository $repository) {
19+
$path = head($args->getArg('dir'));
20+
$repository = $this->loadRepository($path);
2421

2522
// This is a write, and must have write access.
2623
$this->requireWriteAccess();
2724

2825
$future = new ExecFuture(
2926
'git-receive-pack %s',
3027
$repository->getLocalPath());
28+
3129
$err = $this->newPassthruCommand()
3230
->setIOChannel($this->getIOChannel())
3331
->setCommandChannelFromExecFuture($future)

src/applications/diffusion/ssh/DiffusionSSHGitUploadPackWorkflow.php

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,10 @@ public function didConstruct() {
1414
));
1515
}
1616

17-
public function getRequestPath() {
17+
protected function executeRepositoryOperations() {
1818
$args = $this->getArgs();
19-
return head($args->getArg('dir'));
20-
}
21-
22-
protected function executeRepositoryOperations(
23-
PhabricatorRepository $repository) {
19+
$path = head($args->getArg('dir'));
20+
$repository = $this->loadRepository($path);
2421

2522
$future = new ExecFuture('git-upload-pack %s', $repository->getLocalPath());
2623

src/applications/diffusion/ssh/DiffusionSSHMercurialServeWorkflow.php

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,10 @@ public function didConstruct() {
2424
));
2525
}
2626

27-
public function getRequestPath() {
28-
return $this->getArgs()->getArg('repository');
29-
}
30-
31-
protected function executeRepositoryOperations(
32-
PhabricatorRepository $repository) {
27+
protected function executeRepositoryOperations() {
28+
$args = $this->getArgs();
29+
$path = $args->getArg('repository');
30+
$repository = $this->loadRepository($path);
3331

3432
$args = $this->getArgs();
3533

0 commit comments

Comments
 (0)