Class ModuleFileTransactions

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

@Internal public final class ModuleFileTransactions extends Object
The files of the module subsystem and the transactions that change them: which folder modules load from, and how /upm update replaces a module's JAR (#505, #513).

Observation, not prediction. An update never decides before a restart whether the new JAR will load. /upm update downloads the new JAR into the transactions folder, <server root>/.ultikits/upm-transactions -- never into the modules folder -- and writes a record. At the next start, before the module class loader is built, applyBeforeLoad() moves the old JAR aside (it is kept) and puts the new one in place. After the modules have loaded, observeAfterLoad(List, Function) looks at what actually loaded: the update is committed only when the module is loaded from the new JAR at the new version, and otherwise rolled back -- the old JAR restored, the new one removed. Staging never touches the modules folder; the only change while the server runs is a rollback right after loading, which removes the JAR of a module that did not load and puts the old JAR back for the next start.

States. PENDING (staged) -> APPLIED (swapped at a start) -> COMMITTING or ROLLING_BACK (decided) -> record deleted. FAILED (the swap could not be made; nothing in the modules folder changed) is kept until the next /upm update of that module reports it. An APPLIED record found at a start means the previous start ended before the decision; nothing was observed, so it is rolled back before the modules load.

Crash safety. Every state change is written to the record atomically before the step that depends on it, and each step decides from the record plus which files exist -- the new JAR is recognised by its SHA-256, recorded when it was staged -- so running a step again after a crash completes it rather than repeating it, and never deletes a file that is not the staged JAR. Both versions are kept until the decision.

Deferred removal. An uninstall whose JAR cannot be deleted while the server runs -- Windows holds every module JAR open through the shared module class loader -- records the file (recordDeferredRemoval(String, List)), and the next start deletes it before any module loads, if it is still that file (#518). Only a file confirmed to be the recorded one is deleted; one confirmed to be a different file is left alone and its entry dropped; one whose content cannot be read at that start stays recorded, unchanged, for the next start to try again -- a failure to read the file's content never cancels an uninstall the operator asked for. (The size and modification-time checks use File.length() and File.lastModified(), which report 0 on an I/O error, so a failure to stat the file is not covered by this guarantee.)

Where the records live. Under the server root, beside the credential store (transactionsFolder(File)), not under plugins/: the panel's file interface can write under plugins/ when its file writing is enabled, and a forged removal record there would delete a module JAR at the next start (maintainer decision, 2026-09-30). The price is that the start-up swap is an atomic rename between the modules folder and this folder: when plugins/ is on another file system the rename is refused, the update is not applied, and one SEVERE line names both folders. There is deliberately no copy fallback -- a copy is not atomic, and the crash-window guarantees below rely on every move being a rename.

Confinement. A record names files only by their file name. Each name is resolved against the one folder it belongs to and canonicalised; a name that leaves that folder -- a separator, .., or a link pointing elsewhere -- makes the whole record refused with a warning, so a crafted record cannot move or delete an arbitrary file. The transaction's own working folder is derived from a hash of the record's type and key, never read from the record.

Since:
6.3.0
  • Field Details

  • Constructor Details

    • ModuleFileTransactions

      public ModuleFileTransactions(File dataFolder)
      The transactions of the framework whose data folder is given.
      Parameters:
      dataFolder - the framework's data folder
    • ModuleFileTransactions

      ModuleFileTransactions(File modulesFolder, File transactionsFolder, ModuleFileTransactions.FileOps ops, ModuleFileTransactions.CrashPoints crashPoints)
      The seam tests use to inject a failing file operation or a crash at a named point.
      Parameters:
      modulesFolder - the folder modules load from
      transactionsFolder - the folder records and staged JARs live in
      ops - how files are moved and deleted
      crashPoints - called at every named point of a transaction
  • Method Details

    • modulesFolder

      public static File modulesFolder(File dataFolder)
      The folder modules are loaded from: the framework's data folder plus plugins.

      The one place this is computed (#517). The module class loader's URLs, the start-up scan, install, update and uninstall all come from here; the transactions folder comes from the same data folder (transactionsFolder(File)). The data folder follows Bukkit's own plugin-directory option, so this is right wherever the server keeps its plugins -- unlike the JVM's working directory, which a launcher may set anywhere.

      Parameters:
      dataFolder - the framework's data folder
      Returns:
      the modules folder
    • moduleJars

      public static File[] moduleJars(File modulesFolder)
      The module JARs of a modules folder, in file-name order: the entries whose names end in .jar, the test the module loader applies. The start-up scan and the module class loader both read this one listing (#476).

      Sorted because File#listFiles promises no order (on ext4 it is hash order), and the order decides which of two JARs carrying the same class supplies it and which copy of a duplicated module is read first. Sorting by name, compared as plain strings, gives the same answer for the same folder on every file system and in every locale.

      Parameters:
      modulesFolder - the modules folder
      Returns:
      the JARs sorted by file name, or null when the folder cannot be listed
    • sortedByName

      public static File[] sortedByName(File[] files)
      files sorted by file name, compared as plain strings (#476).
      Parameters:
      files - a folder listing, or null
      Returns:
      the same array, sorted, or null
    • transactionsFolder

      public static File transactionsFolder(File dataFolder)
      The folder transaction records and staged JARs live in: <server root>/.ultikits/upm-transactions, beside the credential store. It is outside plugins/, so the module loader never reads anything in it and the panel's file interface, whose default editable roots do not include it, cannot forge a record.
      Parameters:
      dataFolder - the framework's data folder
      Returns:
      the transactions folder
    • serverRoot

      private static File serverRoot(File dataFolder)
      The server root, found the way CredentialStore finds it: the data folder's grandparent (<server root>/plugins/UltiTools). Resolved from the absolute data folder, so a relative one gives the same root; never from the JVM's working directory.
      Parameters:
      dataFolder - the framework's data folder
      Returns:
      the server root
      Throws:
      IllegalStateException - when the data folder has no grandparent
    • codeSourceOf

      public static File codeSourceOf(UltiToolsPlugin module)
      Which file a loaded module was loaded from -- the question the uninstall asks, asked the same way here.
      Parameters:
      module - a loaded module
      Returns:
      its code source, or null when it cannot be determined
    • stageUpdate

      Stages an update of a loaded module: downloads its latest version into the transaction folder and records it, leaving the modules folder untouched. It takes effect at the next start. One update per module waits at a time; a failed apply recorded by an earlier start is reported through ModuleFileTransactions.StageResult.getPreviousFailure() and cleared before staging again.
      Parameters:
      identifyString - the module's identify-string
      loaded - the loaded modules
      codeSource - which JAR a loaded module came from
      catalogue - where the latest version and its download link come from
      downloader - how the new JAR is downloaded
      Returns:
      what happened
    • stageUpdate

      Stages an update of a loaded module, as stageUpdate(String, List, Function, Catalogue, Downloader) does, with the module's main class as the module loader recorded it.

      Once the new JAR's file name is known and before anything is downloaded, the update is refused when another copy of the module would load instead of it at the next start: a JAR in the modules folder whose plugin.yml declares the same main: and whose file name sorts before the new JAR's (maintainer follow-up 19). All module JARs share one class loader whose class path is in file-name order (#476), so such a copy supplies the module's classes and the observation would roll the update back at every attempt. A copy that sorts after the new JAR does not stop it; the start-up duplicate warning names that copy. A copy added after staging is caught by the observation.

      Parameters:
      identifyString - the module's identify-string
      loaded - the loaded modules
      codeSource - which JAR a loaded module came from
      catalogue - where the latest version and its download link come from
      downloader - how the new JAR is downloaded
      mainClassOf - the main class the module loader recorded for a loaded module, or null when it has none; the old JAR's own plugin.yml main: is used then
      Returns:
      what happened
    • stageUpdate

      Stages an update, as stageUpdate(String, List, Function, Catalogue, Downloader, Function) does, reading the loaded modules only under the lock that an uninstall's cancellation also takes (Codex round 16, #561). A list taken before the lock could still name a module an uninstall removed in between, and that uninstall would have found neither a running download nor a record to cancel. Read under the lock, either the module is already gone from the list (refused as not loaded) or the download is marked running before the cancellation can look (and is cancelled).
      Parameters:
      identifyString - the module's identify-string
      loaded - reads the modules loaded now; called once, under the lock
      codeSource - which JAR a loaded module came from
      catalogue - where the latest version and its download link come from
      downloader - how the new JAR is downloaded
      mainClassOf - the main class the module loader recorded for a loaded module, or null
      Returns:
      what happened
    • reserve

      private ModuleFileTransactions.Reservation reserve(ModuleFileTransactions.Record record, File work, String identifyString, List<UltiToolsPlugin> loaded, Function<UltiToolsPlugin,File> codeSource)
      The checks staging makes before it downloads, under LOCK: no download of this module running, no update already staged, no kept old JAR left without its record.
    • locate

      private ModuleFileTransactions.Reservation locate(String key, String identifyString, List<UltiToolsPlugin> loaded, Function<UltiToolsPlugin,File> codeSource, String previousFailure)
      The loaded module to update and the JAR in the modules folder it was loaded from.
    • refuseSharedOrAmbiguous

      private static ModuleFileTransactions.StageResult refuseSharedOrAmbiguous(UltiToolsPlugin module, File oldJar, String key, String identifyString, List<UltiToolsPlugin> loaded, Function<UltiToolsPlugin,File> codeSource, String previousFailure)
      The refusal when the JAR to replace is not the selected module's alone, or null: another loaded module came from the same JAR, or another loaded module in another JAR declares the same identify-string. /upm update names a module only by its identify-string, so in the second case which module was meant cannot be told apart, and updating the first match could replace -- and commit -- the other module's JAR while reporting the requested one staged (Codex, #561). The same-JAR check runs first, so the second only ever sees modules from other JARs.
    • sharedWith

      private static String sharedWith(UltiToolsPlugin module, File oldJar, List<UltiToolsPlugin> loaded, Function<UltiToolsPlugin,File> codeSource)
      The other loaded modules that came from the same JAR, or null when there are none. Two module classes can be packaged in one archive; replacing it would replace them too, so such an update is refused, as the uninstall refuses to delete a JAR another module uses.

      Only the selected instance itself is exempt (reference identity). Every other instance is compared by where it was loaded from, whatever its identify-string: that string is read from the archive's plugin.yml, so all module classes of one JAR report the same one (round-8 review).

    • download

      The part of staging that touches the network and the staged file, run outside the lock; only the record write at its end takes the lock again.
    • refuseBeforeDownload

      private ModuleFileTransactions.StageResult refuseBeforeDownload(String identifyString, String link, String targetName, ModuleFileTransactions.Reservation reservation, Function<UltiToolsPlugin,String> mainClassOf)
      The refusals decided once the new JAR's file name is known, before anything is downloaded or written: no download offered, or another copy of the module that would load instead of the new JAR at the next start (maintainer follow-up 19).
      Returns:
      the refusal, or null to download
    • mainClassOf

      private static String mainClassOf(ModuleFileTransactions.Reservation reservation, Function<UltiToolsPlugin,String> recorded)
      The module's main class: as the module loader recorded it, else as the old JAR's own plugin.yml declares it -- the same entry the loader reads. null when neither says.
    • copiesLoadingFirst

      private List<String> copiesLoadingFirst(File oldJar, String targetName, String mainClass)
      The other JARs in the modules folder that would supply mainClass ahead of a new JAR named targetName: those the start-up would put on the module class path, whose plugin.yml declares it now and whose file name sorts before targetName in the order the module loader reads the folder (#476; loadsAhead(java.io.File, java.lang.String, java.lang.String)). The old JAR is not one: the update moves it away. Judged by what each file declares now, not by the start-up index alone, which goes stale when a copy is removed or replaced. Reads only plugin.yml.
      Returns:
      their file names in file-name order; empty when there is none, when the main class is unknown, or when the folder cannot be listed (the observation still backs the update)
    • loadsAhead

      private static boolean loadsAhead(File jar, String oldPath, String mainClass)
      Whether jar, sorting before the new JAR, would supply mainClass at the next start: it is not the old JAR, it passes the check the start-up applies before putting a JAR on the module class path (SecurityPolicy.isValidModuleJar(java.io.File), as UltiTools#collectModuleJarUrls and PluginManager#loadPluginMainClass do; a JAR failing it is never loaded -- Codex round 17), and its plugin.yml declares the class.
    • verifyStaged

      private static ModuleFileTransactions.StageResult verifyStaged(ModuleFileTransactions.Record record, File work, File staged, String latest, String previousFailure)
      Checks the downloaded JAR -- the size/entry guard, its identity, that it declares the version the catalogue offered, and that its api-version is one this framework installs (ModuleApiLevels; plan 18-40) -- and on success records its version and SHA-256.
      Returns:
      the refusal, with the staging discarded, or null when the JAR is accepted
    • refuseOtherVersion

      private static ModuleFileTransactions.StageResult refuseOtherVersion(File work, String targetName, String stagedVersion, String latest, String previousFailure)
      A download must be the version the catalogue offered: a stale or misconfigured endpoint can serve a valid JAR of the same module declaring another version, which would otherwise be recorded -- and committed -- under the offered version's file name (round-6 review).
      Returns:
      the refusal, with the staging discarded, or null when the versions agree
    • refuseOtherApiLevel

      private static ModuleFileTransactions.StageResult refuseOtherApiLevel(File work, File staged, String previousFailure)
      A download must declare an api-version this framework installs: from ModuleApiLevels.MIN_INSTALLABLE_API_LEVEL to its own level. A catalogue that does not filter by level, or a version recorded wrongly, can otherwise stage a module that cannot load here -- one built before 6.3.0 overrides the now-final unregisterSelf() -- and every start would roll it back (plan 18-40).
      Returns:
      the refusal, with the staging discarded, or null when the level is accepted
    • writeStaged

      private ModuleFileTransactions.StageResult writeStaged(ModuleFileTransactions.Record record, File work, String previousFailure)
      Writes the PENDING record of a staged JAR, under the lock; the staging is discarded when it cannot be.
    • fetch

      private static String fetch(ModuleFileTransactions.Downloader downloader, String link, String targetName, File work, File stagedFolder)
      Downloads the new JAR into a fresh staged folder.
      Returns:
      null when it was downloaded, otherwise the failure, described; the staged folder is then discarded
    • checkExisting

      What an existing record of this module says about staging now, under the lock: null to go ahead (after discarding a FAILED record, whose failure is left in lastDiscardedFailure), or the refusal to return.
    • existingOutcome

      private ModuleFileTransactions.StageResult existingOutcome(ModuleFileTransactions.Record existing, File existingFile)
      What a valid existing record of this module means for staging now: an update already waiting, one in progress, one held for the operator (refused until the operator removes the record), or a FAILED one, discarded -- unless discarding it would touch a file the transaction cannot identify, which holds it for the operator instead.
    • cancelDownloads

      private static void cancelDownloads(ModuleFileTransactions.RemovedModule removed)
      Marks the downloads running now that belong to the uninstalled module -- by its identify-string, a name it was loaded under, or the JAR it was loaded from -- so staging discards them instead of writing their record. Their version is not known yet, so they are not among the versions a cancellation returns; staging's own reply says it was cancelled. Called under LOCK.
    • cancelStagedUpdates

      List<String> cancelStagedUpdates(String moduleName)
      cancelStagedUpdates(RemovedModule) for a module known by one runtime name only.
      Parameters:
      moduleName - the module's runtime name
      Returns:
      the versions whose updates were cancelled
    • cancelStagedUpdates

      public List<String> cancelStagedUpdates(ModuleFileTransactions.RemovedModule removed)
      Cancels every update transaction of a module being uninstalled: its record and its working folder -- the staged JAR and any kept old JAR -- are deleted, so nothing brings the module back at the next start, and a download of its update still running is discarded. The modules folder is not touched; the uninstall owns that.

      A transaction belongs to the module when its identify-string, the runtime name it was staged under, or the JAR it would replace is one the uninstall resolved (see ModuleFileTransactions.RemovedModule). The uninstall resolves those before it touches the modules folder, so the match holds whatever happened to the files afterwards -- deleted, deferred, not deletable, or a folder that could not be listed (round-10 review).

      Parameters:
      removed - the uninstalled module, as the uninstall resolved it
      Returns:
      the versions whose updates were cancelled
    • cancelOne

      private boolean cancelOne(ModuleFileTransactions.Record record, File recordFile)
      Cancels one staged update: its working folder and record are deleted -- unless the record is held for the operator, or its folder holds a file the transaction cannot identify, which is then held for the operator rather than deleted (foreign-file rule). Called under LOCK.
      Returns:
      whether it was cancelled
    • heldForOperator

      public List<String> heldForOperator(ModuleFileTransactions.RemovedModule removed)
      The records of this module's updates held for the operator, which /upm uninstall refuses to go past (foreign-file rule): the module is matched as a cancellation matches it.
      Parameters:
      removed - the module an uninstall would remove
      Returns:
      the record files, empty when there is none (or the folder cannot be listed)
    • updateRecordIn

      private ModuleFileTransactions.Record updateRecordIn(File recordFile)
      The update record in recordFile, or null when it cannot be read or is not one.
    • recordDeferredRemoval

      public void recordDeferredRemoval(String moduleName, List<File> files) throws IOException
      Records module JARs an uninstall could not delete now, so the next start deletes them before any module loads (#518). On Windows the shared module class loader holds every module JAR open for the life of the server, so this is the only point at which such a JAR can go.

      Each file is recorded with its size, modification time and SHA-256; the next start deletes it only if it is still that file, so an install under the same name since -- or a copy put back -- is never deleted. An install through /upm also forgets the record (forgetDeferredRemoval(String)).

      Parameters:
      moduleName - the module being uninstalled, as the operator named it
      files - the JARs still on disk, each directly in the modules folder
      Throws:
      IOException - when the record cannot be written, or a file is not in the modules folder or cannot be read
    • recordDeferredRemovalChecked

      private void recordDeferredRemovalChecked(String moduleName, List<File> files) throws IOException
      recordDeferredRemoval(String, List), before a denied file access is made a checked failure.
      Throws:
      IOException
    • forgetDeferredRemoval

      public void forgetDeferredRemoval(String fileName) throws IOException
      Forgets a recorded deletion of a file name an install is writing again, so the next start does not delete the new install -- even when it is byte-for-byte the JAR that was recorded.
      Parameters:
      fileName - the file name the install wrote into the modules folder
      Throws:
      IOException - when a record cannot be read or rewritten
    • forgetDeferredRemovalChecked

      private void forgetDeferredRemovalChecked(String fileName) throws IOException
      forgetDeferredRemoval(String), before a denied file access is made a checked failure.
      Throws:
      IOException
    • mergeableRemovals

      private List<ModuleFileTransactions.Removal> mergeableRemovals(File recordFile)
      The entries of an earlier removal record of the same module, to merge into a new one; none when it cannot be read or fails the validation start-up applies (problemWith(com.ultikits.ultitools.utils.ModuleFileTransactions.Record, java.io.File): for example a null entry), and it is then replaced, with a warning naming it (Codex round 18, #561).
    • removalRecordIn

      private ModuleFileTransactions.Record removalRecordIn(File recordFile)
      The removal record in recordFile, or null when it holds none: a record of another type, or one that cannot be read or fails the validation start-up applies (problemWith(com.ultikits.ultitools.utils.ModuleFileTransactions.Record, java.io.File): for example a null entry). A record that cannot be used is warned about, naming it, and never stops the others being cleared (round-5 review; Codex round 18, #561).
    • applyBeforeLoad

      public void applyBeforeLoad()
      Carries out every recorded transaction that has to happen before the module class loader is built. Called once per start, before any module JAR is opened. Never throws for a problem with one transaction; every problem becomes a report line and the next record is processed.
    • applyGuarded

      private void applyGuarded(ModuleFileTransactions.Record record)
      Carries out one record. The transaction is the boundary: an unchecked exception from any file operation in it -- a SecurityException, or anything else unexpected -- fails that transaction and never the start (round-4 review; orchestrator decision 2026-09-30).
    • failUnexpectedly

      private void failUnexpectedly(ModuleFileTransactions.Record record, String entryState, RuntimeException error)
      An unchecked exception ended one transaction. An update being applied goes through the same failure path as every other apply failure (failApply(com.ultikits.ultitools.utils.ModuleFileTransactions.Record, java.io.File, java.lang.Throwable)): whatever step already happened is undone, the record is FAILED, and one SEVERE line names the transaction and the exception.

      A record already past its apply (APPLIED, COMMITTING, ROLLING_BACK), a FAILED one, or a removal is not re-marked: its state already says what is left, every step is idempotent, and the next start finishes it, as after a crash at that point. Marking a decided update FAILED instead would put the old JAR back beside a committed new one. It is reported with one SEVERE line.

    • applyRecord

      private void applyRecord(ModuleFileTransactions.Record record) throws ModuleFileTransactions.RecordRefused
      Throws:
      ModuleFileTransactions.RecordRefused
    • observeAfterLoad

      public void observeAfterLoad(List<UltiToolsPlugin> loaded, Function<UltiToolsPlugin,File> codeSource)
      Decides every update applied at this start from what loaded: committed when the module is loaded from the new JAR at the new version, rolled back otherwise.
      Parameters:
      loaded - the modules that loaded at this start; empty when loading failed
      codeSource - which JAR a loaded module came from
    • flushReports

      public void flushReports(Logger logger, Function<String,String> i18n)
      Writes the lines this start collected, translated.
      Parameters:
      logger - where to write them
      i18n - the translation to apply to each line's key
    • pendingReports

      The lines collected and not yet written.
      Returns:
      the lines, in order
    • applyUpdate

      private void applyUpdate(ModuleFileTransactions.Record record) throws ModuleFileTransactions.RecordRefused
      Throws:
      ModuleFileTransactions.RecordRefused
    • stoppedBeforeMoving

      private boolean stoppedBeforeMoving(ModuleFileTransactions.Record record, File work, File old, File target, File backup, File staged) throws ModuleFileTransactions.RecordRefused
      The checks an apply makes before it moves anything; each that fails ends the transaction.
      Returns:
      whether the transaction was ended (failed or abandoned)
      Throws:
      ModuleFileTransactions.RecordRefused
    • resumeWithoutStagedJar

      private void resumeWithoutStagedJar(ModuleFileTransactions.Record record, File target, File staged) throws ModuleFileTransactions.RecordRefused
      The staged JAR is not in its folder: either the new JAR was moved in and the start ended before APPLIED was recorded, or the staged JAR is gone. Its content decides which -- never its name.
      Throws:
      ModuleFileTransactions.RecordRefused
    • failStagedIdentity

      private void failStagedIdentity(ModuleFileTransactions.Record record, File staged, String actual) throws ModuleFileTransactions.RecordRefused
      The staged JAR is not the file recorded at staging (changed, or gone): the apply fails through failApply(com.ultikits.ultitools.utils.ModuleFileTransactions.Record, java.io.File, java.lang.Throwable), so a kept old JAR goes back and the record is FAILED; the line names the staged path and both hashes. Nothing is downloaded again.
      Throws:
      ModuleFileTransactions.RecordRefused
    • abandonedBeforeApply

      private boolean abandonedBeforeApply(ModuleFileTransactions.Record record, File work, File old, File backup)
      Abandons the transaction, before anything is moved, when the old JAR is no longer the one staged against. Only while the old JAR is still in the modules folder: once it is kept aside, the kept copy is the one that was checked.
      • The module's JAR left the modules folder after the update was staged -- an uninstall, whatever name it was given, or a hand removal -- or an uninstall recorded it for a deletion that has not happened yet. Installing the update now would bring back a module the operator removed.
      • A different file now has the old JAR's name -- an install of the current version, a hand-made hotfix. Moving it aside would make the commit delete it; its content is compared with the SHA-256 recorded at staging, so the same bytes put back still count.
      Returns:
      whether the transaction was abandoned
    • present

      private boolean present(File file)
      An existence check of the apply or a rollback, through ModuleFileTransactions.FileOps so a test can make it fail.
    • markApplied

      private void markApplied(ModuleFileTransactions.Record record)
    • failApply

      private void failApply(ModuleFileTransactions.Record record, File file, Throwable error) throws ModuleFileTransactions.RecordRefused
      The apply could not be made. The old JAR goes back if it was moved, the record is kept as FAILED for the next /upm update to report, and one SEVERE line names the file and the error.
      Throws:
      ModuleFileTransactions.RecordRefused
    • failApply

      failApply(Record, File, Throwable) with the line to log, when the old JAR is back in place, given by the caller instead of the generic one.
      Parameters:
      specific - the line to log when nothing is left out of place, or null for the generic one
      Throws:
      ModuleFileTransactions.RecordRefused
    • undoApply

      Undoes whatever part of an apply happened: the new JAR goes back to the staged folder if it is in the modules folder (returnStagedJar(com.ultikits.ultitools.utils.ModuleFileTransactions.Record), by its recorded hash), then the kept old JAR goes back (restoreBackup(com.ultikits.ultitools.utils.ModuleFileTransactions.Record)) -- in that order, and the second only once the first is done, so the modules folder never holds both. The one undo of a failed apply: when it fails (failApply(com.ultikits.ultitools.utils.ModuleFileTransactions.Record, java.io.File, java.lang.Throwable)), at every later start while the record is FAILED (restoreAfterFailure(com.ultikits.ultitools.utils.ModuleFileTransactions.Record)), and when /upm update discards the record (discardFailed(com.ultikits.ultitools.utils.ModuleFileTransactions.Record)). Restoring only the old JAR at those later points put it back beside a new JAR whose return had failed (Codex round 14, #561).
      Returns:
      null when nothing of the apply is left in the modules folder and the old JAR is in place (or there was none), otherwise what failed
      Throws:
      ModuleFileTransactions.RecordRefused
    • may

      private static ModuleFileTransactions.Expected may(File file, String... hashes)
      file may be absent, or hold one of hashes; with none given it must be absent.
    • must

      private static ModuleFileTransactions.Expected must(File file, String hash)
      file must be there and hold hash.
    • requireRecorded

      The foreign-file rule, at every destructive step: the transaction only ever moves, replaces or deletes a file whose SHA-256 matches the record -- the staged new JAR or the kept old JAR -- and only into a place that is free. Each file the step touches, source and target, is checked here before the step does anything. When one is not what the record says -- another actor replaced the new JAR, dropped a file where the old one is kept, and so on -- nothing is done to any file: the record is kept as NEEDS_OPERATOR, which later starts do not act on and which /upm update and /upm uninstall of the module refuse to go past, and one SEVERE line names each unexpected file with its expected and actual hash and says how to resolve it (Codex round 19, #561; the maintainer's precedent for ambiguous identity: write nothing, warn). Two expectations for the same path (the old and new JAR sharing a name) are merged.
      Throws:
      ModuleFileTransactions.NeedsOperator - when a file is not the recorded one; the record is already held
    • unexpected

      private static String unexpected(ModuleFileTransactions.Expected expected)
      What is wrong with one expected file, or null when it is as recorded.
    • holdForOperator

      private void holdForOperator(ModuleFileTransactions.Record record, String detail) throws ModuleFileTransactions.NeedsOperator
      Keeps the record as NEEDS_OPERATOR, says so once, and ends the step.
      Throws:
      ModuleFileTransactions.NeedsOperator
    • returnStagedJar

      Moves the new JAR back to the staged folder when it was already moved into the modules folder -- recognised by its recorded hash, so nothing else is ever moved. Part of undoing a failed apply; the old JAR goes back after it.
      Returns:
      null when the new JAR is not in the modules folder (or is back), otherwise what failed
      Throws:
      ModuleFileTransactions.RecordRefused
    • isAcrossFileSystems

      private static boolean isAcrossFileSystems(Throwable error)
      Whether a move failed because its two ends are on different file systems.
    • restoreAfterFailure

      private void restoreAfterFailure(ModuleFileTransactions.Record record) throws ModuleFileTransactions.RecordRefused
      A failed apply found at a later start: make sure it is undone -- the new JAR out of the modules folder and the old JAR back (undoApply(com.ultikits.ultitools.utils.ModuleFileTransactions.Record)) -- and say so if it cannot be.
      Throws:
      ModuleFileTransactions.RecordRefused
    • restoreBackup

      Moves the kept old JAR back into the modules folder when it is not there.
      Returns:
      null when the old JAR is in place (or there was none), otherwise what failed
      Throws:
      ModuleFileTransactions.RecordRefused
    • discardFailed

      private String discardFailed(ModuleFileTransactions.Record record)
      Discards a FAILED record before staging again: the apply is undone (undoApply(com.ultikits.ultitools.utils.ModuleFileTransactions.Record): the new JAR out of the modules folder, the old JAR back if it is still aside), then the working folder and the record are deleted.
      Returns:
      null on success, otherwise why the old JAR could not be put back
    • isLoadedFrom

      private boolean isLoadedFrom(ModuleFileTransactions.Record record, File target, List<UltiToolsPlugin> loaded, Function<UltiToolsPlugin,File> codeSource)
    • finishCommit

      private void finishCommit(ModuleFileTransactions.Record record) throws ModuleFileTransactions.RecordRefused
      Throws:
      ModuleFileTransactions.RecordRefused
    • finishRollback

      Removes the new JAR -- recognised by content, so nothing else is ever deleted -- and puts the old JAR back. A step that fails leaves the record at ROLLING_BACK for the next start, which finishes it before the modules load.
      Throws:
      ModuleFileTransactions.RecordRefused
    • applyRemoval

      private void applyRemoval(ModuleFileTransactions.Record record) throws ModuleFileTransactions.RecordRefused
      Deletes the JARs an uninstall recorded, before any module loads: each only if it is confirmed to still be the file that was recorded. One that still cannot be deleted stays recorded for the next start and is reported; one confirmed to be a different file -- replaced since -- is left alone, reported, and its entry dropped. One whose content cannot be read to confirm it is not deleted either, and keeps its entry exactly as recorded, reported SEVERE with the read error: whether it is still the recorded file is unknown, and dropping the entry would let a module the operator uninstalled load at every later start.
      Throws:
      ModuleFileTransactions.RecordRefused
    • recordedIdentity

      private static ModuleFileTransactions.RecordedIdentity recordedIdentity(ModuleFileTransactions.Removal removal, File file)
      Whether a file is still the one an uninstall recorded: same size, same modification time, same content. A copy put back after the uninstall has a new timestamp even when its bytes are the same. Three outcomes, not two: a file whose content cannot be read is neither confirmed the same nor confirmed different.
    • readSha256

      private static String readSha256(File file) throws IOException
      A file's SHA-256, failing with the reason it cannot be read. sha256Of(File) returns null instead, which its other callers treat as a refusal or a held state; a recorded removal needs the reason, and must not read "cannot be read" as "a different file".
      Throws:
      IOException
    • isStagedJar

      private static boolean isStagedJar(File file, ModuleFileTransactions.Record record)
      Whether file is the JAR this transaction staged, by content.
    • refuseLinkedFolders

      private void refuseLinkedFolders(ModuleFileTransactions.Record record) throws ModuleFileTransactions.RecordRefused
      The working folder and its two subfolders are this class's own; one that is a symbolic link would take every name confined to it somewhere else, so the record is refused. (The transactions folder itself, and the modules folder, may be links an operator set up.)
      Throws:
      ModuleFileTransactions.RecordRefused
    • stagedOf

      Throws:
      ModuleFileTransactions.RecordRefused
    • backupOf

      Throws:
      ModuleFileTransactions.RecordRefused
    • recordFiles

      private List<File> recordFiles() throws IOException
      The record files, sorted by name.
      Returns:
      the records; empty when the transactions folder does not exist
      Throws:
      IOException - when the folder exists -- or cannot be shown not to -- but cannot be listed; a failed listing is never read as "no records"
    • readRecordOrReport

      private ModuleFileTransactions.Record readRecordOrReport(File recordFile)
    • problemWith

      private String problemWith(ModuleFileTransactions.Record record, File recordFile)
      Why a record read from a file cannot be carried out, or null when it can: the one validation start-up and staging share.
    • missingField

      private static String missingField(ModuleFileTransactions.Record record)
      The first field a record of its type needs and lacks, or null when it is complete.
    • readRecord

      private static ModuleFileTransactions.Record readRecord(File recordFile) throws IOException
      Reads one record.
      Returns:
      the record, or null for an empty file
      Throws:
      IOException - when the file cannot be read, or cannot be parsed as a record -- a parse failure (Gson's unchecked JsonParseException, or any other unchecked exception while parsing) becomes an IOException naming the file, so every reader handles a malformed record through its checked path
    • writeRecord

      private void writeRecord(ModuleFileTransactions.Record record) throws IOException
      Writes a record atomically: a temporary file in the same folder, then an atomic move over the record, so a crash leaves either the previous record or the new one, never a torn file.
      Throws:
      IOException
    • deniedAsIo

      private static void deniedAsIo(File file, ModuleFileTransactions.FileWork work) throws IOException
      Runs work, turning a denied file access -- a SecurityException from any of its Files calls -- into an IOException naming file, so a caller that handles the checked failure handles this one too.
      Throws:
      IOException
    • persist

      private boolean persist(ModuleFileTransactions.Record record)
      writeRecord(Record) at start-up, where a failure is a report line, not an abort.
      Returns:
      whether the record is on disk
    • deleteTemporaryRecords

      private void deleteTemporaryRecords()
      A crash while writing a record leaves a temporary file; it is never a record.
    • sweepOrphanWorkFolders

      private void sweepOrphanWorkFolders(List<String> ids)
      A working folder no record refers to was left by a crash during staging, before its record was written: it can hold only a partial download, which is deleted. One that holds a kept old JAR is never deleted without its record; it is reported instead.
    • recordFileOf

      private File recordFileOf(ModuleFileTransactions.Record record)
    • workFolder

      private File workFolder(ModuleFileTransactions.Record record)
    • idOf

      private static String idOf(ModuleFileTransactions.Record record)
      The name a record's files take: its type and a hash of its key, never text from the record.
    • stripSuffix

      private static String stripSuffix(String name)
    • confined

      static File confined(File folder, String name) throws ModuleFileTransactions.RecordRefused
      Resolves a file name a record carries against the one folder it belongs to, refusing any name that would leave it.
      Parameters:
      folder - the folder the name belongs to
      name - the name from the record
      Returns:
      the file
      Throws:
      ModuleFileTransactions.RecordRefused - when the name is not a plain name inside folder
    • mayHoldFiles

      private static boolean mayHoldFiles(File folder)
      Whether a folder holds anything, or might: true when it lists an entry, and also when it exists (or cannot be shown not to) but cannot be listed -- a failed listing is never read as an empty folder.
    • namesIn

      private static String[] namesIn(File folder)
      The names in a folder, or null when it does not exist or cannot be listed.
    • isJarName

      private static boolean isJarName(String name)
      Whether a file name is one the module loader would load: it ends in .jar.
    • isDirectChild

      private static boolean isDirectChild(File folder, File file)
      Whether file, canonicalised, sits directly in folder, canonicalised.
    • exists

      private static boolean exists(File file)
    • canonicalPath

      private static String canonicalPath(File file)
    • deleteQuietly

      private static boolean deleteQuietly(File file)
    • discardStaging

      private static void discardStaging(File work)
      Deletes what staging put in a working folder, and the folder itself only if nothing else is in it -- a kept old JAR is never deleted by staging.
    • deleteTree

      private static boolean deleteTree(File folder)
      Deletes a folder this class derived itself (never one named by a record), bottom-up.
      Returns:
      whether it is gone
    • walkAndDelete

      private static void walkAndDelete(File folder) throws IOException
      Deletes folder and everything under it, deepest first.
      Throws:
      IOException
    • declaredVersion

      private static String declaredVersion(File jar, String key)
      The version a staged JAR's plugin.yml declares, when it declares the expected identify-string and a version -- identity and version only, read the same way UltiToolsPlugin reads them, and nothing about whether its classes would load.
    • normalize

      private static String normalize(String identifyString)
      The identify-string normalisation every install, update and lookup shares (one implementation).
    • describe

      private static String describe(Throwable error)
    • sha256

      private static String sha256(String text)
    • sha256Of

      static String sha256Of(File file)
      A file's SHA-256 through the framework's one hashing helper, or null when it cannot be read.
    • report

      private void report(Level level, String key, Object... args)