diff --git a/spine.c b/spine.c index 4ae21ce0..75963a79 100644 --- a/spine.c +++ b/spine.c @@ -645,25 +645,25 @@ int main(int argc, char *argv[]) { /* obtain the list of hosts to poll */ { - int remaining = MEGA_BUFSIZE - (qp - querybuf); - qp += snprintf(qp, remaining, "SELECT SQL_NO_CACHE id, device_threads, picount, picount/device_threads AS tppi FROM host AS h LEFT JOIN (SELECT host_id, COUNT(*) AS picount FROM poller_item GROUP BY host_id) AS pi ON h.id = pi.host_id"); + size_t remaining = MEGA_BUFSIZE - (qp - querybuf); + spine_appendf(&qp, &remaining, "SELECT SQL_NO_CACHE id, device_threads, picount, picount/device_threads AS tppi FROM host AS h LEFT JOIN (SELECT host_id, COUNT(*) AS picount FROM poller_item GROUP BY host_id) AS pi ON h.id = pi.host_id"); remaining = MEGA_BUFSIZE - (qp - querybuf); - qp += snprintf(qp, remaining, " WHERE disabled = ''"); + spine_appendf(&qp, &remaining, " WHERE disabled = ''"); remaining = MEGA_BUFSIZE - (qp - querybuf); - qp += snprintf(qp, remaining, " AND availability_method != %d", AVAIL_STREAM); + spine_appendf(&qp, &remaining, " AND availability_method != %d", AVAIL_STREAM); if (!strlen(set.host_id_list)) { qp += append_hostrange(qp, "h.id"); /* AND id BETWEEN a AND b */ } else { remaining = MEGA_BUFSIZE - (qp - querybuf); - qp += snprintf(qp, remaining, " AND h.id IN(%s)", set.host_id_list); + spine_appendf(&qp, &remaining, " AND h.id IN(%s)", set.host_id_list); } remaining = MEGA_BUFSIZE - (qp - querybuf); - qp += snprintf(qp, remaining, " AND h.poller_id = %i", set.poller_id); + spine_appendf(&qp, &remaining, " AND h.poller_id = %i", set.poller_id); remaining = MEGA_BUFSIZE - (qp - querybuf); - qp += snprintf(qp, remaining, " ORDER BY picount DESC"); + spine_appendf(&qp, &remaining, " ORDER BY picount DESC"); } SPINE_LOG_DEVDBG(("DEVDBG: Host SQL:%s", querybuf)); diff --git a/tests/unit/test_linked.c b/tests/unit/test_linked.c index 71371452..63254470 100644 --- a/tests/unit/test_linked.c +++ b/tests/unit/test_linked.c @@ -20,6 +20,8 @@ #include "util.h" #include "ping.h" +#include + /* provided by tests/fuzz/stubs.c, as spine.c would */ extern int *debug_devices; @@ -457,6 +459,156 @@ static void test_is_debug_device_matches_only_listed_ids(void **state) { debug_devices = saved; } + +/* --------------------------------------------------------------------------- + * spine_appendf (util.c) + * + * Replaces `p += snprintf(p, remaining, ...)`, which advances by the length + * snprintf *would* have written, so the first truncation puts the cursor past + * the end and the next `remaining` underflows to a huge size_t. + * ------------------------------------------------------------------------- */ + +struct guarded_buf { + char body[32]; + char canary[8]; +}; + +static void guarded_init(struct guarded_buf *g) { + memset(g->body, 0, sizeof(g->body)); + memset(g->canary, 0x7e, sizeof(g->canary)); +} + +static void guarded_check(struct guarded_buf *g) { + size_t i; + + for (i = 0; i < sizeof(g->canary); i++) { + assert_int_equal((unsigned char) g->canary[i], 0x7e); + } +} + +static void test_appendf_writes_and_advances(void **state) { + struct guarded_buf g; + char *p; + size_t remaining; + + (void) state; + guarded_init(&g); + p = g.body; + remaining = sizeof(g.body); + + assert_true(spine_appendf(&p, &remaining, "abc")); + assert_int_equal(p - g.body, 3); + assert_int_equal(remaining, sizeof(g.body) - 3); + assert_string_equal(g.body, "abc"); + guarded_check(&g); +} + +static void test_appendf_accumulates(void **state) { + struct guarded_buf g; + char *p; + size_t remaining; + + (void) state; + guarded_init(&g); + p = g.body; + remaining = sizeof(g.body); + + assert_true(spine_appendf(&p, &remaining, "SELECT %d", 7)); + assert_true(spine_appendf(&p, &remaining, " FROM %s", "t")); + assert_string_equal(g.body, "SELECT 7 FROM t"); + assert_int_equal(remaining, sizeof(g.body) - strlen("SELECT 7 FROM t")); + guarded_check(&g); +} + +/* The case the old idiom got wrong. */ +static void test_appendf_reports_truncation_and_stays_in_bounds(void **state) { + struct guarded_buf g; + char *p; + size_t remaining; + + (void) state; + guarded_init(&g); + p = g.body; + remaining = sizeof(g.body); + + assert_false(spine_appendf(&p, &remaining, "%s", "0123456789012345678901234567890123456789")); + + /* cursor lands on the terminator, not past the end */ + assert_true(p >= g.body); + assert_true(p < g.body + sizeof(g.body)); + assert_int_equal(*p, '\0'); + assert_int_equal(remaining, 1); + assert_int_equal(strlen(g.body), sizeof(g.body) - 1); + guarded_check(&g); +} + +static void test_appendf_after_truncation_keeps_failing(void **state) { + struct guarded_buf g; + char *p; + size_t remaining; + char full[sizeof(g.body)]; + + (void) state; + guarded_init(&g); + p = g.body; + remaining = sizeof(g.body); + + assert_false(spine_appendf(&p, &remaining, "%s", "0123456789012345678901234567890123456789")); + memcpy(full, g.body, sizeof(full)); + + /* a second append must not write anything, anywhere */ + assert_false(spine_appendf(&p, &remaining, " AND poller_id=%d", 3)); + assert_memory_equal(g.body, full, sizeof(full)); + guarded_check(&g); +} + +static void test_appendf_rejects_null_arguments(void **state) { + char buf[8] = ""; + char *p = buf; + size_t remaining = sizeof(buf); + char *nullp = NULL; + + (void) state; + + assert_false(spine_appendf(NULL, &remaining, "x")); + assert_false(spine_appendf(&nullp, &remaining, "x")); + assert_false(spine_appendf(&p, NULL, "x")); +} + +static void test_appendf_rejects_an_exhausted_buffer(void **state) { + char buf[8] = ""; + char *p = buf; + size_t remaining = 0; + + (void) state; + + assert_false(spine_appendf(&p, &remaining, "x")); + assert_ptr_equal(p, buf); + assert_int_equal(buf[0], '\0'); +} + +/* Documents the defect: the same sequence with the old idiom leaves the cursor + outside the buffer, so the next remainder is negative. */ +static void test_old_idiom_overshoots_where_appendf_does_not(void **state) { + char buf[32]; + char *p = buf; + ptrdiff_t old_offset; + char *q; + size_t remaining; + + (void) state; + + p += snprintf(p, sizeof(buf), "%s", "0123456789012345678901234567890123456789"); + old_offset = p - buf; + assert_true(old_offset > (ptrdiff_t) sizeof(buf)); + assert_true((ptrdiff_t) (sizeof(buf) - old_offset) < 0); + + q = buf; + remaining = sizeof(buf); + assert_false(spine_appendf(&q, &remaining, "%s", "0123456789012345678901234567890123456789")); + assert_true(q - buf < (ptrdiff_t) sizeof(buf)); +} + int main(void) { const struct CMUnitTest tests[] = { cmocka_unit_test(test_strncopy_truncates_within_the_buffer), @@ -496,6 +648,13 @@ int main(void) { cmocka_unit_test(test_get_date_format_clamps_an_out_of_range_format), cmocka_unit_test(test_get_date_format_covers_each_supported_format), cmocka_unit_test(test_is_debug_device_matches_only_listed_ids), + cmocka_unit_test(test_appendf_writes_and_advances), + cmocka_unit_test(test_appendf_accumulates), + cmocka_unit_test(test_appendf_reports_truncation_and_stays_in_bounds), + cmocka_unit_test(test_appendf_after_truncation_keeps_failing), + cmocka_unit_test(test_appendf_rejects_null_arguments), + cmocka_unit_test(test_appendf_rejects_an_exhausted_buffer), + cmocka_unit_test(test_old_idiom_overshoots_where_appendf_does_not), }; return cmocka_run_group_tests(tests, NULL, NULL); diff --git a/util.c b/util.c index fa1dd93f..f0a10c76 100644 --- a/util.c +++ b/util.c @@ -517,6 +517,7 @@ void read_config_options(void) { char web_root[BUFSIZE]; char sqlbuf[HUGE_BUFSIZE]; char *sqlp = sqlbuf; + size_t remaining; char *res; char spine_priv[BUFSIZE]; char spine_auth[BUFSIZE]; @@ -826,11 +827,13 @@ void read_config_options(void) { /* log the requirement for the script server */ if (!strlen(set.host_id_list)) { sqlp = sqlbuf; - sqlp += snprintf(sqlp, BUFSIZE, "SELECT SQL_NO_CACHE action FROM poller_item"); - sqlp += snprintf(sqlp, BUFSIZE, " WHERE action=%d", POLLER_ACTION_PHP_SCRIPT_SERVER); + remaining = sizeof(sqlbuf); + spine_appendf(&sqlp, &remaining, "SELECT SQL_NO_CACHE action FROM poller_item"); + spine_appendf(&sqlp, &remaining, " WHERE action=%d", POLLER_ACTION_PHP_SCRIPT_SERVER); sqlp += append_hostrange(sqlp, "host_id"); - sqlp += snprintf(sqlp, BUFSIZE, " AND poller_id=%i", set.poller_id); - sqlp += snprintf(sqlp, BUFSIZE, " LIMIT 1"); + remaining = sizeof(sqlbuf) - (size_t) (sqlp - sqlbuf); + spine_appendf(&sqlp, &remaining, " AND poller_id=%i", set.poller_id); + spine_appendf(&sqlp, &remaining, " LIMIT 1"); result = db_query(&mysql, LOCAL, sqlbuf); num_rows = mysql_num_rows(result); @@ -844,11 +847,12 @@ void read_config_options(void) { num_rows)); } else { sqlp = sqlbuf; - sqlp += snprintf(sqlp, BUFSIZE, "SELECT SQL_NO_CACHE action FROM poller_item"); - sqlp += snprintf(sqlp, BUFSIZE, " WHERE action=%d", POLLER_ACTION_PHP_SCRIPT_SERVER); - sqlp += snprintf(sqlp, BUFSIZE, " AND host_id IN(%s)", set.host_id_list); - sqlp += snprintf(sqlp, BUFSIZE, " AND poller_id=%i", set.poller_id); - sqlp += snprintf(sqlp, BUFSIZE, " LIMIT 1"); + remaining = sizeof(sqlbuf); + spine_appendf(&sqlp, &remaining, "SELECT SQL_NO_CACHE action FROM poller_item"); + spine_appendf(&sqlp, &remaining, " WHERE action=%d", POLLER_ACTION_PHP_SCRIPT_SERVER); + spine_appendf(&sqlp, &remaining, " AND host_id IN(%s)", set.host_id_list); + spine_appendf(&sqlp, &remaining, " AND poller_id=%i", set.poller_id); + spine_appendf(&sqlp, &remaining, " LIMIT 1"); result = db_query(&mysql, LOCAL, sqlbuf); num_rows = mysql_num_rows(result); @@ -938,7 +942,7 @@ void poller_push_data_to_main(void) { int rows; char sqlbuf[HUGE_BUFSIZE]; char *sqlp = sqlbuf; - int remaining; + size_t remaining; char query[MEGA_BUFSIZE]; char prefix[BUFSIZE]; char suffix[BUFSIZE]; @@ -1037,80 +1041,80 @@ void poller_push_data_to_main(void) { if (rows == 0) { sqlp = sqlbuf; remaining = HUGE_BUFSIZE - (sqlp - sqlbuf); - sqlp += snprintf(sqlp, remaining, "%s", prefix); + spine_appendf(&sqlp, &remaining, "%s", prefix); remaining = HUGE_BUFSIZE - (sqlp - sqlbuf); - sqlp += snprintf(sqlp, remaining, " ("); + spine_appendf(&sqlp, &remaining, " ("); } else { remaining = HUGE_BUFSIZE - (sqlp - sqlbuf); - sqlp += snprintf(sqlp, remaining, ", ("); + spine_appendf(&sqlp, &remaining, ", ("); } remaining = HUGE_BUFSIZE - (sqlp - sqlbuf); - sqlp += snprintf(sqlp, remaining, "%s, ", row[0]); // id mediumint + spine_appendf(&sqlp, &remaining, "%s, ", row[0]); // id mediumint db_escape(&mysql, tmpstr, sizeof(tmpstr), row[1]); // snmp_sysDescr varchar(300) remaining = HUGE_BUFSIZE - (sqlp - sqlbuf); - sqlp += snprintf(sqlp, remaining, "'%s', ", tmpstr); + spine_appendf(&sqlp, &remaining, "'%s', ", tmpstr); db_escape(&mysql, tmpstr, sizeof(tmpstr), row[2]); // snmp_sysObjectID varchar(128) remaining = HUGE_BUFSIZE - (sqlp - sqlbuf); - sqlp += snprintf(sqlp, remaining, "'%s', ", tmpstr); + spine_appendf(&sqlp, &remaining, "'%s', ", tmpstr); db_escape(&mysql, tmpstr, sizeof(tmpstr), row[3]); // snmp_sysUpTimeInstance bigint remaining = HUGE_BUFSIZE - (sqlp - sqlbuf); - sqlp += snprintf(sqlp, remaining, "'%s', ", tmpstr); + spine_appendf(&sqlp, &remaining, "'%s', ", tmpstr); db_escape(&mysql, tmpstr, sizeof(tmpstr), row[4]); // snmp_sysContact varchar(300) remaining = HUGE_BUFSIZE - (sqlp - sqlbuf); - sqlp += snprintf(sqlp, remaining, "'%s', ", tmpstr); + spine_appendf(&sqlp, &remaining, "'%s', ", tmpstr); db_escape(&mysql, tmpstr, sizeof(tmpstr), row[5]); // snmp_sysName varchar(300) remaining = HUGE_BUFSIZE - (sqlp - sqlbuf); - sqlp += snprintf(sqlp, remaining, "'%s', ", tmpstr); + spine_appendf(&sqlp, &remaining, "'%s', ", tmpstr); db_escape(&mysql, tmpstr, sizeof(tmpstr), row[6]); // snmp_sysLocation varchar(300) remaining = HUGE_BUFSIZE - (sqlp - sqlbuf); - sqlp += snprintf(sqlp, remaining, "'%s', ", tmpstr); + spine_appendf(&sqlp, &remaining, "'%s', ", tmpstr); db_escape(&mysql, tmpstr, sizeof(tmpstr), row[7]); // status tinyint remaining = HUGE_BUFSIZE - (sqlp - sqlbuf); - sqlp += snprintf(sqlp, remaining, "'%s', ", tmpstr); + spine_appendf(&sqlp, &remaining, "'%s', ", tmpstr); remaining = HUGE_BUFSIZE - (sqlp - sqlbuf); - sqlp += snprintf(sqlp, remaining, "%s, ", row[8]); // status_event_count mediumint + spine_appendf(&sqlp, &remaining, "%s, ", row[8]); // status_event_count mediumint db_escape(&mysql, tmpstr, sizeof(tmpstr), row[9]); // status_fail_date timestamp remaining = HUGE_BUFSIZE - (sqlp - sqlbuf); - sqlp += snprintf(sqlp, remaining, "'%s', ", tmpstr); + spine_appendf(&sqlp, &remaining, "'%s', ", tmpstr); db_escape(&mysql, tmpstr, sizeof(tmpstr), row[10]); // status_rec_date timestamp remaining = HUGE_BUFSIZE - (sqlp - sqlbuf); - sqlp += snprintf(sqlp, remaining, "'%s', ", tmpstr); + spine_appendf(&sqlp, &remaining, "'%s', ", tmpstr); db_escape(&mysql, tmpstr, sizeof(tmpstr), row[11]); // status_last_error varchar(255) remaining = HUGE_BUFSIZE - (sqlp - sqlbuf); - sqlp += snprintf(sqlp, remaining, "'%s', ", tmpstr); + spine_appendf(&sqlp, &remaining, "'%s', ", tmpstr); remaining = HUGE_BUFSIZE - (sqlp - sqlbuf); - sqlp += snprintf(sqlp, remaining, "%s, ", row[12]); // min_time decimal(10,5) + spine_appendf(&sqlp, &remaining, "%s, ", row[12]); // min_time decimal(10,5) remaining = HUGE_BUFSIZE - (sqlp - sqlbuf); - sqlp += snprintf(sqlp, remaining, "%s, ", row[13]); // max_time decimal(10,5) + spine_appendf(&sqlp, &remaining, "%s, ", row[13]); // max_time decimal(10,5) remaining = HUGE_BUFSIZE - (sqlp - sqlbuf); - sqlp += snprintf(sqlp, remaining, "%s, ", row[14]); // cur_time decimal(10,5) + spine_appendf(&sqlp, &remaining, "%s, ", row[14]); // cur_time decimal(10,5) remaining = HUGE_BUFSIZE - (sqlp - sqlbuf); - sqlp += snprintf(sqlp, remaining, "%s, ", row[15]); // avg_time decimal(10,5) + spine_appendf(&sqlp, &remaining, "%s, ", row[15]); // avg_time decimal(10,5) remaining = HUGE_BUFSIZE - (sqlp - sqlbuf); - sqlp += snprintf(sqlp, remaining, "%s, ", row[16]); // polling_time double + spine_appendf(&sqlp, &remaining, "%s, ", row[16]); // polling_time double remaining = HUGE_BUFSIZE - (sqlp - sqlbuf); - sqlp += snprintf(sqlp, remaining, "%s, ", row[17]); // total_polls int + spine_appendf(&sqlp, &remaining, "%s, ", row[17]); // total_polls int remaining = HUGE_BUFSIZE - (sqlp - sqlbuf); - sqlp += snprintf(sqlp, remaining, "%s, ", row[18]); // failed_polls int + spine_appendf(&sqlp, &remaining, "%s, ", row[18]); // failed_polls int remaining = HUGE_BUFSIZE - (sqlp - sqlbuf); - sqlp += snprintf(sqlp, remaining, "%s, ", row[19]); // availability decimal(8,5) + spine_appendf(&sqlp, &remaining, "%s, ", row[19]); // availability decimal(8,5) db_escape(&mysql, tmpstr, sizeof(tmpstr), row[20]); // last_updated timestamp remaining = HUGE_BUFSIZE - (sqlp - sqlbuf); - sqlp += snprintf(sqlp, remaining, "'%s'", tmpstr); + spine_appendf(&sqlp, &remaining, "'%s'", tmpstr); remaining = HUGE_BUFSIZE - (sqlp - sqlbuf); - sqlp += snprintf(sqlp, remaining, ")"); + spine_appendf(&sqlp, &remaining, ")"); rows++; } else { remaining = HUGE_BUFSIZE - (sqlp - sqlbuf); - sqlp += snprintf(sqlp, remaining, "%s", suffix); + spine_appendf(&sqlp, &remaining, "%s", suffix); db_insert(&mysqlr, REMOTE, sqlbuf); rows = 0; @@ -1120,7 +1124,7 @@ void poller_push_data_to_main(void) { if (rows > 0) { remaining = HUGE_BUFSIZE - (sqlp - sqlbuf); - sqlp += snprintf(sqlp, remaining, "%s", suffix); + spine_appendf(&sqlp, &remaining, "%s", suffix); db_insert(&mysqlr, REMOTE, sqlbuf); } } @@ -1161,35 +1165,35 @@ void poller_push_data_to_main(void) { if (rows == 0) { sqlp = sqlbuf; remaining = HUGE_BUFSIZE - (sqlp - sqlbuf); - sqlp += snprintf(sqlp, remaining, "%s", prefix); + spine_appendf(&sqlp, &remaining, "%s", prefix); remaining = HUGE_BUFSIZE - (sqlp - sqlbuf); - sqlp += snprintf(sqlp, remaining, " ("); + spine_appendf(&sqlp, &remaining, " ("); } else { remaining = HUGE_BUFSIZE - (sqlp - sqlbuf); - sqlp += snprintf(sqlp, remaining, ", ("); + spine_appendf(&sqlp, &remaining, ", ("); } remaining = HUGE_BUFSIZE - (sqlp - sqlbuf); - sqlp += snprintf(sqlp, remaining, "%s, ", row[0]); // local_data_id + spine_appendf(&sqlp, &remaining, "%s, ", row[0]); // local_data_id remaining = HUGE_BUFSIZE - (sqlp - sqlbuf); - sqlp += snprintf(sqlp, remaining, "%s, ", row[1]); // host_id + spine_appendf(&sqlp, &remaining, "%s, ", row[1]); // host_id db_escape(&mysql, tmpstr, sizeof(tmpstr), row[2]); // rrd_name remaining = HUGE_BUFSIZE - (sqlp - sqlbuf); - sqlp += snprintf(sqlp, remaining, "'%s', ", tmpstr); + spine_appendf(&sqlp, &remaining, "'%s', ", tmpstr); remaining = HUGE_BUFSIZE - (sqlp - sqlbuf); - sqlp += snprintf(sqlp, remaining, "%s, ", row[3]); // rrd_step + spine_appendf(&sqlp, &remaining, "%s, ", row[3]); // rrd_step remaining = HUGE_BUFSIZE - (sqlp - sqlbuf); - sqlp += snprintf(sqlp, remaining, "%s", row[4]); // rrd_next_step + spine_appendf(&sqlp, &remaining, "%s", row[4]); // rrd_next_step remaining = HUGE_BUFSIZE - (sqlp - sqlbuf); - sqlp += snprintf(sqlp, remaining, ")"); + spine_appendf(&sqlp, &remaining, ")"); rows++; } else { remaining = HUGE_BUFSIZE - (sqlp - sqlbuf); - sqlp += snprintf(sqlp, remaining, "%s", suffix); + spine_appendf(&sqlp, &remaining, "%s", suffix); db_insert(&mysqlr, REMOTE, sqlbuf); rows = 0; @@ -1199,7 +1203,7 @@ void poller_push_data_to_main(void) { if (rows > 0) { remaining = HUGE_BUFSIZE - (sqlp - sqlbuf); - sqlp += snprintf(sqlp, remaining, "%s", suffix); + spine_appendf(&sqlp, &remaining, "%s", suffix); db_insert(&mysqlr, REMOTE, sqlbuf); rows = 0; @@ -2285,3 +2289,44 @@ const char *regex_replace(const char *exp, const char *value) { return (reti) ? value : msgbuf; } + +/*! \fn int spine_appendf(char **cursor, size_t *remaining, const char *fmt, ...) + * \brief append to a bounded buffer without walking off the end + * + * See util.h for why the `p += snprintf(...)` idiom this replaces is unsafe. + * + * \return TRUE when the whole string was appended, FALSE otherwise + */ +int spine_appendf(char **cursor, size_t *remaining, const char *fmt, ...) { + va_list args; + int written; + + if (cursor == NULL || *cursor == NULL || remaining == NULL || *remaining == 0) { + return FALSE; + } + + va_start(args, fmt); + written = vsnprintf(*cursor, *remaining, fmt, args); + va_end(args); + + if (written < 0) { + /* the buffer is untouched on an encoding error, but vsnprintf may have + written a partial result, so re-terminate where the cursor stands */ + **cursor = '\0'; + return FALSE; + } + + if ((size_t) written >= *remaining) { + /* Truncated. Leave the cursor on the terminator vsnprintf wrote, so + the buffer stays a valid string and every later append fails here + rather than running past the end. */ + *cursor += *remaining - 1; + *remaining = 1; + return FALSE; + } + + *cursor += written; + *remaining -= (size_t) written; + + return TRUE; +} diff --git a/util.h b/util.h index 557d1ed9..a6ba71b3 100644 --- a/util.h +++ b/util.h @@ -111,4 +111,20 @@ extern double start_time; /* the version of Cacti as a decimal */ int get_cacti_version(MYSQL *psql, int mode); +/*! \fn int spine_appendf(char **cursor, size_t *remaining, const char *fmt, ...) + * \brief append to a bounded buffer without walking off the end + * + * snprintf() returns the length it would have written, so `p += snprintf(p, + * remaining, ...)` moves the cursor past the buffer the first time a value is + * truncated. The next `remaining` is then negative, and as a size_t it is + * effectively unbounded, at a destination already out of bounds. + * + * This advances the cursor by what was actually written, stops on the + * terminator when the text does not fit, and says so. + * + * \return TRUE when the whole string was appended, FALSE on truncation or a + * formatting error, in which case the buffer stays NUL-terminated + */ +extern int spine_appendf(char **cursor, size_t *remaining, const char *fmt, ...); + #endif /* SPINE_UTIL_H */