commit 72076448dca192d312fd4a794f144f951c3ad231
parent 6d9c129a2ee42c448076194d825e7a4c8a26a465
Author: finwo <finwo@pm.me>
Date: Wed, 7 Oct 2026 15:46:45 +0200
Fix empty quote directive; split quote states for human-friendly behavior
Diffstat:
4 files changed, 180 insertions(+), 12 deletions(-)
diff --git a/README.md b/README.md
@@ -52,7 +52,13 @@ void cnf_directive_free(struct cnf_directive *subject);
<details>
<summary>cnf_directive_read(fd)</summary>
- Returns a single directive (or NULL) from the file descriptor
+ Returns a single directive (or NULL) from the file descriptor.
+
+ Arguments are separated by whitespace. Double-quoted strings may contain
+ spaces and support the escapes `\n`, `\t`, `\r`, `\\`, `\"` and `\'`;
+ unknown escapes keep the character verbatim, and `\0` becomes a literal
+ `0` (with a warning on stderr). Single-quoted strings are literal except
+ for `\'`.
```C
struct cnf_directive * cnf_directive_read(FILE *fd);
diff --git a/src/cnfparse.c b/src/cnfparse.c
@@ -4,6 +4,7 @@ extern "C" {
// includes/system {{{
#include <stdbool.h>
+#include <stdio.h>
#include <stdlib.h>
#include <string.h>
// }}}
@@ -16,13 +17,32 @@ extern "C" {
#define STATE_BLANK 0
#define STATE_COMMENT 1
-#define STATE_QUOTE 2
-#define STATE_WORD 3
+#define STATE_DQUOTE 2
+#define STATE_SQUOTE 3
+#define STATE_WORD 4
+
+// cnf_unescape(c) {{{
+// Unknown escapes keep the character verbatim
+static int cnf_unescape(char c) {
+ switch (c) {
+ case 'n': return '\n';
+ case 't': return '\t';
+ case 'r': return '\r';
+ case '\\': return '\\';
+ case '"': return '"';
+ case '\'': return '\'';
+ case '0':
+ fprintf(stderr, "cnfparse: warning: \\0 becomes '0', not a null byte\n");
+ return '0';
+ default: return c;
+ }
+}
+// }}}
// cnf_directive_append(subject,value,len) {{{
// caller frees
void cnf_directive_append(struct cnf_directive *subject, char *value, size_t len) {
- if (!value) return;
+ if (!value) value = ""; // len is leading, not value
char *val = calloc(1, len+1);
memcpy(val, value, len);
if (!subject->name) {
@@ -57,7 +77,6 @@ struct cnf_directive * cnf_directive_read(FILE *fd) {
int state = STATE_BLANK;
char c;
- char quote;
bool escape;
while(!feof(fd)) {
@@ -80,9 +99,11 @@ struct cnf_directive * cnf_directive_read(FILE *fd) {
state = STATE_COMMENT;
continue;
case '"':
+ state = STATE_DQUOTE;
+ escape = false;
+ continue;
case '\'':
- state = STATE_QUOTE;
- quote = c;
+ state = STATE_SQUOTE;
escape = false;
continue;
default:
@@ -103,9 +124,34 @@ struct cnf_directive * cnf_directive_read(FILE *fd) {
continue;
}
break;
- case STATE_QUOTE:
+ case STATE_DQUOTE:
+ if (escape) {
+ buf_append_byte(acc, cnf_unescape(c));
+ escape = false;
+ continue;
+ }
+ if (c == '\\') {
+ escape = true;
+ continue;
+ }
+ if (c == '"') {
+ cnf_directive_append(output, acc->dat, acc->len);
+ state = STATE_BLANK;
+ memset(acc->dat, 0, acc->cap);
+ acc->len = 0;
+ continue;
+ }
+ buf_append_byte(acc, c);
+ continue;
+ case STATE_SQUOTE:
if (escape) {
- buf_append_byte(acc, c);
+ // only \' is an escape inside single quotes; any other backslash stays literal
+ if (c == '\'') {
+ buf_append_byte(acc, '\'');
+ } else {
+ buf_append_byte(acc, '\\');
+ buf_append_byte(acc, c);
+ }
escape = false;
continue;
}
@@ -113,7 +159,7 @@ struct cnf_directive * cnf_directive_read(FILE *fd) {
escape = true;
continue;
}
- if (c == quote) {
+ if (c == '\'') {
cnf_directive_append(output, acc->dat, acc->len);
state = STATE_BLANK;
memset(acc->dat, 0, acc->cap);
@@ -121,7 +167,7 @@ struct cnf_directive * cnf_directive_read(FILE *fd) {
continue;
}
buf_append_byte(acc, c);
- continue;;
+ continue;
case STATE_WORD:
switch(c) {
case ' ':
diff --git a/src/cnfparse.h b/src/cnfparse.h
@@ -60,7 +60,13 @@ void cnf_directive_free(struct cnf_directive *subject);
/// <details>
/// <summary>cnf_directive_read(fd)</summary>
///
-/// Returns a single directive (or NULL) from the file descriptor
+/// Returns a single directive (or NULL) from the file descriptor.
+///
+/// Arguments are separated by whitespace. Double-quoted strings may contain
+/// spaces and support the escapes `\n`, `\t`, `\r`, `\\`, `\"` and `\'`;
+/// unknown escapes keep the character verbatim, and `\0` becomes a literal
+/// `0` (with a warning on stderr). Single-quoted strings are literal except
+/// for `\'`.
///<C
struct cnf_directive * cnf_directive_read(FILE *fd);
///>
diff --git a/test/0000-basics.c b/test/0000-basics.c
@@ -131,6 +131,107 @@ void test_escaped_quote_in_double() {
fclose(fd);
}
+void test_escaped_newline_in_double_quotes() {
+ FILE *fd = open_string("op \"a\\nb\"\n"); // config text: op "a\nb"
+ ASSERT("fmemopen succeeds", fd != NULL);
+ struct cnf_directive *dir = cnf_directive_read(fd);
+ ASSERT("directive with escaped newline returned", dir != NULL);
+ ASSERT_STRING_EQUALS("op", dir->name);
+ ASSERT_EQUALS(1, dir->argc);
+ ASSERT_STRING_EQUALS("a\nb", dir->argv[0]);
+ cnf_directive_free(dir);
+ fclose(fd);
+}
+
+void test_escaped_tab_cr_in_double_quotes() {
+ FILE *fd = open_string("op \"a\\tb\\rc\"\n"); // config text: op "a\tb\rc"
+ ASSERT("fmemopen succeeds", fd != NULL);
+ struct cnf_directive *dir = cnf_directive_read(fd);
+ ASSERT("directive with escaped tab/cr returned", dir != NULL);
+ ASSERT_EQUALS(1, dir->argc);
+ ASSERT_STRING_EQUALS("a\tb\rc", dir->argv[0]);
+ cnf_directive_free(dir);
+ fclose(fd);
+}
+
+void test_escaped_backslash_in_double_quotes() {
+ FILE *fd = open_string("op \"a\\\\b\"\n"); // config text: op "a\\b"
+ ASSERT("fmemopen succeeds", fd != NULL);
+ struct cnf_directive *dir = cnf_directive_read(fd);
+ ASSERT("directive with escaped backslash returned", dir != NULL);
+ ASSERT_EQUALS(1, dir->argc);
+ ASSERT_STRING_EQUALS("a\\b", dir->argv[0]);
+ cnf_directive_free(dir);
+ fclose(fd);
+}
+
+void test_unknown_escape_kept_verbatim() {
+ FILE *fd = open_string("op \"a\\zb\"\n"); // config text: op "a\zb"
+ ASSERT("fmemopen succeeds", fd != NULL);
+ struct cnf_directive *dir = cnf_directive_read(fd);
+ ASSERT("directive with unknown escape returned", dir != NULL);
+ ASSERT_EQUALS(1, dir->argc);
+ ASSERT_STRING_EQUALS("azb", dir->argv[0]);
+ cnf_directive_free(dir);
+ fclose(fd);
+}
+
+void test_null_escape_becomes_zero() {
+ FILE *fd = open_string("op \"a\\0b\"\n"); // config text: op "a\0b"
+ ASSERT("fmemopen succeeds", fd != NULL);
+ struct cnf_directive *dir = cnf_directive_read(fd);
+ ASSERT("directive with null escape returned", dir != NULL);
+ ASSERT_EQUALS(1, dir->argc);
+ ASSERT_STRING_EQUALS("a0b", dir->argv[0]);
+ cnf_directive_free(dir);
+ fclose(fd);
+}
+
+void test_single_quotes_keep_backslash_literal() {
+ FILE *fd = open_string("op 'a\\nb'\n"); // config text: op 'a\nb'
+ ASSERT("fmemopen succeeds", fd != NULL);
+ struct cnf_directive *dir = cnf_directive_read(fd);
+ ASSERT("directive with literal backslash in single quotes returned", dir != NULL);
+ ASSERT_EQUALS(1, dir->argc);
+ ASSERT_STRING_EQUALS("a\\nb", dir->argv[0]);
+ cnf_directive_free(dir);
+ fclose(fd);
+}
+
+void test_escaped_single_quote_in_single_quotes() {
+ FILE *fd = open_string("op 'it\\'s'\n"); // config text: op 'it\'s'
+ ASSERT("fmemopen succeeds", fd != NULL);
+ struct cnf_directive *dir = cnf_directive_read(fd);
+ ASSERT("directive with escaped single quote returned", dir != NULL);
+ ASSERT_EQUALS(1, dir->argc);
+ ASSERT_STRING_EQUALS("it's", dir->argv[0]);
+ cnf_directive_free(dir);
+ fclose(fd);
+}
+
+void test_empty_double_quote_first_token() {
+ FILE *fd = open_string("\"\" foo\n"); // config text: "" foo
+ ASSERT("fmemopen succeeds", fd != NULL);
+ struct cnf_directive *dir = cnf_directive_read(fd);
+ ASSERT("directive with empty first token returned", dir != NULL);
+ ASSERT_STRING_EQUALS("", dir->name);
+ ASSERT_EQUALS(1, dir->argc);
+ ASSERT_STRING_EQUALS("foo", dir->argv[0]);
+ cnf_directive_free(dir);
+ fclose(fd);
+}
+
+void test_empty_double_quote_alone() {
+ FILE *fd = open_string("\"\"\n"); // config text: ""
+ ASSERT("fmemopen succeeds", fd != NULL);
+ struct cnf_directive *dir = cnf_directive_read(fd);
+ ASSERT("directive with only an empty token returned", dir != NULL);
+ ASSERT_STRING_EQUALS("", dir->name);
+ ASSERT_EQUALS(0, dir->argc);
+ cnf_directive_free(dir);
+ fclose(fd);
+}
+
void test_hash_in_quoted_string() {
FILE *fd = open_string("set note \"a # b\"\n");
ASSERT("fmemopen succeeds", fd != NULL);
@@ -242,6 +343,15 @@ int main(int argc, const char *argv[]) {
RUN(test_quoted_double_args);
RUN(test_quoted_single_args);
RUN(test_escaped_quote_in_double);
+ RUN(test_escaped_newline_in_double_quotes);
+ RUN(test_escaped_tab_cr_in_double_quotes);
+ RUN(test_escaped_backslash_in_double_quotes);
+ RUN(test_unknown_escape_kept_verbatim);
+ RUN(test_null_escape_becomes_zero);
+ RUN(test_single_quotes_keep_backslash_literal);
+ RUN(test_escaped_single_quote_in_single_quotes);
+ RUN(test_empty_double_quote_first_token);
+ RUN(test_empty_double_quote_alone);
RUN(test_hash_in_quoted_string);
RUN(test_trailing_comment);
RUN(test_comment_no_space);