[Date Prev][Date Next][Thread Prev][Thread Next][Date Index][Thread Index]

[minion-cvs] Finish implementation and tests for directories and pat...



Update of /home/minion/cvsroot/src/minion/lib/mixminion
In directory moria.mit.edu:/tmp/cvs-serv16537/src/minion/lib/mixminion

Modified Files:
	BuildMessage.py ClientMain.py Common.py ServerInfo.py test.py 
Log Message:
Finish implementation and tests for directories and path selection.

BuildMessage:
	- Raise an error if either leg of the path is empty

ClientMain:
	- Debug client-side directory and path code.
	- Add a magic string to cache files so we can update later.
	- Add code to actually *call* downloadDirectory.
	- Remove last vestiges of 'TrivialKeystore'.

Common:
	- Add 'start' and 'end' to IntervalSet

Common, ServerList:
	- Move openUnique from ServerList to Common

ClientMain, ServerInfo, ServerDirectory:
	- Keep a dict of the digests we've already validated, so we
	  don't need to do N RSA-checks every time we download a directory.
	
test: 
	- Tests for client path selection logic
	- Tests for client keystore
	- Tests for formatFnameTime
	- Tests for openUnique
	- Adapt tests for MixminionClient to use new interface.


Index: BuildMessage.py
===================================================================
RCS file: /home/minion/cvsroot/src/minion/lib/mixminion/BuildMessage.py,v
retrieving revision 1.28
retrieving revision 1.29
diff -u -d -r1.28 -r1.29
--- BuildMessage.py	3 Jan 2003 08:47:27 -0000	1.28
+++ BuildMessage.py	4 Jan 2003 04:12:50 -0000	1.29
@@ -31,11 +31,14 @@
             paddingPRNG: random number generator used to generate padding.
                   If None, a new PRNG is initialized.
 
-        Neither path1 nor path2 may be empty.
+        Neither path1 nor path2 may be empty.  If one is, MixError is raised.
     """
     if paddingPRNG is None: 
         paddingPRNG = Crypto.getCommonPRNG()
-    assert path1 and path2
+    if not path1:
+        raise MixError("First leg of path is empty")
+    if not path2:
+        raise MixError("Second leg of path is empty")
 
     LOG.debug("Encoding forward message for %s-byte payload",len(payload))
     LOG.debug("  Using path %s/%s",

Index: ClientMain.py
===================================================================
RCS file: /home/minion/cvsroot/src/minion/lib/mixminion/ClientMain.py,v
retrieving revision 1.20
retrieving revision 1.21
diff -u -d -r1.20 -r1.21
--- ClientMain.py	3 Jan 2003 08:47:27 -0000	1.20
+++ ClientMain.py	4 Jan 2003 04:12:51 -0000	1.21
@@ -32,7 +32,9 @@
 import mixminion.MMTPClient
 from mixminion.Common import IntervalSet, LOG, floorDiv, MixError, \
      MixFatalError, ceilDiv, createPrivateDir, isSMTPMailbox, formatDate, \
-     formatFnameTime, readPossiblyGzippedFile
+     formatFnameTime, formatTime, openUnique, previousMidnight, \
+     readPossiblyGzippedFile
+
 from mixminion.Config import ClientConfig, ConfigError
 from mixminion.ServerInfo import ServerInfo, ServerDirectory
 from mixminion.Packet import ParseError, parseMBOXInfo, parseSMTPInfo, \
@@ -42,33 +44,49 @@
 MIXMINION_DIRECTORY_URL = "http://www.mixminion.net/directory/latest.gz";
 # FFFF This should be made configurable.
 MIXMINION_DIRECTORY_FINGERPRINT = ""
-                                
+
 class ClientKeystore:
-    """A ClientKeystore manages a list of server descriptors, either 
+    """A ClientKeystore manages a list of server descriptors, either
        imported from the command line or from a directory."""
     ##Fields:
     # dir: directory where we store everything.
     # lastModified: time when we last modified this keystore
     # lastDownload: time when we last downloaded a directory
-    # serverList: List of (ServerInfo, 'D'|'I:filename') tuples.  The second
-    #    element indicates whether the ServerInfo comes from a directory or
-    #    a file.
+    # serverList: List of (ServerInfo, 'D'|'I:filename') tuples.  The
+    #   second element indicates whether the ServerInfo comes from a
+    #   directory or a file.
+    # digestMap: Map of (Digest -> 'D'|'I:filename').
     # byNickname: Map from nickname to list of (ServerInfo, source) tuples.
     # byCapability: Map from capability ('mbox'/'smtp'/'relay'/None) to
     #    list of (ServerInfo, source) tuples.
     # allServers: Same as byCapability[None]
     # __scanning: Flag to prevent recursive invocation of self.rescan().
     ## Layout:
-    # DIR/cache: A cPickled tuple of (lastModified, lastDownload, serverlist)
+    # DIR/cache: A cPickled tuple of ("ClientKeystore-0",
+    #         lastModified, lastDownload, serverlist, digestMap)
     # DIR/dir.gz *or* DIR/dir: A (possibly gzipped) directory file.
     # DIR/servers/: A directory of server descriptors.
+
+    MAGIC = "ClientKeystore-0"
     def __init__(self, directory):
         """Create a new ClientKeystore to keep directories and descriptors
            under <directory>."""
         self.dir = directory
         createPrivateDir(self.dir)
+        self.digestMap = {}
         self.__scanning = 0
         self.__load()
+
+    def updateDirectory(self, forceDownload=0, now=None):
+        """Download a directory from the network as needed."""
+        if now is None:
+            now = time.time()
+
+        if forceDownload or self.lastDownload < previousMidnight(now):
+            self.downloadDirectory()
+        else:
+            LOG.debug("Directory is up to date.")
+        
     def downloadDirectory(self):
         """Download a new directory from the network, validate it, and 
            rescan its servers."""
@@ -96,7 +114,8 @@
         # Open and validate the directory
         LOG.info("Validating directory")
         try:
-            directory = ServerDirectory(fname=fname)
+            directory = ServerDirectory(fname=fname,
+                                        validatedDigests=self.digestMap)
         except ConfigError, e:
             raise MixFatalError("Downloaded invalid directory: %s" % e)
 
@@ -119,14 +138,15 @@
 
         # And regenerate the cache. 
         self.rescan()
-        # FFFF Actually, we could be a bit more clever here, and more clever
-        # FFFF about not revalidating already-known-to-be-correct descriptors.
-        # FFFF But that's for later.
+        # FFFF Actually, we could be a bit more clever here, and same some
+        # FFFF time. But that's for later.
         
-    def rescan(self, now=None):
+    def rescan(self, force=None, now=None):
         """Regenerate the cache based on files on the disk."""
         self.lastModified = self.lastDownload = -1
         self.serverList = []
+        if force:
+            self.digestMap = {}
 
         # Read the servers from the directory.
         gzipFile = os.path.join(self.dir, "dir.gz")
@@ -136,13 +156,15 @@
             self.lastDownload = self.lastModified = \
                                 os.stat(fname)[stat.ST_MTIME]
             try:
-                directory = ServerDirectory(fname=fname)
+                directory = ServerDirectory(fname=fname,
+                                            validatedDigests=self.digestMap)
             except ConfigError:
                 LOG.warn("Ignoring invalid directory (!)")
                 continue
 
             for s in directory.getServers():
                 self.serverList.append((s, 'D'))
+                self.digestMap[s.getDigest()] = 'D'
             break
 
         # Now check the server in DIR/servers.
@@ -152,7 +174,9 @@
             # Try to read a file: is it a server descriptor?
             p = os.path.join(serverDir, fn)
             try:
-                info = ServerInfo(fname=p, assumeValid=0)
+                # Use validatedDigests *only* when not explicitly forced.
+                info = ServerInfo(fname=p, assumeValid=0,
+                                  validatedDigests=self.digestMap)
             except ConfigError:
                 LOG.warn("Invalid server descriptor %s", p)
                 continue
@@ -160,6 +184,7 @@
             if mtime > self.lastModified:
                 self.lastModifed = mtime
             self.serverList.append((info, "I:%s"%fn))
+            self.digestMap[info.getDigest()] = "I:%s"%fn
 
         # Regenerate the cache
         self.__save()
@@ -169,15 +194,22 @@
 
     def __load(self):
         """Helper method. Read the cached parsed descriptors from disk."""
+        #self.lastModified = self.lastDownload = -1
+        #self.serverList = []
+        #self.digestMap = {}
         try:
             f = open(os.path.join(self.dir, "cache"), 'rb')
             cached = cPickle.load(f)
-            self.lastModified, self.lastDowload, self.serverList = cached
+            magic, self.lastModified, self.lastDownload, self.serverList, \
+                   self.digestMap = cached
             f.close()
-            self.__rebuildTables()
-            return        
-        except OSError, e:
-            LOG.info("Couldn't create server cache: %s", e)
+            if magic == self.MAGIC:
+                self.__rebuildTables()
+                return
+            else:
+                LOG.warn("Bad magic on keystore cache; rebuilding...")
+        except (OSError, IOError):
+            LOG.info("Couldn't read server cache; rebuilding")
         except (cPickle.UnpicklingError, ValueError), e:
             LOG.info("Couldn't unpickle server cache: %s", e)
         if self.__scanning:
@@ -187,18 +219,23 @@
     def __save(self):
         """Helper method. Recreate the cache on disk."""
         fname = os.path.join(self.dir, "cache.new")
-        os.unlink(fname)
+        try:
+            os.unlink(fname)
+        except OSError:
+            pass
         f = open(fname, 'wb')
-        cPickle.dump((self.lastModified, self.lastDownload, self.serverList),
+        cPickle.dump((self.MAGIC,
+                      self.lastModified, self.lastDownload, self.serverList,
+                      self.digestMap),
                      f, 1)
         f.close()
         os.rename(fname, os.path.join(self.dir, "cache"))
 
     def importFromFile(self, filename):
-        """Import a new server descriptor storred in 'filename'"""
+        """Import a new server descriptor stored in 'filename'"""
         
         contents = readPossiblyGzippedFile(filename)
-        info = ServerInfo(string=contents)
+        info = ServerInfo(string=contents, validatedDigests=self.digestMap)
 
         nickname = info.getNickname()
         identity = info.getIdentity()
@@ -209,15 +246,20 @@
                                                            s.getIdentity()):
                     raise MixError("Identity key changed for server %s in %s",
                                    nickname, filename)
+
+        # Have we already imported this server?
+        if self.digestMap.get(info.getDigest(), "X").startswith("I:"):
+            raise MixError("Server descriptor is already imported")
         
         # Copy the server into DIR/servers.
         fnshort = "%s-%s"%(nickname, formatFnameTime())
         fname = os.path.join(self.dir, "servers", fnshort)
-        f = open(fname, 'w')
+        f = openUnique(fname)[0]
         f.write(contents)
         f.close()
         # Now store into the cache.
-        self.serverList.append((info, 'I:%s', fnshort))
+        self.serverList.append((info, 'I:%s'%fnshort))
+        self.digestMap[info.getDigest()] = 'I:%s'%fnshort
         self.lastModified = time.time()
         self.__save()
         self.__rebuildTables()
@@ -244,6 +286,7 @@
         if n:
             self.lastModifed = time.time()
             self.__save()
+            self.__rebuildTables()
         return n
 
     def __rebuildTables(self):
@@ -267,8 +310,8 @@
 
     def listServers(self):
         """Returns a linewise listing of the current servers and their caps.
-           stdout.  This will go away or get refactored in future versions
-           once we have client-level modules."""
+            This will go away or get refactored in future versions once we
+            have client-level modules."""
         lines = []
         nicknames = self.byNickname.keys()
         nicknames.sort()
@@ -338,7 +381,7 @@
                 pass
             else:
                 valid = info.getIntervalSet()
-                for s in self.byNickname[info.getNickname()]:
+                for s, _ in self.byNickname[info.getNickname()]:
                     if s.isNewerThan(info):
                         valid -= s.getIntervalSet()
                 if not valid.isEmpty():
@@ -376,31 +419,32 @@
             else:
                 LOG.error("Server is not currently valid")
         elif self.byNickname.has_key(name):
-            s = self.__find(self.byNickname[name], startAt, endAt)
+            s = self.__findOne(self.byNickname[name], startAt, endAt)
             if not s:
-                raise MixError("Couldn't find valid descriptor %s")
+                raise MixError("Couldn't find valid descriptor %s" % name)
+            return s
         elif os.path.exists(name):
             try:
                 return ServerInfo(fname=name, assumeValid=0)
             except OSError, e:
-                raise MixError("Couldn't read descriptor %s: %s" %
+                raise MixError("Couldn't read descriptor %r: %s" %
                                (name, e))
             except ConfigError, e:
-                raise MixError("Couldn't parse descriptor %s: %s" %
+                raise MixError("Couldn't parse descriptor %r: %s" %
                                (name, e))
         elif strict:
-            raise MixError("Couldn't find descriptor %s")
+            raise MixError("Couldn't find descriptor %r" % name)
         else:
             return None
 
     def getPath(self, midCap=None, endCap=None, length=None,
                 startServers=(), endServers=(),
                 startAt=None, endAt=None, prng=None):
-        """Workhorse method for path selection.  Constructs a 'length'-hop
-           path, beginning with startServers and ending with endServers.  If
-           more servers are required to make up 'length' hops, they are
-           selected at random.
-           
+        """Workhorse method for path selection.  Constructs a path of length
+           >= 'length' hops, path, beginning with startServers and ending with
+           endServers.  If more servers are required to make up 'length' hops,
+           they are selected at random.
+          
            All servers are chosen to be valid continuously from startAt to
            endAt.  All newly-selected servers except the last are required to
            have 'midCap' (typically 'relay'); the last server (if endServers
@@ -436,12 +480,16 @@
             # If so, find all candidates...
             endList = self.__find(self.byCapability[endCap],startAt,endAt)
             if not endList:
-                raise MixError("No %s servers known"% endCap)
-            # ... and pick one.
-            LOG.info("Choosing from among %s %s servers",
-                     len(endList), endCap)
-            endServers = [ prng.pick(endList) ]
-            LOG.debug("Chose %s", endServers[0].getNickname())
+                raise MixError("No %s servers known" % endCap)
+            # ... and pick one that hasn't been used, if possible.
+            used = [ info.getNickname() for info in startServers ]
+            unusedEndList = [ info for info in endList 
+                              if info.getNickname() not in used ]
+            if unusedEndList:
+                endServers = [ prng.pick(unusedEndList) ]
+            else:
+                endServers = [ prng.pick(endList) ]
+            LOG.debug("Chose %s at exit server", endServers[0].getNickname())
             nNeeded -= 1
 
         # Now are we done?
@@ -466,14 +514,15 @@
         # Which are left?
         unusedMidList = [ info for info in midList 
                           if info.getNickname() not in used ]
-        if len(unusedMidList) >= length:
+        if len(unusedMidList) >= nNeeded:
             # We have enough enough servers to choose without replacement.
             midServers = prng.shuffle(unusedMidList, nNeeded)
         elif len(midList) >= 3:
             # We have enough servers to choose without two hops in a row to
             # the same server.
-            LOG.warn("Not enough servers for distinct path (only %s unused)",
-                     len(unusedMidList))
+            LOG.warn("Not enough servers for distinct path (%s unused, %s known)",
+                     len(unusedMidList), len(midList))
+
             midServers = []
             if startServers:
                 prevNickname = startServers[-1].getNickname()
@@ -491,13 +540,13 @@
                     midServers.append(info)
                     prevNickname = n
                     nNeeded -= 1
-        elif midList == 2:
+        elif len(midList) == 2:
             # We have enough servers to concoct a path that at least 
             # _sometimes_ doesn't go to the same server twice in a row.
             LOG.warn("Not enough relays to avoid same-server hops")
             midList = prng.shuffle(midList)
-            midServers = (midList * ceilDiv(nNeeded, 2))[-nNeeded]
-        elif midList == 1:
+            midServers = (midList * ceilDiv(nNeeded, 2))[:nNeeded]
+        elif len(midList) == 1:
             # There's no point in choosing a long path here: it can only
             # have one server in it.
             LOG.warn("Only one relay known")
@@ -505,11 +554,11 @@
         else:
             # We don't know any servers at all.
             raise MixError("No relays known")
-        
+
         LOG.info("Chose path: [%s][%s][%s]",
-                 " ".join([s.getNickname() for s in startServers]),
-                 " ".join([s.getNickname() for s in midServers]),
-                 " ".join([s.getNickname() for s in endServers]))
+                 " ".join([ s.getNickname() for s in startServers ]),
+                 " ".join([ s.getNickname() for s in midServers   ]),
+                 " ".join([ s.getNickname() for s in endServers   ]))
 
         return startServers + midServers + endServers
 
@@ -530,7 +579,7 @@
     if startAt is None:
         startAt = time.time()
     if endAt is None:
-        endAt = time.time()+3*60*60 # FFFF Configurable
+        endAt = startAt+3*60*60 # FFFF Configurable
 
     # First, find out what the exit node needs to be (or support).
     routingType, _, exitNode = address.getRouting()
@@ -545,8 +594,9 @@
 
     # We have a normally-specified path. 
     if exitNode is not None:
+        exitPath = exitPath[:]
         exitPath.append(exitNode)
-        nHops -= 1
+        
     path = keystore.getPath(length=nHops,
                             startServers=enterPath,
                             endServers=exitPath,
@@ -562,7 +612,7 @@
                        % (server.getNickname(), exitCap))
  
     if nSwap is None:
-        nSwap = ceilDiv(len(path),2)   
+        nSwap = ceilDiv(len(path),2)-1
     return path[:nSwap+1], path[nSwap+1:]
 
 def parsePath(keystore, config, path, address, nHops=None, 
@@ -571,6 +621,7 @@
        (path-leg-1, path-leg-2) tuple.
        
        keystore -- the keystore to use.
+       config -- unused for now.
        path -- the path, in a format described below.  If the path is
           None, all servers are chosen as if the path were '*'.
        address -- the address to deliver the message to; if it specifies
@@ -643,7 +694,17 @@
         myNSwap = swapPos - 1
     else:
         # a,*,b:c,d
-        myNSwap = myNHops - (len(path)-swapPos-1)
+        # There are len(path)-swapPos-1 servers after the swap point.
+        # There are a total of myNHops servers.
+        # Thus, there are myNHops-(len(path)-swapPos-1) servers up to and
+        #  including the swap server.
+        # So, the swap server is at index myNHops - (len(path)-swapPos-1) -1,
+        #   which is the same as...
+        myNSwap = myNHops - len(path) + swapPos
+        # But we need to adjust for the last node that we may have to
+        #   add because of the address
+        if address.getRouting()[2]:
+            myNSwap -= 1
 
     # Check myNSwap for consistency
     if nSwap is not None:
@@ -664,156 +725,6 @@
     return resolvePath(keystore, address, enterPath, exitPath,
                        myNHops, myNSwap, startAt, endAt)
 
-## class TrivialKeystore:
-##     """This is a temporary keystore implementation until we get a working
-##        directory server implementation.
-
-##        The idea couldn't be simpler: we just keep a directory of files, each
-##        containing a single server descriptor.  We cache nothing; we validate
-##        everything; we have no automatic path generation.  Servers can be
-##        accessed by nickname, by filename within our directory, or by filename
-##        from elsewhere on the filesystem.
-
-##        We skip all server descriptors that have expired, that will
-##        soon expire, or which aren't yet in effect.
-##        """
-##     ## Fields:
-##     # directory: path to the directory we scan for server descriptors.
-##     # byNickname: a map from nickname to valid ServerInfo object.
-##     # byFilename: a map from filename within self.directory to valid
-##     #     ServerInfo object.
-##     def __init__(self, directory, now=None):
-##         """Create a new TrivialKeystore to access the descriptors stored in
-##            directory.  Selects descriptors that are valid at the time 'now',
-##            or at the current time if 'now' is None."""
-##         self.directory = directory
-##         createPrivateDir(directory)
-##         self.byNickname = {}
-##         self.byFilename = {}
-
-##         if now is None:
-##             now = time.time()
-
-##         for f in os.listdir(self.directory):
-##             # Try to read a file: is it a server descriptor?
-##             p = os.path.join(self.directory, f)
-##             try:
-##                 info = ServerInfo(fname=p, assumeValid=0)
-##             except ConfigError:
-##                 LOG.warn("Invalid server descriptor %s", p)
-##                 continue
-
-##             # Find its nickname and normalized filename
-##             serverSection = info['Server']
-##             nickname = serverSection['Nickname']
-
-##             if '.' in f:
-##                 f = f[:f.rindex('.')]
-
-##             # Skip the descriptor if it isn't valid yet...
-##             if now < serverSection['Valid-After']:
-##                 LOG.info("Ignoring future decriptor %s", p)
-##                 continue
-##             # ... or if it's expired ...
-##             if now >= serverSection['Valid-Until']:
-##                 LOG.info("Ignoring expired decriptor %s", p)
-##                 continue
-##             # ... or if it's going to expire within 3 hours (HACK!).
-##             if now + 3*60*60 >= serverSection['Valid-Until']:
-##                 LOG.info("Ignoring soon-to-expire decriptor %s", p)
-##                 continue
-##             # Only allow one server per nickname ...
-##             if self.byNickname.has_key(nickname):
-##                 LOG.warn(
-##                     "Ignoring descriptor %s with duplicate nickname %s",
-##                     p, nickname)
-##                 continue
-##             # ... and per normalized filename.
-##             if self.byFilename.has_key(f):
-##                 LOG.warn(
-##                     "Ignoring descriptor %s with duplicate prefix %s",
-##                     p, f)
-##                 continue
-##             LOG.info("Loaded server %s from %s", nickname, f)
-##             # Okay, it's good. Cache it.
-##             self.byNickname[nickname] = info
-##             self.byFilename[f] = info
-
-##     def getServerInfo(self, name):
-##         """Return a ServerInfo object corresponding to 'name'.  If 'name' is
-##            a ServerInfo object, returns 'name'.  Otherwise, checks server by
-##            nickname, then by filename within the keystore, then by filename
-##            on the file system. If no server is found, returns None."""
-##         if isinstance(name, ServerInfo):
-##             return name
-##         elif self.byNickname.has_key(name):
-##             return self.byNickname[name]
-##         elif self.byFilename.has_key(name):
-##             return self.byFilename[name]
-##         elif os.path.exists(name):
-##             try:
-##                 return ServerInfo(fname=name, assumeValid=0)
-##             except OSError, e:
-##                 raise MixError("Couldn't read descriptor %s: %s" %
-##                                (name, e))
-##             except ConfigError, e:
-##                 raise MixError("Couldn't parse descriptor %s: %s" %
-##                                (name, e))
-##         else:
-##             return None
-
-##     def getPath(self, serverList):
-##         """Given a sequence of strings of ServerInfo objects, resolves each
-##            one according to the rule of getServerInfo, and returns a list of
-##            ServerInfos.  Raises MixError if any server can't be resolved."""
-##         path = []
-##         for s in serverList:
-##             if isinstance(s, ServerInfo):
-##                 path.append(s)
-##             elif isinstance(s, types.StringType):
-##                 server = self.getServerInfo(s)
-##                 if server is not None:
-##                     path.append(server)
-##                 else:
-##                     raise MixError("Couldn't find descriptor %s" % s)
-##         return path
-
-##     def listServers(self):
-##         """Returns a linewise listing of the current servers and their caps.
-##            stdout.  This will go away or get refactored in future versions
-##            once we have real path selection and client-level modules."""
-##         lines = []
-##         nicknames = self.byNickname.keys()
-##         nicknames.sort()
-##         longestnamelen = max(map(len, nicknames))
-##         fmtlen = min(longestnamelen, 20)
-##         format = "%"+str(fmtlen)+"s (expires %s): %s"
-##         for n in nicknames:
-##             caps = []
-##             si = self.byNickname[n]
-##             if si['Delivery/MBOX'].get('Version',None):
-##                 caps.append("mbox")
-##             if si['Delivery/SMTP'].get('Version',None):
-##                 caps.append("smtp")
-##             # XXXX This next check is highly bogus.
-##             if (si['Incoming/MMTP'].get('Version',None) and 
-##                 si['Outgoing/MMTP'].get('Version',None)):
-##                 caps.append("relay")
-##             until = formatDate(si['Server']['Valid-Until'])
-##             line = format % (n, until, " ".join(caps))
-##             lines.append(line)
-##         return lines
-
-##     def getRandomServers(self, prng, n):
-##         """Returns a list of n different servers, in random order, according
-##            to prng.  Raises MixError if not enough exist.
-
-##            (This isn't actually used.)"""
-##         vals = self.byNickname.values()
-##         if len(vals) < n:
-##             raise MixError("Not enough servers (%s requested)", n)
-##         return prng.shuffle(vals, n)
-
 def installDefaultConfig(fname):
     """Create a default, 'fail-safe' configuration in a given file"""
     LOG.warn("No configuration file found. Installing default file in %s",
@@ -1008,9 +919,10 @@
 #       options will change between now and 1.0.0
 def runClient(cmd, args):
     # DOCDOC
-    options, args = getopt.getopt(args, "hvf:i:t:H:P:",
+    options, args = getopt.getopt(args, "hvf:i:t:H:P:D:",
                                   ["help", "verbose", "config=", "input=",
                                    "to=", "hops=", "swap-at=", "path",
+                                   "download-directory=",
                                   ])
     configFile = '~/.mixminionrc'
     inFile = "-"
@@ -1019,6 +931,7 @@
     nHops = None
     nSwap = None
     address = None
+    download = None
     for opt,val in options:
         if opt in ('-h', '--help'):
             usageAndExit(cmd)
@@ -1042,6 +955,14 @@
                 usageAndExit(cmd, "%s expects an integer"%opt)
         elif opt in ('-t', '--to'):
             address = parseAddress(val)
+        elif opt in ('-D', '--download-directory'):
+            download = val.lower()
+            if download in ('0','no','false','n','f'):
+                download = 0
+            elif download in ('1','yes','true','y','t','force'):
+                download = 1
+            else:
+                usageAndExit(cmd, "Unrecognized value for %s"%opt)
     if args:
         usageAndExit(cmd,"Unexpected options")
     if address is None:
@@ -1057,6 +978,9 @@
     mixminion.Crypto.init_crypto(config)
 
     keystore = ClientKeystore(os.path.expanduser(config['User']['UserDir']))
+    if download != 0:
+        keystore.updateDirectory(forceDownload=download)
+    
     #try:
     if 1:
         path1, path2 = parsePath(keystore, config, path, address, nHops, nSwap)

Index: Common.py
===================================================================
RCS file: /home/minion/cvsroot/src/minion/lib/mixminion/Common.py,v
retrieving revision 1.40
retrieving revision 1.41
diff -u -d -r1.40 -r1.41
--- Common.py	3 Jan 2003 08:25:47 -0000	1.40
+++ Common.py	4 Jan 2003 04:12:51 -0000	1.41
@@ -9,9 +9,9 @@
             'MixProtocolError', 'ceilDiv', 'checkPrivateDir',
             'createPrivateDir', 'floorDiv', 'formatBase64', 'formatDate',
             'formatFnameTime', 'formatTime', 'installSignalHandlers',
-            'isSMTPMailbox', 'onReset', 'onTerminate', 'previousMidnight',
-            'readPossiblyGzippedFile',
-            'secureDelete', 'stringContains', 'waitForChildren' ]
+            'isSMTPMailbox', 'onReset', 'onTerminate', 'openUnique',
+            'previousMidnight', 'readPossiblyGzippedFile', 'secureDelete',
+            'stringContains', 'waitForChildren' ]
 
 import base64
 import bisect
@@ -542,7 +542,6 @@
 def formatFnameTime(when=None):
     """Given a time in seconds since the epoch, returns a date value suitable
        for use as part of a fileame.  Defaults to the current time."""
-    # XXXX002 test
     if when is None:
         when = time.time()
     return time.strftime("%Y%m%d%H%M%S", time.localtime(when))
@@ -690,6 +689,14 @@
     def __cmp__(self, other):
         """A == B iff A and B contain exactly the same intervals."""
         return cmp(self.edges, other.edges)
+
+    def start(self):
+        """Return the first point contained in this inverval."""
+        return self.edges[0][0]
+
+    def end(self):
+        """Return the last point contained in this interval."""
+        return self.edges[-1][0]
         
 #----------------------------------------------------------------------
 # SMTP address functionality
@@ -786,7 +793,7 @@
         signal.signal(signal.SIGTERM, _sigHandler)
 
 #----------------------------------------------------------------------
-# Gzip helpers
+# File helpers.
 
 def readPossiblyGzippedFile(fname, mode='r'):
     """Read the contents of the file <fname>.  If <fname> ends with ".gz", 
@@ -802,4 +809,17 @@
         if f is not None:
             f.close()
 
-
+def openUnique(fname, mode='w'):
+    """Helper function. Returns a file open for writing into the file named
+       'fname'.  If fname already exists, opens 'fname.1' or 'fname.2' or
+       'fname.3' or so on."""
+    base, rest = os.path.split(fname)
+    idx = 0
+    while 1:
+        try:
+            fd = os.open(fname, os.O_WRONLY|os.O_CREAT|os.O_EXCL, 0600)
+            return os.fdopen(fd, mode), fname
+        except OSError:
+            pass
+        idx += 1
+        fname = os.path.join(base, "%s.%s"%(rest,idx))

Index: ServerInfo.py
===================================================================
RCS file: /home/minion/cvsroot/src/minion/lib/mixminion/ServerInfo.py,v
retrieving revision 1.30
retrieving revision 1.31
diff -u -d -r1.30 -r1.31
--- ServerInfo.py	3 Jan 2003 08:25:47 -0000	1.30
+++ ServerInfo.py	4 Jan 2003 04:12:51 -0000	1.31
@@ -38,6 +38,9 @@
 class ServerInfo(mixminion.Config._ConfigFile):
     ## Fields: (as in ConfigFile, plus)
     # _isValidated: flag.  Has this serverInfo been fully validated?
+    # _validatedDigests: a dict whose keys are already-validated server
+    #    digests.  Optional.  Only valid while 'validate' is being called.
+
     """A ServerInfo object holds a parsed server descriptor."""
     _restrictFormat = 1
     _syntax = {
@@ -77,20 +80,46 @@
                      }
         }
 
-    def __init__(self, fname=None, string=None, assumeValid=0):
+    def __init__(self, fname=None, string=None, assumeValid=0,
+                 validatedDigests=None):
+        """Read a server descriptor from a file named <fname>, or from
+             <string>.
+
+           If assumeValid is true, don't bother to validate it.
+           
+           If the (computed) digest of this descriptor is a key of the dict
+              validatedDigests, assume we have already validated it, and
+              pass it along.
+        """
         self._isValidated = 0
+        self._validatedDigests = validatedDigests
         mixminion.Config._ConfigFile.__init__(self, fname, string, assumeValid)
+        del self._validatedDigests
         LOG.trace("Reading server descriptor %s from %s",
                        self['Server']['Nickname'],
                        fname or "<string>")
 
     def validate(self, sections, entries, lines, contents):
         ####
-        # Check 'Server' section.
+        # Check 'Server' section.               
         server = sections['Server']
         if server['Descriptor-Version'] != '0.1':
             raise ConfigError("Unrecognized descriptor version %r",
                               server['Descriptor-Version'])
+
+        ####
+        # Check the igest of file
+        digest = getServerInfoDigest(contents)
+        if digest != server['Digest']:
+            raise ConfigError("Invalid digest")
+
+        # Have we already validated this particular ServerInfo?
+        if (self._validatedDigests and
+            self._validatedDigests.has_key(digest)):
+            self._isValidated = 1
+            return
+
+        # Validate the rest of the server section.
         identityKey = server['Identity']
         identityBytes = identityKey.get_modulus_bytes()
         if not (MIN_IDENTITY_BYTES <= identityBytes <= MAX_IDENTITY_BYTES):
@@ -107,12 +136,8 @@
         if packetKeyBytes != PACKET_KEY_BYTES:
             raise ConfigError("Invalid length on packet key")
 
-        ####
-        # Check Digest of file
-        digest = getServerInfoDigest(contents)
-        if digest != server['Digest']:
-            raise ConfigError("Invalid digest")
 
+        ####
         # Check signature
         try:
             signedDigest = pk_check_signature(server['Signature'], identityKey)
@@ -148,6 +173,11 @@
         """Returns this server's nickname"""
         return self['Server']['Nickname']
 
+    def getDigest(self):
+        """Returns the declared (not computed) digest of this server
+           descriptor."""
+        return self['Server']['Digest']
+    
     def getAddr(self):
         """Returns this server's IP address"""
         return self['Server']['IP']
@@ -256,9 +286,14 @@
     # servers: list of validated ServerInfo objects, in no particular order.
     # header: a _DirectoryHeader object for the non-serverinfo part of this
     #    directory.
-    def __init__(self, string=None, fname=None):
+    def __init__(self, string=None, fname=None, validatedDigests=None):
         """Create a new ServerDirectory object, either from a literal <string>
            (if specified) or a filename [possibly gzipped].
+
+           If validatedDigests is provided, it must be a dict whose keys
+           are the digests of already-validated descriptors.  Any descriptor
+           whose (calculated) digest matches doesn't need to be validated
+           again.           
         """
         if string:
             contents = string
@@ -277,7 +312,9 @@
         servercontents = [ "[Server]\n%s"%s for s in sections[1:] ]
 
         self.header = _DirectoryHeader(headercontents, digest)
-        self.servers = [ ServerInfo(string=s) for s in servercontents ]
+        self.servers = [ ServerInfo(string=s,
+                                    validatedDigests=validatedDigests)
+                         for s in servercontents ]
 
     def getServers(self):
         """Return a list of ServerInfo objects in this directory"""

Index: test.py
===================================================================
RCS file: /home/minion/cvsroot/src/minion/lib/mixminion/test.py,v
retrieving revision 1.55
retrieving revision 1.56
diff -u -d -r1.55 -r1.56
--- test.py	3 Jan 2003 08:47:27 -0000	1.55
+++ test.py	4 Jan 2003 04:12:51 -0000	1.56
@@ -15,6 +15,7 @@
 import base64
 import cPickle
 import cStringIO
+import gzip
 import os
 import re
 import stat
@@ -204,6 +205,11 @@
             # 3. That previousMidnight is idempotent
             self.assertEquals(previousMidnight(pm), pm)
 
+        now = time.time()
+        ft = formatFnameTime(now)
+        tm = time.localtime(now)
+        self.assertEquals(ft, "%04d%02d%02d%02d%02d%02d" % tm[:6])
+
     def test_isSMTPMailbox(self):
         # Do we accept good addresses?
         for addr in "Foo@bar.com", "a@b", "a@b.c.d.e", "a!b.c@d", "z@z":
@@ -388,6 +394,25 @@
         t(36 not in fromSquareToSquare)
         t(100 not in fromSquareToSquare)
 
+    def test_openUnique(self):
+        d = mix_mktemp()
+        os.mkdir(d)
+        dX = os.path.join(d,"X")
+        f, fn = openUnique(dX)
+        f.write("X")
+        f.close()
+        self.assertEquals(fn, dX)
+
+        f, fn = openUnique(dX)
+        f.write("X")
+        f.close()
+        self.assertEquals(fn, dX+".1")
+
+        f, fn = openUnique(dX)
+        f.write("X")
+        f.close()
+        self.assertEquals(fn, dX+".2")
+
     def _intervalEq(self, a, *others):
         eq = self.assertEquals
         for b in others:
@@ -4124,81 +4149,462 @@
     sys.stdout.flush()
     return _EXAMPLE_DESCRIPTORS
 
+def getDirectory(servers, identity):
+    """Return the filename of a newly created server directory, containing
+       the server descriptors provided as literal strings in <servers>,
+       signed with the RSA key <identity>"""
+    SL = mixminion.directory.ServerList.ServerList(mix_mktemp())
+    active = IntervalSet()
+    for s in servers:
+        SL.importServerInfo(s)
+        s = mixminion.ServerInfo.ServerInfo(fname=s, assumeValid=1)
+        active += s.getIntervalSet()
+    start, end = active.start(), active.end()
+    SL.generateDirectory(start, end, 0, identity)
+    return SL.getDirectoryFilename()
+
 # variable to hold the latest instance of FakeBCC.
 BCC_INSTANCE = None
 
 class ClientMainTests(unittest.TestCase):
-    def testTrivialKeystore(self):
+    def testClientKeystore(self):
         """Check out ClientMain's keystore implementation"""
         eq = self.assertEquals
-        raises = self.failUnlessRaises
-
+        neq = self.assertNotEquals
         ServerInfo = mixminion.ServerInfo.ServerInfo
 
         dirname = mix_mktemp()
-        ks = mixminion.ClientMain.TrivialKeystore(dirname)
+        ks = mixminion.ClientMain.ClientKeystore(dirname)
 
+        ## Write the descriptors to disk.
         edesc = getExampleServerDescriptors()
+        impdirname = mix_mktemp()
+        createPrivateDir(impdirname)
+        for server, descriptors in edesc.items():
+            for idx in xrange(len(descriptors)):
+                fname = os.path.join(impdirname, "%s%s" % (server,idx))
+                writeFile(fname, descriptors[idx])
+                f = gzip.GzipFile(fname+".gz", 'w')
+                f.write(descriptors[idx])
+                f.close()
 
-        # Test empty keystore
+        ## Test empty keystore
         eq(None, ks.getServerInfo("Fred"))
+        self.assertRaises(MixError, ks.getServerInfo, "Fred", strict=1)
+        fred = ks.getServerInfo(os.path.join(impdirname, "fred2"))
+        self.assertEquals("Fred", fred.getNickname())
+        self.assertSameSD(edesc["Fred"][2],fred)
+
+        ## Test importing.
+        ks.importFromFile(os.path.join(impdirname, "Joe0"))
+        ks.importFromFile(os.path.join(impdirname, "Joe1.gz"))
+        ks.importFromFile(os.path.join(impdirname, "Lola1"))
+
+        now = time.time()
+        oneDay=24*60*60
+        for i in 0,1,2,3:
+            self.assertSameSD(edesc["Joe"][0], ks.getServerInfo("Joe"))
+            self.assertSameSD(edesc["Lola"][1], ks.getServerInfo("Lola"))
+            self.assertSameSD(edesc["Joe"][1],
+                              ks.getServerInfo("Joe", startAt=now+10*oneDay))
+            self.assertRaises(MixError, ks.getServerInfo, "Joe",
+                              startAt=now+30*oneDay)
+            self.assertRaises(MixError, ks.getServerInfo, "Joe", startAt=now,
+                              endAt=now+6*oneDay)
+            if i in (0,1,2):
+                ks = mixminion.ClientMain.ClientKeystore(dirname)
+            if i == 1:
+                ks.rescan()
+            if i == 2:
+                ks.rescan(force=1)
+
+        # Refuse to import a server with a modified identity
+        writeFile(os.path.join(impdirname, "Notjoe"),
+                  edesc["Lola"][0].replace("Nickname: Lola", "Nickname: Joe"))
+        self.assertRaises(MixError,
+                          ks.importFromFile,
+                          os.path.join(impdirname, "Notjoe"))
+
+        # Try getServerInfo(ServerInfo)
+        si = ServerInfo(string=edesc['Lisa'][1],assumeValid=1)
+        self.assert_(ks.getServerInfo(si) is si)
         try:
             suspendLog()
-            raises(MixError, ks.getPath, ["Fred"])
+            si = ServerInfo(string=edesc['Lisa'][0],assumeValid=1)
+            self.assert_(ks.getServerInfo(si) is None)
         finally:
-            resumeLog()
-        fred = ServerInfo(string=edesc["Fred"][0])
-        eq(1, len(ks.getPath([fred])))
-        self.failUnless(ks.getPath([fred])[0] is fred)
+            s = resumeLog()
+            self.assert_(stringContains(s, "Server is not currently"))
 
-        ## Test importing.
-        for sname, servers in edesc.items():
-            for idx, sdesc in zip(range(len(servers)), servers):
-                writeFile(os.path.join(dirname, "%s%02d"%(sname,idx)), sdesc)
+        ##
+        # Now try out the directory.  This is tricky; we add the other
+        # descriptors here.
+        identity = getRSAKey(0,2048)
+        fingerprint = Crypto.pk_fingerprint(identity)
+        fname = getDirectory(
+            [os.path.join(impdirname, s) for s in
+             ("Fred1", "Fred2", "Lola2", "Alice0", "Alice1",
+              "Bob3", "Bob4", "Lisa1") ], identity)
+        
+        # Replace the real URL and fingerprint with the ones we have; for
+        # unit testing purposes, we can't rely on an http server.
+        mixminion.ClientMain.MIXMINION_DIRECTORY_URL = "file://%s"%fname
+        mixminion.ClientMain.MIXMINION_DIRECTORY_FINGERPRINT = fingerprint
+
+        # Reload the directory.
+        ks.updateDirectory(now=now)
+
+        for i in 0,1,2,3:
+            self.assertSameSD(ks.getServerInfo("Alice"), edesc["Alice"][0])
+            self.assertSameSD(ks.getServerInfo("Bob"), edesc["Bob"][3])
+            self.assertSameSD(ks.getServerInfo("Bob", startAt=now+oneDay*5),
+                              edesc["Bob"][4])
+
+            if i in (0,1,2):
+                ks = mixminion.ClientMain.ClientKeystore(dirname)
+            if i == 1:
+                ks.rescan()
+            if i == 2:
+                ks.rescan(force=1)
+
+        replaceFunction(ks, 'downloadDirectory')
+
+        # Now make sure that update is properly zealous.
+        ks.updateDirectory(now=now)
+        self.assertEquals([], getReplacedFunctionCallLog())
+        ks.updateDirectory(now=now, forceDownload=1)
+        self.assertEquals(1, len(getReplacedFunctionCallLog()))
+        ks.updateDirectory(now=now+oneDay+60)
+        self.assertEquals(2, len(getReplacedFunctionCallLog()))
+        undoReplacedAttributes()
+        clearReplacedFunctionCallLog()
+
+        ## Now make sure we can really update the directory.
+        # (this is the same as before, but with 'Lisa2'.)
+        fname = getDirectory(
+            [os.path.join(impdirname, s) for s in
+             ("Fred1", "Fred2", "Lola2", "Alice0", "Alice1",
+              "Bob3", "Bob4", "Lisa1", "Lisa2") ], identity)
+        mixminion.ClientMain.MIXMINION_DIRECTORY_URL = "file://%s"%fname
+        ks.updateDirectory(forceDownload=1)
+        # Previous entries.
+        self.assertSameSD(ks.getServerInfo("Alice"), edesc["Alice"][0])
+        self.assertSameSD(ks.getServerInfo("Bob"), edesc["Bob"][3])
+        # New entry
+        self.assertSameSD(ks.getServerInfo("Lisa",startAt=now+6*oneDay),
+                          edesc["Lisa"][2])
+        # Entry from server info
+        self.assertSameSD(edesc["Joe"][0], ks.getServerInfo("Joe"))
+
+        def nRuns(lst):
+            n = 0
+            for idx in xrange(len(lst)-1):
+                if lst[idx] == lst[idx+1]:
+                    n += 1
+            return n
+
+        def allUnique(lst):
+            d = {}
+            for item in lst:
+                d[item] = 1
+            return len(d) == len(lst)
 
         suspendLog()
+        joe = edesc["Joe"]
+        lisa = edesc["Lisa"]
+        alice = edesc["Alice"]
+        lola = edesc["Lola"]
+        fred = edesc["Fred"]
+        bob = edesc["Bob"]
         try:
-            ks = mixminion.ClientMain.TrivialKeystore(dirname)
-        finally:
-            resumeLog()
+            ### Try out getPath.
+            # 1. Fully-specified paths.
+            p = ks.getPath(startServers=("Joe", "Lisa"),
+                           endServers=("Alice", "Joe"))
+            p = ks.getPath(startServers=("Joe", "Lisa", "Alice", "Joe"))
+            p = ks.getPath(endServers=("Joe", "Lisa", "Alice", "Joe"))
 
-        # Try getServerInfo(ServerInfo)
-        si = ServerInfo(string=edesc['Lisa'][0])
-        self.assert_(si is ks.getServerInfo(si))
+            # 2. Partly-specified paths...
+            # 2a. With plenty of servers
+            p = ks.getPath(length=2)
+            eq(2, len(p))
+            neq(p[0].getNickname(), p[1].getNickname())
 
-        # 'Bob' and 'Fred' are dangerous with regspect to the 'almost-expired'
-        # check; don't use them here
-        for (s,i) in [("Lola",0), ("Joe",0),("Alice",0),("Lisa",1)]:
-            self.assert_(self.isSameServerDesc(edesc[s][i],
-                                      ks.getServerInfo(s)))
-            self.assert_(self.isSameServerDesc(edesc[s][i],
-                                      ks.getServerInfo("%s%02d"%(s,i))))
+            p = ks.getPath(startServers=("Joe",), length=3)
+            eq(3, len(p))
+            self.assertSameSD(p[0], joe[0])
+            self.assert_(allUnique([s.getNickname() for s in p]))
+            neq(p[1].getNickname(), "Joe")
+            neq(p[2].getNickname(), "Joe")
+            neq(p[1].getNickname(), p[2].getNickname())
 
-        # Check a nonexistant server.
-        self.assertEquals(None, ks.getServerInfo("Foob"))
+            p = ks.getPath(endServers=("Joe",), length=3)
+            eq(3, len(p))
+            self.assertSameSD(joe[0], p[2])
+            self.assert_(allUnique([s.getNickname() for s in p]))
 
-        # Try getPath()
-        x = mix_mktemp()
-        writeFile(x, edesc["Fred"][1])
-        p = ks.getPath(("Lola", "Joe", "Lola00", ks.getServerInfo("Lisa"), x))
-        self.assert_(self.isSameServerDesc(ks.getServerInfo("Lola"), p[0]))
-        self.assert_(self.isSameServerDesc(ks.getServerInfo("Joe"), p[1]))
-        self.assert_(self.isSameServerDesc(ks.getServerInfo("Lola"), p[2]))
-        self.assert_(self.isSameServerDesc(ks.getServerInfo("Lisa"), p[3]))
-        self.assert_(self.isSameServerDesc(edesc["Fred"][1], p[4]))
+            p = ks.getPath(startServers=("Alice",),endServers=("Joe",),
+                           length=4)
+            eq(4, len(p))
+            self.assertSameSD(alice[0], p[0])
+            self.assertSameSD(joe[0], p[3])
+            nicks = [ s.getNickname() for s in p ]
+            eq(1, nicks.count("Alice"))
+            eq(1, nicks.count("Joe"))
+            neq(nicks[1],nicks[2])
+            self.assert_(allUnique([s.getNickname() for s in p]))
 
-        # We fail on nonexistant files.
-        self.failUnlessRaises(MixError, ks.getPath, ["Lola", mix_mktemp()])
+            p = ks.getPath(startServers=("Joe",),endServers=("Alice","Joe"),
+                           length=4)
+            eq(4, len(p))
+            self.assertSameSD(alice[0], p[2])
+            self.assertSameSD(joe[0], p[0])
+            self.assertSameSD(joe[0], p[3])
+            neq(p[1].getNickname(), "Alice")
+            neq(p[1].getNickname(), "Joe")
+            # 2b. With 3 <= servers < length
+            p = ks.getPath(length=9)
+            eq(9, len(p))
+            self.failIf(nRuns([s.getNickname() for s in p]))
 
-        # We warn on unparseable files.
-        fn = os.path.join(dirname, "xyzzy")
-        writeFile(fn, "this file is not a server descriptor\n")
-        try:
-            suspendLog()
-            _ = mixminion.ClientMain.TrivialKeystore(dirname)
+            p = ks.getPath(startServers=("Joe",),endServers=("Joe",),length=8)
+            self.failIf(nRuns([s.getNickname() for s in p]))
+            eq(8, len(p))
+            self.assertSameSD(joe[0], p[0])
+            self.assertSameSD(joe[0], p[-1])
+
+            p = ks.getPath(startServers=("Joe",),length=7)
+            self.failIf(nRuns([s.getNickname() for s in p]))
+            eq(7, len(p))
+            self.assertSameSD(joe[0], p[0])
+
+            p = ks.getPath(endServers=("Joe",),length=7)
+            self.failIf(nRuns([s.getNickname() for s in p]))
+            eq(7, len(p))
+            self.assertSameSD(joe[0], p[-1])
+
+            # 2c. With 2 servers
+            p = ks.getPath(length=4, startAt=now-9*oneDay)
+            self.failIf(nRuns([s.getNickname() for s in p]) > 1)
+
+            p = ks.getPath(length=4,startServers=("Joe",),startAt=now-9*oneDay)
+            self.failIf(nRuns([s.getNickname() for s in p]) > 2)
+
+            p = ks.getPath(length=4, endServers=("Joe",), startAt=now-9*oneDay)
+            self.failIf(nRuns([s.getNickname() for s in p]) > 1)
+
+            p = ks.getPath(length=6, endServers=("Joe",), startAt=now-9*oneDay)
+            self.failIf(nRuns([s.getNickname() for s in p]) > 1)
+
+            # 2d. With only 1.
+            p = ks.getPath(length=4, startAt=now-11*oneDay)
+            eq(len(p), 2)
+            p = ks.getPath(length=4,
+                           startServers=("Joe",),startAt=now-11*oneDay)
+            eq(len(p), 3)
+            p = ks.getPath(length=4, endServers=("Joe",),startAt=now-11*oneDay)
+            eq(len(p), 2)
+
+            # 2e. With 0
+            self.assertRaises(MixError, ks.getPath,
+                              length=4, startAt=now+100*oneDay)            
         finally:
-            msg = resumeLog()
-            self.assert_(stringContains(msg,"Invalid server descriptor %s"%fn))
+            s = resumeLog()
+            self.assertEquals(4, s.count("Not enough servers for distinct"))
+            self.assertEquals(4, s.count("to avoid same-server hops"))
+            self.assertEquals(3, s.count("Only one relay known"))
+
+        # 3. With capabilities.
+        p = ks.getPath(length=5, endCap="smtp", midCap="relay")
+        eq(5, len(p))
+        self.assertSameSD(p[-1], joe[0]) # Only Joe has SMTP
+
+        p = ks.getPath(length=4, endCap="mbox", midCap="relay")
+        eq(4, len(p))
+        self.assertSameSD(p[-1], lola[1]) # Only Lola has MBOX
+
+        p = ks.getPath(length=5, endCap="mbox", midCap="relay",
+                       startServers=("Alice",))
+        eq(5, len(p))
+        self.assertSameSD(p[-1], lola[1]) # Only Lola has MBOX
+        self.assertSameSD(p[0], alice[0])
+        
+        p = ks.getPath(length=5, endCap="mbox", midCap="relay",
+                       endServers=("Alice",))
+        eq(5, len(p))
+        self.assertSameSD(p[-1], alice[0]) # We ignore endCap with endServers 
+
+        ### Now try parsePath.  This should exercise resolvePath as well.
+        ppath = mixminion.ClientMain.parsePath
+        paddr = mixminion.ClientMain.parseAddress
+        email = paddr("smtp:lloyd@dobler.com")
+        mboxWithServer = paddr("mbox:Granola@Lola")
+        mboxWithoutServer = paddr("mbox:Granola")
+        
+        alice = ks.getServerInfo("Alice")
+        fred = ks.getServerInfo("Fred")
+        bob = ks.getServerInfo("Bob")
+        joe = ks.getServerInfo("Joe")
+        lola = ks.getServerInfo("Lola")
+        lisa = ks.getServerInfo("Lisa")
+
+        def pathIs(p, exp, self=self):
+            if isinstance(p[0],mixminion.ServerInfo.ServerInfo):
+                p1, p2 = p, ()
+                exp1, exp2 = exp, ()
+            else:
+                p1, p2 = p
+                exp1, exp2 = exp
+            try:
+                self.assertEquals(len(p1),len(exp1))
+                self.assertEquals(len(p2),len(exp2))
+                for a, b in zip(p1, exp1):
+                    self.assertSameSD(a,b)
+                for a, b in zip(p2, exp2):
+                    self.assertSameSD(a,b)
+            except:
+                print [s.getNickname() for s in p1], \
+                      [s.getNickname() for s in p2]
+                raise
+
+        # 1. Successful cases
+        # 1a. No colon, no star
+        fredfile = os.path.join(impdirname, "Fred1")
+        p1,p2 = ppath(ks, None, "Alice,Fred,Bob,Joe", email)
+        pathIs((p1,p2), ((alice,fred),(bob,joe)))
+        fredfile = os.path.join(impdirname, "Fred1")
+        p1,p2 = ppath(ks, None, "Alice,%s,Bob,Joe"%fredfile, email)
+        pathIs((p1,p2), ((alice,fred),(bob,joe)))
+        p1,p2 = ppath(ks, None, "Alice,Fred,Bob,Joe", email, nHops=4, nSwap=1)
+        pathIs((p1,p2), ((alice,fred),(bob,joe)))
+        p1,p2 = ppath(ks, None, "Alice,Fred,Bob,Lola,Joe", email, nHops=5,
+                      nSwap=1)
+        pathIs((p1,p2), ((alice,fred),(bob,lola,joe)))
+        p1,p2 = ppath(ks, None, "Alice,Fred,Bob,Lola,Joe", email, nHops=5)
+        pathIs((p1,p2), ((alice,fred,bob),(lola,joe)))
+        p1,p2 = ppath(ks, None, "Alice,Fred,Bob", mboxWithServer)
+        pathIs((p1,p2), ((alice,fred),(bob,lola)))
+        p1,p2 = ppath(ks, None, "Alice,Fred,Bob,Lola", mboxWithoutServer)
+        pathIs((p1,p2), ((alice,fred),(bob,lola)))
+        p1,p2 = ppath(ks, None, "Alice,Fred,Bob", mboxWithServer, nSwap=0)
+        pathIs((p1,p2), ((alice,),(fred,bob,lola)))
+                      
+        # 1b. Colon, no star
+        p1,p2 = ppath(ks, None, "Alice:Fred,Joe", email)
+        pathIs((p1,p2), ((alice,),(fred,joe)))
+        p1,p2 = ppath(ks, None, "Alice:Bob,Fred,Joe", email)
+        pathIs((p1,p2), ((alice,),(bob,fred,joe)))
+        p1,p2 = ppath(ks, None, "Alice,Bob,Fred:Joe", email)
+        pathIs((p1,p2), ((alice,bob,fred),(joe,)))
+        p1,p2 = ppath(ks, None, "Alice,Bob,Fred:Joe", email, nHops=4)
+        pathIs((p1,p2), ((alice,bob,fred),(joe,)))
+        p1,p2 = ppath(ks, None, "Alice,Bob,Fred:Joe", email, nSwap=2)
+        pathIs((p1,p2), ((alice,bob,fred),(joe,)))
+        p1,p2 = ppath(ks, None, "Alice,Bob,Fred:Joe", mboxWithServer)
+        pathIs((p1,p2), ((alice,bob,fred),(joe,lola)))
+        p1,p2 = ppath(ks, None, "Alice,Bob,Fred:Lola", mboxWithoutServer)
+        pathIs((p1,p2), ((alice,bob,fred),(lola,)))
+        
+        # 1c. Star, no colon
+        p1,p2 = ppath(ks, None, 'Alice,*,Joe', email, nHops=5)
+        self.assert_(allUnique([s.getNickname() for s in p1+p2]))
+        pathIs((p1[0],p2[-1]), (alice, joe))
+        eq((len(p1),len(p2)), (3,2))
+
+        p1,p2 = ppath(ks, None, 'Alice,Bob,*,Joe', email, nHops=6)
+        self.assert_(allUnique([s.getNickname() for s in p1+p2]))
+        pathIs((p1[0],p1[1],p2[-1]), (alice, bob, joe))
+        eq((len(p1),len(p2)), (3,3))
+
+        p1,p2 = ppath(ks, None, 'Alice,Bob,*', email, nHops=6)
+        self.assert_(allUnique([s.getNickname() for s in p1+p2]))
+        pathIs((p1[0],p1[1],p2[-1]), (alice, bob, joe))
+        eq((len(p1),len(p2)), (3,3))
+        
+        p1,p2 = ppath(ks, None, '*,Bob,Joe', email) #default nHops=6
+        self.assert_(allUnique([s.getNickname() for s in p1+p2]))
+        pathIs((p2[-2],p2[-1]), (bob, joe))
+        eq((len(p1),len(p2)), (3,3))
+
+        p1,p2 = ppath(ks, None, 'Bob,*,Alice', mboxWithServer) #default nHops=6
+        self.assert_(allUnique([s.getNickname() for s in p1+p2]))
+        pathIs((p1[0],p2[-2],p2[-1]), (bob, alice, lola))
+        eq((len(p1),len(p2)), (3,3))
+
+        p1,p2 = ppath(ks, None, 'Bob,*,Alice,Lola', mboxWithoutServer)
+        self.assert_(allUnique([s.getNickname() for s in p1+p2]))
+        pathIs((p1[0],p2[-2],p2[-1]), (bob, alice, lola))
+        eq((len(p1),len(p2)), (3,3))
+        
+        # 1d. Star and colon
+        p1,p2 = ppath(ks, None, 'Bob:*,Alice', mboxWithServer)
+        self.assert_(allUnique([s.getNickname() for s in p1+p2]))
+        pathIs((p1[0],p2[-2],p2[-1]), (bob, alice, lola))
+        eq((len(p1),len(p2)), (1,5))
+        
+        p1,p2 = ppath(ks, None, 'Bob,*:Alice', mboxWithServer)
+        self.assert_(allUnique([s.getNickname() for s in p1+p2]))
+        pathIs((p1[0],p2[-2],p2[-1]), (bob, alice, lola))
+        eq((len(p1),len(p2)), (4,2))
+        
+        p1,p2 = ppath(ks, None, 'Bob,*,Joe:Alice', mboxWithServer)
+        self.assert_(allUnique([s.getNickname() for s in p1+p2]))
+        pathIs((p1[0],p1[-1],p2[-2],p2[-1]), (bob, joe, alice, lola))
+        eq((len(p1),len(p2)), (4,2))
+
+        p1,p2 = ppath(ks, None, 'Bob,*,Lola:Alice,Joe', email)
+        self.assert_(allUnique([s.getNickname() for s in p1+p2]))
+        pathIs((p1[0],p1[-1],p2[-2],p2[-1]), (bob, lola, alice, joe))
+        eq((len(p1),len(p2)), (4,2))
+
+        p1,p2 = ppath(ks, None, '*,Lola:Alice,Joe', email)
+        self.assert_(allUnique([s.getNickname() for s in p1+p2]))
+        pathIs((p1[-1],p2[-2],p2[-1]), (lola, alice, joe))
+        eq((len(p1),len(p2)), (4,2))
+
+        p1,p2 = ppath(ks, None, 'Lola:Alice,*', email)
+        self.assert_(allUnique([s.getNickname() for s in p1+p2]))
+        pathIs((p1[0],p2[0],p2[-1]), (lola, alice, joe))
+        eq((len(p1),len(p2)), (1,5))
+
+        p1,p2 = ppath(ks, None, 'Bob:Alice,*', mboxWithServer)
+        self.assert_(allUnique([s.getNickname() for s in p1+p2]))
+        pathIs((p1[0],p2[0],p2[-1]), (bob, alice, lola))
+        eq((len(p1),len(p2)), (1,5))
+
+        # 2. Failing cases
+        raises = self.assertRaises
+        # Nonexistant server
+        raises(MixError, ppath, ks, None, "Pierre:Alice,*", email)
+        # Two swap points
+        raises(MixError, ppath, ks, None, "Alice:Bob:Joe", email)
+        # Last hop doesn't support exit type
+        raises(MixError, ppath, ks, None, "Alice:Bob,Fred", email)
+        raises(MixError, ppath, ks, None, "Alice:Bob,Fred", mboxWithoutServer)
+        # Two stars.
+        raises(MixError, ppath, ks, None, "Alice,*,Bob,*,Joe", email)
+        # Swap point mismatch
+        raises(MixError, ppath, ks, None, "Alice:Bob,Joe", email, nSwap=1)
+        # NHops mismatch
+        raises(MixError, ppath, ks, None, "Alice:Bob,Joe", email, nHops=2)
+        raises(MixError, ppath, ks, None, "Alice:Bob,Joe", email, nHops=4)
+        # Nonexistant file
+        raises(MixError, ppath, ks, None, "./Pierre:Alice,*", email)
+        
+        ## Try 'expungeByNickname'.
+        # Zapping 'Lisa' does nothing, since she's in the directory...
+        ks.expungeByNickname("Lisa")
+        self.assertSameSD(ks.getServerInfo("Lisa",startAt=now+6*oneDay),
+                          edesc["Lisa"][2])
+        # But Joe can be removed.
+        ks.expungeByNickname("Joe")
+        eq(None, ks.getServerInfo("Joe"))
+
+        ## Now try clean()
+        ks.clean() # Should do nothing.
+        ks = mixminion.ClientMain.ClientKeystore(dirname)
+        ks.clean(now=now+oneDay*500) # Should zap all of imported servers.
+        raises(MixError, ks.getServerInfo, "Lola")
 
     def testAddress(self):
         def parseEq(s, tp, addr, server, eq=self.assertEquals):
@@ -4258,9 +4664,10 @@
 
         # Now try with some servers...
         edesc = getExampleServerDescriptors()
-        writeFile(os.path.join(serverdir,"lola1"), edesc["Lola"][1])
-        writeFile(os.path.join(serverdir,"joe1"), edesc["Joe"][0])
-        writeFile(os.path.join(serverdir,"alice1"), edesc["Alice"][0])
+        ServerInfo = mixminion.ServerInfo.ServerInfo
+        Lola = ServerInfo(string=edesc["Lola"][1], assumeValid=1)
+        Joe = ServerInfo(string=edesc["Joe"][0], assumeValid=1)
+        Alice = ServerInfo(string=edesc["Alice"][1], assumeValid=1)
 
         # ... and for now, we need to restart the client.
         client = mixminion.ClientMain.MixminionClient(usercfg)
@@ -4278,11 +4685,11 @@
             client.generateForwardMessage(
                 parseAddress("joe@cledonism.net"),
                 payload,
-                path1=["Lola", "Joe"], path2=["Alice", "Joe"])
+                servers1=[Lola, Joe], servers2=[Alice, Joe])
             client.generateForwardMessage(
                 parseAddress("smtp:joe@cledonism.net"),
                 "Hey Joe, where you goin' with that gun in your hand?",
-                path1=["Lola", "Joe"], path2=["Alice", "Joe"])
+                servers1=[Lola, Joe], servers2=[Alice, Joe])
 
             for fn, args, kwargs in getCalls():
                 self.assertEquals(fn, "buildForwardMessage")
@@ -4298,12 +4705,12 @@
             client.generateForwardMessage(
                 parseAddress("mbox:granola"),
                 payload,
-                path1=["Lola", "Joe"], path2=["Alice", "Lola"])
+                servers1=[Lola, Joe], servers2=[Alice, Lola])
             # And an mbox message with a last hop implicit in the address
             client.generateForwardMessage(
                 parseAddress("mbox:granola@Lola"),
                 payload,
-                path1=["Lola", "Joe"], path2=["Alice"])
+                servers1=[Lola, Joe], servers2=[Alice, Lola])
 
             for fn, args, kwargs in getCalls():
                 self.assertEquals(fn, "buildForwardMessage")
@@ -4311,7 +4718,7 @@
                                   (payload, MBOX_TYPE, "granola"))
                 self.assert_(len(args[3]) == len(args[4]) == 2)
                 self.assertEquals(["Lola", "Joe", "Alice", "Lola"],
-                     [x['Server']['Nickname'] for x in args[3]+args[4]])
+                     [x.getNickname() for x in args[3]+args[4]])
             clearCalls()
         finally:
             undoReplacedAttributes()
@@ -4322,23 +4729,7 @@
         self.assertRaises(MixError,
                           client.generateForwardMessage,
                           parseAddress("0xFFFF:zed"),
-                          "Z", [], ["Alice"])
-        # Nonexistant servers...
-        self.assertRaises(MixError,
-                          client.generateForwardMessage,
-                          parseAddress("0xFFFF:zed"),
-                          "Z", ["Marvin"], ["Fred"])
-        # Lola doesn't support SMTP...
-        self.assertRaises(MixError,
-                          client.generateForwardMessage,
-                          parseAddress("smtp:joe@cledonism.net"),
-                          "Z", ["Joe"], ["Lola"])
-        # Joe doesn't support MBOX...
-        self.assertRaises(MixError,
-                          client.generateForwardMessage,
-                          parseAddress("mbox:wahoo"),
-                          "Z", ["Lola"], ["Joe"])
-
+                          "Z", [], [Alice])
 
         # Temporarily replace BlockingClientConnection so we can try the client
         # without hitting the network.
@@ -4365,7 +4756,7 @@
             client.sendForwardMessage(
                 parseAddress("mbox:granola@Lola"),
                 "You only give me your information.",
-                ["Alice", "Lola", "Joe", "Alice"], ["Joe", "Alice"])
+                [Alice, Lola, Joe, Alice], [Joe, Alice])
             bcc = BCC_INSTANCE
             # first hop is alice
             self.assertEquals(bcc.addr, "10.0.0.9")
@@ -4378,6 +4769,9 @@
             undoReplacedAttributes()
             clearCalls()
 
+    def assertSameSD(self, s1, s2):
+        self.assert_(self.isSameServerDesc(s1,s2))
+
     def isSameServerDesc(self, s1, s2):
         """s1 and s2 are either ServerInfo objects or strings containing server
            descriptors. Returns 1 iff their digest fields match"""
@@ -4387,8 +4781,10 @@
                 m = re.search(r"^Digest: (\S+)\n", s, re.M)
                 assert m
                 ds.append(base64.decodestring(m.group(1)))
+            elif isinstance(s, mixminion.ServerInfo.ServerInfo):
+                ds.append(s.getDigest())
             else:
-                ds.append(s['Server']['Digest'])
+                return 0
         return ds[0] == ds[1]
 
 #----------------------------------------------------------------------