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.
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 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 TypeMethodDescription(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 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 voidstatic CredentialStore.ReadResultread()Reads the credential document under the single store lock.private static CredentialStore.ReadResultprivate 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().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)
-
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).
-
-
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
-
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.- Parameters:
data- the complete document to persist; replaces any existing content
-
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.
-
migrateLocked
private static void migrateLocked() -
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
-
readLocked
-
readFrom
-
writeLocked
-