Class RemoteFileOperations

java.lang.Object
com.ultikits.ultitools.manager.RemoteFileOperations
All Implemented Interfaces:
CloudRequestHandler

final class RemoteFileOperations extends Object implements CloudRequestHandler
Answers the UltiCloud protocol 2.0.0 files.* requests for one connection; internal, not for module use.

Every request resolves its path through FileOperationManager.resolveTarget(java.lang.String, com.ultikits.ultitools.manager.FileOperationManager.ResolveMode) unchanged, so the whole 6.3.0 guard set applies to protocol v2 exactly as it applied to v1: the editable roots, the protected server files and extensions, the credential basenames and glob patterns, the remote action log's directory and (since plan 22-07) the framework's own .ultikits/ state directory. This class never builds a File from a request path on its own; it acts only on the operand the guard returns.

Every file decision is recorded. Protocol v1 recorded one RemoteActionLog entry per remote file operation, reads and listings included (phase 6 D-22); this handler keeps that property for all four requests: one entry per request that reached a decision, ALLOWED when the request was carried out (the file read, the directory listed, the change made) and DENIED when it was answered with an error code, with the path as the target (empty for the root listing), the literal "panel" as the actor and — for a denial — exactly the text the panel received. A malformed payload is answered by the router before it reaches a decision and records nothing. Content is never recorded. The log itself is injected, so the handler stays testable and the wiring (plan 22-21) binds it to the live RemoteActionLog. Reads keep being answered while the server disables, but from the first step of the disable they are no longer recorded: the disable closes the action log, and a read changes nothing.

It is package-private on purpose: resolveTarget and ResolvedTarget are package-private, and keeping the handler in this package is what makes calling them — rather than reimplementing a second, weaker path walk — the only possible way to answer a request.

Results are the contract's *.result payloads: status: ok with the typed data, or status: error whose code is one of the codes the vendored schema lists for that result type. Payload validation failures are not answered here; they leave PayloadReader as an unchecked invalid_message that AgentMessageRouter turns into a correlated protocol error before any filesystem access happens.

Why this path cannot overwrite operator content. files.write replaces a file only while the baseVersion the panel sent still equals the SHA-256 of the bytes on disk, and that comparison and the replacement happen under the one JVM-wide lock every configuration load and commit also holds (AtomicConfigWriter.underWriteLock(java.util.function.Supplier<T>)): a config.update or a module's save() of the same file can therefore never commit between this comparison and this replacement, and this replacement never lands between their own last-moment re-read and their rename; one of the two sees the other's bytes and refuses. A files.delete removes its entry under the same lock. A version that matches means the panel is writing back exactly the bytes it read, so the operator's content is changed only by an explicit panel edit of that content. Disabling the write path leaves every file untouched. A writer outside this framework that changes the file in the window between the comparison and the atomic move is a niche case (the operator editing server files while the panel writes the same file) and is not defended further: the next read reports the version the file then has, and the panel's next write is compared against it again.

  • Field Details

  • Constructor Details

  • Method Details

    • setWriteGate

      void setWriteGate(Runnable gate)
      Installs a hook that runs inside the write lock after the version comparison passed and immediately before the replacement. It exists so a test can hold one writer between its check and its write while a second writer (another files.write or a configuration commit) waits for the lock; production leaves it null, which costs one null check per write.
      Parameters:
      gate - the hook to run, or null to remove it
    • handle

      public void handle(CloudRequest request, CloudChannel channel)
      Description copied from interface: CloudRequestHandler
      Validates the payload and handles it on the dispatching thread. No Bukkit main-thread scheduling is implied; handlers must schedule such work themselves. Synchronous payload validation failures become correlated invalid_message replies; other runtime failures become internal_error without exposing exception text. Asynchronous failures are the handler's responsibility.
      Specified by:
      handle in interface CloudRequestHandler
      Parameters:
      request - the parsed envelope with a defensively copied payload
      channel - the reply channel, which may close during processing
    • read

      private void read(CloudRequest request, CloudChannel channel)
      Answers files.read: the file's content decoded as strict UTF-8, its size in bytes and the SHA-256 of exactly those bytes — the version a later files.write must send back as its baseVersion.

      The contract's RUNTIME_CHECKS file-content rule is applied to the raw bytes read from disk, before decoding into a reply: a file above the negotiated fileMaxBytes, bytes that are not valid UTF-8, or a C0 control character other than TAB, LF and CR (and DEL) answer file_not_editable. The rule exists so the panel never receives content it cannot render and write back unchanged.

    • list

      private void list(CloudRequest request, CloudChannel channel)
      Answers files.list: one directory's entries, with the size and modification time of files (null for directories), or — for the empty path — the configured editable roots, which is what the contract means by the server root: the panel may browse no further up than the operator's grant.

      A child whose own path the guard refuses (a credential file, a .jar, a link whose real target lies outside the roots) is left out of the listing rather than marked, because a v2 listing carries no per-entry refusal marker; the panel therefore never learns that such a name exists. Above 2000 entries the reply keeps the first 2000 by name and sets truncated, so one directory cannot produce an oversized frame.

    • appendEditableRoots

      private boolean appendEditableRoots(com.google.gson.JsonArray entries)
      Lists the configured editable roots as directory entries named by their server-root-relative path — the very spelling a subsequent files.list of that name needs. A root that is outside the server root, or whose name the guard refuses, cannot be addressed by any valid ServerPath and is skipped: listing it would offer the panel a name that every later request refuses.
      Parameters:
      entries - receives one entry per addressable root
      Returns:
      whether the contract's 2000-entry cap was exceeded
    • relativeToServerRoot

      private String relativeToServerRoot(File root)
    • allowedChildren

      private List<File> allowedChildren(String basePath, File[] children)
    • entryOf

      private static com.google.gson.JsonObject entryOf(File child)
    • write

      private void write(CloudRequest request, CloudChannel channel)
      Answers files.write: replaces an existing file's content when, and only when, the panel's baseVersion still equals the SHA-256 of the bytes on disk.

      The comparison and the atomic replacement happen under the JVM-wide configuration write lock (AtomicConfigWriter.underWriteLock(java.util.function.Supplier<T>)), so concurrent writers cannot both succeed — neither two files.write requests nor a files.write and a configuration commit (config.update, a module's save()) of the same file. The replacement goes through PosixAttributePreserver.replaceInPlace(java.io.File, byte[], com.ultikits.ultitools.utils.PosixAttributePreserver.ReplaceInPlaceListener), which keeps the file's POSIX permissions, owner and group and refuses to write through a symbolic link or over an operator-pinned read-only file (write_denied). The write never creates a file.

    • writeLocked

      private RemoteFileOperations.WriteOutcome writeLocked(File file, byte[] content, String baseVersion, int maxBytes)
      The critical section of files.write: compare the version against the bytes on disk and replace the file, both under AtomicConfigWriter.underWriteLock(java.util.function.Supplier<T>), the lock every configuration load, last-moment check and commit of this JVM also holds.

      A current file above the connection's fileMaxBytes is answered file_changed without being read: the panel can never have received such a file's content, so no baseVersion it holds can equal that file's bytes. Refusing it here also keeps a large log or plugin database from being loaded into the heap merely to compare a hash.

      Parameters:
      file - the resolved target the guard allowed
      content - the exact bytes to write
      baseVersion - the version the panel read
      maxBytes - the connection's fileMaxBytes
      Returns:
      an empty outcome when the file now holds content, otherwise the refusal
    • compareAndReplace

      private RemoteFileOperations.WriteOutcome compareAndReplace(File file, byte[] content, String baseVersion, int maxBytes)
    • delete

      private void delete(CloudRequest request, CloudChannel channel)
      Answers files.delete: removes one file, or one symbolic link as the link itself. A directory is refused with is_directory — the contract has no recursive delete, and a panel delete must never remove a tree an operator built. The entry is resolved in entry mode (FileOperationManager.ResolveMode.ENTRY), so a link to a directory is judged and removed as the link, never followed.
    • readAtMost

      private static byte[] readAtMost(File file, int maxBytes) throws IOException
      Reads a file through a stream capped at maxBytes + 1 bytes.

      The extra byte is how a file that grew between the caller's size check and this read is still detected: the result is then one byte longer than the limit and the caller's existing content rule refuses it. Without the cap a file of any size would be loaded into the heap before the limit could be applied — the reason this helper exists at all.

      Parameters:
      file - the file to read
      maxBytes - the connection's fileMaxBytes
      Returns:
      the file's bytes, or maxBytes + 1 of them when the file is larger
      Throws:
      IOException - if the file cannot be opened or read
    • contentRefusal

      static String contentRefusal(byte[] bytes, int maxBytes)
      The contract's file-content rule over raw bytes.
      Parameters:
      bytes - the bytes that would be sent, or written, as file content
      maxBytes - the negotiated fileMaxBytes
      Returns:
      why the bytes are not editable content, or null when they are
    • isForbiddenControl

      private static boolean isForbiddenControl(char character)
    • decodeUtf8

      private static String decodeUtf8(byte[] bytes)
    • sha256Hex

      static String sha256Hex(byte[] bytes)
    • error

      private static void error(CloudChannel channel, String replyTo, String type, String code, String message)
      Sends a correlated error for one result type. The message is the guard's own refusal text where one exists; it is capped at the contract's 500 code points because a schema-violating frame would be dropped by the gateway instead of explaining the refusal to the operator.
    • refuseWrite

      private void refuseWrite(CloudChannel channel, String replyTo, String path, String code, String message)
      Answers a refused files.write and records the same text in the action log, so the log says what the operator was told.
    • refuseRead

      private void refuseRead(CloudChannel channel, String replyTo, String path, String code, String message)
      Answers a refused files.read and records it, as refuseWrite(com.ultikits.ultitools.websocket.CloudChannel, java.lang.String, java.lang.String, java.lang.String, java.lang.String) does. The entry is written before the reply, so whoever sees the answer also finds the line.
    • refuseList

      private void refuseList(CloudChannel channel, String replyTo, String path, String code, String message)
    • refuseDelete

      private void refuseDelete(CloudChannel channel, String replyTo, String path, String code, String message)
    • auditAllowed

      private void auditAllowed(Capability capability, String action, String target)
    • auditDenied

      private void auditDenied(Capability capability, String action, String target, String reason)
    • auditReadAllowed

      private void auditReadAllowed(String action, String target)
      Records a served read or listing, unless the server is disabling: the disable closes the action log, and a read changes nothing, so leaving it out loses no change (the shutdown window records no remote action).
    • auditReadDenied

      private void auditReadDenied(String action, String target, String reason)
      Records a refused read or listing, unless the server is disabling, as auditReadAllowed(java.lang.String, java.lang.String) does.
    • record

      private void record(RemoteActionLog.Entry entry)
      Hands one entry to the action log. A missing sink is a silent no-op (tests, headless harnesses), and a sink that throws must not cost the panel its reply — the request itself has already been decided.
    • capped

      private static String capped(String message)
      The refusal text as the panel receives it: never empty and never above the contract's cap.