Skip to content

Commit 3bf92c5

Browse files
author
Iakov Senatov
committed
feat: encrypted archive detection for 7z/RAR + error alert on open
EncryptedArchiveCheck: - ZIP: 8-byte header read (instant) - 7z: shell 7z l -slt, detect exit!=0 or Encrypted=+ (cached) - RAR: shell unrar lt, fallback to 7z (cached) - All other archive formats: checked via 7z if available - Results cached via NSCache per file path AppState.enterArchive: - Show NSAlert on failure instead of silently swallowing error - Encrypted archives: specific message about password protection - Other errors: show error description to user
1 parent 8c0b650 commit 3bf92c5

File tree

3 files changed

+112
-11
lines changed

3 files changed

+112
-11
lines changed

GUI/Resources/Localizable.xcstrings

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -598,6 +598,10 @@
598598
"Copy All Paths" : {
599599
"comment" : "Copy all paths action"
600600
},
601+
"Copy as Pathname" : {
602+
"comment" : "A button that copies the full path of a file as a pathname to the clipboard.",
603+
"isCommentAutoGenerated" : true
604+
},
601605
"Copy File?" : {
602606
"comment" : "Copy confirmation dialog title",
603607
"localizations" : {

GUI/Sources/Features/Panels/EncryptedArchiveCheck.swift

Lines changed: 89 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,11 @@
33
//
44
// Created by Claude on 03.03.2026.
55
// Copyright © 2026 Senatov. All rights reserved.
6-
// Description: Lightweight encrypted archive detection for icon display.
6+
// Description: Encrypted archive detection for icon display.
77
// ZIP: reads 8 bytes from Local File Header (bit 0 of General Purpose Flag).
8-
// Other formats: not checked (would require ArchiveKit dependency).
9-
// Results cached via NSCache — zero repeated I/O cost.
8+
// 7z: runs `7z l -slt` — checks exit code and "Encrypted = +" marker.
9+
// RAR: runs `unrar lt` — checks for encryption markers, fallback to 7z.
10+
// Results cached via NSCache — zero repeated I/O cost after first check.
1011

1112
import FileModelKit
1213
import Foundation
@@ -16,28 +17,105 @@ enum EncryptedArchiveCheck {
1617
// MARK: - Cache (NSCache is internally thread-safe)
1718
nonisolated(unsafe) private static let cache = NSCache<NSString, NSNumber>()
1819
// MARK: - Public API
19-
/// Returns true if archive is encrypted. Currently supports ZIP only.
20-
/// Returns false for non-ZIP archives (not nil — safe for icon logic).
20+
/// Returns true if archive is encrypted.
21+
/// Supports ZIP (instant), 7z, RAR (via shell, cached).
2122
static func isEncrypted(url: URL) -> Bool {
2223
let key = url.path as NSString
2324
if let cached = cache.object(forKey: key) {
2425
return cached.boolValue
2526
}
26-
let result = checkZip(url: url)
27+
let result = detect(url: url)
2728
cache.setObject(NSNumber(value: result), forKey: key)
2829
return result
2930
}
30-
// MARK: - ZIP Check
31+
/// Invalidate cache for a specific file (e.g. after modification)
32+
static func invalidate(url: URL) {
33+
cache.removeObject(forKey: url.path as NSString)
34+
}
35+
// MARK: - Detection Router
36+
private static func detect(url: URL) -> Bool {
37+
let ext = url.pathExtension.lowercased()
38+
switch ext {
39+
case "zip":
40+
return checkZipHeader(url: url) || checkVia7z(url: url)
41+
case "7z":
42+
return checkVia7z(url: url)
43+
case "rar":
44+
return checkViaUnrar(url: url) ?? checkVia7z(url: url)
45+
default:
46+
// Other archive formats handled by 7z (cab, arj, etc.)
47+
if ArchiveExtensions.isArchive(ext) {
48+
return checkVia7z(url: url)
49+
}
50+
return false
51+
}
52+
}
53+
// MARK: - ZIP Header Check
3154
/// Reads PK signature + General Purpose Bit Flag. Bit 0 = encrypted.
32-
private static func checkZip(url: URL) -> Bool {
33-
guard url.pathExtension.lowercased() == "zip" else { return false }
55+
/// Cost: 8 bytes, instant.
56+
private static func checkZipHeader(url: URL) -> Bool {
3457
guard let handle = try? FileHandle(forReadingFrom: url) else { return false }
3558
defer { try? handle.close() }
3659
guard let data = try? handle.read(upToCount: 8), data.count >= 8 else { return false }
37-
// Verify PK\x03\x04 signature
3860
guard data[0] == 0x50, data[1] == 0x4B, data[2] == 0x03, data[3] == 0x04 else { return false }
39-
// General Purpose Bit Flag at offset 6 (little-endian)
4061
let flags = UInt16(data[6]) | (UInt16(data[7]) << 8)
4162
return (flags & 0x0001) != 0
4263
}
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+
}
76+
return false
77+
}
78+
// Content-encrypted: individual entries marked
79+
return stdout.contains("Encrypted = +")
80+
}
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
92+
}
93+
if exit == 0 { return false }
94+
return nil // inconclusive, let caller fallback to 7z
95+
}
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+
)
115+
}
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) }
120+
}
43121
}

GUI/Sources/States/AppState/AppState.swift

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -409,6 +409,7 @@ extension AppState {
409409
log.info("[AppState] Successfully entered archive: \(archiveURL.lastPathComponent)")
410410
} catch {
411411
log.error("[AppState] Failed to enter archive: \(error.localizedDescription)")
412+
await showArchiveErrorAlert(archiveName: archiveURL.lastPathComponent, error: error)
412413
}
413414
}
414415

@@ -456,6 +457,24 @@ extension AppState {
456457
}
457458
}
458459

460+
/// Shows NSAlert when archive open fails (encrypted, corrupted, etc.)
461+
@MainActor
462+
private func showArchiveErrorAlert(archiveName: String, error: Error) async {
463+
let desc = error.localizedDescription
464+
let isEncrypted = desc.lowercased().contains("password") || desc.lowercased().contains("encrypted")
465+
let alert = NSAlert()
466+
alert.alertStyle = .critical
467+
if isEncrypted {
468+
alert.messageText = "Encrypted Archive"
469+
alert.informativeText = "\"\(archiveName)\" is password-protected.\n\nPassword-protected archives cannot be opened yet."
470+
} else {
471+
alert.messageText = "Cannot Open Archive"
472+
alert.informativeText = "\"\(archiveName)\" could not be opened.\n\n\(desc)"
473+
}
474+
alert.addButton(withTitle: "OK")
475+
alert.runModal()
476+
}
477+
459478
/// Shows NSAlert asking user whether to repack the modified archive.
460479
/// Returns true if user chose to repack.
461480
@MainActor

0 commit comments

Comments
 (0)