02 — Architecture: plugin ABI, loader, FOpen intercept

See: story index | App docs: transport | webdav

The plugin ABI (src/os/transport.h)

A minimal C struct — eight function pointers, a version field, and a scheme string. Version 1 has never changed:

struct PWSTransport {
  int         abi_version;   /* = 1 */
  const char *scheme;        /* "https,http"  or  "file"  or … */
  int (*fetch )(url, local_path);             /* GET  → local file */
  int (*store )(local_path, url);             /* PUT  ← local file */
  int (*exists)(url);                          /* HEAD */
  int (*lock  )(url, token_out, token_len);   /* LOCK  */
  int (*unlock)(url, token);                  /* UNLOCK */
  void (*cleanup)(void);
};

All error returns are POSIX errno values. ENOENT = resource not found, EBUSY = locked by another client, ENOTSUP = operation not supported.

Each plugin embeds an identity string in the ELF .comment section: PWS_TRANSPORT_INFO:1:https,http:WebDAV transport. The loader reads this with mmap + memmem before dlopen, preventing accidental loading of an unrelated .so.

The plugin loader (src/os/unix/transport.cpp)

Key decisions:

• Scheme extraction — extract_scheme(url) finds the colon, validates every character against RFC 3986 §3.1, and returns "" for anything that isn't a well-formed scheme. This prevents path-traversal: a URL like https/../evil:foo can never produce a scheme with '/' in it, so the plugin filename pwsafe-<scheme>.so can never escape the plugin directory.

• Single fd, no TOCTOU — the loader opens the plugin file once with O_RDONLY | O_CLOEXEC | O_NOFOLLOW, scans the identity string via mmap on that fd, then passes the same fd to dlopen as /proc/self/fd/<n>. No race between 'check this .so' and 'load this .so'.

• Search path — app binary directory (from /proc/self/exe), then cwd in DEVELOPMENT builds only. The production binary never looks in cwd.

• Lazy loading — plugins are loaded on first use and cached. s_transports maps scheme → PWSTransport*; s_handles maps scheme → dlhandle.

FOpen / FClose intercept (src/os/unix/file.cpp)

These two functions are the only I/O entry points in the codebase:

pws_os::FOpen(path, mode)
    if pws_is_transport_url(path):
        t = pws_find_transport(path)   ← loads plugin if needed
        cache = pws_get_cache_path(path)   ← ~/.cache/pwsafe/<sanitised>
        t->fetch(path, cache)              ← download
        fd = fopen(cache, mode)            ← open local copy
        pws_cache_register(fd, path, cache, is_write)
        return fd
    else:
        return fopen(path, mode)   ← normal local file

pws_os::FClose(fd)
    if pws_cache_lookup(fd, url, cache, is_write):
        pws_cache_remove(fd)   ← BEFORE fclose
        fclose(fd)
        if is_write:
            if pws_has_lock(url):
                pws_lockd_store(cache, url)   ← daemon has the lock token
            else:
                t->store(cache, url)          ← direct call
    else:
        fclose(fd)

The cache directory (~/.cache/pwsafe/) is created with 0700 permissions so no other local user can read the cached (encrypted) database file.

Two plugins installed

pwsafe-https.so — WebDAV plugin, handles https and http

pwsafe-http.so → pwsafe-https.so — symlink (loader's way of handling multiple schemes from one .so)

pwsafe-file.so — file:// plugin (reference implementation and offline test harness)

version1
created2026-02-27