Some bugs announce themselves loudly. Segfaults, stack traces, five-hundred-line panics. This one didn't. This one just sat there quietly for who knows how long, waiting for the first person unlucky (or lucky?) enough to actually need it.
That person turned out to be me.
The setup: packaging proton-pass-cli for Termux
I've been working on getting the Proton Pass CLI packaged for termux-packages, tracked in PR #30987. It's a Rust project, cross-compiled for Android, pulling in system libraries instead of vendored ones wherever possible — openssl, zlib, protobuf, and sqlcipher.
Everything was going fine until the final link step, when the build died with this:
ld.lld: error: unable to find library -lsqlcipher
Which is a delightfully unhelpful error when you're staring at find output that clearly shows libsqlcipher.so sitting right there in $PREFIX/lib.
Chasing a library that "exists" but doesn't
My first instinct was the usual suspects: wrong PKG_CONFIG_PATH, missing PKG_CONFIG_ALLOW_CROSS for cross-compilation, wrong SQLCIPHER_LIB_DIR. I set all of those. Still broken.
So I went straight to the filesystem. ls -la on the lib directory told a different story than find had:
libsqlcipher.so -> libsqlite3.so.0 (broken)
libsqlcipher.so.0 -> libsqlite3.so.<version> (broken)
libsqlcipher.so.<version> (the only real file)
There it was. libsqlcipher.so existed as a name, but it was a symlink pointing at libsqlite3.so.0 — a file that no longer existed, because it had also been renamed away. The linker doesn't care that a file named libsqlcipher.so is present; it cares whether the symlink actually resolves. find and ls will happily list a broken symlink by its entry name and let you assume everything's fine. The linker knows better.
Root cause: mv on a symlink doesn't do what you think
The actual bug lived one layer upstream, in the sqlcipher package's own termux_step_post_massage(), where Termux renames the sqlite3 build artifacts to sqlcipher names to avoid collisions:
mv lib/lib{sqlite3,sqlcipher}.so
mv lib/lib{sqlite3,sqlcipher}.so.0
mv lib/lib{sqlite3,sqlcipher}.so."$sql_version"
Here's the trap: libsqlite3.so and libsqlite3.so.0 aren't regular files, they're symlinks. mv on a symlink only renames the link itself — it does not rewrite the target path stored inside it. So after this rename:
- The link now named
libsqlcipher.sostill internally points atlibsqlite3.so.0 - The link now named
libsqlcipher.so.0still internally points atlibsqlite3.so.<version> - Both of those target names no longer exist, because the real versioned file got renamed to
libsqlcipher.so.<version>in the same step
A broken symlink chain, hiding in plain sight, for what appears to have been the entire life of the sqlcipher package — because nothing had ever linked against it before. proton-pass-cli was about to become the first true reverse dependency of sqlcipher in termux-packages, and reverse dependencies are exactly the kind of thing that surfaces bugs nobody could have found by testing the package in isolation.
The temporary fix
I didn't own the sqlcipher package, and I needed my own PR unblocked, so I patched around it locally in proton-pass-cli's own termux_step_pre_configure() — detect the real installed .so.<version> file at build time and recreate the symlinks pointing at it correctly:
_sqlcipher_real_so="$(find "$TERMUX_PREFIX/lib" -maxdepth 1 -type f -name 'libsqlcipher.so.*' -print -quit)"
if [ -n "$_sqlcipher_real_so" ]; then
_sqlcipher_real_so="$(basename "$_sqlcipher_real_so")"
ln -sf "$_sqlcipher_real_so" "$TERMUX_PREFIX/lib/libsqlcipher.so.0"
ln -sf "$_sqlcipher_real_so" "$TERMUX_PREFIX/lib/libsqlcipher.so"
fi
It worked. The build went green on all four architectures. Problem solved — for my package, at least.
"Fix it properly, in the right place"
When @robertkirkman reviewed PR #30987, he pointed out the obvious: papering over a broken symlink in every downstream consumer of sqlcipher doesn't scale. The correct fix belongs in sqlcipher's own build.sh, not duplicated in mine and every future reverse dependency.
Fair. So I opened PR #31013 against packages/sqlcipher/build.sh, fixing it at the source:
rm -f lib/libsqlite3.so lib/libsqlite3.so.0
mv lib/lib{sqlite3,sqlcipher}.so."$sql_version"
ln -sf libsqlcipher.so."$sql_version" lib/libsqlcipher.so.0
ln -sf libsqlcipher.so.0 lib/libsqlcipher.so
Instead of trying to mv symlinks and hoping the target updates itself, remove the stale links entirely and recreate them fresh, pointing at the correctly renamed versioned file — with the version pulled from $sql_version so it stays correct across future bumps.
Beaten to the finish line by two hours
Here's the part that made me laugh instead of sigh: by the time I pushed PR #31013, @robertkirkman had already opened PR #31010 fixing the exact same root cause — roughly two hours earlier. Same diagnosis, functionally the same fix, just arrived at independently and slightly faster.
No hard feelings there. It happens constantly in open source — two people staring at the same broken symlink chain from different angles, both reaching for ln -sf. I closed #31013 as a duplicate and updated #30987 to track #31010 instead. Once that lands, my temporary workaround in proton-pass-cli comes back out, and the fix lives exactly where it should: upstream, in sqlcipher itself, benefiting every reverse dependency that comes after — not just mine.
Takeaways
A few things stuck with me from this one:
-
mvon a symlink is not the same asmvon a file. It renames the link, not the target it points to. If you're renaming a pair of symlink + real file in the same operation, do the real file first and rebuild the links from scratch. -
findandlslie by omission. They'll show you a symlink's name happily, broken or not. If you actually need to know whether something resolves, check that explicitly. - First reverse dependencies are bug detectors. A library that's never been linked against can hide structural issues indefinitely. The moment something actually depends on it, all bets are off.
-
Fix bugs where they live, not where you found them. My instinct was to patch around the symptom in my own package. The maintainer's instinct — fix it in
sqlcipheritself — was the right one, even though it meant more work and, as it turned out, a friendly race I didn't win.
If you want to follow the whole trail, it's all public:
Top comments (0)