1# -*-python-*-
2#
3# Copyright (C) 1999-2020 The ViewCVS Group. All Rights Reserved.
4#
5# By using this file, you agree to the terms and conditions set forth in
6# the LICENSE.html file which can be found at the top level of the ViewVC
7# distribution or at http://viewvc.org/license-1.html.
8#
9# For more information, visit http://viewvc.org/
10#
11# -----------------------------------------------------------------------
12
13"Version Control lib driver for remotely accessible Subversion repositories."
14
15import vclib
16import sys
17import os
18import re
19import tempfile
20import time
21import functools
22from urllib.parse import quote as _quote
23
24from .svn_repos import Revision, SVNChangedPath, _datestr_to_date, _to_str, \
25                      _compare_paths, _path_parts, _cleanup_path, \
26                      _rev2optrev, _normalize_property_value, \
27                      _normalize_property, _split_revprops
28from svn import core, delta, client, wc, ra
29
30
31### Verify that we have an acceptable version of Subversion.
32MIN_SUBVERSION_VERSION = (1, 14, 0)
33HAS_SUBVERSION_VERSION = (core.SVN_VER_MAJOR,
34                          core.SVN_VER_MINOR,
35                          core.SVN_VER_PATCH)
36if HAS_SUBVERSION_VERSION < MIN_SUBVERSION_VERSION:
37  found_ver = '.'.join([str(x) for x in HAS_SUBVERSION_VERSION])
38  needs_ver = '.'.join([str(x) for x in MIN_SUBVERSION_VERSION])
39  raise Exception("Subversion version %s is required (%s found)"
40                  % (needs_ver, found_ver))
41
42
43def client_log(url, start_rev, end_rev, log_limit, include_changes,
44               cross_copies, cb_func, ctx):
45  include_changes = include_changes and 1 or 0
46  cross_copies = cross_copies and 1 or 0
47  client.svn_client_log4([url], start_rev, start_rev, end_rev,
48                         log_limit, include_changes, not cross_copies,
49                         0, None, cb_func, ctx)
50
51
52def setup_client_ctx(config_dir):
53  # Ensure that the configuration directory exists.
54  core.svn_config_ensure(config_dir)
55
56  # Fetch the configuration (and 'config' bit thereof).
57  cfg = core.svn_config_get_config(config_dir)
58  config = cfg.get(core.SVN_CONFIG_CATEGORY_CONFIG)
59
60  auth_baton = core.svn_cmdline_create_auth_baton(1, None, None, config_dir,
61                                                  1, 1, config, None)
62
63  # Create, setup, and return the client context baton.
64  ctx = client.svn_client_create_context()
65  ctx.config = cfg
66  ctx.auth_baton = auth_baton
67  return ctx
68
69class LogCollector:
70
71  def __init__(self, path, show_all_logs, lockinfo, access_check_func,
72               encoding='utf-8'):
73    # This class uses leading slashes for paths internally
74    if not path:
75      self.path = '/'
76    else:
77      self.path = path[0] == '/' and path or '/' + path
78    self.logs = []
79    self.show_all_logs = show_all_logs
80    self.lockinfo = lockinfo
81    self.access_check_func = access_check_func
82    self.done = False
83    self.encoding = encoding
84
85  def add_log(self, log_entry, pool):
86    if self.done:
87      return
88    paths = log_entry.changed_paths
89    revision = log_entry.revision
90    msg, author, date, revprops = _split_revprops(log_entry.revprops)
91
92    # Changed paths have leading slashes
93    changed_paths = [_to_str(p) for p in paths.keys()]
94    changed_paths.sort(key=functools.cmp_to_key(
95                             lambda a, b: _compare_paths(a, b)))
96    this_path = None
97    if self.path in changed_paths:
98      this_path = self.path
99      change = paths[self.path.encode('utf-8', 'surrogateescape')]
100      if change.copyfrom_path:
101        this_path = _to_str(change.copyfrom_path)
102    for changed_path in changed_paths:
103      if changed_path != self.path:
104        # If a parent of our path was copied, our "next previous"
105        # (huh?) path will exist elsewhere (under the copy source).
106        if (self.path.rfind(changed_path) == 0) and \
107               self.path[len(changed_path)] == '/':
108          change = paths[changed_path.encode('utf-8', 'surrogateescape')]
109          if change.copyfrom_path:
110            this_path = (  _to_str(change.copyfrom_path)
111                         + self.path[len(changed_path):])
112    if self.show_all_logs or this_path:
113      if self.access_check_func is None \
114         or self.access_check_func(self.path[1:], revision):
115        entry = Revision(revision, date, author, msg, None, self.lockinfo,
116                         self.path[1:], None, None)
117        self.logs.append(entry)
118      else:
119        self.done = True
120    if this_path:
121      self.path = this_path
122
123def cat_to_tempfile(svnrepos, path, rev):
124  """Check out file revision to temporary file"""
125  fd, temp = tempfile.mkstemp()
126  fp = os.fdopen(fd, 'wb')
127  url = svnrepos._geturl(path)
128  client.svn_client_cat(fp, url, _rev2optrev(rev),
129                        svnrepos.ctx)
130  fp.close()
131  return temp
132
133class SelfCleanFP:
134  def __init__(self, path):
135    self.readable = True
136    self._fp = open(path, 'rb')
137    self._path = path
138    self._eof = 0
139
140  def read(self, len=None):
141    if len:
142      chunk = self._fp.read(len)
143    else:
144      chunk = self._fp.read()
145    if chunk == b'':
146      self._eof = 1
147    return chunk
148
149  def readline(self):
150    chunk = self._fp.readline()
151    if chunk == b'':
152      self._eof = 1
153    return chunk
154
155  def readlines(self):
156    lines = self._fp.readlines()
157    self._eof = 1
158    return lines
159
160  def close(self):
161    self._fp.close()
162    if self._path:
163      try:
164        os.remove(self._path)
165        self._path = None
166      except OSError:
167        pass
168
169  def __del__(self):
170    self.close()
171
172  def eof(self):
173    return self._eof
174
175
176class RemoteSubversionRepository(vclib.Repository):
177  def __init__(self, name, rootpath, authorizer, utilities, config_dir,
178               encoding):
179    self.name = name
180    self.rootpath = rootpath
181    self.auth = authorizer
182    self.diff_cmd = utilities.diff or 'diff'
183    self.config_dir = config_dir or None
184    self.encoding = encoding
185
186    # See if this repository is even viewable, authz-wise.
187    if not vclib.check_root_access(self):
188      raise vclib.ReposNotFound(name)
189
190  def open(self):
191    # Setup the client context baton, complete with non-prompting authstuffs.
192    self.ctx = setup_client_ctx(self.config_dir)
193
194    ra_callbacks = ra.svn_ra_callbacks_t()
195    ra_callbacks.auth_baton = self.ctx.auth_baton
196    self.ra_session = ra.svn_ra_open(self.rootpath, ra_callbacks, None,
197                                     self.ctx.config)
198    self.youngest = ra.svn_ra_get_latest_revnum(self.ra_session)
199    self._dirent_cache = { }
200    self._revinfo_cache = { }
201
202    # See if a universal read access determination can be made.
203    if self.auth and self.auth.check_universal_access(self.name) == 1:
204      self.auth = None
205
206  def rootname(self):
207    return self.name
208
209  def rootpath(self):
210    return self.rootpath
211
212  def roottype(self):
213    return vclib.SVN
214
215  def authorizer(self):
216    return self.auth
217
218  def itemtype(self, path_parts, rev):
219    pathtype = None
220    if not len(path_parts):
221      pathtype = vclib.DIR
222    else:
223      path = self._getpath(path_parts)
224      rev = self._getrev(rev)
225      try:
226        kind = ra.svn_ra_check_path(self.ra_session, path, rev)
227        if kind == core.svn_node_file:
228          pathtype = vclib.FILE
229        elif kind == core.svn_node_dir:
230          pathtype = vclib.DIR
231      except:
232        pass
233    if pathtype is None:
234      raise vclib.ItemNotFound(path_parts)
235    if not vclib.check_path_access(self, path_parts, pathtype, rev):
236      raise vclib.ItemNotFound(path_parts)
237    return pathtype
238
239  def openfile(self, path_parts, rev, options):
240    path = self._getpath(path_parts)
241    if self.itemtype(path_parts, rev) != vclib.FILE:  # does auth-check
242      raise vclib.Error("Path '%s' is not a file." % path)
243    rev = self._getrev(rev)
244    url = self._geturl(path)
245    ### rev here should be the last history revision of the URL
246    fp = SelfCleanFP(cat_to_tempfile(self, path, rev))
247    lh_rev, c_rev = self._get_last_history_rev(path_parts, rev)
248    return fp, lh_rev
249
250  def listdir(self, path_parts, rev, options):
251    path = self._getpath(path_parts)
252    if self.itemtype(path_parts, rev) != vclib.DIR:  # does auth-check
253      raise vclib.Error("Path '%s' is not a directory." % path)
254    rev = self._getrev(rev)
255    entries = []
256    dirents, locks = self._get_dirents(path, rev)
257    for name in dirents.keys():
258      entry = dirents[name]
259      if entry.kind == core.svn_node_dir:
260        kind = vclib.DIR
261      elif entry.kind == core.svn_node_file:
262        kind = vclib.FILE
263      else:
264        kind = None
265      entries.append(vclib.DirEntry(name, kind))
266    return entries
267
268  def dirlogs(self, path_parts, rev, entries, options):
269    path = self._getpath(path_parts)
270    if self.itemtype(path_parts, rev) != vclib.DIR:  # does auth-check
271      raise vclib.Error("Path '%s' is not a directory." % path)
272    rev = self._getrev(rev)
273    dirents, locks = self._get_dirents(path, rev)
274    for entry in entries:
275      entry_path_parts = path_parts + [entry.name]
276      dirent = dirents.get(entry.name, None)
277      # dirents is authz-sanitized, so ensure the entry is found therein.
278      if dirent is None:
279        continue
280      # Get authz-sanitized revision metadata.
281      entry.date, entry.author, entry.log, revprops, changes = \
282                  self._revinfo(dirent.created_rev)
283      entry.rev = str(dirent.created_rev)
284      entry.size = dirent.size
285      entry.lockinfo = None
286      if entry.name in locks:
287        entry.lockinfo = locks[entry.name].owner
288
289  def itemlog(self, path_parts, rev, sortby, first, limit, options):
290    assert sortby == vclib.SORTBY_DEFAULT or sortby == vclib.SORTBY_REV
291    path_type = self.itemtype(path_parts, rev) # does auth-check
292    path = self._getpath(path_parts)
293    rev = self._getrev(rev)
294    url = self._geturl(path)
295
296    # If this is a file, fetch the lock status and size (as of REV)
297    # for this item.
298    lockinfo = size_in_rev = None
299    if path_type == vclib.FILE:
300      basename = path_parts[-1].encode(self.encoding, 'surrogateescape')
301      list_url = self._geturl(self._getpath(path_parts[:-1]))
302      dirents, locks = client.svn_client_ls3(list_url, _rev2optrev(rev),
303                                             _rev2optrev(rev), 0, self.ctx)
304      if basename in locks:
305        lockinfo = locks[basename].owner
306      if basename in dirents:
307        size_in_rev = dirents[basename].size
308
309    # Special handling for the 'svn_latest_log' scenario.
310    ### FIXME: Don't like this hack.  We should just introduce
311    ### something more direct in the vclib API.
312    if options.get('svn_latest_log', 0):
313      dir_lh_rev, dir_c_rev = self._get_last_history_rev(path_parts, rev)
314      date, author, log, revprops, changes = self._revinfo(dir_lh_rev)
315      return [vclib.Revision(dir_lh_rev, str(dir_lh_rev), date,
316                             author, None, log, size_in_rev, lockinfo)]
317
318    def _access_checker(check_path, check_rev):
319      return vclib.check_path_access(self, _path_parts(check_path),
320                                     path_type, check_rev)
321
322    # It's okay if we're told to not show all logs on a file -- all
323    # the revisions should match correctly anyway.
324    lc = LogCollector(path, options.get('svn_show_all_dir_logs', 0),
325                      lockinfo, _access_checker)
326
327    cross_copies = options.get('svn_cross_copies', 0)
328    log_limit = 0
329    if limit:
330      log_limit = first + limit
331    client_log(url, _rev2optrev(rev), _rev2optrev(1), log_limit, 1,
332               cross_copies, lc.add_log, self.ctx)
333    revs = lc.logs
334    revs.sort()
335    prev = None
336    for rev in revs:
337      # Swap out revision info with stuff from the cache (which is
338      # authz-sanitized).
339      rev.date, rev.author, rev.log, revprops, changes \
340                = self._revinfo(rev.number)
341      rev.prev = prev
342      prev = rev
343    revs.reverse()
344
345    if len(revs) < first:
346      return []
347    if limit:
348      return revs[first:first+limit]
349    return revs
350
351  def itemprops(self, path_parts, rev):
352    path = self._getpath(path_parts)
353    path_type = self.itemtype(path_parts, rev) # does auth-check
354    rev = self._getrev(rev)
355    url = self._geturl(path)
356    pairs = client.svn_client_proplist2(url, _rev2optrev(rev),
357                                        _rev2optrev(rev), 0, self.ctx)
358    propdict = {}
359    if pairs:
360      for pname in pairs[0][1].keys():
361        pvalue = pairs[0][1][pname]
362        pname, pvalue = _normalize_property(pname, pvalue, self.encoding)
363        if pname:
364          propdict[pname] = pvalue
365    return propdict
366
367  def annotate(self, path_parts, rev, include_text=False):
368    path = self._getpath(path_parts)
369    if self.itemtype(path_parts, rev) != vclib.FILE:  # does auth-check
370      raise vclib.Error("Path '%s' is not a file." % path)
371    rev = self._getrev(rev)
372    url = self._geturl(path)
373
374    # Examine logs for the file to determine the oldest revision we are
375    # permitted to see.
376    log_options = {
377      'svn_cross_copies' : 1,
378      'svn_show_all_dir_logs' : 1,
379      }
380    revs = self.itemlog(path_parts, rev, vclib.SORTBY_REV, 0, 0, log_options)
381    oldest_rev = revs[-1].number
382
383    # Now calculate the annotation data.  Note that we'll not
384    # inherently trust the provided author and date, because authz
385    # rules might necessitate that we strip that information out.
386    blame_data = []
387
388    def _blame_cb(line_no, revision, author, date,
389                  line, pool, blame_data=blame_data):
390      prev_rev = None
391      if revision > 1:
392        prev_rev = revision - 1
393
394      # If we have an invalid revision, clear the date and author
395      # values.  Otherwise, if we have authz filtering to do, use the
396      # revinfo cache to do so.
397      if revision < 0:
398        date = author = None
399      elif self.auth:
400        date, author, msg, revprops, changes = self._revinfo(revision)
401      else:
402        author = _normalize_property_value(author, self.encoding)
403
404      # Strip text if the caller doesn't want it.
405      if not include_text:
406        line = None
407      blame_data.append(vclib.Annotation(line, line_no + 1, revision, prev_rev,
408                                         author, date))
409
410    client.blame2(url, _rev2optrev(rev), _rev2optrev(oldest_rev),
411                  _rev2optrev(rev), _blame_cb, self.ctx)
412    return blame_data, rev
413
414  def revinfo(self, rev):
415    return  self._revinfo(rev, 1)
416
417  def rawdiff(self, path_parts1, rev1, path_parts2, rev2, type, options={}):
418    p1 = self._getpath(path_parts1)
419    p2 = self._getpath(path_parts2)
420    r1 = self._getrev(rev1)
421    r2 = self._getrev(rev2)
422    if not vclib.check_path_access(self, path_parts1, vclib.FILE, rev1):
423      raise vclib.ItemNotFound(path_parts1)
424    if not vclib.check_path_access(self, path_parts2, vclib.FILE, rev2):
425      raise vclib.ItemNotFound(path_parts2)
426
427    args = vclib._diff_args(type, options)
428
429    def _date_from_rev(rev):
430      date, author, msg, revprops, changes = self._revinfo(rev)
431      return date
432
433    try:
434      temp1 = cat_to_tempfile(self, p1, r1)
435      temp2 = cat_to_tempfile(self, p2, r2)
436      info1 = p1, _date_from_rev(r1), r1
437      info2 = p2, _date_from_rev(r2), r2
438      return vclib._diff_fp(temp1, temp2, info1, info2, self.diff_cmd, args)
439    except core.SubversionException as e:
440      if e.apr_err == vclib.svn.core.SVN_ERR_FS_NOT_FOUND:
441        raise vclib.InvalidRevision
442      raise
443
444  def isexecutable(self, path_parts, rev):
445    props = self.itemprops(path_parts, rev) # does authz-check
446    return core.SVN_PROP_EXECUTABLE in props
447
448  def filesize(self, path_parts, rev):
449    path = self._getpath(path_parts)
450    if self.itemtype(path_parts, rev) != vclib.FILE:  # does auth-check
451      raise vclib.Error("Path '%s' is not a file." % path)
452    rev = self._getrev(rev)
453    dirents, locks = self._get_dirents(self._getpath(path_parts[:-1]), rev)
454    dirent = dirents.get(path_parts[-1], None)
455    return dirent.size
456
457  def _getpath(self, path_parts):
458    return '/'.join(path_parts)
459
460  def _getrev(self, rev):
461    if rev is None or rev == 'HEAD':
462      return self.youngest
463    try:
464      if type(rev) == type(''):
465        while rev[0] == 'r':
466          rev = rev[1:]
467      rev = int(rev)
468    except:
469      raise vclib.InvalidRevision(rev)
470    if (rev < 0) or (rev > self.youngest):
471      raise vclib.InvalidRevision(rev)
472    return rev
473
474  def _geturl(self, path=None):
475    if not path:
476      return self.rootpath
477    path = self.rootpath + '/' + _quote(path)
478    return core.svn_path_canonicalize(path)
479
480  def _get_dirents(self, path, rev):
481    """Return a 2-type of dirents and locks, possibly reading/writing
482    from a local cache of that information.  This functions performs
483    authz checks, stripping out unreadable dirents."""
484
485    dir_url = self._geturl(path)
486    path_parts = _path_parts(path)
487    if path:
488      key = str(rev) + '/' + path
489    else:
490      key = str(rev)
491
492    # Ensure that the cache gets filled...
493    dirents_locks = self._dirent_cache.get(key)
494    if not dirents_locks:
495      tmp_dirents, locks = client.svn_client_ls3(dir_url, _rev2optrev(rev),
496                                                 _rev2optrev(rev), 0, self.ctx)
497      dirents = {}
498      for name, dirent in tmp_dirents.items():
499        dirent_parts = path_parts + [_to_str(name)]
500        kind = dirent.kind
501        if (kind == core.svn_node_dir or kind == core.svn_node_file) \
502           and vclib.check_path_access(self, dirent_parts,
503                                       kind == core.svn_node_dir \
504                                         and vclib.DIR or vclib.FILE, rev):
505          lh_rev, c_rev = self._get_last_history_rev(dirent_parts, rev)
506          dirent.created_rev = lh_rev
507          dirents[_to_str(name)] = dirent
508      dirents_locks = [dirents, locks]
509      self._dirent_cache[key] = dirents_locks
510
511    # ...then return the goodies from the cache.
512    return dirents_locks[0], dirents_locks[1]
513
514  def _get_last_history_rev(self, path_parts, rev):
515    """Return the a 2-tuple which contains:
516         - the last interesting revision equal to or older than REV in
517           the history of PATH_PARTS.
518         - the created_rev of of PATH_PARTS as of REV."""
519
520    path = self._getpath(path_parts)
521    url = self._geturl(self._getpath(path_parts))
522    optrev = _rev2optrev(rev)
523
524    # Get the last-changed-rev.
525    revisions = []
526    def _info_cb(path, info, pool, retval=revisions):
527      revisions.append(info.last_changed_rev)
528    client.svn_client_info(url, optrev, optrev, _info_cb, 0, self.ctx)
529    last_changed_rev = revisions[0]
530
531    # Now, this object might not have been directly edited since the
532    # last-changed-rev, but it might have been the child of a copy.
533    # To determine this, we'll run a potentially no-op log between
534    # LAST_CHANGED_REV and REV.
535    lc = LogCollector(path, 1, None, None)
536    client_log(url, optrev, _rev2optrev(last_changed_rev), 1, 1, 0,
537               lc.add_log, self.ctx)
538    revs = lc.logs
539    if revs:
540      revs.sort()
541      return revs[0].number, last_changed_rev
542    else:
543      return last_changed_rev, last_changed_rev
544
545  def _revinfo_fetch(self, rev, include_changed_paths=0):
546    need_changes = include_changed_paths or self.auth
547    revs = []
548
549    def _log_cb(log_entry, pool, retval=revs):
550      # If Subversion happens to call us more than once, we choose not
551      # to care.
552      if retval:
553        return
554
555      revision = log_entry.revision
556      msg, author, date, revprops = _split_revprops(log_entry.revprops)
557      action_map = { 'D' : vclib.DELETED,
558                     'A' : vclib.ADDED,
559                     'R' : vclib.REPLACED,
560                     'M' : vclib.MODIFIED,
561                     }
562
563      # Easy out: if we won't use the changed-path info, just return a
564      # changes-less tuple.
565      if not need_changes:
566        return revs.append([date, author, msg, revprops, None])
567
568      # Subversion 1.5 and earlier didn't offer the 'changed_paths2'
569      # hash, and in Subversion 1.6, it's offered but broken.
570      try:
571        changed_paths = log_entry.changed_paths2
572        paths = list((changed_paths or {}).keys())
573      except:
574        changed_paths = log_entry.changed_paths
575        paths = list((changed_paths or {}).keys())
576      paths.sort(key=functools.cmp_to_key(lambda a, b: _compare_paths(_to_str(a), _to_str(b))))
577
578      # If we get this far, our caller needs changed-paths, or we need
579      # them for authz-related sanitization.
580      changes = []
581      found_readable = found_unreadable = 0
582      for path in paths:
583        change = changed_paths[path]
584
585        # svn_log_changed_path_t (which we might get instead of the
586        # svn_log_changed_path2_t we'd prefer) doesn't have the
587        # 'node_kind' member.
588        pathtype = None
589        if hasattr(change, 'node_kind'):
590          if change.node_kind == core.svn_node_dir:
591            pathtype = vclib.DIR
592          elif change.node_kind == core.svn_node_file:
593            pathtype = vclib.FILE
594
595        # svn_log_changed_path2_t only has the 'text_modified' and
596        # 'props_modified' bits in Subversion 1.7 and beyond.  And
597        # svn_log_changed_path_t is without.
598        text_modified = props_modified = 0
599        if hasattr(change, 'text_modified'):
600          if change.text_modified == core.svn_tristate_true:
601            text_modified = 1
602        if hasattr(change, 'props_modified'):
603          if change.props_modified == core.svn_tristate_true:
604            props_modified = 1
605
606        # Wrong, diddily wrong wrong wrong.  Can you say,
607        # "Manufacturing data left and right because it hurts to
608        # figure out the right stuff?"
609        action = action_map.get(change.action, vclib.MODIFIED)
610        if change.copyfrom_path and change.copyfrom_rev:
611          is_copy = 1
612          base_path = change.copyfrom_path
613          base_rev = change.copyfrom_rev
614        elif action == vclib.ADDED or action == vclib.REPLACED:
615          is_copy = 0
616          base_path = base_rev = None
617        else:
618          is_copy = 0
619          base_path = path
620          base_rev = revision - 1
621
622        # Check authz rules (sadly, we have to lie about the path type)
623        parts = _path_parts(_to_str(path))
624        if vclib.check_path_access(self, parts, vclib.FILE, revision):
625          if is_copy and base_path and (base_path != path):
626            parts = _path_parts(_to_str(base_path))
627            if not vclib.check_path_access(self, parts, vclib.FILE, base_rev):
628              is_copy = 0
629              base_path = None
630              base_rev = None
631              found_unreadable = 1
632          changes.append(SVNChangedPath(_to_str(path), revision, pathtype,
633                                        _to_str(base_path), base_rev,
634                                        action, is_copy,
635                                        text_modified, props_modified))
636          found_readable = 1
637        else:
638          found_unreadable = 1
639
640        # If our caller doesn't want changed-path stuff, and we have
641        # the info we need to make an authz determination already,
642        # quit this loop and get on with it.
643        if (not include_changed_paths) and found_unreadable and found_readable:
644          break
645
646      # Filter unreadable information.
647      if found_unreadable:
648        msg = None
649        if not found_readable:
650          author = None
651          date = None
652
653      # Drop unrequested changes.
654      if not include_changed_paths:
655        changes = None
656
657      # Add this revision information to the "return" array.
658      retval.append([date, author, msg, revprops, changes])
659
660    optrev = _rev2optrev(rev)
661    client_log(self.rootpath, optrev, optrev, 1, need_changes, 0,
662               _log_cb, self.ctx)
663    return tuple(revs[0])
664
665  def _revinfo(self, rev, include_changed_paths=0):
666    """Internal-use, cache-friendly revision information harvester."""
667
668    # Consult the revinfo cache first.  If we don't have cached info,
669    # or our caller wants changed paths and we don't have those for
670    # this revision, go do the real work.
671    rev = self._getrev(rev)
672    cached_info = self._revinfo_cache.get(rev)
673    if not cached_info \
674       or (include_changed_paths and cached_info[4] is None):
675      cached_info = self._revinfo_fetch(rev, include_changed_paths)
676      self._revinfo_cache[rev] = cached_info
677    return cached_info
678
679  ##--- custom --##
680
681  def get_youngest_revision(self):
682    return self.youngest
683
684  def get_location(self, path, rev, old_rev):
685    try:
686      results = ra.get_locations(self.ra_session, path, rev, [old_rev])
687    except core.SubversionException as e:
688      if e.apr_err == core.SVN_ERR_FS_NOT_FOUND:
689        raise vclib.ItemNotFound(path)
690      raise
691    try:
692      old_path = results[old_rev]
693    except KeyError:
694      raise vclib.ItemNotFound(path)
695    old_path = _cleanup_path(_to_str(old_path))
696    old_path_parts = _path_parts(old_path)
697    # Check access (lying about path types)
698    if not vclib.check_path_access(self, old_path_parts, vclib.FILE, old_rev):
699      raise vclib.ItemNotFound(path)
700    return old_path
701
702  def created_rev(self, path, rev):
703    lh_rev, c_rev = self._get_last_history_rev(_path_parts(path), rev)
704    return lh_rev
705
706  def last_rev(self, path, peg_revision, limit_revision=None):
707    """Given PATH, known to exist in PEG_REVISION, find the youngest
708    revision older than, or equal to, LIMIT_REVISION in which path
709    exists.  Return that revision, and the path at which PATH exists in
710    that revision."""
711
712    # Here's the plan, man.  In the trivial case (where PEG_REVISION is
713    # the same as LIMIT_REVISION), this is a no-brainer.  If
714    # LIMIT_REVISION is older than PEG_REVISION, we can use Subversion's
715    # history tracing code to find the right location.  If, however,
716    # LIMIT_REVISION is younger than PEG_REVISION, we suffer from
717    # Subversion's lack of forward history searching.  Our workaround,
718    # ugly as it may be, involves a binary search through the revisions
719    # between PEG_REVISION and LIMIT_REVISION to find our last live
720    # revision.
721    peg_revision = self._getrev(peg_revision)
722    limit_revision = self._getrev(limit_revision)
723    if peg_revision == limit_revision:
724      return peg_revision, path
725    elif peg_revision > limit_revision:
726      path = self.get_location(path, peg_revision, limit_revision)
727      return limit_revision, path
728    else:
729      direction = 1
730      while peg_revision != limit_revision:
731        mid = (peg_revision + 1 + limit_revision) // 2
732        try:
733          path = self.get_location(path, peg_revision, mid)
734        except vclib.ItemNotFound:
735          limit_revision = mid - 1
736        else:
737          peg_revision = mid
738      return peg_revision, path
739
740  def get_symlink_target(self, path_parts, rev):
741    """Return the target of the symbolic link versioned at PATH_PARTS
742    in REV, or None if that object is not a symlink."""
743
744    path = self._getpath(path_parts)
745    path_type = self.itemtype(path_parts, rev) # does auth-check
746    rev = self._getrev(rev)
747    url = self._geturl(path)
748
749    # Symlinks must be files with the svn:special property set on them
750    # and with file contents which read "link SOME_PATH".
751    if path_type != vclib.FILE:
752      return None
753    pairs = client.svn_client_proplist2(url, _rev2optrev(rev),
754                                        _rev2optrev(rev), 0, self.ctx)
755    props = pairs and pairs[0][1] or {}
756    if core.SVN_PROP_SPECIAL not in props:
757      return None
758    pathspec = ''
759    ### FIXME: We're being a touch sloppy here, first by grabbing the
760    ### whole file and then by checking only the first line
761    ### of it.
762    fp = SelfCleanFP(cat_to_tempfile(self, path, rev))
763    pathspec = fp.readline()
764    fp.close()
765    if pathspec[:5] != 'link ':
766      return None
767    return pathspec[5:]
768
769