Class FileOperationManager

java.lang.Object
com.ultikits.ultitools.manager.FileOperationManager

@Internal public class FileOperationManager extends Object
File operation manager. Handles file operation requests received over the WebSocket connection.

The remote file API is confined to two layers (D-14): an unconditional, non-configurable deny layer for credential-bearing files and the framework's own action-log directory, and an operator-configured editable-root set (D-15) that answers "where" once the deny layer has already answered "not this, ever". See isPathAllowed(String) for the ordering rationale.

  • Field Details

    • webSocketClient

      private UltiPanelWebSocketClient webSocketClient
    • serverRoot

      private final File serverRoot
    • gson

      private final com.google.gson.Gson gson
    • BLOCKED_FILES

      private static final Set<String> BLOCKED_FILES
    • BLOCKED_EXTENSIONS

      private static final Set<String> BLOCKED_EXTENSIONS
    • DENY_GLOB_MATCHERS

      private static final List<PathMatcher> DENY_GLOB_MATCHERS
      D-16's basename glob patterns for credential-bearing files. Patterns, not an enumeration: a credential file added later is caught without anyone remembering to edit a Set. An exact-name set is only ever as complete as the last person who thought about it; a pattern does not depend on that. Compiled once via FileSystems.getDefault().getPathMatcher(String) rather than hand-rolled wildcard matching.
    • DENY_EXACT_BASENAMES

      private static final Set<String> DENY_EXACT_BASENAMES
      D-16's exact-basename credential set — matched case-insensitively, see basenameOf(java.lang.String).

      Plan 08-15 checkpoint decision (outside-roots-keep-filename): the credential file moved outside every default editable root (D-15) to <server root>/.ultikits/credentials.json — the remote file surface cannot reach it by path at all any more, so this entry stops being the *only* protection. It is kept anyway, referencing CredentialStore.CREDENTIAL_FILE_NAME rather than a duplicated literal, as defence in depth: it costs nothing, and it still protects an operator who widens the editable roots to include the new location's parent. "data.json" stays in this set too — not dead weight, but the pre-6.3.0 name, still live at its old location for any install that has not yet restarted onto a migrated CredentialStore.

    • SECURITY_DIR_RELATIVE

      private static final Path SECURITY_DIR_RELATIVE
      The relative location of the remote action log's own directory (D-23/D-31), expressed as a lexical Path so containment is checked by name element rather than string prefix — a sibling directory named security-backup must not be caught by it.
    • ACTION_FILE_OPERATION_PREFIX

      private static final String ACTION_FILE_OPERATION_PREFIX
      The RemoteActionLog.Entry.getAction() literal prefix every entry this class records carries, extended with the operation itself (e.g. file_operation:read) — D-22.
      See Also:
    • EDITABLE_ROOTS_CONFIG_KEY

      private static final String EDITABLE_ROOTS_CONFIG_KEY
      D-15's config key for the operator-configured editable-root set.
      See Also:
    • DEFAULT_EDITABLE_ROOTS

      private static final List<String> DEFAULT_EDITABLE_ROOTS
      D-15's shipped default: plugin configs and historical logs, nothing else.
    • editableRoots

      private List<File> editableRoots
      The operator-configured editable-root set (D-15), resolved to Files rooted at serverRoot. Not final — reloadRootsFromConfig() may reassign it without reconstructing the manager.
  • Constructor Details

    • FileOperationManager

      public FileOperationManager()
  • Method Details

    • buildDenyGlobMatchers

      private static List<PathMatcher> buildDenyGlobMatchers()
    • setWebSocketClient

      public void setWebSocketClient(UltiPanelWebSocketClient client)
      Sets the WebSocket client.
      Parameters:
      client - the WebSocket client
    • getEditableRoots

      public List<File> getEditableRoots()
      The operator-configured editable-root set this manager currently enforces.
      Returns:
      an unmodifiable view of the resolved root directories
    • reloadRootsFromConfig

      public final void reloadRootsFromConfig()
      (Re)loads editableRoots from ultipanel.files.editable-roots, mirroring ErrorReportCollector.loadConfiguration()'s try/catch shape. Public so a future config reload does not require reconstructing the manager.

      An absent key resolves to the shipped default (plugins, logs); an explicit empty list is honoured as the operator deliberately granting nothing — collapsing those two cases would make "grant nothing" inexpressible.

    • logWarning

      private static void logWarning(String message)
      Best-effort warning logging that never throws — UltiTools.getInstance() may be null in a test harness with no live plugin.
    • isPathAllowed

      public AccessDecision isPathAllowed(String path)
      Checks whether a file path may be reached through the remote file API, and — unlike the boolean this returned before 6.3.0 — says why not when it is refused (D-17).

      Two ordered layers. The unconditional deny layer (deniedUnconditionally(String)) runs first; the editable-root set runs second. Order is load-bearing: a credential file that happens to sit inside a granted editable root (e.g. plugins/UltiTools/data.json with plugins granted) must report the non-configurable cause — checking the root set first would report the wrong cause for exactly that file.

      Parameters:
      path - the requested path, forward- or back-slash separated, with or without a leading slash
      Returns:
      an AccessDecision carrying the refusal cause when denied
    • deniedUnconditionally

      public AccessDecision deniedUnconditionally(String normalizedPath)
      The unconditional deny layer (D-16). Reads no config key and consults no Capability — nothing here can be configured open.

      Why this layer cannot be configured open: authorizing the panel to manage files is not authorizing it to hand out the key to the panel itself (D-01). A stolen refresh token is persistent access an operator cannot revoke by changing a password, unlike a compromised editable-root grant, which a config edit closes immediately.

      The credential file holds the live UltiCloud access and refresh tokens (CloudAuthManager), which is why D-19 names it explicitly rather than leaving it to the glob patterns. As of plan 08-15 it lives at <server root>/.ultikits/credentials.json — outside every default editable root (D-15), so the editable-root layer below can no longer reach it at all, regardless of configuration. This layer's basename entries are kept anyway as defence in depth for both names: "credentials.json" (current, referencing CredentialStore.CREDENTIAL_FILE_NAME) and "data.json" (the pre-6.3.0 name, still live at plugins/UltiTools/data.json for any install that has not yet restarted onto a migrated CredentialStore). Matched by exact basename, not by directory prefix, so a credential file with either name anywhere else is caught too.

      The plugins/UltiTools/security/ rule is what lets the remote action log (RemoteActionLog) live inside getDataFolder() — Bukkit convention intact — instead of being physically relocated (D-23/D-31). Accepted cost: the log and the credential files now share one trust anchor; a defect in this layer loses both.

      Folds in the pre-6.3.0 BLOCKED_FILES/BLOCKED_EXTENSIONS sets so every unconditional refusal produces the same non-configurable decision shape.

      Parameters:
      normalizedPath - the path already stripped of a leading slash, forward-slash separated
      Returns:
      a denied, non-configurable AccessDecision if this layer refuses the path; null if this layer has no objection and the caller must still run the editable-root check
    • reasonCodeFor

      private static String reasonCodeFor(AccessDecision decision)
      D-18's two schema reason codes for a refused list entry. A configurable AccessDecision (outside every editable root — an operator can flip it) maps to OUTSIDE_ROOTS; a non-configurable one (the unconditional credential deny layer) maps to PROTECTED_CREDENTIAL. These two strings are the whole of the wire-protocol contract this plan adds to file_operation_result list entries — see the class javadoc's D-14 note and Plan 06-04's artifacts_produced.
      Parameters:
      decision - a denied AccessDecision (never AccessDecision.allowed())
      Returns:
      "OUTSIDE_ROOTS" or "PROTECTED_CREDENTIAL"
    • matchesDenyGlob

      private static boolean matchesDenyGlob(String fileNameLower)
    • isUnderSecurityDirectory

      private static boolean isUnderSecurityDirectory(String normalizedPath)
    • basenameOf

      private static String basenameOf(String normalizedPath)
    • normalize

      private static String normalize(String path)
    • isWithinEditableRoots

      private boolean isWithinEditableRoots(String normalizedPath)
      Lexical containment check against editableRoots — no filesystem access, safe to call before any real file exists. getSecureFile(String) performs the real, symlink-aware containment check once a file handle is actually needed.
    • handleFileOperation

      public void handleFileOperation(com.google.gson.JsonObject operationData)
      Handles a file operation request.

      Dispatches to one of the four operation handlers inside a CompletableFuture.runAsync(java.lang.Runnable) hop, then records exactly one RemoteActionLog entry per operation (D-22) — at this single point, not inside each of the four handlers, so the log's line count means "file decisions" and not "handler entries". operation/path/actor are captured into locals before the async hop and never re-read from operationData afterward, following the immutable-string capture discipline TriggerContext establishes — the mutable JsonObject itself is not safe to re-read once handed to another thread.

    • recordFileDecision

      private void recordFileDecision(String operation, String path, String actor, AccessDecision decision)
      D-22's single action-log record point for the whole remote file API — called once per operation from handleFileOperation(com.google.gson.JsonObject)'s async task, after the dispatched handler has run, so a refusal decided deep inside a handler (Task 3's missing-recursive-flag case) is the decision this records, not an earlier, now-superseded verdict.

      A null decision — the unsupported-operation branch — records nothing: an operation this manager does not recognise is not a file decision. A null RemoteActionLog (no live plugin, or a test harness) is also a silent no-op.

      Parameters:
      operation - the operation name as received, used verbatim to resolve the capability and build the action string
      path - the requested path as received — the action-log target
      actor - the inbound executor field verbatim, or the literal "panel"
      decision - the policy decision the dispatched handler reached, or null for an unsupported operation
    • handleReadOperation

      private AccessDecision handleReadOperation(String path, com.google.gson.JsonObject operationData, String operationId)
      Handles a file read operation.
      Returns:
      the AccessDecision that gated this read — the value recordFileDecision(java.lang.String, java.lang.String, java.lang.String, com.ultikits.ultitools.entities.AccessDecision) records, regardless of whether the read then succeeded or failed for an unrelated reason (file not found, not a regular file, ...)
    • handleWriteOperation

      private AccessDecision handleWriteOperation(String path, com.google.gson.JsonObject operationData, String operationId)
      Handles a file write operation.
      Returns:
      the AccessDecision that gated this write
    • handleListOperation

      private AccessDecision handleListOperation(String path, com.google.gson.JsonObject operationData, String operationId)
      Handles a directory listing operation.
      Returns:
      the AccessDecision that gated listing the directory itself — one decision for the whole operation, not one per returned row (see recordFileDecision(java.lang.String, java.lang.String, java.lang.String, com.ultikits.ultitools.entities.AccessDecision))
    • buildListEntry

      private com.google.gson.JsonObject buildListEntry(String basePath, File file)
      Builds a single directory-listing row, applying D-18's mark-not-filter policy to a refused child entry. Extracted from handleListOperation(String, JsonObject, String) to keep that method's cyclomatic/NPath complexity within the project's PMD threshold — this is the per-entry decision-and-marking step, the cohesive seam in that loop.
      Parameters:
      basePath - the directory path being listed (the parent of file)
      file - one child entry returned by dir.listFiles()
      Returns:
      the JSON row for this entry, either full metadata or a withheld-metadata marker
    • handleDeleteOperation

      private AccessDecision handleDeleteOperation(String path, com.google.gson.JsonObject operationData, String operationId)
      Handles a file delete operation.
      Returns:
      the AccessDecision that gated this delete
    • requireRecursiveFlag

      private static AccessDecision requireRecursiveFlag(com.google.gson.JsonObject operationData)
      D-20's shape guard: deleting a directory requires the request's data to carry a recursive member that is a JSON boolean primitive with value true.

      A present-but-non-boolean recursive (e.g. the string "false") is treated as absent rather than coerced — Gson's JsonPrimitive.getAsBoolean() is lenient on a string primitive, and a request carrying the string "false" must not delete a world tree. The missing field is never defaulted to true for backward compatibility: D-20 accepts the break because file-delete defaults off, so a caller already reaching this code had the capability turned on deliberately, and fails here with a clear reason rather than silently.

      Parameters:
      operationData - the inbound request's data object
      Returns:
      AccessDecision.allowed() when recursive is present and true; otherwise a denied, non-configurable decision naming the missing/invalid field — no configuration makes an unflagged recursive delete valid
    • deniedAsAncestorOfProtectedPath

      private AccessDecision deniedAsAncestorOfProtectedPath(String normalizedPath, File dir)
      Refuses a recursive directory delete whose target is an ancestor of any path deniedUnconditionally(java.lang.String) would refuse on its own. Runs after requireRecursiveFlag(com.google.gson.JsonObject) and strictly before deleteDirectory(java.io.File); nothing is deleted when this refuses. deleteDirectory(java.io.File) itself is unchanged — the walk it performs stays a naked recursive delete, and this method is the gate that decides whether it may run at all, not a per-descendant check woven into it.

      Why this walks the real directory instead of comparing paths lexically. One of deniedUnconditionally(java.lang.String)'s rules — the security-directory check — is answerable from the path alone: SECURITY_DIR_RELATIVE is a single fixed location, so "is dir an ancestor of it" is just Path.startsWith(Path) run in the opposite direction from the existing direct check. But the credential rules (DENY_EXACT_BASENAMES, the glob patterns, BLOCKED_FILES, BLOCKED_EXTENSIONS) match on a basename, wherever it occurs in the tree — "is dir an ancestor of some path a glob would deny" cannot be answered from dir's own path at all; it depends on what files actually exist underneath it. A recursive delete already requires the real directory handle, and this gate runs before any deletion, so walking the real subtree is exactly as knowable at gate time as the lexical check would be for the security-directory rule alone — and it is the one approach that covers every rule deniedUnconditionally(java.lang.String) enforces with a single method, rather than hand-listing the two paths this defect happened to be filed against. A credential pattern or protected directory added later is covered automatically; nobody has to remember to extend a second list.

      What this does not cover. A symlink inside the tree whose target resolves to a protected location the link's own relative path would not itself trigger — File#listFiles() enumerates the directory's own entries, and following such a link across a policy boundary is the same lexical-vs-real-path asymmetry already tracked and deliberately not fixed this phase; this walk does not attempt to close it.

      Parameters:
      normalizedPath - the normalized path of dir (forward-slash separated, no leading slash), used to build each descendant's relative path
      dir - the real directory handleDeleteOperation(java.lang.String, com.google.gson.JsonObject, java.lang.String) is about to recursively delete
      Returns:
      AccessDecision.allowed() if no descendant (at any depth) is unconditionally denied; otherwise a denied, non-configurable decision naming the first protected descendant found — non-configurable because every rule deniedUnconditionally(java.lang.String) enforces is itself non-configurable
    • deleteDirectory

      private boolean deleteDirectory(File dir)
      Recursively deletes a directory.
    • getSecureFile

      private File getSecureFile(String path) throws SecurityException
      Resolves a requested path to a real File handle, refusing anything that does not canonically resolve inside one of the configured editableRoots (D-21).

      The pre-6.3.0 defect here was never the canonicalization order — getCanonicalPath() already ran before the check, and already resolved symlinks. The live bug was String.startsWith with no separator boundary: "/srv/mc-evil/x" literally starts with the characters "/srv/mc". This rewrite compares by Path name element via Path.startsWith(Path) instead, mirroring the Zip-Slip guard already correct for the opposite direction in UltiToolsPlugin.saveResources() — it appends File.separator to its own canonical base before comparing — the same defect class, already fixed once in this repository.

      A requested path that does not exist yet (a create/write target) resolves its nearest existing ancestor via resolveNearestReal(Path) rather than throwing, so a legitimate create into an existing directory is not refused as a traversal attempt — but an ancestor that itself resolves outside every root is still refused (T-06-21).

      Parameters:
      path - the requested path, forward- or back-slash separated, with or without a leading slash
      Returns:
      a File handle for the (not-yet-canonicalized) requested location — callers operate on this handle, not on the resolved candidate used only for the check
      Throws:
      SecurityException - if the path is empty, unresolvable, or resolves outside every configured editable root
    • resolveNearestReal

      private static Path resolveNearestReal(Path candidatePath) throws IOException
      Resolves candidatePath to its real (symlink-resolved) form. If the path itself does not exist yet, walks up to the nearest existing ancestor, resolves that ancestor for real, and re-appends the unresolved trailing segments — Claude's Discretion hazard #1 in 06-RESEARCH.md: a not-yet-existing create/write target must not be refused outright.
      Throws:
      IOException - if no ancestor of candidatePath exists (should not happen once serverRoot itself exists) or the real path cannot be resolved
    • isContainedInAnyRealRoot

      private boolean isContainedInAnyRealRoot(Path resolvedCandidate)
      Checks resolvedCandidate against every configured editable root's real path.

      A configured root that does not itself resolve (T-06-23: an operator typo) is skipped with a loud warning rather than silently narrowing the set to nothing — the remaining configured roots still apply.

    • sendFileOperationResult

      private void sendFileOperationResult(String operationId, String operation, String path, boolean success, String message, com.google.gson.JsonObject data)
      Sends the file operation result.