Skip to content
Closed
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 @@ -22,7 +22,7 @@ Every destructive action goes through explicit review and the OS trash — DiskS

## Status

🚧 Early development. See the [base design](docs/superpowers/specs/2026-07-10-disksage-design.md), [dataset metadata profile design](docs/superpowers/specs/2026-07-16-dataset-metadata-profile-design.md), [cloud OAuth security design](docs/superpowers/specs/2026-07-16-cloud-provider-oauth-pkce-design.md), and [cloud capacity evidence design](docs/superpowers/specs/2026-07-21-cloud-capacity-evidence-design.md).
🚧 Early development. See the [base design](docs/superpowers/specs/2026-07-10-disksage-design.md), [dataset metadata profile design](docs/superpowers/specs/2026-07-16-dataset-metadata-profile-design.md), [cloud OAuth security design](docs/superpowers/specs/2026-07-16-cloud-provider-oauth-pkce-design.md), [cloud capacity evidence design](docs/superpowers/specs/2026-07-21-cloud-capacity-evidence-design.md), and [redacted Naruon capacity export design](docs/superpowers/specs/2026-07-29-naruon-cloud-capacity-export-design.md).

## Tech

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# Naruon cloud-capacity assessment export

## Context

DiskSage capacity schema version 3 binds read-only provider capacity to a
destination provider and, where the provider exposes it, an authoritative
account scope. The ordinary decision summary is redacted but contains other
planning aggregates that Naruon does not need. The per-file
`disksage.file-lineage` envelope is also the wrong boundary: destination
account capacity is plan evidence, not source-file provenance.

## Decision

DiskSage exports a separate `disksage.cloud-capacity-assessment` version 1
envelope. It contains:

- the decision-batch fingerprint version and fingerprint for the exact plan;
- the destination provider and account scope;
- the version 3 capacity snapshot, including its provider-bound evidence
fingerprint but not the provider account, drive, permission, or cloud-root
identifier; and
- the requested, largest-candidate, reserve, required, fit, blocker, and notice
claims.

Before serialization, Rust:

- validates provider-specific snapshot shapes;
- rejects provider or authoritative account-scope switching;
- binds requested bytes to the plan's potentially reclaimable bytes and the
largest candidate to the plan's largest unblocked candidate;
- recomputes the capacity assessment with checked unsigned 64-bit arithmetic;
and
- rejects a missing or altered assessment.

No absolute source path, destination path, cloud-root path, root label,
provider account identifier, OneDrive drive ID, or Google permission ID appears
in the envelope.

## CLI

The read-only output mode requires a fresh single-destination capacity
observation:

```text
disksage-cloud-plan --root /absolute/source \
--provider icloud \
--verify-capacity \
--export-naruon-capacity
```

OneDrive and Google Drive additionally use the existing absolute
`--oauth-connections` document. The command prints JSON to standard output. It
does not write a provider object, copy a candidate, persist a receipt, hydrate
or evict a File Provider item, move a source, or delete anything.

## Trust boundary

The decision-batch fingerprint intentionally omits volatile capacity because a
copy always requires a fresh capacity check. Naruon receives neither the
redacted plan inputs needed to recompute that batch fingerprint nor the
provider binding identifier needed to recompute the evidence fingerprint. Its
acceptance therefore means schema and claim consistency only, not independent
provider authentication or freshness.

A positive fit assessment is not copy approval, provider-write proof,
provider-sync proof, physical-reclaimability proof, or local-source eviction
authorization.

## Integration choice

The exporter and arithmetic stay in Rust. The contract is deterministic and
database-free, so it does not require Noema, a local or external LLM,
fast-mlsirm, semantic-data-portal, pg-erd-cloud, or Figma.
53 changes: 51 additions & 2 deletions src-tauri/src/bin/disksage-cloud-plan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ use disksage_lib::cloud_review::{self, CloudReviewDecision, CloudReviewDispositi
#[cfg(not(coverage))]
use disksage_lib::cloud_transfer::{self, CloudCopyReceipt, LocalEvictionPermit};
#[cfg(not(coverage))]
use disksage_lib::naruon_capacity;
#[cfg(not(coverage))]
use disksage_lib::naruon_lineage;
#[cfg(not(coverage))]
use disksage_lib::provider_api_client::{self, FixedHostProviderMetadataClient};
Expand Down Expand Up @@ -78,6 +80,7 @@ struct Args {
review_dir: Option<PathBuf>,
export_naruon_lineage: Option<PathBuf>,
naruon_sync_evidence: Option<PathBuf>,
export_naruon_capacity: bool,
}

#[cfg(not(coverage))]
Expand Down Expand Up @@ -195,6 +198,7 @@ fn parse_args(args: &[String], home: &Path) -> Result<Args, String> {
review_dir: None,
export_naruon_lineage: None,
naruon_sync_evidence: None,
export_naruon_capacity: false,
};
let mut index = 0;
while index < args.len() {
Expand Down Expand Up @@ -386,9 +390,10 @@ fn parse_args(args: &[String], home: &Path) -> Result<Args, String> {
"--naruon-sync-evidence",
)?))
}
"--export-naruon-capacity" => parsed.export_naruon_capacity = true,
"--help" | "-h" => {
return Err(
"usage: disksage-cloud-plan [--list-roots | --inspect-roots] [--root PATH] [--cloud-root PATH | --provider icloud|onedrive|google-drive | --all-readable-roots --decision-summary] [--min-size-mib N] [--min-age-days N] [--limit N] [--decision-summary [--review-reason-set REASON|REASON [--private-review-output ABSOLUTE_NEW_FILE.json]] | --exact-duplicate-review-prefix DIR_PREFIX --exact-duplicate-kind document|media|archive|dataset|backup|creative|incomplete-download] [--verify-capacity [--oauth-connections ABSOLUTE_PATH]] [--capacity-reserve-mib N] [--copy-fingerprint HEX64 --receipt-dir PATH [--review-dir PATH] [--oauth-connections ABSOLUTE_PATH] | --adopt-existing-fingerprint HEX64 --receipt-dir PATH [--review-dir PATH] | --attest-receipt RECEIPT.json --evidence-dir ABSOLUTE_PATH [--oauth-connections ABSOLUTE_PATH [--provider-object-id GOOGLE_FILE_ID]] | --evict-receipt RECEIPT.json --confirm-receipt-id HEX64 --eviction-dir ABSOLUTE_PATH --eviction-approval-dir ABSOLUTE_PATH --journal-path ABSOLUTE_PATH --evidence-dir ABSOLUTE_PATH --reviewed-by human:ID --review-rationale TEXT [--oauth-connections ABSOLUTE_PATH [--provider-object-id GOOGLE_FILE_ID]] | --review-candidate-fingerprint HEX64 --review-fingerprint HEX64 --review-disposition approved|held --reviewed-by human:ID --review-rationale TEXT --review-dir PATH | --export-naruon-lineage RECEIPT.json [--naruon-sync-evidence EVIDENCE.json]]".into(),
"usage: disksage-cloud-plan [--list-roots | --inspect-roots] [--root PATH] [--cloud-root PATH | --provider icloud|onedrive|google-drive | --all-readable-roots --decision-summary] [--min-size-mib N] [--min-age-days N] [--limit N] [--decision-summary [--review-reason-set REASON|REASON [--private-review-output ABSOLUTE_NEW_FILE.json]] | --exact-duplicate-review-prefix DIR_PREFIX --exact-duplicate-kind document|media|archive|dataset|backup|creative|incomplete-download] [--verify-capacity [--oauth-connections ABSOLUTE_PATH] [--export-naruon-capacity]] [--capacity-reserve-mib N] [--copy-fingerprint HEX64 --receipt-dir PATH [--review-dir PATH] [--oauth-connections ABSOLUTE_PATH] | --adopt-existing-fingerprint HEX64 --receipt-dir PATH [--review-dir PATH] | --attest-receipt RECEIPT.json --evidence-dir ABSOLUTE_PATH [--oauth-connections ABSOLUTE_PATH [--provider-object-id GOOGLE_FILE_ID]] | --evict-receipt RECEIPT.json --confirm-receipt-id HEX64 --eviction-dir ABSOLUTE_PATH --eviction-approval-dir ABSOLUTE_PATH --journal-path ABSOLUTE_PATH --evidence-dir ABSOLUTE_PATH --reviewed-by human:ID --review-rationale TEXT [--oauth-connections ABSOLUTE_PATH [--provider-object-id GOOGLE_FILE_ID]] | --review-candidate-fingerprint HEX64 --review-fingerprint HEX64 --review-disposition approved|held --reviewed-by human:ID --review-rationale TEXT --review-dir PATH | --export-naruon-lineage RECEIPT.json [--naruon-sync-evidence EVIDENCE.json]]".into(),
)
}
flag => return Err(format!("알 수 없는 인자: {flag}")),
Expand Down Expand Up @@ -563,14 +568,18 @@ fn validate_action_args(args: &Args) -> Result<(), String> {
if args.naruon_sync_evidence.is_some() && args.export_naruon_lineage.is_none() {
return Err("--naruon-sync-evidence에는 --export-naruon-lineage가 필요함".into());
}
if args.export_naruon_capacity && !args.verify_capacity {
return Err("--export-naruon-capacity에는 --verify-capacity가 필요함".into());
}
let actions = usize::from(args.list_roots)
+ usize::from(args.inspect_roots)
+ usize::from(copy_action)
+ usize::from(adoption_action)
+ usize::from(args.attest_receipt.is_some())
+ usize::from(eviction_action)
+ usize::from(review_action)
+ usize::from(args.export_naruon_lineage.is_some());
+ usize::from(args.export_naruon_lineage.is_some())
+ usize::from(args.export_naruon_capacity);
if args.all_readable_roots && actions > 0 {
return Err(
"--all-readable-roots는 mutation 또는 root inspection과 함께 사용할 수 없음".into(),
Expand Down Expand Up @@ -2005,6 +2014,14 @@ fn run() -> Result<(), String> {
args.oauth_connections.as_deref(),
args.capacity_reserve_mib,
)?;
if args.export_naruon_capacity {
let envelope = naruon_capacity::export_naruon_cloud_capacity_assessment(&report)?;
println!(
"{}",
serde_json::to_string_pretty(&envelope).map_err(|error| error.to_string())?
);
return Ok(());
}
if let (Some(redundant_prefix), Some(kind)) = (
args.exact_duplicate_review_prefix.as_deref(),
args.exact_duplicate_kind,
Expand Down Expand Up @@ -2232,6 +2249,7 @@ mod tests {
assert!(defaults.reviewed_by.is_none());
assert!(defaults.review_rationale.is_none());
assert!(defaults.export_naruon_lineage.is_none());
assert!(!defaults.export_naruon_capacity);
assert!(defaults.naruon_sync_evidence.is_none());
assert!(!defaults.verify_capacity);
assert!(!defaults.decision_summary);
Expand Down Expand Up @@ -3366,6 +3384,37 @@ mod tests {
assert!(validate_action_args(&conflicting).is_err());
}

#[test]
fn naruon_capacity_export_requires_fresh_single_destination_capacity() {
let export = parse_args(
&[
"--verify-capacity".into(),
"--export-naruon-capacity".into(),
"--provider".into(),
"icloud".into(),
],
Path::new("/h"),
)
.unwrap();
assert!(export.export_naruon_capacity);
assert!(validate_action_args(&export).is_ok());

let missing_capacity =
parse_args(&["--export-naruon-capacity".into()], Path::new("/h")).unwrap();
assert!(validate_action_args(&missing_capacity).is_err());

let multiple = parse_args(
&[
"--verify-capacity".into(),
"--export-naruon-capacity".into(),
"--all-readable-roots".into(),
],
Path::new("/h"),
)
.unwrap();
assert!(validate_action_args(&multiple).is_err());
}

#[test]
fn action_validation_requires_explicit_complete_eviction_arguments() {
let mut args = parse_args(&[], Path::new("/h")).unwrap();
Expand Down
67 changes: 58 additions & 9 deletions src-tauri/src/cloud_local_eviction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

use crate::cloud::{CloudAccountScope, CloudProvider, CloudRoot};
use serde::Serialize;
use std::collections::{BTreeMap, BTreeSet};
use std::fs::Metadata;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
Expand Down Expand Up @@ -439,18 +440,52 @@ fn process_command_matches_target(command: &str, path: &Path) -> bool {
#[cfg(all(unix, not(coverage)))]
fn parse_process_command_references(output: &[u8], path: &Path, own_pid: u32) -> Vec<u32> {
let text = String::from_utf8_lossy(output);
let mut pids = Vec::new();
let mut records = Vec::new();
for line in text.lines() {
let line = line.trim_start();
let split_at = line.find(char::is_whitespace).unwrap_or(line.len());
let (pid_text, command) = line.split_at(split_at);
let pid_end = line.find(char::is_whitespace).unwrap_or(line.len());
let (pid_text, remainder) = line.split_at(pid_end);
let Ok(pid) = pid_text.parse::<u32>() else {
continue;
};
if pid != own_pid && process_command_matches_target(command.trim_start(), path) {
pids.push(pid);
let remainder = remainder.trim_start();
let parent_pid_end = remainder
.find(char::is_whitespace)
.unwrap_or(remainder.len());
let (parent_pid_text, command) = remainder.split_at(parent_pid_end);
let Ok(parent_pid) = parent_pid_text.parse::<u32>() else {
continue;
};
records.push((pid, parent_pid, command.trim_start()));
}

// A watchdog or timeout wrapper commonly includes the full child command (and therefore the
// target path) in its own argv. It supervises the planner but does not itself use the file.
// Exclude the planner and its complete ancestor chain while retaining unrelated processes that
// independently reference the same target.
let parent_by_pid: BTreeMap<u32, u32> = records
.iter()
.map(|(pid, parent_pid, _)| (*pid, *parent_pid))
.collect();
let mut planner_lineage = BTreeSet::new();
let mut lineage_pid = own_pid;
while planner_lineage.insert(lineage_pid) {
let Some(parent_pid) = parent_by_pid.get(&lineage_pid).copied() else {
break;
};
if parent_pid == 0 || parent_pid == lineage_pid {
break;
}
lineage_pid = parent_pid;
}

let mut pids: Vec<u32> = records
.into_iter()
.filter_map(|(pid, _, command)| {
(!planner_lineage.contains(&pid) && process_command_matches_target(command, path))
.then_some(pid)
})
.collect();
pids.sort_unstable();
pids.dedup();
pids
Expand All @@ -459,7 +494,7 @@ fn parse_process_command_references(output: &[u8], path: &Path, own_pid: u32) ->
#[cfg(all(unix, not(coverage)))]
fn observe_process_command_use(path: &Path) -> ActiveUseEvidence {
let mut child = match Command::new("ps")
.args(["-axo", "pid=,command="])
.args(["-axo", "pid=,ppid=,command="])
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null())
Expand Down Expand Up @@ -1100,15 +1135,29 @@ mod tests {
#[test]
fn process_command_reference_parser_detects_relative_path_and_excludes_self() {
let path = Path::new("/Cloud/SONY ICD-TX650/FOLDER01/231031_2308.wav");
let output = b" 101 python audio_library.py /Cloud/SONY ICD-TX650/FOLDER01/other.wav\n\
202 python audio_library.py --path FOLDER01/231031_2308.wav --keep-local\n\
303 checker --path /Cloud/SONY ICD-TX650/FOLDER01/231031_2308.wav\n";
let output = b" 101 1 python audio_library.py /Cloud/SONY ICD-TX650/FOLDER01/other.wav\n\
202 1 python audio_library.py --path FOLDER01/231031_2308.wav --keep-local\n\
303 1 checker --path /Cloud/SONY ICD-TX650/FOLDER01/231031_2308.wav\n";
assert_eq!(
parse_process_command_references(output, path, 303),
vec![202]
);
}

#[cfg(all(unix, not(coverage)))]
#[test]
fn process_command_reference_parser_excludes_path_bearing_supervisor_lineage() {
let path = Path::new("/Cloud/large-upload.zip");
let output =
b" 500 1 gtimeout 8 disksage-icloud-local-eviction --path /Cloud/large-upload.zip\n\
501 500 disksage-icloud-local-eviction --path /Cloud/large-upload.zip\n\
502 1 preview-worker --input /Cloud/large-upload.zip\n";
assert_eq!(
parse_process_command_references(output, path, 501),
vec![502]
);
}

#[cfg(all(unix, not(coverage)))]
#[test]
fn short_basename_does_not_create_broad_process_match() {
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ pub mod cloud_review;
pub mod cloud_transfer;
pub mod content_digest;
pub mod maven_cache;
pub mod naruon_capacity;
pub mod naruon_lineage;
pub mod provider_api_client;
pub mod provider_capacity;
Expand Down
Loading
Loading