diff --git a/age/format/format.ha b/age/format/format.ha new file mode 100644 index 0000000..f7f1b2e --- /dev/null +++ b/age/format/format.ha @@ -0,0 +1,155 @@ +use os; +use bufio; +use strings; +use fmt; +use encoding::base64; +use io; + +def INTRO: str = "age-encryption.org/v1"; +def STANZA_PREFIX: str = "->"; +def FOOTER_PREFIX: str = "---"; + +def COLUMNS_PER_LINE: size = 64; +def BYTES_PER_LINE: size = COLUMNS_PER_LINE / 4 * 3; + +export type stanza = struct { + recipient_type: str, + args: []str, + body: []u8, +}; + +export type header = struct { + mac: [32]u8, + recipients: []stanza, +}; + +export type parse_error = !void; + +fn is_valid_string(s: str) bool = { + if (len(s) == 0) { + return false; + }; + for (let char .. strings::toutf8(s)) { + if (char < 33 || char > 126) { + return false; + }; + }; + return true; +}; + +fn read_stanza(scanner: *bufio::scanner) (stanza | parse_error) = { + const line = match (bufio::scan_string(scanner, "\n")!) { + case io::EOF => + return fmt::fatal("unexpected end of file"); + case let line: str => + yield line; + }; + if (!strings::hasprefix(line, STANZA_PREFIX)) { + return fmt::fatalf("malformed stanza opening line: {}", line); + }; + + const (prefix, args): (str, []str) = split_args(line); + if (prefix != STANZA_PREFIX || len(args) < 1) { + return fmt::fatalf("malformed stanza: {}", line); + }; + + for (let arg .. args) { + if (!is_valid_string(arg)) { + return fmt::fatalf("malformed stanza: {}", line); + }; + }; + + const recipient_type = args[0]; + const args = args[1..]; + let body: []u8 = []; + + for (true) { + const next_line = match (bufio::scan_string(scanner, "\n")!) { + case io::EOF => + return fmt::fatal("unexpected end of file"); + case let line: str => + yield line; + }; + const b = decode_string(next_line)!; + + if (len(b) > BYTES_PER_LINE) { + return fmt::fatalf("malformed body line {}: too long", next_line); + }; + + append(body, b...)!; + if (len(b) < BYTES_PER_LINE) { + // stanza bodies always end with short lines + return stanza{ + recipient_type = recipient_type, + args = args, + body = body + }; + }; + + }; +}; + +fn split_args(line: str) (str, []str) = { + const parts = strings::split(line, " ")!; + return (parts[0], parts[1..]); +}; + +fn decode_string(encoded: str) ([]u8 | parse_error) = { + if (strings::contains(encoded, "\n", "\r")) { + return fmt::fatalf("unexpected newline character"); + }; + return base64::decodestr(&base64::std_encoding, encoded)!; +}; + +export fn parse(path: str) ((header, bufio::scanner) | parse_error) = { + const file = os::open(path)!; + const scanner = bufio::newscanner(file); + + let recipients: []stanza = []; + + const version_line = match (bufio::scan_string(&scanner, "\n")!) { + case io::EOF => + return fmt::fatal("unexpected end of file"); + case let line: str => + yield line; + }; + if (version_line != "age-encryption.org/v1") { + return fmt::fatalf("unexpected intro: {}", version_line); + }; + let header_mac: [32]u8 = [0...]; + for (true) { + const next_line = match (bufio::scan_line(&scanner)!) { + case io::EOF => + return fmt::fatal("unexpected end of file"); + case let line: str => + yield line; + }; + + if (strings::fromutf8(strings::toutf8(next_line)[0..3])! == FOOTER_PREFIX) { + // we're at the footer line + const (prefix, args) = split_args(next_line); + if (prefix != FOOTER_PREFIX || len(args) != 1) { + return fmt::fatalf("malformed closing line: {}", next_line); + }; + + const mac = decode_string(args[0])!; + if (len(mac) != 32) { + return fmt::fatalf("malformed closing line: {}", next_line); + }; + // io::copy(&memio::fixed(header_mac), &memio::fixed(mac))!; + header_mac[..] = mac[0..32]; + break; + }; + bufio::unread(&scanner, strings::toutf8(next_line)); + + const found_stanza = read_stanza(&scanner)!; + append(recipients, found_stanza)!; + }; + const prefix = bufio::scan_buffer(&scanner); + bufio::unread(&scanner, prefix); + + return (header{ + mac = header_mac, + recipients = recipients, + }, scanner); +}; \ No newline at end of file