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.

Those two layers are the lexical first step only. Every operation then takes its final decision and its I/O operand from one resolver, resolveTarget(String, ResolveMode), which resolves the request against the real filesystem and checks real containment, so the object that is authorized is the object that read, write, list and delete actually reach.

Protected locations are judged by real path (deniedAtRealPath(Path, List)), never by the name of the editable root a request happened to match, so neither an alias root nor the order of the roots moves a protected object out from under the deny layer.

  • Field Details

    • serverRoot

      private final File serverRoot
    • BLOCKED_FILES

      private static final Set<String> BLOCKED_FILES
      Protected server-core files, matched by exact basename. Kept equal to the panel's protected list (PROTECTED_SERVER_FILES.names in UltiCloud's packages/contracts/src/rest/server-files.ts): the panel refuses these first, and this set refuses them again as defence in depth. Add an entry on both sides together.
    • BLOCKED_EXTENSIONS

      private static final Set<String> BLOCKED_EXTENSIONS
      Protected extensions, kept equal to the panel's PROTECTED_SERVER_FILES.extensions (see BLOCKED_FILES).
    • 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.
    • STATE_DIR_RELATIVE

      private static final Path STATE_DIR_RELATIVE
      The relative location of the framework's own state directory, <server root>/.ultikits (plan 22-07, T-22-28). It holds credentials.json -- the linked server's UltiCloud credential -- and cloud-outbox.json, the reliable-delivery state file, together with their write-time siblings (cloud-outbox.json.<ULID>.tmp, cloud-outbox.json.unreadable).

      A DENY_EXACT_BASENAMES entry alone would not be enough: the credential basename is protected anywhere, but the state file's temporary and moved-aside siblings carry other names, and the directory itself would still be listable and deletable. This rule therefore protects the directory, by name element exactly like SECURITY_DIR_RELATIVE -- a sibling directory named .ultikits-backup is not caught by it.

    • ACTION_LOG_DIR_REFUSAL

      private static final String ACTION_LOG_DIR_REFUSAL
      The refusal for a path inside the remote action log's own directory (D-23/D-31).
      See Also:
    • STATE_DIR_REFUSAL

      private static final String STATE_DIR_REFUSAL
      The refusal for a path inside the framework's own state directory (T-22-28).
      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()
    • 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
    • deniedByName

      private static AccessDecision deniedByName(String fileName)
      The name rules of the unconditional deny layer: protected server files, protected extensions, credential basenames and credential glob patterns. Shared by the lexical check on the request (deniedUnconditionally(String)) and the real-path check on the object a request reaches (deniedAtRealPath(Path, List)), so both apply one rule set.
      Parameters:
      fileName - the entry's own name (the last path element)
      Returns:
      a denied, non-configurable decision, or null if no name rule matches
    • deniedAsActionLogDirectory

      private static AccessDecision deniedAsActionLogDirectory()
    • deniedAtRealPath

      private static AccessDecision deniedAtRealPath(Path realEntry, List<FileOperationManager.ProtectedLocation> protectedLocations)
      The unconditional deny layer judged on real paths: the name rules of deniedByName(java.lang.String) on the entry's own real name, and containment in a protected location's real path (protectedRealLocations()). Nothing here depends on which editable root matched the request or on the order of the configured roots, so an alias root (for example plugins/shadow linking to plugins/UltiTools) cannot move a protected object out from under the rule by renaming the path it is reached through.
      Parameters:
      realEntry - the real location of the entry: its real path when it is followed, or its parent's real path plus its own name when it is not (a delete's terminal entry, a child listed from a real directory)
      protectedLocations - the result of protectedRealLocations()
      Returns:
      a denied, non-configurable decision, or null if no rule refuses the entry
    • protectedRealLocations

      private List<FileOperationManager.ProtectedLocation> protectedRealLocations()
      The real locations of the protected directory plugins/UltiTools/security under serverRoot: the location of the directory entry itself (its parent's real path plus security) and, when that entry exists and resolves elsewhere, its real target too. A part of the path that does not exist yet is appended by name to the deepest existing ancestor's real path, so the rule holds before the action log has been created.
      Returns:
      one or two absolute real paths; empty only if the server root itself cannot be resolved, in which case no request can be resolved either
    • addProtectedDirectory

      private void addProtectedDirectory(List<FileOperationManager.ProtectedLocation> locations, Path relative, String refusal)
      Adds the real locations of one protected directory (see protectedRealLocations()).
      Parameters:
      locations - the list to append to
      relative - the directory's location relative to serverRoot
      refusal - the reason a path inside it is refused
    • realPathOrNull

      private static Path realPathOrNull(Path path)
      The real path of path, or null if it cannot be determined.
    • realLocationOf

      private static Path realLocationOf(Path path)
      path's real path when it exists; otherwise the real path of its deepest existing ancestor followed by the remaining names. null only if no ancestor resolves.
    • 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)
    • isUnderStateDirectory

      private static boolean isUnderStateDirectory(String normalizedPath)
      Lexical containment in STATE_DIR_RELATIVE, by name element -- a sibling .ultikits-backup is not inside it.
      Parameters:
      normalizedPath - the path already stripped of a leading slash, forward-slash separated
      Returns:
      whether the path names the state directory or something under it
    • stripTrailingDotsAndSpaces

      private static String stripTrailingDotsAndSpaces(String name)
    • 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. This is only the resolver's first step; resolveTarget(String, ResolveMode) performs the real, symlink-aware resolution and containment check that decides the object every operation actually reaches.
    • resolveTarget

      The one resolver every remote file operation takes its decision and its I/O operand from (D-21, issue #672). The path that is authorized is the path that is used: no handler builds a File from the raw request string.

      Steps, each of which can produce the final decision:

      1. Lexical layer — isPathAllowed(String) exactly as before (unconditional deny layer first, then the lexical editable-root check).
      2. Resolution without lexical collapse — the request's components are walked from the real server root; every existing prefix is resolved by the filesystem (Path.toRealPath(java.nio.file.LinkOption...)), so a .. after a symbolic link means what the operating system means, not what Path.normalize() would guess. At the first component that does not exist, following stops: that component must not exist even as a link, and it and every later component must be an ordinary name (not . or ..). A dangling or looping link, a component whose existence cannot be determined, or a resolution failure is an unsafe shape and is refused non-configurably — an operator can widen the roots but cannot make an ambiguous path safe.
      3. Real containment — the operand must lie inside the real path of a configured editable root, compared by Path.startsWith(Path) name element (a sibling <root>-evil is not inside <root>); otherwise the refusal is configurable through ultipanel.files.editable-roots. A configured root that does not resolve is skipped with a warning (T-06-23).
      4. Actual-target deny — the unconditional deny layer runs again on the operand's real path (deniedAtRealPath(Path, List)), so a credential file or the action-log directory reached through an in-root alias (a link, a linked parent directory, or an editable root that is itself an alias of another root's subdirectory) is refused with the same non-configurable decision a direct request for it gets (D-08), whichever root matched and in whatever order the roots are configured.
      In FileOperationManager.ResolveMode.FOLLOW the operand is the resolved real object, or the real existing prefix plus the ordinary missing names of a new write target. In FileOperationManager.ResolveMode.ENTRY (delete) the parent is resolved as in follow mode, the terminal name must be an ordinary name, and the operand is the parent's real path plus that name, not followed.
      Parameters:
      path - the requested path, forward- or back-slash separated, with or without a leading slash
      mode - whether the terminal component is followed
      Returns:
      the final decision, and when allowed the operand and its mapped path
    • componentsOf

      private static List<String> componentsOf(String normalized) throws FileOperationManager.UnsafePathException
      Splits a normalized request into its name components. java.nio parsing collapses doubled and trailing separators, so root//sub/ and root/sub name the same object; . and .. are kept as components for the walk to resolve.
      Throws:
      FileOperationManager.UnsafePathException
    • resolveFollowing

      private Path resolveFollowing(List<String> names) throws FileOperationManager.UnsafePathException
      Follow-mode walk (step 2): every existing component is resolved by the filesystem; the first missing component starts the missing suffix. Below a real non-directory nothing can exist, so the rest is treated as missing too and a read or list of it fails ordinarily.
      Throws:
      FileOperationManager.UnsafePathException
    • resolveEntry

      private Path resolveEntry(List<String> names) throws FileOperationManager.UnsafePathException
      Entry-mode resolution (delete): the parent is followed, the terminal entry is not. The terminal name must be an ordinary name, so sub/.., . or an empty path never names some other directory to remove.
      Throws:
      FileOperationManager.UnsafePathException
    • appendMissingSuffix

      private static Path appendMissingSuffix(Path existing, List<String> missing) throws FileOperationManager.UnsafePathException
      Throws:
      FileOperationManager.UnsafePathException
    • requireOrdinaryName

      private static void requireOrdinaryName(String name) throws FileOperationManager.UnsafePathException
      Throws:
      FileOperationManager.UnsafePathException
    • realPathOf

      private static Path realPathOf(Path path) throws FileOperationManager.UnsafePathException
      Throws:
      FileOperationManager.UnsafePathException
    • containedTarget

      private FileOperationManager.ResolvedTarget containedTarget(String normalized, Path operand)
      Step 3: real containment. Returns the target with its mapped path (after step 4), or the configurable outside-the-roots refusal.
    • unlessProtected

      private FileOperationManager.ResolvedTarget unlessProtected(File operand, String mappedPath)
      Step 4 (D-08): the unconditional deny layer applied to where the operand really is, by real path (deniedAtRealPath(Path, List)), not by the mapped path: the mapped path depends on which root matched first, so an alias root would otherwise rename a protected object out from under the rule. In entry mode the operand is the entry itself (its parent's real path plus its name), so deleting an alias link is judged on the link, not on what it points to.
    • realRootOf

      private static Path realRootOf(File root)
      A configured root's real path, or null — with a loud warning rather than silently narrowing the set to nothing — when it does not resolve (T-06-23: an operator typo).
    • mappedPath

      private String mappedPath(File root, Path realRoot, Path operand)
      The operand's server-root-relative path expressed through the configured root it belongs to: the root's configured location relative to serverRoot, followed by the operand's location inside the root's real path. It depends on which root matched first, so it only names things in replies; no allow or deny decision is taken on it (those use real paths, see unlessProtected(java.io.File, java.lang.String)).