import sys.Errno.EEXIST;
import sys.FileImplicits._;
+import progress.{Eyecandy, SimpleModel, DataModel};
+
/*----- Useful regular expressions ----------------------------------------*/
-private val RX_COMMENT = """(?x) ^ \s* (?: \# .* )? $""".r;
-private val RX_KEYVAL = """(?x) ^ \s*
+private final val RX_COMMENT = """(?x) ^ \s* (?: \# .* )? $""".r;
+private final val RX_KEYVAL = """(?x) ^ \s*
([-\w]+)
(?:\s+(?!=)|\s*=\s*)
(|\S|\S.*\S)
\s* $""".r;
-private val RX_DOLLARSUBST = """(?x) \$ \{ ([-\w]+) \}""".r;
+private final val RX_DOLLARSUBST = """(?x) \$ \{ ([-\w]+) \}""".r;
/*----- Things that go wrong ----------------------------------------------*/
"sig-fresh" -> { _ => "always" },
"fingerprint-hash" -> { _("hash") });
-/*----- Managing a key repository -----------------------------------------*/
+private def readConfig(file: File): Config = {
-def downloadToFile(file: File, url: URL, maxlen: Long = Long.MaxValue) {
- fetchURL(url, new URLFetchCallbacks {
- val out = file.openForOutput();
- private def toobig() {
- throw new KeyConfigException(s"remote file `$url' is " +
- "suspiciously large");
- }
- var totlen: Long = 0;
- override def preflight(conn: URLConnection) {
- totlen = conn.getContentLength;
- if (totlen > maxlen) toobig();
+ /* Build the new configuration in a temporary place. */
+ var m = HashMap[String, String]();
+
+ /* Read the config file into our map. */
+ file.withReader { in =>
+ var lno = 1;
+ for (line <- lines(in)) {
+ line match {
+ case RX_COMMENT() => ok;
+ case RX_KEYVAL(key, value) => m += key -> value;
+ case _ =>
+ throw new ConfigSyntaxError(file.getPath, lno,
+ "failed to parse line");
+ }
+ lno += 1;
}
- override def done(win: Boolean) { out.close(); }
- def write(buf: Array[Byte], n: Int, len: Long) {
- if (len + n > maxlen) toobig();
- out.write(buf, 0, n);
+ }
+
+ /* Fill in defaults where things have been missed out. */
+ for ((key, dflt) <- DEFAULTS) {
+ if (!(m contains key)) {
+ try { m += key -> dflt(m); }
+ catch {
+ case e: DefaultFailed =>
+ throw new ConfigDefaultFailed(file.getPath, key,
+ e.key, m(e.key));
+ }
}
- });
+ }
+
+ /* And we're done. */
+ m
+}
+
+/*----- Managing a key repository -----------------------------------------*/
+
+def downloadToFile(file: File, url: URL,
+ maxlen: Long = Long.MaxValue,
+ ic: Eyecandy) {
+ ic.job(new SimpleModel(s"connecting to `$url'", -1)) { jr =>
+ fetchURL(url, new URLFetchCallbacks {
+ val out = file.openForOutput();
+ private def toobig() {
+ throw new KeyConfigException(
+ s"remote file `$url' is suspiciously large");
+ }
+ var totlen: Long = 0;
+ override def preflight(conn: URLConnection) {
+ totlen = conn.getContentLength;
+ if (totlen > maxlen) toobig();
+ jr.change(new SimpleModel(s"downloading `$url'", totlen)
+ with DataModel,
+ 0);
+ }
+ override def done(win: Boolean) { out.close(); }
+ def write(buf: Array[Byte], n: Int, len: Long) {
+ if (len + n > maxlen) toobig();
+ out.write(buf, 0, n);
+ jr.step(len + n);
+ }
+ })
+ }
}
/* Lifecycle notes
* (delete old/)
*/
+class RepositoryStateException(val state: Repository.State.Value,
+ msg: String)
+ extends Exception(msg);
+
+class KeyConfigException(msg: String) extends Exception(msg);
+
+private def launderFingerprint(fp: String): String =
+ fp filter { _.isLetterOrDigit };
+
+private def fingerprintsEqual(a: String, b: String) =
+ launderFingerprint(a) == launderFingerprint(b);
+
+private def keyFingerprint(kr: File, tag: String, hash: String): String = {
+ val (out, _) = runCommand("key", "-k", kr.getPath, "fingerprint",
+ "-a", hash, "-f", "-secret", tag);
+ nextToken(out) match {
+ case Some((fp, _)) => fp
+ case _ =>
+ throw new java.io.IOException("unexpected output from `key fingerprint");
+ }
+}
+
object Repository {
object State extends Enumeration {
val Empty, Pending, Confirmed, Updating, Committing, Live = Value;
}
}
-class RepositoryStateException(val state: Repository.State.Value,
- msg: String)
- extends Exception(msg);
+def checkConfigSanity(file: File, ic: Eyecandy) {
+ ic.operation("checking new configuration") { _ =>
-class KeyConfigException(msg: String) extends Exception(msg);
+ /* Make sure we can read and understand the file. */
+ val conf = readConfig(file);
+
+ /* Make sure there are entries which we can use to update. This won't
+ * guarantee that we can reliably update, but it will help.
+ */
+ conf("repos-url"); conf("sig-url");
+ conf("fingerprint-hash"); conf("sig-fresh");
+ conf("master-sequence"); conf("hk-master");
+ }
+}
class Repository(val root: File) extends Closeable {
import Repository.State.{Value => State, _};
/* Important directories and files. */
- private[this] val livedir = root + "live";
- private[this] val livereposdir = livedir + "repos";
- private[this] val newdir = root + "new";
- private[this] val olddir = root + "old";
- private[this] val pendingdir = root + "pending";
- private[this] val tmpdir = root + "tmp";
+ private[this] val livedir = root/"live";
+ private[this] val livereposdir = livedir/"repos";
+ private[this] val newdir = root/"new";
+ private[this] val olddir = root/"old";
+ private[this] val pendingdir = root/"pending";
+ private[this] val tmpdir = root/"tmp";
/* Take out a lock in case of other instances. */
private[this] val lock = {
try { root.mkdir_!(); }
catch { case SystemError(EEXIST, _) => ok; }
- (root + "lk").lock_!()
+ (root/"lk").lock_!()
}
def close() { lock.close(); }
}
} }
- def destroy() {
+ def destroy(ic: Eyecandy) {
/* Clear out the entire repository. Everything. It's all gone. */
- root.foreachFile { f => if (f.getName != "lk") f.rmTree(); }
+ ic.operation("clearing configuration")
+ { _ => root.foreachFile { f => if (f.getName != "lk") f.rmTree(); } }
}
def clearTmp() {
case Empty =>
throw new RepositoryStateException(Empty, "repository is Empty");
}
- val file = dir + "tripe-keys.conf";
-
- /* Build the new configuration in a temporary place. */
- var m = HashMap[String, String]();
-
- /* Read the config file into our map. */
- file.withReader { in =>
- var lno = 1;
- for (line <- lines(in)) {
- line match {
- case RX_COMMENT() => ok;
- case RX_KEYVAL(key, value) => m += key -> value;
- case _ =>
- throw new ConfigSyntaxError(file.getPath, lno,
- "failed to parse line");
- }
- lno += 1;
- }
- }
-
- /* Fill in defaults where things have been missed out. */
- for ((key, dflt) <- DEFAULTS) {
- if (!(m contains key)) {
- try { m += key -> dflt(m); }
- catch {
- case e: DefaultFailed =>
- throw new ConfigDefaultFailed(file.getPath, key,
- e.key, m(e.key));
- }
- }
- }
- /* All done. */
- _config = m;
+ /* And then read the configuration. */
+ _config = readConfig(dir/"tripe-keys.conf");
}
_config
}
- def fetchConfig(url: URL) {
+ def fetchConfig(url: URL, ic: Eyecandy) {
/* Fetch an initial configuration file from a given URL. */
checkState(Empty);
clearTmp();
- downloadToFile(tmpdir + "tripe-keys.conf", url);
- tmpdir.rename_!(pendingdir);
+
+ val conffile = tmpdir/"tripe-keys.conf";
+ downloadToFile(conffile, url, 16*1024, ic);
+ checkConfigSanity(conffile, ic);
+ ic.operation("committing configuration")
+ { _ => tmpdir.rename_!(pendingdir); }
invalidate(); // should move to `Pending'
}
- def confirm() {
+ def confirm(ic: Eyecandy) {
/* The user has approved the master key fingerprint in the `Pending'
* configuration. Advance to `Confirmed'.
*/
checkState(Pending);
- pendingdir.rename_!(livedir);
+ ic.operation("confirming configuration")
+ { _ => pendingdir.rename_!(livedir); }
invalidate(); // should move to `Confirmed'
}
- def update() {
+ def update(ic: Eyecandy) {
/* Update the repository from the master.
*
* Fetch a (possibly new) archive; unpack it; verify the master key
clearTmp();
/* First thing is to download the tarball and signature. */
- val tarfile = tmpdir + "tripe-keys.tar.gz";
- downloadToFile(tarfile, new URL(conf("repos-url")));
- val sigfile = tmpdir + "tripe-keys.sig";
+ val tarfile = tmpdir/"tripe-keys.tar.gz";
+ downloadToFile(tarfile, new URL(conf("repos-url")), 256*1024, ic);
+ val sigfile = tmpdir/"tripe-keys.sig";
val seq = conf("master-sequence");
downloadToFile(sigfile,
new URL(conf("sig-url").replaceAllLiterally("<SEQ>",
- seq)));
+ seq)),
+ 4*1024, ic);
/* Unpack the tarball. Carefully. */
- val unpkdir = tmpdir + "unpk";
- unpkdir.mkdir_!();
- withCleaner { clean =>
- val tar = new TarFile(new GZIPInputStream(tarfile.open()));
- clean { tar.close(); }
- for (e <- tar) {
-
- /* Check the filename to make sure it's not evil. */
- if (e.name.split('/').exists { _ == ".." })
- throw new KeyConfigException("invalid path in tarball");
-
- /* Find out where this file points. */
- val f = unpkdir + e.name;
-
- /* Unpack it. */
- if (e.isdir) {
- /* A directory. Create it if it doesn't exist already. */
-
- try { f.mkdir_!(); }
- catch { case SystemError(EEXIST, _) => ok; }
- } else if (e.isreg) {
- /* A regular file. Write stuff to it. */
-
- e.withStream { in =>
- f.withOutput { out =>
- for ((b, n) <- blocks(in)) out.write(b, 0, n);
+ val unpkdir = tmpdir/"unpk";
+ ic.operation("unpacking archive") { or =>
+ unpkdir.mkdir_!();
+ withCleaner { clean =>
+ val tar = new TarFile(new GZIPInputStream(tarfile.open()));
+ clean { tar.close(); }
+ for (e <- tar) {
+
+ /* Check the filename to make sure it's not evil. */
+ if (e.name(0) == '/' || e.name.split('/').exists { _ == ".." })
+ throw new KeyConfigException("invalid path in tarball");
+
+ /* Report on progress. */
+ or.step(s"entry `${e.name}'");
+
+ /* Find out where this file points. */
+ val f = unpkdir/e.name;
+
+ /* Unpack it. */
+ if (e.isdir) {
+ /* A directory. Create it if it doesn't exist already. */
+
+ try { f.mkdir_!(); }
+ catch { case SystemError(EEXIST, _) => ok; }
+ } else if (e.isreg) {
+ /* A regular file. Write stuff to it. */
+
+ e.withStream { in =>
+ f.withOutput { out =>
+ for ((b, n) <- blocks(in)) out.write(b, 0, n);
+ }
}
- }
- } else {
- /* Something else. Be paranoid and reject it. */
+ } else {
+ /* Something else. Be paranoid and reject it. */
- throw new KeyConfigException("unexpected object type in tarball");
+ throw new KeyConfigException(
+ s"entry `${e.name}' has unexpected object type");
+ }
}
}
}
/* There ought to be a file in here called `repos/master.pub'. */
- val reposdir = unpkdir + "repos";
+ val reposdir = unpkdir/"repos";
+ val masterfile = reposdir/"master.pub";
+
if (!reposdir.isdir_!)
throw new KeyConfigException("missing `repos/' directory");
- val masterfile = reposdir + "master.pub";
if (!masterfile.isreg_!)
throw new KeyConfigException("missing `repos/master.pub' file");
+ val mastertag = s"master-$seq";
/* Fetch the master key's fingerprint. */
- val (out, _) = runCommand("key", "-k", masterfile.getPath,
- "fingerprint",
- "-f", "-secret",
- "-a", conf("fingerprint-hash"),
- s"master-$seq");
- println(s";; $out");
+ ic.operation("checking master key fingerprint") { _ =>
+ val foundfp = keyFingerprint(masterfile, mastertag,
+ conf("fingerprint-hash"));
+ val wantfp = conf("hk-master");
+ if (!fingerprintsEqual(wantfp, foundfp)) {
+ throw new KeyConfigException(
+ s"master key #$seq has wrong fingerprint: " +
+ s"expected $wantfp but found $foundfp");
+ }
+ }
+
+ /* Check the archive signature. */
+ ic.operation("verifying archive signature") { or =>
+ runCommand("catsign", "-k", masterfile.getPath, "verify", "-aqC",
+ "-k", mastertag, "-t", conf("sig-fresh"),
+ sigfile.getPath, tarfile.getPath);
+ }
+
+ /* Confirm that the configuration in the new archive is sane. */
+ checkConfigSanity(unpkdir/"tripe-keys.conf", ic);
+
+ /* Now we just have to juggle the files about. */
+ ic.operation("committing new configuration") { _ =>
+ unpkdir.rename_!(newdir);
+ livedir.rename_!(olddir);
+ newdir.rename_!(livedir);
+ }
+
+ invalidate(); // should move to `Live'
}
}