///|
/// Decodes a hexadecimal signature body.
///
/// Surrounding whitespace is tolerated because a signature is usually produced
/// by a shell pipeline that appends a newline. Nothing else is: a separator or
/// stray character inside the value means the file is not what the release
/// process intended, and guessing would be worse than refusing.
fn decode_hex_body(body : Bytes, url : String) -> Bytes raise UpdateError {
  decode_hex_body_text(bytes_to_utf8(body), url)
}

///|
fn decode_hex_body_text(text : String, url : String) -> Bytes raise UpdateError {
  let digits : Array[Char] = []
  for character in text {
    if character is (' ' | '\t' | '\r' | '\n') {
      continue
    }
    digits.push(character)
  }
  guard digits.length() > 0 && digits.length() % 2 == 0 else {
    raise MalformedSignature(url~)
  }
  let out : Array[Byte] = []
  for index = 0; index < digits.length(); index = index + 2 {
    let high = hex_digit(digits[index], url)
    let low = hex_digit(digits[index + 1], url)
    out.push((high * 16 + low).to_byte())
  }
  Bytes::from_array(out[:])
}

///|
fn hex_digit(digit : Char, url : String) -> Int raise UpdateError {
  let code = digit.to_int()
  if code >= '0'.to_int() && code <= '9'.to_int() {
    return code - '0'.to_int()
  }
  if code >= 'a'.to_int() && code <= 'f'.to_int() {
    return code - 'a'.to_int() + 10
  }
  if code >= 'A'.to_int() && code <= 'F'.to_int() {
    return code - 'A'.to_int() + 10
  }
  raise MalformedSignature(url~)
}

///|
/// Interprets a fetched body as text.
///
/// A body that is not valid UTF-8 yields replacement characters rather than an
/// error. It will fail the signature check or the manifest parse a moment
/// later, and those refusals describe the problem better than an encoding
/// complaint would.
fn bytes_to_utf8(body : Bytes) -> String {
  let builder = StringBuilder::new(size_hint=body.length())
  for character in @encoding.decoder(UTF8).decode_lossy(body[:]) {
    builder.write_char(character)
  }
  builder.to_string()
}