hush: disallow "{echo hi; }" (require whitespace)
 and "{ echo hi }" (require semicolon or &)

function                                             old     new   delta
parse_stream                                        2098    2176     +78
done_command                                          98      84     -14
------------------------------------------------------------------------------
(add/remove: 0/0 grow/shrink: 1/1 up/down: 78/-14)             Total: 64 bytes

diff --git a/shell/hush.c b/shell/hush.c
index 61b6a79..8dda988 100644
--- a/shell/hush.c
+++ b/shell/hush.c
@@ -319,6 +319,10 @@
  */
 	struct redir_struct *redirects; /* I/O redirections */
 };
+/* Is there anything in this command at all? */
+#define IS_NULL_CMD(cmd) \
+	(!(cmd)->group && !(cmd)->argv && !(cmd)->redirects)
+
 
 struct pipe {
 	struct pipe *next;
@@ -341,6 +345,9 @@
 	PIPE_OR  = 3,
 	PIPE_BG  = 4,
 } pipe_style;
+/* Is there anything in this pipe at all? */
+#define IS_NULL_PIPE(pi) \
+	((pi)->num_cmds == 0 IF_HAS_KEYWORDS( && (pi)->res_word == RES_NONE))
 
 /* This holds pointers to the various results of parsing */
 struct parse_context {
@@ -3971,8 +3978,10 @@
 	return pi;
 }
 
-/* Command (member of a pipe) is complete. The only possible error here
- * is out of memory, in which case xmalloc exits. */
+/* Command (member of a pipe) is complete, or we start a new pipe
+ * if ctx->command is NULL.
+ * No errors possible here.
+ */
 static int done_command(struct parse_context *ctx)
 {
 	/* The command is really already in the pipe structure, so
@@ -3981,13 +3990,9 @@
 	struct command *command = ctx->command;
 
 	if (command) {
-		if (command->group == NULL
-		 && command->argv == NULL
-		 && command->redirects == NULL
-		) {
+		if (IS_NULL_CMD(command)) {
 			debug_printf_parse("done_command: skipping null cmd, num_cmds=%d\n", pi->num_cmds);
-			memset(command, 0, sizeof(*command)); /* paranoia */
-			return pi->num_cmds;
+			goto clear_and_ret;
 		}
 		pi->num_cmds++;
 		debug_printf_parse("done_command: ++num_cmds=%d\n", pi->num_cmds);
@@ -3999,12 +4004,9 @@
 	/* Only real trickiness here is that the uncommitted
 	 * command structure is not counted in pi->num_cmds. */
 	pi->cmds = xrealloc(pi->cmds, sizeof(*pi->cmds) * (pi->num_cmds+1));
-	command = &pi->cmds[pi->num_cmds];
+	ctx->command = command = &pi->cmds[pi->num_cmds];
+ clear_and_ret:
 	memset(command, 0, sizeof(*command));
-
-	ctx->command = command;
-	/* but ctx->pipe and ctx->list_head remain unchanged */
-
 	return pi->num_cmds; /* used only for 0/nonzero check */
 }
 
@@ -4024,9 +4026,7 @@
 
 	/* Without this check, even just <enter> on command line generates
 	 * tree of three NOPs (!). Which is harmless but annoying.
-	 * IOW: it is safe to do it unconditionally.
-	 * RES_NONE case is for "for a in; do ..." (empty IN set)
-	 * and other cases to work. */
+	 * IOW: it is safe to do it unconditionally. */
 	if (not_null
 #if ENABLE_HUSH_IF
 	 || ctx->ctx_res_w == RES_FI
@@ -4048,7 +4048,7 @@
 		ctx->pipe->next = new_p;
 		ctx->pipe = new_p;
 		/* RES_THEN, RES_DO etc are "sticky" -
-		 * they remain set for commands inside if/while.
+		 * they remain set for pipes inside if/while.
 		 * This is used to control execution.
 		 * RES_FOR and RES_IN are NOT sticky (needed to support
 		 * cases where variable or value happens to match a keyword):
@@ -4304,7 +4304,7 @@
 		 && ctx->ctx_res_w != RES_IN
 # endif
 		) {
-			debug_printf_parse(": checking '%s' for reserved-ness\n", word->data);
+			debug_printf_parse("checking '%s' for reserved-ness\n", word->data);
 			if (reserved_word(word, ctx)) {
 				o_reset_to_empty_unquoted(word);
 				debug_printf_parse("done_word return %d\n",
@@ -4775,6 +4775,14 @@
 	if (ch == '(') {
 		endch = ')';
 		command->grp_type = GRP_SUBSHELL;
+	} else {
+		/* bash does not allow "{echo...", requires whitespace */
+		ch = i_getch(input);
+		if (ch != ' ' && ch != '\t' && ch != '\n') {
+			syntax_error_unexpected_ch(ch);
+			return 1;
+		}
+		nommu_addchr(&ctx->as_string, ch);
 	}
 
 	{
@@ -5352,13 +5360,11 @@
 		if (end_trigger && end_trigger == ch
 		 && (heredoc_cnt == 0 || end_trigger != ';')
 		) {
-//TODO: disallow "{ cmd }" without semicolon
 			if (heredoc_cnt) {
 				/* This is technically valid:
 				 * { cat <<HERE; }; echo Ok
 				 * heredoc
 				 * heredoc
-				 * heredoc
 				 * HERE
 				 * but we don't support this.
 				 * We require heredoc to be in enclosing {}/(),
@@ -5370,6 +5376,15 @@
 			if (done_word(&dest, &ctx)) {
 				goto parse_error;
 			}
+			/* Disallow "{ cmd }" without semicolon or & */
+			//debug_printf_parse("null pi %d\n", IS_NULL_PIPE(ctx.pipe))
+			//debug_printf_parse("null cmd %d\n", IS_NULL_CMD(ctx.command))
+			if (ch == '}'
+			 && !(IS_NULL_PIPE(ctx.pipe) && IS_NULL_CMD(ctx.command))
+			) {
+				syntax_error_unexpected_ch(ch);
+				goto parse_error;
+			}
 			done_pipe(&ctx, PIPE_SEQ);
 			dest.o_assignment = MAYBE_ASSIGNMENT;
 			/* Do we sit outside of any if's, loops or case's? */