diff --git a/database/src/wal.c b/database/src/wal.c index 58e3d72..2b79021 100644 --- a/database/src/wal.c +++ b/database/src/wal.c @@ -10,6 +10,7 @@ #include #include #include +#include #include /* ---- crc32 (poly 0xEDB88320) — ported from runtime/wo-rt.c ------------- */ @@ -366,6 +367,45 @@ int wo_wal_open(wo_wal *w, const char *path, uint64_t prealloc) { return 0; } +/* dirname(3) without its allocation and locale baggage: "a/b" → "a", "/b" → + * "/", "b" → ".". The boot-time parent check (wo_wal_resolve_data_path) and + * the post-rename fsync (sync_parent_dir) MUST agree on what the parent is; + * this is the one place that decides. 0 ok, -1 when [path] does not fit. */ +static int parent_dir_of(const char *path, char *dir, size_t cap) { + size_t n = strlen(path); + if (n >= cap) return -1; + memcpy(dir, path, n + 1); + char *slash = strrchr(dir, '/'); + if (slash == dir) dir[1] = '\0'; + else if (slash) *slash = '\0'; + else memcpy(dir, ".", 2); + return 0; +} + +int wo_wal_resolve_data_path(const char *wo_data, char *out, size_t cap) { + size_t n = strlen(wo_data); + struct stat st; + int have = stat(wo_data, &st) == 0; + if ((n && wo_data[n - 1] == '/') || (have && S_ISDIR(st.st_mode))) { + /* the directory form: byte for byte what main.c produced before this + * function existed, doubled slash after a trailing '/' included */ + if ((size_t)snprintf(out, cap, "%s/shard-0.wal", wo_data) >= cap) + return WO_WAL_PATH_TOO_LONG; + return 0; + } + if (have && !S_ISREG(st.st_mode)) return WO_WAL_PATH_NOT_A_FILE; + if (!have) { + /* absent: wo_wal_open creates it, but only where a directory already + * is — the parent is handed back so the refusal can name it */ + if (parent_dir_of(wo_data, out, cap) != 0) return WO_WAL_PATH_TOO_LONG; + struct stat pst; + if (stat(out, &pst) != 0 || !S_ISDIR(pst.st_mode)) return WO_WAL_PATH_NO_PARENT; + } + if (n >= cap) return WO_WAL_PATH_TOO_LONG; + memcpy(out, wo_data, n + 1); + return 0; +} + int wo_wal_pend_drop(wo_wal *w, uint32_t cid, uint64_t id, uint64_t off) { if (w->pend_len == w->pend_cap) { size_t nc = w->pend_cap ? w->pend_cap * 2 : 16; @@ -1015,13 +1055,7 @@ int wo_wal_should_compact(uint64_t used, uint64_t last, uint64_t floor, uint32_t * cut. */ static void sync_parent_dir(const char *path) { char dir[4096]; - size_t n = strlen(path); - if (n >= sizeof dir) return; - memcpy(dir, path, n + 1); - char *slash = strrchr(dir, '/'); - if (slash == dir) dir[1] = '\0'; - else if (slash) *slash = '\0'; - else memcpy(dir, ".", 2); + if (parent_dir_of(path, dir, sizeof dir) != 0) return; int fd = open(dir, O_RDONLY); if (fd < 0) return; (void)fsync(fd); diff --git a/database/src/wal.h b/database/src/wal.h index 8822929..fcc9d9a 100644 --- a/database/src/wal.h +++ b/database/src/wal.h @@ -247,6 +247,22 @@ static inline uint64_t wo_wal_next_offset(const wo_wal *w) { return w->off + w-> int wo_wal_open(wo_wal *w, const char *path, uint64_t prealloc); void wo_wal_close(wo_wal *w); +/* databasev2 7: WO_DATA names the store as EITHER a directory or the log file. + * An existing directory or a trailing '/' resolves to "/shard-0.wal" — + * the bytes every deployment before this iteration used, unchanged. Anything + * else IS the log: an existing regular file is opened, an absent path is + * created by wo_wal_open — but only under a parent directory that exists NOW. + * The resolver never mkdirs: a typo must not plant a store somewhere + * unexpected. Pure — stats, creates nothing. 0 ok, [out] = the log path; + * WO_WAL_PATH_NO_PARENT, [out] = the parent that is not an existing directory + * (for the refusal line); WO_WAL_PATH_NOT_A_FILE: exists, neither a regular + * file nor a directory (fifo, socket, device); WO_WAL_PATH_TOO_LONG: the + * result would not fit [cap] — refused, never truncated. */ +#define WO_WAL_PATH_NO_PARENT -1 +#define WO_WAL_PATH_NOT_A_FILE -2 +#define WO_WAL_PATH_TOO_LONG -3 +int wo_wal_resolve_data_path(const char *wo_data, char *out, size_t cap); + /* Stage a record for the row that MUST already be applied to RAM (the * commit-order doctrine). Insert/update read the row via wo_row_ptr. * 0 ok, -1 OOM / no such row. */ diff --git a/runtime/src/main.c b/runtime/src/main.c index 17cb855..aaeafa4 100644 --- a/runtime/src/main.c +++ b/runtime/src/main.c @@ -291,7 +291,29 @@ int main(int argc, char **argv) { int have_schema = 0; if (data_dir && data_dir[0]) { char wal_path[512]; - snprintf(wal_path, sizeof wal_path, "%s/shard-0.wal", data_dir); + /* databasev2 7: WO_DATA is a directory (→ /shard-0.wal, as it + * always was) or THE log file, created if absent. Refuse rather than + * guess: a missing parent is never created, and a path that is + * neither a regular file nor a directory is not a store. */ + int prc = wo_wal_resolve_data_path(data_dir, wal_path, sizeof wal_path); + if (prc != 0) { + if (prc == WO_WAL_PATH_NO_PARENT) + fprintf(stderr, + "wovm: WO_DATA=%s — its parent %s is not an existing " + "directory; create it first (wovm never runs mkdir -p).\n", + data_dir, wal_path); + else if (prc == WO_WAL_PATH_NOT_A_FILE) + fprintf(stderr, + "wovm: WO_DATA=%s exists but is neither a regular file " + "nor a directory.\n", + data_dir); + else + fprintf(stderr, "wovm: WO_DATA=%s is too long for a path.\n", data_dir); + wo_db_destroy(&DB); + wo_vm_destroy(&VM); + wo_module_free(&mod); + return 2; + } /* databasev2 12: the log's head record states the shape that wrote * it. Diff it against the compiled classes BEFORE replay: a matching * shape replays as-is, add/delete migrates the log in place through diff --git a/runtime/test/test_wal.c b/runtime/test/test_wal.c index cb61554..33d23f1 100644 --- a/runtime/test/test_wal.c +++ b/runtime/test/test_wal.c @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -297,6 +298,71 @@ static void test_stale_compact_temp_is_removed(void) { wo_rt_destroy(&rt); } +/* databasev2 7: WO_DATA names EITHER a directory (today's form, /shard-0.wal + * byte for byte) or THE log file. Resolution is a pure function so main.c's + * only branch is "print the refusal, exit 2" — and so every arm of the rule is + * checkable here rather than by booting wovm against the filesystem. */ +static void test_resolve_data_path(void) { + char in[192], out[256], want[256]; + + /* an existing directory: exactly the bytes main.c always produced */ + T_EQ(wo_wal_resolve_data_path(g_dir, out, sizeof out), 0); + snprintf(want, sizeof want, "%s/shard-0.wal", g_dir); + T_STREQ(out, want); + + /* a trailing slash keeps the directory form even when nothing exists + there — including the doubled slash today's snprintf produced */ + snprintf(in, sizeof in, "%s/nodir/", g_dir); + T_EQ(wo_wal_resolve_data_path(in, out, sizeof out), 0); + snprintf(want, sizeof want, "%s/nodir//shard-0.wal", g_dir); + T_STREQ(out, want); + + /* absent file under an existing parent: the path IS the log; resolution + itself creates nothing (wo_wal_open's O_CREAT does, later) */ + snprintf(in, sizeof in, "%s/app.db", g_dir); + T_EQ(wo_wal_resolve_data_path(in, out, sizeof out), 0); + T_STREQ(out, in); + T_CHECK(access(in, F_OK) != 0); + + /* an existing regular file: opened as the log */ + { + int fd = open(in, O_WRONLY | O_CREAT, 0644); + T_CHECK(fd >= 0); + close(fd); + } + T_EQ(wo_wal_resolve_data_path(in, out, sizeof out), 0); + T_STREQ(out, in); + + /* a bare relative name: its parent is ".", which always exists */ + T_EQ(wo_wal_resolve_data_path("app.db", out, sizeof out), 0); + T_STREQ(out, "app.db"); + + /* missing parent: refused, the PARENT is handed back for the message, + and nothing was mkdir'd on the way */ + snprintf(in, sizeof in, "%s/nodir/app.db", g_dir); + T_EQ(wo_wal_resolve_data_path(in, out, sizeof out), WO_WAL_PATH_NO_PARENT); + snprintf(want, sizeof want, "%s/nodir", g_dir); + T_STREQ(out, want); + T_CHECK(access(want, F_OK) != 0); + + /* the parent exists but is a FILE (ENOTDIR): same refusal, same subject */ + snprintf(in, sizeof in, "%s/app.db/x.db", g_dir); + T_EQ(wo_wal_resolve_data_path(in, out, sizeof out), WO_WAL_PATH_NO_PARENT); + snprintf(want, sizeof want, "%s/app.db", g_dir); + T_STREQ(out, want); + + /* exists, but neither a regular file nor a directory */ + snprintf(in, sizeof in, "%s/fifo.db", g_dir); + T_EQ(mkfifo(in, 0600), 0); + T_EQ(wo_wal_resolve_data_path(in, out, sizeof out), WO_WAL_PATH_NOT_A_FILE); + + /* a result that would not fit is refused, never truncated (today's + snprintf into main.c's 512-byte buffer truncated silently) */ + T_EQ(wo_wal_resolve_data_path(g_dir, out, 8), WO_WAL_PATH_TOO_LONG); + snprintf(in, sizeof in, "%s/app.db", g_dir); + T_EQ(wo_wal_resolve_data_path(in, out, strlen(in)), WO_WAL_PATH_TOO_LONG); +} + /* databasev2 3 Task 3: the trigger, tested as a pure decision. Kept pure * precisely so it CAN be tested — a policy only observable by writing megabytes * and waiting is a policy nobody checks. */ @@ -3445,6 +3511,7 @@ int main(void) { test_keys_resident_delete(); test_keys_resident_delete_then_replay(); test_stale_compact_temp_is_removed(); + test_resolve_data_path(); test_should_compact_policy(); test_compact_refuses_with_staged_records(); test_torn_tail();