Class ResourceHashSidecar
UltiToolsPlugin.saveResources() 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 -
Constructor Summary
Constructors -
Method Summary
Modifier and TypeMethodDescriptionreadAllForWrite(File resourceFolder) LikereadAll(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 onResourceHashSidecar.java:155).readRecordedHash(File resourceFolder, String resourcePath) Returns the digest previously recorded forresourcePath(e.g.static voidRecordshashas the provenance digest forresourcePathunderresourceFolder, preserving every other entry already recorded.static voidRecords everyresourcePath -> hashpair innewEntriesin ONE read-modify-write cycle, instead of the one-cycle-per-entry cost callingrecord(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 Stringsha256(byte[] bytes) Computes the SHA-256 digest ofbytes, hex-encoded lowercase.static StringComputes the SHA-256 digest offile's raw bytes, hex-encoded lowercase.private static FilesidecarFile(File resourceFolder) private static StringtoHex(byte[] hash) private static voidWritesentriesto 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).
-
Field Details
-
LOGGER
-
SIDECAR_FILE_NAME
- See Also:
-
ENTRY_MAP_TYPE
-
GSON
private static final com.google.gson.Gson GSON
-
-
Constructor Details
-
ResourceHashSidecar
private ResourceHashSidecar()
-
-
Method Details
-
sha256
Computes the SHA-256 digest offile'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
Computes the SHA-256 digest ofbytes, hex-encoded lowercase.- Parameters:
bytes- the raw bytes to hash- Returns:
- the lowercase hex-encoded SHA-256 digest
-
toHex
-
readRecordedHash
Returns the digest previously recorded forresourcePath(e.g."lang/en.json") underresourceFolder, orOptional.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
Recordshashas the provenance digest forresourcePathunderresourceFolder, 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 (seesha256(File))
-
recordAll
Records everyresourcePath -> hashpair innewEntriesin ONE read-modify-write cycle, instead of the one-cycle-per-entry cost callingrecord(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- theresourcePath -> hashpairs to add, preserving every entry already recorded
-
sidecarFile
-
readAll
-
readAllForWrite
LikereadAll(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 onResourceHashSidecar.java:155).record(java.io.File, java.lang.String, java.lang.String)andrecordAll(java.io.File, java.util.Map<java.lang.String, java.lang.String>)call this instead ofreadAll(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
Writesentriesto 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 onResourceHashSidecar.java:320, threadPRRT_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 inPosixAttributePreserver.replaceInPlace(java.io.File, byte[], com.ultikits.ultitools.utils.PosixAttributePreserver.ReplaceInPlaceListener), the SAME shared operationUltiToolsPlugin#writeBytesuses 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 callingPosixAttributePreserver.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 callingPosixAttributePreserver.replaceInPlace(java.io.File, byte[], com.ultikits.ultitools.utils.PosixAttributePreserver.ReplaceInPlaceListener), rather than streamingGson.toJson(Object, Type, java.io.Writer)directly onto the staging file's ownWriteras an earlier version of this method did --GSON.toJson(entries, ENTRY_MAP_TYPE)'sString-returning overload serializes into an in-memoryStringWriter, which cannot throw a genuine I/O-basedJsonIOExceptionthe way writing to a file-backedWritercould (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.
-