Class CredentialStore
data.json. Any other class that opens a reader or writer on
that file is a defect (D-12) -- route the operation through read(), write(Map)
or update(UnaryOperator) instead.
Before this class existed, data.json had two independent writers guarded by two
different monitors (CloudAuthManager's class monitor and CommonUtils's class
monitor), and both truncated the file and wrote in place with no temporary file and no atomic
rename. That shape allows a torn write (a crash mid-write leaves a partially-written file), a
lost update (two read-modify-write sequences interleaving and one silently discarding the
other's change), and a torn file being swallowed as "no saved token" by a broad catch. This
class dissolves all three by construction: one lock guards every read, write and update; every
write goes to a same-directory temporary file and is applied by
Files.move(Path, Path, java.nio.file.CopyOption...) with StandardCopyOption.ATOMIC_MOVE
so a crash mid-write leaves either the previous complete file or the new complete file, never a
truncated one; and read() reports a file that exists but does not parse as an outcome
structurally distinct from an absent file, so a caller cannot conflate the two.
An existing file that cannot be read as a credential document is preserved (#573). A file
that is empty, whitespace-only, the JSON literal null, or not valid JSON is a parse
failure -- the store itself never writes any of those (an empty store is written as {}).
Following the maintainer's rule for an unreadable configuration file (#470: treated like an
unparseable one and never overwritten), such a file is never replaced by update(java.util.function.UnaryOperator<java.util.Map<java.lang.String, java.lang.Object>>), so no
new server UUID is generated over it and no cloud token is written into it; the same holds for an
unreadable pre-6.3.0 data.json while no current file exists. The condition is logged once,
at Level.SEVERE, naming the file and what to check. Only a file that does not exist at all
starts a fresh identity.
The current file is validated before the pre-6.3.0 copy is removed (D-06). When both files
exist, the pre-6.3.0 copy is deleted only after the current file has been read as a credential
document (any parsed JSON object, {} included); the current value wins and is never
overwritten with the old content. Existence alone is not validity: if the current file is empty, is
not a valid JSON document, or is not a regular file, both files are kept unchanged and one WARNING
naming both paths is logged per episode; if it cannot be opened at all, the read error propagates and
both are kept. While a readable pre-6.3.0 copy waits behind such a current file, read()
reports a parse failure, update(java.util.function.UnaryOperator<java.util.Map<java.lang.String, java.lang.Object>>) refuses as above, and write(Map) refuses too, so
nothing can replace the current file and let a later migration remove the only readable copy. Once
the current file is fixed, the next store access completes the cleanup.
Durability (#573). The temporary file is forced to disk before the atomic move, and the
parent directory is forced after it where the platform allows (a directory cannot be opened as a
channel on Windows, so that step is best-effort), so a power loss cannot leave the credential path
naming a file whose bytes never reached the disk. Writes are rare -- login, token refresh, the
first server UUID -- so the cost of the two flushes is negligible. The temporary-file, owner-only,
force and atomic-move steps live in OwnerOnlyAtomicFile, the one writer this class shares
with CloudStateStore's cloud-outbox.json (plan 22-03); there is no second copy.
Both the current (new) and the pre-6.3.0 (old) target paths require a live
UltiTools.getInstance() to resolve in production -- the new one climbs two directories
above JavaPlugin.getDataFolder() to find the server root, the old one reads that data
folder directly. Neither falls back to a bare System.getProperty("user.dir") guess:
see resolveTargetPath() for why a silent CWD fallback is exactly the kind of stray
credential file CLAUDE.md gotcha 17 warns about. Tests override both locations independently
via setTargetPathForTesting(Path) and setOldLocationForTesting(Path) so
neither requires a running Bukkit server.
Location history (plan 08-15, D-15 pulled into 6.3.0): before 6.3.0 this file lived at
plugins/UltiTools/data.json -- inside the Phase 6 D-15 default editable roots
(plugins, logs), reachable by the remote file surface if an operator widened
them. As of 6.3.0 it lives at <server root>/.ultikits/credentials.json, outside every
default editable root, with migrate() moving an existing install's file across on
first use and FileOperationManager's filename-based deny-list entry kept as defence in
depth (the checkpoint's outside-roots-keep-filename answer).
- Since:
- 6.3.0
-
Nested Class Summary
Nested Classes -
Field Summary
FieldsModifier and TypeFieldDescriptionstatic final StringThe current (>=6.3.0) credential file's bare name.private static final com.google.gson.Gsonprivate static final Objectprivate static final StringThe directoryCREDENTIAL_FILE_NAMElives in, resolved as a sibling ofplugins/under the server root -- outside every Phase 6 D-15 default editable root.private static final StringThe pre-6.3.0 location's file name, inside the plugin data folder (plugins/UltiTools/data.json).private static PathOverridesmigrate()'s old-location lookup for a test, independently oftargetPathOverride.private static StringThe WARNING last logged for a readable pre-6.3.0 file kept because the current file beside it cannot be read as a credential document (D-06), so one episode is reported once; cleared by the next migration that finds no such conflict.private static StringThe WARNING last logged for an unreadable pre-6.3.0 file left beside a valid current file.private static StringThe SEVERE message last logged for an unreadable store, so one condition is reported once rather than on every read; cleared by the next read that finds a readable or absent store.private static booleanTest-only fault injection: whentrue,writeLocked(Map)throws instead of writing, so a migration write-failure path can be exercised deterministically without relying on filesystem permission tricks (which behave differently when tests run as root in a container).private static PathOverrides the production target (new-location) path for a test.private static final String -
Constructor Summary
Constructors -
Method Summary
Modifier and TypeMethodDescriptionprivate static StringcleanUpBesideCurrentLocked(Path oldPath, Path newPath) The existing-current branch of the migration.(package private) static voidRestores production old-location resolution after a test that calledsetOldLocationForTesting(Path).(package private) static voidRestores production target-path resolution after a test that calledsetTargetPathForTesting(Path).private static voiddeleteOldFile(Path oldPath) private static StringdescribeKeptBoth(Path oldPath, Path currentPath) The operator-facing description of a readable pre-6.3.0 file kept beside a current file that cannot be read as a credential document (D-06): both paths, that both were kept, and what to check.private static StringdescribeUnreadable(Path unreadable, Path missingCurrent) The operator-facing description of an unreadable credential file: which file, that it was left untouched, and what to check.private static voidprivate static voidBest-effort, non-throwing logging atLevel.INFO-- mirrorslogWarning(java.lang.String), see that method for why a missingUltiToolsinstance must never surface as an exception here.private static voidlogWarning(String message) Best-effort, non-throwing logging.(package private) static voidmigrate()One-time migration from the pre-6.3.0 location (plugins/UltiTools/data.json) to the current one (<server root>/.ultikits/credentials.json).private static StringRuns the migration and reports a kept-both conflict once per episode.private static Stringstatic CredentialStore.ReadResultread()Reads the credential document under the single store lock.private static CredentialStore.ReadResultReads the current file underLOCK.private static CredentialStore.ReadResultprivate static PathResolves the pre-6.3.0 locationmigrate()looks for, ornullif it cannot be resolved yet --UltiTools.getInstance()is not up (a test with no override, or a caller running before the plugin has enabled).private static PathResolves the current (>= 6.3.0) target path.(package private) static voidsetOldLocationForTesting(Path path) Pointsmigrate()'s old-location lookup at a fixed path instead of resolving it fromUltiTools.getInstance()'s data folder.(package private) static voidsetSimulateWriteFailureForTesting(boolean simulate) Test-only: makes the next (and every subsequent, until cleared)writeLocked(Map)fail with aDataAccessExceptionbefore touching the filesystem, so a migration test can exercise the "new file write fails" path deterministically.(package private) static voidsetTargetPathForTesting(Path path) Points this store at a fixed path instead of resolving it fromUltiTools.getInstance().(package private) static PathThe directory that holds the credential file:<server root>/.ultikitsin production, or the parent of thetest overridewhen one is set.static voidupdate(UnaryOperator<Map<String, Object>> mutator) Reads, appliesmutator, and writes back -- all under a single acquisition of the store lock, so two concurrentupdatecalls each see the other's completed result rather than clobbering it.static voidReplaces the entire content ofdata.jsonwithdata, atomically.private static voidwriteLocked(Map<String, Object> data) WritesdatathroughOwnerOnlyAtomicFile.write(Path, String, OwnerOnlyAtomicFile.WriterBody), the one atomic owner-only writer this class shares withCloudStateStore(plan 22-03, S-15): a same-directory temporary file with the fixed namecredentials.json.tmp, created owner-only on POSIX, forced to disk and moved over the target withATOMIC_MOVE.
-
Field Details
-
OLD_FILE_NAME
The pre-6.3.0 location's file name, inside the plugin data folder (plugins/UltiTools/data.json).migrate()is the only code that still reads from this name; every other operation targetsCREDENTIAL_FILE_NAME.- See Also:
-
NEW_DIR_NAME
The directoryCREDENTIAL_FILE_NAMElives in, resolved as a sibling ofplugins/under the server root -- outside every Phase 6 D-15 default editable root.- See Also:
-
CREDENTIAL_FILE_NAME
The current (>=6.3.0) credential file's bare name. Public soFileOperationManager's filename-based deny-list can reference it instead of duplicating the literal -- a future rename then cannot leave that guard pointing at an abandoned name (plan 08-15 Task 2's key link).- See Also:
-
TEMP_FILE_NAME
- See Also:
-
GSON
private static final com.google.gson.Gson GSON -
LOCK
-
targetPathOverride
Overrides the production target (new-location) path for a test.nullonce cleared, restoring the default production resolution. Package-private -- production code must never call this. -
oldLocationOverride
Overridesmigrate()'s old-location lookup for a test, independently oftargetPathOverride.nullonce cleared, restoring the default production resolution (the live plugin's data folder). Package-private -- production code must never call this. -
simulateWriteFailureForTesting
private static volatile boolean simulateWriteFailureForTestingTest-only fault injection: whentrue,writeLocked(Map)throws instead of writing, so a migration write-failure path can be exercised deterministically without relying on filesystem permission tricks (which behave differently when tests run as root in a container). -
reportedUnreadable
The SEVERE message last logged for an unreadable store, so one condition is reported once rather than on every read; cleared by the next read that finds a readable or absent store. Guarded byLOCK. -
reportedLeftover
The WARNING last logged for an unreadable pre-6.3.0 file left beside a valid current file. Guarded byLOCK. -
reportedKeptBoth
The WARNING last logged for a readable pre-6.3.0 file kept because the current file beside it cannot be read as a credential document (D-06), so one episode is reported once; cleared by the next migration that finds no such conflict. Guarded byLOCK.
-
-
Constructor Details
-
CredentialStore
private CredentialStore()
-
-
Method Details
-
setTargetPathForTesting
Points this store at a fixed path instead of resolving it fromUltiTools.getInstance(). Test-only: lets a test exercise this class against a@TempDirwithout a running Bukkit server. CallclearTargetPathForTesting()in an@AfterEachto restore production resolution.- Parameters:
path- the path a test wantsdata.jsonoperations to target
-
clearTargetPathForTesting
static void clearTargetPathForTesting()Restores production target-path resolution after a test that calledsetTargetPathForTesting(Path). -
setOldLocationForTesting
Pointsmigrate()'s old-location lookup at a fixed path instead of resolving it fromUltiTools.getInstance()'s data folder. Test-only, independent ofsetTargetPathForTesting(Path)-- a migration test needs to control the old and new locations separately. CallclearOldLocationForTesting()in an@AfterEach.- Parameters:
path- the path a test wants the pre-6.3.0data.jsonto be looked up at
-
clearOldLocationForTesting
static void clearOldLocationForTesting()Restores production old-location resolution after a test that calledsetOldLocationForTesting(Path). -
setSimulateWriteFailureForTesting
static void setSimulateWriteFailureForTesting(boolean simulate) Test-only: makes the next (and every subsequent, until cleared)writeLocked(Map)fail with aDataAccessExceptionbefore touching the filesystem, so a migration test can exercise the "new file write fails" path deterministically. Call withfalse(or rely on@AfterEach) to restore normal writes.- Parameters:
simulate-trueto make writes fail,falseto restore normal writes
-
resolveTargetPath
Resolves the current (>= 6.3.0) target path. Deliberately requires a liveUltiTools.getInstance()in production -- exactly as the pre-6.3.0 resolution againstJavaPlugin.getDataFolder()always did -- rather than falling back to a bareSystem.getProperty("user.dir"). A silent CWD fallback would let any test that reachesread()/write(Map)/update(UnaryOperator)without mockingUltiToolswrite a real.ultikits/credentials.jsonat whatever the test runner's working directory happens to be -- formvn testrun from the repository root, the repository root itself (CLAUDE.md gotcha 17; measured happening during this plan's own execution before this guard was added). Failing loudly here is the same shape as theNullPointerExceptionthe pre-6.3.0 code would have thrown in that situation, not a new restriction.- Throws:
DataAccessException- if no test override is set and noUltiToolsinstance is up to resolve the server root from
-
stateDirectory
The directory that holds the credential file:<server root>/.ultikitsin production, or the parent of thetest overridewhen one is set.CloudStateStorekeepscloud-outbox.jsonbesidecredentials.jsonhere, so both framework-owned files share one location rule (plan 22-03).- Returns:
- the absolute directory of
resolveTargetPath() - Throws:
DataAccessException- under the same condition asresolveTargetPath()
-
resolveOldLocationPath
Resolves the pre-6.3.0 locationmigrate()looks for, ornullif it cannot be resolved yet --UltiTools.getInstance()is not up (a test with no override, or a caller running before the plugin has enabled). Anullresult is not an error: it means "nothing to migrate can be determined right now", andmigrateLocked()treats it exactly like an absent file.- Returns:
- the old
data.jsonlocation, ornullif it cannot be resolved
-
read
Reads the credential document under the single store lock. Runsmigrate()first (a cheap no-op once the old file is gone), so a caller can never observe the old location's content through this method once a migration was possible.- Returns:
- a
CredentialStore.ReadResultdistinguishing an absent file, a successfully parsed file, and a file that exists but failed to parse
-
write
Replaces the entire content ofdata.jsonwithdata, atomically.Writes go to a fixed, same-directory temporary file and are applied with
Files.move(Path, Path, java.nio.file.CopyOption...)usingStandardCopyOption.ATOMIC_MOVEandStandardCopyOption.REPLACE_EXISTING.ATOMIC_MOVEis only guaranteed on the same filesystem, which is why the temporary file is always created in the target's own parent directory rather than under the JVM's temp directory. If the filesystem cannot honour an atomic move, this method fails loudly (DataAccessExceptionwrappingAtomicMoveNotSupportedException) rather than silently falling back to a non-atomic copy.Refuses while a readable pre-6.3.0 file waits behind a current file that cannot be read as a credential document (D-06): replacing the current file then would let the next migration delete the only readable copy. The migration and this decision run under the same lock acquisition as the write itself. A torn current file with no pre-6.3.0 file beside it is still replaced, as before.
- Parameters:
data- the complete document to persist; replaces any existing content- Throws:
DataAccessException- if the write fails, or while such a conflict is unresolved
-
update
Reads, appliesmutator, and writes back -- all under a single acquisition of the store lock, so two concurrentupdatecalls each see the other's completed result rather than clobbering it.Refuses to run if the existing file exists but fails to parse: starting
mutatorfrom an empty map in that case would silently discard whatever the torn file held, which is exactly the swallow-as-absent failure this class exists to prevent. Resolve the parse failure (inspect the file, or overwrite it withwrite(Map)) before calling this.WR-03 (08-REVIEW.md):
mutatormust not returnnull. A caller that mutates the map it was given in place and forgets the trailingreturn, or falls through an early-return branch without a value, must not have that silently truncate the credential file to{}-- this store is the sole owner of a file that holds live OAuth tokens, so a contract violation here fails loudly (NullPointerException) rather than discarding the caller's data. This mirrorsCredentialStore.ReadResult.data(), which likewise throws rather than offering an empty-map fallback for a parse failure -- neither method in this class treats "something is wrong" as "return an empty document". To clear the file deliberately, callwrite(Collections.emptyMap())instead of relying on anullreturn frommutator.- Parameters:
mutator- receives a mutable copy of the current document and returns the document to persist; must not returnnull- Throws:
NullPointerException- ifmutatorreturnsnull
-
migrate
static void migrate()One-time migration from the pre-6.3.0 location (plugins/UltiTools/data.json) to the current one (<server root>/.ultikits/credentials.json). Called fromread(),write(Map)andupdate(UnaryOperator)-- the store's own entry points, not a bootstrap step inUltiToolsor any manager, so a caller can never reach the file without migration having had the chance to run first. Naturally idempotent: once the old file is gone, every subsequent call is a singleFiles.exists(Path, java.nio.file.LinkOption...)check and nothing else.The whole safety property is the order of operations, in one sentence: read the old file, write and verify the new one, and only then delete the old one -- so a crash or a write failure at any point during migration leaves the operator's credential exactly where it already was, never in neither place. When a current file already exists, the order is: validate the current file, then delete the old one (D-06) -- a current file that cannot be read as a credential document keeps both files, and a read error keeps both and propagates.
-
migrateLocked
Runs the migration and reports a kept-both conflict once per episode.- Returns:
- the operator-facing description of an unresolved conflict -- a readable pre-6.3.0 file
kept beside a current file that cannot be read as a credential document -- or
nullwhen there is none
-
migrateOnceLocked
-
cleanUpBesideCurrentLocked
The existing-current branch of the migration. Existence is not validity (D-06): the old copy is deleted only after the current file reads as a credential document, and the current file is never touched. A current file that does not read as one keeps both files; a read error (DataAccessException) propagates and deletes nothing.- Returns:
- the kept-both description, or
nullonce the old copy has been handled
-
describeKeptBoth
The operator-facing description of a readable pre-6.3.0 file kept beside a current file that cannot be read as a credential document (D-06): both paths, that both were kept, and what to check. Paths only -- never file content. -
deleteOldFile
-
logInfo
Best-effort, non-throwing logging atLevel.INFO-- mirrorslogWarning(java.lang.String), see that method for why a missingUltiToolsinstance must never surface as an exception here. -
logWarning
Best-effort, non-throwing logging. Migration outcomes are operator-visible on purpose (an operator whose credential just moved location needs to know it happened), but a logging failure -- orUltiTools.getInstance()beingnull, as in a plain unit test -- must never turn into an exception that could mask the real outcome being logged. -
log
-
readCurrentLocked
Reads the current file underLOCK. When it does not exist but an unreadable pre-6.3.0 file does, the store is reported as unreadable rather than empty, so no caller starts a fresh identity over a credential that merely failed to migrate (#573). Logs an unreadable store once per episode. -
describeUnreadable
The operator-facing description of an unreadable credential file: which file, that it was left untouched, and what to check.- Parameters:
unreadable- the file that exists but cannot be read as a credential documentmissingCurrent- the current file's path whenunreadableis the pre-6.3.0 file and no current file exists yet, otherwisenull
-
readFrom
-
writeLocked
WritesdatathroughOwnerOnlyAtomicFile.write(Path, String, OwnerOnlyAtomicFile.WriterBody), the one atomic owner-only writer this class shares withCloudStateStore(plan 22-03, S-15): a same-directory temporary file with the fixed namecredentials.json.tmp, created owner-only on POSIX, forced to disk and moved over the target withATOMIC_MOVE. This class keeps its own simulated-failure hook, its Gson serialisation and itsDataAccessExceptionmessages; the I/O lives in the helper only.
-