From c515c8b04a5b92ca1a6ee94be14a50123f00733f Mon Sep 17 00:00:00 2001 From: Kurach Aleksandr Date: Fri, 19 Jun 2026 16:06:14 +0300 Subject: [PATCH 1/3] ci: add CodeQL SAST workflow (Swift, security-extended) Static application security testing on push/PR to main + weekly schedule. Builds the SwiftPM package on macos-15 and uploads results to code scanning. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/codeql.yml | 43 ++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 .github/workflows/codeql.yml diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..dad2eb6 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,43 @@ +name: CodeQL + +# Static application security testing (SAST). CodeQL builds the Swift package, +# analyzes it with the security-extended query suite, and uploads results to the +# repo's Security ▸ Code scanning tab. Swift analysis requires a macOS runner. + +on: + push: + branches: [main] + pull_request: + branches: [main] + schedule: + - cron: '23 5 * * 1' # weekly, Mondays 05:23 UTC — catches new queries on unchanged code + +concurrency: + group: codeql-${{ github.ref }} + cancel-in-progress: true + +jobs: + analyze: + name: Analyze (Swift) + runs-on: macos-15 + permissions: + security-events: write # upload SARIF to code scanning + actions: read + contents: read + steps: + - uses: actions/checkout@v5 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: swift + build-mode: autobuild + queries: security-extended + + - name: Autobuild + uses: github/codeql-action/autobuild@v3 + + - name: Analyze + uses: github/codeql-action/analyze@v3 + with: + category: "/language:swift" From 1d471fbccb22887260b87d6d74e55644b0260d01 Mon Sep 17 00:00:00 2001 From: Kurach Aleksandr Date: Tue, 23 Jun 2026 22:17:26 +0300 Subject: [PATCH 2/3] fix(volumes): classify mounted disk image as Virtual, not External SSD A mounted .dmg/.sparsebundle is a local ejectable APFS volume, so the classifier fell through to the External-drives branch and tagged it "External SSD". Detect it via DiskArbitration (DADeviceProtocol / DADeviceModel == "Disk Image") and map to .virtualVolume (the kind already existed but was never assigned). Adds VolumeClassifierTests covering the DMG case, the real-SSD case, and internal precedence. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 4 ++ Core/Sources/Core/VolumeDetection.swift | 23 ++++++--- .../CoreTests/VolumeClassifierTests.swift | 51 +++++++++++++++++++ 3 files changed, 72 insertions(+), 6 deletions(-) create mode 100644 Core/Tests/CoreTests/VolumeClassifierTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 23f7e36..1cea84a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,10 @@ the project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). dead `/Volumes` path forever; the cache is now validated and re-mounted on demand. - **Plugin column width** is clamped (an unbounded width could feed AppKit a runaway column frame — the same overflow class that once froze layout). +- **Mounted disk image misclassified as External SSD** — a mounted `.dmg`/`.sparsebundle` + is a local ejectable APFS volume, so volume detection tagged it "External SSD". It is now + recognized via DiskArbitration (`DADeviceProtocol`/`DADeviceModel` = "Disk Image") and + shown as a Virtual volume. ### Changed - **File-type color tiles + name tint are OFF by default** — a calm, native list out of diff --git a/Core/Sources/Core/VolumeDetection.swift b/Core/Sources/Core/VolumeDetection.swift index e381109..6e71f5a 100644 --- a/Core/Sources/Core/VolumeDetection.swift +++ b/Core/Sources/Core/VolumeDetection.swift @@ -79,27 +79,32 @@ public struct VolumeInfoCollector: Sendable { public init() {} public func collect(volume: Volume) -> VolumeClassification { - let (filesystem, bsdName) = daInfo(mountURL: volume.url) + let (filesystem, bsdName, isDiskImage) = daInfo(mountURL: volume.url) let transport = resolveTransport(bsdName: bsdName, filesystem: filesystem, volume: volume) let isReadOnly = readOnly(mountURL: volume.url) let cameraFolders = probeCameraFolders(mountURL: volume.url) let hasTM = probeTimeMachine(mountURL: volume.url) return VolumeClassifier.classify( volume: volume, filesystem: filesystem, transport: transport, - isReadOnly: isReadOnly, cameraFolders: cameraFolders, hasTimeMachine: hasTM + isReadOnly: isReadOnly, cameraFolders: cameraFolders, hasTimeMachine: hasTM, + isDiskImage: isDiskImage ) } // MARK: DiskArbitration - private func daInfo(mountURL: URL) -> (filesystem: String?, bsdName: String?) { + private func daInfo(mountURL: URL) -> (filesystem: String?, bsdName: String?, isDiskImage: Bool) { guard let session = DASessionCreate(kCFAllocatorDefault), let disk = DADiskCreateFromVolumePath(kCFAllocatorDefault, session, mountURL as CFURL), - let desc = DADiskCopyDescription(disk) else { return (nil, nil) } + let desc = DADiskCopyDescription(disk) else { return (nil, nil, false) } let d = desc as NSDictionary let fs = d[kDADiskDescriptionVolumeKindKey] as? String let bsd = d[kDADiskDescriptionMediaBSDNameKey] as? String - return (fs, bsd) + // A mounted disk image (.dmg/.sparsebundle) reports protocol/model "Disk Image". + let proto = (d[kDADiskDescriptionDeviceProtocolKey] as? String)?.lowercased() ?? "" + let model = (d[kDADiskDescriptionDeviceModelKey] as? String)?.lowercased() ?? "" + let isDiskImage = proto.contains("disk image") || model.contains("disk image") + return (fs, bsd, isDiskImage) } // MARK: Transport @@ -164,7 +169,8 @@ public struct VolumeInfoCollector: Sendable { public struct VolumeClassifier: Sendable { public static func classify( volume: Volume, filesystem: String?, transport: TransportType, - isReadOnly: Bool, cameraFolders: [String], hasTimeMachine: Bool + isReadOnly: Bool, cameraFolders: [String], hasTimeMachine: Bool, + isDiskImage: Bool = false ) -> VolumeClassification { func result(_ kind: ExternalVolumeKind, _ confidence: Double, _ reasons: [String]) -> VolumeClassification { VolumeClassification(kind: kind, confidence: confidence, filesystem: filesystem, @@ -181,6 +187,11 @@ public struct VolumeClassifier: Sendable { return result(.networkVolume, 1.0, ["Network volume"]) } + // Mounted disk image (.dmg/.sparsebundle): virtual, not real external media. + if isDiskImage { + return result(.virtualVolume, 0.95, ["Mounted disk image"]) + } + let fs = filesystem?.lowercased() ?? "" // Camera card: folder indicators dominate diff --git a/Core/Tests/CoreTests/VolumeClassifierTests.swift b/Core/Tests/CoreTests/VolumeClassifierTests.swift new file mode 100644 index 0000000..6dee868 --- /dev/null +++ b/Core/Tests/CoreTests/VolumeClassifierTests.swift @@ -0,0 +1,51 @@ +import XCTest +@testable import Core + +final class VolumeClassifierTests: XCTestCase { + + private func volume( + internal isInternal: Bool = false, local: Bool = true, + removable: Bool = false, ejectable: Bool = false, + capacity: Int64? = nil + ) -> Volume { + Volume( + url: URL(fileURLWithPath: "/Volumes/Test"), name: "Test", + isRemovable: removable, isEjectable: ejectable, + isInternal: isInternal, isLocal: local, + totalCapacity: capacity, availableCapacity: capacity + ) + } + + private func classify(_ v: Volume, fs: String?, transport: TransportType = .unknown, + camera: [String] = [], tm: Bool = false, + diskImage: Bool = false) -> VolumeClassification { + VolumeClassifier.classify( + volume: v, filesystem: fs, transport: transport, + isReadOnly: false, cameraFolders: camera, hasTimeMachine: tm, + isDiskImage: diskImage + ) + } + + // Regression: a mounted .dmg is a local ejectable APFS volume; without the + // disk-image flag it was misclassified as an External SSD. + func testMountedDiskImageIsVirtual() { + let v = volume(ejectable: true, capacity: 100_000_000) + let c = classify(v, fs: "apfs", diskImage: true) + XCTAssertEqual(c.kind, .virtualVolume) + XCTAssertEqual(c.kind.label, "Virtual") + } + + // Same APFS ejectable volume, but NOT a disk image -> real external SSD. + func testEjectableAPFSIsExternalSSD() { + let v = volume(ejectable: true) + let c = classify(v, fs: "apfs", diskImage: false) + XCTAssertEqual(c.kind, .externalSSD) + } + + // Internal wins over everything, even if the disk-image flag is set. + func testInternalDominatesDiskImage() { + let v = volume(internal: true) + let c = classify(v, fs: "apfs", diskImage: true) + XCTAssertEqual(c.kind, .internalDisk) + } +} From bdcf469c71e023db62f439dfd2e651c7a01967ec Mon Sep 17 00:00:00 2001 From: Kurach Aleksandr Date: Tue, 23 Jun 2026 22:19:27 +0300 Subject: [PATCH 3/3] chore: release v0.9.7.1 (disk-image classification hotfix) Move the disk-image fix into its own 0.9.7.1 CHANGELOG section (0.9.7 already shipped), bump Info.plist to 0.9.7.1, and refresh the README / VISION current-version lines. Co-Authored-By: Claude Opus 4.8 (1M context) --- App/Resources/Info.plist | 4 ++-- CHANGELOG.md | 12 ++++++++---- README.md | 2 +- VISION.md | 2 +- 4 files changed, 12 insertions(+), 8 deletions(-) diff --git a/App/Resources/Info.plist b/App/Resources/Info.plist index 646d04b..0f2e8cf 100644 --- a/App/Resources/Info.plist +++ b/App/Resources/Info.plist @@ -26,9 +26,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 0.9.7 + 0.9.7.1 CFBundleVersion - 0.9.7 + 0.9.7.1 LSApplicationCategoryType public.app-category.utilities LSMinimumSystemVersion diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cea84a..195c658 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ All notable changes to yafm are recorded here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); the project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.9.7.1] — Hotfix: disk-image volume classification + +### Fixed +- **Mounted disk image misclassified as External SSD** — a mounted `.dmg`/`.sparsebundle` + is a local ejectable APFS volume, so volume detection tagged it "External SSD". It is now + recognized via DiskArbitration (`DADeviceProtocol`/`DADeviceModel` = "Disk Image") and + shown as a Virtual volume. + ## [0.9.7] — Calm pass: function bar · Settings polish · file-engine fixes ### Fixed @@ -23,10 +31,6 @@ the project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). dead `/Volumes` path forever; the cache is now validated and re-mounted on demand. - **Plugin column width** is clamped (an unbounded width could feed AppKit a runaway column frame — the same overflow class that once froze layout). -- **Mounted disk image misclassified as External SSD** — a mounted `.dmg`/`.sparsebundle` - is a local ejectable APFS volume, so volume detection tagged it "External SSD". It is now - recognized via DiskArbitration (`DADeviceProtocol`/`DADeviceModel` = "Disk Image") and - shown as a Virtual volume. ### Changed - **File-type color tiles + name tint are OFF by default** — a calm, native list out of diff --git a/README.md b/README.md index 5961b65..b22c3b5 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ **Yet Another File Manager for macOS.** A fast, keyboard-driven, modern alternative to Finder — that never freezes silently. -> Status: **v0.9.7 — 1.0 candidate.** Native Swift / SwiftUI. The spine (v0.1–0.3: +> Status: **v0.9.7.1 — 1.0 candidate.** Native Swift / SwiftUI. The spine (v0.1–0.3: > dual-pane + tabs, async listing, own file engine, tags, JS plugins, git, search) is > done; v0.4–0.9 added the keyboard-first speed layer, content search, SMB, the plugin > capability model, archives, accessibility, and a Russian UI — then a five-dimension diff --git a/VISION.md b/VISION.md index ff35ec5..6a0b318 100644 --- a/VISION.md +++ b/VISION.md @@ -83,6 +83,6 @@ The arc in brief: tiles + sidebar drag-reorder + app handling · collapsible sidebar + in-window Settings · **calm UI pass + file-engine fixes**: redesigned function bar, Settings polish, tiles off by default, cross-volume move, runaway-plugin watchdog, SMB timeout, notarization entitlements). - Currently **v0.9.7**; before 1.0: notarized + Currently **v0.9.7.1**; before 1.0: notarized DMG (paid cert) + final polish. Photo-ingest + marketplace deferred post-1.0. Full status-tracked breakdown in [`ROADMAP.md`](ROADMAP.md).