Decisions made during development that are not obvious from reading the code.
std::string::find() returns npos (SIZE_MAX) when the character is absent. The original guard if (pos < 2) is false for npos on 64-bit systems, causing the function to return the entire input string — every colon-free path was classified as a transport URL. Fixed to if (pos == npos || pos < 2). Caught by the standalone test suite.
The FILE is used as a map key. If fclose() is called first, the OS may reuse that address for a new FILE before pws_cache_remove() runs, causing a wrong-entry removal. More importantly, using the pointer after fclose() is undefined behaviour. Fixed order: remove → close → store.
src/os/lib.h declares LoadLibrary / FreeLibrary / GetSymbol but only windows/lib.cpp exists — there is no unix/lib.cpp. Transport loader uses dlopen / dlsym / dlclose directly.
DbSelectionPanel::DoValidation() called wxFileName(path).FileExists() which is a direct OS call that bypasses our pws_os::FileExists intercept. Additionally wxFilePickerCtrl does not allow free-text URL entry. Solution: replace with wxComboBox (free text + recent-location dropdown) + "Browse..." button that opens wxFileDialog for local files.
Searching by filename first (pwsafe-<scheme>.so) is O(1) and avoids scanning all .so files. The identity string (PWS_TRANSPORT_INFO:...) is a secondary check: it catches renamed or misplaced files before dlopen(). First match wins; the assumption is at most one plugin per scheme.
In production builds, plugins are only loaded from the directory containing the pwsafe binary. In development builds (-DDEVELOPMENT), the current working directory is also searched. This lets developers run tests without installing. The macro is defined in TransportTest.cpp and used in the standalone test build command.
The file: plugin is local-only and has no meaningful locking. Returning ENOTSUP from lock() is the agreed signal that means "proceed without a lock". The transport dispatcher in LockFile() treats ENOTSUP as success (returns true) but could emit a one-time warning for network transports.
RFC 8089 §2 permits file:/path (one slash) as a valid local-file URI. The original strip_file_scheme() only matched file:// (two slashes), so file:/home/john/x.psafe3 was returned unchanged, causing fs::copy_file to fail with ENOENT. Fix: an else if branch for file:/ that strips the five-character file: prefix, leaving /path. All four forms are now handled.
The PWSprefs MRU list is user-configured, finite, and shared with Recent Databases. Mixing URLs into it would pollute the file picker. Instead, URL history is stored separately in wxConfig under /URLHistory/url0…url9 (newest first, max 10 entries). On a successful open via URL the entry is also added to recentDatabases() so it appears in the File → Recent Databases submenu.
wxArrayString::Remove(value) calls wxASSERT internally and aborts if the value is not in the array. Pattern: always use Index() first, then RemoveAt(idx) by index only if idx != wxNOT_FOUND. The bug appeared in SaveUrlHistory() where Remove(url) was used to deduplicate before inserting — correct intent, wrong API call on first use when the URL was not yet in history.
PWScore::BackupCurFile creates a PWSFileSig from the raw filename (stat call) and calls pws_os::RenameFile to rename the database to a .ibak path. For a transport URL, the stat and rename both fail — there is no local file at that path (the real file is in the XDG cache). Fix: in the UI save path (MenuFileHandlers.cpp), call pws_is_transport_url() before attempting backup and skip BackupCurFile entirely for URL-backed databases.
IsLockedFile answers "do WE hold the lock on this URL?" — not "is this URL locked by anyone?". The right data source is the in-process s_active_locks registry (updated by LockFile / UnlockFile), not a WebDAV HEAD request.
The old approach (FileExists(url + ".plk")) made a WebDAV HEAD request for a non-existent .plk file → 404 → IsLockedFile always returned false → SafeUnlockFile never called UnlockFile → server lock never released → next open got HTTP 423.
The registry approach is also async-signal-safe (no network I/O in signal handlers) and handles race-free transitions between locked and unlocked state.
libcurl is not async-signal-safe. pwsafe's UnlockFile is called from signal handlers, atexit handlers, and C++ destructors. Calling libcurl from any of these contexts risks crashes, deadlocks, or silent failures.
Solution: fork a child process on first lock acquisition. The child inherits the plugin's loaded .so and runs a command loop. The parent communicates via write()/read() on a socketpair — both are async-signal-safe per POSIX. The parent never calls libcurl directly after fork.
Consequence: all plugin state modifications (s_lock_tokens) happen only in the child. See 'Store routed through daemon' below.
The PWSTransport ABI passes token as a parameter to unlock(). For the WebDAV plugin, the token is stored internally in s_lock_tokens (URL → opaque token string). The token argument in the plugin's unlock() implementation is always ignored; the plugin looks up the token from its own map.
Why: the lock daemon design means that the parent never holds the token — the child does, in s_lock_tokens. The parent sends CMD_UNLOCK with just the URL; the child looks up the token itself. This avoids changing the file.h public API (which would require updating all callers).
If a WebDAV server doesn't support DAV:2 (Class 2), lock() returns ENOTSUP. The transport layer (LockFile in file.cpp) treats ENOTSUP as 'server doesn't support locking — warn once and continue'. The user is warned but the database can still be opened and saved.
This matches the design goal: native URL access should work even on basic WebDAV servers (Class 1 only). Locking is best-effort, not a hard requirement.
After fork(), the parent and child have independent copies of all in-process state. s_lock_tokens (in the WebDAV plugin) is populated when the child calls t->lock(). The parent's copy of this map remains permanently empty.
If the parent called t->store() directly (as it originally did), it checked its own empty map, omitted the If: (<token>) header, and got HTTP 423 Locked. The in-memory changes were silently lost.
Fix: FClose tests pws_has_lock(url). If true, it calls pws_lockd_store(cache, url) — which sends CMD_STORE to the child. The child's t->store() sees the correct s_lock_tokens entry and the PUT succeeds.
.plk sidecar files are used by the existing local-file lock mechanism (non-transport paths). We do not remove or replace this mechanism — it serves a different purpose (cooperative lock between two pwsafe instances on the same machine opening the same local file).
For transport URLs, the lock is held server-side (WebDAV LOCK token) and tracked via the in-process registry. The .plk mechanism is never involved for transport URLs.
The original protocol used newline-delimited text: "LOCK url\n". This is analogous to system() — any delimiter in user input (\n in a URL) injects extra commands into the parser.
The replacement is length-prefixed binary frames: [uint8_t opcode][uint32_t url_len LE][url_len bytes]. This is analogous to execve(argv[]) — each element is a separate, self-contained object. A URL can contain any byte value, including \n, space, or NUL, without affecting framing.
See also: PROGRAMMING_RULES/security-interfaces
Windows has no fork(). The Unix lock daemon exists specifically because fork() gives the parent an empty copy of s_lock_tokens — the child acquires the lock token, but the parent's copy is always empty. On Windows, all calls happen in the same process, so the token map is always consistent. windows/transport_lockd.cpp is therefore just direct synchronous calls to the plugin — no IPC, no child process, no socketpair. Graceful unlock-on-exit is provided by a static destructor (LockdGuard). There is no 'unlock on crash' guarantee (the Unix child handles this via EOF detection), but WebDAV locks have a server-side timeout so stale locks expire.
On MSVC, extern "C" gives C linkage (no name mangling) but does NOT export the symbol from the DLL. GetProcAddress returns null unless the symbol appears in the export table. The fix is the PWS_EXPORT macro: __declspec(dllexport) on Windows, empty on Unix/macOS. Without it, plugin loading succeeds (LoadLibrary loads the DLL) but GetProcAddress(handle, "pws_plugin_init") silently returns null — all plugin operations then fail with no obvious error.
Windows paths like C:\Users\john\file.psafe3 contain a colon at position 1 (after the drive letter). The scheme extractor checks pos < 2 to reject these — a valid URL scheme must be at least 2 characters (e.g. "ftp", "http"). This check is the same on all platforms but is particularly important on Windows where drive letters are common. The Unix npos check (pos == npos || pos < 2) must come first to avoid comparing SIZE_MAX < 2 which is always false on 64-bit.
On macOS, loadable modules (loaded via dlopen at runtime) should be built as MH_BUNDLE type, not MH_DYLIB. The flags are -bundle -undefined dynamic_lookup. Using -shared produces a dylib which dlopen can load, but -bundle is semantically correct for plugins and avoids linker warnings. The -undefined dynamic_lookup flag tells the linker not to error on unresolved symbols — they will be resolved from the loading application at dlopen time. The Makefile.transport-tests detects Darwin and uses these flags automatically.
macOS enforces library validation when an app has the Hardened Runtime: it refuses to load a plugin not signed with the same Team ID as the main binary. Two steps are needed: (1) sign the plugin with codesign --sign <identity>; (2) re-sign the app bundle with the com.apple.security.cs.disable-library-validation entitlement. The macos-build.sh script handles both automatically — it reads the app's Team ID from codesign -dv, finds a matching keychain identity, signs the plugin, then re-signs the bundle. For standalone test binaries (no Hardened Runtime), codesigning is not required and dlopen works without it.
libcurl's netrc parser requires the keyword login for the username field. Some tools (notably cadaver) write user instead. This causes silent authentication failures — libcurl finds the machine entry but skips the credentials. The error shown to the user is 'Permission denied' (EACCES from a 401 response). The fix is in the user's ~/.netrc file, not in the code; the error dialog now explicitly hints at this: 'Check ~/.netrc has a login entry (not user) for this host.' Ansible provisioning playbooks must also use login. See ansible/todo.
pws_os::FileExists() returns bool and has no error-detail channel. When a transport URL fails (plugin not found, auth failure, network error), the UI showed a hardcoded 'File or path not found.' regardless of the real cause. Fix: FileExists now sets errno to the actual transport error code before returning false — ENOTSUP if no plugin, EACCES on 401/403, ECONNREFUSED/ETIMEDOUT on network failure, ENOENT only if the resource genuinely doesn't exist. The dialog checks errno and shows a specific message: ENOTSUP → 'No transport plugin for this URL scheme', EACCES → netrc hint, other → strerror(errno) appended.
The system-installed binary looks for help files at /usr/share/passwordsafe/help/ (Linux) or /usr/local/share/doc/passwordsafe/help/ (FreeBSD). A portable install (binary + plugins + help zips in one directory) wouldn't find them. unix/dir.cpp gethelpdir() now checks for helpEN.zip alongside the binary first (via /proc/self/exe), falling back to the system paths. This allows ~/pwsafe/ to be a self-contained directory with no system installation required.