I finally found some time to work on this, sorry for the delay! I'm attaching v4 where I blatantly stole Danielsh's script to read the hash file ( http://mail-archives.apache.org/mod_mbox/subversion-dev/202102.mbox/%3c20210226173525.GA24828@tarpaulin.shahaf.local2%3e ) and addressed the other comments below. I appreciate the "lead by example" and I've tried to follow the best I could.
Den fre 26 feb. 2021 kl 16:11 skrev Daniel Shahaf <d...@daniel.shahaf.name>: > Daniel Sahlberg wrote on Fri, Feb 26, 2021 at 13:49:25 +0100: > > +++ contrib/client-side/store-plaintext-password.py (working copy) > > @@ -0,0 +1,127 @@ > > +import hashlib > > +import argparse > > +import os > > +import getpass > > +import sys > > For bonus points, sort these. PEP 8 doesn't seem to mention this. > I've moved the imports to where they are being used (for most part) > > +def readHashFile(filename): > [...] since the code to read the hash is based on Danielsh's script I will not make any comments on this part of the rewiew, however I'm again grateful for the feedback and I'll try to remember next time. > > + # Store > > + hash[key] = value > > + > > +def writeHashFile(filename, hash): > > + """Write a list of tuples (key, value) to a hashfile of the same > format as svn_hash_write2()""" > > Same issue with the docstring. > This is adjusted. And I've also considered your commment about using FDs as arguments and split the write function in two: - one to open the tmp file (and handle FileExistsError) and the renaming afterwards - one accepting the FD and the hash and writing the actual content > > > + with open(filename + '.tmp', 'x', encoding='utf-8') as file: > > See my previous mail about adding 'w' and ensuring CR isn't emitted on > Windows. > Python doesn't like wx combined: Traceback (most recent call last): File "./store-plaintext-password.py", line 112, in writeHashFile with open(tmpFilename, 'wxb') as fd: ValueError: must have exactly one of create/read/write/append mode The CR issue should be alright by using b mode, inspired by your code reading in b mode > +# Parse arguments > > +parser = argparse.ArgumentParser(description='''Store plain-text > password in ~/.subversion/auth/svn.simple/\n\n > > +Existing passwords and authentication realms can be inspected by:\n\n > > + svn auth [--show-passwords]\n\n > > +The authentication realm can also be found using:\n\n > > + svn info URL\n\n > > +Realm will be read from stdin if not provided on the command line.''', > formatter_class=argparse.RawDescriptionHelpFormatter) > > Don't embed multiline string literals this way. Either make it a global > variable, or indent the named actual parameters conventionally: > > parser = argparse.ArgumentParser( > description='''\ > Store plain-text password in ~/.subversion/auth/svn.simple/ > ⋮ > Realm will be read from stdin if not provided on the command line. > ''', > formatter_class=argparse.RawDescriptionHelpFormatter, > ) > > I'm not sure whether you can indent the string literal's contents there > and still have it output as desired. > > Note you have three \n's between every two lines (two escaped and one > literal). > Indenting the text kept the indentation in the output, doesn't look good. Moved this to a global variable and removed the escaped \n. > > +parser.add_argument('realm', help='Server authentication real') > > +parser.add_argument('-u', '--user', help='Set username', nargs='?') > > +args = parser.parse_args() > > + > > +# The file name is the md5encoding of the realm > > +m = hashlib.md5() > > +m.update(args.realm.encode('utf-8')) > > +authfileName = > os.path.join(os.path.expanduser('~/.subversion/auth/svn.simple/'), > m.hexdigest()) > > + > > +# If new file, check that username was given as an argument before > prompting for password > > +if not os.path.isfile(authfileName) and args.user is None: > > «not isfile()» could mean it's a directory or a broken symlink. How > about: > . > if isfile(): > elif not exists(): > else: > . > ? > I switched to exists() only. I'm not sure it makes sense to care for every possible variation - if it is a directory then open() will raise an exception and I think that is acceptable in this case. The check is here because I want to give the error message "username required" before asking for the password. To reduce the number of stat() I'm storing the result form exists() in a variable and reusing late. As for race condition... well it's not really that kind of script where I could bother. (The point below about stat() applies to this pseudocode too.) > > > + print('New file, username required (see -u)\n', file=sys.stderr) > > Error messages should start with the argv[0] of whoever generated them. > Point taken, but in this particular case it is displayed by print_help(). > > > + parser.print_help() > > + quit() > > + > > +# Prompt for password > > +password = getpass.getpass("Enter password: ") > > How about "Enter password for {realm}" or "for {user}", etc? > "for {realm}" > > > +# In an existing file, we add/replace password/username/passtype > > +if os.path.isfile(authfileName): > > Calling stat() twice has a performance impact and looks like a race > condition. Maybe in this case it doesn't matter, though… > Using the result from the previous check. Race condition, agree.. > > > + hash = readHashFile(authfileName) > > + if args.user is not None: > > + hash['username'] = args.user > > + hash['password'] = password > > + hash['passtype'] = 'simple' > > + > > +# For a new file, set realmstring, username, password and passtype > > +else: > > + hash = {} > > + hash['svn:realmstring'] = args.realm > > + hash['username'] = args.user > > + hash['passtype'] = 'simple' > > + hash['password'] = password > > Style: Use a single, multiline initializer rather than multiple > assignments? > Like this? hash = { b'svn:realmstring': args.realm.encode('utf-8'), b'username': args.user.encode('utf-8'), b'passtype': 'simple'.encode('utf-8'), b'password': password.encode('utf-8') } > Forward cribbing compatibility: «del password» once it's no longer needed? > Sorry, didn't quite understand this. Do you ask for an option to remove a stored password? Isn't svn auth --remove enough? > > + > > +# Write out the resulting file > > +writeHashFile(authfileName, hash) > Kind regards Daniel Sahlberg
Index: contrib/client-side/store-plaintext-password.py =================================================================== --- contrib/client-side/store-plaintext-password.py (nonexistent) +++ contrib/client-side/store-plaintext-password.py (working copy) @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +"""\ +Script to store password in plaintext in ~/.subversion/auth/svn.simple/ + +Useful in case Subversion is compiled without support for writing +passwords in plaintext. + +Only use this script if the security implications are understood +and it is acceptable by your organization to store passwords in plaintext. + +See http://subversion-staging.apache.org/faq.html#plaintext-passwords +""" + +# ==================================================================== +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ==================================================================== + +import os +import sys + +TERMINATOR = b"END\n" + +PARSERDESCR = """\ +Store plaintext password in ~/.subversion/auth/svn.simple/ + +Existing passwords and authentication realms can be inspected by: + + svn auth [--show-passwords] + +The authentication realm can also be found using: + + svn info URL +""" + +def _read_one_datum(fd, letter): + """\ + Read a 'K <length>\\n<key>\\n' or 'V <length>\\n<value>\\n' block from + an svn_hash_write2()-format FD. + + LETTER identifies the first letter, as a bytes object. + """ + assert letter in {b'K', b'V'} + + # Read the letter and the space + if fd.read(1) != letter or fd.read(1) != b' ': + raise ... + + # Read the length and the newline + line = fd.readline() + if line[-1:] != b'\n': + raise ... + expected_length = int(line[:-1]) + + # Read the datum and its newline + datum = fd.read(expected_length) + if len(datum) != expected_length: + raise ... + if fd.read(1) != b'\n': + raise ... + + return datum + +def svn_hash_read(fd): + """\ + Read an svn_hash_write2()-formatted dict from FD, terminated by "END". + + Return a dict mapping bytes to bytes. + """ + assert 'b' in fd.mode + assert TERMINATOR[0] not in {b'K', b'V'} + + ret = {} + while True: + if fd.peek(1)[0] == TERMINATOR[0]: + if fd.readline() != TERMINATOR: + raise ... + if fd.peek(1): + raise ... + return ret + + key = _read_one_datum(fd, b'K') + value = _read_one_datum(fd, b'V') + ret[key] = value + +def outputHash(fd, hash): + """\ + Write a dictionary to an FD in the same format as used by + svn_hash_write2(), ie 'K <length>\\n<key>\\nV <length>\\n<value>\\n' + terminated by "END\n". + """ + assert 'b' in fd.mode + + for key, val in hash.items(): + fd.write(b'K ' + bytes(str(len(key)), 'utf-8') + b'\n') + fd.write(key + b'\n') + fd.write(b'V ' + bytes(str(len(val)), 'utf-8') + b'\n') + fd.write(val + b'\n') + fd.write(TERMINATOR) + +def writeHashFile(filename, hash): + """\ + Open a temporary file and write the dictionary to the temp + file in svn_hash_write2() format. + + If the file is successfully written it is renamed to <filename>. + """ + tmpFilename = filename + '.tmp' + try: + with open(tmpFilename, 'xb') as fd: + outputHash(fd, hash) + os.rename(tmpFilename, filename) + except FileExistsError: + print('{} File {} already exist. Is the script already running?\n'.format(argv[0], tmpFilename), file=sys.stderr) + except: + os.remove(tmpFilename) + raise + +def main(): + """Main function""" + # Parse arguments + import argparse + parser = argparse.ArgumentParser( + description=PARSERDESCR, + formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument('realm', help='Server authentication real') + parser.add_argument('-u', '--user', help='Set username', nargs='?') + args = parser.parse_args() + + # The file name is the md5encoding of the realm + import hashlib + m = hashlib.md5() + m.update(args.realm.encode('utf-8')) + authfileName = os.path.join(os.path.expanduser('~/.subversion/auth/svn.simple/'), m.hexdigest()) + + # If new file, check that username was given as an argument before prompting for password + existingFile = os.path.exists(authfileName) + if not existingFile and args.user is None: + print('New file, username required (see -u)\n', file=sys.stderr) + parser.print_help() + return + + # Prompt for password + import getpass + password = getpass.getpass("Enter password for realm {}: ".format(args.realm)) + + # In an existing file, we add/replace password/username/passtype + if existingFile: + hash = svn_hash_read(open(authfileName, 'rb')) + if args.user is not None: + hash[b'username'] = args.user.encode('utf-8') + hash[b'password'] = password.encode('utf-8') + hash[b'passtype'] = b'simple' + + # For a new file, set realmstring, username, password and passtype + else: + hash = { + b'svn:realmstring': args.realm.encode('utf-8'), + b'username': args.user.encode('utf-8'), + b'passtype': b'simple', + b'password': password.encode('utf-8') + } + + # Write out the resulting file + writeHashFile(authfileName, hash) + + +if __name__ == '__main__': + main() + Property changes on: contrib/client-side/store-plaintext-password.py ___________________________________________________________________ Added: svn:eol-style ## -0,0 +1 ## +native \ No newline at end of property Added: svn:executable ## -0,0 +1 ## +* \ No newline at end of property