commit 79f05bf96cd7e01f3482670fd236c624bba177e0 from: Isaac Meerleo date: Sat Jul 18 17:25:27 2026 UTC Enforce Artifact preview budgets for inline results commit - bd4ce114c04c569958acb4d8c7ecb00465221fb3 commit + 79f05bf96cd7e01f3482670fd236c624bba177e0 blob - 1090a5bf0d5dc1f8c9e76e79baba43a936f5db9a blob + dfeb75234cd47546b69f831bdd53ad9733c02a09 --- regress/artifact_result/artifact_result_test.c +++ regress/artifact_result/artifact_result_test.c @@ -24,6 +24,8 @@ static const char good_id[] = "0123456789abcdef0123456789abcdef"; +static void streamed_matches_contiguous(const void *, size_t, size_t); + static int field_count(const struct json *j, int obj, const char *name) { @@ -74,7 +76,164 @@ string_field(const struct json *j, int root, const cha return (json_get_str(j, json_obj_get(j, root, name), len)); } +static int +base64_value(u_char c) +{ + if (c >= 'A' && c <= 'Z') + return (c - 'A'); + if (c >= 'a' && c <= 'z') + return (c - 'a' + 26); + if (c >= '0' && c <= '9') + return (c - '0' + 52); + if (c == '+') + return (62); + if (c == '/') + return (63); + return (-1); +} + +static int +base64_decode(struct buf *out, const char *src, size_t len) +{ + uint32_t v; + int a, b, c, d; + size_t i; + + buf_reset(out); + if (len % 4 != 0) + return (-1); + for (i = 0; i < len; i += 4) { + a = base64_value((u_char)src[i]); + b = base64_value((u_char)src[i + 1]); + c = src[i + 2] == '=' ? -2 : + base64_value((u_char)src[i + 2]); + d = src[i + 3] == '=' ? -2 : + base64_value((u_char)src[i + 3]); + if (a < 0 || b < 0 || c == -1 || d == -1 || + (c == -2 && d != -2) || + ((c == -2 || d == -2) && i + 4 != len)) + return (-1); + v = ((uint32_t)a << 18) | ((uint32_t)b << 12); + if (c >= 0) + v |= (uint32_t)c << 6; + if (d >= 0) + v |= (uint32_t)d; + buf_addc(out, (u_char)(v >> 16)); + if (c >= 0) + buf_addc(out, (u_char)(v >> 8)); + if (d >= 0) + buf_addc(out, (u_char)v); + } + return (0); +} + +static size_t +encoded_field_len(const struct buf *out, const char *name) +{ + char pattern[64]; + const u_char *p = out->data; + size_t i, j, n, start; + int written; + + written = snprintf(pattern, sizeof(pattern), "\"%s\":\"", name); + if (written < 0 || (size_t)written >= sizeof(pattern)) + return (SIZE_MAX); + n = (size_t)written; + for (i = 0; i + n <= out->len; i++) { + if (memcmp(p + i, pattern, n) != 0) + continue; + start = i + n; + for (j = start; j < out->len;) { + if (p[j] == '"') + return (j - start); + if (p[j] == '\\') { + if (j + 1 >= out->len) + return (SIZE_MAX); + j += 2; + } else + j++; + } + return (SIZE_MAX); + } + return (SIZE_MAX); +} + +static int +preview_bytes(const struct json *j, int root, const char *field, + const char *encoding, struct buf *decoded) +{ + char *value; + size_t len; + int rc = 0; + + value = string_field(j, root, field, &len); + if (value == NULL) + return (-1); + buf_reset(decoded); + if (strcmp(encoding, "base64") == 0) + rc = base64_decode(decoded, value, len); + else if (strcmp(encoding, "utf-8") == 0) + buf_add(decoded, value, len); + else + rc = -1; + free(value); + return (rc); +} + static void +check_preview_result(const struct buf *out, const u_char *raw, size_t raw_len, + size_t head_raw_len, size_t tail_raw_len, const char *want_encoding, + int abbreviated) +{ + struct json j; + struct buf head, tail; + char *encoding, *id; + size_t elen, head_encoded, idlen, tail_encoded; + int root; + + CHECK(out->len <= ARTIFACT_RESULT_MAX); + CHECK(json_parse(&j, out->data, out->len, 0) == 0); + root = json_root(&j); + CHECK(json_get_bool(&j, json_obj_get(&j, root, + "preview_abbreviated")) == abbreviated); + CHECK(field_count(&j, root, "id") == abbreviated); + if (abbreviated) { + id = string_field(&j, root, "id", &idlen); + CHECK(id != NULL && idlen == sizeof(good_id) - 1 && + memcmp(id, good_id, idlen) == 0); + free(id); + } else + CHECK(json_obj_get(&j, root, "id") == -1); + encoding = string_field(&j, root, "preview_encoding", &elen); + CHECK(encoding != NULL && elen == strlen(want_encoding) && + memcmp(encoding, want_encoding, elen) == 0); + head_encoded = encoded_field_len(out, "preview_head"); + tail_encoded = encoded_field_len(out, "preview_tail"); + CHECK(head_encoded <= ARTIFACT_PREVIEW_HEAD_ENCODED_MAX); + CHECK(tail_encoded <= ARTIFACT_PREVIEW_TAIL_ENCODED_MAX); + CHECK(head_encoded != SIZE_MAX && tail_encoded != SIZE_MAX && + out->len >= head_encoded + tail_encoded && + out->len - head_encoded - tail_encoded <= + ARTIFACT_RESULT_META_RESERVE); + buf_init(&head); + buf_init(&tail); + CHECK(encoding != NULL && + preview_bytes(&j, root, "preview_head", want_encoding, &head) == 0); + CHECK(encoding != NULL && + preview_bytes(&j, root, "preview_tail", want_encoding, &tail) == 0); + CHECK(head.len == head_raw_len && tail.len == tail_raw_len); + CHECK(head.len <= raw_len && tail.len <= raw_len - head.len && + memcmp(head.data, raw, head.len) == 0 && + memcmp(tail.data, raw + raw_len - tail.len, tail.len) == 0); + if (!abbreviated) + CHECK(head.len + tail.len == raw_len); + buf_free(&tail); + buf_free(&head); + free(encoding); + json_done(&j); +} + +static void test_inline_and_forgery(void) { static const char *const keys[] = { @@ -228,22 +387,30 @@ test_exact_inline_boundaries(void) { static const struct { const char *name; - u_char byte; + u_char head_byte; + u_char marker_byte; + u_char tail_byte; const char *encoding; - size_t encoded_step; + size_t head_raw_max; + size_t tail_raw_max; } shapes[] = { - { "printable", 'x', "utf-8", 1 }, - { "escape-heavy", '\1', "utf-8", 6 }, - { "NUL", '\0', "utf-8", 6 }, - { "invalid UTF-8/base64", 0xff, "base64", 4 } + { "printable", 'h', 'm', 't', "utf-8", + ARTIFACT_PREVIEW_HEAD_ENCODED_MAX, + ARTIFACT_PREVIEW_TAIL_ENCODED_MAX }, + { "escape-heavy", '\1', '\2', '\3', "utf-8", + ARTIFACT_PREVIEW_HEAD_ENCODED_MAX / 6, + ARTIFACT_PREVIEW_TAIL_ENCODED_MAX / 6 }, + { "NUL", '\0', '\1', '\37', "utf-8", + ARTIFACT_PREVIEW_HEAD_ENCODED_MAX / 6, + ARTIFACT_PREVIEW_TAIL_ENCODED_MAX / 6 }, + { "invalid UTF-8/base64", 0xfd, 0xff, 0xfe, "base64", + ARTIFACT_PREVIEW_HEAD_ENCODED_MAX / 4 * 3, + ARTIFACT_PREVIEW_TAIL_ENCODED_MAX / 4 * 3 } }; struct artifact_result in; - struct json j; struct buf out; - char *encoding; u_char *raw; - size_t elen, hi, i, lo, mid; - int root; + size_t i, max; raw = malloc(ARTIFACT_RESULT_MAX + 1); CHECK(raw != NULL); @@ -251,49 +418,38 @@ test_exact_inline_boundaries(void) return; buf_init(&out); for (i = 0; i < sizeof(shapes) / sizeof(shapes[0]); i++) { - memset(raw, shapes[i].byte, ARTIFACT_RESULT_MAX + 1); - lo = 0; - hi = ARTIFACT_RESULT_MAX + 1; - while (lo + 1 < hi) { - mid = lo + (hi - lo) / 2; - if (artifact_result_inline_fits(raw, mid)) - lo = mid; - else - hi = mid; - } - CHECK(lo != 0 && artifact_result_inline_fits(raw, lo)); - CHECK(!artifact_result_inline_fits(raw, lo + 1)); + max = shapes[i].head_raw_max + shapes[i].tail_raw_max; + memset(raw, shapes[i].head_byte, shapes[i].head_raw_max); + memset(raw + shapes[i].head_raw_max, shapes[i].tail_byte, + shapes[i].tail_raw_max); + CHECK(artifact_result_inline_fits(raw, max)); memset(&in, 0, sizeof(in)); in.raw = raw; - in.raw_len = lo; - in.observed_bytes = lo; - in.retained_bytes = lo; + in.raw_len = max; + in.observed_bytes = max; + in.retained_bytes = max; in.termination = ARTIFACT_TERM_EXIT; CHECK(artifact_result_build(&out, &in) == 0 && - out.len <= ARTIFACT_RESULT_MAX && - ARTIFACT_RESULT_MAX - out.len < shapes[i].encoded_step); - CHECK(json_parse(&j, out.data, out.len, 0) == 0); - root = json_root(&j); - CHECK(json_get_bool(&j, json_obj_get(&j, root, - "preview_abbreviated")) == 0); - encoding = string_field(&j, root, "preview_encoding", &elen); - CHECK(encoding != NULL && elen == strlen(shapes[i].encoding) && - memcmp(encoding, shapes[i].encoding, elen) == 0); - free(encoding); - json_done(&j); + out.len <= ARTIFACT_RESULT_MAX); + check_preview_result(&out, raw, max, shapes[i].head_raw_max, + shapes[i].tail_raw_max, shapes[i].encoding, 0); + streamed_matches_contiguous(raw, max, 7919); - in.raw_len = lo + 1; - in.observed_bytes = lo + 1; - in.retained_bytes = lo + 1; + memmove(raw + shapes[i].head_raw_max + 1, + raw + shapes[i].head_raw_max, shapes[i].tail_raw_max); + raw[shapes[i].head_raw_max] = shapes[i].marker_byte; + CHECK(!artifact_result_inline_fits(raw, max + 1)); + in.raw_len = max + 1; + in.observed_bytes = max + 1; + in.retained_bytes = max + 1; in.id = good_id; CHECK(artifact_result_build(&out, &in) == 0 && out.len <= ARTIFACT_RESULT_MAX); - CHECK(json_parse(&j, out.data, out.len, 0) == 0); - root = json_root(&j); - CHECK(json_get_bool(&j, json_obj_get(&j, root, - "preview_abbreviated")) == 1); - json_done(&j); + check_preview_result(&out, raw, max + 1, + shapes[i].head_raw_max, shapes[i].tail_raw_max, + shapes[i].encoding, 1); + streamed_matches_contiguous(raw, max + 1, 16381); (void)shapes[i].name; } buf_free(&out); @@ -301,6 +457,75 @@ test_exact_inline_boundaries(void) } static void +test_multibyte_inline_boundary(void) +{ + static const u_char euro[] = { 0xe2, 0x82, 0xac }; + static const u_char heart[] = { 0xe2, 0x99, 0xa5 }; + static const u_char snowman[] = { 0xe2, 0x98, 0x83 }; + struct artifact_result in; + struct buf out; + u_char *raw; + size_t head_len, i, max, tail_len; + + head_len = ARTIFACT_PREVIEW_HEAD_ENCODED_MAX / sizeof(euro) * + sizeof(euro); + tail_len = ARTIFACT_PREVIEW_TAIL_ENCODED_MAX / sizeof(snowman) * + sizeof(snowman); + max = head_len + tail_len; + raw = malloc(max + sizeof(heart)); + CHECK(raw != NULL); + if (raw == NULL) + return; + for (i = 0; i < head_len; i += sizeof(euro)) + memcpy(raw + i, euro, sizeof(euro)); + for (i = 0; i < tail_len; i += sizeof(snowman)) + memcpy(raw + head_len + i, snowman, sizeof(snowman)); + CHECK(artifact_result_inline_fits(raw, max)); + + memset(&in, 0, sizeof(in)); + in.raw = raw; + in.raw_len = max; + in.observed_bytes = max; + in.retained_bytes = max; + in.termination = ARTIFACT_TERM_EXIT; + buf_init(&out); + CHECK(artifact_result_build(&out, &in) == 0 && + out.len <= ARTIFACT_RESULT_MAX); + check_preview_result(&out, raw, max, head_len, tail_len, "utf-8", 0); + streamed_matches_contiguous(raw, max, 4093); + + /* A complete rune between the exact spans is omitted without splitting + * either multibyte boundary. */ + memmove(raw + head_len + sizeof(heart), raw + head_len, tail_len); + memcpy(raw + head_len, heart, sizeof(heart)); + CHECK(!artifact_result_inline_fits(raw, max + sizeof(heart))); + in.raw_len = max + sizeof(heart); + in.observed_bytes = in.raw_len; + in.retained_bytes = in.raw_len; + in.id = good_id; + CHECK(artifact_result_build(&out, &in) == 0); + check_preview_result(&out, raw, in.raw_len, head_len, tail_len, + "utf-8", 1); + streamed_matches_contiguous(raw, in.raw_len, 8191); + + /* A partial rune makes the complete stream binary; base64 still selects + * the exact earliest and latest raw bytes within its own budgets. */ + raw[head_len] = heart[0]; + memmove(raw + head_len + 1, raw + head_len + sizeof(heart), tail_len); + in.raw_len = max + 1; + in.observed_bytes = in.raw_len; + in.retained_bytes = in.raw_len; + CHECK(!artifact_result_inline_fits(raw, in.raw_len)); + CHECK(artifact_result_build(&out, &in) == 0); + check_preview_result(&out, raw, in.raw_len, + ARTIFACT_PREVIEW_HEAD_ENCODED_MAX / 4 * 3, + ARTIFACT_PREVIEW_TAIL_ENCODED_MAX / 4 * 3, "base64", 1); + streamed_matches_contiguous(raw, in.raw_len, 12289); + buf_free(&out); + free(raw); +} + +static void test_error_metadata_is_not_spoofable(void) { static const char detail[] = "spawn failed before capture"; @@ -427,6 +652,7 @@ test_escape_heavy_error_budget(void) { struct artifact_result in; struct buf raw, error, out; + size_t head_encoded, tail_encoded; buf_init(&raw); buf_init(&error); @@ -447,6 +673,13 @@ test_escape_heavy_error_budget(void) in.error_len = error.len; CHECK(artifact_result_build(&out, &in) == 0); CHECK(out.len <= ARTIFACT_RESULT_MAX); + head_encoded = encoded_field_len(&out, "preview_head"); + tail_encoded = encoded_field_len(&out, "preview_tail"); + CHECK(head_encoded <= ARTIFACT_PREVIEW_HEAD_ENCODED_MAX && + tail_encoded <= ARTIFACT_PREVIEW_TAIL_ENCODED_MAX && + out.len >= head_encoded + tail_encoded && + out.len - head_encoded - tail_encoded <= + ARTIFACT_RESULT_META_RESERVE); buf_addc(&error, '\1'); in.error_len = error.len; CHECK(artifact_result_build(&out, &in) == -1); @@ -540,6 +773,7 @@ main(void) test_binary_base64(); test_boundary_selection(); test_exact_inline_boundaries(); + test_multibyte_inline_boundary(); test_error_metadata_is_not_spoofable(); test_json_integer_range(); test_empty_streaming_capture_is_inline(); blob - de0a9757de6e43f56feae09c70cb629d4c2e5c52 blob + 010b0e3a50f631a8baadbb405958703a312b986d --- src/fugu/artifact_result.c +++ src/fugu/artifact_result.c @@ -153,6 +153,39 @@ base64_raw_max(size_t budget) } static void +preview_spans(const u_char *raw, size_t len, int utf8, + struct preview_span *head, struct preview_span *tail) +{ + size_t maxraw; + + if (utf8) { + utf8_head(raw, len, ARTIFACT_PREVIEW_HEAD_ENCODED_MAX, head); + utf8_tail(raw, len, ARTIFACT_PREVIEW_TAIL_ENCODED_MAX, tail); + if (head->len + tail->len > len) { + tail->data = raw + head->len; + tail->len = len - head->len; + } + return; + } + maxraw = base64_raw_max(ARTIFACT_PREVIEW_HEAD_ENCODED_MAX); + head->data = raw; + head->len = len < maxraw ? len : maxraw; + maxraw = base64_raw_max(ARTIFACT_PREVIEW_TAIL_ENCODED_MAX); + tail->len = len - head->len; + if (tail->len > maxraw) + tail->len = maxraw; + tail->data = raw + len - tail->len; +} + +static int +preview_complete(const u_char *raw, size_t len, + const struct preview_span *head, const struct preview_span *tail) +{ + return (head->data == raw && tail->data == raw + head->len && + head->len <= len && tail->len == len - head->len); +} + +static void base64_add(struct buf *out, const u_char *p, size_t len) { static const char table[] = @@ -336,36 +369,15 @@ emit_result(struct buf *out, const struct artifact_res const u_char *raw = in->raw != NULL ? in->raw : ∅ struct preview_span head, tail; int utf8, abbreviated; - size_t maxraw; if (result_valid(in, 1) == -1) return (-1); utf8 = valid_utf8(raw, in->raw_len); + preview_spans(raw, in->raw_len, utf8, &head, &tail); abbreviated = !inline_ok; - if (!abbreviated) { - head.data = raw; - head.len = in->raw_len; - tail.data = raw + in->raw_len; - tail.len = 0; - } else if (utf8) { - utf8_head(raw, in->raw_len, - ARTIFACT_PREVIEW_HEAD_ENCODED_MAX, &head); - utf8_tail(raw, in->raw_len, - ARTIFACT_PREVIEW_TAIL_ENCODED_MAX, &tail); - if (head.len + tail.len > in->raw_len) { - tail.data = raw + head.len; - tail.len = in->raw_len - head.len; - } - } else { - maxraw = base64_raw_max(ARTIFACT_PREVIEW_HEAD_ENCODED_MAX); - head.data = raw; - head.len = in->raw_len < maxraw ? in->raw_len : maxraw; - maxraw = base64_raw_max(ARTIFACT_PREVIEW_TAIL_ENCODED_MAX); - tail.len = in->raw_len - head.len; - if (tail.len > maxraw) - tail.len = maxraw; - tail.data = raw + in->raw_len - tail.len; - } + if (!abbreviated && + !preview_complete(raw, in->raw_len, &head, &tail)) + return (-1); return (emit_spans(out, in, &head, &tail, utf8, abbreviated)); }