Class CredentialStore

java.lang.Object
com.ultikits.ultitools.utils.CredentialStore

public final class CredentialStore extends Object
The single owner of 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
  • Field Details

    • OLD_FILE_NAME

      private static final String 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 targets CREDENTIAL_FILE_NAME.
      See Also:
    • NEW_DIR_NAME

      private static final String NEW_DIR_NAME
      The directory CREDENTIAL_FILE_NAME lives in, resolved as a sibling of plugins/ under the server root -- outside every Phase 6 D-15 default editable root.
      See Also:
    • CREDENTIAL_FILE_NAME

      public static final String CREDENTIAL_FILE_NAME
      The current (>= 6.3.0) credential file's bare name. Public so FileOperationManager'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

      private static final String TEMP_FILE_NAME
      See Also:
    • GSON

      private static final com.google.gson.Gson GSON
    • LOCK

      private static final Object LOCK
    • targetPathOverride

      private static volatile Path targetPathOverride
      Overrides the production target (new-location) path for a test. null once cleared, restoring the default production resolution. Package-private -- production code must never call this.
    • oldLocationOverride

      private static volatile Path oldLocationOverride
      Overrides migrate()'s old-location lookup for a test, independently of targetPathOverride. null once 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 simulateWriteFailureForTesting
      Test-only fault injection: when true, 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

      private static String 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 by LOCK.
    • reportedLeftover

      private static String reportedLeftover
      The WARNING last logged for an unreadable pre-6.3.0 file left beside a valid current file. Guarded by LOCK.
    • reportedKeptBoth

      private static String 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 by LOCK.
  • Constructor Details

    • CredentialStore

      private CredentialStore()
  • Method Details

    • setTargetPathForTesting

      static void setTargetPathForTesting(Path path)
      Points this store at a fixed path instead of resolving it from UltiTools.getInstance(). Test-only: lets a test exercise this class against a @TempDir without a running Bukkit server. Call clearTargetPathForTesting() in an @AfterEach to restore production resolution.
      Parameters:
      path - the path a test wants data.json operations to target
    • clearTargetPathForTesting

      static void clearTargetPathForTesting()
      Restores production target-path resolution after a test that called setTargetPathForTesting(Path).
    • setOldLocationForTesting

      static void setOldLocationForTesting(Path path)
      Points migrate()'s old-location lookup at a fixed path instead of resolving it from UltiTools.getInstance()'s data folder. Test-only, independent of setTargetPathForTesting(Path) -- a migration test needs to control the old and new locations separately. Call clearOldLocationForTesting() in an @AfterEach.
      Parameters:
      path - the path a test wants the pre-6.3.0 data.json to be looked up at
    • clearOldLocationForTesting

      static void clearOldLocationForTesting()
      Restores production old-location resolution after a test that called setOldLocationForTesting(Path).
    • setSimulateWriteFailureForTesting

      static void setSimulateWriteFailureForTesting(boolean simulate)
      Test-only: makes the next (and every subsequent, until cleared) writeLocked(Map) fail with a DataAccessException before touching the filesystem, so a migration test can exercise the "new file write fails" path deterministically. Call with false (or rely on @AfterEach) to restore normal writes.
      Parameters:
      simulate - true to make writes fail, false to restore normal writes
    • resolveTargetPath

      private static Path resolveTargetPath()
      Resolves the current (>= 6.3.0) target path. Deliberately requires a live UltiTools.getInstance() in production -- exactly as the pre-6.3.0 resolution against JavaPlugin.getDataFolder() always did -- rather than falling back to a bare System.getProperty("user.dir"). A silent CWD fallback would let any test that reaches read()/write(Map)/update(UnaryOperator) without mocking UltiTools write a real .ultikits/credentials.json at whatever the test runner's working directory happens to be -- for mvn test run 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 the NullPointerException the pre-6.3.0 code would have thrown in that situation, not a new restriction.
      Throws:
      DataAccessException - if no test override is set and no UltiTools instance is up to resolve the server root from
    • stateDirectory

      static Path stateDirectory()
      The directory that holds the credential file: <server root>/.ultikits in production, or the parent of the test override when one is set. CloudStateStore keeps cloud-outbox.json beside credentials.json here, so both framework-owned files share one location rule (plan 22-03).
      Returns:
      the absolute directory of resolveTargetPath()
      Throws:
      DataAccessException - under the same condition as resolveTargetPath()
    • resolveOldLocationPath

      private static Path resolveOldLocationPath()
      Resolves the pre-6.3.0 location migrate() looks for, or null if it cannot be resolved yet -- UltiTools.getInstance() is not up (a test with no override, or a caller running before the plugin has enabled). A null result is not an error: it means "nothing to migrate can be determined right now", and migrateLocked() treats it exactly like an absent file.
      Returns:
      the old data.json location, or null if it cannot be resolved
    • read

      public static CredentialStore.ReadResult read()
      Reads the credential document under the single store lock. Runs migrate() 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.ReadResult distinguishing an absent file, a successfully parsed file, and a file that exists but failed to parse
    • write

      public static void write(Map<String,Object> data)
      Replaces the entire content of data.json with data, atomically.

      Writes go to a fixed, same-directory temporary file and are applied with Files.move(Path, Path, java.nio.file.CopyOption...) using StandardCopyOption.ATOMIC_MOVE and StandardCopyOption.REPLACE_EXISTING. ATOMIC_MOVE is 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 (DataAccessException wrapping AtomicMoveNotSupportedException) 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

      public static void update(UnaryOperator<Map<String,Object>> mutator)
      Reads, applies mutator, and writes back -- all under a single acquisition of the store lock, so two concurrent update calls each see the other's completed result rather than clobbering it.

      Refuses to run if the existing file exists but fails to parse: starting mutator from 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 with write(Map)) before calling this.

      WR-03 (08-REVIEW.md): mutator must not return null. A caller that mutates the map it was given in place and forgets the trailing return, 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 mirrors CredentialStore.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, call write(Collections.emptyMap()) instead of relying on a null return from mutator.

      Parameters:
      mutator - receives a mutable copy of the current document and returns the document to persist; must not return null
      Throws:
      NullPointerException - if mutator returns null
    • 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 from read(), write(Map) and update(UnaryOperator) -- the store's own entry points, not a bootstrap step in UltiTools or 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 single Files.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

      private static String 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 null when there is none
    • migrateOnceLocked

      private static String migrateOnceLocked()
    • cleanUpBesideCurrentLocked

      private static String cleanUpBesideCurrentLocked(Path oldPath, Path newPath)
      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 null once the old copy has been handled
    • describeKeptBoth

      private static String describeKeptBoth(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. Paths only -- never file content.
    • deleteOldFile

      private static void deleteOldFile(Path oldPath)
    • logInfo

      private static void logInfo(String message)
      Best-effort, non-throwing logging at Level.INFO -- mirrors logWarning(java.lang.String), see that method for why a missing UltiTools instance must never surface as an exception here.
    • logWarning

      private static void logWarning(String message)
      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 -- or UltiTools.getInstance() being null, as in a plain unit test -- must never turn into an exception that could mask the real outcome being logged.
    • log

      private static void log(Level level, String message)
    • readCurrentLocked

      private static CredentialStore.ReadResult readCurrentLocked()
      Reads the current file under LOCK. 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

      private static String describeUnreadable(Path unreadable, Path missingCurrent)
      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 document
      missingCurrent - the current file's path when unreadable is the pre-6.3.0 file and no current file exists yet, otherwise null
    • readFrom

      private static CredentialStore.ReadResult readFrom(Path target)
    • writeLocked

      private static void writeLocked(Map<String,Object> data)
      Writes data through OwnerOnlyAtomicFile.write(Path, String, OwnerOnlyAtomicFile.WriterBody), the one atomic owner-only writer this class shares with CloudStateStore (plan 22-03, S-15): a same-directory temporary file with the fixed name credentials.json.tmp, created owner-only on POSIX, forced to disk and moved over the target with ATOMIC_MOVE. This class keeps its own simulated-failure hook, its Gson serialisation and its DataAccessException messages; the I/O lives in the helper only.