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}