@@ -4,24 +4,69 @@ import android.content.Context
44import java.io.File
55import java.io.FileOutputStream
66import java.io.IOException
7+ import java.io.RandomAccessFile
78import java.text.SimpleDateFormat
89import java.util.Date
910import java.util.Locale
11+ import java.security.MessageDigest
12+ import java.util.zip.ZipEntry
13+ import java.util.zip.ZipOutputStream
1014
1115/* * Writes one bounded diagnostic report to an explicitly selected removable volume. */
1216object DiagnosticExporter {
1317
1418 internal const val MAX_REPORT_BYTES = 128 * 1024
19+ internal const val MAX_EVIDENCE_FILES = 8
20+ internal const val MAX_EVIDENCE_BYTES = 64 * 1024
21+ internal const val MAX_BUNDLE_BYTES = 768 * 1024
1522 internal const val TRUNCATION_MARKER = " \n [report truncated at 128 KiB]\n "
1623
1724 fun export (context : Context , report : String , chosenDirectory : File ): File ? {
1825 val target = DiagnosticUsbStorage .writableTarget(context, chosenDirectory) ? : return null
19- return write(report, target)
26+ return writeBundle(report, File (context.filesDir, " evidence" ), target)
27+ }
28+
29+ /* *
30+ * One self-contained USB artifact: readable report plus newest bounded CP-003 captures.
31+ * Nothing is copied back to internal storage, and malformed/oversized capture files are
32+ * listed as skipped rather than allowed to grow the bundle without limit.
33+ */
34+ internal fun writeBundle (
35+ report : String ,
36+ evidenceDirectory : File ,
37+ directory : File ,
38+ nowMs : Long = System .currentTimeMillis(),
39+ ): File ? {
40+ if (! directory.isDirectory) return null
41+ val target = uniqueTarget(directory, fileStem(nowMs), " zip" ) ? : return null
42+ val temp = File (directory, " .${target.name} .${System .nanoTime()} .tmp" )
43+ return try {
44+ val reportBytes = bounded(report).toByteArray(Charsets .UTF_8 )
45+ val evidence = evidenceFiles(evidenceDirectory)
46+ val manifest = manifest(nowMs, reportBytes, evidence)
47+ FileOutputStream (temp).use { output ->
48+ ZipOutputStream (output.buffered()).use { zip ->
49+ zip.writeEntry(" manifest.txt" , manifest.toByteArray(Charsets .UTF_8 ), nowMs)
50+ zip.writeEntry(" diagnostic.txt" , reportBytes, nowMs)
51+ evidence.included.forEach { artifact ->
52+ zip.writeEntry(" evidence/${artifact.file.name} " , artifact.bytes, artifact.modifiedMs)
53+ }
54+ }
55+ }
56+ RandomAccessFile (temp, " rw" ).use { it.fd.sync() }
57+ if (temp.length() > MAX_BUNDLE_BYTES || ! temp.renameTo(target)) null else target
58+ } catch (_: IOException ) {
59+ null
60+ } catch (_: SecurityException ) {
61+ null
62+ } finally {
63+ if (temp.exists()) temp.delete()
64+ }
2065 }
2166
2267 internal fun write (report : String , directory : File , nowMs : Long = System .currentTimeMillis()): File ? {
2368 if (! directory.isDirectory) return null
24- val target = uniqueTarget(directory, fileStem(nowMs)) ? : return null
69+ val target = uniqueTarget(directory, fileStem(nowMs), " txt " ) ? : return null
2570 val temp = File (directory, " .${target.name} .${System .nanoTime()} .tmp" )
2671 return try {
2772 val bytes = bounded(report).toByteArray(Charsets .UTF_8 )
@@ -43,15 +88,86 @@ object DiagnosticExporter {
4388 private fun fileStem (nowMs : Long ): String = " evchargepilot-diagnostic-" +
4489 SimpleDateFormat (" yyyyMMdd-HHmmss" , Locale .US ).format(Date (nowMs))
4590
46- private fun uniqueTarget (directory : File , stem : String ): File ? {
91+ private fun uniqueTarget (directory : File , stem : String , extension : String ): File ? {
4792 repeat(MAX_SAME_SECOND_EXPORTS ) { suffix ->
48- val name = if (suffix == 0 ) " $stem .txt " else " $stem -$suffix .txt "
93+ val name = if (suffix == 0 ) " $stem .$extension " else " $stem -$suffix .$extension "
4994 File (directory, name).takeUnless (File ::exists)?.let { return it }
5095 }
5196 return null
5297 }
5398
99+ private fun evidenceFiles (directory : File ): EvidenceSelection {
100+ val candidates = runCatching { directory.listFiles() }
101+ .getOrNull()
102+ ?.filter { file ->
103+ file.isFile && file.extension == " json" && SAFE_FILE_NAME .matches(file.name)
104+ }
105+ ?.sortedWith(compareByDescending<File > { it.lastModified() }.thenByDescending(File ::getName))
106+ .orEmpty()
107+ .take(MAX_EVIDENCE_FILES )
108+ val included = mutableListOf<EvidenceArtifact >()
109+ val skipped = mutableListOf<String >()
110+ candidates.forEach { file ->
111+ if (file.length() !in 1 .. MAX_EVIDENCE_BYTES .toLong()) {
112+ skipped + = " ${file.name} : size=${file.length()} "
113+ return @forEach
114+ }
115+ val bytes = runCatching { file.readBytes() }.getOrNull()
116+ if (bytes == null || bytes.size !in 1 .. MAX_EVIDENCE_BYTES ) {
117+ skipped + = " ${file.name} : unreadable-or-size-changed"
118+ } else {
119+ included + = EvidenceArtifact (file, bytes, file.lastModified())
120+ }
121+ }
122+ return EvidenceSelection (included, skipped)
123+ }
124+
125+ private fun manifest (
126+ nowMs : Long ,
127+ report : ByteArray ,
128+ evidence : EvidenceSelection ,
129+ ): String = buildString {
130+ appendLine(" schema=1" )
131+ appendLine(" created_epoch_ms=$nowMs " )
132+ appendLine(" bundle_limit_bytes=$MAX_BUNDLE_BYTES " )
133+ appendLine(" diagnostic_bytes=${report.size} " )
134+ appendLine(" diagnostic_sha256=${sha256(report)} " )
135+ appendLine(" evidence_included=${evidence.included.size} " )
136+ appendLine(" evidence_skipped=${evidence.skipped.size} " )
137+ evidence.included.forEachIndexed { index, artifact ->
138+ appendLine(" evidence.$index .name=${artifact.file.name} " )
139+ appendLine(" evidence.$index .bytes=${artifact.bytes.size} " )
140+ appendLine(" evidence.$index .modified_epoch_ms=${artifact.modifiedMs} " )
141+ appendLine(" evidence.$index .sha256=${sha256(artifact.bytes)} " )
142+ }
143+ evidence.skipped.forEachIndexed { index, reason ->
144+ appendLine(" skipped.$index =$reason " )
145+ }
146+ }
147+
148+ private fun ZipOutputStream.writeEntry (name : String , bytes : ByteArray , modifiedMs : Long ) {
149+ putNextEntry(ZipEntry (name).apply { time = modifiedMs.coerceAtLeast(0L ) })
150+ write(bytes)
151+ closeEntry()
152+ }
153+
154+ internal fun sha256 (bytes : ByteArray ): String = MessageDigest .getInstance(" SHA-256" )
155+ .digest(bytes)
156+ .joinToString(" " ) { " %02x" .format(Locale .ROOT , it) }
157+
54158 private const val MAX_SAME_SECOND_EXPORTS = 100
159+ private val SAFE_FILE_NAME = Regex (" ^[A-Za-z0-9._-]+\\ .json$" )
160+
161+ private data class EvidenceArtifact (
162+ val file : File ,
163+ val bytes : ByteArray ,
164+ val modifiedMs : Long ,
165+ )
166+
167+ private data class EvidenceSelection (
168+ val included : List <EvidenceArtifact >,
169+ val skipped : List <String >,
170+ )
55171
56172 /* * Keeps the report head and a visible marker without splitting a UTF-8 sequence. */
57173 internal fun bounded (report : String ): String {
@@ -62,6 +178,17 @@ object DiagnosticExporter {
62178 while (end > 0 && bytes[end].toInt() and 0xC0 == 0x80 ) end--
63179 return String (bytes, 0 , end, Charsets .UTF_8 ) + TRUNCATION_MARKER
64180 }
181+
182+ /* * Keeps newest events for a bounded log section without cutting a UTF-8 sequence. */
183+ internal fun boundedTail (content : String , maxBytes : Int , marker : String ): String {
184+ val bytes = content.toByteArray(Charsets .UTF_8 )
185+ if (bytes.size <= maxBytes) return content
186+ val markerBytes = marker.toByteArray(Charsets .UTF_8 )
187+ if (markerBytes.size >= maxBytes) return String (markerBytes, 0 , maxBytes, Charsets .UTF_8 )
188+ var start = bytes.size - (maxBytes - markerBytes.size)
189+ while (start < bytes.size && bytes[start].toInt() and 0xC0 == 0x80 ) start++
190+ return marker + String (bytes, start, bytes.size - start, Charsets .UTF_8 )
191+ }
65192}
66193
67194/* * Pure fail-closed parked gate, kept separate so stale-sample behavior is JVM-tested. */
0 commit comments