Class ResourceHashSidecar

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

@Internal public final class ResourceHashSidecar extends Object
Records the SHA-256 digest of every resource file UltiToolsPlugin.saveResources(boolean) extracts, so a later boot can distinguish "the operator edited this file" from "an old jar extracted this and nobody has touched it since" (#441, D-05/D-06/D-07).

The digest is deliberately computed over a file's raw bytes, never over decoded text: an encoding-preserving rewrite of the same logical content must read as a difference, and a byte-identical copy must not.

The sidecar file is named ".ultitools-resource-hashes.json". It lives directly under the module's resource folder root -- never under lang/, res/ or config/ -- and its name is dot-prefixed and starts with none of those three strings, so it can never collide with a real extracted resource: saveResources() only ever extracts jar entries whose name starts with "res", "lang" or "config".

A missing or unparsable sidecar degrades to "no record" rather than throwing (T-16-04-03): an operator who can edit the sidecar can already edit the files it describes, so treating a corrupt sidecar as "unknown provenance" is the safe branch, not a defect to guard against harder.

public only so UltiToolsPlugin (a different package) can call it -- this is internal framework plumbing, not a documented capability (WR-02): it appears nowhere in FEATURES.md as its own row, and this milestone's own D-09 decision rejects shipping new public surface in 6.3.0 for exactly this reason. ApiStatus.Internal carries no binary- compatibility consequence here -- the class is new in this same pull request and has never shipped in a released jar, so no COMPATIBILITY.md entry or japicmp exclude is needed for this annotation; annotating it now, before the first release that carries it, is what keeps it from becoming one later.

Since:
6.3.0
  • Field Summary

    Fields
    Modifier and Type
    Field
    Description
    private static final Type
     
    private static final com.google.gson.Gson
     
    private static final Logger
     
    private static final String
     
  • Constructor Summary

    Constructors
    Modifier
    Constructor
    Description
    private
     
  • Method Summary

    Modifier and Type
    Method
    Description
    private static Map<String,String>
    readAll(File resourceFolder)
     
    private static Optional<Map<String,String>>
    readAllForWrite(File resourceFolder)
    Like readAll(File), but distinguishes "the sidecar does not exist yet" (fine to proceed from an empty baseline) from "the sidecar exists but could not be read or parsed THIS TIME" (Codex round 9, P2, discussion on ResourceHashSidecar.java:155).
    readRecordedHash(File resourceFolder, String resourcePath)
    Returns the digest previously recorded for resourcePath (e.g.
    static void
    record(File resourceFolder, String resourcePath, String hash)
    Records hash as the provenance digest for resourcePath under resourceFolder, preserving every other entry already recorded.
    static void
    recordAll(File resourceFolder, Map<String,String> newEntries)
    Records every resourcePath -> hash pair in newEntries in ONE read-modify-write cycle, instead of the one-cycle-per-entry cost calling record(java.io.File, java.lang.String, java.lang.String) once per entry in a loop would pay (Codex round 4, P2): saveResources() extracts every jar entry across a single pass, so collecting that pass's hashes and persisting them together avoids parsing and atomically rewriting an increasingly large sidecar once per extracted file -- quadratic for a module bundling many resources (e.g.
    static String
    sha256(byte[] bytes)
    Computes the SHA-256 digest of bytes, hex-encoded lowercase.
    static String
    sha256(File file)
    Computes the SHA-256 digest of file's raw bytes, hex-encoded lowercase.
    private static File
    sidecarFile(File resourceFolder)
     
    private static String
    toHex(byte[] hash)
     
    private static void
    writeAll(File resourceFolder, Map<String,String> entries)
    Writes entries to the sidecar file, replacing it in place (Codex round 3, P2: atomically, via a temp file, never truncating the real sidecar on a failure partway through).

    Methods inherited from class java.lang.Object

    clone, equals, finalize, getClass, hashCode, notify, notifyAll, toString, wait, wait, wait
  • Field Details

    • LOGGER

      private static final Logger LOGGER
    • SIDECAR_FILE_NAME

      private static final String SIDECAR_FILE_NAME
      See Also:
    • ENTRY_MAP_TYPE

      private static final Type ENTRY_MAP_TYPE
    • GSON

      private static final com.google.gson.Gson GSON
  • Constructor Details

    • ResourceHashSidecar

      private ResourceHashSidecar()
  • Method Details

    • sha256

      public static String sha256(File file)
      Computes the SHA-256 digest of file's raw bytes, hex-encoded lowercase.

      Codex round 4, P2: streams the file in fixed-size chunks rather than Files.readAllBytes(java.nio.file.Path) -- the previous implementation allocated a byte array as large as the entire file, so a sufficiently large bundled resource (a module's web dashboard assets, audio, etc.) could exhaust the server heap and abort module initialization, even though the extraction path that produced the file (saveResources()) already streams with a fixed-size buffer. Streaming here keeps peak memory bounded regardless of file size.

      Parameters:
      file - the file to hash; must exist and be readable
      Returns:
      the lowercase hex-encoded SHA-256 digest of the file's raw bytes
    • sha256

      public static String sha256(byte[] bytes)
      Computes the SHA-256 digest of bytes, hex-encoded lowercase.
      Parameters:
      bytes - the raw bytes to hash
      Returns:
      the lowercase hex-encoded SHA-256 digest
    • toHex

      private static String toHex(byte[] hash)
    • readRecordedHash

      public static Optional<String> readRecordedHash(File resourceFolder, String resourcePath)
      Returns the digest previously recorded for resourcePath (e.g. "lang/en.json") under resourceFolder, or Optional.empty() when there is no record -- including when the sidecar file itself is absent or cannot be parsed.
      Parameters:
      resourceFolder - the module's resource folder root (the sidecar's own location)
      resourcePath - the extracted resource's path, relative to the resource folder, using '/' as separator (matching jar entry names)
      Returns:
      the recorded digest, or empty if there is no record
    • record

      public static void record(File resourceFolder, String resourcePath, String hash)
      Records hash as the provenance digest for resourcePath under resourceFolder, preserving every other entry already recorded.

      Codex round 9, P2: if the sidecar file EXISTS but cannot currently be read/parsed (e.g. a transient filesystem error), this is a no-op -- see readAllForWrite(File)'s own javadoc for why a write must never equate that with "the sidecar does not exist yet".

      Parameters:
      resourceFolder - the module's resource folder root (the sidecar's own location)
      resourcePath - the extracted resource's path, relative to the resource folder, using '/' as separator (matching jar entry names)
      hash - the digest to record (see sha256(File))
    • recordAll

      public static void recordAll(File resourceFolder, Map<String,String> newEntries)
      Records every resourcePath -> hash pair in newEntries in ONE read-modify-write cycle, instead of the one-cycle-per-entry cost calling record(java.io.File, java.lang.String, java.lang.String) once per entry in a loop would pay (Codex round 4, P2): saveResources() extracts every jar entry across a single pass, so collecting that pass's hashes and persisting them together avoids parsing and atomically rewriting an increasingly large sidecar once per extracted file -- quadratic for a module bundling many resources (e.g. thousands of web dashboard assets). A no-op for an empty map: never touches the sidecar file, or its directory, when there is nothing new to record.

      Codex round 9, P2: also a no-op if the sidecar file EXISTS but cannot currently be read/parsed -- see readAllForWrite(File)'s own javadoc.

      Parameters:
      resourceFolder - the module's resource folder root (the sidecar's own location)
      newEntries - the resourcePath -> hash pairs to add, preserving every entry already recorded
    • sidecarFile

      private static File sidecarFile(File resourceFolder)
    • readAll

      private static Map<String,String> readAll(File resourceFolder)
    • readAllForWrite

      private static Optional<Map<String,String>> readAllForWrite(File resourceFolder)
      Like readAll(File), but distinguishes "the sidecar does not exist yet" (fine to proceed from an empty baseline) from "the sidecar exists but could not be read or parsed THIS TIME" (Codex round 9, P2, discussion on ResourceHashSidecar.java:155). record(java.io.File, java.lang.String, java.lang.String) and recordAll(java.io.File, java.util.Map<java.lang.String, java.lang.String>) call this instead of readAll(java.io.File) for exactly that reason: readAll(java.io.File)'s "any failure degrades to an empty map" contract is correct for a READ (readRecordedHash(java.io.File, java.lang.String), per T-16-04-03 -- a caller just gets "no record", nothing is lost), but wrong for a WRITE. Before this fix, record()/recordAll() called readAll() directly, so a transient read failure on an otherwise-valid, non-empty sidecar produced an empty map that was then WRITTEN BACK with only the entry being recorded -- permanently discarding every other module's previously recorded hash over a failure that may not even recur on the very next boot.
      Parameters:
      resourceFolder - the module's resource folder root (the sidecar's own location)
      Returns:
      the parsed entries (a fresh, empty map if the sidecar does not exist), or Optional.empty() if the sidecar file exists but could not be read/parsed this time -- callers writing back MUST treat that as "do nothing", never as "empty"
    • writeAll

      private static void writeAll(File resourceFolder, Map<String,String> entries)
      Writes entries to the sidecar file, replacing it in place (Codex round 3, P2: atomically, via a temp file, never truncating the real sidecar on a failure partway through).

      Round following PosixAttributePreserver.java:104 (symlink finding on ResourceHashSidecar.java:320, thread PRRT_kwDOIcF9Es6i2I49, P2): this method's entire replace-in-place operation -- the operator-pinned-read-only refusal, the symlink refusal, the temp-file-then-atomic-move mechanism, and POSIX attribute preservation -- now lives in PosixAttributePreserver.replaceInPlace(java.io.File, byte[], com.ultikits.ultitools.utils.PosixAttributePreserver.ReplaceInPlaceListener), the SAME shared operation UltiToolsPlugin#writeBytes uses for the language-file path, instead of the sidecar copying each of that operation's properties in one at a time across separate rounds (round 3: attribute preservation; round 6 (this same session): an unreadable-attributes edge case; this round: symlink handling, which had never been ported here at all). A third replace-in-place site now gets every one of these properties for free by calling PosixAttributePreserver.replaceInPlace(java.io.File, byte[], com.ultikits.ultitools.utils.PosixAttributePreserver.ReplaceInPlaceListener) instead of having to notice each property is missing one at a time.

      The entries are serialized to a byte[] BEFORE calling PosixAttributePreserver.replaceInPlace(java.io.File, byte[], com.ultikits.ultitools.utils.PosixAttributePreserver.ReplaceInPlaceListener), rather than streaming Gson.toJson(Object, Type, java.io.Writer) directly onto the staging file's own Writer as an earlier version of this method did -- GSON.toJson(entries, ENTRY_MAP_TYPE)'s String-returning overload serializes into an in-memory StringWriter, which cannot throw a genuine I/O-based JsonIOException the way writing to a file-backed Writer could (Codex round 6, P2, on the pre-refactor version of this method). This is a simplification the refactor enables, not merely a byproduct: PosixAttributePreserver.replaceInPlace(java.io.File, byte[], com.ultikits.ultitools.utils.PosixAttributePreserver.ReplaceInPlaceListener) takes the exact bytes to write, uniformly for every caller, so the disk-I/O-during-serialization failure mode this class used to guard against separately no longer exists as a distinct case to catch.