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.
-
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.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.Gsonprivate 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 UltiPanelWebSocketClient -
Constructor Summary
Constructors -
Method Summary
Modifier and TypeMethodDescriptionprivate static StringbasenameOf(String normalizedPath) private static List<PathMatcher> private com.google.gson.JsonObjectbuildListEntry(String basePath, File file) Builds a single directory-listing row, applying D-18's mark-not-filter policy to a refused child entry.private booleandeleteDirectory(File dir) Recursively deletes a directory.private AccessDecisiondeniedAsAncestorOfProtectedPath(String normalizedPath, File dir) Refuses a recursive directory delete whose target is an ancestor of any pathdeniedUnconditionally(java.lang.String)would refuse on its own.deniedUnconditionally(String normalizedPath) The unconditional deny layer (D-16).The operator-configured editable-root set this manager currently enforces.private FilegetSecureFile(String path) Resolves a requested path to a realFilehandle, refusing anything that does not canonically resolve inside one of the configurededitableRoots(D-21).private AccessDecisionhandleDeleteOperation(String path, com.google.gson.JsonObject operationData, String operationId) Handles a file delete operation.voidhandleFileOperation(com.google.gson.JsonObject operationData) Handles a file operation request.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.private booleanisContainedInAnyRealRoot(Path resolvedCandidate) ChecksresolvedCandidateagainst every configured editable root's real path.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 static booleanisUnderSecurityDirectory(String normalizedPath) 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 static booleanmatchesDenyGlob(String fileNameLower) private static Stringprivate 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 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 static PathresolveNearestReal(Path candidatePath) ResolvescandidatePathto its real (symlink-resolved) form.private voidsendFileOperationResult(String operationId, String operation, String path, boolean success, String message, com.google.gson.JsonObject data) Sends the file operation result.voidSets the WebSocket client.
-
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. -
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.
-
-
Constructor Details
-
FileOperationManager
public FileOperationManager()
-
-
Method Details
-
buildDenyGlobMatchers
-
setWebSocketClient
Sets the WebSocket client.- Parameters:
client- the WebSocket client
-
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
-
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
-
basenameOf
-
normalize
-
isWithinEditableRoots
Lexical containment check againsteditableRoots— no filesystem access, safe to call before any real file exists.getSecureFile(String)performs the real, symlink-aware containment check once a file handle is actually needed. -
handleFileOperation
public void handleFileOperation(com.google.gson.JsonObject operationData) Handles a file operation request.Dispatches to one of the four operation handlers inside a
CompletableFuture.runAsync(java.lang.Runnable)hop, then records exactly 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. -
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.- Parameters:
basePath- the directory path being listed (the parent offile)file- 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.- Returns:
- the
AccessDecisionthat gated this delete
-
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
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.deleteDirectory(java.io.File)itself is unchanged — the walk it performs stays a naked recursive delete, and this method is the gate that decides whether it may run at all, not a per-descendant check woven into it.Why this walks the real directory instead of comparing paths lexically. One of
deniedUnconditionally(java.lang.String)'s rules — the security-directory check — is answerable from the path alone:SECURITY_DIR_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.What this does not cover. A symlink inside the tree whose target resolves to a protected location the link's own relative path would not itself trigger —
File#listFiles()enumerates the directory's own entries, and following such a link across a policy boundary is the same lexical-vs-real-path asymmetry already tracked and deliberately not fixed this phase; this walk does not attempt to close it.- Parameters:
normalizedPath- the normalized path ofdir(forward-slash separated, no leading slash), used to build each descendant's relative pathdir- the real directoryhandleDeleteOperation(java.lang.String, com.google.gson.JsonObject, java.lang.String)is about to recursively delete- Returns:
AccessDecision.allowed()if no descendant (at any depth) is unconditionally denied; otherwise a denied, non-configurable decision naming the first protected descendant found — non-configurable because every ruledeniedUnconditionally(java.lang.String)enforces is itself non-configurable
-
deleteDirectory
Recursively deletes a directory. -
getSecureFile
Resolves a requested path to a realFilehandle, refusing anything that does not canonically resolve inside one of the configurededitableRoots(D-21).The pre-6.3.0 defect here was never the canonicalization order —
getCanonicalPath()already ran before the check, and already resolved symlinks. The live bug wasString.startsWithwith no separator boundary:"/srv/mc-evil/x"literally starts with the characters"/srv/mc". This rewrite compares byPathname element viaPath.startsWith(Path)instead, mirroring the Zip-Slip guard already correct for the opposite direction inUltiToolsPlugin.saveResources()— it appendsFile.separatorto its own canonical base before comparing — the same defect class, already fixed once in this repository.A requested path that does not exist yet (a create/write target) resolves its nearest existing ancestor via
resolveNearestReal(Path)rather than throwing, so a legitimate create into an existing directory is not refused as a traversal attempt — but an ancestor that itself resolves outside every root is still refused (T-06-21).- Parameters:
path- the requested path, forward- or back-slash separated, with or without a leading slash- Returns:
- a
Filehandle for the (not-yet-canonicalized) requested location — callers operate on this handle, not on the resolved candidate used only for the check - Throws:
SecurityException- if the path is empty, unresolvable, or resolves outside every configured editable root
-
resolveNearestReal
ResolvescandidatePathto its real (symlink-resolved) form. If the path itself does not exist yet, walks up to the nearest existing ancestor, resolves that ancestor for real, and re-appends the unresolved trailing segments — Claude's Discretion hazard #1 in 06-RESEARCH.md: a not-yet-existing create/write target must not be refused outright.- Throws:
IOException- if no ancestor ofcandidatePathexists (should not happen onceserverRootitself exists) or the real path cannot be resolved
-
isContainedInAnyRealRoot
ChecksresolvedCandidateagainst every configured editable root's real path.A configured root that does not itself resolve (T-06-23: an operator typo) is skipped with a loud warning rather than silently narrowing the set to nothing — the remaining configured roots still apply.
-
sendFileOperationResult
private void sendFileOperationResult(String operationId, String operation, String path, boolean success, String message, com.google.gson.JsonObject data) Sends the file operation result.
-