Write the KeyStore atomically and close its streams - #1841
Open
kevinherron wants to merge 2 commits into
Open
Conversation
KeyStore.load and KeyStore.store do not close the streams they are given, so every load and store leaked a descriptor until the cleaner ran. On Windows that also kept the file locked. Opening a FileOutputStream on the KeyStore file was the worse problem: it truncates on open, so a store() that failed part way through left behind a KeyStore with no keys in it. Writes now go to a temporary file in the same directory and are moved into place, preserving the original file's POSIX permissions and following symlinks so an existing link is updated rather than replaced by a regular file. set() and remove() mutate the in-memory KeyStore before writing it out, so they now roll that mutation back when the write fails; otherwise memory and disk stay diverged for the life of the process. set() also picks up the null alias guard that contains(), get() and remove() already had, and getAlias is declared @nullable to match how its callers treat it. The tests open a second store over the same file, which is the only way to tell that anything reached disk; the inherited assertions all pass against the in-memory KeyStore alone.
The same unclosed-stream problem as KeyStoreCertificateStore. Both copies now use Path with Files.newInputStream/newOutputStream, matching the client-examples copy that already had the fix.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A bug report pointed out that
KeyStoreCertificateStorehands streams toKeyStore.loadandKeyStore.storewithout ever closing them. That much is true — neither method takes ownership of the stream it's given. On Java 17 the descriptor is released by a cleaner once the stream becomes unreachable, so the practical cost is a non-deterministic delay rather than an unbounded leak, but on Windows the file stays locked for that interval, which is enough to block replacing or deleting the keystore.The more serious problem is on the same lines, and the report didn't mention it.
new FileOutputStream(file)truncates on open, so astore()that threw part way through — bad password supplier, unwritable disk, unsupported key — left the keystore truncated and the server's private keys gone.initialize()only writes once, butset()andremove()write on every certificate update, which made this a routine operation capable of destroying a working keystore. Those two also mutate the in-memoryKeyStorebefore persisting it, so a failed write left memory holding the new state and disk holding the old one for the rest of the process lifetime:get()would keep reporting a certificate that vanished on restart.Writes now go through a single helper that writes to a temporary file in the same directory and moves it into place. It preserves the original file's POSIX permissions, since temporary files are created owner-only and a replace would otherwise silently tighten whatever the operator had configured, and it resolves symlinks so a symlinked keystore is written through rather than replaced by a regular file.
ATOMIC_MOVEis deliberate rather than incidental: a non-atomicFiles.movewithREPLACE_EXISTINGunlinks the target before renaming, which is a worse failure window than a loud error.set()andremove()roll their in-memory mutation back when the write fails. Theentriescache needs no equivalent treatment, sinceget()falls back to theKeyStoreand repopulates it.Two smaller things came along for the ride.
set()picks up the null-alias guard thatcontains(),get()andremove()already had, andgetAliasis now declared@Nullableto match how its callers already treat it. Separately, theKeyStoreLoadercopies underserver-examplesandintegration-testshad the same unclosed-stream bug, and now match theclient-examplescopy that already had the fix — no atomic-write treatment there, since they only write when the file doesn't yet exist.Testing
Everything
CertificateStoreTestasserts runs against the live in-memoryKeyStore, so it would pass unchanged even ifstoreKeyStore()wrote nothing at all. What's added opens a second store over the same file, which is the only way to tell that anything reached disk. One case covers the ordinary persistence path; another injects a failing password supplier and checks what the new code actually claims — that the entry is rolled back in memory, that no temporary file is left behind, and that what was already on disk still reads back afterwards.Not in this PR
watchForChangesis separately broken; #1842 stacks on this branch and fixes it. The same truncate-on-open pattern also exists inFileBasedTrustListManagerandFileBasedCertificateQuarantine, which is still open — lower stakes there, since a truncated.deris logged and skipped rather than costing a private key.🤖 Generated with Claude Code