Foundry/tt/tools/ft_tt_card_lock.c

400 lines
14 KiB
C

/* 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 <dirent.h>
#include <errno.h>
#include <fcntl.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/file.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <unistd.h>
#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 "<dir>/<name>" 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: "<claims dir>/<pid>.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 "<digits>.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);
}