Commit Graph

225 Commits

Author SHA1 Message Date
Levi Neely 874e25f11b
Fix pcl-zqv.4.2: protocol parsing and buffer safety in prpc/papi (#348)
prpc.c:
- on_request: fix total_size computation. The original formula
  sizeof(uint32_t)+sizeof(uint64_t)+response->length had two bugs:
  (1) sizeof(uint32_t)+sizeof(uint64_t)=12 but offsetof(rpc_message_t,value)=16
  due to struct alignment padding between the uint32_t type and uint64_t length
  fields; and (2) respond() was storing full message size in response->length,
  double-counting the header. Fix: use offsetof(rpc_message_t,value)+response->length,
  consistent with readResponse() in rpcclient.cpp which reads a fixed header_size
  of offsetof(rpc_message_t,value) bytes then reads msg->length payload bytes.
- respond: store payload length only in response->length (value_length+1),
  not full message size, to match the client protocol expectation.
- prpc_init: add null check on malloc return value.
- prpc_register: restore old handler table and return -1 if prpc_init fails.

papi.c:
- papi_result_thread: add missing MAX_API_RESPONSE_SIZE guard (present in
  papi_result but absent here), preventing server-controlled unbounded malloc.
- papi_result, papi_result_thread: add null checks on malloc before passing
  pointer to psock_readall.
- papi_result_async: add MAX_API_RESPONSE_SIZE check and malloc null check
  on reader->respsize path.
- calc_ret_len: add _NEED_DATA(1) guard before ARRAY and HASH while-loop
  conditions; empty containers with datalen=0 caused out-of-bounds read.

Co-authored-by: Levi Neely <lkn@darkstar.example.net>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 19:02:01 +01:00
Levi Neely 07e4189bae
Fix pcl-g46: make psync_status queue fields atomic in status_change_thread (#347)
status_change_thread reads psync_status.filestodownload, filestoupload,
localisfull, and remoteisfull under statusmutex, while pstatus_download_recalc,
pstatus_upload_recalc, and pstatus_set write them without statusmutex (or under
a different mutex). TSan reports the race at pqevent.c:274.

Replace all writes of these four fields with __atomic_store_n and capture
atomic snapshots in status_change_thread before the condition, using the
snapshots for both comparison and the status_old update after struct copy.
Also use atomic loads in calc_status() for filestodownload and filestoupload.

Closes #335

Co-authored-by: Levi Neely <lkn@darkstar.example.net>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 18:36:12 +01:00
Levi Neely 3cc42792b5
Fix pcl-bga: make psync_status.status accesses atomic (#346)
psync_status.status is written in pstatus_set() (pstatus.c:263) after
releasing status_internal_mutex, while status_change() (pclsync_lib.cpp:435)
reads it concurrently from the callback thread without any lock. TSan
reports the race between T9 (write in pstatus_set) and the status callback
thread (read in status_change).

Replace all reads and writes of psync_status.status with
__atomic_load_n/__atomic_store_n (__ATOMIC_RELAXED) across pstatus.c,
pqevent.c, and pclsync_lib.cpp. In status_change_thread, capture the
atomic value once into cur_status before the condition to avoid multiple
inconsistent loads. In status_change(), capture cur_status at entry and
use it throughout, also propagating it to the copied status_ struct.

Closes #334

Co-authored-by: Levi Neely <lkn@darkstar.example.net>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 18:24:35 +01:00
Levi Neely 5aae38cbd8
Fix pcl-96f: make psync_current_time accesses atomic (#345)
psync_current_time is a global time_t written by timer_thread without
holding timer_mutex, while ptimer_register reads it under timer_mutex.
TSan reports the race at ptimer.c:152 (write) and ptimer.c:216 (read).

Replace all reads with __atomic_load_n and all writes with
__atomic_store_n using __ATOMIC_RELAXED. This covers the race sites in
ptimer.c and all external readers in plocalscan.c, pnetlibs.c, and
psynclib.c that access psync_current_time without any mutex.

Closes #333

Co-authored-by: Levi Neely <lkn@darkstar.example.net>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 18:14:33 +01:00
Levi Neely 9c721bde00
Fix data race on task refcount in psync_task_free (#344)
Acquire mutex before reading tm->refcnt to prevent race with
psync_task_dec_refcnt(). Fixes ThreadSanitizer warning.

Fixes #332

Co-authored-by: Levi Neely <lkn@darkstar.example.net>
2026-03-03 21:15:57 +01:00
Levi Neely ede53b6b89
Fix pcl-0c1: make pdbg_runtime_level atomic (#343)
Co-authored-by: Levi Neely <lkn@darkstar.example.net>
2026-03-03 20:08:18 +01:00
Levi Neely 5ce7118a35
Fix pcl-zqv.4.12: add bounds check in trim_der_key (#341)
Co-authored-by: Levi Neely <lkn@darkstar.example.net>
2026-03-03 19:53:01 +01:00
Levi Neely 51dd304543
Delete test_deadlock_forced (#340) 2026-03-03 19:35:24 +01:00
Levi Neely 40fd38dde5
Fix pcl-zqv.5.7: eliminate deadlock in pfs_get_both_locks (#339)
* Fix pcl-zqv.5.7: eliminate deadlock in pfs_get_both_locks

- Enforce consistent lock ordering: always acquire psql lock before file mutex
- Remove retry loop that could spin indefinitely
- Replace abort() with error return in pfs_lock_file timeout
- Add unit test verifying lock ordering prevents deadlock

Fixes GH #258

* Reorganize tests: smoke-tests, fault-inject, unit-tests

* Reorganize tests: smoke-tests, fault-inject, unit-tests

* Remove validation report

---------

Co-authored-by: Levi Neely <lkn@darkstar.example.net>
2026-03-03 19:33:56 +01:00
Levi Neely 63d8685240
Fix psync_my_pass wipe to use strlen instead of sizeof pointer (#338)
In psync_unlink(), putil_wipe(psync_my_pass, sizeof(psync_my_pass))
only wiped 8 bytes (pointer size) instead of the full password string.
Changed to strlen(psync_my_pass) and added NULL check.

Fixes pcl-zqv.9.1 (partial)
GH #276

Co-authored-by: Levi Neely <lkn@darkstar.example.net>
2026-03-03 17:11:10 +01:00
Levi Neely 7175636e49
Add .kiro and .claude to .gitignore (#337)
Co-authored-by: Levi Neely <lkn@darkstar.example.net>
2026-03-03 17:01:27 +01:00
Levi Neely d31b009acd
Fix data race on lastseed in prand_seed() (#330)
Mutex protects lastseed read/write
2026-03-03 16:43:59 +01:00
Levi Neely 72ef56652d
Fix race condition in pfs_reopen_file_for_writing encoder state (#329)
Crypto folder operations tested, no regression
2026-03-03 16:18:16 +01:00
Levi Neely 3e777665b6
Fix race condition in pfs_dec_of_refcnt (#328)
File operations tested, no regression
2026-03-03 16:13:22 +01:00
Levi Neely cb938aa128
Fix memory leak in pfs_update_openfile on collision (#327)
File operations tested, no regression
2026-03-03 16:10:18 +01:00
Levi Neely 07cbef9b88
Add write-permission check in pfs_write and pfs_ftruncate (#326)
Write and truncate operations tested, work correctly
2026-03-03 16:05:28 +01:00
Levi Neely c03a37527f
Fix NULL-deref in pfs_rename_openfile_locked (#325)
File rename tested, works correctly
2026-03-03 15:58:07 +01:00
Levi Neely 6ce166a0a5
Fix integer overflow in fake fileid calculation (#324)
Filesystem tested, file creation and removal work
2026-03-03 15:54:50 +01:00
Levi Neely a5b09c73c6
Add upper bound check in check_peer_pubkey() (#323)
Daemon tested, TLS works, crypto folder works
2026-03-03 15:51:54 +01:00
Levi Neely c9bb7685b7
Add POVERLAY_BUFSIZE bounds check in prpc.c read loop (#322)
RPC commands tested, sync list works
2026-03-03 15:49:18 +01:00
Levi Neely 0ae4882ada
Fix integer overflow in prpc.c response->length calculation (#321)
RPC commands tested, daemon works
2026-03-03 15:46:39 +01:00
Levi Neely d0d9ac2d77
Fix ptree_for_each_element loop termination condition (#319)
Daemon tested, starts successfully
2026-03-03 15:29:44 +01:00
Levi Neely ac25de2831
Add integer overflow checks in psync_list_builder_finalize() (#318)
Daemon tested, starts successfully
2026-03-03 15:27:07 +01:00
Levi Neely 00b4824961
Replace sprintf with snprintf in ptools.c (#317)
First login tested, no crash
2026-03-03 15:24:10 +01:00
Levi Neely 465d5b9fb6
Fix ptevent: memory leak, NULL-deref, and race condition (#316)
Upload tested, no crash
2026-03-03 15:15:22 +01:00
Levi Neely 2f733a5025
Add MAX_API_RESPONSE_SIZE bounds check in papi.c (#315)
API calls tested and working
2026-03-03 15:10:27 +01:00
Levi Neely 3240c033bb
Fix use-after-free: heap-allocate socket fd for RPC threads (#314)
RPC commands tested and working
2026-03-03 15:08:29 +01:00
Levi Neely bc943e5b69
Remove legacy Unix fallbacks from pmem.c (#312)
Code cleanup, no functional change
2026-03-03 14:55:20 +01:00
Levi Neely ebda4df468
Add HTTP Content-Length upper-bound check (#309)
HTTP Content-Length bounds check tested with file downloads
2026-03-03 13:54:17 +01:00
Levi Neely c73bcaf871
Fix FUSE channel cleanup and error logging (#311)
FUSE fixes tested and verified
2026-03-03 13:53:50 +01:00
Levi Neely 93153273f7
Fix timing side-channel in check_peer_pubkey (#308)
strcmp() at line 315 short-circuits on first differing byte, leaking
timing info about trusted fingerprints.

Replace with constant-time comparison using memcmp and bitwise OR
accumulation across all trusted entries.

Fixes GH #239

Co-authored-by: Levi Neely <lkn@darkstar.example.net>
2026-03-03 12:53:29 +01:00
Levi Neely fb8d0fc608
Fix unchecked malloc in papi_result_async (#307)
reader->data = malloc(reader->respsize) at line 462 is not checked
for NULL before goto again continues the loop and dereferences it.

Add NULL check and return ASYNC_RES_READY with result=NULL on failure.

Fixes GH #238

Co-authored-by: Levi Neely <lkn@darkstar.example.net>
2026-03-03 12:41:27 +01:00
Levi Neely 87ab65b68c
Fix integer underflow in prpc.c handler dispatch (#306)
cbidx = request->type - 20 computed before checking lower bound.
If request->type < calbacks_lower_band, cbidx wraps to large value
causing out-of-bounds array access.

Move bounds check before subtraction and use calbacks_lower_band
instead of hardcoded 20.

Fixes GH #235

Co-authored-by: Levi Neely <lkn@darkstar.example.net>
2026-03-03 12:37:13 +01:00
Levi Neely 838b826b90
Fix char[] passed as char** in ptools_set_backend_file_dates (#305)
msgErr was declared as char[1024] but cast to char** and passed to
ptools_backend_call(). The function writes a char* into *err,
corrupting the first sizeof(char*) bytes of the array.

Change to char *errPtr = NULL and free it after use.

Fixes GH #216

Co-authored-by: Levi Neely <lkn@darkstar.example.net>
2026-03-03 12:34:18 +01:00
Levi Neely 3c096565ea
Fix optional params always using index 0 in ptools_backend_call (#304)
Loop variable j was declared inside the loop and reset to 0 on every
iteration. All branches had early continue, so j++ never executed.

Change j to i - reqParCnt to correctly index into optionalParams array.
Also fix paramtype check to use j instead of i.

Fixes GH #215

Co-authored-by: Levi Neely <lkn@darkstar.example.net>
2026-03-03 12:31:01 +01:00
Levi Neely 8d203a4f97
Fix swapped struct sizes in pqevent_queue_sync_event_path (#303)
When eventid has PEVENT_TYPE_FOLDER set, the code allocated
psync_file_event_t size but cast to psync_folder_event_t.

Swap the sizeof() calls to match the actual struct types used.

Fixes GH #229

Co-authored-by: Levi Neely <lkn@darkstar.example.net>
2026-03-03 12:27:37 +01:00
Levi Neely 98ebd10c86
Fix typo: rename psync_list_bulder_* to psync_list_builder_* (#302)
Three functions had a consistent typo (missing 'i' in builder):
- psync_list_bulder_push_num
- psync_list_bulder_pop_num
- psync_list_bulder_add_element

Renamed to correct spelling in plist.h, plist.c, and all callers.

Fixes GH #219

Co-authored-by: Levi Neely <lkn@darkstar.example.net>
2026-03-03 12:23:16 +01:00
Levi Neely 81ad3d3846
Fix data race on excepions in ptimer_do_notify_exception (#301)
excepions is read without holding timer_ex_mutex, but
ptimer_exception_handler() modifies it under the mutex.

Move the read inside the critical section to prevent the race.

Fixes GH #227

Co-authored-by: Levi Neely <lkn@darkstar.example.net>
2026-03-03 12:18:31 +01:00
Levi Neely b902d47706
Fix pthread_create return unchecked in prun.c (#300)
pthread_create() return value is ignored. If it fails, the allocated
thread_data is leaked.

Check return value; on error, log and free the data.

Fixes GH #221

Co-authored-by: Levi Neely <lkn@darkstar.example.net>
2026-03-03 12:16:30 +01:00
Levi Neely beaa76adfb
Fix uninitialized keyParams in ptools_create_backend_event (#299)
keyParams is only allocated when pCnt > 0 but freed unconditionally.
When pCnt == 0, free() is called on an uninitialized pointer (UB).

Initialize to NULL so free() is a no-op when allocation is skipped.

Fixes GH #214

Co-authored-by: Levi Neely <lkn@darkstar.example.net>
2026-03-03 12:10:44 +01:00
Levi Neely c514fa1a29
Fix MAP_FAILED detection in pmem_mmap (#298)
mmap(2) returns MAP_FAILED ((void*)-1) on error, not NULL. The check
'if (likely(ret))' treats MAP_FAILED as truthy, so the emergency retry
path is never reached and callers receive MAP_FAILED as a valid pointer.

Normalize pmem_mmap() to return NULL on failure (converting MAP_FAILED
to NULL in the mmap path). This confines the platform-specific error
handling to one place and eliminates the need for preprocessor checks
in pmem_mmap_safe() and psync_mmap_anon_emergency().

Fixes GH #226

Co-authored-by: Levi Neely <lkn@darkstar.example.net>
2026-03-03 12:03:30 +01:00
Levi Neely 0f23a32793
Fix input validation: stoull exception and buffer bounds (#297)
- Wrap std::stoull() in try/catch to prevent daemon crash on invalid folder ID
- Add proper read loop for RPC messages to handle partial reads
- Validate msg->length before memcpy to prevent heap over-read
- Handle daemon bugs gracefully (EOF before full message)
- Fix operator precedence in sync remove command

Fixes #205, #206

Co-authored-by: Levi Neely <lkn@darkstar.example.net>
2026-03-03 11:55:54 +01:00
Levi Neely 73a57c994b
Fix exception handling and operator precedence bugs (#296)
- Add return statement in catch(...) block to prevent fallthrough
- Fix operator precedence in GetState() to capture Call() return value

Fixes #201, #202

Co-authored-by: Levi Neely <lkn@darkstar.example.net>
2026-03-03 11:55:36 +01:00
Levi Neely 175a46a109
Fix exception handling and operator precedence bugs (#295)
- Add return statement in catch(...) block to prevent fallthrough
- Fix operator precedence in GetState() to capture Call() return value

Fixes #201, #202

Co-authored-by: Levi Neely <lkn@darkstar.example.net>
2026-03-03 11:25:25 +01:00
Levi Neely c0cc893cc7
Fix umask and password memory wipe (#294)
* Fix debug build segfault: add strong overrides for psql_lock/unlock functions

The weak/strong symbol approach requires that psql.c undefs the debug
macros to define function implementations. This means functions in psql.c
call psql_lock()/psql_unlock() as functions, not macros.

In debug builds, these must be strong overrides that call the _do_
variants to properly maintain lockctr/rdlockctr. Without these overrides,
the weak stubs are used which don't update counters, causing assertion
failures in psql_unlock().

Added strong overrides in psql_debug.c for:
- psql_lock() -> psql_do_lock(__FILE__, __LINE__)
- psql_rdlock() -> psql_do_rdlock(__FILE__, __LINE__)
- psql_statement() (calls psql_do_lock directly)

Also made psql_statement, psql_start, and all query/prepare functions
weak in psql.c to allow debug overrides.

Fixes #138

* Fix umask and password memory wipe

- Set umask(0077) in daemonize() to prevent world-readable files
- Wipe password from memory after psync_set_user_pass()

Fixes #203, #204

---------

Co-authored-by: Levi Neely <lkn@darkstar.example.net>
2026-03-03 11:20:07 +01:00
Levi Neely 61259858c8
Fix debug build segfault: add strong overrides for psql_lock/unlock functions (#177)
The weak/strong symbol approach requires that psql.c undefs the debug
macros to define function implementations. This means functions in psql.c
call psql_lock()/psql_unlock() as functions, not macros.

In debug builds, these must be strong overrides that call the _do_
variants to properly maintain lockctr/rdlockctr. Without these overrides,
the weak stubs are used which don't update counters, causing assertion
failures in psql_unlock().

Added strong overrides in psql_debug.c for:
- psql_lock() -> psql_do_lock(__FILE__, __LINE__)
- psql_rdlock() -> psql_do_rdlock(__FILE__, __LINE__)
- psql_statement() (calls psql_do_lock directly)

Also made psql_statement, psql_start, and all query/prepare functions
weak in psql.c to allow debug overrides.

Fixes #138

Co-authored-by: Levi Neely <lkn@darkstar.example.net>
2026-03-03 07:59:30 +01:00
Levi Neely 4d64b645a1
Increase maintainability: namespace refactors for putil, pfs, and pfs sub-modules (#176)
* Fix #103: Refactor putil to use putil_ namespace and enforce encapsulation

All public functions in putil now use the putil_ prefix (e.g., putil_strdup,
putil_strcat, putil_time_format, putil_base64_encode, etc.). Internal
variables (normalize_table, base64_reverse_table) and the constructor remain
static. Updated all 34 call-site files across pclsync accordingly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix #96: Refactor pfs to use pfs_ namespace and enforce encapsulation

All public functions in the pfs module renamed from psync_fs_ to pfs_
(e.g., pfs_update_openfile, pfs_refresh, pfs_start, pfs_stop, etc.).
Extern vars psync_fake_prefix/psync_fake_prefix_len renamed to
pfs_fake_prefix/pfs_fake_prefix_len. Functions only used within pfs.c
(pfs_update_openfile_fileid_locked, pfs_chown) made static. Updated all
call sites across 17 files including pclsync_lib.cpp.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Refactor pfsxattr to use pfs_xattr_ namespace

All psync_fs_ prefixed functions in the pfsxattr module renamed to
pfs_xattr_ (e.g., pfs_xattr_set, pfs_xattr_get, pfs_xattr_list,
pfs_xattr_remove, pfs_xattr_file_deleted, pfs_xattr_task_to_file, etc.).
Local macro psync_fs_set_thread_name renamed to pfs_xattr_set_thread_name.
Internal static helpers (delete_object_id, update_object_id,
xattr_get_object_id_locked) remain static and unchanged.
Updated all call sites in pfs.c, pfsupload.c, and pdiff.c.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Rename pfs_xattr_ namespace to pfs_xatr_

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Refactor pfs sub-modules to use namespaced function prefixes

- pfscrypto_* → pfs_crpt_*
- psync_fsstatic_* → pfs_stat_*
- psync_fsupload_* → pfs_upld_*
- psync_fstask_* → pfs_task_* (functions only; typedefs unchanged)
- psync_fsfolder_*/psync_fsfolderid_*/psync_fsfolderflags_*/
  get_decname_for_folder/psync_get_folderid → pfs_fldr_*

Type names, macro constants, and typedef aliases are unchanged.
Updated all call sites across 17 files.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Levi Neely <lkn@darkstar.example.net>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 22:51:25 +01:00
Levi Neely d6858a0166
Fix #90, #109: Support 2FA when running as a daemon (#175)
* Fix #90: Support 2FA when running as a daemon

In daemon mode, when PSTATUS_TFA_REQUIRED fires:
- Automatically send an SMS 2FA code via psync_tfa_send_sms()
- Log delivery details to syslog with instructions to use
  `echo 'tfa CODE' | pcloudcc -k`
- Block on a condition variable until the code arrives via RPC

Add a new `tfa <code>` command to the pcloudcc -k control interface.
This sends the code to the daemon over the existing Unix socket RPC
channel (SENDTFA message type).

Bad codes (PSTATUS_BAD_TFA_CODE) are handled: the daemon logs a syslog
warning and waits for a corrected code without re-sending SMS.

Devices are trusted by default (trusted=1 in psync_tfa_set_code) so
repeated 2FA prompts are avoided for the configured trust period.
TFA codes are wiped from memory after use.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Add auth command: supply password to running daemon

Mirrors the tfa command pattern. When PSTATUS_LOGIN_REQUIRED fires in
daemon mode, read_password() now logs a syslog notice and blocks on a
condition variable rather than calling exit(1).

The new `auth <password>` control command (SENDAUTH RPC type) signals
the condvar and unblocks the daemon. Usage:

  echo 'auth PASSWORD' | pcloudcc -k

The password is wiped from the CLI-side buffer immediately after the
RPC call. putil_wipe() is used for both the tfa and auth transient
strings on the sending side.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Add status command: show current sync state from CLI

Adds a `status` (alias `st`) command to the interactive CLI and to the
pcloudcc -k control interface. The daemon-side handler calls
psync_get_status(), formats the status name plus download/upload
strings, and writes the result to shared memory. The client reads and
prints it.

Example output:
  Status:   READY
  Download: idle
  Upload:   idle

Also works non-interactively:
  echo 'status' | pcloudcc -k

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Levi Neely <lkn@darkstar.example.net>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 21:36:33 +01:00
Levi Neely e3b9f7bfd8
Remove obsolete Pending Transfers section from README (#174)
* Fix #67: Add sync pause and sync resume commands

Wire psync_pause() and psync_resume() from the C library into the RPC
command layer, registering SYNCPAUSE and SYNCRESUME handlers and exposing
them as 'sync pause' and 'sync resume' CLI subcommands.

Note: daemon quit is already handled by the existing 'finalize' command,
which calls psync_destroy() and exits the daemon process cleanly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Remove obsolete Pending Transfers section from README

The pending command now provides a proper way to check for pending
transfers, making the manual cache-inspection workaround obsolete.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Levi Neely <lkn@darkstar.example.net>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 20:17:10 +01:00
Levi Neely 79371d9aac
Fix #67: Add sync pause and sync resume commands (#173)
Wire psync_pause() and psync_resume() from the C library into the RPC
command layer, registering SYNCPAUSE and SYNCRESUME handlers and exposing
them as 'sync pause' and 'sync resume' CLI subcommands.

Note: daemon quit is already handled by the existing 'finalize' command,
which calls psync_destroy() and exits the daemon process cleanly.

Co-authored-by: Levi Neely <lkn@darkstar.example.net>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 20:12:53 +01:00