Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions .github/workflows/scan-validation.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
name: Scan validation

on:
pull_request:
workflow_dispatch:

permissions:
contents: read

jobs:
macos:
runs-on: macos-15
timeout-minutes: 25
steps:
- uses: actions/checkout@v4
- name: Select project toolchain
run: |
sudo xcode-select --switch /Applications/Xcode_26.3.app/Contents/Developer
swift --version
- name: Unit and mounted-volume regression tests
env:
CLEARDISK_TEST_MOUNTS: "1"
run: swift test
- name: Parallel measurement race checks
run: swift test --sanitize=thread --filter DirectoryMeasurementTests.testParallelMeasurementsHaveIndependentState
- name: Universal app and final signature
run: |
bash scripts/build_app.sh
codesign --verify --deep --strict ClearDisk.app
- name: Staging failure tests
run: python3 scripts/test_stage_sign_app.py
- name: Benchmark harness smoke test
run: |
fixture=$(mktemp -d)
trap 'rm -rf "$fixture"' EXIT
mkdir "$fixture/one" "$fixture/two"
dd if=/dev/zero of="$fixture/one/data" bs=4096 count=4
dd if=/dev/zero of="$fixture/two/data" bs=4096 count=8
BENCH_RUNS=2 bash scripts/benchmark_scan.sh "$fixture/one" "$fixture/two"
4 changes: 4 additions & 0 deletions Resources/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
@@ -1,2 +1,6 @@
/* ClearDisk — English (en) is the development language.
Keys are the English source strings themselves, so this table is intentionally empty. */

// Scan completeness and resource policy
"Analysis incomplete: some locations could not be measured." = "Analysis incomplete: some locations could not be measured.";
"Scan stopped to reduce power or thermal load. Start again to scan with fewer workers." = "Scan stopped to reduce power or thermal load. Start again to scan with fewer workers.";
4 changes: 4 additions & 0 deletions Resources/tr.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -590,3 +590,7 @@
"The downloaded update payload, staging copy, updater state, and updater logs." = "İndirilen güncelleme paketi, hazırlık kopyası, güncelleyici durumu ve güncelleyici günlükleri.";
"The installed app, its settings, accounts, and documents." = "Kurulu uygulama, ayarları, hesapları ve belgeleri.";
"Your account, playlists, local files, and explicitly downloaded offline library." = "Hesabınız, çalma listeleriniz, yerel dosyalarınız ve bilerek indirdiğiniz çevrimdışı kitaplığınız.";

// Scan completeness and resource policy
"Analysis incomplete: some locations could not be measured." = "Analiz tamamlanamadı: bazı konumlar ölçülemedi.";
"Scan stopped to reduce power or thermal load. Start again to scan with fewer workers." = "Güç tüketimini veya ısınmayı azaltmak için tarama durduruldu. Daha az eşzamanlı işlemle taramak için yeniden başlatın.";
4 changes: 2 additions & 2 deletions Sources/ClearDisk/AppCacheCatalog.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import Foundation

enum CacheSection: String, CaseIterable {
enum CacheSection: String, CaseIterable, Sendable {
case app
case developer

Expand All @@ -19,7 +19,7 @@ enum CacheSection: String, CaseIterable {
}
}

struct CacheSafetyDetails {
struct CacheSafetyDetails: Sendable {
let removes: String
let keeps: String
let note: String
Expand Down
86 changes: 86 additions & 0 deletions Sources/ClearDisk/DirectoryMeasurement.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import Darwin
import Foundation

struct DirectoryMeasurement: Sendable {
var bytes: Int64 = 0
var files: Int = 0
var errorCount: Int = 0
var firstError: String?
var isComplete: Bool { errorCount == 0 }

mutating func recordError(path: String, code: Int32) {
errorCount += 1
if firstError == nil {
firstError = "\(path): \(NSError(domain: NSPOSIXErrorDomain, code: Int(code)).localizedDescription)"
}
}

/// Allocated bytes attributed to directory entries, not a promise of space reclaimed.
/// Hard links share an allocation; deleting one of several links need not free any blocks.
static func measure(path: String, isCancelled: () -> Bool = { false }) -> Self {
var result = Self()
guard !isCancelled() else {
result.recordError(path: path, code: ECANCELED)
return result
}
guard let cPath = strdup(path) else {
result.recordError(path: path, code: ENOMEM)
return result
}
defer { free(cPath) }

// Missing optional cache roots are empty. Other errors must remain visible.
var rootStat = stat()
if lstat(cPath, &rootStat) != 0 {
let code = errno
if code != ENOENT { result.recordError(path: path, code: code) }
return result
}

var paths: [UnsafeMutablePointer<CChar>?] = [cPath, nil]
// Keep argv storage alive for the entire traversal; each call owns its FTS handle.
paths.withUnsafeMutableBufferPointer { buffer in
guard let tree = fts_open(buffer.baseAddress!, FTS_PHYSICAL | FTS_NOCHDIR | FTS_XDEV, nil) else {
result.recordError(path: path, code: errno)
return
}
defer {
if fts_close(tree) != 0 { result.recordError(path: path, code: errno) }
}
while true {
if isCancelled() {
result.recordError(path: path, code: ECANCELED)
break
}
errno = 0
guard let node = fts_read(tree) else {
let code = errno
if code != 0 { result.recordError(path: path, code: code) }
break
}
switch Int32(node.pointee.fts_info) {
case FTS_F:
let metadata = node.pointee.fts_statp.pointee
let (allocated, overflow) = max(0, Int64(metadata.st_blocks)).multipliedReportingOverflow(by: 512)
let (total, sumOverflow) = result.bytes.addingReportingOverflow(
allocated / max(1, Int64(metadata.st_nlink))
)
if overflow || sumOverflow {
result.recordError(path: path, code: EOVERFLOW)
return
}
result.bytes = total
result.files += 1
case FTS_DC:
result.recordError(path: String(cString: node.pointee.fts_path), code: ELOOP)
case FTS_DNR, FTS_ERR, FTS_NS:
let code = node.pointee.fts_errno
result.recordError(path: String(cString: node.pointee.fts_path), code: code == 0 ? EIO : code)
default:
break
}
}
}
return result
}
}
Loading