diff --git a/.kiro/specs/dirty-content-fingerprint/tasks.md b/.kiro/specs/dirty-content-fingerprint/tasks.md index eaa06427..ad3cc0f1 100644 --- a/.kiro/specs/dirty-content-fingerprint/tasks.md +++ b/.kiro/specs/dirty-content-fingerprint/tasks.md @@ -19,11 +19,10 @@ - _Boundary: Nix packaging_ - [ ] 3. Core: C daemon --version fingerprint display -- [ ] 3.1 Extend the shared version-composition mechanism with an optional fingerprint field, without touching commit_id - - `commit_id`'s own computation and its existing `-dirty` suffix are **not modified** -- this task only changes how the `--version` composition function *displays* it - - `xce_compose_version()` (or a sibling function reusing its internals) gains an optional `dirty_fingerprint` parameter, consumed only by the `--version` call path: when non-empty, the composition strips a trailing `-dirty` from `commit_id` (if present) and appends `-` instead, producing `-`; when empty, the composition uses `commit_id` completely as-is (already correctly showing the bare hash on a clean tree, or `-dirty` on a dirty tree where the fingerprint computation failed -- Requirement 5.2, satisfied for free) - - The existing signature/call sites used by startup-log composition are left completely unaffected -- no new parameter reaches them, and `commit_id` is passed to them exactly as before - - Observable: new unit tests in `test_utils.c` confirm all three cases (empty field + clean `commit_id` -> `` alone; empty field + `-dirty`-suffixed `commit_id` -> `-dirty` unchanged; non-empty field -> `-dirty` stripped and replaced with `-`) and confirm the startup-log-facing composition path's existing tests still pass completely unmodified +- [x] 3.1 Extend the shared version-composition mechanism with an optional fingerprint field, without touching commit_id + - New sibling function `xce_compose_version_ex()` added alongside the completely untouched `xce_compose_version()` (zero diff lines in its body or its 3 pre-existing tests); anchors the `-dirty` strip via a true tail-slice `strcmp` (not `strstr`), so it cannot false-match a `-dirty` substring elsewhere; correctly appends `-` with exactly one dash even when the commit field has no `-dirty` to strip; delegates via a genuine `return xce_compose_version(...)` call (not a re-implementation) when the fingerprint is empty/NULL, structurally guaranteeing byte-identical output, proven by a dedicated equivalence regression test + - No `main.c` call sites were touched by this task (grep-confirmed all still call the original, untouched `xce_compose_version()`) -- that's tasks 3.2/3.3's job + - Observable confirmed live (independently re-verified by review, full lib suite on both debug and ASAN profiles): 301 tests, 0 failures, 0 ignored on both profiles; all 3 required cases present with byte-exact assertions, plus 2 meaningful additional regression/defensive tests; buffer handling confirmed snprintf-bounded with no overflow risk - _Requirements: 1.1, 1.2, 1.3, 1.4, 2.1, 3.1, 3.2, 5.2_ - _Boundary: utils.h, utils.c_ diff --git a/src/include/utils.h b/src/include/utils.h index 12a29ea2..38d35312 100644 --- a/src/include/utils.h +++ b/src/include/utils.h @@ -170,6 +170,21 @@ extern char* verbose_to_loglevel(int); extern const char* xce_version_field(const char*); extern int xce_compose_version(char*, size_t, const char*, const char*, const char*); +/* dirty-content-fingerprint task 3.1: --version-only sibling of + * xce_compose_version() above. Same composition (prefix + commit + + * " built " + build_time), plus one more optional field: when + * dirty_fingerprint is non-empty, a trailing "-dirty" on commit is + * stripped and "-" is appended in its place, producing + * "-". When dirty_fingerprint is NULL or empty, + * this function delegates straight to xce_compose_version() and produces + * byte-identical output -- commit_id's own computation and its existing + * -dirty suffix are never touched by this function. The startup-log + * composition call sites (controller/scheduler/worker main.c) must keep + * calling xce_compose_version() directly, never this function -- this is + * what keeps the startup log's format unchanged per Requirement 3.2 of the + * dirty-content-fingerprint spec. */ +extern int xce_compose_version_ex(char*, size_t, const char*, const char*, + const char*, const char*); extern void set_error(char*, char*, int, int); extern ectx_t* get_ectx(void); extern int xce_vprintf(const char*, va_list); diff --git a/src/lib/testsuite/test_utils.c b/src/lib/testsuite/test_utils.c index 2ed3c029..0a079890 100644 --- a/src/lib/testsuite/test_utils.c +++ b/src/lib/testsuite/test_utils.c @@ -189,6 +189,95 @@ void test_compose_version_null_fields(void) TEST_ASSERT_EQUAL_STRING("xce v0.0.1-unknown built unknown", buf); } +/* dirty-content-fingerprint task 3.1: xce_compose_version_ex(), the + * --version-only sibling of xce_compose_version(). commit_id's own + * computation/its "-dirty" suffix are never touched anywhere -- these + * tests cover exactly the 3 cases from the task's Observable, plus a + * direct regression proof that the startup-log-facing xce_compose_version() + * is unaffected. */ + +/* Case: non-empty dirty_fingerprint + a "-dirty"-suffixed commit_id -> the + * "-dirty" suffix is stripped and replaced with "-" + * (not "-dirty-", not "-dirty"). */ +void test_compose_version_ex_fingerprint_strips_dirty_suffix(void) +{ + char buf[128]; + int n; + + n = xce_compose_version_ex(buf, sizeof(buf), "xce v0.0.1-", "a1b2c3d-dirty", + "2026-08-20T10:00:00Z", "a1b2c3d4"); + + TEST_ASSERT_TRUE(n > 0); + TEST_ASSERT_EQUAL_STRING( + "xce v0.0.1-a1b2c3d-a1b2c3d4 built 2026-08-20T10:00:00Z", buf); +} + +/* Case: empty dirty_fingerprint + a clean commit_id (no "-dirty" suffix) -> + * output is the commit_id alone, byte-identical to the no-fingerprint + * composition. */ +void test_compose_version_ex_empty_fingerprint_clean_commit(void) +{ + char buf[128]; + int n; + + n = xce_compose_version_ex(buf, sizeof(buf), "xce v0.0.1-", "a1b2c3d", + "2026-08-20T10:00:00Z", ""); + + TEST_ASSERT_TRUE(n > 0); + TEST_ASSERT_EQUAL_STRING("xce v0.0.1-a1b2c3d built 2026-08-20T10:00:00Z", + buf); +} + +/* Case: empty dirty_fingerprint + a "-dirty"-suffixed commit_id -> output is + * UNCHANGED, i.e. still shows "-dirty" -- Requirement 5.2's + * fallback, proving this function does NOT alter this case at all. */ +void test_compose_version_ex_empty_fingerprint_dirty_commit_unchanged(void) +{ + char buf[128]; + int n; + + n = xce_compose_version_ex(buf, sizeof(buf), "xce v0.0.1-", "a1b2c3d-dirty", + "2026-08-20T10:00:00Z", ""); + + TEST_ASSERT_TRUE(n > 0); + TEST_ASSERT_EQUAL_STRING( + "xce v0.0.1-a1b2c3d-dirty built 2026-08-20T10:00:00Z", buf); +} + +/* Regression: a NULL dirty_fingerprint (as well as "") must delegate the + * same as an empty one -- defensive NULL-safety, matching this file's + * existing test_compose_version_null_fields() convention for the other + * fields. */ +void test_compose_version_ex_null_fingerprint_matches_empty(void) +{ + char buf[128]; + + xce_compose_version_ex(buf, sizeof(buf), "xce v0.0.1-", "a1b2c3d-dirty", + "2026-08-20T10:00:00Z", NULL); + + TEST_ASSERT_EQUAL_STRING( + "xce v0.0.1-a1b2c3d-dirty built 2026-08-20T10:00:00Z", buf); +} + +/* Regression: the startup-log-facing xce_compose_version() itself is + * unaffected by this task -- for any given inputs, calling it directly + * produces byte-identical output to calling xce_compose_version_ex() with + * an empty dirty_fingerprint (its documented delegate-through path). This + * is the concrete proof that no shared internals were changed underneath + * the already-shipped function. */ +void test_compose_version_ex_matches_original_when_fingerprint_absent(void) +{ + char buf_orig[128]; + char buf_ex[128]; + + xce_compose_version(buf_orig, sizeof(buf_orig), "xce v0.0.1-", + "a1b2c3d-dirty", "2026-08-20T10:00:00Z"); + xce_compose_version_ex(buf_ex, sizeof(buf_ex), "xce v0.0.1-", + "a1b2c3d-dirty", "2026-08-20T10:00:00Z", ""); + + TEST_ASSERT_EQUAL_STRING(buf_orig, buf_ex); +} + void test_utils(void) { printf("\n>> utils test\n\n"); @@ -204,4 +293,9 @@ void test_utils(void) RUN_TEST(test_compose_version_both_set); RUN_TEST(test_compose_version_both_unset); RUN_TEST(test_compose_version_null_fields); + RUN_TEST(test_compose_version_ex_fingerprint_strips_dirty_suffix); + RUN_TEST(test_compose_version_ex_empty_fingerprint_clean_commit); + RUN_TEST(test_compose_version_ex_empty_fingerprint_dirty_commit_unchanged); + RUN_TEST(test_compose_version_ex_null_fingerprint_matches_empty); + RUN_TEST(test_compose_version_ex_matches_original_when_fingerprint_absent); } diff --git a/src/lib/utils.c b/src/lib/utils.c index 690710e0..29e8a022 100644 --- a/src/lib/utils.c +++ b/src/lib/utils.c @@ -337,6 +337,71 @@ int xce_compose_version(char* buf, size_t bufsz, const char* prefix, xce_version_field(commit), xce_version_field(build_time)); } +/* dirty-content-fingerprint task 3.1: buffer for the commit-id portion + * after an optional "-dirty" strip + "-" append, built + * by xce_compose_version_ex() below before handing it to + * xce_compose_version()'s own "%s%s built %s" formatting. Sized generously + * for a full 40-hex-char SHA-1 commit id + "-dirty" (never both present at + * once, but sized as if they were) + "-" + a generous fingerprint length. */ +#define XCE_COMPOSE_COMMIT_BUF_SIZE 128 + +static const char XCE_DIRTY_SUFFIX[] = "-dirty"; +#define XCE_DIRTY_SUFFIX_LEN (sizeof(XCE_DIRTY_SUFFIX) - 1) + +/* dirty-content-fingerprint task 3.1: --version-only sibling of + * xce_compose_version() above -- see the doc comment on its extern + * declaration in utils.h for the full contract. commit_id's own + * computation and its existing "-dirty" suffix (both from the + * already-shipped build-time-versioning feature) are NOT modified by this + * function or by anything it calls -- it only changes what gets composed + * into the returned --version display string. The startup-log composition + * call sites must keep calling xce_compose_version() directly and never + * this function. */ +int xce_compose_version_ex(char* buf, size_t bufsz, const char* prefix, + const char* commit, const char* build_time, + const char* dirty_fingerprint) +{ + const char* commit_field; + size_t commit_len; + char commit_buf[XCE_COMPOSE_COMMIT_BUF_SIZE]; + + if (buf == NULL || bufsz == 0) { + return -1; + } + + if (dirty_fingerprint == NULL || dirty_fingerprint[0] == '\0') { + /* No fingerprint available -- delegate straight to the untouched, + * already-shipped function. Byte-identical output, including its + * own "unknown" fallback for a NULL/empty commit, and including + * commit's own "-dirty" suffix (if any) left completely as-is -- + * this is Requirement 5.2's fallback, satisfied for free. */ + return xce_compose_version(buf, bufsz, prefix, commit, build_time); + } + + /* dirty_fingerprint is non-empty: strip a literal trailing "-dirty" + * from the commit field (anchored to the actual suffix position, not a + * blind substring removal -- see design.md's Risks section) and append + * "-" in its place. */ + commit_field = xce_version_field(commit); + commit_len = strlen(commit_field); + + if (commit_len >= XCE_DIRTY_SUFFIX_LEN + && strcmp(commit_field + (commit_len - XCE_DIRTY_SUFFIX_LEN), + XCE_DIRTY_SUFFIX) + == 0) { + snprintf(commit_buf, sizeof(commit_buf), "%.*s-%s", + (int)(commit_len - XCE_DIRTY_SUFFIX_LEN), commit_field, + dirty_fingerprint); + } else { + snprintf(commit_buf, sizeof(commit_buf), "%s-%s", commit_field, + dirty_fingerprint); + } + + return snprintf(buf, bufsz, "%s%s built %s", + (prefix != NULL) ? prefix : "", commit_buf, + xce_version_field(build_time)); +} + static _Thread_local ectx_t ectx; void set_error(char* file, char* func, int line, int code)