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.

Two rules hold on every path of this class. Links are never followed when an entry is removed or descended into: one predicate, isRealDirectory(Path, Path), decides "real directory or link" for the delete's terminal entry, every entry of a recursive delete, and the list marking, so a symbolic link or a Windows junction is removed as the entry itself whether it is the target or inside the deleted tree. 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

    • 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.
    • 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:
    • 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.
    • realPathResolver

      private Function<Path,Path> realPathResolver
      The real-path lookup behind isRealDirectory(Path, Path): the filesystem's Path.toRealPath(java.nio.file.LinkOption...), or null when a path cannot be resolved. Not final only so a test can model an entry the JDK reports as a directory although its real path is elsewhere (a Windows junction), which cannot be created on Linux.
  • Constructor Details

    • FileOperationManager

      public FileOperationManager()
  • Method Details

    • buildDenyGlobMatchers

      private static List<PathMatcher> buildDenyGlobMatchers()
    • setWebSocketClient

      @Deprecated(since="6.3.1", forRemoval=true) public void setWebSocketClient(UltiPanelWebSocketClient client)
      Deprecated, for removal: This API element is subject to removal in a future version.
      There is no replacement: the framework never calls it, and its own UltiCloud session (protocol 2.0.0) carries the panel requests. Removed in 6.4.0.
      Sets the WebSocket client.
      Parameters:
      client - the WebSocket client
      Scheduled removal version:
      6.4.0
    • 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
    • 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
    • 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.
    • handleFileOperation

      @Deprecated(since="6.3.1", forRemoval=true) public void handleFileOperation(com.google.gson.JsonObject operationData)
      Deprecated, for removal: This API element is subject to removal in a future version.
      There is no replacement for modules: the framework's own UltiCloud session (protocol 2.0.0) answers the panel's file requests; this v1 handler replies only over the client handed to setWebSocketClient. Removed in 6.4.0.
      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.

      Scheduled removal version:
      6.4.0
    • 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, Path dirReal, 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.

      A child that is not a real directory is marked refused exactly when a read or list of that child would be refused: its decision comes from resolveTarget(String, ResolveMode) in follow mode, so an in-root alias whose real target lies outside every editable root, or inside a protected location, is marked too, not only a child whose name the lexical layer rejects. Only a real directory (isRealDirectory(Path, Path)) keeps the never-refused rule; a symbolic link or junction to a directory is judged by its target like any other alias.

      Parameters:
      basePath - the directory path being listed (the parent of file)
      dirReal - the listed directory's real path, or null if it cannot be resolved
      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.

      The target is resolved in entry mode (FileOperationManager.ResolveMode.ENTRY): the parent is followed, the terminal entry itself is not. The terminal entry is classified by the same predicate the walk uses for every entry inside the tree, isRealDirectory(Path, Path): only a real directory keeps the recursive-flag and protected-descendant gates in front of deleteDirectory(java.io.File). Anything else that is a directory or a link without being a real directory - a symbolic link to a file, to a directory or to nothing, a Windows junction, another reparse point - is removed as the entry itself and its target is never touched (JDK Files.delete(Path) semantics; for a link to a directory this is the maintainer's recorded link-only decision). A regular file is deleted as before.

      Returns:
      the AccessDecision that gated this delete
    • directoryDeleteGate

      private AccessDecision directoryDeleteGate(Path entryReal, com.google.gson.JsonObject operationData, String mappedPath)
      The gates a real directory entry passes before deleteDirectory(java.io.File) may run: D-20's recursive-flag shape guard first, then the protected-descendant walk. Only called for an entry isRealDirectory(Path, Path) accepts; a link, a junction and a regular file never reach it, because removing them removes only the entry, so there is no subtree to guard.

      The ancestor walk closes the gap a phase-06 security audit found: the path decision only ever ran against the requested entry itself, never against what is underneath it, so a recursive delete of an ancestor of a protected path (e.g. plugins/UltiTools, an ancestor of plugins/UltiTools/security and of plugins/UltiTools/data.json) reached deleteDirectory(java.io.File) with no policy consulted for any descendant at all.

      Parameters:
      entryReal - the real directory entry (its real path, as isRealDirectory(java.nio.file.Path, java.nio.file.Path) established)
      operationData - the inbound request's data object (carries recursive)
      mappedPath - the entry's server-root-relative path, as mapped by the resolver; used only to name the refused descendant in the reply
      Returns:
      AccessDecision.allowed(), or the first gate's non-configurable refusal
    • deleteEntry

      private boolean deleteEntry(File entry, boolean realDirectory) throws IOException
      Removes one resolved delete entry. A real directory is emptied by deleteDirectory(java.io.File); any other directory or link entry - a symbolic link, a Windows junction, another reparse point - is removed as the entry itself with Files.delete(Path), which never follows it (D-02); a regular file is deleted as a single file.
      Parameters:
      entry - the resolved, not-followed delete operand
      realDirectory - whether isRealDirectory(Path, Path) accepted entry
      Returns:
      whether the entry was removed
      Throws:
      IOException - if removing a link entry fails
    • 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 displayPath, Path dirReal, List<FileOperationManager.ProtectedLocation> protectedLocations)
      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. This method is the gate that decides whether deleteDirectory(java.io.File) 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.

      Which path it judges. Every descendant is judged at its real location - the real path of the directory it was listed from plus its own name - by deniedAtRealPath(Path, List), never by a root-relative name. Which editable root matched the request, and the order of the configured roots, therefore cannot hide a protected descendant: with roots [plugins/shadow, plugins] and plugins/shadow linking to plugins/UltiTools, a recursive delete of plugins/UltiTools still meets the real action-log directory and is refused. displayPath only names the refused descendant in the reply.

      Links inside the tree are never followed. This walk, deleteDirectory(java.io.File), the delete's terminal entry and the list marking share one rule, isRealDirectory(Path, Path): only a real subdirectory is descended into. A symbolic link (or, on Windows, a junction) found inside the tree is checked at its own location like any other entry and is then removed as a link by deleteDirectory(java.io.File); its target and the target's contents are never reached, so a link to an outside directory, to the action-log directory or to the credential directory neither refuses the delete nor empties its target.

      Parameters:
      displayPath - the mapped server-root-relative path of dirReal (forward-slash separated, no leading slash), used only to name a refused descendant in the reply
      dirReal - the real path of the directory handleDeleteOperation(java.lang.String, com.google.gson.JsonObject, java.lang.String) is about to recursively delete, or of a real subdirectory of it
      protectedLocations - the result of protectedRealLocations()
      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 real directory without following any link inside it.

      Only a child for which isRealDirectory(Path, Path) holds is descended into. Every other child — a regular file, a symbolic link to a file or to a directory, a dangling link, or a Windows junction — is removed as a single entry with Files.deleteIfExists(Path), which never follows a link, so a link's target and the target's contents stay untouched. If dir's real path cannot be determined nothing under it is removed, so its own removal fails and the delete reports the failure. Before this rule, File#isDirectory() followed a descendant link to a directory and the walk emptied the link's target, wherever it was.

      Parameters:
      dir - the real directory to delete (never a link; isRealDirectory(java.nio.file.Path, java.nio.file.Path) establishes that for the terminal entry and for every descendant)
      Returns:
      whether dir itself was removed
    • isRealDirectory

      private boolean isRealDirectory(Path parentReal, Path entry)
      The one "real directory or link" predicate of the remote file API, through this manager's real-path lookup. Used for the delete's terminal entry, every entry of the protected-descendant walk and of deleteDirectory(java.io.File), and the list marking.
      See Also:
    • isRealDirectory

      static boolean isRealDirectory(Path parentReal, Path entry, Function<Path,Path> realPath)
      Whether entry is a real directory: it is a directory when links are not followed, and its own real path is exactly parentReal plus its name. Anything else is a link, whatever the operating system calls it: a symbolic link fails the first test; a Windows junction or another directory reparse point, which the JDK reports as a directory, fails the second, because its real path is its target's. An entry, or a parent, whose real path cannot be determined is not a real directory. A link is never descended into and, deleted, is removed as the entry itself - whether it is the delete target or inside the deleted tree.
      Parameters:
      parentReal - the real path of the directory entry lives in, or null
      entry - the entry, not followed: its parent's real path plus its own name
      realPath - the real-path lookup; returns null when a path cannot be resolved
      Returns:
      true only for a real, non-link directory
    • realPathOrNull

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

      private static boolean deleteSingleEntry(Path entry)
      Removes one non-directory entry (or link) of a tree being deleted, as the entry itself. The walk does not act on the result, exactly as it never acted on a per-file failure: the enclosing directory then stays non-empty and its own removal reports the failure.
      Returns:
      whether the entry is gone
    • 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) and deniedAsAncestorOfProtectedPath(java.lang.String, java.nio.file.Path, java.util.List<com.ultikits.ultitools.manager.FileOperationManager.ProtectedLocation>)).
    • sendFileOperationResult

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