commit e3cab0804b129c2d69e9ec8bed28d2369f68a1c0 Author: Jim Nelson Date: Mon Apr 11 23:16:21 2011 +0000 Email client ho! diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..89f1d2bc --- /dev/null +++ b/Makefile @@ -0,0 +1,54 @@ +PROGRAM = geary +BUILD_ROOT = 1 + +VALAC := valac + +APPS := console syntax + +ENGINE_SRC := \ + src/engine/state/Machine.vala \ + src/engine/state/MachineDescriptor.vala \ + src/engine/state/Mapping.vala \ + src/engine/imap/ClientConnection.vala \ + src/engine/imap/ClientSession.vala \ + src/engine/imap/Parameter.vala \ + src/engine/imap/Tag.vala \ + src/engine/imap/Command.vala \ + src/engine/imap/Commands.vala \ + src/engine/imap/Serializable.vala \ + src/engine/imap/Serializer.vala \ + src/engine/imap/Deserializer.vala \ + src/engine/imap/Error.vala \ + src/engine/util/string.vala + +CONSOLE_SRC := \ + src/console/main.vala + +SYNTAX_SRC := \ + src/tests/syntax.vala + +ALL_SRC := $(ENGINE_SRC) $(CONSOLE_SRC) $(SYNTAX_SRC) + +EXTERNAL_PKGS := \ + gio-2.0 \ + gee-1.0 \ + gtk+-2.0 + +.PHONY: all +all: $(APPS) + +.PHONY: clean +clean: + rm -f $(ALL_SRC:.vala=.c) + rm -f $(APPS) + +console: $(ENGINE_SRC) $(CONSOLE_SRC) Makefile + $(VALAC) --save-temps -g $(foreach pkg,$(EXTERNAL_PKGS),--pkg=$(pkg)) \ + $(ENGINE_SRC) $(CONSOLE_SRC) \ + -o $@ + +syntax: $(ENGINE_SRC) $(SYNTAX_SRC) Makefile + $(VALAC) --save-temps -g $(foreach pkg,$(EXTERNAL_PKGS),--pkg=$(pkg)) \ + $(ENGINE_SRC) $(SYNTAX_SRC) \ + -o $@ + diff --git a/src/console/main.vala b/src/console/main.vala new file mode 100644 index 00000000..9b5b1ff0 --- /dev/null +++ b/src/console/main.vala @@ -0,0 +1,337 @@ +/* Copyright 2011 Yorba Foundation + * + * This software is licensed under the GNU Lesser General Public License + * (version 2.1 or later). See the COPYING file in this distribution. + */ + +errordomain CommandException { + USAGE, + STATE +} + +class ImapConsole : Gtk.Window { + private Gtk.TextView console = new Gtk.TextView(); + private Gtk.Entry cmdline = new Gtk.Entry(); + private Gtk.Statusbar statusbar = new Gtk.Statusbar(); + + private uint statusbar_ctx = 0; + private uint statusbar_msg_id = 0; + + private Geary.Imap.ClientSession session = new Geary.Imap.ClientSession(); + private Geary.Imap.ClientConnection? cx = null; + + public ImapConsole() { + title = "IMAP Console"; + destroy.connect(() => { Gtk.main_quit(); }); + set_default_size(800, 600); + + Gtk.VBox layout = new Gtk.VBox(false, 4); + + console.editable = false; + Gtk.ScrolledWindow scrolled_console = new Gtk.ScrolledWindow(null, null); + scrolled_console.add(console); + scrolled_console.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC); + layout.pack_start(scrolled_console, true, true, 0); + + cmdline.activate.connect(on_activate); + layout.pack_start(cmdline, false, false, 0); + + statusbar_ctx = statusbar.get_context_id("status"); + statusbar.has_resize_grip = true; + layout.pack_end(statusbar, false, false, 0); + + add(layout); + + cmdline.grab_focus(); + } + + private void on_activate() { + exec(cmdline.buffer.text); + cmdline.buffer.delete_text(0, -1); + } + + private void clear_status() { + if (statusbar_msg_id == 0) + return; + + statusbar.remove(statusbar_ctx, statusbar_msg_id); + statusbar_msg_id = 0; + } + + private void status(string text) { + clear_status(); + + string msg = text; + if (!msg.has_suffix(".") && !msg.has_prefix("usage")) + msg += "."; + + statusbar_msg_id = statusbar.push(statusbar_ctx, msg); + } + + private void exception(Error err) { + status(err.message); + } + + private void exec(string input) { + string[] lines = input.strip().split(";"); + foreach (string line in lines) { + string[] tokens = line.strip().split(" "); + if (tokens.length == 0) + continue; + + string cmd = tokens[0].strip().down(); + + string[] args = new string[0]; + for (int ctr = 1; ctr < tokens.length; ctr++) { + string arg = tokens[ctr].strip(); + if (!is_empty_string(arg)) + args += arg; + } + + clear_status(); + + try { + switch (cmd) { + case "noop": + case "nop": + noop(cmd, args); + break; + + case "capabilities": + case "caps": + capabilities(cmd, args); + break; + + case "connect": + connect_cmd(cmd, args); + break; + + case "disconnect": + disconnect_cmd(cmd, args); + break; + + case "login": + login(cmd, args); + break; + + case "logout": + case "bye": + case "kthxbye": + logout(cmd, args); + break; + + case "list": + list(cmd, args); + break; + + case "examine": + examine(cmd, args); + break; + + case "exit": + case "quit": + quit(cmd, args); + break; + + case "gmail": + string[] fake_args = new string[1]; + fake_args[0] = "imap.gmail.com:993"; + connect_cmd("connect", fake_args); + break; + + default: + status("Unknown command \"%s\"".printf(cmd)); + break; + } + } catch (Error ce) { + status(ce.message); + } + } + } + + private void check_args(string cmd, string[] args, int count, string? usage) throws CommandException { + if (args.length != count) + throw new CommandException.USAGE("usage: %s %s", cmd, usage != null ? usage : ""); + } + + private void check_connected(string cmd, string[] args, int count, string? usage) throws CommandException { + if (cx == null) + throw new CommandException.STATE("'connect' required"); + + check_args(cmd, args, count, usage); + } + + private void capabilities(string cmd, string[] args) throws Error { + check_connected(cmd, args, 0, null); + + cx.send_async.begin(new Geary.Imap.CapabilityCommand(session), Priority.DEFAULT, null, + on_capabilities); + } + + private void on_capabilities(Object? source, AsyncResult result) { + try { + cx.send_async.end(result); + status("Success"); + } catch (Error err) { + exception(err); + } + } + + private void noop(string cmd, string[] args) throws Error { + check_connected(cmd, args, 0, null); + + cx.send_async.begin(new Geary.Imap.NoopCommand(session), Priority.DEFAULT, null, + on_noop); + } + + private void on_noop(Object? source, AsyncResult result) { + try { + cx.send_async.end(result); + status("Success"); + } catch (Error err) { + exception(err); + } + } + + private void connect_cmd(string cmd, string[] args) throws Error { + if (cx != null) + throw new CommandException.STATE("'logout' required"); + + check_args(cmd, args, 1, "hostname[:port]"); + + cx = new Geary.Imap.ClientConnection(args[0], Geary.Imap.ClientConnection.DEFAULT_PORT); + + status("Connecting to %s...".printf(args[0])); + cx.connect_async.begin(null, on_connected); + } + + private void on_connected(Object? source, AsyncResult result) { + try { + cx.connect_async.end(result); + status("Connected"); + + cx.sent_command.connect(on_sent_command); + cx.received_response.connect(on_received_response); + cx.xon(); + + } catch (Error err) { + cx = null; + + exception(err); + } + } + + private void disconnect_cmd(string cmd, string[] args) throws Error { + check_connected(cmd, args, 0, null); + + status("Disconnecting..."); + cx.disconnect_async.begin(null, on_disconnected); + } + + private void on_disconnected(Object? source, AsyncResult result) { + try { + cx.disconnect_async.end(result); + status("Disconnected"); + + cx.sent_command.disconnect(on_sent_command); + cx = null; + } catch (Error err) { + exception(err); + } + } + + private void login(string cmd, string[] args) throws Error { + check_connected(cmd, args, 2, "user pass"); + + status("Logging in..."); + cx.post(new Geary.Imap.LoginCommand(session, args[0], args[1]), on_logged_in); + } + + private void on_logged_in(Object? source, AsyncResult result) { + try { + cx.finish_post(result); + } catch (Error err) { + exception(err); + } + } + + private void logout(string cmd, string[] args) throws Error { + check_connected(cmd, args, 0, null); + + status("Logging out..."); + cx.post(new Geary.Imap.LogoutCommand(session), on_logout); + } + + private void on_logout(Object? source, AsyncResult result) { + try { + cx.finish_post(result); + status("Logged out"); + } catch (Error err) { + exception(err); + } + } + + private void list(string cmd, string[] args) throws Error { + check_connected(cmd, args, 2, " "); + + status("Listing..."); + cx.post(new Geary.Imap.ListCommand.wildcarded(session, args[0], args[1]), on_list); + } + + private void on_list(Object? source, AsyncResult result) { + try { + cx.finish_post(result); + status("Listed"); + } catch (Error err) { + exception(err); + } + } + + private void examine(string cmd, string[] args) throws Error { + check_connected(cmd, args, 1, ""); + + status("Opening %s read-only".printf(args[0])); + cx.post(new Geary.Imap.ExamineCommand(session, args[0]), on_examine); + } + + private void on_examine(Object? source, AsyncResult result) { + try { + cx.finish_post(result); + status("Opened read-only"); + } catch (Error err) { + exception(err); + } + } + + private void quit(string cmd, string[] args) throws Error { + Gtk.main_quit(); + } + + private void on_sent_command(Geary.Imap.Command cmd) { + append_to_console("[L] "); + append_to_console(cmd.to_string()); + append_to_console("\n"); + } + + private void on_received_response(Geary.Imap.RootParameters params) { + append_to_console("[R] "); + append_to_console(params.to_string()); + append_to_console("\n"); + } + + private void append_to_console(string text) { + Gtk.TextIter iter; + console.buffer.get_iter_at_offset(out iter, -1); + console.buffer.insert(iter, text, -1); + } +} + +void main(string[] args) { + Gtk.init(ref args); + + ImapConsole console = new ImapConsole(); + console.show_all(); + + Gtk.main(); +} + diff --git a/src/engine/imap/ClientConnection.vala b/src/engine/imap/ClientConnection.vala new file mode 100644 index 00000000..b81ae16a --- /dev/null +++ b/src/engine/imap/ClientConnection.vala @@ -0,0 +1,264 @@ +/* Copyright 2011 Yorba Foundation + * + * This software is licensed under the GNU Lesser General Public License + * (version 2.1 or later). See the COPYING file in this distribution. + */ + +public class Geary.Imap.ClientConnection { + public const uint16 DEFAULT_PORT = 143; + public const uint16 DEFAULT_PORT_TLS = 993; + + private string host_specifier; + private uint16 default_port; + private SocketClient socket_client = new SocketClient(); + private SocketConnection? cx = null; + private DataInputStream? dins = null; + private int ins_priority = Priority.DEFAULT; + private Cancellable ins_cancellable = new Cancellable(); + private bool flow_controlled = true; + private Deserializer des = new Deserializer(); + private uint8[] block_buffer = new uint8[4096]; + + public virtual signal void connected() { + } + + public virtual signal void disconnected() { + } + + public virtual signal void flow_control(bool xon) { + } + + public virtual signal void sent_command(Command cmd) { + } + + public virtual signal void received_response(RootParameters params) { + } + + public virtual signal void receive_failed(Error err) { + } + + public ClientConnection(string host_specifier, uint16 default_port) { + this.host_specifier = host_specifier; + this.default_port = default_port; + + socket_client.set_tls(true); + socket_client.set_tls_validation_flags(TlsCertificateFlags.UNKNOWN_CA); + + des.parameters_ready.connect(on_parameters_ready); + } + + private void on_parameters_ready(RootParameters params) { + received_response(params); + } + + /* + public void connect(Cancellable? cancellable = null) throws Error { + if (cx != null) + throw new IOError.EXISTS("Already connected to %s", to_string()); + + cx = socket_client.connect_to_host(host_specifier, default_port, cancellable); + iouts = new Imap.OutputStream(cx.output_stream); + dins = new DataInputStream(cx.input_stream); + dins.set_newline_type(DataStreamNewlineType.CR_LF); + + connected(); + } + */ + + public async void connect_async(Cancellable? cancellable = null) throws Error { + if (cx != null) + throw new IOError.EXISTS("Already connected to %s", to_string()); + + cx = yield socket_client.connect_to_host_async(host_specifier, default_port, cancellable); + dins = new DataInputStream(cx.input_stream); + dins.set_newline_type(DataStreamNewlineType.CR_LF); + + connected(); + } + + /* + public void disconnect(Cancellable? cancellable = null) throws Error { + if (cx == null) + return; + + dins.close(cancellable); + iouts.close(cancellable); + cx.close(cancellable); + + dins = null; + iouts = null; + cx = null; + + disconnected(); + } + */ + + public async void disconnect_async(Cancellable? cancellable = null) + throws Error { + if (cx == null) + return; + + yield cx.close_async(Priority.DEFAULT, cancellable); + + cx = null; + dins = null; + + disconnected(); + } + + public void xon(int priority = Priority.DEFAULT) throws Error { + check_for_connection(); + + if (!flow_controlled) + return; + + flow_controlled = false; + ins_priority = priority; + + next_deserialize_step(); + + flow_control(true); + } + + private void next_deserialize_step() { + switch (des.get_mode()) { + case Deserializer.Mode.LINE: + dins.read_line_async.begin(ins_priority, ins_cancellable, on_read_line); + break; + + case Deserializer.Mode.BLOCK: + long count = long.min(block_buffer.length, des.get_max_data_length()); + dins.read_async.begin(block_buffer[0:count], ins_priority, ins_cancellable, + on_read_block); + break; + + default: + error("Failed"); + } + } + + private void on_read_line(Object? source, AsyncResult result) { + try { + string line = dins.read_line_async.end(result); + des.push_line(line); + } catch (Error err) { + if (!(err is IOError.CANCELLED)) + receive_failed(err); + + return; + } + + if (!flow_controlled) + next_deserialize_step(); + } + + private void on_read_block(Object? source, AsyncResult result) { + try { + ssize_t read = dins.read_async.end(result); + des.push_data(block_buffer[0:read]); + } catch (Error err) { + if (!(err is IOError.CANCELLED)) + receive_failed(err); + + return; + } + + if (!flow_controlled) + next_deserialize_step(); + } + + public void xoff() throws Error { + check_for_connection(); + + if (flow_controlled) + return; + + // turn off the spigot + // TODO: Don't cancel the read, merely don't post the next window + flow_controlled = true; + ins_cancellable.cancel(); + ins_cancellable = new Cancellable(); + } + + /* + public void send(Command command, Cancellable? cancellable = null) throws Error { + if (cx == null) + throw new IOError.CLOSED("Not connected to %s", to_string()); + + command.serialize(iouts, cancellable); + + sent_command(command); + } + */ + + /** + * Convenience method for send_async.begin(). + */ + public void post(Command cmd, AsyncReadyCallback cb, int priority = Priority.DEFAULT, + Cancellable? cancellable = null) { + send_async.begin(cmd, priority, cancellable, cb); + } + + /** + * Convenience method for sync_async.end(). This is largely provided for symmetry with + * post_send(). + */ + public void finish_post(AsyncResult result) throws Error { + send_async.end(result); + } + + public async void send_async(Command cmd, int priority = Priority.DEFAULT, + Cancellable? cancellable = null) throws Error { + check_for_connection(); + + Serializer ser = new Serializer(); + cmd.serialize(ser); + assert(ser.has_content()); + + yield write_all_async(ser, priority, cancellable); + + sent_command(cmd); + } + + public async void send_multiple_async(Gee.List cmds, int priority = Priority.DEFAULT, + Cancellable? cancellable = null) throws Error { + if (cmds.size == 0) + return; + + check_for_connection(); + + Serializer ser = new Serializer(); + foreach (Command cmd in cmds) + cmd.serialize(ser); + assert(ser.has_content()); + + yield write_all_async(ser, priority, cancellable); + + // Variable named due to this bug: https://bugzilla.gnome.org/show_bug.cgi?id=596861 + foreach (Command cmd2 in cmds) + sent_command(cmd2); + } + + // Can't pass the raw buffer due to this bug: https://bugzilla.gnome.org/show_bug.cgi?id=639054 + private async void write_all_async(Serializer ser, int priority, Cancellable? cancellable) + throws Error { + ssize_t index = 0; + size_t length = ser.get_content_length(); + while (index < length) { + index += yield cx.output_stream.write_async(ser.get_content()[index:length], + priority, cancellable); + if (index < length) + debug("PARTIAL WRITE TO %s: %lu/%lu bytes", to_string(), index, length); + } + } + + private void check_for_connection() throws Error { + if (cx == null) + throw new IOError.CLOSED("Not connected to %s", to_string()); + } + + public string to_string() { + return "%s:%ud".printf(host_specifier, default_port); + } +} + diff --git a/src/engine/imap/ClientSession.vala b/src/engine/imap/ClientSession.vala new file mode 100644 index 00000000..74f05a11 --- /dev/null +++ b/src/engine/imap/ClientSession.vala @@ -0,0 +1,25 @@ +/* Copyright 2011 Yorba Foundation + * + * This software is licensed under the GNU Lesser General Public License + * (version 2.1 or later). See the COPYING file in this distribution. + */ + +public class Geary.Imap.ClientSession { + private int tag_counter = 0; + private char tag_prefix = 'a'; + + // Generates a unique tag for the IMAP session in the form of "<000-999>". + public string generate_tag_value() { + // watch for odometer rollover + if (++tag_counter >= 1000) { + tag_counter = 0; + if (tag_prefix == 'z') + tag_prefix = 'a'; + else + tag_prefix++; + } + + return "%c%03d".printf(tag_prefix, tag_counter); + } +} + diff --git a/src/engine/imap/Command.vala b/src/engine/imap/Command.vala new file mode 100644 index 00000000..2ee4f029 --- /dev/null +++ b/src/engine/imap/Command.vala @@ -0,0 +1,25 @@ +/* Copyright 2011 Yorba Foundation + * + * This software is licensed under the GNU Lesser General Public License + * (version 2.1 or later). See the COPYING file in this distribution. + */ + +public class Geary.Imap.Command : RootParameters { + public Tag tag { get; private set; } + public string name { get; private set; } + public string[]? args { get; private set; } + + public Command(Tag tag, string name, string[]? args = null) requires (!tag.is_untagged()) { + this.tag = tag; + this.name = name; + this.args = args; + + add(tag); + add(new StringParameter(name)); + if (args != null) { + foreach (string arg in args) + add(new StringParameter(arg)); + } + } +} + diff --git a/src/engine/imap/Commands.vala b/src/engine/imap/Commands.vala new file mode 100644 index 00000000..206a3476 --- /dev/null +++ b/src/engine/imap/Commands.vala @@ -0,0 +1,62 @@ +/* Copyright 2011 Yorba Foundation + * + * This software is licensed under the GNU Lesser General Public License + * (version 2.1 or later). See the COPYING file in this distribution. + */ + +public class Geary.Imap.CapabilityCommand : Command { + public const string NAME = "capability"; + + public CapabilityCommand(ClientSession session) { + base (new Tag.generated(session), NAME); + } +} + +public class Geary.Imap.NoopCommand : Command { + public const string NAME = "noop"; + + public NoopCommand(ClientSession session) { + base (new Tag.generated(session), NAME); + } +} + +public class Geary.Imap.LoginCommand : Command { + public const string NAME = "login"; + + public LoginCommand(ClientSession session, string user, string pass) { + base (new Tag.generated(session), NAME, { user, pass }); + } + + public override string to_string() { + return "%s %s ".printf(tag.to_string(), name); + } +} + +public class Geary.Imap.LogoutCommand : Command { + public const string NAME = "logout"; + + public LogoutCommand(ClientSession session) { + base (new Tag.generated(session), NAME); + } +} + +public class Geary.Imap.ListCommand : Command { + public const string NAME = "list"; + + public ListCommand(ClientSession session, string mailbox) { + base (new Tag.generated(session), NAME, { "", mailbox }); + } + + public ListCommand.wildcarded(ClientSession session, string reference, string mailbox) { + base (new Tag.generated(session), NAME, { reference, mailbox }); + } +} + +public class Geary.Imap.ExamineCommand : Command { + public const string NAME = "examine"; + + public ExamineCommand(ClientSession session, string mailbox) { + base (new Tag.generated(session), NAME, { mailbox }); + } +} + diff --git a/src/engine/imap/Deserializer.vala b/src/engine/imap/Deserializer.vala new file mode 100644 index 00000000..94f8ad1e --- /dev/null +++ b/src/engine/imap/Deserializer.vala @@ -0,0 +1,417 @@ +/* Copyright 2011 Yorba Foundation + * + * This software is licensed under the GNU Lesser General Public License + * (version 2.1 or later). See the COPYING file in this distribution. + */ + +public class Geary.Imap.Deserializer { + public enum Mode { + LINE, + BLOCK, + FAILED + } + + private enum State { + TAG, + START_PARAM, + ATOM, + QUOTED, + QUOTED_ESCAPE, + LITERAL, + LITERAL_DATA_BEGIN, + LITERAL_DATA, + FAILED, + COUNT + } + + private static string state_to_string(uint state) { + return ((State) state).to_string(); + } + + private enum Event { + CHAR, + EOL, + DATA, + COUNT + } + + private static string event_to_string(uint event) { + return ((Event) event).to_string(); + } + + // Atom specials includes space and close-parens, but those are handled in particular ways while + // in the ATOM state, so they're not included here. Also note that while documentation + // indicates that the backslash cannot be used in an atom, they *are* used for message flags + // and thus must be special-cased in the code. + private static unichar[] atom_specials = { + '(', '{', '%', '*', '\"' + }; + + // Tag specials are like atom specials but include the continuation character ('+'). Like atom + // specials, the space is treated in a particular way, but unlike atom, the close-parens + // character is not. Also, the star character is allowed, although technically only correct + // in the context of a status response; it's the responsibility of higher layers to catch this. + private static unichar[] tag_specials = { + '(', ')', '{', '%', '\"', '\\', '+' + }; + + private struct LiteralData { + public unowned uint8[] data; + + public LiteralData(owned uint8[] data) { + this.data = data; + } + } + + private static Geary.State.MachineDescriptor machine_desc = new Geary.State.MachineDescriptor( + "Geary.Imap.Deserializer", State.TAG, State.COUNT, Event.COUNT, + state_to_string, event_to_string); + + private Geary.State.Machine fsm; + private ListParameter current; + private RootParameters root = new RootParameters(); + private StringBuilder? current_string = null; + private LiteralParameter? current_literal = null; + private long literal_length_remaining = 0; + + public signal void parameters_ready(RootParameters root); + + public signal void failed(); + + public Deserializer() { + current = root; + + Geary.State.Mapping[] mappings = { + new Geary.State.Mapping(State.TAG, Event.CHAR, on_tag_or_atom_char), + + new Geary.State.Mapping(State.START_PARAM, Event.CHAR, on_first_param_char), + new Geary.State.Mapping(State.START_PARAM, Event.EOL, on_eol), + + new Geary.State.Mapping(State.ATOM, Event.CHAR, on_tag_or_atom_char), + new Geary.State.Mapping(State.ATOM, Event.EOL, on_atom_eol), + + new Geary.State.Mapping(State.QUOTED, Event.CHAR, on_quoted_char), + + new Geary.State.Mapping(State.QUOTED_ESCAPE, Event.CHAR, on_quoted_escape_char), + + new Geary.State.Mapping(State.LITERAL, Event.CHAR, on_literal_char), + + new Geary.State.Mapping(State.LITERAL_DATA_BEGIN, Event.EOL, on_literal_data_begin_eol), + + new Geary.State.Mapping(State.LITERAL_DATA, Event.DATA, on_literal_data) + }; + + fsm = new Geary.State.Machine(machine_desc, mappings, on_bad_transition); + } + + // Push a line (without the CRLF!). + public Mode push_line(string line) { + assert(get_mode() == Mode.LINE); + + int index = 0; + unichar ch; + while (line.get_next_char(ref index, out ch)) { + if (fsm.issue(Event.CHAR, &ch) == State.FAILED) { + failed(); + + return Mode.FAILED; + } + } + + if (fsm.issue(Event.EOL) == State.FAILED) { + failed(); + + return Mode.FAILED; + } + + return get_mode(); + } + + public long get_max_data_length() { + return literal_length_remaining; + } + + // Push a block of literal data + public Mode push_data(uint8[] data) { + assert(get_mode() == Mode.BLOCK); + + LiteralData literal_data = LiteralData(data); + if (fsm.issue(Event.DATA, &literal_data) == State.FAILED) { + failed(); + + return Mode.FAILED; + } + + return get_mode(); + } + + public Mode get_mode() { + switch (fsm.get_state()) { + case State.LITERAL_DATA: + return Mode.LINE; + + case State.FAILED: + return Mode.FAILED; + + default: + return Mode.LINE; + } + } + + private bool is_current_string_empty() { + return (current_string == null) || is_empty_string(current_string.str); + } + + private void append_to_string(unichar ch) { + if (current_string == null) + current_string = new StringBuilder(); + + current_string.append_unichar(ch); + } + + private void append_to_literal(uint8[] data) { + if (current_literal == null) + current_literal = new LiteralParameter(data); + else + current_literal.add(data); + } + + private void save_string_parameter() { + if (is_current_string_empty()) + return; + + save_parameter(new StringParameter(current_string.str)); + current_string = null; + } + + private void save_literal_parameter() { + if (current_literal == null) + return; + + save_parameter(current_literal); + current_literal = null; + } + + private void save_parameter(Parameter param) { + current.add(param); + } + + private void push() { + ListParameter child = new ListParameter(current); + current.add(child); + + current = child; + } + + private State pop() { + ListParameter? parent = current.get_parent(); + if (parent == null) { + warning("Attempt to close unopened list"); + + return State.FAILED; + } + + current = parent; + + return State.START_PARAM; + } + + private State flush_params() { + if (current != root) { + warning("Unclosed list in parameters"); + + return State.FAILED; + } + + if (!is_current_string_empty() || current_literal != null || literal_length_remaining > 0) { + warning("Unfinished parameter"); + + return State.FAILED; + } + + parameters_ready(root); + + root = new RootParameters(); + current = root; + + return State.TAG; + } + + // + // Transition handlers + // + + private uint on_first_param_char(uint state, uint event, void *user) { + // look for opening characters to special parameter formats, otherwise jump to atom + // handler (i.e. don't drop this character in the case of atoms) + switch (*((unichar *) user)) { + case '{': + return State.LITERAL; + + case '\"': + return State.QUOTED; + + case '(': + // open list + push(); + + return State.START_PARAM; + + case ')': + // close list + return pop(); + + default: + return on_tag_or_atom_char(State.ATOM, event, user); + } + } + + private uint on_eol(uint state, uint event, void *user) { + return flush_params(); + } + + private uint on_tag_or_atom_char(uint state, uint event, void *user) { + assert(state == State.TAG || state == State.ATOM); + + unichar ch = *((unichar *) user); + + // drop everything above 0x7F + if (ch > 0x7F) + return state; + + // drop control characters + if (ch.iscntrl()) + return state; + + // tags and atoms have different special characters + if (state == State.TAG && (ch in tag_specials)) + return state; + else if (state == State.ATOM && (ch in atom_specials)) + return state; + + // message flag indicator is only legal at start of atom + if (state == State.ATOM && ch == '\\' && !is_current_string_empty()) + return state; + + // space indicates end-of-atom or end-of-tag + if (ch == ' ') { + save_string_parameter(); + + return State.START_PARAM; + } + + // close-parens after an atom indicates end-of-list + if (state == State.ATOM && ch == ')') { + save_string_parameter(); + + return pop(); + } + + append_to_string(ch); + + return state; + } + + private uint on_atom_eol(uint state, uint event, void *user) { + // clean up final atom + save_string_parameter(); + + return flush_params(); + } + + private uint on_quoted_char(uint state, uint event, void *user) { + unichar ch = *((unichar *) user); + + // drop anything above 0x7F + if (ch > 0x7F) + return State.QUOTED; + + // drop NUL, CR, and LF + if (ch == '\0' || ch == '\r' || ch == '\n') + return State.QUOTED; + + // look for escaped characters + if (ch == '\\') + return State.QUOTED_ESCAPE; + + // DQUOTE ends quoted string and return to parsing atoms + if (ch == '\"') { + save_string_parameter(); + + return State.START_PARAM; + } + + append_to_string(ch); + + return State.QUOTED; + } + + private uint on_quoted_escape_char(uint state, uint event, void *user) { + unichar ch = *((unichar *) user); + + // only two accepted escaped characters: double-quote and backslash + // everything else dropped on the floor + switch (ch) { + case '\"': + case '\\': + append_to_string(ch); + break; + } + + return State.QUOTED; + } + + private uint on_literal_char(uint state, uint event, void *user) { + unichar ch = *((unichar *) user); + + // if close-bracket, end of literal length field -- next event must be EOL + if (ch == '}') { + // empty literal treated as garbage + if (is_current_string_empty()) + return State.FAILED; + + literal_length_remaining = long.parse(current_string.str); + if (literal_length_remaining < 0) { + warning("Negative literal data length %ld", literal_length_remaining); + + return State.FAILED; + } + + return State.LITERAL_DATA_BEGIN; + } + + // drop anything non-numeric + if (!ch.isdigit()) + return State.LITERAL; + + append_to_string(ch); + + return State.LITERAL; + } + + private uint on_literal_data_begin_eol(uint state, uint event, void *user) { + return State.LITERAL_DATA; + } + + private uint on_literal_data(uint state, uint event, void *user) { + LiteralData *literal_data = (LiteralData *) user; + + assert(literal_data.data.length <= literal_length_remaining); + literal_length_remaining -= literal_data.data.length; + + append_to_literal(literal_data.data); + if (literal_length_remaining > 0) + return State.LITERAL_DATA; + + save_literal_parameter(); + + return State.START_PARAM; + } + + private uint on_bad_transition(uint state, uint event, void *user) { + warning("Bad event %s at state %s", event_to_string(event), state_to_string(state)); + + return State.FAILED; + } +} + diff --git a/src/engine/imap/Error.vala b/src/engine/imap/Error.vala new file mode 100644 index 00000000..20faa844 --- /dev/null +++ b/src/engine/imap/Error.vala @@ -0,0 +1,10 @@ +/* Copyright 2011 Yorba Foundation + * + * This software is licensed under the GNU Lesser General Public License + * (version 2.1 or later). See the COPYING file in this distribution. + */ + +errordomain Geary.ImapError { + PARSE_ERROR; +} + diff --git a/src/engine/imap/Parameter.vala b/src/engine/imap/Parameter.vala new file mode 100644 index 00000000..407a5289 --- /dev/null +++ b/src/engine/imap/Parameter.vala @@ -0,0 +1,191 @@ +/* Copyright 2011 Yorba Foundation + * + * This software is licensed under the GNU Lesser General Public License + * (version 2.1 or later). See the COPYING file in this distribution. + */ + +public abstract class Geary.Imap.Parameter : Serializable { + public abstract void serialize(Serializer ser) throws Error; + + public abstract string to_string(); +} + +public class Geary.Imap.StringParameter : Geary.Imap.Parameter { + public string value { get; private set; } + + public StringParameter(string value) requires (!is_empty_string(value)) { + this.value = value; + } + + public StringParameter.NIL() { + this.value = "nil"; + } + + public bool is_nil() { + return value.down() == "nil"; + } + + public override string to_string() { + return value; + } + + public override void serialize(Serializer ser) throws Error { + ser.push_string(value); + } +} + +public class Geary.Imap.LiteralParameter : Geary.Imap.Parameter { + private MemoryInputStream mins = new MemoryInputStream(); + private long size = 0; + + public LiteralParameter(uint8[]? initial = null) { + if (initial != null) + add(initial); + } + + public void add(uint8[] data) { + if (data.length == 0) + return; + + mins.add_data(data, null); + size += data.length; + } + + public long get_size() { + return size; + } + + public override string to_string() { + return "{literal/%ldb}".printf(size); + } + + public override void serialize(Serializer ser) throws Error { + ser.push_string("{%ld}".printf(size)); + ser.push_eol(); + ser.push_input_stream_literal_data(mins); + + // seek to start + mins.seek(0, SeekType.SET); + } +} + +public class Geary.Imap.ListParameter : Geary.Imap.Parameter { + private weak ListParameter? parent; + private Gee.List list = new Gee.ArrayList(); + + public ListParameter(ListParameter? parent, Parameter? initial = null) { + this.parent = parent; + + if (initial != null) + add(initial); + } + + public ListParameter? get_parent() { + return parent; + } + + public void add(Parameter param) { + bool added = list.add(param); + assert(added); + } + + public int get_count() { + return list.size; + } + + public Gee.List get_all() { + return list.read_only_view; + } + + /* + public Parameter? get_next(ref int index, Type type, bool optional) throws ImapError { + assert(type.is_a(Parameter)); + + if (index >= list.size) { + if (!optional) + throw new ImapError.PARSE_ERROR; + + return null; + } + + Parameter param = list.get(index); + if (!(typeof(param).is_a(type)) { + if (!optional) + throw new ImapError.PARSE_ERROR; + + return null; + } + + index++; + + return param; + } + */ + + protected string stringize_list() { + string str = ""; + + int length = list.size; + for (int ctr = 0; ctr < length; ctr++) { + str += list[ctr].to_string(); + if (ctr < (length - 1)) + str += " "; + } + + return str; + } + + public override string to_string() { + return "%d:(%s)".printf(list.size, stringize_list()); + } + + protected void serialize_list(Serializer ser) throws Error { + int length = list.size; + for (int ctr = 0; ctr < length; ctr++) { + list[ctr].serialize(ser); + if (ctr < (length - 1)) + ser.push_space(); + } + } + + public override void serialize(Serializer ser) throws Error { + ser.push_string("("); + serialize_list(ser); + ser.push_string(")"); + } +} + +public class Geary.Imap.RootParameters : Geary.Imap.ListParameter { + public RootParameters(Parameter? initial = null) { + base (null, initial); + } + + /* + public bool is_status_response() { + if (get_count() < 2) + return false; + + StringParameter? strparam = get_all().get(1) as StringParameter; + if (strparam == null) + return false; + + try { + Status.decode(strparam.value); + } catch (Error err) { + return false; + } + + return true; + } + */ + + public override string to_string() { + return stringize_list(); + } + + public override void serialize(Serializer ser) throws Error { + serialize_list(ser); + ser.push_eol(); + } +} + diff --git a/src/engine/imap/Serializable.vala b/src/engine/imap/Serializable.vala new file mode 100644 index 00000000..2d5cbb06 --- /dev/null +++ b/src/engine/imap/Serializable.vala @@ -0,0 +1,10 @@ +/* Copyright 2011 Yorba Foundation + * + * This software is licensed under the GNU Lesser General Public License + * (version 2.1 or later). See the COPYING file in this distribution. + */ + +public interface Geary.Imap.Serializable { + public abstract void serialize(Serializer ser) throws Error; +} + diff --git a/src/engine/imap/Serializer.vala b/src/engine/imap/Serializer.vala new file mode 100644 index 00000000..f3110c00 --- /dev/null +++ b/src/engine/imap/Serializer.vala @@ -0,0 +1,60 @@ +/* Copyright 2011 Yorba Foundation + * + * This software is licensed under the GNU Lesser General Public License + * (version 2.1 or later). See the COPYING file in this distribution. + */ + +public class Geary.Imap.Serializer { + private MemoryOutputStream mouts; + private DataOutputStream douts; + + public Serializer() { + mouts = new MemoryOutputStream(null, realloc, free); + douts = new DataOutputStream(mouts); + } + + public unowned uint8[] get_content() { + return mouts.get_data(); + } + + public size_t get_content_length() { + return mouts.get_data_size(); + } + + public bool has_content() { + return get_content_length() > 0; + } + + // TODO: Remove + public void push_nil() throws Error { + douts.put_string("nil", null); + } + + // TODO: Remove + public void push_token(string str) throws Error { + douts.put_string(str, null); + } + + public void push_string(string str) throws Error { + douts.put_string(str, null); + } + + public void push_space() throws Error { + douts.put_byte(' ', null); + } + + public void push_eol() throws Error { + douts.put_string("\r\n", null); + } + + public void push_literal_data(uint8[] data) throws Error { + size_t written; + douts.write_all(data, out written); + assert(written == data.length); + } + + public void push_input_stream_literal_data(InputStream ins) throws Error { + douts.splice(ins, OutputStreamSpliceFlags.NONE); + } +} + diff --git a/src/engine/imap/Tag.vala b/src/engine/imap/Tag.vala new file mode 100644 index 00000000..46c60526 --- /dev/null +++ b/src/engine/imap/Tag.vala @@ -0,0 +1,20 @@ +/* Copyright 2011 Yorba Foundation + * + * This software is licensed under the GNU Lesser General Public License + * (version 2.1 or later). See the COPYING file in this distribution. + */ + +public class Geary.Imap.Tag : StringParameter { + public Tag.generated(ClientSession session) { + base (session.generate_tag_value()); + } + + public Tag(string value) { + base (value); + } + + public bool is_untagged() { + return value == "*"; + } +} + diff --git a/src/engine/state/Machine.vala b/src/engine/state/Machine.vala new file mode 100644 index 00000000..12395dec --- /dev/null +++ b/src/engine/state/Machine.vala @@ -0,0 +1,102 @@ +/* Copyright 2011 Yorba Foundation + * + * This software is licensed under the GNU Lesser General Public License + * (version 2.1 or later). See the COPYING file in this distribution. + */ + +public class Geary.State.Machine { + private Geary.State.MachineDescriptor descriptor; + private uint state; + private Mapping[,] transitions; + private Transition? default_transition; + private bool locked = false; + private bool abort_on_no_transition = true; + private bool logging = false; + + public Machine(MachineDescriptor descriptor, Mapping[] mappings, Transition? default_transition) { + this.descriptor = descriptor; + this.default_transition = default_transition; + + // verify that each state and event in the mappings are valid + foreach (Mapping mapping in mappings) { + assert(mapping.state < descriptor.state_count); + assert(mapping.event < descriptor.event_count); + } + + state = descriptor.start_state; + + // build a transition map with state/event IDs (i.e. offsets) pointing directly into the + // map + transitions = new Mapping[descriptor.state_count, descriptor.event_count]; + for (int ctr = 0; ctr < mappings.length; ctr++) { + Mapping mapping = mappings[ctr]; + assert(transitions[mapping.state, mapping.event] == null); + transitions[mapping.state, mapping.event] = mapping; + } + } + + public uint get_state() { + return state; + } + + public bool get_abort_on_no_transition() { + return abort_on_no_transition; + } + + public void set_abort_on_no_transition(bool abort) { + abort_on_no_transition = abort; + } + + public void set_logging(bool logging) { + this.logging = logging; + } + + public bool get_logging() { + return logging; + } + + public uint issue(uint event, void *user = null) { + assert(event < descriptor.event_count); + assert(state < descriptor.state_count); + + Mapping? mapping = transitions[state, event]; + + Transition transition = (mapping != null) ? mapping.transition : default_transition; + if (transition == null) { + string msg = "%s: No transition defined at %s for %s".printf(to_string(), + descriptor.get_state_string(state), descriptor.get_event_string(event)); + + if (get_abort_on_no_transition()) + error(msg); + else + critical(msg); + + return state; + } + + // guard against reentrancy ... don't want to use a non-reentrant lock because then + // the machine will simply hang; assertion is better to ferret out design flaws + assert(!locked); + locked = true; + + uint old_state = state; + state = transition(state, event, user); + assert(state < descriptor.state_count); + + assert(locked); + locked = false; + + if (get_logging()) { + message("%s: State transition from %s to %s due to event %s", to_string(), + descriptor.get_state_string(old_state), descriptor.get_state_string(state), + descriptor.get_event_string(event)); + } + + return state; + } + + public string to_string() { + return "Machine %s".printf(descriptor.name); + } +} + diff --git a/src/engine/state/MachineDescriptor.vala b/src/engine/state/MachineDescriptor.vala new file mode 100644 index 00000000..6b30fc64 --- /dev/null +++ b/src/engine/state/MachineDescriptor.vala @@ -0,0 +1,39 @@ +/* Copyright 2011 Yorba Foundation + * + * This software is licensed under the GNU Lesser General Public License + * (version 2.1 or later). See the COPYING file in this distribution. + */ + +public delegate string Geary.State.StateEventToString(uint state_or_event); + +public class Geary.State.MachineDescriptor { + public string name { get; private set; } + public uint start_state { get; private set; } + public uint state_count { get; private set; } + public uint event_count { get; private set; } + + private StateEventToString? state_to_string; + private StateEventToString? event_to_string; + + public MachineDescriptor(string name, uint start_state, uint state_count, uint event_count, + StateEventToString? state_to_string, StateEventToString? event_to_string) { + this.name = name; + this.start_state = start_state; + this.state_count = state_count; + this.event_count = event_count; + this.state_to_string = state_to_string; + this.event_to_string = event_to_string; + + // starting state should be valid + assert(start_state < state_count); + } + + public string get_state_string(uint state) { + return (state_to_string != null) ? state_to_string(state) : "%s STATE %u".printf(name, state); + } + + public string get_event_string(uint event) { + return (event_to_string != null) ? event_to_string(event) : "%s EVENT %u".printf(name, event); + } +} + diff --git a/src/engine/state/Mapping.vala b/src/engine/state/Mapping.vala new file mode 100644 index 00000000..698631b1 --- /dev/null +++ b/src/engine/state/Mapping.vala @@ -0,0 +1,28 @@ +/* Copyright 2011 Yorba Foundation + * + * This software is licensed under the GNU Lesser General Public License + * (version 2.1 or later). See the COPYING file in this distribution. + */ + +public delegate uint Geary.State.Transition(uint state, uint event, void *user); + +public class Geary.State.Mapping { + public uint state; + public uint event; + public Transition transition; + + public Mapping(uint state, uint event, Transition transition) { + this.state = state; + this.event = event; + this.transition = transition; + } +} + +namespace Geary.State { + +// A utility Transition for nop transitions (i.e. it merely returns the state passed in). +public uint nop(uint state, uint event, void *user) { + return state; +} + +} diff --git a/src/engine/util/string.vala b/src/engine/util/string.vala new file mode 100644 index 00000000..9b0b7677 --- /dev/null +++ b/src/engine/util/string.vala @@ -0,0 +1,10 @@ +/* Copyright 2011 Yorba Foundation + * + * This software is licensed under the GNU Lesser General Public License + * (version 2.1 or later). See the COPYING file in this distribution. + */ + +public inline bool is_empty_string(string? str) { + return (str == null || str[0] == 0); +} + diff --git a/src/tests/syntax.vala b/src/tests/syntax.vala new file mode 100644 index 00000000..b5abcc02 --- /dev/null +++ b/src/tests/syntax.vala @@ -0,0 +1,51 @@ +/* Copyright 2011 Yorba Foundation + * + * This software is licensed under the GNU Lesser General Public License + * (version 2.1 or later). See the COPYING file in this distribution. + */ + +void print(int depth, Gee.List params) { + string pad = string.nfill(depth * 4, ' '); + + int index = 0; + foreach (Geary.Imap.Parameter param in params) { + Geary.Imap.ListParameter? list = param as Geary.Imap.ListParameter; + if (list == null) { + stdout.printf("%s#%02d >%s<\n", pad, index++, param.to_string()); + + continue; + } + + print(depth + 1, list.get_all()); + } +} + +void on_params_ready(Geary.Imap.RootParameters root) { + print(0, root.get_all()); +} + +int main(string[] args) { + if (args.length < 2) { + stderr.printf("usage: syntax \n"); + + return 1; + } + + Geary.Imap.Deserializer des = new Geary.Imap.Deserializer(); + des.parameters_ready.connect(on_params_ready); + + // turn argument into single line for deserializer + string line = ""; + for (int ctr = 1; ctr < args.length; ctr++) { + line += args[ctr]; + if (ctr < (args.length - 1)) + line += " "; + } + + stdout.printf("INPUT: >%s<\n", line); + Geary.Imap.Deserializer.Mode mode = des.push_line(line); + stdout.printf("INPUT MODE: %s\n", mode.to_string()); + + return 0; +} +