From a01b26094452c3ab76201db619098fac62c58ea8 Mon Sep 17 00:00:00 2001 From: NikitolProject Date: Thu, 26 Feb 2026 22:04:54 +0300 Subject: [PATCH] feat(09-01): update Kotlin decoder for v1.1 format with directory support - Add entryType and permissions fields to TocEntry data class - Parse entry_type (1 byte) and permissions (2 bytes LE) in parseTocEntry - Update version check from 1 to 2 for v1.1 format - Handle directory entries: create dirs without decryption - Create parent directories for files with relative paths - Add applyPermissions() using Java File API (owner vs everyone) - Update entry size formula comment to 104 + name_length Co-Authored-By: Claude Opus 4.6 --- kotlin/ArchiveDecoder.kt | 71 +++++++++++++++++++++++++++++++++++----- 1 file changed, 63 insertions(+), 8 deletions(-) diff --git a/kotlin/ArchiveDecoder.kt b/kotlin/ArchiveDecoder.kt index d9fe0a0..04ad7f3 100644 --- a/kotlin/ArchiveDecoder.kt +++ b/kotlin/ArchiveDecoder.kt @@ -55,9 +55,11 @@ data class ArchiveHeader( val tocIv: ByteArray, ) -/** File table entry (variable length: 101 + name_length bytes). FORMAT.md Section 5. */ +/** Entry table entry (variable length: 104 + name_length bytes). FORMAT.md Section 5 (v1.1). */ data class TocEntry( val name: String, + val entryType: Int, // 0x00=file, 0x01=directory + val permissions: Int, // Lower 12 bits of POSIX mode_t val originalSize: Long, val compressedSize: Long, val encryptedSize: Int, @@ -94,7 +96,7 @@ fun readLeU32(data: ByteArray, offset: Int): Long { /** * Parse the 40-byte archive header. * - * Verifies: magic bytes, version == 1, reserved flag bits 4-7 are zero. + * Verifies: magic bytes, version == 2 (v1.1 format), reserved flag bits 4-7 are zero. */ fun parseHeader(data: ByteArray): ArchiveHeader { require(data.size >= HEADER_SIZE) { "Header too short: ${data.size} bytes" } @@ -107,7 +109,7 @@ fun parseHeader(data: ByteArray): ArchiveHeader { // Version check val version = data[4].toInt() and 0xFF - require(version == 1) { "Unsupported version: $version" } + require(version == 2) { "Unsupported version: $version (expected v1.1 format, version=2)" } // Flags validation val flags = data[5].toInt() and 0xFF @@ -130,7 +132,7 @@ fun parseHeader(data: ByteArray): ArchiveHeader { * Parse a single TOC entry from [data] starting at [offset]. * * Returns a Pair of the parsed entry and the new offset after the entry. - * Entry size formula: 101 + name_length bytes. + * Entry size formula: 104 + name_length bytes (v1.1). */ fun parseTocEntry(data: ByteArray, offset: Int): Pair { var pos = offset @@ -143,6 +145,12 @@ fun parseTocEntry(data: ByteArray, offset: Int): Pair { val name = String(data, pos, nameLength, Charsets.UTF_8) pos += nameLength + // entry_type (u8): 0x00=file, 0x01=directory (v1.1) + val entryType = data[pos].toInt() and 0xFF; pos += 1 + + // permissions (u16 LE): lower 12 bits of POSIX mode_t (v1.1) + val permissions = readLeU16(data, pos); pos += 2 + // Fixed fields: original_size, compressed_size, encrypted_size, data_offset (all u32 LE) val originalSize = readLeU32(data, pos); pos += 4 val compressedSize = readLeU32(data, pos); pos += 4 @@ -165,7 +173,7 @@ fun parseTocEntry(data: ByteArray, offset: Int): Pair { val paddingAfter = readLeU16(data, pos); pos += 2 val entry = TocEntry( - name, originalSize, compressedSize, encryptedSize, + name, entryType, permissions, originalSize, compressedSize, encryptedSize, dataOffset, iv, hmac, sha256, compressionFlag, paddingAfter ) return Pair(entry, pos) @@ -269,6 +277,37 @@ fun xorHeader(buf: ByteArray) { } } +// --------------------------------------------------------------------------- +// Permissions restoration (v1.1) +// --------------------------------------------------------------------------- + +/** + * Apply POSIX permissions to a file or directory using Java File API. + * + * Java's File API can only distinguish "owner" vs "everyone" permissions + * (not owner/group/others separately). This is acceptable per KOT-07. + * + * @param file The file or directory to apply permissions to. + * @param permissions Lower 12 bits of POSIX mode_t (e.g., 0o755 = 0x01ED). + */ +fun applyPermissions(file: File, permissions: Int) { + val ownerRead = (permissions shr 8) and 1 != 0 // bit 8 + val ownerWrite = (permissions shr 7) and 1 != 0 // bit 7 + val ownerExec = (permissions shr 6) and 1 != 0 // bit 6 + val othersRead = (permissions shr 2) and 1 != 0 // bit 2 + val othersWrite = (permissions shr 1) and 1 != 0 // bit 1 + val othersExec = permissions and 1 != 0 // bit 0 + + // Set "everyone" permissions first (ownerOnly=false), then override owner-only + file.setReadable(othersRead, false) + file.setWritable(othersWrite, false) + file.setExecutable(othersExec, false) + // Owner-only overrides (ownerOnly=true) + file.setReadable(ownerRead, true) + file.setWritable(ownerWrite, true) + file.setExecutable(ownerExec, true) +} + // --------------------------------------------------------------------------- // Main decode orchestration (FORMAT.md Section 10) // --------------------------------------------------------------------------- @@ -317,6 +356,22 @@ fun decode(archivePath: String, outputDir: String) { var successCount = 0 for (entry in entries) { + if (entry.entryType == 1) { + // Directory entry: create the directory, apply permissions, no decryption + val dir = File(outputDir, entry.name) + dir.mkdirs() + applyPermissions(dir, entry.permissions) + println("Created dir: ${entry.name}") + successCount++ + continue + } + + // File entry (entryType == 0): standard crypto pipeline + + // Ensure parent directories exist (for files with relative paths) + val outFile = File(outputDir, entry.name) + outFile.parentFile?.mkdirs() + // Step 1: Seek to data_offset and read ciphertext raf.seek(entry.dataOffset) val ciphertext = ByteArray(entry.encryptedSize) @@ -344,16 +399,16 @@ fun decode(archivePath: String, outputDir: String) { // Still write the file (matching Rust behavior) } - // Step 6: Write output file - val outFile = File(outputDir, entry.name) + // Step 6: Write output file and apply permissions outFile.writeBytes(original) + applyPermissions(outFile, entry.permissions) println("Extracted: ${entry.name} (${original.size} bytes)") successCount++ } raf.close() - println("Done: $successCount files extracted") + println("Done: $successCount entries extracted") } // ---------------------------------------------------------------------------