Skip to content
Merged
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@

## Safety first

Every destructive action goes through explicit review and the OS trash — DiskSage has **no permanent-delete code path**. Cloud archiving currently exposes copy and evidence only: even a successful provider attestation returns a local-eviction permit without deleting the source. All destructive operations are journaled and undoable.
Every destructive action goes through explicit review and the OS trash — DiskSage has **no permanent-delete code path**. Developer-artifact selections carry a bounded, metadata-only fingerprint, byte/file counts, scan status, and a platform filesystem-object identity; the Rust command re-scans immediately before trashing, atomically stages the exact identity in a private sibling directory, and rejects changed, recreated, unreadable, or incomplete candidates. Cloud archiving currently exposes copy and evidence only: even a successful provider attestation returns a local-eviction permit without deleting the source. All destructive operations are journaled and sent to OS trash; identity-staged operations retain their private recovery directory so OS-trash undo has a valid staged target, while restoring to the original path remains a separate recovery step.

The headless split-archive audit is read-only. A contiguous sequence does not invent proof that its
last observed member is the terminal part, and a missing-part result is never automatic deletion
Expand Down
18 changes: 18 additions & 0 deletions docs/superpowers/specs/2026-07-21-apfs-reclaim-evidence-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@ reports:
- `physically_reclaimable_bytes: null` and `status: unverified` before the operation;
- stable reason codes explaining shared-extent uncertainty and Trash retention.

When an operator needs to distinguish an idle cache from a build or editor tree that is currently
in use, the command accepts `--check-active-use`. This opt-in adds bounded, path-local `lsof`
evidence per normalized root (`evidence_complete`, `active`, and a capped PID list). Regular-file
roots use an exact-file `lsof` query (`lsof-file-pid`), while directory roots use recursive
`lsof` (`lsof-recursive-pid`). The probe is diagnostic only and never treats an idle result as
permission to delete. The default output omits this optional field for compatibility and to avoid
the extra process/file scan.

Nested selected roots are deduplicated and symbolic-link roots are rejected. The command never
moves, unlinks, or writes to supplied paths. APFS clone sharing is intentionally not inferred from
content equality or per-inode allocated blocks because those are not proof of unique extents or
Expand All @@ -26,3 +34,13 @@ reparse points are included in `skipped` rather than silently disappearing from
The GUI must label selection totals as logical size. Moving an item to Trash preserves its blocks;
actual physical recovery can only be claimed from a post-lifecycle filesystem free-space
observation after Trash is emptied or from an equally strong filesystem-native unique-extent proof.

For example, a read-only cache review can be run with:

```sh
cargo run --locked --manifest-path src-tauri/Cargo.toml \
--bin disksage-reclaim-plan -- \
--operation trash --check-active-use \
"$HOME/Library/Caches/codec-carver" \
"$HOME/Library/Caches/trivy"
```
1 change: 1 addition & 0 deletions src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,9 @@ infer = "0.22.0"
png = "0.17.16"
mail-parser = "0.11.5"

[target.'cfg(windows)'.dependencies]
winapi-util = "0.1.11"

[target.'cfg(target_os = "macos")'.dependencies]
embed_plist = "1.2.2"
objc2 = "0.6.4"
Expand Down
25 changes: 17 additions & 8 deletions src-tauri/src/bin/disksage-reclaim-plan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@
//! The parser preserves operating-system paths without forcing Unicode conversion. The command
//! produces local evidence only and never moves, deletes, or otherwise mutates supplied paths.

use disksage_lib::reclaim::{plan_reclaim, PlannedOperation};
use disksage_lib::reclaim::{plan_reclaim_with_options, PlannedOperation, ReclaimPlanOptions};
use std::ffi::OsString;
use std::path::PathBuf;

const USAGE: &str = "Usage: disksage-reclaim-plan [--operation trash|delete] [--pretty] PATH...\n\
const USAGE: &str = "Usage: disksage-reclaim-plan [--operation trash|delete] [--pretty] [--check-active-use] PATH...\n\
Builds read-only logical/allocation evidence. It never moves or deletes files.";

/// Parsed arguments for one reclaim-plan execution.
Expand All @@ -17,6 +17,8 @@ struct Args {
operation: PlannedOperation,
/// Whether the JSON result should use human-readable indentation.
pretty: bool,
/// Whether to include bounded process/file-use evidence for each root.
check_active_use: bool,
/// Filesystem roots to inspect without mutation.
paths: Vec<PathBuf>,
}
Expand All @@ -34,6 +36,7 @@ enum ParseResult {
fn parse_args(raw_args: impl IntoIterator<Item = OsString>) -> Result<ParseResult, String> {
let mut operation = PlannedOperation::Trash;
let mut pretty = false;
let mut check_active_use = false;
let mut paths = Vec::new();
let mut args = raw_args.into_iter();

Expand All @@ -49,6 +52,7 @@ fn parse_args(raw_args: impl IntoIterator<Item = OsString>) -> Result<ParseResul
operation = value.parse()?;
}
Some("--pretty") => pretty = true,
Some("--check-active-use") => check_active_use = true,
Some("-h" | "--help") => return Ok(ParseResult::Help),
Some("--") => {
paths.extend(args.map(PathBuf::from));
Expand All @@ -64,6 +68,7 @@ fn parse_args(raw_args: impl IntoIterator<Item = OsString>) -> Result<ParseResul
Ok(ParseResult::Run(Args {
operation,
pretty,
check_active_use,
paths,
}))
}
Expand All @@ -77,7 +82,13 @@ fn run_with_args(raw_args: impl IntoIterator<Item = OsString>) -> Result<(), Str
}
ParseResult::Run(args) => args,
};
let plan = plan_reclaim(&args.paths, args.operation)?;
let plan = plan_reclaim_with_options(
&args.paths,
args.operation,
ReclaimPlanOptions {
include_active_use: args.check_active_use,
},
)?;
let json = if args.pretty {
serde_json::to_string_pretty(&plan)
} else {
Expand Down Expand Up @@ -119,24 +130,22 @@ mod tests {
OsString::from("--operation"),
OsString::from("delete"),
OsString::from("--pretty"),
OsString::from("--check-active-use"),
OsString::from("/tmp/example"),
])
.unwrap(),
);

assert_eq!(parsed.operation, PlannedOperation::Delete);
assert!(parsed.pretty);
assert!(parsed.check_active_use);
assert_eq!(parsed.paths, [PathBuf::from("/tmp/example")]);
}

#[test]
fn double_dash_preserves_option_like_paths() {
let parsed = expect_run(
parse_args([
OsString::from("--"),
OsString::from("--not-an-option"),
])
.unwrap(),
parse_args([OsString::from("--"), OsString::from("--not-an-option")]).unwrap(),
);

assert_eq!(parsed.paths, [PathBuf::from("--not-an-option")]);
Expand Down
144 changes: 134 additions & 10 deletions src-tauri/src/cloud.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ const MAX_INCOMPLETE_DOWNLOAD_EOCD_OFFSETS: usize = 64;
const MAX_EMAIL_HEADER_BYTES: usize = 1024 * 1024;
#[cfg(not(coverage))]
const MAX_AUDACITY_SCHEMA_PROBE_BYTES: usize = 64 * 1024;
#[cfg(all(not(coverage), target_os = "macos"))]
const DIRECTORY_READ_TIMEOUT: Duration = Duration::from_secs(3);
#[cfg(all(not(coverage), target_os = "macos"))]
const DIRECTORY_READ_OUTPUT_LIMIT: u64 = 256 * 1024;

#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
Expand Down Expand Up @@ -318,6 +322,16 @@ pub fn validate_cloud_root_readable(root: &CloudRoot) -> Result<(), String> {
root.access_issue.as_deref().unwrap_or("not-verified")
));
}

#[cfg(all(not(coverage), target_os = "macos"))]
{
if let Some(reason) = directory_access_issue(Path::new(&root.path)) {
return Err(format!("cloud-root-unreadable:{}:{reason}", root.path));
}
return Ok(());
}

#[cfg(any(coverage, not(target_os = "macos")))]
std::fs::read_dir(&root.path)
.map(|_| ())
.map_err(|error| format!("cloud-root-unreadable:{}:{error}", root.path))
Expand Down Expand Up @@ -358,24 +372,134 @@ fn access_issue_for_error(error: &std::io::Error) -> String {

#[cfg(not(coverage))]
fn directory_access_issue(path: &Path) -> Option<String> {
#[cfg(all(not(coverage), target_os = "macos"))]
{
return run_bounded_find(
path,
&["-mindepth", "1", "-maxdepth", "1", "-print0", "-quit"],
)
.err();
}

#[cfg(any(coverage, not(target_os = "macos")))]
std::fs::read_dir(path)
.err()
.map(|error| access_issue_for_error(&error))
}

#[cfg(all(not(coverage), target_os = "macos"))]
fn run_bounded_find(path: &Path, action: &[&str]) -> Result<Vec<u8>, String> {
let metadata = std::fs::metadata(path).map_err(|error| access_issue_for_error(&error))?;
if !metadata.is_dir() {
return Err("not-a-directory".into());
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;

let mode = metadata.permissions().mode();
if mode & 0o444 == 0 || mode & 0o111 == 0 {
return Err("permission-denied".into());
}
}

let find = Path::new("/usr/bin/find");
let find_metadata =
std::fs::symlink_metadata(find).map_err(|_| "read-dir-helper-unavailable".to_string())?;
if !find_metadata.file_type().is_file() {
return Err("read-dir-helper-unavailable".into());
}

let mut child = Command::new(find)
.arg(path)
.args(action)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.map_err(|_| "read-dir-helper-failed".to_string())?;
let stdout = child
.stdout
.take()
.ok_or_else(|| "read-dir-helper-failed".to_string())?;
let reader = std::thread::spawn(move || {
let mut output = Vec::new();
stdout
.take(DIRECTORY_READ_OUTPUT_LIMIT + 1)
.read_to_end(&mut output)
.map(|_| output)
});

let deadline = Instant::now() + DIRECTORY_READ_TIMEOUT;
let status = loop {
match child.try_wait() {
Ok(Some(status)) => break status,
Ok(None) if Instant::now() < deadline => {
std::thread::sleep(Duration::from_millis(10));
}
Ok(None) => {
let _ = child.kill();
let _ = child.wait();
let _ = reader.join();
return Err("read-dir-timeout".into());
}
Err(_) => {
let _ = child.kill();
let _ = child.wait();
let _ = reader.join();
return Err("read-dir-helper-failed".into());
}
}
};
let output = reader
.join()
.map_err(|_| "read-dir-helper-failed".to_string())?
.map_err(|_| "read-dir-helper-failed".to_string())?;
if output.len() as u64 > DIRECTORY_READ_OUTPUT_LIMIT {
return Err("read-dir-output-too-large".into());
}
if !status.success() {
return Err("read-dir-failed".into());
}
Ok(output)
}

#[cfg(not(coverage))]
fn read_children_sorted(path: &Path, limit: usize) -> Result<Vec<PathBuf>, String> {
let entries = std::fs::read_dir(path).map_err(|error| access_issue_for_error(&error))?;
let mut children = Vec::new();
for entry in entries.take(limit) {
children.push(
entry
.map_err(|error| access_issue_for_error(&error))?
.path(),
);
#[cfg(target_os = "macos")]
{
use std::ffi::OsString;
use std::os::unix::ffi::OsStringExt;

let output = run_bounded_find(path, &["-mindepth", "1", "-maxdepth", "1", "-print0"])?;
let mut children = Vec::new();
for raw in output
.split(|byte| *byte == 0)
.filter(|raw| !raw.is_empty())
{
if children.len() >= limit {
break;
}
children.push(PathBuf::from(OsString::from_vec(raw.to_vec())));
}
children.sort();
return Ok(children);
}

#[cfg(not(target_os = "macos"))]
{
let entries = std::fs::read_dir(path).map_err(|error| access_issue_for_error(&error))?;
let mut children = Vec::new();
for entry in entries.take(limit) {
children.push(
entry
.map_err(|error| access_issue_for_error(&error))?
.path(),
);
}
children.sort();
Ok(children)
}
children.sort();
Ok(children)
}

#[cfg(not(coverage))]
Expand Down
Loading
Loading