001package com.streamconverter.sloc.command;
002
003import com.streamconverter.command.AbstractStreamCommand;
004import java.io.BufferedInputStream;
005import java.io.IOException;
006import java.io.InputStream;
007import java.io.OutputStream;
008import java.nio.charset.StandardCharsets;
009import java.nio.file.Files;
010import java.nio.file.Path;
011
012/**
013 * モジュール名リストを読み込み、各モジュールの JaCoCo XML ファイルを連結して出力するコマンド。
014 *
015 * <p>入力: モジュール名を1行ずつ列挙したテキスト(プロジェクトルートからの相対パス解決に使用)
016 *
017 * <pre>
018 * streamconverter-core
019 * streamconverter-db
020 * ...
021 * </pre>
022 *
023 * <p>出力: 各モジュールの {@code <report>} 要素を連結したストリーム。XML宣言・DOCTYPE宣言は全モジュールで除去し、 先頭に {@code <?xml
024 * version="1.0" encoding="UTF-8"?>} を1つだけ付与する。結果は複数のルート要素を持つ Well-formed ではない XML となるが、{@link
025 * JacocoXmlToModuleSlocCommand} が {@code <jacoco-reports>} ラッパーで包んでパースするため実用上は問題ない。
026 *
027 * <p><b>YAGNI:</b> 厳密な Well-formed XML が必要になった場合は、ラッパー要素で包む対応が容易にできる。
028 */
029public class ModuleXmlConcatCommand extends AbstractStreamCommand {
030
031  static final String JACOCO_XML_PATH = "build/reports/jacoco/test/jacocoTestReport.xml";
032
033  private final Path projectRoot;
034
035  /**
036   * @param projectRoot JaCoCo XML を探すプロジェクトルートディレクトリ
037   */
038  public ModuleXmlConcatCommand(Path projectRoot) {
039    this.projectRoot = projectRoot;
040  }
041
042  @Override
043  public void execute(InputStream input, OutputStream output) throws IOException {
044    Path normalizedRoot = projectRoot.toAbsolutePath().normalize();
045    try (var reader =
046        new java.io.BufferedReader(new java.io.InputStreamReader(input, StandardCharsets.UTF_8))) {
047      String moduleName;
048      while ((moduleName = reader.readLine()) != null) {
049        if (moduleName.isBlank()) {
050          continue;
051        }
052        Path xmlPath = normalizedRoot.resolve(moduleName).resolve(JACOCO_XML_PATH).normalize();
053        if (!xmlPath.startsWith(normalizedRoot)) {
054          log.error(
055              "Path traversal detected: resolved path '{}' escapes project root '{}' — aborting",
056              xmlPath,
057              normalizedRoot);
058          throw new IOException("Path traversal detected: resolved path escapes project root");
059        }
060        if (!Files.exists(xmlPath)) {
061          log.warn("report not found, skipping: {}", xmlPath);
062          continue;
063        }
064        writeReportOnly(xmlPath, output);
065        output.write('\n');
066        output.flush();
067      }
068    }
069  }
070
071  /**
072   * JaCoCo XML ファイルから XML宣言・DOCTYPE宣言を除去して {@code <report>} 要素のみ書き出す。
073   *
074   * <p>JaCoCo の出力は {@code <?xml...?><!DOCTYPE...><report...>} が1行に連結されているため、 バイト単位で {@code <}
075   * を探しながら宣言部分をスキップする。
076   */
077  private void writeReportOnly(Path xmlPath, OutputStream output) throws IOException {
078    try (InputStream in = new BufferedInputStream(Files.newInputStream(xmlPath))) {
079      skipPrologues(in);
080      in.transferTo(output);
081    }
082  }
083
084  /** ストリーム先頭の {@code <?...?>} および {@code <!...>} を全てスキップし、最初の {@code <[a-zA-Z]} の直前まで読み進める。 */
085  private void skipPrologues(InputStream in) throws IOException {
086    while (true) {
087      in.mark(2);
088      int c1 = in.read();
089      if (c1 != '<') {
090        if (c1 != -1) in.reset();
091        return;
092      }
093      int c2 = in.read();
094      if (c2 == '?') {
095        // <?...?> をスキップ
096        skipUntil(in, '?', '>');
097      } else if (c2 == '!') {
098        // <!...> をスキップ
099        skipUntil(in, '\0', '>');
100      } else {
101        // 通常の要素開始タグ — 巻き戻して返す
102        in.reset();
103        return;
104      }
105    }
106  }
107
108  /** {@code end} の直前が {@code pre}('\0' は任意)になるまで読み飛ばす。 */
109  private void skipUntil(InputStream in, char pre, char end) throws IOException {
110    int prev = -1;
111    int cur;
112    while ((cur = in.read()) != -1) {
113      if (cur == end && (pre == '\0' || prev == pre)) return;
114      prev = cur;
115    }
116  }
117}