/* SPDX-License-Identifier: AGPL-3.0-or-later */ /* Copyright (C) 2026 David (Corey) Herrera */ /** * @file ft_tt_card_lock.c * @brief Implementation of the advisory card-level lock (see ft_tt_card_lock.h). * * @status Runs on the p150a test card. Red/green evidence for the refusal, * acquisition, staleness and override paths is in the recovery notes; a real card-level * reset was never run against this build (concurrent agents were live on the card). * @issues None * @todo None */ /* flock(2) is BSD, not POSIX, so the bare _POSIX_C_SOURCE below hides it -- on * macOS outright, and on glibc by clearing __USE_MISC. Same guard and same * reasoning as ft_tt_udrv_claim.c, which is this module's sibling: macOS * declares BSD interfaces under _DARWIN_C_SOURCE, glibc under _DEFAULT_SOURCE. * Without this the file cannot even be syntax-checked off the card host, which is * where its off-hardware review has to happen. */ #if defined(__APPLE__) #define _DARWIN_C_SOURCE #else #define _DEFAULT_SOURCE #endif #define _POSIX_C_SOURCE 200809L #include "ft_tt_card_lock.h" #include #include #include #include #include #include #include #include #include #include #include #include "ft_tt_device.h" /** Single-host coordination only -- see the header for why /tmp is the right place. World * writable (0666 / 0777) on purpose: any user's agent on this box must be able to * register a claim or take the lock, not just whoever created the file first. */ /* Both live inside a directory we create ourselves, and that placement is load-bearing. * `/tmp` is sticky and world-writable, and a host with `fs.protected_regular=2` (the card * host has it) then refuses to let ROOT open() a regular file in it owned by another user. * A card-level reset must run under sudo, so the lock was unopenable by exactly the caller * that needs it -- MEASURED on the card host: as a user the flock succeeds, as root the open * fails EACCES. protected_regular only applies to sticky world-writable directories, so * putting the files one level down in a directory of our own making sidesteps it. */ #define FT_TT_CARD_LOCK_DIR "/tmp/ft_tt_core_card.d" #define FT_TT_CARD_LOCK_PATH FT_TT_CARD_LOCK_DIR "/lock" #define FT_TT_CARD_LOCK_CLAIMS_DIR FT_TT_CARD_LOCK_DIR "/claims" /* Generous enough that GCC's -Wformat-truncation can prove a "/" join never * truncates even at NAME_MAX (255): the claims directory path is ~30 bytes, so 512 leaves * a wide margin instead of the tightest bound that would compile today and warn again the * next time a path constant grows. */ #define FT_TT_CARD_LOCK_PATH_MAX 512u #define FT_TT_CARD_LOCK_RECORD_MAX (FT_TT_CARD_LOCK_REASON_MAX + 128u) /** Bounded copy that cannot trip -Wformat-truncation the way `snprintf(d, n, "%s", s)` * can when the compiler cannot bound `s`: truncates silently, always NUL-terminates, * never touches `dest` if `dest_bytes` is 0. */ static void copy_bounded(char *dest, size_t dest_bytes, const char *src) { size_t length = strlen(src); if (dest_bytes == 0u) return; if (length >= dest_bytes) length = dest_bytes - 1u; memcpy(dest, src, length); dest[length] = '\0'; } /** This process's own claim-record path: "/.claim". Deterministic from * getpid(), so release needs no state carried over from acquire. */ static void own_record_path(char *out, size_t out_bytes) { snprintf(out, out_bytes, "%s/%d.claim", FT_TT_CARD_LOCK_CLAIMS_DIR, (int)getpid()); } /** mkdir the claims directory if it is not already there. EEXIST is success. */ /** The parent both the lock file and the claims directory live in. * * Every entry point must call this before touching either, and it must be idempotent: * whoever runs first creates it, and everyone after has to be able to write in it. Mode * 0777 explicitly (mkdir honours umask) and chmod'd after, because the first caller may * be root arriving via sudo for a card-level rung, and a plain user must still be able to place * a claim afterwards. Deliberately NOT sticky -- see FT_TT_CARD_LOCK_DIR. */ static int ensure_lock_directory(void) { if (mkdir(FT_TT_CARD_LOCK_DIR, 0777) != 0 && errno != EEXIST) return FT_TT_EIO; (void)chmod(FT_TT_CARD_LOCK_DIR, 0777); return FT_TT_OK; } static int ensure_claims_directory(void) { /* The parent first. Creating the child alone fails ENOENT when nobody has taken the * lock yet -- which is precisely the common case for `claim`, and it is how the first * version of this broke: `claim: could not register (rc=-4)` on a clean host. */ if (ensure_lock_directory() != FT_TT_OK) return FT_TT_EIO; if (mkdir(FT_TT_CARD_LOCK_CLAIMS_DIR, 0777) != 0 && errno != EEXIST) return FT_TT_EIO; /* Same reason as the parent: root may have created it, a user must still write here. */ (void)chmod(FT_TT_CARD_LOCK_CLAIMS_DIR, 0777); return FT_TT_OK; } /** Open (creating if needed) the single lock file every holder flock()s. */ static int open_lock_file(void) { int fd; if (ensure_lock_directory() != FT_TT_OK) return -1; fd = open(FT_TT_CARD_LOCK_PATH, O_CREAT | O_RDWR | O_CLOEXEC, 0666); if (fd < 0) return -1; /* open() honours umask, so a restrictive one leaves this owner-only even though 0666 * was asked for. Best-effort widen it back; harmless if it fails (e.g. another user * already owns the file, so we cannot chmod it -- the lock still works, just possibly * not across users). */ (void)fchmod(fd, 0666); return fd; } /** Write this process's own claim record: pid, exclusive/shared, reason, acquired-at. */ static int write_own_record(bool exclusive, const char *reason) { char path[FT_TT_CARD_LOCK_PATH_MAX]; char sanitized_reason[FT_TT_CARD_LOCK_REASON_MAX]; char body[FT_TT_CARD_LOCK_RECORD_MAX]; size_t char_index; size_t body_length; int fd; ssize_t written; if (ensure_claims_directory() != FT_TT_OK) return FT_TT_EIO; /* Keep the record line-based: a reason containing a newline would otherwise corrupt * the next reader's line-by-line parse. */ if (!reason) reason = "(no reason given)"; copy_bounded(sanitized_reason, sizeof(sanitized_reason), reason); for (char_index = 0; sanitized_reason[char_index] != '\0'; char_index++) if (sanitized_reason[char_index] == '\n') sanitized_reason[char_index] = ' '; own_record_path(path, sizeof(path)); fd = open(path, O_CREAT | O_WRONLY | O_TRUNC | O_CLOEXEC, 0644); if (fd < 0) return FT_TT_EIO; snprintf(body, sizeof(body), "pid=%d\nexclusive=%d\nreason=%s\nacquired=%ld\n", (int)getpid(), exclusive ? 1 : 0, sanitized_reason, (long)time(NULL)); body_length = strlen(body); written = write(fd, body, body_length); close(fd); return written == (ssize_t)body_length ? FT_TT_OK : FT_TT_EIO; } /** Remove this process's own claim record. Not fatal if it never existed -- release must * be safe to call even when acquire failed partway through. */ static void remove_own_record(void) { char path[FT_TT_CARD_LOCK_PATH_MAX]; own_record_path(path, sizeof(path)); (void)unlink(path); } /** True if `pid` still names a live process. ESRCH is dead; EPERM (owned by a different * user) is treated as alive since a permission failure does not prove the process is * gone. */ static bool pid_is_alive(pid_t pid) { if (pid <= 0) return false; if (kill(pid, 0) == 0) return true; return errno == EPERM; } /** Parse one claim-record file into `holder`. Missing fields are left zeroed. */ static bool parse_record(const char *path, ft_tt_card_lock_holder *holder) { char line[256]; FILE *file = fopen(path, "r"); if (!file) return false; memset(holder, 0, sizeof(*holder)); while (fgets(line, sizeof(line), file)) { size_t length = strlen(line); if (length > 0 && line[length - 1] == '\n') line[length - 1] = '\0'; if (strncmp(line, "pid=", 4) == 0) holder->pid = (pid_t)strtol(line + 4, NULL, 10); else if (strncmp(line, "exclusive=", 10) == 0) holder->exclusive = strtol(line + 10, NULL, 10) != 0; else if (strncmp(line, "reason=", 7) == 0) copy_bounded(holder->reason, sizeof(holder->reason), line + 7); else if (strncmp(line, "acquired=", 9) == 0) holder->acquired_at = (time_t)strtol(line + 9, NULL, 10); } fclose(file); return true; } /** True if `name` is ".claim" -- what own_record_path() writes. */ static bool looks_like_claim_file(const char *name) { size_t length = strlen(name); static const char suffix[] = ".claim"; size_t suffix_length = sizeof(suffix) - 1u; if (length <= suffix_length) return false; return strcmp(name + (length - suffix_length), suffix) == 0; } /** Scan the claims directory: collect live holders into `status`, delete dead-pid records. * Succeeds with an empty status if the directory does not exist yet -- that is the normal * "nobody has ever claimed this card" state, not an error. */ static int scan_claims(ft_tt_card_lock_status *status) { DIR *directory; struct dirent *entry; memset(status, 0, sizeof(*status)); directory = opendir(FT_TT_CARD_LOCK_CLAIMS_DIR); if (!directory) return errno == ENOENT ? FT_TT_OK : FT_TT_EIO; while ((entry = readdir(directory)) != NULL) { char path[FT_TT_CARD_LOCK_PATH_MAX]; ft_tt_card_lock_holder holder; if (!looks_like_claim_file(entry->d_name)) continue; snprintf(path, sizeof(path), "%s/%s", FT_TT_CARD_LOCK_CLAIMS_DIR, entry->d_name); if (!parse_record(path, &holder)) continue; if (!pid_is_alive(holder.pid)) { (void)unlink(path); status->stale_cleaned++; continue; } if (status->holder_count < FT_TT_CARD_LOCK_MAX_HOLDERS) status->holders[status->holder_count] = holder; status->holder_count++; } closedir(directory); return FT_TT_OK; } int ft_tt_card_lock_claim_acquire(const char *reason, int *out_fd) { int fd; if (!out_fd) return FT_TT_EINVAL; *out_fd = -1; if (ensure_claims_directory() != FT_TT_OK) return FT_TT_EIO; fd = open_lock_file(); if (fd < 0) return FT_TT_EIO; /* LOCK_SH blocks only behind an exclusive holder, and any number of shared claims * coexist -- this call is allowed to block, unlike the exclusive path below. */ if (flock(fd, LOCK_SH) != 0) { close(fd); return FT_TT_EIO; } if (write_own_record(false, reason) != FT_TT_OK) { (void)flock(fd, LOCK_UN); close(fd); return FT_TT_EIO; } *out_fd = fd; return FT_TT_OK; } int ft_tt_card_lock_exclusive_try(const char *reason, int *out_fd, ft_tt_card_lock_status *out_status) { ft_tt_card_lock_status local_status; int fd; if (!out_fd) return FT_TT_EINVAL; *out_fd = -1; /* BEFORE any early return. A caller that prints the status on a non-OK result would * otherwise print this frame's stack -- which is exactly what happened on the card host: * the open failed, out_status was never written, and the tool announced it had * "cleared 2477232864 stale lock records" and advised waiting for a holder that did * not exist. An out-parameter must be defined on EVERY path that returns. */ if (out_status) memset(out_status, 0, sizeof(*out_status)); fd = open_lock_file(); if (fd < 0) return FT_TT_EIO; if (flock(fd, LOCK_EX | LOCK_NB) != 0) { close(fd); /* Contended. Report who -- this scan is also what cleans up any stale (dead-pid) * records, whether or not the caller wants to see the result. */ (void)scan_claims(out_status ? out_status : &local_status); return FT_TT_EPERM; } if (write_own_record(true, reason) != FT_TT_OK) { (void)flock(fd, LOCK_UN); close(fd); return FT_TT_EIO; } if (out_status) memset(out_status, 0, sizeof(*out_status)); *out_fd = fd; return FT_TT_OK; } int ft_tt_card_lock_inspect(ft_tt_card_lock_status *out_status) { if (!out_status) return FT_TT_EINVAL; return scan_claims(out_status); } void ft_tt_card_lock_release(int fd) { if (fd < 0) return; remove_own_record(); (void)flock(fd, LOCK_UN); close(fd); } /** "3s" / "4m12s" / "1h03m" -- short, since the refusal message is already busy. */ static void format_duration(time_t seconds, char *out, size_t out_bytes) { long total = (long)seconds; long hours; long minutes; long secs; if (total < 0) total = 0; hours = total / 3600; minutes = (total % 3600) / 60; secs = total % 60; if (hours > 0) snprintf(out, out_bytes, "%ldh%02ldm", hours, minutes); else if (minutes > 0) snprintf(out, out_bytes, "%ldm%02lds", minutes, secs); else snprintf(out, out_bytes, "%lds", secs); } void ft_tt_card_lock_status_print(const ft_tt_card_lock_status *status) { unsigned holder_index; time_t now = time(NULL); if (!status) return; if (status->stale_cleaned != 0u) printf(" (cleared %u stale lock record%s left by a dead process)\n", status->stale_cleaned, status->stale_cleaned == 1u ? "" : "s"); if (status->holder_count == 0u) { printf(" nobody currently holds the card lock\n"); return; } for (holder_index = 0; holder_index < status->holder_count && holder_index < FT_TT_CARD_LOCK_MAX_HOLDERS; holder_index++) { char duration[32]; const ft_tt_card_lock_holder *holder = &status->holders[holder_index]; format_duration(now - holder->acquired_at, duration, sizeof(duration)); printf(" pid %d (%s), held %s: %s\n", (int)holder->pid, holder->exclusive ? "card-level operation" : "claim", duration, holder->reason[0] ? holder->reason : "(no reason given)"); } if (status->holder_count > FT_TT_CARD_LOCK_MAX_HOLDERS) printf(" ... and %u more\n", status->holder_count - FT_TT_CARD_LOCK_MAX_HOLDERS); }