qwgit / lib / remote-certs.js
http{/,s} git server
git clone http://git.nthia.dev/qwgit

const EventEmitter = require('events')
const fs = require('fs')
const path = require('path')
const { randomBytes } = require('crypto')
const { nextTick } = process

module.exports = function (file, rdir, cb) {
  cb = once(cb ?? noop)
  let src = null
  let pending = 3
  fs.mkdir(rdir, { recursive: true }, err => {
    if (err) cb(err)
    else if (--pending === 0) ready()
  })
  fs.readFile(file, 'utf8', (err,src_) => {
    src = src_
    if (err && err.code !== 'ENOENT') cb(err)
    else if (--pending === 0) ready()
  })
  if (--pending === 0) ready()
  function ready() {
    let data = null
    try { data = JSON.parse(src || '[]') }
    catch (err) { return cb(`error parsing json in ${file}: ${err}`) }
    if (!Array.isArray(data)) data = []
    cb(null, new RemoteCerts(file, rdir, data))
  }
}

function RemoteCerts(file, rdir, data) {
  if (!(this instanceof RemoteCerts)) return new RemoteCerts(file, data)
  this._file = file
  this._dir = rdir
  this._byFingerprint = new Map
  this._byHostname = new Map
  this._fsQueue = []
  data.forEach(row => this._add(row))
}

RemoteCerts.prototype = Object.create(EventEmitter.prototype)

RemoteCerts.prototype._simplifyQueue = function () {
  for (let i = 0; i < this._fsQueue.length; i++) {
    let a = this._fsQueue[i]
    for (let j = i+1; j < this._fsQueue.length; j++) {
      let b = this._fsQueue[j]
      if (a.file === b.file) a._remove = true
    }
  }
  this._fsQueue = this._fsQueue.filter(x => !x._remove)
}

RemoteCerts.prototype.flush = function (cb) {
  cb = once(cb ?? noop)
  let data = this.list()
  let pending = 1 + this._fsQueue.length
  this._simplifyQueue()
  fs.writeFile(this._file, JSON.stringify(data,null,2)+'\n', done)
  for (let i = 0; i < this._fsQueue.length; i++) {
    let q = this._fsQueue[i]
    if (q.type === 'write') {
      fs.writeFile(q.file, q.data, done)
    } else if (q.type === 'delete') {
      fs.unlink(q.file, done)
    } else {
      throw new Error('unexpected fs queue type: ' + q.type)
    }
  }
  done()

  function done(err) {
    if (err) cb(err)
    else if (--pending === 0) cb(null)
  }
}

RemoteCerts.prototype.add = function (row) {
  let fp = normfp(row.fingerprint512 ?? row.fingerprint)
  let host = row.hostname
  if (/[\s'"]/.test(host)) throw new Error('invalid characters in host')
  if (/[\s'"]/.test(fp)) throw new Error('invalid characters in fingerprint')
  let rec = { fingerprint512: fp, hostname: host }
  if (row.cert) {
    rec.cert = randomBytes(6).toString('hex') + '.pem'
    this._fsQueue.push({
      type: 'write',
      file: path.join(this._dir, rec.cert),
      data: row.cert
    })
  }
  let frecs = this._byFingerprint.get(fp) ?? new Set
  let fcert = null
  for (let x of frecs) {
    if (x.cert && x.hostname === host && x.fingerprint512 === fp) {
      fcert = x.cert
      break
    }
  }
  let hrecs = this._byHostname.get(host) ?? new Set
  let hcert = null
  for (let x of hrecs) {
    if (x.cert && x.hostname === host && x.fingerprint512 === fp) {
      hcert = x.cert
      break
    }
  }
  if (fcert && !rec.cert) rec.cert = fcert
  else if (hcert && !rec.cert) rec.cert = hcert
  this._add(rec)
}

RemoteCerts.prototype._add = function (row) {
  let fp = normfp(row.fingerprint512 ?? row.fingerprint)
  let host = row.hostname
  let frecs = this._byFingerprint.get(fp) ?? new Set
  filterDrainSet(frecs, x => {
    if (x.hostname === host && x.fingerprint512 === fp) {
      if (x.cert) fcert = x.cert
      return true
    }
    return false
  })
  let hrecs = this._byHostname.get(host) ?? new Set
  filterDrainSet(hrecs, x => {
    if (x.hostname === host && x.fingerprint512 === fp) {
      if (x.cert) fcert = x.cert
      return true
    }
    return false
  })
  frecs.add(row)
  hrecs.add(row)
  this._byFingerprint.set(fp, frecs)
  this._byHostname.set(host, hrecs)
}


RemoteCerts.prototype.remove = function (row) {
  let fp = normfp(row.fingerprint512 ?? row.fingerprint)
  let host = row.hostname
  if (fp !== undefined && host !== undefined) {
    let hrecs = this._byFingerprint.get(fp) ?? new Set
    for (let rec of hrecs) {
      if (rec.hostname === host && rec.cert !== undefined) {
        this._fsQueue.push({
          type: 'delete',
          file: path.join(this._dir, rec.cert),
        })
      }
    }
    filterDrainSet(hrecs, x => x.hostname === host)
    if (hrecs.size === 0) this._byFingerprint.delete(fp)
    else this._byFingerprint.set(fp, hrecs)
  } else if (fp !== undefined) {
    this._byFingerprint.delete(fp)
  }
  if (host !== undefined && fp !== undefined) {
    let hrecs = this._byHostname.get(host) ?? new Set
    for (let rec of hrecs) {
      if (rec.fingerprint512 === fp && rec.cert !== undefined) {
        this._fsQueue.push({
          type: 'delete',
          file: path.join(this._dir, rec.cert),
        })
      }
    }
    filterDrainSet(hrecs, x => x.fingerprint512 === fp)
    if (hrecs.size === 0) this._byHostname.delete(host)
    else this._byHostname.set(host, hrecs)
  } else if (host !== undefined) {
    let frecs = Array.from(this._byHostname.get(host) ?? new Set)
    frecs.forEach(rec => {
      let hrecs = this._byFingerprint.get(rec.fingerprint512) ?? new Set
      for (let x of hrecs) {
        if (x.hostname === host && x.cert !== undefined) {
          this._fsQueue.push({
            type: 'delete',
            file: path.join(this._dir, x.cert),
          })
        }
      }
      filterDrainSet(hrecs, x => x.hostname === host)
      this._byFingerprint.set(fp, hrecs)
    })
    this._byHostname.delete(host)
  }
}

RemoteCerts.prototype.getFromFingerprint = function (fp) {
  return Array.from(this._byFingerprint.get(normfp(fp)) ?? new Set)
}

RemoteCerts.prototype.getFromHostname = function (host) {
  return Array.from(this._byHostname.get(host) ?? new Set)
}

RemoteCerts.prototype.readCert = function (file, cb) {
  if (/^\.|\.\.|:/.test(file)) nextTick(cb, new Error('invalid cert filename'))
  if (!/\.pem$/.test(file)) nextTick(cb, new Error('cert not a pem file'))
  fs.readFile(this.certPath(file), cb)
}

RemoteCerts.prototype.certPath = function (file) {
  return path.join(this._dir,file)
}

RemoteCerts.prototype.list = function () {
  let rows = []
  for (let [fp,recs] of this._byFingerprint) {
    rows = rows.concat(Array.from(recs))
  }
  return rows
}

RemoteCerts.prototype.verify = function (opts) {
  let host = opts.hostname
  let fp = normfp(opts.fingerprint512 ?? opts.fingerprint)
  let hrecs = this._byFingerprint.get(fp)
  return hrecs ? someSet(hrecs, x => x.hostname === host) : false
}

function noop() {}
function normfp(fp) { return fp?.toLowerCase().replace(/:/g,'') }

function someSet(s, f) {
  for (let x of s) {
    if (f(x)) return true
  }
  return false
}

function filterDrainSet(s, f) {
  for (let x of s) {
    if (f(x)) s.delete(x)
  }
}

function once(f) {
  return function () {
    let g = f
    f = null
    if (g) g.apply(null, arguments)
  }
}