infocalypse

(djk)
2009-04-30: As is checkin of fn-fmsread, fn-fmsnotify. Needs cleanup.

As is checkin of fn-fmsread, fn-fmsnotify. Needs cleanup.

diff --git a/infocalypse/__init__.py b/infocalypse/__init__.py
--- a/infocalypse/__init__.py
+++ b/infocalypse/__init__.py
@@ -155,7 +155,7 @@ from mercurial import commands, util
 
 from infcmds import get_config_info, execute_create, execute_pull, \
      execute_push, execute_setup, execute_copy, execute_reinsert, \
-     execute_info
+     execute_info, execute_fmsread, execute_fmsnotify
 
 def set_target_version(ui_, repo, opts, params, msg_fmt):
     """ INTERNAL: Update TARGET_VERSION in params. """
@@ -301,6 +301,47 @@ def infocalypse_info(ui_, repo, **opts):
     params['REQUEST_URI'] = request_uri
     execute_info(ui_, repo, params, stored_cfg)
 
+def infocalypse_fmsread(ui_, repo, **opts):
+    """ Read repository update information from fms.
+    """
+    # FCP not required. Hmmm... Hack
+    opts['fcphost'] = ''
+    opts['fcpport'] = 0
+    params, stored_cfg = get_config_info(ui_, opts)
+    request_uri = opts['uri']
+    if request_uri == '':
+        request_uri = stored_cfg.get_request_uri(repo.root)
+        if not request_uri:
+            ui_.status("There is no stored request URI for this repo.\n")
+            request_uri = None
+
+    if opts['listall']:
+        params['FMSREAD'] = 'listall'
+    elif opts['list']:
+        params['FMSREAD'] = 'list'
+    else:
+        params['FMSREAD'] = 'update'
+    params['DRYRUN'] = opts['dryrun']
+    params['REQUEST_URI'] = request_uri
+    execute_fmsread(ui_, repo, params, stored_cfg)
+
+def infocalypse_fmsnotify(ui_, repo, **opts):
+    """ Post an update with the current repository USK
+        index to fms.
+    """
+    params, stored_cfg = get_config_info(ui_, opts)
+    insert_uri = stored_cfg.get_dir_insert_uri(repo.root)
+    if not insert_uri:
+        ui_.warn("You can't notify because there's no stored "
+                 + "insert URI for this repo.\n"
+                 + "Run from the directory you inserted from.\n")
+        return
+
+    params['ANNOUNCE'] = opts['announce']
+    params['DRYRUN'] = opts['dryrun']
+    params['INSERT_URI'] = insert_uri
+    execute_fmsnotify(ui_, repo, params, stored_cfg)
+
 def infocalypse_setup(ui_, **opts):
     """ Setup the extension for use for the first time. """
 
@@ -359,7 +400,21 @@ cmdtable = {
     "fn-info": (infocalypse_info,
                  [('', 'uri', '', 'request URI'),],
                 "[options]"),
- 
+
+
+    "fn-fmsread": (infocalypse_fmsread,
+                   [('', 'uri', '', 'request URI'),
+                    ('', 'list', None, 'show repo USKs from trusted fms identities'),
+                    ('', 'listall', None, 'show all repo USKs'),
+                    ('', 'dryrun', None, "don't update the index cache"),],
+                   "[options]"),
+
+    "fn-fmsnotify": (infocalypse_fmsnotify,
+                     [('', 'dryrun', None, "don't send fms message"),
+                     ('', 'announce', None, "include full URI update"), ]
+                     + FCP_OPTS, # Needs to invert the insert uri
+                     "[options]"),
+
     "fn-setup": (infocalypse_setup,
                  [('', 'tmpdir', '~/infocalypse_tmp', 'temp directory'),]
                  + FCP_OPTS,
diff --git a/infocalypse/config.py b/infocalypse/config.py
--- a/infocalypse/config.py
+++ b/infocalypse/config.py
@@ -26,6 +26,7 @@ import sys
 
 from fcpclient import get_usk_hash, is_usk_file, get_version, \
      get_usk_for_usk_version
+from knownrepos import DEFAULT_TRUST, DEFAULT_GROUPS
 from mercurial import util
 from ConfigParser import ConfigParser
 
@@ -62,6 +63,10 @@ class Config:
         self.request_usks = {}
         # repo_id -> insert uri map
         self.insert_usks = {}
+        # fms_id -> (usk_hash, ...) map
+        self.fmsread_trust_map = {}
+        self.fmsread_groups = ()
+
         self.file_name = None
 
         # Use a dict instead of members to avoid pylint R0902.
@@ -71,6 +76,11 @@ class Config:
         self.defaults['TMP_DIR'] = None
         self.defaults['DEFAULT_PRIVATE_KEY'] = None
 
+        self.defaults['FMS_HOST'] = '127.0.0.1'
+        self.defaults['FMS_PORT'] = 1119
+        self.defaults['FMS_ID'] = None # REDFLAG?
+        self.defaults['FMSNOTIFY_GROUP'] = None # REDFLAG?
+
     def get_index(self, usk_or_id):
         """ Returns the highest known USK version for a USK or None. """
         return self.version_table.get(normalize(usk_or_id))
@@ -150,6 +160,23 @@ class Config:
         if parser.has_section('insert_usks'):
             for repo_id in parser.options('insert_usks'):
                 cfg.insert_usks[repo_id] = parser.get('insert_usks', repo_id)
+
+        # ignored = fms_id|usk_hash|usk_hash|...
+        if parser.has_section('fmsread_trust_map'):
+            for ordinal in parser.options('fmsread_trust_map'):
+                fields = parser.get('fmsread_trust_map',
+                                    ordinal).strip().split('|')
+                # REDFLAG: better validation for fms_id, hashes?
+                if fields[0].find('@') == -1:
+                    raise ValueError("%s doesn't look like an fms id." %
+                                     fields[0])
+                if len(fields) < 2:
+                    raise ValueError("No USK hashes for fms id: %s?" %
+                                     fields[0])
+                cfg.fmsread_trust_map[fields[0]] = tuple(fields[1:])
+        else:
+            cfg.fmsread_trust_map = DEFAULT_TRUST
+
         if parser.has_section('default'):
             if parser.has_option('default','host'):
                 cfg.defaults['HOST'] = parser.get('default','host')
@@ -161,6 +188,21 @@ class Config:
                 cfg.defaults['DEFAULT_PRIVATE_KEY'] = (
                     parser.get('default','default_private_key'))
 
+            if parser.has_option('default','fms_host'):
+                cfg.defaults['FMS_HOST'] = parser.get('default','fms_host')
+            if parser.has_option('default','fms_port'):
+                cfg.defaults['FMS_PORT'] = parser.getint('default','fms_port')
+            if parser.has_option('default','fms_id'):
+                cfg.defaults['FMS_ID'] = parser.get('default','fms_id')
+            if parser.has_option('default','fmsnotify_group'):
+                cfg.defaults['FMSNOTIFY_GROUP'] = parser.get('default',
+                                                             'fmsnotify_group')
+            if parser.has_option('default','fmsread_groups'):
+                cfg.fmsread_groups = (parser.get('default','fmsread_groups').
+                                      strip().split('|'))
+            else:
+                cfg.fmsread_groups = DEFAULT_GROUPS
+
         cfg.file_name = file_name
         return cfg
 
@@ -199,6 +241,14 @@ class Config:
         parser.set('default', 'tmp_dir', cfg.defaults['TMP_DIR'])
         parser.set('default', 'default_private_key',
                    cfg.defaults['DEFAULT_PRIVATE_KEY'])
+
+        parser.set('default', 'fms_host', cfg.defaults['FMS_HOST'])
+        parser.set('default', 'fms_port', cfg.defaults['FMS_PORT'])
+        parser.set('default', 'fms_id', cfg.defaults['FMS_ID'])
+        parser.set('default', 'fmsnotify_group',
+                   cfg.defaults['FMSNOTIFY_GROUP'])
+        parser.set('default', 'fmsread_groups', '|'.join(cfg.fmsread_groups))
+
         parser.add_section('index_values')
         for repo_id in cfg.version_table:
             parser.set('index_values', repo_id, cfg.version_table[repo_id])
@@ -208,6 +258,12 @@ class Config:
         parser.add_section('insert_usks')
         for repo_id in cfg.insert_usks:
             parser.set('insert_usks', repo_id, cfg.insert_usks[repo_id])
+        parser.add_section('fmsread_trust_map')
+        for index, fms_id in enumerate(cfg.fmsread_trust_map):
+            entry = cfg.fmsread_trust_map[fms_id]
+            assert len(entry) > 0
+            parser.set('fmsread_trust_map', str(index),
+                       fms_id + '|' + '|'.join(entry))
 
         out_file = open(file_name, 'wb')
         try:
diff --git a/infocalypse/fms.py b/infocalypse/fms.py
new file mode 100644
--- /dev/null
+++ b/infocalypse/fms.py
@@ -0,0 +1,381 @@
+""" Code to support sending and receiving update notifications via fms.
+
+    Copyright (C) 2009 Darrell Karbott
+
+    This library 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.0 of the License, or (at your option) any later version.
+
+    This library 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 this library; if not, write to the Free Software
+    Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
+
+    Author: djk@isFiaD04zgAgnrEC5XJt1i4IE7AkNPqhBG5bONi6Yks
+"""
+
+import nntplib
+import StringIO
+
+from fcpclient import get_usk_hash, get_version, is_usk_file, \
+     get_usk_for_usk_version
+
+
+MSG_TEMPLATE = """From: %s
+Newsgroups: %s
+Subject: %s
+
+%s"""
+
+# msg_tuple = (sender, group, subject, text)
+def send_msgs(fms_host, fms_port, msg_tuples):
+    """ Send messages via fms.
+    msg_tuple format is: (sender, group, subject, text)
+    """
+
+    server = nntplib.NNTP(fms_host, fms_port)
+
+    try:
+        for msg_tuple in msg_tuples:
+            raw_msg = MSG_TEMPLATE % (msg_tuple[0],
+                                      msg_tuple[1],
+                                      msg_tuple[2],
+                                      msg_tuple[3])
+            in_file = StringIO.StringIO(raw_msg)
+            print raw_msg
+            try:
+                #server.post(in_file)
+                pass
+            finally:
+                in_file.close()
+    finally:
+        server.quit()
+
+
+class IFmsMessageSink:
+    def __init__(self):
+        pass
+
+    def wants_msg(self, group, item):
+        return True
+
+    def recv_fms_msg(self, group, item, lines):
+        pass
+
+def recv_msgs(fms_host, fms_port, msg_sink, groups):
+    """ Read messages from fms. """
+    server = nntplib.NNTP(fms_host, fms_port)
+    try:
+        for group in groups:
+            result = server.group(group)
+            if result[1] == '0':
+                continue
+            # Doesn't return msg lines as shown in python doc?
+            # http://docs.python.org/library/nntplib.html
+            # Is this an fms bug?
+            result, items = server.xover(result[2], result[3])
+            if result.split(' ')[0] != '224':
+                # REDFLAG: untested code path
+                raise Exception(result)
+            for item in items:
+                if not msg_sink.wants_msg(group, item):
+                    continue
+                result = server.article(item[0])
+                if result[0].split(' ')[0] != '220':
+                    # REDFLAG: untested code path
+                    raise Exception(result[0])
+                pos = result[3].index('')
+                lines = []
+                if pos != -1:
+                    lines = result[3][pos + 1:]
+                msg_sink.recv_fms_msg(group, item, lines)
+    finally:
+        server.quit()
+
+############################################################
+# Infocalypse specific stuff.
+############################################################
+def clean_nym(fms_id):
+    pos = fms_id.index('@')
+    if pos == -1:
+        return fms_id
+
+    return fms_id[pos + 1:]
+
+def to_msg_string(updates, announcements=None):
+    """ Dump updates and announcements in a format which can
+        be read by parse. """
+    if updates is None:
+        updates = []
+
+    if announcements is None:
+        announcements = []
+
+    # Make sure we always get the same string rep.
+    updates = list(updates)
+    updates.sort()
+    announcements = list(announcements)
+    announcements.sort()
+
+    text = ''
+    for value in announcements:
+        assert is_usk_file(value)
+        text += "A:%s\n" % value
+
+    for update in updates:
+        assert is_hex_string(update[0], 12)
+        assert update[1] >= 0
+        text += "U:%s:%i\n" % (update[0], update[1])
+
+    return text
+
+# A grepper, not a parser...
+def parse(text, is_lines=False):
+    """ Parse updates and announcements from raw text. """
+    if is_lines:
+        lines = text
+    else:
+        lines = text.split('\n')
+
+    announcements = set([])
+    updates = set([])
+
+    for line in lines:
+        line = line.strip() # Handle crlf bs on Windoze.
+        fields = line.split(':')
+        if fields[0] == 'U' and len(fields) >= 3:
+            try:
+                if is_hex_string(fields[1]):
+                    updates.add((fields[1], int(fields[2])))
+            except ValueError:
+                continue
+        elif fields[0] == 'A' and len(fields) >= 2:
+            try:
+                if is_usk_file(fields[1]):
+                    announcements.add(fields[1])
+                    # Implicit update.
+                    updates.add((get_usk_hash(fields[1]), get_version(fields[1])))
+            except ValueError:
+                continue
+        # else, silently fail... hmmmm
+
+    # Perhaps a bit too metrosexual...
+    # Make sure you always get the same tuple for a given text.
+    updates = list(updates)
+    updates.sort()
+    announcements = list(announcements)
+    announcements.sort()
+    return (tuple(updates), tuple(announcements))
+
+
+def strip_names(trust_map):
+    clean = {}
+    for nym in trust_map:
+        cleaned = clean_nym(nym)
+        if nym in clean:
+            print "strip_name -- nym appears multiple times w/ different " \
+                  + "name part: " + nym
+        clean[cleaned] = list(set(list(trust_map[nym])
+                                  + clean.get(cleaned, [])))
+    return clean
+
+# REDFLAG: Trust map ids are w/o names
+# 'isFiaD04zgAgnrEC5XJt1i4IE7AkNPqhBG5bONi6Yks', not
+# 'djk@isFiaD04zgAgnrEC5XJt1i4IE7AkNPqhBG5bONi6Yks'
+class USKIndexUpdateParser(IFmsMessageSink):
+    """ Class which accumulates USK index update notifications
+        from fms messages. """
+    def __init__(self, trust_map):
+        self.trust_map = strip_names(trust_map)
+        self.updates = {}
+
+    def wants_msg(self, group, items):
+        if len(items[5]) != 0:
+            # Skip replies
+            return False
+
+        if clean_nym(items[2]) not in self.trust_map:
+            #print "Not trusted: ", items[2]
+            # Sender not authoritative on any USK.
+            return False
+
+        return True
+
+    def recv_fms_msg(self, group, items, lines):
+        """ recv_messages message callback implementation. """
+        allowed_hashes = self.trust_map[clean_nym(items[2])]
+
+        #print "---\nSender: %s\nSubject: %s\n" % (items[2], items[1])
+        for update in parse(lines, True)[0]:
+            if update[0] in allowed_hashes:
+                # Only update if the nym is trusted *for the specific USK*.
+                #print "UPDATING ---\nSender: %s\nSubject: %s\n" % (items[2], items[1])
+                self.handle_update(update)
+
+    def handle_update(self, update):
+        """ INTERNAL: Handle a single update. """
+        index = update[1]
+        value = self.updates.get(update[0], index)
+        if index >= value:
+            self.updates[update[0]] = index
+
+    def updated(self, previous=None):
+        """ Returns a USK hash -> index map for USKs which
+            have been updated. """
+        if previous is None:
+            previous = {}
+        ret = {}
+        for usk_hash in self.updates:
+            if not usk_hash in previous:
+                ret[usk_hash] = self.updates[usk_hash]
+                continue
+            if self.updates[usk_hash] > previous[usk_hash]:
+                ret[usk_hash] = self.updates[usk_hash]
+
+        return ret
+
+class USKAnnouncementParser(IFmsMessageSink):
+    """ Class which accumulates USK announcement notifications
+        from fms messages. """
+    # None means accept all announcements.
+    def __init__(self, trust_map = None):
+        if not trust_map is None:
+            trust_map = strip_names(trust_map)
+        self.trust_map = trust_map
+        self.usks = {}
+
+    def wants_msg(self, group, items):
+        if len(items[5]) != 0:
+            # Skip replies
+            return False
+
+        if self.trust_map is None:
+            return True
+
+        if clean_nym(items[2]) not in self.trust_map:
+            #print "Not trusted: ", items[2]
+            # Sender not authoritative on any USK.
+            return False
+
+        return True
+
+    def recv_fms_msg(self, group, items, lines):
+        #print "---\nSender: %s\nSubject: %s\n" % (items[2], items[1])
+        for usk in parse(lines, True)[1]:
+            self.handle_announcement(items[2], usk)
+
+    def handle_announcement(self, sender, usk):
+        """ INTERNAL: Handle a single announcement """
+        usk = get_usk_for_usk_version(usk, 0)
+        entry = self.usks.get(usk, [])
+        if not sender in entry:
+            entry.append(sender)
+        self.usks[usk] = entry
+
+HEX_CHARS = frozenset(['0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
+                       'a', 'b', 'c', 'd', 'e', 'f'])
+# REQUIRES: Lowercase!
+def is_hex_string(value, length=12):
+    if not length is None:
+        if len(value) != length:
+            raise ValueError("Expected hex string of length: %i" % length)
+    for char in value:
+        if not char in HEX_CHARS:
+            return False
+    return True
+
+############################################################
+
+DEFAULT_SUBJECT = 'Ignore'
+def make_update_msg(fms_id, group, updates, announcements=None,
+                    subject=DEFAULT_SUBJECT):
+    print "updates: ",  updates
+    print "announcements: ", announcements
+
+    # fms doesn't want to see the full id?
+    fms_id = fms_id.split('@')[0]
+    text = to_msg_string(updates, announcements)
+    return (fms_id, group, subject, text)
+
+############################################################
+
+MSG_FMT = """---
+Sender : %s
+Subject: %s
+Date   : %s
+Group  : %s
+%s
+---
+"""
+class MsgSink(IFmsMessageSink):
+    def __init__(self):
+        IFmsMessageSink.__init__(self)
+
+    def recv_fms_msg(self, group, item, lines):
+        print MSG_FMT % (item[2], item[1], item[3], group, '\n'.join(lines))
+
+def smoke_test():
+    #    trust_map = {'djk@isFiaD04zgAgnrEC5XJt1i4IE7AkNPqhBG5bONi6Yks':
+    #                 ('be68e8feccdd', ),}
+
+
+    trust_map = {'falafel@IxVqeqM0LyYdTmYAf5z49SJZUxr7NtQkOqVYG0hvITw':
+                 ('1' * 12, ),
+                 'SDiZ@17fy9sQtAvZI~nwDt5xXJkTZ9KlXon1ucEakK0vOFTc':
+                 ('2' * 12, ),
+                 }
+
+    parser = USKIndexUpdateParser(trust_map)
+    recv_msgs('127.0.0.1', 11119, parser, ('test',))
+    print
+    print "fms updates:"
+    print parser.updated()
+    print
+    print
+    parser = USKAnnouncementParser(trust_map)
+    recv_msgs('127.0.0.1', 11119, parser, ('test',))
+    print
+    print "fms announcements:"
+    print parser.usks
+    print
+    print
+
+    values0 = ((('be68e8feccdd', 23), ('e246cc31bc42', 3)),
+               ('USK@kRM~jJVREwnN2qnA8R0Vt8HmpfRzBZ0j4rHC2cQ-0hw,'
+                + '2xcoQVdQLyqfTpF2DpkdUIbHFCeL4W~2X1phUYymnhM,AQACAAE/'
+                + 'infocalypse.hgext.R1/12', ))
+
+    # Includes implicit update from announcement.
+    values2 = ((('be68e8feccdd', 12), ('be68e8feccdd', 23), ('e246cc31bc42', 3)),
+               ('USK@kRM~jJVREwnN2qnA8R0Vt8HmpfRzBZ0j4rHC2cQ-0hw,'
+                + '2xcoQVdQLyqfTpF2DpkdUIbHFCeL4W~2X1phUYymnhM,AQACAAE/'
+                + 'infocalypse.hgext.R1/12',))
+
+    # From tuple to string
+    print "---"
+    print values0
+
+    text = to_msg_string(values0[0], values0[1])
+    print "---"
+    # And back
+    print text
+    values1 = parse(text)
+    print "---"
+    print values1
+    # Not values0 because of implicit update.
+    assert values1 == values2
+
+    msg = make_update_msg('djk@isFiaD04zgAgnrEC5XJt1i4IE7AkNPqhBG5bONi6Yks'
+                          'test',
+                          'test',
+                          values0[0],
+                          values0[1])
+    send_msgs('127.0.0.1', 11119, (msg, ))
+
+if __name__ == "__main__":
+    smoke_test()
diff --git a/infocalypse/infcmds.py b/infocalypse/infcmds.py
--- a/infocalypse/infcmds.py
+++ b/infocalypse/infcmds.py
@@ -31,7 +31,8 @@ import time
 from mercurial import util
 
 from fcpclient import parse_progress, is_usk, is_ssk, get_version, \
-     get_usk_for_usk_version, FCPClient, is_usk_file, is_negative_usk
+     get_usk_for_usk_version, FCPClient, is_usk_file, is_negative_usk, \
+     get_usk_hash
 
 from fcpconnection import FCPConnection, PolledSocket, CONNECTION_STATES, \
      get_code, FCPError
@@ -46,6 +47,9 @@ from updatesm import UpdateStateMachine,
 
 from config import Config, DEFAULT_CFG_PATH, normalize
 
+from fms import USKAnnouncementParser, USKIndexUpdateParser, recv_msgs, \
+     to_msg_string, MSG_TEMPLATE, send_msgs
+
 DEFAULT_PARAMS = {
     # FCP params
     'MaxRetries':3,
@@ -317,7 +321,7 @@ def setup(ui_, repo, params, stored_cfg)
     # REDFLAG: Hack to work around 1208 cancel bug. Remove.
     if update_sm.params['FREENET_BUILD'] == '1208':
         ui_.warn("DISABLING request canceling to work around 1208 FCP bug.\n"
-                 "This may cause requests to hang. :-(\n")
+                 "This may cause requests to hang. :-(\n\n")
         disable_cancel(update_sm)
 
         # Patch state machine to re-enable canceling on shutdown.
@@ -410,8 +414,8 @@ def do_key_setup(ui_, update_sm, params,
 
         # Update the inverted insert URI to the latest known version.
         params['INVERTED_INSERT_URI'] = get_usk_for_usk_version(
-        inverted_uri,
-        max_index)
+            inverted_uri,
+            max_index)
 
     # Update the index of the request uri using the stored config.
     request_uri = params.get('REQUEST_URI')
@@ -675,7 +679,7 @@ NO_INFO_FMT = """There's no stored infor
 USK hash: %s
 """
 
-INFO_FMT ="""USK hash: %s
+INFO_FMT = """USK hash: %s
 index   : %i
 
 Request URI:
@@ -704,6 +708,152 @@ def execute_info(ui_, repo, params, stor
     ui_.status(INFO_FMT %
                (usk_hash, max_index or -1, request_uri, insert_uri))
 
+def execute_fmsread(ui_, repo, params, stored_cfg):
+    action = params['FMSREAD']
+    if params['VERBOSITY'] >= 2:
+        ui_.status(('Connecting to fms on %s:%i\n'
+                    + 'Searching groups: %s\n') %
+                   (stored_cfg.defaults['FMS_HOST'],
+                    stored_cfg.defaults['FMS_PORT'],
+                    ' '.join(stored_cfg.fmsread_groups)))
+
+    if action == 'list' or action == 'listall':
+        if action == 'listall':
+            parser = USKAnnouncementParser()
+            if params['VERBOSITY'] >= 2:
+                ui_.status('Listing all repo USKs.\n')
+        else:
+            trust_map = stored_cfg.fmsread_trust_map.copy() # paranoid copy
+            if params['VERBOSITY'] >= 2:
+                fms_ids = trust_map.keys()
+                fms_ids.sort()
+                ui_.status(("Only listing repo USKs from trusted "
+                            + "fms IDs:\n%s\n\n") % '\n'.join(fms_ids))
+            parser = USKAnnouncementParser(trust_map)
+        recv_msgs(stored_cfg.defaults['FMS_HOST'],
+                  stored_cfg.defaults['FMS_PORT'],
+                  parser,
+                  stored_cfg.fmsread_groups)
+        if len(parser.usks) == 0:
+            ui_.status("No USKs found.\n")
+            return
+        ui_.status("\n")
+        for usk in parser.usks:
+            usk_entry = parser.usks[usk]
+            ui_.status("USK Hash: %s\n%s\n%s\n\n" %
+                       (get_usk_hash(usk), usk,
+                        '\n'.join(usk_entry)))
+    else:
+        trust_map = stored_cfg.fmsread_trust_map.copy() # paranoid copy
+        if params['VERBOSITY'] >= 2:
+            fms_ids = trust_map.keys()
+            fms_ids.sort()
+            ui_.status("Update Trust Map:\n")
+            for fms_id in fms_ids:
+                ui_.status("   %s: %s\n" % (fms_id,
+                                            ' '.join(trust_map[fms_id])))
+            ui_.status("\n")
+        parser = USKIndexUpdateParser(trust_map)
+        recv_msgs(stored_cfg.defaults['FMS_HOST'],
+                  stored_cfg.defaults['FMS_PORT'],
+                  parser,
+                  stored_cfg.fmsread_groups)
+        changed = parser.updated(stored_cfg.version_table)
+        if len(changed) == 0:
+            ui_.status('No updates found.\n')
+            return
+
+        for usk_hash in changed:
+            ui_.status('%s:%i\n' % (usk_hash, changed[usk_hash]))
+
+        if params['DRYRUN']:
+            ui_.status('Exiting without saving because --dryrun was set.\n')
+            return
+
+        for usk_hash in changed:
+            stored_cfg.update_index(usk_hash, changed[usk_hash])
+
+        Config.to_file(stored_cfg)
+        ui_.status('Saved updated indices.\n')
+        # Back map to uris and print
+        # show message if current repo was updated
+        # support dry run
+
+# REDFLAG: Catch this in config when depersisting?
+def is_none(value):
+    return value is None or value == 'None'
+
+def execute_fmsnotify(ui_, repo, params, stored_cfg):
+    update_sm = None
+    try:
+        # REDFLAG: dci, test non uri keys
+        update_sm = setup(ui_, repo, params, stored_cfg)
+        request_uri, dummy = do_key_setup(ui_, update_sm,
+                                          params, stored_cfg)
+        if request_uri is None:
+            ui_.warn("Only works for USK file URIs.\n")
+            return
+
+        usk_hash = get_usk_hash(request_uri)
+        index = stored_cfg.get_index(usk_hash)
+        # REDFLAG: DCI. Needed?
+        request_uri = get_usk_for_usk_version(request_uri, index)
+        if index is None:
+            ui_.warn("Can't notify because there's no stored index "
+                     + "for %s.\n" % usk_hash)
+            return
+
+        if is_none(stored_cfg.defaults['FMS_ID']):
+            ui_.warn("Can't notify because the fms ID isn't set in the "
+                     + "config file.\n")
+            ui_.status("Update the fms_id = line and try again.\n")
+            return
+
+        if is_none(stored_cfg.defaults['FMSNOTIFY_GROUP']):
+            ui_.warn("Can't notify because fms group isn't set in the "
+                     + "config file.\n")
+            ui_.status("Update the fmsnotify_group = line and try again.\n")
+            return
+
+        if params['ANNOUNCE']:
+            text = to_msg_string(None, (request_uri, ))
+        else:
+            text = to_msg_string(((usk_hash, index), ))
+
+        subject = 'Update:' + '/'.join(request_uri.split('/')[1:])
+        msg_tuple = (stored_cfg.defaults['FMS_ID'],
+                     stored_cfg.defaults['FMSNOTIFY_GROUP'],
+                     subject,
+                     text)
+
+        if params['VERBOSITY'] >= 2:
+            ui_.status('Connecting to fms on %s:%i\n' %
+                       (stored_cfg.defaults['FMS_HOST'],
+                        stored_cfg.defaults['FMS_PORT']))
+
+        ui_.status('Group: %s\nSubject: %s\n%s\n' %
+                   (stored_cfg.defaults['FMSNOTIFY_GROUP'],
+                    subject, text))
+
+        if params['VERBOSITY'] >= 5:
+            raw_msg = MSG_TEMPLATE % (msg_tuple[0],
+                                      msg_tuple[1],
+                                      msg_tuple[2],
+                                      msg_tuple[3])
+            ui_.status('--- Raw Message ---\n%s\n---\n' % raw_msg)
+
+        if params['DRYRUN']:
+            ui_.status('Exiting without sending because --dryrun was set.\n')
+            return
+
+        send_msgs(stored_cfg.defaults['FMS_HOST'],
+                  stored_cfg.defaults['FMS_PORT'],
+                  (msg_tuple, ))
+
+        ui_.status('Notification message sent.\n')
+    finally:
+        cleanup(update_sm)
+
 def setup_tmp_dir(ui_, tmp):
     """ INTERNAL: Setup the temp directory. """
     tmp = os.path.expanduser(tmp)
diff --git a/infocalypse/knownrepos.py b/infocalypse/knownrepos.py
new file mode 100644
--- /dev/null
+++ b/infocalypse/knownrepos.py
@@ -0,0 +1,15 @@
+
+# If you maintain a repository that doesn't contain illicit content
+# let me know and I'll add it here.
+KNOW_REPOS = (
+    'USK@kRM~jJVREwnN2qnA8R0Vt8HmpfRzBZ0j4rHC2cQ-0hw,'
+    + '2xcoQVdQLyqfTpF2DpkdUIbHFCeL4W~2X1phUYymnhM,AQACAAE/'
+    + 'infocalypse.hgext.R1/23',
+    )
+
+DEFAULT_TRUST = {
+    'djk@isFiaD04zgAgnrEC5XJt1i4IE7AkNPqhBG5bONi6Yks':
+    ('be68e8feccdd', ),
+    }
+
+DEFAULT_GROUPS = ('test', )