55// Copyright © 2026 Senatov. All rights reserved.
66// Description: Encrypted archive detection for icon display.
77// ZIP: reads 8 bytes from Local File Header (bit 0 of General Purpose Flag).
8- // 7z: runs `7z l -slt` — checks exit code and "Encrypted = +" marker.
9- // RAR: runs `unrar lt` — checks for encryption markers, fallback to 7z.
8+ // 7z: reads 32 bytes — if header starts with 7z signature, checks
9+ // NID_Header (byte at offset 6) and encoded header flags.
10+ // RAR: reads 14 bytes — checks encryption flag in archive header.
11+ // All checks are pure file reads, NO shell calls, NO process spawning.
1012// Results cached via NSCache — zero repeated I/O cost after first check.
1113
1214import FileModelKit
@@ -18,7 +20,7 @@ enum EncryptedArchiveCheck {
1820 nonisolated ( unsafe) private static let cache = NSCache < NSString , NSNumber > ( )
1921 // MARK: - Public API
2022 /// Returns true if archive is encrypted.
21- /// Supports ZIP (instant), 7z, RAR (via shell, cached) .
23+ /// Pure file-header reads only — no shell calls, safe for main thread .
2224 static func isEncrypted( url: URL ) -> Bool {
2325 let key = url. path as NSString
2426 if let cached = cache. object ( forKey: key) {
@@ -28,7 +30,7 @@ enum EncryptedArchiveCheck {
2830 cache. setObject ( NSNumber ( value: result) , forKey: key)
2931 return result
3032 }
31- /// Invalidate cache for a specific file (e.g. after modification)
33+ /// Invalidate cache for a specific file
3234 static func invalidate( url: URL ) {
3335 cache. removeObject ( forKey: url. path as NSString )
3436 }
@@ -37,85 +39,111 @@ enum EncryptedArchiveCheck {
3739 let ext = url. pathExtension. lowercased ( )
3840 switch ext {
3941 case " zip " :
40- return checkZipHeader ( url: url) || checkVia7z ( url : url )
42+ return checkZipHeader ( url: url)
4143 case " 7z " :
42- return checkVia7z ( url: url)
44+ return check7zHeader ( url: url)
4345 case " rar " :
44- return checkViaUnrar ( url : url ) ?? checkVia7z ( url: url)
46+ return checkRarHeader ( url: url)
4547 default :
46- // Other archive formats handled by 7z (cab, arj, etc.)
47- if ArchiveExtensions . isArchive ( ext) {
48- return checkVia7z ( url: url)
49- }
5048 return false
5149 }
5250 }
5351 // MARK: - ZIP Header Check
54- /// Reads PK signature + General Purpose Bit Flag. Bit 0 = encrypted.
55- /// Cost: 8 bytes, instant.
52+ /// Reads PK\x03\x04 + General Purpose Bit Flag (bit 0). Cost: 8 bytes.
5653 private static func checkZipHeader( url: URL ) -> Bool {
57- guard let handle = try ? FileHandle ( forReadingFrom: url) else { return false }
58- defer { try ? handle. close ( ) }
59- guard let data = try ? handle. read ( upToCount: 8 ) , data. count >= 8 else { return false }
54+ guard let data = readBytes ( url: url, count: 8 ) , data. count >= 8 else { return false }
6055 guard data [ 0 ] == 0x50 , data [ 1 ] == 0x4B , data [ 2 ] == 0x03 , data [ 3 ] == 0x04 else { return false }
6156 let flags = UInt16 ( data [ 6 ] ) | ( UInt16 ( data [ 7 ] ) << 8 )
6257 return ( flags & 0x0001 ) != 0
6358 }
64- // MARK: - 7z Shell Check
65- /// Runs `7z l -slt <archive>`. Encrypted header → exit != 0.
66- /// Encrypted content → output contains "Encrypted = +".
67- private static func checkVia7z( url: URL ) -> Bool {
68- guard let bin = findExecutable ( " 7z " ) else { return false }
69- let ( exit, stdout, stderr) = shell ( bin, " l " , " -slt " , url. path)
70- // Non-zero exit with password/encrypted message → header-encrypted
71- if exit != 0 {
72- let combined = ( stdout + stderr) . lowercased ( )
73- if combined. contains ( " password " ) || combined. contains ( " encrypted " ) || combined. contains ( " headers error " ) {
74- return true
75- }
59+ // MARK: - 7z Header Check
60+ /// 7z signature: 37 7A BC AF 27 1C (6 bytes), then 2 bytes version, then
61+ /// 4 bytes StartHeaderCRC, 8 bytes NextHeaderOffset, 8 bytes NextHeaderSize,
62+ /// 4 bytes NextHeaderCRC — total 32 bytes.
63+ /// If header is encrypted, 7z stores EncryptedHeader marker: the encoded header
64+ /// starts at NextHeaderOffset and if that's 0 or very small with non-zero size,
65+ /// it's a strong indicator. But the simplest reliable check:
66+ /// try to read the property IDs — encrypted 7z has kEncodedHeader (0x17) at start
67+ /// of the header block, while normal archives have kHeader (0x01).
68+ /// For icon purposes: read 32+16 bytes, seek to NextHeaderOffset, read first byte.
69+ private static func check7zHeader( url: URL ) -> Bool {
70+ guard let data = readBytes ( url: url, count: 32 ) , data. count >= 32 else { return false }
71+ // Verify 7z signature
72+ guard data [ 0 ] == 0x37 , data [ 1 ] == 0x7A , data [ 2 ] == 0xBC , data [ 3 ] == 0xAF ,
73+ data [ 4 ] == 0x27 , data [ 5 ] == 0x1C else { return false }
74+ // Read NextHeaderOffset (little-endian UInt64 at offset 12)
75+ let nextHeaderOffset = readUInt64LE ( data, offset: 12 )
76+ // Read NextHeaderSize (little-endian UInt64 at offset 20)
77+ let nextHeaderSize = readUInt64LE ( data, offset: 20 )
78+ // Sanity: if NextHeaderSize is 0 the archive is empty
79+ guard nextHeaderSize > 0 else { return false }
80+ // The encoded header starts at file offset 32 + NextHeaderOffset
81+ let headerFileOffset = 32 + nextHeaderOffset
82+ // Read first byte of the actual header block
83+ guard let handle = try ? FileHandle ( forReadingFrom: url) else { return false }
84+ defer { try ? handle. close ( ) }
85+ do {
86+ try handle. seek ( toOffset: headerFileOffset)
87+ guard let headerByte = try handle. read ( upToCount: 1 ) , headerByte. count == 1 else { return false }
88+ // 0x17 = kEncodedHeader → headers are encoded (encrypted)
89+ // 0x01 = kHeader → normal unencrypted header
90+ return headerByte [ 0 ] == 0x17
91+ } catch {
7692 return false
7793 }
78- // Content-encrypted: individual entries marked
79- return stdout. contains ( " Encrypted = + " )
8094 }
81- // MARK: - RAR Shell Check
82- /// Runs `unrar lt <archive>`. Returns nil if unrar not found.
83- private static func checkViaUnrar( url: URL ) -> Bool ? {
84- guard let bin = findExecutable ( " unrar " ) else { return nil }
85- let ( exit, stdout, stderr) = shell ( bin, " lt " , url. path)
86- let combined = ( stdout + stderr) . lowercased ( )
87- if exit != 0 && ( combined. contains ( " encrypted " ) || combined. contains ( " password " ) ) {
88- return true
89- }
90- if stdout. lowercased ( ) . contains ( " encrypted " ) {
91- return true
95+ // MARK: - RAR Header Check
96+ /// RAR5 signature: 52 61 72 21 1A 07 01 00 (8 bytes)
97+ /// RAR4 signature: 52 61 72 21 1A 07 00
98+ /// RAR5: encryption info is in a separate header block — check for
99+ /// encryption record (header type 4 = encryption header) following the main header.
100+ /// RAR4: archive flags at offset 10, bit 7 (0x0080) = encrypted headers.
101+ private static func checkRarHeader( url: URL ) -> Bool {
102+ guard let data = readBytes ( url: url, count: 20 ) , data. count >= 12 else { return false }
103+ // Check RAR signature
104+ guard data [ 0 ] == 0x52 , data [ 1 ] == 0x61 , data [ 2 ] == 0x72 , data [ 3 ] == 0x21 ,
105+ data [ 4 ] == 0x1A , data [ 5 ] == 0x07 else { return false }
106+ if data [ 6 ] == 0x00 {
107+ // RAR4: flags at offset 8-9 (after 7-byte signature)
108+ // Archive header flags at offset 10 (after 3-byte header type)
109+ // Simpler: check if there's a FILE_HEAD with PASSWORD flag
110+ // In the main archive header (type 0x73), flags at offset 9-10
111+ if data. count >= 13 {
112+ // Main archive header: CRC(2) TYPE(1) FLAGS(2) SIZE(2)
113+ // TYPE=0x73 at offset 9, FLAGS at offset 10-11
114+ let flags = UInt16 ( data [ 10 ] ) | ( UInt16 ( data [ 11 ] ) << 8 )
115+ // Bit 7 = headers are encrypted
116+ if ( flags & 0x0080 ) != 0 { return true }
117+ }
118+ } else if data [ 6 ] == 0x01 && data [ 7 ] == 0x00 {
119+ // RAR5: after 8-byte signature comes the archive header
120+ // Header CRC32(4), HeaderSize(vint), HeaderType(vint)
121+ // HeaderType 4 = Encryption header — if present before file headers,
122+ // the archive is encrypted.
123+ // Simple heuristic: scan next ~50 bytes for header type 4
124+ guard let extended = readBytes ( url: url, count: 64 ) , extended. count >= 20 else { return false }
125+ // Skip signature (8 bytes), check for encryption header type (0x04)
126+ for i in 8 ..< ( extended. count - 1 ) {
127+ // vint encoding: if high bit clear, it's the value directly
128+ if extended [ i] == 0x04 && i > 8 {
129+ return true
130+ }
131+ }
92132 }
93- if exit == 0 { return false }
94- return nil // inconclusive, let caller fallback to 7z
133+ return false
95134 }
96- // MARK: - Shell Helper
97- private static func shell( _ args: String ... ) -> ( Int32 , String , String ) {
98- let proc = Process ( )
99- let outPipe = Pipe ( )
100- let errPipe = Pipe ( )
101- proc. executableURL = URL ( fileURLWithPath: args [ 0 ] )
102- proc. arguments = Array ( args. dropFirst ( ) )
103- proc. standardOutput = outPipe
104- proc. standardError = errPipe
105- proc. environment = ProcessInfo . processInfo. environment
106- do { try proc. run ( ) } catch { return ( - 1 , " " , error. localizedDescription) }
107- proc. waitUntilExit ( )
108- let outData = outPipe. fileHandleForReading. readDataToEndOfFile ( )
109- let errData = errPipe. fileHandleForReading. readDataToEndOfFile ( )
110- return (
111- proc. terminationStatus,
112- String ( data: outData, encoding: . utf8) ?? " " ,
113- String ( data: errData, encoding: . utf8) ?? " "
114- )
135+ // MARK: - Helpers
136+ private static func readBytes( url: URL , count: Int ) -> Data ? {
137+ guard let handle = try ? FileHandle ( forReadingFrom: url) else { return nil }
138+ defer { try ? handle. close ( ) }
139+ return try ? handle. read ( upToCount: count)
115140 }
116- // MARK: - Find Executable
117- private static func findExecutable( _ name: String ) -> String ? {
118- [ " /opt/homebrew/bin/ \( name) " , " /usr/local/bin/ \( name) " , " /usr/bin/ \( name) " ]
119- . first { FileManager . default. isExecutableFile ( atPath: $0) }
141+ private static func readUInt64LE( _ data: Data , offset: Int ) -> UInt64 {
142+ guard offset + 8 <= data. count else { return 0 }
143+ var result : UInt64 = 0
144+ for i in 0 ..< 8 {
145+ result |= UInt64 ( data [ offset + i] ) << ( i * 8 )
146+ }
147+ return result
120148 }
121149}
0 commit comments