Class FileOperationManager
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.
-
Nested Class Summary
Nested ClassesModifier and TypeClassDescriptionprivate static final classOne protected directory's real location and the refusal a path inside it produces.(package private) static final classThe result ofresolveTarget(String, ResolveMode): the final decision and, when it is allowed, the operand the handler performs I/O on and that operand's mapped server-root-relative path.(package private) static enumHowresolveTarget(String, ResolveMode)treats the request's terminal component.private static final classA request shape that cannot be located safely; always becomes a non-configurable refusal. -
Field Summary
FieldsModifier and TypeFieldDescriptionprivate static final StringTheRemoteActionLog.Entry.getAction()literal prefix every entry this class records carries, extended with the operation itself (e.g.private static final StringThe refusal for a path inside the remote action log's own directory (D-23/D-31).D-15's shipped default: plugin configs and historical logs, nothing else.D-16's exact-basename credential set — matched case-insensitively, seebasenameOf(java.lang.String).private static final List<PathMatcher> D-16's basename glob patterns for credential-bearing files.private static final StringD-15's config key for the operator-configured editable-root set.The operator-configured editable-root set (D-15), resolved toFiles rooted atserverRoot.private final com.google.gson.GsonThe real-path lookup behindisRealDirectory(Path, Path): the filesystem'sPath.toRealPath(java.nio.file.LinkOption...), ornullwhen a path cannot be resolved.private static final PathThe relative location of the remote action log's own directory (D-23/D-31), expressed as a lexicalPathso containment is checked by name element rather than string prefix — a sibling directory namedsecurity-backupmust not be caught by it.private final Fileprivate static final StringThe refusal for a path inside the framework's own state directory (T-22-28).private static final PathThe relative location of the framework's own state directory,<server root>/.ultikits(plan 22-07, T-22-28).private UltiPanelWebSocketClient -
Constructor Summary
Constructors -
Method Summary
Modifier and TypeMethodDescriptionprivate voidaddProtectedDirectory(List<FileOperationManager.ProtectedLocation> locations, Path relative, String refusal) Adds the real locations of one protected directory (seeprotectedRealLocations()).private static PathappendMissingSuffix(Path existing, List<String> missing) private static StringbasenameOf(String normalizedPath) private static List<PathMatcher> private com.google.gson.JsonObjectbuildListEntry(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.componentsOf(String normalized) Splits a normalized request into its name components.containedTarget(String normalized, Path operand) Step 3: real containment.private booleandeleteDirectory(File dir) Recursively deletes a real directory without following any link inside it.private booleandeleteEntry(File entry, boolean realDirectory) Removes one resolved delete entry.private static booleandeleteSingleEntry(Path entry) Removes one non-directory entry (or link) of a tree being deleted, as the entry itself.private static AccessDecisionprivate AccessDecisiondeniedAsAncestorOfProtectedPath(String displayPath, Path dirReal, List<FileOperationManager.ProtectedLocation> protectedLocations) Refuses a recursive directory delete whose target is an ancestor of any pathdeniedUnconditionally(java.lang.String)would refuse on its own.private static AccessDecisiondeniedAtRealPath(Path realEntry, List<FileOperationManager.ProtectedLocation> protectedLocations) The unconditional deny layer judged on real paths: the name rules ofdeniedByName(java.lang.String)on the entry's own real name, and containment in a protected location's real path (protectedRealLocations()).private static AccessDecisiondeniedByName(String fileName) The name rules of the unconditional deny layer: protected server files, protected extensions, credential basenames and credential glob patterns.deniedUnconditionally(String normalizedPath) The unconditional deny layer (D-16).private AccessDecisiondirectoryDeleteGate(Path entryReal, com.google.gson.JsonObject operationData, String mappedPath) The gates a real directory entry passes beforedeleteDirectory(java.io.File)may run: D-20's recursive-flag shape guard first, then the protected-descendant walk.The operator-configured editable-root set this manager currently enforces.private AccessDecisionhandleDeleteOperation(String path, com.google.gson.JsonObject operationData, String operationId) Handles a file delete operation.voidhandleFileOperation(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.private AccessDecisionhandleListOperation(String path, com.google.gson.JsonObject operationData, String operationId) Handles a directory listing operation.private AccessDecisionhandleReadOperation(String path, com.google.gson.JsonObject operationData, String operationId) Handles a file read operation.private AccessDecisionhandleWriteOperation(String path, com.google.gson.JsonObject operationData, String operationId) Handles a file write operation.isPathAllowed(String path) Checks whether a file path may be reached through the remote file API, and — unlike thebooleanthis returned before 6.3.0 — says why not when it is refused (D-17).private booleanisRealDirectory(Path parentReal, Path entry) The one "real directory or link" predicate of the remote file API, through this manager's real-path lookup.(package private) static booleanWhetherentryis a real directory: it is a directory when links are not followed, and its own real path is exactlyparentRealplus its name.private static booleanisUnderSecurityDirectory(String normalizedPath) private static booleanisUnderStateDirectory(String normalizedPath) Lexical containment inSTATE_DIR_RELATIVE, by name element -- a sibling.ultikits-backupis not inside it.private booleanisWithinEditableRoots(String normalizedPath) Lexical containment check againsteditableRoots— no filesystem access, safe to call before any real file exists.private static voidlogWarning(String message) Best-effort warning logging that never throws —UltiTools.getInstance()may benullin a test harness with no live plugin.private StringmappedPath(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 toserverRoot, followed by the operand's location inside the root's real path.private static booleanmatchesDenyGlob(String fileNameLower) private static StringThe real locations of the protected directoryplugins/UltiTools/securityunderserverRoot: the location of the directory entry itself (its parent's real path plussecurity) and, when that entry exists and resolves elsewhere, its real target too.private static PathrealLocationOf(Path path) path's real path when it exists; otherwise the real path of its deepest existing ancestor followed by the remaining names.private static PathrealPathOf(Path path) private static PathrealPathOrNull(Path path) The real path ofpath, ornullif it cannot be determined.private static PathrealRootOf(File root) A configured root's real path, ornull— with a loud warning rather than silently narrowing the set to nothing — when it does not resolve (T-06-23: an operator typo).private static StringreasonCodeFor(AccessDecision decision) D-18's two schema reason codes for a refusedlistentry.private voidrecordFileDecision(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 fromhandleFileOperation(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.final void(Re)loadseditableRootsfromultipanel.files.editable-roots, mirroringErrorReportCollector.loadConfiguration()'s try/catch shape.private static voidrequireOrdinaryName(String name) private static AccessDecisionrequireRecursiveFlag(com.google.gson.JsonObject operationData) D-20's shape guard: deleting a directory requires the request's data to carry arecursivemember that is a JSON boolean primitive with valuetrue.private PathresolveEntry(List<String> names) Entry-mode resolution (delete): the parent is followed, the terminal entry is not.private PathresolveFollowing(List<String> names) Follow-mode walk (step 2): every existing component is resolved by the filesystem; the first missing component starts the missing suffix.(package private) FileOperationManager.ResolvedTargetresolveTarget(String path, FileOperationManager.ResolveMode mode) The one resolver every remote file operation takes its decision and its I/O operand from (D-21, issue #672).private voidsendFileOperationResult(String operationId, String operation, String path, boolean success, String message, com.google.gson.JsonObject data) Sends the file operation result.voidDeprecated, 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.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.
-
Field Details
-
webSocketClient
-
serverRoot
-
gson
private final com.google.gson.Gson gson -
BLOCKED_FILES
-
BLOCKED_EXTENSIONS
-
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 aSet. 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 viaFileSystems.getDefault().getPathMatcher(String)rather than hand-rolled wildcard matching. -
DENY_EXACT_BASENAMES
D-16's exact-basename credential set — matched case-insensitively, seebasenameOf(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, referencingCredentialStore.CREDENTIAL_FILE_NAMErather 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 migratedCredentialStore. -
SECURITY_DIR_RELATIVE
The relative location of the remote action log's own directory (D-23/D-31), expressed as a lexicalPathso containment is checked by name element rather than string prefix — a sibling directory namedsecurity-backupmust not be caught by it. -
STATE_DIR_RELATIVE
The relative location of the framework's own state directory,<server root>/.ultikits(plan 22-07, T-22-28). It holdscredentials.json-- the linked server's UltiCloud credential -- andcloud-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_BASENAMESentry 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 likeSECURITY_DIR_RELATIVE-- a sibling directory named.ultikits-backupis not caught by it. -
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
The refusal for a path inside the framework's own state directory (T-22-28).- See Also:
-
ACTION_FILE_OPERATION_PREFIX
TheRemoteActionLog.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
D-15's config key for the operator-configured editable-root set.- See Also:
-
DEFAULT_EDITABLE_ROOTS
D-15's shipped default: plugin configs and historical logs, nothing else. -
editableRoots
The operator-configured editable-root set (D-15), resolved toFiles rooted atserverRoot. Notfinal—reloadRootsFromConfig()may reassign it without reconstructing the manager. -
realPathResolver
The real-path lookup behindisRealDirectory(Path, Path): the filesystem'sPath.toRealPath(java.nio.file.LinkOption...), ornullwhen a path cannot be resolved. Notfinalonly 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
-
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
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)loadseditableRootsfromultipanel.files.editable-roots, mirroringErrorReportCollector.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
Best-effort warning logging that never throws —UltiTools.getInstance()may benullin a test harness with no live plugin. -
isPathAllowed
Checks whether a file path may be reached through the remote file API, and — unlike thebooleanthis 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.jsonwithpluginsgranted) 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
AccessDecisioncarrying the refusal cause when denied
-
deniedUnconditionally
The unconditional deny layer (D-16). Reads no config key and consults noCapability— 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, referencingCredentialStore.CREDENTIAL_FILE_NAME) and"data.json"(the pre-6.3.0 name, still live atplugins/UltiTools/data.jsonfor any install that has not yet restarted onto a migratedCredentialStore). 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 insidegetDataFolder()— 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_EXTENSIONSsets 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
AccessDecisionif this layer refuses the path;nullif this layer has no objection and the caller must still run the editable-root check
-
deniedByName
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
nullif no name rule matches
-
deniedAsActionLogDirectory
-
deniedAtRealPath
private static AccessDecision deniedAtRealPath(Path realEntry, List<FileOperationManager.ProtectedLocation> protectedLocations) The unconditional deny layer judged on real paths: the name rules ofdeniedByName(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 exampleplugins/shadowlinking toplugins/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 ofprotectedRealLocations()- Returns:
- a denied, non-configurable decision, or
nullif no rule refuses the entry
-
protectedRealLocations
The real locations of the protected directoryplugins/UltiTools/securityunderserverRoot: the location of the directory entry itself (its parent's real path plussecurity) 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 (seeprotectedRealLocations()).- Parameters:
locations- the list to append torelative- the directory's location relative toserverRootrefusal- the reason a path inside it is refused
-
realLocationOf
path's real path when it exists; otherwise the real path of its deepest existing ancestor followed by the remaining names.nullonly if no ancestor resolves. -
reasonCodeFor
D-18's two schema reason codes for a refusedlistentry. A configurableAccessDecision(outside every editable root — an operator can flip it) maps toOUTSIDE_ROOTS; a non-configurable one (the unconditional credential deny layer) maps toPROTECTED_CREDENTIAL. These two strings are the whole of the wire-protocol contract this plan adds tofile_operation_resultlist entries — see the class javadoc's D-14 note and Plan 06-04'sartifacts_produced.- Parameters:
decision- a deniedAccessDecision(neverAccessDecision.allowed())- Returns:
"OUTSIDE_ROOTS"or"PROTECTED_CREDENTIAL"
-
matchesDenyGlob
-
isUnderSecurityDirectory
-
isUnderStateDirectory
Lexical containment inSTATE_DIR_RELATIVE, by name element -- a sibling.ultikits-backupis 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
-
normalize
-
isWithinEditableRoots
Lexical containment check againsteditableRoots— 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 oneRemoteActionLogentry 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/actorare captured into locals before the async hop and never re-read fromoperationDataafterward, following the immutable-string capture disciplineTriggerContextestablishes — the mutableJsonObjectitself 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 fromhandleFileOperation(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
nulldecision — the unsupported-operation branch — records nothing: an operation this manager does not recognise is not a file decision. AnullRemoteActionLog(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 stringpath- the requested path as received — the action-logtargetactor- the inboundexecutorfield verbatim, or the literal"panel"decision- the policy decision the dispatched handler reached, ornullfor an unsupported operation
-
handleReadOperation
private AccessDecision handleReadOperation(String path, com.google.gson.JsonObject operationData, String operationId) Handles a file read operation.- Returns:
- the
AccessDecisionthat gated this read — the valuerecordFileDecision(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
AccessDecisionthat gated this write
-
handleListOperation
private AccessDecision handleListOperation(String path, com.google.gson.JsonObject operationData, String operationId) Handles a directory listing operation.- Returns:
- the
AccessDecisionthat gated listing the directory itself — one decision for the whole operation, not one per returned row (seerecordFileDecision(java.lang.String, java.lang.String, java.lang.String, com.ultikits.ultitools.entities.AccessDecision))
-
buildListEntry
Builds a single directory-listing row, applying D-18's mark-not-filter policy to a refused child entry. Extracted fromhandleListOperation(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 offile)dirReal- the listed directory's real path, ornullif it cannot be resolvedfile- one child entry returned bydir.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 ofdeleteDirectory(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 (JDKFiles.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
AccessDecisionthat gated this delete
-
directoryDeleteGate
private AccessDecision directoryDeleteGate(Path entryReal, com.google.gson.JsonObject operationData, String mappedPath) The gates a real directory entry passes beforedeleteDirectory(java.io.File)may run: D-20's recursive-flag shape guard first, then the protected-descendant walk. Only called for an entryisRealDirectory(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 ofplugins/UltiTools/securityand ofplugins/UltiTools/data.json) reacheddeleteDirectory(java.io.File)with no policy consulted for any descendant at all.- Parameters:
entryReal- the real directory entry (its real path, asisRealDirectory(java.nio.file.Path, java.nio.file.Path)established)operationData- the inbound request's data object (carriesrecursive)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
Removes one resolved delete entry. A real directory is emptied bydeleteDirectory(java.io.File); any other directory or link entry - a symbolic link, a Windows junction, another reparse point - is removed as the entry itself withFiles.delete(Path), which never follows it (D-02); a regular file is deleted as a single file.- Parameters:
entry- the resolved, not-followed delete operandrealDirectory- whetherisRealDirectory(Path, Path)acceptedentry- Returns:
- whether the entry was removed
- Throws:
IOException- if removing a link entry fails
-
requireRecursiveFlag
D-20's shape guard: deleting a directory requires the request's data to carry arecursivemember that is a JSON boolean primitive with valuetrue.A present-but-non-boolean
recursive(e.g. the string"false") is treated as absent rather than coerced — Gson'sJsonPrimitive.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 totruefor backward compatibility: D-20 accepts the break becausefile-deletedefaults 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()whenrecursiveis present andtrue; 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 pathdeniedUnconditionally(java.lang.String)would refuse on its own. Runs afterrequireRecursiveFlag(com.google.gson.JsonObject)and strictly beforedeleteDirectory(java.io.File); nothing is deleted when this refuses. This method is the gate that decides whetherdeleteDirectory(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_RELATIVEis a single fixed location, so "isdiran ancestor of it" is justPath.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 — "isdiran ancestor of some path a glob would deny" cannot be answered fromdir'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 ruledeniedUnconditionally(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]andplugins/shadowlinking toplugins/UltiTools, a recursive delete ofplugins/UltiToolsstill meets the real action-log directory and is refused.displayPathonly 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 bydeleteDirectory(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 ofdirReal(forward-slash separated, no leading slash), used only to name a refused descendant in the replydirReal- the real path of the directoryhandleDeleteOperation(java.lang.String, com.google.gson.JsonObject, java.lang.String)is about to recursively delete, or of a real subdirectory of itprotectedLocations- the result ofprotectedRealLocations()- 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 ruledeniedUnconditionally(java.lang.String)enforces is itself non-configurable
-
deleteDirectory
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 withFiles.deleteIfExists(Path), which never follows a link, so a link's target and the target's contents stay untouched. Ifdir'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
diritself was removed
-
isRealDirectory
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 ofdeleteDirectory(java.io.File), and the list marking.- See Also:
-
isRealDirectory
Whetherentryis a real directory: it is a directory when links are not followed, and its own real path is exactlyparentRealplus 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 directoryentrylives in, ornullentry- the entry, not followed: its parent's real path plus its own namerealPath- the real-path lookup; returnsnullwhen a path cannot be resolved- Returns:
trueonly for a real, non-link directory
-
realPathOrNull
The real path ofpath, ornullif it cannot be determined. -
deleteSingleEntry
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
FileOperationManager.ResolvedTarget resolveTarget(String path, FileOperationManager.ResolveMode mode) 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 aFilefrom the raw request string.Steps, each of which can produce the final decision:
- Lexical layer —
isPathAllowed(String)exactly as before (unconditional deny layer first, then the lexical editable-root check). - 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 whatPath.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. - 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>-evilis not inside<root>); otherwise the refusal is configurable throughultipanel.files.editable-roots. A configured root that does not resolve is skipped with a warning (T-06-23). - 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.
FileOperationManager.ResolveMode.FOLLOWthe operand is the resolved real object, or the real existing prefix plus the ordinary missing names of a new write target. InFileOperationManager.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 slashmode- whether the terminal component is followed- Returns:
- the final decision, and when allowed the operand and its mapped path
- Lexical layer —
-
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, soroot//sub/androot/subname the same object;.and..are kept as components for the walk to resolve. -
resolveFollowing
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. -
resolveEntry
Entry-mode resolution (delete): the parent is followed, the terminal entry is not. The terminal name must be an ordinary name, sosub/..,.or an empty path never names some other directory to remove. -
appendMissingSuffix
private static Path appendMissingSuffix(Path existing, List<String> missing) throws FileOperationManager.UnsafePathException -
requireOrdinaryName
private static void requireOrdinaryName(String name) throws FileOperationManager.UnsafePathException -
realPathOf
-
containedTarget
Step 3: real containment. Returns the target with its mapped path (after step 4), or the configurable outside-the-roots refusal. -
unlessProtected
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
A configured root's real path, ornull— with a loud warning rather than silently narrowing the set to nothing — when it does not resolve (T-06-23: an operator typo). -
mappedPath
The operand's server-root-relative path expressed through the configured root it belongs to: the root's configured location relative toserverRoot, 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, seeunlessProtected(java.io.File, java.lang.String)anddeniedAsAncestorOfProtectedPath(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.
-