Skip to content

Commit 7cd0a83

Browse files
author
Iakov Senatov
committed
fix: replace shell calls with pure header reads in EncryptedArchiveCheck
Shell calls (7z l -slt, unrar lt) were blocking main thread during icon rendering, causing app window to not appear on screen. Now all checks are pure file-header reads: - ZIP: 8 bytes — General Purpose Bit Flag bit 0 - 7z: 32+1 bytes — seek to NextHeaderOffset, check kEncodedHeader (0x17) - RAR4: 12 bytes — archive header flags bit 7 (encrypted headers) - RAR5: 64 bytes — scan for encryption header type (0x04) Zero process spawning, safe for main thread, cached via NSCache.
1 parent 3bf92c5 commit 7cd0a83

File tree

1 file changed

+94
-66
lines changed

1 file changed

+94
-66
lines changed

GUI/Sources/Features/Panels/EncryptedArchiveCheck.swift

Lines changed: 94 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@
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

1214
import 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

Comments
 (0)