diff --git a/swh/loader/cvs/cvsclient.py b/swh/loader/cvs/cvsclient.py index 4017509..9d816dd 100644 --- a/swh/loader/cvs/cvsclient.py +++ b/swh/loader/cvs/cvsclient.py @@ -1,349 +1,349 @@ # Copyright (C) 2015-2021 The Software Heritage developers # See the AUTHORS file at the top-level directory of this distribution # License: GNU General Public License version 3, or any later version # See top-level LICENSE file for more information """Minimal CVS client implementation """ import socket import subprocess import os.path import tempfile import re from swh.loader.exception import NotFound CVS_PSERVER_PORT = 2401 CVS_PROTOCOL_BUFFER_SIZE = 8192 EXAMPLE_PSERVER_URL = "pserver://user:password@cvs.example.com/cvsroot/repository" EXAMPLE_SSH_URL = "ssh://user@cvs.example.com/cvsroot/repository" VALID_RESPONSES = ["ok", "error", "Valid-requests", "Checked-in", "New-entry", "Checksum", "Copy-file", "Updated", "Created", "Update-existing", "Merged", "Patched", "Rcs-diff", "Mode", "Removed", "Remove-entry", "Template", "Notified", "Module-expansion", "Wrapper-rcsOption", "M", "Mbinary", "E", "F", "MT"] # Trivially encode strings to protect them from innocent eyes (i.e., # inadvertent password compromises, like a network administrator # who's watching packets for legitimate reasons and accidentally sees # the password protocol go by). # # This is NOT secure encryption. def scramble_password(password): s = ['A'] # scramble scheme version number scramble_shifts = [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, # noqa: E241 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, # noqa: E241,E131,E501 114,120, 53, 79, 96,109, 72,108, 70, 64, 76, 67,116, 74, 68, 87, # noqa: E241,E131,E501 111, 52, 75,119, 49, 34, 82, 81, 95, 65,112, 86,118,110,122,105, # noqa: E241,E131,E501 41, 57, 83, 43, 46,102, 40, 89, 38,103, 45, 50, 42,123, 91, 35, # noqa: E241,E131,E501 125, 55, 54, 66,124,126, 59, 47, 92, 71,115, 78, 88,107,106, 56, # noqa: E241,E131,E501 36,121,117,104,101,100, 69, 73, 99, 63, 94, 93, 39, 37, 61, 48, # noqa: E241,E131,E501 58,113, 32, 90, 44, 98, 60, 51, 33, 97, 62, 77, 84, 80, 85,223, # noqa: E241,E131,E501 225,216,187,166,229,189,222,188,141,249,148,200,184,136,248,190, # noqa: E241,E131,E501 199,170,181,204,138,232,218,183,255,234,220,247,213,203,226,193, # noqa: E241,E131,E501 174,172,228,252,217,201,131,230,197,211,145,238,161,179,160,212, # noqa: E241,E131,E501 207,221,254,173,202,146,224,151,140,196,205,130,135,133,143,246, # noqa: E241,E131,E501 192,159,244,239,185,168,215,144,139,165,180,157,147,186,214,176, # noqa: E241,E131,E501 227,231,219,169,175,156,206,198,129,164,150,210,154,177,134,127, # noqa: E241,E131,E501 182,128,158,208,162,132,167,209,149,241,153,251,237,236,171,195, # noqa: E241,E131,E501 243,233,253,240,194,250,191,155,142,137,245,235,163,242,178,152] # noqa: E241,E131,E501 for c in password: s.append('%c' % scramble_shifts[ord(c)]) return "".join(s) class CVSProtocolError(Exception): pass -_re_kb_opt = re.compile(b'\/-kb\/') +_re_kb_opt = re.compile(b'\/-kb\/') # noqa: W605 class CVSClient: def connect_pserver(self, hostname, port, auth): if port is None: port = CVS_PSERVER_PORT if auth is None: raise NotFound("Username and password are required for " "a pserver connection: %s" % EXAMPLE_PSERVER_URL) try: user = auth.split(':')[0] password = auth.split(':')[1] except IndexError: raise NotFound("Username and password are required for " "a pserver connection: %s" % EXAMPLE_PSERVER_URL) try: self.socket = socket.create_connection((hostname, port)) except ConnectionRefusedError: raise NotFound("Could not connect to %s:%s", hostname, port) scrambled_password = scramble_password(password) request = "BEGIN AUTH REQUEST\n%s/%s\n%s\n%s\nEND AUTH REQUEST\n" \ % (self.cvsroot_path, self.cvs_module_name, user, scrambled_password) self.socket.sendall(request.encode('UTF-8')) response = self.socket.recv(11) if response != b"I LOVE YOU\n": raise NotFound("pserver authentication failed for %s:%s" % (hostname, port)) def connect_ssh(self, hostname, port, auth): command = ['ssh'] if auth is not None: # Assume 'auth' contains only a user name. # We do not support password authentication with SSH since the # anoncvs user is usually granted access without a password. command += ['-l' , '%s' % auth] if port is not None: command += ['-p' , '%d' % port] # accept new SSH hosts keys upon first use; changed host keys # will require intervention command += ['-o', "StrictHostKeyChecking=accept-new"] # disable interactive prompting command += ['-o', "BatchMode=yes"] # disable further option processing by adding '--' command += ['--'] command += ['%s' % hostname, 'cvs', 'server'] # use non-buffered I/O to match behaviour of self.socket self.ssh = subprocess.Popen(command, bufsize=0, stdin=subprocess.PIPE, stdout=subprocess.PIPE) def connect_fake(self, hostname, port, auth): command = ['cvs', 'server'] # use non-buffered I/O to match behaviour of self.socket self.ssh = subprocess.Popen(command, bufsize=0, stdin=subprocess.PIPE, stdout=subprocess.PIPE) def conn_read_line(self, require_newline=True): if len(self.linebuffer) != 0: return self.linebuffer.pop(0) buf = b'' idx = -1 while idx == -1: if len(buf) >= CVS_PROTOCOL_BUFFER_SIZE: if require_newline: raise CVSProtocolError("Overlong response from " "CVS server: %s" % buf) else: break if self.socket: buf += self.socket.recv(CVS_PROTOCOL_BUFFER_SIZE) elif self.ssh: buf += self.ssh.stdout.read(CVS_PROTOCOL_BUFFER_SIZE) else: raise Exception("No valid connection") if not buf: return None idx = buf.rfind(b'\n') if idx != -1: self.linebuffer = buf[:idx + 1].splitlines(keepends=True) else: if require_newline: raise CVSProtocolError("Invalid response from CVS server: %s" % buf) else: self.linebuffer.append(buf) if len(self.incomplete_line) > 0: self.linebuffer[0] = self.incomplete_line + self.linebuffer[0] if idx != -1: self.incomplete_line = buf[idx + 1:] else: self.incomplete_line = b'' return self.linebuffer.pop(0) def conn_write(self, data): if self.socket: return self.socket.sendall(data) if self.ssh: self.ssh.stdin.write(data) return self.ssh.stdin.flush() raise Exception("No valid connection") def conn_write_str(self, s): return self.conn_write(s.encode('UTF-8')) def conn_close(self): if self.socket: self.socket.close() if self.ssh: self.ssh.kill() try: self.ssh.wait(timeout=10) except subprocess.TimeoutExpired as e: raise subprocess.TimeoutExpired("Could not terminate " "ssh program: %s" % e) def __init__(self, url): """ Connect to a CVS server at the specified URL and perform the initial CVS protocol handshake. """ self.hostname = url.host self.cvsroot_path = os.path.dirname(url.path) self.cvs_module_name = os.path.basename(url.path) self.socket = None self.ssh = None self.linebuffer = list() self.incomplete_line = b'' if url.scheme == 'pserver': self.connect_pserver(url.host, url.port, url.auth) elif url.scheme == 'ssh': self.connect_ssh(url.host, url.port, url.auth) elif url.scheme == 'fake': self.connect_fake(url.host, url.port, url.auth) else: raise NotFound("Invalid CVS origin URL '%s'" % url) # we should have a connection now assert self.socket or self.ssh self.conn_write_str("Root %s\nValid-responses %s\nvalid-requests\n" "UseUnchanged\n" % (self.cvsroot_path, ' '.join(VALID_RESPONSES))) response = self.conn_read_line() if not response: raise CVSProtocolError("No response from CVS server") try: if response[0:15] != b"Valid-requests ": raise CVSProtocolError("Invalid response from " "CVS server: %s" % response) except IndexError: raise CVSProtocolError("Invalid response from CVS server: %s" % response) response = self.conn_read_line() if response != b"ok\n": raise CVSProtocolError("Invalid response from CVS server: %s" % response) def __del__(self): self.conn_close() def _parse_rlog_response(self, fp): rlog_output = tempfile.TemporaryFile() expect_error = False for line in fp.readlines(): if expect_error: raise CVSProtocolError('CVS server error: %s' % line) if line == b'ok\n': break elif line == b'M \n': continue elif line[0:2] == b'M ': rlog_output.write(line[2:]) elif line[0:8] == b'MT text ': rlog_output.write(line[8:-1]) elif line[0:8] == b'MT date ': rlog_output.write(line[8:-1]) elif line[0:10] == b'MT newline': rlog_output.write(line[10:]) elif line[0:7] == b'error ': expect_error = True continue else: raise CVSProtocolError('Bad CVS protocol response: %s' % line) rlog_output.seek(0) return rlog_output def fetch_rlog(self): fp = tempfile.TemporaryFile() self.conn_write_str("Global_option -q\nArgument --\nArgument %s\nrlog\n" % self.cvs_module_name) while True: response = self.conn_read_line() if response is None: raise CVSProtocolError("No response from CVS server") if response[0:2] == b"E ": raise CVSProtocolError("Error response from CVS server: %s" % response) fp.write(response) if response == b"ok\n": break fp.seek(0) return self._parse_rlog_response(fp) def checkout(self, path, rev, dest_dir): skip_line = False expect_modeline = False expect_bytecount = False have_bytecount = False bytecount = 0 dirname = os.path.dirname(path) if dirname: self.conn_write_str("Directory %s\n%s\n" % (dirname, dirname)) filename = os.path.basename(path) co_output = tempfile.NamedTemporaryFile(dir=dest_dir, delete=True, prefix='cvsclient-checkout-%s-r%s-' % (filename, rev)) # TODO: cvs <= 1.10 servers expect to be given every Directory along the path. self.conn_write_str("Directory %s\n%s\n" "Global_option -q\n" "Argument -r%s\n" "Argument -kb\n" "Argument --\nArgument %s\nco \n" % (self.cvs_module_name, self.cvs_module_name, rev, path)) while True: if have_bytecount and bytecount > 0: response = self.conn_read_line(require_newline=False) if response is None: raise CVSProtocolError("Incomplete response from CVS server") co_output.write(response) bytecount -= len(response) if bytecount < 0: raise CVSProtocolError("Overlong response from " "CVS server: %s" % response) continue else: response = self.conn_read_line() if response[0:2] == b'E ': raise CVSProtocolError('Error from CVS server: %s' % response) if have_bytecount and bytecount == 0 and response == b'ok\n': break if skip_line: skip_line = False continue elif expect_bytecount: try: bytecount = int(response[0:-1]) # strip trailing \n except ValueError: raise CVSProtocolError('Bad CVS protocol response: %s' % response) have_bytecount = True continue elif response == b'M \n': continue elif response == b'MT +updated\n': continue elif response == b'MT -updated\n': continue elif response[0:9] == b'MT fname ': continue elif response[0:8] == b'Created ': skip_line = True continue elif response[0:1] == b'/' and _re_kb_opt.search(response): expect_modeline = True continue elif expect_modeline and response[0:2] == b'u=': expect_modeline = False expect_bytecount = True continue elif response[0:2] == b'M ': continue elif response[0:8] == b'MT text ': continue elif response[0:10] == b'MT newline': continue else: raise CVSProtocolError('Bad CVS protocol response: %s' % response) co_output.seek(0) return co_output diff --git a/swh/loader/cvs/rlog.py b/swh/loader/cvs/rlog.py index cc259cd..1332d91 100644 --- a/swh/loader/cvs/rlog.py +++ b/swh/loader/cvs/rlog.py @@ -1,470 +1,470 @@ # Copyright (C) 2021 The Software Heritage developers # See the AUTHORS file at the top-level directory of this distribution # License: GNU General Public License version 3, or any later version # See top-level LICENSE file for more information """ RCS/CVS rlog parser, derived from viewvc and cvs2gitdump.py """ # Copyright (C) 1999-2021 The ViewCVS Group. All Rights Reserved. # # By using ViewVC, you agree to the terms and conditions set forth # below: # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions # are met: # # * Redistributions of source code must retain the above copyright # notice, this list of conditions and the following # disclaimer. # # * Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following # disclaimer in the documentation and/or other materials provided # with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND # ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR # PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS # BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR # BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, # WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE # OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN # IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. # Copyright (c) 2012 YASUOKA Masahiko # # Permission to use, copy, modify, and distribute this software for any # purpose with or without fee is hereby granted, provided that the above # copyright notice and this permission notice appear in all copies. # # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR # ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES # WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN # ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF # OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. import calendar import re import time from swh.loader.cvs.cvs2gitdump.cvs2gitdump import ChangeSetKey, file_path # There is no known encoding of path names in CVS. The actual encoding used # will depend on the CVS server's operating system and perhaps even the # underlying filesystem used to host a CVS repository. # It is even conceivable that a given repository may use multiple encodings, # e.g. due to migrations of the repository between different servers over time. # # This issue also affects the CVS network protocol which is communicating # paths between the CVS server and the CVS client. For this reason, most # public-facing repositories should stick to ASCII in practice. # # TODO: If known, the actual path encoding used by the repository should # be specified as a parameter. This parameter should be a list since # multiple encodings may be present in a given repository. path_encodings = ["ascii", "utf-8"] class RlogConv: def __init__(self, cvsroot_path, fuzzsec): self.cvsroot_path = cvsroot_path self.fuzzsec = fuzzsec self.changesets = dict() self.tags = dict() self.offsets = dict() def _process_rlog_entry(self, path, taginfo, revisions, logmsgs): """ Convert an rlog entry into an item in self.changesets """ rtags = dict() # RCS and CVS represent branches by adding digits to revision numbers. # And CVS assigns special meaning to certain revision number ranges. # # Revision numbers on the main branch have only two digits: # # 1.1, 1.2, 1.3, ... # # Branches created with 'cvs tag -b' use even numbers for # the third digit: # # 1.1, 1.2, 1.3, ... main branch history of the file # | # 1.1.2.1, 1.1.2.2 ... a branch (2) forked off r1.1 of the file # # Branches are given human-readable names by associating # RCS tag labels with their revision numbers. # Given a file on the above branch which has been changed 10 times # since history was forked, the branch tag would look like this: # # MY_BRANCH: r1.1.2.10 # # Odd branch numbers are reserved for CVS "vendor" branches. # The default vendor branch is 1.1.1. # Vendor branches are populated with 'cvs import'. # Files on the vendor branch are merged to the main branch automatically # unless there are merge conflicts. Such conflicts have to be resolved # manually each time 'cvs import' is used to update the vendor branch. # # See here for details: # https://www.gnu.org/software/trans-coord/manual/cvs/html_node/Branches-and-revisions.html#Branches-and-revisions # # There are also "magic" branch numbers with a zero inserted # at the second-rightmost position: # # 1.1, 1.2, 1.3, ... main branch history of the file # | # 1.1.2.0.1 magic branch (2) # # This allows CVS to store information about a branch's existence # before any files on this branch have been modified. # Even-numbered branch revisions appear once the file is modified. branches = {"1": "HEAD", "1.1.1": "VENDOR"} for k, v in list(taginfo.items()): r = v.split(".") if len(r) == 3: # vendor branch number branches[v] = "VENDOR" elif len(r) >= 3 and r[-2] == "0": # magic branch number branches[".".join(r[:-2] + r[-1:])] = k if len(r) == 2 and branches[r[0]] == "HEAD": # main branch number if v not in rtags: rtags[v] = list() rtags[v].append(k) revs = revisions.items() # sort by revision descending to priorize 1.1.1.1 than 1.1 revs = sorted(revs, key=lambda a: a[1][0], reverse=True) # sort by time revs = sorted(revs, key=lambda a: a[1][1]) novendor = False have_initial_revision = False last_vendor_status = None for k, v in revs: r = k.split(".") if ( len(r) == 4 and r[0] == "1" and r[1] == "1" and r[2] == "1" and r[3] == "1" ): if have_initial_revision: continue if v[3] == "dead": continue last_vendor_status = v[3] have_initial_revision = True elif len(r) == 4 and r[0] == "1" and r[1] == "1" and r[2] == "1": if novendor: continue last_vendor_status = v[3] elif len(r) == 2: if r[0] == "1" and r[1] == "1": if have_initial_revision: continue if v[3] == "dead": continue have_initial_revision = True elif r[0] == "1" and r[1] != "1": novendor = True if last_vendor_status == "dead" and v[3] == "dead": last_vendor_status = None continue last_vendor_status = None else: # trunk only continue b = ".".join(r[:-1]) # decode author name in a potentially lossy way; # it is only used for internal hashing in this case author = v[2].decode("utf-8", "ignore") a = ChangeSetKey(branches[b], author, v[1], logmsgs[k], v[6], self.fuzzsec) a.put_file(path, k, v[3], 0) while a in self.changesets: c = self.changesets[a] del self.changesets[a] c.merge(a) a = c self.changesets[a] = a if k in rtags: for t in rtags[k]: if t not in self.tags or self.tags[t].max_time < a.max_time: self.tags[t] = a def parse_rlog(self, fp): eof = None while eof != _EOF_LOG and eof != _EOF_ERROR: filename, branch, taginfo, lockinfo, errmsg, eof = _parse_log_header(fp) revisions = {} logmsgs = {} if filename: # There is no known encoding of filenames in CVS. # Attempt to decode the path with our list of known encodings. # If none of them work, forcefully decode the path assuming # the final path encoding provided in the list. for i, e in enumerate(path_encodings): try: how = "ignore" if i == len(path_encodings) - 1 else "strict" fname = filename.decode(e, how) break except UnicodeError: pass while not eof: off = fp.tell() rev, logmsg, eof = _parse_log_entry(fp) if rev: revisions[rev[0]] = rev logmsgs[rev[0]] = logmsg if eof != _EOF_LOG and eof != _EOF_ERROR: path = file_path(self.cvsroot_path, fname) if path not in self.offsets.keys(): self.offsets[path] = dict() if rev: self.offsets[path][rev[0]] = off self._process_rlog_entry(path, taginfo, revisions, logmsgs) def getlog(self, fp, path, rev): off = self.offsets[path][rev] fp.seek(off) rev, logmsg, eof = _parse_log_entry(fp) return logmsg # if your rlog doesn't use 77 '=' characters, then this must change LOG_END_MARKER = b"=" * 77 + b"\n" ENTRY_END_MARKER = b"-" * 28 + b"\n" _EOF_FILE = b"end of file entries" # no more entries for this RCS file _EOF_LOG = b"end of log" # hit the true EOF on the pipe _EOF_ERROR = b"error message found" # rlog issued an error # rlog error messages look like # # rlog: filename/goes/here,v: error message # rlog: filename/goes/here,v:123: error message # # so we should be able to match them with a regex like # # ^rlog\: (.*)(?:\:\d+)?\: (.*)$ # # But for some reason the windows version of rlog omits the "rlog: " prefix # for the first error message when the standard error stream has been # redirected to a file or pipe. (the prefix is present in subsequent errors # and when rlog is run from the console). So the expression below is more # complicated -_re_log_error = re.compile(b"^(?:rlog\: )*(.*,v)(?:\:\d+)?\: (.*)$") +_re_log_error = re.compile(b"^(?:rlog\: )*(.*,v)(?:\:\d+)?\: (.*)$") # noqa: W605 # CVSNT error messages look like: # cvs rcsfile: `C:/path/to/file,v' does not appear to be a valid rcs file # cvs [rcsfile aborted]: C:/path/to/file,v: No such file or directory # cvs [rcsfile aborted]: cannot open C:/path/to/file,v: Permission denied _re_cvsnt_error = re.compile( - b"^(?:cvs rcsfile\: |cvs \[rcsfile aborted\]: )" - b"(?:\`(.*,v)' |" - b"cannot open (.*,v)\: |(.*,v)\: |)" + b"^(?:cvs rcsfile\: |cvs \[rcsfile aborted\]: )" # noqa: W605 + b"(?:\`(.*,v)' |" # noqa: W605 + b"cannot open (.*,v)\: |(.*,v)\: |)" # noqa: W605 b"(.*)$" ) def _parse_log_header(fp): """Parse and RCS/CVS log header. fp is a file (pipe) opened for reading the log information. On entry, fp should point to the start of a log entry. On exit, fp will have consumed the separator line between the header and the first revision log. If there is no revision information (e.g. the "-h" switch was passed to rlog), then fp will consumed the file separator line on exit. Returns: filename, default branch, tag dictionary, lock dictionary, rlog error message, and eof flag """ filename = branch = msg = b"" taginfo = {} # tag name => number lockinfo = {} # revision => locker state = 0 # 0 = base, 1 = parsing symbols, 2 = parsing locks eof = None while 1: line = fp.readline() if not line: # the true end-of-file eof = _EOF_LOG break if state == 1: if line[0] == b"\t": [tag, rev] = [x.strip() for x in line.split(b":")] taginfo[tag] = rev else: # oops. this line isn't tag info. stop parsing tags. state = 0 if state == 2: if line[0] == b"\t": [locker, rev] = [x.strip() for x in line.split(b":")] lockinfo[rev] = locker else: # oops. this line isn't lock info. stop parsing tags. state = 0 if state == 0: if line[:9] == b"RCS file:": filename = line[10:-1] elif line[:5] == b"head:": # head = line[6:-1] pass elif line[:7] == b"branch:": branch = line[8:-1] elif line[:6] == b"locks:": # start parsing the lock information state = 2 elif line[:14] == b"symbolic names": # start parsing the tag information state = 1 elif line == ENTRY_END_MARKER: # end of the headers break elif line == LOG_END_MARKER: # end of this file's log information eof = _EOF_FILE break else: error = _re_cvsnt_error.match(line) if error: p1, p2, p3, msg = error.groups() filename = p1 or p2 or p3 if not filename: raise ValueError( "Could not get filename from CVSNT error:\n%s" % line ) eof = _EOF_ERROR break error = _re_log_error.match(line) if error: filename, msg = error.groups() if msg[:30] == b"warning: Unknown phrases like ": # don't worry about this warning. it can happen with some RCS # files that have unknown fields in them e.g. "permissions 644;" continue eof = _EOF_ERROR break return filename, branch, taginfo, lockinfo, msg, eof _re_log_info = re.compile( - b"^date:\s+([^;]+);" - b"\s+author:\s+([^;]+);" - b"\s+state:\s+([^;]+);" - b"(\s+lines:\s+([0-9\s+-]+);?)?" - b"(\s+commitid:\s+([a-zA-Z0-9]+))?\n$" + b"^date:\s+([^;]+);" # noqa: W605 + b"\s+author:\s+([^;]+);" # noqa: W605 + b"\s+state:\s+([^;]+);" # noqa: W605 + b"(\s+lines:\s+([0-9\s+-]+);?)?" # noqa: W605 + b"(\s+commitid:\s+([a-zA-Z0-9]+))?\n$" # noqa: W605 ) # TODO: _re_rev should be updated to extract the "locked" flag -_re_rev = re.compile(b"^revision\s+([0-9.]+).*") +_re_rev = re.compile(b"^revision\s+([0-9.]+).*") # noqa: W605 def cvs_strptime(timestr): try: return time.strptime(timestr, "%Y/%m/%d %H:%M:%S")[:-1] + (0,) except ValueError: return time.strptime(timestr, "%Y-%m-%d %H:%M:%S %z")[:-1] + (0,) def _parse_log_entry(fp): """Parse a single log entry. On entry, fp should point to the first line of the entry (the "revision" line). On exit, fp will have consumed the log separator line (dashes) or the end-of-file marker (equals). Returns: Revision data tuple, and eof flag (see _EOF_*) """ rev = None line = fp.readline() if not line: return None, None, _EOF_LOG if line == LOG_END_MARKER: # Needed because some versions of RCS precede LOG_END_MARKER # with ENTRY_END_MARKER return None, None, _EOF_FILE if line[:8] == b"revision": match = _re_rev.match(line) if not match: return None, None, _EOF_LOG rev = match.group(1) line = fp.readline() if not line: return None, None, _EOF_LOG match = _re_log_info.match(line) eof = None log = b"" while 1: line = fp.readline() if not line: # true end-of-file eof = _EOF_LOG break if line[:9] == b"branches:": continue if line == ENTRY_END_MARKER: break if line == LOG_END_MARKER: # end of this file's log information eof = _EOF_FILE break log = log + line if not rev or not match: # there was a parsing error return None, None, eof # parse out a time tuple for the local time tm = cvs_strptime(match.group(1).decode("UTF-8")) # rlog seems to assume that two-digit years are 1900-based (so, "04" # comes out as "1904", not "2004"). EPOCH = 1970 if tm[0] < EPOCH: tm = list(tm) if (tm[0] - 1900) < 70: tm[0] = tm[0] + 100 if tm[0] < EPOCH: raise ValueError("invalid year") date = calendar.timegm(tm) # return a revision tuple compatible with 'rcsparse', the log message, # and the EOF marker return ( ( rev.decode("ascii"), # revision number string date, match.group(2), # author (encoding is arbitrary; don't attempt to decode) match.group(3).decode( "ascii" ), # state, usually "Exp" or "dead"; non-ASCII data here would be weird None, # TODO: branches of this rev None, # TODO: revnumstr of previous rev None, # TODO: commitid ), log, eof, )