chiark / gitweb /
Multiple key types, key profiles, and user key storage.
[distorted-keys] / extract-profile.in
diff --git a/extract-profile.in b/extract-profile.in
new file mode 100644 (file)
index 0000000..1d19fc2
--- /dev/null
@@ -0,0 +1,465 @@
+#! @PYTHON@
+###
+### Parse a profile definition file and emit a particular section
+###
+### (c) 2011 Mark Wooding
+###
+
+###----- Licensing notice ---------------------------------------------------
+###
+### This file is part of the distorted.org.uk key management suite.
+###
+### distorted-keys is free software; you can redistribute it and/or modify
+### it under the terms of the GNU General Public License as published by
+### the Free Software Foundation; either version 2 of the License, or
+### (at your option) any later version.
+###
+### distorted-keys is distributed in the hope that it will be useful,
+### but WITHOUT ANY WARRANTY; without even the implied warranty of
+### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+### GNU General Public License for more details.
+###
+### You should have received a copy of the GNU General Public License
+### along with distorted-keys; if not, write to the Free Software Foundation,
+### Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+from __future__ import with_statement
+
+import sys as SYS
+import os as OS
+import UserDict as UD
+import optparse as O
+from cStringIO import StringIO
+
+PACKAGE = "@PACKAGE@"
+VERSION = "@VERSION@"
+
+###--------------------------------------------------------------------------
+### Utilities.
+
+class struct (object):
+  def __init__(me, **kw):
+    me.__dict__.update(kw)
+
+class UserError (Exception): pass
+
+###--------------------------------------------------------------------------
+### Configuration section management.
+
+class prop (struct): pass
+
+class Section (object, UD.DictMixin):
+  """
+  A section of a profile configuration file.
+  """
+
+  ## States for depth-first traversal.
+  V_WHITE = 0                           # Not yet visited.
+  V_GREY = 1                            # Currently being visited.
+  V_BLACK = 2                           # Visited previously and processed.
+
+  def __init__(me, name, dict = None):
+    """
+    Initialize a Section object.
+
+    The DICT provides the initial (direct) contents of the Section.  The NAME
+    is used for presentation purposes, e.g., when reporting error conditions.
+    """
+    super(Section, me).__init__()
+    if dict is None: me._dict = {}
+    else: me._dict = dict
+    me._visited = me.V_WHITE
+    me.name = name
+    me.includes = set()
+    me.inferiors = set()
+    me.inherited = {}
+
+  ## Dictionary methods for UD.DictMixin.  The built-in `dict' class provides
+  ## equality, and is therefore not hashable.  By doing things this way, we
+  ## can retain reference equality and stay hashable, which will be useful
+  ## later.
+  def __getitem__(me, key):
+    return me._dict[key]
+  def __setitem__(me, key, value):
+    me._dict[key] = value
+  def __delitem__(me, key):
+    del me._dict[key]
+  def keys(me):
+    return me._dict.keys()
+  def __contains__(me, key):
+    return key in me._dict
+  def __iter__(me):
+    return me._dict.__iter__()
+  def iteritems(me):
+    return me._dict.iteritems()
+  def __repr__(me):
+    return 'Section(%r, %r)' % (me.name, me.inherited)
+
+  def transit(me, seen = None, path = None):
+    """
+    Visit the Section for the purposes of computing transitive inclusion.
+
+    If this completes successfully, the Section's inferiors slot is set up to
+    contain all of its (non-strict) inferiors.  A section's inferiors consist
+    of itself, together with the union of the inferiors of all of its
+    included Sections.
+
+    If the Section's visited state is black, nothing happens; if it's white
+    then it will be coloured grey temporarily, and its included Sections
+    processed recursively; if it's grey to begin with then we have
+    encountered a cycle.
+
+    The SEEN dictionary and PATH list are used for detecting and reporting
+    cycles.  The PATH contains a list of the currently grey Sections, in the
+    order in which they were encountered; SEEN maps Section names to their
+    indices in the PATH list.
+
+    It is possible to make this work in the presence of cycles, but it's more
+    effort than it's worth.
+    """
+
+    ## Extend the path to include us.  This will be useful when reporting
+    ## cycles.
+    if seen is None: seen = {}
+    if path is None: path = []
+    path.append(me)
+
+    ## Already been here: nothing to do.
+    if me._visited == me.V_BLACK:
+      pass
+
+    ## We've found a cycle: report it to the user.
+    elif me._visited == me.V_GREY:
+      raise UserError, 'detected inclusion cycle:\n\t%s' % \
+            ' -> '.join(["`%s'" % s.name for s in path[seen[me]:]])
+
+    ## Not done this one yet: process my included Sections, and compute the
+    ## union of their inferiors.
+    else:
+      seen[me] = len(path) - 1
+      me._visited = me.V_GREY
+      me.inferiors = set([me])
+      for s in me.includes:
+        s.transit(seen, path)
+        me.inferiors.update(s.inferiors)
+      me._visited = me.V_BLACK
+
+    ## Remove myself from the path.
+    path.pop()
+
+  def inherit(me):
+    """
+    Compute the inherited properties for this Section.
+
+    A Section has an inherited property named P if any inferior has a direct
+    property named P.  The value of the property is determined as follows.
+    Firstly, determine the set A of all inferiors which have a direct
+    property P.  Secondly, determine a /reduced/ set containing only the
+    maximal elements of A: if R contains a pair of distinct inferiors I and J
+    such that I is an inferior of J, then R does not contain I; R contains
+    all elements A not so excluded.  If all inferiors in R define the same
+    value for the property, then that is the value of the inherited property;
+    if two inferiors disagree, then the situation is erroneous.
+
+    Note that if a Section defines a direct property then it has an inherited
+    property with the same value: in this case, the reduced set is a
+    singleton.
+    """
+
+    ## First pass: for each property name, determine the reduced set of
+    ## inferiors defining that property, and the values they have for it.
+    ## Here, D maps property names to lists of `prop' records.
+    d = {}
+    for s in me.inferiors:
+
+      ## Work through the direct properties of inferior S.
+      for k, v in s.iteritems():
+
+        ## Ignore `special' properties.
+        if k.startswith('@'):
+          continue
+
+        ## Work through the current reduced set.  Discard entries from
+        ## sections inferior to S.  If an entry exists for a section T to
+        ## which S is inferior, then don't add S itself.
+        addp = True
+        pp = []
+        try:
+          for q in d[k]:
+            if s in q.source.inferiors:
+              addp = False
+            if q.source not in s.inferiors:
+              pp.append(q)
+        except KeyError:
+          pass
+        if addp:
+          pp.append(prop(value = v, source = s))
+        d[k] = pp
+
+    ## Second pass: check that the reduced set defines a unique value for
+    ## each inherited property.
+    for k, vv in d.iteritems():
+      c = {}
+
+      ## Build in C a dictionary mapping candidate values to lists of
+      ## inferiors asserting those values.
+      for p in vv:
+        c.setdefault(p.value, []).append(p.source)
+
+      ## Now C should have only one key.  If not, C records enough
+      ## information that we can give a useful error report.
+      if len(c) != 1:
+        raise UserError, \
+              "inconsistent values for property `%s' in section `%s': %s" % \
+              (k, me.name,
+               ''.join(["\n\t`%s' via %s" %
+                        (v, ', '.join(["`%s'" % s.name for s in ss]))
+                        for v, ss in c.iteritems()]))
+
+      ## Insert the computed property value.
+      me.inherited[k] = c.keys()[0]
+
+  def expand(me, string, seen = None, path = None):
+    """
+    Expand placeholders in STRING and return the result.
+
+    A placeholder has the form $PROP or ${PROP} (the latter syntax identifies
+    the property name unambiguously), and is replaced by the value of the
+    (inherited) property named PROP.  A token $$ is replaced with a single $.
+
+    The SEEN and PATH parameters work the same way as in the `transit'
+    method.
+    """
+
+    if seen is None: seen = {}
+    if path is None: path = []
+
+    ## Prepare stuff for the loop.
+    out = StringIO()
+    left = 0
+    n = len(string)
+
+    ## Pick out placeholders and expand them.
+    while True:
+
+      ## Find a placeholder.
+      dol = string.find('$', left)
+
+      ## None: commit the rest of the string and we're done.
+      if dol < 0:
+        out.write(string[left:])
+        break
+
+      ## Commit the portion before the placeholder.
+      out.write(string[left:dol])
+
+      ## Check for a trailing `$'.  After this, we can be sure of at least
+      ## one more character.
+      if dol + 1 >= n:
+        prop = ''
+
+      ## If there's a left brace, find a right brace: the property name is
+      ## between them.
+      elif string[dol + 1] == '{':
+        ace = string.find('}', dol + 2)
+        if ace < 0:
+          raise UserError, \
+                "invalid placeholder (missing `}') in `%s'" % string
+        prop = string[dol + 2:ace]
+        left = ace + 1
+
+      ## If there's a dollar, just commit it and go round again.
+      elif string[dol + 1] == '$':
+        left = dol + 2
+        out.write('$')
+        continue
+
+      ## Otherwise take as many constituent characters as we can.
+      else:
+        left = dol + 1
+        while left < n and (string[left].isalnum() or string[left] in '-_'):
+          left += 1
+        prop = string[dol + 1:left].replace('-', '_')
+
+      ## If we came up empty, report an error.
+      if prop == '':
+        raise UserError, \
+              "invalid placeholder (empty name) in `%s'" % string
+
+      ## Extend the path: we're going to do a recursive expansion.
+      path.append(prop)
+
+      ## Report a cycle if we found one.
+      if prop in seen:
+        raise UserError, 'substitution cycle:\n\t%s' % \
+              (' -> '.join(["`%s'" % p for p in path[seen[prop]:]]))
+
+      ## Look up the raw value.
+      try:
+        value = me.inherited[prop]
+      except KeyError:
+        raise UserError, "unknown property `%s'" % prop
+
+      ## Recursively expand, and unwind the PATH and SEEN stuff.
+      seen[prop] = len(path) - 1
+      out.write(me.expand(value, seen, path))
+      path.pop()
+      del seen[prop]
+
+    ## Done: return the accumulated result.
+    return out.getvalue()
+
+def link(d):
+  """
+  Link together the Sections in D according to their inclusions.
+
+  If a Section S has an `@include' special property, then set S's `includes'
+  slot to be the set of sections named in that property's value.  Then
+  compute the inferiors and inherited properties for all of the Sections.
+  """
+
+  ## Capture the global section.
+  g = d['@GLOBAL']
+
+  ## Walk through all of the sections.
+  for sect in d.itervalues():
+
+    ## If this isn't the global section, then add the global section as an
+    ## implicit inclusion.
+    if sect is not g:
+      sect.includes.add(g)
+
+    ## If there are explicit inclusions, then add them to the included set.
+    try:
+      inc = sect['@include']
+    except KeyError:
+      pass
+    else:
+      for s in inc.split():
+        try:
+          sect.includes.add(d[s])
+        except KeyError:
+          raise UserError, \
+                "unknown section `%s' included in `%s'" % (s, sect.name)
+
+  ## Compute the inferiors and inherited properties.
+  for sect in d.itervalues():
+    sect.transit()
+  for sect in d.itervalues():
+    sect.inherit()
+
+###--------------------------------------------------------------------------
+### Parsing input files.
+
+## Names of special properties.  All of these begin with an `@' sign.
+SPECIALS = set(['@include'])
+
+def parse(filename, d):
+  """
+  Parse a profile file FILENAME, updating dictionary D.
+
+  Each entry in the dictionary maps a section name to the section's contents;
+  the contents are in turn represented as a dictionary mapping properties to
+  values.  Inter-section references, defaults, and so on are not processed
+  here.
+  """
+
+  sect = '@GLOBAL'
+
+  with open(filename) as f:
+    n = 0
+    for line in f:
+      n += 1
+      line = line.strip()
+      if not line or line[0] in ';#':
+        continue
+      if line[0] == '[' and line[-1] == ']':
+        sect = line[1:-1]
+        continue
+
+      ## Parse an assignment.
+      eq = line.find('=')
+      colon = line.find(':')
+      if eq < 0 or 0 <= colon < eq: eq = colon
+      if eq < 0: raise UserError, '%s:%d: no assignment' % (filename, n)
+      name, value = line[:eq].strip(), line[eq + 1:].strip()
+
+      ## Check that the name is well-formed.
+      name = name.replace('-', '_')
+      if not (name and
+              (name in SPECIALS or
+               all(map(lambda ch: ch == '_' or ch.isalnum(), name)))):
+        raise UserError, "%s:%d: bad name `%s'" % (filename, n, name)
+
+      ## Store the assignment.
+      try:
+        d[sect][name] = value
+      except KeyError:
+        s = Section(sect)
+        d[sect] = s
+        s[name] = value
+
+###--------------------------------------------------------------------------
+### Main program.
+
+OP = O.OptionParser(
+  usage = '%prog FILE|DIRECTORY ... SECTION',
+  version = '%%prog, version %s' % VERSION,
+  description = '''\
+Parse the configurations FILE and DIRECTORY contents, and output the named
+SECTION as a sequence of simple assignments.
+''')
+
+def main(args):
+  try:
+
+    ## Check the arguments.
+    opts, args = OP.parse_args(args[1:])
+    if len(args) < 2:
+      OP.error('not enough positional parameters')
+    files = args[:-1]
+    sect = args[-1]
+
+    ## Read in the inputs.
+    d = { '@GLOBAL': Section('@GLOBAL') }
+    for f in files:
+
+      ## It's a directory: pick out the files contained.
+      if OS.path.isdir(f):
+        for sf in sorted(OS.listdir(f)):
+          if not all(map(lambda ch: ch in '_-' or ch.isalnum(), sf)):
+            continue
+          ff = OS.path.join(f, sf)
+          if not OS.path.isfile(ff):
+            continue
+          parse(ff, d)
+
+      ## Not a directory: just try to parse it.
+      else:
+        parse(f, d)
+
+    ## Print the contents.
+    link(d)
+    try:
+      s = d[sect]
+    except KeyError:
+      raise UserError, "unknown section `%s'" % sect
+    for k, v in s.inherited.iteritems():
+      print '%s=%s' % (k, s.expand(v))
+
+  ## Report errors for expected problems.
+  except UserError, e:
+    SYS.stderr.write('%s: %s\n' % (OP.get_prog_name(), e.args[0]))
+    SYS.exit(1)
+  except OSError, e:
+    SYS.stderr.write('%s: %s\n' % (OP.get_prog_name(), e.args[1]))
+    SYS.exit(1)
+  except IOError, e:
+    SYS.stderr.write('%s: %s: %s\n' %
+                     (OP.get_prog_name(), e.filename, e.strerror))
+    SYS.exit(1)
+
+if __name__ == '__main__':
+  main(SYS.argv)
+
+###----- That's all, folks --------------------------------------------------