1#
2#
3# Licensed to the Apache Software Foundation (ASF) under one
4# or more contributor license agreements.  See the NOTICE file
5# distributed with this work for additional information
6# regarding copyright ownership.  The ASF licenses this file
7# to you under the Apache License, Version 2.0 (the
8# "License"); you may not use this file except in compliance
9# with the License.  You may obtain a copy of the License at
10#
11#   http://www.apache.org/licenses/LICENSE-2.0
12#
13# Unless required by applicable law or agreed to in writing,
14# software distributed under the License is distributed on an
15# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
16# KIND, either express or implied.  See the License for the
17# specific language governing permissions and limitations
18# under the License.
19#
20#
21import unittest, setup_path, os, sys
22from sys import version_info # For Python version check
23from io import BytesIO
24from svn import core, repos, fs, delta
25from svn.core import SubversionException
26import utils
27
28class ChangeReceiver(delta.Editor):
29  """A delta editor which saves textdeltas for later use"""
30
31  def __init__(self, src_root, tgt_root):
32    self.src_root = src_root
33    self.tgt_root = tgt_root
34    self.textdeltas = []
35
36  def apply_textdelta(self, file_baton, base_checksum, pool=None):
37    def textdelta_handler(textdelta):
38      if textdelta is not None:
39        self.textdeltas.append(textdelta)
40    return textdelta_handler
41
42class DumpStreamParser(repos.ParseFns3):
43  def __init__(self):
44    repos.ParseFns3.__init__(self)
45    self.ops = []
46  def magic_header_record(self, version, pool=None):
47    self.ops.append((b"magic-header", version))
48  def uuid_record(self, uuid, pool=None):
49    self.ops.append((b"uuid", uuid))
50  def new_revision_record(self, headers, pool=None):
51    rev = int(headers[repos.DUMPFILE_REVISION_NUMBER])
52    self.ops.append((b"new-revision", rev))
53    return rev
54  def close_revision(self, revision_baton):
55    self.ops.append((b"close-revision", revision_baton))
56  def new_node_record(self, headers, revision_baton, pool=None):
57    node = headers[repos.DUMPFILE_NODE_PATH]
58    self.ops.append((b"new-node", revision_baton, node))
59    return (revision_baton, node)
60  def close_node(self, node_baton):
61    self.ops.append((b"close-node", node_baton[0], node_baton[1]))
62  def set_revision_property(self, revision_baton, name, value):
63    self.ops.append((b"set-revision-prop", revision_baton, name, value))
64  def set_node_property(self, node_baton, name, value):
65    self.ops.append((b"set-node-prop", node_baton[0], node_baton[1], name, value))
66  def remove_node_props(self, node_baton):
67    self.ops.append((b"remove-node-props", node_baton[0], node_baton[1]))
68  def delete_node_property(self, node_baton, name):
69    self.ops.append((b"delete-node-prop", node_baton[0], node_baton[1], name))
70  def apply_textdelta(self, node_baton):
71    self.ops.append((b"apply-textdelta", node_baton[0], node_baton[1]))
72    return None
73  def set_fulltext(self, node_baton):
74    self.ops.append((b"set-fulltext", node_baton[0], node_baton[1]))
75    return None
76
77
78def _authz_callback(root, path, pool):
79  "A dummy authz callback which always returns success."
80  return 1
81
82class SubversionRepositoryTestCase(unittest.TestCase):
83  """Test cases for the Subversion repository layer"""
84
85  def setUp(self):
86    """Load a Subversion repository"""
87    self.temper = utils.Temper()
88    (self.repos, self.repos_path, _) = self.temper.alloc_known_repo(
89      'trac/versioncontrol/tests/svnrepos.dump', suffix='-repository')
90    self.fs = repos.fs(self.repos)
91    self.rev = fs.youngest_rev(self.fs)
92
93  def tearDown(self):
94    self.fs = None
95    self.repos = None
96    self.temper.cleanup()
97
98  def test_cease_invocation(self):
99    """Test returning SVN_ERR_CEASE_INVOCATION from a callback"""
100
101    revs = []
102    def history_lookup(path, rev, pool):
103      revs.append(rev)
104      raise core.SubversionException(apr_err=core.SVN_ERR_CEASE_INVOCATION,
105                                     message="Hi from history_lookup")
106
107    repos.history2(self.fs, b'/trunk/README2.txt', history_lookup, None, 0,
108                   self.rev, True)
109    self.assertEqual(len(revs), 1)
110
111  def test_create(self):
112    """Make sure that repos.create doesn't segfault when we set fs-type
113       using a config hash"""
114    fs_config = { b"fs-type": b"fsfs" }
115    for i in range(5):
116      path = self.temper.alloc_empty_dir(suffix='-repository-create%d' % i)
117      repos.create(path, b"", b"", None, fs_config)
118
119  def test_dump_fs2(self):
120    """Test the dump_fs2 function"""
121
122    self.callback_calls = 0
123
124    def is_cancelled():
125      self.callback_calls += 1
126      return None
127
128    dumpstream = BytesIO()
129    feedbackstream = BytesIO()
130    repos.dump_fs2(self.repos, dumpstream, feedbackstream, 0, self.rev, 0, 0,
131                   is_cancelled)
132
133    # Check that we can dump stuff
134    dump = dumpstream.getvalue()
135    feedback = feedbackstream.getvalue()
136    expected_feedback = b"* Dumped revision " + str(self.rev).encode('utf-8')
137    self.assertEqual(dump.count(b"Node-path: trunk/README.txt"), 2)
138    self.assertEqual(feedback.count(expected_feedback), 1)
139    self.assertEqual(self.callback_calls, 13)
140
141    # Check that the dump can be cancelled
142    self.assertRaises(SubversionException, repos.dump_fs2,
143      self.repos, dumpstream, feedbackstream, 0, self.rev, 0, 0, lambda: 1)
144
145    dumpstream.close()
146    feedbackstream.close()
147
148    # Check that the dump fails when the dumpstream is closed
149    self.assertRaises(ValueError, repos.dump_fs2,
150      self.repos, dumpstream, feedbackstream, 0, self.rev, 0, 0, None)
151
152    dumpstream = BytesIO()
153    feedbackstream = BytesIO()
154
155    # Check that we can grab the feedback stream, but not the dumpstream
156    repos.dump_fs2(self.repos, None, feedbackstream, 0, self.rev, 0, 0, None)
157    feedback = feedbackstream.getvalue()
158    self.assertEqual(feedback.count(expected_feedback), 1)
159
160    # Check that we can grab the dumpstream, but not the feedbackstream
161    repos.dump_fs2(self.repos, dumpstream, None, 0, self.rev, 0, 0, None)
162    dump = dumpstream.getvalue()
163    self.assertEqual(dump.count(b"Node-path: trunk/README.txt"), 2)
164
165    # Check that we can ignore both the dumpstream and the feedbackstream
166    repos.dump_fs2(self.repos, dumpstream, None, 0, self.rev, 0, 0, None)
167    self.assertEqual(feedback.count(expected_feedback), 1)
168
169    # FIXME: The Python bindings don't check for 'NULL' values for
170    #        svn_repos_t objects, so the following call segfaults
171    #repos.dump_fs2(None, None, None, 0, self.rev, 0, 0, None)
172
173  def test_parse_fns3(self):
174    self.cancel_calls = 0
175    def is_cancelled():
176      self.cancel_calls += 1
177      return None
178    dump_path = os.path.join(os.path.dirname(sys.argv[0]),
179        "trac/versioncontrol/tests/svnrepos.dump")
180    stream = open(dump_path, 'rb')
181    dsp = DumpStreamParser()
182    ptr, baton = repos.make_parse_fns3(dsp)
183    repos.parse_dumpstream3(stream, ptr, baton, False, is_cancelled)
184    stream.close()
185    self.assertEqual(self.cancel_calls, 76)
186    expected_list = [
187        (b"magic-header", 2),
188        (b'uuid', b'92ea810a-adf3-0310-b540-bef912dcf5ba'),
189        (b'new-revision', 0),
190        (b'set-revision-prop', 0, b'svn:date', b'2005-04-01T09:57:41.312767Z'),
191        (b'close-revision', 0),
192        (b'new-revision', 1),
193        (b'set-revision-prop', 1, b'svn:log', b'Initial directory layout.'),
194        (b'set-revision-prop', 1, b'svn:author', b'john'),
195        (b'set-revision-prop', 1, b'svn:date', b'2005-04-01T10:00:52.353248Z'),
196        (b'new-node', 1, b'branches'),
197        (b'remove-node-props', 1, b'branches'),
198        (b'close-node', 1, b'branches'),
199        (b'new-node', 1, b'tags'),
200        (b'remove-node-props', 1, b'tags'),
201        (b'close-node', 1, b'tags'),
202        (b'new-node', 1, b'trunk'),
203        (b'remove-node-props', 1, b'trunk'),
204        (b'close-node', 1, b'trunk'),
205        (b'close-revision', 1),
206        (b'new-revision', 2),
207        (b'set-revision-prop', 2, b'svn:log', b'Added README.'),
208        (b'set-revision-prop', 2, b'svn:author', b'john'),
209        (b'set-revision-prop', 2, b'svn:date', b'2005-04-01T13:12:18.216267Z'),
210        (b'new-node', 2, b'trunk/README.txt'),
211        (b'remove-node-props', 2, b'trunk/README.txt'),
212        (b'set-fulltext', 2, b'trunk/README.txt'),
213        (b'close-node', 2, b'trunk/README.txt'),
214        (b'close-revision', 2), (b'new-revision', 3),
215        (b'set-revision-prop', 3, b'svn:log', b'Fixed README.\n'),
216        (b'set-revision-prop', 3, b'svn:author', b'kate'),
217        (b'set-revision-prop', 3, b'svn:date', b'2005-04-01T13:24:58.234643Z'),
218        (b'new-node', 3, b'trunk/README.txt'),
219        (b'remove-node-props', 3, b'trunk/README.txt'),
220        (b'set-node-prop', 3, b'trunk/README.txt', b'svn:mime-type', b'text/plain'),
221        (b'set-node-prop', 3, b'trunk/README.txt', b'svn:eol-style', b'native'),
222        (b'set-fulltext', 3, b'trunk/README.txt'),
223        (b'close-node', 3, b'trunk/README.txt'), (b'close-revision', 3),
224        ]
225    # Compare only the first X nodes described in the expected list - otherwise
226    # the comparison list gets too long.
227    self.assertEqual(dsp.ops[:len(expected_list)], expected_list)
228
229  def test_parse_fns3_invalid_set_fulltext(self):
230    class DumpStreamParserSubclass(DumpStreamParser):
231      def set_fulltext(self, node_baton):
232        DumpStreamParser.set_fulltext(self, node_baton)
233        return 42
234    stream = open(os.path.join(os.path.dirname(sys.argv[0]),
235                               "trac/versioncontrol/tests/svnrepos.dump"), "rb")
236    try:
237      dsp = DumpStreamParserSubclass()
238      ptr, baton = repos.make_parse_fns3(dsp)
239      self.assertRaises(TypeError, repos.parse_dumpstream3,
240                        stream, ptr, baton, False, None)
241    finally:
242      stream.close()
243
244  def test_get_logs(self):
245    """Test scope of get_logs callbacks"""
246    logs = []
247    def addLog(paths, revision, author, date, message, pool):
248      if paths is not None:
249        logs.append(paths)
250
251    # Run get_logs
252    repos.get_logs(self.repos, [b'/'], self.rev, 0, True, 0, addLog)
253
254    # Count and verify changes
255    change_count = 0
256    for log in logs:
257      for path_changed in core._as_list(log.values()):
258        change_count += 1
259        path_changed.assert_valid()
260    self.assertEqual(logs[2][b"/tags/v1.1"].action, b"A")
261    self.assertEqual(logs[2][b"/tags/v1.1"].copyfrom_path, b"/branches/v1x")
262    self.assertEqual(len(logs), 12)
263    self.assertEqual(change_count, 19)
264
265  def test_dir_delta(self):
266    """Test scope of dir_delta callbacks"""
267    # Run dir_delta
268    this_root = fs.revision_root(self.fs, self.rev)
269    prev_root = fs.revision_root(self.fs, self.rev-1)
270    editor = ChangeReceiver(this_root, prev_root)
271    e_ptr, e_baton = delta.make_editor(editor)
272    repos.dir_delta(prev_root, b'', b'', this_root, b'', e_ptr, e_baton,
273                    _authz_callback, 1, 1, 0, 0)
274
275    # Check results.
276    # Ignore the order in which the editor delivers the two sibling files.
277    self.assertEqual(set([editor.textdeltas[0].new_data,
278                          editor.textdeltas[1].new_data]),
279                     set([b"This is a test.\n", b"A test.\n"]))
280    self.assertEqual(len(editor.textdeltas), 2)
281
282  def test_unnamed_editor(self):
283      """Test editor object without reference from interpreter"""
284      # Check that the delta.Editor object has proper lifetime. Without
285      # increment of the refcount in make_baton, the object was destroyed
286      # immediately because the interpreter does not hold a reference to it.
287      this_root = fs.revision_root(self.fs, self.rev)
288      prev_root = fs.revision_root(self.fs, self.rev-1)
289      e_ptr, e_baton = delta.make_editor(ChangeReceiver(this_root, prev_root))
290      repos.dir_delta(prev_root, b'', b'', this_root, b'', e_ptr, e_baton,
291              _authz_callback, 1, 1, 0, 0)
292
293  def test_retrieve_and_change_rev_prop(self):
294    """Test playing with revprops"""
295    self.assertEqual(repos.fs_revision_prop(self.repos, self.rev, b"svn:log",
296                                            _authz_callback),
297                     b"''(a few years later)'' Argh... v1.1 was buggy, "
298                     b"after all")
299
300    # We expect this to complain because we have no pre-revprop-change
301    # hook script for the repository.
302    self.assertRaises(SubversionException, repos.fs_change_rev_prop3,
303                      self.repos, self.rev, b"jrandom", b"svn:log",
304                      b"Youngest revision", True, True, _authz_callback)
305
306    repos.fs_change_rev_prop3(self.repos, self.rev, b"jrandom", b"svn:log",
307                              b"Youngest revision", False, False,
308                              _authz_callback)
309
310    self.assertEqual(repos.fs_revision_prop(self.repos, self.rev, b"svn:log",
311                                            _authz_callback),
312                     b"Youngest revision")
313
314  def freeze_body(self, pool):
315    self.freeze_invoked += 1
316
317  def test_freeze(self):
318    """Test repository freeze"""
319
320    self.freeze_invoked = 0
321    repos.freeze([self.repos_path], self.freeze_body)
322    self.assertEqual(self.freeze_invoked, 1)
323
324  def test_lock_unlock(self):
325    """Basic lock/unlock"""
326
327    access = fs.create_access(b'jrandom')
328    fs.set_access(self.fs, access)
329    fs.lock(self.fs, b'/trunk/README.txt', None, None, 0, 0, self.rev, False)
330    try:
331      fs.lock(self.fs, b'/trunk/README.txt', None, None, 0, 0, self.rev, False)
332    except core.SubversionException as exc:
333      self.assertEqual(exc.apr_err, core.SVN_ERR_FS_PATH_ALREADY_LOCKED)
334    fs.lock(self.fs, b'/trunk/README.txt', None, None, 0, 0, self.rev, True)
335
336    self.calls = 0
337    self.errors = 0
338    def unlock_callback(path, lock, err, pool):
339      self.assertEqual(path, b'/trunk/README.txt')
340      self.assertEqual(lock, None)
341      self.calls += 1
342      if err != None:
343        self.assertEqual(err.apr_err, core.SVN_ERR_FS_NO_SUCH_LOCK)
344        self.errors += 1
345
346    the_lock = fs.get_lock(self.fs, b'/trunk/README.txt')
347    fs.unlock_many(self.fs, {b'/trunk/README.txt':the_lock.token}, False,
348                   unlock_callback)
349    self.assertEqual(self.calls, 1)
350    self.assertEqual(self.errors, 0)
351
352    self.calls = 0
353    fs.unlock_many(self.fs, {b'/trunk/README.txt':the_lock.token}, False,
354                   unlock_callback)
355    self.assertEqual(self.calls, 1)
356    self.assertEqual(self.errors, 1)
357
358    self.locks = 0
359    def lock_callback(path, lock, err, pool):
360      self.assertEqual(path, b'/trunk/README.txt')
361      if lock != None:
362        self.assertEqual(lock.owner, b'jrandom')
363        self.locks += 1
364      self.calls += 1
365      if err != None:
366        self.assertEqual(err.apr_err, core.SVN_ERR_FS_PATH_ALREADY_LOCKED)
367        self.errors += 1
368
369    self.calls = 0
370    self.errors = 0
371    target = fs.lock_target_create(None, self.rev)
372    fs.lock_many(self.fs, {b'trunk/README.txt':target},
373                 None, False, 0, False, lock_callback)
374    self.assertEqual(self.calls, 1)
375    self.assertEqual(self.locks, 1)
376    self.assertEqual(self.errors, 0)
377
378    self.calls = 0
379    self.locks = 0
380    fs.lock_many(self.fs, {b'trunk/README.txt':target},
381                 None, False, 0, False, lock_callback)
382    self.assertEqual(self.calls, 1)
383    self.assertEqual(self.locks, 0)
384    self.assertEqual(self.errors, 1)
385
386    self.calls = 0
387    self.errors = 0
388    the_lock = fs.get_lock(self.fs, b'/trunk/README.txt')
389    repos.fs_unlock_many(self.repos, {b'trunk/README.txt':the_lock.token},
390                         False, unlock_callback)
391    self.assertEqual(self.calls, 1)
392    self.assertEqual(self.errors, 0)
393
394    self.calls = 0
395    repos.fs_unlock_many(self.repos, {b'trunk/README.txt':the_lock.token},
396                         False, unlock_callback)
397    self.assertEqual(self.calls, 1)
398    self.assertEqual(self.errors, 1)
399
400    self.calls = 0
401    self.errors = 0
402    repos.fs_lock_many(self.repos, {b'trunk/README.txt':target},
403                       None, False, 0, False, lock_callback)
404    self.assertEqual(self.calls, 1)
405    self.assertEqual(self.locks, 1)
406    self.assertEqual(self.errors, 0)
407
408    self.calls = 0
409    self.locks = 0
410    repos.fs_lock_many(self.repos, {b'trunk/README.txt':target},
411                       None, False, 0, False, lock_callback)
412    self.assertEqual(self.calls, 1)
413    self.assertEqual(self.locks, 0)
414    self.assertEqual(self.errors, 1)
415
416def suite():
417    return unittest.defaultTestLoader.loadTestsFromTestCase(
418      SubversionRepositoryTestCase)
419
420if __name__ == '__main__':
421    runner = unittest.TextTestRunner()
422    runner.run(suite())
423