Attaching version 5, with changes as below. Den sön 7 mars 2021 kl 20:42 skrev Daniel Shahaf <d...@daniel.shahaf.name>:
> Daniel Sahlberg wrote on Sun, Mar 07, 2021 at 19:34:15 +0100: > > 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: > > > > + 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 > > I stand corrected. > > > > > +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/'), > .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. > > +1 > > > 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. > > Let's just say that "should X be done" and "could Daniel be bothered to > do X" are not synonymous. > Agree. Patches welcome! > > > (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(). > > Yes, but that's printed later. If this script is invoked by another, > that's then invoked by another that's invoked by some CI, etc., > _prefixing_ the script's name to the message tends to be helpful. > Ok. Added now. > > > +# 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.. > > > > > > +# 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') > > } > > Yeah, except add a comma on the fourth line to minimize future diffs. > (y) For future reference, there's also a «dict(foo=42)» syntax which is > sometimes convenient. > > > > 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? > > No, I meant literally add the line of code «del password» once the > variable «password» is no longer needed. That won't actually overwrite > the value's bytes in memory, but it will prevent the variable from being > accidentally used afterwards. > (y) > > +++ contrib/client-side/store-plaintext-password.py (working copy) > > @@ -0,0 +1,184 @@ > > +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". > > Escape the newline. > (y) > Mention the data type of HASH's keys and values? > (y) > > + """ > > + 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) > > s/{}/{}:/ (first time) > (y) > s/{}/{!r}/ (second time) - in this case this will probably just add > quotes, but still. (tl;dr: This prints the repr() of the formatee > rather than its str().) > (y) > Is there any point in naming the exception and printing something for it > (probably one of its attributes)? I don't see a failure mode in which > that would make a difference, but absence of evidence isn't evidence of > absence. > Probably, and moving the error handling to a more "top level" position. However I'm out of time this morning. > + except: > > + os.remove(tmpFilename) > > + raise > > + > > +def main(): > > + """Main function""" > > I'm not a fan of docstrings that simply repeat what may be inferred from > the identifier. I don't think there's much this function's docstring > _could_ say, though. I'd consider removing the docstring entirely or > having it use the term "entry point" so as to make it less tautological. > (y) > > + # Parse arguments > > + import argparse > > + parser = argparse.ArgumentParser( > ⋮ > > + # The file name is the md5encoding of the realm > > + import hashlib > > + m = hashlib.md5() > > Hmm. Should all function-local imports be grouped at the top of the > function? I don't remember the convention here. > Is there a Subversion specific Style Guide for Python? I didn't find anything in HACKING or trunk/doc. PEP 8 says: [[[ Imports are always put at the top of the file, just after any module comments and docstrings, and before module globals and constants. ]]] If I understand this correctly, it prefers global imports only? If keeping the imports in the function, then moving to the top is probably more PEP-8-ish. I didn't change anything though. For reference, ordering should be [[[ Imports should be grouped in the following order: 1. standard library imports 2. related third party imports 3. local application/library specific imports ]]] No explicit mentioning of alphabetical sorting, but that seems like a good idea. (Offtopic: When reviewing /trunk/doc, I had a look at /trunk/doc/programmer/WritingChangeLogs.txt. Is it still relevant? It seems very CVS-ish and mostly covered in HACKING). 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,189 @@ +#!/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". + + The keys and values must have datatype 'bytes' and strings must be + encoded using utf-8. + """ + 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 {!r} already exist. Is the script already running?\n'.format(argv[0], tmpFilename), file=sys.stderr) + except: + os.remove(tmpFilename) + raise + +def main(): + # 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'.print(argv[0]), 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'), + } + + + del password + + # 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