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.

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).
  • 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
    • 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.

      Parameters:
      data - the complete document to persist; replaces any existing content
    • 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.

    • migrateLocked

      private static void migrateLocked()
    • 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)
    • readLocked

      private static CredentialStore.ReadResult readLocked()
    • readFrom

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

      private static void writeLocked(Map<String,Object> data)