ExperimentDocumentWorkspaceService.java

package org.hammer.audio.experiment.document.workspace;

import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Locale;
import java.util.Objects;
import org.hammer.audio.experiment.document.ExperimentDocumentPreview;
import org.hammer.audio.experiment.document.ExperimentDocumentService;
import org.hammer.audio.workflow.collaboration.WorkflowSessionRegistry;
import org.hammer.audio.workflow.editor.WorkflowEditorService;
import org.hammer.audio.workflow.editor.WorkflowProjection;

/** Coordinates confirmed portable-document application with the server-authoritative editor. */
public final class ExperimentDocumentWorkspaceService {

  private final ExperimentDocumentService documentService;
  private final WorkflowEditorService editorService;
  private final WorkflowSessionRegistry sessionRegistry;

  /** Create a coordinator without collaboration-session awareness for focused tests/tools. */
  public ExperimentDocumentWorkspaceService(
      ExperimentDocumentService documentService, WorkflowEditorService editorService) {
    this(documentService, editorService, null);
  }

  /** Create the application coordinator with collaboration-session protection. */
  public ExperimentDocumentWorkspaceService(
      ExperimentDocumentService documentService,
      WorkflowEditorService editorService,
      WorkflowSessionRegistry sessionRegistry) {
    this.documentService = Objects.requireNonNull(documentService, "documentService");
    this.editorService = Objects.requireNonNull(editorService, "editorService");
    this.sessionRegistry = sessionRegistry;
  }

  /**
   * Revalidate and atomically import one explicitly confirmed document.
   *
   * @param source untrusted request body
   * @param expectedCanonicalSha256 hash shown during preview and confirmed by the caller
   * @param discardDirty whether unsaved current state may be discarded
   * @return applied projection and immutable document identity
   */
  public ApplyResult apply(InputStream source, String expectedCanonicalSha256, boolean discardDirty)
      throws IOException {
    ExperimentDocumentPreview preview = documentService.preview(source);
    if (!preview.executionAllowed()) {
      throw new ExperimentDocumentApplyException(
          "document-incompatible", "Experiment document has blocking compatibility diagnostics");
    }
    if (preview.readOnly()) {
      throw new ExperimentDocumentApplyException(
          "document-read-only", "Experiment document is available for read-only inspection only");
    }
    assertNoActiveCollaboration();
    String expected = normalizeExpectedHash(expectedCanonicalSha256);
    if (!sameHash(expected, preview.canonicalSha256())) {
      throw new ExperimentDocumentApplyException(
          "document-hash-mismatch", "Preview hash no longer matches the uploaded document");
    }
    WorkflowProjection projection =
        editorService.importGraph(documentService.workflow(preview), discardDirty);
    return new ApplyResult(preview.canonicalSha256(), projection, true);
  }

  private void assertNoActiveCollaboration() {
    if (sessionRegistry == null) {
      return;
    }
    boolean active =
        sessionRegistry.sessions().stream().anyMatch(session -> !session.participants().isEmpty());
    if (active) {
      throw new ExperimentDocumentApplyException(
          "collaboration-active",
          "Leave active collaboration sessions before replacing the single-user workflow");
    }
  }

  private static String normalizeExpectedHash(String value) {
    String expected = Objects.requireNonNull(value, "expectedCanonicalSha256").trim();
    if (expected.startsWith("W/")) {
      throw new ExperimentDocumentApplyException(
          "invalid-document-hash", "Weak If-Match values are not accepted");
    }
    if (expected.length() >= 2 && expected.startsWith("\"") && expected.endsWith("\"")) {
      expected = expected.substring(1, expected.length() - 1);
    }
    expected = expected.toLowerCase(Locale.ROOT);
    if (expected.length() != 64
        || !expected.chars().allMatch(ExperimentDocumentWorkspaceService::hex)) {
      throw new ExperimentDocumentApplyException(
          "invalid-document-hash", "If-Match must contain one SHA-256 value");
    }
    return expected;
  }

  private static boolean hex(int character) {
    return character >= '0' && character <= '9' || character >= 'a' && character <= 'f';
  }

  private static boolean sameHash(String left, String right) {
    return MessageDigest.isEqual(
        left.getBytes(StandardCharsets.US_ASCII), right.getBytes(StandardCharsets.US_ASCII));
  }

  /**
   * Successful confirmed import response.
   *
   * @param canonicalSha256 canonical imported document digest
   * @param projection resulting server-authoritative workflow projection
   * @param dirty whether the imported workflow has uncheckpointed changes
   */
  public record ApplyResult(String canonicalSha256, WorkflowProjection projection, boolean dirty) {

    /* Validate immutable result values. */
    public ApplyResult {
      Objects.requireNonNull(canonicalSha256, "canonicalSha256");
      Objects.requireNonNull(projection, "projection");
    }
  }
}