Class OperatorFileWriter

java.lang.Object
com.ultikits.ultitools.config.document.OperatorFileWriter

@Internal public final class OperatorFileWriter extends Object
The write gate for operator-editable config files: the one way the framework changes such a file automatically (maintainer decisions of 2026-10-04: "operator-written configuration is never overwritten automatically" and "what code may write, by file type").

Why it cannot overwrite operator content. Every call declares the paths it owns (OwnedPaths). The gate reads the file at write time through ConfigDocument.load(Path), applies the caller's edit to that fresh document, renders the whole document, and then verifies the result instead of trusting the renderer:

  1. the rendered text parses back to exactly the edited values, and outside the owned value paths the edited values equal the file's;
  2. owned spans are computed on both sides from SnakeYAML node marks: a value path's span runs from its key's line to the last line of its value, plus the comment lines directly above the key when the key is inserted or removed or its comment is owned; an owned comment's span is those comment lines - or, for a framework comment run (OwnedPaths.Builder.frameworkComment(List, int)), only the run's last lines, so the comment lines above the run are unowned and must come out unchanged (#604); an inserted key whose nearest key in the file is a section the operator left with no value (messages:, nothing after the colon but a comment) also owns that section's one line, and the section's value may change only from nothing to a mapping holding exactly the inserted keys - the line keeps the key as written, the comment text and its line terminator, and only its whitespace may be normalized: a space before the colon, the spaces before the comment, and trailing spaces after the comment or at the end of a line without one (#620; owned-span rule revision 3, maintainer decision 2026-10-06). A section written as an empty value (~, null, {}) is the operator's value and is never owned this way;
  3. the lines outside the owned spans - each compared as text including its line terminator - are the same sequence on both sides (the line diff restricted to unowned lines; stricter than a plain longest-common-subsequence diff, which could match an unowned line against an owned one);
  4. byte-order mark and final line break are unchanged;
  5. no owned span shares a line with a node of a path it does not own (a flow collection holding an owned and an unowned key on one line is refused, not partially rewritten).
A file using YAML anchors, aliases or merge keys is refused before the edit is applied: the renderer cannot write one back without expanding or moving shared content. A runtime failure of the YAML library while the edit is applied, while the document is rendered or while the rendering is parsed back (an EmitterException for a layout it cannot emit, for example) is refused the same way (#624). If any check fails nothing is written, one WARNING names the absolute file, the keys the write would have changed (a key below a secret-shaped key is redacted) and the reason - never a value - and the caller keeps its in-memory value. Layout is never normalized and no operator value, key or comment is repaired to make a write pass.

When the caller passes the fingerprint of the bytes it last read and the file no longer holds them, the outcome is OperatorFileWriter.Outcome.FILE_CHANGED. Immediately before publishing, after rendering and verifying, the gate reads the target again - after the new content is staged, under the lock every load and write holds - and abandons the write when the bytes moved; the residual window between that read and the atomic rename cannot be closed between an editor and the JVM. Publication goes through AtomicConfigWriter; a file the gate read absent is created exclusively (a hard link of the staged file, or a re-check and a move that does not replace where links are refused), so a file that appeared meanwhile is never replaced. Files that are unreadable or unparseable are never written.

Since:
6.3.0
  • Field Details

  • Constructor Details

    • OperatorFileWriter

      private OperatorFileWriter()
  • Method Details

    • write

      public static OperatorFileWriter.Result write(Path file, OwnedPaths owned, String expectedFingerprint, Consumer<ConfigDocument> edit) throws IOException
      Applies edit to the file as it is now and publishes the result only when every byte outside owned stays as it is (see the class description for why this cannot overwrite operator content).
      Parameters:
      file - the config file
      owned - what this write may change
      expectedFingerprint - the SHA-256 (lower-case hex) of the bytes the caller last read, as ConfigLoadResult.fingerprint() reports it, ABSENT for a file that was absent, or null for no expectation
      edit - the change, applied to a freshly read document; it must change only owned paths
      Returns:
      the outcome; a refusal or a changed file has already been logged once
      Throws:
      IOException - if staging or publishing the verified text fails (the target is then as AtomicConfigWriter leaves it)
    • write

      static OperatorFileWriter.Result write(Path file, OwnedPaths owned, String expectedFingerprint, Consumer<ConfigDocument> edit, AtomicConfigWriter.FileOperations files) throws IOException
      As write(Path, OwnedPaths, String, Consumer), with the publication steps as a test seam (null: real).
      Throws:
      IOException
    • stage

      public static OperatorFileWriter.Staged stage(Path file, OwnedPaths owned, String expectedFingerprint, Consumer<ConfigDocument> edit) throws IOException
      As write(Path, OwnedPaths, String, Consumer), but stops once the verified content is staged (written and forced to a temporary file beside the target), so several files can be prepared before any of them is replaced. OperatorFileWriter.Staged.commit() then performs the last-moment check - the target must still hold exactly the bytes the write was verified against, read again under the lock every load and write holds - and the replacement or the exclusive creation; OperatorFileWriter.Staged.discard() releases a staged file that will not be committed. Every check of the class description applies unchanged, so a staged write cannot overwrite operator content either: a file saved between staging and commit is kept and the commit reports OperatorFileWriter.Outcome.FILE_CHANGED.
      Parameters:
      file - the config file
      owned - what this write may change
      expectedFingerprint - as for write(Path, OwnedPaths, String, Consumer)
      edit - the change, applied to a freshly read document; it must change only owned paths
      Returns:
      the staged write; when nothing needed staging, it already holds its result (OperatorFileWriter.Staged.isPending() is false) - a refusal or a changed file has then already been logged once
      Throws:
      IOException - if staging the verified text fails
    • stage

      static OperatorFileWriter.Staged stage(Path file, OwnedPaths owned, String expectedFingerprint, Consumer<ConfigDocument> edit, AtomicConfigWriter.FileOperations files) throws IOException
      Throws:
      IOException
    • wouldChange

      private static List<String> wouldChange(OperatorFileWriter.Snapshot snapshot, OwnedPaths owned, Consumer<ConfigDocument> edit)
      For a write that cannot start because the file changed: what it would have changed in the file as it is now, to name in the warning; empty when that cannot be told. Nothing is written. A newer file using anchors is never edited, not even in memory (review round 2 IN-R2-01), and an edit the YAML library cannot apply names the owned keys instead (#624).
    • runtimeFailure

      private static String runtimeFailure(OwnedPaths owned, String step, RuntimeException failure)
      The refusal reason for a runtime failure inside a gated write (#624): the keys, the step that failed and the failure's class, never a value.
    • fault

      private static void fault(OperatorFileWriter.Step step)
    • written

      private static OperatorFileWriter.Result written(String rendered, ConfigDocument candidate, String read)
    • fail

      private static OperatorFileWriter.Result fail(OperatorFileWriter.Outcome outcome, Path absolute, OwnedPaths owned, List<String> keys, String reason)
    • refuseAnchored

      private static OperatorFileWriter.Result refuseAnchored(Path absolute, OwnedPaths owned)
      An anchored file is refused on every write but named once per file per server run.
    • resetAnchorWarnings

      static void resetAnchorWarnings()
      Forgets which anchored files were named, for tests that start a fresh "server run".
    • refuse

      private static OperatorFileWriter.Result refuse(Path absolute, OwnedPaths owned, List<String> keys, String reason)
    • changed

      private static OperatorFileWriter.Result changed(Path absolute, OwnedPaths owned, List<String> keys, String reason)
    • warn

      private static void warn(Path absolute, OwnedPaths owned, List<String> keys, String reason)
    • verify

      private static String verify(String text, String rendered, ConfigDocument original, ConfigDocument candidate, OperatorFileWriter.Changes changes, OwnedPaths owned, List<List<String>> expanded)
    • markExpandedSection

      private static String markExpandedSection(OperatorFileWriter.Side left, OperatorFileWriter.Side right, List<String> section, boolean leftBom, boolean rightBom)
      The owned span of an expanded section (OperatorFileWriter.EmptySections, #620): its key line on both sides and nothing else - the inserted keys below it are marked as inserted values by markSpans(com.ultikits.ultitools.config.document.OperatorFileWriter.Side, com.ultikits.ultitools.config.document.OperatorFileWriter.Side, com.ultikits.ultitools.config.document.OperatorFileWriter.Changes, com.ultikits.ultitools.config.document.OwnedPaths). On both sides that line may hold no node of another path, and the rendered line must keep the operator's bytes of it: the text up to the end of the key, the comment after the colon (without trailing spaces), and the line terminator. Only whitespace on the line may change: a space before the colon, the spaces before the comment, and trailing spaces after the comment or at the end of a line without one - every render of such a line writes key: # comment (gate-1 F2 of plan 17-75 stated this exactly); a last line without a line break gains one, as check 3 allows.
    • sectionLineParts

      private static String[] sectionLineParts(String line, org.yaml.snakeyaml.nodes.Node key, boolean byteOrderMark)
      A section line split into the text up to the end of its key, the comment after its colon ("" when none) and its line terminator; null parts are never returned. The comment is everything from the first # after the colon to the end of the line, without trailing spaces.
    • markSpans

      private static String markSpans(OperatorFileWriter.Side left, OperatorFileWriter.Side right, OperatorFileWriter.Changes changes, OwnedPaths owned)
      Checks 2 and 5: marks every owned span on both sides, refusing a span that shares a line with an unowned key.
    • ownedCommentLines

      private static int[] ownedCommentLines(OwnedPaths owned, OperatorFileWriter.Side left, OperatorFileWriter.Side right, List<String> path)
      The owned comment lines directly above the key at path, before and after the edit: the whole comment, or - for a framework comment run - the run's length in the file as read and the length of what replaced it. The comment lines above a run are the same count on both sides and unowned, so check 3 requires them unchanged.
      Returns:
      {before, after}, or null when the run does not fit the comment on either side
    • compareUnowned

      private static String compareUnowned(OperatorFileWriter.Side left, OperatorFileWriter.Side right)
      Check 3: the unowned lines of both sides are the same sequence, each line with its terminator.
    • sameLine

      private static boolean sameLine(List<String> before, int b, boolean[] beforeOwned, List<String> after, int a, boolean[] afterOwned)
      Whether an unowned line is unchanged: equal text including its terminator. One separator is not operator content: the last line of a file without a final line break gains a line break when owned lines are inserted after it (and loses it when the owned lines after it are removed); the line's own text stays byte for byte, and check 4 keeps the file's final line break as it was.
    • isLineBreak

      private static boolean isLineBreak(String text)
    • verifyValues

      private static String verifyValues(ConfigDocument original, ConfigDocument candidate, String rendered, List<List<String>> changedValues, List<List<String>> expanded, boolean wholeFile)
      Check 1: the rendered text holds exactly the edited values, and the edit stayed inside its paths. An expanded section (OperatorFileWriter.EmptySections) held null and must now hold nothing but the inserted keys: once they are stripped it is an empty mapping, which is read back as the null it was.
    • strip

      private static void strip(Map<String,Object> before, Map<String,Object> after, List<String> path)
    • pruneCreatedMapping

      private static void pruneCreatedMapping(Map<String,Object> tree, Map<String,Object> other, List<String> prefix)
    • lookup

      private static Object lookup(Map<String,Object> tree, List<String> path)
    • containsPath

      private static boolean containsPath(Map<String,Object> tree, List<String> path)
    • remove

      private static void remove(Map<String,Object> tree, List<String> path)
    • topmostAbsentPrefix

      private static List<String> topmostAbsentPrefix(ConfigDocument original, List<String> path)
    • commentLineCount

      private static int commentLineCount(ConfigDocument document, List<String> path)
      How many lines directly above the key at path are its own comment in document: the comment lines ConfigDocument.blockComment(List) reports, without the blank lines above them (those are kept by every comment write). The file header and a deeper-indented trailing comment of the previous section are not part of a key's comment, so they are never owned through it.
    • commentOrBlank

      private static boolean commentOrBlank(List<String> lines, int from, int to)
      Whether every line in [from, to) is a comment line or blank; false when out of range.
    • lastLine

      private static int lastLine(org.yaml.snakeyaml.nodes.Node node, List<String> lines)
      The last line of node in lines. A block scalar (|, >) ends at its last content line: SnakeYAML's end mark lies after the line breaks it consumed, which include the blank or whitespace-only lines that follow a clipped or stripped value; those lines are the operator's separators, not the value, and are never owned. With keep chomping (|+, >+) the blank lines the value keeps stay part of it (owned-span rule revision 1, review round 1 IN-02).
    • lastLine

      private static int lastLine(org.yaml.snakeyaml.nodes.NodeTuple tuple, List<String> lines)
      A key with no value token ends on its key's line (#628). Its zero-text value's mark may sit on the next key after a comment; that mark is not operator content and must not extend this key or its parent mapping. The key itself still occupies its full span; explicit null tokens and collection entries are unchanged.
    • withoutSeparatorLines

      private static int withoutSeparatorLines(org.yaml.snakeyaml.nodes.ScalarNode scalar, int start, int end, List<String> lines)
    • isFlow

      private static boolean isFlow(org.yaml.snakeyaml.nodes.Node node)
    • find

      private static org.yaml.snakeyaml.nodes.NodeTuple find(org.yaml.snakeyaml.nodes.Node tree, List<String> path)
      The tuple of the key at path (the last duplicate wins, as on read), or null.
    • nodesByLine

      private static Map<Integer,List<List<Object>>> nodesByLine(org.yaml.snakeyaml.nodes.Node tree, List<String> lines)
      For every line, the paths of the nodes on it: keys, scalars and flow collections (a list index is an Integer).
    • collect

      private static void collect(org.yaml.snakeyaml.nodes.Node node, List<Object> path, Map<Integer,List<List<Object>>> result, List<String> lines)
    • record

      private static void record(org.yaml.snakeyaml.nodes.Node node, List<Object> path, Map<Integer,List<List<Object>>> result, List<String> lines)
    • startsWith

      private static boolean startsWith(List<Object> path, List<String> prefix)
    • unowned

      private static List<Integer> unowned(boolean[] owned)
    • usesAnchors

      private static boolean usesAnchors(org.yaml.snakeyaml.nodes.Node tree)
      Anchors, aliases (a node reached twice) and merge keys; an empty document (null tree) has none.
    • usesAnchors

      private static boolean usesAnchors(org.yaml.snakeyaml.nodes.Node node, Set<org.yaml.snakeyaml.nodes.Node> seen)
    • lines

      static List<String> lines(String text)
      Splits text after every line break SnakeYAML counts (\r\n, \r, \n, U+0085, U+2028, U+2029), keeping the break with its line, so a node mark's line indexes this list.
    • hasByteOrderMark

      private static boolean hasByteOrderMark(String text)
    • endsWithLineBreak

      private static boolean endsWithLineBreak(String text)
    • compose

      private static org.yaml.snakeyaml.nodes.Node compose(String text)
    • identity

      private static String identity(org.yaml.snakeyaml.nodes.Node key)
    • parseOrNull

      private static ConfigDocument parseOrNull(String text)
    • readOrNull

      private static byte[] readOrNull(Path file) throws IOException
      Throws:
      IOException
    • decode

      private static String decode(byte[] bytes)
    • describe

      private static List<String> describe(List<List<String>> values, List<List<String>> comments)
      Key names for a warning: a key below a secret-shaped key is redacted; comment paths are marked.
    • redacted

      private static String redacted(List<String> path)