Wrote script to insert releases into Freenet.
diff --git a/doc/latest_release.txt b/doc/latest_release.txt new file mode 100644 diff --git a/freesite.cfg b/freesite.cfg new file mode 100644 --- /dev/null +++ b/freesite.cfg @@ -0,0 +1,19 @@ +# freesite.cfg used by fn-putsite (called from cut_release.py) +[default] +# Human readable site name. +# MUST match value in cut_release.py +site_name = jfniki_releases_tst000 +# Directory to insert from relative to the repository root. +site_dir = release/generated_freesite +# Optional external file to load the site key from, relative +# to the directory your .infocalypse/infocalypse.ini file +# is stored in. This file should contain ONLY the SSK insert +# key up to the first slash. +# +# If this value is not set the insert SSK for the repo is +# used. +#site_key_file = example_freesite_key.txt +# +# Optional file to display by default. If this is not +# set index.html is used. +#default_file = index.html diff --git a/release/cut_release.py b/release/cut_release.py new file mode 100755 --- /dev/null +++ b/release/cut_release.py @@ -0,0 +1,276 @@ +#!/usr/bin/env python + +""" Script to insert jfniki releases into Freenet. + + Copyright (C) 2011 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 +""" + +# This script isn't really for public consumption. +# +# It assumes you have hg infocalypse installed and configured. +# +# BUG: ANONYMITY ISSUE: This script currently leaks the *nix user id +# into the inserted .tgz and .jar files. +# DO NOT RUN IT if this concerns you. +# +import os +import shutil +import subprocess +import tarfile + +from binascii import hexlify + +from mercurial import ui, hg, commands + +from insert_files import insert_files +from minimalfms import get_connection, send_msgs + +############################################################ + +# CAUTION: This directory is recursively deleted! +STAGING_DIR = '/tmp/staging' + +FCP_HOST = '127.0.0.1' +FCP_PORT = 19481 + +FMS_HOST = '127.0.0.1' +FMS_PORT = 11119 + +FMS_ID = 'djk' +FMS_GROUP = 'test' + +# REQUIRES: must match name in freesite.cfg. LATER: fix. +SITE_NAME = 'jfniki_releases_tst000' + +PUBLIC_SITE = "USK@kRM~jJVREwnN2qnA8R0Vt8HmpfRzBZ0j4rHC2cQ-0hw," + \ + "2xcoQVdQLyqfTpF2DpkdUIbHFCeL4W~2X1phUYymnhM,AQACAAE/%s/%%d/" % \ + SITE_NAME + +############################################################ +# Indexes of refereneced USK sites + +FREENET_DOC_WIKI_IDX = 30 +FNIKI_IDX = 81 +REPO_IDX = 13 + +############################################################ + +THIS_FILES_DIR = os.path.abspath(os.path.dirname(__file__)) + +REPO_DIR = os.path.abspath(os.path.join(THIS_FILES_DIR, '..')) +FREENET_JAR = os.path.abspath(os.path.join(THIS_FILES_DIR, '../alien/libs/freenet.jar')) +RELEASE_NOTES = os.path.abspath(os.path.join(THIS_FILES_DIR, '../doc/latest_release.txt')) +INDEX_HTML = os.path.abspath(os.path.join(THIS_FILES_DIR, 'generated_freesite/index.html')) +INDEX_HTML_TEMPLATE = os.path.abspath(os.path.join(THIS_FILES_DIR, 'index_template.html')) +FMS_MESSAGE_TEMPLATE = os.path.abspath(os.path.join(THIS_FILES_DIR, 'fms_message_template.txt')) + +############################################################ + +def stage_release(): + # LATER: check for uncommitted changes + ui_ = ui.ui() + repo = hg.repository(ui_, REPO_DIR) + + # Get current head. + heads = [hexlify(repo[head].node())[:12] for head in repo.heads()] + assert len(heads) == 1 # Don't try to handle multiple heads + head = heads[0] + + jar_name = "jfniki.%s.jar" % head + tgz_name = "jfniki.%s.tgz" % head + export_dir_name = "jfniki.%s" % head + + tgz_file_name = "%s/%s" % (STAGING_DIR, tgz_name) + jar_file_name = "%s/%s" % (STAGING_DIR, jar_name) + + # scrub staging directory + try: + shutil.rmtree(STAGING_DIR) + except: + pass + + os.makedirs(STAGING_DIR) + + # dump clean source to staging + dest = "%s/%s" % (STAGING_DIR, export_dir_name) + + # TRICKY: Had to put a print in the implementation of command.archive to figure + # out required default opts. + # {'rev': '', 'no_decode': None, 'prefix': '', 'exclude': [], 'include': [], 'type': ''} + commands.archive(ui_, repo, dest, + rev='', no_decode=None, prefix='', exclude=[], include=[], type='') + + + # remove origin tarballs to save space + shutil.rmtree("%s/alien/origins/" % dest) + + # tar up source + tgz_file = tarfile.open(tgz_file_name, 'w:gz') + + #def reset(tarinfo): + # tarinfo.uid = tarinfo.gid = 0 + # tarinfo.uname = tarinfo.gname = "root" + # return tarinfo + # LATER: Use line after upgrading python. Keeps uid, gid, uname out of tar. + # tgz_file.add("%s/%s" % (STAGING_DIR, export_dir_name), filter=reset) # python 2.7 + tgz_file.add("%s/%s" % (STAGING_DIR, export_dir_name)) + tgz_file.close() + + # cp freenet.jar required for build + os.makedirs("%s/%s/%s" % (STAGING_DIR, export_dir_name, "alien/libs")) + shutil.copyfile(FREENET_JAR, "%s/%s/%s" % (STAGING_DIR, export_dir_name, "alien/libs/freenet.jar")) + + # build jar + result = subprocess.check_call(["/usr/bin/ant", + "-buildfile", + "%s/%s/build.xml" % (STAGING_DIR, export_dir_name)]) + print "ant result code: %d" % result + + # copy jar with name including the hg rev. + shutil.copyfile("%s/%s/%s" % (STAGING_DIR, export_dir_name, "build/jar/jfniki.jar"), jar_file_name) + + print + print "SUCCESSFULLY STAGED:" + print jar_file_name + print tgz_file_name + print + return (head, jar_file_name, tgz_file_name) + + +def simple_templating(text, substitutions): + for variable in substitutions: + text = text.replace(variable, str(substitutions[variable])) + assert text.find("__") == -1 # Catch unresolved variables + return text + +def latest_site_index(repo): # C&P: wikibot.py + """ Read the latest known freesite index out of the hg changelog. """ + for tag, dummy in reversed(repo.tagslist()): + if tag.startswith('I_'): + return int(tag.split('_')[1]) + return -1 + +def tag_site_index(ui_, repo, index=None): # C&P: wikibot.py + """ Tag the local repository with a freesite index. """ + if index is None: + index = latest_site_index(repo) + 1 + commands.tag(ui_, repo, 'I_%i' % index) + +############################################################ +# ATTRIBUTION: +# http://wiki.python.org/moin/EscapingHtml +HTML_ESCAPE_TABLE = { + "&": "&", + '"': """, + "'": "'", + ">": ">", + "<": "<"} + +def html_escape(text): + """Produce entities within text.""" + return "".join(HTML_ESCAPE_TABLE.get(c,c) for c in text) + +############################################################ + +def update_html(head, jar_chk, tgz_chk): + ui_ = ui.ui() + repo = hg.repository(ui_, REPO_DIR) + site_usk = PUBLIC_SITE % (latest_site_index(repo) + 1) + + html = simple_templating(open(INDEX_HTML_TEMPLATE).read(), + {'__HEAD__':head, + '__JAR_CHK__': jar_chk, + '__SRC_CHK__': tgz_chk, + '__RELEASE_NOTES__' : html_escape(open(RELEASE_NOTES).read()), + '__SITE_USK__': site_usk, + '__INDEX_FDW__': FREENET_DOC_WIKI_IDX, + '__INDEX_FNIKI__': FNIKI_IDX, + '__INDEX_REPO__': REPO_IDX, + }) + + updated = open(INDEX_HTML, 'w') + updated.write(html) + updated.close() + + commit_msg = "index.html:%s" % head + commands.commit(ui_, repo, pat = (INDEX_HTML, ), + include = [], addremove = None, close_branch = None, + user = '', date = '', + exclude = [], logfile = '', + message = commit_msg) + tag_site_index(ui_, repo) + +# Insert the latest tagged freesite version into freenet. +# NOTE: depends on state set in freesite.cfg +# REQUIRES: 'I_<num>' tag exists in the repo. +# REQUIRES: hg infocalypse is installed and configured. +def insert_freesite(): + print REPO_DIR + ui_ = ui.ui() + repo = hg.repository(ui_, REPO_DIR) + target_index = latest_site_index(repo) + assert target_index >= 0 + + # DCI: Test. does fn-putsite set error code on failure? + subprocess.check_call(["/usr/bin/hg", + "-R", + REPO_DIR, + "fn-putsite", + "--index", + str(target_index)]) + + # LATER: Do better. Parse request URI from output. + return PUBLIC_SITE % target_index, target_index + +def send_fms_notification(site_uri, target_index, head, jar_chk, tgz_chk): + + connection = get_connection(FMS_HOST, FMS_PORT, FMS_ID) + + msg = simple_templating(open(FMS_MESSAGE_TEMPLATE).read(), + {'__HEAD__':head, + '__JAR_CHK__': jar_chk, + '__SRC_CHK__': tgz_chk, + '__SITE_USK__' : site_uri, + '__RELEASE_NOTES__' : open(RELEASE_NOTES).read(), + }) + + send_msgs(connection, + ((FMS_ID, FMS_GROUP, + "jfniki releases #%d" % target_index, + msg),)) + + print "Sent FMS notification to: %s" % FMS_GROUP + +def release(): + print + print "If you haven't read the warning about ANONYMITY ISSUES" + print "with this script, now might be a good time to hit Ctrl-C." + print + print + head, jar_file, tgz_file = stage_release() + jar_chk, tgz_chk = insert_files(FCP_HOST, FCP_PORT, [jar_file, tgz_file]) + update_html(head, jar_chk, tgz_chk) + site_uri, target_index = insert_freesite() + send_fms_notification(site_uri, target_index, head, jar_chk, tgz_chk) + print + print "Success!" + + +if __name__ == "__main__": + release() diff --git a/release/fcpclient.py b/release/fcpclient.py new file mode 100644 --- /dev/null +++ b/release/fcpclient.py @@ -0,0 +1,890 @@ +# REDFLAG: There are changes here that haven't been pushed back into main repo. +""" Simplified client interface for common FCP request. + + Copyright (C) 2008 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 mimetypes, os, re + +from fcpconnection import FCPConnection, IDataSource, READ_BLOCK, \ + MinimalClient, PolledSocket, FCPError, sha1_hexdigest + +from fcpmessage import GETNODE_DEF, GENERATE_SSK_DEF, \ + GET_REQUEST_URI_DEF, GET_DEF, \ + PUT_FILE_DEF, PUT_REDIRECT_DEF, PUT_COMPLEX_DIR_DEF + +# Defaults for commonly used FCP parameters. +FCP_PARAM_DEFAULTS = { + 'ReturnType':'direct', + 'IgnoreDS':False, + 'MaxRetries':3, + 'DontCompress':True, # Hmmmm... + 'Verbosity':1023 # MUST set this to get progress messages. +} + +#-----------------------------------------------------------# +# file_info helper functions +#-----------------------------------------------------------# + +def get_file_infos(directory, forced_mime_type=None, accept_regex = None): + """ Traverse a directory and return a list of file information + tuples which is suitable for use by + FCPClient.put_complex_dir(). + + forced_mime_type determines the value of + the mime_type field in the returned tuples. + + If acceptRegex is not None only files which match + it are added. + + TUPLE FORMAT: + (name, length, mime_type, full_path) + """ + + def walk_visitor(file_info, dirname, names): + """ Function scope visitor implementation passed to os.path.walk. + """ + + for name in names: + full_name = os.path.join(dirname, name) + if os.path.isfile(full_name): + base = file_info[0] + local_path = full_name.replace(base, '') + # REDFLAG: More principled way to do this? + # Fix slashes on windows. + local_path = local_path.replace('\\', '/') + if file_info[2] and not file_info[2].match(local_path): + # Skip files rejected by the regex + continue + + file_info[1].append((local_path, + os.path.getsize(full_name), + forced_mime_type, + full_name)) + if directory[-1] != os.path.sep: + # Force trailing path separator. + directory += os.path.sep + file_info = (directory, [], accept_regex) #REDFLAG: bad variable name + os.path.walk(directory, walk_visitor, file_info) + return file_info[1] + +def total_length(file_infos): + """ Returns the sum of the file lengths in file_info list. """ + + total = 0 + for info in file_infos: + total += info[1] + return total + +def set_index_file(file_infos, file_name): + """ Move the tuple with the name file_name to the front of + file_infos so that it will be used as the index. + """ + index = None + for info in file_infos: # hmmm... faster search? + if info[0] == file_name: + index = info + break + + if index is None: + raise ValueError("No file named: %s" % file_name) + + file_infos.remove(index) + file_infos.insert(0, index) + +def sort_file_infos(file_infos): + """ Helper function forces file infos into a fixed order. + + Note: Doesn't move the first entry. + """ + + if len(file_infos) < 3: + return file_infos + rest = file_infos[1:] + rest.sort() + + return file_infos[:1] + rest + +class FileInfoDataSource(IDataSource): + """ IDataSource which concatenates files in a list of + file infos into a contiguous data stream. + + Useful for direct ClientPutComplexDir requests. + """ + + MSG_LENGTH_MISMATCH = "Upload bytes doesn't match sum of " \ + + "lengths in file_infos. Did the files " \ + + "change during uploading?" + + def __init__(self, file_infos): + IDataSource.__init__(self) + assert file_infos + self.infos = file_infos + self.total_length = total_length(file_infos) + self.running_total = 0 + self.chunks = None + self.input_file = None + + def data_generator(self, infos): + """ INTERNAL: Returns a generator which yields the concatenated + data from all the file infos. + """ + + for info in infos: + #print "FileInfoDataSource.GEN -- opening", info[3] + self.input_file = open(info[3], 'rb') + while True: + raised = True + try: + data = self.input_file.read(READ_BLOCK) + #print "FileInfoDataSource.GEN -- read:", len(data) + raised = False + finally: + # Note: Wacky control flow because you can't yield + # from a finally block + if raised or data is None: + #print "FileInfoDataSource.GEN -- closing", info[3] + self.input_file.close() + self.input_file = None + if not data: + break + self.running_total += len(data) + if self.running_total > self.total_length: + raise IOError(self.MSG_LENGTH_MISMATCH) + #print "FileInfoDataSource.GEN -- yeilding", len(data) + yield data + + if self.running_total != self.total_length: + raise IOError(self.MSG_LENGTH_MISMATCH) + + yield None + return + + def initialize(self): + """ IDataSource implementation. """ + #print "FileInfoDataSource.initialize -- called" + assert self.chunks is None + self.chunks = self.data_generator(self.infos) + + def data_length(self): + """ IDataSource implementation. """ + #print "FileInfoDataSource.data_length -- ", self.total_length + return self.total_length + + def release(self): + """ IDataSource implementation. """ + #print "FileInfoDataSource.release -- called" + if not self.chunks is None: + self.chunks = None + if not self.input_file: + self.input_file.close() + self.input_file = None + + def read(self): + """ IDataSource implementation. """ + #print "FileInfoDataSource.read -- called" + assert not self.chunks is None + if self.chunks: + ret = self.chunks.next() + if ret is None: + self.chunks = None + #print "FileInfoDataSource.read -- returned None" + return None + #print "FileInfoDataSource.read -- returned:", len(ret) + return ret + #print "FileInfoDataSource.read(1) -- returned None, \ + # SHOULD NEVER HAPPEN" + return None + + + + +#-----------------------------------------------------------# +# Key classification and manipulation helper functions +#-----------------------------------------------------------# + +# REDFLAG: Use a common regex? Not sure that would cut loc... +USK_FILE_REGEX = re.compile('(freenet:)?(USK).*/((\\-)?[0-9]+[0-9]*)$') +def is_usk_file(uri): + """ Returns True if uri points to a single file, False otherwise. """ + return bool(USK_FILE_REGEX.match(uri)) + +USK_CONTAINER_REGEX = re.compile('(freenet:)?(USK).*/((\\-)?[0-9]+[0-9]*)/$') +def is_usk_container(uri): + """ Return True if uri is USK uri which points to a Freenet + Container, False otherwise. + """ + return bool(USK_CONTAINER_REGEX.match(uri)) + +KEY_TYPE_REGEX = re.compile('(freenet:)?(?P<key_type>CHK|KSK|SSK|USK)@') +def key_type(uri): + """ Returns the key type. """ + + match = KEY_TYPE_REGEX.match(uri) + if not match: + raise Exception("Doesn't look like a Freenet URI: %s" % uri) + return match.groupdict()['key_type'] + +def is_chk(uri): + """ Returns True if the URI is a CHK key, False otherwise. """ + return key_type(uri) == 'CHK' + +def is_ksk(uri): + """ Returns True if the URI is a KSK key, False otherwise. """ + return key_type(uri) == 'KSK' + +def is_ssk(uri): + """ Returns True if the URI is a SSK key, False otherwise. """ + return key_type(uri) == 'SSK' + +def is_usk(uri): + """ Returns True if the URI is a USK key, False otherwise. """ + return key_type(uri) == 'USK' + +# LATER: fix regex to work for SSKs too. +VERSION_REGEX = re.compile('(?P<usk>USK)@(.*)/(?P<version>' + + '(\\-)?[0-9]+[0-9]*)(/.*)?') +def get_version(uri): + """ Return the version index of USK. + + Raises ValueError if no version could be extracted. + """ + + try: + version = int(VERSION_REGEX.match(uri). + groupdict()['version']) + except: + raise ValueError("Couldn't parse a USK or SSK version from: %s" % uri) + return version + +def get_ssk_for_usk_version(usk_uri, version): + """ Return an SSK for a specific version of a USK. + + NOTE: + The version in usk_uri is ignored. + """ + match = VERSION_REGEX.match(usk_uri) + if not match: + raise Exception("Couldn't parse version from USK: %s" % usk_uri) + + return 'SSK' + usk_uri[match.end('usk') : match.start('version') - 1] \ + + '-' + str(version) + usk_uri[match.end('version'):] + +def get_usk_for_usk_version(usk_uri, version, negative = False): + """ Return an USK for a specific version of a USK. + + NOTE: + The version in usk_uri is ignored. + Works for both containers and files. + """ + match = VERSION_REGEX.match(usk_uri) + if not match: + raise Exception("Couldn't parse version from USK: %s" % usk_uri) + if negative and version > 0: + version = -1 * version + version_str = str(version) + if version == 0 and negative: + version_str = '-0' + # BITCH: + # They should have picked some other symbol ('*'?) which doesn't + # encourage implementers to jam the version into an integer. + # i.e. because you can't represent the version with an integer + # because -0 == 0. + assert not negative or version_str.find('-') > -1 + + return usk_uri[0 : match.start('version')] \ + + version_str + usk_uri[match.end('version'):] + +def is_negative_usk(usk_uri): + """ Returns True if usk_uri has a negative version index, + False otherwise. + + REQUIRES: usk_uri is a USK key. + """ + match = VERSION_REGEX.match(usk_uri) + if not match: + raise Exception("Couldn't parse version from USK: %s" % usk_uri) + return match.groupdict()['version'].find('-') > -1 + +def get_negative_usk(usk_uri): + """ Return an USK with a negative version index. + + NOTE: + Using a negative index causes the FCP server to search + harder for later versions in ClientGet requests. + + NOTE: + This is a NOP if usk_uri is already negative. + """ + version = get_version(usk_uri) + if is_negative_usk(usk_uri): + return usk_uri + + return get_usk_for_usk_version(usk_uri, version, True) + +def prefetch_usk(client, usk_uri, allowed_redirects = 3, + message_callback = None): + """ Force the FCP server to explicitly search for updates + to the USK. + + Returns the latest version as an integer or None if + no version could be determined. + + This works by sending a negative index value for the USK. + + Note that this can return a version LESS THAN the version + in usk_uri. + """ + + if client.in_params.async: + raise ValueError("This function only works synchronously.") + + usk_uri = get_negative_usk(usk_uri) + client.reset() + callback = client.message_callback + return_type = client.in_params.default_fcp_params.get('ReturnType') + version = None + try: + if message_callback: + # Install a custom message callback + client.message_callback = message_callback + client.in_params.default_fcp_params['ReturnType'] = 'none' + try: + # BUG: HANGS + version = get_version(client.get(usk_uri, + allowed_redirects)[1]['URI']) + except FCPError: + version = None + finally: + client.message_callback = callback + if return_type: + client.in_params.default_fcp_params['ReturnType'] = return_type + + return version + +def latest_usk_index(client, usk_uri, allowed_redirects = 1, + message_callback = None): + """ Determines the version index of a USK key. + + Returns a (version, data_found) tuple where version + is the integer version and data_found is the data_found + message for the latest index. + + + NOTE: + This fetches the key and discards the data. + It may take a very long time if you call it for + a key which points to a large block of data. + """ + + if client.in_params.async: + raise ValueError("This function only works synchronously.") + + client.reset() + callback = client.message_callback + #print "PARAMS:", client.in_params.default_fcp_params + return_type = client.in_params.default_fcp_params.get('ReturnType') + try: + if message_callback: + # Install a custom message callback + client.message_callback = message_callback + client.in_params.default_fcp_params['ReturnType'] = 'none' + prev = None + while True: + # Hmmmm... Make sure that the USK has 'settled' + next = client.get(usk_uri, allowed_redirects) + if prev and next[1]['URI'] == prev[1]['URI']: + break + prev = next + finally: + client.message_callback = callback + if return_type: + client.in_params.default_fcp_params['ReturnType'] = return_type + + return (get_version(prev[1]['URI']), prev) + +def get_insert_chk_filename(uri): + """ Returns the file name part of CHK@/file_part.ext style + CHK insert uris. """ + assert uri.startswith('CHK@') + if not uri.startswith('CHK@/'): + if uri != 'CHK@': + raise ValueError("Unexpected data after '@'. Maybe you forgot the " + + "'/' before the filename part?") + return None + return uri[5:] + +def set_insert_uri(params, uri): + """ INTERNAL: Set the 'URI' and 'TargetFilename' in params, + correctly handling CHK@/filename.ext style insert URIs. """ + + if is_chk(uri): + params['URI'] = 'CHK@' + filename = get_insert_chk_filename(uri) + if not filename is None: + params['TargetFilename'] = filename + else: + params['URI'] = uri + +def get_usk_hash(usk): + """ Returns a 12 hex digit hash for a USK which is independant + of verison. """ + return sha1_hexdigest(get_usk_for_usk_version(usk, 0))[:12] + +def check_usk_hash(usk, hash_value): + """ Returns True if the hash matches, False otherwise. """ + return (sha1_hexdigest(get_usk_for_usk_version(usk, 0))[:12] + == hash_value) + +def show_progress(dummy, msg): + """ Default message callback implementation. """ + + if msg[0] == 'SimpleProgress': + print "Progress: (%s/%s/%s)" % (msg[1]['Succeeded'], + msg[1]['Required'], + msg[1]['Total']) + else: + print "Progress: %s" % msg[0] + +def parse_progress(msg): + """ Parse a SimpleProgress message into a tuple. """ + assert msg[0] == 'SimpleProgress' + + return (int(msg[1]['Succeeded']), + int(msg[1]['Required']), + int(msg[1]['Total']), + int(msg[1]['Failed']), + int(msg[1]['FatallyFailed']), + bool(msg[1]['FinalizedTotal'].lower() == 'true')) + +class FCPClient(MinimalClient): + """ A class to execute common FCP requests. + + This class provides a simplified interface for common FCP commands. + Calls are blocking by default. Set FCPClient.in_params.async = True + to run asynchronously. + + You can set FCP parameters using the + FCPClient.in_params.default_fcp_params dictionary. + + GOTCHA: + Don't set FCPClient.in_params.fcp_params directly. It is reset + before most calls so changes to it probably won't have any effect. + """ + def __init__(self, conn): + MinimalClient.__init__(self) + self.conn = conn + self.message_callback = show_progress + self.in_params.default_fcp_params = FCP_PARAM_DEFAULTS.copy() + + @classmethod + def connect(cls, host, port, socket_class = PolledSocket, + state_callback = None): + """ Create an FCPClient which owns a new FCPConnection. + + NOTE: If you need multiple FCPClient instances it is + better to explictly create an FCPConnection and + use the FCPClient.__init__() method so that all + instances are multiplexed over the same connection. + """ + sock = None + conn = None + raised = True + try: + sock = socket_class(host, port) + conn = FCPConnection(sock, True, state_callback) + raised = False + finally: + if raised: + if conn: + conn.close() + if sock: + sock.close() + + return FCPClient(conn) + + + def wait_until_finished(self): + """ Wait for the current request to finish. """ + assert self.conn + self.conn.wait_for_terminal(self) + + def close(self): + """ Close the underlying FCPConnection. """ + if self.conn: + self.conn.close() + + def get_node(self, opennet = False, private = False, volatile = True): + """ Query node information by sending an FCP GetNode message. """ + + # Hmmmm... I added an 'Identifier' value to request message + # even though there's None in the doc. See GETNODE_DEF. + # It seems to work. + self.reset() + self.in_params.definition = GETNODE_DEF + self.in_params.fcp_params = {'GiveOpennetRef': opennet, + 'WithPrivate': private, + 'WithVolatile': volatile } + + return self.conn.start_request(self) + + def generate_ssk(self): + """ Generate an SSK key pair. + + Returns the SSKKeyPair message. + """ + self.reset() + self.in_params.definition = GENERATE_SSK_DEF + return self.conn.start_request(self) + + def get_request_uri(self, insert_uri): + """ Return the request URI corresponding to the insert URI. + + REQUIRES: insert_uri is a private SSK or USK. + """ + + if self.in_params.async: + raise ValueError("This function only works synchronously.") + + assert is_usk(insert_uri) or is_ssk(insert_uri) + + if is_usk(insert_uri): + target = get_ssk_for_usk_version(insert_uri, 0) + else: + target = insert_uri + + self.reset() + self.in_params.definition = GET_REQUEST_URI_DEF + self.in_params.fcp_params = {'URI': target, + 'MaxRetries': 1, + 'PriorityClass':1, + 'UploadFrom':'direct', + 'DataLength':9, + 'GetCHKOnly':True} + self.in_params.send_data = '012345678' # 9 bytes of data + inverted = self.conn.start_request(self)[1]['URI'] + public = inverted[inverted.find('@') + 1: inverted.find('/')] + return insert_uri[:insert_uri.find('@') + 1] + public \ + + insert_uri[insert_uri.find('/'):] + + def get(self, uri, allowed_redirects = 0, output_file = None): + """ Requests the data corresponding to the URI from the + FCP server. + + Returns an AllData or DataFound (when + self.default_fcp_params['ReturnType'] == 'none') message + on success. + + If output_file or self.output_file is not None, write the + raw data to file instead of returning it as a string. + + Raises an FCPError on failure. + + An extra 'URI' entry is added to the returned message + containing the final URI the data was requested + from after redirecting. + + An extra 'Metadata.ContentType' entry is added to the + returned AllData message containing the mime type + information extracted from the last DataFound. + """ + self.reset() + self.in_params.definition = GET_DEF + self.in_params.fcp_params = {'URI':uri } + self.in_params.allowed_redirects = allowed_redirects + self.in_params.file_name = output_file + # REDFLAG: fix + self.in_params.send_data = False + return self.conn.start_request(self) + + + def put(self, uri, bytes, mime_type=None): + """ Insert a string into Freenet. + + Returns a PutSuccessful message on success. + Raises an FCPError on failure. + """ + self.reset() + self.in_params.definition = PUT_FILE_DEF + set_insert_uri(self.in_params.fcp_params, uri) + if mime_type: + self.in_params.fcp_params['Metadata.ContentType'] = mime_type + + self.in_params.send_data = bytes + return self.conn.start_request(self) + + def put_file(self, uri, path, mime_type=None): + """ Insert a single file into Freenet. + + Returns a PutSuccessful message on success. + Raises an FCPError on failure. + + REQUIRES: The size of the file can't change during this + call. + """ + + self.reset() + self.in_params.definition = PUT_FILE_DEF + set_insert_uri(self.in_params.fcp_params, uri) + + if mime_type: + self.in_params.fcp_params['Metadata.ContentType'] = mime_type + + # REDFLAG: test. not sure this ever worked in previous version + #if 'UploadFrom' in params and params['UploadFrom'] == 'disk': + # # REDFLAG: test this code path! + # params['FileName'] = path + # path = None + + self.in_params.file_name = path + # REDFLAG: fix + self.in_params.send_data = True + return self.conn.start_request(self) + + def put_redirect(self, uri, target_uri, mime_type=None): + """ Insert a redirect into freenet. + + Returns a PutSuccessful message on success. + Raises an FCPError on failure. + """ + self.reset() + self.in_params.definition = PUT_REDIRECT_DEF + self.in_params.fcp_params = {'URI':uri, + 'TargetURI':target_uri, + 'UploadFrom':'redirect'} + if mime_type: + self.in_params.fcp_params['Metadata.ContentType'] = mime_type + return self.conn.start_request(self) + + def put_complex_dir(self, uri, file_infos, + default_mime_type = 'text/plain'): + """ Insert a collection of files into a Freenet Container. + + file_infos must be a list of + (name, length, mime_type, full_path) tuples. + + file_infos[0] is inserted as the default document. + + mime types: + If the mime_type value in the file_infos tuple for the + file is not None, it is used. Otherwise the mime type + is guessed from the file extension. Finally, if guessing + fails, default_mime_type is used. + """ + + assert default_mime_type + assert file_infos + + self.reset() + self.in_params.definition = PUT_COMPLEX_DIR_DEF + self.in_params.fcp_params = {'URI': uri} + + # IMPORTANT: Don't set the data length. + return self.conn.start_request(self, + dir_data_source(file_infos, + self.in_params, + default_mime_type), + False) + + +# Break out implementation helper so I can use it elsewhere. +def dir_data_source(file_infos, in_params, default_mime_type): + """ Return an IDataSource for a list of file_infos. + + NOTE: Also sets up Files.* fields in in_params as a + side effect. """ + + for field in in_params.default_fcp_params: + if field.startswith("Files"): + raise ValueError("You can't set file entries via " + + " default_fcp_params.") + if 'DefaultName' in in_params.default_fcp_params: + raise ValueError("You can't set 'DefaultName' via " + + "default_fcp_params.") + + # IMPORTANT: Sort the file infos so that the same set of + # file_infos always yields the same inserted data blob. + # file_infos[0] isn't moved. + file_infos = sort_file_infos(file_infos) + + files = {} + index = 0 + for info in file_infos: + mime_type = info[2] + if not mime_type: + # First try to guess from the extension. + type_tuple = mimetypes.guess_type(info[0]) + if type_tuple: + mime_type = type_tuple[0] + if not mime_type: + # Fall back to the default. + mime_type = default_mime_type + + files['Files.%i.Name' % index] = info[0] + files['Files.%i.UploadFrom' % index] = 'direct' + files['Files.%i.DataLength' % index] = info[1] + files['Files.%i.Metadata.ContentType' % index] = mime_type + + index += 1 + + in_params.fcp_params['Files'] = files + in_params.fcp_params['DefaultName'] = file_infos[0][0] + + #REDFLAG: Fix + in_params.send_data = True + + return FileInfoDataSource(file_infos) + +############################################################ +# Helper function for hg changeset bundle handling. +############################################################ + +# Saw here: +# http://sage.math.washington.edu/home/robertwb/trac-bundle/test \ +# /sage_trac/log/trac.log +HG_MIME_TYPE = 'application/mercurial-bundle' + +def package_metadata(metadata): + """ Package the bundle contents metadata into a string which + can be inserted into to the Metadata.ContentType field + of the Freenet key. + + All args must be full 40 digit hex keys. + """ + return "%s;%s,%s,%s" % (HG_MIME_TYPE, metadata[0], metadata[1], metadata[2]) + +CHANGESET_REGEX = re.compile('.*;\s*([0-9a-fA-F]{40,40})\s*,' + + '\s*([0-9a-fA-F]{40,40})\s*,' + + '\s*([0-9a-fA-F]{40,40})') +def parse_metadata(msg): + """ INTERNAL: Parse the (base_rev, first_rev, tip) info out of the + Metadata.ContentType field of msg. + + FCP2.0 doesn't have support for user defined metadata, so we + jam the metadata we need into the mime type field. + """ + match = CHANGESET_REGEX.match(msg[1]['Metadata.ContentType']) + if not match or len(match.groups()) != 3: + # This happens for bundles inserted with older versions + # of hg2fn.py + raise ValueError("Couldn't parse changeset info from [%s]." \ + % msg[1]['Metadata.ContentType']) + return match.groups() + +def make_rollup_filename(rollup_info, request_uri): + """ Return a filename containing info for a rollup bundle. """ + if not is_usk_file(request_uri): + raise ValueError("request_uri is not a USK file uri.") + + # Hmmmm.... get rid of symbolic names? + tip = rollup_info[0][0] + parent = rollup_info[0][1] + start_index = rollup_info[0][2] + end_index = rollup_info[0][3] + assert len(tip) == 40 # LATER: is_changset_id_str() func? + assert len(parent) == 40 + assert start_index >= 0 + assert end_index >= 0 + assert end_index >= start_index + + human_readable = request_uri.split('/')[1] + # hmmmm... always supress .hg + if human_readable.lower().endswith('.hg'): + human_readable = human_readable[:-3] + # <human_name>_<end_index>_<start_index>_<tip>_<parent>_ID<repoid> + return "%s_%i_%i_%s_%s_ID%s" % (human_readable, end_index, start_index, + tip[:12], parent[:12], + get_usk_hash(request_uri)) + +def parse_rollup_filename(filename): + """ Parse a filename created with make_rollup_filename + into a tuple.""" + fields = filename.split('_') + repo_id = fields[-1] + if not repo_id.startswith("ID") or len(repo_id) != 14: + raise ValueError("Couldn't parse repo usk hash.") + repo_id = repo_id[2:] + parent = fields[-2] + if len(parent) != 12: + raise ValueError("Couldn't parse parent.") + tip = fields[-3] + if len(tip) != 12: + raise ValueError("Couldn't parse tip.") + start_index = int(fields[-4]) + end_index = int(fields[-5]) + human_readable = '_'.join(fields[:-6]) # REDFLAG: dci obo? + return (human_readable, start_index, end_index, tip, parent, repo_id) + + +############################################################ +# Stuff moved from updatesm.py, cleanup. +############################################################ + +# Hmmm... do better? +# IIF ends with .R1 second ssk ends with .R0. +# Makes it easy for paranoid people to disable redundant +# top key fetching. ie. just request *R0 instead of *R1. +# Also could intuitively be expanded to higher levels of +# redundancy. +def make_redundant_ssk(usk, version): + """ Returns a redundant ssk pair for the USK version IFF the file + part of usk ends with '.R1', otherwise a single + ssk for the usk specified version. """ + ssk = get_ssk_for_usk_version(usk, version) + fields = ssk.split('-') + if not fields[-2].endswith('.R1'): + return (ssk, ) + #print "make_redundant_ssk -- is redundant" + fields[-2] = fields[-2][:-2] + 'R0' + return (ssk, '-'.join(fields)) + +# For search +def make_search_uris(uri): + """ Returns a redundant USK pair if the file part of uri ends + with '.R1', a tuple containing only uri. """ + if not is_usk_file(uri): + return (uri,) + fields = uri.split('/') + if not fields[-2].endswith('.R1'): + return (uri, ) + #print "make_search_uris -- is redundant" + fields[-2] = fields[-2][:-2] + 'R0' + return (uri, '/'.join(fields)) + +def make_frozen_uris(uri, increment=True): + """ Returns a possibly redundant SSK tuple for the 'frozen' + version of file USK uris, a tuple containing uri for other uris. + + NOTE: This increments the version by 1 if uri is a USK + and increment is True. + """ + if uri == 'CHK@': + return (uri,) + assert is_usk_file(uri) + version = get_version(uri) + return make_redundant_ssk(uri, version + int(bool(increment))) + +def ssk_to_usk(ssk): + """ Convert an SSK for a file USK back into a file USK. """ + fields = ssk.split('-') + end = '/'.join(fields[-2:]) + fields = fields[:-2] + [end, ] + return 'USK' + '-'.join(fields)[3:] + + diff --git a/release/fcpconnection.py b/release/fcpconnection.py new file mode 100644 --- /dev/null +++ b/release/fcpconnection.py @@ -0,0 +1,999 @@ +""" Classes to create a multiplexed asynchronous connection to an + FCP server. + + Copyright (C) 2008 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 + + OVERVIEW: + IAsyncSocket is an abstract interface to an asynchronous + socket. The intent is that client code can plug in a + framework appropriate implementation. i.e. for Twisted, + asyncore, Tkinter, pyQt, pyGtk, etc. A platform agnostic + implementation, PolledSocket is supplied. + + FCPConnection uses an IAsyncSocket delegate to run the + FCP 2.0 protocol over a single socket connection to an FCP server. + + FCPConnection fully(*) supports multiplexing multiple requests. + Client code runs requests by passing an instance of MinimalClient + into FCPConnection.start_request(). The FCPClient MinimalClient + subclass provides convenience wrapper functions for common requests. + + Both blocking and non-blocking client requests are supported. + If MinimalClient.in_params.async == True, FCPConnection.start_connection() + returns a request id string immediately. This is the same request + id which appears in the 'Identifier' field of subsequent incoming. + FCP messages. The MinimalClient.message_callback(client, msg) + callback function is called for every incoming client message for + the request. Async client code can detect the request has finished + by checking client.is_finished() from this callback. + + (*) GOTCHA: If you start a request which writes trailing data + to the FCP server, the FCPConnection will transition into the + UPLOADING state and you won't be able to start new requests until + uploading finishes and it transitions back to the CONNECTED state. + It is recommended that you use a dedicated FCPConnection instance + for file uploads. You don't have to worry about this if you use + blocking requests exclusively. +""" +# REDFLAG: get pylint to acknowledge inherited doc strings from ABCs? + +import os, os.path, random, select, socket, time + +try: + from hashlib import sha1 + def sha1_hexdigest(bytes): + """ Return the SHA1 hexdigest of bytes using the hashlib module. """ + return sha1(bytes).hexdigest() +except ImportError: + # Fall back so that code still runs on pre 2.6 systems. + import sha + def sha1_hexdigest(bytes): + """ Return the SHA1 hexdigest of bytes using the sha module. """ + return sha.new(bytes).hexdigest() + +from fcpmessage import make_request, FCPParser, HELLO_DEF, REMOVE_REQUEST_DEF + +FCP_VERSION = '2.0' # Expected version value sent in ClientHello + +RECV_BLOCK = 4096 # socket recv +SEND_BLOCK = 4096 # socket send +READ_BLOCK = 16 * 1024 # disk read + +MAX_SOCKET_READ = 33 * 1024 # approx. max bytes read during IAsyncSocket.poll() + +POLL_TIME_SECS = 0.25 # hmmmm... + +# FCPConnection states. +CONNECTING = 1 +CONNECTED = 2 +CLOSED = 3 +UPLOADING = 4 + +CONNECTION_STATES = {CONNECTING:'CONNECTING', + CONNECTED:'CONNECTED', + CLOSED:'CLOSED', + UPLOADING:'UPLOADING'} + +def example_state_callback(dummy, state): + """ Example FCPConnection.state_callback function. """ + + value = CONNECTION_STATES.get(state) + if not value: + value = "UNKNOWN" + print "FCPConnection State -> [%s]" % value + +def make_id(): + """ INTERNAL: Make a unique id string. """ + return sha1_hexdigest(str(random.random()) + str(time.time())) + +#-----------------------------------------------------------# +# Byte level socket handling +#-----------------------------------------------------------# + +class IAsyncSocket: + """ Abstract interface for an asynchronous socket. """ + def __init__(self): + # lambda's prevent pylint E1102 warning + + # Data arrived on socket + self.recv_callback = lambda x:None + # Socket closed + self.closed_callback = lambda :None + # Socket wants data to write. This can be None. + self.writable_callback = None + + def write_bytes(self, bytes): + """ Write bytes to the socket. """ + pass + + def close(self): + """ Release all resources associated with the socket. """ + pass + + # HACK to implement waiting on messages. + def poll(self): + """ Do whatever is required to check for new activity + on the socket. + + e.g. run gui framework message pump, explictly poll, etc. + MUST call recv_callback, writable_callback + """ + pass + +class NonBlockingSocket(IAsyncSocket): + """ Base class used for IAsyncSocket implementations based on + non-blocking BSD style sockets. + """ + def __init__(self, connected_socket): + """ REQUIRES: connected_socket is non-blocking and fully connected. """ + IAsyncSocket.__init__(self) + self.buffer = "" + self.socket = connected_socket + + def write_bytes(self, bytes): + """ IAsyncSocket implementation. """ + assert bytes + self.buffer += bytes + #print "write_bytes: ", self.buffer + + def close(self): + """ IAsyncSocket implementation. """ + if self.socket: + self.socket.close() # sync? + self.closed_callback() + self.socket = None + + def do_write(self): + """ INTERNAL: Write to the socket. + + Returns True if data was written, false otherwise. + + REQUIRES: buffer has data or the writable_callback is set. + """ + + assert self.buffer or self.writable_callback + if not self.buffer: + # pylint doesn't infer that this must be set. + # pylint: disable-msg=E1102 + self.writable_callback() + if self.buffer: + chunk = self.buffer[:SEND_BLOCK] + sent = self.socket.send(chunk) + #print "WRITING:", self.buffer[:sent] + assert sent >= 0 + #print "TO_WIRE:" + #print repr(self.buffer[:sent]) + self.buffer = self.buffer[sent:] + return True + assert not self.writable_callback # Hmmmm... This is a client error. + return False + + def do_read(self): + """ INTERNAL: Read from the socket. + + Returns the data read from the socket or None + on EOF. + + Closes on EOF as a side effect. + """ + data = self.socket.recv(RECV_BLOCK) + if not data: + return None + + #print "FROM_WIRE:" + #print repr(data) + return data + + +class PolledSocket(NonBlockingSocket): + """ Sucky polled IAsyncSocket implementation which should + work everywhere. i.e. *nix, Windows, OSX. """ + + def __init__(self, host, port): + connected_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + # REDFLAG: Can block here. + connected_socket.connect((host, port)) + connected_socket.setblocking(0) + NonBlockingSocket.__init__(self, connected_socket) + + def poll(self): + """ IAsyncSocket implementation. """ + #print "PolledSocket.poll -- called" + if not self.socket: + #print "PolledSocket.poll -- CLOSED" + raise IOError("The socket is closed") + # Why? Because we don't want to call the recv_callback while + # reading... wacky re-entrance issues.... + read = '' + ret = True + while len(read) < MAX_SOCKET_READ: # bound read length + check_writable = [] + if self.buffer or self.writable_callback: + check_writable = [self.socket] + readable, writable, errs = \ + select.select([self.socket], check_writable, + [self.socket], 0) + + #print "result:", readable, writable, errs + + stop = True + if errs: + #print "GOT AN ERROR" + # Hack. Force an IO exception. + self.socket.sendall(RECV_BLOCK) + # Err... should never get here. + raise IOError("Unknown socket error") + + if readable: + data = self.do_read() + if not data: + ret = False + break + + read += data + stop = False + + if writable: + if self.do_write(): + stop = False + + if stop: + break + + if read: + self.recv_callback(read) + #print "PolledSocket.poll -- exited" + return ret + +#-----------------------------------------------------------# +# Message level FCP protocol handling. +#-----------------------------------------------------------# + +# NOTE: +# 'DataFound' is sometimes terminal. See msg_is_terminal(). +# +# NOTE: +# This list is not complete. It only lists +# messages generated by supported FCP commands. +# Messages which always indicate that an FCP request ended in success. +SUCCESS_MSGS = frozenset([ \ + 'NodeHello', 'SSKKeypair', 'AllData', 'PutSuccessful', 'NodeData', + ]) + +# Messages which always indicate that an FCP request ended in failure. +FAILURE_MSGS = frozenset([ \ + 'CloseConnectionDuplicateClientName', 'PutFailed', 'GetFailed', + 'ProtocolError', 'IdentifierCollision', 'UnknownNodeIdentifier', + 'UnknownPeerNoteType' + ]) + +# Messages which always indicate that an FCP request ended. +TERMINAL_MSGS = SUCCESS_MSGS.union(FAILURE_MSGS) + +def msg_is_terminal(msg, params): + """ INTERNAL: Return True if the message ends an FCP request, + False otherwise. + """ + + if msg[0] in TERMINAL_MSGS: + return True + + # Special cases + if msg[0] == 'DataFound' and 'ReturnType' in params and \ + params['ReturnType'] == 'none': + return True + + #print "msg_is_terminal: False" + #print "MSG:", msg + #print "PARAMS:", params + + return False + +def get_code(msg): + """ Returns integer error code if msg has a 'Code' field + None otherwise. + """ + + # Hmmmm... does 'Code' ever appear in non-error messages? + #if not msg[0] in FAILURE_MSGS: + # # Message is not an error. + # return None + + if not 'Code' in msg[1]: + if msg[0] in FAILURE_MSGS: + print "WARNING: get_code(msg, code) couldn't read 'Code'." + return None + + return int(msg[1]['Code']) + +def is_code(msg, error_code): + """ Returns True if msg has a 'Code' field and it is + equal to error_code, False, otherwise. + """ + + code = get_code(msg) + if code is None: + return False + return code == error_code + +def is_fatal_error(msg): + """ Returns True if msg has a 'Fatal' field and it + indicates a non-recoverable error, False otherwise. + """ + + value = msg[1].get('Fatal') + if value is None: + return False # hmmm... + return bool(value.lower() == 'true') + +class FCPError(Exception): + """ An Exception raised when an FCP command fails. """ + + def __init__(self, msg): + Exception.__init__(self, msg[0]) + self.fcp_msg = msg + self.last_uri = None + + def __str__(self): + text = "FCPError: " + self.fcp_msg[0] + if self.fcp_msg[1].has_key('CodeDescription'): + text += " -- " + self.fcp_msg[1]['CodeDescription'] + return text + + def is_code(self, error_code): + """ Returns True if the 'Code' field in the FCP error message + is equal to error_code, False, otherwise. + """ + + if not self.fcp_msg or not 'Code' in self.fcp_msg[1]: + # YES. This does happen. + # Hmmmm... just assert? Can this really happen. + print "WARNING: FCPError.is_code() couldn't read 'Code'." + return False + + return is_code(self.fcp_msg, error_code) + +def raise_on_error(msg): + """ INTERNAL: raise an FCPError if msg indicates an error. """ + + assert msg + if msg[0] in FAILURE_MSGS: + raise FCPError(msg) + +class IDataSource: + """ Abstract interface which provides data written up to + the FCP Server as part of an FCP request. """ + def __init__(self): + pass + + def initialize(self): + """ Initialize. """ + raise NotImplementedError() + + def data_length(self): + """ Returns the total length of the data which will be + returned by read(). """ + raise NotImplementedError() + + def release(self): + """ Release all resources associated with the IDataSource + implementation. """ + raise NotImplementedError() + + def read(self): + """ Returns a raw byte block or None if no more data + is available. """ + raise NotImplementedError() + +class FileDataSource(IDataSource): + """ IDataSource implementation which get's its data from a single + file. + """ + def __init__(self, file_name): + IDataSource.__init__(self) + self.file_name = file_name + self.file = None + + def initialize(self): + """ IDataSource implementation. """ + self.file = open(self.file_name, 'rb') + + def data_length(self): + """ IDataSource implementation. """ + return os.path.getsize(self.file_name) + + def release(self): + """ IDataSource implementation. """ + if self.file: + self.file.close() + self.file = None + + def read(self): + """ IDataSource implementation. """ + assert self.file + return self.file.read(READ_BLOCK) + +# MESSAGE LEVEL + +class FCPConnection: + """Class for a single persistent socket connection + to an FCP server. + + Socket level IO is handled by the IAsyncSocket delegate. + + The connection is multiplexed (i.e. it can handle multiple + concurrent client requests). + + """ + + def __init__(self, socket_, wait_for_connect = False, + state_callback = None): + """ Create an FCPConnection from an open IAsyncSocket instance. + + REQUIRES: socket_ ready for writing. + """ + self.running_clients = {} + # Delegate handles parsing FCP protocol off the wire. + self.parser = FCPParser() + self.parser.msg_callback = self.msg_handler + self.parser.context_callback = self.get_context + + self.socket = socket_ + if state_callback: + self.state_callback = state_callback + else: + self.state_callback = lambda x, y: None + self.socket.recv_callback = self.parser.parse_bytes + self.socket.closed_callback = self.closed_handler + + self.node_hello = None + + # Only used for uploads. + self.data_source = None + + # Tell the client code that we are trying to connect. + self.state_callback(self, CONNECTING) + + # Send a ClientHello + params = {'Name':'FCPConnection[%s]' % make_id(), + 'ExpectedVersion': FCP_VERSION} + self.socket.write_bytes(make_request(HELLO_DEF, params)) + if wait_for_connect: + # Wait for the reply + while not self.is_connected(): + if not self.socket.poll(): + raise IOError("Socket closed") + time.sleep(POLL_TIME_SECS) + + def is_connected(self): + """ Returns True if the instance is fully connected to the + FCP Server and ready to process requests, False otherwise. + """ + return not self.node_hello is None + + def is_uploading(self): + """ Returns True if the instance is uploading data, False + otherwise. + """ + return (self.data_source or + self.socket.writable_callback) + + def close(self): + """ Close the connection and the underlying IAsyncSocket + delegate. + """ + if self.socket: + self.socket.close() + + # set_data_length only applies if data_source is set + def start_request(self, client, data_source = None, set_data_length = True): + """ Start an FCP request. + + If in_params.async is True this returns immediately, otherwise + it blocks until the request finishes. + + If client.in_params.send_data is set, trailing data is sent + after the request message. If data_source is not None, then + the data in it is sent. Otherwise if client.in_params.file is + not None, the data in the file is sent. Finally if neither of + the other sources are not None the contents of + client.in_params.send_data are sent. + + If set_data_length is True the 'DataLength' field is set in the + requests FCP message. + + If in_params.async it True, this method returns the identifier + for the request, otherwise, returns the FCP message which + terminated the request. + """ + assert not self.is_uploading() + assert not client.context + assert not client.response + assert not 'Identifier' in client.in_params.fcp_params + identifier = make_id() + client.in_params.fcp_params['Identifier'] = identifier + write_string = False + if client.in_params.send_data: + assert not self.data_source + if data_source: + data_source.initialize() + if set_data_length: + client.in_params.fcp_params['DataLength'] = (data_source. + data_length()) + self.data_source = data_source + self.socket.writable_callback = self.writable_handler + elif client.in_params.file_name: + self.data_source = FileDataSource(client.in_params.file_name) + self.data_source.initialize() + client.in_params.fcp_params['DataLength'] = (self. + data_source. + data_length()) + self.socket.writable_callback = self.writable_handler + else: + client.in_params.fcp_params['DataLength'] = len(client. + in_params. + send_data) + write_string = True + + self.socket.write_bytes(make_request(client.in_params.definition, + client.in_params.fcp_params, + client.in_params. + default_fcp_params)) + + if write_string: + self.socket.write_bytes(client.in_params.send_data) + + assert not client.context + client.context = RequestContext(client.in_params.allowed_redirects, + identifier, + client.in_params.fcp_params.get('URI')) + if not client.in_params.send_data: + client.context.file_name = client.in_params.file_name + + #print "MAPPED [%s]->[%s]" % (identifier, str(client)) + self.running_clients[identifier] = client + + if self.data_source: + self.state_callback(self, UPLOADING) + + if client.in_params.async: + return identifier + + resp = self.wait_for_terminal(client) + raise_on_error(resp) + return client.response + + def remove_request(self, identifier, is_global = False): + """ Cancel a running request. + NOT ALLOWED WHILE UPLOADING DATA. + """ + if self.is_uploading(): + raise Exception("Can't remove while uploading. Sorry :-(") + + if not identifier in self.running_clients: + print "FCPConnection.remove_request -- unknown identifier: ", \ + identifier + params = {'Identifier': identifier, + 'Global': is_global} + self.socket.write_bytes(make_request(REMOVE_REQUEST_DEF, params)) + + def wait_for_terminal(self, client): + """ Wait until the request running on client finishes. """ + while not client.is_finished(): + if not self.socket.poll(): + break + time.sleep(POLL_TIME_SECS) + + # Doh saw this trip 20080124. Regression from NonBlockingSocket changes? + # assert client.response + if not client.response: + raise IOError("No response. Maybe the socket dropped?") + + return client.response + + def handled_redirect(self, msg, client): + """ INTERNAL: Handle code 27 redirects. """ + + # BITCH: This is a design flaw in the FCP 2.0 protocol. + # They should have used unique numbers for all error + # codes so that client coders don't need to keep track + # of the initiating request in order to interpret the + # error code. + if client.in_params.definition[0] == 'ClientGet' and is_code(msg, 27): + #print "Checking for allowed redirect" + if client.context.allowed_redirects: + #print "Handling redirect" + client.context.allowed_redirects -= 1 + assert client.context.initiating_id + assert client.context.initiating_id in self.running_clients + assert client.context.running_id + if client.context.running_id != client.context.initiating_id: + # Remove the context for the intermediate redirect. + #print "DELETED: ", client.context.running_id + del self.running_clients[client.context.running_id] + + client.context.running_id = make_id() + client.context.last_uri = msg[1]['RedirectURI'] + + # Copy, don't modify params. + params = {} + params.update(client.in_params.fcp_params) + params['URI'] = client.context.last_uri + params['Identifier'] = client.context.running_id + + # Send new request. + self.socket.write_bytes(make_request(client.in_params. + definition, params)) + + #print "MAPPED(1) [%s]->[%s]" % (client.context.running_id, + # str(client)) + self.running_clients[client.context.running_id] = client + + # REDFLAG: change callback to include identifier? + # Hmmm...fixup identifier in msg? + if client.message_callback: + client.message_callback(client, msg) + return True + + return False + + + def handle_unexpected_msgs(self, msg): + """ INTERNAL: Process unexpected messages. """ + + if not self.node_hello: + if msg[0] == 'NodeHello': + self.node_hello = msg + self.state_callback(self, CONNECTED) + return True + + raise Exception("Unexpected message before NodeHello: %s" + % msg[0]) + + if not 'Identifier' in msg[1]: + print "Saw message without 'Identifier': %s" % msg[0] + print msg + return True + + if not msg[1]['Identifier'] in self.running_clients: + #print "No client for identifier: %s" % msg[1]['Identifier'] + # BITCH: You get a PersistentRequestRemoved msg even for non + # peristent requests AND you get it after the GetFailed. + #print msg[0] + return True + + return False + + def get_context(self, request_id): + """ INTERNAL: Lookup RequestContexts for the FCPParser delegate. + """ + + client = self.running_clients.get(request_id) + if not client: + raise Exception("No client for identifier: %s" % request_id) + assert client.context + return client.context + + def msg_handler(self, msg): + """INTERNAL: Process incoming FCP messages from the FCPParser delegate. + """ + + if self.handle_unexpected_msgs(msg): + return + + client = self.running_clients[msg[1]['Identifier']] + assert client.is_running() + + if msg_is_terminal(msg, client.in_params.fcp_params): + if self.handled_redirect(msg, client): + return + + # Remove running context entries + assert msg[1]['Identifier'] == client.context.running_id + #print "DELETED: ", client.context.running_id + del self.running_clients[client.context.running_id] + if client.context.running_id != client.context.initiating_id: + #print "DELETED: ", client.context.initiating_id + del self.running_clients[client.context.initiating_id] + + if msg[0] == 'DataFound' or msg[0] == 'AllData': + # REDFLAG: Always do this? and fix FCPError.last_uri? + # Copy URI into final message. i.e. so client + # sees the final redirect not the inital URI. + msg[1]['URI'] = client.context.last_uri + if msg[0] == 'AllData': + # Copy metadata into final message + msg[1]['Metadata.ContentType'] = client.context.metadata + + # Add a third entry to the msg tuple containing the raw data, + # or a comment saying where it was written. + assert len(msg) == 2 + msg = list(msg) + if client.context.data_sink.file_name: + msg.append("Wrote raw data to: %s" \ + % client.context.file_name) + else: + msg.append(client.context.data_sink.raw_data) + msg = tuple(msg) + + + # So that MinimalClient.request_id() returns the + # initiating id correctly even after following + # redirects. + msg[1]['Identifier'] = client.context.initiating_id + + # Reset the context + client.context.release() + client.context = None + + client.response = msg + assert not client.is_running() + else: + if 'Metadata.ContentType' in msg[1]: + # Keep track of metadata as we follow redirects + client.context.metadata = msg[1]['Metadata.ContentType'] + + # Notify client. + if client.message_callback: + client.message_callback(client, msg) + + def closed_handler(self): + """ INTERNAL: Callback called by the IAsyncSocket delegate when the + socket closes. """ + def dropping(data): # REDFLAG: Harmless but remove eventually. + """ INTERNAL: Print warning when data is dropped after close. """ + print "DROPPING %i BYTES OF DATA AFTER CLOSE!" % len(data) + + self.node_hello = None + if not self.socket is None: + self.socket.recv_callback = lambda x:None + self.socket.recv_callback = dropping # Ignore any subsequent data. + + # Hmmmm... other info, ok to share this? + fake_msg = ('ProtocolError', {'CodeDescription':'Socket closed'}) + #print "NOTIFIED: CLOSED" + + # Hmmmm... iterate over values instead of keys? + for identifier in self.running_clients: + client = self.running_clients[identifier] + # Remove client from list of running clients. + #print "CLIENT:", client + #print "CLIENT.CONTEXT:", client.context + assert client.context + assert client.context.running_id + # Notify client that it has stopped. + if (client.context.initiating_id == client.context.running_id + and client.message_callback): + client.message_callback(client, fake_msg) + + self.running_clients.clear() + self.state_callback(self, CLOSED) + + def writable_handler(self): + """ INTERNAL: Callback called by the IAsyncSocket delegate when + it needs more data to write. + """ + + if not self.data_source: + return + data = self.data_source.read() + if not data: + self.data_source.release() + self.data_source = None + self.socket.writable_callback = None + if self.is_connected(): + self.state_callback(self, CONNECTED) + return + self.socket.write_bytes(data) + +# Writes to file if file_name is set, raw_data otherwise +class DataSink: + """ INTERNAL: Helper class used to save trailing data for FCP + messages. + """ + + def __init__(self): + self.file_name = None + self.file = None + self.raw_data = '' + self.data_bytes = 0 + + def initialize(self, data_length, file_name): + """ Initialize the instance. + If file_name is not None the data is written into + the file, otherwise, it is saved in the raw_data member. + """ + # This should only be called once. You can't reuse the datasink. + assert not self.file and not self.raw_data and not self.data_bytes + self.data_bytes = data_length + self.file_name = file_name + + def write_bytes(self, bytes): + """ Write bytes into the instance. + + Multiple calls can be made. The final amount of + data written into the instance MUST be equal to + the data_length value passed into the initialize() + call. + """ + + #print "WRITE_BYTES called." + if self.file_name and not self.file: + self.file = open(self.file_name, 'wb') + + if self.file: + #print "WRITE_BYTES writing to file" + if self.file.closed: + print "FileOrStringDataSink -- refusing to write" \ + + " to closed file!" + return + self.file.write(bytes) + self.data_bytes -= len(bytes) + assert self.data_bytes >= 0 + if self.data_bytes == 0: + self.file.close() + return + + self.raw_data += bytes + self.data_bytes -= len(bytes) + assert self.data_bytes >= 0 + + def release(self): + """ Release all resources associated with the instance. """ + + if self.data_bytes != 0: + print "DataSink.release -- DIDN'T FINISH PREVIOUS READ!", \ + self.data_bytes + if self.file: + self.file.close() + self.file_name = None + self.file = None + self.raw_data = '' + self.data_bytes = 0 + +class RequestContext: + """ INTERNAL: 'Live' context information which an FCPConnection needs + to keep about a single FCP request. + """ + def __init__(self, allowed_redirects, identifier, uri): + self.initiating_id = identifier + self.running_id = identifier + + # Redirect handling + self.allowed_redirects = allowed_redirects + self.last_uri = uri + self.metadata = "" # Hmmm... + + # Incoming data handling + self.data_sink = DataSink() + + def writable(self): + """ Returns the number of additional bytes which can be written + into the data_sink member. + """ + + return self.data_sink.data_bytes + + def release(self): + """ Release all resources associated with the instance. """ + + self.data_sink.release() + + +#-----------------------------------------------------------# +# Client code +#-----------------------------------------------------------# + +# Hmmmm... created separate class because pylint was complaining +# about too many attributes in MinimalClient and FCPClient +class ClientParams: + """ A helper class to aggregate request parameters. """ + + def __init__(self): + self.definition = None + # These are default values which can be modified by the client code. + # THE IMPLEMENTATION CODE i.e. fcp(connection/client/message) + # MUST NOT MODIFY THEM. + self.default_fcp_params = {} + # These are per request values. They can be modified / reset. + self.fcp_params = {} + self.async = False + self.file_name = None + self.send_data = None + self.allowed_redirects = 0 + + def reset(self): + """ Reset all members EXCEPT async, allowed_redirects and + default_fcp_params to their default values. + """ + + self.definition = None + self.fcp_params = {} + self.file_name = None + self.send_data = None + + # HACK: Not really required, but supresses pylint R0903 + def pretty(self): + """Returns a human readable rep of the params. """ + + return "%s: %s %s %s %s %s %s" % \ + ( self.definition[0], + str(self.send_data), + str(self.async), + self.file_name, + self.allowed_redirects, + self.fcp_params, + self.default_fcp_params ) + +class MinimalClient: + """ A single FCP request which can be executed via the + FCPConnection.start_request() method. + + If in_params.async is True the request runs asynchronously, + otherwise it causes FCPConnection.start_request() to block. + + The message_callback notifier function is called for + each incoming FCP message during the request. The first + argument is the client instance. Its is_finished() + method will return True for the final message. message_callback + implementations MUST NOT modify the state of the client + instance while is_finished() is False. + """ + + def __init__(self): + # IN parameters. + self.in_params = ClientParams() + + # OUT parameter + self.response = None + + # Variables used while client request is running. + self.context = None + + # Notification + self.message_callback = lambda client, msg:None + + def reset(self, reset_params = True): + """ Reset all members EXCEPT self.in_params.allowed_redirects, + self.in_params.default_fcp_params and + self.in_params.async to their default values. + """ + assert not self.is_running() + if reset_params: + self.in_params.reset() + self.response = None + self.context = None + + def is_running(self): + """ Returns True if a request is running, False otherwise. """ + + return self.context + + def is_finished(self): + """ Returns True if the request is finished, False otherwise. """ + + return not self.response is None + + def request_id(self): + """ Returns the request id. """ + if self.response and not self.context: + return self.response[1]['Identifier'] + elif self.context: + return self.context.initiating_id + return None diff --git a/release/fcpmessage.py b/release/fcpmessage.py new file mode 100644 --- /dev/null +++ b/release/fcpmessage.py @@ -0,0 +1,333 @@ +""" Classes and functions for creating and parsing FCP messages. + + Copyright (C) 2008 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 + + An FCP message is represented as a + (msg_name, msg_values_dict) tuple. + + Some message e.g. AllData may have a third entry + which contains the raw data string for the FCP + message's trailing data. +""" + +#-----------------------------------------------------------# +# FCP mesage creation helper functions +#-----------------------------------------------------------# + +def merge_params(params, allowed, defaults = None): + """ Return a new dictionary instance containing only the values + which have keys in the allowed field list. + + Values are taken from defaults only if they are not + set in params. + """ + + ret = {} + for param in allowed: + if param in params: + ret[param] = params[param] + elif defaults and param in defaults: + ret[param] = defaults[param] + return ret + +def format_params(params, allowed, required): + """ INTERNAL: Format params into an FCP message body string. """ + + ret = '' + for field in params: + if not field in allowed: + raise ValueError("Illegal field [%s]." % field) + + for field in allowed: + if field in params: + if field == 'Files': + # Special case Files dictionary. + assert params['Files'] + for subfield in params['Files']: + ret += "%s=%s\n" % (subfield, params['Files'][subfield]) + continue + value = str(params[field]) + if not value: + raise ValueError("Illegal value for field [%s]." % field) + if value.lower() == 'true' or value.lower() == 'false': + value = value.lower() + ret += "%s=%s\n" % (field, value) + elif field in required: + #print "FIELD:", field, required + raise ValueError("A required field [%s] was not set." % field) + return ret + +# REDFLAG: remove trailing_data? +def make_request(definition, params, defaults = None, trailing_data = None): + """ Make a request message string from a definition tuple + and params parameters dictionary. + + Values for allowed parameters not specified in params are + taken from defaults if they are present and params IS + UPDATED to include these values. + + A definition tuple has the following entries: + (msg_name, allowed_fields, required_fields, contraint_func) + + msg_name is the FCP message name. + allowed_fields is a sequence of field names which are allowed + in params. + required_fields is a sequence of field names which are required + in params. If this is None all the allowed fields are + assumed to be required. + constraint_func is a function which takes definitions, params + arguments and can raise if contraints on the params values + are not met. This can be None. + """ + + #if 'Identifier' in params: + # print "MAKE_REQUEST: ", definition[0], params['Identifier'] + #else: + # print "MAKE_REQUEST: ", definition[0], "NO_IDENTIFIER" + + #print "DEFINITION:" + #print definition + #print "PARAMS:" + #print params + name, allowed, required, constraint_func = definition + assert name + + real_params = merge_params(params, allowed, defaults) + + # Don't force repetition if required is the same. + if required is None: + required = allowed + + ret = name + '\n' + format_params(real_params, allowed, required) \ + + 'EndMessage\n' + + # Run extra checks on parameter values + # Order is important. Format_params can raise on missing fields. + if constraint_func: + constraint_func(definition, real_params) + + if trailing_data: + ret += trailing_data + + params.clear() + params.update(real_params) + + return ret + +#-----------------------------------------------------------# +# FCP request definitions for make_request() +#-----------------------------------------------------------# + +def get_constraint(dummy, params): + """ INTERNAL: Check get params. """ + if 'ReturnType' in params and params['ReturnType'] != 'disk': + if 'Filename' in params or 'TempFilename' in params: + raise ValueError("'Filename' and 'TempFileName' only allowed" \ + + " when 'ReturnType' is disk.") + +def put_file_constraint(dummy, params): + """ INTERNAL: Check put_file params. """ + # Hmmmm... this only checks for required arguments, it + # doesn't report values that have no effect. + upload_from = 'direct' + if 'UploadFrom' in params: + upload_from = params['UploadFrom'] + if upload_from == 'direct': + if not 'DataLength' in params: + raise ValueError("'DataLength' MUST be set, 'UploadFrom ==" + + " 'direct'.") + elif upload_from == 'disk': + if not 'Filename' in params: + raise ValueError("'Filename' MUST be set, 'UploadFrom ==" + + " 'disk'.") + elif upload_from == 'redirect': + if not 'TargetURI' in params: + raise ValueError("'TargetURI' MUST be set, 'UploadFrom ==" + + " 'redirect'.") + else: + raise ValueError("Unknown value, 'UploadFrom' == %s" % upload_from) + + +HELLO_DEF = ('ClientHello', ('Name', 'ExpectedVersion'), None, None) + +# Identifier not included in doc? +GETNODE_DEF = ('GetNode', ('Identifier', 'GiveOpennetRef', 'WithPrivate', + 'WithVolatile'), + None, None) + +#IMPORTANT: One entry tuple MUST have trailing comma or it will evaluate +# to a string instead of a tuple. +GENERATE_SSK_DEF = ('GenerateSSK', ('Identifier',), None, None) +GET_REQUEST_URI_DEF = ('ClientPut', + ('URI', 'Identifier', 'MaxRetries', 'PriorityClass', + 'UploadFrom', 'DataLength', 'GetCHKOnly'), + None, None) +GET_DEF = ('ClientGet', + ('IgnoreDS', 'DSOnly', 'URI', 'Identifier', 'Verbosity', + 'MaxSize', 'MaxTempSize', 'MaxRetries', 'PriorityClass', + 'Persistence', 'ClientToken', 'Global', 'ReturnType', + 'BinaryBlob', 'AllowedMimeTypes', 'FileName', 'TmpFileName'), + ('URI', 'Identifier'), + get_constraint) +PUT_FILE_DEF = ('ClientPut', + ('URI', 'Metadata.ContentType', 'Identifier', 'Verbosity', + 'MaxRetries', 'PriorityClass', 'GetCHKOnly', 'Global', + 'DontCompress','ClientToken', 'Persistence', + 'TargetFilename', 'EarlyEncode', 'UploadFrom', 'DataLength', + 'Filename', 'TargetURI', 'FileHash', 'BinaryBlob'), + ('URI', 'Identifier'), + put_file_constraint) +PUT_REDIRECT_DEF = ('ClientPut', + ('URI', 'Metadata.ContentType', 'Identifier', 'Verbosity', + 'MaxRetries', 'PriorityClass', 'GetCHKOnly', 'Global', + 'ClientToken', 'Persistence', 'UploadFrom', + 'TargetURI'), + ('URI', 'Identifier', 'TargetURI'), + None) +PUT_COMPLEX_DIR_DEF = ('ClientPutComplexDir', + ('URI', 'Identifier', 'Verbosity', + 'MaxRetries', 'PriorityClass', 'GetCHKOnly', 'Global', + 'DontCompress', 'ClientToken', 'Persistence', + 'TargetFileName', 'EarlyEncode', 'DefaultName', + 'Files'), #<- one off code in format_params() for this + ('URI', 'Identifier'), + None) + +REMOVE_REQUEST_DEF = ('RemoveRequest', ('Identifier', 'Global'), None, None) + +# REDFLAG: Shouldn't assert on bad data! raise instead. +# Hmmmm... I hacked this together by unwinding a "pull" parser +# to make a "push" parser. Feels like there's too much code here. +class FCPParser: + """Parse a raw byte stream into FCP messages and trailing data blobs. + + Push bytes into the parser by calling FCPParser.parse_bytes(). + Set FCPParser.msg_callback to get the resulting FCP messages. + Set FCPParser.context_callback to control how trailing data is written. + See RequestContext in the fcpconnection module for an example of how + contexts are supposed to work. + + NOTE: This only handles byte level presentation. It DOES NOT validate + that the incoming messages are correct w.r.t. the FCP 2.0 spec. + """ + def __init__(self): + self.msg = None + self.prev_chunk = "" + self.data_context = None + + # lambda's prevent pylint E1102 warning + # Called for each parsed message. + self.msg_callback = lambda msg:None + + # MUST set this callback. + # Return the RequestContext for the request_id + self.context_callback = None #lambda request_id:RequestContext() + + def handle_line(self, line): + """ INTERNAL: Process a single line of an FCP message. """ + if not line: + return False + + if not self.msg: + # Start of a new message + self.msg = [line, {}] + return False + + pos = line.find('=') + if pos != -1: + # name=value pair + fields = (line[:pos], line[pos + 1:]) + # CANNOT just split + # fields = line.split('=') + # e.g. + # ExtraDescription=Invalid precompressed size: 81588 maxlength=10 + assert len(fields) == 2 + self.msg[1][fields[0].strip()] = fields[1].strip() + else: + # end of message line + if line == 'Data': + # Handle trailing data + assert self.msg + # REDFLAG: runtime protocol error (should never happen) + assert 'Identifier' in self.msg[1] + assert not self.data_context + self.data_context = self.context_callback(self.msg[1] + ['Identifier']) + self.data_context.data_sink.initialize(int(self.msg[1] + ['DataLength']), + self.data_context. + file_name) + return True + + assert line == 'End' or line == 'EndMessage' + msg = self.msg + self.msg = None + assert not self.data_context or self.data_context.writable() == 0 + self.msg_callback(msg) + + return False + + def handle_data(self, data): + """ INTERNAL: Handle trailing data following an FCP message. """ + #print "RECVD: ", len(data), "bytes of data." + assert self.data_context + self.data_context.data_sink.write_bytes(data) + if self.data_context.writable() == 0: + assert self.msg + msg = self.msg + self.msg = None + self.data_context = None + self.msg_callback(msg) + + def parse_bytes(self, bytes): + """ This method drives an FCP Message parser and eventually causes + calls into msg_callback(). + """ + #print "FCPParser.parse_bytes -- called" + if self.data_context and self.data_context.writable(): + # Expecting raw data. + assert not self.prev_chunk + data = bytes[:self.data_context.writable()] + self.handle_data(data) # MUST handle msg notification! + bytes = bytes[len(data):] + if bytes: + # Hmmm... recursion depth + self.parse_bytes(bytes) + else: + # Expecting a \n terminated line. + bytes = self.prev_chunk + bytes + self.prev_chunk = "" + last_eol = -1 + pos = bytes.find('\n') + while pos != -1: + if last_eol <= 0: + last_eol = 0 + + line = bytes[last_eol:pos].strip() + last_eol = pos + if self.handle_line(line): + # Reading trailing data + # Hmmm... recursion depth + self.parse_bytes(bytes[last_eol + 1:]) + return + pos = bytes.find('\n', last_eol + 1) + + assert not self.data_context or not self.data_context.writable() + self.prev_chunk = bytes[last_eol + 1:] + diff --git a/release/fms_message_template.txt b/release/fms_message_template.txt new file mode 100644 --- /dev/null +++ b/release/fms_message_template.txt @@ -0,0 +1,13 @@ +jfniki has been updated to version: __HEAD__ + +freesite: +__SITE_USK__ + +plugin jar: +__JAR_CHK__ + +source code: +__SRC_CHK__ + +summary: +__RELEASE_NOTES__ diff --git a/release/generated_freesite/activelink.png b/release/generated_freesite/activelink.png new file mode 100644 index 0000000000000000000000000000000000000000..a3b7c48e68e312742bcc4d65665dd50bac164818 GIT binary patch literal 2019 zc$@*_2ORi`P)<h;3K|Lk000e1NJLTq003+N001Ni1^@s6Y;E7n00001b5ch_0Itp) z=>Px#24YJ`L;(K){{a7>y{D4^000SaNLh0L01FcU01FcV0GgZ_000MeNkl<ZXx{Cb zdvKIj8Hb;}-*>Y~mLx0*4FM65P_#g`B1o|)j<qtqQE2Hn&>1PVwM<hTP$pUjT4tOn zMJW`e6gw#blVS@qnJN)Yicu~~AtnvEu*rrdySeNw*~|Cs@sGlWkT3y~HVf=;X8$?o z?D@{wXWz?t&!-SV2vWLfq)hz;DPT#bK#z>SN4_QYqP~?ubq6J0D|K`Bd<dmN!K2?C z!~I{&NC8VW`9l(K9J)ZWH_D>B?7Z+uE}^i@;~Q&O_?0w_I#mi-vgr870bVX?=Be*y zv-aWKK5Gj}j7ig=(<oCGmMq$T)I;T2Kd-Kt$k+^RqFs^&zu478(W7~&l!_FvWYAwv zb#b_=lixiz3B6X?57w{WKF{4#txO$nOl675psN4**<0@6x6k&6B?PSB)x@N6Mt=D1 z>{OOG8tRF}Q7b`vF&0(T_<6IejaQzUfI-&>tHl#x>p?e@bB#Q<V9YJ!_HPPcQ7O(} zPIUABKRamgM$u^$jLXyk;Pu7W_S_`Q235kUet-A^?^SiO?y+2Ef6;nNB~^-KE=@&a zab7EJrM9JquTQse&onE}OdXOeXz@l7LQs&SPgq-^SLSCs8<{=X!jid}35Zv<*04-l zSB(4|eG1rcC4ddPni%WQvhq7Qs8vH}cdzViMypY<?A|Q=JrY8I0#GUyq+8V3OnnZe zwz-#|?`lM&Qjn3Rp?Jj|*v;w`up8Bdjwr>uo7ndJ7r53$+|wPSq`aN3fW-8C6MCHz zA>dqV568{~D4byANB29iThu(grH)mLbC^+Jrt*xB4^DJb{N(sl3>%JxDOJ~ZW^$xy zc;*MrL3+|zwOFRK!plp4bP<yT_s+C)=R`9A+38w*p;*c#4hxuo;<uWpYYp?QFQ@Un zuV#>8)r`oza$F=_Y<E!N`Pb`t;o)4S<QY@IhN~7&gk#ly{#_d&)23nVgX2)CMy6Wf z^~d<>&PG-&%wle#EoEUNspj?w&up#Z?R8Tz>PF_gzp<p51v72TxrtK$q)&_7-U#nj zc2IfN$4?&29Wht{tsP;UnK##=I%zCSk_C+y!nAfoIMWp3oI8wOrz94O^QC+f56yF6 z(vR%5$o``qx<e8x7w4paU8{n<GH;fBLghz(EG9Ke=4MbZ)<BkBi%GBiOsng%EGXV{ zo~}TQ^~=YTW!I*FT}{1_INRQ9#nm2RUZIWocczhMQll7zPqQb&>G~jEzeI;G#wT4- z6bc1db`2R;4Mv@kaSk05odz7X>(3<rRTJQy@^-8y6;Ixm%^lhL6tF9`sl<iJpkmGU zodY0AvY@RiN@+z0ryGI<du3)6n3+GrMw(fT#rUaf425Mn{Sv1eL)5qS;%bXxHL5V` zRX8&>%$Z`PV5|YNUWHMo95TE7@T`w5rEX?Vw(`V%S!CKYDPWiM;u{UTylTRL_Cr-2 zyi@L>V61`pGi(&*o3NVHLzbq*MHeT@g6@!nt1V1>SBz>`4`&)fC=@U`*T}5N7UmV& z67|O`6_w(gstZzbq#Z|^mS>kZhpb877Qoj1p@|HOn!Bf3QK>cj{mA?LsjLo_&O+(y zYsj%*f9yJN-~h8`%_1)^Z$PZ-2uRepdN^{b3tvdaWKc0@iiMd4X2v;m1Llgy1^bVB zDLdZDibYv`qcAO5Dhi{cNK6+0ITsLr`){|{y1PtVS_&Wz9XfPFa{^-b?%hL<ljOMY zbVtPpC%VKVFI9`89rdC!5F2!?KO~8#U#%0H_qs$f=<^5mX?3XI=h(4h31FKyZx#&= z4MR>7i^as1|F@xlWn^TCg$oxBfcKA&y2az0YDC%b&OyH?%W<*u@CC7E%f})dm2Wd( zmxB{Rc)eaxT3RXq;rII!-O*@NxZQ5i+uJMLZnua;B8k3WFeuz^w+IG<R|V$rctmGs zAAsiOX5sVsL^vECbUC43Nj$N!M(jJ<F5*{Na%qJ}ytt!&RDq59I~}d9t+?H80BUM# z6745Xp2X>NQdCq#b#*lsizU(L^ZBT)t;K9M59Z<t27@>p4x-U$qP@Ppo@vvjv3&XR zLDyl_tJtx25;d+KN{+M*I_80UGKtA>xT^E^i(%rjM^{u-3~+B%RaO1kmMvQ*^7Hfi zT$v;d>Jp2Kiv=LU;joCu``gO&>C?s1rAvp5Z$A#IN==2}vGkYn}d>_|3kS+rYvU z!>d??!(r@p`;g-{Y}f!mdwV;I8+fKlsbJH}d;|)L-*OG;x0zHJ^`rN0<LB!V6(hG% zm1^*_(Y}5AkR*w`ygbUw%lmIusYIjET+=ewFU!M`rskJ>&U5*}=j6E{zMy;?0K2qn zg<=3WwOWl<s~ym$R`*>dn9XLaR_nzn6c`K!GBY#L>-7msN>5KmmSt2b6+#FWELeaL zg4L^6qu1+kxm+APco2iZ!1nFihlKXy2b^T5YgxOko?x#`I2vc$pIccp%RXwrZkqeB z(NyIbAMbwHL03Rx(Oq^Pn&(IXODP#+SW3gz{{ip2$WdDnSP}pL002ovPDHLkV1nax B+T#EK diff --git a/release/generated_freesite/index.html b/release/generated_freesite/index.html new file mode 100644 diff --git a/release/index_template.html b/release/index_template.html new file mode 100644 --- /dev/null +++ b/release/index_template.html @@ -0,0 +1,48 @@ +<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" + "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"> +<html xmlns="http://www.w3.org/1999/xhtml"> +<head> +<title>jfniki version: __HEAD__</title> +</head> +<body> +<h4>jfniki version: __HEAD__</h4> +<div> +<code>jfniki plugin.jar</code>:<br/> +<pre><code><a href="/__JAR_CHK__">__JAR_CHK__</a></code></pre><p/> +<code>source code tarball:</code><br/> +<pre><code><a href="/__SRC_CHK__">__SRC_CHK__</a></code></pre><p/> +<a href="/USK@Gq-FBhpgvr11VGpapG~y0rGFOAHVfzyW1WoKGwK-fFw,MpzFUh5Rmw6N~aMKwm9h2Uk~6aTRhYaY0shXVotgBUc,AQACAAE/fniki/__INDEX_FNIKI__/HgInfocalypse">hg infocalpse</a> repo:<br/> +<code> +USK@kRM~jJVREwnN2qnA8R0Vt8HmpfRzBZ0j4rHC2cQ-0hw,2xcoQVdQLyqfTpF2DpkdUIbHFCeL4W~2X1phUYymnhM,AQACAAE/jfniki.R1/__INDEX_REPO__ +</code> +</div> +<div> +<h4>release notes</h4> +<pre><code>__RELEASE_NOTES__</code></pre> +<h4>about jfniki</h4> +<ul> + <li>The <a href = "/USK@UB0RPdoXvA61bfDbpvIVFyft1JiqUPhTLONHsWVGU0k,gFG9I3679g-1dUZvOorUuudr~JvSgRemmMdfiPxxcY8,AQACAAE/freenetdocwiki_mirror/__INDEX_FDW__/">Freenet Doc Wiki Mirror</a> +has good documentation on jfniki. Activelink: <br/> + <img src = "/USK@UB0RPdoXvA61bfDbpvIVFyft1JiqUPhTLONHsWVGU0k,gFG9I3679g-1dUZvOorUuudr~JvSgRemmMdfiPxxcY8,AQACAAE/freenetdocwiki_mirror/__INDEX_FDW__/activelink.png" alt = "[FWDM activelink]" + width="108" height="36"/> + </li> + <li> The + <a href="/USK@Gq-FBhpgvr11VGpapG~y0rGFOAHVfzyW1WoKGwK-fFw,MpzFUh5Rmw6N~aMKwm9h2Uk~6aTRhYaY0shXVotgBUc,AQACAAE/fniki/__INDEX_FNIKI__/JfnikiProtoType">JfnikiProtoType</a> page in the old wikibot based wiki + has some technical info. + </li> + <li> author: <code>djk@isFiaD04zgAgnrEC5XJt1i4IE7AkNPqhBG5bONi6Yks</code> </li> +</ul> +</div> +<div> +<br/> +<br/> +<br/> +</div> +<div> +<h4>site info</h4> +<img src="activelink.png" alt="[jfniki releases activelink.png]" width="108" height="36"/> +<p> +<a href="/?newbookmark=__SITE_USK__&desc=jfniki releases">[bookmark!]</a> +</div> +</body> +</html> diff --git a/release/insert_files.py b/release/insert_files.py new file mode 100644 --- /dev/null +++ b/release/insert_files.py @@ -0,0 +1,52 @@ +import time +from fcpconnection import FCPConnection, PolledSocket +from fcpclient import FCPClient + +def insert_files(host, port, names): + socket = PolledSocket(host, port) + connection = FCPConnection(socket, True) + + inserts = [] + for name in names: + client = FCPClient(connection) + client.in_params.default_fcp_params['MaxRetries'] = 3 + client.in_params.default_fcp_params['PriorityClass'] = 1 + + inserts.append(client) + client.in_params.async = True + parts = name.split('/') + + client.put_file('CHK@' + '/' + parts[-1], name) + # Hmmmm... Ugly. Add FCPConnection.wait_until_upload_finishes() ? + while connection.is_uploading(): + socket.poll() + time.sleep(.25) + + while min([insert.is_finished() for insert in inserts]) == False: + if not socket.poll(): + break + time.sleep(.25) + + uris = [] + for insert in inserts: + if insert.response == None or len(insert.response) < 2 or insert.response[0] <> 'PutSuccessful': + uris.append(None) + continue + + uris.append(insert.response[1]['URI']) + + return uris + +FCP_HOST = '127.0.0.1' +FCP_PORT = 19481 +FILES = ['/tmp/0.txt', '/tmp/1.txt', '/tmp/2.txt' ] + +def main(): + uris = insert_files(FCP_HOST, FCP_PORT, FILES) + for name, uri in zip(FILES, uris): + print "---" + print name + print uri + print "---" + +#main() diff --git a/release/minimalfms.py b/release/minimalfms.py new file mode 100644 --- /dev/null +++ b/release/minimalfms.py @@ -0,0 +1,66 @@ +""" Code to send fms messages pillaged from hg infocalypse codebase. + + Copyright (C) 2011 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 + +def get_connection(fms_host, fms_port, user_name): + """ Create an fms NNTP connection. """ + return nntplib.NNTP(fms_host, fms_port, user_name) + +MSG_TEMPLATE = """From: %s +Newsgroups: %s +Subject: %s + +%s""" + +# Please use this function for good and not evil. +def send_msgs(server, msg_tuples, send_quit=False): + """ Send messages via fms. + msg_tuple format is: (sender, group, subject, text, send_callback) + + send_callback is optional. + + If it is present and not None send_callback(message_tuple) + is invoked after each message is sent. + + It is legal to include additional client specific fields. + """ + + 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) + try: + server.post(in_file) + + if len(msg_tuple) > 4 and not msg_tuple[4] is None: + # Sent notifier + msg_tuple[4](msg_tuple) + + if send_quit: + server.quit() + finally: + in_file.close() +