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.
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 StringThe refusal for a path inside the remote action log's own directory (D-23/D-31).Protected extensions, kept equal to the panel'sPROTECTED_SERVER_FILES.extensions(seeBLOCKED_FILES).Protected server-core files, matched by exact basename.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 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). -
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> componentsOf(String normalized) Splits a normalized request into its name components.containedTarget(String normalized, Path operand) Step 3: real containment.private static AccessDecisionprivate 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).The operator-configured editable-root set this manager currently enforces.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 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.final void(Re)loadseditableRootsfromultipanel.files.editable-roots, mirroringErrorReportCollector.loadConfiguration()'s try/catch shape.private static voidrequireOrdinaryName(String name) 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 static StringunlessProtected(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
-
serverRoot
-
BLOCKED_FILES
Protected server-core files, matched by exact basename. Kept equal to the panel's protected list (PROTECTED_SERVER_FILES.namesin UltiCloud'spackages/contracts/src/rest/server-files.ts): the panel refuses these first, and this set refuses them again as defence in depth. Add an entry on both sides together. -
BLOCKED_EXTENSIONS
Protected extensions, kept equal to the panel'sPROTECTED_SERVER_FILES.extensions(seeBLOCKED_FILES). -
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:
-
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
-
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
-
realPathOrNull
The real path ofpath, ornullif it cannot be determined. -
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
-
stripTrailingDotsAndSpaces
-
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. -
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)).
-